Pulumi at NearMe: 真のInfrastructure as Codeを実践する
はじめに
Infrastructure as Code(IaC)はクラウドインフラの管理方法に革命をもたらしました。NearMeでは、Pulumi を主要なIaCツールとして採用するに至りました。本記事では、PulumiがNearMeのPlatform Engineeringにどのような変革をもたらし、開発者の生産性向上とインフラの信頼性改善に貢献してきたかを紹介します。インフラ定義に汎用プログラミング言語を使うことのメリットを掘り下げ、Terraform や AWS CDK といった他ツールとの比較も行います。Pulumiの命令的・宣言的プログラミングの融合は大きな柔軟性をもたらしてくれる一方、同じ結果を達成する方法が複数存在することによるコードの複雑化という課題もあります。実際の経験と知見を共有することで、Pulumi・Terraform・AWS CDKのどれを選ぶべきか迷っている方の参考になれば幸いです。
Pulumiについて
Infrastructure as Code
インフラ管理は、手動設定やクリックオペレーション、スクリプトによる自動化を経て、宣言的なInfrastructure as Codeへと進化してきました。この変遷は、以下のニーズの高まりを反映しています。
- 環境間の再現性と一貫性
- バージョン管理と変更追跡
- 自動テストとバリデーション
- スケーラブルなインフラ管理
- 自動化による人的ミスの削減
Pulumiの特徴
Pulumiは私たちのニーズに合った多くの優れた利点を提供しています。
プログラミング言語サポート
ドメイン固有言語を学ぶ必要はなく、開発者はTypeScriptのような使い慣れたプログラミング言語をそのまま使えます。
import * as aws from '@pulumi/aws';
const lambdaFunction = new aws.lambda.Function(
'lambda-function',
{
code: new pulumi.asset.FileArchive(lambdaArchive.outputPath),
sourceCodeHash: lambdaArchive.outputBase64sha256,
handler: 'function.handler',
environment: {
variables: {
/* ... */
},
},
role: lambdaRole.arn,
runtime: aws.lambda.Runtime.NodeJS20dX,
loggingConfig: {
logFormat: 'Text',
},
},
{ parent },
);
型安全性とIDEサポート
- 組み込みの型チェック
- インラインドキュメント
- コード補完
例えば、Argo WorkflowsのようなKubernetesカスタムリソースを扱う際、npmの json-schema-to-typescript を使ってJSONスキーマから型定義を自動生成できます。
import * as pulumi from '@pulumi/pulumi';
import { Primitive } from 'zod';
import { IoArgoprojWorkflowV1Alpha11 } from './__generated__/argoWorkflows';
type DeepPulumiInputType<T> = T extends Primitive
? pulumi.Input<T>
: T extends object | any[]
? {
[k in keyof T]: DeepPulumiInputType<T[k]>;
}
: T;
type ArgoWorkFlowsTemplateCrdSpecInput =
DeepPulumiInputType<IoArgoprojWorkflowV1Alpha11>;
new k8s.apiextensions.CustomResource('argo-workflow', {
apiVersion: 'argoproj.io/v1alpha1',
kind: 'WorkflowTemplate',
spec: {
entrypoint: 'main',
/* Type-safe properties */
} satisfies ArgoWorkFlowsTemplateCrdSpecInput,
});
テスト機能
Pulumiでは使い慣れたテストフレームワークを使ってテストを書くことができ、デプロイ前にインフラ設定を検証できます。これはプレビューとデプロイの両フェーズで強制適用され、即座にフィードバックが得られ、組織のポリシー準拠を保証します。
例えば、以下のコードはDynamoDBテーブルの書き込みキャパシティが64以上にならないことを保証します。
new policy.PolicyPack('dynamo-db-testing', {
policies: [
{
name: 'Minimum DynamoDB Capacity',
description: '',
enforcementLevel: 'mandatory',
validateStack: async ({ resources }, reportViolation) => {
const dynamoDbTables = resources
.filter((r) => r.isType(aws.dynamodb.Table))
.map((tb) => tb.asType(aws.dynamodb.Table));
dynamoDbTables.forEach((table) => {
if ((table?.writeCapacity || 0) >= 64) {
reportViolation(
`Unwanted write capacity ${table?.writeCapacity} for table ${table?.name}`,
);
}
});
},
},
],
});
マルチクラウドサポート
PulumiはAWS、Azure、Google Cloudなど複数のクラウドプロバイダーにわたってインフラを定義・管理できる強力なマルチクラウドサポートを提供しています。同一のプログラミング言語とツールを使えるため、異種クラウド環境での一貫したIaCアプローチが可能になり、移行やハイブリッドデプロイが容易になります。一方、AWS CDKはAWSサービス専用に設計されています。AWSエコシステム内での深い統合と豊富な機能を持ちますが、他のクラウドプラットフォームへのネイティブサポートがなく、マルチクラウド環境での適用性に限界があります。
既存リソースの取り込み
Pulumiは既存のクラウドリソースをインフラコードベースに取り込む点で優れています。pulumi import というCLIコマンド一つで、手動や他ツールで作成されたリソースを管理するためのPulumiコードを自動生成できます。この機能により、管理外のリソースをバージョン管理と一貫した管理下に置くプロセスが効率化されます。一方、AWS CDKやTerraformには既存リソースからコードを自動生成するビルトインのコマンドラインツールがありません。CDKやTerraformでも既存AWSリソースを参照・管理することはできますが、多くの場合手動でコードを書く必要があり、既存環境への統合に追加の手間がかかります。
比較
Terraformとの比較
HashiCorpが開発したTerraformは、宣言的なHashiCorp Configuration Language(HCL)を使う人気のIaCツールです。Terraformではインフラの望ましい状態を定義すると、クラウドリソースがその状態に合うよう管理されます。シンプルさとマルチクラウドサポートのための豊富なプロバイダーエコシステムが強みです。以下はAWS EC2インスタンスをプロビジョニングするTerraformコードの例です。
provider "aws" {
region = "us-west-2"
}
resource "aws_instance" "example" {
ami = "ami-12345678"
instance_type = "t2.micro"
tags = {
Name = "ExampleInstance"
}
}
このコードは特定のAMI・インスタンスタイプ・タグを持つEC2インスタンスを定義しています。Terraformの宣言的アプローチは「どう作るか」ではなく「どうあるべきか」に焦点を当てており、プログラミングの専門知識がなくてもチームが採用しやすいのが特徴です。
主な違い:
- 使いやすさ:Terraformの宣言的な文法は非開発者に向いており、PulumiのプログラミングLanguageアプローチは開発者にとってより柔軟です。
- 拡張性:Pulumiはプログラミング構文で複雑なロジックを実現できますが、Terraformは高度なシナリオにワークアラウンドが必要です。
- エコシステム:Terraformはより大きなエコシステムを持っており、Pulumiは追いかけている途上です。
| 機能 | Pulumi | Terraform |
|---|---|---|
| OSSライセンス | あり(Apache License 2.0) | なし(Business Source License 1.1) |
| 言語 | 汎用プログラミング言語 | HCL(ドメイン固有言語) |
| テスト | ネイティブテストフレームワーク | テスト機能が限定的 |
| 抽象化 | オブジェクト指向・関数型プログラミング | モジュールシステム |
| 動的プロバイダーサポート | あり | なし |
| 学習コスト | 開発者にとって馴染みやすい | 新しいDSLの学習が必要 |
| 料金(最初の有料プラン) | $0.37/リソース/月 | $0.1/リソース/月 |
| セルフホスト | 利用可能 | 利用可能 |
AWS CDKとの比較
AWS CDKはAWS専用に設計されており、TypeScript・Python・Java・C#などの言語でAWSインフラを定義できます。AWSサービスの高レベル抽象化を提供し、再利用可能なコンストラクトでリソースを定義できます。以下はS3バケットを作成するAWS CDKのTypeScriptサンプルです。
import * as cdk from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
const app = new cdk.App();
const stack = new cdk.Stack(app, 'MyStack');
new Bucket(stack, 'MyBucket', {
versioned: true,
});
このコードはTypeScriptを使ってバージョニングが有効なS3バケットを作成します。AWS CDKはAWSと深く統合されており、AWSサービスに特化した豊富なコンストラクトライブラリを提供しています。ただし、AWSに特化しているためマルチクラウド環境の管理には限界があります。
PulumiとAWS CDKの主な違い
- マルチクラウドサポート:Pulumiは複数のクラウドプロバイダーを幅広くサポートしており、ハイブリッドやマルチクラウド戦略に最適です。一方AWS CDKはAWS固有のユースケースに特化しています。
- 二重の知識要件:AWS CDKはソースからソースへのコンパイラとして機能し、高レベルコードをCloudFormationテンプレートに変換します。そのため、インフラを効果的にデバッグ・管理するにはAWS CDKとCloudFormationの両方を理解する必要があり、学習コストが高くなります。
- 依存関係のデッドロック問題:AWS CDKではスタック間の依存関係の管理がデプロイ上の課題につながることがあります。例えば、他のスタックからまだ参照されているエクスポート値を削除するとデプロイが失敗することがあります。この問題の解決には一時的なフェイク出力を導入するなど複雑なワークアラウンドが必要なことも多く、手間とミスを招きやすいです。
これらの違いを踏まえると、Pulumiは多様なクラウド環境でインフラを管理するためのより柔軟な汎用プラットフォームです。AWS CDKはAWSのみに特化したチームにとっては依然として強力な選択肢です。
実践
カナリアデプロイの例
ここでは、Pulumiの強みを最大限に活かしたカナリアデプロイのプロセスを紹介します。このプロセスは Original(元の状態)、Start Canary(カナリア開始)、Switch(切り替え)、Next Stable(次の安定版)という4つのステージで構成されています。各ステージの望ましい状態は、環境変数と前のスタックの出力の組み合わせによって決まります。
プロセス概要
Original(元の状態)

