【AWS CDK】CloudFrontオリジンの順序を変えただけでデプロイが失敗する理由と対策


![cover](https://github.com/ishiharatma/public-contents-images/blob/main/aws-cdk-cloudfront-origin-logical-id-deep-dive/cover.jpg?raw=true)

## 👋 はじめに

AWS CDKでCloudFrontのオリジンを追加する際、コードの記述順序を変更しただけで既存リソースが削除・再作成されてしまう問題に遭遇したことはありませんか？
この問題の原因は、CDKの「論理ID生成」にあります。オリジンを追加する順序によってCloudFormationの論理IDが変わり、意図しないリソースの再作成が発生します。
本記事では、実際にCloudFrontのVPCオリジンで発生した事例を通じて、この問題の原因と対策を解説します。

## 📌 本記事のポイント

- 根本原因: CloudFrontのオリジンを追加する `addBehavior`の呼び出し順を変更するとインデックスが更新され、論理IDが更新される
- 影響: VPCオリジンはCloudFrontディストリビューションに関連付けされているため更新ができずデプロイ失敗となる
- 推奨対策: `addBehavior`で追加する順序を維持する（防衛的コーディング）

## 🚨 問題シナリオ

### 発生した問題

以下のような構成のCloudFrontディストリビューションがあるとします。

![overview](https://github.com/ishiharatma/public-contents-images/blob/main/aws-cdk-cloudfront-origin-logical-id-deep-dive/overview.drawio.svg?raw=true)

この構成のポイントは次のとおりです。

- 複数のS3オリジンとVPCオリジンを組み合わせた構成
- 開発環境でのコスト削減
  - API機能をAmazon ECS + AWS FargateではなくEC2上のDockerで稼働
  - ALBを使わず、CloudFrontから直接EC2にアクセスするVPCオリジンを採用

CDKコードでは、オリジンが以下の順序で追加されています。

- Origin1:デフォルトビヘイビア（S3）
- Origin2:`/wedgit/*`（S3オリジン）
- Origin3:`/files/*`（S3オリジン）
- Origin4:`/api/*`（VPCオリジン）← 問題が発生するオリジン
- Origin5:`/keycloak/*`（VPCオリジン）

```typescript
this.distribution = new cloudfront.Distribution(this, "Distribution", {
  defaultBehavior: {...} // Origin1（S3）
}
// 元のコード配置
dist.addBehavior("/wedgit/*", wedgitOrigin, {...});  // 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` と用途が似ているため、その直下に追加したところ、予期しない問題が発生しました。

```typescript
// 変更前
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:`/wedgit/*`（S3オリジン）
- Origin3:`/files/*`（S3オリジン）
- Origin4:`/private-files/*`（S3オリジン）→追加
- Origin5:`/api/*`（VPCオリジン）→ Origin4 → Origin5
- Origin6:`/keycloak/*`（VPCオリジン）→ Origin5 → Origin6

![overview2](https://github.com/ishiharatma/public-contents-images/blob/main/aws-cdk-cloudfront-origin-logical-id-deep-dive/overview2.drawio.svg?raw=true)

この状態で、`cdk diff`を行なうと、[Origin4]のVPCオリジンが削除され、[Origin6]が追加される結果になりました。

```sh
[-] 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 // １つズレたために、api用の8080に変更となっている
```

つまり、次のような意図しない変更が発生したのです。

- 既存のVPCオリジン(4)を削除する
- S3のオリジン(4)を作成する
- 既存のVPCオリジン(5)のポートを変更する(8081→8080)
- 新しいVPCオリジン(6)を作成する(8081ポート)

そして、現時点では「VPCオリジンが他のディストリビューションに関連付けられている間は削除・更新できない」という制約によるエラーが発生し、スタックの更新ができなくなります。
具体的には、「既存のVPCオリジン(5)のポートを変更する」という更新が失敗します。

```sh
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でも触れられています。

https://github.com/aws/aws-cdk/issues/34629

また、公式ドキュメントの「[VPC オリジンを更新する](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html#update-vpc-origin)」に更新手順があります。
直接の更新ができないため、VPCオリジンとCloudFrontディストリビューションの関連付けを削除してから更新という手順です。

### なぜこのような問題が発生するのか？

この問題を理解するためには、AWS CDKの論理ID生成メカニズムを深く掘り下げる必要があります。

## 🔍 CDKの論理ID生成メカニズムの技術的詳細

### CDK論理ID生成の基本的な仕組み

AWS CDKは、すべてのリソースに対して一意の論理ID（CloudFormation上での識別子）を生成します。これは以下の要素によって決定されます。

1. コンストラクトのパス: CDKコンストラクトの階層構造内での位置
2. コンストラクトのID: 親コンストラクト内での識別子
3. ハッシュ: パスコンポーネントから生成される一貫性のあるハッシュ値

CDKのコアライブラリ内の`uniqueid.ts`で定義されている`makeUniqueId`関数がこの処理の中心です。

```typescript
// 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`メソッドが論理ID生成の鍵を握っています。

```typescript
// 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;
  }
}
```

オリジン追加の処理フローは、以下のようになります。

```sh
// 変更前
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も変わってしまったのです。

このことから、以下のことがわかりました。

1. 各オリジンには、追加された順序に基づいてオリジンインデックスが割り当てられる
2. このオリジンインデックスは、オリジン用に作成される子コンストラクトのIDの一部になる
3. 子コンストラクトのIDは論理ID生成に使われる
    - 子コンストラクト名: `Origin4` → `Origin5`
    - スコープパス: `Stack/AppStack/CloudFrontDistribution/Origin4` → `Stack/AppStack/CloudFrontDistribution/Origin5`
4. **オリジンの追加順序が変わると、コンストラクトのパスが変わり、論理IDも変わる**
    - 生成された論理ID: `AppStackCloudFrontDistributionOrigin407629CA8` → `AppStackCloudFrontDistributionOrigin5FCA7D57C`

## 💡 実践的な対策パターン

この問題に対処するためのパターンは次のようなものがあります。

### 対策1: 追加順序の維持（防衛的コーディング）

新しいオリジンは常に既存のオリジンの後に追加するよう、コードの構造化を徹底します。

```typescript
// 良い例：新しいオリジンは既存オリジンの後ろに追加
// 既存オリジンは順番を変更しない
dist.addBehavior("/wedgit/*", wedgitOrigin, {...});
dist.addBehavior("/files/*", fileUploadOrigin, {...});
dist.addBehavior("/api/*", backendOrigin, {...});
dist.addBehavior("/keycloak/*", keycloakOrigin, {...});
// ✅ 新しいオリジンは後ろに追加する
dist.addBehavior("/private-files/*", privateFileUploadOrigin, {...}); // 新しいオリジンは最後に追加
```

さらに、コメントでオリジン追加順序を明記しておくことも有効です。

```typescript
// オリジン追加順序を変更すると論理IDが変わるため、以下の順序を維持すること
// Origin1: default
// Origin2: wedgitOrigin
// Origin3: fileUploadOrigin
// Origin4: backendOrigin
// ...
```

### 対策2: 静的な定数オブジェクトによる管理

オリジンの順序とIDを静的な定数オブジェクトとして定義しておくアプローチも効果的です。

```typescript
export const ORIGIN_DEFINITIONS: OriginConfig[] = [
  // Index 0: メインのS3オリジン（デフォルト）
  {
    index: 0,
    id: 'DefaultS3Origin',
    :
    behavior: {...}
  },
  {
    index: 1,
    id: 'FileUploadS3Origin',
    type: 'S3_OAC',
    pathPattern: '/files/*',
    :
    behavior: {...}
  },
  :
};
```

そして、このオブジェクトを使ってオリジンを追加します。

```typescript
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の変更を避けたい場合、overrideLogicalId()メソッドを使用して論理IDを固定できます。

```typescript
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](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront-readme.html)
- [AWS CDK Guide - Logical IDs](https://docs.aws.amazon.com/cdk/v2/guide/identifiers.html)
- [AWS CDK GitHub - uniqueid.ts](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/private/uniqueid.ts)
- [AWS CDK GitHub - distribution.ts](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-cloudfront/lib/distribution.ts)
- [AWS CloudFormation ユーザーガイド - 論理ID](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resources-section-structure.html#resources-section-logical-id)
- [エスケープハッチ機能](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/cfn-layer.html#develop-customize-escape)

### AWS CDKコミュニティの関連議論

- [AWS CDK Issue #34629: VpcOrigin update error](https://github.com/aws/aws-cdk/issues/34629): VPCオリジンの更新に関する制約
- [Issue #14866: Cloudfront Distribution: Re-ordering behaviors](https://github.com/aws/aws-cdk/issues/14866): CloudFront Behaviorの順序変更に関する機能要求
- [Issue #1424: "Thoughts about resource IDs"](https://github.com/aws/aws-cdk/issues/1424): CDKのリソースID生成メカニズムに関する根本的な議論
- [Issue #9397: Multiple Behaviors & Origins](https://github.com/aws/aws-cdk/issues/9397): 複数のBehaviorとOriginを安定して管理する方法
- [Issue #1594: Make it easier to explicitly control logical IDs](https://github.com/aws/aws-cdk/issues/1594): 論理IDの明示的制御の改善要求