美文网首页Docker入门教程docker
Docker技术原理之Linux UnionFS(容器镜像)

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

作者: _一叶_ | 来源:发表于2018-09-20 15:21 被阅读0次

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

    参考

    相关文章

      网友评论

        本文标题:Docker技术原理之Linux UnionFS(容器镜像)

        本文链接:https://www.haomeiwen.com/subject/osknnftx.html