こんにちは、microCMS代表の松田です。
肩書きこそ創業者兼CEOの私ではありますが、創業以前の経歴が思いっきりエンジニアということもあり今もプロダクトの実装には毎日ゴリゴリに取り組んでおります。
段々と会社が大きくなってきてもエンジニアリングからはできるだけ離れず、microCMSを使ってくださる方々の気持ちに寄り添い続けたいなと思う毎日です。
さて、今日はmicroCMSでも行なっている「REST APIの(実質的な)キャッシュ削除処理」について説明します。
通常のmicroCMSブログとは違いインフラ / バックエンド寄りの内容ではありますが、この部分は自分が設計から実装まで一貫して携わったため今日は筆を取りました。
microCMS自体がどのように作られているかが少しでも伝わると嬉しいです。
microCMSのREST APIについて
microCMSをものすごく簡単に説明すると「管理画面をノーコードで作れて、入稿したデータはREST APIで取得できる」サービスです。
非常に嬉しいことに今では3000社以上の方にお使いいただくサービスとなっています。
まだご利用いただいていない方は以下より無料でお試しいただけますのでぜひすぐに使ってみてください😌
https://microcms.io/
そんなmicroCMSですがREST API部分には当然ながらCDN(CloudFront)が入っており、レスポンスは極力キャッシュして返却しています。
実際のREST APIのキャッシュヒット率
※概ね90%以上ですがしばしば80%台になるなどまだまだ改善の余地はありそうです...!
こういった設計のサービスにおいてバックエンド側で必要になる処理が「管理画面で入稿されたタイミングでREST APIのキャッシュを更新し、最新状態のコンテンツを取得できるようにする」ということです。
これができないと編集した結果をAPIから取得できず、ウェブサイト等への反映ができない状態となってしまいます。
当初実装:Cloudfrontに対してInvalidateする
これに対し、microCMSの提供当初は編集操作時にCloudfrontに対してシンプルなInvalidation処理を実行していました。
Invalidation処理のコード例(Node.js)
const params = {
DistributionId: <cloudFrontId>,
InvalidationBatch: {
CallerReference: uuid(),
Paths: { Quantity: 1, Items: ["/api/v1/news*"] },
},
};
await new AWS.CloudFront()
.createInvalidation(params)
.promise()
※サブドメイン部分の処理などは割愛しています
CloudfrontはFastlyなどに比べてキャッシュの無効化処理がやや遅く数秒程度かかりますが、それを除けばこの方針でも当初は特に問題はありませんでした。
しかし、この方式で運用していく中でユーザー数も増え、大きな問題が出始めるようになりました。
その問題はCloudFrontのクオータの中のワイルドカード無効化の最大数です。
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html#limits-invalidations
この記事を読んでいる2022/01/26時点ではこの値が 15 となっています。
microCMSのREST APIには様々なパラメータが付けられることやAPI間の参照機能による依存関係先のInvalidationの必要性もあり、ワイルドカードでのキャッシュ無効化処理は実質的に必須でした。
別案として全アクセスパス・パラメータを保存し、最大数 3000 の無効化リクエスト側でのInvalidation実施の方向性も考えましたが、膨大なパス・パラメータの組み合わせを管理する必要があることや比較的すぐに3000の上限にも達してしまうと予想されることからこの方針は諦めることとなりました。
※なお、これとは別にAWSのクオータ上限緩和も申請しましたが、我々が問い合わせた範囲では引き上げていただけませんでした、、
現在の方針:サーバサイドでAPIバージョンを付与する
最初の方針の問題より「Cloudfrontに対してInvalidation処理を実行せずにAPIのレスポンスを最新化する必要がある」ということが見えてきました。
様々検討した結果、以下のような解決策に至りました。
- APIの内部バージョンを保存するDynamoDBテーブルを用意
- 管理画面での入稿時に該当APIの内部バージョンを更新する
- APIからのアクセス時にはLambda@EdgeのViewer RequestでAPIの内部バージョンをヘッダに付与する
少し詳しく説明していきます。まずは1, 2のDynamoDBテーブルについてです。
まずDynamoDBにおいて以下のようなデータが入る形でテーブルを作成します。
この内部バージョン部分は編集が行われた際に必ず更新するように実装していきます。
ここまで用意ができたら次に進みます。
次はLambda@Edgeを利用します。Lambda@Edgeは地理的に近いロケーションで処理を実行するためのCloudFrontの機能の一つです。
https://aws.amazon.com/jp/lambda/edge/
今回の解決策ではLambda@Edgeの中でキャッシュヒット前に処理を実行できるViewer requestを利用します。
具体的にはViewer requestにて先ほど用意したAPIの内部バージョンが保存されたDynamoDBへのアクセスを行い、そのバージョン値をリクエストのヘッダに付与します。
以下の実装イメージではDynamoDBから値を取得し、X-REVISION-STRING
ヘッダに値を付与しています。
なお、このヘッダをCloudfrontのキャッシュキーに含めることも忘れないようにしてください。
Viewer requestの実装イメージ
※実際には海外からのアクセスにおけるレイテンシやDynamoDBの負荷、Lambdaの同時実行数なども考慮する必要があります...!
const AWS = require("aws-sdk");
const region = "ap-northeast-1";
const docClient = new AWS.DynamoDB.DocumentClient({ region });
exports.handler = async (event, context, callback) => {
const request = event.Records[0].cf.request;
//接続先DBを確定する
const tableName = "<some-table-name>";
//内部バージョンをリクエストヘッダに付与
const revisionData = (
await docClient
.get({
TableName: tableName,
Key: { service: "<some-service>", api "<some-api>" },
})
.promise()
).Item;
if (revisionData) {
const header = "X-REVISION-STRING";
request.headers[header.toLowerCase()] = [
{ key: header, value: revisionData.version },
];
}
callback(null, request);
};
このように実装することで利用者側から見ると管理画面などでの変更でAPIが即座に変更され、概ね数百ms以降であればREST APIからは新しい値が返却されるようになります。
また、microCMS側もInvalidation処理は一切要らずクオータの制限を気にすることはなくなり、とてもハッピーです!
おわりに
上述したコードはあくまで概要となりますが、microCMS内部でも実際に取り入れている考え方です。
今回はREST APIの最新化を目的としてこのような解決策を取りましたが、実際にはLamda@Edgeで必要な値を動的に付与・調整するなど考え方としてはかなり汎用性の高いものではないかと考えています。
今回の知見がどなたかの参考になれば幸いです!