
Оглавление:
Почему микросервисы работали
Аргументы в пользу индивидуальных репозиториев
Масштабирование микросервисов и репозиториев. Боли
Отказ от микросервисов и очередей
Сознательный переход на монорепозиторий
Почему работает монолит
Компромиссы
Завершение
Микросервисы — это сервис-ориентированная программная архитектура с преимуществами:
Эффективное функциональное разделение
Изоляция среды
Автономность команды разработчиков.
Противоположностью является монолитная архитектура, в которой большой объём функциональности сосредоточен в одном сервисе. Который тестируется, развёртывается и масштабируется как единое целое.
Twilio Segment с самого начала внедрил эту практику, которая в одних случаях сослужила нам хорошую службу, а в других, как вы вскоре узнаете, не очень.
На заре существования Twilio Segment мы достигли критической точки в работе над основной частью продукта Twilio Segment. Казалось, что мы падаем с дерева микросервисов, задевая по пути все ветки.
Вместо того чтобы работать быстрее, небольшая команда увязла в стремительно растущей сложности. Важнейшие преимущества этой архитектуры превратились в бремя.
По мере того как наша скорость работы падала, количество ошибок стремительно росло.
В конце концов команда поняла, что не может продвинуться дальше, а трое штатных инженеров тратят большую часть времени на поддержание работоспособности системы. Нужно было что-то менять. Эта история о том, как мы сделали шаг назад и выбрали подход, который хорошо соответствовал требованиям к продукту и потребностям команды.
Почему микросервисы работали
Инфраструктура данных клиентов Twilio Segment обрабатывает сотни тысяч событий в секунду и перенаправляет их в API-интерфейсы партнеров, которые мы называем назначениями(destinations) на стороне сервера.
Существует более ста типов таких назначений, например Google Analytics, Optimizely, webhooks, A/B платформы и т.д..
Несколько лет назад, когда продукт только появился, его архитектура была простой. Был API, который принимал события и отправлял их в очередь сообщений. Событие - это объект JSON, созданный веб-приложением или мобильным приложением и содержащий информацию о пользователях и их действиях.
Пример полезной нагрузки выглядит следующим образом:
По мере обработки событий из очереди проверялись настройки, управляемые клиентом, чтобы определить, какие адресаты должны получить событие.
Затем событие отправлялось в API каждого адресата по очереди.
Это было удобно, поскольку разработчикам нужно было отправлять событие только на одну конечную точку — в API Twilio Segment, — а не создавать потенциально десятки интеграций. Twilio Segment отправляет запрос на каждую конечную точку адресата.
Если один из запросов на отправку не выполняется, мы иногда пытаемся отправить это событие повторно в более позднее время. Некоторые из них можно повторить, а некоторые нет. Повторяемые ошибки — это ошибки, которые потенциально могут быть приняты целевым объектом без изменений. Например, ошибки HTTP 500, ограничения скорости и тайм-ауты. Не повторяемые ошибки — это запросы, которые, как мы можем быть уверены, никогда не будут приняты целевым объектом. Например, запросы с неверными учетными данными или без обязательных полей.

На этом этапе в одной очереди находились как самые новые события, так и те, которые могли быть отправлены несколько раз с повторными попытками по всем направлениям, что привело к Head-of-line Blocking. То есть в этом конкретном случае, если одно из направлений замедлилось или вышло из строя, повторные попытки переполнили бы очередь, что привело бы к задержкам по всем нашим направлениям.
Представьте, что в пункте назначения X возникла временная проблема и каждый запрос завершается ошибкой из-за превышения времени ожидания. Это не только приводит к накоплению большого количества запросов, которые ещё не достигли пункта назначения X, но и к тому, что каждое неудачное событие возвращается в очередь для повторной обработки. Хотя наши системы автоматически масштабируются в ответ на возросшую нагрузку, внезапное увеличение глубины очереди может превысить наши возможности по масштабированию, что приведёт к задержкам при обработке новых событий. Время доставки во все пункты назначения увеличится из-за кратковременного сбоя в пункте назначения X. Клиенты рассчитывают на своевременную доставку, поэтому мы не можем допустить увеличения времени ожидания на любом этапе.

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

