iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構系列 第 14

[ Day 14 ] 初探混沌工程(Chaos Engineering ):本地端用 tc / Clumsy 製造網路延遲與封包遺失

  • 分享至 

  • xImage
  •  

在前兩篇中,我們在本地開發環境裡,利用了 simulateScenario 在代碼內部模擬超時,驗證了自我修復確實可以起死回生,手把手完成了微服務的三層防禦盾牌:

  • 第一層:Semaphore Backpressure(併發限流門閥)
  • 第二層:Double-Check Pattern(自我修復二次確認)
  • 第三層:Idempotency with uploadId(冪等交易令牌)

今天要來比較之前設計好的沒有錯誤處理個API以及有保護的API兩者個差異,引入混沌工程(Chaos Engineering)的思維,建立網路干擾的環境,製造網路延遲(Latency)與封包遺失(Packet Drop),來測試微服務的API穩定性!

混沌工程適用工具

要在本機電腦上模擬連線東京機房跨海延遲與丟包,我們使用作業系統底層的網路封包攔截工具:

  1. Windows:Clumsy(極簡圖形化工具)

    Clumsy 是一款免安裝、具備精美 GUI 的網路干擾外掛,利用 Windows 底層的 WinDivert 驅動,能精準攔截、延遲、丟棄本機指定的 TCP/UDP 封包。

    下載網址:Clumsy 官方 GitHub(下載 clumsy-0.2-win64.zip 解壓即可)

  2. macOS / Linux:pfctl 與 tc

    • macOS:pfctl(封包過濾器)搭配 dnctl(Dummynet)進行延遲與丟包整形
    • Linux:內建 tc(Traffic Control),直接控制核心網路卡排隊規則

安裝與網路干擾設定指引

macOS 使用者(pfctl + dnctl)

  1. 建立核干擾管線(Pipe)
sudo dnctl pipe 1 config delay 3000ms plr 0.30

這條指令建立一條 Pipe 1,所有通過的封包一律延遲 3000ms,且隨機丟包 30%(plr = Packet Loss Rate)。

  1. 將出站 443 Port 流量導入該管線
# 只干擾目的地是 443 Port 的出站封包,不影響 localhost:8080 的 curl 請求
(cat /etc/pf.conf && echo "dummynet out proto tcp to any port 443 pipe 1") | sudo pfctl -f -
  1. 啟動 Packet Filter
sudo pfctl -E
# 終端機應印出:pf enabled

https://ithelp.ithome.com.tw/upload/images/20260909/201838647AFO2gfnVn.png

4. ⚠️ 完畢後的還原指令(極重要!)
測試結束一定要把設定關掉不然整個電腦網速都受影響了

sudo pfctl -d
sudo pfctl -F all
sudo dnctl flush

Windows 使用者(Clumsy)

以系統管理員身分執行 clumsy.exe,設定如下:

Filter: outbound and tcp.DstPort == 443
[v] Lag   - Delay: 3000 ms
[v] Drop  - Chance: 30.0 %

點擊 [Start] 開啟混亂,實驗結束點 [Stop]。

Linux 使用者(tc)

# 注入 3000ms 延遲與 30% 丟包(請根據 ip a 替換網卡名稱)
sudo tc qdisc add dev eth0 root netem delay 3000ms loss 30%

# 還原
sudo tc qdisc del dev eth0 root

本地測試:有保護 vs 沒保護的對比實驗

現在,我們將 Spring Boot 的API執行起來,並且開啟物理網路干擾(3000ms 延遲 + 30% 丟包),準備一個 要上傳的檔案,打開AWS S3 console和你的 Spring Boot 後台 Console 視窗一起來實作吧!

為什麼是設定3000ms 延遲與30% 丟包?

  1. 3000ms 延遲: 現代微服務與 AWS SDK 的連線/讀取超時(Timeout)通常設在 1000ms 至 2000ms。如果只模擬幾百毫秒的延遲,請求仍然會順利通過,系統根本不會拋出例外。我們直接設定 3000ms,就是為了超過超時限制,強迫 Netty 拋出真正的 TimeoutException,藉此考驗程式的自我修復能力。

  2. 封包丟包率: 一般惡劣 Wi-Fi 的丟包率約 1% ~ 5%,此時 TCP 還能勉強重傳。但當丟包率飆到 30% 時,TCP 擁塞控制演算法會陷入重傳死循環,這會強制讓帶有 Payload 的大檔案上傳(PUT)100% 失敗。

