news 2026/10/15 4:29:34

Shell脚本实战:从踩坑到高效自动化运维

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Shell脚本实战:从踩坑到高效自动化运维

从事Linux运维和开发这些年,shell脚本是我每天都会碰的东西。做日志切割、批量改文件、写定时任务、数据同步、环境初始化,一件件看起来鸡毛蒜皮的小事,最后都是靠脚本把人力省下来的。这是我自己笔记的第十六篇,前面写过系统启动、权限、磁盘、网络这些基础内容,这篇专门聊聊shell脚本,把我这些年踩过的坑和觉得有价值的写法一次性梳理出来,希望能帮正在学脚本的读者少走几步弯路。

这篇内容不打算从"什么是变量"这种教科书概念开始,而是直接以实战视角切入:什么时候该写脚本、脚本骨架怎么搭、核心语法怎么用才不出错、完整案例怎么落地、出了问题怎么排查。适合已经会敲基本命令、想把零散命令变成可复用工具的读者;如果你是完全零基础,可以先拿着基本命令练几遍再来看这篇,效果会更好。

1. 什么时候真正需要动手写脚本

1.1 脚本最擅长解决的几类问题

先说实话,不是所有事情都值得写成脚本。我自己判断的标准很简单:这件事是不是要重复做两次以上?是不是需要多个命令配合才能完成?如果答案都是肯定的,那基本就该写了。

最常见的场景我列一下:

  • 批量操作类:几十台机器上收集信息、几百个文件统一改名、批量压缩解压。手工敲一条条命令又累又容易漏,脚本跑一遍还能留下输出日志。
  • 定时任务类:日志清理、数据备份、临时文件归档、健康检查告警。cron加脚本是绝配,脚本负责逻辑,cron负责定时触发。
  • 部署与初始化类:新机器装环境、初始化配置、重启服务。这类操作步骤多且有固定顺序,写成脚本后可以反复执行,也方便在出问题时快速重来。
  • 格式转换与数据处理:把CSV转成另一种格式、从日志里面提取关键字段、批量替换文件内容。配合awk、sed、grep这些工具,一个瘦脚本就能解决很多所谓"大数据"需求的土办法方案。

我遇到最多的是日志相关。比如业务系统每天产生的日志文件,按照日期命名,超过30天的需要删除,磁盘空间到80%需要告警。这种需求用手工做根本不现实,定时跑一个脚本才是正解。后面我会把这个完整脚本展开讲。

即使是临时性任务,只要我开始觉得"这串命令要重复手敲两遍",也会顺手写成脚本。写的时候可能只花三分钟,但后面再遇到类似需求,把脚本翻出来改两行就能用。脚本库就是这么一点一点攒出来的。

1.2 别把脚本当万能钥匙

shell脚本也不是没有短板。遇到下面这几类情况,我建议换个工具而不是硬撑。

一是性能敏感的大数据处理。比如几GB的日志要算统计指标,shell里一层层循环会慢到让人怀疑人生,这时候用Python、Go甚至直接上SQL都更合适。shell适合做"胶水"层面的组织和协调,不适合做重计算。

二是逻辑特别复杂的流程。判断分支多了、状态多了、需要复杂数据结构的场景,shell写起来会很痛苦,调试也费劲。脚本里出现几十行嵌套if,基本就是该重构或者换更合适语言重写的信号。

三是跨平台要求高的场景。虽然bash在各种Linux发行版上都通用,但有些命令在不同发行版上行为略有差别,macOS自带的是旧版bash,Windows上更是另一套。如果脚本要在完全不同的环境里跑,要么用Python这类跨平台解释器,要么在脚本里做足兼容判断。

我的经验是:能用标准命令组合解决的就用脚本,感觉脚本写起来已经很别扭了,果断换语言。工具选对了,后面维护的成本才会低。

1.3 动手前先想清楚三个问题

写一个脚本之前,先花两分钟想三件事:输入是什么、输出是什么、中间有哪些约束。输入可能是配置文件、命令行参数或环境变量;输出可能是处理后的文件、日志记录或一个退出码;约束包括运行权限、运行时间窗口、磁盘空间上限等。想清楚这三件事,脚本的结构基本就出来了。

