電通国際情報サービス TechBlog

ISIDが運営する技術ブログ

GitHub ActionsとAWS App Runnerを利用してBlue/Greenデプロイメントを実現してみた

はいどーもー! Xイノベーション本部の宮澤響です!

本記事は電通国際情報サービス Advent Calendar 2021 2日目の記事です! 記念すべき1日目である昨日の記事は、佐藤太一さんの「テックブログ始めました。」でした!我々の記事をホストするためのサービスとしてはてなブログを採用した理由や、記事を執筆するにあたって利用しているツールについて分かりやすくまとめられていますので、ぜひご一読ください!

本記事では、「GitHub ActionsとAWS App Runnerを利用してBlue/Greenデプロイメントを実現してみた」というタイトルのとおり、GitHub ActionsとAWS App Runner(以下、App Runner)、それに加えて、Amazon CloudFront(以下、CloudFront)とAmazon Route 53(以下、Route 53)を利用してBlue/Greenデプロイメントを実現する方法を、サンプルコードとともにご紹介します!

Blue/Greenデプロイメントって何?

Blue/Greenデプロイメントとは、本番環境と検証環境を交互に入れ替えることにより、Webアプリケーションなどに変更を加えるデプロイ手法の一つです。ざっくりとした流れとしては、「本番環境であるBlue」と「本番環境とほぼ同一の検証環境Green」の2つを用意した上で、以下の1.〜4.を繰り返すことになります。

  1. Greenに変更を加えていく

  2. 問題がなければGreenを本番環境に、Blueを検証環境にチェンジする(デプロイ)

  3. 今度はBlueに変更を加えていく

  4. 問題がなければ再びBlueを本番環境に、Greenを検証環境にチェンジする(デプロイ)

メリットとしては、デプロイ時やロールバック時のシステムのダウンタイムを最小限にできることや、限りなく本番環境に近い検証環境でテストできることなどが挙げられます。 一方、デメリットとしては、アーキテクチャが複雑になることや、インフラ構築用のコードと実際のインフラ環境に矛盾が生じる(インフラ構築用のコード上でBlueを本番環境としていた場合、Greenが本番環境になっている間はインフラ構築用のコードと実際のインフラ環境が一致しない)ことなどが挙げられます。

App Runnerって何?

App Runnerとは、コンテナベースのAWSリソースの一つです。噛み砕いて言えば、Amazon ECS、AWS Fargate、ELBといった種々のリソースを全部まとめて裏でイイ感じにやってくれるものです。非常に手軽で簡単にWebアプリケーションをデプロイできる反面、制約も多いです。詳しくは公式ドキュメントを参照ください。

事前に準備すること

各種AWSリソースや、Blue/Greenデプロイメントを実現するためのGitHub Actionsのワークフローを作成します。最終的なアーキテクチャは下図になります。

App Runnerサービスを作成する

まずは、公式ドキュメントの手順に従い、サンプルアプリケーションがデプロイされているApp Runnerサービスを2つ作成します。

  1. Prerequisitesの手順に従い、サンプルリポジトリを作成します。リポジトリ名は任意でOKです。

  2. ステップ 1: App Runner サービスを作成するの手順に従い、App Runnerサービスを作成します。GitHub connectionsの接続名や環境変数NAMEの値は任意でOKです。サービス名はそれぞれsample-service-bluesample-service-greenとします。また、デプロイ設定の部分で、sample-service-blueのデプロイトリガーを手動に、sample-service-greenのデプロイトリガーを自動に、それぞれ設定してください。

何故片方を手動デプロイ、もう片方を自動デプロイにするかというと、自動デプロイの場合、指定したリポジトリ、ブランチのソースコードに変更を加える(=pushする)度に、App Runnerサービスにも変更が反映される(=最新のソースコードを基にアプリケーションがデプロイし直される)ためです。これにより、本番環境(手動デプロイ)に影響を与えることなく、検証環境(自動デプロイ)に変更を加えることが可能となります。ソースコードをpushするだけで最新のアプリケーションが自動でデプロイされるのは非常に手軽で便利ですね!

CloudFrontディストリビューションを作成する

