首页 / 资讯中心 / 文章详情

Shell脚本信号捕获与优雅退出:trap命令实战指南

Shell脚本信号捕获与优雅退出:trap命令实战指南 ★ FEATURED ARTICLE
在服务器上跑过脚本的兄弟应该都有过这种体验一个跑了好几分钟的部署脚本手滑按下 CtrlC 中断了结果发现临时文件没清理、PID 文件残留、配置文件写了一半。下次再跑脚本直接报“进程已存在”或者读到半截配置整个人瞬间血压拉满。这事儿的根子就在信号处理上。Linux 下 kill、CtrlC、系统关机发给进程的都是信号而 Shell 脚本默认对信号只有一种态度直接死。死不可怕可怕的是死之前没来得及收拾现场。这篇就围绕 SIGINT终端中断CtrlC 触发和 SIGTERMkill 默认发送的终止信号聊透一个问题怎么写一个能体面退场的脚本。我默认你用过 Shell、写过带函数和变量的脚本不需要多深入但至少知道trap是干嘛的。看完你会掌握捕获信号的完整姿势、优雅退出的设计套路以及那些只看 man 页根本学不到的血泪教训。1. 信号到底是怎么回事1.1 信号不是玄学它就是一个快递想理解信号捕获先别把它想得多神秘。Linux 内核给进程发信号本质上就是发一个“包裹”包裹上写着一个数字。进程收到后要么执行默认处理大部分是直接终止要么按你设置的逻辑处理。整个过程是异步的——你的脚本在正常运行信号随时可能插进来。做个不严谨但好懂的类比你正在工位上写代码同事走过来拍你肩膀说“老板叫你”SIGINT你把手头的文档保存了再离开优雅退出但要是同事直接拔你电脑电源SIGKILL那啥都没了。Shell 脚本里最常见的两类信号信号数字触发方式默认行为SIGINT2CtrlC终止进程SIGTERM15kill pid终止进程SIGHUP1终端挂断/退出终止进程SIGKILL9kill -9 pid终止进程且不可捕获SIGQUIT3Ctrl\终止进程并生成核心转储核心知识点在前三行。SIGINT 和 SIGTERM 都是“请进程自觉退出”的信号区别在于来源不同——一个来自键盘一个来自 kill 命令。这俩是可以通过trap拦截并自定义处理的。SIGKILL 连拦截的机会都不给你所以别指望通过 trap 解决所有问题这也是后面讲优雅退出设计时的重要边界。1.2 为什么默认行为不够用可能有人问脚本被信号杀掉不是很正常吗CtrlC 就是想让脚本停啊。问题是“停”和“体面地停”是两回事。默认行为下脚本被 SIGINT 中断会当场终止正在写的文件可能停在半截临时目录没人清理锁文件还留在那。对一次性手动跑的脚本这种问题最多让你骂一句脏话重跑一次但脚本一旦放进 crontab、被 CI 系统调用、或者作为服务在后台跑半截退出的后果直接放大——数据被写坏、重复任务冲突、监控告警刷屏。我见过最典型的一次某个数据处理脚本每天晚上定时跑某次系统做维护运维把一堆进程 kill 掉脚本没来得及清理临时目录就死了。第二天凌晨脚本跑起来把上一轮残留的中间文件当成新数据给处理了结果报表整个错掉。排查定位花费的时间比当初写好一个 trap 来得多得多。所以优雅退出的本质是什么就是在死之前把该交代的事情交代完。2. Shell 捕获信号的基础姿势2.1 trap 命令的基本用法与语法陷阱Shell 里捕获信号靠的是内置命令trap格式非常简单trap 要执行的命令 信号名或编号比如最经典的写法#!/bin/bash cleanup() { echo 清理临时文件... rm -rf /tmp/my_app_$$ echo 退出。 } trap cleanup SIGINT SIGTERM脚本运行时按 CtrlC会执行 cleanup 函数然后继续往下走——注意是执行完 trap 里的命令后继续跑不是直接退出。这里牵扯出一个新手最容易踩的坑很多人以为执行完清理函数后脚本会自动退出结果发现脚本还在继续跑甚至把清理函数执行完又跑回主流程继续干活了。如果想要“清理完就退出”得在 trap 处理里显式加exittrap cleanup; exit 1 SIGINT SIGTERM还有个更隐蔽的坑trap 里的字符串和变量展开时机。看我下面举两个例子效果天差地别。# 写法一双引号变量在 trap 设置时就展开 trap echo 收到信号当前PID$$; cleanup SIGTERM # 写法二单引号变量在信号到来时才展开 trap echo 收到信号当前PID$$; cleanup SIGTERM$$在双引号里会被立即替换成当前进程 PID在单引号里会保持原样等信号触发时再求值。大部分场景——尤其是清理函数需要访问运行时状态时——应该用单引号或者直接在 trap 里调用函数名。函数调用写在单引号和双引号里区别不大但变量这块一个不留神就是查半天的 bug。2.2 捕获后还要能“反悔”你以为设置好 trap 就完事了有些场景需要你在运行中临时取消捕获把信号恢复成默认行为。# 恢复 SIGINT 的默认行为 trap - SIGINT # 忽略某个信号不处理也不退出 trap SIGTERMtrap -是把信号处置方式恢复为默认trap 是显式忽略信号。为什么需要恢复默认举个真实例子你写的脚本会 fork 一堆子进程在某些 flow 里你希望子进程被打断时不触发父进程的清理函数这时候就需要在特定代码段临时trap -执行完再重新设置回来。再比如服务启动脚本里启动主进程之前你想忽略 SIGTERM防止用户在初始化关键资源时误kill导致状态不一致等初始化完成再重新打开捕获让主进程进入正常信号处理状态。查看当前 trap 设置也有专用命令trap -p输出类似trap -- cleanup SIGINT trap -- cleanup SIGTERM调试脚本时这招很管用能确认你设置的信号处理逻辑到底生效没有。2.3 EXIT 陷阱不管怎么退都能兜底trap除了能接具体信号还有一个特殊用法——捕获 EXIT也就是脚本退出时触发trap cleanup EXIT这段代码表示不管脚本是正常执行完、被信号终止、还是exit主动退出cleanup 都会执行。这功能我用得最频繁因为它的语义是“注册一个离场动作”和具体信号无关。设计脚本时我喜欢这样搭配#!/bin/bash tmp_dir$(mktemp -d /tmp/myapp.XXXXXX) cleanup() { rm -rf $tmp_dir echo [$(date %T)] 脚本退出已清理 $tmp_dir } trap cleanup EXIT # ... 主体逻辑 ...好处很明显你不需要在脚本每个exit分支手动调用 cleanup也不会因为忘了处理某个信号分支就漏清理。把心思放在主流程上离场动作统一兜底。副作用也不是没有——如果你对 EXIT trap 里的函数本身还有 trap执行顺序就要小心。先设置的 EXIT trap 后执行后进先出多个 EXIT trap 叠加时顺序往往和直觉相反。我的建议是保持一个脚本只设置一个 EXIT trap不要叠太多层。3. 优雅退出不只是“清个临时文件”3.1 清理函数设计的三个层次写过几年脚本之后我总结出 cleanup 函数需要管的三类事情按优先级排序第一层物理垃圾。临时目录、临时文件、下载到一半的安装包这些直接 rm 就行。注意路径变量必须带引号清理前先判断变量不为空防止路径变量没定义直接把家目录给删了——这种事故在同行群里见过不止一次。第二层逻辑状态。PID 文件、锁文件、环境变量、当前工作目录、umask 设置这些都是进程运行期间改变的外部状态。进程退出后这些状态还残留着会影响下一次运行。比如部署脚本一般在开头写一个 PID 文件退出时删掉不然下次启动发现 PID 文件存在就直接拒绝执行。第三层数据完整性。这是最难也最容易被忽略的。脚本写到一半的临时文件要不要留着写坏的配置文件要不要回滚到旧版本数据库操作已经执行了一部分怎么办这些就得根据具体场景决定。我的通用做法是写文件时先写到临时文件全部写完之后再 atomically rename 到目标位置。这样即使被信号打断目标文件永远是完整的旧版或完整的新版不会出现“写了一半的配置文件”。3.2 处理正在跑的子进程才是重头戏脚本干净利落地退出了可是它启动的后台子进程呢这是一个特别多坑的地方。#!/bin/bash run_task() { sleep 300 } # 错误示范 run_task wait如果run_task是个耗时任务主脚本收到 SIGTERM 执行完 cleanup 后直接退出那个sleep 300的子进程会变成孤儿进程继续跑满 5 分钟。如果这个子进程是数据处理任务相当于你把它晾在那不管了。正确的做法是让 trap 处理函数里把子进程也一并收掉#!/bin/bash child_pid run_task() { sleep 300 child_pid$! } cleanup() { echo 收到退出信号停止子进程... [ -n $child_pid ] kill $child_pid 2/dev/null wait $child_pid 2/dev/null echo 清理完成 } trap cleanup SIGTERM SIGINT run_task while true; do sleep 1 done这里用了$!获取后台进程 PIDtrap 触发时先 kill 子进程再 wait 回收防止子进程杀完立刻变成僵尸进程。很多脚本脚本没用 wait 回收结果用ps一看全是defunct。后台子进程再多一些的话可以用jobs -p拿到所有后台任务 PID逐个处理cleanup() { echo 停止所有后台任务... local pids pids$(jobs -p) for pid in $pids; do kill $pid 2/dev/null done wait 2/dev/null }3.3 如何区分“正常退出”和“被打断的退出”cleanup 已经正确执行了但还有一个问题脚本退出后上层调用方需要知道这次退出是因为完成任务还是因为被信号打断。这直接关系到上层逻辑的决策——比如调度系统要不要重试CI 要不要报失败。区分方法很简单就是构造不同的退出码#!/bin/bash cleanup() { local exit_code$? rm -rf $tmp_dir echo 脚本退出exit code$exit_code } signal_cleanup() { echo 收到 SIGTERM退出码设为 143 rm -rf $tmp_dir exit 143 } trap signal_cleanup SIGTERM trap cleanup EXITSIGTERM 默认退出码是 14312815SIGINT 是 1301282。这个“128信号编号”的规则是 Shell 处理信号的通行做法上层工具也按照这个约定来判断退出原因。你在 trap 里自定义退出码时最好也遵循这个惯例别自己发明一套不然上层系统解析会乱。$?在 EXIT trap 里能拿到最后的退出码这是一条非常好用的信息几乎所有的 EXIT trap 调试都能靠它定位问题。4. 实战一带进度和断点续传的数据处理脚本聊了这么多理论把这个完整脚本丢出来三步两步落地。就拿我经常写的一类场景举例脚本要从源目录处理一批文件到目标目录处理过程会产生中间文件支持 CtrlC 中断后保留已完成的进度。#!/bin/bash # 功能批量处理数据文件支持优雅中断中断后已处理文件不会重复处理 source_dir/data/input dest_dir/data/output tmp_dir/data/tmp lock_file/var/run/data_processor.lock log_file/var/log/data_processor.log # 日志函数 log() { echo [$(date %Y-%m-%d %H:%M:%S)] $* $log_file } # 检查锁文件防止重复运行 check_lock() { if [ -f $lock_file ]; then local old_pid old_pid$(cat $lock_file) if kill -0 $old_pid 2/dev/null; then log 检测到另一个实例正在运行 (PID$old_pid)退出 exit 0 else log 发现残留锁文件PID$old_pid 已不存在清理后继续 rm -f $lock_file fi fi echo $$ $lock_file } # 核心清理函数 cleanup() { local exit_code$? log cleanup 开始exit_code$exit_code rm -rf $tmp_dir rm -f $lock_file log cleanup 完成 } danger_cleanup() { log 捕获到中断信号进行安全清理 rm -rf $tmp_dir rm -f $lock_file exit 130 } # 注册信号处理 trap danger_cleanup SIGINT trap cleanup EXIT # 主流程 check_lock mkdir -p $tmp_dir for file in $source_dir/*.dat; do [ -f $file ] || continue base_name$(basename $file) dest_file$dest_dir/${base_name%.dat}.processed # 已处理过的文件直接跳过实现断点续传 [ -f $dest_file ] { echo 跳过已处理文件: $base_name continue } echo 处理中: $base_name cp $file $tmp_dir/ # 模拟耗时处理 sleep 2 mv $tmp_dir/$base_name $dest_file echo 完成: $base_name done echo 全部处理完毕这个脚本有几个设计意图我拆解一下check_lock里用kill -0判断锁文件对应的 PID 是否还存在这是多实例防冲突的经典手法。kill -0不发送信号只检测进程是否存在比ps快且可靠。你手动中断脚本后锁文件被danger_cleanup清掉下次执行不会撞锁如果脚本是被 SIGKILL 崩掉的锁文件就会残留但check_lock用kill -0确认 PID 没了会主动清理不影响下次执行。trap danger_cleanup SIGINT和trap cleanup EXIT分两层设计SIGINT 走专门的处理路径并显式 exit 130EXIT 兜底所有路径。注意danger_cleanup里也做了清理动作这样即使 EXIT trap 因为某种原因没触发也不至于留下垃圾。断点续传的思想也很关键。[ -f $dest_file ] { skip }表示已经生成目标产物的文件直接跳过这样中断后重跑已完成的不受影响。优雅退出并不只是“清理现场”更是“保护现场”让重跑成本降到最低。5. 实战二一个最小可用的服务守护脚本第二个场景更贴近服务端脚本作为守护进程跑在后台主循环里要处理网络请求或消费消息队列收到 SIGTERM 时要停止接收新任务、处理完正在跑的再退出。这是优雅退出最典型的应用。#!/bin/bash # 功能常驻任务 worker支持优雅停止 queue_file/tmp/task_queue.txt processed_file/tmp/processed.txt is_runningtrue graceful_shutdown() { echo [$(date %T)] 收到 SIGTERM不再获取新任务等待当前任务结束... is_runningfalse } do_shutdown() { echo [$(date %T)] 正在处理的任务已结束退出。 exit 0 } trap graceful_shutdown SIGTERM trap do_shutdown EXIT echo worker 启动PID$$ while [ $is_running true ]; do # 从队列取一行任务 task$(head -n 1 $queue_file) if [ -z $task ]; then sleep 2 continue fi # 模拟处理任务3秒 echo 处理任务: $task sleep 3 # 处理完成从队列移除 sed -i 1d $queue_file echo $task $processed_file done和第一个脚本不同这里 SIGTERM 的处理逻辑不是立刻清理退出而是先把状态置为is_runningfalse让主循环在处理完当前任务后自然退出。这种设计能最大限度地保证数据不丢、任务不半截。有个细节主循环里的sleep 2此时不再安全。为什么不安全因为脚本阻塞在 sleep 里时信号确实能打断 sleep触发 traptrap 执行完后脚本会继续往下走。但如果 trap 只是把is_running置为 false主循环当前还在 sleep 的剩余时间内等到 sleep 结束才检查条件。这延迟可以接受但对于某些要求秒级响应的场景你可能希望“收到信号就立刻醒来”。一个小技巧是把 sleep 时间改短比如 0.5 秒轮询更频繁代价是 CPU 占用稍高。生产环境我一般配合 wait、read 或者用timeout命令实现信号响应和阻塞的平衡。再看这个脚本的退出码设计do_shutdown在 EXIT 里执行退出码为 0表示“优雅退场”。上层调度系统看到 0 就知道是正常结束不会触发告警和真正的异常退出区分开了。如果你的 worker 需要处理多个并发任务可以把任务丢到后台维护一个 PID 列表收到信号后轮询列表等所有任务结束pids() graceful_shutdown() { echo 等待所有子任务结束... for pid in ${pids[]}; do wait $pid done echo 所有任务结束退出 exit 0 }这个方案比单纯设置标志位更精细等到了每一个子任务的结束代价是wait会阻塞信号触发后 trap 里执行 wait 期间信号被阻塞可能导致二次信号传不进来。想要兼顾可以改成“先标记退出主循环里等所有 pid 结束再 break”。这些是设计分寸的问题没有银弹关键是要想清楚你的场景到底要“快停”还是要“安全停”。6. 常见问题与排查技巧实录和信号处理相关的坑网上资料很多但很多都停留在“抄一段 trap 语法”的水平。说说我在真实环境里反复踩过的几个问题每个都对应一个排查思路。6.1 trap 里的变量为什么是空的有次脚本里 trap 调用的函数要输出当前进程 PID结果日志里 PID 是空的。排查到最后才发现问题出在引号上trap 注册时用了双引号变量在注册瞬间展开注册时变量还没赋值于是变成了空字符串。这个问题的排查方法很简单在 trap 的 command 字符串里加一句env /tmp/trap_env触发信号后打开文件看环境变量对不对。不过更省事的做法是从一开始就遵守规则——所有涉及变量的 trap 回调都用单引号或在函数内部重新读取变量不要依赖 trap 注册时的环境。6.2 脚本变成“杀不死的小强”另一个让运维同事抓狂的场景脚本收到 SIGTERM 后执行了清理函数但清理函数里有耗时操作比如备份一个大目录或者同步到远程存储结果 kill 一次等了十几秒没反应kill 两次还是没反应最后只能 kill -9。破解方式是给 trap 处理函数加上超时机制cleanup() { # 后台执行真正的清理 real_cleanup local cleanup_pid$! # 最多等 10 秒 for i in $(seq 1 10); do kill -0 $cleanup_pid 2/dev/null || break sleep 1 done # 超时强制结束清理进程 kill $cleanup_pid 2/dev/null }这个模式在容器环境里尤其重要。容器编排系统默认等待 SIGTERM 后进程退出的时间窗口通常很短默认就几秒超时就会被强制 SIGKILL你的“优雅退出”如果表演太久结局等于没表演。6.3 信号叠加导致的递归陷阱trap 处理函数还没执行完第二个信号又来了会怎样默认情况下Shell 执行 trap 命令期间不会再次触发同一个信号这本来是保护机制。但如果你在 trap 里调用的外部命令阻塞了比如清理函数里 curl 一个远程接口用户以为脚本卡死了再按一次 CtrlC根本没用。这不能一概而论说是 bug因为设计初衷就是防止递归。可实际操作中这个“信号屏蔽期”会让用户觉得脚本好像无响应。我的处理办法是在清理函数开头就打印一行日志“正在清理请勿重复中断”至少给个反馈而不是闷不做声卡在那里。6.4 排查工具全家桶遇到信号问题不知道怎么下手时这几个命令是保命符# 列出当前进程所有信号的处理方式 kill -l # 查看某 PID 当前信号捕获状态Linux cat /proc/pid/status | grep -i sig cat /proc/pid/status | grep SigCgt # 输出脚本里当前的 trap 设置 trap -p # 调试模式运行脚本跟踪信号处理 bash -x ./script.sh/proc/pid/status里的SigCgt字段是一个位掩码告诉你哪些信号被捕获了。我调试一个脚本时发现它怎么都不响应 SIGTERM用这个一看果然是位掩码里 SIGTERM 没被设置说明 trap 注册代码根本没执行到。6.5 systemd 和容器环境下的坑现在很少脚本是纯“手工”跑的大多在 systemd 服务或者在容器里。容器给进程发 SIGTERM 后只等几秒就升级成 SIGKILL所以你的 trap 清理动作必须在超时窗口内完成。一个常见误区是脚本里明明写了 trap结果容器停止时日志显示根本没走 trap 分支。排查后发现因为脚本用了exec替换了主进程或者主进程不是脚本本身而是脚本启动的 Java/Python 进程——容器发的是 SIGTERM 给 PID 1而你的 trap 在另一个 PID 上自然不会触发。解决方案是让脚本作为 PID 1 运行或者确保信号能透传给实际工作进程。具体到 Shell 里别用exec替换掉 Shell 进程而是让 Shell 一直作为父进程存在这样 trap 才有容身之地。7. 经验总结与扩展方向写到这里把整个思路串一遍。信号处理这件事初看只是trap一行语法实际深入下去牵扯到进程模型、信号语义、调度设计三个层面。我个人的编码习惯是每个脚本开头先写 cleanup 函数和信号注册再写主逻辑。顺序反了大概率在某个没覆盖到的退出路径上留下一地鸡毛。值得扩展的方向也不少。比如配合set -e和set -u使用时trap ERR能实现出错即清理这是 CI 脚本里很实用的一套组合拳。再比如和日志系统联动在清理函数里把退出原因、处理了哪些任务、耗了多长时间推送到监控平台每次上线退场都自动留痕。我一个做了大半年的后台消费脚本最自豪的就是它半年多没因为异常退出导致过数据重复消费或丢失靠的就是 trap 里那几行低调的清理逻辑。最后提醒一句trap 解决的是 SIGINT/SIGTERM 这类“可拦截信号”千万别指望它能挡住kill -9。避免 SIGKILL 的最有效手段不是写更复杂的 trap而是让 SIGTERM 这条优雅路径足够快、足够可靠——毕竟系统选择给你体面退出的机会时你要接得住。
阅读完成 · 觉得有帮助?
咨询建站