Vấn Đề
Traffic HTTP thông thường chạy qua Nginx reverse proxy của bạn mà không gặp trở ngại gì. Nhưng khi client thử mở kết nối WebSocket — nó thất bại ngay lập tức. Console của trình duyệt hiển thị lỗi 400 Bad Request hoặc WebSocket handshake bị treo, trong khi Nginx ghi log như sau:
2024/01/15 10:23:41 [error] 1234#1234: *56 upstream prematurely closed connection while reading response header from upstream, client: 203.0.113.10, server: example.com, request: "GET /ws HTTP/1.1", upstream: "http://127.0.0.1:3000/ws"
Backend của bạn — dù là server Node.js/Socket.io, dịch vụ WebSocket Go, hay ứng dụng Python websockets — đều chấp nhận kết nối trực tiếp tốt. Nginx mới là thủ phạm, không phải backend của bạn.
Nguyên Nhân Gốc Rễ
Kết nối WebSocket bắt đầu bằng một request HTTP/1.1 tiêu chuẩn mang theo hai header quan trọng:
Upgrade: websocket— báo cho server biết client muốn chuyển đổi giao thứcConnection: Upgrade— báo hiệu rằng headerUpgradephải được xử lý
Nginx âm thầm loại bỏ những header này trước khi chuyển tiếp lên upstream. Chúng được gọi là hop-by-hop headers, và Nginx bỏ chúng theo thiết kế. Server upstream nhận được một request GET thông thường hoàn toàn không có upgrade headers. Nó không biết rằng có yêu cầu chuyển đổi giao thức, nên nó hoặc đóng kết nối hoặc gửi phản hồi mà Nginx không thể phân tích. Đó là điều kích hoạt lỗi upstream prematurely closed connection.
Lỗi 400 Bad Request mà trình duyệt thấy là lỗi của Nginx khi quá trình upgrade handshake không bao giờ hoàn thành.
Chẩn Đoán Sự Cố
Kiểm tra error log của Nginx
sudo tail -f /var/log/nginx/error.log
Tìm các dòng upstream prematurely closed connection xuất hiện cùng thời điểm bạn thử kết nối WebSocket.
Xác nhận header bị thiếu ở upstream
Nếu backend của bạn có access logging, kiểm tra xem các request WebSocket đến có chứa header Upgrade không. Bạn cũng có thể giả lập handshake bằng curl — gọi trực tiếp đến backend, rồi gọi qua Nginx:
# Gọi trực tiếp đến backend (nên thành công)
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
http://127.0.0.1:3000/ws
# Qua Nginx (thất bại trước khi sửa)
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
Gọi trực tiếp trả về 101 Switching Protocols. Gọi qua Nginx trả về 400. Sự khác biệt này xác nhận rằng header bị thiếu chính là nguyên nhân.
Cách Sửa
Thêm header Upgrade và Connection vào proxy block
Mở file cấu hình Nginx server của bạn:
sudo nano /etc/nginx/sites-available/example.com
# hoặc
sudo nano /etc/nginx/conf.d/example.com.conf
Tìm block location xử lý WebSocket endpoint của bạn và thêm hai header bắt buộc:
server {
listen 80;
server_name example.com;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Hai dòng này là phần sửa lỗi
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Hai điểm đáng lưu ý:
proxy_http_version 1.1là bắt buộc, không thể thiếu. WebSocket upgrades yêu cầu HTTP/1.1. Nginx mặc định dùng HTTP/1.0 cho proxying, vốn không hỗ trợ cơ chế upgrade.proxy_set_header Upgrade $http_upgradechuyển tiếp bất cứ thứ gì client gửi. Với các request không phải WebSocket,$http_upgradesẽ rỗng — Nginx tự động bỏ qua header đó, nên traffic HTTP thông thường của bạn sẽ không bị ảnh hưởng.
Khi WebSocket và HTTP thông thường dùng chung một path
Giả sử API của bạn nằm tại /api và WebSocket endpoint tại /ws, cả hai đều phục vụ từ cùng một backend trên cổng 3000. Một location block duy nhất cần đặt Connection khác nhau tùy theo loại request. Directive map xử lý điều này gọn gàng:
# Trong block http {} (nginx.conf hoặc một snippet trong conf.d)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Request WebSocket nhận Connection: upgrade. Request HTTP thông thường nhận Connection: close. Mỗi loại nhận đúng thứ nó cần.
Điều chỉnh timeout cho kết nối tồn tại lâu dài
Kết nối WebSocket duy trì mở trong vài phút hoặc nhiều giờ. Giá trị mặc định proxy_read_timeout của Nginx là 60 giây — nó sẽ âm thầm ngắt các phiên WebSocket đang nhàn rỗi mà không có bất kỳ lỗi nào hiển thị trên trình duyệt. Thêm các dòng sau vào location block của bạn:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
3600 giây (1 giờ) là điểm khởi đầu hợp lý. Điều chỉnh dựa trên mức độ nhàn rỗi của ứng dụng bạn.
Áp dụng cấu hình
# Kiểm tra lỗi cú pháp
sudo nginx -t
# Reload mà không làm gián đoạn kết nối đang hoạt động
sudo systemctl reload nginx
Xác Nhận Kết Quả
Kiểm tra upgrade handshake thành công
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
Upgrade thành công bắt đầu bằng:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Chạy kiểm tra end-to-end với wscat
npm install -g wscat
wscat -c wss://example.com/ws
Nếu kết nối mở thành công và bạn có thể trao đổi tin nhắn trực tiếp, bạn đã hoàn tất.
Theo dõi error log ngừng xuất hiện lỗi
sudo tail -f /var/log/nginx/error.log
Sau khi sửa xong, lỗi upstream prematurely closed connection while reading response header from upstream sẽ không còn xuất hiện khi WebSocket client kết nối nữa.
Bài Học Rút Ra
- Ba thứ phải đồng thời có mặt:
proxy_http_version 1.1, headerUpgrade, và headerConnection. Thiếu bất kỳ một trong số đó và handshake sẽ thất bại. - Pattern
map $http_upgrade $connection_upgradelà giải pháp tiêu chuẩn khi một location block phục vụ cả traffic HTTP lẫn WebSocket. - Giá trị mặc định read timeout 60 giây của Nginx là kẻ thù thầm lặng với các phiên WebSocket nhàn rỗi. Hãy đặt
proxy_read_timeoutrõ ràng cho bất kỳ location block WebSocket nào. - Luôn chạy
nginx -ttrước khi reload. Một lỗi cú pháp trên server production sẽ làm sập toàn bộ các site trên máy đó, không chỉ site bạn đang chỉnh sửa.

