GraphQL caching ③プロダクションリリースまでに解決した課題、導入による効果

# はじめに
本記事は以下の三本立てシリーズの三本目の記事です。

[①導入の背景と目的](https://qiita.com/datahaikuninja/items/f22fde98e76602f12691)
[②AWS Prototyping program を利用した開発](https://qiita.com/datahaikuninja/items/e3c5302602c38b6a8b71)
③プロダクションリリースまでに解決した課題、導入による効果

本記事では、以下のトピックを扱います。
- ASL(Amazon Software License)について
- GraphQL cachingの仕組み
- プロダクションリリースまでに解決した課題
- 導入による効果

# ASL(Amazon Software License)について
本記事で紹介する GraphQL cachingのLambdaコードは、AWS Prototyping programを利用して開発したものです。このプログラムで開発した成果物は、ASL(Amazon Software License)に従って使用することができます。

https://aws.amazon.com/jp/asl/

Apache License 2.0と似ていますが、AWS上での使用を認める、など異なる点もあります。

本記事では、GraphQL cachingを実現しているコードのコア部分を公開しますが、もし参考にされる場合は上記ライセンスをご一読ください。

# GraphQL cachingの仕組み
プロダクションリリースまでにはいくつも課題がありました。後ほど、どのようにして課題をクリアしたのか紹介するために、まずは[LOWYA](https://www.low-ya.com/)で実際に動いている GraphQL cachingの仕組みを説明します。

## アーキテクチャ
構成は次の通りです。
- CloudFront
- Lambda@Edge (略称=L@E)
    - Python 3.9 runtime
- CloudFront Function (略称=CF2)

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/160087ea-8864-1bea-7c6a-40ca3a2730c7.png)

### 各サービスの役割
#### CloudFront
1. Viewer がAPIエンドポイントに向けて発行したPOSTリクエストを受け付ける。
2. Origin Requestで動作するL@Eが発行したGETリクエストを受け付ける。または、L@Eを迂回して直接オリジンへPOSTリクエストを送信する。（後述する暴露対策のため）
3. キャッシュHitした場合は、L@Eへキャッシュオブジェクトを返却する。キャッシュオブジェクトが存在しない場合は、もう一度オリジンへリクエストを送信する。
4. Response Headers Policyにより、Viewerに返却するレスポンスに値が静的なレスポンスヘッダーを付与する。

#### Lambda@Edge
1. POSTからGETへメソッド変換&リクエストボディを分割してキャッシュキーとなるヘッダーに格納し、CloudFrontへリクエストし直す。**なお（レスポンスを）キャッシュできないリクエストの場合は、CloudFront からオリジンへ直接リクエストさせる。**
2. CloudFrontでキャッシュHitした場合、返ってきたキャッシュオブジェクトを使ってレスポンスを作成して CloudFrontへレスポンスを返す。
3. キャッシュHitしない場合、CloudFrontから L@EへGETリクエストが返ってくるので、GETからPOSTへメソッド変換&キャッシュキーのリクエストヘッダーから元々のリクエストボディを取り出して結合して、オリジンへリクエストを発行する。
4. オリジンから返ってきたレスポンスを受け取り、Viewer へ返却するレスポンスを作成して CloudFront へレスポンスを返す。

#### CloudFront Functions
1. Viewer Responseで動作し、Viewerへ返却するレスポンスに値が動的なレスポンスヘッダーを付与する。

### GraphQL のレスポンスを CloudFront にキャッシュさせる方法
CloudFrontはPOSTリクエストに対するレスポンス結果をキャッシュしないため、通常はGraphQLリクエストのレスポンスをキャッシュできません。CloudFrontは単に、Viewerが送信したPOSTリクエストをオリジンへ転送します。

そこで、Origin Requestで動作するL@EでPOSTからGETへメソッド変換します。また、POSTリクエストのボディを1783文字で分割して`Payload0~4`というカスタムヘッダーに格納し、リクエストヘッダーを作成します。これらのヘッダーは、CloudFrontのキャッシュポリシーにおいてキャッシュキーとして使用されます。

1783文字で分割するのは、CloudFrontで使用できるカスタムヘッダーの最大長が1783文字だからです。また、キャッシュキーが`Payload0~4`の5つなのは、CloudFrontで結合されるカスタムヘッダー値および名前の最大長が10240文字であるためです。
1783 * 5 = 8915 なので1325文字の余裕がありますが、この余裕はキャッシュキーとする`Payload0~4`以外のカスタムヘッダーで使用します。

L@EでPOSTからGETへメソッド変換し、POSTリクエストのボディを分割してキャッシュキーとするヘッダーに格納してCloudFrontへGETリクエストを発行することで、CloudFrontがキャッシュオブジェクトを作成できる状況を作ります。

初回リクエスト時はキャッシュオブジェクトがないため、CloudFrontはL@Eが発行したGETリクエストをオリジンへ転送します。このとき、2回目のOriginRequestでL@Eが再動作します。

今度は、GETからPOSTへメソッド変換し、`Payload0~4`のヘッダー値を取り出して結合し、リクエストボディを作成します。つまり、Viewerが送信してきた元々のGraphQLリクエストを作り直します。そして、L@EはオリジンへPOSTリクエストを発行して、オリジンからレスポンスを受け取ります。

L@Eはオリジンから受け取ったレスポンスを、CloudFrontが転送してきたGETリクエスト（自分自身が発行したGETリクエスト）に対するレスポンスとして、CloudFrontへ返します。CloudFrontはGETリクエストのレスポンスからキャッシュオブジェクトを作成します。

GETリクエストを発行したのは L@Eなので、CloudFrontからレスポンスが返ってきます。L@EはCloudFrontから返ってきたレスポンスを使用して、最初にViewerが送信したPOSTリクエストに対するレスポンスを作成して、CloudFrontへ返します。

CloudFrontはL@Eから作成したレスポンスをViewerへ返します。

### キャッシュ可/不可の制御
リクエストの中には、キャッシュ可のGraphQL queryと不可のqueryがあります。以下の条件のいずれかに当てはまるリクエストは、CloudFrontにレスポンスをキャッシュさせないようにしています。
- 認証に必要なヘッダーが欠けているリクエスト
- レスポンスにユーザーの個人情報が含まれるリクエスト
- リクエストボディのサイズが大きく、キャッシュキーとなるヘッダーに格納しきれないリクエスト

これらのリクエストはL@Eを迂回して、CloudFrontとオリジンが直接やりとりする必要があります。L@Eを迂回するとPOST→GETへのメソッド変換が行われないので、CloudFrontはキャッシュオブジェクトを作成しません。

以下がイメージ図です。レスポンスをキャッシュしてもよいリクエストかどうか、最初のOrigin Requestで判定します。
![if-cacheable-then-else.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/c2af9db1-f8cf-67f1-eef4-d03f07ebafbf.png)


特に二つ目の条件が重要で、ユーザーの個人情報が含まれるレスポンスをキャッシュしてしまうと、ユーザーの個人情報が流出するキャッシュ事故が発生します。[^1]

### キャッシュHitした場合の挙動
キャッシュHitした場合は次のように処理されます。キャッシュHitしない場合は最初のアーキテクチャ図で処理されます。
![if-cache-hit-then-else.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/5bb94b5d-8a10-93e2-37e2-ce0e208ceca9.png)

## コード
GraphQL caching のコアである L@E のコードを紹介します。長いので折り畳んでおきます。
<details><summary>convert-http-method.py</summary>

```python
# reference to https://github.com/aws-samples/amazon-cloudfront-cache-graphql
# note to https://aws.amazon.com/jp/asl/
# coding=utf-8
import urllib.request
import urllib.parse
import urllib.error
import json
import base64
import ssl
# Lambda@EdgeからOriginへリクエストするときに "certificate verify failed: Hostname mismatch" のエラーが発生する事象の回避策
ssl._create_default_https_context = ssl._create_unverified_context
from urllib.parse import parse_qs
from urllib.parse import quote

# キャッシュできる Payload のサイズは Header 値の最大に設定している
# 参考: https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html
CACHE_PAYLOAD_SIZE_LIMIT = 1783

# Payload 分割数の最大値
# 「結合されるすべてのヘッダー値および名前の最大長 (10240)」を「ヘッダー値の最大長 (1783)」で割った数にしている
# 参考: https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html
NUM_SPLIT_MAX = 5

# キャッシュ対象のエンドポイント
# それ以外はバイパス処理
GRAPHQL_ENDPOINT = '/graphql/'

cacheable_operations = {
    'pc': [
    ],
    'sp': [
    ],
    'app-android': [
    ],
    'app-ios': [
    ]
}

# CloudFront にキャッシュさせないためのステータスコードをRFCの空き番号から決定する
RESPONSE_STATUS_CODE_NO_CACHE = 
# API から通常返却されるステータスコード
RESPONSE_STATUS_CODE_DEFAULT = 200

def device_type(is_desktop, user_agent):
    if is_desktop == 'true':
        return 'pc'
    else:
        if 'androidのuser-agent' in user_agent.lower():
            return 'app-android'
        elif 'iosのuser-agent' in user_agent.lower():
            return 'app-ios'
    return 'sp'

def exists_required_header(req_headers):
    if 'example-auth-header' in req_headers:
        return True
    return False

def is_cacheable_operation(operation_name, is_desktop, user_agent):
    """operationName からキャッシュ可能な Query かを判定する関数

    Args:
        operation_name: GraphQL Query の operationName
        is_desktop: ヘッダの cloudfront-is-desktop-viewer
        user_agent: ヘッダの user-agent

    Returns:
        True or False (True の場合はキャッシュ可能)
    """
    if operation_name.lower() in cacheable_operations[device_type(is_desktop, user_agent)]:
        return True
    return False

def http_request(endpoint, method='GET', headers={}, data=None):
    """HTTP リクエストを実行して CloudFront のレスポンスの形式に変換する関数

    Args:
        endpoint: リクエスト先の Endpoint
        method  : HTTP メソッド           (デフォルト: GET)
        headers : ヘッダー                (デフォルト: {})
        data    : リクエスト Body         (デフォルト: None)

    Returns:
        CloudFront のレスポンスの形式に変換された Dict
    """
    req = urllib.request.Request(endpoint, method=method, headers=headers, data=data)
    res = urllib.request.urlopen(req)
    res_code = res.getcode()
    res_body = res.read().decode('utf-8')
    res_headers = response_headers(res)

    return {
        'status': res_code,
        'headers': res_headers,
        'body': res_body
    }

def response_headers(res):
    """urllib のレスポンスの Header を CloudFront のレスポンスの形式に変換する関数

    参考: https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#lambda-event-structure-response-origin

    Args:
        res: urllib のレスポンス

    Returns:
        CloudFront の形式に変換された Headers
    """
    headers_raw = dict(res.info())
    headers = list(map(lambda x: { x: { 'key': x, 'value': headers_raw[x] } }, headers_raw.keys()))
    return headers

def split_payload(data):
    """Payload を CACHE_PAYLOAD_SIZE_LIMIT で分割する

    Args:
        data: base64 の Payload

    Returns:
        分割した Payload の配列
    """
    # Offset を示す cursor
    cursor = 0

    # 分割した Payload
    payloads = []

    while True:
        # cursor から Header に収まるサイズの data であれば終了
        if len(data[cursor:]) <= CACHE_PAYLOAD_SIZE_LIMIT:
            payloads.append(data[cursor:])
            break
        # CACHE_PAYLOAD_SIZE_LIMIT までデータを入れて、cursor を移動
        else:
            payloads.append(data[cursor:cursor+CACHE_PAYLOAD_SIZE_LIMIT])
            cursor += CACHE_PAYLOAD_SIZE_LIMIT

    # 分割数が NUM_SPLIT_MAX に満たない場合は、空文字列を挿入
    if len(payloads) < NUM_SPLIT_MAX:
        for _ in range(NUM_SPLIT_MAX - len(payloads)):
            payloads.append('')

    return payloads

def handler(event, context):
    """Lambda@Edge の Handler

    Args:
        event  : CloudFront の Event
        context: Lambda の Context

    Returns:
        CloudFront のレスポンス or バイパス処理
    """
    # CloudFront のイベントを取得し、ドメイン名とリクエストを取得
    # 参考: https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request
    cloud_front_event = event['Records'][0]['cf']
    cloud_front_domain_name = cloud_front_event['config']['distributionDomainName']
    request = cloud_front_event['request']
    origin_domain_name = request['origin']['custom']['domainName']

    # GraphQL エンドポイントの場合は、GET <=> POST で変換する
    if request['uri'] == GRAPHQL_ENDPOINT:
        # POST を GET に変換する (Lambda@Edge => CloudFront)
        if request['method'] == 'POST':
            print('POST')
            # 認証に必要なヘッダーがないものはバイパスする
            if not exists_required_header(request['headers']):
                return request

            # POST の Payload (base64) を取得
            data = request['body']['data']

            # 以下のような形式の Payload を想定し、異なる場合はバイパスする
            # ```
            # [
            #   { "operationName": "xxx", "query": "yyy", "variables": {...} },
            #   ...
            # ]
            # ```
            try:
                queries = json.loads(base64.b64decode(data).decode())
                is_desktop = request['headers']['cloudfront-is-desktop-viewer'][0]['value']
                user_agent = request['headers']['user-agent'][0]['value'] if 'user-agent' in request['headers'] else ''

                if isinstance(queries, dict):
                    queries = [queries]

                for query in queries:
                    # キャッシュ不可な operationName が含まれている場合はバイパス処理
                    if not is_cacheable_operation(query['operationName'], is_desktop, user_agent):
                        return request
            except:
                return request

            # Payload を分割する
            payloads = split_payload(data)

            # 分割数が NUM_SPLIT_MAX (許容量) を越えている場合はバイパス処理
            if len(payloads) > NUM_SPLIT_MAX:
                return request

            headers = {
                # Payload は base64 のまま Header に格納
                # Payload0~4は CloudFront の Cache Policy に指定する
                'Payload0': payloads[0],
                'Payload1': payloads[1],
                'Payload2': payloads[2],
                'Payload3': payloads[3],
                'Payload4': payloads[4],
                # 以下のヘッダーは CloudFront の Origin Request Policy に指定する
                'User-Agent': user_agent,
                'example-auth-header': request['headers']['example-auth-header'][0]['value'],
                'X-Forwarded-For': request['headers']['x-forwarded-for'][0]['value'],
                'Host': request['headers']['host'][0]['value'],
                # Refererが欠けた場合のKeyError発生を回避
                'Referer': request['headers']['referer'][0]['value'] if 'referer' in request['headers'] else '',
                'true-client-ip': request['clientIp']
            }

            # CloudFront (自分自身) に GET リクエストをし直す
            try:
                response = http_request(f'https://{cloud_front_domain_name}{GRAPHQL_ENDPOINT}', headers=headers)
            # ステータスコードが4xx, 5xxのレスポンスを受け取るとurllibがエラーを起こすので例外をキャッチする。
            # CloudFront にキャッシュさせないステータスコードに400番台を使用している。
            # GraphQLクライアント側は CloudFront にキャッシュさせないために書き換えたステータスコードを解釈できないため、200へ書き戻す。
            except urllib.error.HTTPError as e:
                if e.code == RESPONSE_STATUS_CODE_NO_CACHE:
                    response = {
                        'status': RESPONSE_STATUS_CODE_DEFAULT,
                        'headers': e.headers.as_string(),
                        'body': e.read()
                    }

            return response

        # GET を POST に変換する (Lambda@Edge => Origin)
        elif request['method'] == 'GET':
            print('GET')
            # 認証に必要なヘッダーがないものはバイパスする
            if not exists_required_header(request['headers']):
                return request

            # Payload0 ~ Payload4 という Header がない GET リクエストの場合はオリジンにそのまま流す
            if 'payload0' not in request['headers'] or \
               'payload1' not in request['headers'] or \
               'payload2' not in request['headers'] or \
               'payload3' not in request['headers'] or \
               'payload4' not in request['headers']:
                return request

            # Header から Payload の値を取得 (中身は base64 の GraphQL Query)
            payload = \
                request['headers']['payload0'][0]['value'] + \
                request['headers']['payload1'][0]['value'] + \
                request['headers']['payload2'][0]['value'] + \
                request['headers']['payload3'][0]['value'] + \
                request['headers']['payload4'][0]['value']

            # base64 の payload をデコードする
            data = base64.b64decode(payload)

            # リクエスト用のヘッダーを作成
            headers = {
                'Content-Type': 'application/json',
                'Content-Length': len(data),
                'example-auth-header': request['headers']['example-auth-header'][0]['value'],
                # User-Agentが欠けた場合のKeyError発生を回避
                'User-Agent': request['headers']['user-agent'][0]['value'] if 'user-agent' in request['headers'] else '',
                'X-Forwarded-For': request['headers']['x-forwarded-for'][0]['value'],
                'Host': request['headers']['host'][0]['value'],
                # Refererが欠けた場合のKeyError発生を回避
                'Referer': request['headers']['referer'][0]['value'] if 'referer' in request['headers'] else '',
                'true-client-ip': request['headers']['true-client-ip'][0]['value']
            }

            # Origin に POST
            response = http_request(f'https://{origin_domain_name}{GRAPHQL_ENDPOINT}', method='POST', data=data, headers=headers)
            if response['status'] == RESPONSE_STATUS_CODE_DEFAULT:
                try:
                    response_body = json.loads(response['body'])
                    if 'errors' in response_body and len(response_body['errors']) > 0:
                        raise Exception
                # エラーレスポンスであってもステータスコードが200になるため、ステータスコードを書き換えないとCloudFrontにエラーレスポンスがキャッシュされるので、CloudFront がキャッシュしないステータスコードへ書き換える
                except:
                    response['status'] = RESPONSE_STATUS_CODE_NO_CACHE
            return response

    # リクエストをバイパスする
    return request
```
</details>

# プロダクションリリースまでに解決した課題
プロダクションリリースまでに解決した課題について、考慮した点を説明する形で書いていきます。

## 課題1: キャッシュ事故を防ぐ（暴露対策）
一つ目の課題は、暴露対策です。

キャッシュ可/不可の制御の箇所に書きましたが、次の条件のいずれかに当てはまるリクエストはCloudFront にキャッシュさせないようにしています。
- 認証に必要なヘッダーが欠けているリクエスト
- **レスポンスにユーザーの個人情報が含まれるリクエスト**
- リクエストボディのサイズが大きく、キャッシュキーとなるヘッダーに格納しきれないリクエスト

フローチャートで表現すると、次のように表すことができます。
![condition-bypassing-lambda.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/58520ed3-868e-911b-2957-fea814d37310.png)

太字の条件が、個人情報流出を防ぐ防衛線です。図の二つ目の条件、`include a non-cacheable operationName in payloads`が赤太字の条件と対応します。

GraphQL cachingでは、リクエストボディを検査します。キャッシュ不可のGraphQL operationNameが一つでも含まれていたら、CloudFrontにキャッシュさせないようにViewerから受け取ったPOSTリクエストを CloudFrontからオリジンへ直接送信させます。

キャッシュ可/不可の管理は、ホワイトリスト形式で行います。レスポンスがグローバルな情報である（ユーザーの個人情報を含まない）ことが確実なGraphQL operationのみキャッシュさせます。ブラックリスト形式で管理すると、ブラックリストの更新漏れによってキャッシュ事故が起きる可能性があります。

## 課題2: リアルタイム性とのバランス
二つ目の課題は、リアルタイム性とのバランスでした。二本目の記事で書きましたが、GraphQLのURLエンドポイントは一つです。CloudFrontのBehaviorに設定できるパスパターンが一つだけになってしまうので、TTLはキャッシュ可のレスポンスの中で最も短い時間に合わせなければいけません。

TTLを検討する上で、問題になったのは時限データの扱いでした。レスポンスは、有効期限の属性を持つデータを含む場合があります。（例：期間限定イベントに関する案内）

このようなレスポンスはキャッシュしない、とすることもできますが、キャッシュ対象から除外してしまうとキャッシュできるレスポンスがほとんどなくなってしまうことが分かりました。

そのため、LOWYAでは、キャッシュの恩恵を受けつつ、リアルタイム性とのバランスを取る必要があるため、キャッシュオブジェクトのTTLは1分で様子を見ることにしています。

## 課題3: クライアントIPの取得に工夫が必要
三つ目の課題は、どのようにしてバックエンドに正しいクライアントIPを取得させるか、です。

リクエスト送信元とバックエンドの間のホップ数が増えると、`x-forwarded-for`ヘッダーにホップのIPが追加されるので、クライアントIPをバックエンドに渡すために手間を加える必要があります。

以下の記事に詳しいですが、CloudFrontがELBの前にあるアプリケーションでは、クライアントIPをセキュアに取得するには`x-forwarded-for`ヘッダーの先頭のIPを取得する必要があります。

https://mrk21.hatenablog.com/entry/2020/08/06/214922

ところが、2021年10月のアップデートでCloudFrontは`CloudFront-Viewer-Address`ヘッダーをサポートしました。[^2]

このアップデートによって、CloudFront → ELB → AppServer という構成のアプリケーションであっても、クライアントIPを取得しやすくなりました。
`CloudFront-Viewer-Address`ヘッダーは"ClientIPAddress:ClientPort"の形式のため、コロンより後ろを切り落とすとクライアントIPを取得することができます。

今回、`CloudFront-Viewer-Address`ヘッダーを使ってバックエンドアプリケーション側でクライアントIPを取得させようとしました。
しかし、キャッシュ不可のケースではクライアントIPをアプリケーションに渡すことができたのですが、キャッシュ可のケースではクライアントIPを渡すことができませんでした。

キャッシュ可のケースでは、Origin RequestのL@EがGETメソッドの新しいHTTPリクエストを発行します。CloudFrontにGETリクエストを送信するのはL@Eになるので、`CloudFront-Viewer-Address`ヘッダーに格納される値は、L@EのIPになっていました。

L@Eのものと思われるIPをAWSが公開しているip-ranges.json[^3]から確認したところ、以下のコマンドで抽出したIPアドレスの範囲内であったので、おそらくリージョナルエッジキャッシュのIPだと考えています。
```
jq -r '.prefixes[] | select(.region=="ap-northeast-1" and .service=="AMAZON" and .network_border_group=="ap-northeast-1") | .ip_prefix' < ip-ranges.json | sort -n
```
キャッシュ可のケースでもバックエンドでクライアントのIPを取得するために、L@EがGETリクエストを送信する前にLambda@Edgeのリクエストイベントに記録されるクライアントIPを取得して、`true-client-ip`というカスタムヘッダーに値を格納しました。
そして、バックエンドで以下の条件でクライアントIPを取得するよう改修しました。フローチャートにすると次のようになります。
![header.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/6fb8db77-c3e3-5587-785d-11506458ebf5.png)

キャッシュ不可のケースでは、`true-client-ip`ヘッダーはセットされないので、`CloudFront-Viewer-Address`ヘッダーをオリジンへ転送し、バックエンドはこのヘッダーの値を参照すればクライアントのIPを取得できます。

## 課題4: オリジンで付与したヘッダーが消える
四つ目の課題は、オリジンで付与したヘッダーが消えてしまう、という事象です。

どうやら、L@Eがhttp_request関数を呼び出して直接レスポンスを返しているので、CloudFrontの挙動としてオリジンが付与したレスポンスヘッダーが削除（初期化）されるようでした。

そのため、キャッシュ可のケースでオリジンがレスポンスに付与したレスポンスヘッダーが初期化されてしまい、クライアントがレスポンスを正常に処理できなくなりました。

例えば、`content-type`ヘッダーが消失するのでクライアントがレスポンスを正常に処理できなくなる、CORS関連のヘッダーが消失してエラーが発生する、といった不具合が発生します。

この不具合に対処するために、CloudFrontのResponse Headers Policyを使用して、値が静的なヘッダーについては固定のレスポンスヘッダーを付与するようにしました。

また、値が動的なヘッダーについてはCloudFrontのViewer Responseで動作するCF2で付与するようにしました。

## 課題5: 意図せずエラーレスポンスがキャッシュされる
五つ目の課題は、意図せずエラーレスポンスがキャッシュされてしまったことです。

ネガティヴキャッシュ[^4]という考え方があります。ネガティヴキャッシュとは、負のレスポンスのキャッシュのことで、例えばCDNで404エラーをキャッシュすることを意味します。

キャッシングにおいては200のような正のレスポンスだけでなく、負のレスポンスのキャッシュについても考慮すべきです。ネガティヴキャッシュを活用することで、存在しないURLを指定して大量のリクエストを送り付ける嫌がらせからオリジンを守ることができます。

実は一度目のリリースのとき、CloudFrontがオリジンから返ってきたエラーレスポンスをキャッシュしてしまい、エラーの原因となったリクエストを送信したユーザー以外の環境でも、エラーが発生してしまいました。

原因は、GraphQLのお作法でした。GraphQLはリクエストをエラーとして処理する場合も、ステータスコードは200でレスポンスを返します。

エラーがキャッシュされたときは以下の状況でした。

1. あるユーザーのリクエストが 401 Unauthorizedエラーとなる
2. バックエンドアプリケーションは、エラーメッセージをボディに格納し、ステータスコード200でレスポンスを返却する
3. CloudFrontが401 Unauthorizedを引き起こしたリクエストに対するレスポンスをキャッシュする
4. CloudFrontは、1のリクエストを送信したユーザーにエラーレスポンスを返却する
5. 1のリクエストとボディの内容が同じリクエストを送信した別のユーザーに対して、CloudFrontは3で作成したキャッシュオブジェクト返却する

今回の実装においては、リクエストボディを1783文字おきに分割して、`Payload0~4`のヘッダーの値に格納し、これらのヘッダーをキャッシュキーとしています。
セッショントークンはリクエストヘッダーで送信していてボディには含んでいません。よって、別のユーザー（セッション）であっても、リクエストボディが全く同じ場合はCloudFrontが別のユーザーに返却したエラーレスポンスが返ってきてしまいます。

この事象を回避するためには、ステータスコードの書き換えが必要でした。キャッシュ可のケースでオリジンからエラーレスポンスが送信された場合、200からCloudFrontがキャッシュしないステータスコードに書き換えます。そして、Viewerにレスポンスを返す際には再びステータスコードを200に書き戻す、という処理をしています。書き戻す理由は、Viewer (GraphQL クライアント）側はCloudFrontがキャッシュしないステータスコードでレスポンスが返ってくることを想定していないからです。
```python
            # CloudFront (自分自身) に GET リクエストをし直す
            try:
                response = http_request(f'https://{cloud_front_domain_name}{GRAPHQL_ENDPOINT}', headers=headers)
            # ステータスコードが4xx, 5xxのレスポンスを受け取るとurllibがエラーを起こすので例外をキャッチする。
            # CloudFront にキャッシュさせないステータスコードに400番台を使用している。
            # GraphQLクライアント側は CloudFront にキャッシュさせないために書き換えたステータスコードを解釈できないため、200へ書き戻す。
            except urllib.error.HTTPError as e:
                if e.code == RESPONSE_STATUS_CODE_NO_CACHE:
                    response = {
                        'status': RESPONSE_STATUS_CODE_DEFAULT,
                        'headers': e.headers.as_string(),
                        'body': e.read()
                    }

            return response
            # Origin に POST
            response = http_request(f'https://{origin_domain_name}{GRAPHQL_ENDPOINT}', method='POST', data=data, headers=headers)
            if response['status'] == RESPONSE_STATUS_CODE_DEFAULT:
                try:
                    response_body = json.loads(response['body'])
                    if 'errors' in response_body and len(response_body['errors']) > 0:
                        raise Exception
                # エラーレスポンスであってもステータスコードが200になるため、ステータスコードを書き換えないとCloudFrontにエラーレスポンスがキャッシュされるので、CloudFront がキャッシュしないステータスコードへ書き換える
                except:
                    response['status'] = RESPONSE_STATUS_CODE_NO_CACHE
            return response
```
イメージとしては以下の図のようになります。
![drop-negative-cache.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/84bf0192-2a68-27ab-a100-e15109fcecd1.png)
今回、CloudFrontがキャッシュしないステータスコードを471に決めましたが、RFCの空き番号[^5]から適当なものを選んだだけです。

CloudFrontには常にキャッシュするHTTP 4xx,HTTP 5xxステータスコードがありますが[^6]、今回はネガティヴキャッシュは使用しない方針になりました。

# 導入による効果
導入にあたっては課題がいくつもありましたが、GraphQL cachingによって、期待していた効果が得られました。

## Ajaxリクエストのレスポンスタイムの短縮
ブラウザからAPIへ送信されたAjax requestのレスポンスタイムを、GraphQL caching導入前後で比較したところ、最大で52%レスポンスタイムが短縮されました。
![responsetime.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/878d638a-1392-d4a4-3cfb-5a92d3c20a5c.png)
LOWYAではNew Relic BrowserというRUM(Real User Monitoring)ツールを導入しており、timeToLoadEventStart[^7] というデータを用いて計測しています。

>timeToLoadEventStart
>AJAXリクエストの開始から、そのロードイベントの開始までの時間（秒）。この値は、単一ページアプリ（SPA）監視によるAJAXリクエストの持続時間を表します。

## オリジンのコスト削減
本記事の執筆時点では、CloudFrontのキャッシュHit率は平均26%で推移しているため、リクエストの4回に1回がキャッシュHitしていることになります。
![スクリーンショット 2022-09-30 0.22.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344652/dfaa3977-896e-910e-5ffb-f45dbd97044e.png)
キャッシングによってオリジンの負荷がオフロードされた結果、ECSタスク数の削減が可能になり、ECSの月額費用を浮かすことができました。

# おわりに
GraphQL cachingを導入する目的は、サイト（GraphQLサーバー）の負荷対策とAWSコスト削減、そして近い未来のスケーラビリティの課題でした。

GraphQL cachingを導入してから大規模なアクセス集中（スパイク）を経験していないので負荷対策としてどれだけの効果があるのか、まだなんとも言えないところはありますが、CloudFrontのキャッシュHit率の状況からオリジンへ到達するリクエスト数が減っていることは確かなので、期待したいところです。[^8]

AWSコスト削減については、すでに実現できています。

近い未来のスケーラビリティの課題については顕在化するかどうか今は不明ですが、策を講じることができたと考えています。

長くなりましたが、最後までお読みいただきありがとうございました。同じような課題感をお持ちの方の役に立てば幸いです。

[^1]:過去にメルカリでCDNの切り替え作業によって個人情報の流出が発生しています。
https://engineering.mercari.com/blog/entry/2017-06-22-204500/
キャッシュ事故については、IPAのサイトでも暴露対策として紹介されています。
https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/405.html
マサカリ防止で書いておきますが、メルカリの事例を引用したことによってメルカリの対応を批判する意図は全くありません。むしろ、お手本のような素晴らしい対応だと思います。

[^2]:https://aws.amazon.com/jp/about-aws/whats-new/2021/10/amazon-cloudfront-client-ip-address-connection-port-header/
[^3]:https://docs.aws.amazon.com/ja_jp/general/latest/gr/aws-ip-ranges.html
[^4]:ネガティヴキャッシュはDNSの用語かと思います。
https://jprs.jp/glossary/index.php?ID=0177#:~:text=DNS%E3%81%AB%E3%81%8A%E3%81%84%E3%81%A6%E3%80%81%E6%A4%9C%E7%B4%A2%E5%AF%BE%E8%B1%A1%E3%81%8C,%E3%83%8D%E3%82%AC%E3%83%86%E3%82%A3%E3%83%96%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5%E3%80%8D%E3%81%A8%E5%91%BC%E3%81%B3%E3%81%BE%E3%81%99%E3%80%82
CDNの文脈においても、好ましくないレスポンスをキャッシュさせるという意味で使用されています。
https://cloud.google.com/cdn/docs/using-negative-caching?hl=ja
[^5]:空き番号はこちらから確認
https://datatracker.ietf.org/doc/html/rfc7231#section-6.5
[^6]:https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/HTTPStatusCodes.html#HTTPStatusCodes-cached-errors
[^7]:https://docs.newrelic.com/jp/attribute-dictionary/?event=AjaxRequest&attribute=timeToLoadEventStart
[^8]:APIキャッシュ層が増えているので、負荷対策において考慮すべきポイントが増えていることに注意しなくてはいけないと考えています。特に、Lambda@Edgeはプロビジョニングされた同時実行をサポートしていないのでバースト同時クォータに敏感である必要があります。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/invocation-scaling.html