Перейти к содержанию

Масштабирование и наблюдаемость

Масштабирование

Главная идея: микросервисы масштабируются не «всё приложение целиком», а каждый сервис отдельно по своему профилю нагрузки. Один горизонтально по CPU, другой упирается в БД, третий жуёт очередь. Сначала находишь bottleneck, потом масштабируешь именно его — не наугад.

Stateless сервис скейлится копиями

Stateless service — это сервис, который не хранит состояние запроса в памяти инстанса между вызовами.

  • Любой инстанс обрабатывает любой запрос. Поднимаешь N копий за load balancer и раскидываешь трафик по ним.
  • Состояние живёт вовне: в БД, в Redis, в брокере. Инстанс — чистая молотилка.
  • Сессии, локальные кэши, файлы на диске, in-memory счётчики между запросами делают сервис stateful и ломают горизонтальное масштабирование.

В Kubernetes это HPA (Horizontal Pod Autoscaler) — добавляет и убирает поды по метрике. Грубо говоря: метрика выше target — больше подов, ниже — меньше.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: orders-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: orders-api
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

По чему скейлить: - CPU/memory — дефолт для compute-bound сервисов. Самый простой случай. - RPS или latency — ближе к реальному SLA, но это уже custom/external metrics через адаптер (например Prometheus Adapter). - Длина очереди — для воркеров. Скейлишь по числу необработанных сообщений, не по CPU.

Note

CPU-таргет врёт для I/O-bound сервисов. Сервис, который весь день ждёт ответа от БД или соседа, по CPU выглядит простаивающим — а по latency уже задыхается. Для таких скейль по RPS или по длине очереди.


Состояние выноси из инстанса

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

Куда выносить: - Сессии и токены — в Redis или в stateless JWT, не в память процесса. - Кэш — в общий Redis/Memcached, не in-process (иначе у каждого инстанса свой кэш и разнобой). - Файлы и загрузки — в object storage (S3-совместимое), не на локальный диск. - Бизнес-данные — в БД сервиса (database per service, подробнее в Macro-Architecture).

Если без локального состояния никак (стрим-обработка, хранилище, координатор) — это уже не обычный сервис, а stateful workload. В Kubernetes для него StatefulSet: стабильные сетевые имена, привязанные тома. Скейлится он хуже, чем Deployment, — см. грабли в конце.


Чтения масштабируются отдельно от записей

В типичном OLTP-сервисе чтений на порядок больше, чем записей. Их и масштабируешь первыми.

Read replica — это копия БД, на которую идёт асинхронная репликация с primary; запись только в primary, чтения можно раскидать по репликам.

  • Тяжёлые отчёты и read-heavy эндпоинты гонишь на реплики, primary разгружается.
  • Платишь replication lag: реплика отстаёт на миллисекунды-секунды. Запросы, которым нужен read-your-writes, оставляй на primary.
  • Реплики решают чтение, но не запись. Уперся в запись — это уже шардирование.

CQRS — это разделение модели на write-модель (команды) и read-модель (запросы), которые могут жить в разных схемах и хранилищах.

  • Когда read-паттерн сильно отличается от write-паттерна — заводишь отдельную read model под конкретные запросы. Денормализованную, предпосчитанную, в подходящем хранилище (Elasticsearch под поиск, отдельная таблица под дашборд).
  • Read model обновляется асинхронно из событий write-стороны — отсюда eventual consistency между ними. Это цена, не баг.
  • CQRS не требует event sourcing и не требует двух баз — это только разделение моделей. Когда брать его как макро-паттерн и чем он отличается от простых read replica — в Macro-Architecture.

Шардирование и партиционирование

Реплики масштабируют чтение. Когда упёрся в запись или в объём данных — режешь данные на части.

  • Partitioning — это разбиение одной таблицы на куски по ключу внутри одной БД (по диапазону, по хешу, по списку).
  • Sharding — это разнесение данных по разным физическим узлам по shard key; каждый узел держит свой кусок и принимает свою долю записи.

Ключ шардирования решает всё. Хороший ключ распределяет нагрузку равномерно: user_id, tenant_id, order_id. Плохой собирает трафик в одну точку — горячая партиция, и весь смысл шардирования теряется.

В Kafka та же логика — партиция это единица параллелизма консьюмеров:

