docker容器overlay2镜像辗平技术收益分析

0 背景

在今年的cncf上海大会上,某互联网公司介绍了在它们内部的实现场景下,将docker容器镜像进行压扁后,访问效率得到了10倍的提升。回来以后,马上开始做实验,发现只有在非常极端的场景:
50000个文件位于最底层tempA目录,50000个文件位于最上层tempB,中间有100层的镜像,tempA中有20%的文件在各个层都有分布时刻,通过ls -l 遍历容器目录tempA可以得到6倍的内核态访问时间提升,综合访问时间(用户态+内核态)有4倍的访问时间提升。
这种例子非常极端了,以至于我们在公司内部找到了一些典型的容器镜像(镜像很大、层数很多的镜像)进行测试;发现在公司现阶段而言没有什么容器镜像辗平后有明显的收益

1 镜像辗平的方法

目前来说docker提供了两种镜像辗平的方法:

  • docker build参数--squash
    在docker build命令中可以加入--squash参数,将待构建的镜像进行辗平。注意此处辗平只能对dockerfile 构建的新层进行辗平,对于dockerfile引用的基础镜像不能进行辗平
  • docker export & docker import 方式
    此种方式可对一个已存在的镜像进行辗平
#docker create mynginx:v1
0d8c45f9b9.....
#docker export 0d8c45 -o zxy.tar
#docker import zxy.tar newnginx:v2
sha256:1de4e991ed....
#docker history 1de4e99
IMAGE                  CREATE         CREATE BY         SIZE.        COMMENT
1de4e991edf5.      56 seconds ago.                          317MB.     Imported from -

2 原理

本文来以centos7.4内核(3.10.0-693)为例介绍一下镜像辗平的原理。本文中有大量的内核文件系统相关知识,如果读者对内核实现不感兴趣,可以直接跳到最后一章结论处。
下图展示了overlayfs的基本工作图


以docker为例的overlayfs的示意图

如图所示overlayfs分为:
1.lowerdir:可以有多层
2.upperdir:读写层,只有一层
3.mergedir:视图层,也就是overlayfs融合以后,用户可见的一层(对应docker上的rootfs)。
4.workdir:工作层,overlayfs做一些临时操作时刻的层。

overlayfs的使用方法

下面命令可以创建一个overlayfs:

root#cd /root
root#mkdir lower1 lower2 lower3 upperdir workdir mergedir
root#touch lower1/a
root#touch lower2/b
root#touch lower3/c
root#date>lower3/a
root#mount -t overlay overlay -o lowerdir=/root/lower1:/root/lower2:/root/lower3,upper=/root/upperdir,workdir=/root/workdir /root/mergedir
root#cd /root/mergedir
root#ls 
a b c 

通过上述命令,我们在/root/merge下就有一个overlayfs,里面的内容是lower1,lower2,lower3和upper的堆叠。
上述命令中lower3是最底层
当在/root/merge层发生写入后,写入的文件最终会出现在upperdir里

root#echo “zxy testing”>/root/merge/new
root#ls
a b c new
root#ls /root/upperdir
new

这里介绍完一个overlayfs的基本使用了。下面我们来看看在多层堆叠的情况下,overlayfs是如何打开一个普通文件;如何查找目录下的文件。

2.1 文件打开

2.1.1 文件系统基本结构

在linux内核里通用文件系统由4个重要的数据结构组成:superblock,inode,dentry和file,它们都位于内核空间内。

  • superblock每个文件系统一个,存储了文件系统的元信息。对于磁盘文件系统,superblock也存在磁盘上。假设一台主机上有两个设备/dev/sda1 /dev/sda2 分别格式化为两个ext4系统,它们挂在在系统上以后,存在两个superblock
  • inode保存文件的元信息,例如:时间,文件名,使用者和群组等。一个文件对应一个inode,每个inode都有一个inode号在内核中用inode->i_ino表示。它在一个文件系统内部是全局唯一的。对于磁盘文件系统,每个inode都有一个磁盘上的实体对应。
  • dentry 目录项,用来将inode和目录项(文件的路径)关联起来。一个文件可以有多个dentry,但是只有一个inode。通过dentry可以索引到文件的inode。dentry只存在于内存中。
  • file 文件,内核中用struct file表示。用于存储进程与打开文件的交互信息。由于一个文件可以被多个进程打开,所以同一个物理文件可以有多个file 实例。file结构中定义了dentry* 成员,用于指向dentry;定义了file_operations*成员,用于文件的操作函数。


    文件系统涉及
以ext2文件系统为例说明文件内核结构关系图

本文不对这套vfs文件结构做过多说明,我们只需知道一个文件打开的主要流程是:

  1. 创建一个struct file结构
  2. 根据给定的路径找到文件的dentry,如果没有在内核维护的dentry缓存中找到,那么就新创建一个dentry。在新创建dentry时刻就会涉及到根据dentry查找/创建inode的流程。
  3. 将inode的file_operation指针地址复制给inode的对应成员。在centos7.4上struct file里有成员f_inode,可以直接指向inode,在文件打开时刻,此成员被赋值*

2.1.2关键流程

在整个过程中,第二步是整个文件打开的核心步骤。在centos7.4上overlayfs上整个dentry查找调用链为:
open()系统调用的入口是do_sys_open()函数。do_sys_open()->do_filep_open()->path_openat()->do_lask()

2.1.2.1 do_last()函数

do_last()函数的功能是打开文件路径查找文件的最后一个分量。
这个根据文件路径查找文件分量的过程,我们举一个例子展示一下:假设我们需要打开/root/test/lower1/a 。
step1、内核先打开root找到其dentry,读取其中子文件/目录信息;查找root下有test。
step2、查找test的dentry,读取其子文件或目录信息,找到test下有lower1.
stepn、这么一路查找,直到a文件,后此时就调用do_last()

do_last()做的事情比较简单,但却非常重要:
1)尝试在dentry cache中查找待打开文件的dentry和inode。如果这是第一次打开此文件,当然找不到此inode。这次查找使用lookup_fast()方式快速查找。
2)调用lookup_open()尝试查找、分配dentry和inode。

  1. 在lookup_open()完成后,使用vfs_open()实际完成文件打开动作
2.1.2.1.1 lookup_open()函数
  1. lookup_open()此函数先调用lookup_dcache()再次尝试查找dentry,如果找不到则分配一个新的dentry。
  2. lookup_open()在分配新的dentry后,调用lookup_real()。在此函数中调用其父目录的dir->i_op->lookup()查找(overlayfs文件的inode)。在overlayfs中,这个lookup()函数指针指向ovl_lookup()
2.1.2.1.1.1 ovl_lookup()函数真正执行overlayfs查找dentry和inode

ovl_lookup()是这次打开文件过程查找dentry和inode真正执行者,对于理解其整个流程非常重要所以我们贴上部分代码

struct dentry *ovl_lookup(struct inode *dir,struct dentry* dentry,unsigned int flags){
   ...
    if(upperopage && poe->numberlower){
        stack = kcalloc(poe->numlower, sizeof(struct path),GFP_KERNEL);
       ...
    }
    ...
    for(i=0;!upperopage&&inumlower;i++){
       struct path lowerpath= poe->lowerstack[i];
       this = ovl_lookup_real(lowerpath.dentry,&dentry->d_name);

       if(!this)
          continue;
       if(ovl_is_whiteout(this)){
           dput(this);
           break;
       }
       ...
      if(!S_ISDIR(this->d_inode->i_mode))
          opague= true;
     stack[ctr].dentry = this;
     ...
     ctr++;
     ...
   }
  oe = ovl_alloc_entry(ctr);
  ...
  if(upperdentry||ctr){
      ...
      realdentry= upperdentry ? upperdentry :stack[0].dentry;
      realinode = d_inode(realdentry);
      if(upperdentry && !d_is_dir(upperdentry)){
           inode= ovl_get_inode(dentry->d_sb,realinode);
     } else{
          inode = ovl_new_inode(dentry->d_sb,realinode->i_mode)
          If(inode)
             ovl_inode_init(inode,realinode,!!upperdentry);
    
    }
    ...
    ovl_copyattr(realdentry->d_inode,inode);
       
  }
  ...
  memcpy(oe->lowerstack,stack,sizeof(struct path) * ctr);
  dentry->d_fsdata= oe;
  d_add(dentry,inode);
  ...
}

