一、第五次手動跑同一個測試的時候
Day 22 結尾留了一個伏筆:量尺要「固定地量」,才會從守門員變成量尺。但固定地量意味著重複——同一個 smoke 腳本、同一組 thresholds、每次部署後跑一次。前兩次你會覺得很順,第三次開始覺得自己像個按鈕,第五次的時候,你會問:這件事為什麼要我來按?
那一刻就是自動化的時機。不是更早——在你還在調腳本、改門檻、換環境的階段,自動化只會把還沒穩定的東西鎖死;也不是更晚——每多一次手動跑,就多一次「今天太忙先跳過」的機會,而跳過的那一次,往往正是出事的那一次。
這篇的定位要說清楚:不是每個團隊都需要把效能測試進 CI。一年發版三次的系統,手動跑完全合理;每天合併十次的團隊,不自動化才是問題。判斷的依據不是「大家都這樣做」,是你有沒有已經手動重複了五次。有,往下看;沒有,把這篇留著,你會回來的。
先看全貌——今天要搭的東西長這樣:
圖 1:部署後自動跑 smoke——threshold 是裁判,紅綠燈由它決定,本機不參與
二、為什麼是 smoke,而且只有 smoke
Day 12 學過六種負載模型,進 CI 的只有一種:smoke。理由有三個,每一個都對應前面某天的原則。
第一,時間。CI 的每一步都在等,一條 pipeline 多五分鐘,一天合併十次就是五十分鐘。Smoke 測試 1–3 分鐘、3–5 個 VU,是唯一放進去不會被大家想辦法關掉的東西。
第二,環境。Day 6 的五問在 CI 裡一樣成立——而且更嚴格,因為按下執行的不再是你,是每一次部署。你不在場、沒有人在看、也沒有人能在出事時按 Ctrl+C。一個沒人看著的 load test 打在共用環境上,就是 Day 6 那個「把測試環境打掛、影響其他團隊」的職場事故,只是自動化版。Smoke 的流量小到不需要在場。
第三,判讀。Load、stress、soak 的價值在曲線與判讀(Day 18–21),那需要人;smoke 的價值在紅綠燈,這正好是機器擅長的。Day 7 說 threshold 是裁判,今天就是把裁判請進 pipeline:過了就綠、沒過就紅,不需要任何人看數字。
所以分層是這樣:smoke 進 CI,每次部署後跑;load 用排程或手動,每週或每次大版本,人要在場看曲線;stress、spike、soak、breakpoint 只手動,而且走完 Day 6 的五問再按。

