53

容器中某 Go 服务 GC 停顿经常超过 100ms 排查

 4 years ago
source link: https://mp.weixin.qq.com/s/Lk1EbiT7WprVOyX_dXYMyg
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

GC停顿经常超过100ms

现象

有同事反馈说, 最近开始试用公司的k8s, 部署在docker里的go进程有问题, 接口耗时很长, 而且还有超时. 逻辑很简单, 只是调用了kv存储, kv存储一般响应时间<5ms, 而且量很少, 小于40qps, 该容器分配了0.5个核的配额, 日常运行CPU不足0.1个核.

复现

我找了个容器, 踢掉访问流量. 用ab 50并发构造些请求看看. 网络来回延时60ms, 但是平均处理耗时200多ms, 99%耗时到了679ms.

ZRniyaV.jpg!web

用ab处理时, 看了下CPU和内存信息, 都没啥问题. docker分配的是0.5个核. 这里也没有用到那么多.

6rEbuaY.jpg!web

看了下监控, GC STW(stop the world)超过10ms, 50-100ms的都很多, 还有不少超过100ms的. Go不是声称在1.8后GC停顿基本是小于1ms的吗?

Qr2mIz3.png!web

gc信息及trace

看看该进程的runtime信息, 发现内存很少,gc-pause很大,GOMAXPROCS为76,是机器的核数。

yMVJfyN.jpg!web

export GODEBUG=gctrace=1, 重启进程看看. 可以看出gc停顿的确很严重.

在一台有go sdk的服务器上对服务跑一下trace, 再把trace文件下载到本地看看

下图可见有一个GC的wall time为172ms,而本次gc的两个stw阶段,sweep termination和mark termination都在80多ms的样子, 几乎占满了整个GC时间, 这当然很不科学.

vAzuqai.jpg!web

原因及解决方法

原因

这个服务是运行在容器里的, 容器和母机共享一个内核, 容器里的进程看到的CPU核数也是母机CPU核数, 对于Go应用来说, 会默认设置P(为GOMAXPROCS)的个数为CPU核数. 我们从前面的图也可以看到, GOMAXPROCS为76, 每个使用中的P都有一个m与其绑定, 所以线程数也不少, 上图中的为171.然而分配给该容器的CPU配额其实不多, 仅为0.5个核, 而线程数又不少.

猜测: 对于linux的cfs(完全公平调度器)来说, 当前容器内所有的线程(轻量级进程)都在一个调度组内. 为了保证效率, 对于每个被运行的task, 除非因为阻塞等原因主动切换, 那么至少保证其运行/proc/sys/kernel/sched min granularity_ns的时间, 可以看到为4ms.

容器中Go进程没有正确的设置GOMAXPROCS的个数, 导致可运行的线程过多, 可能出现调度延迟的问题. 正好出现进入gc发起stw的线程把其他线程停止后, 其被调度器切换出去, 很久没有调度该线程, 实质上造成了stw时间变得很长(正常情况0.1ms的处理过程因为调度延迟变成了100ms级别).

解决方法

解决的方法, 因为可运行的P太多, 导致占用了发起stw的线程的虚拟运行时间, 且CPU配额也不多. 那么我们需要做的是使得P与CPU配额进行匹配. 我们可以选择:

  1. 增加容器的CPU配额.

  2. 容器层让容器内的进程看到CPU核数为配额数

  3. 根据配额设置正确的GOMAXPROCS

第1个方法: 没太大效果, 把配额从0.5变成1, 没本质的区别(尝试后, 问题依旧).

第2点方法: 因为我对k8s不是很熟, 待我调研后再来补充.

第3个方法: 设置GOMAXPROCS最简单的方法就是启动脚本添加环境变量

GOMAXPROCS=2 ./svr_bin 这种是有效的, 但也有不足, 如果部署配额大一点的容器, 那么脚本没法跟着变.

uber的库automaxprocs

uber有一个库, go.uber.org/automaxprocs, 容器中go进程启动时, 会正确设置GOMAXPROCS. 修改了代码模板. 我们在go.mod中引用该库

