CRI-O 是在kubernetes CRI标准提出后,RedHat开发的一个轻量高效、仅为 Kubernetes 所用的容器运行时,cri-o已成为kubernetes广泛采用的容器运行时组件。RedHat在其容器云产品Openshift的最新版中已默认采用CRI-O做为容器运行时。在底层OCI运行时方面,RedHat推出了crun,crun采用c编写。在容器创建需要用到的fork,exec模型支持,以及跟linux系统底层交互方面,c比golang更有优势,此外,c也更轻量高效。根据社区的测试结果,不管是用时还是内存开销,crun 都比 golang编写的runC 好。RedHat的容器管理产品podman默认的oci运行时已采用crun。麒麟云团队对CRI-O和crun创建容器的过程进行了深入的分析,提出了一些优化的方法。测试结果表明,优化后会有25%的性能提升。这篇文章介绍相关的代码和所做的工作,基于CRI-O v1.24版本,crun v1.8版本。
1 容器创建分析
与docker不同,kubernetes中想要起一个业务容器,通常需要先创建pod(通常使用pause镜像),然后在pod中起业务容器。多个容器可以使用同一个pod,共享部分命名空间。而pod对于底层OCI侧来说,实际上也是一个容器。对于CRI-O来说,pod对应sandbox的概念。一个标准的创建流程是先使用pause镜像(由配置文件决定)创建一个sandbox,然后再创建容器Container。我们主要关注创建容器的部分。
在CRI-O中创建完sandbox后,从耗时上看,创建容器大致需要以下三步:
1. 准备好容器的rootfs,layers等信息并记录,对应图中createSandboxContainer函数;
2. 调用底层OCI运行时,配置namespace和cgroup,对应图中createContainerPlatform函数;
3. 持久化容器状态,对应图中ContainerStateToDisk函数。
图 1 CRI-O创建容器序列图
其中函数 createContainerPlatform会调用底层OCI运行时,创建容器的namespace和cgroup,并配置apparmor和seccomp等安全配置。
以crun作为底层运行时为例,容器创建的状态流转,涉及到多次fork操作(部分操作已省略)。
从libcrun_container_create函数开始,在调用detach_process后将进程daemonize化。
然后进入最关键的容器初始化函数libcrun_container_run_internal,它会fork出一个子进程,并通过libcrun_run_linux_container和container_init函数进行同步协作,完成namespace和cgroup的配置。
图 2 crun 创建容器活动图
经过测试,CRI-O创建单个容器火焰图如下:
图 3 CRI-O创建单个容器火焰图
耗时占比大致如下:
图 4 CRI-O创建容器耗时占比饼图
在创建容器的过程中,我们可以简单地将其按照函数的不同分成这三个部分。后续的介绍也会围绕这三个部分展开。
2 conmon合并
在第二部分的“createContainerPlatform”函数中,其内部的操作可以简单的描述为:cgroup创建,调用conmon,conmon调用crun实际创建容器,之后CRI-O等待crun返回容器pid,确保容器创建完成后结束。
图 5 createContainerPlatform序列图
其中,conmon起到类似Docker中垫片的作用,垫片的作用是让容器运行时和容器相互独立,使得容器管理程序(CRI-O)的启停不会影响容器运行。conmon负责监控crun并获取容器的输入输出流,将容器的退出持久化到文件,CRI-O通过fsnotify感知到容器退出。conmon形态为一个独立的二进制,被CRI-O以命令行的方法调用。所以理论上,我们可以将conmon和crun合并,让conmon与crun绑定,减少一次fork/exec的开销,缩短CRI-O调用conmon,再由conmon到crun的调用链。同时对conmon进行精简,减少二进制大小,缩短程序加载时间,进而缩短第二部分中CRI-O的等待时间。
3 seccomp计算优化
在创建流程中,crun作为底层OCI运行时负责实际启动容器进程,CRI-O则会一直等待crun完成创建返回pid。因此crun的创建速度直接影响整体的创建速度。我们调试中发现,在一些国产arm64平台上crun对于seccomp BPF每次都重新计算,这会增加约10ms的开销,占单次容器创建时间的17%左右。理论上,X86平台上也存在这一问题。seccomp的主要作用是通过限制系统调用,保证容器安全。大多数容器需要受限制的系统调用都是通过seccomp profile模板生成的,容器之间的seccomp BPF文件通常是相同的。因此,可以通过将前一个容器创建过程中计算的seccomp BPF缓存到磁盘,并利用checksum验证缓存是否命中来避免重复计算seccomp BPF,从而减少了容器创建的时间开销,提高了容器的创建效率。测试较不使用缓存时crun耗时缩短15%。
crun的相关代码如下图,Libcrun_open_seccomp_bpf会去查找是否有seccomp bpf缓存,如果存在将from_cache置为true。Libcrun_generate_seccomp会根据from_cache跳过计算,否则会每次都会重新计算seccomp bpf。
由于find_in_cache需要用到gcrypt-devel库计算checksum,但是crun的rpm spec打包没有指定对其的依赖关系,导致使用rpm安装的crun二进制,不会使用seccomp缓存。
4 容器layer记录异步写
在kubernetes的大部分应用场景下,容器提供的为无状态的服务,容器异常可以通过重新创建容器恢复。在CRIO的原有代码中,对容器的layer信息记录采用的为同步写文件方式,引入了较大的延迟。我们在CRI-O的配置文件提供可选配置,用户可对容器是否有状态进行配置。我们修改了storage写文件逻辑,对容器的layer记录引入volatileLayerLocation与原来的stableLayerLocation做区分,对于镜像layer采用同步写的方式确保不丢失,对于指定了Volatile=True特性的容器的layer使用异步写的方式存放在volatile-layers.json中。通过分别存储镜像layer与启用了Volatile特性的容器layer,确保对volatile和非volatile容器都能出错重新创建。这能够让来自第一部分“createSandboxContainer” 函数的耗时缩短约50%,根据实际环境读写性能的不同,实际缩短的时间也会发生变化。
5 性能对比
在搭载了KylinV10系统的物理环境中使用bucketbench测试不同线程数创建总计100个容器的结果如下,整体而言与优化前相比提高了约25%的创建速度。
图6 CRI-O容器创建速度对比图
银河麒麟云原生操作系统是麒麟软件面向云原生场景,为支撑云原生应用而全新打造的一款操作系统产品。云原生应用对操作系统有更多的功能需求和特殊的性能需求,如容器编排,集群调度,服务发现,负载均衡,弹性伸缩,资源隔离,快速容器创建,低延迟容器网络等。同时,在云原生应用编排领域,kubernetes已成为业界的事实标准。银河麒麟云原生操作系统采用“内核+操作系统+ kubernetes”的联合设计思想,在传统操作系统接口外,为云原生应用提供kubernetes 扩展API,支持操作系统集群粒度统一纳管,支持一致性发布和原子回滚。在容器调度,容器网络,容器运行时等关键组件进行深入优化,致力打造国产平台容器性能标杆。银河麒麟云原生操作系统已运用于银行和证券领域。