半夜十二点,你对着群里弹出的告警消息发呆:日志目录快满了,几十个文件堆在那里,要按日期归档、要压缩、还要把三十天前的旧数据清理掉。手动敲命令不是不行,但下一次、下一个月、下一年呢?重复操作永远在消耗你的时间。这时候你就需要Shell脚本编程——把平时在终端里敲的一条条命令,写进一个文件里,让它按顺序、按条件自动执行。别把它想得多高深,它本质就是Linux命令的“组合拳”,只是加上了变量、判断、循环这些编程要素。几乎每一个接触过Linux的人,都会在某个时刻和它相遇。
这篇内容不是从“什么是操作系统”讲起的教科书,而是从我真实使用Shell脚本的经验出发,把手边的场景、踩过的坑、总结出的习惯一起梳理出来。看完你应该能做到三件事:看懂别人写的Shell脚本,自己动手写一个能解决实际问题的脚本,遇到排查问题时知道往哪个方向查。无论你是运维、后端开发,还是偶尔要处理服务器文件的数据分析师,这都值得花半小时读一遍。
1. Shell脚本到底是什么,为什么值得学
1.1 从一次手工操作说起:Shell脚本解决的问题
先还原一个我印象很深的场景。当时一台业务服务器上的应用日志是每天滚动生成的,文件名带日期,但运维要求保留最近30天,更早的要压缩归档。最开始我每天晚上用一条find命令加上一条tar命令来处理,过程大概是这样:先查目录里有哪些文件,按日期分批打包,再把旧的删掉。前几次还行,时间一长就发现麻烦——命令参数太长容易输错,漏掉某个子目录没人知道,赶上哪个月份有31天还会算错。
后来我花20分钟写了一个几十行的脚本,效果立刻不一样。我把“查日志、打包、归档、清理旧文件”这个完整流程固定下来,每天定时执行一次,再也没有出过错。这就是Shell脚本最核心的价值:把不稳定的手工步骤,变成稳定、可复现、可交接的自动化流程。它解决的不是某个高难度算法问题,而是“重复、易错、耗时”这三大日常痛点。
1.2 我理解的Shell脚本:命令解释器与胶水语言的双重角色
要理解Shell脚本,先得理解Shell本身。Shell是Linux系统里的一层命令解释器,它的工作很简单:你输入ls,它帮你调用对应的程序,再把结果返回给你。而Shell脚本,就是把这些你本来会手动输入的命令,预先把执行顺序和判断逻辑写到一个文本文件里,交给Shell逐行解释执行。
它同时扮演两个角色。第一个角色是“翻译官”,把人和操作系统之间的沟通变得流畅;第二个角色是“胶水”,把各种外部程序粘在一起。比如你用grep过滤内容,用awk做统计,用tar做压缩,再用rsync传输到远程机器,一条命令干一件事,脚本则负责把它们按正确的顺序串起来,中间还能做条件判断和错误处理。很多项目部署流程,本质上就是Shell和Python这类外部工具配合完成的自动化流水线。
所以我不建议把Shell脚本看作一门传统编程语言。它的优势不是计算和复杂数据结构,而是文件管理、进程控制、系统状态获取和批量化处理。知道这一点,你就不会在一个需要解析上万条JSON数据的场景里硬用Shell,而是选择合适工具的“胶水层”。
1.3 适用人群与典型场景
Shell脚本适合谁?我个人的判断是:只要你的工作会用到Linux终端,就应该掌握基础的那一小桶技能。运维工程师自然不用说是主力;后端开发在部署服务、排查容器日志时会用;数据分析师哪怕只是批量解压几个.tar.gz文件,掌握它也能省下不少时间。它不像编程那样需要系统学习数据结构,但它的知识密度高,学完立刻能回本。
典型场景我列几个最常见的:
- 批量文件管理:按规则重命名、移动、复制、压缩、清理过期文件。
- 定时任务:配合
cron实现每天备份、日志切割、监控检查。 - 系统状态巡检:检查CPU、内存、磁盘占用率,异常时发告警。
- 软件安装与部署:一条命令完成依赖安装、配置生成、服务启动。
- 日志处理:从海量日志中提取关键字、统计错误数量、生成报表。
这些场景的共同特点是:重复频率高、规则固定、对实时交互要求低。一旦写成脚本,收益是持续的。
2. 写脚本之前,先把环境与选型搞清楚
2.1 默认Shell的差异:bash、sh、dash与zsh
不少初学者拿到Linux,第一件事就是写脚本,却很少留意自己用的到底是哪个Shell。实际上,不同发行版默认Shell不一定相同。绝大多数情况下你遇到的是bash,但系统里可能还有sh、dash、zsh。它们之间并不是完全兼容的。
简单说,bash是功能最全面的,支持数组、关联数组、一些高级扩展语法,绝大多数网上流传的脚本教程都以bash为主。sh在不少系统上只是一个指向POSIX兼容Shell的软链接,它强调的是标准兼容性,但很多bash专属写法它不认。dash启动速度快、资源占用小,常在系统初始化脚本里出现,但同样不支持数组和一堆扩展语法,我在一个项目里就吃过亏:在bash里调试正常的脚本,切到dash后报错,后来才发现用了数组。zsh交互体验好,写脚本的人相对少,但很多macOS用户的默认Shell就是它。
所以在脚本第一行写着#!/bin/bash不是摆样子,而是明确告诉系统“用bash解释执行”。如果写成#!/bin/sh,请确保只使用POSIX兼容语法。我自己的习惯是:新写脚本一律用bash,除非明确要求兼容sh,否则不去给自己挖坑。
2.2 脚本文件的基本结构与执行方式
一个最小的Shell脚本只需要两行:
#!/bin/bash echo "hello shell"第一行#!/bin/bash叫shebang,它告诉内核用什么解释器来运行这个文件。第二行是实际命令。保存为test.sh后,执行方式有几种:
bash test.sh:直接以bash解释器运行,不要求文件有执行权限。chmod +x test.sh然后./test.sh:这是最常见的方式,前提是文件有执行权限,且shebang正确。source test.sh或. test.sh:在当前Shell进程里执行,而不是新开子Shell。这种方式常用于加载环境变量或函数定义。
这里有个容易混淆的概念:直接执行脚本时,系统会创建一个子Shell去跑,脚本里的cd不会改变你当前终端所在目录;而source执行时,脚本里的变量和目录切换会直接影响当前Shell。写脚本时如果想在脚本里切换目录,无所谓;如果希望脚本对当前会话生效,就必须用source。
2.3 权限与路径问题:容易踩的第一个坑
很多第一次跑脚本的人会遇到Permission denied。原因很简单,文件没有执行权限。我见过有人立刻加chmod 777,这不是好习惯。正确做法是只给所有者加执行权限:
chmod +x your-script.sh比权限更容易被忽略的是CRLF换行符。在Windows上编辑的脚本,换行符是\r\n,Linux下执行会在每行末尾多一个\r,经常报奇怪的“找不到命令”错误。我当时第一次遇到这个问题时,反复检查语法都没查出来,最后用file命令才看到问题。解决办法很简单:
sed -i 's/\r$//' your-script.sh或者用dos2unix命令处理。这类问题遇到一次就长记性了。
另一个常见问题是路径。脚本里如果用了相对路径,它依赖的是“执行脚本时所在的目录”,而不是脚本文件所在的目录。这会导致你在A目录执行能成功,在B目录执行却找不到文件。稳妥的做法是脚本一开头就获取自身所在目录,再基于它构造其他路径:
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"掌握了这三件事,你就能避开新手期的绝大部分障碍,放心往下写。
3. 核心语法细节拆解,照着用就行
3.1 变量、引号与命令替换
Shell脚本里的变量,和编程语言里的变量不完全一样。它本质上是字符串,但根据使用场景又可以当作数字、路径、“是/否”的标记。最简单的定义和引用是这样:
name="hello" echo "$name"注意等号两边不能有空格,否则Shell会把name当成一条命令。引用变量时我强烈建议加上双引号,特别是变量可能包含空格或特殊字符的时候。要是写成echo $name,一旦name的值是a b c,它会被拆成三个参数,在很多场景下就出问题了。如果变量里包含通配符,不加引号还会被展开成文件名列表,后果很微妙。
命令替换是另一个高频操作,它把一条命令的输出保存到变量里:
today=$(date +%Y%m%d)用$()比老式的反引号更清晰,而且支持嵌套。命令替换的空白字符会被自动吃掉一部分,所以如果输出有空格,尤其是文件路径,还是要加双引号:"$today"。
还有一个小技巧:变量赋值时用readonly声明只读变量,防止后面误修改:
readonly LOG_DIR="/var/log/myapp"脚本规模一大,这种保护很有价值。
3.2 条件判断与循环
条件判断是脚本“聪明”起来的关键。基本用法是if []、elif []、else和fi结尾。方括号左右都有空格,这是Shell语法里最容易忽略的细节。
if [ "$count" -gt 10 ]; then echo "too many" else echo "ok" fi这里$count是数字比较,用的是-gt;字符串比较用=;文件判断用-f、-d、-x等。新手经常把>直接写进去,结果被当成重定向。如果要写大于号,要么用-gt,要么在[[]]里使用>。
循环有两个常用形态。for循环适合遍历文件列表、数字范围:
for file in /var/log/myapp/*.log; do echo "process $file" donewhile循环适合按行读取文件:
while IFS= read -r line; do echo "$line" done < input.txtIFS=和-r是两处容易踩坑的细节:不设置IFS=,行首尾空格会被吃掉;不加-r,反斜杠会有特殊含义。很多批处理脚本处理日志时,在这上面丢过数据。
条件判断里还能组合多个条件,用-a(与)和-o(或),但我更推荐用&&、||或[[]],可读性更好。
3.3 函数与参数处理
脚本写到一定程度,逻辑会变复杂,这时就该用函数把重复代码收拢起来。定义和调用很简单:
function log_info() { echo "$(date '+%Y-%m-%d %H:%M:%S') INFO $1" } log_info "start job"Shell函数的参数用$1、$2这样的位置变量接收,没有明确的return返回值,最常见的做法是用echo输出结果,调用方用$(...)捕获。函数里也可以访问全局变量,但注意函数内部定义的变量默认也是全局的,除非用local声明:
function calc() { local result=0 ... }相对脚本整体的参数处理,$0是脚本名,$1到$9是参数,$#是参数个数,$@是所有参数列表。配合shift可以逐个取出剩余参数。在写支持命令行选项的脚本时,getopts是标准方案,能解析-f xxx、-v这类参数,虽然看起来很老派,但很可靠。
3.4 常用的文本处理三兄弟:grep、sed、awk
Shell脚本里真正干“重活”的,往往是文本处理命令。grep负责筛选,sed负责按行替换和编辑,awk负责按列提取和统计。三兄弟掌握了,大部分日志和配置文件处理都不在话下。
grep最基本的是按关键字过滤:
grep "ERROR" app.log要忽略大小写加-i,要输出行号加-n,要查找递归目录加-r。
sed最常用的是替换操作:
sed -i 's/old/new/g' file.txt注意-i是原地修改,不加它只是输出结果不写入文件。如果要把所有数字替换成空字符,可以写sed -i 's/[0-9]//g'。在处理配置文件模板时,我经常用sed把占位符替换成真实值。
awk可以按列处理。默认按空白字符分割列,用$1、$2引用:
awk '{print $1, $NF}' access.logNF表示当前行有多少列,$NF就是最后一列。统计某列出现次数用:
awk '{count[$1]++} END {for(k in count) print k, count[k]}' access.log这段代码读起来可能有点吓人,但你可以把它当成一条固定套路记住,随手就能解决不少统计需求。文本处理三兄弟的准确用法需要积累,但一开始知道“筛选用grep、替换用sed、统计用awk”就够了。
4. 一个完整实战:批量日志归档脚本的诞生
4.1 需求拆解与设计思路
讲完基础,用一个完整例子把技术点串起来。假设场景:某服务器上有一个应用日志目录/opt/myapp/logs,里面有多个子目录,每个目录里都有一堆.log文件。我需要每天执行一次:
- 把所有超过1天的
.log文件按当天日期打包压缩。 - 打包文件放到归档目录
/data/backup/logs/日期下。 - 压缩完成后,删除被归档的原始日志文件。
- 保留15天内的归档数据,更早的归档包清理掉。
先别急着写命令。先把需求拆成几个模块:找文件、打包、移动、清理。每个模块单独思考,再组合。同时要考虑错误处理:如果某个子目录没有新文件需要归档,不能报错退出;如果打包失败,绝不能删除原始文件。这个设计原则很关键,自动化脚本一旦误删数据,比不执行还可怕。
4.2 关键代码逐段讲解
脚本我命名为archive_logs.sh,核心骨架如下:
#!/bin/bash set -euo pipefail LOG_ROOT="/opt/myapp/logs" BACKUP_ROOT="/data/backup/logs" TODAY=$(date +%Y%m%d) KEEP_DAYS=15 LOG_FILE="/var/log/archive_logs.log" log_message() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE" } mkdir -p "$BACKUP_ROOT/$TODAY" log_message "start archive process, today=$TODAY" find "$LOG_ROOT" -type f -name "*.log" -mtime +1 -print0 | while IFS= read -r -d '' file; do echo "$file" done > /tmp/archive_file_list.txt if [ ! -s /tmp/archive_file_list.txt ]; then log_message "no files to archive, exit 0" exit 0 fi while IFS= read -r file; do base_dir=$(dirname "$file") base_name=$(basename "$file") archive_name="$BACKUP_ROOT/$TODAY/${base_dir##*/}_${base_name}.tar.gz" tar -czf "$archive_name" -C "$base_dir" "$base_name" if [ $? -eq 0 ]; then rm -f "$file" log_message "archived and removed: $file -> $archive_name" else log_message "failed to archive: $file" fi done < /tmp/archive_file_list.txt find "$BACKUP_ROOT" -type f -name "*.tar.gz" -mtime +"$KEEP_DAYS" -delete log_message "finished archive cleanup"这段代码里,set -euo pipefail放在开头,是我推荐的习惯。-e表示遇到错误就退出,-u表示使用未定义变量时报错,pipefail表示管道中任何命令失败都会使整个管道失败。一开始你可能觉得它太严格,但实际写自动化脚本时,丑话说在前头比静默出错好得多。
查找文件用了find加-print0,这是为了处理文件名里有空格或换行的极端情况。配合while read -d ''可以逐行安全读取。这里再把文件列表落盘,是为了两步操作解耦:先明确准备归档哪些文件,再逐个处理。
打包时我用了-C "$base_dir" "$base_name",先切换到文件所在目录再打包具体文件名。这样生成的压缩包里不会带着一长串绝对路径,解压时也更干净。
重点是“先打包成功,再删除原文件”。这里用$?判断上一条tar命令的返回值,只有返回0才删原文件。这个顺序在实践中救过我很多次,之前有一次磁盘空间快满了,tar中途失败,但因为代码里先删后打包,直接丢了一批日志。后来我强制要求自己,所有涉及“移动/归档”的逻辑,必须先确认新位置数据完整,再动原文件。
4.3 执行结果与验证方法
把脚本保存后,第一步不是直接扔给cron,而是先手动跑一遍,同时观察输出:
bash -x ./archive_logs.sh-x会打印脚本执行过程中的每一条实际命令,对排查路径、变量值、逻辑分支非常有帮助。跑完之后,我习惯按几个维度验证:
- 归档目录下是否出现
日期目录,打包文件名是否符合预期。 - 打开一个压缩包,确认里面内容完整且没有多余层级。
- 原日志目录里,符合条件的文件是否被删掉,不符合条件的是否还在。
- 用
find检查归档目录里的老文件是否被清理。
在测试阶段我会把KEEP_DAYS临时改成1天,用几条历史文件快速验证清理逻辑,确认没问题后再改回15天。这比直接等15天可靠得多。
4.4 安全增强与异常处理
运行环境不会总是那么理想。磁盘可能满掉,目录权限可能不对,源日志可能正在被其他进程写入。这些情况脚本都得有所准备。
我后来给这个脚本加了几个增强:
disk_usage=$(df /data | awk 'NR==2 {print $5}' | tr -d '%') if [ "$disk_usage" -gt 90 ]; then log_message "disk usage high, stop archive to avoid overflow" exit 1 fi当目标磁盘使用率超过90%时,暂停归档,避免压缩包写到一半把磁盘塞满。这个判断我在另一个生产环境里确实触发过,当时日志增长异常,多亏提前停住,否则后果挺麻烦。
另外,tar命令本身可以加入--exclude排除特定文件模式,比如某些目录里的临时文件不需要打包。在文件查找阶段可以加更精确的-not -path条件,让归档范围更可控。整个脚本执行完后,我会从系统日志里检查LOG_FILE是否记录了正常结束标记,确保不是静默挂掉。
这样,一个可调度、可观测、带基础防护的日志归档脚本就落地了。放入crontab之后,每天自动运行,几乎不用人管。
5. 调试、排查与避坑指南
5.1 脚本调试三板斧:bash -x、set -e、管道行为
写脚本时,调试占了相当大一部分时间。最有用的工具是人类一问一答找逻辑,但命令层面的几招也能大幅提速。
第一招就是bash -x script.sh,让脚本输出每条命令展开后的结果。这是最直观的。变量没有展开?路径不对?条件分支走错了?一眼就能看出来。如果脚本太长,还可以在局部用set -x和set +x只调试某一段,这样输出不至于刷屏。
第二招是在脚本开头加set -e。虽然很多人对它又爱又恨,但日常脚本我仍然建议打开。set -e遇到非零退出码就中断执行,能避免程序在错误状态下继续跑,把一个局部失败扩大成整体问题。当然它也有副作用:某些命令的“失败”其实不是错误,比如grep没匹配到内容会返回1,这时set -e会让脚本中断。解决办法是在明确允许失败的命令后面补一段|| true,或者把它放在if条件里。
第三招是关于管道的。管道命令的退出码默认只看最后一个命令,set -e对管道前面的命令失效。比如:
cat missing.txt | grep x即使cat失败了,只要grep因为没输入而返回非零,最终状态才可能是失败;如果grep返回0,整个管道就被当成成功。把set -euo pipefail一起用,管道中任何一个环节失败都会暴露。这套组合是我现在写新脚本的默认配置。
5.2 我踩过的5个Shell脚本坑
这些坑不是我一个人踩过,但每次我都在群里看到有人再问。整理出来,希望能帮你直接避掉。
第一坑:忘记给变量加引号。路径含空格,变量展开后就被拆成多个参数。轻则命令报错,重则误删文件。所以我习惯了所有变量引用都带双引号,尤其是"$file"、"$1"这种位置参数。
第二坑:find和文件名中的换行。如果只用find -exec rm {},文件名里的空格还行,但换行和特殊字符会出问题。更安全的是find ... -print0加xargs -0或while read -d ''。多写两个字符,省掉无数麻烦。
第三坑:脚本里用cd不检查结果。如果cd到一个不存在的目录,脚本还继续往下走,后续所有相对路径操作就都作用在了错误目录上。稳妥做法是cd /path || exit 1,否则宁可用BASE_DIR构造完整路径。
第四坑:crontab里的环境变量差异。在终端里能跑的脚本,放进cron后可能找不到命令,因为cron环境比交互Shell干净得多。解决方式:在脚本里写全命令路径,或者手动导入PATH,比如export PATH=$PATH:/usr/local/bin。这个坑我当年排查了很久,最后发现是PATH没带上。
第五坑:忘了rm -f和tar之间的失败关联。前面说过,压缩失败不能删原文件。不要用tar ... && rm这种简单串联,因为中间任何信号中断都可能让条件判断失真,而是要用显式的返回值判断。
5.3 性能考量:什么时候不该用Shell
Shell脚本不是万能钥匙。它的主要成本在于每次执行命令都会启动子进程,循环处理成千上万个文件时会明显变慢。假设你有一个包含五十万行日志的文本,需要做复杂统计,awk往往还能接受,但如果要逐行执行外部命令,就非常吃力。
什么时候该换工具?我的判断标准是:
- 复杂字符串处理,尤其涉及转义、嵌套,优先用Python。
- 大量并发任务,优先用Python或专门的调度工具。
- 需要精确的错误堆栈和单元测试,Shell写起来太脆弱。
- 数据结构超过“二维文本表”,比如JSON嵌套,优先写Python或直接用
jq。
不过这不意味着Shell的地位低。在“快速处理一批文件、按顺序执行一串命令”这个领域,它依然是最短路径。关键是别硬扛:当逻辑复杂度超过脚本可维护的临界点,及时换语言是明智的。
6. 把脚本变成“工程”:模块化、命名与注释
6.1 函数划分与脚本骨架
当脚本超过几十行,我建议把它组织成几个功能区域:变量定义区、函数区、主逻辑区。这样别人拿到代码时,第一眼就能知道哪里该改、哪里不要动。我常用的骨架是这样:
#!/bin/bash set -euo pipefail # 配置区 LOG_DIR=... BACKUP_DIR=... KEEP_DAYS=... # 函数区 log() { ... } check_env() { ... } archive_one_file() { ... } cleanup_old_files() { ... } # 主逻辑区 check_env ...函数命名尽量体现动作,check_env、archive_one_file一目了然。每个函数只做一件事,如果函数里出现三个以上的局部阶段,就该考虑继续拆分。这个原则和写其他语言是一样的。
6.2 命名规范、日志输出与退出码
命名规范让人少做无用功。变量用全大写表示只读配置,函数名用蛇形命名,临时文件统一放到/tmp并加$$进程号避免冲突。脚本里每一个rm、mv这种破坏性操作之前,我都留有明确日志。
日志输出对排查问题太重要了。我习惯把日志写到固定文件,同时带时间和级别关键词:
log_info() { echo "[INFO] $(date '+%F %T') $*" >> "$LOG_FILE"; } log_error() { echo "[ERROR] $(date '+%F %T') $*" >> "$LOG_FILE"; }脚本退出码也要刻意设计。0表示成功,非0表示失败,不同失败原因可以用不同码区分,比如1是环境问题,2是逻辑错误,3是资源不足。定时任务调度工具对退出码非常敏感,设计好了,监控报警才准确。
6.3 如何管理自己的脚本库
脚本会越写越多,不好好管理就会变成一锅粥。我现在的做法是把所有常用脚本放在一个统一目录,比如~/bin,并同步到版本管理里。每个脚本都带同样的头部注释:作用、用法、参数、依赖命令、更新日期。
#!/bin/bash # 功能: 日志归档 # 用法: ./archive_logs.sh [--dry-run] # 依赖: tar, find, date # 最后修改: 2025-01-01另外,写脚本时我会尽量保持“幂等”。所谓幂等,就是同一个脚本重复执行多次,结果不会叠加出问题。比如归档脚本,如果第一次执行到一半失败,第二次重新跑不会把已经归档过的文件再打包成重复内容。具体做法是打包前先检查目标压缩包是否已存在,存在就跳过或覆盖前先警告。这个习惯在定时任务里极其重要,因为任务可能被手动重跑。
最后再分享一点个人的体会
我从最早只会写三行的echo脚本,到后来接手过上千行的部署脚本,中间最大的转折点是意识到:Shell脚本不只是命令的堆叠,它的价值在于“把一个人的操作经验,固化成一整套可复用的流程”。你每把一个手工步骤变成脚本,就多释放一份精力和注意力去处理真正需要人判断的事情。
如果你刚入门,我的建议也很简单:别追求一开始就写特别复杂的脚本,先把手边最重复、最烦人的那个操作自动化,哪怕只有五行。写完以后加个日志,放一句注释,让它跑起来。等你用上一周,你会自然地想给它加参数、加判断、加异常处理。那时候你就不是在“学Shell”,而是在用它解决自己的问题了。希望这篇内容能让你少走一些弯路,也欢迎你踩过哪些Shell脚本的坑,在评论区一起聊聊。