はじめに

Amazon CloudFront と Amazon S3 を使った構成で SPA を動かしている場合に、リリースを単なるアーティファクトの入れ替えではなく、より安全に、より継続的に、B/G 切り替えのような形で行いたいと思ったことはありますか。今回はこれをちょっと変わった方法で実現してみたので、内容を紹介したいと思います。

最初に結論

  • 従来からある CloudFront Continuous Deployment は本気で向き合うとなるとけっこう大変
  • リクエストヘッダーで向き先を振り分けたい場合は CloudFront FunctionsKeyValueStore でも実現できる (ただしこれは簡易的な方式で、主にロールバックの容易さに焦点を当てている)
  • カナリアリリースがしたい場合は CloudFront Continuous Deployment を使ったほうがよい
  • CloudFront Functions と KeyValueStore 方式は IaC と相性がよい

本来のやりかた

CloudFront で継続的デプロイを行いたい場合、従来からあるマネージドな方法として CloudFront Continuous Deployment という選択肢があります。これは概ね以下のようなデプロイ方式を提供します。

  • 本番ディストリビューションと同等の設定を持つステージングディストリビューションを用意しておく
  • 事前に Continuous Deployment Policy を定義しておく
  • ポリシーに合わせてトラフィックをステージング/プロダクションに振り分ける
  • ステージング側で安全にテストする
  • テスト OK ならステージングの設定をプロダクションに「昇格」することでリリースする

Continuous Deployment Policy ではふたつの振り分け方式を選択でき、さまざまなユースケースで応用可能です。

  • ヘッダーによるリクエストの振り分け
  • 重み付けによるリクエストの振り分け

課題と背景

このように便利な CloudFront Continuous Deployment ですが、以下のような課題もあります。

課題 1: IaC と相性があまりよくない

AWS CDK を例にとって説明します。

AWS CDK で CloudFront を構築する場合、通常は L2 コンストラクトで構築することになるかと思います。L2 コンストラクトは CloudFormation の持つプロパティ群をいい感じに抽象化してくれますが、対応していないサービスや機能も多く、執筆時点では CloudFront Continuous Deployment も未サポートです。このため既存プロジェクトに導入するとなると L2 コンストラクト + エスケープハッチで対応するか、あるいは CloudFront 全体を L1 コンストラクトで書き直す必要があります。

また CloudFront Continuous Policy を IaC で定義する場合、まだ作成されていないディストリビューションの識別子が必要となり、循環参照が発生しやすい構造になっています。これが原因で、すべてのリソースを一撃で立ち上げることができません。リソースの作成とポリシーの紐付けを別々に行う必要があるわけです。これではたしかに、L2 コンストラクトでのサポートは難しそうだなと想像できます。サービス側の仕様であるため、おそらく Terraform などでも同様でしょう。

課題 2: ディストリビューションごと複製するのでちょっと大袈裟

B/G 切り替えとは言いつつ、実際には以下のような要件だったとしたらどうでしょうか。

  • リリース後に問題が起こった時に簡単にロールバックできさえすればいい
  • ひとつ前のバージョンに開発者がアクセスできるようにしたい

このような整理は、実際のプロジェクトにおいてもよくある妥協案かもしれません。この場合、ディストリビューションがふたつあるというのがちょっとオーバースペックに感じるのは私だけでしょうか。

課題 3: パイプライン化するとなるとツライ

これがいちばん大きいかもしれません。このツラさは私の過去記事を読んでいただければわかるかと思います。

これらの記事はやらなくていいことまでやってしまっている気もするのですが、結局はシェルスクリプトをゴリゴリ書くか、Step Functions に頼ることになります。前述のようなミニマムな要件の場合には、労力過多に陥りやすいと感じます (一方でこのような OSS もあるにはあります)

要件

改めて要件を整理します。

  • リリース後に問題が起こった時に簡単にロールバックできること
  • ひとつ前のバージョンに開発者がアクセスできること
  • IaC が壊れないこと

これらを同時に満たすにはどのような方式が最適でしょうか。

代替案

切り口としては、振り分け先として新旧のアーティファクトバージョンを保持しておき、リクエストヘッダーを見て振り分けることができればよさそうです。振り分け処理は CloudFront Functions が使えそうです。