Аргументы в пользу индивидуальных репозиториев
Каждый целевой API использует свой формат запросов, поэтому для преобразования события в соответствии с этим форматом требуется специальный код. В качестве примера рассмотрим ситуацию, когда целевой объект X требует указывать дату рождения в полезной нагрузке как traits.dob, в то время как наш API принимает ее как traits.birthday. Код преобразования в целевом объекте X будет выглядеть примерно так:
Многие современные конечные точки используют формат запросов Twilio Segment, что делает некоторые преобразования относительно простыми. Однако эти преобразования могут быть очень сложными в зависимости от структуры API конечной точки. Например, для некоторых старых и наиболее разветвлённых конечных точек нам приходится вручную добавлять значения в XML-данные.
Изначально, когда целевые объекты были разделены на отдельные сервисы, весь код хранился в одном репозитории. Больше всего раздражало то, что из-за одного неработающего теста все тесты в целевых объектах не проходили. Когда мы хотели внести изменения, нам приходилось тратить время на исправление неработающего теста, даже если изменения не имели ничего общего с первоначальным тестом. Чтобы решить эту проблему, было решено выделить код для каждого целевого объекта в отдельный репозиторий. Все целевые объекты уже были выделены в отдельные сервисы, поэтому переход был естественным.
Разделение на отдельные репозитории позволило нам легко изолировать целевые наборы тестов. Такая изоляция позволила команде разработчиков быстро вносить изменения в целевые системы.
Масштабирование микросервисов и репозиториев
Со временем мы добавили более 50 новых направлений, а это означало, что нам пришлось создать 50 новых репозиториев. Чтобы облегчить разработку и поддержку этих кодовых баз, мы создали общие библиотеки, которые упростили и унифицировали такие распространённые преобразования и функции, как обработка HTTP-запросов.
Например, если нам нужно получить имя пользователя из события, можно вызвать event.name() в коде любого получателя. Общая библиотека проверяет событие на наличие ключа свойства name и Name. Если их нет, она проверяет наличие имени, проверяя свойства firstName, first_name и FirstName. То же самое она делает с фамилией, проверяя варианты и объединяя их в полное имя.
Благодаря общим библиотекам можно было быстро создавать новые направления. Единообразный набор общих функций упрощал обслуживание.
Однако начала возникать новая проблема. Тестирование и развертывание изменений в этих общих библиотеках затрагивали все наши целевые объекты. Их поддержка требовала значительных затрат времени и усилий. Вносить изменения для улучшения наших библиотек, зная, что нам придется тестировать и развертывать десятки сервисов, было рискованно. Когда времени было в обрез, инженеры включали обновленные версии этих библиотек только в кодовую базу одного целевого объекта.
Со временем версии этих общих библиотек в разных целевых кодовых базах начали расходиться. Преимущество, которое мы когда-то получали за счёт сокращения количества настроек в каждой целевой кодовой базе, начало сходить на нет.
В конце концов, все они стали использовать разные версии этих общих библиотек.
Мы могли бы создать инструменты для автоматизации внесения изменений, но на тот момент страдала не только производительность разработчиков, но и возникали другие проблемы с архитектурой микросервисов.
Дополнительная проблема заключалась в том, что у каждого сервиса был свой характер нагрузки. Некоторые сервисы обрабатывали несколько событий в день, в то время как другие обрабатывали тысячи событий в секунду. Для сервисов, обрабатывающих небольшое количество событий, оператору приходилось вручную увеличивать масштаб сервиса, чтобы удовлетворить спрос в случае неожиданного всплеска нагрузки.
Несмотря на то, что у нас было реализовано автоматическое масштабирование, для каждого сервиса требовалось определённое сочетание ресурсов ЦП и памяти, что делало настройку конфигурации автоматического масштабирования скорее искусством, чем наукой.
Количество направлений продолжало стремительно расти: команда добавляла в среднем по три направления в месяц, что означало увеличение количества репозиториев, очередей и сервисов. Из-за нашей микросервисной архитектуры операционные издержки линейно возрастали с каждым новым направлением.
Поэтому мы решили сделать шаг назад и переосмыслить весь процесс.
Отказ от микросервисов и очередей
Первым пунктом в списке было объединение более чем 140 сервисов в один. Управление всеми этими сервисами ложилось тяжёлым бременем на нашу команду. Мы буквально не спали из-за этого, так как дежурному инженеру часто приходилось реагировать на скачки нагрузки.
Однако архитектура того времени затрудняла переход к единому сервису. При наличии отдельной очереди для каждого пункта назначения каждому исполнителю приходилось бы проверять каждую очередь на наличие работы, что добавило бы ещё один уровень сложности в работу с пунктами назначения, с чем мы были не согласны. Это и послужило основным вдохновением для создания Centrifuge.
Centrifuge заменил бы все наши отдельные очереди и отвечал бы за отправку событий в единый монолитный сервис. (Обратите внимание, что Centrifuge стал серверной инфраструктурой для подключений.)

