🏗️

Terraform : ECS on Fargate + CI/CD (AWS CodeSeries)

2025/03/20に公開
4

概要

以前に「Terraform : ECS on Fargate + CI/CD (GitHub Actions)」という記事において、ECSコンテナ環境をTerraformで構築し、GitHub Actionsを用いてCI/CDを実装しました。本記事では同様のCI/CD処理をAWS CodeSeries (CodePipeline, CodeBuild, CodeDeploy) を使用して実現します。

構成図

初期構成 実装

TerraformコードはGitHubに記載しています。本記事では、CodeSeriesを用いたCI/CDに焦点を当てて解説しますので、CI/CD以外のAWS初期構成に関しては以下の記事をご確認ください。

https://zenn.dev/wakaka23/articles/d6867737436c8e

CI/CD 実装

AWSのCodeSeriesを使用してCI/CDを実装していきます。今回の構成におけるCI/CD処理の全体像、各サービス間の大まかな流れは以下の図に示したようになっています。次項からそれぞれの処理の詳細を確認していきます。

ディレクトリ構成(CI/CD部分 抜粋)
├─ environments
│  ├─ prd
│  ├─ stg
│  └─ dev
│     ├─ main.tf
│     ├─ local.tf
│     ├─ variable.tf
│     └─ (terraform.tfvars)
│
├─ modules(各Moduleにmain.tf,variable.tf,output.tfが存在)
│  └─ ecs_backend(Backend ECS関連、CI/CD関連のリソース)
│
├─ backend_app(CI/CD対象となるバックエンドアプリ)
├─ buildspec.yml(CodeBuildによるビルド処理内容を記載)
├─ taskdef.json(ECSタスク定義)
└─ appspec.yml(CodeDeployによるデプロイ処理内容を記載)

0. GitHubとAWSサービスを統合

CodePipelineのSourceステージとしてGitHubを利用する大前提として、CodeConnectionsを使用して両者を統合させる必要があります。今回の構成では、Connectionは手動で作成し、以下のようにTerraform上にてData Sourceで参照するような形を取っています。Terraform管理下に置かないことで、terraform destroyの際などに再度作成する必要がない構成としています。

modules/ecs_backend/main.tf(CodeConnectionsリソース 抜粋)
# Define CodeConnection for GitHub Repository
data "aws_codestarconnections_connection" "github" {
  arn = var.code_pipeline.code_connection_arn
}

そして、CodePipelineのSourceステージにおいて、GitHubと統合したConnection, 対象リポジトリを指定することで、GitHubリポジトリをCodePipelineのソースプロバイダとして認識することができるようになります。

modules/ecs_backend/main.tf(CodePipelineリソース 抜粋)
# Define CodePipeline
resource "aws_codepipeline" "backend" {
  name           = "${var.common.env}-backend-codepipeline"
  role_arn       = aws_iam_role.codepipeline.arn
  pipeline_type  = "V2"
  execution_mode = "QUEUED"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  stage {
    name = "Source"

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

      configuration = {
        ConnectionArn    = data.aws_codestarconnections_connection.github.arn
        FullRepositoryId = "${var.code_pipeline.github_repository_owner}/${var.code_pipeline.github_repository_name}"
        BranchName       = var.code_pipeline.github_repository_branch_name
      }
    }
  }
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
}

1. 特定ブランチへPush

2. Pushを契機にCodePipeline起動

CodePipelineではGitHub Actionsと同様に、CI/CD発動の契機を定義することができます(参考)。今回の構成では「mainブランチに対して、backend_app/handler/配下のgoファイルをPush」したことを契機にCodePipelineを起動するように設計しています。

modules/ecs_backend/main.tf(CodePipelineリソース 抜粋)
# Define CodePipeline
resource "aws_codepipeline" "backend" {
  name           = "${var.common.env}-backend-codepipeline"
  role_arn       = aws_iam_role.codepipeline.arn
  pipeline_type  = "V2"
  execution_mode = "QUEUED"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  trigger {
    provider_type = "CodeStarSourceConnection"
    git_configuration {
      source_action_name = "Source"
      push {
        branches {
          includes = [var.code_pipeline.github_repository_branch_name]
        }
        file_paths {
          includes = ["backend_app/handler/*.go"]
        }
      }
    }
  }
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
}

