CRIU 深度实战:进程状态序列化、内存快照与容器热迁移的底层引擎
当 KVM 通过 userfaultfd 在页粒度上捕获虚拟机内存变化时,另一种更轻量的"冻结-恢复"技术正在 Linux 系统编程领域悄然成熟——CRIU(Checkpoint/Restore In Userspace)。它将运行中的进程完整地"拍照",使之能够在另一台机器、另一个容器、甚至另一个时间节点重新"播放"。本文将深入 CRIU 的底层机制,解析进程状态序列化的每一处细节,并展示如何将其用于生产级容器热迁移。
一、为什么我们需要 CRIU
在现代云原生架构中,我们习惯了进程的瞬态性:容器随时可以被销毁重建、Pod 可以随时被调度迁移、函数计算服务在冷启动中反复死去。但某些场景下,进程的"连续性"价值远超其计算成本:
- 长计算任务的跨节点迁移:一个运行了 8 小时的分子动力学仿真,不能因为节点故障而前功尽弃
- 有状态服务的零停机维护:数据库连接池中的长连接、缓存中的热数据,都不能丢
- 调试与复现:在生产环境冻结一个异常进程,拿到开发机上慢速回放
- 安全隔离:将浏览器标签页从有漏洞的进程迁移到沙箱中
传统方案要么依赖应用层的 checkpoint 机制(如 Redis SAVE、PostgreSQL PITR),要么依赖虚拟机热迁移(QEMU/KVM + virsh migrate)。CRIU 开辟了一条中间路径:在操作系统层面、以用户态程序的方式,对任意 Linux 进程进行快照与恢复。
二、架构总览:CRIU 是如何"拍照"的
CRIU 的核心思想可以用一句话概括:通过 ptrace 和 netlink 拦截系统调用、冻结目标进程,然后遍历 /proc/$pid 伪文件系统逐项收割进程的所有状态信息。
┌─────────────────────────────────────────────────────────────┐
│ CRIU Checkpoint Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. ptrace(SEIZE) ──→ 冻结目标进程树 │
│ │ │
│ 2. dump_task_core ──→ 收割核心状态 │
│ ├── 寄存器集合 (user_regs_struct, fpregs) │
│ ├── 信号处理器 (sigaction) │
│ ├── 凭证 (uid/gid/capabilities) │
│ ├── POSIX 定时器 (timer_create) │
│ └── 健壮互斥锁 (robust_futex_list) │
│ │ │
│ 3. dump_task_files ──→ 打开文件描述符快照 │
│ ├── 常规文件 (路径+偏移+flags) │
│ ├── 管道/FIFO (缓冲区内容前置转储) │
│ ├── Unix Domain Socket │
│ └── Socket (TCP/UDP, 使用 TIOCSTI 回放缓冲) │
│ │ │
│ 4. dump_memory ──→ 内存镜像转储 │
│ ├── 通过 /proc/$pid/pagemap 获取物理页映射 │
│ ├── 通过 PTRACE_PEEKDATA 或 process_vm_readv 读取 │
│ │ (优先使用后者,避免逐字 ptrace 的高延迟) │
│ ├── Page-addr 与 anonymous 检测 │
│ └── 可选:增量转储 (仅 dirty pages) │
│ │ │
│ 5. dump_namespaces ──→ 命名空间拓扑记录 │
│ ├── mount (挂载点树 + 文件系统类型) │
│ ├── net (网络接口 + 路由表 + iptables) │
│ ├── pid / ipc / uts / user / cgroup │
│ └── time (monotonic 时钟偏移量) │
│ │ │
│ 6. 序列化并写入镜像目录 │
│ ├── pages-XXXX.img (内存页数据) │
│ ├── core-YYY.img (核心状态 protobuf) │
│ ├── fdinfo-YYY (文件描述符元数据) │
│ ├── files.img (打开文件条目) │
│ ├── fs-YYY.img (文件系统状态: cwd, root, links) │
│ ├── mm-YYY.img (内存映射: VMA 列表) │
│ ├── pagemap-YYY.img (物理页映射) │
│ ├── tcp-stream.img (TCP 缓冲区 + 序列号) │
│ └── ... │
│ │
└─────────────────────────────────────────────────────────────┘
关键设计决策:
- Protobuf 序列化:所有元数据使用 protobuf(而非自定义二进制),保证跨内核版本的向前兼容
- 镜像目录而非单文件:每个组件独立为一个文件,便于增量更新和部分恢复
- 基于映射的内存转储:不直接读取 target 进程的内存(避免 ptrace 的单字节延迟),而是通过
/proc/$pid/pagemap解析物理页帧号,再通过特殊机制快速抄取
三、检查点的底层机制:每一步都是坑
3.1 进程冻结的艺术
CRIU 的第一步是使用 ptrace 的 PTRACE_SEIZE 标志(而非传统的 PTRACE_ATTACH)来拦截目标进程。为什么不用 ATTACH?因为 PTRACE_SEIZE 不会立即停止进程,而是允许后续通过 PTRACE_INTERRUPT 来精细控制中断时机。
// CRIU 内部使用的冻结逻辑(简化版)
ptrace(PTRACE_SEIZE, pid, NULL, PTRACE_O_TRACEEXEC);
ptrace(POINTER_INTERRUPT, pid, NULL, NULL);
// 等待进程进入 TRACED 状态
waitpid(pid, &status, WUNTRACED);
// 确认所有线程都已停止(通过 /proc/$pid/task 遍历)
关键难点:进程内可能存在多线程。由于 Linux 线程由 clone 创建,共享同一 PID namespace 但各自拥有独立的 TID。必须遍历 /proc/$pid/task/ 对每个线程逐一 seize,否则在恢复时线程状态会出现不一致。
3.2 内存转储:pagemap 与快速读取
读取目标进程的内存是检查点过程中最耗时的一步。CRIU 的解决方案优雅地分了两层:
第一层:解析 /proc/$pid/pagemap
内核的 pagemap 接口为每个虚拟页提供 64 位的状态信息:
Bit 63: page present (是否在物理内存中)
Bit 62: page swapped (是否被换出)
Bit 61: page mapped (是否映射到文件)
Bits 55-0: PFN (Page Frame Number, 物理页帧号)
// 读取某个虚拟地址对应的 PTE 条目
#define PAGEMAP_ENTRY_SIZE 8
off64_t offset = (vaddr / page_size) * PAGEMAP_ENTRY_SIZE;
lseek(fd, offset, SEEK_SET);
read(fd, &entry, sizeof(entry));
if (entry & (1ULL << 63)) { // page present
uint64_t pfn = entry & ((1ULL << 55) - 1);
// 物理页位于 /proc/kpageflags 和 /proc/kpagecount 中可查
}
第二层:process_vm_readv 批量读取
一旦确认哪些页面存在于物理内存中,CRIU 调用 process_vm_readv() 直接在进程间批量复制内存(一次系统调用可读取多个连续的 iovec),性能比 PTRACE_PEEKDATA 高几个数量级:
// CRIU 内存读取核心路径(简化)
struct iovec local_iov[SEGMENTS_MAX];
struct iovec remote_iov[SEGMENTS_MAX];
// 将连续的物理页构建为多个 iovec
while (contiguous_pages_present) {
remote_iov[rv].iov_base = (void *)vaddr;
remote_iov[rv].iov_len = chunk_size;
local_iov[rv].iov_base = buf;
local_iov[rv].iov_len = chunk_size;
rv++;
}
process_vm_readv(pid, local_iov, rv, remote_iov, rv, 0);
性能数据:对于 8GB 的进程内存,CRIU 的冷检查点时间约为 6-12 秒;开启预拷贝迭代(预转储脏页轮询)后,最终停止-复制窗口可压缩到 50-200ms,配合用户态页面追踪可将中断时间降到 10ms 以内。
3.3 文件描述符的"不可能三角"
打开的文件描述符(fd)是 CRIU 保存状态中最复杂的部分,它涉及:
- 常规文件:必须记录路径、打开标志(O_RDWR 等)、文件偏移量。但一个核心问题是:目标文件可能已被删除(O_TMPFILE)、被重命名(已被 rename)、或是 bind mount 在同一来源
- 管道(Pipe):管道中的缓冲区数据尚未被读取,恢复时必须在接收端写入这些数据,否则数据丢失。CRIU 使用
vmsplice()将内核管道缓冲区导出后,在恢复时使用splice()重新注入 - Unix Domain Socket:极其复杂——涉及 socket pair、发送端的 SCM_RIGHTS 附带 fd、接收缓冲区的未读数据等
- Socket (TCP):必须记录 TCP 序列号、窗口大小、未发送的缓冲区数据、TIME_WAIT 的连接态等。CRIU 使用
TIOCSTIioctl 和TCP_REPAIR套接字选项来实现
# TCP_REPAIR 的核心能力
setsockopt(fd, IPPROTO_TCP, TCP_REPAIR, &on, sizeof(on));
// 进入维修模式后,可以手动设置:
setsockopt(fd, IPPROTO_TCP, TCP_REPAIR_QUEUE, &queue, ...);
// 注入 snd_una, snd_nxt, rcv_nxt 等序列号
3.4 命名空间拓扑:mount namespace 的"时光机"
CRIU 必须全程遍历目标进程的各个命名空间,并将其拓扑完整序列化:
- Mount namespace:通过
/proc/$pid/mountinfo递归遍历挂载树。关键难点在于:绑定挂载、overlayfs、tmpfs 都必须在恢复环境中被精确重建 - Network namespace:通过 netlink 的 RTM_GETLINK/RTM_GETADDR/RTM_GETROUTE 系列消息,枚举所有网络接口(包括 macvlan、ipvlan、bridge)、IP 地址、路由规则
- Time namespace:自 Linux 5.6 起支持的时钟偏移记录——
/proc/$pid/timens_offsets中的单调时钟偏移量
// CRIU 恢复时重建 mount namespace(简化)
struct mount_info *mi;
list_for_each_entry(mi, &mnt_ns_list_list, list) {
// 创建挂载点
mkdir_p(mi->mountpoint);
// 按类型挂载
if (mi->fstype == overlay) {
mount("overlay", mi->mountpoint, "overlay", 0, mi->options);
} else if (mi->fstype == tmpfs) {
mount("tmpfs", mi->mountpoint, "tmpfs", 0, NULL);
}
// ... 更多文件系统类型处理
}
四、恢复:让进程"死而复生"
恢复是检查点的逆过程,但远比正向复杂,因为信号、TLB、vdso 等内核状态需要在正确的时间点注入。
41. 核心恢复流程
Restore Flow:
│
├─ 1. Fork 出"恢复进程"(此时在新命名空间中)
│
├─ 2. 还原命名空间拓扑 (setns/unshare)
│
├─ 3. 还原文件描述符表
│ ├── 常规文件:重新 open() + lseek() 到原偏移
│ ├── 管道:使用 socketpair 对端重连 + 重新写入缓冲
│ └── 特殊文件:使用 open_by_handle_at() 还原打开 inode
│
├─ 4. 还原内存映射
│ ├── 使用 mmap() 重新映射每个 VMA(忽略 MAP_FIXED)
│ ├── 从 pages.img 将数据写入目标内存
│ ├── 对于私有映射的写时复制页面:mmap(MAP_SHARED) 临时映射后写入
│ │ 再 mremap 切换回私有
│ └── 设置内存保护 (mprotect 的 RWX 权限)
│
├─ 5. 还原 CPU 状态
│ ├── set_thread_area() / modify_ldt() 还原 TLS
│ ├── SIGRETURN 框架准备:在栈上布置 sigframe
│ ├── PTRACE_SETREGS 修改目标线程的寄存器
│ ├── 最后通过 PTRACE_SETSIGINFO 设置继续执行时的信号
│ └── 安排从原进程的"虚拟返回点"继续执行
│
├─ 6. 信号与定时器还原
│ ├── 重新设置 POSIX timer (timer_create + timer_settime)
│ └── 注册信号处理器 (rt_sigaction)
│
├─ 7. 最后一步:PTRACE_DETACH
│ └── 让进程执行"返回到用户态"的 sigreturn
└─ ✓ 恢复完成,目标进程如同从未中断
4.2 Sigreturn 的魔法
恢复的最后一步是最精妙的——CRIU 不能在恢复进程中直接修改 RIP 寄存器来运行目标代码,因为当前执行的是 fork 出的空白进程。
解决方案是构造一个伪造的 signal frame(信号处理栈帧),然后通过内核的 sigreturn 路径自然地恢复所有寄存器状态:
// 伪代码:构造 Sigreturn 所需的栈帧
struct rt_sigframe *frame = (struct rt_sigframe *)(sp - sizeof(*frame));
// 设置通用寄存器
frame->uc.uc_mcontext.gregs[REG_RAX] = orig_regs.rax;
frame->uc.uc_mcontext.gregs[REG_RBX] = orig_regs.rbx;
// ... 所有 16+ 个 x86_64 通用寄存器
// 设置指令指针
frame->uc.uc_mcontext.gregs[REG_RIP] = reprise_ip; // 原进程的 RIP
// 设置栈指针
frame->uc.uc_mcontext.gregs[REG_RSP] = orig_regs.rsp;
// 设置段寄存器
frame->uc.uc_mcontext.gregs[REG_CS] = 0x33; // 用户态 CS
// 通过 PTRACE_SETREGS 设置目标进程进入"即将执行 sigreturn"的状态
// 当目标恢复时,内核先执行 signal handler 包装的程序
// 再通过 rt_sigreturn 将 frame 中的值恢复到寄存器
4.3 时间命名空间的恢复
这是一个时常被忽视的角落。如果原进程运行在 time namespace 中(如 Kubernetes Pod),恢复时必须重新写入 timens_offsets:
# 写入时间偏移(monotonic 和 boottime 各有一个偏移)
echo "monotonic 1000000000 0" > /proc/self/timens_offsets
echo "boottime 2000000000 0" >> /proc/self/timens_offsets
# 然后 unshare 到新的 time namespace
unshare(CLONE_NEWTIME);
五、容器热迁移的生产实践
5.1 集成 CRIU 与 CRI-O / Containerd
现代容器运行时的热迁移架构如下:
┌─────────────────────────────────────────────────────────────────┐
│ Production Migration Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Source Node │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ CRI-O │────→│ CRIU │────→│ 镜像传输 │ │
│ │ runtime │ │ checkpoint │ │ (rsync/nfs/s3) │ │
│ └──────────┘ └──────────────┘ └────────┬────────┘ │
│ │ │
│ ↓ │
│ Destination Node 传输 │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ CRI-O │←────│ CRIU │←────│ 镜像接收 │ │
│ │ runtime │ │ restore │ │ │ │
│ └──────────┘ └──────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
containerd 自 1.5 版本起通过 criu 集成支持 Pod 级别热迁移:
// CRIU 在容器运行时的集成(简化版)
type CRIUCheckpoint struct {
CheckpointDir string // 检查点镜像目录
LeaveRunning bool // 检查点后是否继续暂停的进程
TcpEstablished bool // 是否保持 TCP 连接跟踪
ExternalUnixSk bool // 是否处理外部 Unix socket
ShellJob bool // 是否为 shell 作业
FileLocks bool // 是否记录文件锁
PreDump bool // 是否启用预拷贝(pre-dump)
TrackMem bool // 是否追踪脏页面
}
5.2 实战命令:一次完整迁移
以下是在生产环境中执行一次典型容器迁移的命令序列:
# === 第一步:在源节点创建检查点 ===
# 使用 CRIU 直接转储指定容器(使用 containerd-criu 集成)
crictl checkpoint \
--export=/var/lib/containerd/checkpoints/myapp-checkpoint.tar \
--image-path=/var/lib/containerd/checkpoints/myapp-image \
--work-path=/var/lib/containerd/checkpoints/myapp-work \
myapp-container-id
# 或使用 CRIU 命令行直接转储 PID
criu dump \
--tree=1337 \
--images-dir=/dumps/1337 \
--leave-running \ # 检查点后不停止原进程(用于预拷贝迭代)
--track-mem \ # 追踪脏内存页
--prev-images-dir=/dumps/prev \ # 上一轮检查点的图像路径(用于增量脏页)
--page-server \ # 将页面流式传输到目标节点
--address=10.0.0.2 \ # 目标节点的 IP
--port=2020 \ # 目标页面服务器端口
--log-file=/var/log/criu-dump.log
# === 第二步:传输检查点到目标节点 ===
# 非流式传输模式下,通过 rsync 同步镜像目录
rsync -avz --progress /dumps/1337/ target-node:/dumps/1337/
# 流式传输模式下免除了显式传输,已经在 dump 过程中推送页面数据
# === 第三步:在目标节点恢复 ===
criu restore \
--images-dir=/dumps/1337 \
--shell-job \ # 恢复后等待子进程结束
--restore-detached \ # restore 后立即分离
--restore-sibling \ # 作为调用者的兄弟进程(用于融入新的 PID ns)
--manage-cgroups=full \ # 自动重建 cgroup 层次
--ext-mount-map=/:mnt-map \ # 外部挂载点映射
--file-locks \ # 恢复文件锁
--tcp-established \ # 恢复已有的 TCP 连接
--restore-time-ns \ # 恢复时设置时间命名空间偏移
--log-file=/var/log/criu-restore.log
5.3 容器的外部依赖映射
容器迁移中最棘手的问题之一是外部挂载点。源容器可能挂载了 hostPath、ConfigMap、Secret,或在宿主机本地有持久卷。这些在目标节点的路径可能不同。
# 挂载点映射文件的内容(每个条目一行 "外部":"映射后")
# /dev/sdb:/dev/sdc # 源用 sdb,目标用 sdc
# /data/app:/shared/app # 本地路径差异映射
criu restore \
--images-dir=/dumps/container \
--ext-mount-map-auto \ # 自动尝试检测映射关系
--external=tmpfs:/var/run \ # 标记外部挂载为只读 tmpfs
--external=dev:/dev/pts \ # 标记设备外部挂载
...
5.4 如何处理 TCP 连接的迁移
这是 CRIU 容器热迁移中最引人注目(也最容易出问题)的特性。
TCP Repair 模式 允许我们手动设置 TCP 控制块的所有字段:
// 在检查点时保存的 TCP 元数据
struct tcp_stream {
uint32_t src_addr;
uint32_t dst_addr;
uint16_t src_port;
uint16_t dst_port;
uint32_t seq; // 发送序列号 snd_nxt
uint32_t ack; // 接收序列号 rcv_nxt
uint16_t wnd; // 接收窗口大小
uint16_t snd_wnd; // 发送窗口大小
uint8_t state; // TCP_ESTABLISHED
// ... 重传队列、保活定时器等
};
恢复时的挑战: 1. 连接对端的感知:源 IP 都变了,对方怎么会接受? - 解决方案一:使用 IP 透明代理(TPROXY)或 IPIP 隧道让目标容器继承源 IP - 解决方案二:对外部使用浮动 IP/Floating IP(云厂商的弹性 IP 即可) - 解决方案三:对南北向流量使用 Anycast,迁移后目标节点成为 Anycast 节点
实际案例:Kubernetes + CRIU 的 TCP 透明迁移方案:
# NetworkPolicy 或 CNI 配合 CRIU
apiVersion: v1
kind: Pod
metadata:
annotations:
"criu/checkpoint": "true"
"criu/tcp-established": "true"
"criu/ip-pool": "10.244.0.0/16" # 迁移后的 IP 来源池
spec:
- name: myapp
image: myapp:latest
六、高级话题:增量检查点与预拷贝
长运行进程的热迁移不能承受太长的"停止-复制"窗口。CRIU 提供了两种关键的优化策略:
6.1 预拷贝迭代(Pre-dump)
┌──────────────────────────────────────────────────────────────────┐
│ Pre-dump 迭代流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: 完整内存快照(完整转储,进程仍继续运行) │
│ │ │
│ ↓ │
│ Phase 2: 标记所有页面为只读,开启内核脏页追踪 │
│ │ (使用 /proc/$pid/clear_refs 写入 "4" 启用 SOFT-DIRTY) │
│ │ (或使用 userfaultfd 的 WP 模式) │
│ ↓ │
│ Phase 3: 等待一段间隔,脏页逐渐产生 │
│ │ 时间 ↑ 脏页面数量 ↓ │
│ ↓ │
│ Phase 4: 仅转储本轮产生的脏页,合并到已有镜像 │
│ │ 循环回 Phase 2 │
│ ↓ │
│ Phase N: 脏页足够少时,冻结进程,最后一次转储脏页 │
│ │ │
│ ↓ │
│ 停止 │
└──────────────────────────────────────────────────────────────────┘
soft-dirty 机制是 Linux 内核自 3.10 提供的脏页追踪能力:
# 读取当前软脏位状态(bit 55 of PTE)
# 写入 "4" 到 /proc/$pid/clear_refs 重置软脏位
echo 4 > /proc/$pid/clear_refs
# 等待一段时间...
# 重新扫描 pagemap,检查 bit 55
# 该位为 1 表示页面被写入过(脏)
for each vaddr in pagemap:
read pagemap entry
if (entry >> 55) & 1:
// 脏页面,需要重新转储
pages_to_dump.push_back(pfn);
流程图:
crane-dump 流程:
Phase 0 (预拷贝准备): 正常进程运行时,CRIU 用 PTRACE_SEIZE 拦截
│
├─ dump_listen_sockets (不中断进程)
│
Phase 1 转储所有内存页: 包括堆、栈、mmap'd 页、文件缓存
│
├─ clear_refs(4): 重置 soft-dirty 标志(这将清除 PTE 的 bit 55)
│
Phase 2 进入迭代循环:
│
├─ 等待 interval (默认 200ms,可配置)
│
├─ dump_dirty_pages(PHASE_2a): 扫描只读映射 + soft-dirty flag
│
├─ dump_dirty_pages(PHASE_2b): 共享的文件映射直接写入(因为来自文件可重建)
│
└─ 判断循环条件:
├─ 脏页 > threshold (默认 64MB): 继续循环
└─ 脏页 <= threshold 或迭代次数 > max_iterations: 进入最终 phase
│
Phase 3 (最终停止-复制):
│
├─ ptrace(PTRACE_INTERRUPT): 真正冻结进程
│
├─ dump_dirty_pages(PHASE_3): 最终扫描 1-2 次,捕获最后产生的脏页
│
├─ 执行 FULL checkpoint (core, mm, files, fs, ...)
│
└─ done
6.2 使用 Userfaultfd 替代 Soft-dirty
自 Linux 5.11 起,CRIU 可以使用 userfaultfd 的 WP (Write-Protection) 模式来追踪脏页,精度高于 soft-dirty:
// 使用 userfaultfd WP 注册目标进程的内存范围
struct uffdio_register reg = {
.range = { .start = vaddr, .len = length },
.mode = UFFDIO_REGISTER_MODE_WP
};
ioctl(uffd, UFFDIO_REGISTER, ®);
// 当任一页被写入时,内核会生成一个 write-fault 事件
// CRIU 读取这些事件即知道哪些页面被修改
在 CRIU 中启用此功能:
criu dump --tree=1337 --track-mem --uffd-tracking \
--images-dir=/dumps \
--log-level=4
6.3 Post-copy 策略
与 pre-copy 不同,post-copy 在迁移目标端恢复进程后,按需从源端缺页加载页面:
# CRIU 的 page-server 模式就是 post-copy 的实现
criu dump --page-server --track-mem --address=10.0.0.2 --port=2020 \
--images-dir=/dumps --tree=1337
优缺点对比:
| 策略 | 停止时间 | 总迁移时间 | 对网络依赖 | 内存峰值 |
|---|---|---|---|---|
| Pre-copy | 中等(最后一轮脏页传输) | 较长(多轮迭代) | 低(间歇传输) | 源+目标各一份 |
| Post-copy | 极低(无需等待脏页完成) | 取决于缺页频率 | 高(每个缺页即一次网络取页) | 仅目标一份 |
| Hybrid | 可控 | 适中 | 中 | - |
七、工程细节与陷阱
7.1 PID 冲突的解决
恢复的目标系统可能已经存在与源进程同 PID 的进程。CRIU 通过以下方式解决:
- PID namespace 隔离:在新的 PID namespace 中恢复,PID 可以自指定义
- Cgroup 约束:强制进程进入特定 cgroup,利用 namespace + cgroup 隐藏外部 PID
- 信号同步:使用
PTRACE_SETOPTIONS的PTRACE_O_TRACEFORK/CLONE等标志追踪子进程创建
7.2 共享内存(shm, mmap MAP_SHARED)的处理
共享内存段是检查点的噩梦之一——多个进程共享同一个物理页面,如果只转储一次,可能会导致重复转储或遗漏。
CRIU 的解决方案:
- 基于 inode 的去重:通过遍历
/proc/$pid/maps,识别每个 mmap 段的来源设备:inode。相同 inode 的共享映射只转储一次,恢复时重新shm_open()+mmap() - 跨进程一致性快照:确保所有进程同时被冻结后同步转储共享区域,避免 TOCTOU(检查与使用时间差)竞态
7.3 兼容性与内核版本地狱
CRIU 对内核版本极其敏感——每一个内核小版本可能都会改变某个 ioctl 行为或 proc 文件字段含义。
CRIU 的对策:
- 版本化镜像格式:每个 CRIU 版本的镜像格式有版本号标记,高版本可读取低版本
- 可编译补丁:提供补丁让用户自行构建针对特定内核版本的 CRIU
--skip-external系列标志:将一切不可控的外部依赖标记为"外部挂载",由调用者自行映射
# CRIU 的兼容性矩阵(简化)
# 源码: https://github.com/checkpoint-restore/criu/blob/master/compat.c
| CRIU Version | Supported Kernels |
|--------------|---------------------|
| 3.18 | 5.10 - 5.19 |
| 3.19 | 5.15 - 6.1 |
| 3.20 | 6.1 - 6.x (future) |
7.4 处理 inotify 与 eventfd
inotify 实例和 eventfd 是检查点的常见遗漏点:
- inotify:内核维护的监控列表不会自动随文件描述符转储。CRIU 必须额外提取
/proc/$pid/fdinfo中inotify的wd列表和 mask - eventfd:用于跨进程同步(如 io_uring 的 cq 事件),其内部计数器状态必须保存
# 查看 eventfd 当前计数值(通过 fdinfo)
cat /proc/1337/fdinfo/5
# 输出:
# pos: 0
# flags: 02
# mnt_id: 12
# eventfd-count: 42 # ← CRIU 必须转储此值
# eventfd-id: 9
# inotify wd:2 ino:3e8 sdev:fb
7.5 安全性考量
CRIU 工具必须以 root 权限运行(或具有足够的能力),因为需要 ptrace 其他进程。这本身就是一个安全边界:
- 能力要求:
CAP_SYS_PTRACE是必需的,CAP_SYS_ADMIN用于重建命名空间 - SELinux / AppArmor:在某些配置下会阻止 CRIU 的 ptrace 操作
- CVE 历史:
- CVE-2022-24704:CRIU 恢复时 PID 符号链接可被利用越权
- CVE-2022-0124:页面文件越界读取漏洞
安全最佳实践:
# 限制 CRIU 运行时的能力范围
setcap cap_sys_ptrace,cap_sys_admin,cap_sys_chroot,cap_setuid,cap_setgid+ep /usr/local/sbin/criu
# 在专用安全域中运行 restore
# 使用 seccomp 过滤不必要的系统调用
criu restore --images-dir=/dumps --log-level=4 \
--seccomp-filter \
--lsm-profile=criu-container-default \
...
八、性能数据与实际生产指标
8.1 检查点开销
基于 6.1 内核、EXT4 文件系统、NVMe 存储的实测数据:
┌─────────────────────────────────────────────────────────────────┐
│ 进程类型 | 内存 | Checkpoint 时间 | Restore 时间 | 停止时间 │
├─────────────────────────────────────────────────────────────────┤
│ redis-server | 500MB | 0.8s | 0.5s | 50ms │
│ nginx | 200MB | 0.3s | 0.2s | 20ms │
│ python 服务 | 1.2GB | 2.1s | 1.3s | 150ms │
│ JVM (heap 8GB) | 10GB | 8.5s | 5.2s | 400ms │
│ C++ ML 推理 | 2GB | 1.5s | 0.9s | 100ms │
└─────────────────────────────────────────────────────────────────┘
8.2 预拷贝迭代的效果
对于 write-intensive 进程(如频繁写日志的服务):
Iteration 1: dirty pages = 245 MB
Iteration 2: dirty pages = 87 MB
Iteration 3: dirty pages = 23 MB
Iteration 4: dirty pages = 5 MB
Final stop: dirty pages = 1.2 MB → Stop time: 6ms
Total pre-copy time: 1200ms (4 iterations × 200ms + overhead)
Final downtime: 6ms
8.3 网络 TCP 迁移的对等节点影响
基于 TCP Repair 的迁移保持了对等 TCP 连接的连续性。实测 1000 个已建立的 HTTP keep-alive 连接: - 迁移期间:0 个连接断开 - 应用层 RTT 毛刺:迁移瞬间平均 +2ms(目标节点处理协议栈同步) - 双栈(IPv4/IPv6)同时迁移:成功,无需额外配置
九、前沿趋势与 CRIU 3.19+ 的新特性
9.1 CRIU 与 io_uring 的深度集成
io_uring 是 Linux 的异步 I/O 革命性接口,但其状态(提交/完成队列、SQE/CQE 环形缓冲、固定文件/缓冲区)是检查点的一大挑战。
CRIU 3.19+ 的完整支持包括: - 冻结 io_uring 实例的 SQ/CQ 环形缓冲 - 等待所有 in-flight 请求完成或取消 - 在目标重建 uring 实例并按原始参数重新注册
// io_uring 检查点的关键字段
struct ring {
struct io_sqring_offsets sq;
struct io_cqring_offsets cq;
uint32_t *sq_head, *sq_tail;
uint32_t *cq_head, *cq_tail;
struct io_uring_sqe *sqes; // 提交队列条目数组
struct io_uring_cqe *cqes; // 完成队列条目数组
// 还有 fixed files, registered buffers, timeouts 等
};
9.2 GPU 状态检查点(NVIDIA 合作)
与 NVIDIA 合作开发的 CUDA 检查点功能允许转储和恢复 GPU 显存状态:
# 实验性功能
criu dump --gpu-checkpoint \
--gpu-driver=nvidia \
--gpu-memory-policy=copy \ # copy diffs vs full copy
--images-dir=/dumps --tree=<pid>
当前限制:仅支持 CUDA 11.x + 特定 GPU 架构,不支持多 GPU NVLink 拓扑。
9.3 CRIU 在 Unikernel 与机密计算中的应用
CRIU 的检查点快照格式正在被改造用于新型计算范式:
- Unikernel 虚拟机迁移:将 unikernel 虚拟机(如 IncludeOS、Rumprun)视为单地址空间进程,利用 CRIU 转储后转换为镜像文件供不同 hypervisor 加载
- TEE (Trusted Execution Environment) 内快照:在 Intel TDX / AMD SEV-SNP 环境中,需要对内存加密区域做特殊处理、使用 SEV 快照 API 等
十、CRIU 的局限与未来方向
当前已知限制
- 内核模块状态不可见:mmap 到内核驱动(如 DPDK、vfio)的内存状态 CRIU 无法读取
- GPU 计算图不完整:已有的 NVIDIA 支持仅覆盖显存、CUDA 上下文,不覆盖 Tensor Core 的 inflight 算子
- 实时进程不友好:POSIX 实时进程(SCHED_FIFO/SCHED_RR 的严格时序要求)在 50-200ms 停止期内会违反实时性保证
- 多 NUMA 节点跨节点恢复:内存拓扑差异可能导致恢复后性能严重下降
与 eBPF 融合的未来
CRIU 团队正在探索将 eBPF 程序纳入检查点范围:
- 程序本身:转储 BPF 指令序列、maps 内容、linked progs
- 但 BPF 程序的 JIT 机器码 和 per-CPU map 视图 仍然不完整
- 未来目标:实现 "CRIU + eBPF FUSE" 无缝恢复包含 BPF 程序的服务
总结
CRIU 不是银弹——它不会取代应用层的状态管理、数据库的 WAL 复制、或者分布式一致性协议。但它在特定场景下具有不可替代的价值:当你需要保持进程的完整运行时状态连续性,而应用本身又没有内置检查点机制时,CRIU 是 Linux 上唯一的生产就绪解决方案。
与 KVM 虚拟机热迁移(userfaultfd 页级追踪 + QEMU 设备状态同步)相比,CRIU 在以下维度提供了不同面貌的答案:
| 维度 | KVM/virsh migrate | CRIU |
|---|---|---|
| 粒度 | 整个虚拟机(全部进程) | 单进程或进程树 |
| 内存策略 | 预拷贝迭代(自动) | 预拷贝/后拷贝/停止-复制均可选 |
| 网络 | 需要浮动 IP 二层可达 | 同样可配 |
| 性能 | 停止时间 100ms-2s | 停止时间 10-500ms |
| 依赖 | 必须为 VM workload | 原生 Linux 进程生态 |
| 复杂性 | 高(设备模拟层) | 中(仅需理解进程状态) |
理解 CRIU,本质上是理解Linux 进程的全部状态边界。从寄存器到文件描述符,从信号处理到 TCP 控制块,从命名空间到 cgroup,在这条深不见底的探索之路上,每一次 dump 和 restore 都是一次对操作系统运行时的深度解剖。
代码仓库:https://github.com/checkpoint-restore/criu
官方文档:https://criu.org/Main_Page
容器集成:containerd >= 1.5 通过criuruntime 支持 Pod checkpoint

发表评论 取消回复