Xserverの共用レンタルでは、WebSocketはUpgradeが中間装置で止まり、SSEはバッファリングで即時配信できません。最終的にLong Pollingに落ち着きました。この記事では、WordPressでチャット機能を実装するために3つの通信方式を試した過程と、コピペ可能な最小サンプルを公開します。
WordPressでチャットアプリを作ろうとしたとき、「リアルタイム=WebSocket」だと思い込んでいました。Slackみたいにメッセージが流れて未読が付く体験を作るなら、双方向の常時接続だろうと。ところが本番環境はXserver(共用レンタル)。ここで通信方式の「理想」とインフラの「現実」の差にぶつかりました。
3つの通信方式の違い
WebSocket・SSE・Long Pollingは接続の仕組みがまったく異なります。共用レンタルで使えるかどうかは「HTTPの範囲に収まるか」で決まります。
3方式の通信フロー比較。WebSocketとSSEは常時接続、Long PollingはHTTPの繰り返し
| 方式 | 方向 | 接続 | プロトコル | 共用レンタル |
|---|---|---|---|---|
| WebSocket | 双方向 | 常時接続 | HTTP→Upgrade→WS | ✗ 動かない |
| SSE | サーバ→ブラウザ | 常時接続 | HTTP(レスポンス未閉鎖) | △ 不安定 |
| Long Polling | ブラウザ→サーバ→ブラウザ | 都度切断 | 通常のHTTP | ◎ 安定 |
共用レンタルでの動作可否。WebSocketはUpgradeが通らず不可、SSEはバッファリングで不安定
ポイントは「HTTPの範囲に収まるか」です。WebSocketはHTTPからプロトコルをUpgradeするため中間装置で止まる。SSEはHTTPのままだが長時間接続のためタイムアウトやバッファリングが起きる。Long PollingだけがHTTPリクエスト/レスポンスの通常動作に収まるため、共用レンタルで安定します。
WebSocket:Upgradeが通らない
WebSocketはHTTPハンドシェイク後にプロトコルをUpgradeする必要がありますが、Xserverの中間装置(プロキシ/WAF等)がこのUpgradeを通しません。コードの問題ではなく、環境の問題です。
ローカル環境では問題なく動きます。ブラウザ側でWebSocketを開き、サーバ側でブロードキャストすれば「チャットっぽい」動きがすぐ出る。
ブラウザ側の最小サンプル:
|
1 2 3 4 |
const ws = new WebSocket("wss://example.com/ws"); ws.addEventListener("open", () => ws.send("hello")); ws.addEventListener("message", (ev) => console.log("recv:", ev.data)); ws.addEventListener("close", () => console.log("closed")); |
サーバ側の最小サンプル(Node.js + ws):
|
1 2 3 4 5 6 7 8 9 |
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (socket) => { socket.on("message", (msg) => { for (const client of wss.clients) { if (client.readyState === 1) client.send(msg.toString()); } }); }); |
ところがXserverに上げた瞬間、繋がらない。
DevToolsのコンソール。WebSocketのハンドシェイクが400系で失敗している
切り分けとして確認したのは3点です。wss://を使っているか(HTTPSサイトでws://だと混在コンテンツでブロックされる)、同一ドメインか(CORS/証明書のねじれ)、どの層で落ちているか(ブラウザ→中間装置→サーバ)。
決定打は「WebSocketは400になるが、通常のHTTPリクエストなら通る」という事実でした。WebSocketはHTTPからUpgradeして別プロトコルに切り替える性質があり、このUpgradeが共用レンタルの中間装置で止まる。コードの問題ではなく、自分が触れない層で詰まっているのです。
ここでWebSocketを諦め、「この環境でWebSocket前提の設計にしない」方針に転換しました。
SSE:バッファリングで即時に届かない
SSE(Server-Sent Events)はHTTPのまま接続するのでUpgradeの壁はありません。しかしXserverではプロキシのバッファリングによってイベントが即時に届かず、溜まってから一括で届く現象が発生しました。
SSEはサーバ→ブラウザへの片方向ストリームです。送信はHTTP POSTで別途行えばよいので、チャットの受信側としては十分。HTTPのままだからUpgradeの壁もない。
PHP側の最小サンプル:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php header("Content-Type: text/event-stream"); header("Cache-Control: no-cache"); header("Connection: keep-alive"); @ini_set("output_buffering", "off"); @ini_set("zlib.output_compression", 0); while (ob_get_level() > 0) { @ob_end_flush(); } @ob_implicit_flush(true); $i = 0; while (true) { $i++; echo "event: msg\n"; echo "data: " . json_encode(["n" => $i, "ts" => time()]) . "\n\n"; flush(); usleep(1000 * 1000); } |
ブラウザ側の最小サンプル:
|
1 2 |
const es = new EventSource("/sse.php"); es.addEventListener("msg", (ev) => console.log("msg:", ev.data)); |
ローカルでは毎秒イベントが流れて快適。しかしXserverに上げると、最初は繋がるが途中で止まる。送っているはずのイベントがリアルタイムに届かず、溜まってから一括で届く。
SSEのイベントがバッファリングされ、まとめて届いている様子
原因:タイムアウトとバッファリング
SSEは「レスポンスを閉じない」通信です。Nginxのリバースプロキシ配下では、SSEがバッファリングされて即時に届かない現象が起きます。対策としてX-Accel-Buffering: noヘッダでバッファリングを無効化する方法がありますが、共用レンタルではその層の設定が自分の手から外れています。
SSEは「仕組みとしてはHTTP」でも、実運用ではプロキシやミドルウェアの設定とセットになる。WebSocketと同じく、安定化のスイッチが自分の手の届かないところにある。
Long Polling:「普通のHTTP」だから通る
Long Pollingは通常のHTTPリクエスト/レスポンスの繰り返しなので、Upgrade不要・常時接続不要。共用レンタルの「得意分野」に収まるため、Xserverでもすんなり動きました。
仕組みは単純です。「新着ある?」とHTTPで問い合わせ、新着がなければ最大15〜20秒待ち、返ったらすぐ次の問い合わせを投げる。これだけです。
PHP側の最小サンプル:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<?php header("Content-Type: application/json; charset=utf-8"); header("Cache-Control: no-store"); $since = isset($_GET["since"]) ? (int)$_GET["since"] : 0; $deadline = microtime(true) + 18.0; function get_new_messages($since) { // DB検索に置き換える(例:id > $since) if (time() % 5 === 0) { $id = $since + 1; return [["id" => $id, "text" => "new message " . $id, "ts" => time()]]; } return []; } $messages = []; $last_id = $since; while (microtime(true) < $deadline) { $messages = get_new_messages($since); if (!empty($messages)) { $last_id = end($messages)["id"]; break; } usleep(250000); } echo json_encode(["messages" => $messages, "last_id" => $last_id], JSON_UNESCAPED_UNICODE); |
ブラウザ側の最小サンプル(連打防止&バックオフ付き):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
let lastId = 0; let backoff = 300; async function poll() { while (true) { if (document.hidden) { await sleep(1500); continue; } try { const res = await fetch(`/poll.php?since=${lastId}`, { cache: "no-store" }); const data = await res.json(); if (Array.isArray(data.messages) && data.messages.length) { for (const m of data.messages) console.log("recv:", m); lastId = data.last_id; } else { await sleep(60); } backoff = 300; } catch (e) { console.log("poll error", e); await sleep(backoff); backoff = Math.min(8000, Math.floor(backoff * 1.6)); } } } function sleep(ms){ return new Promise(r => setTimeout(r, ms)); } poll(); |
DevToolsのNetworkタブ。poll.phpへのリクエストが規則的に繰り返され、正常に動作している
Long Pollingの運用で意識すること
Long Pollingは「動く」のは簡単ですが、安定運用には待ち時間・連打防止・タブ非表示・バックオフの4つを意識する必要があります。
待ち時間は短め(15〜20秒)にする。長くしすぎるとサーバー側でタイムアウトが発生しやすくなります。Xserverのデフォルトのmax_execution_time(30秒)を考慮し、余裕を持たせます。
空レスポンス連打を防ぐ。新着がない場合に即座に次のリクエストを投げると、サーバーに無駄な負荷がかかります。短いsleepを入れて間引きます。
タブ非表示時は間引く。document.hiddenをチェックし、見ていないタブでは頻度を落とします。ブラウザを開きっぱなしのユーザーがサーバーリソースを食い続けるのを防ぎます。
エラー時はバックオフする。障害時にリトライが殺到するとサーバーがさらに苦しくなります。指数バックオフ(300ms → 480ms → 768ms → …最大8秒)で自動的に間隔を広げます。
WebSocketの「リアルタイム感」にはどう対処するか
Long PollingはWebSocketの「ぬるっとした即時感」には勝てません。ただし、最大待ち時間を15秒にしておけば、最悪でも15秒以内にメッセージが届きます。チャットとしては十分実用的です。
体感の遅延を縮める方法として、送信時に自分のメッセージだけは即座にUIに反映する「楽観的UI更新」があります。サーバーからの確認を待たずに画面に表示し、エラーなら取り消す。LINEやSlackも同じ手法を使っています。
将来的にリアルタイム性がどうしても必要になったら、VPS(Xserver VPSやConoHa VPSなど)に移行してWebSocketを使う選択肢もあります。Long Pollingで「運用できる完成品」を先に作り、必要になったタイミングでインフラを変える、という順番がおすすめです。
まとめ
Xserverの共用レンタルでリアルタイム通信を実現するなら、Long Pollingが最も現実的な選択です。WebSocketはUpgradeが通らず、SSEはバッファリングで不安定。Long PollingだけがHTTPの範囲に収まり、安定して動作します。
技術の勝ち負けではなく、環境との相性の問題です。WebSocketが「正解」で Long Pollingが「妥協」なのではなく、共用レンタルという制約下では Long Pollingが最適解。待ち時間・連打防止・タブ非表示・バックオフの4点を押さえれば、十分に実用的なチャットが作れます。








コメント