Pulumiインフラ as Code実戦:5つのパターンでTypeScriptでクラウドリソースを管理

DevOps

Pulumi:本物のプログラミング言語でインフラを管理

TerraformはHCLで設定を書くが、ループ/条件/再利用が必要な時はワークアラウンドの山。PulumiはTypeScript/Python/Goなどの本物のプログラミング言語でインフラを定義、ループ、条件、関数、クラス——すべてのプログラミング能力が利用可能。2026年、Pulumiは80以上のクラウドプロバイダーをサポート、Pulumi AIがインフラコードを自動生成、Pulumi DeploymentsでGitOps自動デプロイを実現。

本記事では5つのコアパターンから、プロジェクト構築→コンポーネント抽象→マルチクラウドデプロイ→状態管理→GitOpsのフルパイプライン実戦を解説します。


コア概念

概念 説明
Pulumi プログラミング言語でインフラを定義するIaCツール
Stack 同一プロジェクトの異なる環境(dev/staging/prod)
Resource クラウドリソースの抽象表現
ComponentResource カスタムコンポーネント、複数のResourceをカプセル化
Provider クラウドプロバイダープラグイン(AWS/GCP/Azure/K8s)
State インフラ状態の永続化ストレージ
Output 非同期値、リソース作成後のプロパティを表す
Config Stackレベルの設定キーバリューペア

問題分析:Pulumi IaCの5つの課題

  1. 学習曲線:HCLからTypeScriptへの移行には思考の転換が必要
  2. Output処理:Outputは直接文字列として使えない、非同期チェーンの理解が必要
  3. 状態管理:状態ファイルの破損や競合からの復旧
  4. コンポーネント設計:再利用可能なインフラコンポーネントの抽象化方法
  5. マルチクラウドオーケストレーション:異なるクラウドプロバイダーリソース間の依存と調整

ステップバイステップ:5つのPulumi IaCパターン

パターン1:Pulumiプロジェクトと基本リソース

// index.ts - Pulumiプロジェクトエントリポイント
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.get('environment') || 'dev';
const vpcCidr = config.get('vpcCidr') || '10.0.0.0/16';

const vpc = new aws.ec2.Vpc('main-vpc', {
  cidrBlock: vpcCidr,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  tags: {
    Name: `${environment}-vpc`,
    Environment: environment,
  },
});

const publicSubnets = [0, 1, 2].map((i) => {
  return new aws.ec2.Subnet(`public-${i}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${i}.0/24`,
    availabilityZone: aws.getAvailabilityZonesOutput().names[i],
    mapPublicIpOnLaunch: true,
    tags: { Name: `${environment}-public-${i}`, Environment: environment },
  });
});

const privateSubnets = [0, 1, 2].map((i) => {
  return new aws.ec2.Subnet(`private-${i}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${i + 10}.0/24`,
    availabilityZone: aws.getAvailabilityZonesOutput().names[i],
    tags: { Name: `${environment}-private-${i}`, Environment: environment },
  });
});

const cluster = new aws.ecs.Cluster('main-cluster', {
  tags: { Name: `${environment}-cluster`, Environment: environment },
});

const sg = new aws.ec2.SecurityGroup('app-sg', {
  vpcId: vpc.id,
  ingress: [
    { protocol: 'tcp', fromPort: 80, toPort: 80, cidrBlocks: ['0.0.0.0/0'] },
    { protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: ['0.0.0.0/0'] },
  ],
  egress: [
    { protocol: '-1', fromPort: 0, toPort: 0, cidrBlocks: ['0.0.0.0/0'] },
  ],
  tags: { Name: `${environment}-app-sg`, Environment: environment },
});

export const vpcId = vpc.id;
export const clusterArn = cluster.arn;
export const securityGroupId = sg.id;

パターン2:ComponentResourceコンポーネント抽象

// components/ecs-service.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';

interface EcsServiceArgs {
  clusterArn: pulumi.Input<string>;
  subnets: pulumi.Input<string>[];
  securityGroups: pulumi.Input<string>[];
  imageName: pulumi.Input<string>;
  cpu: number;
  memory: number;
  port: number;
  desiredCount: number;
  environment: string;
}

export class EcsService extends pulumi.ComponentResource {
  public readonly serviceArn: pulumi.Output<string>;
  public readonly albDns: pulumi.Output<string>;

