LOAD-TESTING TARGET RANGE / FASTAPI × LOCUST × POSTGRESQL

打爆它,
然後解釋為什麼。

這個 repo 是大流量壓測訓練的靶機(Target):一個刻意做成「能被打出各種瓶頸」的 Mini Social Platform。它不是為了上線,是為了讓你在受控環境裡,親手重現 event loop 卡死、connection pool 餓死、攻擊機自己先陣亡——然後學會用數據說話。

看到 metricsOBSERVE▶ 提出假設HYPOTHESIZE▶ 設計實驗EXPERIMENT▶ 驗證VERIFY
0
教學 demo(循序漸進)
0
容器:db / 靶機 / 攻擊機
0
vCPU,cpuset 攻防分核
0 RPS
FastHttpUser 實測峰值
01 //

任務簡報

教科書告訴你 async 比較快、pool 要設大一點。這裡讓你量出來——而且常常量出和直覺相反的結果。

為什麼需要一台靶機?

壓測最常見的失敗不是「不會用工具」,而是分不清瓶頸在誰身上: 你以為在測伺服器,其實在測攻擊機的 CPU;你以為 async 是魔法, 其實 pool 太小時 sync/async 根本沒差。

靶機把每個變因做成可旋轉的旋鈕:同一個端點有 sync / async 兩個版本(同 driver、同 pool,唯一變因是併發模型)、 DB_POOL_SIZE 控制連線池、 ?delay_ms= 模擬慢查詢。 一次只轉一顆,差異才能歸因。

訓練路線

  • 階段一單機壓測:Async 原理 + Locust 六連 demo
  • 階段二容器化 + cpuset 攻防隔離、本地 K8s 部署(單 Pod baseline)
  • 階段三叢集:分散式 Locust + HPA 自動擴展 ◀ 你在這
  • 階段四觀測調校:Observability + Chaos
02 //

攻防隔離:三容器 × cpuset

攻擊機(Locust)打到火力全開時 CPU 會吃滿 100%。如果它和靶機擠在同幾顆核心上, 你量到的延遲一半是攻擊機自己造成的。所以整套架構容器化,用 cpuset 把三方鎖在不同核心,互不搶資源。

ATTACKER — 攻擊機

Locust 2.44.1

cpuset 4-7(給最多核)
mem 2g · Web UI :8089
locustfiles/ 唯讀掛載
TARGET — 靶機

FastAPI + uvicorn

cpuset 2,3 · mem 256m
1 worker · :8000
sync / async 對照端點
DATABASE

PostgreSQL 17

cpuset 0,1 · mem 1g
max_connections=200
host :5433 · down 即重置 seed

CPU 核心地圖(Docker VM 共 10 vCPU)

0DB
1DB
2APP
3APP
4LOCUST
5LOCUST
6LOCUST
7LOCUST
8FREE
9FREE
db:防守方的資料庫 app:靶機——給 2 核是刻意的(sync threadpool 可跨核,async event loop 只吃得到 1 核,不對稱本身就是考點) locust:攻擊機——給最多核,它不能先成為瓶頸
macOS 誠實條款:Docker Desktop 的容器全跑在同一台 Linux VM 裡,cpuset 鎖的是 VM 的 vCPU—— 能保證「靶機與攻擊機不共用核心」,但整台 VM 仍由 macOS hypervisor 排程。要絕對乾淨的數據得攻防分機,那是階段三的事。
03 //

Event Loop 體感模擬器

demo2 的核心戲劇點,先在這裡體感一次:靶機只有 1 個 worker、1 條 event loop。 按下紅色按鈕注入一個 blocking sleep,看整條服務(連 /healthz 都)瞬間凍結; 換成綠色的 async sleep,那個請求自己去旁邊等,其他人照常通行。

uvicorn app.main:app — workers=1 cpuset 2,3 / event loop ×1
/healthz — ok 完成 0 排隊 0

◤ 對應實驗:A /experiments/sync-sleep(卡死全場)、B /experiments/async-sleep(讓路)、 C /experiments/cpu(CPU bound——async 也救不了,模擬器沒演的那課,去 demo2 親手打)。

04 //

六連 Demo 課表

順序是設計過的:先學工具(1)→ 先懂 event loop(2)→ 先排除攻擊機瓶頸(3)→ 才做靶機實驗(4、5)→ 最後整合大流量(6)。 跳過 3 直接做 4,你測到的會是 Locust 的極限,不是靶機的。
每張卡片展開後分三段:實驗設計(釘死哪些變因、對照組 ↔ 實驗組 差在哪)→ 步驟(每一步都附「為什麼現在做這個操作」)→ 結果(實測數字)。照著做就能歸因。

01 locustfiles/demo1_basics.py

Locust 入門 — User / @task / wait_time / Web UI

第一份 p50/p95 報表;RPS ≠ users 數▶

