LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

Amazon ECSにおけるカナリアリリースの実現

LINEヤフー Advent Calendar 2023の10日目の記事です。

こんにちは。LINEヤフー株式会社で出前館の開発に携わっている五嶋です。

私は、Spring Bootを用いてインターナルなマイクロサービスを開発・運用しているクーポンサービスチームに所属しています。2023年9月にAmazon Elastic Container Service(以下、Amazon ECSと表記)上でカナリアリリースの仕組みを導入しました。

この記事では、技術の選定から実現、本番リリースまでの事例を紹介します。

カナリアリリース (Canary release)とは

まずはじめに、一般的に知られているカナリアリリースについて紹介します。

カナリアリリースとはソフトウェアのリリース手法のひとつで、新しいバージョンのソフトウェアを提供するにあたって一部のユーザーに先にリリースする手法です。一部のユーザーに先にリリースし、新しいバージョンの機能が問題を起こさないかを最小限の影響で確認できます。

ちなみに、カナリアリリースという名前は、19世紀の炭鉱労働者がカナリアを用いて毒検知を行ったことに由来します。

新しいバージョンであり正しく動作するかを検証したいバージョンを「カナリア」とし、すでにリリースされ、おおよそ正しく動作することが確認されているバージョンを「ベースライン」と呼びます。

この記事におけるカナリアリリースについて

一般的に知られているカナリアリリースという単語と、この記事で紹介しているカナリアリリースは少し意味が違うので、先に説明しておきます。

通常、カナリアリリースは「アクセスしてきたユーザーにカナリアかベースラインかを割り当て、該当するサーバーにリクエストを割り振る」という流れで行われます。特定のユーザーにカナリアを割り当てる、といった制御はロードバランサーのSticky Sessionなどで実現できます。この方法は、不具合があった際に影響を受けるユーザーを少なくするメリットがあります。一方、カナリアに割り当てられた特定のユーザー群は、何度アクセスしても不具合に出会う問題があります。

カナリアリリースは「影響範囲を狭めつつ新しいソフトウェアが正しく動作することを確認したい」が主な目的です。「私たちのチームでは、ずっと使えないユーザーが発生する仕様はなるべく避けたい」という結論になりました。そのため、クーポンサービスチームではSticky Sessionを意図的に使用せず、リクエストを完全にランダムに分散する方法を採用しました。

導入の背景

クーポンサービスチームではリリースペースが遅いという課題がありました。 この課題を解決するために、「心理的に今より気軽にリリースできる環境を作る」という目的でカナリアリリースの導入を決定しました。

前提

現在、クーポンサービスチームでは、以下の技術スタックを採用しています。

LanguageJava 17
FrameworkSpring Boot 3.1
IaCTerraform 1.5.4
IaaSAmazon ECS
CIGitHub Actions

カナリアリリースに関連のある部分を図に起こしたものが以下です。

ECSクラスターの中にあるサービスに対してターゲットグループを設定し、ALBからのリクエストをタスクに割り振っています。

デプロイはGitHub ActionsのWorkflow Dispatchを使用し、ボタン押下で実行できます。このプロセスはDockerイメージをビルド、Amazon Elastic Container Registry(以下、Amazon ECRと表記)にイメージをプッシュし、Amazon ECSのタスク定義を更新すると自動的にタスクのローリングアップデートが走る仕組みを使用しています。

余談ですが、クーポンサービスでは以前Spring Boot 2.6を採用していました。

Spring Bootの3系へのアップグレードについては記事「出前館クーポンサービスでのサーバーアプリケーションのSpring Boot 3系対応」で同じチームのHonda-sanが紹介されていますので、興味のある方はぜひ一緒にご覧ください。

技術選定

カナリアリリースを実現するには「カナリア、ベースラインのそれぞれ1台以上のインスタンス」「2つの環境にリクエストを割り振る機構」の2つを用意する必要があります。

