systemd 為何勝出:從 SysV init 到主流標準
了解 SysV init 無法處理的相依性、就緒狀態與程序監督,Upstart 和 launchd 先嘗試了什麼,以及各 Linux 發行版為何在四年內轉向 systemd。
systemd 為何勝出
systemd 的歷史始於 SysV init 無法處理的兩件事。SysV init(System V init,也就是 Linux 從 AT&T Unix 繼承而來的啟動系統)無法描述服務相依於哪些項目,也無法在服務啟動後判斷哪些程序屬於該服務。systemd 透過 shell script 無法使用的核心功能解決了這兩個問題:使用 control groups 追蹤程序,並使用預先開啟的 listening socket 控制啟動順序。接下來的發展,是這兩種解法如何擴展到其他 userland 元件;反對意見也由此開始,而其中幾項反對意見確實有道理。
SysV init 實際執行的工作
在 SysV 系統中,PID 1(程序 ID 1,也就是核心啟動的第一個程序)會讀取 /etc/inittab、選擇執行層級,並執行該執行層級的指令碼。這些指令碼位於 /etc/init.d/。/etc/rc3.d/ 中的符號連結決定哪些指令碼會執行及其順序,因此 /etc/rc3.d/S20nginx 會指向 /etc/init.d/nginx,並以 start 作為引數執行。
#!/bin/sh
### BEGIN INIT INFO
# Provides: nginx
# Required-Start: $local_fs $remote_fs $network $syslog
# Required-Stop: $local_fs $remote_fs $network $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
### END INIT INFO
case "$1" in
start)
start-stop-daemon --start --quiet --pidfile /run/nginx.pid \
--exec /usr/sbin/nginx
;;
stop)
start-stop-daemon --stop --quiet --retry TERM/30/KILL/5 \
--pidfile /run/nginx.pid
;;
esacS20nginx 中的 20 代表位置,而不是相依性。它表示此指令碼會在 S19 之後、S21 之前執行。它沒有說明原因,因此系統無法驗證這個順序,也無法在沒有人工確認安全性的情況下,讓兩個互不相關的指令碼同時執行。
rc 程式會依序執行每個指令碼,並等待指令碼結束。若某個指令碼因等待網路位址而阻塞 30 秒,整個開機程序就會停滯 30 秒,即使其他服務完全不需要網路也一樣。
該指令碼頂端的 LSB(Linux Standard Base)標頭,原本是嘗試從 SysV init 內部解決這個問題。Debian 6.0 在 2011 年將 insserv 設為預設值:它會讀取每個指令碼中的 Required-Start、建立相依性圖,並重新編號符號連結。如此一來,Debian 就能透過 startpar 同時執行互不相依的指令碼。這確實有所改善,但沒有解決更深層的問題。相依性仍然建立在指令碼結束這件事上。S20nginx 回傳 0 只表示 shell 函式已回傳,不表示 nginx 已開始接受連線。
init script 無法解決的 5 個問題
- 平行啟動。依檔名排序會對機器上的所有服務建立完整順序,因此開機時間會等於各項工作的總和。
- 就緒狀態。啟動 script 在 daemon 建立子程序後就會結束,而不是等到 daemon 能夠處理請求。因此,下一個 script 往往會過早啟動。
- 監督。daemon 連續 fork 2 次後,父程序會結束,daemon 便會脫離終端機,並重新由 PID 1 收養。init 只看得到子程序結束,無法可靠追蹤存活下來的程序。
- 依需求啟動。inetd(internet super-server)可以在收到連線時啟動 daemon,但它是獨立的系統,有自己的設定檔,而且無法處理開機時其他服務的啟動順序。
- 資源控制。init script 無法限制服務使用的記憶體或 CPU 比例。
ulimit只會套用到單一程序,nice也只會影響排程器,因此服務產生失控的子程序時,該程序看起來就和系統上的其他程序一樣。
監督機制的缺口是日常運作中最嚴重的問題。PID 檔案就是當時的替代方案:daemon 將其程序 ID 寫入 /run/nginx.pid,stop 函式再讀取該檔案。如果 daemon 被強制終止,檔案仍會留在原處。之後,kernel 可能將相同的編號重新配置給其他程序,而 start-stop-daemon --stop --pidfile 便會將 signal 傳送給目前擁有該編號的程序。過期的 PID 檔案會讓 init script 終止錯誤的程序。
launchd 先解決了 socket 問題
Apple 在 2005 年於 Mac OS X 10.4 中推出 launchd,由 Dave Zarzycki 撰寫。單一程序取代了 init、rc、xinetd、crond 和 watchdogd。
值得借鑑的概念是 socket activation。launchd 會先建立所有 listening socket,再啟動各個 daemon。用戶端連線至尚未啟動的 daemon 時,不會收到 connection refused,因為 kernel 會將連線保留在該 socket 的 backlog queue 中,直到 daemon 呼叫 accept()。兩個 daemon 之間的啟動順序不再需要由管理員宣告。socket 會處理這項工作。
launchd 建立於 Mach IPC(inter-process communication)之上。Mach IPC 隸屬於 Apple 的 XNU kernel,在 Linux 中沒有對等實作。直接移植這段程式碼從來不切實際,但這個概念仍然被採用了。
Upstart 將事件作為工作單位
Canonical 的 Upstart 由 Scott James Remnant 撰寫,於 2006 年 10 月隨 Ubuntu 6.10 發布。Fedora 9 至 Fedora 14 使用過 Upstart,RHEL 6 與 Chrome OS 也採用過。Upstart 以事件取代 runlevel,工作定義會啟動及停止該工作的事件。
# /etc/init/example.conf
start on filesystem and net-device-up IFACE!=lo
stop on runlevel [!2345]
respawn
respawn limit 10 5
exec /usr/local/bin/exampled隨著工作數量增加,出現了兩個問題。第一個是依賴方向。工作會說「發生這件事時啟動我」,因此依賴關係的資訊放在錯誤的檔案中:服務知道自己需要什麼,卻不可能知道明年會有哪些服務需要它。新增服務通常代表必須編輯既有工作,讓它發出新的事件。
第二個是追蹤方式。Upstart 透過計算搭配 ptrace 的 fork() 呼叫,追蹤會 fork 的 daemon;這項設定可指定為 expect fork 或 expect daemon。如果猜錯 fork 數量,Upstart 可能監控已經結束的程序,或等待已經發生的 fork。其症狀是 initctl start 無錯誤地持續等待,而工作檔案無法說明原因。
Upstart 也要求貢獻者簽署 Canonical 的貢獻者協議。這不是工程上的缺陷,但確實影響了參與開發的人員。
重新思考 PID 1,April 2010
2010 年 4 月 30 日,Lennart Poettering 發表了一篇名為「重新思考 PID 1」的文章。Kay Sievers 與他共同進行這項專案。其論點分為 4 個部分。
- 少啟動一些服務。許多服務可以等到實際收到請求時再啟動。
- 若 socket 能夠表示順序,就不必另外宣告啟動順序。一次開啟所有 socket,然後同時啟動所有服務。
- 使用 control groups 追蹤程序,不使用 PID 檔案。
- 以宣告式檔案描述服務,讓同一份描述可在各個發行版上使用。
第一個版本於同年發布。Fedora 14 在 2010 年 11 月將 systemd 作為選項提供,Fedora 15 則在 2011 年 5 月將其設為預設值。
cgroups 為何讓服務監督變得可靠
cgroup(control group)是用來分組程序的核心功能,已於 2008 年整合至 Linux 2.6.24。systemd 會將每項服務放入自己的 cgroup。子程序會繼承父程序的 cgroup,而非特權程序無法將自己移出 cgroup。因此,雙重 fork 不會隱藏任何程序:PID 1 隨時都能掌握屬於某個 unit 的完整程序集合。停止服務表示終止其 cgroup 中的所有程序,這正是預設 KillMode=control-group 的行為。
systemctl status 會輸出該群組:
● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-08-11 09:14:22 UTC; 3min ago
Main PID: 1042 (nginx)
Tasks: 3 (limit: 4653)
Memory: 6.1M (peak: 7.4M)
CGroup: /system.slice/nginx.service
├─1042 "nginx: master process /usr/sbin/nginx"
├─1043 "nginx: worker process"
└─1044 "nginx: worker process"這段輸出完整解釋了過時 PID 檔案的問題。不存在會變得過時的檔案,因為程序清單是核心狀態。
同一棵程序樹也會承載資源限制,因為 cgroups 最初是為了記帳而設計,之後才有人用於追蹤程序。MemoryMax=、CPUQuota= 和 TasksMax= 各佔一行。為服務設定記憶體與 CPU 硬上限,現在只需建立 drop-in 檔案;在 2009 年,這得修改一個沒人會撰寫的 shell script。
為什麼各個發行版在 2011 到 2015 年間都完成切換
- Fedora 15,2011 年 5 月。
- openSUSE 12.1,2011 年 11 月。
- Mageia 2,2012 年 5 月。
- Arch Linux,自 2012 年 10 月起,新安裝預設使用 systemd。
- RHEL 7,2014 年 6 月。
- SLES 12,2014 年 10 月。
- Debian 8,2015 年 4 月。
- Ubuntu 15.04,2015 年 4 月。
RHEL 7 的影響比其他發行版更廣,因為 CentOS 7 重新建置了它。多數系統管理員都是在這裡第一次接觸 unit file。這是 從 Red Hat Linux 經過 CentOS,發展至 Rocky 與 AlmaLinux 的更長故事中的一個轉折。
原因大多很普通,因此切換才能快速完成。
- 所有發行版都能使用同一個 unit file。因此,上游專案開始提供
.service檔案,發行版也不再為每個套件的每個版本維護一個 shell script。 - ConsoleKit 約在 2012 年停止維護後,桌面工作階段追蹤改用
systemd-logind。GNOME 需要 logind,因此未使用 systemd 的發行版必須尋找替代方案。該替代方案elogind,就是從 systemd 分離出來並獨立維護的 logind。 - udev 是裝置管理程式,於 2012 年 4 月併入 systemd 原始碼樹。提供 udev 的發行版當時也開始追蹤 systemd 的 repository。Gentoo 因此 fork 了
eudev。 - 容器讓可靠的程序追蹤與每個服務的限制更加重要,因為兩者都是 cgroup 的功能。當你 讓 Docker Compose stack 在重新開機後恢復執行時,哪個 supervisor 負責管理容器程序,這個問題至今仍然存在。
Debian 的決定最受矚目。Technical Committee 於 2014 年 2 月投票,結果票數相同。主席 Bdale Garbee 投下決定票,支持 systemd。幾天後,Ubuntu 宣布將跟隨 Debian,而不再繼續使用 Upstart。2014 年 11 月,一群 Debian 開發人員將該發行版 fork 為 Devuan,並於 2017 年 5 月發布 Devuan 1.0。
公平陳述這些反對意見
範圍。 現在有一個專案同時提供 PID 1、日誌 daemon、登入工作階段管理、裝置管理員、網路設定 daemon、DNS(domain name system)解析器、NTP(network time protocol)用戶端、容器執行器及開機載入程式。常見的辯護是:這些是彼此分開的二進位檔,不必全部安裝。這點沒錯,但無法回應反對意見。桌面環境需要 logind,而 logind 又從 systemd 的程式樹中釋出後,選擇就不再完全自由。這就是爭論中所說的耦合,而且確實已經發生。
二進位日誌。 journald 會寫入具索引的二進位格式,而不是純文字。這提供了純文字從未提供的功能:依單位和優先級篩選、結構化欄位,以及傳送程式無法偽造的中繼資料,因為 journald 會自行記錄單位和 cgroup。journalctl -u nginx -p err --since "-1h" 可用日期正規表示式取代 grep。這也確實有代價。無法開機的機器上,不能在救援 shell 中使用 less 讀取日誌。此時應將 journalctl 指向已掛載的磁碟:
sudo journalctl --directory /mnt/var/log/journal --boot -1 --priority err這裡還有另一個容易忽略的陷阱。除非存在 /var/log/journal,否則 journald 會將日誌保存在 /run/log/journal 中,而該位置位於記憶體。若機器沒有該項目,重新開機後 journalctl -b -1 就沒有任何內容可顯示,而那正是你最需要日誌的時刻。請檢查並修正:
journalctl --disk-usage
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald現在 journalctl --disk-usage 應該會回報 /var/log/journal 下的封存日誌。若也需要純文字日誌,請在 /etc/systemd/journald.conf 中設定 ForwardToSyslog=yes,並保留 rsyslog 的安裝。
開機程序的可除錯性。 單位卡住時,主控台只會顯示一行內容,之後沒有其他資訊:
[ *** ] A start job is running for Wait for Network to be Configured (1min 32s / no limit)可以進一步處理的工具確實存在:卡住時使用 systemctl list-jobs,之後使用 systemd-analyze blame 和 systemd-analyze critical-chain,並在核心命令列加入 systemd.log_level=debug。較公平的說法是:只要熟悉 sh,任何人都能從頭到尾讀完 init script;但遇到卡住的單位時,必須知道十幾個命令中該使用哪一個。這確實是代價。每位管理員都要付出一次,而當時有許多管理員同時付出了這項代價。
所有人都會受到影響的預設值。 systemd 230 在 2016 年變更了 logind 的預設行為:使用者登出時會終止殘留的使用者程序。當啟動 detached tmux 和 screen 工作階段的工作階段結束時,這些工作階段也會終止。各發行版在 /etc/systemd/logind.conf 中提供 KillUserProcesses=no,而支援的設定方式是 loginctl enable-linger <user>。一個專案中的一項預設值,就改變了數百萬人所依賴的使用習慣;這就是「在同一處集中過多 userland」在實務上的含義。
預設相依性就是安全性攻擊面。 2024 年 3 月,xz-utils 中的後門鎖定 Debian 和 Ubuntu 上的 sshd。上游 OpenSSH 並未連結 libsystemd。這些發行版將其加入修補,讓 sshd 能向 systemd 回報就緒狀態;而 libsystemd 又拉入了 liblzma,後門便存在於其中。就緒協定本身只是傳送至 $NOTIFY_SOCKET 所指定 socket 的單一 datagram,因此從未需要任何 library。systemd 的回應是使用 dlopen 載入壓縮 library,使其不再預設連結。另一類相關錯誤也呈現相同模式:2017 年,開頭為數字的 User= 值會被視為無效,單位因此以 root 執行,而不是直接失敗,導致拼寫錯誤變成權限提升。後續版本會拒絕啟動該單位。
自行在 systemctl 提示字元中追溯歷史
現在,上述每個問題都已成為檔案中的一項指令,您可以直接讀取。
- 序列式開機成為
After=與Wants=,而systemd-analyze critical-chain會顯示實際阻塞開機的項目。 - 就緒狀態成為
Type=notify;服務可提供服務時,會將READY=1寫入$NOTIFY_SOCKET。搭配PIDFile=的Type=forking仍用於舊版 daemon;如果 PID 檔案始終未出現,這種類型會以start operation timed out. Terminating.失敗。選錯類型會讓 unit 回報 active,但它啟動的 daemon 早已結束。因此,在寫入該行前,請先了解 哪個 Type= 符合 daemon 實際啟動的方式。 - 監控改由 cgroup 負責,因此搭配
RestartSec=的Restart=on-failure可取代 wrapper script,而StartLimitBurst=可避免當機重啟迴圈無限執行。 - inetd 成為與
.serviceunit 並列的.socketunit。 ulimit成為MemoryMax=、CPUQuota=與TasksMax=。- init script 中的
su - appuser -c行成為User=、NoNewPrivileges=yes與ProtectSystem=strict。因此,以非特權使用者執行服務 是 unit 的預設形式,而不是額外工作。
[Unit]
Description=Example API
Wants=network-online.target
After=network-online.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
ExecStart=/usr/local/bin/exampled
Restart=on-failure
RestartSec=2
MemoryMax=512M
CPUQuota=50%
TasksMax=128
User=exampled
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
StateDirectory=exampled
[Install]
WantedBy=multi-user.target檔案中的一行設定,是每個人都會犯一次的錯誤。Requires=postgresql.service 是相依條件,不是啟動順序;它表示 Postgres 失敗時,您的 unit 也會失敗,但不表示要先啟動 Postgres。沒有 After=postgresql.service 時,兩者會同時啟動,而您的服務會連線到尚未有程序監聽的連接埠。這兩項設定刻意分開,因為有時只需要其中一項。ProtectSystem=strict 會為此服務以唯讀方式掛載檔案系統,因此需要 StateDirectory=:它會在 /var/lib 下提供一個可寫入的路徑給服務。
在採用 systemd 255 的 Ubuntu 24.04 上執行 SSH,是在 2026 伺服器中看見 2005 年設計最清楚的地方;截至 August 2026,該版本隨附 systemd 255。ssh.service 預設由 socket 啟動:ssh.socket 持有監聽 socket,而 sshd 會在收到連線時啟動。因此,/etc/ssh/sshd_config 中的 Port 2222 不會生效,因為開啟連接埠的不是 sshd 程序。這項變更應套用在 socket unit。
sudo systemctl edit ssh.socket[Socket]
ListenStream=
ListenStream=2222空白的 ListenStream= 會清除從套件提供的 unit 繼承而來的值。省略它會同時取得兩個連接埠,因為 systemd 會將清單附加上去,而不是取代清單。接著套用並檢查設定;整個過程都要保持第二個 SSH 工作階段開啟:
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ss -lntp | grep 2222ss 應列出一個由 systemd 擁有、監聽連接埠 2222 的 socket,而不是由 sshd 擁有。這就是 launchd 的設計;二十年後,該設計出現在您的 VPS 上。如果偏好舊行為,執行 sudo systemctl disable --now ssh.socket,再執行 sudo systemctl enable --now ssh.service,即可讓長時間執行的 sshd 再次從自己的設定讀取 Port。
您會遇到哪些細節,取決於所執行的版本。因此,在規劃升級前,值得先了解 LTS 與 interim Ubuntu 版本的差異。在多台機器之間,unit 檔案在各處完全相同,正是從同一處管理多台伺服器如今成為設定管理問題、而非 shell scripting 問題的原因。建立自己的 unit 時,service 與 timer 配對會完成您在 2009 年原本會分別交給 init script 和 cron 行的工作。
FAQ
Linux 發行版為什麼以 systemd 取代 SysV init?
原因有兩項工程考量,以及一項維護考量。SysV init 依檔名排列服務順序,但檔名位置不代表相依性;它也無法追蹤已從父程序分叉出去的 daemon,因此過時的 PID 檔案可能會終止錯誤的程序。systemd 以 socket activation 和相依性指示解決排序問題,並以 control group 解決程序追蹤問題。維護考量則決定了轉換速度:同一個 unit 檔案可在所有發行版上運作,因此上游專案會隨附 .service 檔案,發行版維護者也不再為每個套件各自撰寫 shell script。Fedora 15 於 2011 年 5 月改用 systemd,而 Ubuntu 15.04 是最後一個主要延後轉換的發行版,直到 2015 年 4 月才轉換。
systemd 是一個巨型二進位檔嗎?
不是。原始碼樹會建置許多個獨立程式。PID 1 是 /usr/lib/systemd/systemd,而 journald、logind 和 udevd 都是擁有各自二進位檔的獨立程序;執行 ls /usr/lib/systemd/ 即可在自己的主機上查看。至今仍存在的批評,焦點是版本發布的耦合,而不是二進位檔大小:這些程式會一同發布,並共用私有介面,因此發行版通常會將它們視為一組採用;GNOME 等軟體也逐漸要求特定使用 logind。
我仍然可以在不使用 systemd 的情況下執行 Linux 嗎?
可以。Devuan 隨附 sysvinit,Gentoo 預設使用 OpenRC,Void 使用 runit,Alpine 使用搭配 OpenRC 的 busybox init,而 Slackware 保留 BSD 風格的 script。代價是相容性維護工作。需要 logind 的桌面軟體必須使用 elogind;這是以獨立套件形式維護的 systemd logind。現在越來越多伺服器軟體只隨附 .service 檔案,因此您必須自行撰寫並維護啟動 script。
journal 為什麼使用二進位格式,而不是純文字檔案?
因為 journald 會儲存帶有索引的結構化欄位,提供依 unit 和優先級進行篩選的功能,以及傳送程式無法偽造的中繼資料:journald 會自行記錄 unit、cgroup 和實際 UID,而不是信任 log 行內容。代價是您需要使用 journalctl 讀取它,包括從救援系統讀取時也是如此;此時可透過 journalctl --directory /mnt/var/log/journal 指定已掛載的磁碟。若也需要文字格式,請在 /etc/systemd/journald.conf 中設定 ForwardToSyslog=yes。
什麼取代了直接編輯 /etc/init.d script 的做法?
使用 drop-in 檔案。不要編輯 /usr/lib/systemd/system/ 中的 unit,因為套件升級會覆寫它。執行 sudo systemctl edit nginx.service,systemd 就會建立 /etc/systemd/system/nginx.service.d/override.conf,並將其合併至套件提供的 unit 上方。systemctl cat nginx.service 會顯示合併後的結果,而 systemd-delta 會列出機器上的所有覆寫設定。手動完成任何編輯後,請執行 sudo systemctl daemon-reload,否則下一個命令會輸出 Warning: The unit file, source configuration file or drop-ins of nginx.service changed on disk.