Redis
Elixir
Phoenix

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

More than 3 years have passed since last update.

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

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

Phoenix で WebSocket 通信をさせる方法はコチラをご参照ください。


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

通常の Web アプリケーションにおけるスケールアウトは、サーバ台数を増やしてロードバランサでリクエストを分散させる、というのが一般的ですが、WebSocket アプリケーションではこの手法が使えません。

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

接続情報を外部で管理...と聞くと結構大変な気がしますが、この辺りをよろしく解決してくれるのが Redis の Pub/Sub 機能 です。

この Pub/Sub 機能を中継してメッセージをやりとりすることで、コネクションが Redis サーバ内で管理されるようになり、冗長化によるスケールアウトが実現されます。

今回は1つの端末内にポート番号を分けた Phoenix アプリケーションを2つと Redis サーバを起動させて、擬似的なスケールアウトを体験してみようと思います。

システム構成のイメージは以下のような感じです。

pubsub_websocket.png


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

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

$ mix phoenix.new redis_pubsub_sample

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

なお、今回はアプリケーションを 40004001 の2つのポートで別々に起動させて、WebSocket の挙動を確認してみたいと思います。

利用するポートを起動時に指定できるよう、config/dev.exs に以下の改修を加えます。


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 を開き、コメントアウトを外します。


user_socket.ex

defmodule PubsubRedisSample.UserSocket do

use Phoenix.Socket

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

## Transports
...


次に、PubsubRedisSample.RoomChannel モジュールを実装します。

web/channels/room_channel.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 から読み込ませています。


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 番ポートでアプリケーションを起動させます。

$ PORT=4000 iex -S mix phoenix.server

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

スクリーンショット 2015-09-21 19.20.08.png

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

スクリーンショット 2015-09-21 19.22.08.png

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

$ PORT=4001 iex -S mix phoenix.server

4000 番ポートと同様に一通り動作を確認したら、最後に 4000 と 4001 の両ポート間でメッセージがやりとりされないことを確認しておきましょう。

(現時点では接続情報が 4000 と 4001 で共有されていないため、メッセージはやりとりできません)

スクリーンショット 2015-09-21 19.24.18.png

大丈夫そうですね。


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 です。


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

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

$ mix deps.get

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


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

いい感じですね。


感想


  • WebSocket もちゃんとスケールアウトできるようで安心した


    • ステートフルなサービスは不慣れで不安が多い



  • Redis に Pub/Sub 機能が付いた経緯が知りたい