Nginx日志监控:五条聚合与每日报告

访问日志、错误日志、慢日志,大多数站长都开着,但很少有人真的把它当成一个「监控系统」在用。日志里其实躺着大量可以提前预警的信息:某个 IP 突然把某条搜索路径刷了八千次,某个接口的 500 在半小时内翻了三倍,某个静态文件突然每天都在被 404。这些信号肉眼翻日志永远发现不了,但只要几条 cron 加一个脚本,就能每天自动把它们提到你面前。

这篇写的是「离线批处理式」的日志监控:不装 ELK,不上 Prometheus,用 awk、sort、uniq 加上一点点 Python,在凌晨跑一遍,产出一份当天该看的报告。适合日均几十万请求以下的站点,也是上手最快、最不容易半途而废的路子。

一、先把日志格式定死,否则一切都是空谈

Nginx 默认的 combined 格式缺两个关键字段:请求耗时和上游状态。要做事后分析,必须自定义格式,并且从上线第一天就用它。修改后旧日志无法回溯,所以这件事越早越好。

log_format analyzed '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'rt=$request_time urt=$upstream_response_time '
                    'host=$host ua=$upstream_addr';

access_log /var/log/nginx/access.log analyzed buffer=64k flush=5s;

几个细节值得注意。buffer=64k 加 flush=5s 让 Nginx 攒批写盘,高并发下能明显减少 IO;代价是日志会延迟几秒落盘,做实时 tail -f 排障时心里要有数。$upstream_response_time 在出现重试时会是逗号分隔的多个值,比如 0.031, 0.582,这说明后端第一次超时了,第二次才成功——真相在这里,而 $request_time 只反映总耗时。

格式一旦定了,后面所有脚本都依赖字段位置。用固定位置(第 9 个字段是状态码、第 10 个是字节数等)解析会很脆弱,因为 User-Agent 里可能出现空格被引号包住的情况。稳妥的解析方式是按引号切分,而不是按空格切分:awk 的 FPAT 或者 Python 的 shlex 都行,但最简单可靠的是给每个需要提取的字段加一个显式前缀,像上面的 rt=、urt=、host=,然后用正则精确抓取。

二、五个每天必看的聚合口径

日志原始的每一行价值很低,聚合之后才有意义。以下五个口径覆盖了绝大多数「需要提前知道」的情况,每个都是一条 awk 管道。

口径一:状态码分布。不只看 5xx 的总量,更要看它的构成。

awk '{ for(i=1;i<=NF;i++) if($i ~ /^[1-5][0-9][0-9]$/) { print $i; break } }' \
  /var/log/nginx/access.log | sort | uniq -c | sort -rn

健康站点的形态通常是 200 占九成、304 占几个百分点、404 占几个百分点、5xx 接近 0。如果 499 突然变多,说明客户端等不及主动断开,多半是后端变慢;如果 502/504 变多,方向在上游;如果 404 突然翻倍,先怀疑某个资源被改路径或某个爬虫在扫。

口径二:最慢的路径。按 URL 聚合平均耗时与 P95,比看单条慢请求有用得多。

awk -F'rt=' '{ split($2,a," "); if (a[1]+0 > 1.0) print $0 }' /var/log/nginx/access.log \
  | grep -oE '"[A-Z]+ [^"]+"' | sort | uniq -c | sort -rn | head -20

这一步的目标不是找出「哪一次请求慢」,而是找出「哪一类请求总是慢」。总是慢的路径才是值得优化的,偶发的一次慢往往只是数据库抖了一下。

口径三:单 IP 请求量与集中度。

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

只看 top 20 是不够的,还要算一个比例:top 10 个 IP 的请求量占当日总量的多少。正常内容站这个比例通常低于 10%,如果超过 30%,不是被采集就是某个 CDN 回源没处理好,让同一个边缘节点把请求全打到源站。

口径四:404 集中路径。被大量请求的 404 往往意味着两件事:站内有断链,或者有人在探测漏洞。

awk '$9 == 404 { print $7 }' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

