api-gateway · практика
Бэкенд обрастает инфраструктурными компонентами незаметно. Сначала перед сервисом стоит один прокси. Потом нужна проверка токенов — и рядом появляется oauth2-proxy. Дальше требуются роли и права — и вот уже отдельный permission-сервис, к которому каждый запрос ходит по сети. Просят аудит действий — появляется воркер, читающий логи и рассылающий события. Сверху ложатся лимиты, CORS, автоматический TLS, service discovery, метрики и трейсинг. Каждый пункт — контейнер со своим конфигом, деплоем, мониторингом и собственным режимом отказа. Сопровождать этот зоопарк со временем дороже, чем сам сервис.
api-gateway построен вокруг другого допущения: перечисленные обязанности — это не внешние сервисы, а разделы одного конфига. Проверка JWT, роли из claims, аудит, лимиты, TLS, discovery и наблюдаемость включаются в config.yaml и работают на одном горячем пути. Ниже — пятнадцать типовых задач в формате «задача → конфиг → что получаете». Это практическое дополнение к документации: полные описания полей — в справочнике конфигурации, а те же рецепты с навигацией — на странице сценариев.
Кто пришёл: аутентификация и роли
Первое, что снимают с внешних компонентов, — вход. Вместо прокси и отдельного auth-сервиса токен проверяется прямо в шлюзе. Дело не только в меньшем числе контейнеров: проверка на месте убирает сетевой хоп с каждого запроса. Насколько это дешевле внешней авторизации, показывают замеры:
1. Публичный сервис и JWT на выбранных маршрутах
Задача. Логин и регистрация должны быть открыты, а остальной API — закрыт токеном.
jwt:
secret_key: "${JWT_SECRET}"
algorithm: "HS256"
validate_exp: true
targets:
- name: "auth-api"
url: "http://127.0.0.1:9001"
- name: "client-api"
url: "http://127.0.0.1:9002"
routing:
rules:
- path_prefix: "/api/v1/auth/login"
target_name: "auth-api"
methods: ["POST"]
auth:
required: false
- path_prefix: "/api/v1/auth"
target_name: "auth-api"
auth:
required: true
- path_prefix: "/api/v1/client"
target_name: "client-api"
auth:
required: trueЧто получаете. /api/v1/auth/login доступен анонимно, остальные маршруты ждут валидный токен в Authorization: Bearer или в cookie cml_access. Невалидный токен, пришедший на публичный маршрут, запрос не блокирует.
Оговорки. Правило выбирается по самому длинному совпавшему префиксу, поэтому специфичный /login выносится отдельным правилом выше общего /api/v1/auth. Предъявленный токен проверяется всегда, даже если маршрут не требует auth: публичность не отключает валидацию.
2. Роли из claims токена
Задача. Пустить в админский API только носителей роли admin.
jwt:
secret_key: "${JWT_SECRET}"
claim_mappings: ["id", "roles"]
routing:
rules:
- path_prefix: "/api/v1/admin"
target_name: "admin-api"
auth:
required: true
roles: ["admin"]Что получаете. Запрос без роли admin в claim roles получает 401. Claim roles может быть как массивом, так и строкой.
Оговорки. Требуются все перечисленные роли, а не любая из них. Если claim roles отсутствует или имеет неверный тип — тоже 401.
Кто что сделал: аудит
Аудит — вторая обязанность, которую обычно выносят наружу: события собирает отдельный воркер, который читает логи шлюза. В api-gateway источник событий и их доставка встроены, поэтому аудит описывается там же, где маршруты, и может быть разным для разных путей.
3. Аудит изменений: вебхук только на мутирующие методы
Задача. На каждый POST, PUT, PATCH и DELETE отправлять событие во внешний аудит.
webhooks:
- name: "audit-mutations"
transport: "webhook"
webhook_url: "https://audit.example/events"
trigger: "on_response"
methods: ["POST", "PUT", "PATCH", "DELETE"]Что получаете. На каждый ответ мутирующего запроса уходит POST с JSON-событием: method, path, user_id, status_code, changes.
Оговорки. changes заполняется телом запроса, только если это JSON-объект короче 64 KiB, а само чтение тела ограничено server.max_request_body_size. Для объёмного потока событий включайте батчинг — как в следующем сценарии.
4. Флагман: аудит админки — и чтение, и запись
Задача. Собрать полный аудит админских маршрутов: видеть не только изменения, но и просмотры. Кто что смотрел и кто что изменил.
targets:
- name: "admin-api"
url: "http://127.0.0.1:9003"
routing:
rules:
- path_prefix: "/api/v1/admin"
target_name: "admin-api"
auth:
required: true
roles: ["admin"]
webhooks:
- name: "admin-audit"
transport: "webhook"
webhook_url: "https://audit.example/admin"
trigger: "on_response"
include_request_body: true
include_response_body: true
batch_size: 1000
flush_interval: 100msЧто получаете. Любой запрос к /api/v1/admin, включая GET, рождает событие с телом запроса и телом JSON-ответа. События копятся пачками и уходят одним POST {"count":N,"events":[...]}.
Оговорки. Тело ответа собирается только для JSON и обрезается на 64 KiB. Батчинг включает backpressure: при переполнении очереди (8192 события) постановка блокируется — потерь нет, но запросы замедляются. Тело запроса попадает в changes, только если это JSON.
Периметр: права, лимиты и доставка
Дальше — слой, который обычно собирают из трёх-четырёх отдельных сервисов: проверка прав, ограничение скорости, CORS, базовая защита служебных путей, раздача статики и сертификаты. В шлюзе это независимые секции конфига.
5. Внешний permission-сервис с кешем
Задача. Обогащать запрос эффективными правами пользователя, не дёргая сервис прав на каждый вызов.
jwt:
secret_key: "${JWT_SECRET}"
claim_mappings: ["id"]
permissions:
enabled: true
service_url: "http://permissions:8080"
cache_ttl: 300s
header_name: "X-User-Permissions"
api_key: "${PERMISSIONS_KEY}"
invalidate_token: "${INVALIDATE_TOKEN}"Что получаете. Бэкенд получает заголовок X-User-Permissions со списком разрешений через запятую. Ответы кешируются по user_id на cache_ttl. Сбросить кеш можно через POST /_cache/permissions/invalidate с заголовком X-Invalidate-Token и опциональным ?user_id=….
Оговорки. Модулю нужен claim id, приводимый к целому. Если разрешений нет, заголовок не выставляется. Эндпоинт инвалидации закрыт Basic Auth, когда он включён, — добавьте путь в skip_paths либо шлите токен.
6. Рейт-лимитинг: на маршрут и глобальный
Задача. Зажать логин жёстче остального API и задать общий потолок на процесс.
routing:
global_limit:
requests_per_second: 1000
burst: 2000
rules:
- path_prefix: "/api/v1/auth/login"
target_name: "auth-api"
methods: ["POST"]
rate_limit:
requests_per_second: 5
burst: 10
- path_prefix: "/api/v1/client"
target_name: "client-api"
rate_limit:
requests_per_second: 100
burst: 200Что получаете. Логин ограничен 5 rps на IP, клиентский API — 100 rps на IP, весь процесс — 1000 rps.
Оговорки. Per-route лимит считается на IP клиента и отвечает 429 без Retry-After; глобальный считается на процесс и отдаёт Retry-After: 1. IP берётся из последнего значения X-Forwarded-For, иначе из RemoteAddr.
7. CORS для SPA
Задача. Разрешить браузерному приложению на конкретном домене обращаться к API с credentials.
headers:
cors:
enabled: true
allowed_origins: ["https://app.example"]
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
allowed_headers: ["Content-Type", "Authorization", "X-Request-ID"]
expose_headers: ["X-Request-ID"]
max_age: 86400Что получаете. Запросы с origin https://app.example получают Access-Control-Allow-Origin с этим origin и Allow-Credentials: true; preflight OPTIONS отвечает 200.
Оговорки. При allowed_origins: ["*"] возвращается * без credentials — браузер не отправит куки. Точный origin в allowlist обязателен, если нужны credentials. Любой OPTIONS трактуется как preflight.
8. Basic Auth для внутренних путей
Задача. Закрыть служебные эндпоинты логином и паролем.
basic_auth:
enabled: true
username: "internal"
password: "${BASIC_PASS}"
skip_paths: ["/health"]Что получаете. Все запросы требуют Basic Auth, кроме /health и его подпутей. Сравнение — constant-time.
Оговорки. Basic Auth стоит во внешнем слое и закрывает заодно /metrics, статику и эндпоинт инвалидации кеша. Пароль хранится в конфиге открытым текстом — подставляйте его из окружения.
9. Статика и SPA-fallback
Задача. Отдавать собранный SPA с fallback на index.html, а /api проксировать.
static:
skip_prefixes: ["/api"]
apps:
- path_prefix: "/"
root_dir: "/app/static"
index_file: "index.html"
max_age: 3600
targets:
- name: "api"
url: "http://127.0.0.1:9001"
path_prefix: "/api"Что получаете. Существующие файлы отдаются напрямую, несуществующие пути — index.html; /api/* уходит в прокси.
Оговорки. Статика обрабатывается раньше проксирования. Если маршрут совпал с правилом, у которого задан Host, статика пропускается — так host-based роутинг не перекрывается файлами. Поддерживаются и плоские .html (about.html для /about).
10. Автоматический TLS через ACME
Задача. Получить сертификаты Let's Encrypt и редиректить HTTP на HTTPS.
tls:
enabled: true
port: 443
http_port: 80
domains:
- "api.example.com"
- "*.example.com"
email: "admin@example.com"
cache_dir: "/var/lib/api-gateway/certs"
staging: false
redirect_http: trueЧто получаете. HTTPS-сервер на 443, HTTP на 80 обслуживает ACME-challenge и редиректит на HTTPS. /health и /ready по HTTP отвечают 200.
Оговорки. Сначала прогоняйте на staging: true — это настоящий staging CA с отдельным кешем. directory_url перекрывает выбор CA по staging. При включении TLS домен и email обязательны.
Рантайм: маршруты, здоровье и наблюдаемость
Последний пласт — операционный. Пока сервисов мало, маршруты можно прописывать руками, но в проде они появляются и исчезают на каждом деплое. Ручной список таргетов быстро становится источником 502. Здесь шлюз берёт на себя обнаружение, распределение трафика и телеметрию.
11. Service discovery по labels
Задача. Поднимать и убирать бэкенды без правки конфига шлюза.
discovery:
enabled: true
provider: docker
host: "unix:///var/run/docker.sock"
label_prefix: "gateway"
network: ""
debounce: 500ms
resync_interval: 5m
state_file: "/var/lib/api-gateway/discovery-state.json"services:
blog:
image: ghcr.io/sarnas-it/blog:latest
labels:
gateway.enable: "true"
gateway.port: "8085"
gateway.health: "/health"
gateway.router.public.path_prefix: "/api/blog"
gateway.router.public.auth.required: "false"
gateway.router.admin.path_prefix: "/api/admin/blog"
gateway.router.admin.auth.required: "true"
gateway.router.admin.auth.roles: "admin"Что получаете. Шлюз сам строит таргеты и правила из labels. Найденное дополняет статику; при конфликте имён побеждает статика.
Оговорки. Socket монтируется read-only, клиент делает только GET. Маркер включения — gateway.enable: "true" (true/1/yes). Чтобы пережить рестарт при недоступном Docker, каталог state_file должен быть writable. Секреты в labels не кладите — они видны через docker inspect.
Насколько быстро шлюз реагирует на изменения по сравнению с Traefik, у которого discovery — ключевая фича:
api-gateway переключается симметрично: новый контейнер становится доступен примерно за 0.72 с, пропавший убирается за 0.65 с. Важно не только то, как быстро сервис появляется, но и как быстро шлюз перестаёт слать трафик на мёртвый: асимметрия означает окно, когда запросы уходят на контейнер, которого уже нет.
12. Взвешенная балансировка
Задача. Разделить трафик между канареечной и стабильной версиями сервиса в пропорции 1:4.
targets:
- name: "api-stable"
url: "http://127.0.0.1:9001"
weight: 4
- name: "api-canary"
url: "http://127.0.0.1:9002"
weight: 1
routing:
rules:
- path_prefix: "/api"
target_name: "api-stable"
- path_prefix: "/api"
target_name: "api-canary"Что получаете. Правила, совпавшие по (host, path_prefix, methods), образуют пул; запросы идут взвешенным round-robin по здоровым таргетам в пропорции 4:1.
Оговорки. Все правила пула обязаны совпадать по auth, strip_path и rate_limit. Без weight вес равен 1; weight: 0 или отрицательный исключает таргет. Нездоровые таргеты пропускаются; если здоровых нет — 503.
13. Проброс claims в заголовки
Задача. Передать бэкенду идентификатор, email и роли пользователя отдельными заголовками.
jwt:
secret_key: "${JWT_SECRET}"
claim_mappings: ["id", "email", "roles"]
headers:
strip_authorization: true
claim_to_header:
id: "X-User-ID"
email: "X-User-Email"
roles: "X-User-Roles"
add_headers:
X-Gateway-Version: "1.0.0"Что получаете. Бэкенд получает X-User-ID, X-User-Email, X-User-Roles из токена и X-Gateway-Version; Authorization удаляется.
Оговорки. Маппинг применяется только к извлечённым claim_mappings. Если список пуст, по умолчанию извлекается sub и маппится в X-User-ID. HMAC-подпись (headers.sign_header) требует permissions.api_key и claim id.
14. Health checks и circuit breaker
Задача. Не отправлять трафик на упавший бэкенд и разрывать цепь на ошибках транспорта.
application:
health_check: true
circuit_breaker: true
targets:
- name: "api-a"
url: "http://127.0.0.1:9001"
health_check: "http://127.0.0.1:9001/health"
weight: 1
- name: "api-b"
url: "http://127.0.0.1:9002"
health_check: "http://127.0.0.1:9002/health"
weight: 1
routing:
rules:
- path_prefix: "/api"
target_name: "api-a"
- path_prefix: "/api"
target_name: "api-b"Что получаете. Health-пробы идут раз в 30 секунд с таймаутом 5 секунд. Нездоровые таргеты исключаются из пула. Circuit breaker открывается после 3 ошибок транспорта и через 30 секунд пропускает один пробный запрос.
Оговорки. Любой HTTP-ответ, включая 5xx, считается признаком исправного транспорта — цепь рвут только ошибки соединения. health_check у таргета работает, только если application.health_check: true. Если здоровых таргетов в пуле нет — 503.
15. Метрики и трейсинг
Задача. Снимать метрики шлюза и отправлять трейсы в OTLP-коллектор.
application:
metrics_enabled: true
metrics_allowed_ips: ["10.0.0.5"]OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_EXPORTER_OTLP_INSECURE=true \
./api-gateway -config /etc/proxy/config.yamlЧто получаете. /metrics отдаёт счётчики в формате expvar (gateway_requests_total, gateway_request_duration_ms, gateway_rate_limit_denials_total, gateway_target_up, gateway_active_requests). Трейсы уходят по OTLP HTTP, заголовок traceparent пропагируется в бэкенд.
Оговорки. Метрики по умолчанию выключены — их сбор стоит работы на каждый запрос. При заданном metrics_allowed_ips /metrics доступен только этим IP. Без OTEL_EXPORTER_OTLP_ENDPOINT трейсинг выключен. pprof включается отдельно переменной PPROF_ADDR.
Что в итоге
Пятнадцать задач выше — это один и тот же приём: обязанность, которая обычно живёт в отдельном сервисе, переезжает в раздел конфига. Вход и роли, аудит чтений и записей, права с кешем, лимиты, CORS, Basic Auth, статика, сертификаты, discovery, балансировка, проброс claims, health checks и телеметрия — всё это включается по месту и не требует новых контейнеров на горячем пути. Вместо связки «прокси + auth-сервис + permission-сервис + воркер аудита + контроллер discovery» остаётся один компонент с одним конфигом.
Практический вывод простой. Если задача — только терминировать TLS и раздавать статику, хватит лёгкого прокси. Как только шлюзу нужно понимать, кто пришёл и что ему можно, и фиксировать, что он сделал, выигрывает тот, у кого эти обязанности встроены: их цена предсказуема, а конфигурация остаётся в одном файле. Начните с быстрого старта, сверяйте поля по справочнику конфигурации и забирайте готовые рецепты со страницы сценариев.
Рецепты адаптированы из документации api-gateway · примеры рассчитаны на config.local.yaml · продукт