一次奇幻的 docker libcontainer 代碼閱讀之旅
準(zhǔn)備工作
首先自然要下到代碼才能讀,建議去下完整的 docker 源碼,不要只下 libcontainer 的源碼。不然就會(huì)像我一樣讀的時(shí)候碰到一個(gè)坑掉里面爬了半天。
接下來(lái)就要有一個(gè)代碼閱讀器了,由于 go 語(yǔ)言還是個(gè)比較新的語(yǔ)言,配套的工具還不是很完善,不過(guò)可以用 liteide (自備梯子)這個(gè)輕量級(jí)的 golang ide 來(lái)兼職一下。
打開(kāi)之后可以看到 docker 的目錄結(jié)構(gòu)大致是這樣的。
那么我們所關(guān)注的 libcontainer 在哪里呢?藏得還挺深的在 \verdor\src\github.com\libcontainer\。進(jìn)去之后就會(huì)發(fā)現(xiàn)有個(gè)顯眼的 container.go 在向你招手,嗯第一個(gè)坑馬上就要來(lái)了。
container
這段代碼初看起來(lái)還是很淺顯的。代碼縮水后如下
- type Container interface {
- ID() string
- RunState() (*RunState, Error)
- Config() *Config
- Start(config *ProcessConfig) (pid int, exitChan chan int, err Error)
- Destroy() Error
- Processes() ([]int, Error)
- Stats() (*ContainerStats, Error)
- Pause() Error
- Resume() Error
- }
可以看出這段代碼只是定義了一個(gè)接口,任何實(shí)現(xiàn)這些方法的對(duì)象就會(huì)變成一個(gè) docker 認(rèn)可的 container。其中比較關(guān)鍵的一個(gè)函數(shù)就是 Start 了,他是在 container 里啟動(dòng)進(jìn)程的方法,可以看到接口的要求是傳進(jìn)一個(gè)所要啟動(dòng)進(jìn)程相關(guān)的配置,返回一個(gè)進(jìn)程 pid 和一個(gè)接受退出信息的 channel。
下一步自然就是去找這個(gè)接口的實(shí)現(xiàn)去看看究竟是怎么做的,然后一個(gè)坑就來(lái)了。由于 go 語(yǔ)言不要求對(duì)象向 java 那樣顯示的聲明自己實(shí)現(xiàn)哪個(gè)接口,只要自己默默實(shí)現(xiàn)了對(duì)應(yīng)的方法就默認(rèn)變成了哪個(gè)接口類型的對(duì)象。所以沒(méi)有什么直觀的方法來(lái)找到哪些對(duì)象實(shí)現(xiàn)了這個(gè)接口,翻了一下 libcontainer 文件夾下的文件感覺(jué)哪個(gè)都不像。感覺(jué)有些不詳?shù)念A(yù)兆,裝了個(gè) Cygwin 去 grep Start 這個(gè)函數(shù),結(jié)果意外的發(fā)現(xiàn)沒(méi)有,于是又在整個(gè) docker 目錄下去 grep 發(fā)現(xiàn)還是沒(méi)有。
我就奇怪了,不是說(shuō) docker 1.2 之后就支持 native 的 container 了么,他連 libcontainer 里的 container 接口都沒(méi)實(shí)現(xiàn)他是怎么調(diào)用 native 的 container 的。既然自底向上的找不到,那就只能自頂向下的從上層往下跟去找找怎么回事了。
driver
docker 支持 lxc 和 native 兩套容器實(shí)現(xiàn),是通過(guò) driver 這個(gè)接口的兩個(gè)實(shí)現(xiàn)來(lái)完成的。在 \daemon\execdriver 中可以看到有 lxc 和 native 兩個(gè)文件夾,里面就是相關(guān)的代碼。不過(guò)在 \daemon\ 目錄下可以看到還有一個(gè) container.go 里面是有個(gè) container 對(duì)象,可是并沒(méi)有實(shí)現(xiàn) libcontainer 里對(duì)應(yīng)的接口,難道 libcontainer 里的那個(gè) interface 只是一個(gè)幌子?
先看一下 driver 這個(gè)接口
- type Driver interface {
- Run(c *Command, pipes *Pipes, startCallback StartCallback) (int, error) // Run executes the process and blocks until the process exits and returns the exit code
- // Exec executes the process in a running container, blocks until the process exits and returns the exit code
- Exec(c *Command, processConfig *ProcessConfig, pipes *Pipes, startCallback StartCallback) (int, error)
- Kill(c *Command, sig int) error
- Pause(c *Command) error
- Unpause(c *Command) error
- Name() string // Driver name
- Info(id string) Info // "temporary" hack (until we move state from core to plugins)
- GetPidsForContainer(id string) ([]int, error) // Returns a list of pids for the given container.
- Terminate(c *Command) error // kill it with fire
- Clean(id string) error// clean all traces of container exec
- }
有沒(méi)有感覺(jué)名字雖說(shuō)和上面的 container interface 不太一樣,不過(guò)意思是差不多的。resume 變成了 unpause, destory 變成了 teminate,processes 變成了 getpidsforcontainer,start 也變成了 run 和 exec 兩個(gè)函數(shù)。看到這不得不說(shuō) docker 的代碼的一致性和可讀性還是慘了點(diǎn),codereview 需要更嚴(yán)格一些呀。
再進(jìn)到 native 的 driver.go 就可以看到具體的實(shí)現(xiàn)了。在文件頭部發(fā)現(xiàn)了一長(zhǎng)串 import,其中有幾個(gè)比較抓眼球:
- import (
- ....
- "github.com/docker/libcontainer"
- "github.com/docker/libcontainer/apparmor"
- "github.com/docker/libcontainer/cgroups/fs"
- "github.com/docker/libcontainer/cgroups/systemd"
- consolepkg "github.com/docker/libcontainer/console"
- "github.com/docker/libcontainer/namespaces"
- _ "github.com/docker/libcontainer/namespaces/nsenter"
- "github.com/docker/libcontainer/system"
- )
從這里似乎可以看出一點(diǎn)端倪了。libcontainer 的目的是提供一個(gè)平臺(tái)無(wú)關(guān)的原生容器,這需要包括資源隔離,權(quán)限控制等一系列通用組件,所以 libcontainer 就來(lái)提供這些通用組件,所以他叫 "lib"。而每個(gè)平臺(tái)想實(shí)現(xiàn)自己的容器的話就可以借用這些組件,當(dāng)然可以只用一部分而不全用, docker 就相當(dāng)于用了包括 apparmor、cgroups、namespaces 等等組件,然后沒(méi)用 libcontainer 的 container 接口和其他一些組件,自己寫了其他部分完成的所謂 native 的容器。
還是看 run 函數(shù)
- func (d *driver) Run(c *execdriver.Command, pipes *execdriver.Pipes, startCallback execdriver.StartCallback) (int, error)
其中 execdriver.Pipes 是一個(gè)定義標(biāo)準(zhǔn)輸入輸出和錯(cuò)誤指向的結(jié)構(gòu),startCallback 是在進(jìn)程結(jié)束或者退出時(shí)調(diào)用的一個(gè)回調(diào)函數(shù),最重要的結(jié)構(gòu)是 execdriver.Command 他定義了容器內(nèi)運(yùn)行程序的各種環(huán)境和約束條件。可以在 daemon 下的 driver.go 中找到對(duì)應(yīng)的定義。
Command
- type Command struct {
- ID string `json:"id"`
- Rootfs string `json:"rootfs"` // root fs of the container
- InitPath string `json:"initpath"` // dockerinit
- WorkingDir string `json:"working_dir"`
- ConfigPath string `json:"config_path"` // this should be able to be removed when the lxc template is moved into the driver
- Network *Network `json:"network"`
- Resources *Resources `json:"resources"`
- Mounts []Mount `json:"mounts"`
- AllowedDevices []*devices.Device `json:"allowed_devices"`
- AutoCreatedDevices []*devices.Device `json:"autocreated_devices"`
- CapAdd []string `json:"cap_add"`
- CapDrop[]string `json:"cap_drop"`
- ContainerPid int `json:"container_pid"` // the pid for the process inside a container
- ProcessConfig ProcessConfig `json:"process_config"` // Describes the init process of the container.
- ProcessLabel string `json:"process_label"`
- MountLabel string `json:"mount_label"`
- LxcConfig []string `json:"lxc_config"`
- AppArmorProfile string `json:"apparmor_profile"`
- }
其中和進(jìn)程隔離相關(guān)的有 Resources 規(guī)定了 cpu 和 memory 的資源分配,可供 cgroups 將來(lái)調(diào)用。 CapAdd 和 CapDrop 這個(gè)和 linux Capability 相關(guān)來(lái)控制 root 的某些系統(tǒng)調(diào)用權(quán)限不會(huì)被容器內(nèi)的程序使用。ProcessLabel 為容器內(nèi)的進(jìn)程打上一個(gè) Lable 這樣的話 seLinux 將來(lái)就可以通過(guò)這個(gè) lable 來(lái)做權(quán)限控制。Apparomoprofile 指向 docker 默認(rèn)的 apparmor profile 路徑,一般為/etc/apparmor.d/docker,用來(lái)控制程序?qū)ξ募到y(tǒng)的訪問(wèn)權(quán)限。
可以看到,docker 對(duì)容器的隔離策略并不是自己開(kāi)發(fā)一套隔離機(jī)制而是把現(xiàn)有的能用的已有隔離機(jī)制全用上。甚至 AppArmor 和 seLinux 這兩個(gè)類似并且人家兩家還在相互競(jìng)爭(zhēng)的機(jī)制也都一股腦不管三七二十一全加上,頗有拿來(lái)主義的風(fēng)采。這樣的話萬(wàn)一惡意程序突破了一層防護(hù)還有另外一層擋著,而且這幾個(gè)隔離機(jī)制還相互保護(hù)要同時(shí)突破所有的防護(hù)才行。
而我們真正要在容器中執(zhí)行的程序在 ProcessConfig 這個(gè)結(jié)構(gòu)體中的 Entrypoint。由此可見(jiàn)所謂的容器就是一個(gè)穿著各種隔離外套的程序,用這些隔離外套保護(hù)這個(gè)程序可以活在自己的小天地里,不知有漢無(wú)論魏晉。
Exec
還是回到 run 里面看看究竟是怎么 run 的吧,看完了一系列的初始化和異常判斷后終于到了真正運(yùn)行的代碼,只有一行,長(zhǎng)得是這個(gè)樣子的:
- return namespaces.Exec(container, c.ProcessConfig.Stdin, c.ProcessConfig.Stdout, c.ProcessConfig.Stderr, c.ProcessConfig.Console, dataPath, args, func(container *libcontainer.Config, console, dataPath, init string, child *os.File, args []string) *exec.Cmd {
- c.ProcessConfig.Path = d.initPath
- c.ProcessConfig.Args = append([]string{
- DriverName,
- "-console", console,
- "-pipe", "3",
- "-root", filepath.Join(d.root, c.ID),
- "--",
- }, args...)
- // set this to nil so that when we set the clone flags anything else is reset
- c.ProcessConfig.SysProcAttr = &syscall.SysProcAttr{
- Cloneflags: uintptr(namespaces.GetNamespaceFlags(container.Namespaces)),
- }
- c.ProcessConfig.ExtraFiles = []*os.File{child}
- c.ProcessConfig.Env = container.Env
- c.ProcessConfig.Dir = container.RootFs
- return &c.ProcessConfig.Cmd
- }, func() {
- if startCallback != nil {
- c.ContainerPid = c.ProcessConfig.Process.Pid
- startCallback(&c.ProcessConfig, c.ContainerPid)
- }
- })
看到這里整個(gè)人都不好了,我覺(jué)得 docker 這個(gè)項(xiàng)目要是這樣下去會(huì)出問(wèn)題的,就算你喜歡匿名函數(shù)也不要這么偏執(zhí)好么。我甚至懷疑 docker 在用什么黑科技來(lái)隱藏他的真實(shí)代碼了。于是我決定放棄這行代碼直接看 namespaces.Exec 去了。在\verdor\src\github.com\libcontainer\namespaces\exec.go里
- func Exec(container *libcontainer.Config, stdin io.Reader, stdout, stderr io.Writer, console, dataPath string, args []string, createCommand CreateCommand, startCallback func()) (int, error)
不太確定一個(gè)函數(shù)8個(gè)參數(shù)真的好么,但是我更納悶的是在主項(xiàng)目里既然都有 pipe 這個(gè)結(jié)構(gòu)把 stdin,stdout,stderr 放在一起為啥到這里就要分開(kāi)寫了,6個(gè)雖然也不少,但是比8個(gè)要好點(diǎn)。回過(guò)頭來(lái)說(shuō)一下 namespace ,這又是另一種隔離機(jī)制。顧名思義,隔離的是名字空間,這要的話本來(lái)屬于全局可見(jiàn)的名字資源,如 pid,network,mountpoint 之類的資源虛擬出多份,每個(gè) namespace 一份,每組進(jìn)程占用一個(gè) namespace。這樣的話容器內(nèi)程序都看不到外部其他進(jìn)程,攻擊的難度自然也就加大了。
然后這里面最關(guān)鍵的執(zhí)行的一句倒是很簡(jiǎn)單了。
- if err := command.Start(); err != nil {
- child.Close()
- return -1, err
- }
其中的 command 是系統(tǒng)調(diào)用類 exec.Cmd 的一個(gè)對(duì)象,而之前的關(guān)于程序的配置信息已經(jīng)在那個(gè)一行的執(zhí)行代碼里都整合進(jìn) command 里了,在這里只要 start 一下程序就跑起來(lái)了。然后我就疑惑了,這個(gè)函數(shù)不是 namespaces 包下的么,咋沒(méi)有 namespaces 設(shè)置的相關(guān)代碼呢。其實(shí)你仔細(xì)看那一行的執(zhí)行代碼可以發(fā)現(xiàn) namespaces 的設(shè)置也在里面了,換句話說(shuō)這個(gè) namespaces 包下的 exec 其實(shí)沒(méi)有做什么和 namespaces 相關(guān)的事情,只是 start 了一下。這種代碼邏輯結(jié)構(gòu)可是給讀代碼的人帶來(lái)了不小的困惑啊。
總結(jié)
這次讀代碼的起點(diǎn)是想搞懂容器是如何做隔離和保證安全的。從代碼來(lái)看 docker 并沒(méi)有另起爐灶新開(kāi)發(fā)機(jī)制,而是將現(xiàn)有經(jīng)過(guò)考驗(yàn)的隔離安全機(jī)制能用的全用上,包括 cgroups,capability,namespaces,apparmor 和 seLinux。這樣一套組合拳打出來(lái)的效果理論上看還是很好的,即使其中一個(gè)機(jī)制出了漏洞,但是要利用這個(gè)漏洞的方法很可能會(huì)被其他機(jī)制限制住,要找到一種同時(shí)繞過(guò)所有隔離機(jī)制的方法難度就要大多了。
但是從讀代碼的角度來(lái)看,docker 的代碼的質(zhì)量就讓人很難恭維了,即使 libcontainer 是一個(gè)獨(dú)立的部分,但本是同根生的名字都不一致,不知道之后會(huì)不會(huì)更混亂。而一些代碼風(fēng)格和邏輯上也實(shí)在讓人讀起來(lái)很費(fèi)勁,代碼質(zhì)量要提高的地方還有很多。畢竟是開(kāi)源的項(xiàng)目,即使功能很強(qiáng)大,但是大家如果發(fā)現(xiàn)代碼質(zhì)量有問(wèn)題,恐怕也不大敢用在生產(chǎn)吧。
而至于 libcontainer 盡管從 docker 中獨(dú)立出去發(fā)展,但是可以看出和主項(xiàng)目還有一些沒(méi)有切分干凈的地方,而且 docker 主項(xiàng)目目前也沒(méi)有采用 libcontainer 中的 container 方式,只是在調(diào)用里面的一些機(jī)制方法,看樣子目前還處于一個(gè)逐步替換的過(guò)程中。libcontainer 和一個(gè)獨(dú)立完整的產(chǎn)品還有一段距離,諸位有興趣的也可以參與進(jìn)去,萬(wàn)一這就是下一個(gè)偉大的項(xiàng)目呢?
原文出自:https://docker.cn/p/docker-libcontainer-reading