在中文网络之中,存在着大量的陈旧内容,包括并不限于各种只能在特定环境中一次性安装使用的陈旧软件,Nginx 编译安装的内容尤甚。

在继续 Nginx NJS 实战之前,我们可以先了解下如何相对快速、安全的使用 Nginx 三方模块。

写在前面

本文中的三方模块相关代码,已经提交至开源仓库:https://github.com/soulteary/prebuilt-nginx-modules,欢迎自取或者贡献你觉得还不错的模块。

《Nginx 模块系统:前篇》中,我提到过 Nginx 动态模块的来龙去脉,不了解的同学可以自行补习下前置内容。

在聊如何高效使用前,首先需要知道如何高效的“制作”这些模块。

Nginx 的模块编译通用逻辑

想要了解如何高效的构建 Nginx 模块,首先需要了解下什么是 Nginx 模块编译的通用逻辑。

编译一个 Nginx 模块一般只需要三个步骤:

  • 第一步:准备源代码
    • 获取某个指定版本的 Nginx 代码,以及对应的模块代码,进行简单的处理,调整代码目录结构和名称,留作后用。
  • 第二步:准备系统环境
    • 安装目标运行环境(如 Linux)的各种开发依赖,确保代码编译依赖满足,可以进行后续的编译流程。
  • 第三步:调整编译参数和编译模式
    • 调整 Nginx 编译参数,以及设置模块编译模式,选择进行静态模块或者动态模块编译操作,并等待编译结果顺利完成。

基于容器环境进行实战

使用 Docker 和 Nginx 打造高性能二维码服务(二) 一文中,我提到过:

“之前构建服务的时候,采用的是使用通用基础镜像编译 Nginx 和它的“小伙伴”(模块),在三年后的今天,我们不妨直接使用 Nginx 基础镜像,所谓“原汤化原食”,最大限度复用官方提供的环境、配置参数、入口脚本…毕竟,偷懒是工程师的美德。”

所以,这次我们也使用官方的容器环境来进行编译和构建操作。

构建基础编译镜像

参考官方 Dockerfile ,不难写出类似下面的基础编译环境的 Dockerfile:

ARG NGINX_VERSION=1.19.7
FROM nginx:${NGINX_VERSION}-alpine
# Mirror
# RUN cat /etc/apk/repositories | sed -e "s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/" | tee /etc/apk/repositories

ARG NGINX_SHASUM=0dde53b5a948efc9dc852814186052e559d190ea
RUN apk add --no-cache --virtual .build-deps gcc libc-dev make openssl-dev pcre-dev zlib-dev linux-headers libxslt-dev gd-dev geoip-dev perl-dev libedit-dev mercurial bash alpine-sdk findutils && \
    mkdir -p /usr/src && cd /usr/src && \
    curl -L "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" -o nginx.tar.gz && \
    echo "$NGINX_SHASUM  nginx.tar.gz" | shasum -c && \
    tar -zxC /usr/src -f nginx.tar.gz && \
    cd /usr/src && \
    mv /usr/src/nginx-$NGINX_VERSION /usr/src/nginx && \
    CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
    CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \
    export CONFARGS=$CONFARGS;

上面的镜像支持在构建时传递参数,并将官方镜像中的构建命令最大程度复用到接下来的模块构建中。

如果你想编译目前最新的 1.19.8,或者未来的版本,只需要在构建的时候传递 NGINX_VERSIONNGINX_SHASUM 给 docker 就好了,为了能够将执行过程变为代码的一部分进行长时间的维护管理,这里使用 .env 文件对我们的构建参数进行存储。

NGINX_VERSION=1.19.8
NGINX_SHASUM=c60654a70bea0a9bbc009b83cd95e1e76f0dd7ec

同样,为了将构建命令也一同保持下来,我们可以写一个简单的构建脚本:

#!/bin/bash

RELEASE_DIR="./baseImage";
REPO_NAME="soulteary/prebuilt-nginx-modules"

set -a
    . "$RELEASE_DIR/.env"
set +a

TAG=base-$NGINX_VERSION-alpine;
DIST=$REPO_NAME:$TAG

echo "Build: $DIST";

echo $NGINX_VERSION:$NGINX_SHASUM
echo "docker build --build-arg NGINX_VERSION=$NGINX_VERSION --build-arg NGINX_SHASUM=$NGINX_SHASUM --tag $DIST -f "$RELEASE_DIR/Dockerfile" ."
docker build --build-arg NGINX_VERSION="$NGINX_VERSION" --build-arg NGINX_SHASUM="$NGINX_SHASUM" --tag $DIST -f "$RELEASE_DIR/Dockerfile" .

