利用Docker+CRIU容器快照提高应用程序的启动时间的方法

979 阅读9分钟

最近我遇到了CRIU技术。它可以让你检查任何正在运行的应用程序,并将其状态序列化在磁盘上,以便以后从该状态恢复。更有趣的是,它与Docker集成,可能允许你运行一个容器,对其进行可序列化的快照,并在以后重新创建它--甚至可能在另一个主机上。

这项技术可能有利于实时迁移(事实上,谷歌用它来实时迁移Borg中的批量作业)--但令我兴奋的是,这可能有助于解决启动时间长的问题。随着Rails应用的增长,它最终会有更多的Ruby代码需要在启动时进行解析和加载,这使得启动时间相当长。自动加载和bootnap在本地和CI环境中有所帮助,但在生产环境中(你想急于加载一切)仍然相当缓慢。一些最大的单体在启动时需要1分钟以上的时间才能为请求提供服务,这种情况并不少见。

请注意,我以Rails为例,但从技术上讲,这适用于任何用脚本语言编写的应用程序,其数据库和依赖关系的数量不断增加。

如果我们能事先准备好一个实时应用服务器的快照,并使用它来启动生产中的容器,也许我们可以节省一些启动时间?这就是我想探讨的问题。

这篇文章的简要内容是:1)用Docker+CRIU建立一个实验室,以快照和恢复容器 2)用一个脚本自动化,并利用谷歌云的计算实例 3)测量节省的时间。

建立一个实验室

所有CRIU的魔法都是基于Linux内核的功能,所以Docker for Mac不是一个选项。我将不得不设置一个具有所有依赖性的Linux虚拟机。

一个选择是在AWS或GCP上旋转一个实例,但我的Mac上已经有了VMWare,而且我想节省一些终端延迟(我在法国的ISP不是很好!)。我选择了VMWare中的Linux Alpine虚拟机,因为我听说Alpine是一个很好的轻量级分布式系统。用apk ,在上面安装CRIU和Docker并不难。然而,当我试图用criu check 来验证设置时,我发现由于某些原因,Alpine自带的Linux内核并不具备CRIU所需的所有功能。

我并不期待建立我自己的内核,所以我继续使用Ubuntu Server 18.04 LTS,希望能有一个全功能的内核。

我遵循Docker的CRIU文档。我注意到他们禁用了seccomp(并说明需要较新的内核),而且容器网络被禁用了,因为CRIU不会检查任何打开的TCP连接。我决定无论如何都要试试,看看以后是否会成为一个问题。

对于一个初级的Rails应用来说,它的效果非常好(我能够进行实时快照和恢复!),但是当我让这个应用与数据库对话时,我注意到docker checkpoint ,并出现了CRIU级别的错误。

$ cat /run/containerd/io.containerd.runtime.v1.linux/moby/be56af6556e28725f3d69b4d91c8905268521af9d32e8aa4525fe16a07138a5e/criu-dump.log

...
(00.152987) sockets: Searching for socket 0x27a13 family 2
(00.152991) Error (criu/sk-inet.c:199): inet: Connected TCP socket, consider using --tcp-established option.

我开始寻找一种方法来启用tcp-establish 。CRIU配置指南建议 echo 'tcp-established' > /etc/criu/runc.conf ,用于容器化部署。然而,这样做并没有效果。这时我发现,对配置的支持只出现在runc 1.0-rc7和CRIU 3.11中--而Ubuntu软件包则是老的runc 1.0-rc4和CRIU 3.6。

我花了一些时间从源头上构建最新的Runc和CRIU,但最后,我终于能够快照一个打开了TCP套接字并启用了seccomp的进程。这是个成功。我甚至可以跳到快照中,看到它的内容。

./checkpoint-omg/
./checkpoint-omg/mountpoints-12.img
./checkpoint-omg/inventory.img
./checkpoint-omg/tmpfs-dev-73.tar.gz.img
./checkpoint-omg/tmpfs-dev-72.tar.gz.img
./checkpoint-omg/core-9.img
./checkpoint-omg/tmpfs-dev-71.tar.gz.img
./checkpoint-omg/core-1.img
./checkpoint-omg/core-10.img
./checkpoint-omg/cgroup.img
./checkpoint-omg/core-15.img
./checkpoint-omg/fdinfo-2.img
./checkpoint-omg/core-11.img
./checkpoint-omg/core-14.img
./checkpoint-omg/ids-1.img
./checkpoint-omg/core-20.img
./checkpoint-omg/pipes-data.img
./checkpoint-omg/core-17.img
./checkpoint-omg/fs-1.img
./checkpoint-omg/mm-1.img
./checkpoint-omg/tmpfs-dev-68.tar.gz.img
./checkpoint-omg/utsns-11.img
./checkpoint-omg/pagemap-1.img
./checkpoint-omg/core-12.img
./checkpoint-omg/seccomp.img
./checkpoint-omg/core-13.img
./checkpoint-omg/tmpfs-dev-74.tar.gz.img
./checkpoint-omg/pstree.img
./checkpoint-omg/core-8.img
./checkpoint-omg/core-19.img
./checkpoint-omg/core-16.img
./checkpoint-omg/ipcns-var-10.img
./checkpoint-omg/tcp-stream-cd45.img
./checkpoint-omg/files.img
./checkpoint-omg/pages-1.img
./checkpoint-omg/core-18.img
./checkpoint-omg/descriptors.json
./checkpoint-omg/core-7.img
./checkpoint-omg/tcp-stream-6b38.img

