3 つの PR に共通する根本原因:HTML body はログイン状態・UA・flash で変わるのに、CDN の cache key はそれを知らないため、コンテンツがユーザー間で混線する。Claude に PR チェーンを読ませることで、再利用可能な 3 つのパターンが見えてくる:lazy personalize frame、CSS によるクライアント側非表示、キャッシュ境界ガード。
ユーザーがサイトに来ると、すべての公開ページの下端に "Content missing" という英文が浮かんでいた。最初の数件の報告は全部 iOS から──最初は Hotwire Native クライアント特有の挙動だと思った。Cloudflare のログを掘ってようやく気づいた:これはクライアントとは無関係。CDN キャッシュが汚染されていて、3 端(Web / iOS / Android)すべてが踏み得る。iOS が最初に表面化させただけだ。
第一感:Hotwire Native のバグ。第二感:Cloudflare のどこかの妙なエッジ挙動。どちらも違う。この種のバグを「CDN の問題」と呼ぶこと自体が誤り──CDN は指示された通りに動いている。
Claude に PR チェーンを順に読ませてみると、連続する 3 つの PR の根本原因はすべて同じだった:HTML body はログイン状態・UA・flash といったリクエスト属性で変わるのに、CDN の cache key はそれを知らない。3 つの異なる症状、1 つの根本原因。
/、/topics、/square、/coins、/searches/app は public_expires_in で CDN キャッシュに乗る。レイアウトには:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge は turbo-frame をレンダリングする:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
意図は明確:ログイン済みの Hotwire Native ユーザーだけがタブバーに未読通知の赤丸を見る。Web には別の描画パスがあり不要、未ログインも不要。
問題:mobile_hotwire? && user_signed_in? のブランチで同じ URL の HTML が 2 種類になる。だが CDN の cache key が見るのは URL や Accept ヘッダーくらい──ユーザーがログインしているかどうかは知らない。
タイムライン:
/ にアクセス。CDN がオリジンに行き、tab-badge frame 入りの HTML をキャッシュ。/ にアクセス。CDN は同じキャッシュをヒットさせ、その HTML を渡す。src で /notifications?badge_only=true をフェッチ。NotificationsController には before_action :authenticate_user! があり、未ログインを見て /users/sign_in に 302。tab_badge の frame が見つからず、"Content missing" を吐く。3 端(Web / iOS / Android)の未ログイン訪問者は皆踏み得る──ログインユーザーが先に埋めた cache slot を後から踏みさえすれば。報告が iOS に集中したのは、iOS のログイン率が最も高く、キャッシュがログイン状態で先に汚染されやすいから──未ログインの iOS 訪問者が汚染エントリに当たる確率が最大だった。Web と Android は「無事」だったわけではなく、トリガー確率が低くて報告が少なかっただけ。本質的にはサイト全域の災害が、初期データによって "iOS 専属" に偽装されていた。
修正は 2 段階。どちらもキャッシュをログイン状態で分裂させないため:
ステップ 1:レイアウトの && user_signed_in? を消す。さもないと永遠に cache key の当てっこゲームをやることになる:
<%# mobile_hotwire? のすべてのリクエストに対して描画する。
ログイン状態に関わらず──user_signed_in? のブランチは
同じ URL の HTML を 2 つのバリアントに分裂させ、
どちらかが CDN slot を取り、次の人へ流れる。 %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
ステップ 2:/notifications?badge_only=true の frame エンドポイントは、未ログインリクエストに対しても構造的に同一の 200 frame を返さなければならない。302 はダメ。さらにエンドポイント自体は private, no-store で、特定ユーザーの未読数を CDN が共有しないようにする。
class NotificationsController < ApplicationController
before_action :authenticate_user!, except: [:index]
def index
if params[:badge_only] || params[:count_only]
response.headers["Cache-Control"] = "private, no-store"
redirect_to notifications_path and return unless turbo_frame_request?
@unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
if params[:badge_only]
render(partial: "notifications/tab_badge")
else
render(partial: "notifications/bell")
end
return
end
authenticate_user!
# ... 通常の index ロジック
end
end
authenticate_user! は index に対してのみバイパス、しかも frame サブリクエストに対してのみ。frame 以外のリクエストは依然 redirect を通る。
同じ PR には形がそっくりのバグがもう 1 つあった:未ログインの Hotwire Native クライアントユーザーが compose FAB(フローティングボタン)を見ていた──同じ汚染で、ログイン状態に依存するキャッシュが未ログイン訪問者にリークしていた。元の書き方はレイアウトで直接 user_signed_in? を見て stimulus controller div を描画するか決めていた。修正:FAB も lazy frame に移し、/personalize/compose_fab という private, no-store エンドポイントから描画させる。
<%# レイアウトは常に空 frame を吐く %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab テンプレート %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
レイアウトは毎回空の frame を吐き、frame の中身がログイン状態を見て controller div を入れるか決める。メインページの HTML には「ユーザーで分岐する」マークアップが一切ない──CDN がどうキャッシュしようと壊れない。
同じ病、別の症状。
レイアウトには:
<%= render "shared/_notice" %>
これが flash[:notice] / flash[:alert] を描画する。redirect 後の最初のリクエストには flash が乗っている:
redirect_to root_path(ログイン成功後)redirect_to root_path(ログアウト後)redirect_back(fallback_location: ...)redirect の着地先が public_expires_in 経路だったら、flash がキャッシュされた HTML に焼き込まれる。次の匿名訪問者が同じ URL を踏むと CDN がその HTML を返す──他人の「ログイン成功」が出る。
修正の誘惑は、キャッシュ可能な view 全部をリファクタして flash を frame に移すこと。だが、より堅実なのはキャッシュ境界で止めること:このレスポンスが flash を含むなら、public キャッシュさせない。
def public_expires_in(duration)
return unless Rails.env.live?
# flash はレイアウトで直接描画される。このレスポンスをキャッシュすると
# 前のユーザーの通知が次の匿名訪問者にリークする
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
良いところ:
public_expires_in を使うすべてのページが自動で恩恵を受けるついでに coins#show が独自に expires_in 5.minutes, public: true を書いていて helper をバイパスしていることに気づいた。一括で public_expires_in 経由に変える。さもないと同じ flash リーク・バグがそこから再発する。
デプロイ後は cloudflare:purge_personalized_pages を一度走らせる必要がある──既に汚染されたキャッシュエントリは自動失効しない。最長で対応 URL の TTL(/topics は 1 週間)まで残る。
/topics で「+ 新規トピック」ボタンを見られない数時間後、3 つ目の症状:ログインユーザーが /topics に来ると「+ 新規トピック」ボタンが消えている。リロードしても戻らない。だが同じユーザーが別ページから /topics?r=1(キャッシュ回避用ランダムパラメータ)に飛ぶとボタンが復活する。
/topics は public_expires_in 1.week で、最も強くキャッシュされる。元の view:
<% unless mobile_hotwire? %>
<a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>
<% if mobile_hotwire? %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
2 つのガード:UA ガード(web vs native)、auth ガード(ログイン有無)。CDN はどちらも知らない──最初にオリジンを叩いたのがどんなユーザーかでキャッシュの中身が決まる。最初の匿名 web 訪問者が「native bridge button なし、web button あり」をキャッシュさせる;最初のログイン済み native 訪問者が「bridge button あり、web button なし」をキャッシュさせる──次に来る人の運命は cache slot が当たるかどうか次第。
修正方針は #117 と同じ:
Web のボタン──分岐なし、常に描画:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only で CSS が native UA のとき隠す。CDN にキャッシュされる HTML は常に同じ、ボタンは常にある、UA で表示するかしないかを決める──CSS は client-side の判断で、cache は関与しない。
Native bridge ボタン──lazy personalize frame に移す:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# /personalize/topic_new_button テンプレート %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
メインページの HTML には <turbo-frame> コンテナと web ボタンしかない──両方ともユーザーで分岐しない。「ユーザーで描画を決める」マークアップは全部 personalize エンドポイント側に移った。そっちは private, no-store で CDN には絶対に入らない。
それぞれの症状は、その時々で別々のバグに見えた。"Content missing" は Hotwire Native の問題に見え、flash リークは cookies/session 設定の問題に見え、ボタン消失は view template のロジック分岐の間違いに見えた。
3 つの PR を並べて見ると、根本原因は 1 つ:HTML body の中身が cache key の外にあるリクエスト属性で決まる。CDN の仕事は cache key で HTML をキャッシュすることだけ──それが「ログインユーザーにだけ正しい HTML」「Hotwire Native にだけ正しい HTML」「flash 付きリクエストにだけ正しい HTML」だなんて知るはずがない。
このタイプを根治するための再利用可能な 3 パターン:
可変部分をキャッシュされる body の外に出す──「ユーザーで分岐する」マークアップは全部 lazy turbo-frame に変え、private, no-store の personalize エンドポイントから描画。メインページの HTML は不変、CDN がどうキャッシュしても正しい。
共通版を描画してクライアント側で隠す──たとえば web ボタンは常に HTML に存在し、CSS の .web-only が native UA で隠す。分岐は CSS/JS 層に移り、cache key 内の HTML は完全に統一される。
キャッシュ境界のガード──状態(flash 等)が混入したレスポンスは、キャッシュ境界で Cache-Control を private, no-store に格下げ。通常リクエストは無影響。
3 パターンの共通点:「キャッシュされる HTML」と「ユーザー分岐するマークアップ」が決して重ならないようにする。
修正をデプロイした後にもう 1 つやることがある:CDN の汚染エントリは自動失効しない、TTL は最長で 1 週間。一回限りの rake task cloudflare:purge_personalized_pages を書いて怪しいパスを能動的に purge する──さもなければバグはキャッシュの自然失効まで出続ける。
PR #122 はほぼ同時期に発見された。形は同じ、バグの種類は違う──cache poisoning と同じ「見えない」メカニズムを共有しているので別途取り上げる価値がある。
/following ページは lazy turbo-frame で次のページを読み込んでいる(infinite scroll の定番)。frame の src は set_load_more_path という helper が計算する──現在の controller/action から次ページ URL を決める。
def set_load_more_path(page:, anchor_id: nil)
if controller_name == "coins" && action_name == "show"
path = coin_symbol_path(...)
elsif controller_name == "posts" && action_name == "hot"
path = hot_path(...)
# ... 多くの分岐 ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← フォールバック
end
end
posts#following という action は分岐リストにないので、最後の posts_path フォールバックに落ちる──つまり /posts、explore feed のことだ。
効果:/following の 1 ページ目は正しい(controller action 自身が @posts = following_posts をセットするから)、しかし 2 ページ目以降は lazy frame がこっそり /posts?page=2 を取りに行き、explore feed の内容が読み込まれる──フォローしていない人の投稿が紛れ込んでくる。
修正は短い:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
これは前 3 つの cache poisoning とは別カテゴリ──set_load_more_path の分岐ミスは CDN とは無関係。だが同じ「見えにくい」メカニズムを共有している:lazy turbo-frame で読み込まれる中身は能動的に review されない。
Rails アプリの前に CDN(Cloudflare、Fastly、何でもいい)が立っているなら、以下の組み合わせのうち少なくとも 1 つは踏んでいる確率がかなり高い:
public_expires_in / expires_in ..., public: true を使う view template に if user_signed_in? のような分岐は存在するか?flash はレイアウトで直接描画されているか? public_expires_in 経路で flash がある時にキャッシュされてしまわないか?mobile_hotwire? でガードしたマークアップがキャッシュ可能経路に出ていないか?src エンドポイントの controller は before_action :authenticate_user! を使っているか? 未ログインで 302 しないか?src パスは helper で計算されているか? その helper にフォールバック分岐はあるか? そのフォールバックは正しい URL か?このリストを Claude に走らせよう──どれか 1 つでもヒットすれば、それが次の PR のネタだ。この種のバグは自然には浮上しない、なぜなら lazy frame は静かに失敗し、CDN は静かにヒットし、CSS は静かに隠す。3 つのサイレントなメカニズムが重なり、バグは本番で何ヶ月も潜んでいられる。