2019.11.17

AWS CDKでECSベースのアプリケーションとCICDを構築する

概要

AWS CDKで、ECS(Fargate)ベースのアプリケーションとCICD環境を構築しました。
コンテナのCICDにはGitopsを導入しました。
サービス稼働に必要なVPCやドメイン関連も含めたリソース一式をAWS CDKでコード化したので、紹介します。

説明しないもの

各種AWSリソースがどんなものであるかなどは省いています。
(今後部分的にピックアップして記事化するかもしれません)

AWS CDK

本記事では、AWS CDK(AWS Cloud Development Kit)を使用したインフラ構築を紹介しています。
AWS CDKについて簡単に説明します。

AWS CDKの概要

AWS CDK(Cloud Development Kit)とは、AWSのリソース(S3やLambdaなど)定義やリソース同士の関連をプログラミングコードで記述可能なフレームワークです。
AWSリソースの定義ファイル化・自動化を行う仕組みとしてCloudformationがありますが、AWS CDKはyamlファイルではなくプログラミングコードで記述可能であり、より扱いやすくなったものと言えます。
TypeScript、Python、Java、.NETのSDKが提供されています。
本記事で紹介するコードはTypeScriptで記述しています。
個人的には以下の点で優れている感触です。

リソース同士の関連をコードで表現できる

Cloudformationではリソース同士の関連を追いかけるのが大変でした

エディタのコード補完で設定値や型誤りを指摘してくれる・推測できる

Coudformationにも一応validation的なものはありますが、分かりやすさが段違いです

AWS CDKサンプル構成

2019 11 17 1 最上位に「Stack」があります。
StackはCloudformationのStackと同様の概念で、CDKでビルドすると、Stackとして記述したものはCloudformation Stackとして生成されます。
Stackの中に「Construct」という要素があり、その中に各種AWSリソースがあります。
各種AWSリソースもまたConstructを継承した要素で、入れ子構造にできます。
ConstructはStack内に複数作成でき、Construct間の関連も記述できます。
上記の図では、以下の2つのConstructがあり、それらが連携して動作するシステムを構築しています。

  • SNS TopicのイベントをLambda functionが受け取り、SQSへエンキューする
  • SQSからメッセージをサブスクライブし、DynamoDBへ登録したデータを取得するAPIを設置
    Construct単位で部品化することで、コードとして読み易いだけでなく、似たような構成にバリエーションをつけることで、柔軟なアーキテクチャを構築しやすくなります。
    その他詳細は以下ドキュメントを参照ください。
  • ドキュメント
    https://docs.aws.amazon.com/cdk/latest/guide/home.html
  • APIリファレンス
    https://docs.aws.amazon.com/cdk/api/latest/

アーキテクチャ概要

アプリケーションとCICD基盤、Githubリポジトリを含んだ構成図です。
2019 11 17 2
Stackとして記述している部分がAWS CDKで構築した部分、それ以外は手動で事前に作成している部分です。
以下の要素で構成されています。

Gitリポジトリ

スタック

  • CIパイプラインスタック
  • バックエンドスタック

手動構築リソース

  • Route53
  • ACM
  • ECR リポジトリ

アプリケーションCIスタックとバックエンドスタックの間に、ECS CDリポジトリやPullRequestが登場しますが、少々ややこしいためこれらは後述します。

アプリケーションリポジトリ

ECSで稼働させるアプリケーションには、サンプルとしてLaravelを使用します。
単にlaravel newしただけのものを稼働させます。

Laravelアプリケーションディレクトリ

.  
├── app  
├── bootstrap  
├── config  
├── database  
├── public  
├── resources  
├── routes  
├── storage  
|── tests  
├── Dockerfile       <--- laravel用Dockerfile  
├── Dockerfile.nginx <--- nginx用Dockerfile  
├── composer.json  
├── composer.lock  
├── package.json  
├── phpunit.xml  
├── server.php  
├── webpack.mix.js  
└── yarn.lock  

