【テクニカル・上級編】Pulumi Dynamic Providersでカスタムリソースを自作する:API連携とプロビジョニングの自動化 – インフラ構成管理(IaC)活用バイブル

Pulumi Dynamic Providersの深淵:既存プロバイダの限界を突破し、あらゆるSaaS・社内APIを「宣言的管理」の支配下に置く方法

インフラストラクチャ・アズ・コード(IaC)の領域において、Terraformや既存のPulumiプロバイダが提供するリソースカタログの網羅性は日増しに高まっている。しかし、SREとしてのキャリアが長ければ長いほど、次のような「絶望の壁」にぶぶあたった経験があるはずだ。

  • 「社内のレガシーな自家製プロビジョニングAPIを、KubernetesやAWSと同一のパイプラインで安全に管理したい」
  • 「新進気鋭のSaaSが提供するAPIには公式Terraformプロバイダがなく、かといって脆弱なBashスクリプトや`null_resource`の海に身を落としたくない」
  • 「特定のエンドポイントに対して、リトライや状態検証を含めた極めて複雑なステート遷移を強制させたい」

既存の枠組みでは、ここで妥協が生じる。だが、本記事の読者であるあなたに必要なのは妥協ではない。あらゆる外部システムをPulumiのステート管理エンジンに直結させ、宣言的かつ冪等に制御する「Dynamic Providers(動的プロバイダ)」の極限の活用法だ。

本稿では、Pulumiの内部アーキテクチャの理解を前提に、CRUDライフサイクルの完全実装、API連携における致命的なアンチパターン回避、そしてプロダクション環境に耐えうる堅牢なカスタムリソースの設計思想を、コードの髄まで解説する。

—

1. 内部アーキテクチャの理解:Dynamic Providerはどう動いているのか

多くのエンジニアは、Dynamic Providerを「ちょっとした便利なスクリプト実行ラッパー」と誤解している。しかし、その実態はPulumiエンジンと外部APIの通訳を行う、gRPCベースのブリッジプロセスである。

[Pulumi Engine] –(gRPC / JSON-RPC)–> [Node.js / Python Runtime]
│
[Dynamic Provider]
│
[External SaaS API]

1. Engineとプロセスの分離: Pulumiエンジン(Go製)は、TypeScriptやPythonで書かれたDynamic Providerのコードを別プロセスとして起動する。
2. Stateのシリアライゼーション: リソースの入力(Inputs)と出力(Outputs)は、エンジンとプロバイダの間でシリアライズされ渡される。
3. ライフサイクルのマッピング: ユーザーが定義したクラスのメソッド(`create`, `read`, `update`, `delete`)が、エンジンの計画(Plan)および適用(Apply)フェーズに応じて非同期に呼び出される。

このアーキテクチャを理解していれば、「プロバイダコード内でグローバル変数を状態保持に使ってはならない(プロセスが再起動されるため)」や、「ネットワークタイムアウトや接続断に対するリトライ&サーキットブレーカーの自前実装が必須である」という真理にたどり着くはずだ。

—

2. ハンズオン:社内ドメイン管理APIを制御するカスタムリソースの実装

ここでは、架空の社内ドメイン管理SaaS(社内IPAM / DNSシステム)を操作するカスタムリソースをTypeScriptで実装する。

要件は以下の通り:

  • ドメイン名と紐づくIPアドレスを登録・更新・削除する。
  • APIは最終的な整合性(Eventual Consistency)を持つため、作成後のポーリングによる状態確認が必要。
  • 完全な冪等性とエラーハンドリングを担保する。

プロジェクト構成と型定義

まずは、リソースの入力(Inputs)と出力(Outputs)の型を厳密に定義する。TypeScriptの型システムを最大限に活用し、実行時エラーをコンパイル時に駆逐する。

import as pulumi from “@pulumi/pulumi”;
import as undici from “undici”; // 高速でモダンなHTTPクライアント

// リソース作成時の入力パラメータ
export interface InternalDnsRecordArgs {
domain: pulumi.Input;
ipAddress: pulumi.Input;
ttl?: pulumi.Input;
}

// リソースが保持する実際のステート(出力)
export interface InternalDnsRecordState {
domain: string;
ipAddress: string;
ttl: number;
recordId: string; // API側が発番する一意のID
}

Dynamic Resource Providerのコア実装

次に、`pulumi.dynamic.ResourceProvider` インターフェースを実装するクラスを構築する。ここがエンジニアリングの腕の見せ所だ。

class InternalDnsProvider implements pulumi.dynamic.ResourceProvider {
private apiEndpoint = process.env.INTERNAL_DNS_API_ENDPOINT || “https://api.internal.net/v1”;
private apiToken = process.env.INTERNAL_DNS_API_TOKEN || “”;

// 【Create】リソースのプロビジョニング
async create(inputs: InternalDnsRecordState): Promise {
console.log(`[Create] Creating DNS record for ${inputs.domain} -> ${inputs.ipAddress}`);

const response = await undici.request(`${this.apiEndpoint}/records`, {
method: “POST”,
headers: {
“Authorization”: `Bearer ${this.apiToken}`,
“Content-Type”: “application/json”,
},
body: JSON.stringify({
domain: inputs.domain,
ip_address: inputs.ipAddress,
ttl: inputs.ttl || 300,
}),
});

if (response.statusCode !== 201) {
const body = await response.body.text();
throw new Error(`Failed to create DNS record: ${response.statusCode} – ${body}`);
}

const data = await response.body.json() as { id: string; domain: string; ip_address: string; ttl: number };

// API側が返す一意のIDをIDとしてPulumiに返却する
return {
id: data.id,
outs: {
domain: data.domain,
ipAddress: data.ip_address,
ttl: data.ttl,
recordId: data.id,
},
};
}

// 【Read】実世界のステート取得(ドリフト検出に必須)
async read(id: string, props: InternalDnsRecordState): Promise {
console.log(`[Read] Fetching DNS record ID: ${id}`);

const response = await undici.request(`${this.apiEndpoint}/records/${id}`, {
method: “GET”,
headers: {
“Authorization”: `Bearer ${this.apiToken}`,
},
});

if (response.statusCode === 404) {
// リソースが外部で手動削除されていた場合、Pulumiに「存在しない」と伝える(ステートからの安全なパージ)
return { id: “”, outs: {} as any };
}

if (response.statusCode !== 200) {
throw new Error(`Failed to read DNS record ${id}: ${response.statusCode}`);
}

const data = await response.body.json() as { id: string; domain: string; ip_address: string; ttl: number };

return {
id: data.id,
outs: {
domain: data.domain,
ipAddress: data.ip_address,
ttl: data.ttl,
recordId: data.id,
},
};
}

// 【Update】インプレース更新の制御
async update(id: string, olds: InternalDnsRecordState, news: InternalDnsRecordState): Promise {
console.log(`[Update] Updating DNS record ID: ${id}`);

const response = await undici.request(`${this.apiEndpoint}/records/${id}`, {
method: “PUT”,
headers: {
“Authorization”: `Bearer ${this.apiToken}`,
“Content-Type”: “application/json”,
},
body: JSON.stringify({
domain: news.domain,
ip_address: news.ipAddress,
ttl: news.ttl,
}),
});

if (response.statusCode !== 200) {
const body = await response.body.text();
throw new Error(`Failed to update DNS record ${id}: ${response.statusCode} – ${body}`);
}

const data = await response.body.json() as { id: string; domain: string; ip_address: string; ttl: number };

return {
outs: {
domain: data.domain,
ipAddress: data.ip_address,
ttl: data.ttl,
recordId: data.id,
},
};
}

// 【Delete】リソースの破棄
async delete(id: string, props: InternalDnsRecordState): Promise {
console.log(`[Delete] Deleting DNS record ID: ${id}`);

const response = await undici.request(`${this.apiEndpoint}/records/${id}`, {
method: “DELETE”,
headers: {
“Authorization”: `Bearer ${this.apiToken}`,
},
});

// 404は既に削除されているとみなして正常終了(冪等性の担保)
if (response.statusCode !== 204 && response.statusCode !== 404) {
const body = await response.body.text();
throw new Error(`Failed to delete DNS record ${id}: ${response.statusCode} – ${body}`);
}
}
}

