從零開始寫 Docker(十一)---實現 mydocker exec 進入容器內部

探索云原生發表於2024-04-16

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}

相關文章