关注「开源Linux」,选择“设为星标”

回复「学习」,有我为您特别筛选的学习资料~

作者 | Connor Brewster

译者 | Sambodhi

策划 | Tina

要让所有人都能在 Replit 上使用 Web 浏览器编写代码,我们的后端基础设施就是在可抢占的虚拟机上运行。也就是说,运行你代码的计算机可以随时关闭!当这种情况发生时,我们就用 REPL(Read-Eval-Print Loop,读取 - 求值 - 输出循环)快速重新连接。虽然我们已经尽了最大的努力,但人们还是会发现 REPL 连接被卡了很久。通过分析和挖掘 Docker 源代码,我们发现并解决了这一问题。我们的会话连接错误率从 3% 降到了 0.5% 以下,99 百分位会话启动时间从 2 分钟降到了 15 秒。

造成 REPL 卡死有多种原因,其中有机器故障、竞争条件导致死锁、容器关机慢等原因。本文主要介绍我们如何修复最后一个原因,即容器关机速度慢。缓慢的容器关机几乎影响到每个使用该平台的人,并导致 REPL 无法访问长达一分钟。

Replit 架构

你需要对 Replit 的架构有一些了解,然后才能深入研究如何解决容器关机缓慢的问题。

打开 REPL 后,浏览器将打开 websocket,将其连接到在可抢占虚拟机上运行的 Docker 容器。每一台虚拟机都运行着我们称为 conman 的东西,这是容器管理器(container manager)的简称。

要确保每一个 REPL 在任何时候都只有一个单一的容器。容器被设计用于促进多人游戏的功能,因此 REPL 的重要性在于, REPL 中的每个用户都连接到同一个容器。

当托管这些 Docker 容器的机器关机时,我们必须等待每个容器都被销毁,然后才能在其他机器上再次启动它们。这一过程经常发生,因为我们使用的是可抢占实例。

以下是尝试在 mid-shutdown 实例上访问 REPL 的典型流程。

用户打开他们的 REPL,该 REPL 打开 IDE,然后尝试通过 WebSocket 连接到后端评估服务器。

该请求命中负载均衡器,负载均衡器根据 CPU 使用情况选择一个 conman 实例作为代理。

一个健康的、运行的 conman 收到了这个请求。conman 注意到,该请求是针对一个存在于不同 conman 上的容器的,并在那里代理该请求。

遗憾的是,这个 conman 关闭了 WebSocket 连接并且拒绝了!

该请求将一直失败,直到:

docker 容器被关闭,全局存储中的 REPL 容器项被删除。

conman 完成关闭,不再能访问。在这种情况下,第一个 conman 将删除旧的 REPL 容器项,并启动一个新的容器。

容器关机缓慢

在强制终止可抢占虚拟机之前,将有 30 秒的时间完全关闭虚拟机。通过研究,我们发现,很少能在 30 秒内完成关机。因此,我们必须进一步研究并检测机器关机例程。

通过添加有关机器关机的日志和指标,显然 docker kill 被调用的时间比预期要长得多。正常运行时,docker kill 杀死 REPL 容器通常只需几毫秒,但是,在关机期间,我们同时杀死 100~200 个容器却要花费 20 多秒的时间。

Docker 提供了两种停止容器的方法:docker stop 和 docker kill。Docker stop 会向容器发送一个 SIGTERM 信号,并给容器一个宽限期,让它优雅地关机。如果容器没有在宽限期内关机,就会向容器发送 SIGKILL。我们并不在乎宽限期关闭容器,而是希望 docker kill 发送 SIGKILL,这样它就会立即杀死容器。出于某些原因,docker kill 并不能在几秒钟内完成容器的 SIGKILL,这一理论与现实不符,肯定还有别的原因。

要深入探讨这个问题,这里有一个脚本,可以创建 200 个 docker 容器,同时计算出需要多长时间才能杀死它们。

  1. #!/bin/bash
  2. COUNT=200
  3. echo "Starting $COUNT containers..."
  4. for i in $(seq 1 $COUNT); do
  5. printf .
  6. docker run -d --name test-$i nginx > /dev/null 2>&1
  7. done
  8. echo -e "\nKilling $COUNT containers..."
  9. time $(docker kill $(docker container ls -a --filter "name=test" --format "{{.ID}}") > /dev/null 2>&1)
  10. echo -e "\nCleaning up..."
  11. docker rm $(docker container ls ---filter "name=test" --format "{{.ID}}") > /dev/null 2>&1

对于生产中运行的同一类型的虚拟机,即 GCEn1-highmem-4 实例,将会生成如下结果:

  1. Starting 200 containers...
  2. ................................<trimmed>
  3. Killing 200 containers...
  4. real 0m37.732s
  5. user 0m0.135s
  6. sys 0m0.081s
  7. Cleaning up...