Переход на монорепозиторий
Учитывая, что сервис будет только один, имело смысл перенести весь целевой код в одно репозиторий, а это означало объединение всех различных зависимостей и тестов в одном репозитории. Мы знали, что это будет непросто.
Для каждой из 120 уникальных зависимостей мы предусмотрели одну версию для всех наших целевых объектов. По мере переноса целевых объектов мы проверяли используемые ими зависимости и обновляли их до последних версий. Мы исправляли в целевых объектах всё, что не работало в новых версиях.
Благодаря этому переходу нам больше не нужно было отслеживать различия между версиями зависимостей. Все наши целевые объекты использовали одну и ту же версию, что значительно упростило работу с кодовой базой. Поддержка целевых объектов стала менее трудоёмкой и менее рискованной.
Нам также нужен был набор тестов, который позволил бы нам быстро и легко запускать все целевые тесты. Запуск всех тестов был одним из основных препятствий при обновлении общих библиотек, о которых мы говорили ранее.
К счастью, все целевые тесты имели схожую структуру. В них были базовые модульные тесты, которые позволяли убедиться в правильности нашей пользовательской логики преобразования и в том, что HTTP-запросы к конечной точке партнёра выполняются и события отображаются в целевом объекте должным образом.
Напомним, что изначальной целью разделения каждой целевой кодовой базы на отдельное репозиторий было изолирование сбоев в тестах. Однако оказалось, что это ложное преимущество. Тесты, выполняющие HTTP-запросы, по-прежнему часто давали сбой. Поскольку целевые базы данных были разделены на отдельные репозитории, не было особой мотивации устранять сбои в тестах. Из-за такого небрежного подхода постоянно возникал досадный технический долг. Часто на небольшое изменение, которое должно было занять час или два, уходило от пары дней до недели.
Создание надёжного набора тестов
Исходящие HTTP-запросы к конечным точкам назначения во время тестового запуска были основной причиной неудачных тестов. Несвязанные с этим проблемы, такие как просроченные учётные данные, не должны приводить к сбою тестов. Кроме того, по опыту мы знали, что некоторые конечные точки назначения работают намного медленнее других. Для выполнения тестов на некоторых конечных точках назначения требовалось до 5 минут. При наличии более 140 конечных точек выполнение нашего набора тестов могло занимать до часа.
Чтобы решить обе эти проблемы, мы создали Traffic Recorder. Traffic Recorder создан на основе yakbak и отвечает за запись и сохранение тестового трафика для целевых объектов. При первом запуске теста все запросы и соответствующие им ответы записываются в файл. При последующих запусках теста запрос и ответ из файла воспроизводятся вместо запроса к конечной точке целевого объекта. Эти файлы проверяются в репозитории, чтобы тесты были согласованными при каждом изменении. Теперь, когда набор тестов больше не зависит от HTTP-запросов через Интернет, наши тесты стали значительно более устойчивыми, что необходимо для перехода на единый репозиторий.
После того как мы интегрировали Traffic Recorder, выполнение тестов для всех 140 с лишним пунктов назначения заняло миллисекунды. Раньше выполнение тестов для одного пункта назначения могло занимать пару минут. Это было похоже на волшебство.
Почему работает монолит
Как только код для всех целевых объектов оказался в одном репозитории, их можно было объединить в один сервис. Благодаря тому, что все целевые объекты были объединены в один сервис, производительность наших разработчиков значительно возросла. Нам больше не нужно было развертывать более 140 сервисов для внесения изменений в одну из общих библиотек.
Один инженер может развернуть сервис за считанные минуты.
Доказательством послужило повышение скорости. Когда наша микросервисная архитектура ещё существовала, мы внесли 32 улучшения в наши общие библиотеки. Год спустя мы внесли 46 улучшений.
Это изменение также положительно сказалось на нашей операционной истории. Поскольку все конечные точки находились в одном сервисе, у нас было хорошее сочетание конечных точек, требовательных к ресурсам процессора и памяти, что значительно упростило масштабирование сервиса в соответствии с потребностями. Большой пул рабочих процессов может справляться с пиковыми нагрузками, поэтому мы больше не получаем уведомления о конечных точках, обрабатывающих небольшие объёмы данных.
Компромиссы
Переход от микросервисной архитектуры к монолитной в целом был огромным шагом вперёд, однако пришлось пойти на компромиссы:
Изоляция сбоев затруднена. Поскольку все работает в рамках одного монолитного приложения, если в одном из пунктов назначения возникает ошибка, приводящая к сбою сервиса, то сервис выходит из строя во всех пунктах назначения. У нас есть комплексное автоматизированное тестирование, но оно не всегда помогает. В настоящее время мы работаем над более надежным способом предотвращения сбоев в работе всего сервиса из-за ошибок в одном из пунктов назначения, при этом все пункты назначения остаются в рамках одного монолитного приложения.
Кэширование в оперативной памяти менее эффективно. Раньше, когда у каждого пункта назначения был свой сервис, в пунктах назначения с низким трафиком было всего несколько процессов, а значит, их кэширование данных уровня управления в оперативной памяти оставалось актуальным. Теперь этот кэш распределен между более чем 3000 процессами, поэтому вероятность его использования гораздо ниже. Мы могли бы использовать для решения этой проблемы что-то вроде Redis, но тогда нам пришлось бы учитывать еще один фактор масштабирования. В конце концов мы смирились с этой потерей эффективности, учитывая значительные преимущества в работе.
Обновление версии зависимости может привести к сбоям в нескольких местах назначения. Хотя перенос всего в один репозиторий решил проблему с зависимостями, с которой мы столкнулись, это означает, что, если мы хотим использовать новейшую версию библиотеки, нам, возможно, придётся обновить другие места назначения, чтобы они работали с новой версией. Однако, на наш взгляд, простота этого подхода оправдывает компромисс. А благодаря нашему комплексному набору автоматизированных тестов мы можем быстро определить, что не работает с новой версией зависимости.

