一次“奇幻”的Docker libcontainer代码阅读之旅

编者按: 前段时间转载的《5分钟弄懂Docker!》很受欢迎,短短1500字,让大家快速了解了Docker。今天看到作者又出了新作,马上就转过来了。 之所以称这次代码阅读为奇幻之旅,是因为作者刘梦馨(@oilbeater)在阅读Docker源码的过程中,发现了几个有趣的事情: 从代码来看 Docker 并没有另起炉灶新开发机制,而是将现有经过考验的隔离安全机制能用的全用上,包括 cgroups,capability,namespaces,AppArmor 和 SELinux(CSDN刚刚翻译的文章 《容器VS虚拟化之安全》 中也提到了这一点); 从读代码的角度来看,Docker源代码的质量让人很难恭维,一些代码风格和逻辑上让人读起来很费劲。

作者简介: 刘梦馨,一个当过开发和测试的运维人员,在阿里巴巴挖宝,顺便去点亮DBA的技能树。关注Docker,虚拟化和云计算技术。

以下为原文:

一直对Docker提供的容器感到好奇,不知道究竟是如何实现隔离和保证安全的,之前Docker本来是用LXC来提供容器功能的,但是由于对内核代码有一丝恐惧没敢去看,后来听说Docker为了实现跨平台兼容自己实现了一套Native的容器就是libcontainer。既然是新项目那么代码量和复杂度应该都不会太高吧,抱着这个想法我就翻看libcontainer的代码读一读。

准备工作

首先自然要下到代码才能读,建议去下完整的 Docker源码 ,不要只下libcontainer的源码。不然就会像我一样读的时候碰到一个坑,掉里面爬了半天。

接下来就要有一个代码阅读器了,由于Go语言还是个比较新的语言,配套的工具还不是很完善,不过可以用 liteide (自备梯子)这个轻量级的Golang IDE来兼职一下。

打开之后可以看到Docker的目录结构大致是这样的:

那么我们所关注的libcontainer在哪里呢?藏得还挺深的在 \verdor\src\github.com\libcontainer\。进去之后就会发现有个显眼的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这个函数,结果意外的发现没有,于是又在整个Docker目录下去 grep 发现还是没有。

我就奇怪了,不是说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 就可以看到具体的实现了。在文件头部发现了一长串 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 他定义了容器内运行程序的各种环境和约束条件。可以在 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 规定了 CPU 和 memory 的资源分配,可供 cgroups 将来调用。 CapAdd 和 CapDrop 这个和 Linux Capability 相关来控制 root 的某些系统调用权限不会被容器内的程序使用。ProcessLabel 为容器内的进程打上一个 Lable 这样的话 SELinux 将来就可以通过这个 lable 来做权限控制。Apparmorprofile 指向 Docker 默认的 apparmor profile 路径,一般为/etc/apparmor.d/docker,用来控制程序对文件系统的访问权限。

可以看到,Docker 对容器的隔离策略并不是自己开发一套隔离机制,而是把现有的能用的已有隔离机制全用上。 甚至 AppArmor 和 SELinux 这两个类似并且人家两家还在相互竞争的机制也都一股脑不管三七二十一全加上,颇有拿来主义的风采。这样的话万一恶意程序突破了一层防护还有另外一层挡着,而且这几个隔离机制还相互保护要同时突破所有的防护才行。

而我们真正要在容器中执行的程序在 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 这个项目要是这样下去会出问题的,就算你喜欢匿名函数也不要这么偏执好么。我甚至怀疑 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。这样的话容器内程序都看不到外部其他进程,攻击的难度自然也就加大了。

然后这里面最关键的执行的一句倒是很简单了。

if err := command.Start(); err != nil {
    child.Close()
    return -1, err
}

其中的 command 是系统调用类 exec.Cmd 的一个对象,而之前的关于程序的配置信息已经在那个一行的执行代码里都整合进 command 里了,在这里只要 start 一下程序就跑起来了。然后我就疑惑了,这个函数不是 namespaces 包下的么,咋没有 namespaces 设置的相关代码呢。其实你仔细看那一行的执行代码可以发现 namespaces 的设置也在里面了,换句话说这个 namespaces 包下的 exec 其实没有做什么和 namespaces 相关的事情,只是 start 了一下。这种代码逻辑结构可是给读代码的人带来了不小的困惑啊。

总结

这次读代码的起点是想搞懂容器是如何做隔离和保证安全的。从代码来看 Docker 并没有另起炉灶新开发机制,而是将现有经过考验的隔离安全机制能用的全用上,包括 cgroups,capability,namespaces,AppArmor 和 SELinux。这样一套组合拳打出来的效果理论上看还是很好的,即使其中一个机制出了漏洞,但是要利用这个漏洞的方法很可能会被其他机制限制住,要找到一种同时绕过所有隔离机制的方法难度就要大多了。

但是从读代码的角度来看,Docker 的代码的质量就让人很难恭维了,即使 libcontainer 是一个独立的部分,但本是同根生的名字都不一致,不知道之后会不会更混乱。而一些代码风格和逻辑上也实在让人读起来很费劲,代码质量要提高的地方还有很多。毕竟是开源的项目,即使功能很强大,但是大家如果发现代码质量有问题,恐怕也不大敢用在生产吧。

而至于 libcontainer 尽管从 Docker 中独立出去发展,但是可以看出和主项目还有一些没有切分干净的地方,而且 Docker 主项目目前也没有采用 libcontainer 中的 container 方式,只是在调用里面的一些机制方法,看样子目前还处于一个逐步替换的过程中。libcontainer 和一个独立完整的产品还有一段距离,诸位有兴趣的也可以参与进去,万一这就是下一个伟大的项目呢?

原文链接: 一次奇幻的 docker libcontainer 代码阅读之旅 (责编:周小璐)

如需要了解更多Docker相关的资讯或是技术文档可访问Docker技术社区;如有更多的疑问请在Dcoker技术论坛提出,我们会邀请专家回答。购票等问题可咨询QQ群:303806405。

Container技术日报公众账号已开启,欢迎关注!

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章