Docker Containers with CDK Pipelines
Deploy a Node.js and Redis Container onto ECS with CDK Pipelines
Updated: 03 September 2023
Prior to doing any of the below you will require your
~/.aws/credentialsfile to be configured with the credentials for your AWS account
A good reference for this is also the AWS Workshop Docs and the AWS Advanced Workshop Docs
Create CDK App
First, create a directory for your app and then cd into it:
1mkdir cdk-pipeline-docker2cd cdk-pipeline-dockerWe’re going to be using the CDK CLI to setup our application, to do this we can use npx:
1npx cdk init --language=typescriptThen, remove the package.lock.json so we can then swap the app over to use yarn with:
1rm package.lock.json2yarnNote that we’ll be adding npm packages as we need them instead of all at once
Now, do git init and push the application up to GitHub as the pipeline will source the code from there
Add our Application Files
Before we jump right into the CDK and pipeline setup, we need an application to containerize. We’re going to create a simple Node.js app which uses express and redis
Create the app directory in the root of our CDK app, init, and add the required dependencies
1mkdir app2cd app3yarn init -y4yarn add express redisSince the TypeScript CDK app is setup to ignore .js files by default, we want to create a .gitignore file in our app directory with the following:
app/.gitignore
1!*.js2node_modulesThen, add an index.js file with the following:
app/index.js
1const express = require('express')2const redis = require('redis')3
4const port = process.env.PORT || 80805const redisUrl = process.env.REDIS_URL || 'redis://redis:6379'6
7const app = express()8
9app.use(express.text())10
11const client = redis.createClient({12 url: redisUrl,13})14
15client.on('error', function (error) {16 console.error(error)17})18
19app.get('/', (req, res) => {20 console.log('request at URL')21 res.send('hello from port ' + port)22})23
24app.get('/:key', (req, res) => {25 const key = req.params.key26 client.get(key, (error, reply) => {27 if (error) res.send('Error')28 else res.send(reply)29 })30})31
32app.post('/:key', (req, res) => {33 const key = req.params.key34 const data = req.body35 client.set(key, data, (error, reply) => {36 if (error) res.send('Error')37 else res.send(reply)38 })39})40
41app.listen(port, () => {42 console.log('app is listening on port ' + port)43})The above consists a simple app which will use service discovery to connect to Redis and create/retreive values based on their
key
Next, add a Dockerfile for this application:
app/Dockerfile
1FROM node:142
3COPY package.json .4COPY yarn.lock .5RUN yarn --frozen-lockfile6
7COPY . .8
9EXPOSE 808010CMD ["yarn", "start"]And that should be everything we need to do at this point in terms of the application itself - after all, using Redis with Node.js not the focus of this doc
Setup
A CDK Pipeline consists of a few different stage, namely:
1graph TD2 Source --> Build --> UpdatePipeline --> PublishAssets3 PublishAssets --> Stage1 --> Stage2 --> etcPipeline Stack
To define a pipeline we use the @aws-cdk/core package as the base, create a lib/pipeline-stack.ts file in which we’ll define a Stack which represents our deployment pipeline:
lib/pipeline-stack.ts
1import * as cdk from '@aws-cdk/core'2
3export class PipelineStack extends cdk.Stack {4 constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {5 super(scope, id, props)6
7 // Pipeline code goes here8 }9}Then, instantiate this stack update the bin/pipeline.ts to have the following:
bin/pipeline.ts
1#!/usr/bin/env node2import * as cdk from '@aws-cdk/core'3import { PipelineStack } from '../lib/pipeline-stack'4
5const app = new cdk.App()6new PipelineStack(app, 'CdkPipelineDocker')Then reference this from your cdk.json file in the root directory:
1"app": "npx ts-node --prefer-ts-exts bin/pipeline.ts"And also add the following to the context section of your cdk.json file:
cdk.json
1"@aws-cdk/core:newStyleStackSynthesis": truePipeline Account Permissions
First, install the cdk CLI at a project level with:
1yarn add aws-cdkThe reason for this is to ensure we use a version of the cdk that was installed for our specific application and we aren’t accidentally using something that maybe exists somewhere else on our computer
And then add the following to the scripts section of your package.json file:
package.json
1"scripts" {2 // ... other scripts3 "cdk": "cdk"4}Before we can use the pipelines we need to grant CDK some permissions to our account, we can do this with:
1yarn cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccessThe above will create a
CDKToolkitstack which you will be able to see in AWS’s CloudFormation Console
GitHub Repo Permissions
We need to provide AWS with credentials to our GitHub repo. To do this go to GitHub > Settings > Developer settings > Personal access tokens and create a token with access to repo and admin:repo_hook permissions
Then add the token to AWS’s Secrets Manager via the console with a plaintext value of the token you just generated above, then name the token github-token and complete the rest of the steps to store the new secret
Develop the Pipeline
Now that we’ve got most of the scaffolding in place, we need to actually deploy our pipeline to AWS so that it’s aware of the codebase and everything else it needs to hook into our git repo for the building and deployment of our project
We need to install some of the cdk libraries packages, we can do this with yarn:
1yarn add @aws-cdk/aws-codepipeline @aws-cdk/pipelines @aws-cdk/aws-codepipeline-actionsThen we can use these packages in the pipeline-stack.ts file we’re going to add the following imports:
lib/pipeline-stack.ts
1import * as cdk from '@aws-cdk/core'2import { Stack, Construct, StackProps, SecretValue } from '@aws-cdk/core'3import { Artifact } from '@aws-cdk/aws-codepipeline'4import { CdkPipeline, SimpleSynthAction } from '@aws-cdk/pipelines'5import { GitHubSourceAction } from '@aws-cdk/aws-codepipeline-actions'Next up, we’re going to be writing everything else within the PipelineStack we defined earlier:
lib/pipeline-stack.ts
1export class PipelineStack extends cdk.Stack {2 constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {3 super(scope, id, props)4
5 // Pipeline code goes here6 }7}First, we need to create sourceArtifact and cloudAssemblyArtifact instances for the pipeline:
1const sourceArtifact = new Artifact()2const cloudAssemblyArtifact = new Artifact()Then, we define the sourceAction which is how the pipeline neeeds to get our code from our repository. In this case we use the GitHubSourceAction. We use the SecretValue.secretsManager function to retreive the GitHub token we created previously:
1const sourceAction = new GitHubSourceAction({2 actionName: 'GitHubSource',3 output: sourceArtifact,4 oauthToken: SecretValue.secretsManager('github-token'),5 owner: 'username',6 repo: 'repository',7 branch: 'main',8})Ensure you’ve replaced the owner, repo and branch with the one that contains your code on GitHub
Then, we define the synthAction which is used to install dependencies and optionally run a build of our app:
1// will run yarn install --frozen-lockfile, and then the buildCommand2const synthAction = SimpleSynthAction.standardYarnSynth({3 sourceArtifact,4 cloudAssemblyArtifact,5 buildCommand: 'yarn build',6})And lastly, we combine these to create a CdkPipeline instance:
1const pipeline = new CdkPipeline(this, 'Pipeline', {2 cloudAssemblyArtifact,3 sourceAction,4 synthAction,5})So our overall lib/pipeline-stack will now look like this:
1import * as cdk from '@aws-cdk/core'2import { Artifact } from '@aws-cdk/aws-codepipeline'3import { CdkPipeline, SimpleSynthAction } from '@aws-cdk/pipelines'4import { GitHubSourceAction } from '@aws-cdk/aws-codepipeline-actions'5
6export class PipelineStack extends cdk.Stack {7 constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {8 super(scope, id, props)9 const sourceArtifact = new Artifact()10 const cloudAssemblyArtifact = new Artifact()11
12 // clone repo from GtiHub using token from secrets manager13 const sourceAction = new GitHubSourceAction({14 actionName: 'GitHubSource',15 output: sourceArtifact,16 oauthToken: cdk.SecretValue.secretsManager('github-token'),17 owner: 'username',18 repo: 'repository',19 branch: 'main',20 })21
22 // will run yarn install --frozen-lockfile, and then the buildCommand23 const synthAction = SimpleSynthAction.standardYarnSynth({24 sourceArtifact,25 cloudAssemblyArtifact,26 buildCommand: 'yarn build',27 })28
29 const pipeline = new CdkPipeline(this, 'Pipeline', {30 cloudAssemblyArtifact,31 sourceAction,32 synthAction,33 })34 }35}Next, initialize the Pipeline in AWS by using yarn cdk deploy. This should be the only manual deploy we need. From this point all other Pipeline runs will happen directly in CDK via GitHub Commits:
1yarn cdk deployAdd App to Deployment
To create deployments we need to have a class that inherits from cdk.Stage, in this Stage we specify all the requisites for an application deployment. We’re deploying the AppStack application, we will reference it from a Stage called AppStage which will just create an instance of the application:
lib/app-stage.ts
1import * as cdk from '@aws-cdk/core'2import { AppStack } from './app-stack'3
4export class AppStage extends cdk.Stage {5 constructor(scope: cdk.Construct, id: string, props?: cdk.StageProps) {6 super(scope, id, props)7
8 new AppStack(this, 'AppStack')9 }10}We can then add the above AppStage to the pipeline-stack using the pipeline.addApplicationStage function:
lib/pipeline-stack.ts
1// ... other pipeline code2
3// CdkPipeline as previously created4const pipeline = new CdkPipeline(this, 'Pipeline', {5 cloudAssemblyArtifact,6 sourceAction,7 synthAction,8})9
10// adding app stage to the deployment11const appStage = new AppStage(this, 'Dev')12
13pipeline.addApplicationStage(appStage)Once all that’s been added, the final pipeline-stack.ts file will have the following:
1import * as cdk from '@aws-cdk/core'2import { Artifact } from '@aws-cdk/aws-codepipeline'3import { CdkPipeline, SimpleSynthAction } from '@aws-cdk/pipelines'4import { GitHubSourceAction } from '@aws-cdk/aws-codepipeline-actions'5import { AppStage } from './app-stage'6
7export class PipelineStack extends cdk.Stack {8 constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {9 super(scope, id, props)10
11 const sourceArtifact = new Artifact()12 const cloudAssemblyArtifact = new Artifact()13
14 // clone repo from GtiHub using token from secrets manager15 const sourceAction = new GitHubSourceAction({16 actionName: 'GitHubSource',17 output: sourceArtifact,18 oauthToken: cdk.SecretValue.secretsManager('github-token'),19 owner: 'username',20 repo: 'repository',21 branch: 'main',22 })23
24 // will run yarn install --frozen-lockfile, and then the buildCommand25 const synthAction = SimpleSynthAction.standardYarnSynth({26 sourceArtifact,27 cloudAssemblyArtifact,28 buildCommand: 'yarn build',29 })30
31 const pipeline = new CdkPipeline(this, 'Pipeline', {32 cloudAssemblyArtifact,33 sourceAction,34 synthAction,35 })36
37 const app = new AppStage(this, 'Dev')38
39 pipeline.addApplicationStage(app)40 }41}App Stack
Since our app will use a Docker container we need to install the @aws-cdk/aws-ecs, @aws-cdk/aws-ec2 and @aws-cdk/aws-ecs-patterns packages:
1yarn add @aws-cdk/aws-ecs @aws-cdk/aws-ecs-patternsNext, from our lib/app-stack.ts file, we want to create two services:
- A Docker service which builds a locally defined Docker image
- A Docker service which runs the public
redisimage
In order to define our servivce, we need a vpc, cluster, and some image information and configuration
Importing everything required we would have the following as our AppStack:
lib/app-stack.ts
1import * as cdk from '@aws-cdk/core'2import * as ec2 from '@aws-cdk/aws-ec2'3import * as ecs from '@aws-cdk/aws-ecs'4import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns'5import * as ecrAssets from '@aws-cdk/aws-ecr-assets'6
7export class AppStack extends cdk.Stack {8 constructor(scope: cdk.Construct, id: string, props?: cdk.StageProps) {9 super(scope, id, props)10
11 // constructs go here12 }13}Our applications need a VPC and Cluster in which they will run, we can define a vpc with:
lib/app-stack.ts
1const vpc = new ec2.Vpc(this, 'AppVPC', {2 maxAzs: 2,3})And a cluster:
lib/app-stack.ts
1const cluster = new ecs.Cluster(this, 'ServiceCluster', { vpc })The cluster requires a CloudMapNamespace to enable service discovery. This will allow other containers and application within the Cluster to connect to one another using the service name with the service namespace
lib/app-stack.ts
1cluster.addDefaultCloudMapNamespace({ name: this.cloudMapNamespace })Using the cluster above, we can create a Task and Service using the NetworkLoadBalancedFargateService as defined in the aws-ecs-patterns library
Defining the appService involves the following steps:
- Defining the App as a Docker Asset
lib/app-stack.ts
1const appAsset = new ecrAssets.DockerImageAsset(this, 'app', {2 directory: './app',3 file: 'Dockerfile',4})- Defining the App Task
lib/app-stack.ts
1const appTask = new ecs.FargateTaskDefinition(this, 'app-task', {2 cpu: 512,3 memoryLimitMiB: 2048,4})- Adding a Container Definition to the Task
lib/app-stack.ts
1appTask2 .addContainer('app', {3 image: ecs.ContainerImage.fromDockerImageAsset(appAsset),4 essential: true,5 environment: { REDIS_URL: this.redisServiceUrl },6 logging: ecs.LogDrivers.awsLogs({7 streamPrefix: 'AppContainer',8 logRetention: logs.RetentionDays.ONE_DAY,9 }),10 })11 .addPortMappings({ containerPort: this.appPort, hostPort: this.appPort })- Create a Service
lib/app-stack.ts
1const appService = new ecsPatterns.NetworkLoadBalancedFargateService(2 this,3 'app-service',4 {5 cluster,6 cloudMapOptions: {7 name: 'app',8 },9 cpu: 512,10 desiredCount: 1,11 taskDefinition: appTask,12 memoryLimitMiB: 2048,13 listenerPort: 80,14 publicLoadBalancer: true,15 }16)- Enable Public connections to the serive
lib/app-stack.ts
1appService.service.connections.allowFromAnyIpv4(2 ec2.Port.tcp(this.appPort),3 'app-inbound'4)Defining the Redis service is pretty much the same as above, with the exception that we don’t need to define the Image Asset and we can just retreive it from the reigstry, and instead of allowing public connections we only allow connections from the appService we defined
lib/app-stack.ts
1const redisTask = new ecs.FargateTaskDefinition(this, 'redis-task', {2 cpu: 512,3 memoryLimitMiB: 2048,4})5
6redisTask7 .addContainer('redis', {8 image: ecs.ContainerImage.fromRegistry('redis:alpine'),9 essential: true,10 logging: ecs.LogDrivers.awsLogs({11 streamPrefix: 'RedisContainer',12 logRetention: logs.RetentionDays.ONE_DAY,13 }),14 })15 .addPortMappings({16 containerPort: this.redisPort,17 hostPort: this.redisPort,18 })19
20const redisService = new ecsPatterns.NetworkLoadBalancedFargateService(21 this,22 'redis-service',23 {24 cluster,25 cloudMapOptions: {26 name: 'redis',27 },28 cpu: 512,29 desiredCount: 1,30 taskDefinition: redisTask,31 memoryLimitMiB: 2048,32 listenerPort: this.redisPort,33 publicLoadBalancer: false,34 }35)36
37redisService.service.connections.allowFrom(38 appService.service,39 ec2.Port.tcp(this.redisPort)40)41
42return redisServiceLastly, we want to add the Load Balancer DNS name to our stack’s outputs. We can do this with the cdk.CfnOutput class:
lib/app-stack.ts
1this.appLoadBalancerDNS = new cdk.CfnOutput(this, 'AppLoadBalancerDNS', {2 value: appService.loadBalancer.loadBalancerDnsName,3})4
5this.redisLoadBalancerDNS = new cdk.CfnOutput(this, 'RedisLoadBalancerDNS', {6 value: redisService.loadBalancer.loadBalancerDnsName,7})We can break the AppService definition into a createAppService function, and the RedisService into a createRedisService function for some organization, the final lib/app-stack.ts file looks like this:
lib/app-stack.ts
1import * as cdk from '@aws-cdk/core'2import * as logs from '@aws-cdk/aws-logs'3import * as ec2 from '@aws-cdk/aws-ec2'4import * as ecs from '@aws-cdk/aws-ecs'5import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns'6import * as ecrAssets from '@aws-cdk/aws-ecr-assets'7
8export class AppStack extends cdk.Stack {9 public readonly redisLoadBalancerDNS: cdk.CfnOutput10 public readonly appLoadBalancerDNS: cdk.CfnOutput11
12 public readonly redisPort: number = 637913 public readonly appPort: number = 808014 public readonly cloudMapNamespace: string = 'service.internal'15 public readonly redisServiceUrl: string =16 'redis://redis.service.internal:6379'17
18 constructor(scope: cdk.Construct, id: string, props?: cdk.StageProps) {19 super(scope, id, props)20
21 const vpc = new ec2.Vpc(this, 'AppVPC', {22 maxAzs: 2,23 })24
25 const cluster = new ecs.Cluster(this, 'ServiceCluster', { vpc })26
27 cluster.addDefaultCloudMapNamespace({ name: this.cloudMapNamespace })28
29 const appService = this.createAppService(cluster)30
31 const redisService = this.createRedisService(cluster, appService)32
33 this.appLoadBalancerDNS = new cdk.CfnOutput(this, 'AppLoadBalancerDNS', {34 value: appService.loadBalancer.loadBalancerDnsName,35 })36
37 this.redisLoadBalancerDNS = new cdk.CfnOutput(38 this,39 'RedisLoadBalancerDNS',40 {41 value: redisService.loadBalancer.loadBalancerDnsName,42 }43 )44 }45
46 private createAppService(cluster: ecs.Cluster) {47 const appAsset = new ecrAssets.DockerImageAsset(this, 'app', {48 directory: './app',49 file: 'Dockerfile',50 })51
52 const appTask = new ecs.FargateTaskDefinition(this, 'app-task', {53 cpu: 512,54 memoryLimitMiB: 2048,55 })56
57 appTask58 .addContainer('app', {59 image: ecs.ContainerImage.fromDockerImageAsset(appAsset),60 essential: true,61 environment: { REDIS_URL: this.redisServiceUrl },62 logging: ecs.LogDrivers.awsLogs({63 streamPrefix: 'AppContainer',64 logRetention: logs.RetentionDays.ONE_DAY,65 }),66 })67 .addPortMappings({ containerPort: this.appPort, hostPort: this.appPort })68
69 const appService = new ecsPatterns.NetworkLoadBalancedFargateService(70 this,71 'app-service',72 {73 cluster,74 cloudMapOptions: {75 name: 'app',76 },77 cpu: 512,78 desiredCount: 1,79 taskDefinition: appTask,80 memoryLimitMiB: 2048,81 listenerPort: 80,82 publicLoadBalancer: true,83 }84 )85
86 appService.service.connections.allowFromAnyIpv4(87 ec2.Port.tcp(this.appPort),88 'app-inbound'89 )90
91 return appService92 }93
94 private createRedisService(95 cluster: ecs.Cluster,96 appService: ecsPatterns.NetworkLoadBalancedFargateService97 ) {98 const redisTask = new ecs.FargateTaskDefinition(this, 'redis-task', {99 cpu: 512,100 memoryLimitMiB: 2048,101 })102
103 redisTask104 .addContainer('redis', {105 image: ecs.ContainerImage.fromRegistry('redis:alpine'),106 essential: true,107 logging: ecs.LogDrivers.awsLogs({108 streamPrefix: 'RedisContainer',109 logRetention: logs.RetentionDays.ONE_DAY,110 }),111 })112 .addPortMappings({113 containerPort: this.redisPort,114 hostPort: this.redisPort,115 })116
117 const redisService = new ecsPatterns.NetworkLoadBalancedFargateService(118 this,119 'redis-service',120 {121 cluster,122 cloudMapOptions: {123 name: 'redis',124 },125 cpu: 512,126 desiredCount: 1,127 taskDefinition: redisTask,128 memoryLimitMiB: 2048,129 listenerPort: this.redisPort,130 publicLoadBalancer: false,131 }132 )133
134 redisService.service.connections.allowFrom(135 appService.service,136 ec2.Port.tcp(this.redisPort)137 )138
139 return redisService140 }141}We can kick off the pipeline by pushing to the GitHub repo we setup above which will cause all our services to be deployed. Once that’s done we can go to the Outputs panel for the Dev-AppStage and open the AppLoadBalancerDNS url, this will open our application.
Test the App
Set Data
With the server running you can create a new item with:
1POST http://[AppLoadBalancerDNS]/my-test-key2
3BODY "my test data"4
5RESPONSE "OK"Get Data
You can then get the value using the key with:
1GET http://[AppLoadBalancerDNS]/my-test-key2
3RESPONSE "my test data"And if all that works correctly then congratulations! You’ve successfully setup an application that uses multiple Docker Containers with CDK on AWS