前面几篇文章已经把dockerd
、containerd
、shim
的流程代码给看了,就差最后的创建和启动容器了。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 init
。runc 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.json
,pid
需要每次都启动docker run
命令后用ps -aux | grep runc
命令查看
其中.gdbinit
是gdb
启动后会执行的脚本,里面放了一些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
。
setjmp
是 C
标准库中的一个函数,通常与 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
不能再用原命名空间中的pid
来attach
。
想调试最简单的办法就是修改源码,让其不要分离pid命名空间即可,如下
理论上来说pid
命名空间只是为了隔离不同容器的pid,我不分离只是能看到不应该看到的pid而已,不影响我们调试。
修改代码重新编译runc
,然后打上断点后,按照前面一样的操作获取pid再次debug,可以看到已经能够调试了
不分离pid命名空间,终究是旁门左道,无法调试的原因是命名空间不同,那么只要我们在同一个空间中,就不存在问题了。有一个nsenter命令,如下
nsenter --target 159636 --uts --ipc --net --pid bash
意思就是我进入和目标进程的命名空间中,然后运行一个终端。只要我在这个终端里面发起gdb attach就能够debug了。
但是事与愿违,进入命名空间后,因为在init阶段,并没有挂载/proc
,导致
在init
时,看到的仍旧是原命名空间的/proc
,也就是无法得到正确的pid
,gdb
就无法调试。
由于对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命令源码就到这了。至少知道了容器中的程序是怎么被调起来的,虽然有很多细节的地方没弄懂,但无所谓,毕竟叫浅入浅出嘛,肤浅的了解也是浅啊。
这玩意看了有什么用呢?至少个人来说目前没什么用。。。希望以后能用得上吧。