冻结一组进程为什么难:SIGSTOP 的局限
第 11 篇讲过信号的投递与处置模型。信号里有一个特殊存在:SIGSTOP 无法被捕获、屏蔽或忽略,收到即停,SIGCONT 恢复——这是运维手里最接近「暂停键」的东西。但它的作用域模型是按进程组织而非按容器组织:SIGSTOP 打给单个进程,或按负进程组号打给整个进程组。一旦目标是一棵任意的进程树,问题就来了:服务主进程与它 spawn 的工作进程可能分属不同进程组,逐个停停停,停到一半时某个进程恰好又 fork 出新进程、新进程组,逃逸出你的打击范围。要在任意时刻原子地停住一颗进程树,纯信号模型做不到。
第二层局限来自协作语义。SIGSTOP 虽不可捕获,但它进的是标准的停止状态,进程恢复后看到的信号语义、waitpid 的返回行为、终端作业控制的交互都会被牵动;被停进程的子线程组、以及「辅助进程」——比如与主服务通过共享内存协作的旁路监控进程——并不天然属于同一个进程组,停了主进程不停辅助进程,共享状态就可能出现半更新的中间态。对容器而言更是如此:容器内 PID 一号进程与其 worker、以及挂在同一命名空间里的调试工具进程,组织关系五花八门,靠进程组枚举去暂停,工程上脆弱且不可审计。
于是需求被重新表述:我们要的不是「给一组进程发信号」,而是「把一棵 cgroup 层级树下的所有任务,作为一个整体,原子地置入不可执行状态,之后原样恢复」。这正好落在 cgroup 的管辖范围内——cgroup 本来就是进程树的分组机制,冻结语义理应由它提供。cgroup v1 时代有独立的 freezer 子系统,v2 把它收敛为层级根上的一个开关:cgroup.freeze。
在内核冻结语义出现之前,工程界用各种姿势绕这个坑:容器运行时逐个给进程发停止信号并轮询确认、脚本层面拦截子进程的产生、或者在应用层实现统一的暂停钩子。各有各的漏:轮询有窗口、跟踪器侵入性强、应用层钩子要求所有代码都配合。这些补丁的存在反过来证明:暂停语义的正确位置是内核的分组机制,而不是任何用户态的编排脚本。v1 的 freezer 子系统第一次把正确答案做出来,v2 的 cgroup.freeze 把它简化成一个文件,这条演进本身就是「机制长在正确的抽象层」的好教材。
cgroup.freeze:内核级进程树暂停
用法朴素到极致:向某个 cgroup 目录下的 cgroup.freeze 文件写 1,整棵子树——包括此刻恰好在 fork 的任务——被内核原子冻结;写 0 解冻。冻结态的任务处于专门的冻结状态,内核调度器不再给它们时间片,普通信号照常入队投递(信号处理记账不丢),但任务不会醒来执行处理函数,直到解冻。唯一的例外是致命信号:冻结中的任务收到 SIGKILL 会被破冻终止,这保证「冻结不是金钟罩」,运维永远保留处决通道。冻结状态本身可以从同目录的 events 文件里读到对应字段,v1 时代的 freezer.state 及其 FROZEN、FREEZING、THAWED 三态是它的前身。
与 SIGSTOP 的语义差异值得逐条列清楚。第一,作用域:freeze 按 cgroup 层级树操作,树内新 fork 的进程自动继承冻结,不存在逃逸;SIGSTOP 按进程与进程组操作,有竞态窗口。第二,原子性:freeze 由内核在层级写操作里一次性完成,要么全冻要么没冻,不存在「冻了一半」的中间态;SIGSTOP 的批量投递做不到这点。第三,透明性:冻结不经过信号语义,应用无从感知也无法干预(除非主动读 events 或自查进程状态),SIGSTOP 则会进入作业控制的可观测链路。第四,层级性:冻结父 cgroup 会连带冻结所有子 cgroup,解冻也整树恢复,与容器的组织模型天然对齐。
工程视角下还有一个关键澄清:冻结不清理、不回收、不节省内存。被冻结进程的地址空间原样驻留,页缓存脏页也不因此回写。冻结只是暂停执行,省的是 CPU,不是内存。想要「暂停且释放内存」,需要冻结之后配合换出(把匿名页 swap 出去),或者干脆迁移与回收。把 freeze 当成省内存手段是常见误解,容器的内存占用统计也不会因为冻结而下降。
冻结语义与资源统计的互动再看一眼。冻结进程的 CPU 时间不再增长,cgroup 的 CPU 统计曲线出现平台期,监控面板上一眼可辨;内存统计不变,OOM 风险照旧评估——这意味着冻结不是内存超卖的挡箭牌,超售的宿主机不能指望「冻着就不占」。另外冻结是带层级的:父 cgroup 冻结时子 cgroup 无法单独解冻,编排系统要做局部暂停就按子树设计分组,把「可独立冻结的单元」作为部署拓扑的设计输入。这些细节决定了冻结在编排系统里的正确用法:它是部署拓扑的属性,不只是运维的临时动作。
冻结、杀死与排水:三种运维动作的边界
运维手里的三个动作各有边界。冻结适合「原地静止」:对正在写入的数据库做文件系统快照之前,先冻结写入方,让 IO 静默,快照的一致性窗口才可靠(注意冻结不等于刷盘,配合 sync 或数据库自身的 checkpoint 把脏页落盘,快照内容才完整);批处理作业白天让路给在线服务、夜间恢复,冻结比杀掉重跑便宜得多。杀死是终局手段,SIGKILL 无可阻挡,冻结加杀死的组合用于「先停流量再收尸」,避免杀一半时进程还在写状态。排水则是迁移动作:把负载从本节点搬走,目标是节点腾空,而不是进程停止。
检查点恢复是冻结最有想象力的应用。CRIU 这类工具的转储前提是进程树处于静止且状态完整——冻结正是标准前置步骤:冻结、转储地址空间与内核状态、解冻或直接销毁、在目标机重建并恢复、再解冻。容器的热迁移、长时间离线后恢复现场、把开发机的调试现场原样搬到测试机,都走这条链路。工程上这条链路最脆弱的环节往往不是转储本身,而是恢复环境与原环境的差异:内核版本、文件描述符指向的外部资源(socket 对端、打开的日志文件)、以及依赖的外部服务状态,任何一项对不上,恢复出来的进程都在第一次系统调用就露馅。
节点的滚动排水是另一个高频场景。升级内核、更换硬件、调整宿主机时都要把容器迁走:标准动作是停止接收新请求、等待存量请求排空、对仍存活的工作负载执行迁移或重启、最后腾空下线。freeze 在这里的角色是「排空过程中的临时静默」:让进程在保留现场的前提下暂停接活,配合健康检查摘除流量,比直接 SIGTERM 更可控——尤其当应用对优雅关闭的处理不可靠时,冻结提供了由基础设施兜底的静止手段。
活体迁移的工程细节值得再铺一层。转储阶段除了进程地址空间,还要捕捉打开文件偏移、套接字状态、信号掩码、计时器这些内核态上下文——检查点工具的功夫大半花在这些边角;网络连接的迁移要么依赖连接修复(对端无感重建),要么接受连接中断由应用层重连。恢复阶段先以冻结态重建进程树、再回填内存与寄存器、最后统一解冻,顺序错了进程会在半成品状态醒来。整条链路的复杂度解释了为什么热迁移至今仍是大厂专属工程:冻结只是第一块积木,后面的每一块都不便宜。
工程注意事项与坑
第一坑:D 状态任务会让冻结卡住。不可中断睡眠的任务(典型是卡在 NFS、FUSE 或某些驱动 IO 上的进程)无法立即进入冻结态,内核会等待其离开 D 状态,表现为对 cgroup.freeze 写入后长时间不返回、events 里冻结状态迟迟不置位。历史上 FUSE 请求挂起导致冻结死锁是内核修过多年的经典问题。运维侧的纪律是:冻结卡住先查进程状态里有没有 D,处理 IO 阻塞源,而不是反复重试冻结。
第二坑:冻结与监控的联动。被冻结进程的各处超时计时器都停止推进,解冻瞬间应用可能面对「积压的信号与定时事件一次性到齐」的场面;外部健康检查则在冻结期间持续失败,会触发调度器或网关的摘除逻辑。正确姿势是把冻结动作纳入编排系统的认知:冻结前摘流量,解冻后预热观察再放量,别让基础设施的自动化和应用层的解冻时机互相打架。
第三坑:冻结不改变资源配额的记账。cgroup 的内存水位、OOM 判定在冻结期间照常工作——冻结的进程也可能被 OOM 击杀(这符合预期,破冻执行处决)。反过来,内存压力引发的换出会在冻结进程身上正常发生,解冻时的第一次访问会面对批量缺页,恢复延迟可能超出预期。对延迟敏感的服务,解冻后的预热期要按这个模型估算。
给平台工程团队一个落地建议:把冻结封装成受控的运维原语,而不是暴露裸文件。封装点包括:冻结前自动摘流量、超时保护(冻结卡住自动回滚解冻)、事件审计(谁在何时冻结了哪个 cgroup)、以及与告警的联动(冻结期间抑制进程级告警,避免误报风暴)。人人可写脚本直接操作裸接口的时代,事故多来自脚本间的相互踩踏;收口成原语之后,冻结才从高危操作变成常规工具。
实操清单
给运维一份清单。快照一致性:冻结、刷盘、快照、解冻,四步顺序固定,冻结窗口尽量短并记录时间。检查点迁移:先在测试环境演练恢复差异清单(内核版本、外部 fd、网络对端),再上生产。节点排水:冻结只是静默手段,迁走与重启仍按编排系统的标准流程走。排查冻结卡死:第一步查 D 状态任务,第二步查 FUSE 与网络文件系统,最后才是内核日志。监控联动:冻结前摘流量,解冻后观察缺页风暴与积压处理,再恢复放量。
给开发一份清单。应用不要假设「我没收到信号就没人动我」:冻结是无感的,跨冻结窗口的持久化状态(文件、数据库行)要经得起任意时长的执行暂停,时间戳与租约逻辑要考虑时钟未推进的场面。长事务与外部交互(网络调用、锁服务)在冻结恢复后可能已超时,重试逻辑按「刚睡了一觉」设计而不是「刚才还在跑」。
小结
cgroup.freeze 把「暂停」从信号语义升级为层级树语义:原子冻结整棵进程树、新进程自动继承、致命信号保留破冻通道。它与 SIGSTOP 的差异是作用域、原子性与透明性三方面的代差,v1 的 freezer 子系统是它的前身,v2 把它收敛成一个开关。
运维三个动作的边界要分清:冻结是原地静止,杀死是终局处决,排水是迁移腾空。冻结的工程价值集中在快照一致性与检查点恢复两处,但它不省内存、不刷脏页,这两件事要靠 swap 与 sync 补位。
最大的坑永远在 IO:D 状态任务让冻结卡住,解冻后的缺页风暴让恢复变慢。把冻结当作基础设施层提供的「时间暂停按钮」来设计系统,应用的持久化与重试逻辑天然就能扛住它——这才是冻结机制教给我们的架构功课。
往远处看一眼:冻结语义的价值会随负载形态持续上升。检查点恢复式容错(进程随时可存可续)、确定性重放调试(冻结配合全量记录)、以及更细粒度的运行时暂停,都建立在「进程树可被内核原子静止」这块基石上。今天在容器里用好 cgroup.freeze,就是在为下一代运维形态积累肌肉记忆——静止是恢复的前提,而可靠的静止必须由内核提供。
读者留言
COMMENTS 暂无还没有留言,来说第一句?