Shell脚本工程化:从能跑到稳跑的7大核心原则

1. 这不是“写个脚本”的事,是建立你和系统之间的高效对话通道

Shell 脚本从来就不是程序员的专利,也不是运维工程师的专属工具。它本质上是你和操作系统之间最直接、最省力的一条沟通专线。我带过上百个不同背景的学员——有刚转行的前端开发者,有每天要处理几十份日志的客服主管,有连ls都要查手册的行政助理,还有自己搭 NAS 存家庭照片的退休教师。他们最后都发现:真正卡住效率的,从来不是“不会写”,而是“不知道为什么这样写”、“改一行就全崩”、“别人给的脚本在自己机器上死活不跑”。这篇笔记,就是为解决这些真实痛点而写的。它不讲 Bash 语法大全,不堆砌man bash里的晦涩定义,而是还原一个资深脚本作者在真实项目里怎么思考、怎么选型、怎么调试、怎么把“能跑”变成“稳跑十年”。关键词里有两个词特别关键:TrainingScreencast。这意味着它必须可学、可练、可复现——每一个命令你敲下去,都应该立刻看到结果;每一段逻辑你改完,都应该清楚知道它会改变什么行为。所以整篇内容全部基于 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 中可能崩溃——-atest内置的“与”操作符,但它的行为在不同 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 1fd 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不会一次性读完所有日志,而是按行输出新增内容,grepawk逐行处理,内存占用恒定。如果写成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(打印执行命令)是基础,但生产环境需要更精细的控制。

我的标准调试框架包含三层:

  1. 开发期set -euo pipefail+set -x,所有命令展开并显示。
  2. 测试期:用DEBUG=1 ./script.sh控制调试输出,只在关键路径加[[ $DEBUG ]] && echo "DEBUG: ...".
  3. 生产期:完全关闭调试,但保留结构化日志。

日志的关键是标准化时间戳、级别、来源

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确实存在。

排查链

  1. 检查路径是否被变量扩展破坏echo "Path: [$file]",看是否有意外空格或换行符。用printf "%q" "$file"查看原始字节。
  2. 检查当前工作目录pwd输出是否是预期路径?[ -f "data.csv" ][ -f "/home/user/data.csv" ]完全不同。
  3. 检查文件权限[ -f ... ]只检查文件类型,不检查可读性。但若父目录无x权限(对目录是“可进入”权限),则无法stat该文件,[ -f ]会失败。ls -ld /home/user看目录权限。
  4. 检查符号链接[ -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 fi

4.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拆成myfile.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 foundAccess denied

原因:cron 启动的 Shell 是最小化环境,PATH只有/usr/bin:/bin,没有/usr/local/bin(Homebrew)或/opt/mysql/bin;且不加载~/.bashrc,MySQL 密码等环境变量缺失。

修复步骤

  1. 在脚本开头显式设置 PATH
    #!/usr/bin/env bash export PATH="/usr/local/bin:/opt/mysql/bin:/usr/bin:/bin"
  2. 用绝对路径调用关键命令/usr/bin/mysqldump而非mysqldump
  3. 在 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
  4. 数据库密码不要依赖环境变量:用--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 pullssh user@host在 cron 中卡住,ps显示gitssh进程在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/sshps显示sleep命令尝试读取 stdin(密码),但 stdin 已重定向BatchMode=yesGIT_SSH_COMMANDssh-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