Обратная миграция: как мы вернули 47 микросервисов в модульный монолит

Исходная ситуация: когда микросервисы стали проблемой

Компания «ТаскФлоу» — SaaS-платформа для управления проектами с 12 000 активных пользователей — обратилась к нам с неожиданной просьбой: помочь вернуться от микросервисной архитектуры к монолиту. За два года их система разрослась до 47 микросервисов, и команда из 6 инженеров физически не справлялась с поддержкой.

Симптомы были классическими:

  • Каскадные сбои — падение одного сервиса (notification-service) вызывало цепную реакцию: task-service ждал ответа, исчерпывал пул подключений, после чего переставал отвечать project-service. В итоге вся платформа ложилась на 15-30 минут 2-3 раза в месяц.
  • Ад отладки — один пользовательский запрос проходил через 8-12 сервисов. Для расследования бага инженер тратил 3-4 часа, собирая логи из разных контейнеров. Jaeger-трейсы помогали, но далеко не всегда.
  • Инфраструктурный оверхед — 47 сервисов требовали 47 CI/CD пайплайнов, 47 Dockerfile, 23 отдельных базы PostgreSQL. Ежемесячные расходы на Kubernetes-кластер составляли 840 000 ₽ при выручке 2.1 млн ₽.
  • Дублирование кода — общие библиотеки (авторизация, валидация, логирование) дублировались в каждом сервисе. Обновление одной зависимости требовало 47 pull requests.

Ключевой вопрос, который мы задали: зачем вам 47 сервисов на команду из 6 человек? Ответ был честным — «так было модно два года назад».

Анализ: что объединять, а что оставить

Мы не стали сносить всё бульдозером. Первый шаг — аудит каждого сервиса по трём критериям: частота деплоев, уникальность технологического стека, нагрузочный профиль.

# Скрипт для анализа частоты деплоев каждого сервиса за последний год
#!/bin/bash
for svc in $(kubectl get deployments -n taskflow -o jsonpath='{.items[*].metadata.name}'); do
  deploys=$(kubectl rollout history deployment/$svc -n taskflow | grep -c 'REVISION')
  last=$(kubectl rollout history deployment/$svc -n taskflow | tail -1 | awk '{print $1}')
  echo "$svc: $deploys деплоев, последняя ревизия: $last"
done | sort -t: -k2 -rn

Результаты показали чёткую картину:

ГруппаСервисыДеплоев/годРешение
Ядроtask-service, project-service, user-service, comment-service и ещё 282-5 каждыйОбъединить в монолит
Активныеapi-gateway, auth-service40-60Объединить в монолит
Изолированныеfile-processor, report-generator, notification-worker10-15Оставить отдельно

Из 47 сервисов 44 были кандидатами на объединение. Три сервиса — обработка файлов (ImageMagick, FFmpeg), генерация PDF-отчётов и фоновая отправка уведомлений — имели объективные причины оставаться отдельными: CPU-интенсивные задачи и другой цикл масштабирования.

Архитектура модульного монолита

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

Структура проекта на Go:

taskflow/
├── cmd/
│   └── server/
│       └── main.go              # Единая точка входа
├── internal/
│   ├── tasks/                   # Модуль задач
│   │   ├── api.go               # Публичный интерфейс модуля
│   │   ├── handler.go           # HTTP-обработчики
│   │   ├── service.go           # Бизнес-логика
│   │   ├── repository.go        # Работа с БД
│   │   └── model.go             # Модели данных
│   ├── projects/                # Модуль проектов
│   │   ├── api.go
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   ├── users/                   # Модуль пользователей
│   │   └── ...
│   ├── auth/                    # Модуль авторизации
│   │   └── ...
│   └── shared/                  # Общие компоненты
│       ├── middleware/
│       ├── database/
│       └── logger/
├── migrations/                  # Единые миграции БД
└── go.mod

Ключевое правило: модули общаются только через публичные интерфейсы, никогда не импортируют внутренние пакеты друг друга. Мы написали линтер, который проверяет это при каждом коммите:

# .golangci.yml — правило для проверки границ модулей
linters-settings:
  depguard:
    rules:
      module-boundaries:
        files:
          - "**/internal/tasks/**"
        deny:
          - pkg: "taskflow/internal/projects/repository"
            desc: "Модуль tasks не должен обращаться к internal проектов напрямую"
          - pkg: "taskflow/internal/users/repository"
            desc: "Используйте users.API для доступа к данным пользователей"

Стратегия объединения: Strangler Fig наоборот

Мы применили паттерн Strangler Fig, но в обратном направлении — монолит постепенно поглощал микросервисы. Каждую неделю мы «съедали» 3-4 сервиса.

Порядок был таким:

  1. Неделя 1-2: Базовые модули (user-service, auth-service) — от них зависело всё остальное.
  2. Неделя 3-5: Доменные модули (task-service, project-service, comment-service, label-service и т.д.) — основная бизнес-логика.
  3. Неделя 6-8: Вспомогательные модули (audit-log, analytics, search-service) — наименее критичные.
  4. Неделя 9-10: API-gateway — последний, потому что он маршрутизировал трафик ко всем сервисам.

Для каждого сервиса процесс миграции выглядел одинаково:

#!/bin/bash
# migrate_service.sh — скрипт миграции одного сервиса в монолит
SERVICE=$1

echo "=== Миграция $SERVICE ==="

# 1. Копируем бизнес-логику в модуль монолита
echo "[1/5] Копирование кода..."
mkdir -p internal/$SERVICE
cp -r ../microservices/$SERVICE/internal/* internal/$SERVICE/

# 2. Адаптируем импорты
echo "[2/5] Обновление импортов..."
find internal/$SERVICE -name '*.go' -exec sed -i \
  "s|github.com/taskflow/$SERVICE/internal|taskflow/internal/$SERVICE|g" {} +

# 3. Заменяем HTTP-вызовы на прямые вызовы функций
echo "[3/5] Замена HTTP-вызовов на in-process..."
# Пример: http.Get("http://user-service/api/users/"+id)
# Становится: users.API.GetUser(ctx, id)

# 4. Мигрируем схему БД
echo "[4/5] Миграция схемы БД..."
pg_dump -s -n $SERVICE taskflow_${SERVICE} >> migrations/consolidated.sql

# 5. Обновляем маршруты в API gateway
echo "[5/5] Обновление маршрутов..."
# Nginx upstream переключается с сервиса на монолит

На этапе переключения трафика мы использовали Nginx как прокси: 10% трафика шло в монолит, 90% — в старый микросервис. Мониторили ошибки и латентность в Grafana, и за 2-3 дня доводили до 100%.

Консолидация баз данных

23 отдельных PostgreSQL-инстанса — это 23 точки отказа, 23 бэкапа, 23 мониторинга. Мы свели всё к одной базе данных с разделением по схемам.

-- Создаём единую базу с отдельными схемами для каждого модуля
CREATE DATABASE taskflow_unified;

-- Схема для каждого бывшего микросервиса
CREATE SCHEMA tasks;
CREATE SCHEMA projects;
CREATE SCHEMA users;
CREATE SCHEMA auth;
CREATE SCHEMA comments;
CREATE SCHEMA labels;
CREATE SCHEMA audit;

-- Миграция данных из отдельных баз
-- Для каждого сервиса:
pg_dump -Fc taskflow_tasks | pg_restore -d taskflow_unified --schema=tasks
pg_dump -Fc taskflow_projects | pg_restore -d taskflow_unified --schema=projects
-- ... и так для каждого

Главное преимущество консолидации — вернулись нормальные JOIN-ы:

-- БЫЛО: 3 HTTP-запроса + сборка в коде (120 мс)
-- GET /api/tasks/123
-- GET /api/users/456  (автор)
-- GET /api/projects/789 (проект)

-- СТАЛО: 1 SQL-запрос (2 мс)
SELECT
    t.id, t.title, t.status, t.due_date,
    u.name AS assignee_name, u.avatar_url,
    p.name AS project_name, p.color
FROM tasks.tasks t
JOIN users.users u ON u.id = t.assignee_id
JOIN projects.projects p ON p.id = t.project_id
WHERE t.id = 123;

Мы также восстановили внешние ключи между схемами, что вернуло целостность данных. За время работы на микросервисах накопилось 340 «осиротевших» записей — комментарии к удалённым задачам, задачи в несуществующих проектах.

CI/CD: от 47 пайплайнов к одному

47 пайплайнов в GitLab CI — это не просто неудобство, это 47 мест, где что-то может сломаться. Мы заменили их одним, но умным пайплайном:

# .gitlab-ci.yml — единый пайплайн для модульного монолита
stages:
  - lint
  - test
  - build
  - deploy

lint:
  stage: lint
  script:
    - golangci-lint run --config .golangci.yml ./...
    # Проверяем границы модулей
    - go run cmd/modcheck/main.go

test:
  stage: test
  services:
    - postgres:16
  variables:
    POSTGRES_DB: taskflow_test
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
  script:
    - go test -race -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | tail -1
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

build:
  stage: build
  script:
    - docker build -t taskflow:$CI_COMMIT_SHA .
    - docker push registry.taskflow.ru/taskflow:$CI_COMMIT_SHA

deploy_staging:
  stage: deploy
  script:
    - kubectl set image deployment/taskflow \
        taskflow=registry.taskflow.ru/taskflow:$CI_COMMIT_SHA \
        -n staging
    - kubectl rollout status deployment/taskflow -n staging --timeout=120s
  environment: staging

deploy_production:
  stage: deploy
  script:
    - kubectl set image deployment/taskflow \
        taskflow=registry.taskflow.ru/taskflow:$CI_COMMIT_SHA \
        -n production
    - kubectl rollout status deployment/taskflow -n production --timeout=180s
  environment: production
  when: manual

Время полного цикла CI/CD: с 47 × 8 минут (параллельно, но всё равно 25-30 минут из-за зависимостей) до 6 минут для единого приложения. Деплой стал предсказуемым — одна кнопка, один артефакт, один rollout.

Результаты миграции

Через 10 недель работы мы получили следующие результаты:

Метрика47 микросервисовМодульный монолит
Среднее время ответа API280 мс45 мс
P99 латентность1800 мс180 мс
Каскадные сбои в месяц2-30
Время расследования инцидента3-4 часа20-30 минут
Расходы на инфраструктуру840 000 ₽/мес340 000 ₽/мес
Потребление RAM32 GB (суммарно)4.5 GB
Время деплоя25-30 минут6 минут
Количество CI/CD пайплайнов471

Экономия 500 000 ₽ в месяц на инфраструктуре — это 6 млн ₽ в год. Для компании с выручкой 25 млн ₽ это существенно. Но главный выигрыш — скорость разработки. Команда из 6 человек снова может эффективно работать над продуктом, а не воевать с инфраструктурой.

Три сервиса, которые мы оставили отдельными (file-processor, report-generator, notification-worker), общаются с монолитом через очередь NATS. Это простая, надёжная архитектура: монолит кладёт задачу в очередь, воркер забирает и обрабатывает.

Подробнее о подходах к архитектуре серверных приложений и выборе оптимальной стратегии можно узнать на itfresh.ru.

Выводы: когда микросервисы — зло

Опыт «ТаскФлоу» — далеко не уникальный. Мы видим одну и ту же ошибку: команда из 5-10 человек строит архитектуру, рассчитанную на 200 инженеров. Вот наши рекомендации:

  • Правило Безоса наоборот — если команду можно накормить двумя пиццами, ей не нужно 47 сервисов. Начинайте с монолита, выделяйте сервисы только при конкретной необходимости (другой цикл масштабирования, другой стек, изоляция отказов).
  • Считайте стоимость владения — каждый микросервис стоит не только в разработке, но и в поддержке: мониторинг, логирование, CI/CD, обновление зависимостей, сетевая отладка.
  • Модульный монолит — золотая середина — чёткие границы модулей дают те же преимущества изоляции, что и микросервисы, но без накладных расходов на сеть и инфраструктуру. Если модуль действительно «перерос» монолит — его всегда можно выделить.
  • Паттерн «ствол и ветки» — основная бизнес-логика живёт в монолите, а отдельно выносятся только CPU/IO-интенсивные воркеры: обработка изображений, генерация отчётов, рассылка уведомлений.

Часто задаваемые вопросы

Микросервисы оправданы при большой команде (50+ инженеров), когда разные части системы имеют принципиально разные требования к масштабированию, разные технологические стеки, или когда нужна изоляция отказов критичных компонентов. Если у вас команда до 15 человек, модульный монолит покроет 90% потребностей.
Ключ — модульная архитектура с жёсткими границами. Каждый модуль имеет публичный API (интерфейс в Go, абстрактный класс в Java), скрытую реализацию и запрет на прямой доступ к внутренностям другого модуля. Линтеры и архитектурные тесты проверяют эти границы при каждом коммите.
Зависит от количества сервисов и сложности межсервисного взаимодействия. Для 47 сервисов «ТаскФлоу» весь процесс занял 10 недель с командой из 3 инженеров. Основное время уходит на замену HTTP-вызовов на прямые вызовы функций и консолидацию баз данных.
Используйте подход «одна база — отдельные схемы». Каждый бывший микросервис получает свою схему в единой PostgreSQL. Это сохраняет логическое разделение данных, но даёт возможность делать JOIN-ы и внешние ключи между схемами. Миграцию выполняйте через pg_dump/pg_restore поэтапно.
Проще, чем 47 сервисов. Один Prometheus endpoint с метриками по модулям (используйте лейблы module=tasks, module=projects), единый лог-поток в Loki с полем module, один дашборд в Grafana. Сквозной trace_id по-прежнему полезен для отладки цепочек вызовов между модулями.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#микросервисы#монолит#модульный монолит#обратная миграция#DevOps#архитектура#каскадные сбои#консолидация баз данных
Комментарии 0

Оставить комментарий

загрузка...