ECS(Fargate)のServiceをCDKで構築・デプロイしてみた

お仕事でAWS環境を構築する機会がありましたので、今回はじめてCDKを使って構築してみました。CDKで環境構築する記事はたくさんありますので全体は割愛するとして、本記事ではCDKを使ったECS(Fargate) Serviceのデプロイフローにフォーカスしてご紹介します

バージョン

$ cdk version
1.32.0 (build 9766ad6)

全体構成

全体構成としては、以下の図のようにオーソドックスなWeb3層アプリケーションの構成です。

ECS Serviceに関連する主なリソース構成を以下の図に示します。

このリソース構成のうち、緑色の線で示したリソースはインフラ管理用のCDKで管理し、オレンジ色の線で示したリソースはECS Serviceデプロイ用のCDKとしてアプリケーションのリポジトリで管理しています

デプロイフロー

デプロイフローを以下の図に示します。

  1. GitHubで特定のリポジトリにコミットがマージされたイベントをトリガーとしてCircleCIのJobが起動します。
  2. CircleCIのJobでは、まずコンテナのビルドをしてECRにイメージをプッシュします。
    • 開発環境ではイメージのタグを latest の固定とします
    • 本番環境ではイメージのタグをGitのTagから参照して設定します
  3. 次にCDKによりCloudFormationがUpdate Stackされ、ECS Serviceの更新が完了します。

イメージのビルドとプッシュ

CircleCIのJobで以下のスクリプトを実行してイメージのビルドとプッシュを行います。(aws-cliはv2を利用しています。v1とはコマンドが異なりますのでご注意ください。)

aws ecr get-login-password --region $region | docker login --username AWS --password-stdin $repository
docker image build -t $imageName .
docker image tag $imageName $repository/$imageName:$imageTag
docker image push $repository/$imageName:$imageTag

ECS Serviceの更新

CircleCIのJobで cdk deploy コマンドを実行してECS ServiceのStackを更新します。

開発環境ではイメージのタグを latest に固定したいのですが、タグを指定するとCloudFormationのStackに変更がないのでStackの更新がされず、TaskDefinitionの新しいリビジョンが作成されません。そのために、コンテナのイメージをタグではなくdigest(イメージのビルドごとに一意な値)を指定することでStackを更新させ、新しいTaskDefinitionを作成してECS Serviceが更新されるようにしています。

訂正: 2020/04/17 digestは、正確にはビルドごとに一意ではなく、リポジトリにpushした時に変わるため、リポジトリのイメージのdigestを取得します。

# digest=$(docker images --no-trunc -q $imageName) # これはローカルのimageのdigestなので間違い
digest=$(docker inspect $imageName:latest --format='{{index .RepoDigests 0}}' | sed -e 's/.*\@\(.*\)/\1/')
yarn cdk deploy '*' -c stage=$stage -c digest=$digest --require-approval=never

本番環境ではGitのTagを参照してタグを設定します。(余談ですがTagはセマンティックバージョニングを採用しています。参照: Semantic Versioning おさらい | Developers.IO

tag=$(git describe --tags --abbrev=0)
yarn cdk deploy '*' -c stage=$stage -c tag=$tag --require-approval=never

デプロイ用のCDK

デプロイ用のCDKについて紹介します。

プロジェクトの構成と主要なソースは以下のとおりです。以下でポイントとなる部分だけ説明します。

.
├── bin
│   └── index.ts
├── cdk.json
├── lib
│   ├── service-stack.ts
│   └── util.ts
├── package.json
├── tsconfig.json
└── yarn.lock

cdk.json

{
  "app": "npx ts-node bin/index.ts",
  "context": {
    "project": "",
    "serviceName": "",
    "repository": "",
    "stg": {
      "account": "",
      "region": "",
      "vpcId": "",
      "securityGroupId": "",
      "clusterName": "",
      "targetGroupArn": ""
    },
    "prd": {
      "account": "",
      "region": "",
      "vpcId": "",
      "securityGroupId": "",
      "clusterName": "",
      "targetGroupArn": ""
    }
  }
}

bin/index.ts

#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from '@aws-cdk/core'
import { ServiceStack } from '../lib/service-stack'

const app = new cdk.App()

const stage: string = app.node.tryGetContext('stage')
if (!stage.match(/^(stg|prd)$/)) {
  console.warn('Invalid context. stage must be stg or prd.')
  process.exit(1)
}
let tagOrDigest: string | undefined
const tag: string = app.node.tryGetContext('tag')
if (tag) {
  tagOrDigest = `:${tag}`
}
const digest: string = app.node.tryGetContext('digest')
if (!tag && digest) {
  tagOrDigest = `@${digest}`
}
if (!tagOrDigest) {
  console.warn('Invalid context. tag or digest must be required.')
  process.exit(1)
}

const env = app.node.tryGetContext(stage)
console.log({ stage, env, tagOrDigest })

new ServiceStack(app, 'ServiceStack', {
  stackName: 'ServiceStack',
  env,
  cpu: 512,
  memory: 1024,
  tagOrDigest
})

lib/service-stack.ts

import * as cdk from '@aws-cdk/core'
import * as ecs from '@aws-cdk/aws-ecs'
import * as ec2 from '@aws-cdk/aws-ec2'
import * as iam from '@aws-cdk/aws-iam'
import * as logs from '@aws-cdk/aws-logs'
import * as alb from '@aws-cdk/aws-elasticloadbalancingv2'
import * as ssm from '@aws-cdk/aws-ssm'
import * as secrets from '@aws-cdk/aws-secretsmanager'
import { tryGetStageContext } from './util'

export interface ServiceStackProps extends cdk.StackProps {
  tagOrDigest: string,
  cpu: number,
  memory: number
}

export class ServiceStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: ServiceStackProps) {
    super(scope, id, props)

    // IAM Role

    const executionRole = new iam.Role(this, 'EcsTaskExecutionRole', {
      roleName: 'ecs-task-execution-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')
      ],
    })

    const serviceTaskRole = new iam.Role(this, 'EcsServiceTaskRole', {
      roleName: 'ecs-service-task-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    })

    // ECS TaskDefinition

    const logGroup = new logs.LogGroup(this, 'ServiceLogGroup', {
      logGroupName: this.node.tryGetContext('serviceName')
    })

    const image = ecs.ContainerImage.fromRegistry(`${this.node.tryGetContext('repository')}${props.tagOrDigest}`)

    const serviceTaskDefinition = new ecs.FargateTaskDefinition(this, 'ServiceTaskDefinition', {
      family: this.node.tryGetContext('serviceName'),
      cpu: props.cpu,
      memoryLimitMiB: props.memory,
      executionRole: executionRole,
      taskRole: serviceTaskRole,
    })

    serviceTaskDefinition.addContainer('serviceTaskContainerDefinition', {
      image,
      cpu: props.cpu,
      memoryLimitMiB: props.memory,
      memoryReservationMiB: props.memory,
      secrets: {
        'SECRET': ecs.Secret.fromSecretsManager(secrets.Secret.fromSecretArn(this, 'Secrets', 'secretのARN')),
        'PARAMETER': ecs.Secret.fromSsmParameter(ssm.StringParameter.fromStringParameterName(this, 'Parameter', 'parameterのname')),
      },
      logging: ecs.LogDriver.awsLogs({
        streamPrefix: this.node.tryGetContext('serviceName'),
        logGroup,
      }),
    }).addPortMappings({
      containerPort: 3000,
      hostPort: 3000,
      protocol: ecs.Protocol.TCP,
    })

    // ECS Service

    const vpc = ec2.Vpc.fromLookup(this, 'vpc', {
      vpcId: tryGetStageContext(this.node, 'vpcId')
    })

    const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', {
      clusterName: tryGetStageContext(this.node, 'clusterName'),
      vpc: vpc,
      securityGroups: []
    })

    const securityGroup = ec2.SecurityGroup.fromSecurityGroupId(this, 'ApplicationSecurityGroup', tryGetStageContext(this.node, 'securityGroupId'))

    const serviceFargateService = new ecs.FargateService(this, 'ServiceServiceDefinition', {
      serviceName: this.node.tryGetContext('serviceName'),
      cluster,
      vpcSubnets: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE }),
      securityGroup,
      taskDefinition: serviceTaskDefinition,
      desiredCount: 2,
      maxHealthyPercent: 200,
      minHealthyPercent: 50,
    })

    const albTargetGroup = alb.ApplicationTargetGroup.fromTargetGroupAttributes(this, 'AlbTargetGroup', {
      targetGroupArn: tryGetStageContext(this.node, 'targetGroupArn'),
    })

    albTargetGroup.addTarget(serviceFargateService.loadBalancerTarget({
      containerName: serviceTaskDefinition.defaultContainer!.containerName,
      containerPort: serviceTaskDefinition.defaultContainer!.containerPort,
    }))
  }
}

イメージの参照設定

ecs.ContainerDefinitionのimageに指定するRepositoryImageのインスタンスの生成についてです。ECRのリポジトリに対するRepositoryImageを生成するためにecr.Repository.fromRepositoryArnが提供されていますが、パラメータとしてdigestがサポートされていません。(こちらのIssueで要望されているのでそのうちサポートされるかもしれません。)そのため、ecs.ContainerImage.fromRegistryを使うことでリポジトリ名とタグやダイジェストを指定してRepositoryImageを生成します。

const image = ecs.ContainerImage.fromRegistry(`${this.node.tryGetContext('repository')}${props.tagOrDigest}`)

