先保存容器状态,再允许初始化进程继续
runc 用 procReady/procRun 握手建立父子进程的执行边界:父进程完成限制设置并保存容器状态后,才让子进程继续初始化。
工程问题#
容器子进程和管理它的父进程在不同执行环境中完成设置。如果父进程过早放行,然后在 state.json 写出之前退出,子进程可能仍然存活,而后续管理命令缺少定位和清理它所需的状态。单靠进程创建成功不能表示容器初始化已经完成。
实际实现#
标准初始化子进程发送 procReady 后阻塞等待 procRun。父进程收到 ready,先设置 rlimit、建立 created 状态,再调用 updateState 保存初始化进程信息;saveState 写临时文件,关闭后 rename 为正式状态文件。只有这些步骤成功,父进程才发送 procRun。接收端检查预期消息类型,父进程也要求整个握手中确实见过 procReady,防止把提前关闭连接误判为初始化成功。
处理流程#
- 子进程完成前期设置,在安装后续 seccomp 规则前发送 procReady
- 子进程等待 procRun,父进程设置限制并记录 created 状态
- 父进程把容器状态写入临时文件,再 rename 为 state.json
- 保存成功后父进程发送 procRun,子进程继续剩余初始化
- 子进程最终还要经过 exec FIFO 的启动等待,才执行目标程序
源码片段#
固定版本 · 原文摘录169 // Tell our parent that we're ready to exec. This must be done before the170 // Seccomp rules have been applied, because we need to be able to read and171 // write to a socket.172 if err := syncParentReady(l.pipe); err != nil {173 return fmt.Errorf("sync ready: %w", err)174 }175 if err := selinux.SetExecLabel(l.config.ProcessLabel); err != nil {176 return fmt.Errorf("can't set process label: %w", err)177 }178 defer selinux.SetExecLabel("") //nolint: errcheck此时仍需要读写同步 socket,因此先完成 ready 握手;返回后还会继续安全和身份相关设置。
414// syncParentReady sends to the given pipe a JSON payload which indicates that415// the init is ready to Exec the child process. It then waits for the parent to416// indicate that it is cleared to Exec.417func syncParentReady(pipe *syncSocket) error {418 // Tell parent.419 if err := writeSync(pipe, procReady); err != nil {420 return err421 }422 // Wait for parent to give the all-clear.423 return readSync(pipe, procRun)424}发送 procReady 不会直接继续执行;必须成功读到预期的 procRun 回复。
710 case procReady:711 seenProcReady = true712 // Set rlimits, this has to be done here because we lose permissions713 // to raise the limits once we enter a user-namespace714 if err := setupRlimits(p.config.Rlimits, p.pid()); err != nil {715 return fmt.Errorf("error setting rlimits for ready process: %w", err)716 }717718 // generate a timestamp indicating when the container was started719 p.container.created = time.Now().UTC()720 p.container.state = &createdState{721 c: p.container,722 }父进程先设置 rlimit,再建立 created 状态。此处的 ready 是阶段边界,并不表示最终工作负载已启动。
724 // NOTE: If the procRun state has been synced and the725 // runc-create process has been killed for some reason,726 // the runc-init[2:stage] process will be leaky. And727 // the runc command also fails to parse root directory728 // because the container doesn't have state.json.729 //730 // In order to cleanup the runc-init[2:stage] by731 // runc-delete/stop, we should store the status before732 // procRun sync.733 state, uerr := p.container.updateState(p)734 if uerr != nil {735 return fmt.Errorf("unable to store init state: %w", uerr)736 }737 p.container.initProcessStartTime = state.InitProcessStartTime738739 // Sync with child.740 if err := writeSync(p.comm.syncSockParent, procRun); err != nil {741 return err742 }注释直接说明过早发送 procRun 可能产生的清理问题。updateState 返回成功后才发送放行消息,写状态失败时不会走到放行步骤。
825 err = utils.WriteJSON(tmpFile, s)826 if err != nil {827 return err828 }829 err = tmpFile.Close()830 if err != nil {831 return err832 }833834 stateFilePath := filepath.Join(c.stateDir, stateFilename)835 return os.Rename(tmpFile.Name(), stateFilePath)JSON 写入临时文件并成功关闭后,再替换正式状态文件。这里没有 fsync,不能从 rename 推导出掉电持久性保证。
780 if err := p.comm.syncSockParent.Shutdown(unix.SHUT_WR); err != nil && ierr == nil {781 return err782 }783 if !seenProcReady && ierr == nil {784 ierr = errors.New("procReady not received")785 }786 if ierr != nil {787 return fmt.Errorf("error during container init: %w", ierr)788 }789 return nilparseSync 允许 EOF 结束接收,但调用者额外检查 seenProcReady,防止把子进程提前断开当作成功。
六段原文展示子进程调用、等待协议、父进程先保存再放行、文件替换以及缺少就绪消息的错误处理;完整状态保存与最终 exec FIFO 路径附在参考来源。
收益与取舍#
工程分析带来的收益
- 把必须成立的顺序写进协议:子进程获准继续之前,管理状态已经写出
- 同目录临时文件加 rename 避免读者看到逐步写入的半个 JSON 文件
- 显式校验消息类型与必经阶段,提前 EOF 或错误消息不会被当作正常就绪
付出的代价
- 握手增加进程间通信和状态文件 I/O,父子进程必须遵守同一协议;初始化总成本主要来自系统调用、挂载、限制与钩子,不能用常数条消息代表总耗时
- saveState 的这段实现没有 fsync,因此 rename 提供文件替换的原子可见性,不应据此宣称任意掉电后都保证状态持久存在
- 该顺序减少管理进程退出造成的清理缺口,但不是跨进程事务或自动回滚保证;各阶段失败仍需独立的终止与资源清理路径
可比较的选择
先放行子进程,再异步写状态启动等待可能更短,但父进程提前退出时可能留下缺乏完整管理状态的初始化进程。
轮询共享状态文件判断就绪减少显式消息类型,却引入轮询延迟和文件状态解释;仍必须明确谁写入、何时完整以及失败如何表达。
版本与适用范围#
固定 runc v1.2.3 的新容器标准初始化路径;不把 exec 加入已有容器的 setns 路径混为一谈。procRun 允许子进程继续初始化,不等于用户工作负载已经运行:后面仍有安全设置、关闭同步通道以及等待 exec FIFO。协议是明确的阶段握手,不是形式化验证过的全局状态机。
参考来源#
源码、官方文档与原始论文libcontainer/process_linux.go · initProcess.start · 父进程处理 ready
libcontainer/process_linux.go · initProcess.start · 保存状态后回复
libcontainer/container_linux.go · saveState · 写临时文件后 rename
libcontainer/process_linux.go · initProcess.start · 缺少 ready 就报错
libcontainer/container_linux.go · updateState / saveState · 完整状态保存
libcontainer/standard_init_linux.go · Init · exec FIFO 与执行目标程序
相关实现与比较#
4 个相关案例nginx 将小额分配变成池内游标推进,并以池的生命周期统一释放;较大的分配另建记录管理。
Redis SDS 把字节长度与可用容量放进头部,追加按长度复制,正文允许包含零字节。
QEMU 重建 FlatView 后发布新指针,已有读者继续使用旧视图,直到引用归还且 RCU 宽限期结束才销毁。
virCommandSetSendBuffer 统一接管数据和管道,让虚拟 TPM 调用方只负责取得口令、传入缓冲区并组装参数。