WebSocket サーバーを ECS(Fargate) で構築してみた

2023.01.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

WebSocket を使って双方向通信をすることができるサーバーを ECS(Fargate) と ALB を使って構築してみます。サーバーは、 Node.js の ws を使って実装します。より安全な通信をするために wss を利用して通信したいので、証明書は AWS Certificate Manager(ACM) を使用して作成します。
構築する構成のイメージは、以下となります。

動作環境

  • Node.js: v16.15.1
  • ws: 8.11.0

ECR にリポジトリを作成

コンテナレジストリとして ECR を利用するため、リポジトリを作成します。今回はテスト用ですが、プライベートなリポジトリとしています。
リポジトリ作成後、プッシュコマンドの表示 をクリックすると、コンテナのビルドからプッシュまでのコマンドを確認することができるので、コマンドを参考にイメージをプッシュします。

WebSocket サーバは、受け取ったメッセージをそのまま返却するようなシンプルな実装としています。なお、ヘルスチェックでは WebSocket をサポートしていないようなので、HTTP を受け付けるように実装しています。

index.js

const webSocketServer = require('ws').Server;
const http = require('http');

const wss = new webSocketServer({port: 8080});

wss.on('connection', (ws) => {
    ws.on('message', (message) => {
        // ブロードキャストでメッセージを送信
        wss.clients.forEach((client) => {
            client.send(`received message: ${message}`);
        });
    });
});

// ヘルスチェック用に HTTP リクエストを受け付けるように実装
const httpServer = http.createServer((req, res) => {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/plain');
    res.end('Healthy');
});

httpServer.listen(8000, '0.0.0.0', () => {
    console.log('HTTP server running');
});

Dockerfile

FROM public.ecr.aws/docker/library/node:16.15.1-alpine

WORKDIR /usr/src/app
COPY ./package*.json ./
COPY . .
RUN npm install

EXPOSE 8080 8000
CMD ["node", "index.js"]

以下のようなコマンドを実行し ECR にコンテナイメージをプッシュします。

$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com
Login Succeeded

$ docker build --platform amd64 -t blog-websocket .

$ docker tag blog-websocket:latest xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/blog-websocket:latest

$ docker push xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/blog-websocket:latest

コマンド実行後、ECR にイメージがプッシュされていることを確認できました。

ECS クラスターを作成

ECS のクラスターを作成します。検証用に新規でクラスターを作成し、合わせて VPC も作成しました。

  • クラスターテンプレート: ネットワーキングのみ
  • クラスター名: 適当な名前を設定
  • VPC: チェックを付ける

少し待つと、クラスターが作成されました。

タスク定義を作成

検証用の環境なので、最低限のリソース設定でタスクが起動するようにして、タスク定義を作成しました。

  • 起動タイプの互換性の選択: FARGATE
  • タスクロール: 未選択
  • タスク実行ロール: 新しいロールの作成
  • タスクサイズ
    • タスクメモリ: 0.5GB
    • タスクCPU: 0.25GB
  • コンテナ定義(追加する)
    • コンテナ名: 適当な名前を設定
    • イメージ: ECRにプッシュ済みのイメージ URI (eg. xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/blog-websocket:yyyyy)

ロードバランサーを作成

今回はロードバランサーを経由して、Fargate にアクセスさせたいので、ロードバランサーを作成します。

  • Load balancer types: Application Load Balancer
  • Load balancer name: 適当な名前を設定
  • Scheme: Internet-facing
  • Network mapping: クラスターを作成した VPC と アベイラビリティゾーンとサブネットを設定
  • Security groups: 受け付けるポートに応じたセキュリティグループを設定
  • Listeners and routing: 1組のリスナーとターゲットグループを設定する必要があるので、適当な値で設定しておく(利用しないので後ほど削除する。ただし、ここで設定ないとロードバランサーを作成することができません。)

ALB 作成後、適当な設定で作成したリスナーとターゲットグループは、利用しないので削除します。

サービスを作成

