Nginx-11 日志与监控
Nginx 的日志是排查线上问题的第一手资料。这一篇把日志配置、分析方法和监控体系讲全。
1. access_log
1.1 语法
access_log path [format [buffer=size] [gzip[=level]] [flush=time] [if=condition]];
access_log off;
| 参数 | 说明 |
|---|---|
path |
日志文件路径,支持变量(如按域名分文件) |
format |
用哪个 log_format 定义的格式,默认 combined |
buffer=size |
缓冲区大小,攒够了再写盘,默认 64k |
gzip[=level] |
写入前压缩,1-9,默认 1 |
flush=time |
缓冲区最长多久刷一次盘 |
if=condition |
条件记录,变量为空或 0 时不记录 |
可用的作用域:http、server、location、if in location、limit_except。
1.2 buffer 和 flush 的性能影响
# 高流量站点必配:减少 write 系统调用次数
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
不配 buffer 时每个请求都会触发一次 write 系统调用。10 万 QPS 就是 10 万次系统调用,虽然是追加写、内核会合并,但仍有可观开销。
配了 buffer=64k 后,日志攒到 64k(或者达到 flush=5s)才写一次。代价是最多丢 5 秒的日志(如果 Nginx 被 kill -9),排查实时问题时可能不方便。
权衡建议:
- 高流量生产环境:
buffer=64k flush=5s - 需要实时看日志排查问题时:临时去掉 buffer 或改小
- QPS 低于几百:不用配
1.3 关闭不必要的日志
健康检查、静态资源这类高频且无信息量的请求,记日志纯粹浪费磁盘和 I/O:
# 健康检查
location = /health {
access_log off;
return 200 "ok\n";
}
# 静态资源
location ~* \.(png|jpg|jpeg|gif|ico|css|js|woff2?)$ {
access_log off;
expires 30d;
}
# favicon 和 robots.txt 的 404 也不用记
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
log_not_found off 是控制 error_log 里的 “No such file or directory” 记录,和 access_log off 是两件事。
用 map 做更灵活的条件过滤:
map $request_uri $loggable {
default 1;
~^/health$ 0;
~^/metrics$ 0;
~^/ping$ 0;
~\.(png|jpg|css|js|woff2)$ 0;
}
access_log /var/log/nginx/access.log main if=$loggable;
只记录慢请求和错误(磁盘紧张时很有用):
# 只记录 500ms 以上的请求
map $request_time $slow {
default 0;
"~^0\.[5-9]" 1; # 0.5-0.99s
"~^[1-9]" 1; # 1s 以上
}
# 只记录 4xx/5xx
map $status $is_error {
default 0;
~^[45] 1;
}
map "$slow$is_error" $need_log {
default 1; # 任一为 1 就记
"00" 0;
}
access_log /var/log/nginx/slow.log main if=$need_log;
1.4 多份日志与按变量分文件
http {
# 同时写两份:一份完整的,一份只有错误
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
access_log /var/log/nginx/error_only.log main if=$is_error;
# 按域名分文件
access_log /var/log/nginx/$host.access.log main;
# 按日期分文件(不需要 logrotate,但有性能代价)
map $time_iso8601 $log_date {
"~^(?<d>\d{4}-\d{2}-\d{2})" $d;
default "unknown";
}
access_log /var/log/nginx/access-$log_date.log main;
}
路径里用变量的代价:Nginx 每次写日志都要检查文件是否已打开,且无法使用 open_file_cache 优化,还可能因为路径不存在而报错。高流量场景不推荐,用 logrotate 更好。
2. log_format 与变量大全
2.1 预定义格式
# combined 是默认格式
log_format combined '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
输出样例:
1.2.3.4 - - [03/Aug/2026:10:30:15 +0800] "GET /api/user/1 HTTP/1.1" 200 1234 "https://example.com/" "Mozilla/5.0 ..."
这个默认格式的问题:没有 $request_time(不知道慢不慢)、没有 $upstream_*(不知道是 Nginx 慢还是后端慢)、没有 $http_x_forwarded_for(有代理时看不到真实 IP)。生产环境必须自定义。
2.2 常用变量分类整理
请求相关
| 变量 | 说明 |
|---|---|
$request |
完整请求行,如 GET /a?x=1 HTTP/1.1 |
$request_method |
方法 |
$request_uri |
原始完整 URI(含查询串,不解码) |
$uri |
解码规范化后的 URI(不含查询串,rewrite 后会变) |
$args / $query_string |
查询串 |
$arg_name |
某个查询参数 |
$scheme |
http / https |
$server_protocol |
HTTP/1.1 / HTTP/2.0 / HTTP/3.0 |
$host |
优先 Host 头,无则 server_name |
$http_host |
原始 Host 头 |
$request_length |
请求总长度(行 + 头 + 体) |
$content_length |
Content-Length 头 |
$content_type |
Content-Type 头 |
$http_名字 |
任意请求头(横杠转下划线、小写) |
$cookie_名字 |
某个 Cookie |
客户端与连接
| 变量 | 说明 |
|---|---|
$remote_addr |
客户端 IP(realip 生效后是真实 IP) |
$realip_remote_addr |
realip 改写前的原始 IP |
$remote_port |
客户端端口 |
$remote_user |
Basic Auth 用户名 |
$http_x_forwarded_for |
XFF 头原文 |
$server_addr / $server_port |
服务端 IP / 端口 |
$connection |
连接序号(同一连接的多个请求相同) |
$connection_requests |
当前连接上已处理的请求数 |
$pipe |
HTTP 流水线请求为 p,否则 . |
响应与耗时(排查性能必备)
| 变量 | 说明 |
|---|---|
$status |
响应状态码 |
$body_bytes_sent |
响应体字节数 |
$bytes_sent |
总字节数(含响应头) |
$request_time |
Nginx 视角总耗时,从读到第一个字节到写完最后一个字节 |
$upstream_addr |
实际处理的上游地址(重试会有多个,逗号分隔) |
$upstream_status |
上游返回的状态码 |
$upstream_connect_time |
与上游建连耗时 |
$upstream_header_time |
收到上游响应头的耗时 |
$upstream_response_time |
上游完整响应耗时 |
$upstream_response_length |
上游响应长度 |
$upstream_bytes_received |
从上游接收的字节数 |
$upstream_cache_status |
缓存状态 |
$sent_http_名字 |
任意响应头 |
时间与其他
| 变量 | 说明 |
|---|---|
$time_iso8601 |
2026-08-03T10:30:15+08:00(推荐,标准格式好解析) |
$time_local |
03/Aug/2026:10:30:15 +0800 |
$msec |
Unix 时间戳(毫秒精度) |
$request_id |
每请求唯一的 32 位十六进制串 |
$hostname |
机器 hostname(多机日志汇总时必备) |
$pid |
worker 进程 pid |
$nginx_version |
Nginx 版本 |
$ssl_protocol |
TLS 版本 |
$ssl_cipher |
加密套件 |
$ssl_session_reused |
会话是否复用(r / .) |
$gzip_ratio |
gzip 压缩比 |
$limit_req_status |
限流状态(PASSED/DELAYED/REJECTED,1.17.6+) |
$limit_conn_status |
限连状态(1.17.6+) |
2.3 耗时变量的关系(排查慢请求的核心)
客户端 Nginx 上游
│─── 请求 ──────────────→│
│ │
│ ┌─ $upstream_connect_time ─┐
│ │─── 建连 ───────→│
│ └──────────────────────────┘
│ │─── 转发请求 ───→│
│ ┌─ $upstream_header_time ──────────┐
│ │←── 响应头 ──────│ 后端处理
│ └─────────────────────────────────┘
│ ┌─ $upstream_response_time ────────────┐
│ │←── 响应体 ──────│
│ └─────────────────────────────────────┘
│←── 响应 ───────────────│
│
└── $request_time(包含客户端网络传输时间)───────────┘
诊断表:
| 现象 | 结论 | 下一步 |
|---|---|---|
request_time 大,upstream_response_time 小 |
客户端网络慢,或响应体太大 | 看 body_bytes_sent,考虑压缩 / CDN |
upstream_response_time 大,upstream_header_time 也大 |
后端业务处理慢 | 查后端应用(慢 SQL、外部调用) |
upstream_header_time 小但 upstream_response_time 大 |
后端流式输出慢,或响应体巨大 | 看后端是否在慢慢吐数据 |
upstream_connect_time 大 |
后端 accept 队列满 / 网络问题 / 没开 keepalive | 查后端 backlog、开 upstream keepalive |
upstream_addr 有多个地址 |
发生了重试 | 查第一个地址为什么失败 |
2.4 推荐的生产 log_format
JSON 格式(推荐,方便 ELK/Loki 采集)
log_format json escape=json
'{'
'"time":"$time_iso8601",'
'"host":"$hostname",'
'"request_id":"$request_id",'
'"remote_addr":"$remote_addr",'
'"xff":"$http_x_forwarded_for",'
'"server_name":"$server_name",'
'"vhost":"$host",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"protocol":"$server_protocol",'
'"status":$status,'
'"request_length":$request_length,'
'"bytes_sent":$bytes_sent,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"cache_status":"$upstream_cache_status",'
'"referer":"$http_referer",'
'"user_agent":"$http_user_agent",'
'"ssl_protocol":"$ssl_protocol",'
'"ssl_cipher":"$ssl_cipher",'
'"limit_req_status":"$limit_req_status"'
'}';
access_log /var/log/nginx/access.json.log json buffer=64k flush=5s;
escape=json 必须加。它会把日志值里的双引号、反斜杠、控制字符正确转义。不加的话,一个 User-Agent 里带引号的请求就会产生非法 JSON,导致整条日志无法解析——甚至可能被恶意构造来污染日志分析系统(日志注入)。
数值字段不要加引号($status、$request_time、$bytes_sent),这样在 ES/Loki 里才是数值类型,能做聚合和范围查询。但 $upstream_response_time 要加引号,因为重试时它的值可能是 "0.01, 0.02" 这种逗号分隔的字符串。
文本格式(人肉看日志时更方便)
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time" '
'ua="$upstream_addr" us="$upstream_status" '
'cs=$upstream_cache_status id=$request_id';
两种都配上,各写一份:JSON 给日志系统采集,文本给人在服务器上直接 grep/awk。
3. error_log
3.1 语法与级别
error_log path [level];
级别从低到高:debug < info < notice < warn < error < crit < alert < emerg。
只有等于或高于指定级别的才会记录。默认是 error。
# main 块(必须有一个,作用于所有还没被更具体配置覆盖的地方)
error_log /var/log/nginx/error.log warn;
http {
# 可以在 http/server/location 层覆盖
server {
error_log /var/log/nginx/example.com.error.log warn;
location /api/ {
error_log /var/log/nginx/api.error.log info; # 这个接口记更详细
}
}
}
# 完全关闭(不推荐)
error_log /dev/null crit;
生产用 warn。用 error 会漏掉限流告警(limit_req 默认记 error,但 limit_req_log_level 可以调)和一些有价值的警告。用 info 或 notice 会产生大量噪音。
3.2 debug 级别
需要编译时带 --with-debug:
nginx -V 2>&1 | grep -o with-debug
error_log /var/log/nginx/debug.log debug;
# 全站开 debug 日志量极大(一个请求几百行),只对特定 IP 开
events {
debug_connection 192.168.1.100;
debug_connection 10.0.0.0/24;
debug_connection unix:; # 对 unix socket 连接开
}
debug_connection 是排查线上问题的利器:只有你自己的 IP 会产生 debug 日志,其他用户不受影响。
也可以只开某个子系统的 debug:
error_log /var/log/nginx/debug.log debug_http;
# 可选:debug_core debug_alloc debug_mutex debug_event
# debug_http debug_mail debug_stream
3.3 常见 error_log 报错速查
| 报错 | 原因 | 处理 |
|---|---|---|
connect() failed (111: Connection refused) |
后端没起或端口错 | 检查后端进程 |
connect() failed (113: No route to host) |
网络不通 | 检查安全组/防火墙 |
upstream timed out (110: Connection timed out) |
后端超时 | 查后端慢在哪,别急着调 timeout |
no live upstreams while connecting to upstream |
所有后端都被标记为 down | 检查后端健康、max_fails 是否太激进 |
upstream prematurely closed connection |
后端进程崩了/被 OOM kill/主动关连接 | 查后端日志和 dmesg |
upstream sent too big header |
响应头超出缓冲区 | 调大 proxy_buffer_size |
client intended to send too large body |
请求体超过 client_max_body_size |
调大限制 |
too many open files |
fd 不够 | 调 worker_rlimit_nofile + 系统 ulimit -n |
worker_connections are not enough |
连接数不够(反代场景每请求占 2 个) | 调大 worker_connections |
SSL_do_handshake() failed |
TLS 握手失败(协议/套件不匹配、客户端断开) | 一般是扫描器或老客户端,可忽略 |
rewrite or internal redirection cycle |
rewrite 死循环 | 检查 rewrite ... last 逻辑 |
open() ... failed (13: Permission denied) |
文件权限不对 | 检查路径上每级目录的权限和 SELinux |
upstream server temporarily disabled |
被动健康检查踢出了节点 | 正常行为,查为什么失败 |
limiting requests, excess: N by zone "x" |
限流触发 | 正常行为,评估限流阈值是否合理 |
快速统计错误分布:
# 按错误类型统计
grep -oP '\[error\].*?(?=,|$)' /var/log/nginx/error.log \
| sed 's/\*[0-9]*//g' | sed 's/[0-9]\+/N/g' \
| sort | uniq -c | sort -rn | head -20
# 最近 10 分钟的错误
awk -v d="$(date -d '10 min ago' '+%Y/%m/%d %H:%M')" '$0 >= d' /var/log/nginx/error.log \
| grep -E "\[(error|crit|alert|emerg)\]"
4. 日志切割
4.1 logrotate(标准做法)
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily # 每天切割
rotate 30 # 保留 30 份
missingok # 文件不存在不报错
notifempty # 空文件不切割
compress # 压缩旧日志
delaycompress # 延迟一个周期再压缩(最近的那份不压,方便查看)
dateext # 用日期做后缀而不是 .1 .2
dateformat -%Y%m%d
create 0640 nginx adm # 新文件的权限和归属
sharedscripts # 多个文件只跑一次脚本
postrotate
# 关键:给 master 发 USR1,让 worker 重新打开日志文件
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}
为什么必须发 USR1?
logrotate 是通过 rename 把 access.log 改名成 access.log-20260803。但 Nginx 的 worker 进程持有的是打开文件的 fd,rename 不影响 fd——worker 会继续往那个已改名的文件里写。结果是新的 access.log 一直是空的,所有日志都进了昨天的文件。
kill -USR1 让 Nginx 重新打开配置里指定的路径,此时会创建新的 access.log 并写入。
手动测试:
logrotate -d /etc/logrotate.d/nginx # -d 只调试不执行
logrotate -f /etc/logrotate.d/nginx # -f 强制执行一次
4.2 自己写切割脚本(按小时或按大小)
#!/bin/bash
# /usr/local/bin/nginx-log-rotate.sh —— 每小时执行
LOG_DIR=/var/log/nginx
ARCHIVE_DIR=/var/log/nginx/archive
DATE=$(date -d "1 hour ago" +%Y%m%d%H)
PID_FILE=/var/run/nginx.pid
mkdir -p "$ARCHIVE_DIR"
for f in access.log error.log access.json.log; do
[ -f "$LOG_DIR/$f" ] || continue
mv "$LOG_DIR/$f" "$ARCHIVE_DIR/${f}-${DATE}"
done
# 让 nginx 重新打开日志文件
[ -f "$PID_FILE" ] && kill -USR1 "$(cat $PID_FILE)"
# 等 worker 完成切换(老 worker 可能还在写几毫秒)
sleep 1
# 压缩归档
find "$ARCHIVE_DIR" -name "*.log-*" ! -name "*.gz" -mmin +5 -exec gzip {} \;
# 删除 30 天前的
find "$ARCHIVE_DIR" -name "*.gz" -mtime +30 -delete
4.3 容器环境:日志到 stdout
Docker/K8s 的标准做法是把日志写到标准输出,由容器运行时收集:
access_log /dev/stdout json;
error_log /dev/stderr warn;
官方 nginx 镜像默认就是这么配的(用软链接指向 /dev/stdout):
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
注意 stdout 的性能:Docker 的 json-file 日志驱动在高 QPS 下会成为瓶颈(每条日志都要 JSON 编码 + 写文件)。高流量场景建议:
# docker-compose.yml
services:
nginx:
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# 或者直接用 fluentd/syslog driver 避免落盘
4.4 syslog 输出
access_log syslog:server=10.0.0.100:514,facility=local7,tag=nginx,severity=info json;
error_log syslog:server=10.0.0.100:514,facility=local7,tag=nginx_error warn;
# 也支持 unix socket
access_log syslog:server=unix:/dev/log,tag=nginx main;
好处是日志直接走网络,不落本地盘。坏处是 UDP syslog 会丢日志(高流量时尤其明显)。
5. 命令行日志分析
不装任何工具,用 shell 就能做很多分析。这些命令建议存成脚本备用。
5.1 基础统计
LOG=/var/log/nginx/access.log
# 总请求数
wc -l $LOG
# QPS(按秒统计)
awk '{print $4}' $LOG | cut -d: -f2-4 | uniq -c | sort -rn | head -10
# 状态码分布
awk '{print $9}' $LOG | sort | uniq -c | sort -rn
# TOP 20 访问 IP
awk '{print $1}' $LOG | sort | uniq -c | sort -rn | head -20
# TOP 20 URL
awk '{print $7}' $LOG | sort | uniq -c | sort -rn | head -20
# TOP 20 URL(去掉查询参数,聚合同一接口)
awk '{print $7}' $LOG | cut -d'?' -f1 | sort | uniq -c | sort -rn | head -20
# TOP UA
awk -F'"' '{print $6}' $LOG | sort | uniq -c | sort -rn | head -20
# 4xx 的 URL
awk '$9 ~ /^4/ {print $9, $7}' $LOG | sort | uniq -c | sort -rn | head -20
# 5xx 的详细信息
awk '$9 ~ /^5/' $LOG | tail -50
5.2 性能分析(假设 rt= 是第 N 个字段)
用前面推荐的 main 格式(末尾有 rt=...):
# 最慢的 20 个请求
grep -oP 'rt=\K[0-9.]+' $LOG | sort -rn | head -20
# 慢请求的完整信息(>1s)
awk '{for(i=1;i<=NF;i++) if($i ~ /^rt=/) {split($i,a,"="); if(a[2]+0>1) print}}' $LOG | head -30
# 平均响应时间
awk '{for(i=1;i<=NF;i++) if($i ~ /^rt=/) {split($i,a,"="); s+=a[2]; n++}} END{printf "avg: %.4fs (n=%d)\n", s/n, n}' $LOG
# 响应时间分位数(P50/P90/P95/P99)
grep -oP 'rt=\K[0-9.]+' $LOG | sort -n | awk '{a[NR]=$1} END{
printf "P50: %.3fs\nP90: %.3fs\nP95: %.3fs\nP99: %.3fs\nMAX: %.3fs\n",
a[int(NR*0.50)], a[int(NR*0.90)], a[int(NR*0.95)], a[int(NR*0.99)], a[NR]
}'
# 按接口统计平均耗时(找出最慢的接口)
awk '{
uri=$7; sub(/\?.*/,"",uri);
for(i=1;i<=NF;i++) if($i ~ /^rt=/) {split($i,a,"="); sum[uri]+=a[2]; cnt[uri]++}
} END {
for(u in sum) printf "%.4f %6d %s\n", sum[u]/cnt[u], cnt[u], u
}' $LOG | sort -rn | head -20
5.3 JSON 日志用 jq 分析
LOG=/var/log/nginx/access.json.log
# 状态码分布
jq -r '.status' $LOG | sort | uniq -c | sort -rn
# 最慢的请求
jq -r 'select(.request_time > 1) | "\(.request_time) \(.status) \(.uri)"' $LOG | sort -rn | head -20
# 按 URI 聚合平均耗时
jq -r '"\(.uri|split("?")[0]) \(.request_time)"' $LOG \
| awk '{s[$1]+=$2; c[$1]++} END{for(u in s) printf "%.4f %6d %s\n", s[u]/c[u], c[u], u}' \
| sort -rn | head -20
# 缓存命中率
jq -r '.cache_status' $LOG | sort | uniq -c | sort -rn
# 找出某个 request_id 的完整记录(配合后端日志追踪)
jq 'select(.request_id == "a1b2c3d4e5f6...")' $LOG
# 上游耗时超过 2 秒的
jq -r 'select((.upstream_response_time|tonumber?) > 2) | "\(.upstream_response_time) \(.upstream_addr) \(.uri)"' $LOG
# 被限流的请求
jq -r 'select(.limit_req_status == "REJECTED") | "\(.remote_addr) \(.uri)"' $LOG | sort | uniq -c | sort -rn
5.4 攻击特征识别
# 单 IP 请求量异常(CC 攻击特征)
awk '{print $1}' $LOG | sort | uniq -c | sort -rn | head -20
# 某个 IP 在一分钟内的请求数
grep "1.2.3.4" $LOG | awk '{print $4}' | cut -d: -f1-3 | uniq -c | sort -rn | head
# 扫描器特征:大量 404
awk '$9==404 {print $1}' $LOG | sort | uniq -c | sort -rn | head -20
# 扫描的路径
awk '$9==404 {print $7}' $LOG | sort | uniq -c | sort -rn | head -30
# 可疑 UA
awk -F'"' '{print $6}' $LOG | grep -iE "sqlmap|nikto|nmap|masscan|python|curl|wget|scan" \
| sort | uniq -c | sort -rn
# 请求 URI 里的攻击特征
grep -iE "(union.*select|<script|\.\./|/etc/passwd|base64_decode)" $LOG | head -30
# 同一 IP 请求多个不存在的敏感路径(明显是扫描)
awk '$9==404 {print $1, $7}' $LOG | grep -iE "(admin|phpmyadmin|\.env|\.git|wp-)" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head
5.5 goaccess(可视化分析)
apt install goaccess
# 实时终端界面
goaccess /var/log/nginx/access.log --log-format=COMBINED
# 生成 HTML 报告
goaccess /var/log/nginx/access.log --log-format=COMBINED -o /var/www/report.html
# 自定义格式(对应前面的 main 格式)
goaccess $LOG -o report.html \
--log-format='%h - %e [%d:%t %^] "%r" %s %b "%R" "%u" rt=%T %^' \
--date-format='%d/%b/%Y' --time-format='%H:%M:%S'
# 实时更新的 HTML(WebSocket)
goaccess $LOG -o /var/www/report.html --real-time-html --log-format=COMBINED
6. stub_status 基础监控
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
curl http://127.0.0.1/nginx_status
Active connections: 291
server accepts handled requests
16630948 16630948 31070465
Reading: 6 Writing: 179 Waiting: 106
字段含义:
| 字段 | 含义 |
|---|---|
Active connections |
当前活跃连接数(含 Waiting) |
accepts |
累计接受的连接数 |
handled |
累计处理的连接数 |
requests |
累计处理的请求数 |
Reading |
正在读请求头/体的连接数 |
Writing |
正在写响应的连接数 |
Waiting |
空闲的 keepalive 连接数 |
关键判读:
accepts≠handled→ 有连接被丢弃了。原因通常是worker_connections达到上限。这是需要立刻处理的告警。Reading异常高 → 大量连接停在读请求头阶段,Slowloris 攻击特征。正常应该是个位数到几十。requests / handled比值 → 平均每个连接处理了多少请求。接近 1 说明 keepalive 没生效或客户端不复用。Waiting占比高 → keepalive 空闲连接多,正常现象,但如果占满了worker_connections就要调小keepalive_timeout。
7. Prometheus 监控
7.1 方案对比
| 方案 | 指标丰富度 | 部署难度 |
|---|---|---|
nginx-prometheus-exporter |
低(只有 stub_status 那几个) | 简单,不用改 Nginx |
nginx-module-vts |
高(按 server/upstream/cache 分维度) | 需要编译模块 |
nginx-lua-prometheus(OpenResty) |
最高(完全自定义) | 需要 OpenResty |
日志导出(mtail/grok_exporter/Loki) |
高(能算分位数) | 中等 |
7.2 nginx-prometheus-exporter
docker run -d --name nginx-exporter -p 9113:9113 \
nginx/nginx-prometheus-exporter:latest \
--nginx.scrape-uri=http://nginx:8080/nginx_status
暴露的指标很少但够用于基础告警:
nginx_connections_active
nginx_connections_accepted
nginx_connections_handled
nginx_connections_reading
nginx_connections_writing
nginx_connections_waiting
nginx_http_requests_total
nginx_up
7.3 nginx-module-vts(推荐)
git clone https://github.com/vozlt/nginx-module-vts
./configure --add-module=../nginx-module-vts ...
http {
vhost_traffic_status_zone shared:vts:32m;
vhost_traffic_status_filter_by_host on;
# 按 URI 分组统计(注意不要有太多分组,会占大量共享内存)
vhost_traffic_status_filter_by_set_key $uri uri::$server_name;
server {
listen 8080;
allow 10.0.0.0/8;
deny all;
location /status {
vhost_traffic_status_display;
vhost_traffic_status_display_format prometheus;
}
}
}
暴露的指标:
nginx_vts_server_requests_total{host="example.com",code="2xx"}
nginx_vts_server_request_duration_seconds{host="example.com"}
nginx_vts_server_bytes_total{host="example.com",direction="in"}
nginx_vts_upstream_requests_total{upstream="backend",backend="10.0.0.1:8080",code="5xx"}
nginx_vts_upstream_response_seconds{upstream="backend",backend="10.0.0.1:8080"}
nginx_vts_cache_total{cache="my_cache",status="hit"}
nginx_vts_filter_requests_total{filter="uri::example.com",filter_name="/api/user"}
7.4 核心告警规则
groups:
- name: nginx
rules:
# 5xx 比例超过 1%
- alert: NginxHigh5xxRate
expr: |
sum(rate(nginx_vts_server_requests_total{code="5xx"}[5m])) by (host)
/
sum(rate(nginx_vts_server_requests_total{code=~"[1-5]xx"}[5m])) by (host)
> 0.01
for: 3m
labels: {severity: critical}
annotations:
summary: "{{ $labels.host }} 5xx 比例 {{ $value | humanizePercentage }}"
# P95 响应时间超过 1 秒
- alert: NginxSlowResponse
expr: |
histogram_quantile(0.95,
sum(rate(nginx_vts_server_request_duration_seconds_bucket[5m])) by (le, host)
) > 1
for: 5m
labels: {severity: warning}
# 有连接被丢弃(accepts != handled)
- alert: NginxDroppedConnections
expr: rate(nginx_connections_accepted[5m]) - rate(nginx_connections_handled[5m]) > 0
for: 2m
labels: {severity: critical}
annotations:
summary: "Nginx 正在丢弃连接,检查 worker_connections"
# 活跃连接接近上限(假设 worker_processes=8, worker_connections=10240)
- alert: NginxConnectionsNearLimit
expr: nginx_connections_active > 8 * 10240 * 0.8
for: 3m
labels: {severity: warning}
# 上游节点故障
- alert: NginxUpstreamDown
expr: |
sum(rate(nginx_vts_upstream_requests_total{code="5xx"}[5m])) by (upstream, backend)
/
sum(rate(nginx_vts_upstream_requests_total[5m])) by (upstream, backend)
> 0.5
for: 2m
labels: {severity: critical}
# 缓存命中率骤降
- alert: NginxCacheHitRateLow
expr: |
sum(rate(nginx_vts_cache_total{status="hit"}[10m])) by (cache)
/
sum(rate(nginx_vts_cache_total[10m])) by (cache)
< 0.5
for: 10m
labels: {severity: warning}
# Nginx 挂了
- alert: NginxDown
expr: nginx_up == 0
for: 1m
labels: {severity: critical}
# 证书即将过期(需要 blackbox_exporter)
- alert: SSLCertExpiringSoon
expr: probe_ssl_earliest_cert_expiry - time() < 15 * 86400
for: 1h
labels: {severity: warning}
annotations:
summary: "{{ $labels.instance }} 证书 {{ $value | humanizeDuration }} 后过期"
8. 全链路追踪
8.1 用 $request_id 打通日志
Nginx 生成的 $request_id 传给后端,后端在自己的日志里也打这个 ID:
location /api/ {
# 如果上游代理(CDN/SLB)已经传了 ID 就沿用,否则用 Nginx 生成的
proxy_set_header X-Request-ID $request_id;
# 也返回给客户端,用户报障时可以直接提供这个 ID
add_header X-Request-ID $request_id always;
proxy_pass http://backend;
}
沿用上游 ID 的写法:
map $http_x_request_id $trace_id {
default $http_x_request_id; # 上游传了就用它
"" $request_id; # 没传就用 Nginx 生成的
}
location /api/ {
proxy_set_header X-Request-ID $trace_id;
add_header X-Request-ID $trace_id always;
proxy_pass http://backend;
}
Go 后端配合:
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" {
rid = uuid.NewString()
}
c.Set("request_id", rid)
c.Header("X-Request-ID", rid)
// 放进 logger 的上下文,之后所有日志自动带上
logger := slog.With("request_id", rid)
c.Set("logger", logger)
c.Next()
}
}
排查时:
# 1. 用户提供了 request_id
RID="a1b2c3d4e5f67890"
# 2. Nginx 日志
jq "select(.request_id == \"$RID\")" /var/log/nginx/access.json.log
# 3. 后端日志(同一个 ID)
grep "$RID" /var/log/app/app.log
# 4. 甚至能查到具体的 SQL(如果 ORM 日志里也带了 ID)
grep "$RID" /var/log/app/sql.log
这是排查线上问题效率最高的一招。没有它的话,你只能靠时间戳 + IP 模糊匹配,在高并发下几乎不可行。
8.2 OpenTelemetry
Nginx 1.25.3+ 官方提供了 OTel 模块:
load_module modules/ngx_otel_module.so;
http {
otel_exporter {
endpoint otel-collector:4317;
}
otel_service_name nginx-gateway;
otel_trace on;
otel_trace_context propagate; # 传播 W3C traceparent 头
server {
location /api/ {
otel_span_name "api_$request_method";
otel_span_attr http.route $uri;
otel_span_attr user.id $cookie_uid;
proxy_pass http://backend;
}
}
}
otel_trace_context propagate 会生成/传递标准的 traceparent 头,后端的 OTel SDK 能自动接上,在 Jaeger/Tempo 里看到完整的调用链(Nginx → 服务 A → 服务 B → 数据库)。
9. 日志采集架构
9.1 ELK / EFK
# filebeat.yml
filebeat.inputs:
- type: log
enabled: true
paths:
- /var/log/nginx/access.json.log
json.keys_under_root: true
json.add_error_key: true
json.overwrite_keys: true
fields:
log_type: nginx_access
env: production
fields_under_root: true
- type: log
paths:
- /var/log/nginx/error.log
fields:
log_type: nginx_error
multiline.pattern: '^\d{4}/\d{2}/\d{2}'
multiline.negate: true
multiline.match: after
output.elasticsearch:
hosts: ["es-1:9200", "es-2:9200"]
index: "nginx-%{[log_type]}-%{+yyyy.MM.dd}"
setup.template.settings:
index.number_of_shards: 3
index.number_of_replicas: 1
因为 Nginx 直接输出 JSON,Filebeat 不需要 grok 解析——这是用 JSON 格式的最大好处。grok 解析在高流量下 CPU 开销很大,而且正则写错就丢字段。
9.2 Loki(更轻量)
# promtail-config.yml
scrape_configs:
- job_name: nginx
static_configs:
- targets: [localhost]
labels:
job: nginx
env: production
host: ${HOSTNAME}
pipeline_stages:
- json:
expressions:
status: status
method: method
uri: uri
request_time: request_time
upstream_addr: upstream_addr
# 只把低基数字段做成 label,高基数的(uri、request_id)留在日志内容里
- labels:
status:
method:
- metrics:
nginx_request_duration_seconds:
type: Histogram
source: request_time
config:
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10]
Loki 的 label 基数控制很关键:把 uri 或 request_id 做成 label 会产生海量的 stream,直接把 Loki 打爆。只把状态码、方法这类低基数字段做 label,其他靠全文检索。
LogQL 查询示例:
# 5xx 请求
{job="nginx"} | json | status >= 500
# 慢请求
{job="nginx"} | json | request_time > 1
# 按状态码统计速率
sum by (status) (rate({job="nginx"} | json [5m]))
# P95 响应时间
quantile_over_time(0.95, {job="nginx"} | json | unwrap request_time [5m])
# 某个 request_id
{job="nginx"} |= "a1b2c3d4e5f6"
10. 面试题
Q:$request_time 和 $upstream_response_time 有什么区别?怎么用它们定位慢请求?
$request_time 是 Nginx 视角的完整耗时——从读到客户端第一个字节到写完最后一个字节,包含客户端网络传输时间。$upstream_response_time 只是上游处理的耗时。
request_time大但upstream_response_time小 → 客户端网络慢或响应体太大,考虑压缩/CDN。- 两者都大 → 后端业务慢,去查后端。
upstream_connect_time大 → 后端 accept 队列满、网络问题,或者没开上游 keepalive。
Q:log_format 里为什么要加 escape=json?
它会把日志值中的双引号、反斜杠、控制字符按 JSON 规范转义。不加的话,只要有一个请求的 User-Agent 或 URI 里包含双引号,就会产生非法 JSON,导致这条日志无法被解析——甚至可能被恶意构造来注入伪造字段,污染日志分析系统。
Q:logrotate 切割 Nginx 日志时为什么必须 kill -USR1?
logrotate 用 rename 改名,但 Nginx worker 持有的是已打开文件的 fd,rename 不影响 fd,worker 会继续往改名后的文件里写。结果是新建的 access.log 一直是空的。USR1 信号让 Nginx 重新按配置路径打开日志文件,之后才写入新文件。
Q:stub_status 里 accepts 和 handled 不相等说明什么?
有连接被丢弃了。最常见原因是达到了 worker_connections 上限(也可能是 worker_rlimit_nofile 不够)。这是需要立刻处理的问题,说明 Nginx 已经在拒绝服务。
Q:stub_status 的 Reading 数值异常高说明什么?
大量连接卡在「读取请求头/请求体」阶段,这是 Slowloris 慢速攻击的典型特征。正常情况下 Reading 应该是个位数到几十。防护手段是收紧 client_header_timeout、client_body_timeout,配合 limit_conn。
Q:怎么实现 Nginx 到后端的全链路追踪?
用 $request_id(每请求唯一的 32 位十六进制串):proxy_set_header X-Request-ID $request_id; 传给后端,后端在自己的所有日志行里也打这个 ID,同时 add_header X-Request-ID $request_id always; 返回给客户端。用户报障时提供这个 ID,就能在 Nginx 日志、应用日志、SQL 日志里精确定位到同一次请求的全部记录。更完整的方案是 Nginx 1.25.3+ 的 ngx_otel_module,生成标准 W3C traceparent 头对接 Jaeger/Tempo。
Q:access_log 的 buffer 和 flush 参数有什么作用?有什么代价?
不配 buffer 时每个请求都触发一次 write 系统调用。配 buffer=64k flush=5s 后攒够 64KB 或达到 5 秒才写盘,大幅减少系统调用。代价是 Nginx 被 kill -9 时最多丢 5 秒日志,而且实时排查问题时日志有延迟。
Q:把日志做成 Loki 的 label 有什么注意事项?
只把低基数字段(状态码、请求方法、环境)做成 label。把 uri、request_id、remote_addr 这类高基数字段做 label 会产生海量的 stream(每个唯一组合一个 stream),索引爆炸把 Loki 打垮。高基数字段应该留在日志正文里靠全文检索。
Q:error_log 应该设什么级别?为什么不用 error?
生产建议 warn。用 error 会漏掉一些有价值的警告(比如 limit_req 的限流记录默认在 error 级但可通过 limit_req_log_level 调整、upstream server temporarily disabled 的健康检查提示)。用 info/notice 噪音太大。debug 只在排查时临时开,且要配合 debug_connection <你的IP> 限定范围,否则日志量能达到每请求几百行。
上一篇:Nginx-10 HTTPS 与 TLS 配置 | 下一篇:Nginx-12 性能调优实战
xingliuhua