GitHub Actions を利用しての ECSのBlue/Greenデプロイとトラフィック切り替え前テストを実践

![READYFORアドベントカレンダー20201203.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888413/398a61de-d515-10a4-96e0-194aa4d95948.png)

こんにちは。READYFORでSREとして働いている水本です。これは[READYFOR Advent Calendar 2020](https://qiita.com/advent-calendar/2020/readyfor)の3日目の記事になります。

現在READYFORでは、EC2利用からECS Fargate利用への移行のための検証・開発を進めています。その検証の中でBlue/GreenデプロイのGitHub Actionを用いての自動化や、トラフィックの切り替え前のテストのやり方について調べたので解説してみます。これから導入を考える人の助けになればと思います。

# 前提
- Terraform でAWSリソースを管理します
- GitHub ActionsでのECSのデプロイでは [aws-actions/amazon-ecs-deploy-task-definition](https://github.com/aws-actions/amazon-ecs-deploy-task-definition)を利用します

# ECSのBlue/Greenデプロイ
ECSのBlue/Green デプロイは、[CodeDeploy 使用したBlue/Green デプロイ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/deployment-type-bluegreen.html)が提供されていますのでそちらの方法を利用します。

### Terraform
※ 動作検証に利用したTerraformリソース全体は[mizu0/terraform](https://github.com/mizu0/terraform/tree/main/aws/ecs)に置いています。

Blue/Greenデプロイの利用にあたり、いくつか標準のローリングデプロイの設定から変更する必要がありますので、変更が必要な部分だけ説明していきます。

#### ALB
1つのALBと

```terraform
resource "aws_lb" "ecs_bluegreen" {
  name                       = "ecs-bluegreen-test-lb"
  load_balancer_type         = "application"
...
}
```
2つのターゲットグループを用意します。

```terraform
resource "aws_lb_target_group" "ecs_blue" {
  name                 = "ecs-blue-tg"
...
}

resource "aws_lb_target_group" "ecs_green" {
  name                 = "ecs-green-tg"
...
}
```

リスナーは、作成したターゲットグループの片方を設定(どちらでもOKだが今回はblue)します。
向き先がCodedeployによって切り替わってもTerraformの差分が出ないように、`ignore_changes`を設定します。

```terraform
resource "aws_lb_listener" "ecs_bluegreen" {
  load_balancer_arn = aws_lb.ecs_bluegreen.arn
  port              = "80"
  protocol          = "HTTP"
...
}

resource "aws_lb_listener_rule" "ecs_bluegreen" {
  listener_arn = aws_lb_listener.ecs_bluegreen.arn
  priority     = 100
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ecs_blue.arn
  }
  condition {
    path_pattern {
      values = ["/*"]
    }
  }

  lifecycle {
    # actionの状態は無視
    ignore_changes = [action]
  }
}
```

#### ECS Service
デプロイの設定を`CODE_DEPLOY`にし、ALBと同様に作成したターゲットグループの片方を`load_balancer`に設定します。
また向き先がCodedeployによって切り替わってもTerraformの差分が出ないように`ignore_changes`を設定します。

```terraform

resource "aws_ecs_service" "test_service" {
...
  load_balancer {
    target_group_arn = aws_lb_target_group.ecs_blue.arn
    container_name   = "server"
    container_port   = 8000
  }

  deployment_controller {
    type = "CODE_DEPLOY"
  }

  lifecycle {
    ignore_changes = [task_definition, load_balancer]
  }
}
```
#### Codedeploy
Codedeployは以下のような設定なります。
 `load_balancer_info` に作成したリスナーとデプロイによって切り替える2つのターゲットグループ(ecs_blue/ecs_green)を指定します。

```terraform
resource "aws_codedeploy_app" "ecs" {
  compute_platform = "ECS"
  name             = "ecs"
}

resource "aws_codedeploy_deployment_group" "ecs_bluegreen" {
  app_name               = aws_codedeploy_app.ecs.name
  deployment_group_name  = "ecs_bluegreen"

  service_role_arn = aws_iam_role.ecs_codedeploy.arn
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce" #すべてのトラフィックを同時に更新済み Amazon ECS コンテナに移行します。

...

  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [aws_lb_listener.ecs_bluegreen.arn]
      }

      target_group {
        name = aws_lb_target_group.ecs_blue.name
      }

      target_group {
        name = aws_lb_target_group.ecs_green.name
      }
    }
  }
}
```

### GitHub Actions
※ 動作検証に利用したymlは[こちら](https://github.com/mizu0/test-service/blob/main/.github/workflows/main.yml)に置いています。

`aws-actions/amazon-ecs-deploy-task-definition` の設定に下記のように、Terraform で作成したECSやCodedeployのリソースを指定します。


```yaml
...
      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: test_service
          cluster: test
          codedeploy-appspec: aws/appspec.yaml
          codedeploy-application: ecs
          codedeploy-deployment-group: ecs_bluegreen
```

参照: https://github.com/aws-actions/amazon-ecs-deploy-task-definition#aws-codedeploy-support

　
appspec.yaml リポジトリに配置する必要がありますが、下記のように最低限の設定のみで構いません。
`TaskDefinition`部分はGitHub Actions実行時に自動で設定してくれます。

```yaml
version: 1

Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "Placeholder: GitHub Actions will fill this in"
        LoadBalancerInfo:
          ContainerName: "server"
          ContainerPort: 8000
        PlatformVersion: "LATEST"
```
　　 
これらの設定を追加すると、GitHub Actions の実行ログに以下のように出力され、Codedeployを利用したBlue/Greenデプロイの自動化が実現できました。

```
Deployment started. Watch this deployment's progress in the AWS CodeDeploy console: 
https://console.aws.amazon.com/codesuite/codedeploy/deployments/d-INJJMI4H7?region=ap-northeast-1
```

# トラフィック切り替え前のテスト
ここまでで、GitHubにPushしたら、Blue/Greenデプロイでサービスが更新されるようになりました。
次はBlueとGreenのトラフィックの切り替え前に新しく起動した側のターゲットグループにアクセスし、正常に動作しているかの自動テストが、Codedeployでは可能なのでその設定をしてみます。
https://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/tutorial-ecs-deployment-with-hooks.html


### テストリスナー
サービスで利用するポート以外のポートをテストに利用します。

#### ALB
テスト用のリスナーを作成し、Blue/Green用のALBに設定します。

```terraform

resource "aws_lb_listener" "ecs_bluegreen_test" {
  load_balancer_arn = aws_lb.ecs_bluegreen.arn
  port              = "8080"
  protocol          = "HTTP"
...
}
```

リスナーは本番と同様、作成したターゲットグループの片方を設定(どちらでもOKだが今回はgreen)します。
向き先がCodedeployによって切り替わってもTerraformの差分が出ないように、`ignore_changes`を設定します。

```terraform
resource "aws_lb_listener_rule" "ecs_bluegreen_test" {
  listener_arn = aws_lb_listener.ecs_bluegreen_test.arn
  priority     = 100
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ecs_green.arn
  }
  condition {
    path_pattern {
      values = ["/*"]
    }
  }

  lifecycle {
    # actionの状態は無視
    ignore_changes = [
      action
    ]
  }
}
```

#### ALBのアクセス制限
テストリスナーは、名前の通りテスト用なのでInternetに公開したくはないため、VPC内とNAT GATEWAYからのアクセスのみ許可します。

※テストはAWS Lambdaで実行するため、Lambda=>テスト用のポートは疎通できる必要があります。しかし、どう設定していいかわからなかったため、以下の記事を参考にLambdaをVPC内のprivateサブネットに作成することで、NATのIPからLambdaのアクセスが来るようにし対応しました。
https://www.joyzo.co.jp/blog/2325

```terraform
resource "aws_security_group" "ecs_alb_sg" {
  name        = "ecs-alb-sg"
  description = "Allow http inbound traffic"
  vpc_id      = data.terraform_remote_state.vpc.outputs.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = [data.terraform_remote_state.vpc.outputs.vpc_cidr]
  }

  # codedeploy の AfterAllowTestTraffic Hook用
  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["${data.terraform_remote_state.vpc.outputs.nat_gateway_ip}/32"]
  }
...
```

#### Codedeploy
Codedeploy の設定は、`test_traffic_route`の設定に作成したテストリスナーのリソースを追加するだけです。

```terraform

resource "aws_codedeploy_deployment_group" "ecs_bluegreen" {
  app_name               = aws_codedeploy_app.ecs.name
  deployment_group_name  = "ecs_bluegreen"
...

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [aws_lb_listener.ecs_bluegreen.arn]
      }

      test_traffic_route {
        listener_arns = [aws_lb_listener.ecs_bluegreen_test.arn]
      }

      target_group {
        name = aws_lb_target_group.ecs_blue.name
      }

      target_group {
        name = aws_lb_target_group.ecs_green.name
      }
    }
  }
}
```

### AfterAllowTestTraffic フック
テストリスナーが新しく起動した側のターゲットグループに向いたタイミングで、テストを実行したいため、AfterAllowTestTrafficを利用します。

```
AfterAllowTestTraffic – テストリスナーが置き換えタスクセットにトラフィックを提供した後、タスクを実行するために使用します。この時点でのフック関数の結果により、ロールバックをトリガーできます。
```
https://docs.aws.amazon.com/ja_jp/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html

#### テスト用のLambda
今回は単純にhttpリクエストを実行して、statusとbodyをチェックするというコードです。
実際には、APIであれば期待している値が返ってくるかというチェックしたり、UIであればpuppeteer等を利用してメインの導線だけでもちゃんと動いているかをチェックすればいいかと思っています。

`validationTestResult` の値が "Succeeded"の状態で、 PutLifecycleEventHookExecutionStatusを実行すると成功した判断され、Codedeployが次のステップに進みます。

```golang
func handler(event codedeploy.PutLifecycleEventHookExecutionStatusInput) error {
	deploymentID := event.DeploymentId
	lifecycleEventHookExecutionID := event.LifecycleEventHookExecutionId
	validationTestResult := "Failed"
	sess := session.Must(session.NewSessionWithOptions(session.Options{
		Config: aws.Config{
			Region: aws.String("ap-northeast-1"),
		},
	}))
	cd := codedeploy.New(sess)

	resp, _ := http.Get("<ALBのドメイン名>:8080/hello-world")

	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)

	if resp.StatusCode == 200 && string(body) == "Hello World!" {
		validationTestResult = "Succeeded"
		fmt.Println("Succeeded")
	} else {
		fmt.Println("Failed")
	}

	output, _ := cd.PutLifecycleEventHookExecutionStatus(&codedeploy.PutLifecycleEventHookExecutionStatusInput{
		DeploymentId: deploymentID,
		LifecycleEventHookExecutionId:lifecycleEventHookExecutionID,
		Status: aws.String(validationTestResult),
	})
	fmt.Println(output)

	return nil
}
```

　

テスト用のLambdaなので、更新はアプリケーションのデプロイと同じタイミングでできるのが望ましいです。
そのため同じリポジトリにコードを配置して、GitHub Actions のECSデプロイの前にテスト用Lambdaも自動更新するようにしました。
(テストコードに変更があったときだけ、更新されるようにしたほうが良いと思います)

```yaml
      - name: Update lambda
        run: |
          GOOS=linux go build -o test verifytest/main.go
          zip test.zip test
          aws lambda update-function-code --function-name ecs-bluegreen-verifytest --zip-file fileb://test.zip
```

#### apppspec.yaml
appspec.yamlの `AfterAllowTestTraffic`に作成したLambda関数名を指定するだけです。

```yaml
...

Hooks:
  - AfterAllowTestTraffic: "ecs-bluegreen-verifytest"
```

### テスト失敗時のロールバックの動作確認

まずは平常時は普通に成功することを確認
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888413/3a291354-647d-1802-5a4f-817ebc830040.png" width="800">
AfterAllowTestTraffic フックも成功している
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888413/09375815-e553-b36f-1def-a9fea0198f66.png" width="800">


次に、アプリケーション側のコードをテストに失敗するように変更して、GitHubにPush。
すると、`ステップ 3: 本稼働トラフィックを置き換えタスクセットに再ルーティング中`で失敗し、ロールバックのデプロイが実行されたことを確認できました:tada:
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888413/727e712d-227b-10fd-c3ba-d9639b941527.png" width="800">
AfterAllowTestTraffic フックで失敗していることも確認できました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/888413/904a2fd3-d4ab-3f8d-8756-122ae3839ff5.png" width="800">

# まとめ
GitHub Actions の `aws-actions/amazon-ecs-deploy-task-definition` を利用してのBlue/Greenデプロイの自動化や、トラフィック切り替え前のテスト実施は設定をいくつか追加するだけで実現できることがわかりました。 

Codedeployには、今回利用した、すべてのトラフィックを同時に移行する`CodeDeployDefault.ECSAllAtOnce`以外にも、最初に10％移行して残りの９０%は5分後に切り替える`CodeDeployDefault.ECSCanary10percent5Minutes` など複数のデプロイ設定が用意されているので、シーンに応じて色々な設定を切り替えができるのが良いなと思っています。



明日12月4日は、READYFORサービスのPMである [@yohei_eto](https://qiita.com/yohei_eto) さんの担当です。お楽しみに。