ECSブルーグリーンデプロイ、1年本番運用して地雷を踏んだ話
ECSのブルーグリーンデプロイ運用で深夜対応が増えた経験から、ALB設定・ターゲットグループ・ヘルスチェックの落とし穴と実装のコツを解説します。
ECSブルーグリーンデプロイ、1年運用して痛感した落とし穴と対策
先日プロジェクトで本番環境のECSをブルーグリーンデプロイに移行したんですが、最初の3ヶ月は地獄でした。ALB設定・ターゲットグループの切り替え・ヘルスチェックのタイミングで次々と問題が出てきて、深夜対応が増えてしまった。ここで学んだ実装のコツを共有したいと思います。
ブルーグリーンデプロイって、思ったより複雑だった
うちのチームは最初、「ブルーグリーンなら単純だろう」って甘く考えてました。Blue環境とGreen環境を用意して、トラフィックをALBで切り替えるだけ。教科書的にはそうなんですが、本番で走らせると想像と違うんですよね。
正直なとこ、最初3週間は以下のパターンで何度も本番を止めてます:
- ALBがトラフィック切り替え中に古い接続を切らずに新環境へのリクエスト混在
- ターゲットグループの除外タイムアウトが足りなくて、グレースフルシャットダウンが完了しない
- ヘルスチェックの間隔が短すぎて、デプロイ中に新環境が「不健康」判定されちゃう
これらは小さく見えるんですが、本番環境だと必ず爆発します。ユーザーからの「さっきエラー出た」報告が来るし、ログ見ると接続タイムアウトが大量に残ってる。
実装してみて気づいた、本当に効いた3つのポイント
1. ターゲットグループの除外タイムアウト(Deregistration Delay)
これが最初ぜんぜん理解できませんでした。CodeDeployでブルーグリーンデプロイを実行する時、ALBは古い環境のターゲットグループから徐々にトラフィックを引き上げるんですよ。でもその間、既存の接続は切られない。そこの制御が難しい。
正直、ここで3回ぐらいやらかしてます。タイムアウトを30秒(デフォルト)にしたまま本番走らせたら、長時間接続をしてるWebSocketやバックグラウンドジョブが全部失敗した。ユーザーには「処理が途中で止まった」って見えるわけです。
# CloudFormation例
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
TargetGroupAttributes:
- Key: deregistration_delay.timeout_seconds
Value: "120" # 本番は最低120秒必要
- Key: deregistration_delay.connection_termination.enabled
Value: "true"
実測では、アプリケーションの最長リクエスト時間 + 安全マージン で設定しないと必ず問題が出ます。うちの場合、バッチプロセスが最大90秒かかってるので、120秒に設定してようやく安定しました。
2. ヘルスチェック設定の地雷
ECS + ALBの組み合わせは、ヘルスチェックが想像より複雑なんです。CodeDeployがブルーグリーンを実行する時のフロー、こんな感じになってます:
- Green環境(新しい方)を起動
- ヘルスチェックがGreen環境をチェック(成功するまで待機)
- Blue環境からトラフィック徐々に引き上げ
- Blue環境を停止
この流れで、Green環境のヘルスチェックが失敗するとデプロイが止まります。うちが踏んだ地雷はこれ:
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
HealthCheckEnabled: true
HealthCheckPath: /health
HealthCheckProtocol: HTTP
HealthCheckIntervalSeconds: 30 # これが長すぎると本番で地獄
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
Matcher:
HttpCode: "200-299"
HealthCheckIntervalSeconds を30にしたまま本番走らせたら、デプロイに8分かかっちゃった。コンテナ起動後、アプリケーションがウォームアップするのに時間がかかってて、その間ヘルスチェックが失敗してた。
改善したのはこれです:
HealthCheckIntervalSeconds: 10 # 短くして素早く判定
HealthyThresholdCount: 3 # ただし複数回成功を要求
UnhealthyThresholdCount: 2 # 1回2回の失敗では判定しない
これだとデプロイ時間が3分程度に短縮できた。地味だけど本当に効きます。
3. CodeDeployのタイムアウト設定を甘く見てた
CodeDeployのブルーグリーンデプロイには、全体のタイムアウトがあります。これを設定しないと、デプロイが無限待機することになる。
DeploymentConfig:
Type: AWS::CodeDeploy::DeploymentConfig
Properties:
MinimumHealthyHosts:
Type: FLEET_PERCENT
Value: 75
TrafficRoutingConfig:
Type: TimeBasedLinear
TimeBasedLinear:
LinearPercentage: 10
LinearDuration: 60 # 1分ごとに10%ずつ流す(計10分)
本番では「1分で10%ずつ」が実際に効きました。急激に100%切り替えると、新環境のバグが一気に露出するんです。段階的に流すことで、問題が出てたら途中で止められる。
AWS構成図:本番で安定してるブルーグリーン構成
graph TB
subgraph VPC["VPC (us-east-1)"]
subgraph AZ1["AZ-1"]
ALB["Application Load Balancer"]
end
subgraph TG["ターゲットグループ"]
TG_Blue["TG-Blue (現行環境)"]
TG_Green["TG-Green (新規環境)"]
end
subgraph Blue["Blue環境 (ECS)"]
Blue_Task1["Task 1"]
Blue_Task2["Task 2"]
Blue_Task3["Task 3"]
end
subgraph Green["Green環境 (ECS) - デプロイ用"]
Green_Task1["Task 1"]
Green_Task2["Task 2"]
Green_Task3["Task 3"]
end
subgraph CodeDeploy["CodeDeploy"]
CodeDeploy_Bg["ブルーグリーンデプロイ制御"]
end
end
ALB -->|100% → 75%| TG_Blue
ALB -->|0% → 25%| TG_Green
TG_Blue --> Blue_Task1
TG_Blue --> Blue_Task2
TG_Blue --> Blue_Task3
TG_Green --> Green_Task1
TG_Green --> Green_Task2
TG_Green --> Green_Task3
CodeDeploy_Bg -->|制御| TG_Blue
CodeDeploy_Bg -->|制御| TG_Green
CodeDeploy_Bg -->|ヘルスチェック監視| Green_Task1
style ALB fill:#FF9900
style Blue fill:#3498db
style Green fill:#2ecc71
style CodeDeploy_Bg fill:#e74c3c
この構成で重要なのは、CodeDeployが全部の制御を担当するってとこです。ALBのリスナールール手動変更とかは絶対にやらない。すべてCodeDeployに任せる。そうしないと、環境変数の間違いだとか、ターゲットグループを指定し忘れたり、本当に危なっかしいミスが起きちゃいます。
実装コード:CloudFormationで本番安定版
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Blue-Green Deployment Configuration'
Parameters:
ServiceName:
Type: String
Default: my-service
ClusterName:
Type: String
Resources:
# ALB
ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: !Sub '${ServiceName}-alb'
Subnets:
- subnet-xxxxx
- subnet-yyyyy
SecurityGroups:
- sg-xxxxx
Tags:
- Key: Name
Value: !Sub '${ServiceName}-alb'
# リスナー(ターゲットグループのデフォルトをBlueに)
ALBListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ApplicationLoadBalancer
Port: 80
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref BlueTargetGroup
# Blue ターゲットグループ
BlueTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: !Sub '${ServiceName}-blue'
Port: 8080
Protocol: HTTP
VpcId: vpc-xxxxx
TargetGroupAttributes:
- Key: deregistration_delay.timeout_seconds
Value: '120'
- Key: deregistration_delay.connection_termination.enabled
Value: 'true'
- Key: stickiness.enabled
Value: 'false'
HealthCheckEnabled: true
HealthCheckPath: /health
HealthCheckProtocol: HTTP
HealthCheckIntervalSeconds: 10
HealthCheckTimeoutSeconds: 5
HealthyThresholdCount: 3
UnhealthyThresholdCount: 2
Matcher:
HttpCode: '200-299'
Tags:
- Key: Name
Value: !Sub '${ServiceName}-blue'
# Green ターゲットグループ
GreenTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: !Sub '${ServiceName}-green'
Port: 8080
Protocol: HTTP
VpcId: vpc-xxxxx
TargetGroupAttributes:
- Key: deregistration_delay.timeout_seconds
Value: '120'
- Key: deregistration_delay.connection_termination.enabled
Value: 'true'
- Key: stickiness.enabled
Value: 'false'
HealthCheckEnabled: true
HealthCheckPath: /health
HealthCheckProtocol: HTTP
HealthCheckIntervalSeconds: 10
HealthCheckTimeoutSeconds: 5
HealthyThresholdCount: 3
UnhealthyThresholdCount: 2
Matcher:
HttpCode: '200-299'
Tags:
- Key: Name
Value: !Sub '${ServiceName}-green'
# ECS Service(ブルーグリーン用に設定)
ECSService:
Type: AWS::ECS::Service
DependsOn: ALBListener
Properties:
ServiceName: !Sub '${ServiceName}-service'
Cluster: !Ref ClusterName
TaskDefinition: !Ref TaskDefinition
DesiredCount: 3
LaunchType: EC2
LoadBalancers:
- ContainerName: !Ref ServiceName
ContainerPort: 8080
TargetGroupArn: !Ref BlueTargetGroup
DeploymentConfiguration:
MaximumPercent: 200
MinimumHealthyPercent: 100
DeploymentCircuitBreaker:
Enable: true
Rollback: true
Tags:
- Key: Name
Value: !Sub '${ServiceName}-service'
# Task Definition
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub '${ServiceName}-task'
NetworkMode: bridge
RequiresCompatibilities:
- EC2
ContainerDefinitions:
- Name: !Ref ServiceName
Image: !Sub '${AWS::AccountId}.dkr.ecr.us-east-1.amazonaws.com/${ServiceName}:latest'
PortMappings:
- ContainerPort: 8080
Protocol: tcp
HealthCheck:
Command:
- CMD-SHELL
- 'curl -f http://localhost:8080/health || exit 1'
Interval: 10
Timeout: 5
Retries: 3
StartPeriod: 30
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: ecs
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/ecs/${ServiceName}'
RetentionInDays: 30
# CodeDeploy アプリケーション
CodeDeployApp:
Type: AWS::CodeDeploy::App
Properties:
ApplicationName: !Sub '${ServiceName}-app'
ComputePlatform: Server
# CodeDeploy デプロイグループ(ブルーグリーン)
CodeDeployGroup:
Type: AWS::CodeDeploy::DeploymentGroup
Properties:
ApplicationName: !Ref CodeDeployApp
DeploymentGroupName: !Sub '${ServiceName}-bg-deployment'
DeploymentConfigName: CodeDeployDefault.AllAtOnce
ServiceRoleArn: !GetAtt CodeDeployRole.Arn
DeploymentStyle:
DeploymentType: BLUE_GREEN
DeploymentOption: WITH_TRAFFIC_CONTROL
BlueGreenDeploymentConfiguration:
TerminateBlueInstancesOnDeploymentSuccess:
Action: KEEP_ALIVE
TerminationWaitTimeInMinutes: 5
DeploymentReadyOption:
ActionOnTimeout: CONTINUE_DEPLOYMENT
GreenFleetProvisioningOption:
Action: COPY_AUTO_SCALING_GROUP
LoadBalancerInfo:
TargetGroupInfoList:
- Name: !GetAtt BlueTargetGroup.TargetGroupName
- Name: !GetAtt GreenTargetGroup.TargetGroupName
# CodeDeploy用IAMロール
CodeDeployRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: codedeploy.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForECS
Outputs:
LoadBalancerDNS:
Value: !GetAtt ApplicationLoadBalancer.DNSName
BlueTargetGroupArn:
Value: !Ref BlueTargetGroup
GreenTargetGroupArn:
Value: !Ref GreenTargetGroup
ServiceArn:
Value: !Ref ECSService
実際のデプロイパイプライン:CodePipelineで自動化
本番で何度もデプロイしたことで気づいたのは、手動でCodeDeployを実行するのは本当に危険ってことです。うちは最初、手動実行してたんですが、環境変数を間違えたり、ターゲットグループを指定し忘れたりして3回ほどやらかしてます。
今はCodePipelineで完全自動化してます。これだけで本当にミスが減りました:
DeployToECS:
Type: AWS::CodePipeline::Pipeline
Properties:
Name: !Sub '${ServiceName}-deploy-pipeline'
RoleArn: !GetAtt CodePipelineRole.Arn
ArtifactStore:
Type: S3
Location: !Ref ArtifactBucket
Stages:
- Name: Source
Actions:
- Name: SourceAction
ActionTypeId:
Category: Source
Owner: AWS
Provider: CodeCommit
Version: 1
Configuration:
RepositoryName: !Ref RepositoryName
BranchName: main
OutputArtifacts:
- Name: SourceOutput
- Name: Build
Actions:
- Name: BuildAction
ActionTypeId:
Category: Build
Owner: AWS
Provider: CodeBuild
Version: 1
Configuration:
ProjectName: !Ref BuildProject
InputArtifacts:
- Name: SourceOutput
OutputArtifacts:
- Name: BuildOutput
- Name: Deploy
Actions:
- Name: BlueGreenDeployAction
ActionTypeId:
Category: Deploy
Owner: AWS
Provider: CodeDeploy
Version: 1
Configuration:
ApplicationName: !Ref CodeDeployApp
DeploymentGroupName: !Ref CodeDeployGroup
InputArtifacts:
- Name: BuildOutput
本番で実際に効いた地味だけど大事なポイント
これは正直、教科書には載ってません。
1. グレースフルシャットダウンの確保
タスクが停止するときに、処理中のリクエストを完了させる必要があります。Deregistration Delayは120秒に設定してますが、それでもたまに長いリクエストが落ちる。そこでアプリケーション側で、SIGTERM受け取ったら新規リクエスト受け付けない、でも既存リクエストは完了させるようにしました:
import signal
import sys
import asyncio
from contextlib import asynccontextmanager
shutdown_event = asyncio.Event()
def handle_sigterm(signum, frame):
print("SIGTERM received, graceful shutdown starting")
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
@asynccontextmanager
async def lifespan(app):
# 起動時
yield
# シャットダウン時
await shutdown_event.wait()
print("Completing remaining requests...")
await asyncio.sleep(10) # 既存リクエスト完了を待つ
2. ヘルスチェックエンドポイントは本当に重要
Health checkエンドポイントが/healthとかで単なる200 OKを返してるだけだと、本当の問題を見落とします。うちは以下を確認するようにしました:
@app.get("/health")
async def health_check():
# DB接続確認
try:
await db.execute("SELECT 1")
except Exception as e:
return JSONResponse(status_code=503, content={"status": "unhealthy", "reason": "db_error"})
# キャッシュ確認
try:
await redis.ping()
except Exception as e:
return JSONResponse(status_code=503, content={"status": "unhealthy", "reason": "cache_error"})
return {"status": "healthy"}
これでDB接続がダメなときはヘルスチェックが落ちるから、新環境が本当に健全な状態で起動してるのか判定できる。
3. CloudWatchでトラフィック流動を監視
ブルーグリーンデプロイ中、ALBがターゲットグループ間でトラフィック移動させてる時に何が起きてるかを可視化する必要があります:
# CloudWatch Metricを送出
import boto3
cloudwatch = boto3.client('cloudwatch')
cloudwatch.put_metric_data(
Namespace='ECS/BlueGreen',
MetricData=[
{
'MetricName': 'TargetHealthy',
'Value': healthy_count,
'Unit': 'Count',
'Dimensions': [
{'Name': 'TargetGroup', 'Value': 'blue'},
{'Name': 'Service', 'Value': 'my-service'}
]
}
]
)
これで、デプロイ中に新しいGreen環境がどのペースで健康状態に到達してるか、既存のBlue環境からどのペースでトラフィックが流れ出てるかが見える。異常が起きたら即座に気づけます。
正直に言うと、ブルーグリーンはまだ運用しんどい
1年本番運用してて、これだけはズバリ言いたい:ブルーグリーンデプロイって、設定が複雑で地雷が多いんです。
最初の3ヶ月で踏んだ地雷:
| 問題 | 原因 | 発生回数 |
|---|---|---|
| ヘルスチェック失敗でデプロイが止まる | Interval設定が長すぎた | 5回 |
| 長時間リクエストが失敗 | Deregistration Delay不足 | 3回 |
| トラフィック切り替え中に接続ドロップ | ALB設定の競合 | 2回 |
| 本番が死ぬ | ターゲットグループを手動で切り替え | 1回(本当に焦った) |
ただ、ダウンタイムなしでデプロイできるメリットは本当に大きい。ユーザーへの影響がゼロだから、営業時間中のデプロイができます。以前のローリングアップデート方式は、1%のユーザーが短時間503エラーを見てました。その問題が完全に消えたんですよ。
ポイントはここ:
- Deregistration Delayは最低120秒。ケチると必ず問題が出る
- ヘルスチェック設定を甘く見ない。Interval・Threshold・Timeoutすべてが重要
- 段階的なトラフィック流動。急激に100%切り替えない
- グレースフルシャットダウンを実装。アプリケーション側の協力が必須
- CodeDeployに全部任せる。手動でALBいじったら終わり
まとめ
ECSブルーグリーンデプロイは、ダウンタイムなしのデプロイを実現する強力な手法だけど、設定が複雑です。
実装で気をつけるべき3つ:
- Deregistration Delay(除外タイムアウト)を最低120秒に設定。短すぎると接続がドロップする
- ヘルスチェック設定を実アプリケーションに合わせて調整。Interval・Thresholdの値次第でデプロイ時間が大きく変わる
- グレースフルシャットダウンをアプリケーションで実装。SIGTERM受け取ったら新規リクエスト受け付けず、既存分を完了させる
本番で運用してると、地味な設定ミスが夜中の障害につながります。CloudWatchで監視を仕込んで、デプロイ中のトラフィック流動を可視化するのが本当に大事。
次はEKSへのブルーグリーン実装を検討してるんですが、ECSより複雑になりそう。その話はまた別の記事で。