運(yùn)維排障記:一次百萬長(zhǎng)連接壓測(cè)Nginx內(nèi)存溢出問題
在最近的一次百萬長(zhǎng)連接壓測(cè)中,32C 128G 的四臺(tái) Nginx 頻繁出現(xiàn) OOM,出現(xiàn)問題時(shí)的內(nèi)存監(jiān)控如下所示。
排查的過程記錄如下。
現(xiàn)象描述
這是一個(gè) websocket 百萬長(zhǎng)連接收發(fā)消息的壓測(cè)環(huán)境,客戶端 jmeter 用了上百臺(tái)機(jī)器,經(jīng)過四臺(tái) Nginx 到后端服務(wù),簡(jiǎn)化后的部署結(jié)構(gòu)如下圖所示。
在維持百萬連接不發(fā)數(shù)據(jù)時(shí),一切正常,Nginx 內(nèi)存穩(wěn)定。在開始大量收發(fā)數(shù)據(jù)時(shí),Nginx 內(nèi)存開始以每秒上百 M 的內(nèi)存增長(zhǎng),直到占用內(nèi)存接近 128G,woker 進(jìn)程開始頻繁 OOM 被系統(tǒng)殺掉。32 個(gè) worker 進(jìn)程每個(gè)都占用接近 4G 的內(nèi)存。dmesg -T 的輸出如下所示。
- [Fri Mar 13 18:46:44 2020] Out of memory: Kill process 28258 (nginx) score 30 or sacrifice child
- [Fri Mar 13 18:46:44 2020] Killed process 28258 (nginx) total-vm:1092198764kB, anon-rss:3943668kB, file-rss:736kB, shmem-rss:4kB
work 進(jìn)程重啟后,大量長(zhǎng)連接斷連,壓測(cè)就沒法繼續(xù)增加數(shù)據(jù)量。
排查過程分析
拿到這個(gè)問題,首先查看了 Nginx 和客戶端兩端的網(wǎng)絡(luò)連接狀態(tài),使用 ss -nt 命令可以在 Nginx 看到大量 ESTABLISH 狀態(tài)連接的 Send-Q 堆積很大,客戶端的 Recv-Q 堆積很大。Nginx 端的 ss 部分輸出如下所示。
- State Recv-Q Send-Q Local Address:Port Peer Address:Port
- ESTAB 0 792024 1.1.1.1:80 2.2.2.2:50664
- ...
在 jmeter 客戶端抓包偶爾可以看到較多零窗口,如下所示。
到了這里有了一些基本的方向,首先懷疑的就是 jmeter 客戶端處理能力有限,有較多消息堆積在中轉(zhuǎn)的 Nginx 這里。
為了驗(yàn)證想法,想辦法 dump 一下 nginx 的內(nèi)存看看。因?yàn)樵诤笃趦?nèi)存占用較高的狀況下,dump 內(nèi)存很容易失敗,這里在內(nèi)存剛開始上漲沒多久的時(shí)候開始 dump。
首先使用 pmap 查看其中任意一個(gè) worker 進(jìn)程的內(nèi)存分布,這里是 4199,使用 pmap 命令的輸出如下所示。
- pmap -x 4199 | sort -k 3 -n -r
- 00007f2340539000 475240 461696 461696 rw--- [ anon ]
- ...
隨后使用 cat /proc/4199/smaps | grep 7f2340539000 查找某一段內(nèi)存的起始和結(jié)束地址,如下所示。
- cat /proc/3492/smaps | grep 7f2340539000
- 7f2340539000-7f235d553000 rw-p 00000000 00:00 0
隨后使用 gdb 連上這個(gè)進(jìn)程,dump 出這一段內(nèi)存。
- gdb -pid 4199
- dump memory memory.dump 0x7f2340539000 0x7f235d553000
隨后使用 strings 命令查看這個(gè) dump 文件的可讀字符串內(nèi)容,可以看到是大量的請(qǐng)求和響應(yīng)內(nèi)容。
這樣堅(jiān)定了是因?yàn)榫彺媪舜罅康南?dǎo)致的內(nèi)存上漲。隨后看了一下 Nginx 的參數(shù)配置,
- location / {
- proxy_pass http://xxx;
- proxy_set_header X-Forwarded-Url "$scheme://$host$request_uri";
- proxy_redirect off;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Cookie $http_cookie;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- client_max_body_size 512M;
- client_body_buffer_size 64M;
- proxy_connect_timeout 900;
- proxy_send_timeout 900;
- proxy_read_timeout 900;
- proxy_buffer_size 64M;
- proxy_buffers 64 16M;
- proxy_busy_buffers_size 256M;
- proxy_temp_file_write_size 512M;
- }
可以看到 proxy_buffers 這個(gè)值設(shè)置的特別大。接下來我們來模擬一下,upstream 上下游收發(fā)速度不一致對(duì) Nginx 內(nèi)存占用的影響。
模擬 Nginx 內(nèi)存上漲
我這里模擬的是緩慢收包的客戶端,另外一邊是一個(gè)資源充沛的后端服務(wù)端,然后觀察 Nginx 的內(nèi)存會(huì)不會(huì)有什么變化。
緩慢收包客戶端是用 golang 寫的,用 TCP 模擬 HTTP 請(qǐng)求發(fā)送,代碼如下所示。
- package main
- import (
- "bufio"
- "fmt"
- "net"
- "time"
- )
- func main() {
- conn, _ := net.Dial("tcp", "10.211.55.10:80")
- text := "GET /demo.mp4 HTTP/1.1rnHost: ya.test.mernrn"
- fmt.Fprintf(conn, text)
- for ; ; {
- _, _ = bufio.NewReader(conn).ReadByte()
- time.Sleep(time.Second * 3)
- println("read one byte")
- }
- }
在測(cè)試 Nginx 上開啟 pidstat 監(jiān)控內(nèi)存變化
- pidstat -p pid -r 1 1000
運(yùn)行上面的 golang 代碼,Nginx worker 進(jìn)程的內(nèi)存變化如下所示。
04:12:13 是 golang 程序啟動(dòng)的時(shí)間,可以看到在很短的時(shí)間內(nèi),Nginx 的內(nèi)存占用就漲到了 464136 kB(接近 450M),且會(huì)維持很長(zhǎng)一段時(shí)間。
同時(shí)值得注意的是,proxy_buffers 的設(shè)置大小是針對(duì)單個(gè)連接而言的,如果有多個(gè)連接發(fā)過來,內(nèi)存占用會(huì)繼續(xù)增長(zhǎng)。下面是同時(shí)運(yùn)行兩個(gè) golang 進(jìn)程對(duì) Nginx 內(nèi)存影響的結(jié)果。
可以看到兩個(gè)慢速客戶端連接上來的時(shí)候,內(nèi)存已經(jīng)漲到了 900 多 M。
解決方案
因?yàn)橐С稚习偃f的連接,針對(duì)單個(gè)連接的資源配額要小心又小心。一個(gè)最快改動(dòng)方式是把 proxy_buffering 設(shè)置為 off,如下所示。
- proxy_buffering off;
經(jīng)過實(shí)測(cè),在壓測(cè)環(huán)境修改了這個(gè)值以后,以及調(diào)小了 proxy_buffer_size 的值以后,內(nèi)存穩(wěn)定在了 20G 左右,沒有再飆升過,內(nèi)存占用截圖如下所示。
在測(cè)試環(huán)境重復(fù)剛才的測(cè)試,結(jié)果如下所示。
可以看到這次內(nèi)存值增長(zhǎng)了 64M 左右。為什么是增長(zhǎng) 64M 呢?來看看 proxy_buffering 的 Nginx 文檔(nginx.org/en/docs/htt…
When buffering is enabled, nginx receives a response from the proxied server as soon as possible, saving it into the buffers set by the proxy_buffer_size and proxy_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the proxy_max_temp_file_size and proxy_temp_file_write_size directives.
When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the proxied server. The maximum size of the data that nginx can receive from the server at a time is set by the proxy_buffer_size directive.
可以看到,當(dāng) proxy_buffering 處于 on 狀態(tài)時(shí),Nginx 會(huì)盡可能多的將后端服務(wù)器返回的內(nèi)容接收并存儲(chǔ)到自己的緩沖區(qū)中,這個(gè)緩沖區(qū)的最大大小是 proxy_buffer_size * proxy_buffers 的內(nèi)存。
如果后端返回的消息很大,這些內(nèi)存都放不下,會(huì)被放入到磁盤文件中。臨時(shí)文件由 proxy_max_temp_file_size 和 proxy_temp_file_write_size 這兩個(gè)指令決定的,這里不展開。
當(dāng) proxy_buffering 處于 off 狀態(tài)時(shí),Nginx 不會(huì)盡可能的多的從代理 server 中讀數(shù)據(jù),而是一次最多讀 proxy_buffer_size 大小的數(shù)據(jù)發(fā)送給客戶端。
Nginx 的 buffering 機(jī)制設(shè)計(jì)的初衷確實(shí)是為了解決收發(fā)兩端速度不一致問題的,沒有 buffering 的情況下,數(shù)據(jù)會(huì)直接從后端服務(wù)轉(zhuǎn)發(fā)到客戶端,如果客戶端的接收速度足夠快,buffering 完全可以關(guān)掉。但是這個(gè)初衷在海量連接的情況下,資源的消耗需要同時(shí)考慮進(jìn)來,如果有人故意偽造比較慢的客戶端,可以使用很小的代價(jià)消耗服務(wù)器上很大的資源。
其實(shí)這是一個(gè)非阻塞編程中的典型問題,接收數(shù)據(jù)不會(huì)阻塞發(fā)送數(shù)據(jù),發(fā)送數(shù)據(jù)不會(huì)阻塞接收數(shù)據(jù)。如果 Nginx 的兩端收發(fā)數(shù)據(jù)速度不對(duì)等,緩沖區(qū)設(shè)置得又過大,就會(huì)出問題了。
Nginx 源碼分析
讀取后端的響應(yīng)寫入本地緩沖區(qū)的源碼在 src/event/ngx_event_pipe.c 中的 ngx_event_pipe_read_upstream 方法中。這個(gè)方法最終會(huì)調(diào)用 ngx_create_temp_buf 創(chuàng)建內(nèi)存緩沖區(qū)。創(chuàng)建的次數(shù)和每次緩沖區(qū)的大小由 p->bufs.num(緩沖區(qū)個(gè)數(shù)) 和 p->bufs.size(每個(gè)緩沖區(qū)的大?。Q定,這兩個(gè)值就是我們?cè)谂渲梦募兄付ǖ?proxy_buffers 的參數(shù)值。這部分源碼如下所示。
- static ngx_int_t
- ngx_event_pipe_read_upstream(ngx_event_pipe_t *p)
- {
- for ( ;; ) {
- if (p->free_raw_bufs) {
- // ...
- } else if (p->allocated < p->bufs.num) { // p->allocated 目前已分配的緩沖區(qū)個(gè)數(shù),p->bufs.num 緩沖區(qū)個(gè)數(shù)最大大小
- /* allocate a new buf if it's still allowed */
- b = ngx_create_temp_buf(p->pool, p->bufs.size); // 創(chuàng)建大小為 p->bufs.size 的緩沖區(qū)
- if (b == NULL) {
- return NGX_ABORT;
- }
- p->allocated++;
- }
- }
- }
Nginx 源碼調(diào)試的界面如下所示。
后記
還有過程中一些輔助的判斷方法,比如通過 strace、systemtap 工具跟蹤內(nèi)存的分配、釋放過程,這里沒有展開,這些工具是分析黑盒程序的神器。
除此之外,在這次壓測(cè)過程中還發(fā)現(xiàn)了 worker_connections 參數(shù)設(shè)置不合理導(dǎo)致 Nginx 啟動(dòng)完就占了 14G 內(nèi)存等問題,這些問題在沒有海量連接的情況下是比較難發(fā)現(xiàn)的。
最后,底層原理是必備技能,調(diào)參是門藝術(shù)。上面說的內(nèi)容可能都是錯(cuò)的,看看排查思路就好。