インフラ

CDNキャッシュの背後でページビューを失わずにカウントする

エッジキャッシュは、ほとんどのページビューがオリジンに到達するのを防ぐため、インラインでのカウントは機能しなくなります。収集をビーコンに移動し、ルーティングが提供したガードを置き換えてください。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

HTMLのエッジキャッシュを有効にしたことで、ほとんどのページビューがオリジンに到達しなくなりました。それがまさに狙いでした。また、カウンターがリクエストパス上にあり、そのパスが今やほとんど空になったため、ビューカウントも人知れず機能しなくなりました。修正策は、キャッシュが決して提供しない小さなPOSTリクエストに収集処理を移動することです。誰も警告してくれないことですが、リクエストパスはバリデーターとしても機能しており、そこから移行するということは、存在に気づいていなかったガードを再構築する必要があることを意味します。

キャッシュの背後でインラインカウントが機能しなくなる理由

ページハンドラ内でビューをカウントするのは、それを配置する場所として当然の選択です。リクエストはすでにそこにあり、記事もわかっていて、2回目のラウンドトリップを回避できます。キャッシュが介在するまでは、これは完璧に機能します。

エッジがHTMLをキャッシュすると、キャッシュヒットはオリジンに全く触れることなく提供されます。あなたのハンドラは実行されません。何もインクリメントされません。カウンターは今やキャッシュミスを測定するものとなり、これはあなたが望んでいたことのほぼ逆です。ページの人気が高ければ高いほど、より確実にエッジから提供されるようになり、ビューの数は少なく見えてしまいます。

この障害は静かに発生します。エラーも、アラートも、失敗したリクエストもありません。数値は動き続けますが、ただゆっくりと、そして誤って動くだけです。人気記事ウィジェットがこれらの数値を読み取ると、「どのページがたまたまキャッシュミスしたか」によってランキングを開始します。これは、読者数よりもキャッシュの削除やトラフィックの分散と相関しています。

ページビューはエッジで停止しますが、ビーコンは停止しません 1 ページビュー 2 ミス時 3 ビーコンPOST 読者 CDNエッジ オリジン // キャッシュされたビューはエッジで停止しますが、ビーコンは常にオリジンに到達します

リクエストパスが静かに守っていたもの

ここが見落としやすい部分です。元のカウンターは次のようになっていました:

// runs only after routing proved the article exists and is published
if !isBot(r.UserAgent()) {
    views.Hit(r.Context(), lang, slug)
}

その配置は3つの仕事をしていましたが、そのうち明白だったのは1つだけでした。

明白な仕事はカウントすることでした。2つ目の仕事は順序付けでした。つまり、ハンドラーが記事をロードして公開済みであることを確認した後にカウンターが実行されるため、未知または未公開のスラッグがバッファに到達することは決してありませんでした。ルーティングが、無料でバリデーターの役割を果たしていました。3つ目の仕事は、物理的な制約によるレート制限でした。読者はページをロードできる速さでしかビューを生成できず、ページのロードは十分に高コストなため、誰もそれを攻撃対象とは考えません。

コレクションをパブリックエンドポイントに移動すると、これら3つすべてが一度に変わってしまいます。クライアントがスラッグを指定するようになるため、何でも好きな名前を指定できてしまいます。記事はロードされていないため、その存在を証明するものは何もありません。そして、短いボディを持つPOSTリクエストは、ループで送信できるほど低コストです。

これこそが移行の真のコストであり、どのチュートリアルにも載っていません。カウンター自体は10行程度のコードです。再構築しなければならないガードこそが、残りの作業なのです。

コレクションをビーコンに移動する

このエンドポイントは、意図的に簡素なものになっています。POST を受け付け、1つのビューを記録し、ボディなしで 204 を返します。

func (s *Server) handleHit(w http.ResponseWriter, r *http.Request, lang string) {
    w.Header().Set("Cache-Control", "no-store")

    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBeaconBody))
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    slug := strings.TrimSpace(string(body))
    if !validSlug(slug) {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    if isBot(r.UserAgent()) {
        w.WriteHeader(http.StatusNoContent)
        return
    }
    s.views.Hit(r.Context(), lang, slug)
    w.WriteHeader(http.StatusNoContent)
}

見た目以上に重要な3つの詳細があります。

Cache-Control: no-storeは飾りではありません。キャッシュのルールが緩いと、POSTレスポンスがキャッシュされてしまい、その結果、この一連の処理が1つ下のレイヤーで繰り返されることになります。ほとんどのCDNはデフォルトではPOSTをキャッシュしませんが、これを明記しておけばコストはかからず、将来のルール変更にも対応できます。

メソッドはGETではなく、POSTでなければなりません。GETビーコンはキャッシュ可能で、プリフェッチ可能であり、リンクスキャナーによって発火されます。POSTはこれらのいずれでもありません。

クライアントでは、fetchよりもsendBeaconを呼び出すのが適切です:

if (navigator.sendBeacon) {
  navigator.sendBeacon(url, slug);
} else if (window.fetch) {
  fetch(url, { method: "POST", body: slug, keepalive: true }).catch(function () {});
}

sendBeaconは、keepaliveなしのfetchとは異なり、ページのアンロード後も存続し、文字列のボディをtext/plainとして送信します。これにより、リクエストは単純リクエストのカテゴリに留まり、プリフライトを回避します。スクリプトはページ読み込みごとに1回実行されるため、重複排除ロジックなしで、ビューごとに正確に1つのビーコンが得られます。ブラウザのキャッシュから復元された「戻る」および「進む」ナビゲーションではスクリプトは再実行されないため、履歴ナビゲーションによってもカウントが増加することはありません。

先ほど削除したガードを置き換える

任意のslugに対する魅力的な修正方法は、ビーコンごとに記事を検索することです。それは避けてください。ビーコンごとの読み取りは、バッファリングしている書き込みよりも高コストであり、従量課金制のデータベースでは、安価なカウンターが最大の読み取りソースに変わってしまいます。

3つの安価なレイヤーがそれに取って代わります。そのいずれもデータベースには触れません。

書き込みはすでに非upsertになっています。カウンターはupdateではなくupsertを使用するため、未知のslugはどのドキュメントにも一致せず、何も変更しません。ゴースト行は出現しません。この特性は、おそらくあなたのコードにすでに存在しており、何か他のものを構築する前に確認する価値があります。なぜなら、最も恐ろしい障害モードを無料で取り除いてくれるからです。

バッファにカーディナリティの上限を設けます。閲覧数は通常、メモリ内に蓄積され、定期的にフラッシュされます。ウィンドウごとの個別キーに上限を追加します。

v, loaded := b.counts.LoadOrStore(key, new(int64))
if !loaded && b.keys.Add(1) > maxBufferKeys {
    b.counts.Delete(key)
    b.keys.Add(-1)
    return
}

既存のキーは蓄積され続けるため、実際のトラフィックは影響を受けません。上限を超えた新しいキーのみが破棄されます。実際のカーディナリティは記事数×言語数であり、数十の記事の場合、これは1万キーの制限を2桁下回ります。

エンドポイントは独自のレートリミッターを持ちます。他の制限されたルートとは別のトークンバケットを使用してください。バケットを共有すると、あるエンドポイントの乱用が他のエンドポイントから読者を締め出すことになります。読者は記事の閲覧ごとに1つのビーコンを送信するため、毎秒2の持続レートと20のバーストがあれば、一度に開かれた複数のタブをカバーし、それでもループを停止させることができます。

CORSはその防御の一部ではありません

Access-Control-Allow-Methods から POST を除外することが、他のオリジンがあなたのエンドポイントにPOSTするのを防ぐという根強い考えがあります。そうではありません。

text/plain のボディを持つ POST は単純リクエストです。ブラウザはプリフライトなしでそれを送信します。その後、CORSが呼び出し元のページがレスポンスを読み取れるかどうかを決定しますが、ビーコンはレスポンスを完全に無視するため、攻撃者は何も失いません。リクエストはすでに到着しており、ビューもすでにカウントされています。

したがって、そのヘッダーはあなたを保護しませんし、それに POST を追加しても何の助けにもなりません。それを追加すると、以前はブロックされていたプリフライトを伴うクロスオリジンコールが可能になりますが、これは厳密に攻撃対象領域を広げるだけで、メリットはゼロです。なぜなら、あなた自身のビーコンは同一オリジンであり、CORSを全く参照しないからです。

正しい対処法は、ヘッダーをそのままにしておき、その隣に理由を書き留めておくことです。そうすれば、次の人が一貫性のために POST を追加することがなくなります。防御策はレートリミッターとバッファの上限です。そのことをコメントで明記することは、そのヘッダー自体よりもはるかに価値があります。

間違った方向を保護していたテスト

この変更によって明らかになった最も有用なことは、テストの失敗でした。記事を読み込むとビューカウントが増加することをアサートするテストがあり、それは即座に失敗しました。

そのテストは、書かれた時点では正しかったのです。今では、それは完全に正反対になっています。CDN 環境下では、カウンターをインクリメントする記事ハンドラーがバグになります。なぜなら、それはほとんどの読者が決して経由しないパスに、カウンティングが戻ってしまったことを意味するからです。そのため、そのテストは削除されず、逆転させられました:

// Loading an article must NOT count a view. Collection belongs to the beacon,
// which the cache does not serve. A failure here means inline counting is back.
if got := viewCount(t, srv, st, "hello", "en"); got != 0 {
    t.Fatalf("view count = %d on the SSR path", got)
}

テストを削除するのではなく反転させることが、ここでの重要な教訓です。削除されたテストは何も残しません。そして6ヶ月後、それが見落としのように見えるという理由で、誰かがページハンドラーに views.Hit を追加し直してしまいます。反転させたテストは、古い契約が存在していた場所に新しい契約を記述します。そして、古い設計が戻ってきた瞬間に明確に失敗します。

それを、ビーコンスクリプトが実際にページにレンダリングされることを確認するテストと組み合わせましょう。エンドポイントが機能することと、ページがそれを呼び出すことは、独立した失敗要因です。そして、一方なしでもう一方を出荷すると、何も呼び出さない、機能はするカウンターができあがってしまいます。

リリース後に確認すべきこと

エンドポイントだけを信頼するのではなく、チェーンをエンドツーエンドで検証してください。レンダリングされた記事に、正しいスラッグでビーコンスクリプトが表示されることを確認してください。POSTが、キャッシュをバイパスしたことを示すキャッシュステータスヘッダーとともに204を返すことを確認してください。コンテンツセキュリティポリシーによってブロックされたビーコンは、ブラウザで完全に失敗し、サーバーに痕跡を残さないため、オリジンログでPOSTが到着していることを確認してください。その後、1フラッシュ間隔待って、数値が変動することを確認してください。

最後に、どこか見える場所に書き留めておくべきことがあります。絶対値が不連続になったということです。変更前はすべてキャッシュミスをカウントしていましたが、変更後は実際のビューをカウントします。相対的なランキングは数時間以内に回復しますが、切り替えをまたぐ累積合計は2つの異なる測定値を足し合わせたものになり、変更履歴に記載されていなければ3ヶ月後には誰もそのことを覚えていないでしょう。