では振り分け先はどう作ればいいでしょうか。CloudFront Functions を使うなら、ディストリビューションごと複製するのは難しそうです。以下の二つに絞り込まれるでしょう。

  • CloudFront オリジンで振り分ける
  • S3 プレフィックスで振り分ける

どちらでもいけそうな気がしますが、CloudFront オリジンはリソースで、S3 プレフィックスはデータです。IaC に影響が出にくいのは後者でしょう。ここまでで、以下のように整理できました。

  • S3 プレフィックスで新旧ふたつのアーティファクトバージョンを保持する (ここでは blue/ green/ とする)
  • CloudFront Functions で Viewer リクエストを検査し、通常のリクエストであれば green/ にアクセスさせる
  • 開発者からのリクエストであれば古いバージョンである blue/ にアクセスさせる
  • blue/ green/ を容易に反転できるようにする

重要なのは、これら blue/ green/ というプレフィックスのどちらがユーザーにとって現在アクティブなのかという情報をどこかで保持しなければならないという点です。これに対しては CloudFront KeyValueStore が最適解と言えます。ロールバック時は手動で KVS の値を書き換えるだけでよく、CloudFront Functions とはネイティブに統合されています (以降 KVS と表記します)

エッジ関数の実装

CloudFront Functions のエッジ関数について、KVS を利用するにはランタイムを cloudfront-js-2.0 にする必要があります。エッジ関数の全体像は以下のようになります。

import cf from 'cloudfront';

const kvsHandle = cf.kvs();

async function handler(event) {
    const req = event.request;
    const uri = req.uri;
    if (uri === '/api' || uri.startsWith('/api/')) {
        return req;
    }
    let target = 'blue';
    let header = null;
    if (kvsHandle) {
        if (await kvsHandle.exists('active')) {
            target = await kvsHandle.get('active');
        }
        if (await kvsHandle.exists('header')) {
            header = await kvsHandle.get('header');
        }
    }
    if (target !== 'blue' && target !== 'green') {
        console.log(`WARN: Invalid target value in KVS: ${target}. Fallback to blue.`);
        target = 'blue';
    }
    if (header) {
        const headers = req.headers || {};
        const k = header.toLowerCase();
        const h = headers[k];
        if (h && h.value && h.value.toLowerCase() === 'true') {
            target = target === 'blue' ? 'green' : 'blue';
        }
    }
    if (uri.endsWith('/') || !uri.includes('.')) {
        req.uri = '/' + target + '/index.html';
        return req;
    }
    req.uri = '/' + target + uri;
    return req;
}

SPA の基本動作

まず CloudFront + S3 構成で SPA を動かす場合の鉄則として、ディレクトリインデックスを設定する必要があります。つまり、どの URL にアクセスされても index.html などの単一ファイルを返す必要があります。これは SPA が単一ファイルですべての表示を制御するためです。この基本的な性質を制御するためのコードは以下のようになります。

    const req = event.request;

    // API オリジンに対するアクセスの場合はそのまま通す
    const uri = req.uri;
    if (uri === '/api' || uri.startsWith('/api/')) {
        return req;
    }

    ...

    // スラッシュで終わる、あるいは "." を含まない URI の場合は /<target>/index.html を返す
    if (uri.endsWith('/') || !uri.includes('.')) {
        req.uri = '/' + target + '/index.html';
        return req;
    }

    // それ以外の場合は静的ファイル (画像など) とみなす
    req.uri = '/' + target + uri;
    return req;
  • API オリジンへのアクセスならそのまま通す
  • スラッシュで終わる、または . を含まない URL の場合は /<target>/index.html を返す
  • それ以外の場合は静的ファイル (画像など) とみなし、/<target><uri> を返す

B/G 切り替えの実現

変数 target で、前述の blue/ green/ を切り替えます。まず KVS を宣言します。

const kvsHandle = cf.kvs();

B/G 切り替えのためのメインロジックについて、固まりごとにステップバイステップで説明します。まず KVS から値を取得します。

  • KVS に現在のターゲットを示す active というキーがあるか確認、あれば変数 target にその値を入れる
  • KVS に開発者アクセス時に付与するヘッダー情報を示す header というキーがあるか確認、あれば変数 header にその値を入れる
    if (kvsHandle) {
        if (await kvsHandle.exists('active')) {
            target = await kvsHandle.get('active');
        }
        if (await kvsHandle.exists('header')) {
            header = await kvsHandle.get('header');
        }
    }

次に target の値を検証します。変数 targetblue でも green でもない場合は blue にフォールバックさせます。

    if (target !== 'blue' && target !== 'green') {
        console.log(`WARN: Invalid target value in KVS: ${target}. Fallback to blue.`);
        target = 'blue';
    }

続いて、開発者からのアクセスをハンドリングします。リクエストヘッダーに変数 header が含まれる場合は開発者によるリクエストとみなし、target を反転させます。

    if (header) {
        const headers = req.headers || {};
        const k = header.toLowerCase();
        const h = headers[k];
        if (h && h.value && h.value.toLowerCase() === 'true') {
            target = target === 'blue' ? 'green' : 'blue';
        }
    }

最小限のコードで要件を満たすことができました。

KeyValueStore の構築

エッジ関数は再デプロイするとして、KVS も構築する必要があります。以下は AWS CDK で構築する場合です。

実装例

KVS の L2 コンストラクトは fromInlinefromAsset でデータを設定でき、非常に手軽です。

        // Create Key-Value Store for CloudFront Function
        const keyValueStore = new cdk.aws_cloudfront.KeyValueStore(this, 'KeyValueStore', {
            source: cdk.aws_cloudfront.ImportSource.fromInline(
                JSON.stringify({
                    data: [
                        {
                            key: 'active',
                            value: 'blue',
                        },
                        {
                            key: 'header',
                            value: 'X-Admin-Access',
                        },
                    ],
                })
            ),
        });

        // Create CloudFront Function for single page application routing
        const viewerRequestFunction = new cdk.aws_cloudfront.Function(this, 'Function', {
            functionName: `${props.stackName}ViewerRequestFunction`,
            runtime: cdk.aws_cloudfront.FunctionRuntime.JS_2_0,
            keyValueStore: keyValueStore,
            code: cdk.aws_cloudfront.FunctionCode.fromFile({
                filePath: './src/hosting/viewer-request.js',
            }),
        });

注意点

データを変更する場合、リソースが再作成される挙動になるようです。これに関連して、変更デプロイ時に以下のようなエラーが発生しました。

cannot update a stack when a custom-named resource requires replacing. Rename <名前>

このエラーに対するワークアラウンドのひとつは、名前にユニークな値を含めることです。ただし公式ドキュメントにも書かれている通り、CDK ではリソース名の明示的な指定は推奨されていません。カオスな命名に耐えられない場合にのみ参考にしてください。

        const cfnKeyValueStore = this.keyValueStore.node.defaultChild as cdk.aws_cloudfront.CfnKeyValueStore;
        const uniqueHash = cdk.Names.uniqueId(cfnKeyValueStore).slice(-8);
        cfnKeyValueStore.addPropertyOverride('Name', `${props.stackName}KeyValueStore-${uniqueHash}`);

特性

今回の文脈における KVS の重要な特性ですが、KVS に設定するデータは CloudFormation テンプレートには含まれません。このため、いくら B/G が反転しても差分として検出されることはありません。あくまでデータでありテンプレートに含まれないため、IaC の運用からは明確に切り離されます。

パイプライン

ここまでは、CloudFront Functions + KeyValueStore で実現できました。しかしデプロイ単位で担保すべき課題がまだ残っています。

  • デプロイごとに、デプロイ先となる S3 プレフィックスを決定する
  • デプロイが完了するごとに、次回のデプロイ先となる S3 プレフィックスを反転させる

これらはパイプラインの責務です。

シェルスクリプト

ビルドアーティファクトを S3 バケットにデプロイする場合、一般的には s3 sync コマンドを使うことが多いと思います。今回もこのコマンドを軸としてシェルスクリプトを書いてみました。一応 POSIX 準拠で書いています。

#!/bin/sh

set -eu

