Closed18

API Platform + CloudFront

たつきちたつきち

API PlatformにはCDNのキャッシュを適切なタイミングで自動でInvalidateするための仕組みがビルトインされている。

https://api-platform.com/docs/core/performance/

デフォルトだとVarnishにしか対応していないので、任意のCDNに対応するためには ApiPlatform\HttpCache\PurgerInterface を自前で実装する必要がある。

たつきちたつきち

まずは

$ composer require aws/aws-sdk-php

して、Aws\CloudFront\CloudFrontClientservices.yaml で構成する。

config/services.yaml
services:
  Aws\CloudFront\CloudFrontClient:
    arguments:
      - region:
        version: '2020-05-31'
        credentials:
          key: '%env(AWS_CF_ACCESS_KEY)%'
          secret: '%env(AWS_CF_SECRET_KEY)%'
.env.local
AWS_CF_ACCESS_KEY=xxx
AWS_CF_SECRET_KEY=xxx
AWS_CF_DISTRIBUTION_ID=xxx # あとで使う

って感じ。

Aws\CloudFront\CloudFrontClient の使い方がどこにもドキュメントがなさそうで、コードを読みにいくしかない感じだった(?)けど、運よく↓を見つけたので参考になりました🙏

https://shiro-16.hatenablog.com/entry/2020/05/22/112743

CloudFront APIのバージョン番号は 2020-05-31 としたけど、これも一体どこで知ればよいのか全然分からなかったのだけど、

https://docs.aws.amazon.com/cloudfront/

このページの API ReferencePDF を開いてみたら

https://docs.aws.amazon.com/pdfs/cloudfront/latest/APIReference/cloudfront-api.pdf

表紙に API Version 2020-05-31 って書いてあったので、これを採用した。本来どうやって知るべき情報だったのか謎・・・

たつきちたつきち

で、Purgerを以下のような内容で実装。

<?php

declare(strict_types=1);

namespace App\ApiPlatform\HttpCache;

use ApiPlatform\HttpCache\PurgerInterface;
use ApiPlatform\HttpCache\VarnishPurger;
use Aws\CloudFront\CloudFrontClient;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * @see VarnishPurger
 */
class CloudFrontPurger implements PurgerInterface
{
    public function __construct(
        private readonly CloudFrontClient $client,
        #[Autowire('%env(AWS_CF_DISTRIBUTION_ID)%')]
        private readonly string $distributionId,
    ) {
    }

    /**
     * @param array<string> $iris
     *
     * @see https://shiro-16.hatenablog.com/entry/2020/05/22/112743
     */
    public function purge(array $iris): void
    {
        if (!$iris) {
            return;
        }

        $this->client->createInvalidation([
            'DistributionId' => $this->distributionId,
            'InvalidationBatch' => [
                'CallerReference' => microtime(),
                'Paths' => [
                    'Quantity' => count($iris),
                    'Items' => $iris,
                ],
            ],
        ]);
    }

    /**
     * @param array<string> $iris
     *
     * @return array<string, string>
     */
    public function getResponseHeaders(array $iris): array
    {
        return ['Cache-Tags' => implode(',', $iris)];
    }
}
たつきちたつきち

最後に、api_platform.yaml を以下のように設定。

config/packages/api_platform.yaml
api_platform:

  # ...

  http_cache:
    public: ~
    invalidation:
      enabled: true
      purger: App\ApiPlatform\HttpCache\CloudFrontPurger

  # ...

  defaults:

    # ...

    cache_headers:
      max_age: 0
      shared_max_age: 3600
      vary: [Content-Type, Authorization, Origin]
たつきちたつきち

この状態で、何か適当に更新系のAPIを実行してみると、同時にCloudFront側でキャッシュのInvalidateが自動で走ってくれた。すごい。

たつきちたつきち

盛大に勘違いしてた。

どうやらPurgerのコンセプトは、

  • タグベースのキャッシングに対応しているCDN(Varnish、Cloudflare、Fastly等)を使う前提
  • リファレンス実装の対象であるVarninshの場合なら、Cache-Tags ヘッダーにカンマ区切りでタグを入れておいて、Invalidate時には再度そのタグを指定してのInvalidateが可能(多分)

