凌晨三點跑完的備份腳本,為何 log 是空的?
凌晨三點,排程任務準時把備份腳本拉起來。早上進到辦公室打開 /var/log/backup.log,檔案大小竟然是 0 bytes;信箱裡反而躺著一封塞滿 Python traceback 的通知信。腳本邏輯沒有崩潰,問題出在啟動它的那一行重定向語法:
python3 /opt/backup.py > /var/log/backup.log
這行寫法只將標準輸出接進檔案。當程式拋出例外時,錯誤訊息走的是標準錯誤,依然流向終端或系統郵件通道。想讓兩組通道同時寫入紀錄檔,必須掌握正確的輸出腳本描述符格式。符號看起來只差幾個字符,執行結果卻完全不同。
這裡逐一剖析標準輸出、標準錯誤、自訂檔案描述符的運作邏輯,並整理跨 Shell 移植時務必避開的相容性陷阱。
先搞清 0、1、2 這三個號碼的身分
作業系統核心在建立行程時,會配發一張表格記錄開啟中的檔案關聯,這就是檔案描述符(File Descriptor,簡稱 FD)。前三個整數編號具備約定俗成的用途:
- 0:標準輸入(stdin),預設接聽鍵盤或上游管線資料。
- 1:標準輸出(stdout),正常執行的文字結果皆由此輸出。
- 2:標準錯誤(stderr),例外警告、除錯訊息與進度提示走此通道。
重定向的核心機制是在指令真正被執行前,先修改子行程的檔案描述符對照表。改表動作是由 Shell 率先完成。Shell 由左向右解析重定向設定,每讀到一組設定就即時變更對照表,因此後續語法參照到的永遠是「已被當前步驟改動過的對照狀態」。
此外,> 是 Shell 語法而非指令參數。它觸發開檔與清空檔案的時機比指令執行更早,即使後方指令執行失敗,目標檔案也早已被清空為 0 bytes。
重定向的格式公式:動作 + 目標,前面可加編號
Shell 重定向的通用結構如下:
[描述符編號] 動作 目標
省略編號時,預設對應的是標準輸出(1)。主要動作與行為對應如下:
| 動作語法 | 實際效果 | 目標參數型態 |
|---|---|---|
> |
寫入模式,開啟時立即截斷(清空)檔案 | 檔案路徑 |
>> |
追加寫入,寫入指標移至檔案末端 | 檔案路徑 |
< |
讀取模式,導向為輸入來源 | 檔案路徑 |
>| |
強制覆蓋,無視環境 noclobber 限制 | 檔案路徑 |
>& / <& |
描述符複製,使左側指向右側目標 | 描述符編號(Bash 特殊情境允許接檔名) |
在標準 POSIX 規格的描述符複製語法 2>&1 中,符號 & 代表後方接續的是 FD 編號而非檔案名。它代表「將 2 號描述符的指向,同步設定為 1 號在此刻所指向的目標」。需要特別留意,在 Bash 擴充語法中,若寫成 >& filename 且後方非純數字,Bash 會將其等同於 &> filename;但這屬於非 POSIX 的特有語法,在 dash 或相容 sh 下會產生語法錯誤。
2>&1裡的1是取用「標準輸出當下的流向」。若前面已經更動過 1 號的指向,2 號就會跟著指向新目標;反之則會停留在原本的位置。
標準輸出的幾種寫法與特性
> 與 >> 的區別
> 採用截斷模式開啟檔案,原有檔案內容立即被清空,指標歸零;>> 則使用追加模式,保留現有檔案並將新輸出寫入末端。
在紀錄日誌的場景中,需要保留歷史軌跡以便回溯故障發生點時,必須使用 >>。在迴圈內部寫入日誌時若誤用 >,每次迭代都會覆蓋前一次的資料,最終日誌檔只會殘留最後一圈的輸出。
1> 與省略編號的等效性
> 與 1> 行為完全一致。當同一行指令需要分別指派 stdout 與 stderr 時,明確加上編號能夠大幅提升可維護性:
1> /var/log/app.log 2> /var/log/app.err
如果不需要常規輸出,僅保留報錯資訊,可以直接將 stdout 導入系統黑洞:
1> /dev/null
寫入 /dev/null 的資料會直接被核心捨棄,操作永遠回傳成功,適合用來過濾高頻的進度雜訊。
set -C 防誤寫與強制覆蓋符 >|
在腳本中啟用 set -C(或 set -o noclobber)之後,一般的 > 遇到已存在的檔案時會被阻斷並拋錯,能防止關鍵檔案被覆蓋。此時若明確需要覆蓋檔案,必須改用 >| 強制進行截斷寫入。
標準錯誤:執行順序決定的輸出命運
錯誤輸出的常規指向是明確宣告 2 號描述符:
make 2> build.err
make 2>> build.err
要讓 stdout 與 stderr 同時寫進同一個日誌檔,必須運用描述符複製:
make > build.log 2>&1
對照以下兩種看似相近的排列順序:
make > build.log 2>&1 # 正常結果與報錯皆寫入 build.log
make 2>&1 > build.log # 報錯留在原本 stdout 目的地,僅正常結果寫入檔案
造成差異的原因是 Shell 的由左至右解析順序:
第二行執行時,Shell 先處理 2>&1。此時 1 號標準輸出仍指向預設目的地(例如當前的虛擬終端或父行程管線),因此 2 號被指向該處。隨後 Shell 處理 > build.log,將 1 號重新指向檔案;但 2 號的指向已經確定,不會自動連動,最終導致錯誤訊息外洩。
牢記原則:先指定 stdout 的落腳點,再將 stderr 複製導過去。

