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がブルーグリーンを実行する時のフロー、こんな感じになってます:

  1. Green環境(新しい方)を起動
  2. ヘルスチェックがGreen環境をチェック(成功するまで待機)
  3. Blue環境からトラフィック徐々に引き上げ
  4. 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つ:

  1. Deregistration Delay(除外タイムアウト)を最低120秒に設定。短すぎると接続がドロップする
  2. ヘルスチェック設定を実アプリケーションに合わせて調整。Interval・Thresholdの値次第でデプロイ時間が大きく変わる
  3. グレースフルシャットダウンをアプリケーションで実装。SIGTERM受け取ったら新規リクエスト受け付けず、既存分を完了させる

本番で運用してると、地味な設定ミスが夜中の障害につながります。CloudWatchで監視を仕込んで、デプロイ中のトラフィック流動を可視化するのが本当に大事。

次はEKSへのブルーグリーン実装を検討してるんですが、ECSより複雑になりそう。その話はまた別の記事で。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事