为什么个人站长也该重视错误页和日志格式
很多人做站的习惯是:站点能打开、收录在涨,就万事大吉。直到某天搜索引擎把「无法访问」的错误页收录进去了,或者半夜用户反馈「点开一篇老文章显示白屏」,你翻遍 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_page 和 log_format,大约半小时的工作量,但之后每一次故障排查都会省下你几十分钟的猜测时间,搜索引擎侧也能避免错误页被误收录的长期损耗。个人站长精力有限,正因如此,更应该把功夫下在这种「一次配置、长期受益」的地方,而不是每次出事都靠 SSH 上去 tail -f 干瞪眼。把这两件事做完,你的站点在「可观测性」这一项上就已经超过大部分同规模个人站了。