化虹為橋 - Nginx 如何代理 UDP “連線”

spacewander發表於2023-02-02

眾所周知,UDP 並不像 TCP 那樣是基於連線的。但有些時候,我們需要往一個固定的地址傳送多個 UDP 來完成一個 UDP 請求。為了保證服務端能夠知道這幾個 UDP 包構成同一個會話,我們需要在傳送 UDP 包時繫結某個埠,這樣當網路棧透過五元組(協議、客戶端IP、客戶端埠、服務端IP、服務端埠)進行區分時,那幾個 UDP 包能夠分到一起。通常我們會把這種現象稱之為 UDP 連線。

但這樣又有了一個新的問題。不同於 TCP 那樣有握手和揮手,UDP 連線僅僅意味著使用固定的客戶端埠。雖然作為服務端,由於事先就跟客戶端約定好了一套固定的協議,可以知道一個 UDP 連線應當在何處終止。但如果中間使用了代理伺服器,那麼代理是如何區分某幾個 UDP 包是屬於某個 UDP 連線呢?畢竟沒有握手和揮手作為分隔符,一箇中間人是不清楚某個會話應當在何處放下句號的。

透過下面的實驗,我們會看到 Nginx 是如何處理這個問題的。

實驗

在接下來的幾個實驗中,我都會用一個固定的客戶端。這個客戶端會向 Nginx 監聽的地址建立 UDP “連線”,然後傳送 100 個 UDP 包。

// save it as main.go, and run it like `go run main.go`
package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    conn, err := net.Dial("udp", "127.0.0.1:1994")
    if err != nil {
        fmt.Printf("Dial err %v", err)
        os.Exit(-1)
    }
    defer conn.Close()

    msg := "H"
    for i := 0; i < 100; i++ {
        if _, err = conn.Write([]byte(msg)); err != nil {
            fmt.Printf("Write err %v", err)
            os.Exit(-1)
        }
    }
}

基礎配置

下面是實驗中用到的 Nginx 基礎配置。後續實驗都會在這個基礎上做些改動。

這個配置中,Nginx 會有 4 個 worker 程式監聽 1994 埠,並代理到 1995 埠上。錯誤日誌會發往標準錯誤,而訪問日誌會發往標準輸出。

worker_processes  4;
daemon off;
error_log  /dev/stderr warn;

events {
    worker_connections  10240;
}


stream {
    log_format basic '[$time_local] '
                 'received: $bytes_received '
                 '$session_time';

    server {
        listen 1994 udp;
        access_log /dev/stdout basic;
        preread_by_lua_block {
            ngx.log(ngx.ERR, ngx.worker.id(), " ", ngx.var.remote_port)
        }
        proxy_pass 127.0.0.1:1995;
        proxy_timeout 10s;
    }

    server {
        listen 1995 udp;
        return "data";
    }
}

輸出如下:

2023/01/27 18:00:59 [error] 6996#6996: *2 stream [lua] preread_by_lua(nginx.conf:48):2: 1 51933 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
2023/01/27 18:00:59 [error] 6995#6995: *4 stream [lua] preread_by_lua(nginx.conf:48):2: 0 51933 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
2023/01/27 18:00:59 [error] 6997#6997: *1 stream [lua] preread_by_lua(nginx.conf:48):2: 2 51933 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
2023/01/27 18:00:59 [error] 6998#6998: *3 stream [lua] preread_by_lua(nginx.conf:48):2: 3 51933 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:01:09 +0800] received: 28 10.010
[27/Jan/2023:18:01:09 +0800] received: 27 10.010
[27/Jan/2023:18:01:09 +0800] received: 23 10.010
[27/Jan/2023:18:01:09 +0800] received: 22 10.010

可以看出,全部 100 個 UDP 包分散到了每個 worker 程式上。看來 Nginx 並沒有把來自同一個地址的 100 個包當作同一個會話,畢竟每個程式都會讀取 UDP 資料。

reuseport