三、搭起來:一份 workflow,本機不裝任何東西
GitHub Actions 的做法是在 repo 裡放一個 YAML 檔,GitHub 讀到它就照著做。跑 k6 的機器是 GitHub 的(叫 runner),k6 裝在那台機器上,跑完就回收——所以本機不用裝任何東西,只要你有 GitHub 帳號、repo 裡有 Day 14 整理好的腳本。
照系列的老規矩,YAML 不用背,讓 Claude Code 生成。但生成之前,你得先講清楚五件事,和 Day 4 的五要素是一樣的精神:
Prompt 1|生成 GitHub Actions workflow
請幫我寫一個 GitHub Actions workflow(.github/workflows/perf-smoke.yml),需求:
- 觸發:可以手動執行(workflow_dispatch),也可以每天早上 9 點(台灣時間)自動跑
- 步驟:取得 repo、安裝 k6(請用 Grafana 官方的 setup-k6 action,並確認目前的版本寫法)、
執行 tests/smoke.js,最後不論成敗都把 summary.json 上傳成 artifact
- 目標網址從 GitHub Secrets 讀(BASE_URL),不要寫死在 YAML 裡
- 腳本用 3 VU 跑 1 分鐘(QuickPizza 練習站的小流量禮儀),thresholds 沿用腳本裡的設定
- 要求 k6 的 threshold 沒過時,這個 job 要顯示失敗(紅色)
生成後請逐段解釋每一段在做什麼,並告訴我哪一行決定了「threshold 沒過就標紅」。
生成的內容大致長這樣(版本號與細節以 Claude Code 查到的最新寫法為準):
# .github/workflows/perf-smoke.yml
name: perf-smoke
on:
workflow_dispatch: # 手動按鈕
schedule:
- cron: "0 1 * * *" # UTC 01:00 = 台灣 09:00
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Run smoke test
run: k6 run tests/smoke.js --summary-export=summary.json
env:
BASE_URL: ${{ secrets.BASE_URL }}
- name: Upload summary
if: always() # 紅燈也要留證據
uses: actions/upload-artifact@v4
with:
name: k6-summary
path: summary.json
回答 Prompt 最後那個問題:決定「threshold 沒過就標紅」的,不是 YAML 裡任何一行,是 k6 自己。threshold 失敗時 k6 以非零代碼結束,GitHub Actions 看到非零就把這一步標成失敗——Day 7 的裁判本來就會宣判,只是以前宣判的對象是你的終端,現在是 pipeline。這也是為什麼 k6 適合進 CI:它從第一天就是為此設計的。
三個必須自己動手的地方,Claude Code 幫不了你:第一,Secrets。到 repo 的 Settings → Secrets and variables → Actions,新增 BASE_URL,值是目標網址。Day 2、Day 8 講過機密不能進腳本,YAML 也一樣——而且 YAML 是公開在 repo 裡的,寫死網址等於昭告天下你在壓誰。第二,腳本要讀環境變數。smoke.js 裡的目標網址要寫成 __ENV.BASE_URL,Day 14 已經做過;沒做的今天補。第三,把檔案 commit 並推上去。Actions 只認 repo 裡的檔案。
推上去之後,到 repo 的 Actions 頁籤,找到 perf-smoke,按 Run workflow。一兩分鐘後,你會看到一個綠色勾,點進去看 log——那是你熟悉的 k6 終端輸出,只是跑在別人的機器上。
四、部署後接著跑:真正的「自動把關」
手動按鈕和每日排程是起步,但總覽說的是「部署後自動跑」。做法是把觸發條件改成:某個部署 workflow 成功結束後,接著跑 smoke。
on:
workflow_run:
workflows: ["deploy-staging"] # 你們部署 workflow 的名字
types: [completed]
jobs:
smoke:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
# ...其餘同上
這裡有兩件事必須先問人,不能自己決定。問 RD 或負責 pipeline 的人:你們部署 staging 的 workflow 叫什麼名字?部署完成到服務真的可以接請求,中間有沒有延遲(有的話 smoke 要先 sleep 或先打健康檢查端點)?問團隊:smoke 紅了要不要擋住後續的部署到正式環境?這是一個組織決定,不是技術決定——Day 22 講過,量尺的回饋「像單元測試紅燈一樣,是資訊不是指控」;但要不要把紅燈接成閘門,得團隊同意。建議的順序:先只示警(紅燈+通知),跑穩兩三週、確認沒有誤判之後,再談要不要擋。
示警要有人看到才算數。最簡單的做法是 GitHub 本身的通知(workflow 失敗會寄信給觸發者);團隊有 Slack 或 Teams 的話,請 Claude Code 加一步「失敗時發訊息到頻道」——這一步同樣要 webhook 放 Secrets。訊息內容照 Day 22 的標題格式:端點、指標、門檻,一行看完。
五、CI 專用的禮儀與陷阱
測試跑到別人的機器上,有幾件事和本機不一樣,每一件都是曾經有人踩過的坑。
runner 不是你的機器。GitHub 的 runner 規格普通、網路在別的國家、每次都是全新的乾淨環境。所以:數字會和本機不同,不要把 CI 的數字拿來和本機比,只和 CI 自己前幾次比;門檻也可能需要依 CI 環境重新校準(Day 16 的「有依據的數字」在這裡要重新有依據一次);而 Day 19 的施壓端陷阱在 runner 上一樣存在,只是你看不到它的 CPU——smoke 流量小,這通常不是問題,這也是 load 不進 CI 的另一個理由。
出口 IP 不是你的。GitHub runner 的請求來自 GitHub 的 IP 範圍。目標環境如果有白名單、WAF 或地區限制,第一次跑一定會被擋——這不是系統慢,是你被當成陌生人。先跟基礎設施的人確認,必要時改用 self-hosted runner(自己的機器掛進 GitHub,這條路 Claude Code 也能帶你走,但本系列不展開)。
紅燈不等於系統壞了。Day 7 講過:threshold 失敗時先問三件事——門檻合理嗎、環境正常嗎、然後才是系統有問題嗎。進了 CI,這三問要加一問:runner 正常嗎?GitHub 偶爾抽風、網路偶爾抖動,一次紅燈不足以開單。合理的規則:同一條件連紅兩次才算數,或紅燈後自動重跑一次。這也是為什麼先示警、後擋門。
機密與流量禮儀。Secrets 已經講了;流量禮儀要再說一次:schedule 觸發的測試會在你睡覺時跑,對象如果是共用環境,要在 Day 6 的告知裡寫明「每天 09:00 會有一分鐘 3 VU 的自動 smoke」;對象如果是 QuickPizza 練習站,每天一分鐘小流量是可以接受的練習,但不要把排程開成每小時——練習站不是你的環境。

