本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2021年03月22日 统计字数: 11405字 阅读时间: 23分钟阅读 本文链接: https://soulteary.com/2021/03/22/how-to-use-nginx-third-party-modules-efficiently-in-the-container-era.html ----- # 如何在容器时代高效使用 Nginx 三方模块 在中文网络之中,存在着大量的陈旧内容,包括并不限于各种只能在特定环境中一次性安装使用的陈旧软件,Nginx 编译安装的内容尤甚。 在继续 [Nginx NJS](https://soulteary.com/tags/njs.html) 实战之前,我们可以先了解下如何相对快速、安全的使用 Nginx 三方模块。 ## 写在前面 本文中的三方模块相关代码,已经提交至开源仓库:[https://github.com/soulteary/prebuilt-nginx-modules](https://github.com/soulteary/prebuilt-nginx-modules),欢迎自取或者贡献你觉得还不错的模块。 在[《Nginx 模块系统:前篇》](https://soulteary.com/2021/03/05/nginx-module-system-part-1.html)中,我提到过 Nginx 动态模块的来龙去脉,不了解的同学可以自行补习下前置内容。 在聊如何高效使用前,首先需要知道如何高效的“制作”这些模块。 ## Nginx 的模块编译通用逻辑 想要了解如何高效的构建 Nginx 模块,首先需要了解下什么是 Nginx 模块编译的通用逻辑。 编译一个 Nginx 模块一般只需要三个步骤: - **第一步:准备源代码** - 获取某个指定版本的 Nginx 代码,以及对应的模块代码,进行简单的处理,调整代码目录结构和名称,留作后用。 - **第二步:准备系统环境** - 安装目标运行环境(如 Linux)的各种开发依赖,确保代码编译依赖满足,可以进行后续的编译流程。 - **第三步:调整编译参数和编译模式** - 调整 Nginx 编译参数,以及设置模块编译模式,选择进行静态模块或者动态模块编译操作,并等待编译结果顺利完成。 ## 基于容器环境进行实战 在 [使用 Docker 和 Nginx 打造高性能二维码服务(二)](https://soulteary.com/2021/01/07/use-docker-and-nginx-to-build-high-performance-qr-code-services-2.html) 一文中,我提到过: > “之前构建服务的时候,采用的是使用通用基础镜像编译 Nginx 和它的“小伙伴”(模块),在三年后的今天,我们不妨直接使用 Nginx 基础镜像,所谓“原汤化原食”,最大限度复用官方提供的环境、配置参数、入口脚本…毕竟,偷懒是工程师的美德。” 所以,这次我们也使用官方的容器环境来进行编译和构建操作。 ### 构建基础编译镜像 参考官方 Dockerfile ,不难写出类似下面的基础编译环境的 Dockerfile: ```bash 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_VERSION` 和 `NGINX_SHASUM` 给 docker 就好了,为了能够将执行过程变为代码的一部分进行长时间的维护管理,这里使用 `.env` 文件对我们的构建参数进行存储。 ```bash NGINX_VERSION=1.19.8 NGINX_SHASUM=c60654a70bea0a9bbc009b83cd95e1e76f0dd7ec ``` 同样,为了将构建命令也一同保持下来,我们可以写一个简单的构建脚本: ```bash #!/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”为例子,基于前文中的构建环境,我们编写一个模块构建脚本也很容易: ```bash 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 环境和代码版本,以及我们使用的模块名称、版本、代码包校验和,为我们今后能够尽可能少的写代码来维护镜像做准备。 ```bash 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 ``` 第二部分,准备源代码、调整代码目录结构、复用官方构建命令、对模块进行静态编译。 ```bash 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 ``` 第三部分,使用一个特殊的空镜像,将我们的构建产物保留,以供未来生产环境的镜像快速复用。 ```bash FROM scratch COPY --from=Builder /usr/src/nginx/objs/ngx_http_headers_more_filter_module.so / ``` 和处理基础编译镜像一样,我们同样可以使用独立的 `.env` 文件来持久化构建参数,比如下面这样: ```bash NGINX_VERSION=1.19.8 MODULE_CHECKSUM=7d6af910dae98f0dbc67bf77e82eab8b7da5d0b1 MODULE_VERSION=0.33 MODULE_NAME=headers-more-nginx-module ``` ### 完成批量模块的构建 为了能够对多个模块进行构建管理,我们来了解下如何编写“支持多个模块构建”的构建脚本。假设项目目录结构类似下面的形式: ```bash . ├── 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 ``` 我们对之前的“基础编译环境”的构建脚本进行适当调整和修改,可以得到支持批量构建模块的脚本: ```bash #!/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](https://github.com/soulteary/docker-nginx-time-api),可以自行获取。 ### 编写使用预编译模块的容器文件 在[《从封装 Nginx NJS 工具镜像聊起》](https://soulteary.com/2021/01/10/let-us-start-with-the-mirroring-of-the-nginx-njs-tool-package.html)一文中,我曾提到过如何使用二阶段构建保存动态模块和它的依赖。 这里,我们使用预构建模块也非常简单,只需要将编译好的模块文件复制到目标镜像即可: ```bash 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 配置文件: ```bash 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`,为了验证方便,我们再编写一个简单容器编排脚本: ```yaml 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。 ```bash 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 实践的内容) ```js // 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 一样,使用挂载的方式将文件映射到容器内: ```yaml version: '3' services: node: image: node:15.12.0-alpine3.13 ports: - 8082:8888 volumes: - ./web.js:/web.js command: node /web.js ``` 然后同样使用 `wrk` 执行测试。 ```bash 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千左右。 ```bash 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](https://github.com/soulteary/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` 文件内容如下: ```bash 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,一般情况,只需要简单调整为类似下面的格式,即可完成“动态模块”编译改造,是不是很简单? ```bash 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