Bootstrap 腳本失敗是常態,所以它的設計目標之一應該是「失敗時可診斷」:把失敗當成會發生的事來設計,預先留好定位問題的痕跡。一支自動化安裝腳本要跨越的環境差異很多——機器缺某個工具、套件清單有筆誤、某個指令在這個發行版的行為跟預期不同——任何一處都可能讓它中斷。決定你是「三分鐘看出哪裡錯」還是「對著終端機捲半天瞎猜」的,是這支腳本有沒有在設計時就把可觀測性內建進去,跟運氣無關。

可觀測性要事先設計,是因為失敗發生的當下,你能拿到的資訊就已經定型了。如果腳本只把輸出丟到終端機、失敗時只留下一句通用的錯誤,那當下你就只有那句話可看;如果它一路把帶時間戳的紀錄寫進檔案、失敗時主動印出出錯的位置,那同一個失敗就變得可定位。差別不在失敗本身,在失敗前你準備了什麼。如果你寫的是自己的 bootstrap(例如部署 dotfile 的那支 install.sh),這層要在你第一次跑它之前就設計進去,而不是等它出事才回頭加;就算腳本不是你寫的、你只是來 debug 一次失敗,下一段「找程式自己的 log」一樣適用。

為什麼會瞎找

不可觀測的腳本失敗時,你手上只有終端機捲動過的那些輸出,而那往往不足以定位真正的原因。終端機的輸出是易逝的、會被後續輸出沖掉、多個來源的訊息交錯在一起;更麻煩的是,很多失敗的「表面錯誤」離「真正原因」隔了好幾層。一個指令因為前面某個變數是空的而失敗,但它報出來的錯可能完全沒提到那個空變數——你看著一個誤導性的症狀,往上游找不到源頭。

破解這種瞎找的,常常是一份你一開始沒看的 log。很多程式在終端機只印一段摘要,卻同時把詳細的執行紀錄寫進一個 log 檔;當終端機的訊息不足以定位時,那份程式自己寫的 log 裡往往就有答案。除錯時養成「找程式自己的 log,而不是只盯著終端機捲動」的習慣,是把瞎找變成定位的關鍵一步——這也是 模組七日誌判讀 的核心。而對你自己寫的 bootstrap,你可以更進一步:在設計時就讓它產生這樣一份 log。

三個內建可觀測性的手法

讓一支 bootstrap 腳本可診斷,有三個低成本、效果明顯的手法,它們合起來把「失敗了」變成「失敗在第幾行、哪個指令、什麼狀態」。

log 落地:把全部輸出 tee 進帶時間戳的檔案

第一個手法是讓腳本的全部輸出同時進終端機跟一個 log 檔,而不是只進終端機。終端機的捲動是易逝的,log 檔是持久的——可以事後 grep、可以貼給別人看、可以比對前後兩次跑的差異。在 bash 裡,一行 exec 就能把後續所有 stdout 與 stderr 都導去 tee

1LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles"
2mkdir -p "$LOG_DIR"
3LOG_FILE="$LOG_DIR/install-$(date +%Y%m%d-%H%M%S).log"
4exec > >(tee -a "$LOG_FILE") 2>&1

帶時間戳的檔名讓每次跑各留一份、不互相覆蓋,事後可以回溯「上一次成功跟這次失敗差在哪」。log 檔放在 XDG_STATE_HOME(狀態資料的標準位置)底下,符合慣例、也不污染家目錄。

錯誤定位:用 ERR trap 印出出錯的行與指令

第二個手法是讓腳本在中斷的瞬間,主動報出「是哪一行、哪個指令、什麼結束碼」失敗的。配合 set -e(出錯即停)的腳本,預設只會默默地停,不告訴你停在哪。加一個 ERR trap,就能在 set -e 中斷之前先印出定位資訊:

1set -Eeuo pipefail   # -E 讓 ERR trap 在函式/子 shell 也生效
2trap 'log "ERROR line $LINENO: [$BASH_COMMAND] exit=$?"' ERR

$LINENO 是出錯的行號、$BASH_COMMAND 是當下正在執行的那條指令、$? 是它的結束碼。三者合起來,輸出會長這樣:

1[00:06:51] ERROR line 40: [sudo pacman -S --needed stow git zsh] exit=1

範例裡的 pacman 換發行版會不同,這裡只是示意 trap 輸出的格式——手法本身(行號 + 指令 + 結束碼)跟發行版無關。這一行直接點名元兇。前面提過的那類「表面錯誤離真正原因隔好幾層」的情況——例如某個指令因為 which 不存在而拿到空字串、最後報一個看似無關的錯——有了這行,你會直接看到是哪一行的哪條指令掛了,不必從誤導性的症狀往回猜。set -E-E 旗標)是為了讓 trap 在函式跟子 shell 裡也照樣觸發,少了它,包在函式裡的錯誤會漏掉。

步驟標記:用帶時間戳的 log 函式標出進度

第三個手法是在關鍵步驟前印一行帶時間戳的標記,讓你能看出腳本跑到哪、哪一步慢。一個極簡的 log 函式就夠:

1log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
2
3log "install.sh start | OS=$OS"
4log "Installing base packages..."
5log "Stowing configs..."

時間戳的價值在於它同時給你「進度」跟「效能」兩種資訊:失敗時,最後一行成功的 log 告訴你它跨過了哪些步驟、卡在哪一步之後;正常時,相鄰兩行的時間差告訴你哪一步耗時最久。這比沒有標記、只能從一堆套件下載輸出裡猜「現在到底在幹嘛」清楚得多。

失敗可診斷是設計目標

把這三個手法合起來,一支原本「失敗時只留一句通用錯誤」的腳本,會變成「每次跑都留一份完整 log、失敗時直接點名第幾行哪個指令、過程中每步都有時間戳」。成本是腳本開頭多幾行,回報是把未來每一次除錯從瞎找變成定位。這層可觀測性是 模組八 bootstrap script 設計 的延伸——那篇給安裝腳本的骨架與套件清單,這篇給它加上失敗時的診斷能力,兩篇處理的是同一支腳本的兩個層面。

這是設計階段的決定,不是事後能補的。當一支沒有可觀測性的腳本在一台陌生機器上失敗,你沒辦法回到過去讓它記錄當時的狀態——資訊在失敗的瞬間就已經流失了。所以「失敗可診斷」要跟功能一起設計進去,把它當成 bootstrap 的基本屬性,而不是出事之後才想加的補丁。

回到系列

這幾篇合起來,是把一台機器從「空的」帶到「能接收 dotfile、且部署過程可診斷」的完整地基:安裝選項判讀 處理 OS 怎麼裝、工具驗證與補足 處理裝完缺什麼、外部連入與無 key bootstrap 處理怎麼連進去把 dotfile 弄進來,這一篇處理當部署失敗時怎麼快速看出原因。再往前一步,把這套地基用在無人值守的長任務上、讓機器在你離開後自己跑完工作,見 讓機器跑無人值守的長任務——無人盯著的任務尤其依賴這篇談的可觀測性。地基打好,後面 模組一到八 的 dotfile 管理才有立足點。