PROD欧洲主权BaaS平台打开仪表板 →

工程 · 10 最小读取值

在Rust中设计双平面API网关

Affane Daylami · Fondateur · 2026年8月6日

返回博客

同时为用户的 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-gateway 代码解决了 main.rs中的这种紧张关系:两个独立的 Router,每个都有自己的 CorsLayer — 在数据平面侧授权的通配符,在管理平面侧被拒绝并记录为错误。

#
步骤1

两台 axum 路由器,一台共享 AppState

这种分离并不是单独的部署:两个计划在同一个进程中、同一个 AppState 上运行(Postgres 池、NATS 客户端、Moka 缓存、断路器)。只有 Router 的构造有所不同,通过两个专用函数,每个函数在启动时调用一次并由两个单独的 TcpListener提供服务。

gateway/server.rsrust
// 两个端口、两个路由器、一个 AppState

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 密钥的最后一种模式:访问日志、OTel 跟踪和 Referer 标头中的 URL 密钥泄漏。 JWT 在数据平面端仍然是可选的:没有它,调用者仍然是 anon;有了它,它就变成了 authenticated。

在管理层面,API 密钥不存在:仅接受 JWT 控制台,其受众必须恰好是 aurabase-control。该角色不是由令牌本身携带的——它是根据拥有该项目的组织中的用户成员资格的每个请求重新计算的,通过项目 → 组织关系继承。

需要令牌API 密钥(apikey / X-API-Key),始终JWT 控制台(授权:Bearer),始终
角色提升可选 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) 放置第一→最里面
    .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)。
该命令的具体效果

The request_id middleware only sets the X-Request-Id header on the RESPONSE, never on the incoming request. As AccessLogLayer is placed after it in the file - therefore more external, therefore traversed before - its capture of the request_id field reads the header as the client sent it, not the identifier generated further in the chain. If the caller has not provided any X-Request-Id, the access-log line leaves with an empty field, while the response returned carries a freshly generated UUID. Not a hidden defect — a reminder that the order in which a .layer() string is written does not guarantee anything about the logical order that we attribute to it.

#
步骤4

限速:IP优先,演员其次

速率限制应用于链中两个不同时间的两个不同的通道中。第一个在身份验证和 IP 地址限制之前运行 - 一个通用的防洪过滤器,即使在公共道路上也处于活动状态:没有它,未经身份验证的流可以攻击昂贵的端点,例如日志聚合,而不会触发 JWT 检查。第二个在身份验证之后运行,并由参与者(API 密钥或用户)使用身份验证刚刚注入的声明进行限制:这是真正的产品配额,对计费和计划至关重要。

该实现依赖于 governor 箱(令牌桶)进行本地计算,使用滑动窗口 Lua Redis 脚本在网关实例之间进行分发,以及在 Redis 不可用时进行本地回退(Moka 缓存)。存储库默认值:100 个请求/秒,突发 1000 个。

#
步骤5

断路器不是一个层,它是每个目标的一个对象

与链的其余部分不同,断路器不会出现在 ANY .layer()中。 AppState 每个服务(auth、db、realtime、storage、functions、notifications、ai、provisioner、control)携带一个 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 握手的往返,在代码中记录为比此 RPC 类型流量的经典 HTTP 代理快得多。

存储、实时的三种变体(用于广播/频道/存在的 WebSocket、SSE 和 REST)以及(有条件的)Postgres CRUD 请求退出此路径并通过实时的池化 HTTP 客户端。存储明确地做出了这个选择:在 NATS 信封中编码二进制主体需要将其序列化,将其完全加载到两端的内存中,并保持在 NATS 消息大小上限之下——这对于大型对象来说是真正的成本。 WebSocket 和 SSE 根本不容忍请求/回复语义:协议升级和保持开放的流没有 NATS 等效项。

最有趣的情况是 /v1/db/*,它的处理程序根据每个请求自行决定:管理路由(架构、策略、原始 SQL)始终进入 NATS 到 aura-db,PUT 始终进入 NATS(PostgREST 在完全替换时返回 405),MongoDB 项目始终进入 NATS — 并且只有解析了专用 PostgREST 实例的 Postgres 项目上的 CRUD 进入直接 HTTP。如果此专用实例未解析,网关将响应 503,而不是回退到共享 PostgREST:假设失败关闭,而不是降级的静默回退。 安全标头 (严格 CSP,无 CORS 凭据)统一应用于所有这些路径,在响应离开网关之前放置在链的最末端。

#
步骤7

每条路线的超时预算,而不是全局超时

网关对每组路由应用 TimeoutLayer ,而不是全局超时 - 这一选择与中间件顺序链接到相同的堆栈机制。 Edge 函数路由需要比其他路由更长的预算(函数可以合法运行几分钟):大多数路由的存储库默认值为 30 秒,而 /v1/functions/*为 380 秒。

在所有内容之上堆叠单个全局 TimeoutLayer 会使两个组处于相同的限制:无论放置在更内部的较长超时如何,始终是放置在最外侧位置的最短超时获胜。因此,为函数授予单独预算的唯一方法是它们永远不会进入公共包装:每个路由分支都带有自己的 TimeoutLayer,放置在两个路由器合并之前 - 并且之后不应用全局超时。

#
要记住

在其他地方重现此模式:清单

  1. 按计划(暴露表面)分开,而不是按服务分开:受损的公共 SDK 永远不应该到达管理仪表板的 CORS 来源列表。
  2. 保持单个共享状态而不是两个单独的部署——复制业务逻辑的成本比复制路由器的成本更高。
  3. 通过从最后一个 .layer()跟踪来检查实际的中间件顺序,而不是从文件的线性读取中进行跟踪。
  4. 将每个 IP(授权前)的速率限制与每个参与者的配额(授权后)分开——否则,未经身份验证的流程会强制进行成本高昂的无限制验证。
  5. 将断路器放置在代理中尽可能靠近实际网络调用的位置,并在故障域不共享时按目标调整其大小。
  6. 仅在未送达证明时重播请求,而绝不会在简单超时时重播请求。
  7. 在合并路由器之前,为每个路由组设置自己的超时预算 - 永远不要使用会覆盖最长预算的全局 TimeoutLayer 。

准备好部署了吗?

五分钟内完成您的后端。

无需信用卡 · 500 MB 免费 · 50,000 MAU