Blue/Greenデプロイ時のトラフィック再ルーティングとロールバック通知

## 概要
Blue/Green方式でアプリケーションをデプロイしたときに、本稼働トラフィックを置き換えタスクセットに再ルーティングするタイミングを微調整してみました。具体的には、以下の面倒くさい点を満たすように対応しました。

- 置き換えタスクセットが安定稼働するまで待機したい
- 安定稼働後、自動的に再ルーティングさせたい（開発環境のため、厳密にテストせずに自動で切り替えています）
- アプリがこけてロールバックされたとき、Microsoft Teamsへ通知したい（置き換えタスクセットをテストせずに、このタイミングで修正依頼  ※置き換えタスクセットをテストしてもらえば良いのでしょうが・・・）
- 上記を環境ごとに調整したい

## 環境
ざっくりと、以下のような状況を想定します。
- AWS ECS上でアプリケーションを稼働
- デプロイには AWS CodeDeployを利用、Blue/Green方式を採用
- CI/CDワークフローは Github Actionsで管理

## フロー
1. Github上でmainブランチにマージ（featureブランチでビルドエラーは事前に排除）
2. Github Actions のワークフローが実行されデプロイ開始
3. デプロイ中にアプリが起動失敗した場合はロールバック、Teamsへ通知
4. アプリが起動成功している場合はトラフィックを再ルーティング、元のタスクセットを終了

3と4について、フローチャートにしてみました。

![アルゴリズムフローチャートの例 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918902/aab95e9f-cbc7-c700-2335-09cd7494c456.png)

### AWS LambdaでTeamsへ通知
Teamsへの通知は、SNSとLambdaで実現しました。SNSトピックとしてCodeDeployを登録して、サブスクリプションとしてLambdaを指定しました。

Lambdaの中身は以下に示しますが、やっていることは、
　・SNSからデプロイ情報を取得
　・メッセージ情報を作成
　・送信先を指定（今回はデプロイ情報に応じて向き先を変えています）

TeamsのWebhook_URLの廃止が決定されて、PowerAutomate Workflowに変更するのが地味に面倒でした。

```lambda.py
# インポートは省略

# ログ設定
logger = getLogger(__name__)
logger.setLevel(INFO)
# HTTPクライアント
http = urllib3.PoolManager()
# 日本時間変更用
DIFF_JST_FROM_UTC = 9

def lambda_handler(event, context):
    # SNSからデプロイ時のイベント情報を取得
    records = event.get('Records', [])
    sns_message = records[0].get('Sns', {}).get('Message', '{}')

    # Messageが文字列なので辞書にパース
    try:
        message_dict = json.loads(sns_message.replace("'", '"'))
    except json.JSONDecodeError as e:
        logger.error(f"JSONDecodeError: {e}")
        return

    # Messageから必要な要素を取得
    detail = message_dict.get('detail', {})
    deploymentId = detail.get('deploymentId', '')
    deploymentGroup = detail.get('deploymentGroup', '')
    serviceName = deploymentGroup.replace('projectX-', '').replace('-codedeploy-deploymentgroupX', '')  # 通知先判定用にデプロイグループ名からサービス名を抽出しています
    state = detail.get('state', '')
    
    # ログ出力
    logger.info(f"Records: {records}")

    # Messageから実行時間を取得し、日本時間に変換
    utc_time_str = message_dict.get('time', '')
    try:
        utc_time = datetime.datetime.strptime(utc_time_str, "%Y-%m-%dT%H:%M:%SZ")
        jst_time = utc_time + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
    except ValueError as e:
        logger.error(f"Time conversion error: {e}")
        return

    # 指定時間内かどうかを確認
    start_hour = 10
    end_hour = 21
    if not (start_hour <= jst_time.hour < end_hour):
        logger.info("指定時間外のため、送信処理をスキップします。")
        return

    # メッセージのボディ情報を取得（後述の関数）
    title, detail = get_message(serviceName, deploymentGroup, deploymentId)
    
    # TEAMS_WORKFLOW_URLを取得（後述の関数）
    TEAMS_WORKFLOW_URL_LIST = get_post_url(serviceName.split('-')[1])  # hoge(fuga)-<SERVICE>のためサービス名だけ抽出

    # メッセージ作成
    message = {
      "attachments": [
        {
          "contentType": "application/vnd.microsoft.card.adaptive",
          "content": {
            "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
            "type": "AdaptiveCard",
            "version": "1.2",
            "body": [
              {
                "type": "TextBlock",
                "text": title + detail,
                "wrap": True,
                "markdown": True
              }
            ]
          }
        }
      ]
    }

    # 送信処理
    if state == "FAILURE":
        for URL in TEAMS_WORKFLOW_URL_LIST:
            try:
                response = http.request(
                    'POST',
                    URL,
                    body=json.dumps(message).encode('utf-8'),
                    headers={'Content-Type': 'application/json'}
                )
                if 200 <= response.status < 300:
                    print("メッセージが送信されました。")
                else:
                    print(f"エラーが発生しました: {response.status}, {response.data.decode('utf-8')}")
            except Exception as e:
                print(f"例外が発生しました: {e}")
    else:
        print("デプロイは成功しました。")

# メッセージ作成する関数
def get_message(serviceName: str, deploymentGroup: str, deploymentId: str) -> (str):    
    # 失敗時に参照するログURL
    if serviceName.startswith("hoge-"):
        log_url = "***"
    elif serviceName.startswith("fuga-"):
        log_url = "***"
    else:
        log_url = "***"
    # タイトル
    title = "【デプロイ失敗】 " + serviceName
    # 本文
    detail = " \n\n - サービス名 : " + serviceName.split('-')[1] + " \n - デプロイID : " + deploymentId + " \n - デプロイグループ : " + deploymentGroup + " \n - ログ : " + log_url + " \n ※対象サービスの最新ログストリームを確認"

    return title, detail

# TEAMS_WORKFLOW_URLを返す関数
def get_post_url(serviceName: str) -> (list):
    # デフォルト
    URL00 = "***"
    # サービスA
    URL01 = "***"
    # サービスB
    URL02 = "***"

    if serviceName == 'serviceA':
        TEAMS_WORKFLOW_URL_LIST = [URL01]
    elif serviceName == 'serviceB':
        TEAMS_WORKFLOW_URL_LIST = [URL02]
    else:
        TEAMS_WORKFLOW_URL_LIST = [URL00]

    return TEAMS_WORKFLOW_URL_LIST
```

### Github ACtionsでトラフィックを再ルーティング
待機中の置き換えタスクセットを本稼働トラフィックへ再ルーティングするのは、Github Actionsでワークフローを組みました。

```.github/workflows/routing-new-traffic-workflow.yml
name: Routing

# -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
# Event : scheduled two times hourly
# -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
on:
  schedule:
    - cron: "0,30 0-11 * * MON-FRI"  # 任意に設定（開発時は月-金の9時-20時半まで30分置きに実行）
  workflow_dispatch:  # ブラウザ画面からボタン操作で実行する場合

# -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
# jobs
# -+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
jobs:
  # ---------------------------------------
  # Routing new traffic
  # ---------------------------------------
  deploy-for-ready:
    runs-on: ubuntu-latest

    steps:
      # - - - - - - - - - - - - - - - - #
      # このワークフローを管理するプロジェクトをチェックアウト
      # - - - - - - - - - - - - - - - - #
      - name: Checkout Common Repository
        uses: actions/checkout@v4
        with:
          repository: "***"  # リポジトリ名
          ref: *** # ブランチ指定する場合
          token: ${{ secrets.TOKEN }}  # GithubのPersonal Access Token

      # - - - - - - - - - - - - - - - - #
      # 環境ごとに AWS Configure for CLI
      # ※後続のシェル内でCLIコマンドを利用するため
      # ※他環境の処理を省略
      # - - - - - - - - - - - - - - - - #
      - name: Configure AWS credentials for CLI
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.ACCESS_KEY_ID }}  # AWSアクセスキーID
          aws-secret-access-key: ${{ secrets.SECRET_ACCESS_KEY }}  # AWSシークレットアクセスキー
          aws-region: ${{ vars.AWS_REGION }} # AWSリージョン

      # - - - - - - - - - - - - - - - - #
      # 環境ごとに デプロイ用のシェルを実行
      # ※他環境の処理を省略、以下は開発環境のバックエンドアプリをデプロイ
      # - - - - - - - - - - - - - - - - #
      - name: Routing new traffic in Ready status for Backend
        run: |
          bash .github/sh/develop/backend/deploy_for_ready.sh
```

