容器 21:停止、重启和日志分别由谁负责
时间窗口需要观测
通常运行时先发终止信号,留宽限期等待程序自己处理、回收子进程并结束;超时后可强制终止。应用若安装 handler 却不结束在途请求,或 shell 没有转发信号,即使运行时遵守流程也可能直到超时。比较宽限期要保存发送时刻、程序收到的事件、最后一条业务响应、实际退出原因,单看 shell 的 137 不能推出是超时强制杀还是内存 OOM。
日志链也有不同持有者:程序写 stdout/stderr FD、shim/daemon 依配置收集、日志驱动再存储或转发。程序未 flush 缓冲、进程强制终止或采集器重启都可能造成末尾日志缺口;不能以运行时提示“容器 restarted”证明请求、日志和写盘数据已经恢复。daemon 的重启行为依运行时、shim、配置及版本而异,必须在专用实验 daemon 里验证,禁止重启共享宿主服务。
正例让相同 probe-app 记录一次有 handler 的 TERM 并在宽限期内退出,对照运行时 stop 的实际时刻;边界例令自编受控进程超过短宽限期,保存剩余进程、强制终止与日志缺失/完整证据;只在自建 VM 运行,并给出宿主控制台恢复路径。入口列出需要关联的容器 ID、PID 与日志;探针包本身不模拟 daemon 重启。本云端只运行过普通进程的 TERM 与 PID 1 安装 handler 的例子,daemon/容器停启实验均 NOT_RUN。
本篇另备一个真正有界的信号探针源码,配专用 Dockerfile与校验清单。普通 Linux 进程演示的原始命令和输出可以核对 handler 行为,但它没有在容器中运行,不能给 Docker stop 记 LAB_VERIFIED;daemon 重启没有可取代它的静态演示。
谁把停止请求变成信号
Docker CLI 将停止操作发给所选 daemon;它不直接对客户端机器的同名 PID 发送信号。daemon 从容器配置和请求选项选择 stop signal 与等待时长,再交给运行时管理的 task。Moby v28.4.0 的 daemon/stop.go 中,ContainerStop 先查容器,已停止则返回一个已处于目标状态的分支;containerStop 读取 StopSignal() 和 StopTimeout(),允许本次请求覆盖,先调用发送信号的路径,按设定时长等待容器停止,超时后走 daemon.Kill。源码显示一个负的 timeout 表示不做超时强制终止,但实验绝不使用无界等待。发生错误时源码还有具体清理与异常分支,不能把“调用了 stop 接口”当作“某个固定 PID 一定收到 TERM”。该源码是实现阅读定点,云端没有安装这个 daemon。
容器内部的 PID 1 是信号的目的进程视角,不是另一个 daemon。若镜像使用 shell 形式的启动指令,shell 可能成为 PID 1,是否转发信号、是否等待子进程都要看它的代码;采用 JSON exec 形式直接运行 Python 探针,能够让探针接收送到主进程的信号,但并不自动为其添加退出 handler。03 已解释 PID namespace 里 PID 1 的特殊信号处理,不能把一次 TERM 没退出说成所有容器的普遍属性。探针代码显式安装 SIGTERM handler 并在收到后输出事件;生产应用还须停止接收新请求、处理在途请求、刷新状态并给出成功与失败响应,这些动作不是 Docker 根据宽限期替它自动完成的。
在自建实验 VM,比较两条路径:signal_probe.py --mode graceful 收到 TERM 后设置退出条件,受控事件循环结束并打印 stopped;--mode delayed 在 handler 内等待三秒,如果本轮 docker stop --time 1 按选定 runtime 的配置及时触发强制终止,则 stopped 可能没有机会打印。实际时刻受调度、shim、日志采集和信号发送影响,不应写死精确毫秒。专用镜像的 ENTRYPOINT 使用 JSON exec 形式,避免无意插入外层 shell;等待过程设置八秒内部截止和 VM 命令超时以保证测试受限。普通 Linux 上已经观测到 graceful 收到 TERM 后退出码 0,delayed 在 timeout -s TERM -k 1s 1s 下收到 TERM 后 wrapper 退出码 137 且没有 stopped 事件;这组原始日志仅说明本机普通进程对两种处理路径的响应,不证明 Docker 对 --time 1 的具体行为。
单看退出码 137 也不足以判定“被 daemon 超时 kill”。进程按信号终止时,命令行工具经常用 128 加信号编号表示状态;同样的数字可能出现在 OOM、外部管理者强制 kill、用户手动删除、命令 wrapper 等不同路径。应一起记录 docker inspect 的 State.OOMKilled 和 State.ExitCode、容器事件、daemon 日志与 cgroup 的 memory.events,再比较本轮 stop 的起止时间。若原始事件未保存,就写“不足以区分”,不能根据某个日志最后缺一行倒推出 SIGKILL 的施加者。docker stop 返回 0 也只是操作完成的用户视角,既不是所有在途 HTTP 都成功,也不能证明日志已持久化。
日志链为何会在末尾留空
应用写标准输出和标准错误的文件描述符,运行时/管理层负责把这两条流接给选定日志配置;日志驱动再负责将记录写入本地或者交给目标。哪个驱动、能否通过 docker logs 读取、日志是否阻塞应用输出,受 daemon 配置和驱动能力影响。Moby 同一 tag 的 daemon/logger/copier.go 给出从源 reader 向 logger 复制数据的实现入口;这能说明组件之间有一条数据流,不能证明在 daemon 被强制重启期间所有缓冲内容都不会丢。应用的 print 是否 flush、管道写是否完成、shim 是否仍持有 FD、日志驱动是否已确认,都需要分层记录。21 的信号探针每条 JSON 事件使用 flush=True,降低 Python 用户态缓冲带来的歧义,但即使打印动作已发生,也不代表远端日志后端持久确认。
正常退出时建议并列保存信号事件、应用末尾 JSON、docker logs --timestamps 与请求结果,不把它们视为同一时钟;强制终止轮再次保存这些输入。如果 term_received 已在日志中而 stopped 不在,可能是进程根本没执行到那行、强制终止发生在其前、或者日志流最后没被完整读取。要缩小原因,应对照进程层在其本地可观察到的事件、宿主 PID 是否结束、日志驱动设置和采集时间。单靠 docker logs 缺少末尾记录不能知道这个缺口究竟落在进程还是在日志链;更不能把一个未经验证的日志驱动名称写作所有 Docker 版本的持久性保证。
daemon 重启与应用重启是两条轴
管理进程的 PID、shim 的 PID、容器主进程的 PID 不必相同。daemon 负责 API、配置、事件和部分管理职责;containerd/shim 负责实际 task 的生命周期和 stdio 等实现路径,应用负责业务逻辑。18 已解释 content 与 task 分别有独立身份。重启 daemon 后它是否重新连接到旧 task、旧 task 是否曾存活、日志是否有缺口,要取决于当次 daemon/runtime 的版本和配置;只看 docker ps 出现“running”一行,不足以确认它是同一个宿主 PID,也不足以说明停机间业务请求没有失败。容器级 --restart 策略和 daemon 级的存活配置不是同一个开关;前者针对进程退出后的策略,后者涉及管理进程失效后工作负载如何继续管理。不能根据“这个镜像设置了 restart: always”就推出 daemon 故障期间没有连接中断。
最小对照是只重启专用 VM 的实验 daemon,绝不在共享宿主执行。重启前记 daemon PID、containerd/shim PID、探针宿主 PID、容器完整 ID、镜像 digest 与 /identity;同时从专用 VM 的 loopback 高端口连续发有上限的请求,记录各请求的 UTC 时间、响应码、错误和超时。重启后重新读取这些身份与事件:若容器 PID 保持而 daemon PID 更新,说明本轮观测到管理进程换代且应用进程一度可定位,不足以反推出整个时窗没有请求失败;若容器 PID 也更新,先检查是否有运行时恢复/策略重建事件,再比对数据目录和最早成功的请求。没有宿主控制台回滚手段,不执行 daemon 故障注入;实验 VM 也需要与其他任务隔离,不能将一个只给自己用的 Docker context 指向公司的共享节点。
重启命令本身需要明确作用对象。例如向一个系统服务管理器发送 restart 请求与 docker restart <容器> 是不同操作;前者可能替换 dockerd 进程,后者是针对容器主进程的 stop/start 组合。停止 Docker daemon 但保留旧 task 的能力会因版本与配置改变;以“shim 是独立进程”推断 daemon 一停所有容器都一定不断流,同样缺乏证据。实验入口要求先打印版本与有效配置,并在独占 VM 中由操作者用本地服务管理方式重启测试 daemon,脚本不含对默认 docker.service 的命令;恢复失败时先用宿主控制台回滚。若只能使用共享服务,daemon 重启轮直接标 NOT_RUN,不借应用进程的普通 kill 实验伪装成 daemon 测试。
一次停止实验要保留什么时间关系
在 stop 之前请求 /health 和 /ready,记录时间与具体探针参数,证实此次实验应用原先可响应。开始 stop 的客户端时间只是用户侧时间,真正的信号送达可由探针 term_received 输出印证;事件之间还有进程调度和 stdio 管道传送,不能假定它们在同一个毫秒。若业务探针正在处理在途请求,终止处理策略需要应用自己实现;通用 probe_app.py 只有简单 HTTP handler,没有专门的请求排空与告警事件,不适合作为“优雅排空成功”的证明。可以用它验证短时 HTTP 请求,但要对“已经停止接收新连接”和“原有连接完全完成”分别建证据点。Kubernetes Pod 的 termination grace period 和端点摘除又引入 28 中的调度/网络条件,不能把 Docker CLI 的 stop 行为直接外推成 Pod 更新时零失败。
异常轮用自己构建的 signal_probe.py 让 handler 明确收到 TERM 后仍等待三秒,指定一秒宽限期,检查 docker stop 的退出码、容器 State.ExitCode 与 OOMKilled。脚本内部还有八秒截止,因此就算未收到 TERM,也不会无限期运行;但如果容器根本创建失败,先记录 daemon 报错而不是悄悄改脚本参数直到得到预想的状态。把正常轮与异常轮使用的完整镜像 ID、CMD 和 stop 参数并列,确认唯一改变的是探针模式与宽限期。若两个配置不同,还要说明哪些差异会影响信号路径;不要从两个不同版本的基础镜像得出“容器的默认行为改变了”的结论。
对 --time 1 的含义也应谨慎:这是一段进程可以自行退出的等待窗口,不是保证进程恰好在一秒整接收到 SIGKILL 的硬实时控制。Moby v28.4.0 的源码先尝试 stop 信号并等待,路径出错时还会调整等待,最后的 Kill 和状态更新本身也占时间。实验记录应按实际命令时刻、handler 时刻和退出时刻报告时间线,不把预期的一秒写入“实际耗时”栏。容器因 --stop-signal 或镜像 STOPSIGNAL 使用非 TERM 时,探针只装了 TERM handler,需先读 inspect 的停止信号再决定本轮输入;不能把没观察到 term_received 一律归罪 PID 1。特别是手动 docker kill 发送信号与宽限期 stop 不同,不能把它们作为等价故障注入。
恢复的单位是业务结果
业务恢复至少包含能够对新请求给出预期响应、故障窗口内失败请求数量可数、状态目录中的数据符合预期、后台任务是否重新收敛;容器进程重新有 PID 只是必要线索之一。probe-app 的 /state 使用小于等于 4 KiB 的写入与读取,可用于模拟一份有定义的状态值;若状态写入容器可写层,容器重建与 daemon 重启的结果会受存储生命周期影响。16 已拆开可写层与 volume,测试前必须写明本轮选择哪种挂载及是否删除了旧容器。docker restart 同一容器与删除后新建不是同一个数据测试,混在一起会把数据缺失错归于 daemon 恢复。不要把一次 curl /ready=200 当作 /state 在落盘设备上已经耐久的证明;探针没有 fsync 或事务语义。
同样,日志的“完整”需要定义窗口。为自建信号探针预先定义应有 started、term_received、stopped 三种事件,发生强制终止时后者可能不存在,不能把“未产生的事件”误列为日志丢失;先确定进程是否走到那行。需要检查 stdout 与 stderr 是否分开、timestamp 是否由驱动添加、采集端是否发生截断,至少保留原始日志和日志驱动配置,不能只粘贴美化过的业务日志。一旦开启外部日志驱动,应记录收集系统的可访问性和保留策略;这个共用 VM 没有日志后端时,只能验证本地可读流,不能报外部存储已经验收。对于没被采集的失败路径,保持 NOT_RUN 或“证据不足”,而不是自动记成通过。
有边界的诊断顺序
先区分请求发给哪个 daemon:用 context、socket 和容器完整 ID 定位实例。再检查 stop 是否成功提交、信号目标与其参数、容器主进程的变化;这一层失败不需要先讨论日志是否丢。若应用收到了信号而未按宽限期退出,调查 handler 和等待的子进程;若退出成功但请求仍失败,调查发布端口、服务重新监听和客户端连接。若响应恢复但日志缺口存在,转到 stdio 和日志驱动路径,不回头拿缺日志否认进程曾处理请求。每一步只用目前可证明的上一步作为前提,把“CLI 成功”“进程退出”“请求无损”“日志完整”四个判断分开;遇到拿不到的 PID、事件或时间戳,就在报告中保留对应未知项。
OCI runtime 的边界比 Docker daemon 的控制面窄。OCI 规范约束运行时对已知 bundle 和容器实例的创建、启动、状态与删除等动作,不定义用户浏览 docker logs 的格式,也不保证一次 HTTP 请求返回何种业务状态。若实验中停止后遗留一个 containerd 元数据对象,不应把它直接等同于还活着的业务进程;反之,Docker 对象可能暂存上一次状态而真实应用早已退出,需要核查 task 和宿主 PID。17 对照 OCI 生命周期,18 再识别 shim 与 task,这篇增加的是“停止请求和日志证据穿过这些层后究竟何时可靠”的具体问题。跨组件边界不对齐时,先找双方各自使用的完整 ID 与 event 时间,不用一张进程树给所有 API 盖章。
示例的普通进程记录还有一个必须交代的差别:Linux 上的 timeout -s TERM -k 1s 1s 是 coreutils 的 wrapper,发送与强制信号的时间由本地用户程序控制;容器内的 docker stop --time 1 则要经过 CLI、daemon、task 和实际 PID。普通进程的延迟 handler 确实打印了收到 TERM,但 wrapper 的 137 并不能被照搬成某个容器的 State.ExitCode。当日志说明显示 wrapper 自身被 kill,还要把 wrapper 与 Python 子进程分清;真正的容器实验必须另外采 docker inspect、进程退出和清理。普通记录的价值在于证明探针能区分收到信号与完成退出,而非确认 Docker 的信号传递实现。
测试 daemon 重启需要比容器 stop 更严谨的安全边界。独占 VM 应用控制台与服务回退入口必须在注入之前就可用;控制面测试的 daemon 不能为其他任务提供运行时服务,不碰共享宿主默认网络或日志采集器。请求发生器采用固定次数、固定间隔和超时,记录所有请求的完整状态,不用一个“看上去没有报错”的网页代表无损。若期间无法使用 CLI 查询对象,可以从 VM 控制台读最小进程和端口证据,待 daemon 恢复后再补事件;观测不到的时间窗明确留空,不借恢复后一个 ps 结果回填。清理不仅要删自建容器,还要确认 daemon 用户服务恢复至原来的启停状态和实验端口已经释放。
于是实验的正常案例不是“日志恰好有三行”,而是可同时解释 stop 输入、被发信号的主进程、实际退出与业务停止响应;失败案例也不是单个红色退出码,而是超时、异常结束及证据中未发生的步骤能否被逐一分开。若应用自行在宽限期内退出,最终 State.ExitCode 应结合实际值解释;若发生超时,记录谁首先观察到进程未退出、何时走到强制结束路径,以及进程有没有打印结束事件。不能补全的字段留 NOT_RUN 或未知,与真正执行过的普通进程案例明确分列。
还要注意日志时间戳由谁加:应用 JSON 内的 UTC 时间取自容器进程,docker logs --timestamps 所显示的时间是日志路径的记录时间,客户端开始 stop 的 date 属于发命令的主机。远程 context 时,这三个时钟可能偏移;只按打印顺序排序并不能还原毫秒级的信号送达顺序。报告应并列保留应用自身的单调时钟(仅能比较同一进程内部)、外侧时间和同步方式;无法同步时,推断范围限于同一主体自身的事件先后,不计算跨机器精确延迟。daemon 故障期间事件流若断开,还需要标记采样空窗,不能只用两端快照算出所谓无中断持续时间。
在尾部日志的边界实验里,print(flush=True) 只要求 Python 把用户态缓冲交给文件描述符,不能要求应用所在卷或日志后端做持久同步。若业务写入先于 term_received,并不保证重启后 /state 一定有相同值;数据可能仍在容器可写层,或者只是进程收到网络请求尚未提交文件写入。为了不把问题扩展成任意存储系统耐久性测试,本篇只对自建状态目录给出读回检查,并原样记录挂载类型与写入响应。更强的持久化语义要由 16、30 的卷与存储章节分别界定。
| 现象 | 最先检查 | 限定条件 |
|---|---|---|
| SIGTERM 发送了却没退出 | 目标 PID、handler、子进程 | 不等于内核没送到 |
| 容器重启后有 PID | 请求响应、数据与日志窗口 | PID 存在不等于业务恢复 |
| 末尾日志不见 | stdio FD、flush、驱动配置 | 不等于程序未执行 |
练习
- 预测有 handler 和没有 handler 的 PID 1 对宽限期内退出的不同影响;在专用 VM 保存时间戳、信号、退出状态与后续请求。
- 预测在专用 daemon 重启期间探针是否继续回应,按版本与 shim 记录进程树、日志缺口和请求失败率;禁止对共享 daemon 注入故障。
上一篇:20:rootless;下一篇:22:netns 与 veth,继续核对请求到达应用前所经过的网络对象。
系列总目录:从进程隔离到运行时与编排。


