真正運(yùn)行容器的工具:深入了解 Runc 和 OCI 規(guī)范
我們談?wù)勎挥?Docker、Podman、CRI-O 和 Containerd 核心的工具:runc。
原始容器運(yùn)行時(shí)
如果試圖將鏈從最終用戶繪制到實(shí)際的容器進(jìn)程,它可能如下所示:
runc 是一個(gè)命令行客戶端,用于運(yùn)行根據(jù) Open Container Initiative (OCI) 格式打包的應(yīng)用程序,并且是 Open Container Initiative 規(guī)范的兼容實(shí)現(xiàn)。
有一個(gè)關(guān)于如何運(yùn)行容器和管理容器映像的開(kāi)放容器計(jì)劃(OCI) 和規(guī)范。runc 符合此規(guī)范,但還有其他符合 OCI 的運(yùn)行時(shí)。甚至可以運(yùn)行符合 OCI 標(biāo)準(zhǔn)的虛擬機(jī),Kata Containers 與gVisor就是符合符合 OCI 標(biāo)準(zhǔn)的虛擬機(jī)。gVisor 為代表的用戶態(tài) Kernel 方案是安全容器的未來(lái),只是現(xiàn)在還不夠完善。
runc 希望提供一個(gè)“ OCI 包”,它只是一個(gè)根文件系統(tǒng)和一個(gè)config.json 文件。而不是Podman 或 Docker 那樣有“鏡像”概念,所以不能只執(zhí)行runc run nginx:latest這樣來(lái)啟動(dòng)一個(gè)容器。
Runc 符合 OCI 規(guī)范(具體來(lái)說(shuō),是runtime-spec),這意味著它可以使用 OCI 包并從中運(yùn)行一個(gè)容器。值得重申的是,這些bundle并不是“容器鏡像”,它們要簡(jiǎn)單得多。層、標(biāo)簽、容器注冊(cè)表和存儲(chǔ)庫(kù)等功能 - 所有這些都不是 OCI 包甚至運(yùn)行時(shí)規(guī)范的一部分。有一個(gè)單獨(dú)的 OCI-spec (image-spec )定義鏡像。
文件系統(tǒng)包是你下載容器鏡像并解壓后得到的。所以它是這樣的:
- OCI Image -> OCI Runtime Bundle -> OCI Runtime
在我們的例子中,這意味著:
- Container image -> Root filesystem and config.json -> runc
讓我們構(gòu)建一個(gè)應(yīng)用程序包。我們可以從 config.json 文件開(kāi)始,因?yàn)檫@部分非常簡(jiǎn)單:
- mkdir my-bundle
- cd my-bundle
- runc spec
runc spec生成一個(gè)虛擬的 config.json。它已經(jīng)有一個(gè)“進(jìn)程”部分,用于指定在容器內(nèi)運(yùn)行哪個(gè)進(jìn)程 - 即使有幾個(gè)環(huán)境變量。
- {
- "ociVersion": "1.0.1-dev",
- "process": {
- "terminal": true,
- "user": {
- "uid": 0,
- "gid": 0
- },
- "args": [
- "sh"
- ],
- "env": [
- "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
- "TERM=xterm"
- ],
- ...
它還定義了在哪里查找根文件系統(tǒng)...
- ...
- "root": {
- "path": "rootfs",
- "readonly": true
- },
- ...
...以及其他許多內(nèi)容,包括容器內(nèi)的默認(rèn)掛載、功能、主機(jī)名等。如果檢查此文件,會(huì)注意到,許多部分與平臺(tái)無(wú)關(guān),并且特定于具體操作系統(tǒng)的部分嵌套在適當(dāng)?shù)膬?nèi)部部分。例如,會(huì)注意到有一個(gè)帶有 Linux 特定選項(xiàng)的“linux”部分。
如果我們嘗試運(yùn)行這個(gè)包,我們會(huì)得到一個(gè)錯(cuò)誤:
- # runc run test
- rootfs (/root/my-bundle/rootfs) does not exist
如果我們簡(jiǎn)單地創(chuàng)建文件夾,我們會(huì)得到另一個(gè)錯(cuò)誤:
- # mkdir rootfs
- # runc run test
- container_linux.go:345: starting container process caused "exec: \"sh\": executable file not found in $PATH"
這完全有道理 - 空文件夾并不是真正有用的根文件系統(tǒng),我們的容器沒(méi)有機(jī)會(huì)做任何有用的事情。我們需要?jiǎng)?chuàng)建一個(gè)真正的 Linux 根文件系統(tǒng)。這里可以使用如下命令解壓rootfs:
- $ docker export $(docker create busybox) | tar -C /mycontainer/rootfs -xvf -
這里我們使用skopeo 和 umoci 獲取 OCI 應(yīng)用程序包。
如何使用 skopeo 和 umoci 獲取 OCI 應(yīng)用程序包
從頭開(kāi)始創(chuàng)建 rootfilesystem 是一種相當(dāng)麻煩的事情,因此讓我們使用現(xiàn)有的最小映像之一 busybox。
要拉取鏡像,我們首先需要安裝skopeo。我們也可以使用 Buildah,但它的功能太多,無(wú)法滿足我們的需求。Buildah 專注于構(gòu)建鏡像,甚至具有運(yùn)行容器的基本功能。由于我們今天盡可能地低級(jí)別,我們將使用 skopeo:
- skopeo 是一個(gè)命令行程序,可對(duì)容器鏡像和鏡像存儲(chǔ)庫(kù)執(zhí)行各種操作。
- skopeo 可以在不同來(lái)源和目的地之間復(fù)制鏡像、檢查鏡像甚至刪除它們。
- skopeo 無(wú)法構(gòu)建映像,它不知道如何處理 Containerfile。它非常適合自動(dòng)化容器鏡像升級(jí)的 CI/CD 管道。
- yum install skopeo -y
然后復(fù)制busybox鏡像:
- skopeo copy docker://busybox:latest oci:busybox:latest
沒(méi)有“拉取”——我們需要告訴 skopeo 鏡像的來(lái)源和目的地。skopeo 支持幾乎十幾種不同類型的來(lái)源和目的地。請(qǐng)注意,此命令將創(chuàng)建一個(gè)新busybox文件夾,將在其中找到所有 OCI 鏡像文件,具有不同的鏡像層、清單等。
不要混淆 Image manifest 和 Application runtime bundle manifest,它們是不一樣的。
我們復(fù)制的是一個(gè) OCI Image,但是我們已經(jīng)知道,runc 需要 OCI Runtime Bundle。我們需要一個(gè)將鏡像轉(zhuǎn)換為解壓包的工具。這個(gè)工具將是umoci - 一個(gè) openSUSE 實(shí)用程序,其唯一目的是操作 OCI 鏡像。要安裝它,請(qǐng)從 Github Releases獲取最新版本的PATH。在撰寫本文時(shí),最新版本是0.4.5. umoci unpack獲取 OCI 鏡像并從中制作一個(gè)包:
- umoci unpack --image busybox:latest bundle
讓我們看看bundle文件夾里面有什么:
- # ls bundle
- config.json
- rootfs
- sha256_73c6c5e21d7d3467437633012becf19e632b2589234d7c6d0560083e1c70cd23.mtree
- umoci.json
讓我們將rootfs目錄復(fù)制到之前創(chuàng)建的my-bundle目錄。如果你好奇,這是rootfs的內(nèi)容,如下:
- bin dev etc home root tmp usr var
如果它看起來(lái)像一個(gè)基本的 Linux 根文件系統(tǒng),那么就是對(duì)的。
根據(jù) OCI Runtime 規(guī)范,Linux ABI 下的應(yīng)用程序會(huì)期望 Linux 環(huán)境提供以下特殊的文件系統(tǒng):
- /proc 文件夾,掛載 proc 文件系統(tǒng)。
- /sys 文件夾,掛載 sysfs 文件系統(tǒng)。
- /dev/pts 文件夾,掛載 devpts 文件系統(tǒng)。
- /dev/shm 文件夾,掛載 tmpfs 文件系統(tǒng)。
這幾個(gè)文件夾的作用這里略去,有興趣的讀者可以自行查閱 man7.org。runc 文檔中還額外要求提供:
- /dev 文件夾,掛載 tmpfs 文件系統(tǒng)。
- /dev/mqueue 文件夾,掛載 mqueue 文件系統(tǒng)。
runc 是 OCI Runtime 規(guī)范的參考實(shí)現(xiàn),規(guī)范為容器的創(chuàng)建提供了整潔的接口,只需要為 runc 提供一份 config.json [1]。
使用 runc 運(yùn)行 OCI 應(yīng)用程序包
我們準(zhǔn)備好將我們的應(yīng)用程序包作為名為 的容器運(yùn)行test:
- runc run test
接下來(lái)發(fā)生的事情是我們最終進(jìn)入了一個(gè)新創(chuàng)建的容器內(nèi)的 shell!
- # runc run test
- / # ls
- bin dev etc home proc root sys tmp usr var
我們以默認(rèn)foreground模式運(yùn)行前一個(gè)容器。在這種模式下,每個(gè)容器進(jìn)程都成為一個(gè)長(zhǎng)時(shí)間運(yùn)行的runc進(jìn)程的子進(jìn)程:
- 6801 997 \_ sshd: root [priv]
- 6805 6801 \_ sshd: root@pts/1
- 6806 6805 \_ -bash
- 6825 6806 \_ zsh
- 7342 6825 \_ runc run test
- 7360 7342 | \_ runc run test
如果我終止與該服務(wù)器的 ssh 會(huì)話,runc 進(jìn)程也會(huì)終止,最終殺死容器進(jìn)程。讓我們通過(guò)sleep infinite在 config.json 中替換 command并將終端選項(xiàng)設(shè)置為“false”來(lái)更仔細(xì)地檢查這個(gè)容器。
runc不提供大量的命令行參數(shù)。它有類似start,stop和 run的命令來(lái)做容器的生命周期管理,但是容器的配置總是來(lái)自文件,而不是來(lái)自命令行:
- {
- "ociVersion": "1.0.1-dev",
- "process": {
- "terminal": false,
- "user": {
- "uid": 0,
- "gid": 0
- },
- "args": [
- "sleep",
- "infinite"
- ]
- ...
這次讓我們以分離模式運(yùn)行容器:
- runc run test --detach
我們可以看到正在運(yùn)行的容器runc list:
- ID PID STATUS BUNDLE CREATED OWNER
- test 4258 running /root/my-bundle 2020-04-23T20:29:39.371137097Z root
在 Docker 的情況下,有一個(gè)Docker Daemon守護(hù)進(jìn)程知道關(guān)于容器的一切。runc 如何找到我們的容器?事實(shí)證明,它只是在文件系統(tǒng)上保持狀態(tài),默認(rèn)情況下在里面/run/runc/CONTAINER_NAME/state.json:
- # cat /run/runc/test/state.json
- {"id":"test","init_process_pid":4258,"init_process_start":9561183,"created":"2020-04-23T20:29:39.371137097Z","config":{"no_pivot_root":false,"parent_death_signal":0,"rootfs":"/root/my-bundle/rootfs","readonlyfs":true,"rootPropagation":0,"mounts"....
當(dāng)我們?cè)诜蛛x模式下運(yùn)行時(shí),原始runc run命令(不再有這樣的進(jìn)程)和這個(gè)容器進(jìn)程之間沒(méi)有關(guān)系。如果我們查看進(jìn)程表,我們會(huì)看到容器的父進(jìn)程是PID 1:
- # ps axfo pid,ppid,command
- 4258 1 sleep infinite
Docker、containerd、CRI-O 等使用分離模式。它的目的是簡(jiǎn)化 runc 和全功能容器管理工具之間的集成。值得一提的是 runc 本身并不是某種類型的庫(kù)——它是一個(gè) CLI。當(dāng)其他工具使用 runc 時(shí),它們會(huì)調(diào)用我們剛剛在操作中看到的相同 runc 命令。
在runc 文檔中閱讀有關(guān)前臺(tái)模式和分離模式之間差異的更多信息。雖然容器進(jìn)程的PID是4258,但在容器內(nèi)部PID顯示為1:
- # runc exec test ps
- PID USER TIME COMMAND
- 1 root 0:00 sleep infinite
- 13 root 0:00 ps
這要?dú)w功于Linux 命名空間,它是真正的容器背后的基本技術(shù)之一。我們可以通過(guò)lsns在主機(jī)系統(tǒng)上執(zhí)行來(lái)列出所有當(dāng)前的命名空間 :
- # lsns
- NS TYPE NPROCS PID USER COMMAND
- 4026532219 mnt 1 4258 root sleep infinite
- 4026532220 uts 1 4258 root sleep infinite
- 4026532221 ipc 1 4258 root sleep infinite
- 4026532222 pid 1 4258 root sleep infinite
- 4026532224 net 1 4258 root sleep infinite
runc 負(fù)責(zé)我們?nèi)萜鬟M(jìn)程的進(jìn)程、網(wǎng)絡(luò)、掛載和其他命名空間。
容器世界的影子統(tǒng)治者
Podman、Docker 和所有其他工具,包括在那里運(yùn)行的大多數(shù) Kubernetes 集群,都?xì)w結(jié)為runc啟動(dòng)容器進(jìn)程的二進(jìn)制文件。
在實(shí)際工作中,幾乎永遠(yuǎn)不會(huì)做我剛剛給你展示的事情 - 除非正在開(kāi)發(fā)或者調(diào)試自己的或現(xiàn)有的容器工具。不能從容器映像中組裝應(yīng)用程序包,并且使用 Podman 而不是直接使用 runc 會(huì)更好。
runc就是Low-Level實(shí)現(xiàn)的實(shí)現(xiàn),我們了解幕后發(fā)生的事情以及運(yùn)行容器真正涉及的內(nèi)容是非常有幫助的。最終用戶和最終容器過(guò)程之間仍然有很多層,但是如果了解最后一層,那么容器將不再是神奇的東西,有時(shí)也很奇怪。最后你會(huì)發(fā)現(xiàn)容器它只是 runc 在命名空間中生成一個(gè)進(jìn)程。當(dāng)然最后一層是Linux內(nèi)核,相比宇宙中有無(wú)數(shù)層。
runc 最重要的部分是它跟蹤 OCI運(yùn)行時(shí)規(guī)范。盡管幾乎每一個(gè)容器,這些天與runc催生,它不具有與runc催生。可以將其與遵循運(yùn)行時(shí)規(guī)范的任何其他容器運(yùn)行時(shí)交換,并且容器引擎(如 CRI-O)應(yīng)該以相同的方式工作。
High-Level容器運(yùn)行時(shí)可以不依賴于 runc 本身。它們依賴于一些遵循 OCI 規(guī)范的容器運(yùn)行時(shí)。這是當(dāng)今容器世界真正美麗的部分。
reference
[1]https://github.com/opencontainers/runtime-spec/blob/master/config.md
https://mkdev.me/en/posts/the-tool-that-really-runs-your-containers-deep-dive-into-runc-and-oci-specifications
https://github.com/opencontainers/runc/blob/master/docs/terminals.md
https://katacontainers.io/
https://polyverse.com/blog/skopeo-the-best-container-tool-you-need-to-know-about/
https://umo.ci/quick-start/workflow/