想象一下,你是公司的运维负责人,深夜三点突然收到报警:磁盘满了,业务服务挂了。你冲进办公室(或者在家打开笔记本),第一反应是检查日志、备份数据、重启进程。这时候,如果你手头有一堆随手写的Shell脚本,里面藏着无数坑——变量没引号、逻辑有漏洞、权限没搞对——那你今晚大概率要通宵了。
我见过太多人以为Shell脚本就是“Linux里的记事本”,随便敲几行命令丢进.sh文件就行。但现实中,一个写得不严谨的备份脚本可能导致丢了数据,一个监控脚本可能误杀正常进程。今天咱们不聊虚的,就聊聊那些真实踩过的坑、背后的原理,以及怎么把这些脚本写得像“生产级工具”一样稳健。
备份日志脚本:为什么你的备份可能“假备份”?
先来看一个最常见的场景:每天凌晨备份 /var/log/myapp 下的日志文件。听起来很简单吧?很多人这么写:
#!/bin/bash
DATE=$(date +%Y%m%d)
tar czf /backup/logs_${DATE}.tar.gz /var/log/myapp
echo "Backup completed: $DATE"
代码看着没错,对吧?但这里埋了三个坑,任何一个都能让你在恢复数据时抓狂。
坑一:没有检查源目录是否存在
如果 /var/log/myapp 目录被误删了,tar 命令会报错,但脚本照样继续执行,最后生成一个空的或损坏的压缩包。第二天你恢复时才发现:备份是空的,数据没了。
正确做法:用 -d 或 -e 检查,配合 exit 强制中断:
#!/bin/bash
DATE=$(date +%Y%m%d)
SOURCE="/var/log/myapp"
# 检查源目录是否存在
if [[ ! -d "$SOURCE" ]]; then
echo "Error: Source directory $SOURCE does not exist!" >&2
exit 1
fi
tar czf "/backup/logs_${DATE}.tar.gz" "$SOURCE"
if [[ $? -ne 0 ]]; then
echo "Error: tar command failed for date $DATE" >&2
exit 2
fi
echo "Backup completed: $DATE"
注意两点:
- 用了
[[ ! -d "$SOURCE" ]]而不是[ ! -d $SOURCE ]。双括号支持更好的字符串比较,且变量加了双引号防止路径中含空格时报错。 - 检查
tar的退出码($?),失败时输出到标准错误(>&2),这是Unix哲学的标准做法。
坑二:备份文件名可能冲突或重复
如果你用 date +%Y%m%d,那每天凌晨会覆盖前一天的备份。更糟糕的是,如果脚本因为某种原因意外跑了两次(比如cron配置错误),你会得到两个同名文件,后者覆盖前者,数据永久丢失。
正确做法:加时间戳,或者用 mktemp 确保唯一性:
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/backup/logs_${DATE}.tar.gz"
或者,使用 mktemp 创建临时文件,最后再移动(避免中断时留残骸):
TMPFILE=$(mktemp /tmp/logs_backup.XXXXXX.tar.gz)
tar czf "$TMPFILE" "$SOURCE"
mv "$TMPFILE" "/backup/logs_${DATE}.tar.gz"
这样即使脚本中途崩溃,也不会污染 /backup 目录。
坑三:权限问题导致tar失败
如果脚本以普通用户运行,但 /var/log/myapp 只有root可读,tar 会报 Permission denied,而你可能没注意到,以为备份成功了。
正确做法:在脚本开头检查权限,或用 sudo(但要注意sudo的NOPASSWD配置)。更优雅的方式是在cron中用root执行,并在脚本中显式检查:
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root" >&2
exit 3
fi
监控进程脚本:别让你的监控“瞎了眼”
另一个高频场景:监控关键进程(比如nginx)是否存活,死了就重启。很多公司用这种脚本,但写不好会引发重启风暴或误杀。
看这个常见写法:
#!/bin/bash
PROCESS="nginx"
if ! pgrep -x "$PROCESS" > /dev/null; then
systemctl start "$PROCESS"
fi
问题在哪?
坑一:pgrep -x 的“精确匹配”陷阱
pgrep -x nginx 只匹配命令名完全为 nginx 的进程。但如果你的进程叫 nginx: worker process,pgrep 默认会匹配到父进程,但也可能漏掉。更危险的是,如果有个恶意进程也叫 nginx,你会误以为服务正常。
正确做法:结合 systemctl is-active 或检查端口:
if ! systemctl is-active --quiet nginx; then
echo "nginx is not running, restarting..."
systemctl restart nginx
fi
systemctl is-active 更可靠,因为它直接问systemd“你这个服务状态是什么”,而不是去猜进程名。
坑二:没有频率限制,重启风暴
如果nginx反复崩溃(比如配置错误),脚本每30秒跑一次,就会疯狂重启,消耗CPU,甚至把磁盘写满。
正确做法:加冷却时间或重试计数。用文件记录上次重启时间:
#!/bin/bash
PROCESS="nginx"
COOLDOWN=300 # 5分钟内不重复重启
LAST_RESTART="/tmp/${PROCESS}_last_restart"
if ! systemctl is-active --quiet "$PROCESS"; then
if [[ -f "$LAST_RESTART" ]]; then
LAST_TIME=$(cat "$LAST_RESTART")
NOW=$(date +%s)
DIFF=$((NOW - LAST_TIME))
if [[ $DIFF -lt $COOLDOWN ]]; then
echo "Too soon to restart, skipping. Last restart: $DIFF seconds ago"
exit 0
fi
fi
systemctl restart "$PROCESS"
date +%s > "$LAST_RESTART"
echo "Restarted $PROCESS at $(date)"
else
# 如果服务正常,清除冷却记录
rm -f "$LAST_RESTART"
fi
这样,即使nginx崩溃,脚本最多每5分钟尝试一次重启,给你时间排查问题。
坑三:忽略日志,重启后还是崩
重启成功了,但为什么不崩溃?脚本没记录任何信息,下次出问题你还得手动查。
正确做法:记录重启事件到日志文件:
LOG_FILE="/var/log/process_monitor.log"
echo "$(date): Restarted $PROCESS" >> "$LOG_FILE"
并在脚本开头创建日志目录(如果不存在):
mkdir -p "$(dirname "$LOG_FILE")"
常见报错速查表:这些错误你肯定见过
下面我把常见的Shell脚本报错和原因整理成一张表,方便你排查:
| 报错信息 | 常见原因 | 解决方案 |
|---|---|---|
bash: ./script.sh: /bin/bash^M: bad interpreter |
脚本在Windows下编辑,换行符是CRLF | 用 dos2unix script.sh 转换,或VS Code右下角改LF |
command not found |
环境变量PATH没包含命令路径,或命令名拼错 | 用绝对路径(如 /usr/bin/tar),或检查 which tar |
Permission denied |
脚本没执行权限,或访问的文件权限不足 | chmod +x script.sh,检查文件owner |
unexpected token '(' |
语法错误,通常是括号不匹配或引号未闭合 | 用 bash -n script.sh 检查语法 |
tar: Cowardly refusing to create an empty archive |
源目录为空,tar拒绝创建空包 | 先检查源目录是否有文件:if [[ -z $(ls -A "$SOURCE") ]]; then ... |
ps aux | grep nginx | grep -v grep 中的 grep -v grep |
新手常忘加 -v grep,导致grep自身被匹配 |
始终加 grep -v grep 或改用 pgrep |
if: not found 或 then: not found |
用了zsh语法写bash脚本,或shebang写错 | 确保第一行是 #!/bin/bash,不要用 #!/bin/sh(在某些系统是dash) |
避坑 checklist:写脚本前的自我审查
每次写完Shell脚本,花一分钟过一遍这个清单:
- Shebang对不对? 第一行必须是
#!/bin/bash,不要用#!/bin/sh(除非你确定脚本兼容POSIX)。 - 变量加引号了吗? 所有变量引用都用双引号,尤其是路径:
"$VAR"而不是$VAR。 - 检查了命令的退出码吗? 关键命令后加
if [[ $? -ne 0 ]]; then ...。 - 有错误处理吗? 用
set -e让脚本在命令失败时退出,或用trap捕获信号。 - 测试过边界情况吗? 目录不存在、文件为空、权限不足、网络断开……
- 日志记录了吗? 关键操作都记日志,方便事后排查。
- cron任务配置对了吗? 环境变量和交互式shell不同,记得在脚本里明确设置
PATH。
一个完整的实战例子:带告警的备份+监控脚本
把上面学到的知识整合起来,写一个健壮的脚本:
#!/bin/bash
set -euo pipefail # 严格模式:未定义变量报错、管道失败报错、命令失败退出
# 配置
SOURCE_DIR="/var/log/myapp"
BACKUP_DIR="/backup/logs"
LOG_FILE="/var/log/backup_monitor.log"
PROCESS_NAME="nginx"
COOLDOWN=300
# 初始化
mkdir -p "$BACKUP_DIR" "$(dirname "$LOG_FILE")"
DATE=$(date +%Y%m%d_%H%M%S)
LAST_RESTART_FILE="/tmp/${PROCESS_NAME}_last_restart"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# 检查源目录
if [[ ! -d "$SOURCE_DIR" ]]; then
log "ERROR: Source directory $SOURCE_DIR does not exist!"
exit 1
fi
# 备份日志
TMP_BACKUP=$(mktemp /tmp/backup_${PROCESS_NAME}.XXXXXX.tar.gz)
if tar czf "$TMP_BACKUP" "$SOURCE_DIR"; then
mv "$TMP_BACKUP" "$BACKUP_DIR/logs_${DATE}.tar.gz"
log "Backup completed: $BACKUP_DIR/logs_${DATE}.tar.gz"
else
log "ERROR: Backup failed!"
rm -f "$TMP_BACKUP"
exit 2
fi
# 监控进程
if ! systemctl is-active --quiet "$PROCESS_NAME"; then
if [[ -f "$LAST_RESTART_FILE" ]]; then
LAST_TIME=$(cat "$LAST_RESTART_FILE")
NOW=$(date +%s)
DIFF=$((NOW - LAST_TIME))
if [[ $DIFF -ge $COOLDOWN ]]; then
log "Restarting $PROCESS_NAME..."
systemctl restart "$PROCESS_NAME"
date +%s > "$LAST_RESTART_FILE"
log "$PROCESS_NAME restarted at $(date)"
else
log "Skipping restart, cooldown period not over ($DIFF seconds ago)"
fi
else
log "Restarting $PROCESS_NAME (first time)..."
systemctl restart "$PROCESS_NAME"
date +%s > "$LAST_RESTART_FILE"
log "$PROCESS_NAME restarted at $(date)"
fi
else
rm -f "$LAST_RESTART_FILE"
log "$PROCESS_NAME is running normally"
fi
这个脚本用 set -euo pipefail 开启了严格模式,任何错误都会立即退出。备份和监控逻辑都加了检查、日志和冷却机制。你可以把它放进cron,每小时执行一次。
最后的话
Shell脚本不是“能跑就行”的东西,它是你系统的自动化神经。一个写得不好的脚本,可能在你最忙的时候给你捅娄子;而一个写得好的脚本,能让你安心睡觉。
我见过太多人从“随便写写”开始,最后被坑得团团转。希望这篇指南能帮你少走弯路。记住:在Linux世界,简单不等于简陋,脚本越短,越要严谨。下次写脚本前,先花五分钟想想边界情况,可能会救你一整晚的睡眠。
如果你正在调试某个具体报错,或者想让我帮你review一段脚本,随时丢过来。咱们一起把这些坑填平。
