CDNで生き永らえる古い画像のキャッシュを消すCloud Functionsの話

こんにちは。Mercari Advent Calendar 2019 5日目は、SREの @cookie-s がお送りします。

メルカリでは様々な用途でCDNを使っています。特に静的ファイルについてはCDNにキャッシュをもたせ、効率的な配信をするように努めています。
しかしながら、配信済みファイルの中身を更新された場合など、キャッシュを削除したくなることもあります。

社内の人がファイルを更新した際に、その人が自分でキャッシュを削除するための仕組みについては、以前 @catatsuy本ブログで紹介しました。

今回は、別のケースでファイルが更新される場合のキャッシュへの対処について紹介します。
アプリケーションのビジネスロジック上でキャッシュの削除をしたくなるケースです。
キャッシュ削除をしたくなる場所はメルカリ内でもいくつかありますが、メルカリでは複数のCDNを使用していてそれぞれキャッシュの削除方法が違うこと、CDNのAPIトークン管理などの都合から、それぞれのビジネスロジックで自分でCDNのキャッシュを削除しにいくよりも、どのCDNのキャッシュかを意識せずに使えるような統一的なキャッシュ削除方法があったほうが簡単です。

アーキテクチャ

f:id:cookie-s:20191125172855p:plain

Cloud Pub/Sub

キャッシュの削除は、大抵の場合、最終的に最新のデータが反映さればそれですむことが多いので、ファイルを更新していたもとの処理を待たせてキャッシュの削除の完了を確認するよりも、非同期的に行えるとうれしいです。そのとき、それを積んでおくためのなんらかのタスクキューを用意したくなります。

これをGoogle Cloud Platform上で行うとき、タスクキューのようなものを実現するには、Cloud TasksまたはCloud Pub/Subという選択肢が考えられます。
これらは似ていますが違いも多くあり、比較表がCloud Tasksのほうのマニュアルにありますが、今回はCloud Pub/Subの方を採用しました。機能にもいくつか違いがありますが、それは2つのサービスのコンセプトの違いからくるものであり、Cloud Pub/Subのほうはpublisherから見たときにより暗黙的な呼び出しをすることが意識されています。これが意味するところは、subscriber側のアーキテクチャの変更が容易ということです。

今後このキャッシュ削除のcallerが増えた際に、キャッシュ削除のロジックを変えることがありえるため、Cloud Pub/Subイベントのインタフェースだけを定めて、publishとsubscribeのロジックを分離することが今後のコストを下げると考えました。

Cloud Functions

このような理由からCloud Pub/Subを採用したため、subscriberをどのようなアーキテクチャにするかは初めからベストを尽くす必要はなく、今回は一番面倒がない手軽な方法を取りました。
すでにキャッシュの削除をCloud Functionsの中で行う方法は実現されていますから、これを拡張する形です。

Cloud FunctionsはHTTPトリガ以外に、Cloud Pub/SubトリガPush購読が可能なので、これを採用しました。
実はこの決定は、少しだけ制限を与えます。Cloud Pub/SubトリガのCloud Functions Push購読をするためには、おそらく今の所、トリガとなるCloud Pub/SubトピックのProjectをsubscriberとなるCloud Functionsと同じにしなければならないようです。

リトライ処理

今回デプロイするCloud Functionsは、キャッシュの削除のために外部のAPIを叩くことになります。外部サービスに依存することもあり、エラーがときどき出てしまうことが予想できます。
失敗した際は、できるだけもう一度、少し時間をおいてリトライ処理を入れて、キャッシュの削除を完遂したいところです。

Cloud Functionsのうち、Cloud Pub/Subトリガのような、Background Functionsの場合、関数のデプロイ時にretryフラグを設定することで、エラー時にリトライをさせることができます。この設定をしてデプロイをした上で、Cloud Functionsの各実行でリトライするかを決定するには、Go Runtimeの場合、エントリポイントとなる関数でerrorを返すかどうかを制御します。つまり関数の実装時には、エラーが起きたとき、それはリトライするべきか、単にログを出すだけにとどめるかを判断・記憶しておき、それをエントリポイントからの返り値に反映してやる必要があります。

この配信速度はSlow Startアルゴリズムに基づきエラー率が勘案されているため、外部APIが一時的な障害を起こしていたりしても基本的には安心です。
リトライするべきかの判断に自信があればそれでよいのですが、保険として、publish時刻からの経過時間によって期限を定めることが推奨されています。既定では、未処理のPub/Subメッセージは7日間保持されるため、悪いイベントが配信されたときにリトライが継続的に発生し、配信速度の低下などが起きてしまうので、これを避けるためです。

func Entrypoint(ctx context.Context, msg pubsub.Message) error {
{
// 期限切れ
meta, err := metadata.FromContext(ctx)
// err handling
created := meta.Timestamp
expire := created.Add(ExpireDuration)
if time.Now().After(expire) {
logger.LogExpire(ctx)
return nil // expired
}
}
err := MainProc(ctx, msg)
{
// リトライ判断
retry := ShouldRetry(err)
if retry {
logger.LogRetry(ctx)
return fmt.Errorf("retry")
}
}
return nil
}

デプロイは次のようになります1

$ gcloud functions deploy <FuncName> --runtime go111 --entry-point <GoEntryFuncName> --trigger-topic <Pub/Sub TopicName> --retry --project <Project>

ロギング/モニタリング

このサービスはロギング/モニタリングともGoogle Cloud Platform内で閉じていて、Stackdriver Logging/Stackdriver Monitoringを使用しています。

ロギングでは、Stackdriver Logging API Go Libraryを用いて、キャッシュ削除対象のURLや、削除の成否、Cloud Pub/SubトリガのCloud FunctionsのメタデータにあるEvent IDなどを構造化して記録しています。このEvent IDはCloud Functions自体の実行ログにラベルとしてついているExecution IDと等しいようです。

f:id:cookie-s:20191120221416p:plain
Stackdriver Loggingのようす(一部マスク)

この構造化されたログデータから、ログベースの指標を作成して、Stackdriver Monitoringでエラー数などをダッシュボードに表示/監視し、適宜Slackにアラートを飛ばすようにしています。Slackにアラートを飛ばすときは、PolicyのDocumentationでStackdriver MonitoringのDashboardへのリンクなどを貼ると、状況確認しやすくて便利です。Slackの(formatting text](https://api.slack.com/messaging/composing/formatting)も使えます。

まとめ

Cloud Pub/SubとCloud Functionsで、手軽に非同期にキャッシュの削除をすることができるようになりました。
今の所、この仕組みは安定して、ほかからくる定常的なキャッシュ削除要求を処理しています。
今後、まだ自身でキャッシュ削除を行っている箇所の修正などでpublisherが増えると良いなと思っているので、その中で問題が起きたら、subscriberをもっと作り込めると良さそうです。


  1. 現在、Go RuntimeはGo 1.11.6に基づいているようです。