次に、公式ドキュメントの手順に従い、CloudFrontディストリビューションを2つ作成します。基本的にはデフォルト設定のままで問題ありませんが、以下の項目はデフォルトから変更をお願いします。

項目
プロトコル HTTPSのみ
料金クラス 北米、欧州、アジア、中東、アフリカを使用
(Blue/Greenデプロイメントには直接関係ありませんが、コストを抑えるためです)
カスタムSSL証明書 任意の証明書
(本記事ではexample.comとします)
項目 1つ目のディストリビューションに設定する値 2つ目のディストリビューションに設定する値
オリジンドメイン sample-service-blueのデフォルトドメイン
https://の部分は不要です)
sample-service-greenのデフォルトドメイン
https://の部分は不要です)
代替ドメイン カスタムSSL証明書に対応する任意のドメイン
(本記事ではsample.example.comとします)
なし
説明 sample-distribution-blue sample-distribution-green

何故片方だけに代替ドメイン名を入力するかというと、CloudFrontのルールとして、複数のディストリビューションに同一の代替ドメインを同時に設定できないようになっているためです。つまり、代替ドメインは、常にルーティング先が本番環境になっている方のディストリビューションにのみ設定されるように、適宜付け替える必要があります。

Route 53のレコードを作成する

続いて、公式ドキュメントの手順に従い、Route 53のレコードを2つ作成します。

  • 1つ目(Aレコード)
項目
レコード名 sample-distribution-blueの代替ドメイン名と対応する名称
(本記事ではsampleとします)
レコードタイプ Aエイリアス
トラフィックのルーティング先 CloudFrontディストリビューションへのエイリアス > sample-distribution-blueドメイン
ルーティングポリシー シンプルルーティング
  • 2つ目(TXTレコード)
項目
レコード名 Aレコードのレコード名の先頭に_を付したもの
(本記事では_sampleとします)
レコードタイプ TXT
トラフィックのルーティング先 sample-distribution-greenドメイン名の末尾に.を付したもの
ルーティングポリシー シンプルルーティング

何故2つ目のTXTレコードが必要になるかというと、CloudFrontディストリビューションを作成するの節で説明した代替ドメインの付け替えに必要になるためです。このレコードに、代替ドメインを設定しない方のディストリビューションドメインを設定しておく必要があります。レコード名の先頭に_を付けたり値の末尾に.を付けたりするのは仕様です。詳しくは公式ドキュメントを参照ください。

GitHubのRepository secretsを設定する

ここからはGitHub側の準備になります。GitHub Actionsの利用に際して、AWSの認証情報や間接的に取得することが難しい値を、あらかじめRepository secretsに設定しておきます。公式ドキュメントの手順に従い、以下の4つを設定してください。

Name Value
AWS_ACCESS_KEY_ID 自身のAWSアクセスキーID
AWS_SECRET_ACCESS_KEY 自身のAWSシークレットアクセスキー
AWS_ROUTE53_HOSTED_ZONE_ID Route 53のホストゾーンID
AWS_ROUTE53_RECORD_NAME sample.example.com(Route 53のAレコードのレコード名)

GitHub ActionsのワークフローのYAMLファイルを作成する

最後に、実際にBlue/Greenデプロイメントを実現する部分である、GitHub ActionsのワークフローのYAMLファイルを作成します。今回の構成でBlue/Greenデプロイメントの実現に必要な要素は、以下の4つです。

  • 現在本番環境になっているApp Runnerサービスのデプロイトリガーを自動に変更する

  • 現在検証環境になっているApp Runnerサービスのデプロイトリガーを手動に変更する

  • CloudFrontディストリビューションの代替ドメイン名を付け替える

  • Route 53のAレコードとTXTレコードのルーティング先を入れ替える

まずはこれらを、AWS CLIのコマンドを用いてGitHub Actionsのワークフローに落とし込みます。なお、以下の例では、release/〇〇のようなタグのpushをBlue/Greenデプロイメントのトリガーとしています。

name: Blue/Green Deploy

