1. 引言
以Docker为代表的容器虚拟化技术极大地促进了云计算的发展,同时改变了传统上人们对于计算机软件技术的认知和使用习惯。近年来,由Docker技术演化而来的Serverless无服务器计算正越来越受到广泛的关注和应用,开发人员只需编写代码并将其部署在无服务器平台上,平台负责代码存储、执行、网络和容错能力。目前,无服务器计算已应用于云计算、网络边缘的数据分析、科学计算和移动计算等方面。
容器作为一种新型的虚拟化技术,其在保证了应用级隔离性的基础上,实现了秒级的启动时间。由于与宿主机操作系统共享内核,因此容器在内存占用等指标上要比虚拟机技术所需要的硬件资源要小得多 [1] [2]。此外受益于容器的快速启动等特性的支持,相较于预先启动的虚拟机,无服务器计算(Serverless [3] [4] [5] )可以将计算资源的占用延迟到请求到来时进行,在资源使用的灵活度上拥有很大的优势 [6] [7] [8] [9] [10]。Edwin等人的成本分析表明,Serverless可以显著节省成本:Serverless版服务运行的成本可以达到服务进程常驻状态下成本的0.85% [11]。但是对服务的响应时间有较苛刻需求的场景,例如应对大量并发请求的数据库应用,受限于启动时间对性能的过大影响,Serverless尚不能良好地解决此类问题 [12] [13] [14] [15]。
通常情况下,Serverless的响应时间包含了容器创建的固定开销以及容器内部应用程序的启动延迟。当容器所需要启动的程序较多或者运行时耗费的计算资源较高时,Serverless服务的延迟将会变得不可预计,冷启动时间成为了Serverless大规模应用的一个瓶颈。Serverless应用的启动时间优化,在学术上是一个很有研究价值的问题。
本文目标为解决单机Docker容器启动的性能优化问题,结合多种容器启动优化方法,探究一个新的容器启动流程优化模式,最终实现一个融合多种加速技术的容器启动性能优化系统。主要内容如下:
1) 在典型Serverless应用场景下,通过实验数据分析Docker容器启动整个流程在各阶段的耗时,验证了容器namespace创建和应用初始化是服务冷启动响应延迟的关键因素。
2) 提出了一种基于init-less策略的用户代码编程模式,通过将应用代码改写为init-less模式,使用CRIU技术捕获容器内存快照,以两阶段lazy-restore替代常规Docker容器的启动流程,最终降低FaaS冷启动响应的总耗时。
3) 基于以上研究实现了docker-initless,在多个Serverless应用场景对冷启动情况下任务请求响应延迟进行对比实验,验证了此方法的有效性。
本文第2节介绍容器冷启动优化相关领域的工作,第3节介绍init-less策略用户代码编程模式的设计,第4节介绍Docker容器冷启动流程中影响请求响应时间的关键因素,第5节介绍docker-initless的原型实现,第6节介绍实验和结果分析,第7节总结和未来展望。
2. 相关工作
云计算厂商正尝试以提供编程api接口的形式提升Serverless服务的响应速度,编程开发人员可以将应用中负责进行初始化的代码放到容器预创建阶段执行 [16],以尽量减少在容器运行阶段的时间开销 [17]。容器预创建是一个类似于linux中断上下两部分的优化思路,Docker容器的生命周期包括了create、start、run、stop等阶段 [18]。在create阶段,Docker Daemon将会注册一个容器实例,并为其分配文件层。
通常情况下,Serverless的任务具有可并行重复性,因此可以以相同的运行参数预先进行容器的创建 [19],形成一个“容器池”,此阶段并未进行造成实际的内存占用,在经济上是节约的。在start阶段,Docker将完成对容器进程组的cgroup和namespace以及网络环境的设置 [20],在设置完成之后,将会最终执行容器进程组的启动。此阶段将会耗费至少数百毫秒的固定开销 [21],这在秒级的启动时间中有较大的优化空间。
CRIU (Checkpoint/Restore in Userspace)是Linux系统的一个软件工具,其功能是在用户空间实现Checkpoint/Restore功能。使用此技术可以冻结一个正在运行的程序,并且将其状态保存为一系列的文件,然后使用这些文件就可以在任何主机上重新恢复程序到被冻结的状态 [22]。BLCR [23] (Berkeley Lab Checkpoint/Restart)提供了用户态的libcr库和内核模块来完成相关的Checkpoint/Restore工作。DMTCP [24] (Distributed MultiThreaded Checkpointing)则以library库的形式实现对一个进程的Checkpoint/Restore。gVisitor也支持以Checkpoint/Restore的形式进行容器状态的保存和恢复 [25]。
此外,Ranjan等使用了改进的VAS-CRIU [26] 技术,来解决当Docker容器占用内存较高时CRIU对内存的捕获和还原性能开销过大的问题。CRIU使用了VFS作为快照存储和读取的地址,但是这造成了读写文件系统的额外性能开销 [27]。VAS-CRIU使用了COW [28] (Copy On Write)技术,直接以父容器内存为基准创建子容器 [29],将内存页的拷贝过程延迟到子容器执行内存写操作之时进行。由于避免了向文件系统进行内存页的序列化、反序列化操作,VAS-CRIU的时间开销并不会随着容器所使用的内存量变大而增大。Dong Du等提出的Catalyzer [30] 使用了on-demand的restore策略,将内存恢复和io重建延迟到启动流程的关键路径以后进行,使得容器应用不必等待暂未使用到的内存页和文件描述符恢复完毕即可就绪,可极大减少restore阶段的不必要耗时。
3. Init-Less模式介绍
在常规的Serverless服务中,延迟敏感型任务是本文研究的重点。云计算厂商提供的FaaS是目前Serverless的工程实现,而FaaS服务常以Docker作为底层容器支持。FaaS提出了触发器这一概念作为对任务事件的抽象,典型的触发器类型包括定时任务、对象存储、http请求等,其中http型触发器对响应延迟的敏感性要显著高于其余类型的任务。
http型Serverless服务的目标是保证在无任何请求处理时,无需维持Docker容器实例运行,在请求到来时才进行容器实例的创建。一个典型的http型Serverless服务包含这些项目:
1) Bootstrap启动脚本。Serverless引擎通过在容器内部执行Bootstrap脚本来启动用户进程。
2) 文件目录挂载。用户进程以此来读写保存在外部文件系统中的二进制码、依赖库、数据文件等。
3) 网络端口映射。通过约定一个tcp端口如9000,使得Docker Daemon可以映射任意宿主端口到容器内。
4) 内存规格限制。Docker Daemon基于Cgroup提供计算资源隔离性,使得容器内用户进程不会消耗过多宿主机内存。
3.1. FaaS模式
云服务厂商提出了可在较小编程改动的条件下将现有微服务改造为Serverless服务的方法,称为FaaS模式,FaaS模式演化自基于state-less模式的微服务。
state-less模式的原则是从源头上避免问题的产生。一个微服务应用实例在处理请求时不应使用本机计算资源来缓存计算时产生的上下文数据,而是由统一的有状态服务来进行存储,以避免不同实例间状态数据不一致的问题。
FaaS模式相对于state-less模式额外限制了进程的驻留时间,使得Serverless引擎可以以毫秒级时间片为粒度进行计算资源的分配。通常约定容器实例在不处理请求时为冻结状态,不占用任何计算资源,即不可使用后台线程在处理请求之外的任何时间运行代码。
微服务常由支持长连接的rpc协议对外暴露tcp端口,而FaaS模式通常仅支持编写短连接的http服务,在使用数据库等外部服务时也需要被调用方针对短时间内多次建立短连接进行配置调优。
3.2. FaaS的AOT优化
现有研究表明,在FaaS服务中,适用于python、c、go等轻型runtime语言的冷启动优化方法难以对重型runtime语言如java、dotnet等产生有效作用,一个最基本的Spring Boot项目初始化可能会耗时长达2 s。基于init-less策略的AOT (Ahead of Time)优化是解决此类问题的关键,这也是虚拟机冷启动时间优化的成功经验。
AOT优化的核心思想是收集容器已初始化完毕后对计算资源的占用信息,将进程元数据如寄存器、内存页、文件描述符等序列化为二进制文件保存。将捕获的快照以高吞吐量的磁盘顺序读的方式写入内存,避免在运行时进行初始化所带来的耗时操作,以达到冷启动优化的效果。
不同于虚拟机与宿主机计算资源的高度隔离性,容器本质上为宿主机内的一组进程。CRIU技术对于进程checkpoint和restore前后运行环境的一致性较为严格,在restore时主要困难为:
1) 对于进程已打开的文件,需要进行redo-open操作。如果此时有任何一个文件已被其它进程修改,那么restore会失败。
2) 进程使用的ip端口不可被其他进程占用。
3) 进程使用的pid不可被其他进程占用。
4) 进程使用的tty终端必须可被重连。
5) 进程建立的socket连接的对等端ip必须可被重新访问。
而Docker容器对于ip端口和pid等使用了namespace机制,因此CRIU在对容器进程进行restore时可以比非容器进程获得更好的兼容性。同时,由于容器进程在启动时并不与任何tty终端绑定,也无需关注此类冲突问题。此外,Docker容器使用了overlayfs进行文件目录和挂载点的隔离,这也使得单个进程快照在restore为多个实例时所带来的文件冲突问题得到了很好的解决。但是对于容器进程打开的文件和建立的socket连接,需要有一种新的编程模式进行约定。
3.3. Init-Less模式
为了使得容器可被checkpoint,以及在restore后能够响应请求,init-less模式在FaaS模式的基础之上额外增加了这些限制:
1) 对于在进程初始化完毕后打开的文件,若为多实例公共读模式则应始终保持只读属性,不可为公共写模式。或者进程只在处理请求时才打开文件进行io读写操作,并在初始化过程中不打开任何文件。
2) 对于在进程初始化完毕后主动建立的tcp长连接,在连接不可用时有重试逻辑。或者进程只在处理请求时才使用短tcp连接访问外部服务,并在初始化过程中不建立任何tcp连接。
3) 容器在初始化完毕后,需要向stdout打印初始化完毕短语,以表示容器此时可被checkpoint,而不可使用常规的http健康检查方法探测服务是否已就绪。
init-less模式的提出解决了何种类型的FaaS服务可被改进以适应checkpoint和restore的问题,也同时给出了改造服务的具体思路。基于init-less模式,我们可以编写出能够被docker-initless良好支持的FaaS服务。
3.4. 小结
相对于有状态计算模式,state-less模式消除了对实例内存数据一致性的依赖,FaaS模式额外消除了实例闲置时对计算资源的依赖,init-less模式进一步消除了对实例网络资源、外部文件不可变性的依赖。
4. 容器冷启动耗时分析
由于Docker使用runc进行容器的创建,本文通过在runc源代码中插入计时代码片段,对Docker容器启动的各个阶段进行了耗时统计。
4.1. 容器创建阶段
在容器创建阶段,runc会进行init进程初始化设置,其中创建网络namespace耗时平均为181.492 ms,创建Cgroup为0.381 ms。此阶段主要为Docker容器的网络、内存、CPU等计算资源进行额度分配,并不会产生真正的资源占用。
由于FaaS通常为单服务多Docker容器实例模式,因此可以使用容器预加载机制,形成一个容器池,在有任务需要处理时从容器池中取出pre-start的实例进行派发,可以在整个请求–响应流程中节省300 ms的固定耗时。
4.2. 请求响应阶段
此阶段完成Docker容器计算资源的额度分配和隔离,其流程为:init进程执行Bootstrap创建用户进程,等待用户进程初始化完毕后,将Docker宿主端口收到的http请求转发到容器内部9000端口,再将响应结果返回。我们分为常规场景和基于init-less模式的lazy-restore场景分别分析。
4.2.1
. 常规场景
以java的空Spring Boot项目为例:在用户代码执行之前需要进行JVM初始化和依赖库文件加载,此过程可长达1.89 s。此后才会进行http请求的处理,响应平均耗时为99.351 ms。在FaaS容器冷启动场景下,处理http请求耗时可能会达到微服务场景下的22.7倍。
4.2.2. Lazy-Restore场景
Docker已集成了CRIU作为容器C/R的底层支持。但是由于容器在代码编程模式没有进行限制时难以被正常C/R,因此这个特性长期处于experimental状态没有被默认开启。此外,Docker只使用了CRIU的一阶段restore。本文基于CRIU的lazy-pages特性对Docker容器进行了两阶段的lazy-restore,使得进程并不需要内存页完全恢复完毕即可提前运行。
lazy-pages基于Linux的userfaultfd技术,对于进程预分配的内存,在产生访问并发生缺页中断时可由进程自行处理。此时CRIU将访问page-server并通过rpc读取内存页,实现按需恢复的特性。

