【CSDN 编者按】大家在编写代码时是不是经常会遇到repls卡顿而造成的容器关闭缓慢等问题呢?本文针对这一问题为大家提供了解决方案,希望给大家提供更流畅的体验。

作者 | Connor Brewster 

译者 | arvin      责编 | 屠敏

出品 | CSDN(ID:CSDNnews)

以下为译文:

为了使基于Web浏览器的任何用户都可以在我们的Replit平台(在线编程语言环境)上进行编码,我们的后端基础架构运行在Preemptible VM(经济虚拟机器服务)上。这意味着你运行代码的同时计算机即使关机也毫无问题!

当发生这种情况时,我们努力使repls重新连接的速度加快。尽管我们尽了最大的努力,但是很长一段时间以来,人们一直感觉repls停滞不前。在对Docker源代码进行一些分析和挖掘之后,我们发现并解决了连接速度的问题。我们的会话连接错误率从3%降至0.5%以下,第99个百分位会话启动时间从2分钟缩短至15秒。造成repls卡顿的原因有很多不同,这些原因有:不健康的机器,会引起死锁的资源竞争状态以及容器关闭缓慢等。

本篇文章将重点分享我们如何解决容器缓慢关闭的问题。缓慢的容器关闭影响了几乎所有使用该平台的人,并可能导致长达一分钟的无法访问。也希望本文对大家有所裨益。

Replit架构

在深入解决容器关闭缓慢的问题之前,你需要了解Replit的体系结构。

当你打开一个repl时,浏览器将建立一个与Docker容器(运行在Preemptible VM上的)的websocket连接。每个虚拟机都运行着我们称之为conman的东西,这是容器管理器(container manager)的缩写。

我们必须确保任何时候每个repl只使用一个容器。该容器用于促进多人游戏功能,因此重要的是REPL中的每个用户都连接到同一容器。

当托管这些Docker容器的计算机关闭时,我们必须等待每个容器被销毁,然后才能在其他计算机上再次启动它们。由于我们使用抢占式实例,因此该过程经常发生。

在下方,你可以查看尝试在正在关闭的vm实例上访问repl时的典型流程。

1. 用户打开其repl,后者将打开IDE,并尝试通过WebSocket连接到后端评估服务器;

2. 该请求到达一个负载均衡器,该负载均衡器根据CPU使用情况选择要代理的conman实例;

3. 一个健康,存活的conman得到了请求。Conman注意到,该请求使用的容器位于其他conman,并在此代理该请求;

4. 可悲的是,另一个conman正在关闭并拒绝了该WebSocket连接。

请求将继续失败,直到发生以下任何一种情况:

1. Docker容器已关闭,全局存储中的repl容器条目已删除;

2. Conman完成关闭,无法再访问。在这种情况下,第一个conman将删除旧的repl容器条目并启动新的容器。

缓慢的容器关闭

我们的可抢占式VM在被强行终止之前,有30秒的时间进行彻底关闭。经过调查,我们发现在这30秒之内很少完成关机。这促使我们进一步研究并检测机器关闭程序。

在添加了有关机器关闭的更多日志记录和指标之后,我们可以很明显地发现,调用docker kill花费的时间比预期长得多。docker kill通常在正常操作期间需要花费几毫秒的时间来杀死一个repl容器,但是在实际关闭过程中我们花了20秒钟以上的时间来同时杀死100-200个容器。

Docker提供了两种停止容器的方法:docker stop和docker kill。Docker stop向SIGTERM容器发送信号,并为其提供宽限期以正常关闭。如果容器在宽限期内没有关闭,则向容器发送SIGKILL。我们不关心正常关闭容器,而是希望尽快关闭它,因此我们使用kill命令。docker kill发送SIGKILL,理论上应该立即杀死该容器。出于某种原因,该理论与现实不符,docker kill不应花费秒量级的时间去SIGKILL容器。肯定还有其他事情发生。

为了对此进行深入研究,我们编写了如下所示的脚本,该脚本创建200个Docker容器并确定同时杀死它们所需的时间。

#!/bin/bash
COUNT=200
echo "Starting $COUNT containers..."
for i in $(seq 1 $COUNT); do
    printf .
    docker run -d --name test-$i nginx > /dev/null 2>&1
done


echo -e "\nKilling $COUNT containers..."
time $(docker kill $(docker container ls -a --filter "name=test" --format "{{.ID}}") > /dev/null 2>&1)
echo -e "\nCleaning up..."
docker rm $(docker container ls -a --filter "name=test" --format "{{.ID}}") > /dev/null 2>&1

使用和生产环境一致的VM(GCE n1-highmem-4实例)运行脚本,可以得到:

Starting 200
 containers...................................<trimmed>
