4

众所周知,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 会话的。


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.