Table 1. Resulting data of Flask experiment
表1. Flask实验结果数据
如表1所示,以一个python的Flask项目为例,本文通过在进程初始化时创建一个包含上千万个int键值对的字典对象,并使用lazy-restore方法进行测试。实验分别模拟了1x、2x、4x、8x的大内存规格容器,并在每次请求时按一个随机的key访问不同地址的内存页。
lazy-restore时请求总耗时与具体的容器任务类型有关,按需restore的策略在容器快照体积较大且每次处理请求不需完全访存时可以取得较为恒定的响应时间,且总是优于非lazy-restore场景。
5. Docker-Initless设计
docker-initless是由Docker修改得到的,专用于优化基于init-less模式开发的http容器应用冷启动时间的FaaS系统。docker-initless的架构如图所示,其主要分为Core Controler、Checkpoint Manager、Docker Daemon共3个部分。
5.1. Docker Daemon
Docker Daemon基于Docker CE 17.03版本,是docker-initless的底层容器支持,保持了对原生Docker的兼容。docker-initless修改了此版本所使用的runc和containerd,并调整了dockerd的配置文件。
5.1.1. Dockerd
docker-initless开启了experimental特性,以使用基于CRIU的checkpoint功能。此外将容器底层文件系统由overlayfs更改为btrfs,基于inode级别的COW特性,可极大降低容器文件的写时复制耗时,同时减少冗余文件的空间占用。
5.1.2. Runc
不同于常规情况下runc启动容器的流程,在docker-initless中runc使用了CRIU的swrk模式来进行容器快照的还原。CRIU将进程组的restore共分为network-unlock、setup-namespaces、post-setup-namespaces、post-restore等4个阶段。在swrk模式中,由runc开启一个socket并创建CRIU子进程,runc以rpc的形式对CRIU进行调用,CRIU在restore各个阶段完成时返回响应,runc在此时进行hook函数的执行。
针对lazy-restore策略,docker-initless额外增加了rpc请求中对lazy-pages的支持,使得CRIU可以使用懒加载的模式进行进程restore,在内存页恢复完毕之前即可进入post-restore阶段,并使Core Controler能够尽快进行http请求的分配。
针对pre-start策略,在post-setup-namespaces阶段,runc将通过容器id在redis中获取Core Controler预设的端口号,并开启一个tcp server在localhost上对此端口进行监听。一旦收到来自Core Controler的tcp连接请求,runc将终止等待状态,发送rpc请求调用CRIU进入restore的下一阶段。
5.1.3. Containerd
将containerd的容器启动超时时间由默认的2分钟增加到30分钟,这个调整是针对pre-start策略的优化。pre-start策略要求docker-initless维护一个处于pre-start状态的容器池,由于在rpc响应超时机制中超过2分钟containerd则放弃等待并返回超时错误,因此过短的超时时间将会导致容器的频繁新建和销毁,我们需要对其进行调整。
5.2. Checkpoint Manager
Checkpoint Manager是一个用于创建容器快照的Python脚本,容器快照的制作流程可由约定格式的Dockerfile限定,主要规则为:
1) Dockerfile中必须通过ENV指定CHECKPOINT_MSG。即容器内应用初始化完毕后向stdout打印的字符串短语。
2) Dockerfile中必须通过ENV指定RW_DIRS,且必须为VOLUME的子集,即容器挂载的私有读写目录。这些目录将在容器快照捕获时由Checkpoint Manager进行复制备份,但不包括容器挂载的只读目录。
由于Dockerfile只用于制作镜像,用户需要提供一个docker create命令用于在宿主机上进行容器实例创建。容器快照的制作流程为:
1) 使用build命令进行容器基础镜像的构建。
2) 使用用户提供的create命令和已构建好的基础镜像进行容器创建。
3) 使用start命令开启容器,并执行用户提供的Bootstrap脚本,进行用户代码的初始化。
4) 循环使用info命令捕获容器的stdout信息,判断是否已打印CHECKPOINT_MSG。如果CHECKPOINT_MSG已被打印,则进行下一步。
5) 对所有RW_DIRS进行文件复制备份,在复制的同时保留文件的全部属性。所有的文件将在restore时复制出一份新的版本以进行挂载。
6) 使用commit命令将此时的容器保存为新的容器镜像。这一步是为了保持容器应用内临时文件的一致性,而不是直接使用第1步中构建的镜像。
7) 使用checkpint create命令,获得CRIU捕获的此时容器运行时快照。包含内存页的二进制快照将会在restore时由page-server读取。
5.3. Core Controler
Core Controler是由Go编写的FaaS引擎,也是docker-initless的主控模块。每个FaaS服务都由一个容器池对http请求进行处理,Core Controler负责了该容器池的创建、维护和请求分发。在具体实现上,对于每个已预制作容器快照的服务,Core Controler会使用pre-start策略进行容器预创建,并将预定的端口号保存在redis中。在容器创建时会为每个实例单独开启一个CRIU的page-server,以便配合CRIU的lazy-pages特性使用。并为该实例单独复制一份私有目录文件,基于btrfs的COW特性,只需在复制时设置reflink属性为always即可。pre-start的容器将会处于post-setup-namespaces阶段最多30分钟,如果期间一直没有http请求需要处理,将会被Core Controler销毁并重新创建。
Core Controler会对外暴露一个tcp端口以供http请求的访问。当容器池内并无已完全启动的实例时,一旦有http请求到来,便从容器池内选择一个实例进行CRIU的下一阶段restore。基于lazy-restore策略,CRIU会在容器快照内存页申请完毕并设置为userfaultfd后立即进入post-restore阶段,由page-server配合完成后续缺页中断的处理。此时Core Controler可将http请求转发给容器实例,并等待响应结果返回,最终完成整个http请求–响应流程。
已冷启动完毕的容器实例将会被Core Controler优先分配http请求,直到在一定时间内没有http请求需要处理最终被超时销毁为止。若单个容器实例达到请求并发能力上限,将会触发Core Controler进行扩容冷启动。
6. 实验结果与分析
实验环境为:8核 AMD Ryzen R7 3700X 4.20 GHz处理器;64 GB DDR4内存;512 GB固态硬盘;操作系统为Ubuntu 18.04,内核版本为5.10.60.1-microsoft-standard-WSL2;Docker为Community 17.03.2版本。
实验结果包含了两个阶段的时间:Boot阶段为Docker Daemon收到http请求开始创建容器,到容器内应用可处理http请求为止;Execution阶段为容器内应用开始处理http请求,到Docker Daemon将http响应结果返回为止。
实验包括了三个典型的FaaS服务子实验,其源代码均以init-less模式编写,并且提供了Dockerfile以便docker-initless测试。实验的流程为:
1) 使用Checkpoint Manager制作FaaS服务的容器快照和镜像。
2) 使用原版Docker创建容器,统计两阶段耗时。
3) 关闭pre-start和lazy-pages特性,使用docker-initless基于容器快照创建实例,统计两阶段耗时。
4) 开启pre-start和lazy-pages特性,使用docker-initless基于容器快照创建实例,统计两阶段耗时。
5) 将第1步制作的容器快照放在使用高速内存创建的tmpfs文件系统中,开启pre-start和lazy-pages特性,使用docker-initless基于容器快照创建实例,统计两阶段耗时。
6) 重复第2~5步各1000次,对耗时取平均值。
6.1. Flask