另一種常見的危險寫法是獨立對同一個檔案開檔兩次:
make > build.log 2> build.log # 錯誤示範,切勿使用
此寫法會使核心為 1 號與 2 號各開啟一個獨立的檔案描述表項目,兩者各自維護獨立的寫入位移量(file offset)。當程式同時輸出正常資料與錯誤訊息時,兩條通道會互相覆蓋寫入位置,造成最終日誌內容殘破碎裂。使用 > file 2>&1 才能保證共用同一個檔案位移量,安全合併寫入。
自訂檔案描述符:常駐通道的管理與應用
當 0、1、2 都有特定用途,可以從 3 號開始建立自訂描述符。自訂描述符能實現「一次開啟、全程共用」,免去指令重複開檔的開銷,也能精細控制多管線分流。
exec 3> 開啟與 exec 3>&- 釋放
exec 3>> /var/log/task.log
echo "程序初始化完成" >&3
for f in *.csv; do
echo "處理中:$f" >&3
process_data "$f"
done
exec 3>&-
exec 內建指令在不接指令名稱時,只在當前 Shell 環境內配置描述符。在迴圈前透過 exec 3> file 開檔,開檔動作僅執行一次;即使使用的是截斷模式 >,也是在迴圈開始前清空一次,後續迴圈內透過 >&3 寫入的資料皆會完整保留。若不慎把 exec 3> file 放進迴圈內,才會發生每跑一圈就被截斷清空一次的狀況。腳本執行完畢前,使用 exec 3>&- 即可正常關閉並釋放描述符。
利用自訂描述符保護標準輸入
自動化腳本中常見的典型錯誤是讀檔迴圈被內層指令中斷:
while read -r host; do
echo "連線至 $host"
ssh "$host" uptime # ssh 讀取了標準輸入,直接吃掉清單後續內容
done < servers.txt
ssh、ffmpeg 等工具在執行時預設會嘗試讀取 stdin,這會導致 servers.txt 剩餘內容一次被吸走,造成迴圈跑完第一輪就提早終止。改用描述符 3 傳入清單,即可完美隔絕:
while read -r host <&3; do
echo "連線至 $host"
ssh "$host" uptime
done 3< servers.txt
exec 3<&-
此寫法在各 POSIX 相容 Shell 皆可通用;在純 Bash 環境下,也可直接使用 read -u 3 -r host。