on:
  push:
    tags:
      - release/*

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      # 現在本番環境になっているApp Runnerサービスのデプロイトリガーを自動に変更する
      - name: Change Source Configuration of Current Service
        run: aws apprunner update-service --service-arn `本番環境側のApp RunnerサービスのARN` --source-configuration `設定ファイルのパス`

      # 現在検証環境になっているApp Runnerサービスのデプロイトリガーを手動に変更する
      - name: Change Source Configuration of Next Service
        run: aws apprunner update-service --service-arn `検証環境側のApp RunnerサービスのARN` --source-configuration `設定ファイルのパス`

      # CloudFrontディストリビューションの代替ドメイン名を付け替える
      - name: Replace Alias
        run: aws cloudfront associate-alias --target-distribution-id `検証環境側のCloudFrontディストリビューションのID` --alias ${{ secrets.AWS_ROUTE53_RECORD_NAME }}

      # Route 53のAレコードとTXTレコードのルーティング先を入れ替える
      - name: Change Record Targets
        run: aws route53 change-resource-record-sets --hosted-zone-id ${{ secrets.AWS_ROUTE53_HOSTED_ZONE_ID }} --change-batch `設定ファイルのパス`

このワークフローを実行することにより、現在の検証環境が本番環境となり、Aレコードのドメインからアクセス可能になります。一方、現在の本番環境は検証環境となり、ソースコードをpushするだけで最新のアプリケーションが自動でデプロイされます。

しかしながら、このままでは現在の本番環境がどちらかをその都度手動で調べたり、各リソースのARNやIDなどをコピペしたり、設定ファイルを自作したりする必要があります。当然、そんなことをしていては非常に面倒ですし、ヒューマンエラーも発生しやすくなってしまいます。

そのため、それらの処理も併せて自動化したものが以下になります。

name: Blue/Green Deploy

on:
  push:
    tags:
      - release/*

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      # CloudFrontディストリビューションの一覧を取得し、その中から説明が`sample-distribution`で始まっているものの情報をファイルに書き込む
      # 代替ドメインが1個のものを現在の本番環境、0個のものを検証環境と判定する
      - name: Get Distribution List
        run: |
          aws cloudfront list-distributions | jq '.DistributionList.Items[] | select(.Comment | startswith("sample-distribution"))' > distribution-list.json
          cat distribution-list.json | jq 'select(.Aliases.Quantity==1)' > current-distribution.json
          cat distribution-list.json | jq 'select(.Aliases.Quantity==0)' > next-distribution.json

      # 2つのCloudFrontディストリビューションのドメイン名を取得し、変数に代入する
      - name: Get Distribution Domains
        id: distribution-domains
        run: |
          echo "::set-output name=CURRENT_DISTRIBUTION_DOMAIN::$(cat current-distribution.json | jq -r '.DomainName')"
          echo "::set-output name=NEXT_DISTRIBUTION_DOMAIN::$(cat next-distribution.json | jq -r '.DomainName')"

      # 検証環境側のCloudFrontディストリビューションのIDを取得し、変数に代入する
      - name: Get Distribution ID
        id: distribution-id
        run: echo "::set-output name=NEXT_DISTRIBUTION_ID::$(cat next-distribution.json | jq -r '.Id')"

      # App Runnerサービスの一覧を取得し、ファイルに書き込む
      - name: Get Service List
        run: aws apprunner list-services | jq '.ServiceSummaryList[]' > service-list.json

      # 2つのApp Runnerサービスのドメインを取得し、変数に代入する
      - name: Get Service Domains
        id: service-domains
        run: |
          echo "::set-output name=CURRENT_SERVICE_DOMAIN::$(cat current-distribution.json | jq -r '.Origins.Items[0].DomainName')"
          echo "::set-output name=NEXT_SERVICE_DOMAIN::$(cat next-distribution.json | jq -r '.Origins.Items[0].DomainName')"

      # 2つのApp RunnerサービスのARNを取得し、変数に代入する
      - name: Get Service ARNs
        id: service-arns
        run: |
          echo "::set-output name=CURRENT_SERVICE_ARN::$(cat service-list.json | jq -r --arg domain ${{ steps.service-domains.outputs.CURRENT_SERVICE_DOMAIN }} 'select(.ServiceUrl==$domain) | .ServiceArn')"
          echo "::set-output name=NEXT_SERVICE_ARN::$(cat service-list.json | jq -r --arg domain ${{ steps.service-domains.outputs.NEXT_SERVICE_DOMAIN }} 'select(.ServiceUrl==$domain) | .ServiceArn')"

      # 2つのApp Runnerサービスの設定情報を取得し、デプロイトリガー部分を変更した上でファイルに書き込む
      - name: Get Service Config Files
        run: |
          aws apprunner describe-service --service-arn ${{ steps.service-arns.outputs.CURRENT_SERVICE_ARN }} | jq '.Service.SourceConfiguration | .AutoDeploymentsEnabled=true' > current-service-config.json
          aws apprunner describe-service --service-arn ${{ steps.service-arns.outputs.NEXT_SERVICE_ARN }} | jq '.Service.SourceConfiguration | .AutoDeploymentsEnabled=false' > next-service-config.json

      # 指定したホストゾーンIDのRoute 53のレコードの一覧を取得し、指定したレコード名のものと、指定したレコード名の先頭に`_`を付したものの情報をファイルに書き込む
      # 2つのレコードの設定部分を変更した上でファイルに書き込む
      - name: Get Record Config File
        run: |
          aws route53 list-resource-record-sets --hosted-zone-id ${{ secrets.AWS_ROUTE53_HOSTED_ZONE_ID }} --output json | jq --arg name ${{ secrets.AWS_ROUTE53_RECORD_NAME }} '.ResourceRecordSets[] | select(.Name | endswith($name+"."))' > record-list.json
          cat record-list.json | jq --arg domain ${{ steps.distribution-domains.outputs.CURRENT_DISTRIBUTION_DOMAIN }} 'select(.Type=="TXT") | .ResourceRecords[0].Value="\""+$domain+".\"" | {"Changes":[{"Action":"UPSERT"}+{ResourceRecordSet:.}]}' > txt-record.json
          cat record-list.json | jq --arg domain ${{ steps.distribution-domains.outputs.NEXT_DISTRIBUTION_DOMAIN }} 'select(.Type=="A") | .AliasTarget.DNSName=$domain+"." | {"Changes":[{"Action":"UPSERT"}+{ResourceRecordSet:.}]}' > a-record.json
          cat txt-record.json a-record.json | jq -s '.[0].Changes+.[1].Changes | {"Changes":.}' > record-config.json

      # 現在本番環境になっているApp Runnerサービスのデプロイトリガーを自動に変更する
      - name: Change Source Configuration of Current Service
        run: aws apprunner update-service --service-arn ${{ steps.service-arns.outputs.CURRENT_SERVICE_ARN }} --source-configuration file://current-service-config.json

      # 現在検証環境になっているApp Runnerサービスのデプロイトリガーを手動に変更する
      - name: Change Source Configuration of Next Service
        run: aws apprunner update-service --service-arn ${{ steps.service-arns.outputs.NEXT_SERVICE_ARN }} --source-configuration file://next-service-config.json

      # CloudFrontディストリビューションの代替ドメイン名を付け替える
      - name: Replace Alias
        run: aws cloudfront associate-alias --target-distribution-id ${{ steps.distribution-id.outputs.NEXT_DISTRIBUTION_ID }} --alias ${{ secrets.AWS_ROUTE53_RECORD_NAME }}

      # Route 53のAレコードとTXTレコードのルーティング先を入れ替える
      - name: Change Record Targets
        run: aws route53 change-resource-record-sets --hosted-zone-id ${{ secrets.AWS_ROUTE53_HOSTED_ZONE_ID }} --change-batch file://record-config.json

      # 作成したファイルを削除する
      - name: Delete Temporary Files
        if: ${{ always() }}
        run: rm distribution-list.json current-distribution.json next-distribution.json service-list.json current-service-config.json next-service-config.json record-list.json txt-record.json a-record.json record-config.json

実際にBlue/Greenデプロイメントしてみる

それでは、実際にBlue/Greenデプロイメントのワークフローを動作させ、環境の切り替わりを確認してみます。

  • 現在の本番環境の状態を確認するために、sample.example.comにアクセスします。

    サンプルアプリケーションの文字列がそのまま表示されます。

  • 現在の検証環境の状態を確認するために、sample-distribution-greenドメインにアクセスします。

    本番環境と同一の画面が表示されます。

  • server.pyの8行目のHelloこんにちはに変更して、GitHubリポジトリにpushします。

MESSAGE = "こんにちは, " + name + "!"
  • sample-service-greenへのデプロイが完了するのを待ってから、再度sample-distribution-greenドメインにアクセスしてみます。

    こんにちはに更新されているため、検証環境には先ほどpushした内容が反映されていることが分かります。

  • 再度sample.example.comにアクセスしてみます。

    Helloのままであるため、先ほどのpushが本番環境には影響を与えていないことが分かります。

  • release/v1.0.0というタグをpushします。 GitHub Actionsのワークフローが正常に完了していれば成功です。

git tag release/v1.0.0
git push origin release/v1.0.0
  • 再度sample.example.comにアクセスしてみます。

    こんにちはに更新されています。Blue/Greenデプロイメントにより、先ほどまでの検証環境が数十秒のうちに本番環境に切り替わったことが確認できました!

なお、確認は省略しますが、今回確認したBlueからGreenへの切り替えだけでなく、GreenからBlueへの切り替えも正常に動作します。

GitHub ActionsのワークフローのYAMLファイルの改善方法

GitHub Actionsのログ出力の制御

本記事にサンプルとして掲載したYAMLファイルによるワークフローを実行すると、Repository secrets以外の変数の値やAWS CLIのコマンドの実行結果がGitHub Actionsのログに出力されてしまいます。そのため、Publicなリポジトリで実行する場合には、ログ中での値のマスクnulへのリダイレクトなどを利用して、ログの出力を工夫する必要があります。

# 値のマスクの例
CURRENT_DISTRIBUTION_DOMAIN=$(cat current-distribution.json | jq -r '.DomainName')
echo "::add-mask::$CURRENT_DISTRIBUTION_DOMAIN"
echo "::set-output name=CURRENT_DISTRIBUTION_DOMAIN::$CURRENT_DISTRIBUTION_DOMAIN"

# nulへのリダイレクトの例
aws apprunner update-service --service-arn ${{ steps.service-arns.outputs.CURRENT_SERVICE_ARN }} --source-configuration file://current-service-config.json > nul

OpenID Connectを利用したAWSリソースへのアクセス

GitHub ActionsからAWSリソースにアクセスする方法に関しては、OpenID Connectを利用する方法が先日発表されました。こちらの方法を利用すると、AWSアクセスキーIDやAWSシークレットアクセスキーを利用せずにAWSリソースにアクセスできるため、より安全性を高められます。詳しくは、公式ドキュメントや、テックブログ記事であるOpenID Connectを利用してGitHub ActionsからAWSリソースにアクセスするを参照ください。

CloudFrontディストリビューションのキャッシュパージ

Blue/GreenデプロイメントのワークフローのYAMLファイルとは別に、mainブランチへのpushをトリガーとして検証環境側のCloudFrontディストリビューションのキャッシュをパージするワークフローのYAMLファイルを作成することにより、アプリケーションをデプロイし直す際にキャッシュによって古い情報が配信されることを防げます。

# キャッシュパージの例
NEXT_DISTRIBUTION_ID=$(aws cloudfront list-distributions | jq -r '.DistributionList.Items[] | select(.Comment | startswith("sample-distribution")) | select(.Aliases.Quantity==0) | .Id')
aws cloudfront create-invalidation --distribution-id $NEXT_DISTRIBUTION_ID --paths "/*"

まとめ

本記事では、GitHub Actions、App Runner、CloudFront、Route 53を利用してBlue/Greenデプロイメントを実現する方法をご紹介しました。GitHubリポジトリソースコードをpushするだけで検証環境にアプリケーションがデプロイされ、タグをpushするだけでBlue/Greenデプロイメントが完了するというのは、開発活動を進めていく上で非常に便利で快適です。機会があれば皆さんもぜひお試しください!

電通国際情報サービス Advent Calendar 2021 3日目となる明日の記事は比嘉康雄さんの「Geth(ゲス)はじめました」です!お楽しみに!

最後までお読みいただき、本当にありがとうございました!

執筆:@miyazawa.hibiki、レビュー:@sato.taichiShodoで執筆されました