内部に2つのDockerfileが入っており、それぞれnginx用とLaravel本体用です。
Laravelの前段にNginxコンテナを配置し、FastCGIモードでPHPを起動する構成としています。
こちらの記事を参考にさせて頂きました。
https://qiita.com/kobayashi-m42/items/95ba3f611b3def27241e
https://qiita.com/tomoyamachi/items/991a0d149e9585cac15f#php-fpmコンテナを作成
ひとつのDockerfileにまとめることも出来ると思いますが、複数のDockerコンテナを連携動作させるサンプルという意味合いもあり、webサーバーとアプリケーションサーバーでコンテナを分割した構成にしています。

CDKリポジトリ

CDKリポジトリの概要を紹介します。

CDKリポジトリディレクトリ

.  
├── bin <--- CDKアプリケーション群を格納  
│   └── example <--- CDKアプリケーションの単位  
│       ├── index.ts        <--- 構築対象スタック群とそのパラメータ  
│       ├── parameters.json <--- SSM parameter store に登録しているキー群  
│       └── secrets.json    <--- Secrets manager に登録しているキー群  
│  
├── lib <--- 部品化したStack、Constructを格納  
│   ├── application-ci-ecr-stack.ts    <--- アプリケーションCIスタック   
│   ├── backend-stack.ts               <--- バックエンドスタック   
│   ├── ecs-fargate-task-definition.ts <--- ECSタスク定義 Construct  
│   ├── ecs-service-alb.ts             <--- ALB Construct  
│   └── ecs-service-cd.ts              <--- ECSサービスCD Construct  
│  
│── utils <--- CDKコード内で使用するユーティリティ  
│   ├── secrets-manager.ts <--- Secrets manager から値を取得するユーティリティ  
│   └── ssm-parameter.ts   <--- SSM parameter store から値を取得するユーティリティ│  
│  
└── scripts <-- デプロイ等のスクリプト  
    ├── example-deploy-dev.sh      <--- example CDKアプリケーションデプロイスクリプト(dev)  
    ├── example-deploy-prod.sh     <--- example CDKアプリケーションデプロイスクリプト(prod)  
    ├── example-destroy-dev.sh     <--- example CDKアプリケーション削除スクリプト(dev)  
    ├── example-destroy-prod.sh     <--- example CDKアプリケーション削除スクリプト(prod)  
    ├── ssm-parameter-delete.sh    <--- 定義ファイルのSSM parameter削除スクリプト  
    ├── ssm-parameter-regist.sh    <--- 定義ファイルのSSM parameter登録スクリプト  
    └── ssm-parameters.json.sample <--- SSM parameter定義ファイルサンプル  

CDKアプリケーション構築

ある環境における、CDK構築対象の全てのスタック構築に関するコードがbin/example/index.tsに記述されています。
scriptsディレクトリ配下に、CDKアプリケーションの作成および削除スクリプトが置かれており、これらスクリプトから環境毎のコンテキストと共にbin/example/index.tsを呼び出しています。
例えば、dev環境であればexample-deploy-dev.sh を実行してCDKアプリケーションを作成します。

bin/example/index.ts

// CDKアプリケーション with コンテキスト  
const app = new cdk.App({  
  context: {  
    appName: 'example'  
  }  
});  
const appName = app.node.tryGetContext('appName');  
// 環境名と各環境依存のプロパティ  
const env = app.node.tryGetContext('env')  
const servicesForEnv = env === 'prod' ? {  
  cpu: 512,  
  memoryLimitMiB: 1024,  
} : {  
  cpu: 256,  
  memoryLimitMiB: 512,  
}  
// 登登録済みの SSM Parameter Store と Secrets managerのフィールド名リストを取得  
const parameters = require('./parameters.json');  
const secrets = require('./secrets.json');  
// バックエンドスタック作成  
const backend = new BackendStack(app, `${appName}-${env}`, {...BackendStackProps})  
// アアアプリケーションCIスタック作成  
const serviceNameLaravel = 'laravel-app';  
new ApplicationCiEcrStack(app, `${appName}-${serviceNameLaravel}-${env}`, {...ApplicationCiEcrStackProps})  

CDKアプリケーション作成とコンテキスト設定

現状はappName のみ指定しています。
contextに必要なコンテキストを追加できます。
また、bin/example/index.tsを呼び出す際にコンテキストを指定しています。

環境名取得

デプロイスクリプトで、環境名をenvコンテキストで指定しています。
また、環境毎に値が異なるプロパティをそれぞれ記述しています。
ここでは、後述するECS Task Definitionに指定する、リソース関連プロパティを記述しています。

パラメータとシークレットの登録フィールド名リスト取得

パラメータとシークレットのフィールド名が記述されたリストを取得しています。

各種スタックの作成

上記は各スタックに指定するプロパティは省略しています。

パラメータとシークレット

スタックに必要なパラメータや機密情報は、SSM Parameter storeとSecrets managerを使用して扱っています。
SSM Parameter storeでもSecureStringを扱えるため、機密レベルとしてはParameter storeもSecrets mangerも変わりません。
しかし、CDKコードにて扱う型の都合上、以下のように使い分けています。

- cdk.SecretValue 型で扱う必要があるもの → Secrets manager  
  - GitのOAuthToken  
  - GitのSSH Key  
- 上記以外のもの  
  - ECSへ設定する環境変数  
  - 機密ではないGitの情報  

scripts/ssm-parameters-sample.json に、Parameter store に登録するパラメータリストのサンプルを、scripts/ssm-parameter-regist.sh に登録スクリプトを置いています。
scripts/ssm-parameters-dev.json のように、sample部分を環境名にしたファイルを複製し、パラメータ値を記述した上で、以下コマンドで実行します。
但し、URLをこの方法で登録しようとすると、エラーとなってしまうため、
(本質部分ではないので、詳細は割愛します。)

./scripts/ssm-parameter-regist.sh dev  

Secrets managerへは、機密情報のため、手動でAWSコンソールから登録します。
また、CDKコード内で扱うためのユーティリティをそれぞれ用意しています。

SSM Parameter store

登録パラメータ名リスト
ユーティリティ

Secrets manager

登録シークレットIDリスト
ユーティリティ

ECS CDリポジトリ

ECSサービスに適用するDocker imageを記述しているのリポジトリです。
Gitopsを導入し、CIパイプラインスタックとバックエンドスタックを繋げる役割を果たすのが本リポジトリです。
本リポジトリの詳細は後述します。

CIパイプラインスタック

CIパイプラインスタックでは、CodePipelineに以下のステージを構築しています。

  1. [Source stage] アプリケーションリポジトリのブランチへの変更を検出
  2. [Build stage]2つのDockerfileに対してDocker buildとECRへのdocker push
  3. [Prepare Deploy stage] ECS CDリポジトリに対し、ECSサービスで使用中のdockerイメージタグを、ビルドしたイメージタグへ置き換えるPull Requestを作成

2019 11 17 3
3のPrepare DeployはGitopsによるECSのCD(デプロイ)運用を行うためのものです。
詳細は後述しますが、ビルドしたイメージを使用したデプロイを行うための準備作業という認識で差し支えありません。
イメージタグには、ソースであるGithubリポジトリのコミットIDを使用しています。
ここでPushされたイメージが、ECS環境へデプロイされますが、不具合等の理由で、速やかに以前のバージョンへ戻したいという状況が想定されます。
ECSでは、タグを含めたイメージURLを指定してデプロイするため、イメージタグにてバージョン管理することが重要ですが、設定の簡潔さと過去バージョンの辿りやすさのバランスをとり、gitリポジトリのコミットIDをそのままイメージタグとして使用しています。

手動構築リソース

- ECR
  - laravel-app
  - laravel-app-nginx

