LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

Nginx 访问日志分析:定位慢请求与异常流量

admin
2026年11月4日 9:23 本文热度 42

 一、默认日志能回答的问题太少了 

/var/log/nginx/access.log 的默认格式是 combined:

   text
$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

它能告诉你"谁在什么时候请求了什么、返回了几 K"。它不能回答的是真正需要回答的问题:

  • 这个请求慢在哪?是 Nginx 自己慢,还是后端慢,还是客户端网络慢?
  • 后端是哪个实例处理的?重试了几次?
  • 用户 502 了,是上游超时还是上游主动断开?
  • 流量为什么涨了 3 倍?是真实用户、爬虫,还是某个接口被刷?
  • 某个 IP 一天 200 万次请求,是业务方还是攻击?

我遇到的大部分"Nginx 有问题"的排查,第一步都不是分析日志,而是先改日志格式——因为默认格式里缺了判断所需的关键字段。改格式本身有成本(磁盘、解析、上下游兼容),所以要先想清楚"我要回答哪些问题",再决定加什么字段。

这篇文章解决三段问题:

  1. 日志格式该怎么设计:加哪些字段、为什么加、代价是什么;
  2. 慢请求怎么定位:从 $request_time 到 $upstream_response_time,再到"慢在哪一段"的完整判断路径;
  3. 异常流量怎么识别:从日志里把爬虫、扫描器、刷接口、异常 4xx/5xx 挑出来,以及怎么和限流/封禁联动。

所有内容基于 nginx 1.30.x 稳定分支(1.30.6 为写作时最新稳定版,2026-10 发布),与 1.28 的差异单独标注。Nginx Plus 的独有功能会明确区分。

 二、适用场景与环境假设 

2.1 适用场景

场景说明
单机/多机 Nginx 反向代理 + 上游应用最常见的架构,$upstream_* 变量可用
Nginx 直接提供静态资源没有 upstream 变量,判断逻辑不同
CDN 回源站需要处理 X-Forwarded-For 与真实 IP
网关层做限流、灰度日志要与 limit_req 状态联动
日志量 10GB/天以内用 grep/awk 足以应对,不需要立刻上 ELK

2.2 不适用或需要额外方案

  • 日志量 >100GB/天:必须先做采样或分层(正常请求只记摘要,异常请求记全量),否则磁盘和 IO 先撑不住;
  • 需要秒级实时分析与告警:单机 tail -f + awk 能顶一阵,长期要上 Vector/Fluent Bit + ClickHouse/Loki;
  • 需要追踪单个请求跨多个服务:这是 trace 的活(OpenTelemetry + Jaeger),访问日志只能做"入口侧"记录;
  • Nginx Plus 的实时活动监控面板:开源版没有,得自己做。

2.3 版本与环境假设

   text
Nginx:1.30.6(stable,2026-10-14)、1.28.3(legacy)
       1.31.8(mainline,2026-10-21)
       对照:1.26.3(老版本,模块 API 不同)

操作系统:Rocky Linux 9.6 / Ubuntu 24.04
上游:php-fpm 8.2、Java/Spring Boot、Go
日志:默认写本地文件;可选 syslog,或用 vector/filebeat 采集

关键版本差异(会影响日志字段):
  - escape=json 自 1.11.8 起支持
  - $upstream_bytes_sent 自 1.15.8 起支持
  - $request_length 很早就支持
  - $upstream_response_time 支持多值(重试/内部跳转时逗号分隔)
  - $ssl_protocol / $ssl_cipher 需在 http 层启用 ssl
  - $proxy_protocol_addr 需要 listen ... proxy_protocol
  - 1.23.0 起,同名 header 在 $http_*/$upstream_http_* 中会被合并

 三、核心知识:日志里每个字段意味着什么 

3.1 变量清单与可用时机

