問題の概要
通常のHTTPトラフィックはNginxリバースプロキシを問題なく通過します。ところが、クライアントがWebSocket接続を確立しようとすると、即座に失敗します。ブラウザのコンソールには400 Bad RequestまたはWebSocketハンドシェイクの停止が表示され、Nginxには次のログが記録されます:
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"
バックエンド(Node.js/Socket.ioサーバー、GoのWebSocketサービス、Pythonのwebsocketsアプリ)は直接接続であれば正常に受け付けます。原因はバックエンドではなく、Nginxにあります。
根本原因
WebSocket接続は、2つの重要なヘッダーを持つ標準的なHTTP/1.1リクエストとして開始されます:
Upgrade: websocket— クライアントがプロトコルの切り替えを要求することをサーバーに伝えるConnection: Upgrade—Upgradeヘッダーを処理する必要があることを示す
Nginxはこれらのヘッダーをアップストリームへ転送する前に静かに削除してしまいます。これらはホップバイホップヘッダーと呼ばれ、Nginxは設計上それらを削除します。アップストリームサーバーはUpgradeヘッダーがまったくない通常のGETリクエストを受け取ります。プロトコルの切り替えが要求されたことを認識できないため、接続を閉じるか、Nginxが解析できないレスポンスを返します。これがupstream prematurely closed connectionを引き起こす原因です。
ブラウザに表示される400 Bad Requestは、アップグレードハンドシェイクが完了しなかった際にNginxが返すエラーです。
問題の診断
Nginxエラーログの確認
sudo tail -f /var/log/nginx/error.log
WebSocket接続試行のタイミングに合わせたupstream prematurely closed connectionのエントリを探してください。
アップストリームでヘッダーが欠落していることの確認
バックエンドにアクセスログがある場合、受信するWebSocketリクエストにUpgradeヘッダーが含まれているか確認してください。curlでハンドシェイクをシミュレートすることもできます。バックエンドに直接アクセスした場合と、Nginx経由でアクセスした場合を比較してください:
# バックエンドへ直接(成功するはず)
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
# Nginx経由(修正前は失敗する)
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
直接アクセスでは101 Switching Protocolsが返ります。Nginx経由では400が返ります。この差異が、ヘッダーの欠落が原因であることを裏付けています。
修正方法
プロキシブロックにUpgradeヘッダーとConnectionヘッダーを追加する
NginxのサーバーConfigを開きます:
sudo nano /etc/nginx/sites-available/example.com
# または
sudo nano /etc/nginx/conf.d/example.com.conf
WebSocketエンドポイントを処理するlocationブロックを見つけ、必要な2つのヘッダーを追加します:
server {
listen 80;
server_name example.com;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# この2行が修正内容
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;
}
}
注意すべき点が2つあります:
proxy_http_version 1.1は必須です。WebSocketのアップグレードにはHTTP/1.1が必要です。Nginxのプロキシデフォルトはアップグレードの仕組みをサポートしていないHTTP/1.0です。proxy_set_header Upgrade $http_upgradeはクライアントが送信した値をそのまま渡します。WebSocketでないリクエストでは$http_upgradeが空になり、Nginxは自動的にヘッダーをスキップするため、通常のHTTPトラフィックには影響しません。
WebSocketと通常のHTTPが同じパスを共有する場合
例えば、APIが/apiに、WebSocketエンドポイントが/wsにあり、どちらもポート3000の同じバックエンドで提供されているとします。単一のlocationブロックでリクエストの種類に応じてConnectionを使い分ける必要があります。mapディレクティブがこれをすっきりと解決します:
# http {} ブロック内(nginx.confまたは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;
}
}
WebSocketリクエストにはConnection: upgradeが、通常のHTTPリクエストにはConnection: closeが設定されます。それぞれに必要なものが正確に渡されます。
長時間接続のタイムアウトを調整する
WebSocket接続は数分から数時間にわたってオープンのままになります。Nginxのデフォルトproxy_read_timeoutは60秒のため、ブラウザにエラーを表示することなく、アイドル状態のWebSocketセッションを静かに切断してしまいます。locationブロックに以下を追加してください:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
3600秒(1時間)は合理的な出発点です。アプリケーションのアイドルパターンに応じて調整してください。
設定を適用する
# 構文エラーをチェック
sudo nginx -t
# アクティブな接続を切断せずにリロード
sudo systemctl reload nginx
修正の確認
アップグレードハンドシェイクの成功を確認する
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
アップグレードが成功した場合、次のように始まります:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
wscatでエンドツーエンドのテストを実行する
npm install -g wscat
wscat -c wss://example.com/ws
接続が開き、対話的にメッセージをやり取りできれば完了です。
エラーログが静かになることを確認する
sudo tail -f /var/log/nginx/error.log
修正が適用されると、WebSocketクライアントが接続する際にupstream prematurely closed connection while reading response header from upstreamが表示されなくなります。
得られた教訓
- 3つの要素がすべて揃っている必要があります:
proxy_http_version 1.1、Upgradeヘッダー、Connectionヘッダー。どれか1つでも欠けるとハンドシェイクが失敗します。 map $http_upgrade $connection_upgradeパターンは、1つのlocationブロックでHTTPとWebSocketの両方のトラフィックを処理する際の定番ソリューションです。- Nginxのデフォルト60秒のread timeoutは、アイドル状態のWebSocketセッションを静かに切断する「サイレントキラー」です。WebSocketのlocationブロックには必ず
proxy_read_timeoutを明示的に設定してください。 - リロード前に必ず
nginx -tを実行してください。本番サーバーで構文エラーが発生すると、編集中のサイトだけでなく、そのマシン上のすべてのサイトがダウンします。