給 RD 的一句話:當 pipeline 多了一步 perf-smoke,請把它當成另一種單元測試:紅了先看 log 裡哪條 threshold 沒過,再看是不是 runner 的問題。它跑的是 3 VU 一分鐘,不會拖慢你的合併;它抓的是「這次改動讓某個端點慢了一倍」這種你自己也想第一時間知道的事。
六、動手做:讓它自己跑一次
練習一:手動按鈕版。用 Prompt 1 生成 workflow,設定 BASE_URL 的 Secret(練習用 QuickPizza 的網址),確認 smoke.js 讀的是 __ENV.BASE_URL,推上去,到 Actions 頁籤按 Run workflow。看到綠勾後,下載 artifact 裡的 summary.json,用 Day 5 的方式讀一遍——注意數字和你本機跑的差多少,這就是「CI 數字只和 CI 比」的第一手體會。
練習二:故意弄紅。延續 Day 7 的老規矩:把 smoke.js 的 p95 門檻暫時改成 10ms,推上去再跑一次。你會看到紅色叉、看到 k6 的非零結束碼、看到 artifact 仍然被上傳(因為 if: always())。然後把門檻改回來——這一輪的目的是親眼確認「裁判真的會宣判」,以及紅燈時證據還在。
練習三:加上通知,並寫下擋門規則。請 Claude Code 加一步失敗通知(沒有 Slack 就用 GitHub 的信件通知即可),然後用一段話寫下你打算提給團隊的規則:什麼情況算真的紅、紅了誰收到、要不要擋部署、什麼時候重新檢討。這段話就是下週和 RD 開會時的提案——Day 22 講的「先給對方看一眼」,這裡一樣適用。
七、觀念驗證:三個問題確認你有帶走今天的重點
• 為什麼只有 smoke 進 CI?三個理由各自對應前面哪一天的原則?(第二節)
• 「threshold 沒過就標紅」是由 YAML 的哪一行決定的?如果答案是「沒有那一行」,那是誰決定的?(第三節)
• CI 上的紅燈,Day 7 的三問要加上哪一問?為什麼建議「先示警、再擋門」?(第五節)
八、小結
第五次手動跑同一個 smoke 的時候,自動化的時機就到了。今天搭的東西很小:一個 YAML、一個 Secret、一個讀環境變數的腳本,測試跑在 GitHub 的機器上,本機一樣不裝任何東西。只有 smoke 進 CI——快、小流量、只需要紅綠燈,而裁判是 k6 自己,不是 YAML。部署後接著跑、失敗就通知,先示警、跑穩再談擋門;CI 的數字只和 CI 比,紅燈要連紅兩次才算數。至此第三階段結束:從訂需求、看環境、判讀、除陷阱、視覺化、寫報告、開單,到讓 smoke 自己把關——判讀與溝通的整套能力都在你手上了。明天進入第四階段,回到那條持續爬升的曲線:Soak Test,從 k6 端的數據推測伺服器在漏什麼——Day 24 見。
附錄:perf-smoke.yml 完整範本(含部署後觸發與失敗通知)
name: perf-smoke
on:
workflow_dispatch:
schedule:
- cron: "0 1 * * *" # UTC 01:00 = 台灣 09:00
workflow_run:
workflows: ["deploy-staging"] # 改成你們的部署 workflow 名稱
types: [completed]
jobs:
smoke:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Wait for service (健康檢查)
run: curl --retry 10 --retry-delay 6 --retry-all-errors -fsS "$BASE_URL/healthz" > /dev/null || true
env:
BASE_URL: ${{ secrets.BASE_URL }}
- name: Run smoke test
run: |
K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.html \
k6 run tests/smoke.js --summary-export=summary.json
env:
BASE_URL: ${{ secrets.BASE_URL }}
- name: Upload evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-smoke-${{ github.run_number }}
path: |
summary.json
report.html
- name: Notify on failure
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"perf-smoke 紅燈:run #${{ github.run_number }},請看 artifact 的 summary.json\"}" \
"${{ secrets.SLACK_WEBHOOK }}"
# tests/smoke.js 重點:
# const BASE = __ENV.BASE_URL;
# export const options = { vus: 3, duration: '1m',
# thresholds: { http_req_duration: ['p(95)<800'], http_req_failed: ['rate<0.01'] } };