ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Shell脚本实战:从命令行到自动化运维的完整套路

Shell脚本实战:从命令行到自动化运维的完整套路

写Shell脚本这事儿,很多人都是被逼出来的。我自己就是先被一串又一串的命令搞得头大,然后发现“命令行是一条一条喂给电脑的,脚本是一次性把规则告诉电脑”,从那以后,只要是需要反复执行的活儿,我第一反应就是“能不能写个脚本”。Shell脚本说白了就是把一串Linux命令按逻辑组合起来,放进一个文件里,赋予执行权限后一次跑完。它能干的事非常多:批量备份、日志清理、监控告警、自动部署、数据处理……凡是我们在终端里手动敲过两遍以上的命令,都值得脚本化。这篇文章适合刚接触Linux没太久、想从“会敲命令”迈向“会写脚本”的人,也适合那些已经写了几个脚本但总觉得自己在“硬凑”的人,我会把日常积累的套路、坑、调试方法一起倒出来。

1. 我为什么劝你认真学Shell脚本(而不是只靠记忆命令行)

1.1 从“会敲命令”到“会写脚本”的分水岭

刚接触Linux的时候,我特别崇拜那些能一口气敲出一串复杂命令的人,感觉那才是高手。后来发现,真正的高手从来不靠手速,他们靠的是把复杂逻辑固化下来。Shell脚本就是这个“固化”的手段。

可以这么理解:命令行是“手动挡”,脚本是“自动驾驶”。手动挡当然灵活,每个路口你都能自己控制,但如果是每天上班通勤的固定路线,没有人会愿意天天手动换挡。脚本存在的意义,就是把那些重复的、固定的、容易忘记操作步骤的任务,交给一个可复现的文件去执行。

从“会敲命令”到“会写脚本”,你还需要跨过几个小台阶:

  • 理解脚本就是一个“命令的集合”,但集合里还有变量、循环、判断。
  • 搞懂执行机制:脚本里的每一行,本质上还是在启动一个子Shell去执行命令。
  • 学会给脚本传参数和接收输出。
  • 学会处理异常:命令失败了,是继续跑还是停下来。

很多人卡在第二个台阶上,因为总想着把脚本写出“程序”的样子,一上来就定义函数搞面向对象那套,结果把自己绕晕了。我的经验是:先用最简单的顺序结构解决眼下问题,等脚本写多了,再回头优化结构。

1.2 从需求到脚本:一个最朴素的分步思路

我在写脚本前后,心态上的转变其实就一句话:“先想清楚要干什么,再想怎么敲命令。”有一次我需要清理某个目录下七天前的日志文件,手动敲的流程是:先看目录里有哪些文件,再筛选七天前的,然后删除,最后确认一下磁盘空间有没有释放。第二周,我又要做同样的事,第三周还是同样的事,我就知道该写脚本了。

这个从“手动操作”到“写脚本”的转化过程,其实可以总结成四步:

  1. 把步骤写成大白话:找到目录、筛选旧文件、删除、报告结果。
  2. 把大白话翻译成命令:find、rm,或者再加上df。
  3. 把命令按照运行顺序写进文件。
  4. 加参数支持、加判断逻辑、加输出信息,让脚本更健壮。

别看这四步简单,绝大多数脚本卡住不是因为不会写命令,而是卡在第二步和第三步的衔接上。你以为一条命令能搞定,实际上命令在交互式终端里能用,放到脚本里就各种问题——比如变量没加引号、忘记处理空格路径、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脚本里的变量定义,name="abc",注意等号两边不能有空格。这个我想很多初学的人都遇到过:习惯性写成name = "abc",Shell会把name当成一个命令去执行,然后报command not found。这不是你不够细心,而是Shell语法就是这么“不顺手”,不踩一下根本记不住。

引号是另一个重灾区。双引号里的变量会展开,单引号里的内容就是纯文本。比如:

name="world" 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_DAYS=7 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版本支持情况不同,你本地测试通过了就好。

第二,为什么用-print0+while read而不是直接find ... -exec?因为日志文件名虽然是我们自己生成的,但保不准会带空格或者其他特殊字符。-print0会让find输出文件名时用空字符而不是换行来分隔,配合read -d ''就能安全地处理任意文件名。这个习惯我强烈建议养成,能让你避开很多隐藏很深的坑。

第三,删除前一定要确认压缩成功。我用$?检查上一条命令的返回值:tar成功了是0,失败是非0,只有成功了才执行rm。这个习惯特别重要——宁可压缩失败留着旧文件,也不要删了原文件后才发现压缩包是坏的。

3.3 加上容错和日常运维需要的输出

实战中还有一个容易忽略的问题,就是脚本执行过程中如果中途出错,后面的命令还会不会继续执行?默认情况下Shell脚本不会因为某条命令失败就停下来。这就可能导致:压缩失败了,但删除还是执行了。

一个比较稳妥的思路是在脚本开头加set -euo pipefail:

  • set -e:一旦脚本中任何命令返回非零状态,立即退出脚本。
  • set -u:如果使用了未定义的变量,直接报错退出。
  • set -o pipefail:管道命令中如果任何一段失败,整个管道的返回值就是失败的。

这个组合我是在踩过几次坑之后才真正接受并养成的习惯。刚开始觉得set -e太“激进”了,可能会导致脚本在遇到一些“预期内的小失败”时直接退出。后来我调整了写法:明知某条命令可能会失败、但希望脚本继续往下走的命令,就在末尾加|| true。这样既能有安全网,又能灵活处理预期中的失败。

日志输出方面,我的心得是“不要只把消息写给终端,还要落盘”。脚本如果是通过定时任务跑的,执行完之后你根本看不到终端输出,所以要把关键信息写到日志文件里,方便事后排查。脚本里我设置了LOG_FILE,但为了简化就没在每一行加重定向,实际你可以直接把需要落盘的输出用>> ${LOG_FILE} 2>&1追加进去。

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.sh
No 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的环境里没有那个工具所在的目录。后来我在脚本开头加了一行显式设置PATH:

export PATH="/usr/local/bin:/usr/bin:/bin:${PATH}"

从那以后,我的所有脚本,只要可能有部署到定时任务需求的,都会在开头处理一下PATH,这是一个简单的防御性写法。

另外,cron里运行的脚本如果有输出,默认会通过邮件发给本机用户,但很多服务器根本没配邮件服务,导致错误信息被丢弃。我建议在cron配置里把标准输出和标准错误重定向到日志文件,例如:

30 2 * * * /home/user/scripts/archive_logs.sh >> /home/user/logs/archive.log 2>&1

这样即使脚本出了问题,至少日志里能看到痕迹。

5.3 监控脚本的写法:取指标、做判断、发通知

很多人以为监控脚本很复杂,其实核心就是三件事:获取指标、判断阈值、通知。以磁盘使用率为例:

#!/bin/bash # 磁盘使用率监控,超过阈值则输出告警 THRESHOLD=80 CURRENT=$(df -h / | awk 'NR==2 {print $5}' | sed 's/%//') if [ "$CURRENT" -gt "$THRESHOLD" ]; then echo "磁盘使用率已超过阈值: ${CURRENT}%" # 这里可以接入通知,比如写入告警文件、调用接口等 else echo "磁盘使用率正常: ${CURRENT}%" fi

这个脚本里有个小细节:df -h | awk 'NR==2 {print $5}'取出的是根分区的使用率百分比,带一个百分号,再用sed 's/%//'去掉百分号,才能和THRESHOLD做数值比较。这里绕了一下,原因就是Shell里的数字比较不支持百分号,两者之间不是一个数字类型。

监控脚本和前面几个脚本不同,它通常要跑很多年,所以我觉得最值得花时间的地方不是一上来就写得很复杂,而是把“收集指标”和“发出告警”这两件事拆成两个函数,将来要增加告警渠道时,只需要改一个函数就够了。

6. 我在实际使用中的一些体会

说了这么多,最后聊一点个人体会。

写脚本这件事,说到底看的不是你能写出多高深的语法,而是你能不能在写之前把流程理清楚。我见过不少人一上来就写,结果脚本里全是“补丁式”的逻辑:这里加一句特殊情况判断,那里加一个临时变量,最后脚本又长又难读。其实大多数脚本要解决的场景都是重复劳动,只要把流程拆清楚,脚本自然就好写了。

我自己的习惯是:先手工跑一遍命令,确认每一步都能得到预期结果,再改写成脚本。这个过程能筛掉很多逻辑问题,比写完一堆再调试要省太多时间。另外,脚本里能写得通用一点就通用一点,哪怕只是把“固定路径”改成“可通过参数传入”,都会让脚本的寿命长很多。

再分享一个最后的技巧:脚本最好在一开始就做“幂等性”设计,也就是同一个脚本多次运行,不会因为重复执行而产生错误结果。比如归档脚本,跑第一次会把七天前的日志归档掉,跑第二次再运行,就不会重复归档同一天的日志,因为已经删掉了。这个思路能让脚本在cron里偶尔被误触多次时也安全。

Shell脚本的价值不在技术多高深,而在“解放双手”这四个字。把第一次做觉得麻烦的事记录下来,变成每次都能自动完成的事,这个积累过程,才是它最有魅力的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进