- はじめに
- Pulumiのコア概念
- IaCツール比較:Pulumi vs Terraform vs CDK
- 環境設定とプロジェクトの初期化
- AWSインフラ構築の実践
- スタック管理と環境分離
- Automation APIの活用
- テスト戦略
- CI/CDパイプラインの統合
- トラブルシューティング
- プロダクションチェックリスト
- 障害事例と復旧手順
- 参考資料

はじめに
Infrastructure as Code(IaC)はもはや選択ではなく必須である。2025-2026年現在、先進的な組織はインフラを単にプロビジョニングする水準を超えて、ソフトウェアのように扱っている。テスト、バージョン管理、コードレビュー、CI/CDパイプラインまで、ソフトウェアエンジニアリングのあらゆる慣行をインフラに適用しているのだ。
しかし既存のIaCツール、特にTerraformのHCL(HashiCorp Configuration Language)は汎用プログラミング言語ではない。複雑な条件分岐、繰り返しロジック、型安全性、単体テストなどを実装しようとすると、HCLの限界にぶつかる。Pulumiはこの問題を、TypeScript、Python、Go、C#、Javaなどの汎用プログラミング言語でインフラを定義できるようにして解決する。
本記事ではTypeScriptを中心に、Pulumiのコア概念からプロダクション運用まで、実務で必要になるすべてを扱う。Terraformとの比較、AWSインフラ構築、スタック管理、Automation API、テスト戦略、CI/CD統合、トラブルシューティングまで、コード例とともに見ていく。
Pulumiのコア概念
Pulumiを使う前に必ず理解しておくべきコア概念を整理する。
Project(プロジェクト)
プロジェクトはPulumiプログラムが入ったディレクトリである。Pulumi.yamlファイルがプロジェクトのルートを定義し、プロジェクト名と使用するランタイム(nodejs、python、goなど)を指定する。
Stack(スタック)
スタックはプロジェクトの独立したインスタンスである。同じプログラムをdev、staging、productionのような異なる環境にデプロイするとき、それぞれをスタックとして管理する。各スタックは固有の設定値と状態を持つ。
Resource(リソース)
リソースはクラウドインフラの基本単位である。S3バケット、EC2インスタンス、VPCなどはすべてリソースだ。PulumiではTypeScriptクラスのインスタンスとして表現される。
State(状態)
Pulumiはデプロイされたリソースの現在の状態を追跡する。状態はPulumi Cloud(デフォルト)、AWS S3、Azure Blob Storage、Google Cloud Storage、ローカルファイルシステムなどに保存できる。
Provider(プロバイダー)
プロバイダーは特定のクラウドサービスと通信するプラグインである。AWS、GCP、Azure、Kubernetesなど150個以上のプロバイダーが存在する。
OutputとInput
PulumiリソースのプロパティはOutput<T>型で返される。これは、リソースが実際に作成されるまで値がわからないという非同期的な性質を表現している。他のリソースにこの値を渡すときはInput<T>型で受け取る。
import * as aws from '@pulumi/aws'
// S3バケットを作成
const bucket = new aws.s3.Bucket('my-bucket', {
website: {
indexDocument: 'index.html',
},
})
// bucket.idはOutput<string>型
// 他のリソースのInputとして直接渡せる
const bucketPolicy = new aws.s3.BucketPolicy('my-bucket-policy', {
bucket: bucket.id, // Output<string> -> Input<string> 自動変換
policy: bucket.arn.apply((arn) =>
JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: `${arn}/*`,
},
],
})
),
})
// Output値をexportするとスタック出力として表示される
export const bucketName = bucket.id
export const websiteUrl = bucket.websiteEndpoint
IaCツール比較:Pulumi vs Terraform vs CDK
3つの主要なIaCツールをさまざまな観点から比較する。チームの技術スタックと要件に合ったツールを選ぶ際の参考にしてほしい。
| 項目 | Pulumi | Terraform | AWS CDK |
|---|---|---|---|
| 言語 | TypeScript, Python, Go, C#, Java, YAML | HCL (DSL) | TypeScript, Python, Java, C#, Go |
| マルチクラウド | AWS, GCP, Azure, K8s など150+プロバイダー | 数千の公式/コミュニティプロバイダー | AWS専用 |
| 状態管理 | Pulumi Cloud, S3, GCS, Azure Blob, ローカル | Terraform Cloud, S3, GCS, ローカルなど | CloudFormationに委任 |
| テスト | 標準テストフレームワーク (Jest, Mocha) | terraform test (HCLベース) | 標準テストフレームワーク (Jestなど) |
| 型安全性 | TypeScriptの静的型チェック | 限定的 (HCL変数の型) | TypeScriptの静的型チェック |
| IDE対応 | VS Code IntelliSense, 自動補完 | HCLプラグインが必要 | VS Code IntelliSense, 自動補完 |
| 学習曲線 | プログラミング経験があれば低い | HCLの学習が必要 | AWS + プログラミング知識が必要 |
| 状態ロック | 内蔵 (Pulumi Cloud), S3 DynamoDB | S3 + DynamoDB, Cloudは標準提供 | CloudFormationが自前で管理 |
| Drift検知 | pulumi refresh | terraform plan | CloudFormation drift detection |
| シークレット管理 | 内蔵の暗号化, ESC対応 | Vault連携が必要 | Secrets Manager/SSM連携 |
| Automation API | プログラムからの実行に対応 | 限定的 (CLI wrapper) | 限定的 |
| コミュニティ/エコシステム | 成長中 (GitHub 22k+ スター) | 非常に成熟 (GitHub 43k+ スター) | AWSエコシステム内 |
| ライセンス | Apache 2.0 (オープンソース) | BSL (Business Source License) | Apache 2.0 (オープンソース) |
いつPulumiを選ぶべきか
- 開発者中心のチーム: TypeScript/Pythonなどすでに使っている言語でインフラを管理したいとき
- 複雑なロジックが必要なとき: 条件付きのリソース作成、動的な設定、複雑な変換が頻繁なとき
- テスト駆動のインフラ: Jest、Mochaなど既存のテストフレームワークでインフラをテストしたいとき
- Automation APIの活用: プラットフォームエンジニアリングでインフラのプロビジョニングをAPIとして公開する必要があるとき
- シークレット管理の内蔵が必要: 別途ツールを使わずにシークレットを安全に管理したいとき
いつTerraformを維持すべきか
- 大規模なマルチクラウド: 数千のプロバイダーのエコシステムが必要なとき
- 運用中心のチーム: 開発より運用に焦点を当てたSRE/インフラチームのとき
- 既存のTerraformコードベース: すでに大規模なHCLコードが存在し、移行コストが高いとき
- tf2pulumi: TerraformからPulumiへ移行するツールも提供されているが、大規模な移行は慎重に判断する必要がある
環境設定とプロジェクトの初期化
Pulumi CLIのインストール
# macOS
brew install pulumi/tap/pulumi
# Linux (curl)
curl -fsSL https://get.pulumi.com | sh
# Windows (Chocolatey)
choco install pulumi
# インストール確認
pulumi version
# v3.x.x
# Node.js確認(TypeScript使用時は必須)
node --version
# v20.x.x 以上を推奨
# Pulumiログイン(Pulumi Cloudを使用)
pulumi login
# または S3 バックエンドを使用
pulumi login s3://my-pulumi-state-bucket
# ローカルファイルシステムを使用
pulumi login --local
新規プロジェクトの作成
# 新しいディレクトリを作成
mkdir my-infra && cd my-infra
# AWS TypeScriptテンプレートでプロジェクトを初期化
pulumi new aws-typescript
# 対話型プロンプトで設定
# project name: my-infra
# project description: Production infrastructure
# stack name: dev
# aws:region: ap-northeast-2
プロジェクトを初期化したあとに生成されるファイル構造を見ていこう。
my-infra/
├── Pulumi.yaml # プロジェクトのメタデータ
├── Pulumi.dev.yaml # devスタックの設定
├── index.ts # メインプログラム
├── package.json # npmの依存関係
└── tsconfig.json # TypeScriptの設定
Pulumi.yamlファイルの内容は次のとおりである。
name: my-infra
runtime:
name: nodejs
options:
typescript: true
description: Production infrastructure
config:
pulumi:tags:
value:
pulumi:template: aws-typescript
Pulumi.dev.yamlスタック設定ファイルの例である。
config:
aws:region: ap-northeast-2
my-infra:environment: dev
my-infra:dbPassword:
secure: AAABADEFaBCDeFgHiJkLmNoPqRsTuVwXyZ== # 暗号化されたシークレット
AWSインフラ構築の実践
実際のプロダクションで使えるAWSインフラをTypeScriptで構築してみよう。
VPCとネットワーク構成
import * as pulumi from '@pulumi/pulumi'
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
const config = new pulumi.Config()
const environment = config.require('environment')
// awsxを活用したVPC作成(高レベルの抽象化)
const vpc = new awsx.ec2.Vpc(`${environment}-vpc`, {
cidrBlock: '10.0.0.0/16',
numberOfAvailabilityZones: 3,
subnetStrategy: awsx.ec2.SubnetAllocationStrategy.Auto,
enableDnsHostnames: true,
enableDnsSupport: true,
subnetSpecs: [
{
type: awsx.ec2.SubnetType.Public,
name: 'public',
cidrMask: 24,
},
{
type: awsx.ec2.SubnetType.Private,
name: 'private',
cidrMask: 24,
},
{
type: awsx.ec2.SubnetType.Isolated,
name: 'isolated',
cidrMask: 24,
},
],
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
// セキュリティグループの作成
const albSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-alb-sg`, {
vpcId: vpc.vpcId,
description: 'Security group for ALB',
ingress: [
{
protocol: 'tcp',
fromPort: 80,
toPort: 80,
cidrBlocks: ['0.0.0.0/0'],
description: 'HTTP',
},
{
protocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrBlocks: ['0.0.0.0/0'],
description: 'HTTPS',
},
],
egress: [
{
protocol: '-1',
fromPort: 0,
toPort: 0,
cidrBlocks: ['0.0.0.0/0'],
description: 'Allow all outbound',
},
],
tags: {
Name: `${environment}-alb-sg`,
Environment: environment,
},
})
export const vpcId = vpc.vpcId
export const publicSubnetIds = vpc.publicSubnetIds
export const privateSubnetIds = vpc.privateSubnetIds
ECS Fargateサービスのデプロイ
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
import * as pulumi from '@pulumi/pulumi'
const config = new pulumi.Config()
const environment = config.require('environment')
const containerPort = config.getNumber('containerPort') || 3000
const cpu = config.getNumber('cpu') || 256
const memory = config.getNumber('memory') || 512
const desiredCount = config.getNumber('desiredCount') || 2
// ECRリポジトリの作成
const repo = new awsx.ecr.Repository(`${environment}-app-repo`, {
forceDelete: environment !== 'production',
lifecyclePolicy: {
rules: [
{
description: 'Keep last 10 images',
maximumNumberOfImages: 10,
tagStatus: 'any',
},
],
},
})
// Dockerイメージのビルドとプッシュ
const image = new awsx.ecr.Image(`${environment}-app-image`, {
repositoryUrl: repo.url,
context: '../app',
platform: 'linux/amd64',
})
// ECSクラスターの作成
const cluster = new aws.ecs.Cluster(`${environment}-cluster`, {
settings: [
{
name: 'containerInsights',
value: 'enabled',
},
],
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
// ALB + ECS Fargateサービス(awsxの高レベルコンポーネント)
const service = new awsx.ecs.FargateService(`${environment}-service`, {
cluster: cluster.arn,
desiredCount: desiredCount,
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [albSecurityGroup.id],
assignPublicIp: false,
},
taskDefinitionArgs: {
container: {
name: 'app',
image: image.imageUri,
cpu: cpu,
memory: memory,
essential: true,
portMappings: [
{
containerPort: containerPort,
targetGroup: loadBalancer.defaultTargetGroup,
},
],
environment: [
{ name: 'NODE_ENV', value: environment },
{ name: 'PORT', value: String(containerPort) },
],
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-group': `/ecs/${environment}-app`,
'awslogs-region': aws.config.region!,
'awslogs-stream-prefix': 'ecs',
},
},
},
},
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
export const serviceUrl = pulumi.interpolate`http://${loadBalancer.loadBalancer.dnsName}`
RDSデータベースの作成
import * as aws from '@pulumi/aws'
import * as pulumi from '@pulumi/pulumi'
import * as random from '@pulumi/random'
const config = new pulumi.Config()
const environment = config.require('environment')
const dbName = config.require('dbName')
// ランダムなパスワードを生成
const dbPassword = new random.RandomPassword(`${environment}-db-password`, {
length: 32,
special: true,
overrideSpecial: '!#$%&*()-_=+[]{}<>:?',
})
// Secrets Managerにパスワードを保存
const dbSecret = new aws.secretsmanager.Secret(`${environment}-db-secret`, {
name: `${environment}/database/master-password`,
tags: { Environment: environment },
})
const dbSecretVersion = new aws.secretsmanager.SecretVersion(`${environment}-db-secret-version`, {
secretId: dbSecret.id,
secretString: pulumi
.all([dbPassword.result])
.apply(([password]) => JSON.stringify({ username: 'admin', password })),
})
// DBサブネットグループ
const dbSubnetGroup = new aws.rds.SubnetGroup(`${environment}-db-subnet`, {
subnetIds: vpc.isolatedSubnetIds,
tags: { Environment: environment },
})
// DBセキュリティグループ
const dbSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-db-sg`, {
vpcId: vpc.vpcId,
description: 'Security group for RDS',
ingress: [
{
protocol: 'tcp',
fromPort: 5432,
toPort: 5432,
securityGroups: [albSecurityGroup.id],
description: 'PostgreSQL from ECS',
},
],
tags: { Name: `${environment}-db-sg`, Environment: environment },
})
// RDSインスタンスの作成
const db = new aws.rds.Instance(`${environment}-postgres`, {
engine: 'postgres',
engineVersion: '16.4',
instanceClass: environment === 'production' ? 'db.r6g.large' : 'db.t4g.micro',
allocatedStorage: 20,
maxAllocatedStorage: environment === 'production' ? 100 : 50,
dbName: dbName,
username: 'admin',
password: dbPassword.result,
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [dbSecurityGroup.id],
multiAz: environment === 'production',
backupRetentionPeriod: environment === 'production' ? 14 : 1,
deletionProtection: environment === 'production',
skipFinalSnapshot: environment !== 'production',
finalSnapshotIdentifier:
environment === 'production' ? `${environment}-final-snapshot` : undefined,
storageEncrypted: true,
performanceInsightsEnabled: environment === 'production',
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
export const dbEndpoint = db.endpoint
export const dbSecretArn = dbSecret.arn
上のコードで注目すべき点は、TypeScriptの条件式を活用して環境ごとに異なる設定を自然に適用していることだ。HCLではcount、for_each、ternaryなどの限られた構文を使わなければならないが、Pulumiでは通常のプログラミングロジックをそのまま使える。
スタック管理と環境分離
スタックの作成と切り替え
# 新しいスタックを作成
pulumi stack init staging
pulumi stack init production
# スタック一覧を確認
pulumi stack ls
# NAME LAST UPDATE RESOURCE COUNT URL
# dev* 2 minutes 15 https://app.pulumi.com/...
# staging n/a n/a https://app.pulumi.com/...
# production n/a n/a https://app.pulumi.com/...
# スタックを切り替え
pulumi stack select staging
# スタックごとの設定
pulumi config set environment staging
pulumi config set aws:region ap-northeast-2
pulumi config set desiredCount 2
pulumi config set --secret dbPassword 'super-secret-password'
# すべての設定を確認
pulumi config
# KEY VALUE
# aws:region ap-northeast-2
# dbPassword [secret]
# desiredCount 2
# environment staging
スタック参照(Cross-Stack Reference)
大規模なプロジェクトでは、インフラを複数のプロジェクトに分割し、スタック参照を通じて連携させる。
import * as pulumi from '@pulumi/pulumi'
// ネットワークスタックの出力を参照
const networkStack = new pulumi.StackReference('organization/network-infra/production')
// 他のスタックの出力値を取得
const vpcId = networkStack.getOutput('vpcId')
const privateSubnetIds = networkStack.getOutput('privateSubnetIds')
// これらの値を現在のスタックで使用
const service = new aws.ecs.Service('my-service', {
networkConfiguration: {
subnets: privateSubnetIds.apply((ids) => ids as string[]),
// ...
},
})
Self-Managed Backend (S3)
Pulumi Cloudの代わりにS3をバックエンドとして使うには、次のように設定する。
# S3バケットの作成(AWS CLI)
aws s3 mb s3://my-company-pulumi-state --region ap-northeast-2
# バケットのバージョニングを有効化(状態ファイルの保護)
aws s3api put-bucket-versioning \
--bucket my-company-pulumi-state \
--versioning-configuration Status=Enabled
# サーバーサイド暗号化を有効化
aws s3api put-bucket-encryption \
--bucket my-company-pulumi-state \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
}'
# PulumiのバックエンドをS3に変更
pulumi login s3://my-company-pulumi-state
# KMSキーでシークレットの暗号化を設定
pulumi stack init production \
--secrets-provider="awskms://alias/pulumi-secrets?region=ap-northeast-2"
S3バックエンドを使うとき、状態ロック(State Locking)はデフォルトで有効になっており、複数のプロセスが同時に状態を変更することを防ぐ。既存のDIYバックエンドをプロジェクトスコープのスタック(project-scoped stacks)にアップグレードするにはpulumi state upgradeコマンドを使える。
Pulumi ESC (Environments, Secrets, and Configuration)
Pulumi ESCは環境ごとのシークレットと設定を中央で管理する機能である。
# Pulumi ESC 環境定義の例(my-org/production.yaml)
imports:
- my-org/base-config # 基本設定を継承
values:
aws:
login:
fn::open::aws-login:
oidc:
roleArn: arn:aws:iam::123456789012:role/pulumi-esc-role
sessionName: pulumi-esc-session
environmentVariables:
AWS_ACCESS_KEY_ID: ${aws.login.accessKeyId}
AWS_SECRET_ACCESS_KEY: ${aws.login.secretAccessKey}
AWS_SESSION_TOKEN: ${aws.login.sessionToken}
pulumiConfig:
aws:region: ap-northeast-2
environment: production
dbInstanceClass: db.r6g.large
secrets:
fn::open::aws-secrets:
region: ap-northeast-2
login: ${aws.login}
get:
db-password:
secretId: production/database/master-password
ESCを使えば、AWS OIDC、Azure OIDC、Google Cloud OIDC、HashiCorp Vault、AWS Secrets Managerなどから動的にシークレットを取得できる。
Automation APIの活用
Pulumi Automation APIはPulumiの最も強力な差別化要素の一つである。CLIなしでプログラムからPulumiの操作を実行できるため、プラットフォームエンジニアリングやセルフサービス型インフラポータルの構築に最適だ。
インラインプログラムの例
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'
// インラインでPulumiプログラムを定義
const pulumiProgram = async () => {
const bucket = new aws.s3.Bucket('auto-bucket', {
website: {
indexDocument: 'index.html',
},
})
return {
bucketName: bucket.id,
websiteUrl: bucket.websiteEndpoint,
}
}
async function deployInfrastructure(stackName: string, region: string) {
const args: InlineProgramArgs = {
stackName,
projectName: 'auto-deploy',
program: pulumiProgram,
}
// スタックの作成または選択
const stack = await LocalWorkspace.createOrSelectStack(args)
// スタックの設定
await stack.setConfig('aws:region', { value: region })
console.log('Running pulumi preview...')
const previewResult = await stack.preview({ onOutput: console.log })
console.log(`Preview: ${previewResult.changeSummary}`)
console.log('Running pulumi up...')
const upResult = await stack.up({ onOutput: console.log })
console.log(`Update summary: ${JSON.stringify(upResult.summary)}`)
console.log(`Outputs: ${JSON.stringify(upResult.outputs)}`)
return upResult.outputs
}
async function destroyInfrastructure(stackName: string) {
const args: InlineProgramArgs = {
stackName,
projectName: 'auto-deploy',
program: pulumiProgram,
}
const stack = await LocalWorkspace.createOrSelectStack(args)
console.log('Running pulumi destroy...')
await stack.destroy({ onOutput: console.log })
console.log('Removing stack...')
await stack.workspace.removeStack(stackName)
}
// 使用例
;(async () => {
try {
const outputs = await deployInfrastructure('dev', 'ap-northeast-2')
console.log(`Website URL: ${outputs.websiteUrl.value}`)
} catch (err) {
console.error(`Error: ${err}`)
process.exit(1)
}
})()
HTTP APIでインフラを公開する
Automation APIをExpress.jsと組み合わせれば、REST APIとしてインフラのプロビジョニングを公開できる。
import express from 'express'
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'
const app = express()
app.use(express.json())
// POST /api/environments - 新しい環境を作成
app.post('/api/environments', async (req, res) => {
const { name, region, instanceType } = req.body
try {
const program = async () => {
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: '10.0.0.0/16',
tags: { Name: `${name}-vpc` },
})
const subnet = new aws.ec2.Subnet(`${name}-subnet`, {
vpcId: vpc.id,
cidrBlock: '10.0.1.0/24',
tags: { Name: `${name}-subnet` },
})
return { vpcId: vpc.id, subnetId: subnet.id }
}
const stack = await LocalWorkspace.createOrSelectStack({
stackName: name,
projectName: 'self-service-infra',
program,
})
await stack.setConfig('aws:region', { value: region || 'ap-northeast-2' })
const result = await stack.up({ onOutput: console.log })
res.json({
status: 'deployed',
outputs: result.outputs,
summary: result.summary,
})
} catch (error: any) {
res.status(500).json({ error: error.message })
}
})
// DELETE /api/environments/:name - 環境を削除
app.delete('/api/environments/:name', async (req, res) => {
const { name } = req.params
try {
const stack = await LocalWorkspace.selectStack({
stackName: name,
projectName: 'self-service-infra',
program: async () => ({}),
})
await stack.destroy({ onOutput: console.log })
await stack.workspace.removeStack(name)
res.json({ status: 'destroyed' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
})
// GET /api/environments/:name - 環境の状態を照会
app.get('/api/environments/:name', async (req, res) => {
const { name } = req.params
try {
const stack = await LocalWorkspace.selectStack({
stackName: name,
projectName: 'self-service-infra',
program: async () => ({}),
})
const outputs = await stack.outputs()
const info = await stack.info()
res.json({ stack: name, outputs, lastUpdate: info })
} catch (error: any) {
res.status(404).json({ error: `Stack ${name} not found` })
}
})
app.listen(3000, () => console.log('Infra API running on :3000'))
このパターンは社内開発者プラットフォーム(IDP)の構築時にとても役立つ。開発者が自分でインフラをプロビジョニングできるセルフサービスポータルを作れる。
テスト戦略
Pulumiの大きな利点の一つは、標準のテストフレームワークでインフラコードをテストできることだ。大きく単体テスト(Unit Test)と統合テスト(Integration Test)に分かれる。
単体テスト(Jest)
単体テストではPulumiエンジンをモックして、実際のクラウドリソースなしにインフラのロジックを検証する。
// __tests__/infra.test.ts
import * as pulumi from '@pulumi/pulumi'
// Pulumiランタイムのモック - 必ずimportより前に設定する
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs): { id: string; state: any } => {
// リソースタイプごとのモック応答
switch (args.type) {
case 'aws:s3/bucket:Bucket':
return {
id: `${args.name}-id`,
state: {
...args.inputs,
arn: `arn:aws:s3:::${args.name}`,
bucket: args.name,
websiteEndpoint: `${args.name}.s3-website.ap-northeast-2.amazonaws.com`,
},
}
case 'aws:ec2/securityGroup:SecurityGroup':
return {
id: `sg-${args.name}`,
state: {
...args.inputs,
arn: `arn:aws:ec2:ap-northeast-2:123456789012:security-group/sg-${args.name}`,
},
}
default:
return {
id: `${args.name}-id`,
state: args.inputs,
}
}
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs
},
})
// テスト対象のインフラコードをimport(モック設定のあとにimport)
import { bucket, securityGroup } from '../index'
describe('Infrastructure Tests', () => {
test('S3バケットにウェブサイト設定があること', async () => {
const websiteConfig = await new Promise((resolve) =>
bucket.website.apply((website) => resolve(website))
)
expect(websiteConfig).toBeDefined()
})
test('S3バケット名に環境のプレフィックスが含まれること', async () => {
const bucketName = await new Promise((resolve) => bucket.id.apply((name) => resolve(name)))
expect(bucketName).toContain('dev')
})
test('セキュリティグループがポート443を許可すること', async () => {
const ingress = await new Promise((resolve) =>
securityGroup.ingress.apply((rules) => resolve(rules))
)
const httpsRule = (ingress as any[]).find((r: any) => r.fromPort === 443)
expect(httpsRule).toBeDefined()
expect(httpsRule.protocol).toBe('tcp')
})
test('セキュリティグループがSSH(22)ポートを0.0.0.0/0に開放しないこと', async () => {
const ingress = await new Promise((resolve) =>
securityGroup.ingress.apply((rules) => resolve(rules))
)
const sshRule = (ingress as any[]).find(
(r: any) => r.fromPort === 22 && r.cidrBlocks?.includes('0.0.0.0/0')
)
expect(sshRule).toBeUndefined()
})
})
Jestの設定ファイルも合わせて作成する。
// jest.config.ts
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// Pulumi Outputのタイムアウトを防ぐ
testTimeout: 30000,
}
export default config
Policy as Code (CrossGuard)
Pulumi CrossGuardを使えば、ポリシーをコードとして定義し、インフラのデプロイ時に自動で検証できる。
// policy-pack/index.ts
import * as policy from '@pulumi/policy'
new policy.PolicyPack('security-policies', {
policies: [
{
name: 's3-no-public-read',
description: 'S3バケットにパブリック読み取り権限がないこと',
enforcementLevel: 'mandatory',
validateResource: policy.validateResourceOfType(
'aws:s3/bucket:Bucket',
(bucket, args, reportViolation) => {
if (bucket.acl === 'public-read' || bucket.acl === 'public-read-write') {
reportViolation('S3バケットにパブリックACLが設定されています。')
}
}
),
},
{
name: 'rds-encryption-required',
description: 'RDSインスタンスは必ず暗号化されていること',
enforcementLevel: 'mandatory',
validateResource: policy.validateResourceOfType(
'aws:rds/instance:Instance',
(instance, args, reportViolation) => {
if (!instance.storageEncrypted) {
reportViolation('RDSインスタンスでストレージ暗号化が有効になっていません。')
}
}
),
},
{
name: 'ec2-no-public-ip',
description: 'EC2インスタンスにパブリックIPが割り当てられていないこと',
enforcementLevel: 'advisory',
validateResource: policy.validateResourceOfType(
'aws:ec2/instance:Instance',
(instance, args, reportViolation) => {
if (instance.associatePublicIpAddress) {
reportViolation('EC2インスタンスにパブリックIPが割り当てられています。')
}
}
),
},
],
})
CI/CDパイプラインの統合
GitHub Actionsワークフロー
Pulumiの公式GitHub Actionsを使えば、PRベースのインフラ変更ワークフローを構成できる。
# .github/workflows/pulumi-preview.yml
name: Pulumi Preview
on:
pull_request:
branches: [main]
paths:
- 'infra/**'
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ap-northeast-2
jobs:
preview:
name: Pulumi Preview
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
# プラグインキャッシュでCIを高速化
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
restore-keys: |
${{ runner.os }}-pulumi-
- run: npm ci
# 単体テストの実行
- name: Run Unit Tests
run: npm test
# Pulumi Preview
- uses: pulumi/actions@v6
with:
command: preview
stack-name: organization/my-infra/staging
work-dir: infra
comment-on-pr: true
comment-on-summary: true
# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy
on:
push:
branches: [main]
paths:
- 'infra/**'
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_REGION: ap-northeast-2
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
environment: staging
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
- run: npm ci
- uses: pulumi/actions@v6
with:
command: up
stack-name: organization/my-infra/staging
work-dir: infra
deploy-production:
name: Deploy to Production
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # GitHub Environment(手動承認が可能)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
- run: npm ci
working-directory: infra
- uses: pulumi/actions@v6
with:
command: up
stack-name: organization/my-infra/production
work-dir: infra
GitHub Appをインストールすると、PRにリソース変更のサマリーが自動でコメントされる。これによってコードレビュアーはインフラ変更の影響を把握しやすくなる。
トラブルシューティング
1. Update Conflict(状態の競合)
症状: error: the stack is currently locked by 1 lock(s) または conflict: another update is in progress
原因: 他のユーザーが同じスタックを更新中であるか、以前の更新が異常終了した場合に発生する。
解決:
# 現在の更新をキャンセル(他のユーザーが作業中でない場合のみ)
pulumi cancel
# 状態を確認
pulumi stack --show-urns
2. Interrupted Update(中断された更新)
症状: error: update interrupted またはリソースがpending状態のまま残っている場合
解決:
# 状態をクラウドプロバイダーと同期
pulumi refresh --yes
# pending の作業があれば自動的に整理される
# そのあと通常どおりデプロイを再試行
pulumi up
3. Resource Already Exists(リソースがすでに存在)
症状: error: resource already exists - Pulumiの外部で作成したリソースとの衝突
解決:
# 既存リソースをPulumiの状態に取り込む(import)
pulumi import aws:s3/bucket:Bucket my-bucket my-existing-bucket-name
# またはコード側でimportオプションを使う
const existingBucket = new aws.s3.Bucket(
'my-bucket',
{
bucket: 'my-existing-bucket-name',
},
{
import: 'my-existing-bucket-name', // 既存リソースをimport
}
)
4. Output値へのアクセスの問題
症状: Calling [toString] on an [Output<T>] という警告、または[object Object]が出力される
原因: Output<T>の値を直接文字列として使おうとしたときに発生
解決:
// 誤った使い方
const url = `http://${bucket.websiteEndpoint}` // [object Object] が出力される
// 正しい使い方1: applyメソッド
const url = bucket.websiteEndpoint.apply((ep) => `http://${ep}`)
// 正しい使い方2: pulumi.interpolate タグ付きテンプレート
const url = pulumi.interpolate`http://${bucket.websiteEndpoint}`
// 正しい使い方3: pulumi.all で複数のOutputを結合
const combined = pulumi.all([bucket.id, bucket.arn]).apply(([id, arn]) => {
return { id, arn }
})
5. Providerのバージョン衝突
症状: failed to load plugin または version mismatch エラー
解決:
# プラグインキャッシュを削除
rm -rf ~/.pulumi/plugins
# 依存関係を再インストール
npm install
# 特定のプロバイダーバージョンを固定
npm install @pulumi/aws@6.x.x --save-exact
# Pulumiプラグインをインストール
pulumi plugin install resource aws v6.x.x
6. 状態ファイルの破損
症状: checkpoint file is not valid または予期しないリソース状態
解決:
# 状態ファイルをエクスポート(バックアップ)
pulumi stack export > state-backup.json
# 状態ファイルの検証と編集
# 問題のあるリソースを手動で削除または修正できる
# 修正した状態ファイルをインポート
pulumi stack import < state-fixed.json
# または特定のリソースを状態から削除
pulumi state delete 'urn:pulumi:dev::my-infra::aws:s3/bucket:Bucket::my-bucket'
プロダクションチェックリスト
プロダクション環境にPulumiを導入する前に、次の項目を確認しよう。
状態管理
- 状態バックエンドの選定完了(Pulumi CloudまたはS3/GCS)
- S3を使う場合はバケットのバージョニングを有効化
- S3を使う場合はサーバーサイド暗号化(SSE-KMS)を設定
- S3を使う場合はバケットポリシーでアクセスを制限
- 状態ロック(State Locking)の動作を確認
シークレット管理
- シークレットプロバイダーの設定(Pulumi Cloud、AWS KMS、HashiCorp Vaultなど)
- 機密性の高い設定値には
--secretフラグを使用 - ESC環境の構成(大規模チームの場合)
- OIDCベースの動的な認証情報を設定
コード品質
- 単体テストの作成とCIへの統合
- Policy Pack (CrossGuard) の定義
- コードレビュープロセスの確立
- モジュール化とコンポーネントリソースパターンの適用
- TypeScript strictモードの有効化
CI/CD
- GitHub Actions(または他のCI)ワークフローの構成
- PR時に
pulumi previewを自動実行 - mainブランチへのマージ時に
pulumi upで自動デプロイ - プロダクションへのデプロイ時は手動承認(GitHub Environments)
- プラグインキャッシュの設定でCI速度を最適化
運用
- スタック命名規則の策定(organization/project/environment)
- リソースのタグ付けポリシーを定義(Environment、ManagedBy、Ownerなど)
-
pulumi refreshの定期実行によるドリフト検知 - スタック参照(StackReference)パターンによるプロジェクト分離
- ロールバック手順の文書化
セキュリティ
- IAMの最小権限の原則を適用
- CI/CDではOIDCベースの認証を使用(長期の認証情報は避ける)
- 状態ファイルへのアクセス権限を制限
- 監査ログを有効化
- deletionProtectionの設定(重要なリソース)
障害事例と復旧手順
ケース1: プロダクションRDSの誤削除
状況: pulumi destroyをstagingスタックで実行しようとしたが、productionスタックが選択された状態で実行してしまい、RDSが削除された。
復旧手順:
# 1. ただちにスタックの状態を確認
pulumi stack select production
pulumi stack --show-urns
# 2. RDSの最終スナップショットを確認(deletionProtectionがなかった場合)
aws rds describe-db-snapshots \
--db-instance-identifier production-postgres \
--query 'DBSnapshots[*].{ID:DBSnapshotIdentifier,Time:SnapshotCreateTime}' \
--output table
# 3. スナップショットから復元
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier production-postgres-restored \
--db-snapshot-identifier production-final-snapshot
# 4. Pulumiの状態にimport
pulumi import aws:rds/instance:Instance production-postgres production-postgres-restored
予防策:
// deletionProtectionは常に有効化
const db = new aws.rds.Instance(
'production-postgres',
{
// ... 設定 ...
deletionProtection: true, // 誤削除の防止
},
{
protect: true, // Pulumiのレベルでも保護
}
)
ケース2: 状態ファイルと実リソースの不一致(Drift)
状況: AWSコンソールでセキュリティグループのルールを手動で変更したが、Pulumiの状態には反映されず、次のデプロイで衝突が発生
復旧手順:
# 1. ドリフトを確認
pulumi refresh --diff
# 出力例:
# ~ aws:ec2/securityGroup:SecurityGroup (update)
# ~ ingress: [
# + { fromPort: 8080, toPort: 8080, ... } # 手動で追加されたルール
# ]
# 2-A. 実際の状態をコードに反映(手動変更をコードに適用)
# コードを修正したあと:
pulumi refresh --yes
pulumi up
# 2-B. コードの状態に復元(手動変更を巻き戻す)
pulumi up --yes # refreshせずにそのままupするとコード基準で復元される
予防策: AWSコンソールでの手動変更を禁止し、すべての変更はコードを通じて行うようチームのルールを定める。AWS Config RulesやPulumi CrossGuardでドリフトを自動検知するのもよい方法だ。
ケース3: CI/CDでの同時デプロイ衝突
状況: 2つのPRがほぼ同時にマージされ、2つのpulumi upが同時に実行されて状態の競合が発生
復旧手順:
# 1. 衝突の解決 - まずロックを確認
pulumi cancel # 待機中の更新をキャンセル
# 2. refreshで状態を同期
pulumi refresh --yes
# 3. 再度デプロイ
pulumi up --yes
予防策: GitHub Actionsのconcurrency設定で同時デプロイを防ぐ。
# .github/workflows/pulumi-deploy.yml
concurrency:
group: pulumi-${{ github.ref }}
cancel-in-progress: false # 進行中のデプロイはキャンセルしない
ケース4: 大規模リファクタリング時のリソース再作成の防止
状況: リソース名や構造を変更したときに、Pulumiが既存のリソースを削除して新しく作成(replace)しようとする場合
復旧手順:
# 1. previewで変更内容を確認
pulumi preview --diff
# 2. リソース名を変更するときはaliasesを使う
// 既存コード: new aws.s3.Bucket("old-name", {...})
// 変更後:
const bucket = new aws.s3.Bucket(
'new-name',
{
// ... 設定 ...
},
{
aliases: [{ name: 'old-name' }], // 以前の名前をエイリアスとして指定
}
)
# 3. または状態から直接リソース名を変更
pulumi state rename 'urn:pulumi:dev::project::aws:s3/bucket:Bucket::old-name' 'new-name'
参考資料
- Pulumi公式ドキュメント - Infrastructure as Code - Pulumi IaCの全体像と使い方を扱う公式ドキュメント。
- Pulumi Automation APIドキュメント - プログラムからPulumiを実行するAutomation APIの詳細ガイド。
- Pulumi GitHub Actions統合ガイド - CI/CDパイプラインでPulumiを使う公式ガイド。
- Pulumiの状態とバックエンド管理 - S3、GCSなど自己管理バックエンドを含む状態管理の方法を解説している。
- Pulumi単体テストガイド - TypeScript、Python、Goなどでの単体テストの書き方を扱っている。
- Terraform vs Pulumi vs CDK 2025比較 - 3つの主要なIaCツールの総合比較分析。
- Pulumi ESC - 環境、シークレット、設定の管理 - 中央化されたシークレット/環境管理ソリューションの紹介。
- Pulumiトラブルシューティング公式ガイド - 状態の競合、中断された更新など一般的な問題の解決方法。
- Pulumi GitHub Repository - Pulumiのオープンソースコアリポジトリ(Apache 2.0ライセンス)。
- Pulumi TypeScript/Node.js SDKドキュメント - TypeScriptでPulumiを使うためのSDKドキュメント。