topic: orders (6 партиций)            consumer group: order-processor
┌─────────────┐
│ partition 0 │──────────────┐
│ partition 1 │──────┐       └──────▶ consumer A
│ partition 2 │──┐   └──────────────▶ consumer B
│ partition 3 │  └──────────────────▶ consumer C
│ partition 4 │  ┌──────────────────▶ (consumer D — простаивает,
│ partition 5 │──┘                      партиций на всех не хватило)
└─────────────┘
  • Одну партицию читает максимум один консьюмер в группе. Хочешь больше параллелизма — больше партиций. Консьюмеров больше, чем партиций — лишние простаивают.
  • Партиция выбирается по ключу сообщения (обычно хеш ключа). Один ключ — всегда одна партиция, отсюда гарантия порядка в пределах ключа.
  • Ключ выбирай так же осторожно, как shard key. Перекошенный ключ (например все события одного жирного клиента) забивает одну партицию — горячая партиция, один консьюмер в поту, остальные курят.

Note

Число партиций в Kafka легко увеличить, но это ломает привязку ключа к партиции (хеш считается по числу партиций) — порядок для существующих ключей рассыпается. Закладывай запас партиций заранее, перешардировать на ходу больно.


Тяжёлые задачи — в async-воркеры

Всё, что долго и не нужно прямо в ответе, выноси из request-path в фоновую обработку через очередь.

Что выносить: - Отправку писем, пушей, вебхуков. - Генерацию отчётов, экспорт, импорт, обработку картинок и видео. - Вызовы медленных сторонних API, где клиенту не нужен синхронный результат.

Идея простая: API кладёт задачу в очередь и сразу отвечает 202 Accepted. Воркеры разгребают очередь в своём темпе.

client ──▶ API ──▶ [queue] ──▶ worker pool ──▶ result store
              │                    (scale by
        202 Accepted              queue depth)
  • API скейлится по RPS, воркеры — по длине очереди. Это два разных профиля нагрузки, и хорошо, что они развязаны.
  • Всплеск нагрузки очередь сглаживает: задачи копятся, воркеры догоняют. Request-path не падает под пиком.
  • Воркер обрабатывает сообщение at-least-once — повтор возможен. Делай обработку идемпотентной (idempotent consumer). Механика at-least-once, outbox и дедупликации — в секции «Данные и согласованность».

Грабли

  • Stateful сервис не скейлится копиями. Подняв вторую копию того, что держит состояние в памяти (сессии, in-memory кэш, локальные счётчики), получаешь рассинхрон и плавающие баги. Сначала делаешь stateless, потом скейлишь.
  • Неравномерный ключ шардирования рождает горячую партицию. Один узел или одна партиция тащит львиную долю трафика, остальные простаивают — масштаб есть, толку нет. Бьёт и в БД, и в Kafka.
  • Консьюмеров больше, чем партиций. Лишние простаивают, потолок параллелизма задан числом партиций, а не числом подов. HPA по CPU тут не поможет.
  • Скейлишь не то звено. Добавил подов API, а bottleneck — в БД или в соседнем сервисе. Стало только хуже: больше инстансов сильнее долбят то же узкое место. Сначала измеряешь, где затык, потом масштабируешь именно его.
  • HPA по CPU на I/O-bound сервисе. Сервис ждёт сеть, CPU низкий, HPA не реагирует, а latency растёт. Скейль по той метрике, которая реально отражает насыщение.
  • Авто-скейл без лимита. maxReplicas забыли или поставили в небо — баг или DDoS раздувает кластер, прилетает счёт. Ставь верхнюю границу и алерт на упор в неё.

Наблюдаемость (observability)

В монолите ошибку видно по стектрейсу: упало здесь, причина выше по стеку, всё в одном процессе. В распределёнке так не выйдет. Запрос идёт через gateway, дёргает три сервиса, один пишет в брокер, consumer обрабатывает асинхронно. Стектрейс границу сервиса не пересекает. Падает один сервис — а симптом ловишь в другом, через секунду, в чужом логе.

Observability — это способность по внешним сигналам системы понять, что внутри происходит, не залезая в код и не перевыкатывая. - три сигнала: logs, metrics, traces. Их часто зовут три столпа. - цель не «собрать побольше данных», а ответить на «почему упало» за минуты, не за часы. - monitoring отвечает на «что сломалось» (заранее известные симптомы), observability — на «почему», включая то, чего ты заранее не предвидел.


Три столпа: logs, metrics, traces