ということのよう。

↑で実装したPurgerはタグベースではなくパスベースでInvalidateを行っており、getResponseHeaders() メソッドで返している Cache-Tags ヘッダーの値は特に何にも使われていない。

で、CloudFrontもタグベースでのキャッシュ削除の機能を持っているっぽい

https://aws.amazon.com/jp/blogs/networking-and-content-delivery/tag-based-invalidation-in-amazon-cloudfront/

のだが、↑の説明を読む限り、タグベースでのInvalidateは Step Functions を使ってやる方法しかなく(?)、実際、タグを指定してInvalidationを作成するようなAPIは存在していないかった。

https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_CreateInvalidation.html

なので、CloudFrontを使っている場合は、↑で実装したようにパスベースでInvalidateするしかなさそう。

たつきちたつきち

現状の実装だと、purge(array $iris)/me のようなIRIが渡ってこない(なぜなのかは詳細には未調査)ため、例えばユーザー情報が更新されたときに /me リソースのキャッシュはInvalidateされず古いままになってしまう。

タグベースInvalidationに対応しているCDNを使っている場合なら、

https://api-platform.com/docs/core/performance/#extending-cache-tags-for-invalidation

このようなイベントリスナーを実装することで、Purgerの getResponseHeaders(array $iris)/me が追加されて渡ってくるようになり、purge(array $iri) にも同様に /me が渡ってくる(多分)ので、タグベースでInvalidateできるという寸法だと思う。多分。

ただ、自分の場合はこのようなイベントリスナーを実装しなくてももともと getResopnseHeaders(array $iris)/me が含まれていた。ここでは含まれているのに、purge(array $iris) では含まれていないのがどういうロジックなのか全然分かっていない。

ただし、/meAuthorization ヘッダーによって内容の異なるキャッシュが保存されているはずで、誰か1ユーザーがユーザー情報を変更したときに全ユーザーの /me がInvalidateされてしまうのはよくない。(API Platformのドキュメントの例はそうなってしまっているように見える🤔)

パスだけでなく、キャッシュキーのうちの Authorization ヘッダーの内容を指定してキャッシュをInvalidateする術があれば対応できそうだが、APIリファレンスを見る限りそんなことはできない。

https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_CreateInvalidation.html

そういうもんなのかな。

たつきちたつきち

purge(array $iris)/me が含まれていない件について、他のリソースの編集時なども見てみたところ、どうやら

  • 変更されたリソースのIRI(そのエンティティに対するAPIリソース定義のうち、ApiPlatform\Metadata\Get で定義されている中で最初のもののURI)
  • 変更されたリソースの、ApiPlatform\Metadata\GetCollection で定義されている最初の(多分)リソース定義のURI
  • および、それらをシリアライズした際に含まれる他のリソースを芋づる式にリスト化したもの

purge(array $iris) に渡ってくるっぽい。(ドキュメントやソースを当たったわけではなく、いくつかのリソースの変更時の挙動を見た結果の推測)

つまり、IRIではない ApiPlatform\Metadata\Get のリソース定義が複数ある場合( /users/{username} に対する /me みたいに)には、IRI以外のリソース定義は自動でInvalidateされないので、厳密にやるなら purge() メソッドの実装内で、リソースクラスに応じてInvalidateしたいパスを手動で追加するなどする必要がありそう。

(ちょうど この方法getResponseHeaders(array $iris) に渡ってくるパスを手動で追加したように)

たつきちたつきち

例えばこんな感じでとりあえず対応できた(?)

public function purge(array $iris): void
{
    if (!$iris) {
        return;
    }

    // リソースによってはIRI以外にもpurgeすべきパスがあるのでここで追加
    if (array_filter($iris, fn (string $iri) => preg_match('#^/users/\S+$#', $iri))) {
        $iris[] = '/me';
    }
    if (array_filter($iris, fn (string $iri) => preg_match('#^/posts$#', $iri))) {
        $iris[] = '/posts/followee';
        $iris[] = '/public/posts/trend';
        $iris[] = '/users/*/posts';
        $iris[] = '/posts/liked';
    }

    $this->client->createInvalidation([
        'DistributionId' => $this->distributionId,
        'InvalidationBatch' => [
            'CallerReference' => microtime(),
            'Paths' => [
                'Quantity' => count($iris),
                'Items' => $iris,
            ],
        ],
    ]);
}

