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 程序。 1. Type Metadata eBPF 允许读取和修改内核运行态的数据,常见数据结构有线程的 task_struct 和网络子系统的 __sk_buff。这些常用的结构体在不同内核版本之间存在差异, 比如某一个字段位于 task_struct 结构体的第 16 字节处,然而升级到某一个内核版本后,这个字段被移到第 24 字节处,那么原先编译好的 eBPF 将无法正常工作。早期的 bcc 组件在使用过程中都是在目标节点上利用现有的内核相关头文件来编译。但即使如此,bcc 也没法解决字段名被更换的问题,字段名变化容易出现编译不通过。因此早期大部分情况下,开发者还是会选择根据不同内核版本出不同的二进制,这给测试验证带来了极大的成本。...

February 27, 2022

go sync.Mutex 源码阅读

Linux Kernel 提供 Semaphore/Mutex 来实现线程间的同步机制,可保证在同一个时间段 只有少量的线程可以访问同一块资源(也称为进入临界区域)。 线程之间要通过竞争来获得访问权限,一旦竞争失败,线程会进入到阻塞状态; 而阻塞的线程只能等待离开临界区域被内核唤醒。 go runtime 提供的 sync.Mutex 并不是采用内核级别的同步机制。 作为执行单元的线程一旦阻塞,意味该线程将不再受到 go runtime 控制, go runtime 需要创建新的线程来执行其他 runnable goroutine , 线程的数目会和竞争资源的请求成正比,容易造成资源浪费。 而 go 优势是 goroutine 轻量级调度,因此 sync.Mutex 选择在用户态来实现同步机制。 和线程阻塞类似,在无法进入临界区的情况下,goroutine 会主动释放当前的 执行单元 - 线程,进入到阻塞状态;在 sync.Mutex 持有者离开临界区之前, 阻塞状态的 goroutine 将不会出现在调度队列里。 这样被释放的线程会去执行其他 runnable goroutine,提升线程的利用率。 sync.Mutex 结构设计分析 Mutex 也被称之为锁。 // sync/mutex.go // A Mutex is a mutual exclusion lock. // The zero value for a Mutex is an unlocked mutex. // // A Mutex must not be copied after first use....

June 21, 2020