atama plus techblog
🚀

CDK x CloudFront FunctionsでKeyValueStoreのデプロイが失敗した話

に公開

はじめに

こんにちは、atama plus株式会社 でWebエンジニアをしているbuko106です。 弊社ではSREだけでなくWEBアプリケーションエンジニアもインフラ関係のコードを書ける基盤が整えられており、CDKを用いたインフラ構築を行う機会が増えてきています。

今回は、CDKでCloudFront FunctionsのKeyValueStoreをデプロイする際に発生した問題とその解決方法について紹介します。

TL;DR

CDKでCloudFront FunctionsのKeyValueStoreの内容を更新すると、CloudFormationのデプロイが失敗します。これは、KeyValueStoreの内容が変わってもCDKが自動生成する物理名が変更されないため、CloudFormationが置き換えを実行できないことが原因です。

解決方法は以下の2つ:

  1. 物理名にハッシュを含める
const kvsSourceHash = createHash('md5').update(kvsSourceStr).digest('hex').slice(0, 8);
const keyValueStore = new cloudfront.KeyValueStore(this, 'KeyValueStore', {
  keyValueStoreName: `RedirectUrlStore-${kvsSourceHash}`,
  source: cloudfront.ImportSource.fromInline(kvsSourceStr),
});
  1. リソースIDにハッシュを含める (今回はこちらを選択)
const kvsSourceHash = createHash('md5').update(kvsSourceStr).digest('hex').slice(0, 8);
const keyValueStore = new cloudfront.KeyValueStore(
  this,
  `KeyValueStore${kvsSourceHash}`,
  {
    source: cloudfront.ImportSource.fromInline(kvsSourceStr),
  },
);

どちらを選ぶべきか:

  • 物理名を明示的に管理したい場合(CloudFormationで名前を追跡しやすい)→ 方法1
  • 複数環境での名前衝突を避けたい場合(同一アカウント内にdev/stg/prodなど)→ 方法2

この記事の主な対象者

  • CDKを使っていて、Lambda@EdgeやCloudFront Functionsを検討している方
  • CloudFront Functionsは使っているがKeyValueStoreは使ったことがない方

実装したかったこと

CloudFront Functionsは、CloudFrontのエッジロケーションで実行される軽量なJavaScript処理を提供するサービスです。同じくCloudFrontで動作するLambda@Edgeよりも軽量という特徴があります[1]。KeyValueStoreを使うことで、関数のコードを変更せずに設定値を更新できます。

今回は自社で管理する短縮URLサービスを構築するために、KeyValueStoreに保存したURLにリダイレクトする関数を実装しました。リダイレクト処理の基本的な実装はこちらの記事を参考にしました。以下は、KeyValueStoreに保存したURLにリダイレクトするCloudFront Functionのコード例です。

lib/redirect.js
import cf from 'cloudfront';

const kvsHandle = cf.kvs();

async function handler() {
  const redirectUrl = await kvsHandle.get('url');
  /**
   * ここに詳細なロジックを追加できる
   */
  return {
    statusCode: 302,
    statusDescription: 'Found',
    headers: { location: { value: redirectUrl } },
  };
}
CDKのコード抜粋
export class RecreateKvsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // Key-Value Storeのソースデータをインラインで定義
    const kvsSourceStr = JSON.stringify({
      data: [{ key: 'url', value: 'https://recruite.example.com' }],
    });

    // Key-Value Storeの作成
    const keyValueStore = new cloudfront.KeyValueStore(this, 'KeyValueStore', {
      source: cloudfront.ImportSource.fromInline(kvsSourceStr),
    });

    // CloudFront Functionでリダイレクト処理
    const redirectFunction = new cloudfront.Function(this, 'Function', {
      code: cloudfront.FunctionCode.fromFile({
        filePath: 'lib/redirect.js',
      }),
      keyValueStore,
      runtime: cloudfront.FunctionRuntime.JS_2_0,
    });
  }
}
CDKのコード完全版
recreate-kvs-stack.ts
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origin from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cdk from 'aws-cdk-lib/core';
import type { Construct } from 'constructs';

export class RecreateKvsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // オリジン用のダミーバケット
    const originBucket = new s3.Bucket(this, 'OriginBucket', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Key-Value Storeのソースデータをインラインで定義
    const kvsSourceStr = JSON.stringify({
      data: [{ key: 'url', value: 'https://recruite.example.com' }],
    });

    // Key-Value Storeの作成
    const keyValueStore = new cloudfront.KeyValueStore(this, 'KeyValueStore', {
      source: cloudfront.ImportSource.fromInline(kvsSourceStr),
    });

    // CloudFront Functionでリダイレクト処理
    const redirectFunction = new cloudfront.Function(this, 'Function', {
      code: cloudfront.FunctionCode.fromFile({
        filePath: 'lib/redirect.js',
      }),
      keyValueStore,
      runtime: cloudfront.FunctionRuntime.JS_2_0,
    });

    // CloudFront ディストリビューションを作成
    new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: origin.S3BucketOrigin.withOriginAccessControl(originBucket),
        functionAssociations: [
          {
            function: redirectFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
    });
  }
}

遭遇した問題

KeyValueStoreの内容を変更(ここでは単純なタイポ修正)して再デプロイを試みると、以下のエラーが発生しました。

