SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

systemd 服務 Type 設定指南:simple, forking, notify

當 systemd 顯示 active 但服務已停止,通常是 Type 設定錯誤導致。本文解析 simple、forking 與 notify 的差異,協助您正確定義主 PID 並解決服務監控失效的問題。

為什麼 systemd 在處理程序已終止時仍回報單元為 active

systemd 服務單元會維持 active 狀態,只要 systemd 所定義的主處理程序(main process)仍存活,而 Type= 區段中的 [Service] 設定決定了該處理程序為何。若設定值錯誤,systemd 最終會監控到 shell 封裝程式或短暫存在的父處理程序,而您實際關注的 daemon 卻在同一個單元內終止。該單元所回報的狀態,是針對其被告知要監控的處理程序而言的正確資訊。

更改重啟策略對此無濟於事。Restart= 僅在主處理程序退出時才會觸發,因此若主 PID (process identifier) 仍屬於某個執行中的程序,Restart=always 便永遠不會觸發。請優先修正 Type=。至於主處理程序真正退出後 systemd 的行為,則是另一項決策,詳見 Restart= 與 RestartSec= 指南

Type= 參數的具體作用

每個 Type= 的值會同時回答兩個問題:systemd 何時認定該單元已啟動,以及哪一個行程是主行程。

第一個答案決定了執行順序。若有其他單元在 After= 中指定了您的單元,它們會等待 systemd 呼叫您的單元並將其標記為已啟動。若 Type= 過早回報「已啟動」,會導致相依單元在您的服務準備就緒前就先行執行。

第二個答案決定了監控機制。systemd 會將單元衍生的所有行程放入一個 cgroup(控制群組)中;這是核心的一項功能,能將行程分組以便統一限制或終止。systemctl stop 正是透過 cgroup 進行清理:KillMode= 的預設值為 control-group,因此停止單元時會向群組內的所有行程發送訊號。主 PID(Main PID)的定義則較為狹窄,它是唯一一個其結束會導致單元終止、且其結束狀態會成為單元執行結果的行程。將 cgroup 視同主 PID 來解讀,往往是造成混淆的根源。

Type=simple 會在二進位檔執行前回報服務已啟動

當設定 Type=simple 時,若未指定 ExecStart=,且 Type=BusName= 皆不存在,則 Type=simple 為預設值。systemd 會建立處理程序,立即將該單元視為已啟動,並將該處理程序視為主要 PID。後續的單元會立即開始執行,此時服務的二進位檔甚至尚未執行。

最後這項細節解釋了一個常見的意外狀況。若 ExecStart= 路徑拼字錯誤,啟動任務仍會顯示成功,隨後在執行失敗時才會出現錯誤。systemd 會將此情況記錄為結束代碼 203,其內部表格將其命名為 EXEC,定義為無法執行服務二進位檔。因此,systemctl start 回傳無錯誤並不代表您的二進位檔確實存在。

若程式會留在前景且不會自行轉入背景,請使用 simple。這適用於大多數現代守護行程,以及您自行編寫的幾乎所有程式。

Type=exec 會等待程式實際啟動

Type=execsimple 類似,但多了一個步驟。systemd 必須在 fork 與二進位檔執行皆成功後,才會將該單元視為已啟動。若二進位檔遺失或 User= 無法解析,啟動作業會直接失敗,而非回報成功後再於稍後靜默中斷。

Type=exec 自 systemd 240 版本引入,目前所有主流伺服器發行版皆已支援。截至 2026 年 8 月,Ubuntu 24.04 搭載 systemd 255,Debian 13 則搭載 systemd 257。請使用 systemctl --version 檢查您的版本。

此設定的代價是啟動時會多出一個同步步驟,但優點是能從 systemctl start 獲得準確的結束狀態。對於前景程式,建議優先使用 exec 而非 simple

Type=forking 與主 PID 遺失的問題

Type=forking 用於告知 systemd,ExecStart= 中的處理程序會產生子處理程序後自行結束。systemd 會等待該初始處理程序結束,隨後才將該單元標記為已啟動。留下的子處理程序即為背景常駐程式(daemon)。

