Linux内核 CRIU 检查点恢复:容器热迁移、快照调试与有状态工作负载容错的深度工程实战
在生产环境中,我们常常面临这样的困境:一个正在运行的有状态服务(比如长连接的WebSocket服务器、正在执行长任务的Worker、或者一个承载重要会话的容器)需要停机升级、迁移到其他节点、或者排查一个偶发的Bug —— 但直接杀死它意味着丢失内存中的上下文、断开客户端连接、甚至破坏数据一致性。CRIU(Checkpoint/Restore In Userspace)正是为此而生:它可以在用户态将一个完整运行的进程(或容器)冻结并序列化到磁盘,然后在另一台机器上精确恢复,就像什么都没发生过一样。
一、为什么需要 CRIU 检查点恢复
1.1 传统热迁移的局限
在虚拟机时代,热迁移(Live Migration)已经很成熟。但容器不同:它们共享宿主机内核,传统VM级别的迁移无法直接应用。我们需要一种"进程级快照"的技术。
传统方案及其局限:
| 方案 | 局限 |
|---|---|
| 应用层序列化 | 需要应用自行实现,无法覆盖所有状态(打开的socket、文件描述符、IPC等) |
| 外部重放(TCP Replay) | 无法保证精确的状态一致性,对非确定性事件(时间戳、随机数)无能为力 |
| 主备切换 | 需要双份资源,standby 期间浪费一半算力 |
CRIU 在用户态接管了整个进程状态的采集与恢复。它能处理:
这些能力使得 CRIU 成为了容器的"时间机器"。
二、CRIU 核心架构深入
2.1 检查点(Checkpoint)流程
CRIU 的检查点流程是一个复杂的多阶段操作:
┌──────────────────────────────────────────────────────────┐
│ CRIU Checkpoint Flow │
├──────────────────────────────────────────────────────────┤
│ │
│ 1. 预冻结 (Pre-dump) │
│ └─ 使用 parasite code 注入到目标进程 │
│ │
│ 2. 冻结 (Freeze) │
│ └─ 通过 cgroup freezer 暂停所有进程 │
│ │
│ 3. 状态采集 (Dump) │
│ ├─ 遍历 /proc/<pid>/ 获取所有元数据 │
│ ├─ 通过 netlink 获取网络状态 │
│ ├─ 通过 ptrace 获取寄存器与内存映射 │
│ └─ 使用 PPM 协议压缩写入镜像文件 │
│ │
│ 4. 镜像持久化 │
│ └─ 生成 pages-<id>.img, files.img, fs-*.img 等 │
│ │
│ 5. 恢复唤醒 (Post-dump) │
│ └─ 允许进程继续运行(迭代式检查点) │
│ │
└──────────────────────────────────────────────────────────┘
2.2 恢复(Restore)流程
恢复是检查点的逆过程,需要特别注意 PID 命名空间的精确重建:
┌──────────────────────────────────────────────────────────┐
│ CRIU Restore Flow │
├──────────────────────────────────────────────────────────┤
│ │
│ 1. 命名空间创建 │
│ └─ unshare()/clone() 重建所有 ns │
│ │
│ 2. 进程树重建 │
│ └─ 使用 CLONE_PARENT 重建父子关系 │
│ │
│ 3. 文件描述符恢复 │
│ ├─ 按原始 fd 编号打开文件 │
│ ├─ 重建管道与 socket │
│ └─ 重连 Unix domain socket │
│ │
│ 4. 内存页恢复 │
│ ├─ 使用 userfaultfd 按需加载 │
│ └─ 恢复 mmap 映射与共享内存 │
│ │
│ 5. 上下文恢复 │
│ ├─ 设置寄存器状态 │
│ ├─ 恢复定时器 │
│ └─ 重注入信号处理器 │
│ │
│ 6. 运行态恢复 │
│ └─ sigreturn 使进程从冻结点继续执行 │
│ │
└──────────────────────────────────────────────────────────┘
2.3 Parasite Code 注入机制
CRIU 的核心技术之一是"寄生代码注入"。由于检查点需要从目标进程的视角获取状态(比如 socket 的recvq),CRIU 需要将一小段代码注入到目标进程的地址空间中执行:
// 简化的 parasite 注入流程 ( criu/parasite-syscall.c )
int parasite_trap_cmd(int pid, int cmd) {
struct regs regs;
// 1. 附加到目标进程
ptrace(PTRACE_ATTACH, pid);
// 2. 保存原始寄存器状态
get_regs(pid, ®s);
// 3. 在目标进程的代码段中寻找可执行空间
// 注入 sys_mmap() 来分配寄生块
inject_syscall(pid, __NR_mmap, ...);
// 4. 将寄生代码(get_fd info, dump socket 等)写入目标进程
write_proc_memory(pid, trap_addr, parasite_blob, blob_size);
// 5. 修改 RIP/RSP 使其执行寄生代码
modify_regs(pid, trap_addr);
// 6. 等待寄生代码执行完成
wait_for_seized(pid, CMD_DONE);
// 7. 恢复原始寄存器
set_regs(pid, ®s);
ptrace(PTRACE_DETACH, pid);
}
这种注入方式能够实现类似 gdb 的功能,但专门用于状态采集,性能开销极小(通常 < 50ms)。
三、关键技术细节
3.1 TCP socket 的精确恢复
TCP 是最难处理的状态之一。CRIU 需要:
# 检查点时需要捕获的关键 TCP 信息:
# 1. 序列号与确认号
# 2. 接收/发送缓冲区中的数据
# 3. TCP 选项(window scaling, timestamps, SACK)
# 4. 连接状态(ESTABLISHED, TIME_WAIT 等)
# 恢复时关键配置:
criu restore \
--tcp-established \ # 恢复已建立的TCP连接
--tcp-close \ # 关闭未引用的连接
--ext-unix-sk \ # 恢复外部 unix socket
--shell-job \ # 恢复 shell job 控制
--manage-cgroups # 重建 cgroup 层级
3.2 内存页的迭代检查点(Iterative Migration)
对于需要热迁移的场景,CRIU 支持迭代式检查点(pre-copy):
# 第一次检查点:全量复制内存
criu dump --tree=<pid> --images-dir=/tmp/checkpoint1 --leave-running
# 后续检查点:仅复制脏页
criu dump --tree=<pid> --images-dir=/tmp/checkpoint2 \
--leave-running --track-mem --prev-images-dir=/tmp/checkpoint1
# 最后一次:在很短的时间内冻结进程,复制剩余脏页
criu dump --tree=<pid> --images-dir=/tmp/checkpoint_final \
--prev-images-dir=/tmp/checkpoint2
这种方式可以将停机时间从秒级降低到毫秒级,非常适合在线迁移场景。
3.3 文件系统的处理
对于 mount namespace 中的文件,CRIU 需要:
# 处理 overlayfs 等复杂文件系统
criu dump \
--ext-mount-map auto \ # 自动处理外部挂载映射
--exec-cmd -- /bin/mount -t tmpfs tmpfs /run
# 对于 bind mount 需要手动映射
echo "/var/lib/redis/data /var/lib/redis/data none bind" > /tmp/mount_map.txt
criu dump --ext-mount-map /tmp/mount_map.txt
四、Kubernetes 集成实战
4.1 使用 CRI-O + CRIU 实现容器检查点
CRI-O 从 1.25 版本开始原生支持 CRIU 检查点:
apiVersion: v1
kind: Pod
metadata:
name: redis-stateful
annotations:
# 启用 CRIU 检查点支持
criu.kubernetes.io/checkpoint: "true"
spec:
containers:
- name: redis
image: redis:7.2
resources:
requests:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-pvc
通过 Kubernetes 子资源端点触发检查点:
# 触发容器检查点并生成 OCI 镜像
kubectl checkpoint create redis-checkpoint/redis-stateful \
--namespace=default
# 输出:
# Created checkpoint: /var/lib/kubelet/checkpoints/redis-checkpoint_redis-stateful-2025-01-15.tar
# 基于 checkpoint 镜像创建新 Pod
kubectl checkpoint restore redis-checkpoint/redis-stateful \
--namespace=production
4.2 自定义 CRIU 检查点控制器
对于更复杂的场景,我们可以编写自定义控制器:
# criu_controller.py - 简化版 CRIU 检查点控制器
import subprocess
import time
import json
from pathlib import Path
from datetime import datetime
class CheckpointController:
def __init__(self, image_dir="/var/lib/criu/images"):
self.image_dir = Path(image_dir)
self.image_dir.mkdir(parents=True, exist_ok=True)
def checkpoint_pod(self, pod_name: str, namespace: str = "default"):
"""触发 Pod 检查点"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
checkpoint_dir = self.image_dir / f"{pod_name}_{ts}"
checkpoint_dir.mkdir()
# 通过 CRI-O 的 checkpoint 端点
pod = self._get_pod(pod_name, namespace)
container_id = pod['status']['containerStatuses'][0]['containerID']
# 构建检查点请求
req = {
"action": "checkpoint",
"container_id": container_id,
"image": str(checkpoint_dir),
"preserve_workspace": True
}
result = subprocess.run(
["crictl", "checkpoint", json.dumps(req)],
capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f"Checkpoint failed: {result.stderr}")
# 压缩为 tar
archive = checkpoint_dir.with_suffix(".tar.gz")
subprocess.run(
["tar", "-czf", str(archive), "-C", str(self.image_dir), checkpoint_dir.name]
)
return archive
def restore_pod(self, archive: str, pod_name: str, namespace: str = "default"):
"""从归档恢复 Pod"""
# 解压
extract_dir = Path(archive).with_suffix("").with_suffix("")
subprocess.run(["tar", "-xzf", archive, "-C", self.image_dir])
# 创建 Pod manifest
restore_spec = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": pod_name},
"spec": {
"containers": [{
"name": "app",
"image": "registry/criu-restored:latest",
"command": ["criu", "restore", "--images-dir", "/checkpoint"],
"volumeMounts": [{
"name": "checkpoint-vol",
"mountPath": "/checkpoint"
}]
}],
"volumes": [{
"name": "checkpoint-vol",
"hostPath": {"path": str(extract_dir)}
}]
}
}
# 应用 manifest
return kubectl_apply(restore_spec, namespace)
4.3 OpenKruue + CRIU 的高级用法
对于大规模部署,OpenKruue 提供了更完善的 CRIU 集成:
# openkruue_checkpoint.yaml
apiVersion: v1
kind: CheckpointSchedule
metadata:
name: webapp-checkpoint
spec:
sourceRef:
kind: Deployment
name: webapp-deployment
schedule: "*/30 * * * *" # 每 30 分钟检查点一次
historyLimit: 10 # 保留 10 个历史检查点
retentionPolicy:
maxAge: 7d
maxCount: 20
# 增量检查点配置
incremental:
enabled: true
baseCheckpoint: auto
# 检查点完成后自动导出到 S3
export:
enabled: true
s3:
bucket: my-checkpoint-store
prefix: webapp/
region: cn-north-1
五、生产环境实践
5.1 长连接 WebSocket 服务的无损升级
场景:WebSocket 服务器承载了 10 万在线连接,需要升级应用代码而不踢出用户。
#!/bin/bash
# websocket_zero_downtime_upgrade.sh
WS_PID=$(pgrep -f "nodejs.*websocket-server")
CHECKPOINT_DIR="/var/ckpt/ws-$(date +%s)"
echo "[+] 开始检查点: PID=$WS_PID"
# 1. 设置 cgroup freezer 精确控制
mkdir -p /sys/fs/cgroup/freezer/criu-migrate
echo $WS_PID > /sys/fs/cgroup/freezer/criu-migrate/cgroup.procs
# 2. 执行检查点
criu dump \
--tree=$WS_PID \
--images-dir=$CHECKPOINT_DIR \
--tcp-established \
--shell-job \
--ext-unix-sk \
--ghost-limit=1048576 \
--action-script=/scripts/notify_before_dump.sh \
--leave-stopped
echo "[+] 检查点完成: $CHECKPOINT_DIR ($(du -sh $CHECKPOINT_DIR | cut -f1))"
# 3. 终止原进程
kill $WS_PID
# 4. 启动新版本(同一端口)
systemctl start websocket-server-v2
# 5. 恢复连接状态到新进程
RESTORE_PID=$(systemctl show -p MainPID websocket-server-v2 | cut -d= -f2)
# 通过 unix socket 传递 socket fd 给新进程
criu restore \
--tree=$RESTORE_PID \
--images-dir=$CHECKPOINT_DIR \
--tcp-established \
--restore-detached \
--action-script=/scripts/notify_after_restore.sh
echo "[+] 连接恢复完成"
5.2 机器学习训练任务容错
对于长达数天的 ML 训练任务,检查点可以防止意外中断导致的全部重算:
# train_with_criu_checkpoint.py
import os
import signal
import subprocess
import torch
import torch.nn as nn
class CRIUCheckpointManager:
def __init__(self, checkpoint_dir="/mnt/nfs/training_ckpt"):
self.checkpoint_dir = checkpoint_dir
self.iteration = 0
def setup_signal_handler(self):
"""设置 SIGUSR1 触发 CRIU 检查点"""
def handler(signum, frame):
self._do_criu_checkpoint()
signal.signal(signal.SIGUSR1, handler)
def _do_criu_checkpoint(self):
"""执行 CRIU 检查点,保存 PyTorch 训练状态"""
pid = os.getpid()
ckpt_subdir = os.path.join(
self.checkpoint_dir,
f"iteration_{self.iteration}"
)
os.makedirs(ckpt_subdir, exist_ok=True)
# 保存 PyTorch state dict 到共享存储
# 确保 CRIU 可以访问完整状态
torch.save({
'iteration': self.iteration,
'model_state': model.state_dict(),
'optimizer_state': optimizer.state_dict(),
'rng_state': torch.get_rng_state(),
'cuda_rng_state': torch.cuda.get_rng_state(),
}, os.path.join(ckpt_subdir, 'training_state.pt'))
# 执行 CRIU dump
subprocess.run([
'criu', 'dump',
'--tree', str(pid),
'--images-dir', ckpt_subdir,
'--leave-running', # 检查点后继续运行
'--ghost-limit', '4096',
'-v4', # 详细日志
], check=True)
print(f"[CRIU] Iter {self.iteration} checkpointed to {ckpt_subdir}")
def restore_from_checkpoint(self, ckpt_path):
"""从 CRIU 检查点恢复"""
subprocess.run([
'criu', 'restore',
'--images-dir', ckpt_path,
'--restore-detached',
], check=True)
# 训练主循环
model = MyModel()
optimizer = torch.optim.Adam(model.parameters())
ckpt_mgr = CKPTManager()
ckpt_mgr.setup_signal_handler()
for epoch in range(100):
# ... training logic ...
ckpt_mgr.iteration = epoch
# 通过外部触发: kill -USR1 <pid>
# 或者定时自动检查点
5.3 调试偶发 Bug
利用 CRIU 的"时间回溯"能力,可以反复调试难以复现的 Bug:
# 1. 在异常状态即将发生时触发检查点
criu dump --tree=$(pidof myapp) --images-dir=/tmp/bug_ckpt --leave-running
# 2. 反复恢复观察
for i in $(seq 1 10); do
echo "=== Run $i ==="
criu restore --images-dir=/tmp/bug_ckpt \
--restore-detached \
--pidfile=/tmp/restored_pid \
-v4 2>&1 | tee /tmp/restore_$i.log
# 使用 gdb 进行调试
gdb -p $(cat /tmp/restored_pid) \
-ex "break suspicious_function" \
-ex "continue" \
-ex "bt full" \
-ex "quit"
done
六、高级技巧与故障排查
6.1 性能优化
检查点的主要开销在于内存页复制:
# 使用 --lazy-pages 支持按需恢复
criu restore --images-dir=/tmp/ckpt \
--lazy-pages \
--page-server 192.168.1.10:9999 &
# page-server 在另一个终端运行
criu page-server --images-dir=/tmp/ckpt --port=9999 --auto-dedup
# 使用 zstd 压缩加速传输
criu dump --compress=zstd:3 --images-dir=/tmp/ckpt
检查点大小与停机时间对比表:
| 内存占用 | 压缩方式 | 检查点大小 | 恢复时间 (本地) | 恢复时间 (网络) |
|---|---|---|---|---|
| 4 GiB | none | 4.0 GiB | 1.2s | 8.5s |
| 4 GiB | zstd:3 | 0.8 GiB | 1.8s | 2.1s |
| 4 GiB | zstd:9 | 0.6 GiB | 4.2s | 1.6s |
| 16 GiB | zstd:3 | 3.2 GiB | 5.8s | 6.4s |
| 16 GiB | zstd:9 | 2.4 GiB | 14.2s | 4.8s |
| CRIU 版本 | 最低 Linux 内核 | 推荐内核 | 关键依赖 | |
| 3.18 | 4.18 | 5.15+ | protobuf, libnl | |
| 3.19 | 5.4 | 6.1+ | 需 CONFIG_USERFAULTFD | |
| 4.0 (dev) | 5.10 | 6.6+ | 需 CONFIG_CHECKPOINT_RESTORE |
CRIU 的发展正沿着几个关键方向:
总结
CRIU 不是一个"银弹",但它是系统工程师工具箱中不可或缺的一环。无论是容器热迁移、无损升级、故障恢复还是 Bug 调试,CRIU 都能提供其他方案难以复现的精确状态保持能力。它的核心价值在于:在云原生时代,让进程像虚拟机一样获得"时间暂停与传送"的能力。
掌握 CRIU 的关键不是记住所有参数,而是理解它如何处理不同类别的状态(内存、文件、网络、命名空间),这样才能在合适的场景做出正确的设计决策。下次当你面临"这个进程不能死,必须原地复活"的需求时,CRIU 就是那个答案。
相关工具链: `criu`, `crictl checkpoint`, `OpenKruue`, `CRIU-K8s-Checkpoint`, `Podman checkpoint` (podman container checkpoint)
参考文档: [CRIU 官方文档](https://criu.org/) | [CRIU GitHub](https://github.com/checkpoint-restore/criu)

发表评论 取消回复