Горизонтальное масштабирование Socket.io

Сразу код.

В socket.io >= 1.0 проблема горизонтального масштабирования решается с помощью «адаптеров».  Адаптеры реализуют интерфейс по управлению комнатами, рассылке сообщений всем клиентам и получение списка клиентов. По-умолчанию используется адаптер, хранящий все данные в памяти, а для горизонтального масштабирования нам понадобится хранение данных во внешней системе. Достаточно популярным является socket.io-redis, его и будем использовать.

В теории вся система будет выглядеть так:
* запущено несколько экземпляров нашего приложения с socket.io сервером;
* каждый из экземпляров связан с другим через Redis с помощью socket.io-redis;
* перед экземплярами стоит балансировщик, который распределяет клиентов между всеми серверами.

Для начала установим и настроим балансировщик. Я буду использовать HAProxy только потому, что он умеет делать балансировку по query-параметрам бесплатно (у NGINX такой функционал есть только в платной версии). А балансировка по query-параметрам мне нужна только потому что я тестировал все на одном компьютере. В реально жизни, скорее всего, придется использовать балансировку по IP-адресам клиентов.

Устанавливаем HAProxy:

sudo apt-get install haproxy

Конфиг:

global
    log /dev/log    local0
    log /dev/log    local1 notice
    chroot /var/lib/haproxy
    user haproxy
    group haproxy
    daemon

defaults
    log global
    mode    http
    option  httplog
    option  dontlognull
    contimeout  5000
    clitimeout  50000
    srvtimeout  50000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

listen webfarm 0.0.0.0:3000
    mode http
    stats enable
    stats uri /haproxy?stats
    balance url_param userId
    option httpclose
    option forwardfor
    server webserver01 127.0.0.1:3001 cookie server1 check
    server webserver02 127.0.0.1:3002 cookie server2 check

Здесь идут настройки по-умолчанию и в последнем блоке настройки нашего приложения. Читаются они примерно так:
listen webfarm 0.0.0.0:3000 — слушать на всех интерфейсах и назвать приложение «webfarm».
mode http — это http-приложение (в смысле ожидать http-трафик).
stats enable и stats uri /haproxy?stats — включить статистику и выводить ее по этому адресу.
balance url_param userId — использовать балансировку по query-параметру userId (например, ?userId=152).
server webserver01 127.0.0.1:3001 cookie server1 check — объявление сервера для балансировки, который слушает по адресу 127.0.0.1:3001 и называется webserver01. Также устанавливать клиентам cookie server1 и включить проверку сервера на живучесть.

Теперь нужно запустить несколько экземпляров сервера. Я буду использовать подход двух файлов: master/worker.
Master очень простой:

var child_process = require('child_process'),
    fork = child_process.fork,
    workers = [],
    workersNum = 2;

for(var i = 0; i < workersNum; i++) {
  workers[i] = fork('socket.io-server-worker.js', [], {
    env: {
      PORT: 3000 + i + 1,
      DEBUG: 'socket.io:*,engine'
    }
  });
}

Здесь запускается 2 экземпляра worker и в каждый пробрасываются переменные окружения PORT и DEBUG.

Код worker:

var express = require('express'),
    sio_redis = require('socket.io-redis'),
    app = new express(),
    server = app.listen(process.env.PORT || 3001),
    io = require('socket.io')(server);

app.use(express.static(__dirname));
app.get('/', function(req, res) {
  res.sendFile('./index.html', {root: __dirname});
});

io.adapter(sio_redis({ host: 'localhost', port: 6379 }));

io.on('connection', function(socket) {
  console.log(socket.id, 'connected to', process.pid);
  // io.emit('newSocket', socket.id);
  socket.join('common');

  socket.on('message', function(data) {
    console.log('message', data);
    io.to('common').emit('message', data);
  });

  socket.on('privateMessage', function(data) {
    console.log('privateMessage', data);
    io.to('/#' + data.to).emit('privateMessage', data);
  });

  socket.on('broadcast', function(data) {
    console.log('broadcast', data);
    io.emit('broadcast', data);
  });

  socket.on('disconnect', function(reason) {
    console.log(socket.id, 'disconnected with reason:', reason);
  });
});

Здесь запускается HTTP-сервер, слушающий на порту, полученным от master-процесса. Этот же сервер отдает статическую страницу index.html и на него же вешается слушатель от socket.io. На 12 строке видно как устанавливается Redis-адаптер, а ниже идут обычные обработчики событий.

В index.html подключается socket.io-client и скрипт main.js:

var sockets = [],
    socketsNum = 2;

for(var i = 0; i < socketsNum; i++) {
  (function(i) {
    setTimeout(function() {
      sockets[i] = io.connect('http://' + document.location.host + '/?userId=' + i, {
        'force new connection': true
      }).on('message', function(data) {
        console.log('socket '+ i +': got `message`', data);
      }).on('broadcast', function(data) {
        console.log('socket '+ i +': got `broadcast`', data);
      }).on('privateMessage', function(data) {
        console.log('socket '+ i +': got `privateMessage`', data);
      });
    }, 1000 * i);
  })(i);
}

setTimeout(function() {
  sockets[0].emit('privateMessage', {to: sockets[1].id, test: 'some text'});
}, 1000 * socketsNum);

Здесь создается 2 подключения к серверу, вешаются обработчики. Обратите внимание на query-параметр userId — он разный для разных подключений. Так мы можем быть уверены, что оба подключения попадут на разные экземпляры приложения. После подключения всех клиентов отправляется событие privateMessage, которое должно прийти клиенту, подключенному к другому экземпляру приложения.

Ну, запускаем всю эту балалайку, открываем в браузере http://localhost:3000/index.html и смотрим в консоль. Отлично, сообщение дошло от клиента1 до сервера1, тот понял, что клиент2 подключен к серверу2, отправил сообщение в Redis, откуда его получил сервер2 и отправил клиенту2.

Вот и все, весь код на GitHub.