Skip to content

Instantly share code, notes, and snippets.

@nulltask
Last active September 13, 2020 09:08
Show Gist options
  • Save nulltask/89e6f36e194c951697a0 to your computer and use it in GitHub Desktop.
Save nulltask/89e6f36e194c951697a0 to your computer and use it in GitHub Desktop.
Express / Socket.IO をスケールアウトしてみよう

Express / Socket.IO をスケールアウトしてみよう

スケーラビリティとは

システムの規模に依らず機能を適応できること

  • リクエストに対するスケーラビリティ
  • アプリケーションコードに対するスケーラビリティ

Express

言わずと知れたウェブアプリケーションフレームワーク

  • 右も左もわからなかった頃 => app.js の肥大化
    • メンテナビリティの低下
    • アプリの規模が大きくなってもメンテナビリティを確保したい

Mounting Sub Application

  • Express のアプリを use でマウントする機構
  • Rails Mountable Engine のような機能
  • マトリョーシカみたい
var app = express();
var sub = express();

sub.get('/hello', function(req, res) {
  res.send('this is /sub/hello');
});

app.use('/sub', sub); // /sub 下にマウント

express.Router();

  • ルーティングそのものをモジュール化
  • アプリのマウントより粒度を細かく出来る
  • Express 4.0 以降の新機能
var app = express();
var users = express.Router();

users.use(fn); // 特定の router に対する middleware

users.get('/', fn);
users.get('/:id', fn);
users.post('/', fn);
users.put('/:id', fn);
users.del('/:id', fn);

app.use('/users', users);

Cluster

  • よく言われること
    • 「Node.js はシングルスレッドだから CPU 使い切れない...」
    • 「Cluster でマルチプロセス化すれば性能向上できるよ」

boot.js:

var cluster = require('cluster');

module.exports = function(child, num) {
  return cluster.isMaster() ? master(num) : child();
};

function master(num) {
  num = num || require('os').cpus().length;
  cluster.on('exit', function(worker, code, signal) {
    cluster.fork();
  });
  for (var i = 0; i < num; ++i) cluster.fork();
};

server.js:

var app = require('./app');
// var io = require('./io');
var boot = requier('./boot');

boot(function() {
  var server = app.listen(3000, function() {
    // io.attach(server);
  });
}, require('os').cpus().length);

Benchmarking

  • JMeter!
    • 詳しくは Google で検索

ELB でバランシングする際、Node サーバに Nginx を設置しないほうがオーバーヘッドが少なく Node のプロセスを直接バランシングしたほうが高速だった。

Socket.IO

言わずと知れたリアルタイムウェブアプリケーションフレームワーク

  • 接続数
  • CPU リソース
    • Socket.IO / Engine.IO 自体のコスト
      • ハンドシェイキング
      • 接続クライアントの管理など
    • アプリケーションコードのコスト
      • ブロードキャストの頻度など
      • 要件次第で変動

CPU 使用率との戦いになるため、大規模になる場合は Web アプリと一緒にせず、分けたほうが吉。

アプリケーションは Engine.IO と Socket.IO の関係性を真似るといいかも。 Socket.IO のクライアントを包含するようなクラスを作ってビジネスロジックを記述する。

クライアントサイドのコードは、DOM に依存するレイヤーを分けておくと、Node.js 側で socket.io-client を使ってベンチマークテストする時にコードが流用しやすくなる。

同時接続数の上限緩和

オープンできるファイル数 (ソケット数) の上限が設定されているので緩和する

/etc/security/limits.conf:

* soft nofile 65536
* hard nofile 65536

これだけではデーモンプロセスには適応されない

/etc/sysconfig/init:

ulimit -n 65536

設定を変更したら一旦再起動。再起動後に /proc/${PID}/limits で上限が緩和されているか確認しましょう

Socket.IO + ELB の制約

Socket.IO と組み合わせた時の ELB の悲しい制約

  • HTTP モードの場合
    • HTTP ヘッダが書き換えられ HTTP Upgrade 出来ない
    • WebSocket が使えないため XHR polling が強制される
  • TCP モードの場合
    • Sticky Session が使えない
    • 接続するたび別のサーバが応答するため Socket.IO のハンドシェイキングに失敗する

ELB を使わず複数台をバランシングする場合、Socket.IO 接続前に、どの Nginx ホストに接続するかをサーバに問い合わせて、もらったホストに接続するように知る。

Nginx

コネクション数の設定と、HTTP Upgrade が有効になっていることがキモ。ip_hash で stickiness を有効にするのを忘れずに。

/etc/nginx/nginx.conf:

events {
  worker_connections 32768;
}
worker_rlimit_nofile 65536;

/etc/nginx/conf.d/virtual.conf:

upstream io {
  ip_hash;  # <= Important!
  server 0.0.0.0:3000;
  server 0.0.0.0:3001;
  server 0.0.0.0:3002;
  server 0.0.0.0:3003;
}

server {
  listen 80;
  location / {
    proxy_set_header Upgrade $http_upgrade;  # <= Important!
    proxy_set_header Connection "upgrade";  # <= Important!
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_pass http://io;
  }
}

Socket.IO Redis Adapter

Redis の Pub/Sub を使って複数プロセスを協調するようにする

  • https://github.com/Automattic/socket.io-redis
  • 別プロセスに接続されているクライアントに対してメッセージを送信
  • 特定のプロセスからのブロードキャストをプロセス全体に通知
  • Emitter を使って非 Socket.IO プロセスからの通知をハンドリング出来る
var io = require('socket.io')();
var redis = require('socket.io-redis');

io.adapter(redis('redis.host:6379'));

Socket.IO Emitter

別の Node プロセスからメッセージを送信する。(Redis Adapter に依存。)

var emitter = require('socket.io-emitter')('redis.host:6379');
emitter.emit('broadcast', 'this is broadcasting'); // broadcasting

emitter.of('/nsp').emit('broadcast', 'broadcasting to namespace');
emitter.of(socketId).emit('greeting', 'Hello, ' + socketId + '!');

Socket.IO Emitter for Ruby

Socket.IO Emitter の Ruby 版。別の Ruby プロセスからメッセージを送信する。(Redis Adapter に依存。)

Gemfile:

gem 'redis'
gem 'socket.io-emitter'

broadcast.rb:

emitter = Emitter.new(Redis.new)
emitter.emit('greeting', 'hello from ruby');
emitter.of('/nsp').emit('greeting', 'hello from ruby');

接続数を知る

var io = require('socket.io')();

Object.keys(io.sockets.sockets).length;

io.httpServer.getConnection(function(err, count) {
  console.log(count);
});

Benchmarking

  • require('socket.io-client');

    • Node.js でクライアントを大量生成
    • http.globalAgent.maxSockets で同時接続数を上げておく
    • クライアントの動作をシミュレートする
    • Browserify でクライアントとコードを共有できるかも
  • Multiple IFRAME

    • クライアントと同じ振る舞いをするページを IFRAME で大量生成
    • 親ページから postMesage で IFRAME を操る
    • 同一ホストへの同時接続数が設定できる Firefox 限定
      • about:config => network.http.max-persistent-connections-per-server
    • Chrome は同時接続数を増やせないっぽい
var iframe = document.createElement('iframe');
iframe.src = '/client.html';

接続数と CPU・ネットワーク負荷を確認しながら 1 サーバ、1 プロセスあたりの接続上限数を見極める。

まとめ

  • Cluster, Nginx, ELB をうまく使う
  • プロセスは機能毎に思い切って分割する
    • 一つのアプリで多くのことをしすぎない
    • 経験上 Socket.IO と Express は密結合にならないほうが吉
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment