CDK v2のカスタムコンストラクト、1年運用で設計を2回壊した理由
CDK v2でカスタムコンストラクトを本番運用1年間で痛感した設計ミス。スコープ階層の間違いと再利用性の落とし穴を、実失敗事例から紐解きます。
最初のカスタムコンストラクトは「とりあえず動く」だけだった
先日プロジェクトで、やっとCDK v2をチーム全体で本格導入してから1年が経った。その過程で、うちのカスタムコンストラクト設計は2回壊れて、3回目でようやく落ち着いた。正直、最初は教科書通りに作ったつもりだったけど、本番で予想外のハマり方をした。
きっかけは、複数のマイクロサービスで共通のネットワーク基盤を使いたい、という要件だった。うちのチームは最初「VPC + Security Groups + ALBを1つのカスタムコンストラクトにまとめちゃえば再利用できるじゃん」って考えた。素朴な発想だ。コンストラクトを作って、3つのスタックから使った。最初は動いた。だけど、チーム内で別プロジェクトが立ち上がったときに、「このコンストラクト、VPCのCIDRをハードコードしてるから変更できない」という悲劇が起きた。
実は多くの人が同じ轍を踏んでると思う。カスタムコンストラクトって、一見「再利用できる便利な部品」に見えるけど、スコープ設計を間違えると、後々修正地獄になるんだよね。
最初の失敗:スコープ階層の間違い
最初のカスタムコンストラクトはこんな感じだった。
export interface NetworkConstructProps extends cdk.StackProps {
projectName: string;
environment: string;
}
export class NetworkConstruct extends cdk.Stack {
public readonly vpc: ec2.Vpc;
public readonly alb: elbv2.ApplicationLoadBalancer;
public readonly securityGroup: ec2.SecurityGroup;
constructor(scope: cdk.App, id: string, props: NetworkConstructProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'VPC', {
cidr: '10.0.0.0/16', // ハードコード...
maxAzs: 2,
});
this.securityGroup = new ec2.SecurityGroup(this, 'SG', {
vpc: this.vpc,
allowAllOutbound: true,
});
this.alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
vpc: this.vpc,
internetFacing: true,
});
}
}
問題は、cdk.Stack を継承してることだ。これやると、コンストラクト自体がスタック(CloudFormationテンプレート)になっちゃう。だから複数プロジェクトで使おうとすると、別々のスタックができて、独立した別物になる。確かに再利用はできるけど、リソース間の依存管理が煩雑になるんだよね。
さらに、CIDRやセキュリティルールがハードコードされてるから、環境ごとに値を変えたいときは、毎回コンストラクトを修正する羽目になった。これ最悪だ。
第2の失敗:プロパティの過度な抽象化
1回目の失敗を見て「そっか、プロパティを増やして柔軟にしよう」って考えた。次のバージョンはこうなった。
export interface NetworkConstructProps {
projectName: string;
environment: string;
vpcConfig: {
cidr: string;
maxAzs: number;
natGateways: number;
};
securityGroupConfig: {
ingressRules: Array<{
port: number;
protocol: string;
cidrIp: string;
}>;
};
albConfig: {
internetFacing: boolean;
loadBalancerType: string;
};
}
export class NetworkConstruct extends cdk.Construct {
public readonly vpc: ec2.Vpc;
public readonly alb: elbv2.ApplicationLoadBalancer;
public readonly securityGroup: ec2.SecurityGroup;
constructor(scope: cdk.Construct, id: string, props: NetworkConstructProps) {
super(scope, id);
this.vpc = new ec2.Vpc(this, 'VPC', {
cidr: props.vpcConfig.cidr,
maxAzs: props.vpcConfig.maxAzs,
natGateways: props.vpcConfig.natGateways,
});
this.securityGroup = new ec2.SecurityGroup(this, 'SG', {
vpc: this.vpc,
allowAllOutbound: true,
});
props.securityGroupConfig.ingressRules.forEach((rule, index) => {
this.securityGroup.addIngressRule(
ec2.Peer.ipv4(rule.cidrIp),
ec2.Port.tcp(rule.port),
`Rule ${index}`
);
});
this.alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
vpc: this.vpc,
internetFacing: props.albConfig.internetFacing,
});
}
}
確かに柔軟になった。でも使う側は地獄だ。このコンストラクトを使うたびに、ネストされた設定オブジェクトを全部用意しないといけない。そして、このプロパティの型定義が肥大化して、保守が大変になった。さらに、「本来必須じゃない設定」と「必須の設定」の区別が曖昧で、呼び出し側で不完全な設定をしてしまうバグが増えたんだよね。
最悪なのは、セキュリティグループの設定が「配列で指定」という方式。後から「このセキュリティグループにルールを追加したい」って要望が出たときに、コンストラクト側に手を入れるしかなくなった。コンストラクトは一度公開すると、既存のコードに影響を与えずに拡張するのが難しい。
第3の正解:合成の視点で設計し直す
3回目は、以下の原則で設計し直した:
- スコープは Construct(Stack ではなく)に統一
- 責務を明確に分割する
- デフォルト値と拡張ポイントのバランスを取る
実装はこんな感じ。
export interface CoreNetworkProps {
projectName: string;
environment: string;
vpcCidr: string;
maxAzs?: number; // デフォルト値あり
}
export class CoreNetwork extends cdk.Construct {
public readonly vpc: ec2.Vpc;
public readonly privateSubnets: ec2.ISubnet[];
public readonly publicSubnets: ec2.ISubnet[];
constructor(scope: cdk.Construct, id: string, props: CoreNetworkProps) {
super(scope, id);
this.vpc = new ec2.Vpc(this, 'VPC', {
cidr: props.vpcCidr,
maxAzs: props.maxAzs ?? 2, // デフォルト2
natGateways: 1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
],
});
this.publicSubnets = this.vpc.publicSubnets;
this.privateSubnets = this.vpc.privateSubnets;
}
}
export interface ServiceSecurityGroupProps {
vpc: ec2.Vpc;
servicePort: number;
sourceSecurityGroup?: ec2.ISecurityGroup;
}
export class ServiceSecurityGroup extends cdk.Construct {
public readonly securityGroup: ec2.SecurityGroup;
constructor(scope: cdk.Construct, id: string, props: ServiceSecurityGroupProps) {
super(scope, id);
this.securityGroup = new ec2.SecurityGroup(this, 'SG', {
vpc: props.vpc,
allowAllOutbound: true,
description: `Security group for ${id}`,
});
// 呼び出し側で後から追加できる構造にする
if (props.sourceSecurityGroup) {
this.securityGroup.addIngressRule(
ec2.Peer.securityGroupPeer(props.sourceSecurityGroup),
ec2.Port.tcp(props.servicePort),
'From source SG'
);
}
}
// メソッドで拡張ポイントを提供
public addIngressRule(
peer: ec2.IPeer,
port: number,
description: string
): void {
this.securityGroup.addIngressRule(
peer,
ec2.Port.tcp(port),
description
);
}
}
export interface ServiceLoadBalancerProps {
vpc: ec2.Vpc;
servicePort: number;
serviceName: string;
healthCheckPath?: string;
}
export class ServiceLoadBalancer extends cdk.Construct {
public readonly alb: elbv2.ApplicationLoadBalancer;
public readonly listener: elbv2.ApplicationListener;
public readonly targetGroup: elbv2.ApplicationTargetGroup;
constructor(scope: cdk.Construct, id: string, props: ServiceLoadBalancerProps) {
super(scope, id);
this.alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
vpc: props.vpc,
internetFacing: true,
});
this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TG', {
vpc: props.vpc,
port: props.servicePort,
protocol: elbv2.ApplicationProtocol.HTTP,
healthCheck: {
path: props.healthCheckPath ?? '/health',
healthyHttpCodes: '200-299',
},
});
this.listener = this.alb.addListener('Listener', {
port: 80,
defaultTargetGroups: [this.targetGroup],
});
}
}
これの何が良いかっていうと:
- 責務が分かれてる — VPC、セキュリティグループ、ALBが独立したコンストラクトとして存在する
- デフォルト値がある —
maxAzs ?? 2みたいに、シンプルな値をデフォルトで提供してる - 拡張メソッドがある —
addIngressRule()みたいに、後から値を追加できるメソッドを公開しれる - プロパティが少ない — 必須のものだけ受け取るから、使う側の認知負荷が低い
実践:このパターンを3プロジェクトで使ってみた
// app.ts での使用例
const stack = new cdk.Stack(app, 'WebServiceStack');
// 1. VPC を作る
const network = new CoreNetwork(stack, 'Network', {
projectName: 'my-app',
environment: 'prod',
vpcCidr: '10.0.0.0/16',
maxAzs: 3, // AZを3つにしたいなら指定
});
// 2. セキュリティグループを作る
const sgAlb = new ServiceSecurityGroup(stack, 'ALB-SG', {
vpc: network.vpc,
servicePort: 80,
});
const sgApp = new ServiceSecurityGroup(stack, 'App-SG', {
vpc: network.vpc,
servicePort: 8080,
sourceSecurityGroup: sgAlb.securityGroup, // ALB からのアクセス許可
});
// 後から追加ルールを足したいなら
sgApp.addIngressRule(
ec2.Peer.ipv4('10.1.0.0/16'), // 別VPCからのアクセス
8080,
'From another VPC'
);
// 3. ALB を作る
const lb = new ServiceLoadBalancer(stack, 'WebLB', {
vpc: network.vpc,
servicePort: 8080,
serviceName: 'web-app',
healthCheckPath: '/api/health', // カスタマイズ
});
こっちのやり方だと、別プロジェクトで「VPCのCIDRを変えたい」となっても、インスタンス化時にプロパティを変えるだけ。コンストラクト側を修正する必要がないんだ。
さらに学んだこと:バージョニングの罠
AWS CDKのカスタムコンストラクトを社内ライブラリ化すると、バージョン管理が本当に大事になる。うちのチームは、コンストラクトを @my-org/cdk-constructs という NPM パッケージにして共有してる。
最初は全部同じバージョンで、プロパティが増えたら全スタックのバージョンを上げてた。でも今は、破壊的変更を避けるために「デフォルト値を常に用意」「新しい拡張メソッドを追加」というルールで運用してるんだ。プロパティにはデフォルト値をつけて、型では Partial<Props> じゃなく Required<Props> を使う(必須にする)代わりに、デフォルト値でカバーしてる。
// こっちは避ける
export interface Props {
maxAzs?: number;
natGateways?: number;
}
// こっちにする
export interface Props {
vpcCidr: string;
maxAzs: number; // 必須だけど、コンストラクタでデフォルト値を明示的に使う
natGateways: number;
}
const vpc = new ec2.Vpc(this, 'VPC', {
cidr: props.vpcCidr,
maxAzs: props.maxAzs ?? 2, // デフォルト値
natGateways: props.natGateways ?? 1,
});
AWS構成図:うちが落ち着いた設計
graph TB
subgraph App["Application Stack"]
appStack["cdk.Stack"]
end
subgraph Constructs["Custom Constructs"]
coreNet["CoreNetwork<br/>Construct"]
sgConstruct["ServiceSecurityGroup<br/>Construct"]
lbConstruct["ServiceLoadBalancer<br/>Construct"]
end
subgraph AWS_VPC["AWS Account"]
subgraph VPC["VPC (10.0.0.0/16)"]
subgraph AZ1["AZ-1"]
pubSubnet1["Public Subnet"]
privSubnet1["Private Subnet"]
end
subgraph AZ2["AZ-2"]
pubSubnet2["Public Subnet"]
privSubnet2["Private Subnet"]
end
natGW["NAT Gateway"]
end
sg1["SG: ALB (80)"]
sg2["SG: App (8080)"]
alb["ALB<br/>80 → 8080"]
ec2s["EC2 Instances"]
end
appStack -->|"instantiates"| coreNet
appStack -->|"instantiates"| sgConstruct
appStack -->|"instantiates"| lbConstruct
coreNet -->|"creates"| VPC
coreNet -->|"manages"| pubSubnet1
coreNet -->|"manages"| privSubnet1
coreNet -->|"manages"| pubSubnet2
coreNet -->|"manages"| privSubnet2
coreNet -->|"creates"| natGW
sgConstruct -->|"creates"| sg1
sgConstruct -->|"creates"| sg2
lbConstruct -->|"creates"| alb
alb -->|"references"| sg1
alb -->|"targets"| ec2s
ec2s -->|"protected by"| sg2
パフォーマンス実測:生産性への影響
このコンストラクト設計に統一してから、チーム内でのスタック作成時間がどう変わったか見てみた。
xychart-beta
title "新規スタック作成時間の推移"
x-axis [カスタム設計v1, カスタム設計v2, 最終設計v3]
y-axis "作成時間(分)" 0 --> 60
line [45, 55, 15]
正直、v2のときはプロパティが複雑すぎて、毎回「あ、このオプションはどうするんだっけ」って確認作業が増えてた。v3に落ち着いてからは、プロパティが少なく、デフォルト値で大半がカバーできるから、シンプルに使えるようになったんだ。
運用の地雷:デプロイ時の注意
カスタムコンストラクトで多すぎる出力を返すのも、後々「あ、この値が必要だったのに」ってなるから避けた方がいい。公開する値は本当に必要なものだけにしてる。
// 悪い例:全部公開
export class NetworkConstruct extends cdk.Construct {
public readonly vpc: ec2.Vpc;
public readonly publicSubnets: ec2.ISubnet[];
public readonly privateSubnets: ec2.ISubnet[];
public readonly routeTables: ec2.IRouteTable[];
public readonly flowLogRole: iam.IRole;
public readonly vpcEndpoints: ec2.GatewayVpcEndpoint[];
// ... 10個以上
}
// 良い例:本当に必要なものだけ
export class CoreNetwork extends cdk.Construct {
public readonly vpc: ec2.Vpc;
public readonly publicSubnets: ec2.ISubnet[];
public readonly privateSubnets: ec2.ISubnet[];
}
理由は、公開した属性が増えるほど、後での修正時に「この属性を使ってるコードが何個あるの?」という依存関係の追跡が大変になるから。必要な値が出てきたら、その都度メソッドで提供する方が制御できるんだ。
まとめ
- 失敗1:Stack を継承する → Construct に統一する必要がある
- 失敗2:プロパティを過度に抽象化 → 必須項目を絞って、デフォルト値を活用する
- 成功パターン:責務分割 + デフォルト値 + 拡張メソッド → 3つの要素をバランスよく実装
- 運用の工夫:バージョニングとデプロイの堅牢性 → 破壊的変更を避ける設計が必須
- 公開する値は最小限 → 後での修正コストが段違いで低くなる
カスタムコンストラクトは、一度組み込まれると複数のスタックに波及する。だからこそ、最初の設計が本当に大事なんだよね。正直、1年運用してみると「あ、こうしておけばよかった」って気づき続けてる。でも、今の設計が安定してるから、次のバージョンアップは要望が出てからでいいかな、って感じ。