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

Pulumi Dynamic Providersの深淵:既存プロバイダの限界を超えるAPI連携の自動化

こんにちは。テックリードの私だ。

日々のクラウドインフラ構築において、TerraformやPulumiの公式プロバイダは非常に優秀だ。AWS、GCP、Kubernetes、そして主要なSaaSまで、大抵のリソースはワンライナーで宣言的にプロビジョニングできる。

だが、現実のプロジェクトはどうだ?
「社内ニッチな基幹API」「新進気鋭のSaaS」「自社製オンプレミス連携ゲートウェイ」……こうした独自のインフラストラクチャやAPIに直面した瞬間、既存のプロバイダ網はプツリと途切れる。

「じゃあ、シェルスクリプトやカスタムCLIをラップしてCI/CDで無理やり叩くか?」
……待て。そんなことをすれば、「状態の管理(State)」「冪等性(Idempotency)」「変更履歴の追跡」という、IaCが血眼になって勝ち取ってきた全ての恩恵が水泡に帰す。CI/CDパイプラインは汚れ、リソースのドリフト(乖離)検知は不可能になり、夜間障害でPagerDutyが鳴り響くことになる。

この絶望的なギャップを埋める唯一にして最強のカードが、Pulumi Dynamic Providersだ。

今回は、あらゆるAPIをPulumiのファーストクラス市民(宣言的リソース)へと昇華させ、インフラのライフサイクルに完全に組み込むための実装の極意を、ハンズオン形式でコードの深部まで解説しよう。

—

1. 開発スピードを極限まで高める:Pulumi開発者のためのエコシステム

本題に入る前に、プロの現場で「手薬すりながら」使われている開発環境のチューニングについて共有しておく。これなしでは日々のインフラ開発はただの苦行だ。

絶対入れるべきVS Code / IDEプラグイン

  • Pulumi (Official): 型補完、ホバーでのリソースプレビュー、スタック情報の可視化に必須。
  • Error Lens: エラーや型ミスマッチをコード行の末尾にインライン表示。TypeScript/Pythonでの記述ミスをゼロにする。
  • GitLens: 「なぜこのプロパティが追加されたのか」をコードから一瞬でGit履歴を引いて特定する。

チーム開発のための設定共有(`.vscode/settings.json`)

インフラコードの品質を担保するため、チーム全員の環境でフォーマットとリントを強制せよ。

{
“editor.formatOnSave”: true,
“editor.codeActionsOnSave”: {
“source.fixAll.eslint”: “explicit”
},
“typescript.suggest.completeFunctionCalls”: true,
“files.associations”: {
“Pulumi..yaml”: “yaml”
}
}

—

2. Dynamic Providersのアーキテクチャとライフサイクル

Dynamic Providerは、Pulumiのエンジンと、あなたが書くカスタムロジック(TypeScriptやPythonなど)をRPC(gRPC)で接続する仕組みだ。これにより、任意のAPI呼び出しをPulumiのトランザクション(CRUD操作)にマッピングできる。

実装にあたっては、以下の4つのライフサイクルメソッドを完全に理解し、「完全に冪等な状態」を担保するコードを書く必要がある。

1. `create`: リソースが存在しない状態から、APIを叩いて新規作成し、`id` と `outs`(実リソースの状態)を返す。
2. `read`: 現在のクラウド/API側の実状態をフェッチする。ドリフト検知の要。
3. `update`: プロパティに変更があった場合、APIを叩いて差分を適用する。
4. `delete`: リソース破棄時にAPIを叩いてクリーンアップする。

—

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

今回は例として、「社内DNS/ドメイン管理SaaS(架空の社内API)」をPulumiから直接プロビジョニングするカスタムリソースをTypeScriptで実装する。

プロジェクト構成

.
├── package.json
├── tsconfig.json
├── index.ts
└── provider.ts # Dynamic Providerの実装

① Dynamic Providerの実装 (`provider.ts`)

ここが今回の核心だ。APIクライアントのラップと、CRUDの各メソッドを実装する。

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

// 1. リソースの入力プロパティ(ユーザーがPulumiコードで指定する値)
export interface DomainRecordArgs {
domain: string;
targetIp: string;
recordType: “A” | “CNAME” | “TXT”;
}

// 2. リソースの出力プロパティ(APIから返却される実ステータス)
export interface DomainRecordState extends DomainRecordArgs {
recordId: string;
}

