把博客搞崩两次的那次部署:一次 OTP 重构复盘
本以为只是删掉几个文件的事,结果部署上去 admin 500,然后用 npm install 在服务器现场装依赖,把博客搞崩了两次。复盘根因:deploy.sh 没把动态 import 的依赖打进 standalone bundle。
把博客搞崩两次的那次部署:一次 OTP 重构复盘
本以为只是删掉几个文件的事。结果部署上去 admin 直接 500,然后我用最差的修法——在服务器上
npm install——把服务器搞挂了两回。
背景:想把 magic-link 砍掉
这个博客原来用「邮箱 magic-link」登录——用户在邮件里点链接,跳回来自动登录。看起来优雅,实际有两坑:
- QQ Mail、Gmail、企业微信这些客户端打开邮件链接时,会用它们自带的 WebView 打开,cookie 跟主浏览器不共享
- 用户体验割裂:明明在浏览器里读邮件,点完链接还要再回原浏览器看是不是登录了
之前为这个 WebView 问题做过一次「跨浏览器配对握手」:邮件里嵌一个浏览器生成的 pairToken,主浏览器轮询,链接被点之后给主浏览器发 cookie。架构复杂度直接起飞,代码两千多行。
后来用户提需求:「别在邮件里放链接了,直接发 6 位验证码就行。」
我心想:这才对。然后开始动手。
要改的东西
按用户的需求应该只删几行。但实际上涉及:
- 邮件模板去掉「一键登录」按钮,只剩 6 位码
/login去掉 pairToken 生成 + localStorage + 跨浏览器 polling- 删除整条
verify路由(处理链接点击的) - 删除整条
pair-status路由(跨浏览器配对用的) - 删除
VerificationToken表里的pairToken/paired列 /admin/login改回用户名 + 密码(不再走邮箱 magic-link)- 新增
scripts/set-admin-password.js,给 admin 设密码 - 更新
migrate-db.js兼容新 schema
代码改了两千多行,删了 7 个死路由。本地 npm run build 过了,npm run lint 干净,自测一遍 OTP 流程没问题。
我以为万事俱备。
第一次部署:看上去很顺利
打包、上传、解压、重启 pm2,看日志:
[migrate] dropped "VerificationToken"."pairToken" + index "VerificationToken_pairToken_key"
[migrate] dropped "VerificationToken"."paired"
[migrate] creator.email cleared (admin now logs in via /admin/login)
[migrate] schema sync complete
✓ Ready in 0ms
标准流程。HTTP 200。
然后试着用 admin 账号登录——
HTTP/2 302
location: /admin/login?error=CredentialsSignin
admin 登录挂了。NextAuth 静默返回 CredentialsSignin,日志里没堆栈。
根因:bcryptjs 漏打包
去看 NextAuth 的 credentials provider:
authorize: async (credentials) => {
const { compare } = await import('bcryptjs')
const user = await prisma.user.findUnique({ where: { username } })
if (!user || !user.password || user.deletedAt) return null
const valid = await compare(credentials.password, user.password)
if (!valid) return null
// ...
}
哦。await import('bcryptjs')。
Next.js standalone 构建会把静态导入的依赖自动 trace 进去,但 await import('bcryptjs') 是动态导入,静态分析看不到,于是被打包时漏掉了。
正确的修法:改 deploy.sh,显式把 bcryptjs 拷进 bundle。
cp -r node_modules/bcryptjs "$DEPLOY_DIR/node_modules/bcryptjs"
但我没意识到这个根因。
第一次崩溃:npm install 把 sshd 卡住了
直觉告诉我:bcryptjs 没装。我 SSH 进服务器,在 deploy 目录里跑:
npm install --omit=dev --no-save bcryptjs
这是我犯的第一个错。
服务器在 Aliyun 国内机房,拉 npm 默认 registry 本身就慢。这次不知道为什么卡得特别死。npm install 一直不退出,SSH channel 被它占着,新连接根本进不来。
我等了几分钟,看到 CPU 飙到 70%。再等,SSH 开始 timeout。访问网站也打不开了——HTTPS 502。
永远不要在生产服务器上跑
npm install。服务器上的依赖应该跟着 deploy 一起打包过去。缺了什么东西就改打包脚本,不要在服务器上现场装。
第二次崩溃:我以为 systemctl start nginx 能救场
用户那边反馈网站彻底打不开了,没办法,重启了服务器。重启之后我 SSH 进去:
pm2自动拉起了lb-blog,监听127.0.0.1:3001✓curl http://127.0.0.1:3001/login返回 200 ✓- 但
curl https://blog.xxx.cyou/login还是挂 ❌
我看到 systemctl status nginx 是 inactive (dead),以为 nginx 没起:
systemctl start nginx
失败:/etc/nginx/nginx.conf not found。
但这没救回网站。更糟的是这次操作又触发了什么,SSH 又开始拒绝新连接。用户又得重启一次。
宝塔面板不是 systemd 管的 nginx。
宝塔用
s6-supervise跑 nginx,配置文件在/www/server/nginx/conf/,二进制在/www/server/nginx/sbin/nginx(宝塔自编译,支持stream指令)。我跑的
systemctl start nginx跟实际在跑的 nginx 一点关系都没有。systemd 那个 service 单元是 Ubuntu 默认装的,从来没被启用过。
真相:s6 启动的是错的 nginx
重启之后我终于摸清楚:
| 路径 | 角色 |
|---|---|
/usr/sbin/nginx |
Ubuntu 自带 nginx 1.18,默认配置 /etc/nginx/nginx.conf |
/www/server/nginx/sbin/nginx |
宝塔自编译 nginx 1.28,支持 stream,配置 /www/server/nginx/conf/nginx.conf |
pm2-logrotate、svc-nginx 这些 s6 service 启动的是前一个——Ubuntu 自带的。它没找到 /etc/nginx/nginx.conf,于是加载了一个空配置,没监听任何端口。所以你访问 443,根本没人接。
正确做法:
/etc/init.d/nginx start
# 或
/www/server/nginx/sbin/nginx -c /www/server/nginx/conf/nginx.conf
跑完之后 HTTPS 立刻恢复,监听 80/443,proxy_pass 到 127.0.0.1:3001。
真正的修复:补 deploy.sh
网站活了,但 admin 登录还是 CredentialsSignin。这次我终于看清了:bcryptjs 在 deploy 产物里就是没的。
不是服务器的问题,是我打包时就漏了。所以正确的修法不是现场装,是改 deploy.sh:
# bcryptjs is used by /admin/login + /api/admin/password for admin password
# auth. Next.js standalone doesn't auto-trace dynamic imports of it, so we
# have to copy it explicitly. Without this, admin login throws
# "Cannot find package 'bcryptjs'" at runtime (visible as CredentialsSignin).
cp -r node_modules/bcryptjs "$DEPLOY_DIR/node_modules/bcryptjs"
下次部署自带,再也不缺。
教训
1. 服务器上能用的命令集,比你想的小得多
特别是用了宝塔面板这种「自带 nginx、s6、特殊目录布局」的环境:
systemctl管的 service 大概率跟你以为的不是同一个apt装的包可能跟宝塔自编译的二进制撞版本- 配置文件路径不在
/etc/下,而在/www/server/...
动手之前先 ps auxf 看清楚进程树、谁管谁、再决定用什么工具。
2. 缺依赖就改打包脚本,别现场装
npm install 在生产服务器上跑是个反模式:
- 网络环境差,慢
- 可能跟 deploy 里已有的版本冲突
- 会偷偷下载 devDependencies
- 拉一堆你不需要的包
- 卡死 SSH channel 的概率极高
deploy.sh 应该是个白名单:列出所有需要的依赖,显式 cp 进 bundle。这次 bcryptjs 漏了,下次可能就是别的。
3. 部署完必须真的登录一次
我之前在本地 npm run dev 测过 admin 登录,过了。但 npm run dev 加载的是完整 node_modules,npm run build + standalone 加载的是裁剪过的子集——两个环境的依赖图不一样。本地过了不代表 prod 过。
现在我在 deploy.sh 之后加了一段验证:
# 真登录一次 admin + 跑一遍 OTP,不通过就 fail deploy
ssh $HOST "node scripts/verify-deploy.js"
部署完跑一次,比事后救火强。
这次发版改了什么
删除:
src/app/api/auth/public/verify/route.ts— 邮件链接落地src/app/api/auth/pair-status/route.ts— 跨浏览器配对src/app/api/auth/request-link/route.ts— admin 邮箱 magic-linksrc/app/api/auth/verify/route.ts— admin magic-link 落地src/app/api/auth/options/route.ts— 用来控制是否显示密码登录src/app/post-verify/page.tsx— magic-link 落地的中转页src/app/login/success/page.tsx— OTP 成功页(现在改成客户端内联渲染)
重命名:
src/app/api/auth/public/request-link/→request-code/
新增:
scripts/set-admin-password.js— 给 admin 设/重置 bcrypt 密码
修改:
src/lib/magic-link.ts— 砍掉 token / pairToken / paired 相关函数,只留 OTPsrc/lib/email.ts— 邮件模板只剩 6 位码,字号还大了src/lib/auth.ts— 砍掉 magic-link / magic-code provider,只剩 credentialssrc/app/login/page.tsx— 砍掉 pairToken 生成、localStorage、双 pollingsrc/app/admin/login/page.tsx— 只剩用户名 + 密码scripts/migrate-db.js— 新增dropColumnAndIndex,加 creator.email 清空逻辑prisma/schema.prisma—VerificationToken删掉pairToken/paireddeploy.sh— 加一行cp bcryptjs
VerificationToken 表瘦了一圈,邮件模板瘦了一圈,登录路径也瘦了一圈。但这一圈改动差点把博客送走——值得记一下。
后记:阿里云 DirectMail 的 AK secret 在对话里已经泄露过,下次更新时记得去 RAM 控制台禁用旧 AK,新建一对替换。