Каждый сигнал отвечает на свой вопрос. По отдельности они слепые наполовину.

  • logs — дискретные события с контекстом. «Что именно случилось вот здесь, в этот момент». Хороши для деталей одного события, плохи для агрегатов.
  • metrics — числовые ряды во времени, агрегированные. «Сколько, как быстро, как часто». Дёшевы в хранении, отвечают мгновенно, но теряют контекст конкретного запроса.
  • traces — путь одного запроса через все сервисы. «Где именно в цепочке время потерялось и кто кого звал». Связывают сервисы в одну картину.

Идея простая: метрика говорит «p99 latency подскочил», trace показывает в каком сервисе, лог объясняет почему. Один сигнал без двух других оставляет половину вопроса без ответа.


Correlation: traceId сквозь цепочку

Главный механизм распределённой отладки — сквозной идентификатор запроса. Gateway генерирует traceId на входе, дальше он едет через все хопы: HTTP-заголовки, метаданные брокера, поля лога.

Без него три сервиса пишут три несвязанных лога, и «почему упало» руками не собрать.

client ──▶ gateway ──▶ order-svc ──▶ payment-svc ──▶ broker ──▶ notification-svc
          traceId=a1b2  a1b2          a1b2            a1b2        a1b2
          span=1        span=2        span=3          (publish)   span=5
                                      span=4 ──▶ DB
                                      a1b2

лог из notification-svc:
  {"ts":"...", "level":"ERROR", "traceId":"a1b2", "spanId":"5",
   "msg":"failed to send", "orderId":"77"}
  • traceId — общий на весь запрос, один на всю цепочку.
  • spanId — на каждую операцию внутри (хоп, запрос в БД, publish в брокер). Span'ы образуют дерево: у каждого есть parent.
  • propagation — передача этих id между сервисами. Стандарт — W3C Trace Context, заголовок traceparent. Через брокер id кладёшь в headers сообщения, не в payload.

Грабли: trace рвётся на асинхронной границе. Положил сообщение в Kafka — перенеси traceId из текущего контекста в headers, иначе consumer стартует новый trace и связь теряется. То же с background-задачами и cron.


OpenTelemetry: вендор-нейтральный стандарт

OpenTelemetry (OTel) — это набор API, SDK, семантических соглашений и Collector для генерации, сбора и экспорта телеметрии (traces, metrics, logs) в едином формате. Проект CNCF, появился в 2019 из слияния OpenTracing и OpenCensus.

Зачем: один способ инструментировать код, любой backend на выходе. Меняешь Jaeger на вендора — правишь конфиг Collector, а не код сервисов.

Грабли: OTel сам ничего не хранит и не рисует. Он только производит и доставляет телеметрию в стандартном формате. Хранилище и UI — отдельно (Jaeger, Prometheus, вендор). Частая ошибка — ждать от OTel готовых дашбордов.

Типичная раскладка компонентов:

сервисы (OTel SDK)
      │  OTLP (gRPC/HTTP)
  OTel Collector  ──── receive → process (batch, sample, redact) → export
      │              │                │
      ▼              ▼                ▼
   traces         metrics           logs
  Jaeger/Tempo   Prometheus       Loki / ELK
                  Grafana (единый UI поверх всего)
  • SDK живёт в сервисе: создаёт span'ы, собирает метрики, прокидывает context.
  • Collector — отдельный процесс (часто sidecar или daemonset). Принимает OTLP, батчит, семплирует, режет PII, разводит сигналы по backend'ам.
  • Collector как буфер развязывает сервисы и хранилища. Упал Jaeger — сервисы не знают, телеметрия копится в Collector, а не в твоём коде.

Про сам формат сериализации и схемы событий через брокер (Avro/Protobuf, Schema Registry) — смотри Communication & API Style, это не про observability.


Метрики: технические и бизнесовые

Технические метрики описывают здоровье сервиса. Два канона, что именно мерить.

  • RED — для сервисов, обрабатывающих запросы: Rate (RPS), Errors (доля ошибок), Duration (latency, обычно p95/p99).
  • USE — для ресурсов: Utilization, Saturation, Errors. Про CPU, память, пулы соединений, очереди.

Latency меряешь перцентилями, не средним. Среднее прячет хвост: при average 50 ms p99 может быть 2 s, и именно эти 1% запросов бьют по самым активным юзерам.