// 3. 実際のAPIリクエストをハンドリングする Provider クラス
class DomainRecordProvider implements pulumi.dynamic.ResourceProvider {
private apiEndpoint = “https://api.internal.net/v1/domains”;
private apiToken = process.env.INTERNAL_API_TOKEN;

private getHeaders() {
return {
Authorization: `Bearer ${this.apiToken}`,
“Content-Type”: “application/json”,
};
}

// CREATE: リソースの新規作成
async create(inputs: DomainRecordArgs): Promise {
try {
const response = await axios.post(
this.apiEndpoint,
{
domain: inputs.domain,
ip: inputs.targetIp,
type: inputs.recordType,
},
{ headers: this.getHeaders() }
);

const recordId = response.data.id;

// Pulumiのステートに保存される出力値を返す
return {
id: recordId,
outs: {
…inputs,
recordId,
},
};
} catch (error: any) {
throw new pulumi.RunError(`Failed to create domain record: ${error.message}`);
}
}

// READ: リソースの現状確認(ドリフト検出に必須)
async read(id: string, props: DomainRecordState): Promise {
try {
const response = await axios.get(`${this.apiEndpoint}/${id}`, {
headers: this.getHeaders(),
});

return {
id: id,
outs: {
domain: response.data.domain,
targetIp: response.data.ip,
recordType: response.data.type,
recordId: id,
},
};
} catch (error: any) {
// リソースが既に外部で削除されていた場合等のハンドリング
if (error.response && error.response.status === 404) {
return { id: “” }; // 空IDを返すとPulumiは「削除された」と判定し再作成する
}
throw new pulumi.RunError(`Failed to read domain record: ${error.message}`);
}
}

// UPDATE: 差分の適用(冪等性を意識したPUT/PATCH)
async update(id: string, olds: DomainRecordState, news: DomainRecordArgs): Promise {
try {
await axios.put(
`${this.apiEndpoint}/${id}`,
{
domain: news.domain,
ip: news.targetIp,
type: news.recordType,
},
{ headers: this.getHeaders() }
);

return {
outs: {
…news,
recordId: id,
},
};
} catch (error: any) {
throw new pulumi.RunError(`Failed to update domain record: ${error.message}`);
}
}

// DELETE: リソースの削除
async delete(id: string, props: DomainRecordState): Promise {
try {
await axios.delete(`${this.apiEndpoint}/${id}`, {
headers: this.getHeaders(),
});
} catch (error: any) {
// 404はすでに消えているとみなして許容する
if (error.response && error.response.status !== 404) {
throw new pulumi.RunError(`Failed to delete domain record: ${error.message}`);
}
}
}
}

// 4. カスタムリソースのラッパークラス(ユーザーが呼び出すコンポーネント)
export class DomainRecord extends pulumi.dynamic.Resource {
public readonly domain!: pulumi.Output;
public readonly targetIp!: pulumi.Output;
public readonly recordType!: pulumi.Output;
public readonly recordId!: pulumi.Output;

constructor(name: string, args: DomainRecordArgs, opts?: pulumi.CustomResourceOptions) {
super(new DomainRecordProvider(), name, args, opts);
}
}

② ユーザーコードでの利用 (`index.ts`)

作成したカスタムリソースは、通常のAWSリソース等と全く同じように、宣言的かつ型安全に扱うことができる。

import as pulumi from “@pulumi/pulumi”;
import { DomainRecord } from “./provider”;

// 社内APIを通じてDNSレコードを宣言的にプロビジョニング
const myAppDns = new DomainRecord(“my-app-dns”, {
domain: “app.internal.corp”,
targetIp: “192.168.10.50”,
recordType: “A”,
});

// 出力エクスポート
export const dnsRecordId = myAppDns.recordId;
export const registeredDomain = myAppDns.domain;

—

4. プロの知見:実運用でハマる「アンチパターン」と回避策

Dynamic Providersを本番環境(Production)に投入する際、多くのエンジニアが踏み抜く地雷がある。これを事前に回避せよ。

アンチパターン1: ネットワークタイムアウトとリトライの欠如

社内APIやSaaSは、クラウド大手のマネージドサービスに比べて圧倒的に不安定だ。ネットワークの瞬断やレートリミット(429 Too Many Requests)で容易に死ぬ。

  • 対策: `axios` 単体で使わず、必ず `axios-retry` などのライブラリを挟むか、指数バックオフ(Exponential Backoff)を実装したラッパー関数経由でAPIを叩け。

アンチパターン2: センシティブ情報の平文ステート保存

APIトークンや払い出されたシークレットが、PulumiのStateファイルに平文で記録される事故が後を絶たない。

  • 対策: 動的プロバイダが返す `outs` にパスワードやシークレットを含める場合、必ず `pulumi.secret()` でラップするか、ステートに残すべきではない機密情報は出力値(outs)に含めない設計にすること。

アンチパターン3: `read` メソッドでの破壊的エラーハンドリング

API側の一時的な障害(500 Internal Server Error)で `read` が例外を吐いた場合、Pulumiは「リソースが消失した」と誤認して勝手に再作成を試みることがある。

  • 対策: `read` 内での非意図的なHTTPエラー(5xx系)は、リソース消失(404)と明確に区別し、即座に例外をスローしてデプロイをアボート(中断)させろ。

—

5. おわりに:IaCの領域を拡張せよ

Pulumi Dynamic Providersをマスターしたあなたにもはや「プロバイダが存在しないから自動化できない」という言い訳は通用しない。
社内のレガシーシステム、IoTデバイスのプロビジョニング、SaaSのライセンス自動割り当て――世界中のあらゆるAPIをコードの支配下に置き、インフラストラクチャの境界線を拡張してほしい。

真のSRE、真のインフラエンジニアであれば、無いなら作ればいい。
コードですべてを制御する快感を、存分に味わってくれ。

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