Nginx 的绝大部分功能都以模块形式实现:ngx_http_proxy_module 负责反向代理、ngx_http_rewrite_module 负责 URL 重写、ngx_http_gzip_filter_module 负责压缩。当内置模块与第三方模块都无法满足需求时(比如需要一段极高频的自定义鉴权逻辑、或者要在响应体上做特定改写),就需要自己写模块。Nginx 模块开发的门槛不在于 C 语言本身,而在于理解它的异步事件模型、内存池管理与请求生命周期——这些设计与常规的同步编程范式差别很大。本文从最小骨架开始,逐步构建一个可用的 HTTP 模块。
一句话总结: Nginx 模块开发的关键是「在正确的阶段做正确的事」,handler 负责生成响应,filter 负责改写响应,二者都必须遵守非阻塞与内存池规则。
1. 模块体系与模块类型
一句话总结: Nginx 模块按挂载点分为 core、event、http、stream 等类型,HTTP 模块内部又按处理阶段分为 handler、filter、upstream、load-balancer 等角色。
每个 Nginx 模块都用一个 ngx_module_t 结构描述自己,其中 ctx 字段决定它属于哪个子系统。HTTP 模块的 ctx 是 ngx_http_module_t,它提供了一系列回调钩子:preconfiguration、postconfiguration、create_main_conf、create_srv_conf、create_loc_conf、merge_loc_conf 等。
按职责,HTTP 模块可以分为四类:
handler 模块 :直接生成响应(如 ngx_http_static_module)
filter 模块 :改写上游或本地生成的响应(如 gzip、sub_filter)
upstream 模块 :实现自定义上游协议(如 proxy、fastcgi)
load-balancer :实现自定义负载均衡算法
理解自己的模块属于哪一类,决定了代码挂在哪个阶段。一个「在响应体里替换关键字」的模块是 filter;一个「校验请求头并拒绝非法请求」的模块是 handler(它在 content 之前的阶段就能决定返回 403);一个「实现自定义后端协议」的模块是 upstream。
/* 在 postconfiguration 中把 handler 注册到指定阶段 */
static ngx_int_t
ngx_http_mymodule_init(ngx_conf_t *cf)
{
ngx_http_core_main_conf_t *cmcf;
ngx_http_handler_pt *h;
cmcf = ngx_http_conf_get_module_main_conf(cf, ngx_http_core_module);
h = ngx_array_push(&cmcf->phases[NGX_HTTP_ACCESS_PHASE].handlers);
if (h == NULL) { return NGX_ERROR; }
*h = ngx_http_mymodule_handler;
return NGX_OK;
}
ngx_http_conf_get_module_main_conf 这类宏是模块与 Nginx 内核交互的主要方式:先用 ngx_http_get_module_ctx 或 conf 系列宏拿到配置结构,再按需读写。所有配置结构都由 Nginx 在解析阶段通过 create_*_conf 回调分配,模块只需提供构造函数与合并函数。
2. C 模块骨架与 ngx_module_t
一句话总结: 一个最小模块由「模块定义 ngx_module_t + 上下文 ngx_http_module_t + 配置结构 + 指令表」四部分组成,缺一不可。
先看配置结构与模块定义的骨架。下面的模块实现了一个可配置的响应头注入功能,用来演示完整的结构:
#include <ngx_config.h>
#include <ngx_core.h>
#include <ngx_http.h>
/* 每个 location 的配置 */
typedef struct {
ngx_str_t header_name;
ngx_str_t header_value;
ngx_flag_t enable;
} ngx_http_mymodule_loc_conf_t;
/* 指令表:声明模块支持的配置指令 */
static ngx_command_t ngx_http_mymodule_commands[] = {
{ ngx_string("mymodule_header"),
NGX_HTTP_LOC_CONF | NGX_CONF_TAKE2,
ngx_http_mymodule_set_header,
NGX_HTTP_LOC_CONF_OFFSET,
0,
NULL },
{ ngx_string("mymodule_enable"), NGX_HTTP_LOC_CONF | NGX_CONF_FLAG,
ngx_conf_set_flag_slot, NGX_HTTP_LOC_CONF_OFFSET,
offsetof(ngx_http_mymodule_loc_conf_t, enable), NULL },
ngx_null_command
};
/* 模块上下文:提供各配置结构的创建与合并回调 */
static ngx_http_module_t ngx_http_mymodule_module_ctx = {
NULL, ngx_http_mymodule_init, /* pre/post configuration */
NULL, NULL, NULL, NULL, /* main/srv 配置的回调 */
ngx_http_mymodule_create_loc_conf, /* create_loc_conf */
ngx_http_mymodule_merge_loc_conf /* merge_loc_conf */
};
/* 模块定义 */
ngx_module_t ngx_http_mymodule_module = {
NGX_MODULE_V1,
&ngx_http_mymodule_module_ctx, /* module context */
ngx_http_mymodule_commands, /* module directives */
NGX_HTTP_MODULE, /* module type */
NULL, NULL, NULL, NULL, /* init master/module/process/thread */
NULL, NULL, NULL, /* exit thread/process/master */
NGX_MODULE_V1_PADDING
};
NGX_MODULE_V1 与 NGX_MODULE_V1_PADDING 是两个固定宏,前者填充模块版本与索引字段,后者保证结构体尾部对齐预留。ngx_module_t 的字段顺序在 Nginx 各版本间保持稳定,但必须用这两个宏而不是手工填字段,否则升级时容易出问题。
指令表的每一项由「指令名、位置与参数规则、处理函数、偏移量、可选字段」组成。NGX_HTTP_LOC_CONF | NGX_CONF_TAKE2 表示该指令只能出现在 location 块内且必须带两个参数。ngx_conf_set_flag_slot 这类内置 setter 可以处理常见的 on/off、字符串、整数、时间等类型,只有特殊语法才需要自定义解析函数。模块编译加载后,业务配置就可以这样写:
location /demo/ {
mymodule_enable on;
mymodule_header "X-Custom-From" "mymodule";
proxy_pass http://backend;
}
3. handler 与 filter 阶段
一句话总结: handler 挂在 content 或 access 阶段决定「是否放行/返回什么」,filter 挂在 header/body filter 链上改写响应,二者都必须返回标准状态码。
3.1 handler:在阶段回调中处理请求
一句话总结: handler 返回 NGX_OK 表示已生成响应,返回 NGX_DECLINED 表示交棒给下一个 handler。
一个只做校验的 access 阶段 handler 大致如下。它检查请求头,不合法就返回 403,合法则 NGX_DECLINED 交棒:
static ngx_int_t
ngx_http_mymodule_handler(ngx_http_request_t *r)
{
ngx_http_mymodule_loc_conf_t *mlcf;
ngx_table_elt_t *token;
mlcf = ngx_http_get_module_loc_conf(r, ngx_http_mymodule_module);
if (!mlcf->enable) {
return NGX_DECLINED; /* 未启用,交给下一个 handler */
}
token = r->headers_in.x_forwarded_for;
if (token == NULL) {
return NGX_HTTP_FORBIDDEN; /* 直接返回 403,不进入后续阶段 */
}
return NGX_DECLINED; /* 校验通过,继续 */
}
handler 的返回值有三类含义:NGX_OK 表示已生成完整响应(后续阶段不再执行)、NGX_DECLINED 表示未处理交给下一个、NGX_HTTP_* 表示直接返回该状态码。在 access 阶段返回 NGX_HTTP_FORBIDDEN 时,Nginx 会自动生成 403 响应并执行后续的 error_page 逻辑,非常省事。
如果需要自己生成响应体(比如返回一段 JSON),就要构造响应头与响应体链:先设置 r->headers_out 的状态、content_length_n 与 content_type,调用 ngx_http_send_header 下发头部;再用 ngx_pcalloc 从请求池分配 ngx_buf_t,把 pos/last 指向响应内容,memory = 1 表示指向常驻内存不释放,last_buf = (r == r->main) 标记主请求的最后一个 buffer;最后把 buffer 包成 ngx_chain_t 交给 ngx_http_output_filter 输出。
3.2 filter:改写响应体
一句话总结: filter 模块要注册到 header filter 与 body filter 两条链上,处理完自己的逻辑后必须调用下一个 filter 继续传递。
filter 比 handler 复杂一些,因为它要在链条中传递。注册时通常先注册 body filter,再由它「顺带」注册 header filter:
static ngx_http_output_header_filter_pt ngx_http_next_header_filter;
static ngx_http_output_body_filter_pt ngx_http_next_body_filter;
static ngx_int_t
ngx_http_mymodule_header_filter(ngx_http_request_t *r)
{
/* 只处理 200 且 content-type 为文本的响应,其余直接放行 */
if (r->headers_out.status != NGX_HTTP_OK
|| r->headers_out.content_type.len == 0
|| ngx_strncasecmp(r->headers_out.content_type.data,
(u_char *) "text/", 5) != 0)
{
return ngx_http_next_header_filter(r);
}
/* 改写头部后必须继续传递,否则响应链断裂 */
return ngx_http_next_header_filter(r);
}
static ngx_int_t
ngx_http_mymodule_body_filter(ngx_http_request_t *r, ngx_chain_t *in)
{
ngx_chain_t *cl;
/* 逐 buffer 处理:buffer 可能被拆分,不能假设一次拿全 */
for (cl = in; cl; cl = cl->next) {
if (cl->buf->last > cl->buf->pos) {
/* 在此处改写 cl->buf 指向的内容 */
}
}
return ngx_http_next_body_filter(r, in);
}
/* 注册 filter 链:保存原链头,把自己插到链首 */
static ngx_int_t
ngx_http_mymodule_init(ngx_conf_t *cf)
{
ngx_http_next_header_filter = ngx_http_top_header_filter;
ngx_http_top_header_filter = ngx_http_mymodule_header_filter;
ngx_http_next_body_filter = ngx_http_top_body_filter;
ngx_http_top_body_filter = ngx_http_mymodule_body_filter;
return NGX_OK;
}
body filter 最容易踩的坑是「假设响应体在一次调用中完整给出」。实际上 Nginx 会把大响应拆成多个 buffer,跨 buffer 的关键字替换必须自己维护状态(缓存上一段的尾部若干字节),否则替换会失败。这也是为什么很多第三方 filter 模块要求响应体小于某个阈值。
4. 配置指令与 merge
一句话总结: 配置结构按 main/srv/loc 三级创建与合并,merge 函数的职责是把未设置的值从上层继承下来,未初始化的值用 NGX_CONF_UNSET 系列标记。
配置继承是 Nginx 模块开发中最容易出错的部分。create_loc_conf 负责分配并初始化结构,所有可选字段必须显式初始化为 NGX_CONF_UNSET、NGX_CONF_UNSET_UINT 等哨兵值:
static void *
ngx_http_mymodule_create_loc_conf(ngx_conf_t *cf)
{
ngx_http_mymodule_loc_conf_t *conf;
conf = ngx_pcalloc(cf->pool, sizeof(ngx_http_mymodule_loc_conf_t));
if (conf == NULL) {
return NULL;
}
conf->enable = NGX_CONF_UNSET; /* 关键:未设置的标志位用 UNSET 标记 */
return conf;
}
static char *
ngx_http_mymodule_merge_loc_conf(ngx_conf_t *cf, void *parent, void *child)
{
ngx_http_mymodule_loc_conf_t *prev = parent;
ngx_http_mymodule_loc_conf_t *conf = child;
/* 若本层未设置则继承父层,都未设置则用默认值 */
ngx_conf_merge_value(conf->enable, prev->enable, 0);
ngx_conf_merge_str_value(conf->header_name, prev->header_name, "");
return NGX_CONF_OK;
}
ngx_pcalloc 已经把内存清零,但零值不等于「未设置」:enable = 0 既可能是「用户显式写了 off」,也可能是「用户没写」。因此必须用 NGX_CONF_UNSET 区分。ngx_conf_merge_value、ngx_conf_merge_str_value、ngx_conf_merge_uint_value 是内置的合并宏,语义是「conf 未设置则取 prev,prev 也未设置则取默认值」;手写这个逻辑容易漏掉分支,建议一律使用宏。
自定义指令的解析函数在需要复杂语法时使用。例如上面的 mymodule_header 带两个参数,解析函数要校验参数个数与合法性:
static char *
ngx_http_mymodule_set_header(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
ngx_http_mymodule_loc_conf_t *mlcf = conf;
ngx_str_t *value = cf->args->elts; /* value[0] 是指令名本身 */
if (mlcf->header_name.len != 0) {
return "is duplicate"; /* 同一个 location 内不允许重复配置 */
}
mlcf->header_name = value[1];
mlcf->header_value = value[2];
/* 参数含变量时可用 ngx_http_script_compile 预编译为运行时脚本 */
return NGX_CONF_OK;
}
5. 内存池与请求生命周期
一句话总结: 请求内存池在请求结束时统一释放,模块不必手动 free;跨请求存活的数据必须挂在 cycle 或自己的池上,绝不能用 malloc 后忘记释放。
内存池是 Nginx 高性能的关键设计之一:每个请求有独立的 r->pool,请求结束时整池释放,模块只需 ngx_palloc 不需要 free。这消除了大部分内存泄漏与碎片问题,但也带来两个约束:
约束一:不要保存指向请求池的指针到请求之外。 如果模块把 r->pool 里分配的指针存进全局变量或共享内存,请求结束后该内存已失效,后续访问就是野指针。跨请求的数据应该用 ngx_cycle->pool 或 ngx_create_pool 单独创建的池。约束二:模块自己的状态要挂在请求上下文上,这样既随请求释放,又能在后续阶段取回:
typedef struct {
ngx_uint_t counter;
ngx_str_t cached_key;
} ngx_http_mymodule_ctx_t;
/* 创建上下文并挂到请求上,后续阶段可以取回 */
static ngx_int_t
ngx_http_mymodule_create_ctx(ngx_http_request_t *r)
{
ngx_http_mymodule_ctx_t *ctx;
ctx = ngx_pcalloc(r->pool, sizeof(ngx_http_mymodule_ctx_t));
if (ctx == NULL) { return NGX_ERROR; }
ngx_http_set_ctx(r, ctx, ngx_http_mymodule_module);
return NGX_OK;
}
/* 后续阶段取回;子请求要改用 r->parent 上的上下文 */
static ngx_int_t
ngx_http_mymodule_later_phase(ngx_http_request_t *r)
{
ngx_http_mymodule_ctx_t *ctx;
if (r != r->main) { r = r->parent; }
ctx = ngx_http_get_module_ctx(r, ngx_http_mymodule_module);
if (ctx == NULL) { return NGX_ERROR; }
ctx->counter++;
return NGX_DECLINED;
}
请求的生命周期还包括子请求与内部重定向。内部重定向(error_page、rewrite ... last)会重置 location 级别的上下文,但 main 级别的上下文会保留。如果模块的状态需要跨内部重定向保持,就要挂在 r->main 上。这是排查「状态莫名丢失」问题的关键线索。
另一个生命周期要点是清理回调。如果模块持有外部资源(打开的文件、注册的定时器、共享内存引用),必须注册清理回调,在请求结束或进程退出时释放:用 ngx_pool_cleanup_add(r->pool, 0) 申请一个 ngx_pool_cleanup_t,把 handler 指向自己的清理函数、data 指向待释放的资源即可,Nginx 会在池销毁时自动调用它。
6. 动态模块编译与加载
一句话总结: 动态模块需要用 –with-compat 或与目标 Nginx 完全一致的编译参数构建,配置文件中用 load_module 加载,模块路径要写在 events 之前。
Nginx 1.9.11 起支持动态模块,无需重新编译整个 Nginx。构建动态模块的 config 文件与静态模块略有不同,要额外声明 ngx_module_type 与 ngx_module_libs:
# 动态模块的 config 文件
ngx_addon_name=ngx_http_mymodule_module
if test -n "$ngx_module_link"; then
ngx_module_type=HTTP
ngx_module_name=ngx_http_mymodule_module
ngx_module_srcs="$ngx_addon_dir/ngx_http_mymodule_module.c"
. auto/module
else
HTTP_MODULES="$HTTP_MODULES ngx_http_mymodule_module"
NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_mymodule_module.c"
fi
$ngx_module_link 是构建系统提供的变量,动态构建时非空、静态构建时为空,这段条件逻辑让同一份 config 支持两种构建方式。编译、安装与加载:
# 用与目标 Nginx 一致的参数配置,并只编译模块
./configure --with-compat --add-dynamic-module=../ngx_http_mymodule \
$(nginx -V 2>&1 | sed 's/^.*configure arguments: //')
make modules
cp objs/ngx_http_mymodule_module.so /usr/lib/nginx/modules/
# nginx.conf 顶部加载(必须在 events 之前)
# load_module modules/ngx_http_mymodule_module.so;
--with-compat 生成与官方二进制 ABI 兼容的模块,但它不能替代编译参数一致性:如果目标 Nginx 是用 --with-http_ssl_module 编译的,而你的模块用不同参数构建,加载时可能因结构体布局差异而崩溃。最稳妥的做法是始终用 nginx -V 输出的原始参数来构建,并用 nginx -t 验证;若报 module is not binary compatible,说明编译参数不匹配。
7. 调试与测试
一句话总结: 模块调试依赖 gdb 附加到 worker、debug 级别的 error_log,以及用 ngx_log_error 打印上下文,测试则要覆盖内存池释放与错误分支。
模块开发最常见的两类 bug 是段错误与内存泄漏。段错误通常源于空指针解引用(配置结构未初始化、上下文未创建),内存泄漏则多来自跨请求持有的指针或未注册的清理回调。
调试的第一步是开启 debug 日志。用 --with-debug 编译的 Nginx 支持 error_log ... debug,它会打印每个请求经过的阶段,是定位「我的 handler 为什么没被调用」的最快方法;配合 grep -i mymodule 可以只看自己模块的输出:
error_log /var/log/nginx/error.log debug;
第二步是用 gdb 附加到 worker 进程或分析 core 文件,抓取崩溃现场:
ulimit -c unlimited
gdb -p $(pgrep -f "nginx: worker" | head -1)
gdb /usr/sbin/nginx /var/core/nginx.core -ex bt -ex "info registers"
第三步是在代码里主动打日志。ngx_log_error 是模块里最常用的调试手段,它按级别输出并自动带上连接信息:
ngx_log_error(NGX_LOG_INFO, r->connection->log, 0,
"mymodule: enable=%d, name=%V", mlcf->enable, &mlcf->header_name);
/* 严重错误用 NGX_LOG_ERR,调试信息用 NGX_LOG_DEBUG_HTTP */
if (mlcf->header_name.len == 0) {
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, "mymodule: empty name");
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
%V 是 ngx_str_t 的格式化占位符,还有 %s(u_char* 以 \0 结尾)、%p(指针)等。使用 %V 能避免手动计算长度。测试方面,建议用 Test::Nginx 或自建的 curl 脚本覆盖三类场景:正常路径、配置缺省时的默认行为、以及错误分支(配置非法、上游超时)。同时用 valgrind 或 ASan 跑一轮:配置时加上 --with-cc-opt="-fsanitize=address -g" 与 --with-ld-opt="-fsanitize=address",编译出带检测能力的模块,压一轮请求后检查报告即可发现越界与泄漏。
8. 总结
| 环节 | 要点 |
|---|---|
| 模块分类 | handler 生成响应、filter 改写响应、upstream 自定义协议 |
| 骨架结构 | ngx_module_t + ngx_http_module_t + 配置结构 + 指令表 |
| handler | 返回 OK/DECLINED/状态码,在 access 阶段即可拒绝请求 |
| filter | 注册 header/body 两条链,必须调用下一个 filter |
| 配置 merge | UNSET 哨兵 + ngx_conf_merge_* 宏,实现三级继承 |
| 内存池 | 请求池随请求释放,跨请求数据必须另建池 |
| 动态模块 | –with-compat + 编译参数一致 + load_module |
| 调试 | debug 日志、gdb 与 core dump、ngx_log_error、ASan |
Nginx 模块开发的学习曲线集中在「理解异步模型与内存池」上,一旦跨过这个门槛,读写源码和写模块都会变得顺畅。实践建议是先从 filter 模块入手(结构简单、无需处理异步 IO),再逐步接触需要与上游交互的 handler 与 upstream 模块。模块能解决功能扩展问题,但模块本身的 bug 往往以「进程崩溃」或「请求挂起」的形式出现,因此排错与调试能力是模块开发者的必备技能,这也是下一篇文章的主题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。