目录

Nginx-11 日志与监控

前置阅读:Nginx-10 HTTPS 与 TLS 配置

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 时不记录

可用的作用域:httpserverlocationif in locationlimit_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 可以调)和一些有价值的警告。用 infonotice 会产生大量噪音。

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 是通过 renameaccess.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 连接数

关键判读

  1. acceptshandled → 有连接被丢弃了。原因通常是 worker_connections 达到上限。这是需要立刻处理的告警。
  2. Reading 异常高 → 大量连接停在读请求头阶段,Slowloris 攻击特征。正常应该是个位数到几十。
  3. requests / handled 比值 → 平均每个连接处理了多少请求。接近 1 说明 keepalive 没生效或客户端不复用。
  4. 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 基数控制很关键:把 urirequest_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_statusacceptshandled 不相等说明什么?

有连接被丢弃了。最常见原因是达到了 worker_connections 上限(也可能是 worker_rlimit_nofile 不够)。这是需要立刻处理的问题,说明 Nginx 已经在拒绝服务。

Q:stub_statusReading 数值异常高说明什么?

大量连接卡在「读取请求头/请求体」阶段,这是 Slowloris 慢速攻击的典型特征。正常情况下 Reading 应该是个位数到几十。防护手段是收紧 client_header_timeoutclient_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_logbufferflush 参数有什么作用?有什么代价?

不配 buffer 时每个请求都触发一次 write 系统调用。配 buffer=64k flush=5s 后攒够 64KB 或达到 5 秒才写盘,大幅减少系统调用。代价是 Nginx 被 kill -9 时最多丢 5 秒日志,而且实时排查问题时日志有延迟。

Q:把日志做成 Loki 的 label 有什么注意事项?

只把低基数字段(状态码、请求方法、环境)做成 label。把 urirequest_idremote_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 性能调优实战