Как масштабировать WebSocket?
Как разработчик, вы, вероятно, знаете разницу между вертикальным и горизонтальным масштабированием. Но если у вас нет большого опыта работы с протоколом WebSocket, вы можете не осознавать, что горизонтальное масштабирование для него не так просто, как с обычным REST API. В этом руководстве мы узнаем, как масштабировать серверы WebSocket, на простых практических примерах.
Когда мы начинаем думать о разработке приложения, мы обычно сначала сосредотачиваемся на MVP и наиболее важных функциях. Это нормально, если мы понимаем, что в какой-то момент нам нужно будет сосредоточиться на масштабируемости . Для большинства REST API это довольно просто. Однако, когда дело доходит до WebSockets, это совсем другая история.
Вертикальное и горизонтальное масштабирование - какая разница
Мы все знаем, что такое масштабирование, но знаем ли мы, что существует два типа масштабирования?
Первый - вертикальное масштабирование. На сегодняшний день это самый простой способ масштабирования вашего приложения, но в то же время он имеет свои ограничения.
Вертикальное масштабирование - это все о ресурсах. Мы собираемся сохранить один экземпляр нашего приложения и просто улучшить аппаратное обеспечение - улучшенный процессор, больше памяти, более быстрый ввод-вывод и т.д. Это не требует дополнительной работы и, в то же время, это не самый эффективный вариант масштабирования.
Прежде всего, время выполнения нашего кода не меняется линейно с улучшенным оборудованием. Более того, мы ограничены возможными аппаратными усовершенствованиями - очевидно, мы не можем бесконечно увеличивать скорость нашего процессора.
Итак, что насчет альтернативы? Вместо добавления дополнительных ресурсов к существующим экземплярам, мы могли бы подумать о создании дополнительных. Это называется горизонтальным масштабированием.
Такой подход позволяет нам масштабироваться практически бесконечно. В настоящее время даже возможно иметь динамическое масштабирование - экземпляры добавляются и удаляются в зависимости от текущей нагрузки.
С другой стороны, для этого требуется немного больше конфигурации, поскольку вам нужен как минимум один дополнительный компонент - балансировщик нагрузки, который отвечает за распределение запросов в конкретном экземпляре, а для некоторых систем нам необходимо ввести дополнительные службы, например, обмен сообщениями.
Горизонтальное масштабирование с помощью WebSocket Проблема № 1: Состояние
Хорошо, допустим, у нас есть два простых приложения. Одним из них является простой REST API:
const express = require("express");
const app = express();
const mockedUsers = [
{
id: 1,
firstname: "John",
lastname: "Doe"
}
];
app.get("/users", (req, res) => {
res.json(mockedUsers);
});
app.listen(3000);
Другой - простой API WebSocket:
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
const mockedUsers = [
{
id: 1,
firstname: "John",
lastname: "Doe"
}
];
wss.on("connection", ws => {
ws.on("message", data => {
const message = JSON.parse(data);
if (message.type === "get-users") {
ws.send(JSON.stringify(mockedUsers));
}
});
});
Несмотря на то, что оба API используют разные способы связи, база кода довольно схожа. Более того, не будет никакой разницы между этими двумя, когда дело доходит до вертикального масштабирования. Проблема возникает с горизонтальным масштабированием. Чтобы иметь возможность обрабатывать несколько экземпляров, нам нужно ввести балансировщик нагрузки. Это специальный сервис, отвечающий за равномерное (с использованием выбранной стратегии) распределение трафика между экземплярами.
HAProxy является примером балансировщика нагрузки. Все, что нам нужно, это предоставить простую конфигурацию.
defaults
mode http
option http-server-close
timeout connect 5s
timeout client 30s
timeout client-fin 30s
timeout server 30s
timeout tunnel 1h
default-server inter 1s rise 2 fall 1 on-marked-down shutdown-sessions
option forwardfor
frontend all
bind 127.0.0.1:8080
default_backend backends
backend backends
server srv1 127.0.0.1:8081 check
server srv2 127.0.0.1:8082 check
Как видите, мы определяем внешний и внутренний интерфейсы. Интерфейс будет общедоступным (это адрес, используемый для связи с нашими бэкэндами). Нам нужно указать для него адрес, а также имя сервера, который будет использоваться для него. После этого нам нужно определить наши бэкэнды. В нашем случае у нас есть два из них, оба используют один и тот же IP, но разные порты. По умолчанию HAProxy использует стратегию циклического перебора - каждый запрос направляется следующему бэкэнду в списке, а затем мы выполняем итерацию с самого начала.
Мы также настраиваем так называемые проверки работоспособности, поэтому мы гарантируем, что запросы не будут перенаправлены в неактивный бэкэнд.
Так в чем же проблема? Большинство API REST не имеют состояния. Это означает, что ничто, связанное с одним пользователем, делающим запрос, не сохраняется в самом экземпляре. Дело в том, что с WebSockets дело обстоит иначе.
Каждое сокетное соединение связано с конкретным экземпляром, поэтому нам нужно убедиться, что все запросы от конкретных пользователей перенаправляются в конкретный бэкэнд. Как это исправить?
Решение № 1: Липкая сессия
То, что мы ищем, это липкая сессия. К счастью, мы используем HAProxy, поэтому единственное, что нужно сделать, - это некоторые настройки.
backend backends
balance leastconn
cookie serverid insert
server srv1 127.0.0.1:8081 check cookie srv1
server srv2 127.0.0.1:8082 check cookie srv2
Прежде всего, мы изменили стратегию балансировки. Вместо того, чтобы использовать циклический перебор, мы решили пойти с наименьшим количеством подключений. Это обеспечит подключение нового пользователя к экземпляру с наименьшим общим количеством подключений.
Второе изменение - подписывать каждый запрос от одного пользователя cookie. Он будет содержать имя используемого бэкэнда.
После этого остается только указать, какой бэкэнд следует использовать для данного значения cookie. На данный момент мы должны быть в порядке с обработкой сообщений одного пользователя. Но как насчет трансляции?
Горизонтальное масштабирование с помощью WebSocket. Проблема №2. Трансляция
Давайте начнем с добавления новой функции на наш сервер WebSocket, чтобы мы могли отправлять сообщения всем подключенным клиентам одновременно.
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
const mockedUsers = [
{
id: 1,
firstname: "John",
lastname: "Doe"
}
];
wss.on("connection", ws => {
ws.on("message", data => {
const message = JSON.parse(data);
if (message.type === "get-users") {
ws.send(JSON.stringify(mockedUsers));
}
if (message.type === "broadcast") {
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(mockedUsers));
}
});
}
});
});
Выглядит хорошо. Так в чем же подвох? Сервер WebSocket знает только о клиентах, подключенных к этому конкретному экземпляру. Это означает, что мы отправляем сообщение только нескольким клиентам, а не всем.
Решение № 2: Pub / Sub
Самый простой вариант - ввести связь между разными экземплярами. Например, все они могут быть подписаны на определенный канал и обрабатывать предстоящие сообщения. Это то, что мы называем публикацией-подписчиком или публикацией / подпиской . Есть много готовых решений, таких как Redis, Kafka или Nats. Давайте начнем с метода подписки на канал.
const redis = require("redis");
const subscriber = redis.createClient({
url: "redis://localhost:6379"
});
const publisher = subscriber.duplicate();
const WS_CHANNEL = "ws:messages";
subscriber.on("message", (channel, message) => {
console.log(`Message from: ${channel}, ${message}`);
});
subscriber.subscribe(WS_CHANNEL);
publisher.publish(WS_CHANNEL, "my message");
Прежде всего, нам нужно разделить клиентов для подписчика и издателя. Это связано с тем, что клиент в режиме подписчика может выполнять только команды, связанные с подпиской, поэтому мы не можем использовать команду публикации на этом клиенте. Однако мы можем использовать дублирующий метод для создания копии определенного клиента Redis. После этого мы подписываемся на событие сообщения. Сделав это, мы получим любое сообщение, опубликованное в Redis. Конечно, мы также получаем информацию о канале, на котором он был опубликован. Последний шаг - запустить метод публикации, чтобы отправить сообщение на определенный канал.
А теперь перейдем к последней части. Давайте свяжем это с нашим кодом WebSocket.
const WebSocket = require("ws");
const redis = require("redis");
const subscriber = redis.createClient({
url: "redis://localhost:6379"
});
const publisher = subscriber.duplicate();
const WS_CHANNEL = "ws:messages";
const mockedUsers = [
{
id: 1,
firstname: "John",
lastname: "Doe"
}
];
const wss = new WebSocket.Server({ port: +process.argv[4] || 8080 });
subscriber.on("message", (channel, message) => {
if (channel === WS_CHANNEL) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
});
wss.on("connection", ws => {
console.log("new connection");
ws.on("message", data => {
const message = JSON.parse(data);
if (message.type === "get-users") {
ws.send(JSON.stringify(mockedUsers));
}
if (message.type === "broadcast") {
publisher.publish(WS_CHANNEL, JSON.stringify(mockedUsers));
}
});
});
subscriber.subscribe(WS_CHANNEL);
Как видите, вместо отправки сообщений клиенту WebSocket прямо сейчас мы публикуем их на канале, а затем обрабатываем их отдельно. Делая это, мы уверены, что сообщение публикуется для каждого экземпляра, а затем отправляется пользователям.
Масштабирование серверов WebSocket - краткая информация
Масштабирование WebSocket не является тривиальной задачей. Вы не можете просто увеличить количество экземпляров, потому что это не сработает сразу. Однако с помощью нескольких инструментов мы можем построить полностью масштабируемую архитектуру. Все, что нам нужно, - это балансировщик нагрузки (такой как HAProxy или даже Nginx), настроенный с помощью липкой сессии и системы обмена сообщениями.