  constructor(name: string, args: EcsServiceArgs, opts?: pulumi.ComponentResourceOptions) {
    super('custom:ecs:Service', name, {}, opts);

    const alb = new aws.lb.LoadBalancer(`${name}-alb`, {
      subnets: args.subnets,
      securityGroups: args.securityGroups,
      internal: false,
      loadBalancerType: 'application',
      tags: { Name: `${args.environment}-${name}-alb`, Environment: args.environment },
    }, { parent: this });

    const targetGroup = new aws.lb.TargetGroup(`${name}-tg`, {
      port: args.port,
      protocol: 'HTTP',
      vpcId: args.subnets[0],
      targetType: 'ip',
      healthCheck: {
        path: '/health',
        interval: 30,
        timeout: 5,
        healthyThreshold: 2,
        unhealthyThreshold: 3,
      },
      tags: { Name: `${args.environment}-${name}-tg`, Environment: args.environment },
    }, { parent: this });

    const listener = new aws.lb.Listener(`${name}-listener`, {
      loadBalancerArn: alb.arn,
      port: 80,
      protocol: 'HTTP',
      defaultActions: [{
        type: 'forward',
        targetGroupArn: targetGroup.arn,
      }],
    }, { parent: this });

    const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: `${args.environment}-${name}`,
      networkMode: 'awsvpc',
      requiresCompatibilities: ['FARGATE'],
      cpu: args.cpu.toString(),
      memory: args.memory.toString(),
      containerDefinitions: pulumi.jsonStringify([{
        name: name,
        image: args.imageName,
        essential: true,
        portMappings: [{ containerPort: args.port, protocol: 'tcp' }],
        logConfiguration: {
          logDriver: 'awslogs',
          options: {
            'awslogs-group': `/ecs/${args.environment}-${name}`,
            'awslogs-region': 'us-east-1',
            'awslogs-stream-prefix': 'ecs',
          },
        },
      }]),
    }, { parent: this });

    const service = new aws.ecs.Service(`${name}-svc`, {
      cluster: args.clusterArn,
      desiredCount: args.desiredCount,
      launchType: 'FARGATE',
      taskDefinition: taskDefinition.arn,
      networkConfiguration: {
        awsvpcConfiguration: {
          subnets: args.subnets,
          securityGroups: args.securityGroups,
          assignPublicIp: true,
        },
      },
      loadBalancers: [{
        targetGroupArn: targetGroup.arn,
        containerName: name,
        containerPort: args.port,
      }],
    }, { parent: this });

    this.serviceArn = service.id;
    this.albDns = alb.dnsName;

    this.registerOutputs({
      serviceArn: this.serviceArn,
      albDns: this.albDns,
    });
  }
}

パターン3:マルチクラウドデプロイ

// multi-cloud.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as gcp from '@pulumi/gcp';

const config = new pulumi.Config();
const cloud = config.get('cloud') || 'aws';

function createAwsInfrastructure() {
  const bucket = new aws.s3.Bucket('data-bucket', {
    forceDestroy: true,
    tags: { Name: 'data-bucket' },
  });

  const db = new aws.rds.Instance('database', {
    engine: 'postgres',
    engineVersion: '16.1',
    instanceClass: 'db.t3.micro',
    allocatedStorage: 20,
    username: 'admin',
    password: config.requireSecret('dbPassword'),
    skipFinalSnapshot: true,
  });

  return { bucketArn: bucket.arn, dbEndpoint: db.endpoint };
}

function createGcpInfrastructure() {
  const bucket = new gcp.storage.Bucket('data-bucket', {
    location: 'US',
    forceDestroy: true,
  });

  const db = new gcp.sql.DatabaseInstance('database', {
    databaseVersion: 'POSTGRES_16',
    settings: { tier: 'db-f1-micro' },
    deletionProtection: false,
  });

  return { bucketName: bucket.name, dbConnection: db.connectionName };
}

const infra = cloud === 'aws' ? createAwsInfrastructure() : createGcpInfrastructure();

export const bucketOutput = 'bucketArn' in infra ? infra.bucketArn : infra.bucketName;
export const dbOutput = 'dbEndpoint' in infra ? infra.dbEndpoint : infra.dbConnection;

パターン4:状態管理とディザスタリカバリ

// pulumi.state-config.ts
// S3バックエンドで状態を保存
const backendConfig = {
  url: 's3://pulumi-state-bucket/my-project',
};

// pulumi stack init my-project-dev
// pulumi config set aws:region us-east-1
// pulumi up
# 状態ロック競合の解決
pulumi stack import --file backup-state.json

# 状態ファイルのバックアップとリカバリ
pulumi stack export --file state-backup-$(date +%Y%m%d).json

# 強制アンロック(注意して使用)
pulumi stack unlock

# 状態を実際のリソースと同期
pulumi refresh

# 全リソースの破棄
pulumi destroy
// 状態ドリフト検出
import * as pulumi from '@pulumi/pulumi';

pulumi.runtime.setOptions({
  stackTransform: (args) => {
    args.resource.options.protect = true;
    return Promise.resolve(args);
  },
});

パターン5:Pulumi GitOpsと自動化

# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  preview:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: my-project-staging
          comment-on-pr: true
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}

  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    needs: []
    steps:
      - uses: actions/checkout@v4
      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: my-project-prod
          skip-install: false
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}
// Policy as Code - コンプライアンスチェック
import * as pulumi from '@pulumi/pulumi';
import { PolicyPack, validateResourceOfType } from '@pulumi/policy';

