YAOTU INSIGHTS

Docker stop 深度解析:信号机制、超时参数与优雅停止实践

Docker stop 深度解析:信号机制、超时参数与优雅停止实践
1. 一个看似简单的命令背后藏着不少门道docker stop大概是所有用 Docker 的人最早学会的几个命令之一。容器跑起来了想让它停下来敲一行docker stop 容器名等几秒钟容器就退出了。整个过程顺滑得像关个灯以至于绝大多数人从来没想过这个命令内部到底发生了什么——直到某天你发现容器停得特别慢、或者停完之后数据丢了、又或者进程明明还在但 Docker 说它已经停了。我自己第一次对docker stop产生怀疑是在一个跑批处理任务的场景里。容器里跑着一个消费队列的进程每次docker stop之后日志里总会出现几条处理到一半的记录下游收到的是不完整的数据。当时我以为是业务代码的 bug查了大半天才发现问题出在停止流程上进程收到停止信号后没有正确处理而我又没给够它收尾的时间。从那以后我才认真去翻docker stop的机制发现这个命令远比表面看起来复杂。这篇内容就是把我这些年踩过的坑、翻过的源码、做过的实验整理出来。它适合已经会用 Docker 基本命令、但想搞清楚停止这件事到底怎么运作的开发者也适合那些正在被容器优雅退出、数据一致性、停止超时等问题困扰的同学。我会从信号机制讲起一路拆到超时参数、退出码、不同 PID 1 进程的差异再给出可以直接抄的配置方案。看完之后你至少能做到知道容器为什么停得慢、知道怎么让它停得干净、知道什么时候该用stop什么时候该用kill。2. docker stop 到底做了什么从信号到进程的完整链路2.1 它不是关机而是一次有礼貌的协商很多人对docker stop的第一印象是把容器关掉这个理解不算错但太粗糙。准确地说docker stop做的事情是向容器内 PID 为 1 的进程发送一个SIGTERM信号然后等待一段时间默认 10 秒如果进程还没退出再补一个SIGKILL强制杀掉。这里的关键词是协商。SIGTERM是一个可以被捕获、被忽略、被处理的信号它相当于 Docker 在跟容器里的主进程说麻烦你收拾一下准备退出。主进程收到之后理论上应该停止接收新任务、把手头的活干完、关闭连接、刷盘、释放资源然后自己调用exit退出。这个过程就是我们常说的优雅停止graceful shutdown。而SIGKILL是完全不同性质的东西。它无法被捕获、无法被忽略、无法被处理进程收到它的瞬间就被内核直接干掉没有任何收尾的机会。所以docker stop的完整逻辑其实是先礼后兵先给一个体面的退出机会超时了再动粗。理解这一点非常重要因为它直接决定了你容器的停止行为。如果你的应用根本没处理SIGTERM那它收到信号后什么都不会做只能干等 10 秒被SIGKILL杀掉——这就是很多人觉得docker stop 怎么这么慢的根本原因。2.2 信号是发给谁的PID 1 的特殊地位Docker 容器本质上是一个被隔离的进程命名空间容器内的第一个进程PID 1就是容器的主进程。docker stop发出的SIGTERM只会发给 PID 1不会自动传播给它的子进程。这一点是无数坑的源头。假设你的容器启动命令是sh -c python app.py那么 PID 1 是shpython app.py是它的子进程。SIGTERM发给sh而sh作为 shell默认并不会把信号转发给子进程于是python进程根本不知道要退出最后只能等 10 秒后被SIGKILL连同整个进程组一起杀掉。这就解释了一个非常常见的现象明明应用里写了优雅退出的逻辑但docker stop还是要等满 10 秒。因为信号压根没送到应用手上。提示判断容器主进程是谁用docker exec 容器 ps -ef看一眼 PID 1 是什么。如果是sh、bash、tini之外的 shell就要警惕信号转发问题。2.3 超时之后发生了什么SIGKILL 的连锁反应当等待时间耗尽Docker 会向 PID 1 发送SIGKILL。由于SIGKILL作用于整个进程组容器内所有进程都会被强制终止。这时候不会再有刷盘、不会有连接关闭、不会有事务提交正在写的文件可能处于半截状态正在处理的消息可能丢失。从内核角度看进程被SIGKILL杀掉后它持有的文件描述符会被关闭未fsync的数据可能还在页缓存里是否落盘取决于文件系统和内核的刷盘策略。对于数据库类应用这意味着可能的数据损坏。所以对于有状态服务让应用自己优雅退出永远比依赖SIGKILL兜底要安全。下面这张表把docker stop涉及的两个信号对比清楚方便你随时对照维度SIGTERMSIGKILL能否被捕获能不能能否被忽略能不能进程有无收尾机会有无默认发送时机stop 开始时超时后典型用途优雅退出强制终止数据安全风险低应用自行处理高可能丢数据3. 超时参数 -t那个被大多数人忽略的开关3.1 默认 10 秒是怎么来的什么时候该改docker stop有一个-t或--time参数用来指定等待SIGTERM生效的秒数默认值是 10。这个默认值对大多数无状态 Web 服务够用但对一些重应用就未必了。比如一个正在处理大文件的转码任务或者一个正在执行长事务的数据库10 秒可能连收尾的边都摸不到。这时候你有两个选择要么调大-t要么让应用更快地响应SIGTERM。我的经验是优先优化应用的响应速度因为超时时间设得太长会拖慢整个部署流水线——每次滚动更新、每次缩容都要多等这么久。但如果应用确实需要时间比如必须等一个批次处理完那就老老实实调大。命令写法docker stop -t 60 my-container在编排工具里对应的是不同的字段。以 Compose 为例是stop_grace_periodservices: worker: image: my-worker:latest stop_grace_period: 60s注意 Compose 里这个字段支持带单位的时间格式1m30s而docker stop -t只接受秒数两者不要混用。3.2 超时设太短的代价一个真实的数据丢失案例我见过一个团队把-t设成了 2 秒理由是加快发布速度。结果他们的消息消费服务在高峰期每次发布都会丢几条消息——因为 2 秒内消费进程来不及把正在处理的消息标记为完成就被SIGKILL干掉了消息在队列里重新投递时又因为幂等没做好导致重复处理。这个问题的隐蔽之处在于低峰期完全正常只有高峰期才偶发排查了很久才定位到停止超时上。所以-t的取值不能拍脑袋要结合应用的最长单次任务耗时来定。一个简单的估算方法是观察应用处理单个任务 P99 耗时把超时设成它的 1.5 到 2 倍再留一点余量给连接关闭和资源释放。3.3 超时设太长的副作用部署卡顿与资源占用反过来超时设太长也有问题。假设你把-t设成 300 秒某次应用因为死锁卡住了docker stop就会实打实地等 5 分钟才强杀。在滚动更新场景下这意味着旧容器迟迟不退新容器又起不来整个发布流程被拖住。更麻烦的是在集群环境里节点缩容时如果每个容器都要等这么久调度器可能判定节点不健康触发额外的迁移动作形成连锁反应。所以超时时间是一个需要权衡的参数够应用收尾但不要给卡死的进程太多赖着不走的机会。我的习惯是给不同类型的服务设不同的值用一个表格管理起来避免每次部署都靠记忆服务类型建议 stop 超时理由无状态 HTTP 服务10-15s只需关闭监听、处理完在途请求消息消费者30-60s需等当前批次处理完并提交位点定时任务容器按最长任务定避免任务被腰斩数据库/缓存60s 以上需要时间刷盘和关闭连接一次性脚本5s通常无需复杂收尾4. 为什么你的容器停不下来PID 1 与信号处理的那些坑4.1 shell 包装导致的信号黑洞前面提到过sh -c的问题这里展开讲透。当你写CMD python app.py时Docker 会以 exec 形式启动python直接成为 PID 1信号能直达。但如果你写CMD [sh, -c, python app.py]PID 1 就变成了sh。sh收到SIGTERM后的行为取决于具体实现。有些 shell 在非交互模式下会等待子进程结束再退出有些则直接退出但把子进程留成孤儿。无论哪种你的python进程都没收到信号优雅退出逻辑形同虚设。解决办法有三个层次首选 exec 形式CMD [python, app.py]让应用直接当 PID 1。用 exec 替换 shellCMD [sh, -c, exec python app.py]exec会让python替换掉sh成为 PID 1。引入 init 进程用tini或dumb-init作为 PID 1它们专门负责转发信号和回收僵尸进程。第三种方案在需要处理子进程回收的场景下尤其有用。比如你的应用会 fork 子进程而应用本身又不处理SIGCHLD那僵尸进程会越积越多。tini用起来很简单Dockerfile 里加两行RUN apk add --no-cache tini ENTRYPOINT [/sbin/tini, --] CMD [python, app.py]--后面的命令会成为tini的子进程tini负责把信号转发下去并在子进程退出后清理。实测下来这一招能解决大部分停得慢的问题。4.2 应用层没注册 SIGTERM 处理器会怎样即使信号正确送达了 PID 1如果应用本身没注册SIGTERM的处理逻辑默认行为是直接终止进程。对 Python 来说默认的SIGTERM处理就是退出不会执行finally块也不会触发atexit钩子严格说atexit在信号终止时不一定执行。对 Java 来说JVM 会执行 shutdown hook但前提是你注册了。所以应用有没有优雅退出逻辑和信号有没有送达是两个独立的问题排查时要分开看。一个快速验证方法是进容器手动发信号观察应用日志有没有打出正在关闭之类的字样。docker exec my-container kill -TERM 1如果日志里出现了收尾信息说明信号处理没问题那docker stop慢的原因就在别处比如超时设太短、或者有子进程没退出。如果毫无反应那就是应用层没处理得改代码。4.3 多进程容器信号只到主进程的尴尬有些容器里跑着多个进程比如一个 supervisor 管着 nginx 和 php-fpm。docker stop只给 PID 1supervisor发信号supervisor 需要负责把信号转发给被管理的进程。如果 supervisor 配置不当子进程可能收不到信号最后被SIGKILL一锅端。这类场景的排查思路是先确认 PID 1 是谁再看它有没有转发信号的职责最后看被管理的进程有没有正确处理。三层任何一层断了优雅停止就失效。我的建议是尽量一个容器一个进程把多进程的复杂度交给编排层而不是塞进单个容器里。5. 停止之后退出码、状态与清理的连锁影响5.1 退出码 137 和 143 分别意味着什么容器停止后docker ps -a会显示退出码。很多人看到 137 就慌其实要分情况看。143 128 15表示进程是被SIGTERM终止的。这通常是正常优雅停止的结果如果应用没自己exit 0的话。137 128 9表示进程是被SIGKILL终止的。这意味着超时了应用没能在规定时间内退出。看到 137 不一定是故障但一定值得关注要么是应用没处理信号要么是超时设太短要么是应用卡住了。我在排查时会把 137 当作一个信号去翻那段时间的应用日志看看它卡在哪一步。还有一种情况是应用自己exit了非零码比如exit 1那退出码就是 1跟信号无关。所以看退出码要结合日志一起判断不能只看数字。5.2 stop 与 rm、kill 的边界什么时候该用哪个这三个命令经常被混用其实职责很清晰docker stop优雅停止先SIGTERM后SIGKILL容器进入 exited 状态但仍然存在。docker kill直接发SIGKILL默认也可以指定其他信号比如docker kill -s SIGTERM就等价于不带超时的 stop。容器同样保留。docker rm删除容器前提是容器已停止除非加-f强制。所以停止和删除是两回事。停止只是让进程退出容器的文件系统层、配置、日志都还在可以docker start重新拉起。删除才是真正清理。日常运维里我习惯先stop确认状态再rm清理避免误删还在排查的容器。docker kill -s SIGTERM这个用法值得记住它给了你只发信号、不等超时的能力适合在脚本里做精细控制。比如你想自己控制等待逻辑就可以先kill -s SIGTERM然后轮询容器状态超时了再补一刀。5.3 停止对数据卷和网络的影响容器停止不会删除数据卷卷里的数据是持久的。但要注意如果应用在停止时没把内存里的数据刷到卷上那部分数据就丢了。这就是为什么有状态服务必须处理SIGTERM。网络方面容器停止后它会从所属网络断开IP 被释放。如果其他容器依赖它的固定 IP 或主机名停止期间会连不上。用服务名做发现的话编排层会自动把停止的实例从端点列表里摘掉影响相对可控。但如果你的架构里有硬编码 IP 的地方停止就会引发连锁故障这点在设计阶段就要避免。6. 把停止做扎实可落地的配置与验证方法6.1 一份可以直接抄的优雅停止检查清单在把服务上线前我会按下面这份清单过一遍基本能覆盖 90% 的停止相关问题确认 PID 1 是谁docker exec 容器 ps -ef确保是应用本身或 init 进程不是裸 shell。确认信号能送达手动kill -TERM 1看应用日志有没有收尾输出。确认应用有处理器代码里注册SIGTERM执行关闭连接、刷盘、提交位点等动作。确认超时合理按 P99 任务耗时估算设成 1.5 到 2 倍。确认退出码符合预期正常停止后看docker ps -a的退出码137 要能解释原因。确认子进程被回收停止后ps检查有没有残留进程。这份清单看起来啰嗦但每一条都对应过真实的故障。尤其是第 2 条很多团队从来没做过直到线上出问题才回头补。6.2 用脚本模拟一次完整的停止流程光看文档不够动手验证才踏实。下面这个脚本可以帮你观察停止全过程的时间线和退出码#!/bin/bash CONTAINER$1 START$(date %s) docker stop -t 30 $CONTAINER END$(date %s) CODE$(docker inspect -f {{.State.ExitCode}} $CONTAINER) echo 停止耗时: $((END - START)) 秒 echo 退出码: $CODE跑几次不同配置的容器对比耗时和退出码你会对超时参数的影响有直观感受。比如一个没处理信号的容器耗时基本等于超时值退出码 137一个正确处理信号的容器耗时通常远小于超时值退出码 0 或 143。6.3 在编排环境里stop 语义如何被放大单机docker stop的影响是局部的但在编排环境里停止语义会被放大成滚动更新、缩容、故障迁移等操作的基础。以滚动更新为例编排层会先给旧实例发停止信号等它退出后再起新实例。如果旧实例停得慢整个更新就被拖慢如果旧实例没优雅退出正在处理的请求就会失败。所以前面讲的每一条——信号送达、应用处理、超时合理——在编排环境里都是可用性的一部分。我见过因为stop_grace_period配得太短导致每次发布都有少量请求 502 的情况最后就是靠调大这个值加上应用层完善信号处理解决的。还有一点容易被忽略编排层通常会在发停止信号的同时把实例从负载均衡里摘除但摘除和信号之间有个时间差。如果应用在收到信号后立刻拒绝所有新连接而负载均衡还没完全摘除就会有一小段请求打到正在关闭的实例上。稳妥的做法是收到信号后先排空一小段时间比如 5 秒再真正关闭监听给负载均衡留出收敛时间。这个技巧在网关类服务里尤其重要。7. 几个我踩过之后才记住的细节最后分享几个零散但很实用的经验都是文档里不太会写、但实际会遇到的。第一个是关于docker stop的并发行为。如果你同时 stop 多个容器Docker 是并行处理的不会串行等待。所以在批量停止时总耗时约等于最慢那个容器的停止时间而不是所有超时之和。这个特性在写清理脚本时可以利用。第二个是SIGTERM和SIGINT的区别。有些应用只处理了SIGINTCtrlC没处理SIGTERM结果本地跑没问题进容器就停不干净。排查时记得两个信号都测一下。第三个是日志的重要性。优雅停止出问题时应用日志里往往有线索——比如等待连接关闭超时队列还有 N 条未处理。把这些日志打出来比盲猜快得多。我现在的习惯是在关闭逻辑里加足够的日志出问题时一眼就能看出卡在哪一步。第四个是别迷信默认值。10 秒对很多场景够用但对你的场景未必。花十分钟测一下自己服务的真实停止耗时比出事后花十小时排查划算得多。这个投入产出比怎么算都值。