Deploying updates to ECS Fargate services for every ECR push with AWS CodePipeline and AWS CDK

How to setup your AWS ECS Fargate service so it automatically gets re-deployed once its ECR image gets updated for continuous delivery with AWS CodePipeline in AWS CDK.

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 name
  • imageUri = 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 manifest
  • Version = the image version
  • ImagePushedAt = the date and time when the latest image was pushed to the repository
  • RegistryId = the AWS account ID associated with the registry that contains the repository
  • RepositoryName = the name of the Amazon ECR repository where the image was pushed
  • ImageURI = the URI for the image
  • ImageTags = 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.

  1. CI/CD pushes an updated Docker image to ECR
  2. ECR triggers an EcrSourceAction in CodePipeline due to the push and writes imageDetail.json to sourceOutput
  3. CodePipeline runs the custom CodeBuild action that generates a proper imagedefinitions.json file and writes it to transformedOutput
  4. CodePipeline triggers an EcsDeployAction with the generated imagedefinitions.json taken from transformedOutput
  5. 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.)