三個核心概念:User(一個虛擬使用者 = 一個 greenlet,跑自己的任務迴圈)、@task(weight)(任務與權重,read_posts(3):read_users(1) = 75%:25%)、wait_time(思考時間,真人不會連發請求)。

實驗設計 — 這支還沒有對照組,先學會「讀報表」
固定不動端點 /async/posts:/async/users = 3:1、wait_time between(1,3)、靶機單 worker
這次只動它同時在線 users 數:10 → 50 → 100,每級各跑一輪
  1. 全套起來:docker compose up -d --build --wait
    為什麼:demo1 就是 compose 的預設腳本,三容器(db / 靶機 / Locust)一起上、cpuset 與記憶體限制全套生效,不必另外指定 LOCUSTFILE。
  2. 開 Web UI http://localhost:8089/?tab=charts,先用 10 users 開打
    為什麼從 10 開始:低負載先確認管線是通的、報表會動,當作後面兩級的基準;一上來就 100 users,「慢」是真瓶頸還是還沒暖機會分不清。
  3. 依序 50、100 users 各跑一輪,每輪記錄 p50 / p95 / RPS / error rate
    為什麼一次只加一級:users 是這支唯一動的旋鈕。三級分開跑,才能畫出「users → RPS」的關係,看出它不是線性。
# 全套起來(db + 靶機 + Locust),demo1 是預設腳本
docker compose up -d --build --wait
open http://localhost:8089/?tab=charts   # UI 設 10 → 50 → 100 users 各跑一輪,直接看 Charts
結果 / 關鍵洞察

100 users 配 wait_time(1,3),RPS 不會衝到 100,而是卡在 ≈ 50:每個 user 做完一個請求要先「思考」平均 2 秒,100 ÷ 2 ≈ 50 RPS。RPS 是吞吐量、users 是併發數,兩者不相等——這是讀懂後面所有報表的第一課。

要能回答:100 users + wait_time(1,3) 的理論 RPS 上限是多少?為什麼 users 加倍,RPS 沒跟著加倍?

02 locustfiles/demo2_event_loop.py

Event Loop 卡死實況 — 實驗 A / B / C

一個 time.sleep 凍結全服務,healthz 也死▶

上面模擬器的真槍實彈版。用同樣 20 users、同樣 constant(0)(連發)打三個只差在「怎麼等那 1 秒」的端點,把 blocking / async / CPU-bound 一次量出來。靶機只有 1 worker、1 條 event loop,這是所有戲劇的前提。

實驗設計 — 三組對照,變因是「端點怎麼等」
固定不動20 users、wait_time constant(0)、靶機單 event loop
這次只動它打哪個端點 = 等待方式:async sleep / blocking sleep / CPU 計算
◆ 對照組 B — 正常基準 /async-sleep

await asyncio.sleep:等待時 event loop 切去服務別人。RPS ≈ 20、p95 ≈ 1s。

◆ 實驗組 A — blocking /sync-sleep

time.sleep:卡死唯一的 event loop,全服務凍結。RPS ≈ 1、p95 數十秒。

◆ 實驗組 C — CPU bound /cpu?n=30

純計算:宣告 async def 也無法切換。RPS ≈ 10、全部排隊。

  1. 先跑對照組 B:在 class-picker 勾 AsyncSleepUser,20 users / spawn 20,按 Start
    為什麼先跑 B:它是「正確行為」的基準線——RPS≈20、p95≈1s。手上先有「正常長什麼樣」,A 和 C 的異常才有對照。
  2. Stop 後改勾實驗組 A:SyncSleepUser(users / spawn 不變)
    為什麼只換 class:users、時長、spawn rate 全固定,唯一變因是端點等待方式,p95 從 1s 暴衝到數十秒就只能歸因到 blocking。
  3. 跑 A 的同時,另開 terminal:curl -m 5 http://localhost:8000/healthz
    為什麼這一刀最關鍵:/healthz 根本不碰 DB、理應毫秒回應,此刻卻 timeout——證明 blocking 卡的不是「某個請求」,是整條服務。這正是 K8s liveness probe 會誤殺 Pod 的根因。
  4. 最後改勾實驗組 C:CpuUser
    為什麼留到最後:打破「async = 快」的迷思——C 是 async def 卻照樣串行,因為 CPU 計算期間沒有讓出點。async 解決 I/O 等待,救不了 CPU bound。
# 起 Web UI,在 class-picker 選 class 開打(教學預設用 UI 看 Charts)
docker compose up -d --build --wait
LOCUSTFILE=demo2_event_loop.py docker compose up -d locust
open http://localhost:8089/?tab=charts   # 先勾 AsyncSleepUser(B),20 users / spawn 20,Start
# 看完一輪按 Stop → 改勾 SyncSleepUser(A)、CpuUser(C) 各跑一輪對照
結果 / 三組對照(20 users)
組別端點RPSp95
對照 Basync-sleep≈ 20≈ 1s
實驗 Async-sleep≈ 1數十秒
實驗 Ccpu≈ 10全排隊