これらをAmazon ECS上でどのように組み合わせるかを検討したところ、以下の3つの案が出てきました。

  1. DNS(Amazon Route 53)でロードバランサーを切り替える
  2. AWS CodeDeployを用いたカナリアリリース
  3. ロードバランサーでターゲットグループを切り替える

これらの詳細を深掘りして選定します。

検討案 1. DNS(Amazon Route 53)でロードバランサーを切り替える

まずは、DNS(Amazon Route 53)を用いた方法を見ていきます。

Route 53には加重ルーティングという機能があります。

これはひとつのドメインに複数のリソースを割り当て、設定したWeightに基づいてトラフィックを分けるものです。

具体的には、以下の図のようにALBを2つ用意し、Amazon Route 53のレコードにリソースの配分を設定します。これで指定した配分でリクエストが分散されました。

カナリアだけを新しいソフトウェアにすると、カナリアリリースを実施できます。

ベースラインとカナリアへの独立したデプロイは、サービスを分けられます。

Amazon ECSでは、サービスに対してタスク定義を指定し、新しいバージョンをデプロイします。カナリアのサービスだけに新しいバージョンのタスク定義を適用すると、カナリアのみを更新できます。

またカナリアリリースを終了したい時は、ベースラインとカナリアに同じバージョンのタスク定義を適用すると、通常運用状態にできます。

DNSを用いたこの方法は比較的簡単に構築できますが、基本的にランダムで割り振けられません。

そのため、特定のユーザーはカナリア、といった割り振り方をしたい場合は別の方法を利用する必要があります。

今回の記事で取り上げるカナリアリリースは、DNSを使用した方法でも実現できます。ただし、この方法の場合、DNSがローカルにキャッシュされると、ユーザーが不具合のあるインスタンスに継続してアクセスしてしまうリスクがあります。そのため、DNSを用いたカナリアリリースは採用しませんでした。

かつてはこの方法が主流でした。現在は後述するApplication Load Balancer(以下、ALBと表記)を活用した方法がよく使われます。ただ、この方法だとリージョンをまたいだバランシングが可能なため、冗長性を確保したい用途などにおいては便利かもしれません。

検討案 2. AWS CodeDeployを用いたカナリアリリース

次に、デプロイを自動化してくれるAWS CodeDeployというサービスを用いた方法を見ていきます。

AWS CodeDeployにはカナリア機能が標準で搭載されており、こちらを使えばカナリアリリースできます。

AWS CodeDeployでのカナリアリリースは、指定した割合のリクエストの処理をカナリアにさせて、一定時間問題がなかったらそのままカナリアの割合を100%にする、という流れで行われます。

この方法は、簡単かつ自動的にカナリアリリースを実現してくれるので非常に便利です。

AWS CodeDeployを用いたデプロイは、リリースの際に自動的にカナリア用のタスクを作成してくれるので、無駄なリソースが発生しないメリットもあります。

しかし、この方法はデプロイの最終判断もAWS CodeDeployが自動的に行います。

デプロイの自動化は便利ですが、クーポンサービスチームでは手動テストと状況に応じて時間を調整するため、AWS CodeDeployのカナリアリリースは利用しませんでした。

検討案 3. ロードバランサーでターゲットグループを切り替える

最後に、ALBを用いた方法を見ていきます。

ALBでは、ターゲットグループにインスタンスやIPアドレスを指定し、ロードバランシングの対象を設定します。

このターゲットグループに重み付けをすれば、任意のバランスでリクエストを分散できます。

ALBを用いたリクエストの分散では、Sticky Sessionを用いてユーザーごとにどちらに割り当てるかを制御できます。また、ロードバランサーによる分散になるので、DNSのキャッシュといった心配も不要です。

ベースラインとカナリアのデプロイは、DNSで行う方法と同様に、サービスを分けて実施できます。

採用したカナリアリリースの構成

ここまで検討してきた結果を以下の表にまとめました。

長所短所
DNS(Amazon Route 53)でロードバランサーを切り替える
  • 比較的簡単に実現できる
  • 任意のタイミングでバランスを調整できる
  • リージョンをまたいだバランシングができる
  • ユーザーごとに割り当てを記憶できない
  • ローカルにDNSがキャッシュされると意図しない振り分けになる可能性がある
  • 常にカナリア用のタスクを保持する必要がある
  • ALBを2つ用意する必要がある
  • 完全な自動化には向いていない
AWS CodeDeployを用いたカナリアリリース
  • 自動的にカナリア用のタスクを立ててくれるので、無駄なリソースが発生しない
  • 完全に自動化されたカナリアリリースを実現できる
  • 意図しないキャッシュの心配がない
  • 人がチェックしてから100%にするのが難しい
ロードバランサーでターゲットグループを切り替える
  • ユーザーごとに割り当てを記憶できる
  • 任意のタイミングでバランスを調整できる
  • 意図しないキャッシュの心配がない
    • 常にカナリア用のタスクを保持する必要がある
    • 完全な自動化には向いていない

    これらを私たちの求めているものと照らし合わせた結果、クーポンサービスチームでは「ロードバランサーでターゲットグループを切り替える」方式を採用しました。

    以下が全体図です。

    構築

    ここからはTerraformを使って、実際にカナリアリリースをするための仕組みを構築していきます。

    かなり簡略化しているので、実際に使うには権限管理やログ管理といった細かい制御が必要になります。

    Terraformによるインフラの構築

    まずはTerraformでベースラインとカナリアにリクエストを振り分ける部分を作っていきます。ここでは特徴的なところを抜粋して紹介します。

    以下はALBのTerraformです。

    aws_lb_listenerの転送設定を見ると、target_groupが2つあるのがわかります。このように、target_groupを複数配置し、weightを設定して、任意の比率でリクエストを分散できます。

    また、必要に応じてstickinessを変更し、Sticky Sessionを有効にできます。

    resource "aws_lb" "example_lb" {
      name                       = "example-load-balancer"
      load_balancer_type         = "application"
    }
    
    resource "aws_lb_listener" "example_lb_listener" {
      load_balancer_arn = aws_lb.example_lb.arn
      port              = 80
      protocol          = "HTTP"
    
      default_action {
        type = "forward"
    
        forward {
          stickiness {
            duration = 1
            enabled  = false
          }
          target_group {
            arn    = aws_lb_target_group.lb_baseline_target_group.arn
            weight = 9
          }
          target_group {
            arn    = aws_lb_target_group.lb_canary_target_group.arn
            weight = 1
          }
        }
      }
    }
    
    resource "aws_lb_target_group" "lb_baseline_target_group" {
      name        = "example-baseline-target-group"
      port        = 8080
      protocol    = "HTTP"
      target_type = "ip"
      vpc_id      = aws_vpc.main.id
    }
    
    resource "aws_lb_target_group" "lb_canary_target_group" {
      name        = "example-canary-target-group"
      port        = 8080
      protocol    = "HTTP"
      target_type = "ip"
      vpc_id      = aws_vpc.main.id
    }

    今回の方法ではベースラインとカナリアの2つのECSサービスを用意します。

    以下はベースラインとカナリアのそれぞれのサービスです。ロードバランサーとして先ほど作成したTarget Groupを指定しています。

    resource "aws_ecs_service" "ecs_baseline_service" {
      name            = "example-baseline-service-service"
      cluster         = aws_ecs_cluster.example_ecs_cluster.name
      launch_type     = "FARGATE"
      desired_count   = 3 # ベースラインの台数
      task_definition = aws_ecs_task_definition.ecs_task_definition.id
    
      load_balancer {
        target_group_arn = aws_lb_target_group.lb_baseline_target_group.arn
        container_name   = "example-service"
        container_port   = 8080
      }
    }
    
    resource "aws_ecs_service" "ecs_canary_service" {
      name            = "example-canary-service-service"
      cluster         = aws_ecs_cluster.example_ecs_cluster.name
      launch_type     = "FARGATE"
      desired_count   = 1 # カナリアの台数
      task_definition = aws_ecs_task_definition.ecs_task_definition.id
    
      load_balancer {
        target_group_arn = aws_lb_target_group.lb_canary_target_group.arn
        container_name   = "example-service"
        container_port   = 8080
      }
    }

    以上で、AWS上のインフラ構築は完了です。

    ここまでの作業で、ベースラインとカナリアの2つのサービスがデプロイされ、ロードバランサーに指定した割合でリクエストが分散されました。今は両方のサービスで同じアプリケーションが起動しているため、通常のリリース状態です。

    GitHub Actionsの構築

    ここではカナリアのみのタスク定義を更新し、カナリアリリースを実施できるようにします。クーポンサービスチームはすでにGitHub Actionsを使用したデプロイの仕組みがあるので、このワークフローを拡張して実装します。

    GitHub Actionsからのデプロイは大きく2段階に分けられます。

    まずは、Dockerイメージのビルドとプッシュです。こちらは非常にシンプルで、以下の形です。 ここには記載していませんが、ECRの認証に関しては別途記述が必要です。

    build:
      runs-on: ubuntu-latest 
      steps:
        - id: build-docker-image
          run: |
            docker build -t ${{ env.image_name }} .
          shell: bash
    
        - id: push-docker-image
          run: |
            docker push ${{ env.image_name }}
          shell: bash
    

    次に、ECSタスクのデプロイについて説明します。ベースラインとカナリアを別々に記述しているため複雑に見えますが、実際のプロセスは3ステップで構成されています。

    GitHub Actionsからのデプロイにはworkflow_dispatchを使用し、Web上のUIからデプロイを実行できるようにしています。デプロイタイプを指定するための入力欄を用意しています。

    このデプロイタイプの値に合わせて操作の対象を切り替えており、デプロイタイプを「canary」にすればカナリアのみをデプロイできます。 ALBによるリクエストの分散は常に行われています。カナリアのみが新しいバージョンに更新されると、自動的にカナリアリリースが実行される仕組みです。

    deploy:
      runs-on: ubuntu-latest
      needs: build
      steps:
        # 既存のタスク定義をダウンロード
        - id: download-task-definition-baseline
          if: ${{ inputs.deploy-type == 'baseline' || inputs.deploy-type == 'baseline & canary' }}
          name: download task definition baseline
          run: |
            aws ecs describe-task-definition --task-definition ecs_task_definition --query taskDefinition | \
            jq "del (.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)" > task-definition-baseline.json
          shell: bash
    
        - id: download-task-definition-canary
          if: ${{ inputs.deploy-type == 'canary' || inputs.deploy-type == 'baseline & canary' }}
          name: download task definition canary
          run: |
            aws ecs describe-task-definition --task-definition ecs_task_definition --query taskDefinition | \
            jq "del (.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)" > task-definition-canary.json
          shell: bash
    
        # タスク定義を更新
        - id: render-task-definition-baseline
          if: ${{ inputs.deploy-type == 'baseline' || inputs.deploy-type == 'baseline & canary' }}
          name: render task definition baseline
          uses: aws-actions/amazon-ecs-render-task-definition@v1
          with:
            task-definition: task-definition-baseline.json
            container-name: exmaple-service
            image: ${{ env.image_name }}
    
        - id: render-task-definition-canary
          if: ${{ inputs.deploy-type == 'canary' || inputs.deploy-type == 'baseline & canary' }}
          name: render task definition canary
          uses: aws-actions/amazon-ecs-render-task-definition@v1
          with:
            task-definition: task-definition-canary.json
            container-name: exmaple-service
            image: ${{ env.image_name }}
    
        # タスク定義をデプロイ
        - id: deploy-task-definition-baseline
          if: ${{ inputs.deploy-type == 'baseline' || inputs.deploy-type == 'baseline & canary' }}
          name: deploy task definition baseline
          uses: aws-actions/amazon-ecs-deploy-task-definition@v1
          with:
            task-definition: ${{ steps.render-task-definition-baseline.outputs.task-definition }}
            cluster: example-cluster
            service: example-baseline-service-service
            wait-for-service-stability: false
    
        - id: deploy-task-definition-canary
          if: ${{ inputs.deploy-type == 'canary' || inputs.deploy-type == 'baseline & canary' }}
          name: deploy task definition canary
          uses: aws-actions/amazon-ecs-deploy-task-definition@v1
          with:
            task-definition: ${{ steps.render-task-definition-canary.outputs.task-definition }}
            cluster: example-cluster
            service: example-canary-service-service
            wait-for-service-stability: false

    以上で、カナリアリリースの仕組みが構築できました。

    GitHub Actionsでdeploy typeをcanaryに設定してデプロイを実行すると、カナリアだけが更新され、カナリアリリースができるはずです。

    カナリアリリースを実施している時は、カナリアのログをよく見るようにします。通常通りの意図した出力であれば、おおよそ問題ないでしょう。

    カナリアのみをターゲットグループとしたALBを用意すれば、本番環境でのテストも簡単に行えます。

    カナリアリリースを終了するには、ベースラインとカナリアの両方を同じバージョンにデプロイするだけで済みます。

    本番リリース

    ここまででカナリアリリースの仕組みは構築できました。しかしそのまま既存の本番システムに適用すると、ECSサービスの作り直しが発生するため、ダウンタイムが発生します。

    ダウンタイムを回避するため、6ステップに分割してリリースを行ったので、こちらも合わせて紹介します。

    前提

    現在は以下の構成です。

    このインフラを、カナリアリリースに対応したものに作り替えていきます。

    Step 1. 一時ALB, 一時ECSサービスを作る

    まずは、リリース作業中にリクエストを処理するための一時ALBと一時ECSサービスを作成します。

    このタイミングで、一時ALBをテストするためにRoute 53のレコードを追加しておきます。

    次のステップで、公開用レコードの向き先を一時ALBに変える前に、一時ALBと一時ECSサービスが正しく動作していることを確認しておきましょう。

    Step 2. Route53の公開用レコードの向き先を一時ALBに変える

    一時システムの動作が確認できたら、公開用レコードの向き先を一時ALBに変更します。これにより、既存のALBとECSサービスに変更を加えられます。

    Step 3. 既存のALBとECSサービスを削除する

    新しいALBを定義する時にリソースが競合するため、古いALBとECSサービスはいったん削除します。

    Step 4. カナリアリリースに対応したALBとECSサービスを作成する

    次に、ベースライン用のECSサービスと、カナリア用のECSサービスを作成し、新しいALBとひもづけておきます。

    このタイミングで、新ALBをテストするレコードも作りましょう。

    一時システムを作った時と同じように、新システムが動作するか確認します。

    ログを見て、カナリアECSサービスにリクエストが来てるかも合わせてチェックすると良いですね。

    Step 5. Route53の公開用レコードの向き先を新しいALBに変える

    新しいALBとECSサービスが動作することを確認できたら、公開用レコードの向き先を新しいALBに変更します。

    これで完全に新しいシステムで動作できました。

    Step 6. 一時ALB, 一時ECSサービスを削除する

    最後に不要になった一時リソースを削除すれば完了です!

    まとめ

    この記事では、Amazon ECSにおけるカナリアリリースの技術選定から実現方法、リリースまでを紹介しました。

    実際にカナリアリリースを導入してみて数カ月たちました。本番リリースの手順の中にカナリアリリースを組み込み、より安全に新しいアプリケーションを提供できました。現時点では発生していませんが、万が一新しいアプリケーションでアラートが発生すればその恩恵を受けられるかもしれません。

    この記事がAmazon ECSにカナリアリリースを導入しようとしている方のお役に立てれば幸いです。