变量含义精度/坑
$remote_addr客户端 IP有代理/CDN 时是代理 IP,要用 realip 模块
$request_time从读到客户端第一个字节到发送完最后一个字节秒,毫秒精度(0.023)
$upstream_response_time从与上游建立连接(或复用)到收到完整响应秒,多值用逗号分隔
$upstream_connect_time与上游 TCP 握手耗时(含 TLS,若 proxy_pass https)排查"建连慢"必加
$upstream_header_time从连接建立到收到上游响应头关键:区分"上游算得慢"vs"上游吐得慢"
$request_length请求行 + 头 + body 的总字节判断大 body / 慢上传
$body_bytes_sent发给客户端的 body 字节(不含响应头)不等于用户下载量(有压缩)
$bytes_sent含响应头的总字节计费用这个
$upstream_addr上游地址:端口多值时逗号分隔,重试能看到多个
$upstream_status上游返回的状态码多值,与 $upstream_addr 一一对应
$upstream_cache_status缓存命中状态HIT/MISS/BYPASS/EXPIRED/REVALIDATED/STALE/UPDATING
$server_name命中的 server 块名多域名共 IP 时区分
$host请求的 Host 头(不含端口)可能被客户端伪造
$http_x_forwarded_for代理链可能被伪造,不能直接用于风控
$request_id请求唯一 ID1.11.0+,跨服务追踪的锚点
$connection / $connection_requests连接号 / 该连接上第几个请求判断长连接复用
$msec当前时间(秒.毫秒)比 $time_local 精度高
$limit_req_status限流结果1.17.6+,值为 PASSED/DELAYED/REJECTED
$limit_conn_status并发限制结果1.17.6+,值为 PASSED/REJECTED
$uri规范化后的路径,不含 query聚合分析首选,比从 $request 里切更可靠
$is_args有 query 时返回 ?和 $args 拼用可还原原始串

$limit_req_status 的 PASSED 表示"检查通过、未被限流",不是"没有走 limit_req"。如果某个 location 压根没配 limit_req,这个变量是空的(日志里显示为 lrs= 或 -)。两者含义不同,写脚本判断时容易搞混。

最容易混淆的一对:$request_time 与 $upstream_response_time。

  • $request_time 包含:读客户端请求体 + 与上游交互 + 向客户端发送响应;
  • $upstream_response_time 只包含:与上游交互那一段。

所以:

  • 两者接近 → 时间主要花在后端;
  • $request_time 远大于 $upstream_response_time → 时间花在"和客户端之间的传输",通常是客户端网络慢、下载慢、大响应体;
  • 两者都小但用户报慢 → 问题不在这个 Nginx(可能在 DNS、客户端、前面的 CDN/LB)。

3.2 多值字段:为什么会有逗号

$upstream_addr、$upstream_status、$upstream_response_time 在以下情况会出现多值(用 , 或 ,  分隔):

  1. proxy_next_upstream 触发重试:第一次后端失败,换一个后端重试;
  2. X-Accel-Redirect 内部跳转(error_page 到另一个 location 再 proxy):会多一段;
  3. proxy_pass 到 upstream 组 + try_files 回退。

示例(一次重试):

   text
upstream_addr: 10.20.1.11:8080, 10.20.1.12:8080
upstream_status: 502, 200
upstream_response_time: 0.001, 0.032
request_time: 0.041

解读:第一台后端立刻返回 502(0.001s),Nginx 重试第二台成功(0.032s)。用户看到 200,但日志记录了故障。只看 $status 会漏掉这类问题——这正是"为什么要加 $upstream_status"的理由。

统计时要特别小心:直接对 $upstream_response_time 求平均,逗号值会被当成一个异常大的数字或被跳过。必须先按逗号拆开取最后一个值(最终成功那次)或求和(总耗时),取决于要回答什么问题。

3.3 $request_time 小于 $upstream_response_time 是怎么回事

偶尔会看到 request_time=0.020 而 upstream_response_time=0.030,看起来矛盾。原因:

  • 两者计时起点不同,$request_time 从读请求开始,$upstream_response_time 从连接上游开始;
  • 在多 worker、事件循环调度下,采样时刻不严格嵌套;
  • 缓冲区写回客户端的时间计入 request_time,而上游计时在收到最后一个字节就停。

这种误差通常在 1~10ms 量级,属于正常。如果差距很大(比如 rt 0.5 而 urt 5.0),说明日志格式或变量拼接写错了。

 四、日志格式设计:按问题选字段 

4.1 三种格式,按场景选

格式 A:基础 + 上游(推荐大多数场景)

   nginx
log_format edge 'ip=$remote_addr - $remote_user [$time_local] "$request" '
                'st=$status bs=$body_bytes_sent ref="$http_referer" ua="$http_user_agent" '
                'm=$request_method uri=$uri '
                'rt=$request_time urt=$upstream_response_time '
                'uct=$upstream_connect_time uht=$upstream_header_time '
                'upa=$upstream_addr us=$upstream_status ucs=$upstream_cache_status '
                'rl=$request_length host=$host sn=$server_name '
                'ms=$msec rid=$request_id lrs=$limit_req_status';

