Как масштабировать сервер сокетов Node.js с помощью Nginx и Redis

Как вы можете сбалансировать сервер сокетов, какие проблемы могут возникнуть и как вы можете их решить.

Масштабирование и распределение серверов являются одними из самых интересных тем в разработке серверных приложений.

Существует много способов масштабировать ваше приложение и обрабатывать множество запросов и подключений. В этой статье рассказывается об одном из самых популярных способов масштабирования приложений Node.js, особенно о сокетных подключениях.

Представьте, что у вас есть приложение Node, которое получает 300 запросов в секунду. Он работает нормально, но в один прекрасный день количество запросов становится в 10 или 100 раз больше. Тогда у вас будут большие проблемы. Приложения Node не предназначены для обработки 30 тыс. запросов в секунду (в некоторых случаях они могут, но только благодаря ЦП и ОЗУ).

Как мы знаем, Node является однопоточным и не использует много ресурсов вашей машины (ЦП, ОЗУ). В любом случае это будет малоэффективно.

У вас нет никакой гарантии, что ваше приложение не рухнет, или вы не сможете обновить свой сервер, не остановив его. Если у вас есть только один экземпляр, ваше приложение, скорее всего, будет некоторое время простоя.

Как мы можем сократить время простоя? Как мы можем практически использовать оперативную память и процессор? Как мы можем обновить приложение, не останавливая все системы?

Балансировщик нагрузки NGINX

Одним из решений является балансировщик нагрузки. В некоторых случаях вы также можете использовать кластер, но мы не рекомендуем использовать кластер узлов, потому что балансировщики нагрузки более эффективны и предоставляют более ценные функции.

В этой статье мы будем использовать только Load Balancer. В нашем случае это будет Nginx. Вот статья, которая объяснит вам, как установить Nginx.

Итак, вперед.

Мы можем запускать несколько экземпляров приложения Node и использовать сервер Nginx для передачи всех запросов/соединений на сервер Node. По умолчанию Nginx будет использовать логику round robin для последовательной отправки запросов на разные серверы.

Как видите, у нас есть сервер Nginx, который получает все клиентские запросы и перенаправляет их на разные серверы Node. Как я уже говорил, Nginx по умолчанию использует логику round robin, поэтому первый запрос доходит до server:8000, второй до 8001, третий до 8002 и так далее.

Nginx также имеет некоторые дополнительные функции (например, создание резервных серверов, которые помогут при сбое сервера, и Nginx автоматически переместит все запросы на резервный сервер), но в этой статье мы будем использовать только прокси.

Вот основной сервер Express.js, который мы будем использовать с Nginx.

Используя env, мы можем отправить номер порта с терминала, и экспресс-приложение будет прослушивать этот номер порта.

Давайте побежим и посмотрим, что произойдет.

PORT=8000 node server.js

Мы можем видеть PID сервера в консоли и браузере, что поможет определить, какой сервер получил наш вызов.

Запустим еще два сервера в портах 8001 и 8002.

PORT=8001 node server.js
PORT=8002 node server.js

Теперь у нас есть три Node-сервера в разных портах.

Запустим сервер Nginx.

Наш сервер Nginx прослушивает порт 3000, а прокси-сервер — upstream node серверов.

Перезапустите сервер Nginx и перейдите на http://127.0.0.1:3000/

Обновите несколько раз, и вы увидите разные PID числа. Мы только что создали основной сервер Load Balancer, который перенаправляет запросы на другие серверы Node. Вы можете обрабатывать множество запросов и полностью использовать ЦП и ОЗУ.

Давайте посмотрим, как работает Socket и как мы можем таким образом сбалансировать сервер Socket.

Балансировка нагрузки сокет-сервера

Прежде всего, давайте посмотрим, как Socket работает в браузерах.

Есть два способа, как Socket открывает соединения и прослушивает события. Это Длительный опрос и WebSocket, которые называются транспортами.

По умолчанию все браузеры запускают соединения Socket с опросом, а затем, если браузер поддерживает WebSocket, он переключается на транспорт WebSocket. Но мы можем добавить необязательную опцию transports и указать, какой транспорт или транспорты мы хотим использовать для соединения. И тогда мы можем сразу открыть сокетное соединение, используя транспорт WebSocket, или наоборот, будем использовать только транспорт опроса.

Давайте посмотрим, в чем разница между Polling и WebSocket.

Длинный опрос

