🚢

Terraform : ECS on Fargate + CI/CD (GitHub Actions)

2025/01/04に公開

概要

業務でECS on Fargate + GitHub Actionsを使用したコンテナ構築を担当することになった際に読んだAWSコンテナ設計・構築[本格]入門の内容が非常に良かったため、書籍の構成をベースにしたECSコンテナ環境をTerraformで構築し、GitHub Actionsを用いてCI/CDを実装しました。

構成図

初期構成 実装

TerraformコードはGitHubに記載しています。本記事では一部を抜粋して紹介させていただきますので、詳細はGitHubをご確認ください。

ディレクトリ構成(Terraform部分 抜粋)
├─ environments
│  ├─ prd
│  ├─ stg
│  └─ dev
│     ├─ main.tf
│     ├─ local.tf
│     ├─ variable.tf
│     └─ (terraform.tfvars)
│
└─ modules(各Moduleにmain.tf,variable.tf,output.tfが存在)
      ├─ alb_ingress(Public ALB関連のリソース)
      ├─ alb_internal(Private ALB関連のリソース)
      ├─ ec2(管理EC2関連のリソース)
      ├─ ecr(ECR関連のリソース)
      ├─ ecs_backend(Backend ECS関連のリソース)
      ├─ ecs_frontend(Frontend ECS関連のリソース)
      ├─ initializer(tfstate用S3バケット作成)
      ├─ network(VPC、Subnet、VPNなどネットワーク全般のリソース)
      ├─ rds(RDS関連のリソース)
      └─ secrets_manager(Secrets Manager関連のリソース)

Public ALB + Frontend ECS

下記構成図の赤枠で囲ったフロントエンド部分から確認していきます。

【Public ALB】
Public ALBは80番の本稼働リスナーを持ち、ターゲットグループに関連付けられたECSコンテナの80番ポートに通信を転送する一般的な構成となっています。ALBを用いてECSコンテナ間で負荷分散するためには、ECS Serviceをターゲットグループに関連付けた上で、通信の転送先であるECSコンテナを指定する必要があります(本関連付けはaws_ecs_serviceにて定義しています)。

modules/alb_ingress/main.tf(ALBリソース 抜粋)
# Define ingress ALB
resource "aws_lb" "ingress" {
  name               = "${var.common.env}-alb-ingress"
  internal           = false
  load_balancer_type = "application"
  subnets            = var.network.public_subnet_for_ingress_ids
  security_groups    = [var.network.security_group_for_ingress_alb_id]
  tags = {
    Name = "${var.common.env}-alb-ingress"
  }
}

# Define the listner for ingress ALB
resource "aws_lb_listener" "ingress" {
  load_balancer_arn = aws_lb.ingress.arn
  protocol          = "HTTP"
  port              = "80"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ingress.arn
  }
}

# Define target group for ingress ALB
resource "aws_lb_target_group" "ingress" {
  name        = "${var.common.env}-tg-frontend"
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.network.vpc_id
  health_check {
    protocol            = "HTTP"
    path                = "/healthcheck"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = 200
  }
}
modules/ecs_frontend/main.tf(ECS Service 抜粋)
# Define ECS service
resource "aws_ecs_service" "frontend" {
  name               = "${var.common.env}-ecs-frontend-service"
  ~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  load_balancer {
    target_group_arn = var.alb_ingress.alb_target_group_ingress_arn
    container_name   = "app"
    container_port   = 80
  }
}

【Frontend ECS】
Frontend ECSは1タスク当たり1コンテナというシンプルな構成になっています。管理用EC2で事前に作成したFrontend用コンテナイメージを用いて、ECSタスク定義を作成しています。

アプリの設計は詳しく把握できていませんが、コンテナ定義においてPrivate ALBのURLをValueとして持った環境変数を定義することで、ECSコンテナが受け取った通信をPrivate ALBに転送することを実現しているようです。同様にして、DB接続に必要な機密情報もSecrets Managerから取得し、環境変数として定義することでECSコンテナに渡しています。

