KINTO Tech Blog
PlatformEngineering

GitHubActions+ECSでBlueGreenDeploymentを実装するお話

Cover Image for GitHubActions+ECSでBlueGreenDeploymentを実装するお話

はじめに

こんにちは。プラットフォームGのOperationToolManagerチームでPlatformEngineeringとかツール周りの開発・運用の役割の島村です。

同じくプラットフォームGのOperationToolManagerチームで内製ツールの開発を行っている山田です。

KINTOテクノロジーズではAmazon ECS+Fargateをアプリケーション実行基盤として使用しています。また、CICDについてはGitHubActionsを使用しています。
AWSのECSにおけるBlueGreenDeploymentの仕組みは、DeploymentControllerとして「CODE_DEPLOY」が主として使用されており、「EXTERNAL(サードパーティーでの制御)」を使用している実例は少ないと思います。
CI/CD Conference 2023 by CloudNative Daysでも、BlueGreenDeploymentをするために、ECSからKuberenetesへ移動された事例もお伺いしました。小さく始める Blue/Green Deployment

とはいえ、ECSでもCodeDeployの条件に制限されないBlueGreenDeploymentができるのでは?ということと、アプリケーション開発部門にはデプロイ方式を複数提供するべきと考え、準備を開始しました。
やはり通常はCODE_DEPLOYを設定するほうが多く、EXTERNALの設定のドキュメントなども少ない状態でしたが、アプリケーション側への仕組みの提供を行うことができました。

外部のパイプラインツール+ECS(Fargate)を対象としたBlueGreenDeploymentの実装事例としてご紹介いたします。

背景

課題

  • ECSのローリングアップデートだけだと今後のリリースの際に要件とマッチしない可能性がある
  • 複数のデプロイ手法を選べるようにして、アプリケーションの特性に合ったデプロイを行うべきである

解決方法

ということで、まずは第一歩として、ECSでのBlueGreenDeploymentを提供することにしました。カナリアリリースなどは今後の課題としますが、最終的にはこの形で実装できたので、流入量などをCLIで設定する形で実装できると想定しています。

設計

CODE_DEPLOYでの確認

「ECS BlueGreenDeployment」で調べれば、色々と出てきます。
が、それだけで済ますのもよろしくはないかなというので、概要などをまとめたいと思います。

CODE_DEPLOY構成図

このような構成です。CodeDeployに各種の設定をして、新しくTask定義に紐づいたTaskを作成、Deployment設定に則って流入を変更させていきます。一括で切り替えたり、一部だけを確認して徐々に増やすなどです。

満たせないなと思った仕様

CodeDeployでの環境と動作を確認したところ、こういった点が気になりました。設定次第かもですので、ご存じなら突っ込んでいただけると。

  • テスト系をある程度期間立ち上げて動作確認したい(カスタマーの確認など)
    • 1日程度は維持できるが、その設定を経過して切り替えボタンを押さない場合デプロイに失敗する
  • 切り替え後に任意のタイミングで古いアプリケーションを落としたい
    • CodeDeployだと、時間制限は設定できるが任意ではなさそう
  • 切り戻しをConsoleからだと煩雑になりそう
    • 権限設計で、SwitchRoleをしないとConsoleから触れないので、操作がややこしくなる

EXTERNALでの全体構成

構成図

コンポーネント(要素)

名称 概要
Terraform AWSなど色々なサービスをコード化する製品。IaC。社内のデザインパターンとModuleはTerraformで作成されています。
GitHubActions GitHubに包含されているCICDツール。KINTOテクノロジーズではGitHubActionsを使用してアプリケーションのビルド・リリースなどを実行しています。このGitHubActionsからパイプラインを使用して新旧アプリケーションのデプロイや切り替えを行っています。
ECS(ElasticContainerService) アプリケーション実行環境としてECSを使っています。設定上、DeploymentControllerはECS/CODE_DEPLOY/EXTERNALの設定ができますが、本件はEXTERNALでの実装例です。
DeploymentController ECSのコントロールプレーンのようなもの(と個人的には思っている)。
TaskSet ECSのサービスに紐づくTaskのまとまり。CLIからは作成できますがConsoleから作成できないようです。これを使うことで、1つのサービスに複数のタスク定義バージョンを並行で作成できます。CLIリファレンス 。作成の際にALBやTargetGroupなどが必要で、設定する項目が多い
ALB ListenerRule ALB上でTargetGroupに振り分けるルール。BlueGreenDeploymentでは、この紐づけを変更することで新旧アプリケーションの導線を切り替えます。

制限事項

  • ECSのDeploymentControllerは作成時のみ設定できるので、既存Serviceの変更は不可
  • EXTERNALの場合、プラットフォームバージョンはServiceで固定されない(TaskSetの際に指定)
  • サービスの起動タイプがEC2に固定される。ただ、TaskSetを作成する際にFargateを指定すればタスクはFargateで起動する

実装

Terraform

KINTOテクノロジーズではIaCとしてTerraformを使用しています。Module化もしていますが、そのModuleを修正した際に出た注意点などを整理します。

ListenerRule

GitHubActionsでListenerRuleを書き換えてTargetGroupを変更するので、ignore_changeを設定。

ECS Service

  • NetworkConfiguration
  • LoadBalancer
  • ServiceRegisteries

の3つはEXTERNALの場合は設定できないため、Dynamicなどで設定をしている場合は作成しないようにする必要があります。その場合、CloudMapに登録されないので、AppMeshなどで組み合わせる場合は、考慮が必要です。BlueGreenDeploymentを設定したServiceからほかのECS ServiceへAppMeshを使った通信は問題ありません。

そもそも、BlueGreenで並行稼働していることから、CloudMapに登録されて通信可能とすると…間違えたアクセスが発生すると思うので、挙動的には正しいかなと思います。

CICD用RoleのIAMPolicy

色々とECS系以外にも権限が必要となります。サンプルとしては以下の通りです。

cicd_policy.tf(sample)
resource "aws_iam_policy" "cicd-bg-policy" {
  name = "cicd-bg_policy"
  path = "/"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "iam:PassRole"
        ]
        Effect   = "Allow"
        Resource = "arn:aws:iam::{ACCOUNT}:role/{ROLE名}"
      },
      {
        Action = [
          "ecs:DescribeServices"
        ]
        Effect   = "Allow"
        Resource = "arn:aws:ecs:{REGION}:{ACCOUNT}:service/{ECS_CLUSTER_NAME}/{ECS_SERVICE_NAME}"
      },
      {
        Action = [
          "ecs:CreateTaskSet",
          "ecs:DeleteTaskSet"
        ]
        Effect   = "Allow"
        Resource = "*"
        conditions = [
          {
            test : "StringLike"
            variable = "ecs:service"
            values = [
              "arn:aws:ecs:{REGION}:{ACCOUNT}:service/{ECS_CLUSTER_NAME}/{ECS_SERVICE_NAME}"
            ]
          }
        ]
      },
      {
        Action = [
          "ecs:RegisterTaskDefinition",
          "ecs:DescribeTaskDefinition"
        ]
        Effect    = "Allow"
        resources = ["*"]
      },
      {
        Action = [
          "elasticloadbalancing:ModifyRule"
        ]
        Effect   = "Allow"
        Resource = "arn:aws:elasticloadbalancing:{REGION}:{ACCOUNT}:listener-rule/app/{ALB_NAME}/*"
      },
      {
        Action = [
          "elasticloadbalancing:DescribeLoadBalancers",
          "elasticloadbalancing:DescribeListeners",
          "elasticloadbalancing:DescribeRules",
          "elasticloadbalancing:DescribeTargetGroups"
        ]
        Effect   = "Allow"
        Resource = "*"
      },
      {
        Action = [
          "ec2:DescribeSubnets",
          "ec2:DescribeSecurityGroups"
        ]
        Effect    = "Allow"
        resources = ["*"]
      },
    ]
  })
}

ECSのクラスタや、ECSサービス名、ALB名はCICDのRoleの対象範囲などを考慮して置換・読み替えてください。
Create/DeleteTaskSetの権限は、Resourceでは絞れず、Conditionで起動するServiceを固定しています。DescribeLoadBalancers系の権限とec2:DescribeSubnet、DescribeSecurityGroupsの権限は、ワークフローの中で、状態判定を入れていますので、そのためのものです。

"elasticloadbalancing:ModifyRule"は言わずもがな、ListenerRuleを書き換えてリリースのために必要です。ListenerRuleはARNがランダム値が付与されるので、ALB名までで絞っています。

GitHubActions

KINTOテクノロジーズではCICDツールとしてGitHubActionsを利用しています。
運用方法としてはプラットフォームGでCICD標準ワークフローを作成して、それをアプリ開発チームに提供して利用していただいています。

ワークフロー概要

今回のワークフローでは以下のステップに沿った形でBlueGreenDeploymentの仕組みを作成しました。今回ご紹介するのはDeployのワークフローのみとなります。

ワークフロー図

注意した点など

