CloudFormationでECS/FargateのBlue/Greenデプロイ

# 背景

前回の投稿にも書きましたが、2020/5/19にCloudFormationでCodeDeployのECS/FargateにおけるBlue/Greenデプロイがサポートされたらしいので、ちょっと調べてみました。

https://qiita.com/yusuke-ka/items/593eba9a303bb4878506
https://aws.amazon.com/jp/about-aws/whats-new/2020/05/aws-cloudformation-now-supports-blue-green-deployments-for-amazon-ecs/


# 公式のユーザーガイド

まずは公式のユーザーガイドを読んでみた。(現時点では英語しかない。。)

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/blue-green.html


気になったのはココ。
「**In order to perform ECS blue/green deployment using CodeDeploy through CloudFormation, your template needs to include the resources that model your deployment, such as an Amazon ECS service and load balancer.**」

翻訳すると、
「**CloudFormationを介して、CodeDeployを使用してECSブルー/グリーンデプロイを実行するには、Amazon ECSサービスやロードバランサーなど、デプロイをモデル化するリソースをテンプレートに含める必要があります。**」

つまり、ECSサービスやALB等は、事前に作成しておいたり、他のスタックで作成したものを使うことはできず、同じテンプレート内で定義する必要があるということでしょうか。

テンプレートのサンプルを見てみても、各リソースを指定する部分は、Arnを指定する感じではなく、直接リソース定義名を指定しているように見える。

```template.yaml
Parameters:
  ...
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:
  ...
  ALBTargetGroupBlue:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
　　  ...
    ...
  ...
```

公式ユーザーガイドの説明においても、それぞれ「**Resource Logical ID**」を指定しろと書いてあって、「**Logical ID**」の説明には「英数字（A-Za-z0-9）で、テンプレート内で一意である必要があります。」とあることからも、テンプレート内に定義しなければならないことは確定っぽい。

前回の記事のやつは、ネットワーク系、ECS系、CI/CD系のような区分でスタックを分けていたので、これを利用するなら、構成を見直す必要がありそう。

https://qiita.com/yusuke-ka/items/593eba9a303bb4878506#aws%E7%92%B0%E5%A2%83%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%83%8A%E3%83%83%E3%83%97

前回作成したテンプレートをちょこっと書き換えるだけで済むかと思っていたが、結構大変そう。

戦略としては、前回のを書き換えるより、公式のサンプルをベースにECS/FargateのBlue/Greenデプロイのテンプレートを新たに作成して、そこにPipelineやらCodeBuildを組み込んでいくほうがよさげ。

あと、前回はバックエンドのサービスとフロントエンドのサービスを同じテンプレートに含めたが、同じテンプレート内に定義しないといけないリソースが増えるので、今回はそれぞれ別のテンプレートにしたほうが良いかもしれない。(横割りから縦割りへ)

ただ、縦割りにするとなると、もともとALBやクラスターはフロントエンドとバックエンドで共有していたが、公式のユーザーガイドによると、少なくともALBは*Required*になっていて同じテンプレートに含める必要がありそうなので、ALBの作成を別のスタックに分離して、フロントエンドとバックエンドで共有するのは無理かもしれない。


# AWS公式のサンプルを試してみる

とりあえず、AWSのサンプルを試してみる。
公式のサンプルを以下のファイルに書き出してみた。

```sample-template.yml
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
```

マネジメントコンソールで上記ファイルを読み込んで、スタックを作成。
Subnet1、Subnet2、Vpcのパラメータはもともと自分の環境にあるやつを選択。

そのまま作成しようとすると、「Requires capabilities : [CAPABILITY_IAM]」とか「Requires capabilities : [CAPABILITY_AUTO_EXPAND]」のエラーがでて作成できなかったため、以下にチェックを入れて再実行。

* AWS CloudFormation によって IAM リソースが作成される場合があることを承認します。
* AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します。
* AWS CloudFormation によって、次の機能が要求される場合があることを承認します: CAPABILITY_AUTO_EXPAND

今度は、問題なくスタック作成が完了。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/984013d5-3e20-90cc-3679-88a9e8e5d493.png)


ただ、ここで想定外の現象が起こっていた。
CodeDeployのところに、アプリケーションやデプロイグループが作成されていなかった。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/c8e93046-d14b-4410-8c58-13eb6f763f3a.png)


失敗したのかと思ったが、どうやら違うらしい。
よくよく読んでみると、
**「次のECSリソースの交換が必要なプロパティを更新するスタック更新を実行すると、CloudFormationはグリーンデプロイを開始します。
  AWS :: ECS :: TaskDefinition
  AWS :: ECS :: TaskSet
」**
と書いてある。

つまり、*update-stack*したタイミングでCodeDeployのBlue/Greenデプロイがトリガーされる模様。

テンプレートのタスク定義を変更してみる(イメージ名を変更）。

```sample-template.yml
...
Resources:
  ...
  BlueTaskDefinition:
    ...
    Properties:
      ...
      ContainerDefinitions:
        - Name: DemoApp
          Image: "nginxdemos/hello:plain-text"
          ...
      ...
  ...
```

マネジメントコンソールで上記ファイルを読み込んで、スタックを更新。

CodeDeployのアプリケーションは空のままだったが、デプロイ履歴を見ると、デプロイが始まっていた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/cca54692-357b-24c9-a526-2188c5464cb8.png)

詳細はこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/03cc3940-ed7b-acd1-2b93-d77f53fcf6ed.png)

ブラウザでALBにアクセスしてみると、テキストベースのnginxのデモページが表示された。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/b4d6db5f-0bbd-8fb6-9457-6f316878f5a3.png)


再度イメージ名を**nginxdemos/hello:latest**に戻し、スタックを更新して確認。
問題なくBlue/Greenデプロイが始まり、今度はHTMLのページが表示された。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643184/b6337c76-6735-267f-80d8-f777cf86c727.png)



# さいごに

公式のサンプルでECS/FargateのBlue/Greenデプロイを試してみた。

元々は、これをベースにPipelineやらCodeBuildを組み込んで、コミットからの自動デプロイを実現しようかと思っていたが、実際には想像していたものとは違っていた。

今回のものは、ECS/FargateにおけるBlue/Greenデプロイ用のCodeDeploy(デプロイグループ)を作成するためのCloudFormationの仕様が追加になった訳ではなく、CloudFormationのスタック更新でタスク定義を変更することで、自動的にCodeDeployのBlue/Greenデプロイが実行される感じだった。

実際、下記のサイトにある「AWS CloudFormation supports blue/green deployments on the AWS Lambda compute platform only.」の文言は消えていない。
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-codedeploy-deploymentgroup-loadbalancerinfo.html

CodePipelineのデプロイステージでデプロイプロバイダーをAWS CloudFormationしてスタックを更新してやるような感じにすれば、全部CloudFormationに置き換えできそうな気もするけど、元のままのほうがシンプルなので、上の文言が消えるまで様子見かな。