一次 Next.js 部署后的“幽灵页面”排查:PM2 停了,页面为什么还能访问?
这次给 SummerField 上线之后,我遇到了一个很绕的问题:本地明明已经改了页面文字,代码也推到了 GitHub,服务器上也 git pull、npm run build、重启了 PM2,但浏览器里看到的页面还是旧的。
更诡异的是,我把 PM2 里的项目删掉以后,首页 / 和后台首页 /admin 都变成了 502 Bad Gateway,但 /admin/content、/admin/settings、/admin/interaction 这些后台子页面居然还能打开。页面壳子还在,只是里面的数据加载失败。
最后排下来,问题不是 Git 没同步,也不是 PM2 跑错目录,而是 Next.js 静态预渲染页面、宝塔/Nginx 代理缓存、浏览器显示结果混在一起,制造了一个“服务明明停了,页面却还活着”的假象。
背景
SummerField 的部署环境大概是这样:
- Next.js App Router 项目
- PM2 常驻运行
next start -p 3000 - Nginx 反向代理到
127.0.0.1:3000 - PostgreSQL 保存数据
- 腾讯云对象存储挂载到服务器目录,用
/lighthouse/...走同源访问音视频资源 - 宝塔面板管理网站和 Node 项目
日常更新流程原本是:
git pull
npm run build
npm run pm2:reload看起来很标准,但问题就藏在“看起来已经更新了”和“浏览器实际看到什么”之间。
现象
我先用 PM2 停掉项目:
pm2 delete summer-field这时访问首页和后台首页:
https://linche.me
https://linche.me/admin都返回 502 Bad Gateway。这很正常,因为 Next 服务已经停了,Nginx 反代不到 127.0.0.1:3000。
但访问后台子页面时,情况变奇怪了:
https://linche.me/admin/content
https://linche.me/admin/settings
https://linche.me/admin/interaction这些页面还能显示出后台布局和页面壳,只是内容区提示加载失败。
一开始我怀疑是不是服务器上还有别的 Next 进程,或者 PM2 实际跑的不是我正在更新的目录。后来用 curl 看响应头,才发现关键线索:
curl -I https://linche.me/admin/content返回里有这些字段:
HTTP/2 200
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-cache: HIT
cache-control: s-maxage=31536000尤其是:
x-cache: HIT这说明页面不是从当前 Next 服务实时返回的,而是命中了 Nginx/宝塔的代理缓存。
排查过程
1. 确认 PM2 状态
先看 PM2 里项目到底有没有跑:
pm2 list如果项目是 online,再看日志:
pm2 logs summer-field --lines 100之前我还遇到过 .next 构建目录不存在的问题,日志里会出现:
Could not find a production build in the '.next' directory.
Try building your app with 'next build' before starting the production server.这种情况不是缓存问题,而是没有成功执行 npm run build,或者构建被服务器内存不足杀掉了。
2. 判断 Next 服务是否真的存在
服务器上用这条命令判断 3000 端口有没有服务:
curl -I http://127.0.0.1:3000如果返回 200、307、308,说明 Next 至少在响应。
如果是:
Failed to connect to 127.0.0.1 port 3000说明服务根本没起来。
如果是:
Empty reply from server说明端口上有进程,但它没有正常返回 HTTP 响应,需要继续看 PM2 日志。
3. 确认新代码是否进了构建产物
为了排除“Git 没拉到”或者“build 没生效”,我临时把后台标题改成了一个很显眼的文字:往里豪。
服务器拉代码并构建后,先查源码:
grep -R "往里豪" -n src | head再查构建产物:
grep -R "往里豪" -n .next | head如果 .next 里能搜到,说明服务器确实已经拿到了新代码,并且新内容已经进入生产构建。
后来我发现,用下面两条命令都能搜到新文字:
curl -s http://127.0.0.1:3000/admin/content | grep "往里豪"
curl -s https://linche.me/admin/content | grep "往里豪"这就说明源站和域名都已经是新内容了。浏览器还显示旧内容,就不能继续怀疑 Git 或 build 了。
4. 确认 PM2 没跑错目录
还有一个很容易踩的坑:服务器上可能有多个项目目录,自己在 A 目录 git pull 和 build,PM2 实际却在 B 目录运行。
可以用这条命令确认 PM2 进程的真实工作目录:
readlink -f /proc/$(pm2 pid summer-field)/cwd正确结果应该是:
/www/wwwroot/linche.me/summer-field我的结果是对的,所以也排除了“PM2 跑错目录”。
5. 最终定位:Nginx/宝塔代理缓存
真正让我确认问题的是这条:
pm2 delete summer-field
curl -I https://linche.me/admin/contentPM2 明明已经删了,但响应还是:
HTTP/2 200
x-cache: HIT这就很清楚了:Next 服务已经停了,Nginx 仍然从缓存里返回旧的 /admin/content HTML。
所以浏览器看到的不是“活着的后台页面”,而是一个被缓存的静态壳。页面里的 API 请求仍然会失败,因为 API 必须由 Next 服务处理。
为什么 /admin/content 会被缓存,而 /admin 会 502?
Next build 输出里有两种标记:
○ /admin/content
ƒ /admin大致可以这样理解:
○是静态预渲染页面,容易被缓存成一个 HTML 壳。ƒ是动态渲染页面,需要 Next 服务运行时处理。
所以当 PM2 停掉以后:
/admin 动态页面,Next 停了,直接 502
/admin/content 静态壳页面,Nginx 缓存命中,还能显示
/admin/content 里的 API 数据请求依赖 Next,加载失败这也是为什么这个问题特别迷惑:页面看起来还在,但它其实已经不是实时服务返回的页面了。
解决方案
1. 关闭 Nginx 代理缓存
在宝塔站点的 Nginx 配置里,找到反向代理部分:
location / {
proxy_pass http://127.0.0.1:3000;
}加入:
proxy_no_cache 1;
proxy_cache_bypass 1;我的配置最后类似这样:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_no_cache 1;
proxy_cache_bypass 1;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
add_header X-Cache $upstream_cache_status;
proxy_set_header X-Host $host:$server_port;
proxy_set_header X-Scheme $scheme;
proxy_connect_timeout 30s;
proxy_read_timeout 86400s;
proxy_send_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}然后检查并重载 Nginx:
nginx -t && systemctl reload nginx再次测试:
pm2 delete summer-field
curl -I https://linche.me/admin/content如果这时变成 502,说明缓存不再“骗我”了。
2. 给后台和 API 加 no-store
代理缓存关掉后,最好在项目层也加一层保险。后台页面和 API 都不应该被长期缓存。
我在 next.config.ts 里给这些路径加了响应头:
/admin/*
/api/*核心是:
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Pragma: no-cache
Expires: 0这样即使以后换代理配置,也不应该把后台页面壳和 API 响应当成长期静态资源缓存。
3. 固定部署流程
日常更新不要只 git pull。Next 生产环境跑的是 .next 构建产物,拉完代码必须重新构建。
我现在固定用:
cd /www/wwwroot/linche.me/summer-field
git pull
npm run build
npm run pm2:reload如果依赖变了,再加:
npm ci如果数据库迁移变了,再加:
npm run db:deploy如果 PM2 状态已经乱了,就直接重建进程:
pm2 delete summer-field
npm run pm2:start
pm2 save一套我会保留的排查清单
以后再遇到“线上没更新”或“页面像旧的”,我会按这个顺序查。
代码是否同步
git status
git log -1 --oneline
git rev-parse HEAD
git rev-parse origin/main构建是否包含新内容
grep -R "往里豪" -n src | head
grep -R "往里豪" -n .next | head这里的 往里豪 换成自己刚改的显眼文字。
PM2 是否真的跑着
pm2 list
pm2 logs summer-field --lines 100Next 本机端口是否正常
curl -I http://127.0.0.1:3000PM2 是否跑错目录
readlink -f /proc/$(pm2 pid summer-field)/cwd域名返回是不是缓存
curl -I https://linche.me/admin/content重点看:
x-cache: HIT
cache-control
x-nextjs-cache如果服务停了还能 200,并且出现 x-cache: HIT,那就不是服务还活着,而是缓存命中了。
这次踩坑后的结论
这次最重要的教训是:不要只相信浏览器里看到的页面。
浏览器能显示,不代表服务器还活着;页面壳能打开,不代表 API 能工作;git pull 成功,不代表线上已经用了新代码;build 成功,也要确认 PM2 确实重启到了新构建。
以后判断线上状态,我会优先相信这三样:
curl 响应
PM2 日志
Nginx 响应头至于浏览器,它很方便,但也最会制造错觉。尤其是后台这种静态壳加客户端请求的页面,一旦代理缓存开错,就很容易出现“PM2 都停了,页面怎么还在”的幽灵现场。
这次把缓存关掉之后,再停 PM2,/admin/content 也会正常返回 502。到这里我才终于确定:不是云端和本地代码不同步,而是缓存让我看见了旧世界。