Заключение
Наша первоначальная архитектура микросервисов какое-то время работала, решая насущные проблемы с производительностью в нашем конвейере за счёт изоляции конечных точек друг от друга. Однако мы не были готовы к масштабированию. Нам не хватало подходящих инструментов для тестирования и развёртывания микросервисов при необходимости массового обновления. В результате производительность наших разработчиков быстро снизилась.
Переход на монолитную архитектуру позволил нам избавиться от операционных проблем в конвейере и значительно повысить производительность разработчиков. Однако мы не относились к этому переходу легкомысленно и понимали, что нам нужно учесть некоторые моменты, чтобы всё получилось.
Нам нужен был надёжный набор тестов, чтобы поместить всё в один репозиторий. Без этого мы оказались бы в той же ситуации, что и тогда, когда мы решили разделить их. Постоянные неудачные тесты в прошлом снижали нашу продуктивность, и мы не хотели, чтобы это повторилось.
Мы смирились с компромиссами, присущими монолитной архитектуре, и позаботились о том, чтобы у каждого из них была хорошая история. Мы должны были смириться с некоторыми жертвами, которые повлекло за собой это изменение.
При выборе между микросервисами и монолитом необходимо учитывать различные факторы. В некоторых частях нашей инфраструктуры микросервисы работают хорошо, но наши серверные приложения — отличный пример того, как эта популярная тенденция может негативно сказаться на производительности. Оказалось, что для нас решением стал монолит.
? Больше архитектурных разборов, активностей в виде архитектурный кат, System Design собеседований, встречей с экспертами индустрии на моём канале @system_design_world⭐
Комментарии (26)

