🐱

ECS用のCDパイプラインに対する考察

2021/03/25に公開1

趣旨

ECSへデプロイを行うCDパイプラインをGitHub ActionsとAWS CodePipelineを使ってどのように構築すべきかを考察してみます。

背景

ECSへのデプロイについてはデファクトスタンダードと言えるものはなく、様々な選択肢があります。
また、Dockerイメージのビルドに関してもキャッシュまわりの仕組みが日々進化しており、ビルド方法・ビルド環境に関しても様々な選択肢があります。

業務・プライベート問わずいろんなタイプのECSのCDパイプラインを組んできましたが、やはりどの構成も一長一短であり、最適といえる構成はなかなか定まりませんでした。

また、業務でアプリケーションのインフラとCI/CDパイプラインを構築する場合、チームやエンジニア役割に応じて、どこを境界にすべきか検討する必要が出てきます。
(趣味のコードなら、自分が全部管理するのでどうでもいいのですが)
例えば、SREが管理するCDパイプラインのコードがRailsアプリのGitリポジトリに含まれていると面倒だったりしますよね?
このように、責任・責務の境界をどこに引くかでも最適な構成は変化していきます。

これらの経験から、ECSへのCDパイプラインをどのように組むべきか一度しっかり検討してみたくなり、本記事を執筆してみました。

前提

これからいくつかのパイプラインのパターンを列挙していきますが、以下に関しては各パターンで共通の条件とします。

  • サービスのアプリケーション実行環境はECSとする
  • RDSはVPC内からしかアクセスできない
  • アプリケーションは問わないが、デプロイ前にDBのMigration処理が必要とする
    • 例: Ruby on Railsにおける rails db:migrate
  • ECS用のコンテナイメージはECRに格納する
  • アプリケーション側とインフラ側でリポジトリが別れている構成とする
  • アプリケーション側はGitHub Actionsをテスト等のCIに利用していることとする
  • デプロイのトリガーは main ブランチへのpushとする

デプロイパイプラインのパターン

ECS用のCDパイプラインの構築方法は様々あり、細かい差異まで考慮すると組み合わせは無限大になってしまいます。
そこで、下記の観点でいくつかのパターンに分類してみます。

  • Image Build = ( GitHub Actions | CodePipeline )
    • Dockerイメージのビルドをどこで実行するか
  • Migration = ( GitHub Actions | CodePipeline )
    • Migration処理をどこで実行するか
  • Deploy = ( GitHub Actions | CodePipeline )
    • Deploy実行をどこから行うか
    • デプロイツールに何を使うかで更に細分化されます
  • イメージ数 = 1 or multi
    • アプリケーション用リポジトリからいくつのイメージをビルドするか
    • NginxとRails用コンテナのイメージをビルドする場合等で他の選択肢に影響がでる

これらの組み合わせを考慮すると、パターンは以下のようになります。

No. Image Build Migration Deploy イメージ数
1 GitHub Actions GitHub Actions GitHub Actions 1
2 GitHub Actions GitHub Actions CodePipeline 1
3 GitHub Actions CodePipeline CodePipeline 1
4 CodePipeline CodePipeline CodePipeline 1
5 GitHub Actions GitHub Actions GitHub Actions multi
6 GitHub Actions GitHub Actions CodePipeline multi
7 GitHub Actions CodePipeline CodePipeline multi
8 CodePipeline CodePipeline CodePipeline multi

それでは、1パターンずつ構成を確認していきます。

1. 全部GitHub Actionsでやる

GitHub Actionsでパイプライン全体を構築する場合は、以下の図のような構成になります。

ちなみに、GitHubのDeploymentsを使用することで、Slackと組み合わせてのChatOpsや、任意のコミットのデプロイを実行などいろいろと便利になります。
ですが、Deployments APIをパイプラインで利用するには、Actionsのトリガーの条件を変えたり、ECSへのデプロイの成否に応じてAPIを使ってDeploymentのステータスを更新したりする処理が必要になり、少々パイプラインが複雑化してしまいます。
なので、今回の趣旨とちょっとずれているため、各パターンには記載していません。

では、以下で処理を順に説明していきます。

Image Build

図中の Build Image の部分です。
これは難しいことはありません。
下記のように便利なActionsが公開されているので、これらを組み合わせて実行していきます。

実装は以下のようになります。

name: deploy

on:
  push:
    branches:
      - main

env:
  ECR_REPOSITORY: my-repo
  DOCKER_BUILDKIT: 1

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v1

      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build and Push
        uses: docker/build-push-action@v2
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        with:
          push: true
          tags: |
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest

Migration

図中の Download TaskDef TemplateCreate TaskDefMigration on RunTask に該当します。
GitHub ActionsからVPCに閉じたRDSに対してMigrationを実行するのには少し工夫が必要です。
ECSの RunTask機能 を利用して、VPC内に起動したECSタスクでMigrationコマンドを実行するという方法があります。

awscliでECSタスク定義の登録とRunTaskの実行をしても良いですが、これらの処理も便利なActionsが公開されているので、そちらを利用することも可能です。

      - name: Download TaskDefinition for migration
        run: |
          aws s3 cp s3://my-bucket/task_definition_migration.json .

      - name: Fill in the new image ID for migration
        id: migration-task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        with:
          task-definition: task_definition_migration.json
          container-name: my-container
          image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}

      - name: Create Task Definition
        id: create-task-def
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.migration-task-def.outputs.task-definition }}
      
      # NOTE: ARNを foo:1 のような文字列に変換する
      - name: Get Task String
        id: split-task-def
        uses: jungwinter/split@v1
        with:
          msg: ${{ steps.create-task-def.outputs.task-definition-arn }}
          seperator: /  

      - name: Run migration
        uses: sinsoku/amazon-ecs-run-task-definition@v1
        with:
          task-definition: ${{ steps.split-task-def.outputs._1 }}
          container: my-container
          command: '["migration", "command"]'
          service: my-service
          cluster: my-cluster
          wait-for-stopped: true

Deploy

図中の Deploy の部分です。
ECSサービス側でCodeDeployを利用する設定となっている場合は、CodeDeployでのデプロイをActions上から実行する必要があります。
上記ではCodeDeployを利用する想定の図となっています。
aws-actions/amazon-ecs-deploy-task-definition がCodeDeployもサポートしているので、特に理由がなければこのActionsを利用するのが良いでしょう。

      - name: Download TaskDefinition and appspec
        run: |
          aws s3 cp s3://my-bucket/task_definition.json .
          aws s3 cp s3://my-bucket/appspec.yml .

      - name: Fill in the new image ID
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        with:
          task-definition: task_definition.json
          container-name: my-container
          image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}

      - name: Deploy to Amazon ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          cluster: my-cluster
          service: my-service
          wait-for-service-stability: true
          codedeploy-appspec: appspec.yml
          codedeploy-application: my-app
          codedeploy-deployment-group: my-group

上記の図とは異なりますが、CodeDeployに制約されていない場合は、awscliでのデプロイや aws-actions/amazon-ecs-deploy-task-definition を利用したデプロイも可能です。

    - name: Deploy to Amazon ECS
      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
      with:
        task-definition: task-definition.json
        service: my-service
        cluster: my-cluster
        wait-for-service-stability: true

2. MigrationまでGitHub Actionsでやる

MigrationまでActions上でやったなら、デプロイもActions上でやればいいじゃんとなるので、このパターンは実際に選択することはほぼ無いと思われます。
割愛します。

3. Image BuildのみGitHub Actionsでやる

Image BuildはGitHub Actions上で行い、MigrationとDeployをCodePipelineを使って行う構成です。
全体の構成は次の図のようになります。

CodePipelineのトリガー

トリガーには、ECRへのlatest タグがついたイメージのPushを指定します。
PushされたイメージのURIはInput ArtifactとしてCodePiepelineの以降のアクションで参照できます。

Migration

GitHub Actions側からMigrationを行う場合は、VPC内から実行しないとRDSに接続できない都合上、RunTask機能でECSタスクを起動していました。
CodePipeline中でCodeBuildをVPC内起動オプションと共に使えば、CodeBuild上からRDSへ接続できます。

そのため、RunTaskでECSタスクを起動する必要はなく、作成されたDockerイメージを使って docker run <option省略> migrate_command のように実行すればMigrationを行うことができます。

例えば、Ruby on RailsのMigrationをCodeBuildで行う場合は下記のようなbuildspec.ymlになります。

buildspec.yml
version: 0.2

env:
  parameter-store:
    RAILS_MASTER_KEY: ${ssm_rails_master_key_name}

phases:
  pre_build:
    commands:
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${account_id}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
  build:
    commands:
      # NOTE: Get Image URI from imageDetail.json
      - IMAGE=$(cat imageDetail.json | jq -r '.ImageURI')

      # NOTE: execute db:migrate
      - docker run --rm -e RAILS_MASTER_KEY -e DATABASE_URL $IMAGE rails db:migrate

タスク定義ファイルの取得

