🔧

AWS CDKでCloudFrontFunctionsとKeyValueStoreを作成し、メンテモードを実装する

2024/03/18に公開

概要

S3CloudFrontでReactなどのSPAを配信する方法はコスト的にも安価(ほぼゼロ)で済むのでPoCやプロトタイプ開発のフェーズだと重宝します。
ただ、それらはその性質上、逐次更新していくため「メンテナンス中...」といった表記を挟みたくなることがしばしばあります。

今回はCloudFront Functionsを用いてメンテナンスモードを実現する方法を紹介します。
各種リソースはすべてAWS CDKで作る前提で話を進めますが、マネコンからの手動作成でも手間なく可能な内容だと思います。

CloudFront Functionsでメンテモードが実現できる仕組み

CloudFront FunctionsCloudFrontを通過するリクエスト・レスポンスに対してJSの関数を設定することでその内容を制御することができます。
似たものにLambda@Edgeがありますが、あちらより実行時間などの面での制限が厳しく、それゆえに高速での処理が可能です。
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html

そんなCloudFront Functionsで利用できるデータストアとしてKeyValueStoreが昨年リリースされました。
https://aws.amazon.com/jp/about-aws/whats-new/2023/11/amazon-cloudfront-keyvaluestore-globally-managed-key-value-datastore/

その名の通りシンプルなKVSで、用途によって様々な運用が可能です。
今回はメンテナンスモードであるかどうかのフラグの格納先として利用していますが、それ以外に例えばABテスト用のパラメータやリダイレクト先の設定などでも使えると思います。

準備

AWS CDKを用いる場合は最低でもv2.118.0から利用可能です。
https://github.com/aws/aws-cdk/releases/tag/v2.118.0

バージョンが低い場合は引き上げます。既にv2.118.0以上であれば不要です。

yarn upgrade aws-cdk@latest aws-cdk-lib@latest

実装

順番に関連するコードを見ていきます。

CloudFront Functions

まずはCloudFront Functions本体です。
先ほども書いた通り、シンプルなJSの関数になっています。
KeyValueStoreのIDですが、これは後でCDKから注入する形になるので、この段階では"KVS_ID"という値でハードコーディングしています。この文字列を後から正しい値でreplaceをかけることになります。

KeyValueStoreから得られたメンテナンスモードフラグが"true"の場合は、許可済みのアクセス元IPからのリクエストだけを通し、それ以外は503のレスポンスを返すようにしています。

CloudFront Functionsを差し込めるタイミングは2つあり、ViewerRequestViewerResponseです。ざっくり言うとリクエスト取得時とレスポンス返却時です。
今回は若干冗長な感じがしますが、両方とも使用しています。
ViewerRequestでリクエスト先を/maintenance.htmlに振り替えて、ViewerReponseで503エラーに置き換えています。

assets/viewerRequest.js
import cf from 'cloudfront';
const kvsId = 'KVS_ID';
const kvsHandle = cf.kvs(kvsId);
const ALLOW_IPS = ['xxx.xxx.xxx.xxx'];

async function handler(event) {
  const request = event.request;
  const currentUri = request.uri;
  const doReplace = request.method === 'GET' && currentUri.indexOf('.') === -1;
  const clientIp = event.viewer.ip;
  const isMaintenance = await kvsHandle.get('isMaintenance');
  if (
    convertStrToBool(isMaintenance) &&
    !ALLOW_IP_LIST.includes(clientIp) &&
    doReplace
  ) {
    request.uri = '/maintenance.html';
  }
  return request;
}

function convertStrToBool(str) {
  if (typeof str != 'string') {
    return Boolean(str);
  }
  try {
    var obj = JSON.parse(str.toLowerCase());
    return obj == true;
  } catch (e) {
    return str != '';
  }
}
assets/viewerResponse.js
import cf from 'cloudfront';
const kvsId = 'KVS_ID';
const kvsHandle = cf.kvs(kvsId);
const ALLOW_IPS = ['xxx.xxx.xxx.xxx'];

async function handler(event) {
  const response = event.response;
  const request = event.request;
  const currentUri = request.uri;
  const doReplace = request.method === 'GET' && currentUri.indexOf('.') === -1;
  const clientIp = event.viewer.ip;
  const isMaintenance = await kvsHandle.get('isMaintenance');
  if (
    convertStrToBool(isMaintenance) &&
    !ALLOW_IP_LIST.includes(clientIp) &&
    doReplace
  ) {
    response.statusCode = 503;
    response.statusDescription = 'Is Maintenance.';
  }
  return response;
}

function convertStrToBool(str) {
  if (typeof str != 'string') {
    return Boolean(str);
  }
  try {
    var obj = JSON.parse(str.toLowerCase());
    return obj == true;
  } catch (e) {
    return str != '';
  }
}

CDK

