使用 Go 实现容器内进程执行功能:mydocker exec 详解
功能概述
在之前的内容中,我们已经实现了容器日志查看功能。现在本文将详细介绍如何实现容器内进程执行功能,使运维人员能够进入指定容器内部进行文件查看、程序调试和命令执行等操作。
本实现的核心思路是利用 Linux Namespace 机制,将当前进程加入到目标容器的命名空间中,从而实现对容器内部环境的访问。
技术原理
- 容器进程执行原理
容器执行命令的本质是将当前进程添加到目标容器对应的 Namespace 中,使当前进程能够看到容器内的进程视图、网络配置等信息。
具体实现涉及两个关键步骤:首先根据容器标识获取容器的 PID,进而找到其对应的 Namespace 文件;其次将当前进程切换到这些 Namespace 中。
- setns 系统调用
Linux 提供了 setns 系统调用用于进程加入指定的 Namespace。该调用需要打开 /proc/[pid]/ns/ 目录下的相关文件,然后使当前进程进入对应的 Namespace。
然而在 Go 语言环境下使用 setns 存在一个重要限制:该系统调用要求单线程执行上下文,而 Go Runtime 采用多线程模型。
这是因为 Goroutine 会在底层 OS 线程间随机切换,而非固定绑定某个线程。在 Go 中执行 setns 无法准确确定操作的是哪个线程,结果具有不确定性,因此需要特殊处理。
针对这一问题,社区进行了广泛讨论(参见 Go GitHub issue #14163),但始终没有完美的纯 Go 解决方案。
一个可行的方案是利用 C 语言的构造函数特性。通过 gcc 的 __attribute__((constructor)) 扩展,可以在程序启动前执行特定代码。借助 cgo,Go 程序可以嵌入这段 C 代码,使其在 Go Runtime 启动前执行,从而规避多线程问题。
runc 项目中的 nsenter 功能也采用了类似的实现方式。
- cgo 简介
cgo 是 Go 语言的一个强大特性,允许 Go 程序调用 C 函数和标准库。通过在 Go 源代码中以特定注释格式编写 C 代码,cgo 会自动整合 C 源文件和 Go 文件。
以下是一个简单的 cgo 使用示例:
package main
/*
#include <stdlib.h>
#include <stdio.h>
*/
import "C"
import "fmt"
func main() {
// 调用 C 语言的随机数函数
C.srandom(123)
fmt.Printf("Random: %d\n", C.random())
}
其中 "C" 并不是真正的 Go 标准库包,而是 cgo 创建的特殊命名空间,用于与 C 代码交互。
具体实现
实现分为三个主要部分:首先在 C 语言中实现 setns 核心逻辑;其次通过环境变量控制是否执行 Namespace 切换;最后实现 exec 命令入口。
- Namespace 切换实现
在 nsenter 包中实现 C 代码,利用构造函数特性在 Go Runtime 启动前执行:
//go:build linux && !gccgo
// +build linux,!gccgo
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) {
// 获取容器 PID 环境变量
char *container_pid = getenv("CONTAINER_PID");
if (container_pid == NULL) {
// 未指定 PID,直接退出
return;
}
// 获取要执行的命令
char *exec_cmd = getenv("EXEC_CMD");
if (exec_cmd == NULL) {
// 未指定命令,直接退出
return;
}
// 需要进入的 5 种 Namespace 类型
char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt" };
char nspath[256];
for (int i = 0; i < 5; i++) {
// 构造 Namespace 文件路径
snprintf(nspath, sizeof(nspath), "/proc/%s/ns/%s", container_pid, namespaces[i]);
int fd = open(nspath, O_RDONLY);
if (fd < 0) {
continue;
}
// 执行 setns 系统调用
if (setns(fd, 0) == -1) {
fprintf(stderr, "setns failed on %s: %s\n", namespaces[i], strerror(errno));
}
close(fd);
}
// 在目标 Namespace 中执行指定命令
system(exec_cmd);
exit(0);
}
*/
import "C"
这段代码的关键点在于:
- 使用构造函数属性确保在 Go Runtime 启动前执行,规避多线程问题
- 通过环境变量控制执行流程,避免影响其他命令
- 遍历 5 种 Namespace 并执行 setns 操作
- 执行完命令后立即退出
在 Go 代码中需要导入该包才能触发构造函数:
import (
_ "mydocker/nsenter"
)
- Exec 命令实现
在命令文件中添加 exec 子命令:
var execCommand = cli.Command{
Name: "exec",
Usage: "在指定容器中执行命令",
Action: func(context *cli.Context) error {
// 防止重复执行
if os.Getenv(EnvContainerPid) != "" {
log.Infof("already in namespace, skip exec")
return nil
}
// 验证参数数量
if len(context.Args()) < 2 {
return fmt.Errorf("请提供容器名称和执行命令")
}
containerName := context.Args().Get(0)
var cmdArgs []string
for _, arg := range context.Args().Tail() {
cmdArgs = append(cmdArgs, arg)
}
ExecInContainer(containerName, cmdArgs)
return nil
},
}
将命令注册到主程序:
func main() {
app.Commands = []cli.Command{
initCommand,
runCommand,
commitCommand,
listCommand,
logCommand,
execCommand, // 添加 exec 命令
}
// ...
}
- ExecInContainer 函数
exec 命令的核心实现逻辑:
const (
EnvContainerPid = "CONTAINER_PID"
EnvExecCmd = "EXEC_CMD"
)
func ExecInContainer(containerId string, cmdArray []string) {
// 根据容器名称获取对应的 PID
pid, err := findPidByContainerName(containerId)
if err != nil {
log.Errorf("获取容器 PID 失败: %v", err)
return
}
// 创建新进程执行自身
cmd := exec.Command("/proc/self/exe", "exec")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 拼接命令为字符串
cmdStr := strings.Join(cmdArray, " ")
log.Infof("容器 PID: %s, 命令: %s", pid, cmdStr)
// 设置环境变量传递给 C 代码
_ = os.Setenv(EnvContainerPid, pid)
_ = os.Setenv(EnvExecCmd, cmdStr)
if err = cmd.Run(); err != nil {
log.Errorf("在容器中执行命令失败: %v", err)
}
}
获取容器 PID 的实现:
func findPidByContainerName(containerId string) (string, error) {
// 构造配置文件路径
configPath := fmt.Sprintf("%s/%s/config.json",
container.InfoLocation(containerId),
container.ConfigFileName)
// 读取并解析配置
data, err := os.ReadFile(configPath)
if err != nil {
return "", err
}
var info container.Info
if err = json.Unmarshal(data, &info); err != nil {
return "", err
}
return info.Pid, nil
}
整个执行流程如下:
- 用户执行
mydocker exec 容器ID 命令 - 程序根据容器ID找到对应的 PID
- fork 一个新进程,通过环境变量传递 PID 和命令
- 新进程启动时,nsenter 包的 C 代码检测到环境变量
- C 代码执行 setns,将进程加入目标容器的 Namespace
- 在目标 Namespace 中执行用户指定的命令
- 命令执行完毕后退出
测试验证
首先编译程序并启动一个后台容器:
root@host:~/mydocker# go build .
root@host:~/mydocker# ./mydocker run -d -name test top
查看容器状态:
root@host:~/mydocker# ./mydocker ps
ID NAME PID STATUS COMMAND CREATED
2147624410 test 180358 running top 2024-01-30 09:48:33
执行 exec 命令进入容器:
root@host:~/mydocker# ./mydocker exec 2147624410 sh
{"level":"info","msg":"容器 PID: 180358, 命令: sh","time":"2024-01-30T09:48:42+08:00"}
got CONTAINER_PID=180358
got EXEC_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
6 root 0:00 sh
7 root 0:00 ps -ef
可以看到容器内 PID 为 1 的进程是 top,说明我们已经成功进入容器内部。
总结
本文实现了 mydocker exec 功能,核心要点如下:
- 利用 setns 系统调用将进程加入目标容器的 Namespace
- 通过 cgo 配合构造函数特性,规避 Go Runtime 多线程与 setns 单线程上下文的冲突
- 使用环境变量传递参数并控制执行流程,避免重复执行
- 整体实现思路与 Docker 官方实现基本一致
完整代码可在 GitHub 仓库的 feat-exec 分支查看。