エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

terraformで AWS ECS built-in Blue/Greenを実装しよう

【Unit4 ブログリレー12日目】 エムスリーエンジニアリンググループの松山です。Unit4(m3.com開発チーム)のチームSREとしてなんでもやっています。

最近は手芸が好きで、紫外線硬化レジンで飼い猫や好きなキャラクターのグッズを作ったり、羊毛フェルトにひたすら針をさしたりしています。

この記事では、起動時の挙動が不安定なサービスのデプロイを安定させるため、ECS Blue/Greenデプロイへの切り替えを試してみた、という内容を書きます。

世界一かわいい生き物のアクキー(高さ12cm)。気泡を減らすのが今後の課題です

背景

とあるScalaで書かれているサービスを改修するため調べていたところ、起動直後の不安定さが気になりました。ECSではローリングアップデートによってデプロイを実施しているのですが、起動直後数分間のCPU利用率が高くなりがちでした。(実装が軽量な)ヘルスチェックは通るためタスクがHealthyと判定され、実際のリクエストが届いたところでAPIがGateway Timeoutを発生させるようになる、という事象がたまに起きるようになりました。

起動直後の挙動を改善するべくチーム内で調査を進めることになったのですが、それとは並行して別のアプローチについても検討を始めました。

AWS ECS Blue/Green デプロイ

2025年7月22日に AWS ECS Blue/Greenデプロイが発表されました。

aws.amazon.com

ECS Blue/Greenデプロイ(以下BGデプロイと略します) は、ECSサービス内で完結してBGデプロイを行えるようにする仕組みです。これまでECS環境で同等の内容を実施するにはCode Deployの設定を書く必要がありましたが、これにより設定が簡略化されるとのことです。

そしてこのBGデプロイ中にhookを書けるとのことで、「BGデプロイ中にヘルスチェックが通ってから挙動が安定するまでの間1-2分待機時間を設ければいいのでは?」 と思いつきました。

以下は検証レベルではあるのですが、実装にあたってハマりポイントがいくつかあったため備忘録として共有したい、というのがこの記事の趣旨となります。

必要なリソース

  1. Blue相当となるalternative ターゲットグループ
  2. ECSがトラフィックを切り替えるためのリスナールール
  3. deploy hookとなるlambda関数
  4. ECS ターゲットグループやリスナーの設定を変更するためのポリシー
  5. ECS lambda関数をinvokeするためのロール
  6. BGデプロイのためのサービス設定変更

1,2,3 はイメージしやすいと思います。ECS BGデプロイではECSがそれぞれBlue/Green となる2つのターゲットグループ交互に使用することでBGデプロイを行います。

トラフィックの切り替えはECSが、BGデプロイ用に設定されているリスナールールをデプロイの段階によって切り替えたり合成したりして振り分けることにより行います。オプションとして、先行してGreen側に振り分けるトラフィックをリスナールールとして与えることも可能です(テストトラフィック)。

deploy hookは後述しますがBGデプロイの指定した段階で実行され、次の段階に進んでいいかどうかを返すLambda関数を用意しておくことで、こちらからデプロイの進行を制御できる、というものです。

問題は4,5 で、上記の挙動をすべてECSが主役となって行うため、ECSに対して各リソースへの権限を設定する必要があります。

実装

なお、チームにおけるterraformの大まかな実装方針についてはこちらの記事をお読みください。

www.m3tech.blog

ECSのヘルスチェック猶予時間は次のように設定しています。

  health_check_grace_period_seconds = 300

(もちろんこの値も調整しているのですが、あんまり長くしてもデプロイにかかる時間が長くなります)

ターゲットグループとリスナールール

既存のターゲットグループに追加して、Green用のターゲットグループを作成します。

また、リスナールールを既存のリスナーの中に作成します。 今回はすべてのトラフィックを一度にGreenに向けてもらって構わないので、testトラフィックとproductionトラフィック用のリスナールールの内容は同じにしてあります。

Deploy hook

hook関数の説明の前にライフサイクルステージに軽く触れておきます。 いくつかあるステージのうち、lifecycle hookを実行させられるステージは次のようなものがあります。

