
1. 为什么我劝你认真啃下Shell脚本这块硬骨头刚入行那会儿我对Shell脚本的态度可以用四个字概括敬而远之。总觉得这玩意儿语法古怪、报错晦涩远不如Python优雅更不如Go现代。直到有一次线上环境出了故障运维同事甩过来一段两百行的Shell脚本让我帮忙看逻辑我盯着满屏的$?、、||和嵌套的if愣是花了半小时才理清执行路径。那次之后我才意识到Shell脚本不是“过时的老古董”而是每个跟服务器打交道的人都绕不开的基本功。所谓Shell脚本编程说白了就是把一串在终端里敲的命令按照你想要的逻辑组织成一个文件让系统替你去批量执行。它能做的事情远比很多人想象的多日常备份、日志清理、服务健康检查、批量文件处理、自动化部署流水线里的胶水层甚至一些轻量级的定时任务调度都离不开它。你不需要编译不需要装运行时只要有Shell解释器脚本就能跑起来这种“开箱即用”的特性是它最大的优势。这篇文章适合谁看如果你是刚接触Linux的新手想搞明白那些#!/bin/bash开头的文件到底在干什么如果你是开发人员想自己写个脚本把重复劳动自动化掉或者你是运维方向的同学需要系统性地把Shell的语法、陷阱和实战套路捋一遍那这篇内容应该能帮到你。我会从设计思路讲到核心语法细节再给出一套可以直接抄作业的实操流程最后把我这些年踩过的坑整理成排查清单。全程说人话不堆术语每个关键选择我都会解释为什么这么做。2. 整体设计思路Shell脚本到底该怎么学、怎么写2.1 先想清楚Shell脚本的定位是什么很多人学Shell脚本容易陷入两个极端。一种是把它当编程语言来学上来就研究数组、函数、正则表达式的高级用法结果学了一堆语法却不知道能干嘛另一种是把它当命令备忘录只会把几条命令堆在一起遇到稍微复杂点的逻辑就抓瞎。这两种路子都有问题。我的理解是Shell脚本的核心定位是“命令的编排层”。它的强项在于调用系统命令、处理文本流、串联各种工具而不是做复杂的数据结构和算法。你写一个Shell脚本本质上是在描述“先做什么、再做什么、如果怎样就怎样”的流程。所以学习的时候应该以“任务驱动”为主先想清楚要完成什么任务再看需要哪些命令最后用Shell的控制结构把它们串起来。语法是为任务服务的不要本末倒置。基于这个定位我在设计任何脚本时都会先问自己三个问题这个任务涉及哪些命令命令之间的数据怎么传递哪些地方可能出错、出错后怎么处理把这三个问题想明白了脚本的骨架基本就出来了。2.2 为什么选择Bash而不是其他ShellShell有很多种sh、bash、zsh、fish、dash等等。实际工作中我几乎只用Bash原因很实在绝大多数Linux发行版默认就是Bash兼容性最好网上能找到的脚本示例绝大多数也是Bash语法它的功能足够全数组、关联数组、字符串操作、进程替换这些都有。虽然POSIX sh的可移植性更强但为了那点可移植性放弃Bash的便利性在大多数场景下不划算。不过有一个细节必须注意脚本第一行的shebang要写清楚。#!/bin/bash和#!/bin/sh在很多系统上指向不同的解释器如果你用了Bash特有的语法比如[[ ]]、数组、却写了#!/bin/sh在某些环境下就会报错。我见过不止一次因为shebang写错导致脚本在测试环境正常、生产环境挂掉的情况。所以我的习惯是只要用了Bash特性就老老实实写#!/bin/bash别偷懒。2.3 脚本结构的设计原则一个能长期维护的脚本结构上应该具备几个特征。首先是入口清晰脚本开头集中定义变量和常量中间是函数定义最后是主逻辑调用读的人一眼能看出从哪里开始看。其次是错误处理到位每个关键命令执行后都要检查返回值不能假设一切顺利。再次是可配置把路径、阈值、超时时间这类容易变的东西抽成变量放在顶部改的时候不用满脚本找。我通常会用一个简单的模板来组织脚本大致长这样#!/bin/bash set -euo pipefail # 配置区 LOG_DIR/var/log/myapp BACKUP_DIR/data/backup RETENTION_DAYS7 # 函数区 log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* } log_error() { echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $* 2 } # 主逻辑 main() { log_info 开始执行备份任务 # ... } main $这个模板里最关键的是set -euo pipefail这一行它相当于给脚本上了三道保险具体含义我在下一节详细说。函数区把日志输出这类重复逻辑封装起来主逻辑放在main函数里统一调用这样脚本的可读性和可维护性都会好很多。3. 核心语法细节与实操要点拆解3.1 变量看似简单坑最多的地方Shell的变量用起来很随意赋值就是namevalue引用就是$name或者${name}。但正是这种随意埋了不少雷。第一个要注意的是等号两边不能有空格name value会被Shell当成命令name带两个参数来执行直接报错。这个规则跟其他编程语言都不一样新手极容易写错。第二个是变量引用要加引号。$name和$name在大多数情况下效果一样但如果变量值里包含空格或者通配符不加引号就会出问题。比如file$1如果传入的参数是my document.txt后面用rm $file就会变成删除两个文件。所以我的习惯是只要引用变量一律加双引号除非明确知道不需要。第三个是默认值和条件赋值。Shell提供了一组很方便的语法${var:-default}表示var为空时用default替代${var:default}表示var为空时赋值default${var:?message}表示var为空时报错退出。这几个在写健壮脚本时特别有用比如LOG_DIR${LOG_DIR:-/var/log}用户没传就用默认值传了就用用户的。第四个是局部变量。函数里定义的变量默认是全局的要声明局部变量必须用local关键字。这个坑我踩过在一个函数里用了循环变量i结果把外层同名变量覆盖了排查了半天才发现。所以函数内部除了确实需要对外输出的变量其他一律加local。3.2 条件判断[ ]、[[ ]]和(( ))怎么选Shell的条件判断有三套写法很多人搞不清楚该用哪个。简单说[ ]是POSIX标准兼容性最好但功能有限[[ ]]是Bash扩展支持正则匹配和逻辑运算符用起来更顺手(( ))专门用于算术运算写数学表达式最直观。我的选择原则是字符串和文件判断用[[ ]]算术比较用(( ))只有在需要严格POSIX兼容的场景才用[ ]。用[[ ]]的时候有几个好处字符串比较不需要给变量加引号内部会自动处理支持和||直接写在括号里支持~做正则匹配。比如判断一个字符串是不是数字可以写[[ $input ~ ^[0-9]$ ]]比用[ ]配合grep简洁得多。文件判断的常用操作符也得记牢-f判断是否是普通文件-d判断是否是目录-e判断是否存在-r/-w/-x判断读写执行权限-s判断文件是否非空。这些在写备份、清理类脚本时天天用。有个细节-e对断开的符号链接返回false如果要判断链接本身是否存在得用-L。3.3 循环与流程控制for、while、case的实战用法for循环在Shell里主要有两种形式。一种是遍历列表for item in a b c; do ...; done另一种是C语言风格for ((i0; i10; i)); do ...; done。遍历文件列表时我推荐用通配符配合for file in /path/*.log但要注意如果目录为空通配符会原样保留导致循环体执行一次且变量值是字面量/path/*.log。解决办法是配合shopt -s nullglob让空匹配展开为空。while循环常用来逐行读文件标准写法是while IFS read -r line; do ...; done file。这里的IFS是为了保留行首尾的空白-r是防止反斜杠被转义两个都不能省。如果用管道把文件内容传给while比如cat file | while read line循环体在子Shell里执行里面修改的变量在外面看不到这是个经典陷阱。case语句适合处理多分支的场景比如根据传入的参数执行不同操作。它的语法是case $1 in start) ...;; stop) ...;; *) ...;; esac每个分支用;;结尾*)是默认分支。相比一长串if-elifcase在分支多的时候更清晰而且支持通配符匹配比如*.log)可以匹配所有以.log结尾的参数。3.4 函数与参数传递让脚本具备复用能力Shell函数定义有两种写法function name { ... }和name() { ... }。我推荐后者因为它是POSIX标准写法兼容性更好。函数内部获取参数用$1、$2跟脚本本身获取命令行参数的方式一样。$#是参数个数$是所有参数列表$*也是所有参数但作为一个整体。这里有个容易混淆的点函数内部的$1和脚本的$1是两套东西。脚本的$1是命令行第一个参数函数的$1是调用函数时传的第一个参数。如果函数里需要访问脚本的参数得在调用函数时显式传进去或者用全局变量。我一般倾向于显式传参这样函数的依赖关系一目了然。函数的返回值用return但只能返回0到255的整数通常0表示成功、非0表示失败。如果函数需要返回字符串或复杂数据惯用做法是让函数把结果打印到标准输出调用方用命令替换捕获result$(my_function arg1 arg2)。这种“输出即返回”的模式在Shell里非常常见理解这一点对读懂别人的脚本很关键。3.5 输入输出重定向与管道Shell的灵魂重定向和管道是Shell最强大的特性也是它区别于普通编程语言的地方。覆盖写入追加写入从文件读入2重定向标准错误同时重定向标准和错误。这些符号组合起来能玩出很多花样比如command output.log 21把标准输出和错误都写到同一个文件注意21必须写在后面顺序反了就不生效。管道|把前一个命令的输出作为后一个命令的输入这是Shell做文本处理的看家本领。grep过滤、awk提取字段、sed替换、sort排序、uniq去重、wc统计这几个命令配合管道能解决大部分日志分析需求。比如统计访问日志里每个IP的请求次数并取前10名awk {print $1} access.log | sort | uniq -c | sort -rn | head -10。这一行命令拆开看每一步都很简单组合起来却非常高效。还有一个实用技巧是tee命令它能把输入同时写到文件和标准输出适合既要在终端看结果又要存日志的场景command | tee output.log。加上-a参数是追加模式。4. 完整实操流程从零写一个日志清理脚本4.1 需求分析与方案设计光讲语法没意思我们拿一个真实场景走一遍完整流程。假设需求是定期清理某应用日志目录下超过7天的.log文件清理前先统计要删多少、占多少空间删除操作记录到日志并且支持“预演模式”——只显示会删哪些文件但不真正删除。这个需求看起来简单但涉及好几个知识点日期计算、文件查找、大小统计、安全删除、参数解析。方案设计上我用find命令按修改时间筛选文件用-print0配合xargs -0处理带空格的文件名用date命令计算截止日期用getopts解析命令行参数。预演模式通过一个DRY_RUN变量控制删除动作封装成函数根据变量决定是真删还是只打印。为什么用find而不是ls配合awk因为find的-mtime参数直接支持按天数筛选而且能正确处理子目录和特殊文件名比解析ls输出可靠得多。为什么用-print0因为文件名可能包含空格、换行等特殊字符用-print0以空字符分隔配合xargs -0就不会出错。这些选择背后都是踩过坑之后的经验。4.2 脚本实现与逐段讲解先看完整脚本然后我逐段解释关键点#!/bin/bash set -euo pipefail LOG_DIR${1:-/var/log/myapp} RETENTION_DAYS7 DRY_RUNfalse SCRIPT_LOG/tmp/cleanup_$(date %Y%m%d).log log() { echo [$(date %Y-%m-%d %H:%M:%S)] $* | tee -a $SCRIPT_LOG } usage() { echo 用法: $0 [-d 日志目录] [-n 保留天数] [-p 预演模式] exit 1 } while getopts d:n:p opt; do case $opt in d) LOG_DIR$OPTARG ;; n) RETENTION_DAYS$OPTARG ;; p) DRY_RUNtrue ;; *) usage ;; esac done if [[ ! -d $LOG_DIR ]]; then log 错误: 目录 $LOG_DIR 不存在 exit 1 fi log 开始清理目标目录: $LOG_DIR保留天数: $RETENTION_DAYS预演模式: $DRY_RUN mapfile -d files (find $LOG_DIR -type f -name *.log -mtime $RETENTION_DAYS -print0) total_count${#files[]} total_size0 for f in ${files[]}; do size$(stat -c %s $f 2/dev/null || echo 0) total_size$((total_size size)) done log 共找到 $total_count 个待清理文件合计 $((total_size / 1024 / 1024)) MB if [[ $DRY_RUN true ]]; then for f in ${files[]}; do log [预演] 将删除: $f done log 预演结束未执行实际删除 exit 0 fi deleted0 for f in ${files[]}; do if rm -f $f; then deleted$((deleted 1)) else log 删除失败: $f fi done log 清理完成成功删除 $deleted 个文件逐段说几个关键设计。set -euo pipefail这行前面提过这里展开讲-e让命令失败时脚本立即退出-u让引用未定义变量时报错-o pipefail让管道中任一环节失败都算失败。这三个组合起来能避免大量“错误被静默吞掉”的问题。但要注意-e有例外情况比如命令在if条件里、或者后面跟了||就不会触发退出这是符合预期的。mapfile -d 配合进程替换 (find ... -print0)是读取以空字符分隔的文件列表的标准做法。mapfile把每一行读进数组-d 指定分隔符为空字符这样带空格的文件名也能正确分割。数组用${files[]}展开加引号保证每个元素独立。stat -c %s获取文件字节大小2/dev/null || echo 0是容错处理万一文件在统计过程中被删了不至于让脚本崩掉。大小累加用$(( ))做算术运算最后除以两次1024换算成MB。删除循环里用if rm -f $f判断每条删除是否成功失败的记录下来继续处理其他文件而不是一失败就整体退出。这种“尽力而为”的策略在批量操作里更实用。4.3 参数解析与使用示例脚本用getopts解析三个参数-d指定目录-n指定保留天数-p开启预演模式。getopts d:n:p里带冒号的字母表示需要参数值不带冒号的表示开关。解析到的值存在$OPTARG里选项字母存在$opt里。实际使用的时候先跑预演看看会删什么./cleanup.sh -d /var/log/myapp -n 7 -p确认没问题再去掉-p正式执行./cleanup.sh -d /var/log/myapp -n 7这种“先预演后执行”的模式是我强烈推荐的习惯尤其是涉及删除操作的脚本。预演模式让你在不产生任何副作用的情况下验证筛选逻辑是否正确能避免“手一抖删错目录”的悲剧。4.4 加入定时任务的注意事项清理脚本通常要定期跑用crontab配置定时任务是最常见的做法。比如每天凌晨3点执行0 3 * * * /path/to/cleanup.sh -d /var/log/myapp /tmp/cleanup_cron.log 21。这里有几个坑要注意。第一cron执行时的环境变量跟登录Shell不一样PATH可能很短脚本里用到的命令最好写绝对路径或者在脚本开头显式设置PATH。第二cron的工作目录是用户主目录脚本里的相对路径会出问题所有路径都用绝对路径。第三cron执行没有终端脚本里如果有需要交互的命令会卡住确保所有命令都是非交互式的。第四输出重定向别忘加否则cron会尝试发邮件日积月累可能撑爆邮件队列。5. 常见问题与排查技巧实录5.1 那些年我踩过的Shell坑坑一变量在管道子Shell里改了不生效。前面提过cat file | while read line; do count$((count1)); done执行完count还是0因为管道右边的命令在子Shell里跑。解决办法是用进程替换while read line; do ...; done (cat file)或者用mapfile先把内容读进数组。坑二[ ]里变量不加引号导致语法错误。if [ $var abc ]当var为空时变成if [ abc ]直接报unary operator expected。加引号写成if [ $var abc ]就没事或者干脆用[[ ]]。坑三cd失败后继续执行。脚本里cd /some/dir如果目录不存在默认会继续往下跑后面的相对路径操作全乱套。用cd /some/dir || exit 1或者靠set -e兜底。坑四通配符没匹配到文件时原样保留。for f in *.log在目录里没有log文件时f的值是字面量*.log循环体照样执行一次。用shopt -s nullglob让空匹配展开为空或者先判断ls *.log /dev/null。坑五rm -rf $DIR/里DIR为空。如果DIR变量因为某种原因没赋值rm -rf /的后果不用我多说。防御性写法是rm -rf ${DIR:?DIR is not set}/${DIR:?}在变量为空时直接报错退出。5.2 问题速查表现象可能原因排查方法解决方式脚本报“command not found”PATH不含命令所在目录echo $PATH检查用绝对路径或补全PATH变量值不符合预期引号使用不当或子Shell问题set -x跟踪执行加引号、改用进程替换脚本在cron里不执行环境变量或路径问题手动模拟cron环境执行脚本内设PATH、用绝对路径条件判断总是false[ ]语法错误或类型不匹配单独测试条件表达式改用[[ ]]或(( ))删除操作误删文件变量为空或通配符展开错误预演模式验证加${var:?}保护、先echo脚本执行到一半退出set -e触发bash -x script.sh看最后执行到哪检查失败命令必要时加5.3 调试技巧set -x和bash -x调试Shell脚本最实用的工具就是set -x它会在执行每条命令前把展开后的命令打印出来前面带号。你可以在脚本开头加set -x也可以只对某一段临时开启set -x...set x。更灵活的方式是用bash -x script.sh直接以调试模式运行整个脚本不用改文件。set -x的输出有时候会很啰嗦尤其是循环里。这时候可以配合PS4变量定制提示符比如export PS4${BASH_SOURCE}:${LINENO}: 让每行调试输出带上文件名和行号定位问题快很多。另一个技巧是用shellcheck做静态检查。这是个开源工具能自动发现大量常见错误比如未加引号的变量、错误的比较操作符、不可移植的语法等。我现在的习惯是写完脚本先跑一遍shellcheck能消灭掉八成低级错误。它给出的每条警告都有详细解释和修改建议对学习Shell规范也很有帮助。5.4 几个提升脚本健壮性的习惯第一所有变量引用加双引号这条规则能避免绝大多数跟空格和特殊字符相关的问题。第二关键操作前先判断前置条件比如删文件前确认文件存在、目录可写。第三危险操作加确认或预演rm、mv、覆盖写入这类操作能加-i交互确认就加不能加就提供预演模式。第四日志要带时间戳出问题时能还原执行时间线。第五脚本开头写清楚用途和用法注释不是给别人看的是给三个月后的自己看的。6. 从脚本到工程进阶方向与个人体会Shell脚本写到一定程度会遇到一些单靠脚本本身解决不了的问题。比如脚本越来越长、逻辑越来越复杂维护成本急剧上升比如需要处理并发、需要更复杂的数据结构比如需要跨平台运行。这时候就该考虑往更工程化的方向走了。一个自然的演进路径是把Shell脚本里的复杂逻辑抽出来用Python或Go重写Shell只保留最外层的调度和胶水部分。另一个方向是引入配置管理工具把一堆零散脚本组织成可版本控制、可复用的模块。但无论怎么演进Shell作为“最后一公里”的执行层地位不会变——你总需要在服务器上快速写几行命令验证想法、处理临时任务。我个人在实际操作中的体会是Shell脚本的价值不在于写得多巧妙而在于写得让人放心。一个用了三年没出过事的脚本往往不是因为作者技术多高超而是因为作者在错误处理、边界条件、危险操作防护上足够保守。宁可多写几行判断也不要假设“应该不会出问题”。我见过太多事故的根因都是“当时觉得不会有事”。最后分享一个小技巧养成给脚本写“使用说明”的习惯哪怕只有你自己用。在脚本开头用注释写清楚用途、参数、依赖、注意事项再配一个-h参数打印用法。这个习惯在脚本交接或者隔很久再回来看的时候能省下大量回忆时间。脚本是写给人读的顺便让机器执行——记住这句话你写出来的东西质量会完全不一样。