AWS Fargate+AWS Memory DBで実装するServer Sent Events(SSE)の検証

記事タイトルとURLをコピーする

はじめに

アプリケーションサービス本部ディベロップメントサービス4課の池上です。

今回はFargateとMemoryDBを使ったServer Sent Events(SSE)検証を行ってみたという記事です。

モダンなWebアプリケーションにおいて、サーバー側からクライアントへ即座に情報を伝える「リアルタイム性」は欠かせない要素です。

リアルタイム通信といえば WebSocket が有名ですが、今回はよりシンプルに HTTP プロトコルの上で動作する Server-Sent Events (SSE) を採用しました。本記事では、AWS ECS (Fargate) 上でスケーラブルな配信基盤を構築した際の検証結果を共有します。

Server-Sent Events (SSE) とは

私たちが普段 REST API などで利用している「リクエスト・レスポンス形式」の HTTP 通信では、サーバー側から自発的に情報を送ることができません。また、接続を維持し続けようとしても、タイムアウトといった問題があり、リアルタイムな情報更新には不向きです。

そこで、サーバーから継続的に情報をプッシュするための技術として、Server-Sent Events (SSE)WebSocket が有力な選択肢となります。

通信方式のイメージ比較

方式 通信の形 主導権(きっかけ) 実世界での例え
リクエスト・レスポンス 一往復 クライアントのみ 手紙(送ったら返事が来て完結)
SSE 単方向プッシュ サーバーから継続的 ラジオ放送(受信機を繋ぐと流れてくる)
WebSocket 双方向プッシュ 両方からいつでも 電話・チャット(お互い同時に喋れる)

なぜ WebSocket ではなく SSE なのか

WebSocket は双方向で高速な通信ができる非常に便利な技術ですが、一方で「ステートフル(状態保持)」な通信であるため、プロトコルの維持や専用ライブラリによるメモリ管理が複雑になりがちです。双方向のやり取りが必須でない要件においては、オーバーヘッドも少なくありません。

対して Server-Sent Events (SSE) は、HTTP/TCP をベースとした単方向のストリーミング技術です。

  • 信頼性の高い HTTP ベース: UDPのようにパケットロスを許容する速さ優先の通信ではなく、使い慣れた HTTP プロトコルの仕組みをそのまま使いながら、サーバーからのプッシュ通知を低遅延で実現します。

  • 運用・設計のシンプルさ: 既存のロードバランサー(ALB)やリバースプロキシ(Nginx)との親和性が高く、ブラウザ標準の自動再接続機能も備わっているため、保守・運用コストを低く抑えることが可能です。

SSE と WebSocket の使い分け

特徴 SSE が向いているケース WebSocket が向いているケース
主な用途 ニュース、株価更新、通知、ログ監視 チャット、対戦ゲーム、共同編集ツール
通信頻度 サーバーから時々(随時)プッシュ 双方向で頻繁にデータをやり取りする
データ形式 テキストデータのみ バイナリデータ(画像など)も可能
開発コスト 低い(既存のHTTPの知識で実装可能) 高い(独自のハンドシェイク等が必要)

SSEとWebSocketの通信方法の違い

SSE(左側)では、配信フェーズの矢印がすべてサーバーからクライアントへの一方向になっています。通常の HTTP で接続した後は、サーバーがデータを送り続けるだけのシンプルな構造です。

一方、WebSocket(右側)では、配信フェーズの矢印が交互に向きを変えており、双方向にデータをやり取りしていることがわかります。その分、接続時に HTTP からのプロトコル切替が必要で、切断時にも双方で Close のハンドシェイクを行う必要があります。

システム構成(アーキテクチャ)

ディレクトリ構成

.
├── cdk/              # AWSインフラを定義するCDKプロジェクト
├── docker/           # コンテナ設定ファイル
│   ├── nginx.conf        # Nginxのサーバー設定
│   └── supervisord.conf  # プロセス管理(Nginx/PHP-FPM)の設定
├── public/           # Web公開ディレクトリ
│   └── index.php         # フロントコントローラー(全リクエストの入口)
├── src/              # PHPアプリケーションソースコード
│   └── Controller/       # コントローラークラス
│       ├── DataController.php # データ投稿・ヘルスチェック用
│       └── SseController.php  # SSE配信・Redis接続用
├── composer.json     # PHP依存関係管理
├── composer.lock     # 依存ライブラリのバージョン固定
└── Dockerfile        # アプリケーションコンテナのビルド定義

インフラ実装のポイント

