深入 ‘Checkpoint/Restore In Userspace’ (CRIU):如何将一个运行中的进程及其内核状态打包并迁移到另一台机器?

各位同仁,大家好!

今天,我们将深入探讨一个在现代云计算、容器化以及高性能计算领域都至关重要的技术:Checkpoint/Restore In Userspace (CRIU)。顾名思义,CRIU 允许我们对一个运行中的 Linux 进程进行“检查点”操作,将其完整状态保存下来,然后在同一个或另一台机器上“恢复”这个进程,就好像它从未停止过一样。这听起来像是科幻小说,但它确实是 Linux 内核和用户空间工具协同工作的强大成果。

1. 进程迁移的宏大愿景与严峻挑战

在深入 CRIU 之前,我们先来理解一下“进程迁移”这个概念及其背后的驱动力。

什么是进程迁移?
简单来说,就是将一个正在运行的计算任务(即一个或一组进程)从一台物理机或虚拟机上暂停,然后将其完整的执行上下文——包括内存、CPU 寄存器、打开的文件、网络连接等所有状态——转移到另一台机器上,并在那里从暂停点继续执行。

为什么我们需要进程迁移?
这项技术带来的好处是巨大的:

  • 故障容错与高可用性: 当一台机器出现硬件故障或需要维护时,可以将上面的关键服务迁移到健康的机器上,而无需停机。
  • 负载均衡: 动态调整计算资源,将过载机器上的任务迁移到负载较低的机器,优化资源利用率。
  • 系统维护与升级: 在不中断服务的情况下对操作系统、内核或底层硬件进行升级。
  • 能源管理: 将所有任务集中到少量机器上,然后关闭空闲机器以节省能源。
  • 容器与虚拟机生命周期管理: 容器和虚拟机的实时迁移是现代云平台的核心功能,CRIU 是许多容器运行时实现此功能的基础。
  • 调试与分析: 捕获一个复杂程序的瞬时状态,以便在离线环境中进行深入调试和分析。

进程迁移的挑战:
然而,要实现完美的进程迁移绝非易事。一个运行中的进程与操作系统内核、硬件以及其他进程之间存在着错综复杂的关系。我们需要捕获并重建以下所有状态:

  1. 进程自身状态:

    • CPU 状态: 寄存器值(指令指针、栈指针等)、浮点寄存器、CPU 特权级别。
    • 内存状态: 进程地址空间中的所有映射页,包括代码段、数据段、堆、栈以及共享内存。
    • 信号处理: 注册的信号处理函数、待处理信号、信号掩码。
    • 进程凭证: 用户 ID (UID)、组 ID (GID)、能力集 (capabilities)。
    • 资源限制: 打开文件数、内存使用量等限制。
    • 进程关系: 父子关系、进程组、会话。
  2. 内核管理的状态(与进程关联):

    • 打开的文件: 不仅仅是文件路径和句柄,还包括文件偏移量、访问模式、锁状态、底层文件系统的挂载点。对于管道、FIFO、sockets 等特殊文件,还需要捕获其内部缓冲区状态和连接状态。
    • 网络连接: TCP 连接的序列号、确认号、窗口大小、各种定时器状态;UDP 监听的端口和地址。
    • System V IPC: 共享内存段、消息队列、信号量集的状态。
    • 内核对象: eventfdsignalfdtimerfdinotifyepoll 实例的状态。
    • 命名空间 (Namespaces): 如果进程在独立的 PID、网络、挂载、用户、IPC 或 UTS 命名空间中运行,这些命名空间的状态也必须被捕获和重建。
    • 控制组 (cgroups): 进程所属的 cgroup 信息。

这些挑战使得进程迁移成为操作系统领域的一个经典难题。传统上,虚拟机管理程序 (Hypervisor) 可以通过直接捕获和迁移整个虚拟机内存和 CPU 状态来实现虚拟机迁移,但对于单个用户空间进程,这需要更精细、更深度的控制。

2. CRIU 核心原理:用户空间的救星

CRIU (Checkpoint/Restore In Userspace) 正是为了解决上述用户空间进程迁移挑战而诞生的。它是一个用户空间工具,利用了 Linux 内核提供的一系列接口来完成检查点和恢复操作。CRIU 的一个显著特点是,它尝试在不修改应用程序代码或内核自身的情况下,实现对进程的完整状态捕获与重建。

2.1 CRIU 的工作流程概览

CRIU 的核心思想可以概括为以下两步:

  1. Checkpoint (检查点/Dump):

    • CRIU 暂停目标进程(及其子进程)。
    • 通过 ptrace/proc 文件系统、netlink 接口等方式,从内核收集目标进程的所有相关状态信息。
    • 将这些状态信息序列化并保存到一系列的“图像文件” (Image Files) 中。
    • 可以选择性地终止目标进程,或者让它继续运行。
  2. Restore (恢复):

    • CRIU 从图像文件中读取保存的状态信息。
    • 在目标机器上,利用系统调用(如 forkexecmmapsocketsetns 等)精确地重建进程的地址空间、文件描述符、网络连接、命名空间等所有状态。
    • 最终,恢复进程的 CPU 寄存器,让它从暂停点继续执行。

2.2 关键的内核接口与机制

CRIU 能够成功运行,离不开 Linux 内核提供的一系列强大功能:

  • ptrace 系统调用: CRIU 使用 ptrace 来暂停目标进程,读取和修改其寄存器、内存,以及控制其执行流程。这是 CRIU 能够深入进程内部的关键。
  • /proc 文件系统: procfs 提供了大量关于进程和系统状态的信息,例如 /proc/<pid>/maps (内存映射)、/proc/<pid>/fd (文件描述符)、/proc/net (网络状态) 等。
  • netlink 接口: 用于与内核的网络栈进行通信,获取和设置复杂的网络配置,例如 TCP 连接的详细状态。
  • 命名空间 (Namespaces): CRIU 依赖于 setns()unshare() 等系统调用来创建和进入特定的命名空间,以隔离和重建进程的环境。
  • memfd_create() 创建一个匿名文件并返回文件描述符,这个文件可以像普通文件一样被操作,但只存在于内存中。CRIU 用它来处理匿名内存区域。
  • userfaultfd() 这是一个相对较新的内核特性,允许用户空间程序在访问到未映射或已交换出内存的页面时收到通知。CRIU 利用它实现懒加载 (lazy loading) 或预拷贝 (pre-copy) 迁移,以减少停机时间。
  • F_SET_SEAL (对 memfd ): 允许将 memfd 标记为只读、不可写入等,用于确保内存段的完整性。
  • SO_REUSEADDR / SO_REUSEPORT 这些 socket 选项在恢复网络连接时至关重要,允许不同的进程绑定到同一个端口。

2.3 CRIU 的图像文件 (Image Files)

当 CRIU 执行 dump 操作时,它会将捕获到的所有状态信息存储在一系列 Protobuf 格式的二进制文件中。这些文件通常存放在一个指定的目录中,每个文件类型对应一种特定的进程状态。

文件名模式 描述 示例内容
core-<pid>.img 进程核心状态,包括 CPU 寄存器、信号处理、凭证、资源限制等。 struct task_state
mm-<pid>.img 进程内存管理信息,如虚拟内存区域 (VMA) 布局、内存映射。 struct vma_area
pages-<N>.img 实际的内存页面内容。这通常是最大的文件。 二进制内存数据
files.img 打开的常规文件信息,如路径、文件描述符、偏移量、权限。 struct file_entry
pipes.img 管道 (pipe) 的状态,包括缓冲区内容和读写指针。 struct pipe_info
sockets.img TCP/UDP/UNIX 域套接字的状态,包括连接状态、端口、地址、缓冲区。 struct tcp_info, struct udp_info, struct unix_info
eventfds.img eventfd 实例的状态。 struct eventfd_info
signalfds.img signalfd 实例的状态。 struct signalfd_info
timerfds.img timerfd 实例的状态。 struct timerfd_info
inotify.img inotify 实例的状态。 struct inotify_info
epolld.img epoll 实例的状态,包括注册的事件和关联的文件描述符。 struct epoll_info
ipc_shm.img, ipc_sem.img, ipc_msg.img System V IPC 共享内存、信号量、消息队列的状态。 struct shm_info, struct sem_info, struct msg_info
pstree.img 进程树结构,描述父子关系。 struct pstree_node
namespaces.img 进程所属的各种命名空间信息。 struct ns_info
cgroup.img 进程所属的 cgroup 信息。 struct cgroup_info
fs.img 文件系统相关的挂载点信息。 struct mount_entry

