Sentinel Nginx 模块如何支持多个 Nginx 版本

Sentinel 是在大促流量洪峰等场景下保障系统稳定性和可用性的重要技术产品。Nginx 是一款高性能开源 HTTP 服务器,通常部署为应用前端入口。为方便应用接入,我们提供了 Sentinel Nginx 模块 ,只需添加 3 条配置即可在 Nginx 入口层接入阿里云 AHAS Sentinel 流量防护。

动态模块与 Nginx 版本绑定

我们提供预编译的 Sentinel Nginx 动态模块 (.so 文件) 以方便用户直接使用。然而 Nginx 自身限制预编译动态模块与 Nginx 版本绑定。

举个例子,我们在开发环境基于较低版本的 Nginx 1.10.3 开发 (以保证较好的代码兼容性) ,而 CentOS 7 从 EPEL 仓库 安装的 Nginx 版本是 1.16.1 ,尝试加载开发环境编译的 Sentinel 模块时出现如下报错:

$ nginx -p $PWD -c etc/nginx.conf -t
2020/12/11 22:17:37 [emerg] 7+0: module ".../centos7-nginx-1.10.3-noconf/ngx_sentinel_module.so" version 1010003 instead of 1016001 in .../nginx.conf:12
nginx: configuration file .../nginx.conf test failed

从报错信息看,编译模块时的 Nginx 版本为 1010003 ,即 1.10.3 的整数版本号,而期望的版本号是 1016001 ,即当前 Nginx 版本 1.16.1 的整数版本号。

查看 Nginx 1.16.1 版本的源码,报错代码位于 ngx_add_module() 函数,代码如下:

    if (module->version != nginx_version) {
        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                           "module \"%V\" version %ui instead of %ui",
                           file, module->version, (ngx_uint_t) nginx_version);
        return NGX_ERROR;
    }

其中 nginx_version 即 Nginx 的整数版本号,在头文件 nginx.h 中定义:

#define nginx_version      1016001
#define NGINX_VERSION      "1.16.1"
#define NGINX_VER          "nginx/" NGINX_VERSION

定义模块的基本代码结构如下:

typedef struct ngx_module_s          ngx_module_t;

ngx_module_t ngx_http_sentinel_module = {
        NGX_MODULE_V1,
        // ... ...
        NGX_MODULE_V1_PADDING
};

模块结构 struct ngx_module_s 头部包含若干 Nginx 内部字段,使用宏 NGX_MODULE_V1 填充,其中 version 字段被填充为编译时的 Nginx 整数版本号 nginx_version。相关定义在 ngx_module.h 头文件中:

#define NGX_MODULE_V1                                                         \
    NGX_MODULE_UNSET_INDEX, NGX_MODULE_UNSET_INDEX,                           \
    NULL, 0, 0, nginx_version, NGX_MODULE_SIGNATURE

#define NGX_MODULE_V1_PADDING  0, 0, 0, 0, 0, 0, 0, 0

struct ngx_module_s {
    ngx_uint_t            ctx_index;
    ngx_uint_t            index;

    char                 *name;

    ngx_uint_t            spare0;
    ngx_uint_t            spare1;

    ngx_uint_t            version;
    const char           *signature;

    // ... ...
}

因此,预编译模块绑定了编译时的 Nginx 版本号,要支持多个版本,最简单可靠的办法即用多个 Nginx 版本分别进行编译,这样还能确保模块源码与各 Nginx 版本源码兼容。

Nginx 版本相对稳定,算上 Tengine 和 OpenResty 等变体,常用版本也只有几个。如果只为每个 Nginx 版本维护一个预编译 .so 文件,还是相对容易的,然而事情并没有这么简单。

动态模块与 Nginx 编译配置绑定

尝试使用当前 Nginx 版本的源码重新编译模块,测试加载模块时出现如下报错:

$ nginx -p $PWD -c etc/nginx.conf -t
2020/12/12 13:58:28 [emerg] 9+0: module ".../centos7-nginx-1.16.1-noconf/ngx_sentinel_module.so" is not binary compatible in .../nginx.conf:12
nginx: configuration file .../nginx.conf test failed

报错代码同样在 ngx_add_module() 函数中,紧跟在版本检查之后,代码如下:

    if (ngx_strcmp(module->signature, NGX_MODULE_SIGNATURE) != 0) {
        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                           "module \"%V\" is not binary compatible",
                           file);
        return NGX_ERROR;
    }

回顾模块结构头部字段,整数版本号 version 之后紧跟着 signature 字段,这是一个用字符串表示的二进制兼容性签名,使用宏 NGX_MODULE_SIGNATURE 定义并编译到最终 .so 文件中 (类似整数版本号 version)。Nginx 加载 .so 模块时,检查模块二进制签名与自身签名不匹配则报错退出。

NGX_MODULE_SIGNATURE 定义在 ngx_module.h 头文件中,部分代码摘录如下:

#define NGX_MODULE_SIGNATURE_0                                                \
    ngx_value(NGX_PTR_SIZE) ","                                               \
    ngx_value(NGX_SIG_ATOMIC_T_SIZE) ","                                      \
    ngx_value(NGX_TIME_T_SIZE) ","

#if (NGX_HAVE_KQUEUE)
#define NGX_MODULE_SIGNATURE_1   "1"
#else
#define NGX_MODULE_SIGNATURE_1   "0"
#endif

#if (NGX_PCRE)
#define NGX_MODULE_SIGNATURE_23  "1"
#else
#define NGX_MODULE_SIGNATURE_23  "0"
#endif

#if (NGX_COMPAT)
#define NGX_MODULE_SIGNATURE_34  "1"
#else
#define NGX_MODULE_SIGNATURE_34  "0"
#endif

#define NGX_MODULE_SIGNATURE                                                  \
    NGX_MODULE_SIGNATURE_0 NGX_MODULE_SIGNATURE_1 NGX_MODULE_SIGNATURE_2      \
    NGX_MODULE_SIGNATURE_3 NGX_MODULE_SIGNATURE_4 NGX_MODULE_SIGNATURE_5      \
    NGX_MODULE_SIGNATURE_6 NGX_MODULE_SIGNATURE_7 NGX_MODULE_SIGNATURE_8      \
    NGX_MODULE_SIGNATURE_9 NGX_MODULE_SIGNATURE_10 NGX_MODULE_SIGNATURE_11    \
    NGX_MODULE_SIGNATURE_12 NGX_MODULE_SIGNATURE_13 NGX_MODULE_SIGNATURE_14   \
    NGX_MODULE_SIGNATURE_15 NGX_MODULE_SIGNATURE_16 NGX_MODULE_SIGNATURE_17   \
    NGX_MODULE_SIGNATURE_18 NGX_MODULE_SIGNATURE_19 NGX_MODULE_SIGNATURE_20   \
    NGX_MODULE_SIGNATURE_21 NGX_MODULE_SIGNATURE_22 NGX_MODULE_SIGNATURE_23   \
    NGX_MODULE_SIGNATURE_24 NGX_MODULE_SIGNATURE_25 NGX_MODULE_SIGNATURE_26   \
    NGX_MODULE_SIGNATURE_27 NGX_MODULE_SIGNATURE_28 NGX_MODULE_SIGNATURE_29   \
    NGX_MODULE_SIGNATURE_30 NGX_MODULE_SIGNATURE_31 NGX_MODULE_SIGNATURE_32   \
    NGX_MODULE_SIGNATURE_33 NGX_MODULE_SIGNATURE_34

从代码可知,二进制签名由多个部分组成 (NGX_MODULE_SIGNATURE_0NGX_MODULE_SIGNATURE_34) ,每个部分通常用字符 "0" 或 "1" 标识当前环境是否具备某个功能,或是否开启了某个特性。是否开启某个功能特性通常使用编译配置选项来控制。

以正则表达式功能 NGX_PCRE (影响 NGX_MODULE_SIGNATURE_23 签名) 为例,配置脚本 auto/options 包含如下代码:

USE_PCRE=NO
PCRE=NONE
# ... ...

for option
do
    # ... ...
    case "$option" in
        # ... ...
        --without-pcre)                  USE_PCRE=DISABLED          ;;
        --with-pcre)                     USE_PCRE=YES               ;;
        --with-pcre=*)                   PCRE="$value"              ;;
        # ... ...
    esac
done

配置脚本 auto/lib/conf 包含如下代码:

if [ $USE_PCRE = YES -o $PCRE != NONE ]; then
    . auto/lib/pcre/conf
    # ... ...
fi

即配置了 --with-pcre--with-pcre=* 选项时,将执行配置脚本 auto/lib/pcre/conf 并最终在 ngx_auto_config.h 头文件中设置 NGX_PCRE 宏。

动态模块 .so 文件与 Nginx 可执行文件的开发环境与编译配置完全一致时,可确保两者完全兼容,此时二进制签名也相同。

版本检查与二进制签名是 Nginx 对动态模块 .so 文件进行兼容性检查的一种安全机制。使用相同版本的源码和编译选项重新编译后,再次测试加载模块成功。

$ nginx -p $PWD -c etc/nginx.conf -t
nginx: the configuration file .../nginx.conf syntax is ok
2020/12/12 16:22:45 [notice] 9+0: nginx-sentinel-module/0.6.0
nginx: configuration file .../nginx.conf test is successful

Nginx 官方安装包与自定义编译版本

安装 Nginx 最简单的方式是直接使用 apt-getyum 命令安装,Ubuntu 和 CentOS EPEL 仓库都提供了 Nginx 安装包。对没有特殊需求的用户,使用官方安装包通常也是最佳选择。因此,一开始我们针对系统软件仓库提供的 Nginx 安装包,为 Ubuntu 18.04, CentOS 6, CentOS 7 提供了 3 个预编译模块版本。

操作系统 Nginx 版本 Sentinel Nginx 模块路径
Ubuntu 18.04 nginx-1.14.0 lib/ubuntu-18.04-nginx-1.14.0/ngx_sentinel_module.so
CentOS 6 nginx-1.10.3 lib/centos6-nginx-1.10.3/ngx_sentinel_module.so
CentOS 7 nginx-1.16.1 lib/centos7-nginx-1.16.1/ngx_sentinel_module.so

然而从我们目前接触的用户来看,有不少用户都使用了自定义编译 Nginx 版本。由于早期 Nginx 版本不支持动态模块,有特殊需求 (添加扩展模块) 的用户只能自行定制编译 Nginx ,一些用户仍保留了此使用方式。多数用户使用 CentOS 7 (最近 CentOS 6 生命周期结束,CentOS 8 宣布取消长期支持, CentOS 7 成为最后一个长期支持版本) 。Nginx, OpenResty 和 Tengine 各有一定的用户数,其中 Tengine 没有官方安装包,其用户通常使用自定义编译版本。

Nginx 二进制签名可能不可靠

用户自定义编译 Nginx 时,编译配置通常也不一样,如果为每个用户都维护一个预编译 Sentinel Nginx 模块版本,维护成本将大大增加。

为了解决这个问题,我曾设计过一个方案,分析影响 Nginx 二进制兼容性的编译选项 (类似上述分析 NGX_PCRE 的方式),编写脚本提取出与指定 Nginx 编译版本兼容的最小编译选项集合,如果多个 Nginx 编译版本提取得到的最小兼容选项集合相同,则可合并为一个兼容版本。如上述 CentOS 7 下 Nginx 1.16.1 共有 48 个编译选项,经过提取只需保留 6 个编译选项即可实现二进制签名相同,并测试加载成功。

但随后我们遇到一些特殊 Nginx 版本,虽然使用最小兼容选项编译插件可以加载成功,但运行中出现了一些问题。而使用与编译 Nginx 完全相同的配置 (包括 3 方模块) 重新编译 Sentinel Nginx 模块后,这些问题消失。正常情况下,Nginx 3 方模块不应该影响 Nginx 的二进制兼容性,然而 3 方模块可以引入头文件和库,一些未良好设计的 3 方模块可能影响宏定义和编译选项等,进而意外影响 Nginx 的二进制兼容性。

简单说,在某些特殊情况下,Nginx 二进制签名可能不可靠 (可能因为某些 3 方模块引入的缺陷等) 。因此,我放弃了简化 Nginx 编译配置的想法。

最可靠的办法还是使用与编译 Nginx 时完全相同的环境和编译配置来编译动态模块

给对 Nginx 编译配置感兴趣的同学留一个练习题: Nginx 配置脚本有一个 --with-compat 选项,这个选项的作用是什么?

自动编译脚本与 Docker 标准开发环境

为了支持多种操作系统和 Nginx 编译版本,自动化编译构建流程自然是必不可少的。

目前我们使用 Docker 镜像为 Ubuntu, CentOS 和 Alpine Linux 等维护了多套标准开发环境,并编写了自动化构建脚本,使用一条命令即可为指定 Nginx 版本构建对应的 Sentinel Nginx 动态模块 .so 文件。

现在主要需要人工介入的地方在于,一些 Nginx 编译版本使用了特定的程序库或工具时,需要在开发环境镜像中增加安装这些库或工具,一些 Nginx 编译版本使用了额外的 3 方模块时,需要找到并拉取这些 3 方模块的源码以保证编译环境完全一致。如最近有用户使用了一个叫 "宝塔面板" 的产品一键安装的 Nginx ,这是个定制编译的 Nginx 版本,使用了外部 3 方模块,而用户也没有这些 3 方模块的源码,最终我们通过查看 "宝塔面板" 的 Nginx 安装脚本找到了这些 3 方模块源码的下载地址。

未来: 系统化 OR 开源

从本质上说,因为 Nginx 没有像 Java 那样提供标准的二进制编程接口 (ABI) ,我们不得不为每个 Nginx 编译版本提供定制支持,并且随着 Nginx 编译版本的数量增加,维护成本也将增加,现在的维护方式肯定不是长久之计。

一种方案是将现在的维护方式系统化,为用户提供一个打包页面,输入操作系统版本和 nginx -V (大写的 V) 的输出,系统解析 Nginx 版本和编译配置后,自动编译好对应的 .so 文件给用户下载。

另一种方案是开源,将 Sentinel Nginx 模块源码和编译脚本提供给用户,用户在 Nginx 开发环境下执行编译脚本即可一键编译好对应的 .so 文件。

最后,如果用户都迁移到使用官方预编译的 Nginx 版本,我们就可以只针对官方预编译的 Nginx 版本提供预编译的 Sentinel Nginx 模块即可。

上一篇:【Unix 网络编程】TCP 客户/服务器简单 Socket 程序


下一篇:让运维更高效:关于ECS系统事件