高性能文件网关:OpenResty 鉴权后直通 Swift

1 分钟阅读
·

云盘合入大象 IM 后,用 OpenResty 的 Lua 完成权限校验与文件寻址,再由 Nginx 直接代理 Swift S3 对象,避免 Java 中转文件流。

背景:IM 文件接入后,下载链路也要做权限控制

云盘合入大象后,群聊里的图片、附件和其他资源由云盘存储。底层文件放在内部基于 Swift 的对象存储中。对象路径可以定位文件,但不能直接暴露给客户端。对象路径本身不能证明请求方有访问权限;群成员变化、消息撤回或文件权限调整后,旧链接也可能继续被访问。

下载请求需要先确认三件事:请求方是否已登录,是否属于对应群聊或消息上下文,以及这条消息关联的对象路径是什么。这个判断依赖 Java 服务中的 IM、云盘元数据和权限逻辑。

最直接的实现是由 Java 接住下载请求,完成鉴权和寻址后,再从 Swift 拉取对象并把 InputStream 写到客户端响应。早先的 IM 消息文件实现高性能 IO 已经分析过这条链路的问题:Java 被放进了文件数据路径,每个下载会占用客户端连接、Java 处理资源、上游 HTTP 连接和 JVM 缓冲。文件流量在内部多经过一跳,Java 侧还需要按文件传输量规划网络与内存资源。

这篇记录当时落地的文件网关:把 Java 留在权限判断和对象寻址的控制面,文件字节由 OpenResty 的 Nginx 代理链路直接从 Swift 回给客户端。

设计目标

这个网关将职责拆开:

  • 权限服务负责结论。 Java 根据用户身份、IM 上下文和文件元数据返回允许或拒绝;允许时附带对象的 bucket、path、Content-Type 等代理所需信息。
  • OpenResty 负责入口编排。 Lua 读取请求中的 Cookie、Header 和消息标识,调用权限服务,拒绝时立即返回 401,允许时把对象定位结果写入 Nginx 上下文。
  • Nginx 负责文件转发。 内部跳转后,请求进入固定的代理 location,由 proxy_pass 连接 Swift 并转发响应。Lua 和 Java 不读取或拼接文件响应体。

OpenResty 文件网关的鉴权、寻址与 Swift 代理链路

图中有两条路径。橙色虚线是控制面,传递身份、消息标识、权限结论和对象路径等元数据。蓝色实线是数据面,文件从 Swift 经 Nginx 返回客户端。鉴权明确拒绝的请求在入口结束,不访问对象存储。

请求如何经过网关

以群聊图片为例,请求 URL 只携带文件标识或消息标识,身份信息仍由 Cookie 或 Header 提供。网关不能相信客户端传来的 bucket 或对象路径,这些字段只能由权限服务返回。

  1. 客户端请求 /im-file/{messageId},携带登录态。
  2. access_by_lua 提取身份和 messageId,调用 Java 的内部鉴权接口。
  3. Java 校验用户与群、消息、文件的关系;不通过时返回拒绝,通过时返回对象路径和响应元信息。
  4. Lua 将路径写进 Nginx 变量,使用内部 rewrite 转到专门的对象代理 location。
  5. 代理 location 使用 proxy_pass 请求 Swift。Nginx 从上游读取时持续向客户端写入,文件内容不进入 Lua 或 Java。

权限接口的返回内容只表达网关需要的最小信息。例如:

{
  "allow": true,
  "upstream": "swift-internal",
  "objectPath": "/im-bucket/2016/12/abc123",
  "contentType": "image/jpeg"
}

接口不应把可从公网直接访问的长期有效对象 URL 返回给客户端。客户端访问网关 URL,对象路径只用于网关的内部代理请求。权限服务不返回文件内容,因此可以分别观察权限判断耗时和对象传输的带宽成本。

Lua 只做控制面调用

Lua 侧的逻辑应保持短小:组装鉴权请求、设置超时、解析小 JSON、根据结果进入内部 location 或结束请求。下面是简化后的结构,字段和内部接口名均作了泛化:

local http = require("resty.http")
local cjson = require("cjson.safe")

local client = http.new()
client:set_timeout(100) -- 控制面超时,不能跟着文件下载时间走

local res, err = client:request_uri("http://file-auth.internal/authorize", {
    method = "POST",
    body = cjson.encode({
        messageId = ngx.var.message_id,
        cookie = ngx.var.http_cookie,
        authorization = ngx.var.http_authorization,
    }),
    headers = { ["Content-Type"] = "application/json" },
})

if not res then
    return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end

local result = cjson.decode(res.body)
if res.status ~= ngx.HTTP_OK or not result or not result.allow then
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

ngx.var.swift_upstream = result.upstream
ngx.var.swift_object_path = result.objectPath
ngx.header["Content-Type"] = result.contentType
return ngx.exec("/_internal/swift-object")

这里有两个边界。

第一,Lua 不应该用 socket.http 把 Swift 文件读成字符串,再用 ngx.print 输出。这会让文件内容进入 Lua 用户态,重新引入缓冲、内存占用和错误处理问题。第二,鉴权服务不可用时不应放行。权限超时、上游错误或返回内容无法验证时应结束请求并返回 5xx;权限不确定时继续提供对象会形成越权路径。示意代码将所有非 200 或未允许结果归为 401,生产实现应区分明确拒绝与鉴权服务故障。