ECRリポジトリを手動構築している理由として、CDKにて構築するCI環境は、「アプリケーションのDockerfileからdocker buildを行い、イメージをECRリポジトリへプッシュする」というものですが、この部分は必ずしもAWSリソースで構築する必要はなく、CircleCIなど他の手段でも実現可能です。
ECRをCDKによる構築対象に含めてしまうと、このような置き換えが難しくなります。
なるべく交換可能な設計にしておくためにECRリポジトリはスタックから切り離しています。

CDK構築リソース

- CodePipeline
- CodeBuild (Laravel)
- CodeBuild (Nginx)
- CodeBuild (Prepare Deploy)

CDKリポジトリのlib/application-ci-ecr-stack.tsにCIパイプラインをスタックとして部品化したものを記述しています。
以下、CodePipeline作成部分を一部抜粋します。
Source, Build, PrepareDeployの各ステージを構築しています。

lib/application-ci-ecr-stack.ts

const pipeline = new codepipeline.Pipeline(this, `${props.serviceName}-build-CodePipiline`, {  
  pipelineName: `${appName}-${props.serviceName}-${env}-build-pipeline`,  
  stages: [  
    {  
      stageName: 'Source',  
      actions: [  
        new codepipeline_actions.GitHubSourceAction({  
          actionName: `${props.serviceName}-application-Source`,  
          owner: SsmParameterUtil.value(this, props.source.git.owner),  
          repo: SsmParameterUtil.value(this, props.source.git.repo),  
          branch: SsmParameterUtil.value(this, props.source.git.branch),  
          oauthToken: SecretManagerUtil.secureValue(this, props.source.git.oauthToken),  
          output: sourceArtifact,  
          trigger: codepipeline_actions.GitHubTrigger.WEBHOOK  
        })  
      ]  
    },  
    {  
      stageName: 'Build',  
      actions: props.builds.map(build => {  
        return this.imageBuildAction({  
          serviceName: props.serviceName,  
          build: build,  
          input: sourceArtifact,  
          codeBuildRole: codeBuildRole  
        })  
      })  
    },  
    {  
      stageName: 'PrepareDeploy',  
      actions: [  
        this.prepareDepoyBuildAction({  
          serviceName: props.serviceName,  
          deploy: props.deploy,  
          input: sourceArtifact,  
          codeBuildRole: codeBuildRole  
        })  
      ]  
    }  
  ]  
})  

Sourceには、アプリケーションのGithubリポジトリやブランチ名、Webhookトリガーに必要なOAuthTokenを指定しています。
Buildには、アプリケーション内のDockerfile群をビルドし、それぞれECRへプッシュする処理を記述します。
ビルド内容をbuildspecに記述し、CodeBuildには環境に応じた環境変数を指定しています。

lib/application-ci-ecr-stack.ts

const buildspec = {  
  version: '0.2',  
  phases: {  
    install: {  
      "runtime-versions": {  
        docker: 18  
      },  
    },  
    pre_build: {  
      commands: [  
        "$(aws ecr get-login --no-include-email --region $AWS_REGION)",  
        'IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)'  
      ]  
    },  
    build: {  
      commands: [  
        `docker build -t $REPO_NAME:$IMAGE_TAG -f $DOCKERFILE .`  
      ]  
    },  
    post_build: {  
      commands: [  
        `docker tag $REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG`,  
        `docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG`,  
      ]  
    }  
  }  
}  
return new codepipeline_actions.CodeBuildAction({  
  actionName: `${props.build.repositoryName}-ImageBuild`,  
  project: new codebuild.PipelineProject(this, `${props.build.repositoryName}-CodebuildProject`, {  
    role: props.codeBuildRole,  
    environment: props.build.environment,  
    environmentVariables: {  
      AWS_ACCOUNT_ID: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: Aws.ACCOUNT_ID  
      },  
      AWS_REGION: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: Aws.REGION  
      },  
      ENV: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: this.node.tryGetContext('env')  
      },  
      ...  
    },  
    buildSpec: codebuild.BuildSpec.fromObject(buildspec),  
    cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER)  
  }),  
  input: props.input  
})  

