Пропускная способность на голом проксировании мало что говорит о шлюзе, если в продакшене на каждый запрос приходит токен, проверяются роли и считаются лимиты. Именно на этом участке расходятся шлюзы, которые на синтетическом тесте «роут → бэкенд» выглядят почти одинаково. Ниже — как устроен api-gateway, что он закрывает из коробки и что показал бенчмарк на пути с авторизацией.
Какую задачу решает api-gateway
api-gateway — открытый API-гейтвей с авторизацией из коробки: он понимает JWT, роли и права доступа без плагинов и без отдельного auth-сервиса. Он предназначен для замены связки «гейтвей + плагины + отдельный сервис авторизации», то есть для тех, кому нужен один входной компонент вместо нескольких. Код открыт и бесплатен для самостоятельного развёртывания.
Без готового шлюза вручную пришлось бы собирать JWT-аутентификацию, ролевой RBACразграничение доступа по ролям, здесь — по claims токена, кэшируемую интеграцию с 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 вместо падения процесса: он перехватывает панику, логирует её и отдаёт 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=…).