以下の表の「GitHub (via GitHub App)」に記載のように、内部的にはCodeConnectionsがGitHubリポジトリに対するPushを検知してCodePipelineを起動するイベント駆動検知となっているようです(参考)。CodeCommitでイベント駆動検知を実現するためにはEventBridgeを用いる必要があるため、余計なリソースが増えるだけでなく、クロスアカウント構成を組むのが少々面倒でした。この点、CodeConnectionsはRAMによるクロスアカウント共有に対応しているため、比較的容易にアカウント間のCI/CDを実装することができます(参考)。これらの観点はCodeConnectionsを使用する明確な利点となりそうですね。

For a CodeStarSourceConnection source action, you do not have to set up a webhook or default to polling. The connections action manages your source change detection for you. (参考)

3. SourceArtifactにGitHubのコードを複製

リポジトリへのPushを契機にCodePipelineが起動すると、初めにアーティファクト用S3バケットにGitHubのコードを複製します。output_artifactsで定義したSourceArtifactというディレクトリにこれらのコードは格納されます。実際には、アーティファクト名がSourceArtiのように切り詰められてしまいますが、これはCodepipelineのポリシーサイズにおける制約であり、パイプラインは正常に動作するとのことです(参考)。

modules/ecs_backend/main.tf(CodePipelineリソース 抜粋)
# Define CodePipeline
resource "aws_codepipeline" "backend" {
  name           = "${var.common.env}-backend-codepipeline"
  role_arn       = aws_iam_role.codepipeline.arn
  pipeline_type  = "V2"
  execution_mode = "QUEUED"

  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 = ["SourceArtifact"]
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
}

4. CodePipelineよりCodeBuildが呼び出されビルド実行環境起動

5. ビルド実行環境にコードを複製

Buildステージに遷移すると、CodePipelineによりCodeBuild Projectが呼び出されます。CodeBuild Projectの実体はimageで定義されたイメージから作成されたコンテナ上に構築されるビルド実行環境です。CodeBuild Projectが起動すると、最初にアーティファクト用S3バケットからコンテナ上にSourceArtifact内のファイルを複製します。ここで注意すべき点として、CodeBuild Projectのsourceで定義された値よりも、CodePipeline Buildステージのinput_artifactsで定義された値が優先されることが挙げられます。つまり、CodeBuild ProjectのsourceでGitHubリポジトリを指定したとしても、Buildステージのinput_artifactsでS3バケットを指定していた場合には、S3バケットに格納されているソースアーティファクトが複製されます(参考)。

The artifact configured in your CodeBuild project becomes the input artifact used by the CodeBuild action in your pipeline.(参考

modules/ecs_backend/main.tf(CodeBuildリソース 抜粋)
# Define CodeBuild project
resource "aws_codebuild_project" "backend" {
  name         = "${var.common.env}-backend-codebuild"
  service_role = aws_iam_role.codebuild.arn

  source {
    type     = "GITHUB"
    location = "https://github.com/${var.code_pipeline.github_repository_owner}/${var.code_pipeline.github_repository_name}"
  }

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

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/amazonlinux-x86_64-standard:5.0"
    type         = "LINUX_CONTAINER"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  }
}
modules/ecs_backend/main.tf(CodePipelineリソース 抜粋)
# Define CodePipeline
resource "aws_codepipeline" "backend" {
  name           = "${var.common.env}-backend-codepipeline"
  role_arn       = aws_iam_role.codepipeline.arn
  pipeline_type  = "V2"
  execution_mode = "QUEUED"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  stage {
    name = "Build"

    action {
      name             = "Build"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      version          = "1"
      input_artifacts  = ["SourceArtifact"]
      output_artifacts = ["BuildArtifact"]

      configuration = {
        ProjectName = aws_codebuild_project.backend.name
      }
    }
  }
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
}

6. イメージのビルド→ECRにPush

CodeBuild Projectはbuildspec.ymlに従って以下のような処理を実行します。