这些文件构成了进程的完整快照。在恢复时,CRIU 会按照特定的顺序读取和处理这些文件,逐步重建进程状态。

3. CRIU 的安装与基础使用

3.1 环境准备

在使用 CRIU 之前,我们需要确保操作系统满足以下条件:

  1. Linux 内核版本: CRIU 对内核版本有要求,通常建议使用较新版本的内核(例如 4.x 或 5.x 系列),因为许多关键功能(如 userfaultfdmemfd_create 等)是在较新版本中引入或完善的。
  2. 内核配置: 编译内核时需要启用一些特定的配置选项,例如 CONFIG_CHECKPOINT_RESTORECONFIG_MEMFD_CREATECONFIG_USERFAULTFDCONFIG_NAMESPACES 等。大多数现代发行版默认会启用这些。
  3. CRIU 工具: 安装 CRIU 软件包。

安装 CRIU:

  • Debian/Ubuntu:
    sudo apt update
    sudo apt install criu
  • CentOS/RHEL:
    sudo yum install criu
  • Fedora:
    sudo dnf install criu
  • 从源码编译:
    git clone https://github.com/checkpoint-restore/criu.git
    cd criu
    make
    sudo make install

检查 CRIU 兼容性:
安装后,可以使用 criu check 命令来检查当前系统环境是否支持 CRIU 的所有功能:

criu check

这会列出所有支持和不支持的功能,以及可能需要的内核模块或配置。

3.2 基础 Checkpoint/Restore 示例

让我们用一个简单的 C 语言程序来演示 CRIU 的基本功能。这个程序会每秒打印一个递增的计数器,并将其写入一个文件。

my_app.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define FILENAME "app_state.txt"

int main() {
    long counter = 0;
    FILE *f_state = NULL;
    char buffer[256];

    // 尝试打开或创建状态文件
    f_state = fopen(FILENAME, "r+");
    if (f_state == NULL) {
        if (errno == ENOENT) {
            // 文件不存在,创建它
            f_state = fopen(FILENAME, "w+");
            if (f_state == NULL) {
                perror("Failed to create app_state.txt");
                return 1;
            }
            fprintf(f_state, "0n"); // 初始值
            printf("Created %s with initial counter 0.n", FILENAME);
        } else {
            perror("Failed to open app_state.txt");
            return 1;
        }
    } else {
        // 文件存在,读取上次的计数器
        if (fgets(buffer, sizeof(buffer), f_state) != NULL) {
            counter = atol(buffer);
            printf("Loaded counter %ld from %s.n", counter, FILENAME);
        } else {
            fprintf(stderr, "Warning: Could not read counter from %s. Starting from 0.n", FILENAME);
        }
        fseek(f_state, 0, SEEK_SET); // 重置文件指针到开头
    }

    printf("Application started. PID: %dn", getpid());
    printf("Press Ctrl+C to terminate or dump with CRIU.n");

    while (1) {
        counter++;
        time_t rawtime;
        struct tm *info;
        time(&rawtime);
        info = localtime(&rawtime);

        // 打印到控制台
        printf("[%02d:%02d:%02d] Counter: %ldn",
               info->tm_hour, info->tm_min, info->tm_sec, counter);

        // 更新文件中的计数器
        ftruncate(fileno(f_state), 0); // 清空文件内容
        fseek(f_state, 0, SEEK_SET);   // 重置文件指针
        fprintf(f_state, "%ldn", counter);
        fflush(f_state); // 确保数据写入磁盘

        sleep(1); // 等待1秒
    }

    fclose(f_state);
    return 0;
}

编译程序:

gcc my_app.c -o my_app

