使用 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

embedshim: 内核是我的边车

在 2019 年的时候,当时所在的团队正在开始大规模使用 containerD,我们初期遇到较多 containerd-shim 的死锁等稳定性问题,我们不得不去思考去除 containerd-shim 进程的可能性。由于当时技术选型上的限制,containerd-shim 必须作为容器 subreaper 而存在。直到去年才留意到 pidfd pollable,我才发现 containerd-shim 管控面其实是可以被移除。我顺着这个思路作出了 embedshim 这个 containerD 第三方插件。在介绍这个插件之前,我们先简单回顾下 containerd-shim 的发展历程。 1. 从 docker 的原地升级到 containerd-shim 1.1 原地升级的需求 最初 dockerd 的容器进程管理是非常简单粗暴的,它采用了 Fork-and-Wait 模式来监控容器状态,并通过无名管道接管容器的标准输入输出。如果 dockerd 进程重启,那么它将无法重新监控容器的状态变化,而这些已运行的容器都将变成 孤儿。为了防止资源残留,dockerd 重启后的第一件事就是停掉正在运行的容器。然而节点组件的重启和周期性升级都属于正常操作,dockerd 停服重启应保证正在运行的容器不受影响。 这 docker#2658 帖子记录了当时 dockerd 原地升级的细节讨论;当然除了方案讨论外,用户对该需求落地呼声评论是更强烈些的。组件进程重启涉及到的细节比较多,但可以归类为状态恢复以及临时(残留)数据的清理,比如有讨论清理未完成的网络初始化资源,有讨论如何恢复接管容器的标准输出,还有讨论如何做镜像下载的断点续下等等。而对于本文的主题 - 如何重新接管存量容器进程的场景而言,个人认为仅需要考虑下面两个问题即可: 如何保证容器退出事件不丢失? 如何重新接管容器的标准输入输出? 首先,我们来看第一个问题。进程退出码能正确反映一个进程是以什么状态结束的,有正常退出的,有收到 SIGTERM 信号优雅停服的,还有因无法分配新的内存而被内核 SIGKILL 的。开发者和运维人员可以根据进程退出码以及关键日志信息来做 非预期退出事件 的诊断,所以对于进程管理方案而言,进程退出码必须要能被正确捕获,而当时最稳妥的方式是有一个常驻进程来做容器进程的 subreaper。 相比于第一个问题,第二个问题处理起来要简单些。经历过早期节点运维的朋友都知道,在容器化之前呢,大部分业务进程的管理是通过 systemd-service 来实现。业务进程直接被一号进程所监管,同时它们采用了 Headless 无界面无交互的方式运行。它们的标准输出通常以 UDS 流的形式传递给 systemd-journald 服务,由 systemd-journald 来做日志持久化和轮动存储。 容器化后的节点运维比 systemd 模式要稍微复杂些。容器化产生了根路经和资源视图隔离,容器管理面需要封装 nsenter 和 chroot/pivot 等系统调用来提供便捷的运维通道。dockerd 进程提供了 execCreate/execStart/execAttach HTTP 接口来进入到容器隔离视图,这种具有交互能力的运维通道必定会感知 dockerd 停服。但个人认为这种感知是可接受的,只要能保证容器标准输出不因 dockerd 停服而丢失即可。在标准输入输出的接管上,dockerd 并没有采用 UDS 流模式,而是采用有名管道的方式。...

May 2, 2022

Towards truly portable eBPF

在上一篇 eBPF Loader 中介绍了 eBPF 加载器的工作原理。Compile-Once Run-Everywhere (CO-RE) 是目前社区的发力方向,但它要求内核版本支持 CONFIG_DEBUG_INFO_BTF=y 特性。除了 REHL 等商业公司会回合高版本特性到当前支持的商业版本之外,大部分社区免费版本都要求 >= 5.5 版本的内核。为了能在当前主流的 4.14, 4.15, 4.18, 4.19 内核版本上支持 eBPF CO-RE 特性, Aqua Security 的工程师在 2021 Linux Plumbers Conference - Towards truly portable eBPF 议题上展示了它们的想法: BTF-Hub + Embedded BTF。 1. BPF Portable 作为用户提供的程序片段,eBPF bytecode 可直接注入到 linux 内核里直接读取和操作内核运行态的内存数据,它的可观测性和对内核模块的可拓展性受到系统开发者的青睐。这是它强大的优势,但同时也是一大痛点:只有获得了目标内核版本的头文件才能保证 eBPF 程序能正确地访问内存数据。 早期在使用 bcc 诊断工具时,我们需要 llvm/clang 做 eBPF 实时编译;如果开发 - 测试 - 线上环境没有做到版本的强一致,那么即使逃过了 BPF Verifier 的审判,程序也会因为没有正确地读取内存 (偏移地址不正确) 而出现非预期的行为。 虽然 eBPF 程序的非预期行为不会导致内核崩溃,但研发体验和内核模块开发差别不大:eBPF 开发者还是需要针对不同的内核版本编译出不同的版本,线上内核版本越多,测试上线的成本就越高。下图为 Falco 为不同版本提供的内核模块;如果 eBPF 没有移植能力的话,它的发布模式其实和普通的内核模块差别不大。一次构建,到处运行 的能力直到 BPF Type Format (BTF) 的出现才有了质的飞跃。...

March 10, 2022

eBPF Loader

0. What is eBPF? Extended Berkeley Packet Filter (eBPF) 是由 Linux 提供的内核技术,它是以安全沙盒 (Virtual Machine) 的形式运行用户定义的 ByteCode 来观测内核运行状态以及拓展内核的能力,开发者无需定制内核模块就可以高效地完成对现有模块的拓展。eBPF 安全沙盒是嵌入到 Linux 内核运行态的关键路径上,通过事件订阅的形式来触发 eBPF 程序,其运用场景有: cilium 在 L3/L4 提供高效的网络转发能力 bcc 提供常用的观测组件来定位业务遇到的性能问题 Google 内核调度拓展 ghOSt 我过去主要使用 eBPF 在观测和排查一些节点的性能问题。由于底层基础设施能力以及业务运行模型存在差异,这将导致节点组件出现不在预期内的行为,而在本地又难以复现,大大增加了沟通和排查成本。而 eBPF 可以捕捉到程序在内核里的状态,甚至是短命的程序调用 (比如容器领域的 runC 命令) 都可以捕捉, 它可以最大程度地呈现程序的运行状态来提升问题的排查效率。 比如前段时间遇到的 containerD CRI 组件创建容器超时的问题,我通过 bcc stackcount 捕抓到 根因: umount 可写的文件系统时会调用底层文件系统的刷盘动作,它用来保证数据能及时落盘;但这同时也给磁盘带来压力,IOPS 弱的数据盘将会拖慢 umount 调用。对于一个不熟悉内核代码的开发者来说,eBPF 观测类工具暴露出的关键函数路径就和日志的错误信息一样,全局搜索相应的内核代码段,然后顺藤摸瓜,总会找到些蛛丝马迹。 eBPF 目前发展的比较快,其中一个重要的支线是 Compile-Once Run-Everwhere (CO-RE) ,这有点像 docker 镜像分发的 Build-Once Run-Anywhere, 下面将主要围绕兼容性去介绍如何加载 eBPF 程序。...

February 27, 2022