前言
「這台 2 核的 VM,到底能扛多少流量?」這個問題我之前一直是用感覺回答的:應該夠吧,平常 CPU 才 5% 左右。直到有一天我決定認真量一次,才發現憑感覺講的數字和實測差了不少,而且過程中還有一個我很有把握的假設被實驗直接推翻。
【系列】自架 VM 維運實戰 第 5 篇/共 5 篇
上一篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
下一篇:(本篇是最後一篇)
這篇文章記錄我用 k6 壓測自己 VM 上 Laravel Octane(FrankenPHP)API 的完整過程。分成三輪:第一輪從筆電經 Cloudflare 打,結果量到的幾乎是網路延遲;第二輪繞過 Cloudflare 直接打 origin,才看到真正的 CPU 上限;第三輪我懷疑 --max-requests 拖慢了效能,改完重測,結果幾乎沒差。最後還有一個彩蛋:壓測完去看 Grafana,居然「看不出很忙」,原因出在 PromQL 的時間窗口。
除特別標註的估算外,文中的數字都是實測值,沒有經過美化。測的是一支只回固定 JSON 的輕量端點,目的是看「框架本身」的極限,不是你的業務邏輯的極限,這點後面會再強調。
測試環境與目標
先交代環境,因為壓測結果離開環境就沒有意義:
| 項目 | 內容 |
|---|---|
| 主機 | (ARM)虛擬機,2 vCPU / 12 GB |
| 部署方式 | Docker Compose |
| 應用程式 | Laravel + Octane(FrankenPHP,--workers=2) |
| 請求路徑 | Cloudflare → 主機 nginx 反向代理 → 容器 nginx → FrankenPHP |
| 同機其他服務 | 另外兩個 Laravel 服務、MySQL 8.0、Prometheus / Grafana / Loki 監控 |
| 壓測端點 | 一支很輕的 JSON API,回傳 {"status":true,...,"data":[]} |
這台機器不是專門的壓測靶機,而是一台「什麼都在跑」的小型正式環境。這其實更貼近真實:你的 VM 通常也不會只跑一個服務。但也代表我必須非常小心,壓測不能把同機的其他服務拖垮。
我想回答的問題有三個:
- 吞吐量上限:這台 VM 在這個架構下,每秒最多能處理幾個請求?
- 瓶頸在哪:是 CPU、記憶體、資料庫,還是網路?
- 超過上限會怎樣:是開始噴錯,還是慢慢變慢?
選一支很輕的端點是刻意的:它幾乎不碰資料庫,這樣量到的就是「Cloudflare、nginx、FrankenPHP、Laravel 路由與中介層」這條路徑本身的成本。真實業務端點只會比它更慢,所以這個數字可以當作上限的天花板。
k6 腳本與安全閥
壓測工具我選 k6,原因很單純:腳本是 JavaScript、thresholds 可以直接寫成通過條件、輸出的百分位數報告很完整,而且有官方 Docker image,不用在 VM 上裝任何東西。
壓測的是正式環境,所以最重要的設計不是「怎麼打得兇」,而是「怎麼在出事時自動停手」。k6 的 thresholds 支援 abortOnFail,只要超標就自動中止整個測試。完整腳本如下:
import http from 'k6/http';
import { check } from 'k6';
// 分段加壓:每段 30 秒;錯誤率 > 5% 或 p95 > 2s 立即中止,保護正式服務
export const options = {
stages: [
{ duration: '10s', target: 5 }, { duration: '30s', target: 5 },
{ duration: '5s', target: 10 }, { duration: '30s', target: 10 },
{ duration: '5s', target: 25 }, { duration: '30s', target: 25 },
{ duration: '5s', target: 50 }, { duration: '30s', target: 50 },
{ duration: '5s', target: 0 },
],
thresholds: {
http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '10s' }],
http_req_duration: [{ threshold: 'p(95)<2000', abortOnFail: true, delayAbortEval: '10s' }],
},
summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
};
export default function () {
const r = http.get('https://app-b.example.com/api/health', {
headers: { 'User-Agent': 'k6-loadtest' },
});
check(r, {
'200': (x) => x.status === 200,
'status:true': (x) => /"status"\s*:\s*true/.test(x.body),
});
}
幾個設計重點:
- stages 階梯式加壓:5 → 10 → 25 → 50 VU,每段維持 30 秒,段與段之間用 5 秒爬升,開頭 10 秒暖機、最後 5 秒降壓,整段共 150 秒。階梯式的好處是可以對照「每個負載等級」下的伺服器指標,找到曲線開始彎曲的位置。
- 兩個 threshold 都設 abortOnFail:失敗率 5%、p95 2 秒是我認為「使用者已經會感覺到壞掉」的底線。碰到就停,不要硬打下去。
- check 同時驗狀態碼與 body:只看 200 不夠。有些錯誤(例如反向代理回的錯誤頁、被 Cloudflare 擋下的挑戰頁)狀態碼可能是 200,但 body 不是我要的 JSON。所以我同時驗
status:true。 - 沒有 sleep:每個 VU 收到回應就立刻發下一個請求,這是最粗暴的 closed-loop 模型,吞吐量會自然等於「VU 數 ÷ 單次延遲」。
這個最後一點很重要,後面解讀第一輪結果時會用到。
第一輪:從筆電經 Cloudflare 壓測
第一輪我直接在筆電跑,走完整的對外路徑:筆電 → Cloudflare → 遠端機房的 VM。這也是真實使用者會走的路徑。
結果
| 指標 | 數值 |
|---|---|
| 總請求數 | 12,456 筆 |
| 失敗率 | 0% |
| 平均延遲 | 253 ms |
| p95 | 270 ms |
| p99 | 304 ms |
| 最慢一筆 | 389 ms |
| 整段平均吞吐量 | 83 req/s |
| 50 VU 時吞吐量 | 約 200 req/s(估算:50 VU ÷ 0.25 s) |
兩個 threshold 都沒有觸發,0% 失敗,p95 才 270ms。乍看之下「很穩」,但這個結果其實藏了一個陷阱。
關鍵觀察:延遲根本沒有變
k6 的摘要沒有分階段統計,但整體分布已經說明了一切:負載從 5 VU 放大到 50 VU(10 倍),中位數 250ms、p99 304ms、最慢也只有 389ms,延遲幾乎沒有拉開。這代表什麼?
代表這 250ms 幾乎都是網路往返時間(筆電 → Cloudflare → 遠端機房),而不是伺服器處理的時間。我從 VM 本機自己探測同一支 API,回應時間只要約 60ms。也就是說,在這個負載範圍內,伺服器還很閒,閒到延遲曲線完全是平的。
這也解釋了為什麼 50 VU 時吞吐量大約是 200 req/s(估算值):50 個 VU、每個請求 0.25 秒,50 ÷ 0.25 = 200。吞吐量被「網路延遲 × VU 數」限制住了,而不是被伺服器限制。想打出更高的吞吐量,要嘛加更多 VU,要嘛縮短往返時間。
伺服器端的指標
我在壓測同時用 docker stats 觀察 app-b-web 容器(100% = 1 核):
| VU 數 | app-b-web CPU |
|---|---|
| 5 | 6–9% |
| 10 | 12–15% |
| 25 | 約 31% |
| 50 | 約 56% |
CPU 使用率隨 VU 數明顯往上爬。MySQL 全程只有 1.5–5%,因為這支端點幾乎不碰資料庫。到 50 VU 為止,伺服器遠遠沒到極限。
結論:第一輪沒有找到上限。從筆電經 Cloudflare 打,最先到頂的是我的網路,而不是伺服器。要找真實上限,必須換個打法。
第二輪:繞過 Cloudflare 直接打 origin
要排除網路往返,最直接的辦法是把壓測工具搬到 VM 上,直接打本機的 nginx。
踩坑:which ab 看到的是自己的筆電
我本來想用經典的 ab(ApacheBench),到 VM 上敲 which ab,有輸出,就以為有裝。結果跑起來才發現不對:我那個終端機視窗其實還連在自己的筆電上,看到的是筆電的 ab。這個教訓很蠢但很實用:在遠端機器上確認工具存在之前,先確認你真的在遠端機器上。
既然 VM 上沒有 ab,我乾脆改用 k6 官方 Docker image,這樣連安裝都省了,而且兩輪的壓測工具一致,數字比較有意義:
docker run --rm --network host \
-v /tmp/k6-origin.js:/s.js:ro \
grafana/k6:0.54.0 run --quiet /s.js
--network host 讓容器直接使用主機網路,這樣就能請求 127.0.0.1;-v ...:ro 把腳本以唯讀方式掛進去。因為是直接打主機 nginx,需要自己帶上 Host header,nginx 才知道要轉給哪個站台:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '5s', target: 10 }, { duration: '20s', target: 10 },
{ duration: '5s', target: 25 }, { duration: '20s', target: 25 },
{ duration: '5s', target: 50 }, { duration: '20s', target: 50 },
{ duration: '5s', target: 100 }, { duration: '20s', target: 100 },
{ duration: '5s', target: 200 }, { duration: '20s', target: 200 },
{ duration: '5s', target: 0 },
],
thresholds: {
http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '5s' }],
http_req_duration: [{ threshold: 'p(95)<2000', abortOnFail: true, delayAbortEval: '5s' }],
},
summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
};
export default function () {
const r = http.get('http://127.0.0.1/api/health', {
headers: { Host: 'app-b.example.com', 'User-Agent': 'k6-origin' },
});
check(r, {
'200': (x) => x.status === 200,
'status:true': (x) => /"status"\s*:\s*true/.test(x.body),
});
}
這次 stages 改成 10 → 25 → 50 → 100 → 200 VU,每段維持 20 秒,段間 5 秒爬升、最後 5 秒降壓,整段共 130 秒。安全閥維持跟第一輪一樣:失敗率 5%、p95 2 秒,任何一個超標就自動中止。
同時採集伺服器指標
壓測只看 k6 的輸出是不夠的,你只會知道「變慢了」,不會知道「為什麼」。所以我另外開一個視窗,每 5 秒取樣一次 load、CPU idle 與各容器的 CPU:
# 每 5 秒取樣一次,共 30 次(約 150 秒,涵蓋整段壓測)
for i in $(seq 1 30); do
echo "$(date +%H:%M:%S) load=$(cut -d' ' -f1 /proc/loadavg) idle=$(top -bn1 | awk '/Cpu\(s\)/{print $8}')% $(docker stats --no-stream --format '{{.Name}}={{.CPUPerc}}' app-b-web app-b-nginx shared-mysql | tr '\n' ' ')"
sleep 5
done > /tmp/k6-origin-samples.log 2>&1 &
結果(跑兩次,可重現)
| 指標 | 第一次 | 第二次 |
|---|---|---|
| 總請求數 | 40,035 | 39,184 |
| 失敗 | 0 | 0 |
| 吞吐量 | 308 req/s | 301 req/s |
| 中位數 | 138 ms | 145 ms |
| p90 | 555 ms | 628 ms |
| p95 | 936 ms | 967 ms |
| p99 | 1.55 s | 1.45 s |
| 最慢一筆 | 3.4 s | 2.34 s |
兩次的結果非常接近:吞吐量 301–308 req/s,中位數 138–145ms,p99 在 1.5 秒上下,0 失敗。可重現這點很重要,代表我量到的是系統的特性,不是某次的運氣。
對照第一輪:同樣是這支 API,第一輪最高約 200 req/s(估算)是被網路限制住,這一輪拿掉網路之後,上限落在大約 300 req/s。
取樣數據在說什麼
- 主機 CPU idle 幾秒內就掉到 0%,load average 從 0.5 一路爬到 17.8。2 核的機器 load 到 17.8,代表平均有十幾個工作在排隊等 CPU。
- app-b-web 卡在約 100%:也就是只拿到 1 核。我沒有量到其他程序各吃多少,推測另一核被 k6 自己、主機 nginx、docker-proxy 分走了;從 app-b-web 只拿到 1 核、主機 idle 0% 來看,這個推測合理。
- k6 每次加開 VU 的瞬間,app-b-web 會掉到 50–60%:推測是 k6 在建立新的 VU 與連線,短暫搶走了 CPU(我沒有另外量 k6 容器的 CPU)。如果推測成立,這就是壓測工具與服務同機的代價,後面的守則會再提。
- MySQL 5–7%:比第一輪高一些,但這不是這支端點造成的,大多是正式環境上開著的除錯/觀測工具在寫紀錄。
最後一個重點:超過上限時,系統不會出錯,只會排隊。0 個失敗、最慢 3.4 秒,代表請求都被處理了,只是在 CPU 前面排隊等。這對容量規劃是好消息(不會突然崩潰),也是壞消息(監控如果只看錯誤率,會完全看不到問題)。
用 Little's Law 檢查數字合不合理
拿到一組壓測數字之後,我習慣先用一個簡單的公式做 sanity check:並行數 ≈ 吞吐量 × 平均延遲,也就是排隊理論裡的 Little's Law。在沒有 sleep 的 closed-loop 模型下,每個 VU 永遠都在「等回應」,所以 VU 數就是系統裡同時存在的請求數。
第一輪:50 VU、延遲約 0.25 秒,吞吐量 ≈ 50 ÷ 0.25 = 200 req/s,大約是這個量級。這時系統裡的請求大部分是在網路上「飛行」,而不是在伺服器裡排隊。
第二輪:吞吐量被 CPU 鎖死在約 300 req/s 之後,VU 繼續增加到 200,多出來的請求無處可去,只能排隊。以尖峰的 200 VU 除以 300 req/s 估算,平均每個請求要在系統裡待約 0.67 秒(這是粗估,不是實測值)。這可以解釋延遲分佈為什麼拉出一條長尾。我推測(沒有分階段數據)中位數 138–145ms 主要來自前面幾個低負載階段,p99 超過 1.5 秒則是尖峰階段排隊的請求。
換句話說,p95、p99 的數字和「你加了多少 VU」是綁在一起的,不要把它們當成這支 API 的固定屬性。同樣的上限下,VU 加得越多,排隊的請求就越多,延遲數字自然越難看,但吞吐量的平台期不會跟著變。評估容量時,吞吐量的平台期比延遲數字更可靠。
closed-loop 與 open-loop 的差別
我用的 stages + vus 是 closed-loop 模型:VU 收到回應才會發下一個請求,所以系統一變慢,壓測流量自己就會降下來,這會讓壓測結果「偏樂觀」。真實的使用者流量比較像 open-loop:不管你的伺服器回得多慢,使用者還是照自己的節奏湧進來。k6 提供 constant-arrival-rate 與 ramping-arrival-rate 這類 executor 可以模擬 open-loop,如果你要驗證「每秒 400 個請求湧進來會怎樣」,應該改用它們。這次我的目的是找吞吐量上限,closed-loop 已經足夠,但我心裡清楚它的限制在哪。
瓶頸分析
把兩輪的數據放在一起看,結論相當清楚:
| 第一輪(經 Cloudflare) | 第二輪(直打 origin) | |
|---|---|---|
| 最高吞吐量 | 約 200 req/s(估算) | 約 300 req/s |
| 延遲特徵 | 約 250ms,與負載無關 | 隨 VU 增加持續拉長,p99 約 1.5 s |
| app-b-web CPU | 最高約 56% | 卡在約 100% |
| 主機 CPU idle | 仍有餘裕 | 幾秒內降到 0% |
| 瓶頸 | 網路往返與 VU 數 | CPU |
第二輪的證據鏈是這樣的:
- 吞吐量在 VU 增加到某個程度之後就不再成長,穩定在 300 req/s 左右。
- 同一時間主機 CPU idle 是 0%,app-b-web 容器停在約 100%。
- 延遲拉長但沒有錯誤,符合「CPU 排隊」而非「資源耗盡」的特徵。
- MySQL 只有 5–7%,網路在本機迴路上不是問題,記憶體也沒有壓力。
換句話說,app-b-web 實際只拿到約 1 核,就跑出約 300 req/s。另一核推測被 nginx、docker-proxy、k6 與其他同機服務分走,所以 2 核 VM 對這個輕量端點的「真實上限」大約是 300 req/s,而不是理論上的「2 個 worker 各跑滿一核」。
這裡我也想提醒自己:這是一支回傳空陣列的端點。真實業務端點只要多打一次 MySQL 或 Redis,吞吐量只會更低。300 req/s 是天花板,不是承諾。
想再往上,有哪些選項
既然瓶頸是 CPU,要提升上限的方向就很明確。我把想到的選項依成本排了一下,目前都只是評估,沒有實際動手:
- 把不必要的負載搬走:正式環境上開著的除錯/觀測工具會持續寫紀錄,這在壓測期間讓 MySQL 多吃了幾個百分點。把它們的取樣率調低,或者只在需要時開啟,是零成本的做法。
- 減少同機服務的搶佔:這台機器上還有另外兩個 Laravel 服務與整套監控。如果其中一個服務的流量也在成長,它們會直接分走 app-b-web 的 CPU。
- 加核心:的 vCPU 數可以調整,這是最直接的辦法。因為這個工作負載是 CPU-bound,吞吐量大致會跟可用核心數同向成長,但我會在加完之後用同一支腳本重測,而不是用估算的。
- 在前面擋掉請求:對可以快取的公開端點,讓 Cloudflare 或 nginx 直接回應,根本不進到 PHP。這是 CP 值最高的做法,但只適用於可快取的內容。
這些選項的排序,我覺得最重要的原則是:先弄清楚瓶頸在哪,再決定花錢還是花時間。如果沒有這輪壓測,我很可能會先去調 Octane 參數,結果就是白忙一場。
調 max-requests 的實驗與推翻
到這裡我有了一個很順的故事,也因此做了一件危險的事:我開始「推測」。
原本的假設
Octane 的啟動參數我一直設定 --max-requests=300,意思是每個 worker 處理 300 個請求後就重啟,目的是防止記憶體慢慢洩漏。但仔細算一下:
吞吐量 300 req/s
worker 數 2
每個 worker 150 req/s
重啟門檻 300 req
300 ÷ 150 = 每個 worker 每 2 秒重啟一次
2 個 worker 每 2 秒各重啟一次,聽起來很誇張。每次重啟都要重新載入框架、重新 boot service provider,這個成本如果不小,豈不是吃掉了一大塊 CPU?我當下幾乎認定這就是瓶頸的一部分,覺得把 max-requests 調大應該能看到明顯提升。
驗證:改成 1000 再測
與其相信推測,不如直接量。我先讓 entrypoint 支援環境變數覆寫,之後想調整就不用重新 build image:
# docker entrypoint
php artisan octane:start \
--server=frankenphp \
--host=0.0.0.0 \
--port=8000 \
--workers=2 \
--max-requests=${OCTANE_MAX_REQUESTS:-1000}
重新部署之後,用完全相同的腳本、同樣的 stages 再打一次:
| 指標 | max-requests=300 | max-requests=1000 |
|---|---|---|
| 吞吐量 | 301–308 req/s | 301.8 req/s |
| 中位數 | 138–145 ms | 149 ms |
| p95 | 936–967 ms | 957 ms |
| p99 | 1.45–1.55 s | 1.56 s |
幾乎沒有差別,甚至中位數還略高了一點點(在兩次重現的誤差範圍內)。我的假設被推翻了。
為什麼沒差?
事後回頭想,原因其實合理。FrankenPHP 的 worker 在達到 max-requests 時,重啟的只是 worker 這一層(重新載入應用程式),並不是把整個 FrankenPHP 程序砍掉重開,不用重新建立監聽連線、也不用重新初始化 Go runtime。對每秒 300 個請求來說,這個成本小到可以忽略。真正的瓶頸仍然是 CPU:每個請求本身的處理成本,而不是 worker 生命週期。
這個實驗我覺得比前面任何一個數字都有價值,因為它示範了三件事:
- 算出來很可怕的數字,不一定是真正的瓶頸。「每 2 秒重啟一次」聽起來很糟,但實測成本可以忽略。
- 一次只改一個變數。只動
max-requests,其他全部不變,才能把差異歸因到它身上。 - 用同一支腳本、同一個流程重測。可重現性是比較的前提。
那我最後有沒有把設定留在 1000?有。理由是:重啟次數減少約 3 倍,而記憶體上限的成本(worker 多活一段時間多累積的記憶體)對 12 GB 的機器可以忽略,沒有理由不保留。但我也很清楚,這是一個「無害的小優化」,而不是「解決瓶頸」。如果沒有重測,我很可能會在文章裡寫「調大 max-requests 大幅提升效能」,那就是在誤導讀者,也誤導未來的自己。
監控面板為什麼沒看出來
壓測結束後,我打開 Grafana 首頁想看看這幾分鐘的衝擊,結果有點錯愕:首頁看起來一點都不忙。明明主機 CPU idle 掉到 0%、load 衝到 17.8,面板上卻只是一條不起眼的小突起。
我花了一些時間逐項拆,發現是四個原因疊加:
- CPU 面板(主機層級)用
rate(...[5m]):2 分鐘的滿載被 5 分鐘窗口平均稀釋,最高只顯示約 53%。 - Stat 大數字顯示的是「現在」:壓測結束後數字就掉回去了,沒有任何「剛剛發生過什麼」的資訊。
- 時間範圍是 6 小時:2 分鐘的尖峰在 6 小時的橫軸上只剩一條細線。
- 首頁根本沒有請求量面板:沒有 req/s,就無從知道流量變化。
最有說服力的是同一時間不同窗口的對照:
| 查詢 | 壓測期間的顯示值 |
|---|---|
rate(...[5m]) CPU |
最高約 53% |
rate(...[1m]) CPU |
96–99% |
| nginx 請求量(Prometheus 實測) | 288–315 req/s |
同一份資料、同一段時間,只因為窗口大小不同,看起來一個是「半閒」,一個是「爆滿」。這不是 Prometheus 的 bug,是 rate() 的本質:它計算的是窗口內的平均增加速率,窗口越長,短事件越被稀釋。
我做的修正
CPU 面板改用 2 分鐘窗口。我的 scrape 間隔是 30 秒,窗口至少要涵蓋 4 倍的 scrape 間隔,rate() 才算得穩,所以 2m 是兼顧靈敏度與穩定的折衷:
# 主機 CPU 使用率 stat(原本是 [5m],改成 [2m];scrape interval = 30s)
100 - avg(rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100
另外新增一個請求量面板,使用 Grafana 內建的 $__rate_interval,讓窗口隨時間範圍自動調整:
# 請求量(req/s)
sum(rate(nginx_http_requests_total[$__rate_interval]))
最後是一個操作習慣:想看短事件,就把時間範圍調成 Last 1 hour,不要停在 6 小時。這三件事做完,再回頭看那兩分鐘的壓測,尖峰就清清楚楚了。
這個坑的教訓是:面板「看起來正常」不代表系統正常。如果我沒有另外做壓測,也不會發現監控會把短暫的飽和平均掉。真正的線上事故往往就是這種幾分鐘的尖峰。
容量規劃:把上限換算成可用的水位
壓測的最終用途是容量規劃,所以我也把這個數字轉成一個比較實用的判斷標準。上限約 300 req/s,但我不會把它當成可以放心跑到的水位:
- 同機的其他服務也要空間:這 300 req/s 是在其他服務幾乎閒置時量到的,真實環境裡它們也會用 CPU。
- 真實端點更重:這是最輕的端點,一般業務端點的上限會低很多。
所以我給自己訂的規則是:輕量端點的穩態流量如果持續超過上限的一半,就該開始考慮擴充;到了接近上限才處理,就已經是在救火。這個「一半」的數字是我的保守經驗法則,不是從這次壓測推導出來的,你可以依自己對延遲的容忍度調整。
壓測守則
把這一輪做下來的經驗整理成幾條守則,下次壓測我會先對照一遍:
- 挑離峰時段,並先通知相關的人。即使有 abortOnFail,保護機制也只能「事後止損」,不是「完全無影響」。
- 一定要設 abortOnFail。壓測正式環境時,thresholds 就是你的安全閥。失敗率與 p95 兩個門檻都要設,缺一個都可能漏接。
- 壓測工具不要跟服務同機。這次我為了排除網路因素把 k6 放在同一台,我沒有量 k6 的 CPU,但 app-b-web 只拿到約 1 核、每次加 VU 就掉到 50–60%,推測 k6 自己也在搶 CPU,所以這次的 300 req/s 很可能是偏保守的值。最理想是用同一個內網的另一台機器發壓。
- 同時採集伺服器指標。只看 k6 的延遲報告,你只知道「慢」,不知道「為什麼慢」。取樣腳本很簡單,卻是整個分析的依據。
- 分清楚「輕量端點」與「真實端點」。輕量端點量到的是框架上限,真實端點會被資料庫、快取、外部 API 拖慢。兩種都要測,而且不要把前者的數字當成容量承諾。
- 一次只改一個變數,並重測驗證。max-requests 的實驗就是最好的例子,沒有重測就會得到錯誤結論。
- 同一組測試至少跑兩次。第二輪我跑了兩次,吞吐量只差約 2%,才敢說這是系統的特性。
- 監控本身也要驗證。壓測順便驗證一下,你的面板能不能在負載飆高時真的反映出來。
小結
回到開頭那個問題:這台 2 核 ARM VM 跑 Laravel Octane(FrankenPHP,2 workers),在輕量 API 上的真實上限大約是 300 req/s,瓶頸是 CPU,超過之後不會出錯,只會讓延遲排隊變長。這個數字是在壓測工具與其他服務同機的情況下量到的,所以偏保守。
這次實驗我自己覺得最大的收穫有三個:
- 第一輪量到的往往是網路。從外面打、延遲平平的,不代表伺服器強,可能只是還沒打到它。
- 假設必須用量測驗證。max-requests 從 300 改到 1000,數字幾乎沒動,我預期的提升完全沒有出現。能被數據推翻的假設,才是有價值的假設 ✅
- 監控的窗口大小會騙人。5 分鐘窗口把 2 分鐘的滿載稀釋成 53%,看短事件要用短窗口。
如果你也有一台小 VM 上跑著 Laravel,我建議找個離峰的晚上,拿 k6 跑一次,並且一邊記下伺服器指標。花不到一小時,換來的是一組你自己環境的真實數字,比任何「應該夠用」的感覺都可靠。
系列文章:自架 VM 維運實戰
保護 → 部署 → 觀測 → 調校,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限(本篇)
No comments yet.