BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

AWS App RunnerとGitHub Actionsでレビュー環境を構築する

この記事はBASE Advent Calendar 2021の22日目の記事です。 f:id:gucccccc1:20211221175407p:plain

はじめに

はじめまして、Owners Success Frontend Shop Frontチームの坂口です。
普段はフロントエンドエンジニアとしてVue.jsを使った開発をメインに行なっているのですが、チームでプロジェクトマネージャーやデザイナーが手軽に動作を確認できるレビュー環境がほしいという話があり、AWS App RunnerとGitHub Actionsを連携して構築をしたのでその話をしたいと思います。

レビュー環境とは

レビュー環境というのはGitHubのプルリクエストやブランチごとに動作確認ができる環境で以下のような動きをするものを今回は構築することにしました。

  • プルリクエストが作成されるとレビュー環境立ち上げ (作成)
  • プルリクエストに紐付いたブランチが更新されるとレビュー環境更新 (更新)
  • プルリクエストがclose、またはマージされるとレビュー環境削除 (削除)

イメージとしてはHerokuのReview AppsやNetlifyのDeploy Previewsなどに近いかもしれません。

使用技術

AWS App RunnerとGitHub Actionsを利用して以下のような構成にしました。
f:id:gucccccc1:20211221173626p:plain

AWS App Runner

AWS App Runnerは今年5月に発表されたコンテナベースのフルマネージドサービスで、インフラの知識があまりなくても簡単にアプリケーションをデプロイすることができます。

今回構築するにあたってAWS LambdaやAWS ECSなど選択肢はありましたが、簡単にデプロイできるということで採用しました。

準備

事前にGitHub Actionsで利用するAWSのIAMユーザーを作成して、リポジトリのsecretsにアクセスキーとシークレットアクセスキーを用意しておきます。
必要な権限はこちらの公式記事で書いてあるものになります。

ワークフロー内容

ワークフローは先ほど上げた作成、更新、削除に加えて、一時停止、復帰を追加した以下5パターンを用意しました。

  • プルリクエストにラベルがつくとレビュー環境立ち上げ (作成)
  • ラベルが付いたプルリクエストへ新たにpushされると更新 (更新)
  • プルリクエストがclose、またはマージされるとレビュー環境削除 (削除)
  • 毎日21時になると稼働中のレビュー環境を一時停止(一時停止)
  • プルリクエストの/resumeとコメントすると復帰(復帰)

プルリクエストにラベルがつくとレビュー環境立ち上げ

name: review-app-deploy

on:
  pull_request:
    types:
      - labeled

jobs:
  deploy-review-app:
    name: Deploy Review App
    runs-on: ubuntu-latest
    environment: development
    if: ${{ github.event.label.name == 'ラベル名' }}
    env:
      BRANCH_NAME: ${{ github.head_ref }}

    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:  ${{ secrets.AWS_REGION }}

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      env:
        ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}
        ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
      run: |
        IMAGE_TAG=`echo $BRANCH_NAME | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        SERVICE_NAME=`echo review-app_$IMAGE_TAG | cut -c 1-40`
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
        echo "::set-output name=service::$SERVICE_NAME"
        echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

    - name: Deploy to App Runner
      id: deploy-service
      uses: awslabs/amazon-app-runner-deploy@main
      with:
        service: ${{ steps.build-image.outputs.service }}
        image: ${{ steps.build-image.outputs.image }}
        access-role-arn: ${{ secrets.APPRUNNER_ROLE_ARN }}
        runtime: NODEJS_16
        region: ${{ secrets.AWS_REGION }}
        cpu : 1
        memory : 2
        port: 80
        wait-for-service-stability: false

    - name: Comment Review App URL
      run: |
        SERVICE_URL=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='${{ steps.build-image.outputs.service }}']" | jq -r '.[]["ServiceUrl"]'`
        gh pr comment $PR_NUMBER --body "Review App URL: https://$SERVICE_URL"

大まかな流れは以下になります。

  1. Dockerイメージビルド
  2. Amazon ECRへDockerイメージプッシュ
  3. AWS App Runnerのサービス作成

今回、プルリクエストのブランチ名をDockerイメージのタグとAWS App Runnerのサービス名に利用しているのですが、AWS App Runnerのサービス名の制限に合わせて文字列を置換しています。
またあとから識別しやすいようにサービス名にreview-app_プレフィックスを付与しています。

