重构改了入口路径,systemd 不知道——Release 轮转部署的致命盲区
某次把一个 Node 服务从「自定义 server 脚本」重构成「框架 CLI 启动」,顺手把旧入口文件搬进了 legacy/ 目录。本地跑得很顺,CI 也绿,自信满满地 deploy。
结果 systemctl restart 一执行,服务进入疯狂 reboot loop,journalctl 刷满了 Cannot find module '.../src/server/main.mjs'——那个文件在新代码里已经不存在了。
现象
# journalctl -u myapp -n 30
Error: Cannot find module '/www/wwwroot/myapp/current/src/server/main.mjs'
Require stack:
- ...
(process:12345): failed with exit code 1
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'exit-code'.
systemctl status myapp 显示 auto-restart counter 一路往上涨,服务完全不可用。
根因
这套部署链路是 Capistrano 风格:
/www/wwwroot/myapp/
├── releases/
│ ├── 20260510120000/ ← 旧 release(有 src/server/main.mjs)
│ └── 20260514105131/ ← 新 release(只有 .next/ + package.json)
└── current -> releases/20260514105131 ← symlink 指向最新
每次 deploy 流程:rsync 新代码 → ln -sfn 切 symlink → systemctl restart。
问题在于部署脚本生成 systemd unit 时,ExecStart 是字面硬编码的字符串:
# 部署脚本模板里的旧写法
ExecStart=/usr/bin/env node src/server/main.mjs重构提交把 src/server/main.mjs 删了、package.json 的 scripts.start 改成了 next start -p 3000,但部署脚本里那行字符串没人动。每次 deploy 仍然把这个旧 ExecStart 写进 unit,current symlink 一切到新 release,进程就找不到文件。
仓库里的 package.json scripts.start 是开发者视角的入口声明,systemd unit 的 ExecStart 是生产进程管理器视角的入口声明。两者各自独立存在,互相不感知对方。重构时只改 package.json 而忘了同步部署脚本,等于改了源头没改执行端,deploy 才是爆雷时刻。
正解
第一步:紧急止损(生产挂着时先回滚)
cd /www/wwwroot/myapp/
ls -1t releases/ | head -5 # 找上一个能跑的 release
ln -sfn releases/20260510120000 current
systemctl restart myapp
sleep 3 && systemctl is-active myapp # 必须输出 'active'绝对不要一边挂着一边改部署脚本——先止损,再排查。
第二步:让 ExecStart 跟 package.json 保持单一来源
部署脚本里生成 systemd unit 的模板改成走框架 CLI,不再写死自定义脚本路径:
# Next.js 项目
ExecStart=/usr/bin/env node node_modules/next/dist/bin/next start -p 3000 -H 0.0.0.0
Environment=HOSTNAME=0.0.0.0
# 通用方案:直接执行 package.json 的 start
ExecStart=/usr/bin/env npm start第三步:加 fail-fast 校验,防止再次踩坑
在 systemctl restart 之前先验证 ExecStart 引用的文件存在:
TARGET=$(systemctl cat myapp | grep -oP 'ExecStart=.*?\K(\S+\.(mjs|js|cjs))' | head -1)
if [ -n "$TARGET" ] && [ ! -e "$DEPLOY_PATH/current/$TARGET" ]; then
echo "[deploy] ABORT: ExecStart target $TARGET not found in new release"
ln -sfn releases/<prev_release> current # 主动回滚
exit 1
fi
systemctl restart myapp部署失败时主动回滚 symlink,避免服务长时间 down。
第四步:从「自定义 server」切到「框架 CLI」时的额外注意
框架 CLI 启动需要构建产物,部署脚本要相应调整:
# 本地先 build,再 rsync(含 .next/ 目录)
npm run build
rsync -avz --delete \
--exclude '.next/cache/' \
--exclude 'node_modules/.cache/' \
. user@prod:/www/wwwroot/myapp/releases/$(date +%Y%m%d%H%M%S)/最彻底的防护是让部署脚本从 package.json 动态读取启动命令,而不是在模板里硬编码。退而求其次,也要在 systemctl restart 前加一行 [ -e current/$TARGET ] 校验,让部署链路自己检测出入口文件丢失并回滚,而不是让 systemd 反复 reboot loop 才暴露问题。
一句话外卖
currentsymlink 切换是原子的,也是危险的——任何会让新 release 起不来的代码变更(入口改名、切启动方式、删旧脚本)都是 P0 待校验项,部署脚本必须在 restart 前主动验证,而不是靠 systemd 的失败日志来发现。