CDK v2カスタムコンストラクト、1年運用して設計を2回壊した話
「このコンストラクト、どういう設計思想で作ったんですか?」と聞かれて言葉に詰まった経験から書いた。Props設計の失敗、L2/L3の判断ミス、テスト戦略まで正直に振り返る。
CDK v2 カスタムコンストラクト設計、チームで1年運用して見えた設計パターンの現実
先日、チームの新メンバーから「このコンストラクト、どういう設計思想で作ったんですか?」と聞かれて、正直言葉に詰まった。1年前に「これでいける」と思って作ったやつが、途中でProps設計を2回壊して、最終的にまったく違う形になっていた。その変遷を説明するのに30分かかった。
CDK v2のカスタムコンストラクト、最初は「ただのTypeScriptクラスでしょ」と思ってた。でも実際にチームで使い続けると、設計の甘さがじわじわ痛みになって返ってくる。そういう経験を共有したくて書いた。
ちなみに、CDK関連ではCDK Aspects・Nag本番導入3ヶ月で気づいた、自動セキュリティ検証の現実やCDK Pipelines 2026年版|マルチアカウント対応CI/CD構築ガイドも書いているので、合わせて読んでもらえると文脈が伝わりやすいと思う。
うちのチームが作ったコンストラクト、最初はこうだった
プロジェクト初期、「App Runner + Aurora Serverless v2 + S3」という構成を複数のマイクロサービスで使いまわすことになっていた。毎回同じパラメータをベタ書きするのは辛いので、カスタムコンストラクトを作ることにした。
最初のコードはこんな感じだった:
// 初期バージョン(これが後々地獄になる)
export interface AppRunnerServiceProps {
serviceName: string;
imageUri: string;
cpu: string;
memory: string;
envVars: { [key: string]: string };
dbClusterArn: string;
dbSecretArn: string;
}
export class AppRunnerService extends Construct {
public readonly service: apprunner.CfnService;
constructor(scope: Construct, id: string, props: AppRunnerServiceProps) {
super(scope, id);
this.service = new apprunner.CfnService(this, 'Service', {
serviceName: props.serviceName,
sourceConfiguration: {
imageRepository: {
imageIdentifier: props.imageUri,
imageRepositoryType: 'ECR',
imageConfiguration: {
runtimeEnvironmentVariables: Object.entries(props.envVars).map(
([name, value]) => ({ name, value })
),
},
},
},
instanceConfiguration: {
cpu: props.cpu,
memory: props.memory,
},
});
}
}
これ、最初の3ヶ月は普通に動いてた。でも4ヶ月目に「オートスケーリングの設定もコンストラクトに入れてほしい」「VPCコネクタも必要になった」「カスタムドメインも」という要件が続々と来た。その都度Propsを足していった結果、Propsが20個を超えた。「ちょっとこれ、どのパラメータが何を指してるかわからなくなってきた」というフィードバックが来た。あの頃の自分に「最初からネストしろ」と言いたい。
L1・L2・L3の使い分け、実務でどうやってるか
CDKのコンストラクトには抽象化レベルがある。公式ではL1/L2/L3と呼ばれているけど、実際にチームで設計するときにこのレベル感をどう決めるかが難しい。
| レベル | 特徴 | 向いているケース |
|---|---|---|
| L1 (Cfn系) | CloudFormationリソースの直接マッピング | AppRunnerみたいにL2がまだない・不安定なサービス |
| L2 | よく使う設定をデフォルト化・型安全 | EC2・S3・IAMなど安定したサービス |
| L3 (Patterns) | 複数リソースをひとまとめに | 再利用するアーキテクチャパターン全般 |
うちのチームでは、新しいAWSサービスや急ぎで対応が必要なものはL1、安定していて複数チームが使いまわすものはL3設計という使い分けに落ち着いた。個人的には「迷ったらL3を目指す」くらいの気持ちでいい気がしている。最初からL3で設計しようとすると過剰になりがちだけど、「後でL3に育てる前提でL1を書く」とリファクタが格段に楽になる。
最初に失敗したAppRunnerコンストラクトを、L3的な発想で再設計した。ポイントは「Propsをネストする」こと:
// リファクタ後のバージョン(2026年現在も運用中)
export interface NetworkingProps {
vpc: ec2.IVpc;
subnetIds: string[];
securityGroupIds: string[];
}
export interface ScalingProps {
minSize?: number; // default: 1
maxSize?: number; // default: 10
maxConcurrency?: number; // default: 100
}
export interface DatabaseProps {
cluster: rds.IDatabaseCluster;
secret: secretsmanager.ISecret;
databaseName: string;
}
export interface AppRunnerServiceV2Props {
serviceName: string;
imageUri: string;
cpu?: apprunner.Cpu; // default: CPU_1_VCPU
memory?: apprunner.Memory; // default: MEM_2_GB
networking?: NetworkingProps;
scaling?: ScalingProps;
database?: DatabaseProps;
additionalEnvVars?: Record<string, string>;
tags?: Record<string, string>;
}
export class AppRunnerServiceV2 extends Construct {
public readonly service: apprunner.Service;
public readonly serviceUrl: string;
constructor(scope: Construct, id: string, props: AppRunnerServiceV2Props) {
super(scope, id);
const cpu = props.cpu ?? apprunner.Cpu.ONE_VCPU;
const memory = props.memory ?? apprunner.Memory.TWO_GB;
// VPCコネクタは必要な場合のみ作成
const vpcConnector = props.networking
? new apprunner.VpcConnector(this, 'VpcConnector', {
vpc: props.networking.vpc,
vpcSubnets: {
subnets: props.networking.subnetIds.map(id =>
ec2.Subnet.fromSubnetId(this, `Subnet${id}`, id)
),
},
})
: undefined;
// DB接続情報は database が渡された場合のみ環境変数に注入
const dbEnvVars: Record<string, string> = props.database
? {
DB_HOST: props.database.cluster.clusterEndpoint.hostname,
DB_PORT: props.database.cluster.clusterEndpoint.port.toString(),
DB_NAME: props.database.databaseName,
}
: {};
this.service = new apprunner.Service(this, 'Service', {
serviceName: props.serviceName,
source: apprunner.Source.fromEcr({
imageConfiguration: {
port: 8080,
environmentVariables: {
...dbEnvVars,
...(props.additionalEnvVars ?? {}),
},
environmentSecrets: props.database
? {
DB_SECRET: apprunner.Secret.fromSecretsManager(
props.database.secret
),
}
: undefined,
},
repository: ecr.Repository.fromRepositoryUri(
this, 'Repo', props.imageUri
),
tagOrDigest: 'latest',
}),
cpu,
memory,
vpcConnector,
autoScalingConfiguration: new apprunner.AutoScalingConfiguration(
this, 'Scaling', {
minSize: props.scaling?.minSize ?? 1,
maxSize: props.scaling?.maxSize ?? 10,
maxConcurrency: props.scaling?.maxConcurrency ?? 100,
}
),
});
this.serviceUrl = this.service.serviceUrl;
// タグ付け
if (props.tags) {
Object.entries(props.tags).forEach(([key, value]) => {
cdk.Tags.of(this.service).add(key, value);
});
}
}
}
ネストされたPropsは「必要なときだけ渡す」設計にすることで、使う側がぐっとシンプルになった:
// 使う側がこんなにスッキリした
new AppRunnerServiceV2(this, 'PaymentService', {
serviceName: 'payment-service',
imageUri: '123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/payment:latest',
// VPCが必要ない場合はnetworkingを省略できる
database: {
cluster: auroraCluster,
secret: dbSecret,
databaseName: 'payment_db',
},
scaling: { maxSize: 20 }, // デフォルトから変えたいものだけ指定
});
Props設計で2回失敗して学んだこと
正直、Props設計が一番悩んだ。特に「どこまでをコンストラクトの責任範囲にするか」という問題。
失敗1: 既存リソースの参照をStringで渡していた
最初期、dbClusterArn: string みたいにARN文字列で渡していた。これ、deployするまで型エラーが出ないし、ARNの文字列が間違っててもコンパイルが通る。CDKの強みを自分で捨ててた。
解決策は、CDKのIFace(rds.IDatabaseCluster)で渡すこと。既存リソースをimportする場合は呼び出し側で fromAttributes() を使ってもらう設計にした。これでIDEの補完も効くし、型の不整合がある程度コンパイル時に弾ける。地味に便利というか、これをやってからレビューで「ARNのタイポ」を指摘することがほぼなくなった。
失敗2: コンストラクトがリソースを作りすぎていた
ある時期、「Security Groupも自動で作ってほしい」という要望に応えようとして、コンストラクト内部でSecurity Groupを生成するようにした。一見便利なんだけど、「あのSecurity GroupってどのコンストラクトのIDで引けるんだっけ?」みたいな話が増えてきた。
ルールとして「コンストラクトが自動生成するリソースはできるだけpublicプロパティで公開する」ことにした:
export class AppRunnerServiceV2 extends Construct {
// 外部から参照できるように全部公開
public readonly service: apprunner.Service;
public readonly vpcConnector?: apprunner.VpcConnector;
public readonly autoScalingConfiguration: apprunner.AutoScalingConfiguration;
public readonly serviceUrl: string;
// ...
}
こうしておくと、CDK Aspects でスキャンするときも、テストで検証するときも、参照しやすくなった。「ブラックボックスになってるせいでテストが書けない」状態を防ぐ一番シンプルな手だと思っている。
実際のAWS構成とコンストラクト設計の全体像
うちのチームが最終的に落ち着いた構成はこんな感じ。複数マイクロサービスを同じパターンで展開するために、L3コンストラクトを共通ライブラリとして切り出している。
graph TB
subgraph shared_lib["共通コンストラクトライブラリ (npm package)"]
LC1[AppRunnerServiceV2]
LC2[AuroraServerlessCluster]
LC3[FrontendCDNConstruct]
LC4[ObservabilityConstruct]
end
subgraph account_prod["本番アカウント"]
subgraph vpc_prod["VPC (10.0.0.0/16)"]
subgraph az_a["AZ: ap-northeast-1a"]
SVC_A[App Runner\nPayment Service]
DB_A[(Aurora Serverless v2\nWriter Instance)]
end
subgraph az_b["AZ: ap-northeast-1c"]
SVC_B[App Runner\nOrder Service]
DB_B[(Aurora Serverless v2\nReader Instance)]
end
VPC_EP[VPC Endpoint\nSecrets Manager]
end
CF[CloudFront\nFrontendCDN]
S3_STATIC[S3\nStatic Assets]
CW[CloudWatch\nDashboard & Alarms]
end
subgraph account_shared["共有サービスアカウント"]
ECR[ECR\nContainer Registry]
SECRETS[Secrets Manager]
end
LC1 --> SVC_A
LC1 --> SVC_B
LC2 --> DB_A
LC2 --> DB_B
LC3 --> CF
LC3 --> S3_STATIC
LC4 --> CW
SVC_A --> VPC_EP
SVC_B --> VPC_EP
VPC_EP --> SECRETS
SVC_A --> DB_A
SVC_B --> DB_B
ECR --> SVC_A
ECR --> SVC_B
CF --> S3_STATIC
このアーキテクチャで重要なのは、コンストラクトライブラリをnpmパッケージとして切り出しており、複数のCDKアプリから共有している点だ。これについては後述する。
コンストラクトのテスト戦略、実際どうやってるか
2026年時点でCDKのテストはかなり書きやすくなった。aws-cdk-lib/assertions を使ったユニットテストと、スナップショットテストを組み合わせている。
AWS CDKテスト戦略|AssertionsとSnapshotで自動化で詳しく書いたけど、カスタムコンストラクトのテストはこういう構成にしている:
import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { AppRunnerServiceV2 } from '../lib/constructs/app-runner-service-v2';
describe('AppRunnerServiceV2', () => {
let app: App;
let stack: Stack;
beforeEach(() => {
app = new App();
stack = new Stack(app, 'TestStack', {
env: { account: '123456789012', region: 'ap-northeast-1' },
});
});
test('最小構成でApp Runnerサービスが作られる', () => {
new AppRunnerServiceV2(stack, 'TestService', {
serviceName: 'test-service',
imageUri: '123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/test:latest',
});
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::AppRunner::Service', {
ServiceName: 'test-service',
InstanceConfiguration: {
Cpu: '1 vCPU',
Memory: '2 GB',
},
});
// VPCコネクタは作られないはず
template.resourceCountIs('AWS::AppRunner::VpcConnector', 0);
});
test('database Propsを渡すと環境変数が注入される', () => {
const vpc = new ec2.Vpc(stack, 'TestVpc');
const cluster = rds.DatabaseCluster.fromDatabaseClusterAttributes(
stack, 'TestCluster', {
clusterIdentifier: 'test-cluster',
clusterEndpointAddress: 'test.cluster.ap-northeast-1.rds.amazonaws.com',
port: 5432,
}
);
const secret = secretsmanager.Secret.fromSecretNameV2(
stack, 'TestSecret', 'test-secret'
);
new AppRunnerServiceV2(stack, 'TestService', {
serviceName: 'test-service',
imageUri: '123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/test:latest',
database: {
cluster,
secret,
databaseName: 'test_db',
},
});
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::AppRunner::Service', {
SourceConfiguration: {
ImageRepository: {
ImageConfiguration: {
RuntimeEnvironmentVariables: expect.arrayContaining([
{ Name: 'DB_NAME', Value: 'test_db' },
]),
},
},
},
});
});
test('スケーリング設定のデフォルト値が正しい', () => {
new AppRunnerServiceV2(stack, 'TestService', {
serviceName: 'test-service',
imageUri: '123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/test:latest',
});
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::AppRunner::AutoScalingConfiguration', {
MinSize: 1,
MaxSize: 10,
MaxConcurrency: 100,
});
});
});
これが地味に便利で、Propsをリファクタするたびに「あ、このテストが落ちた」という形で気づける。コンストラクトの変更が意図しない副作用を起こしていないか確認するのに、このくらいのカバレッジがあれば十分な安心感がある。
スナップショットテストは変更の意図確認に使っているけど、ひとつ注意点がある。CDKのバージョンアップでCloudFormationテンプレートの論理IDが変わることがあって、スナップショットが大量に壊れる。壊れたスナップショットを機械的に --updateSnapshot で更新するだけだと意味がない。うちのチームでは「論理IDが変わったらPR必須で変更理由を書く」ルールにした。最初は「めんどくさい」という声もあったけど、本番で予期しないリソース置き換えが走ってから全員が納得した。
コンストラクトライブラリの共有、モノレポとの組み合わせ
最初、各プロジェクトのリポジトリ内にコンストラクトを置いていた。当然、同じようなコンストラクトが複数リポジトリに生まれて、バグ修正したはずのコンストラクトが別リポジトリでは古いまま使われている状態になった。これはかなり痛かった。
この問題を解決するために、コンストラクトライブラリを独立したnpmパッケージとして切り出した。モノレポ運用ガイド|2026年ベストプラクティスと導入戦略でも触れているが、コンストラクトライブラリとCDKアプリを同じモノレポに置くのが今のベストプラクティスだと思っている。
cdk-constructs/
├── packages/
│ ├── common-constructs/ # 共有コンストラクトライブラリ
│ │ ├── src/
│ │ │ ├── app-runner-service-v2.ts
│ │ │ ├── aurora-serverless-cluster.ts
│ │ │ └── index.ts
│ │ ├── test/
│ │ └── package.json
│ ├── service-a/ # CDKアプリ(サービスA)
│ │ ├── bin/
│ │ ├── lib/
│ │ └── package.json # common-constructsに依存
│ └── service-b/ # CDKアプリ(サービスB)
├── pnpm-workspace.yaml
└── package.json
この構成にしてから、「コンストラクトを更新したら自動で全アプリのCDK diffが走る」CIを組んだ。差分が出たアプリのPRを自動で作成してくれるので、更新漏れがなくなった。地味に便利というか、これなしでどうやってたんだろうという感じがする。
デプロイ安定性を高めるための細かいテクニック
1年運用して積み上がった、地味だけど効いてる工夫をまとめておく。
removalPolicyをコンストラクト内で明示的にコントロールする
// 本番環境でテーブルを誤削除しないための防衛線
const removalPolicy = props.environment === 'production'
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY;
const table = new dynamodb.Table(this, 'Table', {
// ...
removalPolicy,
});
これをコンストラクト内に閉じ込めておくと、呼び出し側がうっかり環境を間違えても守られる。個人的にはこれが一番「コンストラクトに責任を持たせる」の実感がある。
コンストラクト内でGrantメソッドを提供する
export class AppRunnerServiceV2 extends Construct {
// ...
// 他のリソースへのアクセス権をまとめて付与するヘルパー
public grantReadFromBucket(bucket: s3.IBucket): void {
bucket.grantRead(this.service.grantPrincipal);
}
public grantInvokeFunction(fn: lambda.IFunction): void {
fn.grantInvoke(this.service.grantPrincipal);
}
}
このパターン、CDK標準のL2コンストラクトを真似したものだけど、呼び出し側がIAMポリシーを直接書かずに済むので、権限管理がコンストラクト単位でカプセル化できる。
バージョニングとCHANGELOGを必ず書く
共有ライブラリになった瞬間から、破壊的変更は致命的になる。Propsの型を変えたり、リソース名のロジックを変えたりするとCDKの論理IDが変わって、本番でリソースの削除・再作成が走ることがある。これが一番怖い。
うちのチームでは BREAKING CHANGE: をコミットメッセージに含めるルールにして、リリースノートに自動で集約されるようにしている。
これら5つの工夫、どれか一つでも欠けると「なぜか本番でリソースが再作成されました」みたいな事故につながる。特に最後のバージョニングは後回しにしがちなので要注意だった。
実際に1年間でどれくらい効果があったか、チームで簡易計測したのがこちら:
xychart-beta
title "カスタムコンストラクト化による効果(チーム計測値)"
x-axis ["新規サービス\n立ち上げ時間", "IAMポリシー\nレビュー工数", "本番デプロイ\nエラー率", "コード重複率"]
y-axis "削減率 (%)" 0 --> 100
bar [72, 65, 58, 80]
新規サービス立ち上げ時間が72%削減、IAMポリシーのレビュー工数が65%削減。数字だけ見ると「本当に?」って感じだけど、「コンストラクト呼ぶだけでVPC接続とかSecret注入が全部済む」状態になると体感としてもそれくらいの差はある。コード重複率80%削減はほぼそのままの数字で、4リポジトリに散らばっていた同じようなコードが1箇所にまとまったというシンプルな話だ。
まとめ
CDK v2のカスタムコンストラクト、1年運用してわかった要点をまとめる:
-
Propsは最初からネスト構造で設計する。後から変えると全利用箇所の修正が発生する。
NetworkingProps,ScalingPropsのように関心事ごとにグループ化するのが吉。 -
コンストラクト内部で生成したリソースは全部publicプロパティで公開しておく。テストしやすさとCDK Aspectsでの検証が劇的に楽になる。
-
テストはAssertions + Snapshotの二本立て。Assertionsで重要なプロパティを明示的に検証、Snapshotで変更の意図確認。スナップショットの機械的な更新は危険。
-
複数アプリで使いまわすならnpmパッケージ化してモノレポに置く。コピペ運用は半年で限界が来る。
-
removalPolicyとGrantメソッドはコンストラクト内に閉じ込める。呼び出し側がIAMやリソース削除のことを考えなくていい状態が理想。
正直まだ改善中の部分もある。特に「コンストラクトのバージョンアップ時に本番で論理IDが変わるリスクをどう自動検知するか」はまだ完璧な解がない。CDKの --context を使ったfeature flagみたいな移行パスも検討中だ。
皆さんのチームではどうやってますか?似たような悩みを抱えている人がいれば、ぜひコメントで教えてほしい。
次のアクションとしては、Propsがフラットになっているコンストラクトを一つ選んでネスト構造にリファクタするところから始めるのがおすすめ。「全部一気に直す」ではなく「次に触るときに直す」くらいの感覚でやっていくのが、チームで長続きする改善の進め方だと思っている。