看结果里 URL 的形态就能判断:/wp-login.php、/.env、/phpmyadmin 这类是扫描;/uploads/2025/xx.jpg 是断链;/tech/xxx.html 是页面被删但外链还在。

口径五:爬虫占比。用 User-Agent 里包含的关键词做粗分类,比精确解析可靠。

awk -F'"' '{print $6}' /var/log/nginx/access.log \
  | grep -oiE 'googlebot|bingbot|baiduspider|yandex|ahrefs|semrush|mj12|python-requests|curl' \
  | sort | uniq -c | sort -rn

这个口径的价值在于发现「伪装爬虫」:如果 python-requests 或 curl 的请求量比 Googlebot 还高,说明有采集器在批量抓你的站,而它可能压根不看 robots.txt。

三、把这些口径做成一份每天七点自动送达的报告

脚本不要写成一个大泥球。分成「采集」与「渲染」两步:先用 awk 把五个口径的结果各写进 /var/log/report/sections/ 下的小文件,再由一个 Python 脚本拼成 HTML。这样单条口径出错不影响其它,也方便以后加新指标。

#!/bin/bash
set -euo pipefail
DAY=$(date -d yesterday +%Y%m%d)
LOG="/var/log/nginx/access.log.1"
OUT="/var/log/report/$DAY"
mkdir -p "$OUT"
[ -f "$LOG" ] || { echo "no log for $DAY"; exit 1; }
TOTAL=$(wc -l < "$LOG")
awk '{ for(i=1;i<=NF;i++) if($i ~ /^[1-5][0-9][0-9]$/) { print $i; break } }' "$LOG" \
  | sort | uniq -c | sort -rn > "$OUT/codes.txt"
awk '{print $1}' "$LOG" | sort | uniq -c | sort -rn | head -20 > "$OUT/ips.txt"
awk '$9 == 404 { print $7 }' "$LOG" | sort | uniq -c | sort -rn | head -30 > "$OUT/404.txt"
echo "total=$TOTAL" > "$OUT/summary.txt"
python3 /usr/local/bin/log_render.py "$OUT"

注意 LOG 指向 .log.1,因为 logrotate 通常在凌晨切分,七点跑的时候当天日志是新的、昨天的已经躺在 .log.1 里。如果 logrotate 配置成压缩,这里就要先 zcat 解压再分析,或者把 delaycompress 打开——后者更省 CPU,也更省事。

cron 那一行要加上时间约束,避免和备份、数据库维护抢 IO:

0 7 * * * /usr/local/bin/log_report.sh >> /var/log/report/cron.log 2>&1

四、让报告「异常才出声」,正常就安静

绝大多数自制监控死于同一件事:它每天都发一份一样的报告,人看了三天就不看了,第四天开始直接忽略。要打破这个循环,唯一的办法是让报告在正常情况下足够安静,只在超过阈值时说一句话。

做法是给每个口径定一个阈值,只在越界时推送。阈值不要拍脑袋,用历史数据算:把最近 30 天每天的 5xx 量取出来,取 P95 作为基准线,再乘 2 作为告警线。这样阈值会随站点规模自然变化,不需要每季度手工调。

#!/bin/bash
# 5xx 越界告警:与最近 30 天 P95 的两倍比较
TODAY=$(awk '{ for(i=1;i<=NF;i++) if($i ~ /^5[0-9][0-9]$/) { print $i; break } }' \
  /var/log/nginx/access.log.1 | wc -l)
BASE=$(cat /var/log/report/history_5xx.txt | sort -n | awk '{a[NR]=$1} END{print a[int(NR*0.95)+1]}')
LIMIT=$(( BASE * 2 + 50 ))
if [ "$TODAY" -gt "$LIMIT" ]; then
  echo "5xx 异常: 今日 $TODAY, 基准 P95 $BASE, 阈值 $LIMIT" | mail -s "nginx 告警 $TODAY" ops@example.com
fi
echo "$TODAY" >> /var/log/report/history_5xx.txt
tail -n 60 /var/log/report/history_5xx.txt > /tmp/h && mv /tmp/h /var/log/report/history_5xx.txt

+50 这个常数是为了防止小站点基数太低导致误报:如果基准是 3 次 5xx,两倍是 6,那么今天出现 7 次并不值得半夜叫你起来。加一个绝对下限,噪音会少掉一大半。

五、报告目录本身也是攻击面

这些报告里有 IP、有访问路径、有 UA,如果放在网站根目录下并且可被直接访问,等于把服务器日志公开了。三条基本要求:

  • 目录在 web 根目录之外,或者用 Nginx 显式 deny 掉 /report/。
  • 如果要通过浏览器看,加一层 HTTP Basic 认证,不要靠「URL 猜不到」当保护。
  • 报告文件保留 30 天就够,历史 HTML 每天几十 KB 到几百 KB,一年下来也是几个 G。用 find /var/log/report -name "*.html" -mtime +30 -delete 挂在同一个 cron 里。

六、一个真实的排查过程:从报告到根因

曾经有个站的报告连续三天显示「404 总量比基准高出四倍」,但站点本身没有改动。第一步看 404 集中路径,发现绝大部分来自同一个前缀 /uploads/2023/,且 UA 里有一半是 Googlebot。第二步抽样几条原始日志,发现请求的文件名都是真实存在过的图片名,只是路径里的年份目录少了后缀。第三步去看存储层,才发现前一周做过一次目录结构整理,把 /uploads/2023/ 下的文件按月份重新分了子目录,旧的直接访问路径全部失效。

整个排查不到二十分钟,因为结论已经在报告里了——404 的聚合口径直接指出了「哪一批路径集中失效」。如果没有这个口径,这个问题的表现只会是「最近收录掉了不少」,等到发现时已经是两周之后。

这类「先看聚合、再看原始、最后定位」的三段式路径,是日志监控真正的用法。具体指标叫什么并不重要,重要的是每天都有一个固定的入口,让你在症状变成事故之前注意到它们。

七、几个容易踩的坑

日志轮转与脚本竞争。如果分析脚本在 logrotate 正在切分时读取,可能拿到半截文件或者读到一个刚被重命名的 inode。解法是让 logrotate 用 copytruncate,或者让分析脚本只读 .log.1 这类已经稳定的文件(上面的写法就是这么做的)。

gzip 压缩的历史日志。分析一周以上的数据时必须解压,直接 cat *.gz 会输出二进制垃圾,后面所有聚合都是错的。判据:如果 wc -l 得到一堆乱码行,就是忘了 zcat。

时区不一致。Nginx 日志的时间是服务器本地时区,而很多云主机默认 UTC。如果操作者在另一个时区看报告,「昨天」的边界就会错开八小时。要么统一服务器时区并写进文档,要么在脚本里显式做换算,别指望凭感觉。

awk 的字段号被引号破坏。$9 == 404 只在日志格式固定且 UA 中没有空格时成立。一旦某个 UA 里含空格(这是常态),字段就错位。生产环境应该像第一节那样给每个字段加前缀,用正则提取,而不是依赖位置。

报告与真实流量的偏差。如果站前面有 CDN,Nginx 看到的可能已经是清洗过的流量,甚至只有 CDN 回源的请求。这时候 $remote_addr 是 CDN 节点的地址,真实客户端在 X-Forwarded-For 里。分析前先确认这一点,否则「top IP」永远只有一个,看着像被 DDoS,其实是没配 real_ip。

八、小结

日志监控的本质不是「攒一堆指标」,而是建立一条固定的、每天都会走的路径:从五个聚合口径出发,异常时才出声,正常时安静。技术上它只需要 awk、sort 和 cron,成本极低;难点在于坚持和克制——不要一次加二十个指标,不要每天都发一份没人看的报告,也不要为了让报告好看而发明没有诊断价值的数字。把上面五个口径跑满一个月,你会对自己的站产生一种以前没有的直觉,这比任何工具都值钱。

Last modification:November 8th, 2026 at 12:28 pm

Leave a Comment