假设我们将上面的 Dockerfile.env 都放置于一个名为 ** baseImage**的目录中,并将上面的脚本保存为 build.sh

接着执行这个脚本,不需多久,便能够得到一个基于 Nginx 某个确切版本的构建环境镜像。

使用容器编译 Nginx 模块

有了构建环境,编译模块的步骤将能大幅简化,以常用的 Nginx 三方模块“headers-more-nginx-module”为例子,基于前文中的构建环境,我们编写一个模块构建脚本也很容易:

ARG NGINX_VERSION=1.19.7
FROM soulteary/prebuilt-nginx-modules:base-${NGINX_VERSION}-alpine AS Builder

ARG MODULE_CHECKSUM=7d6af910dae98f0dbc67bf77e82eab8b7da5d0b1
ARG MODULE_VERSION=0.33
ARG MODULE_NAME=headers-more-nginx-module

RUN cd /usr/src && \
    curl -L "https://github.com/openresty/headers-more-nginx-module/archive/v${MODULE_VERSION}.tar.gz" -o "v${MODULE_VERSION}.tar.gz" && \
    echo "${MODULE_CHECKSUM}  v${MODULE_VERSION}.tar.gz" | shasum -c && \
    tar -zxC /usr/src -f v${MODULE_VERSION}.tar.gz && \
    cd /usr/src && \
    mv ${MODULE_NAME}-${MODULE_VERSION}/ ${MODULE_NAME} && \
    cd /usr/src/nginx && \
    CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
    CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \
    echo $CONFARGS && \
    ./configure --with-compat $CONFARGS --add-dynamic-module=../${MODULE_NAME}/ && \
    make modules

FROM scratch

COPY --from=Builder /usr/src/nginx/objs/ngx_http_headers_more_filter_module.so /

这个 Dockerfile 主要分为三部分,我们来详细讲解下各部分的职责。

第一部分,负责抽象我们使用的 Nginx 环境和代码版本,以及我们使用的模块名称、版本、代码包校验和,为我们今后能够尽可能少的写代码来维护镜像做准备。

ARG NGINX_VERSION=1.19.7
FROM soulteary/prebuilt-nginx-modules:base-${NGINX_VERSION}-alpine AS Builder

ARG MODULE_CHECKSUM=7d6af910dae98f0dbc67bf77e82eab8b7da5d0b1
ARG MODULE_VERSION=0.33
ARG MODULE_NAME=headers-more-nginx-module

第二部分,准备源代码、调整代码目录结构、复用官方构建命令、对模块进行静态编译。

RUN cd /usr/src && \
    curl -L "https://github.com/openresty/headers-more-nginx-module/archive/v${MODULE_VERSION}.tar.gz" -o "v${MODULE_VERSION}.tar.gz" && \
    echo "${MODULE_CHECKSUM}  v${MODULE_VERSION}.tar.gz" | shasum -c && \
    tar -zxC /usr/src -f v${MODULE_VERSION}.tar.gz && \
    cd /usr/src && \
    mv ${MODULE_NAME}-${MODULE_VERSION}/ ${MODULE_NAME} && \
    cd /usr/src/nginx && \
    CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
    CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \
    echo $CONFARGS && \
    ./configure --with-compat $CONFARGS --add-dynamic-module=../${MODULE_NAME}/ && \
    make modules

第三部分,使用一个特殊的空镜像,将我们的构建产物保留,以供未来生产环境的镜像快速复用。

FROM scratch
COPY --from=Builder /usr/src/nginx/objs/ngx_http_headers_more_filter_module.so /

和处理基础编译镜像一样,我们同样可以使用独立的 .env 文件来持久化构建参数,比如下面这样:

NGINX_VERSION=1.19.8
MODULE_CHECKSUM=7d6af910dae98f0dbc67bf77e82eab8b7da5d0b1
MODULE_VERSION=0.33
MODULE_NAME=headers-more-nginx-module

完成批量模块的构建

为了能够对多个模块进行构建管理,我们来了解下如何编写“支持多个模块构建”的构建脚本。假设项目目录结构类似下面的形式:

.
├── README.md
├── baseImage
│   └── Dockerfile
├── make-base.sh
├── make-image.sh
├── modules
│   ├── echo
│   │   └── Dockerfile
│   ├── headers-more
│   │   └── Dockerfile
│   ├── http-redis
│   │   └── Dockerfile
│   ├── memc
│   │   └── Dockerfile
│   ├── misc
│   │   └── Dockerfile
│   ├── redis2
│   │   └── Dockerfile
│   ├── srcache
│   │   └── Dockerfile
│   └── waf
│       └── Dockerfile
└── push-image.sh

