0%

Gin获取Response Body引发的OOM

有轮子尽量用轮子 :sob: :sob: :sob: :sob: :sob: :sob:

我们在开发中基于Gin开发了一个Api网关,但上线后发现内存会在短时间内暴涨,然后被OOM kill掉。具体内存走势如下图:

放大其中一次

在图二中可以看到内存的增长是很快的,在一分半的时间内,内存增长了近2G。

对于这种内存短时间暴涨的问题,pprof不太分析,除非写个脚本定时去pprof

经过再次review代码,找到了原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package server

import (
"bytes"
"fmt"

"github.com/gin-gonic/gin"
jsoniter "github.com/json-iterator/go"
)

var json = jsoniter.ConfigCompatibleWithStandardLibrary

type BodyDumpResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}

func (w *BodyDumpResponseWriter) Write(b []byte) (int, error) {
w.body.Write(b) // 注意这一行
return w.ResponseWriter.Write(b)
}

func ReadResponseBody(ctx *gin.Context) {
rbw := &BodyDumpResponseWriter{body: &bytes.Buffer{}, ResponseWriter: ctx.Writer}
ctx.Writer = rbw

ctx.Next()

rawResp := rbw.body.String()
if len(rawResp) == 0 {
AbnormalPrint(ctx, "resp-empty", rawResp)
return
}
ctx.Set(ctx_raw_response_body, rawResp)

// 序列化Body,并放到ctx中
// 读取响应Body的目的是记录审计日志用
}

// AbnormalPrint 异常情况,打印信息到日志
func AbnormalPrint(ctx *gin.Context, typ string, rawResp string) {
// 具体代码忽略
}

简单一看,这不就是Gin获取响应体一种标准的方式吗?毕竟GitHub及Stack Overflow上都是这么写的
https://github.com/gin-gonic/gin/issues/1363
https://stackoverflow.com/questions/38501325/how-to-log-response-body-in-gin

那么问题出在哪呢?

再看下代码,可以看到这个代码的逻辑是每一个请求都会将响应的Body完整的缓存在内存一份,对于响应体很大的请求,在这里就会造成内存暴涨,比如:像日志下载。

找到了原因修改起来就比较简单了,根据请求响应的Header跳过文件下载类的请求;同时根据请求的Header跳过SSE及Websocket请求,因为这两类流的请求记录到审计日志中意义不大,而且在json序列化的时候也会有问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
package server

import (
"bytes"
"fmt"
"net/http"
"strings"

"github.com/gin-gonic/gin"
jsoniter "github.com/json-iterator/go"
)

var json = jsoniter.ConfigCompatibleWithStandardLibrary

type BodyDumpResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}

func (w *BodyDumpResponseWriter) Write(b []byte) (int, error) {
// 文件下载类请求,不再缓存相应结果
if !isFileDownLoad(w.Header()) {
w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}

func isNoNeedToReadResponse(req *http.Request) bool {
if isSSE(req) || isWebsocket(req) {
return true
}
return false
}

func isSSE(req *http.Request) bool {
contentType := req.Header.Get("Accept")
if contentType == "" {
contentType = req.Header.Get("accept")
}
contentType = strings.ToLower(contentType)
// sse
if !strings.Contains(contentType, "text/event-stream") {
return false
}
return true
}

// https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Basics_of_HTTP/MIME_types
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
func isFileDownLoad(responseHeader http.Header) bool {
contentType := strings.ToLower(responseHeader.Get("Content-Type"))
if strings.Contains(contentType, "application/octet-stream") {
return true
}
contentDisposition := responseHeader.Get("Content-Disposition")
if contentDisposition != "" {
return true
}
return false
}

func isWebsocket(req *http.Request) bool {
conntype := strings.ToLower(req.Header.Get("Connection"))
upgrade := strings.ToLower(req.Header.Get("Upgrade"))
if conntype == "upgrade" && upgrade == "websocket" {
return true
}
return false
}

func ReadResponseBody(ctx *gin.Context) {

if isNoNeedToReadResponse(ctx.Request) {
return
}

rbw := &BodyDumpResponseWriter{body: &bytes.Buffer{}, ResponseWriter: ctx.Writer}
ctx.Writer = rbw

ctx.Next()

contentType := ctx.Writer.Header().Get("content-type")
if !strings.Contains(contentType, "application/json") {
return
}

rawResp := rbw.body.String()
if len(rawResp) == 0 {
AbnormalPrint(ctx, "resp-empty", rawResp)
return
}
ctx.Set(ctx_raw_response_body, rawResp)

// 序列化Body,并放到ctx中
// 读取响应Body的目的是记录审计日志用
}

// AbnormalPrint 异常情况,打印信息到日志
func AbnormalPrint(ctx *gin.Context, typ string, rawResp string) {
// 具体代码忽略
}

其实,写这篇文章的目的并不是为了阐述这个问题如何解决,而是想说:

  • Copy 代码的时候需要留意下自己的场景
  • 尽量用轮子,而不是自己去造轮子

在我们手写API网关的时候,还遇到过以下问题

  • 第一版的网络处理也是手写的,导致对于各种Content-Type处理不好;
  • 因为要解析Body,也没有精力去适配各种压缩协议,所以在网关这里会强制关闭压缩;
  • 手写网络处理,会出现一些诡异的问题
    • 比如:我们支持页面终端连接到K8S集群,而这个终端连接走的是Websocket,假设支持该连接操作的服务是A(就是:页面< - - - - - - >网关< - - - - - - >服务A< - - - - - - >K8S集群),那么后面过网关的请求部分请求会直接请求到服务A上(此时根本没有走网关的API router,直接就复用Websocket这个连接了),即使这些API不是服务A的。

第一版手写网络请求处理的代码示意如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
func proxyHttp(ctx context.Context, proxy_req *http.Request, domain string) {
// origin request
req := ctx.Request()

response, err := HttpClient.Do(proxy_req)
if err != nil {
// 打印异常
return
}

defer response.Body.Close()

//copy response header
if response != nil && response.Header != nil {
for k, values := range response.Header {
for _, value := range values {
ctx.ResponseWriter().Header().Set(k, value)
}
}
}
// status code
ctx.StatusCode(response.StatusCode)
buf := make([]byte, 1024)

for {
len, err := response.Body.Read(buf)
if err != nil && err != io.EOF {
// 打印异常
break
}
if len == 0 {
break
}

ctx.ResponseWriter().Write(buf[:len])
ctx.ResponseWriter().Flush()
continue

}
ctx.Next()
}

func proxyWebSocket(ctx context.Context, request *http.Request, target string) {
var logger = ctx.Application().Logger()
responseWriter := http.ResponseWriter(ctx.ResponseWriter())

conn, err := net.Dial("tcp", target)
if err != nil {
// 打印异常
return
}
hijacker, ok := responseWriter.(http.Hijacker)
if !ok {
http.Error(responseWriter, "Not a hijacker?", 500)
return
}

nc, _, err := hijacker.Hijack()
if err != nil {
// 打印异常
return
}
defer nc.Close()
defer conn.Close()

err = request.Write(conn)
if err != nil {
// 打印异常
return
}

errc := make(chan error, 2)
cp := func(dst io.Writer, src io.Reader) {
_, err := io.Copy(dst, src)
errc <- err
}
go cp(conn, nc)
go cp(nc, conn)

// wait over
<-errc

ctx.Application().Logger().Infof("websocket proxy to %s over", target)
}

后来换成了基础类库的httputil.ReverseProxy来处理网络连接,问题解决。

欢迎关注我的其它发布渠道