写 Shell 循环脚本这件事,十个新手九个觉得简单。for i in ...; do ...; done,一行命令,把重复劳动变成自动化,成就感直接拉满。但如果你真的把这套思路原封不动搬进生产环境,大概率会被线上告警教育得怀疑人生。
我见过太多类似的场景:课堂作业里用 for 循环批量 ping 了几个 IP,考试通过了;但到生产环境批量重启服务时,脚本跑到一半进程挂掉,没有日志、没有断点,重启之后不知道处理到哪一步,只能对着业务报障发呆。课堂实验关心的是“能不能跑通”,生产实践要求的是“能不能跑稳、能不能跑完、出了问题能不能快速恢复”。这个差距,就是所有 Shell 脚本“生产改造”的核心矛盾。
这篇文章想做的,就是把这个“差距”拆开揉碎,用一条完整的改造链路,讲清楚怎么把一个教科书式的 for 循环脚本,改造成可以拿上生产、敢跑通宵、敢面对突发故障的版本。文章适合刚接触 Shell 的运维新人,也适合写过一堆脚本但还没“真正上过线”的开发者。我尽量把每一步的“为什么”也讲清楚,而不是只给一个“抄作业”的模板。
1. 课堂脚本和生产脚本的差别在哪
1.1 先从一道课堂练习说起
课堂里,我们常写的循环脚本大概是这个样子的。假设场景:批量检查一组服务器是否在线。
#!/bin/bash for ip in $(cat ip_list.txt); do ping -c 1 -W 1 "$ip" > /dev/null if [ $? -eq 0 ]; then echo "$ip 在线" else echo "$ip 不通" fi done这个脚本教学上是标准答案:循环变量、条件判断、退出码检查全都有。但如果这是线上要用的巡检脚本,你会发现一堆问题:
ip_list.txt如果有多余空格或空行,$(cat ...)会把内容拆错;- 文件不存在时,就相当于循环了一个空列表,脚本没有任何报错,看似成功实则失败;
- 中途断电或被人 Ctrl+C,脚本从头开始跑,已处理完的 IP 又被跑了一遍;
- 如果 IP 列表有上万条,串行 ping 的耗时可以让人等到崩溃,没人想看它跑一小时;
- 脚本没有日志文件,跑完只能盯着屏幕看输出,历史结果说没就没。
这些不是“写法不好”的问题,而是整个设计的目标压根没对上。课堂实验的目标是展示语法,生产脚本的目标是:输入明确、过程可控、结果可追溯、异常可恢复。目标变了,代码自然要像换了一套骨架一样重写。
1.2 生产环境最怕什么:留活口、断点续跑、失败重试、并发控制
生产环境的运维任务,本质上是一个“不可重启的批处理”。为什么?因为你在凌晨跑一个批量任务,等天亮才发现处理到第 357 个文件的时候挂掉了——你不光要修复脚本,还要回答一个致命问题:前 356 个文件到底成功了没有?哪些成功、哪些没成功?
这说明生产脚本要承担三个层次的职责:
| 层次 | 课堂脚本 | 生产脚本 |
|---|---|---|
| 执行结果 | 只要命令不报错就行 | 每个迭代成败都要记录 |
| 中断恢复 | 从头再来 | 断点续跑,跳过已完成项 |
| 失败策略 | 不关心,反正整体退出 | 显式决定:跳过、重试、还是终止 |
| 性能 | 串行足够 | 需要并发,但要可控 |
另外还有并发问题。几百个文件串行处理,可能只需几十分钟,但如果是几千个文件、每个都要调远程接口或者做解压压缩,串行能把人等到崩溃。生产级脚本要考虑用xargs -P或后台任务 +wait的方式做并行,但并行又会带来新的问题:输出交错、日志混在一起、资源占用不可控。这个平衡很难,但必须做。
1.3 从“能跑”到“会跑”的心态转换
我第一次写生产脚本时,也踩过这种坑:觉得自己在课堂上学得不错,拿到需求就开始写循环,写完本地测了一次,觉得没报错就上服务器 crontab 挂起来。结果第二天收到监控告警,任务跑挂了。我跑到服务器看日志,发现脚本的第 23 行报错,但脚本是纯串行、只有 echo 没有重定向,前面跑了多少步、哪些步失败,一概不知。最痛苦的是,我想手动补跑,但根本没有记录哪些文件已经被处理过了。
从那时候起,我就养成了一个习惯:在动笔之前,先闭上眼想——如果这个脚本跑到一半挂了,我要怎么知道自己处理到哪了?如果我对这个问题没有清晰答案,那我写的就不是生产脚本,只是把课堂作业挪了位置而已。
这种心态转变,比记住一百条 Shell 技巧都重要。接下来我讲的改造方法,都是建立在这个前提上的:脚本不是因为“复杂”所以生产化,而是因为“要能回答上面那个问题”才必须生产化。
2. 生产改造的核心设计:从流程骨架到健壮性
2.1 先定输入,再写循环:参数校验与环境变量
拿到改造任务,第一件事不是改循环体,而是定义“输入是什么”。
课堂脚本通常把输入写死在脚本里,或者简单地从文件读。生产脚本必须把“谁控制它”这件事想清楚。我通常遵循这些原则:
- 输入文件的路径不要写成相对路径,要允许通过命令行参数传入,或者从环境变量读取。
- 脚本里的目录、并发数、日志路径,都尽量参数化。
举个例子:
#!/usr/bin/env bash set -Eeuo pipefail INPUT_FILE="${1:-}" OUTPUT_DIR="${OUTPUT_DIR:-/tmp/output}" CONCURRENCY="${CONCURRENCY:-4}" if [ -z "$INPUT_FILE" ]; then echo "用法: $0 <输入文件路径>" >&2 exit 1 fi if [ ! -f "$INPUT_FILE" ]; then echo "错误: 输入文件不存在: $INPUT_FILE" >&2 exit 2 fiset -Eeuo pipefail是生产脚本的标配。很多新手不敢加,原因是怕它误伤。我来解释它的每一项:
-e:任何命令返回非零就退出。但要注意,在if判断里的命令、while条件里的命令不受影响,所以不用太担心。-u:未定义变量直接报错,避免$VAR拼出空参数导致命令行为诡异。课堂脚本里常见的坑就是变量名拼错,结果拼出一个空字符串,命令把目录当文件处理。-o pipefail:管道命令的最终退出码取所有命令中最右边的非零退出码,而不是最后一条命令的退出码。这能防止cmd1 | cmd2中 cmd1 失败但 cmd2 成功导致整体误判成功。-E:让 ERR 陷阱对函数和子 shell 生效,配合 trap 使用。
参数校验要区分“缺失”和“路径不存在”,用不同的退出码。退出码不仅表示“有没有成功”,还承担了“给调用方传递失败原因”的功能。如果调用方(比如 crontab 邮件、监控系统)只看退出码,至少能区分是参数错还是运行时错。
写入临时文件的路径建议用mktemp,不要自己拼/tmp/abc_$$。mktemp会保证唯一性和合理权限,比手动拼凑安全得多:
WORK_DIR=$(mktemp -d /tmp/process.XXXXXX) trap 'rm -rf "$WORK_DIR"' EXIT这样即使脚本退出,临时目录也会被清掉,不会在服务器上留一堆垃圾文件。
2.2 循环体的“隔离性”:每个迭代独立,防串扰
Shell 循环里的一个隐蔽问题是:变量在循环内被修改后,会“污染”下一次迭代。比如你在循环里定义了一个函数、改写了某个全局变量,下一次迭代可能莫名其妙地受影响。
所以生产级循环脚本里,我会刻意让每个迭代尽量独立。几个常见做法:
- 循环内的变量都加
local,或者把循环体的核心逻辑抽成一个函数,函数内部变量用local声明:
process_one() { local file="$1" local base base=$(basename "$file") # ... 处理逻辑 } while IFS= read -r line; do process_one "$line" done < "$INPUT_FILE"- 使用
while read而不是for i in $(cat ...)。这是个老生常谈但非常关键的改造点。for i in $(cat file)有两个先天问题:
- 文件里的空格、tab、通配符会被错误拆词;
- 当文件特别大时,
$(cat file)会把整个文件内容吃进内存,容易触发参数过长(ARG_MAX)错误。
最稳妥的逐行读取方式:
while IFS= read -r line || [ -n "$line" ]; do process_one "$line" done < "$INPUT_FILE"这里|| [ -n "$line" ]是为了防止文件最后一行没有换行符时漏读。IFS=保证行首行尾的空格不被吃掉,-r保证反斜杠不会被吞。这些都是课堂不会教,但线上一定会踩的细节。
- 尽量避免在循环内修改全局状态。比如要统计成功数、失败数,别直接在循环里
((count++))之后就到处用,因为一旦后续加并发(管道、subshell),统计值可能丢失或错乱。后面讲并发的时候再细说。
2.3 日志设计:记录每一次迭代的时间、输入、结果
生产脚本一定要有自己的日志文件。日志不是给调试用的,是给事故恢复用的。我见过太多线上脚本,任务失败后没有任何痕迹,只能靠业务方猜。
我习惯的日志设计很简单:
LOG_DIR="${LOG_DIR:-/var/log/my_script}" LOG_FILE="${LOG_DIR}/$(basename "$0").$(date +%Y%m%d).log" mkdir -p "$LOG_DIR" log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" >> "$LOG_FILE" } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >> "$LOG_FILE" }注意几个点:
- 日志文件名带上日期,方便按天归档;如果需要覆盖多天,再考虑按小时。
- 在循环里记录每次迭代的输入、结果(成功/失败),至少写一行。比如:
if process_one "$line"; then log_info "OK $line" else log_error "FAIL $line" fi- 日志要追加写入(
>>),不要覆盖写,否则重启一次任务就把之前的日志清空了。 - 别只把 echo 打到
> log 2>&1,因为 stdout/stderr 混在一起,看起来费劲;有条件的分类记录更好。
如果后面要做“断点续跑”,日志里记录的“成功行”甚至可以直接作为续跑的标记来源。
2.4 错误处理与退出码:set -e 的坑与自救
很多人一听说set -e,就以为万事大吉。其实set -e在循环里有一些非常坑的行为,我踩过好几次,这里直接列出来:
set -e不保证在函数内部、子 shell 内部一定生效。比如管道里的命令失败,单看最后一个命令的返回值可能被掩盖。这就是为什么我坚持set -Eeuo pipefail一起用,-E和-e配合能扩大 ERR trap 的覆盖范围。set -e突然退出时,你可能来不及处理现场。比如临时文件没有清理、后台任务还挂着、日志还来不及写最后一行。解决办法是用trap定义退出时要执行的收尾:
cleanup() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] 脚本退出,开始清理" >> "$LOG_FILE" [ -n "${WORK_DIR:-}" ] && rm -rf "$WORK_DIR" if [ -n "${BG_PIDS:-}" ]; then kill $BG_PIDS 2>/dev/null || true fi } trap cleanup EXIT trap 'echo "收到中断信号"; exit 1' INT TERM- 不要在循环里用
set -e后还想着“捕获错误然后继续”。因为一旦某个命令失败,整个脚本直接退出,预期是“跳过当前项继续处理”的场景会被破坏。正确的做法是:要么在循环体内显式判断命令返回值,要么把循环体放进一个函数,函数内部用|| return处理。例如:
if command_that_may_fail; then log_info "OK $item" else log_error "FAIL $item,跳过" continue fi这是生产脚本最常用、也最不容易出错的模式:把“失败”当作一个普通结果,由你来决策是继续还是中断。
3. 实操:把一个 for 循环脚本改造成生产级
3.1 原始课堂脚本示例:批量压缩日志目录
为了讲得更具体,我构造一个非常常见的场景:每天早上对/logs/目录下前一天的.log文件进行压缩,然后删除原文件(释放磁盘)。这是很多运维同学第一次写生产脚本时会遇到的需求。
课堂版长这样:
#!/bin/bash cd /logs for file in $(ls *.log); do gzip "$file" done这个到处是坑的“经典作业版”。先列一下问题:
ls *.log在文件很多时会换行输出,文件名带空格或特殊字符时会被拆碎;- 没有参数化,哪天要改成压缩 3 天前的文件就得改代码;
- 空目录时通配符
*.log没匹配到文件,ls *.log会直接报错或返回非零,然后循环体不执行,但脚本没有提示; - 压缩失败没有检测,删原文件时才后悔;
- 没有日志,没有幂等性,被定时任务重复执行时会把已压缩过的文件再找一遍(虽然
.gz后缀不同不会被匹配,但逻辑上不够严谨)。
3.2 改造后的生产级脚本拆解
我会把完整脚本写出来,然后逐段解释。脚本较长,但每一段都对应一个“生产化考量”。
#!/usr/bin/env bash # # batch_compress_logs.sh # 批量压缩日志文件,支持并发、断点续跑、日志输出 # 用法: ./batch_compress_logs.sh [--days 1] [--dir /logs] # set -Eeuo pipefail # ---------- 参数解析 ---------- DAYS=1 LOG_DIR="/logs" while [ "$#" -gt 0 ]; do case "$1" in --days) DAYS="$2" shift 2 ;; --dir) LOG_DIR="$2" shift 2 ;; *) echo "未知参数: $1" >&2 exit 1 ;; esac done # ---------- 参数校验 ---------- if ! [[ "$DAYS" =~ ^[0-9]+$ ]]; then echo "参数 --days 必须是正整数" >&2 exit 2 fi if [ ! -d "$LOG_DIR" ]; then echo "日志目录不存在: $LOG_DIR" >&2 exit 2 fi # ---------- 环境初始化 ---------- SCRIPT_NAME=$(basename "$0") TIMESTAMP=$(date '+%Y%m%d') LOG_FILE="/tmp/${SCRIPT_NAME}.${TIMESTAMP}.log" : > "$LOG_FILE" log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" >> "$LOG_FILE" } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >> "$LOG_FILE" } cleanup() { log_info "脚本退出,清理完成" } trap 'cleanup' EXIT # ---------- 主体逻辑 ---------- # 找出符合条件且尚未压缩的文件 mapfile -t files < <(find "$LOG_DIR" -type f -name "*.log" -mtime "$DAYS" -print) if [ "${#files[@]}" -eq 0 ]; then log_info "没有需要压缩的日志文件" exit 0 fi log_info "共发现 ${#files[@]} 个待处理文件,开始处理" processed=0 failed=0 compress_one() { local file="$1" local compressed_file="${file}.gz" if [ -f "$compressed_file" ]; then log_info "SKIP $file (已存在压缩文件)" return 0 fi if gzip -c "$file" > "$compressed_file"; then if rm "$file"; then log_info "OK $file -> $compressed_file" return 0 else log_error "FAIL $file (压缩成功但删除原文件失败)" rm -f "$compressed_file" return 1 fi else log_error "FAIL $file (gzip 压缩失败)" return 1 fi } # 注意:这里用 for 循环 + 函数,保证每个迭代失败后继续处理下一个 for file in "${files[@]}"; do if compress_one "$file"; then processed=$((processed + 1)) else failed=$((failed + 1)) fi done log_info "处理完成:成功 ${processed},失败 ${failed}" if [ "$failed" -gt 0 ]; then exit 3 fi exit 0逐段说明:
set -Eeuo pipefail放第一行。压制住“脚本死了都不知道死在哪”的情况,配合后续退出码,至少能把错误暴露出来。参数解析。用
while / case支持--days和--dir,才具备复用性。别嫌复杂,生产环境要用一个脚本处理多种场景(全量、增量、指定日期),没有参数化就只能复制粘贴脚本,那才是维护灾难。参数校验。天数必须是数字,目录必须存在。别让脚本在一半时报错,越早校验就越早暴露配置问题。
日志初始化。
LOG_FILE写在/tmp只是示例,生产环境建议放到明确的日志目录。: > "$LOG_FILE"是清空/创建文件的小技巧,注意这里是为本次运行准备一个全新的日志文件。mapfile+find收集文件列表。这一步替代了for file in $(ls *.log)。mapfile会把 find 输出的每一行存成数组元素,天然处理了空格和特殊字符的问题。用find而不是ls的好处是:可以精确控制匹配模式、按修改时间过滤、还能用-print0配合mapfile -d ''应对极端文件名(含换行符)。如果要非常稳妥,可以改成:
mapfile -d '' -t files < <(find "$LOG_DIR" -type f -name "*.log" -mtime "$DAYS" -print0)幂等性检查。函数开头判断
compressed_file是否已存在,如果存在就直接跳过。这是“断点续跑”的基础:任务中断后重新启动,已经压缩成功的文件不会被重复压缩。压缩 + 删除原文件的原子性问题。先
gzip -c "$file" > "$compressed_file",成功后再删除原文件。这里有两个细节:
- 为什么用
gzip -c > 文件而不是直接gzip "$file"?因为直接gzip默认会在成功后删除原始文件,一旦压缩不成功,原始文件也可能处于损坏状态。拆成两步,先保证压缩产物完整,再删除源文件,风险隔离更清晰。 - 为什么删除失败要回滚压缩文件?因为一个
.gz文件产生但原文件还在,下次运行会发现compressed_file已存在,直接 SKIP,就会造成“原文件一直存在,压缩文件也一直存在”的假死状态。所以要么删除原文件成功完事,要么把压缩文件也删掉让脚本下次能重新压缩。
- 失败计数与退出码。循环体里不
set -e中断,而是把每个失败记录下来,最后根据失败数量决定退出码。生产定时任务(crontab)通常只关心返回码,退出 3 表示“有部分失败”,和退出 0 分离开,监控系统就能识别。
3.3 并发改造:xargs -P 与后台任务 + wait
如果日志文件特别多,串行压缩可能太慢。上生产后,性能是硬指标。改并发之前,要先想清楚:我这个任务能并行吗?如果每个文件互相不依赖,且并发带来的 I/O 压力在可接受范围,那就可以。
最简单的并发改造是用xargs -P。把循环体抽成脚本或函数后,把要处理的文件列表交给 xargs:
printf '%s\0' "${files[@]}" | xargs -0 -P "$CONCURRENCY" -n 1 bash -c ' file="$0" # ... 处理逻辑,注意用 log 函数要传入或用 lib '但这里有个麻烦:日志输出从函数变成了bash -c,函数没法直接复用。所以更推荐“后台任务 + wait”的方式:
pids=() for file in "${files[@]}"; do ( if compress_one "$file"; then echo "OK $file" >> "$LOG_FILE" else echo "FAIL $file" >> "$LOG_FILE" fi ) & pids+=($!) # 控制并发数量:如果后台任务达到上限,等其中一个完成 while [ "${#pids[@]}" -ge "$CONCURRENCY" ]; do wait -n -p done_pid "${pids[@]}" 2>/dev/null || true # 移除已完成的 pid new_pids=() for pid in "${pids[@]}"; do [ "$pid" != "$done_pid" ] && new_pids+=("$pid") done pids=("${new_pids[@]}") done done wait "${pids[@]}" 2>/dev/null || true这段比较啰嗦,其实是 Bash 4.3+ 的wait -n才能简化。生产环境常常是兼容性要求高的老版本 Bash,所以这个“手动移除已结束 PID”的写法虽然笨,但通用。
并发改造要重点提示:并发时的统计变量会丢。原因是括号( ... ) &会开子 shell,子 shell 里的processed=$((processed+1))影响不到父 shell。所以并发模式下,统计要用临时文件或echo追加日志,不要指望变量计数。
3.4 幂等性与断点续跑:用标记文件
上面脚本里,幂等性靠compressed_file是否存在来判断。但很多任务做不到这么天然,比如批量修改数据库记录、调用外部接口。这类任务要断点续跑,通常用“标记文件”的方式。
思路很简单:
- 为每个任务项生成一个唯一 ID(文件名、行号、记录主键);
- 建一个
.done目录,每完成一项就touch .done/<ID>; - 循环开始前先检查
.done/<ID>是否存在,存在则跳过。
示例:
DONE_DIR="${WORK_DIR}/done" mkdir -p "$DONE_DIR" process_one() { local item_id="$1" local done_file="$DONE_DIR/$item_id" if [ -f "$done_file" ]; then log_info "SKIP $item_id" return 0 fi # 真正处理逻辑... if 处理成功; then touch "$done_file" log_info "OK $item_id" else log_error "FAIL $item_id" return 1 fi }好处是:脚本中断后重跑,已经完成的任务被直接 SKIP,不会有重复执行的风险,也不会因为重复执行产生账单、重复扣量这类生产事故。
这里有个细节:touch本身也可能失败。如果临时目录满了或者权限不对,done 文件没写下,但业务逻辑又执行了,重跑就会重复处理。所以我一般建议:关键任务在业务完成后立刻touch,并且touch失败要当作整个任务失败处理,宁可让脚本报错也不要留下“逻辑已执行但标记没落盘”的窗口。
4. 常见问题与排查技巧实录
4.1 循环里变量不生效:管道和子 shell 的坑
最经典的问题:cat file | while read line; do count=$((count+1)); done; echo $count输出还是 0。
原因是在 bash 里,管道后面的while是在子 shell 里执行的,循环结束后子 shell 退出,count的修改没有传回父 shell。解决方式有三个:
- 用进程替换:
while read line; do ... done < <(cat file),这样while在当前 shell 执行,变量能保留。 - 把统计结果写文件,最后再读。
- 使用
shopt -s lastpipe(仅在 bash 4.2+ 有效)。这个选项让管道最后一个命令留在当前 shell,但依赖环境。
我在生产脚本里最常用的是进程替换,它直观、可控,而且不需要改动整个流水线。
4.2 文件名带空格或特殊字符
前面已经提到for file in $(ls *.log)的坑。这里再补充一个更隐蔽的:如果文件名是a.b.log和a b.log混在一起,并且你用了gzip $file(没有加引号),脚本能把文件拆成多个参数,直接把gzip搞懵。
生产并发的调试里,这类问题最容易出现“偶发失败”——本地测试时文件名没空格,线上有空格就疯狂报错。
解决办法:凡是变量作为命令参数,一律加双引号;读取文件列表,用while IFS= read -r或mapfile;匹配文件名,用find ... -name "*.log" -print0。
还有处理含换行符的文件名这种极端情况——实测不常见,但如果你要写的是那种面向不可信输入的脚本,建议升级到-print0+read -d ''的完全防御式写法。
4.3 任务中断后重复执行导致重复操作
这个场景可以说是生产事故里最高发的。比如批量发邮件、批量更新库存、批量调支付接口,一旦脚本在任务中途因为服务器重启、网络超时而中断,运维同学的第一反应往往是“重新跑一遍”。如果脚本没有幂等性设计,重跑一遍等于重复发邮件、重复扣款。
所以说,做任何批量任务前,先问自己:如果我的脚本被重复执行,会发生什么?
如果答案是“会产生重复操作”,那这个脚本后面一定要加一个“幂等机制”——就是上面标记文件、状态记录那套东西。不要觉得“只是个脚本,重跑就重跑吧”。线上每一条告警背后,都可能有人在替你负重。
4.4 超时不处理导致任务卡死
循环里调外部命令,比如curl、ssh、mysql,很容易卡在“连接等待”上。课堂脚本里不考虑超时,一跑可能几个小时卡住,然后缩进到 Cron 任务里,第二天又叠加上一个进程,最后机器负载爆炸。
生产脚本必须给外部命令加超时。常用办法:
curl用--connect-timeout 5 --max-time 10- 任意命令用
timeout 30 command args - 串行循环里给每个 item 加
timeout,防止单个 item 卡死
示例:
if timeout 30 gzip -c "$file" > "$compressed_file"; then ... fi4.5 生产环境千万别这样写
最后列几条“我强烈不建议在生产写的 Shell 循环”:
| 危险写法 | 为什么会出事 | 正确替代 |
|---|---|---|
for i in $(cat file) | 空格/换行拆词 | while read -r或mapfile |
| 外部命令不加超时 | 单个 item 卡死整个任务 | timeout/curl限时参数 |
| 并发时共享临时文件 | 输出互相覆盖 | 每个子 shell 写独立文件 |
用sleep控制并发 | 不是资源控制,拖延进度 | wait -n/ 信号量 |
| 忽略退出码语义 | 调用方无法区分错误类型 | 用 0/1/2/3 分类退出 |
另外,不要直接对线上目录做cd后动态修改原始文件,最好把命令都指向明确路径或用绝对路径。脚本在受限用户下跑时,mkdir、touch、写日志都可能失败,别在测试时用 root 没感觉,上线后才发现全是权限问题。
5. 测试、发布与回收:脚本上线前的最后一步
5.1 先沙箱测试:小批量验证
改造完不是直接挂 crontab,要有一个“沙箱验证”环节。我的习惯是:
- 准备一个小测试目录,放几十个不同特征的文件:空文件、几个 MB 的大文件、文件名带空格的文件、符号链接。用生产脚本对这些文件跑一遍,观察日志和结果。
- 然后故意制造一次失败,比如把一个文件权限改成
000,确认脚本能记录失败、跳过、最后返回非零。 - 再模拟中断:启动脚本后立刻
kill,然后重新运行,验证幂等逻辑是否生效,统计是否准确。 - 最后测试并发参数,比如
CONCURRENCY=2/4/8各跑一次,观察是否会资源耗尽。
这一套走下来,基本能把 90% 的初级问题过滤掉。不要嫌麻烦,线上脚本出一次事,成本高得多。
5.2 发布前的情景区:全量、增量、回滚
脚本上线前还有一个容易被忽略的点:这个脚本到底是一次性任务,还是周期性任务?如果是周期性任务,要考虑两种模式:
- 全量模式:处理所有历史文件;
- 增量模式:只处理昨天/当天新增的文件。
把参数化做好,就能用同一个脚本完成两种模式。比如前面例子里的--days 1就是增量模式,--days 3650近似全量。
回滚则更重要。很多 Shell 批量操作是不可逆的,比如删除原文件后,如果压缩文件也损坏,数据就没了。生产级设计要问:我有没有为每一个破坏性动作留一条“后悔路”?
现实一点的方案:默认不删源文件,只压缩到目标目录;确认数据没问题后再由另一个清理任务、或者人工命令删除源文件。这样即使压缩产物有问题,原始数据还在,可以重跑。
5.3 脚本也要有生命周期:版本、注释、退役
最后一个容易被忽略但很重要的生产习惯:脚本文件本身要纳入管理。至少做到三点:
- 在脚本头部写清楚:用途、参数、退出码含义、维护者、创建日期、修改记录。
- 把脚本纳入版本控制,不要直接在服务器上手动改完忘了同步,导致多台服务器脚本不一致。
- 当脚本要退役或变更行为时,先在日志里打点,观察一段时间,再迁移。
我自己在把课堂脚本改成生产脚本的过程中,踩过最痛的那次,就是没有给删除动作留后悔路。凌晨四点被叫起来,发现日志目录全被压缩删了,但有一批压缩文件因为磁盘写满损坏了,原始文件又没了,只能从备份恢复。从那以后,我的原则就变成了:破坏性动作永远分两步走。
写脚本这件事,语法上的坑靠背,设计上的坑只能靠踩。希望这篇改造思路能让你少踩几个,至少在写for或者while的时候,多问一句“如果它挂了,我能不能知道它走到哪了”。这句话,比任何一行命令都值钱。