cdk-docker-image-deploymentを使ってAWSにDockerイメージをデプロイする

以前、CDKを使ってLambda関数をデプロイする記事を書きました。

同じCDKネタで、今度はDockerイメージをECSにデプロイすることを試してみましょう。

CDK

CDKコマンドのインストール

まず、CDKコマンドをインストールします。既にやっている場合は不要です。
CDKコマンドはNode.jsの16系か18系がサポートされているようなので、今回はNode.js 18.16.0を使用します。(バージョン切り替えにasdfを使っています。nodenvなどであればその書き方で。)

asdf local nodejs 18.16.0
npm i -g aws-cdk

CDKプロジェクトのセットアップ

適当なフォルダを作り、その中にまずcdkフォルダを作ります。使用する言語としてTypeScriptを指定してCDKプロジェクトを作成し、ひとまずsynthします。

mkdir hello-ecs
cd hello-ecs
mkdir cdk
cd cdk
cdk init --language typescript
cdk synth

CDKのライブラリをインストールしておきます。

npm i --save aws-cdk-lib

CDKを使ってDockerイメージをECSにデプロイするにあたって、cdk-docker-image-deploymentというライブラリを使用します。このライブラリを使うと、ローカルのソースファイルからDockerイメージを作り、ECRにプッシュして、ECSにデプロイするという一連の流れを自動的にやってくれます。さらに、ALBを作ったロードバランサの設定なども行い(その辺のバリエーションはいろいろできそうです)、すぐに外部からアクセス出来る状態まで持って行ってくれます。

npm i --save cdk-docker-image-deployment

アプリ(Dockerイメージ)

Expressアプリの作成と動作確認

次に、この後デプロイすることになるDockerイメージのソースを作成します。cdkフォルダと並列にappフォルダを作成し、TypeScriptとExpressでアプリを開発するベースを作成します。

cd ..
mkdir app
cd app
npm init
npm i --save typescript @types/node ts-node nodemon
npx tsc --init
npm i --save express dotenv
npm i --save-dev @types/express

index.tsを作成し、下記のようにします。
CORSのあたりはガバガバですので、実際には上手く調整してください。

import 'dotenv/config'
import http from 'http'
import express from 'express'

const app: express.Express = express()
app.use(express.json())
app.use(express.urlencoded({ extended: true }))

app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Access-Control-Allow-Methods', '*')
    res.header('Access-Control-Allow-Headers', '*')
    next()
})

const server: http.Server = http.createServer(app)
server.listen(process.env.PORT, () => {
    console.log(`Start on port ${process.env['PORT']}.`)
})

app.get('/', (req: express.Request, res: express.Response) => {
    res.json({
        result: 'OK'
    })
})

使用するポートは.envで設定するようにしています。下記のような.envファイルを作成します。

PORT=3000

tsconfig.jsonに下記の設定を追加します。

  "compilerOptions": {
    "outDir": "./dist/",
  }

package.jsonは下記のようにします。mainscriptsを変更します。

  "main": "dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon --watch '**/*.ts' --exec 'ts-node' index.ts",
    "build": "tsc",
    "watch": "tsc -w",
    "start": "yarn build && node dist/index.js"
  },

この状態でアプリの動作をローカルで試せます。まず、Dockerを使わずに実行してみましょう。

npm run dev

curlでhttp://localhost:3000にアクセスすると、JSON文字列が表示されるでしょう。
動作確認できたら、Ctrl+Cで終了します。

Dockerイメージの設定とローカルでの動作確認

次に、Dockerで実行できるようにします。Dockerfileを作成し、下記のようにします。EXPOSEするポートは.envの設定内容を使用しますが、Dockerfileから直接.envファイルを参照できないので、docker-compose.ymlから渡された値を使うようにします。

FROM node:18.16.0

WORKDIR /usr/src/app

COPY package*.json .
RUN npm install

