PRODСуверенная европейская платформа BaaSОткрыть панель управления →

Инженерное дело · 10 минута чтения

Проектирование двухплоскостного API-шлюза в Rust

Affane Daylami · Fondateur · 6 августа 2026 г.

Вернуться в блог

Шлюз, который обслуживает как трафик SDK ваших пользователей, так и трафик администратора вашего бэк-офиса, плохо защищает их одновременно. Шлюз Aurabase (aura-gateway, Rust/axum) разрешает это противоречие в восходящем направлении: два отдельных маршрутизатора, два порта, две модели аутентификации, одно общее состояние. В этом руководстве этот шаблон плоскости данных/плоскости управления описывается в том виде, в котором он фактически существует в коде (маршруты, порядок промежуточного программного обеспечения, ограничение скорости, автоматический выключатель и прокси), а не в идеализированной версии архитектурной диаграммы.

Этот текст на английском языке был создан автоматически на основе французского оригинала и еще не проверялся.
Эта страница была переведена автоматически. Английская версия является авторитетной.

Самое необходимое

Две оси Router на двух портах (плоскость данных8080, плоскость управления 8090), построенные на основе одного и того же общего AppState. Плоскость данных требует ключа API на любом маршруте; Для управления плоскостями требуется выделенная консоль аудитории JWT — эти два механизма никогда не пересекаются. Ограничение скорости применяется дважды: по IP-адресу до аутентификации, а затем по аутентифицированному субъекту после. Автоматический выключатель не является глобальным промежуточным программным обеспечением: это объект для каждой службы (и для каждой выделенной цели для PostgREST), вызываемый непосредственно в коде прокси. И последний прокси меняет транспорт в зависимости от маршрута — иногда в соответствии с методом HTTP или поиском в базе данных: запрос/ответ NATS для большей части трафика, прямой поток HTTP для хранения, три варианта реального времени и выделенный Postgres CRUD.

#
Проблема

Один шлюз, две совершенно разные аудитории

Трафик плоскости данных поступает из SDK или клиентского приложения: объем анонимных запросов или запросов, аутентифицированных ключом API, с профилем злоупотреблений, близким к любому общедоступному API. Плоскость управления трафиком исходит из Studio — интерфейса администрирования проекта — и выполняет конфиденциальные операции: создание проекта, ротацию ключей, чтение журналов арендатора. У них общая цель (проксирование к одним и тем же внутренним сервисам: aura-auth, aura-db, aura-storage и т. д.), но не одинаковая поверхность риска.

Прохождение обоих через один и тот же маршрутизатор требует выбора между двумя плохими вариантами: либо Studio CORS наследует подстановочный знак, необходимый для общедоступного SDK (Access-Control-Allow-Origin: *), либо SDK наследует ограниченный список источников, предназначенный для внутренней информационной панели. Код шлюза aura устраняет это противоречие с main.rs: два отдельных Router, каждый со своим собственным CorsLayer — подстановочным знаком, разрешенным на стороне плоскости данных, отклоненным и зарегистрированным как ошибка на стороне плоскости управления.

#
Шаг 1

Два маршрутизатора axum, один общий AppState

Разделение не представляет собой отдельное развертывание: два плана выполняются в одном процессе, на одном и том же AppState (пулы Postgres, клиент NATS, кэши Moka, автоматические выключатели). Отличается только конструкция Router: две специальные функции, каждая из которых вызывается один раз при запуске и обслуживается двумя отдельными TcpListener.

gateway/server.rsrust
// Два порта, два маршрутизатора, одно состояние приложения

let data_addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let mgmt_addr: SocketAddr = format!("{}:{}", config.host, config.management_port).parse()?;

let data_listener = TcpListener::bind(data_addr).await?;
let mgmt_listener = TcpListener::bind(mgmt_addr).await?;

// Одно и то же состояние, два разных графа маршрутов
let data_app = build_data_plane_router(state.clone(), data_cors);
let mgmt_app = build_management_plane_router(state);

