はじめに
AWS CDKでCloudFrontのオリジンを追加する際、コードの記述順序を変更しただけで既存リソースが削除・再作成されてしまう問題に遭遇したことはありませんか?
この問題の原因は、CDKの「論理ID生成」にあります。オリジンを追加する順序によってCloudFormationの論理IDが変わり、意図しないリソースの再作成が発生します。
本記事では、実際にCloudFrontのVPCオリジンで発生した事例を通じて、この問題の原因と対策を解説します。
📌 本記事のポイント
- 根本原因: CloudFrontのオリジンを追加する
addBehavior
の呼び出し順を変更するとインデックスが更新され、論理IDが更新される - 影響: VPCオリジンはCloudFrontディストリビューションに関連付けされているため更新ができずデプロイ失敗となる
- 推奨対策:
addBehavior
で追加する順序を維持する
🚨 問題シナリオ
発生した問題
以下のような構成のCloudFrontディストリビューションがあるとします。
この構成のポイントは次のとおりです。
- 複数のS3オリジンとVPCオリジンを組み合わせた構成
- 開発環境でのコスト削減
- API機能をAmazon ECS + AWS FargateではなくEC2上のDockerで稼働
- ALBを使わず、CloudFrontから直接EC2にアクセスするVPCオリジンを採用
CDKコードでは、オリジンが以下の順序で追加されています。
- Origin1:デフォルトビヘイビア(S3)
- Origin2:
/widget/*
(S3オリジン) - Origin3:
/files/*
(S3オリジン) - Origin4:
/api/*
(VPCオリジン)← 問題が発生するオリジン - Origin5:
/keycloak/*
(VPCオリジン)
this.distribution = new cloudfront.Distribution(this, "Distribution", {
defaultBehavior: {...} // Origin1(S3)
}
// 元のコード配置
dist.addBehavior("/widget/*", widgetOrigin, {...}); // Origin2(S3)
dist.addBehavior("/files/*", fileUploadOrigin, {...}); // Origin3(S3)
const backendOrigin = origins.VpcOrigin.withEc2Instance(backendInstance, {
httpPort: 8080,
// その他の設定...
}
dist.addBehavior("/api/*", backendOrigin, {...}); // Origin4(VPCオリジン) - 問題が発生するオリジン
const keycloakOrigin = origins.VpcOrigin.withEc2Instance(backendInstance, {
httpPort: 8081,
// その他の設定...
}
dist.addBehavior("/keycloak/*", keycloakOrigin, {...}); // Origin5(VPCオリジン)
後日、署名付きURLでアクセスする新しいS3オリジン privateFileUploadOrigin
を追加することになりました。
既存の fileUploadOrigin
と用途が似ているため、その直下に追加したところ、予期しない問題が発生しました。
// 変更前
dist.addBehavior("/files/*", fileUploadOrigin, {...});// Origin3
dist.addBehavior("/api/*", backendOrigin, {...}); // Origin4
// 変更後
dist.addBehavior("/files/*", fileUploadOrigin, {...}); // Origin3
dist.addBehavior("/private-files/*", privateFileUploadOrigin, {...}); // ← 新規追加
dist.addBehavior("/api/*", backendOrigin, {...}); // Origin4 → Origin5に変更!
新しいオリジンの挿入により、後続のすべてのオリジンのインデックスが1つずつ後ろにシフトしました。
- Origin1:デフォルトビヘイビア(S3)
- Origin2:
/widget/*
(S3オリジン) - Origin3:
/files/*
(S3オリジン) - Origin4:
/private-files/*
(S3オリジン)→追加 - Origin5:
/api/*
(VPCオリジン)→ Origin4 → Origin5 - Origin6:
/keycloak/*
(VPCオリジン)→ Origin5 → Origin6
この状態でcdk diff
を実行すると、予想外の結果が。
なんと、[Origin4]のVPCオリジンが削除して、新しいVPCオリジン[Origin6]を作成しようとしています。
[-] AWS::CloudFront::VpcOrigin CloudFrontDistributionOrigin4VpcOrigin11F5FD9D destroy // ✖削除
[+] AWS::CloudFront::VpcOrigin CloudFront/Distribution/Origin6/VpcOrigin CloudFrontDistributionOrigin6VpcOrigin9403AA18
[~] AWS::CloudFront::Distribution CloudFront/Distribution CloudFrontDistributionEAB06B35
:
│ [+] "PathPattern": "/private-files/*",
│ [+] "TargetOriginId": "AppStackCloudFrontDistributionOrigin407629CA8", // 💡追加
:
│ [ ] "PathPattern": "/api/*",
│ [-] "TargetOriginId": "AppStackCloudFrontDistributionOrigin407629CA8",
│ [+] "TargetOriginId": "AppStackCloudFrontDistributionOrigin5FCA7D57C", // 💡変更:Origin4→Origin5
:
│ [ ] "PathPattern": "/keycloak/*",
│ [-] "TargetOriginId": "AppStackCloudFrontDistributionOrigin5FCA7D57C",
│ [+] "TargetOriginId": "AppStackCloudFrontDistributionOrigin698A0EC33", // 💡変更:Origin5→Origin6
:
[~] AWS::CloudFront::VpcOrigin CloudFront/Distribution/Origin5/VpcOrigin CloudFrontDistributionOrigin5VpcOrigin3A40350A
└─ [~] VpcOriginEndpointConfig
└─ [~] .HTTPPort:
├─ [-] 8081 // 変更前はKeycloak用の8081
└─ [+] 8080 // 1つズレたために、api用の8080に変更となっている
つまり、次のような意図しない変更が発生したのです。
- 既存のVPCオリジン(4)を削除する
- S3のオリジン(4)を作成する
- 既存のVPCオリジン(5)のポートを変更する(8081→8080)
- 新しいVPCオリジン(6)を作成する(8081ポート)
そして、現時点では「VPCオリジンが他のディストリビューションに関連付けられている間は削除・更新できない」という制約によるエラーが発生し、スタックの更新ができなくなります。
具体的には、「既存のVPCオリジン(5)のポートを変更する」という更新が失敗します。
12:12:06 AM | UPDATE_FAILED | AWS::CloudFront::VpcOrigin | CloudFrontDistribu...5VpcOrigin3A40350A
Resource handler returned message: "Invalid request provided: AWS::CloudFront::VpcOrigin: The specified VPC origin is currently associated with one or more distributions.
Please disassociate the VPC origin from all distributions before updating or deleting (Service: CloudFront, Status Code: 409, Request ID: dc41b3d2-dc49-4e3d-8be0-0f0885d7
1) (SDK Attempt Count: 1)" (RequestToken: 43cd8ec2-4c8d-a409-151d-667d2b560939, HandlerErrorCode: InvalidRequest)
この「VPCオリジンが他のディストリビューションに関連付けられている間は削除・更新できない」については、以下のIssueでも触れられています。
また、公式ドキュメントの「VPC オリジンを更新する」に更新手順があります。
直接の更新ができないため、VPCオリジンとCloudFrontディストリビューションの関連付けを削除してから更新という手順です。
CDKの論理ID生成の仕組み
なぜこのような問題が起こるのでしょうか。AWS CDKの内部動作を見てみましょう。
論理ID生成の仕組み
AWS CDKは、すべてのリソースに対して一意の論理ID(CloudFormation上での識別子)を生成します。これは以下の要素によって決定されます。
- コンストラクトのパス(CDKコンストラクトの階層構造内での位置)
- コンストラクトのID( 親コンストラクト内での識別子)
- ハッシュ(パスコンポーネントから生成される一貫性のあるハッシュ値)
CDKのコアライブラリ内のuniqueid.ts
で定義されているmakeUniqueId
関数がこの処理の中心です。
// aws-cdk-lib/core/lib/names.ts
// https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/names.ts#L48
public static uniqueId(construct: IConstruct): string {
const node = Node.of(construct);
const components = node.scopes.slice(1).map(c => Node.of(c).id);
return components.length > 0 ? makeUniqueId(components) : '';
}
// aws-cdk-lib/core/lib/private/uniqueid.ts
// https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/private/uniqueid.ts#L33
export function makeUniqueId(components: string[]) {
components = components.filter(x => x !== HIDDEN_ID);
if (components.length === 0) {
throw new UnscopedValidationError('Unable to calculate a unique id for an empty set of components');
}
//(中略)
const hash = pathHash(components); // パスコンポーネントからハッシュを生成
const human = removeDupes(components) // 人間が読める部分を生成
.filter(x => x !== HIDDEN_FROM_HUMAN_ID)
.map(removeNonAlphanumeric)
.join('')
.slice(0, MAX_HUMAN_LEN);
return human + hash; // 最終的な論理IDを返す
}
ここでの重要なポイントは次のとおりです。
-
components
は、コンストラクトのパスを表す文字列の配列である -
pathHash
関数は、パスコンポーネントから8文字のハッシュを生成する - 最終的な論理IDは「人間が読める部分 + ハッシュ」の形式となる
CloudFrontディストリビューションでの論理ID生成
CloudFrontディストリビューションの場合、addBehavior
メソッドから呼ばれるaddOrigin
メソッドが鍵となります。
// aws-cdk-lib/aws-cloudfront/lib/distribution.ts
// https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-cloudfront/lib/distribution.ts#L625
public addBehavior(pathPattern: string, origin: IOrigin, behaviorOptions: AddBehaviorOptions = {}) {
if (pathPattern === '*') {
throw new ValidationError('Only the default behavior can have a path pattern of \'*\'', this);
}
this.validateGrpc(behaviorOptions);
const originId = this.addOrigin(origin);
this.additionalBehaviors.push(new CacheBehavior(originId, { pathPattern, ...behaviorOptions }));
}
// https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-cloudfront/lib/distribution.ts#L685
private addOrigin(origin: IOrigin, isFailoverOrigin: boolean = false): string {
const ORIGIN_ID_MAX_LENGTH = 128;
// 既存のオリジンなら、そのIDを返す
const existingOrigin = this.boundOrigins.find(boundOrigin => boundOrigin.origin === origin);
if (existingOrigin) {
return existingOrigin.originGroupId ?? existingOrigin.originId;
} else {
// 💡新しいオリジンのインデックスを計算
const originIndex = this.boundOrigins.length + 1;
// 💡このインデックスをIDにして新しいコンストラクトを作成
const scope = new Construct(this, `Origin${originIndex}`);
// 💡そのコンストラクトから一意のIDを生成
const generatedId = Names.uniqueId(scope).slice(-ORIGIN_ID_MAX_LENGTH);
const distributionId = this.distributionId;
const originBindConfig = origin.bind(scope, { originId: generatedId, distributionId: Lazy.string({ produce: () => this.distributionId }) });
const originId = originBindConfig.originProperty?.id ?? generatedId;
//(中略)
// 既存のオリジンとして追加
this.boundOrigins.push({ origin, originId, distributionId, ...originBindConfig });
// 最終的なオリジンIDを返す
return originBindConfig.originProperty?.id ?? originId;
}
}
オリジン追加の処理フローは、以下のようになります。
// 変更前
addBehavior("/api/*", backendOrigin, {...})
↓
originIndex = 4 // boundOrigins.length + 1
↓
new Construct(this, "Origin4")
↓
Logical ID: "CloudFrontDistributionOrigin4..."
// 変更後
addBehavior("/api/*", backendOrigin, {...})
↓
originIndex = 5 // Now boundOrigins.length + 1 = 5
↓
new Construct(this, "Origin5")
↓
Logical ID: "CloudFrontDistributionOrigin5..."
つまり、新しいオリジン(privateFileUploadOrigin)を既存のオリジン(backendOrigin)の前に追加したことで、backendOriginのインデックスが4
から5
に変わりました。
その結果としてCloudFormationの論理IDも変わってしまったのです。
このことから、以下のことがわかりました。
- 各オリジンには、追加された順序に基づいてオリジンインデックスが割り当てられる
- このオリジンインデックスは、オリジン用に作成される子コンストラクトのIDの一部になる
- 子コンストラクトのIDは論理ID生成に使われる
- 子コンストラクト名:
Origin4
→Origin5
- スコープパス:
Stack/AppStack/CloudFrontDistribution/Origin4
→Stack/AppStack/CloudFrontDistribution/Origin5
- 子コンストラクト名:
-
オリジンの追加順序が変わると、コンストラクトのパスが変わり、論理IDも変わる
- 生成された論理ID:
AppStackCloudFrontDistributionOrigin407629CA8
→AppStackCloudFrontDistributionOrigin5FCA7D57C
- 生成された論理ID:
実践的な対策パターン
この問題への対処方法をいくつか紹介します。
対策1: 追加順序の維持(推奨)
最もシンプルで確実な方法は、新しいオリジンを常に既存オリジンの後に追加することです。
// 良い例:新しいオリジンは既存オリジンの後ろに追加
// 既存オリジンは順番を変更しない
dist.addBehavior("/widget/*", widgetOrigin, {...});
dist.addBehavior("/files/*", fileUploadOrigin, {...});
dist.addBehavior("/api/*", backendOrigin, {...});
dist.addBehavior("/keycloak/*", keycloakOrigin, {...});
// ✅ 新しいオリジンは後ろに追加する
dist.addBehavior("/private-files/*", privateFileUploadOrigin, {...}); // 新しいオリジンは最後に追加
さらに、コメントでオリジン追加順序を明記しておくことも有効です。
// オリジン追加順序を変更すると論理IDが変わるため、以下の順序を維持すること
// Origin1: default
// Origin2: widgetOrigin
// Origin3: fileUploadOrigin
// Origin4: backendOrigin
// ...
対策2: 静的な定数オブジェクトによる管理
オリジンの順序とIDを静的に定義しておく方法です。
export const ORIGIN_DEFINITIONS: OriginConfig[] = [
// Index 0: メインのS3オリジン(デフォルト)
{
index: 0,
id: 'DefaultS3Origin',
:
behavior: {...}
},
{
index: 1,
id: 'FileUploadS3Origin',
type: 'S3_OAC',
pathPattern: '/files/*',
:
behavior: {...}
},
:
};
そして、このオブジェクトを使ってオリジンを追加します。
function generateOriginDefinitions(origins, distribution) {
const sortedOrigins = [...ORIGIN_DEFINITIONS].sort((a, b) => a.index - b.index);
sortedOrigins.forEach((originConfig) => {
if (originConfig.index > 0) { // デフォルトビヘイビア(Index 0)は設定済みなのでスキップ
const origin = createOrigin(originConfig); // 設定からオリジンを作成
distribution.addBehavior(originConfig.pathPattern, origin, originConfig.behavior);
}
});
}
この方法の利点は、オリジン追加の順序を明示的に管理でき、新しいオリジンを任意の位置に挿入できることです。
対策3: Aspectによる論理ID固定(高度)
既存環境で論理IDの変更を避けたい場合、overrideLogicalId()メソッドを使用して論理IDを固定できます。
export class VpcOriginLogicalIdOverride implements cdk.IAspect {
private overrides = new Map<string, string>();
addOverrideByOriginIndex(originIndex: number, logicalId: string): void {
this.overrides.set(`origin${originIndex}`, logicalId);
}
visit(node: IConstruct): void {
if (node.node.defaultChild &&
(node.node.defaultChild as cdk.CfnResource).cfnResourceType === 'AWS::CloudFront::VpcOrigin') {
const cfnVpcOrigin = node.node.defaultChild as cdk.CfnResource;
const nodePath = node.node.path.toLowerCase();
for (const [pathPattern, logicalId] of this.overrides) {
if (nodePath.includes(pathPattern)) {
cfnVpcOrigin.overrideLogicalId(logicalId);
break;
}
}
}
}
}
// Usage
const vpcOriginOverride = new VpcOriginLogicalIdOverride();
vpcOriginOverride.addOverrideByOriginIndex(4, 'BackendVpcOrigin');
vpcOriginOverride.addOverrideByOriginIndex(5, 'KeycloakVpcOrigin');
cdk.Aspects.of(this).add(vpcOriginOverride);
⚠️ 重要事項: この例では、簡潔にするためハードコードされたオリジンインデックス (4, 5) を使用しています。本番環境では、オリジンの追加または削除によるメンテナンス上の問題を回避するため、動的なインデックス検出を実装する必要があります。
📝 まとめ
今回発生した「オリジン追加順序によって論理IDが変わる」という問題を通じて、CDKフレームワークの内部動作を深く理解できました。
ポイントは次のとおりです。
- 順序の重要性: CDKではリソースの作成順序が論理 ID に影響する可能性を理解する
- 防御的アプローチ: 明確な理由がない限り、新しいリソースは常に最後に追加する
- ドキュメント化: コード内で順序付けの依存関係を明示的に記述する
CDKの内部動作を理解することで、より安定したインフラストラクチャをコードで管理することが可能になります。
参考リソース
- AWS CDK API Reference - CloudFront
- AWS CDK Guide - Logical IDs
- AWS CDK GitHub - uniqueid.ts
- AWS CDK GitHub - distribution.ts
- AWS CloudFormation ユーザーガイド - 論理ID
- エスケープハッチ機能
AWS CDKコミュニティの関連議論
- AWS CDK Issue #34629: VpcOrigin update error: VPCオリジンの更新に関する制約
- Issue #14866: Cloudfront Distribution: Re-ordering behaviors: CloudFront Behaviorの順序変更に関する機能要求
- Issue #1424: "Thoughts about resource IDs": CDKのリソースID生成メカニズムに関する根本的な議論
- Issue #9397: Multiple Behaviors & Origins: 複数のBehaviorとOriginを安定して管理する方法
- Issue #1594: Make it easier to explicitly control logical IDs: 論理IDの明示的制御の改善要求