- date
- entry
- 020
- topic
- tooling
- rev
- —
部署腳本不要用 set -e
set -euo pipefail 是好習慣,但它建立在一個假設上:出錯的時候,停下來比繼續走安全。部署腳本剛好是這個假設不成立的地方。
先說 set -e 為什麼通常是對的。多數腳本是「一連串各自獨立的計算」,前一步失敗還硬往下走,只會拿錯的輸入產生錯的輸出。這種情況下中止是明智的。
部署不是這種形狀。部署是一連串對外部世界的修改,而且這些修改有順序、有相依。在這裡,「做到一半停下來」不是安全狀態——它是一個沒有人知道的壞掉狀態。
我踩過的那一次
這個站的部署有一個必要的收尾步驟:把每個 index.html 額外寫進目錄 key。因為那些 key 在本機不是檔案,每次 sync --delete 都會把它們刪掉,所以順序固定是「先 sync,再補 key,然後 invalidation,最後驗證」。
那次的狀況是:
sync --delete完成——上一輪補的目錄 key 全部被刪掉了- 補 key 的迴圈跑到一半,AWS session 過期
set -e生效,腳本立刻中止- invalidation 沒跑,驗證也沒跑
結果是站上有一批頁面直接 404,而且沒有任何訊號——CI 的紀錄顯示失敗了沒錯,但沒有人在盯著;而唯一會發現內容不對的那一步(全站比對),正好被 set -e 跳過了。
失敗時最需要跑的,往往就是被中止跳過的那幾步。
分界線在哪
把腳本切成兩段,分界點是第一次寫入線上:
-
那之前——身分確認、build、參數檢查。這些步驟失敗時,線上完全沒被動過,中止是最乾淨的結果。這裡該直接
exit - 那之後——sync、補 key、invalidation、驗證。線上已經處於變動中,中止只會讓它停在半路。這裡該記錄失敗、繼續往下跑完
寫出來大致是這樣:
set -uo pipefail # 保留 -u 與 pipefail,拿掉 -e
# ---- 線上尚未變動:失敗就直接停 ----
ACC=$(aws sts get-caller-identity --query Account --output text) || exit 1
[ "$ACC" = "$EXPECTED_ACCOUNT" ] || { echo "帳號不對:$ACC" >&2; exit 1; }
npm run build || exit 1
# ---- 開始寫入線上:失敗就記帳,不中斷 ----
FAIL=0
while IFS= read -r f; do
put_directory_key "$f" || { echo "!! FAILED $f" >&2; FAIL=$((FAIL+1)); }
done < <(find dist -mindepth 2 -name index.html)
[ "$FAIL" -gt 0 ] && echo "警告:$FAIL 個目錄 key 寫入失敗" >&2
aws cloudfront create-invalidation ... # 一定要跑
verify_all_pages # 一定要跑
RC=$?
exit $(( RC != 0 || FAIL > 0 ))
前半段的 || exit 1 是顯式寫的。這比全域 set -e 好,因為它把「這一步失敗要停」變成一個看得見的決定,而不是一個隱形的預設值——讀腳本的人可以直接看出作者在哪裡認為中止是安全的。
繼續跑,但退出碼要誠實
「不中斷」不等於「假裝成功」。上面那個 FAIL 計數的存在,就是為了最後能給出正確的退出碼。
這一點很容易做錯:拿掉 set -e 之後腳本會一路跑到底,如果最後沒有匯總,它會回 0——於是 CI 顯示綠燈,而站上有 404。那比中止還糟,因為中止至少留下了一個紅燈。
所以規則是兩條:過程中不中斷,結束時說實話。
-u 和 pipefail 要留著
拿掉的只有 -e。另外兩個在部署腳本裡反而更重要。
set -u(未定義變數視為錯誤)擋掉的是這種東西:
aws s3 sync dist/ "s3://$BUCKET/$PREFIX" --delete
$PREFIX 如果沒設,這行會變成同步到 bucket 根目錄——加上 --delete,它會刪掉整個 bucket 裡不在 dist 的東西。同樣的錯誤在 rm -rf "$DIR/" 上更有名。
pipefail 則是讓管線中間的失敗不被最後一個指令的成功掩蓋:
aws s3 ls "s3://$BUCKET" | grep -c index.html
# 沒有 pipefail:aws 失敗了,grep 照樣回 0,你拿到「0 個檔案」當成事實
這不只是 shell 的問題
同樣的判斷適用在任何有序的外部修改上。串文發到一半失敗不能重跑整串,付款回調處理到一半不能假裝沒收到。
共通的問題是:外部世界不提供交易,所以「中止」不會把已經做過的事收回去。在這種地方,例外處理的目標不是停止,是讓下一次執行能接得上,以及讓人知道現在停在哪裡。
如果只記得一件事
set -e 的價值在於「還沒造成外部影響」的階段;過了那條線,它保護的東西就變成它破壞的東西。
找出腳本裡第一次寫入線上的那一行,在它之前用中止,在它之後用計數。
修訂紀錄
- 首次發布