На этой неделе мы выпустили Squadus — единое цифровое рабочее пространство, которое позволяет компаниям удобно и гибко структурировать коммуникации. На создание этого on-premise решения ушло порядка трех лет; для ускорения разработки Squadus мы скомбинировали наши технологии и компоненты СПО. Мы рассмотрели несколько вариантов мессенджеров с открытым кодом в качестве основы, но все они не в полной мере отвечали нашим требованиям и не обеспечивали обслуживание более чем 1000 пользователей. Мы же хотели добиться значительно более высоких показателей — несколько десятков тысяч одновременно работающих пользователей. Поэтому переработали серверные компоненты и внедрили современную микросервисную архитектуру. В новом цикле статей рассказываем, как Squadus развивался с технической точки зрения. Начнем с бэкенда: поговорим сегодня о переходе от монолитной архитектуры к микросервисной, о том, какие проблемы и как мы решали, и что нам это дало. Привет, Хабр! Меня зовут Александр Захаров, я руковожу группой разработки в МойОфис. Эта статья посвящена архитектуре бэкенда Squadus. Начну с общей мысли: архитектура проекта — не только нашего, но и любого другого — не может быть описана «в вакууме». У ее актуального состояния всегда есть предпосылки, которые важно знать для понимания общей картины. Мы начали разрабатывать проект на базе Rocket.Chat, решения с открытым кодом. С тех пор Squadus проделал большой технологический путь, и ниже я расскажу, куда и как мы пришли. Ремарка: в тексте я использую термин «микросервис» наравне с термином «сервис». Воспринимаю их как синонимы, несмотря на вечные споры о том, чем «микросерисная архитектура» отличается от «сервисной архитектуры» и в какой момент «микросервис» становится просто «сервисом». В 2020 году Squadus стартовал как масштабируемое монолитное приложение. Это упрощало разработку: изначально над Squadus работала небольшая команда, и с масштабируемым монолитом мы могли оперативно представить proof of concept, плюс довольно быстро делать фичи. По мере роста проекта мы начали активно использовать Squadus внутри компании. Стал ощущаться накопленный технический долг. В основном он выражался в ситуациях из категории «потянули за уши — отвалился хвост»: небольшое изменение в одном месте могло сильно сказаться на, казалось бы, совершенно не связанной с ним функциональности. Кроме того, стало понятно, что некоторые операции затратны по ресурсам и хорошо бы их масштабировать отдельно. Все прекрасно знают, что уменьшение технического долга по организации кода, называется «рефакторингом». Нам предстояло только понять, в каком ключе его проводить. Выбирали между двумя вариантами: полномасштабно переписать монолит, либо идти в сторону микросервисной архитектуры. Мы выбрали второй путь, и вот почему: В компании часть продуктов имеет микросервисную архитектуру (речь, например, о почтовой системе нового поколения Mailion). Если мы будем двигаться в ту же сторону, то сможем переиспользовать наработки нативно, а не как инородные сущности, для которых нужно делать обвязку для встраивания в приложение. Ускорение сборки. Время, которое уходит на linting, unit-тесты и сборки, в какой-то момент стало настоящей болью для всех членов команды. Более четкие архитектурные границы. В случае с микросервисами автоматически выполняется инкапсуляция компонентов (изоляция контракта от реализации). За счет этого понижается связанность системы. (Для того, чтобы ввести новую связь, требуется больше усилий: из-за этого разработчики склонны оставлять функциональность модуля внутри него.) Явные интерфейсы модулей. Для связи между сервисами мы используем GRPC, это дает нам понятное место расположения публичного контракта модуля (proto-файл) и практически автоматизированную техническую документацию. Изоляция компонентов. При микросервисном подходе мы исключаем взаимное влияние разных компонентов, и если у нас возникают проблемы, не связанные с бизнес-логикой, их намного проще локализовать. Вместе с тем нам предстояло решить несколько проблем, которые так или иначе свойственны микросервисной архитектуре: Boilerplate-код. Чтобы запустить сервис, нужно написать код, который его инициализирует. Чтобы подключить сервис к другому сервису, нужно написать код и для этого. И так далее — появляется много шаблонных действий, которые не хотелось бы каждый раз описывать с нуля. Переиспользование кода. Если раньше всё было в одном месте и можно было просто импортировать нужную функциональность, то теперь всё разделено, и разработчик может захотеть переиспользовать код методом copy-paste. Локальное развертывание. Если с монолитом всё понятно, то сервисы нужно развернуть и передать им правильные параметры для общения друг с другом. Версионирование. Если раньше монолит был всегда консистентен, то теперь за этим нужно следить — не ожидает ли один сервис от другого той функциональности, которая есть только в новой версии. Health-check. В большой системе всегда может что-то произойти: сетевые проблемы, «моргнул» свет или что-то подобное. Хорошо, когда сервис может распознать проблему и сообщить нам, что не может исполнять свои функции (например, по причине недоступности зависимостей). А также сообщить об этом оркестратору, чтобы тот попробовал предпринять какие-то шаги для исправления этого безобразия. Кольцевые зависимости. Если сервису для его функционирования требуются сервисы, которым, в свою очередь, требуется данный сервис, то у нас возникают проблемы монолитного приложения, помноженные на проблемы микросервисов. Обработка ошибок. В монолите, как правило, ошибки появляются или из-за бизнес-логики, или из-за проблем с железом (пример — кончилась оперативная память). Но теперь к этому добавляется обработка всевозможных сетевых ошибок при вызове любого сервиса. Распределенные транзакции. Главная боль микросервисной архитектуры. Если что-то пошло не так, мы можем на этапе отлова ошибки понимать границы произошедшего с точностью до модуля. И если в цепочке модулей есть состояние, мы не можем гарантировать его консистентность. Когда проблемы известны заранее, стоит найти способы решения до их возникновения, а не перестраивать самолет в процессе взлета. Первые три проблемы (boilerplate, переиспользование кода и локальное развертывание) мы частично решили при помощи монорепозитория. Бэкенд нашего проекта написан на TypeScript (Node.js) и для него есть множество решений. Мы остановились на NX: Библиотеки (для переиспользования кода) генерятся буквально одной командой и их очень легко подключать в сервисы. У нас есть небольшая проблема в том, что для jest нужно отдельно добавлять их в конфиг, но надеемся скоро это исправить. Сервисы так же легко создаются из шаблонов. Запуск всех сервисов в режиме отладки еще проще, чем генерация — не нужно передавать дополнительные параметры. Для сервисов извне у нас есть docker-compose файл, который запускает окружение в минимально рабочей, с точки зрения потребления ресурсов, конфигурации. С версионированием сервисов мы решили пойти прямым путем — поскольку все они генерятся из одного репозитория, то и версия у них должна быть одна на всех. Да, из версии сервиса не всегда понятно, есть ли в нем новая функциональность или же она не менялась. Но мы точно можем сказать, что состояние, когда у всех сервисов одинаковая версия, является поддерживаемым. Проблему проверки «состояния здоровья» мы решили тем, что все сервисы реализуют одинаковый протокол отдачи своего состояния — с некоторыми расширениями, но сохраняя совместимость с оригиналом, ради более простого использования другими продуктами компании. Каждый сервис периодически опрашивает свои «зависимости» и выводит из их состояния своё. Так относительно легко можно понять, какая функциональность работает в системе, а какая «отвалилась», и где искать корень проблемы. С кольцевыми зависимостями мы решили бороться классическим путем. А именно, их инверсией. Если предполагается, что два сервиса нужны друг другу, то нам требуется еще один сервис, который должен решить эту проблему. Например, у нас есть некие настройки, которые администратор может менять динамически без остановки системы. Проблема в том, что запрос изменения настройки (jwt-токен в запросе) нужно проверить на наличие прав для конкретной настройки (а возможность изменения настройки, конечно же, зависит от других настроек). Для этого делаем отдельный сервис, который проксирует запросы, но проверяет права в запросе. Так, сервис настроек обеспечивает только хранение и доступ к настройкам, сервис прав и ролей использует его для вычисления конфигурации. Сервис доступа к настройкам зависит от этих двух сервисов, а от него не зависит ни один из троицы. Но бездумное применение этого принципа породило бы огромное количество прокси-сервисов. Чтобы этого избежать, мы пользуемся уже пониманием того, как продукт должен функционировать, какие операции требуют подтверждения, а в каких можно обойтись без него. И если без подтверждения операции можно обойтись, то мы используем «шину данных» (брокер сообщений) — посылаем туда оповещение о событии. Далее те, кому оно нужно, отреагируют на него. Хороший пример — это оповещение пользователей. Предположим, один пользователь написал сообщение в чат, и пользователям чата должно прийти оповещение об этом. Опуская подробности: здесь затрагивается множество сервисов, отвечающих за самые разные вещи. Нас же интересует, как в сервис оповещения пользователя придет информация о том, что и кому нужно отправить. Ставить сервис оповещений в зависимость сервиса сообщений — странная история. Так что лучше породить событие об отправке сообщения в шину данных и реагировать на него сервисом оповещений. Опять же, если кому-то еще понадобится реагировать на это событие, в сервис сообщений не нужно будет вносить изменения. В некотором смысле нам очень повезло с продуктом. Это инструмент коммуникаций — у нас нет связанных денежных счетов. И если вдруг у пользователя, из-за сетевой аварии или, скажем, попадания метеорита в сервер не обновится счетчик непрочитанных сообщений, будет обидно, но не слишком. Тем не менее, мы хотим допускать такие ситуации как можно реже. Поэтому приняли несколько решений: Если в запросе от пользователя возникла проблема и она не ожидаема, надо вернуть на клиент ошибку через весь стек вызовов. Как минимум это даст пользователю информацию о том, что ожидаемого действия не произошло, и нужно либо попытаться повторить действие, либо обратиться в поддержку. Систему нужно попытаться вернуть в исходное состояние. Поэтому на каждый модифицирующий запрос должен быть обратный ему. Вернуть систему в исходное состояние — обязанность первого из сервисов, который узнал об ошибке. Для примера возьмем отправку сообщения. И сбой при записи в базу данных (скажем, соединение с ней оборвалось в момент записи). Сервис, сохраняющий сообщение в базу, попытается его удалить. Получилось это или нет — не важно. Он отправит сообщение сервису, который его вызывал. Тот попробует попросить другой инстанс сервиса снова удалить сообщение, и опять же, в любом случае передаст ошибку «выше». Таким образом мы сделаем всё возможное для консистентного состояния системы, и в итоге высветим пользователю окошко «ваше сообщение не отправлено». Скорее всего, клиент пользователя попробует отправить сообщение еще раз, прежде чем показать окошко, но в данном контексте это не имеет большого значения. Что ж, решения приняты, базовая документация для разработчиков написана. Можно приступать к наведению красоты? Не совсем. Напоминаю, продукт активно используется внутри компании. Нашим пользователям не хотелось бы терять свою переписку. Да и новых фич просят. Теперь пришло время рисовать схему того, куда мы хотим попасть. Естественно, предварительно предупреждая всех, что она не окончательна и может меняться в процессе. За основу мы взяли текущую функциональность и, стараясь сверяться с логикой и здравым смыслом, начали дробить ее на куски. На этом этапе было множество споров об ответственности того или иного компонента за ту или иную функциональность, но в итоге мы пришли к некому общему пониманию того, чего мы хотим и как это должно работать. Можно было бы брать верхнеуровневый сервис, переносить всю функциональность туда, а потом из нее вычленять куски. Но у этого подхода есть несколько существенных недоработок: На момент выноса модуля вся работа по связанной с ним функциональности останавливается. Так как модули верхнеуровневые, то работа встает целиком. До последнего этапа зависимость между модулями не разрывается. То есть, если два модуля использовали еще один, он будет задублирован. Пересылка данных между модулями требует временных решений, которые потом надо извлекать. Мы хотели избавится от старых костылей, а не плодить новые. О разделении доступа к данным тоже можно забыть до последнего этапа. Исходя из этого мы выбрали путь, который начинается с модулей «нижнего уровня» — с минимальным числом зависимостей. Мы извлекаем функциональность в сервис, делаем «интеграцию» монолита с ним, и в таком варианте это идет в релиз. Но идет под отключаемым для установки «фича флагом», то есть в одном релизе мы поддерживаем и старый и новый код. Это позволяет убедиться, что всё хорошо и, если что-то пошло не так, отключить новые наработки и пользоваться проверенным временем вариантом, пока мы исправляем проблемы. После этого, если всё хорошо, мы удаляем старый код из проекта (отмечу, что пока случаев «всё пошло не так» не возникало). Также этот подход позволяет команде работать над несколькими сервисами параллельно, без взаимного влияния, что хорошо сказывается как на скорости, так и на взаимоотношениях в коллективе. Все базовые сервисы вынесены, и мы работаем над крупной функциональностью. Работы еще очень много, но она интересна, и уже виден ее результат. В планах у нас превратить монолит в backend for frontend, убрав из него всю, или почти всю, логику. На текущем этапе мы получили: Улучшение производительности  «нижнеуровневой» функциональности. Там есть несколько весьма затратных операций и мы очень рады, что теперь они живут отдельно и их проще контролировать. Время сборки сервисов приблизительно в восемь раз меньше, чем у монолита и, что немаловажно, их можно собирать параллельно. Команда очень довольна. Время деплоя сервисов тоже заметно уменьшилось. Уменьшился размер docker image. Увеличилась скорость старта. Уменьшение потребления ресурсов. Теперь задачи не влияют друг на друга внутри одного процесса, что существенно улучшило ситуацию с производительностью. Команда лучше понимает код. Бесплатным бенефитом стало то, что вынося функциональность в сервис, разработчики начинают лучше ее понимать, видят нюансы, оптимизируют. Увеличилось покрытие тестами. Небольшие сервисы легче покрывать тестами, чем большой монолит. И разработчики этим активно пользуются. Что мы ожидаем в будущем: Гибкую масштабируемость, прозрачный autoscalling. Увеличение производительности системы для всех операций: когда влияние того или иного участка кода локализовано, намного проще проводить оптимизации и, в последствии, поддерживать их. Улучшение нагрузоустойчивости. За счет того, что улучшится производительность и будет оптимизировано использование ресурсов, система сможет выдерживать большее количество единовременных пользователей без необходимости добавления нового «железа». Облегчение входа в проект для новых разработчиков. Появление специализации на той или иной функциональности у разработчиков. Это есть и сейчас, но границы пока очень размыты. Полный отказ от JavaScript в пользу TypeScript. В монолите есть места, написанные на JavaScript, и они сильно усложняют разработку. Быстрая и аккуратная реализация новых фич. Во многом ради этого всё и затевалось. Если у вас есть вопросы, замечания и предложения по содержанию статьи, будем рады получить обратную связь в комментариях. Возможно, об отдельных моментах разработки Squadus (или в целом бэкенда) вы хотите узнать подробнее — сообщите нам, и не исключено, что по интересующей вас теме мы подготовим отдельный материал.