if
    ! GET_KEY_RESPONSE=$(
        aws cloudfront-keyvaluestore get-key \
            --kvs-arn "${KEYVALUESTORE_ARN}" \
            --key "active"
    )
then
    echo "ERROR: Failed to get 'active' key from key-value store." >&2
    exit 1
fi


ACTIVE=$(printf '%s' "${GET_KEY_RESPONSE}" | jq -r '.Value')


if ! DESCRIBE_KEYVALUESTORE_RESPONSE=$(
    aws cloudfront-keyvaluestore describe-key-value-store \
        --kvs-arn "${KEYVALUESTORE_ARN}"
); then
    echo "ERROR: Failed to describe key-value store." >&2
    exit 1
fi


ETAG=$(printf '%s' "${DESCRIBE_KEYVALUESTORE_RESPONSE}" | jq -r '.ETag')


if [ -z "${ACTIVE}" ] || [ -z "${ETAG}" ]; then
    echo "ERROR: Failed to retrieve 'active' target or ETag from key-value store response." >&2
    exit 1
fi
echo "INFO: Current active target: ${ACTIVE}"


case "${ACTIVE}" in
"blue")
    NEXT="green"
    ;;
"green")
    NEXT="blue"
    ;;
*)
    echo "ERROR: Invalid active target value. Expected 'blue' or 'green', but got '${ACTIVE}'." >&2
    exit 1
    ;;
esac
echo "INFO: Next deployment target: ${NEXT}"


if ! npm ci; then
    echo "ERROR: Failed to install npm dependencies." >&2
    exit 1
fi


if ! npm run build; then
    echo "ERROR: Failed to build frontend application." >&2
    exit 1
fi
echo "INFO: Frontend application build completed."


if
    ! aws s3 sync ./build/ "s3://${BUCKET_NAME}/${NEXT}/" \
        --delete \
        --exact-timestamps
then
    echo "ERROR: Failed to sync with S3." >&2
    exit 1
fi
echo "INFO: S3 sync completed: s3://${BUCKET_NAME}/${NEXT}/"


if
    ! aws cloudfront-keyvaluestore put-key \
        --kvs-arn "${KEYVALUESTORE_ARN}" \
        --key "active" \
        --value "${NEXT}" \
        --if-match "${ETAG}"
then
    echo "ERROR: Failed to update 'active' key in KeyValueStore." >&2
    exit 1
fi
echo "INFO: Switched active target to '${NEXT}'."


if
    ! INVALIDATION_ID=$(
        aws cloudfront create-invalidation \
            --distribution-id "${DISTRIBUTION_ID}" \
            --paths "/${ACTIVE}/*" \
            --query "Invalidation.Id" \
            --output text
    )
then
    echo "ERROR: Failed to create CloudFront invalidation." >&2
    exit 1
fi
echo "INFO: Invalidation created: ${INVALIDATION_ID}"


if
    ! aws cloudfront wait invalidation-completed \
        --distribution-id "${DISTRIBUTION_ID}" \
        --id "${INVALIDATION_ID}"
then
    echo "ERROR: Failed to wait for CloudFront invalidation to complete." >&2
    exit 1
fi
echo "INFO: Invalidation completed: ${INVALIDATION_ID}"


echo "INFO: Frontend deployment completed successfully!"

概ね以下のような流れです。

  • KVS の情報を取得
  • KVS から現在のターゲットを取得
  • 次回のターゲットを判定
  • 依存関係のインストール
  • ビルド
  • ビルドしたアーティファクトを次回のターゲットを示す S3 プレフィックスに同期
  • KVS の現在のターゲットを更新
  • CloudFront のキャッシュを無効化

あとはパイプラインの設定で、デプロイ時にこのスクリプトが実行されるようにしておけばよいでしょう。

おわりに

CloudFront の充実したマネージド機能は強力ですが、すべてのプロジェクトにとって最適とは限りません。今回のようにロールバック容易性さえ担保できればそれでよいというケースもあり得ます。CloudFront Functions と KeyValueStore を用いた軽量な B/G 切り替えは、IaC で構築された既存環境への導入コストを大幅に抑えることができ、運用性にも優れます。導入の障壁もさほど高くないと思いますので、ぜひ検討してみてください。