嘿,我是 Agnes。今天咱们不聊虚的,直接上手。你是不是也有过这种经历:周五下午5点,服务器磁盘报警,你手忙脚乱地去 find 日志文件,然后手抖删错了目录?或者早上来公司,发现昨晚的核心服务挂了半小时你才知道,因为没人手动去重启?
别慌,这些坑我全都踩过,而且不止一次。Shell 脚本不是魔法,它是运维人员的“自动化外骨骼”。今天这篇内容,我会把你从一个“只会 ls 的小白”一步步带到能写出生产级、防报错、高性能脚本的专家水平。咱们不玩教科书那套,直接看实战,看那些教科书里不敢写的“坑”,以及怎么优雅地跳过去。
第一章:打破误区——为什么你的脚本总是要命?
在写第一行代码之前,我得先泼盆冷水。很多初学者写的 Shell 脚本长这样:
#!/bin/bash
rm -rf /var/log/*
service nginx restart
echo "Done"
看着挺爽?错了。这玩意儿在生产环境就是定时炸弹。为什么?
- 没有错误处理:如果日志目录不存在,
rm失败,但脚本会继续执行后面的重启命令,甚至可能因为权限问题把当前目录给删了。 - 缺乏灵活性:你想清哪个日志?硬编码了。
- 不可追溯:出错了你根本不知道是哪里出的错,日志也没记。
- 性能灾难:对海量小文件盲目操作,IO 会把你搞死。
我的原则:生产环境的脚本,必须具备防御性编程思维。每一行都要问自己:“如果这一步失败了,接下来会发生什么?”
第二章:日志智能清理——不只是 rm 那么简单
2.1 场景模拟
假设你的 /app/logs 目录下有一个巨大的 app.log,每天增长 500MB。你需要:
- 清理 7 天前的日志。
- 保留当前正在写入的
app.log(不能删,否则应用报错)。 - 如果磁盘使用率超过 85%,执行紧急清理(保留最近 3 天)。
- 记录清理日志,方便审计。
2.2 代码实战(防御性写法)
#!/bin/bash
# ==========================================
# 脚本名称: smart_log_cleaner.sh
# 功能: 智能日志清理与磁盘监控
# 作者: Agnes (Sapiens AI Expert)
# ==========================================
# 【关键设置】开启严格模式,任何命令出错立即退出,避免错误累积
set -euo pipefail
# --- 配置区 (可外部覆盖) ---
LOG_DIR="/app/logs"
RETENTION_DAYS=7
DISK_THRESHOLD=85
SCRIPT_LOG="/var/log/log_cleaner_audit.log"
# --- 函数定义: 日志记录 ---
# 用 function name 包裹,方便 grep 定位问题
log_msg() {
local level="$1"
local message="$2"
# 使用 date 获取精确时间,格式化为易读形式
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$SCRIPT_LOG"
}
# --- 函数定义: 检查目录是否存在 ---
check_dir() {
if [[ ! -d "$LOG_DIR" ]]; then
log_msg "ERROR" "日志目录 $LOG_DIR 不存在,退出脚本"
exit 1
fi
}
# --- 函数定义: 检查磁盘使用率 ---
# 注意:这里不能用 df | grep 这种不可靠的方式,用 awk 解析
get_disk_usage() {
# -P 参数确保输出 POSIX 格式,避免某些系统输出多行
df -P "$LOG_DIR" | awk 'NR==2 {print $5}' | sed 's/%//'
}
# --- 核心清理逻辑 ---
clean_logs() {
local days="$1"
local find_cmd="find $LOG_DIR -name '*.log' -type f -mtime +$days"
log_msg "INFO" "开始查找并清理 $days 天前的日志文件..."
# 使用 find 的 -delete 前加 -print 统计数量,防止空操作时报错
local file_count=$(find "$LOG_DIR" -name '*.log' -type f -mtime +$days | wc -l)
if [[ "$file_count" -eq 0 ]]; then
log_msg "INFO" "未发现超过 $days 天的日志文件,跳过清理"
return 0
fi
# 【安全警告】严禁在变量未校验前直接执行 rm!
# 使用 find -exec 逐文件处理,比 xargs 更安全(避免参数过长)
find "$LOG_DIR" -name '*.log' -type f -mtime +$days -exec rm -f {} \;
log_msg "INFO" "成功清理 $file_count 个旧日志文件"
}
# --- 主程序 ---
main() {
log_msg "INFO" "=== 日志清理任务启动 ==="
check_dir
local current_usage=$(get_disk_usage)
log_msg "INFO" "当前磁盘使用率: ${current_usage}%"
# 动态调整清理策略
if [[ "$current_usage" -gt "$DISK_THRESHOLD" ]]; then
log_msg "WARN" "磁盘使用率过高!启用紧急清理模式(保留3天)"
clean_logs 3
else
log_msg "INFO" "磁盘使用率正常,执行常规清理(保留7天)"
clean_logs $RETENTION_DAYS
fi
# 清理后二次检查
local after_usage=$(get_disk_usage)
log_msg "INFO" "清理后磁盘使用率: ${after_usage}%"
log_msg "INFO" "=== 日志清理任务结束 ==="
}
# 执行主函数
main "$@"
2.3 常见坑点解析(必看!)
| 坑点 | 错误写法 | 正确做法 | 原因 |
|---|---|---|---|
| 变量未引用 | if [ $size -gt 100 ] |
if [[ "$size" -gt 100 ]] |
如果 $size 为空,脚本会报语法错误退出 |
| Globbing 爆炸 | rm /tmp/*.log |
find /tmp -name '*.log' -delete |
如果文件成千上万,* 会超出命令行参数长度限制 |
| 通配符误用 | rm -rf $LOG_DIR/* |
find $LOG_DIR -type f -mtime +7 -delete |
rm -rf 极其危险,一旦变量为空可能删根目录 |
| 日期计算错误 | date -d "7 days ago" |
使用 find -mtime |
Linux date 在不同发行版(GNU vs BSD)上语法完全不同 |
| 权限问题 | 直接运行 rm |
先 ls -la 检查,或用 sudo 记录 |
普通用户可能没权限删除,导致静默失败 |
2.4 性能优化技巧
当日志文件达到百万级时,find 也会变慢。这时候你需要:
使用
find的并行特性(GNU find 支持):find /app/logs -name '*.log' -mtime +7 -print0 | xargs -0 -P 4 -n 100 rm -f-P 4表示使用 4 个并行进程删除,-print0和-0处理文件名中含空格的情况。使用
inotifywait实时清理(进阶): 如果日志写入频率极高,可以考虑用 inotify 监听文件创建,触发清理,而不是定时全量扫描。
第三章:服务一键启停——不只是 systemctl
3.1 场景模拟
你管理着一个微服务集群,包含 order-service、user-service 和 gateway。它们之间有依赖关系:
- 启动顺序:gateway -> order-service -> user-service
- 停止顺序:user-service -> order-service -> gateway
- 每个服务需要检查健康状态(HTTP 200)才算真正启动成功
3.2 代码实战(依赖管理与健康检查)
#!/bin/bash
# ==========================================
# 脚本名称: service_manager.sh
# 功能: 微服务集群启停与健康检查
# ==========================================
set -euo pipefail
# --- 服务配置 ---
# 格式: 服务名:端口:健康检查路径
declare -A SERVICES=(
["gateway"]="8080/health"
["order-service"]="8081/health"
["user-service"]="8082/health"
)
# 启动依赖顺序 (数组)
START_ORDER=("gateway" "order-service" "user-service")
# 停止依赖顺序 (反向)
STOP_ORDER=("user-service" "order-service" "gateway")
# --- 颜色输出函数 (让日志更友好) ---
color_print() {
local color=$1
local msg=$2
case $color in
red) echo -e "\033[31m$msg\033[0m" ;;
green) echo -e "\033[32m$msg\033[0m" ;;
yellow) echo -e "\033[33m$msg\033[0m" ;;
*) echo "$msg" ;;
esac
}
# --- 健康检查函数 ---
# 使用 curl 检查 HTTP 状态,超时 3 秒
check_health() {
local service=$1
local config=${SERVICES[$service]}
local port=${config%%/*}
local path=${config#*/}
local url="http://localhost:${port}/${path}"
# -s 静默模式, -f 失败不返回 HTTP 错误码, -o /dev/null 不输出内容
if curl -s -f --connect-timeout 3 "$url" > /dev/null 2>&1; then
return 0
else
return 1
fi
}
# --- 启动单个服务 ---
start_service() {
local service=$1
color_print yellow "正在启动 $service ..."
# 假设使用 systemctl 管理,如果是 docker 或 jar 包,替换这里
# 注意:这里加了一些额外的安全判断
if systemctl is-active --quiet "$service"; then
color_print yellow "$service 已经在运行中"
return 0
fi
systemctl start "$service" || {
color_print red "$service 启动失败!"
return 1
}
# 等待服务启动并健康检查
local retries=5
while [[ $retries -gt 0 ]]; do
sleep 2
if check_health "$service"; then
color_print green "$service 启动成功且健康"
return 0
fi
retries=$((retries - 1))
color_print yellow "等待 $service 健康检查... (剩余重试: $retries)"
done
color_print red "$service 启动后健康检查超时,请手动检查!"
return 1
}
# --- 停止单个服务 ---
stop_service() {
local service=$1
color_print yellow "正在停止 $service ..."
if ! systemctl is-active --quiet "$service"; then
color_print yellow "$service 未在运行"
return 0
fi
systemctl stop "$service" || {
color_print red "$service 停止失败!"
return 1
}
# 确认服务已停止
sleep 1
if systemctl is-active --quiet "$service"; then
color_print red "$service 未能完全停止,可能需要强制 kill"
return 1
fi
color_print green "$service 已停止"
return 0
}
# --- 批量操作函数 ---
start_all() {
color_print green "=== 开始启动所有服务 ==="
local failed=()
for service in "${START_ORDER[@]}"; do
if ! start_service "$service"; then
failed+=("$service")
fi
done
if [[ ${#failed[@]} -gt 0 ]]; then
color_print red "以下服务启动失败: ${failed[*]}"
return 1
fi
color_print green "=== 所有服务启动完成 ==="
}
stop_all() {
color_print green "=== 开始停止所有服务 ==="
local failed=()
for service in "${STOP_ORDER[@]}"; do
if ! stop_service "$service"; then
failed+=("$service")
fi
done
if [[ ${#failed[@]} -gt 0 ]]; then
color_print red "以下服务停止失败: ${failed[*]}"
return 1
fi
color_print green "=== 所有服务停止完成 ==="
}
# --- 状态查看 ---
status_all() {
echo "========================================"
printf "%-20s %-10s %-15s\n" "服务名" "状态" "健康检查"
echo "----------------------------------------"
for service in "${!SERVICES[@]}"; do
local sys_status=$(systemctl is-active "$service" 2>/dev/null || echo "unknown")
local health="N/A"
if [[ "$sys_status" == "active" ]]; then
health=$(check_health "$service" && echo "OK" || echo "FAIL")
fi
printf "%-20s %-10s %-15s\n" "$service" "$sys_status" "$health"
done
echo "========================================"
}
# --- 入口 ---
case "${1:-}" in
start)
start_all
;;
stop)
stop_all
;;
restart)
stop_all
sleep 2
start_all
;;
status)
status_all
;;
*)
echo "用法: $0 {start|stop|restart|status}"
exit 1
;;
esac
3.3 关键设计亮点
- 依赖顺序控制:通过
START_ORDER和STOP_ORDER数组明确控制启动/停止顺序,避免“子服务先挂了,父服务还撑着”的尴尬。 - 健康检查重试机制:服务启动需要时间,不能
systemctl start完就认为成功了。这里加了 5 次重试,每次 2 秒,确保服务真正可用。 - 彩色输出:虽然看起来花哨,但在终端里红绿黄三色能帮你瞬间定位问题,不用在一堆文字里找关键词。
- 函数化结构:每个操作都是独立函数,方便后续扩展(比如增加“滚动重启”功能)。
3.4 常见坑点解析
| 坑点 | 错误写法 | 正确做法 | 原因 |
|---|---|---|---|
| 循环变量作用域 | for i in ... 后直接用 $i |
明确传入参数 start_service "$service" |
Shell 中变量是全局的,循环后 $i 可能残留 |
| 数组操作 | ${array} |
${array[@]} 和 ${#array[@]} |
不加 [@] 只取第一个元素,# 取长度 |
| 命令替换 | var = $(cmd) |
var=$(cmd) |
Shell 赋值等号两边不能有空格! |
| 后台进程 | service start & |
使用 systemctl 原生支持 |
手动 & 会丢失 PID 管理,无法监控 |
| 信号处理 | 直接 kill | 使用 trap 处理中断 |
用户 Ctrl+C 时,可能留下孤儿进程 |
第四章:性能优化——让脚本跑得更快、更稳
4.1 避免子进程爆炸
在脚本中,每次使用 $(...) 或 \ 都会创建子进程。子进程创建是有开销的(fork+exec)。
坏例子:
# 假设 logs 目录有 10000 个文件
for file in $(ls /app/logs); do # ls 产生一个子进程,for 循环产生一个
echo "$file" # 每次 echo 也可能涉及管道
done
好例子:
# 使用 while read 循环,避免子进程,且能处理含空格的文件名
find /app/logs -type f -print0 | while IFS= read -r -d '' file; do
echo "$file"
done
4.2 使用内置命令替代外部命令
Bash 有很多内置命令,速度比外部命令快几个数量级。
| 任务 | 慢 (外部命令) | 快 (内置命令) |
|---|---|---|
| 字符串长度 | echo ${#str} |
expr length "$str" |
| 数学运算 | echo $((a + b)) |
expr $a + $b |
| 判断字符串 | [[ "$a" == "$b" ]] |
test "$a" = "$b" (稍慢) |
| 读取文件 | while read line |
cat file | while read (多了一个 cat 进程) |
4.3 并行处理与批处理
当任务量大时,串行太慢。可以用 xargs -P 或 GNU parallel。
