【テクニカル・上級編】Pulumi Component Resources(カスタムコンポーネント)の作り方と設計パターン – インフラ構成管理(IaC)活用バイブル

Pulumi Component Resourcesの真髄:組織的インフラ腐敗を防ぐ要塞の築き方

インフラストラクチャ・インフラストラクチャ・アズ・コード(IaC)の黎明期、我々は「テキストによるリソース定義」を手に入れたことに歓喜した。しかし、Terraformの巨大なモジュール群や、コピペの山と化したYAMLの海に溺れ、「モジュールを書くたびに破綻する依存関係」「暗黙のグローバルステート」「レビューのたびに露見するベストプラクティスの欠落」に絶望した者も少なくないはずだ。

汎用プログラミング言語(TypeScript、Python、Goなど)をそのままIaCの駆動系として使えるPulumiは、このカオスに対する決定的な解となり得る。特にComponent Resources(カスタムコンポーネント)を極限まで使いこなすことで、インフラストラクチャを単なる「APIの羅列」から「厳格に型付けされた自社専用のPaaS基盤」へと昇華させることができる。

本稿では、Pulumiのコンポーネントリソースの内部アーキテクチャから、実戦投入に耐えうるTypeScriptでの実装、そしてテスト駆動型インフラ構築の極意まで、現場の修羅場をくぐり抜けてきたアーキテクトの視点から余すところなく解説する。

—

1. なぜ「普通のモジュール」ではスケールしないのか?

多くのエンジニアは、TerraformのModuleや、単なる関数によるラッパーを「再利用可能なインフラ」と勘違いしている。だが、それらは本質的な解決になっていない。

従来の抽象化が抱える構造的欠陥

1. ステートの汚染とURNの破綻: 単なる関数でリソース群をラップした場合、Pulumiのエンジン(Resource Model)から見て、それらが「論理的なまとまり」として認識されない。結果として、依存関係のグラフ(DAG)がフラットになりすぎ、リソースの移動やリネーム時にステートファイルが破損するリスクが跳ね上がる。
2. カプセル化の欠如: 内部でどのようなセキュリティグループやIAMロールが生成されているのかが呼び出し側に露出する。結果、利用者が勝手に「ちょっと設定を書き換える」というアンチパターンが蔓延し、組織全体のセキュリティガバナンスが崩壊する。
3. 型の安全性の欠如: 動的な文字列や未検証の引数がそのままクラウドAPIへ渡され、デプロイの最終段階(Apply時)までエラーに気づけない。

Component Resourcesの本質

Pulumiの `ComponentResource` は、単なるコードの共通化ではない。「独自のライフサイクルを持ち、内部の具象リソースを隠蔽し、親リソースから完全に独立したURN(Uniform Resource Name)のスコープを形成する抽象境界」である。

内部的には、親コンポーネントのURNがプレフィックスとなり、子リソースのURNは `urn:pulumi:stack::project::mypkg:v1:Database$aws:rds/instance:Instance::my-db` のように階層化される。これにより、インフラストラクチャのオブジェクト指向設計が可能になる。

—

2. 実装ハンズオン:セキュアな「VPC + 冗長NAT Gateway」コンポーネントの構築

ここでは、AWS上に「ベストプラクティスが強制されたVPC」を構築するカスタムコンポーネントをTypeScriptで実装する。このコンポーネントは、単一のクラスとして定義し、呼び出し側には最小限の入力(CIDRと環境名)だけを要求する。

ディレクトリ構造

.
├── package.json
├── tsconfig.json
├── index.ts
└── components/
└── secure-vpc.ts

実装コード: `components/secure-vpc.ts`

import as pulumi from “@pulumi/pulumi”;
import as aws from “@pulumi/aws”;

// 1. コンポーネントが受け取る入力を厳格に定義(TypeScriptの型システムをフル活用)
export interface SecureVpcArgs {
/ VPC全体のCIDRブロック /
cidrBlock: string;
/ 環境名 (staging, production等) /
environment: string;
/ パブリックサブネットのCIDRリスト /
publicSubnetCidrs: string[];
/ プライベートサブネットのCIDRリスト /
privateSubnetCidrs: string[];
}

export class SecureVpc extends pulumi.ComponentResource {
// 外部に公開する出力プロパティ(Output型でラップすることで遅延評価を保証)
public readonly vpcId: pulumi.Output;
public readonly publicSubnetIds: pulumi.Output;
public readonly privateSubnetIds: pulumi.Output;
public readonly natGatewayEips: pulumi.Output;

constructor(name: string, args: SecureVpcArgs, opts?: pulumi.ComponentResourceOptions) {
// 第1引数はパッケージ名とタイプ名。URNの階層構造を決定づける極めて重要な要素。
super(“custom:infra:SecureVpc”, name, args, opts);

// — VPCの作成 —
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: args.cidrBlock,
enableDnsHostnames: true,
enableDnsSupport: true,
tags: {
Name: `${name}-vpc-${args.environment}`,
Environment: args.environment,
ManagedBy: “Pulumi”,
},
}, { parent: this }); // 親子関係を明示することで依存グラフを構築

this.vpcId = vpc.id;

// — インターネットゲートウェイ —
const igw = new aws.ec2.InternetGateway(`${name}-igw`, {
vpcId: vpc.id,
tags: { Name: `${name}-igw` },
}, { parent: this });

// — パブリックサブネット & ルートテーブル —
const publicSubnets: aws.ec2.Subnet[] = [];
const azs = [“ap-northeast-1a”, “ap-northeast-1c”, “ap-northeast-1d”]; // 実際の運用では動的取得を推奨

args.publicSubnetCidrs.forEach((cidr, i) => {
const subnet = new aws.ec2.Subnet(`${name}-public-${i}`, {
vpcId: vpc.id,
cidrBlock: cidr,
availabilityZone: azs[i % azs.length],
mapPublicIpOnLaunch: true,
tags: { Name: `${name}-public-${i}`, Type: “Public” },
}, { parent: this });
publicSubnets.push(subnet);
});

this.publicSubnetIds = pulumi.output(publicSubnets.map(s => s.id));

const publicRt = new aws.ec2.RouteTable(`${name}-public-rt`, {
vpcId: vpc.id,
routes: [{
cidrBlock: “0.0.0.0/0”,
gatewayId: igw.id,
}],
tags: { Name: `${name}-public-rt` },
}, { parent: this });

publicSubnets.forEach((subnet, i) => {
new aws.ec2.RouteTableAssociation(`${name}-public-rta-${i}`, {
subnetId: subnet.id,
routeTableId: publicRt.id,
}, { parent: this });
});

// — プライベートサブネット & 冗長NAT Gateway —
const privateSubnets: aws.ec2.Subnet[] = [];
const natEips: string[] = [];
const privateRts: aws.ec2.RouteTable[] = [];

args.privateSubnetCidrs.forEach((cidr, i) => {
const subnet = new aws.ec2.Subnet(`${name}-private-${i}`, {
vpcId: vpc.id,
cidrBlock: cidr,
availabilityZone: azs[i % azs.length],
tags: { Name: `${name}-private-${i}`, Type: “Private” },
}, { parent: this });
privateSubnets.push(subnet);

// 【ベストプラクティス】可用性を担保するため、各AZにEIPとNAT Gatewayを配置
const eip = new aws.ec2.Eip(`${name}-nat-eip-${i}`, {
domain: “vpc”,
tags: { Name: `${name}-nat-eip-${i}` },
}, { parent: this });
natEips.push(eip.publicIp);

const natGw = new aws.ec2.NatGateway(`${name}-nat-${i}`, {
allocationId: eip.id,
subnetId: publicSubnets[i % publicSubnets.length].id,
tags: { Name: `${name}-nat-${i}` },
}, { parent: this });

const privateRt = new aws.ec2.RouteTable(`${name}-private-rt-${i}`, {
vpcId: vpc.id,
routes: [{
cidrBlock: “0.0.0.0/0”,
natGatewayId: natGw.id,
}],
tags: { Name: `${name}-private-rt-${i}` },
}, { parent: this });
privateRts.push(privateRt);

new aws.ec2.RouteTableAssociation(`${name}-private-rta-${i}`, {
subnetId: subnet.id,
routeTableId: privateRt.id,
}, { parent: this });
});

