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