一个人管服务器,最贵的成本不是带宽也不是机器钱,是「上下文重建」。三个月前为了修一次磁盘写满,我翻了半个小时的聊天记录去找当时那条命令;上个月给站点换证书,又把去年写过的验证步骤从头想了一遍;前两周新加的一台机器,我发现它上面有个 cron 任务是我两年前加的,注释只有一行「每日执行」,我还得靠猜去判断它到底会不会删东西。这些事情单看都不大,加起来却是我这一年里最大的时间黑洞。
后来我把运维脚本和文档当成一个需要长期维护的工程来对待,情况才慢慢改变。这篇文章讲的就是这件事:脚本怎么写才不会在半年后变成隐患,文档写什么才真的有人(也就是未来的我自己)会看。里面没有高深的东西,都是被坑出来的一条条具体规则。
一、先承认一件事:脚本的第一读者是半年后的自己
写脚本的时候,人总会下意识地假设读者是「现在的我」——知道这个目录是干什么的、知道那个变量为什么写死、知道为什么要在第 40 行 sleep 3。半年后的我完全不具备这些上下文。我甚至不记得这个脚本存在。
所以判断一个运维脚本写得好不好,有一个非常朴素的测试方法:假设它现在报错了,你只有它的输出,能不能在五分钟内判断出是哪里出了问题、该不该动手修。如果不能,那这个脚本就是个黑盒,它跑得越久,风险越大。
基于这个标准,我给自己定了三条硬规则:
第一,脚本开头必须有一块说明区,写清楚「它做什么、什么时候跑、失败会怎样、出问题看哪里」。这四句话比代码本身还重要。
第二,脚本必须有明确的退出码,0 是成功,非 0 是失败,失败时要往 stderr 写一句人能看懂的话。别让 cron 每天早上给你发一封标题叫「Output」的空邮件。
第三,任何会修改数据、删除文件、重启服务的脚本,都必须先支持 dry-run。没有 dry-run 的破坏性脚本,不要放进 crontab。
二、脚本头部的四行注释,具体该写什么
我见过很多脚本的注释是「# 备份数据库」,这跟没有注释差不多。真正有用的说明区大概长这样:
#!/bin/bash
# 用途: 每日 03:20 把 MySQL 全库导出到 /data/backup,保留 7 天本地副本,
# 并同步一份到远端 rsync 目标。
# 触发: crontab - 20 3 * * * /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
# 依赖: /root/.my.cnf (含密码)、远端 ssh key、rsync 3.x
# 失败: 任一步骤非 0 退出并写 stderr;cron 邮件即为告警源。
# 注意: 清理逻辑按文件名日期匹配,改文件名格式前先改下面的 find 条件。
set -euo pipefail
这里「注意」那一行是我吃过亏之后加的。当时我把备份文件名从 db-2026-01-01.sql.gz 改成了带小时的形式,结果清理用的 find -name "db-*.sql.gz" -mtime +7 依然匹配,看起来没问题,但另一个按名字解析日期的校验脚本就全废了,它把日期解析成了空值,然后判定「所有备份都过期」,差点把一个月的备份全删了。规则是:任何跨越「命名约定」和「处理逻辑」两处的设计,必须在注释里点明耦合点。
三、set -euo pipefail 不是万能药,得知道它救不了什么
现在几乎每个 bash 脚本开头都会写 set -euo pipefail。这四段分别管:命令失败即退出、未定义变量报错、管道中任一环节失败即失败。它们确实能挡掉大量事故,但有几个场景它管不到,而这些恰恰是运维脚本里最常见的写法。
第一种,命令在 if 或 || 里时,set -e 不会触发。比如 if ! mysqldump ...; then echo fail; fi,mysqldump 挂了不会让脚本退出,只会进 else 分支——这通常是你想要的,但如果你后面忘了处理这个分支,脚本会带着一份残缺的 dump 继续往下走。
第二种,管道中间进程失败。pipefail 能解决大部分,但如果你写了 cmd | head -1,head 提前退出会导致 cmd 收到 SIGPIPE,pipefail 反而把它判成失败。这时候要显式处理,或者干脆别用管道截断。
第三种,也是最隐蔽的一种:命令返回 0 但结果是错的。mysqldump 在某些情况下会输出一条警告后返回 0,但导出的文件是不完整的;rsync 返回 24 表示「源文件在传输过程中消失了」,它在语义上是失败,在很多脚本里却被当成可忽略。这类问题 set -e 无能为力,只能靠「结果校验」这一步来兜。
所以我给备份类脚本固定加了一步校验:导完之后检查文件大小是否大于上一次的 80%,再抽一行 tail -1 确认有 Dump completed 字样。这一步花三秒,挡住的是「备份存在但不可用」这种最糟糕的情况。
四、幂等和锁:让脚本被跑两次也不会出事
cron 脚本在三种情况下会被并发执行:上一次还没跑完下一次就触发了、手工执行的时候正好赶上定时任务、或者有台机器上装了两份一样的 crontab(这在迁移服务器的时候特别容易发生)。解决方案是加文件锁,最省事的写法是 flock:
#!/bin/bash
set -euo pipefail
exec 9>/var/lock/db-backup.lock
if ! flock -n 9; then
echo "上一次备份还在运行,退出" >&2
exit 0
fi
注意这里退出码给的是 0 而不是 1。因为「上一次还在跑」不是故障,如果给 1,监控会误报。这个细节看起来小,但它决定了你的告警是否可信,而一个总在误报的告警系统,两周之后就会被所有人忽略。
幂等比加锁更深一层:即使只跑一次,重跑也应该得到同样的结果。mkdir 要写 mkdir -p,cp 要考虑目标已存在,追加写日志的脚本要考虑重跑导致数据翻倍,删除类操作要能识别「已经删过了」。我遇到过最典型的一个:一个清理脚本用 rm -rf /data/tmp/*,然后 mkdir /data/tmp——第一次跑没问题,第二次跑时因为 mkdir 已存在而返回非 0,在 set -e 下整个脚本中止,后面的正向操作全都没执行,监控看起来一切正常,因为脚本「失败了」这件事本身没人看。
五、文档不是写给人看的,是写给「未来的故障」看的
个人站长写文档最常见的失败模式,是写成了「安装教程」:怎么装 Nginx、怎么配 PHP,一步一步抄下来。这种文档有两大问题,一是网上一搜一大把,二是它记录的是「正常路径」,而你真的会去翻文档的时候,往往是「不正常的时候」。
我现在只写四类文档,而且每一类都控制在一页以内:
第一类,资产清单。一台机器一行,写清楚主机名、IP、用途、系统版本、关键服务、数据目录、备份位置、到期时间。这张表的价值在于「出事的时候你知道去哪找」,以及「续费的时候不会漏」。我把域名到期、证书到期、云主机到期三类时间都放进了同一张表,按月看一眼。
第二类,操作手册,但只写「会出错的步骤」。比如「换证书」这个操作,安装步骤不用写,要写的是:改完配置记得 nginx -t 再 reload;证书链顺序必须是叶子证书在前、中间证书在后,反了会导致部分安卓设备报错;reload 之后要用 curl -vI https://域名 确认返回的证书序列号是新的。这三条才是真正容易错的地方。
第三类,故障复盘。每次线上出事之后,一页纸:现象是什么、第一判断是什么、实际原因是什么、改了什么、以后怎么避免。这类文档有个隐形好处——写第三次类似故障的时候你会发现,前两次的复盘其实指向同一个根因,那就该动架构了,而不是继续打补丁。
第四类,变更记录。一个文件,按时间倒序,一行一次变更:日期、改了什么、为什么改、怎么回滚。我把它放在 /root/CHANGELOG.md,用 git 管着。「怎么回滚」这一列经常是空的,可是每次填不出来的时候,我都会顺手把回滚方案补上——因为它填不出来的那一刻,正说明这次变更风险很高。
六、dry-run 的具体写法,以及为什么它比「小心一点」有用
破坏性脚本加 dry-run 已经不是新鲜建议了,但很多人的 dry-run 只是加个 echo,输出「would delete /data/a.log」,然后就结束了。这样的 dry-run 价值有限,因为它没有真正跑一遍筛选逻辑,你根本不知道筛选条件是不是写对了。
我现在的写法是:把「决定要操作哪些目标」和「对目标执行操作」拆成两段,dry-run 只跳过第二段。这样筛选逻辑(find 条件、SQL where 条件、名单过滤)在 dry-run 里是真实执行的,你能看到真正会被处理的东西。
#!/bin/bash
set -euo pipefail
DRY="${DRY_RUN:-0}"
mapfile -t targets < <(find /data/logs -type f -name '*.log' -mtime +30)
echo "命中 ${#targets[@]} 个文件"
if [ "${#targets[@]}" -gt 500 ]; then
echo "数量异常偏大,可能是筛选条件写错了,中止" >&2
exit 1
fi
for f in "${targets[@]}"; do
if [ "$DRY" = "1" ]; then echo "would rm $f"; else rm -f -- "$f"; fi
done
里面那个「数量异常偏大就中止」的阈值,是我最看重的一行。所有按条件批量删除的脚本,都应该有一个「预期数量上限」,超过就停下等人。因为误删事故的形态几乎永远一样:条件写宽了,一次匹配到了几千个文件,脚本很听话地全删了。加一个数字判断只需要一行,挡住的是不可逆的损失。
七、日志与输出的分寸:能被 grep,也能被人读
运维脚本的输出有两种读者:人和机器。给人看的要能一眼看懂发生了什么,给机器看(监控、告警、日志聚合)的要能稳定解析。比较省事的做法是让每一行都有固定前缀:
[2026-11-12 03:20:01] [db-backup] [INFO] 开始导出,目标 /data/backup/db-20261112.sql.gz
[2026-11-12 03:22:47] [db-backup] [INFO] 导出完成,大小 412M,耗时 166s
[2026-11-12 03:22:49] [db-backup] [WARN] 远端同步耗时 2s,低于预期,请确认网络
[2026-11-12 03:22:49] [db-backup] [DONE] 全部成功
时间戳必不可少——cron 日志没有时间戳是灾难,因为你没法判断它是今天三点跑的还是一周前三点跑的。级别标签(INFO/WARN/DONE)让 grep 一秒钟就能筛出异常。最后一行固定写 DONE,这样监控里可以查「最近一次日志里有没有 DONE」,比查退出码更可靠,因为有些脚本的失败发生在子进程里。
一个反直觉的经验:日志不要写太多。set -x 开着的脚本在 crontab 里每天给你发几百行,你三个月之后就不会再打开它了。我一般只在 DRY_RUN=1 或调试模式下打开 xtrace,正常运行时只输出「开始、关键节点、结束」三类。
八、把重复的部分抽成模板,但别抽成框架
写到第五个脚本的时候,你会发现每个脚本都有同样的开头:shebang、set、锁、日志函数、错误处理。抽出来是自然的。但这里有个度的问题——我见过有人为了管五个脚本,写了三百行的公共库,最后改公共库比改脚本还紧张,因为谁也不知道哪个脚本依赖了哪个函数。
我的做法是克制地抽三样东西:日志函数、锁的获取、以及「失败时发通知」的函数。其他一概不抽。三个函数大概四十行,放在 /opt/scripts/lib/common.sh,脚本里 source 进来即可。四十行的库改起来没心理负担,三百行的库会变成第二个需要维护的系统。
另外一条:脚本目录要能一眼看清现状。我用 /opt/scripts/ 放所有正式脚本,文件名统一用「对象-动作.sh」,比如 db-backup.sh、log-clean.sh、cert-renew-hook.sh。临时脚本一律放 /tmp 且不写进 crontab。这条看着像洁癖,但它解决了一个真实问题:迁移服务器的时候,我能把 /opt/scripts/ 整个打包带走,而不会漏掉某个藏在 /root 下的关键脚本。
九、crontab 也要有文档
crontab 本身是文档的天敌:一行一个任务,没有注释习惯的人写完就不管了。crontab -l 的输出应该能被一个完全不了解这个服务器的人读懂,方法是每一行上面加一个注释块:
# 每日 03:20 数据库全量备份,保留 7 天,失败发邮件
20 3 * * * /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
# 每 5 分钟拉一次外部价格源,脚本内部有 flock,不会并发
*/5 * * * * /opt/scripts/price-sync.sh >/dev/null 2>&1
另外两条实践:一是尽量用绝对路径,cron 的 PATH 跟你登录时的 PATH 不一样,很多「手工能跑、cron 跑不了」都是这个原因;二是时间别都堆在整点,我见过十几个站点的备份全在 03:00 跑,如果它们共享同一台数据库或者同一个对象存储,那就变成了每天一次的自我 DDoS。错开五分钟,代价为零。
十、一份「如果我不在了」的清单
最后说一个稍微敏感但很实际的场景:如果某天你没法处理这台服务器(出差、生病、或者干脆想休息一周),别人能不能接手?个人站长常常是单点,而这个单点的风险不是「我不在」,而是「我不在的时候没人知道该做什么」。
我维护了一份不超过两页的应急清单,内容包括:服务器登录方式在哪里、数据库和备份在哪里、域名和证书在哪个账号下、当前有哪些定时任务、出问题先看哪个日志。这份清单不写技术细节,只写「去哪找」。它平时用不上,但每次更新它的时候,我都会发现自己有一两处其实也记不清了——那正好是该补文档的地方。
写脚本和维护文档,本质上都是在给未来的自己省时间,而未来那个自己通常是在最着急的时候才来找这些东西。所以标准不应该是「写得全」,而是「着急的时候找得到、看得懂、敢照着做」。按这个标准,一页写清操作边界的手册,比三页抄来的安装步骤有用得多。