問題

一支批次驅動逐家跑壓測腳本,用退出碼判斷成敗。12 家後端 × 5 支腳本跑完,多數是 0。實際打開每份 log 的結果表才發現:有一家的資料表根本沒建、有一家 user-crud 的錯誤率是 52.9%、有兩家的 user-crud 根本沒產出任何一列結果——退出碼全是 0。

改成解析每份 log 的結果表(Err%、Check%、WebSocket/SSE 成功率)之後,這些才被抓出來。

七種形態

#形態為什麼退出碼騙人怎麼抓
1跑了但沒產出腳本照流程走完、收棧、印「完成!」,只是中間那次 run 沒有資料判讀看結果表的列數,零列就算失敗
2產出了但全紅腳本把 k6 的門檻失敗當成「有資料就繼續」判讀看 Err%/Check%,不看退出碼
3pipefail 加 grep | head -1head 讀到第一行就結束,grep 還在寫就收到 SIGPIPE,整條管線失敗,set -e 直接結束腳本,一個字都不印;偶發,看 grep 有沒有剛好多寫一行取第一筆的管線一律補 || true;我們的 shellcheck 沒有對這個寫法報警告
4錯誤訊息被 $(...) 吃掉id=$(create_project) 裡面的 log_err 寫到 stdout,被變數接走,exit 1 只結束子殼層,外層 set -e 再結束——畫面上什麼都沒有錯誤訊息寫到 stderr;回傳值與訊息分開
5逾時只砍到外層timeout 砍掉包裝程式、回 124,底下的 bash 與 k6 繼續跑到自己結束,下一支在它還沒收完時就開始子程序開新的程序群組,逾時砍整個群組
6清單寫死,新成員落空每支腳本的 case 只列當初那幾家,新加的四家走不進任何分支,migration、種子、改密碼都沒做,後面的步驟全部是在空 DB 上跑清單改讀單一來源(對接矩陣),沒列到的明確報錯而不是靜默跳過
7共用資料格式不對把整組帳密當 body 送出,某一家嚴格拒絕多餘欄位照該家的樣板組 body

每一種是怎麼撞到的

形態 3 的寫法長這樣:VAR=$(docker compose ps … | grep … | head -1),在 set -euo pipefail 的腳本裡有 36 處。rails 的 queue 腳本、django 的 redis-onoff 腳本都是在設完連線上限之後就無聲無息地結束,補上 || true 之後消失。

機制拆開來是三條 Bash 規則疊在一起:

  • 讀取端(head)關掉之後,寫入端(grep)再寫就會收到 SIGPIPE(pipe(7)),被訊號 N 結束的指令,Bash 記為退出碼 128+N(bash(1):EXIT STATUS)。
  • 沒開 pipefail 時,管線的退出碼是最後一個指令的;開了之後,變成最後一個以非零結束的指令的退出碼(GNU Bash manual: Pipelines)。所以 head 成功也沒用,grep 的 SIGPIPE 會讓整條管線非零。
  • 一行只有變數指派、沒有指令名稱時,它的退出碼就是最後一個命令替換的退出碼(bash(1):SIMPLE COMMAND EXPANSION),於是 set -e 在這一行結束整支腳本。

「偶發」是因為 grep 只有在 head 關掉之後還要再寫,才會撞上 SIGPIPE;輸出夠短、一次寫完的時候就不會出事。

形態 4 的觸發點是一個已經修好的寫死設定:三支腳本(ws-sse、queue、file-upload)原本把平台 token 寫死成 changeme,API 伺服器改成「缺 AUTH_TOKEN 就 fail-fast」之後,建 Project 就失敗了。但 log_err 寫在 $(create_project) 裡面被吃掉,go 那家的 ws-sse 沒有任何錯誤訊息就結束。命令替換是在子殼層環境執行的(bash(1)),裡面的 exit 1 只結束那個子殼層;它的 stdout 則整份變成變數的值。

順帶一提,Bash 在非 POSIX 模式下,會把命令替換子殼層裡的 -e 清掉,除非開了 inherit_errexit(同上)。這次的函式是自己顯式 exit 1,所以不受影響;但如果函式依賴 set -e 在中途自動停下,在 $(...) 裡面它不會停。

形態 5:驅動用 timeout 1200 包住 Python 包裝程式。timeout 在逾時時回 124(timeout(1)),但實際上只砍到 Python 那層,底下的 bash 加 docker run k6 沒被砍。express-js 的 ws-sse 退出碼是 124,實際卻跑了 3984 秒。修法是 Popen(..., start_new_session=True),讓子程序在新的 session 裡(Python 文件:child 在執行前會呼叫 setsid(),subprocess),逾時時用 killpg 砍整個程序群組。

形態 6:七支腳本裡 update_admin_password 的 case 分支只列了當初那 9 家,後來新增的 rails、phoenix、kotlin-ktor、rust-axum 全部落空。rails 的資料表根本沒建(整個資料庫裡只有 performance_schema 有 users 表);rust-axum 的資料庫裡沒有 admin,手動跑一次 /app/server --seed 之後登入才回 200。

形態 7:queue 與 batch-upload 兩支把整組 admin_credentials(username、email、password)當成登入的 body 送出,rust-axum 多一個欄位就拒絕。改成照對接矩陣裡每家的 login_body 樣板挑欄位。

為什麼這樣解

退出碼回答的是「腳本有沒有走到最後」,不是「測到了沒有」。壓測、驗收這類腳本的成功條件是產物:結果表有列、錯誤率在範圍內、指標有值。所以判讀一定要讀產物,退出碼只能當作「腳本有沒有崩」的輔助。

形態 3 到 5 的共同點是失敗沒有留下訊息。這比失敗本身更糟:失敗會被看見、會被修;沒訊息的失敗會被當成成功,一路帶到結論裡。這跟「測試綠不等於事實成立」是同一族問題。

形態 6 跟「兩種情況都寫」的容錯設定會兩頭落空剛好相反:那篇是萬用寫法兩頭落空,這裡是寫死清單漏掉新成員。兩者都靠「單一事實來源,加上沒命中就報錯」解。

怎麼預防再次發生

  • 批次驅動的判讀函式只讀產物:結果表列數>0、Err% 與 Check% 在門檻內、WS/SSE 成功率>0。退出碼另外記,不參與判定。
  • 腳本開了 set -euo pipefail 就要檢查所有 | head、| grep -q 之類會提早結束讀取端的管線。
  • 錯誤訊息一律寫 stderr,不要放在會被 $(...) 接走的函式裡。
  • 包裝子程序時用新的程序群組,逾時砍整群。
  • 依框架分支的 case 在最後放 *):明確報「這家沒有對應處理」並失敗,不要靜默略過。

相關