カスタムリソースクラスのラッピング

ユーザーが通常のPulumiリソースと同じ感覚で扱えるよう、クラスとしてラップする。

export class InternalDnsRecord extends pulumi.dynamic.Resource {
public readonly domain!: pulumi.Output;
public readonly ipAddress!: pulumi.Output;
public readonly ttl!: pulumi.Output;
public readonly recordId!: pulumi.Output;

constructor(name: string, args: InternalDnsRecordArgs, opts?: pulumi.CustomResourceOptions) {
super(new InternalDnsProvider(), name, {
domain: args.domain,
ipAddress: args.ipAddress,
ttl: args.ttl ?? 300,
recordId: undefined, // 初期値は未定
}, opts);
}
}

—

3. プロダクション運用における極限の最適化とハック

Dynamic Providerを現場に投入するにあたり、シニアエンジニアとして知っておくべき「地雷」と「最適化ハック」を共有する。

1. ネットワーク障害・APIレートリミットに対する堅牢性(レジリエンス)

SaaSや社内APIは、高頻度のデプロイや並行実行(`pulumi up –parallel`)によって容易にレートリミット(429 Too Many Requests)や一時的な503エラーを引き起こす。
カスタムプロバイダ内のHTTPクライアントには、必ず指数バックオフ(Exponential Backoff)とジッター付きのリトライロジックを組み込むべきである。`undici` や `axios` を使う場合でも、ネイティブのフェッチ機構に頼らず、リトライライブラリ(例: `p-retry`)でラップすることを強く推奨する。

2. シークレット情報の漏洩防止

プロバイダ内でAPIトークンや機密情報を扱う際、`console.log` やエラーメッセージにプレーンテキストとして出力してしまう事故が後を絶たない。
Pulumiのシークレット機構(`pulumi.secret`)をインプットで受け取った場合、Dynamic Provider側では解決された値(Plaintext)として渡される。そのため、ログ出力時には必ずマスキング処理を挟むコード規約をチーム全体で徹底すること。

3. `diff` メソッドの明示的実装による無駄な更新の抑止

デフォルトでは、Pulumiエンジンは入力値の変更を検知して自動的に `update` を呼ぶ。しかし、API側が小文字・大文字を正規化して返したり、デフォルト値を自動付与する場合、「実際には変更がないのに差分(Diff)が生じる」という無限ループに陥ることがある。
これを防ぐため、`ResourceProvider` に `diff` メソッドを実装し、真の差分のみを判定させよ。

async diff(id: string, olds: InternalDnsRecordState, news: InternalDnsRecordState): Promise {
const changes = olds.ipAddress !== news.ipAddress ||
olds.domain !== news.domain ||
olds.ttl !== news.ttl;

return {
changes: changes,
replaces: olds.domain !== news.domain, // ドメインが変わる場合のみリソースの作り直し(Replace)を強制
deleteBeforeReplace: true,
};
}

—

4. 結び:コードこそがインフラの唯一の真実である

既存プロバイダがないという理由で、インフラの自動化を諦めたり、シェルスクリプトや場当たり的なTerraformの `local-exec` に逃げる時代は終わった。

Pulumi Dynamic Providersを使いこなすことは、「あらゆる外部システムを自社のコードベースの厳密な型システムとステート管理の支配下に置く」ことを意味する。それは単なるツールのハックではなく、組織全体のインフラ信頼性を次の次元へと引き上げるアーキテクトの特権である。

さあ、今すぐエディターを開き、これまでブラックボックスだった社内APIをあなたの宣言的コードの統治下へ引きずり出そう。

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