これまで作成したリソースを使って、サービスを作成します。

  • サービスの設定
    • 起動タイプ: FARGATE
    • タスク定義: 作成したタスク定義を選択
    • クラスター: 作成したクラスターを選択
    • サービス名: 適当な名前を設定
    • タスクの数: 1
  • ネットワーク構成
    • クラスターVPC: 作成した VPC を選択
    • セキュリティグループ: コンテナのポートに合わせた設定
    • ロードバランサーの種類: Application Load Balancer
    • ロードバランサー名: 作成した ALB を選択
    • ロードバランス用のコンテナ(ロードバランサーに追加から追加)
      • プロダクションリスナーポート: 8080
      • プロダクションリスナープロトコル: HTTP

動作確認

ここまでの手順でサーバーの準備は整ったので、簡単なクライアントを実装して、動作確認をしてみます。アクセスは ALB が発行してくれている DNS を使って ws:// でアクセスします。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8"/>
  <title>WebSocketテスト</title>
</head>

<body>
<header>
  <h1>WebSocketテスト</h1>
</header>
<article>
  <div>
    <input type="text" id="message">
    <button id="btn">Send</button>
  </div>
  <hr>
  <div id="log">
  </div>
</article>
</body>

<script>
    const sock = new WebSocket("ws://[接続先のURI]:8080");
    const sendBtn = document.getElementById("btn");
    const message = document.getElementById("message");
    const logArea = document.getElementById("log");

    sock.addEventListener("message", e => {
        const p = document.createElement('p');
        p.textContent = `From server: ${e.data}`;
        logArea.appendChild(p);
    });

    sendBtn.addEventListener("click", e => {
        sock.send(message.value);
        const p = document.createElement('p');
        p.textContent = `From client: ${message.value}`;
        logArea.appendChild(p);
    });
</script>

</html>

この HTML ファイルをローカルのブラウザで開いて、WebSocket 通信ができているかを確認してみました。双方向で通信できていること、サーバーから接続されているクライアントにブロードキャストできていることを確認できました。

wss でアクセスできるように設定する

ws:// でアクセスできるところまでは確認できましたので、よりセキュアにアクセスできるように wss:// で接続できるように設定していきます。

  • 独自ドメインでアクセスできるようにする
  • wss で利用する証明書を作成する
  • wss で接続できるようにリスナーを設定する

Route53 を設定する

作成した ALB に独自ドメインでアクセスできるように、Route 53 にエイリアスレコードを設定します。

  • レコード名: 適当なサブドメイン名を設定
  • レコードタイプ: A
  • トラフィックのルーティング先: Application Load Balancer と Classic Load Balancer へのエイリアス を選択し、作成した ALB を選択
  • ルーティングポリシー: シンプルルーティング

ACM で証明書を作成する

WebSocket サーバへのアクセスを TLS/SSL 経由でアクセスさせるために、証明書が必要になるので、ACM で作成します。

  • 完全修飾ドメイン名: *.[独自ドメイン]
  • 検証方法: DNS 検証
    • Route53 で DNS を作成 からレコードを作成

ALB のリスナーをHTTPSに設定する

WebSocket の場合も HTTP の場合と同様に、リスナーの設定を HTTPS に設定することで TLS/SSL 経由でアクセスできそうなので、設定を変更します。

wss で動作確認

WebSocekt サーバーの設定が完了したので、wss:// で実際にアクセスしてみます。アクセスする際は、独自ドメインを指定してアクセスします。

wss でアクセスできていること、メッセージをやりとりできていることを確認することができました。

さいごに

Fargate で WebSocket サーバーを構築してみること、TLS/SSL 経由でアクセスする際に ALB + ACM を利用して設定できるか?ということを実際に構築してみながら検証してみました。事前にドキュメントを読んだ感じだとできそうだとわかっていたのですが、私自身が WebSocket や ECS(Fargate) の知識が薄かったので、実際に手を動かすことで、理解が深まりました。
WebSocket サーバの構築を検討中の方々の案の1つとなれば幸いです。

参考