Назад к блогу

Голого RPS недостаточно: сравниваем API Gateway в реальном сценарии

Голого RPS недостаточно: сравниваем API Gateway в реальном сценарии

Сравнивать API-шлюзы по скорости простого проксирования бессмысленно, если в реальном приложении каждый запрос проходит проверку токена, ролей и лимитов. В статье разбирается, как устроен api-gateway с авторизацией из коробки, и приводятся результаты бенчмарка на сценарии с JWT, где синтетические лидеры часто теряют позиции.

Пропускная способность на голом проксировании мало что говорит о шлюзе, если в продакшене на каждый запрос приходит токен, проверяются роли и считаются лимиты. Именно на этом участке расходятся шлюзы, которые на синтетическом тесте «роут → бэкенд» выглядят почти одинаково. Ниже — как устроен api-gateway, что он закрывает из коробки и что показал бенчмарк на пути с авторизацией.

Какую задачу решает api-gateway

api-gateway — открытый API-гейтвей с авторизацией из коробки: он понимает JWT, роли и права доступа без плагинов и без отдельного auth-сервиса. Он предназначен для замены связки «гейтвей + плагины + отдельный сервис авторизации», то есть для тех, кому нужен один входной компонент вместо нескольких. Код открыт и бесплатен для самостоятельного развёртывания.

Без готового шлюза вручную пришлось бы собирать JWT-аутентификацию, ролевой RBAC, кэшируемую интеграцию с permission-service и публикацию webhook-событий. Туда же попадают рейт-лимитинг, CORS, раздача статики и SPA с fallback на index.html, автоматический TLS через Let's Encrypt и service discovery по labels контейнеров Docker/Podman. Шлюз закрывает эти задачи из коробки, а также маршрутизацию по префиксу пути и Host (включая wildcard-домены), Basic Auth, проброс claims в заголовки, HMAC-подпись, вебхуки и NATS, метрики и трейсинг.

Как устроен путь запроса

Цепочка обработки строится вокруг базового обработчика проксирования, поверх которого оборачиваются middleware. Порядок оборачивания обратный порядку срабатывания, поэтому первым при запросе срабатывает recovery: он перехватывает панику, логирует её и отдаёт 500. Дальше идёт request ID — берёт X-Request-ID из заголовка или генерирует его, формирует X-Trace-ID и кладёт оба в контекст. Затем трейсинг: извлекает traceparent, стартует span, добавляет атрибуты метода, URL, хоста, user-agent и request_id, инжектит контекст в заголовки upstream и после ответа пишет статус. После него — метрики активных запросов, эндпоинт /metrics, глобальный рейт-лимит, статика SPA и CORS-preflight. Эндпоинт /metrics отдаётся только разрешённым IP, иначе 403. Глобальный лимит при исчерпании возвращает 429 с Retry-After: 1. CORS-preflight на OPTIONS выставляет CORS-заголовки и отвечает 200. При включённых опциях добавляются Basic Auth и инвалидация кеша permissions, а per-route рейт-лимит проверяется уже внутри обработчика проксирования.

Порядок middleware снаружи внутрь: инвалидация кеша permissions, Basic Auth, recovery, request id, tracing, метрики активных запросов, /metrics, глобальный лимит, статика, CORS-preflight, проксирование. Поэтому Basic Auth защищает и /metrics, и статику.

Выбор таргета и балансировка

Пул кандидатов одного маршрута — это все таргеты правил, совпадающих по (host, path_prefix, methods). Вес таргета берётся как эффективный (отрицательный приводится к 0) и накапливается в общей сумме; кандидаты с весом ≤ 0 в сумму не входят и пропускаются при выборе. Выбор идёт взвешенным round-robin: счётчик инкрементируется атомарно, берётся остаток от деления на сумму весов здоровых кандидатов, и по этой позиции выбирается таргет. Half-open пробник расходуется только выбранным таргетом: кандидаты проверяются read-only, а пробник забирает победитель, чтобы проигравший не «сжёг» его. Если пробник забрал параллельный запрос, кандидаты пересчитываются и попытка повторяется; при отсутствии здоровых кандидатов с положительным весом отдаётся 503.

Правила пула обязаны совпадать по auth, strip_path и rate_limit. weight не задан — это 1; weight: 0 или отрицательный исключает таргет.

Проверка JWT и роли

Шлюз сначала берёт токен из заголовка Authorization; если он пуст, читает cookie cml_access и превращает значение в Bearer-заголовок. Предъявленный токен всегда проверяется криптографически, а затем по claims; при ошибке на маршруте с требованием аутентификации возвращается ошибка, иначе запрос идёт как анонимный. Роли проверяются только если у правила заданы roles или roles_all: из claim roles (строкой или массивом строк) собирается набор ролей, и проверка идёт по схеме (любая из roles) И (все из roles_all). Если claim roles отсутствует или его тип не строка и не массив строк, маршрут с требованиями ролей отвечает 401.

