生产服务器热补丁流程(通用方法论)
目标:改得动、验得过、退得回。适用于只有 SSH、代码不在本地、服务正在对外提供服务的场景。
> 本 skill 是跨项目通用的。平台专属信息(某个项目跑在哪个目录、服务单元叫什么、要不要跳板、改完还要动哪些文件)查对应的平台 skill,例如幻镜漫剧平台见 manju-platform-patching。
一、先摸清真实运行方式(别上来就 restart)
很多服务并非名为 <app>.service,先确认:
ps -eo pid,ppid,user,etime,cmd | grep -E "node|python|java" | grep -v grep
systemctl list-units --all | grep -i <app>
ls -la /etc/systemd/system/ | grep -i <app>
command -v pm2 && pm2 list
典型陷阱:app.service 是指向 /dev/null 的 masked 符号链接,实际跑的是模板实例 app@3000/3001/3002。正确重启是 sudo systemctl restart app@$p,而不是 systemctl restart app。
同时确认文件属主(stat -c %U file),决定用不用 sudo;写入新文件后要 chown 回原属主,否则服务可能读不到。
还要确认数据目录(改配置类文件时是高危陷阱):
systemctl cat <unit> | grep -iE "Environment|EnvironmentFile"
仓库里常有 data/xxx.json 副本,但生产 Environment=DATA_DIR=... 才是真正生效的。改错地方 = 白忙一场且毫无报错。
权限不一致很常见:同一个 lib/ 目录下可能 a.js 是 ubuntu:ubuntu、b.js 是 root:root。补丁脚本里不要假定权限,统一 sudo node + 保属主写入(见 §3 writeKeepOwner),最省心。
二、SSH 被封禁时走跳板
症状:ping 通、业务端口(443)通、/api/health 200,但 22 端口 TCP 超时,长时间等待也不恢复。
不要用高频 TCP 探测(bash -c "exec 3<>/dev/tcp/host/22" 循环)—— 会加剧封禁。
解法:找一台能访问目标机 22 端口的同环境机器做跳板(同机房的开发机/跳板机通常不被封)。
# 1) 把私钥投到跳板机(/tmp,用完删)
cat /path/key.pem | ssh -i /path/key.pem ubuntu@<JUMP_IP> \
"umask 077; cat > /tmp/<prod-key>.pem; chmod 600 /tmp/<prod-key>.pem; echo KEY_WRITTEN"
# 2) 跳板执行
ssh -i /path/key.pem ubuntu@<JUMP_IP> \
"ssh -i /tmp/<prod-key>.pem -o StrictHostKeyChecking=no ubuntu@<PROD_IP> '命令'"
注意:
- ssh-agent forwarding 在 Windows Git Bash 下不可用(Error connecting to agent),别指望 ssh -A。
- 远程命令三层引号嵌套极易出错。改成脚本转发,稳定得多:
bash
cat local-script.sh | ssh -i KEY ubuntu@<JUMP_IP> \
'cat > /tmp/s.sh; ssh -i /tmp/<prod-key>.pem ubuntu@<PROD_IP> "bash -s" < /tmp/s.sh'
- Node 补丁脚本需 sudo 时,脚本落在跳板机上生产机读不到,要 stdin 二次转发:
bash
cat patch.js | ssh -i KEY ubuntu@<JUMP_IP> \
'cat > /tmp/p.js; ssh -i /tmp/<prod-key>.pem ubuntu@<PROD_IP> "cat > /tmp/p.js && sudo node /tmp/p.js" < /tmp/p.js'
- 清理顺序:先清生产机临时文件 → 再删跳板私钥。反了会连不上,得重投。
- 跳板机 /tmp 可能被自动清理,间隔稍久需重新投递。
- ⚠️ 内层 ssh 必须加 -n(2026-09-15 静默截断事故):
当整段脚本是用 cat x.sh | ssh 跳板 'bash -s' 喂进去时,stdin 就是脚本本身。
脚本里若再 ssh 一次跳到生产机,那个内层 ssh 默认会把 stdin 转发给远端命令,
于是把脚本剩余部分整段啃掉 —— 症状是「跑完第 1 段就没有下文」,不报任何错;
若靠 stdout 回传 tar,拿到的是 0 字节文件(tar: unexpected end of file)。
⚠️ 迷惑性极强:内层 ssh 认证失败时它快速返回、症状轻,脚本可能侥幸跑完;
一旦认证成功就必然截断 —— 所以「上次这么写没问题」不能当依据。
修法:内层 ssh 一律加 -n;而 bash -s 那一层绝不能加 -n(那会把脚本本身设成 /dev/null,一条都不执行)。
自检:脚本分段输出 + 末尾打一个 XXX_DONE 标记,没看到标记就是被截断了,别只看「跑过了」。
- ⚠️ 私钥投递必须与「使用它的那条命令」在同一条命令里(2026-09-15 实战):
先投私钥、再单独发一条用它的命令,第二步会报
Warning: Identity file /tmp/<prod-key>.pem not accessible + Permission denied
—— 投递那一跳结束时私钥已被清理(或两次连接落在不同实例)。
正确写法:一次登录内完成「投」和「用」,退出前自己清:
bash
cat key.pem | ssh -i key.pem ubuntu@<JUMP_IP> \
"umask 077; cat > /tmp/<prod-key>.pem; bash /tmp/run.sh; rc=\$?; shred -u /tmp/<prod-key>.pem; exit \$rc"
(/tmp/run.sh 需先用同一跳板投递;脚本内不要再自己 shred 私钥,避免双删报错。)
- 💡 优先「先落文件、再 bash 文件」,少用 cat x.sh | ssh 'bash -s':
bash -s 把脚本塞进 stdin,任何内层 ssh / 交互式命令都会跟脚本抢 stdin(即上一条 -n 事故的根源)。
落文件后 bash /tmp/x.sh,stdin 空闲,坑少一半。脚本头部自带
[ ! -f "$PRODKEY" ] && exit 9 自检,比靠报错信息反推可靠。
- ⚠️ 一条 ssh 命令里只能有一个 cat > 目标文件(2026-09-15 实测):
stdin 是一次性的流,第一个 cat 读到底后,第二个 cat 只会拿到 EOF。
bash
# ❌ 脚本写对了,私钥写成 0 字节
cat verify.sh | ssh dev 'cat > /tmp/verify.sh; cat > /tmp/<prod-key>.pem; bash /tmp/verify.sh'
症状:内层 ssh 报 Load key "/tmp/<prod-key>.pem": error in libcrypto
+ Permission denied (publickey,password) —— 极易误判成"密钥不对/没权限",实际是文件空的。
→ 投脚本与投私钥必须拆成两条命令,每条 stdin 只喂一件事。
- ⚠️ 管道转发必须比对字节数:cat x | ssh 'cat > y' 是"静默失败"重灾区
(-n 吞成 0 字节、认证失败静默跳过、网络中断只留半截),
转发脚本里加一句 [ "$local_sz" = "$remote_sz" ] || exit 1 才算真校验过。
- ⚠️ 别给外层管道加 < /dev/null(2026-09-15 实测):
bash
# ❌ 文件传成 0 字节,exit 0,不报任何错
cat x.tgz | ssh -i KEY ubuntu@<JUMP> 'cat > /tmp/x.tgz' < /dev/null
症状极隐蔽:退出码 0、没有任何报错,远端文件 md5 = d41d8cd98f00b204e9800998ecf8427e(空文件)。
「内层 ssh 加 -n」与「外层管道加 < /dev/null」是两回事:前者必须加(见上一条),
后者禁止 —— cat x | 的输入端正是那条管道,< /dev/null 会把它掐掉。
自检:转发后立刻双端 md5sum / wc -c 比对,别只看 exit code。
- ⚠️ 本地 /tmp ≠ 远端 /tmp(2026-09-15 实测):
cat local.tgz | ssh 跳板 'tar xzf /tmp/local.tgz' 会在远端报
Cannot open: No such file or directory —— 那个路径在跳板机上并不存在。
管道传输必须在目标机显式落盘:
bash
cat local.tgz | ssh 跳板 'cat > /tmp/x.tgz && tar xzf /tmp/x.tgz'
同理,node /tmp/probe.js 里的 /tmp 指的是执行那台机器的 /tmp,不是本机的。
⚠️ 混合属主 → 命令非 0,但文件已「部分成功」覆盖(2026-09-15 实战)
同一目录文件属主不统一(典型:engine.js 属 root、lib/*.js 属 ubuntu),
用 ubuntu 身份跑 cp a b c /opt/app/:
- 到 root 属主那个文件时
Permission denied,命令退出码非 0; - 但排在它前面的文件已经覆盖成功 → 线上变成半新半旧。
你只看到「失败了、没生效」,容易直接放弃或重跑,根本不会想到已经改了一半。
处置顺序(严格按此,别急着重启):
- 先核对现状与备份的 md5,确认「改了哪些、没改哪些」,别凭命令输出推断:
bash md5sum /opt/app/engine.js /opt/app/lib/*.js md5sum /opt/app/.bak-<TS>/engine.js /opt/app/.bak-<TS>/lib/*.js # 回滚点是否完好 - 确认回滚点完好后,改
sudo cp补齐剩余文件(保属主可用 §3 的writeKeepOwner)。 - 再核对一遍 md5(应为「全部 == 本地」)。
- 最后才滚动重启 + 健康检查。
预防:投递脚本开头就 [ "$(id -u)" = "0" ] || { echo 需 root; exit 1; },或干脆一律 sudo;
并在脚本末尾逐个文件打印「期望 md5 vs 实际 md5」,让「部分成功」在输出里无处藏身 ——
「命令成功」不等于「文件都变了」,反之亦然。
三、写幂等补丁脚本(不要手写 sed)
用 Node 写补丁脚本,在服务器以 sudo node /tmp/patch.js 执行。核心模板:
const fs = require('fs');
const TS = new Date().toISOString().replace(/[-:T]/g,'').slice(0,14);
function backup(f){ const b=f+'.bak-'+TS; if(!fs.existsSync(b)) fs.copyFileSync(f,b); return b; }
function writeKeepOwner(f, content){ // 保持原属主/权限,root 写入也不破坏服务
let uid,gid,mode;
if (fs.existsSync(f)) { const st=fs.statSync(f); uid=st.uid; gid=st.gid; mode=st.mode; }
else { const st=fs.statSync(require('path').dirname(f)); uid=st.uid; gid=st.gid; mode=0o100644; }
fs.writeFileSync(f, content);
try { fs.chownSync(f,uid,gid); fs.chmodSync(f,mode & 0o7777); } catch(e){}
}
// 锚点替换:缺失/不唯一都报错,绝不做半截改动
function rep(src, from, to, all){
const n = src.split(from).length - 1;
if (n === 0) throw new Error('ANCHOR_NOT_FOUND: '+JSON.stringify(from.slice(0,70)));
if (!all && n > 1) throw new Error('ANCHOR_NOT_UNIQUE('+n+'): '+JSON.stringify(from.slice(0,70)));
return all ? src.split(from).join(to) : src.replace(from, to);
}
幂等五原则(血泪教训):
- 先清理历史残留再写入。脚本可能被重复执行(工具重试、网络重传),第二轮的锚点已变。写一个
stripPrevious()把上一轮插入的块删干净。 - 幂等检测要用特征串,绝不能用「逗号」这类通用字符。错误写法
src.indexOf(insert.trim().split('\n')[0])拿到的是,,永远命中 → 永远跳过或永远不跳过。要用结果里独有的字符串(如新条目的完整 key 名)。 - 插入对象字面量条目时,正则不要吞掉收尾结构。错误做法:正则匹配到
};\n /* 注释 */,插入点落到对象外面 → 语法错误。正确做法:用分组只匹配末项,替换时重组保留};:
js const re = /(` \})\n( {2}\};\n {2}\/\* 注释 \*\/)/; // 分两组 src = src.replace(re, '$1,\n' + NEW_ENTRIES + '\n };\n /* 注释 */'); - 写文件保属主:
fs.writeFileSync后跟chown/chmod,否则 sudo 写入会让文件变成root:root,服务可能读不到。 -
⚠️ 守卫条件里的特征串,写完必须
grep -c验一次命中次数(2026-09-15 实测翻车):
守卫写成grep -q '角色特效' lib/llm-steps.js,但那个标签实际写在engine.js里
→ 命中 0 次 → 条件恒假 → 守卫形同虚设。
现象极具迷惑性:第二遍执行打印了BACKUP_SKIP(备份那半段的守卫是好的),
却照样跑完了安装 + 滚动重启(完成标记那半段是废的)—— 看着像"幂等生效了",实际没有。
bash # 写完守卫,先在本地把每个特征串数一遍(都应为 >0) grep -c 'charSheetPrompt' engine.js # 2 grep -c 'unitCountFor' lib/pipeline.js # 10 grep -cF 'vfx: vfx' lib/llm-steps.js # 1
另:多个标记最好落在多个不同文件(防单个文件被回滚后守卫误判"已装");
含|(等字符的串用grep -F避免被当正则。 -
⚠️ 「完成标记」不要用
touch一个标记文件,要用「结果本身」当标记(2026-09-15 实测):
bash # ❌ 守卫生效了,但把证据盖掉了 [ -f /tmp/relaykit/.done ] && { echo "SKIP: 已执行过"; exit 0; } mkdir -p /tmp/relaykit; touch /tmp/relaykit/.done ... 真正干活 ...
问题不在"守卫失效",而在守卫生效得太好:执行环境会把同一条命令串行跑两遍,
第一遍真跑(拿到的才是真数据),第二遍命中标记打印SKIP——
而这行SKIP把第一遍的真实输出整个盖掉了,你只看到SKIP,一份数据都拿不到。
正确写法:以「输出文件」为完成标记,第二遍直接cat复用:
bash # ✅ 两遍看到的是同一份真实结果 OUT=/tmp/job.out [ -s "$OUT" ] && { cat "$OUT"; exit 0; } { ... 真正干活 ... } 2>&1 | tee "$OUT"
这个模式已固化成通用执行器scripts/run-probe.sh(跨机投递 + 双端 md5 校验 + 输出落盘),可直接复用。
大文件内容(如新增模块)可 base64 内嵌进脚本,实现「一次连接完成全部」。
> ⚠️ 脚本报 ALREADY 不等于成功。EACCES 写入失败也会走到跳过分支;工具重试导致脚本跑两次同样全报 ALREADY。
> 每次打完补丁必须上机 grep 每个改动点验真,不能信脚本输出。
四、验证与重启(顺序不能颠倒)
# 1) 改完立刻语法检查,错误必须在这里拦住
for f in server.js app.js lib/*.js; do node --check "$f" || echo "FAIL $f"; done
# 2) 滚动重启(多实例逐个,避免整体中断)
for p in 3000 3001 3002; do
sudo systemctl restart "<unit>@$p"; sleep 7
curl -s -m 6 "http://127.0.0.1:$p/api/health" # 单实例健康
done
# 3) 外网 + 日志双验证
curl -s -m 8 https://<domain>/api/health
sudo journalctl -u '<unit>@*' -n 20 --no-pager --since '-2min' | grep -iE "error|fatal"
若改的是前端静态资源,额外验证外网取到的版本(证伪 CDN 缓存):
curl -s https://<domain>/app.js | grep -c "新加的标识串"
四·五、验证技巧:token 拿不到时,直接 require 派生
管理后台类接口常需要 admin token,而 token 未必在以为的位置(可能已从 env 文件移除、改为账号登录)。拿不到 token 时不必卡住 —— 直接在服务器上 require 业务模块,打印派生结果,这比走 HTTP 更硬:
node -e "
const gen = require('/opt/app/lib/generators.js');
Object.keys(gen.PROVIDERS).forEach(id => console.log(id, JSON.stringify(gen.PROVIDERS[id])));
"
能直接证明「UI 数据源里有没有这一项」。注意此时进程没有 systemd 注入的环境变量(DATA_DIR 等会走默认值),结论只对代码派生逻辑有效,不要用它判断数据文件内容。
> ⚠️ 调用前先打印 fn.toString() 确认真实签名(2026-09-15 实测,差点误报):
> 直接 require 业务函数取值时,签名猜错不会报错,会静默返回一个"看起来合理"的值。
> 真实案例:某计费函数的签名是 4 个位置参数 (provider, type, unitCount, u),
> 按"一个配置对象"的直觉写成 f({provider, type, unitCount}) →
> 内部 rates[provider] 变成 rates['[object Object]'] → 取不到费率 → 函数开头的
> if (!pr) return 0; 直接命中 → 所有样例金额整整齐齐输出 0。
> 这个"全 0"长得极像"计费坏了",差点被当成资损缺陷写进报告(真因只是探针调用姿势不对)。
> js
> console.log(String(mod.someFn)); // ① 先看实现,确认真实签名
> console.log(mod.someFn(a, b, c, d)); // ② 再按真实签名调用
>
> 原则:「整齐的 0 / 整齐的 null」优先怀疑自己的调用姿势,而不是被调用方有 bug。
> 用 HTTP 调 admin 接口若鉴权方式已变,可能静默拿到登录页 HTML(302 重定向)而非报错 —— 极难发现。看到 HTML 响应先怀疑这个。
四·六、密钥类文件:用只读探针,别用肉眼判「损坏」(2026-09-14 血亏)
审计含私钥/证书的代码(payment.js、证书配置等)时,别把 grep 到的内容当真。
踩过的坑:某平台 payment.js 的 PEM 头 grep 出来是「5 个横杠 + 一串随机字母」,而且两台机器的串还不一样 → 判定「PEM 被脱敏流程改坏了」并写进了交付报告。结论是错的 —— 那是输出脱敏在渲染时把疑似密钥替换成了随机串,文件本身完好。
识别指纹:同一个文件的同一处内容,两次读取得到不同的串 ⇒ 是脱敏,不是损坏。 凡「乱码」值在不同次输出里不一致,一律先按脱敏处理,别急着"修"。
正确姿势:布尔 / 计数探针(只输出判定结果,不打印任何材料):
const s = require('fs').readFileSync(F, 'utf8');
const D = '-'.repeat(5); // 关键:命令文本里也别出现 PEM 字面量
console.log('PKCS8:', s.includes(D + 'BEGIN ' + 'PRIVATE KEY' + D));
console.log('PKCS1:', s.includes(D + 'BEGIN ' + 'RSA PRIVATE KEY' + D));
const body = s.split('\n').filter(l => /^[A-Za-z0-9+/]{40,}={0,2}$/.test(l.trim()));
console.log('base64 正文行数:', body.length); // 0 = 只有格式串,没有真实密钥实体
四·七、证明「重启真的发生过」:用 /api/health 的 uptime 反推,不碰 22 端口
改完 EnvironmentFile 类的配置(/etc/xxx/env)后,必须重启才生效(systemd 只在启动时读入进程环境)。
但「重启过了」这件事,别只用被检查机器上的证据(systemctl show、journalctl 都在那台机器上,
且脚本自述更不可信)。拿一条外部、独立、无法伪造的证据:
# 外网直接读健康端点,看进程 uptime
curl -s https://<domain>/api/health
# → {"ok":true,"uptime":1081,"version":"1.9.7","port":"3000",...}
把 uptime 从当前时刻反推启动时刻,与 journal 记录的滚动重启时间比对:
| 观测 | 值 |
|---|---|
| 读取时刻 | 12:05:10 |
uptime |
1081s |
| 反推启动时刻 | 11:47:09 |
| journal 记录的滚动重启 | 11:47:11 / 11:47:12 / 11:47:13 |
| 结论 | 吻合(±2s) → 那次重启真实发生,且此后没有二次重启 |
为什么这条比上机查更硬:
1. 不碰 22 端口 —— 绕开「短时多次建连即封禁」那个坑(见 §一 的封禁说明),零风险。
2. 不依赖服务器上任何脚本自述、也不依赖 journal(本机可被改写)。
3. 一次请求同时验证「服务活着 + 重启时刻对得上 + 配置注入的进程是同一个」三件事。
⚠️ 两个前提,别用错场景:
- uptime 是进程级的。若服务做了 warm reload / 优雅重启(不重建进程),uptime 不会归零,
此法失效 —— 只适用于「restart 真的换进程」的场景(systemd Type=simple 默认如此)。
- 多实例时健康端点通常只由其中一个实例应答 → 反推出的是那个实例的启动时刻。
滚动重启各实例间隔几秒,所以应当看「落在重启窗口内」,而不是要求与某一个实例秒级相等。
两个附加收益:
base64 正文行数 = 0证明文件里没有嵌入真实私钥/证书 → 这是把含密钥的文件提交进仓库前的必查项。- 反之若正文行数 > 0,说明真有密钥实体,不要 commit,先改成读环境变量。
> 命令本身也会被拦:命令文本里出现 PEM 字面量会触发敏感审批;一旦超时,整条命令被丢弃且没有任何输出(看起来像命令没执行)。用 '-'.repeat(5) 拼接构造标记即可规避。
四·八、部署工具脚本本身也是代码:别让它只活在 /tmp(2026-09-15 事故)
滚动部署常配一个「前置安全检查」,例如"此刻是否还有任务在跑?有就中止部署"。
这类脚本有个极隐蔽的失效模式:它一直只活在跳板机/生产机的 /tmp 里,从未进仓库。
真实事故:某次 e2e 的前置检查输出为空,部署侧按「未知」继续 ——
于是"错开线上任务"这个保证根本没兑现。排查发现逻辑本身是好的
(逐项复现:能定位数据文件、能 require 依赖、查得到 0 条)。
真因是版本时序:
- 该脚本此前修过一次(第一版查错了数据源:查 PostgreSQL,而目标表在 SQLite)
- 但修正只存在于临时目录,部署时打包的是修正之前的版本
- 于是生产上跑的仍是"查错库"的旧版 → 查询失败 → 静默输出空 → 显示"未知"
同一个 bug 因同一个原因,现形了两次。
两条硬规则
-
部署/运维脚本必须进仓库(放
scripts/等),与业务代码同等对待、一起提交。
只活在/tmp的脚本 = 没有版本管理 —— 它会在你最需要它的时候悄悄退化成旧版,
而且没有任何迹象提示你。 -
"检查失败"的语义要显式设计:
- ❌ 纯静默失败最危险 —— 调用方看到"未知"会当作良性状态继续,安全检查形同虚设;
而一个会静默失败的检查脚本比没有检查更糟,因为它让人以为已经检查过了。
- ✅ 折中做法:stdout 仍输出空/中性值(保持调用方兼容,不因工具故障阻断发布),
但必须往 stderr 打醒目报错,并带上关键上下文(DATA_DIR、ROOT等)。
让操作者在部署输出里一眼看见「这次其实没检查成」。
- 给"探测不到路径"这类前置失败也单独告警 —— 否则它会被后续
path.join(undefined, ...)的 TypeError 掩盖成一句没头没尾的报错,排查时看不出真根因。
验证这种修复:照抄调用方的取法
不要"我觉得它能工作",要用与调用方完全相同的形式跑一遍:
# 部署脚本里是这么取的:
R=$(node scripts/check_running.js "$ROOT" 2>/dev/null | tail -1)
# 那验证就必须照抄这一行,看它到底得到什么(而不是单独 node 跑一遍就算数)
判定标准:修复后应得到 '[0]'(不再是空);再故意传一个错误路径,
确认 stderr 确实大声报错。
> 顺带一个 shell 陷阱:$(cmd || echo 0) 在 cmd 已输出 0 且退出码非 0 时,
> || 会再补一个 0 → 命令替换结果是 0\n0,于是 echo "x = $(...)" 打出两行。
> 计数类写法定型为 $(cmd 2>/dev/null || true) 后再判空,或直接 cmd 2>/dev/null; true。
四·九、多步子脚本:每一个都要检查退出码,否则整条流水线会「绿着失败」(2026-09-15 事故)
把部署拆成多个子脚本(改代码 / 改配置 / 改 nginx / 各步验证)是个好结构,
但有一个会让整次部署结论完全失真的坑:
> 主脚本只检查了"最后一步"或"关键一步"的退出码,其余子步骤失败后继续往下跑,最后照样打印 ALL DONE。
真实事故:一次部署要打 4 个补丁,其中「nginx 路由收敛」那步的子脚本里
投送目录常量写错(/tmp/lp4 vs 实际 /tmp/p4in)→ node 报 Cannot find module →
但主脚本没检查这一步的退出码 → 于是它接着跑完后面全部验证、
最后打印 PROD_..._DONE —— 从输出看和成功一模一样。
后果:交付报告会写「4 项全部完成」,而线上实际只完成了 3 项。"看起来成功"比"明确失败"危害大得多。
硬规则
- 分布在各节里的每一个子脚本调用,都要接退出码并处理:
bash bash "$IN/step_x.sh" RC=$? if [ $RC -ne 0 ]; then echo " 🔴 步骤 X 失败(退出码 $RC)—— 该子项未完成" # 关键:不中断(后续步骤可能仍要跑),但必须留下失败标记 STEP_X_FAILED=1 else STEP_X_FAILED=0 fi - 最后一节必须汇总标记,而不是无条件报成功:
bash if [ "${STEP_X_FAILED:-1}" = "0" ]; then echo " ✓ 全部完成"; else echo " ⚠️ 第 X 项未完成,需单独重跑"; fi - 子脚本自己要保证"失败时不留半成品":例如打 nginx 补丁的子脚本,
必须在nginx -t失败时自动从备份回滚且不 reload,让线上保持原样。
这样即使主脚本漏检退出码,也不会造成实际故障。
补跑时:只跑缺的那一步,别整条重跑
发现某一步没生效后,不要图省事重跑整个部署脚本 —— 已经生效的步骤会被再做一遍
(配置改写、滚动重启),每多一次重启就多一次停机窗口和一次风险。
写一个只做缺项的最小执行体,并在注释里写清「为什么只做这一步」。
四·十、生产上的 git 仓库可能早已与远端分叉 —— 别盲目 pull(2026-09-15 发现)
部署文档常写「生产机 git pull + 重启」。先验证这一步是否真的在发生,再决定要不要动它。
真实情况:某平台文档写「开发机 push → 生产 pull + 重启」,实际查下来:
| 开发机 | 生产机 | |
|---|---|---|
| 是否 git 仓库 | ✓ | ✓ |
| HEAD | 与远端一致 | 落后很多 |
| 工作区 | 干净 | 大片脏(M LICENSE、D *.preview.html、M app.js、M lib/*.js … 20+ 项) |
也就是说:生产一直靠"直接投文件"部署,git pull 从来没在跑,仓库是历史遗留。
此时在生产执行 git pull 是高风险动作 —— 会撞上那堆没人管过的本地改动。
做法:
- 部署不依赖生产 git 状态:代码正确性用逐字节比对兜底
(md5sum 开发机 vs 生产,逐文件确认一致),这比"git 说它是最新的"更硬。
- 复核时主动查一次 git -C <dir> rev-parse HEAD 与 git status --short,把分叉事实写进报告。
- 想对齐就单独立项(先备份那 20+ 项本地改动),不要在部署过程中顺手做。
更强的形态:单个文件的内容就与本地不一致(2026-09-15 实测)
比"仓库分叉"更隐蔽的是:同一个文件,生产版含本地版没有的补丁。
真实案例:某平台 server.js 的生产版多了 8 行限流治理逻辑
(P0-CODE-PASSTHRU 标记、code / retryCount / realError 字段),
这些改动从未回流本地工作区。若照例"本地改完整体覆盖上去",限流治理会被静默抹掉 ——
而且功能测试大概率还是绿的(限流只在被触发时才现形)。
识别:动生产文件前,先 diff 一次「生产版 vs 本地版」,
看到本地根本没有的内容就要警觉,别默认把它当成"本地领先"。
处置:
1. 把生产版拉下来当新基线(存为 _work/xxx.prod-base.js);
2. 在这个基线上重新施加本轮改动,而不是把本地版推上去;
3. 部署脚本末尾强制校验那些"只在生产存在"的标记仍在:
bash
grep -c 'P0-CODE-PASSTHRU' server.js # 必须 ≥1,否则视为构建失败
原则:任何「整体覆盖」之前,先证明「被覆盖的那份里没有你不知道的东西」。
> 通用原则:文档里写的流程,必须周期性用"实际状态"去反证。
> 一个从不被执行的流程步骤,比没有这个步骤更危险 —— 它会让人误以为有那道保护。
四·十一、幂等锁会吞掉第一次执行的输出 —— 校验段要放在 SKIP 之外(2026-09-15 实战)
症状:部署/探针脚本明明跑成功了,屏幕上却只有一句 SKIP 已跑过(锁存在);
第一次真跑出来的那一大段证据(md5、标记计数、健康状态、探针结果)一个字都看不到。
根因:本执行环境会把同一条命令串行跑两遍,而调用方只回显第二次的 stdout。
第二遍被幂等锁挡住 → 只打印 SKIP → 第一遍的证据永远丢失。
两层修法(叠加使用最好):
-
锁连带回放日志:执行时把输出落一份到
$LOG,SKIP 分支cat出来。
bash LOCK=/tmp/.x.${TAG}.done ; LOG=/tmp/.x.${TAG}.log if [ -f "$LOCK" ]; then echo "SKIP 已跑过 → 回放上次输出:"; cat "$LOG" 2>/dev/null; exit 0 fi node probe.js "$@" > "$LOG" 2>&1 ; RC=$? ; cat "$LOG" [ "$RC" = 0 ] && touch "$LOCK" # ★ 只有成功才落锁,失败必须允许重试
锁要按参数分文件(TAG),否则 dry 与 live 互相挡住;再给一个--force清锁重跑。
⚠️ 解析 flag 用while case循环而不是顺序if—— 顺序敏感的实现会把
--live当成位置参数传下去(探针只会说"找不到项目",不报错)。 -
更好:把「校验段」整体放到 SKIP 判断之外,让脚本无论第几次执行都打印当前真实状态。
这样它同时是部署脚本和巡检脚本,一次解决"证据被吞"与"事后核对"两件事。
bash NEED=1 if grep -q '新标记' f.js; then NEED=0; echo "SKIP 已安装"; fi if [ "$NEED" = 1 ]; then ...解包/备份/替换/滚动重启... ; fi echo "=== 校验段(无条件执行)===" md5sum f.js ; grep -c 'P4标记' server.js ; curl -s -o /dev/null -w '%{http_code}\n' .../api/health
⚠️ 反例(当天踩的):SKIP 分支只echo "已装过"; exit 0→
第二次执行只回一句 SKIP,第一次的部署证据全部丢失,只能再补跑一次独立校验。
同理:探针的"零成本 dry 模式"与"花钱 live 模式"要用不同 TAG,否则先跑的 dry 会把 live 锁在门外。
四·十二、跨机传二进制(图片 / 包)必须 base64(2026-09-15 实战)
Windows / Git Bash 环境下 ssh ... > local.bin 会在编码层破坏字节 —— 图片直接损坏,且全程不报错。
# 元信息(ls / md5sum)重定向到 stderr,stdout 只留纯净的 base64 流
remote_cmd='ls -l "$F" >&2; md5sum "$F" >&2; base64 -w0 "$F"'
cat key.pem | ssh jump 'cat > /tmp/k.pem; chmod 600 /tmp/k.pem; bash /tmp/fetch.sh /path/f.bin' > out.b64
base64 -d out.b64 > local.bin && md5sum local.bin # ★ 与远端 md5 双边比对
- 元信息必须走 stderr:混进 base64 流会让
base64 -d直接失败或产出脏数据。 - 落地后必须比对 md5 —— 只比字节数不够(损坏可能等长)。
- 干净的
gzip/tgz同理;走管道时在转发脚本里加dev_sz == prod_sz断言。
四·十三、一次要投多个文件且带新依赖时:投递前三查 + 自动回滚(2026-09-18 实战)
单文件热补丁的套路(§三)在「投一个带新 require 的新版本」时会直接把站点搞挂。
这次是上线一个新功能,最终同时投了 3 个文件(1 个覆盖 + 2 个新增),
过程中踩到一个把正常部署误判成失败的坑。以下三条前置检查缺一不可。
检查 1:新版本的 require 在目标机是否都存在(最容易全站挂)
Node 在模块加载期解析 require —— 缺文件不是"降级",是模块加载失败 = 全站挂(进程起来就崩)。
# ① 本地版新增了哪些 require?先 diff 出来
grep -nE "^const .*require\('\./" new/lib/pipeline.js
# ② 再逐个到目标机确认存在
for f in seed consistency; do
ssh ... "test -f $APP/lib/$f.js && echo OK_$f || echo MISSING_$f"
done
不要只投"改动的那一个文件"。本例 dev 版 pipeline.js 顶部新增
require('./seed') + require('./consistency'),而生产两个文件都不存在 →
若只投 pipeline.js,平台当场全挂。
> 对照 §四·十(反向):那边讲"生产版含本地没有的补丁,小心覆盖抹掉";
> 这边讲"本地版含生产没有的依赖,小心加载即崩"。双向都要查。
检查 2:投递前用「只解析不执行」验证依赖完整性
直接 require('./lib/pipeline.js') 会执行模块顶层代码(建连接、起定时器)——有副作用。
用 Module.createRequire().resolve() 只走路径解析:
node <<'NODE'
const Module = require('module');
const req = Module.createRequire('/opt/app/lib/pipeline.js');
let bad = 0;
for (const m of ['./seed', './consistency', './frames', './store', './logger']) {
try { req.resolve(m); console.log(' OK', m); }
catch (e) { console.log(' FAIL', m, e.message.split('\n')[0]); bad++; }
}
process.exit(bad ? 1 : 0);
NODE
必须在重启之前跑:这一步失败就回滚,连重启都不用(零停机窗口)。
检查 3:确认健康端点的真名
不要凭直觉 curl /health。先枚举一次:
for ep in /api/health /health /api/healthz /api/ping /; do
printf "%-16s -> %s\n" "$ep" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000$ep)"
done
判据错会让"重启成功"被记成失败(或反过来)。⚠️ 前缀不同可能返回登录页 HTML 且 200(见 §四·五)。
分阶段投递:先 /tmp,再安装
阶段1 校验源 md5 → 建 bundle → 投目标机 /tmp/xxx-bundle/ → 双边 md5+wc 比对 ← 不触碰运行目录
阶段2 备份(固定目录 + ORIGINAL.md5)→ sudo cp → chown → node --check
阶段3 Require.resolve 依赖检查 → 滚动重启 → 逐实例健康 → 全局健康 → 写完成标记
好处:投递失败/中断不会留下半个文件;阶段 1 可反复重跑无副作用。
⚠️ stderr 重定向到固定 /tmp/<名> → 把正常部署误判成失败
真实事故:语法检查写成 node --check "$f" 2>/tmp/syn.err,三个文件全报"语法错误",
触发自动回滚。真因是:
bash: line 83: /tmp/syn.err: Permission denied
/tmp/syn.err 是上一轮部署以 root 身份留下的 644 root:root 文件;
本次以 ubuntu 运行无法覆盖它 → 重定向失败(退出码非 0)→ if node --check …; then 判为失败。
修法:禁用固定 /tmp/<名> 做 stderr 重定向,改命令替换:
if SYN_OUT=$(node --check "$APP/lib/$f" 2>&1); then
echo " OK $f"
else
echo " FAIL $f"; echo "$SYN_OUT" | sed 's/^/ /'; SYNFAIL=1
fi
> 一般化:任何"用固定临时文件名在两个进程/两种权限之间传递数据"的写法都是雷。
> 同族坑:共享 .state 文件被并发实例互相污染。
> 附带一条:这类误判发生在语法检查这一步,代价还算小(在重启前拦住了);
> 若发生在"重启后健康检查"那一步,就会白白回滚一次。所以检查步骤越靠前越好。
自动回滚:健康检查不过就自己退回,不留半坏状态
if all_healthy; then
echo "$WANT_MD5" | sudo tee "$MARK" > /dev/null
else
# 还原「被覆盖的文件」+ 删除「本次新增的文件」+ 重启回原状 + 再验一次
sudo cp "$BAKDIR/pipeline.js" "$APP/lib/pipeline.js"; sudo chown ubuntu:ubuntu "$APP/lib/pipeline.js"
sudo rm -f "$APP/lib/seed.js" "$APP/lib/consistency.js" # ← 新增文件回滚 = 删除
for p in 3000 3001 3002; do sudo systemctl restart "manju@$p"; sleep 5; done
sleep 12; all_healthy && echo "回滚后恢复 200" || echo "回滚后仍异常 → 需人工介入"
fi
要点:
- 新增文件与覆盖文件的回滚动作不同(删 vs 还原),备份时就要分清哪类是新增。
- 回滚后必须再验一次:只回滚不验证 = 不知道回滚成功没有。
- 回滚后仍不健康要打印每个实例的 is-active + health + journal 尾部,别只报一句失败。
- ⚠️ 远端脚本 exit 1 的退出码必须被外层捕获,否则外层照样打印 DONE(见 §四·九)。
- 健康检查给一次二次机会(sleep 15 后重试),避免把"慢启动"误判成失败而白回滚一次。
- 备份目录用固定路径 + ORIGINAL.md5,不能用「时间戳名 + test -f」——
本环境命令会串行跑两遍,第二遍时源文件已是新版,备份里存的就是新版(回滚回去还是坏的)。
幂等 SKIP 判据要覆盖全部被投文件
不能只看主文件的 md5 —— 漏投一个依赖时,判据仍会认为"已装"。用
「完成标记 + 每个文件 md5 + 健康检查」三者同时满足才 SKIP(§四·十一 的校验段仍放 SKIP 之外)。
可复用脚本清单(本项目已入仓)
p11-recon-prod.sh(只读侦察)· p11-diff-prod.sh(dev↔prod 逐行 diff)·
p11-prod-lib-inventory.sh(依赖盘点)· p11-check-prod-env.sh(DATA_DIR/SITE_BASE 真值)·
p11-prod-precheck.sh(端点/用户/属主/磁盘)· p11-prod-deploy-stage1.sh(投 /tmp)·
p11-prod-deploy-stage23.sh(安装+重启+验证+自动回滚)· p11-prod-postmortem.sh(回滚后核实)·
p11-prod-verify-final.sh(幂等/外网/日志)· p11-probe-consistency-prod.sh(真机引擎探针)
四·十四、新增「运行时读取的静态资源」+ 代码换代:先资源、后代码(2026-09-20 实战)
§四·十三 讲的是「新 require 依赖」—— 缺了进程起不来,症状明显。
比它更隐蔽的是运行时才读取的静态资源(模型权重、wasm、字体、字典):缺了进程照常启动,
只有走到那条功能路径时才失败,失败形态往往只是一行日志,部署后极易被忽略。
顺序铁律
备份 → 静态资源 → 代码 → md5 判据 → 语法 → 滚动重启 → 健康验证
资源必须先于代码到位。原因:代码里常带出厂自检函数(checkVendorAssets() 之类),
它在启动/首次调用时对资源做存在性 + 体积下限硬校验。顺序反了 ⇒ vendor-missing: xxx
⇒ 该功能全线不可用(不是降级,是不可用),而健康检查仍是绿的。
与 require 缺失的差别(决定了要不要单独做一步)
缺 require 目标 |
缺运行时静态资源 | |
|---|---|---|
| 进程能否启动 | ❌ 启动即崩 | ✅ 正常启动 |
| 首现位置 | 启动日志 / 进程状态 | 功能调用时 |
| 健康端点表现 | 全站 503 | 200(假绿) |
| 检出手段 | require.resolve(见 §四·十三) |
只能靠资源清单 + 自检日志 |
⇒ 静态资源必须有独立校验步骤:既查「文件在不在」,也查体积 / md5
(自检函数本身就依赖体积下限,说明体积是被信任的判据)。
三件事耦合上线时,如何保留归因能力
「检测器换代 + 门控策略变更 + 阈值语义调整」这类多变量同时上线,用户常担心「出问题归因难」。
不能靠"分多次部署"解决(有时必须一次推完),要靠判据分层:
| 层 | 判据 | 失败时指向 |
|---|---|---|
| 资源层 | 自检日志无 xxx-missing + 资源 md5 全命中 |
资源没到位 |
| 代码层 | 启动日志打出新引擎 / 新版本的专属文案 | 代码没生效 |
| 行为层 | 关键常量计数(grep -c 新旧特征词,如 tier:'soft' 2 处 / 'hard' 0 处) |
改动点漂移 |
| 服务层 | 逐实例 200 + 外网 200 + uptime 归零 |
重启 / 依赖问题 |
启动日志里的「版本 / 引擎切换」文案是最值钱的一条 —— 它是"新代码真的在跑"的直接证据,
比 md5 更硬(md5 只证明文件变了,不证明进程载入了它)。
⇒ 上线前就应确认代码里有这么一行日志;没有的话本步部署应顺手加上。
md5 判据用关联数组写,别逐条 echo 期望值
declare -A EXP=(
["lib/face-scorer.js"]=ac43ca6fb8600c1f11a37a9516868de9
["lib/vendor/face/yunet.onnx"]=4ae92eeb150c82ce15ac80738b3b8167
)
FAILN=0
for f in "${!EXP[@]}"; do
got=$(md5sum "$f" 2>/dev/null | awk '{print $1}')
[ "$got" = "${EXP[$f]}" ] || { echo " FAIL $f got=$got want=${EXP[$f]}"; FAILN=$((FAILN+1)); }
done
echo ">>> MD5_FAILN=$FAILN"
逐条 echo "期望: xxx" 只是打印给人看,脚本自己并不比对 ⇒ 眼睛看漏就过去了。
sudo cp 覆盖已存在文件:属主不变
混合属主目录里,sudo cp -f 到已存在文件会沿用原 inode ⇒ 属主 / 权限保持原样,
不会因为 sudo 变成 root:root;只有新建的文件才以执行者身份落盘。
所以「一律 sudo cp」是安全的(§二 说的"部分成功"根源是权限不够,不是 sudo 造成的)。
落位后仍要 ls -l 确认一遍,别默认。
五、回滚
补丁脚本的 backup() 必须在任何写入之前调用,且每个文件保留一份改动前的原始备份。
注意:备份文件名若只用秒级时间戳,同一秒内的第二次运行会把原始备份覆盖成已改版本,导致回滚失效。要么加随机后缀,要么第一轮跑完立刻确认。
一轮改动涉及多个文件时,用统一时间戳后缀(如 .bak-featurex-<ts>),便于整套识别与整体回退。
回滚 = cp x.js.bak-xxx x.js + 滚动重启。整套一起退,不要只退一部分。
六、收尾
- 清理中间备份,每个文件只留一份原始回滚点。
- 删除跳板机上的临时私钥
/tmp/<prod-key>.pem(先清生产机,再删私钥)。 - 在交付文档里写清:改了哪些文件/行、验证结果、剩余需人工操作的步骤、回滚命令。
验证清单
- [ ] 摸清真实运行方式(
ps/systemctl list-units/pm2 list),确认单元名与实例数 - [ ] 确认
Environment=DATA_DIR等数据目录,改的是真生效的那份 - [ ] 补丁脚本幂等(先清理 → 特征串检测 → 写入)+ 保属主写入
- [ ] 改动文件逐个
node --check - [ ] 上机 grep 验真每个改动点(脚本报 ALREADY 不代表成功)
- [ ] 滚动重启后每个实例
systemctl is-active全 active - [ ] 内网每个实例端口 + 外网域名双验证
- [ ] 改前端资源:外网抓取确认新字符串存在(证伪 CDN 缓存)
- [ ] 重启后 2 分钟
journalctl无 error/fatal/EACCES - [ ] 每文件留一份原始备份作回滚点;清理中间备份
- [ ] 清理生产机临时脚本 → 再删跳板私钥
- [ ] 投静态资源时:资源先于代码,且自检日志无
xxx-missing - [ ] 多变量耦合上线:四层判据(资源 / 代码 / 行为 / 服务)逐层都有输出
- [ ] 部署/运维脚本已进仓库(不是只活在
/tmp);安全检查失败时 stderr 有声,且用调用方同款取法验证过
脱敏说明(发布版)
本包在发布前做过脱敏:原文中的开发机 / 生产机 IP、生产域名、SSH 私钥文件名、
部署根目录已替换为占位符 —— <dev-host> / <prod-host> / <prod-domain> /
<dev-key>.pem / <prod-key>.pem / <dev-root> / <prod-root>。
示例命令里的占位符需要替换为你自己的环境取值后才可执行;127.0.0.1、ubuntu 等
非敏感标识未做替换。方法与判据部分完整保留,脱敏不影响任何结论。