WebSocket(ウェブソケット)の仕組み:リアルタイム接続の完全ウォークスルー

Webの初期段階では、ブラウザはシンプルなドキュメントビューアに過ぎませんでした。ページをリクエストすると、サーバーがそれをレンダリングし、接続が閉じられていました。この「リクエスト・レスポンス」のサイクルが HTTP (Hypertext Transfer Protocol) の核心です。

しかし、Webアプリケーションがリアルタイムチャット、ライブの株価表示、共同編集、マルチプレイヤーゲームなどのリッチでインタラクティブな体験へと進化するにつれて、従来のHTTPモデルの限界が浮き彫りになりました。

リアルタイムの更新情報を取得するために、開発者は当初、以下のような回避策に頼っていました。

  • ショートポーリング (Short Polling): ブラウザが数秒おきにHTTPリクエストを繰り返し送信し、新しいデータがあるかサーバーに問い合わせます。これは大量のヘッダーオーバーヘッドを生み出し、サーバーリソースを浪費します。
  • ロングポーリング (Long Polling / Comet): ブラウザがリクエストを送信し、サーバーは新しいデータが利用可能になるまで接続を保持します。データが送信されると接続が閉じられ、ブラウザは即座に新しいリクエストを開きます。これは管理が複雑で、接続確立時のオーバーヘッドも依然として発生します。

**WebSocket(ウェブソケット)**は、単一のTCP接続上で永続的、双方向、かつ全二重(フルデュプレックス)の通信を行うための標準化されたプロトコルを導入することで、これらの制限を解決しました。


WebSocketとは何か?

WebSocket(RFC 6455で定義)はHTTPと並行して動作します。HTTPがクライアントからしかリクエストを開始できないステートレスなプロトコルであるのに対し、WebSocket接続は一度確立されると無期限に開いたままになり、クライアントとサーバーの両方がいつでも最小限の遅延でデータを送信し合うことができます。

WebSocketの根本的なルールは以下の通りです。

一度接続が確立されると、どちらの側からでも、新しい接続リクエストを開始することなく、いつでもメッセージを送信できます。


ステップバイステップ・ウォークスルー:接続のライフサイクル

WebSocket接続は、ハンドシェイクデータ転送切断という3つの明確なフェーズを経ます。

WebSocket Connection Lifecycle Diagram

1. HTTPハンドシェイク(プロトコルのアップグレード)

ファイアウォールやルーターは、ポート80(HTTP)および443(HTTPS)の標準的なウェブトラフィックを許可するように設定されているため、WebSocketは標準のHTTP/1.1リクエストとして開始されます。これはアップグレードハンドシェイクと呼ばれます。

クライアントからのリクエスト

クライアントは、プロトコルの切り替えを要求する特定のヘッダーを含むHTTP GETリクエストを送信します。

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
  • Upgrade: websocketConnection: Upgrade: プロトコルの切り替えを希望していることをサーバーに伝えます。
  • Sec-WebSocket-Key: Base64でエンコードされたランダムな16バイトの値。サーバーがハンドシェイクを受信し、WebSocketプロトコルを理解していることを証明するために使用されます。
  • Sec-WebSocket-Version: WebSocketプロトコルのバージョンを指定します(通常は13)。
  • Origin: サーバーが接続を許可するかどうかを判断するために使用されます(不正なサイトからのクロスサイト接続を防ぐセキュリティチェック)。

サーバーからのレスポンス

サーバーがWebSocketをサポートしている場合、リクエストを検証し、HTTPステータスコード 101 Switching Protocols(プロトコル切り替え)で応答します。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • サーバーによる Sec-WebSocket-Accept の計算方法:
    1. クライアントの Sec-WebSocket-Key(例: dGhlIHNhbXBsZSBub25jZQ==)を取得します。
    2. それを標準の魔法のGUID文字列 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" と結合します。
    3. 結合された文字列のSHA-1ハッシュを計算します。
    4. 生成されたハッシュをBase64でエンコードします。
    5. クライアントはこの値が期待通りであることを検証し、ハンドシェイクが成功すると、HTTP接続は生のTCPソケットに切り替わり、双方がWebSocketプロトコルに移行します。

2. データフレーミングと転送

プレーンテキストのヘッダーに続いてボディを送信するHTTPとは異なり、WebSocketはフレームと呼ばれる構造化されたバイナリパケットでデータを送信します。

WebSocketフレームは、非常に軽量なヘッダー(2〜14バイト)とそれに続くペイロードで構成されています。このヘッダーには以下が含まれます。

  • FINビット (1ビット): メッセージの最後のフレームであるかどうかを示します。
  • Opcode (4ビット): フレームのタイプを定義します。
    • 0x1: テキストフレーム(UTF-8エンコード)
    • 0x2: バイナリフレーム
    • 0x8: 接続クローズリクエスト
    • 0x9: Ping(ピング)
    • 0xA: Pong(ポング)
  • Maskビット (1ビット): ペイロードデータがマスクされているかどうかを指定します。
  • Payload Length: データのサイズ。
  • Masking Key (4バイト): 重要なセキュリティ要件: クライアントからサーバーに送信されるすべてのフレームは、ランダムな4バイトのキーを使用してマスク(XORによる難読化)されていなければなりません。これにより、プロキシキャッシュがトラフィックを読み取ったり、キャッシュポイズニング攻撃を実行したりするのを防ぎます。サーバーからクライアントへのフレームはマスクしてはなりません。

ハートビート (Ping/Pong)

ルーターやロードバランサーがアイドル接続を閉じるのを防ぐために、どちらの側からでも Ping フレームを送信できます。受信側は、同じペイロードを含む Pong フレームで即座に応答する必要があります。