タイポ修正
     const kvsSourceStr = JSON.stringify({
-      data: [{ key: 'url', value: 'https://recruite.example.com' }],
+      data: [{ key: 'url', value: 'https://recruit.example.com' }],
     });
$ cdk deploy
RecreateKvsStack: deploying... [1/1]
RecreateKvsStack: creating CloudFormation changeset...
2:55:38 PM | UPDATE_FAILED        | AWS::CloudFront::KeyValueStore       | KeyValueStoreA7449E89
CloudFormation cannot update a stack when a custom-named resource requires replacing. Rename RecreateKvsStackKeyValueStoreDF7C4DA8 and update the stack again.

❌  RecreateKvsStack failed: ToolkitError: The stack named RecreateKvsStack failed to deploy: UPDATE_ROLLBACK_COMPLETE: CloudFormation cannot update a stack when a custom-named resource requires replacing. Rename RecreateKvsStackKeyValueStoreDF7C4DA8 and update the stack again.

原因

CloudFormation cannot update a stack when a custom-named resource requires replacing. とあるように、KeyValueStoreリソースの置き換えができないことが原因です。

CloudFormationでは、一部のリソースは変更が加えられた場合に置き換え(削除→再作成)が必要となります。しかし、KeyValueStoreの内容が変わってもCDKが自動生成する物理名は変更されないため、CloudFormationが同じ名前のリソースを置き換えようとして失敗します。[2]

解決方法1: 物理名にハッシュを含める

KeyValueStoreのソースデータのハッシュ値を物理名に含めることで、内容が変わるたびに異なる物理名を持つリソースが作成されます。「自動で生成されるリソース名を使用し、物理的な名前を使用しない」というのが基本的なCDKのベストプラクティス[3]ですが、説明のために物理名を指定しています。

import { createHash } from 'crypto';
     const kvsSourceStr = JSON.stringify({
-      data: [{ key: 'url', value: 'https://recruite.example.com' }],
+      data: [{ key: 'url', value: 'https://recruit.example.com' }],
     });

+    // ソースデータ文字列のハッシュを計算
+    const kvsSourceHash = createHash('md5')
+      .update(kvsSourceStr)
+      .digest('hex')
+      .slice(0, 8);
+
     // Key-Value Storeの作成
     const keyValueStore = new cloudfront.KeyValueStore(this, 'KeyValueStore', {
+      keyValueStoreName: `RedirectUrlStore-${kvsSourceHash}`,
       source: cloudfront.ImportSource.fromInline(kvsSourceStr),
     });

cdk diff で確認すると、KeyValueStoreが再作成されることがわかります。

cdk diffの出力
Stack RecreateKvsStack
Resources
[~] AWS::CloudFront::KeyValueStore KeyValueStore KeyValueStoreA7449E89 replace
 ├─ [~] ImportSource
 │   └─ [~] .SourceArn:
 │       └─ [~] .Fn::Join:
 │           └─ @@ -9,6 +9,6 @@
 │              [ ]     {[ ]       "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"[ ]     },
 │              [-]     "/a5c35bc1d20c22045a08552139ac204e610f4b43b77a82e2a8004b0741b6c4f1.json"[+]     "/fd0354be47b43b360a171cf2b2cca4a5d016e45938da7cea082206539671478b.json"[ ]   ][ ] ]
 └─ [~] Name (requires replacement)
     ├─ [-] RecreateKvsStackKeyValueStoreDF7C4DA8
     └─ [+] RedirectUrlStore-b62cafde

解決方法2: リソースIDにハッシュを含める (今回はこちらを選択)

KeyValueStoreの物理名を指定する代わりに、CDKのリソースIDにハッシュを含める方法もあります。1つのAWSアカウント内に複数の環境が存在するケースで、リソース名の衝突を避けるために有効です。

     const kvsSourceStr = JSON.stringify({
-      data: [{ key: 'url', value: 'https://recruite.example.com' }],
+      data: [{ key: 'url', value: 'https://recruit.example.com' }],
     });

+    // ソースデータ文字列のハッシュを計算
+    const kvsSourceHash = createHash('md5')
+      .update(kvsSourceStr)
+      .digest('hex')
+      .slice(0, 8);
+
     // Key-Value Storeの作成
-    const keyValueStore = new cloudfront.KeyValueStore(this, 'KeyValueStore', {
-      source: cloudfront.ImportSource.fromInline(kvsSourceStr),
-    });
+    const keyValueStore = new cloudfront.KeyValueStore(
+      this,
+      `KeyValueStore${kvsSourceHash}`,
+      {
+        source: cloudfront.ImportSource.fromInline(kvsSourceStr),
+      },
+    );

さいごに

今回は、CDKでCloudFront FunctionsのKeyValueStoreをデプロイする際に発生した問題とその解決方法について紹介しました。CloudFormationの置き換え制約を理解し、適切に物理名やリソースIDを管理することで、スムーズなデプロイが可能になります。

atama plus では一緒に働く仲間を募集しています。
https://herp.careers/v1/atamaplus

脚注
  1. CloudFront Functions と Lambda@Edge の違い(公式ドキュメント) ↩︎

  2. リソースとプロパティのリファレンス AWS::CloudFront::KeyValueStore に、ImportSourceのアップデートは出来ないと記されています ↩︎

  3. AWS CDKでクラウドアプリケーションを開発するためのベストプラクティス | Amazon Web Services ブログ ↩︎

atama plus techblog
atama plus techblog

Discussion