pre_build

  • ECRにログイン
    ECRはDockerの標準的なAPIを実装しているため、DockerクライアントがECRプライベートレジストリにアクセスするためには、他のDockerプライベートレジストリと同様に認証が必要です。aws ecr get-login-passwordにより取得した一時的な認証情報でログインします。
  • Docker Hubにログイン
    Docker HubからのコンテナイメージPull回数のレート制限である「CodeBuild環境に割り当てられるGlobal IP当たり100回/6時間」に抵触しないためにユーザ認証をする必要があります。これにより「200回/6時間」というレート制限を占有できるようになります(参考)。
  • コミットハッシュを取得
    今回の構成では、ビルドするコンテナイメージに付与するタグとしてコミットハッシュを採用しています。CodeBuildのビルド実行環境では様々な環境変数が事前に定義されており、CODEBUILD_RESOLVED_SOURCE_VERSIONによりコミットハッシュを取得することが可能です。最終的には、パイプ処理により得た短縮形ハッシュをタグに使用しています(参考)。

build

  • コンテナイメージのビルド
    アーティファクト用S3バケットから取得したコードに格納されたDockerfileに従い、コンテナイメージをビルドします。注意点ですが、ECRにイメージをPushするためにはイメージ名を${REPOSITORY_URI}:${IMAGE_TAG}という形式にする必要があります(参考)。

post_build

  • イメージをECRにPush
    ${REPOSITORY_URI}:${IMAGE_TAG}という形式にしたイメージをECRにPushします。
buildspec.yml(ビルド部分 抜粋)
phases:
  pre_build:
    commands:
      - echo "Login to Amazon ECR"
      - aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com
      - echo "Login to Docker Hub"
      - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
      - echo "Get commit hash"
      - IMAGE_TAG=$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} | cut -c 1-7)
  build:
    commands:
      - echo "Build Docker image"
      - docker build -f ./backend_app/Dockerfile -t ${REPOSITORY_URI}:${IMAGE_TAG} ./backend_app
  post_build:
    commands:
      - echo "Push Docker image to Amazon ECR"
      - docker push "${REPOSITORY_URI}:${IMAGE_TAG}"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~

7. イメージURLを格納 + taskdef更新

CodeBuildではコンテナイメージをビルド・Pushするだけでなく、以下のようにしてCodeDeployで使用するビルドアーティファクトを準備します。

  • イメージURLを格納
    まずはビルド・PushしたコンテナイメージのURLをimageDetail.jsonに格納します。詳細はDeployステージの方で説明しますが、CodeDeployがimageDetail.jsonに格納されたイメージURLを自動でtaskdef.jsonに書き込んで、タスク定義を完成させてくれます。
  • taskdef.jsonを更新
    以下に示すように、taskdef.jsonには様々な変数を定義しています。aws_codebuild_projectenvironment_variableにおいて各変数に対応する環境変数をビルド実行環境に作成し、sedコマンドで置換することで、teskdef.json内の変数に値を格納しています。
  • アーティファクト格納
    ビルドアーティファクトとして、imageDetail.json, appspec.yml, taskdef.jsonをアーティファクト用S3バケットのBuildArtifactに格納します。
buildspec.yml(ビルド後作業部分 抜粋)
phases:
  pre_build:
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  build:
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  post_build:
    commands:
      - echo "Push Docker image to Amazon ECR"
      - docker push "${REPOSITORY_URI}:${IMAGE_TAG}"
      - printf '{"Version":"1.0","ImageURI":"%s"}' ${REPOSITORY_URI}:${IMAGE_TAG} > imageDetail.json
      - echo "Create new revision of the task definition"
      - sed -ie "s#<TASK_FAMILY>#${TASK_FAMILY}#" taskdef.json
      - sed -ie "s#<TASK_EXECUTION_ROLE_ARN>#${TASK_EXECUTION_ROLE_ARN}#" taskdef.json
      - sed -ie "s#<SECRETS_FOR_DB_ARN>#${SECRETS_FOR_DB_ARN}#" taskdef.json
      - sed -ie "s#<REGION>#${REGION}#" taskdef.json
      - sed -ie "s#<LOG_GROUP_NAME>#${LOG_GROUP_NAME}#" taskdef.json
      - echo "$(cat taskdef.json)"
artifacts:
  files:
    - imageDetail.json
    - taskdef.json
    - appspec.yml
taskdef.json
{
  "family": "<TASK_FAMILY>",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "networkMode": "awsvpc",
  "executionRoleArn": "<TASK_EXECUTION_ROLE_ARN>",
  "containerDefinitions": [
    {
      "name": "app",
      "image": "<IMAGE1_NAME>",
      "cpu": 256,
      "memory": 512,
      "essential": true,
      "secrets": [
        {
          "name": "DB_HOST",
          "valueFrom": "<SECRETS_FOR_DB_ARN>:host::"
        },
        {
          "name": "DB_NAME",
          "valueFrom": "<SECRETS_FOR_DB_ARN>:dbname::"
        },
        {
          "name": "DB_USERNAME",
          "valueFrom": "<SECRETS_FOR_DB_ARN>:username::"
        },
        {
          "name": "DB_PASSWORD",
          "valueFrom": "<SECRETS_FOR_DB_ARN>:password::"
        }
      ],
      "portMappings": [{"containerPort": 80}],
      "readonlyRootFilesystem": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "<REGION>",
          "awslogs-group": "<LOG_GROUP_NAME>",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}
modules/ecs_backend/main.tf(CodeBuildリソース 抜粋)
# Define CodeBuild project
resource "aws_codebuild_project" "backend" {
  name         = "${var.common.env}-backend-codebuild"
  service_role = aws_iam_role.codebuild.arn
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/amazonlinux-x86_64-standard:5.0"
    type         = "LINUX_CONTAINER"
    environment_variable {
      name  = "REGION"
      value = var.common.region
    }
    environment_variable {
      name  = "AWS_ACCOUNT_ID"
      value = var.common.account_id
    }
    environment_variable {
      name  = "DOCKER_USERNAME"
      value = var.code_pipeline.docker_username
    }
    environment_variable {
      name  = "DOCKER_PASSWORD"
      value = var.code_pipeline.docker_password
    }
    environment_variable {
      name  = "REPOSITORY_URI"
      value = var.ecr_repository.backend_repository_uri
    }
    environment_variable {
      name  = "TASK_FAMILY"
      value = "${var.common.env}-backend-def"
    }
    environment_variable {
      name  = "TASK_EXECUTION_ROLE_ARN"
      value = aws_iam_role.task_execution_role.arn
    }
    environment_variable {
      name  = "SECRETS_FOR_DB_ARN"
      value = var.secrets_manager.secret_for_db_arn
    }
    environment_variable {
      name  = "LOG_GROUP_NAME"
      value = aws_cloudwatch_log_group.backend.name
    }
  }
}

8. CodePipelineよりCodeDeployが呼び出される

9. BuildArtifactよりファイル取得

10. appspecを更新→タスク更新

Deployステージに遷移すると、CodePipelineによりCodeDeployが呼び出されます。CodeDeployでは以下のパラメータにより指定した格納場所から各ファイルを取得します(参考)。今回の構成では、格納場所としてBuildArtifactを指定し、ビルドアーティファクトを取り込んでいます。

  • TaskDefinitionTemplateArtifact:taskdef.jsonの格納場所
  • AppSpecTemplateArtifact:appspec.jsonの格納場所
  • Image1ArtifactName:taskdef.jsonの<IMAGE1_NAME>に格納するイメージURLを記載しているimageDetail.jsonの格納場所
modules/ecs_backend/main.tf(CodePipelineリソース 抜粋)
# Define CodePipeline
resource "aws_codepipeline" "backend" {
  name           = "${var.common.env}-backend-codepipeline"
  role_arn       = aws_iam_role.codepipeline.arn
  pipeline_type  = "V2"
  execution_mode = "QUEUED"
~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  stage {
    name = "Deploy"

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

      configuration = {
        ApplicationName                = aws_codedeploy_app.backend.name
        DeploymentGroupName            = aws_codedeploy_deployment_group.backend.deployment_group_name
        TaskDefinitionTemplateArtifact = "BuildArtifact"
        AppSpecTemplateArtifact        = "BuildArtifact"
        Image1ArtifactName             = "BuildArtifact"
        Image1ContainerName            = "IMAGE1_NAME"
      }
    }
  }
}

取得したビルドアーティファクトを用いてCodeDeployがappspec.ymlを更新します。
まず、CodeDeployはImage1ArtifactNameとして定義したアーティファクト内のimageDetail.jsonに記載されたイメージURLをtaskdef.json内の<IMAGE1_NAME>に代入します。この段階で、taskdef.jsonに定義されていた全ての変数は実際の値で置換されている状態となります。

