はじめに

Amazon CloudFront を API の前段に配置し、バックエンドに AWS Lambda を据える構成は一般的ですが、トラフィックが増大した際の Lambda の呼び出し回数やコスト、そしてレスポンスの遅延に悩まされたことはありませんか。今回は、リアルタイム性を維持しつつも高頻度のアクセスからバックエンドを保護するマイクロキャッシュ戦略について、実装例を交えて紹介します。

※今回のバックエンドは、関数 URL を利用して Lambda をそのまま API として利用する方式を想定しています

最初に結論

  • API のキャッシュには数秒程度のマイクロキャッシュ戦略が有効な場合がある
  • わずか数秒のキャッシュでも、スパイクに対する防御力は劇的に向上する
  • 読み取りが多いアプリケーションでパフォーマンス向上が期待できる
  • 更新後の再取得など、キャッシュを返したくない場合はアプリケーション側で制御する必要がある

キャッシュ戦略

今回 API のキャッシュにはマイクロキャッシュと呼ばれる手法を用います。これは、通常はキャッシュされないような、更新頻度の高いコンテンツをごく短時間だけ CDN にキャッシュする手法です。この言葉については、NGINX のブログがわかりやすいでしょう。

マイクロキャッシュを導入すべきかどうかは当然アプリケーションの性質に左右されますが、今回のアプリケーションはインシデント一覧のようなものがメインであり、パーソナライズされた設定もありません。このため、数秒程度の TTL であれば許容できます。

キャッシュを導入することで、瞬間的なスパイクにより同じページへのリクエストが例えば 1 秒間に 1,000 回あった場合でも、オリジンに到達するリクエストを 1 回だけに抑えることができます。特に読み取りの多いアプリケーションでパフォーマンスの向上が期待できます。

キャッシュポリシー

以下は AWS CDK で CloudFront のキャッシュポリシーを設定するコード例です。一見シンプルですが、API の配信を安定させるための意図が凝縮されています。

new cdk.aws_cloudfront.CachePolicy(this, 'ApiCachePolicy', {
    cachePolicyName: stackName + 'Api',
    comment: 'Policy for backend API hosted on AWS Lambda',
    minTtl: cdk.Duration.seconds(0),
    maxTtl: cdk.Duration.seconds(10),
    defaultTtl: cdk.Duration.seconds(10),
    headerBehavior: cdk.aws_cloudfront.CacheHeaderBehavior.allowList(PUBLIC_CACHE_CONTEXT_HEADER_NAME),
    queryStringBehavior: cdk.aws_cloudfront.CacheQueryStringBehavior.all(),
    cookieBehavior: cdk.aws_cloudfront.CacheCookieBehavior.none(),
    enableAcceptEncodingGzip: true,
    enableAcceptEncodingBrotli: true,
});

TTL 設定

min, max, default の 3 つの設定は、互いに補完し合ってキャッシュの有効期間を決定します。

minTtl は、今回の最重要ポイントです。これを 0 に設定することで、オリジンが返却する Cache-Control ヘッダーを CloudFront が尊重するようになります。もし 1 秒以上に設定してしまうと、API 側が no-cache を返しても CloudFront は強制的にキャッシュを保持してしまいます。0 に設定して初めて、アプリケーション側からのバイパス要求がエッジを通り抜けることができます (バイパスに関しては後述します)

maxTtl は、オリジン側が誤って長いキャッシュ時間を返してきたとしても、エッジ側で強制的にこの値に抑え込みます。この設定が、データの鮮度を一定に保つための境界線になります。今回は 10 秒に設定しています。アプリケーションの性質に合わせてチューニングが必要です。

defaultTtl は、API 側からキャッシュ制御の指示がない場合に採用される値です。これは maxTtl と同一にしています。

精度

API はクエリパラメーターによってレスポンスが変わるため、キャッシュが難しいとされます。今回の設定では queryStringBehavior.all() を指定してすべてのクエリパラメーターをキャッシュキーに含めています。これによりページネーションやフィルター結果が混ざるリスクを排除することができます。つまりキャッシュヒット率よりも精度を優先しているわけです。

また、headerBehavior.allowList(...) でキャッシュ制御用のヘッダーをキーに含めることで、指定されたヘッダーのみがキャッシュキーに含まれるようにしています。

課題

マイクロキャッシュ戦略の最大の弱点は更新直後の不整合です。つまりデータを PUT/POST した直後の GET リクエストで、キャッシュされた古いデータが表示されてしまう問題です。

ユーザーからすれば保存ボタンを押したのに、一覧に戻ったら反映されていないという、バグに近い挙動に見えてしまいます。この課題を解決するには、CloudFront の設定だけでなく、アプリケーション側(UI と API)の連携が必要不可欠です。

解決策

この問題にはアプリケーション側で対処するしかありません。TTL の説明で「バイパス」という言葉が出てきましたが、特定のコンテキストでだけキャッシュをバイパスする仕組みが必要です。

今回採用したのは、更新直後のリクエストにのみ「キャッシュを無視してくれ」という意思表示のヘッダーを付与し、API 側でそれに応える方式です。

UI 側の実装

リソースの更新が完了した直後の再取得、つまりコールバック処理におけるリフレッシュ時に、通常の GET とは異なる特別なヘッダーを付与します。

// cacheContextHeaders はキャッシュをバイパスするための HTTP ヘッダーを定義します。
export const cacheContextHeaders = (): HeadersInit => {
    const kv = PUBLIC_CACHE_CONTEXT_HEADER.split(':');
    return {
        [kv[0].trim()]: kv[1].trim(),
        'Cache-Control': 'no-cache, no-store, must-revalidate'
    };
};

ポイントは PUBLIC_CACHE_CONTEXT_HEADER で特定のヘッダーを指定することで、どのリクエストがキャッシュを回避すべきかを制御している点です。例えば X-Cache-Context: bypass のようなヘッダーを思い浮かべてください。

API 側の実装

API 側では、リクエストヘッダーに期待される値が含まれているかをチェックし、一致する場合はレスポンスの Cache-Control を上書きします。以下は labstack/echo で API サーバーを構築している場合の例です。

// cacheControl は与えられた v の値に応じて Cache-Control ヘッダーを設定します。
func cacheControl(c echo.Context, v string) {
    switch v {
    case "bypass":
        // 一致した場合はキャッシュを持たせない設定をレスポンスヘッダーにセット
        c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
    }
}

フロー

この仕組みにより、以下のようなフローが実現します。

  • PUT/POST で、ユーザーがデータを変更する
  • 直後の GET で、UI は cacheContextHeaders() を付与してリクエストする
  • CloudFront は headerBehavior.allowList() にこのヘッダーが含まれているため、キャッシュを介さず直接オリジンへリクエストを送信する
  • API はヘッダーを検知し、レスポンスに no-cache を付与する
  • UI はキャッシュからではなくオリジンから最新のデータを受信する

戻りますが、重要なのはキャッシュポリシー設定の以下の部分です。allowList に UI 側で付与しているカスタムヘッダーの名前を意味する PUBLIC_CACHE_CONTEXT_HEADER_NAME を設定しています。これを含めることで、CloudFront にこのヘッダーが付いているリクエストは、付いていないリクエストとは別の扱いが必要と伝えています。

    headerBehavior: cdk.aws_cloudfront.CacheHeaderBehavior.allowList(PUBLIC_CACHE_CONTEXT_HEADER_NAME),

Pros/Cons

Pros/Cons を整理してみましょう。ざっくりになりますが、まとめると以下のようになるかと思います。

Pros

  • アプリケーションの性質に合わせて適切に設計されていれば、オリジンの負荷を大幅に軽減することができる
  • うまくいかない場合、キャッシュポリシーを CachingDisabled に差し替えるだけですぐに切り戻しできる

Cons

  • キャッシュポリシーだけでなくアプリケーション側の設計も必要で、導入障壁が少し高い
  • インフラ、API、UI といったアプリケーションを構成するコンポーネント全体での整合性が必要

おわりに

CloudFront 経由で配信する API のマイクロキャッシュ戦略について紹介しました。また、キャッシュポリシーを設定すれば OK ということではなく、アプリケーション側でも適切な設計を行う必要がある点をコード例を交えながら説明しました。うまくいけば効果を発揮する手法だと思いますので、ぜひ検討してみてください。