PrepareDeployには、ECS CDリポジトリへのプルリクエストを作成する処理を記述します。
ここでは、プルリクエストを作成するため、hubを使用しています。
本来は、予めインストール済みのイメージを作成し、使用するべきですが、ここでは直接インストールしています。
ECS CDリポジトリへの変更内容は後述しますが、imegedefinitions.jsonのimageUriを、ビルドしたイメージのタグを使用するように置換しています。

lib/application-ci-ecr-stack.ts

const buildspec = {  
  version: "0.2",  
  phases: {  
    install: {  
      "runtime-versions": {  
        docker: 18  
      },  
      commands: [  
        "mkdir -p ~/.ssh",  
        "echo \"$GIT_SSHKEY\" > ~/.ssh/id_rsa",  
        "chmod 600 ~/.ssh/id_rsa",  
        "ssh-keygen -F github.com || ssh-keyscan github.com >>~/.ssh/known_hosts",  
        'git config --global user.email ${GITHUB_EMAIL}',  
        'git config --global user.name ${GITHUB_NAME}',  
        "apt-get install wget",  
        "wget https://github.com/github/hub/releases/download/v2.12.8/hub-linux-amd64-2.12.8.tgz",  
        "tar -xzvf hub-linux-amd64-2.12.8.tgz",  
        "mv hub-linux-amd64-2.12.8/bin/hub /usr/bin/hub"  
      ]  
    },  
    pre_build: {  
      commands: [  
        "IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)",  
      ]  
    },  
    build: {  
      commands: [  
        "git clone git@github.com:${GIT_OWNER}/${GIT_REPO}.git",  
        "cd ${GIT_REPO}",  
        "git checkout ${GIT_BRANCH}",  
        "git checkout -b deploy/${IMAGE_TAG}",  
        "cd ecs/${SERVICE_NAME}",  
        "sed -i -e \"s/\\\"imageUri\\\": \\(.*\\):.*/\\\"imageUri\\\": \\1:${IMAGE_TAG}\\\"/g\" imagedefinitions.json",  
        "git add imagedefinitions.json"  
      ]  
    },  
    post_build: {  
      commands: [  
        "git commit -m \"deploy ${IMAGE_TAG}\"",  
        "git push origin deploy/${IMAGE_TAG}",  
        "/usr/bin/hub pull-request -b ${GIT_BRANCH} -m \"deploy/${IMAGE_TAG}\""  
      ]  
    }  
  }  
}  
return new codepipeline_actions.CodeBuildAction({  
  actionName: `PrepareDeploy`,  
  project: new codebuild.PipelineProject(this, `CodebuildProject-PrepareDeploy`, {  
    role: props.codeBuildRole,  
    environment: props.deploy.environment,  
    environmentVariables: {  
      AWS_ACCOUNT_ID: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: Aws.ACCOUNT_ID  
      },  
      AWS_REGION: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: Aws.REGION  
      },  
      ENV: {  
        type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,  
        value: this.node.tryGetContext('env')  
      },  
      ...      .  
    },  
    buildSpec: codebuild.BuildSpec.fromObject(buildspec)  
  }),  
  input: props.input  
})  

バックエンドスタック

バックエンドスタックには、ECS Fargate Cluster / Service / Task定義と、それを取り巻くVPC、ALB、データベース(Aurora)、ドメイン、SSL化などの関連リソースを合わせて構築しています。
ユーザーがSSL化されたドメインへアクセスし、ALBを通してECS Serviceへアクセスし、アプリケーションからはセキュアなサブネットに配置されたデータベースとの接続可能となるところまで構築されます。
ECSには複数のサービスを設定可能な構成としており、今回はlaravelアプリケーションのみですが、ほかのアプリケーションも追加可能な拡張性を持たせています。
ECSサービスに対するCDパイプラインを合わせて構築しており、ECS CDリポジトリの変更を検出し、リポジトリ内のimagedefinitions.json のコンテナ毎のイメージURIを使用したタスク定義を作成し(古いタスク定義は削除し)、ECS Serviceへ作成したタスク定義を適用します。
2019 11 17 4