演示步骤:

  1. 启动程序:

    ./my_app & # 在后台运行,获取 PID

    记下输出的 PID,例如 Application started. PID: 12345。程序会开始每秒打印计数器。

  2. 执行 Checkpoint (Dump):
    在程序运行一段时间后(例如,当计数器达到 10-20 时),在一个新的终端中执行 CRIU dump 命令。我们需要指定目标进程的 PID (-t) 和保存图像文件的目录 (-D)。--shell-job 选项表示这个进程是一个简单的 shell 任务,不是复杂的容器。

    mkdir /tmp/my_app_dump
    criu dump -t <PID_OF_MY_APP> -D /tmp/my_app_dump --shell-job

    例如:criu dump -t 12345 -D /tmp/my_app_dump --shell-job

    dump 命令执行后:

    • my_app 程序会被暂停。
    • CRIU 会收集进程状态并保存到 /tmp/my_app_dump 目录。
    • 默认情况下,dump 成功后,原进程会终止。

    检查 /tmp/my_app_dump 目录,你会看到一系列 .img 文件。

  3. 执行 Restore (恢复):
    现在,我们可以在同一台机器上(或者将 /tmp/my_app_dump 目录复制到另一台机器上)恢复这个进程。

    criu restore -D /tmp/my_app_dump --shell-job

    程序会从之前暂停的地方继续执行,计数器会从 my_app 终止时的值开始递增,仿佛从未中断过。app_state.txt 文件也会继续更新。

    你可以对比 app_state.txt 文件内容,看它是否正确从上次的计数继续。

这个简单的例子展示了 CRIU 的核心能力:无缝地捕获和恢复用户空间进程的执行状态

3.3 进程迁移场景

将上述 Checkpoint/Restore 扩展到进程迁移,只需要一个额外的步骤:

  1. 源机器 (Source Machine):

    • 运行应用程序 my_app
    • 执行 criu dump -t <PID> -D /tmp/my_app_dump --shell-job
    • /tmp/my_app_dump 目录通过 scprsync 或其他文件传输工具复制到目标机器。
      scp -r /tmp/my_app_dump user@destination_ip:/tmp/
  2. 目标机器 (Destination Machine):

    • 确保目标机器也安装了 CRIU,并且内核版本和相关库环境与源机器兼容。
    • 执行 criu restore -D /tmp/my_app_dump --shell-job

    应用程序将在目标机器上从上次暂停的点继续运行。

迁移注意事项:

  • 内核版本和配置: 尽量保持源机器和目标机器的 Linux 内核版本和编译配置一致,尤其是一些与 CRIU 相关的特性。
  • 库文件: 应用程序依赖的动态链接库 (libc, libstdc++, etc.) 版本应尽可能一致。
  • 文件系统路径: 如果进程打开了位于特定路径的文件,这些路径在目标机器上也应该存在且内容一致。对于 my_app 例子,app_state.txt 应该在恢复目录或工作目录中。
  • 网络配置: 如果进程有网络连接,目标机器的网络环境(IP 地址、路由、防火墙)需要允许这些连接的重建。

4. 深入 CRIU 的状态捕获与重建机制

CRIU 能够处理如此复杂的进程状态,其背后是精巧的设计和对 Linux 内核机制的深刻理解。

4.1 内存状态的捕获与恢复

内存是进程状态的核心。CRIU 对内存的处理是其最复杂也最关键的部分之一。

  • 虚拟内存区域 (VMA) 映射:
    CRIU 首先通过 /proc/<pid>/maps 文件读取目标进程的整个虚拟内存布局,包括每个 VMA 的起始地址、大小、权限 (读/写/执行)、是否共享、对应的文件(如果存在)以及文件偏移量等信息。这些信息被保存在 mm-<pid>.img 中。

  • 内存页内容:
    pages-<N>.img 文件存储了实际的内存页面内容。CRIU 在 dump 时,会暂停进程,然后遍历其所有可读的 VMA,使用 ptrace 或直接读取 /proc/<pid>/mem(如果权限允许且内核支持)来复制所有脏页 (dirty pages) 和匿名页 (anonymous pages)。为了最小化停机时间,CRIU 会利用 PTRACE_O_SUSPEND_VMptrace 选项来冻结进程的内存访问。
    对于只读的、文件支持的页面(如代码段),CRIU 通常不需要复制其内容,因为这些页面可以从原始文件重新映射。

  • 内存恢复:
    restore 时,CRIU 会根据 mm-<pid>.img 中的 VMA 信息,使用 mmap() 系统调用在新的进程中重新创建相同的虚拟内存布局。
    对于匿名内存页,CRIU 会使用 memfd_create() 创建一个内存文件,然后将 pages-<N>.img 中的内容写入到这个 memfd 中,再将其映射到进程的地址空间。
    对于文件支持的页面,CRIU 会重新打开相应的文件并 mmap 它们。

  • 懒加载 (Lazy Paging) 与 userfaultfd
    对于大型内存的应用程序,在 dump 时复制所有内存页面可能非常耗时,导致较长的停机时间。CRIU 利用 userfaultfd 实现了“懒加载”或“按需页面传输” (post-copy migration)。

    1. Pre-dump (预检查点): 在真正 dump 之前,CRIU 可以先进行一次“预检查点”,只复制大部分内存页面,但让进程继续运行。
    2. Iterative dump (迭代检查点): 在预检查点之后,CRIU 可以定期执行迭代 dump,只复制在上次 dump 后被修改的页面 (dirty pages)。
    3. Final dump (最终检查点): 最后一次 dump 会暂停进程并捕获所有剩余的脏页。
      restore 时,CRIU 可以使用 userfaultfd 机制。它会先恢复 VMA 布局,但不立即加载所有页面。当恢复后的进程首次访问一个尚未加载的页面时,userfaultfd 会通知 CRIU。CRIU 收到通知后,会从图像文件中读取对应的页面内容并加载到进程的内存中。这种方式可以显著减少恢复时的停机时间,尤其是在网络带宽有限的情况下。

