ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Shell脚本防死循环指南:超时控制与异常退出实战

Shell脚本防死循环指南:超时控制与异常退出实战 写Shell脚本这些年我最怕碰到的不是语法报错而是脚本运行到一半不动了。尤其那些挂着while循环的脚本一旦循环体里有个命令在等网络、等文件、等用户输入整个任务就像被按了暂停键日志停在最后一行进程占着CPU不放你甚至分不清它是在慢速处理还是彻底卡死。这个问题的本质是Shell脚本里的循环天然缺少时间概念只要循环条件没有被打破它会一直转下去直到机器资源耗尽或者被外部kill。这一章就专注解决两件事——给循环加超时控制以及让脚本在异常情况下能体面退出。对于做自动化运维、数据处理、定时任务的朋友来说这套防死循环的思路属于必须掌握的保命技能。后面讲的内容不涉及高深技巧大多数是命令行的常规武器但组合起来会非常有效。我会把实际踩过的坑和排查路径一并放进来确保你看完能直接对号入座把脚本改安全。1. 死循环是怎么来的常见触发场景与根因定位思路想防死循环先得搞清楚死循环到底是怎么出现的。很多时候程序员写循环时的逻辑本身没错是循环依赖的外部状态出了问题。1.1 等待类命令把循环体卡死最常见的死循环元凶是循环体内部执行了一个永远等下去的命令。比如下面这段while read line; do process $line done (tail -f /app/log/access.log)tail -f会持续监听文件追加内容永远不会主动结束。while read会一直从中读取于是这个循环的退出条件——文件读到EOF——永远无法满足。这不是逻辑错误是你把两个无限语义叠加在了一起。类似的场景还有while :; do ssh $host uptime done如果某个主机的SSH连接挂起ssh命令本身不会超时退出循环体就被它卡住后面的命令全都执行不到。这类问题的特点是循环不是飞快地空转而是停在一个点不动。排查时很容易误判成网络慢实际上就是循环体里缺了超时参数。1.2 轮询条件永远不满足最坑的sleep补偿另一类死循环是轮询型比如等一个服务端口起来、等一个文件出现、等一个进程退出。典型的写法是while ! nc -z 127.0.0.1 3306; do sleep 1 done这段逻辑没毛病MySQL起来之后循环自然退出。但假如MySQL配置错误、端口被防火墙挡住、或者服务进程崩溃后没被拉起nc -z会一直返回非0循环就无限执行下去了。这里有个陷阱很多人在循环体加了sleep 1就以为有间隔的循环不会打死机器。实际上sleep只降低循环频率不提供退出条件。哪怕每30秒轮询一次脚本照样能挂一整天只是看起来没那么凶而已。问题的核心不是频率而是循环次数有没有上限。1.3 文件遍历中的幽灵更新for循环为何会越跑越多for循环表面上是遍历固定集合但如果你在循环体内修改了集合本身就会出问题。比如批量重命名文件的场景for file in *.txt; do mv $file ${file%.txt}.bak doneShell在执行for file in *.txt时会先把匹配到的文件列表缓存起来所以这个循环本身是安全的。但如果你写的是$(ls *.txt)这种命令替换形式或者循环体在子目录中切换后再用通配符重新展开行为就会变得不可控。更隐蔽的是下面这种while [[ -n $(ls /data/in/*.csv 2/dev/null) ]]; do process_one_file done如果process_one_file处理失败没有把CSV文件移走或删除那么/data/in/目录下的文件永远存在ls永远有输出-n永远为真循环永远不会退出。这类死循环的特点是空转但无害CPU占用不高但日志会以恐怖速度增长磁盘很快被撑爆。我把这三种情况总结成一张判断表方便你在面对脚本卡住时快速定位现象可能成因定位手段进程停在一个点CPU很低循环体内有等待类命令strace 看阻塞在哪个系统调用进程高速运转CPU拉满循环条件永不为假bash -x 看最后重复执行的命令日志疯狂增长CPU不高空转型死循环检查循环条件依赖的文件/命令输出2. 超时控制三板斧timeout、read -t与自旋计数器防死循环最直接的手段就是加超时给循环一个到点就收手的机制。Shell脚本里能做超时控制的方案不少关键是选对场景。2.1 timeout命令的正确姿势不止包一层那么简单GNU coreutils 自带的timeout命令是首选武器用法非常简单timeout 10 tail -f /app/log/access.log它会在10秒后向目标进程发送TERM信号命令没结束也强制终止。用在循环里就是给每个可能卡住的命令设上限while :; do # 这条SSH命令最多只给它15秒 timeout 15 ssh $host uptime || { echo [$(date)] $host 连接超时 /var/log/check.log break } done这里有两个细节容易踩坑。第一timeout需要放在命令的最前面而不是中间。ssh timeout 15 $host会把timeout当成远程命令传给远端效果完全不一样。第二timeout默认只发送TERM信号如果目标进程不响应TERM它会继续挂在那里。这时需要配合-k参数指定宽限期# 10秒后发TERM若还不退再过5秒发KILL timeout -k 5 10 ssh $host uptime给timeout加--preserve-status也值得留意。默认情况下timeout会把目标命令被杀时的退出码改成124。如果你对外层脚本做了退出码约定这个特殊码反而会干扰判断。加上--preserve-status后timeout会如实传递原命令的退出码外层逻辑更干净。2.2 read -t交互式脚本的保命符如果你的循环涉及用户输入read命令自带超时功能这是很多人没注意到的。while :; do read -t 5 -p 请输入操作指令: cmd if [[ -z $cmd ]]; then echo 等待输入超时退出 exit 1 fi execute $cmd done-t 5表示等待输入最多5秒。如果用户在5秒内没有输入read返回非0cmd变量为空脚本可以据此做出退出或继续的决定。还有-N参数控制读取的字符数-s隐藏输入内容。对需要密码确认的交互式循环这三个参数组合起来就能防止提示符出现但没人应答的挂死。2.3 自旋等待场景下的退出计数器对于轮询型循环最朴素的方案反而是计数器——允许重试N次超过次数就放弃。不要迷信什么花哨机制计数器在长时间任务中的可靠性最高MAX_RETRY30 count0 until nc -z 127.0.0.1 3306; do count$((count 1)) if [[ $count -ge $MAX_RETRY ]]; then echo 等待MySQL启动超时已重试 $count 次 exit 1 fi sleep 2 done这里的count变量每次循环加1达到30次立即退出。加上sleep 2整个等待窗口是60秒非常可控。还有一个进阶写法用$((count 1))代替count$((count 1))少敲几个字。计数器方案的最大好处是退出原因可追溯。你可以在超时时把count值、最后一次的状态都写进日志后面排查时能知道循环卡了多久、卡在哪个阶段。我在生产环境里基本都是计数器方案因为它一眼就能看出循环允许的最大运行时间。下面把三种方案适用的场景小结一下方案适用场景优点局限timeout 命令单个命令可能挂起精确控制单命令耗时无法直接控制整个循环read -t交互式输入循环天然等待用户输入只对read生效计数器轮询、重试型循环退出原因可追溯需要手动维护计数变量3. 异常退出处理trap、set -e与退出码的三层防线超时控制解决的是循环停不下来异常退出处理解决的是循环出错了怎么收场。一个健壮的脚本必须在异常发生后能清理临时文件、恢复环境变量、并向外层返回正确的退出码。3.1 set -e的误区和正确打开方式很多人一上来就给脚本加set -e指望出错就停。但set -e不是银弹它有几个容易误判的规则管道命令中只检查最后一个命令的退出码。cmd1 | cmd2中 cmd1 失败但 cmd2 成功set -e不会退出。if、while、until的测试条件命令即使返回非0也不会触发退出。被!取反的命令不会触发退出。这就导致一种尴尬情况循环体内某个关键命令失败了脚本却还在继续跑。比如set -e while :; do curl -s http://example.com/api # 假设curl返回非0但由于它在普通语句位置set -e生效应该退出 process_result donecurl返回非0时set -e能让脚本退出这没问题。但如果curl的请求失败被set -e杀掉了循环没跑完临时文件没人清理。所以set -e的正确打开方式是配合trap一起用一个负责死一个负责死后料理。3.2 trap EXIT/ERR/信号清理战场的关键trap命令能捕获脚本退出、错误、信号三类事件。最常用的是捕获EXIT保证脚本无论正常结束还是异常退出都会执行清理函数cleanup() { rm -f /tmp/my_script_*.tmp echo [$(date)] 脚本退出临时文件已清理 /var/log/my_script.log } trap cleanup EXIT while :; do tmp_file$(mktemp /tmp/my_script_XXXXXX.tmp) process_data $tmp_file # 这里如果执行 exit 1cleanup 依然会被调用 done只要是脚本进程结束无论是自然结束、exit、还是被信号杀死在允许捕获的情况下EXITtrap都会执行。这让清理变成了一个确定性行为而不是靠程序员在每一条出错路径上手动写清理代码。再往下细化ERRtrap可以捕获任何命令返回非0的情况常用于记录失败现场err_report() { echo 命令 $BASH_COMMAND 执行失败行号 $LINENO exit 1 } trap err_report ERR配上BASH_COMMAND和LINENO能自动打印出出错的那条命令和行号。这个信息在定位死循环时尤其宝贵——你会知道循环体里到底是哪一步先爆炸的。还有一类情况是信号处理。长时间运行的脚本运维可能用kill -9强杀强杀无法捕获但如果用kill发送TERM信号脚本就能捕获并执行善后handle_term() { echo [$(date)] 收到TERM信号停止任务... # 标记循环需要退出 STOP_FLAG1 } trap handle_term TERM配合一个全局变量让TERM信号不直接杀死进程而是改变循环状态的判断条件。这种软停止在需要优雅关闭的场景下非常实用。3.3 退出码设计给外层调用者一个可判断的死亡原因防死循环的最后一步是让脚本死得明明白白。如果你只是exit 1外层脚本只知道失败了不知道为什么失败。我建议按以下规程设计退出码EXIT_OK0 EXIT_TIMEOUT1 EXIT_GENERAL2 EXIT_INPUT_ERR3 EXIT_DEPENDENCY_MISSING4 if [[ $count -ge $MAX_RETRY ]]; then echo 等待端口超时退出码 $EXIT_TIMEOUT exit $EXIT_TIMEOUT fi外层调用方就可以精确判断if check_service.sh; then echo 检查通过 elif [[ $? -eq $EXIT_TIMEOUT ]]; then echo 服务启动超时切备用节点 elif [[ $? -eq $EXIT_DEPENDENCY_MISSING ]]; then echo 缺少依赖环境先安装依赖 fi尽量做标准化的退出码映射。把每个退出码的含义写进脚本头部的注释块后续维护的人看到exit $EXIT_TIMEOUT就知道发生了什么不用翻看错误日志。4. 实战场景拆解监控脚本、服务探测与批量任务理论讲完进入实操。我挑三个最常见的场景把完整脚本和设计思路放出来。4.1 场景一等待服务启动——带超时的健康检查部署服务后脚本需要等服务进程真正监听到端口才能继续下一步。单纯用nc -z轮询会存在服务假死的风险——端口开了但进程还不可用。更保险的做法是同步去请求一个实际的健康检查接口#!/bin/bash # 等待 HTTP 服务就绪支持超时和中止 MAX_RETRY30 RETRY_INTERVAL2 HEALTH_URLhttp://127.0.0.1:8080/healthz check_health() { local code code$(curl -s -o /dev/null -w %{http_code} --connect-timeout 2 --max-time 3 $HEALTH_URL) [[ $code -eq 200 ]] } echo [$(date)] 开始等待服务就绪... count0 until check_health; do count$((count 1)) if [[ $count -ge $MAX_RETRY ]]; then echo [$(date)] 服务在 $((MAX_RETRY * RETRY_INTERVAL)) 秒内未就绪退出 exit 1 fi echo [$(date)] 第 ${count} 次探测失败${RETRY_INTERVAL}秒后重试 sleep $RETRY_INTERVAL done echo [$(date)] 服务已就绪curl本身带了--connect-timeout和--max-time保证单次探测不会因为网络黑洞而无限等待。外层计数器保证整体等待时间有限。这里的双重超时设计是我在线上看了太多次单层超时被绕过之后养成的习惯。4.2 场景二批量重命名——先快照后遍历批量处理文件时防止死循环的核心思路是先快照再遍历绝不在遍历的同时依赖实时目录状态。用数组把文件名一次性存起来切断了遍历集合与文件系统状态之间的耦合#!/bin/bash # 批量重命名照片文件处理失败不阻塞最后输出汇总 cd /data/photos || exit 1 # 先做快照 mapfile -t files (find . -maxdepth 1 -type f -name *.jpg | sort) total${#files[]} echo [$(date)] 共发现 $total 个jpg文件 processed0 failed0 for file in ${files[]}; do new_name${file%.jpg}_$(date %Y%m%d).jpg if mv $file $new_name 2/dev/null; then processed$((processed 1)) else failed$((failed 1)) echo [$(date)] 重命名失败: $file fi done echo [$(date)] 处理完成成功 $processed 个失败 $failed 个 [[ $failed -eq 0 ]]mapfile是readarray的别名把命令输出读到数组里非常方便。快照先被完整保存后续文件系统怎么变化都不影响遍历的集合。这样即使mv因为文件被占用而失败也只是影响单条记录不会让循环进入文件永远处理不完的怪圈。4.3 场景三日志监控——双保险退出机制监控日志文件时常见的坑是tail -f无限等待以及监控目标日志文件被轮转logrotate之后找不到内容。我给脚本加上一个最大读取行数和一个文件存在性检查形成双保险#!/bin/bash # 监控日志并触发后续动作单次运行最多处理 N 行持续 M 秒 LOG_FILE${1:-/var/log/app.log} MAX_LINES1000 MAX_SECONDS60 start_time$(date %s) line_count0 if [[ ! -f $LOG_FILE ]]; then echo 日志文件不存在: $LOG_FILE exit 1 fi # 预设超时时间循环到点强制退出 while [[ $(( $(date %s) - start_time )) -lt $MAX_SECONDS ]]; do # 用 read -t 保证单次读取也有超时 if read -t 5 -r line; then process_line $line line_count$((line_count 1)) if [[ $line_count -ge $MAX_LINES ]]; then echo 已达最大行数 $MAX_LINES停止处理 exit 0 fi fi done $LOG_FILE echo 已达到最大运行时间 $MAX_SECONDS 秒停止处理 exit 0这个脚本即使日志文件是tail -f的替代品也能做到最多跑60秒或最多处理1000行两个条件任何一个先满足就先退出。read -t 5作为单行读取的最后防线防止某一行内容让process_line卡住。5. 脚本卡死后怎么排查从bash -x到strace的完整链路就算做了超时控制还是会有漏网之鱼。脚本卡死后不要急着kill先按下面这条链路排查一遍往往能直接揪出根因。5.1 bash -x与PS4快速定位死循环现场最基础的手段是用bash -x调试模式运行脚本它会把每条执行的命令打印出来。配合PS4变量加时间戳能看出命令执行的节奏PS4 $(date %s.%N) $LINENO: bash -x ./monitor.sh输出会变成 1694356789.123456789 28: count0 1694356789.123456789 30: check_health 1694356789.123456789 31: curl -s -o /dev/null -w %{http_code} --connect-timeout 2 --max-time 3 http://127.0.0.1:8080/healthz如果时间戳完全不变说明命令卡在某个系统调用里比如curl在等待响应。如果时间戳在不断变化但打印的总是同一行比如check_health的调用行说明循环在空转条件判断一直不满足。这两种情况导致死循环的机理完全不同处理方式也不一样——前者需要给命令加超时后者需要修正循环条件。5.2 strace从系统层面看清阻塞点当bash -x显示命令卡在某一行但又看不出具体卡在哪个阶段时就要上strace了。strace跟踪系统调用能告诉你进程到底在等什么# 附加到卡死的进程 strace -p 12345 -t -f常见的阻塞输出有几种# 阻塞在网络连接 connect(3, {sa_familyAF_INET, sin_porthtons(3306), sin_addrinet_addr(10.0.0.5)}, 16) # 阻塞在读取管道等待输入数据 read(0, unfinished ... # 反复执行同一命令序列没有阻塞 stat(/data/in/, 0x7ffd...) -1 ENOENT (No such file or directory)如果是connect卡住说明是网络层面没有超时控制应回到timeout或nc -z的参数配置。如果是read(0)卡住说明循环在等待输入或管道数据应检查是否有tail -f之类不会关闭的流。如果反复stat循环那基本就是逻辑层面的条件永不满足和等待无关。5.3 日志切割与看守进程的配合排查结束后还要考虑以后怎么避免。我的习惯是给所有允许长时间运行的脚本配两个东西日志切割和时间线记录。日志切割不复杂就是每次循环写入日志时带上日期日志单独存放按天滚动避免一个死循环脚本撑爆/var/logLOG_PATH/var/log/monitor/$(date %Y%m%d).log echo [$(date %H:%M:%S)] 第 $count 次探测失败 $LOG_PATH时间线记录则是为了事后回溯。脚本每完成一个重要阶段或者每次循环迭代就打出当前时间。死循环发生时最后两条日志之间的间隔就能告诉你卡在哪儿了。如果脚本的稳定性要求非常高还可以在外面套一个看门狗——用另一个脚本定时检查目标进程是否存在并通过日志文件的更新时间判断它是否还在干活。日志文件超过5分钟没更新就用kill重启目标脚本。这个方案虽土但在生产环境中救过我好几次。写在最后的经验我在真实服务器上踩过的死循环事故基本都逃不出本章讲的这几种成因等待类命令卡住、轮询条件永不满足、遍历集合被动态修改。解决的手段归根结底就三条——给单个命令加超时给整个循环加上限以及用trap保证异常退出时能清理战场。你可以在写脚本时把这些手段当成默认标配而不是出了问题再补。另外分享一个我个人的习惯所有可能长时间运行的循环脚本第一行注释一定写明最大运行时间和设计退出条件。比如# 最多运行60秒条件check_health成功。这行注释在三个月后你回来维护脚本时比任何文档都好用。工具是死的防死循环的意识才是活的。如果你的脚本还没做过超时和异常退出处理现在就可以打开手头那个while循环补上计数器成本很低收益却可能是一次大事故免于发生。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进