
干运维这行越往后越会发现一个扎心的事实新技术年年冒但真正在关键时刻帮你兜底的还是那几行稳定得不起眼的 Shell。Linux 环境下的 Shell 编程不是“会不会敲命令”的问题而是能不能把重复劳动批量甩给机器自己跑的问题。这篇内容我准备带你把 Shell 从“能看懂、能改两行”的状态一路推到“能独立写自动化运维脚本并且敢放到生产环境里定时执行”的水平。适合谁来读刚接触 Linux、对各种命令还停留在复制粘贴阶段的运维新手被日常巡检、日志清理、服务拉起、备份同步这些重复操作逼到想骂人的开发还有想系统把手上的服务器管理工作“偷懒化”的进阶用户。看完你会有个很清晰的答案Shell 到底要怎么组织才能让脚本在无人值守的情况下不出幺蛾子同时出了问题还能第一时间定位。1. 先把思路理清楚为什么 Shell 依然是自动化运维的根1.1 选 Shell 而不是选 Python 的逻辑很多刚从 Python 转过来的人会问既然写自动化为什么不用 PythonPython 能做更复杂的数据处理、写更优雅的类还有完善的第三方库。但落到服务器运维场景Shell 有几个别人替代不了的优势。第一是依赖极轻。几乎每一台 Linux 服务器都自带 bash不需要虚拟环境、不需要 pip install没有版本兼容地狱。你去处理一台老掉牙的 CentOS 6或者某个裁剪版系统Python 版本可能五花八门但 /bin/sh 始终躺在那里。第二是管道思维。Shell 最强大的地方是把各种命令粘合在一起一个命令的输出变成下一个命令的输入。这种粒度极小的工具链配合在排查问题、清洗日志、批量处理文本时效率极高。Python 需要打开文件、for 循环、正则匹配Shell 一行 awk 完事。第三是交互式查错成本低。我们做运维很多场景是“人先验证几条命令然后写进脚本固化下来”。Shell 可以直接在终端敲出来看效果而 Python 你得来回改文件执行。这种即时反馈对于日常维护工作太重要了。当然如果任务真的复杂到需要面向对象、需要复杂数据结构、需要操作数据库 API那还是老实上 Python 或 Go。我的经验是脚本超过三四百行、逻辑判断层层嵌套时就该考虑换语言了。Shell 适合做“命令的指挥者”不适合做“复杂的计算器”。1.2 好脚本的四个标准很多人写脚本能跑就行。但跑通了和能上线中间其实差着一条线。我判断一个自动化脚本能不能扔到生产环境看四个点第一个是退出码明确。脚本执行的每一步都要能反馈成败不能整个跑下来全是 0出了问题也不知道在哪个环节断的。第二个是可重入也就是脚本重复执行不会出乱子。比如日志清理脚本上次没跑完留下的临时文件下次跑不会覆盖正常数据。第三个是日志可追溯。脚本不是“有人盯着跑”的东西定时执行之后它静悄悄地运行出了问题必须留痕。第四个是有自我保护机制。比如并发执行时防止脚本被重复拉起关键操作前做合法性检查这些细节决定脚本的成熟度。这四个标准前面写代码时可能不觉得真出一次事故你就明白了。我见过太多案例脚本在测试环境完美上线之后因为一个目录不存在就崩了然后因为退出码始终是 0监控还发现不了。所以后面写所有示例我都会带着这个标准去写。1.3 自动化脚本的基本结构一个成熟的 Shell 脚本不是顺着从上到下写命令就行。我会习惯性拆成几段顶部是解释器和脚本说明。#!/bin/bash指定解释器下面写清楚脚本用途、参数说明、作者和修改日期。这不是形式主义半年后再翻脚本你会感谢这些注释。然后是环境初始化。定义变量、设置 PATH、加载公共函数库、创建临时目录。这一步非常关键因为 cron 执行时的 PATH 跟终端不一样很多命令找不到就挂在半路。中间是主体逻辑。我习惯把不同功能拆成函数主流程只负责按顺序调用并对每个函数的返回值做判断。底部是收尾清理。删除临时文件、释放锁、输出结束日志。别小看这一步临时文件堆积造成的磁盘满事故我遇到不止一次。2. 自动化的地基常用命令和 Shell 语法这样学最快2.1 变量、引号和命令替换最常见的翻车点Shell 的变量用起来简单但坑也不少。变量定义赋值号两边不能有空格nameserver01和name server01一个对一个直接报错。引用变量时除非你百分之百确定变量内容不会有空格否则一定要加双引号写成$name。不加引号系统会按空格把变量拆成多个词这就是很多脚本在本地跑没问题、一遇到带空格的路径就崩的根源。单引号和双引号的区别也要刻进脑子里。单引号内部所有字符都是字面量$不会被展开双引号内部$、反引号这些会被解释。我经常看到新手写路径拼按时出错就是因为单双引号用反了。命令替换有两种写法反引号和$()。我强烈推荐用$()因为反引号里面遇到嵌套时转义能把人绕晕。比如你想拿到当前目录下的文件名数量用count$(ls | wc -l)就干净利落。算术运算在 Shell 里也容易懵。num12结果根本算不出来它只是把字符串存进去。要计算就得用$((num1))或者let。批量处理端口号、生成序号这些场景掌握$(( ))是基本功。2.2 条件判断和循环让脚本开始有脑子if判断是脚本的决策中枢。判断文件存不存在用-f目录用-d变量是否为空用-z。字符串比较写数字比较写-eq这个写错就是运行时才报错坑得很。这里有个容易踩的细节在[ ]内部中括号两边必须有空格变量最好都带上双引号。我写判断时更推荐[[ ]]双中括号它是 bash 的扩展比单中括号更安全支持、||直接写在里面不需要转义。注意/bin/sh不一定支持[[所以脚本第一行如果写#!/bin/sh最好别用写#!/bin/bash就放心用。循环里for常用于遍历一批主机名或文件列表while read line常用于逐行处理文件。逐行读文件的写法有一种坑就是管道后面跟while会变成子 shell循环内部赋值的变量在外部取不到。我后面专门讲这个坑。2.3 文本处理三件套grep、sed、awk自动化绕不开日志和配置文件所以文本处理就是 Shell 的核心肌肉。grep用来过滤grep ERROR app.log能直接捞出错误行。但生产上我更多用grep -c统计错误次数用grep -v排除干扰项比如过滤注释行。sed擅长按行处理。最常用的是替换sed -i s/old/new/g file-i直接改文件。注意-i在各平台下行为不完全一致macOS 上要写成sed -i Linux 上直接写。自动改配置文件的场景sed之前一定要先备份。awk是按列处理的神器。默认按空格切分$1、$2取列内置变量NR是行号NF是当前行列数。查看磁盘使用率、解析日志字段、统计访问量awk 都顺手得离谱。很多人觉得这三个命令难记我的笨办法是只记最常用的套路剩下的现场查。不用强迫自己把 man 手册背下来但每个命令的“高频套路”要滚瓜烂熟。2.4 函数化把脚本从面条代码里救出来脚本一旦超过几十行如果不做函数化读起来就是一碗面条。函数定义很简单function check_disk() { local disk_usage$(df -h / | awk NR2 {print $5} | tr -d %) if [ $disk_usage -gt 90 ]; then echo 磁盘使用率已达 ${disk_usage}% return 1 fi return 0 }定义函数时function关键字可写可不写我习惯写上一眼能认出来。函数里的循环、判断、命令都直接从终端操作搬进来不需要额外语法。一个关键习惯是函数内部用local定义局部变量。不用local的话变量是全局的函数之间互相覆盖值出了事你根本不知道是谁改的。函数返回值要留意return 0表示成功非 0 表示失败。但注意 Shell 函数的返回码只能传数字没法传字符串想拿到字符串结果就得配合全局变量或命令替换输出。3. 实操案例写一个能直接上线的服务器巡检脚本3.1 需求拆解先列清单再动手写脚本最忌讳打开编辑器就开始敲。我先描述需求假设现在要巡检一批 Linux 服务器需要检查 CPU 负载、内存使用率、磁盘使用率、关键进程是否存活然后把这些结果汇总发到工作群。拆开看每个检查点独立成一个函数主流程循环调用最后生成一份汇总文本。巡检结果的输出既要人眼能看也要方便后续脚本解析。所以我用固定的格式输出时间|指标名称|数值|状态。脚本要支持传入参数比如--threshold 90自定义磁盘告警阈值。参数解析用getopts来做比你手写一堆$1、$2判断可靠得多。3.2 完整脚本与逐段说明直接上一份我常用结构的巡检脚本代码里有详细注释#!/bin/bash # # description: 服务器基础巡检脚本 # author: ops # date: 2024-01-01 # usage: bash check_server.sh # set -u DISK_THRESHOLD${DISK_THRESHOLD:-90} MEM_THRESHOLD${MEM_THRESHOLD:-90} LOAD_THRESHOLD${LOAD_THRESHOLD:-4.0} CHECK_PROCESS_LIST(nginx mysqld) result_file/tmp/check_result_$(date %Y%m%d).txt # 检查磁盘使用率 check_disk() { local disk_usage disk_usage$(df -P / | awk NR2 {print $5} | tr -d %) local statusOK [ $disk_usage -ge $DISK_THRESHOLD ] statusWARN printf %s|磁盘使用率|%s%%|%s\n $(date %F %T) $disk_usage $status $result_file } # 检查内存使用率 check_mem() { local mem_usage mem_usage$(free | awk /Mem:/ {printf %.0f, $3/$2*100}) local statusOK [ $mem_usage -ge $MEM_THRESHOLD ] statusWARN printf %s|内存使用率|%s%%|%s\n $(date %F %T) $mem_usage $status $result_file } # 检查最近1分钟平均负载 check_load() { local load_avg load_avg$(uptime | awk -Fload average: {print $2} | cut -d, -f1 | tr -d ) local statusOK if awk -v load$load_avg -v thr$LOAD_THRESHOLD BEGIN {exit !(load thr)}; then statusWARN fi printf %s|系统负载|%s|%s\n $(date %F %T) $load_avg $status $result_file } # 检查关键进程 check_process() { for proc in ${CHECK_PROCESS_LIST[]}; do if pgrep -x $proc /dev/null 21; then printf %s|进程检查|%s|OK\n $(date %F %T) $proc $result_file else printf %s|进程检查|%s|WARN\n $(date %F %T) $proc $result_file fi done } # 主流程 main() { : $result_file check_disk check_mem check_load check_process cat $result_file } main这段代码有几个细节值得讲。开头set -u的意思是变量未定义就报错。我以前吃过亏一个变量名拼错脚本照样跑结果把数据写到了别的地方。加了这个选项至少能拦住一部分低级错误。磁盘检查命令df -P /中-P是为了避免输出换行某些系统磁盘路径长时不加-P会折行awk 取列就乱了。awk NR2 {print $5}取的是根分区使用率tr -d %把百分号删掉方便后面数值比较。内存检查用free时有的系统输出第二行是Mem:开头有的不是。我用/Mem:/正则匹配更稳。$3/$2*100是已用内存除以总内存printf %.0f取整避免出现小数比较时的问题。负载检查里那个awk BEGIN {exit !(load thr)}可能看着绕。原因是load_avg是字符串Shell 里直接拿[ $load_avg -gt $LOAD_THRESHOLD ]遇到小数会报错因为-gt只支持整数。让 awk 做浮点比较然后用退出码判断结果这个写法在需要小数运算时非常实用。还有一个细节函数里把输出追加到统一的结果文件而不是直接echo到终端。这样以后要扩展发送逻辑只需要在main里加一行调用发送函数就行职责分离。3.3 定时执行和消息通知脚本写好了真正让它“自动化”的是cron。用crontab -e编辑当前用户的任务*/5 * * * * /bin/bash /opt/scripts/check_server.sh /var/log/check_server.log 21每 5 分钟执行一次日志写入文件。这里有个细节cron 里尽量用绝对路径/bin/bash、/opt/scripts/check_server.sh都写满因为 cron 环境里的 PATH 非常精简直接写bash或check_server.sh很容易找不到命令。还要注意脚本里如果用了外部命令尽量也在脚本开头导出完整 PATH。例如export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin你可以在终端里执行env看看自己有哪些路径把这些路径复制到脚本里就行。这是无数人踩过的坑脚本手动跑得好好的一挂到 cron 上就报“command not found”。消息通知怎么发最省事的是 Linux 服务器本地有mailx就发邮件但现在更多团队用企业微信或钉钉的 webhook。原理很简单向一个固定的 HTTP 地址 POST 一段 JSON。比如企业微信机器人可以用 curlsend_wechat() { local content$1 curl -s -X POST $WECHAT_WEBHOOK_URL \ -H Content-Type: application/json \ -d {\msgtype\: \text\, \text\: {\content\: \$content\}} /dev/null }调用时就地取材if grep -q |WARN $result_file; then send_wechat 服务器巡检发现异常请查看$(cat $result_file) fi只把含 WARN 的结果发到群里正常巡检不打搅人这个思路我在实际项目里用了很久。3.4 上线前这些坑一定要自己先踩一遍脚本上线前我建议做一轮“破坏性测试”。什么意思人为制造异常看脚本的反应。比如把磁盘阈值调成 1确认会告警把某个进程停掉确认进程检查能报出 WARN把结果文件目录改成只读确认脚本不会因为写不了文件而退出码还是 0。这些测试花不了十分钟但能挡住大部分线上事故。权限也要注意。脚本文件建议设置成 755 或 700不要让普通用户随便改毕竟它可能以 root 身份跑。如果脚本里涉及明文密码或密钥权限更得收紧别 777 一甩放那儿。还有一点不要在脚本里写死绝对路径到底层尽量用变量。比如日志目录变量log_dir将来迁移服务器改一行比全局替换要安全得多。4. 常见问题与排查技巧实录4.1 脚本在终端能跑在 crontab 里就是跑挂这是被问烂的问题。表现通常是手动执行脚本一切正常cron 到点执行日志里没内容或者脚本报错。原因基本就两类。一类是环境变量不同上文说了PATH 少了命令找不到。另一类是目录不同cron 执行时的当前工作目录跟终端不一样。比如脚本里用了相对路径./conf终端在脚本目录跑没问题cron 在用户家目录跑就找不到 conf。解决思路脚本开头用cd $(dirname $0)强制切到脚本所在目录或者全部用绝对路径。这里$(dirname $0)能拿到脚本自己的路径比写死/opt/scripts更通用。判断技巧也简单先在 crontab 里加一行*/1 * * * * env /tmp/env_$(date %s).txt等一分钟看看 cron 环境的 PATH 到底是什么一对比就明白。4.2 明明赋值了判断却显示变量为空在 Shell 里管道、命令替换、子 shell 都会让变量作用域变得诡异。最常见的一个echo abc | read var echo $var # 输出为空read在管道右侧的子 shell 中执行子 shell 里赋的值退出后就被丢弃了。解决办法是不要用管道改用这里var$(echo abc) echo $var或者用进程替换read var (echo abc) echo $var我在后面“while 循环读文件”的问题里还会碰到一模一样的原理。4.3 while 读取文件只处理了第一行或最后一行经常有这种需求逐行读取一个 IP 列表逐个 ping 或逐个做操作。很多人写cat ip_list.txt | while read ip; do ping -c 1 $ip /dev/null done echo 最后处理的IP是: $ip输出发现$ip是空的因为while在管道右侧的子 shell 里循环结束变量就没了。如果循环里要追加写某个累加变量结果同样丢失。推荐写法是用输入重定向while read ip; do ping -c 1 $ip /dev/null done ip_list.txt echo 最后处理的IP是: $ip这样while是当前 shell 的一部分循环内变量都能在结束后继续访问。还有一个细节如果 IP 列表是 Windows 传上来的每行结尾会带\r循环里拿到192.168.1.1\r去 ping 就会失败。可以先sed -i s/\r$// ip_list.txt处理一下。4.4 调试三板斧set -x、shellcheck、人工加日志遇到脚本行为诡异先不要两眼一抹黑瞎猜。我的固定流程是三步。第一步在脚本开头加set -x执行时会打印每一步的展开结果。看变量到底被展开成了什么这个原始但有效。调试完一定记得删掉。第二步用 shellcheck 这种静态检查工具。很多发行版可以直接apt install shellcheck或yum install shellcheck一条命令扫下来未引用的变量、过时语法、常见陷阱都会列出来。我接手别人的旧脚本时习惯先过一遍 shellcheck。第三步在关键分支加echo日志输出到标准错误2。注意真正上线时这些调试输出要么删掉要么整理进统一的日志文件别污染了正常的输出。下面这张表是我日常最常对照的坑你可以收藏常见问题典型表现解决思路变量未定义拼错变量名脚本不报错数据写错位置脚本开头加set -u文件名带空格复制、移动时报错变量引用全加双引号cron 路径问题手动正常cron 报 command not found脚本开头设置完整 PATH管道子 shell 问题变量循环后消失改用while ... file结构浮点比较报错比较负载值直接报 integer expression global让 awk 做浮点判断换行符问题文件来自 Windows逐行处理失败处理前sed -i s/\r$//5. 更进一步Shell 如何与大运维体系协同5.1 并行处理别再一个接一个傻跑运维脚本经常会碰上这种任务要批量检测 100 台机器的端口连通性。用 for 循环一个一个跑100 次串行可能要几分钟体验非常差。Shell 有现成的并行方案用xargs的-P参数cat ip_list.txt | xargs -P 10 -I {} sh -c nc -z -w 2 {} 22 echo {}:22 通 || echo {}:22 不通 -P 10表示同时跑 10 个进程-I {}把每一行内容替换进后面的命令。100 台机器瞬间变成 10 组并行耗时能降到原来的十分之一以下。注意sh -c里面用了单引号如果命令里有变量要小心引号层级。5.2 参数解析让脚本跟上千军万马的工具交互脚本如果写死了阈值、目标列表每次改动都要编辑源码那还不能叫自动化。用getopts让脚本接收参数while getopts :h:t: opt; do case $opt in h) HOST_FILE$OPTARG ;; t) THRESHOLD$OPTARG ;; \?) echo 无效参数: -$OPTARG 2; exit 1 ;; esac done调用方式就变成了./check.sh -h hosts.txt -t 85。这样脚本就能作为一个“标准工具”被别的流程调用而不是一堆改来改去的临时脚本。要注意getopts的选项字母前面有冒号是“选项后面必须带参数”的意思。第一个冒号:h:t:表示关闭默认报错方便你自己写错误提示。5.3 trap防止脚本跑一半留下烂摊子脚本执行到一半被中断临时文件、锁文件全留在系统里这是很常见的事故源。trap命令可以在脚本退出时执行指定的清理动作trap rm -f /tmp/check_$$.lock; echo 脚本异常退出已清理临时文件; exit 1 INT TERMINT是 CtrlC 中断TERM是 kill 默认信号。上线前一不做二不休把锁文件和临时文件都规划好位置必要时用trap统一清理。这个习惯养成了脚本的健壮性能上一个台阶。再配合一个“防止重复执行”的思路用mkdir做锁lock_dir/tmp/check_process.lock if ! mkdir $lock_dir 2/dev/null; then echo 已有实例在运行 exit 1 fi trap rmdir $lock_dir EXITmkdir是原子操作同一个目录只能创建一个当锁用很靠谱。脚本退出时EXIT信号触发锁自动释放比文件锁要省心一些。5.4 把 Shell 嵌进 GitLab CI/CD 和监控体系自动化运维不只是定时跑脚本现代成熟团队已经把 Shell 嵌入了整个软件交付链路。比如 GitLab CI 里before_script和after_script经常就是一段 Shell负责安装依赖、打包产物、推送镜像。CI 里写 Shell有一个重要的原则每一步都要能失败即停。默认set -e可以在某条命令失败时立刻退出避免流程继续跑下去把一个坏的产物部署到线上。但set -e也有副作用比如grep没匹配到内容时返回 1脚本就退了。所以在关键位置配合逻辑判断使用if grep -q ERROR app.log; then echo 发现错误构建中止 exit 1 fi echo 日志检查通过这样既主动控制失败又不会被set -e误杀。监控方面Shell 脚本通常扮演“采集器”角色。脚本把指标输出成特定格式然后被 Prometheus node_exporter 的 textfile collector、Zabbix 的用户参数、或者自研监控系统读取。本质上就是约定好输出格式剩下的交给平台。所以前面巡检脚本特意用时间|指标|数值|状态的格式输出就是为这一层做的准备。另外现在容器化部署大面积普及很多运维脚本会被打进镜像里执行。这种场景下要注意基础镜像里可能没有 bash只有/bin/shcron服务也可能没装。脚本的兼容性、依赖的外部命令在做镜像前就要验证清楚。写在最后的一点个人经验从最早只会敲ls、cd到现在能靠几段脚本撑起上百台服务器的日常巡检我最大的感触是Shell 编程的进步不是靠看出来的是靠一次次上线踩坑踩出来的。你不需要一开始就追求那种一屏都放不下的“神级脚本”先把一个巡检、一个备份写利索再往里加容错、加通知、加参数慢慢就成体系了。如果只让我给一条最实在的建议那就是所有要进 crontab 的脚本先准备好完整的 PATH、明确的日志输出和可靠的退出码判断。这三样做到位脚本基本上就断不了气。其他更花哨的写法都是在这些基本功之上长出来的。希望你读完之后能动手把自己手上最枯燥的那件事写成一个以后再也不用手忙脚乱的 Shell 脚本。等你真把自动化跑起来回头再看到那个曾经每天重复敲命令的自己就知道今天折腾的这一切值了。