Skip to content

实战案例 ​

前面九章解决的是"语法怎么写",本章解决"合起来能干什么"。三个主案例——日志统计、文件备份、批量重命名——都是运维日常高频任务,每个都给出完整可运行脚本、真实终端输出、逐段设计取舍(为什么用 while read 而不是管道、为什么要 nullglob、退出码怎么传),并标注依赖工具的三发行版安装方式。另有系统监控、访问日志、批量 SSH、rsync 增量同步等精简示例作扩展。先跑通主案例,再按自己环境改造路径与参数。

本章不追求"覆盖所有运维场景",而是把三类最常见形态(只读统计、有状态归档、批量改名)拆到能看清每一处安全细节的程度。你读完应能自己写出第四类脚本,而不是只会改这三份示例的路径。扩展节保持要点式,是为了避免与前面章节重复展开语法;真正要落地时,把要点补成主案例那样的完整脚本即可。

内容参考自实际运维经验、鸟哥的私房菜与各工具 man 手册,见文末参考资料。

学习目标 ​

  • 能独立写出带参数校验、严格模式的备份/统计/重命名脚本
  • 理解每个设计决策背后的"为什么"(子 shell、glob、退出码、安全)
  • 会用三系包管理器安装脚本依赖(含 pacman 对照)
  • 能读懂真实输出并据此排错
  • 把单个脚本升级为可进 cron/CI 的可维护模板(DRY、退出码分层、stderr/stdout 分流)
  • 能把主案例的骨架迁移到新任务,而不是从零发明安全写法

读法建议:先通读每个案例的"任务与为什么",再对照完整脚本逐段问"删掉这行会死在哪个输入上",最后看真实输出是否与你的预期一致。若输出不同,先查 locale、时区与工作目录,再怀疑逻辑。

验收建议:每个主案例至少跑三种输入——正常目录、空目录、不存在的路径——并记录退出码;再故意去掉一处引号或 nullglob,确认你能说出会坏在哪类文件名上。会解释故障比会背脚本更重要。

0. 环境准备:依赖与三系安装 ​

主案例只依赖 Bash 自带能力和 tar/find/awk 等 coreutils,三系开箱可用(GNU coreutils 三系通用,无需担心 BSD 差异)。也就是说:下面三个主案例在 Debian/Ubuntu、Arch 及 RHEL/CentOS/Rocky 上不需要额外装包就能跑;只有扩展示例与开发工作流会碰到 rsync、shellcheck、bashdb。扩展案例相关工具的安装对照如下:

准备环境时建议建一个一次性演示目录(例如 /tmp/script-demo),在里面造日志、源文件与 *.txt,所有真实输出都在这里采集;不要拿生产目录练手。演练与实跑使用同一套脚本、只切换 DRY_RUN/目标路径,是本章从头到尾强调的流程。

写本章时的默认假设:你已经读过 Bash 基础到 正则表达式的正文,本章不再解释 set -e、nullglob、"$@" 的含义,只标注"这里用到了哪条规则、为什么选它"。每节结构固定为:任务与为什么 → 完整脚本 → 真实运行输出 → 设计要点 →(若有)常见坑。

工具Debian/UbuntuArchRHEL/CentOS/Rocky
bash(通常已装)sudo apt install bashsudo pacman -S bashsudo dnf install bash
shellchecksudo apt install shellchecksudo pacman -S shellchecksudo dnf install ShellCheck
rsyncsudo apt install rsyncsudo pacman -S rsyncsudo dnf install rsync
bashdbsudo apt install bashdbsudo pacman -S bashdbsudo dnf install bashdb

注意 RHEL 系 shellcheck 包名是 ShellCheck(驼峰),Debian/Arch 是全小写——拼错会 No match。下文所有输出均来自在临时演示目录中的真实执行(日期与主机相关字段可能与你环境不同)。

1. 案例一:日志统计脚本 ​

日志统计是"读多写少、只依赖 Bash + coreutils"的第一课,也最适合演示退出码与子 shell 的组合坑。选它当案例一,是因为几乎每台机器都有 *.log,你可以立刻把路径换成 /var/log 自己验证;同时它把前面章节的 nullglob、进程替换、case、参数校验串成了一条完整控制流。

1.1 任务与为什么 ​

需求:扫描一个目录下所有 *.log,统计 INFO/WARN/ERROR 出现次数。输出一行机器可读的 INFO=n WARN=n ERROR=n,退出码 0 表示统计成功(允许某级别为 0),非 0 表示目录不存在或没有可统计文件——这样 cron 邮件只会在"真的没扫到"时告警,而不是"今天恰好没有 ERROR"。

为什么不用一行 grep -c 拼起来?因为:(1) 多文件多级别时命令会爆炸;(2) 需要统一的参数、退出码与后续扩展(按小时、按文件输出);(3) 要在 set -euo pipefail 下正确处理"某级别 0 次"——grep -c 无匹配返回 1 的经典坑。grep -c 还会在管道里把"计数"变成多行输出,要再 paste -sd+ 求和,错误分支比本例的 case 更绕。

若日志来自多个应用、需要按文件分列,外层再包一层循环即可;计数器仍须留在当前 shell,不能借管道"省事"。级别名称若与业务相关(如 FATAL),优先扩 case 分支而不是事后 sed 改名——统计与归一化混在一起会让第二次需求变更特别痛。

