浅入浅出docker run命令源码5-runc篇

前面几篇文章已经把dockerdcontainerdshim的流程代码给看了,就差最后的创建和启动容器了。runc已经很靠近操作系统了,大部分实际的工作都是调用linux的api来完成的。linux底层的东西,得等研究后才能写。该篇作为浅入浅出docker run命令源码的最后一篇,由于水平有限,只能大致了解容器最后是怎样运行用户进程的,也足够了。把runc启动容器的代码流程了解个大概,等需要的时候,再回来看就行了。

在这里插入图片描述

与前几个篇章一样,本篇文章主要介绍runc代码的流程以及如何调试。通过了解代码流程以及如何调试了解用户进程是如何启动的。runc的代码没有办法像之前那样直接用dlv就能调试,需要修改一些代码后,一部分一部分的调试。以下就是runc篇中的主要内容
1、runc create代码调试
2、runc init 中c代码调试
3、runc init 中go代码调试

源码编译

runc的代码也是独立的一个仓库,所以也是要获取源码自己编译。

查看当前在用的runc的版本

root@iZ7xv60bt3xh588holkr1fZ:~# runc -v
runc version 1.1.14
commit: v1.1.14-0-g2c9f560
spec: 1.0.2-dev
go: go1.22.7
libseccomp: 2.5.5

克隆1.1.14版本的源码

git clone --branch v1.1.14 --single-branch   https://github.com/opencontainers/runc.git

在项目的README.md处可以看到如何编译安装runc,如果是ubuntu系统,需要安装以下软件

apt update && apt install -y make gcc linux-libc-dev libseccomp-dev pkg-config git

虽然已经装过docker,但这步不能省略,否则会提示找不到pkg-config命令.
接着是编译源码了,这里直接make即可,因为make install会把runc安装到/local/usr/sbin目录里面去。

在这里插入图片描述

可以看到默认的参数里面并没有禁用内联优化,这可能会导致我们debug的时候找不到代码。
所以我们要加上-gcflags="all=-N -l",此外,还要加上cgo的环境变量,这个后续有用

CGO_CFLAGS="-g -O0" CGO_LDFLAGS="-g" go build -trimpath "-buildmode=pie"  -tags "seccomp" -gcflags="all=-N -l" -ldflags "-X main.gitCommit=v1.1.14-0-g2c9f5602 -X main.version=1.1.14 " -o runc .

CGO_CFLAGS="-g -O0" 解释

  • -g: 生成调试信息,用于调试工具(如 GDB)
  • -O0:关闭所有优化,使生成的代码更容易调试

CGO_LDFLAGS="-g" 解释

  • -g:传递调试信息给链接器,以便在最终的可执行文件中保留调试符号

runc create代码调试

编写脚本runtime-dlv.sh放到runc的源码根目录下, 通过指定DEBUG_RUNC变量来指定调试哪个命令,不然调试起来很繁琐

#!/bin/bash

# 该脚本需要作为 Docker 的自定义运行时

# 设置默认的 runc 路径
RUNC_PATH="/root/runc/runc"
DLV_PATH="dlv"

# 定义 Delve 的参数
DLV_PARAMS="--listen=:8009 --headless=true --api-version=2 exec"

# 日志记录(可选,用于调试运行时脚本)
LOG_FILE="/root/runc/runtime-dlv.log"
echo "[$(date)] Executing runtime with args: $@" >> $LOG_FILE

# 获取 DEBUG_RUNC 环境变量,用于控制调试
DEBUG_RUNC=${DEBUG_RUNC:-""}

# 检查 DEBUG_RUNC 是否启用
if [[ -n "$DEBUG_RUNC" ]]; then
    echo "[$(date)] DEBUG_RUNC is enabled with value: $DEBUG_RUNC" >> $LOG_FILE
else
    echo "[$(date)] DEBUG_RUNC is not set or empty, proceeding without debug" >> $LOG_FILE
fi