Figure 1. Resulting data of Flask experiment
图1. Flask实验结果数据
如图1所示,本实验场景为一个内存消耗约1.3 GB的Flask应用,在其初始化的过程中需要创建一个包含一千万个int键值对的字典对象。实验发送的http请求会随机访问其中的一个键值对,以测试按需restore的优化效果。
根据实验结果,在不额外使用优化策略的情况下,init-less模式相较于普通FaaS模式在总响应时间上取得了3.11x的优化效果。主要收益来源于对Python大内存对象初始化的AOT优化,通过以init-less策略取代init过程来节省耗时。
在使用了lazy-pages的对比实验中,pre-start策略消除了Boot阶段约92%的耗时,主要收益来源于提前进行了namespace和Cgroup等容器环境的初始化;lazy-restore策略也在Execution阶段取得了17.82x的优化效果。同时在基于高速内存的tmpfs的加速下,总的响应时间最终得到了64.06x的优化。
6.2. Spring Boot
如图2所示,本实验场景为一个输出Hello World的Spring Boot项目,在初始化的过程中需要启动JVM并加载依赖库文件,最终产生约300 MB的内存占用。本实验模拟了带有重型runtime的FaaS服务的一般情况。
与Flask实验类型,在不额外使用优化策略的情况下,init-less模式相较于FaaS模式在总响应时间上取得了3.17x的优化效果。由docker run场景可知,Docker创建空容器耗时约为364 ms;由criu restore场景可知,Spring Boot在完全初始化完毕时,其Execution阶段固定耗时约为99 ms;因此可认为在criu restore场景中CRIU恢复快照耗时约为611 − 364 = 247 ms,而在docker run场景中JVM和Spring Boot库初始化耗时约为1888 − 99 = 1789 ms,init-less模式相较于init流程可取得7.24x的加速比。

Figure 2. Resulting data of Spring Boot experiment
图2. Spring Boot结果数据
由lazy-pages场景可知,在使用了pre-start策略后,在Boot阶段可取得平均约364 − 53 = 311 ms的耗时优化。而额外的611 + 99 − ( 257 + 53 ) − 311 = 89 ms,是lazy-restore策略在Execution阶段取得的耗时优化,这表明快照中由于懒加载并未在Execution阶段产生缺页中断的内存页占比约为89/247 = 36.03%。
由于Spring Boot实验的内存快照体积相比于Flask较小,CRIU在restore阶段并未产生过多磁盘io,因此将文件系统改为tmpfs并没有取得显著优化效果,使用高速内存前后的加速比分别为7.26x和7.93x。
6.3. Rembg

Figure 3. Resulting data of Rembg experiment
图3. Rembg结果数据
如图3所示,实验场景为一个为图片人像去除背景的Pytorch应用,在初始化的过程中需要约160 MB的模型文件并在计算时加载约1 GB的依赖库。本实验模拟了CPU密集型FaaS服务的一般情况,根据阿姆达尔定律,此类计算加速比的提升效果取决于固定耗时的占比。
即使Rembg在初始化完毕的情况下,完成一次请求–响应耗时仍需约2 s,而使用init-less模式并不能减少这一部分的耗时。因此在本实验中毫秒级的耗时优化对加速比提升的贡献较小,但最终仍然取得了最高约1.75x的优化效果。
6.4. 小结
在多种FaaS服务场景下,docker-initless均可将容器冷启动阶段耗时由数秒优化至数百毫秒级别,并且验证了docker-initless在使用计算资源方面的经济性,同时还可以保证对现有Serverless方案的兼容性。
7. 总结与未来展望
本文提出了一种面向Serverless服务冷启动优化的init-less编程模式,并基于该模式实现了一个集成pre-start和lazy-restore策略的FaaS系统docker-initless。该系统在多个典型实验场景下对FaaS服务的冷启动响应时间有了显著改善效果,同时在成本上保持经济性。
本文实验并未涉及在恢复内存快照时使用写时复制技术,这部分的工作将会是未来的研究重点。