this.privateSubnetIds = pulumi.output(privateSubnets.map(s => s.id));
this.natGatewayEips = pulumi.output(natEips);

// コンポーネントの初期化完了をエンジンに通知
this.registerOutputs({
vpcId: this.vpcId,
publicSubnetIds: this.publicSubnetIds,
privateSubnetIds: this.privateSubnetIds,
natGatewayEips: this.natGatewayEips,
});
}
}

呼び出し側のコード: `index.ts`

import as pulumi from “@pulumi/pulumi”;
import { SecureVpc } from “./components/secure-vpc”;

// 組織の標準に準拠したセキュアなVPCをわずか数行でインスタンス化
const coreVpc = new SecureVpc(“core”, {
environment: “production”,
cidrBlock: “10.100.0.0/16”,
publicSubnetCidrs: [“10.100.1.0/24”, “10.100.2.0/24”],
privateSubnetCidrs: [“10.100.64.0/24”, “10.100.65.0/24”],
});

// 出力のエクスポート
export const vpcId = coreVpc.vpcId;
export const natEips = coreVpc.natGatewayEips;

—

3. 組織内でインフラのベストプラクティスをモジュール化する設計哲学

コンポーネントを書くだけなら誰でもできる。問題は「組織全体でどう維持・配布し、破綻を防ぐか」である。生粋のアーキテクトが実践している設計原則を伝授する。

原則1: 「不変のポリシー」をコードにハードコードする

開発者に「セキュアな設定を選んでください」と委ねてはならない。コンポーネントの内部で強制すべきである。

  • パブリックS3バケットを作成させない(強制的にBlock Public Accessを有効化する)。
  • 暗号化されていないRDSインスタンスを弾く、あるいはデフォルトでAWS KMSカスタマーマネージドキーを適用する。
  • すべてのリソースにコスト配分タグ(CostCenter, Ownerなど)を自動付与する。

原則2: プライベートパッケージとしての切り出し

各プロジェクトのレポジトリにコンポーネントをベタ書きするのはアンチパターンである。
1. コンポーネント群を独立した Git リポジトリ(例: `pulumi-aws-bootstrap`)として切り出す。
2. `npm` のプライベートレジストリ(GitHub PackagesやAWS CodeArtifact)にパブリッシュする。
3. 各プロダクトチームは、バージョンを指定してパッケージをインストールする。

プロダクトチームでの利用イメージ
npm install @my-org/pulumi-aws-bootstrap

これにより、中央プラットフォームチームが基盤のセキュリティパッチやベストプラクティスの更新を中央集権的に行い、各チームは `npm update` するだけで最新のインフラ標準を取り込めるようになる。

—

4. ユニットテストの書き方:インフラをデプロイせずに検証する

「インフラのテストは実際にクラウドにデプロイしないと分からない」という神話は過去のものだ。Pulumiには強力なモック機能(Mocks API)が存在し、クラウドプロバイダに一切アクセスすることなく、コンポーネントのロジックと出力値をミリ秒単位でユニットテストできる。

Jestを用いたテストコードの実装例を示す。

テストコード: `components/secure-vpc.test.ts`

import as pulumi from “@pulumi/pulumi”;
import { SecureVpc } from “./secure-vpc”;