要想讓 Nginx 代理 UDP 連線,需要在 listen 時指定 reuseport:

    ...
    server {
        listen 1994 udp reuseport;
        access_log /dev/stdout basic;

現在全部 UDP 包都會落在同一個程式上,並被算作同一個會話:

2023/01/27 18:02:39 [error] 7191#7191: *1 stream [lua] preread_by_lua(nginx.conf:48):2: 3 55453 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:02:49 +0800] received: 100 10.010

多個程式在監聽同一個地址時,如果設定了 reuseport 時,Linux 會根據五元組的 hash 來決定發往哪個程式。這樣一來,同一個 UDP 連線裡面的所有包就會落到一個程式上。

順便一提,如果在 1995 埠的 server 上列印接受到的 UDP 連線的客戶端地址(即 Nginx 跟上游通訊的地址),我們會發現同一個會話的地址是一樣的。也即是 Nginx 在代理到上游時,預設就會使用 UDP 連線來傳遞整個會話。

proxy_xxx directives

相信讀者也已經注意到,在錯誤日誌中記錄的 UDP 訪問開始時間,和在訪問日誌中記錄的結束時間,正好差了 10 秒。該時間段對應了配置裡的 proxy_timeout 10s;。由於 UDP 連線中沒有揮手的說法,Nginx 預設根據每個會話的超時時間來決定會話何時終止。預設情況下,一個會話的持續時間是 10 分鐘,只是由於我缺乏耐心,所以特定配成了 10 秒。

除了超時時間,Nginx 還會依靠什麼條件決定會話的終止呢?請往下看:

        ...
        proxy_timeout 10s;
        proxy_responses 1;

在新增了 proxy_responses 1 後,輸出變成了這樣:

2023/01/27 18:07:35 [error] 7552#7552: *1 stream [lua] preread_by_lua(nginx.conf:48):2: 2 36308 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:07:35 +0800] received: 62 0.003
2023/01/27 18:07:35 [error] 7552#7552: *65 stream [lua] preread_by_lua(nginx.conf:48):2: 2 36308 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:07:35 +0800] received: 9 0.000
2023/01/27 18:07:35 [error] 7552#7552: *76 stream [lua] preread_by_lua(nginx.conf:48):2: 2 36308 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:07:35 +0800] received: 7 0.000
2023/01/27 18:07:35 [error] 7552#7552: *85 stream [lua] preread_by_lua(nginx.conf:48):2: 2 36308 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:07:35 +0800] received: 3 0.000
2023/01/27 18:07:35 [error] 7552#7552: *90 stream [lua] preread_by_lua(nginx.conf:48):2: 2 36308 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:07:35 +0800] received: 19 0.000

我們看到 Nginx 不再被動等待時間超時,而是在收到上游發來的包之後就終止了會話。proxy_timeoutproxy_responses 兩者間是“或”的關係。

proxy_responses 相對的有一個 proxy_requests

        ...
        proxy_timeout 10s;
        proxy_responses 1;
        proxy_requests 50;

在配置了 proxy_requests 50 後,我們會看到每個請求的大小都穩定在 50 個 UDP 包:

2023/01/27 18:08:55 [error] 7730#7730: *1 stream [lua] preread_by_lua(nginx.conf:48):2: 0 49881 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
2023/01/27 18:08:55 [error] 7730#7730: *11 stream [lua] preread_by_lua(nginx.conf:48):2: 0 49881 while prereading client data, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:08:55 +0800] received: 50 0.002
[27/Jan/2023:18:08:55 +0800] received: 50 0.001

注意讓會話終止所需的上游響應的 UDP 數是 proxy_requests * proxy_responses。在上面的例子中,如果我們把 proxy_responses 改成 2,那麼要過 10 秒才會終止會話。因為這麼做之後,對應每 50 個 UDP 包的請求,需要響應 100 個 UDP 包才會終止會話,而每個請求的 UDP 包只會得到一個 UDP 作為響應,所以只能等超時了。

動態代理

在大多數時候,UDP 請求的包數不是固定的,我們可能要根據開頭的某個長度欄位來確定會話的包數,抑或透過某個包的包頭是否有 eof 標記來判斷什麼時候終結當前會話。目前 Nginx 的幾個 proxy_* 指令都只支援固定值,不支援藉助變數動態設定。

proxy_requestsproxy_responses 實際上只是設定了 UDP session 上的對應計數器。所以理論上我們可以修改 Nginx,暴露出 API 來動態調整當前 UDP session 的計數器的值,實現按上下文決定 UDP 請求邊界的功能。那是否存在不修改 Nginx 的解決方案呢?

換個思路,我們能不能透過 Lua 把客戶端資料都讀出來,然後在 Lua 層面上由 cosocket 傳送給上游?透過 Lua 實現上游代理這個思路確實挺富有想象力,可惜它目前是行不通的。

使用如下程式碼代替前面的 preread_by_lua_block

        content_by_lua_block {
            local sock = ngx.req.socket()
            while true do
                local data, err = sock:receive()

                if not data then
                    if err and err ~= "no more data" then
                        ngx.log(ngx.ERR, err)
                    end
                    return
                end
                ngx.log(ngx.WARN, "message received: ", data)
            end
        }
        proxy_timeout 10s;
        proxy_responses 1;
        proxy_requests 50;

我們會看到這樣的輸出:

2023/01/27 18:17:56 [warn] 8645#8645: *1 stream [lua] content_by_lua(nginx.conf:59):12: message received: H, udp client: 127.0.0.1, server: 0.0.0.0:1994
[27/Jan/2023:18:17:56 +0800] received: 1 0.000
...

由於在 UDP 下面, ngx.req.socket:receive 目前只支援讀取第一個包,所以即使我們設定了 while true 迴圈,也得不到全部的客戶端請求。另外由於 content_by_lua 會覆蓋掉 proxy_* 指令,所以 Nginx 並沒有走代理邏輯,而是認為當前請求只有一個包。把 content_by_lua 改成 preread_by_lua 後,雖然 proxy_* 指令這下子生效了,但因為拿不到全部客戶端請求,依然無法完成 Lua 層面上的代理。

總結

假如 Nginx 代理的是 DNS 這種只有一個包的基於 UDP 的協議,那麼使用 listen udp 就夠了。但如果需要代理包含多個包的基於 UDP 的協議,那麼還需要加上 reuseport。另外,Nginx 目前還不支援動態設定每個 UDP 會話的大小,所以沒辦法準確區分不同的 UDP 會話。Nginx 代理 UDP 協議時能用到的功能,更多集中於像限流這種不需要關注單個 UDP 會話的。

相關文章