補充:混沌工具製造的延遲,必須大於 SDK 的 Timeout 設定,才有意義

我們程式碼預設 AWS SDK timeout 設定是 30 秒,這時 3000ms 的延遲對 SDK 來說根本是撓癢癢——請求慢一點還是能完成,系統永遠測不到失敗。因此,我們需要把 S3Config.java 的 timeout 調整到比混沌工具的延遲更小:

// S3Config.java — 配合混沌工程的 timeout 設定
@Bean
public S3AsyncClient s3AsyncClient() {
    return S3AsyncClient.builder()
            .region(region)
            .credentialsProvider(DefaultCredentialsProvider.create())
            .httpClientBuilder(NettyNioAsyncHttpClient.builder()
                    .maxConcurrency(100)
                    .connectionTimeout(Duration.ofMillis(2000)) // 低於 pfctl 3000ms 延遲
                    .readTimeout(Duration.ofMillis(2000))
                    .writeTimeout(Duration.ofMillis(2000))
            )
            .overrideConfiguration(configuration -> configuration
                    .apiCallAttemptTimeout(Duration.ofMillis(1800)) // 低於 pfctl 3000ms
                    .apiCallTimeout(Duration.ofMillis(2500)))       // 給 HEAD Double-Check 空間
            .build();
}

這樣 SDK 的 2000ms timeout 面對 3000ms 的 pfctl 延遲,請求必然觸發超時,混沌才真的有效。


在實際測試中,我們會發現並非所有情況都能用 pfctl 模擬。因為 pfctl 的 dummynet out 只干擾出站封包,情況 B 需要的是「S3 的回應封包(入站)被丟棄」,這發生在入站方向,pfctl out 根本管不到。這也正是我們在 Day 11 設計 simulateScenario=INBOUND_LOST 後門的工程理由——不是偷懶,而是物理上無法用網路工具重現,程式碼模擬是唯一嚴謹的驗證手段。

測試一:無保護 API v.s 有保護 API

先來測試之前寫的舊版上傳API不用帶uploadId,以及透過 uuidgen (macOS / Linux 的內建指令),輸出一組隨機 UUID給新版的API。

// 舊版無保護上傳api
  curl -i \
    -X POST \
    -F "file=@pom.xml" \
    "http://localhost:8080/api/s3/async/upload"
    
// 新版有保護上傳api
  curl -i \
    -X POST \
    -H "uploadId: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
    -F "file=@pom.xml" \
    "http://localhost:8080/api/s3/async/upload/protected"

測試結果:

=== 舊版 ===
HTTP/1.1 500
{"status":500,"error":"Internal Server Error","path":"/api/s3/async/upload"}

=== 新版 ===
ApiCallTimeoutException: Client execution did not complete before 2500 millis
  Suppressed: Request attempt 1 failure: HTTP request did not complete before 1800 millis
  Suppressed: Request attempt 2 failure: connection timed out after 2000 ms

https://ithelp.ithome.com.tw/upload/images/20260909/20183864ekEIGhKoPX.png

由此可以看出,沒有保護的狀態下java服務直接報錯,我們在debug時甚至毫無頭緒找不到問題點,也有可能其實成功上傳s3變成無人知曉的孤兒檔案,反觀新版的api就明確透過Double-Check列出,雖然是回傳500,但檔案沒有上傳成功,所以使用者端和s3端也是一致的,換言之雖然兩組都回傳 500,但有一個關鍵差異:

無保護 API 有保護 API
失敗原因 SDK timeout,直接爆炸 PUT 超時 → HEAD 二次確認 → 確認去程遺失
錯誤訊息 框架拋出的通用 500 Upload failed; retry with the same uploadId
S3 孤兒檔案 可能存在,永遠無從得知 確定不存在(HEAD 已驗證)
客戶端下一步 不知道要怎麼辦 明確告知:帶同一個 uploadId 重試

同樣都失敗,但有保護的系統知道自己為什麼失敗,並且知道下一步該怎麼做。


測試二:封包回程遺失驗證(INBOUND_LOST 後門)

從測試一可以發現:pfctldummynet out 其實只作用在出站方向,它能把你送出去的 PUT 封包延遲或丟棄,卻對 S3 回傳給你的回應封包無能為力。