暫存標準輸出與環境復原
在長腳本中,若需要暫時將所有輸出導向日誌、事後再恢復終端顯示,可以利用 3 號暫存既有的 stdout:
exec 3>&1 # 備份當前 stdout 至 FD 3
exec 1> /var/log/task.log # 將 stdout 改道至日誌
echo "此訊息寫入日誌檔"
echo "此訊息直接顯示在終端畫面" >&3
exec 1>&3 # 復原 stdout
exec 3>&- # 關閉暫存的 FD 3
Bash 4.1+ 動態描述符語法:{fd}
在大型專案或模組化函式庫中,手動指定 3、4、5 等固定編號容易互相衝突。Bash 4.1 以上支援自動指派空閒描述符:
exec {logfd}> /var/log/app.log
echo "系統運行中" >&$logfd
exec {logfd}>&-
Bash 會動態配發大於等於 10 的編號並寫入變數 logfd,避免號碼碰撞。但需留意此語法非 POSIX 標準,若 shebang 宣告為 #!/bin/sh,在 dash 環境下會引發語法解析錯誤。
常用寫法速查表
| 目標需求 | 標準語法寫法 | 機制特點與相容性 |
|---|---|---|
| 結果覆蓋寫檔 | cmd > file |
等同 1> file,開檔即清空 |
| 結果追加寫檔 | cmd >> file |
保留舊內容,寫至末尾 |
| 錯誤單獨寫檔 | cmd 2> err.log |
必須明寫 2 號,可配合 >> |
| 標準輸出與錯誤合流 | cmd > file 2>&1 |
通用 POSIX 規格,順序不可顛倒 |
| 徹底靜音(捨棄全部) | cmd > /dev/null 2>&1 |
自動化排程任務常用 |
| 將錯誤併入下游管線 | cmd 2>&1 | grep err |
管線 | 預設只接收 1 號輸出 |
| 配置常駐輸出描述符 | exec 3> file |
後續以 >&3 導向寫入 |
| 關閉自訂描述符 | exec 3>&- |
釋放行程占用之系統 FD 資源 |
| 自訂描述符隔離輸入 | done 3< file |
配合 read -r var <&3 保護 stdin |
| 動態分配描述符變數 | exec {fd}> file |
限 Bash 4.1+,不可用於純 sh |
實際排錯最常見的五大重定向問題
1. 管線運算子(|)遺漏錯誤輸出
cmd | tee app.log 只會抓取 stdout 寫入檔案,stderr 會直接穿透管線印在終端。若需要完整捕捉:
cmd 2>&1 | tee app.log
注意 2>&1 必須置於管線符號 | 左側,才能先將錯誤併入標準輸出管線中。
2. 搭配 sudo 執行時權限不足
執行 sudo cmd > /root/output.log 經常會遇到 Permission denied。這是因為重定向是由發起呼叫的當前使用者 Shell 處理開檔,此時尚未取得 root 權限。正確解法是將包含重定向的命令交由 root 啟動的子 Shell 執行:
sudo sh -c 'cmd > /root/output.log 2>&1'
3. 子 Shell(Subshell)內的描述符外洩無效
( exec 3> /tmp/sub.log; echo "test" >&3 )
echo "next" >&3 # 報錯:Bad file descriptor
圓括號 (...) 會建立獨立的子行程環境,其中建立的描述符在離開括號後隨之消亡。若需在整個腳本作用域共享,應在主環境直接配置,避免放在圓括號內。
4. set -e 遇上提前截斷的日誌遺失
當腳本啟用 set -e 時,若使用 > app.log 清空開檔,一旦接續的第一條指令執行報錯退出,前一次留下的歷史紀錄已全被清空,新紀錄又尚未寫入,將導致現場排錯線索全失。正式環境建議搭配時間戳記建立獨立紀錄檔,或採用 >> 追加存檔。
5. 複合指令的重定向層級錯誤
比較以下兩種寫法的作用差異:
{ echo "步驟一"; echo "步驟二"; } > out.log # 整組指令合併寫入,開檔截斷一次
{ echo "步驟一" > out.log; echo "步驟二" > out.log; } # 開檔兩次,第二次截斷覆蓋第一次
重定向置於大括號外部時,內部指令會共用同一次開檔串流;若放在內部每行指令尾端,每次指令都會重新開檔並截斷,導致前面的內容被後面的指令覆蓋抹除。
如何檢視描述符實際指向哪裡
懷疑重定向未如預期運作時,可以直接透過核心介面檢查。在 Linux 系統中,當前行程開啟的描述符會映射為符號連結:
ls -l /proc/$$/fd
輸出會清晰顯示各編號對應的目標節點,例如 1 -> /var/log/backup.log 或 2 -> /dev/pts/1。在 macOS 或 BSD 系統中,則可透過 /dev/fd 進行檢查。
若需要動態追蹤腳本執行過程,可透過 set -x 印出參數擴展後的指令。但需注意 set -x 預設只會印出展開後的指令名稱與參數,通常不會在追蹤資訊中顯示重定向操作本身。要確認描述符流向,直接查詢 /proc/$$/fd,或搭配 set -v 檢視原始輸入行最為可靠。
主流 Shell 的相容性地雷
同一段腳本在不同環境執行可能出現完全相反的行為,主要差異集中在語法支援度:
- POSIX sh / dash:Ubuntu 與 Debian 的
/bin/sh預設指向 dash。它支援標準的>、>>、2>&1與數字編號的exec 3>;但完全不支援&>、&>>及{fd}>。 - Bash:提供簡寫
&> file(等效於> file 2>&1),以及read -u、{fd}>。方便但無法相容純 sh。 - zsh:預設開啟多重輸出(multios),語法
cmd > a > b會將輸出同時寫入 a 與 b 兩個檔案(類似內建 tee)。若需確保行為符合標準 POSIX,需在 zsh 環境下使用unsetopt multios,或統一改用tee明確傳遞。
撰寫具備高度跨平台移植性的腳本時,最安全的標準解法是採用 > file 2>&1 搭配固定編號的 exec 3>。
上線前確認清單
- 合併輸出時,是否嚴格遵循「檔案路徑在前、複製語法在後」(
> file 2>&1)? - 是否存在同時對同一路徑執行
> file 2> file的多重開檔寫法? - 日誌記錄是否正確使用
>>追加,避免在迴圈內反覆以>截斷? - 讀取外部清單的迴圈內若呼叫了會消耗 stdin 的指令(如 ssh、ffmpeg),是否已將清單改由
3<導入? - 所有常駐配置的描述符在腳本收尾處是否皆有
exec 3>&-正常釋放? - 若 shebang 指定為
#!/bin/sh,是否已移除所有 Bash 專屬的&>與{fd}>? - 跨權限執行時,重定向操作是否已正確包覆於
sudo sh -c '...'內部?
重新檢視開頭的定時備份指令,只需將呼叫行修正為 python3 /opt/backup.py >> /var/log/backup.log 2>&1,程式拋出的異常堆疊就會完整寫入紀錄檔中,徹底解決日誌空白與通知信轟炸的問題。
常見問題 FAQ
Q1:為什麼執行指令時使用 2>&1 > file,錯誤訊息依然會印在畫面上?
Shell 解析重定向是「由左至右」進行的。當 Shell 先讀到 2>&1 時,標準輸出(1)此時依然指向終端畫面,因此標準錯誤(2)被複製設定為流向終端;隨後 Shell 才讀到 > file 並把標準輸出改道到檔案,但此時 2 號的指向已經確立,不會自動跟著變更。正確寫法必須先決定標準輸出的檔案目的地,再執行複製:> file 2>&1。
Q2:&> file 和 > file 2>&1 有什麼差異?平時可以混用嗎?
在 Bash 和 Zsh 環境下,兩者的執行效果完全相同,&> 是前者的語法簡寫。但 &> 屬於非 POSIX 標準的擴充功能,在 Ubuntu/Debian 預設的 /bin/sh(dash)環境下執行會觸發語法錯誤(Syntax error)。若腳本需兼顧相容性與跨機器移植,建議一律使用標準的 > file 2>&1。
Q3:使用 while read 迴圈配合 ssh 指令時,為什麼清單只跑了第一行就結束了?
這是因為 ssh 預設會讀取當前環境的標準輸入(FD 0)。當迴圈透過 < servers.txt 將清單接在 stdin 時,第一輪迴圈內的 ssh 指令會直接把 servers.txt 後續所有資料全部讀走。解決方法是幫 ssh 加上 -n 參數(等同將其輸入導向 /dev/null),或者將讀檔清單改掛載至自訂描述符(如 while read -r host <&3; do ... done 3< servers.txt)以保護 stdin。
Q4:為什麼不能直接寫 cmd > log.txt 2> log.txt?
這種寫法會讓作業系統對同一個檔案路徑執行兩次獨立的 open 系統呼叫,使 FD 1 和 FD 2 各自持有獨立的檔案指標(offset)。當兩個描述符同時寫入時,彼此不會感知對方的寫入進度,會頻繁發生互相覆蓋(clobbering)的情況,導致日誌內容缺失混亂。使用 > log.txt 2>&1 能讓 2 號直接共用 1 號的描述符指標,確保輸出依序寫入。



