嗨,朋友!既然你点开了这个话题,说明你可能刚被服务器告警惊醒,或者正对着满屏的乱码日志发呆。别慌,咱们今天不聊那些枯燥的语法书,而是直接钻进运维现场,看看那些真正在Linux机房里“流血流汗”的Shell脚本是怎么写的。
我会把自己想象成一个刚从生产环境“爬出来”的老兵,带你看看那些坑,也看看怎么填坑。如果你是个刚入门的小朋友,或者只是个想把重复劳动甩给脚本的“懒人”程序员,这篇文章就是为你准备的。我们要聊的不仅是代码,更是一种“让机器干活,让人喝茶”的思维方式。
一、 为什么我们还需要Shell?
在Kubernetes、Ansible、Terraform满天飞的时代,很多人问:“都2026年了,还在写Shell脚本?是不是太土了?”
我的回答是:只要Linux还活着,Shell就永远不会死。
为什么?因为有些活儿,只有Shell能最快、最轻、最直接地搞定。
想象一下这个场景:
- 你的Redis集群突然内存爆满,告警响了。
- 你需要立刻清理过期Key,然后重启某个节点,并检查主从同步状态。
- Ansible playbook写好了,但要部署、测试、调试……耗时至少10分钟。
- 而你只需要一个脚本,30秒内直接登录那台机器,执行清理,重启,验证。
这就是Shell的魅力:它没有依赖,没有容器,没有复杂的配置,它就是Linux本身。
但Shell也是一把双刃剑。它简单,所以容易写出“垃圾代码”;它强大,所以一个小小的语法错误就能让生产环境宕机。今天,我们就来看看怎么写出像人话一样好读、像钟表一样精准的Shell脚本。
二、 基础不牢,地动山摇:那些90%的人都会踩的坑
在写复杂脚本之前,我们先聊聊那些让你怀疑人生的基础问题。很多脚本出Bug,不是因为逻辑错,而是因为对Shell的行为理解错了。
1. 引号的秘密:双引号 vs 单引号
这是一个经典问题。很多人分不清什么时候该用单引号,什么时候该用双引号。
规则很简单:
- 单引号:里面的一切都是字面意思,变量不会展开,转义字符也无效。
- 双引号:变量会展开,
$()命令替换会执行,转义字符\依然有效(除了$、`、\、")。
实战案例:日志处理
假设你要写一个脚本,把当天的日志按日期归档,并记录操作人。
#!/bin/bash
# 错误示范:变量未加双引号,如果变量为空或包含空格,会出问题
DATE=$(date +%Y%m%d)
USER=$(whoami)
echo $USER created $DATE.log > /tmp/log_action.txt
# 正确示范:始终用双引号包裹变量
DATE=$(date +%Y%m%d)
USER=$(whoami)
echo "$USER created $DATE.log" > /tmp/log_action.txt
为什么?因为如果USER的值是John Doe(带空格),不加引号的话,echo会把Doe当成第二个参数,导致输出变成John Doe created 20260715.log,而重定向>只会把第一个单词John写进去,剩下的直接丢失!这可不是闹着玩的。
给小朋友的比喻: 想象单引号是一个“保密箱”,里面说什么就是什么,谁也听不见;双引号是一个“广播喇叭”,里面的变量会被大声读出来。如果你想隐藏信息,用单引号;如果你想展示信息,用双引号。
2. set -e:让脚本在出错时立即停止
很多新手写脚本,遇到命令失败也不管,继续往下执行。这会导致错误累积,最后产生一堆无效数据,甚至灾难性后果。
最佳实践:在脚本开头加上set -e
#!/bin/bash
set -e # 任何命令返回非0状态码,脚本立即退出
set -u # 使用未定义的变量时报错
set -o pipefail # 管道中任何一个命令失败,整个管道返回失败
echo "Starting backup process..."
backup_db() {
mysqldump -u root -p${DB_PASS} mydb > /backup/db.sql
}
backup_db
echo "Backup completed."
如果没有set -e,当mysqldump失败(比如密码错误),脚本会继续执行“Backup completed.”,并可能尝试压缩一个不存在的.sql文件,导致后续所有操作都基于错误前提进行。
但是! set -e也有坑。比如你有一个命令可能失败,但你希望脚本继续运行:
# 错误示范:if语句中的命令失败不会触发set -e退出,但单独写会
if [ -f /tmp/flag ]; then
rm /tmp/flag
fi
# 正确示范:如果希望命令失败不退出,用 || true
rm /tmp/flag || true
echo "Continuing..."
3. 管道中的陷阱:pipefail
在Shell中,cmd1 | cmd2的返回值通常是cmd2的返回值。但如果cmd1失败了,你根本不知道!
# 危险:grep找不到会返回1,但整个管道可能返回0(因为tail成功)
cat /var/log/nginx/error.log | grep "critical" | tail -5
# 正确:启用pipefail
set -o pipefail
cat /var/log/nginx/error.log | grep "critical" | tail -5
有了pipefail,只要管道中任何一个命令失败,整个管道的返回值就是失败。这对于自动化脚本至关重要,因为你需要知道“哪一步出错了”。
三、 实战案例1:智能日志轮转与清理脚本
日志管理是运维的日常。Linux有logrotate,但有时候你需要更灵活的自定义逻辑。比如:
- 只清理大于7天的日志
- 清理时压缩并保留最近10个压缩包
- 如果磁盘使用率超过90%,强制清理所有日志
下面是一个完整的、生产可用的日志清理脚本:
#!/bin/bash
# log_cleaner.sh - 智能日志清理脚本
# 作者:Agnes (Sapiens AI)
# 用途:清理指定目录下的旧日志,并在磁盘紧张时强制执行
set -euo pipefail
# ================= 配置区 =================
LOG_DIR="/var/log/myapp"
RETENTION_DAYS=7
COMPRESS_DAYS=3
DISK_THRESHOLD=90
BACKUP_DIR="/backup/log_archives"
LOCK_FILE="/tmp/log_cleaner.lock"
# ================= 函数定义 =================
# 日志输出函数:带时间戳,区分级别
log() {
local level=$1
local msg=$2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $msg"
}
# 检查磁盘使用率
check_disk_usage() {
local usage=$(df -h "$LOG_DIR" | awk 'NR==2 {gsub(/%/,""); print $5}')
echo "$usage"
}
# 获取锁,防止并发执行
acquire_lock() {
if [ -f "$LOCK_FILE" ]; then
local pid=$(cat "$LOCK_FILE")
if kill -0 "$pid" 2>/dev/null; then
log "ERROR" "Another instance is running with PID $pid. Exiting."
exit 1
else
log "WARNING" "Stale lock file found. Removing."
rm -f "$LOCK_FILE"
fi
fi
echo $$ > "$LOCK_FILE"
log "INFO" "Lock acquired."
}
release_lock() {
rm -f "$LOCK_FILE"
log "INFO" "Lock released."
}
# 压缩日志文件
compress_logs() {
log "INFO" "Compressing logs older than $COMPRESS_DAYS days..."
find "$LOG_DIR" -name "*.log" -type f -mtime +${COMPRESS_DAYS} -exec gzip {} \;
}
# 删除过期日志
remove_old_logs() {
log "INFO" "Removing logs older than $RETENTION_DAYS days..."
find "$LOG_DIR" -type f \( -name "*.log" -o -name "*.log.gz" \) -mtime +${RETENTION_DAYS} -delete
}
# 强制清理:当磁盘使用率超过阈值时
force_clean() {
local usage=$(check_disk_usage)
if [ "$usage" -gt "$DISK_THRESHOLD" ]; then
log "WARNING" "Disk usage is ${usage}%, exceeding threshold ${DISK_THRESHOLD}%. Performing force clean."
find "$LOG_DIR" -type f -name "*.log" -delete
find "$LOG_DIR" -type f -name "*.log.gz" -mtime +1 -delete
else
log "INFO" "Disk usage is ${usage}%, normal. Skipping force clean."
fi
}
# 移动压缩日志到备份目录
archive_logs() {
if [ ! -d "$BACKUP_DIR" ]; then
mkdir -p "$BACKUP_DIR"
fi
log "INFO" "Archiving compressed logs..."
find "$LOG_DIR" -name "*.log.gz" -mtime +0 -exec mv {} "$BACKUP_DIR" \;
}
# ================= 主流程 =================
main() {
log "INFO" "=== Log Cleaner Started ==="
acquire_lock
trap release_lock EXIT # 脚本退出时自动释放锁
check_disk_usage
compress_logs
remove_old_logs
force_clean
archive_logs
log "INFO" "=== Log Cleaner Finished ==="
}
main "$@"
代码解析:
- 配置区:所有可调参数集中在顶部,方便你修改。
set -euo pipefail:安全第一。log()函数:统一输出格式,方便后期用grep过滤日志级别。- 锁机制:防止脚本被多次启动导致混乱。
trap EXIT确保即使脚本异常退出,锁也能被释放。 find命令:-mtime +7表示7天前的文件,-exec gzip就地压缩,-delete直接删除。
给小朋友的比喻:
想象你的日志是房间里的玩具。RETENTION_DAYS是你决定玩具多久不玩就扔。DISK_THRESHOLD是房间太挤了(超过90%),你必须立刻清出空间。这个脚本就像一个聪明的机器人,每天自动收拾房间,把不玩的玩具压缩打包(compress),扔掉的玩具存到仓库(archive),如果房间太挤,连最新的玩具也先扔出去。
四、 实战案例2:数据库健康检查与自动重启
数据库是应用的心脏。心脏停了,全完。我们需要一个脚本,定期检查数据库状态,如果挂了,自动重启,并通知负责人。
#!/bin/bash
# db_health_check.sh - MySQL健康检查与自动重启
# 注意:生产环境请谨慎使用自动重启,建议先告警再决策
set -euo pipefail
# 配置
DB_HOST="localhost"
DB_USER="admin"
DB_PASS="s3cr3tP@ss"
DB_NAME="production_db"
ALERT_EMAIL="ops-team@company.com"
LOG_FILE="/var/log/db_health_check.log"
MAX_RETRY=3
RETRY_DELAY=10
# 记录日志
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# 检查数据库是否可达
check_db() {
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" -e "SELECT 1" &>/dev/null
}
# 发送告警邮件
send_alert() {
local subject="ALERT: Database $DB_NAME on $DB_HOST is down!"
local body="Database health check failed at $(date). Automatic restart will be attempted."
echo "$body" | mail -s "$subject" "$ALERT_EMAIL"
log "ALERT" "Email sent to $ALERT_EMAIL"
}
# 重启数据库服务
restart_db() {
log "INFO" "Attempting to restart MySQL service..."
systemctl restart mysql
sleep 5 # 等待服务启动
# 验证重启是否成功
if check_db; then
log "INFO" "Database restarted successfully."
return 0
else
log "ERROR" "Database restart failed."
return 1
fi
}
# 主循环
main() {
log "INFO" "=== DB Health Check Started ==="
local attempt=0
while ! check_db; do
attempt=$((attempt + 1))
log "WARNING" "Database check failed (attempt $attempt/$MAX_RETRY)"
if [ "$attempt" -ge "$MAX_RETRY" ]; then
log "ERROR" "Max retries reached. Sending alert."
send_alert
restart_db || log "CRITICAL" "All recovery attempts failed!"
break
fi
sleep "$RETRY_DELAY"
done
if check_db; then
log "INFO" "Database is healthy."
fi
log "INFO" "=== DB Health Check Finished ==="
}
main
关键点:
tee -a:日志既输出到屏幕,也追加到文件。MAX_RETRY和RETRY_DELAY:避免误判。网络抖动可能让数据库暂时不可达,不要立刻报警。restart_db函数:重启后必须验证是否成功。很多新手只重启,不验证,结果重启了个寂寞,数据库还是挂的。send_alert:在重启失败前告警,让运维人员有时间介入。自动重启是最后的保险丝,不是首选。
给小朋友的比喻:
这就像你家里有自动浇水系统。每天检查土壤湿度(check_db),如果太干(数据库挂了),先等等看是不是云遮住了太阳(网络抖动,RETRY_DELAY),如果还是太干,就打开水龙头(restart_db),并给你发微信(send_alert)。如果水龙头坏了,你就得亲自去修了。
五、 实战案例3:批量服务器状态巡检脚本
当你管理几十上百台服务器时,不可能每台都手动SSH上去看。我们需要一个批量巡检脚本。
假设你有一个服务器列表文件servers.txt,每行一台服务器的IP:
192.168.1.10
192.168.1.11
192.168.1.12
巡检内容:CPU使用率、内存使用率、磁盘使用率、SSH是否可达。
”`bash #!/bin/bash
server_inspect.sh - 批量服务器状态巡检
依赖:sshpass(非交互式登录),sar(系统活动报告)
set -euo pipefail
SERVER_FILE=“servers.txt” RESULT_FILE=“inspectionresult$(date +%Y%m%d_%H%M%S).csv” THRESHOLD_CPU=80 THRESHOLD_MEM=85 THRESHOLD_DISK=90
检查依赖
for cmd in sshpass sar; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd is not installed. Please install it first."
exit 1
fi
done
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
巡检单台服务器
inspect_server() {
local ip=$1
local ssh_cmd="sshpass -p 'your_password' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@$ip"
log "INFO" "Inspecting $ip..."
# 检查SSH可达性
if ! $ssh_cmd "echo 'SSH OK'" &>/dev/null; then
echo "$ip,UNREACHABLE,-,-,-" >> "$RESULT_FILE"
return
fi
# 获取CPU使用率(取最近5分钟的平均值)
local cpu_idle=$($ssh_cmd "sar -u 1 3 | awk 'NR==4 {print \$NF}'" | tail -1)
local cpu_usage=$(echo "100 - $cpu_idle" | bc)
# 获取内存使用率
local mem_total=$($ssh_cmd "free -m | awk 'NR==2{print \$2}'")
local mem_used=$($ssh_cmd "free -m | awk 'NR==2{print \$3}'")
local mem_usage=$(echo "scale=2; $mem_used * 100 / $mem_total" | bc)
# 获取磁盘使用率(取根分区)
local disk_usage=$($ssh_cmd "df -h / | awk 'NR==2{gsub(/%/,""); print \$5}'")
# 判断是否告警
local alert=""
if (( $(echo "$cpu_usage > $THRESHOLD_CPU" | bc -l) )); then alert="${alert}CPU_HIGH "; fi
if (( $(echo "$mem_usage > $THRESHOLD_MEM" | bc -l) )); then alert="${alert}MEM_HIGH "; fi
if (( disk_usage > THRESHOLD_DISK )); then alert="${alert}DISK_HIGH "; fi
echo "$ip,$cpu_usage,$mem_usage,$disk_usage,$alert" >> "$RESULT_FILE"
if [ -n "$alert" ]; then
log "WARNING" "Alert for $ip: $alert"
else
log "INFO" "Server $ip is healthy."
fi
}
主流程
main() {
log "INFO" "=== Batch Inspection Started ==="
echo "IP,CPU_