Killing 200 containers...
real    0m37.732s
user    0m0.135s
sys     0m0.081s
Cleaning up...

这证实了我们的怀疑,即Docker运行时内部正在发生某些事情,这导致关闭速度如此之慢。是时候深入研究Docker本身了...

Docker守护程序具有启用调试日志记录的选项。这些日志使我们可以深入了解dockerd内部发生的情况,并且每个条目都有一个时间戳,因此它可以使你了解时间都花在了哪里。

启用调试日志记录源码:https://docs.docker.com/config/daemon/

启用调试日志记录后,让我们重新运行脚本并查看dockerd的日志。由于我们正在处理200个容器,因此这将输出很多日志消息,因此,我手动选择了感兴趣的部分日志。

2020-12-04T04:30:53.084Z    dockerd    Calling GET /v1.40/containers/json?all=1&filters=%7B%22name%22%3A%7B%22test%22%3Atrue%7D%7D
2020-12-04T04:30:53.084Z    dockerd    Calling HEAD /_ping
2020-12-04T04:30:53.468Z    dockerd    Calling POST /v1.40/containers/33f7bdc9a123/kill?signal=KILL
2020-12-04T04:30:53.468Z    dockerd    Sending kill signal 9 to container 33f7bdc9a1239a3e1625ddb607a7d39ae00ea9f0fba84fc2cbca239d73c7b85c
2020-12-04T04:30:53.468Z    dockerd    Calling POST /v1.40/containers/2bfc4bf27ce9/kill?signal=KILL
2020-12-04T04:30:53.468Z    dockerd    Sending kill signal 9 to container 2bfc4bf27ce93b1cd690d010df329c505d51e0ae3e8d55c888b199ce0585056b
2020-12-04T04:30:53.468Z    dockerd    Calling POST /v1.40/containers/bef1570e5655/kill?signal=KILL
2020-12-04T04:30:53.468Z    dockerd    Sending kill signal 9 to container bef1570e5655f902cb262ab4cac4a873a27915639e96fe44a4381df9c11575d0
...

在这里我们可以看到杀死每个容器的请求,这些SIGKILL请求几乎立即发送到每个容器。

这是执行docker kill后约30秒后看到的一些日志条目:

...
2020-12-04T04:31:32.308Z    dockerd    Releasing addresses for endpoint test-1's interface on network bridge
2020-12-04T04:31:32.308Z    dockerd    ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.2)
2020-12-04T04:31:32.308Z    dockerd    Released address PoolID:LocalDefault/172.17.0.0/16, Address:172.17.0.2 Sequence:App: ipam/default/data, ID: LocalDefault/172.17.0.0/16, DBIndex: 0x0, Bits: 65536, Unselected: 65529, Sequence: (0xfa000000, 1)->(0x0, 2046)->(0x1, 1)->end Curr:202
2020-12-04T04:31:32.308Z    dockerd    Releasing addresses for endpoint test-5's interface on network bridge
2020-12-04T04:31:32.308Z    dockerd    ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.6)
2020-12-04T04:31:32.308Z    dockerd    Released address PoolID:LocalDefault/172.17.0.0/16, Address:172.17.0.6 Sequence:App: ipam/default/data, ID: LocalDefault/172.17.0.0/16, DBIndex: 0x0, Bits: 65536, Unselected: 65530, Sequence: (0xda000000, 1)->(0x0, 2046)->(0x1, 1)->end Curr:202
2020-12-04T04:31:32.308Z    dockerd    Releasing addresses for endpoint test-3's interface on network bridge
2020-12-04T04:31:32.308Z    dockerd    ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.4)
2020-12-04T04:31:32.308Z    dockerd    Released address PoolID:LocalDefault/172.17.0.0/16, Address:172.17.0.4 Sequence:App: ipam/default/data, ID: LocalDefault/172.17.0.0/16, DBIndex: 0x0, Bits: 65536, Unselected: 65531, Sequence: (0xd8000000, 1)->(0x0, 2046)->(0x1, 1)->end Curr:202
2020-12-04T04:31:32.308Z    dockerd    Releasing addresses for endpoint test-2's interface on network bridge
2020-12-04T04:31:32.308Z    dockerd    ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.3)
2020-12-04T04:31:32.308Z    dockerd    Released address PoolID:LocalDefault/172.17.0.0/16, Address:172.17.0.3 Sequence:App: ipam/default/data, ID: LocalDefault/172.17.0.0/16, DBIndex: 0x0, Bits: 65536, Unselected: 65532, Sequence: (0xd0000000, 1)->(0x0, 2046)->(0x1, 1)->end Curr:202

这些日志无法提供dockerd正在执行的所有操作的完整信息,但是这似乎显示dockerd在花费大量时间来释放网络地址。

