休眠容器:一种用于快速启动和高密度部署的瘦身容器模式,适用于无服务器计算
date
Dec 9, 2024
slug
hibernate-container
status
Published
tags
容器
云原生
论文阅读
Quark
summary
Hibernate Container: A Deflated Container Mode for Fast Startup and High-density Deployment in Serverless Computing
type
Post
英文标题:Hibernate Container: A Deflated Container Mode for Fast Startup and High-density Deployment in Serverless Computing
0 摘要
无服务器计算是一种流行的云计算范式,要求低响应延迟以处理按需用户请求。有两种显著的技术用于减少响应延迟:保持完全初始化的容器处于活动状态(热容器1)或减少新容器的启动(冷启动)延迟。本文提出了第三种容器启动模式:休眠容器2,启动速度比冷启动容器模式更快,内存消耗比热容器模式更少。休眠容器本质上是一个“瘦身”的热容器。其应用内存被交换到磁盘,释放的内存被回收,基于文件的mmap内存被清理。休眠容器的瘦身内存在响应用户请求时被膨胀。由于休眠容器的应用程序已完全初始化,其响应延迟低于冷启动模式;而且由于应用内存被瘦身,其内存消耗
低于热容器模式。此外,当休眠容器被“唤醒”以处理请求时,唤醒的容器具有与热容器相似的响应延
迟,但内存消耗更少,因为并非所有的瘦身内存都需要被膨胀。我们将休眠技术作为开源的Quark安全容器运行时项目的一部分进行实现,我们的测试演示表明,休眠容器的内存消耗约为热容器的7%到25%。所有这些都导致了更高的部署密度、更低的延迟以及整体系统性能的显著改善。
1热容器是指作为热启动过程的一部分创建的完全初始化的容器 2休眠容器是指休眠容器模式
1 引言
无服务器计算,包括函数即服务(FaaS)和无服务器容器,正变得越来越成为一种流行的云计算范式。无服务器计算环境通常在共享环境中运行多租户工作负载,以处理按需用户请求。它得到了主要云服务提供商的支持,如 AWS Lambda [1]/Fargate [2]、Google Function [3]/Cloud Run [4] 和 Azure Function[5]/Container Instance [6]。
为了在共享环境中托管多租户用户应用程序工作负载,主要云服务提供商通常使用基于虚拟机的安全容器运行时,而不是像 runC/LXC 这样的基于进程的容器运行时。基于虚拟机的安全容器运行时提供的安全隔离就像传统的虚拟机一样。例如,AWS使用 Firecracker[7],GCP 使用 gVisor[8],而阿里云和华为云使用 Kata 容器[9] 进行无服务器计算。但这些基于虚拟机的安全容器运行时消耗更多内存,并且响应延迟高于基于进程的容器运行时。
用户请求的响应延迟对于无服务器计算环境至关重要。用户请求的响应延迟包括三个部分:容器运行时启动、用户应用程序初始化和用户请求处理。容器运行时的启动时间通常需要大约100毫秒,而应用程序初始化时间范围从10毫秒到10秒不等,而用户请求处理时间通常较短,仅需几毫秒到10秒。与请求处理时间相比,容器运行时启动和应用程序初始化延迟的贡献相当显著。减少容器运行时启动和应用程序初始化延迟是无服务器计算设计中的关键挑战之一。通常有两种优化方法用于减少延迟:
- 热启动优化:一种常见的技术是保持执行运行时处于活动状态,即热容器,持续一段短时间,以便于这样未来调用相同请求时,可以重新使用热容器。热启动确实减少了冷启动的开销,但保持容器处于活动状态会消耗大量计算资源,尤其是内存资源。这反过来又导致更高的系统资源需求。目前有多种正在进行的努力,以提高热启动效率,例如减少容器运行时开销[10][8]和优化热容器保持活动调度策略[11]。
- 冷启动延迟优化:当然,由于系统资源限制,我们无法保持所有无服务器容器处于活动状态。另一个研究领域是减少冷启动延迟。这一努力包括减少容器运行时启动时间[10][8]和应用初始化延迟[12][13][14]。
本文提出了一种基于传统内存交换技术的第三种启动机制,该技术非常适合缓解无服务器计算的启
动时间。以下是与此相关的关键考虑因素:
- 快速交换存储:随着高性能二级存储(如SSD、NVM)在公共云中的商业可用性[15]的出现,内存交换性能获得了巨大的提升。
- 轻量级无服务器工作负载:无服务器计算环境的快速启动要求需要轻量级和低内存占用的工作负载。例如,在AWS [16]中,47%的函数配置为使用默认的最小内存设置128 MB。总体而言,只有14%的AWS Lambda函数的内存分配超过512 MB。而在Azure[17]中,90%的应用程序从未消耗超过400MB,50%的无服务器应用程序工作负载最多分配170MB。由于无服务器计算工作负载的内存占用较小,因此产生的交换内存成本相对较低。
总之,在无服务器计算环境中采用内存交换技术以实现低延迟启动,并结合保持活动容器的低内存
消耗,存在很大的机会。以内存交换技术为关键推动力,我们的论文提出并实现了休眠容器,这是一
种瘦身的保持活动的热容器。休眠容器利用以下关键优化来实现低延迟启动和低内存消耗:
- 内存: 休眠容器的内存消耗远低于热容器,因为它将应用程序内存交换到磁盘,回收并将容器应用程序的空闲内存返回给主机操作系统内核,最后清理文件支持的mmap内存并返回给主机操作系统。
- CPU: 它完全不消耗系统CPU周期因为它将用户应用程序置于完全暂停状态。
休眠容器的用户请求响应延迟远低于冷启动。这主要是因为休眠容器的用户应用程序已完全初始化,并且容器运行时正在使用以下保持活动的资源:
- 主机操作系统对象: 休眠容器保持其主机操作系统对象处于活动状态,例如容器运行时操作系统进程、控制组、容器网络、容器文件系统、进程。操作系统对象消耗很少的系统内存,但保持它们的存活可以节省大量的重新初始化成本。
- 阻塞的容器运行时线程:容器运行时的主机线程被阻塞以等待用户请求。它不消耗CPU周期,但系统会立即响应,类似于热容器。
重要的是要强调,唤醒容器对后续请求的响应延迟几乎与热容器相似,但它消耗的内存少于热容器。这主要是因为唤醒容器在处理用户请求时并不需要所有的膨胀内存。
总体而言,这一切导致了更高的部署密度和更好的系统整体性能。
以下是我们主要贡献的总结:
- 我们提出并实现了休眠容器模式,作为开源Quark容器运行时的一部分 [18]。休眠容器模式消耗的内存少于热容器,并且启动速度比冷启动快。此外,从休眠容器派生的唤醒容器消耗的内存比热容器少,并且请求响应延迟几乎相似。
- 我们发现主要的内存换入延迟是由于SSD磁盘的随机读取造成的。受到REAP[16](一种记录和预取机制)的启发,我们将批量内存预取换入机制作为休眠容器膨胀过程的一部分进行实现。我们比较了基于页面错误的换入和REAP换入在不同基准测试中的表现。
- 我们实现了一种新的回收导向内存管理算法,以高效地将空闲内存页面返回给主机操作系统内核。这避免了复杂的气球化技术的需求。
Ballooning技术形象地在客户机占用的内存中引入气球(Balloon)的概念,气球中的内存是可以供宿主机使用的(但不能被客户机访问或使用),所以,当宿主机内存使用紧张,空余内存不多时,可以请求客户机回收利用已分配给客户机的部分内存,客户机就会释放其空闲的内存,此时若客户机空闲内存不足,可能还会回收部分使用中的内存,可能会换出部分内存到客户机的交换分区(swap)中,从而使得内存气球充气膨胀,从而让宿主机回收气球中的内存可用于其他进程(或其他客户机)。反之,当客户机中内存不足时,也可以让客户机的内存气球压缩,释放出内存气球中的部分内存,让客户机使用更多的内存。
2 背景与动机
休眠容器是一个在Quark容器运行时中实现的瘦身热容器。它的瘦身过程包括回收用户应用程序的空闲内存和将用户内存交换到二级存储。本节讨论Quark安全容器运行时设计的背景、现有的客户操作系统空闲内存回收和交换机制。最后,本节讨论了我们论文的动机,并列举了优化当代最先进的无服务器计算环境的各种机会。
2.1 安全容器与Quark运行时
如前所述,我们将休眠容器模式作为开源Quark容器运行时的一部分进行了实现[18]。在这一小节中,我们简要描述了各种最先进的安全容器运行时技术的现状。我们特别深入探讨了Quark运行时架构的细节。
无服务器计算在共享环境中托管多租户工作负载。传统的容器运行时,如RunC/LXC,不适合无服务器计算环境。这主要是因为这些容器运行时无法提供多租户级别的安全隔离。相反,主要云服务提供商在各自的无服务器计算环境中使用虚拟机(VM)级别的安全容器运行时。此类虚拟机级容器运
行时的例子包括Kata[9]/Firecracker[19]和gVisor[8]。
Kata和Firecracker使用基于Linux内核的虚拟机进行安全隔离。由于它们都使用现有的通用Linux内核,因此它们在无服务器计算环境中的启动延迟和资源开销相当高。
Quark[18]和gVisor[8]是另外两个专门为无服务器计算设计的显著安全容器运行时。它们由用户空间
操作系统内核和轻量级虚拟机监视器(VMM)组成。它们旨在提供与Linux兼容的系统调用接口,并支持CRI/OCI接口,以便现有的Linux容器镜像可以无须任何更改地运行。如前所述,这两个安全容器运行时针对无服务器工作负载进行了高度优化。因此,它们的启动延迟和资源开销低于Kata/Firecracker运行时。然而,与Kata/Firecracker安全容器运行时明确基于Linux内核不同,Quark和gVisor安全容器运行时的Linux兼容性并不好。
图2展示了Quark的架构。Quark的架构与传统的Linux虚拟机相似。它们都运行在Linux主机内核之上,使用KVM虚拟机监控器。Quark运行时进程在一个标准的Linux容器内运行,使用Cgroup和网络/文件系统命名空间进行隔离。Quark包括一个新的用户空间操作系统内核(QKernel)和虚拟机监控器(QVisor),这些都是为无服务器计算进行了大量优化的。Quark提供对虚拟化系统调用接口的支持,该接口模拟Linux系统调用。Quark实现了大多数Linux内核功能,如内存管理、进程管理和输入输出管理等。
Quark专门为无服务器计算环境设计,并集成了无服务器计算特定功能,如支持休眠容器模式等。
2.2 客户操作系统释放的内存回收
如前所述,休眠容器模式的一个关键价值主张是将用户应用程序释放的内存返回给主机操作系统。释
放的内存回收过程对于像Linux这样的通用客户操作系统来说并不是一项简单的任务。通常,当Linux客户操作系统的应用程序释放内存给Linux客户操作系统内核时,理想情况下,Linux客户操作系统内核应该将应用程序释放的内存返回给主机Linux内核,以便释放的内存可以重新分配给其他主机操作系统进程。不幸的是,Linux 客户操作系统将释放的内存保留在其内存池中,而不将其返回给主机 Linux 操作系统。这是因为 Linux 针对裸金属机器环境进行了优化,在这种环境中没有内存回收的要求。简而言之,这意味着客户操作系统释放的内存不会在传统虚拟化设置中被主机操作系统释放和回收。
目前,有两种不同的方法来解决 Linux 客户操作系统释放内存回收的问题:
- 气球化[20]:气球化方法依赖于驻留在客户操作系统中的特殊气球化驱动程序,并与虚拟机监控器合作,动态调整虚拟机的内存大小。从本质上讲,气球化是一种计算机内存回收技术,虚拟机监控器使用它来允许物理主机系统从某些客户虚拟机中检索未使用的内存并与其他虚拟机共享。
- 内存插件[21]:内存插件机制依赖于操作系统内核对热添加/热移除物理内存的支持。内存热移除使内存区域对用户不可用。它需要进行页面迁移,将已使用的页面移动到另一个区域。然而,页面迁移会导致性能损失。内存插件已在虚拟机内存收集 [22] 中使用。
基于虚拟机的容器运行时,如Kata/Firecracker,也基于Linux客户操作系统,因此它们也面临释放内存回收问题。根据我们的了解,它们都没有采用气球化或内存插件的方法。这主要是因为气球化和内存插件方法在无服务器计算环境中过于复杂,难以采用。
作为我们休眠容器工作的一个部分,我们在Quark安全容器运行时中实现了一个专用内存管理系统,以提高无服务器计算环境中的回收效率。
2.3 客户应用内存交换
如前所述,休眠容器模式的一个关键价值主张是将用户应用程序的内存换出。
换出是一种内存管理方案,它暂时将非活动内存页换出到二级存储,并在页面表中将该页面的条目标记为不存在。当系统需要访问被换出的页面时,操作系统的虚拟内存管理系统会生成一个页面错误,以便换入该页面。
在一个常见的虚拟化环境中,当前主机操作系统的交换机制效率不高,因为目前的交换方式是以不
合作的方式进行的。VSWAPPER[23] 的研究调查了虚拟化环境中不合作交换的低效问题。问题包括静默交换写入、过时交换读取和错误交换读取等。VSWAPPER 实现了一个与客户机无关的内存交换器,以解决所有这些问题。VSWAPPER 在解决常见虚拟化环境中的不合作交换问题方面表现相当不错,但并不特别适用于无服务器计算环境。
对于无服务器计算,我们有许多机会来实现更好的交换性能,因为我们希望交换出一个空闲容器的整个内存:
- 有应用程序内存页面的批量换出:对于常见的交换场景,操作系统内核选择交换出不活跃的页面。在无服务器计算环境中,我们交换出一个空闲容器的所有用户应用程序内存集。这反过来导致了与内存管理过程相关的成本节省。
- 暂停应用程序的无竞争条件换出:在无服务器计算环境中,我们可以在交换出内存页面时暂停空闲的用户应用程序进程。这避免了常见交换过程中的复杂竞争条件处理。
- 页面换入的批量顺序磁盘读取:常见的操作系统内存页面换入是由页面错误触发的,二级交换存储以随机读取的方式访问。对于常见的二级存储,无论是 HDD 还是 SSD,批量顺序读取的性能总是优于随机读取。REAP[14] 揭示了函数在同一函数的不同调用中访问相同的稳定工作集页面。在识别出页面集后,可以使用批量顺序读取预取这些页面。与基于页面错误的换入方法相比,批量换入方法不仅可以节省与磁盘随机页面加载成本相关的开销,还可以节省与页面错误处理和客机之间切换相关的成本。
我们的动机是解决上述所有提到的优化机会,并为无服务器计算环境开发一个更高效的交换机制。
3 设计与实现
以下小节深入探讨了启用休眠容器功能作为 Quark安全容器运行时的一部分的架构和设计细节。
3.1 休眠容器的状态机
本节介绍休眠容器如何响应传入的用户请求。
图3显示了为服务传入用户请求而进行的各种容器状态转换。
在传入用户请求时,无服务器平台执行1️⃣冷启动。这反过来会生成一个新的热容器,用户请求被转
发到新生成的热容器。当热容器接收到用户请求时,它2️⃣转变为运行状态以处理请求,当请求处理完成后,它3️⃣返回到热状态。
为了减少用户请求的响应延迟,无服务器平台可能会将热容器保持活跃一段短时间。如果在短时间内有其他传入的用户请求(相继发生),这些请求可能会由热容器处理,以实现低响应延迟。然而,当热容器处于空闲状态时,它仍然会消耗特定于应用程序的分配内存占用。在这种情况下,无服务器平台可能会在内存压力下驱逐热容器,以释放内存供其他无服务器功能容器使用。在热容器被驱逐后,下一个用户请求会经历更高的冷启动延迟。简而言之,系统中热容器的数量越多,用户请求的响应延迟就越好。
除了上述传统无服务器计算环境中的容器状态外,我们提出了以下三种新的容器状态:
休眠:休眠容器是一个膨胀的热容器,其内存占用低于热容器。无服务器平台可以选择将热容器“膨胀”到休眠容器,而不是完全驱逐热容器,以释放内存。
无服务器平台可以通过向热容器发送 SIGSTOP 信号,将热容器4️⃣的状态转变为休眠容器,从而启动热容器的通货紧缩。
休眠运行:休眠容器在接收到用户请求以处理用户请求时,可能会像运行容器一样7️⃣过渡到休眠运行状态。
唤醒:休眠运行容器8️⃣在用户请求处理完成后返回到唤醒状态。唤醒容器6️⃣在下一个用户请求时再次进入休眠运行状态。唤醒容器9️⃣在接收到SIGSTOP信号时也可能返回到休眠状态。唤醒容器的用户请求响应延迟几乎与热容器相似,但消耗更少的内存。当无服务器平台预测到有即将到来的用户请求时,它也可能通过向休眠容器发送SIGCONT信号5️⃣,将其“唤醒”为唤醒容器,以减少响应延迟。
3.2 脱气过程概述(Deflation Process Overview)
休眠容器本质上是一个被压缩的热容器。休眠容器是通过以下四个步骤从热容器派生而来的:
- 暂停热容器用户应用程序进程,并阻塞容器运行时主机操作系统线程,以等待“唤醒”触发。
- 回收并将释放的应用程序内存页返回给主机Linux内核。
- 将应用程序提交的内存页换出到本地磁盘。
- 通过使用MADV DONTNEED作为“建议”参数的madvise()清理应用程序的文件支持的mmap内存,并将其返回给Linux内核。
在步骤#1中,休眠容器不消耗任何系统CPU周期。在步骤#2、#3、#4中,休眠容器的用户应用程序分配的内存被返回给主机Linux内核,因此它消耗的内存远低于热容器。我们在子节3.3、3.4、3.5中分别描述步骤#2、#3、#4的详细信息。
休眠容器可以转变为热容器,转变过程包括内存膨胀和用户应用程序进程的恢复。休眠容器可以通过两种触发器被唤醒:
- 用户请求:当无服务器平台收到用户请求时,无服务器平台可以直接将请求转发给休眠容器,而不首先唤醒它,以减少整体系统延迟。休眠容器通过将容器运行时线程置于阻塞状态来实现这一点,以等待用户请求,例如 Posix 套接字 sys accept 或 sys read。当有 Posix 套接字客户端连接请求或套接字数据可用时,容器运行时线程会被 Linux 主机内核解锁,并开始剩余的唤醒处理,即内存换入步骤,用户应用程序进程恢复。
- 无服务器系统控制平面:无服务器平台可以在预期用户请求即将到来时显式唤醒容器。由于在用户请求到来之前部分完成了内存膨胀,因此用户请求响应延迟相较于用户请求触发步骤更低。
3.3 内存回收方向内存管理
休眠容器回收客户用户应用程序释放的内存页,并将其返回给主机Linux内核,就像虚拟机气球化技术一样。
QKernel在KVM虚拟机中运行,其客户物理内存是主机Linux操作系统的虚拟内存。QKernel的客户物理内存页(即主机虚拟内存页)在被访问之前不会被主机Linux操作系统内核提交。Quark可以通过系统调用sys madvise()将已提交的内存页返回给主机Linux内核,使用MADV DONTNEED作为“建议”参数[24]。在成功的madvise()操作之后,后续对该范围内的页的访问将成功,但这会导致匿名私有映射的零填充按需页。理想情况下,在从来宾操作系统内核内存管理系统中识别出空闲内存区域后,可以通过调用madvise()进行回收。
不幸的是,原始的 Quark 运行时并不容易回收已释放的内存。Quark 容器运行时目前使用二进制伙
伴分配器 [25]。它不适合内存回收,因为它将空闲内存块维护在一个空闲列表中,该列表是一个线性链表,其“下一个”指针保存在空闲内存块中。对于裸金属优化的操作系统内核,空闲列表工作良好。但对于客户操作系统内核,如果我们使用 madvise()来回收空闲内存页块,由于对内存块的后续访问是零填充的,因此“下一个”指针被清除,从而导致空闲列表数据结构损坏。总之,现有的伙伴分配器不适合内存页的回收。相反,我们实现了位图页面分配器(bitmap page allocator)来解决这个特定问题。我们将在后续段落中介绍位图页面分配器的设计。
原始Quark容器运行时的内存分配分为以下两个领域:
- 客户用户应用程序内存地址空间分配器:客户用户应用程序通过系统调用(如sys brk,sys mmap)从客户操作系统内核分配内存。这些系统调用仅分配内存地址空间,内存页在通过内存页面错误处理程序访问之前不会被提交。
- Quark运行时全局堆分配器:Quark运行时是使用Rust编程语言开发的。Rust运行时支持自定义堆分配器。Quark使用基于二进制伙伴分配器的自定义堆分配器。QKernel的内核数据结构,如内核栈,是从堆中分配的。原始Quark还在页面错误处理程序中为用户应用程序从全局堆中分配内存页,这对内存回收不友好。因此,我们决定为休眠容器开发第三种内存页分配器:位图页面分配器(bitmap page allocator)。
位图页面分配器旨在为客户用户应用程序的内存页面分配管理。它仅用于页面错误处理程序中用户应用程序的固定大小 4KB 内存页面分配。
如图 4 所示,位图页面分配器使用 4MB 内存块进行 4KB 内存页面分配。4MB 内存块的起始地址也是 4MB 对齐,4MB 内存块的第一个 4KB 内存页面被保留为控制页面。控制页面包含 3 个字段:
- “下一个”指针:在位图页面分配器中,所有具有空闲页面的 4MB 内存块在一个空闲列表中链接,该空闲列表是一个线性链表。线性链表的“下一个”指针保存在控制页面中。
- 空闲页面位图:一个 4MB 块包含总共 1024 个4KB 内存页面。在此基础上,第一页用作控制页面。剩余的1023页可以分配。位图页面分配器保持一个L2(第2级)位图,包含一个16 x 64位整数数组(总共1024位),以指示页面是否已分配(即一个页面对应一个位)。为了加速空闲页面查找,位图页面分配器保持另一个64位整数作为L1(第#1级)位图,以指示L2位图中的64位整数是否为零。因此,对于每个页面分配,位图页面分配器需要访问两个64位整数:一个是L1位图,另一个来自L2位图。例如,要查找一个空闲页面,位图页面分配器首先查找L1位图64位整数的第一个非零位。
假设第一个非零位是第4位,这意味着第4个L2位图64位整数指示有空闲页面。位图页面分配器其次查找第4个L2位图64位整数的第一个非零位。通过这种方式,位图页面分配器的空闲页面查找复杂度为 O(2)。
- 内存页引用计数数组:操作系统内核的内存页可能会被多个页面表引用,当进行进程克隆时。位图页面分配器还在控制页面中保持页面引用计数,使用一个16位原子整数数组[26]以最大化内存使用效率并提高性能。
内存页分配和引用计数的增加和减少如下。
- 内存页分配:在页面分配请求时,位图页面分配器从空闲列表的第一个4MB内存块中分配一个页面,并更新控制页面的位图。如果第一个4MB内存块中没有更多的空闲页面,它将从空闲列表中移除。如果空闲列表中没有更多的 4MB内存块,位图页面分配器将从全局堆中分配另一个 4MB 内存块,即全局二进制伙伴分配器。内存分配需要获取全局锁以避免竞争条件。
- 页面引用计数增加和减少:每当有来宾进程克隆/终止或页面写时复制 (COW),来宾页面引用计数就会增加和减少。位图页面分配器还在控制页面中存储页面引用计数以提高性能。由于 4MB 内存块是 4MB 对齐的,任何来宾页面可以通过清除其地址的最低 22位来找到其控制页面(即 4MB 块的第一页)。因此,从任何用户内存页面地址查找其控制页面不需要查找表。在找到页面的控制页面后,引用计数的增加和减少通过 Rust 的原子操作完成:原子取加和原子取减 [26],这是无锁操作。当引用计数减少到零时,页面被释放并通过更新空闲页面位图返回给页面分配器。如果4MB内存块的空闲页面计数在有新的空闲页面时为零,则该内存块被放回空闲列表。当4MB块的空闲页面计数达到最大空闲页面计数,即1023时,该4MB内存块可以返回给全局堆。
当Quark运行时开始处理休眠容器时,需要将释放的页面返回给主机Linux内核。由于位图页面分配器中的释放数据页面由控制页面中的位图指示,因此内存页中没有存储诸如二进制伙伴分配器的空闲列表中的“下一个”指针等数据。在进行休眠时,Quark运行时通过调用主机系统调用 madvise() 将空闲页面返回给主机操作系统。通过这种方式,Quark的休眠过程比虚拟机气球化技术简单得多。
此外,每当休眠容器被唤醒并且回收的内存页被重新分配给客户应用程序时,内存页由主机Linux内核通过主机操作系统页面错误进行提交。
该过程由主机Linux处理,对客户操作系统Quark是透明的。因此,Quark不需要显式地重新分配页面。这反过来减少了唤醒延迟和整体系统复杂性。
3.4 休眠容器内存交换
休眠容器将客户应用程序内存换出到二级存储,换出的内存页可能在休眠容器被唤醒时被换入。
有两种类型的换入机制:
- 基于页面错误的换入:就像常见的操作系统换入一样,当客户应用程序访问一个换出的页面时,会触发页面错误,页面错误处理程序加载内存页。
- 批量 REAP 换入:一个休眠容器可以预取所有由 REAP 过程记录的页面。
如图5所示,对于每个容器沙箱,有一个用于基于页面错误的换入的交换文件,以及另一个用于批量
REAP换入的REAP文件。交换文件专用于一个沙箱,不会在沙箱之间共享,以减轻潜在的安全漏洞,这些文件在沙箱终止时被删除。
基于页面错误的换入与基于 REAP 的换出过程不同,具体细节如下。
3.4.1 基于页面错误的换入和换出
本节描述基于页面错误的换入及其对应的换出。
无服务器平台可以通过向热容器发送信号 SIGSTOP 来触发空闲热容器的休眠过程。之后,图 5 中的Quark 容器运行时的交换管理器执行以下步骤以换出内存。
- 暂停客户应用程序:交换管理器暂停所有客户应用程序作为一个通用SIGSTOP 处理程序。之后,客户用户应用程序线程被阻塞,不会访问任何内存页,因此交换管理器不需要处理任何复杂的竞争条件,从而简化了整体过程;
- 遍历并修改客户应用程序页面表:交换管理器遍历所有客户应用程序的页面表以识别匿名页面并执行以下工作:
- 将每个匿名页面的页面表项标记为未存在,以便后续访问该页面时触发页面错误;
- 设置页面表项的标志位#9,这是一个自定义位,以指示页面错误是由于页面换出;
- 将页面的客户物理地址放入哈希表中,以便在多个页面表中存在对同一客户物理页面地址的多个引用时进行页面去重。
- 将内存页写入交换文件:休眠容器为每个Quark沙箱都有一个交换文件。交换管理器枚举哈希表并将内存页写入交换文件,然后将内存页的文件偏移量保存回哈希表。
- 将内存页返回给主机Linux操作系统:交换管理器通过调用系统调用madvise()将换出的页面返回给主机Linux操作系统。
当休眠容器被唤醒时,它会恢复暂停的用户应用程序的执行。当用户应用程序执行访问一个换出的内存页时,它会触发一个页面错误以换入该页。
页面错误处理过程如下。
- 确认页面错误是否来自换出页面:Quark页面处理程序检查页面表项的自定义标志位#9。如果该位被设置,则它是一个换出页面,换入过程将作为步骤#2开始。
- 从交换文件加载内存:页面错误处理程序的虚拟CPU(vCPU)从客户模式退出到主机模式,并从交换文件读取内存页。
- 更新页表项:页表项的标志位#9被清除,并且该项也被标记为存在,以便不再因该页触发页面错误。
基于页面错误的换入成本很高。其成本包括以下几个部分。
- 页面错误处理:在页面错误处理过程中,客户vCPU从客户用户空间转移到客户内核空间,所有通用寄存器都存储在主内存中。
- 在客户模式和主机模式之间切换:客户模式和主机模式的切换成本很高。它不仅需要在主内存中存储通用寄存器,还需要存储浮点上下文。我们在测试环境中观察到这种客户/主机切换的延迟约为15微秒。
- 从SSD随机页面内存读取:休眠容器在SSD磁盘上进行了测试。尽管SSD的随机4KB读取吞吐量远高于HDD,但仍然低于顺序批量读取。根据我们的测试环境,4K页面随机读取吞吐量约为100MB/秒,而顺序批量读取吞吐量超过1GB/秒。
根据我们的观察,基于页面错误的换入仅加载30%到90%的换出页面来处理用户请求。例如,在我们对Node.js Hello World休眠测试的实验中,总共约10MB的内存被换出,而用户请求处理仅换入约4MB的内存。这是因为换出页面包含了用于客户应用初始化和请求处理的内存页面。而当膨胀休眠容器时,应用初始化已经完成,因此相关的内存页面不会被访问和换入。基于这一观察,并受到REAP[14]的启发,休眠容器实现了一种批量REAP预取换入机制。
3.4.2 REAP换出和批量换入记录与预取
(REAP)的主要概念是在处理用户请求时记录来宾物理内存工作集,并在下次唤醒时批量预取它们。这是基于页面错误的换入机制的一种优化。
与基于页面错误的换出相比,REAP在第一次休眠和唤醒后增加了一个记录过程。在容器第一次进入休眠状态后,记录过程执行以下步骤:
- 示例用户请求:无服务器平台发送一个示例用户请求以触发其切换到休眠运行状态;
- 物理内存工作集记录:在休眠运行状态下,请求进程的来宾物理内存工作集通过页面错误换入从交换文件加载,而未触及的页面仍保留在交换文件中;3.REAP 休眠:在样本用户请求过程完成后,容器返回到唤醒状态。无服务器平台发送 SIGSTOP 信号以触发唤醒容器的休眠。REAP 换出过程被触发,其过程如下。
- 暂停所有的用户进程。
- 遍历所有页面表以获取所有活动的匿名内存页。
- 将客户物理内存页地址记录在散列 IO 向量中,并根据 IO 向量使用批量写入 pwritev() 系统调用将内存页保存到图 5 中的 REAP交换文件。
- 通过调用系统调用 madvise() 释放客户物理内存页。
我们可以看到 REAP 换出与基于页面错误的换出是不同的。
- REAP 换出不会更改页面表项,因此不会触发页面错误;
- REAP 换出将写入专用的 REAP 交换文件,这可以通过磁盘批量顺序读取加速换入过程。
REAP 换入过程比基于页面错误的换入更简单。
其步骤如下。
- 根据在 REAP 换出过程中创建的散列 IO 向量,使用批量顺序读取 preadv() 从 REAP 交换文件中预取所有内存页。
- 恢复客户应用程序进程。REAP 交换过程的性能优于基于页面错误的换入,原因如下:
- 无页面错误开销:REAP 换出不会更改页面表项,因此在换入时不会触发页面错误,从而避免了页面错误处理和客户/主机切换的开销。
- 批量文件读取:REAP 换入以批量读取的方式预取所有内存页,这比随机读取具有更高的吞吐量。
3.5 文件支持的内存共享和安全问题
休眠容器还使用 madvise() 清理文件支持的 mmap 内存,以将其返回给主机 Linux。Quark 可能会在不同的容器之间共享文件支持的 mmap 内存,采用写时复制(Copy On Write)技术。当内存被共享时,休眠容器的压缩过程不需要清理这些内存,因为它们可能被其他容器使用。内存共享不仅可以减少容器启动延迟,还可以降低整体系统内存占用。这对于无服务器计算来说是一种相当有吸引力的技术。
不幸的是,在多租户的无服务器计算环境中,当文件支持的 mmap 内存在不同租户之间共享时,会导致安全风险,例如缓存侧信道攻击 [27]。因此,在多租户生产环境中不推荐使用内存共享 [7]。
对于在安全容器中运行的应用程序,有两种主要类型的文件支持内存可能会在容器之间共享:
- 用户应用程序语言运行时二进制文件:用户应用程序是基于不同程序语言运行时二进制文件开发的,例如 Node.js 运行时和 Python 运行时。这种文件的类型被内存映射到用户内存空间并可以被用户应用程序直接访问。在不同租户之间共享它们是有风险的。
- 安全容器运行时二进制文件:这是安全容器运行时的可执行二进制文件和库文件,例如Kata运行时的Linux客人内核二进制文件。这种类型的文件不会被映射到用户内存空间,用户应用程序无法直接访问它们,因此安全风险低于用户应用程序语言运行时二进制文件。RunD [10] 在生产环境中共享Linux客人内核,以加速冷启动并减少内存占用。
休眠容器选择启用Quark运行时二进制文件的共享,但禁用程序语言运行时二进制文件的共享。
运行时二进制文件共享导致请求响应延迟显著减少。例如,我们在测试环境中评估了Node.js运行时二进制文件共享。当我们启用Node.js二进制内存共享时,休眠的Node.js hello-world容器请求响应延迟从25毫秒减少到11毫秒。存在一些缓解措施,如[28] [29],以解决程序语言运行时共享的安全风险。在生产部署中采用了一些缓解措施。例如,Cloudflare Worker [29] 基于 V8 引擎的隔离妥协了多租户隔离的安全风险。当问题解决后,休眠容器可能通过采用这些缓解措施实现更好的性能,启用更多基于文件的内存共享。
3.6 休眠容器实现
休眠容器的代码库是用 Rust 编程语言编写的,并作为 Quark 安全容器运行时环境的一部分运行。Quark运行时是一个虚拟用户空间操作系统,拥有超过 20万行的 Rust 代码。休眠容器的功能改变了 Quark 运行时的关键代码路径,例如虚拟内存管理、IO 管理和虚拟机监控器 (VMM)。我们从头开始实现了交换管理器和位图分配器。交换管理器有780行代码,位图分配器有484行代码。我们通过修改Quark的文件备份内存管理和页面表管理模块开发了内存回收管理器,代码大约有500行。在Quark信号处理模块和IO管理中还有大约300行代码的更改,用于触发休眠过程。
4 评估
在本节中,我们通过实验展示了休眠容器的性能。我们的实验在一台配置为1×12核的Intel(R) Core(TM)i7-8700K CPU @ 3.70GHz、64GB RAM、PM981 NVMe三星512GB SSD的机器上进行,运行Ubuntu 20.04.4 Linux,内核版本为5.15.0-46-generic。
我们使用两组微基准测试来评估容器休眠对请求响应延迟和内存占用的影响。
- Python基准测试:我们从Function Bench[30]中选择了一组微基准测试,以覆盖不同的进程类型、内存使用和进程延迟。
- 浮点运算:此浮点算术运算工作负载具有较小的内存使用和处理延迟;
- 视频处理:视频处理工作负载将OpenCV库的灰度效果应用于视频输入。它的内存占用超过200 MB,处理延迟超过1000毫秒。
- 图像处理:该程序使用Python Pillow库执行图像变换任务。我们选择了2种不同大小的图像文件以评估数据大小对内存使用和相同程序处理延迟的影响。
- 程序语言运行时hello-world:我们测试Python、Node.js、Golang、Java的hello-world程序,以评估不同程序语言运行时的行为。
4.1 用户请求响应延迟
该实验表明,休眠容器的用户请求响应延迟低于冷启动,而唤醒容器的响应延迟与热容器相似。我们还比较了页面错误和基于REAP的换入的延迟。
由于休眠容器已经启动并处于保持活动状态,因此我们测量的是用户应用程序请求/响应的端到端延迟,而不是应用程序启动延迟。除了冷启动测试外,我们在HTTP服务中运行微基准测试,并通过HTTP请求触发应用程序处理,然后测量HTTP响应延迟。
在实验中,我们收集了冷启动、热容器、带有页面错误/REAP换入的休眠容器和唤醒容器的延迟,如下所示:
- 冷启动:在不使用HTTP请求触发的情况下,容器启动和请求处理的过程延迟;
- 热:容器完全初始化后的请求响应延迟;
- 休眠:容器休眠后第一次请求的请求响应延迟。我们还收集了页面错误和REAP换入机制的延迟。
- 唤醒:唤醒容器的请求响应延迟。
图6显示了测试结果。由此,我们可以得出以下结论:
- Hibernate容器的请求处理延迟低于冷启动延迟:例如,Hibernate REAP请求响应延迟占冷启动过程延迟的3%(Python/Golang Hello-world)到67%(图像处理,文件大小为2.6 MB)。Hibernate REAP可以节省从296毫秒(Golang Hello-world)到2407毫秒(视频处理)的冷启动过程延迟。
- 唤醒容器的请求处理延迟与热容器几乎相似。
- 带有页面错误交换的Hibernate容器请求延迟高于REAP:在大多数基准测试中,REAP的表现优于基于页面错误交换的交换机制。唯一的例外是图像处理,文件大小为2.6 MB,但差异可以忽略不计。
测试结果证明,休眠容器的响应延迟低于冷启动,而唤醒容器的处理延迟与热容器相似。此外,休眠容器可以使程序语言运行时受益,例如 Python、Node.js、Golang 和 Java。
4.2 容器内存消耗
该实验表明,休眠容器及其派生的唤醒容器消耗的内存少于热容器。我们通过 Linux 工具“pmap”收集基准内存消耗的比例集大小(PSS),以便于以下状态:
- 热:容器处理少量用户请求。
- 休眠:容器从热状态过渡到休眠状态。
- 唤醒:休眠容器通过用户请求被唤醒。
如第 3.4 节所述,休眠容器共享 Quark 运行时二进制文件。因此,当运行更多实例时,内存 PSS 更少。在我们的环境中,我们收集了10个正在运行的基准应用实例的PSS数据。
我们可以从图7中得出以下结论:
- “休眠”状态的内存消耗远低于“热”状态:例如,“休眠”状态的内存消耗约为“热”状态的7%(视频处理)到25%(Golang Hello-world)。总内存节省范围从12 MB(总计16 MB,Golang Hello-world)到252 MB(总计281 MB,图像处理,文件大小为2.6 MB)。
- ”唤醒”状态的内存消耗低于”热”状态:例如,”唤醒”状态占用”热”状态内存的28%(Node.js Hello-world)到90%(图像处理,文件大小为2.6MB)。总内存节省范围从7MB(总共16 MB的Golang Hello-world)到151 MB(总共226 MB的视频处理)。
测试结果证明,休眠容器及其派生的唤醒容器比热容器消耗更少的内存。这种内存节省适用于不同的编程语言运行时,包括Python、Node.js、Golang和Java。
从响应延迟和内存测试来看,我们可以得出结论:
- 休眠容器和唤醒容器的共同部署可以实现比热容器更高的部署密度。
- 唤醒容器的内存消耗更少,但请求延迟与热容器相似。在可能的情况下,将热容器转换为通过休眠容器作为保持活动容器的唤醒容器是有益的。
5 相关工作
已经有几项相关工作正在进行,特别是与冷启动优化相关的工作。冷启动通常由两个不同的活动组成:安全容器运行时启动和用户应用程序启动。
以下小节描述了这两个与冷启动相关的活动的各种正在进行的优化工作。
5.1 基于虚拟机的安全容器运行时优化
基于虚拟机的安全容器运行时启动包括Linux容器环境(例如Cgroup、容器网络、容器文件系统)设置和虚拟机操作系统内核启动任务。RunD [10] 努力通过预创建Cgroup、优化容器rootfs映射来加速容器环境的设置。RunD使用Kata模板来减少每个微虚拟机的内存开销以及启动延迟的减少。
Firecracker[19] 引入了一种轻量级虚拟机监控器来替代常见的虚拟机监控器,如QEMU或CloudHypervisor。Firecracker针对无服务器计算进行了优化,因为它仅支持少量设备:支持virtio网络设备和单个块设备类型。这反过来导致了与安全容器内存使用以及沙箱启动延迟相关的优化收益。
Quark[18]和gVisor[8]则引入了一个新的用户空间操作系统内核和一个专门针对无服务器工作负载的新虚拟机监控器,从而减少了内存占用和启动延迟。
5.2 应用启动优化
上述冷启动优化的关键思想是从一种状态启动容器,该状态与热容器相对“更接近”。[13][31] 使用安全容器运行时 gVisor 或 JVM 提供的检查点恢复技术。[32] 重用一个待回收的热容器来托管另一个容器镜像。
基于检查点恢复(即 C/R),催化剂 [13] 实现了无初始化启动。在 C/R 之上还有更多优化,例如 REAP 使用批量预取来加速 VMM 镜像加载。Sock [12]扩展了 Zygote,通过分叉一个带有预导入软件包的辅助容器来启动一个新容器,以节省软件包导入成本。催化剂引入了一个沙箱分叉(sfork),它从一个现有的热容器中分叉,带有完整的应用状态,并在父/子容器之间共享内存。
结论
低延迟容器启动时间对于无服务器计算环境的整体用户体验至关重要。除了冷启动和热启动作为当代无服务器计算环境的一部分,本文提出了第三种启动模式:休眠容器。休眠容器本质上是一个瘦身的热容器。
我们的实验表明,休眠容器的内存消耗远低于热容器,并且其请求响应延迟低于冷启动。在休眠容器被唤醒时,唤醒容器的内存消耗也低于热容器,并且在同一时间具有相同的请求响应延迟。所有这些都导致了更高的部署密度、更低的请求响应延迟,以及整体系统性能的显著提升。