// Pulumiのモックを設定
pulumi.runtime.setMocks({
newResource:- (args: pulumi.runtime.MockResourceArgs): { id: string, state: Record } => {
return {
id: `${args.name}-id`,
state: {
…args.inputs,
// クラウド側で自動生成されるプロパティをモック
arn: `arn:aws:ec2:ap-northeast-1:123456789012:${args.type}/${args.name}`,
publicIp: “203.0.113.50”,
},
};
},
call: (args: pulumi.runtime.MockCallArgs): Record => {
return args.inputs;
},
});

describe(“SecureVpc Component”, () => {
let vpcComponent: SecureVpc;

beforeAll(() => {
vpcComponent = new SecureVpc(“test-vpc”, {
environment: “test”,
cidrBlock: “10.0.0.0/16”,
publicSubnetCidrs: [“10.0.1.0/24”],
privateSubnetCidrs: [“10.0.10.0/24”],
});
});

test(“VPC IDが正しくエクスポートされていること”, (done) => {
pulumi.all([vpcComponent.vpcId]).apply(([vpcId]) => {
expect(vpcId).toBeDefined();
expect(vpcId).toContain(“test-vpc-vpc”);
done();
});
});

test(“NAT GatewayのEIPが正しく生成されていること”, (done) => {
pulumi.all([vpcComponent.natGatewayEips]).apply(([eips]) => {
expect(eips.length).toBe(1);
expect(eips[0]).toBe(“203.0.113.50”);
done();
});
});
});

このテストをCI/CDパイプライン(GitHub Actionsなど)のPRチェックに組み込むことで、「間違ったCIDR設計」「誤ったサブネット構成」をデプロイ前のコードレビューの段階で100%弾き出すことが可能になる。

—

5. エキスパートの知見:パフォーマンスとメモリ消費の最適化ハック

最後に、大規模なモノリス環境やマルチクラウド構成でPulumiを運用する際に直面する「メモリ肥大化」と「評価遅延」の罠に対する処方箋を記す。

1. `pulumi.Output` のチェーニング地獄からの脱出

非同期の依存関係を解決しようとして `output.apply(v => …)` を何重にもネストさせると、コードの可読性が致命的に低下し、コールスタックが爆発する。
複雑なプロパティの結合には、`pulumi.all()` を用いて配列として並行評価させ、型安全にdestructuring(分割代入)する手法を徹底せよ。

// アンチパターン: 深度の深いネスト
depA.apply(a => depB.apply(b => {
return createSomething(a, b);
}));

// ベストプラクティス: pulumi.allによるフラットな並行評価
pulumi.all([depA, depB]).apply(([a, b]) => {
return createSomething(a, b);
});

2. コンポーネント内のガベージコレクションとメモリ消費

数千のリソースを単一のStackで管理する場合、Node.jsのV8エンジンは膨大なメモリを消費する。カスタムコンポーネント内で作成した一時的なオブジェクトや配列は、スコープを明確にしてメモリリークを防がなければならない。
特に `ComponentResource` のコンストラクタ内で巨大なJSONファイルを読み込んでパースするような処理は、スタック全体のビルド時間を劇的に悪化させるため、静的な型定義ファイル(TypeScriptの `interface` や `const`)へ完全にコンパイル時移行させるべきである。

—

結び:インフラを「コード」から「プロダクト」へ

PulumiのComponent Resourcesを極めるとは、もはや単なる設定ファイルの記述作業ではない。それは、「自社に必要なインフラストラクチャの物理法則をコードで再定義し、開発者体験(DX)とセキュリティを極限まで高めたプラットフォームを自らの手で創り上げる行為」に他ならない。

泥臭いコピペインフラや、脆弱性に満ちたモジュールの山から組織を救い出す唯一の手段がここにある。今日からあなたのプロジェクトでも、この要塞の構築を始めてほしい。

タイトルとURLをコピーしました