これらのワークフローをアプリ開発チームに提供する側として以下の点に注意しました。

  • 誤操作を発生させないように実行時のパラメータ指定は最小限にする実装
    • ワークフローは手動実行のため実行時に誤ったパラメータを指定しないよう、CLIで取得できるパラメータは全てワークフロー内で取得する
  • ワークフロー設定の簡略化
    • シークレットを極力使わないような実装
    • 環境変数でAWSリソース名を設定しているが、システム固有の値以外は固定値にすることでほとんど設定が不要
    • シークレットに利用するAWSリソースのARNを全て登録しておけば、ワークフロー内でリソース名からARNを取得する処理が不要になりコード量が減ります。
      しかし、初期設定の負担をなるべく減らすためにほとんど設定不要のリソース名からCLIでARNを取得して利用する処理で実装しました。

ワークフローの実装

ここでは各ワークフローをサンプルコードを用いながら主となる処理の説明をしたいと思います。

どのワークフローも基本的には、

AWS Credentialsの取得 → CLIで必要なパラメータを取得 → バリデーションチェック → 実行

のような流れになっています。

タスクセット作成

ワークフロー実行時のパラメータは、ECRにあるイメージタグと環境です。

タスクセット作成前にテスト用としてターゲットグループが利用できるか、実行時のパラメータのイメージタグがECRに存在するか、などのバリデーションチェックを入れます。
その後、イメージタグからタスク定義を作成します。
タスク定義の作成後はタスクセット作成時に必要なパラメータ(サブネット、セキュリティグループ、タスク定義)を取得して、タスクセットを作成するCLIを実行する流れです。

jobs:

  ...

  ## 利用するターゲットグループの確認
  check-available-targetGroup:
    ...

  ## ECRのイメージからタスク定義の作成
  deploy-task-definition:
    ...

  ## タスクセットの作成
  create-taskset:
    runs-on: ubuntu-latest
    needs: deploy-task-definition
    steps:
      # AWS Credentialsの取得
      - Set AWS Credentials
       ...

      - targetGroupの取得
        ...

      # タスクセットの作成
      - name: Create TaskSet
        run: |
          # タスク定義のARNを取得
          taskDefinition=`aws ecs describe-task-definition\
            --task-definition ${{ env.TASK_DEFINITION }}\
            | jq -r '.taskDefinition.taskDefinitionArn'`

          echo $taskDefinition

          # サブネットの取得
          subnetList=(`aws ec2 describe-subnets | jq -r '.Subnets[] | select(.Tags[]?.Value | startswith("${{ env.SUBNET_PREFIX }}")) | .SubnetId'`)

          if [ "$subnetList" == "" ]; then
            echo ※サブネットが取得できないため、処理を中断します。
            exit 1
          fi

          # セキュリティグループの取得
          securityGroupArn1=`aws ec2 describe-security-groups | jq -r '.SecurityGroups[] | select(.Tags[]?.Value == "${{ env.SECURITY_GROUP_1 }}") | .GroupId'`
          if [ "$securityGroupArn1" == "" ]; then
            echo ※セキュリティグループが取得できないため、処理を中止します。
            exit 1
          fi
          securityGroupArn2=`aws ec2 describe-security-groups | jq -r '.SecurityGroups[] | select(.Tags[]?.Value == "${{ env.SECURITY_GROUP_2 }}") | .GroupId'`
          if [ "$securityGroupArn2" == "" ]; then
            echo ※セキュリティグループが取得できないため、処理を中止します。
            exit 1
          fi

          echo ---------------------------------------------
          echo タスクセットの作成
          aws ecs create-task-set\
            --cluster ${{ env.CLUSTER_NAME }}\
            --service ${{ env.SERVICE_NAME }}\
            --task-definition ${taskDefinition}\
            --launch-type FARGATE\
            --network-configuration "awsvpcConfiguration={subnets=["${subnetList[0]}","${subnetList[1]}"],securityGroups=["${securityGroupArn1}","${securityGroupArn2}"]}"\
            --scale value=100,unit=PERCENT\
            --load-balancers targetGroupArn="${createTaskTarget}",containerName=application,containerPort=${ env.PORT }

リスナールール切り替え

リスナールール切り替えのワークフローでは、まず起動中のタスクセット数を取得して確認します。
本番環境のタスクセットだけが起動している場合に(タスク数は1のとき)、本番環境とテスト環境に関連するリスナールールを切り替えてしまうと、本番環境に紐づくタスクセットがなくなってしまいます。
そのケースを避けるために起動中のタスクセット数を取得して、1つ以下のときはリスナールールを切り替えずに処理を落とす実装をしています。

