本节目录
处理 TCP/UDP 的七个阶段
nginx 自 1.9.0 起引入了 stream 模块(ngx_stream_core_module),用于实现 TCP/UDP 层的反向代理(四层反向代理),与 HTTP 模块不同,stream 模块并不解析应用层,而是直接转发原始字节流。

nginx 四层反向代理 7 个阶段的结构体
Post-Accept
此阶段(NGX_STREAM_POST_ACCEPT_PHASE)是 nginx 四层反向代理处理引擎中的第一个阶段。当 nginx 的监听进程成功接收到一个新的 TCP 连接或 UDP 数据包,并初步创建了 ngx_stream_session_t 会话结构后(对于 TCP nginx在 accept() 之后创建绘画结构,对于 UDP nginx 会基于客户端五元组虚拟创建会话结构),程序立将即进入此阶段。
此阶段的核心任务就是,在握手之后、正式决策之前,对连接进行最基础的元数据预处理(ip 和 端口等),它的存在也主要是为了解决一个矛盾 ------ 拿到某些必须支业务处理之前的信息。(典型场景 proxy protocol)
在标准 nginx stream 的模块中,挂载在此阶段的常用指令非常少,常用到的就俩(set_real_ip_from、proxy_protocol)。所以如果你想要获得更多指令,就需要使用第三方写好的模块或者自己定制化开发。下面就是官方的 ngx_stream_realip_module 模块的注册案例:

将 realip 模块注册到此阶段
如果你想在此阶段开发三方模块,必须遵守如下规矩:
- 绝对不能执行任何会导致进程阻塞的操作(如同步磁盘 I/O 、数据库查询),因为这会卡住整个 nginx 事件循环;
- 此阶段数据尚未就绪,preread 缓冲区基本是空的,所以你无法在此阶段读取业务数据(如 header 头或 mysql 登录包等);
- 三方模块通常返回
NGX_DECLINED,以便让后续模块(如 realip)继续运行。除非检测到致命错误(返回NGX_ERROR)或者你明确此模块执行后中止当前 handler 进入下一阶段(返回NGX_OK)。
Pre-Access
此阶段(NGX_STREAM_PREACCESS_PHASE)是 nginx 四层反向代理处理引擎中的第二个阶段。
其主要任务是进行访问控制前的初步检查,由于它处于 Access 阶段之前,所以通常用于处理那些不需要读取应用层数据、仅根据连接本身属性就能决定的限制逻辑。
在标准版 nginx stream 中,可用于此阶段的指令也几乎没有,常用的就 limit_conn 它一个,用于限制连接数量,当超过规定数量 nginx 会关闭连接。如果需要开发第三方模块请参照 nginx/src/stream/ngx_stream_limit_conn_module.c 这个内置模块,将你的模块追加到 &cmcf->phases[NGX_STREAM_PREACCESS_PHASE].handlers[1](#1) 这个动态数组中即可。
Access
此阶段(NGX_STREAM_ACCESS_PHASE)是紧随 pre-access 之后的第三个阶段,如果说 pre-access 是初步安检(如限制并发连接数),那么 Access 阶段就是正式准入审查。
其主要任务是根据预设的规则(ip 黑白名单、认证状态等)来决定当前连接是否允许继续向后转发到上游服务器。
在 nginx stream 模块中,此阶段主要由 ngx_stream_access_module 负责,通过指令 allow 和 deny 来允许或拒绝特定 ip 或网段访问。
如果你要开发一个复杂的准入控制模块(对接 redis 校验 token、动态防火墙等),则此阶段是最佳落脚点。强烈建议参考 src/stream/ngx_stream_access_module.c 模块的实现。
如果你想在此阶段开发三方模块,必须遵守如下规矩:
- 此阶段具有局限性,你拿不到 HTTP 的内容,只能通过
s->connection->sockaddr拿到客户端的 ip 和 端口号;- 此阶段如果要拒绝访问,可以在此阶段设置标志变量,并在 content 阶段由
ngx_stream_return_module统一返回提示信息(ngx_stream_access_module默认打印一条错误日志后就会直接关闭 socket),这样对用户相对友好些;- 此阶段为每个连接的必经之路,还是老生常谈的问题,务必使用非阻塞的方式来实现它。
SSL
此阶段(NGX_STREAM_SSL_PHASE)是紧随 access 之后的第四个阶段,也是一个极其特殊的阶段,它的核心任务只是完成 TLS/SSL 握手即可。在四层代理中,此阶段要处理加密流量的解密(如果 nginx 作为 SSL 的终点)或简单的属性提取。
注意:
- 如果 nginx 没有配置证书,它通常是不会进入此阶段的,而是直接将加密包透传给后端;
- 此阶段是四层代理中计算量最大的,非对称加密握手会消耗大量 CPU 资源,所以建议在全局配置中开启
ssl_session_cache以复用会话,减少重复握手,提高性能。
与之前的 access 阶段不同,很少人会在 NGX_STREAM_SSL_PHASE 上注册自定义 handler 来实现业务,这其中涉及到了 openssl 库和SSL 核心处理逻辑等。如果非要在此阶段挂 handler,请挂载到 SSL_set_verify 的回调函数上,或者使用 ngx_stream_ssl_conf_t 提供的钩子,不要直接在 cmcf->phase 数组里塞函数[2](#2)。
如果你想在此阶段开发三方模块,必须遵守如下规矩:
- 此阶段完成之前,你无法读取到加密报文中的任何业务数据,如果你需要根据原始报文内容来决定是否开启 SSL,你需要去下一阶段利用变量判断;
- 一旦进入 SSL 阶段并握手成功,大量的
$ssl_*变量(如$ssl_protocol, $ssl_cipher, $ssl_client_s_dn)才会变得可用;- 对于高性能场景,nginx 支持异步 openssl 操作。如果你的模块涉及外部硬件加速器,需要特别处理
NGX_AGAIN返回值,确保握手挂起时不会阻塞 Worker。
Preread
此阶段(NGX_STREAM_PREREAD_PHASE)是紧随 ssl 之后的第五个阶段,也是最核心、最具有技术含量的一个阶段。在四层代理中,nginx 默认是不会读取应用层数据的,它只是简单地把包从左手倒右手,但此阶段是允许 nginx 在不真正消耗数据的情况下,偷摸的看一眼客户端发送的前几个字节。
此阶段的核心意义在于判断流量是 HTTP、数据库还是 SSH 并进行只能路由转发,它读取的数据会保留在缓冲区中,当连接发往后端时,这些原始数据依然会完整的发送。
在 nginx stream 模块中,此阶段主要是由 ngx_stream_ssl_preread_module 等模块支持。其指令 ssl_preread 可以实现根据域名转发 TCP 流量,指令 preread_buffer_size 可以设置预读缓冲区的大小用于分析结果,指令 preread_timeout 可以设置预读数据到达的超时时间,当客户端只建立连接而不发送数据时,避免 nginx 死等着。
作为开发者来说,这里将是重灾区,推荐参考 src/stream/ngx_stream_ssl_preread_module.c 模块来进行开发。
如果你想在此阶段开发三方模块,必须遵守如下规矩:
- 想要获取 peek 数据,请通过
c->recv(需谨慎,避免多读)或检查s->preread_buf(优先)来获取;- 因为 TCP 是流式数据,所以你必须要遵循状态机机制,正确处理 handler 的
NGX_DONE、NGX_AGAIN、NGX_DECLINED;- 对于缓冲区指针的操作,你只能移动指针或读取内容,坚决不能把数据从缓冲区中删除,否则后端收到的包将是不完整的;
- 预读会增加首包延迟 TTFB(nginx 必须等缓冲区填满或匹配到特征码才往下走),所以建议将
preread_buffer_size设置得尽量小且精准;- 注意前一阶段是否存在 SSL 解密,如果存在则本阶段读到的是明文,反之是密文。
Coutent
此阶段(NGX_STREAM_CONTENT_PHASE)是紧随 preread 之后的第六阶段,也是 nginx 四层代理的终点站,其唯一任务就是产生响应或将流量转发到上游服务器。 在四层代理中,这通常意味着建立与后端服务器的 TCP/UDP 连接,并在客户端和后端之间开启双向数据透明传输。
此阶段具有独占性,在一个 server 块中只能有一个模块负责此阶段,一旦某个模块接管了内容处理,后续的 content 处理程序将不会被触发。此外,此阶段不止负责数据的发送,还管理着整个连接的生命周期。
在 nginx stream 模块中,此阶段主要是由 ngx_stream_proxy_module、ngx_stream_return_module 两模块支持,前一个负责将流量转发到后端服务器,后一个负责维护状态或非法访问的友好提示(向客户端发送指定字符串并关闭连接)。
如果你想在此阶段开发三方模块,有下列注意事项:
- 与上述的其他阶段不同,此阶段通常是通过配置指令直接设置
ngx_stream_core_srv_conf_t中的 handler 指针,让你的模块来接管内容处理,而不是推入全局的 phase 数组;(参见下图)- 如果你实现的 handler 只是想发点数据就行了,那么可以参见
nginx/src/stream/ngx_stream_return_module.c模块;- 如果你实现的 handler 还想要做代理,那么你需要自行调用
ngx_stream_proxy_handler和管理上游连接;- 作为内容的产生者,你必须确保在连接结束时释放所有分配的资源,不过一般不需要自己管理,而是通过内存池
s->connection->pool自动管理;- 你自定义的 handler 一旦被调用,就必须负责整个会话的生命周期,且禁止返回
NGX_DECLINED,否则会导致未定义的行为。

return 模块的单点挂载
Log
此阶段(NGX_STREAM_LOG_PHASE)是最后一个阶段,当上一阶段处理完成、连接准备关闭或已经关闭的时候,nginx 会进入此阶段记录这次会话的所有相关信息。
此阶段是事后审计的阶段,无论连接是正常结束、被中途拒绝(access 直接跳过来的),还是因为超时断开,nginx 都会调用该阶段的处理器。
如果你需要将日志实时推送到 kafka、发送到特定的监控系统或者进行自定义的流量审计,你需要在此阶段注册 handler。可以参考 src/stream/ngx_stream_log_module.c 模块来写。
Nginx Stream 如何串联七个阶段
在 nginx stream 源码中,串联这七个阶段的核心逻辑被称为阶段引擎,它并不是靠简单的函数嵌套,而是通过一个连续的函数数组(阶段数组)和一个索引指针来回拨动状态机实现的。下面是一个连接的全流程追踪:(基于 nginx-1.26.x 及以后的最新源码)
- 打开工厂大门,连接从 event 核心进入 stream 模块的第一个函数,需要分配 ngx_stream_session_t 会话结构,先判断连接是否是 SSL,以决定是否进入
NGX_STREAM_SSL_PHASE;(本次假设不是 SSL,不进入 SSL 阶段)

收到货物,送入工厂准备启动阶段引擎处理它
- 阶段引擎启动,这是阶段引擎运行起点,调用
ngx_stream_core_run_phases(s)正式让连接进入流水线;
引擎启动函数:拉闸开电,准备干活
- 阶段驱动引擎,这是整个流水线的核心,它根据
s->phase_handler查找对应阶段的 checker,并循环调用每个阶段 checker 函数。只有 checker 能决定是停下来等事件(返回NGX_OK),还是继续走下一个(索引自增并继续循环);

引擎驱动函数:状态机逐步推进,去找牛马们(handler)来处理流水线上的货物
- 阶段 python,ngx_stream_core_generic_phase() 函数就像胶水一样,负责调用用户注册的函数。如果你的 handler 返回
NGX_OK,它会在当前阶段完成后直接进入下一阶段,忽略此阶段后续 handler;如果返回NGX_DECLINED,它会放弃处理当前 handler,并自增s->phase_handler在循环中立即执行下一模块;如果返回NGX_AGAIN,它会等待当前 handller 被事件触发,此时阶段引擎暂时挂起(等待事件回调再次驱动);

包工头函数:接收状态机找的牛马们,然后有序组织它们干活
- 阶段货仓,这是整个流水线的终点站,它不再寻找下一个阶段,而是查找
cscf->handler,如果存在(比如proxy_pass),就直接跳过去执行,阶段引擎的循环到此终止;

快递员函数:根据货物目的地发送出去,独占性发货,后续 handler 不再触发
-
统计员,无论连接成功还是失败最后都会手动调用此函数,它会触发
NGX_STREAM_LOG_PHASE进入 log 阶段。log 阶段不在 run_phases 的主循环里跑,而是由这个销毁函数在关闭 socket 前最后拉起来跑一次。

记录所有货物的处理情况
四层反向代理配置
在生产环境中,nginx 四层代理常用于数据库集群(MySQL/Redis)、中继加速(SSH/RDP)、DNS 转发以及非 HTTP 协议的负载均衡。
nginx
limit_conn_zone $binary_remote_addr zone=addr:10m;
stream {
log_format proxy '$remote_addr [$time_local] ' # 定义日志格式,记录连接耗时、流量和后端响应情况
'$protocol $status $bytes_sent $bytes_received '
'$session_time "$upstream_addr" '
'"$upstream_bytes_sent" "$upstream_bytes_received" "$upstream_connect_time"';
access_log /var/log/nginx/stream_access.log proxy;
upstream mysql_backend {
hash $remote_addr consistent; # 采用通用 hash,确保同一 ip 尽量落到同一台库,减少 session 漂移
server 192.168.1.10:3306 max_fails=3 fail_timeout=30s;
server 192.168.1.11:3306 max_fails=3 fail_timeout=30s;
server 192.168.1.12:3306 backup;
}
server {
listen 33060;
allow 10.0.0.0/8; # 仅允许内网网段访问(access 阶段)
deny all; # 拒绝其他所有连接
limit_conn addr 100; # 限制单个 ip 同时最多 100 个连接(pre-access 阶段)
proxy_connect_timeout 5s; # 连接后端超时,生产建议设短一点,快速重试
proxy_timeout 10m; # 数据传输超时,对于长连接(数据库),设长一些防止被意外切断
proxy_buffer_size 16k; # 存放后端响应数据的缓冲区
proxy_pass mysql_backend; # 转发
proxy_bind 192.168.100.100:3306; # 通过本地接口 192.168.100.100 的端口 3306 转发到上游
}
server {
listen 12345;
...
}
}
配置 nginx 四层反向代理的注意事项:
nginx 作为中转,作为客户端去连后端时会消耗临时端口,可以根据需要去调优内核参数
net.ipv4.ip_local_port_range扩大端口范围,并开启net.ipv4.tcp_tw_reuse以快速回收端口;
/etc/sysctl.confnet.ipv4.ip_local_port_range = 1024 65535 # 扩大临时端口范围 net.ipv4.tcp_tw_reuse = 1 # 开启快速回收和重用处于 time_wait 的连接 net.core.somaxconn = 4096 # 调高全连接队列大小,防止高并发下丢包上游服务器(如 mysql)看到的连接 ip 全是 nginx 的内网 ip 时是无法进行审计的,可以设置
proxy_protocol on(需要上游软件支持,如 mysql 8.0 或 pgsql);
nginxstream { server { listen 3306; proxy_pass mysql_backend; proxy_protocol on; # 开启 proxy protocol 发送,将客户端真实 ip 封装在报文头传给后端 } }四层代理一个会话要占两个 socket(一个客户端,一个后端),所以必须修改系统
ulimit -n和 nginx 的worker_rlimit_nofile(如果单机并发 10 万,句柄数至少要设为 20 万以上);
nginxuser nginx; worker_processes auto; worker_rlimit_nofile 100000; # 限制每个 worker 能打开的最大文件数 events { worker_connections 40000; # 每个 worker 允许的并发连接 use epoll; # 开启 epoll 事件模型提高效率 multi_accept on; # 允许一个 worker 同时接收多个新连接 }UDP 是无连接的,如果代理 UDP,必须配置
proxy_responses指令告知 nginx 收到多少个包后就认为本次会话结束,否则会话会一直占用直到超时。
nginxstream { server { listen 53 udp; proxy_pass dns_backends; proxy_responses 1; # 期望从后端收到 1 个包后就关闭会话(适用于 DNS 查询) proxy_timeout 10s; # 如果后端没回包,10秒后强制释放连接资源 } }
附加
下面是一个 nginx stream 中各阶段的 handler 开发模板:(不适用于内容处理阶段)
c
#include <ngx_config.h>
#include <ngx_core.h>
#include <ngx_stream.h>
/**
* 业务逻辑处理的模板
* @param s: 当前 stream 会话上下文,包含连接、内存池、变量等所有信息
*/
static ngx_int_t ngx_stream_my_filter_logic_handler(ngx_stream_session_t *s) {
ngx_connection_t *c;
c = s->connection;
// 1. 记录一条调试日志
ngx_log_error(NGX_LOG_DEBUG, c->log, 0, "my filter: f*`*k nginx");
// 2. 处理业务逻辑
// 在四层代理中,获取客户端 ip 地址进行判断(c->sockaddr 包含了原始的二进制地址信息)
if (c->sockaddr->sa_family == AF_INET) {
// 如果是 ipv4,执行一些逻辑
// ...
}
// 3. 决定连接命运
// 验证通过,允许进入下一个 handler 或下一个阶段
// return NGX_DECLINED;
// 验证通过,且不需要再执行本阶段后续的 handler,直接跳到下一阶段
// return NGX_OK;
// 验证失败,出现异常,立即中断并关闭连接
// return NGX_ABORT;
// 默认行为:交给下一个处理器
return NGX_DECLINED;
}
/**
* 在 nginx 内存池中申请内存的模板
* @param s: 当前 stream 会话上下文,包含连接、内存池、变量等所有信息
*/
static ngx_int_t ngx_stream_my_filter_memory_handler(ngx_stream_session_t *s) {
ngx_connection_t *c;
void *my_data;
ngx_str_t *custom_msg;
c = s->connection;
// 1:申请一块普通的内存(类似 malloc)
// 使用 s->connection->pool,这块内存在 TCP 连接断开时自动释放
// ngx_pallo 分配对齐的内存,用于性能要求极高的结构体分配
my_data = ngx_palloc(c->pool, 1024);
if (my_data == NULL) {
return NGX_ERROR; // 内存分配失败,返回错误
}
// 2:申请并清零内存(类似 calloc)
// ngx_pcalloc 分配并初始化为 0,防止读到内存中的脏数据
custom_msg = ngx_pcalloc(c->pool, sizeof(ngx_str_t));
if (custom_msg == NULL) {
return NGX_ERROR;
}
// 3:拷贝字符串
// nginx 的字符串不是以 \0 结尾的,手动拷贝容易出错
// ngx_pnalloc 分配不对齐的内存,可以节省内存对齐造成的空隙
u_char *src = (u_char *) "cnmd nginx";
size_t len = ngx_strlen(src);
custom_msg->data = ngx_pnalloc(c->pool, len);
if (custom_msg->data == NULL) {
return NGX_ERROR;
}
ngx_memcpy(custom_msg->data, src, len);
custom_msg->len = len;
ngx_log_error(NGX_LOG_DEBUG, c->log, 0, "fenpei memory for: %V", custom_msg);
return NGX_DECLINED;
}
/**
* 模块初始化注册函数的模板(不适用于内容处理阶段)
* 通常赋值给 ngx_stream_module_t 中的 postconfiguration 钩子
*/
static ngx_int_t ngx_stream_my_filter_init(ngx_conf_t *cf) {
ngx_stream_handler_pt *h;
ngx_stream_core_main_conf_t *cmcf;
// 1:获取 stream 核心模块的 main 配置
cmcf = ngx_stream_conf_get_module_main_conf(cf, ngx_stream_core_module);
// 2:在对应的阶段(以 preaccess 为例)推入一个新的 handler 指针
h = ngx_array_push(&cmcf->phases[NGX_STREAM_PREACCESS_PHASE].handlers);
if (h == NULL) {
return NGX_ERROR;
}
// 3:将占位符指向我们编写的函数地址
*h = ngx_stream_my_filter_logic_handler;
return NGX_OK;
}
下面是一份适用于内容处理阶段的模板:
c
#include <ngx_config.h>
#include <ngx_core.h>
#include <ngx_stream.h>
static void ngx_stream_my_echo_handler(ngx_stream_session_t *s);
static void ngx_stream_my_echo_event_handler(ngx_event_t *ev);
/**
* 注册函数,在配置解析时将本模块设为内容处理阶段的处理器
* 函数统一这样签名,否则编译器报错
*/
static char *ngx_stream_my_echo_directive(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) {
ngx_stream_core_srv_conf_t *cscf;
// 获取当前 server 块的核心配置
cscf = ngx_stream_conf_get_module_srv_conf(cf, ngx_stream_core_module);
// 直接将 handler 指针挂载到核心配置上(第六阶段独有)
cscf->handler = ngx_stream_my_echo_handler;
return NGX_CONF_OK;
}
/**
* 入口 handler,当连接经过前五个阶段后,会跳转到这里
*/
static void ngx_stream_my_echo_handler(ngx_stream_session_t *s) {
ngx_connection_t *c;
c = s->connection;
ngx_log_error(NGX_LOG_INFO, c->log, 0, "enter my echo content phase");
// 设置读写事件的回调函数(接管状态机)
c->read->handler = ngx_stream_my_echo_event_handler;
c->write->handler = ngx_stream_my_echo_event_handler;
// 将 session 指针存入 connection 的 data,方便在事件触发时找回 session
c->data = s;
// 立即触发一次读逻辑
ngx_stream_my_echo_event_handler(c->read);
}
/**
* 事件循环,处理实际的数据收发
*/
static void ngx_stream_my_echo_event_handler(ngx_event_t *ev) {
ngx_connection_t *c;
ngx_stream_session_t *s;
u_char buf[1024];
ssize_t n;
c = ev->data;
s = c->data;
// 如果连接已经出错或超时,直接终结
if (ev->timedout) {
ngx_stream_finalize_session(s, NGX_STREAM_OK);
return;
}
// 读循环逻辑
for ( ;; ) {
n = c->recv(c, buf, 1024);
if (n == NGX_AGAIN) {
// 内核缓冲区暂时没数据,等待下一次 epoll 通知
if (ngx_handle_read_event(c->read, 0) != NGX_OK) {
ngx_stream_finalize_session(s, NGX_STREAM_INTERNAL_SERVER_ERROR);
}
return;
}
if (n == NGX_ERROR || n == 0) {
// n=0 表示客户端主动断开连接
ngx_stream_finalize_session(s, NGX_STREAM_OK);
return;
}
// 业务逻辑:将读到的内容原样发回
// 实际开发中应检查 c->send 的返回值和缓冲区状态
c->send(c, buf, n);
break;
}
}