4.2 文件描述符 (File Descriptors)

文件描述符是进程与内核资源交互的关键。CRIU 必须精确地捕获并重建它们。

  • 常规文件:
    CRIU 会记录每个文件描述符对应的文件路径、文件偏移量、访问模式 (O_RDONLY, O_WRONLY, O_RDWR)、文件状态标志 (O_APPEND, O_NONBLOCK) 等。
    restore 时,CRIU 会尝试使用 open() 系统调用重新打开这些文件,并使用 lseek()fcntl() 重新设置偏移量和标志。
    挑战: 目标机器上的文件路径必须存在且内容兼容。如果文件已被删除或修改,恢复可能会失败。

  • 管道 (Pipes) 和 FIFO:
    CRIU 不仅要记录管道的读写端文件描述符,还要捕获管道内部缓冲区中尚未被读取的数据。
    restore 时,CRIU 会创建新的管道,并将捕获到的缓冲区数据写入到管道的写入端,以保证管道状态的连续性。

  • 套接字 (Sockets):
    这是最复杂的部分之一,尤其是 TCP 套接字。

    • TCP 套接字: CRIU 必须捕获 TCP 连接的完整状态,包括:
      • 本地/远程 IP 地址和端口。
      • TCP 状态(LISTEN, ESTABLISHED, FIN_WAIT, CLOSE_WAIT 等)。
      • 发送/接收序列号、确认号。
      • 发送/接收窗口大小。
      • 重传队列、拥塞控制状态。
        这些信息通过 netlink 接口从内核的网络栈中获取。
        restore 时,CRIU 会创建新的套接字,然后使用 restore_tcp_stream() 等特殊的 netlink 命令(CRIU 专用)将捕获的 TCP 状态注入到内核中,使连接看起来就像从未中断过一样。这通常需要目标机器和源机器具有兼容的网络栈实现。SO_REUSEADDRSO_REUSEPORT 选项对于监听套接字的恢复至关重要。
    • UDP 套接字: 相对简单,只需记录本地/远程地址和端口。因为 UDP 是无连接的,通常没有复杂的内部状态需要维护。
    • UNIX 域套接字: 对于 SOCK_STREAM 类型的 UNIX 域套接字,CRIU 也会捕获其连接状态。对于 SOCK_DGRAM 类型,通常只需记录路径。
  • 其他内核对象:
    eventfd, signalfd, timerfd, inotify, epoll 等,CRIU 都有专门的机制来捕获和重建它们的状态。例如,对于 epoll,CRIU 会记录 epoll 实例中注册的所有文件描述符及其对应的事件。

