When using an ECS Fargate service, you may simply want to publish updates to your service whenever the consumed ECR repository is updated (in order to enable continuous delivery), for example due to a new image being built and pushed to ECR in a CI/CD pipeline.
In general, this can be set up using AWS CodePipeline, a fully managed continuous delivery service that lets you automate publishing pipelines for fast and reliable application updates.
However, when using AWS ECR as a source, ECR generates an artifact file called imageDetail.json
with a specific format but the ECS deploy stage requires a file called imagedefinitions.json
which is formatted and structured differently.
The imagedefinitions.json
file provides the container name and ECR image URI and must be constructed with the following properties.
name
= the container nameimageUri
= the URI for the image
The imageDetail.json
document generated by ECR is a JSON file that describes an ECR image and consists of the the following properties.
ImageSizeInBytes
= the size of the image in the repository (in bytes)ImageDigest
= the SHA-256 digest of the image manifestVersion
= the image versionImagePushedAt
= the date and time when the latest image was pushed to the repositoryRegistryId
= the AWS account ID associated with the registry that contains the repositoryRepositoryName
= the name of the Amazon ECR repository where the image was pushedImageURI
= the URI for the imageImageTags
= the tag used for the image
As you can see, both files use different and incompatible properties (i.e. imageUri
vs. ImageURI
are using differing case sensitivity). Thus a step in-between is needed that creates a proper imagedefinitions.json
file based on aforementioned structure.
Using AWS CDK, this can be achieved by creating a CodeBuild pipeline project with custom CodePipeline artifacts.
First, the pipeline is prepared which passes the relevant container name and ECR repository URI (which is defined in CDK during the ECS/ECR setup) as environment variables (i.e. CONTAINER_NAME
and REPOSITORY_URI
) so a build specification (»buildspec«) can be executed based on those variables. The »buildspec« is a collection of build commands (or, in this case, a single command) and related settings that AWS CodeBuild uses to run a build.
const sourceOutput = new codepipeline.Artifact();
const transformedOutput = new codepipeline.Artifact();
const buildProject = new codebuild.PipelineProject(this, 'PipelineProject', {
buildSpec: codebuild.BuildSpec.fromObject({
version: 0.2,
phases: {
build: {
commands: [
// https://docs.aws.amazon.com/codepipeline/latest/userguide/file-reference.html#pipelines-create-image-definitions
`echo "[{\\"name\\":\\"$CONTAINER_NAME\\",\\"imageUri\\":\\"$REPOSITORY_URI\\"}]" > imagedefinitions.json`,
],
},
},
artifacts: {
files: ['imagedefinitions.json'],
},
}),
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_2_0,
},
environmentVariables: {
// Container name as it exists in the task definition
CONTAINER_NAME: {
value: containerName,
},
// ECR URI
REPOSITORY_URI: {
value: registry.repository.repositoryUri,
},
},
});
This means we’re completely ignoring imageDetail.json
coming out of ECR pushes as we can fetch and access the repository URI anyway in CDK. Using CONTAINER_NAME
and REPOSITORY_URI
, the echo
command in the build
phase creates the file called imagedefinitions.json
matching the aforementioned format.
Here, containerName
is a static value that is used for both the ECS container configuration and the CodeBuild pipeline. registry
is simply an instance of ecr.Repository
, allowing CDK to access the repository URI of the resource once it’s provisioned.
Caution! It’s quite easy to consider CDK’s TypeScript variables to contain the actual values. This is not the case. For example, aforementioned registry.repository.repositoryUri
doesn’t contain the real URL when deploying the CDK stack. Instead, it contains an internal reference/pointer that gets resolved by CloudFormation during the provisioning of the infrastructure. That’s also the reason why we’re doing the »detour« here to pass the values CONTAINER_NAME
and REPOSITORY_URI
as environment variables. CDK/CloudFormation will pass the proper values during provisioning so the actual values will be contained in the environment variables during build script runtime. (Using registry.repository.repositoryUri
directly in the echo …
command template string would interpolate it with the internal reference pointer, not the actual value.)
Later, sourceOutput
will be used as output trigger for ECR actions and as input trigger for the CodeBuild action. transformedOutput
will hold the built imagedefinitions.json
file as output in order to pass it on to the ECS deploy action so it can use the configuration in there to trigger an ECS deployment.
This way, the following flow/interactions takes place.
- CI/CD pushes an updated Docker image to ECR
- ECR triggers an
EcrSourceAction
in CodePipeline due to the push and writesimageDetail.json
tosourceOutput
- CodePipeline runs the custom CodeBuild action that generates a proper
imagedefinitions.json
file and writes it totransformedOutput
- CodePipeline triggers an
EcsDeployAction
with the generatedimagedefinitions.json
taken fromtransformedOutput
- ECS deploys the updated service based on
imagedefinitions.json
For this to work, you’ll have to grant the CodeBuild pipeline project access to pulls and pushes of the ECR repository.
repository.grantPullPush(buildProject.grantPrincipal);
Finally, we can implement the CodePipeline pipeline to reflect the flow described above.
new codepipeline.Pipeline(this, 'Pipeline', {
stages: [
{
stageName: 'Source',
actions: [
new codepipelineActions.EcrSourceAction({
actionName: 'Push',
repository,
output: sourceOutput,
}),
],
},
{
stageName: 'Build',
actions: [
new codepipelineActions.CodeBuildAction({
actionName: 'Build',
input: sourceOutput,
outputs: [transformedOutput],
project: buildProject,
}),
],
},
{
stageName: 'Deploy',
actions: [
new codepipelineActions.EcsDeployAction({
actionName: 'Deploy',
input: transformedOutput,
service: this.loadBalancedService.service,
}),
],
},
],
});
That’s it. Concluding, below you can find a full CDK stack definition.
import * as ecs from '@aws-cdk/aws-ecs';
import * as cdk from '@aws-cdk/core';
import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns';
import * as route53 from '@aws-cdk/aws-route53';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as ecr from '@aws-cdk/aws-ecr';
import * as codebuild from '@aws-cdk/aws-codebuild';
import * as codepipeline from '@aws-cdk/aws-codepipeline';
import * as codepipelineActions from '@aws-cdk/aws-codepipeline-actions';
import { Database } from './database';
import { Vpc } from './vpc';
import { Registry } from './registry';
const DB_PORT = 5432;
const DOMAIN_NAME = 'example-app.dev-environment.com';
export type MainStackProps = {
registry: Registry;
};
export class MainStack extends cdk.Stack {
readonly loadBalancedService: ecsPatterns.ApplicationLoadBalancedFargateService;
constructor(scope: cdk.Construct, id: string, { registry }: MainStackProps) {
super(scope, id);
const vpc = new Vpc(this, 'VPC');
const database = new Database(this, 'Database', {
vpc: vpc.vpc,
port: DB_PORT,
name: 'app',
});
const hostedZone = new route53.PublicHostedZone(this, 'HostedZone', {
zoneName: DOMAIN_NAME,
});
const certificate = new acm.DnsValidatedCertificate(this, 'Certificate', {
domainName: DOMAIN_NAME,
hostedZone,
});
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc: vpc.vpc,
});
const containerName = 'app';
this.loadBalancedService = new ecsPatterns.ApplicationLoadBalancedFargateService(
this,
'FargateService',
{
cluster,
domainName: DOMAIN_NAME,
domainZone: hostedZone,
certificate,
memoryLimitMiB: 1024,
taskImageOptions: {
containerName,
image: ecs.ContainerImage.fromEcrRepository(registry.repository),
environment: {
DB_HOST: database.instance.instanceEndpoint.hostname.toString(),
DB_NAME: database.name,
DB_USER: database.credentials.username.toString(),
DB_PASSWORD: database.credentials.password.toString(),
DB_PORT: DB_PORT.toString(),
},
containerPort: 3000,
},
healthCheckGracePeriod: cdk.Duration.seconds(60),
},
);
this.loadBalancedService.targetGroup.configureHealthCheck({
unhealthyThresholdCount: 10,
});
// We're using `fromRepositoryAttributes` here to circumvent the `would create cyclic reference`
// error caused by using a direct reference to the repository (see https://github.com/aws/aws-cdk/issues/5657)
const repository = ecr.Repository.fromRepositoryAttributes(
this,
'ImportedRepository',
{
repositoryArn: registry.repository.repositoryArn,
repositoryName: registry.repository.repositoryName,
},
);
const sourceOutput = new codepipeline.Artifact();
const transformedOutput = new codepipeline.Artifact();
const buildProject = new codebuild.PipelineProject(
this,
'PipelineProject',
{
buildSpec: codebuild.BuildSpec.fromObject({
version: 0.2,
phases: {
build: {
commands: [
// https://docs.aws.amazon.com/codepipeline/latest/userguide/file-reference.html#pipelines-create-image-definitions
`echo "[{\\"name\\":\\"$CONTAINER_NAME\\",\\"imageUri\\":\\"$REPOSITORY_URI\\"}]" > imagedefinitions.json`,
],
},
},
artifacts: {
files: ['imagedefinitions.json'],
},
}),
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_2_0,
},
environmentVariables: {
// Container name as it exists in the task definition
CONTAINER_NAME: {
value: containerName,
},
// ECR URI
REPOSITORY_URI: {
value: registry.repository.repositoryUri,
},
},
},
);
// Grant access to detect ECR pushes
repository.grantPullPush(buildProject.grantPrincipal);
new codepipeline.Pipeline(this, 'Pipeline', {
stages: [
// If something is pushed to the referenced ECR repository…
{
stageName: 'Source',
actions: [
new codepipelineActions.EcrSourceAction({
actionName: 'Push',
repository,
output: sourceOutput,
}),
],
},
// …then run the build pipeline above to create `imagedefinitions.json`…
{
stageName: 'Build',
actions: [
new codepipelineActions.CodeBuildAction({
actionName: 'Build',
input: sourceOutput,
outputs: [transformedOutput],
project: buildProject,
}),
],
},
// …and trigger an ECS deploy based on the previously created `imagedefinitions.json`
{
stageName: 'Deploy',
actions: [
new codepipelineActions.EcsDeployAction({
actionName: 'Deploy',
input: transformedOutput,
service: this.loadBalancedService.service,
}),
],
},
],
});
}
}
(Database
, Vpc
, and Registry
in here are custom CDK constructs wrapping rds.DatabaseInstance
, ec2.Vpc
, and ecr.Repository
respectively.)