然后遵循一个原则:一个脚本只干一件事。把功能拆成函数,每个函数只做它负责的那部分。这样做的好处是定位问题快、复用方便,也方便以后把脚本拆成更细的模块。我早期写脚本总是"一个脚本解决所有问题",结果脚本越来越长,改一个逻辑就动到全局,线上出了故障排查得像考古。吃过亏以后才养成先拆功能、再动手写的习惯。

2. 脚本骨架搭建与变量引用的关键细节

2.1 shebang、执行权限和首行配置

一个普通文本文件要让系统当成脚本执行,需要两个条件:第一行以#!开头指定解释器,并且文件要有执行权限。第一行写#!/bin/bash还是#!/usr/bin/env bash,两者都能用,但意义不同。

直接写#!/bin/bash是明确指定解释器路径,性能稍好,兼容性也更容易预料。写#!/usr/bin/env bash是先找到bash所在路径再执行,在bash不在标准位置的系统上更灵活,但env本身如果找不到也会带来问题。我自己在服务器上习惯直接写#!/bin/bash,因为标准发行版上bash基本都在这个位置,少一层解析也更稳。

执行权限用chmod +x script.sh加上。这一步很多人忽略,导致明明脚本没问题却提示Permission denied。另外如果在当前目录运行脚本,要写成./script.sh而不是script.sh,因为当前目录通常不在PATH环境变量里。这个踩坑率极高,尤其是刚从Windows转到Linux环境的读者。

首行配置我还建议加两样东西:一是文件头的注释,写明脚本用途、创建时间和维护人;二是set选项。虽然set选项后面详细讲,但我现在写任何脚本,开头几乎固定是:

#!/bin/bash # 功能:xxxx # 创建:2024-xx-xx set -euo pipefail

set -euo pipefail这一行带来的变化我后面详细说,先记住它能让脚本在出错时立刻暴露问题,而不是带病往下跑。

2.2 变量取值与引号使用的门道

变量赋值在shell里看似简单,坑全藏在细节中。赋值语句等号两边不能有空格,写成name = "张三"会变成执行命令name,直接报command not found。这个错误我见新人踩过无数次。

取变量值用$name,但更严谨的写法是${name}。两者差别在变量名后面紧跟其他字符时体现出来:echo "$name_file"会把name_file当成一个变量名,而echo "${name}_file"才是取name变量的值再加下划线和file。凡是变量名边界不清晰的场景,一律用${}包裹,这是一个让脚本更清晰、更安全的好习惯。

引号的使用是shell脚本最容易出问题的点。什么时候用双引号,什么时候不用,规则其实很明确:如果变量的值可能包含空格、通配符或其他特殊字符,并且你希望它作为一个整体被处理,就必须加双引号。比如:

file_path="/data/logs/app 2024.log" if [ -f "$file_path" ]; then echo "文件存在" fi

如果这里不加双引号,[ -f $file_path ]会被展开成[ -f /data/logs/app 2024.log ],test命令会因为参数个数不对直接报语法错误。双引号的作用就是给整个值"包一层",不让shell把里面的空格当成分隔符。

还有一种情况是变量的值来源不可控,比如读取外部文件内容、命令输出的结果,这时候双引号尤其重要。不加双引号的话,文件里如果有分号、感叹号、星号之类的内容,shell会做各种展开,轻则结果不对,重则执行了本不该执行的命令。我在这上面栽过跟头:一个读取文件内容后再拼成命令发送的脚本,文件里恰巧有分号,结果一行原本正常的命令被拆成了两条。从那以后,凡是非我手写的字符串,默认一律加双引号处理。

命令替换是另一个常用特性:$(command)会执行命令并把输出作为值。比如拿今天日期:today=$(date +%F)。这里我建议优先用$()而不是反引号,因为反引号在处理嵌套时容易出错,$()可以嵌套,可读性也更好。注意命令替换的结果如果有换行符,赋值给变量后会保留,echo出来可能看到多行,这在处理命令输出时要心里有数。

2.3 位置参数与入参检查

脚本接收外部输入,最常见的是位置参数:$1、$2这种。比如./backup.sh /data/mysql /backup,脚本里$1就是/data/mysql,$2就是/backup。$#表示参数个数,$@表示所有参数并且每个参数独立,$也表示所有参数但视为一个整体。遍历参数时应该用for arg in "$@",而不是"$",这样才能正确处理带空格的参数值。

