containerd 1.7: 垃圾回收的拓展

之前和苦总/Ace 维护 pouch-container/containerd 的时候,我们遇到比较多的问题是资源泄漏,比如节点负载太高以至于无法 umount 容器根目录(容器 bundle 目录残留),内核 pidns 死锁问题导致容器进程僵尸态( cgroup 残留),还有进程重启导致 CNI 网络资源泄漏等等。对于内核死锁等问题,重启可能是唯一的解决方案;而那些因为短期高负载或者进程重启导致的资源泄漏,它们需要从系统层面解决,至少应该让资源的创建者有机会去清理。 containerd 它本身就具备垃圾回收能力,但它只关注内部资源的清理,比如镜像数据以及容器可写层。而 CNI 网络资源以及 containerd-shim 等外部资源并不在垃圾回收的管理范围内,这部分资源容易出现泄漏的情况,比如 GKE 平台的用户就多次遇到了 Containerd IP leakage : 直到 pause 容器创建之前,containerd 仅在内存里保持对网络资源的引用;在将网络资源绑定给 pod 以前,containerd 一旦被强制重启就会发生泄漏。当时 containerd 社区的处理方式是提前创建 pod 记录,以此来提前关联网络资源,并利用 kubelet 的垃圾回收机制来触发资源清理。问题倒是解决了,但该方案仅适用于 kubernetes 场景,最合理的方案应该是创建者应具备周期性的清理流程。 考虑我们还有 shim 资源泄漏的问题,containerd 社区提了 Add collectible resources to metadata gc 方案来管理外部资源清理。在介绍这个方案之前,我想先介绍下 containerd 的垃圾清理机制。 基于标签系统和 lease 构建的垃圾回收 containerd 核心插件 metadata 管理着容器和镜像数据,如下图所示,其中虚线方块代表着子模块,比如 Images 代表着 image service,它仅用来管理镜像名字和镜像 manifest 的映射关系,而镜像的 blob 数据和解压后的文件内容分别由 content service 和 snapshot service 管理;需要说明的是,container service 仅用来保存用于启动容器的配置信息,它并不负责管理容器进程的生命周期。...

March 19, 2023

containerd 1.7: Image Transfer Service

从 HELM Chart 成功对接容器镜像仓库开始,到近两年 OCI 社区 artifacts 标准化的落地推广,现在 OCI Registry as Storage 已经是事实标准,不同业务场景的数据存储都可以对接 OCI registry 的分发协议。同时,这几年容器镜像的安全供应链需求徒增,以及各大云厂商对容器镜像 lazy-loading 探索力度在加大,OCI 社区在 artifacts 标准的基础上为容器镜像引入 referrers 定义:在保证前后兼容的情况下,每一个容器镜像都可以通过关联的 artifacts 来拓展自身的属性,如下图所示,用户可以通过 referrers 关联特性为 net-monitor:v1 镜像提供了镜像签名和 SBOM 信息。当然,容器镜像 lazy-loading 属性也可以与之相关联,比如阿里云的 overlaybd,蚂蚁金服的 nydus 以及亚马逊的 soci。 NOTE: 图片取自 Feynman Zhou 发表的 Azure Container Registry: the first cloud registry to support the OCI Specifications 1.1 文章。 可预见的是,各大云厂商的镜像仓库都会跟进这一标准,安全供应链以及容器镜像加速的增值服务也会因此得到大面积推广。而作为容器镜像的消费端,为了支持这一特性,containerd 社区的想法是引入组合型插件 Image Transfer Service,将镜像分发和打包处理都抽象成数据流的转发,并统一收编在服务端处理。 Pull 需要新的抽象 在 containerd 1.7 版本之前,镜像数据处理的绝大部分操作都集中在客户端。containerd 设计偏向于把服务端做薄,服务端仅做底层资源的 CRUD;而客户端则采用类似 Backends For Frontend 理念来为复杂流程封装接口,如下图所示 Pull 镜像的函数接口。下载镜像涉及到镜像地址解析、密钥管理、并发下载以及解压处理,但该流程仅适用于下载标准格式的镜像,任何调整都容易产生不适。...

March 13, 2023

containerd 1.7: UserNamespace Stateless Pod

containerd 1.7 版本有比较多的实验特性。在这里,我会介绍 containerd 对 UserNamespace Stateless Pod 支持的情况,算是个人对 containerd 1.7 版本特性介绍系列的开篇。 1. UserNamespace 安全特性 Linux 内核是基于进程的 credentials(7) 凭证来做访问控制,比如进程的拥有者标识 UID/GID 和用于系统资源访问控制判定的 Effective UID/GID 等凭证。而 user_namespace(7) 提供了安全隔离特性。在不同的 user namespace(userns) 下,同一个进程不仅有不同的 UID/GID,同时还具备了不同的 capabilities(7) 权限。比如 u1001 用户进程 bash 通过 unshare -r bash 进入到了新的 userns,并将自己映射成了 root 用户,还具备了所有的 capabilities。但这个进程真的就变成了特权进程?这取决于该用户在系统资源所属的 userns 里是否拥有访问权限。 在介绍具体的判定规则前,先花点时间了解下内核是如何管理 UID/GID 的映射关系。 每个进程都必须归属于某一个 userns。在初始状态下,进程都属于 initial userns。Initial userns 比较特殊,它没有任何映射关系;Linux 支持嵌套的 userns,所有正在使用的 userns 组成的关系图将会是以 initial userns 为根的树状图。在 parent userns 里的进程,只要具备 CAP_SET{UID,GID} 能力,这些进程就可以通过 /proc/[pid]/{uid,gid}_map 文件接口,以 ID-inside-ns ID-outside-ns Range-length 的格式,给刚进入 child userns 的进程设置 UID/GID 映射关系,而创建该 userns 进程的 Effective UID(EUID) 将成为 userns 的所有者,如下图所示。...