4.3 进程树与 PID 管理

  • 进程树结构:
    CRIU 使用 pstree.img 文件来记录进程之间的父子关系。在 dump 时,如果指定 criu dump -t <PID> --tree,CRIU 会捕获目标 PID 及其所有子进程的状态。
    restore 时,CRIU 会按照正确的父子顺序 fork 出新的进程,并重建它们之间的关系。

  • PID 命名空间:
    现代 Linux 系统广泛使用 PID 命名空间来隔离进程。CRIU 完全支持 PID 命名空间的迁移。如果进程在自己的 PID 命名空间中运行,CRIU 会捕获该命名空间的状态。
    restore 时,CRIU 会先创建新的 PID 命名空间,然后将进程恢复到该命名空间中。
    PID 重映射: 在新的机器或命名空间中,进程的 PID 可能会发生变化。CRIU 会在内部维护一个 PID 映射表,确保进程之间的父子关系和信号发送等操作能正确地重定向到新的 PID 上。--enable-external-pid 选项允许用户提供一个 PID 映射文件,以便在恢复时将旧 PID 映射到指定的外部新 PID。

4.4 命名空间 (Namespaces) 与 控制组 (Cgroups)

CRIU 是容器化技术的核心组件之一,它对命名空间和控制组的支持至关重要。

  • 命名空间:
    CRIU 可以捕获和恢复以下所有类型的命名空间:

    • PID Namespace: 隔离进程 ID。
    • Network Namespace: 隔离网络设备、IP 地址、路由表等。
    • Mount Namespace: 隔离文件系统挂载点。这对于确保文件路径在迁移后仍然有效非常重要。
    • UTS Namespace: 隔离主机名和 NIS 域名。
    • IPC Namespace: 隔离 System V IPC 资源。
    • User Namespace: 隔离用户和组 ID,允许非特权用户在容器内拥有 root 权限。
    • Cgroup Namespace: 隔离 cgroup 视图。
      dump 时,CRIU 会通过 /proc/<pid>/ns 获取进程所属的命名空间文件描述符,并读取其内部状态。
      restore 时,CRIU 会使用 unshare()setns() 系统调用来创建新的命名空间并将进程放入其中,然后重建命名空间内部的状态。
  • 控制组 (Cgroups):
    CRIU 能够处理进程所属的 cgroup 信息。它会捕获进程在不同 cgroup 控制器下的路径和资源限制。
    restore 时,CRIU 会尝试将恢复的进程放置到目标机器上对应的 cgroup 中,并重新应用资源限制。这需要目标机器的 cgroup 层次结构与源机器兼容,或者通过脚本进行调整。

4.5 进程间通信 (IPC)

  • System V IPC (共享内存、消息队列、信号量):
    CRIU 有专门的图像文件来存储这些 IPC 对象的全局状态,例如共享内存段的 ID、大小、权限,消息队列中的消息内容,以及信号量集的值。
    restore 时,CRIU 会使用 shmget(), msgget(), semget() 等系统调用重新创建这些 IPC 对象,并填充它们的状态。

  • futex
    futex (Fast Userspace muTEX) 是一种用户空间互斥量,其状态通常存储在进程的内存中。因此,当 CRIU 捕获内存状态时,futex 的状态通常也会随之被捕获。

5. 高级用法与挑战

5.1 预检查点 (Pre-dump) 与 迭代检查点 (Iterative Dump)

为了实现“实时迁移”或“低停机时间迁移”,CRIU 引入了预检查点和迭代检查点的概念。

  1. criu pre-dump
    在应用程序正常运行期间,CRIU 可以执行一次 pre-dump。它会捕获进程的全部状态,尤其是内存页,但不会终止进程。进程会继续运行。这个阶段可以传输大量数据。

    criu pre-dump -t <PID> -D /tmp/dump_dir --shell-job
  2. criu dump (迭代模式):
    pre-dump 之后,应用程序会继续修改内存。CRIU 可以再次执行 dump,但这次它只会捕获自上次 pre-dump 以来修改过的内存页 (dirty pages)。这个过程可以重复多次,每次捕获的脏页数量会越来越少。

    criu dump -t <PID> -D /tmp/dump_dir --shell-job --prev-images /tmp/dump_dir

    --prev-images 选项告诉 CRIU,先前的图像文件可以在指定的目录中找到,CRIU 会利用这些信息来计算增量。

  3. 最终 criu dump
    当需要真正迁移时,执行最后一次 dump。这次 dump 会暂停进程,捕获所有剩余的脏页,并终止原进程。由于大部分数据已在 pre-dump 和迭代 dump 阶段传输,因此最终的停机时间会非常短。

