🏗️

CDK for TerraformとTypeScriptを活用したIaCのベストプラクティス

に公開

1. はじめに

インフラストラクチャのコード化(IaC)は、現代の開発プロセスにおいて欠かせない要素となっています。しかし、プロジェクトの規模が拡大するにつれて、環境(開発・本番など)ごとの設定差分や、機密情報の管理、そしてモノレポ環境における複数製品のデプロイ戦略など、IaC特有の課題に直面することが多くなります。

本記事では、私たちのチームで実践している CDK for Terraform (CDKTF) を活用したIaCの設計パターンとベストプラクティスをご紹介します。以下の4つのアプローチを取り入れることで、安全で保守性の高いインフラ基盤を構築しています。

  1. CDK for Terraform(CDKTF)とTypeScriptの採用
  2. Zodを活用した環境設定の型安全な定義と差分吸収
  3. Key Vaultによるシークレットの集約と機密情報レスなIaC
  4. 共通スタックと製品スタックの分割によるデプロイの独立性

2. CDK for Terraform を選んだ理由

IaCツールとして最も一般的なのはTerraform(HCL)ですが、私たちは CDK for Terraform (CDKTF) を採用し、TypeScript でインフラを記述しています。

TypeScript主体の開発チームにとって、インフラの定義にも使い慣れた言語を採用できることは非常に大きなメリットです。

  • 高い学習容易性と生産性: フロントエンドやバックエンドと同じ言語・ツールチェーンを使用できるため、インフラ専任のエンジニアがいなくても、開発メンバー全体でIaCの保守が可能になります。
  • 強力な型補完とIDE支援: TypeScriptの型システムにより、リソースのプロパティや必須項目の補完が効くため、タイポや設定漏れを未然に防ぐことができます。
  • リファクタリングの容易さ: HCLでは難しかったループ処理や条件分岐、関数によるモジュール化などが、通常のプログラミング言語の機能を使って直感的に記述できます。
  • Terraformエコシステムの活用: クラウドプロバイダーなどの豊富なTerraformプロバイダーや既存のモジュールをそのままTypeScriptから利用できるため、表現力に制限がありません。

3. env と Zod による環境差分の吸収

インフラ構築でよくある課題が、「開発環境と本番環境で微妙に設定が違うが、リソースの全体構成は同じ」というケースです。これをHCLで愚直に書くと、環境ごとに似たようなコードをコピペすることになりがちです。

私たちの設計では、環境ごとに必要となる設定値のスキーマを定義し、設定とリソース定義のロジックを完全に分離しています。この設定のバリデーションには TypeScript ファーストなスキーマ宣言・検証ライブラリである Zod を使用しています。

環境設定の分離と型安全な検証

まず、インフラ全体で必要となる設定値のスキーマを定義します。

import { z } from 'zod';

export const Env = {
  Prod: 'prod',
  Dev: 'dev',
} as const;

export const ConfigSchema = z.object({
  env: z.nativeEnum(Env),
  appConfig: z.object({
    cpu: z.number(),
    memory: z.string(),
    maxReplicas: z.number(),
    corsDomains: z.array(z.string()),
  }),
});

export type Config = z.infer<typeof ConfigSchema>;

次に、各環境ごとの設定値を別のファイル(例:dev.config.ts, prod.config.ts)に切り出します。

// dev.config.ts
import { Env } from './env';
import { Config } from './configSchema';

export const devConfig: Config = {
  env: Env.Dev,
  appConfig: {
    cpu: 0.5,
    memory: '1Gi',
    maxReplicas: 3,
    corsDomains: ['https://dev.example.com', 'http://localhost:3000'],
  },
};

スタックの定義側では、この設定オブジェクトを受け取り、Zodで検証した上でインフラを構築します。

export class MyStack extends TerraformStack {
  constructor(scope: Construct, config: Config) {
    // 実行時に設定値の型と要件を検証
    ConfigSchema.parse(config);
    const { env, appConfig } = config;
    
    super(scope, `my-stack-${env}`);
    
    // configの値を利用してリソースを定義
    // 環境ごとのif文を減らし、宣言的にインフラを構築できる
  }
}

このようにすることで、開発・本番などでほぼ同じリソースを複数回書く必要がなくなり、設定の差分だけを環境ごとの設定ファイルに集約できます。また、誤った設定値が渡された場合は cdktf synth の段階でZodが即座にエラーを検知するため、デプロイ前に問題を特定できます。

4. Key Vault によるシークレット管理

IaCのコードベースにデータベースのパスワードやAPIキーなどの機密情報(シークレット)を含めることは、セキュリティ上の重大なリスクとなります。

私たちは、クラウドプロバイダーが提供するシークレット管理サービス(Azure Key Vault など)にシークレットを集約し、IaCは機密情報を一切保持しないままデプロイできる設計を採用しています。

マネージドIDとアクセスポリシーの活用

具体的には、IaC上でシークレットの中身を直接扱うのではなく、アプリケーション側がセキュアにシークレットを取得できる「経路」のみをIaCで構築します。

  1. マネージドIDの作成: アプリケーション(コンテナやサーバーレス関数)に割り当てる一意のIDをIaCで作成します。
  2. アクセス権の付与: 作成したIDに対して、Key Vaultのシークレットを「読み取る」ことだけができるアクセスポリシー(またはRBAC)を付与します。
  3. アプリケーションのデプロイ: アプリケーションにこのIDを紐付け、シークレットのURI(Key Vaultの参照パス)のみを環境変数などとして渡します。