modules/ecs_frontend/main.tf(ECSリソース 抜粋)
# Define ECS task definition
resource "aws_ecs_task_definition" "frontend" {
  family                   = "${var.common.env}-frontend-def"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 512
  memory                   = 1024
  network_mode             = "awsvpc"
  execution_role_arn       = aws_iam_role.task_execution_role.arn
  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "${var.common.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/container-app-frontend:v1"
      cpu       = 256
      memory    = 512
      essential = true
      environment = [
        {
          name  = "APP_SERVICE_HOST"
          value = "http://${var.alb_internal.alb_internal_dns_name}"
        },
        {
          name  = "NOTIF_SERVICE_HOST"
          value = "http://${var.alb_internal.alb_internal_dns_name}"
        },
        {
          name  = "SESSION_SECRET_KEY"
          value = "41b678c65b37bf99c37bcab522802760"
        },
      ]
      secrets = [
        {
          name      = "DB_HOST"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:host::"
        },
        {
          name      = "DB_NAME"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:dbname::"
        },
        {
          name      = "DB_USERNAME"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:username::"
        },
        {
          name      = "DB_PASSWORD"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:password::"
        }
      ]
      portMappings = [{ containerPort = 80 }]
      "readonlyRootFilesystem" : true
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-region : "ap-northeast-1"
          awslogs-group : aws_cloudwatch_log_group.frontend.name
          awslogs-stream-prefix : "ecs"
        }
      }
    }
  ])
}

# Define ECS cluster
resource "aws_ecs_cluster" "frontend" {
  name = "${var.common.env}-frontend-cluster"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

# Define ECS service
resource "aws_ecs_service" "frontend" {
  name                               = "${var.common.env}-ecs-frontend-service"
  cluster                            = aws_ecs_cluster.frontend.arn
  task_definition                    = aws_ecs_task_definition.frontend.arn
  launch_type                        = "FARGATE"
  platform_version                   = "1.4.0"
  scheduling_strategy                = "REPLICA"
  desired_count                      = 2
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
  deployment_controller {
    type = "ECS"
  }
  enable_ecs_managed_tags = true
  network_configuration {
    subnets          = var.network.private_subnet_for_container_ids
    security_groups  = [var.network.security_group_for_frontend_container_id]
    assign_public_ip = false
  }
  health_check_grace_period_seconds = 120
  load_balancer {
    target_group_arn = var.alb_ingress.alb_target_group_ingress_arn
    container_name   = "app"
    container_port   = 80
  }
}

Private ALB + Backend ECS

下記構成図の赤枠で囲ったバックエンド部分を確認します。

【Private ALB】
Private ALBは80番の本稼働リスナーの他に、10080番のテストリスナーを持っており、それに対応する形で本稼働用/テスト用ターゲットグループ(Blue/Green)が定義されています。初期構築時にはBlue側のターゲットグループとECS Serviceをaws_ecs_serviceにおいて関連付けており、Green側のターゲットグループにリソースは関連付けられていません。

modules/alb_internal/main.tf
# Define internal ALB
resource "aws_lb" "internal" {
  name               = "${var.common.env}-alb-internal"
  internal           = true
  load_balancer_type = "application"
  subnets            = var.network.private_subnet_for_container_ids
  security_groups    = [var.network.security_group_for_internal_alb_id]
  tags = {
    Name = "${var.common.env}-alb-internal"
  }
}

# Define the production listner for internal ALB
resource "aws_lb_listener" "internal_prod" {
  load_balancer_arn = aws_lb.internal.arn
  protocol          = "HTTP"
  port              = "80"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.internal_blue.arn
  }
}

# Define target group Blue for internal ALB
resource "aws_lb_target_group" "internal_blue" {
  name        = "${var.common.env}-tg-backend-blue"
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.network.vpc_id
  health_check {
    protocol            = "HTTP"
    path                = "/healthcheck"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = 200
  }
}

# Define the test listner for internal ALB
resource "aws_lb_listener" "internal_test" {
  load_balancer_arn = aws_lb.internal.arn
  protocol          = "HTTP"
  port              = "10080"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.internal_blue.arn
  }
}

# Define target group Green for internal ALB
resource "aws_lb_target_group" "internal_green" {
  name        = "${var.common.env}-tg-backend-green"
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.network.vpc_id
  health_check {
    protocol            = "HTTP"
    path                = "/healthcheck"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = 200
  }
}

modules/ecs_backend/main.tf(ECS Service 抜粋)
# Define ECS service
resource "aws_ecs_service" "backend" {
  name               = "${var.common.env}-ecs-backend-service"
  ~~~~~~~~~~~~~~~~~~~~~~~ 省略 ~~~~~~~~~~~~~~~~~~~~~~~
  load_balancer {
    target_group_arn = var.alb_internal.alb_target_group_internal_blue_arn
    container_name   = "app"
    container_port   = 80
  }
}

【Backend ECS】
Backend ECSにおいても、Backend用コンテナイメージを使用してタスク定義を作成するくらいで、基本的な構成はFrontend ECSと変わりません。唯一大きく異なる点として、デプロイタイプが挙げられます。Frontend ECSではECSのみで設定が完結する「ローリングアップデート」でしたが、Backend ECSではCode Deployを使用した「B/Gデプロイメント」を実装しています。CI/CDにより新しくデプロイしたECSタスクをGreen側ターゲットグループに関連付けることで、本稼働環境に影響を与えることなく新しいアプリケーションをテストすることができます。

注意点ですが、CI/CDによりタスク定義が更新されるため、実際のAWS環境とTerraformコード/tfstateファイルの間で、ECSサービスで指定するタスク定義が異なってしまいます。
具体的には「実際のAWS環境ではv1.1のタスク定義を指定しているのに、terraformコードではv1.0のタスク定義を指定している」という状況が発生します。この状態でterraform applyを実行すると、CI/CDで更新した最新のタスク定義(v1.1)を用いたECSサービスから、terraformで定義されている旧タスク定義(v1.0)を用いたECSサービスへ巻き戻そうとします
そこで、aws_ecs_serviceにおいてignore_changesを定義することで、terraformコードの内容がAWS環境に反映されないように設定する必要があります(参考)。

※「ECSサービスへ巻き戻そうとします」と記載しましたが、実際にはエラーが発生し、ECSサービスの更新はできません。deployment_controllerとしてCode Deployを指定した際には、ECSサービスを更新するためにCodeDeploy:CreateDeploymentをコールする必要がある一方で、terraformにてaws_ecs_serviceを更新する際にコールされるAPIはECS:UpdateServiceであるためです。

modules/ecs_backend/main.tf(ECSリソース 抜粋)
# Define ECS task definition
resource "aws_ecs_task_definition" "backend" {
  family                   = "${var.common.env}-backend-def"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 512
  memory                   = 1024
  network_mode             = "awsvpc"
  execution_role_arn       = aws_iam_role.task_execution_role.arn
  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "${var.common.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/container-app-backend:v1"
      cpu       = 256
      memory    = 512
      essential = true
      secrets = [
        {
          name      = "DB_HOST"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:host::"
        },
        {
          name      = "DB_NAME"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:dbname::"
        },
        {
          name      = "DB_USERNAME"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:username::"
        },
        {
          name      = "DB_PASSWORD"
          valueFrom = "${var.secrets_manager.secret_for_db_arn}:password::"
        }
      ]
      portMappings = [{ containerPort = 80 }]
      "readonlyRootFilesystem" : true
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-region : "ap-northeast-1"
          awslogs-group : aws_cloudwatch_log_group.backend.name
          awslogs-stream-prefix : "ecs"
        }
      }
    }
  ])
}

