less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

CloudFormationでECSのBlue/Greenデプロイができるようになったので試す

概要

これまで CodeDeploy と連携させて実現していた ECS の Blue/Green デプロイが CloudFormation にインテグレートされて、Externalなデプロイメントコントローラーとして使用できるようになりました。これによって、一連のデプロイパイプラインをテンプレート上に集約して構築することができます。

プレスリリース
aws.amazon.com

ドキュメント

docs.aws.amazon.com

サンプルのテンプレートで試す

Fargate で Nginx コンテナを動かして、ALB経由で80番ポートでアクセスするアプリを作成するようです。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Vpc:
    Type: 'AWS::EC2::VPC::Id'
  Subnet1:
    Type: 'AWS::EC2::Subnet::Id'
  Subnet2:
    Type: 'AWS::EC2::Subnet::Id'
Transform:
  - 'AWS::CodeDeployBlueGreen'
Hooks:
  CodeDeployBlueGreenHook:
    Properties:
      TrafficRoutingConfig:
        Type: AllAtOnce
      Applications:
        - Target:
            Type: 'AWS::ECS::Service'
            LogicalID: ECSDemoService
          ECSAttributes:
            TaskDefinitions:
              - BlueTaskDefinition
              - GreenTaskDefinition
            TaskSets:
              - BlueTaskSet
              - GreenTaskSet
            TrafficRouting:
              ProdTrafficRoute:
                Type: 'AWS::ElasticLoadBalancingV2::Listener'
                LogicalID: ALBListenerProdTraffic
              TargetGroups:
                - ALBTargetGroupBlue
                - ALBTargetGroupGreen
    Type: 'AWS::CodeDeploy::BlueGreen'
Resources:
  ExampleSecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: Security group for ec2 access
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: '80'
          ToPort: '80'
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: '8080'
          ToPort: '8080'
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: '22'
          ToPort: '22'
          CidrIp: 0.0.0.0/0
  ALBTargetGroupBlue:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
      HealthCheckIntervalSeconds: 5
      HealthCheckPath: /
      HealthCheckPort: '80'
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 2
      HealthyThresholdCount: 2
      Matcher:
        HttpCode: '200'
      Port: 80
      Protocol: HTTP
      Tags:
        - Key: Group
          Value: Example
      TargetType: ip
      UnhealthyThresholdCount: 4
      VpcId: !Ref Vpc
  ALBTargetGroupGreen:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
      HealthCheckIntervalSeconds: 5
      HealthCheckPath: /
      HealthCheckPort: '80'
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 2
      HealthyThresholdCount: 2
      Matcher:
        HttpCode: '200'
      Port: 80
      Protocol: HTTP
      Tags:
        - Key: Group
          Value: Example
      TargetType: ip
      UnhealthyThresholdCount: 4
      VpcId: !Ref Vpc
  ExampleALB:
    Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer'
    Properties:
      Scheme: internet-facing
      SecurityGroups:
        - !Ref ExampleSecurityGroup
      Subnets:
        - !Ref Subnet1
        - !Ref Subnet2
      Tags:
        - Key: Group
          Value: Example
      Type: application
      IpAddressType: ipv4
  ALBListenerProdTraffic:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    Properties:
      DefaultActions:
        - Type: forward
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref ALBTargetGroupBlue
                Weight: 1
      LoadBalancerArn: !Ref ExampleALB
      Port: 80
      Protocol: HTTP
  ALBListenerProdRule:
    Type: 'AWS::ElasticLoadBalancingV2::ListenerRule'
    Properties:
      Actions:
        - Type: forward
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref ALBTargetGroupBlue
                Weight: 1
      Conditions:
        - Field: http-header
          HttpHeaderConfig:
            HttpHeaderName: User-Agent
            Values:
              - Mozilla
      ListenerArn: !Ref ALBListenerProdTraffic
      Priority: 1
  ECSTaskExecutionRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: ''
            Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'
  BlueTaskDefinition:
    Type: 'AWS::ECS::TaskDefinition'
    Properties:
      ExecutionRoleArn: !Ref ECSTaskExecutionRole
      ContainerDefinitions:
        - Name: DemoApp
          Image: 'nginxdemos/hello:latest'
          Essential: true
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80
      RequiresCompatibilities:
        - FARGATE
      NetworkMode: awsvpc
      Cpu: '256'
      Memory: '512'
      Family: ecs-demo
  ECSDemoCluster:
    Type: 'AWS::ECS::Cluster'
    Properties: {}
  ECSDemoService:
    Type: 'AWS::ECS::Service'
    Properties:
      Cluster: !Ref ECSDemoCluster
      DesiredCount: 1
      DeploymentController:
        Type: EXTERNAL
  BlueTaskSet:
    Type: 'AWS::ECS::TaskSet'
    Properties:
      Cluster: !Ref ECSDemoCluster
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsVpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - !Ref ExampleSecurityGroup
          Subnets:
            - !Ref Subnet1
            - !Ref Subnet2
      PlatformVersion: 1.3.0
      Scale:
        Unit: PERCENT
        Value: 1
      Service: !Ref ECSDemoService
      TaskDefinition: !Ref BlueTaskDefinition
      LoadBalancers:
        - ContainerName: DemoApp
          ContainerPort: 80
          TargetGroupArn: !Ref ALBTargetGroupBlue
  PrimaryTaskSet:
    Type: 'AWS::ECS::PrimaryTaskSet'
    Properties:
      Cluster: !Ref ECSDemoCluster
      Service: !Ref ECSDemoService
      TaskSetId: !GetAtt 
        - BlueTaskSet
        - Id

VPC とサブネットをパラメータとして指定します。
作るのは面倒なのでデフォルトVPCのものを使います。

また、テンプレートには IAM リソースが含まれるので CAPABILITY_IAM を指定する必要があります。さらにマクロを使って変更セットを作成するのでCAPABILITY_AUTO_EXPANDの指定も必要です。

aws cloudformation create-stack \
  --stack-name ecs-blue-green-sample \
  --template-body file://./sample.cf.yml \
  --parameters ParameterKey=Vpc,ParameterValue=vpc-e6023881 \
               ParameterKey=Subnet1,ParameterValue=subnet-34955b1f \
               ParameterKey=Subnet2,ParameterValue=subnet-8d8fb9d6 \
  --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND

スタックが作成されたら、ALBのDNS名を調べてアクセスしてみます。
200が返ってくることを確認します。

curl -I $(aws elbv2  describe-load-balancers --load-balancer-arns 
    $(aws cloudformation describe-stack-resource \
      --stack-name ecs-blue-green-sample \
      --logical-resource-id ExampleALB | jq -r ".StackResourceDetail.PhysicalResourceId") 
    | jq -r ".LoadBalancers[0].DNSName"
)

HTTP/1.1 200 OK
Date: Sat, 23 May 2020 07:28:44 GMT
Content-Type: text/html
Connection: keep-alive
Server: nginx/1.13.8
Expires: Sat, 23 May 2020 07:28:43 GMT
Cache-Control: no-cache

ブラウザ上ではこう見えます。

f:id:bluepixel:20200523163405p:plain

テンプレートの説明は後にして、ここから実際に新しいバージョンをデプロイしてみます。

ざっくり言うと TaskDefinition に変更が加えられたらそれがフックされてデプロイが走るので、イメージのタグをlatestからplain-textに変更します。

Image: 'nginxdemos/hello:plain-text'
aws cloudformation update-stack \
  --stack-name ecs-blue-green-sample \
  --template-body file://./sample.cf.yml \
  --parameters ParameterKey=Vpc,ParameterValue=vpc-e6023881 \
               ParameterKey=Subnet1,ParameterValue=subnet-34955b1f \
               ParameterKey=Subnet2,ParameterValue=subnet-8d8fb9d6 \
  --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND

実行すると、CloudFormation のマクロが変更セットを作成して、
CodeDeploy や GreenTaskDefinition などを作り始めます。

f:id:bluepixel:20200523164414p:plain

f:id:bluepixel:20200523164507p:plain

CodeDeploy を見て、トラフィックが新しい環境に流れ始めたことを確認します。

f:id:bluepixel:20200523164626p:plain

再びブラウザからアクセスすると、画像のようにplain-textイメージが使われていることが確認できます。

f:id:bluepixel:20200523164641p:plain

基本的な動きはこんな感じです。

マクロとフックの仕組み

テンプレートにはTransformHooksのブロックがあります。

Transformはテンプレートに対して適用できるマクロです。
通常の宣言的な記述とは違って、このマクロでテンプレートを書き換えたりします。

Hooksはそのマクロをいつどのように適用するかを定義するブロックです。 今回の ECS の Blue/Green デプロイの場合、TaskDefinitionまたはTaskSetに対する変更がフックされます。

