记一次lua io使用不当导致内存泄露问题

1,894 阅读6分钟

背景

小编公司的网关上提供了一个文件上传的接口,最近在与外部对接是发现,当文件超过100m后,大概率会导致上传失败。

分析过程

一、文件大小限制

对应状态码为413,"Request Entity Too Large"错误。

这个问题的出现是因为nginx层有默认的body size阈值限制,默认是1m,可以通过client_max_body_size进行调整。小编这里是这么修改的:

client_max_body_size 500m;

业务上要求能支持最大500m文件上传。

二、客户端超时断开连接

对应状态码为499,"Client Closed Reques"错误。该状态码语义为服务端处理时间长,客户端等不及,主动关闭了连接。

针对这种情况小编也做了相应的timeout调整:

proxy_send_timeout       600s;
proxy_connect_timeout    600s;
proxy_read_timeout       600s;
send_timeout             600s;

当然了,延长超时时间可能带来副作用,小编这里也仅针对文件上传接口做了设置。

三、服务端错误

对应状态码有500,502和504。

500, Internal Server Error , 服务器内部错误,服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。
502,Bad Gateway,网关错误,它往往表示网关从上游服务器中接收到的响应是无效的。
504,Gateway Timeout,网关超时。

出现以上几种错误码时,可能的原因就不那么好分析了。小编这里在上传100m以上文件时大概率会出现以上错误中的一个。以下是一次500错误的错误信息:

2020/09/07 15:21:33 [error] 51#0: *4260 [lua] responses.lua:121: send(): failed to get from node cache: callback threw an error: /usr/local/share/lua/5.1/pgmoon/init.lua:233: attempt to index local 'sock' (a nil value), client: 172.168.0.1, server: kong, request: "POST /obs/zcy.obs.file.upload HTTP/1.1", host: "api.test.com"
172.18.212.3 - - [07/Sep/2020:15:21:33 +0800] api.test.com POST "/obs/zcy.obs.file.upload" "-" 500 54 "-" - - 90.599 - "Apache-HttpClient/4.5.1 (Java/1.8.0_92)" "172.18.212.3" "-" ggtest -
2020/09/07 15:22:44 [error] 48#0: *105700 lua coroutine: memory allocation error: not enough memory
stack traceback:
coroutine 0:
	[C]: in function '(for generator)'
	/usr/local/share/lua/5.1/multipart.lua:43: in function 'decode'
	/usr/local/share/lua/5.1/multipart.lua:115: in function 'Multipart'
	...are/lua/5.1/kong/plugins/hmac-auth-ocean-file/access.lua:273: in function 'init_form_args'
	...are/lua/5.1/kong/plugins/hmac-auth-ocean-file/access.lua:619: in function 'do_authentication'
	...are/lua/5.1/kong/plugins/hmac-auth-ocean-file/access.lua:676: in function 'execute'
	...re/lua/5.1/kong/plugins/hmac-auth-ocean-file/handler.lua:14: in function <...re/lua/5.1/kong/plugins/hmac-auth-ocean-file/handler.lua:12>
coroutine 1:
	[C]: in function 'resume'
	coroutine.wrap:21: in function <coroutine.wrap:21>
	/usr/local/share/lua/5.1/kong/init.lua:379: in function 'access'
	access_by_lua(nginx-kong.conf:94):2: in function <access_by_lua(nginx-kong.conf:94):1>, client: 172.18.212.3, server: kong, request: "POST /obs/zcy.obs.file.upload HTTP/1.1", host: "api.test.com"

从这些日志中可以看出,"memory allocation error: not enough memory"内存不足了。

查看监控系统是发现以下可以情况,上传文件是内存在持续飙升,且内存飙升远大于文件大小

这是为什么呢?

开始填坑

一、内存飙升理论可能性分析

一般情况,kong网关(实际上是nginx)不会出现内存瓶颈。原因是nginx会将超过一定大小的请求缓存成本地文件。

2020/09/08 18:47:56 [warn] 60#0: *127418 a client request body is buffered to a temporary file /usr/local/kong/client_body_temp/0000000009, client: 172.18.212.3, server: kong, request: "POST /obs/zcy.obs.file.upload HTTP/1.1", host: "api.test.com"

这个也可以通过配置项进行调整:

请求体的size大于nginx配置里的client_body_buffer_size,则会导致请求体被缓冲到磁盘临时文件里,client_body_buffer_size默认是8k或者16k

我们上传的文件远大于这个buffer值,所以会生成本地缓存文件,即理论上不会出现内存持续飙升的问题。

想明白以上问题,小编开始猜测是kong网关上处理文件上传的签名插件逻辑除了问题。

二、签名插件逻辑大bug