我们认为, Docker 运行时发生了一些内部事件,导致关机速度非常缓慢,这就证实了我们的怀疑。现在要挖掘 Docker 本身。

Docker 守护进程有一个启用调试日志记录的选项。通过这些日志,我们可以了解 dockerd 内部发生了什么,并且每个条目都有一个时间戳,因此可以对这些时间所花费的位置提供一些信息。

在启用了调试日志之后,让我们重新运行脚本,看看 dockerd 的日志。因为要处理的容器有 200 个,它会输出大量的日志信息,所以我手工选择了一些有意义的日志。

  1. 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
  2. 2020-12-04T04:30:53.084Z dockerd Calling HEAD /_ping
  3. 2020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/33f7bdc9a123/kill?signal=KILL
  4. 2020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container 33f7bdc9a1239a3e1625ddb607a7d39ae00ea9f0fba84fc2cbca239d73c7b85c
  5. 2020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/2bfc4bf27ce9/kill?signal=KILL
  6. 2020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container 2bfc4bf27ce93b1cd690d010df329c505d51e0ae3e8d55c888b199ce0585056b
  7. 2020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/bef1570e5655/kill?signal=KILL
  8. 2020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container bef1570e5655f902cb262ab4cac4a873a27915639e96fe44a4381df9c11575d0
  9. ...

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

以下是执行 docker kill 后 30 秒左右看到的一些日志记录:

  1. ...
  2. 2020-12-04T04:31:32.308Z dockerd Releasing addresses for endpoint test-1's interface on network bridge
  3. 2020-12-04T04:31:32.308Z dockerd ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.2)
  4. 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
  5. 2020-12-04T04:31:32.308Z dockerd Releasing addresses for endpoint test-5's interface on network bridge
  6. 2020-12-04T04:31:32.308Z dockerd ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.6)
  7. 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
  8. 2020-12-04T04:31:32.308Z dockerd Releasing addresses for endpoint test-3's interface on network bridge
  9. 2020-12-04T04:31:32.308Z dockerd ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.4)
  10. 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
  11. 2020-12-04T04:31:32.308Z dockerd Releasing addresses for endpoint test-2's interface on network bridge
  12. 2020-12-04T04:31:32.308Z dockerd ReleaseAddress(LocalDefault/172.17.0.0/16, 172.17.0.3)
  13. 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 请求作出响应。(来源)。

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

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

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

事实证明,在清理网络资源时持有这个容器锁,而上面的 s.Lock() 结束了长时间的等待。这种情况发生在 handleContainerExit 里面。容器锁在该函数的持续时间内保持不变。这个函数调用容器的 Cleanup 方法,以释放网络资源。

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

这里的情况开始让人觉得有些绝望。我们似乎没有什么办法可以改变,以逃避等待网络资源被清理的命运。但我们或许可以完全绕过 Docker 而杀死容器。

对我们来说,我们可以杀死容器,而不必等到网络资源被清理。关键是容器不会产生任何副作用。举例来说,我们不想让容器获得更多的文件系统快照。

我采用的解决方案是通过直接杀死容器的 pid 来绕过 docker。在容器启动之后, conman 记录下容器的 pid,然后在需要终止时向容器发送 SIGKILL。因为容器形成了 pid 命名空间,所以容器 /pid 命名空间中的所有其他进程在容器的 pid 终止时也终止。

来自 pid_namespaces 手册页:

当 PID 命名空间的 “init” 进程终止时,内核就会通过 SIGKILL 信号终止该命名空间的所有进程。

考虑到这一点,我们有理由相信,在将 SIGKILL 发送到容器之后,它不再产生任何副作用。

实施此更改后,在关机期间,REPL 的控制权将在数秒内被放弃。与之前的 30 多秒相比,这是一个巨大的改进,会话连接错误率从大约 3% 降低到 0.5% 以下。另外,会话启动时间的第 99 个百分比从约 2 分钟降至约 15 秒。

总结一下,我们发现,虚拟机关机缓慢会导致 REPL 卡住和糟糕的用户体验。经过研究,我们发现 Docker 花了超过 30 秒的时间在虚拟机上杀死所有容器。我们通过绕过 Docker 和自己杀死容器来解决这个问题。这样就减少了 REPL 卡住的次数,加速了会话启动时间。但愿这会给 Replit 带来更加流畅的体验!

原文链接:https://blog.repl.it/killing-containers-at-scale


  1. 关注「开源Linux」加星标,提升IT技能

