工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    每個 IP 只發 2 個 request 的攻擊,rate limit 要掛在什麼上面?

    Authors
    • avatar
      Name
      Alex Yu

    上一篇事故文發出去之後,有讀者問:「後來你是怎麼減輕(mitigate)這個問題的?」

    好問題。那次攻擊最陰的地方是:6,401 個 IP,平均每個只發 2 個多 request——per-IP rate limit 看每一個都像正常人。傳統防禦的第一直覺直接失效。

    這類威脅其實有正式的名字:OWASP 把它編為 OAT-011 Scraping,有標準的解法組合。不是單一招,是三層。

    第一層:Bot Management(邊緣辨識)——rate limit 要掛在「指紋」上,不是 IP 上

    爬蟲可以換 IP、可以偽裝 UA,但有一個東西很難偽裝:TLS handshake 的特徵

    瀏覽器和爬蟲程式在建立 HTTPS 連線時,送出的加密參數組合不一樣。把這些參數做成 hash,就是所謂的 JA3/JA4 指紋(Salesforce 開源的方法,JA4 是後繼版)——Python 的 requests 跟 Chrome 的指紋長得完全不同,UA 寫得再像也蓋不掉。

    所以邊緣層的正解是把 rate limit 的計數單位從 IP 換成指紋:

    per-IP:    6,401 個 IP × 每個 2 個 request → 每個都正常,全部放行 ❌
    per-指紋:  同一隻爬蟲程式 × 14,542 個 request → 一個 key 爆表,全部攔下 ✅

    AWS WAF 從 2025 年起支援直接拿 JA3/JA4 指紋當 rate-based rule 的計數 key——官方特地做這個功能,等於承認 per-IP 對分散式攻擊沒用。

    另一個訊號是來源網段:那次攻擊的 IP 全部來自雲端 VPS 業者。正常讀者不會從 DigitalOcean 的機房看健康資訊,對機房 ASN 的頁面請求出一層 JS challenge(真人瀏覽器無感通過,無頭爬蟲大多卡住),就能濾掉一大片。

    這層的取捨:商用 bot management(Cloudflare、Akamai 那些)要錢,而且有誤殺率要調——誤殺的每一個都是真實使用者。這不是「開下去就好」的開關。

    第二層:拆掉「一變多」的結構——讓漏網的 request 變便宜

    邊緣擋不到 100%,所以第二層是讓每個穿透的 request 傷害變小。上一篇講過我們的放大效應:沒快取的長尾 URL × SSR 要打的 API 數。拆它有三招:

    站內搜尋改 client-side render

    search 頁本來就設 noindex(站內搜尋結果頁不該進 Google 索引),SSR 對它沒有 SEO 價值。改成 client 端 fetch 之後,爬蟲抓到的 HTML 只是一個空殼——SSR 那層「一個 request 變好幾個 API call」直接歸零。

    很多大型網站的站內搜尋就是這樣做的。老實說這招沒有哪份官方文件叫你做,它是從「noindex 的頁面不值得付 SSR 的成本」推出來的慣行取捨,但對症程度是三招裡最高的。

    Single-flight:一百個相同的 cache miss,只放一個過去

    爬蟲的併發轟炸有個特性:同一瞬間大量重複的請求。single-flight(也叫 request coalescing)讓同 key 的併發 miss 共用一次後端呼叫:

    const inflight = new Map<string, Promise<unknown>>()
     
    async function coalesced<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
      const existing = inflight.get(key)
      if (existing) return existing as Promise<T> // ← 已經有人在問了,跟著等答案
      const p = fetcher().finally(() => inflight.delete(key))
      inflight.set(key, p)
      return p
    }

    Go 圈把這個 pattern 做成了標準庫(singleflight),解的就是 cache stampede/thundering herd 這類「大家同時問同一題」的問題。

    別把使用者輸入印成連結

    爬蟲的隊列來自 HTML 裡的 <a href>。把追蹤用的參數、無限組合的 tag 印成連結,等於親手把攻擊面清單交出去。追蹤參數改 query string、非導覽用途的路徑不要 render 成 <a>,攻擊面就縮回有限集合。

    第三層:止血與隔離——最壞情況發生時,別讓它擴散

    前兩層都漏了,最後一層決定「掛多慘」。

    • Fail fast:那次事故 pod 的回應時間飆到 15–55 秒——每個卡住的 request 都佔著資源陪葬。正解是短 timeout +熔斷,過載時快速回 503,讓系統保有處理「能處理的量」的能力。Google SRE book 的 Handling OverloadAddressing Cascading Failures 把這套講得最透,那次 latency 雪崩的過程跟書裡的案例幾乎逐句對得上。
    • 爆炸半徑隔離:search 用獨立的 Pod 池(Bulkhead pattern,船艙隔板——一艙進水不沉全船),被打掛也不拖累文章頁。這項我們有做,下一節會講它實際起了什麼作用。
    • 探針分層:liveness 走淺層檢查,別讓「Pod 忙」被誤判成「Pod 死」而遭 ALB 剔除——過載時每誤殺一個 Pod,剩下的更快陣亡。

    那我們做到哪了?

    誠實回答(也是我回覆那位讀者的內容):目前落地的是「減傷」多於「阻擋」。先說明:出於安全考量,這裡列的只是已經公開談過的部分,不是完整的防禦清單——

    1. 爆炸半徑隔離本來就有:search 在 K8s 上是獨立的 pod 池(自己的 deployment 和 HPA,/search 的流量單獨導過去)。這是事故當下文章頁能照常服務的原因——被打掛的自始至終只有搜尋功能。也因為隔離做了,「讓它掛幾分鐘自己恢復」才是一個可以接受的選項
    2. SSR 加了 in-memory cache(TTL 2 分鐘),把選單、熱門關鍵字這些每頁共用的 call 快取住,search 頁一次 SSR 約 8 個 API call,一半以上不再打到後端
    3. search 分頁做了防爬收斂:參數不合法提早 404 不打 API、超過最後一頁 404、整頁 noindex
    4. robots.txt 對 /search 加了 Disallow(對守規矩的爬蟲才有效,自知)

    對照三層架構:第三層的隔離有了、第二層補了一部分。放大效應還沒從根本消除(第一層和 CSR 化都還沒上),但爆炸半徑已經隔離掉了——最壞情況是 search 自己掛一下、會自動恢復,剩下的是可接受的殘餘風險,不是等著出事的洞。也因為這樣,把「正解全貌」整理成這篇才有意義——知道完整的光譜,才知道自己站在哪裡、下一步補哪塊 CP 值最高。

    重點整理

    • 分散式爬蟲的破綻不在頻率在指紋:rate limit 的 key 用 JA3/JA4 或 ASN,不要用 IP
    • 站內搜尋頁都 noindex 了還 SSR,等於成本白繳、攻擊面白送——CSR 是對症解
    • single-flight 讓併發轟炸自然塌縮成一次後端呼叫
    • 過載時 fail fast 比撐著好:15 秒的 hang 比 0.1 秒的 503 傷害大得多
    • 爆炸半徑先隔離,「讓它掛幾分鐘」才會是一個可接受的選項

    延伸閱讀

    參考資料