0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

10分でGCPにメンテ可能なサービスをデプロイするためのterraformベストプラクティス

0
Last updated at Posted at 2026-06-27

ハッカソンでWebサービスを作るとき、意外と困るのがデプロイ先です。

ローカルでは動いた。
でも発表までにURLが必要。
できればGitHubにpushしたら自動で更新されてほしい。
ついでに、ハッカソン後に少し育てても破綻しない構成にしておきたい。

この条件で実際に使ったのが、Firebase Hosting + Cloud Run + Cloud Build + Terraformの構成です。

ハッカソン用に選んだ構成ではありますが、発表用の使い捨て構成ではありません。
小さな業務アプリ、社内ツール、PoCから本番に寄せていくサービスでも、そのまま使える部分が多いです。

理由は、責務がかなり素直だからです。

Cloud Runはコンテナを置くだけで動きます。
Firebase Hostingは静的ファイルをCDNに載せつつ、/api/**をCloud Runへ流せます。
Cloud Buildを使えば、GitHub pushからデプロイできます。
Secret ManagerやFirestoreも同じGCPプロジェクトに置けます。

ただし、全部をTerraformで管理しようとすると急にしんどくなります。

GCPは便利な反面、「初回だけブラウザで認可が必要」「Firebase CLIの方が明らかに強い」「まだ存在しないDocker imageをTerraformで参照したくなる」みたいな罠があります。

この記事では、ハッカソンで実際に使った構成をもとに、短時間でGCPにデプロイしつつ、実務寄りにもメンテできるTerraformの割り切りを書きます。

結論から言うと、こうです。

  • 箱はTerraformで作る
  • 中身のデプロイはCloud Buildに任せる
  • GitHub連携やFirebase Hostingのデプロイは、無理にTerraformだけで完結させない
  • シークレット値はTerraformに持たせない
  • ロードバランサは自作せず、まずFirebase Hostingに乗る

構成

だいたいこういう構成です。

  • Frontend
    • React Router
    • Firebase Hosting
  • Backend
    • Cloud Run
    • Docker
  • CI/CD
    • Cloud Build
    • GitHub trigger
  • Infra
    • Terraform
    • Secret Manager
    • Artifact Registry
    • Firestore

Firebase Hostingで静的ファイルを配信し、/api/**だけCloud Runにrewriteします。

{
  "hosting": {
    "public": "build/client",
    "rewrites": [
      {
        "source": "/api/**",
        "run": {
          "serviceId": "api",
          "region": "asia-northeast1"
        }
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

Firebase HostingはCloud Runへのrewriteを公式にサポートしています。
公式ドキュメントにも、Hostingのrewritesrun.serviceIdregionを指定する例があります。

Cloud Runのimageはhelloにしてignoreする

TerraformでCloud Runを作るとき、最初に困るのがコンテナimageです。

普通に考えると、こう書きたくなります。

containers {
  image = "${var.region}-docker.pkg.dev/${var.project_id}/app/api:latest"
}

でも、初回apply時点ではそのimageはまだ存在しません。
Artifact RegistryのリポジトリをTerraformで作って、そのあとCloud Buildでimageをpushして、そのあとCloud Runを更新したい。

つまり、TerraformがCloud Runを作りたいタイミングでは、本番imageがまだ存在していないわけです。

こういうときは、Cloud Runの公式hello imageを仮で指定して、image差分をTerraform管理から外すのが楽です。

resource "google_cloud_run_v2_service" "api" {
  name     = "api"
  location = var.region

  template {
    containers {
      image = "us-docker.pkg.dev/cloudrun/container/hello"

      ports {
        container_port = 3000
      }
    }
  }

  lifecycle {
    ignore_changes = [
      template[0].containers[0].image,
    ]
  }
}

ポイントは、Terraformの責務を「Cloud Run serviceという箱を作る」までにすることです。

実際のアプリケーションimageはCloud Buildがgcloud run deployで差し替えます。
その後にterraform applyしても、Terraformはimage差分を無視するので、hello worldに巻き戻りません。

これは逃げではなく、責務分離です。

  • Terraform
    • API有効化
    • IAM
    • Cloud Run service
    • Secret Managerの箱
    • Artifact Registryの箱
  • Cloud Build
    • Docker build
    • Docker push
    • Cloud Run deploy

Terraformにデプロイ成果物の最新版まで管理させると、インフラ管理とアプリケーションリリースが密結合します。
小さいサービスでは、その方がつらいです。

scalingもCloud Buildや手動調整を許す

個人開発や小さなサービスでは、Cloud Runのmin_instance_countは基本0でいいです。

scaling {
  min_instance_count = 0
  max_instance_count = 3
}

Cloud Runはリクエストがないときにスケールダウンできます。
Firebase Hosting連携でも、Cloud Run自体はリクエストに応じて起動します。
公式にも、Cloud Runは受け取ったリクエストに応じて水平スケールし、需要が減るとスケールダウンすると説明されています。

ただし、max_instance_countは入れておいた方がいいです。
個人開発でいちばん怖いのは落ちることより、謎にスケールして請求が跳ねることです。

Terraformで初期値を置きつつ、必要ならCloud Run側で一時的に調整する。
その運用を許すなら、scalingignore_changesに入れておく選択肢があります。

lifecycle {
  ignore_changes = [
    template[0].containers[0].image,
    template[0].scaling,
  ]
}

何でもTerraformに戻させるのが正義ではありません。
「普段いじる運用値」と「構成として固定したい値」は分けた方がメンテしやすいです。

シークレット値をTerraformで注入しない

Secret Manager自体はTerraformで作っていいです。

resource "google_secret_manager_secret" "agent_service_url" {
  project   = google_project.default.project_id
  secret_id = "agent-service-url"

  replication {
    user_managed {
      replicas {
        location = var.region
      }
    }
  }
}

Cloud Runから参照する設定もTerraformで書いていいです。

env {
  name = "AGENT_SERVICE_URL"

  value_source {
    secret_key_ref {
      secret  = google_secret_manager_secret.agent_service_url.secret_id
      version = "latest"
    }
  }
}

でも、シークレットの値までTerraformで管理するのはおすすめしません。

resource "google_secret_manager_secret_version" "agent_service_url" {
  secret      = google_secret_manager_secret.agent_service_url.id
  secret_data = var.agent_service_url
}

これをやると、terraform applyのたびに値の扱いを気にすることになります。
tfvarsに書けば漏洩リスクがあります。
環境変数で渡せばapplyのたびに面倒です。
stateにも機微情報が入ります。

なので、基本はこう割り切るのがいいです。

  • Secret Managerの箱はTerraformで作る
  • Cloud Runがそのsecretを読む設定もTerraformで作る
  • secret valueはブラウザかCLIで入れる

CLIならこうです。

printf '%s' "$AGENT_SERVICE_URL" \
  | gcloud secrets versions add agent-service-url --data-file=-

Terraformは「この名前のsecretが存在し、Cloud Runから読める」ことだけ保証します。
値の投入は運用手順に逃がします。

これは中途半端ではなく、漏れて困る値をTerraformの関心から外しているだけです。

Cloud BuildのGitHub連携は手動でいい

Cloud Build trigger自体はTerraformで書けます。

resource "google_cloudbuild_trigger" "backend" {
  project     = var.project_id
  name        = "backend-cloudrun-deploy"
  description = "Deploy backend to Cloud Run when packages/backend changes"

  github {
    owner = var.github_owner
    name  = var.github_repo

    push {
      branch = "^main$"
    }
  }

  included_files = ["packages/backend/**"]
  filename       = "packages/backend/cloudbuild.yaml"
  service_account = local.cloudbuild_sa
}

ここで大事なのは、GitHub Appの接続までTerraformで頑張りすぎないことです。

Cloud BuildのGitHub連携は、結局GitHub Appの認可が絡みます。
公式ドキュメントでも、Connectボタンを押した後にCloud Build GitHub Appを認可する流れが説明されています。
gcloudを使う場合でも、ブラウザで認可リンクを開く手順が出てきます。

これはブラウザでやった方が早いです。

最初に一度だけGoogle Cloud Consoleから「リポジトリを接続」を押す。
Cloud Build GitHub Appを認可する。
その後、triggerの定義はTerraformで管理する。

このくらいの分担がちょうどいいです。

あと、monorepoではincluded_filesを必ず入れた方がいいです。

included_files = ["packages/backend/**"]

フロントエンドを触っただけでバックエンドのDocker buildが走ると普通に無駄です。
Cloud Buildは無料枠がありますが、無駄なビルドは待ち時間も増やします。

Cloud Build用Service Accountを分ける

Cloud Buildは強い権限を持ちがちです。
なので、Cloud Build用のService Accountを作って、必要なroleだけ付けます。

resource "google_service_account" "cloudbuild_api" {
  project      = var.project_id
  account_id   = "cloudbuild-api"
  display_name = "Cloud Build - API builder & deployer"
}

locals {
  project_roles = [
    "roles/run.admin",
    "roles/artifactregistry.writer",
    "roles/logging.logWriter",
    "roles/firebasehosting.admin",
    "roles/storage.admin",
    "roles/secretmanager.secretAccessor",
  ]
}

さらに、Cloud Runのruntime Service Accountに対してiam.serviceAccountUserを付けます。

resource "google_service_account_iam_member" "cloudbuild_act_as_cloudrun_sa" {
  service_account_id = "projects/${var.project_id}/serviceAccounts/${var.cloud_run_service_account_email}"
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.cloudbuild_api.email}"
}

Cloud BuildはCloud Runをデプロイする人。
Cloud Runのruntime Service Accountはアプリが実行時に使う人。

この2つは分けた方がいいです。

firebaseは大人しくfirebase-toolsを使う

Firebase周りは、Terraformだけで完結させようとしない方がいいです。

Firebase projectやWeb Appの作成くらいはTerraformでできます。
でも、Hosting、Firestore rules、Storage rules、Auth providerなどは、Firebase CLI前提の体験の方が強いです。

firebase.jsonにこう書いて、

{
  "firestore": {
    "database": "(default)",
    "location": "asia-northeast1",
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "hosting": {
    "site": "your-firebase-site",
    "public": "build/client"
  },
  "storage": {
    "rules": "storage.rules"
  }
}

Cloud Buildでは普通にこれを叩きます。

steps:
  - name: "node:22.22.0"
    entrypoint: "bash"
    args:
      - "-lc"
      - |
        set -e
        npm i -g firebase-tools@15.10.0
        corepack enable
        pnpm install --frozen-lockfile
        pnpm --filter @my-app/frontend build
        cd packages/frontend
        firebase deploy --only hosting --project "$PROJECT_ID"

TerraformでFirebaseのすべてを表現しようとすると、サポート差分やCLIとの差分に引っかかります。
FirebaseはFirebase CLIでデプロイする。
Terraformはプロジェクト、API、IAM、Secret、Cloud Runなどの土台を作る。

この分担の方が、変更時に壊れる範囲を読みやすくなります。

firebase-toolsやNodeのバージョンは固定する

CIでlatestを使うと、ある日突然壊れます。

自分の環境では、Firebase HostingへのデプロイでNodeの特定バージョン起因らしきPremature closeを踏みました。
こういうとき、CIの実行環境が浮いていると原因調査がかなり面倒です。

なので、Cloud Buildのimageやfirebase-toolsは固定します。

- name: "node:22.22.0"
  entrypoint: "bash"
  args:
    - "-lc"
    - |
      npm i -g firebase-tools@15.10.0

これは地味ですが有効です。

「昨日まで通っていたdeployが今日は落ちる」を減らすには、依存を固定するしかありません。

Load Balancerはまず自分で作らない

Cloud Runに独自ドメインを当てたい。
SPAも配信したい。
APIは/apiでCloud Runに流したい。

この要件だけ見ると、Google Cloud Load Balancerを作りたくなります。

でも、小さいサービスならまずFirebase Hostingでいいです。

理由は単純で、安いからです。

Firebase Hostingの料金表を見ると、Spark/BlazeともにCustom domain & SSLが含まれています。
HostingはStorage 10GB、Data transfer 360MB/dayの無料枠があり、Blazeでもその無料枠の後にStorageが\$0.026/GB、Data transferが$0.15/GBです。
https://firebase.google.com/pricing

一方で、Google Cloud Load Balancingを自分で作ると、まずforwarding ruleの時間課金があります。
公式料金では、最初の5 forwarding rulesが\$0.025/hourです。
1個だけでも$0.025/hourなので、30日でざっくり18ドルです。

さらに外部Application Load Balancerでは、ロードバランサで処理されるinbound/outbound dataにも課金があります。
Cloud CDNを付けるなら、Cloud CDN側のcache data transfer、cache fill、cache lookup requestも別で見ます。

つまり、自前Load Balancerは「柔軟だが、常時存在するネットワーク部品を自分で持つ」構成です。
Firebase Hostingは「静的配信とカスタムドメインとSSLとCloud Run rewriteをまとめて面倒見てもらう」構成です。

個人開発や初期サービスで欲しいのは、たいてい後者です。

もちろん、Firebase Hostingにも制約はあります。
公式ドキュメントでは、HostingからCloud Runへのrewriteには60秒のrequest timeoutがあると説明されています。
長時間処理や細かいLB制御が必要なら、自前Load Balancerや別構成を考えるべきです。

でも、普通のWebアプリのBFFくらいならFirebase Hostingで十分です。

Firestore databaseは手動でもいい

Firestoreのdefault databaseはTerraformで作れます。

resource "google_firestore_database" "default" {
  name        = "(default)"
  location_id = "asia-northeast1"
  type        = "FIRESTORE_NATIVE"
}

ただ、既存プロジェクトに対して後からTerraform管理へ入れると面倒なことがあります。
default databaseは1個だけですし、削除も気軽にできません。

なので、ここも無理しなくていいです。

新規プロジェクトを完全にTerraformで作るならTerraformで作る。
すでにFirebase Consoleで作ったなら、そのままにする。
rulesとindexesはfirebase deployで管理する。

このくらいの割り切りで十分です。

10分で作るための手順

実際に最短でやるなら、この順番が楽です。

  1. TerraformでGCP project、API、IAM、Artifact Registry、Secret Manager、Cloud Runの箱を作る
  2. Secret Managerに必要な値をブラウザかCLIで入れる
  3. Google Cloud ConsoleでCloud BuildとGitHubを接続する
  4. TerraformでCloud Build triggerを作る
  5. GitHubにpushしてCloud BuildでCloud Runへdeployする
  6. Firebase CLIでHostingを初期化する
  7. firebase.jsonにHosting rewriteを書く
  8. Cloud Buildでfirebase deploy --only hostingする

全部をTerraformでやろうとすると10分では終わりません。
でも、Terraformに向いているところだけTerraformに寄せれば、10分でかなり本番っぽい構成まで行けます。

まとめ

Terraformは強いです。
でも、Terraformで全部やる必要はありません。

GCPとFirebaseでメンテ可能な小規模サービスを作るなら、境界はこう置くのがよかったです。

  • Terraformは箱、IAM、API、参照関係を作る
  • Cloud Buildはアプリケーションをデプロイする
  • Firebase CLIはFirebaseのデプロイを担当する
  • Secretの値とGitHub App認可は手動でよい
  • Load Balancerは必要になるまで作らない

特にCloud Runのimageignore_changesを入れるのは有効です。
Terraform applyでhello worldに戻る事故を防げます。

インフラをコード化する目的は、手作業をゼロにすることではありません。
後から見て、どこが自動で、どこが手動で、どこを触ると壊れるのかを分かるようにすることです。

その意味では、「ここは手動」と明示しておくのも立派な設計です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?