Уроки, извлеченные из создания сервера WebSocket
Appwrite - это автономный сервер Backend-as-a-Service с открытым исходным кодом, который призван упростить разработку приложений с помощью SDK, доступных на различных языках программирования.
До выпуска Realtime API с версией 0.10.0 приложения могли взаимодействовать только с нашим REST API.
Почему мы создали Realtime API?
В прошлом REST API были популярной архитектурой для доставки данных. Так зачем нам сейчас нужен Realtime?
Наш REST API отлично работает и очень прост, но для того, чтобы обеспечить большую гибкость и позволить разработчикам создавать новые сценарии использования, такие как разработка игр и реактивные приложения, нам потребовалось добавить новый уровень API для взаимодействия в реальном времени.
Вместо того, чтобы клиент API получал новые данные только по своему следующему запросу, новые данные отправляются им немедленно. Если разработчик уже опрашивает REST API на предмет изменений данных, это не просто означает, что он хочет получить доступ к данным быстрее, но является убедительным признаком того, что им действительно нужен Realtime API.
Realtime API обеспечивают более приятную работу разработчика, что может значительно снизить накладные расходы на обработку приложений и сложность кода. Как только данные передаются в систему в режиме реального времени, вы позволяете разработчикам сосредоточиться на добавлении ценности продукту.
Архитектура
Поскольку служба Realtime API реализован поверх уже существующего REST API, все сообщения, отправляемые в режиме реального времени, запускаются с HTTP-сервера. Это означает, что при создании или обновлении ресурса WebSocket Server будет инициирован для отправки этого действия своим подписчикам.
Основой обмена данными между REST и WebSocket является экземпляр Redis. Мы используем единственный Pub/Sub канал, который является источником истины для сервера WebSocket. Если новый ресурс добавлен через REST API, HTTP-сервер опубликует полезную нагрузку вместе с метаданными в этом канале. WebSocket сервер подписывается на канал, обрабатывает сообщение, решает, какому клиенту разрешено получать сообщение, и отправляет его предназначенному клиенту.
Поток данных
В Appwrite ресурсы REST API разделяются по проектам, защищены разрешениями, а события классифицируются по каналам. Когда клиент устанавливает соединение с сервером реального времени, идентификатор проекта отправляется вместе с информацией для аутентификации соединения с пользователем и каналами, через которые клиент будет получать сообщения. Далее мы берем ресурс Car в качестве примера и говорим серверу WebSocket подписаться на канал Cars.
Теперь сервер WebSocket распределяет все роли пользователя, проекта и каналов по уникальному идентификатору соединения для клиента.
Если ресурс Car теперь обновляется через REST API, HTTP-сервер публикует это событие в канале Redis со своей полезной нагрузкой. Затем сервер WebSocket получит это событие и начнет проверять, кто будет получателем этого события.
Для клиента необходимо выполнение следующих условий:
- Идентификатор проекта должен быть одинаковым.
- Разрешения ресурса должны соответствовать ролям пользователя.
- Канал должен быть подписан.
Затем WebSocket Server отправит полезные данные ресурса всем клиентам, которые соответствуют условиям.
Структура данных
Скорость жизненно важна для создания приложений с обновлениями в реальном времени. Наша структура данных должна обрабатываться как можно быстрее, чтобы решить, какой Клиент должен и которому разрешено получать событие. Для этого мы поддерживаем в памяти 2 хэш-карты. Одна из них содержит все подписки, а другая - все подключения.
Глядя на предыдущие условия, мы можем увидеть узор, отраженный в этом дереве. Вы можете понять, что эта структура имеет недостаток, а именно, есть много повторяющихся записей данных идентификатора соединения. Однако этот недостаток является преднамеренным и имеет конкретную причину - скорость.
Компромисс между памятью и скоростью важен для WebSocket сервера. Эта структура позволяет нам, даже при большом количестве подписчиков, быстро идентифицировать их и пересылать им сообщение, даже если это может занять больше памяти.
Ниже приведен пример нашей реализации, которая равномерно распределяет подписчиков по 20 различным каналам, а затем использует одно событие для сбора всех подписчиков для этого события.
Этих чисел более чем достаточно для повседневных приложений, особенно с учетом того, что один сервер WebSocket вряд ли сможет поддерживать более миллиона соединений одновременно. Поскольку сервер WebSocket не имеет состояния и управляет только своими подписками, его можно легко масштабировать по горизонтали и сбалансировать работу.
Теперь мы подошли к нашей следующей структуре данных и причине, по которой она нам вообще нужна.
Предположим, клиент подключается к нашему серверу WebSocket и подписывается на некоторые каналы. Через некоторое время клиент отключается, и мы должны очистить его и удалить его соединение со всех каналов.
Чтобы избежать бесконечных циклов идентификации каждого наследия, у нас есть вспомогательная таблица данных, которая содержит проект и роли каждого соединения, легко доступные для нас. Используя эти данные, мы можем удалить всю информацию о подписчиках без особого поиска.
Камни преткновения
Конечно, с первого раза у нас не все получилось. Каждый раз, когда мы сталкивались и преодолевали одно препятствие, следующее уже ждало нас.
Изменение разрешений
Одним из первых препятствий, с которыми мы столкнулись, было: что произойдет, если права пользователя изменятся, пока они подключены? Что делать, если пользователь деактивирован, а соединение все еще открыто?
Сервер WebSocket не узнает об этом изменении и продолжит отправлять все сообщения, которые пользователю было разрешено получать в начале соединения. Это приведет к тому, что ресурс станет доступен кому-то, кто не имеет права его читать.
Чтобы предотвратить это явление, мы добавили флаг к сообщению, отправляемому на WebSocket сервер, который указывает, изменились ли разрешения для конкретного пользователя. Когда WebSocket Server получает это сообщение, он проверяет, подключен ли этот пользователь в данный момент, и совпадает ли их роли с ролями в бэкэнде.
Операционная система
Сетевой стек Linux поставляется с разумными настройками по умолчанию для многих рабочих нагрузок, но стек не настроен на более 1 миллиона одновременных подключений. Мы ожидали, что столкнемся с проблемой C10k в той или иной форме, поэтому заранее подготовили наши системы:
- Увеличены размеры буфера TCP по умолчанию для системы
- Увеличен диапазон портов IPv4 по умолчанию
- Увеличен лимит для открытых файлов и дескрипторов файлов.
Несмотря на эту настройку, мы достигли ограничения в 260 тыс. подключений - после этого HTTP-сервер перестал отвечать на запросы наших клиентов. Мы заметили, что наш сервер не завершает трехэтапное рукопожатие TCP: он получал SYN-пакеты от клиента (как это наблюдалось с tcpdump), но не отвечал ACK.
После нескольких часов бесплодной отладки мы обратились к другим сопровождающим, чтобы они увидели проблему. Благодаря возможностям совместной работы с открытым исходным кодом, мы нашли виновного в считанные минуты:
$ cat /proc/sys/net/netfilter/nf_conntrack_max
262144
Поскольку соединения через веб-сокеты долговечны, нам потребовалось увеличить лимит отслеживания соединений в сетевом стеке. После увеличения мы с легкостью проехали до 1 миллиона подключений.
Асинхронная доставка
Когда мы проверили производительность отправки сообщений, все шло хорошо, то есть до того момента, как мы запустили более масштабные тесты и были удивлены очень плохими результатами. Причина заключалась в том, что мы отправляли каждое сообщение последовательно, а не параллельно.
К счастью, до решения оставалось всего несколько строк кода.
Аутентификация с помощью файлов cookie
Первая реализация WebSocket Server использовала только одностороннюю связь, которая отправляла обновления клиентам. В ретроспективе это оказалось проблемой, поскольку наша текущая реализация использует файл cookie только для HTTP, который передается на сервер WebSocket с помощью рукопожатия.
Позже, при разработке демонстрационного приложения, мы заметили, что при определенных обстоятельствах этот файл cookie не отправляется, например, когда клиент и сервер находятся в разных доменах.
После небольшого исследования мы обнаружили информацию о том, что рукопожатие вообще не предназначено как метод аутентификации. Причину этого можно найти здесь у одного из разработчиков реализации Chrome WebSocket. Это было решено дополнительной аутентификацией через сообщение по протоколу WebSocket. Если пользователь не был аутентифицирован с помощью cookie, мы решили вернуться к аутентификации с помощью сообщения и отправить токен cookie на сервер WebSocket.
Итак, полагаться только на рукопожатие для аутентификации было явно плохой идеей.
В заключении
Конечно, описанные выше подходы могут применяться не для каждого варианта использования, но на данный момент они подходят нам. Как сказал Дональд Кнут в своей книге Искусство программирования :
«Настоящая проблема в том, что программисты потратили слишком много времени, беспокоясь об эффективности в неправильных местах и в неподходящее время; Преждевременная оптимизация - это корень всего зла (или, по крайней мере, большей его части) в программировании».
Мы могли бы оптимизировать нашу структуру данных на микроуровне, чтобы добиться еще лучших результатов с большим количеством подписчиков. Однако проще добавить еще один экземпляр WebSocket Server за балансировщиком нагрузки и масштабировать его по горизонтали.
Пока это работает для нас, мы будем следовать совету Дональда.