tokio::join!(
    axum::serve(data_listener, data_app),
    axum::serve(mgmt_listener, mgmt_app),
);

Два графа маршрутов начинаются с одной и той же базы service_routes(): одни и те же обработчики прокси (db_proxy, storage_proxy, functions_proxy…) монтируются в обоих планах, с дополнительными маршрутами, специфичными для каждого. Повторное использование одних и тех же обработчиков позволяет избежать двойной реализации прокси; расхождение только в промежуточном программном обеспечении позволяет избежать дублирования бизнес-логики для достижения границ безопасности. Если ваша серверная часть сама по себе структурирована как мультисервисное рабочее пространство Cargo, см. наше Руководство по архитектуре рабочего пространства Cargo — шлюз — это всего лишь один ящик среди других в этом подразделении.

#
Шаг 2

Аутентификация расходится при входе

На уровне данных ключ API является обязательным на любом маршруте, за исключением нескольких действительно общедоступных путей (/health, JWKS, конечных точек регистрации). Он передается как заголовок apikey или X-API-Key или, только для маршрутов потоковой передачи WebSocket и SSE, как параметр ?apikey=. Код явно запрещает этот последний режим для ключа service_role: ключ URL-адреса просачивается в журналы доступа, трассировки OTel и заголовок Referer. JWT остается необязательным на стороне плоскости данных: без него вызывающая сторона остается anon; при этом он становится authenticated.

В плоскости управления API-ключа не существует: принимается только JWT-консоль, аудитория которой должна быть именно aurabase-control. Роль не несет в себе сам токен — она пересчитывается при каждом запросе членства пользователя в организации-владельце проекта, наследуемой через связь проект → организация.

Требуется токенКлюч API (apikey/X-API-Key), всегдаКонсоль JWT (авторизация: носитель), всегда
Повышение ролиНеобязательный JWT: аноним → аутентифицированныйRBAC, унаследованный от организации (владелец/администратор/разработчик/просмотрщик)
Введите строку запросаДопускается только для WS/SSE, никогда для service_role.Непригодный
Ожидаемая аудиторияЦелевой проект (UUID пути)исправлен «аурабаза-контроль»
КОРСПодстановочный знак * разрешеноПодстановочный знак отклонен, только источники Studio
#
Шаг 3

Реальный порядок промежуточного программного обеспечения (и почему это важно)

axum объединяет промежуточное программное обеспечение с последовательными вызовами .layer() — и правило, управляющее порядком выполнения, на практике удивительно: ПОСЛЕДНИЙ размещенный .layer() становится самым НАРУЖНЫМ слоем, поэтому первый, через который проходит входящий запрос, и последний, увидевший ответ, уходит. Таким образом, линейное чтение файла дает порядок, обратный фактическому порядку выполнения.

gateway/router.rsrust
// Написано следующим образом (фактический отрывок, порядок файлов):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) размещен 1-м → самым внутренним
    .layer(rate_limit_actor_middleware)  // (2)
    .layer(data_plane_auth_middleware)   // (3)
    .layer(rate_limit_middleware)        // (4)
    .layer(request_id_middleware)        // (5)
    .layer(AccessLogLayer)               // (6)
    .layer(prometheus_layer)             // (7)
    .layer(TraceLayer)                   // (8)
    .layer(RequestBodyLimitLayer)        // (9)
    .layer(security_headers_middleware)  // (10)
    .layer(cors)                         // (11) размещен последним → крайним

// Таким образом, входящий запрос проходит через (11) → (1), а не через (1) → (11).
Конкретный эффект этого приказа