并在main.go中import

效果

automaxprocs库的提示

使用automaxprocs库, 会有如下日志:

  1. 对于虚拟机或者实体机

    8核的情况下: 2019/11/07 17:29:47 maxprocs: Leaving GOMAXPROCS=8: CPU quota undefined

  2. 对于设置了超过1核quota的容器

    2019/11/08 19:30:50 maxprocs: Updating GOMAXPROCS=8: determined from CPU quota

  3. 对于设置小于1核quota的容器

    2019/11/08 19:19:30 maxprocs: Updating GOMAXPROCS=1: using minimum allowed GOMAXPROCS

  4. 如果docker中没有设置quota

    2019/11/07 19:38:34 maxprocs: Leaving GOMAXPROCS=79: CPU quota undefined

    此时建议在启动脚本中显式设置 GOMAXPROCS

请求响应时间

设置完后, 再用ab请求看看,网络往返时间为60ms, 99%请求在200ms以下了, 之前是600ms. 同等CPU消耗下, qps从差不多提升了一倍.

bIfmQjN.jpg!web

runtime及gc trace信息

因为分配的是0.5核, GOMAXPROC识别为1. gc-pause也很低了, 几十us的样子. 同时也可以看到线程数从170多降到了11.

32AvIb3.jpg!web

上下文切换

以下为并发50, 一共处理8000个请求的perf stat结果对比. 默认CPU核数76个P, 上下文切换13万多次, pidstat查看system cpu消耗9%个核.  而按照quota数设置P的数量后, 上下文切换仅为2万多次, cpu消耗3%个核.

UfqIBjJ.png!web

yaEJfqB.png!web

automaxprocs原理解析

这个库如何根据quota设置GOMAXPROCS呢, 代码有点绕, 看完后, 其实原理不复杂. docker使用cgroup来限制容器CPU使用, 使用该容器配置的cpu.cfs quota us/cpu.cfs period us即可获得CPU配额. 所以关键是找到容器的这两个值.

获取cgroup挂载信息

cat /proc/self/mountinfo

cpuacct,cpu在/sys/fs/cgroup/cpu,cpuacct这个目录下.

获取该容器cgroup子目录

cat /proc/self/cgroup

该容器的cpuacct,cpu具体在/kubepods/burstable/pod62f81b5d-xxxx/xxxx92521d65bff8子目录下

计算quota

两者相除得到0.5, 小于1的话,GOMAXPROCS设置为1,大于1则设置为计算出来的数。

核心函数

automaxprocs库中核心函数如下所示, 其中cg为解析出来的cgroup的所有配置路径. 分别读取cpu.cfs _quota _us和cpu.cfs _period _us, 然后计算. 

uaMnMjR.jpg!web

官方issue

谷歌搜了下, 也有人提过这个问题

runtime: long GC STW pauses (≥80ms) #19378 https://github.com/golang/go/issues/19378

总结

  1. 容器中进程看到的核数为母机CPU核数,一般这个值比较大>32, 导致go进程把P设置成较大的数,开启了很多P及线程

  2. 一般容器的quota都不大,0.5-4,linux调度器以该容器为一个组,里面的线程的调度是公平,且每个可运行的线程会保证一定的运行时间,因为线程多, 配额小, 虽然请求量很小, 但上下文切换多, 也可能导致发起stw的线程的调度延迟, 引起stw时间升到100ms的级别,极大的影响了请求

  3. 通过使用automaxprocs库, 可根据分配给容器的cpu quota, 正确设置GOMAXPROCS以及P的数量, 减少线程数,使得GC停顿稳定在<1ms了. 且同等CPU消耗情况下, QPS可增大一倍,平均响应时间由200ms减少到100ms. 线程上下文切换减少为原来的1/6

  4. 同时还简单分析了该库的原理. 找到容器的cgroup目录, 计算cpuacct,cpu下cpu.cfs _quota _us/cpu.cfs _period _us, 即为分配的cpu核数.

  5. 当然如果容器中进程看到CPU核数就是分配的配额的话, 也可以解决这个问题. 这方面我就不太了解了.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK