Shell script 的執行參數

來聊聊 shell script 的 `set` 參數們

在寫 shell script 的時候部分預設行為可能會跟想像有差異,如果沒有適當的設置參數就會發生「腳本正確地失敗了」的窘境。

1 ON_d7DWgW8g8uu3EBntfNw.webp

最佳實踐

Bash strict mode 一文中有建議到我們可以在所有 bash 腳本中設置:

set -euxo pipefail

Shell Script Best Practices 文中則建議了:

set -o errexit
set -o nounset
set -o pipefail

兩者建議的參數十分接近,但直觀上又不好對應兩種寫法的差異,查了下文件、嘗試玩一下各種指令然後來做個紀錄。

參數清單

這裡大多數的選項是 sh 就有的,如果是 bash 特有的功能則會另外說明。另外這個清單裡面不放入其他 shell 的參數,考量為這兩個以外的 shell 在可移植性方面會顯著的變差。

-o allexport / -a

所有的變數皆被賦予 export 屬性,即設為環境變數之一,讓其他處理指令可以利用。

舉例來說,FOO 在這裡只是一個腳本自己的變數,在下面這個例子中輸出會是 None

#! /bin/sh
FOO="hello"
python3 -c 'import os; print(os.getenv("FOO"))'
hello

但如果有設置 -a,則 FOO 就會變成一個環境變數,故輸出會變成 hello

-o notify / -b

Cause the status of terminated background jobs to be reported immediately, rather than before printing the next primary prompt.

(讓背景工作的狀態被即時回報)

試玩了一下,看起來跟用 & 把某個指令丟去背景執緒這個操作無關(嗎),我還沒搞懂就先不要妖言惑眾了。

-o errexit / -e

在某一指令出錯的時候終止腳本。

sh 的設計不同於 python 、 java script 等語言,沒有 exception 來強制終止執行緒。我們僅能利用 exit code 去解讀是否有正確地執行,但逐指令去檢查 $? 實在是勞民傷財。故可以透過設置這個選項讓腳本在某個指令回傳不為 0 的時候主動終止。

設想今天有一腳本中間有發生過錯誤:

#! /bin/sh
false
echo 'pass'
pass

在預設況下它是會成功的,因為最後一個指令的 echo 回傳結果為 0。但如果有設置 -e,則在 false 那裡就會發生終止。

注意到一個情況,如果某個指令中間有做管道(pipe),則該指令的成功與否還是只看最後一個程式。如果需要對管道階段中的其他程式做檢查,則需要 pipefail(後面說明)。

-o noglob / -f

停用 glob 行為;即 * 從此就只是個星星符號。

#! /bin/sh
set -f
echo *
*

在上面的例子中這段腳本只會輸出一個 *,如果把 -f 拿掉的話 glob 會發生在 argv 被建立之前,故會輸出當前目錄下的所有檔案、資料夾名稱。

-o hashall / -h

將所使用的工具程式、指令的路徑(path)在腳本啟動時即記錄起來(建進雜湊表中);若未設置,則這些工具程式的路徑是在該工具初次被調度的時候記憶。

如在 bash 中則這個指令預設開啟。

理解上用於解決在腳本開跑後、還沒跑到某個指令的過程中有同名的程式出現在 $PATH 中較優先的順位,然後影響腳本結果。聽起來像是某種在多人共用的伺服器上互相惡搞的過程。

-o keyword / -k

將函數參數設為環境變數。

在下面這個範例中我們呼叫了 foo 兩次,而第一次是在有 set -k 的情況下執行,然後就造成了兩次的輸出內容不相同。

#! /bin/sh
foo() {
echo "BAR=$BAR"
}
foo BAR="hello"
set -k; foo BAR="hello"
BAR=
BAR=hello

POSIX 文件中也有記錄到這個選項,但文件作者顧慮 set -k 的存在會讓一些函數的行為發生變化,可能進一步地導致不預期的錯誤,故其建議永遠不要使用 -k

-o monitor / -m

讓分支出去的工作在獨立的行程(process)下執行,而不是成為當前行程的子行程。這個指令在 POSIX 文件端有做記錄,但不是強制要求實作;在 bash 端則是對應成將 job control 功能開啟。

-o noexec / -n

假運行(dry run)。shell 應讀完整支腳本、解析但不運行,可用於檢查有沒有語法錯誤。

-o

