0.前言
前面我们讨论了Docker容器实现隔离和资源限制用到的技术Linux namespace 、Linux CGroups,本篇我们来讨论Docker容器镜像用到的技术UnionFS。
1.关于UnionFS
1)什么是UnionFS
联合文件系统(Union File System):2004年由纽约州立大学石溪分校开发,它可以把多个目录(也叫分支)内容联合挂载到同一个目录下,而目录的物理位置是分开的。UnionFS允许只读和可读写目录并存,就是说可同时删除和增加内容。UnionFS应用的地方很多,比如在多个磁盘分区上合并不同文件系统的主目录,或把几张CD光盘合并成一个统一的光盘目录(归档)。另外,具有写时复制(copy-on-write)功能UnionFS可以把只读和可读写文件系统合并在一起,虚拟上允许只读文件系统的修改可以保存到可写文件系统当中。
2)docker的镜像rootfs,和layer的设计
任何程序运行时都会有依赖,无论是开发语言层的依赖库,还是各种系统lib、操作系统等,不同的系统上这些库可能是不一样的,或者有缺失的。为了让容器运行时一致,docker将依赖的操作系统、各种lib依赖整合打包在一起(即镜像),然后容器启动时,作为它的根目录(根文件系统rootfs),使得容器进程的各种依赖调用都在这个根目录里,这样就做到了环境的一致性。
不过,这时你可能已经发现了另一个问题:难道每开发一个应用,都要重复制作一次rootfs吗(那每次pull/push一个系统岂不疯掉)?
比如,我现在用Debian操作系统的ISO做了一个rootfs,然后又在里面安装了Golang环境,用来部署我的应用A。那么,我的另一个同事在发布他的Golang应用B时,希望能够直接使用我安装过Golang环境的rootfs,而不是重复这个流程,那么本文的主角UnionFS就派上用场了。
Docker镜像的设计中,引入了层(layer)的概念,也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs(一个目录),这样应用A和应用B所在的容器共同引用相同的Debian操作系统层、Golang环境层(作为只读层),而各自有各自应用程序层,和可写层。启动容器的时候通过UnionFS把相关的层挂载到一个目录,作为容器的根文件系统。
需要注意的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
3)各Linux版本的UnionFS不同
由于各种原因(有兴趣的可自行谷歌),Linux各发行版实现的UnionFS各不相同,所以Docker在不同linux发行版中使用的也不同。你可以通过docker info
来查看docker使用的是哪种,比如:
- centos, docker18.03.1-ce:
Storage Driver: overlay2
- debain, docker17.03.2-ce:
Storage Driver: aufs
2.举个例子(debain aufs)
1)准备如下目录和文件
$ tree
.
|-- a
| |-- a.log
| `-- x.log
`-- b
|-- b.log
`-- x.log
2)执行挂载命令
$ mkdir mnt
$ mount -t aufs -o dirs=./a:./b none ./mnt
$ tree ./mnt
./mnt
|-- a.log
|-- b.log
`-- x.log
可以看到被挂载的mnt目录合并了目录a和目录b
3)修改
$ echo test > mnt/x.log
$ cat mnt/x.log
test
$ cat a/x.log
test
$ cat b/x.log
你会发现x.log在a、b目录都存在,在修改后只有a目录生效了,原因是我们在mount aufs命令中,没有指a、b目录的权限,默认上来说,命令行上第一个(最左边)的目录是可读可写的,后面的全都是只读的,所以会出现上面这种情况,你也可以在挂载的时候自己指定权限(mount -t aufs -o dirs=./a=rw:./b=rw none ./mnt
),如果你有兴趣可以去尝试一下,这里就不再演示了。
那么再试一下修改b目录(只读目录)才有的b.log文件试一下呢:
$ echo test > mnt/b.log
$ cat mnt/b.log
test
$ cat b/b.log
$ cat a/b.log
test
你会发现,b目录下的文件没有被修改,而是在a目录(可读写目录)创建了一个b.log。
4)删除
$ touch b/bb.log
$ rm mnt/a.log
$ rm mnt/bb.log
$ ls -al mnt
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
$ ls -al a
-rw-r--r-- 1 root root 0 Sep 19 23:15 .wh.bb.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
$ ls -al b
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:14 bb.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
你会看到在mnt目录中删除a.log和bb.log后,a目录(可读写)中的a.log真的删除了,而b目录(只读)中的bb.log还在,只是a目录中多个.wh.bb.log这个文件。
一般来说只读目录都会有whiteout的属性,所谓whiteout的意思,就是如果在union中删除的某个文件,实际上是位于一个readonly的目录上,那么,在mount的union这个目录中你将看不到这个文件,但是readonly这个层上我们无法做任何的修改,所以,我们就需要对这个readonly目录里的文件作whiteout。AUFS的whiteout的实现是通过在上层的可写的目录下建立对应的whiteout隐藏文件来实现的。 所以上面的rm mnt/bb.log
操作和touch a/.wh.bb.log
效果相同。
5)来看一个docker容器
我们一起来执行如下命令:
#启动一个容器
$ docker run -dt golang:1.8.3 /bin/sh
7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34
#通过上面容器id查看挂载点
$ ls /var/lib/docker/image/aufs/layerdb/mounts/7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34
init-id mount-id parent
$ cat /var/lib/docker/image/aufs/layerdb/mounts/7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34/mount-id
e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b#
可以看到容器挂载的目录是e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
,那么找到该目录,看看里面的文件都有些什么:
$ ls /var/lib/docker/aufs/mnt/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
bin dev etc go go% home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
一个完整的操作系统根目录出现在里面。我们再来看看这个rootfs联合挂载的层级结构:
# 通过上面找到的mount-id查看aufs的内部id(也叫si)
$ cat /proc/mounts |grep e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
none /var/lib/docker/aufs/mnt/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b aufs rw,relatime,si=63e50947768841ec,dio,dirperm1 0 0
# 然后通过si查看layer
$ cat /sys/fs/aufs/si_63e50947768841ec/br[0-9]*
/var/lib/docker/aufs/diff/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b=rw
/var/lib/docker/aufs/diff/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b-init=ro
/var/lib/docker/aufs/diff/974a7e81b15c1eb6ea6c3c66dfb50dfcdf7b99b1e6458e2d3dca9451e2414106=ro
/var/lib/docker/aufs/diff/fd68755d715f47edc7f5ceaa2e5dc6788d4ca36a4d50f51a92a53045cd0b9fb1=ro
/var/lib/docker/aufs/diff/0e1237afa6d0fff72d9fdd5f84ef7275b1a49448d7523d590686131a3b129496=ro
/var/lib/docker/aufs/diff/440bf3d93514f6a35bd99d4ac098d9b709e878146e355c670bd8f1f533c185c5=ro
/var/lib/docker/aufs/diff/57e27832290597d0c5f2dc2ab55d1c53a7aa8a2a40eb6d21d014ad1210b1bb6f=ro
/var/lib/docker/aufs/diff/55da955ef5752f9c3d1810a7b23e0325dd7947a0c0aaecf6ae373f3e33979143=ro
由此我们找到了每个增量rootfs(即layer)所在的目录,那么现在你可以在容器里执行上面UnionFS中实验过的增删改,看看在最终被修改的layer是哪个,这里就不一一实验了。从上面可以看到容器的layer一共有8层:
第一部分 只读层
它是这个容器的rootfs最下面的6层(xxx=ro结尾)。可以看到,它们的挂载方式都是只读的(ro+wh,即readonly+whiteout,上面已经讲过一般来说只读目录都会有whiteout属性)。
第二部分 Init层
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。需要这样一层的原因是,这些文件本来属于只读的系统镜像层的一部分,但是用户往往需要在启动容器时写入一些指定的值比如hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行docker commit时,把这些信息连同可读写层一起提交掉。所以,Docker做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行docker commit只会提交可读写层,所以是不包含这些内容的。
第三部分 可读写层
它是这个容器的rootfs最上面的一层,它的挂载方式为:rw,即read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。删除ro-wh层等文件时,也会在rw层创建对应的个whiteout文件,把只读层里的文件“遮挡”起来。最上面这个可读写层的作用,就是专门用来存放你修改rootfs后产生的增量,无论是增删改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用docker commit和push指令,保存这个被修改过的可读写层,并上传到Docker Hub上,供其他人使用。而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量rootfs的好处。
最终,这8个层都被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的操作系统和golang环境供容器使用。
6)性能
IBM的研究中心对Docker的性能给了一份非常不错的性能报告(PDF)《An Updated Performance Comparison of Virtual Machinesand Linux Containers》。
这里扒了两张图下来,顺序读写和随机读写:
3.对照Docker源码
1)在启动docker daemon时会根据系统初始化好能使用的unionfs
func NewDaemon(ctx context.Context, config *config.Config, pluginStore *plugin.Store) (daemon *Daemon, err error) {
//...
for operatingSystem, gd := range d.graphDrivers {
layerStores[operatingSystem], err = layer.NewStoreFromOptions(layer.StoreOptions{
Root: config.Root,
MetadataStorePathTemplate: filepath.Join(config.Root, "image", "%s", "layerdb"),
GraphDriver: gd,
GraphDriverOptions: config.GraphOptions,
IDMapping: idMapping,
PluginGetter: d.PluginStore,
ExperimentalEnabled: config.Experimental,
OS: operatingSystem,
})
}
//...
}
func NewStoreFromOptions(options StoreOptions) (Store, error) {
driver, err := graphdriver.New(options.GraphDriver, options.PluginGetter, graphdriver.Options{
Root: options.Root,
DriverOptions: options.GraphDriverOptions,
UIDMaps: options.IDMapping.UIDs(),
GIDMaps: options.IDMapping.GIDs(),
ExperimentalEnabled: options.ExperimentalEnabled,
})
//...
}
// New creates the driver and initializes it at the specified root.
func New(name string, pg plugingetter.PluginGetter, config Options) (Driver, error) {
//...
driversMap := scanPriorDrivers(config.Root)
list := strings.Split(priority, ",")
logrus.Debugf("[graphdriver] priority list: %v", list)
for _, name := range list {
if name == "vfs" {
// don't use vfs even if there is state present.
continue
}
if _, prior := driversMap[name]; prior {
driver, err := getBuiltinDriver(name, config.Root, config.DriverOptions, config.UIDMaps, config.GIDMaps)
//...
return driver, nil
}
}
//...
}
2)再来看创建容器时,如何使用这个driver的
//docker daemon创建容器api的http handler
router.NewPostRoute("/containers/create", r.postContainersCreate)
//handler 挨着往下扒~
func (s *containerRouter) postContainersCreate(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
//...
ccr, err := s.backend.ContainerCreate(types.ContainerCreateConfig{
Name: name,
Config: config,
HostConfig: hostConfig,
NetworkingConfig: networkingConfig,
AdjustCPUShares: adjustCPUShares,
})
//...
}
func (daemon *Daemon) ContainerCreate(params types.ContainerCreateConfig) (containertypes.ContainerCreateCreatedBody, error) {
return daemon.containerCreate(params, false)
}
func (daemon *Daemon) containerCreate(params types.ContainerCreateConfig, managed bool) (containertypes.ContainerCreateCreatedBody, error) {
//...
container, err := daemon.create(params, managed)
//...
}
func (daemon *Daemon) create(params types.ContainerCreateConfig, managed bool) (retC *container.Container, retErr error) {
//...
//创建init和rw层
// Set RWLayer for container after mount labels have been set
rwLayer, err := daemon.imageService.CreateLayer(container, setupInitLayer(daemon.idMapping))
//...
}
func (i *ImageService) CreateLayer(container *container.Container, initFunc layer.MountInit) (layer.RWLayer, error) {
var layerID layer.ChainID
if container.ImageID != "" {
img, err := i.imageStore.Get(container.ImageID)
if err != nil {
return nil, err
}
layerID = img.RootFS.ChainID()
}
rwLayerOpts := &layer.CreateRWLayerOpts{
MountLabel: container.MountLabel,
InitFunc: initFunc,
StorageOpt: container.HostConfig.StorageOpt,
}
// Indexing by OS is safe here as validation of OS has already been performed in create() (the only
// caller), and guaranteed non-nil
//这里的layerStores正式NewDaemon时 layerStores[operatingSystem], err = layer.NewStoreFromOptions(...)这里的这个
return i.layerStores[container.OS].CreateRWLayer(container.ID, layerID, rwLayerOpts)
}
3)接下来我们来追溯CreateRWLayer的实现:
func (ls *layerStore) CreateRWLayer(name string, parent ChainID, opts *CreateRWLayerOpts) (RWLayer, error) {
//...
//这里driver不同环境有不同的实现,下面我们主要来看aufs的实现
if err = ls.driver.CreateReadWrite(m.mountID, pid, createOpts); err != nil {
return nil, err
}
//
//这里的saveMount正是save上面2.5里面查看挂载点的mount-id,init-id,parent
//
if err = ls.saveMount(m); err != nil {
return nil, err
}
return m.getReference(), nil
}
//我们这里来看aufs的实现
//
// CreateReadWrite creates a layer that is writable for use as a container
// file system.
func (a *Driver) CreateReadWrite(id, parent string, opts *graphdriver.CreateOpts) error {
return a.Create(id, parent, opts)
}
// Create three folders for each id
// mnt, layers, and diff
func (a *Driver) Create(id, parent string, opts *graphdriver.CreateOpts) error {
if opts != nil && len(opts.StorageOpt) != 0 {
return fmt.Errorf("--storage-opt is not supported for aufs")
}
if err := a.createDirsFor(id); err != nil {
return err
}
// Write the layers metadata
f, err := os.Create(path.Join(a.rootPath(), "layers", id))
if err != nil {
return err
}
defer f.Close()
if parent != "" {
ids, err := getParentIDs(a.rootPath(), parent)
if err != nil {
return err
}
if _, err := fmt.Fprintln(f, parent); err != nil {
return err
}
for _, i := range ids {
if _, err := fmt.Fprintln(f, i); err != nil {
return err
}
}
}
return nil
}
至此,容器镜像的实现我们就讨论的差不多了,有兴趣的朋友,可以在去看看devicemapper、overlay2等驱动,这就不一一展开讨论了。