一文詳解 Systemd 工作原理

在我決定寫這文章之前,我已知道 Systemd 是 Linux 中的初始化(init)進程,並且是所有其他進程運行的進程。我執行過一些 Systemd 命令,但坦白說,我並未真正深入瞭解過它。我從未思考過 Systemd 如何知道運行哪些進程或者它還能做些什麼。我只知道當一個進程啓動,Systemd 會以某種方式接管它。

有許多出色的資源詳細介紹了 Systemd 的運行原理,但真正幫助我開始理解是自己設置它來管理一個服務。在這個教程中,我們將設定一個簡單的 Golang 程序,並讓 Systemd 在後臺運行它。我們將確保它在被終止後會重啓,並且我們還會確保 Systemd 在系統啓動時啓動該進程。這樣,我們可以深入地瞭解 Systemd 的運行原理和它的其他功能。

01 麼是初始化進程?

在 Linux 進程世界中,init 進程是至高無上的。 它是在內核加載後第一個啓動的進程,所有其他進程都在其之下運行。init 進程首先通過初始化必要的進程將機器轉變爲一個可以正常運行的狀態。然後,它負責在系統運行期間啓動和停止進程,以及在系統關閉時安全地終止進程。

在舊版本的 Linux 中,init 進程是一個或一系列的 shell 腳本。但是,大多數現代 Linux 發行版已將 shell 腳本替換爲一個名爲 systemd 1 的二進制程序。Systemd 是一個較新的 init 系統,設計用來提高性能和簡化服務管理。它使用稱爲單元的配置文件來定義和管理服務,並提供瞭如並行服務啓動、套接字激活和按需服務啓動等高級功能。它還管理日誌。對於一個程序來說,要做的事情太多了,這就是爲什麼 Systemd 不僅僅是一個程序,它實際上是一套協同工作的工具集。

我應該指出,並非所有的 Linux 發行版都使用 Systemd,但許多已經切換過來。你應該檢查你特定的 Linux 版本使用哪種 init 進程。

以下是一些使用 Systemd 的流行 Linux 發行版:

02 快速瞭解父進程和子進程

在我們開始設定我們的 Golang 應用被 Systemd 管理之前,讓我們快速瀏覽一下 Linux 進程。

我有一個運行 Ubuntu 22.04.2 LTS 的 Amazon EC2 實例。我們可以使用 htop 來獲取所有正在運行的進程列表。一旦進入 htop,按下 t 鍵就可以得到一個樹形視圖,這樣我們就可以看到哪些進程正在其他進程之下運行。

htop 中的樹視圖

請看最頂端的進程,PID 爲 1 的 /sbin/init。它的 PID 爲 1,因爲它是在我們啓動這個 Linux 實例時啓動的第一個進程。你也會注意到,在我們的樹視圖中,所有其他的進程都存在於 /sbin/init 的分支下。

但是等等,我認爲 Systemd 是 init 進程呢?讓我們到 /sbin 目錄下看看。

ubuntu@ip-xxxx:/sbin$ ls -lah | grep init
lrwxrwxrwx  1 root root     20 Mar 20 14:32 init -> /lib/systemd/systemd

如果我們更近一步研究 init,我們可以看到它其實只是一個指向 /lib/systemd/systemd 的符號鏈接。所以實際上,所有的進程都是由 Systemd 運行的。

我們的進程

爲了詳細介紹如何設定一個進程由 Systemd 管理,我們將使用一個簡單的 Go 程序作爲例子,該程序每秒打印一次 “Hello World”。

package main

import (
    "fmt"
    "time"
)

func main() {

  for {
      fmt.Println("Hello World")
        time.Sleep(1 * time.Second)
  }

}

代碼和可執行文件位於我的主目錄中。

ubuntu@ip-xxxx:~/hello-world$ ls
go.mod  hello-world  main.go

讓我們運行一下吧。

ubuntu@ip-xxxx:~/hello-world$ ./hello-world
Hello World
Hello World
Hello World
...

當它運行時,我們可以在單獨的選項卡中打開 htop。

我們的 hello world 進程在 bash 下運行

這張圖片被放大了,因此我們看不到最頂端的 init 進程,但是請相信,它仍然在那裏,觀察着一切。我們可以沿着樹狀結構向下追蹤到 SSH(這是我連接到這個實例的方式)。SSH 下有它自己運行的子進程。在這些子進程中,有我們的 shell,即 bash,這是我們運行 hello-world 的環境。最後,在 bash 下面,我們有我們的 hello-world 進程。(在運行一個簡單的 Go 程序時出現三個子進程,可能是因爲 Go 運行時和操作系統處理進程創建和管理的方式。)