绕过 Docker ,大规模杀死容器的更多相关文章

  1. [置顶] Kubernetes1.7新特性:支持绕过docker,直接通过containerd管理容器

    背景情况 从Docker1.11版本开始,Docker依赖于containerd和runC来管理容器,containerd是控制runC的后台程序,runC是Docker公司按照OCI标准规范编写的一 ...

  2. Docker进阶之五:容器管理

    容器管理 一.创建容器常用选项 docker container --help 指令 描述 资源限制指令 -i, --interactive 交互式 -m,--memory 容器可以使用的最大内存量 ...

  3. Docker 核心技术之容器

    什么是容器 容器(Container) 容器是一种轻量级.可移植.并将应用程序进行的打包的技术,使应用程序可以在几乎任何地方以相同的方式运行 Docker将镜像文件运行起来后,产生的对象就是容器.容器 ...

  4. 2.ASP.NET Core Docker学习-镜像容器与仓库

    Docker下载 https://www.docker.com/community-edition 社区版 (CE) 下载完后安装,运行 docker --version 可查看版本 基本命令: 下面 ...

  5. Docker备份Gitlab容器以及还原数据

    概述 今天,我们将学习如何快速地对docker容器进行快捷备份.恢复和迁移.Docker是一个开源平台,用于自动化部署应用,以通过快捷的途径在称之为容器的轻量级软件层下打包.发布和运行这些应用.它使得 ...

  6. Docker 外部访问容器Pp、数据管理volume、网络network 介绍

    Docker 外部访问容器Pp.数据管理volume.网络network 介绍 外部访问容器 容器中可以运行一些网络应用,要让外部也可以访问这些应用,可以通过 -P 或 -p 参数来 指定端口映射. ...

  7. Win10系统下基于Docker构建Appium容器连接Android模拟器Genymotion完成移动端Python自动化测试

    原文转载自「刘悦的技术博客」https://v3u.cn/a_id_196 Python自动化,大概也许或者是今年最具热度的话题之一了.七月流火,招聘市场上对于Python自动化的追捧热度仍未消减,那 ...

  8. docker:从 tomcat 容器连接到 mysql 容器

    docker 中的容器互联是一个较为复杂的话题,详细内容将在后续章节中介绍. 续前 2 个章节的内容,我们创建了一个 mysql 容器和一个 tomcat 容器,可以使用 「docker ps」来查看 ...

  9. 阿里云部署Docker(7)----将容器连接起来

    路遥知马力.日久见人心.恩. 该坚持的还是要坚持. 今天看到一个迅雷的师弟去了阿里,祝福他,哎,尽管老是被人家捧着叫大牛.我说不定通过不了人家的面试呢.哎,心有惭愧. 本文为本人原创,转载请表明来源: ...

随机推荐

  1. redis 过期键的删除策略?

    1.定时删除:在设置键的过期时间的同时,创建一个定时器 timer). 让定时器在键 的过期时间来临时,立即执行对键的删除操作. 2.惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的 ...

  2. volatile 修饰符的有过什么实践?

    一种实践是用 volatile 修饰 long 和 double 变量,使其能按原子类型来读写. double 和 long 都是 64 位宽,因此对这两种类型的读是分为两部分的,第一次 读取第一个  ...

  3. Java 有没有 goto?

    goto 是 Java 中的保留字,在目前版本的 Java 中没有使用.(根据 James Gosling (Java 之父)编写的<The Java Programming Language& ...

  4. 安装TypeScript

    安装TypeScript 创建工程文件夹:mkdir <project folder> 进入工程文件夹:cd <project folder> 快速创建程序包:npm init ...

  5. WebView的一些简单用法

    一直想写一个关于 WebView 控件的 一些简单运用,都没什么时间,这次也是挤出时间写的,里面的一些基础知识就等有时间再更新讲解一下,今天就先把项目出来做一些简单介绍,过多的内容可以看我的源码,都传 ...

  6. 2022DASCTF X SU 三月春季挑战赛 Calc

    查看代码 #coding=utf-8 from flask import Flask,render_template,url_for,render_template_string,redirect,r ...

  7. leetcode-剑指 Offer II 012. 左右两边子数组的和相等

    题目描述: 给你一个整数数组 nums ,请计算数组的 中心下标 . 数组 中心下标 是数组的一个下标,其左侧所有元素相加的和等于右侧所有元素相加的和. 如果中心下标位于数组最左端,那么左侧数之和视为 ...

  8. DB2表数据导出、导入及常用sql使用总结

      一.DB2数据的导出: export to [path(例:D:"TABLE1.ixf)]of ixf select [字段(例: * or col1,col2,col3)] from ...

  9. mongodb安装错误以及原理

    安装mongodb,默认是安装到"C:\Program Files\MongoDB\"这里的,我在注册表里没有找到mongodb的信息,所以猜测它只是将其解压到那个位置而已,它只是 ...

  10. uni-app中实现图片左滑的效果

    template: 1 <view class="my-reg"> 2 <view class="my-regs"> 3 <ima ...