Nginx 与 OpenResty:从编译调优到 CDN 边缘的 Lua 定制

1791 字
9 分钟
Nginx 与 OpenResty:从编译调优到 CDN 边缘的 Lua 定制

关于这篇文章:最初写于 2018 年,当时只是一份 Nginx 编译和常用命令的记录。2026 年 10 月重写:第一到三节保留了当年的内容(压测数据也是 2018 年的),并修正了过时的配置;第四到六节是新增的,来自在中国电信、百度云 CDN 工作期间做 Nginx + Lua 定制开发的经验。

一、编译安装#

需要第三方模块(比如 nginx-rtmp-module)时,只能从源码编译。以 CentOS 7 为例:

Terminal window
# 依赖:正则(rewrite、location 正则匹配)、zlib(gzip)、openssl(https)
yum install -y gcc pcre-devel zlib-devel openssl-devel
wget http://nginx.org/download/nginx-1.12.2.tar.gz
tar zxvf nginx-1.12.2.tar.gz && cd nginx-1.12.2
./configure --prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-debug \
--add-module=../nginx-rtmp-module # 按需添加第三方模块
make && make install

现在更推荐直接用 OpenResty:它是 Nginx 加上 LuaJIT 和一组常用 Lua 库的发行版,后面的 Lua 定制都基于它。Lua 第三方库可以用 OpenResty 自带的 opm 安装,或者用 LuaRocks 指定 OpenResty 的 LuaJIT 编译安装:

Terminal window
./configure --with-lua=/usr/local/openresty/luajit \
--with-lua-include=/usr/local/openresty/luajit/include/luajit-2.1/
make && make install

二、常用命令与 gzip#

Terminal window
nginx -t # 检查配置语法,改完配置先跑这个
nginx -s reload # 平滑重载:新 worker 接新请求,老 worker 处理完再退出
nginx -s quit # 优雅退出
nginx -V # 查看编译参数和已编译的模块

gzip 配置。当年的版本把 jpeg、png 也加进了 gzip_types,这是个常见错误:图片本身已经压缩过,再 gzip 只会白白消耗 CPU。

gzip on;
gzip_min_length 1k; # 太小的响应压缩不划算
gzip_comp_level 4; # 1-9,超过 5 收益很小、CPU 开销明显
gzip_types text/plain text/css application/javascript application/json application/xml;
gzip_vary on; # 加 Vary: Accept-Encoding,避免缓存把压缩版给不支持的客户端

三、性能调优:一次简单的压测#

2018 年在一台 4 核 2G 的虚拟机上,用 webbench 对一个静态文件做过压测。Nginx 关键配置:

worker_processes auto; # 等于 CPU 核数,当时手写的是 4
events {
worker_connections 1024; # 单个 worker 的最大连接数
}
并发客户端持续时间成功请求数每秒请求数
50010 s164,54816,454
1,00010 s198,59319,859
2,00010 s190,46319,046
5,00010 s182,66218,266
10,00010 s161,92716,192

几个结论:

  • 吞吐在 1000 并发左右到顶,约 2 万 QPS,再加并发反而下降。说明这台机器已经到了处理上限,继续加并发只会增加连接管理和调度的开销。
  • worker_processes × worker_connections 是理论最大连接数,并发超过它会直接报错;同时要调高系统的文件句柄限制(ulimit -n 和 worker_rlimit_nofile)。
  • 这只是静态小文件的结果。真实 CDN 节点上,磁盘 IO、TLS 握手、回源才是大头,不能用这个数字去估容量。

webbench 现在已经很少用了,同类压测更推荐 wrk,它支持 Lua 脚本构造请求。

四、OpenResty 的请求处理阶段#

Nginx 把一个请求的处理分成多个阶段,OpenResty 允许在每个阶段挂 Lua 代码。写定制逻辑之前,先想清楚它该放在哪个阶段:

阶段指令典型用途
rewriterewrite_by_lua_block改写 URI、参数
accessaccess_by_lua_block鉴权、防盗链、限流,不通过就 ngx.exit(403)
contentcontent_by_lua_block直接生成响应(不走 proxy_pass 时)
header_filterheader_filter_by_lua_block改响应头,比如加 CORS、改缓存头
body_filterbody_filter_by_lua_block改响应体,流式处理
loglog_by_lua_block统计、打点,不影响响应时间

在 CDN 节点上,客户定制几乎都落在 rewrite 和 access 两个阶段:请求进来,先改写、再鉴权,通过之后才查缓存或者回源。

五、CDN 边缘的 Lua 定制实践#

在百度云 CDN 时,我参与过 Nginx 层的客户定制开发。下面挑三类典型需求,用简化版代码说明原理(生产代码还要处理配置下发、异常和监控,这里省略)。

1. 防盗链:Referer 校验 + 带时效的签名 URL#

最简单的防盗链是 Nginx 自带的 Referer 白名单,但 Referer 可以伪造。更可靠的是签名 URL:客户的业务服务器用密钥给 URL 签名并带上过期时间,CDN 节点用同一个密钥校验。

location /video/ {
access_by_lua_block {
local args = ngx.req.get_uri_args()
local t, sign = tonumber(args.t), args.sign
if not t or not sign then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 过期检查
if t < ngx.time() then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 签名 = md5(密钥 + 路径 + 过期时间),密钥按域名配置下发
local secret = ngx.var.auth_secret
if ngx.md5(secret .. ngx.var.uri .. t) ~= sign then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
}
proxy_pass http://origin;
}

注意:签名参数要从缓存 key 里去掉,否则同一个文件每个签名都是一份缓存,命中率会很难看。

2. URI 改写#

比如客户把旧路径迁移到新路径,或者要把带版本号的 URL 归一成同一份缓存:

rewrite_by_lua_block {
-- /static/v123/app.js -> /static/app.js
local uri, n = ngx.re.sub(ngx.var.uri, [[^/static/v\d+/]], "/static/", "jo")
if n > 0 then
ngx.req.set_uri(uri)
end
}

ngx.re 的 "jo" 参数表示开启 PCRE JIT 并缓存编译后的正则,热点路径上性能差别很明显。

3. 回源 AWS S3 的 V4 签名鉴权#

当时有客户的源站是私有的 AWS S3 桶,CDN 回源时必须按 AWS Signature Version 4 签名,否则 S3 返回 403。这个功能做成了一个 Lua 插件,region、AccessKey、SecretKey、回源域名都按域名动态配置。

V4 签名的计算分四步:

  1. 规范请求(Canonical Request):把方法、URI、查询参数、参与签名的请求头按固定格式拼起来;
  2. 待签字符串(String to Sign):算法名 + 时间 + 凭证范围(日期/region/s3/aws4_request)+ 规范请求的 SHA256;
  3. 派生签名密钥:用 "AWS4" + SecretKey 对日期、region、服务名、aws4_request 逐层做 HMAC-SHA256;
  4. 签名:用派生密钥对待签字符串做 HMAC-SHA256,结果写进 Authorization 头。

简化后的 Lua 实现(HMAC 用的是 lua-resty-hmac 库,回源只用 GET,所以 payload 用 UNSIGNED-PAYLOAD):

local resty_sha256 = require "resty.sha256"
local str = require "resty.string"
local hmac = require "resty.hmac"
local function sha256_hex(s)
local h = resty_sha256:new()
h:update(s)
return str.to_hex(h:final())
end
local function hmac_sha256(key, msg)
return hmac:new(key, hmac.ALGOS.SHA256):final(msg) -- 返回二进制
end
local ak, sk = ngx.var.s3_ak, ngx.var.s3_sk
local region = ngx.var.s3_region
local host = ngx.var.s3_host -- bucket.s3.<region>.amazonaws.com
local amz_date = os.date("!%Y%m%dT%H%M%SZ", ngx.time())
local date = amz_date:sub(1, 8)
local payload = "UNSIGNED-PAYLOAD"
-- 1. 规范请求(URI 需按 AWS 规则逐段编码,查询参数需排序,这里省略)
local signed_headers = "host;x-amz-content-sha256;x-amz-date"
local canonical = table.concat({
"GET",
ngx.var.uri,
ngx.var.args or "",
"host:" .. host .. "\nx-amz-content-sha256:" .. payload .. "\nx-amz-date:" .. amz_date .. "\n",
signed_headers,
payload,
}, "\n")
-- 2. 待签字符串
local scope = date .. "/" .. region .. "/s3/aws4_request"
local string_to_sign = "AWS4-HMAC-SHA256\n" .. amz_date .. "\n" .. scope .. "\n" .. sha256_hex(canonical)
-- 3. 派生签名密钥
local k = hmac_sha256("AWS4" .. sk, date)
k = hmac_sha256(k, region)
k = hmac_sha256(k, "s3")
k = hmac_sha256(k, "aws4_request")
-- 4. 签名并设置回源请求头
local signature = str.to_hex(hmac_sha256(k, string_to_sign))
ngx.req.set_header("x-amz-date", amz_date)
ngx.req.set_header("x-amz-content-sha256", payload)
ngx.req.set_header("Authorization", "AWS4-HMAC-SHA256 Credential=" .. ak .. "/" .. scope
.. ", SignedHeaders=" .. signed_headers .. ", Signature=" .. signature)