03 什麼是 Systemd 單元?

當我們談論 Linux 中長期運行的進程時,我們通常將它們稱爲服務或守護進程。Systemd 稱它們爲單元。Systemd 單元可以是服務,比如我們的 hello world 程序,但它也可以是其他 Systemd 可以管理的東西,如套接字、掛載點或目標(稍後會更詳細地討論)。

單元在單元文件中定義。Systemd 單元文件是一種配置文件,定義了各種系統組件。每個單元文件包含了關於單元行爲、依賴關係、啓動條件以及 Systemd 管理和監控相應系統資源所需的其他設置的信息。

這些單元文件存在於幾個不同的地方。瞭解其中的一些是非常有幫助的。

如何創建你自己的 Systemd 單元

現在我們可以創建自己的 Systemd 單元文件來管理我們的 hello-world 進程。創建一個名爲 /etc/systemd/system/hello-world.service 的文件。

[Unit]
Description=Hello World Service

[Service]
ExecStart=/home/ubuntu/hello-world/hello-world

[Install]
WantedBy=multi-user.target

這裏最引人注目的是 ExecStart,它告訴 Systemd 如何啓動我們的服務。WantedBy 使我們可以設置程序運行所需的依賴關係。我們可以用它來聲明另一個服務,但聲明一個目標也很常見。

目標是單元的組,經常被用來指定你可能希望你的系統處於的某個狀態,類似於 Unix 中的運行級別。在這種情況下,設置 multi-user.target 確保系統處於可以進行多用戶交互的狀態。在這裏聲明它就是告訴 Systemd,當它達到多用戶目標狀態時,應啓動我們的 hello-world 服務。

如果你需要知道某個目標包含哪些其他單元,檢查起來很容易。目標是 Systemd 可以管理的另一種形式的單元,並且它們的配置方式與我們的服務相同;都在單元文件中配置。我們可以在 /lib/systemd/system/multi-user.target 找到多用戶目標的單元文件,但這並不能顯示所有的依賴關係,因爲它們並不全都在一個文件中聲明。要查看多用戶目標(或任何單元)的所有依賴關係,我們可以運行:

ubuntu@ip-xxxx:~$ sudo systemctl list-dependencies multi-user.target
multi-user.target
● ├─apport.service
● ├─chrony.service
● ├─console-setup.service
● ├─cron.service
● ├─dbus.service
○ ├─dmesg.service
○ ├─e2scrub_reap.service
○ ├─ec2-instance-connect.service
○ ├─grub-common.service
○ ├─grub-initrd-fallback.service
○ ├─hibinit-agent.service
○ ├─irqbalance.service
○ ├─lxd-agent.service
● ├─networkd-dispatcher.service
○ ├─open-vm-tools.service
● ├─plymouth-quit-wait.service
● ├─plymouth-quit.service
....

爲了節省空間,這裏的輸出被截斷了,但你應該明白這是什麼意思。注意,我們還沒看到我們的 hello-world 程序列出來。我們馬上就會看到的。

僅僅創建單元文件並不能讓 Systemd 開始管理我們的程序。我們需要進行兩個步驟。首先,我們需要讓 Systemd 讀取我們新創建的。service 單元文件。我們可以使用以下命令來實現:

systemctl daemon-reload

這會導致 Systemd 重新讀取所有單元文件。現在 Systemd 瞭解了我們的單元,我們可以檢查服務的狀態。

ubuntu@ip-xxxx:/etc/systemd/system$ sudo systemctl status hello-world
○ hello-world.service - Hello World Service
     Loaded: loaded (/etc/systemd/system/hello-world.service; enabled; vendor preset: enabled)
     Active: inactive (dead)

目前,我們的服務處於非活動狀態。讓我們啓動它,然後再次檢查狀態。

ubuntu@ip-xxxx:/etc/systemd/system$ sudo systemctl status hello-world
● hello-world.service - Hello World Service
     Loaded: loaded (/etc/systemd/system/hello-world.service; enabled; vendor preset: enabled)
     Active: active (running) since Thu 2023-07-20 20:51:37 UTC; 14s ago
   Main PID: 179038 (hello-world)
      Tasks: 3 (limit: 1141)
     Memory: 564.0K
        CPU: 3ms
     CGroup: /system.slice/hello-world.service
             └─179038 /home/ubuntu/hello-world/hello-world


Jul 20 20:51:42 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:43 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:44 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:45 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:46 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:47 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:48 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:49 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:50 ip-xxxx hello-world[179038]: Hello World
Jul 20 20:51:51 ip-xxxx hello-world[179038]: Hello World