ECS Serviceのデプロイ自動化手段

ECSサービスのデプロイには、以下の作業が必要です。

  1. タスク定義のイメージURIを更新した新たなタスク定義を作成
  2. 稼働中のタスクに新たなタスク定義を適用する
    AWSでこの作業を自動化するためには、以下の方法があります。
  3. Code DeployのECSプラットフォームを使用する
  4. CodePipelineのECS Deployアクションを使用する
    AWS CDKのCodeDeployはECSプラットフォームをサポートしていないため、後者を採用しています。

以下のAPIドキュメントに「The CDK currently supports Amazon EC2, on-premise and AWS Lambda applications.」と記載されています。
(version 1.16.1時点)
https://docs.aws.amazon.com/cdk/api/latest/docs/aws-codedeploy-readme.html
今後対応されたら、また見直そうと思います。

手動構築リソース

- Route53  
  - Hosted zone  
- ACM  
  - certificate  

Route53のHosted zoneやcertificateは、ひとつのアプリケーションだけでなく、複数の目的で様々な箇所で使用することが想定されます。
例えば、app.example.comはCDKで構築するアプリケーションで使用するが、blog.example.comは別途構築したWordPressから使用する、などが考えられます。
CDKで構築しようとしているリソース群とライフサイクルが異なる場合は、CDKが切り出した方がいいと思います。

CDK構築リソース

- VPC  
  - Ingress network  
  - Application network  
  - Database network  
  - Nat Gateway  
- Rtoute53  
  - Aレコード  
- ALB  
- ECS(Fargate)  
  - Cluster  
  - Task Definition  
  - Fargate Service  
- ECS CDパイプライン  
  - CodePipeline  

CDKリポジトリのlib/backend-stack.tsにバックエンドリソースをスタックとして部品化したものを記述しています。

VPCとECS Cluster

まず、VPCとECS Clusterを作成しています。
作成したVPCをECS Clusters作成時にパラメータとして指定しています。
このように、リソース同士の関連を直感的に記述できます。

lib/backend.ts

const appName = this.node.tryGetContext('appName');  
const env = this.node.tryGetContext('env');  
const vpc = new ec2.Vpc(this, `Vpc`, {  
  cidr: props.vpc.cidr,  
  maxAzs: 2,  
  subnetConfiguration: [  
    {  
      cidrMask: 24,  
      name: 'Ingress',  
      subnetType: ec2.SubnetType.PUBLIC,  
    },  
    {  
      cidrMask: 24,  
      name: 'Application',  
      subnetType: ec2.SubnetType.PRIVATE,  
    },  
    {  
      cidrMask: 28,  
      name: 'Database',  
      subnetType: ec2.SubnetType.ISOLATED,  
    }  
  ],  
});  
const ecsCluster = new ecs.Cluster(this, `EcsCluster`, {  
  vpc,  
  clusterName: `${appName}-${env}`  
});  

ALBとECSタスク定義、ECSサービス、CDパイプライン

ここからは、サービス単位(=アプリケーション単位)で作成します。

今回は1つのサービス(Laravelアプリケーション)のみですが、複数のサービスを追加できるような作りにしています。
サービス毎のECS ServiceとTask Definition、そしてサービスのロードバランサー(ALB)を作成しています。
ECS Serviceの作成には、aws-ecs-patternsのApplicationLoadBalancedFargateServiceを使用しています。
ECS Serviceを使用したサービス公開には、ALBとRoute53 AレコードやSSL証明書とを紐づける必要があります。
本来は、これらのリソースもCDKコード上で作成する必要がありますが、ApplicationLoadBalancedFargateServiceはこの辺りも一緒に生成してくれます。

lib/backend.ts