此處的難點在於識別。由於 systemd 啟動的處理程序已經結束,systemd 必須判斷哪個存活的處理程序才是主處理程序。請將 PIDFile= 設定為該常駐程式寫入的檔案路徑(通常位於 /run 下),systemd 會從中讀取 PID。systemd 也會驗證該檔案中的 PID 是否確實屬於此服務,因此若檔案中記載的是無關的舊處理程序,該檔案將被拒絕,而非被信任。

若未設定 PIDFile=,則會套用 GuessMainPID=,其預設值為 yes。此自動推測機制僅在服務穩定於單一處理程序時才可靠。手冊明確指出其限制:若常駐程式包含多個處理程序,推測結果可能會出錯,導致故障偵測失效。單元也可能出現主 PID 為 0 的情況,這代表 systemd 完全無法進行監控。

大多數會進行 fork 的常駐程式都提供讓其在前台執行的參數。請搭配 Type=exec 使用該參數,並刪除 PIDFile= 行。減少運作中的組件,就能降低 PID 遺失的風險。

Type=oneshot 用於執行完畢的工作

Type=oneshot 預期處理程序會執行並結束。systemd 僅在該單元結束後才會將其視為已啟動,這使得 oneshot 成為其他單元必須等待之任務的正確設定。當單元未指定 Type=ExecStart= 時,這也是預設的隱含類型。

oneshot 有兩種特殊行為。它是唯一接受多行 ExecStart= 指令的類型,且這些指令會依序執行。其啟動逾時預設為停用,因此若 oneshot 發生掛起,除非您自行設定 TimeoutStartSec=,否則它會無限期等待。

處理程序結束後,單元會回到 inactive 狀態。RemainAfterExit=yes 會使其保持 active 狀態,且不會有任何處理程序在執行。這是本頁開頭所述症狀的刻意版本,當單元的工作是留下狀態而非維持某個程序執行時(例如載入防火牆規則集或啟動容器堆疊),此設定是正確的。這也是 重開機後自動恢復的 Docker Compose 堆疊 背後的模式,其中單元執行 compose 指令後隨即結束,但因為它所啟動的容器生命週期比單元本身更長,所以單元仍保持 active。oneshot 單元也是排程觸發的對象,這正是 使用 systemd timer 取代 cron 執行工作 的另一半核心機制。

Type=notify 讓服務能自行回報就緒狀態

Type=notify 將判斷權交給服務本身。systemd 會暫停啟動任務,直到處理程序透過 Unix socket 發送 READY=1 為止;該 socket 路徑由 NOTIFY_SOCKET 環境變數提供。其 C 語言介面為 sd_notify(3),許多伺服器軟體已內建支援。

這是判斷「服務是否已啟動」最準確的方式。simpleexec 在服務讀取設定檔或開啟監聽 socket 之前就會回報已啟動,導致相依單元過早啟動並因連線失敗而崩潰。notify 則是在服務自身回報就緒的瞬間才判定為已啟動。

systemd 僅接受來自主要處理程序的訊息,這正是 NotifyAccess=main 的定義,而 Type=notify 也隱含此行為。若訊息來自子處理程序或輔助程式,請設定 NotifyAccess=all。Shell script 可呼叫 systemd-notify --ready,但該指令會以短暫的獨立處理程序執行,因此需要 NotifyAccess=all,且若發送者已結束,systemd 可能無法正確識別訊息來源。由服務本身實作該協定會更為可靠。

另外有兩個相關設定值得參考。Type=notify-reload 自 systemd 253 起提供,將此握手機制延伸至重新載入(reload)流程,使 systemctl reload 在服務回報重新載入完成後才返回,而非僅在發送訊號後立即返回。WatchdogSec= 則要求通知型服務定期發送存活訊息(keep-alive),若超過期限未收到訊息,systemd 將視為服務失敗。

Type=dbus 與 Type=idle

Type=dbus 會等待服務在 D-Bus 上取得名稱;D-Bus 是系統與桌面服務之間進行通訊的訊息匯流排。此設定需要 BusName=,且一旦設定 BusName=,它就會成為預設值。請僅在確實會註冊匯流排名稱的服務上使用此設定。