注意狀態是如何從非激活(死亡)轉變爲激活(運行)。同時,也要注意,我們在狀態信息下方獲取到了日誌的最後十行,這非常酷。我們再次查看 htop,並查看我們的 hello-world 進程在樹視圖中的顯示位置。

我們的 hello-world 進程在 Systemd 下運行

我們可以看到,與我們之前從 bash 中運行的進程不同,它現在顯示爲 /sbin/init(即 Systemd)的直接子進程。

如果我們再次檢查 multi-user target 的依賴性,我們也可以看到:

ubuntu@ip-xxxx:~$ sudo systemctl list-dependencies multi-user.target
multi-user.target
● ├─apport.service
● ├─chrony.service
● ├─console-setup.service
● ├─cron.service
● ├─dbus.service
○ ├─dmesg.service
○ ├─e2scrub_reap.service
○ ├─ec2-instance-connect.service
○ ├─grub-common.service
○ ├─grub-initrd-fallback.service
● ├─hello-world.service
○ ├─hibinit-agent.service
○ ├─irqbalance.service

嘿,看看這,列表中的第 11 個就是我們的 hello-world.service!太好了。現在我們的服務已經在後臺運行了。如果我們把它關掉會發生什麼呢?

ubuntu@ip-xxxx:~$ sudo kill 179038

正如我們所期望的,我們的單位變得不活躍。我們還可以在日誌中看到它被停用的位置。

○ hello-world.service - Hello World Service
     Loaded: loaded (/etc/systemd/system/hello-world.service; enabled; vendor preset: enabled)
     Active: inactive (dead) since Mon 2023-07-24 19:18:25 UTC; 1min 41s ago
    Process: 179038 ExecStart=/home/ubuntu/hello-world/hello-world (code=killed, signal=TERM)
    Main PID: 179038 (code=killed, signal=TERM)
        CPU: 39.210s

Jul 24 19:18:18 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:19 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:20 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:21 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:22 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:23 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:24 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:25 ip-xxxx hello-world[179038]: Hello World
Jul 24 19:18:25 ip-xxxx systemd[1]: hello-world.service: Deactivated successfully.
Jul 24 19:18:25 ip-xxxx systemd[1]: hello-world.service: Consumed 39.210s CPU time.

但如果我們希望服務一直在運行呢?在這種情況下,我們可以告訴 Systemd,當服務停止時,應該重新啓動它。我們需要做的就是更新我們的單元文件。

我們有兩種方式來告訴 Systemd 何時重新啓動我們的服務。設置 Restart=always 將確保無論何時,只要服務停止,Systemd 就會重新啓動它,即使我們手動停止了它。而設置 Restart=on-failure 則會告訴 Systemd,只有在服務因錯誤(返回非零狀態)退出時,才需要重新啓動服務;基本上就是當它崩潰時。

[Unit]
Description=Hello World Service

[Service]
ExecStart=/home/ubuntu/hello-world/hello-world
Restart=always

[Install]
WantedBy=multi-user.target

現在讓我們重新啓動我們的服務。

_ubuntu@ip-xxxx:~$ sudo systemctl start hello-world
Warning: The unit file, source configuration file or drop-ins of hello-world.service changed on disk. Run 'systemctl daemon-reload' to reload units.

似乎 Systemd 檢測到我們的文件已更改。這很酷。我們只需按照錯誤中的說明讓 Systemd 重新讀取單元文件即可。

我不完全確定這在所有情況下都是必要的,但我也將重新啓動該服務。

_ubuntu@ip-xxxx:~$ sudo systemctl restart hello-world

現在,如果你再次檢查狀態,它應該處於活動狀態。那麼我們就殺掉它吧。

ubuntu@ip-xxxx:~$ sudo kill 190646

然後再次檢查狀態。雖然服務仍然處於活動狀態,但這是因爲 Systemd 在我們檢查狀態和發現它已停用之前,就已經將其快速重新啓動了。但無需擔憂,因爲我們可以直接從日誌中看到單元何時被停止以及何時重新啓動。

ubuntu@ip-xxxx:~$ sudo systemctl status hello-world
● hello-world.service - Hello World Service
     Loaded: loaded (/etc/systemd/system/hello-world.service; enabled; vendor preset: enabled)
     Active: active (running) since Mon 2023-07-24 19:43:23 UTC; 2s ago
   Main PID: 190658 (hello-world)
      Tasks: 3 (limit: 1141)
     Memory: 572.0K
        CPU: 1ms
     CGroup: /system.slice/hello-world.service
             └─190658 /home/ubuntu/hello-world/hello-world