配合的 Nginx 配置要注意两点:回源的 Host 必须和签名里的一致,所以要 proxy_set_header Host $s3_host;另外 S3 只接受和服务端时间相差 15 分钟以内的签名,节点时钟一旦漂移,就会出现间歇性 403,排查起来很隐蔽。

六、调试 Lua#

线上排查主要靠 ngx.log(ngx.ERR, ...) 打日志,配合 error_log 的级别控制。本地开发想单步调试,可以用 ZeroBrane Studio,它支持远程调试 OpenResty 里的 Lua 代码;在没有图形界面的 Linux 服务器上,可以通过 ssh -X 把界面转发到本机。

参考#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Nginx 与 OpenResty:从编译调优到 CDN 边缘的 Lua 定制
https://tracerzzz.cn/posts/nginx-usage/
作者
tracerZzz
发布于
2018-01-15
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
指标异动归因:从贡献度公式到 CDN 毛利率的层层下钻
工作整理我学习指标异动归因的过程:加法、乘法、比率三类指标分别怎么算贡献度,为什么比率指标要拆出「结构变化」,以及我在字节火山 CDN 经营分析中,如何用这套方法对毛利率、收入、成本做逐层递归归因,把收入变化拆到客户增减、用量变化和价格变化。
2
CDN 是怎么收费的:从流量、95 峰值到日峰月均,厂商和客户两边看计费
工作我在百度云 CDN 做过客户账单,在字节做过火山 CDN 的计费推量,也做过融合 CDN 向多家厂商采购的对账和成本分摊,厂商和采购两边都经历过。这篇整理 CDN 常见的计费方式:流量、带宽峰值、95 峰值、日峰月均、月第四峰值、夜间减半、闲忙时、请求数,以及计费区域、包头系数这些容易忽略的细节,并用一个月的模拟数据对比不同方式算出来差多少。
3
CDN 的错峰与超卖:为什么厂商能把同一份带宽卖好几次
工作在 95 计费下,一个新客户接入后,大盘的 95 可能几乎不涨,这就是错峰客户。它让 CDN 厂商可以把同一份带宽卖给多个客户,也就是超卖。这篇用模拟数据讲清楚错峰和超卖,以及衡量它们的两个指标:单个客户的边际超卖倍数,和整个平台的超售比。
4
CDN 新客户接入前的价格测算:用波形拟合算出最低成本价
工作火山 CDN 经营分析平台上的价格测算功能:销售拿到一个新客户的带宽打点,平台把它和大盘拟合,判断客户是否错峰、算出边际超卖倍数,再结合资源诉求的加权成本和包头系数,给出不同计费方式下的最低成本价。这篇讲它的输入、计算过程、示例和后续的优化方向。
5
一个接口管多家 CDN:多厂商刷新预热的适配层设计
工作我在字节负责火山多云 CDN 和融合 CDN 的多厂商内容管理(刷新、预热)。这篇讲怎么用适配器模式屏蔽厂商差异,怎么在多云层做配额和限频,怎么按每家厂商的限制拆分任务,怎么用 RocketMQ、Redis、MongoDB 实现分布式下发、共享配额、重试和进度汇总,以及后来如何借助 AI 把新厂商接入时间缩短到一两天。
随机文章随机推荐

评论区

Profile Image of the Author
tracerZzz
想当木匠的程序员!
分类
标签
站点统计
文章
19
分类
9
标签
48
总字数
79,096
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
ESA Pages
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录