# 检查 DEBUG_RUNC 是否包含关键字,并判断第 8 个参数
if [[ -n "$DEBUG_RUNC" ]]; then
    # 使用 "," 分割 DEBUG_RUNC 并存入数组
    IFS=',' read -ra DEBUG_COMMANDS <<< "$DEBUG_RUNC"

    # 遍历数组,检查第 8 个参数是否匹配其中的任何关键字
    for cmd in "${DEBUG_COMMANDS[@]}"; do
        if [[ "${8}" == "$cmd" ]]; then
            echo "[$(date)] Using Delve to debug command: ${8}" >> $LOG_FILE
            $DLV_PATH $DLV_PARAMS -- $RUNC_PATH "$@"
            exit $?
        fi
    done
fi

# 如果未匹配或不满足条件,直接调用 runc
echo "[$(date)] Executing runc directly" >> $LOG_FILE
exec $RUNC_PATH "$@"

需要将可执行文件的路径、以及日志文件路径修改为你的路径。
脚本编写好后,并赋予可执行权限:

chmod +x runtime-dlv.sh

修改dockerd的配置文件/etc/docker/daemon.json

{
  "registry-mirrors": ["https://xxxxxx.aliyuncs.com"],
  "runtimes": {
            "dlv-runtime": {
                "path": "/root/runc/runtime-dlv.sh"
            }
		}
}

重启dockerd进程,因为我用的是自己启动的docker守护进程,所以我直接dockerd进程kill了,再启动dockerd

重启containerd进程需要在启动时,添加环境变量,假设我要调试 runc create命令,如下命令

DEBUG_RUNC=create ./bin/containerd --config containerd.toml

假设我要调试runc create 以及 runc start命令则

DEBUG_RUNC=create,start ./bin/containerd --config containerd.toml

dockerd以及containerd都重启后,运行docker run命令,指定运行时--runtime=dlv-runtime

docker run --runtime=dlv-runtime --rm --name test hello-world

GoLand Debug, 看到runc进来了
在这里插入图片描述

在这里插入图片描述

因为使用了github.com/urfave/cli库,所以runc create的代码的入口位置相当清晰
在这里插入图片描述

runc create命令的作用初始化容器的环境,包括 Namespace 和 cgroups 等;挂载文件系统。但是runc create的代码中,大部分都是围绕着runc启动时候参数指定的config文件做校验然后构建一个config对象,然后创建一个子进程runc initrunc init中才是真正的环境初始化。
在这里插入图片描述

runc init 中c代码调试

runc init分为c以及go两部分代码.

runc init 代码位置

前面可以看到 runc init是在runc create运行过程中启动的子进程。直接搜索init 关键字,会找到
根目录下的init.go
在这里插入图片描述

可以看到当第二参数是init时候,该init的逻辑就会执行。init函数是go程序启动的时候,在main函数执行前由go的runtime执行的。由于该程序是由runc create进程启动的,可执行文件路径用的是/proc/self/exe。

/proc/self/exe是一个特殊的文件路径,它指向当前正在执行的可执行文件。所以想要调试runc init进程就得改源码。要么改动启动命令,要么在runc init启动后增加暂停程序的逻辑,等程序暂停后,在用dlv attach程序进行调试。在init.go代码处添加下面的代码

// 获取当前进程的 PID
pid := os.Getpid()  
fmt.Printf("Sending SIGSTOP to process PID: %d\n", pid)  
  
// 使用 syscall.Kill 发送 SIGSTOP 信号  
err := syscall.Kill(pid, syscall.SIGSTOP)  
if err != nil {  
    fmt.Printf("Failed to send SIGSTOP: %v\n", err)  
} else {  
    fmt.Println("SIGSTOP signal sent successfully!")  
}

打上断点后,等待init程序启动后,你会发现程序已经发出暂停信号,但是且docker run命令直接就完成了,并没有实现暂停的效果。
在这里插入图片描述

仔细看会发现PID已经变成1了,说明runc init的时候已经切换了PID命名空间!

