API Platform + CloudFront
CloudFront自体の設定は
このスクラップのとおり実施済み。
API PlatformにはCDNのキャッシュを適切なタイミングで自動でInvalidateするための仕組みがビルトインされている。
デフォルトだとVarnishにしか対応していないので、任意のCDNに対応するためには ApiPlatform\HttpCache\PurgerInterface
を自前で実装する必要がある。
まずは
$ composer require aws/aws-sdk-php
して、Aws\CloudFront\CloudFrontClient
を 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)%'
AWS_CF_ACCESS_KEY=xxx
AWS_CF_SECRET_KEY=xxx
AWS_CF_DISTRIBUTION_ID=xxx # あとで使う
って感じ。
Aws\CloudFront\CloudFrontClient
の使い方がどこにもドキュメントがなさそうで、コードを読みにいくしかない感じだった(?)けど、運よく↓を見つけたので参考になりました🙏
CloudFront APIのバージョン番号は 2020-05-31
としたけど、これも一体どこで知ればよいのか全然分からなかったのだけど、
このページの API Reference
の 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
を以下のように設定。
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もタグベースでのキャッシュ削除の機能を持っているっぽい
のだが、↑の説明を読む限り、タグベースでのInvalidateは Step Functions を使ってやる方法しかなく(?)、実際、タグを指定してInvalidationを作成するようなAPIは存在していないかった。
なので、CloudFrontを使っている場合は、↑で実装したようにパスベースでInvalidateするしかなさそう。
現状の実装だと、purge(array $iris)
に /me
のようなIRIが渡ってこない(なぜなのかは詳細には未調査)ため、例えばユーザー情報が更新されたときに /me
リソースのキャッシュはInvalidateされず古いままになってしまう。
タグベースInvalidationに対応しているCDNを使っている場合なら、
このようなイベントリスナーを実装することで、Purgerの getResponseHeaders(array $iris)
に /me
が追加されて渡ってくるようになり、purge(array $iri)
にも同様に /me
が渡ってくる(多分)ので、タグベースでInvalidateできるという寸法だと思う。多分。
ただ、自分の場合はこのようなイベントリスナーを実装しなくてももともと
getResopnseHeaders(array $iris)
に/me
が含まれていた。ここでは含まれているのに、purge(array $iris)
では含まれていないのがどういうロジックなのか全然分かっていない。
ただし、/me
は Authorization
ヘッダーによって内容の異なるキャッシュが保存されているはずで、誰か1ユーザーがユーザー情報を変更したときに全ユーザーの /me
がInvalidateされてしまうのはよくない。(API Platformのドキュメントの例はそうなってしまっているように見える🤔)
パスだけでなく、キャッシュキーのうちの Authorization
ヘッダーの内容を指定してキャッシュをInvalidateする術があれば対応できそうだが、APIリファレンスを見る限りそんなことはできない。
そういうもんなのかな。
普通にできないということっぽい。
公式の言及を見つけた。できないとのこと。
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
に対応している
ので、サービスの性質によっては、API Platformの設定を例えば以下のように変更してSWRを有効にすることで、あえてPurgerで明示的にキャッシュをInvalidateすることはしない運用にできるかもしれない。
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
を使う方法はドキュメントには明記されていないが、
この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がオリジンからフェッチするときに料金が発生するのかどうなのか。
オリジンからのダウンロードにコストがかかるとは書いてないように見えるけど、、よく分からない。しばらく動かしてみて様子を見ようかな。
に関しては、refetch()
をラップして
export const refetchWithWarmup = async (refetch: () => Promise<unknown>) => {
refetch() // CDNのキャッシュ更新を促す
await new Promise((resolve) => setTimeout(resolve, 500)) // CDNのキャッシュ更新を待つ
refetch() // 更新されたデータをフェッチする
}
みたいにすることで見かけ上問題なく動作している。
一旦これで様子見
とあるエンドポイントを、クエリパラメータに2000文字ちょいの長い文字列を渡してGETしようとしたら、CloudFrontから即座に403が返ってくるという現象が発生した。
リクエストの最大サイズは20480バイト、URLの最大長は8192文字らしく、どう見ても収まってるし、レスポンスは413じゃなく403だし、謎すぎる・・・
上記、結局原因分からず・・・
アプリの実装の都合的に、クエリパラメータに渡す文字列をもっと短く省略することがたまたま可能だったので、2000文字などという長いクエリパラメータを渡すようなケースが発生しないようアプリ側を修正することで今回は対応したが、根本原因は謎のまま・・・