1

七层代理经常会有需要承接流式业务的需求,比如通过 SSE 来代理推理服务返回的结果。有些时候,我们还需要在流式处理过程中进行异步操作,比如访问其他服务来丰富原来的输入输出。

OpenResty 支持在流式处理中做异步操作,但现行的方法有一些缺陷。关于如何更好地做异步的流式代理,我有一些未经验证的想法。可惜现在我已不做 OpenResty 相关的操作,所以一直没机会把这个想法付之实现。为了不让这个点子被埋没,在此我将它记录下来。

现状

要达成异步的流式代理,第一步是要能动态开启流式代理。代理有两个方向,一个是客户端经代理到后端,在本文里称之为“请求路径”;另一个是后端经代理到客户端,在本文里称之为“响应路径”。在请求路径,Nginx 有个配置项 proxy_request_buffering 可以控制是否流式上传。稍微修改下 Nginx 代码,就能通过 Lua 代码动态修改这项参数(参考 apisix-nginx-module 里的 set_proxy_request_buffering)。在响应路径,对应的配置项是 proxy_buffering。按动态修改 proxy_request_buffering 的方式照葫芦画瓢就可以实现对应动态修改的能力,抑或通过响应头 X-Accel-Buffering 来控制。

一旦动态流式代理在响应路径上开启之后,就能在 body_filter_by_lua_file 里修改响应体。但 body_filter_by_lua_file 不支持异步操作,所以没办法在里面请求其他服务。

同样的,虽然请求路径上可以开启流式代理,但是 OpenResty 没有提供和 body_filter_by_lua_file 对等的过滤请求体的方式,没办法流式改写请求体,比如解压缩之类。

针对这两种问题,现行的解决方式都是类似的:通过 cosocket 来实现自己的代理功能,绕过 Nginx 自身代理实现。在响应路径上实现异步的流式这样做:通过 lua-resty-http 去连接后端,然后逐 chunk 读取响应。Kong 和 APISIX 里都有类似的代码。在请求路径上则是通过 ngx.req.socket 来获取一个可以访问请求体的对象,然后自己操作这个对象来获取请求体。

但这种方案有些限制:

  1. 重新实现代理来对齐 Nginx 的实现,工作量很大。一般来说,都是先实现核心功能,然后把这一条路径标识为 experimental,随着用户反馈(通常是 bug report)再慢慢补全缺失的部分。这种方式用户体验不好,会留下经常出问题的印象。
  2. 如果想要自己实现流式的响应路径,需要把整个请求体读出来,做不到请求路径走原生的 Nginx 流式路径,而响应路径走自己的实现。请求路径上同理。
  3. 对于在请求路径上实现异步流式,ngx.req.socket 没办法在 HTTP2 中使用,而即使是不对外网的 API 网关,像是 GRPC 请求也是走 HTTP2 路径的。

我的想法

有没有更好的解决方法?让我们回到本质上,思考一个支持异步的流式接口应该具备什么特质:

  1. 流式:它必须是在处理 body 的路径上,而且是个逐 chunk 调用的 filter。
  2. 异步:它必须能够给客户端/后端一个反压,否则如果对端能一直发送,那么异步操作时有可能会出现读缓冲区暴涨的情况。具体在代码上的实现,是在处理过程中可以中途退出然后择机重入,不需要完整处理全部可用的数据。只要有数据一直卡在内核里,通过 TCP 接收窗口就能阻塞对端的发送。

注意下文都只是我的纯脑内推演,没有做过任何实际的试验验证过下面的想法是正确的!请各位读者明辨。

在响应路径上,有没有一个地方能尽可能满足上述两个特质呢?是有的,就是 ngx_event_pipe_read_upstream 函数。如其名所示,这是一个流式读取上游响应的函数,所以它满足第一个特质。ngx_event_pipe_read_upstream 里面有个功能,就是根据 limit_rate 的设置,如果当前读的数据量超过限制,则停止处理,并设置一个 timer,delay 目标时间后重新处理。把这个机制修改成“停止处理,然后由用户自己调用该函数重新处理”,即可满足异步调用之后重入的需求。

同样的思路也能套在请求路径上。罕为人知的是,Nginx 内部其实也有一套和 body_filter_by_lua 一样的针对请求路径一样的 body filter 机制:ngx_http_top_request_body_filter。可惜 ngx_http_top_request_body_filter 以及在它之上的 ngx_http_request_body_filter 都和 body_filter_by_lua 有着一样的缺陷:它们都是不可重入的。由于满足不了第二个特质,我们不得不将它们从候选者列表中除名。好在 ngx_http_request_body_filter 隔壁有个 ngx_http_do_read_client_request_body,能满足全部两个特质:

  1. 它的调用路径和 ngx_http_request_body_filter 差不多,就是 preread(Nginx 读 header 时有可能读了部分 body)场景下需要额外的特殊处理。
  2. 它支持通过 NGX_AGAIN 中途退出请求体的处理流程,也支持后续的重入。

基本上做一些调整,比如支持通过手动调用该函数重入执行逻辑,就应该能支持请求路径上的异步流式。

不幸的是 ngx_http_do_read_client_request_body 只是在 HTTP1 的路径上调用,HTTP2 和 HTTP3 路径上需要寻找替代品。比如在 HTTP2 上,就需要在 ngx_http_v2_process_request_body 里完成我们的改造工作。


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.