Промежуточное программное обеспечение request_id устанавливает заголовок X-Request-Id только для ОТВЕТА, но не для входящего запроса. Поскольку AccessLogLayer размещается после него в файле (то есть более внешний и, следовательно, проходится раньше), при захвате поля request_id считывается заголовок, отправленный клиентом, а не идентификатор, сгенерированный дальше в цепочке. Если вызывающая сторона не предоставила X-Request-Id, строка журнала доступа оставляет пустое поле, а возвращаемый ответ содержит только что сгенерированный UUID. Это не скрытый дефект — напоминание о том, что порядок записи строки .layer() ничего не гарантирует относительно логического порядка, который мы ей приписываем.

#
Шаг 4

Ограничение скорости: сначала IP, затем актер

Ограничение скорости применяется за два отдельных прохода в два разных момента цепочки. Первый запускается ДО аутентификации и ограничения по IP-адресу — общий фильтр защиты от флуда, активный даже на дорогах общего пользования: без него неаутентифицированный поток может забить дорогостоящую конечную точку, например агрегацию журналов, даже не запуская проверку JWT. Второй запускается ПОСЛЕ аутентификации и ограничивает субъекта — ключ API или пользователя — с использованием утверждений, которые только что были введены при аутентификации: это реальная квота продукта, которая учитывается при выставлении счетов и планировании.

Реализация использует крейт governor (корзину токенов) для локальных вычислений, сценарий Lua Redis со скользящим окном для распределения между экземплярами шлюза и локальный резервный вариант (кеш Moka), если Redis недоступен. Настройки репозитория по умолчанию: 100 запросов в секунду, пакет 1000.

#
Шаг 5

Автоматический выключатель — это не слой, это объект для каждой цели

В отличие от остальной части цепочки, автоматический выключатель не появляется ни в одном .layer(). AppState содержит один экземпляр CircuitBreaker для каждой службы (аутентификация, база данных, реальное время, хранилище, функции, уведомления, искусственный интеллект, обеспечение, управление), и сам прокси-код, а не маршрутизатор, вызывает try_acquire_probe() перед попыткой запроса, а затем record_success() или record_failure() в зависимости от результата.

Случай с PostgREST отличается: проекты в выделенной топологии (Postgres и PostgREST для конкретного проекта) не имеют общего домена сбоя — каждый процесс PostgREST является своей собственной целью. Таким образом, шлюз поддерживает таблицу автоматических выключателей, индексированную по решенной цели, заполняемую на лету и очищаемую каждые 60 секунд с помощью очистки, удаляющей неактивные записи: без этой очистки каждый новый выделенный проект будет добавлять запись, которая никогда не исчезнет.

gateway/circuit_breaker.rsrust
let Some(probe) = circuit_breaker.try_acquire_probe() else {
    return Err(ServiceUnavailable);
};

// …Попытки запроса NATS с ограниченной повторной попыткой…

match resultat {
    Ok(Ok(_))     => match probe.take() { Some(p) => p.record_success(), _ => {} },
    Ok(Err(_))    => match probe.take() { Some(p) => p.record_failure(), _ => {} },
    Err(_timeout) => {} // зонд не израсходован → возвращен Drop
}

Токен проверки возвращается как Drop, если он никогда не используется явно — полезно, когда все попытки запроса истекают по тайм-ауту, не достигая ветки, которая могла бы его освободить. А автоматический повтор запускается только при строгом доказательстве недоставки на стороне NATS (NoResponders): простой тайм-аут шлюза ничего не доказывает о реальной доставке запроса, и при его воспроизведении он может быть выполнен дважды.

#
Шаг 6

Последняя ссылка: NATS или прямой HTTP, никогда случайно.

Конечный прокси не говорит ни по одному протоколу в обратном порядке, и выбор не фиксируется маршрутом: он может зависеть от метода HTTP или даже от поиска в базе данных. Для большей части трафика (аутентификация, функции, уведомления, управление и большая часть базы данных) шлюз сериализует HTTP-запрос в конверте NATS и отправляет его в виде запроса/ответа субъекту, выделенному для службы. Это процедура туда и обратно без TCP-квитирования, задокументированная в коде как значительно более быстрая, чем классический HTTP-прокси для трафика этого типа RPC.

