Nginx 反向代理 WebSocket 和 SSE 的踩坑
項目上了 Nginx 反向代理之后,HTTP 接口全部正常,WebSocket 卻連不上,SSE 推送也收不到消息。控制臺沒有報錯,Network 面板看著像是連上了,數據就是不過來。先給結論:WebSocket 和 SSE 都不是標準的 HTTP 請求-響應模型,Nginx 默認配置會把它們當成普通 HTTP 處理,要么握手失敗,要么連接被提前關掉。 兩者的解法不同,不能混為一談。
WebSocket 反向代理:三行配置解決 90% 的問題
最小可用配置只需要三行,把 Upgrade 和 Connection 頭透傳給后端:
location /ws {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
proxy_http_version 1.1 這行容易被忽略。Nginx 代理后端時默認用 HTTP/1.0,而 HTTP/1.0 壓根不支持 Upgrade 機制,所以必須顯式聲明。$http_upgrade 是 Nginx 內置變量,值就是客戶端發來的 Upgrade 頭內容。
配好之后可以用 wscat 快速驗證:通過 Nginx 代理地址連接 wscat -c ws://your-domain.com/ws,能發消息能收消息就說明配置生效了。
超時問題:連上了但過一會兒自動斷
大部分人配好 WebSocket 之后會遇到第二個坑——連接建立了,過 60 秒沒有數據傳輸就自動斷開。
原因是 Nginx 的 proxy_read_timeout 默認 60 秒。對普通 HTTP 請求來說,60 秒沒響應大概率是后端掛了,斷開合理。但 WebSocket 連接可能幾分鐘才有一次消息,60 秒的超時就太短了。一個直接的做法是把 proxy_read_timeout 和 proxy_send_timeout 調到 3600 秒,但這不是最優解。更靠譜的做法是讓應用層做心跳保活——WebSocket 協議本身支持 Ping/Pong 幀,服務端每 30 秒發一個 ws.ping(),超時計時器就會被重置。這樣 proxy_read_timeout 保持默認 60 秒都行,還能及時檢測到真正的死連接。無腦調大超時反而會讓死連接長時間占用資源。
下面是 Node.js 服務端心跳的核心邏輯,每 30 秒向所有活躍連接發送協議級 Ping 幀,客戶端會自動回復 Pong,Nginx 感知到數據傳輸就不會斷連:
const wss = new WebSocket.Server({ port: 3000 });
wss.on('connection', (ws) => {
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) ws.ping();
}, 30000);
ws.on('close', () => clearInterval(heartbeat));
});
Connection 頭的條件判斷
有些教程把 Connection 頭寫死為 "upgrade",如果這個 location 只處理 WebSocket 請求沒問題。但如果普通 HTTP 和 WebSocket 請求共用同一個路徑前綴,寫死就容易出事——我在一個項目里踩過這個坑,前端 fetch 請求和 WebSocket 用了同一個路徑前綴 /api,寫死 Connection "upgrade" 導致普通接口偶爾返回 502。
解決方案是在 http 塊里用 map 做條件判斷:當客戶端請求攜帶 Upgrade 頭時,Connection 設為 upgrade;普通請求沒有該頭,則回退為 close。這樣同一個 location 就能同時服務 WebSocket 和普通 HTTP 請求:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
SSE 反向代理:關緩沖、關壓縮、調超時
SSE 看起來比 WebSocket 簡單——畢竟就是個長連接的 HTTP 響應,不涉及協議升級。但 Nginx 對 SSE 的干擾點更多,也更隱蔽。
第一坑:proxy_buffering 吃掉實時性
這是 SSE 最常見的坑。Nginx 默認開啟 proxy_buffering,會把后端的響應數據攢到緩沖區,攢夠一定量(默認 4K 或 8K,取決于系統頁大小)才發給客戶端。普通接口無所謂,SSE 要的就是"服務端寫一條、客戶端立刻收到一條",緩沖直接破壞了實時性。
表現很有迷惑性:連接建立成功,后端日志顯示事件已發送,但前端 EventSource 的 onmessage 遲遲不觸發,過幾秒突然一口氣收到一堆消息。排查時抓包看 Nginx 到客戶端的響應,會發現數據是批量到達的而非逐條到達。
解法很簡單,在 SSE 的 location 里關閉代理緩沖,同時關閉 proxy_cache 防止響應被緩存:
location /sse {
proxy_pass http://backend:3000;
proxy_buffering off;
proxy_cache off;
}
第二坑:gzip 壓縮阻塞數據流
如果全局開了 gzip on,SSE 的數據流也會被壓縮。gzip 算法需要攢夠一定量的數據才能輸出一個壓縮塊,效果和 proxy_buffering 一樣——消息被攢著了。
這個坑隱蔽得很。我曾經在一個內部監控系統(Nginx 1.22 + Node.js 18)上排查 SSE 延遲,proxy_buffering 早就關了,后端日志確認消息已發出,但前端就是 3-5 秒才收到一批。翻了大半天配置,最后發現是全局 gzip on 藏在一個 include 的公共配置文件里。SSE 消息通常很短,幾十到幾百字節,壓縮收益幾乎為零,延遲代價卻很大。在 SSE 的 location 里加一行 gzip off 就解決了。
第三坑:超時斷連
SSE 和 WebSocket 一樣面臨超時問題。服務端長時間沒有事件要推,Nginx 的 proxy_read_timeout 到了就會斷開連接。配置思路類似——可以調大超時,也可以讓服務端定時發心跳注釋。
SSE 協議規范里約定以冒號開頭的行是注釋,客戶端的 EventSource 不會觸發 onmessage,天然適合做保活。
app.get('/sse', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
req.on('close', () => clearInterval(heartbeat));
});
把上面三個坑點的配置合在一起,就是 SSE 完整的 Nginx 配置。
location /sse {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
gzip off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
}
生產環境的邊界情況
連接數限制
每個 WebSocket/SSE 連接都占用一個文件描述符。Nginx 的 worker_connections 默認值是 1024,同時在線 500 個 WebSocket 用戶就可能打滿(Nginx 自身也需要連接對接后端,一個客戶端連接對應一個上游連接,實際容量要折半)。
系統層面需要同步調整,否則 Nginx 配置再大也會被 OS 限制擋住。worker_rlimit_nofile 控制 Nginx worker 進程的文件描述符上限,需要大于等于 worker_connections。系統級的 ulimit 也必須配合調高,否則 Nginx 啟動時拿不到足夠的文件描述符:
# nginx.conf 主配置
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 65535;
multi_accept on;
}
系統級文件描述符限制需要在 /etc/security/limits.conf 中設置,確保 Nginx 進程用戶有足夠的配額:
nginx soft nofile 65535
nginx hard nofile 65535
改完后用 ulimit -n 確認生效,再 nginx -s reload。可以通過 cat /proc/<nginx_worker_pid>/limits 驗證 worker 進程實際拿到的限制值。
多層代理的頭丟失
生產環境經常不止一層代理:客戶端 → CDN/SLB → Nginx → 后端。經過多層轉發,Upgrade、Connection 這些逐跳(hop-by-hop)頭會被中間層剝掉,WebSocket 握手到了 Nginx 時已經丟失了關鍵頭信息,后端收到的是一個普通 HTTP 請求。
表現為:開發環境直連 Nginx 一切正常,上了生產經過負載均衡器就連不上 WebSocket,返回 400 或 502。
解法分兩步。第一步,確認前置代理(SLB/CDN)支持 WebSocket 透傳并開啟了相關選項,阿里云 SLB 需要在監聽配置里勾選"開啟 WebSocket",AWS ALB 原生支持但 CLB 需要用 TCP 監聽。第二步,在 Nginx 層用 proxy_set_header 顯式補上可能丟失的頭,而不是依賴客戶端傳過來的值:
location /ws {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade websocket;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
注意這里 Upgrade 直接寫死 websocket 而不是用 $http_upgrade 變量,因為變量值可能已經被前置代理清空了。
Nginx reload 斷連接
執行 nginx -s reload 時,Nginx 會優雅關閉舊的 worker 進程。
如果沒配 worker_shutdown_timeout,舊 worker 會一直等,直到所有長連接自然斷開,導致 reload 后系統里同時跑著新舊兩套 worker,內存持續上漲。配了超時(比如 30 秒),reload 時所有 WebSocket/SSE 用戶會在 30 秒后被強制斷開。
兩種策略都不完美,實際操作中建議:
# nginx.conf 主配置
worker_shutdown_timeout 60s;
把超時設為 60 秒,給正在傳輸數據的連接留夠緩沖時間。同時客戶端必須實現自動重連——WebSocket 用庫自帶的 reconnect 機制,SSE 的 EventSource 本身就有自動重連能力(斷開后默認 3 秒重試)。這樣 reload 造成的斷連對用戶來說只是一次短暫的閃斷,幾秒后自動恢復。在需要頻繁改配置的場景下,可以考慮用 upstream 的灰度策略,先切走流量再 reload,徹底避免斷連。
各配置項速查
| 配置項 | WebSocket | SSE | 默認值 | 建議值 |
|---|
proxy_http_version | 1.1(必須) | 1.1(推薦) | 1.0 | 1.1 |
proxy_set_header Upgrade | $http_upgrade | 不需要 | — | — |
proxy_set_header Connection | $connection_upgrade | '' | — | — |
proxy_buffering | 默認即可 | off(必須) | on | — |
gzip | 默認即可 | off(必須) | on | — |
proxy_read_timeout | 心跳間隔×2 | 心跳間隔×2 | 60s | 60-3600s |
worker_connections | 按最大連接數設 | 按最大連接數設 | 1024 | 65535 |
worker_shutdown_timeout | 建議設置 | 建議設置 | 無限制 | 60s |