Nginx 访问日志分析:定位慢请求与异常流量
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
一、默认日志能回答的问题太少了
它能告诉你"谁在什么时候请求了什么、返回了几 K"。它不能回答的是真正需要回答的问题:
我遇到的大部分"Nginx 有问题"的排查,第一步都不是分析日志,而是先改日志格式——因为默认格式里缺了判断所需的关键字段。改格式本身有成本(磁盘、解析、上下游兼容),所以要先想清楚"我要回答哪些问题",再决定加什么字段。 这篇文章解决三段问题:
所有内容基于 nginx 1.30.x 稳定分支(1.30.6 为写作时最新稳定版,2026-10 发布),与 1.28 的差异单独标注。Nginx Plus 的独有功能会明确区分。 二、适用场景与环境假设2.1 适用场景
2.2 不适用或需要额外方案
2.3 版本与环境假设
三、核心知识:日志里每个字段意味着什么3.1 变量清单与可用时机
最容易混淆的一对:
所以:
3.2 多值字段:为什么会有逗号
示例(一次重试):
解读:第一台后端立刻返回 502(0.001s),Nginx 重试第二台成功(0.032s)。用户看到 200,但日志记录了故障。只看 统计时要特别小心:直接对 3.3 |
| 成本项 | 影响 | 量级 |
|---|---|---|
| 变量求值 | 每请求多一次格式化 | 单变量约 1~3ns,10 个字段约 20ns,QPS 1 万时可忽略 |
| 磁盘 IO | 每行多几十字节 | 每请求 100 字节,QPS 1 万 = 约 86GB/天 |
| 日志轮转 | 大文件 copytruncate 会阻塞 | 用 mv 后 USR1 重开,不能 copytruncate |
| 下游解析 | JSON 解析比正则快,字段多了内存涨 | ES 单文档字段别超 1000 |
实践建议:先用格式 A 跑一周,看确切的体积增量(du -sh 前后对比),再决定要不要上 JSON 全字段。
如果前面有 CDN/负载均衡,$remote_addr 是代理 IP,所有分析都会失真。用 ngx_http_realip_module:
nginx # 只信任代理来源
set_real_ip_from 10.20.0.0/16;
set_real_ip_from 203.0.113.10;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
关键点:
set_real_ip_from 必须精确,写成 0.0.0.0/0 等于让任何人伪造 IP;X-Real-IP 时取的是最后一个代理的写法(单层代理更安全);用 X-Forwarded-For 时要配 real_ip_recursive on,否则取最左边(可伪造);$remote_addr 变成真实 IP,但 $http_x_forwarded_for 保留原始串。风控分析应使用 $remote_addr(realip 处理后的),而不是 XFF。验证:
bash curl -s -H 'X-Forwarded-For: 1.2.3.4' http://10.20.30.40/healthz
tail -n 1 /var/log/nginx/access.log
如果日志里出现 1.2.3.4,说明 set_real_ip_from 写宽了,立刻收窄。
"接口慢"是最没有信息量的一句话。定位前必须把它变成可度量的东西:
| 问题 | 需要确认 |
|---|---|
| 多慢? | P50 正常但 P99 爆炸,还是整体平移? |
| 哪些请求? | 某个 URI、某类参数、某个客户端、某个上游实例? |
| 什么时候开始? | 突变(发布/配置变更/流量上涨)还是渐变(数据量增长)? |
| 用户可感知吗? | 前端超时是 5s 还是 30s?慢到超时的请求在日志里是 499 还是 504? |
四个问题答不上来就别开始翻日志,翻了也白翻。
平均值会被两件事骗:一是海量快请求把慢请求稀释掉,二是长时间挂起的少数请求拉高均值却看不出比例。生产上只看三个数:P50、P95、P99,必要时加最大值和超阈值比例。
bash # 提取 rt= 数值,计算 P50/P95/P99/最大值/慢请求占比
LOG=/var/log/nginx/access.log
THRESH=1.0 # 慢请求阈值,秒
awk -v t="$THRESH" '
{
for (i=1; i<=NF; i++) {
if ($i ~ /^rt=/) { v = substr($i, 4); break }
}
if (v == "" || v == "-") next
a[++n] = v + 0
if (v + 0 > t) slow++
sum += v + 0
}
END {
if (n == 0) { print "no data"; exit }
asort(a)
# 下标夹紧到 [1, n],避免样本很小时取到未赋值的空元素
p50 = a[(int(n*0.50) < 1 ? 1 : int(n*0.50))]
p95 = a[(int(n*0.95) < 1 ? 1 : int(n*0.95))]
p99 = a[(int(n*0.99) < 1 ? 1 : int(n*0.99))]
printf "count=%d avg=%.3fs p50=%.3fs p95=%.3fs p99=%.3fs max=%.3fs over%.1fs=%.2f%%\n", \
n, sum/n, p50, p95, p99, a[n], t, slow*100/n
}' "$LOG"
三个必须注意的点:
asort() 是 gawk 的扩展,mawk(Ubuntu/Debian 的默认 awk)不支持。Ubuntu 上要装 gawk 并显式调用 gawk,或者改用 sort -n 管道。sort 精确算。不依赖 gawk 的写法:
bash # 纯 sort 方案:更慢但通用
grep -o 'rt=[0-9.]*' "$LOG" | cut -d= -f2 | sort -n | awk '
{ a[NR]=$1; s+=$1 }
END {
if (NR==0) { print "no data"; exit }
printf "count=%d avg=%.3fs p50=%.3fs p95=%.3fs p99=%.3fs max=%.3fs\n", \
NR, s/NR, a[int(NR*0.5)], a[int(NR*0.95)], a[int(NR*0.99)], a[NR]
}'
注意 sort -n 对 0.023 这类小数是正确的(按数值排),但不要用 sort -g 之外的可疑变体,也不要在有大量数据时反复 sort 同一个文件——一次排序结果喂给 awk 就够。
按小时看趋势:
bash awk '
{
# 时间字段形如 [10/Oct/2026:14:23:01 +0800]
hr = substr($4, 14, 2)
for (i=1; i<=NF; i++) if ($i ~ /^rt=/) { v = substr($i, 4) + 0; break }
if (v == "") next
key = hr
cnt[key]++
sum[key] += v
if (v > max[key]) max[key] = v
if (v > 1.0) slow[key]++
}
END {
printf "%-4s %-10s %-10s %-10s %-10s\n", "hour", "count", "avg", "max", "over1s%"
for (h = 0; h <= 23; h++) {
k = sprintf("%02d", h)
if (cnt[k] == 0) continue
printf "%-4s %-10d %-10.3f %-10.3f %-10.2f\n", k, cnt[k], sum[k]/cnt[k], max[k], slow[k]*100/cnt[k]
}
}' "$LOG"
这张表能立刻把"全天都慢"和"某个时段慢"分开。如果只有某一小时慢,去查那一小时发生了什么(发布、定时任务、上游扩容、备份、大促)。如果全天均匀变慢,那是容量或上游代码问题。
bash # 按 URI 聚合:请求数、慢请求数、慢请求占比、平均耗时,按慢请求数降序
awk '
{
uri = ""; rt = 0
for (i=1; i<=NF; i++) {
if ($i ~ /^uri=/) { uri = substr($i, 5) }
else if ($i ~ /^rt=/) { rt = substr($i, 4) + 0 }
}
if (uri == "") next
# $uri 本身不带 query,这里再兜一层,兼容从请求行取 path 的写法
sub(/\?.*/, "", uri)
# 数字 ID 归一化,避免每个资源 ID 变成一个桶
gsub(/\/[0-9]+(\/|$)/, "/:id\\1", uri)
cnt[uri]++
sum[uri] += rt
if (rt > 1.0) slow[uri]++
}
END {
for (u in cnt) {
if (cnt[u] < 10) continue # 过滤长尾,太少样本说明不了问题
printf "%-55s %8d %8d %7.2f%% %8.3f\n", u, cnt[u], slow[u], slow[u]*100/cnt[u], sum[u]/cnt[u]
}
}' "$LOG" | sort -k3,3nr | head -30
gsub(/\/[0-9]+(\/|$)/, "/:id\\1", uri) 这一步很关键。不做归一化的话,/api/order/1001、/api/order/1002 会各自成为一个桶,永远凑不够 10 个样本,真正的慢接口反而被过滤掉。同理,UUID、长数字 ID 也要归一化:
awk gsub(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, "/:uuid", uri)
上面用的是 uri=$uri 字段。如果日志里没有它,只能从请求行里按位置切($7 是 "GET /path HTTP/1.1" 里的 path)。那个位置依赖 $remote_user 是否为空——空时 - 占一个字段,$7 恰好是 path;一旦有值(基本认证场景),列号整体偏移一位,脚本就会把 HTTP 版本当成 URI 统计。
这就是要加 uri=$uri 的理由:$uri 是 Nginx 规范化后的路径(已解码 %XX、已合并 //、已处理 ../),准确且稳定,成本一个字段。
这是整个分析里最重要的一张判断表。前提:$request_time 是从读到客户端第一个字节开始、到写回响应最后一个字节结束;$upstream_response_time 是从与上游建立连接开始、到收到上游响应体结束。所以:
| rt | urt | 含义 | 下一步查什么 |
|---|---|---|---|
| 大 | 大(接近 rt) | 上游本身慢 | 应用日志、APM、上游 P99、数据库慢查询 |
| 大 | 小 | 时间花在 Nginx 或客户端 | 见下面细分 |
| 小 | 大 | 理论上少见,多半是 rt 计时起点更早 | 检查是否 proxy_buffering off、是否大量小请求排队 |
| 大 | - | 没有走上游 | 静态文件、try_files 落盘、return、限流拒绝 |
| 大 | 多值 | 发生过重试 | 拆开看每个实例的耗时,重试本身会累加 rt |
rt 大而 urt 小的几种具体成因:
| 现象 | 证据 | 说明 |
|---|---|---|
| 客户端下载慢 | body_bytes_sent 大,rt - urt 接近传输时间 | 客户端带宽小、移动网络差。不是服务端问题,但会影响并发连接数 |
| 客户端上传慢(慢速攻击) | $request_length 大、rt 大、urt 小或 - | 请求体慢慢发,占着 worker 连接。查 client_body_timeout/client_header_timeout |
proxy_buffering off | 配置里关了 buffering | 客户端慢会直接反压到上游,urt 也会被拉长 |
| 磁盘慢(静态资源) | urt 为 -,URI 是静态路径 | iostat -x 1 看 await、%util;检查页缓存命中 |
| 日志/写盘阻塞 | 磁盘满、access_log 到慢盘 | df -h、iostat,同步写盘会卡住 worker |
limit_req 延迟模式 | $limit_req_status=DELAYED | 请求被主动延迟,rt 变大而 urt 正常。这是预期行为,不是故障 |
最后一条经常被误判。如果你用了 limit_req ... nodelay,超限请求会立刻 503,记为 REJECTED;不加 nodelay 则会排队延迟,rt 会明显变大。看到 rt 大但 urt 小、同时 $limit_req_status 有值,先怀疑限流排队。
判断示例:
bash # 找出 rt 大、urt 小、且响应体也不大的请求,看它们集中在哪些 URI
awk '
{
# 每行必须重置,否则上一行的值会污染这一行
rt = 0; urt = ""; us = ""; bs = 0; lrs = ""; uri = ""
for (i=1; i<=NF; i++) {
if ($i ~ /^rt=/) rt = substr($i, 4) + 0
else if ($i ~ /^urt=/) urt = substr($i, 5)
else if ($i ~ /^us=/) us = substr($i, 4)
else if ($i ~ /^bs=/) bs = substr($i, 4) + 0
else if ($i ~ /^uri=/) uri = substr($i, 5)
else if ($i ~ /^lrs=/) lrs = substr($i, 5)
}
if (lrs == "") lrs = "(none)"
if (rt > 2.0 && urt != "-" && urt + 0 < 0.2) {
printf "rt=%.3f urt=%-6s us=%-4s bs=%-8d lrs=%-10s uri=%s\n", rt, urt, us, bs, lrs, uri
}
}' "$LOG" | head -50
**rt = 0 这类每行重置不能省。**awk 变量在整个文件范围内共享,如果某行缺 urt=(日志被截断、格式刚改过),它会沿用上一行的值——输出看着合理,结论完全错误。这是 awk 单行分析脚本最常见的静默 bug。
多实例部署时,"后端慢"通常只是个别实例慢。$upstream_addr 记录了实际处理的实例 IP:PORT:
bash # 按上游实例聚合:请求数、平均 urt、慢请求数、5xx 数
awk '
{
upa = ""; us = ""; urt = ""
for (i=1; i<=NF; i++) {
if ($i ~ /^upa=/) upa = substr($i, 5)
else if ($i ~ /^us=/) us = substr($i, 4)
else if ($i ~ /^urt=/) urt = substr($i, 5)
}
if (upa == "" || upa == "-") next
# 重试时形如 "10.0.1.5:8080, 10.0.1.6:8080",先统一成无空格逗号
gsub(/, /, ",", upa); gsub(/, /, ",", us); gsub(/, /, ",", urt)
n = split(upa, parts, ",")
addr = parts[n] # 最后一个:最终把响应交出去的实例
cnt[addr]++
if (urt != "" && urt != "-") {
m = split(urt, u, ",")
v = u[m] + 0
sum[addr] += v
if (v > 1.0) slow[addr]++
}
if (us != "" && us != "-") {
m2 = split(us, s2, ",")
if (s2[m2] ~ /^5/) err5[addr]++
}
}
END {
printf "%-24s %10s %10s %10s %8s\n", "upstream", "count", "avg_urt", "over1s", "5xx"
for (a in cnt) {
avg = (cnt[a] > 0) ? sum[a] / cnt[a] : 0
printf "%-24s %10d %10.3f %10d %8d\n", a, cnt[a], avg, slow[a], err5[a]
}
}' "$LOG" | (read -r header; echo "$header"; sort -k2,2nr)
avg_urt 统计的是"该实例作为最终处理者的请求平均耗时"。如果一次请求重试过($upstream_addr 多值),只有最终成功的那个实例会计入耗时,前一个失败的实例会计入 5xx——这样拆开才符合实际:故障算在出问题的一方,耗时算在交付响应的一方。
如果发现某个实例平均 urt 是其他的 3 倍,先摘掉它,再登录查 JVM GC / 慢查询 / 磁盘 / CPU steal。不要停在"确认是后端慢"就不动了,那不叫定位。
注意:
$upstream_addr在多实例、多协议(HTTP/2、gRPC)下格式会有差异,可能是unix:/var/run/app.sock、10.0.1.5:8080、或带->的写法(缓存/内部重定向场景)。分析脚本要能容错,不要假设一定是ip:port。
$request_id 串联整条链路日志里对不上的根本原因是没有统一的请求标识。在入口生成、透传给上游、上游打进自己的日志:
nginx # 入口:生成或复用上游传入的 ID
map$http_x_request_id$req_id {
default$http_x_request_id;
"" $request_id;
}
# 把 rid=$req_id 拼进完整 log_format,不要照抄省略号
# log_format edge '...其他字段... rid=$req_id';
server {
location / {
proxy_set_header X-Request-ID $req_id;
proxy_pass http://app_upstream;
}
}
应用侧把 X-Request-ID 写进自己的访问日志和错误日志(Spring 用 MDC,Go 用 context,Node 用 AsyncLocalStorage),之后:
bash # 用 rid 在 Nginx 和上游日志里同时搜
RID="6f1a2b3c4d5e6f70"
grep -h "id=$RID\|rid=$RID\|$RID" /var/log/nginx/access.log /var/log/app/app.log
这才是能端到端回答"这个用户那次请求到底发生了什么"的方法。没做这一步之前,所有跨服务排查都是靠时间戳猜。
把上面的手段汇总成一张对照表,排查时按顺序排除:
| 根因 | 日志证据 | 进一步验证 |
|---|---|---|
| 上游应用慢(GC、锁、慢 SQL) | urt 高且与 rt 接近 | 应用日志、jstat -gcutil、慢查询日志 |
| 上游连接池/线程池耗尽 | urt 高、$upstream_connect_time 也高 | 上游 accept queue 溢出(ss -lnt 的 Recv-Q)、线程栈 |
| 上游连接建立慢 | $upstream_connect_time 占比高 | 上游 backlog、net.ipv4.tcp_max_syn_backlog、防火墙丢 SYN |
| 上游返回慢(首字节慢) | $upstream_header_time 接近 urt | 应用内部处理逻辑;若 header_time 远小于 urt,是响应体传输慢 |
| 上游重试放大 | $upstream_addr 多值、$upstream_status 多值 | 检查 proxy_next_upstream 配置与合理的 proxy_next_upstream_tries |
| DNS 解析慢 | 每个请求都卡在固定时长(如 5s/10s) | 见 DNS 那篇;proxy_pass 用变量时每次请求都会解析 |
| 客户端网络慢 | rt 高、urt 低、bs 大 | 抓包看 TCP 重传、ss -ti 看 retrans |
| Nginx 自身 CPU 饱和 | 所有请求 rt 都涨、worker 进程 CPU 100% | top -H、strace -p、检查是否开了 gzip 压缩大文件或大量 SSL 握手 |
| 磁盘 I/O 慢 | 静态资源 urt 为 -、await 高 | iostat -x 1、iotop -oP |
| 限流排队 | $limit_req_status 有值、rt 规律性增大 | 核对 limit_req 的 rate/burst 与实际 QPS |
| 日志写盘阻塞 | worker 周期性卡顿、磁盘 %util 接近 100 | 把 access_log 移到独立盘或改用 buffer= |
下面的命令全部基于前面推荐的 edge 格式(字段带 key= 前缀)。如果你的日志是 combined,需要改列号,可靠性差很多——这也是加前缀字段的理由之一。
统一前置:
bash LOG=/var/log/nginx/access.log
bash # 总请求数
wc -l < "$LOG"
# 状态码分布(含占比)
awk '{ for(i=1;i<=NF;i++) if($i ~ /^st=/) { s=substr($i,4); break } c[s]++ ; n++ }
END { for (k in c) printf "%s %d %.2f%%\n", k, c[k], c[k]*100/n }' "$LOG" | sort -k2,2nr
# 2xx/3xx/4xx/5xx 分桶
awk '{ for(i=1;i<=NF;i++) if($i ~ /^st=/) { s=substr($i,4); break }
b = substr(s,1,1) "xx"; c[b]++ ; n++ }
END { for (k in c) printf "%s %d %.2f%%\n", k, c[k], c[k]*100/n }' "$LOG" | sort
# 方法分布
awk '{ for(i=1;i<=NF;i++) if($i ~ /^m=/) { m=substr($i,3); break } c[m]++ }
END { for (k in c) printf "%-8s %d\n", k, c[k] }' "$LOG" | sort -k2,2nr
**健康的线上服务,5xx 占比通常应该低于 0.1%,4xx 在 1%~5%(含正常的 404、401)。**这两个数字是你的基线,基线之外的突变才是信号。先算基线,再看异常,否则 4xx 涨一点就报警会把人逼疯。
bash # Top 20 客户端 IP
awk '{ ip=""
for(i=1;i<=NF;i++) if($i ~ /^ip=/) { ip=substr($i,4); break }
if (ip == "") next
c[ip]++
}
END { for (k in c) printf "%-18s %d\n", k, c[k] }' "$LOG" | sort -k2,2nr | head -20
substr($i, 4) 是因为前缀 ip= 占 3 个字符,从第 4 位开始就是值。改字段名就要同步改这个偏移量,这是这类脚本唯一的脆弱点。如果格式里没有 ip=($remote_addr 恰好是第一列),awk '{print $1}' 更快,但同样不建议依赖列位置。
bash # Top 20 URI($uri 本身不含 query)
awk '{ uri=""
for(i=1;i<=NF;i++) if($i ~ /^uri=/) { uri=substr($i,5); break }
if (uri == "") next
c[uri]++
}
END { for (k in c) printf "%-60s %d\n", k, c[k] }' "$LOG" | sort -k2,2nr | head -20
UA 里有空格、括号和引号,不能按空格切分。-F'"' 看起来能用,但它依赖"UA 是日志里第几个引号字段"这个位置假设——后端加一个带引号的字段就整体错位,而且脚本不报错,只是安静地输出错误结果。用正则提取,与字段顺序完全解耦:
bash # Top 20 User-Agent
grep -o 'ua="[^"]*"' "$LOG" | sort | uniq -c | sort -nr | head -20
# Top 20 Referer
grep -o 'ref="[^"]*"' "$LOG" | sort | uniq -c | sort -nr | head -20
[^"]* 会截到第一个收尾引号为止,所以 UA 内部的引号(如果有)会被提前截断。这是可接受的近似——统计排名不受影响,只是个别超长 UA 显示不全。
空 UA 和异常 UA 是重点。 在 edge 格式里 UA 写作 ua="...":客户端没发 UA 头时是 ua="",而 Nginx 对未定义变量补 - 时可能变成 ua="-"。两种都要统计,只 grep 一种会漏掉一半。
bash # 空 UA 的请求总量
grep -cE 'ua="(-)?"' "$LOG"
# 空 UA 的来源 IP Top
grep -E 'ua="(-)?"' "$LOG" \
| awk '{ for(i=1;i<=NF;i++) if($i ~ /^ip=/) { print substr($i,4); break } }' \
| sort | uniq -c | sort -nr | head -20
# 各类脚本型 UA 的请求量
grep -oE 'ua="(curl|python-requests|Go-http-client|okhttp|Java|libwww-perl)[^"]*"' "$LOG" \
| sort | uniq -c | sort -nr | head -20
UA 里可能带空格和引号(虽然日志里已被 escape=json 之外的场景污染),grep -o 的 [^"]* 正好截到第一个收尾引号为止,不会跨字段。
bash # 响应体 Top 50(阈值 10MB),含状态码、客户端 IP、URI
awk '{ bs=0; st=""; uri=""; ip=""
for(i=1;i<=NF;i++) {
if ($i ~ /^bs=/) bs = substr($i,4)+0
else if ($i ~ /^st=/) st = substr($i,4)
else if ($i ~ /^uri=/) uri = substr($i,5)
else if ($i ~ /^ip=/) ip = substr($i,4)
}
if (bs > 10485760) printf "%12d %-4s %-16s %s\n", bs, st, ip, uri
}' "$LOG" | sort -nr | head -50
静态资源(视频、安装包)单文件几十 MB 是正常的,不排除它们,Top 50 会被占满,接口的问题反而看不见:
bash # 排除静态资源后缀后再排行
awk '{ bs=0; uri=""
for(i=1;i<=NF;i++) {
if ($i ~ /^bs=/) bs = substr($i,4)+0
if ($i ~ /^uri=/) uri = substr($i,5)
}
if (uri ~ /\.(png|jpe?g|gif|webp|mp4|zip|gz|deb|rpm|apk|ipa|woff2?)$/) next
if (bs > 1048576) printf "%12d %s\n", bs, uri
}' "$LOG" | sort -nr | head -50
同时看总带宽估算:
bash # 总下行字节(GB)
grep -o 'bs=[0-9]*' "$LOG" | cut -d= -f2 | awk '{ s+=$1 } END { printf "%.2f GB\n", s/1024/1024/1024 }'
两个常见异常:
page_size=999999)。这类请求还会拉高上游内存和 GC,值得优先治理。bs 不大但请求数极高:不是带宽问题,是 QPS 问题,看 6.4 信号四的窗口计数。异常流量不会自己说"我是攻击"。要从下面几类信号里交叉验证。
信号一:单 IP 高频
bash # 单 IP 请求数 Top
awk '{ ip=""
for(i=1;i<=NF;i++) if($i ~ /^ip=/) { ip=substr($i,4); break }
if (ip == "") next
c[ip]++
}
END { for (k in c) printf "%-18s %d\n", k, c[k] }' "$LOG" | sort -k2,2nr | head -20
判断标准要结合业务:正常的浏览器一个页面可能触发 50~200 个请求,但同一 IP 在 10 分钟内超过几千次就值得看一眼。如果是公司出口 NAT,几千个用户共享一个 IP 是正常的——所以必须先搞清出口 IP 清单,否则误封就是事故。
信号二:高 4xx 比例的单 IP
bash awk '{ ip=""; st=""
for(i=1;i<=NF;i++) {
if ($i ~ /^ip=/) ip=substr($i,4)
else if ($i ~ /^st=/) st=substr($i,4)
}
if (ip == "") next
n[ip]++
if (st ~ /^4/) e4[ip]++
if (st == "404") e404[ip]++
}
END {
for (k in n) {
if (n[k] < 100) continue # 样本太小的不看
if (e4[k] * 100 / n[k] < 50) continue # 只看 4xx 占多数的
printf "%8.1f%% 4xx=%-8d 404=%-8d total=%-8d %s\n", \
e4[k]*100/n[k], e4[k], e404[k], n[k], k
}
}' "$LOG" | sort -nr | head -20
排序键放在第一列(%8.1f%% 这类格式化后仍以数字开头),sort -nr 直接可用。不要用 -t= -k3 这类按分隔符找列的方式——一旦某个字段里多出现一个 =,列号就偏了。
扫描器的典型特征:大量 404,且路径是 /admin.php、/.env、/wp-login.php、/phpmyadmin、/actuator/env、/../etc/passwd。 这类流量几乎全是 404/403,bs 很小,rt 很短(Nginx 直接返回,不走上游)。
bash # 探测类路径 Top(命中说明有人在扫)
grep -Ei '/(\.env|\.git|wp-login|phpmyadmin|admin\.php|actuator|\.aws|config\.json|\.ssh)' "$LOG" \
| awk '{ uri=""
for(i=1;i<=NF;i++) if($i ~ /^uri=/) { uri=substr($i,5); break }
if (uri != "") c[uri]++
}
END { for (k in c) printf "%-50s %d\n", k, c[k] }' \
| sort -k2,2nr | head -30
注意这里只能对 $LOG 用一次 grep,管道后面的 awk 不能再接文件参数——awk '...' file 会把文件当输入,和前面 grep 的结果冲突,输出会变成两份。
另外,/../etc/passwd 这类路径穿越请求在到达 Nginx 的 $uri 之前就已被规范化(../ 被消解),所以日志里通常看不到原始的穿越串,看到的是规范化后的结果或 400。要抓原始请求行,用 grep -E '\.\./' "$LOG" 直接扫全文。
信号三:404 的时间集中(分布式扫描)
单 IP 阈值抓不到分布式扫描——每个 IP 只请求几十次,总量却上万,谁都没超过阈值。换个角度:看短窗口内 404 总量的突增。分布式扫描在时间上仍然是集中的,这是它和"业务里零星 404"的关键差别。
bash # 按分钟统计 404 数量,找突增的分钟
grep 'st=404' "$LOG" | awk '{ print substr($4, 14, 5) }' | sort | uniq -c | sort -nr | head -20
信号四:10 秒窗口的 CC 攻击
分钟粒度太粗。用 $msec(毫秒时间戳)做窗口计数:
bash # 每 IP 在 10 秒窗口内的请求数峰值(简化版:固定 10s 分桶)
awk '{ ip=""; t=0
for (i=1;i<=NF;i++) {
if ($i ~ /^ip=/) ip=substr($i,4)
if ($i ~ /^ms=/) t=substr($i,4)+0
}
if (t == 0 || ip == "") next
win = int(t/10)
c[ip "|" win]++
}
END { for (k in c) { split(k, p, "|"); print p[1], c[k] } }' "$LOG" \
| sort -k2,2nr | head -20
$msec 是带毫秒小数的秒级时间戳,+0 转成数值、除以 10 取整就是 10 秒桶。日志里没有 $msec 时可以用 $time_iso8601 折算,但精度只到秒,桶边界误差更大。
桶边界会把一个真实的 10 秒窗口劈成两半,所以实际攻击强度可能被低估,但用来找可疑 IP 足够了。要更精确就用滑动窗口,代价是脚本复杂度上升——生产上通常没必要,因为限流配置本身也不是精确的。
信号五:异常的请求速率规律
真实用户流量有昼夜波动,机器流量往往很平。看每小时请求数:
bash # 每小时请求数
awk '{ print substr($4, 14, 2) }' "$LOG" | sort | uniq -c
如果凌晨 3 点的请求数居然和白天持平,那不是"用户很活跃",是有机器人在跑。
更进一步,把"每小时请求数"和"每小时独立 IP 数"放在一起看:
bash awk '{
hr = substr($4, 14, 2)
ip = ""
for (i=1; i<=NF; i++) if ($i ~ /^ip=/) { ip = substr($i, 4); break }
if (ip == "") next
cnt[hr]++
if (!((hr SUBSEP ip) in seen)) { seen[hr SUBSEP ip] = 1; uip[hr]++ }
}
END {
for (h = 0; h <= 23; h++) {
k = sprintf("%02d", h)
if (cnt[k] == 0) continue
printf "%s req=%-9d uniqIP=%-7d req/IP=%.1f\n", k, cnt[k], uip[k], cnt[k]/uip[k]
}
}' "$LOG"
**req/IP 这个比值异常升高,几乎一定是机器流量。**正常业务里一个 IP 一小时几百个请求;如果某小时 req=500000 而 uniqIP 只有 300,平均每个 IP 一千多个请求,那不是用户。
| 特征 | 真实用户 | 机器流量 |
|---|---|---|
| UA | 主流浏览器 + 版本号 | 空、curl、python-requests、Go-http-client、伪装成旧浏览器 |
| URI 分布 | 有静态资源(js/css/图片)+ 少数 API | 只有 API,或只有错误路径,无静态资源 |
| Referer | 有站内来源 | 空或外部可疑域名 |
| 请求间隔 | 不均匀 | 高度均匀(每 100ms 一个) |
| 状态码 | 以 200 为主 | 大量 404/403,或固定 200 但 bs 极小 |
| 时间分布 | 有昼夜 | 全天恒定 |
| 是否带 Cookie | 有会话 Cookie | 无或固定值 |
最强的单条判据是"没有静态资源请求"。真实浏览器访问页面一定会加载 js/css/font,纯 API 请求流几乎必然是程序。所以离线分析时,把"只请求 /api/* 且 UA 为空或为脚本"的组合当成机器流量的高置信度判据:
bash awk '{ ip=""; ua=""; uri=""
for (i=1;i<=NF;i++) {
if ($i ~ /^ip=/) ip = substr($i,4)
else if ($i ~ /^ua=/) ua = substr($i,4)
else if ($i ~ /^uri=/) uri = substr($i,5)
}
# 去掉 ua=" 的前缀引号,剩下 - 或具体 UA
ua = gensub(/^"/, "", 1, ua)
if (ua == "-" || ua == "") {
if (uri ~ /^\/api\//) c[ip]++
}
}
END { for (k in c) printf "%-18s %d\n", k, c[k] }' "$LOG" | sort -k2,2nr | head -30
(gensub 是 gawk 扩展,mawk 环境改用 sub(/^"/, "", ua)。)
$request_id 反查一次具体异常当用户或监控报"某次请求 502"时,最快的方式是拿到那次请求的 ID(前端在错误提示里带上,或从响应头里取),然后:
bash grep 'rid=6f1a2b3c4d5e6f70' "$LOG"
输出一行完整记录:请求行、状态码、rt、urt、ua(上游实例)、us(上游状态)。一行就能区分"上游返回了 502"和"连不上上游(urt 为 -、us 为 -)"。
如果没做 rid,只能靠时间 + IP + URI 模糊匹配:
bash # 用户说 14:23 左右访问 /api/order 失败
grep '14:23:' "$LOG" | grep '/api/order' | grep -E 'st=5|st=499'
这已经很不精确了,而且并发高时会有几十条候选,逐条比对 urt 才能猜。这就是为什么要提前加 $request_id。
bash # 每个上游实例的成功率与超时数(504 通常代表 upstream timed out)
awk '{ ua=""; us=""
for (i=1;i<=NF;i++) {
if ($i ~ /^ua=/) ua=substr($i,4)
if ($i ~ /^us=/) us=substr($i,4)
}
if (ua == "-" || ua == "") next
n = split(ua, p, ", "); addr = p[n]
cnt[addr]++
if (us ~ /^5/) e5[addr]++
if (us == "504") to[addr]++
}
END { for (a in cnt) printf "%-22s total=%-8d 5xx=%-6d timeout=%-6d %.2f%%\n", a, cnt[a], e5[a], to[a], e5[a]*100/cnt[a] }' "$LOG"
如果某个实例 5xx 率明显高于其他,且日志显示它有重启时间点,基本可以确认该实例在重启/崩溃。看实例的分布是否均匀,也是发现"上游摘除失败""权重配置错误"的有效手段——比如 4 个实例里 1 个几乎没有请求,说明健康检查或权重有问题。
分析出异常流量后,动作分三档,风险递增:
| 档位 | 动作 | 风险 | 适用 |
|---|---|---|---|
| 低 | 只加监控告警,不动流量 | 无 | 首次发现,需要观察 |
| 中 | 加 limit_req/limit_conn 限速 | 可能误伤正常用户 | 确认是异常来源且需要保护后端 |
| 高 | 直接在 Nginx/firewalld 封 IP | 可能封掉共享出口、CDN 回源 IP | 明确攻击且有把握 |
**永远先做低档。**尤其封 IP,封错一个 NAT 出口就是几百个用户同时报障。
nginx # http 块
limit_req_zone$binary_remote_addr zone=perip:20m rate=20r/s;
limit_req_zone$server_name zone=perserver:10m rate=2000r/s;
limit_conn_zone$binary_remote_addr zone=connperip:10m;
# 在已有的 log_format 里补两个字段,不要整段替换
# lrs=$limit_req_status lcs=$limit_conn_status
server {
location /api/ {
limit_req zone=perip burst=40 nodelay;
limit_req zone=perserver burst=500;
limit_conn connperip 50;
limit_req_status429; # 默认 503,改成 429 语义更准确
limit_conn_status429;
proxy_pass http://app_upstream;
}
}
关键说明:
limit_req_zone 的 zone 大小决定能记录多少个 key,20m 大约能存 16 万个 IP(每条约 64~128 字节)。key 溢出会导致老记录被淘汰,限流精度下降,日志里不会有提示——所以 zone 要按实际 IP 量估算,别用默认的小值。nodelay 是核心权衡:不加 nodelay,超限请求排队等待($limit_req_status=DELAYED),客户端看到的是"变慢";加了 nodelay,超限立即拒绝(REJECTED),客户端看到 429。限流的目的通常是保护后端,所以 nodelay 更常见,但队列模式对"允许稍微变慢"的场景更好。limit_req_status/limit_conn_status 需要 nginx 1.3.15+;$limit_req_status 变量需要 1.17.6+。
验证限流是否生效,不要靠压测猜,直接看日志:
bash # 限流命中量与状态分布
awk '{ lrs=""; st=""
for (i=1;i<=NF;i++) {
if ($i ~ /^lrs=/) lrs=substr($i,5)
if ($i ~ /^st=/) st=substr($i,4)
}
k = lrs "|" st
c[k]++
}
END { for (k in c) printf "%-22s %d\n", k, c[k] }' /var/log/nginx/access.log | sort -k2,2nr
预期输出里 REJECTED|429 有具体数量、-|200 占绝大多数。如果全是 -,说明限流根本没触发(key 选错、zone 没生效、或 location 没匹配上);如果 REJECTED 占比异常高,说明 rate/burst 设得太紧,先放宽再观察。
上线限流的正确姿势:
limit_req_zone 和 log_format 里的 $limit_req_status,不加 limit_req 指令,跑一天。这样日志里能看到"如果当时有限流,会有多少请求被拒"——零风险的影子模式。rate 设到 当前 P99 峰值的 1.5~2 倍,burst 设到瞬时峰值的余量。location 或单个 server,观察 429 数量和业务侧错误率。$limit_conn_status 同理,需要 1.17.6+ 才有。低于这个版本只能调 limit_conn_log_level,靠 limiting connections 关键字判断是否触发。
两者同时配置在同一 location 时,Nginx 先判 limit_conn 再判 limit_req。所以慢速连接占满的场景会先看到 lcs=REJECTED、lrs 为空——据此可以区分"并发超限"和"速率超限",处理方式完全不同。
map + geo 做名单控制静态封一批 IP 时,deny 指令最简单,但改一次就要 reload。用 geo 模块把名单放到独立文件里,配合 reload 可以做到不停机更新:
nginx # /etc/nginx/conf.d/blocklist.conf
# 格式:每行一个 IP 或 CIDR,后面跟 1
geo $blocked_ip {
default 0;
include /etc/nginx/blocklist/ips.conf;
}
# /etc/nginx/blocklist/ips.conf
# 203.0.113.45 1;
# 198.51.100.0/24 1;
nginx server {
listen443 ssl;
http2on;
server_name www.example.com;
# 命中黑名单的请求在进入 location 之前就被拒绝
if ($blocked_ip) {
return403;
}
location / {
proxy_pass http://app_upstream;
}
}
if 在 server 级别的 return 是安全的写法(if 的危险在于和 proxy_pass 混用)。更规范的做法是用 map 直接给出状态码:
nginx map $blocked_ip $deny_status {
0 "";
1 403;
}
server {
if ($deny_status) { return $deny_status; }
}
更新流程要有审计:
bash #!/usr/bin/env bash
set -euo pipefail
BLOCK_FILE=/etc/nginx/blocklist/ips.conf
BACKUP_DIR=/var/backups/nginx-blocklist
TS=$(date +%Y%m%d-%H%M%S)
IP="${1:-}"
if [[ -z "$IP" ]]; then
echo"usage: $0 <ip-or-cidr>" >&2
exit 1
fi
# 粗校验:只放行 IP/CIDR 合法字符,防止把注释、命令、空行写进名单
# 严格校验(如 999.1.1.1 这种)交给 ipcalc / sipcalc,脚本里不做
if ! [[ "$IP" =~ ^[0-9a-fA-F:.]+(/[0-9]{1,3})?$ ]]; then
echo"invalid ip/cidr: $IP" >&2
exit 1
fi
# 内网与回环保护:这些地址封了会直接打挂监控、健康检查或内网调用
case"$IP"in
10.*|127.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*)
echo"refuse to block private/loopback: $IP" >&2
exit 2
;;
esac
mkdir -p "$BACKUP_DIR"
cp -a "$BLOCK_FILE""$BACKUP_DIR/ips.conf.$TS"
# 幂等:已存在就不重复加
if grep -qxF "$IP 1;""$BLOCK_FILE"; then
echo"already blocked: $IP"
exit 0
fi
echo"$IP 1;" >> "$BLOCK_FILE"
# 先测语法,失败就回滚
if ! nginx -t 2>/dev/null; then
echo"nginx -t failed, rolling back" >&2
cp -a "$BACKUP_DIR/ips.conf.$TS""$BLOCK_FILE"
exit 1
fi
nginx -s reload
echo"$(date -Is) blocked $IP by $(whoami)" | tee -a /var/log/nginx/blocklist-audit.log
三个必须做到的点:
grep -qxF 做幂等,否则重复添加会让文件无限增长(虽然 geo 允许重复,但会拖慢 reload);nginx -t 失败必须回滚,否则一次手误就 reload 失败,虽然旧配置还在跑,但下次重启就起不来了;封 IP 是最高危的操作之一。列一下必须提前想清楚的事:
| 风险 | 后果 | 规避 |
|---|---|---|
| 封了 NAT 出口 IP | 数百用户同时不可用 | 封前查该 IP 的请求 URI/UA 分布,确认是攻击不是共享出口 |
| 封了 CDN 回源 IP | 所有用户不可用 | 先拿到 CDN 回源 IP 清单,加入白名单 |
| 封了监控/健康检查 IP | 告警误报、LB 摘除节点 | 内部 IP 段、监控探针 IP 必须白名单 |
| 封了搜索引擎爬虫 | SEO 下降,几天后才发现 | 用 UA + 反向 DNS 双验证,别只看 IP |
| 攻击者换 IP | 封禁无效,反而封了一批正常 IP | 优先用限流和 UA/路径规则,封 IP 作为兜底 |
| 名单越滚越大 | geo 文件上千行,reload 变慢 | 定期清理(结合日志确认已无请求的 IP) |
**白名单必须先于黑名单生效。**在 geo 里用靠前的规则覆盖:
nginx geo $blocked_ip {
default 0;
# 内部与监控,永远放行
10.0.0.0/8 0;
172.16.0.0/12 0;
192.168.0.0/16 0;
include /etc/nginx/blocklist/allow.conf;
include /etc/nginx/blocklist/ips.conf;
}
geo 的取值规则要记清:多个条目都能匹配时,取前缀最长的那一条。所以写在上面的 10.0.0.0/8 0 并不能保护具体的 10.1.2.3 1——后者前缀更长,会胜出。换句话说,白名单不是靠"写在前面"生效的,而是靠"前缀更长或相同"生效的。
这意味着:一旦把 10.0.0.0/8 设为 0,你再也没法用 geo 封禁单个内网 IP(10.1.2.3 1 前缀是 /32,比 /8 长,实际上能覆盖回来)。所以上例是可行的;但如果你写的是 10.1.2.3 0 这种单 IP 白名单,后面再加 10.1.2.0/24 1,结果是 /24 更短、不会覆盖——这块要么保持简单,要么用两个独立变量做两级判断。
fail2ban 还是自己写fail2ban 适合"根据日志里的失败模式自动封禁",但不适合 Nginx 层面的高频限流——因为它的检测是批处理(默认每分钟扫一次),挡不住每秒几千次的突刺。
| 方案 | 延迟 | 精度 | 适用 |
|---|---|---|---|
limit_req | 毫秒级 | 只看速率 | 保护后端,首选 |
limit_conn | 毫秒级 | 只看并发 | 防慢速攻击、防连接占满 |
fail2ban | 分钟级 | 可基于日志模式 | 防暴力破解(登录失败次数)、持续扫描 |
| WAF / 云防护 | 毫秒级 | 规则丰富 | 有预算、有明确攻击面的场景 |
| 自写脚本 | 取决于轮询周期 | 完全可控 | 特殊业务规则 |
fail2ban 的正确用法是配 nginx-limit-req 和自定义 jail 针对具体日志模式,而不是试图用它做通用限流。它的 bantime/findtime/maxretry 三个参数必须结合日志基线设,否则会变成"每天自动封一批正常用户"。
ini # /etc/fail2ban/jail.d/nginx-scan.local
[nginx-scan]
enabled = true
port = http,https
filter = nginx-scan
logpath = /var/log/nginx/access.log
maxretry = 30
findtime = 60
bantime = 3600
# 用 ipset 而不是 iptables 逐条规则,避免规则膨胀
banaction = nftables-multiport
上面引用的 filter = nginx-scan需要自己创建,否则 jail 启动时找不到 filter 会直接失败:
ini # /etc/fail2ban/filter.d/nginx-scan.conf
[Definition]
# <HOST> 是 fail2ban 的占位符;正则要和你 log_format 的字段前缀一致
failregex = ^ip=<HOST> .*"(GET|POST|HEAD) [^"]*/(\.env|\.git|wp-login\.php|phpmyadmin|admin\.php|actuator)[^"]*" st=(404|403)
ignoreregex =
写完先离线验证,不要直接上线:
bash sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-scan.conf --print-all-matched | head -40
输出里 Failregex: N total 为 0 说明正则没匹配上(大概率是字段前缀与实际日志不一致);匹配数远大于预期说明正则太宽,那种情况下先修正则,别急着开 maxretry 小值。
banaction 在不同发行版默认值不同(iptables-multiport、nftables-multiport、firewallcmd-ipset),必须和系统实际用的防火墙一致。用错了 fail2ban 会"看起来在封但实际没生效",日志里只有 ERROR 级别的一行,很容易忽略。
USR1 信号Nginx 的日志文件句柄在 master 进程里持有。直接 mv 文件不会让 Nginx 写新文件,它还会往已删除的 inode 写,磁盘空间不释放。正确做法是发 USR1 让它重开日志:
bash # /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
if [ -f /run/nginx.pid ]; then
kill -USR1 "$(cat /run/nginx.pid)" 2>/dev/null || true
fi
endscript
}
几个容易踩的坑:
postrotate 里不要用 kill -USR1 $(cat ...) 裸写,pid 文件不存在时 kill 会报错并让 logrotate 记失败;加 if [ -f ] 和 || true。sharedscripts 必须有,否则每个日志文件都会触发一次 USR1。日志文件多时(每站点一个),会连续发几十次信号。copytruncate 不是首选。它靠"复制 + 清空"实现,复制和清空之间的日志会丢失,而且大文件复制本身很慢。只有在程序不支持重开日志时才用。delaycompress 是为了让 postrotate 有窗口期——上一个周期的文件还没压缩时,旧句柄可能还指向它。验证轮转是否有效:
bash # 强制跑一次(不影响生产,但会真的轮转)
sudo logrotate -d /etc/logrotate.d/nginx # -d 是 dry-run,只看不执行
sudo logrotate -f /etc/logrotate.d/nginx # 强制执行
ls -l /var/log/nginx/
# 检查 Nginx 是否已打开新文件(inode 应该变了)
sudo ls -l /proc/$(cat /run/nginx.pid)/fd | grep access.log
sudo lsof -p "$(cat /run/nginx.pid)" | grep -c 'access.log.*deleted'
最后一条如果输出大于 0,说明还有进程持有已删除的日志文件,磁盘空间不会被释放。
单机 10GB/天以内的日志,grep/awk 完全够用。但下面几种情况必须上采集平台:
$request_id)。轻量方案对比:
| 方案 | 资源开销 | 查询能力 | 适用 |
|---|---|---|---|
| Fluent Bit → Loki | 低 | LogQL,类 grep + 标签 | 中小规模,主要按时间+标签查 |
| Vector → ClickHouse | 中 | 完整 SQL,聚合强 | 需要复杂分析、报表 |
| Filebeat → Elasticsearch | 高 | 全文检索强 | 已有 ELK,需要全文搜索 |
| Fluent Bit → S3/OSS | 极低 | 冷查询 | 合规留存,偶尔翻查 |
Nginx 日志进 Loki 时的关键点:不要把 $uri、$status、$remote_addr 全做成标签(label)。标签会建索引,基数爆炸会把 Loki 拖垮。只把 host、status_class(2xx/4xx/5xx)这种低基数字段做标签,其余留在日志行里用 LogQL 过滤。
ini # /etc/fluent-bit/parsers.conf
# 正则必须与 log_format 的字段顺序、分隔符完全对应,改格式就要同步改这里
[PARSER]
Name nginx_edge
Format regex
Regex ^(?<remote_addr>\S+) \[(?<time_local>[^\]]+)\] "(?<request>[^"]*)" st=(?<status>\d{3}) bs=(?<body_bytes_sent>\d+) rt=(?<request_time>[0-9.]+) urt=(?<upstream_time>[0-9.,\- ]*) ua=(?<upstream_addr>[^ ]*) rid=(?<request_id>\S*)
Time_Key time_local
Time_Format %d/%b/%Y:%H:%M:%S %z
ini # /etc/fluent-bit/fluent-bit.conf
[SERVICE]
Flush 5
Log_Level info
Parsers_File /etc/fluent-bit/parsers.conf
[INPUT]
Name tail
Path /var/log/nginx/access.log
Parser nginx_edge
Tag nginx.access
Read_from_Head false
Mem_Buf_Limit 64MB
Skip_Long_Lines On
Refresh_Interval 10
[FILTER]
Name lua
Match nginx.access
script /etc/fluent-bit/scripts/status_class.lua
call add_class
[OUTPUT]
Name loki
Match nginx.access
Host loki.internal
Port 3100
Labels job=nginx,host=${HOSTNAME}
Label_Keys $status_class
Line_Format json
status_class.lua 只做一件事——把三位状态码折成 2xx/3xx/4xx/5xx,因为这是唯一的低基数派生字段:
lua -- /etc/fluent-bit/scripts/status_class.lua
function add_class(tag, timestamp, record)
local st = record["status"]
if st ~= nil then
local code = tonumber(st)
if code ~= nil then
record["status_class"] = string.format("%dxx", math.floor(code / 100))
end
end
return 1, timestamp, record
end
**注意 Label_Keys 里只能用 $status_class 这种低基数字段。**把 $request_id、$remote_addr 放进 label 会让 Loki 的索引基数爆炸,写入直接 OOM。$remote_addr 留在日志行里,用 LogQL 的 | json | remote_addr="1.2.3.4" 过滤。
另外,tail 输入插件默认从文件头读还是文件尾读要用 Read_from_Head 明确指定;Mem_Buf_Limit 加上 Skip_Long_Lines On 是防止单条畸形日志(被截断的超长行)把内存撑爆的必要配置。
Mem_Buf_Limit 和磁盘缓冲,但缓冲满了必须丢数据而不是阻塞,否则会把采集端拖成 Nginx 的瓶颈。宁可丢日志,不能丢服务。escape=json + JSON 日志的体积比文本格式大约 20%~40%,洗数据前先估算磁盘。日志分析是事后手段,告警必须用指标。两者要能互相验证。
| 指标 | 含义 | 建议告警阈值 |
|---|---|---|
| 5xx 率 | st=5xx / 总数 | > 0.5% 持续 5 分钟 |
| 499 率 | 客户端主动断开 | > 2%(说明用户等不及了) |
P99 $request_time | 尾部延迟 | > 业务 SLA 的 80% |
| 上游超时数 | us=504 或 urt=- 且 st=504 | > 基线 3 倍 |
| 限流拒绝率 | lrs=REJECTED | > 1%(说明限流参数过紧或真被打) |
| 单 IP 请求占比 | 最大 IP 请求数 / 总数 | > 30%(可能是单点刷) |
| 空 UA 请求占比 | ua=- / 总数 | > 10% 且持续上升 |
| 平均响应体 | 总 bs / 请求数 | 突增 50%(可能是分页被绕过) |
告警要基于比例而不是绝对值。"5xx 有 100 个"这种告警在流量涨了之后每天都会响;"5xx 率超过 0.5%"才有意义。
nginx-prometheus-exporter 还是自己算官方 nginx-prometheus-exporter 读 stub_status,能给出:
nginx_connections_active/reading/writing/waitingnginx_http_requests_totalnginx_connections_accepted/handled(两者差值能看出连接丢弃)它不提供按状态码、按 URI、按延迟的指标——那些必须靠日志派生(nginx-lua-prometheus,或采集平台落库后再聚合)。所以生产上通常是两条线:
nginx # stub_status 只允许本机访问
server {
listen 127.0.0.1:8080;
location /stub_status {
stub_status;
allow 127.0.0.1;
deny all;
access_log off;
}
}
stub_status 一定要绑 127.0.0.1 或严格 allow,暴露到公网等于把流量数据送人。这点在生产事故里出现过多次。
关键的两个告警:
nginx_connections_active 接近 worker_connections × worker_processes 时,新连接会被拒绝。先看 waiting 是不是很高(keepalive 空闲连接多,正常),再看 active 是否持续高位。nginx_connections_accepted - nginx_connections_handled 持续增长,说明有连接被丢弃(worker 连接数不够或资源耗尽)。分析结论落地后,按这个清单验证:
bash # 1. 配置语法(必须看输出,不能只看退出码)
sudo nginx -t
# 2. 日志格式已生效:新请求里应能看到全部 key= 前缀
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/healthz
tail -n 1 /var/log/nginx/access.log
tail -n 1 /var/log/nginx/access.log | grep -oE '(^| )(ip|st|bs|m|uri|rt|urt|uct|uht|upa|us|rl|rid|lrs)=' | sort -u
# 3. 慢请求确实被统计到(构造一个慢请求,看 rt 是否如实记录)
curl -s -o /dev/null -w '%{time_total}\n' https://example.com/slow-endpoint
tail -n 1 /var/log/nginx/access.log | grep -o 'rt=[0-9.]*'
# 4. 限流规则生效:连续打到超限,统计返回码分布
for i in $(seq 1 200); do
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/api/healthz
done | sort | uniq -c
# 预期:200 与 429 混合出现;全是 200 说明限流没触发
grep 'lrs=REJECTED' /var/log/nginx/access.log | tail -n 5
# 5. 日志轮转正常
sudo logrotate -d /etc/logrotate.d/nginx
lsof -p "$(cat /run/nginx.pid)" | grep -c 'access.log.*deleted' # 应为 0
# 6. 采集链路通(Loki/ES 里能查到刚才那条)
# 查询最近 1 分钟该 host 的日志条数,与 wc -l 大致对得上
每一项都有明确的通过标准,不要只看"命令没报错"就认为完成了。
日志格式和限流配置的回滚成本很低,但限流回滚必须快,因为它直接影响用户。
| 变更 | 回滚动作 | 耗时 | 注意 |
|---|---|---|---|
log_format 改动 | 恢复配置文件 + nginx -s reload | 秒级 | 已写入的旧格式日志会与新格式混杂,分析脚本要能容错 |
加 limit_req | 注释指令 + reload | 秒级 | reload 不清空 limit_req_zone 的计数,但会重新加载配置 |
改 rate/burst | 改参数 + reload | 秒级 | 放宽立即生效,收紧也立即生效 |
geo 黑名单 | 删行 + reload | 秒级 | 备份文件在 /var/backups/nginx-blocklist/ |
| 上采集平台 | 停采集进程 | 分钟级 | 不影响 Nginx 本身(前提是采集不阻塞) |
配置变更的标准流程:
bash TS=$(date +%Y%m%d-%H%M%S)
sudocp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$TS
sudocp -a /etc/nginx/conf.d /etc/nginx/conf.d.bak.$TS
# 此时手工编辑配置并保存
sudo vim /etc/nginx/conf.d/app.conf
# 语法检查(必做,且必须看输出不只看退出码)
sudo nginx -t || { echo"abort"; exit 1; }
# reload(不是 restart)
sudo nginx -s reload
# 验证
sudo nginx -T 2>/dev/null | grep -A3 'limit_req_zone'
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/healthz
reload 与 restart 的区别要在变更前明确:reload 逐个启动新 worker、优雅关闭旧 worker,不断连接;restart 会断开所有连接。Nginx 的绝大多数配置(含 log_format、limit_req)都支持 reload。只有 listen 端口变更、编译模块变更才需要 restart。
回滚时的一个陷阱:reload 失败后旧配置仍在运行,但磁盘上的配置文件已经是坏的。这时候如果机器重启,Nginx 会起不来。所以 nginx -t 失败必须立刻恢复备份文件,不能"先放着待会再改"。
日志格式是长期契约。一旦下游(采集、分析脚本、报表)依赖了字段,改格式就要同步改下游。加字段安全,改字段名、改字段顺序、删字段都可能导致下游静默出错(脚本不报错但结果全错)。
access_log 一定要配 buffer= 或独立磁盘。高 QPS 下同步写日志会让 worker 卡在 write() 上。推荐:
nginx access_log /var/log/nginx/access.log edge buffer=64k flush=5s;
buffer=64k flush=5s 意味着最多积 64KB 或 5 秒才落盘。副作用是实时性下降,且 Nginx 被 kill -9 时会丢这部分日志。故障排查期间可以临时改成 flush=1s。
日志里不要记录敏感信息。$request_uri 里可能带 token、手机号、身份证。如果必须记,用 map 脱敏:
nginx map $request_uri $safe_uri {
"~^(?<p>[^?]*)\?.*$" $p; # 只保留路径,丢掉 query
default $request_uri;
}
或者在应用侧就不把敏感参数放 query(改用 POST body)。access.log 是最容易被忽视的敏感数据泄露渠道。
$request_body 千万别默认开。它会记录用户提交的所有内容(密码、支付信息),而且大请求体会撑爆日志。
分析脚本要能处理脏数据。日志里会出现 rt=-、urt="-"、urt="1.2, 0.3"、空的 $upstream_addr、被截断的行(进程崩溃时)。脚本遇到这些要跳过而不是崩,否则一次异常就让每天的报表任务失败。
时间字段要带时区。$time_local 用服务器本地时区,多机房部署时如果是 UTC 和东八区混着,跨机分析会错 8 小时。建议统一用 $time_iso8601(ISO 8601 格式,含时区偏移,nginx 1.3.12+)。
"只记慢请求"能做,但收益未必划算。access_log 支持 if= 条件,配合 map 对 $request_time 做数值判断即可:
nginx # 单条日志 rt >= 1.0 才记录:整数部分非 0 即 >= 1s
map $request_time $log_slow {
default 0;
"~^[1-9][0-9]*\.|^[1-9][0-9]*$" 1;
}
access_log /var/log/nginx/slow.log edge if=$log_slow;
两个必须注意的点:$request_time 是浮点,用正则做数值比较很容易写错边界(上面这条漏掉了 0.999 之外的亚秒级,但正确覆盖了 1.0 及以上,因为整数部分一旦非 0 就一定 >= 1);此外 map 里的正则加引号才能包含空格和特殊字符。
实践中更推荐全量记录 + 采集侧筛选:省下的磁盘换不来多少收益,反而丢掉了"慢请求在总请求里占比多少"这个最重要的分母。只有当 QPS 极高、磁盘是真的瓶颈时,才值得做条件记录。
$request_time 的精度是毫秒。不要用它做微秒级分析,也不要指望它区分 1ms 和 2ms 的差异。
限流参数的基线必须来自真实数据。拍脑袋设 rate=10r/s 然后被业务方投诉,是很常见的翻车方式。先跑影子模式,用数据定参数。
告警和日志分析要能互相印证。如果监控说 5xx 涨了但日志里查不到,先怀疑日志格式变更、采集断流或采样,而不是怀疑监控。
Nginx 访问日志分析的核心不是"会敲几个 awk",而是先想清楚要回答什么问题,再决定记什么字段。默认 combined 格式缺失的恰好是所有判断需要的字段:时间花在哪(rt/urt)、哪个上游(ua/us)、是否被限流(lrs)、能否串联(rid)。加这几个字段的成本远低于事后瞎猜的成本。
把方法压缩成一条主线:
rt - urt 判断慢在哪一段,再对照那张成因表逐项排除。特别注意限流排队造成的"假慢"。nginx -t + reload,失败立刻恢复备份,别留坏配置在磁盘上。最容易犯的三个错误:用平均值判断延迟、不做 URI 归一化就看聚合结果、算出异常就立刻封 IP。
最后一句实话:日志分析的价值不在于事后查案,而在于让你在下次出问题时,能在 5 分钟内定位而不是 5 小时。 前期花半天把字段加对、脚本写好,后面每一次故障都会还回来。
阅读原文:点击这里