这种分阶段的 dump 结合 userfaultfdrestore 时的懒加载,是实现大规模云环境中容器实时迁移的关键技术。

5.2 挂钩脚本 (Hooks)

有时,进程的状态不仅仅局限于 CRIU 能够直接捕获的范畴。例如,进程可能与外部设备交互,或者依赖于特定的内核模块。CRIU 提供了挂钩脚本机制,允许在检查点和恢复过程的不同阶段执行自定义脚本。

  • --pre-dump-script <script>:在 dump 之前执行。可以用来优雅地关闭某些服务或同步外部状态。
  • --post-dump-script <script>:在 dump 之后(进程终止之前或之后)执行。可以清理资源或通知外部系统。
  • --pre-restore-script <script>:在 restore 之前执行。可以准备目标环境,如挂载文件系统、加载内核模块、配置网络接口。
  • --post-restore-script <script>:在 restore 之后执行。可以重新启动服务、验证状态或进行其他后续操作。

示例用途:

  • 数据库迁移:在 pre-dump 脚本中暂停数据库写入,在 post-restore 脚本中恢复写入。
  • 网络配置:在 pre-restore 脚本中配置目标机器的特定网络接口或 iptables 规则。
  • 设备驱动:在 pre-restore 脚本中加载特定硬件(如 GPU)的驱动,在 post-restore 脚本中检查设备状态。

5.3 容器 Checkpoint/Restore

CRIU 是 runc (OCI 容器运行时) 实现容器 checkpointrestore 命令的基础。当你在 Docker 或 Podman 中使用 docker checkpointpodman container checkpoint 时,底层就是 CRIU 在发挥作用。

容器的 Checkpoint/Restore 比裸进程更复杂,因为它涉及:

  • 多个进程: 容器通常包含一个进程树。
  • 复杂的命名空间: 所有的 PID、网络、挂载、UTS、IPC、用户和 cgroup 命名空间都需要被正确地捕获和重建。
  • 绑定挂载 (Bind Mounts): 容器内部的文件系统通常是通过绑定挂载宿主机目录实现的,CRIU 需要正确处理这些挂载点。
  • cgroup 层次结构: 容器在宿主机上通常位于复杂的 cgroup 层次结构中,CRIU 需要将它们恢复到目标机器上相应的 cgroup 中。

runc checkpoint 命令会将这些复杂性封装起来,暴露给用户一个简单的接口。

# 示例:使用 runc 对一个容器进行 checkpoint
# 1. 启动一个容器 (例如,一个简单的 sleep 容器)
sudo runc run my_container_id

# 2. 在另一个终端中,对容器进行 checkpoint
sudo runc checkpoint my_container_id --image-path /tmp/my_container_dump

# 3. 停止容器 (如果它没有自动停止)
sudo runc delete my_container_id

# 4. 将 /tmp/my_container_dump 传输到目标机器

# 5. 在目标机器上,恢复容器
sudo runc restore my_container_id --image-path /tmp/my_container_dump

通过 runc 这样的高层工具,CRIU 的强大功能被安全且方便地暴露给了容器用户。

5.4 限制与挑战

尽管 CRIU 非常强大,但它并非万能,存在一些局限性:

  • 内核兼容性: 源机器和目标机器的内核版本和配置越接近,迁移成功的概率越高。一些内核内部数据结构可能在不同版本间发生变化,导致 CRIU 无法正确解析或重建。
  • 硬件依赖:
    • 特定硬件设备: CRIU 无法迁移直接与硬件(如 GPU、FPGA、TPM)交互的进程状态,因为这些状态通常存在于硬件或其驱动的内核模块中。需要通过挂钩脚本进行特殊处理或重新初始化。
    • 设备文件: 打开的 /dev/video0/dev/input/eventX 等设备文件可能无法直接迁移。
  • 内核模块状态: CRIU 只能迁移用户空间进程状态,无法迁移内核模块的内部状态。如果应用程序依赖于某个自定义内核模块的特定状态,那这部分状态将丢失。
  • ptrace 限制: CRIU 自身大量使用 ptrace。如果目标进程已经被另一个 ptrace 进程(如调试器 gdb)附加,CRIU 可能无法正常工作。
  • 某些文件类型: CRIU 可能不支持所有特殊文件类型,例如一些虚拟文件系统 (如 sysfsdebugfs) 中的文件。
  • 安全性: CRIU 需要 CAP_SYS_ADMINCAP_CHECKPOINT_RESTORE 能力,这意味着它运行在特权模式下。图像文件的传输和存储需要考虑安全性,防止篡改。
  • 不可预测的外部依赖: 如果进程通过某种方式与外部、CRIU 无法感知的系统状态(如物理世界的传感器、一个特定的外部 USB 设备)进行强耦合,那么迁移后可能无法正常工作。

