一次奇幻的 docker libcontainer 代碼閱讀之旅
準備工作
首先自然要下到代碼才能讀,建議去下完整的 docker 源碼,不要只下 libcontainer 的源碼。不然就會像我一樣讀的時候碰到一個坑掉里面爬了半天。
接下來就要有一個代碼閱讀器了,由于 go 語言還是個比較新的語言,配套的工具還不是很完善,不過可以用 liteide (自備梯子)這個輕量級的 golang ide 來兼職一下。
打開之后可以看到 docker 的目錄結構大致是這樣的。
那么我們所關注的 libcontainer 在哪里呢?藏得還挺深的在 \verdor\src\github.com\libcontainer\。進去之后就會發(fā)現有個顯眼的 container.go 在向你招手,嗯第一個坑馬上就要來了。
container
這段代碼初看起來還是很淺顯的。代碼縮水后如下
- 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
- }
可以看出這段代碼只是定義了一個接口,任何實現這些方法的對象就會變成一個 docker 認可的 container。其中比較關鍵的一個函數就是 Start 了,他是在 container 里啟動進程的方法,可以看到接口的要求是傳進一個所要啟動進程相關的配置,返回一個進程 pid 和一個接受退出信息的 channel。
下一步自然就是去找這個接口的實現去看看究竟是怎么做的,然后一個坑就來了。由于 go 語言不要求對象向 java 那樣顯示的聲明自己實現哪個接口,只要自己默默實現了對應的方法就默認變成了哪個接口類型的對象。所以沒有什么直觀的方法來找到哪些對象實現了這個接口,翻了一下 libcontainer 文件夾下的文件感覺哪個都不像。感覺有些不詳的預兆,裝了個 Cygwin 去 grep Start 這個函數,結果意外的發(fā)現沒有,于是又在整個 docker 目錄下去 grep 發(fā)現還是沒有。
我就奇怪了,不是說 docker 1.2 之后就支持 native 的 container 了么,他連 libcontainer 里的 container 接口都沒實現他是怎么調用 native 的 container 的。既然自底向上的找不到,那就只能自頂向下的從上層往下跟去找找怎么回事了。
driver
docker 支持 lxc 和 native 兩套容器實現,是通過 driver 這個接口的兩個實現來完成的。在 \daemon\execdriver 中可以看到有 lxc 和 native 兩個文件夾,里面就是相關的代碼。不過在 \daemon\ 目錄下可以看到還有一個 container.go 里面是有個 container 對象,可是并沒有實現 libcontainer 里對應的接口,難道 libcontainer 里的那個 interface 只是一個幌子?
先看一下 driver 這個接口
- 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
- }
有沒有感覺名字雖說和上面的 container interface 不太一樣,不過意思是差不多的。resume 變成了 unpause, destory 變成了 teminate,processes 變成了 getpidsforcontainer,start 也變成了 run 和 exec 兩個函數??吹竭@不得不說 docker 的代碼的一致性和可讀性還是慘了點,codereview 需要更嚴格一些呀。
再進到 native 的 driver.go 就可以看到具體的實現了。在文件頭部發(fā)現了一長串 import,其中有幾個比較抓眼球:
- 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"
- )
從這里似乎可以看出一點端倪了。libcontainer 的目的是提供一個平臺無關的原生容器,這需要包括資源隔離,權限控制等一系列通用組件,所以 libcontainer 就來提供這些通用組件,所以他叫 "lib"。而每個平臺想實現自己的容器的話就可以借用這些組件,當然可以只用一部分而不全用, docker 就相當于用了包括 apparmor、cgroups、namespaces 等等組件,然后沒用 libcontainer 的 container 接口和其他一些組件,自己寫了其他部分完成的所謂 native 的容器。
還是看 run 函數
- func (d *driver) Run(c *execdriver.Command, pipes *execdriver.Pipes, startCallback execdriver.StartCallback) (int, error)
其中 execdriver.Pipes 是一個定義標準輸入輸出和錯誤指向的結構,startCallback 是在進程結束或者退出時調用的一個回調函數,最重要的結構是 execdriver.Command 他定義了容器內運行程序的各種環(huán)境和約束條件??梢栽?daemon 下的 driver.go 中找到對應的定義。
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"`
- }
其中和進程隔離相關的有 Resources 規(guī)定了 cpu 和 memory 的資源分配,可供 cgroups 將來調用。 CapAdd 和 CapDrop 這個和 linux Capability 相關來控制 root 的某些系統(tǒng)調用權限不會被容器內的程序使用。ProcessLabel 為容器內的進程打上一個 Lable 這樣的話 seLinux 將來就可以通過這個 lable 來做權限控制。Apparomoprofile 指向 docker 默認的 apparmor profile 路徑,一般為/etc/apparmor.d/docker,用來控制程序對文件系統(tǒng)的訪問權限。
可以看到,docker 對容器的隔離策略并不是自己開發(fā)一套隔離機制而是把現有的能用的已有隔離機制全用上。甚至 AppArmor 和 seLinux 這兩個類似并且人家兩家還在相互競爭的機制也都一股腦不管三七二十一全加上,頗有拿來主義的風采。這樣的話萬一惡意程序突破了一層防護還有另外一層擋著,而且這幾個隔離機制還相互保護要同時突破所有的防護才行。
而我們真正要在容器中執(zhí)行的程序在 ProcessConfig 這個結構體中的 Entrypoint。由此可見所謂的容器就是一個穿著各種隔離外套的程序,用這些隔離外套保護這個程序可以活在自己的小天地里,不知有漢無論魏晉。
Exec
還是回到 run 里面看看究竟是怎么 run 的吧,看完了一系列的初始化和異常判斷后終于到了真正運行的代碼,只有一行,長得是這個樣子的:
- 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)
- }
- })
看到這里整個人都不好了,我覺得 docker 這個項目要是這樣下去會出問題的,就算你喜歡匿名函數也不要這么偏執(zhí)好么。我甚至懷疑 docker 在用什么黑科技來隱藏他的真實代碼了。于是我決定放棄這行代碼直接看 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)
不太確定一個函數8個參數真的好么,但是我更納悶的是在主項目里既然都有 pipe 這個結構把 stdin,stdout,stderr 放在一起為啥到這里就要分開寫了,6個雖然也不少,但是比8個要好點。回過頭來說一下 namespace ,這又是另一種隔離機制。顧名思義,隔離的是名字空間,這要的話本來屬于全局可見的名字資源,如 pid,network,mountpoint 之類的資源虛擬出多份,每個 namespace 一份,每組進程占用一個 namespace。這樣的話容器內程序都看不到外部其他進程,攻擊的難度自然也就加大了。
然后這里面最關鍵的執(zhí)行的一句倒是很簡單了。
- if err := command.Start(); err != nil {
- child.Close()
- return -1, err
- }
其中的 command 是系統(tǒng)調用類 exec.Cmd 的一個對象,而之前的關于程序的配置信息已經在那個一行的執(zhí)行代碼里都整合進 command 里了,在這里只要 start 一下程序就跑起來了。然后我就疑惑了,這個函數不是 namespaces 包下的么,咋沒有 namespaces 設置的相關代碼呢。其實你仔細看那一行的執(zhí)行代碼可以發(fā)現 namespaces 的設置也在里面了,換句話說這個 namespaces 包下的 exec 其實沒有做什么和 namespaces 相關的事情,只是 start 了一下。這種代碼邏輯結構可是給讀代碼的人帶來了不小的困惑啊。
總結
這次讀代碼的起點是想搞懂容器是如何做隔離和保證安全的。從代碼來看 docker 并沒有另起爐灶新開發(fā)機制,而是將現有經過考驗的隔離安全機制能用的全用上,包括 cgroups,capability,namespaces,apparmor 和 seLinux。這樣一套組合拳打出來的效果理論上看還是很好的,即使其中一個機制出了漏洞,但是要利用這個漏洞的方法很可能會被其他機制限制住,要找到一種同時繞過所有隔離機制的方法難度就要大多了。
但是從讀代碼的角度來看,docker 的代碼的質量就讓人很難恭維了,即使 libcontainer 是一個獨立的部分,但本是同根生的名字都不一致,不知道之后會不會更混亂。而一些代碼風格和邏輯上也實在讓人讀起來很費勁,代碼質量要提高的地方還有很多。畢竟是開源的項目,即使功能很強大,但是大家如果發(fā)現代碼質量有問題,恐怕也不大敢用在生產吧。
而至于 libcontainer 盡管從 docker 中獨立出去發(fā)展,但是可以看出和主項目還有一些沒有切分干凈的地方,而且 docker 主項目目前也沒有采用 libcontainer 中的 container 方式,只是在調用里面的一些機制方法,看樣子目前還處于一個逐步替換的過程中。libcontainer 和一個獨立完整的產品還有一段距離,諸位有興趣的也可以參與進去,萬一這就是下一個偉大的項目呢?
原文出自:https://docker.cn/p/docker-libcontainer-reading