Проект ЦИТадель
Спор «монолит против микросервисов» часто превращается в выбор лагеря, хотя полезнее поставить два отдельных вопроса: где проходят границы между частями системы и какие из них следует сделать сетевыми. Первый вопрос относится к модульности, которая нужна при любом способе развёртывания. Второй — к распределённости и её инженерной цене. Микросервис можно рассматривать как модуль с отдельным процессом и сетевым контрактом. В статье разбирается, какие возможности даёт такая граница, какие издержки создаёт и в каком порядке принимать решение.
В разделе 1 различаются модульность и распределённость. В разделе 2 рассматривается цена удалённого взаимодействия, а в разделе 3 — его практические выгоды и необходимые условия. Раздел 4 посвящён закону Конвея, раздел 5 — распределённому монолиту. Затем предлагается порядок перехода от модульного монолита к отдельным сервисам, разбирается устройство хорошей границы и даётся практикум для аудита собственной системы.
Модульность — свойство устройства: система разбита на части с ясными обязанностями, узкими интерфейсами и скрытой реализацией; отдельную часть можно понять, изменить и проверить без знания всех деталей системы. Распределённость — свойство исполнения: части работают в разных процессах и взаимодействуют через сеть. Эти свойства не следует смешивать. Возможны модульный монолит, хорошо разделённая система сервисов, плохо структурированный монолит и плохо структурированная распределённая система — распределённый монолит.
Переход к микросервисам сам по себе не создаёт удачных границ. Он делает обход интерфейса труднее, но одновременно повышает стоимость ошибки: перенос обязанностей и данных между уже развёрнутыми сервисами требует изменения контрактов, миграции состояния и координации выпусков. Модульность появляется благодаря проектированию и дисциплине, а сеть усиливает выбранную границу и добавляет к ней новые свойства.
Вызов внутри процесса обычно быстр, может участвовать в общей транзакции и виден в одном стеке вызовов. После переноса взаимодействия в сеть появляются задержки, частичные отказы и неопределённость результата. Это следствия модели, изложенной в главе 1 курса о распределённых системах:
Таким образом, сетевая граница превращает локальное взаимодействие в задачу распределённой системы. Её следует вводить там, где ожидаемая выгода оправдывает постоянную стоимость контрактов, надёжности и эксплуатации.
У сетевой границы есть несколько важных выгод, но каждая достигается только при дополнительных условиях.
Независимое развёртывание. Команда может выпускать и откатывать свой сервис отдельно от остальных. Для этого недостаточно разнести код по репозиториям: сервису нужны совместимые контракты, самостоятельное владение данными и автоматизированный инженерный конвейер. Если изменение по-прежнему требует согласованного выпуска нескольких сервисов, независимость осталась номинальной. Выгода здесь в значительной мере организационная: команды получают разные ритмы поставки.
Изоляция отказов. Отдельный процесс может ограничить область непосредственного сбоя. Однако синхронная зависимость способна передать отказ дальше по цепочке. Реальную изоляцию создают ограниченные тайм-ауты, управление повторами, деградация и отказ от обязательной синхронной зависимости там, где это допускает предметная логика. Эти приёмы связаны с переборками, описанными в статье «Как современные языки обращаются с ошибками».
Независимое масштабирование. Части системы можно масштабировать по отдельности, если у них действительно различаются нагрузка и требования к ресурсам. Эту выгоду следует сопоставлять с расходами на сетевое взаимодействие и эксплуатацию нескольких сервисов; иногда масштабирование монолита целиком оказывается проще и дешевле.
Выбор технологий. Сервис может использовать подходящие ему язык и хранилище, но каждая дополнительная технология требует сопровождения, обновлений, наблюдаемости и компетенций. Поэтому свобода выбора должна отвечать на вопрос из статьи «Облака: устройство и модели»: что команда готова эксплуатировать сама и зачем?
В 1968 году Мелвин Конвей сформулировал наблюдение: структура создаваемой системы отражает структуру коммуникаций организации. Когда множество команд меняет один тесно связанный выпускной модуль, техническая граница не совпадает с границами ответственности: растёт число согласований и увеличивается время поставки. Микросервисы могут уменьшить такую координацию, если автономная команда владеет сервисом от разработки до эксплуатации. Для небольшой команды эта организационная выгода обычно меньше, хотя размер команды сам по себе не определяет архитектуру.
Граница сервиса должна учитывать и предметную область, и владение. Хороший кандидат объединяет связанную бизнес-возможность и её данные, имеет ясные инварианты и одну ответственную команду. Простого существительного в модели — «заказ», «пользователь», «товар» — для отдельного сервиса недостаточно. Но и одна организационная схема не заменяет анализа связности: если два сервиса постоянно меняются и выпускаются вместе, формальное разделение команд не делает их независимыми.
Организацию можно осознанно менять так, чтобы поддержать желаемые архитектурные границы; этот приём называют обратным манёвром Конвея. Однако он не гарантирует хорошего результата автоматически. Одна команда вполне может владеть несколькими сервисами, а сервис без явного владельца со временем становится источником задержек и эксплуатационных рисков.
Распределённый монолит состоит из нескольких процессов, но сохраняет сильную связность единого приложения. Его характерные признаки:
В такой системе уже появились задержки, частичные отказы и сложная диагностика, но независимое развёртывание и изоляция не достигнуты. Общая физическая СУБД сама по себе ещё не определяет архитектуру: во время миграции или ради экономии сервисы могут временно использовать один сервер. Опасна постоянная совместная запись в одни схемы и таблицы, потому что она создаёт скрытый контракт и связывает выпуски. Сеть не исправляет неудачную модульность, а делает её последствия дороже.
Разумное исходное решение для многих новых систем — модульный монолит. В начале работы границы предметной области часто ещё неустойчивы, профиль нагрузки не измерен, а состав команд меняется. Внутрипроцессную границу обычно дешевле пересмотреть, чем сетевой контракт с отдельно хранимыми данными. Поэтому сначала полезно выстроить настоящие модули: определить публичные интерфейсы и владение данными, контролировать запрещённые зависимости анализаторами и ревью и защищать изменения тестами, как описано в статье «Тестирование сегодня».
Это рекомендация, а не универсальный закон. Начать с нескольких сервисов может быть оправданно, если предметные границы уже известны, систему создают автономные команды и у организации есть опыт эксплуатации распределённых приложений. В любом случае крупные, хорошо понятные границы безопаснее преждевременной мелкой нарезки.
Выделять сервис следует ради явно названной цели. Такой целью может быть независимый выпуск команды, особый профиль нагрузки, отдельные требования к отказоустойчивости или необходимость изолировать быстро меняющуюся часть. Кандидата выбирают по совместно изменяющемуся коду, связности данных и устойчивости интерфейса. Периферийный модуль нередко удобен для первого опыта, но важнее не его положение, а понятная граница и измеримая польза.
До промышленного выделения нужна эксплуатационная основа. Минимум включает воспроизводимое развёртывание и быстрый откат, мониторинг, понятное владение и порядок реакции на инциденты. По мере роста числа сервисов становятся необходимы распределённая трассировка, автоматизированное предоставление среды и систематическая проверка контрактов. Эти требования подробнее разобраны в статьях «Инженерный конвейер» и «Наблюдаемость».
После выбора сетевой границы необходимо обеспечить несколько связанных свойств:
Задания можно выполнять на монолитной или уже распределённой системе.
Задание 1. Карта фактических границ. Постройте граф зависимостей модулей с помощью анализатора импортов для своего языка или небольшого скрипта. Сопоставьте его с принятой архитектурной схемой. Найдите циклы, модули с чрезмерным числом зависимостей и обращения в обход публичных интерфейсов. Для каждого случая запишите, относится ли проблема к неверной границе или к нарушению уже выбранной.
Задание 2. Цена одной границы. Выберите модуль — кандидат на выделение. Для одной пользовательской операции посчитайте пересечения его интерфейса и оцените, какие из них пришлось бы объединить в более крупный удалённый вызов. Определите транзакции и инварианты, пересекающие границу; для каждого предложите вариант сохранения атомарности, сагу или допустимое промежуточное состояние. Перечислите передаваемые типы данных и правила их совместимой эволюции. Получится предварительная смета сетевой границы.
Задание 3. Аудит владения. Наложите границы ответственности команд на карту из задания 1. У каждого ли модуля есть один владелец? Какие части регулярно меняют несколько команд? Какие изменения постоянно затрагивают чужие модули? Для каждого несовпадения рассмотрите не только выделение сервиса, но и перенос ответственности или исправление интерфейса внутри монолита.
Задание 4. Эксплуатационная готовность. Проверьте, можно ли воспроизводимо развернуть и откатить отдельный компонент, увидеть его ошибки и задержки, проверить совместимость контракта и определить ответственного за инцидент. Каждому пробелу назначьте срок и владельца. Отделите возможности, необходимые для первого сервиса, от тех, которые понадобятся только при дальнейшем росте.
Задание 5*. Одна граница на стенде. Поместите периферийный модуль за отдельный локальный сервис, используя Docker Compose и методику статьи о контейнерах. Опишите контракт, задайте тайм-аут и добавьте повтор только для идемпотентной операции; передайте сквозной контекст трассировки. Измерьте задержку пользовательской операции до и после изменения, затем остановите сервис и зафиксируйте поведение системы. Такой опыт покажет не только стоимость вызова, но и новые режимы отказа.