使用Delve 调试Go服务的一次经历
Vladimir Varankin 写于 2018/12/02
某天,我们的生产服务上的几个实例突然不能处理外部进入的流量,HTTP请求成功通过负载均衡到达实例,但是之后却hang住了。接下来记录的是一次调试在线Go服务的惊心动魄的经历。
正是下面逐步演示的操作,帮助我们定位了问题的根本原因。
简单起见,我们将起一个Go写的HTTP服务作为调试使用,这个服务实现的细节暂时不做深究(之后我们将深入分析代码)。一个真实的生产应用可能包含很多组件,这些组件实现了业务罗和服务的基础架构。我们可以确信,这些应用已经在生产环境“身经百战” :)。
源代码以及配置细节可以查看GitHub仓库。为了完成接下来的工作,你需要一台Linux系统的虚机,这里我使用vagrant-hostmanager插件。Vagrantfile
在GitHub仓库的根目录,可以查看更多细节。
让我们开启虚机,构建HTTP服务并且运行起来,可以看到下面的输出:
1 | vagrant up |
通过curl
发送请求到所起的HTTP服务,可以判断其是否处于工作状态,新开一个terminal并执行下面的命令:
1 | curl 'http://server-test-1:10080' |
为了模拟失败的情况,我们需要发送大量请求到HTTP服务,这里我们使用HTTP benchmark测试工具wrk进行模拟。我的MacBook是4核的,所以使用4个线程运行wrk,能够产生1000个连接,基本能够满足需求。
1 | wrk -d1m -t4 -c1000 'http://server-test-1:10080' |
一会的时间,服务器hang住了。甚至等wrk跑完之后,服务器已经不能处理任何请求:
1 | curl --max-time 5 'http://server-test-1:10080/' |
我们遇到麻烦了!让我们分析一下。
在我们生产服务的真实场景中,服务器起来以后,goroutines的数量由于请求的增多而迅速增加,之后便失去响应。对pprof调试句柄的请求变得非常非常慢,看起来就像服务器“死掉了”。同样,我们也尝试使用SIGQUIT
命令杀掉进程以释放所运行goroutines堆栈,但是收不到任何效果。
GDB和Coredump
我们可以使用GDB(GNU Debugger)尝试进入正在运行的服务内部。
在生产环境运行调试器可能需要额外的权限,所以与你的团队提前沟通是很明智的。
在虚机上再开启一个SSH会话,找到服务器的进程id并使用调试器连接到该进程:
1 | vagrant ssh server-test-1 |
调试器连接到服务器进程之后,我们可以运行GDB的bt
命令(aka backtrace)来检查当前线程的堆栈信息:
1 | (gdb) bt |
说实话我并不是GDB的专家,但是显而易见Go运行时似乎使线程进入睡眠状态了,为什么呢?
调试一个正在运行的进程是不明智的,不如将该线程的coredump保存下来,进行离线分析。我们可以使用GDB的gcore
命令,该命令将core文件保存在当前工作目录并命名为core.<process_id>
。
1 | (gdb) gcore |
core文件保存后,服务器没必要继续运行,使用kill -9
结束它。
我们能够注意到,即使是一个简单的服务器,core文件依然会很大(我这一份是1.2G),对于生产的服务来说,可能会更加巨大。
如果需要了解更多使用GDB调试的技巧,可以继续阅读使用GDB调试Go代码。
使用Delve调试器
Delve是一个针对Go程序的调试器。它类似于GDB,但是更关注Go的运行时、数据结构以及其他内部的机制。
如果你对Delve的内部实现机制很感兴趣,那么我十分推荐你阅读Alessandro Arzilli在GopherCon EU 2018所作的演讲,[Internal Architecture of Delve, a Debugger For Go]。
Delve是用Go写的,所以安装起来非常简单:
1 | go get -u github.com/derekparker/delve/cmd/dlv |
Delve安装以后,我们就可以通过运行dlv core <path to service binary> <core file>
来分析core文件。我们先列出执行coredump时正在运行的所有goroutines。Delve的goroutines
命令如下:
1 | dlv core example/server/server core.1628 |
不幸的是,在真实生产环境下,这个列表可能会很长,甚至会超出terminal的缓冲区。由于服务器为每一个请求都生成一个对应的goroutine,所以goroutines
命令生成的列表可能会有百万条。我们假设现在已经遇到这个问题,并想一个方法来解决它。
Delve支持”headless”模式,并且能够通过JSON-RPC API与调试器交互。
运行dlv core
命令,指定想要启动的Delve API server:
1 | dlv core example/server/server core.1628 --listen :44441 --headless --log |
调试服务器运行后,我们可以发送命令到其TCP端口并将返回结果以原生JSON的格式存储。我们以上面相同的方式得到正在运行的goroutines,不同的是我们将结果存储到文件中:
1 | echo -n '{"method":"RPCServer.ListGoroutines","params":[],"id":2}' | nc -w 1 localhost 44441 > server-test-1_dlv-rpc-list_goroutines.json |
现在我们拥有了一个(比较大的)JSON文件,里面存储大量原始信息。推荐使用jq命令进一步了解JSON数据的原貌,举例:这里我获取JSON数据的result字段的前三个对象:
1 | jq '.result[0:3]' server-test-1_dlv-rpc-list_goroutines.json |
JSON数据中的每个对象都代表了一个goroutine。通过命令手册
可知,goroutines
命令可以获得每一个goroutines的信息。通过手册我们能够分析出userCurrentLoc
字段是服务器源码中goroutines最后出现的地方。
为了能够了解当core file创建的时候,goroutines正在做什么,我们需要收集JSON文件中包含userCurrentLoc
字段的函数名字以及其行号:
1 | jq -c '.result[] | [.userCurrentLoc.function.name, .userCurrentLoc.line]' server-test-1_dlv-rpc-list_goroutines.json | sort | uniq -c |
大量的goroutines(上面是1000个)在函数main.(*Metrics).CoutS
的95行被阻塞。现在我们回头看一下我们服务器的源码。
在main
包中找到Metrics
结构体并且找到它的CountS
方法(example/server/metrics.go)。
1 | // CountS increments counter per second. |
我们的服务器在往inChannel
通道发送的时候阻塞住了。让我们找出谁负责从这个通道读取数据,深入研究代码之后我们找到了下面的函数:
1 | // starts a consumer for inChannel |
这个函数逐个地从通道中读取数据并加以处理,那么什么情况下发送到这个通道的任务会被阻塞呢?
当处理通道的时候,根据Dave Cheney的通道准则,只有四种情况可能导致通道有问题:
- 向一个nil通道发送
- 从一个nil通道接收
- 向一个已关闭的通道发送
- 从一个已关闭的通道接收并立即返回零值
第一眼就看到了“向一个nil通道发送”,这看起来像是问题的原因。但是反复检查代码后,inChannel
是由Metrics
初始化的,不可能为nil。
n你可能会注意到,使用jq
命令获取到的信息中,没有startInChannelConsumer
方法。会不会是因为在main.(*Metrics).startInChannelConsumer
的某个地方阻塞而导致这个(可缓冲)通道满了?
Delve能够提供从开始位置到userCurrentLoc
字段之间的初始位置信息,这个信息存储到startLoc
字段中。使用下面的jq命令可以查询出所有goroutines,其初始位置都在函数startInChannelConsumer
中:
1 | jq '.result[] | select(.startLoc.function.name | test("startInChannelConsumer$"))' server-test-1_dlv-rpc-list_goroutines.json |
结果中有一条信息非常振奋人心!
在main.(*Metrics).startInChannelConsumer
,109行(看结果中的startLoc字段),有一个id为20的goroutines阻塞住了!
拿到goroutines的id能够大大降低我们搜索的范围(并且我们再也不用深入庞大的JSON文件了)。使用Delve的goroutines
命令我们能够将当前goroutines切换到目标goroutines,然后可以使用stack
命令打印该goroutines的堆栈信息:
1 | dlv core example/server/server core.1628 |
从下往上分析:
(6)一个来自通道的新inMetrics
值在main.(*Metrics).startInChannelConsumer
中被接收
(5)我们调用main.(*Metrics).sendMetricsToOutChannel
并且在example/server/metrics.go
的146行进行处理
(4)然后main.(*Metrics).SetM
被调用
一直运行到runtime.gopark
中的waitReasonChanSend
阻塞!
一切的一切都明朗了!
单个goroutines中,一个从缓冲通道读取数据的函数,同时也在往通道中发送数据。当进入通道的值达到通道的容量时,消费函数继续往已满的通道中发送数据就会造成自身的死锁。由于单个通道的消费者死锁,那么每一个尝试往通道中发送数据的请求都会被阻塞。
这就是我们的故事,使用上述调试技术帮助我们发现了问题的根源。那些代码是很多年前写的,甚至从没有人看过这些代码,也万万没有想到会导致这么大的问题。
如你所见,并不是所有问题都能由工具解决,但是工具能够帮助你更好地工作。我希望,通过此文能够激励你多多尝试这些工具。我非常乐意倾听你们处理类似问题的其它解决方案。
Vladimir是一个后端开发工程师,目前就职于adjust.com. @tvii on Twitter, @narqo on Github
via: https://blog.gopheracademy.com/advent-2018/postmortem-debugging-delve/
作者:Vladimir Varankin
译者:hantmac