动态

详情 返回 返回

在 OpenResty 裏實現異步的流式代理 - 动态 详情

七層代理經常會有需要承接流式業務的需求,比如通過 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 裏完成我們的改造工作。

Add a new 评论

Some HTML is okay.