Кромка системы: API-шлюз, BFF, sidecar и service mesh
Почти на любой архитектурной схеме между пользователем и сервисами стоит один безымянный прямоугольник. Иногда он подписан «API Gateway», иногда «nginx», иногда просто «вход». Внутри него живёт половина поведения системы: терминация TLS, аутентификация, лимиты, маршрутизация, ретраи, сбор метрик, формат ошибок, канареечный трафик. Пока сервис один, прямоугольник почти пуст. Когда сервисов двадцать, а клиентов четыре — мобильное приложение, веб, партнёрское API и внутренние скрипты, — он становится самой политически сложной частью системы: каждая команда хочет положить туда «всего одно правило», а платформенная команда потом объясняет, почему релиз шлюза занимает неделю.
Предыдущие статьи трека разбирали, что происходит внутри сервисов: как устроены слои, как резать границы, как договариваться контрактами, как переживать отказы. Эта статья — про то, что происходит между ними и перед ними, и отвечает на вопрос, который в девяти проектах из десяти решается молча: где физически исполняется каждая сквозная забота и кто ею владеет.
Ответов ровно четыре: библиотека внутри процесса, отдельный процесс рядом с ним (sidecar), общий прокси на входе (шлюз), декларативная политика платформы (mesh). Дальше — как выбирать, чем платит каждый вариант и какие аварии рождаются на стыках. Понятия «дедлайн», «размыкатель», «переборка», «хвостовая латентность» используются без пояснений: они разобраны в «Устойчивости».
1. Сквозные заботы: список, который никто не пишет целиком
Сквозная забота (cross-cutting concern) — поведение, нужное каждому вызову независимо от его смысла. Её признак: если реализовать её в одном сервисе и забыть в другом, система не упадёт сразу — она сломается неравномерно, и это выяснится в аварии.
| Группа | Забота | Если её нет ни на одном слое |
|---|---|---|
| Транспорт | TLS, HTTP/2 и HTTP/3, сжатие | Трафик открыт; мобильные клиенты платят лишние RTT |
| Транспорт | Лимиты размера тела и заголовков | Один запрос на 2 ГБ выносит память процесса |
| Идентичность | Аутентификация вызывающего | Любой в сети — админ |
| Идентичность | Идентичность сервиса (mTLS) | Подделка внутреннего вызова равна взлому |
| Идентичность | Авторизация на уровне объекта | Пользователь читает чужой заказ по прямой ссылке |
| Поток | Маршрутизация и версии маршрутов | Релиз требует правки клиентов |
| Поток | Лимиты, квоты, приоритеты трафика | Один партнёр съедает ёмкость всех |
| Поток | Сплит трафика для канареек | Релиз — это «выкатили и молимся» |
| Устойчивость | Таймауты и распространение дедлайна | Пул занят мёртвыми вызовами |
| Устойчивость | Ретраи и их бюджет | Ретраи усиливают аварию вместо лечения |
| Устойчивость | Выброс больных инстансов | Балансировщик упорно шлёт в сломанный под |
| Наблюдаемость | Контекст трассировки, request-id | Инцидент разбирается по логам вручную по времени |
| Наблюдаемость | Метрики RED на каждом хопе | Непонятно, чей это 500-й код |
| Форма | Единый формат ошибок и пагинации | Клиент пишет обработчик на каждый сервис |
У каждой из четырнадцати есть один правильный вопрос: на каком слое она исполняется и почему не на соседнем. Мест ровно четыре, и у каждого своя цена.
- Библиотека в процессе. Ноль добавки к латентности, полный доступ к домену. Цена: реализаций столько, сколько языков; смена политики — релиз всего парка; «версия 2.3 у половины сервисов» — классический источник аварий.
- Sidecar — процесс рядом. Не зависит от языка, обновляется отдельно от кода. Цена: два сетевых хопа на вызов, CPU и память на каждый под, новые режимы отказа.
- Общий шлюз на входе. Один TLS, одна аутентификация, один формат ошибки для внешнего мира. Цена: общий ресурс, очередь на изменение конфигурации, соблазн положить туда бизнес-логику.
- Политика платформы (control plane). Правила декларативны и действуют на весь кластер. Цена: ещё одна распределённая система в эксплуатации и потеря локальной понятности — «почему 503, если сервис здоров?».
2. Анатомия пути запроса: от DNS до строки в базе
дедлайн, повтор, кэш"] CDN["CDN и edge
TLS, кэш, WAF, гео"] end subgraph edge["Кромка платформы"] LB["L4/L7 балансировщик
соединения, health checks"] GW["API-шлюз
authn, лимиты, маршрут,
request-id, формат ошибки"] end subgraph cluster["Доверенная зона: только mTLS"] BFF["BFF
форма ответа под клиента,
агрегация, деградация"] SC["sidecar out → sidecar in"] SVC["Сервис заказов
домен, авторизация на объект,
идемпотентность"] DB[("Хранилище")] end C --> CDN --> LB --> GW --> BFF --> SC --> SVC --> DB GW -. "401/403/429 отдаются здесь,
а не в глубине" .-> C BFF -. "частичный ответ вместо 500" .-> C
Из схемы следуют два правила, которые стоят дороже остальной статьи. Правило одного слоя. Каждая забота исполняется ровно на одном слое для каждого хопа. Если ретраит и клиент, и шлюз, и sidecar — это не «три уровня надёжности», а умножение нагрузки на 27 при первой деградации.
Правило вычитания бюджета. Внешний слой всегда отдаёт внутреннему меньше времени, чем есть у него самого. Обратное соотношение (шлюз ждёт 1 с, сервис ждёт базу 3 с) означает, что сервис две секунды делает работу, результат которой некому принять.
| Слой | Бюджет на входе | Резерв себе | Отдаёт вниз |
|---|---|---|---|
| Клиент | 3000 мс | 200 мс на отрисовку | 2800 мс |
| CDN и шлюз | 2800 мс | 100 мс (TLS, authn, сеть) | 2700 мс |
| BFF | 2700 мс | 200 мс на сборку ответа | 2500 мс на параллельный вызов |
| Сервис заказов | 2500 мс | 300 мс на свою логику | 2200 мс |
| Вызов сервиса цен | 2200 мс | — | 800 мс: некритичная зависимость урезана жёстко |
| Запрос в базу | — | — | 300 мс, дальше отказ |
Резерв на каждом слое — не бюрократия, а единственный способ вернуть пользователю осмысленный деградированный ответ вместо 504: когда бюджет кончился, у слоя ещё остаётся время собрать то, что успело приехать.
3. API-шлюз: три законные роли и одна незаконная
Шлюз решает три задачи, описанные в каталоге облачных паттернов и проверенные десятилетием практики. Gateway Routing — один публичный адрес поверх меняющейся топологии: клиент не знает, что /orders переехал из монолита в отдельный сервис (это же механизм strangler fig при распиле). Gateway Aggregation — один запрос клиента превращается в несколько внутренних, что экономит RTT на плохих сетях. Gateway Offloading — вынос общего: TLS, сжатие, проверка токена, лимиты, WAF, журнал доступа.
Незаконная роль ровно одна: бизнес-логика. Признаки, что она уже там: в конфигурации появился if по тарифу, стране или типу клиента; шлюз ходит в базу; изменение бизнес-правила требует релиза шлюза; в шлюзе склеиваются поля двух сервисов «потому что так удобнее фронту». Это ровно тот путь, которым SOA превратилась в ESB: центральный компонент с логикой всех команд, релизная очередь и невозможность протестировать что-либо локально.
Второй запрет мягче, но важнее: на шлюз нельзя вынести авторизацию на уровне объекта. Шлюз проверяет, что токен валиден и что вызывать GET /orders/{id} в принципе разрешено. Может ли этот пользователь читать этот заказ — знает только сервис заказов, потому что это вопрос данных, а не маршрута (классификация уровней проверки прав — в «Авторизации»). Правило: аутентификация — на кромке, грубая авторизация — на кромке, тонкая — только в сервисе.
Владение конфигурацией: главное решение
Шлюз становится узким местом не технически, а организационно: если все маршруты лежат в одном файле платформенной команды, вы получаете очередь. Kubernetes Gateway API (стабилен с конца 2023 года) построен вокруг разделения ролей: инфраструктурой владеет платформа, маршрутом — команда сервиса, в своём namespace и своём репозитории.
# Владеет платформа: точка входа, сертификат, кого пускаем в листенер.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: public-edge, namespace: platform }
spec:
gatewayClassName: envoy-gateway
listeners:
- { name: https, protocol: HTTPS, port: 443, tls: { certificateRefs: [{ name: wildcard-cert }] },
allowedRoutes: { namespaces: { from: Selector, # только помеченные namespace
selector: { matchLabels: { edge-exposed: "true" } } } } }
---
# Владеет команда заказов: свой файл, свой репозиторий, свой релизный цикл.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: orders, namespace: orders }
spec:
parentRefs: [{ name: public-edge, namespace: platform }]
hostnames: ["api.example.com"]
rules:
- matches: [{ path: { type: PathPrefix, value: /v1/orders } }]
timeouts:
request: 2700ms # меньше клиентских 2800 мс — правило вычитания
backendRequest: 2500ms # бюджет на одну попытку к бэкенду
backendRefs:
- { name: orders-api, port: 8080, weight: 90 }
- { name: orders-api-canary, port: 8080, weight: 10 } # канарейку описывает владелец
Лимиты, ретраи и политики безопасности вынесены в отдельные объекты-политики, привязываемые к маршруту, и зависят от реализации (Envoy Gateway, Istio, Kong, NGINX Gateway Fabric): ядро спецификации одинаково у всех, специфика — в политиках. Дополнительный хоп стоит 0,5–2 мс внутри зоны доступности и почти всегда приемлем. Неприемлемо другое: шлюз — общий ресурс со всеми патологиями общего ресурса. Партнёр, выгружающий отчёты, занимает соединения, которых потом не хватает мобильному приложению. Лечение то же, что везде, — переборки: отдельные экземпляры (минимум — отдельные пулы и лимиты) для публичного, партнёрского и внутреннего трафика.
4. Аутентификация на кромке, идентичность внутри
Самая частая ошибка проектирования кромки: шлюз проверил токен, положил в запрос X-User-Id: 42 и пустил дальше, а сервис этому заголовку верит. Работает ровно до первого пода с curl в том же namespace.
Рабочих моделей три, и выбирать надо явно. Непрозрачный токен снаружи с интроспекцией на кромке (phantom token): клиент получает случайную строку, шлюз меняет её на данные пользователя и передаёт вниз подписанный внутренний токен — структура не утекает наружу, отзыв мгновенный, цена в виде вызова на каждый запрос лечится кэшем на секунды (разбор паттерна). JWT насквозь: быстро, но требует короткого TTL, узкой аудитории и обязательной проверки подписи и aud в каждом сервисе (механика — в «JWT и токенах»). Обмен токена (RFC 8693): внешний токен меняется на внутренний с аудиторией одного сервиса и временем жизни в минуты — украденный бесполезен за пределами этой минуты.
Во всех трёх моделях действует одно правило: сервис доверяет заголовкам ровно настолько, насколько доверяет отправителю, а отправителя подтверждает mTLS с рабочей идентичностью (SPIFFE/SPIRE или встроенная идентичность mesh). Тогда в запросе живут две разные идентичности, и для аудита нужны обе: кто вызывает (сервис bff-mobile) и от чьего имени (пользователь 42).
Внутренняя проверка одинакова во всех сервисах — поэтому ей место в библиотеке платформы или в фильтре sidecar, а не в копипасте:
// X-User-Id из запроса игнорируется всегда: личность берётся из токена,
// а сам токен принимается только от вызывающего с известной рабочей идентичностью.
func RequireInternalIdentity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Кто вызывает: заголовок ставит sidecar после проверки сертификата, и подделать
// его из сети нельзя — порт приложения слушает только localhost.
caller, ok := parseSPIFFE(r.Header.Get("X-Forwarded-Client-Cert"))
if !ok || !allowedCallers[caller] {
problem(w, http.StatusForbidden, "caller-not-allowed", "неизвестный вызывающий сервис")
return
}
// От чьего имени: аудитория обязательна, токен для другого сервиса тут не работает.
claims, err := verifyJWT(r.Header.Get("Authorization"), withAudience("orders"))
if err != nil {
problem(w, http.StatusUnauthorized, "invalid-token", "токен невалиден или просрочен")
return
}
// Бюджет времени приезжает с запросом, а не задаётся константой в коде.
ctx := r.Context()
if budget, err := time.ParseDuration(r.Header.Get("X-Deadline-Ms") + "ms"); err == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, budget)
defer cancel()
}
ctx = context.WithValue(ctx, ctxCaller, caller) // аудит: какой сервис
ctx = context.WithValue(ctx, ctxSubject, claims.Subject) // авторизация: кто пользователь
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Формат ошибки — единый на всю систему, RFC 9457 (см. «Стили API»). Шлюз со своим JSON для 429 и сервис с problem+json для 409 — это два формата ошибок для клиента и гарантированный костыль в его коде.
5. BFF: один API не может быть удобен трём клиентам
Паттерн Backend for Frontend сформулировал Сэм Ньюман по опыту SoundCloud: вместо общего API — по бэкенду на каждый класс клиента. Причина в физике и в организации. Мобильный клиент платит 50–150 мс за каждый RTT и не может позволить себе пять последовательных запросов; веб — может. Приложение обновляется неделями, и старые версии живут годами; веб — за минуты. Экран заказа в приложении показывает шесть полей, веб-версия — тридцать. Общий API вынужденно превращается либо в «толстые» ответы, либо в набор параметров ?fields=...&expand=..., которые невозможно версионировать.
Правило владения — главное в паттерне. BFF принадлежит команде клиента: тот же репозиторий, тот же релизный цикл, та же дежурная смена. Как только BFF переезжает в «команду бэкенда», он перестаёт быть BFF и становится ещё одним общим API, только с лишним хопом.
| Можно в BFF | Нельзя в BFF |
|---|---|
| Агрегация нескольких вызовов | Правила предметной области |
| Проекция и переименование полей | Запись в чужие базы данных |
| Форматирование под клиента: даты, валюты, локали | Авторизация вместо сервиса-владельца |
| Кэш ответа и деградация | «Временная» бизнес-логика до переезда в сервис |
Дублирование между BFF — не дефект: три BFF, по-разному собирающих карточку заказа, — нормальное состояние. Общая библиотека клиентов к сервисам — да; общий «супер-BFF на всех» — нет, это возврат к тому самому общему API, от которого уходили.
import asyncio
from dataclasses import dataclass
@dataclass
class Budget:
"""Остаток бюджета приезжает от шлюза, а не выдумывается на месте."""
total_ms: int
reserve_ms: int = 200 # время на сборку ответа: отдать 200, а не 504
def per_call(self) -> float:
return max(0.0, (self.total_ms - self.reserve_ms) / 1000)
async def call(coro, timeout: float, fallback, name: str):
try:
return await asyncio.wait_for(coro, timeout=timeout), None
except (asyncio.TimeoutError, UpstreamError):
return fallback, name # деградация вместо отказа всего экрана
async def order_screen(order_id: str, budget: Budget) -> dict:
"""Экран заказа: три источника, разные права на бюджет и разная критичность."""
t = budget.per_call()
(order, e1), (eta, e2), (recs, e3) = await asyncio.gather(
call(orders.get(order_id), t, None, "orders"), # критичный: весь бюджет
call(shipping.eta(order_id), min(t, 0.4), None, "shipping"),
call(recos.similar(order_id), min(t, 0.15), [], "recos"), # некритичным — жёсткий лимит
)
if order is None: # без заказа экрана не существует
raise UpstreamUnavailable("orders")
return {
"order": project_for_mobile(order), # проекция: 6 полей из 30
"eta": eta, # None — клиент покажет «уточняется»
"recommendations": recs,
# Явное поле вместо тихой лжи: клиент знает, что часть данных не приехала,
# а метрика degraded_ratio показывает это дежурному.
"degraded": [e for e in (e1, e2, e3) if e],
}
Арифметика, о которой забывают при агрегации: параллельные вызовы складывают хвосты. Если у каждого из трёх источников p99 = 100 мс, вероятность, что уложились все три, равна 0,99³ ≈ 0,970 — то есть 3 % запросов экрана медленнее 100 мс даже когда все зависимости «в норме». Это эффект из «The Tail at Scale» (Dean, Barroso, CACM 2013), подробнее — в «Кэшировании и масштабировании». Отсюда правило: чем шире веер в BFF, тем жёстче индивидуальные таймауты и тем обязательнее деградация. И признак, что BFF деградировал в монолит, — в нём появились тесты на бизнес-правила. Это отличная fitness function: «доменных правил в BFF — ноль», проверяемая обзором тестов, а не намерением.
6. Sidecar и service mesh: сквозные заботы как инфраструктура
Sidecar — прокси в том же поде, через который идёт весь сетевой трафик приложения (паттерн Sidecar и его частный случай Ambassador). Service mesh — sidecar-и плюс control plane, раздающий им конфигурацию: data plane обрабатывает трафик, control plane трафика не касается.
Что это даёт приложению на любом языке бесплатно: mTLS и идентичность рабочих нагрузок вместе с ротацией сертификатов; таймауты, ретраи и выброс больных хостов декларативно; сплит трафика по весам и заголовкам как основу канареечных релизов (см. «Безопасные релизы»); единую телеметрию L7 для сервиса, который сам ничего не инструментировал; балансировку на уровне запросов — критично для gRPC и долгоживущих соединений.
# Istio: сплит, ретраи и таймаут описаны рядом с маршрутом, а не в коде сервиса.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: orders }
spec:
hosts: [orders]
http:
- match: [{ headers: { x-canary: { exact: "true" } } }] # тестировщики — принудительно
route: [{ destination: { host: orders, subset: canary } }]
- route:
- { destination: { host: orders, subset: stable }, weight: 95 }
- { destination: { host: orders, subset: canary }, weight: 5 }
timeout: 2.5s # согласовано с бюджетом, пришедшим от шлюза
retries:
attempts: 2 # ретраит ТОЛЬКО этот слой; клиент и шлюз — нет
perTryTimeout: 800ms # attempts × perTryTimeout <= timeout
retryOn: connect-failure,refused-stream,unavailable
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: orders }
spec:
host: orders
trafficPolicy:
connectionPool: { http: { http2MaxRequests: 200 } } # переборка на зависимость
outlierDetection: # выброс больных инстансов
{ consecutive5xxErrors: 5, baseEjectionTime: 30s, maxEjectionPercent: 30 }
subsets:
- { name: stable, labels: { version: v7 } }
- { name: canary, labels: { version: v8 } }
Честная цена
Про mesh принято говорить либо «магия», либо «страшно». Полезнее числа, и они у Istio опубликованы: порядка 0,5–1,5 мс добавки к p90 на пару прокси и доли ядра на 1000 запросов/с на каждый прокси, плюс десятки мегабайт памяти на под — тем больше, чем больше эндпоинтов прокси знает. Цифры меняются от версии к версии, важен порядок: на кластере из 500 подов налог измеряется десятками ядер и десятками гигабайт, а на цепочке из пяти сервисов вы добавляете десять прокси-хопов к каждому пользовательскому запросу.
Появляются и режимы отказа, которых не было. 503 без единой ошибки в приложении: прокси отвечает своими кодами, и в терминах Envoy это флаги UF (не установилось соединение), UO (переполнение переборки), UT (таймаут), DC (клиент отключился) — знание таблицы response flags экономит часы разбирательств. Порядок запуска и остановки: приложение стартует раньше прокси и падает на первом вызове, а job не завершается, потому что sidecar не выходит; для этого в Kubernetes появились штатные sidecar-контейнеры (init-контейнер с restartPolicy: Always, KEP-753) — альфа в 1.28, бета в 1.29, стабильно в 1.33. Рассинхрон конфигурации: половина прокси уже с новой политикой, половина со старой, и отладка требует смотреть в дамп конфигурации прокси, а не в код.
Налог на sidecar породил два ответа. Ambient mesh (стабилен с Istio 1.24) разделяет функции: L4-туннель с mTLS живёт одним агентом на узле, а L7-политики включаются отдельно и только там, где нужны, — плата за mTLS перестаёт зависеть от числа подов. Proxyless mesh — ход в другую сторону: gRPC-библиотека сама получает конфигурацию по протоколу xDS и балансирует без прокси; хопа нет, но зависимость от языка вернулась.
Практический порог для mesh — три условия, достаточно выполнения двух: больше 15–20 сервисов (политику в библиотеках уже не обновить синхронно); три и более языка (реализовывать ретраи и mTLS в каждом дороже, чем эксплуатировать mesh); требование mTLS и аудита на весь внутренний трафик, которое библиотеками не доказать. Если сервисов пять и язык один, mesh — дорогая покупка вместо десяти строк в библиотеке. Классическая ошибка — ставить mesh ради трассировки, которую OpenTelemetry даёт за неделю (см. «Наблюдаемость распределённых систем»).
7. Матрица размещения: куда класть каждую заботу
| Забота | Библиотека | Sidecar / mesh | Шлюз | Рекомендация |
|---|---|---|---|---|
| TLS снаружи | — | — | ✔ | Всегда на кромке |
| mTLS между сервисами | тяжело | ✔ | — | Mesh, если он есть; иначе библиотека + SPIRE |
| Аутентификация пользователя | — | частично | ✔ | Шлюз, единообразно для всех |
| Авторизация на объект | ✔ | — | — | Только сервис: нужен доступ к данным |
| Лимиты по клиенту и тарифу | — | — | ✔ | Шлюз: он видит клиента целиком |
| Лимит параллелизма на зависимость | ✔ | ✔ | — | Sidecar, если есть; иначе библиотека |
| Таймаут вызова | ✔ | ✔ | ✔ | На всех, но с вычитанием, а не одинаковые |
| Ретрай | ✔ | ✔ | ✔ | Ровно один слой, выбранный явно |
| Размыкатель | ✔ | ✔ | — | Sidecar для сетевых отказов, библиотека для семантических |
| Сплит канареечного трафика | — | ✔ | ✔ | Шлюз для внешнего, mesh для внутреннего |
| Трассировка и request-id | ✔ | ✔ | ✔ | Шлюз порождает, все пробрасывают |
| Агрегация ответов | — | — | BFF | BFF, если ответ зависит от клиента |
| Формат ошибок | ✔ | — | ✔ | Общий контракт, проверяемый тестами |
| Кэш ответов | ✔ | — | ✔ | CDN и шлюз для публичного, библиотека для внутреннего |
Верхний правый квадрант — самый опасный: заботам оттуда нужен и домен, и единообразие. Их нельзя «просто вынести на шлюз» и нельзя оставить каждой команде: рабочий ход — библиотека платформы плюс проверка в CI, а само решение записывается в ADR, потому что через год никто не вспомнит, почему лимиты по тарифу считает шлюз, а не сервис биллинга.
8. Что ломается на стыках
Тройной ретрай. Клиент повторяет 3 раза, шлюз 3 раза, sidecar 3 раза — один пользовательский запрос превращается в 27 внутренних ровно в момент деградации. Лечение: явное решение «ретраит слой X», выключенные ретраи на остальных, бюджет в процентах трафика и заголовок-счётчик попыток, чтобы усиление было видно в метриках.
Рассогласованные таймауты. Шлюз ждёт 2 с, сервис 5 с, база 30 с: пользователь получает 504 через две секунды, а система ещё двадцать восемь занимает соединение работой, результат которой выбросят. Проверяется таблицей бюджета из раздела 2 и тестом «сумма таймаутов вниз меньше таймаута сверху».
gRPC за L4-балансировщиком. Долгоживущее HTTP/2-соединение закрепляется за одним бэкендом: десять клиентов и три реплики дают распределение 10-0-0. Нужен L7-балансинг или клиентская балансировка с периодическим переустановлением соединений — детали в «Прокси и балансировке» и «RPC и gRPC».
Разные лимиты на слоях и потерянный клиентский IP. CDN режет тело на 10 МБ, шлюз на 1 МБ, сервис принимает 100 МБ — загрузка файла падает с 413 «из ниоткуда» и только через публичный домен; лимиты на тело, заголовки и длину URL согласуются явно и проверяются сквозным тестом. Рядом живёт вторая беда: лимит по IP на шлюзе считает адрес CDN и блокирует либо всех, либо никого — нужен разбор X-Forwarded-For / Forwarded (RFC 7239) с доверенным списком прокси и понимание, что от недоверенного клиента этот заголовок подделывается тривиально.
Обрыв контекста трассировки. Один слой не пробросил traceparent — и трасса рвётся ровно там, где начинается интересное. Кромка порождает traceparent, если его нет или клиенту не доверяют; x-request-id возвращается пользователю, чтобы поддержка нашла запрос в логах всех слоёв по одной строке.
Health check против readiness. Инстанс объявляет себя нездоровым из-за недоступной некритичной зависимости, балансировщик выводит его, нагрузка идёт на оставшиеся — каскад. Readiness не должна зависеть от некритичного, а у outlier detection обязан быть maxEjectionPercent.
Три формата ошибок. Шлюз отдаёт {"error": "rate limit"}, mesh — пустой 503, сервис — problem+json. Клиент пишет три обработчика и в итоге показывает пользователю «Что-то пошло не так». Единый формат — архитектурное решение уровня кромки, а не вопрос вкуса.
9. Наблюдаемость кромки
Кромка — единственное место, где виден пользовательский запрос целиком, поэтому её метрики становятся SLI. Требований три. Первое: разделять источник ошибки — метрика «доля 5xx», в которую сложены отказ апстрима, таймаут, переполнение переборки и обрыв со стороны клиента, бесполезна, и именно для этого прокси проставляет флаг ответа. Второе: считать по маршруту, а не по шлюзу целиком — деградация одного маршрута из сорока не видна в общем показателе, но отлично видна пользователю. Третье: алертить на бюджет, а не на всплеск.
groups:
- name: edge
rules:
# Доля неуспешных ответов кромки по маршрутам — это и есть пользовательский SLI.
- record: route:edge_error_ratio:rate5m
expr: |
sum by (route) (rate(gateway_requests_total{code=~"5.."}[5m]))
/ sum by (route) (rate(gateway_requests_total[5m]))
- { alert: EdgeRouteBurningErrorBudget, for: 10m, labels: { severity: page },
expr: route:edge_error_ratio:rate5m > 0.02 }
# Живём на деградации: формально «зелено», фактически данные не приезжают час.
- { alert: EdgeDegradedTooLong, for: 30m, labels: { severity: ticket },
expr: "rate(bff_degraded_total[15m]) / rate(bff_requests_total[15m]) > 0.1" }
Последнее правило — самое недооценённое. Деградация, которую никто не видит, со временем перестаёт быть деградацией и становится нормой: пользователи привыкают, что «время доставки иногда не показывается», а команда узнаёт о сломанном сервисе через полгода.
10. Маятник и лестница зрелости
История вопроса — это качание маятника: железный балансировщик (TLS и распределение в устройстве, остальное в приложении) → ESB, забравшая маршрутизацию вместе с логикой и ставшая узким местом → библиотеки Netflix (Ribbon, Hystrix, Zuul) ценой привязки к JVM → sidecar-прокси Envoy и Linkerd, которым язык приложения безразличен → service mesh с control plane → Gateway API и proxyless gRPC → ambient, где L4 живёт на узле, а L7 включается по требованию. Каждый виток меняет не столько технику, сколько владельца заботы: команду сервиса или платформу. Выбирая слой, вы прежде всего выбираете, кто дежурит, когда это сломается.
или нужен публичный API L1 --> L2: партнёры и разные классы трафика L2 --> L3: >15 сервисов, ≥3 языков
или mTLS по требованию регулятора L3 --> L4: налог на sidecar заметен
в счёте и в хвостовой латентности L3 --> L2: некому эксплуатировать
control plane — откат допустим L2 --> L1: сервисов стало меньше
после консолидации
Обратные переходы не декоративны: снять mesh — нормальное решение, если выяснилось, что его эксплуатирует один человек в свободное время. Сложность, которую некому обслуживать, снижает надёжность, а не повышает. Минимум для каждой ступени: L1 — маршруты в репозиториях команд, единый формат ошибок, request-id, метрики по маршрутам; L2 — mTLS на критичных путях, лимиты по клиентам, разделённые пулы по классам трафика; L3 — платформенная команда с реальной ёмкостью (ориентир — два человека, для которых это основная работа), стенд для проверки политик, план отката; L4 — измеренный налог на sidecar и понимание, какие политики L7 действительно нужны.
11. Типичные ошибки
- Шлюз как «место, где быстро правится». Любое правило, которое проще положить в шлюз, чем в сервис, — будущая бизнес-логика в инфраструктуре.
- Аутентификация только на кромке. Сервис, доверяющий заголовку без mTLS, беззащитен перед любым процессом в том же кластере.
- Ретраи включены везде по умолчанию. Особенно на неидемпотентных операциях: ретрай
POST /paymentsбез ключа идемпотентности — второе списание (см. «Saga и идемпотентность»). - Один общий BFF на все клиенты. Через полгода это обычный API с лишним хопом и владельцем, которому все звонят.
- Mesh ради трассировки. Дорогой способ решить задачу, для которой есть OpenTelemetry.
- Единый таймаут «30 секунд везде» и лимиты «на глаз». Первое гарантирует исчерпание пулов при первой деградации; второе либо не срабатывает никогда, либо режет здоровый трафик — лимит выводится из измеренной ёмкости.
- Кромка без своего стенда. Конфигурация прокси не должна впервые проверяться на проде — но именно так обычно и происходит.
- Забыли внутренний трафик. Публичная кромка защищена по всем правилам, а внутренние вызовы ходят по HTTP без идентичности, «потому что мы в приватной сети».
- Нет владельца. Кромка, которой никто не владеет, за три года превращается в набор накопленных исключений.
12. Как это выглядит в проде
Netflix прошёл весь маятник публично: библиотеки Ribbon и Hystrix, затем Zuul как шлюз, затем Zuul 2 на асинхронном стеке — переписывали не из моды, а из физики: блокирующая модель не держала нужное число долгоживущих соединений. Lyft написал Envoy как ответ на полиглотную среду: единый data plane, одинаковая наблюдаемость и устойчивость для сервисов на пяти языках; сегодня Envoy — движок внутри большинства шлюзов и mesh. Kubernetes-мир зафиксировал организационное решение прямо в API: Gateway API разделяет, чем владеет платформа, а чем — команда сервиса.
Небольшие команды правильно останавливаются на первой ступени: Traefik или nginx перед приложением, аутентификация в общей библиотеке, таймауты и ретраи в HTTP-клиенте. Это не незрелость, а корректный выбор: mesh для пяти сервисов на одном языке — чистый убыток, и никакой ADR его не оправдает.
Мини-итог и чек-лист
Кромка — не «нужный прямоугольник перед сервисами», а явное решение о том, где живёт каждая сквозная забота и кто за неё дежурит. Четыре места, четырнадцать забот, одно правило: одна забота — один слой на хоп, и все бюджеты вычитаются сверху вниз.
- Для каждой из четырнадцати забот записано, на каком слое она исполняется.
- Ни одна забота не реализована на двух слоях случайно; исключения записаны в ADR.
- Таблица бюджета времени существует, и таймауты слоёв ей соответствуют.
- Ретраит ровно один слой; у ретраев есть бюджет и джиттер.
- Аутентификация на кромке, авторизация на объект — в сервисе; внутренний трафик имеет проверяемую идентичность, заголовкам без неё не доверяют.
- Единый формат ошибок для шлюза, BFF и сервисов, проверяемый контрактным тестом.
-
traceparentпорождается на кромке и не теряется;x-request-idвозвращается клиенту. - Метрики считаются по маршрутам и разделяют источник ошибки; есть алерт на долгую деградацию.
- Лимиты на тело, заголовки и параллелизм согласованы между слоями и проверены сквозным тестом.
- У BFF владелец — команда клиента, и в нём нет доменных правил.
- Для mesh есть команда, стенд и план отката; иначе mesh нет.
Источники
- Sam Newman. Backends For Frontends; Building Microservices, 2nd ed.
- Azure Architecture Center: Gateway Routing, Gateway Aggregation, Gateway Offloading, Sidecar, Ambassador
- Kubernetes Gateway API — разделение ролей платформы и команд сервисов
- Istio: Performance and Scalability, Ambient mode
- Envoy: access log response flags, Outlier detection; Christian Posta, Rinor Maloku. Istio in Action
- RFC 8693: OAuth 2.0 Token Exchange, RFC 7239: Forwarded, RFC 9457: Problem Details
- W3C Trace Context, SPIFFE, Phantom Token Pattern
- Jeffrey Dean, Luiz André Barroso. The Tail at Scale, CACM 2013
- Open Sourcing Zuul 2, Netflix Tech Blog; Announcing Envoy, Lyft Engineering
Что дальше
Трек «Архитектурные паттерны» на этом сходится в одну точку: карта стилей, слои, монолит, микросервисы, события, CQRS, саги, API, масштабирование, устойчивость, serverless, архитектурные решения и system design — всё это встречается на кромке, где видно, кто чем владеет и кто дежурит.
Куда идти дальше, зависит от того, где у вас сейчас тоньше:
- Проектируете границы и модель предметной области — Domain-Driven Design: ограниченные контексты, агрегаты, контекстные карты.
- Нужен фундамент, на котором стоит всё описанное, — Распределённые системы: отказы, время и часы, консистентность, консенсус.
- Отвечаете за то, чтобы это работало ночью, — SRE: SLO, бюджет ошибок, дежурства, безопасные релизы.
- Строите платформу, на которой живут чужие сервисы, — Платформенная инженерия.
- Нужен уровень ниже — протоколы, прокси, TLS и балансировка: Компьютерные сети.
- Хотите закрепить принципы и приёмы — Принципы и Паттерны проектирования.
И общий маршрут по всем трекам портала — Дорожная карта.