本構成では、AWSのベストプラクティスに基づき2つのAvailability Zone(AZ)を利用しています。ALBとFargateタスクを分散配置することで、片方のデータセンターで障害が発生しても、SSEのストリーミング配信が途切れない設計にしています。 この構成で特徴的な部分は、データベースを一般的なRDBMSであるRDSやNoSQLのDynamoDBではなく、MemoryDBを利用している点です。こちらはリアルタイム性が求められるSSE配信において、リレーショナルデータベースではレイテンシがボトルネックとなります。 一方で、配信データの整合性も無視できないため、インメモリの高速性とマルチAZでのデータ永続性、データの耐久性を兼ね備えたデータベースサービスであるMemoryDBを選定しました。

1コンテナ・2プロセス構成

Docker では「1コンテナ・1プロセス」の場合が多いですが、AWS Fargate などでシンプルに動かすため、Nginx と PHP-FPM を 1 つのコンテナに入れ、Supervisor で管理する手法を今回取りました。

今回の検証で利用したDockerfile

FROM php:8.2-fpm

# Install system dependencies
RUN apt-get update && apt-get install -y \
    nginx \
    supervisor \
    unzip \
    git \
    $PHPIZE_DEPS \
    && rm -rf /var/lib/apt/lists/*

# Install PHP extensions
RUN pecl install redis \
    && docker-php-ext-enable redis

# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /app

# Copy composer files and install dependencies
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-autoloader --no-scripts
RUN composer dump-autoload --optimize

# Copy application code
COPY public ./public
COPY src ./src

# Copy configuration files
COPY docker/nginx.conf /etc/nginx/sites-available/default
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

EXPOSE 8080

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

SSE (リアルタイム配信) 専用のチューニング

SSE部分の設定は、後半の fastcgi 関連のパラメータにあります。これらは SSE を正常に動作させるための必須設定です。

  • fastcgi_buffering off; / X-Accel-Buffering: no

    • 通常、Nginx は効率化のためにデータをある程度溜めてから送信しますが、これをオフにすることで、サーバーが送ったデータを即座にクライアントに届けます。
  • fastcgi_read_timeout 600s;

    • SSE は長時間接続を維持するため、デフォルトの短いタイムアウト(60秒など)では接続が切れてしまいます。検証中にデフォルトのタイムアウト値が原因で接続が切れる事象が発生したため、これを 10 分(600秒)に延ばすことで対応をしています。
  • gzip off;

    • 圧縮処理によるバッファリング(遅延)を防ぎ、リアルタイム性を優先しています。

アプリケーションのソースコードについて

<?php
// public/index.php

require __DIR__ . '/../vendor/autoload.php';

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    $r->addRoute('GET', '/api/sse', 'App\Controller\SseController@stream');
    $r->addRoute('POST', '/api/post', 'App\Controller\DataController@publish');
    $r->addRoute('GET', '/health', 'App\Controller\DataController@healthCheck');
});

// Fetch method and URI from somewhere
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string (?foo=bar) and decode URI
if (false !== $pos = strpos($uri, '?')) {
    $uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        // ... 404 Not Found
        header("HTTP/1.0 404 Not Found");
        echo '404 Not Found';
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        $allowedMethods = $routeInfo[1];
        // ... 405 Method Not Allowed
        header("HTTP/1.0 405 Method Not Allowed");
        echo '405 Method Not Allowed';
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        
        list($class, $method) = explode('@', $handler);
        $controller = new $class();
        $controller->$method($vars);
        break;
}

デプロイ・動作検証

CDKによるインフラ構築

こちらのコマンドで実行

cd cdk
npm install
cdk deploy

実際の挙動(今回は検証であるためCurlコマンドはhttpで行っています)

GETリクエスト

GETリクエストはこのように実行します

curl "http://<ALBのDNS名>/api/sse"

POSTリクエスト

POSTリクエストはこのように実行します

curl -X POST "http://<ALBのDNS名>/api/post" \
     -H "Content-Type: application/json" \
     -d '{"key": "test_id_123", "value": "TestData"}'

今回の検証アプリでは、POSTでデータを投稿すると、SSE接続中のクライアントがリアルタイムにそのデータを受信します。 また放置すると: heartbeatメッセージになります。

まとめ

これまでリアルタイムの通信といえばUDPのイメージが強く、またリアルタイム通信は難しそうと思ったのですが自分で実装ができたのでこの分野について知見が得られたのはよかったです。 また、これまでECSにはなんとなく苦手意識がありましたが、サービスのタスク定義で、タスクやコンテナをコントロールしてデプロイをコントロールしつつ、不要な時は使わずに済むという便利さを痛感し、イメージをアップロードするだけで必要なものがある状態でアプリを動かせるということに気づけたので個人的にはその部分も良いと感じました。