IMAGE_TAG=`echo $BRANCH_NAME | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
SERVICE_NAME=`echo review-app_$IMAGE_TAG | cut -c 1-40`

サービス作成では、AWS公式GitHub Actionsであるaws-actions/configure-aws-credentialsを利用していて、CPU、メモリは最低限にしています。
wait-for-service-stabilitytrueにするとサービス作成完了まで待ってくれるのですが、完了まで5分ほどかかってしまいGitHub Actionsの枠を使い切ってしまうことを懸念してfalseにしています。

さらにAWS App RunnerのURLをプルリクエストのコメントに通知するようにしています。(※サービス作成完了後ではないので、利用可能までに5分程度待機が必要です。)

SERVICE_URL=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='${{ steps.build-image.outputs.service }}']" | jq -r '.[]["ServiceUrl"]'`
gh pr comment $PR_NUMBER --body "Review App URL: https://$SERVICE_URL"

以下のように特定のラベル(今回はdeploy review app)を付与するとワークフローが走り、サービスURLをコメントしてくれるようになります。 f:id:gucccccc1:20211221173719p:plain

ラベルが付いたプルリクエストへ新たにpushされると更新

name: review-app-update

on:
  pull_request:
    types:
      - synchronize

jobs:
  update-review-app:
    name: Update Review App
    runs-on: ubuntu-latest
    environment: development
    if: ${{ contains(github.event.pull_request.labels.*.name, 'ラベル名') }}
    env:
      BRANCH_NAME: ${{ github.head_ref }}

    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: ${{ secrets.AWS_REGION }}

    - name: Check Service Exist
      id: check-service-exist
      run: |
        IMAGE_TAG=`echo $BRANCH_NAME | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        SERVICE_NAME=`echo "review-app_$IMAGE_TAG" | cut -c 1-40`
        SERVICE_ARN=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='$SERVICE_NAME'&&Status=='RUNNING']" | jq -r '.[]["ServiceArn"]'`
        echo "::set-output name=image-tag::$IMAGE_TAG"
        echo "::set-output name=service-arn::$SERVICE_ARN"


    - name: Login to Amazon ECR
      id: login-ecr
      if: ${{ steps.check-service-exist.outputs.service-arn }}
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      if: ${{ steps.check-service-exist.outputs.service-arn }}
      env:
        ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}
        ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
      run: |
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:${{ steps.check-service-exist.outputs.image-tag }} .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{ steps.check-service-exist.outputs.image-tag }}

    - name: Update App Runner Service
      id: update-service
      if: ${{ steps.check-service-exist.outputs.service-arn }}
      run: |
        aws apprunner start-deployment --service-arn ${{ steps.check-service-exist.outputs.service-arn }}

大まかな流れは作成とほぼ同じになりますが、注意点としてaws-actions/configure-aws-credentials内部でサービスが存在する場合は更新コマンドを実行しているのですが、更新コマンドはサービスの設定を更新してくれるのみでソースを更新してはくれないため、そのまま利用せず、start-deploymentを実行する必要があります。

aws apprunner start-deployment --service-arn ${{ steps.check-service-exist.outputs.service-arn }}

また細かいですが、jqでは-rオプションをつけて結果の文字列からダブルクウォートを削除して扱いやすくしています。

SERVICE_ARN=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='$SERVICE_NAME'&&Status=='RUNNING']" | jq -r '.[]["ServiceArn"]'`

毎日21時になると稼働中のレビュー環境を一時停止

name: review-apps-pause

on:
  schedule:
    - cron: '0 12 * * MON-FRI'

jobs:
  pause-review-app:
    name: Pause Review App
    runs-on: ubuntu-latest
    environment: development

    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: ${{ secrets.AWS_REGION }}

    - name: Pause App Runner Services
      id: pause-services
      run: |
        SERVICE_ARN_LIST=`aws apprunner list-services --query "ServiceSummaryList[?Status=='RUNNING']" |  jq -r '.[] | select(.ServiceName | test("^review-app_")) | .ServiceArn'`
        if [ -n "$SERVICE_ARN_LIST" ] ; then
          echo $SERVICE_ARN_LIST | while read SERVICE_ARN ; do
            aws apprunner pause-service --service-arn $SERVICE_ARN
          done
        fi

AWS App Runnerはサービスが起動している間課金されてしまうため、深夜の誰も利用しない時間では一時停止するようにしています。タイムゾーンはUTCなので注意してください。

on:
  schedule:
    - cron: '0 12 * * MON-FRI'

一時停止するサービスはAWS CLIの--queryオプションで稼働中のものを抽出した後、jqでサービス名がreview-app_プレフィックスのものを絞り込んでいます。

SERVICE_ARN_LIST=`aws apprunner list-services --query "ServiceSummaryList[?Status=='RUNNING']" |  jq -r '.[] | select(.ServiceName | test("^review-app_")) | .ServiceArn'`

プルリクエストに/resumeとコメントすると復帰

name: review-app-resume

on:
  issue_comment:
    types: [created, edited]

jobs:
  resume-review-app:
    name: Resume Review App
    runs-on: ubuntu-latest
    environment: development
    if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/resume') }}
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    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: ${{ secrets.AWS_REGION }}

    - name: Check Service Exist
      id: check-service-exist
      run: |
        BRANCH_NAME=`gh pr view ${{ github.event.issue.number }} --json headRefName --jq '.headRefName'`
        SERVICE_NAME=`echo "review-app_$BRANCH_NAME" | cut -c 1-40 | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        SERVICE_ARN=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='$SERVICE_NAME'&&Status=='PAUSED']" | jq -r '.[]["ServiceArn"]'`
        echo "::set-output name=service-arn::$SERVICE_ARN"

    - name: Resume App Runner Service
      id: resume-service
      if: ${{ steps.check-service-exist.outputs.service-arn }}
      run: |
        resume-service --service-arn ${{ steps.check-service-exist.outputs.service-arn }}

issue_commentをトリガーにしており、作成や更新のようにgithub.head_refでブランチ名が取得できないため、コメントされたプルリクエストからブランチを特定するための処理を挟んでいます。

BRANCH_NAME=`gh pr view ${{ github.event.issue.number }} --json headRefName --jq '.headRefName'`

以下のようにコメントすることでワークフローが走り、一時停止中のAWS App Runnerサービスが復帰してくれます。 f:id:gucccccc1:20211221173742p:plain

プルリクエストがclose、またはマージされるとレビュー環境削除

name: review-app-delete

on:
  pull_request:
    types:
      - closed

jobs:
  delete-review-app:
    name: Delete Review App
    runs-on: ubuntu-latest
    environment: development
    if: ${{ contains(github.event.pull_request.labels.*.name, 'ラベル名') }}
    env:
      BRANCH_NAME: ${{ github.head_ref }}


    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: ${{ secrets.AWS_REGION }}

    - name: Delete App Runner Service
      id: delete-service
      run: |
        SERVICE_NAME=`echo "review-app_$BRANCH_NAME" | cut -c 1-40 | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        SERVICE_ARN=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='$SERVICE_NAME']" | jq -r '.[]["ServiceArn"]'`
        if [ -n "$SERVICE_ARN" ] ; then
          aws apprunner delete-service --service-arn $SERVICE_ARN
          echo "::set-output name=tag::$SERVICE_NAME"
        fi

    - name: Delete ECR Image
      id: delete-ecr-image
      if: success()
      env:
        ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
      run: |
        TAG=`echo "$BRANCH_NAME" | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        aws ecr batch-delete-image --repository-name $ECR_REPOSITORY --image-ids imageTag=$TAG

削除したいサービスの存在確認をして、サービスとDockerイメージを削除します。

        SERVICE_NAME=`echo "review-app_$BRANCH_NAME" | cut -c 1-40 | sed -e "s/[^a-zA-Z0-9_-]/-/g"`
        SERVICE_ARN=`aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='$SERVICE_NAME']" | jq -r '.[]["ServiceArn"]'`
        if [ -n "$SERVICE_ARN" ] ; then
          aws apprunner delete-service --service-arn $SERVICE_ARN
          echo "::set-output name=tag::$SERVICE_NAME"
        fi

おわりに

今回は公式でGitHub Actionsが用意されているのもあって比較的簡単に連携・レビュー環境の構築ができました。まだサービス作成完了時の通知など改善の余地はたくさんありますが、手軽に構築ができるので、ぜひ一度検討してみてはいかがでしょうか。