【AWS】ECS CI/CD の作り方（GitHub Actions / Code シリーズ / Terraform）

ECS の CI/CD を GitHub Actions、Code シリーズ、Terraform というおいしいものづくしで作ります。

ECS の CI/CD は定番ものですが、構築にはいろいろなパターンがあるので、そのあたりに悩みつつも楽しみながら作ってみましょう :hammer_pick:

# 構築の方針
考えるポイントとしては、アプリとインフラの境界をどうするかというところです。本記事の CI/CD ではアプリの範囲はアプリ側の GitHub リポジトリで扱えるところまでとし、インフラは「それ以外すべて」と考えました。

ここをどう考えるかは、以下の記事がとても参考になります。上の記事ではパターン3、下の記事ではパターン 3-3 に近しいものを採用しました。

ECS の CI/CD において、アプリとインフラが構築に混在するのであれば、GitHub Actions + Code シリーズはファーストチョイスと考えてよいかと思います。

https://zenn.dev/reireias/articles/8e987af2762eaa#%E5%A2%83%E7%95%8C%E3%81%AB%E9%96%A2%E3%81%97%E3%81%A6

https://speakerdeck.com/tsukuboshi/cdgou-cheng-jie-shuo

## 構成図

上記の方針を踏まえた構成図です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/984662/3714e5cd-f17f-802c-b1a6-a9436e6cb398.png)

ポイントは以下です。

**・CodePipeline のトリガー**
GitHub Actions から ECR へのプッシュします。
 
**・DB のマイグレーション**
GitHub Actions から一度 S3 にコピーしてから CodeBuild でコピーして行います。

**・appspec**
Terraform のリポジトリから一度 S3 に配置して、同じく CodeBuild でアーティファクトにコピーします。

やたら複雑な構成だと思うかもしれないですが、いろいろと制約があったので、一旦この構成にたどりつきました。SQL を Docker イメージに組み込んだり、appspec をもっとスマートに取得できれば、より構成はスッキリできそうです。

# 作ってみる
大まかな流れはこんな感じです。

1. ECR へイメージプッシュまで
2. 基本リソースの作成
3. Code シリーズの作成

:::note warn
本記事のコードは構築上、主要になるところだけを記載しています。その他必要なコードは下記リポジトリにありますが、記事内含め動作確認のみを目的としたコードです。とくに IAM や命名は意識してないので、あくまで参考程度にお使いください。お仕事などで使う場合は、しっかり精査してお使いください。

動作確認はパイプラインの全フェーズから最後の ECS 接続確認までできています。実際の経過などは下記 Zenn の Scrap をご確認ください。

Blue/Green やロールバックはこの記事では言及しませんが、設定自体はされているので動作する想定です。
:::

https://github.com/hiyanger/gha-image-push

https://github.com/hiyanger/ecs-cicd

https://zenn.dev/hiyanger/scraps/34555c2d7cf4a0

## 1. ECRイメージプッシュまで

### 1. OIDC
GitHub Actions と ECR の接続は OIDC を使います。

```hcl:iamrole.tf
# IDプロバイダの作成
data "http" "github_actions_openid_configuration" {
  url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}

data "tls_certificate" "github_actions" {
  url = jsondecode(data.http.github_actions_openid_configuration.response_body).jwks_uri
}

resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = data.tls_certificate.github_actions.certificates[*].sha1_fingerprint
}

# IAMロール作成
data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = ["arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # 特定のリポジトリの特定のブランチからのみ認証を許可する
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:hiyanger/gha-image-push:ref:refs/heads/master"]
    }
  }
}

resource "aws_iam_role" "oidc" {
  name               = "oidc-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}
```


### 2. GitHub Actions
```yaml:main.yaml
name: ecr push image

on:
  push:

jobs:
  push:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3

      # AWS認証
      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: "ap-northeast-1"
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}

      # ECRログイン
      - uses: aws-actions/amazon-ecr-login@v1
        id: login-ecr

      # Dockerイメージを build・push する
      - name: build and push docker image to ecr
        env:
          # ECRレジストリを `aws-actions/amazon-ecr-login` アクションの `outputs.registry` から取得
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          # イメージをpushするECRリポジトリ名
          REPOSITORY: "ecs-cicd"
          IMAGE_TAG: latest
        run: |
          docker build . --tag ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
          docker push ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}

      # RDSマイグレーション用SQLをS3にアップロードする
      - name: Upload to S3
        run: |
          aws s3 cp migration.sql s3://ecs-cicd-migration-bucket-20241231
```

