Shell 脚本最容易犯的错,是把它写成一串命令的顺序执行而不考虑"万一"。在自己终端里跑,环境干净、只跑一次、失败了你能马上看见;丢进 cron,这些前提全没了。
下面以一个备份脚本为例,把该有的骨架补齐。
最小但正确的开头
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
这行的作用:
set -e:任何命令返回非 0 就退出,不会因为中间某步失败而继续往下跑set -u:引用未定义变量直接报错,防止变量名打错导致rm -rf $DIR/变成rm -rf /set -o pipefail:cmd1 | cmd2里 cmd1 失败也算失败(默认只看最后一个命令)IFS=$'\n\t':让含空格的文件名不会被意外拆开
set -u 那条不是危言耸听,DIR 拼错成 DIRR 时,rm -rf $DIRR/* 在没开 -u 的情况下会展开成 rm -rf /*。
防并发:flock
cron 每 5 分钟跑一次,但上一轮因为磁盘慢还没结束,下一轮又起来了,两个备份进程互相踩。用文件锁挡住:
LOCK_FILE="/var/lock/backup.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "[$(date '+%F %T')] 已有实例在运行,退出" >&2
exit 0
fi
flock -n 拿不到锁就立即退出(非阻塞)。文件描述符 9 在脚本结束时自动释放锁,进程被 kill 也不会留死锁——这是 flock 比自己写 pid 文件可靠的地方。
如果希望等待而不是立即退出,用 flock -w 60 9(最多等 60 秒)。
清理:trap
脚本中途失败或被 Ctrl+C,别留下半个 tar 包占着磁盘:
TMP_DIR=$(mktemp -d)
cleanup() {
local rc=$?
rm -rf "$TMP_DIR"
[ $rc -ne 0 ] && echo "[$(date '+%F %T')] 脚本失败,退出码 $rc" >&2
exit $rc
}
trap cleanup EXIT INT TERM
trap ... EXIT 无论正常结束还是异常退出都会执行。INT/TERM 捕获 Ctrl+C 和 kill 发来的信号。
注意 cleanup 里先接住 $?(退出码),否则一执行别的命令 $? 就变了。
日志:既能看文件也能看终端
LOG_FILE="/var/log/backup.log"
log() {
local level=$1; shift
local msg="[$(date '+%F %T')] [$level] $*"
echo "$msg" | tee -a "$LOG_FILE" >&2
}
log INFO "备份开始"
log WARN "磁盘剩余空间偏低"
log ERROR "数据库连接失败"
输出到 stderr 并在 cron 里配 MAILTO,失败时就能收到邮件;同时追加到日志文件备查。
完整的备份脚本
把上面拼起来:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# ---------- 配置 ----------
BACKUP_ROOT="/data/backup"
SRC_DIR="/data/app"
RETENTION_DAYS=7
LOG_FILE="/var/log/backup.log"
LOCK_FILE="/var/lock/backup.lock"
# ---------- 锁 ----------
exec 9>"$LOCK_FILE"
flock -n 9 || { echo "[$(date '+%F %T')] 已有实例运行中" >&2; exit 0; }
# ---------- 日志 ----------
log() { echo "[$(date '+%F %T')] [$1] ${*:2}" | tee -a "$LOG_FILE" >&2; }
# ---------- 清理 ----------
TMP_DIR=$(mktemp -d)
trap 'rc=$?; rm -rf "$TMP_DIR"; [ $rc -ne 0 ] && log ERROR "异常退出 rc=$rc"; exit $rc' EXIT INT TERM
# ---------- 前置检查 ----------
[ -d "$SRC_DIR" ] || { log ERROR "源目录不存在: $SRC_DIR"; exit 1; }
# 磁盘剩余空间检查(小于 1GB 就别跑了)
avail=$(df -Pm "$BACKUP_ROOT" | awk 'NR==2 {print $4}')
if [ "$avail" -lt 1024 ]; then
log ERROR "备份目录剩余空间不足: ${avail}MB"
exit 1
fi
# ---------- 执行备份 ----------
mkdir -p "$BACKUP_ROOT"
stamp=$(date '+%Y%m%d_%H%M%S')
target="$TMP_DIR/backup_${stamp}.tar.gz"
log INFO "开始打包 $SRC_DIR"
tar -czf "$target" -C "$(dirname "$SRC_DIR")" "$(basename "$SRC_DIR")"
# 校验包完整性,别等恢复时才发现是坏的
if ! tar -tzf "$target" >/dev/null 2>&1; then
log ERROR "压缩包校验失败,放弃本次备份"
exit 1
fi
log INFO "打包完成: $(du -h "$target" | cut -f1)"
# 原子性地挪到目标位置(先写临时再改名,避免半截文件被误用)
mv "$target" "$BACKUP_ROOT/"
log INFO "备份落盘: $BACKUP_ROOT/backup_${stamp}.tar.gz"
# ---------- 清理旧备份 ----------
deleted=$(find "$BACKUP_ROOT" -name 'backup_*.tar.gz' -mtime +$RETENTION_DAYS -print -delete | wc -l)
log INFO "清理过期备份: ${deleted} 个(保留 ${RETENTION_DAYS} 天)"
log INFO "备份完成"
几个值得单说的点
先写临时目录,再 mv
tar 直接写到 $BACKUP_ROOT 的话,万一打包到一半失败,那里会留一个半个的 .tar.gz。如果恰好有别的程序扫描这个目录,就会拿到坏文件。在 mktemp -d 里打包完再 mv 过去,mv 在同一文件系统上是原子的,要么没有要么完整。
tar -tzf 校验
这一步常被省掉,但它是"备份能不能恢复"的第一个信号。真正的保险是定期做恢复演练,但至少这一步能挡住打包中断这种低级问题。
find -delete 要小心
一定要先用 -print 看一遍会删什么,确认无误再加 -delete。或者干脆分两步:先 find ... -mtime +7 输出到变量,确认后再删。误删备份的痛,经历过一次就记住了。
-mtime +7 的含义
是"修改时间在 7*24 小时之前",不是"7 个自然日前"。配合每天跑一次足够,别和 +0、-1 搞混。
上 cron
# crontab -e
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.cron 2>&1
注意 cron 的环境变量和你的终端不一样——PATH 通常只有 /usr/bin:/bin。如果脚本用了 /usr/local/bin 下的命令(比如自己装的 aws),要么在脚本里写全路径,要么在 crontab 顶部加:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
这是"手动跑得好好的,cron 里就失败"最常见的原因。
排错清单
脚本在 cron 里不工作时按这个顺序查:
bash -n script.sh检查语法bash -x script.sh逐行看执行过程- 手动执行看是否正常(排除权限问题)
- 看 cron 日志
/var/log/cron或journalctl -u cron - 检查
PATH、工作目录(cron 的 cwd 是用户家目录,不是脚本所在目录) - 确认脚本有执行权限
chmod +x