Jul 24 19:43:23 ip-xxxx systemd[1]: hello-world.service: Scheduled restart job, restart counter is at 1.
Jul 24 19:43:23 ip-xxxx systemd[1]: Stopped Hello World Service.
Jul 24 19:43:23 ip-xxxx hello-world[190658]: Hello World
Jul 24 19:43:23 ip-xxxx systemd[1]: Started Hello World Service.
Jul 24 19:43:24 ip-xxxx hello-world[190658]: Hello World
Jul 24 19:43:25 ip-xxxx hello-world[190658]: Hello World

這只是我們開始利用 Systemd 在單元文件中配置的一小部分。我們還可以爲我們的服務設定資源限制,配置套接字,設定文件權限等等。

04 關於 journalctl

儘管我們可以另寫一篇完整的博文來介紹 journalctl,但我還是想在這裏簡要地提一下,因爲它可以是使用 Systemd 的一大優點。當 Systemd 管理服務單元時,它會自動捕獲其標準輸出和標準錯誤流,並將它們存儲在日誌中。這意味着任何由服務產生的日誌消息或輸出都會被記錄並可以通過 journalctl 命令獲取。

我們可以使用 journalctl -u hello-world 來檢查 hello-world 的日誌。這會給出所有的日誌,但這可能會讓你覺得瀏覽起來有些喫力。更常見的是,你可能在調試時需要從特定的時間範圍獲取日誌。爲此,你可以使用 --since 和 --until 選項。

journalctl -u hello-world --since "2023-07-25 12:00:00" --until "2023-07-25 18:00:00"

05 結論

確定這篇文章要涵蓋的內容真的很難。Systemd 比我預期的更大也更復雜。我試圖提供一種實踐的方法來介紹一些 Systemd 的內部運作,並且希望重現那些真正幫助我開始理解 Systemd 的課程和知識。

如果你希望更深入地理解,我建議你接下來研究以下主題:

如果你殺死 Systemd 會發生什麼?

最後,我知道你在想如果你執行 sudo kill -9 1 會發生什麼,我可以告訴你,在 Ubuntu 的 EC2 機器上,這並不會觸發內核恐慌或在亞馬遜引發爆炸,這有點令人失望。

要理解爲什麼,我們需要回顧一下 kill 命令的幾個要點。默認情況下,kill 命令會向進程發送一個 SIGTERM 信號。SIGTERM 信號可以被進程捕獲,並用於優雅地退出程序。

但我們也可以通過添加 - 9,即 sudo kill -9 ,向進程發送 SIGKILL 信號。SIGKILL 信號無法被進程捕獲,這意味着在你的程序中,你無法忽略或優雅地處理 SIGKILL。你的進程被終止了,這對你來說是個壞消息。但這對所有進程都是如此,除了 init。init 是 Linux 內核允許捕獲並忽略 SIGKILL 的唯一進程。

因此,實際上,sudo kill -9 1 什麼也不做。SIGKILL 就這樣消失了。你甚至在日誌中都看不到任何東西,除了你執行了這個命令。這是個好消息,因爲殺死 Systemd 肯定會引發內核恐慌,這對於管理員來說是非常糟糕的事情。但對於我們這些有破壞慾望的人來說,這是非常悲傷的事情。

那麼如果我們不能發送 SIGKILL,那麼可以發送 SIGTERM,SIGKILL 的可捕獲的兄弟信號嗎?其實,我們可以做到這一點。結果可能並不會引發爆炸,但它們會更有趣一些。

當我執行 sudo kill 1 時,起初好像什麼也沒發生。系統還在運行,我甚至還保持着 SSH 連接。我唯一注意到的變化是 PID 1 從 /sbin/init 變爲了 /lib/systemd/systemd --system --deserialize 12。

/sbin/init 被 /lib/systemd/systemd --system --deserialize 12 取代

所以我推測存在一段代碼,一旦 Systemd 收到 SIGTERM 信號,它會立即重啓 Systemd。當它重啓時,它會直接調用 Systemd,而不是通過符號鏈接來訪問它。(或者類似的操作。)

我進行了一些深入的研究,我在一些論壇上找到了這樣的信息:

--deserialize 用於恢復一個先前的 Systemd 實例通過 exec() 函數調用時,已經寫入到文件中的內部狀態。它的選項參數是該進程的一個開放的文件描述符。

所以無論何種防止故障的機制,似乎都會重新啓動 Systemd 並恢復到之前保存的狀態,這就是爲什麼我並沒有真正注意到任何的干擾。這個功能非常棒。我想我需要找其他的東西來打破。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9Wu81h3HV5pvF42D7Zw5yA