读取文件用 while ... done < <(cat "$dir"/*.log) 进程替换而不是 cat | while,原因见 循环结构:管道右侧开子 shell,计数器会丢。先 nullglob 收集文件进数组,避免无匹配时把字面量 *.log 当文件名;无文件直接 exit 1,让调用方(cron/CI)能感知。case 分支从上到下第一个命中即停——若日志行可能同时含多个关键字,需要多关键字累加时改用独立 [[ ]] 判断。

1.2 完整脚本 ​

脚本刻意保持"无外部文本工具、单次遍历":所有判断都在 case 里完成,避免 grep -c 无匹配返回 1、又在 set -e 下被当成失败的经典事故。若你的日志极大,可把 cat "${files[@]}" 换成 find -print0 流式喂入,但计数循环仍应留在当前 shell。

bash
#!/bin/bash
# logstats.sh — 统计目录下日志的级别分布
set -euo pipefail

dir=${1:-logs}
[[ -d $dir ]] || { echo "目录不存在: $dir" >&2; exit 1; }
shopt -s nullglob
files=( "$dir"/*.log )
shopt -u nullglob
(( ${#files[@]} > 0 )) || { echo "无 *.log 文件"; exit 1; }

info=0; warn=0; err=0
while IFS= read -r line; do
    case $line in
        *ERROR*) err=$((err + 1)) ;;
        *WARN*)  warn=$((warn + 1)) ;;
        *INFO*)  info=$((info + 1)) ;;
    esac
done < <(cat "${files[@]}")

echo "INFO=$info WARN=$warn ERROR=$err"

1.3 真实运行输出 ​

演示目录下有三个日志文件,每个含 1 条 INFO、1 条 ERROR、1 条 WARN。下面的会话在临时目录真实执行(时间戳与主机相关字段可能随环境变化);第二段展示了"目录不存在"时的 stderr 与退出码 1,方便你在 cron/CI 里直接根据 $? 分支:

bash
$ ls logs/
app1.log  app2.log  app3.log

$ ./logstats.sh
INFO=3 WARN=3 ERROR=3

$ ./logstats.sh missing/
目录不存在: missing/
$ echo $?
1

1.4 设计要点 ​

case 比三条 grep -c 快得多:只 fork 一次 cat,统计全在当前 shell 完成。退出码约定:参数错/无文件非 0,成功 0,便于 cron 与 CI 判定。若只是单纯计数且不需多级别分支,直接 grep -c 更快——本例选择 case 是为了展示"脚本里做多路统计"的完整形态。

为什么把文件先收进数组再 cat "${files[@]}",而不是直接 cat "$dir"/*.log?因为 nullglob 生效后,若没有任何匹配,"$dir"/*.log 在双引号内不会被 glob 展开(引号抑制了扩展),数组为空时 (( ${#files[@]} > 0 )) 能明确失败;若裸写 cat "$dir"/*.log 且无文件,行为依赖 nullglob 与引号的交互,不同写法容易写出"静默 cat 一个字面量路径"的 bug。先把集合确定下来、再对集合操作,是批量脚本里值得养成的顺序。

进程替换 done < <(cat ...) 而不是 done < "$dir"/*.log(重定向一次只接一个文件)也是有意为之:需要把多个日志当成一条流喂给同一个循环,进程替换一次搞定,且循环仍在当前 shell——计数器不会像管道那样丢。若你改成 for f in "${files[@]}"; do while read ... done < "$f"; done 的双层循环,语义更清晰,但要注意每层 read 的变量作用域,以及跳过空文件时的计数是否还要包含"文件数"维度。

case 的分支顺序隐含了优先级:若某行同时含 ERROR 和 WARN,只计入 ERROR(先匹配者赢)。这是刻意选择——严重级别取最高。若你的日志格式保证每行只有一个级别标记,顺序无所谓;若一行可能复合,应把 case 换成按严重度降序的多个 [[ ]] 且允许累加,或先规范化日志再统计。

扩展方向:按文件分列输出(外层 for f in "${files[@]}" 再内层统计)、按小时桶聚合(cut/awk 抽时间戳)、把结果 tee 到 CSV。每加一层聚合,就回头检查一遍"是否还有循环跑在管道右侧"——计数器丢失的 bug 在单测里往往测不出来,只有接上真实目录才暴露。

2. 案例二:文件备份脚本 ​

备份脚本把"归档、命名、清理、退出码"四件事压进不到三十行,是检验你是否真的读懂 set -e 与管道子 shell 的试金石。和统计脚本不同,这里出现破坏性操作(轮转删除),所以设计要点里会花更多篇幅讲安全边界与演练方式。

2.1 任务与为什么 ​

需求:把源目录打包成带时间戳的 tar.gz,保留最近 N 份,删除过期备份。默认 KEEP=7(最近七份),可用环境变量覆盖——为什么用环境变量而不是再加一个位置参数?因为 cron 里改环境比改命令行更直观,且与"第二个参数是备份目录"的用法互不打架;若你更习惯 --keep 风格,用 getopts 解析即可,见 Bash 基础。

为什么用 tar 而不是逐文件 cp?归档保权限、保硬链接、一个文件传一台;为什么时间戳精确到秒?同一天多次备份不覆盖;为什么清理用按修改时间排序截断而不是 ls 字典序?字典序在月/日进位时会乱,且 ls 遇特殊文件名与本地化排序都不可靠。归档后 wc -c 打出字节数,是为了在"磁盘写满但 tar 静默成功"这类罕见故障下仍能从日志里看出产物异常(0 字节或远小于预期)。

与"只备份、不清理"的最小脚本相比,本例多出来的轮转逻辑才是生产分水岭:不轮转的备份目录会在几个月后悄悄写满磁盘,而备份任务本身仍报成功。轮转失败也必须可见——本例把"已清理"打到 stdout,便于审计;若你接集中日志,可再 tee 一份到固定文件。

安全底线:源目录与备份目录必须非空校验,且绝不 rm -rf 未加引号的变量路径——见 条件判断与 循环结构。tar -C "$src" . 让归档内路径不带冗长前缀;date +%Y%m%d_%H%M%S 可排序;ls -1t 按修改时间取最新 N 份之后的丢弃。清理循环在管道右侧会开子 shell,但这里只 rm/echo、不返回变量,所以可接受;若要统计删除数量,改为重定向读入数组。rm -f -- "$old" 的 -- 防止文件名以 - 开头被当成选项。

轮转次数用环境变量 KEEP 而不是写死常量,是为了让 cron 用 KEEP=14 ./backup.sh ... 覆盖而无需改文件;默认 7 对应"保留一周"的常见预期。清理只针对"本脚本产出的前缀",不会把目录里手工放的其它 tar.gz 误删——前缀由 basename "$src" 保证多源共存时互不干扰。

生产增强:KEEP 环境变量可调份数;接 rsync 推到远端见第 4.4 节;接 logrotate 见 日志轮转。

2.2 完整脚本 ​

bash
#!/bin/bash
# backup.sh — 目录归档 + 按份数清理
set -euo pipefail

src=${1:?用法: $0 <源目录> [备份目录]}
dst=${2:-./backups}
keep=${KEEP:-7}
name=$(basename "$src")
stamp=$(date +%Y%m%d_%H%M%S)

[[ -d $src ]] || { echo "源目录不存在: $src" >&2; exit 1; }
mkdir -p "$dst"

archive="$dst/${name}_${stamp}.tar.gz"
tar -czf "$archive" -C "$src" .
echo "已创建: $archive ($(wc -c < "$archive") 字节)"

ls -1t "$dst/${name}_"*.tar.gz 2>/dev/null | tail -n +$((keep + 1)) |
while IFS= read -r old; do
    rm -f -- "$old"
    echo "已清理: $old"
done

2.3 真实运行输出 ​

先准备两份小文件再跑一次;wc -c 打出的字节数与随后 ls -lh、tar -tzf 清单相互印证——备份类脚本的验收标准是"能列出、能解回",不是"命令退出码是 0":

bash
$ echo 'hello backup' > src/a.txt
$ echo 'world' > src/b.txt

$ ./backup.sh src ./backups
已创建: ./backups/src_20260922_050700.tar.gz (179 字节)

$ ls -lh backups/
-rw-rw-r-- 1 cui cui 107 Sep 22 05:06 backup_20260922_050643.tar.gz
-rw-rw-r-- 1 cui cui 179 Sep 22 05:06 src_20260922_050700.tar.gz

$ tar -tzf backups/src_20260922_050700.tar.gz
./
./b.txt
./a.txt

2.4 设计要点 ​

${1:?...} 在参数缺失时直接报错退出,比手写 if (( $# < 1 )) 更紧凑;basename 取目录名做归档前缀,便于多源共存一个备份目录;wc -c 打出字节数让"是否真的写进去了"一眼可见。清单输出用 tar -t 而不是假设文件在——备份脚本的验收标准永远是"能列出来、能解回去"。

清理策略为什么用 ls -1t | tail -n +$((keep + 1)) 而不是按文件名排序?因为文件名里的时间戳虽然可排序,但一旦混入手工拷贝的旧文件、或 locale 影响了 ls 的排序规则,字典序就会和"新旧"脱钩;-t 按 mtime 排序才是"保留最近 N 份"的本意。tail -n +K 表示从第 K 行开始输出(含 K),配合 keep 语义正好是"丢掉前 keep 个、处理剩下的"。管道进 while 会开子 shell,本例只 rm 不返回变量,所以可接受;若你要统计删除数量,把循环改成重定向读入数组,在当前 shell 里累加。

另一个容易忽略的点是 tar -czf 失败时的行为:在 set -e 下,tar 非零会直接终止脚本,后面的清理逻辑不会执行——这通常是好事(半成品归档不该被当成合法备份进入轮转)。若希望"备份失败也要跑清理",需要显式 tar ... || { echo fail; } 消费退出码,并想清楚失败时是否允许删掉旧的完好备份(默认绝不要)。gzip 层也可换成 zstd/xz 换压缩比,三系 coreutils/包管理器都能装到对应工具,脚本结构不用大改。

KEEP 为 0 或负数时的语义值得显式定义:本例 tail -n +1 会把全部归档交给清理循环,等于"只保留刚写的这一份之前的状态"——通常不是用户想要的。生产版应在开头加 (( keep >= 1 )) || { echo "KEEP 至少为 1" >&2; exit 2; },把非法配置在动磁盘之前拦下。同理,src 与 dst 落在同一子树时要拒绝,避免"备份打进正在被备份的目录"导致归档自我膨胀。

并发与锁:两台机器或两个 cron 同时备份同一 src 到同一 dst 时,时间戳可能撞车(秒级精度),tar -czf 同名会互相覆盖。简单对策是把主机名/随机段拼进文件名;严肃对策是 flock 包住整个脚本。示例为可读性省略了锁——接入多调度器前请补上,否则清理逻辑也可能在写到一半时读到半截归档。

3. 案例三:批量重命名脚本 ​

重命名案例的篇幅不在"功能"而在边界:无匹配、空格、已存在、破折号开头、演练模式。它最适合作为 code review 的教学样本——功能正确但缺任一防护的版本,在演示目录往往也能"看起来跑通",换到真实数据才炸。与前两个案例相比,这里的破坏性更直接(改的是用户文件本身),因此 DRY 与目标存在检查的优先级高于"尽量多改几个"。

需求本身在真实环境里还会变形:按日期插入前缀、统一大小写、跳过符号链接、只处理最近 24 小时修改的文件。无论怎么变,安全骨架不变——先 glob/find 确定集合,再对每个目标做存在性与引号安全检查,最后才是 mv;过滤条件加在"确定集合"阶段,不要在 mv 前一刻才补 if,否则 DRY 输出会与真实计划不一致。

3.1 任务与为什么 ​

需求:把目录下所有 *.txt 改名为 *.md。这是"小脚本里塞满安全细节"的典型教学案例:功能只有一行 mv,但生产可用版必须处理无匹配、空格、已存在、破折号开头与演练模式五件事。下面四个是硬性点:

  1. 无匹配文件:*.txt 不存在时会原样进入循环,mv 报错 → nullglob。
  2. 含空格文件名:my notes.txt 不加引号会被拆成两个参数 → 全程 "$file"。
  3. 目标已存在:静默覆盖是事故 → 先 [[ -e $new ]] 跳过。
  4. 文件名以 - 开头:被 mv 当成选项 → mv --。

再加一层 DRY_RUN 开关:破坏性批量操作先打印计划、确认无误再实跑,是运维金标准。计数用 count=$((count + 1)) 而非 ((count++)),避免 set -e 下 count 为 0 时后缀自增返回 1 导致退出(见 循环结构)。${file%.txt} 是后缀删除扩展,见 变量与数据类型。整脚本过一遍 shellcheck 应无 error;三系安装见第 0 节表。

若源目录是版本库或同步盘,批量改名还会牵动 watch/索引:建议先在副本目录演练,或关闭同步客户端后再实跑。跨文件系统的 mv 退化为复制+删除,失败时可能留下半截目标——重要数据仍应以备份(案例二)为前置,而不是假设 mv 原子。

3.2 完整脚本 ​

bash
#!/bin/bash
# rename.sh — *.txt → *.md,安全批量重命名
set -euo pipefail
shopt -s nullglob

dir=${1:-.}
dry=${DRY_RUN:-0}
count=0; skipped=0

for file in "$dir"/*.txt; do
    base=${file%.txt}
    new_name="${base}.md"
    if [[ -e $new_name ]]; then
        echo "跳过(已存在): $new_name" >&2
        skipped=$((skipped + 1))
        continue
    fi
    if [[ $dry == 1 ]]; then
        echo "DRY: mv -- '$file' '$new_name'"
    else
        mv -n -- "$file" "$new_name"
        echo "重命名: $file → $new_name"
    fi
    count=$((count + 1))
done

echo "完成: 重命名 $count 个, 跳过 $skipped 个"

3.3 真实运行输出 ​

演示目录先造几个文件(含带空格的)。DRY_RUN=1 只打印计划;去掉后再跑一次,第二次会因为目标已存在而把剩余文件计入 skipped——这正是幂等重跑应有的样子:

bash
$ ls *.txt
my notes.txt  report.txt  sample.txt  words.txt

$ DRY_RUN=1 ./rename.sh
DRY: mv -- './my notes.txt' './my notes.md'
DRY: mv -- './report.txt' './report.md'
DRY: mv -- './sample.txt' './sample.md'
DRY: mv -- './words.txt' './words.md'
完成: 重命名 4 个, 跳过 0 个

$ ./rename.sh
重命名: ./my notes.txt → ./my notes.md
重命名: ./report.txt → ./report.md
完成: 重命名 2 个, 跳过 2 个

$ ls *.md 2>/dev/null
my notes.md  report.md  sample.md  words.md

3.4 设计要点 ​

含空格文件名 my notes.txt 全程未被拆散,证明 glob 展开阶段 + 循环体引号都正确。第二次执行时已存在的目标被跳过且计数进 skipped——幂等重跑不会覆盖。若把重命名改成 sed 处理文件名,反而要处理转义,不如纯 Bash 参数扩展清晰。

mv -n 与脚本开头的 [[ -e $new_name ]] 是双保险:前者是 GNU mv 的原子倾向(不覆盖),后者在覆盖之前就 continue 并计入 skipped,让统计更准。只靠 -n 时,覆盖被静默跳过,用户可能以为"都改完了"其实有的没改。DRY 模式下不要真的 mv,但计数仍加在 DRY 分支里——这样演练输出的"将处理 N 个"与实跑一致,方便对账。

若目录里同时有 *.txt 与子目录里的 *.txt,本脚本只扫一层("$dir"/*.txt 不递归)。需要递归时改成 find "$dir" -type f -name '*.txt' -print0 配 while IFS= read -r -d '',-print0/-d '' 用 NUL 分隔,才能安全处理含换行的极端文件名——这是 find+rename 的标准姿势,比 for $(find) 稳健得多。

${file%.txt} 只剥最短后缀匹配;若希望"任意一串 .txt 都剥"且不误伤 notes.txt.bak 这类复合名,保持 % 即可,不要换成贪婪写法。前缀冲突也值得防:若目标 .md 文件是通过硬链接或同步盘从别处来的,[[ -e ]] 会看到"已存在"并跳过——这通常是正确的保守行为。批量操作的验收标准是:先在含空格、含中文、含 - 开头文件的目录里 DRY 一遍,计数与 ls 手工核对一致,再实跑。

排序与顺序:glob 按当前 locale 的字典序展开,与 ls 默认一致;重命名本身不依赖顺序,但若改成"生成临时名再改回"的两阶段算法,就必须显式排序或用 NUL 列表,避免两阶段顺序不一致导致自覆盖。单层直接改名没有这个问题,这也是本例坚持"一步到位 + 目标存在即跳过"的原因。

重命名后的验收不只看 ls:若文件被 git/备份系统跟踪,改名前后应各跑一次状态确认(如 git status 或 rsync -n),避免"脚本成功、索引未更新"的静默不一致。对批量操作,计划(DRY)→ 执行 → 清单核对三步写进 runbook,比依赖操作者记忆更可靠。

4. 扩展示例 ​

主案例已覆盖"读、写、改名"三类本地操作;扩展示例再补监控快照、访问日志、批量远程与增量同步,凑齐运维脚本最常见的四个方向。扩展示例偏"要点清单"而非完整脚本——骨架与安全写法以主案例为准,这里只强调各自的领域差异。

读扩展示例时的正确姿势:不要直接复制半截命令行上生产;先问三件事——输入是否只读、失败是否可感知、依赖在三系上是否同名。三点都确认后,再按主案例的模板补上 usage、严格模式与退出码,扩展示例才真正可用。下面各小节均按"需求 → 数据源/命令选择 → 边界与验收"展开,与主案例的叙述层次保持一致。

4.1 系统资源监控(单次快照) ​

需求:一条命令打出 CPU 空闲率、内存可用量、根分区使用率与负载,输出保持"键: 值"便于 awk/jq 二次加工。为什么不用 top/htop 一把梭?因为脚本要的是可解析、可入库、可告警的稳定字段,top 的交互界面与本地化输出(小数点、千分位、语言)都不适合被再加工。数据源直接读 /proc/stat、/proc/meminfo 与 df,三发行版接口一致(procfs 与 coreutils 三系通用);容器里若裁剪了 procfs,需换成 cgroup 接口或宿主机采集。

脚本要点:CPU 空闲率要用 idle+iowait 除以 cpu 行各字段之和(自开机累计,适合做粗粒度快照);内存优先用 MemAvailable 而非 MemFree(后者不含可回收的 cache,会严重低估可用内存);负载直接取 /proc/loadavg 前三个字段。

bash
#!/bin/bash
set -euo pipefail
read -r _ cuser cnice csys cidle ciowait cirq csoft csteal _ < /proc/stat
cpu_total=$((cuser+cnice+csys+cidle+ciowait+cirq+csoft+csteal))
cpu_idle_pct=$(( 100 * (cidle+ciowait) / cpu_total ))
mem_total=$(awk '/^MemTotal/{print $2}' /proc/meminfo)
mem_avail=$(awk '/^MemAvailable/{print $2}' /proc/meminfo)
disk_root=$(df -h / | awk 'NR==2{print $5}')
printf 'CPU 空闲: %s%%\n' "$cpu_idle_pct"
printf '内存可用: %s / %s KiB\n' "$mem_avail" "$mem_total"
printf '根分区使用: %s\n' "$disk_root"
printf '负载: %s\n' "$(cut -d' ' -f1-3 /proc/loadavg)"

真实输出(本机演示,数值随负载变化):

bash
$ ./sysinfo.sh
CPU 空闲: 63%
内存可用: 30272288 / 39948940 KiB
根分区使用: 58%
负载: 10.71 25.26 28.77

持续监控不要在脚本里 while true; sleep 长驻(进程难管理、日志难轮转),交给 cron 或 systemd timer 每分钟调一次更符合运维习惯;需要秒级曲线时用 sar、mpstat 或 Prometheus 采集器,而不是自己造轮子。若必须写常驻循环,至少 trap 处理 TERM、限制日志轮转,并避免无退避的忙轮询。

/proc 与 df 的输出在容器/宿主机视角可能不一致:容器里 df / 常是 overlay 后的假象,CPU 也可能是宿主机配额的相对值。把脚本跑在"你真正想监控的那一层"(宿主机 agent vs 容器探针),或显式读 cgroup 接口,比把宿主机数字误标成容器指标更重要。

4.2 Nginx/Apache 访问日志 TOP IP ​

设计:参数必填 + [[ -r ]] 可读性检查 → wc -l 总请求 → 抽第 1 字段 sort | uniq -c | sort -rn | head 出 TOP N。全部只读,可直接挂 cron。配合 文本处理的状态码分布脚本,可快速出运维日报。

为什么 TOP IP 适合 sort | uniq -c 而不适合关联数组计数?因为日志行数常在百万级,关联数组虽能避免外部排序,但内存与 Bash 哈希开销会先爆;sort 走外部排序能处理超过内存的输入,且是三系 coreutils/sort 的标准能力。若只关心"最近一小时",先用 awk '$4 >= ...' 按时间戳过滤再统计,避免全量文件反复扫。

字段抽取要处理引号与空格:$1 在默认分隔符下对"无空格客户端 IP"够用;若上游是带空格的 forwarded 字段,应改 awk '{print $1}' 前先按 " 分层,或用 logformat 固定字段数。管道链每多一环就多一次 fork,TOP 10 场景下瓶颈通常在磁盘读而不是 uniq;真要优化可先 tail 窗口文件再统计。

4.3 批量远程执行 ​

要点:hosts.txt 一行一台,跳过空行与 # 注释;ssh -o BatchMode=yes -o ConnectTimeout=5——BatchMode 避免卡在密码提示,ConnectTimeout 防止一台挂起拖死整个循环;每台机器的失败用 if 消费退出码、不中断整体,汇总后统一处理。不要写成 while read host; do ssh ... || exit 1; done,那样第一台失败就放弃剩余主机。

失败汇总建议用"失败列表写入数组 + 结束时非零退出"而不是打印完就 exit 0:调用方(CI/Ansible wrapper)需要知道"有多少台没成"。并发可以用 xargs -P 或后台 & + wait,但要控制上限,避免同时打开数百条 SSH 触发限流;输出交错时给每行加 [host] 前缀,或先写临时文件再按主机合并。密钥与 known_hosts 策略(StrictHostKeyChecking=accept-new vs 预置指纹)必须在团队文档里写死,否则新机器第一次连会挂起——这与脚本本身无关,却是批量执行最常见的"假死"原因。

4.4 远程/增量备份(rsync) ​

rsync -avz --delete --exclude '.git' "$src/" "$dest":源路径末尾 / 表示"复制目录内容"而非目录本身;--delete 会删除目标里源已不存在的文件——第一次执行前务必确认目标路径,可先去掉 --delete 演练一遍。三系安装:sudo apt install rsync / sudo pacman -S rsync / sudo dnf install rsync,装完再跑。

与第 2 节本地 tar 备份的关系是互补而非二选一:tar 适合"某一时刻的可携带快照"(恢复不依赖 rsync),rsync 适合"长期保持镜像 + 传大目录省流量"。常见组合是本地 tar 保留 N 份、夜间 rsync 推到 NAS;两边的清理策略要分开算,不要用同一把 find -delete 扫两个根。

-a 已含 -rlptgoD,一般不再单加 -r;-z 对已压缩日志收益有限且耗 CPU,局域网可关。--exclude 模式是相对源路径的 glob,漏写尾部 / 的语义差别只对源路径本身生效,对 --exclude 目录名通常不影响——真正要小心的是 --delete-after 与 --delay-updates 在断电时的目标一致性,关键路径可加 --partial --append-verify 保证续传语义(以你安装的 rsync 版本 man 为准)。

4.5 退出码、信号与收尾 ​

三个主案例都把"退出码"当作 API 的一部分:0 成功,1 业务/环境失败,2 用法错误。接入 cron 时,非零退出默认会触发 MAILTO;若你只想记日志不发信,在 cron 行里显式 || true 或 >/dev/null 2>&1,但不要在脚本内部把所有错误都 || true 掉——那等于删掉调用方唯一的感知通道。

trap 的最小集是 INT/TERM(停止时别留下半截临时文件)与 EXIT(统一清理)。HUP 在远程会话断开时有意义,交互式工具可顺手注册。注意 trap 在 set -e 触发的 EXIT 里仍会执行,但此时 $? 已是失败码,清理逻辑不要覆盖它后再 exit 0,除非你有意吞掉错误——吞错误是比 DRY 缺失更隐蔽的事故源。

退出码分层在组合多个脚本时尤其有用:上层编排脚本看到 127 就知道该先装依赖而不是重试,看到 2 就该打印用法而不是报警告。若你的监控只区分"非零即坏",至少在脚本头注释里写明每个非零码的含义,方便后来人写 case $? in。信号处理不要做重活——INT 里只删临时文件、恢复终端;耗时清理放到 EXIT 或交下次启动的自愈逻辑,否则用户 Ctrl+C 会感觉脚本"卡住"。

5. 把案例组装成可维护脚本 ​

三个主案例各自能跑,但要进仓库、进流水线,还需要统一骨架。本节不引入新语法,只回答一个问题:怎样让下一个接手的人(或三个月后的你)在十分钟内改对一个边界。答案是把"参数校验、严格模式、退出码、清理、DRY"固定成模板,而不是每次凭手感重写。

推荐直接抄作模板的顺序是:shebang 与一句话描述、set -euo pipefail、usage() 与参数解析、main "$@"、文件末尾只留 main "$@" 一行业务入口。破坏性路径补 trap:至少 INT 要能恢复终端或清理半成品,EXIT 可用于"删除临时目录"这类必须执行的收尾。函数与业务逻辑分离——先定义,再在 main 里编排,测试时可以直接 source 后调用单个函数。

再加上 脚本调试的 shellcheck 门禁(CI 里 shellcheck -x *.sh 失败即挡)与 trap EXIT 清理,就达到"能交给同事、能进 cron"的基线。命名、缩进、是否允许 eval 这类风格问题交给 shellcheck 与团队 .editorconfig,不要在 code review 里靠人肉盯。模板里每个选择都能对上前面章节:set -euo pipefail 的副作用边界见调试章;"$@" 与 local 见变量与函数章;循环不进管道、glob 配 nullglob 见循环章;参数与文件测试见条件判断章。

把三个案例读懂后再回头改自己的环境,比从零抄网上的"一键脚本"安全得多——你知道每一行在防什么事故。维护角度还有三条实践值得写进团队规范:一是破坏性操作永远先 DRY;二是日志与数据分流(提示走 stderr,结果走 stdout),方便被 cron 邮件、CI 日志、上层脚本三方同时消费;三是退出码分层(0 成功、1 业务失败、2 用法错误、127 缺依赖),调用方才能写可靠的 if/&& 链。这三条在单机小脚本里看似过度,一旦进流水线就会变成硬需求。

模板落地时建议固定目录约定:bin/ 放入口脚本、lib/ 放 source 的函数库、tests/ 放对 stdout/退出码的断言(bash tests/run.sh)。哪怕只有两个脚本,也值得先建 shellcheck 的 CI 步骤——它抓的是引号、子 shell、拼写级问题,比人工 review 更稳定。版本升级到需要兼容旧环境时,用 #!/bin/bash 明确最低版本,并在 README 写清"需要 Bash ≥ 4.0"(关联数组、mapfile 等分界),避免用户在老系统的默认 sh 上运行而得到天差地别的报错。

可维护性还体现在"错误消息可执行":报错里带上出错的路径、参数、下一步动作("源目录不存在: …(检查挂载)"),比光秃秃的 error 能少开一半的工单。示例里所有 echo ... >&2 都遵守这个原则——把排障线索留在 stderr,数据留在 stdout,两边互不污染。交接文档最少包含:如何 DRY 演练、如何看退出码、依赖如何三系安装、回滚上一版备份的步骤——这四段写在 bin/README 就比口头传递可靠。

5.1 一个可直接复用的骨架清单 ​

把主案例的公共部分抽成 checklist(顺序即实现顺序):1)shebang + set -euo pipefail + 简短 usage;2)参数解包与 [[ -d ]]/[[ -r ]] 校验,失败 exit 2;3)command -v 检查外部依赖,缺则 exit 127 并打印三系安装名(含 pacman);4)破坏性分支先 DRY_RUN;5)核心循环确认不在管道右侧丢状态;6)成功路径 exit 0,业务失败 exit 1;7)trap EXIT 清理临时文件。七条打勾的脚本,基本可以放心进 cron。

5.2 何时该拆库、何时保持单文件 ​

单文件便于拷贝与审计(一把 scp 就能部署),适合 <300 行、只被一个入口用的脚本。当第二个脚本开始复制粘贴同一段 log/require_cmd,就该抽 lib/*.sh 并用 ${BASH_SOURCE[0]} 定位;当同一函数在三个仓库里出现三份补丁,就该进共享库并打 tag。反过来,过早抽库会让每个改动都变成跨文件 review,对一次性运维脚本反而是负担——第二次重复再抽象,而不是第一次。

6. 本章常见坑(实战向) ​

实战章的坑几乎都不是"语法写错",而是"语义写错但看起来能跑":脚本在演示目录全绿,换到生产目录一次就丢数据。下列八条按危害排序,建议贴在脚本模板的注释头里逐条打勾。每条都对应前文某个设计决策——先读"为什么"再记"别做什么",比背清单更不容易忘。

6.1 数据与破坏性操作 ​

  1. 破坏性操作没有演练模式。重命名、删除、rsync --delete 一律先 DRY_RUN=1 打印计划。演练输出要包含将要发生的完整命令行,而不是只打文件名——这样你才能在日志里直接看出有没有路径拼接错误。

  2. 路径与文件名不加引号。"$src"、"$file"、"$archive" 出现在任何 mv/rm/tar 里都不能省。glob 展开阶段由 Bash 处理是安全的;危险的是把展开结果再塞进未加引号的变量、或再交给 eval/sh -c 走一遍。

  3. 清理误伤。find $dst -name '*.tar.gz' -delete 会删掉目录里所有归档;应限定前缀 ${name}_ 与保留份数。清理前缀一旦与归档命名脱钩(比如改了 basename 规则却没改清理 glob),就会出现"新备份永远堆积、旧备份永远不删"或反过来的静默事故。

  4. 时间戳只到天。同一天多次备份覆盖归档;至少 %Y%m%d_%H%M%S。若与其它系统对拍,注意时区:date -u 的 UTC 戳更适合跨机对齐,本地时区更适合人工翻日志——选定一种写进注释,别两种混用。

6.2 环境、依赖与调度 ​

  1. 依赖工具未检查。跨发行版分发脚本时开头 command -v rsync || { echo "请安装: apt/pacman/dnf install rsync"; exit 127; },三系提示一次给全。包名差异(如 RHEL 的 ShellCheck 驼峰)也放进提示字符串,能省掉一轮"装了但 command not found"的排查。

  2. cron 里不写绝对路径。cron 的 PATH 极简,date/tar 之外你自己安装的工具用绝对路径,或脚本内 PATH=/usr/local/bin:/usr/bin:/bin。同时注意 cron 的工作目录通常是 $HOME,相对路径脚本要改成基于 ${BASH_SOURCE[0]} 或 crontab 里显式 cd。

  3. ssh 批量无超时。一台主机黑洞会导致整晚卡住;ConnectTimeout + BatchMode 必须加。若走跳板或 ProxyJump,超时要分别设在"直连跳板"与"跳板到目标"两层,否则仍可能在中间层挂死。

  4. 统计循环用管道。计数器写在 cmd | while 右侧会静默归零,案例一用进程替换的原因就在此。这条在 set -e 下更隐蔽:循环"成功结束"但结果永远是初值,测试目录若碰巧只有一个文件还容易被误判为通过。

6.3 验收与交接 ​

脚本写完不等于可交接。最低验收动作是:在非演示目录跑一遍 DRY;用 echo $? 确认成功/失败/用法错误三种退出码;shellcheck 无 error;把用法与依赖写进文件头注释。做完这四步,再交给 cron——否则第一次触发往往发生在你不在场的凌晨。

复盘时若发现"输出对了但多打了日志到 stdout",优先查是否把进度提示写进了 $( ) 会捕获的路径(见 函数的 stderr 约定);若是"计数总是少一截",优先查是否又把循环写进了管道右侧。这两类问题在三个主案例里都已用正确写法示范过,对照改即可,不必从头设计。

7. 本章小结 ​

三个主案例合起来覆盖了运维脚本的完整生命周期:怎么读输入、怎么安全改状态、怎么把结果与错误分开交给调用方。日志统计示范只读统计与子 shell 边界,备份示范归档、轮转与破坏性清理,重命名示范 glob、引号与演练模式。扩展示例再补上监控、访问日志、批量 SSH 与 rsync 四个方向的要点,骨架均可复用。

离开本章时,请至少带走这三条可执行习惯:破坏性命令先 DRY 并核对计划文本;一切循环确认不在管道右侧丢计数;退出码与 stderr 当成对外 API 来设计。语法细节忘了可以回头翻对应章节,但这三条若丢掉,脚本会在最不该出错的夜里给你惊喜。

三条习惯落到工具上分别是:DRY_RUN 分支打印完整计划;计数循环用进程替换或数组重定向;成功/用法错/业务失败/缺依赖对应 0/2/1/127。把它们写进团队脚本模板的注释头,比长篇规范更容易在 code review 时被真正执行;模板评审通过一次,后续每个新脚本都自动继承这套安全底线。

自测:能否不看正文,从零写出带 usage、set -euo pipefail、DRY_RUN 与退出码分层的备份脚本,并解释每一处引号与 -- 的作用?能的话,本章目标已达成;不能的话,优先重读案例二的设计要点,再对照第 5 节的骨架清单逐项补齐。

下一步可把统计脚本接上 cron、把备份脚本接上 rsync/远端与 日志轮转,用 脚本调试的 shellcheck 与 set -x 做上线前门禁——从"会写"到"敢交给别人跑",中间缺的正是这层工程化。

参考资料 ​

动手路径建议:先在临时目录按第 1–3 节原样跑通三个主案例,对照真实输出确认一致;再把路径与 KEEP/DRY_RUN 换成你的环境做一次 DRY;最后按第 5 节骨架补齐 usage、trap 与 shellcheck,并接到 cron 或 CI。每一步都保留退出码与 stderr 日志,出问题时按"用法错 → 环境缺依赖 → 业务失败"的顺序排查,通常比直接加 echo 调试更快。

第一次接手他人脚本时,也按同一顺序读:先看用法与退出码约定,再看依赖检查与 DRY 分支,最后才进入核心循环。顺序反过来容易陷入"逐行改语法却不理解失败语义"的陷阱,改完仍不敢上线。把本章的读法固化为团队习惯后,脚本 review 的争论点会从"风格"转向"边界是否覆盖",效率更高。

若你维护多机环境,把案例四的 SSH 要点与主案例的退出码分层结合:每台结果写独立日志文件,汇总脚本只读退出码决定是否告警。需要更系统的文本流水线(状态码分布、UA 聚合)再回看 文本处理;需要在失败时精确定位行号与命令,接 脚本调试的 set -x/bashdb 流程。三系依赖安装对照保留在第 0 节表中,分发前按目标发行版核对一次包名(注意 pacman、apt、dnf 的差异与 RHEL 系 ShellCheck 驼峰)即可;不确定的选项以对应发行版 man 页为准,不要凭记忆改生产参数。

改完脚本若准备提交,最后再跑一遍本篇三个主案例的"不存在路径 / 空目录 / 正常目录"三种用例,并确认 echo $? 符合 0/1/2 约定——这一步比再加一段功能更值得花时间。

相关页面 ​

基于 Apache License 2.0 许可发布