同樣 20 users,只因「怎麼等」不同,吞吐差 20 倍。blocking 與 CPU-bound 都會獨佔單一 event loop;async 只在 I/O 等待時有效。

要能回答:為什麼一個 time.sleep(1) 連 /healthz 都殺死?實驗 C 是 async def 為什麼還是卡?哪些常見函式庫(requests、psycopg2…)會像 A 一樣偷偷 block?

03 locustfiles/demo3_attacker_bottleneck.py

攻擊機自己先陣亡 — HttpUser vs FastHttpUser

5,921 → 24,762 RPS:換武器差 4.2 倍▶

做任何靶機實驗前的前置檢查:先證明攻擊機自己不是瓶頸,否則 demo4/5/6 全是在測 Locust,不是測靶機。同樣 300 users、同樣打 /healthz(靶機最快、幾乎零成本的端點),只換攻擊機用的 HTTP client。

實驗設計 — 變因是「攻擊機的武器」,靶機完全不動
固定不動300 users、端點 /healthz、constant(0)、靶機設定一律不碰
這次只動它攻擊機 HTTP client(武器):requests ↔ geventhttpclient ↔ 多 process
◆ 對照組 — 舊武器 SlowAttacker(HttpUser)

基於 requests,純 Python 開銷。單一 locust process 的 CPU 會先鎖死 100%(GIL 限制)。

◆ 實驗組 — 新武器 FastAttacker(FastHttpUser)

基於 geventhttpclient(C 實作)。同樣 300 users 吞吐翻數倍;再加 --processes 4 吃滿 4 核又翻一次。

  1. 另開 terminal 先掛上 docker stats
    為什麼要先開:本 demo 重點不在 RPS 數字,而在攻擊機的 CPU%。沒同時盯攻擊方,看到「RPS 上不去」會誤判成靶機弱。
  2. 第一輪對照組:class-picker 勾 SlowAttacker,300 users / spawn 100,Start
    觀察什麼:locust 容器 CPU 鎖死 ~100%(單核),RPS 卡在 5,921。此刻去看靶機 uvicorn——CPU 還很閒。瓶頸在攻擊機,數據是假的。
  3. Stop 後改勾實驗組:FastAttacker,users / spawn 不變
    為什麼參數要完全一樣:唯一變因是武器。RPS 從 5,921 跳到 24,762(4.2×),靶機沒任何改動——直接證明剛才壓的是 Locust 的天花板。
  4. 第三輪 --processes 4:要停掉預設 locust、改用 --service-ports 起(見指令;前菜:階段三分散式)
    為什麼還要加 process:單 process 受 GIL 限制只吃一核;locust 容器有 4 核(cpuset 4-7),開 4 process 才吃得滿。RPS 再翻——接著瓶頸換人(靶機?網路?)。
docker compose up -d --build --wait
LOCUSTFILE=demo3_attacker_bottleneck.py docker compose up -d locust
open http://localhost:8089/?tab=charts   # 勾 SlowAttacker 跑一輪 → Stop → 改勾 FastAttacker,皆 300 users / spawn 100
docker stats   # 全程盯 locust 容器 CPU% —— SlowAttacker 會先鎖死 100%

# 第三輪要 --processes 4:停掉預設 locust 讓出 8089,改用一次性 run(不加 --headless = Web UI)
docker compose stop locust
docker compose run --rm --service-ports locust -f /mnt/locust/demo3_attacker_bottleneck.py \
  --host http://app:8000 --processes 4 --class-picker
open http://localhost:8089/?tab=charts   # 勾 FastAttacker,Start
結果 / 已驗證(300 users・/healthz)
武器RPS瓶頸落點
SlowAttacker5,921攻擊機 CPU 100%(單核)
FastAttacker24,762攻擊機,但已大幅鬆綁

同參數差 4.2×——差的全是攻擊機自己。判斷瓶頸在攻擊機的訊號:靶機 CPU 還很閒,RPS 卻上不去。

要能回答:怎麼從 metrics 判斷「瓶頸在攻擊機」而非靶機?Python GIL 在這裡扮演什麼角色?單機 --processes 和 K8s 分散式 Locust 差在哪?

04 locustfiles/demo4_sync_vs_async_db.py

sync vs async 打真 PG — 核心對照組

同 100 併發:async 512 RPS vs sync 335 RPS▶

本訓練的核心對照實驗。前置都做完了(會用工具、懂 event loop、攻擊機不是瓶頸),現在打真 PostgreSQL。?delay_ms=100 讓每個查詢佔住 connection 100ms,模擬慢查詢。

實驗設計 — 變因只有併發模型,其餘全部釘死
固定不動同 driver psycopg3、同 pool=100、100 users、delay_ms=100
這次只動它create_engine(sync)↔ create_async_engine(async)
◆ 對照組 — 傳統 sync /sync/posts

FastAPI 把它丟進 threadpool(預設 40 條)跑,每條 thread 佔住一個連線等 DB。瓶頸 = 40 條 thread。

