2013年2月7日木曜日

Node.jsってなんじゃ?(Socket.IOとELBのまとめ)

過去の記事でnode.jsの話題をいくつか取り上げて来ましたが、node.jsではAmazonのELBと併用した時の問題があり、その注意点をまとめました。

Socket.IO


Socket.IOはnode.jsでwebsocketを使用するときのデファクトといっていいライブラリです。
他のSocket.IOが人気になったのは、以下のような利点のためです。

  • websocketをサポートしていないブラウザでは、自動的にxhrなどのポーリング使い通信できる
  • 接続に失敗しても再接続などを自動的に行う


ここではSocket.IOを使用する前提で、ELBを経由して複数のnodeサーバーをホストする場合についてまとめてみました。

インストールに関しては以下に書いてありますので割愛します。
WebSocketってなんじゃ?(Node編2 Socket.IOでプッシュ通信)

nodeサーバーには以下のようにファイルを配置します。上記の記事などで使用したチャットアプリです。
publicをhttpdのドキュメントルートにしておきます。
app
├── node
│   └── server.js
└── public
    ├── assets
    │   └── js
    │       └── client.js
    ├── health.txt
    └── index.html

public/health.txtはELB用のヘルスチェックファイルで、中身はありません。
その他の各ファイルの内容は以下の通りです。

server.js
var io = require('socket.io').listen(3000);
io.sockets.on('connection', function (socket) {
socket.emit('info', { msg: 'welcome' });
socket.on('msg', function (msg) {
io.sockets.emit('msg', {msg: msg});
});
socket.on('disconnect', function(){
socket.emit('info', {msg: 'bye'});
});
});
view raw gistfile1.js hosted with ❤ by GitHub
index.html
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript">
var hostname = location.hostname;
$.ajaxSetup({cache: true});
$(function(){
function load(){
$.getScript("/assets/js/client.js");
}
$.getScript("http://" + hostname + ":3000/socket.io/socket.io.js", function(){
load();
});
});
</script>
<title>nodetest</title>
</head>
<body>
<input id="msg" type="text" style="width:400px;"></input>
<input id="send" type="button" value="send" /></br >
<div id="log" style="width:400px;height:400px;overflow:auto;border:1px solid #000000;"></div>
</html>
view raw gistfile1.html hosted with ❤ by GitHub
client.js
$(function(){
var socket = io.connect('http://'+location.hostname+':3000/');
socket.on('connect', function(){
$("#log").html($("#log").html() + "<br />" + (new Date()).toLocaleString()+ 'connected');
});
socket.on('disconnect', function(){
$("#log").html($("#log").html() + "<br />" + (new Date()).toLocaleString()+'disconnected');
});
socket.on('info', function (data) {
$("#log").html($("#log").html() + "<br />" + (new Date()).toLocaleString()+ data.msg);
});
socket.on('msg', function(data){
$("#log").html($("#log").html() + "<br />" + (new Date()).toLocaleString()+ "<b>" + data.msg + "</b>");
});
$("#send").click(function(){
var msg = $("#msg").val();
if(!msg){
alert("input your message");
return;
}
socket.emit('msg', msg);
});
});
view raw gistfile1.js hosted with ❤ by GitHub



ポートは3000番を利用します。
ここで、httpdを起動しておきます。
また、
node server.js

などで、nodeを起動しておきます。


AWS


単体構成



まず最初の例として、AWSは例として以下のような構成だとします。
nodeサーバーのEC2インスタンスはhtmlのホストとwebsocketの両方を行うので、80と3000のポートをセキュリティグループで開放しておきます。

画面を開いてみます。

普通に成功します。
通信をみてみると、websocketで通信されていることがわかります。


ELB


次に、nodeサーバーのインスタンスをもう一台追加し、新規作成したELB配下に2つのインスタンスをおきます。



ELBのリスナーを以下のように80番と3000番を設定します。


そしてELBのエンドポイントのURLをブラウザで開きます。


すると、xhr-pollingになり、接続と切断が繰り返されます。
また、画面を2つ開いてメッセージを送信しても相手に届かない場合があります。

注意点1:ELBはhttpではなくtcpでポートを設定


websocket通信ではクライアントとサーバーの間のハンドシェイクにUpgradeヘッダを送信します。
しかし、ELBはhttpリスナーの場合Upgradeヘッダを削ってしまうようです。
RFC6455 — The WebSocket Protocol 日本語訳
ELBでHTTPリスナーだとWebSocketは使えない

そのため、Socket.IOはwebsocketが使えないと判断し、次善策の一つとして通常のhttp通信でxhr-pollingで接続することになります。これはajaxの通信と同じです。
そこで、ELBでは上記リンクの通り、websocket用のリスナーはhttpの3000番ではなくtcpの3000番を設定する必要があります。



注意点2:Redisでセッション共有を行う


また、接続や切断が繰り返されるのはxhrのポーリングのたびにハンドシェイクが確立したサーバーとは別のサーバーに接続にいくからのようです。これはELBのリスナー設定をtcpにした場合も同様で、一度websocket通信が確立したかのように見えても、次に接続した時に別のサーバーにつながると、接続が切れたもしくはwebsocketに失敗したと判断し xhr-pollingなど他の方法で通信しようとするようです。

また、そもそもの問題として2つのnodeサーバーの間で接続情報(セッション)が共有されないので、ELBを通してnode1とnode2にそれぞれ接続したクライアント間ではメッセージのやりとりができません。

そこで、nodeサーバーのバックエンドとして、redisを利用してセッション共有を行う方法が有効です。
socket.ioはセッションを保持する方式としてローカルメモリを使用するMemoryStoreを使用しますが、オプションでRedisにを使用RedisStoreが選べます
Configuring Socket.IO

これを使います。
redisサーバーを追加し、redisを起動しておきます。
インスタンスにはセキュリティグループなどでredisで使用するポートを開放しておきます。



そして、以下のようにserver.jsを変更します。

server.js
var io = require('socket.io').listen(3000);
var RedisStore = require('socket.io/lib/stores/redis');
opts = {host:'xxx.xxx.xxx.xxx', port:6379};
io.set('store', new RedisStore({redisPub:opts, redisSub:opts, redisClient:opts}));
io.sockets.on('connection', function (socket) {
socket.emit('info', { msg: 'welcome' });
socket.on('msg', function (msg) {
io.sockets.emit('msg', {msg: msg});
});
socket.on('disconnect', function(){
socket.emit('info', {msg: 'bye'});
});
});
view raw gistfile1.js hosted with ❤ by GitHub


これで、redisサーバーでnode1,node2のセッションを共有できます。


何度か試しましたが、うまく通信できているようです。


まとめ


注意点としては、ELBをつかった場合はtcpでリッスンすることと、ELBにかぎらずnodeサーバーをスケールする場合は、redisサーバーでセッション共有する必要があります。

以上です。