每一个转储都可以用crit show

现在是时候准备一些启动缓慢的Rails应用样本了。

根据Shopify的经验,要加载和解析的代码量很大。这些代码包括你的应用中的类,一堆YAML配置(对于一个大型应用,自然会有很多配置),以及你所有的gem依赖。

为了模拟所有这些,我在Gemfile中塞进了250个宝石,并为Ruby和YAML写了一个小型代码生成器。

现在是时候对胖子应用进行检查并尝试恢复它了。这很容易!

$ docker run --name fat-app-donor -p 3000:3000 -d fat-app:latest
# curl localhost:3000 to verify that the app is booted and running

$ docker checkpoint create fat-app-donor my-checkpoint
# the checkpoint is located in /var/lib/docker/containers/<donor-container-id>/checkpoints/my-checkpoint

$ docker create --name fat-app-clone -p 3000:3000 fat-app:latest

$ cp -r /var/lib/docker/containers/<donor-container-id>/checkpoints/my-checkpoint /var/lib/docker/containers/<clone-container-id>/checkpoints

$ docker start --checkpoint my-checkpoint fat-app-clone

耶!你现在可以curl localhost:3000 ,并击中已经从序列化状态恢复的容器!

在更接近生产的环境中自动化和运行

在上面的步骤中,我能够在一个本地Ubuntu虚拟机上获取一个实时容器的快照,并在另一个虚拟机上重新创建它,但我也想在一个类似生产的环境中运行这个实验。我计划创建一个GCE实例,将容器快照上传到GCS(来自谷歌的类似S3的存储),从GCS下载,并从中恢复。

为什么要上传和下载容器到/从远程存储?我想让它尽可能地接近生产,并衡量下载该blob的惩罚。

我能够在之前手动运行的命令的基础上自动完成所有这些步骤。与其说是描述这些步骤,我想如果你阅读脚本本身,就会不言自明。

set -e -x

IMAGE=fat-app:latest
CHECKPOINT_NAME=checkpoint-omg

echo "+++ SNAPSHOT PART"

docker run --name fat-app-donor -p 3000:3000 -d $IMAGE

echo "+++ Waiting for container to boot"
time (while ! curl localhost:3000 > /dev/null 2>&1; do : sleep 0.5 ; done )

echo "+++ Boot stats"
curl http://localhost:3000/stats

echo "+++ Creating a checkpoint"
sudo time docker checkpoint create fat-app-donor $CHECKPOINT_NAME

DONOR_CONTAINER_ID=$(docker inspect --format="{{.Id}}" fat-app-donor)

echo "+++ Packing the checkpoint"
sudo time tar cvzf checkpoint.tar.gz -C /var/lib/docker/containers/$DONOR_CONTAINER_ID/checkpoints .

echo "+++ Checkpoint size:"
ls -l --block-size=M

echo "+++ Uploading the checkpoint:"
time gsutil cp checkpoint.tar.gz gs://kirs-criu/checkpoints-experiment/$CHECKPOINT_NAME.tar.gz

echo "--- RESTORING"

echo "+++ Downloading the checkpoint:"
time gsutil cp gs://kirs-criu/checkpoints-experiment/$CHECKPOINT_NAME.tar.gz .

echo "+++ Preparing the new container"
time docker create --name fat-app-clone -p 3000:3000 $IMAGE

CLONE_CONTAINER_ID=$(docker inspect --format="{{.Id}}" fat-app-clone)

echo "+++ Unpacking the checkpoint to clone docker dir:"
sudo tar -C /var/lib/docker/containers/$CLONE_CONTAINER_ID/checkpoints -xvf $CHECKPOINT_NAME.tar.gz
rm $CHECKPOINT_NAME.tar.gz

echo "+++ Launching the clone from the snapshot:"
time docker start --checkpoint $CHECKPOINT_NAME fat-app-clone

curl http://localhost:3000
curl http://localhost:3000/stats

