Nginx 错误页与 JSON 日志格式实战:error_page 接管、fastcgi 拦截与 jq 日志分析

为什么个人站长也该重视错误页和日志格式

很多人做站的习惯是:站点能打开、收录在涨,就万事大吉。直到某天搜索引擎把「无法访问」的错误页收录进去了,或者半夜用户反馈「点开一篇老文章显示白屏」,你翻遍 Nginx 日志却发现里面只有一堆看不懂的默认格式行,根本定位不到问题。错误页和日志格式这两件事,平时看起来是「锦上添花」,真出事的时候就是「救命稻草」。这篇文章把这两个话题放在一起讲,因为它们服务于同一个目标:让站点在用户和搜索引擎眼里是「干净、可用、可诊断」的。

先厘清一个观念:错误页不是「给用户看的道歉信」,日志格式也不是「给运维看的流水账」。它们其实是站点对外暴露的两种接口——错误页是给用户和搜索引擎爬虫看的,日志是给未来的你自己看的。这两种接口设计得好不好,直接决定了站点在出现异常时是「优雅降级」还是「雪崩式失控」。

先搞清楚 Nginx 默认错误页会带来什么问题

Nginx 对 4xx、5xx 状态码的默认响应是一个极简的 HTML 页面,类似这样:


404 Not Found

404 Not Found


nginx

问题有三层:第一,它暴露了服务器软件是 Nginx,属于信息泄露,虽然不严重但没必要主动告诉扫描器;第二,它对用户极不友好,用户看到「404 Not Found」加一个 nginx 字样,只会以为站点挂了,直接关掉;第三,也是最容易被忽略的——如果这个错误页没有正确返回 404 状态码(比如用 rewrite 把所有请求都重写到首页却返回 200),搜索引擎会把错误页当正常页面收录,你的索引里就会塞满垃圾。

补充一点:4xx 和 5xx 的处理策略应该完全不同。4xx 是「用户或爬虫请求的东西不存在」,这是正常业务的一部分,可以给一个漂亮的导航页引导用户去别的文章;5xx 是「服务器自己出问题了」,这时用户最需要的是被告知「不是你的问题,等一下就好」,而不是被塞一堆推荐阅读。混在一起做,反而会让用户的认知更混乱。

用 error_page 统一接管错误响应

标准做法是在站点的 server 块里显式声明错误页映射,并且让静态错误页文件存在:

server {
    listen 443 ssl http2;
    server_name www.example.com;
    root /www/wwwroot/example;

    error_page 400 403 404 /404.html;
    error_page 500 502 503 504 /50x.html;

    location = /404.html {
        root /www/wwwroot/example/static;
        internal;
    }

    location = /50x.html {
        root /www/wwwroot/example/static;
        internal;
    }
}

这里有几个细节值得展开。第一,error_page 后面跟的路径是「以 root 为基准的 URI」,不是文件系统路径;如果你想让错误页文件放在别处,就必须单独写一个 location = 并指定它自己的 root。第二,加 internal; 非常关键。不加的话,用户可以手动访问 /404.html,这时 Nginx 返回的是 200 状态码,等于你亲手制造了一个「看起来正常的垃圾页」,搜索引擎照样收录。internal 让这个 location 只能被内部重定向命中,外部直接访问会得到 404,干净利落。

还有一个常见误区:把 404 指向首页。有些老一辈的 SEO「技巧」建议这么做,说是「留住用户」。这在 2026 年完全是有害的——用户点了一个不存在的链接,结果被强行送到首页,会立刻认为你在耍花招;搜索引擎爬到一个「所有链接都跳首页」的站点,会判定为典型的作弊模式。错误页就该老老实实返回错误码。

Typecho 等 PHP 站点的错误页要注意别被 PHP 抢走

如果你的站点是 Typecho、WordPress 这类 PHP 程序,情况会复杂一层:文章不存在时,是 PHP 自己输出 404 页面,Nginx 的 error_page 根本不会触发。这时候有两种选择。一种是接受 PHP 的错误页(Typecho 自带 404.php 模板),但要在模板里确保 http_response_code(404) 被正确设置;另一种是在 Nginx 层做兜底,把 PHP 返回的 404 也拦截掉:

location ~ \.php$ {
    fastcgi_pass unix:/tmp/php-cgi-74.sock;
    fastcgi_index index.php;
    include fastcgi.conf;

    # 让 fastcgi 返回 404 时走统一错误页
    fastcgi_intercept_errors on;
    error_page 404 /404.html;
}

fastcgi_intercept_errors off 是 Nginx 的默认值,意思是「后端返回的错误码直接透传给用户,我不插手」。改成 on 之后,PHP 返回的 404、502 才会被 error_page 接管。这里要小心:如果你的 PHP 程序返回的 500 页面里带了调试信息(比如 Typecho 的异常堆栈),开启拦截后反而会丢失这些信息,排查问题时会不方便。建议的做法是日常开启拦截保证用户体验,排查阶段临时关掉看真实报错。

另外要提醒一个安全细节:Typecho 的异常页面在 config.inc.php 里由 define('__TYPECHO_DEBUG__', true) 控制。上线前一定要确认这个值是 false 或者根本没定义,否则任何一次数据库连接失败都会把用户名、主机地址甚至栈信息直接打到用户浏览器上,这比错误页难看严重得多。

把默认日志换成 JSON 格式,排查效率翻倍

Nginx 默认的 combined 日志格式是这样的:

log_format combined '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

它能用,但字段全靠空格分隔,User-Agent 里带空格,用 awk 切列经常切错;而且它不包含任何性能信息,你无法回答「哪个请求慢」这个问题。改造方式是定义一套 JSON 格式:

log_format json_combined escape=json
'{'
  '"time":"$time_iso8601",'
  '"remote_addr":"$remote_addr",'
  '"x_forwarded_for":"$http_x_forwarded_for",'
  '"method":"$request_method",'
  '"uri":"$request_uri",'
  '"status":$status,'
  '"bytes":$body_bytes_sent,'
  '"rt":$request_time,'
  '"urt":"$upstream_response_time",'
  '"referer":"$http_referer",'
  '"ua":"$http_user_agent",'
  '"host":"$host",'
  '"scheme":"$scheme"'
'}';

关键点是 escape=json。Nginx 0.9.6 之后支持这个参数,它会把引号、反斜杠、控制字符自动转义,否则 User-Agent 里一个双引号就能把整行 JSON 弄成非法结构,你的日志采集器会直接解析失败。另外注意 "status":$status"bytes":$body_bytes_sent 没有加引号——这两个是数字,输出成 JSON number 类型后续聚合统计更方便;urt 加了引号,因为上游响应时间可能是多个值用逗号拼接的字符串(多级代理时),也可能是 -

定义好之后别忘了真正引用它:access_log /www/wwwlogs/example.log json_combined;。如果只定义了 log_format 却没有改 access_log 指令,日志依然是老格式,这是新手最常见的空转。

JSON 日志怎么真正用起来

日志写成 JSON 只是第一步,用起来才有价值。最简单的用法是配合 jq 做即时查询。比如找出今天所有响应时间超过 3 秒的请求:

grep '"time":"2026-09-21' /www/wwwlogs/example.log \
  | jq -c 'select(.rt > 3) | {uri, rt, status, ua}' \
  | head -20

再比如统计哪些 URI 的 404 最多,用于发现死链:

jq -r 'select(.status == 404) | .uri' /www/wwwlogs/example.log \
  | sort | uniq -c | sort -rn | head -30

统计各个上游响应时间分位数,判断后端到底慢不慢:

jq -r 'select(.urt != "-") | .urt' /www/wwwlogs/example.log \
  | sort -n | awk '{a[NR]=$1} END{print "p50="a[int(NR*0.5)], "p95="a[int(NR*0.95)], "max="a[NR]}'

如果站点流量不大,这样直接跑完全够用。流量大了之后,把 JSON 日志交给 Loki、Vector 或 GoAccess(GoAccess 支持自定义 JSON 格式的 log-format)做集中分析,就不用再关心单机文件有多大了。个人站的一个实用折中是:保留 JSON 明文日志给实时排查,同时用 GoAccess 每天生成一份静态报告放在 /report/ 目录下加访问密码,随时能看趋势。

日志轮转别忘了一起改

换了日志格式之后,logrotate 的配置本身不用改,但有一个坑必须提醒:如果用了 logrotate,务必确认 postrotate 段里向 Nginx 发的 USR1 信号是有效的,否则 Nginx 会继续往已经被重命名(甚至已删除)的文件句柄里写,你的新日志文件会一直是空的,磁盘上的旧文件却在持续膨胀直到撑爆分区。标准配置长这样:

/www/wwwlogs/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        [ -f /www/server/nginx/logs/nginx.pid ] && kill -USR1 $(cat /www/server/nginx/logs/nginx.pid)
    endscript
}

验证方法很简单:手动执行一次 logrotate -f /etc/logrotate.d/nginx,然后看新日志文件有没有内容写入、旧文件有没有被压成 .gz。如果新文件十分钟都不涨,说明信号没生效,赶紧查 nginx.pid 路径对不对。另外 delaycompress 要保留,它让最近一份轮转文件暂不压缩,方便你继续 grep 昨天的日志。

给 50x 页面加一点自救逻辑

502 / 504 是个人站长最常遇到的错误,尤其在小内存 VPS 上。一个实用的做法是在 50x.html 里放一小段 JavaScript,自动隔几秒重试一次,并提示用户「服务器正在重启,请稍候」。但要克制——重试次数必须限制,否则会给刚恢复的后端再压一层流量:

var retry = 0;
function reloadOnce() {
    if (retry >= 3) {
        document.getElementById('tip').textContent = '请稍后手动刷新页面';
        return;
    }
    retry++;
    setTimeout(function () { location.reload(); }, 5000);
}

这段代码的作用是无状态提示,不要额外引入任何外部脚本或图片,因为 50x 的时候你的静态资源域名很可能也是挂的。更稳妥的做法是把这段 JS 直接内联写在 50x.html 里,不产生任何额外请求。

检查清单

  • 4xx 与 5xx 分别映射到独立错误页,且 location = 里加了 internal;
  • 错误页文件真实存在,权限可读(chmod 644,属主与 Nginx 运行用户一致)
  • PHP 站点确认 fastcgi_intercept_errors 的取舍,避免调试信息泄露或错误页失效
  • 确认生产环境 __TYPECHO_DEBUG__ 为 false
  • 日志格式使用 escape=json,数值字段不加引号,且 access_log 已引用新格式
  • logrotate 的 USR1 信号路径正确,手动验证过一次
  • jq 或 GoAccess 至少跑通一次真实查询,确认 JSON 可解析

小结

错误页和日志格式都是「低成本、高回报」的运维投入。改一次 error_pagelog_format,大约半小时的工作量,但之后每一次故障排查都会省下你几十分钟的猜测时间,搜索引擎侧也能避免错误页被误收录的长期损耗。个人站长精力有限,正因如此,更应该把功夫下在这种「一次配置、长期受益」的地方,而不是每次出事都靠 SSH 上去 tail -f 干瞪眼。把这两件事做完,你的站点在「可观测性」这一项上就已经超过大部分同规模个人站了。

Last modification:September 21st, 2026 at 10:25 pm

Leave a Comment