Cloud Run を活用した Pull Request 単位での Ad hoc 開発環境作成

Ikki Shoka

2023.8.9

きっかけ

開発時、feature ブランチの Pull Request (以下、PR)ごとに実行環境が準備されると便利だよねというところから、PR ごとに開発環境を構築される仕組みを作ることになりました。

使用技術スタック

  • Github Actions
  • Cloud Run

実装例 (バックエンド)

git-flow にて feature/hotfix のブランチ上で、環境を用意したいという要望があり、
git-flow に基づいて実装をしてきます。
※ git-flow は Atlassianのブログ がわかりやすいかと思います。

git-flow の図

  1. feature ブランチにて PR を作成する
  2. 環境を作成したい PR に対して PReview というラベルを Github を付与する
  3. Github Actions の Job が実行される
  4. PR ごとの環境とエンドポイントの URL が発行される

Github Actionsのバックエンド job実装例

on:
  pull_request:
    types: [synchronize, labeled]
    branches:
      - "develop"
      - "master"

env:
  PROJECT_ID: site-staging
  GCP_SA_KEY: ${{ secrets.STG_GCLOUD_SERVICE_KEY }}
  MYSQL_USER: XXX
  SERVICE: site-api
  REGION: asia-northeast1
  IMAGE: asia-docker.pkg.dev/site-staging/site-api/api:${{ github.sha }}

jobs:
  deploy-pr-staging-environment:
    if: contains(github.event.pull_request.labels.*.name, 'PReview')
    runs-on: ubuntu-18.04
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup Cloud SDK
        uses: google-github-actions/setup-gcloud@v0.2.0
        with:
          project_id: ${{ env.PROJECT_ID }}
          service_account_key: ${{ env.GCP_SA_KEY }}
          export_default_credentials: true

      - name: Exec Cloud SQL Proxy
        uses: mattes/gce-cloudsql-proxy-action@v1
        with:
          creds: ${{ env.GCP_SA_KEY }}
          instance: site-staging:asia-northeast1:site-database

      - name: Copy site database
        run: |
          sudo apt-get --allow-releaseinfo-change update
          sudo apt install -y default-mysql-client
          mysql -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 -e "DROP DATABASE IF EXISTS site_${{ github.event.pull_request.number }};"
          mysqldump -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 --set-gtid-purged=OFF site > from_db.dump.sql
          mysqladmin -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 create site_${{ github.event.pull_request.number }}
          mysql -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 site_${{ github.event.pull_request.number }} < from_db.dump.sql

      - name: Authorize Docker push
        run: gcloud auth configure-docker asia-docker.pkg.dev --quiet

      - name: Build and Push Container
        run: |-
          docker build . -t ${{ env.IMAGE }}
          docker push ${{ env.IMAGE }}

      - name: Deploy to Cloud Run
        id: deploy
        uses: google-github-actions/deploy-cloudrun@v0.10.0
        with:
          service: ${{ env.SERVICE }}
          image: ${{ env.IMAGE }}
          region: ${{ env.REGION }}
          tag: pr-${{ github.event.pull_request.number }}
          env_vars: MYSQL_DATABASE=site-${{ github.event.pull_request.number }}
          no_traffic: true

      - name: Find Comment
        uses: peter-evans/find-comment@v1
        id: fc
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: github-actions[bot]
          body-includes: "Preview"

      - name: Create Preview URL
        id: preview-url
        run: echo "::set-output name=value::https://pr-${{ github.event.pull_request.number }}---site-api-*******-an.a.run.app"

      - name: Get datetime for now
        id: datetime
        run: echo "::set-output name=value::$(date)"
        env:
          TZ: Asia/Tokyo

      - name: Create or update comment
        uses: peter-evans/create-or-update-comment@v1
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Visit the :eyes: **Preview** :eyes: for this PR (updated for commit ${{ github.event.pull_request.head.sha }}):
            <${{ steps.preview-url.outputs.value }}>
            <sub>(:fire: updated at ${{ steps.datetime.outputs.value }})</sub>
          edit-mode: replace

トリガー条件

on:
  pull_request:
    types: [synchronize, labeled]
    branches:
      - "develop"
      - "master"
  • PRに新しくpushされた際、またラベルが付与された際に、実行されるように設定が加えられています
  • また、develop への PR、 master への PR はそれぞれ、feature ブランチ、hotfix ブランチからの PR を想定して、2つのブランチに絞った設定になっています

実行条件

jobs:
  deploy-pr-staging-environment:
    if: contains(github.event.pull_request.labels.*.name, 'PReview')
    runs-on: ubuntu-18.04
...

こちらの設定にて、PReviewが付与されたときだけにトリガーされるようになっています。

