Shell脚本实战:从命令行到自动化运维的完整套路 写Shell脚本这事儿很多人都是被逼出来的。我自己就是先被一串又一串的命令搞得头大然后发现“命令行是一条一条喂给电脑的脚本是一次性把规则告诉电脑”从那以后只要是需要反复执行的活儿我第一反应就是“能不能写个脚本”。Shell脚本说白了就是把一串Linux命令按逻辑组合起来放进一个文件里赋予执行权限后一次跑完。它能干的事非常多批量备份、日志清理、监控告警、自动部署、数据处理……凡是我们在终端里手动敲过两遍以上的命令都值得脚本化。这篇文章适合刚接触Linux没太久、想从“会敲命令”迈向“会写脚本”的人也适合那些已经写了几个脚本但总觉得自己在“硬凑”的人我会把日常积累的套路、坑、调试方法一起倒出来。1. 我为什么劝你认真学Shell脚本而不是只靠记忆命令行1.1 从“会敲命令”到“会写脚本”的分水岭刚接触Linux的时候我特别崇拜那些能一口气敲出一串复杂命令的人感觉那才是高手。后来发现真正的高手从来不靠手速他们靠的是把复杂逻辑固化下来。Shell脚本就是这个“固化”的手段。可以这么理解命令行是“手动挡”脚本是“自动驾驶”。手动挡当然灵活每个路口你都能自己控制但如果是每天上班通勤的固定路线没有人会愿意天天手动换挡。脚本存在的意义就是把那些重复的、固定的、容易忘记操作步骤的任务交给一个可复现的文件去执行。从“会敲命令”到“会写脚本”你还需要跨过几个小台阶理解脚本就是一个“命令的集合”但集合里还有变量、循环、判断。搞懂执行机制脚本里的每一行本质上还是在启动一个子Shell去执行命令。学会给脚本传参数和接收输出。学会处理异常命令失败了是继续跑还是停下来。很多人卡在第二个台阶上因为总想着把脚本写出“程序”的样子一上来就定义函数搞面向对象那套结果把自己绕晕了。我的经验是先用最简单的顺序结构解决眼下问题等脚本写多了再回头优化结构。1.2 从需求到脚本一个最朴素的分步思路我在写脚本前后心态上的转变其实就一句话“先想清楚要干什么再想怎么敲命令。”有一次我需要清理某个目录下七天前的日志文件手动敲的流程是先看目录里有哪些文件再筛选七天前的然后删除最后确认一下磁盘空间有没有释放。第二周我又要做同样的事第三周还是同样的事我就知道该写脚本了。这个从“手动操作”到“写脚本”的转化过程其实可以总结成四步把步骤写成大白话找到目录、筛选旧文件、删除、报告结果。把大白话翻译成命令find、rm或者再加上df。把命令按照运行顺序写进文件。加参数支持、加判断逻辑、加输出信息让脚本更健壮。别看这四步简单绝大多数脚本卡住不是因为不会写命令而是卡在第二步和第三步的衔接上。你以为一条命令能搞定实际上命令在交互式终端里能用放到脚本里就各种问题——比如变量没加引号、忘记处理空格路径、rm删错了目录。这些问题我下面会专门讲到。2. 写脚本前必须养成的几个习惯2.1 Shabang行和权限两个最常见的入门坑第一个坑是脚本第一行的#!也就是Shebang行。很多文章会说“必须写”但没解释为什么。其实原理很直接当你直接执行./myscript.sh时操作系统需要知道用哪个解释器来解析这个文件里的内容。#!/bin/bash就是明确告诉系统“用/bin/bash去跑这个文件”。如果漏了这行系统会默认用当前Shell去解释不同环境下的行为可能完全不一样非常容易踩坑。操作层面建议直接写#!/bin/bash不要用#!/bin/sh。因为不少发行版里/bin/sh已经指向dash或者别的解释器语法兼容性会差一些我在某台服务器上就吃过这个亏本地跑得好好的脚本一放到服务器上就各种语法报错查来查去发现是解释器不一致导致的。第二个坑是执行权限。很多人写完脚本一执行报Permission denied第一反应是“脚本写错了”其实只是少了个执行权限。一开始我习惯用chmod x script.sh去处理后来发现更稳妥的方式是chmod 755 script.sh直接明确所有者有读写执行权限其他用户只有读和执行权限。这部署到多用户环境时能省掉很多不必要的权限问题。2.2 变量、引号、通配符看着简单坑里都是过来人Shell脚本里的变量定义nameabc注意等号两边不能有空格。这个我想很多初学的人都遇到过习惯性写成name abcShell会把name当成一个命令去执行然后报command not found。这不是你不够细心而是Shell语法就是这么“不顺手”不踩一下根本记不住。引号是另一个重灾区。双引号里的变量会展开单引号里的内容就是纯文本。比如nameworld echo hello $name echo hello $name第一次输出hello world第二次输出hello $name。这个问题看起来简单但一旦脚本里要拼接路径、拼接命令参数时有没有加引号导致的差别非常大。特别是文件路径里带空格的时候——如果变量不加双引号Shell会把它拆成多个独立的词去处理命令直接就是错的。通配符*、?、[]也得好小心。用rm *.log的时候Shell会先把*.log匹配到的文件列表展开再交给rm执行。但如果目录下的文件特别多命令行的长度有限制可能直接报错。更安全的方式是用find去配合处理后面我会给案例。2.3 命名规范与注释给三个月后的自己留条活路写脚本的时候加注释这件事我真的吃了很多亏才养成习惯。倒不是有多难就是当时总觉得“这是我自己的脚本我肯定记得住”。事实是过三个月再看你很可能完全不记得当初为什么用了一个那么奇怪的写法。我的建议是至少做到这几点脚本文件命名用小写字母加下划线比如backup_logs.sh不要用a.sh这种没有信息的名字。全局变量用大写加下划线比如LOG_DIR函数内的局部变量用小写。每个函数上方用两三行注释说明它干什么、接收什么参数。关键命令前加注释解释“为什么这么做”特别是那些看起来很绕的写法。比如我之前写过一段判断磁盘使用率的逻辑当时觉得很简单没加注释后来回看时一直在纠结“为什么这里要多一个倒数第二层的管道”。这种时候才意识到注释是写给未来的自己看的不是写给现在的。3. 一个完整的实操案例日志归档脚本3.1 需求梳理先别急着写代码前面讲了那么多理论这里我拿一个非常典型的场景来说日志归档。假设有这样一个需求——某应用每天会往/var/log/myapp/目录下写入日志文件日志按天命名例如app-2025-06-01.log。现在需要写一个脚本把七天前的日志打包压缩然后删除原始文件释放磁盘空间整个过程要有日志输出方便事后追踪。这个需求看起来很常规但动手之前还是先梳理清楚几个问题目录路径是否固定如果脚本被别人拿去用路径最好不要硬编码。“七天前”怎么界定按文件名里的日期还是按文件的mtime修改时间我建议按文件名来因为mtime可能因为复制、修改而变化。要不要保留最近七天的文件要。删除原始文件前是不是必须先确认压缩成功必须。脚本如果出错了怎么让人知道要有错误信息。想清楚这几点再去写脚本顺序逻辑就非常清晰了。3.2 分步实现每一段代码都说明白为什么这么写下面是我后来实际用过的脚本简化版我分段拆开来解释。#!/bin/bash # # 日志归档脚本将七天前的日志压缩并删除原文件 # 用法./archive_logs.sh 日志目录 LOG_DIR${1:-/var/log/myapp} RETENTION_DAYS7 TIMESTAMP$(date %Y%m%d_%H%M%S) ARCHIVE_DIR${LOG_DIR}/archive LOG_FILE${ARCHIVE_DIR}/archive_${TIMESTAMP}.log这里用${1:-/var/log/myapp}处理参数如果用户传了目录就用用户传的如果没传就用默认值。这个写法是我个人强烈推荐的能让脚本更通用不用为每一个目录单独复制一份脚本。接下来创建归档目录和日志文件目录mkdir -p $ARCHIVE_DIR-p的意思是不只创建目录如果上级目录不存在也会一并创建而且如果目录已经存在不会报错。这是写脚本时很常用的一个选项。然后是核心的查找和压缩逻辑cd $LOG_DIR || { echo 无法进入目录 $LOG_DIR; exit 1; } find . -maxdepth 1 -type f -name app-*.log -mtime $RETENTION_DAYS -print0 | while IFS read -r -d file; do base$(basename $file) tar -czf ${ARCHIVE_DIR}/${base}.tar.gz $base if [ $? -eq 0 ]; then rm -f $base echo [OK] 归档并删除: $base else echo [FAIL] 归档失败已保留原文件: $base 2 fi done这里有几个关键点我要专门讲讲。第一为什么用find -mtime 7而不是用日期字符串mtime是最近修改时间7表示超过七天了。虽然我前面说过用文件名更准但实际情况下如果日志文件不是严格按天命名mtime也是能接受的选择。更严格的做法你可以用! -newermt 7 days ago来处理但这个写法不同find版本支持情况不同你本地测试通过了就好。第二为什么用-print0while read而不是直接find ... -exec因为日志文件名虽然是我们自己生成的但保不准会带空格或者其他特殊字符。-print0会让find输出文件名时用空字符而不是换行来分隔配合read -d 就能安全地处理任意文件名。这个习惯我强烈建议养成能让你避开很多隐藏很深的坑。第三删除前一定要确认压缩成功。我用$?检查上一条命令的返回值tar成功了是0失败是非0只有成功了才执行rm。这个习惯特别重要——宁可压缩失败留着旧文件也不要删了原文件后才发现压缩包是坏的。3.3 加上容错和日常运维需要的输出实战中还有一个容易忽略的问题就是脚本执行过程中如果中途出错后面的命令还会不会继续执行默认情况下Shell脚本不会因为某条命令失败就停下来。这就可能导致压缩失败了但删除还是执行了。一个比较稳妥的思路是在脚本开头加set -euo pipefailset -e一旦脚本中任何命令返回非零状态立即退出脚本。set -u如果使用了未定义的变量直接报错退出。set -o pipefail管道命令中如果任何一段失败整个管道的返回值就是失败的。这个组合我是在踩过几次坑之后才真正接受并养成的习惯。刚开始觉得set -e太“激进”了可能会导致脚本在遇到一些“预期内的小失败”时直接退出。后来我调整了写法明知某条命令可能会失败、但希望脚本继续往下走的命令就在末尾加|| true。这样既能有安全网又能灵活处理预期中的失败。日志输出方面我的心得是“不要只把消息写给终端还要落盘”。脚本如果是通过定时任务跑的执行完之后你根本看不到终端输出所以要把关键信息写到日志文件里方便事后排查。脚本里我设置了LOG_FILE但为了简化就没在每一行加重定向实际你可以直接把需要落盘的输出用 ${LOG_FILE} 21追加进去。4. 测试和调试别让脚本裸奔上线4.1 用bash -x观察脚本的每一步“内心戏”最开始我写脚本写完直接执行报错就盯着错误信息猜。后来朋友给我推荐了bash -x直接让我打开了新世界bash -x ./archive_logs.sh执行的时候Shell会把每一行命令展开后的实际内容打印到终端比如变量值替换成什么、命令拼接成什么样都能看得清清楚楚。这功能解释“脚本怎么回事”的时候太有用了。比如之前我写了一个循环拼接字符串的脚本结果发现输出的路径老是少了一段怎么查都查不出来。后来用bash -x一跑清清楚楚看到变量在循环里被覆盖掉了问题一下定位。现在我的排查习惯是脚本执行前先bash -n检查语法再bash -x看执行过程配合起来非常好用。bash -n是只检查语法不执行适合在正式执行前先用它过一遍防止低级错误。不过它只能查语法查不出运行时才会暴露的问题所以调试阶段的bash -x不能省。4.2 常见报错信息速查看到这些别再慌平时跑脚本的时候我积累了几类高频报错这里整理成表格方便大家直接对照报错信息常见原因排查方向command not found命令拼错了或者环境变量PATH没包含命令路径检查命令名必要时用绝对路径Permission denied脚本没有执行权限chmod x script.shNo such file or directory打开文件时路径不对或者Shabang行指定的解释器路径错误检查#!/bin/bash是否拼对检查文件路径unexpected end of file经常是少了fi、done、}检查循环和判断语句的结尾是否配对integer expression expected比较时用了字符串而不是数字检查变量里存的是不是数字unbound variable用了set -u后变量没定义给变量加默认值或提前定义遇到报错我的处理顺序是先看是哪一行再确定是语法层面还是运行层面然后用bash -x跑一遍看实际的执行过程最后再搜索报错的完整消息。而不是一上来就复制报错去搜。4.3 自动化测试的思路让旧脚本不再“哪天崩了都不知道”另外一个容易被忽略的问题是脚本改了一次之后怎么确定没改坏尤其是那些放在定时任务里运行的脚本有时候一个月才出一次问题等发现问题的时候日志已经堆了很久了。我自己用的方法是给脚本做一个简易的“冒烟测试”用一个临时目录模拟真实环境放上测试文件。把脚本跑一遍检查是否有错误输出。检查归档包是否生成、原文件是否被清理。这听起来很朴素但能挡住大部分低级回归。有条件的话还可以给脚本加一个--dry-run选项只打印即将执行的命令而不真正执行这对自己排查问题和给别人演示脚本都特别友好。5. 日常高频场景脚本套路可以直接抄5.1 批量重命名和批量压缩一个套路走天下日常用得最多的两大类脚本就是批量重命名和批量压缩。我这里分享两个模板思路。批量重命名时核心是“循环每个文件”和“生成新文件名”#!/bin/bash # 批量将当前目录下的 .txt 文件重命名为 .md for file in *.txt; do base${file%.txt} mv $file ${base}.md done这里${file%.txt}是Shell的参数展开意思是把变量file末尾的.txt去掉。这种写法比用sed再去提取文件名要直观得多。批量压缩时我遇到过一个问题直接把所有需要压缩的文件打包成一个包和每个文件单独打包需求不一样。常规做法是# 将所有 .log 文件打包成一个包 tar -czf all_logs.tar.gz *.log # 分别打包每个日期目录 for dir in 2025-*; do tar -czf ${dir}.tar.gz $dir done需要提醒的是如果目录下有几百上千个文件*.log展开可能会超过命令行长度限制。这种场景我一般会改用find配合-print0的方式再结合前面提到的while read安全性和稳定性都会高很多。5.2 定时任务与脚本的配合别踩PATH和环境的坑脚本写好之后最常见的部署方式就是放进cron定时任务里。但这里有个特别隐蔽的坑cron执行脚本时环境变量和交互式终端里完全不一样PATH可能非常精简不包含/usr/local/bin这类路径。我在这里真实踩过坑某次脚本里用了一个工具交互式终端里跑得好好的但放进cron之后一直报command not found原因就是cron的环境里没有那个工具所在的目录。后来我在脚本开头加了一行显式设置PATHexport PATH/usr/local/bin:/usr/bin:/bin:${PATH}从那以后我的所有脚本只要可能有部署到定时任务需求的都会在开头处理一下PATH这是一个简单的防御性写法。另外cron里运行的脚本如果有输出默认会通过邮件发给本机用户但很多服务器根本没配邮件服务导致错误信息被丢弃。我建议在cron配置里把标准输出和标准错误重定向到日志文件例如30 2 * * * /home/user/scripts/archive_logs.sh /home/user/logs/archive.log 21这样即使脚本出了问题至少日志里能看到痕迹。5.3 监控脚本的写法取指标、做判断、发通知很多人以为监控脚本很复杂其实核心就是三件事获取指标、判断阈值、通知。以磁盘使用率为例#!/bin/bash # 磁盘使用率监控超过阈值则输出告警 THRESHOLD80 CURRENT$(df -h / | awk NR2 {print $5} | sed s/%//) if [ $CURRENT -gt $THRESHOLD ]; then echo 磁盘使用率已超过阈值: ${CURRENT}% # 这里可以接入通知比如写入告警文件、调用接口等 else echo 磁盘使用率正常: ${CURRENT}% fi这个脚本里有个小细节df -h | awk NR2 {print $5}取出的是根分区的使用率百分比带一个百分号再用sed s/%//去掉百分号才能和THRESHOLD做数值比较。这里绕了一下原因就是Shell里的数字比较不支持百分号两者之间不是一个数字类型。监控脚本和前面几个脚本不同它通常要跑很多年所以我觉得最值得花时间的地方不是一上来就写得很复杂而是把“收集指标”和“发出告警”这两件事拆成两个函数将来要增加告警渠道时只需要改一个函数就够了。6. 我在实际使用中的一些体会说了这么多最后聊一点个人体会。写脚本这件事说到底看的不是你能写出多高深的语法而是你能不能在写之前把流程理清楚。我见过不少人一上来就写结果脚本里全是“补丁式”的逻辑这里加一句特殊情况判断那里加一个临时变量最后脚本又长又难读。其实大多数脚本要解决的场景都是重复劳动只要把流程拆清楚脚本自然就好写了。我自己的习惯是先手工跑一遍命令确认每一步都能得到预期结果再改写成脚本。这个过程能筛掉很多逻辑问题比写完一堆再调试要省太多时间。另外脚本里能写得通用一点就通用一点哪怕只是把“固定路径”改成“可通过参数传入”都会让脚本的寿命长很多。再分享一个最后的技巧脚本最好在一开始就做“幂等性”设计也就是同一个脚本多次运行不会因为重复执行而产生错误结果。比如归档脚本跑第一次会把七天前的日志归档掉跑第二次再运行就不会重复归档同一天的日志因为已经删掉了。这个思路能让脚本在cron里偶尔被误触多次时也安全。Shell脚本的价值不在技术多高深而在“解放双手”这四个字。把第一次做觉得麻烦的事记录下来变成每次都能自动完成的事这个积累过程才是它最有魅力的地方。