Docker技术原理之Linux UnionFS(容器镜像)

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等驱动,这就不一一展开讨论了。

参考

推荐阅读更多精彩内容