1. 为什么一个 stop 命令值得单独研究
很多人第一次接触容器编排的时候,都会觉得docker stop是最没技术含量的命令之一——不就是停个容器吗,跟关电脑点关机按钮有什么区别。我一开始也这么想,直到有一次在预发布环境做批量重启,一条docker stop发下去,业务侧监控直接报警,连接池里堆了几百个半死不活的会话,数据库那边还挂着一批没提交的事务。那次之后我才认真去翻这个命令的实现逻辑,才发现它背后牵扯的东西比想象中多得多。
docker stop的本质是向容器内主进程发送终止信号,并给进程一段优雅退出的时间窗口,超时之后强制杀掉。它解决的核心问题是:如何在保证数据一致性和连接正常释放的前提下,让一个运行中的服务体面地停下来,而不是像拔电源一样粗暴中断。这个命令适合所有用容器跑服务的人——不管你是刚学会docker run的新手,还是已经在生产环境维护几百个容器的老手,只要你的服务有状态、有连接、有未落盘的数据,就绕不开对停止行为的精细控制。
我见过太多团队把停止逻辑当成运维的边角料,结果每次发布都靠"重启大法"兜底,出了问题再回滚。实际上,把docker stop的机制吃透,能省掉相当一部分发布期间的抖动和脏数据问题。下面我按自己踩坑的顺序,把这个命令从设计思路到实操细节完整拆一遍。
2. 信号机制与优雅停止的整体设计
2.1 停止流程到底发生了什么
先把这个命令的执行链路说清楚。当你敲下docker stop 容器名,客户端把请求发给守护进程,守护进程找到对应的容器,然后做两件事:第一,向容器内 PID 为 1 的进程发送一个终止信号,默认是SIGTERM;第二,启动一个倒计时,默认 10 秒。如果在这 10 秒内主进程自己退出了,容器状态转为 stopped,流程结束;如果超时还没退出,守护进程补一发SIGKILL,这个信号进程无法捕获也无法忽略,直接由内核终结,容器随即停止。
这里有个容易被忽略的点:信号是发给 PID 1 的,不是发给容器里所有进程。PID 1 在容器里承担了类似传统系统中 init 进程的角色,它负不负责把信号转发给子进程,完全取决于这个进程自己怎么写的。这就解释了一个经典现象——有些容器docker stop之后总是要等满 10 秒才停,日志里也看不到优雅退出的痕迹,原因往往就是 PID 1 是个不处理信号的程序,SIGTERM被它默默吞掉了,只能等SIGKILL兜底。
2.2 为什么默认是 SIGTERM 加 10 秒
选择SIGTERM而不是上来就SIGKILL,逻辑很直白:SIGTERM是一个"请求你退出"的信号,进程可以捕获它,然后做清理工作——关闭监听端口、把缓冲区数据刷到磁盘、通知下游服务自己即将下线、等待正在处理的请求完成。这套动作就是所谓的优雅停止。如果直接SIGKILL,进程没有任何反应机会,正在写的文件可能只写了一半,正在处理的事务可能停在中间状态,对于有状态服务来说这是灾难性的。
10 秒这个默认值是个折中。太短了,很多服务来不及完成清理,比如一个正在做大批量数据导入的任务,10 秒根本不够把当前批次处理完;太长了,批量停止或者滚动发布的时候整体耗时会被拉得很长,影响发布效率。所以这个值在设计上就是可配置的,用-t或者--time参数指定,单位是秒。我的经验是,无状态的服务 5 到 10 秒足够,有数据库写入或者消息消费的服务建议给到 30 秒,做批量计算的任务型容器可以给到 60 秒甚至更长,具体看单次任务的最长处理时间。
2.3 停止和重启、杀死的边界
这里顺带把几个容易混淆的命令区分开,因为实际排查问题时经常有人用错。docker stop是"先礼后兵",SIGTERM加超时SIGKILL;docker kill是"直接动武",默认发SIGKILL,当然它也能用-s指定其他信号,但语义上它就是不等你的;docker restart则是 stop 加 start 的组合,同样走优雅停止流程。理解这个边界很重要——如果你在脚本里图省事用docker kill代替docker stop,等于主动放弃了所有清理逻辑,短期看不出问题,长期一定会在数据一致性上还债。
3. 超时参数与信号选择的实操要点
3.1 超时时间怎么定才合理
-t参数的取值不是拍脑袋定的,我一般按这个思路算:先看服务处理单个请求的 P99 耗时,再看停止时需要等待的在途请求最大数量,两者相乘得到一个粗略的下界,再留一倍余量。举个例子,某接口 P99 是 800 毫秒,停止瞬间最多可能有 20 个在途请求,那理论下界是 16 秒,实际给 30 秒比较稳妥。对于消费消息的容器,要看一次批量拉取的最大条数和单条处理时间,比如一次拉 100 条、每条 200 毫秒,那就是 20 秒,同样留余量给 40 秒。
反过来,超时给太长也有代价。滚动发布时如果每个容器都要等满超时,发布窗口会被拉得很长,而且如果服务本身有 bug 导致卡在清理阶段,长超时会让问题容器占着资源不释放。所以我的做法是:正常路径下服务应该能在超时的一半时间内退出,如果监控发现大量容器都是卡到超时才停,说明清理逻辑有问题,得去查而不是简单调大超时。
3.2 自定义停止信号的使用场景
--stop-signal参数允许你换掉默认的SIGTERM。什么时候需要换?常见的是有些老服务或者特定框架约定用SIGINT来触发优雅退出,比如某些交互式程序把SIGINT当作"用户请求中断"来处理清理。还有一种情况是用SIGQUIT让进程打印堆栈后退出,方便排查停止时的状态。设置方式是在运行容器时指定,比如docker run --stop-signal=SIGINT ...,之后对这个容器的 stop 操作就会发SIGINT。
需要提醒的是,这个信号必须和容器内程序实际处理的信号对上,否则等于白设。我见过有人设了SIGINT但程序只处理SIGTERM,结果还是等满超时。验证方法很简单,进容器手动kill -SIGINT 1看程序反应,能正常退出就说明配置有效。
3.3 停止单个与批量停止的差异
单个容器停止没什么好说的,批量停止才是坑多的地方。docker stop支持一次传多个容器名,守护进程会并发处理这些停止请求,但并发度不受你控制。如果一次停几十个容器,可能瞬间对下游依赖造成冲击——比如一批服务同时断开数据库连接,连接池那边会看到连接数骤降又骤升。我的做法是批量停止时自己写循环加间隔,或者用编排工具的分批策略,而不是一条命令甩过去。
另外批量停止时要注意依赖顺序。有上下游关系的服务,应该先停上游再停下游,否则上游还在发请求、下游已经没了,会产生一批失败调用。这个顺序在单机 docker 环境下得靠脚本控制,在编排环境里一般有更细的粒度可以配。
4. 完整实操过程与现场记录
4.1 准备一个能观察停止行为的测试容器
光看文档不够,得动手验证。我一般用一个带信号处理逻辑的简单服务来做实验,这里用一段 Python 脚本模拟,它能捕获SIGTERM、打印清理日志、故意睡几秒再退出,方便观察整个时间线。
import signal import time import sys def handle_term(signum, frame): print("收到终止信号,开始清理", flush=True) time.sleep(3) print("清理完成,准备退出", flush=True) sys.exit(0) signal.signal(signal.SIGTERM, handle_term) print("服务已启动,等待信号", flush=True) while True: time.sleep(1)把它打成镜像,Dockerfile 里注意用 exec 形式启动,保证脚本是 PID 1:
FROM python:3.11-slim COPY app.py /app.py CMD ["python", "-u", "/app.py"]构建并运行:docker build -t stop-demo .,然后docker run -d --name demo stop-demo。用docker logs -f demo盯着日志,另开一个终端执行docker stop demo,你能清楚看到"收到终止信号"到"清理完成"之间隔了 3 秒,容器在这 3 秒内处于 stopping 状态,之后才变成 exited。这个实验做完,你对优雅停止的时间线就有直观感受了。
4.2 对比超时不足时的强制终止
把上面的脚本清理时间改成 15 秒,然后用默认 10 秒超时去停,观察会发生什么。执行docker stop demo后,日志打印"收到终止信号,开始清理",然后 10 秒一到,容器直接变成 exited,日志里没有"清理完成"那一行。这就是SIGKILL兜底的效果——清理被腰斩了。再换成docker stop -t 20 demo,这次就能看到完整的清理日志。这个对比实验能让你彻底记住超时参数的意义,比看十遍文档都管用。
4.3 用 inspect 观察停止前后的状态变化
停止过程中容器的状态字段会变化,用docker inspect --format '{{.State.Status}}' demo可以实时观察。正常运行时是 running,执行 stop 的瞬间变成 stopping,退出后变成 exited。同时State.ExitCode和State.FinishedAt也会更新,退出码能反映是正常退出还是被信号杀掉——被SIGKILL终结的退出码通常是 137,这个数字在排查"为什么容器总是异常退出"时非常关键。我习惯在停止脚本里把退出码打出来,一旦看到 137 就知道是超时强杀,而不是程序自己正常退出的。
4.4 停止耗时统计的小脚本
批量操作时我习惯统计每个容器的停止耗时,方便发现异常。用 shell 简单实现:
for c in $(docker ps -q); do name=$(docker inspect --format '{{.Name}}' $c) start=$(date +%s) docker stop $c > /dev/null end=$(date +%s) echo "$name 停止耗时 $((end - start)) 秒" done跑一遍就能看出哪些容器是秒退、哪些是卡满超时。卡满超时的那些就是需要重点排查的对象,通常是信号没被处理或者清理逻辑阻塞了。
5. 常见问题与排查技巧实录
5.1 容器总是等满超时才停
这是最高频的问题。排查顺序我一般这样走:先看容器内 PID 1 是什么进程,用docker exec 容器名 ps -ef看第一行。如果 PID 1 是 shell 脚本(比如sh -c "xxx"这种启动方式),那信号很可能被 shell 吞了,因为 shell 默认不转发信号给子进程。解决办法是把启动命令改成 exec 形式,让真正的业务进程成为 PID 1,或者在脚本里显式做信号转发。
如果 PID 1 就是业务进程,那看它有没有注册信号处理。有些程序默认对SIGTERM的处理就是直接退出,不做清理,这种反而停得快;停得慢的往往是注册了处理函数但里面逻辑阻塞了,比如在等一个永远不返回的网络请求。这时候得看程序日志,确认清理阶段卡在哪一步。
5.2 停止后数据丢失或文件损坏
这个问题的根因基本都是没走优雅停止,或者优雅停止的时间不够。先确认停止时用的命令和超时参数,再看程序退出码是不是 137。如果是 137,说明清理没做完就被杀了,需要调大超时或者优化清理逻辑。还有一种隐蔽情况是程序捕获了SIGTERM但清理逻辑写得不完整,比如只关了数据库连接没刷缓冲区,这种得从代码层面修,光调参数没用。
5.3 批量停止导致下游雪崩
前面提过,批量停止的并发冲击是真实存在的。表现是停止过程中下游服务报大量连接错误或者超时。应对办法是控制停止节奏,分批停、批间加间隔,同时让服务在收到SIGTERM后先把自己从注册中心摘除,等一小段时间让流量自然流走,再开始真正的清理。这个"先摘流量再清理"的顺序很关键,顺序反了就会出现"还在处理请求但连接已经关了"的尴尬局面。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 总是等满超时 | PID 1 是 shell 或未处理信号 | 查看 PID 1 进程、检查信号注册 | 改 exec 启动、补信号处理 |
| 退出码 137 | 超时被 SIGKILL 强杀 | 看停止耗时是否等于超时值 | 调大超时、优化清理逻辑 |
| 停止后数据异常 | 清理未完成或逻辑不全 | 检查退出码、审查清理代码 | 补全清理逻辑、延长超时 |
| 下游报连接错误 | 批量停止并发冲击 | 观察下游错误时间点 | 分批停止、先摘流量 |
| 停止卡住不动 | 清理阶段阻塞 | 看程序日志卡在哪一步 | 修复阻塞点、设合理超时 |
5.5 几个踩过坑才记住的细节
第一个细节:docker stop对已经停止的容器执行不会报错,会直接返回容器名,脚本里不用额外判断状态。第二个细节:停止中的容器用docker rm删不掉,得等它彻底退出,所以脚本里如果有 stop 紧接着 rm 的逻辑,要处理这个时间差。第三个细节:docker stop的-t参数设成 0 表示不等待,直接发SIGKILL,效果等同于docker kill,这个在紧急情况下有用,但别当成常规操作。
我个人在实际操作中的体会是,docker stop这个命令的价值不在于它本身多复杂,而在于它逼着你去想清楚服务的生命周期该怎么收尾。一个能把停止逻辑写好的服务,往往在启动、健康检查、故障恢复这些环节也不会太差,因为背后是同一套对状态的严谨态度。后续如果要做更精细的控制,可以往编排层的停止策略上延伸,比如设置终止宽限期、配置 preStop 钩子,那些都是在docker stop这套基础机制上叠出来的能力,底层原理还是这里讲的信号加超时那一套。