YAMLファイルが問題を起こし続ける理由
クラウドインフラの管理に携わったことがある人なら、こんな経験があるはずだ。自分のマシンでは完璧に動いていたTerraformファイルが、誰かが変数の更新を忘れたせいでステージング環境で壊れる。あるいは800行のYAMLで書かれたCloudFormationテンプレートに、ループも抽象化もまともなテストもできない、という状況だ。
根本的な原因はツール自体にあるのではない。HCLやYAMLのような宣言的DSLには明確な限界がある——本物のプログラミング言語ではないからだ。関数を書くことも、ユニットテストを実行することも、ライブラリをインポートすることもできない。インフラが複雑になるたびに、本来解決すべき問題ではなく言語そのものと格闘することになる。
Pulumiはまったく異なるアプローチを取る。TypeScript、Python、Go、C#でインフラを記述するのだ。本物の言語、本物の型システム、本物のIDEサポート。本番環境の4つの異なるAWS環境で実際に使ってきた経験から言えば——3人から15人規模のチームで——特にチームがアプリ開発でTypeScriptをすでに使っている場合は、非常に安定して機能している。
Pulumiの実際の仕組み
Pulumiは単なる「TypeScript版Terraform」ではない。モデルが根本的に異なる:
- 状態管理:PulumiはPulumi Cloud(またはS3などのセルフホスト型バックエンド)経由で状態を追跡する。手動でのステートファイル管理は不要だ。
- 本物の言語機能:
forループで複数のS3バケットを作成したり、インターフェースでコンフィグの型チェックをしたり、async/awaitで依存関係のあるリソースを組み合わせたりできる。 - コンポーネントリソース:VPC+サブネット+セキュリティグループをひとつの再利用可能なクラスにまとめ、他のモジュールと同じようにスタック間で呼び出せる。
- Policy as Code:「S3バケットをパブリックにしない」といったルールをデプロイ前にプログラムで強制できる。CI/CDにおけるシークレット管理と組み合わせると、セキュリティポリシーをコード全体で一貫して強制できる。
正直なトレードオフも一つある。チームにプログラミング経験がまったくない場合、Terraformよりも学習曲線が急になる。ただし、日常的にTypeScriptを書いている人であれば、習得の負担は最小限だ——慣れるまで数日ではなく、たいてい数時間で済む。
インストール
前提条件
Node.js 18以降とPulumi CLIが必要だ。macOSまたはLinuxの場合:
# Pulumi CLIをインストール
curl -fsSL https://get.pulumi.com | sh
# バージョン確認
pulumi version
Windowsの場合はwingetを使う:
winget install pulumi
次に、対象リージョン用のAWS CLIを設定する:
aws configure
# アクセスキーID、シークレット、リージョン(例:ap-northeast-1)を入力
新しいPulumiプロジェクトを作成する
mkdir my-infra && cd my-infra
pulumi new aws-typescript
これにより以下のプロジェクト構成が生成される:
Pulumi.yaml— プロジェクトのメタデータPulumi.dev.yaml— スタック固有のコンフィグindex.ts— インフラのコードpackage.json— Node.jsの依存関係
npm install
最初のリソースを書く
バージョニング付きS3バケット
index.tsを開こう。デフォルトのスキャフォールドにはすでにS3バケットの作成が含まれているが、バージョニングとタグを加えたよりきれいなバージョンがこちらだ:
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.Bucket("my-app-bucket", {
versioning: {
enabled: true,
},
tags: {
Environment: "production",
ManagedBy: "pulumi",
},
});
export const bucketName = bucket.id;
export const bucketArn = bucket.arn;
exportの行はアウトプットを公開する——後でpulumi stack output bucketNameで参照できる。
コピー&ペーストの代わりにループを使う
異なる環境向けに3つのS3バケットが必要な場合、Terraformではcountやfor_eachを使ってインデックス構文と格闘することになる。Pulumiなら単純なmapで済む:
import * as aws from "@pulumi/aws";
const environments = ["dev", "staging", "production"];
const buckets = environments.map(env =>
new aws.s3.Bucket(`app-bucket-${env}`, {
versioning: { enabled: env === "production" },
tags: { Environment: env },
})
);
export const bucketNames = buckets.map(b => b.id);
3つのリソースを、重複なしで作成できる。バージョニングのロジック——本番環境でのみ有効化——は1行の条件式だ。回避策は不要。
より完全な例:セキュリティグループ付きEC2
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const instanceType = config.get("instanceType") || "t3.micro";
// セキュリティグループ
const sg = new aws.ec2.SecurityGroup("web-sg", {
description: "HTTPとSSHを許可",
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] }, // デモ用のみ — 本番環境では自分のIPに制限すること
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
],
egress: [
{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] },
],
});
// 最新のAmazon Linux 2 AMI
const ami = aws.ec2.getAmi({
mostRecent: true,
owners: ["amazon"],
filters: [{ name: "name", values: ["amzn2-ami-hvm-*-x86_64-gp2"] }],
});
// EC2インスタンス
const server = new aws.ec2.Instance("web-server", {
instanceType: instanceType,
ami: ami.then(a => a.id),
vpcSecurityGroupIds: [sg.id],
tags: { Name: "pulumi-web-server" },
});
export const publicIp = server.publicIp;
export const publicDns = server.publicDns;
pulumi.Config()はスタックのコンフィグファイルから値を読み込む。値を設定するには:
pulumi config set instanceType t3.small
同じコードで、スタックごとに異なるインスタンスサイズを使い分けられる。ソースの変更も変数ファイルの操作も不要だ。
スタック:1つのコードベース、複数の環境
# ステージングスタックを作成
pulumi stack init staging
pulumi config set instanceType t3.small
# devに切り替える
pulumi stack select dev
各スタックはそれぞれ独自の状態とコンフィグを持ち、独立してデプロイされる。環境ごとにTerraformディレクトリをコピー&ペーストするという、どのチームも最終的に後悔することになるあのやり方から解放されるのだ。Gitブランチ戦略と組み合わせれば、環境間の変更管理がさらに体系的になる。
デプロイと検証
適用前にプレビューする
まず必ずプレビューを実行しよう:
pulumi preview
terraform planと同様に、Pulumiが作成・更新・削除するものを正確に表示する——リソース名だけでなく、プロパティレベルの差分も含めて。
デプロイ
pulumi up
Pulumiが確認を求め、その後適用する。出力はこのようになる:
Updating (dev):
Type Name Status
+ pulumi:pulumi:Stack my-infra-dev created
+ aws:ec2:SecurityGroup web-sg created
+ aws:ec2:Instance web-server created
Outputs:
publicIp: "54.123.45.67"
publicDns: "ec2-54-123-45-67.ap-northeast-1.compute.amazonaws.com"
Resources:
+ 3 created
Duration: 42s
スタックのアウトプットを確認する
pulumi stack output publicIp
# 54.123.45.67
pulumi stack output --json
# { "publicDns": "...", "publicIp": "54.123.45.67" }
リソースグラフを見る
Pulumi Cloudの無料プランでは、ビジュアルなリソースグラフ、デプロイ履歴、更新ごとの差分が確認できる。自分のインフラで状態を管理したい場合は、S3バケットをバックエンドとして指定できる:
pulumi login s3://your-state-bucket
リソースの削除
pulumi destroy
スタックの状態で追跡されているすべてのリソースをクリーンアップする。コンソールでの操作忘れによる孤立したリソースが残ることはない。
インフラのユニットテスト
TypeScriptなので、きちんとしたユニットテストを書くことができる。PulumiのテストSDKを使えば、実際のAWSに触れることなくリソースのアウトプットをモックできる:
npm install --save-dev @pulumi/pulumi mocha ts-node @types/mocha
// infra.test.ts
import * as pulumi from "@pulumi/pulumi";
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
call: (args) => ({ outputs: {} }),
});
describe("S3バケット", () => {
it("本番環境ではバージョニングが有効になっているべき", async () => {
const infra = await import("./index");
// ここにアサーションを追加
});
});
インフラコードに対してmochaを実行することで、実際のアカウントに何も触れる前に設定のバグを検出できる。Git Hooksでこのテストをコミット前に自動実行する設定と組み合わせると、問題のあるインフラ定義がリポジトリに入り込む前にブロックできる。これだけで、金曜の午後のインシデントを何度も未然に防いできた。
次のステップ
上記のパターンで日常的なIaCの作業の大部分はカバーできる。慣れてきたら、次に探求する価値のある領域がいくつかある:
- Pulumiコンポーネントリソース:再利用可能なインフラパッケージを構築する(TypeScriptクラスとしての「VPCモジュール」など)
- CrossWalk for AWS:ベストプラクティスをデフォルトで組み込んだ、より高レベルなAWSの抽象化
- Automation API:自分のバックエンドアプリケーションにPulumiを組み込み、デプロイをプログラムから制御する
- Policy as Code(CrossGuard):デプロイ前にインフラのルールを定義して強制する。Kubernetesデプロイ戦略と組み合わせるとロールバックの安全性もさらに高まる。
YAMLベースのツールから本物のプログラミング言語への切り替えは、最初の1〜2日は戸惑いを感じるかもしれない——しかしあるとき突然、すべてがつながる瞬間が来る。そうなると、生のHCLやCloudFormationに戻るのは、XMLを手書きしているような感覚になる。一度知ってしまったら、もう元には戻れないものがある。