-o 若不帶參數使用,則會將所有的設定印出到 stdout 上;若帶有參數,則就是很多選項有看到的、比較長的設定格式。

+o

類似於 -o :會將所有的設定印出到 stdout。不過使用 +o 時輸出格式會是適合直接輸入給其他腳本再利用的格式。如:

$ set +o
set +o allexport
set -o braceexpand
set +o emacs
set +o errexit

-o privileged / -p [bash only]

啟用 bash privileged mode。

在這個模式下,環境設置 $BASH_ENV$ENV 會被無視,並且 bash 會以腳本所有者的身份去執行該腳本,這個行爲近似 setuid

在實例上,這個參數多用於系統啟動階段,讓初始化腳本能使用各自的權限去工作、而不是使用到呼叫他們起床的那個 root。但相對地,考慮到系統的腳本中有不少都是由 root 持有,濫用這個參數反而可能造成權限過大而成為一種漏洞。

-r [bash only]

啟用 restrict shell mode

在這個模式下,腳本能做的事會被限制,包括不能變動當前執行路徑、不能直接使用絕對路徑呼叫指令、不能設置 $PATH$SHELL 等。

-o onecmd / -t

Exit after reading and executing one command.

(在讀取及執行一個指令後就結束)

沒看懂實際應用情境就先原文丟著這樣。

這也是一個在 POSIX 文件後被嘴一波說很沒用的設置;不過它也說到這個設置會導致同一指令在 sh 下跟在 ksh 下可能會有不同的執行結果,故不建議使用。

-o nounset / -u

對於未設置的變數、及特殊變數如(@, *)視為一種錯誤,並立即終止腳本。

shell 腳本中不強制要求我們去將變數是先定義過,當我們想使用某個未被設置的變數時,其預設會讀到空字串。而這個未設置的變數在一些情況下,如打錯變數名稱,便是常見的錯誤來源。透過設置 -u 可以去避免掉這種問題:

#! /bin/sh
set -u
echo "$HELLO"
./demo.sh: line 3: HELLO: unbound variable

通常會建議 -u 就永遠開著,用於避免一些錯誤的發生;而在某些情境下,我們會允許使用者用特定環境變數來當作設置、以微調腳本的行為,這時候其實可以透過對變數設置預設值來繞過 -u 帶來的限制:

#! /bin/sh
set -u
echo "${HELLO:-hello}"
hello

-o verbose / -v

把輸入的指令複述到 stderr。

#! /bin/sh
set -v
echo foo="${bar:-hello}"
echo foo="${bar:-hello}"
foo=hello

-o xtrace / -x

將輸入的指令在參數拓展完成、執行之前輸出到 stderr。

直接用 -v 的範例來跑一次:

#! /bin/sh
set -x
echo foo="${bar:-hello}"
+ echo foo=hello
foo=hello

在〈最佳實踐〉段落中有一個文章建議了設置 -x;而筆者認為看使用情境而定,在部分的腳本中你可能已經做了很完善的日誌記錄、或是製作了很漂亮的進度回報,那此時 -x 的輸出就會擾亂使用者閱讀。

反之,如果只是某些「反正就這些指令,去給我跑完」的情境,如 CI 或 docker build,那麼設個 -x 則會有助於偵錯。

-o braceexpand / -B [bash only]

啟用 bash 的 brace expansion 功能。預設開啟。

-o noclobber / -C

禁止輸出重導向行為(如 >, >&<>)去覆寫掉存在的檔案。

-o errtrace / -E [bash only]

trap ERR 的行為可以套用到子函數:

#! /usr/bin/env bash
set -E
foo() {
false
echo "done"
}
trap 'echo Failed on line $LINENO' ERR
foo
Failed on line 3
done

另外這個選項十分建議跟 -e 一起搭配服用。

-o histexpand / -H [bash only]

啟用 History Expansion 功能中以 ! 符號進行歷史紀錄代換這個項目。

-o physical / -P [bash only]

對於設置工作路徑的的指令(如 cd),不要去解析符號連結(symbolic link)所對應的路徑,而是使用其實際路徑。

舉例而言,macOS 機器上的 /tmp 路徑其實際路徑為 /private/tmp。我們可以用這個路徑去比較兩者的差異:

$ cd /tmp
$ cd ..; echo $PWD
/
$ cd /tmp
$ set -P; cd ..; echo $PWD
/private

參考資料