*ステージ名 *説明
PRE_SCALE_UP Green側のターゲットグループにタスクを生やす前の状態
POST_SCALE_UP Green側のターゲットグループに必要数Healthyなタスクが生えた状態。今回はこのステージにdeploy hookを使います
TEST_TRAFFIC_SHIFT  testトラフィックをGreenに流す状態。この段階でGreen側のエラーが一定以上発生したらロールバックさせるようにしても良さそうです
POST_TEST_TRAFFIC_SHIFT すべてのtestトラフィックがGreenに振り分けられた状態
PRODUCTION_TRAFFIC_SHIFT productionトラフィックをGreenに振り分けていく状態
POST_PRODUCTION_TRAFFIC_SHIFT すべてのproductionトラフィックがGreenに振り分けられた状態

この各ステージ中にlambda関数を実行させることができる、というのがdeploy hookです。

具体的には、"hookStatus": "SUCCEEDED" を返却すると次の段階へ進み、"hookStatus": "FAILED" を返却するとデプロイが失敗したとみなされてロールバックを開始します。今回は「一定時間待ったら次の段階に進んでください」というhookなので次のように書いてみます。

import json
import logging
import time

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    logger.info(f"Event: {json.dumps(event)}")
    logger.info(f"Context: {context}")

    time.sleep(180)

    return {
        "hookStatus": "SUCCEEDED",
    }

権限設定

まず、ECSがターゲットグループやリスナールールを操作するためのroleを用意しましょう。

data "aws_iam_policy_document" "alb_service_role_assume_policy" {
  statement {
    effect = "Allow"
    actions = [
      "sts:AssumeRole"
    ]
    principals {
      type = "Service"
      identifiers = [
        "ecs.amazonaws.com"
      ]
    }
  }
}

resource "aws_iam_role" "alb_service_role" {
  name               = "${var.application_env_name}-ecs-alb-service-role"
  assume_role_policy = data.aws_iam_policy_document.alb_service_role_assume_policy.json
}

このroleにアタッチするポリシーなのですが、直近(8月10日時点)ではAmazonECSInfrastructureRolePolicyForLoadBalancers を渡せばいいことになりました。

# BGデプロイ用にECSに渡すIAMロール用ポリシー
# https://docs.aws.amazon.com/ja_jp/aws-managed-policy/latest/reference/AmazonECSInfrastructureRolePolicyForLoadBalancers.html
resource "aws_iam_role_policy_attachment" "alb_service_role_policy" {
  role       = aws_iam_role.alb_service_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForLoadBalancers"
}

次にECSが deploy hook Lambda関数をinvokeするためのroleを作りましょう。こちらは何を作ればいいかわかっていれば特に困ることは無いと思います。

# ECSのデプロイフック用のLambda関数のIAMロールとポリシー
# https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/blue-green-permissions.html
data "aws_iam_policy_document" "ecs_deploy_hook_policy" {
  statement {
    effect = "Allow"
    actions = [
      "lambda:InvokeFunction",
    ]
    resources = [aws_lambda_function.deploy_hook.arn]
  }
}

resource "aws_iam_policy" "ecs_deploy_hook_policy" {
  name   = "${var.application_env_name}-ecs-deploy-hook-policy"
  policy = data.aws_iam_policy_document.ecs_deploy_hook_policy.json
}

data "aws_iam_policy_document" "ecs_deploy_hook_role_assume_policy" {
  statement {
    actions = [
      "sts:AssumeRole"
    ]
    principals {
      type = "Service"
      identifiers = [
        "ecs.amazonaws.com"
      ]
    }
  }
}

resource "aws_iam_role" "ecs_deploy_hook_role" {
  name               = "${var.application_env_name}-ecs-deploy-hook-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_deploy_hook_role_assume_policy.json
}


resource "aws_iam_role_policy_attachment" "ecs_task_role_deploy_hook" {
  role       = aws_iam_role.ecs_deploy_hook_role.name
  policy_arn = aws_iam_policy.ecs_deploy_hook_policy.arn
}

Service設定の変更

ここまでできたら、あとはServiceリソースの設定を変更するだけです。

