Shell脚本从能跑到扛造:错误处理与安全实践指南
发布时间:2026/9/15 23:27:38 作者:尧图编辑部 阅读量:1,286

写 Shell 脚本这事看起来门槛低到不行——把几条命令写进一个文件加个执行权限跑起来就完事了。很多人的自动化之路就是这么开始的。但我在生产环境里见过太多“跑一次炸一次”的脚本删库的、删备份的、把线上配置覆盖的几乎每一个事故背后都对应着同一个问题写脚本的人只想着让它“能跑”没想过让它“别乱跑”。这篇就想跟你认真聊聊Shell 脚本怎么从“能跑”进化到“扛造”别让你的自动化最终变成“自爆化”。我会把这次的内容按这几块来展开先讲清楚 Shell 脚本翻车的底层原因再梳理写脚本时必须守住的基础纪律接着重点讲错误处理机制然后用真实案例做问题排查最后聊聊日志、幂等性和安全习惯。无论你刚入门还是已经写了几年脚本我相信总有几个点能帮你在下一次踩坑之前反应过来。1. 先搞清楚脚本为什么会“翻车”1.1 Shell 的本质决定了它的风险等级很多人对 Shell 脚本有个误解觉得它就是“把命令存了起来”。对但也不对。严格说Shell 脚本是一条一条解释执行的命令序列执行时和你在终端里手动敲命令没有任何区别——它能做的事和你手动敲命令能做的事一样多破坏力也一样大。问题就出在这里。手动敲命令时你有实时反馈敲完一条看一条的输出发现不对劲可以马上按 CtrlC。但脚本一旦开始执行它不会停下来问你“确定吗”不会因为命令看起来危险就跳过它就老实巴交地把每一条命令都跑完直到最后一条或者第一条报错。更要命的是编译器会给高级语言做类型检查、语法检查、越界检查但 Shell 脚本没有这个阶段。写错一个变量名它不会在“编译期”告诉你而是在“执行期”静默地用空值代替。绝大多数 Shell“自爆”事故根源都是这四个字空变量、拼错名、少引号、不检查。它们单个看都是小事拼在一起就是灾难。1.2 新手最容易踩的三个认知误区第一个认知误区是“能跑就行”。一条命令你手动执行成功了不代表把它写进脚本就安全。手动执行成功是因为当时的目录、当时的变量值、当时的文件状态刚好满足条件脚本在不同时间、不同机器、不同用户下去跑状态完全不同失败方式也千奇百怪。第二个误区是“脚本出错会自己停”。默认情况下Shell 执行完一条命令不管成功失败都会接着跑下一条。你写了一个cd /data/project这行失败了但脚本不会停它会继续执行后面的rm -rf *.log这时脚本是在什么目录下执行的这完全取决于上一步失败时停在哪。真正干过运维的人都知道这种“继续执行”的默认行为是无数事故的起点。第三个误区是“我的环境永远不变”。脚本在你自己机器上没问题换到服务器上可能就报错在 bash 下正常换 sh 就歇菜交互式终端里跑得好好的放进 crontab 里就啥也不输出。环境差异是 Shell 脚本最大的隐形杀手最典型的例子就是 PATH 变量终端里加载了你的配置文件所以能找到某个命令cron 环境里什么都没有命令直接 not found。2. 基础纪律写好脚本的第一步是不出事2.1 变量与引号你的脚本是“瞬间爆炸”还是“毫发无伤”往往取决于一对引号我先说结论在 Shell 里所有变量展开的地方请默认加双引号。这句话值得你刻在脑子里。不加引号到底会发生什么我给你看一个我真实经历过的例子。有人写了个清理备份的脚本核心就一句rm -rf $project/backup他想象中这行代码是这样执行的rm -rf /data/myproject/backup结果呢某个环境里project变量没传进来是空的这一行就变成了rm -rf /backup如果这台机器上恰好有个根目录下面的 backup 目录那不好意思整个备份目录直接被抄家。这还算运气好的如果变量是空的时候命令变成rm -rf /那后果你自己想。这个案例把 Shell 的两个核心机制体现得淋漓尽致第一变量展开后Shell 会做分词word splitting和路径名展开glob expansion所以变量值里的空格、星号都会被当成语法解析第二变量如果未定义默认展开为空字符串而空字符串在无引号情况下就等同于“什么都没写”直接把后面的路径变成绝对路径。正确的写法很简单rm -rf $project/backup加了引号变量里的空格会被当做一个整体变量为空就保持为空不会引入额外的歧义。再说一个引号的经典坑很多人写find或xargs时喜欢这样find . -name *.log | xargs rm文件名的通配符是给 find 用的但如果文件名里有空格xargs 就会把它拆成两段导致误删。我更建议用find ... -exec或者-delete或者用管道配合while IFS read -r处理。处理文件名、目录名这种可能包含任意字符的场景引号和特殊循环是标配不是为了装专业是为了保命。2.2 路径与环境不要在脚本里做“绝对路径假设”写脚本时很多人习惯上来就cd /some/path然后就开始对这个目录做操作。这个习惯有什么隐患最大的问题在于一旦cd失败脚本并不知道还会继续往下跑。我知道你本来想爱护某个目录结果却可能在错误的目录下执行了rm -rf。处理方式有两种思路。第一种在脚本开头用set -e后面会详细讲这样cd失败脚本就退出从根本上防止“在错误的地方执行危险命令”。第二种也是更稳的不轻易cd而是用绝对路径配合readlink -f定位脚本自身目录SCRIPT_DIR$(cd $(dirname $0) pwd)这样无论你在哪个目录下调用脚本它都能找到自己所在的位置再去引用同目录下的资源就不会迷路。至于环境变量我的建议是脚本里面需要的外部命令尽量不赌真实环境的 PATH。尤其在 crontab 里面跑脚本时PATH 经常只有/usr/bin:/bin你的 Node、Python、自定义工具全都不在里面。要么在脚本开头显式 export 你需要的 PATH要么用全路径调用或者先用command -v检查命令是否存在for cmd in curl jq python3; do command -v $cmd /dev/null 21 || { echo 缺少 $cmd; exit 1; } done这套“环境自检”看起来繁琐但它能在脚本跑飞之前就把问题暴露出来省掉后面一长串不可预测的连锁反应。2.3 通配符与空值空目录、空列表都是雷除了变量通配符也是“自爆”重灾区。最常见的就是这种情况for file in /var/log/*.log; do rm $file done如果/var/log/下面一个.log文件都没有for 循环里的file就是字面量/var/log/*.log一个不存在的文件。你当然不会因为删除一个不存在的文件而出大事但如果你对这个文件做更复杂的处理比如提取文件名、拼接路径、写进结果文件问题就会一点点膨胀。更危险的是find配合xargs空结果和高风险名称组合在一起很容易出现不可控行为。处理空列表比较规范的姿势是开启nullglobshopt -s nullglob for file in /var/log/*.log; do ... done开启后如果没有任何匹配for 循环直接跳过不会拿字面量当文件名。和“空值”相关的另一个大坑是未定义变量。默认情况下Shell 使用未定义变量时不会报错只是当成空字符串。这个设计初衷是为了方便交互式使用但写脚本时必须反过来想变量为空往往意味着参数没传、配置文件没加载、环境没准备好它就是个错误信号不该被静默吞掉。所以我在所有正式脚本里都会加set -u这样只要引用到未定义变量就立即退出绝不带着“空值的隐患”继续往后走。3. 错误处理自动化系统的安全网3.1 先掌握最基础的退出码检查Shell 里每条命令执行完都会返回一个退出码0 表示成功非 0 表示失败。这个信息极其重要它是判断命令是否成功的唯一可靠标准而不是肉眼看输出。最简单也最常见的检查手法就是$?grep ERROR /var/log/app.log if [ $? -ne 0 ]; then echo 日志中未找到 ERROR可能服务正常也可能日志文件不存在 fi不过$?有个问题它是“上一条命令”的退出码只要中间插个echo或者赋值操作它就变成别的值了。所以直接跟if判断其实是更干净的写法if grep -q ERROR /var/log/app.log; then echo 发现错误记录 fi用if把命令包起来Shell 会直接根据命令的退出码走分支不需要手工保存$?代码读起来也自然得多。另一种常见诉求是“执行 A 命令成功后再执行 B”。传统写法是cd /data ./deploy.sh的意思是左边成功了才执行右边这本身就是一种错误处理。我推荐尽量用而不是用分号因为分号只负责分隔命令不关心前一条是否成功。自动化脚本里每一条命令都应该是深思熟虑的“下一步”而不是盲目的“下一条”。3.2 set -euo pipefail一张让人又爱又恨的安全网如果你只记住一个提升脚本可靠性的操作那就记住在脚本开头加上这一行set -euo pipefail这几个选项单独拆开说set -e遇到任何命令返回非 0 就立即退出。这就解决了“出错还在继续跑”的默认行为。但用-e也要小心它有时候会让你误判。比如grep没匹配到内容时返回 1脚本直接退出但你可能只是检查一个可选条件并不想因此中断。这种情况要么把它放在if里要么临时忽略错误要么用grep ... || true来显式说明“失败也无所谓”。set -u引用未定义变量直接报错退出。前面说的空变量问题就是-u的防线。用了它变量拼错会在第一时间暴露而不是变成空字符串在脚本里“裸奔”。set -o pipefail管道中只要任意一条命令失败整个管道的退出码就是失败的。默认情况是只看最后一条命令的退出码。举个例子./app | grep 启动成功如果前面的./app崩了输出为空grep 找不到内容返回 1管道退出码是 1——这没问题。但反过来看这个./app | head -100head 只读 100 行就退出返回 0但 app 可能已经崩了。没有pipefail你根本不知道。加了它之后管道里任何一环出错都会反映在最终结果里。set -euo pipefail不是万能的有少数命令比如grep需要在允许失败的地方显式标注但相比它拦下来的事故这点麻烦完全不值一提。这条组合就是我所有脚本的默认起手式。3.3 trap脚本就算挂了也能“善后”trap是 Shell 里被严重低估的一个内置命令。它能让你在脚本退出不管正常退出还是异常退出时执行指定的清理动作相当于给脚本加了一个“finally 块”。最经典的用法是配合临时文件#!/bin/bash set -euo pipefail TMPDIR$(mktemp -d) trap rm -rf $TMPDIR EXIT # 脚本正文...不管脚本执行到哪一步正常结束也好中途 set -e 触发退出也好trap ... EXIT都会把临时目录清掉。如果没有 trap你每次中断脚本都会在系统里留一堆没用的临时文件日积月累既占磁盘又闹心。trap还能做更多事。比如调试脚本时我经常用trap echo 第 $LINENO 行命令失败 ERR这个和set -e配合可以定位到具体哪一行出了错。或者脚本需要在退出时恢复某项环境配置比如解除一个锁文件、删除一个 pid 文件都可以放进 trap 里。有编程经验的同学可以把 trap 理解成“异常清理钩子”它让脚本即使崩溃也能死得体面一点。4. 我踩过的“自爆”现场与排查速查表4.1 案例一rm -rf 清缓存差点把用户主目录扬了有次同事写了个清理脚本目标是清空某个服务目录下的临时文件。原脚本大概是这样的find /data/app -name *.tmp -delete单看这句没问题但他突发奇想要在脚本里支持传入项目名来动态指定目录PROJECT$1 rm -rf /data/$PROJECT/tmp/*某一次调用时$1根本没传脚本里的PROJECT就空了。rm -rf /data//tmp/*本身不是严重事故但因为他所在的环境里/data下面有自己的 tmp 目录这就把别的项目的临时文件也扫进去更麻烦的是他还加了set -e吗没有。脚本继续跑下去了。这个事故的教训非常典型的三个点外部参数必须校验空值直接拒绝执行拼接路径时宁可先拼成一个变量再整体加引号也不要在命令中间掺变量破坏性操作前面必须加上保险哪怕只是输出一行将要执行的完整命令。修复后的写法大致是PROJECT${1:-} if [ -z $PROJECT ]; then echo 用法: $0 project exit 1 fi TARGET/data/$PROJECT/tmp # 防止误删根目录或空路径 case $TARGET in /data/*) ;; *) echo 非法路径; exit 1 ;; esac rm -rf $TARGET/*别嫌这些检查啰嗦。自动化脚本的价值不在于它有多酷而在于它可以放心大胆地反复执行不用人蹲在旁边盯着。一个不校验入参的 rm 脚本第一次执行就是在赌人品。4.2 案例二为什么 Windows 上跑脚本总出“无法识别”的错看热词就知道很多人搜“npm 无法识别”“git 无法识别”这类报错。这不一定是你代码的问题大概率是 PATH 环境变量没把这个命令的路径加进去或者当前终端会话没有重新加载环境变量。写脚本的人可能觉得这是“系统问题”但脚本在跨平台时这恰恰是脚本健壮性问题。如果你需要在 Windows 上跑 Shell 脚本Git Bash、WSL 等环境有三个地方容易出幺蛾子第一个就是 PATH。终端里能用命令不代表脚本里能用。脚本里最好是先做检查比如command -v npm || { echo npm 不存在; exit 1; }这样问题出现时提示信息会更友好。第二个是 Windows 的换行符。Windows 下文件默认是\r\nLinux 下是\n。你把这个在 Windows 上编辑过的脚本传到 Linux 上跑Shell 会把\r解析成命令的一部分于是出现怪异的$\r: command not found。处理方式很直接用dos2unix转一下或者在编辑器里把换行符改成 LF。第三个是执行策略。如果用的是 PowerShell你可能遇到“因为在此系统上禁止运行脚本”的报错。这是 PowerShell 默认的执行策略在保护你需要管理员身份执行Set-ExecutionPolicy RemoteSigned才能放开。写脚本文档的时候把这一步写进去别让用户卡在环境上。4.3 案例三for 循环里的隐藏雷区for循环是 Shell 入门必备但很多人死记硬背只知道一行写法却不清楚它背后怎么分词。看这个例子for ip in $(cat ip_list.txt); do ping -c 1 $ip /dev/null echo $ip 通 done单看没问题但如果ip_list.txt里的 IP 后面跟了空格、换行或者文件格式是 Windows 的 CRLF每一行末尾都会带一个\r你 ping 的就是一个错误的地址。另一个更隐蔽的问题是文件里如果有通配符或者空格$(cat ...)会被当成多个词拆开循环变量会出乎意料地分裂。更稳的做法是while配合readwhile IFS read -r ip; do ping -c 1 $ip /dev/null echo $ip 通 done ip_list.txt-r防止反斜杠转义IFS确保行首尾空格不被吞掉。这两行代码看起来多了一点但它们把“按行读取文件”这件事做得严谨、可预期。另外提醒一下不要在循环体内修改ip_list.txt这样的输入文件因为while ... done file是持续读入的一旦改了内容读取位置就可能乱掉。4.4 案例四cron 环境为什么和终端不一样这是我见过最多人踩的坑没有之一。脚本在终端里跑得妥妥的一放进 crontab 就各种找不到命令、找不到文件、输出为空。原因高度一致cron 的 PATH 极其精简一般只有/usr/bin:/bin你的脚本里用到的/usr/local/bin/python、/opt/some/bin/tool都不在里面。其次是 HOME 变量可能没有被正确设置脚本里的~/.ssh/config、~/logs这种写法访问不到预期的位置。再一个就是没有加载用户 shell 配置文件所以脚本依赖的一些 alias、环境变量全会缺失。排查建议是先手动用 cron 一样的最小环境测试脚本做法是把 SHELL 环境清一下再跑env -i /bin/bash /path/to/your/script.sh这样你能模拟出类似 cron 的执行环境脚本缺什么马上会暴露。修复方向就是脚本开头显式设置 PATH、HOME#!/bin/bash export PATH/usr/local/bin:/usr/bin:/bin export HOME/root这样脚本就不依赖外部的环境假设谁调用它行为都一致。4.5 常见问题速查表我按实际踩坑频率整理了一张速查表排查脚本问题时可以直接对照。现象常见原因排查方向command not foundPATH 不完整、命令未安装用command -v检查显式 export PATH$\r: command not foundWindows 换行符用dos2unix或编辑器改 LF变量明明定义了却是空拼写不一致、作用域问题、导入失败用set -udeclare -p检查rm 提示但目录没删掉路径带引号错误、权限不够加set -x看实际执行命令脚本执行几行就退出set -e遇到了预期的失败命令定位具体命令按需if包裹或加 日志在终端正常但 cron 里失败环境差异env -i模拟 cron 环境测试脚本在中途 CtrlC 后残留下一次的问题未清理临时文件/锁文件使用trap统一清理对包含空格的文件名操作出错文件名未加引号所有变量展开处都加双引号5. 更进一步的可靠性测试、幂等与日志5.1 幂等性一个脚本能不能被安全地重复执行“幂等”这个词听起来挺术语但意思很简单脚本执行一次和执行一百次最终结果一致不会因为重复执行而产生额外副作用。为什么幂等性对脚本这么重要因为真实的自动化世界里脚本很少只跑一次。crontab 每隔几分钟跑一次部署脚本不断被触发日志切割每天执行。如果你的脚本不具备幂等性比如每次执行都往配置里追加一行那跑十次之后配置就有十行重复的垃圾每次执行都无条件覆盖某个目录那别人在目录里放的新文件就会被杀掉。砸几个常见的写法对比mkdir -p是幂等的而直接mkdir遇到已存在目录会报错cp -r src/* dest/大致幂等但cp会覆盖同名文件如果你期望保留 dest 里已有而 src 没有的文件每次都全量拷贝可能不符合预期docker pull是幂等的重复拉取不会重复占用空间tar -xzf app.tar.gz -C /opt/app是幂等的只要你保证压缩包内容一致curl -o file是幂等的但要注意curl -O会重复下载覆盖如果服务端文件频繁变化结果就不可控。写脚本时对每个操作问一句如果执行两遍会不会出问题只要有一个操作不是幂等的就要么改成判断式执行要么先清理再重建。最常见的防呆写法是if [ ! -d $BUILD_DIR ]; then mkdir -p $BUILD_DIR fi或者[ -f /var/lock/app.lock ] exit 0 touch /var/lock/app.lock这个“锁文件”思路可以防止两个任务并发重复执行也是一种事务性保护。但锁文件记得在脚本结束或 trap 里删掉不然它会永久阻塞后续任务。5.2 日志让脚本的每一步都被看见脚本一旦跑飞最难受的不是报错而是不知道它在哪一步飞出去的。所以日志是脚本可靠性的另一半。我常用的日志策略有三层。第一层在不影响功能的前提下用set -x打开跟踪把每条命令和展开后的参数打印到 stderr。调试完再关掉。第二层在关键步骤前面用echo输出一条执行信息让人在跑的时候能实时看到进度。第三层把整段脚本的输出同时导向 stdout 和日志文件exec (tee -a /var/log/myjob.log) 21这条命令的意思是从今往后脚本所有 stdout 和 stderr 都同时送到终端和日志文件。之后整个脚本的大部分内容不需要再关心日志格式所有输出都自动落盘。日志落盘了但注意一个问题日志如果无限增长会吃满磁盘。日志文件的日常轮转建议依赖系统自带的 logrotate而不是在脚本里自己写复杂的切割逻辑。你只需要挑选一个有意义的日志名和存放位置配置好轮转策略剩下交给系统工具。5.3 小步试跑与破坏性模拟先假装成功再真正成功我见过太多人一开始就把完整脚本写得又臭又长然后一次性跑出了错还要从几十行里定位问题。我的经验是自动化脚本要像程序一样迭代先写最小的核心链路跑通再加判断再加错误处理逐步增量。更重要的是给破坏性操作预留“试跑”能力。比如脚本里有一段清理逻辑可以先用一个DRY_RUN变量控制DRY_RUN${DRY_RUN:-0} run() { if [ $DRY_RUN 1 ]; then echo [DRY RUN] $* else $ fi } # 示例调用 run rm -rf $TARGET当DRY_RUN1 ./script.sh时脚本只打印将要执行的命令不做真实操作确认无误后再正常跑。这种能力便宜、简单但能挡住绝大部分的“手滑”。对于更复杂的场景推荐先在临时目录、容器或独立的测试环境跑一遍。比如你想写一个批量修改文件名的脚本先在temp/里放几个测试文件跑通再拿到真实目录执行。这不算浪费时间这是在给脚本做“上线前验证”。6. 安全习惯别把一个来路不明的脚本直接喂给 shell6.1 为什么不建议直接执行外部脚本互联网上到处都是现成脚本GitHub 上一抓一大把很多时候跑别人脚本比从零写省事得多。但我要提醒你Shell 脚本是“不设防”的它在你当前的用户权限下执行可以用你的权限读写文件、删除数据、连接任何它想连接的服务。一个恶意脚本可以在你毫不知情的情况下把环境变量、密码文件、网络配置全部打包拖走。网上最经典的手段是把下载和执行连在一起比如curl xxx | bash。你看到的也许只是一条安装命令但实际上你把一个不了解的程序的最高执行权直接交给了远端服务器。这类操作在下载官方维护的安装脚本时还算常见但它仍然是高风险行为因为一旦脚本被劫持或者你访问到了仿冒站点后果是直接的。我的建议是对于来源不可信的脚本永远先下载下来看一眼再决定是否执行。你不需要读懂每一行但至少要扫一遍有没有curl、wget、eval、base64 -d、chmod -R、rm -rf这类敏感关键词。如果脚本很短但是里面塞着一长串 base64那基本可以直接判定为恶意。想真正安全地试运行就放到临时容器、虚拟机或者 nohup限制权限的沙箱目录里跑。6.2 命令拼接与注入外部输入永远不要直接进命令Shell 脚本最容易出安全漏洞的位置就是“把外部输入拼接进命令字符串”。比如脚本接收一个域名作为参数有人会这样写resp$(curl -s http://$domain/status)如果domain的值不是预期域名而是example.com; rm -rf ~这样的字符串它在某些场景下就会被 Shell 解释为多条命令执行结果就完全不可控了。这本质上和 Web 里的 SQL 注入是同一种问题属于命令注入漏洞。防御思路有三个能用eval的地方千万别用eval会把字符串完整解析成命令是所有命令注入的入口外部输入必须经过白名单校验比如域名只允许字母、数字、点、横线路径只允许安全字符在传递参数时用双引号包裹避免意外分词。再往深一步脚本里如果需要执行动态拼接命令更安全的方式是使用数组cmd(curl -s --max-time 3 http://$domain/status) ${cmd[]}数组的每个元素会被当成独立的参数不会经历分词重新展开比直接写字符串靠谱得多。这个细节在平时写复杂脚本时非常有用。6.3 脚本自动化里的“最小权限”原则最后一个习惯是给脚本分配“刚好够用”的权限而不是“越多越好”。比如脚本只需要读取某个日志文件那就别用 root 跑脚本只需要操作某个应用目录那它的属主就应该是那个应用用户不是管理员。在 crontab 里也是一样能用普通用户跑的定时任务就不要用 root。权限越小脚本出错时的破坏半径就越小。再比如脚本如果只需要在特定目录下写临时文件就不要全局用mktemp -d生成的目录而是放到自己的专用目录。锁文件、pid 文件都放到约定位置不要随手扔在系统的临时目录。长期积累下来你会发现这些习惯不是“保守”而是让你敢在无人值守时信任脚本的根本前提。我个人在实际工作中还有个习惯每个脚本顶部都留一块注释记录它的用途、用法、依赖、维护人以及“什么情况下不要跑它”。你可能会觉得注释可有可无但一年后你自己回来看这些脚本时就会发现当年多写的那几行字比脚本里任何技巧都有价值。还有一个小技巧值得推荐给脚本加set -euo pipefail之前先想清楚脚本里有没有哪条命令平时就经常返回非 0提前用|| true或if块包好这样就不会被安全网误伤。自动化不是为了写看起来很酷的命令集合而是为了让自己在重复劳动里解脱出来同时还能放心大胆地把关键任务交给脚本。你给脚本加一点防护、留几条日志、做几个检查它回报你的就是一次一次稳定可靠的输出。别让你的自动化变成“自爆化”从下一个脚本开始克制、严谨、有兜底。