会议:SoCC’ 18
原文:https://doi.org/10.1145/3267809.3267836
Wharf:通过分布式文件系统进行 Docker 镜像文件的共享,而不是一个物理机一个镜像文件。 Wharf将Docker的运行时状态划分为本地和全局部分,并有效地同步对全局状态的访问。
现状:在Docker中,每个守护进程都是不共享的,也就是说,它在本地存储中提取和存储图像,然后从本地副本启动容器。
主要针对目前的 Docker 守护进程的管理方式的现状提出4个主要问题:
Wharf使分布式Docker守护进程能够协同检索和存储共享存储中的容器映像,并从共享映像创建容器。通过在中央存储中为所有守护进程只保留一个映像的副本,Wharf可以显著降低网络和存储开销。
Wharf利用Docker图像的结构来降低同步开销。图像由几个层组成,可以并行下载。Wharf实现了一个细粒度的层锁来协调对共享映像存储的访问。这允许守护进程并行地拉取同一映像的不同层和不同的映像,从而提高网络利用率并避免过度阻塞。Wharf将每个守护进程的数据和元数据分割为全局和本地状态,以最小化必要的同步。另外,使用层锁,Wharf确保每个守护进程只能看到一致的层状态,并防止在单个守护进程失败时整个集群失败。
Wharf被设计为对用户透明,并允许重用现有的Docker图像而不做任何更改。Wharf独立于分布式存储层,可以部署在任何存储系统上,提供简易的语义。Wharf目前支持两种常见的Docker即写即拷存储驱动程序AUFS和overlay2。此外,虽然我们为Docker实现了Wharf,但所提出的设计也适用于其他容器管理框架。
这里仅提取一些关键点
层是一组文件,表示容器文件系统树的一部分。
Docker 容器通过hash 在本地实现了共享层缓存,这节省了存储空间并提高了容器的启动时间。
Docker push 时将镜像分层并压缩归档为 tar,然后附带一份 manifest 文件用于 pull 的时候的镜像检索
当创建一个新容器时,Docker 守护进程的创建步骤:
检索映像之后,守护进程将创建容器。因此,它首先在一个挂载点上联合只读层,然后在其上创建一个可写层。对容器根文件系统所做的任何更改都通过写时复制(COW)机制存储在可写层中。这意味着,在从只读层修改文件之前,将其复制到可写层,并对该副本进行所有更改。一旦容器退出,可写层通常被丢弃。
具体的创建联合挂载点与COW能力取决于 Docker 的图形驱动程序;目前应用最广泛的为层叠驱动和专用驱动
层叠驱动:如 AUFS,这些文件系统位于现有文件系统之上,并拦截文件和目录上的操作。在使用时,多个只读文件系统合并到一个逻辑文件系统中,任何更改都通过COW写入到一个可写文件系统中。
专用驱动:专用驱动程序采用相同的COW原理,但使用文件系统或块设备的特殊本机功能来实现层模型和COW。 这些驱动程序需要特殊的块设备,或者需要使用专用文件系统格式化的块设备。
将Docker 进行分布式分区或多节点,有三个缺点:
五个目标:
Wharf的核心思想是将图形驱动程序内容分解为全局状态和局部状态,并同步访问全局状态。
包含跨守护进程访问的数据,如:镜像和层数据,以及像层和镜像 manifest 的静态元数据
它还包括运行时状态,例如,当前传输层的进度,运行的容器以及拉取的图像和层之间的关系
全局状态存放在分布式文件系统里,可以被所有守护进程访问
局部状态与在单个守护进程下运行的容器相关,比如网络配置、附加卷的信息和容器插件,比如Nvidia GPU插件,它简化了部署支持GPU的容器的过程。
该数据只需要由其对应的守护进程访问,因此,每个守护进程分别存储该数据。
局部状态既可以存储在守护进程的本地存储中,也可以存储在分布式文件系统中的单独位置上。
这样就保证了和镜像有关的信息只存储一次并解决了目标(1)
架构图如下:
包含三个主要组件:wharf 守护进程,镜像管理接口,数据与元数据共享存储
wharf 守护进程运行在独立的Docker主机上,并为它们的本地容器管理它们自己的本地状态。
当守护进程收到创建容器的请求时,它设置容器根文件系统,这样可写层存储在一个私有的、非共享的位置,只读层从分布式文件系统读取。
守护进程通过更新分布式文件系统上的全局状态来交换信息,但彼此之间并不直接通信。(类似于共享内存通信,这里是共享一个全局文件
更像是一个网关,是各本地守护进程访问全局状态的网关
作用是当某个守护进程更新全局状态时,通过锁定部分全局状态临界区,来确保状态同步
镜像管理接口提供了实现分布式锁的不同方式,如:zookeeper 或 etcd;如果底层文件系统支持,甚至会使用底层文件锁定接口fcntl()
进行同步,该方法不需要额外服务,具有高度可移植性
托管全局状态,并分为两个部分
守护进程要从共享内容存储访问某个层时,都需要首先读取共享元数据存储并检查该层的状态,即该层是否已经存在、当前是否被拉出,或者是否被正在运行的容器引用。
由于元数据的写操作是被保护的,故守护进程可以被保证读取到共享内容存储的一致性视图
为了满足目标(2)和(3),Wharf 在镜像管理接口中实现了层级锁;层级锁保证写操作只需写单层,而不需要写或添加整个文件,读操作不需要同步,这在并发操作上意味着守护进程可以:
下图为层锁情况下的并发拉取镜像,在 Docker 中,默认每个守护进程有3个线程进行并发拉取镜像
注意:下图以及文中说明Wharf守护进程的工作方式,引入了一些额外的数据结构如线程队列、map等以及不同情况下对应的处理逻辑,拉取流程部分自我感觉有点晦涩难懂
本地守护进程数据结构:
共享传输存储数据结构:
Wharf 守护进程,拉取层的流程如下:
Wharf 除了协调本地多线程还需要协调多远程进程调用。因此在全局存储中定义了三个额外的映射数据结构:GCT、GRT、GWT;全局写操作被锁保护
我理解为上面的操作为分布式守护进程,下面的操作为本地多个守护进程
对于删除操作,Wharf 为每层使用全局引用计数,只有当全局引用计数为0时,才可以删除
在典型的Docker部署中,容器的根文件系统是短暂的,也就是说,当容器停止运行时,写入文件系统的更改将被丢弃。这转化为底层存储驱动程序移除可写层。
用户应用程序经常在根文件系统中创建大量的中间数据,这会使可写层膨胀。此外,覆盖存储驱动程序,如overlay2和aufs,会将整个文件复制到可写层,即使只有文件的一小部分被修改。与用户写操作相比,这将导致文件系统级的写扩展。
Docker通常将可写和可读层存储在同一文件系统中。 在分布式文件系统的情况下,这会转换为跨存储网络传输的大量临时容器数据,从而增加了网络和远程存储服务器的开销。
与Docker不同,Wharf在两个不同的位置存储可写和可读的层。 具体来说,Wharf将运行容器的可写层放在本地连接的存储中,而将只读层存储在共享存储中,以便任何守护程序都可以访问它。 该设计通过减少对分布式文件系统的写入量来解决目标(4)。
对于全局数据和元数据而言,读可以并行操作,并且不允许进行本地元数据缓存。排他写结合不缓存状态提供了跨守护进程全局状态的必要的强一致性,并确保守护进程总是对相同的元数据视图进行操作。
若守护进程需要更新镜像数据,如拉取新镜像或层,则会遵循如下三个阶段:
由于数据阶段总是在元数据阶段之前,因此没有两个守护进程可以执行相同的动作两次。
疑问:这里的更新流程在字面意思的理解上感觉有锁的bug,类似于 mysql 的读未提交?
根据上述的字面意思,对于两个守护进程 A 和 B:
阶段\进程 A B t1 获取元数据锁
锁定部分全局状态
注册行为:拉取对应层
释放锁t2 检索实际数据 获取元数据锁
锁定部分全局状态
注册行为
释放锁t3 获取锁
更新元数据
释放锁检索实际数据 t4 获取锁
更新元数据
释放锁t3 更新了的元数据难道不会被t4的更新覆盖掉吗?因为也没开源,或许是字面意思理解错误或翻译错误,此处不再深究
Wharf 需要处理两种类型的错误
在 Docker 的基础上,添加 2800行代码
要运行Wharf,用户只需运行带有共享根参数集的Docker守护进程。
Wharf主要解决状态上的两个问题:全局分布式访问同步和本地内存缓存状态
原生 Docker 守护进程使用三个主要结构体存储全局状态:
LayerStore
:用于存取可用层的信息(只读和可写层的列表)ImageStore
:用于本地镜像信息的存取(存储后端和镜像元数据)ReferenceStore
:包括对所有可用镜像的引用原生Docker:每个守护进程都在内存中保存这些数据结构的副本,并可以通过重新加载负责保存守护进程持久状态的目录来生成它们。为了容纳多个并发客户端,Docker守护进程通过互斥锁保护它的内存状态。
Wharf:通过序列化扩展原生实现,将它们存储在共享存储中,并使用分布式读写锁定机制来同步访问。读可并发,但写访问则需要一个占用局部状态资源的独占锁。
默认情况下,Wharf使用fcntl系统调用来实现锁定。由于大多数分布式文件系统都支持fcntl,该方法无需额外的软件和库依赖即可使用。通过Wharf的镜像管理界面,允许使用第三方内存键值对存储组件来替换默认的基于文件的锁定。
由于多个守护进程现在可以更新全局状态,单个守护进程的内存状态可能无效。因此,在读取局部内存状态数据之前,守护进程必须检查全局状态是否已经更新。
为了确保守护进程在处理任何操作之前总是拥有最新版本的元数据,Wharf应用了一个延迟更新机制,它只在操作需要元数据访问时更新单个守护进程的缓存。Wharf通过从分布式存储中反序列化二进制文件来更新单个守护进程的内存数据结构,并将检索到的数据写入其内存数据。
Wharf 通过新增两个组件,对 Docker 的层级传输者(layer transfer)进行拓展去实现多进程并发拉取层
下图是当多个 Wharf 进程同时拉取相同镜像时,对于 Transfer 的镜像拉取行为的表现流程图
起点是Start Transfer
,终点是Transfer Done
,当一次Transfer 完成之后,又会立即去 Waiting Transfer 中查看等待中的任务,故会无限循环下去
每个守护进程以拉取镜像清单和提取层信息开始
若本地其他线程已经拉取层了,守护进程将创建一个 watcher 观察者去监控传输进度;否则查看是否被其他不同节点拉取,该信息可以在SharedTransferStore
中被提取,若确实被拉取,则会创建一个dummy transfer
去监控master transfer
一旦传输完成,守护进程会更新SharedTransferStore
,由于该结构体被共享访问,所以dummy transfers
能获取到相应资源的进度
问题:Docker 原生支持两种层叠驱动:aufs
和overlay2
,然而,在分布式文件系统上使用overlay2
驱动并不常见
Wharf 选择采用 NFS 和 IBM Spectrum Scale 联合作为分布式文件系统;但这其中又有其他问题出现
Wharf可以与NFS以及aufs和overlay2图形驱动程序一起使用
由于在NFS文件上设置了system.nfs4_acl扩展属性,在使用NFSv4和overlay2时会存在问题;OverlayFS无法将扩展属性复制到上层文件系统(可写层),即存储已更改文件的文件系统(本例中为ext4)
论文中认为,这是由于NFSv4 ACL与上层文件系统的ACL之间不兼容所致。 虽然可以使用noacl选项挂载NFSv4,但我们发现Linux发行版不支持此功能。
还可以在IBM Spectrum Scale并行文件系统之上运行Wharf,但同样遇到了问题。
当aufs驱动程序正常工作时,使用overlay2 会抛出问题。
即使配置了对ext4的本地写入,Docker仍尝试在Spectrum Scale上为OverlayFS创建上层文件系统。 这会导致错误(“filesystem on ’/path’ not supported as upperdir”)。
论文目前不知道此行为的确切原因,因为能够在Spectrum Scale上手动创建OverlayFS挂载点,故计划将来做调查解决。
由于OverlayFS是主线Linux内核的一部分,因此提供了更好的可移植性,论文将overlay2和NFSv3结合在一起用于我们对Wharf的实验。
Benchmark 测试部分,将 Wharf 和其他两种策略作对比:
测试维度采用五个变量,并控制变量:
论文中的实验结果表明 Wharf 性能与带宽变化和节点数量没有太大关系,性能稳定;
由于Wharf最小化了到注册表的网络流量,它提供了稳定的性能,并且不受注册表连接带宽变化的影响。
也由于Wharf 只拉取一个镜像一次
虽然从分布式存储共享图像可以减少存储和网络开销,但在Wharf 中容器现在必须远程访问数据。这可能会给工作负载中的单个任务增加额外的延迟。
Wharf 和 本地 Docker 运行做控制变量实验
实验表明运行计算量大的容器任务时,其额外开销是很轻微的;总的来说,两种系统设置的任务执行时间分布相似,因此我们得出结论,使用Wharf不会导致显著的运行时性能下降。
Wharf 从镜像存储形式出发,采用分布式共享存储,从不同维度很巧妙地解决了存储和镜像分发的问题,之前看的 FID 和 阿里 dragonfly 等都是聚焦在 P2P 镜像分发方式;Wharf 聚焦在 Registry 和镜像的存储方式,从而影响分发和运行,这是一个切入点。