Cordekk
12.01.2026 12:24ключевая ошибка, вам изначально не нужны были микросервисы, но вы рассчитывали, что будет рост и под это заложили микросервисы. В целом количество кода росло, но команда не выросла.
Отсюда и проблемы, ведь небольшой команде микросервисы не нужны.
avovana7 Автор
12.01.2026 12:24
Cordekk
12.01.2026 12:24ну формально, да.
Ведь микросервисы закладывают под рост нагрузки и команд, а тут ничего не растет, вот и вывод в конце статьи.
Cordekk
12.01.2026 12:24а в целом, если монолит решает задачи, то значит на нем можно остановиться. Если потом упретесь в какой-то потолок производительности, то начнете опять пилить на сервисы.

PerroSalchicha
12.01.2026 12:24ключевая ошибка, вам изначально не нужны были микросервисы
Да, но эта же ключевая ошибка, она касается, я вряд ли ошибусь, если скажу, что этак 80% проектов, в которых используются микросервисы.

haitdb
12.01.2026 12:24Не совсем понял - какая связь между архитектурой приложения и организацией работы с исходным кодом?
Монолитное приложение может состоять из кучи библиотек, каждая из которых ведется в отдельной репе, так и единый код можно запускать как кучу отдельных сервисов.Все же основанием для выбора архитектуры должно быть что то другое ;)

avovana7 Автор
12.01.2026 12:24haitdb, интересно) Например? :)

ganqqwerty
12.01.2026 12:24То, как приложенька деплоится, какие её куски должны скейлиться горизонтально, какие команды что поддерживают. Сорцы по гитовым репам можно распилить и так, и сяк - можно все микросервисы сложить в монореп, а можно монолит распихать по маленьким репам.

diderevyagin
12.01.2026 12:24Вы построили не совсем правильную микросервисную архитектуру.
Грубо говоря Вы построили распределенный монолит. Оттуда и все проблемы.
Теперь Вы решили его проблемы, просто решив эти проблемы. Это хороший опыт, но немного некорректный, чтобы оценивать и критиковать микросервисную архитектуру как концепцию

avovana7 Автор
12.01.2026 12:24diderevyaginтолько, интересный поинт. Что бы Вы посоветовали команде в момент боли, когда они раскатались на такое большое количество микросервисов?

diderevyagin
12.01.2026 12:24Что бы Вы посоветовали команде в момент боли
Мой подход был бы такой:
Команда построила распределенный монолит. Это создало проблемы.
Дальше - анализ. Принятие решение. Если проект позволяет - свести к нормальному монолиту. Нет - распил такого распределенного монолита на реальные микросервисы.
Вы пошли путем 1 и это вполне хороший выбор. У меня были замечания скорее к терминологии

sergey_prokofiev
12.01.2026 12:24Вместо того чтобы работать быстрее, небольшая команда увязла в стремительно растущей сложности. Важнейшие преимущества этой архитектуры превратились в бремя.
Какую проблему решают микросеврисы - deploy fast в проектах с большим количеством команд: каждая команда деплоит свой микросервис независимо(сохраняя контракты ессна) и в крупных проектах может быть чуть ли не ежечасовый релиз нового функционала. В аналогичных условиях типичный релизный цикл монолита - один год. Естественно такой лайфхак ускорения релизного цикла за счет релиза "по частям" имеет и свою темную сторону - there is no one silver bullet.
Все остальные бенефиты микросервисов - buzzwords разной степени "buzzword-ности".
А у вас - небольшая команда и не планируется ее резкий рост. Значит основного бенефита от микросервисов вы в принципе получить не можете - одна команда релизит "как умеет" и ее "в 50 раз" не ускорить. А вот косков "темной стороны" собрали полной - очень недалеки от джек пота.
Хорошо что приняли волевое решение и выбрали архитектуру, больше подходящую вашим задачам.
ganqqwerty
Написание слова "гудбай" в следующий раз лучше тоже поручить нейросетке
avovana7 Автор
ganqqwerty, fixed, thank you :)
hitech_topkcorp
а было goldbuy чтоли