SIGTOP信号被忽略掉的原因可能是 容器内的 PID 是在其命名空间内的虚拟值,与宿主机上的真实 PID 不同。当 SIGSTOP 信号发给容器内的进程时,必须通过容器运行时正确映射 PID,否则信号可能丢失。

那么容器是什么时候切换了命名空间呢?
仔细看init.go的代码,会发现有这么个import,引入后也不用的包
在这里插入图片描述

nsenter.go的代码,会发现runc中用了cgo调用了c语言定义的init函数。这个init函数标记为 constructor 的函数,程序启动时,会先于init.go中的init函数执行,命名空间的切换就在c语言的init函数中。

nsexec.c代码调试

在init函数中调用了nsexec.c中的nsexec函数,想要debug这部分的代码,需要修改源码让程序暂停然后用gdb attach runc init进程进行调试。
在这里插入图片描述

修改nsexec函数,代码如下
在这里插入图片描述

为了省事,用code-server来远程debug。

https://github.com/coder/code-server

需要安装微软的C/C++插件,但是在code-server的插件市场里面搜索不到,所以直接去插件的github地址下,然后手动安装

https://github.com/microsoft/vscode-cpptools

下载插件后,上传到服务器,然后手动安装插件
在这里插入图片描述

然后配置launch.jsonpid需要每次都启动docker run命令后用ps -aux | grep runc命令查看
在这里插入图片描述

其中.gdbinitgdb启动后会执行的脚本,里面放了一些gdb的命令

# 禁用分页,防止 GDB 输出时需要手动按回车翻页
set pagination off

# 美化打印输出,使复杂数据结构更易读
set print pretty

# 设置路径替换规则,将调试符号中记录的路径替换为实际路径
# 例如,将 /_/github.com/opencontainers/runc 替换为 /root/runc
set substitute-path /_/github.com/opencontainers/runc /root/runc

运行docker run命令

docker run --runtime=dlv-runtime --rm --name test hello-world

runc create正常运行到启动runc init进程,然后用命令ps -aux | grep runc命令查看pid
在这里插入图片描述

修改launch.json中的pid,启动后,在DEBUG_CONSOLE输入-exec signal SIGCONT让程序继续运行,会看程序停止在断点处了
在这里插入图片描述

当你准备好大干特干的时候,你很快就会发现没debug几行代码不能再继续了

	/*
	 * We need to re-exec if we are not in a cloned binary. This is necessary
	 * to ensure that containers won't be able to access the host binary
	 * through /proc/self/exe. See CVE-2019-5736.
	 */
	if (ensure_cloned_binary() < 0)
		bail("could not ensure we are a cloned binary");

原因是为了确保宿主机的runc不被篡改,nsexec一上来就复制了一个runc可执行文件,并调用了fexecve。

fexecve 会用指定的可执行文件替换当前进程的代码和数据段,原进程的映像会完全被新的程序所取代。估计是代码段和数据段变了导致GDB找不到代码运行到哪了,就会一直卡主。想要继续往下调试,我的办法就是注释了这两句代码。

这对于程序的运行是没有影响的,因为,这只是为了防止runc的可执行文件被替换的措施,而我不需要防我自己。。。

当你又准备大干特干的时候,你又遇到这个switch语句,让你无法继续DEBUG
在这里插入图片描述

原因是,这里面又会clone一个子进程,然后子进程又clone一个孙进程,最终在孙进程里运行后续的go代码。

要看懂这里的代码,要知道setjmp以及longjmp
setjmpC标准库中的一个函数,通常与 longjmp 配合使用,用于实现非局部跳转(即在函数调用栈中跳过中间层次,直接返回到一个特定的点)。它的作用是保存当前的程序执行状态(包括栈信息、寄存器内容等),以便后续通过 longjmp 恢复到这个状态。肤浅地理解就是goto的效果。

父进程在STAGE_PARENT中,调用clone产生一个子进程,子进程的逻辑中使用了longjmp回到了setjmp处。但是在子进程中current_stage的值已经变成了STAGE_CHILD,因此子进程运行了STAGE_CHILD的逻辑。STAGE_INIT的逻辑同理。

如何调试STAGE_CHILD逻辑

child的逻辑还是比较好调试的,只需要在setjmp后添加暂停逻辑即可,其余都和前面nsexec.c代码调试的操作差不多。

current_stage = setjmp(env);
	raise(SIGSTOP);  // 用于DEBUG,暂停自身
	// if (getenv("DEBUG_RUNC")) {
    // 	printf("Waiting for GDB to attach to process %d...\n", getpid());
	// 	raise(SIGSTOP);  // 用于DEBUG,暂停自身
	// }

	switch (current_stage) {
		...省略
	}

如何调试STAGE_INIT逻辑

当你像child一样用gdb attach去调试的时候,你会发现gdb不能正常工作了。原因是在child的逻辑中,会调用unshare分离了命名空间,再调用clone搞出init进程,init进程的命名空间已经变了。
在这里插入图片描述

分离的命名空间中包括了pid命名空间,这会导致gdb不能再用原命名空间中的pidattach
想调试最简单的办法就是修改源码,让其不要分离pid命名空间即可,如下
在这里插入图片描述

理论上来说pid命名空间只是为了隔离不同容器的pid,我不分离只是能看到不应该看到的pid而已,不影响我们调试。

修改代码重新编译runc,然后打上断点后,按照前面一样的操作获取pid再次debug,可以看到已经能够调试了
在这里插入图片描述

不分离pid命名空间,终究是旁门左道,无法调试的原因是命名空间不同,那么只要我们在同一个空间中,就不存在问题了。有一个nsenter命令,如下

nsenter --target 159636 --uts --ipc --net --pid bash 

意思就是我进入和目标进程的命名空间中,然后运行一个终端。只要我在这个终端里面发起gdb attach就能够debug了。

但是事与愿违,进入命名空间后,因为在init阶段,并没有挂载/proc,导致
init时,看到的仍旧是原命名空间的/proc,也就是无法得到正确的pidgdb就无法调试。
由于对linux的命名空间机制不熟,gdb的机制不熟,就没再研究,等以后有机会再继续研究了。

runc init 中go代码调试

通过前面修改源码用gdb 调试能够看完c语言部分的代码了,还剩go的代码没调试。其实像上面那样改了代码(不分离pid命名空间以及在 case处添加raise语句),就可以直接用dlv调试了。只是每次调试麻烦一点,需要手动启动·dlv的server端,然后再在goland处debug。

dlv attach后会接管程序,不需要再手动发送SIGCONT信号,因此可以在不改init.go的代码的情况下,直接debug

修改的代码如下

1、修改case STAGE_CHILD逻辑,不分离pid命名空间
2、修改case STAGE_INIT逻辑, 增加暂停信号

启动容器

docker run --runtime=dlv-runtime --rm --name test hello-world

查看pid, 进程状态为T这个
在这里插入图片描述

运行dlv server端

dlv attach 1072569 --listen=:8011 --headless=true --api-version=2 --accept-multiclient

goland 运行,可以看到,已经能够调试了

在这里插入图片描述

runc init程序运行完成并不会退出,而是会阻塞等待管道被runc start 打开
在这里插入图片描述

runc start运行,会打开该管道,然后runc init就会运行到最后的代码,调用system.Exec,其底层会调用execve替换当前进程为容器中用户定义进程并运行!

所以runc start中最主要的事情就是打开该管道,让runc init运行用户进程。runc start的代码调试和runc create一样都是可以直接撸的,就不再说了。

总结

最近没时间,加上这runc不好调试,搞了这么久才弄完。最近没时间,加上这runc不好调试,搞了这么久才弄完。总的来说呢,这个浅入浅出docker run命令源码就到这了。至少知道了容器中的程序是怎么被调起来的,虽然有很多细节的地方没弄懂,但无所谓,毕竟叫浅入浅出嘛,肤浅的了解也是浅啊。

这玩意看了有什么用呢?至少个人来说目前没什么用。。。希望以后能用得上吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值