Hono/Bun の WebSocket を GCP CloudRun にデプロイする

## はじめに
今回は ws ライブラリを使用しての一般的なWebSocketサーバー実装ではなく、
Hono/Bun に用意された createBunWebSocket を使って
WebSocket サーバーを実装したかったのですが、
思ったより落とし穴があったのと情報が少なかったため記事にしてみました。

## 必要な環境
- [Node.js](https://nodejs.org) と [Bun](https://bun.sh) のインストール
- [GCP CLI (gcloud)](https://cloud.google.com/sdk/docs/install) のインストールと設定
- GCP プロジェクトが作成済みであること
- Docker のインストール

## ソースコード

Hono/Bun には createBunWebSocket が用意されており、このコードは `/ws` エンドポイントで WebSocket を提供し、upgradeWebSocketを使ってクライアントからのメッセージを受け取って応答します。
ちなみに "/" のルートパスで提供しているように、RestAPIとWebSocketの実装を共存することができます。

```javascript
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";

const { upgradeWebSocket, websocket } = createBunWebSocket();
const app = new Hono();

const clients: Set<ServerWebSocket> = new Set();

app.get("/", (c) => {
  return c.json({
    time: new Date().toLocaleString(),
    message: "Hello, World!",
  });
});

app.get(
  "/ws",
  upgradeWebSocket((c) => {
    return {
      onOpen(ws: ServerWebSocket) {
        setInterval(() => {
          console.log(43434, "interval passing");
        }, 1000 * 5);
        clients.add(ws); // 接続クライアントを保持
      },
      onMessage(event, ws) {
        console.log(`Message from client: ${event.data}`);

        // 自分の接続クライアント以外のクライアントに配信する
        clients.forEach((client: ServerWebSocket) => {
          if (client !== ws) { // 自分以外のクライアントに送信
            client.send(event.data.toString());
          }
        });
      },
      onClose: () => {
        console.log("Connection closed");
      },
    };
  }),
);

Bun.serve({
  fetch: app.fetch,
  websocket
});

export default app;
```

実装自体は非常にシンプルですね。
普通と少し違うのは特定の "/ws" というパスに 
upgradeWebSocket を実装する形であったことです。

## Dockerfile の作成
Cloud Run 向けに実行コンテナを Dockerfile を作成します。

### dockerfile

```
# ベースイメージを指定
FROM oven/bun AS build

WORKDIR /app

COPY package*.json ./
RUN bun install

# アプリケーションのソースコードをコピー
COPY . .

EXPOSE 8080

# アプリケーションを起動
CMD ["bun", "run", "start"]
```

### 落とし穴
Dockerfileに記載した CMD ["bun", "run", "start"] ですが、
package.jsonに多くの場合は下記のように書かれているのではないでしょうか。

```
  "scripts": {
    "dev": "bun run --hot src/index.ts",
    "start": "bun run src/index.ts",
```
ここが落とし穴の一つでした。Hono/BunのWebSocket実装をCloud Runにデプロイするには --hot オプションをつけないと下記のエラーで落ちます。これは通常のRestAPIの実装やNextJSのデプロイでは起こらないため非常に気づきにくいです。

```
error: Failed to start server. Is port 8080 in use?
```

start にも --hot オプションを付けましょう。

（これはおそらく、CloudRunの内部で8080ポートへのプロキシーサーバーを立てる際に動的にポート移動できないと、ポートの取り合いが起こるものと思われます...）

## Cloud Run にデプロイ
以下の手順で GCP Cloud Run にデプロイします。

###  1. イメージをビルド
```
docker buildx build -t gcr.io/<プロジェクトID>/<CloudRunアプリケーション名>:latest --platform linux/amd64 .
```

###  2. ビルドしたDockerイメージをアップロード
```bash
docker push gcr.io/<プロジェクトID>/<CloudRunアプリケーション名>
```

### 3. Cloud Run にデプロイ

```bash
gcloud run deploy <CloudRunアプリ名> \
    --image gcr.io/<プロジェクトID>/<CloudRunアプリ名>:latest \
    --platform managed \
    --allow-unauthenticated \
    --port 8080
```

リージョンの選択が出てきますが、コマンドで指定したい場合は下記オプションをつけてください。

```bash
--region=<リージョン名>
```

Cloud SQLへの接続も許可する場合は下記も。

```bash
--add-cloudsql-instances=<プロジェクトID>:<リージョン名>:<DB名> \
```

3. デプロイ完了後の確認
デプロイが完了すると、Cloud Run の URL が表示されます。
この URL をブラウザで開いて、/ または /ws エンドポイントにアクセスして動作を確認してください。
接続先へのWebSocketURLは下記のようになり、ポート指定はなしで大丈夫です。

```
wss://<https://を除いたCloudRunの公開URL>/ws
```

"/ws" なしでは接続できないことにも注意です。

## テスト接続

テスト接続用のpythonコードを置いておきます。

```
import websocket

def on_message(ws, message):
    print(f"Received: {message}")

def on_error(ws, error):
    print(f"Error: {error}")

def on_close(ws, close_status_code, close_msg):
    print("### closed ###")

def on_open(ws):
    print("Opened connection")
    ws.send("Hello WebSocket Server!")

if __name__ == "__main__":
    websocket.enableTrace(True)
    ws = websocket.WebSocketApp("wss://<CloudRunの公開URL>/ws",
                                on_open=on_open,
                                on_message=on_message,
                                on_error=on_error,
                                on_close=on_close)

    ws.run_forever()
```

## おわりに
今回無事にHono/BunによるWebSocketサーバーが実装できましたが、CloudRunのようなフルマネージドなサーバーではスケール設定に注意が必要です。

Cloud Run はリクエスト数に応じて自動的にスケールしますが、WebSocket 接続はリクエストのように短時間で終了せず、接続が維持されるため、スケーリングの挙動に影響を与える可能性があります。

- **同時接続の制限**: Cloud Run ではコンテナごとに接続数の制限があります。例えば、1つのコンテナで多数のクライアントと接続を維持すると、リソースが不足してスケールが正しく機能しなくなる場合があります。
- **アイドル状態のタイムアウト**: Cloud Run のインスタンスはアイドル状態が続くと終了します。WebSocket 接続がある場合も、長時間アイドル状態が続くと接続が切断される可能性があります。

本番環境では上記の点を気にしつつ、快適なWebSocketライフを送りましょう！