ovl_lookup()函数的入参:dir为待查找的文件父目录的inode,dentry为lookup_real()函数中为本次查找文件分配的
dentry。
上面的大段代码分为四个部分:
1、创建一个struct path数组stack,用于保存此待查找overlayfs文件的所有需要参考的底层文件的dentry信息。
2、遍历poe->lowerstack[]每一层,在其中查找待查找的文件是否存在。存在且待查找文件不是目录,那么查找结束;如果待查找文件为目录,那么会接着将所有层遍历完毕。
每次有效遍历都会向stack数组记录查找到低层文件的dentry。那么对于一般文件而言,完成遍历后stack[]只有一个元素;但是对于目录文件来说stack[]有多个元素,每个元素对应着此目录在overlay诸多低层中出现一次。
3、realdentry 设置为真正有效的文件的dentry,也就是最上层文件的dentry。为待查找的overlay文件分配一个inode,将realdentry’s inode的属性拷贝到刚刚分配的overlay文件的inode上。
4、将stack数组放置到overlay文件dentry的d_fsdata字段上,并且将新分配的inode与此dentry关联起来。

综上可以看到:

  1. 对于常规文件而言,当overlayfs低层文件位于愈低层,那么其查找时间愈长。且查找时长和层数成正比。
  2. 对于目录文件而言,因为overlayfs查找需要遍历所有层(直到whiteout或者opage),那么层数的增加查找时间会增加。

whiteout:在overlayfs上一个低层存在的文件,在merge上被删除以后,会在upper上创建一个同名的whiteout文件,且文件类型为c(char),主次设备号都是0
opague:在overlayfs上,如果一个目录在低层存在,但是在后面某一层被删除了,现在在merge上又创建了。那么在upper目录上会出现一个同名opaque文件,其属性“trusted.overlay.opaque”扩展属性值为y

2.1.2.1.2 vfs_open()实际打开文件

vfs_open()对于非目录文件分为两步操作

  1. 调用d_real()获取overlayfs文件真正起效果的文件(也就是overlay文件最上层的文件)的dentry,这一dentry在ovl_lookup()中被关联到overlayfs文件dentry内部数据结构里了(dentry对应的inode的i_private字段中了)。
    d_real()真正使用的ovl_real()查找到真实的dentry和inode。在ovl_real()中会根据vfs_open()的打开属性执行overlayfs的copyup动作(只要以写属性打开就会执行copyup动作,执行copyup动作后,生效的dentry和inode指向这个新创建出现了copyup destination的文件)。

2.通过这个真正起效文件的dentry和其inode,打开真正起效的文件。

2.2 文件读写

在文件打开章节2.1中,我们介绍了对于overlayfs的文件打开动作,真实打开的文件是overlayfs最上层的文件。那么后续所有的读写动作都直接定位到终极目标文件了,不用再经过overlayfs干预了。

2.3 目录文件读

目录文件是可以读写的,目录文件里记录了当前这个目录下有哪些子文件/目录。目录文件读取动作之前首先也需要open此目录文件,open过程就伴随着2.1过程中介绍的dentry、inode查找/创建。对于overlayfs在open的lookup阶段,就已经在将待读取目录的所有低层信息记录在dentry->fs_data->lowerstack[]数组中了。那么overlayfs读取目录文件,就是对lowerstack[]数组内部所有的低层dentry进行遍历读。

综上,如果一个目录在overlayfs的低层中多个层存在,那么此目录的读取肯定比只是单层存在更耗时。

3 结论

本文通过分析发现:
1.overlayfs 越低层的文件,比越上层的文件在首次dentry查找时更耗时。
2.一个目录在多层都存在,那么其读取时间也更耗时
3.在文件正常读写阶段,overlayfs没有起任何干扰作用。

所以层数并不影响overlayfs 文件read和write的速度,只影响首次open文件/目录或者目录读取的速度。在现实使用中,通过辗平overlay2 镜像并不能获取太多收益。
只有下面极端场景中才有意义:
1.层数特别多
2.低层文件很多 或者同一目录文件在很多层都存在。
3.业务启动后首次读取文件或遍历目录时可以感觉到有收益。
但是镜像辗平后带来的副作用时,多层镜像的基层本来在多个镜像之间是共用的(共享),辗平后无法共用。这带来的镜像存储空间变大,网络传输镜像数据变多。
所以你的业务是否真正需要镜像辗平,请通过测试来决定

你可能感兴趣的:(docker容器overlay2镜像辗平技术收益分析)