6. 实践中的 CRIU 迁移工作流

一个完整的基于 CRIU 的进程迁移工作流通常包括以下步骤:

  1. 环境准备:

    • 在源机器和目标机器上安装 CRIU。
    • 确保两台机器的 Linux 内核版本和主要系统库 (如 glibc) 尽可能一致。
    • 验证 CRIU check 命令通过。
  2. 源机器操作:

    • 运行应用程序: 确保你要迁移的应用程序正在源机器上运行。
    • 创建 Checkpoint 目录:
      mkdir /tmp/my_app_migration_dump
    • 执行 dump
      criu dump -t <PID> -D /tmp/my_app_migration_dump --shell-job --track-mem

      --track-mem 用于在 dump 之后保持进程运行,以便进行增量 dump(如果不需要实时迁移,可以省略此选项,进程会终止)。
      如果需要更复杂的容器迁移,使用 runc checkpoint

    • 等待或增量 dump (可选,用于实时迁移):
      如果目标机器距离较远或网络带宽有限,可以执行多次增量 dump

      criu dump -t <PID> -D /tmp/my_app_migration_dump --shell-job --prev-images /tmp/my_app_migration_dump --track-mem
      # 重复多次,直到传输的脏页数量非常少
    • 最终 dump (终止进程):
      criu dump -t <PID> -D /tmp/my_app_migration_dump --shell-job
      # 此时,源机器上的应用程序进程会终止。
    • 传输图像文件:/tmp/my_app_migration_dump 目录及其所有内容传输到目标机器。
      rsync -avz /tmp/my_app_migration_dump/ user@destination_ip:/tmp/my_app_migration_dump/
  3. 目标机器操作:

    • 准备目标目录: 确保 /tmp/my_app_migration_dump 目录已存在并包含所有图像文件。
    • 执行 restore
      criu restore -D /tmp/my_app_migration_dump --shell-job

      如果需要容器迁移,使用 runc restore

    • 验证: 检查应用程序是否在目标机器上正确恢复并继续执行。例如,检查日志、访问其提供的服务端口。

7. CRIU 的未来与生态系统

CRIU 是一个活跃的开源项目,由 OpenVZ 团队发起,并得到了 Red Hat、Google 等公司的支持。它不断发展,以适应 Linux 内核和用户空间技术的新变化。

  • 与容器生态系统的深度融合: CRIU 已经成为 Docker、Podman、Kubernetes (通过 containerdCRI-Ocheckpoint 插件) 等容器平台实现实时迁移和快照的核心技术。
  • 持续的内核特性利用: 随着 Linux 内核不断引入新的系统调用和机制,CRIU 会积极采纳,以提高其功能、性能和兼容性。
  • 更广泛的用例: 除了容器迁移,CRIU 还在无服务器计算 (Serverless) 中探索新的应用,例如快速冷启动函数实例。
  • 社区贡献: 作为一个开源项目,CRIU 拥有一个活跃的社区,不断有新的功能被开发和贡献。

CRIU 是 Linux 世界中一个令人兴奋的技术,它赋予了用户空间进程前所未有的灵活性和弹性。通过对进程状态的精细控制,它为构建高可用、高性能和可伸缩的分布式系统提供了强大的基石。理解并掌握 CRIU,无疑能让我们在现代系统架构设计和问题解决中,拥有更强大的工具箱。

谢谢大家!


CRIU 的出现,极大地扩展了 Linux 进程管理的能力边界,使得运行中进程的生命周期管理进入了一个新的维度。它在容器化、云计算等领域扮演着越来越核心的角色,是实现弹性、高效计算的关键技术之一。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注