一次gRPC使用不当导致goroutine泄漏排查记录
由于保留必要的“罪证”,因此某些异常只能通过文字来描述了~
背景 #
昨晚上10点左右,前端童鞋反映开发环境接口响应超时,但过了几分钟后又恢复了,于是有了这一篇文章。
其实很久以前就出现了内存占用异常的情况~,只是占用并不高也就是50MB左右,加上当时还忙着写业务需求就没有急着加上pprof来检查。
首先通过运维平台(k8s based)
直观发现了该pod数量从1变成了2, 再结合新增pod的启动时间,我发现该时间正好是前端童鞋反映状况的时间节点,稍后我检查了下该服务的资源限制如下图:
那么前端童鞋反映的问题就很明显了,由于某种原因导致了pod内存超限,触发了运维平台对于内存超限的“容忍机制”。表现为: 新增一个pod用于缓解服务压力,老服务由于无法申请更多内存会导致崩溃或其他异常(无法响应客户端请求),这与反映的情况一致。
pprof排查 #
知道了服务内存异常,想要具体定位的话,这时候就需要pprof上场了。
如果你需要重启服务才能开启pprof的话,那么只能等待复现了。这里我在开发环境和测试环境一直开启了pprof,因此可以直接检查。个人觉得,这样还可以帮助开发和测试,完成最初的性能分析😼。
内存检查 #
go tool pprof --http=:8080 https://service.host.com/debug/pprof/heap
这个命令是在本地打开一个web服务,直接可视化该服务的内存占用情况。也可以使用:
go tool pprof https://service.host.com/debug/pprof/heap
使用交互模式来分析。通过这个步骤定位到了 grpc相关的包内存占用异常分为两个部分:
50MB+ google.golang.org/grpc/internal/transport.newBufWriter
50MB+ bufio.NewReaderSize
http2 相关库的占用也比较多
这一切都指向了我们使用的gRPC,可是为啥使用gRPC会用到这么“多”内存呢?接着分析
goroutine检查 #
打开一看 https://service.host.com/debug/pprof/一看,goroutine和heap居“高”(4000+)不下,虽然对于动辄10W+的别人家的服务来说,这点根本不算事,但在我们这种小作坊里可就算异常了。点开看goroutine查看详情,有四个部分的goroutine分别有900个左右,这里就算初步定位了“gRPC客户端使用了较多的goroutine,但是却没有正确的结束掉”,如下图(这是解决后的截的图):
pprof总结 #
服务中使用的gRPC客户端出了某些故障,导致了goroutine泄漏,引发了OOM(Out Of Memory)。如下图:
代码排查 #
上一步已经定位到是gRPC客户端的问题,那么就可以直接从代码上手了。我心里已经有一个“嫌疑犯”了,如下:
var (
defaultHandler *handler
timeout = 5 * time.Second
// _ pb.UserServiceClient = &handler{}
)
// Init of usersvc.handler
func Init(rpcAddr string) error {
// ... 略去不表
}
type handler struct {
// rpc configs
rpcAddr string
client pb.UserServiceClient
lastGrpcReqError error
}
func (h *handler) connectRPC() {
if h.client != nil && h.lastGrpcReqError != nil {
// 这里判断的本意是:如果客户端初始化失败,
// 或者期间因为异常情况,导致客户端与服务端连接中断的情况下尝试重连。
//
// 但是忽略了gRPC实现中,对于客户端的处理:
// 1. grpc.Dail 是异步的
// 2. grpc 有自己的重连机制
//
// 这一部分我还没有看完,就不乱发表看法了。
conn, err := grpc.Dial(h.rpcAddr, grpc.WithInsecure())
if err != nil {
logger.Std.Errorf("could not dial: %s with err: %v", h.rpcAddr, err)
return
}
logger.Std.Infof("usersvc.client.connectRPC called")
h.client = pb.NewUserServiceClient(conn)
}
}
// QueryBasicInfoByID based default Handler .
func QueryBasicInfoByID(in *pb.ByIDForm) (*pb.BasicInfoResponse, error) {
defaultHandler.connectRPC()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
resp, err := defaultHandler.client.QueryBasicInfoByID(ctx, in)
defaultHandler.lastGrpcReqError = err
return resp, err
}
抛开本意不谈,这样的写法也是不OK的。。。因为
...