本文介绍了公司“云化服务”的大背景下,将一个Go服务迁移至公司的基于K8s+docker的容器云平台,使用火焰图进行性能排查和优化方面的实践。欢迎在留言区进行阅读探讨。
在公司“云化服务”的大背景下,将一个Go服务迁移至公司的基于K8s+docker的容器云平台。在迁移过程中发现服务在Docker容器内的CPU使用率异常的问题。针对此问题,进行了一些排查和优化的实践。本文将重现排查过程以及优化方案,希望能为读者提供一些参考。
在将服务由物理机迁移至容器云计算平台过程中,发现CPU使用率远超预期。该服务是一个Go编写的消息推送服务,其业务特点是:短时间内会推送大量消息,因此该服务的负载曲线会是一个类似方波的图形。在迁移前预估业务高峰期的CPU使用率为20%,但是实际的CPU使用率远超预期,到达了70%,是预估值的3倍,如下图所示:使用指令 pidstat -w 观察到线程切换次数也比较高,达到了千次/秒。
在确认容器节点和物理机节点的请求量负载基本一致后,开始对容器节点进行性能排查。Linux平台有很多性能分析工具像perf、systemtap等。Go的工具集非常丰富,相比于其他Linux工具,可以更加简便深入地进行分析调试。这里直接使用go tool pprof对服务进行profiling采样分析。a) 首先在代码中开启pprof,对于具备http server的服务来说,仅需增加一行代码 import _ "net/http/pprof"
b) 访问 http://ip:port/debug/pprof/ 可以在浏览器中查看pprof采样得到的数据,使用命令行进行采样则更加方便 go tool pprof http://ip:port/debug/pprof/profile?seconds=30
c) 在pprof中输入指令web,即可生成一个函数调用链的CPU耗时分析,但是通常对于一个线上逻辑较为复杂的服务来说,此图并不直观,以火焰图的方式查看效果更佳
d) 在Go 1.10之后,官方的pprof工具直接支持了火焰图展示。目前由uber开源的第三方工具go-torch更为常用,这里使用了 go-torch -b ./pprof.demo.cpu.pb.gz -f demo.svg生成了火焰图如下所示:
火焰图的颜色不代表实际意义,纵轴代表代码函数调用栈,横轴代表CPU占用百分比,横轴的不同部分代码块是按照字母顺序排序。a) 总体来看 runtime 相关代码占据了60%左右的CPU,实际的业务代码占据了40%左右的CPU
b) 由于所使用的消息队列的客户端是使用Go调用C通过 cgo机制实现的,而cgo是较为缓慢和消耗CPU的,因此这里的 runtime._ExternalCode 会占用较多的CPU,是符合正常逻辑的
c) runtime中占用CPU比例较大的是:runtime.gcBgMarkWorker, runtime.schedule, runtime.findrunnable,正常情况下不应占据如此多的CPU。
在Go的运行模型GMP中,每个P会运行一个gcBgMarkWorker用于垃圾回收。是否由于P的数量不正确导致GC过多,从而CPU使用率过高?Go程序在运行时,会使用查询到的CPU的数量作为默认的P的数量,简单地用一个Go脚本验证一下:func main() {
cpu := runtime.NumCPU()
procs := runtime.GOMAXPROCS(0)
fmt.Println("cpu num:", cpu, " GOMAXPROCS:", procs)
}
// output -> cpu num:32 GOMAXPROCS:32
该脚本运行结果表明:在程序运行时读取到的CPU的数量是宿主机的CPU数量,而不是容器设置的CPU核心数量。
通过环境变量GOMAXPROCS可以设置Go运行时P的数量,设置环境变量GOMAXPROCS=8,这个值是容器分配的CPU核心数。灰度一台效果如下:对比未设置环境变量的节点,CPU峰值从69%下降到19%,效果非常明显。全量上线之后,CPU使用率保持了与预期值一致。由于Go程序本身的特性,在运行时会默认读取系统的CPU核心数作为最大的并行执行线程数。而在容器内,读取到的是宿主机的CPU核心数。在容器被分配的CPU核心数远小于宿主机的CPU核心数的情况下,就会发生CPU使用率异常升高的情况。出现问题的这个服务,其业务特点就是周期性的峰值QPS极高,所以会较为明显地观察出CPU使用率异常的现象。
通过配置环境变量 GOMAXPROCS,指定最大的并行执行线程数,可以解决CPU使用率异常的问题。由于业务逻辑的不同,达到最佳性能的GOMAXPROCS也不同。《The Way to Go》曾给出过一个经验公式:GOMAXPROCS=CPU数量-1。Go程序的运行是使用协程的方式,在Go中协程被称之为goroutine。在操作系统看来,所有程序都是以多线程(暂不细分LWP和线程的区别)的方式运行,而线程切换(context switch)对性能的影响还是比较高的。一次线程切换一般需要1000~2000ns,而一次goroutine协程切换一般需要200ns。Go为了提高并发能力,代码中任务的执行,由运行在内核态的操作系统对线程的控制,转移为运行在用户态的Go scheduler对协程的控制,协程的调度在Go runtime中进行。实际的内核线程与goroutine之间的数量关系为M:N,在M个内核线程上会有N个goroutine。a) G:代表goroutine
b) M:代表实际的内核线程
c) P:代表虚拟的processer
a) 重用线程,一个线程上会多次执行协程任务
b) 限制同时运行(不包含阻塞)的线程数为GOMAXPROCS,默认情况下就是 CPU 的核心数目
c) 线程私有的 runqueues,并且可以从其他线程 stealing goroutine 来运行,线程阻塞后,可以将 runqueues 传递给其他线程
在最初的设计中是没有P这个单元的,在增加了P这个逻辑processer后,并发能力大大增强。a) 在程序启动时,默认地会启动CPU最大核心数量的P,同时为每个P分配一个实际的内核线程M。
b) 使用协程G来执行任务代码,包括GC等runtime代码。
c) 每一个协程G在创建之后,都会通过P来找到一个实际的内核线程M,由该M来执行G中的代码。
d) P通过队列来接收G,队列分为本地队列LocalQueue和全局队列GlobalQueue。
e) 如果某个P的本地队列无可执行的G,那么就会去全局队列里面去抽取一部分的G来执行。如果全局队列为空,那么就会随机选择其他的P,将被选中的P的本地队列中的G抽取一部分来执行。这个机制称之为work stealing。
f) 为了避免某个G占用了非常多的资源,有一个后台任务sysmon,用来检测G的运行时间。当G的运行时长超过10ms后,会被强制剥夺运行权限,将其放入全局队列之中。
g) 如果内核线程M由于发生系统调用、网络调用等产生了阻塞,为了最大地提高效率,P会暂时将M解绑,创建一个新的内核线程M或者找到其他可运行的线程,与其绑定,继续执行该P上面的待执行的G。
h) 如果某个G因为触发了GC或者atomic,mutex,channel等阻塞操作,为了避免阻塞,该G同样会被调度走,等待其处于可运行的状态后再被调度执行。
在火焰图中runtime.gcBgMarkWorker, runtime.schedule, runtime.findrunnable 占用CPU较多:a) 首先是GC占用过长的问题,源码中的注释写道,每个P都拥有一个GC后台协程。
如果宿主机的核心数是32,容器分配的核心数是8,那么程序运行时就会有32个P。因此就会有32个GC后台协程,那么runtime.gcBgMarkWorker就会占据很多CPU。
b) 前面提到Go的调度原理,P寻找可执行的G的顺序为:1.先检查P的本地队列 2.如果没找到,则去全局队列寻找 3.如果还没有找到,则去其他的P的本地队列里面去抽取一部分来执行。调度器的runtime.findrunnable函数就是执行的此流程。那么在宿主机核心数的数量远多于实际分配的核心数的情况下,就会有很多空闲的P需要执行runtime.findrunnable的流程,而且会出现work stealing这种现象。
c) 了解到容器主要使用CGroup的cpu.cfs_period_us和cpu.cfs_quota_us等参数来对进程使用的CPU资源进行限制的。在不进行绑核等配置的操作下,CPU资源的分配是按照使用情况而不是实际的核心来分配的。例如宿主机的核心数是32,容器分配的核心数是8,那么最多可以使用总计800%的CPU资源,这些资源会被平均分配到32个核心,每个核心占用25%的CPU。那么当达到配置的限额后,就会触发Linux的调度策略,导致线程切换增多。
对于不能正确识别容器内分配的核心数的现象,JDK同样有此问题。JDK在JDK 8u191之后开始支持使用容器分配的CPU核心数。https://www.oracle.com/technetwork/java/javase/8u191-relnotes-5032181.html.对于Go服务来说,可以通过环境变量GOMAXPROCS来设置合理的CPU核心数量。另外,Uber开源了一个自动调整GOMAXPROCS的库:https://github.com/uber-go/automaxprocs.本文对迁移服务至容器过程中,出现的CPU使用率过高的问题进行了排查和优化。通过设置环境变量GOMAXPROCS的方式,限制了GMP模型中P的数量的方式,解决了CPU使用率过高的问题。然后对Go程序的GMP模型和调度原理进行了探究,并分析了本文中CPU使用率过高问题的原因。最后,对容器内CPU数量不能正确识别这个较为普遍的问题,展示了Java和Go两种解决方案。1. [Analysis of the Go runtime scheduler] http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf2. [Go runtime scheduler]https://speakerdeck.com/retervision/go-runtime-scheduler?slide=14https://qcrao.com/2019/09/02/dive-into-go-scheduler/4. [CPU considerations for Java applications running in Docker and Kubernetes]https://medium.com/@christopher.batey/cpu-considerations-for-java-applications-running-in-docker-and-kubernetes-7925865235b75. [容器中某Go服务GC停顿经常超过100ms排查]https://mp.weixin.qq.com/s/Lk1EbiT7WprVOyX_dXYMyg王立明 / 云平台部-平台应用部后台开发工程师,主要负责消息推送服务以及框架组件的开发。