◆ 實驗組 — async /async/posts

等 DB 時 event loop 切去服務別人,不佔 thread。瓶頸往後推到 pool(100)或 CPU。

  1. export DB_POOL_SIZE=100,再 docker compose up -d --build --wait
    為什麼先把 pool 開大:要讓 sync 的瓶頸確定落在 threadpool(40)而不是 pool。pool 太小兩組都卡 pool、看不出差異(demo5 會證明)。為什麼一定要 export:不 export,下一個 compose 指令會用預設 pool=5 把 app 默默重建回去——真踩過,四輪數據一模一樣。
  2. 先跑對照組:class-picker 勾 SyncPostsUser,100 users / spawn 50,Start
    為什麼當對照:sync 是「沒做任何特別處理」的基準。先量它,才知道 async 到底快多少。
  3. Stop 後改勾實驗組:AsyncPostsUser,users / spawn 不變
    為什麼只換 class:唯一變因是併發模型。100 併發 > threadpool 40 時,sync 被迫排隊、async 不必,差異此刻才現形。
  4. 跑完驗證旋鈕:docker inspect k8s-app-1 … | grep DB_POOL
    為什麼養成這習慣:確認 app 真的吃到 pool=100 而非被默默重建回 5。「改了變因卻沒變化,先驗證旋鈕接上了」是 LESSON 04。
# pool 開大,逼 sync 瓶頸落在 threadpool(40)而非 pool
# 一定要 export,否則下個指令以預設 pool=5 把 app 重建回去(真實踩過)
export DB_POOL_SIZE=100
docker compose up -d --build --wait
# 起 Web UI,對照組 → 實驗組只在 class-picker 換 class,users / spawn 不變
LOCUSTFILE=demo4_sync_vs_async_db.py docker compose up -d locust
open http://localhost:8089/?tab=charts   # 勾 SyncPostsUser → Stop → 改勾 AsyncPostsUser,皆 100 users / spawn 50
# 驗證旋鈕真的生效
docker inspect k8s-app-1 --format '{{.Config.Env}}' | tr ' ' '\n' | grep DB_POOL
結果 / 已驗證(pool=100・100 users)
組別端點RPS瓶頸
對照 sync/sync/posts335threadpool 40 條
實驗 async/async/posts512pool / event loop CPU

pool 夠大時 async 領先(512 vs 335 RPS)。但領先幅度被 ORM 序列化的 CPU 成本吃掉一截(理論 3× → 實測 ~1.5×,LESSON 03)——把 -u 拉到 200/400,看 sync 的 p95 線性惡化、async 撐到 pool 滿才惡化。

要能回答:sync 版的 40 哪來的(AnyIO threadpool 預設)?把它調大是對的解法嗎?瓶頸是 threadpool 還是 pool 連線數,怎麼設計實驗分開驗證?

05 locustfiles/demo5_pool_starvation.py

Pool Starvation — DB_POOL_SIZE 10 → 100 掃描

167 → 642 RPS:pool 翻倍 p95 砍半,直到 CPU 102%▶

固定攻擊參數,只掃一個旋鈕 DB_POOL_SIZE,畫出「p95 對 pool 大小」的曲線。這支證明:connection pool 才是 p95 的主宰,async 不是魔法。?delay_ms=50 讓查詢佔住連線、放大 pool 的影響。

實驗設計 — 四輪掃描,變因只有 pool 大小
固定不動200 users、delay_ms=50、打 /async/posts、每輪重置 DB
這次只動它DB_POOL_SIZE:10 → 20 → 50 → 100,四輪
◆ 對照組 — 基準 DB_POOL_SIZE = 10

pool 嚴重不足,請求在 pool 前大排長龍。此時 sync ≈ async(1.28s ≈ 1.28s)——瓶頸在 pool,誰都救不了。

◆ 實驗組 — 逐級放大 20 / 50 / 100

每翻一倍看 p95 怎麼走。一路砍半,直到靶機 CPU 撞 102%、瓶頸從 pool 轉移到 event loop 為止。

  1. 每輪開頭先重置:docker compose down
    為什麼每輪都重置(鐵則 03):不掛 volume,down 即回到同一份 seed(50 users / 200 posts)。每輪從相同起點出發,四條 p95 數據才能放同一張圖比較。
  2. 設這一輪 pool:export DB_POOL_SIZE=10,再 up -d --wait
    為什麼從 10 當對照:先量「pool 嚴重不足」長什麼樣,當基準線。為什麼 export:同 demo4——不 export 會被下個 compose run 默默重設回 5,四輪全是假數據(LESSON 04 的由來)。
  3. 打固定攻擊:UI 設 200 users / spawn 100,跑 ~60s 記錄 RPS / p95
    為什麼攻擊參數一字不動:四輪唯一變因是 pool。攻擊一變,p95 的變化就無法單純歸因到 pool。
  4. 換 export DB_POOL_SIZE=20 / 50 / 100,重複步驟 1–3
    為什麼掃四級而非只比兩點:要看出「報酬遞減」的轉折——pool 50→100 幾乎不再有效益,代表瓶頸已換人。一條曲線比兩個點更有說服力。
# 每輪:重置 DB(鐵則 3)→ 換 pool 重啟 → 同一個攻擊 → 記錄
docker compose down
export DB_POOL_SIZE=10    # 下一輪換 20 / 50 / 100,攻擊參數不動
docker compose up -d --build --wait
LOCUSTFILE=demo5_pool_starvation.py docker compose up -d locust
open http://localhost:8089/?tab=charts   # UI 設 200 users / spawn 100,跑 ~60s 看 Charts 的 p95
docker inspect k8s-app-1 --format '{{.Config.Env}}' | tr ' ' '\n' | grep DB_POOL  # 驗證旋鈕
結果 / 已驗證(200 users・delay_ms=50)
DB_POOL_SIZERPSp95
10(對照)1671400ms
20302740ms
50620390ms
100642420ms

pool 翻倍、p95 砍半——直到 50→100 只 +3.6% RPS、p95 反而微升:靶機 CPU 撞 102%,瓶頸從 pool 轉移到 event loop。pool=10 時 sync/async 幾乎沒差:瓶頸在誰身上,誰就決定 p95。

要能回答:pool 開越大越好嗎?PG 每條 connection 都是一個 process,max_connections 撞到會怎樣?pool_timeout 30s 到了會發生什麼——demo6 看到的某些 error 就是它。

06 locustfiles/demo6_step_load_social.py

階梯大流量 — 10 → 100 → 1000 → 5000 混合流量

1000 users 撐住、5000 崩:p95 = 30s pool timeout▶

整合戰:用擬真的讀寫混合流量(滑 feed:看用戶:發訊息 = 10:3:1)做階梯式加壓,找出靶機的崩潰點。StepLoadShape 自動控制階梯,不必手動調 users。

實驗設計 — 階梯加壓找崩潰點(不是兩組對照,是逐級逼近)
固定不動pool=100、讀寫比 10:3:1、wait_time between(1,3)
這次只動它同時在線 users,每 2 分鐘一階:10 → 100 → 1000 → 5000
◆ 撐得住 — 健康區 ≤ 1000 users

1000 users:CPU 56–69%、零 error。系統還有餘裕。

◆ 崩潰 — 過載區 5000 users

CPU 100%、MEM 貼死 256MiB limit、HTTP 500 ×2,802(3.23%),p95 = 30 秒整 = QueuePool timeout。

  1. export DB_POOL_SIZE=100,再 up -d --build --wait
    為什麼用最大 pool:要找的是「系統極限」,不是 pool 瓶頸——pool 先開到最大,崩潰點才反映真正的天花板(CPU / MEM / 連線數)。export 理由同前。
  2. 三容器一起掛 docker stats
    為什麼三方都要盯:崩潰時要立刻分辨誰先死——靶機 CPU?靶機 MEM?攻擊機?只看 RPS 看不出崩在哪一層。
  3. 用多 process 起 Web UI:--processes 4 --service-ports(不加 --headless;階梯由腳本控制,免設 users)
    為什麼一定要多 process:5000 users 單 process 會先讓攻擊機變瓶頸(demo3 的教訓);locust 容器有 4 核,開 4 process 才打得出 5000。坑:--processes 下別加 --csv-full-history,worker 繼承 flag 卻拿不到 prefix、啟動即死。
  4. 逐階記錄,盯住「哪一階開始噴 error、第一個 error 是什麼」
    為什麼盯第一個 error 類型:它指向崩在哪一層——pool timeout 30s(pool/DB)?connection refused?OOM?這條崩潰曲線就是階段三 HPA 要對抗的敵人。
# 兩側容器 fd 上限 nofile=10240 已在 compose 設好,免調 ulimit
export DB_POOL_SIZE=100
docker compose up -d --build --wait
# 5000 users 要多 process;先停掉預設 locust 讓出 8089,再用 --service-ports 起 Web UI(不加 --headless)
docker compose stop locust
docker compose run --rm --service-ports locust -f /mnt/locust/demo6_step_load_social.py \
  --host http://app:8000 --processes 4
open http://localhost:8089/?tab=charts   # 按 Start,StepLoadShape 自動跑 10→100→1000→5000
docker stats   # 三容器一起盯:誰先死?
結果 / 已驗證(pool=100)
階靶機 CPUerrorp95
1000 users56–69%0正常
5000 users100%HTTP 500 ×2,802(3.23%)30s

崩潰點現形:5000 users 下 MEM 貼死 256MiB、p95 = 30 秒整 = QueuePool limit reached, timeout 30.00。單 Pod baseline:1000 撐住、5000 必崩——這就是階段三「3 Pods 是否撐到 3N」的對照基準。

要能回答:崩潰時第一個 error 是哪種(pool timeout?connection refused?OOM?),它指向哪一層?wait_time(1,3) 下 5000 users 的理論 RPS 是多少?(≈ 2500,不是 5000)

05 //

Runbook — 如何運行

# 一鍵全上:db + 靶機(:8000) + Locust Web UI(:8089),cpuset / 記憶體限制全套生效
docker compose up -d --build --wait
open http://localhost:8089/?tab=charts   # 預設開 Web UI:class-picker 選 class、設 users 後 Start,直接看 Charts

# 換 demo 腳本(蓋掉預設的 demo1,重開 Web UI)
LOCUSTFILE=demo4_sync_vs_async_db.py docker compose up -d locust

# 實驗旋鈕:pool 大小
DB_POOL_SIZE=100 docker compose up -d app

# 每輪重置 DB(不掛 volume,down 即回到同一份 seed:50 users / 200 posts)
docker compose down && docker compose up -d --wait

# 只有自動化/CI 才用 headless(教學一律用上面的 Web UI 看 Charts)
docker compose run --rm locust \
  -f /mnt/locust/demo1_basics.py --host http://app:8000 \
  --headless -u 50 -r 10 -t 60s

# 鐵則 1:同時盯攻防兩側的 CPU / MEM
docker stats
# 攻防同擠一台機器會互搶 CPU(demo3 的教訓)—— 開發、改 code 時用
docker compose up -d --wait db        # 只起 PostgreSQL(host port 5433)
uv sync
uv run main.py                        # hot-reload,http://127.0.0.1:8000

# 本機壓測:關 reload、固定 1 worker、關 access log
ulimit -n 10240
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log
uv run locust -f locustfiles/demo1_basics.py --class-picker   # Web UI :8089
open http://localhost:8089/?tab=charts
鐵則 01同時監控攻擊方與防守方(CPU / MEM)——分不清瓶頸在誰身上的數據沒有意義。容器模式一行搞定:docker stats
鐵則 02一次只改一個變因:users 數、pool 大小、sync/async——不要同時動,動了就無法歸因。
鐵則 03每輪重置 DB:docker compose down && up,每輪都從同一份 seed 開始,數據才可比。
鐵則 04用 Problem-Driven 格式記錄每一輪實驗——寫不出 Lesson 的實驗等於沒做:
Problem:    (觀察到什麼異常/想驗證什麼)
Hypothesis: (你認為的原因)
Experiment: (改了什麼、攻擊參數)
Result:     (p50 / p95 / RPS / error rate / 兩側 CPU·MEM)
Lesson:     (一句話,能教給別人的那種)
06 //

上 K8s:同一台靶機,換個戰場

階段二(下):整套攻防搬上本地 K8s(OrbStack 內建叢集,namespace loadtest)。 重點不是會打 kubectl,而是同一份壓測在兩種調度機制下會量出什麼差異—— cpuset 換成 CFS quota、docker stats 換成 kubectl top,然後跑同一支 demo4 對答案。 manifest 全在 k8s/,每個欄位(連預設值)都有教學註解。 下方「部署實戰」把上線到清理拆成八步,每步都說明為什麼現在做這個操作。

ATTACKER — 攻擊機

Locust @ host

跑在 host 上(uv run locust)
不進 K8s · 沒有 cpuset
直打 LoadBalancer EXTERNAL-IP
TARGET — 靶機 Pod

Deployment ×1

cpu limit 2(CFS quota)· mem 256Mi
strategy Recreate · liveness + readiness
Service type LoadBalancer
DATABASE — db Pod

PostgreSQL 17

Deployment + emptyDir(刻意揮發)
max_connections=200 · init.sql 進 ConfigMap
Service type ClusterIP(不對外)

compose ↔ K8s 對照表:同一台靶機,兩種調度語言

變因COMPOSE(階段二上)K8S(階段二下)
靶機 CPU 隔離cpuset: "2,3" — 鎖死核心編號,真隔離cpu limit: "2" — CFS quota:每 100ms 最多 200ms CPU 時間,可跨核但不鎖核,App 和 DB 可能擠同一顆
攻擊機位置locust 容器,cpuset 4-7host 上 uv run locust,與 Docker VM 共擠——量出來的差異來源之一
監控docker statskubectl top pod -n loadtest(單位是 millicpu:648m = 0.648 vCPU)
實驗旋鈕(pool)export DB_POOL_SIZE=100 再 uppatch ConfigMap + rollout restart——改了不會自動生效(踩坑 03)
重置 DB(鐵則 03)compose down && up刪 db Pod 即可:emptyDir 揮發 + init.sql 重跑,回到同一份 seed
image 來源本地 buildimagePullPolicy: Never — OrbStack K8s 與 Docker 共用 image store,免推 registry(Minikube/Kind 都要多一步 load)
服務發現compose DNS(container name)CoreDNS + Service:db:5432 同名直接沿用,DATABASE_URL 一字不改無縫切換

