はじめに
クラウドのリソースを、GCP のコンソール(Cloud Console)で「ポチポチ」作った経験はあると思う。VPC を作って、サブネットを切って、ファイアウォールルールを設定して…。一度作る分には問題ない。
問題は 2回目 だ。
- staging 環境を作ろうとして「あれ、dev と同じ設定どうやるんだっけ?」
- 半年後に構成を見て「これ誰がいつ何のために作った…?」
- 同僚が手で設定を変えて、いつの間にか環境が食い違う
この「手作業の限界」を解決するのが Infrastructure as Code (IaC) であり、その有力ツールのひとつが Pulumi だ。
この記事は「使い方」だけでなく、なぜ IaC が必要なのか・Pulumi はどう動くのか・なぜその仕組みなのかという背景まで踏み込んでまとめた。仕組みが腹落ちすると、後半の設計の話が「なるほど、だからこうするのか」と繋がる。
1. なぜ IaC が必要なのか(背景と「宣言的」という発想)
2. そもそも Pulumi とは(なぜ "普段の言語" で書けるのか)
3. Pulumi の基本概念(用語の整理)
4. とりあえず触ってみる(最小ハンズオン)
5. その `pulumi up` の裏で何が起きているか(仕組みと、その理由)
6. 入門の"次"でハマる設計3つ(State / 環境分離 / シークレット)
想定読者: クラウドはコンソールで触ったことがあるが、IaC / Pulumi はこれからの人。
1. なぜ IaC が必要なのか
1-1. 背景: 手作業はなぜ破綻するのか
「コンソールでポチポチ」は、操作の手順を人間が毎回再現する方式だ。これは規模が小さいうちは回るが、次の3つで必ず破綻する。
| 破綻ポイント | 何が起きるか | 根本原因 |
|---|---|---|
| 再現性 | dev と同じ staging を作れない | 手順が人の頭の中にしかない |
| 履歴 | 誰がいつ何を変えたか不明 | 変更がどこにも記録されない |
| 一貫性 | 環境ごとに設定が少しずつズレる | 手作業は毎回ブレる |
クラウドが「数十〜数百のリソースを、複数環境で、チームで」扱う時代になり、手順を人間が再現する方式そのものが限界を迎えた。これが IaC が生まれた背景だ。
1-2.「宣言的」という発想 — なぜ"手順"でなく"状態"を書くのか
ここが IaC 最大の発想の転換。IaC ツールの多く(Pulumi 含む)は 宣言的 (declarative) モデルを採る。
命令的 (imperative): 「バケットを作れ → なければ作る → あれば何もしない…」
→ "どうやるか(手順)" を人間が全部書く
宣言的 (declarative): 「バケットが1つ存在する状態であってほしい」
→ "あるべき状態" だけ書く。手順はツールが考える
宣言的にすると何が嬉しいのか。冪等性 (idempotency) が手に入る。
同じコードを何回流しても、結果は同じ「あるべき状態」に収束する。
1回目は作成、2回目以降は「もう存在するので何もしない」。差分があればそこだけ直す。
「今どうなっているか」を人間が気にせず、「どうあってほしいか」だけ書けばいい。この差分計算をやってくれるのが IaC エンジンの本体であり、後述の Pulumi の仕組みの核心でもある。
| 手作業 / 命令的 | 宣言的 IaC | |
|---|---|---|
| 書くもの | 手順 | あるべき状態 |
| 2回目の実行 | 手順を再現(ブレる) | 差分だけ適用(冪等) |
| 現状把握 | 人間が確認 | ツールが state と比較 |
2. そもそも Pulumi とは
Pulumi を一言でいうと:
普段使っているプログラミング言語で、インフラを宣言的に書けるIaCツール
主な性質はこの4つ。
| 特徴 | 説明 |
|---|---|
| 汎用言語対応 | TypeScript / Python / Go / C# / Java で書ける |
| マルチクラウド | AWS / GCP / Azure / Kubernetes などに対応 |
| 状態管理 | Pulumi Cloud または GCS 等で state を管理 |
| 既存リソース取込 |
pulumi import で手作業で作った既存インフラをコード化できる |
2-1. なぜ「普段の言語で書ける」ことを選んだのか — 設計思想
IaC の定番 Terraform は HCL という専用言語 (DSL) でインフラを記述する。Pulumi は 既存の汎用言語をそのまま使う。これは単なる好みの違いではなく、設計思想の違いだ。
専用 DSL は「インフラ記述に特化してシンプル」という利点がある一方、規模が大きくなると次の壁にぶつかりやすい。
- ループ・条件分岐が独自構文で、複雑になると読みにくい
- 「関数」「クラス」のような抽象化の手段が限られる
- 既存言語のテストフレームワーク・IDE 補完が使いにくい
Pulumi の賭けは明快だ。「インフラ記述も結局はプログラミングだ。ならば本物の言語を使えば、抽象化・型・テスト・IDE補完の資産がそのまま活きる」。
| 観点 | Terraform (HCL) | Pulumi (汎用言語) |
|---|---|---|
| 言語 | HCL(独自DSL) | TS / Python / Go / C# / Java |
| 学習コスト | HCL を新規習得 | 既存言語の知識を流用 |
| ループ・条件 |
count / for_each(独自作法) |
言語標準の for / if
|
| 抽象化 | モジュール | 関数・クラス・パッケージ |
| 型・補完 | 限定的 | IDE でフル補完・型チェック |
| 事例の多さ | 圧倒的(枯れている) | 後発だが急成長中 |
ただしこの自由には裏返しがある。汎用言語はチューリング完全なので、書こうと思えば何でも書けてしまう(無駄に複雑なロジックも)。「インフラ定義はできるだけ素直に書く」という規律は、書き手側に求められる。これは後半の「早すぎる抽象化を避ける」に繋がる。
ざっくりした選び方:
- すでに TS / Python に慣れている → Pulumi(新しい言語を覚えなくていい)
- 事例の豊富さ・枯れた安定を重視 → Terraform
- どちらでも、宣言的モデルや state の考え方は共通
この記事では Pulumi(TypeScript)で進める。
3. Pulumi の基本概念
仕組みの話に入る前に、4つの言葉だけ押さえる。
| 概念 | 説明 |
|---|---|
| Project | インフラコードの単位(基本 1リポジトリ = 1プロジェクト) |
| Stack | 同じコードの「環境別インスタンス」(dev / staging / prod) |
| Resource | 作成するインフラの1単位(バケット、サーバー、DB等) |
| Output | リソース作成後に確定する値(IPアドレス、URL、バケット名等) |
とくに Stack(環境別インスタンス)と Output(作成後に決まる値)は、次の「仕組み」で深掘りする。
4. とりあえず触ってみる
仕組みは、一度動かしてからの方が腹落ちする。最小の流れはこれだけ。
# 1. インストール(macOS)
brew install pulumi
# 2. プロジェクト作成(公式 Get Started に沿って GCP + TypeScript)
pulumi new gcp-typescript
# 3. プレビュー(何が起きるか "適用前" に確認)
pulumi preview
# 4. デプロイ(確認してから実行)
pulumi up
# 5. お片付け(検証で作ったものは消す)
pulumi destroy
index.ts は、GCS バケットを1つ作るだけならこれだけ(pulumi new 実行時に gcp:project(デプロイ先のプロジェクトID)を聞かれる)。
import * as gcp from "@pulumi/gcp";
// Resource: GCSバケットを1つ作る(location は必須)
const bucket = new gcp.storage.Bucket("my-bucket", {
location: "US",
});
// Output: 作成後に確定する値(バケットURL)を外に出す
export const bucketName = bucket.url;
ここに Resource(バケット本体) と Output(作成後に確定する値) が両方詰まっている。次章で、pulumi up した瞬間にこの裏で何が起きているかを分解する。
最小実行アクション: まず GCS バケット1個から。
preview → up → destroyを一周すると、次章の仕組みが「あの挙動か」と繋がる。
5. その pulumi up の裏で何が起きているか
ここが本記事の核心。さっき叩いた pulumi up、その裏では 3つの登場人物が動いている。
5-1. 登場人物は3つ
| 登場人物 | 役割 |
|---|---|
| 言語ホスト (Language Host) | あなたのプログラムを各言語のランタイムで実行し、「リソースが登録された」のを検知してエンジンに伝える |
| デプロイエンジン (Deployment Engine) | CLIに内蔵。「現状」を「あるべき状態」に近づけるのに必要な操作を計算し、順序立てて実行する |
| リソースプロバイダ (Resource Provider) |
~/.pulumi/plugins に入るプラグイン。実際のクラウドAPI操作はエンジンでなくここが担当
|
ポイントは、エンジンは直接クラウドAPIを叩かないこと。クラウド固有の知識はプロバイダ(プラグイン)に隔離されている。だから「どの言語でも・どのクラウドでも」という拡張性が成立する。これがマルチクラウドの仕組み的な裏付けだ。
5-2. pulumi up の中で起きること(ステップ分解)
公式の説明をかみ砕くと、こうなる。
1. CLIが言語ホストを起動し、あなたのプログラムを実行する
2. new gcp.storage.Bucket() に到達 → 言語ホストが「このバケットを
"あるべき状態" に含めたい」とエンジンに登録リクエストを送る
※ この時点ではまだ実物は作られていない(登録しただけ)
3. エンジンが state を見て「これは新規か、既存か」を判定
4. 既存なら「その場で更新できるか / 作り直しが必要か」を判断(差分計算)
5. プロバイダに create / update / delete を指示 → 実物が動く
6. プログラム実行後、state にあるのに今回登録されなかったリソースを
"不要" とみなして削除対象にする
7. 完了したら state を最新化する
ここから読み取れる「なぜ」が2つある。
-
なぜ
previewが効くのか: 1〜4 までは「計算」で、5 の実行前に差分が分かる。だからpreview(=適用せず差分だけ見る)が成立する。git diffに相当する。 - なぜコードから消すと"消える"のか: ステップ6が理由。コードに書かない=「あるべき状態に含めない」=削除対象。コードが唯一の正という思想がここに表れている。「コメントアウトして様子見」は本番では危険、という勘所もここから来る。
5-3. なぜ State(状態)が要るのか — IaCの心臓部
エンジンが差分を計算するには、「前回どうしたか」の記録が要る。それが state(状態ファイル / checkpoint) だ。state が保持する最重要情報は:
論理名(コード中の名前)↔ 物理ID(クラウド上の実体)のマッピング
コード: new gcp.storage.Bucket("my-bucket", …) ← 論理名 "my-bucket"
↕ (この対応を state が覚えている)
クラウド: my-bucket-a1b2c3d ← 物理的なバケット名(実体, GCSは全世界で一意)
なぜこのマッピングが必要か。クラウド上の実体には重複回避のためランダムな接尾辞が付くことがある(auto-naming)。コードの論理名と実体を結びつける台帳がないと、「次に流したとき、これは前回のあのバケットだ」と分からなくなる。
だから state が壊れたり失われたりすると、Pulumi は「実体との対応」を見失う。state は単なるキャッシュではなく、コードとクラウドを繋ぐ台帳——これが「心臓部」と呼ぶ理由だ。置き場所の設計(6章)が重要になる根拠でもある。
5-4. Output はなぜ "ただの値" じゃないのか
入門者が最初に「?」となるのが Output。なぜ bucket.url をそのまま文字列結合できないのか。
理由は クラウドのプロビジョニングが非同期だから。バケットのURLやIDは「作成し終わってから」しか確定しない。プログラムが上から下に流れる時点では、まだ値が存在しない。
そこで Pulumi は、こうした未確定の値を Output<T> で包む。これは Promise / Future に近い——「今はまだ無いが、リソースができたら確定する値」。
import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";
const bucket = new gcp.storage.Bucket("my-bucket", { location: "US" });
// ❌ Output はまだ値を持っていないので、そのまま文字列化できない
// console.log(bucket.url); // → [object Output] のような表示になる
// ✅ apply: 値が確定したら処理する
export const upperUrl = bucket.url.apply(u => u.toUpperCase());
// ✅ interpolate: 文字列の組み立てはこれが楽
export const message = pulumi.interpolate`bucket url is ${bucket.url}`;
そして Output には、もう一つ重要な役割がある。依存関係の自動追跡だ。
あるリソースの Output を、別のリソースの Input に渡すと、Pulumi は「AができてからBを作る」という依存を自動で記録する。
const network = new gcp.compute.Network("net", { autoCreateSubnetworks: false });
// subnet は network.id(Output)に依存 → Pulumi が「Network → Subnet」の順序を自動で決める
const subnet = new gcp.compute.Subnetwork("subnet", {
network: network.id,
ipCidrRange: "10.0.1.0/24",
region: "us-central1",
});
これにより、ユーザーは作成順序を手で書かなくていい。Pulumi は Output の繋がりから依存グラフ (DAG) を組み立て、依存のない部分は並列で、依存がある部分は正しい順序で実行する。
Output が「ただの値」でなく特別な型なのは、①値が非同期に確定することと、②依存グラフを自動構築すること——この2つを両立させるための設計だ。
6. 入門の"次"でハマる設計3つ
仕組みが分かると、ここからの設計判断が「なぜそうするのか」まで腑に落ちる。チュートリアルが「うごいた!」で終わると必ずこの3つにぶつかる。
① State をどこに置くか — 5-3 の続き
5-3 で見たとおり、state はコードとクラウドを繋ぐ台帳。だからこそ置き場所が設計対象になる。
❌ state をローカルに置く
- 自分のPCにしか台帳がない → チームで共有できない
- 同時に2人が up → 台帳が競合して壊れる
- PCが壊れたら台帳ロスト → 実体との対応を見失う
# ✅ マネージド(ロック・履歴・暗号化込み・無料枠あり)
pulumi login
# ✅ 自前のGCS(Google Cloud Storage)をバックエンドにする
pulumi login gs://my-pulumi-state-bucket
なぜロックが要るのか: エンジンは「state を読む → 差分計算 → 適用 → state を書く」という一連の流れで動く。2人が同時にやると、台帳の書き込みが競合して壊れる。だから同時実行ロックが要る(マネージドなら自動)。
最小実行アクション: 学習段階でも
--localを卒業し、最初からリモート state + ロック。「あとで移行」は台帳の移行という地味に怖い作業を生む。
② 環境分離 dev / staging / prod — Stack を使う
「dev で検証 → prod へ」をやるとき、コードをコピペしてはいけない(片方だけ直って乖離する)。Pulumi では同じコードを Stack という環境別インスタンスとして持ち、差は設定値 (config) で吸収する。
const config = new pulumi.Config();
const env = config.require("environment");
// 環境差は「コードの分岐」でなく「設定値」で表現する
const machineType = config.get("machineType") || "e2-micro";
const enableHa = env === "prod"; // 本番だけ冗長化(HA構成)
pulumi stack select prod
pulumi config set machineType e2-standard-4 # prod だけ強くする
なぜ config で吸収するのか: コードを分岐 (if env === ...) で埋めるほど、環境ごとの挙動差がコードに散らばってレビューしにくくなる。「ロジックは共通・差分はデータ」に寄せると、差分が一箇所(設定ファイル)に集約されて見通しが良い。
最小実行アクション: 環境差は
configで表現。if (env === "prod")が増えてきたら設定の外出しを検討するサイン。
③ シークレット管理 — なぜ state まで気にするのか
学習中に一番やりがちで、一番怖いのがこれ。
❌ const dbPassword = "P@ssw0rd123"; // Gitに上げたら即アウト
ここで 5-3 の知識が効く。state はリソースの値(Output 含む)を保持する台帳だった。つまり、パスワードをコードに直書きしなくても、state に平文で残れば同じこと。state ファイルが漏れれば筒抜けになる。
Pulumi のシークレット機能を使うと、state 内でも暗号化される。
# 設定値を暗号化して保存(state内も暗号化される)
pulumi config set --secret dbPassword "super-secret-123"
const dbPassword = config.requireSecret("dbPassword"); // コードに値を書かない
最小実行アクション: シークレットは必ずツールの secret 機能を通す。
.gitignoreにローカル state / 設定ファイルを入れておく。
おまけ: drift(乖離)も仕組みで説明できる
コンソールで手動変更すると、コード(あるべき状態)と現実がズレる= drift。5-2 のステップ3(state との比較)を思い出すと、次の up でエンジンは「あるべき状態(コード)」に寄せようとするため、手動変更が巻き戻される。だから「変更は必ずコード + PR 経由、コンソール手動変更は禁止」が鉄則になる。
7. もう一歩: 運用のベストプラクティス(公式推奨)
4〜6章で「動かす」「設計する」までは押さえた。最後に、チーム運用に乗せるときに効く2つを、公式推奨ベースで足しておく。
7-1. CI/CD に載せる — 「誰が手元で up したか」をなくす
なぜ必要か
手元の端末から pulumi up を打つ運用は、6章の drift と同じ問題を生む。「誰が・いつ・どの state に対して適用したか」が属人化し、レビューも残らない。変更は必ず PR 経由 → CI が適用にすると、インフラ変更がコードレビューと監査ログの土俵に乗る。
仕組み(2ワークフロー構成)
| トリガー | コマンド | 役割 |
|---|---|---|
| Pull Request | pulumi preview |
差分を PR にコメント してレビューさせる |
| main へ merge | pulumi up |
承認済みの変更を 自動適用 |
公式の GitHub Actions 連携(pulumi/actions@v7)を使うと、これがそのまま書ける。
# .github/workflows/preview.yml(PR時: 差分を PR にコメント)
name: preview
on:
pull_request:
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- uses: pulumi/actions@v7
with:
command: preview
stack-name: dev
comment-on-pr: true
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
なぜ
comment-on-prが効くのか: preview は「これから何が created / updated / deleted されるか」を出力する。それを PR コメントに貼ることで、マージ前に影響範囲をレビューできる。インフラ版の "git diff" に相当する。(PR コメントは Pulumi GitHub App を入れるとgithub-tokenなしでも可。公式はこちらを推奨)
main への merge では command: up のワークフローをもう1つ用意し、承認済みの変更だけを自動適用する。
認証は OIDC を推奨(長期キーを置かない)
| 方式 | 何を置くか | リスク |
|---|---|---|
| ❌ 長期サービスアカウントキー | JSON 鍵を Secrets に保存 | 漏洩時の被害が大きい・ローテーション負債 |
| ✅ OIDC(GCP は Workload Identity Federation) | 鍵を置かない(実行時に短命トークン発行) | 漏洩面が小さい |
GCP では Workload Identity Federation を使い、GitHub Actions が発行する OIDC トークンを GCP の短命 credential に交換する。静的な鍵ファイルを CI に置かないのが今のベストプラクティス。
最小実行アクション: まず PR→preview→コメント だけ導入する。
upの自動化と OIDC は、チームでの運用が固まってから足せばよい。
7-2. Secrets の暗号化プロバイダを選ぶ — 「誰が鍵を持つか」
6章で「シークレットは state 内で暗号化される」と書いた。その暗号鍵を誰が管理するかを選べる、というのがこの話。
なぜ選択肢が要るのか
既定(Pulumi Cloud)では、スタックごとの鍵を Pulumi が自動管理してくれる。手間はゼロだが、「鍵管理を自社の KMS に寄せたい」「Pulumi Cloud を使わず GCS バックエンドで完結させたい」という要件が出てくる。そのとき暗号プロバイダを差し替える。
| プロバイダ | 鍵の管理者 | 使いどころ |
|---|---|---|
| Pulumi Cloud(既定) | Pulumi(スタック単位で自動) | 何も考えず安全に始めたい |
passphrase |
自分(パスフレーズ) | Pulumi Cloud を使わない・最小構成 |
gcpkms |
GCP Cloud KMS | 鍵管理を GCP に寄せたい |
awskms / azurekeyvault / hashivault
|
各クラウド / Vault | 既存の鍵基盤に合わせる |
GCP KMS を使う場合
スタック作成時に暗号プロバイダを指定する:
pulumi stack init prod \
--secrets-provider="gcpkms://projects/MYPROJECT/locations/MYLOCATION/keyRings/MYKEYRING/cryptoKeys/MYKEY"
既存スタックの鍵を後から移行することもできる:
pulumi stack change-secrets-provider \
"gcpkms://projects/MYPROJECT/locations/MYLOCATION/keyRings/MYKEYRING/cryptoKeys/MYKEY"
最小実行アクション: 最初は既定(Pulumi 管理鍵)でよい。「鍵の所在を自社統制下に置け」という要件が出たら
gcpkmsに切り替える、の順で十分。
まとめ
| 問い | 答え |
|---|---|
| なぜ IaC? | 手作業は再現性・履歴・一貫性で破綻する。宣言的に"状態"を書けば冪等になる |
| なぜ Pulumi は普段の言語? | 抽象化・型・テスト・IDE補完の資産を活かすため(自由ゆえの規律は必要) |
| どう動く? | 言語ホスト→エンジン→プロバイダ。エンジンが現状↔あるべき状態の差分を適用 |
| なぜ state が要る? | 論理名↔物理IDの台帳。差分計算と drift 検出の土台だから「心臓部」 |
| なぜ Output は特別? | 値が非同期に確定する+依存グラフ(DAG)を自動構築するため |
| 設計の勘所 | ① state はリモート+ロック ② 環境差は config ③ シークレットは暗号化 |
| 運用に乗せる | CI/CD で PR→preview / main→up。secrets の暗号鍵は GCP KMS も選べる |
「使い方」だけなら 4章で足りる。だが なぜその仕組みなのかが分かると、6章の設計判断が"作法"でなく"必然"として腑に落ちる。まずは GCS バケット1個を up して、本記事の 5章を片手に「今この裏で何が起きたか」を辿ってみてほしい。