ECS Service DiscoveryでもRoute53を使ってCanaryとBlue/Greenデプロイをどうにかやってみた

## Summary

- ECSでSercice Discoveryを利用しているケースで、Route53のPrivate Hosted ZoneのCNAMEを活用してRouting Weightをコントロールし、CanaryおよびBlue/Greenデプロイを実現できました。
- 課題として3点残りました。
  (1) Route53を追加で管理する必要があり、Service Discoveryの手軽さが一部損なわれる
  (2) クライアント側で呼び出しエンドポイントを変更する必要がある
  (3) Service Connectでは同様の構成は実現が厳しい

## Agenda　

- [はじめに](#はじめに)
- [やりたいこと](#やりたいこと)
- [構成](#構成)
  - [この構成案に至った背景： 直面した諸制約](#この構成案に至った背景-直面した諸制約)
- [動作検証](#動作検証)
  - [下準備](#下準備)
  - [Greenのデプロイ](#greenのデプロイ)
  - [Blue/Greenの切替](#bluegreenの切替)
  - [ロールバック](#ロールバック)
  - [デプロイの流れについて補足](#デプロイの流れについて補足)
- [制約、残る課題](#制約残る課題)
- [所感](#所感)
- [参考](#参考)



## はじめに

- この記事は、CyberAgent Group SRE Advent Calendar 2023の12日目の記事です。
  - 12/18時点で12日枠が空いていたので、後から投稿になります。
- Developer Productivity室(DP室)の菊池です。
  - 普段は[PipeCD](https://pipecd.dev/)の開発、特にECS周りの機能充実に努めています。
- 本記事では、「ECSでService Discoveryを利用しているケースでもCanaryとBlue/Greenデプロイを実現できるか」を検証しました。
    - 本実装をPipeCDに組み込むことも視野に入れています。

### PipeCDとは

- PipeCDとは、(1)Progressive Deliveryを (2)GitOpsスタイルで (3)あらゆるPlatform（Kubernetes, ECS, Lambda, etc.)で実現するための、OSSのCDツールです。

https://pipecd.dev/

- 今年からCNCFのSandboxに参加しているOSSプロジェクトです。
    -  [PipeCDがCNCF Sandboxに参加することが決まりました](https://developers.cyberagent.co.jp/blog/archives/42448/)



- PipeCDのECS関連の対応状況は現時点で下記のとおりです。
  - スタンドアロンタスクのRolling
  - ELBを利用したケースでのRolling、Canary、Blue/Green
  - Service Discoveryを利用したケースでのRolling、シンプルなCanary（[Preview中](https://github.com/pipe-cd/pipecd/releases/tag/v0.46.0-rc0))
    - しかし、この方式ではBlue/Greenは実現できていません。
  - Docs: [Feature Status(dev版)](https://pipecd.dev/docs-dev/feature-status/)

### ECS Service Discoveryとは

- Service Discoveryとは、ECSでサービス間通信を簡単に実現するための機能です。
- 主な機能は名前解決(サービスディスカバリ)であり、Service Connect/ELB/App Meshのような複雑な機能はありません。
- 仕組みとしては、Cloud MapとRoute53が使われています。
- Docs: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html


## やりたいこと

- 「ECSのService Discoveryでサービス間通信を行っているサービスにおいて、CanaryやBlue/Greenでデプロイしたい」
  - しかし、標準ではサポートされていません。
  - ELBを挟めばCanaryやBlue/Greenも実現可能ですが、レイテンシがネックになりえます。
- デプロイにおいて満たすべき制約を下記のとおりとします。
  - デプロイ中でもクライアントは単一のエンドポイントでBlue環境/Green環境にアクセスできること
  - Blue環境<->Green環境の高速な切替が可能であること（すなわち、タスク再起動といった時間はかけない）

## 構成

- 後述の諸制約を踏まえて、下記の構成案に至りました。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635929/f50cd44c-c55c-0a8f-b002-693ca1f60fb3.png)


- ClientサービスがCNAMEでアクセスすると、そこに紐づいたBlue環境のタスクまたはGreen環境のタスクにルーティングされます。
- デプロイの際は、`service-green.ns-2`に`service-green`経由で`TaskSet-Green`を紐付けた上で、`my-cname.ns-1`レコードのWeightをコントールすれば良いだろうという寸法です。
- Hosted Zoneが2つあるのは、後述のRoute53の制約を回避するためです。
  - `ns-1`はユーザが作成したもので、`ns-2`はManaged by CloudMapです。

### この構成案に至った背景

- Service Discoveryを使用しているケースでBlue/Greenを実現するのは、一筋縄ではいきませんでした。

- Blue/Greenを自前実装する上で難しい点は、「高速のトラフィック切替手段」をどうするか？です。
  - ELBを用いるケースでは、リスナールールにおいてターゲットグループのWeightをコントロールすることで、 CanaryもBlue/Greenも実現可能です。
  - 100%のトラフィックが新規アプリに流れるため、ロールバックが低速であると、失敗時の影響が大きくなってしまいます。

- ELBを使わずにWeightをコントロールできるとすれば、下記の順に簡単そうです。（ECSタスクに近い順）
  - (1) Service Discovery自体の機能
  - (2) CloudMap
  - (3) Route53
- しかし、下記の通りそれぞれに困難があります。
  - (1) Service Discovery自体にWeight Controlの手段はない
    - さらに、TaskSetのService Discoveryの設定を編集すると、そのTaskSetのタスクが全て再起動される
      - そのため「2つのTaskSetを用意しておいてService Discovery設定を交換することでトラフィック切替」はできない
  - (2) CloudMap関連の操作が柔軟にできない
    - CloudMapのサービスのRouting Policyとして"WeightedRouting"を指定できるが、Weight調整はできない
        >All records have the same weight, so you can't route more or less トラフィック to any instances.
        
        https://docs.aws.amazon.com/cloud-map/latest/dg/services-values.html
      
    - CloudMap上でインスタンスをRegister/Deregisterしてタスク数を調整しようとしても、Deregisterするとタスクが再起動されるためうまくいかない
  - (3) Route53を使えばWeight Controlも可能だが、下記の制約がある
    - CloudMapによって生成されたRoute53 Private Hosted Zoneは、ユーザによって直接編集できない 
      - -> Service Discoveryが扱うNamespaceと同じNamespaceにCNAMEを配置しても、Weightをコントロールできない
    - 逆に、ユーザが作成したPrivate Hosted Zoneと同名のNamespaceはCloudMapで扱えない

## 動作検証

- 検証としてはBlue/Greenデプロイを行いますが、同様の処理でCanaryも実現可能です。
- デプロイ開始前の稼働状態から、Green環境を起動し、Green環境にトラフィックを切り替え、Blue環境にロールバックする流れを試していきます。
- Blue環境としてhttpd、Green環境としてnginxを動かします。
- なおClientサービスとしては、ECSのExecCommandでcurlを実行するだけのTaskを作成していますが、構築手順は割愛します。

### 下準備

デプロイ前の状態として、下記を構築していきます。

![prepare.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635929/1366287a-3891-6104-805b-360477614cff.png)


#### 1. CloudMapの用意

##### 1-1. Namespace`ns-2`の作成

Managed by CloudMapとなるNamespaceの作成です。Private Hosted Zoneも作成されます。

```bash
aws servicediscovery create-private-dns-namespace --name ns-2 --vpc vpc-xxxxxxxxxxxxxxxxx
```

##### 1-2. サービスの作成

`NamespaceId`は、1-1.で作成したものを指定します。

```bash
# Blue用サービス
aws servicediscovery create-service --name service-blue --dns-config '{
  "NamespaceId": "ns-xxxxxxxxxxxxx",
  "RoutingPolicy": "MULTIVALUE",
  "DnsRecords": [
    {
      "Type": "A",
      "TTL": 15
    }
  ]
}'

# Green用サービス
aws servicediscovery create-service --name service-green --dns-config '{
  "NamespaceId": "ns-xxxxxxxxxxxxx",
  "RoutingPolicy": "MULTIVALUE",
  "DnsRecords": [
    {
      "Type": "A",
      "TTL": 15
    }
  ]
}'
```

#### 2. ECS Cluster,Serviceの用意

##### 2-1. ECS Clusterの作成

```bash
aws ecs create-cluster --cluster-name my-cluster
```

##### 2-2. ECS タスク定義の作成(httpd)

```bash
aws ecs register-task-definition --cli-input-json '{
    "family": "my-taskdef",
    "executionRoleArn": "arn:aws:iam::<account-id>:role/xxx",
    "networkMode": "awsvpc",
    "containerDefinitions": [
       {
            "name": "web",
            "image": "public.ecr.aws/docker/library/httpd:latest",
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp",
                    "name": "web-80-tcp"
                }
            ],
            "cpu": 100,
            "memory": 100,
            "essential": true
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512"
}'
```


##### 2-3. ECS Serviceの作成

EXTERNALデプロイメントを行うため、サービス自体の設定は最小限になっています。

```bash
aws ecs create-service --cli-input-json '{
    "cluster": "my-cluster",
    "serviceName": "my-ecs-service",
    "desiredCount": 2,
    "deploymentController": {
        "type": "EXTERNAL"
    }
}'
```

##### 2-4. ECS TaskSet(Blue)の作成

`registryArn`には、1-2.で作成したBlueサービスのARNを指定します。

```bash
aws ecs create-task-set --cli-input-json '{
    "cluster": "my-cluster",
    "service": "my-ecs-service",
    "taskDefinition": "my-taskdef:1",
    "networkConfiguration": {
        "awsvpcConfiguration": {
            "subnets": ["subnet-xxx","subnet-yyy"],
            "securityGroups": ["sg-xxx"],
            "assignPublicIp": "ENABLED"
        }
    },
    "serviceRegistries": [
        {
            "registryArn": "arn:aws:servicediscovery:ap-northeast-1:<account-id>:service/srv-xxx",
            "containerName": "web"
        }
    ],
    "launchType": "FARGATE",
    "scale": {
        "value": 100,
        "unit": "PERCENT"
    }
}'
```


#### 3. Route53の用意

##### 3-1. Private Hosted Zone`ns-1`の作成

```bash
aws route53 create-hosted-zone --name ns-1 --vpc VPCRegion=ap-northeast-1,VPCId=vpc-xxx --caller-reference `date +%Y-%m-%d_%H-%M-%S`
```

##### 3-2. CNAMEレコード2点の作成

- Blue用(`my-cname.ns-1` -> `service-blue.ns-2`)はWeightを1にしておきます
- Green用(`my-cname.ns-1` -> `service-green.ns-2`)はWeightを0にしておきます

```bash
aws route53 change-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --change-batch '{
  "Changes": [
    {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-blue",
        "Weight": 1,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-blue.ns-2"
          }
        ]
      }
    },
    {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-green",
        "Weight": 0,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-green.ns-2"
          }
        ]
      }
    }
  ]
}'
```


### Greenのデプロイ

- ここからデプロイを始めていきます。
- Green環境(`TaskSet-Green`)を作成しつつもトラフィックは流さずに、下記の状態にしていきます。

![deploy-green.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635929/95d8992e-6df6-d0bd-6ba5-7a648ada9426.png)


#### 1. ECS タスク定義の作成(nginx)　

imageをnginxに変更している点以外はblueと同じです。

```bash
aws ecs register-task-definition --cli-input-json '{
    "family": "my-taskdef",
    "executionRoleArn": "arn:aws:iam::<account-id>:role/xxx",
    "networkMode": "awsvpc",
    "containerDefinitions": [
       {
            "name": "web",
            "image": "public.ecr.aws/nginx/nginx:mainline-alpine",
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp",
                    "name": "web-80-tcp"
                }
            ],
            "cpu": 100,
            "memory": 100,
            "essential": true
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512"
}'
```

#### 2. ECS TaskSet(Green)の作成

- taskDefinitionのrevisionは最新の値(nginxを使用したもの)にします。
- `registryArn`には、下準備1-2.で作成したGreen用サービスのARNを指定します。

```bash
aws ecs create-task-set --cli-input-json '{
    "cluster": "my-cluster",
    "service": "my-ecs-service",
    "taskDefinition": "my-taskdef:2",
    "networkConfiguration": {
        "awsvpcConfiguration": {
            "subnets": ["subnet-xxx","subnet-yyy"],
            "securityGroups": ["sg-xxx"],
            "assignPublicIp": "ENABLED"
        }
    },
    "serviceRegistries": [
        {
            "registryArn": "arn:aws:servicediscovery:ap-northeast-1:<account-id>:service/srv-xxx",
            "containerName": "web"
        }
    ],
    "launchType": "FARGATE",
    "scale": {
        "value": 100,
        "unit": "PERCENT"
    }
}'
```


#### 3. 動作確認

- この時点ではGreen環境にトラフィックが流れないことを確認していきます。

- タスクへの疎通（Clientサービスのcurl用コンテナから行います）

  ```bash
  curl http://my-cname.ns-1
  ```

  response:  何度実行してもhttpdへのアクセスになります。
  ```html
  <html><body><h1>It works!</h1></body></html>
  ```


- Route53上の設定確認


  ```bash
  aws route53 list-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --query "ResourceRecordSets[?Name=='my-cname.ns-1.']" 
  ```

  response: blueのWeightが1、greenのWeightが0になっています。
  ```json
  [
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-blue",
          "Weight": 1,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-blue.ns-2"
              }
          ]
      },
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-green",
          "Weight": 0,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-green.ns-2"
              }
          ]
      }
  ]
  ```


### Blue/Greenの切替

トラフィックをBlueからGreenに切り替えます。下記の状態にしていきます。

![trafficRouting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635929/f7b3d9f8-8381-f7e7-9127-e71a0754e88e.png)


#### 1. Hosted ZoneのCNAMEレコードのWeight変更

Weightを編集し、blueに0%、greenに100%のトラフィックが流れるようにします。


```bash
aws route53 change-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --change-batch '{
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-blue",
        "Weight": 0,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-blue.ns-2"
          }
        ]
      }
    },
     {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-green",
        "Weight": 1,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-green.ns-2"
          }
        ]
      }
    }
  ]
}'
```

#### 2. 動作確認

- タスクへの疎通

  ```bash
  curl http://my-cname.ns-1
  ```


  response: nginxに切り替わりました。
  ```html
  <!DOCTYPE html>
  <html>
  <head>
  <title>Welcome to nginx!</title>
  <style>
  html { color-scheme: light dark; }
  body { width: 35em; margin: 0 auto;
  font-family: Tahoma, Verdana, Arial, sans-serif; }
  </style>
  </head>
  <body>
  <h1>Welcome to nginx!</h1>
  <p>If you see this page, the nginx web server is successfully installed and
  working. Further configuration is required.</p>

  <p>For online documentation and support please refer to
  <a href="http://nginx.org/">nginx.org</a>.<br/>
  Commercial support is available at
  <a href="http://nginx.com/">nginx.com</a>.</p>

  <p><em>Thank you for using nginx.</em></p>
  </body>
  </html>
  ```


- Route53上の設定確認

  ```bash
  aws route53 list-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --query "ResourceRecordSets[?Name=='my-cname.ns-1.']" 
  ```

  response: blueのWeightが0、greenのWeightが1になっています。
  ```json
  [
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-blue",
          "Weight": 0,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-blue.ns-2"
              }
          ]
      },
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-green",
          "Weight": 1,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-green.ns-2"
              }
          ]
      }
  ]
  ```

### ロールバック

GreenからBlueにトラフィックを戻します。下記の状態にしていきます。


![rollback.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635929/ab2b3ba8-94a9-b3ac-e62d-d66939662552.png)


#### 1. Hosted ZoneのCNAMEレコードのWeight変更

Weightをデプロイ前に戻します。

```bash
aws route53 change-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --change-batch '{
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-blue",
        "Weight": 1,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-blue.ns-2"
          }
        ]
      }
    },
     {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "my-cname.ns-1.",
        "Type": "CNAME",
        "SetIdentifier": "record-green",
        "Weight": 0,
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "service-green.ns-2"
          }
        ]
      }
    }
  ]
}'
```



#### 2. 動作確認
  
元のVersionに戻ったことを確認します。

- タスクへの疎通

  ```bash
  curl http://my-cname.ns-1
  ```

  response: httpdに戻りました。
  ```html
  <html><body><h1>It works!</h1></body></html>
  ```

- Route53上の設定確認

  ```bash
  aws route53 list-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --query "ResourceRecordSets[?Name=='my-cname.ns-1.']" 
  ```

  response: Weightが元に戻り、Greenにトラフィックが流れなくなっています。

  ```json
  [
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-blue",
          "Weight": 1,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-blue.ns-2"
              }
          ]
      },
      {
          "Name": "my-cname.ns-1.",
          "Type": "CNAME",
          "SetIdentifier": "record-green",
          "Weight": 0,
          "TTL": 300,
          "ResourceRecords": [
              {
                  "Value": "service-green.ns-2"
              }
          ]
      }
  ]
  ```

### デプロイの流れについて補足

- 上記のロールバック後、Greenタスクセットを削除すれば、ECSサービスをデプロイ前の状態に戻せます。
- Weightを1:0ではなく9:1等にすれば、Canaryも実現可能です。

## 制約、残る課題

- Route53を追加で管理する必要があり、Service Discoveryの手軽さが損なわれる
  - Private Hosted Zoneの作成、CNAMEレコードの作成/編集、そしてそれらのIAM権限の管理も必要となります。
- アクセスエンドポイントの変更
  - 本構成への変更にあたっては、クライアント側でアクセス先指定を変更する必要があるかもしれません。
  - Service Discoveryを使う際は`<サービス名>.<Namespace名>`でアクセスしたいかもしれませんが、本構成では別のCNAME,Namespaceを使う必要があります。
  - 呼び出し元がservicediscoveryのDiscoverInstances APIでインスタンス取得している場合には、CNAMEレコードを編集してもBlue環境に引き続きアクセスできてしまいます。
  - アクセスエンドポイントを変更せずにCanary的なことを実現する方法は、PipeCDでPreviewとして実装済みです。
    - https://github.com/pipe-cd/pipecd/releases/tag/v0.46.0-rc0
- Service Connectでは同様の構成ができない
  - 制約が結構あります。**本記事は元々Service Connectで実装する予定でしたが、頓挫しました**。
    - 例えば、CloudMapのサービスが`APIコールとDNSクエリ`モードである必要がありますが、Service Connectではそれを指定できませんでした。
  - Service Connectの今後の発展に期待です。
    - Service ConnectでのBlue/GreenやCanaryについても標準では未対応ですが、AWSのコンテナロードマップにリクエストが上がっています。
      - [[ECS] [request]: Service Connect with Blue/Green deployment #1993](https://github.com/aws/containers-roadmap/issues/1993)

## 所感

- 検証を通じて、Service DiscoveryとService Connectが想像以上に異なる仕組みであることを学べました。
- もっといい方法がある方はぜひ教えてください。

## 参考

- [サービスディスカバリとAWS Cloud Map -- AWS Black Belt Online Seminar](https://pages.awscloud.com/rs/112-TZM-766/images/202107_AWS_Black_Belt_Containers261-CloudMap.pdf)