事情的起因是这样的。我的一个Golang服务通过docker部署在了线上环境中,但是该服务经常出现内存爆满导致容器自动被kill而退出的现象。因为是线上环境,排查问题会耗时很长,因此先在线上服务的容器上加了个–restart=always的参数,这样可以让容器退出后自动重启,继续进行任务。
但在排查内存爆满问题时,线上环境有严格的网络限制,较难用golang的pprof工具进行内存分析,因此将其转移到本地环境中进行事故模拟。一次内存泄漏的排查便从此开始展开。
在本地运行时,我首先模拟了docker环境,通过docker stats 命令实时观测其内存变化,分别测试了发送http请求成功和发送http请求失败的两种情况,发现在http请求失败时内存不会增加,但http请求成功时,内存会逐渐增加,初步推断可能是http请求时出现的问题。
根据第一步中的结果,初步推断可能是在http请求时生成了大量对象,但GC清理速度较慢,导致内存溢出的问题出现,因此,我在代码中加入了一个goroutine,用于实时监测内存状态,发现堆内存占用超过300MB时,则进行runtime.GC()清理。
加入runtime.GC()后,的确起到了一些效果,但效果不佳,通过docker stats命令观测其内存变化从 200MB 到 600 MB 之间不断变化,初步认为问题已经解决。
但容器在运行了一段时间后,又出现了自动退出的情况,且当时的内存占用仅为600MB左右,并未超过设定阈值 1 GB,问题仍需排查。
由于docker容器自动退出时,很多runtime的堆栈日志例如fatal错误不会输出到控制台,并且在docker容器内将这种错误进行导出的配置也相当麻烦,因此我把服务从容器中拿出来,直接在本地运行,观测其发生的现象。
在本地运行时,不观测不知道,一观测吓一跳!
在高并发下,起初程序运行并没有问题,但是通过top命令观测其内存变化,竟然逐渐增大至高达1.5GB,并且runtime.GC()并不其任何作用,说明程序中有大量内存无法被GC清理,说明肯定有很多对象的引用一直存在。
并且在本地运行时,还观测到,日志报错中出现了:
An operation on a socket could not be performed because the system lacked sufficient buffer space or because a queue was full.
的错误。
该错误表明,大量的http请求连接仍然处于keep-alive状态,并没有被消灭。那么问题出在什么地方呢?
我的源代码是这样的:(示例代码。已将源代码敏感信息删去)
func httpSend(dto *Dto, payload *bytes.Reader) error {
response, err := http.Post(url, "application/json", payload)
if err != nil {
log.Printf("ERROR: %s\n", err)
return err
}
log.Printf("HTTP post success, response.StatusCode is:%d\n", response.StatusCode)
if response.StatusCode == http.StatusOK {
log.Println("HTTP post success, response code is HTTP.StatusOK")
return nil
} else {
return errors.New("send failed")
}
}
看了半天没错呀,难道是response没有关闭?
我试着找了下response的方法,发现并没有Close()这个方法。
我百思不得其解,到底是为什么呢?
此时,不经意间看到的其他的代码令我恍然大悟:
原来是response.Body没有关闭!!!
response.Body是属于 io.ReadCloser 对象,该对象会占用文件描述符socket,当该对象不被主动关闭时,随着时间的推移,肯定会造成系统中socket数量不够用,进而导致内存泄漏,容器崩溃,甚至服务器卡死的情况!
至此,一次golang中内存泄漏的问题排查到此结束,原来是在http请求时,没有主动关闭返回体中的response.Body导致了socket文件被大量占用,进而导致内存泄漏的问题出现。
根据此次问题排查,以后在每一次http请求时,不管response.Body的内容是否被使用,只要是使用了http请求,请求的返回体都需要主动关闭!!!否则就会造成严重的内存泄漏,甚至服务器卡死!