XserverでWebSocketが動かない?原因を追ってLong Pollingに落ち着いた実体験

WebSocketとSSEはNG、Long PollingはOKを示す通信方式の比較図 Program
この記事は約10分で読めます。

WordPressでチャットアプリを作ろうとしたとき、私は最初から「リアルタイム=WebSocket」だと思い込んでいました。Slackみたいに、メッセージが流れて、未読が付いて、ファイルも飛んでくる。あの体験を作るなら、双方向の常時接続でしょ、と。

ところが本番環境はXserver(共用レンタル)。ここで私は、通信方式の“理想”と、インフラの“現実”の差にぶつかります。この記事は、その試行錯誤と挫折、そしてLong Pollingに落ち着くまでの体験談です。


第1章:WebSocketで行けると思った(そして最初の挫折)

実装の序盤は順調でした。ブラウザ側でWebSocketを開き、サーバ側はイベントを受けたら全員にブロードキャスト。ローカル環境だと「うわ、チャットっぽい!」がすぐ出ます。

WebSocket(ブラウザ側)最小サンプル

// WebSocket(ブラウザ側)最小サンプル
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"));
ws.addEventListener("error", (e) => console.log("error", e));

(参考)WebSocketサーバ最小(Node.js)

※WebSocketは「別プロセス(常駐)」で待ち受ける形になりやすいです。共用レンタルではこの“運用前提”が壁になりがちです。

// WebSocket(サーバ側)最小サンプル(Node.js + ws)
// npm i ws
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());
    }
  });
});
console.log("ws server on :8080");

で、私は「よし本番へ」とXserverに上げて、ブラウザで開きました。

……繋がらない。

コンソールには、WebSocketハンドシェイクが通らないようなエラー。HTTP的には400系の匂い。私はここで初めて「WebSocketって、アプリだけの問題じゃないんだ」と思い知らされます。

なぜ動かない? 私がやった切り分け

  • wssか?(HTTPSサイト上でws://に繋いでいないか)
  • 同一ドメインか?(CORS/終端/証明書のねじれ)
  • どこで落ちてる?(ブラウザ→中間装置→サーバ、どの層か)

このとき私の中で決定打になったのが、「XserverではWebSocketが400になり、pollingなら動く」という実例を見つけたことでした。WebSocketはHTTPからUpgradeして別プロトコルへ切り替える性質があり、このUpgradeが中間装置(プロキシ/WAF等)で止まると成立しません。

つまり私の挫折は「コードが悪い」というより、共用レンタルの構造上、自分が触れない層で詰まっている可能性が高かった。

ここで私はWebSocketを諦めました。正確には、「この環境でWebSocket前提の設計にしない」方針へ転換しました。


第2章:じゃあSSEなら…と思った(そして2回目の挫折)

WebSocketがダメなら、片方向でもいい。チャットの“受信”さえリアルタイムにできれば、送信はHTTP POSTでも成立する。

そう考えて、私はSSE(Server-Sent Events)に希望をかけました。SSEはHTTPのまま接続して、サーバ→ブラウザへイベントを流し続けられます。Upgradeしないなら、通る余地がある。そう思ったんです。

SSE(PHP:サーバ側)最小サンプル

<?php
// SSE(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秒
}

SSE(ブラウザ側)最小サンプル

// SSE(ブラウザ側)最小サンプル
const es = new EventSource("/sse.php");
es.addEventListener("msg", (ev) => console.log("msg:", ev.data));
es.addEventListener("error", (e) => console.log("sse error", e));

ローカルでは、これも気持ちよく動きます。ログが毎秒増える。イベントが流れる。「これだ」と思いました。

しかし、Xserverに上げた途端に雰囲気が変わります。

  • 最初は繋がるけど、途中で止まる
  • 送っているはずのイベントが、リアルタイムに届かない(溜まって後から来る)

私はまた「なぜだろう?」に戻りました。

原因の当たり:タイムアウトとバッファリング

SSEは“レスポンスを閉じない”通信です。つまり長時間接続。ここで問題になるのが、中間装置のタイムアウトバッファリングです。

たとえばNginxのリバースプロキシ配下では、SSEがバッファリングされて「即時に届かない」現象が起こり得ます。対策としてヘッダでバッファリングを無効化する(例:X-Accel-Buffering: no)などが紹介されていますが、共用レンタルでは“その層の設定”が自分の手から外れていることが多い。

私はここで理解しました。SSEは「仕組みとしてはHTTP」でも、実運用ではプロキシやミドルウェアの設定とセットになりがちだ、と。

WebSocketと同じです。やりたいことは正しい。でも、安定化のスイッチが自分の手の届かないところにある。

この時点で、私はまた方針を変えます。


第3章:しかたない、Long Pollingにする(そしてようやく“運用”になった)

WebSocketもSSEも「長時間接続」を前提にしています。共用レンタルの現実では、長時間接続は不確定要素が増える。

ならば、私は普通のHTTPで完結する方式に寄せるべきだ。

そこで最後に選んだのがLong Polling(ロングポーリング)です。名前は強そうですが、やっていることは地味です。

  1. 「新着ある?」とHTTPで問い合わせる
  2. 新着がなければ最大15〜20秒くらい待つ
  3. 返ったらすぐ次の問い合わせを投げる

これが、Xserverではすんなり通りました。なぜなら、Long PollingはUpgradeしないし、永遠に開きっぱなしにしない。見た目が“普通のHTTP”なので、共用レンタルの得意分野に収まるからです。

Long Poll(PHP:サーバ側)最小サンプル

<?php
// Long Polling(PHP)最小サンプル:最大18秒待ってJSONを返す
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); // 250ms
}

echo json_encode(["messages" => $messages, "last_id" => $last_id], JSON_UNESCAPED_UNICODE);

Long Poll(ブラウザ側)最小サンプル

// Long Polling(ブラウザ側)最小サンプル(連打防止&バックオフ入り)
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();

私がLong Pollingで意識したこと(運用の現実)

  • 待ち時間は短め(15〜20秒)…長くしすぎると逆に不安定になりやすい
  • 空レスポンス連打を防ぐ…少しsleepを入れて負荷を抑える
  • タブ非表示は間引く…見てない間に回し続けない
  • エラー時はバックオフ…障害時に自分で炎上を広げない

正直、WebSocketの“ぬるっとしたリアルタイム感”には勝てません。でも、私が欲しかったのは「理想のリアルタイム」より「止まらないチャット」でした。


エピローグ:技術の勝ち負けじゃなく、環境との相性だった

  • WebSocket:最高に気持ちいい。でもUpgrade常時接続の前提が重い
  • SSE:HTTPのままでも、結局は長時間接続でタイムアウト/バッファリングの壁が出る
  • Long Polling:地味。でもHTTPの範囲に収まり、共用レンタルで成立しやすい

もし「Xserver+WordPressでチャット」をやるなら、私はまずLong Pollingで“運用できる完成”を作り、必要になったタイミングでVPS/専用へ移行してWebSocket/SSEを再検討する、という順番をおすすめします。

コメント

タイトルとURLをコピーしました