Shell脚本信号处理实战:trap捕获SIGINT/SIGTERM实现优雅退出 写这个系列到第100章我确实犹豫过最后一篇到底要写什么。语法技巧写过调试心法写过就连脚本性能优化也写了不止一次。最后我决定把“信号处理”放在这个位置——因为它几乎串起了Shell脚本运维里最真实的悲欢。你写过服务脚本就一定遇过进程“杀不死”或者“死得很难看”的场景你部署过批处理任务就一定体会过重跑一次要修一堆数据的痛苦。这次我把SIGINT和SIGTERM的捕获原理、trap用法、以及如何写出一个真正“体面退场”的脚本完整复盘一遍全程都是我在生产环境里摔过跤、也填过坑之后留下的东西。1. 为什么Shell脚本需要信号处理1.1 从一次“关不干净”的事故说起大概几年前我维护过一个数据同步任务。任务每天凌晨2点启动处理大约500万行数据全程大概要跑40分钟。按理说这种脚本很简单无非是读文件、转换、写结果。但它有个致命问题当运维执行kill pid停止脚本时进程确实消失了但下次启动会报“临时目录已存在”“数据表状态异常”必须有人手动清理一遍才能继续跑。最要命的一次脚本在处理到一半时被强制终止结果临时文件和正式数据混在一起当晚的数据全部报废第二天早上我连早饭都没吃就去修数据。后来我把脚本加上信号处理才明白问题出在哪脚本默认对SIGTERM是“直接死”那它负责清理的临时文件、负责释放的锁、负责整理的半成品状态全都没人管。信号处理解决的不是“让脚本不死”而是“让脚本知道自己可能会死并且在死之前把该做的事情做完”。这个认知是我后来写所有长任务脚本的起点。1.2 信号是什么进程之间的“轻量级消息”信号Signal在Linux里的本质是内核给进程发送的一种异步通知。你可以把它想象成手机来电进程正在做自己的事突然收到一个“中断请求”它有权决定是接听、忽略还是直接挂断。Shell脚本里最常见的信号就是你在终端按CtrlC时产生的中断信号专业称呼是SIGINT还有系统关机、服务停止时常常发来的终止信号SIGTERM。很多人有个误区觉得kill命令就是把进程“杀掉”。其实kill只是“发送信号”的通用工具。kill pid不带参数默认发送的就是SIGTERM也就是“请你退出”的请求真正的“一刀砍死”是kill -9 pid它发送SIGKILL这个信号不允许进程做任何善后工作。理解到这一层你就会明白日常停止进程应该优先发SIGTERM给它一个“交代后事”的机会只有确认进程卡死了才考虑SIGKILL。2. 认识SIGINT和SIGTERM两个你必须掌握的信号2.1 常见信号速查表信号不是只有这两个。Shell编程中你会遇到的主要信号我整理成一张表方便你直接参考。信号名编号默认行为典型触发方式SIGHUP1终止进程终端断开连接SIGINT2终止进程CtrlCSIGQUIT3终止并产生core文件Ctrl\SIGKILL9强制终止不可捕获、不可忽略kill -9SIGTERM15终止进程kill 默认信号SIGUSR110自定义行为kill -USR1SIGUSR212自定义行为kill -USR2SIGCHLD17子进程退出时通知父进程子进程状态改变Shell里提供了一个基本不占资源的命令trap -l可以列出当前系统支持的所有信号及编号。我建议你至少在脚本头部的注释里写清楚你捕获的是哪几个信号方便别人扫一眼就知道这个脚本的退出行为是什么。2.2 SIGINT与SIGTERM谁更“急”谁更“礼貌”虽然SIGINT和SIGTERM的默认行为都是终止进程但它们来自完全不同的场景含义差异很大。SIGINT是用户主动中断前台任务的信号按CtrlC就发出来了。它的特点是“急”意味着用户在等待、在催促你的脚本响应它越快越好。SIGTERM则更“礼貌”它通常由kill命令或其他进程发来翻译过来是“你能不能准备好再退出”系统关机流程、服务管理器停止服务时都会发SIGTERM然后等待进程退出等待超时才发SIGKILL。两者的处理策略也因此不同。收到SIGINT可能是人为干预我一般会尽快中止当前业务、只做最小清理收到SIGTERM如果脚本正在执行一个不可中断的重要批次我会先把当前批次处理完或者至少把进度安全落盘再开始清理。实际项目中我通常在trap里同时捕获这两个信号但在清理函数内部用分支判断它们来自哪里决定是“马上走”还是“温和走”。2.3 什么也不做会发生什么很多脚本的作者没有写任何信号处理代码。这意味着脚本对SIGINT和SIGTERM采取系统默认行为——直接终止。直接终止听起来很干脆但代价你得想清楚正在写入的配置文件可能只写了一半起始的锁文件还留在原地后台子进程可能变成孤儿继续占用资源临时目录里会堆积半成品数据。这些不会写在进程退出码里但它们会变成下一次运行时的“地雷”。我之前遇到的那个数据同步问题就是最典型的例子。默认终止永远只解决“当前进程没了”从来不解决“进程留下的烂摊子”。这也是我坚持在每一个有一定规模的长脚本里加入信号处理的根本原因。3. trap命令全解析捕获信号的正确姿势3.1 trap的三种调用方式Shell里捕获和处理信号靠的是trap命令。它有三种主要调用方式对应三种完全不同的意图。# 方式一捕获信号并执行指定的命令 trap do_cleanup INT TERM # 方式二忽略信号 trap INT # 方式三恢复信号的默认行为 trap - INT TERM方式一用在绝大多数优雅退出的场景中。do_cleanup可以是一个函数名也可以是一段简单命令比如echo 收到中断信号; exit 1。方式二意味着“用户按CtrlC也不会理”这种写法用在那些“千万别中途打断”的脚本里但用完后一定要记得trap -恢复默认否则脚本的后半段会变得很难终止。方式三则是把信号的处理权交还给系统。我见过一种不太推荐的写法trap exit 0 INT TERM它确实能让进程响应信号并退出但没有清理动作、没有日志记录本质上是“快速逃跑”而不是“优雅退出”。优雅退出的核心是在退出之前你自己掌握了退出路径上的所有动作。3.2 单引号还是双引号一个最容易踩的坑trap命令的引号选择是新手最容易翻车的地方。它的规则其实不复杂trap的第一个参数是“字符串形式的Shell命令”Shell在解析trap语句时就会决定变量是否展开。count0 # 信号发生时count才被读取显示的是当时的值 trap echo 当前计数: $count INT # trap定义时count立刻被展开之后信号发生时值是固定的 trap echo \当前计数: $count\ INT如果你在trap里用了函数名比如trap cleanup INT单引号双引号区别不大因为函数名不涉及变量展开。但如果你直接写了trap echo $var INT而$var是全局变量且之后会变化那信号触发时打印出来的就是trap定义那一刻的值不是你期望的最新值。我自己的习惯是需要动态读取变量一律用单引号确实需要在定义时固定值才用双引号。这个习惯帮我少踩了很多莫名其妙的坑。3.3 trap的恢复与卸载trap不是只能设置还能取消。脚本执行到不同阶段可能需要不同的信号策略。比如说脚本刚启动时捕获INT做清理但到了某个危险操作阶段你担心清理函数本身被打断就可以先忽略信号操作完成后再恢复捕获。# 危险操作期间忽略中断 trap INT TERM do_risky_operation # 恢复捕获 trap cleanup INT TERM另外要特别注意trap设置是会被覆盖的。同一个信号后设置的trap会替换前面的trap。如果一个函数内部设置了trap函数结束后它仍然生效。这可能导致“我以为脚本对SIGINT是默认行为其实早就被某个函数改成忽略”的情况。排查时可以用trap -p打印当前所有已设置的信号处理器这会让你看到脚本在哪个时间点、对哪些信号动了手脚。4. 实战案例从基础捕获到完整优雅退出4.1 第一个脚本捕获SIGINT做最简清理先从一个最基础的例子开始。假设你有一个常驻循环脚本希望在用户按CtrlC时打印一条日志并退出。#!/bin/bash cleanup() { echo [$(date %F %T)] 收到中断信号执行清理后退出 # 这里可以放删除临时文件、记录状态等代码 exit 0 } trap cleanup INT echo 脚本启动按 CtrlC 试试中断 while true; do echo 工作循环运行中... sleep 2 done这个脚本的关键点在于cleanup函数里必须带exit 0。这是因为trap的命令在信号到来时会打断当前正在执行的命令执行完trap里的命令后如果没有exit脚本会回到刚才被打断的地方继续往下执行。很多人写trap不写exit会发现“清理完了脚本又继续跑了”正是这个原因。我建议无论清理工作多简单都给trap的处理逻辑一个唯一的出口要么exit要么回到主循环并靠状态变量自然跳出。绝不依赖“trap执行完回到原处”这个隐式行为因为它容易让你忽略后续代码的副作用。4.2 第二个脚本用状态变量配合SIGTERM实现服务优雅停止直接exit在简单脚本里够用但真实服务往往需要“等当前任务完成再退”。比如脚本正在把一批数据写入数据库强迫它立刻退出可能写了一半记录。这时更好的模式是收到信号把状态变量置为停止主循环检查到状态变化后自然结束。#!/bin/bash # 0表示继续运行1表示收到停止信号 stop_flag0 handle_stop() { echo $(date %F %T) 收到停止信号处理完当前任务后退出 stop_flag1 } trap handle_stop TERM INT echo 服务开始运行 while [ $stop_flag -eq 0 ]; do # 模拟一段完整的业务处理 echo $(date %F %T) 开始处理批次数据... sleep 3 echo $(date %F %T) 批次数据处理完成 done echo $(date %F %T) 主循环已退出开始最终收尾 # 这里放收尾逻辑关闭连接、释放资源等 echo 服务已优雅停止 exit 0用状态变量的好处有两点。第一脚本不会因为信号而中断当前操作而是在“一个完整的任务单元结束后”停下来保证数据一致性。第二代码的可读性更强主循环的退出条件一目了然不会被trap里的exit搞得程序流程跳跃。有一个细节容易忽略sleep命令会被信号打断并立即返回。在trap里只修改stop_flag不退出的话sleep被信号打断后返回接着while循环检查stop_flag然后退出循环。这个流程是正常的。但如果你的脚本里有更复杂的阻塞操作比如等待网络请求完成信号打断就不一定那么干净需要结合后面的子进程处理来设计。4.3 第三个脚本完整版优雅退出临时文件、子进程、锁文件现在把前面所有要点合成一个更接近生产环境的完整脚本。这个脚本模拟的是一段带锁、会生成临时文件、还会启动子进程的数据处理任务。#!/bin/bash TEMP_DIR/tmp/data_pipeline_$$ LOCK_FILE/tmp/mydata_pipeline.lock LOG_FILE/tmp/mydata_pipeline.log child_pid stopping0 log() { echo $(date %F %T) $* $LOG_FILE } cleanup() { # 防止清理过程中重复触发信号 if [ $stopping -eq 1 ]; then exit 0 fi stopping1 # 清理时要恢复默认信号处理防止第二次信号打断清理流程 trap - TERM INT log 开始清理临时目录 $TEMP_DIR rm -rf $TEMP_DIR # 如果有正在运行的子进程先优雅终止子进程再等它结束 if [ -n $child_pid ] kill -0 $child_pid 2/dev/null; then log 停止子进程 $child_pid kill -TERM $child_pid wait $child_pid fi # 释放锁文件 rm -f $LOCK_FILE log 清理完成锁文件已释放 exit 0 } trap cleanup TERM INT # 防止脚本重复运行 if [ -e $LOCK_FILE ]; then echo 已有实例在运行PID$(cat $LOCK_FILE) exit 1 fi echo $$ $LOCK_FILE mkdir -p $TEMP_DIR log 数据任务启动临时目录 $TEMP_DIR 已创建 # 模拟数据批处理主循环 batch_id0 while [ $stopping -eq 0 ]; do batch_id$((batch_id 1)) log 开始处理第 $batch_id 批数据 # 模拟一个后台子任务比如调用另一个脚本或工具 sleep 5 child_pid$! # 等待子任务完成同时如果收到信号也能被中断 wait $child_pid child_pid if [ $stopping -eq 1 ]; then log 第 $batch_id 批处理期间收到停止请求当前批次未计入正式结果 break fi log 第 $batch_id 批数据处理完成 done cleanup这个脚本里有几个生产环境必须考虑的细节。第一个是防重入stopping1放在清理函数的开头判断。因为信号是可以连续到达的如果不做防重入第二次信号会再次执行cleanup导致锁文件被删两次、临时文件目录被删两次甚至出现递归执行清理的混乱。第二个是清理前恢复默认信号trap - TERM INT的意思是在清理过程中如果再有新信号到来就按系统默认行为直接终止。这个是“两害相权取其轻”因为清理流程本身就比较关键宁可被直接终止也不能再嵌套执行一遍清理。第三个是自杀式进程管理子进程通常不会自动跟随父进程消失。父进程必须在退出前主动kill -TERM子进程并用wait等待它结束否则子进程会变成孤儿进程继续占用资源。关于$$在临时目录里的作用Linux上同时运行两个脚本实例时目录名如果不带PID就会出现冲突。$$表示当前脚本的PID用它做目录名基本能保证唯一性。这也是我在写带临时文件的长任务时固定的习惯。5. 优雅退出的典型应用场景5.1 服务脚本与systemdSIGTERM是停止服务的标准请求现在大多数服务都由systemd管理。当执行systemctl stop my-service时systemd做的事情就是向主进程发送SIGTERM并等待一段时间默认90秒让它退出如果超时才发SIGKILL。换句话说你写的服务脚本如果不处理SIGTERMsystemd给你的“善后时间”就完全浪费了。我维护过的一个常驻脚本最初就是靠默认行为“死掉”的。结果每次服务停止后它的Unix socket文件还在/tmp下躺着下次启动时bind直接失败因为端口和socket都被认为还占用着。后来我在trap的清理函数里加了一行rm -f /tmp/my_service.sock问题当场解决。还有一点建议服务停止时记得记录时间、原因和当前状态这些日志能帮你在排障时快速判断服务是“正常停止”还是“被强制终止”。systemd的TimeoutStopSec参数也可以根据脚本的清理耗时来调整但前提是你确认脚本确实能在兜底时间内清理完。5.2 容器环境你的脚本只有10秒钟容器化部署之后docker stop也遵循同样的逻辑先发SIGTERM到容器的主进程PID 1默认再等10秒超时后发SIGKILL。很多人第一次接触容器时觉得在容器里跑个脚本“不需要管信号”结果CI构建或者服务发布时容器总是卡到10秒超时再被强制杀掉。问题的根源是容器里PID 1的进程必须“主动转发和处理信号”如果PID 1是你的Shell脚本那Shell脚本的trap逻辑就决定了容器是否能在几秒内平滑退出。我在容器里的常用做法是脚本在启动时安装一个最小的信号处理器至少把SIGTERM捕获标记停止状态并退出循环同时子进程全部用wait等待。这么做之后docker stop基本都能在1秒内完成。千万不要在容器脚本里盲目执行trap TERM忽略信号那等于告诉Docker“你永远别想优雅地让我停下来”。5.3 长任务批处理断点续跑的关键批处理任务和常驻服务不同它追求的不是“随叫随停”而是“跑了多少下次接着跑”。我有时需要处理几十万行的数据文件整个流程要跑很久。如果完全不处理信号运维一旦为了维护机器发来SIGTERM脚本要么直接死透要么留下半截数据。后来我在trap里增加了一个动作把当前处理到的行号、批次数、临时目录状态写到一个进度文件。下次启动时先检查进度文件从上次停止的位置继续处理。信号处理就不再只是“怎么死”而是变成了“怎么保存现场”。save_progress() { echo $batch_id $PROGRESS_FILE echo $(date %F %T) 已保存进度: $batch_id $LOG_FILE }注意保存进度这个动作要尽可能轻量不要在清理函数里做复杂计算。我在生产环境里就遇到过进度保存逻辑本身太慢结果清理还没做完第二个SIGTERM又来了整个状态变得更乱。所以清理函数里的代码能短则短尽量只做“删临时文件、记录简单日志、退出”这几件事。6. 容易踩的坑与排查技巧实录6.1 trap不生效的几个常见原因先说一个我在社区里反复看到的问题“我明明写了trap cleanup INT为什么按CtrlC没反应”排查顺序是这样的。第一确认信号是否被更早的trap覆盖。比如脚本前面某个位置执行了trap INT后面再设置trap cleanup INT是能覆盖的但如果顺序反了后面的trap INT就会把前面的清理逻辑覆盖掉。用trap -p看一眼就知道。第二确认trap定义在哪个作用域。如果脚本用sh -c或者子Shell执行了包含trap的代码trap不会传递给父Shell的进程。第三确认你发送的信号和目标一致。有些发行版有别名或者包装kill -INT 1234和kill -2 1234效果相同但如果你拼写错误为kill -ITN命令可能直接报错。还有一个隐蔽的问题当你执行nohup script.sh 脚本可能对终端信号不敏感因为nohup会把SIGHUP忽略掉。但SIGINT和SIGTERM不会因此失效。如果测试时发现“nohup下CtrlC没反应”这其实是正常的因为CtrlC对应的是前台进程组后台进程收不到。测试SIGINT应该用kill -INT pid不要继续按CtrlC。6.2 子进程的信号传递问题Shell脚本里的子进程默认不会继承父进程的trap也不会因为父进程收到信号就自动退出。很多人在父进程的trap里写了exit 0结果父进程走了子进程还在后台继续跑等需要清理的时候才发现有一堆“僵尸作业”在消耗CPU。推荐的做法就是前文那个完整脚本里的写法父进程保存child_pid在清理函数里kill -TERM $child_pid然后wait $child_pid。这里有个细节wait在等待子进程时如果当前Shell又收到信号wait会被立即中断返回大于128的状态码。所以我在清理函数中会先恢复默认信号再执行kill和wait避免信号递归。如果你在脚本里启动了一系列子进程还可以把多个PID存到数组里统一在清理时遍历发送信号。但要注意子进程如果还有它自己的子进程一层kill -TERM可能不够深。这种情况我只建议你确保直接子进程退干净孙进程能通过它自己的父进程收到信号如果做不到那就得在启动时把子进程放到独立的进程组如setsid清理时再成组发送信号这属于更高级的玩法日常用到的机会不多但知道原理能少走很多弯路。6.3 测试与验证方法写完信号处理代码不能只靠“看着没问题”就上线。我常用一套流程来验证脚本的退出行为。先在终端前台运行脚本然后从另一个终端执行kill -TERM pid观察脚本日志里是否按预期执行了清理。再测试一次kill -INT pid确认SIGINT路径同样有效。接着连续快速发送两次信号比如kill -TERM pid kill -TERM pid验证stopping防重入逻辑是否可靠。最后检查系统状态临时目录还在不在锁文件是否删除子进程是否全部退出。这些都可以用一行命令验证比如ps -ef | grep 脚本关键字 | grep -v grep看残留进程。还有一个容易被忽略的验证点脚本正常退出时没有收到信号是否会执行清理注意trap只在信号到达时触发正常执行完主循环后脚本并不会自动调用trap里的清理函数。所以如果你的脚本“正常完成”也需要清理那就要在主流程结束前显式调用清理函数或者在退出路径上统一收敛到一个函数。我在第三套完整脚本里主循环结束后的最后一行就是调用cleanup这样才能保证“无论正常结束还是信号结束都走同一套清理动作”。6.4 常见问题速查表现象可能原因解决思路CtrlC 按了没反应trap被别的代码覆盖或正在子Shell中用trap -p检查确认trap在目标Shell内定义信号触发了但清理没执行清理函数内部报错导致退出码异常在清理函数前加set -x或先logger清理中途又被信号打断未先恢复默认信号处理清理开头执行trap - TERM INT父进程退出后子进程还在跑父进程没有向子进程转发信号保存PID清理时kill后wait锁文件无法删除清理代码没执行到或权限不足检查清理日志确认锁文件路径和权限脚本能退出但状态异常主循环和trap各退各的未统一清理逻辑将正常退出和信号退出都收敛到同一个清理函数容器停止时总是超时被杀PID 1未捕获或未转发SIGTERM确认容器主进程是Shell脚本且安装了trap写到这里系列的第100章也差不多该收了。我最后想认真说一句体会信号处理这门手艺表面上是技术本质上是责任。你在终端里敲下的那句kill对一个脚本来说就是一个“退出请求”脚本能不能体面地接受这个请求其实取决于你提前为它写好了多少“遗嘱”。让脚本学会优雅退出从来不是为了浪漫而是为了让生产环境里的数据不出错、留下的资源不堆积、下一次启动不背上上一次的烂账。如果你以前写脚本只关心“怎么跑起来”那从这篇之后我真心建议你多花一点时间想想“怎么让它停下来”。等到某一天运维发来一条停止指令你的脚本在几秒内清理完临时文件、释放锁、等子进程安稳退出然后留下一行干净的日志从容结束那感觉比任何花哨技巧都让人安心。