第9章 Bash 脚本工程化:shellcheck、参数展开与那些年我们踩过的坑

发布时间:2026/9/7 23:16:46
第9章 Bash 脚本工程化:shellcheck、参数展开与那些年我们踩过的坑 第9章 Bash 脚本工程化shellcheck、参数展开与那些年我们踩过的坑系列导读《Linux 从入门到高阶4 节点华为云 ECS 全实操》第 9 篇。所有实验均在真实云主机执行输出可复现。文中命令与输出取自 node2ecs-7b14-81c9-0002、node4ecs-7b14-81c9-0004真实留痕无编造。摘要 / 写在前面“一行命令能跑通为什么非要写成脚本”——这是每个工程师都经历过的阶段。答案很简单能跑的命令不等于能维护、能复用、能在凌晨三点不把你叫醒的脚本。Bash 是运维/后端/SRE 的母语但也是最容易被低估的一门语言它语法宽松、错误默认静默、引号空格差一点就行为迥异。一个没做错误处理的部署脚本可能悄悄跳过关键步骤、把半成品推上生产一个没加引号的函数遇到带空格的文件名就原地爆炸。本章带你把随手写的 shell升级成工程化脚本落地四件事语法体检bash -n上线前先查语法静态分析shellcheck在运行前揪出 200 类隐患真实安装到 node4版本 0.9.0参数展开与变量引用$varvs$var、数组、${var//pat/repl}、间接展开——全是真实脚本输出严格模式 清理set -euo pipefail与trap让脚本出错即停、退出必清。读完你能写出一份自带语法检查、静态分析零告警、出错就退、退出必清理的 Bash 脚本并避开空格、引号、命令替换、全局变量污染这四颗最常见的雷。说明node1 曾因 sshd 加固事故临时下线本章 shellcheck 安装验证落在 node4脚本运行验证落在 node2两机配置一致Ubuntu 24.04.4 LTS8 vCPU / 16 GiB结论互通。一、为什么脚本要工程化1.1 一次性命令 vs 可维护脚本维度一次性命令工程化脚本可重复性靠记忆/历史记录易漏步骤一行命令复跑步骤固化错误处理某步失败你肉眼发现set -e失败即停不雪崩可读性一串管道三天后自己看不懂函数 注释 颜色输出安全性覆盖/误删靠手稳trap清理、-i.bak备份协作只有你会跑同事bash script.sh即可1.2 工程化的四个支柱支柱1: 语法检查 bash -n script.sh ── 不执行只查语法 │ 支柱2: 静态分析 shellcheck script.sh ── 不执行查 200 类隐患 │ 支柱3: 严格模式 set -euo pipefail ── 执行时错就停、漏就崩 │ 支柱4: 清理收尾 trap ... EXIT ── 无论成败退出必清理这四个支柱正好对应运行前1、2和运行时3、4两道防线。下面逐一用真实实验验证。二、语法体检bash -nnode2 真实输出bash -n只做语法解析、不真正执行是脚本上线前的第一道闸门——尤其适合检测少了done、fi、引号不配对这类写出来能存盘但一跑就炸的低级错误。我们在 node2 上准备了一份演示脚本/tmp/inspect.sh先给它做语法体检$bash-n/tmp/inspect.shecho语法 OK bash -n 语法检查 语法 OK输出解读bash -n返回退出码 0 且打印语法 OK说明这份脚本在语法层面过关可以放心进入下一步静态分析。如果脚本里有未闭合的引号或if缺fi这里就会直接报错并给出行号根本不用等到运行时才暴露。三、静态分析神器 shellcheck真实安装 运行3.1 安装 shellchecknode4 真实输出bash -n只查语法查不出逻辑隐患比如忘了加引号会导致词分裂。shellcheck才是真正能读懂 Bash 语义的静态分析器。我们在 node4 上真实安装$exportDEBIAN_FRONTENDnoninteractive;apt-getinstall-yshellcheck/tmp/sc-install.log21;echorc$?;shellcheck--version21|head-5rc0 ShellCheck - shell script analysis tool version: 0.9.0 license: GNU General Public License, version 3 website: https://www.shellcheck.net输出解读rc0安装成功apt 退出码 0。version: 0.9.0当前 shellcheck 版本。规则库随版本迭代本文涉及的核心规则在 0.9.0 均已覆盖。3.2 第一次运行node2 真实输出把刚才那份脚本交给 shellcheck 审查$shellcheck/tmp/inspect.sh21|head-12||true shellcheck 静态检查 无任何输出零告警输出解读shellcheck 对/tmp/inspect.sh没有任何输出——这本身就是最有价值的结果它意味着这份脚本已经符合 shellcheck 的全部默认规则SC1000–SC2300 系列没有未加引号的变量、没有不安全的命令替换、没有不可移植的写法。后面几节我们就用这份零告警脚本反向拆解它做对了什么才换来零告警。注本章刻意展示零告警的真实结果而非伪造一堆报错。规则的具体含义在第 3.3 节结合标准定义讲解——shellcheck 的规则编号与提示文本是其官方稳定定义非本次实验新造数据。3.3 核心规则解读shellcheck 官方规则结合本文脚本下面这些 SC 编号是 shellcheck 最常报的高频雷区。我们逐条说明它检查什么、为什么危险、本文脚本如何规避SC 编号规则含义危险点本文脚本的规避写法SC2086Double quote to prevent globbing and word splitting未加引号的$var会被词分裂/通配符展开全脚本使用$name、${arr[]}SC2046Quote this to prevent word splitting$(...)/...结果未加引号参与命令用${arr[]}而非${arr[]}SC2155Declare and assign separately to avoid masking return valueslocal x$(cmd)会吞掉cmd的退出码double(){ local x$1; ... }分开SC2181Check exit code directly instead of$?cmd; if [ $? -eq 0 ]易错且啰嗦直接[ -f /etc/os-release ] c_ok ...SC1090 / SC1091Can’t follow non-constant sourcesource $var路径动态无法静态分析本文未用动态 sourceSC2016Expressions don’t expand in single quotes单引号里写$()不展开需展开处用双引号/拼接SC1009 / SC1073语法结构解析失败if/for/while括号/关键字用错已通过bash -n前置拦截逐条展开SC2086最重要echo $files当filesa b.txt c.txt时会被拆成三个参数文件名里的空格直接让脚本把一个文件当成三个文件。本文脚本一律$name、${arr[]}因此零告警。SC2046for f in $(ls)是典型的反模式——ls输出一旦含空格就分裂。本文用for i in ${arr[]}遍历数组根本不依赖命令替换。SC2155local x$(somecmd)会把somecmd的退出码覆盖成local的退出码导致错误被吃掉。本文double()写成local x$1赋的是参数不是命令规避此坑。SC2181与其grep x file; if [ $? -eq 0 ]不如直接if grep -q x file。本文[ -f /etc/os-release ] c_ok os-release 存在正是这种直接判断的写法。3.4 更多高频 SC 规则速查官方定义结合本文除了 3.3 的七条下面这些也常在 Code Review 里出现提前认知能少改好几轮SC 编号含义典型触发写法推荐改法SC2089 / SC2090Quoted arrays don’t work inexport/赋值export arr(a b)用arr(a b); export arr或逐元素SC2034Variable assigned but not used定义了DEBUG1却没用删掉或用上CI 里可用-W noqa抑制SC2126Considergrep -cinstead ofgrep | wc -lgrep x f | wc -l直接grep -c x fSC2143Usegrep -qinstead ofgrep /dev/nullif grep x f /dev/nullif grep -q x fSC2164cdmay fail, use cd …exitSC2230Usewhichonly for PATH, prefercommand -vif which gitif command -v gitSC2002Uselesscatcat f | grep xgrep x f逐条要点SC2164尤其致命cd失败后脚本仍在原目录继续执行后续相对路径操作可能误改/误删别的目录文件。配合set -e时cd失败本应退出但若cd写在if/里被吞掉错误就绕过了-e。养成cd dir || exit 1的习惯。SC2002 / SC2126 / SC2143是写法冗余类——不影响正确性但暴露还没用对工具静态分析帮你顺手优化。SC2230的command -v比which更可移植POSIX 规定which在部分最小化镜像里甚至不存在。本文用command/内置判断更安全。3.5 把 shellcheck 接进 CI工程化收口光自己跑不够要在每次提交时自动跑。Git 仓库里加一个 pre-commit 钩子或 CI 步骤即可# .github/workflows/shellcheck.yml 思路或 pre-commitfindscripts-name*.sh-print0|xargs-0shellcheck这样任何新引入的未加引号变量 / 不安全命令替换都会在合并前被卡住——这是工程化从个人习惯升级为团队防线的关键一步。四、参数展开与变量引用node2 真实输出参数展开Parameter Expansion是 Bash 最强大也最易被忽视的特性。下面全部来自 node2 上/tmp/inspect.sh的真实运行结果。先给出脚本里的关键片段已还原终端乱码为正确中文name${1:-world}# 默认值echo你好,$name| 脚本$0参数个数$#PID$$arr(cpu mem disk net);echo组件:${arr[*]}数量:${#arr[]}foriin${arr[]};doecho -$i;donesLinux.From.Beginner.To.Expertecho点换空格:${s//./}echo去最短后缀:${s%.*}去最短前缀:${s#*.}echo逻辑核两倍:$(($(nproc)*2))[-f/etc/os-release]c_okos-release 存在double(){localx$1;echo$((x*2));}echodouble(21) $(double21)运行$bash/tmp/inspect.sh 数据中心你好, 数据中心 | 脚本/tmp/inspect.sh 参数个数1 PID30533 组件: cpu mem disk net 数量: 4 - cpu - mem - disk - net 点换空格: Linux From Beginner To Expert 去最短后缀: Linux.From.Beginner.To 去最短前缀: From.Beginner.To.Expert 逻辑核两倍: 16 [OK] os-release 存在 loop 1 loop 2 loop 3 double(21) 42 [OK] 检查完成 [cleanup] 退出码 0 CH9_DONE4.1$varvs$var——加不加引号天差地别name${1:-world}${1:-world}是默认值展开——若调用时没传第一个参数name取world传了数据中心就取数据中心。真实输出你好, 数据中心印证了传参生效。整行用双引号包裹你好, $name | 脚本$0 ...保证即使$name含空格也当作一个整体输出不会被词分裂。这就是 SC2086 要求的写法。$0脚本名/tmp/inspect.sh$#参数个数1$$当前进程 PID30533。输出解读PID30533在排查哪个进程跑的脚本kill 残留子进程时极有用。4.2 数组展开${arr[]}vs${arr[*]}真实输出组件: cpu mem disk net 数量: 4 - cpu - mem - disk - netarr(cpu mem disk net)定义数组${#arr[]}取元素个数4${arr[*]}把所有元素用IFS首个字符默认空格拼成一个字符串cpu mem disk net。关键区别遍历时必须写${arr[]}带引号、 符号它会把每个元素作为独立参数传出若写成${arr[*]}或${arr[]}缺引号一旦某元素含空格就会被拆裂。本文用${arr[]}输出工整地逐行- cpu / - mem / - disk / - net正是正确展开的证明。4.3 字符串展开${s//pat/repl}、${s%.*}、${s#*.}真实输出点换空格: Linux From Beginner To Expert 去最短后缀: Linux.From.Beginner.To 去最短前缀: From.Beginner.To.Expert${s//./ }全局替换把字符串里所有.换成空格 →Linux From Beginner To Expert。注意是双斜杠//表示全部替换单斜杠${s/. / }只换第一个。${s%.*}去最短后缀%从右删最短匹配。Linux.From.Beginner.To.Expert从右找第一个.及之后删掉 →Linux.From.Beginner.To。${s#*.}去最短前缀#从左删最短匹配。从左找第一个.及之前删掉 →From.Beginner.To.Expert。工程价值这三个展开是文件名/路径处理的瑞士军刀——${file%.*}取不带扩展名的文件名${file##*.}取扩展名${path#*/}去首段路径。比sed/awk更轻、纯 Bash 内完成、无子进程开销。4.4 算术展开$(( ... ))真实输出逻辑核两倍: 16$(( $(nproc) * 2 ))内层$(nproc)取 CPU 逻辑核数node2 为 8 vCPU外层$((...))做算术 →8 * 2 16。输出解读拿到 16反向印证该机确为 8 vCPU8×2。算术展开比expr古老写法更安全、无命令替换开销。4.5 间接展开与默认值进阶除了上面的${1:-world}Bash 还有${var:-default}变量为空或未设 → 用 default本文即用此${var:default}为空则赋值default 后再用${var:alt}变量非空则用 alt间接展开${!ref}当refvar时${!ref}取出var的值——用于变量名动态拼接场景如按arg1/arg2拼出变量名。本文脚本用最实用的${1:-world}演示默认值已足够覆盖 90% 的参数可选需求。4.6 更多实用展开速查纯 Bash 内完成零子进程参数展开是能背下来就受益终身的特性。再列一组高频写法语义基于 Bash 官方定义配合上面真实脚本的验证思路可在你机器复跑确认写法含义典型用途${var:-default}为空/未设则用 default参数可选默认值本文已用${var:default}为空则赋值default 再返回懒初始化配置项${var:alt}非空则用 alt“有值才追加”${var:?err}为空则打印 err 并退出强制必填参数配合set -u双保险${var#pattern}去最短前缀匹配去路径首段${var##pattern}去最长前缀匹配取文件名去全部路径${var%pattern}去最短后缀匹配去扩展名${var%%pattern}去最长后缀匹配去最后一段${var/pat/repl}替换首个pat定点替换${var//pat/repl}替换全部pat全局清洗本文已用${!arr[*]}/${!arr[]}数组下标列表遍历下标${#var}字符串长度 / 数组元素数计数本文${#arr[]}${!ref}间接展开变量名的值当变量名动态变量名重点记忆#向左删、%向右删单个#/%删最短双个##/%%删最长。这组展开几乎能替代 80% 你本来想用sed/awk/basename/dirname做的文件名/路径处理而且全在 Bash 进程内完成、不 fork 子进程在循环里处理海量文件时性能优势明显。本文脚本里点换空格/去后缀/去前缀三个例子正是这张表的实景演出。五、set -euo pipefail 与 trap 清理node2 真实输出5.1 严格模式set -euo pipefail脚本第一行就是set-euopipefail逐字拆解-eerrexit任何命令返回非 0立刻终止脚本。避免前面 step1 失败后面 step2/3 还接着跑最后产出一个半成品还报成功。-unounset引用未定义变量直接报错退出。防止把空变量当真值用如rm -rf /$DIR而DIR为空 → 删根。-o pipefail管道中任一段失败整条管道返回非 0。默认情况下cmd1 | cmd2只看cmd2的退出码若cmd1悄悄失败你发现不了pipefail堵上这个洞。5.2trap清理真实输出trapecho [cleanup] 退出码 $?EXIT脚本无论正常结束还是因-e中途退出EXIT陷阱都会执行。真实运行末尾印证了它[OK] 检查完成 [cleanup] 退出码 0 CH9_DONE输出解读[cleanup] 退出码 0出现在[OK] 检查完成之后、脚本真正退出之前正是trap ... EXIT触发——哪怕脚本中途因-e失败这段清理也照样跑用来删临时文件、杀后台子进程、恢复被改的配置。$?在 trap 里是触发退出时的退出码0 表示正常。若是出错退出$?会是非 0清理逻辑可据此决定要不要告警。⚠️ 危险操作标注生产脚本里trap常见写法是清理临时目录rm -rf $TMPDIR。务必保证$TMPDIR加了引号且确实被赋值配合set -u否则rm -rf /的惨剧就源于一个未定义的空变量。5.3 调试技巧set -x与PS4当你看不懂脚本在干嘛静态分析查不出所有问题运行时脚本行为跟你想象的不一样时用set -x打开命令跟踪每一步执行的命令和它展开后的真实参数都会打印出来set-x# 打开跟踪name${1:-world}arr(cpu mem disk net)setx# 关闭跟踪配合PS4还能给每行跟踪加上文件名:行号:函数前缀定位更易exportPS4 ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]} # 跟踪行显示来源set-xdouble21setx输出解读set -x打印的行以开头显示变量已被展开的真实命令——当你怀疑某个$var是不是空了 / 是不是被词分裂了看后面那行就一目了然。注意set -x只适合调试生产脚本别长期开着会刷屏且可能把敏感参数打到日志里 ⚠️。它是set -e/shellcheck之外的第三只眼三者互补shellcheck 防写法、set -e 防静默失败、set -x 防看不懂。六、函数与返回值node2 真实输出double(){localx$1;echo$((x*2));}echodouble(21) $(double21)double(21) 42输出解读double()用local x$1把第一个参数收进局部变量x避免污染全局呼应第 7.4 节的全局变量污染坑再echo $((x*2))输出结果。函数返回值在 Bash 里分两层return只能返回 0–255 的整数状态码适合表示成功/失败要返回数据得靠echo打印、调用方用$(...)捕获。这里$(double 21)捕获到42正是用 stdout 传值的标准范式。注意local x$1而非local x$(cmd)——正是为了规避 SC2155见 3.3 节不吞掉命令退出码。七、常见坑结合真实经验下面这些坑每一个都能让你在深夜被叫醒。它们大多对应 shellcheck 的某条规则但理解为什么比记住编号更重要。7.1 空格Bash 里空格是语法不是格式# 错 两边有空格Bash 会把 x 当成命令x5# 对赋值等号两边不能有空格x5# 错if 后缺空格[ 是命令必须留空if[$x-eq1];then...# 对if[$x-eq1];then...[其实是个命令等价于test所以它和参数之间必须有空格]前也要有空格。bash -n能在运行前抓出这类错误。7.2 引号不加引号空格就是分裂炸弹filesreport final.txtcp$files/backup/# 炸被拆成 cp report final.txt /backup/找不到文件 reportcp$files/backup/# 对整体作为一个参数若本意是多个文件应改用数组正解是用数组files(report\ final.txt); cp ${files[]} /backup/。这正是第 4.2 节${arr[]}写法存在的意义也是 SC2086/SC2046 反复强调的。7.3 命令替换反引号 vs$()以及它的陷阱# 反引号旧式嵌套难读已不推荐nowdate# $() 推荐可读、可嵌套now$(date)命令替换的隐藏坑它会自动剥掉结尾的换行符且结果若不加引号仍会被词分裂。经验凡$(...)的结果要当整体用一律var$(...)加引号。本文逻辑核两倍用$(nproc)后立刻放进$((...))算术上下文无引号问题。7.4 全局变量污染函数里随手赋值会漏到外面process(){tmp/tmp/leak# 没用 local → 污染全局...}processecho$tmp# 外面能读到 /tmp/leak意外副作用正解函数内所有临时变量都用local声明如本文double(){ local x$1; ... }。否则多函数协作时变量互相覆盖bug 极难排查。SC2155/未声明 local 是这类问题的温床。7.5 管道里的 while 读变量读不到# 坑while 在管道子进程里跑循环内赋值的 var 出了管道就消失catlist.txt|whilereadline;dovar$line;doneecho$var# 空# 正解用进程替换让 while 在当前 shell 跑whilereadline;dovar$line;donelist.txtecho$var# 正常这是 Bash 新手高频疑惑管道会 fork 子 shell子 shell 里的变量改动不影响父 shell。用 file或 (cmd)进程替换可规避。7.6[[vs[条件测试的现代化[$ax]# 旧式 test单词分裂/通配符风险必须加引号[[$ax]]# 新式更安全支持 || 和正则 ~无需过度引号[[ ... ]]是 Bash 关键字不会做词分裂、支持~正则匹配工程化脚本里优先用[[。7.7 其他两个高频小坑坑七echo不可靠优先printf。echo对-n、\n的处理随-x选项和平台而异BSD vs GNU 行为不同。需要精确控制格式/转义时用printf %s\n $var更可移植。本文c_ok用echo -e仅为终端上色演示生产日志输出建议改printf。坑八整数比较别用字符串运算符。[ $a 5 ]是字符串比较遇到[ 5 05 ]会判不等数值比较要用-eq[ $a -eq 5 ]或在[[ ]]里用(( a 5 ))。混用类型是大事故来源。7.8 工程化自查清单上线前 10 问把下面这 10 条贴进你的 PR 模板逐条打勾跑过bash -n script.sh且语法 OK跑过shellcheck script.sh且零告警或已评审每条告警开头有set -euo pipefail有trap ... EXIT做清理删临时文件/杀子进程所有变量引用都加了双引号$var数组遍历用${arr[]}不是${arr[*]}函数内临时变量都用local声明函数返回数据用echo$(...)捕获而非依赖returncd都写了|| exitrm -rf的路径都加了引号且确认非空有没有把set -x留在生产脚本里应删掉这 10 问覆盖了本章 90% 的工程化要点也是 shellcheck 严格模式 良好习惯的三重合集。八、小结四支柱bash -n查语法 →shellcheck查隐患node4 实装 0.9.0、node2 实跑零告警→set -euo pipefail运行时严格 →trap ... EXIT退出必清理。参数展开是基本功$var防分裂、${arr[]}遍历数组、${s//./ }全局替换、${s%.*}/${s#*.}去前后缀、${1:-world}默认值、$((...))算术——本章每个都有 node2 真实输出佐证。函数靠 stdout 返回值return只管状态码局部变量务必local防全局污染。四大雷区空格是语法、引号防分裂、命令替换要加引号、管道内变量作用域。一句话结论Bash 工程化不是多写几行而是用bash -nshellcheck两道静态闸 set -euo pipefailtrap一道运行时闸把人的疏忽挡在脚本之外。附本文实验环境 / 一键复现脚本环境node4ecs-7b14-81c9-0004shellcheck0.9.0安装验证apt-get install -y shellcheckrc0。node2ecs-7b14-81c9-0002脚本运行验证全部输出已贴于正文。两机均为 Ubuntu 24.04.4 LTS内核 6.8.0-106-generic8 vCPU / 16 GiB。node1 因 sshd 加固事故临时下线本章未依赖 node1。一键复现在任意节点上执行#!/usr/bin/env bash# 第9章一键复现安装 shellcheck 跑通工程化脚本set-euopipefail# 1) 安装 shellchecknode4 已验证 rc0, version 0.9.0exportDEBIAN_FRONTENDnoninteractiveapt-getinstall-y-qqshellcheck/dev/null21shellcheck--version|head-1# 2) 落地演示脚本cat/tmp/inspect.shEOF #!/usr/bin/env bash set -euo pipefail trap echo [cleanup] 退出码 $? EXIT c_ok(){ echo -e \033[32m[OK]\033[0m $*; } name${1:-world} echo 你好, $name | 脚本$0 参数个数$# PID$$ arr(cpu mem disk net); echo 组件: ${arr[*]} 数量: ${#arr[]} for i in ${arr[]}; do echo - $i; done sLinux.From.Beginner.To.Expert echo 点换空格: ${s//./ } echo 去最短后缀: ${s%.*} 去最短前缀: ${s#*.} echo 逻辑核两倍: $(( $(nproc) * 2 )) [ -f /etc/os-release ] c_ok os-release 存在 double(){ local x$1; echo $((x*2)); } echo double(21) $(double 21) c_ok 检查完成 EOF# 3) 两道静态闸echo bash -n 语法检查 ;bash-n/tmp/inspect.shecho语法 OKecho shellcheck 静态检查应为空零告警;shellcheck/tmp/inspect.sh||true# 4) 运行echo 运行 ./inspect.sh 数据中心 ;bash/tmp/inspect.sh 数据中心命令汇总目标命令语法体检bash -n script.sh静态分析shellcheck script.sh严格模式set -euo pipefail退出清理trap cmd EXIT默认值${var:-default}全局替换${s//pat/repl}去后缀/前缀${s%.*}/${s#*.}数组遍历for i in ${arr[]}算术$(( $(nproc) * 2 ))函数返回值echo输出 $(func)捕获全部输出取自 node2 / node4 真实执行公网 IP 已按规范脱敏为121.36.x.x主机名ecs-7b14-81c9-000X按规范保留。