LEARNING GUIDE / 00 22章 · 図解 · 実行できるコード
コードで管理するインフラ / GOOGLE CLOUD
Google Cloudで学ぶ Terraform 実践教科書最初のplanから、Webアプリの運用まで。 つくる・変える・守る・復旧する力を、 ひとつずつ、自分の手に。
第1章から学び始める → 最初のインフラ構成 + network
+ cloud_run
+ cloud_sql
+ 復旧の手順
構成を理解して、変更を確かめる。
インフラを「自分で管理できる」とは、サービスを起動できるだけではありません。変更前に影響を予測し、問題を検知し、データを守りながら元に戻せることです。この教科書では、その一連の仕事をGoogle Cloudで練習します。
題材は、Webアプリを支える小さな実用構成
アプリはCloud Run、データベースはCloud SQL for PostgreSQL、イメージはArtifact Registry、秘密はSecret Manager。最終段階で外部Application Load Balancer、HTTPS、Cloud Armor、監視を加えます。アプリは読み取り専用の状態確認用です。業務画面の実装より、インフラの振る舞いの確認に集中します。
最終構成:公開入口、アプリ、DBを分けて管理する↔ 狭い画面では図を左右にスクロールできます
本書でいう「中規模」は特定の月間PV数ではなく、開発と本番が分かれ、複数人が変更し、DBと機密情報を扱い、継続した運用責任がある 規模です。単一リージョンから始め、可用性・性能・復旧時間に応じて拡張する判断を学びます。Cloud Runの適性を外れる常駐処理や特殊なネットワーク要件の有無は、GKEやCompute Engineを検討する境界です。
4段階の学習ルート
01 仕組みをつかむ 第1〜3章。ローカルファイルでplan、apply、state、HCLを体験。
02 Google Cloudへ 第4〜7章。認証、最初のリソース、共同管理、環境分離。
03 Webアプリを組む 第8〜13章。VPC、DB、秘密、コンテナ、HTTPS、監視。
04 運用を引き受ける 第14〜21章。容量計画、CI/CD、変更・取込・復旧、卒業課題。
準備と学習の進め方
想定する前提は、ファイルを編集できることと、ターミナルでコマンドを実行できることです。HTTP、DNS、CIDR、IAMは必要な章で説明します。実践の目安は24〜36時間+卒業課題 。章の時間表示は読解と基本演習の目安で、サービス作成や証明書発行の待ち時間を含みません。
上部の「演習ZIP」からZIPを展開する。 展開したterraform-google-cloudを作業ルートにする。以降、特記しないコマンドはそのルートから実行する。 コードを読む → 実行前の予測を書く → 実行結果との差を調べる、を繰り返す。 理解度テストと「完了」を使い、日を分けて学習する。進捗はこのブラウザ内に保存される。
実行範囲 第2〜3章はクラウド料金なし。第5章以降のapplyは実リソースを作り、費用が発生します。専用の学習プロジェクトで実施してください。第12〜13章は独自ドメインが必要な発展演習です。本番プロジェクトを教材の接続先にしないでください。
版と検証の読み方
仕様確認日:2026年9月6日。Terraform 1.16.1 、Google provider 8.1.0 、local provider 2.5.3 を教材の基準にしています。最新版を自動追従する教材ではありません。全ルートの静的検証とローカル演習を確認し、Google Cloud上でのapplyと課金を伴う試験は実施していません。組織ポリシー、権限、割当量、DNSは実行時に確認する受け入れ条件です。
この教科書で扱わない領域 Kubernetesの詳細、アプリ利用者のログイン実装、全面的なセキュリティ監査、法令対応、企業共通の組織・フォルダ設計は独立した学習領域です。最終章ではそれらを本番導入前に誰が確認するかまで設計します。「このサンプルをapplyしたら本番準備がすべて終わる」という教材ではありません。
「どう操作するか」より「何が存在すべきか」
手作業では「コンソールを開く、バケットを作る、公開を禁止する」と操作を並べます。Terraformでは、バケットの名前・場所・公開設定を宣言します。Terraformが現在の状況を読み取り、その宣言に近づけるための作成・変更・削除を計画します。これが宣言的なInfrastructure as Codeです。
Terraformは常駐監視サーバーではありません。コンソールで誰かが設定を変更しても、次のplan/applyまで自動で戻すわけではありません。また、applyはDBトランザクションのような全体一括ロールバックではなく、途中まで成功することがあります。
3つを見比べる:望ましい構成、管理の記録、実リソース↔ 狭い画面では図を左右にスクロールできます
構成(.tf) Gitでレビューする設計。「これから、こうしたい」。 state(.tfstate) Terraformのアドレスとクラウドの実体IDを対応づける記録。「何を自分が管理しているか」。 実リソース Google Cloud側に実際に存在するもの。現在の設定やデータはAPIで確認する。
providerとresourceの役割
Terraform本体はHCLを解釈し、依存関係とstateを扱います。Google providerはGoogle CloudのAPIとのやり取りを担当します。google_storage_bucketはresourceの種類、practiceはそのコード内の名前です。この2つを結んだgoogle_storage_bucket.practiceがリソースアドレスになります。
HCL コピー
resource "google_storage_bucket" "practice" {
name = "世界で一意なバケット名"
location = "ASIA-NORTHEAST1"
}
この断片は文法の説明です。実行用の完全なコードは第5章にあります。コード内の名前を変えてもクラウド名は自動では変わりません。逆に、state上のアドレスが変わるだけで削除・新規作成と解釈される場合があります。後の章でmovedを使って対応づけを移します。
差分を手で予測してみる
変更を予測する、小さなplan演習 操作して学ぶ / 01
変更の場面 初めてバケットを作る ラベルを追加する 置換が必要な属性を変える 構成からresourceを削除する
01 READ 02 PLAN 03 APPLY
planを読む 仮想環境へapply
教育用シミュレーション。Google CloudのAPIは呼ばず、課金も発生しません。実際の差分はprovider・属性・現在の状態によって変わります。
planは通常、実リソースの状態を読み、前回stateと構成を比較します。+は作成、~は更新、-は削除、-/+は置換です。変更可能な属性かどうかはproviderとAPIによって異なるため、名称だけで判断せず実際のplanを確認します。
手を動かす前の練習 「バケットにラベルを追加」「バケットを構成から削除」「既存バケットを初めてコードに書く」の3つについて、stateがある場合・ない場合に分けて予測してください。
解説 管理済みへのラベル追加は通常更新。管理済みを構成から削除すると通常削除。既存の実体をコードに書くだけでは対応づけが無いため新規作成を試み、名前重複で失敗し得ます。既存資産にはimportが必要です。
ツールを準備する
macOSではHashiCorp公式Homebrew tapからインストールできます。教材と同じ1.16.1を使う場合は公式配布ページの該当バイナリを選び、SHA-256を照合してください。WindowsはWSL2、Linuxは公式手順を利用できます。以降のシェル例はbash/zshを想定します。
BASH コピー
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform version
.terraform-versionは対応するバージョン管理ツール向けの目印です。Terraform自体がそのファイルを読んで版を切り替えるわけではありません。required_versionが適合しないとエラーになるので、先に版を確認します。
演習A:ファイルをTerraformで作る
完全なコードを見る · labs/00-local/main.tf 演習ZIP内の同じファイルを掲載しています。
labs/00-local/main.tf コピー
terraform {
required_version = ">= 1.16.1, < 1.17.0"
required_providers {
local = {
source = "hashicorp/local"
version = "2.5.3"
}
}
}
variable "message" {
type = string
default = "はじめての Terraform"
}
resource "local_file" "hello" {
filename = "${path.module}/hello.txt"
content = "${var.message}\n"
file_permission = "0644"
}
output "path" {
value = local_file.hello.filename
}
BASH コピー
terraform -chdir=labs/00-local init
terraform -chdir=labs/00-local fmt
terraform -chdir=labs/00-local validate
terraform -chdir=labs/00-local plan -out=create.tfplan
terraform -chdir=labs/00-local show create.tfplan
terraform -chdir=labs/00-local apply create.tfplan
cat labs/00-local/hello.txt
terraform -chdir=labs/00-local plan
最初のplanに1 to add 、apply後のファイルに本文、最後のplanにNo changes が出れば成功です。initはproviderの準備、fmtは整形、validateは設定検査です。validateの成功は、権限や課金設定が正しい証明ではありません。
演習B:変更して、手作業との差を観察する
messageのdefaultを「Google Cloudをコードで管理する」に編集する。plan -out=change.tfplanを作り、ファイルが更新か置換かを読む。apply change.tfplanを実行してcatする。今度はhello.txtだけをエディターで変え、再度planする。
local_fileは内容変更が置換になることがあります。「テキストが変わるから~だろう」という直感より、providerが出すplanを優先します。Terraform管理対象への手作業はdrift(構成と実体のずれ)の入口です。
完了条件と片付け
BASH コピー
terraform -chdir=labs/00-local output
terraform -chdir=labs/00-local state list
terraform -chdir=labs/00-local destroy
destroyの一覧がlocal_fileだけであることを確認し、yesを入力します。hello.txtが消えても、設定ファイルは残ります。もう一度applyすれば再作成できることがIaCの利点です。次章の検証に使うのでlabs/00-local自体は残します。
よくあるつまずき 「No configuration files」は作業場所の違いを疑います。本書は-chdirで場所を固定します。「Unsupported Terraform Core version」は1.16系への切り替えを確認。providerの取得に失敗したらプロキシやネットワークを調べます。stateを消して直そうとしないでください。
まず読めるようになる5つの部品
部品 役目 この教材の例 variable 外から渡す入力 project_id / region locals コード内の計算結果 tfbook-devという共通名 resource 管理する実体 Cloud Runサービス data 既存情報の読み取り プロジェクト番号 output モジュールの外へ公開する結果 サービスURL
同じディレクトリの*.tfはまとめて1つのモジュールとして評価されます。main.tfから順番に実行されるわけではありません。Terraformは参照関係から依存グラフを作り、依存しない処理は並行して進めます。
HCL コピー
variable "environment" {
type = string
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "dev または prod を指定してください。"
}
}
locals {
name = "tfbook-${var.environment}"
labels = {
environment = var.environment
managed_by = "terraform"
}
}
型と式をconsoleで試す
stringは文字列、numberは数、boolは真偽値。list(string)は順序のある集合、set(string)は重複を除いた集合、map(string)は文字列キーの辞書です。異なる型の属性をまとめたいときはobject({name=string, ha=bool})を使います。nullは空文字とは異なり「未指定」を表す場面があります。
BASH コピー
terraform -chdir=labs/00-local console
HCL コピー
upper("dev")
length(["run", "sql"])
toset(["run", "sql", "run"])
{ for name in ["run", "sql"] : name => "api-${name}" }
true ? "REGIONAL" : "ZONAL"
期待値は順にDEV、2、重複なしの集合、キーrun/sqlを持つmap、REGIONALです。consoleはexitで終了します。
for_eachは「安定した名前」で管理する
HCL コピー
resource "google_project_service" "apis" {
for_each = toset([
"run.googleapis.com",
"sqladmin.googleapis.com"
])
project = var.project_id
service = each.value
disable_on_destroy = false
}
apis["run.googleapis.com"]というアドレスになるため、追加しても既存要素の番号がずれません。countは同じものをN個作る用途や0/1の切り替えに適しますが、途中要素を削除すると添字のずれが変更を増やすことがあります。for_eachのキーはplan時に確定している必要があります。
参照すれば、作成順序が生まれる
サブネットのnetwork = google_compute_network.main.idは値の受け渡しであり、ネットワーク作成後にサブネットを作るという依存関係でもあります。depends_onは、API有効化やPrivate Services Accessのような参照だけで表せない依存に限定します。何でもdepends_onで直列にすると、不要な待ち時間や「apply後まで不明」が増えます。
演習:APIの集合を拡張する 上の断片にartifactregistry.googleapis.comを加え、各resourceのアドレスを3つ書いてください。さらに環境名の検証をstagingにも対応させる変更案を考えます。本教材の実行ルートはdev/prodに限定しているので、変更は練習用コピーで行います。
入力ファイルの扱い
terraform.tfvarsと*.auto.tfvarsは自動で読み込まれます。複数経路から同じ値を渡すと優先順位で上書きされるため、通常値は各ルートのtfvarsに統一します。プロジェクトIDは秘密ではありませんが、教材の誤実行防止のため実値入りtfvarsをGitから除外しています。チームでは秘密なしの環境設定をレビュー対象にする設計も有効です。
プロジェクト・課金・APIの3点セット
Google CloudのプロジェクトはリソースとIAMの主要な境界です。プロジェクトIDは人が選ぶ文字列、プロジェクト番号は自動付与の数値。Terraformの多くの引数はIDを使いますが、WIFのリソース名や予算フィルターでは番号も登場します。混同しないよう手元に記録します。
Google Cloudコンソールで空の学習プロジェクト を選び、課金アカウントを紐づける。 開始前にCloud Billingで学習予算とメール通知を設定する。予算は自動停止装置ではない。 組織管理下なら、許可リージョン、外部公開、サービスアカウント、外部IPの制約を管理者に確認する。 gcloud CLI、Git、Python 3、Docker(第10章で使用)を準備する。
BASH コピー
export BOOK_PROJECT="自分の学習プロジェクトID"
export BOOK_REGION="asia-northeast1"
export BOOK_STATE="世界で一意なstateバケット名"
gcloud auth login
gcloud config set project "$BOOK_PROJECT"
gcloud auth application-default login
gcloud auth application-default set-quota-project "$BOOK_PROJECT"
gcloud projects describe "$BOOK_PROJECT" --format='value(projectNumber)'
gcloud services enable serviceusage.googleapis.com cloudresourcemanager.googleapis.com storage.googleapis.com --project="$BOOK_PROJECT"
これらの環境変数はターミナルを開き直すと消えます。秘密ではない値だけを自分のメモに控えておきます。Cloud Shellでは通常ADCの追加ログインは不要です。
gcloud loginとADCは別の認証経路
コマンド・Terraform・アプリは異なる主体で動く↔ 狭い画面では図を左右にスクロールできます
gcloud auth loginは主にgcloud用、gcloud auth application-default loginはTerraformやアプリSDKが使うADC用です。gcloud config set projectだけでTerraformのprojectが自動的に正しくなると考えず、providerにも明示します。GOOGLE_APPLICATION_CREDENTIALSに古い認証ファイルが設定されていると、ADCの探索でそちらが優先される場合があります。
必要な権限を段階ごとに付ける
実行対象 代表的なロール 付与先・補足 初期API Service Usage Admin 学習プロジェクト バケット Storage Admin 作成時。state操作はバケットのObject Adminへ絞る 基盤 Compute Network Admin / Service Networking Admin / Cloud SQL Admin VPC・PSA・DBの作成 秘密とSA Secret Manager Admin / Service Account Admin / Project IAM Admin 初期構築に必要。日常CIと分離 アプリ Cloud Run Admin / Artifact Registry Admin 初回構築。実行用SAへのService Account Userも必要 外部入口 Compute Load Balancer Admin / Compute Security Admin IP・LB・Cloud Armor。組織側の制約も確認 監視・予算・WIF Monitoring Admin / Billing Account Costs Manager / Workload Identity Pool Admin 請求権限は課金アカウント。API有効化権限とは別
この表は機能ごとの権限設計の出発点です。組織のカスタムロールやDenyポリシーによって不足する場合があります。403なら失敗したAPIのpermissionを特定し、最小のスコープで追加します。常用CIにOwner/Editorを付与して解決しないこと。実行用SAのToken Creatorを持つことは、そのSAの権限を利用できることでもあります。
鍵ファイルを作らない経路
最初は個人ADC、チームではサービスアカウントのimpersonation、GitHub ActionsではWIFを使います。サービスアカウントのJSON鍵をダウンロードしてGitやCIの変数へ保存する経路を標準にしません。providerとGCS backendは別に認証するため、impersonationを採用する際は両方の権限を確認します。
なぜバケットから始めるのか
作成に必要な設定が少なく、VMやDBより早くフィードバックを得られるからです。ただし保存量・操作・ネットワーク通信などに課金があり、無料を保証する演習ではありません。今回はオブジェクトを入れない空のバケットで操作します。
完全なコードを見る · labs/01-bucket/main.tf 演習ZIP内の同じファイルを掲載しています。
labs/01-bucket/main.tf コピー
variable "project_id" { type = string }
variable "bucket_name" { type = string }
variable "region" {
type = string
default = "asia-northeast1"
}
provider "google" {
project = var.project_id
region = var.region
}
resource "google_storage_bucket" "practice" {
name = var.bucket_name
location = var.region
uniform_bucket_level_access = true
public_access_prevention = "enforced"
force_destroy = false
labels = {
environment = "learning"
managed_by = "terraform"
}
}
output "bucket" { value = google_storage_bucket.practice.name }
uniform_bucket_level_accessは個別オブジェクトACLを無効にしてIAMへ統一します。public_access_prevention = "enforced"は公開を防ぎます。force_destroy = falseは中身があると削除を失敗させる設定です。バケット名は世界で一意なので、プロジェクトIDと任意の短い接尾辞を組み合わせます。
演習:実値を設定して適用する
BASH コピー
cp labs/01-bucket/terraform.tfvars.example labs/01-bucket/terraform.tfvars
# エディターで project_id と bucket_name を自分の値に変更
terraform -chdir=labs/01-bucket init
terraform -chdir=labs/01-bucket validate
terraform -chdir=labs/01-bucket plan -out=bucket.tfplan
terraform -chdir=labs/01-bucket show bucket.tfplan
terraform -chdir=labs/01-bucket apply bucket.tfplan
terraform -chdir=labs/01-bucket output -raw bucket
apply前の確認は「正しいproject」「意図したリージョン」「公開防止」「削除0」の4点です。outputに出た名前をGoogle Cloudコンソールで開き、公開アクセスが禁止されていることを確認します。
BASH コピー
BOOK_BUCKET=$(terraform -chdir=labs/01-bucket output -raw bucket)
gcloud storage buckets describe "gs://$BOOK_BUCKET" --project="$BOOK_PROJECT"
terraform -chdir=labs/01-bucket plan
変更は1つずつ
main.tfのlabelsにpurpose = "terraform-book"を追加してplanします。期待するのはバケットの更新です。別のバケットを作る計画になったら、名前・作業場所・stateの接続先を見直します。変更後は再度planしてNo changesを確かめます。
理解を深める実験 コンソールでラベルを1つ変更し、通常のplanでTerraformが何を戻そうとするか確認してください。今回は「コードを正」にする練習なので、意図したラベルだけが戻ることを確認してapplyします。実務では手作業変更が必要な事故対応だった可能性を確認してから判断します。
片付けまでを1回の演習にする
BASH コピー
terraform -chdir=labs/01-bucket plan -destroy -out=cleanup.tfplan
terraform -chdir=labs/01-bucket show cleanup.tfplan
terraform -chdir=labs/01-bucket apply cleanup.tfplan
対象はこのバケット1つだけです。ソフトデリートが有効なら削除後も保持期間中の費用が残ることがあります。force_destroyで強制的に消す習慣を付けず、空かどうかを調べるところから始めます。次章のstateバケットは、この練習用バケットとは別に作ります。
stateをGitに置かない
stateには実リソースのIDや属性、場合によっては秘密の値も含まれます。Gitの差分共有ではなく、暗号化・IAM・履歴・ロックを持つbackendで管理します。本書はGCSを利用します。GCS backendはstate lockingをサポートし、同じstateへの同時書き込みを防ぎます。
共有stateはアクセス制御とロックを持つ保存先へ↔ 狭い画面では図を左右にスクロールできます
bootstrap:保存先を誰が作るのか
GCS backendは、すでに存在するバケットを要求します。そのため最初だけローカルstateでバケットを作り、できた後でstateを移します。この「自分の土台を先に用意する」処理をbootstrapと呼びます。
完全なコードを見る · bootstrap/state/main.tf 演習ZIP内の同じファイルを掲載しています。
bootstrap/state/main.tf コピー
variable "project_id" { type = string }
variable "bucket_name" { type = string }
provider "google" { project = var.project_id }
resource "google_storage_bucket" "state" {
name = var.bucket_name
location = "ASIA-NORTHEAST1"
uniform_bucket_level_access = true
public_access_prevention = "enforced"
force_destroy = false
versioning { enabled = true }
lifecycle { prevent_destroy = true }
}
output "bucket_name" { value = google_storage_bucket.state.name }
BASH コピー
cp bootstrap/state/terraform.tfvars.example bootstrap/state/terraform.tfvars
# project_id と bucket_name を編集
terraform -chdir=bootstrap/state init
terraform -chdir=bootstrap/state plan -out=bootstrap.tfplan
terraform -chdir=bootstrap/state apply bootstrap.tfplan
cp bootstrap/state/backend.tf.example bootstrap/state/backend.tf
cp bootstrap/state/backend.hcl.example bootstrap/state/backend.hcl
# backend.hcl の bucket を作成済みバケット名に変更
terraform -chdir=bootstrap/state init -migrate-state -backend-config=backend.hcl
terraform -chdir=bootstrap/state state list
terraform -chdir=bootstrap/state plan
移行確認にyesを答え、bootstrap/state/default.tfstateがGCSに存在することと、No changesを確認します。旧ローカルstateや計画ファイルも機密として扱い、移行完了まで保管し、完了後は不要な複製を削除します。移行途中にバケットを削除しないでください。
ロック・バージョニング・保護は別物
ロック 同時更新による衝突を防ぐ。planから承認までの長い時間をずっと排他にするわけではない。 Object Versioning 上書き前のstateを残す。消したDB内のデータを復元する機能ではない。 prevent_destroy 構成が残っている間のTerraform経由の削除を止める。コンソール操作に対する防御ではない。
-lock-timeout=5mはロックが空くまで待ちます。force-unlockは実行者がもう動いていないことを確認した障害対応に限ります。「ロックが邪魔だから解除する」と並行applyの防御がなくなります。
本書の分割単位
同じdev専用バケット内にdev/foundation、dev/runtime、dev/edge、dev/operations、dev/identityを作ります。prefixは名前空間であり、そのままIAMの境界になるわけではありません。本番は別プロジェクト・別stateバケット・別実行SA で分けます。
remote_stateへのアクセスは軽い権限ではない 後続ルートはterraform_remote_stateで前段のoutputを読みます。outputだけを公開するAPIではなく、読む主体にはstate全体へのアクセスが必要です。チームをまたぐ場合は、必要な非秘密のIDだけを別の設定ストアへ公開する設計を検討します。
モジュールは「入力と出力があるディレクトリ」
root moduleはTerraformを実行するディレクトリです。子モジュールはそこからmoduleで呼び出す再利用単位です。再利用はコピーを減らし、state分割は変更の影響を小さくします。両者の目的は違うので、「すべてを1モジュール」「resourceごとに1state」の両極端を避けます。
同じ部品を使い、環境の権限とstateは分ける↔ 狭い画面では図を左右にスクロールできます
TEXT コピー
terraform-google-cloud/
bootstrap/state/ # state保存先を準備
labs/00-local/ # 基本操作
labs/01-bucket/ # 最初のGCP演習
modules/
network/ platform/ runtime/ edge/ operations/
environments/
dev/
foundation/ runtime/ edge/ operations/ identity/
prod/
foundation/ runtime/ edge/ operations/
app/ # 状態確認用Webアプリ
ci/ # 有効化前のWorkflow
helpers/ # 検証と秘密の一時入力
ライフサイクルで分ける
foundation はVPC・DB・SA・イメージ置き場・秘密。runtime は頻繁に更新するCloud Run。edge はドメインに結びつく公開入口。operations は監視と予算。WIFとCI用IAMはidentity に分け、通常のアプリ更新から切り離します。
複数stateをまたぐ順序はTerraformが自動で解決しません。foundation → runtime → edge → operationsの順に実行します。子モジュールの中に別stateがあるわけでもありません。1回のroot実行が、そのroot配下の全子モジュールを同じstateで管理します。
演習:設定ファイルを用意する
BASH コピー
for stage in foundation runtime edge operations identity; do
cp "environments/dev/$stage/terraform.tfvars.example" "environments/dev/$stage/terraform.tfvars"
cp "environments/dev/$stage/backend.hcl.example" "environments/dev/$stage/backend.hcl"
done
project_id、state_bucket、backendのbucketを自分の値へ変更します。prefixは既定のdev/各段階を保ちます。image、ドメイン、通知先、GitHub IDは該当章で埋めれば構いません。YOUR_...が残るルートをapplyしないでください。
CLI workspaceを本番の境界にしない
workspaceは同じ構成に複数のstateを持たせる機能ですが、選択忘れが見えにくく、権限・backend・アカウントの違いまで解決しません。本書は各ディレクトリのdefault workspaceだけを使います。Google Cloudのroot moduleガイドも環境ごとのディレクトリを勧めています。
共通化しすぎない
モジュールの入力を100個作ると、利用者は結局サービスの全設定を理解する必要があります。教材ではregion、容量、HA、保護フラグなど、実際に変える項目を入力にします。サービス間の契約としてoutputを定義し、リソース内部の細部をすべて公開しないこと。本書のplatform出力は学習しやすさを優先した集約ですが、実務では必要な値だけに絞れます。
設計課題 「DBを別チームが管理する」「アプリ更新は毎日、VPCは月1回」「本番IAMを開発者が変更できない」の3条件を満たすstate、SA、承認経路を図にしてください。
解答の方向性 DB/VPCを基盤state、Cloud Runをアプリstateへ分け、アプリSAには基盤変更権限を付けない。本番は別project/backend/SA。必要な接続情報を限定して共有します。コードの再利用はmodulesで続けられます。
通信を4つに分ける
「アプリがネットワークにつながらない」では調べる場所が広すぎます。利用者→Cloud Runの入口、Cloud Run→DBの出口、アプリ→Google APIの通信、Terraform→管理APIの通信は、それぞれ別の経路と認証を使います。
Cloud Runの出口:自分のVPCからサービス側ネットワークへ↔ 狭い画面では図を左右にスクロールできます
VPCは仮想的なネットワーク、subnetはリージョンごとにIPアドレスを割り当てる範囲です。10.20.0.0/24は256個のアドレス空間を表しますが、すべてをアプリに使えるわけではありません。Google Cloudの予約アドレスとCloud RunのIP確保を考慮します。Direct VPC egressはrevision更新時も余分なIPを必要とするため、現在のインスタンス数だけでサブネットを小さくしません。
Direct VPC egress
Cloud RunからVPCへ出ていく通信の機能です。本書では別のServerless VPC Access connectorを作らずに利用します。これはCloud RunのHTTP入口をVPC内へ移す設定ではありません。入口の許可はingressとIAMで別に制御します。
PRIVATE_RANGES_ONLYは私設アドレス向けをVPCへ流し、それ以外は通常の出口を使います。ALL_TRAFFICへ変えるなら、外部通信のためのCloud NATなどを設計する必要があります。NATを無条件に足すと常時費用が増えるので、固定送信元IPなどの要件があるときに選びます。
Private Services Access(PSA)
Cloud SQLのprivate IPは、自分のCloud Runサブネットに直接割り当てるものではありません。Google管理のサービス側ネットワークと接続するために、10.30.0.0/16を予約し、Service Networkingの接続を作ります。自社VPN・他VPC・将来のサブネットと重複しないことを、apply前に確認します。
完全なコードを見る · modules/network/main.tf 演習ZIP内の同じファイルを掲載しています。
modules/network/main.tf コピー
variable "project_id" { type = string }
variable "name" { type = string }
variable "region" { type = string }
variable "subnet_cidr" { type = string }
variable "psa_address" { type = string }
resource "google_compute_network" "main" {
project = var.project_id
name = var.name
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "run" {
project = var.project_id
name = "${var.name}-run"
region = var.region
network = google_compute_network.main.id
ip_cidr_range = var.subnet_cidr
private_ip_google_access = true
}
resource "google_compute_global_address" "psa" {
project = var.project_id
name = "${var.name}-psa"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
address = var.psa_address
prefix_length = 16
network = google_compute_network.main.id
}
resource "google_service_networking_connection" "sql" {
network = google_compute_network.main.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa.name]
}
output "network_id" { value = google_compute_network.main.id }
output "subnet_id" { value = google_compute_subnetwork.run.id }
まず基盤の計画を読む
foundationにはネットワークだけでなく次章のDBと秘密も含まれます。ここではコードの関係を読み、実際のapplyは第9章の説明後にまとめて行います。通常の演習のために-targetで途中だけ切り出しません。
BASH コピー
terraform -chdir=environments/dev/foundation init -backend-config=backend.hcl
terraform -chdir=environments/dev/foundation validate
ローカルのPCはこのVPCに入っていないため、private IPのDBへ直接接続できません。Cloud SQL Auth ProxyやLanguage Connectorはネットワーク経路そのものを作る機能ではありません 。ローカルDB接続が必要ならVPN、VPC内の作業環境、認可した踏み台などの経路を用意します。
完了条件 ①Cloud Run用/24、②PSA用/16、③Cloud SQLのprivate IP、④外向けインターネット通信の経路を、自分の言葉で区別する。HTTP入口にVPCの0.0.0.0/0許可ルールを足す必要がない理由も説明します。
解説 Cloud Runの入口はVPC内VMのNICではありません。本書のVPCは主にDBへ出る経路です。Cloud Run ingressの制御はVPC firewallと別に行います。egressの暗黙許可がある専用演習VPCを前提とし、実務では組織のegressルール・タグ・経路を追加確認します。
3種類の「権限」を混同しない
Terraform実行者のCloud SQL AdminはDBインスタンスを構成する権限です。アプリSAのCloud SQL Clientは接続のためのGoogle Cloud権限です。PostgreSQLのユーザー・ロールはSQLを実行する権限です。Cloud SQL Clientを付けても、任意のテーブルをSELECT/UPDATEできるとは限りません。
教材のアプリSAはDB接続と1つのSecret参照だけを許可します。DBユーザーapp_readerはPostgreSQLのpg_read_all_dataを明示し、管理者ロールを使いません。これは専用演習DB全体を読める権限です。業務アプリでは対象schema・tableの必要操作だけを許可するカスタムロールへ絞り、DDLを行うmigrationユーザーと分離します。
同じ一時パスワードをSQLとSecretへ渡す↔ 狭い画面では図を左右にスクロールできます
sensitiveとephemeralは違う
sensitive = trueは主にCLI表示を隠します。通常のpasswordに渡せばstateに残り得ます。本書ではTerraform 1.16.1とGoogle provider 8.1.0の組み合わせで、入力変数にephemeral = true、リソースにpassword_woとsecret_data_woを使います。これらはplan/stateへ値を保存しないwrite-only引数です。
HCL コピー
variable "db_password" {
type = string
sensitive = true
ephemeral = true
}
# SQLユーザーとSecretの両方に、同じ一時値を渡す。
# password_wo = var.db_password
# secret_data_wo = var.db_password
この機能は対応provider/属性に限られます。古い記事のpassword = var.db_passwordへ置き換えると秘密の保存条件が変わります。またwrite-onlyは値の比較ができないので、変更時はpassword_versionを増やします。秘密そのものはパスワードマネージャーで保管し、plan時とapply時に同じ値を入力します。
完全なコードを見る · modules/platform/main.tf 演習ZIP内の同じファイルを掲載しています。
modules/platform/main.tf コピー
resource "google_service_account" "runtime" {
project = var.project_id
account_id = "${var.name}-runtime"
display_name = "Webアプリ実行用"
}
resource "google_artifact_registry_repository" "app" {
project = var.project_id
location = var.region
repository_id = var.name
format = "DOCKER"
description = "ハンズオンのアプリイメージ"
}
resource "google_sql_database_instance" "main" {
project = var.project_id
name = "${var.name}-db"
region = var.region
database_version = "POSTGRES_16"
deletion_protection = var.protect_data
settings {
edition = "ENTERPRISE"
tier = var.sql_tier
availability_type = var.ha ? "REGIONAL" : "ZONAL"
disk_size = 10
disk_autoresize = true
disk_autoresize_limit = 100
deletion_protection_enabled = var.protect_data
ip_configuration {
ipv4_enabled = false
private_network = var.network_id
ssl_mode = "ENCRYPTED_ONLY"
}
backup_configuration {
enabled = true
start_time = "18:00"
point_in_time_recovery_enabled = true
transaction_log_retention_days = 7
backup_retention_settings {
retained_backups = 7
retention_unit = "COUNT"
}
}
maintenance_window {
day = 7
hour = 19
update_track = "stable"
}
user_labels = {
environment = var.environment
managed_by = "terraform"
}
}
}
resource "google_sql_database" "app" {
project = var.project_id
name = "app"
instance = google_sql_database_instance.main.name
}
resource "google_sql_user" "app" {
project = var.project_id
name = "app_reader"
instance = google_sql_database_instance.main.name
password_wo = var.db_password
password_wo_version = var.password_version
# 専用の演習DBの読み取り用。実務では対象schemaだけのカスタムロールへ絞る。
database_roles = ["pg_read_all_data"]
}
resource "google_project_iam_member" "sql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.runtime.email}"
}
resource "google_secret_manager_secret" "db" {
project = var.project_id
secret_id = "${var.name}-db-password"
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "db" {
secret = google_secret_manager_secret.db.id
secret_data_wo = var.db_password
secret_data_wo_version = tostring(var.password_version)
# 切り戻しに備えて旧版を残す。廃止後の無効化は別途実施する。
deletion_policy = "ABANDON"
depends_on = [google_sql_user.app]
}
resource "google_secret_manager_secret_iam_member" "runtime" {
project = var.project_id
secret_id = google_secret_manager_secret.db.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.runtime.email}"
}
演習:基盤をapplyする
第7章のfoundationのtfvarsを埋め、devはdb-f1-micro、ha=falseを確認。 20文字以上のランダムパスワードを生成し、パスワードマネージャーへ保存する。 以下のplanを作る。入力した秘密はヘルパーがTerraform子プロセスの環境変数にだけ渡す。 DBのpublic IP無効、PITR有効、保護フラグ、IAMの付与先を確認してapplyする。
BASH コピー
python3 helpers/with-db-password.py environments/dev/foundation plan -lock-timeout=5m -out=foundation.tfplan
terraform -chdir=environments/dev/foundation show foundation.tfplan
python3 helpers/with-db-password.py environments/dev/foundation apply foundation.tfplan
terraform -chdir=environments/dev/foundation output
python3 helpers/with-db-password.py environments/dev/foundation plan
Cloud SQLやPSAの作成には時間がかかります。出力にはVPC、subnet、イメージ置き場、DB接続名、SecretのIDと版番号が出ます。秘密の値がoutputへ出ない ことを確認します。Secret Managerコンソールで版が有効、Cloud SQLでprivate IPのみ、バックアップ有効、runtime SAの限定ロールを確認できたら成功です。
パスワード更新には順序がある
PostgreSQLの単一ユーザーのパスワードを変えると、旧パスワードを使う既存revisionは再接続に失敗し得ます。教材の単一ユーザー方式は無停止ローテーションを保証しません 。devで新しい秘密とversionを同時に更新し、foundation適用後にruntimeを再適用する練習をします。本番では新旧ユーザーを並行運用して切り替える方法や、IAM DB認証を検討します。
秘密が残らない範囲 write-onlyでも端末メモリ・プロセス環境・API・Secret Managerには秘密が渡ります。デバッグログを有効にして共有しないでください。stateが安全な公開ファイルになるわけでもありません。
Terraformはビルドツールではない
TerraformはCloud Runの構成と参照するイメージを管理します。Dockerのビルドやアプリの単体テストは別の手順です。local-execにビルドとデプロイを詰め込むと、差分の意味・失敗時の再開位置・再現性が分かりにくくなります。
タグlab1は後で別のイメージを指せます。digestのsha256:...は内容の識別子なので、環境間で同じものを渡せます。本書のruntime moduleはdigestなしのimageを検証エラーにします。
完全なコードを見る · app/Dockerfile 演習ZIP内の同じファイルを掲載しています。
app/Dockerfile コピー
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py ./
RUN useradd --uid 10001 --create-home appuser
USER 10001
ENV PYTHONUNBUFFERED=1
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "8", "--timeout", "30", "--access-logfile", "-", "main:app"]
演習:ローカルでビルドし、登録する
Dockerを起動し、docker versionで動作を確認してください。Apple SiliconでもCloud Runが利用できるlinux/amd64向けをビルドします。ベースイメージのタグは教材の可読性のために使っています。本番はベースイメージも検証したdigestに固定し、脆弱性更新の手順を作ります。
BASH コピー
export BOOK_IMAGE="$BOOK_REGION-docker.pkg.dev/$BOOK_PROJECT/tfbook-dev/web"
gcloud auth configure-docker "$BOOK_REGION-docker.pkg.dev"
docker buildx build --platform linux/amd64 --provenance=false --push -t "$BOOK_IMAGE:lab1" app
BOOK_DIGEST=$(gcloud artifacts docker images describe "$BOOK_IMAGE:lab1" --project="$BOOK_PROJECT" --format='value(image_summary.digest)')
printf '%s@%s\n' "$BOOK_IMAGE" "$BOOK_DIGEST"
表示された.../web@sha256:64桁をruntimeのterraform.tfvarsのimageへ貼り付けます。Terraformの実行SAがアプリSAを割り当てる権限、Cloud Runのサービスエージェントがイメージを読む権限も必要です。同じプロジェクトを前提にしており、別プロジェクトのRegistryを使う場合は追加のアクセス設計が必要です。
runtimeを構成する
完全なコードを見る · modules/runtime/main.tf 演習ZIP内の同じファイルを掲載しています。
modules/runtime/main.tf コピー
variable "project_id" { type = string }
variable "region" { type = string }
variable "name" { type = string }
variable "runtime_sa" { type = string }
variable "network_id" { type = string }
variable "subnet_id" { type = string }
variable "sql_connection_name" { type = string }
variable "db_user" { type = string }
variable "db_name" { type = string }
variable "secret_id" { type = string }
variable "secret_version" { type = string }
variable "image" {
type = string
validation {
condition = can(regex("@sha256:[0-9a-f]{64}$", var.image))
error_message = "image は @sha256:... で固定してください。タグだけでは再現できません。"
}
}
variable "min_instances" { type = number }
variable "max_instances" { type = number }
variable "edge_only" { type = bool }
variable "protect_service" { type = bool }
resource "google_cloud_run_v2_service" "app" {
project = var.project_id
name = var.name
location = var.region
deletion_protection = var.protect_service
ingress = var.edge_only ? "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" : "INGRESS_TRAFFIC_ALL"
# edge_only=false: IAM認証。true: LBで公開し、直接の外部入口を閉じる。
invoker_iam_disabled = var.edge_only
template {
service_account = var.runtime_sa
execution_environment = "EXECUTION_ENVIRONMENT_GEN2"
max_instance_request_concurrency = 20
timeout = "30s"
scaling {
min_instance_count = var.min_instances
max_instance_count = var.max_instances
}
vpc_access {
egress = "PRIVATE_RANGES_ONLY"
network_interfaces {
network = var.network_id
subnetwork = var.subnet_id
}
}
containers {
image = var.image
ports { container_port = 8080 }
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
startup_cpu_boost = true
}
env {
name = "INSTANCE_CONNECTION_NAME"
value = var.sql_connection_name
}
env {
name = "DB_USER"
value = var.db_user
}
env {
name = "DB_NAME"
value = var.db_name
}
env {
name = "DB_PASSWORD"
value_source {
secret_key_ref {
secret = var.secret_id
version = var.secret_version
}
}
}
startup_probe {
initial_delay_seconds = 10
timeout_seconds = 3
period_seconds = 10
failure_threshold = 20
http_get { path = "/healthz" }
}
liveness_probe {
initial_delay_seconds = 10
timeout_seconds = 2
period_seconds = 30
http_get { path = "/healthz" }
}
}
}
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
lifecycle {
precondition {
condition = var.min_instances >= 0 && var.min_instances <= var.max_instances && var.max_instances >= 1
error_message = "0 <= min_instances <= max_instances の範囲で指定してください。"
}
}
}
output "service_name" { value = google_cloud_run_v2_service.app.name }
output "url" { value = google_cloud_run_v2_service.app.uri }
BASH コピー
terraform -chdir=environments/dev/runtime init -backend-config=backend.hcl
terraform -chdir=environments/dev/runtime validate
terraform -chdir=environments/dev/runtime plan -out=runtime.tfplan
terraform -chdir=environments/dev/runtime show runtime.tfplan
terraform -chdir=environments/dev/runtime apply runtime.tfplan
BOOK_RUN_URL=$(terraform -chdir=environments/dev/runtime output -raw url)
gcloud run services proxy tfbook-dev --region="$BOOK_REGION" --project="$BOOK_PROJECT" --port=8085
proxyは起動したままにして、ブラウザでhttp://localhost:8085を開きます。別のターミナルでcurl -i http://localhost:8085/healthzを実行すると200が返るのが期待値です。プロキシの利用者にはrun.routes.invoke(例:Cloud Run Invoker)が必要です。終了はCtrl+Cです。
この時点はIAM認証つき
初期値のedge_only=falseは、外部HTTP入口を受け入れますがInvokerのIAM検査は有効です。匿名でrun.app URLへアクセスしたら成功しないことを確認します。第12章では認証済みテストを終えてからLB経由の公開へ切り替えます。
起動と生存を確認するprobe
startup_probeは起動待ち、liveness_probeはプロセスが応答し続けるかの確認です。教材の/healthzはDBを呼びません。DBが一時停止しただけでアプリを大量再起動すると、DB復旧時の接続殺到を悪化させるためです。外部依存を含む確認は次章の/readyzで分けます。
接続名はIPアドレスではない
PROJECT:REGION:INSTANCEという接続名をPython Connectorへ渡します。ConnectorはAPIから接続情報を取得し、暗号化した接続を作ります。本書はIPTypes.PRIVATEを指定し、Direct VPC egressでDBのprivate IPへ届く構成です。接続にはGoogle CloudのSA、DBのユーザーとパスワード、ネットワーク経路がすべて必要です。
完全なコードを見る · app/main.py 演習ZIP内の同じファイルを掲載しています。
app/main.py コピー
"""Cloud SQLへの実接続を確認する、読み取り専用の教材アプリ。"""
import atexit
import logging
import os
import threading
from flask import Flask, jsonify
from sqlalchemy import create_engine, text
app = Flask(__name__)
engine = None
connector = None
engine_lock = threading.Lock()
def get_engine():
global engine, connector
if engine is None:
with engine_lock:
if engine is None:
from google.cloud.sql.connector import Connector, IPTypes
connector = Connector(refresh_strategy="lazy")
def connect():
return connector.connect(
os.environ["INSTANCE_CONNECTION_NAME"], "pg8000",
user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"],
db=os.environ["DB_NAME"], ip_type=IPTypes.PRIVATE,
)
engine = create_engine(
"postgresql+pg8000://", creator=connect,
pool_size=2, max_overflow=0, pool_timeout=5,
pool_recycle=1800, pool_pre_ping=True,
)
return engine
@atexit.register
def shutdown():
if engine is not None:
engine.dispose()
if connector is not None:
connector.close()
@app.get("/")
def index():
return """<!doctype html><html lang="ja"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Terraform ハンズオン</title>
<style>body{font:17px/1.8 sans-serif;max-width:45rem;margin:8vh auto;padding:24px;color:#131F33}a{color:#004AAD}code{background:#ECF0F5;padding:4px}</style>
<h1>コードから、Webアプリへ。</h1><p>Cloud Runがリクエストに応答しています。</p>
<p><a href="/readyz">Cloud SQLへの接続を確認する</a></p>
<p><code>/healthz</code> はプロセス、<code>/readyz</code> はDBを含む状態を確認します。</p></html>"""
@app.get("/healthz")
def health():
return jsonify(status="ok")
@app.get("/readyz")
def ready():
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1")).scalar_one()
return jsonify(status="ok", database="connected")
except Exception:
# 接続文字列・パスワード・SQL詳細は返さない。
logging.error("database readiness failed")
return jsonify(status="unavailable", database="unreachable"), 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
接続プールを読む
リクエストごとにTCP接続を作り直すと、遅延とDB負荷が増えます。SQLAlchemyのpool_size=2、max_overflow=0で再利用数を制限します。gunicornは1プロセスなので、1コンテナの基本上限は2接続です。workersを増やせばプロセスごとに プールができます。
pool_pre_pingは古い接続が生きているかを確認し、pool_timeoutはプール待ちを制限します。Connectorのrefresh_strategy="lazy"は、必要時に接続情報を更新する選択です。CPUがリクエスト中に割り当てられる実行形態との相性を考えます。
演習:DBを含む成功を確認する
BASH コピー
# 第10章と同じ認証プロキシを起動
gcloud run services proxy tfbook-dev --region="$BOOK_REGION" --project="$BOOK_PROJECT" --port=8085
BASH コピー
# 別ターミナル
curl -i http://localhost:8085/healthz
curl -i http://localhost:8085/readyz
/healthzは{"status":"ok"}、/readyzは{"status":"ok","database":"connected"}と200が期待値です。画面が開くだけでDB接続成功とは判断しません。/readyzは毎回SELECT 1を行います。
失敗を3層へ切り分ける
症状 疑う層 確認 起動前にSecretアクセス拒否 Cloud Run起動時の権限 専用SA、Secret単位のAccessor、存在する有効な版 ConnectorのAPIで403 Google Cloudの認可 SQL Admin APIとCloud SQL Client 接続タイムアウト ネットワーク private IP、VPC、PSA、subnet、egressルール password authentication failed DB認証 SQLユーザー、password_version、参照Secret版 permission denied for table DB内権限 schemaのUSAGE、tableのSELECT等 too many connections 容量 全revision×process×poolとDB上限
教材アプリは秘密をログへ漏らさないよう、外部には一般的な503しか返しません。実務で詳細ログを追加するときも、パスワードや接続URLを出さず、エラー分類とrequest IDを記録します。
実データを持つアプリに拡張するとき
テーブル作成やスキーマ変更はアプリのmigrationとして管理します。TerraformはSQLインスタンス・DB・ユーザーのライフサイクルを扱い、データの全行を構成として同期する用途には使いません。migrationジョブ用SAとDBロールを分け、通常runtimeにDDL権限を与えない設計へ進みます。
障害を作る前の練習 下の3層のうちどれに当たるか答えてください。「ネットワークは通るがSQLユーザー名が違う」「SAにSecret権限がない」「DBのCPUが飽和」。正解は順にDB認証、起動時の認可、容量です。闇雲にVPC firewallを開くような解決はしません。
開始条件と費用
この章は自分でDNSを変更できるドメインがある場合に実施します。LB・外部IPv4・Cloud Armorなどの料金が追加されます。ドメインがなくても第11章まででTerraformとDB接続の学習は可能です。Cloud Runの標準URLを利用する小さなサービスでは、LBを追加しない判断も成立します。
公開入口を揃える:DNSと証明書、LBとingress↔ 狭い画面では図を左右にスクロールできます
DNSは名前をIPへ対応づけます。証明書は、その名前のサーバーと暗号化通信するために使います。LBはURL mapに従ってbackendを選び、Serverless NEGがCloud Runを参照します。この構成のNEGは通常のVM health checkの対象ではありません。
演習A:公開入口を作る
edgeのtfvarsに所有するapp.自分のドメインを設定します。証明書リソースは作成できても、DNSが正しく向くまでは利用可能になりません。
完全なコードを見る · modules/edge/main.tf 演習ZIP内の同じファイルを掲載しています。
modules/edge/main.tf コピー
variable "project_id" { type = string }
variable "region" { type = string }
variable "name" { type = string }
variable "service_name" { type = string }
variable "domain" { type = string }
resource "google_compute_global_address" "web" {
project = var.project_id
name = "${var.name}-web-ip"
}
resource "google_compute_region_network_endpoint_group" "run" {
project = var.project_id
name = "${var.name}-neg"
region = var.region
network_endpoint_type = "SERVERLESS"
cloud_run { service = var.service_name }
}
resource "google_compute_security_policy" "web" {
project = var.project_id
name = "${var.name}-armor"
rule {
action = "throttle"
priority = 1000
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
rate_limit_options {
conform_action = "allow"
exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold {
count = 120
interval_sec = 60
}
}
}
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
}
}
resource "google_compute_backend_service" "web" {
project = var.project_id
name = "${var.name}-backend"
protocol = "HTTP"
load_balancing_scheme = "EXTERNAL_MANAGED"
security_policy = google_compute_security_policy.web.id
# Serverless NEG に通常の health_checks は付けない。
backend { group = google_compute_region_network_endpoint_group.run.id }
log_config {
enable = true
sample_rate = 1
}
}
resource "google_compute_url_map" "web" {
project = var.project_id
name = "${var.name}-https"
default_service = google_compute_backend_service.web.id
}
resource "google_compute_managed_ssl_certificate" "web" {
project = var.project_id
name = "${var.name}-cert"
managed { domains = [var.domain] }
}
resource "google_compute_target_https_proxy" "web" {
project = var.project_id
name = "${var.name}-https"
url_map = google_compute_url_map.web.id
ssl_certificates = [google_compute_managed_ssl_certificate.web.id]
}
resource "google_compute_global_forwarding_rule" "https" {
project = var.project_id
name = "${var.name}-https"
ip_address = google_compute_global_address.web.address
target = google_compute_target_https_proxy.web.id
port_range = "443"
load_balancing_scheme = "EXTERNAL_MANAGED"
}
resource "google_compute_url_map" "redirect" {
project = var.project_id
name = "${var.name}-redirect"
default_url_redirect {
https_redirect = true
strip_query = false
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
}
}
resource "google_compute_target_http_proxy" "redirect" {
project = var.project_id
name = "${var.name}-redirect"
url_map = google_compute_url_map.redirect.id
}
resource "google_compute_global_forwarding_rule" "http" {
project = var.project_id
name = "${var.name}-http"
ip_address = google_compute_global_address.web.address
target = google_compute_target_http_proxy.redirect.id
port_range = "80"
load_balancing_scheme = "EXTERNAL_MANAGED"
}
output "ip" { value = google_compute_global_address.web.address }
output "url" { value = "https://${var.domain}" }
BASH コピー
terraform -chdir=environments/dev/edge init -backend-config=backend.hcl
terraform -chdir=environments/dev/edge plan -out=edge.tfplan
terraform -chdir=environments/dev/edge apply edge.tfplan
terraform -chdir=environments/dev/edge output -raw ip
DNS管理画面で対象のホスト名のAレコードをoutputのIPv4へ設定します。同じ名前の古いAAAAレコードや別サービス向けレコードが残っていないか確認します。既存サイトの名前を流用せず、学習用サブドメインを使います。
Google Cloud DNSで管理したい場合 同梱のexamples/dnsは学習用の子ゾーンを新規作成する完全な例です。親ドメイン側でその子ゾーンのNSを委任し、ゾーン内にappのAレコードを作ります。既存の親ゾーン全体を教材へimportする必要はありません。作っただけでは委任されない点に注意してください。
完全なコードを見る · examples/dns/main.tf 演習ZIP内の同じファイルを掲載しています。
examples/dns/main.tf コピー
# 第12章の任意演習。新しい学習用子ゾーンだけを管理する。
terraform {
required_version = ">= 1.16.1, < 1.17.0"
required_providers {
google = {
source = "hashicorp/google"
version = "8.1.0"
}
}
}
variable "project_id" { type = string }
variable "zone_dns_name" {
type = string
validation {
condition = endswith(var.zone_dns_name, ".")
error_message = "DNSゾーン名は末尾にドットを付けてください。"
}
}
variable "lb_ip" { type = string }
provider "google" { project = var.project_id }
resource "google_project_service" "dns" {
service = "dns.googleapis.com"
disable_on_destroy = false
}
resource "google_dns_managed_zone" "learning" {
name = "tfbook-learning"
dns_name = var.zone_dns_name
depends_on = [google_project_service.dns]
}
resource "google_dns_record_set" "app" {
managed_zone = google_dns_managed_zone.learning.name
name = "app.${var.zone_dns_name}"
type = "A"
ttl = 300
rrdatas = [var.lb_ip]
}
output "name_servers" { value = google_dns_managed_zone.learning.name_servers }
output "app_fqdn" { value = google_dns_record_set.app.name }
演習B:Cloud RunをLB向けに切り替える
runtimeのedge_only = trueに変更し、plan/applyします。INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCERに制限し、Invoker IAM検査を無効化します。これで公開WebアプリとしてLB経由の匿名アクセスを受けます。アプリのエンドユーザー認証を追加する場合は、アプリ側で別途実装が必要です。
BASH コピー
terraform -chdir=environments/dev/runtime plan -out=edge-mode.tfplan
terraform -chdir=environments/dev/runtime apply edge-mode.tfplan
export BOOK_DOMAIN="app.自分のドメイン"
dig +short A "$BOOK_DOMAIN"
gcloud compute ssl-certificates describe tfbook-dev-cert --global --project="$BOOK_PROJECT" --format='yaml(managed.status,managed.domainStatus)'
証明書がACTIVEになるまで待ちます。DNS伝播と発行には時間がかかります。Terraform applyの成功だけでHTTPS完了とは言えません。組織ポリシーがInvoker検査の無効化を禁止している場合は、この公開方式を管理者と調整します。禁止を別方式で回避する前提にはしません。
演習C:入口の検証
BASH コピー
curl -I "http://$BOOK_DOMAIN"
curl -i "https://$BOOK_DOMAIN/readyz"
BOOK_RUN_URL=$(terraform -chdir=environments/dev/runtime output -raw url)
curl -i "$BOOK_RUN_URL/healthz"
HTTPはHTTPSへのリダイレクト、HTTPSの/readyzは200、run.appへの外部直接アクセスは成功しない ことが期待値です。内部と分類される通信は依然として許可されます。要件に応じてdefault URL自体の無効化も検討できますが、その影響を確認してから導入します。
Cloud Armorの役割と限界
教材は1送信元IPあたり毎分120件のthrottle例です。厳密な課金上限でも、利用者単位の公平な制限でもありません。企業ネットワークや携帯回線では多数の利用者が同じIPを共有します。WAFルールを追加するときはpreviewで誤検知を測り、アプリの認可・入力検証も併用します。
何を知りたいかから監視を選ぶ
ログは何が起きたかを調べる記録、メトリクスは時間と量の傾向、トレースはリクエスト内のどこが遅いかを追う手段です。全ログを毎日人が読むのではなく、利用者への影響がある状態を通知し、通知から必要なログへ進めるようにします。
最初に見るのはリクエスト数、エラー率、遅延、飽和です。教材のoperationsはHTTPSの/readyzを複数地点から確認し、2地点以上で5分継続して失敗した場合の通知を構成します。1つのprobeだけでは全機能の正常性は保証できません。業務アプリでは主要な利用フローも合成監視します。
監視は、担当者の判断と復旧までつないで完成する↔ 狭い画面では図を左右にスクロールできます
演習:通知先まで接続する
第12章のHTTPSがACTIVEで、/readyzが200になってから開始します。operationsのtfvarsにdomain、email、billing_account、monthly_budget、currency_codeを設定します。金額の5000は教材用の予算入力例で、実際の月額見積もりではありません。通貨は課金アカウントに合わせます。
BASH コピー
gcloud services enable billingbudgets.googleapis.com cloudbilling.googleapis.com --project="$BOOK_PROJECT"
terraform -chdir=environments/dev/operations init -backend-config=backend.hcl
terraform -chdir=environments/dev/operations plan -out=operations.tfplan
terraform -chdir=environments/dev/operations apply operations.tfplan
完全なコードを見る · modules/operations/main.tf 演習ZIP内の同じファイルを掲載しています。
modules/operations/main.tf コピー
variable "project_id" { type = string }
variable "name" { type = string }
variable "domain" { type = string }
variable "email" { type = string }
output "email_channel" { value = google_monitoring_notification_channel.email.name }
resource "google_monitoring_notification_channel" "email" {
project = var.project_id
display_name = "運用通知"
type = "email"
labels = { email_address = var.email }
}
resource "google_monitoring_uptime_check_config" "web" {
project = var.project_id
display_name = "${var.name} / DBを含む到達確認"
timeout = "10s"
period = "60s"
http_check {
path = "/readyz"
port = 443
use_ssl = true
validate_ssl = true
}
monitored_resource {
type = "uptime_url"
labels = {
project_id = var.project_id
host = var.domain
}
}
}
resource "google_monitoring_alert_policy" "availability" {
project = var.project_id
display_name = "${var.name} 到達確認の失敗"
combiner = "OR"
notification_channels = [google_monitoring_notification_channel.email.name]
conditions {
display_name = "2地点以上で5分継続"
condition_threshold {
filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND resource.type=\"uptime_url\" AND metric.label.check_id=\"${google_monitoring_uptime_check_config.web.uptime_check_id}\""
duration = "300s"
comparison = "COMPARISON_GT"
threshold_value = 1
aggregations {
alignment_period = "60s"
per_series_aligner = "ALIGN_NEXT_OLDER"
cross_series_reducer = "REDUCE_COUNT_FALSE"
group_by_fields = ["resource.label.host"]
}
trigger { count = 1 }
}
}
documentation {
mime_type = "text/markdown"
content = "外形監視が失敗。DNS/TLS、LB、Cloud Runログ、DB接続を順に確認する。教科書第19章を参照。"
}
}
メール通知チャンネルに確認が必要と表示されたら完了させます。通知チャネルのテスト送信とCloud Monitoring上の到達確認の成功を確認してください。予算の既定IAM受信者と指定通知先が、自分の想定と合っていることも確認します。
予算は自動で支払いを止めない
Cloud Billingのbudgetは通知の仕組みです。利用量反映と通知には遅延があり、閾値ぴったりで支出を停止しません。課金APIを止める自動化を安易に入れると、復旧が難しい停止につながります。予算通知、サービス側の上限、定期的な棚卸し、演習終了時の削除を組み合わせます。
この計算は自分で入力した仮単価による概算 です。Google Cloudの請求額を再現するものではありません。DB・LB・IPv4・Armor・保存容量・ログ・イメージ・バックアップ・通信・DNS・税・割引を別々に確認し、料金計算ツールで正式に見積もります。
読み取り専用の診断コマンド
BASH コピー
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="tfbook-dev" AND severity>=ERROR' --project="$BOOK_PROJECT" --limit=20 --format=json
gcloud run services describe tfbook-dev --region="$BOOK_REGION" --project="$BOOK_PROJECT"
gcloud sql instances describe tfbook-dev-db --project="$BOOK_PROJECT"
ログを共有する前に個人情報やトークンの有無を確認します。日常ダッシュボードにSQLのCPU・接続数・ディスク使用量を追加し、「どの値で誰が何をするか」を決めます。通知先を設定しただけでは運用体制は完成しません。
通知を検証する小さな訓練 学習環境の到達確認pathだけを一時的に存在しないURLへ変更し、実際の通知を受けます。検出時刻・到着時刻を記録し、元に戻して回復を確認します。予算は実際に費用を増やして試さず、通知先と設定を確認します。
Cloud Runだけ増やすと、DBが先に詰まる
アプリの同時リクエスト数とDB接続数は同じではありません。短いCPU処理もあれば、DBからの応答を待つ処理もあります。HTTPのconcurrency、gunicornのworker/thread、接続プール、DBの最大接続数を別々に設計します。無制限の接続プールを使ってスケールさせると、小さなDBが先に上限へ達します。
概算は最大コンテナ数 × 同時稼働revision数 × worker数 × (pool_size + max_overflow)に、管理・migration・他サービスの接続を足します。本書のdevなら基本は3×1×1×2=6、更新中に2revisionあれば12接続が目安です。Cloud Runのmax_instancesは常に絶対的なハード上限ではなく、一時的に超える場合もあるため余裕を残します。
性能を測る順序
業務目標を決める。例:「通常時200リクエスト/秒、p95が500ms以内」。これは教材の仮要件で、達成を保証する設定値ではない。 dev/stagingで負荷試験し、CPU、遅延、DB接続待ち、SQL時間を測る。 最も詰まる箇所を1つずつ改善する。SQL/indexの改善、キャッシュ、非同期化、DB容量変更も候補。 ピーク・デプロイ時・障害からの再接続を含めて再測定する。
HA、バックアップ、DRは代わりにならない
3つの故障に、3つの備え↔ 狭い画面では図を左右にスクロールできます
Cloud SQLのREGIONAL構成は同一リージョン内のゾーン障害への可用性を高めます。誤って削除した行はレプリカにも反映されるので、データ復旧にはバックアップやPITRが必要です。リージョン障害への備えは別リージョンの復旧手段、ネットワーク、アプリ、DNS切り替えまで含むDR設計です。
RPO 許容できるデータ損失の時間。例:5分前まで戻れればよい。 RTO サービスを戻すまでの許容時間。例:60分以内に利用を再開する。 SLO 利用者へ提供する信頼性の目標。例:一定期間の正常リクエスト比率。
教材のdevとprod例の差
項目 dev prod例 プロジェクト・state 学習専用 別project / 別bucket / 別SA SQL ZONAL / db-f1-micro REGIONAL / db-custom-2-7680 Cloud Run min/max 0 / 3 2 / 10 削除保護 有効 有効+運用承認 バックアップ PITR有効・保持を確認 要件に応じた保持と復元試験
prodの容量は学習用の出発点です。「中規模ならこのマシンで足りる」とは判断できません。DBの接続上限は構成によって変わるので、稼働DBの設定を確認します。ゼロスケールのコスト削減と、コールドスタートやSLOを両立できるかも測って決めます。
設計課題 「ピーク時600接続、DB上限500、管理用50を確保」という計画を直してください。アプリ側の接続予算は450未満に抑える必要があります。worker・pool・最大台数・複数revisionを調整し、余裕を持たせた案を作成します。
CIに秘密鍵を置く前に、信頼関係を設計する
Workload Identity Federation(WIF)は、GitHubが発行したOIDCトークンの属性をGoogle Cloudが検証し、短期の認証へ交換する仕組みです。「この組織ID・repository ID・mainブランチ・dev環境の実行だけ」を信頼します。名前の再取得リスクを抑えるため、所有者とrepositoryは数値IDで制限します。
WIF:信頼する実行条件を検証して短期認証へ↔ 狭い画面では図を左右にスクロールできます
認証できることと、操作できることは別です。外部の主体には特定SAへのroles/iam.workloadIdentityUser、そのSAには既存dev/runtimeの更新権限を付けます。教材CIはWIF自体、DB、IAM、予算を変更できる構成にしません。
演習A:WIFとCI用SAを作る
GitHub上にこの演習一式を保存する自分のrepositoryを用意します。environments/dev/identityのproject、state bucket、GitHubの数値IDを編集します。IDはGitHub APIで確認できます。ここで使うOWNER/REPOは自分が管理するものです。
BASH コピー
gh api repos/OWNER/REPO --jq '{repository_id: .id, owner_id: .owner.id}'
terraform -chdir=environments/dev/identity init -backend-config=backend.hcl
terraform -chdir=environments/dev/identity plan -out=identity.tfplan
terraform -chdir=environments/dev/identity apply identity.tfplan
terraform -chdir=environments/dev/identity output
完全なコードを見る · environments/dev/identity/main.tf 演習ZIP内の同じファイルを掲載しています。
environments/dev/identity/main.tf コピー
variable "project_id" { type = string }
variable "state_bucket" { type = string }
variable "github_owner_id" { type = string }
variable "github_repository_id" { type = string }
provider "google" { project = var.project_id }
data "google_project" "current" { project_id = var.project_id }
resource "google_service_account" "ci" {
account_id = "tfbook-dev-ci"
display_name = "開発Cloud Run更新用CI"
}
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "tfbook-dev-github"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository_id" = "assertion.repository_id"
}
attribute_condition = "assertion.repository_owner_id == '${var.github_owner_id}' && assertion.repository_id == '${var.github_repository_id}' && assertion.ref == 'refs/heads/main' && assertion.sub.endsWith(':environment:dev')"
oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
}
resource "google_service_account_iam_member" "federation" {
service_account_id = google_service_account.ci.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository_id/${var.github_repository_id}"
}
resource "google_storage_bucket_iam_member" "state" {
bucket = var.state_bucket
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.ci.email}"
}
resource "google_project_iam_member" "run" {
project = var.project_id
role = "roles/run.developer"
member = "serviceAccount:${google_service_account.ci.email}"
}
resource "google_service_account_iam_member" "act_as" {
service_account_id = "projects/${var.project_id}/serviceAccounts/tfbook-dev-runtime@${var.project_id}.iam.gserviceaccount.com"
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${google_service_account.ci.email}"
}
resource "google_project_iam_member" "network" {
project = var.project_id
role = "roles/compute.networkUser"
member = "serviceAccount:${google_service_account.ci.email}"
}
resource "google_project_iam_member" "service_usage" {
project = var.project_id
role = "roles/serviceusage.serviceUsageConsumer"
member = "serviceAccount:${google_service_account.ci.email}"
}
resource "google_artifact_registry_repository_iam_member" "reader" {
project = var.project_id
location = "asia-northeast1"
repository = "tfbook-dev"
role = "roles/artifactregistry.reader"
member = "serviceAccount:${google_service_account.ci.email}"
}
output "provider_name" { value = google_iam_workload_identity_pool_provider.github.name }
output "service_account" { value = google_service_account.ci.email }
演習B:Workflowを有効にする
GitHub Environment devを作り、承認者とmainブランチ制限を設定する。契約プランで利用できる保護機能を確認する。 ci/README.mdに従い、WIF_PROVIDER、TF_SERVICE_ACCOUNT、PROJECT_ID、STATE_BUCKET、IMAGE_DIGEST、EDGE_ONLYをEnvironment variablesに設定する。imageのdigestと公開モードは現状に合わせる。ci/terraform-runtime.ymlを.github/workflows/へコピーし、内容をレビューしてmainに置く。手動実行し、認証、plan、apply、No changesを確認する。
完全なコードを見る · ci/terraform-runtime.yml 演習ZIP内の同じファイルを掲載しています。
ci/terraform-runtime.yml コピー
# 第15章の設定後、.github/workflows/terraform-runtime.yml へコピーする。
name: 開発環境のTerraform適用
on:
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: terraform-dev-runtime
cancel-in-progress: false
jobs:
apply:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: dev
defaults:
run:
working-directory: environments/dev/runtime
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.16.1
terraform_wrapper: false
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.TF_SERVICE_ACCOUNT }}
- name: 設定を生成
env:
PROJECT_ID: ${{ vars.PROJECT_ID }}
STATE_BUCKET: ${{ vars.STATE_BUCKET }}
IMAGE: ${{ vars.IMAGE_DIGEST }}
EDGE_ONLY: ${{ vars.EDGE_ONLY }}
run: |
python3 - <<'PY'
import json, os
assert os.environ['EDGE_ONLY'] in ('true', 'false')
values = dict(project_id=os.environ['PROJECT_ID'], state_bucket=os.environ['STATE_BUCKET'], environment='dev', region='asia-northeast1', image=os.environ['IMAGE'], min_instances=0, max_instances=3, edge_only=os.environ['EDGE_ONLY']=='true', protect_service=True)
with open('ci.auto.tfvars.json', 'w') as f:
json.dump(values, f)
PY
terraform init -input=false -backend-config="bucket=$STATE_BUCKET" -backend-config="prefix=dev/runtime"
- run: terraform fmt -check -recursive ../../../modules
- run: terraform validate
- run: terraform plan -input=false -lock-timeout=5m -out=review.tfplan
# 同じジョブ内の保存済みplanだけを適用。planを公開artifactにしない。
- run: terraform apply -input=false review.tfplan
このサンプルの承認は「開始前」
Environment承認はジョブの開始前に行われます。サンプルはその後、同じジョブ内で保存済みplanをapplyします。したがって生成されたplanそのものを後から人が承認する仕組みではありません 。まず開発環境で短期認証と同一plan適用を練習するための最小構成です。
本番ではplanジョブ → 限定公開のplan保管 → plan内容を人が確認 → 別の承認付きapplyジョブへ分けます。commit SHA、lockfile、入力、backendを記録し、承認対象と適用対象が同じことを保証します。stateが変わって計画がstaleになったら、再plan・再承認します。concurrencyとstateのロックを併用し、古いapplyを自動キャンセルしません。
信頼しないコードには認証を渡さない
外部PRや任意ブランチにWIFを開放しないこと。Terraformの設定やprovider、外部プログラムはCI権限で実行され得ます。PRの静的検査は無認証で実行し、実環境のplanは信頼済みの経路に限定します。state/planを公開artifactへアップロードしないこと。Actionは本番採用前に検証したcommit SHAへ固定し、更新を管理します。
いつもの変更手順
変更目的、利用者への影響、成功条件、切り戻しを短く書く。 小さな差分で編集し、fmt・validate・テストを実行する。 正しいproject/backend/commitからplanを作る。 削除、置換、公開範囲、IAM、容量、課金の変化を確認する。 確認したplanをapplyし、実エンドポイント・監視・No changesを確認する。
Terraformの成功はアプリ機能の成功と同義ではありません。DBへ到達できるか、エラー率が増えていないかまで確認します。変更前の画面ではなく、稼働中のrevisionとdigestを記録することが切り戻しを助けます。
変更の証拠をつなぐ↔ 狭い画面では図を左右にスクロールできます
演習:Cloud Runの最大数を3→4→3へ
dev/runtimeのtfvarsでmax_instancesだけを4に変えます。SQLやネットワークが削除されないことを読み取ります。適用後にNo changesを確認し、3へ戻す変更を新たにplan/applyします。小さな設定変更で一連の運用を練習します。
BASH コピー
terraform -chdir=environments/dev/runtime plan -out=scale.tfplan
terraform -chdir=environments/dev/runtime show scale.tfplan
terraform -chdir=environments/dev/runtime apply scale.tfplan
terraform -chdir=environments/dev/runtime plan
lifecycleを万能な保護にしない
prevent_destroy 削除を計画したとき止める。ただしresourceブロックごと構成から消すと保護も消える。API側の保護と運用ルールも使う。 create_before_destroy 先に新しいものを作るが、同じ名前が使えない資源や容量制約では成立しない。DBが自動的に無停止移行される機能ではない。 ignore_changes 明確に別システムが所有する属性に限る。安易にallを指定すると重要なdriftまで見失う。
アプリのデプロイ所有者を決める
本書ではimageとtrafficをTerraformが所有します。別CIからgcloud run deployで勝手に変えると、次のapplyが戻すことがあります。アプリCIがimage/trafficを所有する運用も可能ですが、その場合はTerraform側の対象を限定し、責任境界を文書化します。
段階的リリースへの発展例 教材はLATESTへ100%です。カナリアを行うなら、実在する新旧revision名を入力として渡し、以下のようなtrafficへ置き換えます。合計100%にし、エラー率・遅延を測ってから比率を増やします。revision名はgcloudで一覧を確認してから使用してください。
HCL コピー
# 既存の traffic ブロックを置き換える設計例。
# var.stable_revision / var.candidate_revision はstring入力として追加する。
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION"
revision = var.stable_revision
percent = 95
}
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION"
revision = var.candidate_revision
percent = 5
}
アプリの戻し方とデータの戻し方は違う
旧digestへ戻しても、新アプリが削除したDB列や更新済みデータは戻りません。DB変更は、先に互換的な追加、両版対応、データ移行、旧コード停止、最後に不要列削除というexpand/contractで段階化します。DBエンジンのメジャーアップグレードも、providerの更新とは分けて計画します。
importはコピーでもバックアップでもない
importは、すでにある実体をTerraformのアドレスに対応づける操作です。設定コードがその実体を適切に表しているかは別に確認します。1つの実体を複数stateから管理しないことが重要です。削除・公開変更が出たまま「importだから安全」とapplyしないでください。
演習A:手動バケットを取り込む
専用学習projectのコンソールで、空のバケットを新規作成。東京、Uniform access、公開防止を設定する。 examples/importのtfvars例をコピーし、project_idとbucket_nameを設定する。以下のimport.tfを同じディレクトリに作成する。bucket_nameはtfvars経由で参照する。 planで「import」と追加変更を分けて読み、想定外の削除・置換がなければapplyする。
HCL コピー
import {
to = google_storage_bucket.adopted
id = var.bucket_name
}
完全なコードを見る · examples/import/main.tf 演習ZIP内の同じファイルを掲載しています。
examples/import/main.tf コピー
terraform {
required_version = ">= 1.16.1, < 1.17.0"
required_providers {
google = {
source = "hashicorp/google"
version = "8.1.0"
}
}
}
variable "project_id" { type = string }
variable "bucket_name" { type = string }
provider "google" { project = var.project_id }
resource "google_storage_bucket" "adopted" {
name = var.bucket_name
location = "ASIA-NORTHEAST1"
uniform_bucket_level_access = true
public_access_prevention = "enforced"
force_destroy = false
}
BASH コピー
terraform -chdir=examples/import init
terraform -chdir=examples/import plan -out=import.tfplan
terraform -chdir=examples/import show import.tfplan
terraform -chdir=examples/import apply import.tfplan
terraform -chdir=examples/import plan
期待するのはimportと、必要に応じた非破壊の属性調整です。既定値の違いによる差分もあるため、実体を表す構成へ調整してから進めます。作成場所が違うなど置換が必要な差分なら、ここでは止めてコードを修正します。
演習B:コード内の名前を変える
resourceのラベルをadoptedからarchiveに変え、importブロックは取り込み完了後に削除します。同時に以下を追加します。
HCL コピー
moved {
from = google_storage_bucket.adopted
to = google_storage_bucket.archive
}
planに「移動」が出て実体の作成・削除がないことを確認します。movedを先に消すと、まだ更新していない別環境が移動を認識できなくなるため、共有モジュールでは履歴として残します。movedは同じstate内のリファクタリングです。stateをまたぐ移管は別の手順と排他管理が必要です。
driftは原因を聞いてから扱う
手動の変更が見つかったら、①事故なのか、②緊急対応なのか、③所有者が別なのかを確認します。コードに戻すなら通常plan/apply。手動変更を採用するならコードを更新してから通常planで整合を確認します。refresh-onlyは実体の現状をstateへ反映しますが、コードを変更しません。次の通常planで再び元へ戻そうとする可能性があります。
BASH コピー
terraform -chdir=examples/import plan -refresh-only
terraform -chdir=examples/import plan
定期drift検査ではplan -detailed-exitcodeを使えます。0は差分なし、1はエラー、2は差分ありです。2を障害と混同しないようCIの終了コード処理を明示します。
片付け 取り込んだバケットは空の演習用です。terraform -chdir=examples/import destroyで対象を確認し削除します。実務で管理から外すだけの場合は、削除せずstateから外すremoved等を検討します。state rmは実体削除ではありません。
検査を層に分ける
検査 見つかるもの 見つからないもの fmt 整形の不統一 構成が目的に合うか validate 型、参照、providerスキーマの不整合 IAM、実際の割当量、DNS伝播 terraform test + mock 重要な設定の契約違反 Google Cloudの実API挙動 planレビュー 実体との差、削除・置換・権限変更 適用後のアプリの全機能 実環境での試験 接続、公開範囲、監視、復元 まだ試していない故障条件
演習A:基本の検証
BASH コピー
bash helpers/validate.sh
terraform -chdir=labs/00-local test
ヘルパーはbackendを接続せずに初期化してvalidateします。provider取得にはインターネットが必要ですが、Google Cloudのリソースは作りません。既にリモートbackendで運用中の作業フォルダがある場合は、検査専用の別チェックアウトで実行すると初期化設定を混ぜずに済みます。
演習B:安全性を契約として書く
完全なコードを見る · modules/platform/tests/security.tftest.hcl 演習ZIP内の同じファイルを掲載しています。
modules/platform/tests/security.tftest.hcl コピー
mock_provider "google" {}
variables {
project_id = "unit-test-project"
region = "asia-northeast1"
name = "tfbook-dev"
environment = "dev"
network_id = "projects/unit-test-project/global/networks/test"
sql_tier = "db-f1-micro"
ha = false
protect_data = true
db_password = "nonsecret-placeholder-for-mock-test-only"
password_version = 1
}
run "data_protection" {
command = plan
assert {
condition = !google_sql_database_instance.main.settings[0].ip_configuration[0].ipv4_enabled
error_message = "DBの公開IPv4を有効にしないでください。"
}
assert {
condition = google_sql_database_instance.main.settings[0].backup_configuration[0].point_in_time_recovery_enabled
error_message = "PITRを有効にしてください。"
}
assert {
condition = google_sql_database_instance.main.deletion_protection && google_sql_database_instance.main.settings[0].deletion_protection_enabled
error_message = "TerraformとAPI両方の削除保護が必要です。"
}
assert {
condition = google_sql_user.app.database_roles == tolist(["pg_read_all_data"])
error_message = "教材のDB実行ユーザーに管理者ロールを付与しないでください。"
}
}
BASH コピー
terraform -chdir=modules/platform init -backend=false
terraform -chdir=modules/platform test
このテストはGoogle providerをmockに置き換え、planだけを使います。DBの公開IP無効、PITR、削除保護、読み取り用DBロールを確認します。練習用コピーでipv4_enabledをtrueへ変え、テストが落ちることを確かめて戻します。単に実装と同じ式をテストに書くのでなく、守るべき要求を表現します。
アプリのテスト
BASH コピー
python3 -m venv app/.venv
app/.venv/bin/pip install -r app/requirements.txt
cd app
.venv/bin/python -m unittest -v
cd ..
DBなしでも/healthzが応答すること、DB障害では/readyzが503となり秘密を返さないことをテストします。DB接続成功のmockだけでは本物の通信は証明できないので、第11章の実接続確認を別に残します。
provider更新は独立した変更にする
.terraform.lock.hclは選択版とチェックサムを記録するのでGitへ保存します。Terraform本体、provider、モジュール、コンテナの版は別物です。制約を見直す → changelogと移行ガイドを読む → init -upgrade → テスト → devのplan/apply → 本番の順で進めます。新機能を使う変更と大規模アップグレードを一度に混ぜません。
本番でPolicy as Codeを導入するなら、plan JSONに対して「DB公開禁止」「本番削除禁止」「許可リージョン」「IAMの過剰付与」を検査できます。JSONも機密として扱い、policyを通すための例外には理由と期限を付けます。
失敗したapplyは「何も起きていない」ではない
VPCは作れたがDBの割当量で失敗するなど、途中成功は通常あり得ます。まず並行実行を止め、エラーと時刻、成功したリソース、stateの状態を確認します。原因を直したらplanで残りの変更を読みます。無条件にdestroyして最初からやり直すと、成功済みのデータや共有資源を失います。
失敗時は現状を把握してから、最小の復旧を選ぶ↔ 狭い画面では図を左右にスクロールできます
最初の10分のrunbook
利用者への影響と開始時刻を確認し、追加変更を止める。 直前のcommit・image digest・Cloud Run revision・DB変更を確認する。 DNS/TLS → LB → Cloud Run → Secret/IAM → VPC → DBの順に、成功と失敗の境界を絞る。 データ影響を考慮して復旧策を選ぶ。旧imageへ戻す、誤設定を直す、DBを別インスタンスへ復元する、を区別する。 実際の成功指標を確認し、恒久修正をコードと手順へ戻す。
演習A:stateを失うと何が起こるか
クラウドstateを使った破壊実験はしません。labs/00-localのコピーでファイルを作り、Terraformを停止した上でstateを別の安全な場所へ退避します。planが新規作成を提案することを確認してキャンセルし、元のstateを戻します。実体が残っていても、対応づけを失うと認識できないことを確かめます。
GCSのstateを復元する本当の事故では、書き込みを止め、現在stateを保管し、復元候補のgeneration、lineage、serial、更新時刻を確認します。古いstateを戻すだけで現在の実体と合うとは限りません。復元後にplanを読み、必要なimportや整合修正を判断します。stateをGitで上書きしないでください。
演習B:DBのPITRを別インスタンスで試す
これは追加DB料金が発生する発展演習です。PITR有効化後にログが蓄積していることと、復元可能な期間を確認します。業務データではなく、学習用DBに許可された管理ユーザーで検証用テーブルと数件のデータを入れてから実施します。
UTC時刻と検証用データを記録する。 学習用データを1件変更し、変更前の時刻を復元対象に選ぶ。 Cloud SQLコンソールのPITRから別のインスタンス名 で復元する。 VPC内の許可された接続環境から、対象時点の行・件数・権限を確認する。 DB接続名とSecret版を含めたアプリ切り替え手順を検証し、所要時間を測る。 復元した一時インスタンスを識別して削除し、料金残存を確認する。
PITRは新しいインスタンスを作りますが、それはTerraformのstateへ自動追加されません。採用する場合は、元DBのresourceを上書きする前に 別resourceとしてimportし、設定差分を確認します。旧DBを削除する計画と新DBへ向ける変更を分けます。教材アプリはデータを作らないので、SELECT 1が通るだけでデータ復元の検証済みとは言えません。
復旧結果を記録する
記録 書く内容 影響 どの機能・利用者・時間帯か 検出 監視がいつ気づき、人がいつ確認したか 復旧 使ったバックアップ・時刻・新インスタンス・確認したデータ 実測 RTO/RPOの目標と結果 改善 コード、検査、通知、手順の変更と担当
削除前のチェック
作業project、backend bucket、prefix、対象resourceを確認します。学習用に見えても、共有のstateやDNSを削除しないこと。必要なデータと証跡を保管し、依存する利用者がいないことを確認します。復元テストで作った一時DBなど、Terraform管理外の資源も棚卸しします。
順序は「利用する側から」
削除は利用側から。stateは最後まで維持する↔ 狭い画面では図を左右にスクロールできます
CIのWorkflowを無効化し、並行実行がないことを確認。 operationsを削除して、不要なアラート・予算を止める。 edgeを削除。作成したDNSレコード・子ゾーン・親側のNS委任も片付ける。 runtimeの削除保護をfalseに変更してapplyし、その後destroy。 foundationの両方のDB削除保護をfalseにする変更をapplyし、その後destroy。 identityを最後に削除する。実行は管理者の個人ADC等で行い、削除されるCI権限に依存しない。
BASH コピー
# 実際に構築したルートだけで実行。各destroyの一覧を確認する。
terraform -chdir=environments/dev/operations destroy
terraform -chdir=environments/dev/edge destroy
# runtime/terraform.tfvars: protect_service = false に編集後
terraform -chdir=environments/dev/runtime plan -out=unprotect.tfplan
terraform -chdir=environments/dev/runtime apply unprotect.tfplan
terraform -chdir=environments/dev/runtime destroy
# foundation/terraform.tfvars: protect_data = false に編集後
python3 helpers/with-db-password.py environments/dev/foundation plan -out=unprotect.tfplan
python3 helpers/with-db-password.py environments/dev/foundation apply unprotect.tfplan
python3 helpers/with-db-password.py environments/dev/foundation destroy
terraform -chdir=environments/dev/identity destroy
Cloud RunやCloud SQLの削除保護は、falseにした設定を実際にapplyしてstate/APIに反映してから削除します。設定だけ変えて直ちにdestroyすると、保存されている保護状態が解除されておらず止まる場合があります。入力ヘルパーを利用するfoundationは削除時も同じ秘密の入力が必要です。
非同期の削除は待つ
Cloud SQLを削除した直後にPSA接続の削除が「まだ利用中」で失敗する場合があります。コンソールとサービスの削除状態を確認し、時間を置いてplan -destroyを読み直します。state rmやネットワークの強制解除でエラーを隠さないでください。Cloud RunのDirect VPC egressもIP解放に時間がかかり得ます。
stateバケットは最後まで残す
bootstrapはprevent_destroy=trueなので通常の削除から守られます。stateを残したまま保管する運用も有効です。学習projectを完全に閉じる場合は、すべての管理対象が削除済みであることを確認し、bootstrapのstateをローカルへ移行してから保護解除・バケット内容の削除を個別に計画します。現用backendのバケットを先に消さないでください。
削除後の残存費用を確認する
Artifact Registryのイメージ、保持中のSecret旧版、SQLの保持バックアップ、Cloud Storageのバージョンやソフトデリート、ログ保持、独自ドメイン、外部IP、復元した一時DBを確認します。Terraformに管理されていない資源はdestroyでは消えません。課金レポートには反映遅延があるため、後日も対象projectの費用を確認します。
管理を続ける場合のリズム
頻度 確認すること 変更ごと plan、エンドポイント、監視、No changes 毎週 費用、drift、アクセス権の増分、失敗したジョブ 毎月 provider/イメージ更新、保存量、DB容量、バックアップ状況 定期訓練 復元、切り戻し、担当不在時のアクセス、通知の到達
よく使うコマンド 作業先は terraform -chdir=対象ディレクトリ ... で固定します。
コマンド 役目 注意点 terraform initproviderとbackendを準備する 最初や構成変更時。移行では-migrate-stateを使う。 terraform fmt -check -recursive整形ルールの差分を検出 ファイルを書き換えず確認。 terraform validate設定の型・参照を検査 実環境の権限や動作を保証しない。 terraform plan -out=review.tfplan適用する計画を保存 planファイルは機密として保管。 terraform show review.tfplan保存した計画を読む 削除・置換・IAM・公開設定を確認。 terraform apply review.tfplan保存した計画を適用 通常のyes確認は出ない。事前に確認する。 terraform plan -detailed-exitcode差分の有無を終了コード化 0=差分なし / 1=エラー / 2=差分あり。 terraform plan -refresh-only手動変更のstate反映を計画 コードは更新されない。 terraform state list管理中のアドレスを表示 作業環境と管理範囲を確認。 terraform outputrootの出力を確認 sensitiveをraw/jsonで出す操作に注意。 terraform consoleHCLの式を対話で試す 文字列、型、関数の練習に。 terraform testテスト定義を実行 mockなしのapplyは実体を作り得る。 terraform init -upgrade許可範囲内のprovider等を更新 lockfile差分と移行ガイドをレビュー。 terraform plan -destroy削除計画を読む 作業先・保護・データ・依存関係を確認。 terraform force-unlock LOCK_ID残留ロックを解除 実行者が動いていないことを先に確認。
用語集 IaC Infrastructure as Code。インフラの構成をコードで管理する方法。 HCL Terraformの構成を書くための宣言的な言語。 provider 外部サービスのAPIとTerraformをつなぐプラグイン。 resource Terraformが作成・変更・削除を管理する実体。 data source 既存の資源や設定を読み取るための参照。 resource address google_storage_bucket.practiceのような、構成とstate内の識別名。 root module Terraformを実行するディレクトリ。state管理の入口。 子モジュール root等からmoduleで呼び出す再利用部品。 state コード内のアドレスと実体IDを対応づける管理記録。 backend stateを保存する場所とロックなどの機能。 state lock 同じstateへの同時書き込みを防ぐ排他制御。 drift コードが想定する構成と実リソースの設定のずれ。 plan 提案される作成・更新・削除・置換の計画。 apply 計画に従って実リソースを変更する操作。 置換 既存リソースを削除し、別の実体として作り直す変更。 import 既存実体をTerraformのアドレスへ対応づける操作。 moved state内のアドレス移動をコードで伝える宣言。 ADC 実行環境に応じて認証情報を探すGoogle Cloudの仕組み。 IAM 誰が、何に対して、何をできるかを管理する仕組み。 サービスアカウント アプリや自動処理のためのGoogle Cloudの主体。 impersonation 許可された利用者が特定SAとして短期の認証を行うこと。 WIF 外部IdPのIDを検証し、短期のGoogle Cloud認証へ連携する仕組み。 OIDC IDトークンで主体や実行条件を伝える認証の規格。 VPC Google Cloud内の仮想ネットワーク。 subnet リージョン内でIPアドレスを割り当てる範囲。 CIDR 10.20.0.0/24のようにネットワーク範囲を表す記法。 ingress / egress サービスに入る通信 / サービスから出る通信。 PSA Google管理サービスとのprivate接続に使うPrivate Services Access。 Cloud NAT VPC内の対象リソースが外部へ出る際のアドレス変換。 revision Cloud Runの構成とコンテナを固定したデプロイ単位。 digest コンテナイメージの内容を識別するハッシュ。 Serverless NEG LBからCloud Run等のサーバーレスbackendを参照する単位。 DNS / TLS 名前をIP等へ解決する仕組み / 通信を暗号化し相手を検証する仕組み。 接続プール DB接続を再利用し、同時接続数を制限する仕組み。 sensitive 値の表示を秘匿する指定。stateから自動除外する指定ではない。 ephemeral 実行中だけ使う値。使用できる位置には制約がある。 write-only providerへ渡すがplan/stateへ値を保存しない対応属性。 HA High Availability。障害時も利用可能な状態を保つ設計。 PITR Point-in-Time Recovery。復元可能な範囲の特定時点へデータを戻す方法。 RPO / RTO 許容するデータ損失の時間 / 復旧までの目標時間。 SLI / SLO 信頼性を測る指標 / その目標値。 冪等性 同じ処理を繰り返しても意図しない重複結果を増やさない性質。 仕様確認日は2026年9月6日。解説と演習構成は独自に作成し、HashiCorp、Google Cloud、各公式repositoryを一次資料として参照しています。公式ページが更新されたら、教材の固定版と照合してください。
検証の境界 Terraform本体とproviderを固定し、全実行ルートのfmt/validateとmockテストを確認。 クラウド不要のローカル作成・再計画・削除と、サンプルアプリのテストを実施。 ブラウザで章移動、検索、進捗、テスト、計算、モバイル表示を確認。 Google Cloudの実プロジェクトへのapply、実課金、実際のDNS/TLS/DB復元は未実施。各章の完了条件を自分の学習環境で確認。 検証記録の詳細は完全版ZIPのQUALITY.mdを参照してください。
デザイン 指定のstellararc-inc/design-system の配布CSS、ロールトークン、文字組み、インラインSVG規定を適用。オフラインでも読めるようCSSを内包し、フォントは指定のフォールバックを使用します。
公式資料一覧