特点:每个可变字段都带 key= 前缀,分析脚本匹配 ^key= 即可取值,与字段顺序解耦。这是本文后续所有命令的前提。代价是行变长(约 250~350 字节),高 QPS 下磁盘占用要按此估算。

三个细节:

  • $request 和 $http_user_agent 都在双引号里,所以一行日志里有两对引号。用 awk -F'"' 切分时 $2 是请求行、$4 是 UA——但不要依赖这个,后端加一个带引号的字段就会错位。
  • uri= 用的是 $uri(已解码、已规范化),比从 $request 里切 path 可靠。
  • lrs= 放在最后:没配 limit_req 时它是空串,日志末尾会显示 lrs=,不影响任何按空格切分的逻辑。

格式 B:JSON(推荐接 ELK/Loki/ClickHouse)

   nginx
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"msec":$msec,'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for",'
'"request_method":"$request_method",'
'"uri":"$uri",'
'"args":"$args",'
'"request":"$request",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_length":$request_length,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_cache_status":"$upstream_cache_status",'
'"host":"$host",'
'"server_name":"$server_name",'
'"server_addr":"$server_addr",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent",'
'"request_id":"$request_id",'
'"connection":"$connection",'
'"connection_requests":$connection_requests,'
'"scheme":"$scheme",'
'"limit_req_status":"$limit_req_status",'
'"ssl_protocol":"$ssl_protocol"'
'}';

必须注意:

  1. escape=json 是必须的,否则 UA 里的 " 和 \ 会破坏 JSON;
  2. 数值字段不加引号($status、$request_time),但 $upstream_response_time 可能为空或多值,加引号更安全;
  3. $args 可能包含敏感参数(token、密码),要记录就先在 map 里脱敏;
  4. 字段名要稳定,下游 schema 依赖它。

脱敏示例:

   nginx
map $args $args_masked {
    default $args;
    "~*(token|password|secret)=[^&]*" "[REDACTED]";
}

格式 C:两段式(高流量场景)

当 QPS 超过 2 万或单机日志超过 100GB/天,全字段记录的 IO 成本会显著影响性能。这时分层:

   nginx
# 正常请求不记,异常请求记全量
map $status $loggable {
    ~^[23]  0;
    default 1;
}
access_log /var/log/nginx/access.log json_combined if=$loggable;

代价是丢掉正常请求的统计,所以配套要有一个低采样率的全量日志:

   nginx
# 1% 采样,按 IP+连接号哈希,保证同一连接要么都记要么都不记
split_clients "${remote_addr}${connection}" $sampled {
    1%      "1";
    *       "0";
}
access_log /var/log/nginx/sample.log json_combined if=$sampled;

split_clients 是 nginx 自带的哈希分流模块,比随机采样更稳定可控。

4.2 加字段的代价

成本项影响量级
变量求值每请求多一次格式化单变量约 1~3ns,10 个字段约 20ns,QPS 1 万时可忽略
磁盘 IO每行多几十字节每请求 100 字节,QPS 1 万 = 约 86GB/天
日志轮转大文件 copytruncate 会阻塞用 mv 后 USR1 重开,不能 copytruncate
下游解析JSON 解析比正则快,字段多了内存涨ES 单文档字段别超 1000

实践建议:先用格式 A 跑一周,看确切的体积增量(du -sh 前后对比),再决定要不要上 JSON 全字段。

4.3 真实客户端 IP:realip 与 XFF 的坑

如果前面有 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 写宽了,立刻收窄。

 五、慢请求定位:从现象到根因 

5.1 先明确"慢"的定义

"接口慢"是最没有信息量的一句话。定位前必须把它变成可度量的东西:

问题需要确认
多慢?P50 正常但 P99 爆炸,还是整体平移?
哪些请求?某个 URI、某类参数、某个客户端、某个上游实例?
什么时候开始?突变(发布/配置变更/流量上涨)还是渐变(数据量增长)?
用户可感知吗?前端超时是 5s 还是 30s?慢到超时的请求在日志里是 499 还是 504?

四个问题答不上来就别开始翻日志,翻了也白翻。

5.2 分位数:不要用平均值判断慢

平均值会被两件事骗:一是海量快请求把慢请求稀释掉,二是长时间挂起的少数请求拉高均值却看不出比例。生产上只看三个数: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"

这张表能立刻把"全天都慢"和"某个时段慢"分开。如果只有某一小时慢,去查那一小时发生了什么(发布、定时任务、上游扩容、备份、大促)。如果全天均匀变慢,那是容量或上游代码问题。

5.3 URI 维度聚合:找到慢的集中点

   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、已合并 //、已处理 ../),准确且稳定,成本一个字段。

5.4 rt 与 urt 的差值:判断慢在哪一段

这是整个分析里最重要的一张判断表。前提:$request_time 是从读到客户端第一个字节开始、到写回响应最后一个字节结束;$upstream_response_time 是从与上游建立连接开始、到收到上游响应体结束。所以:

rturt含义下一步查什么
大大(接近 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。

5.5 定位到具体上游实例

多实例部署时,"后端慢"通常只是个别实例慢。$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。

5.6 用 $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

这才是能端到端回答"这个用户那次请求到底发生了什么"的方法。没做这一步之前,所有跨服务排查都是靠时间戳猜。

5.7 慢请求的根因清单与证据

把上面的手段汇总成一张对照表,排查时按顺序排除:

根因日志证据进一步验证
上游应用慢(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

6.1 基础统计:总量、状态码分布、方法分布

   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 涨一点就报警会把人逼疯。

6.2 请求量 Top:IP / URI / UA / Referer

   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 的 [^"]* 正好截到第一个收尾引号为止,不会跨字段。

6.3 大响应体:谁在拖带宽

   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 }'

两个常见异常:

  • 单个 URI 的响应体异常大:多半是接口把整个列表一次性返回了,或者分页参数被绕过(page_size=999999)。这类请求还会拉高上游内存和 GC,值得优先治理。
  • bs 不大但请求数极高:不是带宽问题,是 QPS 问题,看 6.4 信号四的窗口计数。

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 一千多个请求,那不是用户。

6.5 区分真实用户与机器流量

特征真实用户机器流量
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)。)

6.6 用 $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。

6.7 上游维度的健康度

   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 出口就是几百个用户同时报障。

7.1 限流配置与日志联动

   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 设得太紧,先放宽再观察。

上线限流的正确姿势:

  1. 先只加 limit_req_zone 和 log_format 里的 $limit_req_status,不加 limit_req 指令,跑一天。这样日志里能看到"如果当时有限流,会有多少请求被拒"——零风险的影子模式。
  2. 根据影子数据算出的比例,把 rate 设到 当前 P99 峰值的 1.5~2 倍,burst 设到瞬时峰值的余量。
  3. 灰度到单个 location 或单个 server,观察 429 数量和业务侧错误率。
  4. 全量。

$limit_conn_status 同理,需要 1.17.6+ 才有。低于这个版本只能调 limit_conn_log_level,靠 limiting connections 关键字判断是否触发。

两者同时配置在同一 location 时,Nginx 先判 limit_conn 再判 limit_req。所以慢速连接占满的场景会先看到 lcs=REJECTED、lrs 为空——据此可以区分"并发超限"和"速率超限",处理方式完全不同。

7.2 用 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 为什么访问不了",这条日志就是唯一答案。

7.3 封禁的风险

封 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 更短、不会覆盖——这块要么保持简单,要么用两个独立变量做两级判断。

7.4 用 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 级别的一行,很容易忽略。

 八、日志轮转与采集 

8.1 logrotate 与 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,说明还有进程持有已删除的日志文件,磁盘空间不会被释放。

8.2 采集:什么时候该上平台