我为两个应用程序运行了这个脚本:fat-app(我建立的一个)和Redmine。我选择了Redmine,因为它是一个典型的Rails应用的好例子,有一堆宝石和类。它也不像Discourse那样优化,这对我们来说是好事。

你可能最好奇的是启动时间与从快照恢复的时间的结果。

结果

我得到了在两种类型的虚拟机上运行的两个应用程序的结果:n1-standard-1(单vCPU GCE实例)和n1-standard-16(16 vCPUs)。

我测量热启动和冷启动的方法是,从启动容器到能够提供一个HTTP请求的时间差。

检查点大小检查点下载RSS冷启动热启动性能提升
Fat-app; 1 vCPU44.3 Mb2.16s191 Mb18.58s7.96s+2.33x
Fat-app; 16 vCPU44.3 Mb1.94s191 Mb20.60s6.34s+3.25x
Redmine; 1 vCPU24.3 Mb2.02s121Mb18.39s6.33+2.91x
Redmine; 16 vCPU24.3 Mb1.95s121Mb13.48s3.71s+3.63x

**从快照中启动应用程序会有相当大的提升:**在单核机器上至少是2.3倍,在有更多计算能力的虚拟机上最多是3.6倍。CPU之间的差异可能是由于解压/反序列化转储的CPU绑定工作。我也在有SSD磁盘的虚拟机上试过,但我没有看到像添加更多CPU后那样的改善。

下载快照的时间更快,可以解释为GCP为较大的虚拟机分配了更高的网络带宽。

这花了我大约一周的时间,我对结果印象深刻。

在生产中使用时需要注意的事项

CRIU甚至是预测级的软件吗?

根据我的研究,它是。它已经在谷歌的Borg使用了多年,用于实时迁移工作负载(但不是用Docker),而且Docker支持自2015年起就已经存在。虽然它可能仍然有边缘情况,需要报告。

动态主机名

当CRIU对容器命名空间进行快照时,它也会持续保存容器的主机名。在恢复时,它将捐赠者容器的主机名设置为克隆的容器。这并不意外,但如果你在一台主机上运行同一个容器的多个实例,可能会产生问题--这是可横向扩展的Web应用程序的典型设置。而在像K8s这样的协调环境中,作为复制集的一部分的主机名是随机生成的(例如:web-7cfd6d677d )。

对于我们的用例,这意味着我们需要改变克隆的主机名,以避免到处都有相同的主机名。幸运的是,有一个方法,CRIU的维护者在github.com/checkpoint-…,与我分享了这个方法。

从应用程序的角度来看,我们必须准备好总是动态地检查主机名。这意味着你不能再把它记忆化。

# typical code to avoid extra syscall on repeating hostname access
def hostname
  @hostname || Socket.gethostname
end

这可能更难执行,但至少有一个疯狂的解决方案是定期检查ObjectSpace ,看是否有包含记忆化主机名的字符串。最后,并没有太多的业务逻辑依赖于主机名。你需要调整的主要是基础设施代码。

TCP连接

虽然CRIU和Linux内核都支持恢复TCP连接,但是一旦应用程序的快照在新的主机上被恢复,它就必须准备好重新连接各种资源。幸运的是,这对于任何成熟的、大规模的、已经为弹性设计的应用程序来说都不是问题。重试和重新连接是其中的一个重要部分。

准备快照

在一个新的版本发布之前,你必须准备容器的快照,以便在生产中使用。这完全属于在CI期间构建发布工件的模式。最后,CI很可能已经参与了构建镜像并将其推送到容器注册中心。

协调的环境

这可能是在生产中采用容器CRIU的路上最重要的障碍。很难想象在K8s等容器编排框架成为标准的情况下,还有人手动管理容器。

如果Kubernetes管理你的容器并在实际主机上调用docker start ,它就必须了解与从快照恢复和从头启动新实例有关的所有问题。

我想,让Kubernetes pods意识到恢复的问题并不难。与谷歌的Borg类似,我们可以让它在快照可用的情况下更倾向于从快照中恢复,如果从快照中启动因某种原因而不成功,则回落到从头启动。

下面是一个YAML规范的例子。

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: web
    image: gcr.io/companyname/project:sha
    snapshotPolicy: IfPresent # start container from the snapshot if it's available
    snapshotPath: gs://companyname-snapshots/project/sha/boot-snapshot.tar.gz
    ...

要推动CRIU的采用,Kubernetes需要做一些工作,但是,我认为至少有几个原因可以让它很容易地卖给社区。Kubernetes是Borg的一个开源继承者,而Borg支持CRIU。而Docker对CRIU的支持已经存在,所以这主要是将Docker功能与K8s整合的问题。

资源