概要
Blue/Green方式でアプリケーションをデプロイしたときに、本稼働トラフィックを置き換えタスクセットに再ルーティングするタイミングを微調整してみました。具体的には、以下の面倒くさい点を満たすように対応しました。
- 置き換えタスクセットが安定稼働するまで待機したい
- 安定稼働後、自動的に再ルーティングさせたい(開発環境のため、厳密にテストせずに自動で切り替えています)
- アプリがこけてロールバックされたとき、Microsoft Teamsへ通知したい(置き換えタスクセットをテストせずに、このタイミングで修正依頼 ※置き換えタスクセットをテストしてもらえば良いのでしょうが・・・)
- 上記を環境ごとに調整したい
環境
ざっくりと、以下のような状況を想定します。
- AWS ECS上でアプリケーションを稼働
- デプロイには AWS CodeDeployを利用、Blue/Green方式を採用
- CI/CDワークフローは Github Actionsで管理
フロー
- Github上でmainブランチにマージ(featureブランチでビルドエラーは事前に排除)
- Github Actions のワークフローが実行されデプロイ開始
- デプロイ中にアプリが起動失敗した場合はロールバック、Teamsへ通知
- アプリが起動成功している場合はトラフィックを再ルーティング、元のタスクセットを終了
3と4について、フローチャートにしてみました。
AWS LambdaでTeamsへ通知
Teamsへの通知は、SNSとLambdaで実現しました。SNSトピックとしてCodeDeployを登録して、サブスクリプションとしてLambdaを指定しました。
Lambdaの中身は以下に示しますが、やっていることは、
・SNSからデプロイ情報を取得
・メッセージ情報を作成
・送信先を指定(今回はデプロイ情報に応じて向き先を変えています)
TeamsのWebhook_URLの廃止が決定されて、PowerAutomate Workflowに変更するのが地味に面倒でした。
# インポートは省略
# ログ設定
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でワークフローを組みました。
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
#!/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ですね。
SampleDeploymentGroup:
Type: "AWS::CodeDeploy::DeploymentGroup"
Properties:
(略)
BlueGreenDeploymentConfiguration:
DeploymentReadyOption:
ActionOnTimeout: STOP_DEPLOYMENT // CONTINUE_DEPLOYMENTにすればOK
WaitTimeInMinutes: XX // 待機させたい時間に変更
TerminateBlueInstancesOnDeploymentSuccess:
Action: TERMINATE // 残したい場合はKEEP_ALIVE
TerminationWaitTimeInMinutes: XX
(略)
AWSリソースを変更するのに、業務内の手続き上すぐに対応できないことがあるので、今回は別の方法でカバーしたということで。また今回は色々と変更したかったので大目に見てください。