我们对之前的“基础编译环境”的构建脚本进行适当调整和修改,可以得到支持批量构建模块的脚本:

#!/bin/bash

RELEASE_DIR="./modules";
REPO_NAME="soulteary/prebuilt-nginx-modules"

for moduleName in $RELEASE_DIR/*; do
    set -a
        . "$moduleName/.env"
    set +a

    tag=$(echo $moduleName | cut -b 11-);
    BUILD_NAME="$REPO_NAME:$tag-$NGINX_VERSION-alpine"
    echo "Build: $BUILD_NAME";
    BUILD_ARGS=$(tr '\n' ';' < "$moduleName/.env" | sed 's/;$/\n/' | sed 's/^/ --build-arg /' | sed 's/;/ --build-arg /g')

    docker build $BUILD_ARGS --tag $BUILD_NAME -f $moduleName/Dockerfile .
done

然后,只要执行这个脚本,就能够根据每个模块的不同配置信息,构建出可复现的稳定结果啦。

基于容器快速使用 Nginx 三方模块

目前为止,我们已经了解了如何在容器内快速编译构建 Nginx 三方模块,接下来我们可以步入正题,如何快速使用这些模块。

假设我们现在需要一个能够直接返回简单 JSON 的接口,接口包含当前服务器端端时间,并且这个接口有很高的调用压力,诸如活动、秒杀等场景的高频调用,可以使用 Nginx 借助 Nginx Echo 和 Set Misc 模块来进行实现。

相关代码已上传至 https://github.com/soulteary/docker-nginx-time-api,可以自行获取。

编写使用预编译模块的容器文件

《从封装 Nginx NJS 工具镜像聊起》一文中,我曾提到过如何使用二阶段构建保存动态模块和它的依赖。

这里,我们使用预构建模块也非常简单,只需要将编译好的模块文件复制到目标镜像即可:

FROM nginx:1.19.8-alpine

COPY --from=soulteary/prebuilt-nginx-modules:misc-1.19.8-alpine     /ndk_http_module.so                 /etc/nginx/modules/
COPY --from=soulteary/prebuilt-nginx-modules:misc-1.19.8-alpine     /ngx_http_set_misc_module.so        /etc/nginx/modules/
COPY --from=soulteary/prebuilt-nginx-modules:echo-1.19.8-alpine     /ngx_http_echo_module.so            /etc/nginx/modules/

因为 Set Misc 模块依赖 NDK 模块,所以这里要复制三个文件。将上面的内容保存为 Dockerfile,然后执行 docker build -t nginx-time-api:1.19.8-alpine . 构建第一个基于预编译模块的 Nginx 镜像。

接着以官方镜像中的 Nginx 为模版,编写一个简单的 Nginx 配置文件:

load_module modules/ndk_http_module.so;
load_module modules/ngx_http_set_misc_module.so;
load_module modules/ngx_http_echo_module.so;

user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;
    sendfile on;
    keepalive_timeout 65;

    server {
        listen 80;
        server_name localhost;

        charset utf-8;
        gzip on;

        location / {
            default_type application/json;
            set_formatted_gmt_time $timestr "%a %b %e %H:%M:%S %Y GMT";
            echo $timestr;
        }

    }
}

将上面的内容保存为 nginx.conf,为了验证方便,我们再编写一个简单容器编排脚本:

version: '3'

services:

  ngx-time-api:
    image: nginx-time-api:1.19.8-alpine
    ports:
      - 8080:80
    volumes:
      # 在 Linux 环境中需要使用
      # - /etc/localtime:/etc/localtime:ro
      # - /etc/timezone:/etc/timezone:ro
      - ./nginx.conf:/etc/nginx/nginx.conf

将上面的内容保存为 docker-compose.yml,然后使用 docker-compose down && docker-compose up 启动容器,访问 127.0.0.1:8080,不出意外,将看到一条类似:Mon Mar 22 05:32:50 2021 GMT 的内容。

到这里为止,我们就已经完成了“打印服务端时间”的接口应用啦。

进行不严谨的性能测试

这里就不使用 ab 来进行“鲁大师”测试了,我们直接使用 wrk 做一个简单测试,可以看到在容器环境下,经过 NAT 转发,依旧能够达到每秒 2万 QPS。

wrk -t2 -c 100 -d 10s http://localhost:8080
Running 10s test @ http://localhost:8080
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.26ms    1.77ms  27.69ms   76.80%
    Req/Sec    11.67k     1.21k   13.81k    62.00%
  232312 requests in 10.01s, 44.74MB read
Requests/sec:  23218.65
Transfer/sec:      4.47MB

为了让大家有更直观的了解,我们继续使用运行相对较快的动态语言运行时 Node 进行相同类型的测试。

因为 Nginx 运行在 Alpine 中,为了相对公平,同样使用基于 alpine 3.13 的镜像: node:15.12.0-alpine3.13 。(此处我有试验过 fibjs,结果激动人心,但是为了普适性,这里先不展开,后续有机会我一定会写一些使用 fibjs 实践的内容)

// node 15.12
var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, {'Content-Type': 'text/plain'});
    response.end(new Date()+'');
}).listen(8888);

简单编写一个编排脚本,和 Nginx 一样,使用挂载的方式将文件映射到容器内:

version: '3'

services:

  node:
    image: node:15.12.0-alpine3.13
    ports:
      - 8082:8888
    volumes:
      - ./web.js:/web.js
    command: node /web.js

然后同样使用 wrk 执行测试。

wrk -t2 -c 100 -d 10s http://localhost:8082
Running 10s test @ http://localhost:8082
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    13.12ms   11.55ms 200.41ms   94.13%
    Req/Sec     4.25k     1.27k   10.92k    80.10%
  85035 requests in 10.10s, 18.65MB read
Requests/sec:   8417.80
Transfer/sec:      1.85MB

可以看到,在容器内、有 NAT 转发的情况下进行测试,Node 单机响应在 13 ms,相比相同环境的 Nginx 慢了至少 8ms,响应相比 Nginx 的2万,少了1万5千QPS。

直接在主机内执行 Node

如果我们直接使用主机环境中的 Node ,可以看到性能会跃升到2万6千左右。

wrk -t2 -c 100 -d 10s http://localhost:8888
Running 10s test @ http://localhost:8888
  2 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     3.87ms    1.25ms  40.60ms   95.79%
    Req/Sec    13.13k     1.67k   14.43k    93.07%
  263920 requests in 10.10s, 55.88MB read
Requests/sec:  26128.23
Transfer/sec:      5.53MB

如果使用宿主机运行的“性能释放”是接近线性的,那么请自行脑补 Nginx 在相同的宿主环境运行的结果。

因为 Nginx 的执行文件并非类似 Node 只有一个可执行文件,出于不想污染本地环境,我就不在本地编译使用或者安装 Nginx 了,感兴趣的同学可以自行测试。

其他

接下来简单一些相关联的内容,后面有时间我会单独写成文章,展开聊聊。

目前开箱即用的模块

prebuilt-nginx-modules这个开源仓库中,我们目前有以下常见模块可以直接使用,后续我会根据需求逐步将更多的常用、好用的模块加进来:

  • soulteary/prebuilt-nginx-modules:headers-more-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:http-redis-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:echo-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:set-misc-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:redis2-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:memc-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:srcache-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:base-1.19.8-alpine
  • soulteary/prebuilt-nginx-modules:waf-1.19.8-alpine

简单针对老模块进行动态模块编译

Nginx 发展十余年,许多公司目前还是在宿主机上使用,所以不会提供动态模块,这时我们就需要进行动态模块改造,一般情况下我们只需要调整 config 文件,添加动态模块编译依赖,以及调整编译使用的目标脚本即可。

假设原始的 config 文件内容如下:

ngx_addon_name=ngx_http_hello_world_module

HTTP_MODULES="$HTTP_MODULES ngx_http_hello_world_module"
NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_hello_world_module.c"

如果模块代码不需要针对 Nginx 核心模块进行 Patch,一般情况,只需要简单调整为类似下面的格式,即可完成“动态模块”编译改造,是不是很简单?

ngx_addon_name=ngx_http_hello_world_module

if test -n "$ngx_module_link"; then
  ngx_module_type=HTTP
  ngx_module_name=ngx_http_hello_world_module
  ngx_module_srcs="$ngx_addon_dir/ngx_http_hello_world_module.c"
  . auto/module
else
	HTTP_MODULES="$HTTP_MODULES ngx_http_hello_world_module"
	NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_hello_world_module.c"
fi

最后

好了,还是来总结一下。

本篇文章中,我们了解了 Nginx 模块的通用构建方式、容器环境下相对通用的 Nginx 模块构建文件、如何快速使用预编译的三方模块制作定制的 Nginx 服务、以及针对这种积木模式产生的服务进行了简单的性能测试和对比。

填完了这个坑,下一篇我们可以继续聊聊,NJS 如何在定制过的 Nginx 镜像、环境中和三方模块一起工作,以及 NJS 到底能够干哪些更复杂的活?

–EOF