Извлечённые claims раскладываются по заголовкам согласно настройке claim_mappings. При заданном заголовке подписи и наличии claim id вычисляется HMAC-SHA256 от user_id с ключом Permissions.APIKey. При включённом permission-сервисе по claim id в запрос подмешивается заголовок с эффективными разрешениями пользователя. В конце применяются дополнительные заголовки, при включённой опции удаления токена Authorization не пробрасывается дальше, а при отсутствии X-Forwarded-For он выставляется из адреса клиента.

Health check и circuit breaker

Health check периодически опрашивает URL таргета HTTP GET с настроенным таймаутом. При ошибке запроса таргет помечается нездоровым, при ответе — здоровым, если код в диапазоне 200–299. Если таргет стал здоровым при открытой цепи, она сразу переводится в half-open и выставляется пробник, чтобы пробный запрос мог закрыть цепь. circuit breaker считает ошибки транспорта: в closed растёт счётчик неудач, и при достижении порога цепь открывается; в half-open ошибка снова открывает цепь, а успех закрывает её и сбрасывает счётчик. При выборе таргета нездоровые и открытые отсеиваются, а пробник расходуется на победителе. Любой HTTP-ответ, включая 5xx, считается признаком исправного транспорта — цепь рвут только ошибки соединения. Если здоровых таргетов в пуле нет — 503.

Типовые сценарии и настройка

Рейт-лимитинг

За лимиты отвечают два параметра: rate_limit внутри правила rules[] (лимит на маршрут) и global_limit на уровне routing (лимит на весь процесс). У обоих одинаковые поля: requests_per_second (float, token bucket) и burst (int). Per-route лимит считается на IP клиента и отдаёт 429 без Retry-After; глобальный — на процесс и отдаёт Retry-After: 1. IP берётся из последнего значения X-Forwarded-For, иначе из адреса клиента. Лимитеры по IP очищаются после часа простоя.

JWT и RBAC на маршрут

JWT-аутентификация настраивается секцией jwt: токен берётся из Authorization: Bearer … или из cookie cml_access, ключ проверки задаётся через secret_key (симметричный HMAC) либо public_key_file (PEM для RSA/ECDSA/Ed25519), алгоритм — полем algorithm. Какие claims извлекаются в контекст, определяет claim_mappings (по умолчанию ["sub"]), а глобальное требование токена — required, которое перекрывается настройкой маршрута auth.required. RBAC на конкретный маршрут задаётся в routing.rules[].auth: roles требует любую из перечисленных ролей, roles_all — все перечисленные, при одновременном задании условия объединяются через И.

Service discovery и балансировка

Discovery включается полем discovery.enabled: true; провайдер задаётся provider (docker или podman, один клиент), сокет — host, префикс меток — label_prefix (по умолчанию gateway). Контейнер становится таргетом при маркере gateway.enable: "true" (принимаются true/1/yes, регистр не важен); имя берётся из gateway.name, иначе из service_name_labels, иначе из имени контейнера, порт — из gateway.port (обязателен) или единственного exposed TCP-порта, вес — из gateway.weight (по умолчанию 1, 0/отрицательный исключает таргет). Роутеры описываются метками gateway.<field> (роутер default) или gateway.router.<id>.<field> с полями host, path_prefix, methods, strip_path, auth.*, rate_limit.*; роутер без host и path_prefix пропускается. Обнаруженное дополняет статику, при конфликте имени таргета побеждает статика, а совпадающие по (host, path_prefix, methods) правила образуют общий пул.

Если портов у контейнера несколько и gateway.port не задан, контейнер пропускается с предупреждением. Health-проверки выполняются только при application.health_check: true.

Логи, метрики, трейсинг

Access-логи включаются булевым полем access_log (по умолчанию false, выключено из-за аллокаций на горячем пути); при включении пишется одна строка с request_id, trace_id, method, path, status, duration, remote_addr, user_agent и target. Уровень и формат логов задаются полями level (debug/info/warn/error/panic/fatal, по умолчанию info) и format (console/text/json, по умолчанию text; иное значение — ошибка запуска). Трейсинг OpenTelemetry включается только при заданной переменной окружения OTEL_EXPORTER_OTLP_ENDPOINT; без неё трейсинг выключен. При включении создаётся OTLP HTTP-экспортёр (без проверки сертификата при OTEL_INSECURE=true или OTEL_EXPORTER_OTLP_INSECURE=true), батчер и ресурс с именем сервиса, а пропагатор — композиция TraceContext и Baggage. Метрики управляются metrics_enabled (по умолчанию выключены) и metrics_allowed_ips: при непустом списке /metrics доступен только этим IP (точное совпадение или CIDR), иначе отдаётся 403. Счётчики отдаются в формате expvar.

Что показывает бенчмарк

Методология