props.services.forEach(service => {  
  // ALB  
  const ecsServiceAlb = new EcsServiceAlb(this, `${service.name}-EcsServiceAlb`, {  
    vpc,  
    serviceName: service.name  
  })  
  // ECS Fargate TaskDefinition  
  const ecsFargateTaskDefinition = new EcsFargateTaskDefinition(this, `${service.name}-EcsFargateTaskDefinition`, {  
    taskDefinitionProps: service.taskDefinitionProps,  
    containerDefinitionPropsArray: service.containerDefinitionPropsArray  
  });  
  // ECS Fargate Service  
  const fargateService = new ApplicationLoadBalancedFargateService(this, `${service.name}-FargateService`, {  
    cluster: ecsCluster,  
    domainName: `${props.route53.subDomain}-${this.node.tryGetContext('env')}`,  
    domainZone: route53.HostedZone.fromHostedZoneAttributes(this, `${service.name}-FargateService-Domain`, {  
      hostedZoneId: props.route53.hostedZoneId,  
      zoneName: props.route53.domain  
    }),  
    certificate: Certificate.fromCertificateArn(this, `${service.name}-FargateService-Certificate`, props.acm.certificateArn),  
    loadBalancer: ecsServiceAlb.alb,  
    publicLoadBalancer: true,  
    listenerPort: service.listenerPort,  
    protocol: elbv2.ApplicationProtocol.HTTPS,  
    healthCheckGracePeriod: service.healthCheckGracePeriod,  
    assignPublicIp: service.assignPublicIp,  
    cpu: service.cpu,  
    memoryLimitMiB: service.memoryLimitMiB,  
    desiredCount: service.desiredCount,  
    enableECSManagedTags: service.enableECSManagedTags,  
    serviceName: service.name,  
    taskDefinition: ecsFargateTaskDefinition.taskDefinition  
  })  
  // ECS Service CD Pipeline  
  new EcsServiceCd(this, `${service.name}-EcsServiceCd`, {  
    git: {  
      owner: SsmParameterUtil.value(this, props.cd.git.owner),  
      repo: SsmParameterUtil.value(this, props.cd.git.repo),  
      branch: SsmParameterUtil.value(this, props.cd.git.branch),  
      oauthToken: SecretManagerUtil.secureValue(this, props.cd.git.oauthToken),  
    },  
    service: fargateService.service,  
    serviceName: service.name  
  });  
});  

ECS CDパイプライン

ECS CDリポジトリへの変更をトリガーして、ECS ServiceへデプロイするCodePipelineを構築します。

lib/ecs-service-cd.ts

const appName = scope.node.tryGetContext('appName');  
const env = scope.node.tryGetContext('env');  
const sourceArtifact = new codepipeline.Artifact('source');  
const pipeline = new codepipeline.Pipeline(this, `${props.serviceName}-deploy-CodePipiline`, {  
  artifactBucket: new s3.Bucket(this, `${props.serviceName}-deploy-ArtifactBucket`, {  
    bucketName: `${appName}-${props.serviceName}-${env}-deploy-artifact`,  
    encryptionKey: new kms.Key(this, `${props.serviceName}-deploy-EncryptionKey`, {  
      alias: `${appName}-${props.serviceName}-deploy-${env}`,  
      removalPolicy: cdk.RemovalPolicy.DESTROY  
    })  
  }),  
  pipelineName: `${appName}-${props.serviceName}-${env}-deploy-pipeline`,  
  role: new iam.Role(this, `${appName}-${props.serviceName}-${env}-CodePipelineRole`, {  
    assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'),  
    managedPolicies: [  
      iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess')  
    ]  
  }),  
  stages: [  
    {  
      stageName: 'Source',  
      actions: [  
        new codepipeline_actions.GitHubSourceAction({  
          actionName: 'GitHub_Source',  
          owner: props.git.owner,  
          repo: props.git.repo,  
          oauthToken: props.git.oauthToken,  
          output: sourceArtifact,  
          branch: props.git.branch,  
          trigger: codepipeline_actions.GitHubTrigger.WEBHOOK  
        })  
      ],  
    },  
    {  
      stageName: 'EcsDeploy',  
      actions: [  
        new codepipeline_actions.EcsDeployAction({  
          actionName: 'DeployAction',  
          service: props.service,  
          imageFile: new codepipeline.ArtifactPath(sourceArtifact, `ecs/${props.serviceName}/imagedefinitions.json`)  
        }),  
      ],  
    },  
  ],  
});  

