NginxのWebSocketリバースプロキシで発生する400 Bad RequestをUpgradeとConnectionヘッダーの追加で修正する

intermediate Nginx2026-05-20| Ubuntu 20.04/22.04、Debian 11/12、CentOS 7/8、RHEL 8/9上のNginx 1.14以降 — Node.js、Socket.io、またはその他のWebSocketバックエンドへのプロキシ構成

Error Message

upstream prematurely closed connection while reading response header from upstream
#nginx#websocket#リバースプロキシ#アップグレード#400

問題の概要

通常の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: UpgradeUpgradeヘッダーを処理する必要があることを示す

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.1Upgradeヘッダー、Connectionヘッダー。どれか1つでも欠けるとハンドシェイクが失敗します。
  • map $http_upgrade $connection_upgradeパターンは、1つのlocationブロックでHTTPとWebSocketの両方のトラフィックを処理する際の定番ソリューションです。
  • Nginxのデフォルト60秒のread timeoutは、アイドル状態のWebSocketセッションを静かに切断する「サイレントキラー」です。WebSocketのlocationブロックには必ずproxy_read_timeoutを明示的に設定してください。
  • リロード前に必ずnginx -tを実行してください。本番サーバーで構文エラーが発生すると、編集中のサイトだけでなく、そのマシン上のすべてのサイトがダウンします。

Related Error Notes