在冒险旅程的这一刻,我决定是时候研究docker引擎的源代码,并添加一些额外的日志记录构建自己的dockerd版本了。

我首先查找处理容器终止请求的代码路径。我添加了一些具有不同时间跨度的额外日志消息,最终我发现所有这些时间都花在了哪里:

SIGKILL被发送到容器,然后在响应HTTP请求之前,引擎将等待容器不再运行。

源码:https://github.com/docker/engine/blob/ab373df1 125b6002603456fd7f554ef370389ad9/daemon/kill.go

 <-container.Wait(context.Background(), containerpkg.WaitConditionNotRunning)

该container.Wait函数返回一个通道,该通道接收退出代码和来自容器的任何错误。不幸的是,要获得退出代码和错误,必须获取内部容器结构的锁。

源码:https://github.com/docker/engine/blob/ab373df1125b6002603456fd7f554ef370389ad9/container/state.go

...
    go func() {
        select {
        case <-ctx.Done():
            // Context timeout or cancellation.
            resultC <- StateStatus{
                exitCode: -1,
                err:      ctx.Err(),
            }
            return
        case <-waitStop:
        case <-waitRemove:
        }
        s.Lock() // <-- Time is spent waiting here
        result := StateStatus{
            exitCode: s.ExitCode(),
            err:      s.Err(),
        }
        s.Unlock()
        resultC <- result
    }()
    return resultC
  ...

事实证明,在清理网络资源时会保持此容器锁,并且s.Lock()上面的内容将等待很长时间。这发生在handleContainerExit函数里面。容器锁在该函数期间保持不变。该函数调用容器的Cleanup方法,该方法释放网络资源。

handleContainerExit源码:https://github.com/docker/engine/blob/ab373df1125b6002603456fd7f554ef370389ad9/daemon/monitor.go

Cleanup源码:https://github.com/docker/engine/blob/ab373df1 125b6002603456fd7f554ef370389ad9/daemon/start.go

那么,为什么要花这么长时间清理网络资源呢?网络资源通过netlink处理。Netlink用于在用户进程和内核空间进程之间进行通信,在这种情况下,Netlink用于与内核空间进程进行通信以配置网络接口。不幸的是,netlink通过串行接口工作,并且释放每个容器地址的所有操作都受到netlink的限制。

netlink源码:https//man7 .org/linux/man-pages/man7/netlink.7.html

这里的事情开始变得有些绝望了。似乎没有什么我们可以做的事情来逃避等待清理网络资源的事情。但是,也许我们可以在杀死容器时完全绕开Docker。

就我们而言,我们想杀死容器,但是我们不需要等待网络资源被清理。重要的是容器不再产生任何副作用。例如,我们希望容器不再拍摄文件系统快照。

我采用的解决方案是通过直接杀死容器的pid来绕过docker。容器启动后,Conman会记录该容器的pid,然后在需要杀死该容器时将SIGKILL发送到该容器。由于容器形成了pid名称空间,因此当容器的pid终止时,容器/ pid命名空间中的所有其他进程也会终止。

pid_namespaces 手册页:

如果PID名称空间的“初始化”进程终止,则内核会通过SIGKILL信号终止名称空间中的所有进程。

鉴于此,我们可以有把握地确信,在发送SIGKILL到容器后,该容器不再产生任何副作用。

应用此更改后,关机期间只需几秒钟vm即可放弃对repls容器的控制。与之前的30秒钟相比,这是一个巨大的进步,使我们的会话连接错误率从〜3%下降到0.5%以下。此外,会话启动时间的99百分位数从〜2分钟减少至〜15秒。

概括地说,我们发现缓慢的VM关闭会导致repl卡顿和不良的用户体验。经过调查,我们确定Docker需要30秒钟以上的时间来杀死VM上的所有容器。作为解决方案,我们规避了Docker的方案并自行杀死容器。这带来更少的卡顿repl和更快的会话启动时间。我们希望这能为Replit提供更流畅的体验!

手册页源码:https://man7.org/linux/man-pages/man7/pid_ namespaces.7 .html

原文地址:

https://blog.repl.it/killing-containers-at-scale

声明:本文为 CSDN 翻译,转载请注明来源。





☞巨头王炸不断,硬核解读芯片技术路线☞Babel 陷财务困境,负责人13万年薪遭质疑,Vue.js作者尤雨溪发文力挺☞9 岁自学编程、24 岁身价涨至数百万美元,与微软一较高低的大佬多厉害?
Logo

20年前,《新程序员》创刊时,我们的心愿是全面关注程序员成长,中国将拥有新一代世界级的程序员。20年后的今天,我们有了新的使命:助力中国IT技术人成长,成就一亿技术人!

更多推荐