```.github/sh/develop/backend/deploy_for_ready.sh
#!/bin/bash

# アプリケーション名を定義
APP_NAME="***"

# 現在時刻からX分前の時刻を取得（UTC時刻で指定）
ONE_HOUR_AGO=$(date -u -d "1 hour ago" '+%Y-%m-%dT%H:%M:%SZ')
TEN_MINUTES_AGO=$(date -u -d "10 minutes ago" '+%Y-%m-%dT%H:%M:%SZ')

# デプロイグループを取得
DEPLOYMENT_GROUPS=$(aws deploy list-deployment-groups \
  --application-name $APP_NAME \
  --query "deploymentGroups[]" \
  --output text)

for group in $DEPLOYMENT_GROUPS
do
  # 過去1時間以内かつ10分以上前に「Ready」状態のデプロイメントをリスト ※安定稼働する前にトラフィック切替たくないので10分前までに限定
  DEPLOYMENTS=$(aws deploy list-deployments \
    --application-name $APP_NAME \
    --deployment-group-name $group \
    --create-time-range start=$ONE_HOUR_AGO,end=$TEN_MINUTES_AGO \
    --include-only-statuses Ready \
    --query "deployments[]" \
    --output text)
  
  # DEPLOYMENTSが空の場合はスキップ
  if [ -z "$DEPLOYMENTS" ]; then
    continue
  fi
  
  echo "  ---------------------  "
  echo "Deployment Group : $group"
  echo "DEPLOYMENTS: $DEPLOYMENTS"

  for deployment_id in $DEPLOYMENTS
  do
    if [[ ! $deployment_id =~ ^d-[A-Z0-9]*$ ]]; then
      echo "Invalid deployment_id : $deployment_id"
      continue
    fi

    echo "Ready Deployment ID : $deployment_id"
    
    # デプロイ情報を取得してリビジョンのSHA256を抽出
    REVISION_SHA256=$(aws deploy get-deployment \
      --deployment-id $deployment_id \
      --query 'deploymentInfo.revision.appSpecContent.sha256' \
      --output text)
    
    # REVISION_SHA256 が空の場合はスキップ
    if [ -z "$REVISION_SHA256" ]; then
      echo "▼FAILED to retrieve REVISION_SHA256 : $deployment_id"
      continue
    fi
    
    # サービス名をデプロイグループ名から取得
    SERVICE_NAME=$(echo $group | sed 's/hogeapp-\(.*\)-hogegroup/\1/')
            
    # トラフィックの再ルーティングを実行
    echo "Continuing deployment with READY_WAIT : $deployment_id"
    aws deploy continue-deployment \
      --deployment-id "$deployment_id" \
      --deployment-wait-type "READY_WAIT"
    
    if [ $? -ne 0 ]; then
      echo "▼FAILED to continue deployment (READY_WAIT) : $deployment_id"
      continue
    else
      echo "★SUCCESSFULLY continued deployment (READY_WAIT) : $deployment_id"
    fi
    
    # 元のタスクセットの終了を実行
    echo "Continuing deployment for Deployment ID: $deployment_id with TERMINATION_WAIT"
    aws deploy continue-deployment \
      --deployment-id "$deployment_id" \
      --deployment-wait-type "TERMINATION_WAIT"
    
    if [ $? -ne 0 ]; then
      echo "▼FAILED to continue deployment (TERMINATION_WAIT) : $deployment_id"
    else
      echo "★SUCCESSFULLY continued deployment (TERMINATION_WAIT) : $deployment_id"
    fi

  done
done
```


## エクスキューズ
待機させてそのまま再ルーティングさせたい場合は、CodeDeployの設定で、ActionOnTimeoutを「CONTINUE_DEPLOYMENT」にすればOKですね。

```cloudformation.yml
SampleDeploymentGroup:
    Type: "AWS::CodeDeploy::DeploymentGroup"
    Properties:
        (略)
        BlueGreenDeploymentConfiguration:
            DeploymentReadyOption:
                ActionOnTimeout: STOP_DEPLOYMENT  // CONTINUE_DEPLOYMENTにすればOK
                WaitTimeInMinutes: XX  // 待機させたい時間に変更
              TerminateBlueInstancesOnDeploymentSuccess:
                Action: TERMINATE  // 残したい場合はKEEP_ALIVE
                TerminationWaitTimeInMinutes: XX
        (略)
```

AWSリソースを変更するのに、業務内の手続き上すぐに対応できないことがあるので、今回は別の方法でカバーしたということで。また今回は色々と変更したかったので大目に見てください。