单机 10GB/天以内的日志,grep/awk 完全够用。但下面几种情况必须上采集平台:

  • 多机(超过 3 台),需要跨机聚合;
  • 需要保留 7 天以上;
  • 需要按任意维度即时查询(不是预先想好的那几个维度);
  • 需要和上游应用日志关联(按 $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 是防止单条畸形日志(被截断的超长行)把内存撑爆的必要配置。

8.3 采集本身的代价

  • 正则解析会消耗 CPU。单机 5 万 QPS 的 Nginx 节点上,本地跑 Fluent Bit 正则解析能吃掉 1~2 个核。要预留余量。
  • 采集端崩溃不能影响 Nginx。Fluent Bit/Vector 要配 Mem_Buf_Limit 和磁盘缓冲,但缓冲满了必须丢数据而不是阻塞,否则会把采集端拖成 Nginx 的瓶颈。宁可丢日志,不能丢服务。
  • escape=json + JSON 日志的体积比文本格式大约 20%~40%,洗数据前先估算磁盘。

 九、指标与告警 

日志分析是事后手段,告警必须用指标。两者要能互相验证。

9.1 从日志衍生的关键指标

指标含义建议告警阈值
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%"才有意义。

9.2 用 nginx-prometheus-exporter 还是自己算

官方 nginx-prometheus-exporter 读 stub_status,能给出:

  • nginx_connections_active/reading/writing/waiting
  • nginx_http_requests_total
  • nginx_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 失败必须立刻恢复备份文件,不能"先放着待会再改"。

 十二、生产注意事项 

  1. 日志格式是长期契约。一旦下游(采集、分析脚本、报表)依赖了字段,改格式就要同步改下游。加字段安全,改字段名、改字段顺序、删字段都可能导致下游静默出错(脚本不报错但结果全错)。

  2. 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。

  3. 日志里不要记录敏感信息。$request_uri 里可能带 token、手机号、身份证。如果必须记,用 map 脱敏:

       nginx
    map $request_uri $safe_uri {
        "~^(?<p>[^?]*)\?.*$"  $p;      # 只保留路径,丢掉 query
        default               $request_uri;
    }

    或者在应用侧就不把敏感参数放 query(改用 POST body)。access.log 是最容易被忽视的敏感数据泄露渠道。

  4. $request_body 千万别默认开。它会记录用户提交的所有内容(密码、支付信息),而且大请求体会撑爆日志。

  5. 分析脚本要能处理脏数据。日志里会出现 rt=-、urt="-"、urt="1.2, 0.3"、空的 $upstream_addr、被截断的行(进程崩溃时)。脚本遇到这些要跳过而不是崩,否则一次异常就让每天的报表任务失败。

  6. 时间字段要带时区。$time_local 用服务器本地时区,多机房部署时如果是 UTC 和东八区混着,跨机分析会错 8 小时。建议统一用 $time_iso8601(ISO 8601 格式,含时区偏移,nginx 1.3.12+)。

  7. "只记慢请求"能做,但收益未必划算。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 极高、磁盘是真的瓶颈时,才值得做条件记录。

  8. $request_time 的精度是毫秒。不要用它做微秒级分析,也不要指望它区分 1ms 和 2ms 的差异。

  9. 限流参数的基线必须来自真实数据。拍脑袋设 rate=10r/s 然后被业务方投诉,是很常见的翻车方式。先跑影子模式,用数据定参数。

  10. 告警和日志分析要能互相印证。如果监控说 5xx 涨了但日志里查不到,先怀疑日志格式变更、采集断流或采样,而不是怀疑监控。

 十三、总结 

Nginx 访问日志分析的核心不是"会敲几个 awk",而是先想清楚要回答什么问题,再决定记什么字段。默认 combined 格式缺失的恰好是所有判断需要的字段:时间花在哪(rt/urt)、哪个上游(ua/us)、是否被限流(lrs)、能否串联(rid)。加这几个字段的成本远低于事后瞎猜的成本。

把方法压缩成一条主线:

  1. 算分位数,不看均值;按时段分桶,先分清"全天慢"还是"某时段慢"。
  2. 按 URI 聚合前先归一化(去掉 ID、UUID),否则长尾会掩盖真问题。
  3. 用 rt - urt 判断慢在哪一段,再对照那张成因表逐项排除。特别注意限流排队造成的"假慢"。
  4. 上游维度聚合找个别实例,不要停在"确认是后端慢"。
  5. 异常流量靠多信号交叉:高频 IP + 高 4xx 率 + 空 UA + 无静态资源 + 全天恒定。任何单一信号都可能误判。
  6. 动作分档:先监控、再限流、最后封禁。限流参数用影子模式定,封禁必须有白名单和审计。
  7. 变更必 nginx -t + reload,失败立刻恢复备份,别留坏配置在磁盘上。

最容易犯的三个错误:用平均值判断延迟、不做 URI 归一化就看聚合结果、算出异常就立刻封 IP。

最后一句实话:日志分析的价值不在于事后查案,而在于让你在下次出问题时,能在 5 分钟内定位而不是 5 小时。 前期花半天把字段加对、脚本写好,后面每一次故障都会还回来。


阅读原文:点击这里​


该文章在 2026/11/4 9:23:59 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-9  粤公网安备44030602007207号