我之前在公司内部做过一次分享主题是Linux编程和Shell脚本。当时有个同事问我你天天说Shell能提高效率可我一天下来也没少敲命令啊差别在哪我说差别不在敲命令在于你怎么把零散的Linux命令组织成一段能反复执行、能传参、能自己处理异常的逻辑。这是Shell脚本和手动敲命令最重要的分界线。这篇文章就围绕这条分界线展开适合三类人刚接触Linux、想系统学会写脚本的新手会写一点脚本但经常被各种诡异报错卡住的进阶者以及想把手头重复性运维工作改造成自动化脚本的工程师。你不需要有很强的编程基础我尽量用实际场景加代码来说清楚每个知识点背后为什么是这样。1. Shell脚本的定位它到底是什么东西我想先纠正一个常见误解Shell脚本不是一门编程语言这么简单它本质上是Linux命令的粘合剂。你用Shell脚本写东西写的不只是语法而是如何调动Linux系统里现成的工具来解决问题。1.1 什么场景该用Shell什么场景该换Python或Go这是很多人问我的第一个问题。我自己的判断标准很简单如果这个任务的核心逻辑靠管道连接几个现成命令能完成80%那就用Shell。比如批量改文件名、分析日志里某个字段的统计值、定时备份目录、遍历一堆服务器执行同样的命令这些都是Shell的主场。但如果任务里有复杂的数据结构、需要频繁用正则做文本解析、要跑并发任务、要做HTTP服务或者逻辑分支超过几十行还涉及多维数组那就别死磕Shell直接上Python。Shell处理文本强但正则和复杂数据结构远不如Python利索。另外跨平台性也要考虑Windows环境下Shell脚本能跑但别扭Python生态反而更统一。选错工具不丢人硬用Shell写一个三百行的复杂业务脚本才真的折磨人。我实际工作里有个经验超出命令管道变量循环判断这五件事的范畴就要考虑换语言了。Shell脚本超过两三百行之后调试成本会指数上升可读性和可维护性都在下降。五件事能解决的事你就用它解决高效直接解决不了的果断换场地。1.2 第一行 #!/bin/bash 到底是什么含义几乎每个Shell脚本的第一行都是#!/bin/bash这行叫shebang作用是指定这个脚本用哪个解释器来执行。很多新手以为写了它脚本就能跑其实它决定的是当你直接执行这个脚本时系统用谁去解析后面的内容。这个细节很关键如果你在终端里执行bash script.shshebang其实是用不到的因为bash已经被你手动指定了但如果你./script.sh直接执行系统就会读第一行的shebang来找解释器。所以如果你在#!/bin/sh写的系统上用了bash特有语法比如[[ ]]判断脚本直接执行时可能报错而用bash script.sh又正常。这种灵异问题根因往往是shebang写错。还有个容易被忽略的点脚本要能直接执行除了有shebang还要有可执行权限。chmod x script.sh这步别省。之前有同事配好了脚本但老忘加执行权限每次运行都要bash xxx.sh时间长了路径一错就找不到文件源头就是没做这步。2. 变量、循环与条件判断Shell脚本的骨架编程语言的核心要素无非是数据、流程控制、函数。Shell也一样但它在语法上有很多容易踩坑但没人提醒的地方。这一节我把最常用的知识点都拆开讲。2.1 变量赋值与引用等号两边不能有空格这是Shell新手最容易犯的错误报错信息还特别难理解。namejc echo $name name jc # 执行上面这行你会看到: command not found: name原因在于Shell按空格切分命令name jc会被解析成三个单词Shell会认为name是一个待执行的命令。所以变量赋值必须紧贴着等号写不能有空格。变量引用的规范是${name}而不是$name。绝大多数场景两者等价但拼接字符串时区别就出来了prefixbackup_ num001 echo $prefix$num # 输出 backup_001 # 如果想达到同样效果写成 ${prefix}${num} 更清晰另外一个实用建议引用变量时尽量加双引号比如echo $name、cp $file $dest。不加引号时变量值如果包含空格Shell会把一个变量拆成多个参数最终命令行为完全不是你想要的。我处理过的生产事故里因为变量没加引号导致rm删错文件的事不是没有。2.2 for循环多种写法对应不同场景Shell的for循环有好几种写法每种适用场景不同。我列出常用的四种你按需求选。# 第一种遍历一堆明确列出的值 for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do ping -c 1 $ip /dev/null echo $ip 通 || echo $ip 不通 done # 第二种遍历数字范围 for i in {1..10}; do echo 第 $i 次 done # 第三种遍历命令输出的每一行 for file in $(ls *.log); do echo 处理文件: $file done # 第四种C风格循环适合有完整逻辑控制的场景 for ((i0; i10; i)); do echo 下标: $i done第一种和第三种使用频率最高。第一种适合参数固定的场景比如批量处理指定IP第三种适合把某个命令的输出作为循环列表的场景比如遍历所有日志文件。要注意的是第三种有个坑$(ls *.log)会按空格切分输出如果文件名里带空格文件名就会被拆成两个循环项。后面我会专门讲这个坑怎么绕过。2.3 if判断中括号和双中括号的区别Shell的条件判断里[ ]和[[ ]]是两套不同的写法。简单理解[是一个命令等价于test命令[[是Shell内建的关键字功能更强更安全。# 判断字符串是否为空 if [ -z $var ]; then echo 变量为空 fi # 判断文件是否存在 if [ -f /etc/passwd ]; then echo 文件存在 fi # 用双中括号写法更接近现代语言 if [[ -z $var ]]; then echo 变量为空 fi # 正则匹配是双中括号的强项 if [[ $str ~ ^[0-9]$ ]]; then echo str是纯数字 fi双中括号里你不需要像单中括号那样处处加引号和转义。比如正则表达式和特殊字符的处理双中括号都有更好体验。我的建议是新写的脚本一律用[[ ]]除非你需要兼容POSIX标准下的sh环境。很多涉及/bin/sh的Docker镜像里[[是不可用的那种环境就只能退回[ ]。判断条件除了中括号还有几个常用文件参数-f判断文件是否存在-d判断目录是否存在-x判断是否有执行权限-s判断文件是否非空。配合和||你可以写出非常简洁的判断链[[ -d /var/log/myapp ]] || mkdir -p /var/log/myapp这个表达式的效果是如果目录不存在就创建它。比if写出来简洁很多适合放在脚本开头做环境初始化。3. 位置参数与shift让脚本从一次性变成通用工具写脚本最忌讳的就是把参数写死。参数写死意味着换一个环境、换一个目录、换一个IP你就要打开脚本改一次。真正好用的脚本应该能接收外部传参像Linux自带命令一样灵活。3.1 位置参数$0、$1、$#、$、$* 各是什么先解释几个内建变量$0脚本本身的名字$1、$2...第一个、第二个位置参数$#参数个数$所有参数列表每个参数独立成项$*把所有参数拼成一个字符串$和$*的区别很微妙但非常重要。拿一个循环来说for arg in $; do echo 参数: $arg done这里$会保留每个参数的边界所以一个参数是 hello world循环里它就是一项而$*会把所有参数合成一个字符串循环只会执行一次。日常处理参数几乎永远应该用$。3.2 shift移动参数位置的利器shift命令的作用是把位置参数集体向左移动一位原来的$2变成$1$3变成$2以此类推。它经常用来写边解析边消费参数的循环。假设你想写一个脚本支持-n 5 -f /tmp/a.txt --verbose这种形式用shift配合case就是最经典的做法#!/bin/bash verbose0 count1 file while [[ $# -gt 0 ]]; do case $1 in -n) count$2 shift 2 ;; -f) file$2 shift 2 ;; --verbose) verbose1 shift ;; *) echo 未知参数: $1 exit 1 ;; esac done echo count$count, file$file, verbose$verbose执行./parse.sh -n 10 -f /etc/hosts --verbose输出就是count10, file/etc/hosts, verbose1。这种写法让脚本看起来专业得多而且加新参数只需扩展case分支可维护性很好。3.3 默认参数与必填参数校验处理参数时建议同时考虑用户没传怎么办。Shell的${1:-default}提供了简洁的默认值语法name${1:-无名} echo 你好,$name如果用户没传第一个参数name就会是无名。相配套的还有${1:?错误提示}只要参数为空就直接报错退出input${1:?请传入要处理的文件名}这条写法省掉了if [[ -z $1 ]]; then ... fi好几行代码适合在脚本开头对必填参数做校验。我在脚本里已经习惯了这两种写法默认值用于可选项报错式用于必选项代码看起来干净很多。4. 文件批量操作rename、find、xargs的组合拳文件批量处理是Shell脚本最高频的应用场景之一。不管是批量重命名、批量压缩还是把一批文件按规则分发到不同目录基本都离不开find和xargs这两个工具。4.1 批量重命名写一个安全可复用的修改脚本热搜词里有人搜linux用shell重命名文件这确实是高频需求。最简单的批量重命名方式是用for循环加mvfor file in *.txt; do mv $file ${file%.txt}.bak done这个脚本把所有.txt文件改成.bak后缀。${file%.txt}是Shell的变量字符串截取语法它会从变量末尾删除以.txt结尾的最短匹配核心逻辑就是去掉.txt这几个字符。但这写法有个隐患如果目录里有a.txt.txt这种双后缀文件名字处理完后可能和预期不一致。更安全一点的做法是用basename和awk做更精细的控制。我在生产里重命名文件时会先打印出修改前和修改后的对比人工看一眼没问题再真正执行for file in *.log; do newname${file%.log}_$(date %Y%m%d).log if [[ -e $newname ]]; then echo 跳过: $newname 已存在 else mv $file $newname fi done这种执行前检查目标是否存在的习惯能在极大程度上避免覆盖文件造成的数据丢失。看似多写了两行if实际省去的恢复成本远大于这个代价。4.2 find和xargs安全处理带空格文件名的管道find加xargs的组合能实现找到一批文件然后对每个文件执行命令。但有个极其常见的坑文件名里有空格时管道传输过程中空格会被误判为分隔符。举一个实际案例。你要把所有/tmp目录下超过7天、后缀是.log的文件压缩打包find /tmp -name *.log -mtime 7 -print0 | xargs -0 tar -czf old_logs.tar.gz这里的-print0和xargs -0是关键它们让find和xargs之间用空字符而不是空格来分隔文件名这样即使文件名里有空格甚至换行符也能安全传递。这个组合我几乎每次写批量脚本都会带上属于保安系数拉满的标准写法。如果你只是批量删除文件可以用find自带的-delete参数不需要经过xargs更安全find /tmp -name *.tmp -mtime 3 -delete4.3 实战场景批量改权限与批量打包再举两个我经常用的场景扩展你的命令肌肉记忆。批量把当前目录下所有脚本改成可执行find . -type f -name *.sh -exec chmod x {} \;这个-exec ... {} \;是find的另一种批量处理方式{}会被替换为每个找到的文件路径。它和xargs思路相近但更直观适合逻辑简单的单条命令。批量按日期归档日志假设目录结构是app/logs/2024/05/这种find app/logs -name *.log -mtime 30 -exec mv {} /archive/logs/ \;这条命令能把超过30天的日志全部挪到归档目录配合crontab就能形成最简单的日志轮换方案。如果归档目录有重名风险mv前先用[[ -e ]]判断一下或者改用带时间戳的拷贝。5. Shell脚本里最常见的坑与排查思路这一节价值可能超过前面所有内容。Shell脚本的坑不是语法难度问题而是它的行为和你直觉不一致。我把自己踩过、以及帮同事排查过的高频问题按类型整理出来逐个拆开讲。5.1 变量没加引号导致文件被截断最典型、后果最严重的一类坑。看这段脚本file/tmp/my file.log if [[ -f $file ]]; then echo 文件存在 fi当$file展开成/tmp/my file.log时在没有引号的情况下Shell看到的其实是两个词/tmp/my和file.log条件判断就会变成检查这两个玩意儿结果大概率和你期望不同。如果是rm命令rm $file就相当于执行了rm /tmp/my file.log会把/tmp/my这个文件删掉。文件名的空格是Shell脚本里最常见的类安全问题的来源。解决方案没有技巧就是变量引用一律加双引号这个习惯。5.2 cd失败却继续执行另一个高发坑cd /path/to/project rm -rf build/如果cd因为目录不存在而失败脚本不会停下来而是继续在当前目录执行 rm。假如你当前恰好在根目录或者家目录这一条rm可能直接造成大面积误删。我见过不止一次因为这种情况导致开发环境被清空的事故。预防方案是set -e加在脚本开头它会让脚本在任意一条命令返回非零退出码时立即退出。但set -e也有它的局限后面会说。更稳妥的做法是在关键操作前显式判断cd /path/to/project || exit 1 rm -rf build/|| exit 1的意思是如果cd失败就退出脚本退出码1。凡是会影响后续操作成败的命令都应该成对使用这种守卫逻辑。5.3 管道里的子Shell变量丢失Shell的管道会在子Shell中执行命令。这意味着管道右侧的赋值操作结果不会传回父Shell。看这个例子count0 echo a b c | read a b c echo $a $b $c # 输出为空因为read在子Shell里执行赋值的变量在父Shell里根本不存在这是让人一头雾水的问题明明管道执行了变量怎么就空了呢。解决方案有两种。一是用进程替换process substitution它是bash的扩展特性不是管道所以变量能留在当前Shellcount0 while read line; do count$((count1)) done (command_that_outputs_lines)二是用反引号或$()把命令的输出捕获到变量这不算管道问题output$(ls -l) echo $output5.4 set -e的边界它没你想的那么可靠我在5.2里提到set -e这里必须补充说清楚它的局限。set -e会让脚本在命令失败时退出但在if判断条件里、在或||组合的左侧、在管道非最后一段命令退出检查是不生效的。举个例子set -e false echo 这行还会执行吗不会执行因为false返回1set -e生效脚本退出。但set -e if false; then echo 条件内部 else echo 走了else fi echo 脚本结束这段里false虽然返回1但它处于if的判断条件中set -e不会在这里退出。这个设计是合理的否则没法写带判断的逻辑。关键是你要知道set -e不等于脚本永不往下执行它只是兜底。关键的、有依赖关系的命令守卫逻辑不能省。5.5 排查手段bash -x 看清每一步写Shell脚本卡住了别靠眼睛一行行扫直接用调试模式bash -x script.sh它会把脚本执行的每一行命令展开后的实际内容打印出来变量替换后是什么值、哪一步返回了错误一目了然。脚本内部也可以用set -x开启、set x关闭只对某一段进行追踪。还有bash -n可以只检查语法错误不执行脚本上线前跑一遍能拦住低级拼写错误。我排查问题的顺序通常是先bash -n过语法再bash -x跑一遍看展开定位到可疑段落后再补上临时echo打印关键变量的值。这套流程对付90%的Shell问题都够了。6. 一个综合案例自动备份并上传到SFTP服务器前面知识点讲了这么多最后用一个完整的、贴近生产场景的脚本把它们串起来。这个脚本也是我实际在用的模板你拿过去改改路径和账号就能用。6.1 需求分析与设计方案需求很简单每天凌晨把指定目录下新增的日志文件打包上传到远程备份机的SFTP目录保留本地最近7天的备份超期自动清理。设计时要考虑的事比第一眼看上去多日志文件可能带空格打包和传输要用安全方式处理上传SFTP需要非交互不能弹出输入密码的提示得用密钥认证或expect脚本命令执行可能失败失败要有日志可查备份文件要带日期按天归档定时任务需要设置好环境变量因为crontab环境是精简的6.2 完整脚本与逐段解析#!/bin/bash # 日志备份脚本归档并上传至SFTP set -uo pipefail # ------- 配置区 ------- SRC_DIR/var/log/myapp BACKUP_BASE/backup/myapp DATE$(date %Y%m%d) KEEP_DAYS7 SFTP_HOST192.168.10.10 SFTP_PORT22 SFTP_USERbackup_user SFTP_REMOTE_DIR/remote/backup/myapp # ------- 结束配置区 ------- mkdir -p $BACKUP_BASE/$DATE # 归档当天日志 find $SRC_DIR -type f -name *.log -newer $BACKUP_BASE/last_backup_mark -print0 | \ xargs -0 -r tar -czf $BACKUP_BASE/$DATE/logs_$DATE.tar.gz # 如果归档结果为空文件删掉避免上传空包 if [[ ! -s $BACKUP_BASE/$DATE/logs_$DATE.tar.gz ]]; then rmdir $BACKUP_BASE/$DATE echo 没有新日志跳过归档 exit 0 fi # 生成标记文件下次以这个文件作为增量边界 touch $BACKUP_BASE/last_backup_mark # 上传到SFTP sftp -P $SFTP_PORT -o BatchModeyes $SFTP_USER$SFTP_HOST EOF mkdir -p $SFTP_REMOTE_DIR/$DATE put $BACKUP_BASE/$DATE/logs_$DATE.tar.gz $SFTP_REMOTE_DIR/$DATE/ bye EOF # 清理本地超过KEEP_DAYS天的备份目录 find $BACKUP_BASE -maxdepth 1 -type d -name 20* -mtime $KEEP_DAYS -exec rm -rf {} \;逐段说明几个关键设计set -uo pipefail之所以不用-e是因为这个脚本里有管道场景管道失败如果不捕获可能掩盖真实错误。pipefail会让管道中任何一个命令的失败都导致管道整体返回失败更符合直觉。加-u则避免未定义变量的低级错误。find $SRC_DIR -newer $BACKUP_BASE/last_backup_mark利用一个标记文件实现增量归档比每次全量打包省空间省时间。mark文件在归档成功后touch一下下次就只挑比它新的文件。-r参数配合xargs -0如果find没有匹配结果xargs直接不执行tar避免tar收到空输入报错。空归档判断-s判断文件是否非空空文件说明没有新日志直接退出省得传一个空的压缩包到远程。SFTP这里用了BatchModeyes强制禁止交互提示配合密钥认证能实现全自动登录。如果你环境里还没有配SSH密钥先执行ssh-keygen生成密钥再用ssh-copy-id backup_userhost把公钥发到备份机之后sftp就能免密登录了。6.3 定时执行crontab里的两个坑脚本写好之后怎么让它每天自动跑标准答案是crontabcrontab -e # 每天凌晨2点30分执行 30 2 * * * /usr/local/bin/backup_logs.sh /var/log/backup_logs_cron.log 21crontab有两个经典坑我给你打个预防针。第一个坑是环境变量。crontab执行时的PATH比登录Shell精简得多你在终端能用的命令cron里不一定找得到。所以脚本第一行附近最好显式写上关键命令的绝对路径或者直接在脚本里导出PATHexport PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin第二个坑是时间别安排太满。凌晨2点半是个热门时段很多系统任务都集中在这个点跑。如果同一个服务器上项目多建议错峰比如用20 2 * * *这种随机偏移。6.4 如何扩展到设备老化测试全自动执行这类场景热搜词里有个设备老化测试全自动执行脚本这类需求我恰好做过思路可以复用。老化测试本质上就是循环加压 日志记录 状态检测三个环节都适合用Shell组织。循环加压可以用forfor ((round1; round100; round)); do echo 第 $round 轮 # 执行测试命令比如CPU负载、内存压测、网络ping stress-ng --cpu 4 --timeout 60s # 记录系统状态 uptime $LOG_DIR/uptime_$round.log free -h $LOG_DIR/memory_$round.log # 判断是否失败 if ! ping -c 1 192.168.1.1 /dev/null; then echo 网络异常,轮次$round $LOG_DIR/failures.log fi done这种脚本只要加好日志和错误计数就能直接放到设备上晚间无人值守跑。核心还是我们前面讲的那些循环、条件判断、变量、重定向。Shell脚本之所以强大就是因为它能把这些最简单的零件组合成自动化系统。7. 常用命令速查与自测练习建议这一节汇总我日常使用频率最高的Linux命令按功能分组整理成表格。这份清单不是Linux命令大全它是够用且实用的精简版你写脚本时大概率只需要这些。7.1 文件与目录操作命令作用常用场景ls -lh以人类可读方式列出文件查看文件大小、权限cp -a递归完整复制保留属性备份目录mv移动/重命名文件归档rm -rf删除目录及内容清理构建产物慎用du -sh统计目录总大小排查磁盘占用df -h查看磁盘分区使用监控磁盘水位ln -s创建软链接多个版本切换7.2 文本处理三剑客命令作用典型用法grep按模式过滤行grep ERROR app.logsed流编辑器替换行内容sed -i s/old/new/g fileawk按列处理文本awk {print $1, $NF} file这三个是Shell脚本里文本处理的核心。grep负责筛选sed负责替换awk负责按列提取和统计。建议至少把grep和awk的常用参数练熟sed至少会替换和按行删除。7.3 网络与进程命令作用常用场景ping网络连通性测试排查网络故障ss -tlnp查看端口监听和服务确认服务有没有启动ps aux查看进程确认进程是否在跑top/htop实时系统资源查看定位CPU、内存占用systemctl status 服务名查看systemd服务状态服务管理排错netstat -an查看所有连接排查端口占用7.4 自测建议从看得懂到写得出很多朋友看完教程觉得会了一上手就卡壳。我的建议是直接用下面这几个任务自测顺序从易到难每个任务都能在20分钟内完成写一个脚本打印当前目录下所有文件的文件名和大小用ls和awk或循环。写一个脚本把当前目录下所有.txt文件改成.bak后缀并打印每一行操作结果。写一个脚本输入一个目录路径作为参数统计该目录下文件总数和总大小。写一个脚本每5秒检查一次某个进程是否在运行如果进程消失就记录时间并发送告警可以先写日志代替发送。结合crontab写一个每天执行、自动清理超过N天的临时文件的脚本并把执行结果记录到日志。能顺利写完第4、5题你的Shell脚本水平就足以覆盖绝大多数日常运维和开发辅助工作了。回到开头那个同事的问题——他缺的不是敲命令的熟练度而是用脚本把命令串起来的思维方式。Shell脚本的价值就是让你从手动敲几十条命令升级成一个命令搞定所有事。这套东西入门不难难在把细节抠清楚引号、循环边界、管道行为、退出码、环境变量每一样都可能在你不注意的时候咬你一口。希望这篇内容能帮你少踩几个坑。最后再分享一个小技巧写脚本时永远假设运行环境里会有空格文件名、会有未定义变量、会有命令失败按这个假设去写脚本的健壮性能超过大多数人。