Микросервисная трансформация в Купере — как это было. Часть I: Начинаем распил монолитов
- суббота, 22 ноября 2025 г. в 00:00:04
Привет! Меня зовут Фёдор Засечкин. С 2023 года я руковожу группой разработки операционной платформы в Купере. Наша команда отвечает за стабильность и развитие сервисов, которые обеспечивают сборку и доставку заказов, а также найм и выход партнёров в смены.
Последние два года наша ключевая задача — микросервисная трансформация. Мы постепенно распиливаем монолит, перераспределяя нагрузку по сервисам. На сегодня более 1 000 RPS HTTP-трафика уже ушло с монолита; до полного завершения осталось около 30 RPS и часть межсервисных интеграций.
Я решил написать серию статей о том, как мы проходили этот путь: что сработало, какие ошибки допустили и какие решения реально помогли. Этот текст — первый из серии.

Купер (тогда ещё — СберМаркет) имеет ярко выраженную сезонность. Конец года — самый сложный период: Черная пятница, Зеленый день в честь дня рождения Сбера, предновогодние акции. Количество заказов в этот период растёт кратно.
Что было у нас в ноябре 2022 года?
два монолита: storefront (витрины) и shopper (сборщики и курьеры);
синхронное взаимодействие между ними;
минимум механизмов гарантированной доставки данных;
первые микросервисы, но сильно завязанные на монолиты.

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

Мы вручную синхронизировали данные между системами почти 18 часов, пока коллеги восстанавливали их работоспособность. Сказать, что все вымотались, — ничего не сказать.
После этого инцидента мы сформулировали цели:
Обеспечить стабильность под планируемой нагрузкой.
Снизить взаимное влияние бизнес-процессов. Например, чтобы сбой оформления заказа не останавливал доставку.
Это и стало точкой входа в микросервисную трансформацию.
В 2023 году приоритет был один — пройти горячий сезон без падений. Для этого мы сделали ставку на системное нагрузочное тестирование (НТ) и повышение отказоустойчивости обмена данными между системами.
Мы использовали Grafana k6 и выделенный деплоймент для подачи нагрузки. Со временем обросли удобным функционалом: запуск НТ из портала для разработчиков, планирование по расписанию и пр.
Процесс выглядел так: запуск НТ → анализ → поиск узких мест → оптимизация → повтор.

Изначально в компании уже существовали некоторые скрипты генерации нагрузки для операционного монолита. Они находились в зоне ответственности команды НТ. Первым делом мы договорились о том, чтобы забрать к себе в команду поддержку и расширение тестов — именно для нашей системы. Команда НТ сфокусировалась на сквозном тестировании основного пользовательского пути.
После анализа скриптов, которые нам передали, мы поняли, что следует расширить покрытие по HTTP-ручкам, с нуля создать тесты для gRPC-эндпоинтов и Kafka-топиков и компоновать их в единый сценарий.
Усугублялась ситуация тем, что нам нужно было в рамках этого сценария понимать, какие тестовые заказы уже закреплены и за каким тестовым сборщиком, чтобы не провоцировать 4xx-ошибки при назначении нового заказа на партнера, который еще не закончил свой флоу.
В итоге мы сделали дополнительную обвязку для k6, которая решала проблему корректного запуска:
Persistence-слой для хранения данных генератора нагрузки (в качестве базы данных использовали Redis);
Конфигурация, в которой хранилась текущая информация о нагрузке на ручки и топики — для формирования плана нагрузки при запуске НТ;
Скрипты, генерирующие тестовые данные в самом сервисе, и скрипты, которые проливали существующие тестовые данные в Redis тестирования.
Следующий вызов — максимально изолировать процесс НТ от работы реальных пользователей и других сервисов. Такое требование продиктовано двумя обстоятельствами.
Во-первых, у нас не было возможности поднять отдельное окружение для НТ: слишком дорого, и мы не были уверены, что у провайдера есть нужное количество железа, чтобы поднять вторую копию нашего продакшена.
Во-вторых, дальнейшая проливка тестовых данных могла негативно повлиять на соседние сервисы и работу пользователей.
Решение требовало дополнительной работы с логикой сервиса:
В модели следовало добавить метод, который возвращает информацию о том, является ли объект тестовым;
Во всех HTTP- и gRPC-клиентах, а также Kafka-продьюсерах надо было прерывать отправку данных, если они тестовые;
Поскольку интенсивность создания сущностей во время нагрузочного тестирования сильно выше стандартной, важно было добавить клинеры, которые вычищали бы ненужные тестовые данные.
Решив эти проблемы на уровне сервиса и генератора нагрузки, мы приступили к самой простой, но самой объемной части — расширению покрытия. Сначала покрывали тот функционал, который критичен для завершения флоу работы с заказом, затем постепенно добавляли нагрузку на те места, без которых пользователь мог достичь результата, пусть и с тратой времени или дополнительными действиями.
Итого:
Тесты сервисов были изолированы. Каждая команда готовила тестовые данные и сценарии, строго избегая пролива в боевые системы;
Раз в неделю отдельная команда инициировала сквозные тесты и прогоняла полный бизнес-путь: оформление → сборка → доставка;
Постепенно мы устраняли все узкие места и вышли на стабильную работу под требуемой нагрузкой.
Все интеграции между монолитами были построены на веб-хуках.
Они делились на два типа:
События — например, «Заказ оформлен». Мы получали сигнал и в фоне ходили за деталями.
Команды — например, «Пересчитать стоимость». Их выполняли синхронно, заставляя пользователя ждать ответа сразу двух систем.
Разобрав все взаимодействия, мы поняли: синхронность нужна далеко не всегда. Большую часть можно перевести в асинхронный обмен через Kafka. Это исключало потерю данных: события хранились у брокера до обработки.
Хороший пример — как мы переделали схему синхронизации статуса заказа и его позиций. В изначальном взаимодействии два монолита синхронизировались веб-хуками не только в случаях, когда статус заказа изменился, но и когда изменился статус самой позиции (собран, отменен, заменен). В такой схеме трафик между системами был очень высокий, а при проблемах рассинхронизация могла быть очень точечной.
Мы провели сессию Event Storming, на которой были спецы как со стороны инженеров, так и со стороны бизнеса. Покрутив проблему, мы поняли, что на самом деле у нас при оформлении заказа есть бизнес-правило, в котором пользователь выбирает, что делать с позицией, если ее нет на полке: позвонить и согласовать замену; не звонить и заменить; убрать из заказа. То есть не обязательно через монолит витрины синхронно показывать пользователю то, что происходит с позициями при сборке.
Решение было принято такое: вместо N хуков об изменении каждой позиции заказа мы будем посылать одно событие в Kafka — уже с готовым агрегатом, на основе которого система витрин проставит нужный статус заказа и все статусы позиций. Это решило две проблемы: уменьшило нагрузку на веб-сервер (на тот момент около 200 RPS) и снизило связность систем (так как операционной системе становилось не важно, может ли в моменте система витрин обработать запрос об изменении статуса позиции).
Но одной замены транспорта было мало — точка отказа просто сместилась бы на Kafka. Вовремя появилась библиотека с реализацией паттерна Inbox/Outbox, и мы начали применять ее повсеместно.
Теперь события фиксировались и у брокера, и локально в базе сервиса. В случае сбоя можно было повторно прочитать или отправить их. Мы добавили алертинг и мониторинг на снижение рейта успешной обработки инбокса или роста времени обработки событий.

Также в базе данных уже лежал трейс с ошибкой и payload события, что резко снижало время на поиск первопричины сбоя. Мы сразу понимали, в чем проблема: в логике обработки и нарушении контракта на стороне отправителя, — быстро откатывали изменения и синхронизировали статусы всего одной командой такого вида:
rake outbox:retry_failed_items[‘outbox_name’]
В ней происходило перечитывание неуспешно обработанных событий:namespace :outbox do desc "Retry messages that we were unable to deliver" # rake 'outbox:retry_failed_items[some/box_item]' # rake 'outbox:retry_failed_items[some/box_item,1,2,3,4,5]' task :retry_failed_items, %i[] => :environment do |_, args| item_class_name, *ids = args.extras item_class_name = item_class_name.classify raise "Invalid item name" unless Sbmt::Outbox.item_classes.map(&:to_s).include?(item_class_name) item_class = item_class_name.constantize scope = item_class.failed scope = scope.where(id: ids) unless ids.empty? scope.in_batches.update_all( status: Sbmt::Outbox::BaseItem.statuses[:pending], errors_count: 0 ) endend


В результате время ручной синхронизации данных при крупных инцидентах сократилось с 18 часов до примерно 30 минут.
Что это дало трансформации?
Стабильность монолитов — и ясное понимание, что мы выдержим сезон.
Проверка микросервисов под нагрузкой — возможность видеть реальное поведение вынесенного функционала.
Гибкость — с помощью фича-флагов можно переключать трафик между монолитами и сервисами.
Пересмотр инфообмена — от синхронных связей к асинхронным.
Надежность интеграций — Inbox/Outbox дал гарантии доставки и повторяемости.

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

При развитии или трансформации информационных систем важно не забывать выставлять нефункциональные требования к их работе, а также выстраивать процесс, который проверял бы соответствие систем выставленным требованиям. Часто именно в требованиях гарантии доставки и скорости обработки данных лежат ответы на вопрос, как правильно спроектировать общую архитектуру.
При интеграции с новой системой задавайте себе вопросы:
Важно ли, чтобы актор получил ответ сразу от внешней системы?
Что будет, если мы не получим от внешней системы запрашиваемые данные? Или что будет, если мы, наоборот, получим их несколько раз?
Какие механизмы мы можем использовать, чтобы доотправить или переполучить данные из внешней системы?
Какая пропускная способность требуется от систем и существуют ли ограничения (рейтлимиты)? Если ограничений сейчас нет, нужны ли они?
Продолжение следует…