Type=idle 的行為類似 simple,但它會延遲執行程式,直到佇列中的工作皆已分派完畢,並設有 5 秒的上限。此設定的存在是為了避免開機時的控制台輸出與狀態訊息交錯。它並非排序工具,不應使用於一般服務。

為何封裝腳本會導致 systemd 鎖定錯誤的 PID

以下架構會產生此問題。

[Service]
Type=simple
ExecStart=/opt/app/run.sh
#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101

systemd 會將 shell 記錄為主要 PID。當 exporter 在前景執行時,shell 會保持運作。若 server 意外終止,shell 不會察覺,因此主要 PID 依然存活,單元狀態仍為 active,且 Restart= 無法採取任何行動。由於兩個處理程序始終位於該單元的 cgroup 中,systemctl stop 仍能正確執行清理。損壞的是監控機制,而非清理機制。

解決方案取決於該單元實際包含多少個長期執行的處理程序。

若只有一個,請用該處理程序取代 shell。

#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yaml

exec 會以指定的程式取代 shell 並保留相同的 PID,因此 systemd 記錄的 PID 現在即為該 daemon。更好的做法是移除封裝腳本。Environment=EnvironmentFile= 可傳遞變數,而 ExecStartPre= 可處理設定步驟,讓 systemd 能直接啟動 daemon 並在建立時即掌握其 PID。

若有兩個處理程序,則沒有單一 PID 能代表該單元。請將其拆分為兩個單元,並使用 After=Wants= 進行排序。每個處理程序對應一個單元是 systemd 能妥善監控的配置,這也是確保每個處理程序能擁有各自重啟行為的唯一方式。

ExitType=cgroup 的變更

ExitType= 於 systemd 250 版本加入。預設值為 main:當主程序結束時,該單元即被視為已停止。若設為 ExitType=cgroup,則只要 cgroup 中仍有任何程序存活,該單元就會被視為執行中。