※参考（ほぼこの記事ままでいけました 素晴らしい...）

https://zenn.dev/kou_pg_0131/articles/gh-actions-ecr-push-image#1.-github-actions-%E7%94%A8%E3%81%AE-id-%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80%E3%81%A8-iam-%E3%83%AD%E3%83%BC%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B

### 3. リポジトリへ push
dockerfile と RDS マイグレーション用の SQL をテスト配置して ECR へ push しましょう。テスト程度であれば Dockerfile は Apache と html の記述をし、SQL は DB を確認するくらいのもので OK です。

## 2. 基本リソースの作成
CI/CD 以前にも地味にいろいろ作る必要があります。
- ECS
- VPC
- RDS
- ALB
- S3

ここでは ECS と S3 だけ記述します。その他は上述の通り GitHub を参考にしてください。妥協点として、RDS はプライベートサブネットに配置してしまうと、CodeBuild も VPC に配置して NAT Gateway という構成をとる必要があるため、割り切ってパブリックに配置しています。


### 1. ECS
タスク定義は `task_def.json` を使う方法もありますが、ここでは Terraform で記述しています。

```hcl:container.tf
# ECSクラスター
resource "aws_ecs_cluster" "ecs_cicd" {
  name = "ecs-cicd-cluster"
}

# ECSタスク定義
resource "aws_ecs_task_definition" "ecs_cicd" {
  family                   = "ecs-cicd-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"

  container_definitions = jsonencode([
    {
      name  = "ecs-cicd-container"
      image = "${var.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-cicd:latest"
      portMappings = [
        {
          containerPort = 80
          hostPort      = 80
        }
      ]
    }
  ])

  execution_role_arn = aws_iam_role.ecs_cicd.arn
}

# ECSサービス
resource "aws_ecs_service" "ecs_cicd" {
  name            = "ecs-cicd-service"
  cluster         = aws_ecs_cluster.ecs_cicd.id
  task_definition = aws_ecs_task_definition.ecs_cicd.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  deployment_controller {
    type = "CODE_DEPLOY"
  }

  network_configuration {
    subnets          = [aws_subnet.ecs_cicd_public_1.id, aws_subnet.ecs_cicd_public_2.id]
    security_groups  = [aws_security_group.ecs_cicd.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.ecs_cicd_blue.arn
    container_name   = "ecs-cicd-container"
    container_port   = 80
  }
}
```

### 2. S3
アーティファクト、SQL、appspec バケットを作ります。SQL はバージョニングしました。appspec は etag で Terraform から更新できるようにしておきます。

```hcl:s3.tf
# アーティファクト用バケット
resource "aws_s3_bucket" "ecs_cicd" {
  bucket = "ecs-cicd-artifacts-20241230"
}

# マイグレーションSQL配置用バケット
resource "aws_s3_bucket" "ecs_cicd_migration" {
  bucket = "ecs-cicd-migration-bucket-20241231"
}

resource "aws_s3_bucket_versioning" "versioning_migration" {
  bucket = aws_s3_bucket.ecs_cicd.id
  versioning_configuration {
    status = "Enabled"
  }
}

# appspec 配置用バケット
resource "aws_s3_bucket" "ecs_cicd_deploy" {
  bucket = "ecs-cicd-appspec-task-bucket-20241231"
}

# appspec
resource "aws_s3_object" "appspec" {
  bucket = aws_s3_bucket.ecs_cicd_deploy.id
  key    = "appspec.yml"
  content = templatefile("s3/appspec.yml", {
    account_id = "${var.account_id}"
  })
  etag = filemd5("s3/appspec.yml")
}

```

### 3. appspec
S3 に配置する `appspec.yml` です。`<TASK_DEFINITION>` が取得できなかったので、ここではタスクを直接指定しています。