March 4, 2023

使用 unshare(CLONE_FS) 来优化 OverlayFS 挂载

背景 在 Linux 平台上,大部分情况下会使用 OverlayFS 文件系统来管理容器镜像存储,而 OverlayFS 文件的特点也比较符合容器场景使用:它不仅可以将多个目录合并成统一的访问视图,还能做到读写分离。 mount -t overlay overlay \ -olowerdir=/lower1:/lower2:/lower3,upperdir=/upper,workdir=/work \ /merged 如上面的挂载命令所示, lowerdir 代表着容器镜像层解压后的目录。从 OCI Image 标准 定义来看,容器镜像的层数并没有限制。但 mount(2) 系统调用的参数被严格限制在 4KiB,所以实际使用的容器镜像层级有限制的。 为了解决这个层级的问题,Docker 采用压缩 lowerdir 参数来尽可能地支持更多层级的容器镜像。Docker 存储插件使用 l/${random-id(len=26)} 软链接指向实际的存储目录,然后跳到 /var/lib/docker/overlay2 目录下进行挂载,这样就不需要在 lowerdir 参数里重复填写 /var/lib/docker/overlay2/ 这 25 个字符。按照 Docker 代码里的注释,Overlay 镜像存储最大可支持到 128 层。 /var/lib/docker/overlay2/l/63WSQBTYICXV2O7SOZXAXYLAY2 -> ../f98d68377b05c44bacc062397f7ebaaf066b070fce15fbcfe824698d15f2eaa8/diff 但在当时,Go 并没有提供太多的线程操作,所有被 Go-Runtime 管理的线程都使用了 CLONE_FS。一旦某个 Goroutine 通过 Chdir 修改了当前工作目录,这会污染到整个进程,Docker 无法基于这样的方式来并发处理 OverlayFS 挂载请求,所以在当时只能选择 Fork-Exec 子进程来处理。考虑到维护多个二进制的成本过高,Docker 采用了 Re-exec 的方式。 不管怎么样,Fork-Exec 处理挂载成本很高,而且这样挂载逻辑没法独立成一个 Go Package,它要求使用者在 Go-Main-Init 函数里添加启动的预处理逻辑。所以在 containerd 项目里,我们采用了 Clone-Thread 的形式。...

October 15, 2022

eBPF 动态观测之指令跳板

在 containerd 自定义插件 embedshim 项目里,我借助了 Linux 内核里的 trace_sched_process_exit 观测能力,并利用 eBPF Map 记录和持久化容器进程退出事件。 这类观测能力依赖内核在关键代码路径上提前定义好钩子,它属于静态观测技术,任何变化都需要重新编译 Linux 内核。 如果我们想观测内核中的某一个关键函数或者某一行关键代码时,我们可以选择 kprobe 或者 ftrace 这类动态观测技术。 kprobe - single-step Kernel Probe(kprobe) 是一个轻量级内核指令观测的技术,用户可以指定观测内核的某一个函数,甚至可以观测函数内的某一条指令,除了 kprobe 框架自身的代码以及异常处理函数外,用户几乎可以观测内核运行的每一条指令。 当 CPU 执行到被观测指令时,也就是产生了一次 观测事件,那么 kprobe 会把当前 CPU 的寄存器信息作为输入去执行用户注册的观测程序。 然而被观测的指令由用户随机指定,考虑到性能问题,kprobe 无法在编译内核时为每一条指令预留埋点,同时我们很难在编译好的程序里动态插入指令。 基于性能和稳定性考虑,kprobe 选择了 单步调试 的通用方案。 在介绍 kprobe 方案之前,我们先简单回顾下 gdb 调试过程。为了调试某一行代码,我们先通过 breakpoint 给该行打上断点,当程序运行到该行代码时就会停下来,等待我们的下一步交互。 这个时候我们就可以通过 p 或者 info 等命令来查看当前程序的状态,甚至我们还可以通过 单步调试 来观察程序每条指令带来的变化。 我们利用断点和单步调试产生的 停顿 来观测程序,这本质上也是一种埋点,内核也正是通过这种方式来实现 kprobe,如下图所示。 x86_64 CPU 架构下的断点指令为 INT3,它是一个单字节指令 0xcc 。我们可以用 INT3 来替换任何指令的 opcode,被替换的指令(以及后续指令)都将被中断所短路掉,而 CPU 将进入 do_int3 [1] 中断处理逻辑。...

June 12, 2022