GitOps風のContinuous Delivery (CD)

ECS CDリポジトリによるプルリクエストとマージによって、ECSサービスをデプロイする仕組みを導入しており、Weaveworksが提唱しているkubernetesに対するデプロイ管理手法であるGitOpsを参考にしています。

GitOpsについては以下記事が参考になります。
https://www.infoq.com/jp/news/2018/11/gitops-weaveworks/

アプリケーションリポジトリへのGit操作によってデプロイを実行するのは、CIOpsと呼ばれ、GitOpsとは区別されます。

CIOpsでは、CIとCDを分離せずにビルド・テスト・デプロイをセットで実施するような構成になりがちですが、以下が問題となることがあります。

  • ロールバックの際にもビルド・テストで時間がかかり、敏速なロールバックができない
  • CIサーバ/サービスに強力な権限を持たせる必要がある

GitOpsは「Gitリポジトリとクラスターがコマンド実行なしで同期する」という点で、ECSへの適用は難しいと思われますが、ある程度GitOpsを参考にして、ある程度CIOpsから脱却し、CIとCDの分離を意識しています。

CDリポジトリでは、リリースバージョンをGitで管理でき、Gitに対する操作だけでリリース/ロールバックが可能です。

リリース/ロールバックを実施する時点では、コードやDockerイメージのビルドは既に完了しているため、ビルドを待つ必要はなく、監視の必要があるのは、純粋にECSサービスのデプロイの時間だけです。

普段の運用時の負荷を軽減し、構成がコードとして記述されているため共有しやすく、チームとしてもワークし易くなるかと思います。

ECS CDリポジトリ

ecs配下にECSサービス毎にディレクトリを作成し、imagedefinitions.jsonを格納します。
(本記事の例ではecs/laravel-app/imagedefinitions.json です。)

.  
└── ecs  
    └── laravel-app  
        └── imagedefinitions.json  

imagedefinitions.jsonには、ECSサービス内のタスク毎に適用するDockerイメージのURIを記述します。

[  
  {  
    "name": "laravel",  
    "imageUri": "<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-app:4cf03d1"  
  },  
  {  
    "name": "nginx",  
    "imageUri": "<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-app-nginx:4cf03d1"  
  }  
]  

コード変更からECSデプロイまでの流れ

アプリケーションのデプロイは以下の流れで行われます。

  1. アプリケーションリポジトリへ変更をプッシュ or マージ
  2. CIパイプラインスタックが、変更されたコードをビルドし、新たなタグを付与したDockerイメージをECRへプッシュ
  3. CIパイプラインスタックが、ECS CDリポジトリへimagedefinitions.json のイメージタグを更新するプルリクエストを作成
  4. ECS CD管理リポジトリのプルリクエストをシステム管理者等がマージ
  5. リポジトリの変更を検出し、バックエンドスタックのECS CDパイプラインが起動
  6. imagedefinitions.jsonに記述されているイメージURIの内容でタスク定義を更新
  7. 稼働中サービスのタスクに、新しいタスク定義を適用(デプロイ)

まとめ

AWS CDKで構築した、ECS(Fargate)ベースのLaravelアプリケーションとCICD環境を紹介しました。
コンテナを本番環境で稼働させるインフラとして、AWS ECSを取り上げ、CICDも含めた環境を考えていったところ、本記事のような構成になりました。
まだまだ、改善の余地はあると思いますが、参考になれば幸いです。
CDKでは、サーバレスアプリケーション構築でも威力を発揮するため、今後いくつかのアーキテクチャパターンを作成していこうと思います。