本文为从零开始写 Docker 系列第十一篇,实现类似 docker exec 的功能,使得我们能够进入到指定容器内部。
完整代码见: https://github.com/lixd/mydocker
欢迎 Star
推荐阅读以下文章对 docker 基本实现有一个大致认识:
- 核心原理 : 深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs
- 基于 namespace 的视图隔离 : 探索 Linux Namespace:Docker 隔离的神奇背后
-
基于 cgroups 的资源限制
- 初探 Linux Cgroups:资源控制的奇妙世界
- 深入剖析 Linux Cgroups 子系统:资源精细管理
- Docker 与 Linux Cgroups:资源隔离的魔法之旅
- 基于 overlayfs 的文件系统 : Docker 魔法解密:探索 UnionFS 与 OverlayFS
- 基于 veth pair、bridge、iptables 等等技术的 Docker 网络 : 揭秘 Docker 网络:手动实现 Docker 桥接网络
开发环境如下:
root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.2 LTS
Release: 20.04
Codename: focal
root@mydocker:~# uname -r
5.4.0-74-generic
注意:需要使用 root 用户
1. 概述
上一篇已经实现了
mydocker logs
命令,可以查看容器日志了。本篇主要实现
mydocker exec
,让我们可以直接进入到容器内部,查看容器内部的文件、调试应用程序、执行命令等等。
下面这篇文章分析了 Docker 是如何使用 Linux Namespace 来实现视图隔离的,那么 mydocker exec 也是需要在 Namespace 上做文章。
[探索 Linux Namespace:Docker 隔离的神奇背后]
2. 核心原理
docker exec
实则是将当前进程添加到指定容器对应的 namespace 中
,从而可以看到容器中的进程信息、网络信息等。
因此我们的
mydocker exec
具体实现包括两部分:
- 根据容器 ID 找到对应 PID,然后找到 Namespace
- 将当前进程切换到对应 Namespace
setns
将进程加入到对应的 Namespace 很简单,Linux提供了 setns 系统调用给我们使用。
setns 是一个系统调用,可以根据提供的 PID 再次进入到指定的 Namespace 中
。它需要先打开
/proc/[pid/ns/
文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中。
但是用 Go 来实现则存在一个致命问题: setns 调用需要单线程上下文,而 GoRuntime 是多线程的 。
准确的说是 MountNamespace。
Linux 的 Namespace 是一种资源隔离机制,它允许将一组进程的视图隔离到系统的不同部分,比如 PID Namespace、Network Namespace 等。
setns
系统调用允许进程加入(或重新进入)到指定的 Namespace 中。由于 Namespace 涉及到整个进程的资源隔离,因此需要在进程的上下文中执行,以确保进程及其所有线程都在相同的 Namespace 中
。
Go Runtime 是多线程的,这意味着 Go 程序通常会有多个线程在同时运行。这种多线程模型与
setns
调用所需的单线程上下文不兼容。
Goroutine 会随机在底层 OS 线程之间切换,而不是固定在某个线程,因此在 Go 中执行 setns 不能准确的知道是操作到哪个线程了,结果是不确定的,因此需要特殊处理。
这个问题对 Go 本身来说没有太好的解决办法, #14163 是 Github 上对一些解决方案的讨论,不过最终还是被拒绝了。
不过好消息是 C 语言可以通过 gcc 的 扩展 attribute ((constructor)) 来实现 程序启动前执行特定代码 ,因此 Go 就可以通过 cgo 嵌入 这样的一段 C 代码来完成 runtime 启动前执行特定的 C 代码。
runC 中的 nsenter 也是借助 cgo 实现的。
具体代码如下:
//go:build linux && !gccgo
// +build linux,!gccgo
package nsenter
/*
#cgo CFLAGS: -Wall
extern void nsexec();
void __attribute__((constructor)) init(void) {
// something
}
*/
import "C"
这段代码就会在 Go Runtime 启动前执行这里定义的 init() 函数,我们只需要把 setns 的调用放在这个 init 方法中即可。
cgo
cgo 是一个很炫酷的功能,允许 Go 程序去调用 C 的函数与标准库。你只需要以一种特殊的方式在 Go 的源代码里写出需要调用的 C 的代码,cgo 就可以把你的 C 源码文件和 Go 文件整合成一个包。
下面举一个最简单的例子,在这个例子中有两个函数一Random 和 Seed,在
它们里面调用了 C 的 random 和 srandom 函数。
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"fmt"
)
func main() {
Seed(123)
// Output:Random: 128959393
fmt.Println("Random: ", Random())
}
// Seed 初始化随机数产生器
func Seed(i int) {
C.srandom(C.uint(i))
}
// Random 产生一个随机数
func Random() int {
return int(C.random())
}
这段代码导入了一个叫 C 的包,但是你会发现在 Go 标准库里面并没有这个包,那是因为这根本就不是一个真正的包,而只是 Cgo 创建的一个特殊命名空间,用来与 C 的命名空间交流。
这两个函数都分别调用了 C 里面的 random 和 uint 函数,然后对它们进行了类型转换。这就实现了 Go 代码里面调用 C 的功能。
3. 实现
首先,自然是需要在 C 中实现 setns 核心逻辑,根据 PID 实现 Namespace 切换。
其次,由于使用 C 的
constructor
方式,以 init 形式执行的 setns 这段代码,意味这,执行任何 mydocker 命令的时候这段代码都会执行,因此需要限制,只有 mydocker exec 时才切换 Namespace。
大致流程如下图所示:
setns
setns 的 C 实现具体如下:
package nsenter
/*
#define _GNU_SOURCE
#include <unistd.h>
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
__attribute__((constructor)) void enter_namespace(void) {
// 这里的代码会在Go运行时启动前执行,它会在单线程的C上下文中运行
char *mydocker_pid;
mydocker_pid = getenv("mydocker_pid");
if (mydocker_pid) {
fprintf(stdout, "got mydocker_pid=%s\n", mydocker_pid);
} else {
fprintf(stdout, "missing mydocker_pid env skip nsenter");
// 如果没有指定PID就不需要继续执行,直接退出
return;
}
char *mydocker_cmd;
mydocker_cmd = getenv("mydocker_cmd");
if (mydocker_cmd) {
fprintf(stdout, "got mydocker_cmd=%s\n", mydocker_cmd);
} else {
fprintf(stdout, "missing mydocker_cmd env skip nsenter");
// 如果没有指定命令也是直接退出
return;
}
int i;
char nspath[1024];
// 需要进入的5种namespace
char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt" };
for (i=0; i<5; i++) {
// 拼接对应路径,类似于/proc/pid/ns/ipc这样
sprintf(nspath, "/proc/%s/ns/%s", mydocker_pid, namespaces[i]);
int fd = open(nspath, O_RDONLY);
// 执行setns系统调用,进入对应namespace
if (setns(fd, 0) == -1) {
fprintf(stderr, "setns on %s namespace failed: %s\n", namespaces[i], strerror(errno));
} else {
fprintf(stdout, "setns on %s namespace succeeded\n", namespaces[i]);
}
close(fd);
}
// 在进入的Namespace中执行指定命令,然后退出
int res = system(mydocker_cmd);
exit(0);
return;
}
*/
import "C"
为什么要这么写,前面 setns 部分已经解释了,这里简单提一下,这里主要使用了构造函数,然后导入了 C 模块,一旦这个包被引用,它就会在所有 Go Runtime 启动之前执行,这样就避免了 Go 多线程导致的无法执行 setns 的问题。
即:这段程序执行完毕后,Go 程序才会执行。
同时,为了避免执行其他命令的时候这段 setns 的逻辑影响到其他功能,因此,在这段 C 代码前面一开始的位置就添加了环境变量检测,没有对应的环境变量时,就直接退出。
mydocker_cmd = getenv("mydocker_cmd");
if (mydocker_cmd) {
// fprintf(stdout, "got mydocker_cmd=%s\n", mydocker_cmd);
} else {
// fprintf(stdout, "missing mydocker_cmd env skip nsenter");
// 如果没有指定命令也是直接退出
return;
}
对于不使用 exec 功能的 Go 代码,只要不设置对应的环境变量,这段 C 代码就不会运行,这样就不会影响原来的逻辑。
注意:只有在你的 Go 应用程序中注册、导入了这个包,才会调用这个构造函数
。
就像这样:
import (
_ "mydocker/nsenter"
)
使用 cgo 我们无法直接获取传递给程序的参数,可用的做法是,通过 go exec 创建一个自身运行进程,然后通过传递环境变量的方式,传递给 cgo 参数值。
体现在 runc 中就是
runc create → runc init
,runc 中有很多细节,他通过环境变量传递 netlink fd,然后进行通信。
execCommand
在 main_command.go 中增加一个 execCommand,具体如下:
var execCommand = cli.Command{
Name: "exec",
Usage: "exec a command into container",
Action: func(context *cli.Context) error {
// 如果环境变量存在,说明C代码已经运行过了,即setns系统调用已经执行了,这里就直接返回,避免重复执行
if os.Getenv(EnvExecPid) != "" {
log.Infof("pid callback pid %v", os.Getgid())
return nil
}
// 格式:mydocker exec 容器名字 命令,因此至少会有两个参数
if len(context.Args()) < 2 {
return fmt.Errorf("missing container name or command")
}
containerName := context.Args().Get(0)
// 将除了容器名之外的参数作为命令部分
var commandArray []string
for _, arg := range context.Args().Tail() {
commandArray = append(commandArray, arg)
}
ExecContainer(containerName, commandArray)
return nil
},
}
然后添加到 main 函数中去:
func main(){
// 省略其他内容
app.Commands = []cli.Command{
initCommand,
runCommand,
commitCommand,
listCommand,
logCommand,
execCommand,
}
}
这里主要是将获取到的容器名和需要的命令处理完成后,交给下面的函数,下面看一下 ExecContainer 的实现。
ExecContainer
exec 命令核心实现就是 ExecContainer 方法。
// nsenter里的C代码里已经出现mydocker_pid和mydocker_cmd这两个Key,主要是为了控制是否执行C代码里面的setns.
const (
EnvExecPid = "mydocker_pid"
EnvExecCmd = "mydocker_cmd"
)
func ExecContainer(containerId string, comArray []string) {
// 根据传进来的容器名获取对应的PID
pid, err := getPidByContainerId(containerId)
if err != nil {
log.Errorf("Exec container getContainerPidByName %s error %v", containerId, err)
return
}
cmd := exec.Command("/proc/self/exe", "exec")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 把命令拼接成字符串,便于传递
cmdStr := strings.Join(comArray, " ")
log.Infof("container pid:%s command:%s", pid, cmdStr)
_ = os.Setenv(EnvExecPid, pid)
_ = os.Setenv(EnvExecCmd, cmdStr)
if err = cmd.Run(); err != nil {
log.Errorf("Exec container %s error %v", containerId, err)
}
}
首先是通过ContainerId 找到进程 PID,具体实现如下:
因为之前已经记录了容器信息,因此这里直接读取对应文件就可以找到了。
func getPidByContainerId(containerId string) (string, error) {
// 拼接出记录容器信息的文件路径
dirPath := fmt.Sprintf(container.InfoLocFormat, containerId)
configFilePath := path.Join(dirPath, container.ConfigName)
// 读取内容并解析
contentBytes, err := os.ReadFile(configFilePath)
if err != nil {
return "", err
}
var containerInfo container.Info
if err = json.Unmarshal(contentBytes, &containerInfo); err != nil {
return "", err
}
return containerInfo.Pid, nil
}
然后则是通过 exec 简单 fork 出了一个进程,并把这个进程的标准输入输出都绑定到宿主机的 stdin、stdout、stderr 上。
cmd := exec.Command("/proc/self/exe", "exec")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 把命令拼接成字符串,便于传递
cmdStr := strings.Join(comArray, " ")
最关键的是设置环境变量的这两句:
_ = os.Setenv(EnvExecPid, pid)
_ = os.Setenv(EnvExecCmd, cmdStr)
设置了这两个环境变量,于是在新的进程里,前面的 nsenter 部分的 C 代码就会执行到 setns 部分逻辑,从而将进程加入到对应的 Namespace 中进行操作了。
C 代码中根据环境变量拿到 PID 和要执行的命令,首先根据 PID 找到对应 Namespace,然后将当前进程加入到该 Namespace 然后执行具体命令。
这也是 mydocker exec 命令要实现的效果。
而执行其他命令时,由于没有指定这两个环境变量,因此那段 C 代码不会执行到 setns 这里。
这时应该就可以明白前面一段 C 代码的意义了 。
mydocker_pid = getenv("mydocker_pid");
if (mydocker_pid) {
// fprintf(stdout, "got mydocker_pid=%s\n", mydocker_pid);
} else {
// 如果没有指定PID就不需要继续执行,直接退出
return;
}
执行 exec 命令就会设置这两个环境变量,那么问题来了,执行 exec 之后环境变量就已经存在了,C 代码也运行了,那么再次执行 exec 命令岂不是会重复执行 setns 系统调用?
为了避免重复执行,在 execCommand 中加了如下判断:如果对应环境变量已经存在了就直接返回,啥也不执行。
因为环境变量存在就代表着 C 代码执行了,即setns系统调用执行了,也就是当前已经在这个 namespace 里了。
var execCommand = cli.Command{
Name: "exec",
Usage: "exec a command into container",
Action: func(context *cli.Context) error {
// 如果环境变量存在,说明C代码已经运行过了,即setns系统调用已经执行了,这里就直接返回,避免重复执行
if os.Getenv(EnvExecPid) != "" {
log.Infof("pid callback pid %v", os.Getgid())
return nil
}
// 省略其他内容
},
}
至此, mydocker exec 命令实现就完成了,核心就是 setns 系统调用
4. 测试
首先编译最新的 mydocker,然后启动一个后台容器,这里直接把 name 指定为 test,方便观察。
这里要运行交互式命令,例如 top,保证容器能在后台一直运行。
root@mydocker:~/feat-exec/mydocker# go build .
root@mydocker:~/feat-exec/mydocker# ./mydocker run -d -name test top
{"level":"info","msg":"createTty false","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0 }","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-30T09:48:33+08:00"}
然后查看容器 ID
root@mydocker:~/feat-exec/mydocker# ./mydocker ps
ID NAME PID STATUS COMMAND CREATED
2147624410 test 180358 running top 2024-01-30 09:48:33
然后执行 exec 命令并指定 Id 为 2147624410 进入该容器
root@mydocker:~/feat-exec/mydocker# ./mydocker exec 2147624410 sh
{"level":"info","msg":"container pid:180358 command:sh","time":"2024-01-30T09:48:42+08:00"}
got mydocker_pid=180358
got mydocker_cmd=sh
setns on ipc namespace succeeded
setns on uts namespace succeeded
setns on net namespace succeeded
setns on pid namespace succeeded
setns on mnt namespace succeeded
/ # ps -e
PID USER TIME COMMAND
1 root 0:00 top
6 root 0:00 sh
7 root 0:00 ps -e
在容器内部执行 ps -ef 可以发现 PID 为 1 的进程为 top,这也就意味着已经成功进入到了容器内部。
说明我们的 mydocker exec 命令实现是成功了。
5. 小结
本篇主要实现
mydocker exec
命令,和 docker 实现基本类似,通过 setns 系统调用将当前进程加入到容器所在 Namespace 即可。
比较关键的一点在于,Go Runtime 是多线程的,和 setns 冲突,因此需要使用 Cgo 以
constructor
方式在 Go Runtime 启动之前执行 setns 调用。
最后就是根据是否存在指定环境变量来防止重复执行。
【从零开始写 Docker 系列】 持续更新中,搜索公众号【 探索云原生 】订阅,阅读更多文章。
完整代码见: https://github.com/lixd/mydocker
欢迎关注~
相关代码见
feat-exec
分支,测试脚本如下:
需要提前在 /root 目录准备好 busybox.tar 文件,具体见第四篇第二节。
# 克隆代码
git clone -b feat-exec https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试
./mydocker run -d -name c1 top
# 查看容器 Id
./mydocker ps
# 根据 Id 执行 exec 进入对应容器
./mydocker exec ${containerId}
标签:游戏攻略