免費

讓 Claude 找出 Rails 裡的 CDN 快取中毒——三種症狀,一個根因

三個 PR 一個根因:HTML body 隨登入狀態、UA、flash 變化,但 CDN 的 cache key 不知道,導致內容跨使用者串號。讓 Claude 順著 PR 鏈一路讀出三種可重用模式:lazy personalize frame、CSS 客戶端隱藏、快取邊界守衛。


使用者進到我的站,每個公開頁面下方都浮著一行 "Content missing"。最早的幾條報告全來自 iOS——一開始我以為是 Hotwire Native 客戶端的某個怪行為,等翻 Cloudflare 日誌才意識到不對:這跟客戶端無關,是 CDN 快取被汙染了,三端(Web、iOS、Android)都會中招,只是 iOS 最先把它暴露出來

第一反應是 Hotwire Native 的 bug,第二反應是 Cloudflare 的某個怪邊緣行為。真相都不是。把這一類型的 bug 看作「CDN 問題」也是錯的——CDN 在做你告訴它的事。

讓 Claude 順著 PR 鏈一路讀下去,連續三個 PR 的根因是同一個:HTML body 會隨登入狀態、UA、flash 這種請求屬性變化,但 CDN 的 cache key 不知道這件事。下面是三種不同症狀,一個根因。

陷阱 1:tab-badge turbo-frame 跨使用者串了

//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 使用者在底部 tab bar 上看未讀訊息紅點。Web 端不需要(有自己的渲染分支),未登入的也不需要。

問題mobile_hotwire? && user_signed_in? 這個分支讓同一個 URL 的 HTML 長成兩種。但 CDN 的 cache key 只看 URL 和 Accept 標頭之類的東西——它不知道你登入沒登入。

時間線:

  1. 一個登入的 Hotwire Native 使用者訪問 /,CDN 第一次回源,快取了帶有 tab-badge frame 的 HTML
  2. 一個未登入的訪客(Web / iOS / Android 都行)訪問 /,CDN 命中同一條快取,把這份 HTML 給了他
  3. 瀏覽器執行到 turbo-frame,按 src 去請求 /notifications?badge_only=true
  4. NotificationsControllerbefore_action :authenticate_user!,看到沒登入就 302 到 /users/sign_in
  5. Turbo 在 sign_in 頁面裡找不到 id 為 tab_badge 的 frame,吐出 "Content missing"

三端(Web / iOS / Android)公開訪客都可能踩到——只要那條 cache slot 被某個登入使用者先填過。報告集中在 iOS,是因為 iOS 使用者登入率最高、快取最容易被登入態先汙染,所以未登入的 iOS 訪客碰中汙染條目的機率最大。Web 和 Android 不是「沒事」,是觸發機率低 + 報告少——一個本質上是全站災難的 bug,被早期資料偽裝成了「iOS 專屬」。

修法兩步走,目的都是讓快取不再按登入態分裂:

第一步:版面裡那個 && user_signed_in? 必須去掉。否則你永遠在和 cache key 玩猜猜看:

<%# 渲染給所有 mobile_hotwire? 請求,不管登入與否——
    帶 user_signed_in? 分支會把同一個 URL 的 HTML 切成兩個變體,
    CDN 快取命中誰就把誰的版本發給下一個人。 %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

第二步/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 裡還有一個一模一樣形狀的 bug:未登入的 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。主頁面裡完全沒有任何「按使用者分支」的 markup,CDN 怎麼快取都不會出問題。

陷阱 2:flash 提示串到了別的使用者身上

修完 #117 幾天,又一個使用者報告:他沒登入,進首頁,頁面頂上有一行「成功登入」的 flash 提示,他人都懵了。

這是同一類病,新一種症狀。

版面裡有:

<%= render "shared/_notice" %>

這一行渲染 flash[:notice] / flash[:alert]。任何 redirect 之後第一次請求都會帶著 flash,比如:

  • 登入成功後 redirect_to root_path
  • 登出後 redirect_to root_path
  • Pundit redirect_back(fallback_location: ...)

如果這次 redirect 落在的 URL 又是 public_expires_in 路徑,那 flash 就被烤進快取的 HTML 裡了。下一個匿名使用者訪問同一個 URL,CDN 把這份 HTML 給他——他就看到了別人的「成功登入」。

修法的誘惑是去重構每一個可快取的 view,把 flash 渲染移到 frame 裡。但更穩的辦法是在快取邊界攔一刀:只要這次回應帶 flash,就不能 public 快取

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash 在 layout 裡直接渲染,快取這次回應就會把上一個使用者的提示
  # 漏給下一個匿名訪客
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

這一招好處:

  1. 不用改 view——所有走 public_expires_in 的頁面自動受益
  2. 不浪費快取空間——下一次沒有 flash 的請求來時,CDN 重新回源拿到乾淨的 HTML,快取正常工作
  3. 沒有 flash 的常態存取完全不受影響——99% 的請求該快取還是快取

