返回文章列表
技术文章2026年7月3日·5 次阅读

一次 Next.js 部署后的“幽灵页面”排查:PM2 停了,页面为什么还能访问?

这次给 SummerField 上线之后,我遇到了一个很绕的问题:本地明明已经改了页面文字,代码也推到了 GitHub,服务器上也 git pullnpm 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

如果返回 200307308,说明 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 pullbuild,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/content

PM2 明明已经删了,但响应还是:

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 100

Next 本机端口是否正常

curl -I http://127.0.0.1:3000

PM2 是否跑错目录

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。到这里我才终于确定:不是云端和本地代码不同步,而是缓存让我看见了旧世界。