最近我遇到了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 vCPU | 44.3 Mb | 2.16s | 191 Mb | 18.58s | 7.96s | +2.33x |
| Fat-app; 16 vCPU | 44.3 Mb | 1.94s | 191 Mb | 20.60s | 6.34s | +3.25x |
| Redmine; 1 vCPU | 24.3 Mb | 2.02s | 121Mb | 18.39s | 6.33 | +2.91x |
| Redmine; 16 vCPU | 24.3 Mb | 1.95s | 121Mb | 13.48s | 3.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整合的问题。
资源
- 我的设置和基准测试脚本
- 来自RubyKaigi的关于CRIU和Ruby的讲座
- Borg会议上关于CRIU的演讲:使用CRIU进行大规模的任务迁移