# Define ECS cluster
resource "aws_ecs_cluster" "backend" {
  name = "${var.common.env}-backend-cluster"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

# Define ECS service
resource "aws_ecs_service" "backend" {
  name                               = "${var.common.env}-ecs-backend-service"
  cluster                            = aws_ecs_cluster.backend.arn
  task_definition                    = aws_ecs_task_definition.backend.arn
  launch_type                        = "FARGATE"
  platform_version                   = "1.4.0"
  scheduling_strategy                = "REPLICA"
  desired_count                      = 2
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
  deployment_controller {
    type = "CODE_DEPLOY"
  }
  enable_ecs_managed_tags = true
  network_configuration {
    subnets = var.network.private_subnet_for_container_ids
    security_groups = [
      var.network.security_group_for_backend_container_id
    ]
    assign_public_ip = false
  }
  health_check_grace_period_seconds = 120
  load_balancer {
    target_group_arn = var.alb_internal.alb_target_group_internal_blue_arn
    container_name   = "app"
    container_port   = 80
  }
  lifecycle {
    ignore_changes = [
      task_definition
    ]
  }
}

下記コードにて、B/GデプロイメントのためのCode Deploy関連リソースを作成しています。
Code Deployによるデプロイ詳細設定はデプロイグループ(aws_codedeploy_deployment_group)において定義されています。load_balancer_info/target_group_pair_infoにおいて指定される本稼働リスナーとテストリスナーに対する転送先ターゲットグループをCode Deployが適宜更新することで、コードの下に図示したような順序でB/Gデプロイメントを実現してくれます。

Terraformではデプロイグループの定義までをおこない、実際のデプロイ実施はGitHub ActionsによるCI/CD処理により実現します。これに関しては、CI/CD 実装編にて詳しく解説します。

modules/ecs_backend/main.tf(Code Deployリソース 抜粋)
# Define CodeDeploy application
resource "aws_codedeploy_app" "backend" {
  compute_platform = "ECS"
  name             = "${var.common.env}-backend-app"
}

# Define CodeDeploy deployment group
resource "aws_codedeploy_deployment_group" "backend" {
  app_name               = aws_codedeploy_app.backend.name
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
  deployment_group_name  = "${var.common.env}-ecs-backend-deployment-group"
  service_role_arn       = aws_iam_role.codedeploy.arn
  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }
  ecs_service {
    cluster_name = aws_ecs_cluster.backend.name
    service_name = aws_ecs_service.backend.name
  }
  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [var.alb_internal.alb_listener_internal_blue_arn]
      }
      test_traffic_route {
        listener_arns = [var.alb_internal.alb_listener_internal_green_arn]
      }
      target_group {
        name = var.alb_internal.alb_target_group_internal_blue_name
      }
      target_group {
        name = var.alb_internal.alb_target_group_internal_green_name
      }
    }
  }
  blue_green_deployment_config {
    deployment_ready_option {
      action_on_timeout    = "STOP_DEPLOYMENT"
      wait_time_in_minutes = 10
    }
    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 60
    }
  }
}

CI/CD 実装

書籍ではAWSのCode Seriesを使用してCI/CDを構成していましたが、2024年7月にCode Commitに対する新規アクセスが終了してしまいました。そこで今回はGitHubをリポジトリとして利用し、GitHub ActionsによりCI/CD処理を実装しています。

ディレクトリ構成(CI/CD部分 抜粋)
├─ .github
│  └─ actions
│     └─ container-build
│        └─action.yml(コンテナイメージのビルド処理を記載したactionファイル)
│     └─ container-deploy
│        └─action.yml(ECSコンテナのデプロイ処理を記載したactionファイル)
│  └─ workflows
│     └─ build_deploy.yml(CI/CD処理の流れを記載したWorkflowファイル)
│ 
├─ backend_app(CI/CD対象となるバックエンドアプリ)
│ 
└─ appspec.yml(Code Deployによるデプロイ処理内容を記載)

今回の構成におけるGitHub ActionsによるCI/CDの全体像・各アクション/コマンドの概要は以下のようになっています。各アクション/コマンドの詳細は次項から確認していきます。

  1. actions/checkout:特定ブランチ(下図ではmain)をローカルリポジトリにcheckoutする
  2. aws-actions/configure-aws-credentials:OpenID Connect Provider (OIDC Trust) から一時的な認証情報を取得する
  3. aws-actions/amazon-ecr-login:ECRに対する認証処理をおこなう
  4. docker/metadata-action:イメージIDとタグ名を取得する
    (イメージID:リポジトリURI、タグ名:コミットハッシュ)
  5. docker/build-push-action:イメージをビルドし、ECRにPushする
  6. aws ecs describe-task-definition:タスク定義をGithub Runner上にダウンロードする
  7. amazon-ecs-render-task-definition:ローカルにダウンロードしてきたタスク定義におけるコンテナイメージを更新する
  8. amazon-ecs-deploy-task-definition:ローカルで更新したタスク定義をAWSに登録し、そのタスク定義を使用してECSサービスを更新する

Workflows

GitHub ActionsによるCI/CD処理を定義したWorkflowファイルはstepsに対応する4つのアクション(リモートアクション×2, ローカルアクション×2)により構成されています。backend_appディレクトリにおける任意のgoファイルをPushすると、これらのアクションが実行されます。

  • actions/checkout@v4:【CI/CD 全体像】におけるNo.1です。リモートリポジトリの作業対象ブランチを GitHub Hosted Runner 内に複製する操作であり、内部的には git fetch + git checkout を実行しています。デフォルトではワークフローを実行したブランチをcheckoutしますが、refパラメータを利用することで別のブランチをcheckoutすることも可能です。
  • aws-actions/configure-aws-credentials:【CI/CD 全体像】におけるNo.2です。GitHub OIDC Providerから取得したJWTトークンを用いて署名認証することで、引数で指定したIAM Roleで定義されている権限を有する一時的な認証情報を取得します。IAM RoleにはECR・ECS・Code Deployに対する権限が必要なことに注意が必要です。

  • ./.github/actions/container-build/action.yml:【CI/CD 全体像】におけるNo.3~No.5をまとめて定義したローカルアクションです。コンテナイメージをビルドし、ECRにPushします。詳細はActions編にて確認します。
  • ./.github/actions/container-deploy/action.yml:【CI/CD 全体像】におけるNo.6~No.8をまとめて定義したローカルアクションです。新しいコンテナイメージを使用するようにタスク定義を更新し、新コンテナをデプロイします。詳細はActions編にて確認します。
build_deploy.yml
name: Deploy container
run-name: Deploy container

on:
  push:
    paths:
      - 'backend_app/**/*.go'
  workflow_dispatch:

env:
  ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ID }}:role/${{ vars.ROLE_NAME }}
  SESSION_NAME: gh-deploy-${{ github.run_id }}-${{ github.run_attempt }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4 # リポジトリをチェックアウト
      - uses: aws-actions/configure-aws-credentials@v4 # AWSの認証情報を設定
        with:
          role-to-assume: ${{ env.ROLE_ARN }}
          role-session-name: ${{ env.SESSION_NAME }}
          aws-region: ap-northeast-1
      - uses: ./.github/actions/container-build/ # コンテナイメージのビルド/ECRにPush
        id: build
        with:
          ecr-repository-uri: ${{ secrets.ECR_REPOSITORY_URI }}
          dockerfile-path: backend_app/
      - uses: ./.github/actions/container-deploy/ # コンテナのデプロイ
        with:
          ecs-cluster: ${{ vars.ECS_CLUSTER_NAME }}
          ecs-service: ${{ vars.ECS_SERVICE_NAME }}
          task-definition: ${{ vars.TASK_DEFINITION_NAME }}
          container-name: ${{ vars.CONTAINER_NAME }}
          container-image: ${{ steps.build.outputs.container-image }}
          codedeploy-application: ${{ vars.CODEDEPLOY_APPLICATION_NAME }}
          codedeploy-deploygroup: ${{ vars.CODEDEPLOY_DEPLOYGROUP_NAME }}
modules/ecs_backend/main.tf(GitHub Actions用IAMリソース 抜粋)
# Define IAM role for Github Actions
resource "aws_iam_role" "github_actions" {
  name               = "${var.common.env}-role-for-github-actions"
  assume_role_policy = data.aws_iam_policy_document.trust_policy_for_github_actions.json
}

# Define trust policy for Github Actions role
data "aws_iam_policy_document" "trust_policy_for_github_actions" {
  statement {
    effect = "Allow"
    principals {
      type        = "Federated"
      identifiers = ["arn:aws:iam::${var.common.account_id}:oidc-provider/token.actions.githubusercontent.com"]
    }
    actions = ["sts:AssumeRoleWithWebIdentity"]
    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_actions.account_name}/${var.github_actions.repository}:*"]
    }
  }
}

# Define IAM policy for Github Actions
resource "aws_iam_policy" "policy_for_github_actions" {
  name   = "${var.common.env}-policy-for-github-actions"
  policy = data.aws_iam_policy_document.policy_for_github_actions.json
}

data "aws_iam_policy_document" "policy_for_github_actions" {
  statement {
    sid    = "GetAuthorizationToken"
    effect = "Allow"
    actions = [
      "ecr:GetAuthorizationToken"
    ]
    resources = ["*"]
  }
  statement {
    sid    = "PushImageOnly"
    effect = "Allow"
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:BatchGetImage",
      "ecr:InitiateLayerUpload",
      "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload",
      "ecr:PutImage"
    ]
    resources = [var.repository]
  }
  statement {
    sid    = "RegisterTaskDefinition"
    effect = "Allow"
    actions = [
      "ecs:RegisterTaskDefinition",
      "ecs:DescribeTaskDefinition"
    ]
    resources = ["*"]
  }
  statement {
    sid    = "UpdateService"
    effect = "Allow"
    actions = [
      "ecs:UpdateServicePrimaryTaskSet",
      "ecs:DescribeServices",
      "ecs:UpdateService"
    ]
    resources = [aws_ecs_service.backend.id]
  }
  statement {
    sid    = "PassRole"
    effect = "Allow"
    actions = [
      "iam:PassRole"
    ]
    resources = [aws_iam_role.task_execution_role.arn]
    condition {
      test     = "StringLike"
      variable = "iam:PassedToService"
      values   = ["ecs-tasks.amazonaws.com"]
    }
  }
  statement {
    sid    = "DeployService"
    effect = "Allow"
    actions = [
      "codedeploy:CreateDeployment",
      "codedeploy:GetDeployment",
      "codedeploy:GetDeploymentConfig",
      "codedeploy:GetDeploymentGroup",
      "codedeploy:RegisterApplicationRevision"
    ]
    resources = [
      aws_codedeploy_app.backend.arn,
      aws_codedeploy_deployment_group.backend.arn,
      "arn:aws:codedeploy:${var.common.region}:${var.common.account_id}:deploymentconfig:*"
    ]
  }
}

