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

    6,401 個 IP、3 分鐘、7,262 個錯誤:一隻普通的爬蟲怎麼打掛我的 search 頁

    Authors
    • avatar
      Name
      Alex Yu

    前陣子某個平日中午,監控炸出一波警報:網站的 search 頁大量回 502/503,使用者點開搜尋結果就是錯誤頁。12:13 開始,12:15 自己恢復,前後大概三分鐘。

    短到我打開 dashboard 時,事故已經結束了。本以為是後端偶發抖一下,拉出數字才發現不對勁:

    • 7,262 個 5XX 錯誤,最高峰的那一分鐘,每兩個 request 就有一個失敗(錯誤率 50.3%)
    • 97% 的錯誤集中在 /search/*/tag/* 這類搜尋結果頁

    到底是誰、用什麼方式,在三分鐘內把一個平常好好的頁面打成這樣?

    先交代架構

    要看懂這次事故,得先知道一個 request 進來會經過哪些關卡:

    使用者 / 爬蟲
    
     CDN(手上有快取就直接回,沒有就往下送)
    
     ALB(AWS 的 load balancer,把流量分給後面的機器)
    
     K8s 上的 4 個 Next.js pod(SSR 在這裡發生)
    
     後端 API

    重點只有兩個:CDN 擋在最前面,快取住的頁面根本不會碰到我們的伺服器;search 頁是 SSR(server-side rendering),每個 request 都要在伺服器上現場組頁面。

    兇手:6,401 個 IP 的分散式爬蟲

    拉 ALB 的 access log 分析,攻擊輪廓很清楚:

    • 6,401 個不同的 IP,全部來自雲端 VPS 業者(DigitalOcean、Contabo⋯)
    • 3 分鐘內打了約 14,542 個 request,全部瞄準 /search/*/tag/*

    換算下來,每個 IP 平均只發了 2 個多 request。單看任何一個 IP,行為都完全正常——這件事很關鍵,後面會回來講,它讓一種常見的防禦手段直接失效。

    但爬蟲天天都有,Google 也天天在爬。為什麼這次會把服務打掛?

    為什麼一隻爬蟲打得掛一個網站

    因為我們的架構裡疊了三層「一變多」的效果,爬蟲只是把它們串起來引爆而已。

    第一層:search 頁把使用者輸入變成了連結

    search 頁會把關鍵字、tag 全部 render 成 <a> 連結,連 GA 追蹤用的參數也是:

    <a href="/search/糖尿病/tag/飲食">飲食</a>
    <a href="/tag/導覽列_logo">…</a>   <!-- ← GA 追蹤用的,根本不是給人點的 -->
    <a href="/tag/fb-button">…</a>

    爬蟲的行為很單純:抓到一頁,就把頁面裡每個 <a href> 排進隊列繼續抓。「關鍵字 × tag」的組合幾乎是無限的——等於我們親手把一份打不完的 URL 清單,印在 HTML 裡送給爬蟲。

    第二層:這些 URL 沒人訪問過,CDN 手上沒有快取

    CDN 的邏輯是「有人要過的頁面我存一份,下個人再要就直接給」。所以熱門頁面像 /search/糖尿病,爬蟲怎麼打都傷不到伺服器。

    /tag/導覽列_logo 這種組合,正常使用者一輩子不會點到,CDN 手上根本沒這一頁,只能把 request 原封不動轉回我們的伺服器(這個行為叫「回源」,back to origin)。

    這三分鐘的回源量,衝到平常的 91 倍

    第三層:組一個頁面,要打 4–7 個 API

    回到伺服器還沒完。SSR 組一個 search 頁要打 4–7 個後端 API——搜尋結果、熱門關鍵字、分類清單⋯⋯一個 request 進來,好幾個 request 出去,這種一變多的行為叫 fan-out:

    // search 頁 SSR:1 個 request 進來,4-7 個 API call 出去
    const [results, hotKeywords, categories /* ... */] = await Promise.all([
      searchApi(term, tag), // ← 真正非在伺服器上做不可的只有這個
      getHotKeywords(),
      getCategories(),
      // ...
    ])

    三層疊起來

    14,542 個 request(爬蟲)
      → 幾乎全是 CDN 沒看過的 URL,整批回源
      → 每個又乘以 4–7 個 API call
      ≈ 後端 3 分鐘內多吃了數萬個 call

    4 個 Next.js pod 全數過載,回應時間從正常水位飆到 15–55 秒。

    更麻煩的是,K8s 的 readiness probe(定期戳一下 pod 確認「你還能服務嗎」的健康檢查)也跟著 timeout。ALB 一看:4 個 pod 全部「不健康」,沒有機器能導流——於是 3,464 個 503(找不到健康的 pod)加上 3,798 個 502(送過去但後端逾時)。

    回頭看,每一層單獨存在都沒事:有 CDN、有 4 個 pod、SSR 打幾個 API 也是常見設計。但三層相乘,一隻普通的爬蟲就能把流量撐成後端吃不下的量——不需要任何技巧,甚至不需要針對你。

    這不是一次性事故

    本以為只是倒楣遇到一次掃站,結果接下來兩個月,同類的 search 過載又發生了至少五起。

    會一直重演,是因為根因在架構:只要「使用者輸入會變成可爬的連結」這件事還成立,那份 URL 清單就一直攤在那裡,差別只是這次來的爬蟲有多兇。

    防得了嗎?三個方向的取捨

    1. 想辦法讓這些頁面也有快取

    對 search/tag 頁拉長 CDN 快取時間、把明顯是追蹤參數的路徑直接擋掉不進 SSR。但快取有個天生極限:這些 URL 的問題就是「從來沒人要過」,快取時間再長,也救不了第一次。

    2. 減少 SSR 要打的 API 數

    4–7 個 API 裡,非要在伺服器上做的只有搜尋結果本身。熱門關鍵字、分類這種全站共用的資料,可以在伺服器上用 in-memory cache 存一份,或乾脆搬到 client 端再抓。API 數從 4–7 降到 1–2,第三層的倍數直接砍半以上。代價是要逐個判斷哪些內容 SEO 需要、必須留在 SSR。

    3. Rate limit / WAF

    還記得每個 IP 平均只發 2 個多 request 嗎?這就是為什麼「限制單一 IP 的請求頻率」在這裡完全沒用——每個 IP 看起來都像正常人。有效的得是 WAF(Web Application Firewall)層級的 bot 辨識:來源是不是機房網段、UA、行為模式。代價是有誤殺真實使用者的風險,跟多一層要維護的規則。

    重點整理

    • SSR 網站的脆弱性要用乘法看:沒快取的 URL × SSR 的 API 數,單項都無害,相乘起來一隻爬蟲就能打掛你
    • 把使用者輸入 render 成連結之前,想一下是不是在幫爬蟲印一份打不完的 URL 清單
    • 分散式爬蟲的單一 IP 流量看起來完全正常,per-IP rate limit 防不了
    • 事故自己恢復不代表問題解決了,只要這個相乘的結構還在,就會一直重演

    延伸閱讀

    同一套 K8s 環境踩過的其他坑:

    參考資料