pixiv insideは移転しました! ≫ http://inside.pixiv.blog/

koaとsocket.ioをherokuで運用した話

こちらはピクシブ株式会社Advent Calendar 12/13分の記事です。


エンジニアの@geta6です。

先日開催されたAPOLLOというイベントで、今音楽を聴いている人の数をアルバム別にリアルタイムにカウントする小さなアプリケーションを本体のアプリケーションと完全に切り離してkoasocket.ioで実装し、herokuにデプロイして運用した話をします。

f:id:geta6:20141212231510p:plain

↑ アートワークの右下に見えている黒い半透明のボックスがカウンターです

今回使用したコードを少し改変したものをGitHubにあげていますので、興味がありましたら参照ください。

geta6/koa-ws

開催直前に話が飛んできた

「じゃあ今日はgeta6は例のリアルタイムカウンターの実装ね」

「サーバとかどうしますか?」

「任せる」

任されたので好きにやりました。

完全に別のアプリケーションとして実装した

本体であるBOOTHのrailsアプリケーションはunicornサーバで動いているのですが、unicornはpreforkサーバのため、リアルタイム計測を実現するためにポーリングやwebsocketでコネクションを取り続けるのは好ましくありませんでした。

また、今回の要件ではセッション情報やデータベースに格納された情報を一切必要としなかったので、node.jsを用いて完全に別のアプリケーションとして機能を実装することにしました。

フレームワークにkoaを選択した

koaを選択した理由は特にありません。expressに飽きていたから、前から気になっていたから、折角社内でnodeを使う機会を得たので新しい機能を使ってみたかったから、そんなところだと思います。

koaとは

koaはECMAScript6のGeneratorFunctionとcoを使って実装されたアプリケーションフレームワークです。GeneratorFunctionの恩恵を受けることで、非同期の処理を同期的に記述する手法が提示されています。

コールバック関数の中にコールバック関数がネストする、俗にコールバック地獄と呼ばれる状況への解決策として、DeferredPromiseasync等の手法が提示されていますが、これらと比べてとてもスマートに非同期コードを取り扱うことができます。

例として、HTTPリクエストでredisに値を書き込み、書き込んだ値を再び取得して、その結果をJSONでレスポンスする、というサンプルを示します。まずは伝統的なコールバックスタイルで記述します。

var http = require('http');
var redis = require('redis');
var client = redis.createClient();

http.createServer(function(req, res){
    function error(res, err){
        res.statusCode = 500;
        res.body = err.message;
    };
    client.hmset('foo', {foo: 'bar'}, function(err){
        if (err) return error(err);
        client.hgetall('foo', function(err, val) {
            if (err) return error(err);
            res.end(JSON.stringify(val));
        });
    });
}).listen(3000);

これと同等の機能をkoaで記述すると下記のようになります。GeneratorFunction内でyieldの右辺に渡された非同期関数がtry-catchでexceptionされていたり[1]、一つのコードブロックで連続して同期的に評価が行われている[2]のがわかると思います。[2]で発生した例外は[1]でキャッチすることができます。

var app = require('koa')();
var redis = require('redis');
var client = redis.createClient();

var hmset = function(key, val){
    return function(callback) {
        client.hmset(key, val, callback);
    }
};

var hgetall = function(key){
    return function(callback) {
        client.hgetall(key, callback);
    }
};

app.use(function*(next){ // [1]
    try {
        yield next;
    } catch(reason) {
        this.status = reason.status || 500;
        this.body = reason.message;
    }
});

app.use(function*(next){ // [2]
    yield hmset('foo', {foo: 'bar'});
    this.body = yield hgetall('foo');
    yield next;
});

app.listen(3000);

koaはAPIが洗練されているのも特長です。例えば、他のミドルウェアを評価した後に評価したいセクション(レスポンスタイムを含むロガーなど)の実装が、responseコードのoverrideやEventListenerを作ることなく一つのコードブロックで実現できるのは非常に魅力的です。

app.use(function(req, res, next){
    var now = Date.now();
    var origin = res.end;
    res.end = function(){
        console.log(Date.now() - now);
        origin.apply(this, arguments);
    };
    next(null);
});

これと同等の機能をkoaで記述すると下記のようになります。

app.use(function*(next){
    var now = Date.now();
    yield next();
    console.log(Date.now() - now);
});

koaは、expressの4.*系と同様に、パーサーやロガーなどの基本的なミドルウェアを全て外部パッケージ化して本体の実装をコンパクトに抑えています。本体にはルーターすら含まれていません。GitHubのリポジトリでソースを読んでみてもそれほど時間はかからないと思いますので、興味を持たれた方は是非ご一読ください。

herokuにデプロイした

再生回数をアルバムID別にカウントする機能を提供するには、WebSocketのインターフェースと、プロセス間で値を共有するためのKVSがあればそれだけでよかったので、herokuを利用することにしました。

herokuを選定した理由としては、デプロイが手軽かつ無料のプランが存在すること、実際にどの程度の人数が同時に接続しに来るかわからないこと、計算資源が殆ど必要なくむしろ同時接続数が重視されたこと、hubotの運用でちょうど使っていたこと、等があげられます。

イベントの開催期間中には接続数を監視しながらインスタンスの台数を増やし、朝方になって落ち着いてきたら1台に戻す、というような運用をしていました。WebInterfaceかherokuコマンドで即座にインスタンス台数を変更できるため、今回はやりませんでしたが自動化も簡単にできそうです。

イベントの終了後もイベントページ自体はアーカイブとしてアクティブな状態になっているので、1X*1の無料プランにダウングレードし、アラートだけ設定した状態で放置して機能を維持しています。サービスの動いているサーバでTCPコネクションを食べっぱなしになるアプリケーションを放置するのには少し抵抗がありますが、外部に置いておくことで気軽に機能を維持することができました。

HTTPS + socket.ioの留意点

socket.ioの1.*では、WebSocketの接続が確立できないユーザを高速に接続させるため、接続するプロトコルの優先度としてpollingを優先する変更が入っています。まずhttpで接続し、websocketが利用可能であれば即時にアップグレードする、という接続フローになっています。

今回はクロスドメインでの接続が念頭にあったこと・モダンブラウザのみが対象であったこと・例え接続が確立できなくてもカウンターが表示されないだけで他機能と競合しないこと等を踏まえて、優先度としてwebsocketのみを利用する設定をクライアント側で行いました。

また、今回のイベントページはHTTPSで提供されており、クライアント側はHTTPSから接続を行います。herokuはアプリケーションのインターフェースにデフォルトでhttpsを提供してくれているので、そちらを利用してwssプロトコルで接続します。下記のような設定で接続を確立することができました。

io.connect('wss://***.herokuapp.com', {
    transports: ['websocket']
});

まとめ

メンテはしないが維持したいイベント限りの単発機能を本体とは完全に別のアプリケーションとして実装し、herokuなど外部のインスタンスに投げてしまうのは非常にお手軽で有用でした。本体のデプロイフローとは完全に切り離されたレベルでの開発が可能でしたので、メンテナンスもデプロイも気楽なものでした(ユーザ情報等が絡んでくるとそうはいかないと思いますが)。

今回の要件は他機能との競合が一切無く、最悪止まってしまっても一機能が提供されなくなるだけで特に問題ないという少し特殊な要件でしたが、何か参考になるようなことがあれば幸いです。

明日はハイパーエンジニアの @catatsuy が最高の話をしてくれると思います。ご期待ください。