# Associate IAM policies with Github Actions role
resource "aws_iam_role_policy_attachments_exclusive" "github_actions" {
  role_name = aws_iam_role.github_actions.name
  policy_arns = [
    "arn:aws:iam::aws:policy/IAMReadOnlyAccess",
    aws_iam_policy.policy_for_github_actions.arn
  ]
}

Actions

【./.github/actions/container-build/action.yml】
以下の【CI/CD 全体像】において赤枠で囲んだNo.3~No.5のアクションをまとめて定義したアクションである./.github/actions/container-build/action.ymlについて各アクションの詳細を確認します。

  • aws-actions/amazon-ecr-login:【CI/CD 全体像】におけるNo.3です。ECRにログインするためのアクションであり、内部的にdocker loginコマンドを実行しています。
  • docker/metadata-action:【CI/CD 全体像】におけるNo.4です。イメージ名やタグなどのメタデータを生成するアクションです。ECRにイメージをPushするためにはイメージ名としてリポジトリURIを指定する必要があります。イメージタグにはコミットハッシュを使用しています。
  • docker/build-push-action:【CI/CD 全体像】におけるNo.5です。docker/metadata-actionにより定義したイメージ名/タグを基に、引数で渡したDocker Fileからコンテナイメージをビルドし、ECRにPushします。
./.github/actions/container-build/action.yml
name: Container Build
description: コンテナイメージをビルドし、ECRへプッシュします。
inputs:
  ecr-repository-uri:
    required: true
    description: ECRリポジトリのURI
  dockerfile-path:
    required: true
    description: Dockerfileのパス

# metadata-actionのoutputであるsteps.meta.outputs.tagsは「イメージ名:タグ」という形式
# ex) xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-repo:sha-yyyyyyy
outputs:
  container-image:
    value: ${{ steps.meta.outputs.tags }}
    description: ビルドしたコンテナイメージ 
runs:
  using: composite
  steps:
    - uses: aws-actions/amazon-ecr-login@v2 # Amazon ECRへのログイン
    - uses: docker/metadata-action@v5       # コンテナイメージのメタデータ生成
      id: meta
      with:
        images: ${{ inputs.ecr-repository-uri }}
        tags: type=sha
    - uses: docker/build-push-action@v5     # コンテナイメージのビルドとプッシュ
      with:
        push: true
        context: ${{ inputs.dockerfile-path }}
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}

【./.github/actions/container-deploy/action.yml】
以下の【CI/CD 全体像】において赤枠で囲んだNo.6~No.8のアクションをまとめて定義したアクションである./.github/actions/container-deploy/action.ymlについて各アクションの詳細を確認します。

  • aws ecs describe-task-definition:【CI/CD 全体像】におけるNo.6です。現在のタスク定義を取得します。タスク定義を取得するためのリモートアクションは提供されていないため、AWS CLIを実行しています。
  • aws-actions/amazon-ecs-render-task-definition:【CI/CD 全体像】におけるNo.7です。取得した現在のタスク定義を更新します。引数を用いて、タスク定義における変更箇所を識別します。container-nameで更新対象のコンテナ名を指定して、imageで更新後のコンテナイメージURIを定義します。
  • aws-actions/amazon-ecs-deploy-task-definition:【CI/CD 全体像】におけるNo.8です。更新したタスク定義をECSに登録し、そのタスク定義を使用してECSサービスを更新することで、B/Gデプロイメントを実行します。