上の図では、優先度が3の倍数のリスナールール群がリクエストをComponent 1にルーティングしています。このステージのスタック出力には、バージョン1のコミットハッシュ一覧と安定コンポーネントとしてComponent 1が含まれています。
Start Canary(カナリア開始)

STAGE = 'Start Canary' とバージョン2のコミットハッシュをPulumiプログラムに設定します。バージョン2用の新しいスタックをデプロイし、優先度が2の倍数のリスナールールを設定してトラフィックをバージョン2に誘導します。これらのカナリアルールには特別なヘッダーが必要で、特定のリクエストのみが新バージョンに到達することを保証します。スタックの出力は変更されず、カナリアデプロイの準備中も安定性が維持されます。
Switch(切り替え)

手動での検証後、リスナールールを更新してトラフィックを切り替えます。
- Component 1(以前の安定スタック)は優先度が1の倍数のリスナールールを使うようになり、アクセスに特別なヘッダーが必要になります。
- Component 2(以前のカナリアスタック)は優先度が4の倍数のリスナールールを使うようになり、公開リクエストがそちらにルーティングされます。
これにより安定スタックからカナリアスタックへトラフィックが移行します。
Next Stable(次の安定版)

以前の安定スタックを削除し、リスナールールを元の設定に戻します。スタック出力をバージョン2のコミットハッシュで更新し、Component 2を新しい安定コンポーネントとして指定します。
振り返り
- 決定論的なステージ完了:PulumiはKubernetesとAWSの両リソースを直接管理できるため、デプロイの各ステージが確実かつ決定論的に完了します。オペレーターの処理遅延が生じる可能性があるingressルールのアノテーションとは異なり、Pulumiはリソースを直接制御するため意図通りの遷移が保証されます。また、エラーはコントローラーログではなくコード上に現れるため追跡・デバッグが容易です。
- 途中終了のリスク軽減:
Next Stableステージが途中で終了した場合、Component 1が完全に削除されていなかったりComponent 2が完全にデプロイされていないのに、スタック出力がすでにComponent 2が安定していると示してしまう可能性があるため、再実行はリスクを伴います。このリスクを軽減するため、スタック出力をHTTPリクエストに依存させています。スタック出力を更新する前にComponent 2のデプロイと稼働を確認するヘルスチェックを組み込むことで、新コンポーネントが完全に機能している場合のみ処理を進めることを保証します。この依存関係により、スタックがComponent 2を早期に安定とマークすることを防ぎ、失敗した再デプロイによるダウンタイムを回避します。
const healthChecks = pulumi.all(stackOutputIds).apply(async () =>
// health checks are performed on non-global dns
pulumi.runtime.isDryRun() || envs.NM_USE_GLOBAL_DNS === 'true'
? []
: // check the url health and timeout if necessary
checkBasicWebApp({
routings: routingOutputs,
newAppImageTagEnvs,
headers: {
...(NM_TARGET_STAGE === 'start canary' && {
[toCanaryHeader.name]: toCanaryHeader.value,
}),
},
}),
);
// stack outputs
const stageInfo = healthChecks.apply((checks) =>
pulumi.all(checks).apply(() => {
const stable = variantConstruction.find((c) => c.variantName === 'stable');
const canary = variantConstruction.find((c) => c.variantName === 'canary');
if (!stable || !canary) throw new Error('Error constructing components.');
return {
stable: stable.component,
canary: canary.component,
images: stable.imgTags,
};
}),
);
Pulumiはデプロイを動的に設定できる柔軟性を提供しますが、コードの複雑さも増すため、開発者がコードベースの理解とメンテナンスに時間を要する場合があります。NearMeでは学習コストの高さを理解した上でも、私たちの特定のニーズに対応できる強力な機能を持つPulumiを選択しています。
Pulumiのヒント
以下に私たちが共有したいPulumiのヒントをいくつか紹介します。
pulumi.runtime.isDryRun()を活用する
初回デプロイ時、リソースが実際に作成されるまで一部のリソースプロパティが利用できない場合があります。これは特に以下のケースで多く見られます。
- 自動生成されるリソースID
- 動的に割り当てられるIP
- Kubernetesが割り当てるノードポート
const nodePort = pulumi
.all([
service.spec.ports[0],
service.metadata.name,
service.metadata.namespace,
])
.apply(([p, n, ns]) => {
// nodePort will be generated by Kubernetes
if (pulumi.runtime.isDryRun() && !p.nodePort) return '30000';
const port = p.nodePort;
if (typeof port !== 'number') {
const msg = `Unable to find node port in service. Make sure the passed service, ${n}.${ns}, is of type NodePort. Port object: ${JSON.stringify(
p,
)}`;
pulumi.log.error(msg);
throw new Error(msg);
}
return port.toString();
});
const targetGroup = new aws.lb.TargetGroup('target-group', {
port: nodePort.apply((p) => parseInt(p)),
healthCheck: {
port: nodePort,
},
/* ... */
});
- デフォルトプロバイダーを避ける
CI以外の環境でAWS環境変数などのデフォルトプロバイダーを使うと多くの問題を引き起こす可能性があります。例えば、KubernetesリソースがローカルのMinikubeコンテキストに誤ってデプロイされることがあります。以下のようにプロバイダーを明示的に渡すことを推奨します。
const localWorkspace = await LocalWorkspace.createOrSelectStack(
{
projectName: 'example',
stackName: 'local',
program: async () => {
/* Kubernetes Provider */
const provider = new k8s.Provider('provider', {
context: 'minikube', // or pass config from EKS resource
});
new k8s.core.v1.Namespace(
'example',
{
metadata: {
name: 'example',
},
},
{ provider },
);
},
},
{
envVars: {
PULUMI_CONFIG_PASSPHRASE: 'passphrase',
PULUMI_BACKEND_URL: `file://${homedir()}`,
},
},
);
/* Disable default providers */
await localWorkspace.setConfig('pulumi:disable-default-providers', {
value: JSON.stringify(['*']),
});
await localWorkspace.up({
onOutput(out) {
console.log(out);
},
});
展望
PulumiのクラウドインフラとKubernetesリソースの両方を管理できる能力を活用することで、独立したエフェメラル環境を自動でスピンアップするシステムを構築できます。これにより開発者はコードをマージする前に本番に近い環境で変更をテストできるようになります。
まとめ
PulumiはNearMeのインフラ管理に革命をもたらし、Platform Engineeringチームが使い慣れたプログラミング言語を使い、型安全性を高め、堅牢なテスト体制を実装することを可能にしました。多少の複雑さをもたらすこともありますが、Pulumiは絶えず進化する私たちの環境での高度に動的な要件に対応するのに優れています。NearMeでのインフラの未来を切り拓く挑戦的な問題に取り組むことに興味がある方、ぜひご応募ください!
著者:Cyan Chen