LabHub

ブログ

Pulumi IaC 実践ガイド:TypeScriptで構築するクラウドインフラ自動化

한국어English日本語

Pulumi IaC

はじめに

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ツールをさまざまな観点から比較する。チームの技術スタックと要件に合ったツールを選ぶ際の参考にしてほしい。

項目PulumiTerraformAWS CDK
言語TypeScript, Python, Go, C#, Java, YAMLHCL (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 DynamoDBS3 + DynamoDB, Cloudは標準提供CloudFormationが自前で管理
Drift検知pulumi refreshterraform planCloudFormation 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を選ぶべきか

いつTerraformを維持すべきか

環境設定とプロジェクトの初期化

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ではcountfor_eachternaryなどの限られた構文を使わなければならないが、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&lt;T&gt;の値を直接文字列として使おうとしたときに発生

解決:

// 誤った使い方
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を導入する前に、次の項目を確認しよう。

状態管理

シークレット管理

コード品質

CI/CD

運用

セキュリティ

障害事例と復旧手順

ケース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'

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます