OCI 和 runc:容器標準化和 Docker_Kubernetes中文社群
OCI 和容器標準
容器技術隨著 docker 的出現炙手可熱,所有的技術公司都積極擁抱容器,促進了 docker 容器的繁榮發展。容器一詞雖然口口相傳,但卻沒有統一的定義,這不僅是個技術概念的問題,也給整個社群帶來一個陰影:容器技術的標準到底是什麼?由誰來決定?
很多人可能覺得 docker 已經成為了容器的事實標準,那我們以它作為標準問題就解決了。事情並沒有那麼簡單,首先是否表示容器完全等同於 docker,不允許存在其他的容器執行時(比如 coreOS 推出的 rkt);其次容器上層抽象(容器叢集排程,比如 kubernetes、mesos 等)和 docker 緊密耦合,docker 介面的變化將會導致它們無法使用。
總的來說,如果容器以 docker 作為標準,那麼 docker 介面的變化將導致社群中所有相關工具都要更新,不然就無法使用;如果沒有標準,這將導致容器實現的碎片化,出現大量的衝突和冗餘。這兩種情況都是社群不願意看到的事情,OCI(Open Container Initiative) 就是在這個背景下出現的,它的使命就是推動容器標準化,容器能執行在任何的硬體和系統上,相關的元件也不必繫結在任何的容器執行時上。
官網上對 OCI 的說明如下:
An open governance structure for the express purpose of creating open industry standards around container formats and runtime. – Open Containers Official Site
OCI 由 docker、coreos 以及其他容器相關公司創建於 2015 年,目前主要有兩個標準文件:容器執行時標準 (runtime spec)和 容器映象標準(image spec)。
這兩個協議通過 OCI runtime filesytem bundle 的標準格式連線在一起,OCI 映象可以通過工具轉換成 bundle,然後 OCI 容器引擎能夠識別這個 bundle 來執行容器。
下面,我們來介紹這兩個 OCI 標準。因為標準本身細節很多,而且還在不斷維護和更新,如果不是容器的實現者,沒有必須對每個細節都掌握。所以我以介紹概要為主,給大家有個主觀的認知。
image spec
OCI 容器映象主要包括幾塊內容:
- 檔案系統:以 layer 儲存的檔案系統,每個 layer 儲存了和上層之間變化的部分,layer 應該儲存哪些檔案,怎麼表示增加、修改和刪除的檔案等
- config 檔案:儲存了檔案系統的層級資訊(每個層級的 hash 值,以及歷史資訊),以及容器執行時需要的一些資訊(比如環境變數、工作目錄、命令引數、mount 列表),指定了映象在某個特定平臺和系統的配置。比較接近我們使用 docker inspect <image_id> 看到的內容
- manifest 檔案:映象的 config 檔案索引,有哪些 layer,額外的 annotation 資訊,manifest 檔案中儲存了很多和當前平臺有關的資訊
- index 檔案:可選的檔案,指向不同平臺的 manifest 檔案,這個檔案能保證一個映象可以跨平臺使用,每個平臺擁有不同的 manifest 檔案,使用 index 作為索引
runtime spec
OCI 對容器 runtime 的標準主要是指定容器的執行狀態,和 runtime 需要提供的命令。下圖可以是容器狀態轉換圖:
- init 狀態:這個是我自己新增的狀態,並不在標準中,表示沒有容器存在的初始狀態
- creating:使用 create 命令建立容器,這個過程稱為建立中
- created:容器創建出來,但是還沒有執行,表示映象和配置沒有錯誤,容器能夠執行在當前平臺
- running:容器的執行狀態,裡面的程序處於 up 狀態,正在執行使用者設定的任務
- stopped:容器執行完成,或者執行出錯,或者 stop 命令之後,容器處於暫停狀態。這個狀態,容器還有很多資訊儲存在平臺中,並沒有完全被刪除
runc
runc 是 docker 捐贈給 OCI 的一個符合標準的 runtime 實現,目前 docker 引擎內部也是基於 runc 構建的。這部分我們就分析 runc 這個專案,加深對 OCI 的理解。
使用 runc 執行 busybox 容器
先來準備一個工作目錄,下面所有的操作都是在這個目錄下執行的,比如 mycontainer:
# mkdir mycontainer
接下來,準備容器映象的檔案系統,我們選擇從 docker 映象中提取:
# mkdir rootfs # docker export $(docker create busybox) | tar -C rootfs -xvf - # ls rootfs bin dev etc home proc root sys tmp usr var
有了 rootfs 之後,我們還要按照 OCI 標準有一個配置檔案 config.json 說明如何執行容器,包括要執行的命令、許可權、環境變數等等內容,runc 提供了一個命令可以自動幫我們生成:
# runc spec # ls config.json rootfs
這樣就構成了一個 OCI runtime bundle 的內容,這個 bundle 非常簡單,就上面兩個內容:config.json 檔案和 rootfs 檔案系統。config.json 裡面的內容很長,這裡就不貼出來了,我們也不會對其進行修改,直接使用這個預設生成的檔案。有了這些資訊,runc 就能知道怎麼怎麼執行容器了,我們先來看看簡單的方法 runc run(這個命令需要 root 許可權),這個命令類似於 docker run,它會建立並啟動一個容器:
➜ runc run simplebusybox / # ls bin dev etc home proc root sys tmp usr var / # hostname runc / # whoami root / # pwd / / # ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever / # ps aux PID USER TIME COMMAND 1 root 0:00 sh 11 root 0:00 ps aux
最後一個引數是容器的名字,需要在主機上保證唯一性。執行之後直接進入到了容器的 sh 互動介面,和通過 docker run 看到的效果非常類似。但是這個容器並沒有配置網路方面的內容,只是有一個預設的 lo 介面,因此無法和外部通訊,但其他功能都正常。
此時,另開一個終端,可以檢視執行的容器資訊:
➜ runc list ID PID STATUS BUNDLE CREATED OWNER simplebusybox 18073 running /home/cizixs/Workspace/runc/mycontainer 2017-11-02T06:54:52.023379345Z root
目前,在我的機器上,runc 會把容器的執行資訊儲存在 /run/runc 目錄下:
➜ tree /run/runc/ /run/runc/ └── simplebusybox └── state.json 1 directory, 1 file
除了 run 命令之外,我們也能通過create、start、stop、kill 等命令對容器狀態進行更精準的控制。繼續實驗,因為接下來要在後臺模式執行容器,所以需要對 config.json 進行修改。改動有兩處,把 terminal 的值改成 false,修改 args 命令列引數為 sleep 20:
"process": { "terminal": false, "user": { "uid": 0, "gid": 0 }, "args": [ "sleep", "20" ], ... }
接著,用 runc 子命令來控制容器的執行,實現各個容器狀態的轉換:
// 使用 create 創建出容器,此時容器並沒有執行,只是準備好了所有的執行環境 // 通過 list 命令可以檢視此時容器的狀態為 `created` ➜ runc create mycontainerid ➜ runc list ID PID STATUS BUNDLE CREATED OWNER mycontainerid 15871 created /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root // 執行容器,此時容器會在後臺執行,狀態變成了 `running` ➜ runc start mycontainerid ➜ runc list ID PID STATUS BUNDLE CREATED OWNER mycontainerid 15871 running /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root // 等待一段時間(20s)容器退出後,可以看到容器狀態變成了 `stopped` ➜ runc list ID PID STATUS BUNDLE CREATED OWNER mycontainerid 0 stopped /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root // 刪除容器,容器的資訊就不存在了 ➜ runc delete mycontainerid ➜ runc list ID PID STATUS BUNDLE CREATED OWNER
把以上命令分開來雖然讓事情變得複雜了,但是也有很多好處。可以類比 unix 系統 fork-exec 模式,在兩者動作之間,使用者可以做很多工作。比如把 create 和 start 分開,在創建出來容器之後,可以使用外掛為容器配置多主機網路,或者準備儲存設定等。
runc 程式碼實現
看完了 runc 命令演示,這部分來深入分析 runc 的程式碼實現。要想理解 runc 是怎麼建立 linux 容器的,需要熟悉 namespace 和 cgroup、 go 語言 、常見的系統呼叫。
分析的程式碼對應的 commit id 如下,這個程式碼是非常接近 v1.0.0 版本的:
➜ runc git:(master) git rev-parse HEAD 0232e38342a8d230c2745b67c17050b2be70c6bc
runc 的程式碼結構如下(略去了部分內容):
➜ runc git:(master) tree -L 1 -F --dirsfirst . ├── contrib/ ├── libcontainer/ ├── man/ ├── script/ ├── tests/ ├── vendor/ ├── checkpoint.go ├── create.go ├── delete.go ├── Dockerfile ├── events.go ├── exec.go ├── init.go ├── kill.go ├── LICENSE ├── list.go ├── main.go ├── Makefile ├── notify_socket.go ├── pause.go ├── PRINCIPLES.md ├── ps.go ├── README.md ├── restore.go ├── rlimit_linux.go ├── run.go ├── signalmap.go ├── signalmap_mipsx.go ├── signals.go ├── spec.go ├── start.go ├── state.go ├── tty.go ├── update.go ├── utils.go └── utils_linux.go
main.go 是入口檔案,根目錄下很多 .go 檔案是對應的命令(比如 run.go 對應 runc run命令的實現),其他是一些功能性檔案。
最核心的目錄是 libcontainer,它是啟動容器程序的最終執行者,runc 可以理解為對 libcontainer 的封裝,以符合 OCI 的方式讀取配置和檔案,呼叫 libcontainer 完成真正的工作。如果熟悉 docker 的話,可能會知道 libcontainer 本來是 docker 引擎的核心程式碼,用以取代之前 lxc driver。
我們會追尋 runc run 命令的執行過程,看看程式碼的呼叫和實現。
main.go 使用 github.com/urfave/cli 庫進行命令列解析,主要的思路是先宣告各種引數解析、命令執行函式,執行的時候 cli 會解析命令列傳過來的引數,把它們變成定義好的變數,呼叫指定的命令來執行。
func main() { app := cli.NewApp() app.Name = "runc" ... app.Commands = []cli.Command{ checkpointCommand, createCommand, deleteCommand, eventsCommand, execCommand, initCommand, killCommand, listCommand, pauseCommand, psCommand, restoreCommand, resumeCommand, runCommand, specCommand, startCommand, stateCommand, updateCommand, } ... if err := app.Run(os.Args); err != nil { fatal(err) } }
從上面可以看到命令函式列表,也就是 runc 支援的所有命令,命令列會實現命令的轉發,我們關心的 runCommand 定義在 run.go 檔案,它的執行邏輯是:
Action: func(context *cli.Context) error { if err := checkArgs(context, 1, exactArgs); err != nil { return err } if err := revisePidFile(context); err != nil { return err } spec, err := setupSpec(context) status, err := startContainer(context, spec, CT_ACT_RUN, nil) if err == nil { os.Exit(status) } return err },
可以看到整個過程分為了四步:
- 檢查引數個數是否符合要求
- 如果指定了 pid-file,把路徑轉換為絕對路徑
- 根據配置讀取 config.json 檔案中的內容,轉換成 spec 結構物件
- 然後根據配置啟動容器
其中 spec 的定義在 github.com/opencontainers/runtime-spec/specs-go/config.go#Spec,其實就是對應了 OCI bundle 中 config.json 的欄位,最重要的內容在 startContainer 函式中:
utils_linux.go#startContainer
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) { id := context.Args().First() if id == "" { return -1, errEmptyID } ...... container, err := createContainer(context, id, spec) if err != nil { return -1, err } ...... r := &runner{ enableSubreaper: !context.Bool("no-subreaper"), shouldDestroy: true, container: container, listenFDs: listenFDs, notifySocket: notifySocket, consoleSocket: context.String("console-socket"), detach: context.Bool("detach"), pidFile: context.String("pid-file"), preserveFDs: context.Int("preserve-fds"), action: action, criuOpts: criuOpts, } return r.run(spec.Process) }
這個函式的內容也不多,主要分成兩部分:
- 呼叫 createContainer 創建出來容器,這個容器只是一個邏輯上的概念,儲存了 namespace、cgroups、mounts、capabilities 等所有 Linux 容器需要的配置
- 然後建立 runner 物件,呼叫 r.run 執行容器。這才是執行最終容器程序的地方,它會啟動一個新程序,把程序放到配置的 namespaces 中,設定好 cgroups 引數以及其他內容
我們先來看 utils_linux.go#createContainer:
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) { config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{ CgroupName: id, UseSystemdCgroup: context.GlobalBool("systemd-cgroup"), NoPivotRoot: context.Bool("no-pivot"), NoNewKeyring: context.Bool("no-new-keyring"), Spec: spec, Rootless: isRootless(), }) .... factory, err := loadFactory(context) .... return factory.Create(id, config) }
它最終會返回一個 libcontainer.Container 物件,上面提到,這並不是一個執行的容器,而是邏輯上的容器概念,包含了 linux 上執行一個容器需要的所有配置資訊。
函式的內容分為兩部分:
- 建立 config 物件,這個配置物件的定義在 libcontainer/configs/config.go#Config,包含了容器執行需要的所有引數。specconv.CreateLibcontainerConfig 這一個函式就是把 spec 轉換成 libcontainer 內部的 config 物件。這個 config 物件是平臺無關的,從邏輯上定義了容器應該是什麼樣的配置
- 通過 libcontainer 提供的 factory,建立滿足 libcontainer.Container 介面的物件
libcontainer.Container 是個介面,定義在 libcontainer/container_linux.go 檔案中:
type Container interface { BaseContainer // 下面這些介面是平臺相關的,也就是 linux 平臺提供的特殊功能 // 使用 criu 把容器狀態儲存到磁碟 Checkpoint(criuOpts *CriuOpts) error // 利用 criu 從磁碟中重新 load 容器 Restore(process *Process, criuOpts *CriuOpts) error // 暫停容器的執行 Pause() error // 繼續容器的執行 Resume() error // 返回一個 channel,可以從裡面讀取容器的 OOM 事件 NotifyOOM() (<-chan struct{}, error) // 返回一個 channel,可以從裡面讀取容器記憶體壓力事件 NotifyMemoryPressure(level PressureLevel) (<-chan struct{}, error) }
裡面包含了 Linux 平臺特有的功能,基礎容器介面為 BaseContainer,定義在 libcontainer/container.go 檔案中,它定義了容器通用的方法:
type BaseContainer interface { // 返回容器 ID ID() string // 返回容器執行狀態 Status() (Status, error) // 返回容器詳細狀態資訊 State() (*State, error) // 返回容器的配置 Config() configs.Config // 返回執行在容器裡所有程序的 PID Processes() ([]int, error) // 返回容器的統計資訊,主要是網路介面資訊和 cgroup 中能收集的統計資料 Stats() (*Stats, error) // 設定容器的配置內容,可以動態調整容器 Set(config configs.Config) error // 在容器中啟動一個程序 Start(process *Process) (err error) // 執行容器 Run(process *Process) (err error) // 銷燬容器,就是刪除容器 Destroy() error // 給容器的 init 程序傳送訊號 Signal(s os.Signal, all bool) error // 告訴容器在 init 結束後執行使用者程序 Exec() error }
可以看到,上面是容器應該支援的命令,包含了查詢狀態和建立、銷燬、執行等。
這裡使用 factory 模式是為了支援不同平臺的容器,每個平臺實現自己的 factory ,根據執行平臺呼叫不同的實現就行。不過 runc 目前只支援 linux 平臺,所以我們看 libcontainer/factory_linux.go 中的實現:
func New(root string, options ...func(*LinuxFactory) error) (Factory, error) { if root != "" { if err := os.MkdirAll(root, 0700); err != nil { return nil, newGenericError(err, SystemError) } } l := &LinuxFactory{ Root: root, InitPath: "/proc/self/exe", InitArgs: []string{os.Args[0], "init"}, Validator: validate.New(), CriuPath: "criu", } Cgroupfs(l) for _, opt := range options { if opt == nil { continue } if err := opt(l); err != nil { return nil, err } } return l, nil } func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) { ...... containerRoot := filepath.Join(l.Root, id) if err := os.MkdirAll(containerRoot, 0711); err != nil { return nil, newGenericError(err, SystemError) } ...... c := &linuxContainer{ id: id, root: containerRoot, config: config, initPath: l.InitPath, initArgs: l.InitArgs, criuPath: l.CriuPath, newuidmapPath: l.NewuidmapPath, newgidmapPath: l.NewgidmapPath, cgroupManager: l.NewCgroupsManager(config.Cgroups, nil), } ...... c.state = &stoppedState{c: c} return c, nil }
New 建立了一個 linux 平臺的 factory,從 LinuxFactory 的 fields 可以看到,它裡面儲存了和 linux 平臺相關的資訊。
Create 返回的是 linuxContainer 物件,它是 libcontainer.Container 介面的實現。有了 libcontainer.Container 物件之後,回到 utils_linux.go#Runner 中看它是如何執行容器的:
func (r *runner) run(config *specs.Process) (int, error) { // 根據 OCI specs.Process 生成 libcontainer.Process 物件 // 如果出錯,執行 destroy 清理產生的中間檔案 process, err := newProcess(*config) if err != nil { r.destroy() return -1, err } ...... var ( detach = r.detach || (r.action == CT_ACT_CREATE) ) handler := newSignalHandler(r.enableSubreaper, r.notifySocket) // 根據是否進入到容器終端來配置 tty,標準輸入、標準輸出和標準錯誤輸出 tty, err := setupIO(process, rootuid, rootgid, config.Terminal, detach, r.consoleSocket) defer tty.Close() switch r.action { case CT_ACT_CREATE: err = r.container.Start(process) case CT_ACT_RESTORE: err = r.container.Restore(process, r.criuOpts) case CT_ACT_RUN: err = r.container.Run(process) default: panic("Unknown action") } ...... status, err := handler.forward(process, tty, detach) if detach { return 0, nil } r.destroy() return status, err }
runner 是一層封裝,主要工作是配置容器的 IO,根據命令去呼叫響應的方法。newProcess(*config) 將 OCI spec 中的 process 物件轉換成 libcontainer 中的 process,process 的定義在 libcontainer/process.go#Process,包括程序的命令、引數、環境變數、使用者、標準輸入輸出等。
有了 process,下一步就是執行這個程序 r.container.Run(process),Run 會呼叫內部的 libcontainer/container_linux.go#start() 方法:
func (c *linuxContainer) start(process *Process, isInit bool) error { parent, err := c.newParentProcess(process, isInit) if err := parent.start(); err != nil { return newSystemErrorWithCause(err, "starting container process") } c.created = time.Now().UTC() if isInit { ...... for i, hook := range c.config.Hooks.Poststart { if err := hook.Run(s); err != nil { return newSystemErrorWithCausef(err, "running poststart hook %d", i) } } } return nil }
執行容器程序,在容器程序完全起來之前,需要利用父程序和容器程序進行通訊,因此這裡封裝了一個 paerentProcess 的概念,
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) { parentPipe, childPipe, err := utils.NewSockPair("init") cmd, err := c.commandTemplate(p, childPipe) ...... return c.newInitProcess(p, cmd, parentPipe, childPipe) }
parentPipe 和 childPipe 就是父程序和創建出來的容器 init 程序通訊的管道,這個管道用於在 init 容器程序啟動之後做一些配置工作,非常重要,後面會看到它們的使用。
最終建立的 parentProcess 是 libcontainer/process_linux.go#initProcess 物件,
type initProcess struct { cmd *exec.Cmd parentPipe *os.File childPipe *os.File config *initConfig manager cgroups.Manager intelRdtManager intelrdt.Manager container *linuxContainer fds []string process *Process bootstrapData io.Reader sharePidns bool }
- cmd 是 init 程式,也就是說啟動的容器子程序是 runc init,後面我們會說明它的作用
- paerentPipe 和 childPipe 是父子程序通訊的管道
- bootstrapDta 中儲存了容器 init 初始化需要的資料
- process 會儲存容器 init 程序,用於父程序獲取容器程序資訊和與之互動
有了 parentProcess,接下來它的 start() 方法會被呼叫:
func (p *initProcess) start() error { defer p.parentPipe.Close() err := p.cmd.Start() p.process.ops = p p.childPipe.Close() // 把容器 pid 加入到 cgroup 中 if err := p.manager.Apply(p.pid()); err != nil { return newSystemErrorWithCause(err, "applying cgroup configuration for process") } // 給容器程序傳送初始化需要的資料 if _, err := io.Copy(p.parentPipe, p.bootstrapData); err != nil { return newSystemErrorWithCause(err, "copying bootstrap data to pipe") } // 等待容器程序完成 namespace 的配置 if err := p.execSetns(); err != nil { return newSystemErrorWithCause(err, "running exec setns process for init") } // 建立網路 interface if err := p.createNetworkInterfaces(); err != nil { return newSystemErrorWithCause(err, "creating network interfaces") } // 給容器程序傳送程序配置資訊 if err := p.sendConfig(); err != nil { return newSystemErrorWithCause(err, "sending config to init process") } // 和容器程序進行同步 // 容器 init 程序已經準備好環境,準備執行容器中的使用者程序 // 所以這裡會執行 prestart 的鉤子函式 ierr := parseSync(p.parentPipe, func(sync *syncT) error { ...... return nil }) // Must be done after Shutdown so the child will exit and we can wait for it. if ierr != nil { p.wait() return ierr } return nil }
這裡可以看到管道的用處:父程序把 bootstrapData 傳送給子程序,子程序根據這些資料配置 namespace、cgroups,apparmor 等引數;等待子程序完成配置,進行同步。
容器子程序會做哪些事情呢?用同樣的方法,可以找到 runc init 程式執行的邏輯程式碼在 libcontainer/standard_init_linux.go#Init(),它做的事情包括:
- 配置 namespace
- 配置網路和路由規則
- 準備 rootfs
- 配置 console
- 配置 hostname
- 配置 apparmor profile
- 配置 sysctl 引數
- 初始化 seccomp 配置
- 配置 user namespace
上面這些就是 linux 容器的大部分配置,完成這些之後,它就呼叫 Exec 執行使用者程式:
if err := syscall.Exec(name, l.config.Args[0:], os.Environ()); err != nil { return newSystemErrorWithCause(err, "exec user process") }
NOTE:其實,init 在執行自身的邏輯之前,會被 libcontainer/nsenter 劫持,nsenter 是 C 語言編寫的程式碼,目的是為容器配置 namespace,它會從 init pipe 中讀取 namespace 的資訊,呼叫setns 把當前程序加入到指定的 namespace 中。
之後,它會呼叫 clone 建立一個新的程序,初始化完成之後,把子程序的程序號傳送到管道中,nsenter 完成任務退出,子程序會返回,讓 init 接管,對容器進行初始化。
至此,容器的所有內容都 ok,而且容器裡的使用者程序也啟動了。
runc 的程式碼呼叫關係如上圖所示,可以在新頁面開啟檢視大圖。主要邏輯分成三塊:
- 最上面的紅色是命令列封裝,這是根據 OCI 標準實現的介面,它能讀取 OCI 標準的容器 bundle,並實現了 OCI 指定 run、start、create 等命令
- 中間的紫色部分就是 libcontainer,它是 runc 的核心內容,是對 linux namespace、cgroups 技術的封裝
- 右下角的綠色部分是真正的建立容器子程序的部分