Хост: Linux, 12 vCPU, 15 GiB RAM, Docker 28.3.2. Каждый шлюз запускался в отдельном контейнере с жёстким лимитом --cpuset-cpus=0-1 --memory=768m (2 ядра и лимит памяти), по одному за раз. Бэкенд — контейнер traefik/whoami, закреплён на ядрах 8–11; нагрузочный клиент wrk — на ядрах 2–7. Два теста: 50 соединений (wrk -t2 -c50 -d15s) и 300 соединений (wrk -t4 -c300 -d20s), перед каждым — прогрев 5 секунд, память снималась через docker stats раз в секунду. Версии образов: api-gateway собран из актуального кода репозитория, traefik:v3.1, nginx:1.27-alpine, envoyproxy/envoy:v1.31-latest с --concurrency 2, kong:3.7 в режиме DB-less с KONG_NGINX_WORKER_PROCESSES=2 и выключенным access-log. Все пять прокси настроены одинаково: один роут / → backend, без TLS и без дополнительного middleware сверх встроенных дефолтов. Цифры — лучший из 5 прогонов на одном и том же железе, машина освобождена от фоновой нагрузки, числа ориентировочные (±10% от прогона к прогону).

Результаты

В тесте на 300 соединений (proxy-only, без авторизации) api-gateway показал 24 068 req/s, Traefik — 24 811, Nginx — 27 216, Envoy — 25 272, Kong — 25 322; отставание api-gateway от Nginx составляет 11.6 %.

В тесте с авторизацией (валидный HS256-токен на каждый запрос) картина меняется: api-gateway дал 20 641 req/s при цене −15 %, Envoy — 20 290 (−20 %), Kong — 18 331 (−29 %), Nginx — 2 849 (−89 %), Traefik — 1 541 (−94 %). У Nginx и Traefik проверка вынесена во внешний auth-сервис, что и даёт такую цену.

Пик памяти api-gateway в тесте на 300 соединений — 121.5 MB, у Nginx — 15.8 MB, Envoy — 29.1 MB, Traefik — 174.8 MB, Kong — 314.1 MB. По этому показателю api-gateway легче Traefik и Kong, уступая Nginx и Envoy, у которых нет встроенной авторизации.

Встроенные функции api-gateway почти бесплатны: RBAC из claims — −0.5 % к JWT-режиму, rate limiting — −2.4 %, permission-сервис с TTL-кэшем — в пределах погрешности (без кэша — −93 %).

Оговорки

Измерялась пропускная способность и пиковая память на пути «один роут → backend» без TLS и без дополнительного middleware сверх встроенных дефолтов; бэкенд — один и тот же контейнер traefik/whoami, отвечающий ~200 байтами текста. Тест был proxy-only: JWT-валидация, роли, permissions-модуль, rate-limiting и т.д. в конфиге api-gateway выключены, чтобы сравнивать голый reverse-proxy путь. Service discovery не покрыт — это управляющий слой, который на горячем пути проксирования не работает. Backend отвечает мгновенно (~0 полезной нагрузки), поэтому это тест накладных расходов самого шлюза, а не реальной сети или бэкенда. Публикуются только RPS и пиковая память.

Ограничения

Access log выключен по умолчанию, потому что каждая запись — аллокации на горячем пути; включать его следует для отладки, а не в проде под нагрузкой. Тело запроса для аудита читается только для POST, PUT, PATCH, QUERY, ограничено max_request_body_size, и в событие попадает только JSON-объект короче 64 KiB.

Аварийный фолбэк discovery: последний удачный результат атомарно пишется в state_file и применяется при старте, поэтому маршруты переживают рестарт при недоступном Docker/Podman. Каталог state_file должен быть writable, пустая строка отключает персист. Ограничение: самый первый холодный старт без файла состояния и с недоступным Docker/Podman останется без маршрутов, пока провайдер не подключится; начиная со второго запуска фолбэк работает.

Приоритет статики: конфликт таргета — по имени, конфликт правила — по паре (host, path_prefix); обнаруженное правило, чей таргет был пропущен из-за коллизии имени, тоже отбрасывается. Несколько контейнеров одного сервиса дают один таргет — балансировку делает DNS Docker/Podman.

При деградации источников каждый запрос перечитывает их заново, и если один недоступен, страница всё равно отрисовывается с баннером ошибки, а остальные панели работают; дашборд стартует даже при невалидном конфиге шлюза. Секреты (jwt.secret_key, basic_auth.password, permissions.api_key, permissions.invalidate_token) никогда не отображаются, URL с userinfo редактируются, а учётные данные из URL не попадают в текст ошибок.

По фактам сравнения api-gateway — единственный из пяти шлюзов, где JWT-аутентификация, ролевой RBAC по claims, кэшируемая интеграция с permission-service и публикация webhook-событий встроены «из коробки», без плагинов и без внешнего auth-сервиса. Ответы permission-сервиса кешируются по user_id на cache_ttl, инвалидация — POST /_cache/permissions/invalidate с X-Invalidate-Token (опционально ?user_id=…).

Источники

Похожее