兩支探針,兩個問題

livenessProbe → /healthz:「死了嗎?」失敗就重啟 container。 刻意不碰 DB——如果 liveness 打 DB,pool 耗盡時 K8s 會誤判 App 死了而瘋狂重啟, 那不是 App 的問題,是探針設計錯誤。

readinessProbe → /readyz:「準備好了嗎?」真打一次 DB, 失敗只是暫時從 Service 摘掉、不重啟。DB 還沒好,Pod 就不收流量。

demo2 的 K8s 進階版:sync-sleep 卡死 event loop → 連 /healthz 都回不了 → liveness 連續 fail → 重啟 → 新 Pod 起來又被打 → 又重啟——這就是 K8s 下的雪崩迴圈。 模擬器(03)演的那一幕,在 K8s 裡會被 kubelet 放大。

單 Pod baseline:押注 → 跑 → 對答案

demo4 async・100u・delay 100ms・pool 100RPSp50
compose(cpuset 隔離)512200ms
K8s 單 Pod(CFS quota)440180ms

RPS 低 ~14%、p50 反而略好——差異來自攻擊機在 host 與 VM 共擠 + kube-proxy/LoadBalancer 多一跳 + CFS 無核心隔離。靶機 CPU 648m,離 limit 2000m 還遠。 量級一致 → 單 Pod baseline 成立,「1000 撐住、5000 必崩」就是階段三 HPA 的對照組。

踩坑 01 · 真實案發K8s 的 command: 蓋掉的是 ENTRYPOINT,args: 才對應 Docker 的 CMD。把 compose 的 command 原樣搬過來 → docker-entrypoint.sh 被跳過 → init.sql 沒跑、PG 以 root 啟動被拒 → db 和 app 一起 CrashLoopBackOff。
踩坑 02 · 真實案發RollingUpdate 讓 DB 連線足跡翻倍。新舊 Pod 並存的瞬間,pool 預熱撞上 too many clients → 新 Pod CrashLoop、舊 Pod 不退 → rollout 永久死鎖。本 demo 用 Recreate 治標,production 正解是 pgbouncer(→ LESSON 05)。
踩坑 03改 ConfigMap 不會自動生效。configMapKeyRef 的 env 只在容器啟動時讀一次,必須 kubectl rollout restart 才收斂——驗證方式:kubectl exec ... env | grep DB_POOL_SIZE(LESSON 04 的 K8s 版:先驗證旋鈕接上了)。
踩坑 04kubectl patch 是暫時的。patch 是 imperative 操作,下次 kubectl apply 會把 ConfigMap 蓋回 YAML 宣告的值——declarative 才是真相,實驗完想保留設定就改 manifest 再 apply。
部署實戰 — 八步上線到清理,每步都附「為什麼現在做」
  1. ① 前置 · build image:docker compose build app,確認 kubectl config current-context 是 orbstack
    為什麼現在做:OrbStack 的 K8s 跟 Docker 共用同一個 image store,build 完那顆 image kubelet 直接看得到、免推 registry——這正是 app.yaml 用 imagePullPolicy: Never 的前提。沒先 build,Pod 會卡在 ErrImageNeverPull。
  2. ② 部署 · 按依賴順序 apply:先 namespace.yaml,再 kubectl apply -f k8s/
    為什麼有順序:namespace 是其他資源的容器,必須先存在;其餘同目錄一起 apply,K8s 會自理建立順序。db.yaml 一檔含四物件(ConfigMap 裝 init.sql、Secret 裝帳密、Deployment、ClusterIP Service)。
  3. ③ 驗證 Pod · kubectl get pods -n loadtest -w 等到 1/1 Running
    為什麼盯 Ready 而不只看 Running:db 的 readiness 用 pg_isready、app 的 readiness 打 /readyz 真連一次 DB——app 要等 DB 通了才會 Ready,這是刻意的啟動順序保護。卡住就 kubectl describe pod / logs 看事件。
  4. ④ 驗證 Service · 拿 LoadBalancer 的 EXTERNAL-IP,curl …/healthz
    為什麼分兩種 Service:db 是 ClusterIP(只在叢集內、不對外=正確);app 是 LoadBalancer(OrbStack 自動配 EXTERNAL-IP 給 host 直打)。IP 還沒分配就先 kubectl port-forward svc/app 8000:8000 頂著。
  5. ⑤ 開打 · host 上 uv run locust … --host http://$APP_IP:8000 --class-picker,開 :8089/?tab=charts
    為什麼 Locust 跑 host、不進 K8s:沿用攻防分離;也因此 K8s baseline 比 compose 多了「攻擊機與 VM 共擠 + LoadBalancer 一跳」的成本(對答案見上方 baseline)。換 demo 只換 -f,靶機不必重部署。
  6. ⑥ 監控 · kubectl top pod -n loadtest(docker stats 的 K8s 版)
    為什麼一定要盯(鐵則 01):單位是 millicpu(648m = 0.648 vCPU)。重點看 app 和 db 會不會擠到同一顆核心——CFS quota 只限「CPU 時間」、不鎖核,這是和 compose cpuset 最大的行為差異。
  7. ⑦ 實驗旋鈕 · 改 pool 要三步:patch ConfigMap → rollout restart → exec 驗證
    為什麼不能只 patch:configMapKeyRef 的 env 只在容器啟動時讀一次,patch 完 Deployment 模板沒變 → 不觸發 rollout → 跑著的 Pod 還拿舊值(踩坑 03)。必須 rollout restart 收斂,再 exec … | grep DB_POOL_SIZE 驗證旋鈕真的接上(LESSON 04 的 K8s 版)。注意 patch 是暫時的,下次 apply 會蓋回(踩坑 04)。
  8. ⑧ 清理 · kubectl delete namespace loadtest 一刀全清
    為什麼這樣等於重置:namespace 是隔離邊界,刪它連帶刪光裡面所有物件。db 用 emptyDir(揮發),下次 apply 時 init.sql 重跑、回到 50 users / 200 posts 的 seed——這就是鐵則 03「每輪重置 DB」的 K8s 版。
