不少刚接触Linux的朋友都有一种感觉:命令背了一堆,遇到重复性工作还是只能一条一条手工敲。真正让Linux运维效率起飞的关键,恰恰是Shell脚本编程这门基本功。简单说,Shell脚本就是把一串命令写进文件里,让它批量、自动、可复用地去执行,这也是“脚本自动化基础”最核心的价值所在。这篇文章我会从脚本的底层逻辑讲起,再到变量、判断、循环、位置参数这些必备要素,然后结合真实场景把高频命令的坑和组合用法拆开揉碎,最后给出一套可以直接抄作业的自动化案例。无论你是刚入门的新手,还是写过几个脚本但总被各种报错卡住的进阶用户,这篇都能给你一些用得上的东西。
1. 脚本到底是个什么东西——先建立正确的自动化思维
1.1 别把脚本当“高级命令”,它是一套执行逻辑
我见过太多人把Shell脚本当成“把命令罗列在一起”的文件,这种理解虽然不算错,但对写脚本这件事帮助不大。Shell脚本的本质是:让Shell解释器按你设定的顺序、条件和循环去执行一系列命令,并且在这个过程中处理变量、接收参数、判断结果。
用人话说,你在终端里敲命令就像手动炒菜,每一步都得自己看着来:放油、下菜、加盐、翻炒。而脚本相当于你把整个菜谱写下来,交给一个厨师去照做,他还会自己检查“盐放够了没有”“菜熟没熟”。这个“检查”就是脚本里的判断逻辑,“反复翻炒”就是循环,“这一勺盐是多少”就是变量和参数。
理解了这一层,你就明白为什么脚本自动化基础这么重要了。比如你要在20台服务器上批量改一个配置文件的权限、要把一天的日志按小时归档、要定时检测某个进程是否挂了然后自动拉起,这些事靠手工敲命令会让人崩溃,但用脚本写下来,就是几秒钟的事。
1.2 自动化思维的三个层次:替代、复用、编排
我在带新人的时候,经常跟他们讲一句话:写脚本不是为了显得自己很厉害,而是为了把时间省下来去干更重要的事。自动化思维是可以分层次的:
- 第一层是替代:把一条长命令、一串重复命令写进脚本,减少击键量。这个层次门槛最低,但它能让你尝到甜头。
- 第二层是复用:写一个脚本,通过传递不同的参数来适应不同的场景。比如一个日志清理脚本,传路径和保留天数就能处理任意目录,不用每次改代码。
- 第三层是编排:把多个脚本、多个命令组合成一个完整流程,加入判断、循环、异常处理。比如“先备份、再更新、然后重启服务、最后检查健康状态”,这就是一个标准的部署自动化脚本的雏形。
绝大多数人卡在第二层到第三层之间。原因不是不懂语法,而是缺少“用程序逻辑去思考运维任务”的思维习惯。举个例子,你接到一个任务:把某个目录下的所有.log文件重命名,加上日期后缀。第一反应可能是mv a.log a_20250101.log这样手动敲,但你要是用循环加变量去写,一条for就能处理所有文件。
2. 脚本自动化的核心元素——变量、作用域与流程控制
2.1 变量:脚本的“记忆单元”,以及被误解的export
变量是脚本里最基础也最重要的概念,它相当于给数据贴了个标签,之后你可以到处引用。Shell变量的语法很直白,name=value,注意等号两边绝对不能有空格,这是新手最容易踩的坑。我见过有人写name = value,然后报command not found,一脸懵。
光会定义变量还不够,你得理解变量的作用域。默认情况下,你在脚本里定义的变量是“局部”的——只对当前Shell进程有效,不会传递给子进程。这里就要说到export了。
很多教程会告诉你“export是导出环境变量”,但没告诉你到底导出了什么。实际上,export做的事是把变量标记为“可传递给子进程”。打个比方,普通变量是你写在便签上的信息,只给你自己看;export之后的变量,相当于你把便签贴在了工牌上,你的下属(子进程)一看工牌就知道这些信息。
看一个我会在实际教学中反复用的例子:
#!/bin/bash MY_NAME="linux" echo "当前进程: $MY_NAME" # 启动一个子shell试试 bash -c 'echo "子进程直接读取: $MY_NAME"' export MY_NAME bash -c 'echo "子进程export后读取: $MY_NAME"'执行结果非常直观:第一次子进程读不到,export之后就能读到了。很多人写脚本时,发现调用的其他脚本怎么都获取不到自己定义的变量,问题就出在这里——忘了export。
2.2 条件判断:if、test与那个让人迷惑的-n
自动化的核心能力之一就是“会看情况办事”,这个在Shell里靠if判断实现。最基础的写法:
if [ 条件 ]; then 命令 else 命令 fi注意那个[ ],它实际上是一个叫test的命令,所以方括号两边必须有空格。写if [$name ]会直接报错。
条件判断里,字符串判断是高频操作,尤其是-n。-n的意思是“字符串长度不为0”,也就是“变量非空”。常见的组合是:
if [ -n "$1" ]; then echo "收到了第一个参数: $1" fi这种写法的典型应用场景是:脚本必须在有参数时才能继续执行。如果用户忘了传参,就提示用法并退出。实际操作中我强烈建议变量加双引号,因为变量为空时,[ -n $1 ]会被解释成[ -n ],而-n本身是个非空字符串,判断结果恒为真,这就会产生逻辑错误。这类问题排查起来非常隐蔽,我后面会专门讲。
关于判断条件,我还想提醒几个容易混淆的:
-z表示“字符串为空”,和-n正好相反-f判断文件是否存在且是普通文件,-d判断是否为目录-eq、-ne、-gt、-lt是整数比较,不要和=(字符串比较)混用
2.3 循环:for循环批量处理的威力
说for循环是脚本自动化第一功臣一点也不夸张,因为日常运维里到处都是“批量”需求。最常用的两种写法:
# 写法一:遍历一段数字 for i in {1..10}; do echo "第 ${i} 次" done # 写法二:遍历一个列表 for file in /var/log/*.log; do echo "处理文件: ${file}" done # 写法三:C风格 for ((i=1; i<=10; i++)); do echo $i done我在批量处理文件时,最常用的是第二种写法。比如压缩多天的日志,直接一条:
for f in /data/logs/access_2025*.log; do gzip "$f" done这里必须提醒:for file in /var/log/*.log在没有任何匹配文件时,file会被赋成字面量/var/log/*.log,也就是通配符本身。这种野路子的结果就是后面命令拿一个不存在的文件去操作,报错还不容易发现。预防办法是先判断文件是否存在,或者用nullglob之类的选项。
2.4 位置参数与shift:让脚本“接得住话”
写一个能接收参数的脚本,是脚本复用性的关键一步。Shell里$1、$2就是第1个、第2个参数,$#是参数总数,$@是所有参数。但很多人不知道shift的妙用。
shift的作用是把所有位置参数向左移动一位,原来的$2变成$1,原来的$1就被丢掉了。这有什么用?最经典的应用是处理带选项的命令行。比如你写一个脚本,需要支持-f 文件名 -d 天数这种带值参数:
#!/bin/bash while [ $# -gt 0 ]; do case "$1" in -f) FILE="$2" shift 2 ;; -d) DAYS="$2" shift 2 ;; *) echo "未知参数: $1" exit 1 ;; esac done echo "文件: $FILE, 天数: $DAYS"这种“循环+shift+case”的组合,是很多高级脚本里处理参数的通用框架。理解了shift,你就能看懂别人脚本里那些“莫名其妙”的shift 2是在干什么——跳过选项名和它的值,处理下一个参数。
3. 高频命令的深度用法与组合实战——cd、cat、重命名一次讲透
3.1 cd和cat:你以为你会的,可能真的没弄明白
cd和cat算是Linux里最常用的两个命令了,但恰恰是基础命令,很多人用的时候有认知偏差。
先看cd。它不只是“切换目录”这么简单,它直接影响的是当前Shell的工作路径,而这个路径决定了你脚本里所有相对路径的解析起点。写脚本时我基本不用cd,改用绝对路径或者变量拼接,就是因为cd会让脚本的路径体系变得脆弱。你在脚本里cd /data后执行rm somefile,如果/data不存在,脚本不会停,而是继续执行rm,此时它删的是当前目录下的somefile,这是非常危险的事。
实际开发中我推荐的做法是:
BASE_DIR="/data/app" LOG_DIR="${BASE_DIR}/logs" if [ -d "$LOG_DIR" ]; then cd "$LOG_DIR" # 在确认目录存在后才cd else echo "目录不存在: $LOG_DIR" exit 1 fi再看cat。很多人只用它来查看文件,却忽略了它在脚本里有两个非常高频的用法:一是读取文件内容赋值给变量,二是配合EOF生成多行文本。
# 读取文件到变量 content=$(cat /etc/hostname) echo "主机名是: $content" # 用cat生成多行配置文件 cat > /tmp/nginx.conf <<EOF server { listen 80; server_name example.com; } EOF第二种写法在自动化部署场景里几乎是标配。你不用再手写文件再上传,脚本自己就能生成想要的配置文件,配合变量替换,一套模板适配多套环境。
3.2 重命名的正确姿势:从mv到批量处理的进化
和重命名相关的热词里,很多人搜“linux用shell重命名文件”。单文件重命名很简单,mv oldname newname,但批量重命名就有讲究了。给大家分享一个我常用的批量加日期后缀的脚本:
#!/bin/bash # 批量给指定目录下的.log文件加上日期后缀 TARGET_DIR="$1" DATE_SUFFIX=$(date +%Y%m%d) if [ -z "$TARGET_DIR" ]; then echo "用法: $0 目标目录" exit 1 fi for file in "${TARGET_DIR}"/*.log; do if [ -f "$file" ]; then base="${file%.log}" new_name="${base}_${DATE_SUFFIX}.log" mv "$file" "$new_name" echo "已重命名: $file -> $new_name" fi done这里有两个小技巧值得展开。${file%.log}是Shell的变量模式匹配,%表示从尾部删除最短匹配,也就是把.log后缀摘掉,然后拼接新的后缀。另一个技巧是if [ -f "$file" ],用来防止通配符没有匹配到任何文件时的误操作。
批量重命名场景里还会有更复杂的需求,比如“去掉文件名中的特定字符串”“改成统一前缀”,思路都是先提取、再拼接、最后mv。核心不是mv这个命令本身,而是你怎样用变量操作和循环把命名规则表达出来。
4. Shell常见坑与排查技巧实录——这些年我踩过的雷
4.1 变量加不加引号,结果天差地别
在我遇到的所有Shell问题里,因为引号引发的问题占比最高。前面提到过[ -n $1 ]的陷阱,这属于变量没加引号导致语义变化的典型。再举一个更隐蔽的例子:
name="" if [ -n $name ]; then echo "非空" else echo "为空" fi你以为会输出“为空”,实际上输出的是“非空”。因为[ -n $name ]在name为空时,被解释成[ -n ],而-n作为字符串长度为2,不为空,所以判断为真。这个Bug非常经典,几乎每个写过Shell的人都踩过。解决方式就是任何变量都养成加双引号的习惯:[ -n "$name" ]。
同样的道理适用于变量展开。echo $file和echo "$file"表面上差不多,但文件名里有空格时,不加引号就会被拆成多个词,后面命令就懵了。写脚本的黄金法则:变量出现的地方,默认加双引号,除非你有明确理由不加。
4.2 语法细节:空格、换行和隐藏字符
Shell对空格和换行的敏感程度远超其他语言,这是新手最大的拦路虎。a=b是赋值,a = b是执行一个叫a的命令并传两个参数。[条件]和[ 条件 ]也不一样,后者才是正确写法,因为[是一个命令,命令和参数之间必须有空格。
还有一个陷阱是Windows下编辑脚本导致的问题。在Windows里写脚本,保存后传到Linux执行,经常报$'\r': command not found。这是因为Windows的换行是\r\n,Linux只认\n,多出来的\r被当成命令的一部分。解决办法是转换格式:
sed -i 's/\r$//' script.sh # 或者用 dos2unix script.sh4.3 crontab环境变量与脚本执行环境的差异
很多人写好的脚本手动运行时一切正常,一放到crontab里就出幺蛾子。最典型的场景是:脚本里用了某个命令,手动执行能找到,定时执行就报command not found。
原因在于,手动执行脚本时,你的Shell已经加载了/etc/profile和~/.bashrc,PATH环境变量里包含了各种可执行文件路径。而crontab执行脚本时用的是非交互Shell,加载的环境变量少得可怜,PATH可能只剩/usr/bin:/bin。解法也简单,脚本开头显式声明环境:
#!/bin/bash source /etc/profile export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"还有一种更隐蔽的情况:脚本在终端里执行正常,但通过sh script.sh执行就报错。这通常是因为脚本里有[[或${var//pat/rep}等bash专用语法,而你用sh调用时,实际解释器可能是dash,它不认这些高级语法。规范做法是脚本开头写#!/bin/bash,执行时用bash script.sh或直接./script.sh,不要用sh script.sh去跑bash脚本。
4.4 调试三板斧:bash -x、set -x和回显
脚本出问题不可怕,可怕的是你不会排查。我最常用的方法是bash -x script.sh,它能逐行显示脚本执行的每一步,展开变量后的实际命令都会打印出来,前缀+表示执行的命令。看输出你就能定位到到底是哪一步出了问题、变量值是什么。
如果不想整段跑,可以在脚本内部加set -x,从该位置开始打印调试信息,配合set +x关闭:
#!/bin/bash set -x echo "调试这一段" set +x echo "这一段不调试"还有一个容易被忽略的排查工具是echo。在关键步骤前后加输出,把变量值打出来,虽然原始但异常好用。尤其是判断分支比较多的时候,在if的每个分支里加个提示,你立刻知道脚本走了哪条路。
建议所有脚本开头加set -u,它能让脚本在遇到未定义变量时报错退出,而不是静默地用空值继续跑。很多诡异问题就是变量的名字拼错了,Shell却把空的未定义变量当作合法值用,导致结果莫名其妙。再加一个set -e,遇到任何命令失败就退出,避免错误被掩盖、脚本带着错误状态一路执行下去。不过set -e有些时候会误伤,比如grep没匹配到内容返回非零、你的脚本逻辑里这不算错误,所以要根据场景选择。
另外,排查问题时别忽略语法检查。bash -n script.sh只做语法检查不执行,能在运行前发现拼写、引号配对这类低级问题。
5. 脚本自动化的落地案例——三个可以直接抄的实战脚本
5.1 场景一:日志文件按日期归档并清理过期日志
日志处理是运维自动化里最先接触也是最典型的需求。这个脚本要做的事:把昨日日志重命名加上日期后缀,然后删除超过30天的旧日志。
#!/bin/bash # 日志归档与清理脚本 # 用法: ./log_archive.sh /var/log/myapp 30 LOG_DIR="$1" KEEP_DAYS="${2:-30}" YESTERDAY=$(date -d "yesterday" +%Y%m%d) if [ -z "$LOG_DIR" ] || [ ! -d "$LOG_DIR" ]; then echo "错误: 日志目录不存在或未提供" exit 1 fi # 归档昨日日志 for file in "${LOG_DIR}"/*.log; do if [ -f "$file" ]; then base="${file%.log}" new_name="${base}_${YESTERDAY}.log" mv "$file" "$new_name" echo "归档: $(basename "$file") -> $(basename "$new_name")" fi done # 清理过期日志 find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS" -delete echo "已清理 ${KEEP_DAYS} 天前的日志"这里用到了几个值得细说的点。${2:-30}是Shell的参数默认值写法,意思是:如果第二个参数没传或为空,就用30。date -d "yesterday"能得到昨天的日期。find -mtime +30表示修改时间超过30天的文件,-delete直接删除。这三个都是自动化脚本里的高频件。
5.2 场景二:批量重命名文件并统一格式
这个脚本解决的是素材文件批量整理问题。比如你有一堆照片,名字乱七八糟,想统一改成photo_001.jpg这种格式:
#!/bin/bash # 批量重命名:统一为指定前缀加序号 # 用法: ./batch_rename.sh /path/to/photos photo DIR="$1" PREFIX="${2:-file}" EXT="${3:-jpg}" COUNT=1 if [ ! -d "$DIR" ]; then echo "错误: 目录不存在" exit 1 fi for file in "${DIR}"/*."${EXT}"; do if [ -f "$file" ]; then new_name="${PREFIX}_$(printf "%03d" $COUNT).${EXT}" mv "$file" "${DIR}/${new_name}" echo "重命名: $(basename "$file") -> ${new_name}" COUNT=$((COUNT + 1)) fi doneprintf "%03d"的作用是把数字格式化为三位,不足补零,这样排序时文件名不会出现photo_10排在photo_9前面的问题。$((COUNT + 1))是Shell的算术运算语法,注意变量名前不需要加$符号也能运算。
这种脚本写起来不难,但能极大提升整理文件的效率。我有一次整理几百张活动照片,手动改会疯掉,用类似脚本几秒钟全搞定。
5.3 场景三:系统巡检脚本生成报告
巡检脚本是自动化运维的另一个典型场景。它把系统状态搜集下来,生成一份可读的报告:
#!/bin/bash # 系统基础巡检脚本 REPORT="/tmp/sys_check_$(date +%Y%m%d_%H%M%S).txt" { echo "=== 系统巡检报告 ===" echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" echo "主机名: $(hostname)" echo "" echo "--- 系统负载 ---" uptime echo "" echo "--- 内存使用 ---" free -h echo "" echo "--- 磁盘使用 ---" df -h echo "" echo "--- CPU占用TOP5 ---" ps aux --sort=-%cpu | head -6 echo "" echo "--- 当前监听端口 ---" ss -tlnp } > "$REPORT" echo "巡检报告已生成: $REPORT"这里用的{ ... } > file技巧很多人不熟:它把一组命令的标准输出整体重定向到同一个文件,省得每条命令单独写一次重定向。配合date生成带时间戳的报告文件名,每一份报告都不会被覆盖。
如果想让脚本更智能一点,可以加上条件判断,比如磁盘使用率超过80%就额外输出警告。巡检脚本的价值不在于命令有多高级,而在于它把散落的检查项组织成了一个标准化的、可重复执行的产品。
5.4 如何把这些脚本纳入定时任务
脚本写好了,最后一步是让它自动化地跑起来。crontab -e打开的是当前用户的定时任务表,每一行的格式是“分 时 日 月 周 命令”。举个实际例子:
# 每天早上3点执行日志归档 0 3 * * * /opt/scripts/log_archive.sh /var/log/myapp 30 # 每周一上午9点执行系统巡检 0 9 * * 1 /opt/scripts/sys_check.sh加上定时任务之前,记得先确认脚本有执行权限:chmod +x script.sh。另外我建议把crontab里的命令用绝对路径写完整,包括脚本路径和日志路径,这能避免环境不同导致的诡异问题。日志重定向也要养成习惯,>> /tmp/cron.log 2>&1,这样脚本出错时你有迹可查。
6. 我的几点体会
写Shell脚本这几年,我最大的感受是:语法真的不难,难的是把复杂任务拆解成清晰的执行步骤,然后用Shell的逻辑把它表达出来。初学者不妨从最笨的办法入手——先手动执行一遍要自动化的任务,把每一步命令记录下来,再逐步替换成变量、加上判断和循环,脚本自然就成型了。
另外,不要试图写出一个万能脚本。脚本越通用,可能性和分支就越多,调试成本也越高。实际运维中我更倾向于把一个大任务拆成几个职责单一的小脚本,再用一个主脚本把流程串起来。这样做的好处是单个脚本好理解、好排错,出了问题时定位范围小,也不会因为一个环节挂了把整条链路拖垮。
最后想说:Shell脚本是那种投入产出比特别高的技能,花两周时间把基础打牢,之后几年的运维效率都会因此受益。这篇提到的坑和技巧,都是我实际干活时反复用到的,希望对你有点帮助。