自己动手写Docker系列 -- 5.4实现进入容器的namespace,exec命令

201 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情

简介

在上篇中我们实现了将容器后台运行,本篇中我们将实现docker的ps命令,查看当前正在运行中的容器列表

源码说明

同时放到了Gitee和Github上,都可进行获取

本章节对应的版本标签是:5.4,防止后面代码过多,不好查看,可切换到标签版本进行查看

代码实现

这一部分实现起来就有点麻烦了,其中的一个nsenter始终不能运行正常,折腾了好一阵子发现,需要导出包才行,相关的会在代码中详细的说明

首先我们是需要使用setns去再次进入到我们容器的namespace中:

setns是一个系统调用,可以根据提供的PID再次进入到指定的Namespace 中。它需要先打开/proc/[pid]/ns/文件夹下对应的文件,然后使当前进程进入到指定的Namespace 中

但是一个具有多线程的进程是无法使用setns调用进入到对应的命名空间的,而Go启动一个程序就会进入多线程状态,所以无法简单使用命令调用去实现这个功能,需要借助C来实现

Cgo是一个很炫酷的功能,允许Go程序去调用C的函数与标准库。你只需要以一种特殊的方式在Go的源代码里写出需要调用的C的代码,Cgo就可以把你的C源码文件和Go文件整合成一个包

Cgo代码实现

新建一个文件夹 nsenter,新建文件:nsenter.go

编写Cgo的进入命名空间的代码,如下:

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)) static void enter_namespace(void) {
	char *mydocker_pid;
    // 从环境变量中获取需要进入的PID
    // 如果没有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");
		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];
	char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt" };

	for (i=0; i<5; i++) {
		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);
	}
    // 进入后执行指定的命令
	int res = system(mydocker_cmd);
	exit(0);
	return;
}
*/
import "C"

如上所示,这样就把进入命名空间的Cgo文件写好了,具体使用在后面会详细说明

Exec命令实现

我们在main中增加exec命令:

func main() {
	......
	app.Commands = []cli.Command{
		command.InitCommand,
		command.RunCommand,
		command.CommitCommand,
		command.ListCommand,
		command.LogCommand,
		command.ExecCommand,
	}
    ......
}

在main_command.go文件,增加Exec指令解析

var ExecCommand = cli.Command{
	Name:  "exec",
	Usage: "exec a command into container",
	Action: func(context *cli.Context) {
		if os.Getenv(run.EnvExecPid) != "" {
			log.Infof("pid callback pid %d", os.Getgid())
			return
		}

		// 我们希望命令格式是docker exec 容器名 命令
		if len(context.Args()) < 2 {
			log.Errorf("missing container name or command")
			return
		}

		containerName := context.Args().Get(0)
		var commandArray []string
		for _, arg := range context.Args().Tail() {
			commandArray = append(commandArray, arg)
		}

		// 执行命令
		if err := run.ExecContainer(containerName, commandArray); err != nil {
			log.Errorf("%v", err)
		}
	},
}

新增exec.go文件,编写具体的exec逻辑

import (
	// 这个很关键,引入而不使用,但其在启动的时候后自动调用
	_ "dockerDemo/mydocker/nsenter"
	"fmt"
	log "github.com/sirupsen/logrus"
	"os"
	"os/exec"
	"strings"
)

// EnvExecPid
/**
前面的C代码中已经出现了mydocker_pid 和 mydocker_cmd 这两个key
主要是为了控制是否执行c代码里面的setns
*/
const EnvExecPid = "mydocker_pid"
const EnvExecCmd = "mydocker_cmd"