Storage, три варианта реального времени (WebSocket, SSE и REST для трансляции/каналов/присутствия) и — условно — запросы Postgres CRUD выходят из этого пути и проходят через работающий пул HTTP-клиента. Система хранения сделала этот выбор явно: кодирование двоичного тела в конверте NATS требует его сериализации, полной загрузки в память с обоих концов и соблюдения ограничения на размер сообщения NATS — реальная цена для больших объектов. WebSocket и SSE просто не допускают семантики запроса/ответа: обновление протокола и поток, который остается открытым, не имеют эквивалента NATS.

Самый интересный случай — /v1/db/*, чей обработчик принимает решение самостоятельно при каждом запросе: маршруты управления (схема, политики, необработанный SQL) всегда идут через NATS к aura-db, PUT всегда идет в NATS (PostgREST возвращает 405 при полной замене), проект MongoDB всегда идет в NATS — и только CRUD в проекте Postgres с выделенным разрешенным экземпляром PostgREST идет в Direct HTTP. Если этот выделенный экземпляр не разрешен, шлюз отвечает 503 вместо того, чтобы вернуться к общему PostgREST: предполагается закрытие при отказе, а не ухудшенный тихий резервный вариант. Заголовки безопасности (строгий CSP, без учетных данных CORS) применяются единообразно ко всем этим путям и размещаются в самом конце цепочки, прежде чем ответ покинет шлюз.

#
Шаг 7

Бюджет тайм-аута для каждого маршрута, а не глобальный тайм-аут.

Шлюз применяет TimeoutLayer PER GROUP маршрутов, а не глобальный тайм-аут — выбор, связанный с той же механикой укладки, что и порядок промежуточного программного обеспечения. Маршрут функций Edge требует гораздо большего бюджета, чем остальные (функция может законно работать в течение нескольких минут): значение по умолчанию для репозитория составляет 30 секунд для большинства маршрутов по сравнению с 380 секундами для /v1/functions/*.

Если разместить один глобальный TimeoutLayer поверх всего, обе группы будут иметь одинаковый предел: всегда выигрывает самый короткий тайм-аут, помещенный в самую внешнюю позицию, независимо от более длительного тайм-аута, помещенного дальше внутри. Единственный способ предоставить функциям отдельный бюджет состоит в том, чтобы они НИКОГДА не входили в общую обертку: каждая ветвь маршрутов несет свой собственный TimeoutLayer, размещенный до слияния двух маршрутизаторов, и после этого глобальный тайм-аут не применяется.

#
Чтобы помнить

Воспроизведите этот шаблон в другом месте: контрольный список

  1. Разделять по ПЛАНУ (поверхности воздействия), а не по службе: скомпрометированный общедоступный SDK никогда не должен попадать в список источников CORS на панели администратора.
  2. Сохраняйте одно общее состояние, а не два отдельных развертывания — дублирование бизнес-логики обходится дороже, чем дублирование маршрутизатора.
  3. Проверьте ФАКТИЧЕСКИЙ порядок промежуточного программного обеспечения, отслеживая его от последнего .layer(), а не от линейного чтения файла.
  4. Отдельное ограничение скорости на IP-адрес (до аутентификации) и квоты на актера (после) — в противном случае поток без аутентификации потребует дорогостоящей проверки без ограничений.
  5. Разместите прерыватель как можно ближе к фактическому сетевому вызову в прокси-сервере и определите его размер по цели, если домен сбоя не является общим.
  6. Воспроизводите запрос только при подтверждении недоставки, а не по простому тайм-ауту.
  7. Перед объединением маршрутизаторов дайте каждой группе маршрутов свой собственный бюджет таймаута, а не глобальный TimeoutLayer, который перезапишет самый длинный бюджет.

ГОТОВЫ К РАЗВЕРТЫВАНИЮ?

Ваш бэкэнд за пять минут.

Кредитная карта не требуется · 500 МБ бесплатно · 50 000 MAU