その後は本番用とテスト用リスナールールの切り替えです。2つのリスナールールを切り替えるCLIがないため、切り替えと言っていますが正確にはリスナールールを変更するCLI(modify-rule)を実行しています。それぞれのリスナールール変更の処理を同時並行で実行しているため、多少の処理時間の差があった場合でも2つのリスナールールがともにテスト環境に紐づかないよう、sleepコマンドで処理タイミングを調整しています。

env:
  RULE_PATTERN: host-header ## http-header / host-header / path-pattern / source-ipなど
  PROD_PARAM: domain.com
  TEST_PARAM: test.domain.com
  ...

jobs:
  ## 起動しているタスクセットが1つ以下の場合はホストヘッダーを変更できないようする
  check-taskSet-counts:
    runs-on: ubuntu-latest
    steps:
      ## AWS Credentialsの取得
      - name: Set AWS Credentials
        ...

      # バリデーション
      - name: Check TaskSet Counts
        run: |
          taskSetCounts=(`aws ecs describe-services --cluster ${{ env.CLUSTER_NAME }}\
            --service ${{ env.SERVICE_NAME }}\
            --region ${{ env.AWS_REGION }}\
            | jq -r '.services[].taskSets | length'`)

          if [ "$taskSetCounts" == "" ]; then
            echo※ 起動中のタスクセット数が取得できないため、処理を中断します。
            exit 1
          fi

          echo 起動中のタスクセット数: $taskSetCounts

          if [ $taskSetCounts -le 1 ]; then
            echo ※起動中のタスクセット数が1つ以下のため、処理を中断します。
            exit 1
          fi


  ## ALBリスナールール(本番用、テスト用)の切り替え
  change-listener-rule-1:
    runs-on: ubuntu-latest
    needs: check-taskSet-counts
    steps:
      ## AWS Credentialsの取得
      - name: Set AWS Credentials
        ...

      - name: Change Listener Rules
        run: |
          # alb名からALBのARNを取得
          albArn=`aws elbv2 describe-load-balancers --names ${{ env.ALB_NAME }} | jq -r .LoadBalancers[].LoadBalancerArn`

          # ALBのARNからリスナーのARNを取得
          listenerArn=`aws elbv2 describe-listeners --load-balancer-arn ${albArn} | jq -r .Listeners[].ListenerArn`

          # リスナーのARNからリスナールールのARNを取得
          listenerRuleArnList=(`aws elbv2 describe-rules --listener-arn ${listenerArn} | jq -r '.Rules[] | select(.Priority != "default") | .RuleArn'`)
          
          pattern=`aws elbv2 describe-rules --listener-arn ${listenerArn}\
            | jq -r --arg listener_rule ${listenerRuleArnList[0]} '.Rules[] | select(.RuleArn  == $listener_rule) | .Conditions[].Values[]'`


          if [ "$pattern" == "" ]; then
            echo ※リスナールールが取得できないため、処理を中止します。
            exit 1
          fi

          echo ---------------------------------------------
          echo 現在のルールパターン: $pattern

          echo ---------------------------------------------
          if [ $pattern == "${{ env.TEST_PARAM }}" ]; then
            aws elbv2 modify-rule --rule-arn ${listenerRuleArnList[0]} --conditions Field="${{ env.RULE_PATTERN }}",Values="${{ env.PROD_PARAM }}"
          else
            sleep 5s
            aws elbv2 modify-rule --rule-arn ${listenerRuleArnList[0]} --conditions Field="${{ env.RULE_PATTERN }}",Values="${{ env.TEST_PARAM }}"
          fi

          echo ---------------------------------------------
          echo 変更後のルールパターン
          aws elbv2 describe-rules --listener-arn ${listenerArn}\
            | jq -r --arg listener_rule ${listenerRuleArnList[0]} '.Rules[] | select(.RuleArn  == $listener_rule) | .Conditions[].Values[]'

  ## ALBリスナールール(本番用、テスト用)の切り替え
  change-listener-rule-2:
    ...
    change-listener-rule-1と同様の処理で、listenerRuleArnListの要素の指定のみ異なる
    ...

タスクセット削除

タスクセット削除のワークフローでは、実行時のパラメータは環境のみにしています。

もし削除するタスクセットIDをパラメータに指定するような実装にすれば、ワークフローではそのタスクセットIDを削除するCLIを実行するだけなので一行で済みます(AWS Credentialsの取得などはありますが)。
しかし、もし誤って本番稼働中のタスクセットIDを指定してしまった場合、本番環境のタスクセットが消えてテスト環境のみ残る危険があります。
そのため、実行時のパラメータは環境のみにして、ワークフローの実装でテスト環境のタスクセットを取得して削除するような実装をしました。

