Shell脚本实战指南:从基础语法到自动化服务器巡检
写Shell脚本这件事我一开始是抗拒的。总觉得它语法松散、坑又多一个空格都能让整段脚本翻车哪有Python写着舒服。但真正在服务器上折腾过几轮之后我承认自己错了Shell是Linux环境下绕不开的基础能力尤其在批量文件处理、系统巡检、定时任务、服务部署这类场景里它的效率是其他语言根本比不上的。这篇内容不打算讲成一本教材而是按我自己的学习与实践路径从最核心的认知讲起把语法、实操、踩坑串成一条完整的学习脉络。我会尽量少说废话多给可直接抄走的命令和脚本尽量让零基础的朋友也能顺着这条路走下来。先说说这篇内容能帮你解决什么问题如果你是运维、后端开发、测试或者单纯想把每天在命令行里反复敲的重复操作自动化那么这篇非常适合你。它既能让你搞清楚Shell脚本到底怎么组织也能让你在写脚本时少踩一些我踩过的大坑。如果你已经有了一些基础可以直接跳到第3部分的实战脚本章节那里面包含了一个完整的自动化巡检脚本从需求拆解到最终落地全都有。1. 整体认知Shell脚本到底在解决什么问题1.1 Shell脚本的本质是什么很多人第一次接触Shell脚本时会误以为这是一种和Python、Java并列的编程语言。实际上它更准确的定义是“命令解释器”。你可以把它理解成你在终端里手输命令的“批处理记录仪”把一串一串要敲的命令按顺序写进一个文件然后一次性执行。这就像你去超市买东西原本每次拿一件商品都要跑一趟收银台现在你把购物清单写在一张纸上一次搞定结算。但Shell脚本又不止是“命令的堆叠”。它具备变量、条件判断、循环、函数、数组等完整的编程结构可以做到根据不同的情况动态决定下一步执行什么命令。举个例子你写一个日志备份脚本先检查磁盘剩余空间是否足够如果不够就发告警如果够就先把昨天的日志压缩再搬到备份目录最后清理7天前的旧备份。这里面的“检查”“如果不够”“如果够”“循环处理”“判断时间”全是编程逻辑只是操作对象换成了系统命令而已。所以我的建议是不要把Shell当成一门传统编程语言来啃而是把它当成“指挥Linux系统的操控语言”来学。语法只是骨架核心是你要懂Linux系统有哪些命令、每个命令能干什么然后把它们组合起来。1.2 为什么今天还值得花时间学Shell这几年云计算、容器化很火很多人觉得服务器都用Kubernetes编排了直接操作Shell的机会越来越少。这个想法大错特错。恰恰因为基础设施越来越自动化Shell才更像一个底层通行证。我举个最直观的例子你写一个容器镜像的启动脚本或者写一个CI/CD流水线里的构建步骤绝大部分时候用的就是Shell命令。CI工具里跑的stage本质就是在干净的临时环境里执行你写的那几行shell命令。哪怕你主要用Ansible、SaltStack这种自动化工具它们底层也要通过SSH连到目标机器执行命令你在调试playbook时发现不对劲第一反应一定是手动SSH上去敲两下Shell看看。所以说Shell是你排查问题、在服务器上干活的兜底能力绕不开。另外在纯运维场景里Shell脚本是消耗资源最少、依赖最少、成功率最高的自动化方案。Python脚本可能会因为缺requests库、缺setuptools而跑不起来但一个用标准工具grep、awk、sed、find写出来的Shell脚本几乎在任何Linux发行版上都能直接运行。这种没有依赖焦虑的开箱即用是Shell最大的实战价值。1.3 学习路线规划从命令到脚本再到实战我在带新人时通常会推荐一条三阶段的路线第一阶段熟练常用命令。至少掌握文件操作ls、cd、cp、mv、rm、find、文本处理grep、awk、sed、sort、uniq、权限管理useradd、chmod、chown、进程管理ps、kill、top、网络排查ping、curl、netstat/ss这些。这一阶段的检验标准不是“我会用”而是“看到一个场景能立刻想到该用哪条命令组合”。第二阶段掌握脚本基本结构。变量、字符串处理、条件判断、循环、函数、位置参数、退出码。能写一个二三十行的脚本完成类似“批量重命名文件”“自动备份目录”这类单一任务。第三阶段综合实战。脚本要能处理异常比如判断上一条命令是否成功、文件是否存在、参数是否合法能通过日志记录运行过程能配合crontab定时执行能把结果发到钉钉、企业微信或者邮件。这三个阶段没有严格的时间分界。我见过有人第一周就把命令和脚本混着学的效果也不错因为命令是脚本的砖瓦写脚本会反过来逼你熟悉命令。核心是不要停留在“看教程”的状态一定要到真实环境里跑你的脚本哪怕脚本写得不完美跑错了也是一种进阶。2. 核心细节解析与实操要点2.1 第一行到底怎么写Shebang与执行方式写Shell脚本的第一步必须是Shebang行。所谓Shebang就是文件第一行的#!加上解释器路径#!/bin/bash这一行告诉系统“当你要执行这个文件时用 /bin/bash 这个程序来解释它”。如果你写的是#!/bin/sh那么将使用系统默认的POSIX shell来执行在Ubuntu上一般会链接到dash而dash不支持[[ ]]高级判断所以如果你的脚本里用了[[ ]]就会报语法错误。因此我个人的习惯是只要没有特殊兼容需求一律写#!/bin/bash避免不必要的坑。执行脚本有三种常见方式bash script.sh直接用bash解释器执行不需要文件有执行权限新手最推荐。./script.sh需要文件具备执行权限先chmod x script.sh再执行这是最“正统”的方式。source script.sh或. script.sh在当前Shell进程中执行脚本里设置的变量和cd影响会保留在当前Shell中这是“加载”而非“执行”。第三种方式很多人会忽略。如果你写了一个修改环境变量或切换目录的脚本必须用source方式才能让它生效。这也是新人最容易困惑的地方为什么脚本里cd进去之后脚本跑完一看还在原目录因为你执行脚本时是在子Shell里cd的子Shell退出后影响自然消失了。2.2 变量与引号最容易被忽略的细节Shell变量的定义和使用都很简单name张三 echo 你好$name但这里有几个值得注意的细节。第一等号两边不能有空格name 张三是错的。第二字符串包含空格时一定要加引号否则Shell会把空格当作分隔符导致字符串被拆成多个词。第三双引号和单引号有本质区别双引号内的$name会被解析为变量的值单引号内的$name原样输出。所以你如果想让变量效果生效就使用双引号如果你需要的是字面量使用单引号。我踩过最值得记录的一次坑是这样的我写了个删除旧日志的脚本里面有一条命令rm -rf $log_dir/*这条命令看上去没问题可如果$log_dir变量因为某种原因没有被赋值命令就会变成rm -rf /*。想象一下这个后果有多严重。从那以后我写所有涉及变量路径的命令都会加双引号rm -rf ${log_dir}/*这还不够还要在删除前判断变量是否为空。对于生产环境的脚本我还会额外加一个保险判断if [ -z $log_dir ]; then echo log_dir 变量为空终止执行 exit 1 fi这个习惯救过我很多次。Shell脚本最可怕的地方不是语法复杂而是它执行得“太流畅”了一条有问题的命令照样会跑跑完才发现灾难已经发生。所以对于rm、mv、格式化、清空文件这类破坏性操作永远要加上防护逻辑。2.3 条件判断与退出码脚本的决策大脑Shell中条件的写法有一种独特的分支体系[ ]、[[ ]]、(( ))各有各的适用场景。[ 条件 ]是POSIX标准的test命令缩写注意[后面和]前面必须有空格。比如[ $a b ]判断两个字符串相等[ -f /etc/passwd ]判断文件是否存在且是普通文件[ $a -gt 10 ]判断数字大小。[[ 条件 ]]是bash的扩展支持更丰富的逻辑运算和正则匹配比如[[ $name ~ ^张 ]]判断字符串是否以“张”开头还支持、||。因为[[ ]]内部做了词法处理变量不加引号也不容易出问题所以在bash脚本里我更推荐用[[ ]]。(( 数学表达式 ))主要用于整数运算比如(( a 10 ))C语言风格非常直观。除了语法退出码也是Shell里很重要但常被忽视的概念。每个命令执行完都会产生一个退出码0表示成功非0表示失败。在脚本里用$?可以拿到上一条命令的退出码grep error /var/log/nginx/error.log if [ $? -eq 0 ]; then echo 发现了error关键词 else echo 没找到error关键词 fi不过$?只能代表紧挨着它的上一条命令的结果如果中途插入其他命令就会被覆盖所以更好的习惯是立刻保存grep error /var/log/nginx/error.log ret$? if [ $ret -eq 0 ]; then ... fi2.4 循环与数组批量处理的核心武器循环是我在写自动化脚本时用得最多的结构。for循环有两种典型写法# 写法一遍历列表 for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do ping -c 1 $ip /dev/null echo $ip 通了 || echo $ip 不通 done # 写法二遍历序列 for i in $(seq 1 10); do echo 第 $i 次 done # 写法三遍历文件列表 for file in /var/log/*.log; do echo 处理 $file donewhile循环常用于读取文件内容while IFS read -r line; do echo $line done /etc/hosts这里IFS和-r的设置有点讲究-r禁止反斜杠转义IFS取消默认的分隔符保证读取的每一行内容不会被切割特别是路径中包含空格时这个习惯能帮你少踩大坑。对了读取文件时千万别写成for line in $(cat file)一旦文件里的行含有空格就会被拆成多段几乎必出bug。数组方面bash数组的用法也不算复杂servers(web01 web02 db01) echo ${servers[0]} echo 数组长度: ${#servers[]} for server in ${servers[]}; do echo 正在检查 $server done2.5 函数与脚本结构让代码可维护当脚本超过50行建议就要用函数来组织。我写脚本的标准模板是这样的#!/bin/bash set -euo pipefail # 全局变量 LOG_DIR/var/log/myapp BACKUP_DIR/data/backup # 初始化 init() { mkdir -p $BACKUP_DIR } # 日志函数 log() { echo $(date %Y-%m-%d %H:%M:%S) $* $LOG_FILE } # 主函数 main() { init do_something clean_up } main $函数定义的固定写法就是函数名() { ... }调用时直接写函数名。注意函数与函数之间不要互相依赖得太深能用前缀区分功能如log_、check_就行。main $这行很关键它把脚本接收到的位置参数透传给main函数这样全局逻辑更清晰。提起set -euo pipefail这句可以说是现代Shell脚本的“安全三件套”set -e只要任何一个命令执行失败脚本立即退出绝不带病运行。set -u使用未定义的变量时报错退出防的就是那个万恶的rm -rf $var/*。set -o pipefail管道中只要任意一条命令失败整条管道的退出码就为非0。没有它的话cmd1 | cmd2即使cmd1挂了只要cmd2正常整体退出码仍为0。有读者可能会担心set -e会不会让脚本变得太脆弱一些非关键命令失败也退出。这一点我承认所以我们可以在需要容忍失败的命令末尾加|| true或者临时关闭set e、set -e。这是个平衡技巧总好过脚本带病执行到失控。3. 实操过程与核心环节实现一个自动化服务器巡检脚本3.1 需求拆解理论讲再多不动手都是纸面功夫。下面进入实战我带你完整实现一个“服务器自动化巡检脚本”。这个脚本也是我自己线上环境一直在用的精简版主要解决下面几个痛点每天早上检查各台服务器的CPU、内存、磁盘使用率超过阈值就告警。检查关键服务例如Nginx、MySQL是否存活。检查系统负载和僵尸进程数量。将检查结果写日志并把异常信息推送出来。这样的脚本可以用在任意一台管理机上通过SSH向各台目标机执行远程命令。为了方便演示我们先做成“本机巡检”版本再扩展成“多服务器巡检”。3.2 脚本完整实现下面是我整理过、可以直接跑的版本#!/bin/bash set -euo pipefail # 配置区 THRESHOLD_CPU80 THRESHOLD_MEM80 THRESHOLD_DISK80 LOG_FILE/var/log/server_check.log REMOTE_SERVERS(192.168.1.10 192.168.1.11) SSH_USERops SSH_PORT22 # 配置区结束 # 日志函数带时间戳输出 log() { echo $(date %Y-%m-%d %H:%M:%S) $* | tee -a $LOG_FILE } # 发送告警函数 alert() { local msg$1 log [ALERT] $msg # 这里可以替换为企业微信/钉钉的webhook推送 # curl -s -X POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxx \ # -H Content-Type: application/json \ # -d {\msgtype\:\text\,\text\:{\content\:\$msg\}} } # 检查本机CPU使用率 check_cpu() { local cpu_usage cpu_usage$(top -bn1 | grep Cpu(s) | awk -F, {print $4} | awk {print 100-$1}) log CPU使用率: ${cpu_usage}% if (( $(echo $cpu_usage $THRESHOLD_CPU | bc -l) )); then alert CPU使用率 ${cpu_usage}% 超过阈值 ${THRESHOLD_CPU}% fi } # 检查内存使用率 check_mem() { local total used usage_percent total$(free -m | awk /^Mem:/ {print $2}) used$(free -m | awk /^Mem:/ {print $3}) usage_percent$((used * 100 / total)) log 内存使用率: ${usage_percent}% (${used}MB/${total}MB) if (( usage_percent THRESHOLD_MEM )); then alert 内存使用率 ${usage_percent}% 超过阈值 ${THRESHOLD_MEM}% fi } # 检查磁盘使用率 check_disk() { df -hP | grep ^/dev/ | while read -r line; do local mount_point usage percent mount_point$(echo $line | awk {print $6}) usage$(echo $line | awk {print $5} | tr -d %) log 磁盘 $mount_point 使用率: ${usage}% if (( usage THRESHOLD_DISK )); then alert 磁盘 $mount_point 使用率 ${usage}% 超过阈值 ${THRESHOLD_DISK}% fi done } # 检查系统负载 check_load() { local load1 load5 load15 read -r load1 load5 load15 /proc/loadavg local cores cores$(nproc) log 系统负载: $load1 $load5 $load15 (CPU核数: $cores) if (( $(echo $load1 $cores | bc -l) )); then alert 1分钟负载 $load1 超过CPU核数 $cores fi } # 检查关键服务 check_service() { local service$1 if systemctl is-active --quiet $service; then log 服务 $service 运行正常 else alert 服务 $service 未运行尝试重启... systemctl restart $service || alert 服务 $service 重启失败 fi } # 检查目标远程服务器通过SSH check_remote_server() { local host$1 log 开始巡检远程服务器: $host ssh -p $SSH_PORT ${SSH_USER}${host} echo --- CPU --- top -bn1 | head -5 | tail -2 echo --- MEM --- free -m echo --- DISK --- df -hP echo --- LOAD --- cat /proc/loadavg || alert SSH连接 ${host} 失败 } # 主函数 main() { log 服务器巡检开始 check_cpu check_mem check_disk check_load check_service nginx check_service mysql for server in ${REMOTE_SERVERS[]}; do check_remote_server $server done log 服务器巡检结束 } main $3.3 脚本关键细节说明先看check_cpu()这里的top -bn1表示批处理模式只采样一次否则top默认会进入交互界面卡住脚本。然后用grep过滤出CPU行awk提取idle列再用100 - idle算出实际使用率。由于Shell本身不支持浮点运算所以比较大小用了bc -l这也是一个常见的坑点。再看check_mem()这里用free -m把内存输出调整成MB单位然后通过awk取出total和used用整数运算算出百分比。为什么不直接用free自带的百分比因为不同发行版free输出格式略有差异自己解析更可控。磁盘检查那一行df -hP | grep ^/dev/要去掉一些虚拟文件系统tmpfs、overlay等只检查真实的磁盘分区。tr -d %用来删除百分号把80%变成纯数字80然后参与比较。远程巡检部分要注意SSH命令中的双引号嵌套。外层脚本已使用了双引号内层为了传一整段命令用了双引号包裹内层里面又用了单引号。这个层级关系很容易出错建议先在命令行手动跑一遍SSH命令再放进脚本里。3.4 部署与定时执行脚本写好后先在当前终端测试chmod x server_check.sh ./server_check.sh观察输出是否正常然后检查/var/log/server_check.log的日志内容。确认无误后加入crontab定时任务。比如每天早上8点执行一次crontab -e # 在文件末尾添加 0 8 * * * /opt/scripts/server_check.sh /dev/null 21注意cron环境变量和手动执行的环境不太一样PATH可能比较精简所以脚本里最好使用命令的绝对路径或者在脚本开头显式声明export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin这个PATH声明看着不起眼但能避开cron任务执行不了的经典问题。我在接手的系统里见过好几次明明脚本手动运行成功、一进cron就报command not found的情况原因就是PATH。4. 常见问题与排查技巧实录4.1 语法问题报错却不知道错在哪Shell报错最让人头疼的是它并不总是直接指出精确位置。最常见的有这么几类command not found脚本里某个“命令”不存在。也可能是你写变量时$name没加引号Shell把变量值当成多个词误把其中一部分当命令执行了。syntax error near unexpected token通常是某个关键字拼写错误比如if写成了fi或者循环里缺少do、done。这个问题在拼写多字符关键字时最容易犯。[: missing ]中括号条件判断里]前面少了空格是经典错误。只要你写[和]时养成“内部两侧都有空格”的习惯就能规避。排查效率最高的方式就是给bash加-x参数运行脚本bash -x server_check.sh它会打印每一步实际执行的命令和展开后的变量值一眼就能看出是哪个环节把变量展开出了错。此外bash -n server_check.sh只做语法检查不执行脚本适合快速排查语法层的问题。4.2 权限问题Permission denied与换行符Permission denied是新手最常见的问题之一。文件明明在眼前为什么执行不了基本上就是没加执行权限执行chmod x 脚本名即可。但还有种情况是文件系统挂载时带了noexec选项比如某些安全加固过的服务器上/tmp目录不允许执行任何文件这时把脚本挪到/opt或家目录下运行就行。另一个非常隐蔽的问题是CRLF换行符。如果你在Windows上用记事本或某些编辑器写脚本再传到Linux上每行末尾会多一个不可见的\r字符导致运行时报错$\r: command not found。解决办法很简单sed -i s/\r$// server_check.sh或者用dos2unix server_check.sh工具转换。这个坑在团队协作中尤其常见传脚本给别人之前最好先检查一遍文件格式。4.3 引号陷阱变量明明有值为什么执行结果不对前面提到过单双引号的区别这里再扩展讲一下命令替换和变量的组合问题。有人说Shell里最难的部分就是“什么时候加引号、加什么引号”。这句话一点不为过。举一个例子你想把命令输出的内容存到变量里再遍历# 正确 files$(find /tmp -name *.log) echo $files # 错误示例 filesfind /tmp -name *.log第二种写法里filesfind只是把变量赋值成了字符串“find”后面的参数变成了新的命令语义完全跑偏。写命令替换时一定要用$()包起来。还有$(cmd)的双引号问题当你不希望输出被分词时记得给变量加引号for file in $files; do ... # 遍历文件列表时不加引号会按照空格/换行分词另外在脚本中出现了$(hostname)这类命令替换最好在写完后用bash -x验证一下展开结果是否符合预期我就因为没验证把DATE$(date %F)里的%F引号漏掉导致date命令执行失败排查了整整半小时。4.4 循环批量处理的两大坑空目录和特殊字符写for循环遍历文件时如果目录里没有文件for file in /var/log/*.log会保留字面量/var/log/*.log作为文件名于是循环体就跑一次处理一个不存在的文件。解决方法是先判断shopt -s nullglob for file in /var/log/*.log; do ... done这个nullglob选项会让匹配不到任何文件时循环体一次都不执行。还有一个坑是文件名包含空格或特殊字符比如my file.log。如果不处理for file in *.log会把my和file.log当成两个文件。解决办法是在for循环前设置IFS$\n让分隔符只认换行IFS$\n for file in $(find /tmp -name *.log); do echo $file done注意设置IFS后可能影响循环内的其他命令用完后最好还原。或者更稳妥的选择是使用find配合-execfind /tmp -name *.log -exec cp {} /data/bak/ \;4.5 调试三板斧bash -x、trap与set -x局部开关面对一个写了几百行的复杂脚本我最常用的排查方式分三层第一层全局开启bash -x 脚本.sh配合set -euo pipefail大多数逻辑错误都能定位。第二层如果某个函数或某几行需要单独跟踪就在函数前后加调试开关set -x # 开启跟踪 check_cpu set x # 关闭跟踪第三层也是高阶玩法用trap捕获ERR信号在命令失败时打印调用链trap echo 第 $LINENO 行执行失败退出码: $? ERR加了这行后脚本中任意命令失败都会在退出前打印当前行号配合日志可以快速锁定问题位置。对于经常需要远程排查别人写的脚本的场景这个技巧省了我大量的时间。4.6 常见问题速查表现象可能原因解决方式command not foundPATH不完整 / 命令拼错 / 变量未加引号被分词用绝对路径检查引号export PATHPermission denied文件无执行权限 / 挂载目录带noexecchmod x换目录执行$\r: command not foundWindows CRLF换行符dos2unix 或 sed -i s/\r$//[: missing ]test语法空格问题[和]内部留空格变量值总是被截断未加双引号养成$var的习惯循环处理了不存在的文件通配符无匹配开启 nullglob文件含空格处理出错IFS默认分词while read 循环或设置 IFS手动跑成功cron跑失败cron的PATH精简脚本开头export PATH5. 一些进阶建议从会写脚本到写得好脚本写多了之后会发现真正拉开差距的不是语法熟不熟练而是工程化的意识和排错能力。这里我把自己的习惯分享出来。第一想清楚“脚本能承受多坏的环境”再动手。比如一台刚初始化的机器可能没有bc命令没有dos2unix没有jq。那我写的脚本要么用基础命令替代要么在脚本开头做依赖检查for cmd in bc curl ssh; do command -v $cmd /dev/null || { echo 缺少命令 $cmd; exit 1; } done第二日志不是可选项是必需品。不写日志的脚本等于裸奔出问题时无从下手。我在每条关键分支都打日志日志里包含时间戳和关键变量的值。这样即使出问题也能从日志里还原当时的现场。第三多利用ShellCheck这个静态检查工具。它是一个很流行的Shell脚本Lint工具安装方式很简单Debian系是apt install shellcheckRedHat系用yum install shellcheck。运行shellcheck 你的脚本.sh后它会指出未加引号的变量、误用的命令、不规范的括号等潜在问题。我最近几年写的脚本都会先过一遍ShellCheck它会拦截掉大量的低级错误尤其在引号和空格这些人工检查容易眼花的环节。它给出的建议不一定全都要改但每条建议背后基本都有真实的踩坑案例值得认真看。我在实际工作中还发现把脚本当作“给三个月后的自己看的小项目”来写质量会提升不少。加注释、划分配置区与功能区、写清函数的作用这些投入都不会白费。Shell脚本虽然看起来只是几行命令但它同样需要可读性、可维护性和健壮性。真到了系统出故障、你需要顶着压力在日志里找线索的时候平时那些好习惯都会回馈你。这个巡检脚本的后续扩展空间其实也很大可以加上邮件告警、接入企业微信机器人推送、增加趋势记录曲线甚至可以加上简单的并发调度同时巡检多台服务器。我个人下一步的计划是把告警接入飞书机器人再配合cron做一次晚高峰前的二次巡检感兴趣的朋友也可以沿着这个方向继续玩下去。

相关新闻

最新新闻

日新闻

周新闻

月新闻