Phoenix で Redis の Pub/Sub を使って WebSocket をスケールアウトさせる

WebSocket を利用するアプリケーションは Pub/Sub サーバを使ってスケールアウトさせるのが一般的です。

今回は Redis の Pub/Sub 機能を使って Phoenix の WebSocket をスケールアウトさせてみます。

Phoenix で WebSocket 通信をさせる方法は[コチラ](http://qiita.com/mserizawa/items/2c67031e794964f3a740)をご参照ください。

# 事前知識: WebSocket アプリケーションのスケールアウトについて

通常の Web アプリケーションにおけるスケールアウトは、サーバ台数を増やしてロードバランサでリクエストを分散させる、というのが一般的ですが、WebSocket アプリケーションではこの手法が使えません。
なぜかというと、WebSocket アプリケーションはコネクションをサーバ内で管理する**ステートフル**な作りになっているためです。冗長化させたサーバにリクエストを分散させてしまうと、**他サーバで接続されたクライアントに対してメッセージのブロードキャストができない**という問題が生じてしまいます。これを解決するためには、接続情報をなんらかの方法でサーバ外部で管理して、サーバ自体はステートレスな状態にする必要があります。

接続情報を外部で管理...と聞くと結構大変な気がしますが、この辺りをよろしく解決してくれるのが [Redis の Pub/Sub 機能](Pub/Sub – Redis http://redis.io/topics/pubsub) です。
この Pub/Sub 機能を中継してメッセージをやりとりすることで、コネクションが Redis サーバ内で管理されるようになり、冗長化によるスケールアウトが実現されます。

今回は１つの端末内にポート番号を分けた Phoenix アプリケーションを2つと Redis サーバを起動させて、擬似的なスケールアウトを体験してみようと思います。
システム構成のイメージは以下のような感じです。

![pubsub_websocket.png](https://qiita-image-store.s3.amazonaws.com/0/77513/f193f229-1a4f-a30a-fdb6-eba4e46bf19b.png)


# Phoenix アプリケーションをセットアップする

`redis_pubsub_sample` というアプリケーションを作ります。

```bash
$ mix phoenix.new redis_pubsub_sample
```

ひとまずは依存関係は弄らないでおきます。

なお、今回はアプリケーションを `4000` と `4001` の2つのポートで別々に起動させて、WebSocket の挙動を確認してみたいと思います。
利用するポートを起動時に指定できるよう、`config/dev.exs` に以下の改修を加えます。

```ex:dev.exs
use Mix.Config
...
config :pubsub_redis_sample, PubsubRedisSample.Endpoint,
  # http: [port: 4000],
  # ポート番号を環境変数 PORT から取得するようにする
  http: [port: System.get_env("PORT")],
  debug_errors: true,
  code_reloader: true,
  cache_static_lookup: false,
...
```

以上でセットアップは完了です。

# 簡易的なチャットアプリを作成する

動作確認用に簡易的なチャットアプリを構築します。

`web/channels/user_socket.ex` を開き、コメントアウトを外します。

```ex:user_socket.ex
defmodule PubsubRedisSample.UserSocket do
  use Phoenix.Socket

  ## Channels
  # 以下の1行についてコメントアウトを解除
  channel "rooms:*", PubsubRedisSample.RoomChannel

  ## Transports
...
```

次に、`PubsubRedisSample.RoomChannel` モジュールを実装します。
`web/channels/room_channel.ex` というファイルを作成し、以下のように記述します。

```ex:room_cahnnel.ex
defmodule PubsubRedisSample.RoomChannel do
  use Phoenix.Channel

  def join("rooms:lobby", _auth_msg, socket) do
    {:ok, socket}
  end
  def join("rooms:" <> _private_room_id, _auth_msg, socket) do
    {:error, %{reason: "unauthorized"}}
  end

  def handle_in("send_message", %{"message" => message}, socket) do
    broadcast! socket, "receive_message", %{message: message}
    {:noreply, socket}
  end
end
```

以上でサーバサイドは完了で、次にクライアントサイドを作ります。
例のごとく `web/templates/layout/app.html.eex` を直に書き換えていきましょう。
メッセージ送信用のフォームと、受け取ったメッセージを表示するコンテナを作ります。
また、今回は jQuery を利用しますので、`app.js` に加えて jQuery を CDN から読み込ませています。

```jsp:app.html.eex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Phoenix PubSub Sample</title>
    <link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
    <style>
      .received-message {
        color: #555;
        font-style: italic;
        padding: 10px 0 5px;
        border-bottom: 1px solid #ddd;
      }
      .received-message:first-child {
        font-size: 2em;
      }
    </style>
  </head>

  <body>
    <div class="container" role="main">
      <form class="form-inline">
        <div class="form-group">
          <input type="text" class="form-control" id="input-send-message">
        </div>
        <button type="submit" class="btn btn-default">Send</button>
      </form>
      <div id="received-messages">
      </div>
    </div>

    <script src="//code.jquery.com/jquery-2.1.4.min.js"></script>
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>
</html>
```

最後に `app.js` を書き換えます。

```app.js
import {Socket} from "deps/phoenix/web/static/js/phoenix/"

var socket = new Socket("/socket");
socket.connect();
var channel = socket.channel("rooms:lobby", {});
channel.join();

$("form").submit(function(e) {
    e.preventDefault();
    channel.push("send_message", {message: $("#input-send-message").val()});
    $("#input-send-message").val("");
});

channel.on("receive_message", function(dt) {
    var div = $("<div></div>", {"class": "received-message"})
        .text(dt.message);
    $("#received-messages").prepend(div);
});
```

フォームが送信されたタイミングでチャンネルに対してプッシュし、メッセージが受信されたタイミングでコンテナにメッセージを表示させています。

以上で実装は完了です。


# 動作を確認する その一

まずはこのチャットアプリ単体の動作を確認してみましょう。
以下のコマンドで、4000 番ポートでアプリケーションを起動させます。

```bash
$ PORT=4000 iex -S mix phoenix.server
```

プラウザで `http://localhost:4000/` にアクセスすると入力フォームが出ると思いますので、いくつか文字を入力して送信してみましょう。

![スクリーンショット 2015-09-21 19.20.08.png](https://qiita-image-store.s3.amazonaws.com/0/77513/7743f411-3f86-6980-8956-ea792e9512e5.png)

別ウィンドウなどで `http://localhost:4000/` にアクセスすると、WebSocket によるメッセージのブロードキャストが確認できると思います。

![スクリーンショット 2015-09-21 19.22.08.png](https://qiita-image-store.s3.amazonaws.com/0/77513/1974df98-71ef-d861-2a8b-94cdfe9592cf.png)

次に、ターミナルをもう１つ起動させるなどして、今度は 4001 番ポートでアプリケーションを起動させます。

```bash
$ PORT=4001 iex -S mix phoenix.server
```

4000 番ポートと同様に一通り動作を確認したら、最後に **4000 と 4001 の両ポート間でメッセージがやりとりされない**ことを確認しておきましょう。
（現時点では接続情報が 4000 と 4001 で共有されていないため、メッセージはやりとりできません）

![スクリーンショット 2015-09-21 19.24.18.png](https://qiita-image-store.s3.amazonaws.com/0/77513/b1d3d5da-4c53-de67-5ea6-4dc09ee5861f.png)

大丈夫そうですね。

# Redis を準備する

Phoenix と Redis を連携させる前に、まずは Redis 自体を準備しましょう。
と言っても、端末に Redis をインストールして起動しておくだけで OK です。
Mac な方は homebrew でサクッとインストール可能です。

```bash
$ brew install redis
$ redis-server /usr/local/etc/redis.conf
```

これで 6379 ポート（Redis のデフォルト）で立ち上がります。

# Redis 連携をセットアップする

さて、いよいよ Redis と連携させます。
今までは Phoenix 自身の PubSub を使ってメッセージをやりとりしていましたが、今後は先程起動させた Redis の PubSub サーバを中継させるようにします。これにより、4000 と 4001 の両ポートでメッセージのやりとりができるようになるハズです。

まずは依存関係の追加をします。使うライブラリは [Phoenix.PubSub.Redis](https://github.com/phoenixframework/phoenix_pubsub_redis) です。

```ex:mix.exs
defmodule PubsubRedisSample.Mixfile do
  ...
  def application do
    [mod: {PubsubRedisSample, []},
     applications: [:phoenix, :phoenix_html, :cowboy, :logger,
                    :phoenix_ecto, :postgrex,
                    # phoenix_pubsub_redis を追加
                    :phoenix_pubsub_redis]]
  end
  ...
  defp deps do
    [{:phoenix, "~> 1.0.0"},
     {:phoenix_ecto, "~> 1.1"},
     {:postgrex, ">= 0.0.0"},
     {:phoenix_html, "~> 2.1"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:cowboy, "~> 1.0"},
     # phoenix_pubsub_redis を追加
     {:phoenix_pubsub_redis, "~> 1.0.0"}]
  end
end
```

依存関係をダウンロードします。

```bash
$ mix deps.get
```

次に、`config/config.exs` を編集して PubSub のエンドポイントを変更します。

```ex:config.exs
...
# Configures the endpoint
config :pubsub_redis_sample, PubsubRedisSample.Endpoint,
  url: [host: "localhost"],
  root: Path.dirname(__DIR__),
  secret_key_base: "gu6oPHeuSxfTrcoUg22GFBBmKFGnn2AebGrRe/tUY4g2drnpxZWKgraTbztTpJwp",
  render_errors: [accepts: ~w(html json)],
# 従来の設定はコメントアウトしておきます
#  pubsub: [name: PubsubRedisSample.PubSub,
#           adapter: Phoenix.PubSub.PG2]
  pubsub: [name: PubsubRedisSample.PubSub,
           adapter: Phoenix.PubSub.Redis,
           host: "localhost"]
...
```

以上で Redis 連携のセットアップは完了です。

# 動作を確認する その二

さて、先程と同様に 4000 番ポートと 4001 番ポートでそれぞれアプリケーションを起動して、ブラウザからメッセージのやりとりをしてみましょう。
今回は中継サーバを入れたために 4000 と 4001 で接続情報が共有され、**お互いにメッセージのやりとりが可能となっているはず**です。

![スクリーンショット 2015-09-21 19.52.40.png](https://qiita-image-store.s3.amazonaws.com/0/77513/db110620-59e3-c30f-940c-46c218f39fab.png)

いい感じですね。

# 感想

* WebSocket もちゃんとスケールアウトできるようで安心した
    * ステートフルなサービスは不慣れで不安が多い
* Redis に Pub/Sub 機能が付いた経緯が知りたい