回顧 Day 10 提出的三種超時情況:

  • 情況 A:去程遺失 → PUT 封包沒到 S3 → pfctl ✅ 可模擬(TCP 握手就超時)
  • 情況 B:回程遺失 → S3 收到了,200 OK 回不來 → pfctl ❌ 入站封包管不到
  • 情況 C:處理中超時 → S3 還在寫,你先 timeout → pfctl ✅ 可模擬

發現回程遺失這種會造成孤兒檔案的情況我們無法透過pfctl測試,沒有保護的系統會直接拋出 500,從此對那份已存在 S3 的檔案一無所知;有保護的系統則透過 headObject 探針找回它。

要驗證這條路徑,我們需要切換工具。情況 B 在本機物理環境下無法用任何網路干擾工具重現,這正是 Day 11 -實作 Double-Check Pattern設計 simulateScenario=INBOUND_LOST 後門的工程理由,我們就再來測試一次

  1. 先關閉 pfctl 干擾,回到乾淨的網路環境
  2. 用 INBOUND_LOST 模擬S3 收到了,但回程封包遺失
curl -i \
  -X POST \
  -H "uploadId: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
  -F "file=@pom.xml" \
  "http://localhost:8080/api/s3/async/upload/protected?simulateScenario=INBOUND_LOST"
  1. 會看到系統回傳200,Upload success (confirmed by S3 HEAD): 0bc87dcb-7354-4acf-8554-33775c00be54%

  2. 後端 Log 也可以清楚看到 S3 確實已把檔案收好,只是回程封包遺失了。透過headObject 來確認檔案存在

https://ithelp.ithome.com.tw/upload/images/20260909/20183864AoqdyW5uyi.png

薛丁格超時

整合前面驗證與程式設計的結果,可以總結出面對超時可能發生情況如下:

網路物理真相 pfctl 能模擬? Double-Check (HEAD) 行為 自癒判定 客戶端策略 S3 最終狀態
A:去程遺失(S3 無檔案) ✅ 出站封包被延遲掐斷 HEAD 回傳 404 修復失敗(500) 保留同一 uploadId 重試 1 份(重試成功後寫入)
B:回程遺失(S3 有檔案) ❌ 入站封包管不到 HEAD 回傳 200 修復成功(200) 無需重試,無感過關 乾淨 1 份(救回)
C:處理中超時(S3 寫入中) ✅ 延遲讓 SDK 先 timeout HEAD 查詢時 S3 恰好寫完 修復成功(200) 無需重試 乾淨 1 份(救回)

目前對面三種情況,我們的系統都具備 100% 的狀態掌控力,沒有孤兒檔案,沒有重複垃圾!

總結

今天我們走出了 localhost 的溫室,利用簡單、乾淨的 cURL 對照,目睹了 Double-Check 在丟包 30% 時是如何在背景起死回生挽救任務的,也驗證了在徹底失敗時,冪等重試是怎麼完美防呆、不產生重複垃圾檔案。

也了解到物理網路工具只能干擾出站封包,無法模擬入站回應遺失。這正是 simulateScenario=INBOUND_LOST 後門存在的工程理由。混沌工程的真正精神不只是搞破壞,而是清楚知道每一種破壞的邊界在哪裡,並為邊界以外的情況準備對應的驗證手段。

然而,手動 cURL 測試畢竟只是小打小鬧。 當我們的微服務同時面對 100 個、甚至 500 個高併發使用者,和網路環境差的狀況下同時上傳或下載時,我們的系統吞吐量會被摧毀到什麼地步?我們的 Semaphore 真的能頂住高頻請求,保護底層 JVM 執行緒不爆炸嗎?

假設系統真的出問題了,造成OOM或是CPU滿載當機了,這時還正在上傳或是儲存的服務,還有沒有其他的補救措施?所以明天我們就要來介紹Resilience4j這個套件,防止服務壞掉時,連帶拖垮整個系統,以及要如何設定 fallback 預備方案(例如回傳快取資料或錯誤訊息),確保系統不會直接崩潰。


⚠️ 再次提醒

實驗結束後,記得執行還原指令:

  • Macsudo pfctl -d && sudo pfctl -F all && sudo dnctl flush
  • Linuxsudo tc qdisc del dev eth0 root
  • Windows(Clumsy):點擊 [Stop]

否則你接下來的追劇、打電動會超級卡!


上一篇
[ Day 13 ] AWS S3 清理 : 利用生命週期規則(Lifecycle Rules)與版本控制自動清理孤兒與歷史檔案
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言