ダイジェストの指定方法は以下の通りです。(参照: イメージのプル - Amazon ECR

ダイジェストを使用してプルする場合は registry/repository[@digest] とします。

Parameter StoreやSecrets Managerの値を参照

コンテナの環境変数に、Parameter StoreやSecrets Managerから直接値を埋め込むように設定します。(参照: ECSでごっつ簡単に機密情報を環境変数に展開できるようになりました! | Developers.IO)いままではデプロイフローの中でParameter StoreやSecrets Managerからデータを取得してコンテナの環境変数に値を設定していたのですが、この機能ができてからはデプロイフローではParameter StoreやSecrets Managerのキーを指定するだけで良くなりました。値の取得はコンテナ起動時にECSによりタスクの実行ロールによって自動的に行われます。セキュアでとても良いですね!

serviceTaskDefinition.addContainer('serviceTaskContainerDefinition', {
  // ...
  secrets: {
    'SECRET': ecs.Secret.fromSecretsManager(secrets.Secret.fromSecretArn(this, 'Secrets', 'secretのARN')),
    'PARAMETER': ecs.Secret.fromSsmParameter(ssm.StringParameter.fromStringParameterName(this, 'Parameter', 'parameterのname')),
  },
  // ...
})

補足ですが、値の取得に必要なロールはCDKが判断して追加してくれます(便利すぎてよくわからないけど動いてるってなりそう)。あとこれも余談ですが、ECS(Fargate)はまだ非対応ですが、ECS(EC2)の方はより細かい指定ができるみたいです。(参照: ECSがSecrets ManagerのバージョンやJSONキーに対応。機密情報管理がめっちゃ便利になりました! | Developers.IO

トラブルシューティング

インフラ管理用のCDKも含めて、遭遇したトラブルについて記載しておきます。

クロススタック参照によるエラー

CDKでは、あるスタックで生成したリソースのインスタンスをPropsとして別のスタックに渡して参照することができます。これはCFnとしてはクロススタック参照を利用しています。

例えばスタックAのリソース1をスタックBで参照する場合は、実態としては以下になります。

  • スタックAのEXPORT出力フィールドにリソース1の情報を出力
  • スタックBからスタックAのEXPORT出力フィールドをFn::ImportValue組み込み関数で参照

このとき、リソース1を削除しようとして、「スタックAからリソース1の削除」と「スタックBからリソース1の参照を削除」を同時に行うとスタックAの更新に失敗します。これは、先にスタックAからリソース1のEXPORT出力フィールドを削除しようとしますが、このときまだスタックBからはスタックAのリソース1を参照しており、他のスタックから参照されている出力は変更したり削除したりできないという制約があるためエラーとなるためです。(似たような事例がIssueとして報告されています。)

対処としては、まず「スタックBからリソース1の参照を削除」を適用したあとに「スタックAからリソース1の削除」を適用すれば解決します。CDKを使っているとスタック間の参照が楽なのでついついスタックを細かくしたくなりがちですが、実態はCFnであるため、CFnのプラクティスに従う事が大事だと思いました

循環参照によるエラー

S3のバケットを生成するスタック('s3')と、CloudFrontのOriginAccessIdentityを生成してS3にポリシーを設定するスタック('cloudfront')を分けたとき、CDKがCFnのテンプレートを生成する際に循環参照となりエラーが発生しました。

Error: 'cloudfront' depends on 's3' (cloudfront -> s3/AssetsBuclet/Resource.RegionalDomainName). Adding this dependency (s3 -> cloudfront/OriginAccessIdentity/Resource.S3CanonicalUserId) would create a cyclic reference.

こちらは両者を同じスタック内で生成することで回避できました。(類似の例として、S3とLambdaに関する循環参照のIssueが報告されていました。)

Vpc.fromLookupのvpcIdにImportValueは使えない

デプロイ用のCDKから、インフラ管理用のCDKで構築したリソース(具体的にはVPC)を参照したいシーンがありました。最初、インフラ管理用のCDKでVPCのIDをCfnOutputを使ってEXPORT出力フィールドに出力し、デプロイ用のCDKからimportValueで読み込みVpc.fromLookupにパラメータとして渡そうとしました。

const vpc = ec2.Vpc.fromLookup(this, 'vpc', {
  vpcId: cdk.Token.asString(cdk.Fn.importValue('VpcId'))
})

ところが、いざ実行してみると、synthesize(CFnのテンプレートを出力する)の時点でエラーとなります。原因は、Fn.importValueCFnの組み込み関数なので解決されるタイミングがCFnの実行時なのに対して、Vpc.fromLookupを解決しようとするタイミングはsynthesizeのタイミングだからです。(こちらのIssueのコメントに書かれていました。)

なのでvpcIdをコンテキストなどに登録しておき、synthesizeのタイミングで解決できるようにすることでこの問題は解決します。

const vpc = ec2.Vpc.fromLookup(this, 'vpc', {
  vpcId: this.node.tryGetContext('vpcId')
})

感想

  • CDKはCFnを出力するツールなので、CFnのプラクティスやAWSのリソース構成の理解は必須。
  • CDKそのものの学習コストは少しあるが、お作法が大体決まっているので慣れてくると速い。
  • 不具合や破壊的変更もまだまだあるが、体験としてはCFnを生で書くより相当いいので今後も使っていきたい。
  • 高レベルのコンポーネントは暗黙値も多いので、AWSリソースがどのように構成されるのか一つ一つ理解しながら使ったほうが良い。
  • (CFnも同じだが)リソース名は変更不可の場合が多くあとから変更しようとすると作り直しになるので、最初に命名規則を決めたほうが良い。
  • 弊社ブログの記事が充実しすぎててやばい。