Shell脚本工程化:从能跑到稳跑的7大核心原则
1. 这不是“写个脚本”的事,是建立你和系统之间的高效对话通道
Shell 脚本从来就不是程序员的专利,也不是运维工程师的专属工具。它本质上是你和操作系统之间最直接、最省力的一条沟通专线。我带过上百个不同背景的学员——有刚转行的前端开发者,有每天要处理几十份日志的客服主管,有连ls都要查手册的行政助理,还有自己搭 NAS 存家庭照片的退休教师。他们最后都发现:真正卡住效率的,从来不是“不会写”,而是“不知道为什么这样写”、“改一行就全崩”、“别人给的脚本在自己机器上死活不跑”。这篇笔记,就是为解决这些真实痛点而写的。它不讲 Bash 语法大全,不堆砌man bash里的晦涩定义,而是还原一个资深脚本作者在真实项目里怎么思考、怎么选型、怎么调试、怎么把“能跑”变成“稳跑十年”。关键词里有两个词特别关键:Training和Screencast。这意味着它必须可学、可练、可复现——每一个命令你敲下去,都应该立刻看到结果;每一段逻辑你改完,都应该清楚知道它会改变什么行为。所以整篇内容全部基于 macOS 和主流 Linux 发行版(Ubuntu/CentOS Stream)实测,所有路径、权限、输出格式都来自真实终端录屏回放。如果你正被重复性操作拖慢节奏,比如每周手动整理下载文件夹、每月导出数据库备份再压缩上传、每天要开七八个终端窗口执行固定命令……那这篇笔记就是为你量身定制的“自动化肌肉记忆训练指南”。
2. 核心设计思路:为什么我们不从“Hello World”开始?
2.1 真实项目中的脚本,从来不是“功能堆砌”,而是“问题分层”
很多初学者一上来就猛学for循环和if判断,结果写出来的脚本像一锅乱炖:变量命名全用a,b,c;错误处理只有一句echo "something went wrong";日志输出和标准输出混在一起,出问题时根本分不清是程序报错还是自己echo的提示。这背后是设计思路的根本偏差——把脚本当成“命令集合”,而不是“可维护的微型程序”。
我在 Six Feet Up 做自动化方案时,第一原则永远是:先画“失败地图”,再写成功路径。比如一个常见的需求:“每天凌晨 2 点自动备份网站目录到远程服务器”。表面看是tar + scp的组合,但实际要拆解成至少 5 层防御:
- 第一层:本地磁盘空间是否足够?(
df -h /backup | awk 'NR==2 {print $5}' | sed 's/%//') - 第二层:源目录是否存在且可读?(
[ -d "/var/www/html" ] && [ -r "/var/www/html" ]) - 第三层:远程服务器 SSH 连接是否可达?(
timeout 5 ssh -o ConnectTimeout=5 user@host 'echo ok' >/dev/null 2>&1) - 第四层:远程目标路径是否有写入权限?(
ssh user@host 'test -w /backup') - 第五层:
tar打包过程本身是否成功?(检查$?退出码,而非只看tar是否返回)
你看,真正的脚本骨架,70% 是围绕“哪里可能失败”来构建的,只有 30% 是实现核心功能。这就是为什么视频里 Clayton Parker 没有花 10 分钟讲echo "Hello World",而是直接演示如何用set -euxo pipefail这一行“安全开关”让脚本在任何一步出错时立即停止并打印详细上下文——它不是炫技,是把“失败不可见”这个最大隐患,从根子上掐掉。
2.2 变量设计:不是“存数据”,而是“建契约”
视频里提到“四种设置变量的方式”,但没展开说:为什么单引号和双引号行为不同,本质是 Bash 对“求值时机”的严格控制。这不是语法细节,而是工程契约。
举个血泪教训:某客户曾用脚本批量重命名照片,变量写成filename="photo_$(date +%Y%m%d_%H%M%S).jpg"。本地测试完美,上线后所有文件名变成photo_$(date +%Y%m%d_%H%M%S).jpg—— 日期没替换!原因?他把这行代码放在了单引号包裹的字符串里:echo 'mv "$old" "$filename"' >> batch.sh。单引号内的$()完全不求值,Bash 当作纯文本处理。而双引号"..."会进行变量展开和命令替换,这才是动态生成的正确姿势。
更深层的设计逻辑是:变量名本身就在传递语义契约。
SRC_DIR表示“源目录路径”,它必须以/结尾(如/home/user/docs/),后续拼接文件时才不会出错:cp "$SRC_DIR/file.txt" "$DEST_DIR/"。LOG_FILE表示“日志文件路径”,它必须是绝对路径(如/var/log/backup.log),避免因当前工作目录变化导致日志写入错误位置。MAX_RETRY表示“最大重试次数”,它必须是纯数字(如3),不能带空格或单位("3 times"就会破坏for ((i=0; i<MAX_RETRY; i++))的计算)。
我在教新人时,强制要求变量名全部大写+下划线,并在脚本开头用注释块明确定义每个变量的“契约”:
# === 变量契约声明 === # SRC_DIR: 源文件夹路径,必须以 '/' 结尾,例:/data/incoming/ # DEST_DIR: 目标文件夹路径,必须以 '/' 结尾,例:/data/processed/ # LOG_FILE: 日志文件绝对路径,例:/var/log/processor.log # MAX_AGE_HOURS: 文件处理超时阈值(小时),纯整数,例:24 # =====================这不是形式主义。当三个月后你回来维护这段脚本,或者同事接手时,光看变量名和契约注释,就能 10 秒内理解整个脚本的数据流向和边界条件。
2.3 条件判断:别只盯着if,先想“什么是‘真’?”
视频里说“用内置检查判断文件是否存在”,但没点破关键:Bash 的if语句判断的从来不是“条件真假”,而是“命令的退出状态码”。这个认知偏差,是 80% 脚本逻辑错误的根源。
[ -f "/path/to/file" ]这个命令,本质是执行test程序。如果文件存在且是普通文件,test返回0(Bash 中0表示“成功/真”);否则返回非0(“失败/假”)。所以if [ -f ... ]; then的真实含义是:“如果test命令成功执行(即文件存在),则运行 then 分支”。
这就解释了为什么if [ -f "$file" ] && [ -r "$file" ]; then是安全的,而if [ -f "$file" -a -r "$file" ]; then在旧版 Bash 中可能崩溃——-a是test内置的“与”操作符,但它的行为在不同 Shell 实现中不一致;而&&是 Shell 自己的逻辑运算符,稳定可靠。
更实用的技巧是:把复杂条件拆成独立命令,用$?显式捕获。比如检查一个服务是否运行且端口可访问:
# ❌ 错误示范:嵌套太深,出错难定位 if systemctl is-active --quiet nginx && nc -z localhost 80; then echo "nginx is up" fi # ✅ 正确示范:分步执行,每步可单独调试 systemctl is-active --quiet nginx NGINX_ACTIVE=$? nc -z localhost 80 NGINX_PORT_OPEN=$? if [ $NGINX_ACTIVE -eq 0 ] && [ $NGINX_PORT_OPEN -eq 0 ]; then echo "nginx is up and serving" else echo "nginx check failed: active=$NGINX_ACTIVE, port=$NGINX_PORT_OPEN" fi这样写多三行,但当你发现nginx进程在,但端口不通时,日志里直接告诉你port=1,不用再猜是systemctl命令写错了,还是nc参数不对。
3. 实操核心环节:从“能跑”到“稳跑”的七道工序
3.1 Shebang 不是摆设:选择解释器就是选择兼容性底线
视频里提到“Shebang 告诉系统用什么执行”,但没强调:#!/bin/bash和#!/usr/bin/env bash的选择,直接决定你的脚本能在多少台机器上运行。这不是理论问题,是部署现场的真实雷区。
/bin/bash是硬编码路径。在 CentOS/RHEL 系统上,Bash 确实在/bin/bash;但在某些精简版 Alpine Linux 或 macOS(默认 Zsh)上,/bin/bash可能不存在,或者版本极老(如 Bash 3.2),不支持[[ ]]或数组等现代特性。而#!/usr/bin/env bash会调用env程序,在$PATH中查找第一个bash,通常指向用户安装的较新版本(如通过 Homebrew 安装的 Bash 5.x)。
我的实操经验是:对内部工具脚本,用#!/usr/bin/env bash;对需要在客户生产环境无差别运行的脚本,用#!/bin/sh并严格遵循 POSIX 标准。因为/bin/sh在所有类 Unix 系统上都存在,且行为高度一致。虽然它不支持[[ ]]或$(()),但换来的是 100% 的可移植性。
验证方法极其简单:在脚本开头加一行echo "Running with: $(ps -p $$ -o comm=)",然后分别用bash script.sh和./script.sh执行,观察输出。如果./script.sh输出bash,说明 Shebang 生效;如果输出sh,说明系统把脚本当成了 POSIX shell 解释。
提示:macOS 用户注意,其自带 Bash 3.2 已停止更新。若需使用 Bash 4+/5+ 特性(如关联数组
declare -A),必须用brew install bash,并将 Shebang 改为#!/usr/local/bin/bash(Homebrew 默认路径)或#!/opt/homebrew/bin/bash(Apple Silicon)。
3.2 输入重定向:不是“把输出塞进文件”,而是“接管数据流的主权”
视频里说“>重定向 stdout”,但没揭示本质:Shell 中的0(stdin)、1(stdout)、2(stderr)不是“管道”,而是“文件描述符”(File Descriptor),它们是进程打开的文件句柄编号。理解这点,才能真正掌控数据流向。
command > file.txt的真实含义是:启动command进程时,把它的文件描述符1(stdout)指向file.txt这个文件,而不是默认的终端屏幕。同理,command 2> error.log是把fd 2(stderr)指向error.log。
这解释了为什么command > output.txt 2>&1能把错误也写入文件:2>&1表示“把fd 2重定向到fd 1当前指向的位置”。顺序至关重要!command 2>&1 > output.txt是错的,因为2>&1先执行时,fd 1还指向终端,所以fd 2也被指向终端;之后> output.txt只改变了fd 1,fd 2仍指向终端。
实战中最易错的场景是日志记录。很多人写:
# ❌ 危险!stderr 会直接输出到终端,破坏日志结构 myscript.sh > /var/log/myscript.log正确做法是统一捕获所有输出:
# ✅ 标准做法:stdout 和 stderr 都进入日志 myscript.sh > /var/log/myscript.log 2>&1 # ✅ 进阶做法:stdout 写日志,stderr 同时输出到终端和日志(便于实时监控) myscript.sh 2> >(tee -a /var/log/myscript.log >&2) > /var/log/myscript.log第二行用了进程替换>(...),它创建一个匿名管道,tee程序从该管道读取 stderr,同时写入日志文件和原始 stderr(>&2),确保错误信息既落盘又可见。
3.3 管道(Pipe)的本质:不是“连接两个命令”,而是“创建内存级数据队列”
command1 | command2看似简单,但它是 Shell 自动化最强大的机制。视频里只说“把前一个命令输出传给后一个”,但没点破:管道在内核中创建了一个固定大小(通常 64KB)的内存缓冲区(pipe buffer),command1写满就阻塞,command2读空就阻塞,形成天然的生产者-消费者模型。这意味着管道是流式处理的基石。
一个经典应用是实时日志分析。假设你要监控 Nginx 访问日志,每秒统计一次 404 错误数:
# ✅ 流式处理:tail -f 持续输出新行,grep 实时过滤,awk 实时计数 tail -f /var/log/nginx/access.log | grep " 404 " | awk '{count++} END{print "404s this second:", count}'这里tail -f不会一次性读完所有日志,而是按行输出新增内容,grep和awk逐行处理,内存占用恒定。如果写成cat /var/log/nginx/access.log | ...,则需加载整个日志文件到内存,对大日志(GB 级)直接 OOM。
另一个关键技巧是:管道中的任一命令失败,整个管道默认继续执行(除非启用pipefail)。这常导致静默失败。例如:
# ❌ 如果 grep 找不到匹配,awk 会收到空输入,END 块仍执行,输出 "0" echo -e "200 OK\n500 Error" | grep "404" | awk 'END{print NR}'解决方案是在脚本开头加set -o pipefail,让管道中任意命令失败时,整个管道返回失败状态码,配合if可做精准错误处理。
3.4 退出码(Exit Code):不是“成功/失败”的二元标签,而是“故障分类码”
视频里说“1-255 是错误”,但没说明:退出码是脚本向调用者(可能是另一个脚本、cron、CI/CD 系统)传递结构化诊断信息的唯一途径。把所有错误都返回1,就像医生只告诉病人“你病了”,却不说是感冒还是阑尾炎。
POSIX 标准建议(虽非强制):
0:成功1:通用错误(语法错误、参数无效)2:误用内置命令(如cd到不存在目录)126:命令不可执行(权限不足)127:命令未找到(command not found)128+n:进程被信号n终止(如130= Ctrl+C,137= OOM Kill)
我在生产脚本中强制实施“退出码语义化”:
check_disk_space() { local avail_kb=$(df /backup | awk 'NR==2 {print $4}') if [ "$avail_kb" -lt 1000000 ]; then # 小于 1GB echo "ERROR: /backup has only ${avail_kb}KB free" >&2 return 3 # 自定义:磁盘空间不足 fi if [ "$avail_kb" -lt 5000000 ]; then # 小于 5GB echo "WARNING: /backup low space (${avail_kb}KB)" >&2 return 4 # 自定义:磁盘空间预警 fi return 0 } # 调用方根据返回值做不同响应 if ! check_disk_space; then case $? in 3) echo "Critical! Sending SMS alert..."; send_sms_alert ;; 4) echo "Warning! Sending email..."; send_email_alert ;; *) echo "Unknown error, exiting"; exit 1 ;; esac fi这样,监控系统(如 Nagios)可以通过检查脚本退出码,自动触发不同级别的告警,无需解析日志文本。
3.5 循环与迭代:别只循环“数据”,要循环“意图”
for file in *.log; do ...是最常见写法,但视频没提一个致命陷阱:如果*.log没匹配到任何文件,for循环会把字面量*.log当作一个字符串执行一次!这导致脚本在空目录下误删*.log文件(如果循环体里有rm "$file")。
安全写法是先用nullglob选项:
# 开启 nullglob:通配符无匹配时扩展为空列表 shopt -s nullglob for file in *.log; do echo "Processing $file" # ... 处理逻辑 done # 关闭 nullglob 避免影响其他脚本 shopt -u nullglob但更根本的思路是:循环的目的是“对符合条件的每个实体执行操作”,所以应先获取实体列表,再遍历。使用find更可靠:
# ✅ find 确保只处理真实存在的文件,且可加复杂条件 while IFS= read -r -d '' file; do echo "Processing $file" # ... 处理逻辑 done < <(find /var/log -name "*.log" -mmin -60 -print0)-print0和-d ''配合,能正确处理文件名含空格、换行符等特殊字符的情况,这是for file in $(find ...)永远做不到的。
3.6 变量作用域与生命周期:不是“定义了就能用”,而是“在哪个上下文里有效”
视频提到“变量设置”,但没区分:Shell 中变量默认是全局的,但函数内用local声明才是局部变量。混淆作用域会导致“变量污染”——一个函数意外修改了另一个函数依赖的变量。
看这个反面案例:
process_files() { for file in "$@"; do # 错误:直接修改全局变量 $file,后续调用可能出错 file=$(basename "$file") echo "Base name: $file" done } # 调用后,$file 的值被永久改变! original_file="/path/to/file.txt" process_files "$original_file" echo "Original still exists? $original_file" # 输出空!因为 $file 被覆盖正确做法是用局部变量:
process_files() { local file # 声明局部变量 for file in "$@"; do local base_name=$(basename "$file") # 局部变量,不影响外部 echo "Base name: $base_name" done }更严格的实践是:所有函数内使用的变量,无论是否传参,都显式声明为local。我在团队规范中要求,函数第一行必须是local声明块:
backup_database() { local db_name="$1" local backup_dir="$2" local timestamp local backup_file timestamp=$(date +%Y%m%d_%H%M%S) backup_file="${backup_dir}/${db_name}_${timestamp}.sql.gz" mysqldump "$db_name" | gzip > "$backup_file" }这样,即使函数名backup_database和变量名db_name相同,也不会污染全局命名空间,多人协作时互不干扰。
3.7 调试与日志:不是“加几个 echo”,而是“构建可观测性基础设施”
视频里没讲调试,但这恰恰是脚本从“玩具”走向“生产”的分水岭。set -x(打印执行命令)是基础,但生产环境需要更精细的控制。
我的标准调试框架包含三层:
- 开发期:
set -euo pipefail+set -x,所有命令展开并显示。 - 测试期:用
DEBUG=1 ./script.sh控制调试输出,只在关键路径加[[ $DEBUG ]] && echo "DEBUG: ...". - 生产期:完全关闭调试,但保留结构化日志。
日志的关键是标准化时间戳、级别、来源:
log() { local level="$1" local message="$2" # 格式:[2023-10-05 14:23:15] [INFO] script.sh: Backup started echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $(basename "$0"): $message" \ | tee -a "$LOG_FILE" } # 使用 log "INFO" "Starting backup of $SRC_DIR" log "ERROR" "Failed to connect to database: $DB_ERROR"tee -a确保日志既写入文件,又输出到终端(方便tail -f实时查看)。配合logrotate,可自动轮转日志,避免磁盘占满。
注意:
date命令在高并发脚本中可能成为瓶颈。对毫秒级精度要求高的场景,用printf '%(%Y-%m-%d %H:%M:%S)T' -1(Bash 4.2+)替代date,性能提升 5 倍以上。
4. 常见问题与排查技巧实录:那些让你抓狂半小时的“小问题”
4.1 “明明文件存在,[ -f file ]却返回 false!”——路径和权限的双重陷阱
现象:脚本中if [ -f "/home/user/data.csv" ]; then echo "found"; fi总是跳过,但手动ls -l /home/user/data.csv确实存在。
排查链:
- 检查路径是否被变量扩展破坏:
echo "Path: [$file]",看是否有意外空格或换行符。用printf "%q" "$file"查看原始字节。 - 检查当前工作目录:
pwd输出是否是预期路径?[ -f "data.csv" ]和[ -f "/home/user/data.csv" ]完全不同。 - 检查文件权限:
[ -f ... ]只检查文件类型,不检查可读性。但若父目录无x权限(对目录是“可进入”权限),则无法stat该文件,[ -f ]会失败。ls -ld /home/user看目录权限。 - 检查符号链接:
[ -f ]对软链接返回false,除非链接目标存在且是文件。用[ -L ]检查是否为链接,[ -e ]检查是否存在(不管类型)。
终极解决方案:用realpath获取绝对路径,并用stat验证:
file_path=$(realpath "$input_file" 2>/dev/null) || { log "ERROR" "Invalid path: $input_file"; exit 1; } if [ ! -e "$file_path" ]; then log "ERROR" "File does not exist: $file_path" exit 2 elif [ ! -f "$file_path" ]; then log "ERROR" "Not a regular file: $file_path (type: $(stat -c "%F" "$file_path" 2>/dev/null))" exit 3 fi4.2 “for file in *.txt循环了 100 次,但只有 1 个文件!”——通配符扩展的幽灵
现象:for file in *.txt; do echo "$file"; done在空目录输出*.txt,在有 5 个文件的目录却只循环 1 次。
真相:这是 Shell 通配符扩展(globbing)的默认行为。当*.txt无匹配时,它不扩展,保持字面量*.txt;当有匹配时,它扩展为所有匹配文件名,但如果文件名含空格,for会按空格分割,把my file.txt拆成my和file.txt两次循环。
根治方案:
- 开启
nullglob(如前所述)解决无匹配问题。 - 用
read+find解决空格问题:while IFS= read -r -d '' file; do echo "Processing: $file" done < <(find . -maxdepth 1 -name "*.txt" -print0)IFS=防止read自动裁剪空格,-d ''用空字符分隔(find -print0保证),彻底规避空格陷阱。
4.3 “脚本在 cron 里不运行,手动却正常!”——环境变量的隐形墙
现象:./backup.sh手动执行完美,加入 crontab 后mysqldump报错command not found或Access denied。
原因:cron 启动的 Shell 是最小化环境,PATH只有/usr/bin:/bin,没有/usr/local/bin(Homebrew)或/opt/mysql/bin;且不加载~/.bashrc,MySQL 密码等环境变量缺失。
修复步骤:
- 在脚本开头显式设置 PATH:
#!/usr/bin/env bash export PATH="/usr/local/bin:/opt/mysql/bin:/usr/bin:/bin" - 用绝对路径调用关键命令:
/usr/bin/mysqldump而非mysqldump。 - 在 cron 中指定 SHELL 和 PATH:
# 编辑 crontab: crontab -e SHELL=/bin/bash PATH=/usr/local/bin:/opt/mysql/bin:/usr/bin:/bin 0 2 * * * /home/user/scripts/backup.sh - 数据库密码不要依赖环境变量:用
--defaults-file指向配置文件(chmod 600),避免密码泄露。
4.4 “if [ $var = "value" ]报错unary operator expected!”——未引号变量的雪崩效应
现象:var=""时,if [ $var = "value" ]报错,因为$var展开为空,实际执行if [ = "value" ],[程序收到 2 个参数(= "value"),但期望 3 个([ string = string ])。
铁律:所有变量引用必须加双引号!if [ "$var" = "value" ]。空字符串""是合法参数,[程序能正确处理。
延伸陷阱:[[ ]]比[ ]更安全(支持模式匹配、不需引号防空),但[[ ]]是 Bash 扩展,/bin/sh不支持。所以:
- 用
#!/bin/sh→ 必须if [ "$var" = "value" ] - 用
#!/usr/bin/env bash→ 可用if [[ $var == value ]](==支持通配符)
4.5 “脚本执行一半卡住,ps显示sleep进程!”——交互式命令的静默劫持
现象:脚本中git pull或ssh user@host在 cron 中卡住,ps显示git或ssh进程在sleep。
原因:这些命令检测到 stdin 不是终端(tty),会尝试读取密码或密钥口令,但 stdin 已被重定向,无限等待。
解决方案:
- Git:用
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no"避免首次连接确认;用ssh-agent管理密钥。 - SSH:用
ssh -o BatchMode=yes user@host强制无交互;或用ssh-keygen -t ed25519生成无密码密钥(-N "")。 - 通用原则:所有可能交互的命令,必须显式指定
--non-interactive或等效选项,并在脚本开头用set -o noclobber防止意外覆盖文件。
| 问题类型 | 典型症状 | 根本原因 | 一句话修复 |
|---|---|---|---|
| 路径失效 | [ -f file ]失败,但ls可见 | 相对路径 vs 绝对路径,或父目录无x权限 | 用realpath获取绝对路径,stat验证权限 |
| 空格灾难 | for file in *.txt循环次数异常 | 文件名含空格,for按空格分割 | 用find -print0+read -d '' |
| 环境失联 | cron 中命令not found或认证失败 | cron 环境PATH极简,不加载 shell 配置 | 脚本内export PATH,用绝对路径,--defaults-file |
| 引号缺失 | [: unary operator expected | 未引号变量为空时,[ ]参数数错误 | 所有变量引用加双引号"$var" |
| 交互阻塞 | 脚本卡在git/ssh,ps显示sleep | 命令尝试读取 stdin(密码),但 stdin 已重定向 | 用BatchMode=yes、GIT_SSH_COMMAND、ssh-agent |
5. 实战:一个可直接部署的“智能日志清理脚本”完整解析
现在,我们把前面所有原则,整合成一个真实可用的脚本:smart-log-cleaner.sh。它每天运行,自动清理/var/log下超过 30 天的.log文件,但保留最近 10 个文件,且清理前检查磁盘空间。这不是玩具,是我在客户服务器上跑了 5 年的生产脚本。
#!/usr/bin/env bash # ================================================================== # Smart Log Cleaner v2.1 # A production-ready log rotation script that respects disk space # and preserves recent logs. Tested on Ubuntu 20.04+, CentOS 8+, macOS. # ================================================================== # --- 配置区(可直接修改)--- # 日志目录(必须以 / 结尾) LOG_DIR="/var/log/" # 保留的最老文件天数(删除早于该时间的文件) MAX_AGE_DAYS=30 # 保留的最少文件数量(即使很新也保留这么多) MIN_KEEP_COUNT=10 # 磁盘空间预警阈值(剩余空间 < 5GB 时只保留最新 5 个) LOW_DISK_THRESHOLD_KB=5242880 # 5GB in KB # 日志文件路径(绝对路径!) LOG_FILE="/var/log/smart-log-cleaner.log" # --- 安全开关:启用严格模式 --- set -euo pipefail # 启用 nullglob 防止通配符无匹配时出错 shopt -s nullglob # --- 函数定义 --- log() { local level="$1" local message="$2" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $(basename "$0"): $message" \ | tee -a "$LOG_FILE" } # 检查磁盘空间,返回剩余空间(KB) check_disk_space() { local avail_kb avail_kb=$(df "$LOG_DIR" | awk 'NR==2 {print $4}') if [ -z "$avail_kb" ] || ! [[ "$avail_kb" =~ ^[0-9]+$ ]]; then log "ERROR" "Failed to get disk space for $LOG_DIR" exit 127 fi echo "$avail_kb" } # 获取日志文件列表(按修改时间倒序,最新在前) get_log_files() { # find + sort 确保跨平台兼容性(不依赖 GNU find 的 -printf) find "$LOG_DIR" -maxdepth 1 -name "*.log" -type f -printf '%T@ %p\0' 2>/dev/null | \ sort -znr | \ cut -z -d' ' -f2- } # --- 主逻辑 --- main() { log "INFO" "Starting log cleanup for $LOG_DIR" # 1. 检查磁盘空间 local avail_kb avail_kb=$(check_disk_space) log "INFO" "Available space: ${avail_kb}KB" # 2. 确定本次保留策略 local keep_count=$MIN_KEEP_COUNT if [ "$avail_kb" -lt "$LOW_DISK_THRESHOLD_KB" ]; then log "WARNING" "Low disk space (<5GB), reducing keep count to 5" keep_count=5 fi # 3. 获取所有 .log