func ExecContainer(containerName string, commandArray []string) error {
	// 根据传过来的容器名获取宿主机对应的pid
	pid, err := getContainerPidByName(containerName)
	if err != nil {
		return err
	}

	// 把命令以空格为分隔符拼接成一个字符串,便于传递
	cmdStr := strings.Join(commandArray, " ")
	log.Infof("container pid %s", pid)
	log.Infof("command %s", cmdStr)

	cmd := exec.Command("/proc/self/exe", "exec")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := os.Setenv(EnvExecPid, pid); err != nil {
		return fmt.Errorf("setenv %s err: %v", EnvExecPid, err)
	}
	if err := os.Setenv(EnvExecCmd, cmdStr); err != nil {
		return fmt.Errorf("setenv %s err: %v", EnvExecCmd, err)
	}

	envs, err := getEnvsByPid(pid)
	if err != nil {
		return err
	}
	cmd.Env = append(os.Environ(), envs...)

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("exec container %s err: %v", containerName, err)
	}
	return nil
}

这里又遇到熟悉的/proc/self/exe,只不过是换了后面的参数,由原来的init 变成了现在的exec。这么做的目的就是为了那段C代码的执行。 因为一旦程序启动,那段C代码就会运行,那么对于我们使用exec来说,当容器名和对应的命令传递进来以后,程序已经执行了,而且那段C代码也应该运行完毕。 那么,怎么指定环境变量让它再执行一遍呢?这里就用到了这个/proc/self/exe。 这里又创建了一个command,只不过这次只是简单地fork出来一个进程,不需要这个进程拥有什么命名空间的隔离,然后把这个进程的标准输入输出都绑定到宿主机上。 这样去run这里的进程时,实际上就是又运行了一遍自己的程序,但是这时有一点不同的就是,再一次运行的时候已经指定了环境变量,所以C代码执行的时候就能拿到对应的环境变量,便可以进入到指定的Namespace中进行操作了。这时应该就可以明白前面一段代码的意义了

简单来说,就是需要再次触发下我们相关的docker命令运行

其中这句:_ "dockerDemo/mydocker/nsenter"

这个一定要加上,不然的话,不能在exec运行的时候触发Cgo文件的运行,进入命名空间就会失败

测试运行

命令如下:

# 启动一个后台进行
 root@lw-Code-01-Series-PF5NU1G  ~/code/go/dockerDemo   main ●  ./main run --name bird -d top                                                                                       ✔  ⚡  395  05:50:30
{"level":"info","msg":"memory cgroup path: /sys/fs/cgroup/memory/mydocker-cgroup","time":"2022-04-08T05:50:45+08:00"}
{"level":"info","msg":"memory cgroup path: /sys/fs/cgroup/memory/mydocker-cgroup","time":"2022-04-08T05:50:45+08:00"}
{"level":"info","msg":"all command is : top","time":"2022-04-08T05:50:45+08:00"}
{"level":"info","msg":"parent process run","time":"2022-04-08T05:50:45+08:00"}

# 查看其正在运行中
 root@lw-Code-01-Series-PF5NU1G  ~/code/go/dockerDemo   main ●  ./main ps                                                                                                  SIG(127) ↵  ⚡  396  05:50:45
ID           NAME        PID         STATUS      COMMAND     CREATED
0462374057   bird        35641       running     top         8000-04-04 00:00:00

# 运行exec进入容器中
 root@lw-Code-01-Series-PF5NU1G  ~/code/go/dockerDemo   main ●  ./main exec bird sh                                                                                                 ✔  ⚡  397  05:50:52
{"level":"info","msg":"container pid 35641","time":"2022-04-08T05:51:02+08:00"}
{"level":"info","msg":"command sh","time":"2022-04-08T05:51:02+08:00"}
got mydocker_pid=35641
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 -ef
PID   USER     TIME  COMMAND
    1 root      0:00 top
    7 root      0:00 sh
    8 root      0:00 ps -ef
/ # exit

# 运行一个立即退出的命令,可以看到也成功输出
 root@lw-Code-01-Series-PF5NU1G  ~/code/go/dockerDemo   main ●  ./main exec bird "ls -l"                                                                                            ✔  ⚡  398  05:51:28
{"level":"info","msg":"container pid 35641","time":"2022-04-08T05:51:41+08:00"}
{"level":"info","msg":"command ls -l","time":"2022-04-08T05:51:41+08:00"}
got mydocker_pid=35641
got mydocker_cmd=ls -l
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
total 4
drwxr-xr-x   15 root     root          4096 Apr  7 21:50 bird