包包baobaolin.com
date
entry
020
topic
tooling
rev

部署腳本不要用 set -e

set -euo pipefail 是好習慣,但它建立在一個假設上:出錯的時候,停下來比繼續走安全。部署腳本剛好是這個假設不成立的地方。

先說 set -e 為什麼通常是對的。多數腳本是「一連串各自獨立的計算」,前一步失敗還硬往下走,只會拿錯的輸入產生錯的輸出。這種情況下中止是明智的。

部署不是這種形狀。部署是一連串對外部世界的修改,而且這些修改有順序、有相依。在這裡,「做到一半停下來」不是安全狀態——它是一個沒有人知道的壞掉狀態。

我踩過的那一次

這個站的部署有一個必要的收尾步驟:把每個 index.html 額外寫進目錄 key。因為那些 key 在本機不是檔案,每次 sync --delete 都會把它們刪掉,所以順序固定是「先 sync,再補 key,然後 invalidation,最後驗證」。

那次的狀況是:

  1. sync --delete 完成——上一輪補的目錄 key 全部被刪掉了
  2. 補 key 的迴圈跑到一半,AWS session 過期
  3. set -e 生效,腳本立刻中止
  4. 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 的價值在於「還沒造成外部影響」的階段;過了那條線,它保護的東西就變成它破壞的東西。 找出腳本裡第一次寫入線上的那一行,在它之前用中止,在它之後用計數。

修訂紀錄

  1. 首次發布