# ① 前置:build image(OrbStack K8s 直接看得到,免推 registry)
docker compose build app
kubectl config current-context        # 應為 orbstack

# ② 部署:namespace 先行,其餘同目錄一起 apply
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/

# ③④ 驗證:等 1/1 Running,再拿 LoadBalancer 的 EXTERNAL-IP
kubectl get pods -n loadtest -w
APP_IP=$(kubectl get svc app -n loadtest -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl http://$APP_IP:8000/healthz      # {"status":"ok"};沒 IP 就 kubectl port-forward svc/app 8000:8000

# ⑤ 開打:host 上的 Locust Web UI 直打 LB(換 demo 只換 -f,靶機不用重部署)
uv run locust -f locustfiles/demo4_sync_vs_async_db.py --host http://$APP_IP:8000 --class-picker
open http://localhost:8089/?tab=charts   # 設 users 後 Start

# ⑥ 監控:鐵則 01 的 K8s 版(millicpu:648m = 0.648 vCPU)
kubectl top pod -n loadtest

# ⑦ 實驗旋鈕:改 pool 三步,少一步就拿到舊值(踩坑 03)
kubectl patch configmap app-config -n loadtest --patch '{"data":{"DB_POOL_SIZE":"100"}}'
kubectl rollout restart deployment/app -n loadtest
kubectl exec -n loadtest deploy/app -- env | grep DB_POOL_SIZE   # 驗證旋鈕接上了

# ⑧ 清理:一刀刪 namespace(emptyDir 揮發,下次 apply 回到 seed)
kubectl delete namespace loadtest
07 //

已驗證的實測數據

不是理論值——都是在這台靶機上打出來的(SQLAlchemy ORM stack,攻擊參數見各卡片)。

0 RPS
FastHttpUser 峰值(vs HttpUser 5,921)——同參數差 4.2×,攻擊機武器很重要
0 vs 335 RPS
pool=100 時 async vs sync——threadpool 40 條成為 sync 的瓶頸
1.28s ≈ 1.28s
pool=10 時 async wall ≈ sync wall——pool 是共同瓶頸,async 不是魔法
3× → 1.5×
換上 ORM 後 async 的領先幅度腰斬——抽象層的 CPU 成本全擠在單一 event loop
0 RPS 天花板
demo5 掃描:pool 50→100 翻倍、RPS 只 +3.6%——靶機 CPU 102%,瓶頸從 pool 轉移到 event loop
30s p95
demo6 崩潰點:5000 users 下 QueuePool timeout 30s 全數現形(HTTP 500 ×2,802)。單 Pod baseline:1000 撐住、5000 必崩
LESSON 01瓶頸在誰身上,誰就決定 p95。pool 小的時候 sync/async 沒差——先找到瓶頸,再談優化。
LESSON 02攻擊機也會是瓶頸。xargs+curl 起 100 個 process 的 fork 開銷就吃掉 0.3s,差異完全被淹沒——所以 Locust 要用 FastHttpUser,所以容器要分核。
LESSON 03ORM 不是免費的。200 筆 row → ORM 物件的序列化是 CPU 工作,async 版全擠在單一 event loop 上無法重疊——抽象層的成本,async 要自己扛。
LESSON 04改了變因卻沒變化?先驗證旋鈕真的接上了。沒 export 的 DB_POOL_SIZE 被下一個 compose 指令默默重設回 5,掃了四輪全是同一份假數據——差點寫成教材。每輪跑完 docker inspect 驗 env。
LESSON 05零停機部署會讓 DB 連線足跡翻倍。K8s RollingUpdate 新舊 Pod 並存的瞬間,pool 預熱撞上 too many clients → rollout 永久死鎖——壓測靶機改用 Recreate,production 用 pgbouncer。