./.github/actions/container-deploy/action.yml
name: Container Deploy
description: ECSサービスを更新し、コンテナをデプロイします。
inputs:
  ecs-cluster:
    required: true
    description: ECSクラスター
  ecs-service:
    required: true
    description: ECSサービス
  task-definition:
    required: true
    description: タスク定義
  container-name:
    required: true
    description: コンテナ名
  container-image:
    required: true
    description: コンテナイメージ
  codedeploy-application:
    required: true
    description: CodeDeployアプリケーション
  codedeploy-deploygroup:
    required: true
    description: CodeDeployデプロイグループ
runs:
  using: composite
  steps:
    # タスク定義をGithub Hosted Runnerにて取得(ファイル名:task-def.json)
    - run: |                                                 
        aws ecs describe-task-definition --task-definition "${TASK_DEFINITION}" \
        --query taskDefinition --output json > "${RUNNER_TEMP}/task-def.json"
      env:
        TASK_DEFINITION: ${{ inputs.task-definition }}
      shell: bash

    # Github Hosted Runnerにて取得したタスク定義(task-def.json)におけるコンテナイメージURIを更新する
    # task-definitionで更新するタスク定義ファイルを指定・container-nameで更新するコンテナ名を指定・imageでコンテナイメージURIを指定
    - uses: aws-actions/amazon-ecs-render-task-definition@v1 
      id: render
      with:
        task-definition: ${{ runner.temp }}/task-def.json
        container-name: ${{ inputs.container-name }}
        image: ${{ inputs.container-image }}
    
    # ローカルで更新したタスク定義をAWS上に登録し、そのタスク定義を使用してECSサービスを更新する
    - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 
      with:
        cluster: ${{ inputs.ecs-cluster }}
        service: ${{ inputs.ecs-service }}
        task-definition: ${{ steps.render.outputs.task-definition }}
        wait-for-service-stability: true
        codedeploy-appspec: appspec.yml
        codedeploy-application: ${{ inputs.codedeploy-application }}
        codedeploy-deployment-group: ${{ inputs.codedeploy-deploygroup }}

動作確認

初期構築

【Public ALB】


【Frontend ECS】



【Private ALB】
初期構築時点では、本稼働リスナーとテストリスナーの両者において、Blue側のターゲットグループに通信を転送します。Green側のターゲットグループにはリソースは関連付けられていません。



【Backend ECS】
初期構築時点では、管理用EC2で事前に作成したBackend用コンテナイメージ(container-app-backend:v1)を使用してECSタスクを作成しています。



【ECR】

【Code Deploy】


【アプリケーションに接続】
フロントエンドアプリケーションのトップページにアクセスすると、バックエンドアプリケーションから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)
	}
}

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

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

【ECSタスク定義更新】
helloworld_handler.goを編集/Commit/Pushをすると、CI/CD処理が実行されます。ECRにはコミットハッシュをタグとして持つ新たなBackend用コンテナイメージ(container-app-backend:sha-6004d56)が作成され、このコンテナイメージを使用した新たなECSタスク定義も作成されます。



【テストトラフィック設定】
続いてCode Deployによるデプロイが実行されます。ステップ3において、テストリスナーの転送先がGreen側ターゲットグループに更新されます。Green側ターゲットグループにはcontainer-app-backend:sha-6004d56を用いて新たに作成されたECSタスクが関連付けられています。







【本稼働トラフィック再ルーティング】
ステップ3において「トラフィックの再ルーティング」を押下すると、本稼働リスナーの転送先がGreen側ターゲットグループに更新されます。ステップ5の段階では、旧ECSタスクは削除されずに残っています。「デプロイを停止してロールバック」を押下することで、本稼働リスナーの転送先をBlue用ターゲットグループへとロールバックすることができます。



【元のタスクセット終了】
ステップ5において「元のタスクセットの終了」を押下することで、旧ECSタスクを削除し、Code DeployによるB/Gデプロイメントを完了します。



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

最後に

コンテナ+CI/CDという構成は、引き続き需要が高まっていくと思うので、もっと詳しい内容も把握できるように頑張らないといけませんね...

参考

GiHub Actions用 IAM権限
https://github.com/aws-actions/amazon-ecs-deploy-task-definition?tab=readme-ov-file#aws-codedeploy-support
https://dev.classmethod.jp/articles/github-actions-ecs-ecr-minimum-iam-policy/#ECS%25E7%2594%25A8

Discussion