順手發現 coins#show 自己寫了 expires_in 5.minutes, public: true,繞開了 helper。一併改成走 public_expires_in,否則同樣的 flash 串號 bug 會從那裡冒出來。

部署完之後必須跑一次 cloudflare:purge_personalized_pages——已經被汙染過的快取條目不會自動過期,最長會留到對應 URL 的 TTL 用完(/topics 是 1 週)。

陷阱 3:登入使用者在 /topics 看不到「+ 新主題」按鈕

又過幾個小時,第三個症狀:登入使用者進 /topics,「+ 新主題」按鈕不見了。重新整理也不行。但同一個使用者用別的頁面跳到 /topics?r=1 加隨機參數(繞開快取),按鈕就回來了。

/topicspublic_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 %>

兩個守衛: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 按鈕——這兩塊都不會按使用者分支。所有「按使用者決定要不要渲染」的 markup 都搬到了 personalize 端點裡,那個端點是 private, no-store,永遠不會進 CDN。

真正的教訓:CDN cache poisoning 不是 CDN 的 bug

每一個症狀當時看都像是不同的 bug。「Content missing」看像是 Hotwire Native 的問題,flash 串號看像是 cookies/session 設定問題,按鈕失蹤看像是 view template 的邏輯分支錯了。

把三個 PR 放在一起看,根因只有一個:HTML body 的內容由 cache key 之外的請求屬性決定。CDN 的工作就是按 cache key 把 HTML 快取起來——它不可能知道這一份 HTML 其實只對登入使用者、或者只對 Hotwire Native、或者只對帶 flash 的請求是正確的。

要根治這一類 bug,三個可重用的模式:

  1. 把變化推出 cached body——任何「按使用者分支」的 markup 都改成 lazy turbo-frame,由 private, no-store 的 personalize 端點渲染。主頁面 HTML 永遠不變,CDN 怎麼快取都對。

  2. 渲染通用版本,前端 client-side 隱藏——比如 web 按鈕永遠在 HTML 裡,CSS 用 .web-only 在 native 下藏起來。這是把分支推到了 CSS/JS 層,cache key 之內的 HTML 完全統一。

  3. 快取邊界守衛——已經有狀態漏進回應(比如 flash)的請求,在快取邊界把 Cache-Control 降級成 private, no-store。常態請求完全不受影響。

這三個模式的共同點:讓「被快取的 HTML」和「按使用者分支的 markup」永遠不重疊

部署修復完之後還要做一件事:CDN 已經汙染的條目不會自動過期,TTL 最長到 1 週。寫一個一次性 rake task cloudflare:purge_personalized_pages 把所有可疑路徑主動 purge 掉,否則 bug 還會在快取自然過期前一直冒出來。

順帶提一下:lazy frame 不只有快取問題

PR #122 是同一段時間發現的,形狀一樣、bug 類型不一樣——值得單獨說一下,因為它和 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, ...)  # ← fallback
    end
end

posts#following 這個 action 不在分支列表裡,於是落到了最後的 posts_path fallback——也就是 /posts,那是 explore feed。

效果:/following 頁面第一頁是對的(因為 controller action 自己 set 了 @posts = following_posts),第二頁之後 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)

這跟前面三個 cache poisoning bug 不是同一類——set_load_more_path 的分支錯和 CDN 沒關係。但它和它們共享同一個讓人看不到的機制:lazy turbo-frame 載入的內容你不會主動 review

117 的 frame 在出錯時靜默 302,#119/#121 的內容快取裡寫錯了你看不出來,#122 的 frame 路徑錯了你只看第一頁就發現不了。一旦把「動態內容」塞進 lazy frame,就要主動 review frame 載入之後的狀態——Claude 在 PR review 階段幫我抓的就是這種「frame 載入完之後會發生什麼」的檢查項。

幾個值得自查的清單

如果你的 Rails 應用前面掛了 CDN(Cloudflare、Fastly、隨便哪家),下面這幾個組合八成至少踩了一個:

  • 任何 public_expires_in / expires_in ..., public: true 的 view 樣板裡,存不存在 if user_signed_in? 這種分支?
  • flash 在 layout 裡直接渲染嗎?public_expires_in 路徑在 flash 存在時會不會被快取?
  • Hotwire Native 應用是否有 mobile_hotwire? 守衛的 markup 出現在可快取路徑上?
  • 任何 turbo-frame 的 src 端點,它的 controller 用 before_action :authenticate_user! 嗎?未登入請求會不會 302?
  • lazy turbo-frame 的 src 路徑是從一個 helper 算出來的嗎?這個 helper 有沒有兜底分支?兜底是不是錯的 URL?

讓 Claude 把這五個清單跑一遍——如果某條 hit 了,那就是下一個 PR 的素材。這種 bug 不會主動浮出水面,因為 lazy frame 靜默失敗、CDN 靜默命中、CSS 靜默隱藏。三個機制疊在一起,bug 可以在生產藏好幾個月。