if: contains(github.event.pull_request.labels.*.name, 'PReview')

データベースの作成

  - name: Copy site database
        run: |
          sudo apt-get --allow-releaseinfo-change update
          sudo apt install -y default-mysql-client
          mysql -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 -e "DROP DATABASE IF EXISTS site_${{ github.event.pull_request.number }};" # 2回目動かす際に失敗するためにDBがあったら削除するように設定を追加している
          mysqldump -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 --set-gtid-purged=OFF site > from_db.dump.sql
          mysqladmin -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 create site_${{ github.event.pull_request.number }}
          mysql -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 site_${{ github.event.pull_request.number }} < from_db.dump.sql

site_${{ github.event.pull_request.number }} で DB を作成し、既存 DB からダンプして、インポートする形で DBを作成しています。

ビルド、プッシュ、デプロイ

      - name: Build and Push Container
        run: |-
          docker build . -t ${{ env.IMAGE }}
          docker push ${{ env.IMAGE }}

      - name: Deploy to Cloud Run
        id: deploy
        uses: google-github-actions/deploy-cloudrun@v0.10.0
        with:
          service: ${{ env.SERVICE }}
          image: ${{ env.IMAGE }}
          region: ${{ env.REGION }}
          tag: pr-${{ github.event.pull_request.number }}
          # 先程作成したデータベース名を環境変数で指定している
          env_vars: MYSQL_DATABASE=site-${{ github.event.pull_request.number }}
          no_traffic: true

ここではDockerでビルドし、Cloud Runでデプロイする形を取っています。
Action は google-github-actions/deploy-cloudrun@v0.10.0 を利用して作成し、tag を PR ナンバーを利用して作成しています。
こちらは PR ごとの URL 作成に必要となってきます。

既にURL用のコメントが発行されているかを確認する

      - name: Find Comment
        uses: peter-evans/find-comment@v1
        id: fc
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: github-actions[bot]
          body-includes: "Preview"

後述する Preview という Body が含まれていたら、新しくコメントするようにし、既にあればコメントを更新するようにできます。

PR 用のコメントを残すようにする

      - name: Create Preview URL
        id: preview-url
        run: echo "::set-output name=value::https://pr-${{ github.event.pull_request.number }}---site-api-*******-an.a.run.app"

      - name: Get datetime for now
        id: datetime
        run: echo "::set-output name=value::$(date)"
        env:
          TZ: Asia/Tokyo

      - name: Create or update comment
        uses: peter-evans/create-or-update-comment@v1
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Visit the :eyes: **Preview** :eyes: for this PR (updated for commit ${{ github.event.pull_request.head.sha }}):
            <${{ steps.preview-url.outputs.value }}>
            <sub>(:fire: updated at ${{ steps.datetime.outputs.value }})</sub>
          edit-mode: replace
  1. 1 つ目のステップで、PR の URL を output、2 つ目のステップで、日付の output が取得します。
  2. 最後のステップで PR に URL をコメントして残します。

次のURLが発行されます。

Cloud Run 上ではリビジョンが新しく作成され、トラフィックは既存の staging に向かないように 0% になっています。

PR Close 時の参考 (一部抜粋)

name: PR Staging Delete

on:
  pull_request:
    types: [closed]
    branches:
      - "develop"
      - "master"
...

jobs:
  delete-pr-staging-environment:
    if: contains(github.event.pull_request.labels.*.name, 'PReview')
...

- name: Delete Database
        run: |
          sudo apt-get --allow-releaseinfo-change update
          sudo apt install -y default-mysql-client
          mysql -u ${{ env.MYSQL_USER }} -h 127.0.0.1 --port=5432 -e "DROP DATABASE site_${{ github.event.pull_request.number }};"

      - name: Delete revision with tag
        run: >
          gcloud run services update-traffic ${{ env.SERVICE }}
          --region ${{ env.REGION }}
          --remove-tags pr-${{ github.event.pull_request.number }}

      - name: Find Comment
        uses: peter-evans/find-comment@v1
        id: fc
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: github-actions[bot]
          body-includes: "Preview"

      - name: Create Preview URL
        id: preview-url
        run: echo "::set-output name=value::https://pr-${{ github.event.pull_request.number }}---site-api-*******-an.a.run.app"

      - name: Get datetime for now
        id: datetime
        run: echo "::set-output name=value::$(date)"
        env:
          TZ: Asia/Tokyo

      - name: Create or update comment
        uses: peter-evans/create-or-update-comment@v1
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Visit the :eyes: **Preview** :eyes: for this PR (updated for commit ${{ github.event.pull_request.head.sha }}):
            ~<${{ steps.preview-url.outputs.value }}>~
            <sub>(:warning: deleted at ${{ steps.datetime.outputs.value }})</sub>
          edit-mode: replace