[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcher

此設定解決了一個特定問題。若啟動器(launcher)啟動了實際工作後隨即結束,在 ExitType=main 模式下,systemd 會認為該單元已停止並殺掉剩餘的程序。使用 ExitType=cgroup 時,單元會追蹤整個群組的狀態。

請釐清此設定無法解決的問題。ExitType=cgroup 只要有至少一個程序存活就會保持單元為啟用狀態,因此若一個單元包含兩個 daemon,其中一個死亡後,該單元仍會保持啟用。它僅修復了啟動器的問題,並不會將單元轉變為多個獨立程序的監控者。此外,ExitType= 無法與 Type=oneshot 同時使用。

資源統計同樣基於 cgroup,因此無論 Type= 對主 PID 的定義為何,諸如 MemoryMax=CPUQuota= 等限制皆會套用於該單元所衍生的所有程序。關於此部分的詳細說明,請參閱 使用 systemd 限制服務的記憶體與 CPU

如何找出 systemd 實際監控的處理程序

請依照順序在您正在偵錯的單元(unit)上執行下列步驟。先讀取 systemd 載入的設定,接著讀取其追蹤的內容,最後與處理程序表(process table)進行比對。

systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.service

systemctl cat 會列印出單元檔案以及所有套用的 drop-in 設定,確保您讀取的是 systemd 實際載入的內容,而非您記憶中編輯過的檔案。systemctl show 會列印出最終生效的數值,包含您未曾寫入的預設值。在繼續下一步之前,請留意 MainPID 的數值。

systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"

systemd-cgls 會列出該單元 cgroup 中的所有處理程序。ps 行描述了 systemd 所監管的單一處理程序。請將兩者對照閱讀。MainPID 為 0 代表 systemd 目前沒有可監控的處理程序。若 MainPID 解析為 shell,且 cgroup 中同時包含您的 daemon,則屬於上述的 wrapper 情況。若 cgroup 中的處理程序數量超出預期,則代表涉及了啟動器(launcher)或分岔(forking)的 daemon。

systemctl status app.service
journalctl -u app.service -b

systemctl status 會同時列印狀態行與 cgroup 樹狀結構,通常能一次回答上述兩個問題。journalctl -u 加上 -b 參數可限制顯示本次開機的紀錄,呈現 systemd 為該單元記錄的啟動與停止事件,以及其偵測到的結束代碼。若 daemon 是寫入自己的 log 檔案而非 journal,請同時讀取該檔案,因為 systemd 僅能記錄傳遞給它的資訊。

當您變更 Type= 後,請重新載入並重啟服務。

systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.service

systemd-analyze verify 會解析檔案並回報無法接受的設定。daemon-reload 會讓 systemd 從磁碟重新讀取單元檔案。變更 Type= 不會自動套用到已在執行的單元,因此重啟是必要步驟,而非選用。

接著測試變更。從 systemd-cgls 取得您實際關心的處理程序 PID 並將其終止(kill)。隨即執行 systemctl is-active app.service。若 Type= 設定正確,該單元應會離開 active 狀態。若它仍保持 active,代表 systemd 仍在監控其他處理程序。

該使用哪種 systemd 服務 Type=

  • 保持在前台執行的程式:Type=exec
  • 支援就緒通知的程式:Type=notify,若同時支援重新載入確認則使用 notify-reload
  • 強制轉入背景執行的常駐程式:搭配 PIDFile= 使用 Type=forking,或使用該程式的前台執行參數搭配 Type=exec
  • 執行完畢即退出的腳本:Type=oneshot,若目的是為了保留狀態則加上 RemainAfterExit=yes
  • 啟動後即退出但子行程持續執行的啟動器:搭配 ExitType=cgroup 使用 Type=simple

若不確定第三方常駐程式需要哪種類型,請先閱讀其隨附的 unit file。對發行版提供的 unit 執行 systemctl cat,即可查看上游開發者選擇的 Type=,該選擇已經過比您個人更多的測試驗證。

FAQ

為什麼我的 systemd 單元在處理程序終止後仍顯示為 active?

因為 systemd 視為主處理程序的程序仍然存活。systemd 根據 Type= 監控每個服務的單一 PID,而非監控該單元 cgroup 中的所有程序。使用 Type=simple 啟動的包裝腳本是常見原因:shell 本身是主 PID,因此當 shell 在背景啟動的 daemon 結束時,該單元仍會保持 active。請執行 systemctl show -p MainPID app.service,接著使用 systemd-cgls --unit=app.service 列出該單元的 cgroup,並比較兩者。

Type=simple 與 Type=exec 有何不同?

Type=simple 在 systemd 建立程序後即視為單元已啟動,此時二進位檔尚未執行,因此 ExecStart= 中的路徑錯誤仍會導致啟動工作顯示成功,隨後才發生失敗。Type=exec 會等待執行成功,因此啟動工作本身會直接回報失敗。兩者皆將相同的程序視為主 PID。Type=exec 需要 systemd 240 或更新版本。

使用 Type=forking 時仍需要 PIDFile= 嗎?

是的,只要 daemon 會寫入 PID 檔案就需要。若無此設定,systemd 會退而求其次使用 GuessMainPID=,這僅是推測,且僅在服務最終穩定為單一程序時才可靠。當推測錯誤或無法判斷時,該單元的失敗偵測與自動重啟功能將會失效。請將 PIDFile= 指向 daemon 實際寫入的路徑,通常位於 /run 下。

何時該使用 RemainAfterExit=yes?

當單元的目的是改變系統狀態,而非維持程序執行時。例如用來載入防火牆規則或啟動容器堆疊的 Type=oneshot 單元,在工作完成後即會結束;若無 RemainAfterExit=yes,該單元會變為 inactive,導致 systemctl stop 沒有東西可以停止,也無法執行 ExecStop= 的清理工作。設定此選項後,單元會在沒有程序的情況下保持 active,這正是此處所需的行為。

修改 Type= 是否需要執行 daemon-reload?

是的,且必須重啟該單元。systemctl daemon-reload 會讓 systemd 重新讀取磁碟上的單元檔案,但正在執行的實例仍會保留其啟動時的 Type=。在測試前請執行 sudo systemctl daemon-reload 並接著執行 sudo systemctl restart app.service,否則您觀察到的仍是舊有的監控行為。