systemd unit 無法啟動?先看 exit code
先執行 systemctl status,查看 status=203/EXEC 與 226/NAMESPACE 的差異,並了解 unit 為何可能正常啟動後,僅一秒就結束。
為何 systemd 單元無法啟動
若 systemd 單元無法啟動,其狀態欄位會顯示原因。執行 systemctl status <unit> 並查看報告失敗行中的 code= 與 status=。狀態碼若為 200 開頭,通常代表 systemd 未能執行您的程式:它在建立單元檔案所要求的執行環境時失敗。若該行顯示 code=exited 且狀態碼低於 200,代表您的程式已執行但自行結束,此時單元檔案通常無誤,問題在於應用程式本身。code=killed 則代表程序被訊號終止,這是另一種情況,將於本節末尾說明。
此區分方式為判斷路徑。以下內容依據上述數字出現的順序進行說明。
哪三個指令能回答該問題,依序排列
systemctl status myapp.service
journalctl -u myapp.service -b --no-pager
systemd-analyze verify /etc/systemd/system/myapp.servicesystemctl status 會給出判斷結果。請先閱讀 Loaded: 行,因為它標示了 systemd 實際解析的檔案,並說明該單元是已啟用、已遮蔽還是根本找不到。接著閱讀 Active: 行以及下方的 code= 與 status= 配對。
journalctl -u myapp.service -b --no-pager 會提供詳細資訊。-u 用於篩選該單元,-b 將輸出限制在本次開機,以免讀到上週的錯誤紀錄,而 --no-pager 則直接輸出到終端機,方便您透過管線傳送給 grep。status 僅顯示最後幾行日誌並截斷過長的內容。日誌會顯示程式在崩潰前輸出的所有資訊,這通常就是真正的錯誤原因。若不使用 -n,您將會看到本次開機的所有項目。加上 -n 100 可僅保留最新的 100 行,或者在另一個終端機執行 -f 同時重新啟動該單元。
systemd-analyze verify 會載入單元檔案但不執行。它會針對未知的區段與指令發出警告,並標記 ExecStart= 中無法執行的指令。這能捕捉兩類容易被忽略的錯誤:拼字錯誤的鍵值(systemd 在載入時會忽略並發出大多數人不會注意的警告),以及不存在的路徑。
編輯任何單元檔案後,請執行 sudo systemctl daemon-reload。在此之前,systemd 會持續使用先前載入的副本,而 systemctl status 會發出警告,提示磁碟上的檔案已變更。所謂「沒效果」的修復,通常是因為 systemd 還沒讀取更新後的檔案。
另外兩個指令也很有用。systemctl cat myapp.service 會印出實際生效的單元內容,即主檔案加上 /etc/systemd/system/myapp.service.d/ 下的所有插入式設定檔(drop-in)。systemctl show myapp.service -p ExecStart -p User -p WorkingDirectory 則會印出 systemd 解析後的數值,這才是實際運作的設定。
status=203/EXEC 代表什麼?
203/EXEC 表示 systemd 已完成設定、呼叫了 execve(),但核心拒絕執行。您的程式完全沒有執行任何一行自己的程式碼。幾乎所有情況都可歸納為以下 4 個原因。
ExecStart=中的路徑錯誤,或不是絕對路徑。使用ls -l檢查,並與 unit 檔案中的完整字串比對。- 檔案沒有執行位元。
sudo chmod +x /opt/myapp/run.sh可修正此問題。從封存檔解開或從其他機器複製的檔案,常會遺失這個位元。 - shebang 行損壞。核心會讀取指令碼的第一行,並執行其中指定的直譯器。因此,當服務的 PATH 沒有
python3時,#!/usr/bin/env python3會失敗;如果檔案使用 Windows 換行格式儲存,核心會尋找名為/bin/bash\r的直譯器,但該檔案不存在。 - 檔案不是這台機器可執行的格式,例如架構錯誤,或是完全沒有 shebang 的文字檔案。
在變更任何設定前,先以服務帳號手動重現問題。
sudo -u appuser /opt/myapp/run.sh
file /opt/myapp/run.sh
head -1 /opt/myapp/run.sh | cat -Afile 會顯示架構;如果換行格式造成問題,也會顯示「with CRLF line terminators」。cat -A 會以結尾的 ^M 顯示相同問題。使用 sed -i 's/\r$//' /opt/myapp/run.sh 移除這些字元。
關於這個範圍,有一點必須說明:200 以上的狀態碼只是慣例,不是保證。您自己的程式也可以以 203 結束,而 systemd 無法區分這兩種情況。systemd-analyze exit-status 203 會顯示任何程式碼的名稱與類別,有助於閱讀這份表格;但如果您的應用程式使用 199 以上的結束碼,請改用其他數值。
為什麼會出現 217/USER 或 216/GROUP 錯誤?
217/USER 通常表示服務啟動時,User= 指定的帳號不存在。此代碼也涵蓋了無法為 PrivateUsers= 等沙盒選項設定使用者命名空間(user namespacing)的情況;在此情形下,journal 會顯示 "Failed to set up user namespacing" 而非指出遺失的使用者名稱。216/GROUP 則是針對 Group= 或 SupplementaryGroups= 的相同錯誤。請分別使用以下指令進行確認。
getent passwd appuser
getent group appgroup每個指令若非印出一行資訊,就是不輸出任何內容並回傳非零值。若無輸出,代表系統無法識別該名稱,因此 systemd 無法切換至該帳號並在執行前終止。解決方法是建立該帳號,而非移除 User=root。以 具備最小權限的專用系統帳號 執行服務,正是該指令的核心目的。
sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuserDynamicUser=yes 可透過讓 systemd 在每次啟動時分配臨時帳號來規避此問題。這適用於不需保留狀態的服務。任何需要寫入檔案的服務都必須搭配 StateDirectory= 使用,因為使用者 ID 會在每次啟動時變動,若使用一般路徑,檔案最終會歸屬於一個已不存在的帳號。
什麼是 226/NAMESPACE?
226/NAMESPACE 來自沙箱化指令。當 unit 設定 ProtectSystem=、ProtectHome=、PrivateTmp=、ReadWritePaths= 或類似設定時,systemd 會在執行程式前,為該服務建立私有掛載 namespace。這裡的 namespace 是單一程序所看到的檔案系統私有視圖。如果該配置中的任何掛載失敗,啟動就會以 226 失敗,程式也不會執行。
最常見的原因是 ReadWritePaths= 中指定的路徑不存在。ProtectSystem=strict 會以唯讀方式掛載整個檔案系統,而 ReadWritePaths= 會重新開放指定路徑供寫入。systemd 無法重新開放不存在的目錄。有兩種適當的修正方式。使用 StateDirectory= 讓 systemd 建立目錄;這會在每次啟動時建立 /var/lib/<name>,並將目錄交給服務使用者。或者在路徑前加上 -,告知 systemd 在來源不存在時忽略該項目。不要刪除強化設定,因為這只是把 5 分鐘的問題換成永久性的問題。
[Service]
ProtectSystem=strict
ProtectHome=yes
StateDirectory=myapp
ReadWritePaths=-/srv/uploads如果無法判斷是哪一行造成問題,請先移除整個強化設定區塊,重新載入設定,然後啟動服務。如果服務成功啟動,請逐行加回設定,每加一行就重新啟動一次。此類錯誤中另外兩個相近的錯誤是 233/RUNTIME_DIRECTORY 和 238/STATE_DIRECTORY。這表示 systemd 無法建立 RuntimeDirectory= 或 StateDirectory= 中指定的目錄,或無法取得該目錄的所有權。通常是因為該路徑已存在,且歸屬於其他使用者。
為什麼 WorkingDirectory 看似正確,卻出現 200/CHDIR?
200/CHDIR 表示進入 WorkingDirectory= 的 chdir() 失敗。目錄可能不存在,或服務使用者無法進入該目錄。進入目錄需要該目錄及其所有上層父目錄具備 execute 權限。因此,即使 /home/deploy/app 可讀,只要 /home/deploy 的模式為 700,而服務以 appuser 執行,仍然無法存取該目錄。
sudo -u appuser test -x /srv/myapp && echo ok
namei -l /srv/myappnamei -l 會列出路徑中每個元件的擁有者與模式。這是找出哪個目錄阻擋後續存取的最快方法。將 WorkingDirectory=-/srv/myapp 設為可讓缺少目錄不致造成致命錯誤。這適合不在意從哪個目錄開始執行的程式;若程式會依相對路徑開啟檔案,則不適合這樣設定。
為什麼服務啟動後一秒就停止?
此時不會出現 200 系列的代碼,通常也沒有任何錯誤訊息。該單元在啟動後會直接顯示 inactive (dead),或者在 activating (auto-restart) 之間循環。systemd 已正確建立執行環境。問題在於您的程式實際行為與 Type= 所承諾的行為不符。
預設值 Type=simple 表示程式會留在前景執行。若您提供的是會 fork 到背景並結束的 daemon,systemd 會認為主程序已完成並將服務視為結束。大多數 daemon 都有讓其保持在前景的旗標,例如 nginx -g 'daemon off;'。
Type=forking 表示第一個程序在子程序準備就緒後即結束。若您提供的是前景程式,啟動任務會一直等待直到 TimeoutStartSec= 耗盡(預設為 90 秒),隨後 systemd 會將其殺掉並記錄逾時。
Type=notify 表示程式會呼叫 sd_notify() 來宣告準備就緒。若程式不支援此功能,則不會發出任何宣告,導致啟動逾時,日誌會將結果記錄為協定失敗。
請根據程式的實際行為選擇類型。simple、forking、oneshot 與 notify 的差異 是解決此類失敗的關鍵決策。
當服務反覆退出時,systemd 會停止嘗試並回報啟動請求過於頻繁。該單元隨後會保持失敗狀態,且 systemd 不會自動重啟。您可以在速率限制間隔過後手動啟動,或執行 sudo systemctl reset-failed myapp.service,這會清除失敗狀態與重啟計數器,以便立即進行手動啟動。提高限制只會掩蓋症狀。請閱讀第一次失敗時的日誌,而非最後一次,並在修改前參閱 Restart=on-failure 實際重試的內容。
為什麼 unit 會在完全沒有錯誤的情況下保持 inactive?
unit 可能會被略過,而不是啟動。Condition* 指令的設計就是不顯示錯誤:檢查失敗時,systemd 會將該工作標記為成功,然後不執行任何動作。含有 ConditionPathExists=/etc/myapp/config.yml 的 unit 在該檔案不存在時永遠不會啟動,也不會回報錯誤。
systemctl show myapp.service -p ConditionResult -p ConditionTimestamp
journalctl -u myapp.service -b --no-pager | grep -i conditionConditionResult=no 會確認該 unit 已被略過,而 journal 會指出未通過的檢查。當缺少必要條件時應明確失敗,請改用 Assert* 指令。Conditions、assert 與 unit 排序說明不同檢查應放在哪個位置。
附近還有幾種不會顯示錯誤的情況。「could not be found」錯誤通常表示檔案位於錯誤的目錄,或尚未重新載入:自行建立的 unit 檔案應放在 /etc/systemd/system/。被 mask 的 unit 在 sudo systemctl unmask myapp.service 清除 mask 前會拒絕所有啟動要求。此外,沒有 [Install] 區段的 unit 執行 systemctl enable 會失敗,因此請為它加入 WantedBy=multi-user.target。
如果進程是被強制終止而非執行失敗該怎麼辦?
code=killed 與 code=exited 的情況截然不同。這代表有外部因素終止了該進程。status=9/KILL 通常指向記憶體不足(OOM)殺手,若確實如此,日誌中會標明被選中的進程名稱。然而,SIGKILL 並不會顯示發送者是誰:當停止中的服務在超過 TimeoutStopSec= 後仍未回應 SIGTERM 時,systemd 也會發送 SIGKILL,因此在歸咎於記憶體問題前,請先確認當時是否正在進行停止或重啟操作。您自行設定的限制也會在 cgroup(控制群組)內觸發相同的行為,請使用 free -m 檢查主機的可用記憶體,並檢查該單元設定檔中是否有 MemoryMax=。MemoryMax, CPUQuota 與其他 cgroup 限制 一文說明了哪些限制會導致進程被殺,哪些僅會造成降速。
status=15/TERM 若出現在啟動嘗試後,通常代表 systemd 的啟動逾時並終止了該進程,這會將您導回 Type= 的排查流程。
預防大多數此類故障的兩個習慣
在所有地方使用絕對路徑。 systemd 不會執行您的登入 shell,因此沒有 .bashrc、沒有 .profile,也沒有啟動虛擬環境。系統服務的 $PATH 僅包含簡短的內建列表,其中不會包含 /opt 或語言版本管理器的 shims。請完整寫出 /usr/bin/python3 或 /opt/myapp/venv/bin/python。在 shell 中執行 command -v myapp 可印出路徑供您貼上。同樣的規則適用於 WorkingDirectory=、EnvironmentFile= 以及 ReadWritePaths= 中的每一條路徑。
ExecStart= 並非 shell。 systemd 會將指令行拆分為多個單字,並自行呼叫 execve()。管線 (pipes)、重新導向 (redirections)、萬用字元 (globs)、&&、反引號 (backticks) 與 ~ 皆無意義:它們會以字面參數的形式傳遞給您的程式。ExecStart=/usr/bin/myapp --flag > /tmp/out.log 會將 > 與 /tmp/out.log 傳遞給 myapp,後者隨後會因用法錯誤而終止,且該錯誤看起來完全不像 systemd 的問題。當您需要 shell 功能時,請明確要求使用 shell。
ExecStart=/bin/sh -c '/usr/bin/myapp --flag | /usr/bin/tee -a /var/log/myapp.log'若僅需輸出,您並不需要這樣做。服務輸出預設會進入 journal,而 StandardOutput=append:/var/log/myapp.log 可在不涉及 shell 的情況下寫入檔案。
變數擴展也受到同樣的限制。$MYVAR 與 ${MYVAR} 會從 Environment=、EnvironmentFile= 以及 systemd 自行設定的少數變數(例如 $USER)中替換,% 指定符(例如 %i)也會進行擴展。來自您登入 shell 的任何內容都不會擴展。$HOME 僅在單元擁有 User= 時才會設定(透過 SetLoginEnvironment= 的預設值),因此以 root 身分執行且未設定 User= 的服務,除非您自行設定,否則不會有 $HOME。EnvironmentFile= 也不是 shell 指令碼:export 不屬於其中,其引號規則與 bash 不同,且除非您在路徑前加上 -,否則檔案缺失將導致致命錯誤。
在正式運作中的伺服器上逐步處理
讀取程式碼,確認根因,修改一項設定,然後重新啟動。這個順序比記住所有編號更重要,因為可以避免同時進行 3 項推測性的修改,也不會失去對哪一項修改有效的追蹤。這套方法同樣適用於不是由你撰寫的 unit。永遠不會觸發的 timer,代表對應的 service 從未啟動,因此應先偵錯 service:systemd timer 與其觸發的 service 會以完全相同的方式失敗,而 timer 會隱藏輸出,直到你要求 journal 顯示相關內容。
FAQ
systemctl status 顯示 status=203/EXEC 是什麼意思?
systemd 已完成單元定義的所有前置作業,但隨後的 execve() 呼叫失敗,導致程式無法啟動。請依序檢查四點:ExecStart= 中的路徑必須存在且為絕對路徑、檔案必須具備執行權限(execute bit)、Shebang 指定的直譯器必須存在於服務的 PATH 中,且檔案必須使用 Unix 換行符號。若檔案使用 Windows 換行符號,file 會顯示 "with CRLF line terminators",這會導致直譯器名稱被誤判為 /bin/bash\r,進而遭核心拒絕執行。
為什麼我的服務啟動後隨即停止?
這是因為單元檔案定義的行為與程式實際運作方式不符。若設定為 Type=simple,systemd 會預期程式在前台執行,因此若 daemon 程式自行 fork 到背景,systemd 會誤以為程式已結束。若設定為 Type=forking,systemd 會等待第一個行程結束,導致前台程式啟動後一直處於等待狀態,直到 TimeoutStartSec= 超時。請將 Type= 設定為符合程式的類型;若程式提供前台執行旗標,請務必搭配預設的 Type=simple 使用。
如何查看完整的錯誤訊息,而非簡短的狀態輸出?
systemctl status 僅會顯示日誌的最後幾行,且會截斷過長的內容。請執行 journalctl -u myapp.service -b --no-pager 以取得該單元在本次開機期間的所有日誌,加上 -n 200 可僅保留最後 200 行,或透過 pipe 傳送至 grep。若應用程式會自行寫入日誌檔,也請一併檢查,因為 systemd 僅會擷取程式輸出至標準輸出(stdout)與標準錯誤(stderr)的內容。
為什麼我的單元顯示為 inactive 卻沒有錯誤訊息?
最常見的原因是 Condition* 指令導致跳過啟動。這些檢查不會產生錯誤訊息:若條件不符,啟動作業會被視為成功。請執行 systemctl show myapp.service -p ConditionResult 並搜尋 ConditionResult=no,接著查看日誌中標註該檢查的行數。另一個常見原因是單元被 masked,這會導致所有啟動請求被拒絕,直到執行 sudo systemctl unmask 解除為止。
修改單元檔案後,每次都需要執行 daemon-reload 嗎?
是的,任何對單元檔案或 drop-in 檔案的修改都需要執行。sudo systemctl daemon-reload 會讓 systemd 從磁碟重新讀取檔案,隨後執行 sudo systemctl restart myapp.service 即可將變更套用至執行中的服務。若使用 systemctl edit 則會自動重新載入,無須手動執行;此外,若修改的是應用程式本身的設定檔(而非 systemd 的單元檔案),則無須執行此指令。