2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-25

概要

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

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?