CodeDeployでデプロイを行うための、タスク定義ファイルとappspecファイルをS3から取得します。
特に中身を修正する必要は無いので、そのままArtifactsに設定します。

phases:
  build:
    commands:
      # 中略
      - aws s3 cp s3://${bucket}/${task_definition_key} ./task_definition.json
      - aws s3 cp s3://${bucket}/${appspec_key} ./appspec.yml

artifacts:
  files:
    - task_definition.json
    - appspec.yml

Deploy

CodePipelineの設定で、CodeDeployに以下の情報を与えてデプロイを実行します。

  • タスク定義ファイル
  • appspecファイル
  • imageDetails.json

これらの情報をCodeDeployに渡すことで、 imageDetails.json を利用して、タスク定義ファイル中の特定のコンテナのイメージを、置換して新たにタスク定義が作成されます。(自分で aws ecs register-task-definition 等を実行する必要はありません。)

このパターンのCodePipelineを構築するTerraformの実装は以下のようになります。

codepipeline.tf
resource "aws_codepipeline" "deploy" {
  name     = "${local.name}-deploy"
  role_arn = aws_iam_role.codepipeline.arn

  artifact_store {
    location = aws_s3_bucket.codepipeline.bucket
    type     = "S3"
  }

  stage {
    name = "Source"

    action {
      name             = "Source"
      category         = "Source"
      owner            = "AWS"
      provider         = "ECR"
      version          = "1"
      output_artifacts = ["source_output"]

      configuration = {
        RepositoryName = aws_ecr_repository.rails.name
        ImageTag       = "latest"
      }
    }
  }

  stage {
    name = "Build"

    action {
      name             = "BuildRails"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["source_output"]
      output_artifacts = ["build_output"]
      version          = "1"

      configuration = {
        ProjectName = aws_codebuild_project.build.name
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name            = "Deploy"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "CodeDeployToECS"
      version         = "1"
      input_artifacts = ["source_output", "build_output"]

      configuration = {
        ApplicationName                = aws_codedeploy_app.app.name
        DeploymentGroupName            = aws_codedeploy_deployment_group.app.deployment_group_name
        TaskDefinitionTemplateArtifact = "build_output"
        TaskDefinitionTemplatePath     = "task_definition.json"
        AppSpecTemplateArtifact        = "build_output"
        AppSpecTemplatePath            = "appspec.yml"
        # NOTE: using image in imageDetail.json from source_output.
        # see: https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-ECR.html
        Image1ArtifactName  = "source_output"
        Image1ContainerName = "IMAGE1_NAME"
      }
    }
  }
}

4. 全部CodePipelineでやる

デプロイパイプライン全てをAWS側に寄せることで、シンプルな構成となります。
CodePipelineのお手本通りの使い方になるので、ドキュメントも豊富にある点が利点です。

構成は下の図のようになります。

基本的にはMigration以降はパターン3と同じなので省略します。

ソースコードの取得

CodePipelineでGitHubのソースコードのpushをトリガーにする場合、CodeStarSourceConnectionのGitHubバージョン2を利用することが推奨されています。
このリソースを作成すると、GitHubへアプリケーションが登録され、そのアプリケーション経由でソースコードの取得やpush時のパイプラインのトリガーが実行されます。

TerraformによるCodePipelineの実装は下記のようになります。

codepipeline.tf
resource "aws_codepipeline" "deploy" {
  name     = "${local.name}-deploy"
  role_arn = aws_iam_role.codepipeline.arn

  artifact_store {
    location = aws_s3_bucket.codepipeline.bucket
    type     = "S3"
  }

  stage {
    name = "Source"

    action {
      name             = "Source"
      category         = "Source"
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      version          = "1"
      output_artifacts = ["source_output"]

      configuration = {
        ConnectionArn        = aws_codestarconnections_connection.github.arn
        FullRepositoryId     = "my-organization/my-repository"
        BranchName           = "main"
        OutputArtifactFormat = "CODE_ZIP"
      }
    }
  }

  # 以下略
}

resource "aws_codestarconnections_connection" "github" {
  name          = "${local.name}-github"
  provider_type = "GitHub"
}

Image Build

GitHub Actions上で行うのとなんら変わりありません。
同じようにイメージビルドをCodeBuild上で実行するように buildspec.yml を定義します。

buildspec.yml
version: 0.2

env:
  variables:
    DOCKER_BUILDKIT: 1
  parameter-store:
    RAILS_MASTER_KEY: ${ssm_rails_master_key_name}

phases:
  pre_build:
    commands:
      - IMAGE_TAG=$CODEBUILD_RESOLVED_SOURCE_VERSION
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${account_id}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
  build:
    commands:
      - docker build -e RAILS_MASTER_KEY -t ${rails_repository}:$IMAGE_TAG .
      - docker tag ${rails_repository}:$IMAGE_TAG ${rails_repository}:latest

      # Migration以降は省略

  post_build:
    commands:
      - docker push ${rails_repository}:$IMAGE_TAG
      - docker push ${rails_repository}:latest

5. 全部GitHub Actionsでやる(複数イメージ)

パターン1と特に違いはありません。
イメージビルドを並列実行し、全イメージビルドが完了したらデプロイを実施するように設定すればOKです。

6. MigrationまでGitHub Actionsでやる(複数イメージ)

こちらもパターン2と同様です。
MigrationまでActions上でやったなら、デプロイもActions上でやればいいじゃんとなるので、このパターンは実際に選択することはほぼ無いと思われます。
割愛します。

7. Image BuildのみGitHub Actionsでやる(複数イメージ)

GitHub ActionsからCodePipelineへの接続部分でちょっとトリッキーな構成にする必要があります。
単一イメージの場合は、CodePipelineのトリガーをECRへのプッシュにすることができました。
複数イメージの場合、全てのイメージがECRに格納された後で、CodePipelineをトリガーしないといけません。
そのため、GitHub Actions上で各イメージのビルドが終了してから、イメージ情報をCodePipelineに渡す形でCodePipelineをトリガーする必要があります。
トリガーにS3のオブジェクトのPushを使うことで、下記のような構成で実現可能です。

  • イメージビルドをGitHub Actions上で並列で行う
  • 全イメージのビルドが終わったら、ActionsからS3にビルドしたイメージのタグ = コミットハッシュを含めたオブジェクトを配置
  • CodePipelineのSourceステージをS3に設定し、S3へのオブジェクトの設置をトリガーにする
  • CodePipeline上で、上記で設置したS3オブジェクトから使用するイメージタグを取得する
  • CodeBuildでイメージをECRから取得し、このイメージを使ってMigrationを実行する
  • デプロイを行う

Actions上でawscliでCodePipelineをスタートすることもできるのですが、CodePipelineのスタート時に環境変数等の引数を伝える術が無いため、このようにS3上のオブジェクトを経由してデプロイするべきイメージのタグを伝達しています。

8. 全部CodePipelineでやる(複数イメージ)

基本的にはパターン4と同じです。
CodePipelineのステージとアクションの組み合わせで、「複数イメージの並列ビルド」「全イメージのビルドが終わったらデプロイ」といったパイプラインが実現できます。
構成的には下記の図のようになります。

Migrationは依存する特定のイメージビルドと同じCodeBuild上で実施しても問題ありません。
この例ではイメージビルドとは別のCodeBuildで実行しています。

考察

境界に関して

例として、(Web)アプリケーションエンジニアとインフラエンジニアがいたとしましょう。
リポジトリに関してもアプリケーションコードとインフラのコードでリポジトリが別れているとします。
この時、アプリケーションエンジニアはデプロイパイプラインのどこまでを構築すべきでしょうか?
インフラエンジニアがイメージビルドまでメンテナンスすべきでしょうか?

パターン1, 5の場合ですと、GitHub Actionsで全部パイプラインを構築する都合上、アプリケーション側リポジトリにパイプラインの実装が寄ります。アプリケーション側リポジトリのコードはアプリケーションエンジニアが責任を持つのが相応しいので、アプリケーションエンジニアがパイプラインのメンテナンスを行うことになりそうです。
もしくは下の図のように、インフラエンジニアがリポジトリをまたいでパイプラインを管理するといったことになる可能性もあります。

パターン4, 8の場合、デプロイパイプラインは全てインフラ側のリポジトリでTerraform等を利用して管理されることになるでしょう。Dockerfile自体はアプリケーション側リポジトリで管理されると思いますが、アプリケーションエンジニアがDockerイメージビルドの最適化やビルド高速化といった改善を実施しなくなる将来が予見できます。

以上のことから、私はアプリケーションエンジニアとインフラエンジニアの責任の境界を、DockerイメージのPush にするのが相応しいと考えます。(もちろん、チームのメンバー構成などによりそれ以外の境界が相応しい場合もありますが)

イメージのビルドはアプリケーション側のリポジトリのCIなどで行い、事前にインフラエンジニアから指定されたECRリポジトリにイメージをPushします。
インフラエンジニアはイメージのPushをトリガーにECSへデプロイを行うパイプラインを実装します。
このパイプラインはインフラ側のリポジトリでTerraform等のIaCツールで管理します。

この境界に当てはめると、パターン3が最も相応しいパイプラインの構成となります。
パターン3を運用したイメージを表にしてみます。

アプリケーションエンジニア インフラエンジニア
リポジトリ Rails用リポジトリ Terraform用リポジトリ
リポジトリの中身 Railsアプリ、Dockerfile、Actionsの設定 AWS上のインフラのコード全般
CDパイプラインのメンテ範囲 イメージビルドまで Migration実行とデプロイ

エンジニアの責務とリポジトリの内容も一致しており良さそうです。
また、CDパイプラインもイメージのPush部分で連携しているため疎結合になっています。
仮に、インフラを別のPaaSに乗り換える場合でも、アプリケーション側のCDパイプライン部分の変更は軽微でしょう。

一方で、イメージを複数ビルドする場合のパターン7も境界はイメージのPushになっているのですが、全イメージのビルドを待ってからデプロイパイプラインを開始しなければいけない都合上、パターン3よりもだいぶ複雑なパイプラインとなるので、個人的にはあまりオススメできません

境界的には パターン3 > パターン4 = パターン1 といったところでしょうか。
複数イメージをビルドする場合も同様と考えてよいでしょう。

実装のシンプルさ(コード量)

シンプルさ(コード量)のみで言えば、すべてGitHub Actionsで行うパターン1が最も簡潔な記述になると思われます。(短いコードは正義です)
様々なActionsを活用しているおかげですね。

一方で、CodeDeployのグループ名やECSサービス名など、インフラ側で定義されているリソースの名称やIDをActions上にかなりの量記述しなければなりません。
Actionsの設定と、インフラの実装がかなり密結合となってしまいます。

このようなリポジトリをまたぐ密結合はサービスや組織のスケールの妨げとなることがしばしばあるため、目先の実装のシンプルさを優先するか、将来のスケールやメンテナンス性を重視するかで採用を判定するのが良さそうです。

趣味で行う個人開発のような、一人で全部やる場合は全部Actionsで良いかなと個人的には考えています。

ということで実装のシンプルさで言うと、 パターン1 > パターン3 > パターン4 というイメージです。
複数イメージの場合は パターン5 > パターン8 = パターン7 となります。

実装難易度

筆者は今回掲載したパターンすべて構築したことがありますが、個人的にはGitHub Actionsによせる方法はわりと難易度が高い気がしています。
GitHub Actionsがリリースされてそんなに経っていないことから日本語のドキュメントは多くなく、基本的に公式ドキュメントやActionsの実装を読みながらパイプラインを構築していきます。
また、CIやパイプラインにありがちなのですが、テストコードやテスト環境といったものを用意することが困難だったりします。

特にGitHub Actionsに寄せるパターン1やパターン5の場合、GitHub ActionsとAWSをそれなりに理解した上で、アプリケーション側のリポジトリにこれらのパイプラインを構築することになります。

構築初期は、できるエンジニアが構築してしまえば良いですが、将来のメンテナンスなどを考えると、少し覚悟のいる選択のような気がします。
(あくまで個人の主観です)

ということで、個人的主観での難易度は (難易度低) パターン4 > パターン3 > パターン1 (難易度高) な気がします。

複数イメージの場合

サービスの要件によっては、アプリケーションのリポジトリ1つからサイドカー含めた複数のイメージをビルドしてデプロイする必要があります。
この場合、パターン7で示したように、ECRへのイメージプッシュを境界にすることができず、少しトリッキーな構成になってしまいます。
そのような場合は実装のシンプルさからパターン5 or パターン8が候補になってきますが、エンジニアの責務やスキルを考えるとパターン8のCodePipelineでイメージビルドからデプロイまで全てを実行するのが良さそうに思えます。

まとめ

ECSへのデプロイパイプラインを数パターン挙げ、「エンジニアの責任の境界」「実装のシンプルさ」「実装難易度」などの観点で見てきました。
どの選択肢が最適であるかは、チーム構成やサービス構成によって変化してくるかと思いますが、本記事が検討の一助となれば幸いです。

個人的には、一人で行うプライベートの開発の場合はパターン1、業務の場合はパターン3を主に選択していきそうな気がしています。


関連記事

https://tech.medpeer.co.jp/entry/2020/11/24/090000

Discussion

iwasakiiwasaki

こんにちは。
興味深く読ませていただきました。
エンジニアの責任の境界の観点から、3を検討しているのですが、latestタグだけしか取得できない点が困っています。
このあたり、どう解決されていますでしょうか?