基本的には PR が Close 時に反対のことをしているだけです。
コメントは Close 時に以下のようにコメントアウトされます。

実装例 (フロントエンド) ※参考までに

こちらに関しては変更点を環境変数として上書きする必要があり、また複数 Workflow Template を利用しているので、最初に URL を取得する形の実装をしています。

jobs:
  get-preview-url:
    if: contains(github.event.pull_request.labels.*.name, 'PReview')
    runs-on: ubuntu-latest
    outputs:
      app_url: ${{ steps.preview-url.outputs.app_url }}
      api_url: ${{ steps.set-api-url.outputs.result }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Extract PR request API URL
        uses: actions/github-script@v6
        id: set-api-url
        if: contains(toJSON(github.event.pull_request.body) , 'backend:')
        with:
          script: |
            const description = context.payload.pull_request.body
            const result = description.split('\r\n').find(str => str.startsWith('backend:')).split(':')[1].trim()
            return result
          result-encoding: string

      - name: Create Preview URL
        id: preview-url
        run: |
          echo "::set-output name=app_url::https://pr-${{ github.event.pull_request.number }}---site-app-*******-an.a.run.app"
...

バックエンドの環境変数を書き換える際に使用

      - name: Extract PR request API URL
        uses: actions/github-script@v6
        id: set-api-url
        if: contains(toJSON(github.event.pull_request.body) , 'backend:')
        with:
          script: |
            const description = context.payload.pull_request.body
            const result = description.split('\r\n').find(str => str.startsWith('backend:')).split(':')[1].trim()
            return result
          result-encoding: string

PR 作成時に backend:pr-XXX——api-hogehoge.a.run.app という PR のコメントを追記するとそれを見て、参照バックエンドビルド時の環境変数が書き換わるようになります。

環境変数の書き換え (一部抜粋)

- name: Set PR Staging URL
        run: |-
          if [ -n "${{ inputs.api_url }}" ]; then
            sed -i 's|API_ROOT=.*|API_ROOT=${{ inputs.api_url }}/api/v1/|g' ./packages/site-app/.env.stg
          fi
          sed -i 's|SITE_APP_DOMAIN=.*|SITE_APP_DOMAIN=${{ inputs.app_url }}|g' ./packages/site-app/.env.stg
          cat ./packages/site-app/.env.stg

      - name: Build and Push Container
        run: |-
          docker build -t ${{ env.IMAGE }} -f ${{ inputs.dockerfile_name }} --build-arg ENV=stg .
          docker push ${{ env.IMAGE }}

ビルド前に環境変数が埋め込まれる実装になっているため、ビルド前に環境変数のファイルを先程取得した URL の値に sed で書き換えるようにしました。

導入時の課題

実際に導入をしてみると下記のような課題がありました。

  • 各ホストのプレビューの URL が DB の ID がサブドメインとして付与されるため、各ホストごとのにプレビュー環境を用意するのが難しい形になっている
  • タグより前に . がついた URL が発行されると証明書の ASN にマッチしなくなる *.preview.test.site が使用不可能
  • **.**.preview.test.site の証明書は発行できないので各ホストに合わせて証明書を当てることができない
  • Cloud Run はドメインが完全一致する必要があるので、プレフィックスに何が当たるかわからないものは使用できない
  • 独自ドメインでプロキシ転送する必要があるが、アプリケーションの実装も変更しない限り実現は難しそう
    ⇒ 結論: 動的な値が2箇所入ると厳しい

PreviewURL

https://97938b8a21dc443a848b5ecbb3ec8e5e.preview.test.site

各ドメインごとの URL (下記のようなドメインは不可能)

https://97938b8a21dc443a848b5ecbb3ec8e5e.preview.test.site/pr-XXX—site-app-*******-an.a.run.app

補記

  • Workflow Templates はデフォルトブランチに merge されると、直接利用しないにもかかわらず Actions の一覧に出てきて厄介 (視認性が落ちるため)
  • Workflow Templates は .github/workflows 以下に作成する必要があり、template だけ下の階層にディレクトリを作成しても動作しないので同列になって見にくい (命名規則で解消する必要あり)
  • Github Actions は実行が逐一表示されないときがあるので、その点は CircleCI の方が利便性が高い

参考文献

https://zenn.dev/matken/articles/preview-deploy-on-cloud-run

ブログ一覧へ戻る

お気軽にお問い合わせください

SREの設計・技術支援から、
SRE運用内で使用する
ツールの導入など、
SRE全般についてご支援しています。

資料請求・お問い合わせ