なぜ「システム割り当て」ではなく「ユーザー割り当て」なのか?

マネージドIDには「システム割り当て(System Assigned)」と「ユーザー割り当て(User Assigned)」の2種類があります。一見すると、リソースと1対1で自動的に管理されるシステム割り当ての方が便利に思えます。

しかし、システム割り当てマネージドIDは、コンテナアプリなどのリソースが「作成された後」に初めて発行されます。そのため、コンテナアプリの作成プロセス中にプライベートなコンテナレジストリ(ACRなど)からイメージをプルしようとしても、まだIDが存在せず権限を付与できないため、初回デプロイが失敗してしまう(鶏と卵の)問題が発生します。

あらかじめ独立した「ユーザー割り当てマネージドID」を作成し、そこにACRからのPull権限やKey Vaultへのアクセス権を先行して付与しておくことで、コンテナアプリの初回作成時からスムーズにデプロイを完了させることができます。

// 1. ユーザー割り当てIDの先行作成
const appIdentity = new UserAssignedIdentity(this, 'id_app', {
  name: `id-app-${env}`,
  // ...
});

// Key Vaultへの読み取り権限のみを付与
new KeyVaultAccessPolicy(this, 'kv_access_policy', {
  keyVaultId: commonStack.kv.id,
  objectId: appIdentity.principalId,
  secretPermissions: ['Get', 'List'],
});

// コンテナアプリへのシークレット参照の注入
new ContainerApp(this, 'app', {
  name: `app-${env}`,
  identity: {
    type: 'UserAssigned',
    identityIds: [appIdentity.id],
  },
  secret: [
    {
      name: 'database-url',
      identity: appIdentity.id, // マネージドIDを使用してアクセス
      keyVaultSecretId: `${commonStack.kv.vaultUri}secrets/database-url`, // 実体ではなくURIを渡す
    },
  ],
  // ...
});

このアプローチにより、IaCのコードやTerraformのStateファイル(tfstate)にシークレットが平文で記録されることを防ぐことができます。IaCのリポジトリは完全にクリーンな状態に保たれ、安全にGitにコミットし、CI/CDパイプラインでの自動化を円滑に進めることが可能になります。

5. スタック分割と製品単位デプロイ

モノレポ環境で複数の製品やサービスを一つのインフラ基盤上で運用する場合、すべてのリソースを1つの巨大なスタックで管理すると、デプロイに時間がかかったり、一部の変更が全体に影響を及ぼすリスクが高まります。

そこで、私たちは全体を 「共通基盤(common)」スタック「各製品」スタック に分割し、スタック間で依存関係を持たせる設計にしています。

Commonスタックと製品スタックの役割分担

  • Common(共通)スタック: リソースグループ、Key Vault、コンテナレジストリ、共有のメッセージキューやAPIゲートウェイなど、製品横断で利用される基盤リソースを管理します。
  • 製品スタック(Product A, Product B...): 各製品に特化したコンテナアプリケーション、サーバーレス関数、ストレージなどを個別に管理します。

コードでの依存関係の表現

TypeScript(CDKTF)のクラスベースの性質を活かし、製品スタックのコンストラクタでCommonスタックのインスタンスを引数として受け取るように実装しています。

const app = new App();

// 開発環境の設定
// 1. 共通スタックの宣言
const devCommonStack = new CommonStack(app, devConfig);

// 2. 製品スタックの宣言(共通スタックに依存)
new ProductAStack(app, devProductAConfig, devCommonStack);
new ProductBStack(app, devProductBConfig, devCommonStack);

app.synth();

製品スタック内では、渡された devCommonStack のパブリックプロパティを通じて、共通リソースのIDや名前に安全にアクセスできます(例:commonStack.kv.id)。各スタックは別々の terraform.tfstate ファイルとして保存されるよう設定しています。

分割によるメリット

  1. 製品ごとの独立したデプロイ: cdktf deploy product-a-dev のように、特定の製品スタックだけをターゲットにしてデプロイが可能です。これにより、他の製品に影響を与えることなく迅速なリリースが可能になります。
  2. Stateファイルの分割: スタックごとに別々のStateファイルを持つため、Stateファイルの肥大化を防ぎ、Terraformの実行計画(plan)や適用のパフォーマンスが大幅に向上します。
  3. 影響範囲の局所化: 共通基盤への変更と、特定の製品アプリケーションへの変更を明確に分離でき、安全にインフラを成長させることができます。

6. おわりに

本記事では、CDK for Terraform、Zod、Key Vault、そして適切なスタック分割を組み合わせた、モダンなIaCの設計アプローチについて紹介しました。

TypeScriptという身近な言語を使いながら、型の恩恵を受けつつ堅牢なインフラを構築できるCDKTFは、ソフトウェア開発のライフサイクル全体をシームレスにつなぐ強力なツールです。環境ごとの設定分離やシークレットの安全な管理、複数製品環境での分割デプロイといった課題を抱えているチームは、ぜひこのアーキテクチャを参考にしてみてください。

YOSHINANI

Discussion