env:
  TEST_PARAM: test.domain.com # テスト用のホストヘッダー
  ...

jobs:
  ## タスクセットの削除
  delete-taskset:
    runs-on: ubuntu-latest
    steps:
      ## AWS Credentialsの取得
      - name: Set AWS Credentials
        ...

      # テスト用ホストヘッダーに紐づくターゲットグループを取得
      - name: Get TargetGroup
        run: |
          # ALB名からALBのARNを取得
          albArn=`aws elbv2 describe-load-balancers --names ${{ env.ALB_NAME }} | jq -r .LoadBalancers[].LoadBalancerArn`

          # ALBのARNからリスナーのARNを取得
          listenerArn=`aws elbv2 describe-listeners --load-balancer-arn ${albArn} | jq -r .Listeners[].ListenerArn`
          
          # リスナーのARNとテスト用のホストヘッダーから、テスト用ルールに紐づくターゲットグループを取得
          testTargetGroup=`aws elbv2 describe-rules --listener-arn ${listenerArn}\
            | jq -r '.Rules[] | select(.Conditions[].Values[] == "${{ env.TEST_PARAM }}") | .Actions[].TargetGroupArn'`

          echo "testTargetGroup=${testTargetGroup}" >> $GITHUB_ENV

      # リスナールールがテスト用ホストヘッダーのターゲットグループに紐づくタスクセットIDを取得
      - name: Get TaskSetId
        run: |
          taskId=`aws ecs describe-services\
            --cluster ${{ env.CLUSTER_NAME }}\
            --service ${{ env.SERVICE_NAME }}\
            --region ${{ env.AWS_REGION }}\
            | jq -r '.services[].taskSets[] | select(.loadBalancers[].targetGroupArn == "${{ env.testTargetGroup }}") | .id'`

          if [ "$taskId" == "" ]; then
            echo  ※テスト用ホストヘッダーのターゲットグループに紐づくタスクセットが見つからないため、処理を中断します。
            exit 1
          fi

          echo 削除予定のタスクセットID
          echo $taskId
          echo "taskId=${taskId}" >> $GITHUB_ENV
      
      # 取得したタスクセットIDからタスクセットを削除
      - name: Delete TaskSet
        run: |
          aws ecs delete-task-set --cluster ${{ env.CLUSTER_NAME }} --service ${{ env.SERVICE_NAME }} --task-set ${{ env.taskId }}

次のステップ

ALBのListenerRuleの部分をブラッシュアップ、検討をしてカナリアリリースについても可能としたいが、まずは使用してもらって、フィードバックをいただくのが先ということで、アプリケーション側へ展開中です。

GithubActionsのワークフローに関しては、シークレットは極力使わない実装ができましたが、まだ環境変数の設定が多いため今後は減らしていきたいと思います。例えば、システム固有の値だけを環境変数で設定するなど。
また、リスナールールの切り替えは安全かつ瞬時にルールの切り替えはできないかなと模索中です。

所感

最初にもありましたが、ECS + EXTERNAL(GithubActions)でのBlueGreenDeploymentの実例はおそらく少なく、参考にできるドキュメントがない状態からここまで仕組みを作成しました。
振り返るとGithubActionsのワークフローでの実装自体は難しくはないと思いますが、簡単(設定が少ない)かつ安全に利用できるワークフローを目指すために工夫できた点がたくさんありました。
今後はこの仕組みを実際に利用していただき、フィードバックから改善してより良い仕組みを目指していきたいと思います。

まとめ

OperationToolManagerチームは、社内向けの横断ツールを統制して必要なものを開発しています。
Platformグループの他チームが作ったものを受け入れたり、必要なものを新規作成や既存のものをマイグレーションしたりしています。
こういった活動に少しでも興味を持ったり話を聞いてみたいと思った方は、お気軽にご連絡いただければと思います。

Facebook

関連記事 | Related Posts

We are hiring!

【プラットフォームエンジニア】プラットフォームG/東京・大阪

プラットフォームグループについてAWS を中心とするインフラ上で稼働するアプリケーション運用改善のサポートを担当しています。

【クラウドエンジニア】Cloud Infrastructure G/東京・大阪

KINTO Tech BlogWantedlyストーリーCloud InfrastructureグループについてAWSを主としたクラウドインフラの設計、構築、運用を主に担当しています。