🥓

ECSでカナリアリリースを実現する

2024/10/28に公開

概要

AWSのECSを使用して、カナリアリリースができるようにしてみました。
ECSで通常のアプリケーションとカナリア用のアプリケーションを作成し、ALBのターゲットグループを使用して、それぞれのアプリケーションにリクエストを振り分けます。

GitHub: https://github.com/hosimesi/code-for-techblogs/tree/main/aws_ecs_canary

今回作成したものの構成図は以下のとおりです。

ECSとは

Amazon Elastic Container Service (ECS) はフルマネージドのコンテナオーケストレーションサービスです。タスク定義の指定した数のインスタンスを同時に実行して維持したい場合(サーバーなど)に使うECS ServiceとJob的な使い方をするECS Taskがあります。
似たようなコンテナアプリケーションを実行する環境として、EKSやApp Runnerなどがありますが、本番運用を見越した時にはECSはかなり機能も充実しており、有力な選択肢になります。

カナリアリリースとは

カナリアリリースとは、新しいバージョンのアプリケーションを一部のユーザーに先にリリースする手法で、一部のユーザで先に検証することで問題が起こったとしても最小限の影響で抑えられる手法になっています。
さらに本番運用を見据えると、問題が起こった際に自動でアプリケーションの切り戻しをしてくれるようなツールもあります。
A/Bテストとも親和性があり、カナリアのアプリーションに割り当てるリクエスト割合を増やしていくと、A/Bテストも可能になります。

実現するカナリアリリース

ECSを使ってカナリアリリースを実現する方法はいくつかあります。

  • DNSを使ってALBのドメインごと割り振る
  • ALBのターゲットグループで割り振る

上記は一例ですが、DNSを使う場合は各ALBへのトラフィックを柔軟にコントロールできる一方で、ALBを2つ用意する必要があります。トラフィックの割り当てをドメインレベルで細かく制御したい場合やALBごとに異なる設定やセキュリティポリシーを適用したい場合に使用できます。
ターゲットグループを使う場合は簡単な反面、細かいインスタンスレベルでリクエスト量を制御はできないです。
今回の場合は、コスト・運用の簡単化のためにターゲットグループを使う方法を試してみます。

実装

アプリケーションの準備

今回は2つのアプリケーションに適切にリクエストが割り振られているか確認したいだけなので、簡単なhttp serverを作ります。

package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
    appName := os.Getenv("APP_NAME")
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "APP_NAME: %s", appName)
    })
    http.ListenAndServe(":8080", nil)
}

どちらのアプリケーションかを判別するためにAPP_NAMEという環境変数を用意し、メインのアプリケーションとカナリアのアプリケーションかをレスポンスで返します。
こちらを作成後、DockerイメージをECRにプッシュしておきます。

インフラの準備

インフラはTerraform経由で作成していきます。
重要なところのみ抜粋すると、ECS Taskをメインのアプリケーションとカナリアのアプリケーションを用意します。
各ECS Serviceのロードバランサーの設定にそれぞれのターゲットグループを指定します。

resource "aws_ecs_cluster" "http_server" {
  name = "http-server"
}

resource "aws_ecs_task_definition" "http_server" {
  family                   = "http-server"
  network_mode             = "awsvpc"
  cpu                      = 1024
  memory                   = 2048
  requires_compatibilities = ["FARGATE"]
  container_definitions = templatefile("./modules/ecs/container_definitions/http_server.json", {
    http_server_ecr_uri = "${var.http_server_ecr_uri}",
  })
  task_role_arn      = var.ecs_task_role_arn
  execution_role_arn = var.ecs_task_execution_role_arn
}

resource "aws_ecs_task_definition" "http_server_canary" {
  family                   = "http-server-canary"
  network_mode             = "awsvpc"
  cpu                      = 1024
  memory                   = 2048
  requires_compatibilities = ["FARGATE"]
  container_definitions = templatefile("./modules/ecs/container_definitions/http_server_canary.json", {
    http_server_canary_ecr_uri = "${var.http_server_canary_ecr_uri}",
  })
  task_role_arn      = var.ecs_task_role_arn
  execution_role_arn = var.ecs_task_execution_role_arn
}

resource "aws_ecs_service" "http_server" {
  name                               = "http-server-service"
  cluster                            = aws_ecs_cluster.http_server.name
  task_definition                    = aws_ecs_task_definition.http_server.arn
  desired_count                      = 1
  deployment_minimum_healthy_percent = 0
  deployment_maximum_percent         = 200
  launch_type                        = "FARGATE"
  network_configuration {
    security_groups = [var.http_server_security_group]
    subnets         = var.private_subnets
  }

  load_balancer {
    target_group_arn = var.http_server_target_group_arn
    container_name   = "http-server"
    container_port   = 8080
  }
}

resource "aws_ecs_service" "http_server_canary" {
  name                               = "http-server-canary-service"
  cluster                            = aws_ecs_cluster.http_server.name
  task_definition                    = aws_ecs_task_definition.http_server_canary.arn
  desired_count                      = 1
  deployment_minimum_healthy_percent = 0
  deployment_maximum_percent         = 200
  launch_type                        = "FARGATE"
  network_configuration {
    security_groups = [var.http_server_security_group]
    subnets         = var.private_subnets
  }

  load_balancer {
    target_group_arn = var.http_server_canary_target_group_arn
    container_name   = "http-server-canary"
    container_port   = 8080
  }
}

ALBは共通のものを使い、lisner ruleでそれぞれのサービスに割り振ります。
ここではメイン用のターゲットグループ:カナリア用のターゲットグループ = 90:10の割合でリクエストを振り分けています。

resource "aws_lb_listener_rule" "http_server" {
  listener_arn = aws_lb_listener.http_server.arn

  action {
    type = "forward"
    forward {
      target_group {
        arn    = aws_lb_target_group.http_server.arn
        weight = 90
      }
      target_group {
        arn    = aws_lb_target_group.http_server_canary.arn
        weight = 10
      }
    }
  }

  condition {
    path_pattern {
      values = ["/"]
    }
  }
}

実際に動かしてみる

実際にリクエストを投げてみて意図通りの割合になっているか確認してみます。

.PHONY: request
request:
	@canary_counter=0 ; \
	main_counter=0 ; \
	for i in $$(seq 1 200) ; do \
		response=$$(curl -s $(ALB_DNS)) ; \
		echo "$$response" | grep -q "app-canary" && canary_counter=$$((canary_counter+1)) ; \
		echo "$$response" | grep -q "app-main" && main_counter=$$((main_counter+1)) ; \
	done ; \
	echo "Canary responses: $$canary_counter" ; \
	echo "Main responses: $$main_counter"

意図通り、リクエスト割合が9:1で分かれていることが確認できました。

$ make request                                                    
Canary responses: 20
Main responses: 180

終わりに

今回はECSとALBを使ってカナリアリリースができるようにしてみました。実際に運用する場合はArgo Rolloutsなどを使ってロールバックなどを自動でできるようにするのがいいと思います。

参考

https://techblog.lycorp.co.jp/ja/20231210a#:~:text=カナリアリリース (Canary release)とは&text=カナリアリリースとはソフトウェア,影響で確認できます。

Discussion