COPY . .
RUN npm run build
EXPOSE ${EXPOSE_PORT}
CMD [ "npm", "start" ]

docker-compose.ymlも作成します。

version: '3'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - PORT=${PORT}
    container_name: app
    volumes:
      - /usr/src/node_modules
      - .:/usr/src/app
    env_file: .env
    ports:
      - "${PORT}:${PORT}"

ここまで進んだら、ローカルのDockerで動作を確認してみましょう。

docker compose up -d

先ほどと同様にCurlでhttp://localhost:3000にアクセスすると、JSON文字列が表示されるでしょう。
動作確認できたら、docker compose downで終了します。

再び、CDK

ここまでに作成したアプリをCDKでAWSにデプロイできるようにします。

CDKフォルダ内のlib/cdk-stack.tsを下記のようにします。

デプロイする毎にECRのリポジトリにプッシュされますが、その際にバージョンタグを指定し、そのバージョンタグをECSへのデプロイ時に指定するようにしています。そのため、versionTagの値はデプロイ毎に書き換える必要があります。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as ecs from 'aws-cdk-lib/aws-ecs'
import * as ecr from 'aws-cdk-lib/aws-ecr'
import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns'
import * as image_deploy from 'cdk-docker-image-deployment'
import * as path from 'path'

const versionTag: string = 'v0'

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const repository = new ecr.Repository(this, 'MyRepository', {
      repositoryName: 'hello-ecs',
      imageTagMutability: ecr.TagMutability.IMMUTABLE,
    })

    new image_deploy.DockerImageDeployment(this, 'imageDeployWithTag', {
      source: image_deploy.Source.directory(path.join('..', 'app')),
      destination: image_deploy.Destination.ecr(repository, {
        tag: versionTag,
      })
    })

    const vpc = new ec2.Vpc(this, 'MyVPC', {
      maxAzs: 2, // ALBでは2以上
    })

    const cluster = new ecs.Cluster(this, 'MyCluster', {
      vpc,
    })

    const taskDefinition = new ecs.FargateTaskDefinition(this, 'MyTaskDefinition', {
      runtimePlatform: {
        operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
        cpuArchitecture: ecs.CpuArchitecture.X86_64,
      }
    })

    taskDefinition.addContainer('MyAppContainer', {
      image: ecs.ContainerImage.fromEcrRepository(repository, versionTag),
      portMappings: [
        {
          containerPort: 3000,
        }
      ]
    })

    const service = new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'MyService', {
      cluster,
      taskDefinition,
    })
  }
}

まず、synthを行います。この時点でDockerイメージのビルドが行われます。

cdk synth

次にデプロイです。
AWSのアカウント、リージョン毎に一度だけcdk bootstrapを実行する必要があります(プロジェクト毎に必要なわけではありません。)が、私の環境では冒頭に挙げた以前の記事の中でやっているので、省略します。

cdk deploy

デプロイ中にも再びDockerイメージのビルドが行われ、その結果がECRにプッシュされます。途中でIAMとセキュリティポリシの設定内容が表示され、確認が求められますので、yと入力します。

デプロイが完了すると、ALBのLoadBalancerDNSと、実際にリクエスト可能なURLが表示されるので、実際にアクセスして動作を確認することができます。

クリーンアップ

cdk destroyでクリーンアップできます。但し、cdk bootstrapで実行された内容はクリーンアップされません。(同じアカウント、リージョンでデプロイする際に利用されます。)

cdk destroy

この記事を書いた人

井上 研一

株式会社ビビンコ代表取締役、ITエンジニア/経済産業省推進資格ITコーディネータ。AI・IoTに強いITコーディネータとして活動。画像認識モデルを活用したアプリや、生成AIを業務に組み込むためのサービス「Gen2Go」の開発などを行っている。近著に「使ってわかった AWSのAI」、「ワトソンで体感する人工知能」。日本全国でセミナー・研修講師としての登壇も多数。