Shell 脚本这东西,很多人第一次接触是在“不得不写”的时候——服务器上要批量改个配置、日志要按天切分归档、部署流程里有一串命令每次都要手动敲一遍。于是打开编辑器,凭感觉拼几行命令,能跑就行,跑完就忘。等到下次再需要改的时候,翻出那坨三个月前写的脚本,自己都看不懂当时在想什么。这篇内容就是写给这类人的:不追求把 Shell 讲成一本语法手册,而是把日常真正用得上的那部分——变量怎么管、条件怎么判、循环怎么写、函数怎么拆、出错怎么查——按实际写脚本的顺序捋一遍,顺带把那些“文档里不写、踩过才知道”的坑一并交代清楚。不管你是刚接手运维工作的新手,还是写惯了高级语言、偶尔要碰 Shell 的后端,下面这些内容都能直接拿去改改用。
1. 先把 Shell 脚本当成一门“胶水语言”来理解
1.1 Shell 到底在替你做什么
很多人学 Shell 卡在第一步,是因为把它当成了一门“编程语言”来学,结果被一堆奇怪的语法符号劝退。换个角度就通了:Shell 脚本本质上是把你在终端里一条条敲的命令,按顺序写进文件里,再交给解释器批量执行。它的核心能力不是计算,而是调度——调用系统命令、串联工具、根据结果决定下一步做什么。
理解这一点,后面很多设计就顺了。比如为什么 Shell 里字符串处理那么别扭?因为它本来就不擅长这个,字符串处理应该交给awk、sed、cut这些专门的工具。为什么 Shell 没有复杂的数据结构?因为它定位就是胶水,复杂逻辑该用 Python 就用 Python。我见过不少脚本写得又长又绕,仔细一看是在用 Shell 硬做 JSON 解析、硬做复杂数学运算,这就属于用错了工具。
所以写 Shell 的第一条心法:能用一条命令解决的,别写十行逻辑;能交给专业工具干的,别自己造轮子。脚本的价值在于把零散命令组织成可靠的流程,而不是证明你会在 Shell 里写算法。
1.2 解释器声明与执行方式的选择
脚本第一行那个#!/bin/bash不是装饰。它叫 shebang,作用是告诉系统用哪个解释器来跑这个文件。这里有个实际选择:写#!/bin/bash还是#!/bin/sh?
sh是 POSIX 标准的最小集合,bash是它的超集,多了数组、[[ ]]、字符串替换等一堆便利功能。如果你的脚本只在 Linux 上跑,直接上bash省心;如果要考虑在精简环境(比如某些容器基础镜像里只有sh)里运行,就得老老实实按 POSIX 写,别用 bash 特有语法。我个人的习惯是:内部运维脚本一律#!/bin/bash,需要跨环境分发的才退回sh,因为 bash 的语法容错和调试信息都更友好。
执行方式也有讲究。bash script.sh是显式指定解释器执行,./script.sh要求文件有可执行权限(chmod +x)且 shebang 正确,source script.sh则是在当前 shell 环境里执行——这意味着脚本里定义的变量、改的环境变量会留在当前会话里。最后这个特性经常被误用:有人source一个脚本想跑完就完事,结果脚本里cd了目录、改了PATH,回到终端发现环境全变了。要隔离就用前两种,要共享环境才用source。
1.3 一个最小可用脚本的骨架长什么样
别小看骨架,很多人脚本出问题就出在开头几行没写对。一个我常用的起手式大概是这样:
#!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_FILE="${SCRIPT_DIR}/run.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" }逐行说下为什么这么写。set -euo pipefail是三道保险:-e让命令失败时脚本立即退出(而不是继续往下跑出一堆连锁错误),-u让引用未定义变量时报错(防止变量名拼错导致空值悄悄传下去),pipefail让管道中任意一环失败都能被捕获(默认管道只看最后一个命令的退出码,前面失败了也发现不了)。这三条能挡掉日常脚本里一大半的“莫名其妙”问题。
SCRIPT_DIR那行解决的是“脚本被从任意目录调用时,相对路径全乱套”的经典问题。它通过BASH_SOURCE拿到脚本自身路径,再取目录名转绝对路径。有了它,脚本里所有文件引用都可以基于$SCRIPT_DIR拼,不管从哪儿执行都不会错。这个技巧值得背下来,几乎每个正经脚本都用得上。
2. 变量、引号与那些让人抓狂的展开规则
2.1 变量赋值为什么等号两边不能有空格
这是新手第一个绊脚石:name = "value"会报错,必须写name="value"。原因在于 Shell 的解析逻辑——它把空格当作命令和参数的分隔符,name = "value"会被理解成“执行一个叫name的命令,参数是=和value”。Shell 的赋值是语法层面的特殊形式,不是普通命令,所以不能有空格。
赋值时还有个小细节:name="value"里的引号在赋值阶段其实会被去掉,name拿到的就是value。但如果值里有空格,引号就必须留着,否则name=hello world会被解析成“赋值name=hello,然后执行命令world”。所以养成习惯:赋值时值里可能有空格就加引号,永远加也不亏。
2.2 单引号、双引号、反引号的区别用一张表说清
引号是 Shell 里最容易混淆的部分,但规律其实很清晰:
| 引号类型 | 变量是否展开 | 命令是否执行 | 特殊字符处理 | 典型用途 |
|---|---|---|---|---|
单引号'...' | 否 | 否 | 全部原样保留 | 写死不含变量的字面量 |
双引号"..." | 是 | 否(反引号除外) | $、`、\有特殊含义 | 绝大多数日常场景 |
反引号`...` | 是 | 是 | 执行命令并替换为输出 | 旧式命令替换,建议改用$() |
反引号现在基本被$()取代了,因为$()支持嵌套、可读性更好。比如echo "今天是 $(date +%F)"就比反引号版本清晰得多。我建议新写的脚本一律用$(),反引号只在读老代码时认识就行。
这里有个高频坑:变量不加引号导致的问题。看这段:
file="my document.txt" rm $file # 危险!会被拆成 rm my 和 document.txt 两个参数 rm "$file" # 正确,整体作为一个参数如果$file的值里有空格或通配符(比如*),不加引号就会被 Shell 二次拆分或展开,轻则命令行为不对,重则误删文件。所以经验法则:变量展开时,除非你明确知道要分词,否则一律加双引号。这条能避免大量诡异 bug。
2.3 参数展开的进阶用法:默认值、截取、替换
Shell 的变量展开远不止$var这么简单,掌握几个进阶写法能省掉很多 if 判断:
# 默认值:变量为空或未定义时用默认值 port="${PORT:-8080}" # 赋值默认值:为空时顺便赋上 : "${LOG_DIR:=/var/log/myapp}" # 字符串截取 path="/data/logs/app.log" echo "${path##*/}" # app.log,去掉最后一个 / 及之前的内容 echo "${path%/*}" # /data/logs,去掉最后一个 / 及之后的内容 # 字符串替换 echo "${path/logs/logs_bak}" # /data/logs_bak/app.log,替换第一个匹配 echo "${path//\//_}" # _data_logs_app.log,替换所有匹配${var:-default}这个写法在脚本里出现频率极高,比如读取配置时db_host="${DB_HOST:-127.0.0.1}",既支持环境变量覆盖,又有合理默认值,比写一堆if [ -z "$DB_HOST" ]干净得多。##*/取文件名、%/*取目录名这两个也值得记牢,比调basename、dirname命令快,还不用起子进程。
3. 条件判断:test、[ ] 与 [[ ]] 的取舍
3.1 三种写法到底该用哪个
Shell 里做条件判断有三种常见写法:test、[ ]、[[ ]]。它们的关系是:[其实就是test命令的别名(所以[后面和]前面必须有空格),而[[ ]]是 bash 内置的增强版。
我的建议很直接:写 bash 脚本就用[[ ]],需要 POSIX 兼容才退回[ ]。原因有三:[[ ]]里变量不用加引号也不会分词(更安全)、支持&&||直接连接、支持正则匹配=~。而[ ]里如果变量为空又没加引号,会直接报语法错误,这是新手最常遇到的坑之一。
# [ ] 的经典坑:变量为空时报错 if [ $name = "admin" ]; then # name 为空时变成 [ = "admin" ],语法错误 if [ "$name" = "admin" ]; then # 加引号才安全 # [[ ]] 则没这个问题 if [[ $name = "admin" ]]; then # 安全3.2 文件判断与数值比较的常用清单
日常脚本里文件判断用得最多,整理成表方便查:
| 判断 | 含义 | 判断 | 含义 |
|---|---|---|---|
-e file | 存在 | -f file | 存在且是普通文件 |
-d dir | 存在且是目录 | -r file | 可读 |
-w file | 可写 | -x file | 可执行 |
-s file | 存在且非空 | -L file | 是符号链接 |
数值比较要注意:[ ]里用-eq-ne-gt-lt-ge-le,而[[ ]]和(( ))里可以直接用><==。字符串比较用=和!=(注意是单等号,不是==,虽然 bash 里==也行但 POSIX 不保证)。这里有个反直觉点:[ 1 > 2 ]不会报错,但结果是字符串比较,"1" > "2"按字典序是假,看起来“对”了纯属巧合;换成[ 10 > 2 ]就变成假了,因为"10" < "2"。所以数值比较一定用-gt这类,别用>。
3.3 case 分支:比一长串 if-elif 更清爽的场景
当判断条件是基于同一个变量的多个固定取值时,case比if-elif链可读性好太多:
case "$1" in start) start_service ;; stop) stop_service ;; restart) stop_service && start_service ;; *) echo "用法: $0 {start|stop|restart}" >&2 exit 1 ;; esaccase支持通配符匹配,比如*.log)能匹配所有以.log结尾的参数,这在处理文件类型分发时特别顺手。*)作为兜底分支一定要写,否则参数不匹配时脚本会静默什么都不做,排查起来很费劲。另外注意每个分支结尾的;;不能漏,漏了会继续往下执行下一个分支(这个行为偶尔能被利用来做 fallthrough,但日常不建议依赖它)。
4. 循环与文本处理:Shell 真正的主战场
4.1 for 循环的三种典型形态
Shell 的for循环有三种常见写法,用途各不相同:
# 形态一:遍历列表 for env in dev test prod; do echo "部署到 $env" done # 形态二:遍历命令输出(按行) while IFS= read -r line; do echo "处理: $line" done < <(cat filelist.txt) # 形态三:C 语言风格计数 for ((i = 0; i < 10; i++)); do echo "第 $i 次" done这里要重点说形态二。很多人写for line in $(cat file.txt),这在文件内容不含空格时没问题,但一旦某行有空格,就会被拆成多个词。正确做法是用while read配合重定向。IFS=的作用是保留行首行尾的空白,-r防止反斜杠被转义,这两个参数几乎是标配,建议形成肌肉记忆。< <(...)是进程替换,把命令输出当成文件喂给循环,比管道更灵活(管道会让循环在子 shell 里跑,循环内改的变量外面读不到,这是个隐蔽的坑)。
4.2 管道与子 shell 的变量丢失问题
刚才提到的子 shell 坑值得单独拎出来讲,因为它太隐蔽了:
count=0 cat file.txt | while read -r line; do ((count++)) done echo "$count" # 输出 0!因为循环在子 shell 里跑,count 的修改没传出来管道右边的命令会在子 shell 中执行,子 shell 里的变量修改不会影响父 shell。解决办法有两个:一是改用进程替换while ... done < <(cat file.txt),循环在当前 shell 跑;二是用{ ... }把整段包起来重定向。我一般首选进程替换,写法直观。这个坑在统计行数、累加计数时特别容易中招,明明逻辑没错结果就是 0,第一次遇到能查半天。
4.3 awk、sed、grep 三件套的定位分工
Shell 脚本做文本处理,绕不开这三个工具。它们的定位其实很清楚:
- grep:按模式筛选行,输出匹配的行。适合“找出包含某关键字的日志”。
- sed:按规则编辑文本流,做替换、删除、插入。适合“把配置文件里某个值改掉”。
- awk:按字段处理,做列提取、条件统计、格式化输出。适合“从日志里提取第 3 列并求和”。
举个实际例子,从访问日志里统计每个 IP 的请求数并取前 10:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10这一行里awk取第一列(IP),sort排序让相同 IP 相邻,uniq -c计数,再按次数倒序,最后取前 10。这种“小工具串联”正是 Shell 的精髓——每个工具只做一件事,用管道组合出复杂能力。写这类命令的关键是想清楚数据在每一步的形态:一开始是原始行,awk后变成纯 IP 列表,uniq -c后变成“次数 IP”的格式,心里有这条数据流,命令就不会写错。
5. 函数、错误处理与调试:让脚本从“能跑”到“可靠”
5.1 函数怎么写才不给自己挖坑
Shell 函数定义很简单,但有几个约定值得遵守:
backup_file() { local src="$1" local dst="$2" if [[ ! -f "$src" ]]; then echo "源文件不存在: $src" >&2 return 1 fi cp "$src" "$dst" }第一,参数用local声明。Shell 函数里的变量默认是全局的,不声明local会污染外部同名变量,这种 bug 极难排查。第二,用return返回状态码,用echo输出数据,别指望return能返回字符串(它只能返回 0-255 的整数)。第三,错误信息输出到 stderr(>&2),这样正常输出和错误输出能分开重定向,调用方好处理。
函数返回值这块还有个常见误解:return 1之后,调用方要用if ! backup_file a b; then或backup_file a b || exit 1来捕获,而不是result=$(backup_file a b)然后判断$result——后者拿到的是函数的标准输出,不是返回值。这个区分清楚,函数用起来就顺了。
5.2 set -e 不是万能的:哪些情况它管不住
前面推荐了set -e,但它有几个“失灵”场景必须知道:
- 命令在
if条件里:if grep -q foo file; then中grep失败不会触发退出,因为它在条件位置,这是设计如此。 - 命令用
&&或||连接:cmd1 && cmd2里cmd1失败不会退出,因为整个表达式的结果由cmd2决定。 - 管道中间的命令:默认只看最后一个,需要
set -o pipefail才能捕获。 - 命令替换里:
result=$(false)不会让脚本退出。
所以set -e是道保险,但不能完全依赖。关键操作我还是会显式判断:
if ! cp "$src" "$dst"; then log "复制失败: $src -> $dst" exit 1 fi显式判断的好处是能加上下文日志,出问题时知道是哪一步、哪个文件出的错,比set -e直接静默退出强得多。
5.3 调试三板斧:-x、-n、shellcheck
脚本不按预期跑,先别急着加 echo。三个手段按顺序上:
第一,bash -n script.sh做语法检查,不执行只解析,能提前发现括号不匹配、fi漏写这类问题。第二,bash -x script.sh打开执行跟踪,会打印每一行展开后的实际命令(前面带+),变量到底展开成了什么一目了然。跟踪输出太多时可以配合PS4加上行号:export PS4='+${LINENO}: '。第三,用shellcheck做静态检查,它能揪出引号没加、变量没用、[ ]用法错误等一大堆隐患,几乎每个脚本跑一遍都能发现几个问题。我现在的习惯是新脚本写完先过一遍 shellcheck,把警告清掉再测,能省掉大量低级调试时间。
提示:
set -x的跟踪信息里会包含变量的实际值,如果脚本处理的是密码、密钥这类敏感内容,调试完记得关掉,别把跟踪日志留在生产环境。
6. 几个真实场景的脚本拆解与经验
6.1 日志按天归档:一个完整可复用的例子
需求很常见:每天把超过 N 天的日志压缩归档,清理过期文件。直接上脚本:
#!/bin/bash set -euo pipefail LOG_DIR="${1:-/var/log/myapp}" KEEP_DAYS="${2:-7}" ARCHIVE_DIR="${LOG_DIR}/archive" mkdir -p "$ARCHIVE_DIR" # 归档 1 天前的 .log 文件 find "$LOG_DIR" -maxdepth 1 -name "*.log" -mtime +0 | while IFS= read -r f; do base="$(basename "$f" .log)" gzip -c "$f" > "${ARCHIVE_DIR}/${base}_$(date +%Y%m%d).log.gz" rm -f "$f" done # 清理超过保留期的归档 find "$ARCHIVE_DIR" -name "*.log.gz" -mtime +"$KEEP_DAYS" -delete这个脚本有几个设计点值得说。参数用${1:-默认值}读取,既支持调用时传参又有兜底。find ... | while read而不是for f in $(find ...),避免文件名含空格出问题。gzip -c输出到指定文件而不是原地压缩,是为了控制归档文件的命名格式(带日期),方便后续按名检索。清理用-mtime +N配合-delete,一行搞定。
实际跑的时候有个坑:如果LOG_DIR下文件很多,find输出可能很大,while read逐行处理是流式的没问题,但要注意别在循环里做太重的操作。另外-mtime +0表示“修改时间在 1 天以前”,这个+0的语义容易记混——-mtime 0是 24 小时内,-mtime +0是超过 24 小时,-mtime +7是超过 7 天。建议第一次用先在测试目录跑一遍,find加-print确认选中的文件对不对,再上删除操作。
6.2 批量处理时如何避免“跑一半失败”
批量操作最怕的是跑到一半出错,前面改了一半、后面没改,状态不一致。我的处理套路是先干跑、再确认、后执行:
DRY_RUN="${DRY_RUN:-true}" for host in $(cat hosts.txt); do if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] 将在 $host 上执行部署" else echo "部署到 $host" deploy_to "$host" || { echo "失败: $host"; exit 1; } fi done用环境变量控制干跑模式,默认true,确认无误后DRY_RUN=false ./deploy.sh才真正执行。这个模式在批量改配置、批量重启这类操作上特别有用,能提前看到影响范围。另外循环里失败就exit 1中断,而不是继续往下跑——批量操作里“部分成功”往往比“全部失败”更难收拾,宁可停下来人工介入。
6.3 脚本里调用外部命令的健壮性细节
最后聊几个调用外部命令时的经验。第一,命令是否存在要先检查,尤其是依赖第三方工具的脚本,开头加一句command -v jq >/dev/null || { echo "需要 jq"; exit 1; },比跑到一半报“command not found”友好得多。第二,路径尽量用绝对路径或基于$SCRIPT_DIR拼,别依赖当前工作目录,否则换个地方调用就找不到文件。第三,临时文件用mktemp创建,别自己拼/tmp/xxx,避免并发冲突和权限问题,用完记得trap清理:
tmpfile="$(mktemp)" trap 'rm -f "$tmpfile"' EXITtrap ... EXIT保证脚本无论正常结束还是异常退出都会清理临时文件,这个习惯能避免 /tmp 越积越脏。第四,输出重定向分清 stdout 和 stderr,正常结果走 stdout 方便被其他命令消费,日志和错误走 stderr,这样./script.sh > result.txt时错误信息还能在终端看到。
写 Shell 这些年,最大的体会是:脚本的可靠性不取决于你用了多高级的语法,而取决于你有没有把边界情况想全——变量会不会为空、文件会不会不存在、命令会不会失败、路径会不会带空格。上面这些写法看着琐碎,但每一条背后都是一次真实的翻车。把这些当成默认习惯,脚本的“莫名其妙”问题会少一大半。