小编这里的签名插件是用lua脚本写的,核心片段如下:

--这里必须使用'Content-Type',首字符大写
        if string.sub(receive_headers[CONTENT_TYPE], 1, 20) == "multipart/form-data;" then
            --判断是否是multipart/form-data类型的表单
            is_have_file_param = true
            local body_data = ngx.req.get_body_data()
            if not body_data then
                local datafile = ngx.req.get_body_file()
                ngx.log(ngx.ERR, "body is in file: ", tostring(datafile))

                if not datafile then
                    error_code = 1
                    error_msg = "no request body found"
                else
                    local fh, err = io.open(datafile, "r")
                    if not fh then
                        error_code = 2
                        error_msg = "failed to open " .. tostring(datafile) .. "for reading: " .. tostring(err)
                    else
                        fh:seek("set")
                        body_data = fh:read("*a")
                        fh:close()
                        if body_data == "" then
                            error_code = 3
                            error_msg = "request body is empty"
                        end
                    end
                end
            end

关键代码是以下两行:

fh:seek("set")  --设置文件从头开始读取
body_data = fh:read("*a")  --读取全部内容

什么?读取缓存文件所有内容?!

聪明的你可能已经意识到了,这里是文件啊,将文件所有内容读取出来,这不就要占用大量的内存了吗?

是的,问题就在这里了。

三、历史原因回溯

为什么这里要读取文件所有内容呢?小编分析这里本意并非读取所有内容,而是要获取post请求的request body data。我们知道,通过lua脚本有时是无法直接读取到request中的body data数据的(回看《lua脚本获取request body》)。正应为请求的body内容被缓存在了文件当中,我们才不得不去加载文件,解析出响应的数据。

解决方案

原因已经找到,接下来看怎么解决该问题。

我们来看下缓存文件中的内容是什么:

------ZcyOpenBoundaryj5pjAaN060Reqm05
Content-Disposition: form-data; name="_data_"
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

{"fileName":"nohup.out","bizCode":"1071"}
------ZcyOpenBoundaryj5pjAaN060Reqm05
Content-Disposition: form-data; name="file"; filename="deaultFilename"
Content-Type: application/octet-stream

... 省略内容 ...

实际上,小编这签名插件要获取的就是这部分内容,key=_data_,对应的value={"fileName":"nohup.out","bizCode":"1071"}。为了满足签名算法,这里需要将这部分内容获取到,包装成lua table。

换个思路实现:

--这里必须使用'Content-Type',首字符大写
        if string.sub(receive_headers[CONTENT_TYPE], 1, 20) == "multipart/form-data;" then
            --判断是否是multipart/form-data类型的表单
            is_have_file_param = true
            local body_data = ngx.req.get_body_data()
            if not body_data then
                local datafile = ngx.req.get_body_file()
                ngx.log(ngx.ERR, "body is in file: ", tostring(datafile))

                if not datafile then
                    error_code = 1
                    error_msg = "no request body found"
                else
                    local fh, err = io.open(datafile, "r")
                    if not fh then
                        error_code = 2
                        error_msg = "failed to open " .. tostring(datafile) .. "for reading: " .. tostring(err)
                    else
                        fh:seek("set")
                        local i = 0
                        local continue_flag = false
                        for line in fh:lines() do
                            local match_result = string.match(line, "_data_")
                            if match_result then
                                continue_flag = true
                            end
                            if continue_flag then
                                i = i + 1
                            end
                            if i==5 then
                                local position = string.find(line, "\r");
                                local body_data_str = string.sub(line, 1, position-1)
                                args["_data_"] = unescape(body_data_str)
                                break
                            end
                        end
                        fh:close()
                    end
                end
            end

核心代码:

local i = 0
                        local continue_flag = false
                        for line in fh:lines() do
                            local match_result = string.match(line, "_data_")
                            if match_result then
                                continue_flag = true
                            end
                            if continue_flag then
                                i = i + 1
                            end
                            if i==5 then
                                local position = string.find(line, "\r");
                                local body_data_str = string.sub(line, 1, position-1)
                                args["_data_"] = unescape(body_data_str)
                                break
                            end
                        end

这里小编把读取文件的方法改成了fh:lines(),即逐行读取内容。当获取到_data_的value后即结束文件读取。实际上通过这种方式紧紧需要内存中加载极少的内容即可。

效果立现

插件代码优化后,再来上传300m文件试试,轻松搞定了,而且内存也几乎没有波动。

总结

网关平台在处理小文件上传时问题不明显,但当处理超大文件时问题即可显露出来,作为程序员,需要保持对问题的敬畏之心,努力找出问题的根源,才能从源头将问题解决掉。