ECSブルーグリーンデプロイ、1年運用して痛感した落とし穴と対策
本番でブルーグリーンデプロイを1年運用して経験した失敗と解決策を赤裸々に。切り替えミス・ロールバック地獄・コネクション切断の実例から学べる。
ECSブルーグリーンデプロイ本番運用1年で気づいたこと
正直な話、うちのチームがECSのブルーグリーンデプロイを導入した当初は、「新旧環境を持てばダウンタイムなしで切り替わるでしょ」くらいの認識でした。ところが本番で運用し始めたら、想像以上にいろいろありました。1年経ったいま、「あの時こうしておけば…」という失敗が積み重なってるんです。
今回は、実際に踏んだ地雷と、それをどう乗り越えたか、をぶっちゃけで共有しますね。
最初に失敗した「単純に2つのサービス切り替える」という甘い考え
うちは最初、CloudFormationでECSタスク定義を2つ用意して、ALBのターゲットグループを手動で付け替えるやり方をしてました。デプロイ手順は単純で:
- Blue(本番)が動いてる
- Green(新環境)にタスク定義v2をデプロイ
- ALBのターゲットグループをGreenに付け替え
- ヘルスチェック通ったら完了
…これが地獄の入口だったんです。
実際に起きたトラブル
まず切り替えの瞬間、一部のコネクションが途中で切れました。クライアント側のソケットタイムアウトが3秒だったんですが、デプロイ中は新旧の負荷分散が不安定になって、一部リクエストが失敗してたんです。
それから2週間後。本番でバグが見つかったので、さっさと前のバージョン(Blue)に戻そうとしたら、前のバージョンのECSタスク定義が既に上書きされてて、どの定義が正しいのかわかんない状態に。結局手作業で復旧するのに45分かかりました。
おまけに、Greenにデプロイしたタスクが一部のみ立ち上がってて、ターゲットグループに登録されないまま数日放置されてたことも発見。これめちゃ危険なパターンですよね。
CodeDeployを導入して変わったこと
3ヶ月目に本気になって、ちゃんとしたブルーグリーンデプロイの仕組みを構築しました。CodeDeployを使って自動化することにしたんです。
基本的な構成は:
# CodeDeploy appspec.yml (簡略版)
version: 0.0
Resources:
- TargetService:
Type: AWS::ECS::Service
Properties:
TaskDefinition: "<TASK_DEFINITION>"
LoadBalancerInfo:
ContainerName: "web"
ContainerPort: 8080
PlatformVersion: "1.4.0"
Hooks:
- BeforeAllowTraffic: "pre_traffic_hook"
- AfterAllowTraffic: "post_traffic_hook"
この構成だと、CodeDeployが自動で:
- Green(新しいタスク定義)をデプロイ
- ヘルスチェック実行
- 指定したLambda関数(pre_traffic_hook)で本当に動いてるか検証
- OKなら段階的にALBの重みを100%に変更
- 古いタスク(Blue)をドレイン&停止
…という流れを全部やってくれます。手動介入がほぼなくなったのが、めちゃデカい。正直これだけで運用が一変しましたね。
ここが意外と大変だった「接続ドレイニング設定」
CodeDeploy導入で一つ気づいたのが、接続ドレイニングの設定がめちゃ重要だということ。
ALBの設定でタイムアウトを短く(デフォルト300秒)してると、長時間接続のWebSocketやgRPCが途中で強制的に切られます。逆に長くしすぎると、デプロイがダラダラ続いて気持ち悪い。
うちは最終的にこう決めました:
| 項目 | 設定値 | 理由 |
|---|---|---|
| 接続ドレイニングタイムアウト | 120秒 | BlueからGreenへの移行時間として十分 |
| HTTP/2 Keep-Alive自動切断 | 90秒 | ドレイニング完了前に新規接続が来ない |
| WebSocket用タイムアウト | 150秒 | 長時間接続でも途中で切られない |
この設定でやると、デプロイ中に新規接続はGreenに流れ、既存接続はBlueでゆっくり終了させられます。ユーザー体験がめっちゃ向上したんですよ。
ロールバック戦略を甘く見てた
デプロイが失敗したときの戻し方を、最初は「前のバージョンのタスク定義に戻すだけ」と思ってました。でも本番運用してみると、こんな課題が浮かんできます:
- デプロイ自体が途中で失敗した場合:Greenとは言いつつ、一部タスクだけ新バージョンという危険な状態に
- デプロイ後、5分以内にバグが見つかった場合:前のBlueはもう停止してる(ドレイン完了)
- Greenは立ち上がったけど、DBマイグレーションが失敗した場合:どっちに戻しても危険
これらの課題を解決するために、今はこんな構成にしてます:
# ロールバック用のLambda関数(抜粋)
def rollback_to_previous_task_definition(service_name, region='us-east-1'):
client = boto3.client('ecs', region_name=region)
# 前のタスク定義を取得
response = client.describe_services(
cluster='production',
services=[service_name]
)
current_task_def = response['services'][0]['taskDefinition']
# 1つ前のバージョンに戻す
task_def_arn = current_task_def.split(':')[0]
task_def_parts = current_task_def.split(':')
previous_revision = int(task_def_parts[-1]) - 1
previous_task_def = f"{task_def_arn}:{previous_revision}"
# サービスを更新(強制実行)
client.update_service(
cluster='production',
service=service_name,
taskDefinition=previous_task_def,
forceNewDeployment=True
)
print(f"Rolled back to {previous_task_def}")
でも個人的には、ロールバック自動化より「デプロイ前に本当にテストできてるか」の方が重要だと感じてます。実は、うちのチームは「smoke test」という軽量テストをGreenデプロイ直後に走らせるようにしました。これがめっちゃ効きます。
# pre_traffic_hook用Lambda (smoke test)
def lambda_handler(event, context):
import requests
import time
# CodeDeployから渡される新しいタスクのエンドポイント
hook_execution_id = event['DeploymentId']
lifecycle_event_hook_execution_id = event['LifecycleEventHookExecutionId']
try:
# 新環境のヘルスチェックエンドポイントに100回アクセス
for i in range(100):
response = requests.get(
'http://localhost:8080/health',
timeout=5
)
assert response.status_code == 200
# 簡単なAPI呼び出し
api_response = requests.get(
'http://localhost:8080/api/users/1',
timeout=5
)
assert api_response.status_code == 200
time.sleep(0.1)
# すべてOKならCodeDeployに成功を報告
codedeploy = boto3.client('codedeploy')
codedeploy.put_lifecycle_event_hook_execution_status(
deploymentId=hook_execution_id,
lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
status='Succeeded'
)
return {'statusCode': 200}
except Exception as e:
print(f"Smoke test failed: {e}")
codedeploy = boto3.client('codedeploy')
codedeploy.put_lifecycle_event_hook_execution_status(
deploymentId=hook_execution_id,
lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
status='Failed'
)
return {'statusCode': 500}
これをやると、DBマイグレーション失敗とか、環境変数ミスとか、依存サービスの疎通ミスとか、そういったおおよその問題は本番トラフィックが流れる前に見つかります。
AWS構成図:ブルーグリーンデプロイの全体像
graph TB
subgraph "VPC"
subgraph "ALB"
ALB["Application Load Balancer"]
end
subgraph "Blue Environment (旧)"
BTGP["Target Group: Blue"]
BEC2_1["ECS Task 1"]
BEC2_2["ECS Task 2"]
BTGP --> BEC2_1
BTGP --> BEC2_2
end
subgraph "Green Environment (新)"
GTGP["Target Group: Green"]
GEC2_1["ECS Task 1"]
GEC2_2["ECS Task 2"]
GTGP --> GEC2_1
GTGP --> GEC2_2
end
ALB ---|100% 切替え| BTGP
ALB ---|デプロイ時| GTGP
end
subgraph "CI/CD Pipeline"
GH["GitHub Push"]
CP["CodePipeline"]
CB["CodeBuild"]
CD["CodeDeploy"]
GH --> CP
CP --> CB
CB --> CD
end
subgraph "検証層"
SH["Smoke Test Lambda<br/>(pre_traffic_hook)"]
CD -.->|デプロイ| GTGP
GTGP --> SH
SH -->|OK| CD
end
subgraph "リソース管理"
ECS["ECS Service"]
TD["Task Definition<br/>Versioning"]
ECR["ECR Repository"]
end
CD -.->|タスク定義更新| ECS
ECS --> TD
ECR --> CB
この図で大事なポイント:
- デプロイ時:Green環境に新しいタスクをデプロイ
- Smoke Test:本番トラフィック流す前に軽量テスト実行
- 段階的切替:成功したらALBがBlue→Greenに100%切替え
- ドレイニング:古いBlue側の接続は120秒かけてゆっくり終了
本番運用で見えた「意外と大変だった」3つのこと
1. タスク数が多いと切り替え時間が長くなる
うちは本番で20個のECSタスクを動かしてます。CodeDeployでGreenに全部デプロイするのに3分、ヘルスチェック2分、トラフィック切替え2分…で計7分。
この間にバグを見つけると、ロールバックにまた7分かかるんですよ。つまり最大14分のリスク。地味に怖いです。これを見直すために、今は「カナリアデプロイ」も検討中。最初は10%のトラフィックだけGreenに流して、10分間様子を見る、みたいなやり方ですね。
2. ログの一貫性が難しい
BlueとGreenが同時に動いてる間、CloudWatch Logsを見ると、どちらのコンテナから出てるログなのかがわかりづらいんです。最初は結構困りました。
今は、ログにはタスクARNとコンテナインスタンスを自動付与するようにしました:
{
"timestamp": "2026-06-09T14:32:15Z",
"level": "INFO",
"message": "Request processed",
"task_arn": "arn:aws:ecs:us-east-1:123456789:task/production/abc123",
"container_instance": "arn:aws:ecs:us-east-1:123456789:container-instance/def456",
"environment": "green"
}
そうするとDatadogやCloudWatch Insights で環境ごとにフィルタできるようになります。地味ですが、トラブル時には超助かる。
3. RDSとの接続プール管理
これは本当に困りました。古いBlueのコンテナが接続を保ってるまま、新しいGreenのコンテナがデプロイされると、RDSのコネクションプールが急増することがあるんです。
うちはRDS Aurora PostgreSQLを使ってますが、最大接続数200で張られてるので、デプロイ時に一気に150まで行くことがありました。
対策として、タスク定義のコンテナに環境変数を追加しました:
# 接続プール設定(pgbouncer使用)
PG_POOL_SIZE = 5 # デプロイ中は最小限に
PG_RESERVE_POOL_SIZE = 2
PG_MAX_DB_CONNECTIONS = 200
# グレースフルシャットダウン(実装例)
async def shutdown_handler(signum, frame):
logger.info("Shutdown signal received, draining connections...")
pool.close()
await asyncio.sleep(5) # 既存接続を完了させる
exit(0)
実装してよかった「自動リソースクリーンアップ」
1年運用してると、古いタスク定義や、失敗したデプロイの残骸がどんどん溜まります。
うちは毎週日曜深夜に、古いタスク定義と未使用のセキュリティグループを削除する自動化を入れました。これだけで月単位で見ると、ECRストレージ代が地味に浮きます。
def cleanup_old_task_definitions(keep_count=5):
ecs = boto3.client('ecs')
# 全タスク定義を取得
response = ecs.list_task_definitions(
familyPrefix='production-web',
sort='DESC',
maxResults=100
)
task_defs = response['taskDefinitionArns']
# keep_count個より古いものを削除
for task_def in task_defs[keep_count:]:
try:
ecs.deregister_task_definition(taskDefinition=task_def)
print(f"Deregistered {task_def}")
except Exception as e:
print(f"Error deregistering {task_def}: {e}")
まとめ
ECSのブルーグリーンデプロイ、1年本番運用して気づいたことをまとめると:
- 手動切り替えは地獄:CodeDeployで自動化すべき。切り替えタイミングのバグでえらい目を見る
- 接続ドレイニング設定が命:短すぎると途中で切れるし、長すぎるとデプロイが遅延。アプリの通信パターンに合わせて細かく調整が必要
- Smoke testで9割のバグは事前に見つかる:本番トラフィック流す前に軽量テスト実行するのが超重要
- ログと接続プールに気を配る:Blue/Greenが同時稼働するから、ここをサボるとトラブル時のデバッグが地獄になる
- リソースクリーンアップは自動化:溜まり続けるタスク定義やECRイメージが、気づかないうちに費用を喰う
次のステップとしては、「カナリアデプロイ」や「ローリングデプロイとの組み合わせ」も検討してます。ブルーグリーンだけが完璧なわけじゃなくて、シーンに応じた使い分けが大事だなと、1年運用してようやく気づきました。
皆さんはブルーグリーンデプロイで困ったことありますか?特に大規模な環境での切り替え時間とか、トラブル対応で工夫してることとか、聞かせてもらえると嬉しいです。