Масштабирование и наблюдаемость¶
Масштабирование¶
Главная идея: микросервисы масштабируются не «всё приложение целиком», а каждый сервис отдельно по своему профилю нагрузки. Один горизонтально по 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. Воркеры разгребают
очередь в своём темпе.
- 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'ы внутри бизнес-логики за тебя не сделает никто.