```hcl:s3/appspec.yml
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "arn:aws:ecs:ap-northeast-1:${account_id}:task-definition/ecs-cicd-task:1"
        LoadBalancerInfo:
          ContainerName: "ecs-cicd-container"
          ContainerPort: 80
```

## 3. CI/CD
メインである CI/CD を作ります。CodePipeline、CodeBuild、CodeDeploy を使います。

```hcl:pipeline.tf
# CodeBuild
resource "aws_codebuild_project" "ecs_cicd_migration" {
  name         = "rds-migration"
  service_role = aws_iam_role.ecs_cicd.arn

  source {
    type      = "CODEPIPELINE"
    buildspec = file("buildspec.yml")
  }

  artifacts {
    type = "CODEPIPELINE"
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/standard:5.0"
    type         = "LINUX_CONTAINER"

    # 環境変数
    environment_variable {
      name  = "DB_HOST"
      value = var.rds_endpoint
    }

    environment_variable {
      name  = "DB_USER"
      value = var.rds_user
    }

    environment_variable {
      name  = "DB_PASS"
      value = var.rds_password # Secrets Manager 等を推奨
    }
  }
}

# CodeDeploy
resource "aws_codedeploy_app" "ecs_cicd" {
  compute_platform = "ECS"
  name             = "ecs-cicd"
}

# デプロイメントグループ
resource "aws_codedeploy_deployment_group" "ecs_cicd" {
  app_name               = aws_codedeploy_app.ecs_cicd.name
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
  deployment_group_name  = "ecs_cicd"
  service_role_arn       = aws_iam_role.ecs_cicd.arn

  # 自動ロールバック
  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  # Blue/Greenデプロイメント
  blue_green_deployment_config {

    deployment_ready_option {
      action_on_timeout = "CONTINUE_DEPLOYMENT"
    }

    # Blueインスタンス（旧バージョン）の処理
    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 5
    }
  }

  # デプロイスタイルの設定
  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  # ECSサービスの関連付け
  ecs_service {
    cluster_name = aws_ecs_cluster.ecs_cicd.name
    service_name = aws_ecs_service.ecs_cicd.name
  }

  # ロードバランサー情報の設定
  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [aws_lb_listener.ecs_cicd.arn]
      }

      # Blueターゲットグループ
      target_group {
        name = aws_lb_target_group.ecs_cicd_blue.name
      }

      # Greenターゲットグループ
      target_group {
        name = aws_lb_target_group.ecs_cicd_green.name
      }
    }
  }
}

# CodePipeline
resource "aws_codepipeline" "ecs_cicd" {
  name          = "ecs-cicd-pipeline"
  pipeline_type = "V2"
  role_arn      = aws_iam_role.ecs_cicd.arn

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

  stage {
    name = "Source"

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

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

  stage {
    name = "Build"

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

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

  stage {
    name = "Deploy"

    action {
      name            = "DeployToECS"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "CodeDeploy"
      version         = "1"
      input_artifacts = ["build_output"]

      configuration = {
        ApplicationName     = aws_codedeploy_app.ecs_cicd.name
        DeploymentGroupName = aws_codedeploy_deployment_group.ecs_cicd.deployment_group_name
      }
    }
  }
}
```

構築は以上です！

# さいごに
わたしは Code シリーズだと、CodeCatalyst、CodeCommit（新規は廃止）、CodePipeline、CodeBuild で何度か構築経験がありましたが、思ったよりも時間がかかりました。考えていたよりも必要なリソースが多かったり、ネットワーク、RDS、アーティファクトあたりがネックになった印象です。

冒頭にも記述しましたが、CI/CD はいろんなパターンをとることができるので、そこが大変でもあり、面白いところでもあるなと感じました。AWS では CodeCommit が新アカウントで使えなくなるなど、選択肢にも常に動きがあるのでそこも含めて最善の選択がとれると良いなと思います。

この CI/CD はまだまだ改善の余地があると思うので、もしお仕事で使うことになったらより精査して再構築してみます。実際に使っていただける方も同じく、この CI/CD を採用する場合は、より精査して構築いただけるとよいかと思います :relaxed: