
1. 为什么是这4个脚本先想清楚运维自动化的起步逻辑1.1 运维自动化有三个阶段Shell脚本是第一级台阶我做了这么多年运维见过太多人一上来就喊着要上自动化运维平台结果搞了大半年连最基本的服务器巡检还在手动敲命令。其实运维自动化这条路是有明确阶段的第一个阶段是单机脚本化把每天重复敲的命令封装成脚本再挂到crontab里让它定时跑第二个阶段是批量自动化通过SSH免密批量执行命令、分发文件或者直接上Ansible、Salt这类工具第三个阶段才是平台化、智能化比如接入CMDB、监控平台甚至现在很多团队在尝试的AI Agent辅助运维。很多人容易忽略的是第一阶段的Shell脚本能力恰恰是第二阶段和第三阶段的地基。你去看Ansible的playbook、写监控脚本、处理日志清洗底层逻辑都离不开Linux命令和Shell思维。而且Shell脚本是每台Linux机器自带的解释器不需要装任何额外环境遇到问题随手写几行就能排障。这4个脚本我用了很多年从最初的个人服务器到后来的几十台生产机器一直都在用。它们不花哨但足够稳。1.2 巡检、清理、自愈、批量刚好覆盖最高频的重复劳动为什么偏偏是这4个脚本而不是别的我梳理过运维日常最花时间的四件事。第一系统巡检。每天早上到了工位第一件事就是挨个登录服务器看CPU负载、内存余量、磁盘使用率。机器少还好机器多了纯属浪费时间而且容易漏掉哪台。第二日志清理。磁盘被日志打爆是运维事故里出现频率最高的之一应用日志、访问日志、系统日志几天不清理就能把一个分区塞满。第三服务监控。进程挂了自己不知道等业务方投诉过来才去恢复非常被动。第四批量操作。改个配置、发个文件、重启个服务如果机器多一台台连过去操作又慢又容易出错。这4个脚本分别解决这四件事巡检脚本让你每天省掉半小时的重复敲命令日志清理脚本让磁盘告警从根本上减少服务自愈脚本让你在半夜不用爬起来救火批量执行和分发脚本让几十台机器的操作缩短到几分钟。把这4个脚本跑起来你就能体会到运维自动化带来的第一个明显变化从人肉盯服务器变成机器自动汇报人只处理异常。2. 脚本一系统巡检脚本让服务器每天自动汇报身体情况2.1 先描述场景每天手动连机器敲 free、df真的会漏我最早管理服务器的时候大概有十几台。每天早上到公司第一件事就是打开终端一台一台ssh上去执行free -h看内存、df -h看磁盘、uptime看负载再把关键数字记到备忘录里。这个流程大概要花半小时到一小时如果赶上服务器多或者中间被别的事情打断很容易漏掉某台机器。后来我就想为什么不写一个脚本把这些信息一次性全部收集出来一台机器只需要跑一次输出一份报告我再花两三分钟扫一眼报告里的关键指标就行。如果哪台机器有问题报告里一眼就能看出来。再后来我把它挂到crontab里每天早上8点自动执行把报告写到固定路径我到了工位直接看文件就行。这个脚本解决的核心痛点就三个重复劳动、容易漏查、没有历史记录。手动敲命令的时候你没法对比昨天的磁盘使用率和今天相比涨了多少但脚本输出到文件后每次结果都落在磁盘上就形成了历史数据你可以观察趋势提前预判问题。比如某个分区使用率连续一周每天涨2%你心里就有数了再过一段时间要扩容或者清理不至于等它满了才手忙脚乱。2.2 完整脚本代码与逐段拆解先看完整脚本我用的是纯bash实现不依赖额外软件任何主流Linux发行版都能直接跑#!/bin/bash # # 功能: 系统资源巡检脚本 # 用法: bash sysinfo_check.sh [报告输出路径] # 示例: bash sysinfo_check.sh /var/log/sysinfo_report.log # REPORT_FILE${1:-/tmp/sysinfo_report_$(date %Y%m%d_%H%M%S).txt} # ------ 采集基础信息 ------ HOSTNAME$(hostname) IP_ADDR$(ip -4 addr show 2/dev/null | grep -w inet | awk {print $2} | cut -d/ -f1 | head -n 1) KERNEL$(uname -r) OS_NAME$(grep ^NAME /etc/os-release | cut -d -f2 | tr -d ) OS_VER$(grep ^VERSION /etc/os-release | cut -d -f2 | tr -d ) UPTIME$(uptime -p) LOAD$(uptime | awk -Fload average: {print $2} | sed s/^ *//) # ------ 采集内存信息用MB为单位方便计算百分比 ------ MEM_TOTAL$(free -m | awk /^Mem:/{print $2}) MEM_USED$(free -m | awk /^Mem:/{print $3}) MEM_FREE$(free -m | awk /^Mem:/{print $4}) MEM_PERCENT$(awk BEGIN{printf \%.1f\, ${MEM_USED}/${MEM_TOTAL}*100}) # ------ 采集磁盘信息 ------ DISK_INFO$(df -h | grep -E ^/dev/ | awk {print $1, 总大小:$2, 已用:$3, 可用:$4, 使用率:$5, 挂载点:$6}) # ------ 采集其他信息 ------ LAST_REBOOT$(who -b 2/dev/null | awk {print $3, $4}) LOGIN_USERS$(who | awk {print $1} | sort -u | tr \n , | sed s/,$//) # ------ 输出报告 ------ { echo 系统巡检报告 echo 生成时间 : $(date %Y-%m-%d %H:%M:%S %A) echo 主机名 : $HOSTNAME echo IP地址 : $IP_ADDR echo 内核版本 : $KERNEL echo 系统版本 : $OS_NAME $OS_VER echo 开机时长 : $UPTIME echo 最近重启 : $LAST_REBOOT echo 负载均值 : $LOAD echo ------------------------------------------------------ echo 内存状况 : 总计 ${MEM_TOTAL}MB, 已用 ${MEM_USED}MB, 可用 ${MEM_FREE}MB echo 内存使用率 : ${MEM_PERCENT}% echo ------------------------------------------------------ echo 磁盘使用 : echo $DISK_INFO | sed s/^/ / echo ------------------------------------------------------ echo 当前登录用户: $LOGIN_USERS echo } | tee $REPORT_FILE echo 巡检报告已生成: $REPORT_FILE逐段说下关键点。开头接收一个可选参数REPORT_FILE如果你不传就自动生成带时间戳的文件名。这样做的好处是既可以在命令行直接看结果也能把结果固定下来作为历史记录。我习惯传一个固定路径比如/var/log/sysinfo_$(date %Y%m%d).txt这样每天的巡检报告就是一个独立文件方便回头查。主机名、IP、内核、系统版本这几项是基础信息。IP地址这里我用的是ip -4 addr show这是比较新的写法CentOS 7以上的系统都支持。如果你还在用老系统只有ifconfig可以把这行换成ifconfig | grep -w inet | grep -v 127.0.0.1 | awk {print $2}。这就是Shell脚本的一个特点同一件事往往有多种实现方式取决于你手头系统的实际情况。内存部分我特意用free -m而不是free -h原因是free -h输出的是带单位的人类可读格式比如1.2G、3.4G直接拿去做awk算百分比会出问题awk会把带字母的字符串当成0处理。用free -m拿到纯数字再算百分比就稳妥多了。这个细节是我写了无数次脚本后得出的经验算百分比、做计算的时候优先用不带单位的原始数字。磁盘部分用df -h过滤出/dev/开头的行这样可以把tmpfs、overlay这类虚拟文件系统过滤掉只看真实磁盘。grep -E ^/dev/这个写法在容器环境里可能需要调整但物理机和虚拟机上是够用的。最后用tee同时输出到终端和文件这样你手动跑的时候能看到结果crontab跑的时候结果也会记录到文件里。2.3 接入 crontab再顺手加个告警阈值脚本写好后先手动跑一遍确认没问题再挂到crontab。我的建议是每天都跑一次时间选在早上上班前比如8点0 8 * * * /bin/bash /opt/scripts/sysinfo_check.sh /var/log/sysinfo_$(date %Y%m%d).log /var/log/sysinfo_cron.log 21注意crontab里%号是需要转义的所以写成$(date \%Y\%m\%d)或者更简单在脚本内部生成文件名不要放在crontab命令里。我脚本里本来就带了默认文件名所以crontab直接写/bin/bash /opt/scripts/sysinfo_check.sh就行省去转义的麻烦。跑一段时间后你会发现只看报告还是不够高效。更实用的做法是在脚本里加一个阈值判断比如磁盘使用率超过80%、内存使用率超过90%就单独输出一行异常标记。我后来专门抽了一个check_alert函数出来把这些关键指标单独提取配合企业微信或钉钉的webhook做告警推送。这一步不复杂就是在脚本末尾加几行判断但带来的价值立竿见影——不用每天盯着报告看异常会自动找你。3. 脚本二日志清理与归档脚本把磁盘告警掐死在摇篮里3.1 日志为什么必须自动化清理磁盘写满是Linux服务器最常见的故障之一而日志文件是最大的磁盘杀手。Nginx访问日志、Tomcat日志、应用自己打出来的业务日志如果放任不管一两个月就能把几十个GB的分区塞满。最难受的是日志文件通常还在持续写入磁盘满了之后应用直接无法写入报错、卡死、甚至崩溃。有人会说系统自带的logrotate不是能处理吗确实logrotate可以管理系统日志比如/var/log/messages、/var/log/secure但很多应用日志是logrotate管不到的尤其是你自己部署的应用日志路径五花八门。这时候就需要一个自定义的清理脚本把指定目录下超过一定天数的日志文件先压缩归档再删除原文件。我这边实际的管理思路是日志保留策略要分两层。第一层15天以内的日志原样保留方便排查近期问题第二层15天到60天的日志压缩成tar.gz归档占用空间能缩小到原来的十分之一第三层超过60天的归档包直接删掉。这个保留周期可以根据业务需要调整但分层归档的思路是通用的既保证了排查窗口又不会让磁盘无限增长。3.2 完整脚本代码与关键点讲解直接上脚本这个是经过生产环境验证的版本#!/bin/bash # # 功能: 日志归档与清理脚本带 dry-run 演练模式 # 用法: # bash log_cleanup.sh --dry-run # 演练模式只看不删 # bash log_cleanup.sh # 正式执行 # set -u DRY_RUNfalse [[ ${1:-} --dry-run ]] DRY_RUNtrue # ------ 配置区按需修改 ------ LOG_DIRS( /data/logs/nginx /data/logs/app /data/logs/tomcat ) ARCHIVE_BASE/data/logs/archive KEEP_DAYS15 # 日志文件保留天数超过则归档并删除原文件 ARCHIVE_KEEP_DAYS60 # 归档包保留天数超过则删除 FILE_PATTERN*.log MAXDEPTH1 # 只处理当前目录不递归防止误删子文件 # ---------------------------- mkdir -p $ARCHIVE_BASE LOG_FILE/data/logs/log_cleanup_$(date %Y%m%d_%H%M%S).log # 把脚本自身的执行记录写入独立日志 exec $LOG_FILE 21 ts() { date %F %T; } echo 清理脚本启动: $(ts) echo dry-run 模式: $DRY_RUN for dir in ${LOG_DIRS[]}; do if [[ ! -d $dir ]]; then echo [跳过] $dir 不存在 continue fi echo [处理目录] $dir # 用 mapfile 把 find 结果读进数组注意 bash 4 才支持 mapfile -t files (find $dir -maxdepth $MAXDEPTH -type f -name $FILE_PATTERN -mtime $KEEP_DAYS 2/dev/null) if [[ ${#files[]} -eq 0 ]]; then echo 没有超过 ${KEEP_DAYS} 天的日志跳过 continue fi for f in ${files[]}; do fname$(basename $f) dir_name$(basename $dir) # 生成归档包名加序号避免重复 seq1 archive_name${dir_name}_$(date %Y%m%d_%H%M%S)_${seq}.tar.gz while [[ -e $ARCHIVE_BASE/$archive_name ]]; do seq$((seq 1)) archive_name${dir_name}_$(date %Y%m%d_%H%M%S)_${seq}.tar.gz done if [[ $DRY_RUN true ]]; then echo [演练] 将归档 $f - $ARCHIVE_BASE/$archive_name else # 在日志所在目录压缩这样包内路径只含文件名解压时不会覆盖多层目录 if tar -czf $ARCHIVE_BASE/$archive_name -C $dir $fname; then rm -f $f echo [完成] $f - $archive_name else echo [失败] $f 归档失败原文件未删除 fi fi done done # 清理过期归档包 if [[ $DRY_RUN false ]]; then old_count$(find $ARCHIVE_BASE -name *.tar.gz -mtime $ARCHIVE_KEEP_DAYS | wc -l) find $ARCHIVE_BASE -name *.tar.gz -mtime $ARCHIVE_KEEP_DAYS -delete echo 已清理过期归档包 $old_count 个 else echo [演练] 预计清理归档包 find $ARCHIVE_BASE -name *.tar.gz -mtime $ARCHIVE_KEEP_DAYS -exec echo {} \; fi echo 清理脚本结束: $(ts) 这里有几个关键点值得细说。第一个是MAXDEPTH1和find $dir -maxdepth $MAXDEPTH。这个参数限制find只在当前目录查找不递归进入子目录。为什么要这么干因为很多应用的日志目录下面会有子目录比如按日期分的文件夹或者日志文件旁边还有配置文件。如果不加限制find会把所有匹配*.log的文件全找出来万一子目录里有个你不想动的文件就可能被误删。我吃过这个亏后面专门讲。第二个是利用mapfile把find结果一次性读入数组再用for循环逐文件处理。一开始我用的是find ... -exec tar ...但这样如果有多个文件要归档tar命令会被执行多次每次打包一个文件效率低且包名难以管理。改成分批读入数组后可以逐文件做精细处理先归档、确认成功、再删除原文件中间多了一层保护。第三个是设计了这个DRY_RUN演练开关。这个太重要了。所有涉及删除操作的脚本都应该有一个演练模式默认只告诉你会干什么不会真的执行。第一次写清理脚本时我直接就是正式执行结果把不该删的文件删了那次事故让我养成了删之前先演练三次的习惯。第四个是exec $LOG_FILE 21把脚本自身所有输出重定向到独立日志文件。这样每次清理动作都有据可查万一出了问题能回溯当时脚本到底做了什么。3.3 删除操作必须带演练开关这是我在生产环境踩坑换来的教训说到踩坑我讲一次真实的经历。那个时候我刚开始写日志清理脚本版本很粗糙核心逻辑就两行find /data/logs -name *.log -mtime 15 -exec rm -f {} \;当时觉得这个脚本真简单一个find就搞定了。上线几天后业务方突然说有个服务的数据丢了排查半天发现那个服务的日志目录下有一个子目录里面放的是历史数据文件后缀恰好也是.log。find递归进去把它们全当成过期日志删了。那次问题很严重从那以后我给自己定了几条铁律删除类脚本必须加maxdepth默认只处理当前目录删除之前必须先归档归档失败不删除原文件必须有演练模式第一次上线先跑--dry-run人肉确认输出列表目标路径禁止使用/或/var/log这种宽泛路径必须具体到业务目录。这几条铁律写进我的脚本模板里以后再没出过类似的删除事故。日志清理这个场景技术含量其实不高难的是把安全两个字做到位。4. 脚本三服务监控自动拉起脚本给业务加一层自愈保险4.1 设计思路探测、防抖、重启、告警四步走服务挂了怎么办最原始的做法是等业务方或者用户来反馈然后你手动去重启。这种模式的问题在于你总是最后一个知道的人。稍微好一点的做法是监控平台告警比如Zabbix、Prometheus它们能第一时间发现服务不可达并通知你。但通知了之后呢如果是半夜你还是得爬起来处理。更进一层的思路是自愈脚本探测到服务异常先尝试自动重启如果重启后还是不行再告警让人介入。这样很多偶发性的服务问题在用户还没感知到的时候就解决了。我发现很多团队过度依赖systemd自带的Restartalways但那个只能做进程级别的重启没法做业务级别的健康检查。比如Nginx进程还在但后面的Java服务已经假死了这时候系统认为Nginx活着实际接口已经超时。所以自定义健康检查脚本仍然有价值尤其是对关键业务可以检查端口、检查特定的HTTP接口返回等。设计上我采用了四步走探测、防抖、重启、告警。第一步先探测服务是否健康第二步如果不健康不立即处理而是记录失败次数连续多次失败才触发后续动作这就是防抖第三步达到阈值后执行重启第四步重启后再次检查如果还是失败立刻发告警通知人工介入。这套逻辑并不复杂但防抖这个设计很关键它能避免服务还在启动过程中就被误判为故障也能避免服务在重启和崩溃之间无限循环。4.2 完整脚本代码与防抖机制怎么落地这个脚本需要配合crontab每1到5分钟执行一次。因为每次执行是独立的进程所以失败计数要保存到状态文件里不能在脚本内部定义变量#!/bin/bash # # 功能: 服务健康检查与自动拉起脚本 # 用法: bash service_watchdog.sh service_name # 示例: bash service_watchdog.sh nginx # 建议: 配合 crontab 每1-5分钟执行一次 # SERVICE_NAME${1:?用法: $0 service_name} FAIL_THRESHOLD3 # 连续失败多少次才触发重启 COOLDOWN30 # 重启后冷却时间秒 ALERT_WEBHOOK请替换为钉钉/企业微信机器人地址 WATCH_LOG/var/log/watchdog_${SERVICE_NAME}.log STATE_FILE/tmp/watchdog_${SERVICE_NAME}.count ts() { date %F %T; } log() { echo $(ts) $* $WATCH_LOG; } # 检查服务是否正常优先用 systemd兼容旧服务用 pgrep check_service() { if systemctl list-units --typeservice --all 2/dev/null | grep -qw ${SERVICE_NAME}.service; then systemctl is-active --quiet $SERVICE_NAME else pgrep -x $SERVICE_NAME /dev/null fi } # 重启服务兼容 systemd 和 sysvinit restart_service() { log 尝试重启 $SERVICE_NAME if systemctl list-units --typeservice --all 2/dev/null | grep -qw ${SERVICE_NAME}.service; then systemctl restart $SERVICE_NAME else service $SERVICE_NAME restart fi sleep $COOLDOWN } # 发送告警默认是打印到日志可换成 webhook send_alert() { local content服务器 $(hostname) 上的服务 ${SERVICE_NAME} 连续 ${FAIL_THRESHOLD} 次检查失败重启后仍然异常请人工介入 if [[ -n $ALERT_WEBHOOK ]] [[ $ALERT_WEBHOOK ! 请替换为钉钉/企业微信机器人地址 ]]; then curl -s -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\$content\}} \ $ALERT_WEBHOOK /dev/null 21 \ echo $(ts) 告警已发送 $WATCH_LOG \ || echo $(ts) 告警发送失败 $WATCH_LOG else log 告警: $content fi } # 读取当前失败计数 FAIL_COUNT$(cat $STATE_FILE 2/dev/null || echo 0) # 服务正常时清零计数并退出 if check_service; then if [[ $FAIL_COUNT -ne 0 ]]; then echo 0 $STATE_FILE log 服务恢复正常失败计数清零 fi exit 0 fi # 服务异常计数1后写入状态文件 FAIL_COUNT$((FAIL_COUNT 1)) echo $FAIL_COUNT $STATE_FILE log 服务异常第 ${FAIL_COUNT} 次失败 # 达到阈值就重启重启后再检查还是不行就告警 if [[ $FAIL_COUNT -ge $FAIL_THRESHOLD ]]; then log 达到重启阈值执行重启 restart_service if check_service; then log 重启成功 echo 0 $STATE_FILE else log 重启失败发送告警 send_alert echo 0 $STATE_FILE fi fi这个脚本值得说道的地方有几个。首先是状态文件的设计。因为crontab每次都是新起一个进程如果计数只放在脚本内的变量里那么每次执行都是0防抖根本不起作用。用状态文件/tmp/watchdog_${SERVICE_NAME}.count保存失败次数就能在多次执行之间传递状态。服务恢复正常时清零连续失败时累加逻辑非常清晰。其次是检查方式的兼容性。脚本先用systemctl list-units | grep判断服务是否由systemd管理如果是用systemctl is-active检查如果不是退回用pgrep -x检查进程名。这样无论是新系统还是老服务都能覆盖。不过pgrep -x是精确匹配进程名如果服务启动后进程名和systemd的service名不一致需要手动调整参数或者改为pgrep -f做模糊匹配。然后是重启前的COOLDOWN时间。重启之后我故意sleep 30秒再做健康检查。原因是很多服务启动需要时间刚执行完systemctl restart如果立即检查服务还没初始化完可能会误判为重启失败。这个冷却时间可以根据业务启动速度调整快则10秒慢则60秒。最后是send_alert里我保留了webhook配置项。钉钉机器人很简单创建一个群机器人后会得到一个URL填进ALERT_WEBHOOK变量就行。脚本会用curl把告警内容推送到钉钉群。如果没有配置webhook告警信息会打印到日志文件不耽误排查。4.3 告警对接与运行方式钉钉机器人加 cron运行方式就一行crontab我建议每1分钟执行一次。阈值设3次意味着服务连续异常3分钟才重启如果服务比较敏感可以把阈值下调到2或者把cron频率提高到每30秒一次。注意cron最小粒度是1分钟如果想更频繁就得用循环加sleep的方式在脚本内部跑不过一般1分钟足够了* * * * * /bin/bash /opt/scripts/service_watchdog.sh nginx另一个建议是把监控脚本纳入管理。这个脚本本身也是服务如果它挂了故障就没人发现了。所以我在脚本里把日志写到/var/log/watchdog_${SERVICE_NAME}.log同时定期检查这个日志文件是否有新的记录。另外重启操作本身要留痕脚本里每次执行动作都会记日志这个日志是后续排查的依据。我还遇到过一个情况某服务频繁崩溃重启脚本刚把它拉起来没到一分钟又挂了然后脚本又在下一个周期检查时发现异常继续计数继续重启。这样反复重启对业务影响很大。所以我在设计里增加了冷却和阈值但实际情况更复杂。后来我在脚本里加了一个启动时间检查重启成功后记录时间如果在N秒内再次失败不再自动重启直接告警。这个属于进阶优化生产环境很推荐加上。5. 脚本四批量远程执行与文件分发告别一台一台连5.1 先花5分钟配置SSH免密后面效率翻倍批量操作的前提是SSH免密登录不然每次执行都要输密码自动化就无从谈起。配置免密实际上分三步每台机器只需要一次第一步在跳板机或你的管理机上生成密钥对。现在的Linux系统默认支持ed25519算法比RSA更安全也更快ssh-keygen -t ed25519一路回车就行生成后的公钥在~/.ssh/id_ed25519.pub。第二步把公钥复制到目标机器的~/.ssh/authorized_keys里。手动做法是ssh-copy-id一条命令搞定ssh-copy-id -i ~/.ssh/id_ed25519.pub root192.168.1.10如果有几十台机器自己写个循环批量执行ssh-copy-id也行。第三步测试一下ssh root192.168.1.10 uptime如果不需要输密码直接返回结果就说明免密配置成功。需要提醒的是免密配置是基于用户的。如果你用root账号批量操作就要把公钥放到目标机器的root用户下如果用普通用户则放到普通用户下。另外~/.ssh/authorized_keys的权限必须是600~/.ssh目录权限必须是700权限不对的话SSH会忽略这个文件。我第一次配免密的时候遇到过这个问题排查好久。5.2 批量执行命令脚本读取主机列表循环下命令先看一个我现在还在用的批量执行命令脚本#!/bin/bash # # 功能: 批量远程执行命令基于 SSH 免密 # 用法: bash batch_remote_cmd.sh hosts_file command # 示例: bash batch_remote_cmd.sh hosts.txt uptime df -h | head -5 # HOSTS_FILE${1:?用法: $0 hosts_file command} REMOTE_CMD${2:?用法: $0 hosts_file command} SSH_USER${SSH_USER:-root} SSH_PORT${SSH_PORT:-22} TIMEOUT${TIMEOUT:-10} SUCCESS0 FAIL0 RESULT_LOG/tmp/batch_result_$(date %Y%m%d_%H%M%S).log : $RESULT_LOG while IFS read -r host; do # 跳过空行和注释行 [[ -z $host || $host \#* ]] continue # 支持 hosts 文件里写 ip:port 格式 if [[ $host *:* ]]; then SSH_PORT${host##*:} host${host%%:*} fi echo 执行节点: $host | tee -a $RESULT_LOG if timeout $TIMEOUT ssh -p $SSH_PORT \ -o StrictHostKeyCheckingno \ -o ConnectTimeout5 \ -o ConnectionAttempts2 \ ${SSH_USER}${host} $REMOTE_CMD; then echo [OK] $host | tee -a $RESULT_LOG SUCCESS$((SUCCESS 1)) else echo [FAIL] $host | tee -a $RESULT_LOG FAIL$((FAIL 1)) fi done $HOSTS_FILE echo 批量执行结束 echo 成功: $SUCCESS 台, 失败: $FAIL 台 echo 详细结果: $RESULT_LOGhosts.txt的格式很简单每行一个IP或者主机名支持IP:端口格式空行和#开头的是注释# 生产环境服务器列表 192.168.1.10 192.168.1.11:2222 192.168.1.12执行方式bash batch_remote_cmd.sh hosts.txt uptime free -h | head -2这里有一个容易被坑的地方如果你要执行的远程命令里包含$符号比如awk {print $1}外层一定要用单引号包住整个命令否则$1会被本地shell先解析掉到远程就变成空值了。我的建议是凡是远程命令超过一行或者包含特殊字符都优先用单引号。脚本里的timeout命令也很重要。SSH连接遇到网络问题或者目标机器卡死有可能长时间等不到返回。timeout 10限制了每条命令最多跑10秒超时就强制结束避免整个批量操作被一个故障节点卡住。StrictHostKeyCheckingno是跳过首次连接时的指纹确认提示否则批量执行时每个新主机都会卡一个yes/no输入。注意这有轻微的安全风险如果在意的话可以先把所有主机的公钥写入~/.ssh/known_hosts再去掉这个参数。5.3 批量分发文件脚本把配置和安装包一次发到所有机器批量执行命令解决了让所有机器做一件事的问题批量分发文件解决的是把东西放到所有机器的问题。场景很常见新版本JDK需要发到100台机器、某个配置文件需要统一更新、应用的jar包需要部署。我写了一个简单的分发脚本#!/bin/bash # # 功能: 批量分发文件或目录基于 scp # 用法: bash batch_scp.sh hosts_file local_path remote_path # 示例: bash batch_scp.sh hosts.txt /opt/jdk17.tar.gz /opt/ # HOSTS_FILE${1:?用法: $0 hosts_file local_path remote_path} LOCAL_PATH${2:?用法: $0 hosts_file local_path remote_path} REMOTE_PATH${3:?用法: $0 hosts_file local_path remote_path} SSH_USER${SSH_USER:-root} SSH_PORT${SSH_PORT:-22} [[ ! -e $LOCAL_PATH ]] { echo 本地路径不存在: $LOCAL_PATH; exit 1; } SUCCESS0 FAIL0 while IFS read -r host; do [[ -z $host || $host \#* ]] continue if [[ $host *:* ]]; then SSH_PORT${host##*:} host${host%%:*} fi if scp -P $SSH_PORT -o StrictHostKeyCheckingno -o ConnectTimeout5 \ -r $LOCAL_PATH ${SSH_USER}${host}:${REMOTE_PATH}; then echo [OK] $host SUCCESS$((SUCCESS 1)) else echo [FAIL] $host FAIL$((FAIL 1)) fi done $HOSTS_FILE echo 分发完成: 成功 $SUCCESS, 失败 $FAIL这个脚本的逻辑和批量执行命令几乎一模一样只是把ssh换成了scp-p换成-P来指定端口。-r参数支持分发目录如果你只想发单个文件去掉-r也行。分发完成后会统计成功和失败的数量方便你快速定位哪些机器有问题。实际使用中我还加了一个优化先把本地文件做一次MD5校验分发到目标机器后再校验一次两次结果一致才认为分发成功。当分发对象是配置类文件时校验非常重要文件损坏的结果比不发还严重。这个增强不复杂在scp后面加一行ssh ... md5sum $REMOTE_PATH对比即可。还有一个实用技巧在hosts.txt里给主机分组。我的做法是准备多个hosts文件比如hosts_web.txt、hosts_db.txtWeb服务器和数据库服务器操作不同用不同的主机列表文件简单又不容易搞错。5.4 从一台台连到一条命令的转变运维效率翻倍我印象很深的一次经历是有一次需要给40台机器下发一份新的Nginx配置。以前的做法是写个文档发给同事大家自己上去改或者我一个一个ssh上去改至少半天时间还容易有人改错。用了批量分发脚本以后先在一台测试机上验证配置没问题然后一行命令发到40台机器再一行命令nginx -t nginx -s reload检查并重载。整个流程十分钟内完成而且是可记录的、可回溯的。这个脚本和Ansible的核心理念很像Ansible本质上就是把SSH免密批量执行文件分发做成了标准化框架。所以你要是能把这4个Shell脚本的原理吃透以后学Ansible会非常快因为底层思路你已经理解了。Shell脚本灵活、轻量适合临时和简单的批量操作Ansible成熟、有资源管理和幂等性适合规范化的场景。两者不是替代关系而是互补关系。6. 常见问题与避坑实录脚本跑不起来90%是这几个原因6.1 问题与解决办法速查表我把自己和身边同事经常踩的坑整理成了一张表基本能覆盖掉Shell脚本运维中的大部分常见问题现象根本原因解决办法脚本报错command not foundcrontab 环境里 PATH 不完整脚本开头 export PATH或用命令绝对路径脚本执行报错$\r在 Windows 下编辑导致换行符是 CRLFsed -i s/\r$// script.sh或dos2unix script.sh脚本没有输出结果忘记加执行权限或被 sh 解释执行用bash script.sh执行检查chmod xmapfile: command not found被sh script执行但 mapfile 是 bash 特性确保用bash script.sh或脚本 shebang 为#!/bin/bashSSH 远程命令里变量为空$被本地 shell 提前展开远程命令用单引号包裹或者转义\$定时任务不执行crontab 里命令路径错误或脚本没权限先手动执行脚本检查/var/log/cron日志find 报错paths must precede expression路径和选项顺序不对或通配符被展开把*.log用引号包起来先路径后选项tar打包后解压带多层目录使用绝对路径打包tar -czf xx.tar.gz -C /path/to/dir filename6.2 我亲自踩过的三个坑每一个都耽误过半天时间第一个坑set -e把脚本坑惨了。我在脚本开头加了set -e本意是只要任何一条命令返回非零就立即退出防止错误继续执行。但实际中很多命令在正常场景下也会返回非零比如grep没匹配到内容、find在空目录里没输出这些都会导致脚本提前退出。有一次我的巡检脚本加了set -e之后因为某台机器的某个目录不存在脚本在中途就退了后面的磁盘检查根本没执行。后来我改成set -uo pipefail对于确实允许失败的命令单独加|| true再没出现过这类问题。第二个坑删除类脚本里用了变量但没做防空判断。我之前写过一个清理脚本里面有一句rm -rf $DIR/*有一次$DIR在配置里没赋值成功它变成了空字符串命令实际执行成了rm -rf /*。虽然运行时的用户权限救了一命但我到现在想起来都后怕。现在的铁律是所有包含rm -rf的脚本必须先判断变量非空且目录存在再执行删除并且路径尽量用引号包起来比如rm -rf $DIR/*。第三个坑crontab里的环境变量像一张白纸。crontab执行脚本的时候PATH和交互式shell不一样经常只有/usr/bin:/bin很多命令比如nginx、java在/usr/local/bin或/opt下直接执行会得到command not found。我的习惯是脚本开头统一加一行export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin然后把可能的业务命令路径也追加进去这样crontab执行时就不会找不到命令了。6.3 后续怎么扩展从脚本到平台化Shell都是地基把这4个脚本用顺之后你会发现运维自动化的路才刚刚开始。我自己是这么走过来的脚本先满足单机需求然后通过批量执行脚本把操作铺开到所有机器再然后开始接触Ansible把以前写在Shell脚本里的逻辑改写成playbook最终实现可版本化、可复用、可回滚的自动化任务。你会发现Ansible的很多模块比如synchronize、service、cron本质上就是在Shell命令外层包了一层标准接口。另外一个值得关注的方向是用Shell脚本配合API做系统集成。比如调用云厂商的OpenAPI实现弹性伸缩、在监控脚本里调用企业微信或钉钉机器人接口推送告警、用脚本解析日志文件把结构化数据上报到监控平台。这些场景不需要很复杂的语言Shell足够快速完成。现在也有一些团队开始尝试AI Agent辅助自动化运维比如让大模型根据自然语言生成Shell脚本或者分析日志。但不管上层工具怎么变底层还是得懂Linux命令、懂Shell语法、懂运维逻辑。工具会迭代但这些基础能力永远不会过时。我个人最大的体会是自动化脚本最重要的不是炫技而是稳定。宁可脚本写得笨一点、慢一点也要保证每次执行结果可预期、可回溯。每写一个脚本都给自己留一份日志留一个--dry-run的开关留几条出错时宁可不动也不要乱动的保护逻辑。把这些做好了你的运维工作才会真正从被动救火变成主动规划。建议你先把这4个脚本跑起来改造成适合自己环境的版本你会发现那些曾经占据你大量时间的重复操作正在一点点变成自动运行的固定流程。