脚本里的关键参数,我习惯加一道检查,避免用户忘了传参导致脚本在后面某个莫名其妙的位置报错。典型写法:

if [ $# -lt 2 ]; then echo "用法: $0 <源目录> <备份目录>" exit 1 fi

这样做的好处是错误信息明确,而不是脚本跑了一大半才因为找不到文件而失败。exit 1返回非零退出码,调用方可以据此判断脚本执行失败。

有时候参数是可选的,需要默认值。shell的参数展开语法能直接处理:${var:-默认值}表示var没设置或为空时使用默认值。比如日志保留天数支持通过参数指定,不指定就默认30天:keep_days=${1:-30}。这样脚本既灵活又稳妥。类似地,${var:=默认值}不但取默认值还给变量赋值;${var:?提示信息}在变量为空时直接退出并输出提示,适合强制要求参数的场景。

3. 条件判断与循环的实战细节

3.1 条件测试写法:单中括号和双中括号怎么选

shell里做判断,本质上是运行一个test命令并根据它的退出码决定分支。if [ ... ]中的方括号其实是test命令的另一种写法,所以方括号两边必须有空格,[ "$a" = "$b" ]写成[ "$a"="$b" ]就会出错,因为shell把整个字符串当成了一个参数。

现代bash里更推荐用双中括号[[ ... ]]。它是bash内置的关键词,支持更丰富的表达式,比如==和=都可以用,支持正则匹配,也支持&&和||这种逻辑运算符。最关键的是,在[[ ]]内部变量即使不加双引号也不会被错误分词,对新手友好得多。我的建议是:既然用的是bash,直接统一用[[ ]],不要再拿单中括号去踩那些边边角角的坑。

文件判断操作符要记牢:-f判断是不是普通文件,-d判断是不是目录,-e判断路径是否存在,-r、-w、-x判断读、写、执行权限,-z和-n判断字符串为空或非空。-nt和-ot可以比较文件的修改时间新旧,在增量备份场景里很好用。数值比较用-eq、-ne、-gt、-lt,而不是>和<,因为>在shell里是重定向符号,写错会引发文件重定向,非常隐蔽。

逻辑判断的写法在[[ ]]里直接用&&和||:if [[ -f "$file" && -r "$file" ]]; then。在单中括号里要用-a和-o,再加上各种转义,可读性和安全性都差。所以坚持用[[ ]]能让代码干净很多。

3.2 for、while、until在什么场景下各司其职

for循环适合遍历已知列表:遍历一组文件、一组主机名、一组参数。最典型的是遍历当前目录下的所有txt文件:

for f in *.txt; do echo "处理文件: $f" done

注意*.txt在这里会先被shell展开成文件列表,如果目录里没有匹配的文件,展开结果会是字面的*.txt,循环还是会执行一次,所以最好先判断文件是否存在再进循环。

遍历时如果文件名有空格,for f in *.txt这种写法会出问题,因为空格会被当成列表分隔。处理方法是用find配合while read:

find . -name "*.txt" -print0 | while IFS= read -r -d '' f; do echo "处理文件: $f" done

-print0把文件名以null分隔,-d ''让read也按null分隔读取。这个写法看着繁琐,但它在处理带空格、带换行的文件名时百分之百可靠,是处理任意文件名最稳妥的方式。

while循环适合条件驱动的场景:读文件直到文件结束、等待某个条件成立、消费某个队列。最常见的while读文件写法:

while IFS= read -r line; do echo "$line" done < input.txt

IFS=防止行首行尾的空白被trim掉,-r防止反斜杠被解释,< input.txt把文件作为循环的输入。这种写法比for i in $(cat input.txt)安全得多,因为for配合$(cat)会把文件按空格、按换行全部分裂一遍,文件内容稍微有点特殊字符就乱套了。

until循环和while结构一样,只是条件为假时执行。实际用得少,但偶尔用来等待某个服务就绪还挺顺手:until nc -z 127.0.0.1 3306; do sleep 1; done,一直等到数据库端口能连通。循环里break可以提前跳出,continue进入下一轮,是两个常用的控制指令,不要忘记。

3.3 管道、子shell与循环变量丢失问题

这个问题我必须单独提,因为它坑过的绝对不止我一个人:在管道后面接while循环,循环内对变量的修改在循环结束后会丢失。看这个经典例子:

count=0 echo -e "a\nb\nc" | while read line; do count=$((count + 1)) done echo "$count"

输出是0,而不是3。原因在于管道会让while循环跑在一个子shell进程里,子shell里的变量修改不会传回父shell。我第一次遇到时百思不得其解,查来查去才发现是shell的设计使然。

解决思路有三种。第一种把输入重定向而不是用管道:

while read line; do count=$((count + 1)) done < <(echo -e "a\nb\nc")

进程替换<(command)产生的输入通过重定向进入循环,循环本身还在当前shell里运行,变量修改就能保留。第二种是把结果写到文件再读出来。第三种是把处理逻辑封装成函数后用输出捕获结果,但本质上还是在子shell里,靠的是函数输出而非变量传递。

这类"子shell边界"问题在管道、find -exec、括号子shell里都会出现。遇到"变量值诡异消失"的现象,第一反应就是检查是不是跨越了子shell边界。排查方法也很简单:在关键位置加echo输出中间值,看看循环内和循环外的值是否一致,不一致基本就是边界问题。

4. 函数设计、退出码与脚本健壮性

4.1 用函数拆骨架,local变量防污染

脚本一长,组织方式就成了大问题。我现在的习惯是:脚本主体只保留流程调度,真正的逻辑全部放进函数。函数定义用function name() {}或者直接name() {}都可以,我倾向不带function关键字,和POSIX兼容性更好。

函数里要特别注意变量的作用域。默认情况下函数里赋值的变量是全局的,会污染脚本其他部分。所以凡是函数内部的临时变量,一律用local声明:local tmp_file="/tmp/xxx"。这能避免函数间因为同一个变量名互相影响,排查问题时也清楚每个变量的归属。这个习惯和写代码时不让函数偷偷改全局状态是同一个道理。

函数内用return返回一个整数状态码,0表示成功,非0表示失败。return和exit容易混:return只是退出当前函数,脚本还能继续;exit是直接终止整个脚本。在某个函数里判断"要不要让整个脚本停下来",就要分清楚用return还是exit。我一般是:函数内部出错且后续步骤依赖该函数的结果,就在函数里exit 1让脚本立刻失败;只是单一环节出错、不影响整体,就return非零值让调用方自行处理。

函数设计上还有一个经验:一个函数只做一件事,函数名诚实描述行为。read_config作为一个函数,就只负责读取配置并校验,不把别的逻辑塞进来。这样脚本读起来像是一篇有目录的文章,而不是一团纠缠的线。

4.2 退出码与set -euo pipefail的核心策略

每个命令执行完都会留下一个退出码,0是成功,1到255是各种失败原因。在条件判断和循环里,shell就是靠退出码来决定走向。$?保存着上一个命令的退出码,但它有个致命特点:任何命令执行都会覆盖$?。你想检查某个命令的结果,必须立刻取值,中间不能穿插任何其他命令。下面这个写法就是典型的错:

grep "error" app.log echo "grep退出码记录到变量: $?" if [ $? -eq 0 ]; then

第一次$?还是grep的退出码,第二次$?已经是echo命令的了,想判断grep结果时早就不对了。正确做法是第一时间把退出码存下来:

grep "error" app.log ret=$? if [ $ret -eq 0 ]; then

set选项能大幅改变脚本的容错行为。set -e表示任何一条命令返回非零就立刻退出脚本,避免错误被掩盖、脚本带着错误状态继续跑出更多问题。初学者经常觉得set -e太敏感,但正是这种敏感让问题尽早暴露。set -u表示使用未定义变量时直接报错退出,能顺手抓出很多变量名拼写错误。set -o pipefail让管道中任何一个环节失败都被识别为管道失败,否则bash只关心管道最后一个命令的状态,前面环节悄悄挂了你都不知道。

三个选项拼在一起set -euo pipefail就是我现在所有脚本开头的标配。它们改变了脚本遇到错误时的默认反应:从"默默继续"变成"大声失败"。这个转变对线上任务的意义非常大,我自己是踩过"脚本其实早就出错但还在跑"的亏之后,才真正体会到这行配置的价值。

也要说明,set -e在某些场景下需要临时关闭。比如你明确知道某条命令可能失败,且失败是可以接受的,可以用if cmd; then的写法把它放在条件里,这样set -e不会干涉。或者用cmd || true主动吞掉失败状态。但"|| true"相当于告诉读者这行失败无所谓,所以要谨慎使用,别让该报的错都被蒙混过关。

4.3 trap清理与临时文件安全管理

脚本跑着跑着被中断了,留下的临时文件、锁文件怎么办?trap命令就是为这种场景准备的。trap 'code' EXIT可以让脚本在任何方式退出时都会执行那段清理代码,无论是正常结束、被中断还是报错退出。我写脚本时凡是创建了临时目录,必配一句:

tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT

mktemp -d会在/tmp下生成一个随机命名的目录,避免用固定路径导致多个实例互相覆盖,也防止了路径被其他用户提前猜到带来隐患。trap清理保证脚本就算中途挂了,垃圾也能被带走。单独看rm -rf "$tmp_dir"这个命令还有一个细节:变量要用双引号包住,否则目录名里有空格时会拆成多个参数,甚至可能误删不该删的东西。关于rm -rf的风险,后面安全部分还会展开。

trap还可以监听信号:trap 'echo "收到中断信号,开始收尾"; exit 1' INT TERM,让脚本在被Ctrl+C时先做清理再退出。这种"无论如何退出都先清理"的思维,是脚本健壮性的一个核心习惯,建议把它内置到自己的脚本模板里。

5. 实战拆解:一个日志自动清理脚本

5.1 需求描述与功能拆分

前面讲了这么多理论,现在用一个我真实场景里非常常见的例子把整个流程串起来。假设有一个业务日志目录/data/app/logs,里面每天生成一个以日期命名的.log文件,还可能有子目录。需求有三点:

  • 删除30天前的日志文件,释放磁盘空间;
  • 删除前记录一份清理清单,方便以后查账;
  • 如果目录所在磁盘使用率超过80%,输出告警信息,并留下一条醒目的记录。

这种需求如果直接写三坨命令,也能完成大部分功能,但可读性、安全性都差。按前面说的思路,我先拆功能:校验目录、执行清理、记录日志、检查磁盘水位。每个功能一个函数,主流程简单清晰。

5.2 完整脚本逐段解析

先看完整代码,再逐段解释关键处。这个脚本偏向通用教学,你可以直接拿来改成本地路径使用。

#!/bin/bash # 功能:日志目录过期清理与磁盘水位检查 # 用法:./clean_logs.sh [保留天数] [目标目录] set -euo pipefail LOG_DIR="/data/app/logs" KEEP_DAYS="${1:-30}" LIMIT=80 CLEAN_LOG="/var/log/clean_logs_history.log" log_msg() { echo "$(date '+%F %T') $*" >> "$CLEAN_LOG" } check_dir() { if [ ! -d "$LOG_DIR" ]; then log_msg "目录不存在: $LOG_DIR" exit 1 fi } disk_usage() { local used used=$(df "$LOG_DIR" | awk 'NR==2 {print $5}' | tr -d '%') if [ "$used" -ge "$LIMIT" ]; then log_msg "磁盘使用率超限: ${used}% >= ${LIMIT}%" echo "警告: 磁盘使用率已达 ${used}%" fi } do_clean() { local file while IFS= read -r file; do [ -f "$file" ] || continue log_msg "删除过期日志: $file" rm -f "$file" done < <(find "$LOG_DIR" -type f -name "*.log" -mtime +"$KEEP_DAYS" -print0) } main() { check_dir do_clean disk_usage log_msg "清理任务完成,保留天数=${KEEP_DAYS}" } main

逐段来说。

set -euo pipefail就是前面说的标配。其中-u在这里尤其重要,因为脚本引用了KEEP_DAYS、LOG_DIR这类变量,如果变量名写错或者赋值没成功,-u会立刻让脚本报错,而不是用空值一路带偏后面的逻辑。

LOG_DIR和KEEP_DAYS是配置区。我把可变的配置集中在脚本顶部,以后要改路径或天数,只动这几行就行。KEEP_DAYS="${1:-30}"表示支持命令行参数指定天数,不指定就默认30天。这种"配置集中加参数覆盖"的模式很实用。

log_msg函数把所有运行痕迹追加到CLEAN_LOG。日志统一记录是运维脚本的底线,出事后能回溯,也是"留痕"思维的体现。函数里的"$*"会把所有参数合并成一行,配合前面的日期时间戳,记录的信息就很完整了。

check_dir先验证目录存在。在脚本一开头就校验前置条件,是避免任务做到一半才发现环境不满足的经典做法。disk_usage用df拿使用率,再用awk取第二行的百分比字段,用tr去掉百分号,最后和LIMIT比较。这里一个细节是df的输出第一行是标题,NR==2才是目标文件系统的数据行。

do_clean是整个脚本的核心。find "$LOG_DIR" -type f -name "*.log" -mtime +"$KEEP_DAYS"查找指定目录下距离修改时间超过保留天数的.log文件。-mtime +30的意思是文件修改时间在30天之前。选项-print0让find以null字符分隔输出的文件名,这是为了应对文件名里有空格或换行的极端情况。然后通过进程替换<(...)把find的结果作为while循环的输入,这样while循环跑在当前shell里,不会遇到前面说的子shell变量丢失问题。循环体里先判断[ -f "$file" ]再删除,主要是防止find结果因路径变化已经失效,避免把不存在路径当普通参数处理。

main函数把整个流程串起来,脚本真正执行的地方就一行main。这样设计的好处是脚本的逻辑一目了然,想单独调试某个环节,可以临时注释掉main里的某一项,或者直接拿出某个函数单独测试。

5.3 接入crontab与运行验证

脚本写好后,用crontab -e编辑定时任务,添加一行:

30 2 * * * /usr/local/bin/clean_logs.sh 30 /data/app/logs >> /dev/null 2>&1

这表示每天凌晨2点半执行一次,第一个30是保留天数。定时任务里有一个经典坑:cron执行环境非常精简,PATH可能不包含脚本依赖的命令路径,所以脚本里的命令最好写全路径,或者调用时显式带上环境。我一般习惯在脚本里固定export PATH="/usr/local/bin:/usr/bin:/bin",避免cron环境下找不到命令。

先手动执行一遍,确认退出码为0,再检查CLEAN_LOG里有没有正常记录。然后用find手动验证一下过期文件确实被清理了。最后再挂cron,不要写完就直接挂上,万一有bug,定时任务会在凌晨悄悄帮你放大问题。

6. 调试技巧与高频避坑速查

6.1 调试三板斧:bash -x、set -x、关键节点输出

shell脚本出了问题怎么排查?我用得最多的方法,是bash -x script.sh直接以调试模式运行。这个模式下,每条命令执行前都会把展开后的实际命令打印到标准错误,前面带一个加号。看到加号后面的实际命令,就能立刻发现变量展开是不是符合预期、某条命令是不是被拆成了两条。比如变量没加引号导致参数分裂,在-x模式下会非常直观地看到命令参数被拆开。

脚本内部也可以用set -x开、set +x关,把调试范围限制在某个区间:

echo "开始调试" set -x rm -f "$file" set +x echo "调试结束"

这样做可以避免整个脚本的输出被调试信息淹没。

还有一种低技术含量但极有效的方法:在关键节点加echo输出。变量赋值后、分支进入前、循环每次迭代时,各打一行带标记的输出,比如echo "DEBUG: file=$file, count=$count"。配合bash -x,基本能定位九成的逻辑问题。

再有就是trap 'echo "line:$LINENO, ret=$?"' ERR,每行出错时自动打印行号和退出码。这个对定位"到底哪条命令挂了"特别有帮助。需要留意ERR触发和set -e、命令返回非零等条件有依赖关系,实际使用中配合bash -x效果更好。

6.2 高频错误对照速查

我整理了平时答疑时遇到最多的问题,做成一个速查表,能省不少排查时间。

现象常见原因解决办法
执行脚本提示Permission denied没有执行权限chmod +x script.sh
能执行但报command not found文件名含特殊字符或CRLF换行用file查看格式,dos2unix转换
变量值不对,多出奇怪内容未加双引号,被shell分词或扩展变量引用一律加双引号
if判断总是走false方括号左右缺空格[ "$a" = "$b" ]每个地方留空格
循环后变量值没变子shell边界问题改用重定向或进程替换
整数比较报语法错误用了> <而不是-gt -lt数值比较用-eq -ne -gt -lt
rm在某些文件上报参数过多find结果过多导致参数上限超限改用find -delete或分批处理
脚本某个环节静默失败没开启set -e和pipefail脚本开头加set -euo pipefail

这里面CRLF换行的问题值得单独说一句。在Windows上编辑过的脚本上传到Linux,文件每行末尾会带着\r,第一行shebang会变成#!/bin/bash\r,系统会报bad interpreter错误。用file script.sh看一下,显示with CRLF line terminators就知道是这个原因。解决用sed -i 's/\r$//' script.sh或者dos2unix命令。养成在Linux环境里编辑脚本、或者给编辑器设置好换行符的习惯,能从根上避开这个问题。

6.3 隐蔽风险:rm -rf、通配符展开、eval与临时文件

shell脚本因为可以直接执行任意系统命令,安全上的坑比其他语言更需要警惕。首当其冲的是rm -rf这类危险命令。如果变量没设置好,rm -rf "$dir/"里的dir是空字符串,就会变成rm -rf /,后果不堪设想。我的习惯是:凡是涉及rm -rf,先用[ -n "$dir" ]校验变量非空,再用set -u兜底,并且在执行前echo出完整命令供人工检查。虽然这次写的清理脚本用的是rm -f不带-r,但任何删除操作都值得同样的谨慎。

另一个隐蔽风险是变量里的通配符展开。如果文件名里带有*号,不加引号的使用会把星号按通配符展开,可能展开成一堆文件路径。这个问题在读取外部数据来源时尤其值得注意:外部数据不可信,你的引号就是防线。凡是对变量内容不确定,默认加双引号,这一条能挡住一大片问题。

eval命令要尽量少用。eval会先把字符串拼接成命令再执行,等于允许外部输入直接变成命令,注入风险极高。在我处理的场景中,绝大多数eval需求都可以通过关联数组、case分支或函数映射解决,没有必要用eval。如果你确实绕不开,至少确保eval的参数完全由脚本内部受控内容构成,绝不让外部输入直接进eval。

临时文件的安全也要注意。脚本里别用固定的/tmp/my_temp_file这种路径,多个实例同时跑会互相覆盖,别人也可能猜到路径做手脚。统一用mktemp或mktemp -d生成随机路径,用完配合trap清理。关于脚本安全,我的总原则是:输入严格校验、输出路径明确、权限最小化、危险操作前置检查。这些习惯不复杂,但能在关键时候把损失控制住。

7. 脚本长期维护的几点习惯

7.1 命名、注释与版本管理

脚本命名要一看就知道用途:clean_logs.sh、backup_mysql.sh、check_disk.sh,都比test1.sh、new2.sh强一百倍。命名里建议包含动词,表达"做什么"而不是"是什么"。脚本存放到统一目录,比如~/bin或/usr/local/bin,按功能分目录更清晰,比如scripts/clean、scripts/backup、scripts/monitor。

注释不需要泛滥,但文件头的用途说明、每个函数的输入输出说明、以及关键代码行中"为什么不这样写"的说明,值得认真写。我最重视的是"为什么"的注释:一行代码解释不了为什么需要这段逻辑,而"这里不直接用rm是因为..."这种注释,能帮半年后的自己快速回忆当初的决策。

版本管理方面,哪怕只是个人脚本,也建议纳入git。脚本会不断演进,能回溯每一次改动、看到什么时候改了什么,排查线上问题时价值巨大。如果嫌每次提交麻烦,可以只在脚本改动达到一定规模时提交,但至少要保留一份修订记录,比如在文件头加一行"修改历史:2024-01-01 增加磁盘水位检查"。我在自己的环境里是喜欢都用git管理的,脚本库一个仓库,分支按环境区分,发布用标签。长期下来,脚本库从"一坨快看不懂的文件"变成了"有历史有说明的工具集",维护成本明显下降。

7.2 幂等设计与锁文件

好的脚本应该可以重复运行而不产生坏结果,这叫做幂等性。举例说,初始化目录的脚本,mkdir -p /data/app/logs无论执行多少次都不报错;但如果是清空目录的脚本,不加判断跑两遍可能就删掉了不该删的东西。设计脚本时问自己一个问题:同一个脚本跑两遍、跑十遍,结果应该一样吗?如果答案应该是"一样",那就要确保关键步骤是幂等的。

常见做法包括:创建目录用mkdir -p而不是先判断再加mkdir;写文件前先检查是否已存在或先备份;删除操作前先确认目标存在且符合预期;涉及状态变更的步骤,用锁文件避免重复执行。锁文件可以简单用flock实现:

exec 9>"${script_dir}/.lock" flock -n 9 || { echo "脚本已在运行"; exit 1; }

这样能防止同一个脚本并发执行互相干扰。定时任务和手动任务指向同一个脚本时,这种做法尤其重要。

7.3 写给下一个自己

最后聊点个人的体会。脚本这行功夫,不是把语法背熟就完了,真正值钱的是踩坑之后的判断力:知道什么场景该写脚本、什么场景不该上;知道哪几行代码在运行时最容易出问题;知道出问题后第一步应该看哪里。这些能力没有捷径,就是多写多踩多总结。

我现在的习惯是,每次踩到一个不常见的坑,就在自己的笔记里记一笔,包括现象、原因、解决过程。久而久之,这本笔记就变成了自己的排障字典。这也正是我写这第十六篇笔记的初衷,很多经验不记下来,三个月后我可能还是得靠重新踩坑才能想起来。

如果你刚开始学shell脚本,我的建议很朴素:从改造自己昨天手工敲过的命令开始,今天把它写成脚本,明天给脚本加参数和错误处理,后天再试着用函数重构一遍。不用特意去找复杂案例,你日常的重复劳动就是最好的练习题。一次把一个小的需求做扎实,脚本能力就是这么积累起来的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 4:29:09

知网AI率95%怎么降?SpeedAI一键稳降到15%以内

知网AI率95%如何快速达标&#xff1f;SpeedAI一键稳降到15%以内打开知网查重报告&#xff0c;看到“AI疑似率95%”那一栏的时候&#xff0c;相信不少人跟我最初一样&#xff0c;心里是凉了半截的。临近毕业或者准备投稿&#xff0c;论文已经反反复复改了好几轮&#xff0c;结果…

作者头像 李华
网站建设 2026/10/15 4:29:03

CMake实战:C与C++混合编译的配置与符号桥接

1. 先把一个关键概念捋清楚&#xff1a;CMake不会“把.c编译成c代码”看到这个标题&#xff0c;我第一反应是&#xff1a;真正的问题多半不是“怎么把.c编译成cpp代码”&#xff0c;而是“我有一堆.c文件和.cpp文件&#xff0c;在Windows上用CMake怎么把它们组织起来、编译链接…

作者头像 李华
网站建设 2026/10/15 4:28:28

美国家用水泵前18强生产商排名及市场份额PDF深度解读与数据验证

简介&#xff1a;这份PDF调研报告聚焦美国家用水泵市场&#xff0c;面向行业分析师、投资决策者及水泵制造企业战略人员&#xff0c;帮助其快速掌握市场格局与竞争态势。报告核心内容包括2023至2029年市场规模预测、年复合增长率、前18强生产商排名及市场份额分布&#xff0c;并…

作者头像 李华
网站建设 2026/10/15 4:28:27

组织节点过滤与子项查询:从非0处理到树形递归的完整实践

1. 先把“0节点”的来龙去脉搞清楚&#xff1a;它为什么会混进来组织架构类的功能&#xff0c;无论是做权限控制、数据隔离&#xff0c;还是做部门树展示&#xff0c;基本都逃不开“按组织节点筛选数据”这个动作。我接手过好几个类似需求&#xff0c;最典型的一个是&#xff1…

作者头像 李华
网站建设 2026/10/15 4:27:24

游戏引擎事件系统架构实战:从解耦设计到性能调优

1. 为什么引擎架构里第一个该较劲的是事件系统事件系统&#xff0c;估计是每个做游戏引擎的人绕不开的坎。我在搭建某跨平台引擎原型的架构时&#xff0c;第一个认真设计、反复推翻重来的模块不是渲染管线&#xff0c;也不是内存池&#xff0c;而是这个看起来不起眼的事件系统。…

作者头像 李华