Сокеты позволяют получать события от Сервера, ничего не запрашивая, что можно использовать для игр и мессенджеров. Вы не знаете, когда ваш друг отправит вам сообщение, чтобы запросить сервер и получить ответ. Используя сокеты, сервер автоматически отправит вам событие, и вы получите эти данные.

Как мы можем реализовать функциональность, которая будет использовать только HTTP-запросы и предоставлять слой, который сможет получать некоторые данные с сервера, не запрашивая эти данные? Другими словами, как реализовать сокеты, используя только HTTP-запросы?

Представьте, что у вас есть уровень, который отправляет запросы на Сервер, но Сервер не отвечает вам сразу — другими словами, вы ждете. Когда у Сервера есть что-то, что нужно отправить вам, Сервер отправит вам эти данные, используя то же HTTP-соединение, которое вы открыли некоторое время назад.

Как только вы получите ответ, ваш уровень автоматически отправит новый запрос на сервер и снова будет ждать другого ответа, не проверяя ответ предыдущего запроса.

Таким образом, ваше приложение может получать данные/события с сервера в любое время, потому что у вас всегда есть открытый запрос, ожидающий ответа сервера.

Вот как работает опрос. Вот визуализация его работы.

Веб-сокет

WebSocket — это протокол, который позволяет открывать только одно TCP соединение и удерживает его в течение длительного времени. Вот еще одно изображение визуализации, которое показывает, как работает WebSocket.

Как я уже сказал, большинство браузеров по умолчанию подключаются к серверу Socket с помощью опросного транспорта (с запросами XHR). Затем Сервер запрашивает изменение транспорта на WebSocket. Но если браузер не поддерживает WebSockets, он может продолжать использовать опрос. Для некоторых старых браузеров, которые не могут использовать транспорт WebSocket, приложение продолжит использовать транспорт опроса и не будет обновлять транспортный уровень.

Давайте создадим базовый сервер сокетов и посмотрим, как он работает в Chrome Inspect Network.

Запустите сервер Node.js на порту 3000 и откройте index.html в Chrome.

Как видите, мы подключаемся к сокету с помощью polling, поэтому HTTP-запросы выполняются скрытно. Если мы откроем XHR на странице Inspect Network, мы увидим, как он отправляет запросы на сервер.

Откройте вкладку «Сеть» в режиме проверки браузера и просмотрите последний запрос XHR в сети. Он всегда находится в процессе ожидания. Ответа нет. Через какое-то время этот запрос будет завершен, и будет отправлен новый запрос — потому что, если на один запрос не будет ответа от Сервера в течение длительного времени, вы получите ошибку тайм-аута. Итак, если нет ответа от Сервера, он обновит запрос и отправит новый.

Также обратите внимание на ответ Сервера на первый запрос Клиенту. Данные ответа выглядят следующим образом:

96:0{"sid":"EHCmtLmTsm_H8u3bAAAC","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}2:40

Как я уже сказал, Сервер отправляет Клиенту параметры для обновления транспорта с опроса на веб-сокет. Однако, поскольку у нас в опциях есть только опросный транспорт, он не переключится.

Попробуйте заменить строку подключения на это:

const socket = io('http://0.0.0.0:3000');

Откройте консоль и выберите All на странице Inspect Network.

Когда вы обновите страницу, вы заметите, что после некоторых запросов XHR клиент обновляется до веб-сокета. Обратите внимание на тип элементов сети в сетевой консоли. Как видите, опрос — это базовый XHR, а WebSocket — тип веб-сокета. Когда вы нажмете на это, вы увидите кадры. Вы получите новый кадр, когда Сервер выдаст новое событие. Также есть некоторые события (просто числа, т.е. 2, 3), которые клиент/сервер отправляет друг другу, чтобы сохранить соединение. В противном случае мы получим ошибку тайм-аута.

Теперь у вас есть базовые знания о том, как работает Socket. Но какие проблемы могут возникнуть, когда мы попытаемся сбалансировать сервер сокетов с помощью Nginx, как в предыдущем примере?

Проблемы

Есть две существенные проблемы.

Во-первых, возникает проблема, когда у нас есть балансировщик нагрузки Nginx с несколькими серверами Node, а клиент использует опрос.

Как вы, возможно, помните, Nginx использует логику циклического перебора для балансировки запросов, поэтому каждый запрос, который клиент отправляет в Nginx, будет перенаправлен на сервер Node.

Представьте, что у вас есть три сервера Node и балансировщик нагрузки Nginx. Пользователь запрашивает подключение к серверу с помощью опроса (запросы XHR), Nginx балансирует этот запрос на Node: 8000, а сервер регистрирует идентификатор сеанса клиента, чтобы получать информацию о клиенте, подключенном к этому серверу. Во второй раз, когда пользователь выполняет какое-либо действие, Клиент отправляет новый запрос, который Nginx перенаправляет на Node:8001.

Что должен делать второй Сервер? Он получает событие от Клиента, который к нему не подключен. Сервер вернет ошибку с сообщением Session ID unknown.

Балансировка становится проблемой для клиентов, использующих Polling. В случае Websocket вы не получите такой ошибки, потому что вы подключаетесь один раз, а затем получаете/отправляете кадры.

Где решить эту проблему: на клиенте или сервере?

Однозначно на сервере! Точнее, в Nginx.

Мы должны изменить форму логики Nginx, которая уравновешивает нагрузку. Другая логика, которую мы можем использовать, это ip_hash.

У каждого клиента есть IP-адрес, поэтому Nginx создает хэш с использованием IP-адреса и перенаправляет запрос клиента на сервер узла, что означает, что каждый запрос с одного и того же IP-адреса всегда будет перенаправляться на один и тот же сервер.

Это минимальное решение этой проблемы; есть и другие возможности. Если вы хотите пойти глубже, иногда это решение будет недостаточным. Вы можете изучить другую логику для Nginx/Nginx PLUS или использовать другие балансировщики нагрузки (например, HAProxy).

Переходим к второй проблеме: пользователь подключается к одному Серверу.

Представьте ситуацию, когда вы подключены к Node:8000, ваш друг подключен к Node:8001, и вы хотите отправить ему сообщение. Вы отправляете его по Сокету, а Сервер получает событие и хочет отправить ваше сообщение другому пользователю (вашему другу). Думаю, вы уже догадались, какая у нас может быть проблема: Сервер хочет отправить данные пользователю, который к нему не подключен, но подключен к другому серверу в системе.

Есть только одно решение, которое можно реализовать разными способами.

Создайте внутренний уровень связи для серверов.

Это означает, что каждый сервер сможет отправлять запросы на другие серверы.

Таким образом, Node:8000 отправляет запрос Node:8001 и Node:8002. И они проверяют, подключен ли к нему user2. Если user2 подключен, этот сервер будет передавать данные, предоставленные Node:8000.

Давайте обсудим одну из самых популярных технологий, которая обеспечивает уровень связи, который мы можем использовать на наших серверах Node.

Редис

Как написано в официальной документации:

Redis — это хранилище структур данных в памяти, используемое в качестве базы данных.

Таким образом, он позволяет создавать в памяти key-value пар. Кроме того, Redis предоставляет ряд ценных и полезных функций. Давайте поговорим об одной из таких популярных функций.

PUB/SUB

Эта система обмена сообщениями предоставляет нам Подписчика и Издателя.

Используя подписчик в клиенте Redis, вы можете подписаться на канал и прослушивать сообщения. С Publisher вы можете отправлять сообщения на определенный канал, которые получит подписчик.

Это как Node.js EventEmitter. Но EventEmitter не поможет, когда приложению Node нужно отправить данные другому.

Давайте посмотрим, как это работает с Node.js.

Теперь, чтобы запустить этот код, нам нужно установить модуль redis. Также не забудьте установить Redis на свой локальный компьютер.

npm i redis --save

Запустим и посмотрим на результаты.

node subscriber.js

и

node publisher.js

В окне подписчика вы увидите такой вывод:

Message "hi" on channel "my channel" arrived!
Message "hello world" on channel "my channel" arrived!

Поздравляем! Вы только что установили связь между различными приложениями Node.

Таким образом, мы можем иметь подписчиков и издателей в одном приложении для получения и отправки данных.

Подробнее о Redis PUB/SUB можно прочитать в официальной документации. Кроме того, вы можете проверить модуль node-redis-pubsub, который предоставляет простой способ использования Redis PUB/SUB.

💡 Используя инструмент OSS, такой как Bit, эта часть становится намного проще. Вы можете инкапсулировать функциональность Redis в модуль, который можно хранить в репозитории Bit, независимо тестировать, документировать и управлять версиями, а также легко интегрировать в несколько проектов.

Одним из подходов может быть определение объекта конфигурации клиента Redis, который принимает учетные данные хоста, порта и аутентификации в качестве входных данных, создает с ним экземпляр клиента и экспортирует его как функцию модуля с помощью Bit. Затем, чтобы использовать его в других проектах, вы можете просто установить его с помощью npm i @bit/your-username/your-redis-service, импортировать его и передать ему конфигурацию для конкретного проекта.

Узнайте больше здесь:



Соединение всех этих частей

Наконец, мы подошли к одной из самых захватывающих частей.

Для обработки большого количества подключений мы запускаем несколько экземпляров приложения Node.js, а затем распределяем нагрузку на эти серверы с помощью Nginx.

Используя Redis PUB/SUB, мы устанавливаем связь между серверами Node.js. Всякий раз, когда какой-либо Сервер хочет отправить данные неподключенному клиенту, Сервер публикует данные. Затем каждый Сервер получает его и проверяет, подключен ли к нему пользователь. В конце концов, этот Сервер отправляет предоставленные данные Клиенту.

Вот общая картина того, как организована внутренняя архитектура:

Давайте посмотрим, как мы можем реализовать это в Node.js. Вам не нужно будет создавать все это с нуля. Уже есть пакеты, которые выполняют часть этой работы.

Эти два пакета работают для socket.io.

Одна интересная вещь: зависимость socket.io-emitter обеспечивает отправку событий пользователям из-за пределов сервера сокетов. Если вы можете опубликовать действительные данные в Redis, которые получат серверы, то один из серверов может отправить событие сокета Клиенту. Это означает, что наличие Socket-сервера не обязательно для отправки события пользователю. Вы можете запустить собственный сервер, который будет подключаться к тому же Redis и с помощью PUBLISH отправлять события сокета клиентам.

Также есть еще один пакет под названием SocketCluster. SocketCluster более продвинутый — он использует кластер с брокерами и рабочими процессами. Брокеры помогают нам с частью Redis PUB/SUB; worker — это наши приложения Node.js.

Также есть Pusher, который помогает создавать большие масштабируемые приложения. Он предоставляет API для своей размещенной системы обмена сообщениями PUB/SUB и имеет SDK для некоторых платформ (например,, Android, IOS, Интернет). Однако учтите, что это платная услуга.

Заключение

В этой статье мы объяснили, как сбалансировать сервер сокетов, какие проблемы могут возникнуть и как их решить.

Мы использовали Nginx для балансировки нагрузки сервера на несколько узлов. Существует множество балансировщиков нагрузки, но мы рекомендуем Nginx/Nginx Plus или HAProxy. Кроме того, мы увидели, как работает Socket, и разницу между уровнем опроса и транспортным уровнем WebSocket. Наконец, мы увидели, как можно установить связь между экземплярами Node.js и использовать их все вместе.

В результате у нас есть балансировщик нагрузки, который перенаправляет запросы на несколько серверов Node.js. Обратите внимание, что вы должны настроить логику балансировщика нагрузки, чтобы избежать каких-либо проблем. У нас также есть уровень связи для сервера Node.js. В нашем случае мы использовали Redis PUB/SUB, но вы можете использовать и другие средства связи.

Я работал с Socket.io (с Redis) и SocketCluster и советую вам использовать их как в маленьких, так и в больших проектах. С этими стратегиями можно играть с SocketCluster, который может обрабатывать 30 тыс. соединений сокетов. Библиотека SocketCluster немного устарела, и ее сообщество не так велико, но, скорее всего, это не вызовет никаких проблем.

Многие инструменты помогут вам сбалансировать нагрузку или распределить вашу систему. Мы советуем вам также узнать о Docker и Kubernetes. Начните исследовать их как можно скорее!

Спасибо, не стесняйтесь задавать любые вопросы или писать мне в Твиттере @nairihar.

Создавайте приложения с повторно используемыми компонентами, как Lego

Инструмент с открытым исходным кодом Bit помогает более чем 250 000 разработчиков создавать приложения с компонентами.

Превратите любой пользовательский интерфейс, функцию или страницу в компонент многократного использования — и поделитесь им со своими приложениями. Легче сотрудничать и строить быстрее.

Подробнее

Разделите приложения на компоненты, чтобы упростить разработку приложений, и наслаждайтесь наилучшими возможностями для рабочих процессов, которые вы хотите:

Микро-интерфейсы

Система дизайна

Совместное использование кода и повторное использование

Монорепо

Узнать больше: