Тут могла бы быть история о том, как команда решила перейти на микросервисы ради «масштабируемости» и «гибкости», но на самом деле всё началось с того, что обновление одного модуля монолита ломало половину продакшна. Мы устали. И однажды CTO сказал: «Кажется, пора». Что было дальше — расскажем без прикрас.

Монолит, который нас довёл

Начнём с лирики. Монолитная система, написанная на Spring Boot, существовала с 2017 года. В ней было 8 команд разработчиков, около 2 миллионов строк кода, и каждая третья фича обязательно ломала две старых.

Вот, например, как выглядел один из сервисов (ну, модулей в монолите):

@RestController
@RequestMapping("/api/v1/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
}

А теперь угадайте, что происходит, если UserService вызывает BillingService, который, в свою очередь, зависит от AuthService, а тот от LoggerService, который из-за багов пишет по 100 МБ логов в секунду? Правильно, ад.

План атаки: распилить аккуратно

Резать монолит сразу — значит убить всё. Поэтому мы начали с анализа зависимостей. Самое важное — понять, какие куски можно оторвать с минимальной болью. Использовали для этого комбинацию:

  • Spring Boot Actuator (для метрик)

  • Jaeger + OpenTracing (для трассировки зависимостей)

  • jQAssistant + ArchUnit (для статического анализа кода)

Визуализация зависимостей выглядела как лапша. Но кое-что было понятно: User, Billing, Notifications и Reporting — кандидаты на миграцию.

Первый микросервис: Auth

Начали с самого «изолированного» (ха-ха) сервиса — AuthService. Он уже почти не зависел от других модулей, кроме User. Мы вынесли его в отдельный Spring Boot сервис, завернули в Docker, деплоили в k8s, настроили Kafka для асинхронной коммуникации.

Пример конфигурации Kafka consumer:

spring:
  kafka:
    consumer:
      bootstrap-servers: kafka:9092
      group-id: auth-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

Ничего сложного. Кроме того, что он развалился под нагрузкой в 200 RPS, потому что мы забыли про connection pool. Ну и, конечно, забили на retry-политику, из-за чего Kafka messages уходили в никуда. Учли, переделали.

Вторая волна: Billing и Notifications

Эти сервисы мы вынесли параллельно. Billing работал с PostgreSQL, Notifications — с RabbitMQ. Появился первый общий контракт — мы ввели protobuf-схемы, генерируемые через CI. Это был первый шаг к контрактному тестированию.

Фрагмент proto-файла:

syntax = "proto3";

message UserCreated {
  string id = 1;
  string email = 2;
  string name = 3;
}

Мы добавили lint для схем, автогенерацию в build.gradle.kts и пайплайн в GitLab:

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.19.1"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:1.42.1"
        }
    }
}

Логика миграции: синхрон → асинхрон

Одна из больших проблем была в том, что все модули раньше общались через синхронные вызовы. Мы перешли на event-driven архитектуру. Но это потребовало:

  • Переобучить команды мыслить событиями, а не методами.

  • Ввести event versioning.

  • Создать свой Event Gateway — агрегатор событий с трассировкой.

Мысли и баги на проде

Самое неприятное — момент, когда старый монолит и новые микросервисы живут одновременно. Например:

  • AuthService уже обрабатывает регистрацию,

  • но UserService в монолите всё ещё создаёт пользователя в базе.

Мы временно ввели sync proxy, чтобы Auth дергал старый метод внутри монолита. Грязно? Да. Зато работало.

CI/CD и DevOps ад

Новые микросервисы = новые пайплайны. Мы использовали:

  • GitLab CI

  • Helm + ArgoCD

  • ECR + Kustomize

Каждый сервис теперь имеет helm/ и kustomize/overlays/ с параметрами для stage/prod.

Вот пример deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: auth-service
  template:
    metadata:
      labels:
        app: auth-service
    spec:
      containers:
        - name: auth
          image: registry.example.com/auth:latest
          ports:
            - containerPort: 8080

Что не взлетело

  • Feature Flags — пытались внедрить через LaunchDarkly, но потом сделали свой велосипед.

  • Service Mesh — Istio был хорош на демо, но в проде всё начало лагать.

  • Distributed tracing — почти никто не смотрел в Jaeger, пока не стало совсем плохо.

Что получилось

  • У нас теперь 16 микросервисов.

  • 90% старого монолита выключено.

  • Обновление одного сервиса больше не валит всё остальное.

  • Команды перешли к DevOps-культуре, стали автономными.

Заключение

Если вы думаете мигрировать с монолита — не делайте это, пока не будете уверены, что сможете жить в хаосе как минимум полгода. Это тяжело, это больно, но в итоге — это свобода.