WebSocket を利用するアプリケーションは Pub/Sub サーバを使ってスケールアウトさせるのが一般的です。
今回は Redis の Pub/Sub 機能を使って Phoenix の WebSocket をスケールアウトさせてみます。
Phoenix で WebSocket 通信をさせる方法はコチラをご参照ください。
事前知識: WebSocket アプリケーションのスケールアウトについて
通常の Web アプリケーションにおけるスケールアウトは、サーバ台数を増やしてロードバランサでリクエストを分散させる、というのが一般的ですが、WebSocket アプリケーションではこの手法が使えません。
なぜかというと、WebSocket アプリケーションはコネクションをサーバ内で管理するステートフルな作りになっているためです。冗長化させたサーバにリクエストを分散させてしまうと、他サーバで接続されたクライアントに対してメッセージのブロードキャストができないという問題が生じてしまいます。これを解決するためには、接続情報をなんらかの方法でサーバ外部で管理して、サーバ自体はステートレスな状態にする必要があります。
接続情報を外部で管理...と聞くと結構大変な気がしますが、この辺りをよろしく解決してくれるのが [Redis の Pub/Sub 機能](Pub/Sub – Redis http://redis.io/topics/pubsub) です。
この Pub/Sub 機能を中継してメッセージをやりとりすることで、コネクションが Redis サーバ内で管理されるようになり、冗長化によるスケールアウトが実現されます。
今回は1つの端末内にポート番号を分けた Phoenix アプリケーションを2つと Redis サーバを起動させて、擬似的なスケールアウトを体験してみようと思います。
システム構成のイメージは以下のような感じです。
Phoenix アプリケーションをセットアップする
redis_pubsub_sample
というアプリケーションを作ります。
$ mix phoenix.new redis_pubsub_sample
ひとまずは依存関係は弄らないでおきます。
なお、今回はアプリケーションを 4000
と 4001
の2つのポートで別々に起動させて、WebSocket の挙動を確認してみたいと思います。
利用するポートを起動時に指定できるよう、config/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
を開き、コメントアウトを外します。
defmodule PubsubRedisSample.UserSocket do
use Phoenix.Socket
## Channels
# 以下の1行についてコメントアウトを解除
channel "rooms:*", PubsubRedisSample.RoomChannel
## Transports
...
次に、PubsubRedisSample.RoomChannel
モジュールを実装します。
web/channels/room_channel.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 から読み込ませています。
<!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
を書き換えます。
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 番ポートでアプリケーションを起動させます。
$ PORT=4000 iex -S mix phoenix.server
プラウザで http://localhost:4000/
にアクセスすると入力フォームが出ると思いますので、いくつか文字を入力して送信してみましょう。
別ウィンドウなどで http://localhost:4000/
にアクセスすると、WebSocket によるメッセージのブロードキャストが確認できると思います。
次に、ターミナルをもう1つ起動させるなどして、今度は 4001 番ポートでアプリケーションを起動させます。
$ PORT=4001 iex -S mix phoenix.server
4000 番ポートと同様に一通り動作を確認したら、最後に 4000 と 4001 の両ポート間でメッセージがやりとりされないことを確認しておきましょう。
(現時点では接続情報が 4000 と 4001 で共有されていないため、メッセージはやりとりできません)
大丈夫そうですね。
Redis を準備する
Phoenix と Redis を連携させる前に、まずは Redis 自体を準備しましょう。
と言っても、端末に Redis をインストールして起動しておくだけで OK です。
Mac な方は homebrew でサクッとインストール可能です。
$ brew install redis
$ redis-server /usr/local/etc/redis.conf
これで 6379 ポート(Redis のデフォルト)で立ち上がります。
Redis 連携をセットアップする
さて、いよいよ Redis と連携させます。
今までは Phoenix 自身の PubSub を使ってメッセージをやりとりしていましたが、今後は先程起動させた Redis の PubSub サーバを中継させるようにします。これにより、4000 と 4001 の両ポートでメッセージのやりとりができるようになるハズです。
まずは依存関係の追加をします。使うライブラリは Phoenix.PubSub.Redis です。
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
依存関係をダウンロードします。
$ mix deps.get
次に、config/config.exs
を編集して PubSub のエンドポイントを変更します。
...
# 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 で接続情報が共有され、お互いにメッセージのやりとりが可能となっているはずです。
いい感じですね。
感想
- WebSocket もちゃんとスケールアウトできるようで安心した
- ステートフルなサービスは不慣れで不安が多い
- Redis に Pub/Sub 機能が付いた経緯が知りたい