resource "aws_ecs_service" "main" {
  ...
  # BG deploy
  deployment_configuration {
    strategy             = "BLUE_GREEN"
    bake_time_in_minutes = "3"
    lifecycle_hook {
      hook_target_arn  = aws_lambda_function.deploy_hook.arn
      role_arn         = aws_iam_role.ecs_deploy_hook_role.arn
      lifecycle_stages = ["POST_SCALE_UP"]
    }
  }

  # Load balancer
  load_balancer {
    ...

    # BGデプロイ用の設定
    advanced_configuration {
      alternate_target_group_arn = var.alb_alternate_target_group_arn
      production_listener_rule   = var.alb_listener_rule_blue_arn
      role_arn                   = aws_iam_role.alb_service_role.arn
      test_listener_rule         = var.alb_listener_rule_green_arn
    }
  }
  ...
}

lifecycle_hookのrole_arn にはECSが deploy hookをinvokeできるようにするためのロールを、advanced_configurationのrole_arn には ECSがターゲットグループやリスナールールを操作するためのロールをそれぞれ設定しましょう。

bake_time_in_minutes は、Greenへのトラフィック全面移行が終わった後、Blueに所属するタスクを生かしておく期間を設定します。この間であればGreen側でなにか問題を見つけたときにロールバックできます。

挙動を確認してみよう

では検証環境で挙動を確認してみましょう。先ほど修正したterraform をapplyし、コンソールを確認してみます。

terraform apply後のデプロイ設定

デプロイ戦略がBlue/Greenデプロイに変わっていることがわかります。 なお、トラフィックの移行方法が「全て一度に」となっていますが、ここはまだ変更できません。ドキュメントを読む限りだと、今後すこしずつ割り振っていくようなオプションが追加されるのかと思われます。

また、ネットワーク設定を確認すると、次のようにBGデプロイ用の項目が追加されていることがわかります。

代替ターゲットグループなどの項目が追加されている。

では、検証環境でlocustを用いて一定のリクエストを投げながら、サービスのデプロイを行い経過をみてみましょう。

aws ecs update-service --cluster <クラスタ名> --service <サービス名> --force-new-deployment

Green側のタスクを生やし始めた段階
スケールアップ中となりました。この段階ではGreen側にタスクコンテナが起動し、ヘルスチェックが通るのを待っているところです。そして全てのタスクがHealthyとなりスケールアップ後段階にはいると、先ほど作成したdeploy hook関数が起動し、デプロイプロセスが180秒間停止します。

lambdaのログの抜粋

[INFO] ... Event: 
{
    ...
    "lifecycleStage": "POST_SCALE_UP",
    ...
}
...
REPORT RequestId: xxxxx Duration: 180003.61 ms  Billed Duration: 180004 ms  Memory Size: 128 MB Max Memory Used: 35 MB  Init Duration: 123.55 ms

このlambdaが完了後、テストトラフィック, 本番トラフィックがGreen側に移行され、Blue側へのトラフィックはなくなります。

そしてベークタイム中になると、すべてのトラフィックはGreen側に流れていますが、Blue側のタスクもまだスタンバイしています。

ベークタイムが経過すると最終的にBlue側のタスクは消えます。

次のcloudwatchのグラフにそれぞれのターゲットグループにきたリクエスト数の状況を示します。

緑線が元のターゲットグループ、橙線がデプロイ先のターゲットグループへのリクエスト

ここで緑矢印の時間帯がスケールアップ中、赤い矢印がdeploy hookによってデプロイプロセスが止まっている段階となります。 そしてトラフィック数が逆転し、青矢印のベークタイムの経過中もとのターゲットグループへのリクエストは0 (=まだターゲットグループやタスクは生きている) ということがわかります。

まとめ

今回はAWS ECS Blue/Greenデプロイを既存のローリングアップデートを採用しているプロダクトに導入するには、という内容を書きました。発表直後と比べると多少公式ドキュメントが増えてきたような気はするのですが、当初はほぼ発表のブログだけだったので情報が足りず、権限周りを試行錯誤した記憶があります。

Unit4のチームSREでは、チームが持つ全てのサービスの運用を開発メンバーやビジネスチームと連携しながら改善を続けています。

We are Hiring!

エムスリーでは一緒にサービスの信頼性を高めていけるエンジニアを絶賛募集中です。ご興味ある方は是非カジュアル面談等ご応募ください!

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイト

fresh.m3recruit.com

カジュアル面談はこちら

jobs.m3.com