new PolicyPack('infra-policies', {
  policies: [
    {
      name: 'no-public-egress',
      description: 'Security groups must not allow unrestricted egress',
      enforcementLevel: 'mandatory',
      validateResource: validateResourceOfType(aws.ec2.SecurityGroup, (sg, args, reportViolation) => {
        for (const rule of sg.egress || []) {
          if (rule.cidrBlocks?.includes('0.0.0.0/0')) {
            reportViolation('Security group allows unrestricted egress');
          }
        }
      }),
    },
    {
      name: 'encryption-required',
      description: 'S3 buckets must have encryption enabled',
      enforcementLevel: 'mandatory',
      validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args, reportViolation) => {
        if (!bucket.serverSideEncryptionConfiguration) {
          reportViolation('S3 bucket must have server-side encryption enabled');
        }
      }),
    },
    {
      name: 'tag-compliance',
      description: 'All resources must have required tags',
      enforcementLevel: 'advisory',
      validateResource: (args, reportViolation) => {
        const tags = args.props.tags || {};
        if (!tags.Environment) {
          reportViolation('Resource missing required tag: Environment');
        }
      },
    },
  ],
});

よくある落とし穴

落とし穴1:Outputを文字列として使えない

// ❌ 間違い:Outputを直接連結
const url = `https://${alb.dnsName}/api`;  // 型エラー

// ✅ 正しい:pulumi.interpolateまたはapplyを使用
const url = pulumi.interpolate`https://${alb.dnsName}/api`;
// または
const url = alb.dnsName.apply(dns => `https://${dns}/api`);

落とし穴2:リソース依存の未宣言

// ❌ 間違い:暗黙的依存、作成順序が間違う可能性
const db = new aws.rds.Instance('db', { ... });
const app = new aws.ecs.Service('app', {
  environment: [{ name: 'DB_HOST', value: db.endpoint }],  // 暗黙的依存
});

// ✅ 正しい:依存を明示的に宣言
const app = new aws.ecs.Service('app', {
  environment: [{ name: 'DB_HOST', value: db.endpoint }],
}, { dependsOn: [db] });

落とし穴3:Secretの平文保存

// ❌ 間違い:パスワードが平文
const db = new aws.rds.Instance('db', {
  password: 'my-password-123',
});

// ✅ 正しい:Config Secretを使用
const password = config.requireSecret('dbPassword');
const db = new aws.rds.Instance('db', {
  password: password,
});

落とし穴4:リソース保護が未設定

// ❌ 間違い:本番データベースが無保護、誤削除の可能性
const db = new aws.rds.Instance('prod-db', {
  skipFinalSnapshot: true,
});

// ✅ 正しい:保護を有効化
const db = new aws.rds.Instance('prod-db', {
  skipFinalSnapshot: false,
  finalSnapshotIdentifier: 'prod-db-final',
}, { protect: true });

落とし穴5:状態ファイルが暗号化されていない

# ❌ 間違い:ローカルファイルバックエンド、暗号化なし
pulumi login --local

# ✅ 正しい:Pulumi Cloudまたは暗号化S3バックエンドを使用
pulumi login
# または
pulumi login s3://pulumi-state-bucket?region=us-east-1

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 Cannot read property of Output Outputでapply未使用 pulumi.interpolateまたはapplyを使用
2 Resource already exists 状態と実際が不一致 pulumi importで既存リソースをインポート
3 Conflict: stack is locked 状態ロック競合 pulumi stack unlock
4 403 Access Denied AWS認証情報が無効 AWS_ACCESS_KEY_ID設定を確認
5 Provider credential failed クラウドプロバイダー認証失敗 Provider設定を検証
6 Destroy failed: protected resource リソースが保護されている pulumi state unprotect
7 Stack not found Stackが存在しない pulumi stack initで作成
8 Plugin not found Providerプラグインが不足 pulumi plugin install
9 Drift detected 状態と実際のドリフト pulumi refreshで同期
10 Secret exposed in state Secretが暗号化されていない requireSecretと暗号化バックエンドを使用

高度な最適化

  1. Pulumi AI:自然言語記述からインフラコードを自動生成
  2. Automation API:アプリケーションにPulumiを組み込み、セルフサービスプラットフォームを実現
  3. Pulumi Deployments:Gitプッシュで自動デプロイをトリガー
  4. CrossGuard:Policy as Codeコンプライアンスチェック
  5. Pulumi ESC:環境シークレットと設定の一元管理

比較分析

次元 Pulumi Terraform CDK for Terraform AWS CDK
言語 TS/Python/Go/C# HCL TS/Python/Java TS/Python/Java
状態管理 Pulumi Cloud/S3 S3/Consul/Cloud Terraform Cloud CloudFormation
テスト ユニット+インテグレーション terraform test CDKテスト CDKテスト
マルチクラウド 80+プロバイダー 3000+プロバイダー 3000+プロバイダー AWSのみ
コンポーネント再利用 プログラミング言語ネイティブ Module CDK Construct Construct
学習曲線 中高

まとめ:Pulumiは本物のプログラミング言語でインフラを定義し、IaCにループ、条件、型チェック、ユニットテストの力をもたらします。ComponentResourceコンポーネント抽象+Output非同期チェーン+Policy as Codeコンプライアンスチェックの三位一体により、インフラコードが「設定ファイル」から「エンジニアリングコード」へ進化。2026年のPulumi AIとAutomation APIの成熟により、インフラ管理がよりインテリジェントで自動化されています。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#基础设施即代码#Pulumi#IaC#TypeScript IaC#2026#DevOps