- Published on
每個 IP 只發 2 個 request 的攻擊,rate limit 要掛在什麼上面?
- Authors
-
-
- 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 Overload 和 Addressing Cascading Failures 把這套講得最透,那次 latency 雪崩的過程跟書裡的案例幾乎逐句對得上。
- 爆炸半徑隔離:search 用獨立的 Pod 池(Bulkhead pattern,船艙隔板——一艙進水不沉全船),被打掛也不拖累文章頁。這項我們有做,下一節會講它實際起了什麼作用。
- 探針分層:liveness 走淺層檢查,別讓「Pod 忙」被誤判成「Pod 死」而遭 ALB 剔除——過載時每誤殺一個 Pod,剩下的更快陣亡。
那我們做到哪了?
誠實回答(也是我回覆那位讀者的內容):目前落地的是「減傷」多於「阻擋」。先說明:出於安全考量,這裡列的只是已經公開談過的部分,不是完整的防禦清單——
- 爆炸半徑隔離本來就有:search 在 K8s 上是獨立的 pod 池(自己的 deployment 和 HPA,
/search的流量單獨導過去)。這是事故當下文章頁能照常服務的原因——被打掛的自始至終只有搜尋功能。也因為隔離做了,「讓它掛幾分鐘自己恢復」才是一個可以接受的選項 - SSR 加了 in-memory cache(TTL 2 分鐘),把選單、熱門關鍵字這些每頁共用的 call 快取住,search 頁一次 SSR 約 8 個 API call,一半以上不再打到後端
- search 分頁做了防爬收斂:參數不合法提早 404 不打 API、超過最後一頁 404、整頁 noindex
- 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 傷害大得多
- 爆炸半徑先隔離,「讓它掛幾分鐘」才會是一個可接受的選項
延伸閱讀
- 6,401 個 IP、3 分鐘、7,262 個錯誤:一隻普通的爬蟲怎麼打掛我的 search 頁——這篇的前傳,那個放大效應怎麼形成的
參考資料
- OWASP - OAT-011 Scraping
- salesforce/ja3・FoxIO-LLC/ja4(TLS 指紋的原始實作)
- Cloudflare - JA3/JA4 fingerprint
- AWS WAF - JA4 fingerprinting for rate-based rules
- Google SRE Book - Handling Overload
- Google SRE Book - Addressing Cascading Failures
- Request coalescing with Go singleflight
- Azure Architecture Center - Bulkhead pattern