метрика вопрос пример
Rate сколько запросов RPS на endpoint
Errors сколько падает доля 5xx, доля failed saga
Duration как долго p50 / p95 / p99 latency
Saturation насколько забит глубина очереди, занятость пула коннектов

Бизнес-метрики описывают, делает ли система то, за чем существует. - конверсия по шагам воронки, число успешных оплат в минуту, средний чек. - SLA доменных операций: «order доходит до payment за < 5 s в 99% случаев». - latency saga end-to-end, доля компенсаций (откатов) — мост между техникой и бизнесом.

Грабли: алертить только на технику. CPU в норме, 5xx нет, дашборды зелёные — а оплаты не проходят, потому что молча сломалась интеграция с платёжкой. Бизнес-метрика ловит это, техническая — нет. Минимум одна бизнес-метрика на критичный flow.


SLI, SLO и бюджет ошибок

Чтобы алерты не были «на глаз», нужны явные цели по надёжности.

  • SLI (indicator) — измеримая величина: доля успешных запросов, доля запросов быстрее 300 ms.
  • SLO (objective) — цель по SLI на окне: «99.9% запросов успешны за 30 дней».
  • error budget — допустимый объём нарушения: при SLO 99.9% бюджет ошибок 0.1%. Пока бюджет не выбран — катишь фичи; выбрали досрочно — притормаживаешь и чинишь надёжность.

Идея простая: алертить на сжигание бюджета (burn rate), а не на каждый одиночный 5xx. Один сбойный запрос — шум. Бюджет, который горит вдвое быстрее обычного, — повод будить дежурного.


Structured logging

Лог — это не строка для человека, а событие для машины. Пиши JSON с полями, не текст.

// плохо: не распарсить, не отфильтровать по traceId
log.Printf("order %s failed for user %s: %v", orderID, userID, err)

// хорошо: структурно, с корреляцией
logger.Error("order processing failed",
    "traceId", ctx.TraceID(),
    "orderId", orderID,
    "userId", userID,
    "err", err,
)
  • обязательное поле в каждом логе — traceId (и обычно spanId). Без него лог не привязать к запросу и к остальным сервисам.
  • единый набор полей по всем сервисам: service, level, ts, traceId. Иначе агрегатор не сможет искать сквозь сервисы.
  • уровни осмысленно: ERROR — то, на что реагируют; WARN — подозрительно; INFO — бизнес-события; DEBUG — детали для разбора, обычно выключен в проде.
  • PII в логи не пишешь. Режешь на стороне сервиса либо в Collector. Логи живут долго и расползаются по хранилищам.

Грабли: логи без traceId. Самая частая и самая дорогая ошибка. Сами по себе логи есть, но связать их в историю одного запроса нельзя — отладка распределёнки превращается в гадание.


Health checks: liveness и readiness

Оркестратор (обычно Kubernetes) должен понимать состояние сервиса, чтобы решать: убить под или нет, слать ли в него трафик. Для этого сервис отдаёт два разных endpoint'а.

  • liveness — «процесс жив, не завис». Провалился — оркестратор перезапускает под. Проверяй только себя, без зависимостей.
  • readiness — «готов принимать трафик». Провалился — под выводят из балансировки, но не убивают. Тут уместно проверить критичные зависимости: БД, брокер.

Чем отличаются: путаница между ними — классические грабли. Если в liveness проверять БД, то при недоступной БД оркестратор начнёт перезапускать здоровые поды по кругу, делая хуже. БД — это про readiness (перестань слать трафик), не про liveness (убей и подними заново).

livenessProbe:        # жив ли процесс — только сам сервис
  httpGet: { path: /health/live, port: 8080 }
  periodSeconds: 10
readinessProbe:       # готов ли к трафику — можно чекать зависимости
  httpGet: { path: /health/ready, port: 8080 }
  periodSeconds: 5
  • startup probe пригодится для медленного старта: пока сервис прогревается, liveness не душит его преждевременным рестартом.
  • readiness — точка интеграции с circuit breaker и graceful shutdown: на SIGTERM сервис сначала отдаёт readiness=false, дослуживает текущие запросы, потом гасится.

Про то, где в топологии стоит gateway и mesh и почему сигналы вообще приходится склеивать через сеть — смотри Macro-Architecture. Service mesh снимает часть observability на инфраструктуру (трейсинг и метрики per-hop через sidecar), но прикладные бизнес-метрики и осмысленные span'ы внутри бизнес-логики за тебя не сделает никто.