如何解决Go Docker容器高延迟暂停问题?


在容器编排器中运行时,设置 CPU 限制非常重要,以确保容器不会消耗主机上的所有 CPU。

然而,Go 运行时不知道容器上设置的 CPU 限制,并且会愉快地使用所有可用的 CPU,导致高延迟。

原因:

  • 绝大多数情况下,Go 运行时在执行程序的同时执行垃圾收集。这意味着 GC 与程序同时运行。
  •  Go 垃圾收集器需要两个“停止世界”阶段:其中所有 goroutine 都暂停以进行标记和清除终止。

在 GC 过程中有两阶段 Go 运行时需要停止每个 Goroutine,这是确保数据完整性所必需的:

  1. 扫描终止:在 GC 的标记阶段之前,运行时会停止每个 Goroutine,以应用写屏障,从而确保在此阶段之后创建的对象不会被垃圾回收。这一阶段被称为 "扫描终止"。
  2. 标记终止:在标记阶段结束后,还会有另一个 "停止世界 "阶段,这被称为 "标记终止",同样的过程也发生在移除写入障碍上。这些过程通常需要几十微秒。

测试
我创建了一个分配大量内存的简单网络应用程序,并通过以下命令在一个 CPU 内核限制为 4 的容器中运行。

docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)

值得注意的是,docker 的 CPU 限制是软限制,也就是说,只有当主机的 CPU 受限时才会强制执行。这意味着,如果主机有空余的CPU内核,容器就可以使用超过4个CPU内核。

你可以使用 runtime/trace 软件包收集跟踪,然后使用 go 工具跟踪分析。
你可以看到进程 5 上的 Sweep Termination 和 Mark Termination stop the world 阶段(它们被标记为 STW,表示停止世界)。

这次 GC 循环耗时不到 2.5 毫秒,但其中有近 10% 的时间是在 "停止世界 "阶段。这个时间相当可观,尤其是在运行对延迟敏感的应用程序时。

测试源码:here.

Linux 调度程序
完全公平调度程序(CFS)是在 Linux 2.6.23 中引入的,在上周发布的 Linux 6.6 之前一直是默认的调度程序。你很可能正在使用 CFS。

CFS 是一种按比例共享的调度程序,这意味着进程的权重与允许使用的 CPU 内核数成正比。例如,如果一个进程被允许使用 4 个 CPU 内核,它的权重就是 4;如果一个进程被允许使用 2 个 CPU 内核,它的权重就是 2。

CFS 通过分配部分 CPU 时间来实现这一点。一个 4 核系统每秒可分配 4 秒 CPU 时间。当你为一个容器分配一定数量的 CPU 内核时,实际上就是在要求 Linux 调度器给它分配 n 个 CPU 的时间。

在上面的 docker run 命令中,我要求分配 4 个 CPU 的时间。这意味着,容器每秒钟将获得 4 秒钟的 CPU 时间。

问题所在
Go 运行时启动时,会为每个 CPU 内核创建一个操作系统线程。这意味着,如果你有一台 16 核的机器,Go 运行时将会创建 16 个操作系统线程,而不考虑任何 CGroup CPU 限制。然后,Go 运行时会使用这些操作系统线程来调度 goroutines。

问题是,Go 运行时并不知道 CG 组的 CPU 限制,它会很乐意在所有 16 个操作系统线程上调度 goroutines。这意味着 Go 运行时希望每秒都能使用 16 秒的 CPU 时间。

由于 Go 运行时需要在等待 Linux 调度器调度的线程上停止 goroutine,因此会出现较长的停止时间。一旦容器用完 CPU 配额,这些线程就不会被调度。

解决方案
Go 允许使用 GOMAXPROCS 环境变量来限制运行时创建的 CPU 线程数。这次我使用以下命令启动容器

docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)

在这次跟踪中,尽管负载完全相同,但垃圾回收的时间却短得多。GC 循环耗时不到 1 毫秒,停止世界阶段耗时 26 分钟,约为无限制时的 1/10。

GOMAXPROCS 应设置为允许容器使用的 CPU 内核数,如果分配的是零碎 CPU,则向下取整,除非分配的 CPU 内核数少于 1 个,则向上取整。可以使用 GOMAXPROCS=max(1, floor(CPUs)) 来计算该值。如果你觉得更方便,Uber 已经开源了一个库 automaxprocs,可以自动从容器的 cgroups 中为你计算这个值。

Go 运行时有一个突出的Github 问题来支持这个开箱即用的功能,所以希望它最终会被添加!


结论
在容器化应用程序中运行 Go 时,设置 CPU 限制非常重要。同样重要的是,通过设置合理的 GOMAXPROCS 值或使用 automaxprocs 等库,确保 Go 运行时知道这些限制。