ただ、すでに考察したとおり、これだとユーザーごとに異なる値をキャッシュしているリソースについても、全ユーザーのキャッシュがInvalidateされてしまう(CloudFrontの限界)ので、かなりイケてない・・・

たつきちたつきち

なお、CloudFrontはCache-Controlヘッダーの stale-while-revalidate に対応している

https://dev.classmethod.jp/articles/cloudfront-stale-while-revalidate-stale-if-error/

ので、サービスの性質によっては、API Platformの設定を例えば以下のように変更してSWRを有効にすることで、あえてPurgerで明示的にキャッシュをInvalidateすることはしない運用にできるかもしれない。

config/packages/api_platform.yaml
api_platform:
  defaults:
    cache_headers:
      max_age: 0
      shared_max_age: 0
      stale_while_revalidate: 3600
      vary: [Content-Type, Authorization, Origin]

API Platformの cache_headers 設定で stale-while-revalidate を使う方法はドキュメントには明記されていないが、

https://github.com/api-platform/core/pull/3439

このPRで対応がなされており、これを頼りに AddHeadersListener を見に行けば設定項目名を知ることができる。(ちなみにもとのIssueは これ

たつきちたつきち

フロントエンドで更新操作をしたときに、変更を即座に画面に反映させるためにReactQueryの refetch() を呼んでいる箇所があるのだけど、更新操作直後に refetch() するとまだCloudFront側のInvalidateが終わっていなくて refetch() しても古いデータがフェッチされちゃう問題(例えば setTimeout で1秒待ってから refetch() する、とかすれば一応解決はする)があって、これは原理的に適当な時間(500msとか?)待ってから refetch() するようにするしかない気がする。

たつきちたつきち

というかよく考えてみると、

API PlatformのPurgerを使ってデータ更新時に自動でInvalidateするようにする

のと、↑に書いたように

      max_age: 0
      shared_max_age: 0
      stale_while_revalidate: 3600

みたいなアグレッシブな(キャッシュは常にstaleであり、リクエストされる度に裏でフェッチが走る)SWR設定を採用する

のと、どっちにしてもフロントエンドから見ると更新操作のあとキャッシュが最新に変わるまでに少しのタイムラグが必要になるとことに変わりはないので、だったら後者を選んでおくほうが楽なのではと思い至った。

気になるのはCloudFrontがオリジンからフェッチするときに料金が発生するのかどうなのか。

https://aws.amazon.com/jp/cloudfront/pricing/

オリジンからのダウンロードにコストがかかるとは書いてないように見えるけど、、よく分からない。しばらく動かしてみて様子を見ようかな。

たつきちたつきち

https://zenn.dev/link/comments/44a85fd7865346

に関しては、refetch() をラップして

export const refetchWithWarmup = async (refetch: () => Promise<unknown>) => {
  refetch() // CDNのキャッシュ更新を促す
  await new Promise((resolve) => setTimeout(resolve, 500)) // CDNのキャッシュ更新を待つ
  refetch() // 更新されたデータをフェッチする
}

みたいにすることで見かけ上問題なく動作している。

たつきちたつきち

とあるエンドポイントを、クエリパラメータに2000文字ちょいの長い文字列を渡してGETしようとしたら、CloudFrontから即座に403が返ってくるという現象が発生した。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#RequestCustomMaxRequestStringLength

リクエストの最大サイズは20480バイト、URLの最大長は8192文字らしく、どう見ても収まってるし、レスポンスは413じゃなく403だし、謎すぎる・・・

たつきちたつきち

上記、結局原因分からず・・・

アプリの実装の都合的に、クエリパラメータに渡す文字列をもっと短く省略することがたまたま可能だったので、2000文字などという長いクエリパラメータを渡すようなケースが発生しないようアプリ側を修正することで今回は対応したが、根本原因は謎のまま・・・

このスクラップは2023/09/08にクローズされました