
写 Shell 脚本这几年我最怕的不是脚本报错而是脚本“太顺利”。那种你本来只想清理一下临时文件结果因为变量没赋值、空格没加引号、排错时少看一眼直接让生产环境“原地爆炸”的事故我见过不止一次。很多人觉得 Shell 只是“把命令串在一起”但恰恰是这种轻视让自动化变成了“自爆化”。尤其是现在随便一搜就能看到大量“shell脚本入门”“shell脚本for循环”“shell脚本连接sftp服务器命令”这类需求很多新手照着抄完就跑根本不知道脚本会在哪个环节失控。这篇文章不打算给你列一百个案例而是从几个高频翻车场景出发把 Shell 脚本容易“自爆”的底层原因拆开再给一套我实际在用的防爆检查单。适合刚学 Shell 的同学也适合那些已经在服务器上跑自动化脚本、但总感觉心里没底的同行。我会尽量结合大家搜索频率比较高的几个点来讲比如 for 循环里的空格坑、shift 参数解析、批量重命名文件、SFTP 连接这类真实需求因为踩坑往往就踩在这些看起来最日常的操作上。1. 自动化为什么会“自爆”三个失控源头1.1 失控一命令偏差变成了灾难Shell 脚本的本质还是命令但命令的“偏差”在脚本里会被无限放大。最经典的就是rm -rf加空格。比如你写了这么一行rm -rf /data/logs /backup/old看起来没问题两个目录之间有个空格。但如果你是从某个配置文件里读目录路径而这个配置少写了一行变量没取到值后果就是rm -rf /data/logs /backup/old变成rm -rf /data/logs /backup/old这个还好更恐怖的是变量为空时DIR rm -rf /data/$DIR这一行执行完删的是/data/整个目录。如果你粗心写成/data /$DIR那就是/data和/一起干掉。很多人在本地虚拟机里练脚本没事一上生产就出事就是因为本地环境没有真实数据删错了也无所谓感知不到危险。再比如重命名文件很多人用 for 循环for f in *.txt; do mv $f ${f%.txt}.md done这行本身没问题但如果当前目录一个.txt都没有bash 的默认行为是保留*.txt这个字面量导致mv报错。如果在某些设置了nullglob的环境里还好没设置的话for循环还是会执行一次只不过$f的值是*.txt。这不算最糟糕的最糟糕的是你在循环里做了更多操作而文件名本身包含空格或换行你没有加引号整个循环就会把名字切片处理出完全错误的结果。1.2 失控二状态假设永远不成立写脚本的人最容易犯的毛病是“假设条件成立”。假设目录存在、假设磁盘空间够、假设网络通了、假设上一条命令成功了。我有个血泪教训。有次写一个巡检脚本里面用df -h取磁盘使用率然后判断是否超过 90%超过就告警。一切都正常直到有一次/tmp目录满了导致脚本的临时文件写不进去df执行失败但脚本没有检查命令返回值继续往下走。结果因为变量是空值数值比较变成[ -gt 90 ]报了个语法错误然后告警系统把这条错误当成脚本崩溃又触发了一轮告警。整整折腾了一个小时最后发现是临时目录满了。这就是自爆化一条命令失败不可怕可怕的是脚本把失败吞掉拿错误的数据继续算、继续写、继续执行。尤其在自动化场景下人不在旁边错误会被放大很多倍。你原本想只删旧备份结果因为find命令的路径变量为空最后变成全盘搜索把在新目录下的所有文件都当成了目标。1.3 失控三执行环境比你想的更复杂同一个脚本在终端里手动跑没问题一旦放到crontab里就出错。这种情况太常见了。原因不复杂交互式 shell 会加载~/.bashrc、~/.bash_profile这些配置文件你手动测试时用的PATH、别名、函数脚本里全都用得上。但crontab的环境是最小化的PATH可能只有/usr/bin:/bin你用python3或git的时候命令直接找不到。还有 Shell 解释器的差异。我自己就遇到过在 Ubuntu 上写好的脚本扔到某个嵌入式设备上一跑各种语法错误。一看设备的默认 shell 是dash而不是bash数组语法不兼容、[[ ]]不支持全踩雷。更别提像光猫、路由器这类嵌入式设备上的 busybox shell功能裁剪非常严重很多常用的参数都不支持。另外再提一类场景安卓上的adb shell你看着像是 Linux实际上它的 shell 环境、可执行文件路径、权限模型跟普通 Linux 服务器差别很大。很多人在安卓上执行sh /storage/emulated/0/xxx/up.sh这类脚本报出各种奇怪错误核心就是没有意识到这是受限环境。类似的问题在 Windows 上的 PowerShell、Git Bash、WSL 里也普遍存在——同一份脚本换个环境就是另一副嘴脸。2. 最容易“自爆”的高频场景拆解2.1 批量重命名与文件操作for 循环里的细节魔鬼搜索“linux用shell重命名文件”的人很多说明这是刚需。但批量重命名恰恰是自爆重灾区因为它往往配合mv、find、sed一起用每个环节都可能出岔子。先说一个最常见的文件名有空格。假设你有三个文件report final.txt、report draft.txt、notes.txt你想把所有report开头的文件重命名为summary开头写了for f in report*.txt; do mv $f summary_$f done第一轮循环$f是report$f会分词成report和final.txtmv会认为你给了三个参数直接报错。正确写法是给变量加引号for f in report*.txt; do mv $f summary_$f done注意后面这个summary_$f能不能达到目的还要看你想怎么命名。如果只是想在前面加个前缀上面是对的。但如果你想替换名字中间的部分就需要别的处理。Shell 里对变量做字符串操作有几种方式比如${f#report}去掉前缀${f%.txt}去掉后缀。这是很基础但很容易用混的东西。再说/bin/mv的-i和-n参数。-i是交互式覆盖前询问-n是不覆盖已存在的文件。写批量重命名脚本时我强烈建议先加-n试跑一轮确认结果无误再真正执行。如果你用的是find加-exec也建议先用-print看一遍匹配结果。还有一个隐蔽的坑find ... -delete或find ... -exec rm这种链式操作配合-name *.log之类模式时偶尔会误伤。比如你写-name *.log本意是清掉日志但目录里有order.log.20240601这个文件它是不会被匹配的。如果你写的是-name *log*那可能把catalog.old也给删了。这类问题最好在匹配模式上多花几秒钟想清楚。2.2 远程与数据脚本SFTP、Oracle巡检的防呆设计搜索“shell脚本连接sftp服务器命令”和“linuxshelloracle脚本”的朋友多是要做自动化传输或数据库巡检的。这类脚本一旦写不好不只是自爆还会变成安全事故。先聊 SFTP。很多人直接写sftp userhost EOF cd /remote/path put localfile.tar.gz quit EOF这种写法能用但有几个问题一是密码没法直接写在标准输入里expect脚本或者密钥没配好的话就会卡在那等人输密码。自动化任务可没法等人。二是不管上传还是下载脚本里几乎从不检查文件是否存在、传输是否成功。“put 失败了你都不知道”等发现的时候已经晚了。更稳的做法是先把公钥配好实现免密登录再用sftp -b batchfile这种批处理模式执行或者用lftp这类的工具因为它能捕获 ECONNREFUSED、ETIMEDOUT 这些错误码方便脚本判断成功失败。还有一点路径里有空格的话SFTP 里也要额外处理。再聊 Oracle 脚本。sqlplus连接数据库免不了把用户名密码写进脚本里sqlplus system/123456orcl EOF select * from dual; EOF这玩意儿如果脚本权限没控制好比如 644那服务器上任何用户都能把密码看光。我的习惯是改用外部凭据文件sqlplus /nolog script.sql连接信息放在只有脚本所有者能读的隐藏文件里并且设置umask 077。另外数据库操作不是儿戏尤其涉及DELETE、UPDATE、TRUNCATE之类的语句建议在脚本里加一个“运行前检查行数影响级别”的逻辑或者干脆只生成 SQL 文件让 DBA 审核而不是让脚本直接执行。2.3 参数与输入shift、高级参数解析搜索“shell的shift命令”的人通常是在写带参数的脚本。很多脚本出问题不是逻辑不对而是参数处理太粗暴。shift的用途是左移位置参数比如$1变$2、$2变$3常用于循环解析参数。比如while [ $# -gt 0 ]; do case $1 in -f) FILE$2; shift 2;; -d) DIR$2; shift 2;; *) echo Usage: $0 -f file -d dir; exit 1;; esac done这个套路是对的但有几个陷阱。第一个是shift 2之前要先确认$#足够大否则shift会报错脚本退出。第二个是$2可能为空如果你写了-f但后面没跟文件名FILE就是空值后面的逻辑会拿空值去跑。我通常会在解析完参数后做一次“必填参数校验”为空就打印帮助信息并退出。还有$和$*的区别。不加引号时两者差不多但加引号$会把每个参数当独立字符串$*会把所有参数拼成一个字符串。如果是处理文件名就必须用$否则带空格的文件名会被拆开。这个问题在写循环处理多个文件时非常典型。如果参数一多纯手写case解析会很累也容易出漏洞。我建议直接用getopt它支持长短参数、参数值可选、错误提示统一比裸写 case 稳得多。注意 macOS 的getopt和 Linux 的getopt行为不同Linux 上通常是 util-linux 的增强版macOS 上是 BSD 版语法不通用。跨平台脚本里要特别注意这个差异。2.4 特殊运行环境安卓ADB与Windows PowerShell自爆化不只在 Linux 服务器上像安卓设备、Windows 环境同样是重灾区。安卓上的adb shell我见过很多人在设备上执行sh /storage/emulated/0/android/data/.../up.sh这类脚本然后报各种错。有的是因为脚本里的换行符是 Windows 风格sh直接说command not found有的是因为路径里的特殊字符没处理好还有的是因为安卓的/system/bin/sh和 Linux bash 功能差异太大语法不支持。我建议是如果你要在安卓上部署自动化脚本先跑一个最小的 echo 测试确认 shell 可用、目录可读写、路径无空格再放手写逻辑。另外尽量用sh而不是bash语法因为安卓上未必有 bash。Windows 上搜“powershell开机自启脚本”“windows脚本命令闪退”“npm无法识别”这类问题的人也不少。先说“npm、git、claude无法识别为 cmdlet”这就是典型的 PATH 环境变量没配好。Windows 装完软件没有自动加 PATH或者加了但当前终端没刷新都会报这个错。解决办法是在系统环境变量里把对应安装目录加进去或者重新打开终端。“因为在此系统上禁止运行脚本”是 PowerShell 的 ExecutionPolicy 默认受限导致的。执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned可以放开但这会带来安全风险别在共享机器上乱开。更好的方式是不要依赖执行策略直接在用命令行调用时明确指定比如powershell -ExecutionPolicy Bypass -File script.ps1只对这一次执行放行。“脚本闪退”这个问题更常见很多人写了个.bat双击运行窗口一闪而过报错信息根本看不见。最简单的排查方法是在脚本末尾加pause或者在执行前先cmd /k。Windows 命令行是“无头”环境错误提示不会暂停等你看。很多新手因为这个误解觉得脚本有问题实际只是没加暂停。3. 防爆实操我写脚本时的固定检查单3.1 护体四件套set -euo pipefail 与 shellcheck我现在写任意一个超过十行的非交互脚本第一行永远是#!/usr/bin/env bash set -euo pipefail这四个选项各有用处set -e命令出错立即退出。脚本不再是“出错还继续跑”而是“一有错就停”。set -u使用未定义变量时报错退出。这个能挡住上面说的变量为空导致的rm -rf /data/$DIR类事故。set -o pipefail管道中任意一环失败整体返回失败。默认只关心最后一个命令的返回值中间的命令挂了也不管有了 pipefail 就能暴露出来。set -x可选打印每条命令的内容方便调试但不适合长期保留在生产脚本里。注意set -e有很多细节。比如在if条件里执行的命令、while循环的条件、以及或||连接的短电路判断中命令返回非零不会导致退出。很多人因为不理解这个规则遇到“为什么 set -e 没生效”就很困惑。还有个细节set -e不会捕获被command substitution吞掉的状态。所以要靠你自己在关键位置显式检查返回值。写完脚本我还会跑一遍shellcheckshellcheck myscript.sh这工具能指出一大堆你不容易看到的问题比如grep没加引号导致的 glob 展开、find的-exec使用不当、变量引用缺失等。大多数问题它都会明确告诉你错在哪一行直接按提示改就能避免很多坑。如果你还没有把它加入工作流强烈建议装一个。3.2 安全操作三原则备份、锁定、留痕前面讲的“防爆”最核心的还是三条动手前备份、执行时锁定、干完留日志。备份要看操作对象的大小和重要性。如果是删除日志可以先mv到一个临时目录而不是直接rm如果是改配置文件先cp config config.bak.$(date %F_%T)如果是数据库操作在脚本里写清“先导出备库再执行删除”的固定顺序。备份不是浪费空间是给自己留后悔药。锁定针对的是并发问题。有些脚本是按小时跑的跑到一半下一个任务又启动了两边同时操作同一个目录结果不可预测。用flock可以给脚本加一把锁exec 9/var/lock/myscript.lock flock -n 9 || echo another instance is running exit 1这条的意思是拿锁失败就退出防止同一时间有两个实例在跑。这个技巧对于 crontab 里设置每分钟执行的脚本尤其重要。留痕也就是日志。我见过太多脚本执行完只留一个 exit code出事了根本不知道中间哪个文件被改了、哪个命令失败了。我习惯在脚本里做一个简单的 log 函数log() { echo $(date %Y-%m-%d %H:%M:%S) [${SCRIPT_NAME}] $* ${LOG_FILE} }关键操作、文件操作结果、错误信息都写进日志。别嫌啰嗦出问题复盘时日志是唯一的现场。3.3 防呆输入参数校验和默认值脚本在有用户输入时必须做足防御。手动跑还好如果是定时任务或远程触发的一个空参数、一个非法文件路径都可能把整个流程打断。我常用的做法是“提前校验 默认值兜底”。比如脚本需要一个目标目录if [ $# -lt 1 ]; then echo Usage: $0 target_dir exit 2 fi TARGET_DIR$1如果目录本身在环境变量里有默认值可以用${VAR:-default}这种写法DATA_DIR${DATA_DIR:-/data/default}这个写法的含义是DATA_DIR如果没定义或为空就用/data/default。这种写法在写“可配置”的脚本时非常实用。对于文件是否存在、是否可写也建议显式判断if [ ! -d $TARGET_DIR ]; then echo ERROR: $TARGET_DIR does not exist or not a directory exit 2 fi对于从外部输入的值比如文件路径、IP 地址、日期最好加一层格式校验。比如校验 IP 正则if ! echo $IP | grep -qE ^([0-9]{1,3}\.){3}[0-9]{1,3}$; then echo ERROR: invalid IP format exit 2 fi别嫌烦这些校验在脚本自爆时是最有效的拦截网。3.4 从“能跑”到“敢跑”测试与灰度脚本写完之后我的习惯是先在“沙盘”里跑一遍。比如给脚本加一个环境变量开关开启后不是真的执行而是打印命令内容DRY_RUN${DRY_RUN:-0} if [ $DRY_RUN 1 ]; then echo DRY_RUN: rm -rf $TARGET_DIR else rm -rf $TARGET_DIR fi这样你可以在正式执行之前先看一遍“脚本接下来会干什么”确认没有意外。对于批量操作脚本我更推荐“双阶段执行”第一阶段生成操作清单第二阶段才真正执行。比如先列出所有要删除的文件人工确认或程序确认后再执行删除。还有一种常用的方式是“灰度”先在测试目录里小范围跑确认结果符合预期后再把范围扩大。很多生产事故就是因为跳过了这一步直接用脚本处理全量数据结果一个小不对就全线崩盘。3.5 定时任务相关的隐藏陷阱如果你的脚本要放到 crontab 里跑有几个坑几乎是必踩的一是PATH问题我在前面提过。解决办法是在脚本开头显式设置export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin二是脚本里尽量用绝对路径。你手动执行时可能是在/home/user/project目录下但 crontab 的工作目录经常是/或$HOME。如果脚本内部用了相对路径结果完全不可控。我建议脚本开头就cd $(dirname $0)切到脚本所在目录或者统一用绝对路径。三是stdout/stderr的输出。crontab 的执行环境里没人看终端命令的输出默认会以邮件形式发给 root。如果脚本不产生输出还好产生的话邮箱会被塞满。我一般这样加在 crontab 行尾*/5 * * * * /usr/local/bin/myscript.sh /var/log/myscript.log 21这样日志就只进文件不会打扰人。日志文件本身要注意滚动否则跑久了会撑爆磁盘。搜“设备老化测试全自动执行脚本”这类需求多半也要跑个几天几夜日志和磁盘规划从一开始就要考虑。4. 常见报错速查与排查实录4.1 高频报错速查表下面这个表是我见过最多的 Shell 相关报错如果你碰到了可以先对号入座。报错信息原因解决方式command not found/npm : 无法将“npm”项识别为 cmdletPATH 未包含可执行文件目录检查安装路径加入 PATH重启终端Permission denied没有执行权限chmod x script.sh$\r: command not found脚本是 Windows 换行符sed -i s/\r$// script.shsyntax error near unexpected tokenShell 解释器不兼容如 dash 不支持数组显式用#!/bin/bash或改写语法./script.sh: No such file or directory脚本里面有解释器路径不存在或换行符问题检查首行 shebang检查换行符argument list too long参数太多常见于通配符展开改用find -exec或 xargsread: Illegal option -u在 dash 之类的精简 shell 里用了 bash 专属语法指定#!/bin/bash或换语法[[: not found用了[[ ]]但跑在 POSIX shell 模式确保 shebang 是 bash或改[ ]并加引号rm: cannot remove *.log: No such file or directory通配符没展开目录为空或没有匹配文件用nullglob或先判断是否有匹配PermissionError: [Errno 13]脚本调用的程序没有对应目录写权限给脚本或当前用户授权或调整路径需要提醒一下报错原因经常是“组合拳”。比如git : 无法将“git”项识别为 cmdlet不一定是没装 git也可能是装了但 PATH 没生效终端缓存了旧的 PATH。重新开一个终端或者直接在系统环境变量里“编辑并确认后重开”很常见的坑。4.2 一个典型的排查过程crontab 里脚本跑不起来的追踪实录有一次一位同事发现自己写的备份脚本在终端里执行正常但 crontab 就是没有产出备份文件。他跑来问我的时候已经折腾了半天。我让他按下面的顺序排查第一步看 crontab 本身的执行记录。很多系统里 cron 的执行情况会被记录或者脚本输出被发到邮箱先看mail有没有内容。结果看到一条python3: command not found。第二步确认 crontab 环境里的 PATH。执行crontab -e在脚本行前面加一句echo $PATH /tmp/test.log跑一次看输出。果然PATH 只有/usr/bin:/bin而他的 python3 装在/usr/local/bin。第三步模仿 crontab 的方式执行一次用env -i /bin/bash -c /path/to/script.sh清空环境变量再跑很容易复现问题。修复方式就是脚本开头自己设置 PATH或 crontab 里写PATH/usr/local/bin:/usr/bin:/bin。第四步在脚本里加了日志输出后发现备份命令本身还报了一个权限错误。因为他脚本里用了/backup目录普通用户没有写权限而他在终端执行时用的用户恰好有权限。这个发现让他一下子明白crontab 执行用户和执行终端用户不一致也是个大坑。最终修复后备份正常但整个过程耗时一小时。如果一开始就在脚本里把 PATH 和日志都打好这个时间可以压缩到五分钟以内。4.3 团队协作层面的防爆脚本自爆有时候不是一个人的问题而是一个团队缺乏规范。我见过有些公司的服务器脚本分散在各个人目录下谁写的只有谁看得懂别人根本不敢动。还有的脚本用了大量魔法数字和硬编码路径换台机器就废了。如果团队里要做脚本规范化我建议从这几个约定开始脚本开头统一加set -euo pipefail并且用 bash 而不是 sh。脚本顶部写明用途、参数、示例、维护人让自己的脚本像一个小文档。公共函数抽出来单独放比如日志函数、锁函数、校验函数谁用都可以。关键脚本必须进版本库任何人改动要有记录不能只在服务器上改。在 CI 里跑一遍 shellcheck至少保证基础语法和常见坑被扫一遍。涉及生产数据的脚本强制要求先--dry-run或测试模式不能直接全量操作。这些约定不复杂但能在“人换了一轮”之后让脚本仍然可维护。很多团队出现自爆化事故往往发生在“原来写脚本的人走了后来的人一知半解就改了起来”的节点上。规范本质上是把经验固化下来让后来的人不用从头踩坑。说到底Shell 脚本本身并不难难的是你永远不知道它会在什么边界条件上出错。我在实际工作中养成的习惯是每写完一个脚本都刻意问自己几个问题——这个脚本在空参数下会怎样目录不存在会怎样命令执行一半失败会怎样权限不够会怎样把这些“会怎样”都补上防护脚本才算真的有资格上生产。最后再分享一个小技巧把shellcheck装好别嫌它啰嗦把echo日志写全别怕文件变大能先dry-run就先dry-run别急着让命令“真实”跑起来。踩过几次坑之后你会认同稳才是自动化脚本最值得追求的东西。