一、日志里没有"关于你的记录"
有一天查一个问题:某个页面的 404 突然从每天几十次涨到几千次。我打开访问日志准备看来源,结果是——没有。日志里只有那些我已经知道的、正常的请求,坏请求一个都没留下。
原因很简单:Nginx 的 log_format 是按固定字段拼的,我当年只记了 $remote_addr、$request、$status 和 UA。而那次 404 的元凶是查询串里的参数被拼错了,$request 里其实带着,但日志按空白分隔、我又用 awk 切了第七列,恰好把那部分吃掉了。更糟的是,我当时为了"日志干净",还把状态码小于 400 之外的都给关了(access_log off 写在某个 location 里),于是真相连写都没写。
这件事之后我重做了一遍日志格式。本文讲的就是这件事:Nginx 日志字段怎么挑、怎么切分文件、怎么在不炸盘的前提下把该有的信息留下来。
二、log_format 里真正值钱的字段
Nginx 变量有上百个,但日常排障真正用得上的不超过十五个。下面这套是我在内容站上稳定用了两年的配置:
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time '
'urtc=$upstream_connect_time urh=$upstream_cache_status '
'hx=$host sn="$server_name" '
'rid=$request_id';逐段解释关键的几个:
$request_time:从读到请求第一个字节到发出响应最后一个字节,单位秒,精度毫秒。这是用户视角的耗时;$upstream_response_time:后端处理耗时。两个值相减,留出来的就是 Nginx 自身 + 网络传输的时间;$upstream_cache_status:HIT/MISS/BYPASS/EXPIRED。没有这个字段,你永远不知道自己配的缓存到底命中没命中;$request_id:需要uuid4之类模块,但它让"前端报错"和"后端日志"能对上号,价值极高;$host:多域名服务器上,少了这个字段根本分不清是哪个站出的问题。
注意 $upstream_response_time 在有重试或内部跳转时会出现逗号分隔的多个值,比如 0.120, 3.400。写告警阈值时要取最后一个或者求和,按"整串转数字"处理会得到错误结果。这是我在统计里踩过的坑。
三、字段里的两个"说谎者"
$remote_addr 不等于真实用户 IP。站点在 CDN 后面时,它是 CDN 节点的地址。真实的用户 IP 在 X-Forwarded-For 里,但抓这个头有三个前提条件:CDN 回源时确实加了;Nginx 信任的代理层配了 set_real_ip_from;客户端没有伪造过这个头。
# 只信任 CDN 回源网段,不要写 0.0.0.0/0
set_real_ip_from 203.0.113.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;如果 set_real_ip_from 写成全网段,攻击者只要自己加一个 X-Forwarded-For 就能把日志里的 IP 改成任意值,你在封禁时可能封掉别人。这一点务必确认。
$http_user_agent 会带引号。如果 UA 里本身有双引号(有些爬虫工具会这么干),日志行就会被多切出一段,awk 的列号全部错位。稳妥的做法是在字段外统一用单引号包围,或者在采集端用正则而不是按列切分。
四、切分策略:不炸盘是底线
日志炸盘是内容站最常见的"低级事故"。一个日 PV 二十万的站点,如果每个请求 400 字节,一天就是 80MB,一年 30GB,何况还有 404 探测和蜘蛛请求。真正让盘满的通常不是正常流量,而是被批量扫描的那几天。
分文件是第一步。把不同类型的请求放进不同文件,好处不只是好查,还能给它们设不同的保留期:
map $status $loggable {
~^[23] 1;
default 0;
}
# 静态资源单独一份,只留 3 天
location ~* \.(jpg|png|css|js|woff2)$ {
access_log /var/log/nginx/static.log main buffer=64k flush=5m;
access_log off if=$loggable; # 2xx/3xx 不写
}buffer=64k flush=5m 这两个参数非常关键:前者让 Nginx 攒够 64KB 才写一次盘,后者保证最多 5 分钟后也会刷出去。没有缓冲时,每个请求一次 write(),在并发高时日志写入本身就是可观的 IO 开销。
轮转用 logrotate,保留期按类型分开:主站日志 14 天、静态 3 天、错误日志 30 天。压不压缩取决于你要不要"很久以前翻一次"——压缩后 zgrep 一样能查,代价是多一次解压,一般值得。
daily
rotate 14
dateext
compress
delaycompress
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscriptdateext 让文件名变成 access.log-20261102,比 .1 .2 .3 好找得多。delaycompress 保证"刚刚转出去的那一份还是明文",出了事不用现解压。
五、几个不用装软件就能查的问题
有了完整字段,日常排查几乎都是单行命令。
哪个 URL 最慢?按 $request_time 排序取前十:
awk -F'rt=' '{split($2,a," "); if (a[1]+0 > 1) print a[1], $0}' /var/log/nginx/access.log | sort -rn | head -10TTFB 慢但总时长正常,还是反过来?对比 rt 与 urt:
awk '{match($0,/rt=([0-9.]+) urt=([0-9.]+)/,m); if (m[1]+0 > 2 && m[2]+0 < 0.5) print}' /var/log/nginx/access.log | head如果 rt 很大而 urt 很小,问题在 Nginx 与用户之间(多半是大文件传输或客户端太慢);反过来则是后端慢。
缓存命中率是多少?
grep -o 'urh=[A-Z]*' /var/log/nginx/access.log | sort | uniq -c | sort -rn这个数如果 MISS 占九成,说明你配的缓存键有问题,或者缓存时间太短,白忙一场。
哪些扫描器在扫?
awk '$9 == 404 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10(这里的 $9 是上面 main 格式里 status 的位置,改了字段顺序就要改列号——这也是我一直建议给日志配一个"列号注释"的原因。)
六、错误日志同样要分层
error_log 默认 warn 级别就够了,但有两个级别必须单独看:
error:上游超时、连接被拒、配置文件错误,都是要立刻处理的;crit及以上:worker 异常退出、共享内存失败,这类几乎必然有用户已经受影响。
区分方法是在不同 server 块里指定不同的 error_log 文件,把 crit 单独落一个文件,直接对接告警:
error_log /var/log/nginx/critical.log crit;
error_log /var/log/nginx/error.log warn;Nginx 允许同一个 server 配多条 error_log,级别不同写不同文件,这个特性很少人用,但在"我要告警,但不想被 warn 淹没"的场景下非常合适。
七、如果 PCRE 不够用,就交给下游
awk 和 grep 能解决八成问题,剩下的两成通常是"聚合":按小时、按状态码、按上游分组求均值。这类需求硬用 awk 会写成一本天书,不如交给一个轻量的日志采集端。
我的选择是尽量不引入重型组件。一个折中的做法是每天凌晨跑一次脚本,把前一天的日志聚合成一份 JSON 日报,只保留十个维度(总请求、各状态码数、p50/p95 耗时、慢 URL 前十、404 来源前十、UA 分布前五、缓存命中率、错误日志条数、带宽估算),落到一个静态页面上。这份日报比任何仪表盘都更容易被人真正读完。
# 用 sort + awk 现算 p95(不需要 pandas)
awk '{match($0,/rt=([0-9.]+)/,m); print m[1]}' access.log | sort -n | \
awk '{a[NR]=$1} END {print "p95:", a[int(NR*0.95)]}'关键在于:日报要能回答"昨天有什么异常"。如果看完整份报告还得再去看日志,那这份报告就是失败的。
八、给日志格式留一条"以后能加字段"的路
日志格式一旦定下来,中途加字段就会导致新旧数据对不齐,历史统计全部断裂。两个规避办法:
一是用 key=value 形式(上面配置里的 rt= urt=),加字段只需要在末尾追加,老数据仍能用"缺省为 0"的方式解析;二是保留一个 extra 字段,把当天临时要看的变量拼进去,比如某个实验性的头,看完就删掉,不影响主格式。
反过来最糟糕的设计是纯空格分隔、没有 key 的格式,例如把 referer 和 UA 直接拼在最后。字段一多,人就记不住第几列是什么,每次排查都要先数一遍。
九、小结
日志格式这件事的特点是:配置一次,受益几年;配错了,几年都在猜。我给自己定的三条规矩是:
- 时间必须拆成
rt和urt两个数,否则永远分不清是"用户慢"还是"服务器慢"; - 缓存状态、真实 IP、请求 ID 必须有,这三样决定你能不能复盘一次故障;
- 任何新增字段都用
key=value,随时可加,不破坏历史。
至于日志保留多久、要不要上集中采集,那是量的问题。日均几十万 PV 的站点,本地文件 + logrotate + 一份压缩日报就足够了,过早引入 Elasticsearch 只会让人把时间花在维护它上面,而不是看数据。
最后一句实话:日志的价值不在于"记录得全",而在于你出问题时真的会去看。配一个自己愿意读的格式,比配一个理论上最完整的格式更有用。