the CodeDeployToECS action replaces the placeholder <IMAGE1_NAME> into actual image URI retrieved from imageDetail.json in the artifact which you specify as Image1ArtifactName.(参考

そして、CodeDeployがappspec.yml内の<TASK_DEFINITION>を更新後のタスク定義で置換することで、最終的にタスク更新に利用されるappspec.ymlの作成が完了し、デプロイが開始されます。

TaskDefinition: <TASK_DEFINITION>
This property will be updated by the CodeDeployToECS action after the new task definition is created.For the value of the TaskDefinition field, the placeholder text must be <TASK_DEFINITION>. The CodeDeployToECS action replaces this placeholder with the actual ARN of the dynamically generated task definition.(参考

動作確認

初期構築

冒頭でも述べたように、本記事ではCodeSeriesを用いたCI/CDに焦点を当てて解説しています。初期構築時の設定値は基本的に以下の記事と同等なので、こちらをご確認いただければと思います。
https://zenn.dev/wakaka23/articles/d6867737436c8e

CI/CDによるB/Gデプロイメント実行

初期構築時点でフロントエンドアプリケーションのトップページにアクセスすると、バックエンドアプリケーションからAPIレスポンスとして取得した「Hello world」が出力されます。

backend_app/handler/helloworld_handler.go
package handlers

import (
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/uma-arai/sbcntr-backend/domain/model"
)

// HelloWorldHandler ...
type HelloWorldHandler struct {
}

// NewHelloWorldHandler ...
func NewHelloWorldHandler() *HelloWorldHandler {
	return &HelloWorldHandler{}
}

// SayHelloWorld ...
func (handler *HelloWorldHandler) SayHelloWorld() echo.HandlerFunc {
	body := &model.Hello{
		Data: "Hello world",
	}
	return func(c echo.Context) error {
		return c.JSON(http.StatusOK, body)
	}
}

上記のhelloworld_handler.goにおいて、Hello worldという部分をHello world! CI/CD succeeded!に変更し、Commit/Pushします。CI/CD処理が実行され、トップページにおいても「Hello world! CI/CD succeeded!」という文字列が出力されることを確認します。

【S3にソースアーティファクト格納】
helloworld_handler.goを編集/Commit/Pushをすると、CI/CD処理が実行されます。アーティファクト用S3バケットにはSourceArtifactというディレクトリが作成され、ソースアーティファクトが格納されていることを確認できます。

【ECSタスク定義更新】
CodeBuildがソースアーティファクト内のDockerfileによりコンテナイメージをビルドします。ECRにはコミットハッシュをタグとして持つ新たなBackend用コンテナイメージ(cntr-codeseries-backend:a246f2f)が作成され、このコンテナイメージを使用した新たなECSタスク定義も作成されます。

【S3にビルドアーティファクト格納】
CodeBuildはECRにコンテナイメージをPushした後に、一部ファイルを更新し、ビルドアーティファクトとして出力します。アーティファクト用S3バケットにはBuildArtifactというディレクトリが作成され、ビルドアーティファクトが格納されていることを確認できます。

【CodeDeployによるB/Gデプロイ】
以前の記事でも解説しているので詳細は割愛しますが、CodeDeployはappspec.ymlを参照し、「テストトラフィック設定→本稼働トラフィック再ルーティング→元のタスクセット終了」という過程を経て、B/Gデプロイメントを完了します。CodePipelineにおいても、すべてのステージのアクションが成功していることを確認することができます。

【アプリケーションに接続】
再びフロントエンドアプリケーションのトップページにアクセスすると、「Hello world! CI/CD succeeded!」という文字列が出力されることを確認できます。以上で、AWS CodeSeriesを使用したCI/CD実行の動作確認は完了です!

最後に

前回のGitHub Actionsに引き続きAWS CodeSeriesも実践してみたことで、CI/CDに対して結構自信がついてきた気がしてます!これからの案件で上手く活用できるといいですねぇ...

参考

AWS CodeConnections
https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodestarConnectionSource.html#action-reference-CodestarConnectionSource-config
https://blog.serverworks.co.jp/create-codeconnections

AWS CodeCommitにおけるクロスアカウント構成
https://zenn.dev/ncdc/articles/cd84517e10e3a5

4

Discussion