続いてCDKから必要なリソースを作成するコードです。
CloudFrontの作成自体は一部省略しています。ポイントはKeyValueStoreを作成した後で、先ほどのCloudFront FunctionsのコードからKVS_IDの値を実際のIDで置換してます。
将来的にはもっとスマートにKeyValueStoreの参照をCloudFront Functionsに引き渡す方法が生まれるかもしれませんが、現時点(2024/03/18)ではこれぐらいしか方法が内容でした。情報求。

import { Stack, StackProps } from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';

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

    // KeyValueStoreを作成
    const keyValueStore = new aws_cloudfront.KeyValueStore(stack, 'KVS');

    // CloudFront Function のコードを読み込む
    const viewerRequestScript = readFileSync(
        'assets/requestChecker.js',
        {
          encoding: 'utf-8'
        }
    ).replace(/\n/g, '');
    const viewerResponseScript = readFileSync(
        'assets/viewerResponse.js',
        {
          encoding: 'utf-8'
        }
    ).replace(/\n/g, '');

    // CloudFront Functionsの作成
    const requesetFn = new cloudfront.Function(stack, 'ViewerRequest', {
        // KeyValueStoreを指定
        keyValueStore,
        // ランタイムを設定
        runtime: cloudfront.FunctionRuntime.JS_2_0,
        // 読み込んだコードから"KVS_ID"の文字列を実際のKeyValueStoreのIDに置き換える
        code: cloudfront.FunctionCode.fromInline(
            viewerRequestScript.replace('KVS_ID',keyValueStore.keyValueStoreId)
        )
    });
    const responseFn = new cloudfront.Function(stack, 'ViewerResponse', {
        keyValueStore,
        runtime: cloudfront.FunctionRuntime.JS_2_0,
        code: cloudfront.FunctionCode.fromInline(
            viewerResponseScript.replace('KVS_ID',keyValueStore.keyValueStoreId)
        )
    });

    // CloudFrontDestribution作成
    const distribution = new cloudfront.Distribution(this, 'SampleDistribution', {
      defaultRootObject: 'index.html',
      defaultBehavior: {
        // ...一部略
        // CloudFront Functionsを紐付け
        functionAssociations: [
            // request,responseそれぞれのタイミングで紐付け
            {
                function: requestFn,
                eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
            },
            {
                function: responseFn,
                eventType: cloudfront.FunctionEventType.VIEWER_RESPONSE,
            },
        ],
      },
    });

  }
}

これでKeyValueStoreおよび、それを参照するCloudFront Functionsが完成しました。
あとは KeyValueStoreに必要な値を入れていくことになります。
※もちろん、CDKでKeyValueStoreを定義する段階で設定することも可能です。その場合は宣言時にsourceを指定します。

KeyValueStoreの設定

マネコンから設定する場合は、他のサービスと同じようにEditから行います。
1

CloudFront Functionsのロジックと整合性がとれるようにisMaintenanceのキーで値を入れていきます。ちなみにCloudFront Functionsで扱う場合には"false"といった形で文字列型として扱われるので注意です。
2

SDKから設定する

SDKから値の設定をすることも可能です。
以下はLambdaからKeyValueStoreの値を更新するサンプルコードです。

import { Handler } from 'aws-lambda';
import {
  CloudFrontKeyValueStoreClient,
  DescribeKeyValueStoreCommand,
  PutKeyCommand
} from '@aws-sdk/client-cloudfront-keyvaluestore';
import '@aws-sdk/signature-v4-crt';

const client = new CloudFrontKeyValueStoreClient({ region: 'us-east-1' });

export const handler: Handler = async () => {
  try {
    // 先にKVSを取得する
    const kvs = await client.send(
      new DescribeKeyValueStoreCommand({
        KvsARN: 'xxxxxxxxxx'
      })
    );
    // 取得したKVSのETagやARNなどの情報+更新したいKeyとValueを指定
    const data = await client.send(
      new PutKeyCommand({
        KvsARN: kvs.KvsARN,
        Key: 'isMaintenance',
        Value: 'false',
        IfMatch: kvs.ETag
      })
    );
  } catch (err) {
    console.error(err);
  }
};
export default handler;

感想

今回はCloudFront FunctionsKeyValueStoreを使って静的サイトもしくはSPAのメンテナンスモードを実現する方法をまとめました。
フロントエンドにフォーカスした内容だったので特に触れませんでしたが、メンテモードにする場合はバックエンドも対応が必要になります。
よく使うものだとALB+ECSEC2もしくはAPI Gatewayなどが挙げられます。
それぞれ方法は異なりますが、以下のような記事が参考になるかと思います。

https://zenn.dev/yumemi_inc/articles/f1f8a8269da4a9

https://zenn.dev/rescuenow/articles/c85265894b6efd

この記事の内容が役立ちましたら幸いです。

参考

https://dev.classmethod.jp/articles/aws-cdk-supports-cloudfront-keyvaluestore-l2-construct/

https://dev.classmethod.jp/articles/aws-cdk-v2-124-0-supports-association-of-cloudfront-keyvaluestore-with-functions/

https://qiita.com/yokku21/items/1fd3667b74577db5db12

Discussion