ECSAttributes:
  TaskDefinitions:
    - BlueTaskDefinition
    - GreenTaskDefinition
  TaskSets:
    - BlueTaskSet
    - GreenTaskSet

最初にここで気になったのがGreenTaskDefinitionという論理IDです。
これはテンプレートには存在しませんが、マクロで作るリソースの論理IDをあらかじめ指定するという感じで動作しているみたいです。 名前を Red/Black に変えても動きました。
存在しない論理IDを参照してるようでちょっと違和感ある🤔

さっきは直接スタックの更新を走らせましたが、今度は変更セットを作ってマクロがどのような動作をするのかを見てみます。

f:id:bluepixel:20200523190748p:plain

f:id:bluepixel:20200523190800p:plain

以上のように、マクロは古いタスクを削除して、新しいGreen環境のリソースを作成するようなテンプレートを生成します。

これが裏側で起こっていることです。

Green環境が安定するまでにエラーが発生した場合は、通常どおりロールバックが行われ、Blue環境が維持されます。

ルーティング

サンプルではトラフィックが一気に切り替わります。
これはTrafficRoutingConfig: Type: AllAtOnceによるものです。

次は1分おきに20%ずつ新しい環境へ流していく戦略を取ります。

TrafficRoutingConfig:
  Type: TimeBasedLinear
  TimeBasedLinear: 
    StepPercentage: 20
    BakeTimeMins: 1

f:id:bluepixel:20200523173646p:plain

f:id:bluepixel:20200523173707p:plain

リロードするたびに変わるのが見て取れると思います。
20%はplain-textに流れる状態です。

f:id:bluepixel:20200523173733g:plain

他にTimeBasedCanaryという指定もできます。 これは Linear とは違って、段階的でも2段階で切り替えを行う設定のようです。

次に、テストトラフィックの指定に関してです。
時間ベースで徐々にトラフィックを切り替えるとはいえ、明示的にGreen環境の確認をしたいこともあると思います。

Optional でTestTrafficRouteを指定することで実現できます。
8080番ポートで受けるテスト用リスナーを作成します。

ALBListenerTestTraffic:
  Type: 'AWS::ElasticLoadBalancingV2::Listener'
  Properties:
    DefaultActions:
      - Type: forward
        ForwardConfig:
          TargetGroups:
          - TargetGroupArn: !Ref ALBTargetGroupGreen
             Weight: 1
    LoadBalancerArn: !Ref ExampleALB
    Port: 8080
    Protocol: HTTP

セキュリティグループに追加するのを忘れないでください。
サンプルだとすでに8080への許可がついてますが。
なぜ使ってないのに許可してるのかわからなかったがこのためか。
サンプル分かりにくいよ😂

ルーティングの設定を追加します。

TrafficRouting:  
  ProdTrafficRoute:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    LogicalID: ALBListenerProdTraffic
  TestTrafficRoute:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    LogicalID: ALBListenerTestTraffic

これで、トラフィックの切り替え中は、:8080にアクセスすると必ずGreen環境にフォワードされます。

その他の制約

  • デプロイ以外の変更を含んだ更新はできない

例えば、イメージの変更に加えて、ALBに新しくタグをつけたテンプレートを実行しようとすると以下のエラーが発生します。

'CodeDeployBlueGreenHook' of type AWS::CodeDeploy::BlueGreen failed with message: Additional resource diff other than ECS application related resource update is detected,CodeDeploy can't perform BlueGreen style update properly. Diff resource logical Ids: [ExampleALB]

デプロイと、それ以外の変更は分けて適用する必要があります。

  • NoEchoなパラメータや、SSMなど外部サービスから動的に参照するパラメータは利用できない

  • Outputs, ImportValue は利用できない

  • ネストしたスタックを含むテンプレートでは利用できない

  • ネストされたスタックでは利用できない

  • 既存リソースのImportでは利用できない

所感

正直、まだ導入するには尚早かなと思う。
サポートされてないものもあるし、CodeDeployを自分で構築した方が取り回しやすいかな。

運用保守がBlue/Greenデプロイを中心に考えないといけないので、柔軟性は損なわれる。

あとマクロってちょっと怖くないですか。
宣言的記述ってどこまで言っていいのかわからない。
Terraformだったらforループ使うことはよくあると思うけど、このマクロはブラックボックスすぎて変更セット見るまでdiff分からんし中身もわからん。