Паттерны бэкенда, часть 2: эксплуатация и изменения
Service discovery, sidecar, health check, graceful shutdown, распределённые локи, API-ключи, фича-флаги, совместимость контрактов, метрики и трассировка — с анимациями и продакшн-кейсами
Эти паттерны спрашивают на собеседованиях на middle и senior, и отвечать определением из документации — худшее, что можно сделать. Спрашивают не «что такое circuit breaker», а «что у вас происходило, когда падал Redis»
Поэтому всё разобрано на одной системе — сервисе уведомлений. Он маленький, но в нём есть всё: входящие события, чужой API, кеш, база, очередь на отправку и воркеры по каналам. Каждая стрелка на схеме — место, где что-то отваливалось в проде
событие → профиль → история → отправка
Notification Service — на нём и разбираем
Всё начинается с чужого события: какой-то сервис сообщил, что пользователь зарегистрировался. Notification Service читает его из Kafka и решает, что с этим делать
Совет
Как читать схемы
Каждая анимация проходит сценарий по шагам: видно запрос, видно отказ и видно, что именно сделал паттерн. Шаги переключаются кнопками внизу, пауза — кнопкой в шапке. У каждого паттерна сверху есть строка «проблема — решение»: если суть ясна, дальше можно листать
1. Service discovery в Kubernetes
Поды пересоздаются с новыми адресами при каждом релизе, скейле и переезде ноды. IP в конфиге ломается на первом же рестарте соседнего сервиса
В коде только имя. Платформа сама держит актуальный список живых адресов и раскидывает по нему соединения
Discovery отвечает на один вопрос: по какому адресу сейчас живёт email-worker. В Kubernetes ответ встроен в платформу, поэтому код об этом даже не знает — он ходит на имя, всё остальное делают CoreDNS и kube-proxy
DNS → ClusterIP → Endpoints
Под переехал, IP сменился — вызывающий не заметил
В коде нет ни одного IP-адреса — только имя email-worker.prod.svc.cluster.local. Адреса подов меняются при каждом рестарте, имя не меняется никогда
- Service даёт стабильный виртуальный адрес, который не принадлежит ни одному поду
- CoreDNS резолвит имя вида
service.namespace.svc.cluster.localв этот адрес - Endpoints — список живых подов, туда попадают только прошедшие readiness
- kube-proxy или eBPF-датаплейн раскидывает соединения по этому списку
Памятка
Чем это делают на практике
Внутри Kubernetes — CoreDNS и kube-proxy из коробки, ставить ничего не нужно. Вне его — Consul, etcd или Eureka как отдельный реестр, куда сервис регистрируется сам. В сервис-мешах Istio и Linkerd тот же список эндпоинтов раздаётся сайдкарам Envoy, и балансировка уезжает из ядра кластера в под
Не делай так
Грабли
DNS-ответ кешируется в JVM и в некоторых HTTP-клиентах бессрочно. Под переехал, адрес сменился, клиент продолжает долбиться в мёртвый IP до перезапуска процесса. Проверьте TTL резолвера и то, что клиент действительно перерезолвит имя, а не разово запомнит его на старте
2. Sidecar
mTLS, ретраи, лимиты, метрики, секреты — одна и та же работа в каждом сервисе. На двадцати сервисах и трёх языках это двадцать версий библиотеки, половина не обновлялась год
Работа уезжает в соседний контейнер того же пода. Приложение ходит на 127.0.0.1 и не знает ни про TLS, ни про адреса, ни про политику повторов
Контейнеры внутри пода делят сетевое пространство — именно это и делает приём возможным. Для приложения вызов соседа выглядит как обычный HTTP на localhost, а всё, что происходит с пакетом дальше, описано конфигом, а не кодом
один под — два контейнера
Приложение ходит на localhost и ничего не знает про TLS
Приложение отправляет обычный HTTP на 127.0.0.1. Весь сетевой обвес живёт в соседнем контейнере того же пода — они делят network namespace
- Авторизация и mTLS — sidecar терминирует TLS и проверяет сертификат собеседника
- Секреты — забирает из Vault и обновляет без рестарта приложения
- Discovery и балансировка — держит актуальный список эндпоинтов
- Таймауты, повторы и лимиты — описаны конфигом, одинаково для всех языков
- Метрики и трассировка — снимаются с трафика, приложение не инструментируют
Памятка
Продакшн-кейс
В образе сервиса нет ни одного пароля: креды к Redis подставляет sidecar, забирая их из Vault с часовым TTL. Ротация секрета не требует ни релиза, ни рестарта пода — меняется только то, что sidecar принесёт в следующий раз
Важно
Цена
Плюс контейнер к каждому поду, плюс его память, плюс миллисекунда-другая на хоп. И отладка усложняется: на вопрос «почему 503» теперь может отвечать sidecar, а не приложение, и в логах приложения этого не видно. На трёх сервисах меш обычно не окупается
3. Health check: liveness и readiness
Одна ручка /health на оба случая. Зависимость моргнула — и Kubernetes перезапускает поды вместо того, чтобы просто увести с них трафик
Две отдельные пробы. Liveness проверяет только процесс, readiness — готовность принимать запросы. Провалы у них приводят к разному
Пробы отвечают на разные вопросы: liveness — «жив ли процесс», readiness — «могу ли я сейчас принимать трафик». Разница в последствиях: провал liveness убивает контейнер, провал readiness всего лишь выводит под из балансировки, оставляя его работать
liveness ≠ readiness
Redis лёг — под вывели из трафика, но не убили
Liveness отвечает на один вопрос: жив ли процесс. Он не ходит в базу и не проверяет зависимости — иначе мигание базы устроит рестарт-шторм всему кластеру разом
livenessProbe:
httpGet: { path: /healthz, port: 8080 } # только сам процесс
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /readyz, port: 8080 } # плюс база, брокер, кеш
periodSeconds: 5
failureThreshold: 2
startupProbe: # даёт время на прогрев
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 2- Liveness не ходит в зависимости — только проверяет, что процесс не завис
- Readiness проверяет базу, брокер и кеш: не готов — трафик уходит на другие реплики
- StartupProbe закрывает медленный старт, чтобы liveness не убил под во время прогрева
- Обе ручки отвечают быстро и не требуют авторизации
Памятка
Продакшн-кейс
Redis отвалился — readiness вернул 503, под вышел из Endpoints, трафик ушёл на здоровые реплики. Процесс при этом жив, ничего не перезапускалось. Redis вернулся — под сам вернулся в балансировку
Не делай так
Самая дорогая ошибка
Liveness, который проверяет подключение к базе. База моргнула на десять секунд — Kubernetes одновременно перезапускает все поды сервиса. Вместо частичной деградации вы получаете полный отказ, а поднявшиеся поды добивают базу лавиной переподключений
4. Graceful shutdown
Под выключается в середине работы: открытые запросы обрываются, взятые из брокера сообщения теряются. При каждом релизе, а не раз в год — поэтому симптом читается как «после деплоя что-то отваливается»
По SIGTERM сначала уходим из балансировки, потом дорабатываем взятое, потом закрываем соединения. И ничего не удаляем из общего внешнего состояния
Health check из предыдущего раздела отвечает на вопрос «пускать ли сюда трафик». Shutdown — вторая половина того же разговора: как перестать его принимать, ничего не уронив
Порядок здесь важнее самого факта. Если сначала закрыть пул к базе, а потом дорабатывать запросы, то дорабатывать будет нечем. Правильная последовательность — обратная порядку запуска
SIGTERM → drain → close
Под выключается и никого не роняет по дороге
Оркестратор просит процесс завершиться. Выйти немедленно — значит оборвать открытые запросы и потерять сообщения, уже взятые из брокера
- Readiness отдаёт
503— под уходит из Endpoints, новые запросы не приходят - Ждём пару секунд — балансировщик узнаёт об этом не мгновенно
- Дорабатываем взятое — открытые ответы, сообщения в обработке, коммит оффсета
- Закрываем соединения с базой и брокером — последними
- Выходим с нулевым кодом, пока не истёк
terminationGracePeriodSeconds
Не делай так
Продакшн-кейс: убитый вебхук
Сервис аккуратно снимал за собой Telegram-вебхук при остановке. Логика безупречная — до первого blue-green деплоя: новая версия успела установить вебхук раньше, чем умерла старая, и умирающий контейнер снёс его у живого. Бот молчал до ручного вмешательства. Отсюда правило: при выкатке два экземпляра живут одновременно, и общий внешний ресурс принадлежит не вам, даже если вы его создавали
Важно
Окно короче задачи
terminationGracePeriodSeconds по умолчанию 30 секунд, после чего прилетает SIGKILL. Если самая долгая задача идёт минуту, корректный shutdown не поможет — её убьют посередине. Окно должно быть больше самой долгой единицы работы, а сама работа — достаточно мелкой, чтобы это было выполнимо
5. Распределённая блокировка
Сервис работает в трёх репликах, планировщик живёт внутри каждой. В полдень задача стартует три раза — и рассылка уходит трижды одному человеку
Перед работой пода берётся общий ключ с TTL. Взял — выполняет, не взял — молча выходит. Ключ один на весь кластер
Ошибка в том, что @Cron в коде выглядит как «раз в сутки», а по факту означает «раз в сутки на каждой реплике». Пока реплика одна, разницы не видно — она появляется ровно в тот день, когда вы отскейлились
Механизм простой: SET key value NX PX 60000. Атомарность обеспечивает Redis, а не ваш код — проверить и потом записать двумя командами нельзя, между ними успеет вклиниться сосед
гонка → SET NX → продление
Три пода проснулись в 12:00, рассылка ушла один раз
Сервис работает в трёх репликах, и планировщик живёт внутри каждой. В полдень задача стартует три раза — по разу на под
- Захват атомарен —
SET ... NX, а не «проверил и записал» - У лока есть TTL, иначе умерший под заблокирует задачу навсегда
- TTL продлевается, пока работа идёт: иначе лок протухнет посреди долгой задачи
- Снимает лок только владелец — сравнение по значению, иначе снимете чужой
- Не взял лок — вышел, а не встал в очередь: задача уже выполняется
Памятка
Продакшн-кейс
Каждая периодическая задача обёрнута в общий захват лока — это правило уровня «нельзя мержить без него». Причина прямо в blue-green: во время выкатки одновременно живут два цвета, оба со своими планировщиками. Без лока каждый релиз в окно рассылки означал бы двойную отправку
Важно
Лок — это не транзакция
Redis-лок не даёт строгих гарантий: при разрыве сети или паузе GC владелец может считать себя владельцем, когда ключ уже перешёл к другому. Для рассылки и отчётов этого достаточно. Для денег — нет: там нужна блокировка в самой базе или уникальный ключ операции
6. Авторизация между сервисами через API-ключ
Внутренняя сеть — не периметр безопасности. Один скомпрометированный под или забытая наружу ручка — и внутренние вызовы начинает слать кто угодно
Каждый вызывающий получает свой ключ со скоупом и лимитом. В базе лежит только хеш, а на время ротации активны сразу два ключа
API-ключ — самый дешёвый способ узнать, кто пришёл. Не самый сильный, но включается за час и закрывает основную дыру: аноним больше не может дёргать внутренние ручки
X-Api-Key → хеш → скоуп
Свой сервис пускаем, чужой — нет
Вызывающий сервис кладёт ключ в заголовок X-Api-Key. Не в query-параметр: query оседает в логах nginx, в истории браузера и в системах трассировки
- Ключ едет в заголовке
X-Api-Key— не в query, который оседает в логах и трассировках - В базе
sha-256, а не сам ключ: утечка дампа не даёт доступа - Сравнение constant-time, чтобы ключ нельзя было подобрать по времени ответа
- У ключа есть владелец, скоуп и лимит запросов
- Ротация — через два одновременно активных ключа
Памятка
Продакшн-кейс
У ключа для отправки уведомлений скоуп notify:send и ничего больше — читать чужую историю он не умеет. Лимит и счётчик считаются по ключу, поэтому в графиках сразу видно, какой сервис внезапно начал слать втрое больше обычного
Важно
Границы применимости
API-ключ отвечает на вопрос «какой сервис пришёл», а не «какой пользователь». Для пользователя нужен токен с его идентификатором, сроком жизни и правами — иначе права перестают быть проверяемыми, а действия неотличимы друг от друга в аудите
Не делай так
Грабли
Один ключ на все сервисы и все окружения. Ключ утёк — менять нужно везде одновременно, а значит, простой согласовывается неделю. Ключ на каждого потребителя стоит те же пять минут при заведении и спасает ровно в тот момент, когда всё горит
7. Фича-флаги и A/B-тесты
Включение функции намертво привязано к выкатке кода. Что-то пошло не так — откат идёт через пайплайн, а не через переключатель
Код уезжает в прод выключенным, включается отдельным действием. Тот же механизм разводит два варианта и даёт сравнить их по метрике
Флаг разводит выкатку и включение. Сначала на себя, потом на процент пользователей, потом на всех — и обратно за секунды, без ожидания сборки
A/B-тест — тот же флаг, но с вопросом «какой вариант лучше» вместо «включать ли». Разница ровно в одном: результат обязан считаться, иначе это спор о вкусах с лишней инфраструктурой
флаг → бакет по userId → метрика
Новый текст пуша раскатан на половину пользователей
Перед отправкой сервис спрашивает SDK: включён ли новый шаблон для этого пользователя. Ответ лежит в локальном кеше 30 секунд, поэтому это не сетевой вызов на каждое сообщение
- Значение резолвится из локального кеша SDK, а не сетевым вызовом на каждое сообщение
- Вариант выбирается хешем от
userId— один и тот же человек всегда видит одно и то же - Метрика считается в разрезе варианта, иначе эксперимент бессмысленен
- У флага есть владелец и дата смерти
Памятка
Продакшн-кейс
Новый текст пуша раскатывали через флаг: половина получала старый шаблон, половина новый. На сорока тысячах отправок открываемость составила 4.1 % против 6.7 %, после чего вариант стал единственным, а флаг удалили
Не делай так
Грабли
Флаг без срока жизни — это ветвление, которое остаётся в коде навсегда. Через год их сорок, половина всегда включена, и никто не помнит, что произойдёт при выключении. Удаление флага — часть работы, а не задача на «когда-нибудь потом»
Важно
Случайность вместо хеша
Если выбирать вариант через Math.random() на каждый запрос, один пользователь за неделю увидит оба письма, а метрика перестанет что-либо значить. Бакет должен быть детерминированным: hash(userId + experiment) % 100
8. Обратная совместимость контрактов
При blue-green деплое старая и новая версии работают одновременно. Удалённое поле не даёт ошибки — старый клиент молча читает пустую строку и работает неправильно
Поля только добавляются, снятые закрываются через reserved. Снятие идёт двумя релизами, а проверяет это buf breaking в пайплайне
Это не паттерн, а правило, но оно дороже большинства паттернов из списка. Пока идёт выкатка, старый код читает данные, которые пишет новый, — и так минуты или часы
В protobuf ломающие изменения особенно коварны, потому что не вызывают ошибок. Поле удалили — старый клиент получает не исключение, а значение по умолчанию: пустую строку или ноль. Он не падает, он просто тихо работает неправильно
expand → migrate → contract
Поле удалили из proto — старый клиент молча получил ноль
Из сообщения выкинули поле phone под номером 3. Старый клиент не падает и не получает ошибку — он просто читает пустую строку и считает, что телефона у пользователя нет
message User {
reserved 3; // здесь был phone — номер больше не занять
reserved "phone"; // и имя тоже
string id = 1;
string email = 2;
string phone_e164 = 7; // новое поле — новый номер
}- Поля только добавляются, с новыми номерами — старые читатели их игнорируют
- Номер и тип существующего поля не меняются никогда
- Снятое поле закрывается через
reserved— и номер, и имя - Обязательное поле добавляется только с дефолтом, иначе старый продюсер станет невалидным
- Снятие идёт двумя релизами: сначала перестаём читать, потом перестаём писать
Памятка
То же самое в базе
Правило expand/contract работает и для миграций: колонка не удаляется в том релизе, который перестал ей пользоваться. Сначала выкатывается код, который её не трогает, и только следующим релизом — миграция на удаление. Иначе откат на предыдущую версию встречает схему, которой уже нет
Не делай так
Грабли
Переиспользовать освободившийся номер поля. Опасность в том, что типы на проводе не различаются поштучно: string, bytes и вложенное сообщение кодируются одинаково, int32, bool и enum — тоже. Если новое поле попало в ту же группу, старый клиент прочитает чужие байты как своё значение — молча, без единой ошибки в логах
Совет
На словах это не держится. Держится на проверке: buf breaking в пайплайне сравнивает proto с веткой по умолчанию и роняет сборку до мержа — то есть до инцидента, а не после него
9. Метрики: pull и push
При push молчащий сервис неотличим от сервиса без нагрузки — метрик нет в обоих случаях, и упавший под не поднимает тревогу
Prometheus сам ходит на /metrics и при неответе пишет up = 0. Push остаётся исключением для задач, которые не доживают до скрейпа
Сервис отдаёт по /metrics текущие значения счётчиков в текстовом формате. Prometheus раз в 15 секунд обходит все известные ему цели и складывает срез к себе. Сервис при этом не знает ни адреса мониторинга, ни того, что его вообще кто-то читает
pull по умолчанию, push для эфемерного
Prometheus сам ходит за метриками, кроме одного случая
Prometheus раз в 15 секунд сам приходит на /metrics каждого пода. Сервисы ничего никуда не отправляют — они просто отдают текущее состояние счётчиков по HTTP
- Цели берутся из service discovery — новые поды попадают в скрейп автоматически
- Не ответил на скрейп — Prometheus сам пишет
up = 0, недоступность становится сигналом - Отладка сводится к
curlна/metrics: видно ровно то, что увидит мониторинг - Push нужен там, где цель не доживает до скрейпа — короткие джобы через Pushgateway
Памятка
Продакшн-кейс
Notification Service и воркеры отдают /metrics, цели подтягиваются из Kubernetes SD. Ночная рассылка живёт восемь секунд и в скрейп не попадает — она толкает свои метрики в Pushgateway перед завершением
Важно
Ловушка Pushgateway
Он хранит последнее полученное значение вечно. Джоба удалена месяц назад, а её метрика всё ещё торчит в графиках и продолжает участвовать в алертах. Записи нужно удалять явно — сами они не исчезают
Совет
Правило простое: долгоживущее тянем, эфемерное толкаем. Всё остальное — попытка обойти pull там, где он работал бы лучше
10. Трассировка и correlation ID
Метрики показывают, что стало плохо, но не показывают где. Запрос прошёл четыре сервиса за три секунды — какой из них их съел, по графикам не понять
Сквозной идентификатор рождается на входе и едет через все вызовы. По нему собирается водопад спанов и склеиваются логи всех участников
Это вторая половина наблюдаемости. Метрики отвечают на вопрос «плохо ли» и хороши для алертов. Трассировка отвечает на «где именно» и хороша для разбора конкретного случая. Одно не заменяет другое
Технически всё сводится к одному: trace_id создаётся на входе и пробрасывается дальше без потерь. Забыли в одном месте — трасса рвётся, и вместо цельной картины остаются несвязанные огрызки
trace_id → проброс → водопад
Запрос прошёл четыре сервиса — где потерялись 3 секунды
На входе появляется trace_id. Если клиент прислал свой заголовок traceparent — продолжаем его трассу, а не начинаем новую: иначе мобильное приложение и бэкенд окажутся в разных историях
- Входящий
traceparentпродолжаем, а не заменяем — иначе клиент и бэкенд окажутся в разных историях - В очередь контекст кладётся руками — в заголовки сообщения, HTTP-заголовков там нет
- Тот же id пишется в каждую строку лога — один поиск склеивает пять сервисов
- Сэмплирование 1–5 %, но ошибочные и медленные трассы сохраняются всегда
- Контекст не таскается параметром через все функции — для этого есть хранилище на уровне запроса
Памятка
Продакшн-кейс
Контекст запроса — идентификатор, тенант, пользователь — живёт в AsyncLocalStorage и подмешивается в каждую строку лога автоматически. Разработчику не нужно ничего передавать: обычный new Logger() в глубине сервиса уже пишет с полным контекстом
Не делай так
Грабли
Трасса обрывается в фоновых задачах. Запрос поставил задачу в очередь и ответил, воркер разобрал её через минуту — и если контекст не уехал в сообщении, половина работы окажется вне трассы. Именно та половина, которая падает чаще
Что с этим делать
Двадцать два паттерна выглядят устрашающе, но внедряют их не целиком и не сразу. Порядок диктует боль: сначала таймауты и повторы, потому что без них падает всё; следом health-чеки и shutdown, потому что без них Kubernetes не умеет вас лечить; дальше outbox и идемпотентность, когда появляются события и обнаруживаются потери
На собеседовании работает то же правило. Вместо определения — история: что было, что сломалось, что сделали, что получилось измерить. Одна такая история весит больше, чем пересказ пяти паттернов подряд
Важно
Каждый паттерн стоит денег
Sidecar — это лишний контейнер в каждом поде. Outbox — фоновый процесс и таблица, которую надо чистить. Сага — компенсации, которые тоже падают. «Мы не стали это делать, потому что нагрузка не та» звучит сильнее, чем список внедрённого без причины