从零开始写 Docker(十一)---实现 mydocker exec 进入容器内部

时间:2024-04-16 12:35:00

mydocker-exec.png

本文为从零开始写 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。

大致流程如下图所示:

mydocker-exec-process.png

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}