3. 接続の切断

接続を正常に閉じるには:

  1. 一方が、ステータスコード(正常終了を示す 1000 や、異常終了を示す 1006 など)とオプションの理由テキストを含む Close フレームを送信します。
  2. もう一方が、独自の Close フレームで応答します。
  3. 基盤となるTCPソケットがクローズされます。

コード例:Node.jsにおけるWebSocketの実装

WebSocketの動作を確認するために、シンプルなNode.jsアプリケーションを作成してみましょう。受信したメッセージをそのままオウム返しするローカルのWebSocketサーバーと、それに接続するクライアントスクリプトを作成します。

WebSocketサーバー (server.js)

const { WebSocketServer } = require('ws');
const http = require('http');

// 1. 標準のHTTPサーバーを作成
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('HTTP Server running. Use WebSocket to connect.\n');
});

// 2. HTTPサーバーにWebSocketサーバーをアタッチ
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
    const clientIp = req.socket.remoteAddress;
    console.log(`[Server] 新しいクライアントが接続されました。IP: ${clientIp}`);

    // クライアントにウェルカムメッセージを送信
    ws.send(JSON.stringify({ type: 'welcome', message: 'Ghaznix WebSocketサーバーへようこそ!' }));

    // クライアントからのメッセージを受信
    ws.on('message', (message) => {
        console.log(`[Server] 受信: ${message}`);
        
        try {
            const data = JSON.parse(message);
            ws.send(JSON.stringify({
                type: 'echo',
                message: `サーバーエコー: ${data.text.toUpperCase()}`,
                timestamp: new Date().toISOString()
            }));
        } catch (e) {
            ws.send(JSON.stringify({ type: 'error', message: '無効なJSONフォーマットです' }));
        }
    });

    // クライアントの切断を処理
    ws.on('close', (code, reason) => {
        console.log(`[Server] クライアントが切断されました (コード: ${code}, 理由: ${reason.toString() || 'なし'})`);
    });

    ws.on('error', (error) => {
        console.error(`[Server] ソケットエラー: ${error.message}`);
    });
});

server.listen(8080, () => {
    console.log('WebSocketサーバーがポート8080で待機中: ws://localhost:8080');
});

ブラウザクライアント(クライアントサイドのJavaScript)

このクライアントは、ブラウザのコンソールで直接実行できます。

// 1. サーバーへの接続を確立
const socket = new WebSocket('ws://localhost:8080');

// 2. 接続オープン時のハンドラ
socket.addEventListener('open', (event) => {
    console.log('[Client] サーバーに接続しました。');
    
    const payload = JSON.stringify({ text: 'hello, server!' });
    socket.send(payload);
    console.log(`[Client] 送信: ${payload}`);
});

// 3. サーバーからのメッセージを受信
socket.addEventListener('message', (event) => {
    const response = JSON.parse(event.data);
    console.log('[Client] サーバーからメッセージを受信:', response);
});

// 4. 接続クローズ時のハンドラ
socket.addEventListener('close', (event) => {
    console.log(`[Client] 接続が閉じられました (コード: ${event.code})`);
});

// 5. エラー時のハンドラ
socket.addEventListener('error', (error) => {
    console.error('[Client] WebSocketエラー:', error);
});

HTTP vs. WebSocket:詳細な比較

特徴 HTTP/1.1 WebSocket
通信方向 単方向(クライアント開始) 双方向(クライアント・サーバー双方向)
接続モデル リクエスト-レスポンス(接続は一時的) 永続的(接続は維持される)
オーバーヘッド 高い(リクエストごとにヘッダーを送信) 非常に低い(最小限のフレームヘッダー)
状態管理 ステートレス(状態なし) ステートフル(接続コンテキストを保持)
プロトコル http:// または https:// ws:// または wss://
主な用途 ドキュメントの取得、REST API リアルタイムチャット、ダッシュボード、配信

WebSocketのセキュリティに関する考慮事項

WebSocketはハンドシェイクの後に標準のHTTPルーティングをバイパスするため、特有のセキュリティリスクが生じます。

  1. 暗号化された WebSocket Secure (wss://) の使用: WebSocketは常にTLS/SSL(ポート443)経由で実行してください。WSSはフレームのペイロードを暗号化し、盗聴や中間者による改ざんを防ぎます。
  2. オリジン(Origin)の検証: WebSocketは同一生成元ポリシー(SOP)による制限を受けません。悪意のあるサイトからの不正アクセスを防ぐため、ハンドシェイク時にサーバー側で必ず Origin ヘッダーを検証してください。
  3. ハンドシェイク時の認証: 接続が確立される前にユーザーを認証します。これは通常、クエリパラメータでトークン(JWTなど)を渡すか、セッションCookieを検証することによって行われます。
  4. 入力値のサニタイズ: WebSocketを介して受信するすべてのメッセージを信頼できない入力として扱います。クロスサイトスクリプティング(XSS)を防ぐため、ペイロードの検証とサニタイズを行ってください。

まとめ

WebSocketは、従来のHTTPポーリングに伴うオーバーヘッドを排除することにより、リアルタイムWebアプリケーションのあり方を大きく変えました。単一の永続的なTCP接続を維持することで、瞬時の双方向メッセージングを可能にし、今日のライブダッシュボード、マルチプレイヤーゲーム、チャットアプリを支えています。HTTPアップグレードの仕組み、データフレーミングの構造、そして極めて重要なセキュリティ対策を理解することで、高速で安全なリアルタイムサービスを構築することができます。


Ghaznixブログで開発者向けのチュートリアルやガイドをさらに探索する →