這個 repo 是大流量壓測訓練的靶機(Target):一個刻意做成「能被打出各種瓶頸」的 Mini Social Platform。它不是為了上線,是為了讓你在受控環境裡,親手重現 event loop 卡死、connection pool 餓死、攻擊機自己先陣亡——然後學會用數據說話。
教科書告訴你 async 比較快、pool 要設大一點。這裡讓你量出來——而且常常量出和直覺相反的結果。
壓測最常見的失敗不是「不會用工具」,而是分不清瓶頸在誰身上:
你以為在測伺服器,其實在測攻擊機的 CPU;你以為 async 是魔法,
其實 pool 太小時 sync/async 根本沒差。
靶機把每個變因做成可旋轉的旋鈕:同一個端點有 sync / async 兩個版本(同 driver、同 pool,唯一變因是併發模型)、
DB_POOL_SIZE 控制連線池、
?delay_ms= 模擬慢查詢。
一次只轉一顆,差異才能歸因。
攻擊機(Locust)打到火力全開時 CPU 會吃滿 100%。如果它和靶機擠在同幾顆核心上, 你量到的延遲一半是攻擊機自己造成的。所以整套架構容器化,用 cpuset 把三方鎖在不同核心,互不搶資源。
demo2 的核心戲劇點,先在這裡體感一次:靶機只有 1 個 worker、1 條 event loop。
按下紅色按鈕注入一個 blocking sleep,看整條服務(連 /healthz 都)瞬間凍結;
換成綠色的 async sleep,那個請求自己去旁邊等,其他人照常通行。
◤ 對應實驗:A /experiments/sync-sleep(卡死全場)、B /experiments/async-sleep(讓路)、
C /experiments/cpu(CPU bound——async 也救不了,模擬器沒演的那課,去 demo2 親手打)。
順序是設計過的:先學工具(1)→ 先懂 event loop(2)→ 先排除攻擊機瓶頸(3)→ 才做靶機實驗(4、5)→ 最後整合大流量(6)。
跳過 3 直接做 4,你測到的會是 Locust 的極限,不是靶機的。
每張卡片展開後分三段:實驗設計(釘死哪些變因、對照組 ↔ 實驗組 差在哪)→
步驟(每一步都附「為什麼現在做這個操作」)→ 結果(實測數字)。照著做就能歸因。
三個核心概念: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)、靶機單 workerdocker compose up -d --build --waithttp://localhost:8089/?tab=charts,先用 10 users 開打p50 / p95 / RPS / error rate# 全套起來(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 沒跟著加倍?
上面模擬器的真槍實彈版。用同樣 20 users、同樣 constant(0)(連發)打三個只差在「怎麼等那 1 秒」的端點,把 blocking / async / CPU-bound 一次量出來。靶機只有 1 worker、1 條 event loop,這是所有戲劇的前提。
wait_time constant(0)、靶機單 event loopawait asyncio.sleep:等待時 event loop 切去服務別人。RPS ≈ 20、p95 ≈ 1s。
time.sleep:卡死唯一的 event loop,全服務凍結。RPS ≈ 1、p95 數十秒。
純計算:宣告 async def 也無法切換。RPS ≈ 10、全部排隊。
AsyncSleepUser,20 users / spawn 20,按 StartSyncSleepUser(users / spawn 不變)curl -m 5 http://localhost:8000/healthz/healthz 根本不碰 DB、理應毫秒回應,此刻卻 timeout——證明 blocking 卡的不是「某個請求」,是整條服務。這正是 K8s liveness probe 會誤殺 Pod 的根因。CpuUserasync 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) 各跑一輪對照
| 組別 | 端點 | RPS | p95 |
|---|---|---|---|
| 對照 B | async-sleep | ≈ 20 | ≈ 1s |
| 實驗 A | sync-sleep | ≈ 1 | 數十秒 |
| 實驗 C | cpu | ≈ 10 | 全排隊 |
同樣 20 users,只因「怎麼等」不同,吞吐差 20 倍。blocking 與 CPU-bound 都會獨佔單一 event loop;async 只在 I/O 等待時有效。
要能回答:為什麼一個 time.sleep(1) 連 /healthz 都殺死?實驗 C 是 async def 為什麼還是卡?哪些常見函式庫(requests、psycopg2…)會像 A 一樣偷偷 block?
做任何靶機實驗前的前置檢查:先證明攻擊機自己不是瓶頸,否則 demo4/5/6 全是在測 Locust,不是測靶機。同樣 300 users、同樣打 /healthz(靶機最快、幾乎零成本的端點),只換攻擊機用的 HTTP client。
/healthz、constant(0)、靶機設定一律不碰基於 requests,純 Python 開銷。單一 locust process 的 CPU 會先鎖死 100%(GIL 限制)。
基於 geventhttpclient(C 實作)。同樣 300 users 吞吐翻數倍;再加 --processes 4 吃滿 4 核又翻一次。
docker statsSlowAttacker,300 users / spawn 100,StartFastAttacker,users / spawn 不變--processes 4:要停掉預設 locust、改用 --service-ports 起(見指令;前菜:階段三分散式)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
| 武器 | RPS | 瓶頸落點 |
|---|---|---|
| SlowAttacker | 5,921 | 攻擊機 CPU 100%(單核) |
| FastAttacker | 24,762 | 攻擊機,但已大幅鬆綁 |
同參數差 4.2×——差的全是攻擊機自己。判斷瓶頸在攻擊機的訊號:靶機 CPU 還很閒,RPS 卻上不去。
要能回答:怎麼從 metrics 判斷「瓶頸在攻擊機」而非靶機?Python GIL 在這裡扮演什麼角色?單機 --processes 和 K8s 分散式 Locust 差在哪?
本訓練的核心對照實驗。前置都做完了(會用工具、懂 event loop、攻擊機不是瓶頸),現在打真 PostgreSQL。?delay_ms=100 讓每個查詢佔住 connection 100ms,模擬慢查詢。
psycopg3、同 pool=100、100 users、delay_ms=100create_engine(sync)↔ create_async_engine(async)FastAPI 把它丟進 threadpool(預設 40 條)跑,每條 thread 佔住一個連線等 DB。瓶頸 = 40 條 thread。
等 DB 時 event loop 切去服務別人,不佔 thread。瓶頸往後推到 pool(100)或 CPU。
export DB_POOL_SIZE=100,再 docker compose up -d --build --waitpool=5 把 app 默默重建回去——真踩過,四輪數據一模一樣。SyncPostsUser,100 users / spawn 50,StartAsyncPostsUser,users / spawn 不變docker inspect k8s-app-1 … | grep DB_POOL# 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
| 組別 | 端點 | RPS | 瓶頸 |
|---|---|---|---|
| 對照 sync | /sync/posts | 335 | threadpool 40 條 |
| 實驗 async | /async/posts | 512 | pool / 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 連線數,怎麼設計實驗分開驗證?
固定攻擊參數,只掃一個旋鈕 DB_POOL_SIZE,畫出「p95 對 pool 大小」的曲線。這支證明:connection pool 才是 p95 的主宰,async 不是魔法。?delay_ms=50 讓查詢佔住連線、放大 pool 的影響。
delay_ms=50、打 /async/posts、每輪重置 DBDB_POOL_SIZE:10 → 20 → 50 → 100,四輪pool 嚴重不足,請求在 pool 前大排長龍。此時 sync ≈ async(1.28s ≈ 1.28s)——瓶頸在 pool,誰都救不了。
每翻一倍看 p95 怎麼走。一路砍半,直到靶機 CPU 撞 102%、瓶頸從 pool 轉移到 event loop 為止。
docker compose downexport DB_POOL_SIZE=10,再 up -d --waitexport DB_POOL_SIZE=20 / 50 / 100,重複步驟 1–3# 每輪:重置 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 # 驗證旋鈕
| DB_POOL_SIZE | RPS | p95 |
|---|---|---|
| 10(對照) | 167 | 1400ms |
| 20 | 302 | 740ms |
| 50 | 620 | 390ms |
| 100 | 642 | 420ms |
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 就是它。
整合戰:用擬真的讀寫混合流量(滑 feed:看用戶:發訊息 = 10:3:1)做階梯式加壓,找出靶機的崩潰點。StepLoadShape 自動控制階梯,不必手動調 users。
pool=100、讀寫比 10:3:1、wait_time between(1,3)1000 users:CPU 56–69%、零 error。系統還有餘裕。
CPU 100%、MEM 貼死 256MiB limit、HTTP 500 ×2,802(3.23%),p95 = 30 秒整 = QueuePool timeout。
export DB_POOL_SIZE=100,再 up -d --build --waitdocker stats--processes 4 --service-ports(不加 --headless;階梯由腳本控制,免設 users)--csv-full-history,worker 繼承 flag 卻拿不到 prefix、啟動即死。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 # 三容器一起盯:誰先死?
| 階 | 靶機 CPU | error | p95 |
|---|---|---|---|
| 1000 users | 56–69% | 0 | 正常 |
| 5000 users | 100% | 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)
# 一鍵全上: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
docker statsdocker compose down && up,每輪都從同一份 seed 開始,數據才可比。Problem: (觀察到什麼異常/想驗證什麼) Hypothesis: (你認為的原因) Experiment: (改了什麼、攻擊參數) Result: (p50 / p95 / RPS / error rate / 兩側 CPU·MEM) Lesson: (一句話,能教給別人的那種)
階段二(下):整套攻防搬上本地 K8s(OrbStack 內建叢集,namespace loadtest)。
重點不是會打 kubectl,而是同一份壓測在兩種調度機制下會量出什麼差異——
cpuset 換成 CFS quota、docker stats 換成 kubectl top,然後跑同一支 demo4 對答案。
manifest 全在 k8s/,每個欄位(連預設值)都有教學註解。
下方「部署實戰」把上線到清理拆成八步,每步都說明為什麼現在做這個操作。
| 變因 | COMPOSE(階段二上) | K8S(階段二下) |
|---|---|---|
| 靶機 CPU 隔離 | cpuset: "2,3" — 鎖死核心編號,真隔離 | cpu limit: "2" — CFS quota:每 100ms 最多 200ms CPU 時間,可跨核但不鎖核,App 和 DB 可能擠同一顆 |
| 攻擊機位置 | locust 容器,cpuset 4-7 | host 上 uv run locust,與 Docker VM 共擠——量出來的差異來源之一 |
| 監控 | docker stats | kubectl top pod -n loadtest(單位是 millicpu:648m = 0.648 vCPU) |
| 實驗旋鈕(pool) | export DB_POOL_SIZE=100 再 up | patch ConfigMap + rollout restart——改了不會自動生效(踩坑 03) |
| 重置 DB(鐵則 03) | compose down && up | 刪 db Pod 即可:emptyDir 揮發 + init.sql 重跑,回到同一份 seed |
| image 來源 | 本地 build | imagePullPolicy: 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 就不收流量。
| demo4 async・100u・delay 100ms・pool 100 | RPS | p50 |
|---|---|---|
| compose(cpuset 隔離) | 512 | 200ms |
| K8s 單 Pod(CFS quota) | 440 | 180ms |
RPS 低 ~14%、p50 反而略好——差異來自攻擊機在 host 與 VM 共擠 + kube-proxy/LoadBalancer 多一跳 + CFS 無核心隔離。靶機 CPU 648m,離 limit 2000m 還遠。 量級一致 → 單 Pod baseline 成立,「1000 撐住、5000 必崩」就是階段三 HPA 的對照組。
command: 蓋掉的是 ENTRYPOINT,args: 才對應 Docker 的 CMD。把 compose 的 command 原樣搬過來 → docker-entrypoint.sh 被跳過 → init.sql 沒跑、PG 以 root 啟動被拒 → db 和 app 一起 CrashLoopBackOff。too many clients → 新 Pod CrashLoop、舊 Pod 不退 → rollout 永久死鎖。本 demo 用 Recreate 治標,production 正解是 pgbouncer(→ LESSON 05)。configMapKeyRef 的 env 只在容器啟動時讀一次,必須 kubectl rollout restart 才收斂——驗證方式:kubectl exec ... env | grep DB_POOL_SIZE(LESSON 04 的 K8s 版:先驗證旋鈕接上了)。kubectl apply 會把 ConfigMap 蓋回 YAML 宣告的值——declarative 才是真相,實驗完想保留設定就改 manifest 再 apply。docker compose build app,確認 kubectl config current-context 是 orbstackimagePullPolicy: Never 的前提。沒先 build,Pod 會卡在 ErrImageNeverPull。namespace.yaml,再 kubectl apply -f k8s/db.yaml 一檔含四物件(ConfigMap 裝 init.sql、Secret 裝帳密、Deployment、ClusterIP Service)。kubectl get pods -n loadtest -w 等到 1/1 Runningpg_isready、app 的 readiness 打 /readyz 真連一次 DB——app 要等 DB 通了才會 Ready,這是刻意的啟動順序保護。卡住就 kubectl describe pod / logs 看事件。curl …/healthzClusterIP(只在叢集內、不對外=正確);app 是 LoadBalancer(OrbStack 自動配 EXTERNAL-IP 給 host 直打)。IP 還沒分配就先 kubectl port-forward svc/app 8000:8000 頂著。uv run locust … --host http://$APP_IP:8000 --class-picker,開 :8089/?tab=charts-f,靶機不必重部署。kubectl top pod -n loadtest(docker stats 的 K8s 版)648m = 0.648 vCPU)。重點看 app 和 db 會不會擠到同一顆核心——CFS quota 只限「CPU 時間」、不鎖核,這是和 compose cpuset 最大的行為差異。rollout restart → exec 驗證configMapKeyRef 的 env 只在容器啟動時讀一次,patch 完 Deployment 模板沒變 → 不觸發 rollout → 跑著的 Pod 還拿舊值(踩坑 03)。必須 rollout restart 收斂,再 exec … | grep DB_POOL_SIZE 驗證旋鈕真的接上(LESSON 04 的 K8s 版)。注意 patch 是暫時的,下次 apply 會蓋回(踩坑 04)。kubectl delete namespace loadtest 一刀全清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
不是理論值——都是在這台靶機上打出來的(SQLAlchemy ORM stack,攻擊參數見各卡片)。
docker inspect 驗 env。too many clients → rollout 永久死鎖——壓測靶機改用 Recreate,production 用 pgbouncer。