Архитектурные паттерны Кромка системы: API-шлюз, BFF, sidecar и service mesh
0%

Кромка системы: API-шлюз, BFF, sidecar и service mesh

Кромка системы: 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 до строки в базе

Слои кромки: что решается на каждом уровне и где проходит граница доверия

Из схемы следуют два правила, которые стоят дороже остальной статьи. Правило одного слоя. Каждая забота исполняется ровно на одном слое для каждого хопа. Если ретраит и клиент, и шлюз, и 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 включается по требованию. Каждый виток меняет не столько технику, сколько владельца заботы: команду сервиса или платформу. Выбирая слой, вы прежде всего выбираете, кто дежурит, когда это сломается.

Обратные переходы не декоративны: снять 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 нет.

Источники


Что дальше

Трек «Архитектурные паттерны» на этом сходится в одну точку: карта стилей, слои, монолит, микросервисы, события, CQRS, саги, API, масштабирование, устойчивость, serverless, архитектурные решения и system design — всё это встречается на кромке, где видно, кто чем владеет и кто дежурит.

Куда идти дальше, зависит от того, где у вас сейчас тоньше:

  • Проектируете границы и модель предметной области — Domain-Driven Design: ограниченные контексты, агрегаты, контекстные карты.
  • Нужен фундамент, на котором стоит всё описанное, — Распределённые системы: отказы, время и часы, консистентность, консенсус.
  • Отвечаете за то, чтобы это работало ночью, — SRE: SLO, бюджет ошибок, дежурства, безопасные релизы.
  • Строите платформу, на которой живут чужие сервисы, — Платформенная инженерия.
  • Нужен уровень ниже — протоколы, прокси, TLS и балансировка: Компьютерные сети.
  • Хотите закрепить принципы и приёмы — Принципы и Паттерны проектирования.

И общий маршрут по всем трекам портала — Дорожная карта.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов