これまでは staging と production でそれぞれ別々にコンテナイメージをビルドし、環境ごとにデプロイしていました。 これを Cloud Deploy のデリバリーパイプラインに置き換え、1 つのイメージを staging にデプロイしてから production にプロモーションする形に変更しました。 その過程でやったことを、次の 3 つに分けてまとめます。
- コンテナイメージを staging/production の両方で動作させる
- Cloud Deploy でイメージをデプロイする
- デプロイ前にデータベースのマイグレーションを実行する
コンテナをstaging/production環境で動作させるようにする
staging から production へプロモーションするには、同じコンテナイメージが両方の環境で動作する必要があります。 そのため、ビルド時に値が解決されてイメージに埋め込まれてしまう VITE_ などの環境変数の利用をやめ、実行時に解決するようにしました。
あわせて Cloud Build の定義を staging/production のプロジェクトから CI/CD 用のプロジェクトへ移し、コンテナイメージのビルドを一本化しました。
旧ビルド
- staging プロジェクト - develop に push されたら api/admin/web のイメージをビルドする
- production プロジェクト - タグが push されたら api/admin/web のイメージをビルドする
新ビルド
- CI/CD プロジェクト - タグが push されたら api/admin/web のイメージをマルチステージビルドでまとめてビルドする
ビルド方法を変更したあと、実際に Cloud Build でイメージをビルドし、staging/production の両環境でイメージを差し替えて正常に動作することを確認しました。
Cloud Deployでイメージをデプロイできる様にする
Cloud Deploy からイメージを差し替えると Terraform の管理する状態と食い違ってしまうため、まず Terraform 側の Cloud Run サービス定義でイメージの差分を無視するようにしました。 そのうえで Cloud Deploy のデリバリーパイプラインを定義します。 ターゲットは staging と production の 2 つで、staging から production へのデプロイには承認を必要としました。
Cloud Deploy には Cloud Run 向けの標準ターゲットもありますが、これを使うとサービスの定義そのものを Cloud Deploy 側のマニフェストで管理することになります。 Cloud Run の設定は Terraform で管理したいので、標準ターゲットは使わずカスタムターゲットを定義し、render と deploy の 2 つのタスクを自前のシェルスクリプトで実装しました。 render ではデプロイ対象のイメージを列挙したマニフェストを生成し、deploy ではそのイメージで Cloud Run のサービスを更新します。 Cloud Deploy にはイメージの差し替えだけを任せる形です。
カスタムターゲットの定義
resource "google_clouddeploy_custom_target_type" "cloud_run" { project = var.project_id location = var.region name = "cloud-run-services" description = "Update the image tag of the Cloud Run services managed by Terraform" tasks { render { container { image = "gcr.io/google.com/cloudsdktool/cloud-sdk:slim" command = ["/bin/bash", "-c"] args = [file("${path.module}/scripts/render.sh")] env = { ARTIFACT_REPOSITORY = local.artifact_repository SERVICE_IMAGES = "api-service=api,admin-service=admin,web-service=web" # サービス名=イメージをカンマ区切りで指定 } } } deploy { container { image = "gcr.io/google.com/cloudsdktool/cloud-sdk:slim" command = ["/bin/bash", "-c"] args = [file("${path.module}/scripts/deploy.sh")] env = { ARTIFACT_REPOSITORY = local.artifact_repository SERVICE_IMAGES = "api-service=api,admin-service=admin,web-service=web" # サービス名=イメージをカンマ区切りで指定 } } } } depends_on = [ google_project_iam_member.deploy ] }
render.sh
#!/usr/bin/env bash # render.sh set -uo pipefail workdir="$(mktemp -d)" cd "${workdir}" || exit 1 : "${ARTIFACT_REPOSITORY:?ARTIFACT_REPOSITORY is required}" : "${SERVICE_IMAGES:?SERVICE_IMAGES is required}" : "${CLOUD_DEPLOY_customTarget_imageTag:?customTarget/imageTag deploy parameter is required}" tag="${CLOUD_DEPLOY_customTarget_imageTag}" { echo "# generated by Cloud Deploy render task" echo "imageTag: ${tag}" echo "services:" IFS=',' read -r -a pairs <<< "${SERVICE_IMAGES}" for pair in "${pairs[@]}"; do echo " - service: ${pair%%=*}" echo " image: ${ARTIFACT_REPOSITORY}/${pair#*=}:${tag}" done } > manifest.yaml cat manifest.yaml gcloud storage cp manifest.yaml "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/manifest.yaml" || exit 1 printf '{"resultStatus":"SUCCEEDED","manifestFile":"%s/manifest.yaml"}\n' \ "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}" > results.json gcloud storage cp results.json "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/results.json"
deploy.sh
#!/usr/bin/env bash # deploy.sh set -uo pipefail workdir="$(mktemp -d)" cd "${workdir}" || exit 1 write_result() { if [ -n "${2:-}" ]; then printf '{"resultStatus":"%s","failureMessage":"%s"}\n' "$1" "$2" > results.json else printf '{"resultStatus":"%s"}\n' "$1" > results.json fi gcloud storage cp results.json "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/results.json" } fail() { echo "$1" >&2 write_result FAILED "$1" exit 1 } : "${ARTIFACT_REPOSITORY:?ARTIFACT_REPOSITORY is required}" : "${SERVICE_IMAGES:?SERVICE_IMAGES is required}" : "${CLOUD_DEPLOY_customTarget_imageTag:?customTarget/imageTag deploy parameter is required}" : "${CLOUD_DEPLOY_customTarget_runProject:?customTarget/runProject deploy parameter is required}" : "${CLOUD_DEPLOY_customTarget_runRegion:?customTarget/runRegion deploy parameter is required}" tag="${CLOUD_DEPLOY_customTarget_imageTag}" run_project="${CLOUD_DEPLOY_customTarget_runProject}" run_region="${CLOUD_DEPLOY_customTarget_runRegion}" # # Cloud Run サービスの更新 # IFS=',' read -r -a pairs <<< "${SERVICE_IMAGES}" for pair in "${pairs[@]}"; do service="${pair%%=*}" image="${ARTIFACT_REPOSITORY}/${pair#*=}:${tag}" echo "==> ${service}: ${image}" gcloud run services update "${service}" \ --project="${run_project}" \ --region="${run_region}" \ --image="${image}" \ --quiet || fail "failed to update Cloud Run service ${service} to ${image}" done write_result SUCCEEDED
デプロイ前にデータベースのマイグレーションできるようにする
deploy タスクをシェルスクリプトで実装したので、Cloud Run のサービスを更新する前にデータベースのマイグレーションを実行するようにしました。
具体的には、Cloud Run にマイグレーション用のジョブを追加し、deploy タスクの中でサービスのイメージを差し替える前にそのジョブを実行します。 ジョブが正常に完了したときだけ、サービスのイメージを新しいものに更新します。
ジョブのイメージには API サーバーのコンテナイメージを、サービスアカウントには API サーバーに割り当てているものをそのまま流用しました。 API サーバーと同じ権限でマイグレーションを実行することで、新しいテーブルを作成したときにテーブルのオーナーが食い違う様な問題が起きないようにしています。
カスタムターゲットの定義
resource "google_clouddeploy_custom_target_type" "cloud_run" { project = var.project_id location = var.region name = "cloud-run-services" description = "Update the image tag of the Cloud Run services managed by Terraform" tasks { render { container { image = "gcr.io/google.com/cloudsdktool/cloud-sdk:slim" command = ["/bin/bash", "-c"] args = [file("${path.module}/scripts/render.sh")] env = { ARTIFACT_REPOSITORY = local.artifact_repository SERVICE_IMAGES = "api-service=api,admin-service=admin,web-service=web" # サービス名=イメージをカンマ区切りで指定 MIGRATION_JOB = "db-migrate" MIGRATION_IMAGE = "api" # apiのコンテナイメージを利用する } } } deploy { container { image = "gcr.io/google.com/cloudsdktool/cloud-sdk:slim" command = ["/bin/bash", "-c"] args = [file("${path.module}/scripts/deploy.sh")] env = { ARTIFACT_REPOSITORY = local.artifact_repository SERVICE_IMAGES = "api-service=api,admin-service=admin,web-service=web" # サービス名=イメージをカンマ区切りで指定 MIGRATION_JOB = "db-migrate" MIGRATION_IMAGE = "api" # apiのコンテナイメージを利用する } } } } depends_on = [ google_project_iam_member.deploy ] }
render.sh
#!/usr/bin/env bash # render.sh set -uo pipefail workdir="$(mktemp -d)" cd "${workdir}" || exit 1 : "${ARTIFACT_REPOSITORY:?ARTIFACT_REPOSITORY is required}" : "${SERVICE_IMAGES:?SERVICE_IMAGES is required}" : "${MIGRATION_JOB:?MIGRATION_JOB is required}" : "${MIGRATION_IMAGE:?MIGRATION_IMAGE is required}" : "${CLOUD_DEPLOY_customTarget_imageTag:?customTarget/imageTag deploy parameter is required}" tag="${CLOUD_DEPLOY_customTarget_imageTag}" { echo "# generated by Cloud Deploy render task" echo "imageTag: ${tag}" echo "migration:" echo " job: ${MIGRATION_JOB}" echo " image: ${ARTIFACT_REPOSITORY}/${MIGRATION_IMAGE}:${tag}" echo "services:" IFS=',' read -r -a pairs <<< "${SERVICE_IMAGES}" for pair in "${pairs[@]}"; do echo " - service: ${pair%%=*}" echo " image: ${ARTIFACT_REPOSITORY}/${pair#*=}:${tag}" done } > manifest.yaml cat manifest.yaml gcloud storage cp manifest.yaml "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/manifest.yaml" || exit 1 printf '{"resultStatus":"SUCCEEDED","manifestFile":"%s/manifest.yaml"}\n' \ "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}" > results.json gcloud storage cp results.json "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/results.json"
deploy.sh
#!/usr/bin/env bash # deploy.sh set -uo pipefail workdir="$(mktemp -d)" cd "${workdir}" || exit 1 write_result() { if [ -n "${2:-}" ]; then printf '{"resultStatus":"%s","failureMessage":"%s"}\n' "$1" "$2" > results.json else printf '{"resultStatus":"%s"}\n' "$1" > results.json fi gcloud storage cp results.json "${CLOUD_DEPLOY_OUTPUT_GCS_PATH}/results.json" } fail() { echo "$1" >&2 write_result FAILED "$1" exit 1 } : "${ARTIFACT_REPOSITORY:?ARTIFACT_REPOSITORY is required}" : "${SERVICE_IMAGES:?SERVICE_IMAGES is required}" : "${MIGRATION_JOB:?MIGRATION_JOB is required}" : "${MIGRATION_IMAGE:?MIGRATION_IMAGE is required}" : "${CLOUD_DEPLOY_customTarget_imageTag:?customTarget/imageTag deploy parameter is required}" : "${CLOUD_DEPLOY_customTarget_runProject:?customTarget/runProject deploy parameter is required}" : "${CLOUD_DEPLOY_customTarget_runRegion:?customTarget/runRegion deploy parameter is required}" tag="${CLOUD_DEPLOY_customTarget_imageTag}" run_project="${CLOUD_DEPLOY_customTarget_runProject}" run_region="${CLOUD_DEPLOY_customTarget_runRegion}" # # 1. DB マイグレーション # migration_image="${ARTIFACT_REPOSITORY}/${MIGRATION_IMAGE}:${tag}" echo "==> migration ${MIGRATION_JOB}: ${migration_image}" gcloud run jobs update "${MIGRATION_JOB}" \ --project="${run_project}" \ --region="${run_region}" \ --image="${migration_image}" \ --quiet || fail "failed to update Cloud Run job ${MIGRATION_JOB} to ${migration_image}" # --wait は実行が終わるまでブロックし、タスクが失敗すると非 0 で終了する gcloud run jobs execute "${MIGRATION_JOB}" \ --project="${run_project}" \ --region="${run_region}" \ --wait \ --quiet || fail "database migration failed. see the logs of the Cloud Run job ${MIGRATION_JOB} in ${run_project}" # # 2. Cloud Run サービスの更新 # IFS=',' read -r -a pairs <<< "${SERVICE_IMAGES}" for pair in "${pairs[@]}"; do service="${pair%%=*}" image="${ARTIFACT_REPOSITORY}/${pair#*=}:${tag}" echo "==> ${service}: ${image}" gcloud run services update "${service}" \ --project="${run_project}" \ --region="${run_region}" \ --image="${image}" \ --quiet || fail "failed to update Cloud Run service ${service} to ${image}" done write_result SUCCEEDED