为什么用内部跳转分离鉴权和对象代理

如果让 Lua 按请求决定完整的代理目标,鉴权和对象代理会混在同一个 location,连接管理、错误处理和日志也难以统一。这里将流程拆成入口和内部代理两个 location:入口 location 只做鉴权,内部 location 只做对象代理。上游组 swift_cluster 保持固定,权限服务只提供该请求允许访问的对象路径。

location ~ ^/im-file/(?<message_id>[^/]+)$ {
    access_by_lua_file conf/lua/im_file_auth.lua;
}

location = /_internal/swift-object {
    internal;
    proxy_set_header Host $swift_upstream;
    proxy_pass http://swift_cluster$swift_object_path;
}

示意配置省略了鉴权接口地址、TLS、上游健康检查和真实的 URI 规则。internal 会拒绝客户端对 /_internal/swift-object 的直接请求,内部跳转仍可进入这个 location,ngx.exec 只是其中一种方式。代理层使用的对象路径来自权限服务,客户端参数不会直接拼到上游 URL 上。swift_upstream 仅用于设置 Host 请求头,不应由客户端控制,并应限制为 Swift 服务接受的值。

实际配置还要明确处理请求方法、Range、上游超时和响应头。图片预览和大文件下载都可能使用 Range 请求,应确认 Range 请求头以及 Swift 返回的 Accept-RangesContent-RangeContent-Length 和状态码能按预期传递。Nginx 默认会转发多数客户端请求头,网关仍应显式审查哪些请求头可以到达 Swift,避免把 Cookie、内部认证头等无关头部带到数据面。proxy_read_timeout 约束的是上游长时间无数据的等待,不应按鉴权接口的几十或几百毫秒超时机械设置。

性能账:移除 Java 的数据面中转

Java 中转时,文件路径大致是:

客户端 → 文件网关 → Java 服务 → Swift → Java 服务 → 文件网关 → 客户端

Java 同时承担权限判断和文件字节转发。下载并发增加时,Java 的线程、连接池、堆缓冲和 GC 都需要纳入容量计算;上游读取慢或客户端接收慢时,这些资源的占用时间会增加。

改造后,控制面和数据面分开:

控制面:OpenResty → Java 鉴权服务 → OpenResty
数据面:客户端 → OpenResty/Nginx → Swift → OpenResty/Nginx → 客户端

Java 的 QPS 仍然随下载请求数增长,但传输的是小 JSON,不再随文件大小增长。对象存储带宽仍要按真实下载量规划,OpenResty 仍需要连接、网络缓冲和上游保护;减少的是 Java 中转这一层的带宽、内存和失败点,而不是让文件下载没有成本。

这也延续了第 9 篇的结论:在文件转发场景里,减少 Java 中转节点通常比继续优化 InputStream、NIO 或零拷贝实现更直接。Nginx 的代理模块适合处理上游到下游的转发;Lua 保持在请求决策路径,避免处理文件响应体。

安全与可用性上的约束

网关上线后,需要持续检查以下约束是否成立:

  • 对象路径只由服务端生成。 路径、bucket、Content-Type 等由 Java 根据服务端元数据生成,网关不把请求参数直接作为 Swift URI。服务端还应校验路径属于允许的对象命名空间,并避免路径规范化或编码差异改变实际访问的对象。
  • 鉴权失败时不访问 Swift。 鉴权服务超时、返回格式异常或明确拒绝时,网关不访问 Swift。身份无效可返回 401;已认证但无权访问时,如客户端协议需要区分,可返回 403。服务不可用和无法验证的鉴权结果应返回 5xx
  • 内部 location 不对外开放。 对象代理 location 使用 internal,并在网关层保留统一的审计日志,记录请求方、文件标识、鉴权结果、上游状态和耗时,不记录完整 Cookie。
  • 为控制面和数据面分别设置保护措施。 鉴权接口需要独立的连接池、超时和限流,防止控制面异常占满网关资源。Swift 上游也需要独立的连接与超时配置,防止慢对象读取耗尽连接。
  • 默认不缓存权限结果。 群成员、消息撤回和文件删除都可能改变结论。若以后为热点图片缓存允许结果,缓存键必须包含用户或可靠的权限版本,并通过撤回、退群等事件主动失效;没有这套失效机制时,每次请求实时校验更稳妥。若缓存对象响应,鉴权必须发生在缓存命中判断之前,缓存键和响应头也不能让一个用户的授权结果被另一个用户复用。

当时的边界

这套方案解决的是下载数据面不经 Java 中转,不能替代对象存储的访问控制。Swift 在网络层仍应只接受网关或受控上游访问。对象路径泄露后,外部请求仍应无法绕开网关读取对象。

此外,OpenResty 不是业务规则的归宿。群成员关系、消息归属、撤回状态和云盘权限继续由 Java 服务维护。Lua 脚本只把请求上下文交给该服务,并根据确定的结果调度 Nginx。这样规则变更仍集中在业务服务,网关只需要稳定地执行“校验后代理”这一件事。

这次改造把文件权限管理保留在服务端,同时避免了 Java 承担大文件中转。后续压测时,控制面应看鉴权接口 QPS、RT 和拒绝率,数据面应看网关连接数、上游 RT、带宽、4xx/5xx 与客户端中断率。分开记录两套指标后,可以区分鉴权故障与对象传输故障。


415 字 · 46 段落
ximing

Written by ximingFollow onGitHub

相关文章