使用 GoLang 从零开始写一个 Docker(容器进阶篇/完结篇?)– 《自己动手写 Docker》读书笔记

大概是最后一篇了,后面的容器网络部分看心情填坑吧。
顺便附上学习时提交的源代码

系列:

  1. 使用 GoLang 从零开始写一个 Docker(概念篇)– 《自己动手写 Docker》读书笔记
  2. 使用 GoLang 从零开始写一个 Docker(容器篇)– 《自己动手写 Docker》读书笔记
  3. 使用 GoLang 从零开始写一个 Docker(镜像篇)– 《自己动手写 Docker》读书笔记
  4. 使用 GoLang 从零开始写一个 Docker(容器进阶篇/完结篇?)– 《自己动手写 Docker》读书笔记

本文是系列的第四篇–容器进阶篇。

前面几篇文章下来,一个非常简单的 docker 其实已经写完了。但是依旧缺少很多东西。比如后台运行,查看输出,停止和再运行容器,等等我们常用的 docker 命令。虽然我们本来也就准备写一个简单的 docker,但现在的结果实在是过于简单,还得完善一下才能看。

1. 容器的后台运行

  1. 在 commands.go 里添加 -d 标签:
  2. 判断 -it 和 -d 不能同时出现。
  3. 改变 run.go,只有在 -it 的时候,才会 wait。
if tty {
        if err := initProcess.Wait(); err != nil {
                logrus.Error(err)
        }
        imagesRootURL := "./images/"
        mntURL := "./mnt/"
        container.DeleteWorkspace(imagesRootURL, mntURL, volume)
}
复制代码

2. 查看容器(docker ps)

之前我们一直都没有给容器名字,这次一次性给足容器各种状态信息。因为这一次我们要实现 docker ps。

2.1 生成容器 ID

  1. 生成随机字母或数字构成的 ID
// run.go
func getContainerID(n int) string {
	candidate := "abcdefghijklmnopqrstuvwxyz1234567890"
	rand.Seed(time.Now().UnixNano())

	res := make([]byte, n)
	for i := range res {
		res[i] = candidate[rand.Intn(len(candidate))]
	}
	return string(res)
}
复制代码

2.2 container/containerInfo.go

定义一些常量和 ContainerInfo 结构。

package container

type ContainerInfo struct {
	Pid         string `json:"pid"`
	Id          string `json:"id"`
	Name        string `json:"name"`
	Command     string `json:"command"`
	CreatedTime string `json:"created_time"`
	Status      string `json:"status"`
}

var (
	RUNNING string = "running"
	STOP string = "stopped"
	Exit string = "exited"
	DefaultInfoLocation = "/var/run/oyishyi-docker/%s/"
	ConfigName = "config.json"
)
复制代码

2.3 run.go

  1. 添加 recordContainerInfo 函数:

func recordContainerInfo(containerPID int, containerCmd []string, containerName string) (string, error) {
	pid := containerPID
	id := getContainerID(10)
	name := containerName
	cmd := strings.Join(containerCmd, " ")
	createdTime := time.Now().Format("2021-06-23 15:04:05")
	if name == "" {
		name = id
	}
	
	containerInfo := &container.ContainerInfo{
		Pid:        strconv.Itoa(pid),
		Id:          id,
		Name:        name,
		Command:     cmd,
		CreatedTime: createdTime,
		Status:      container.RUNNING,
	}
	
	// create json string
	jsonBytes, err := json.Marshal(containerInfo)
	jsonStr := string(jsonBytes)
	if err != nil {
		return "", err
	}
	// create path
	dirPath := fmt.Sprintf(container.DefaultInfoLocation, name)
	if err:= os.Mkdir(dirPath, 0622); err != nil {
		logrus.Errorf("mkdir %s fails %v", dirPath, err)
		return "", err
	}
	// create file
	filePath := dirPath + "/" + container.ConfigName
	file, err := os.Create(filePath)
	defer file.Close()
	if err != nil {
		logrus.Errorf("create file %s fails %v", filePath, err)
		return "", err
	}
	// write json string
	if _, err := file.WriteString(jsonStr); err != nil {
		logrus.Errorf("write file %s fails: %v", filePath, err)
		return "", err
	}
	return name, nil
}
复制代码
  1. 添加 deleteContainerInfo 函数:
func deleteContainerInfo(containerName string) {
	dirPath := fmt.Sprintf(container.DefaultInfoLocation, containerName)
	if err:=os.RemoveAll(dirPath); err != nil {
		logrus.Errorf("delete container config %s fails %v", dirPath, err)
	}
}
复制代码
  1. 在 Run 函数中使用以上两个函数。
/ This is the function what `docker run` will call
func Run(tty bool, containerCmd []string, res *subsystems.ResourceConfig, volume string, containerName string) {

	// this is "docker init <containerCmd>"
	initProcess, writePipe := container.NewProcess(tty, volume)
	if initProcess == nil {
		logrus.Error("create init process fails")
	}

	// start the init process
	if err := initProcess.Start(); err != nil{
		logrus.Error(err)
	}

	// record container info
	containerName, err := recordContainerInfo(initProcess.Process.Pid, containerCmd, containerName)
	if err != nil {
		logrus.Errorf("record container %s fails: %v", containerName, err)
		return
	}
	
	// create container manager to control resource config on all hierarchies
	// this is the cgroupPath
	cm := cgroups.NewCgroupManager("oyishyi-docker-first-cgroup")
	defer cm.Remove()
	if err := cm.Set(res); err != nil {
		logrus.Error(err)
	}
	if err := cm.AddProcess(initProcess.Process.Pid); err != nil {
		logrus.Error(err)
	}

	// send command to write side
	// will close the plug
	sendInitCommand(containerCmd, writePipe)

	if tty {
		if err := initProcess.Wait(); err != nil {
			logrus.Error(err)
		}
		imagesRootURL := "./images/"
		mntURL := "./mnt/"
		container.DeleteWorkspace(imagesRootURL, mntURL, volume)
		deleteContainerInfo(containerName)
	}

	os.Exit(-1)
}
复制代码

2.4 docker ps

就像所有的命令一样,我们首先得生成一个 psCommand 变量并加入应用中。
其中关于列出容器信息的函数如下:
其实原理很简单,就只是读取文件罢了。非要说有什么特别的地方,那就是使用 tabwriter 来格式化输出。

package dockerCommands

import (
	"encoding/json"
	"fmt"
	"github.com/oyishyi/docker/container"
	"github.com/sirupsen/logrus"
	"io/ioutil"
	"os"
	"strings"
	"text/tabwriter"
)

func ListContainers() {
	dirPath := strings.Split(container.DefaultInfoLocation, "%s")[0]
	dirs, err := ioutil.ReadDir(dirPath)
	if err != nil {
		logrus.Errorf("read dir %s fails: %v", dirPath, err)
		return
	}

	// stored in a slice
	var containerInfos []*container.ContainerInfo
	for _, dir := range dirs {
		tempContainer, err := getContainerInfo(dir)
		if err != nil {
			logrus.Error(err)
		}
		containerInfos = append(containerInfos, tempContainer)
	}

	// output
	w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
	// store output in the writer
	fmt.Fprint(w, "ID\tNAME\tPID\tSTATUS\tCOMMAND\tCREATED\n")
	for _, containerInfo := range containerInfos {
		fmt.Fprintf(
			w,
			"%s\t%s\t%s\t%s\t%s\t%s\n",
			containerInfo.Id,
			containerInfo.Name,
			containerInfo.Pid,
			containerInfo.Status,
			containerInfo.Command,
			containerInfo.CreatedTime,
		)
	}
	// print the output stored onto the os.stdout
	if err := w.Flush(); err != nil {
		logrus.Errorf("flush error: %v", err)
		return
	}
}

func getContainerInfo(dir os.FileInfo)(*container.ContainerInfo, error) {
	containerName := dir.Name()
	configPath := fmt.Sprintf(container.DefaultInfoLocation, containerName) + container.ConfigName
	content, err := ioutil.ReadFile(configPath)
	if err != nil {
		return nil, fmt.Errorf("read container config %s fails: %v", configPath, err)
	}
	var containerInfo container.ContainerInfo
	if err := json.Unmarshal(content, &containerInfo); err != nil {
		return nil, fmt.Errorf("unmarshal json file fails: %v", err)
	}

	return &containerInfo, nil
}
复制代码

3. 查看容器日志

容器在后台运行的时候,看不到输出,但是很明显我们需要记录后台的输出。
因此进如下改动:

  1. 生成 init 进程的时候生成 log 文件。
  2. 运行 docker logs 的时候读取文件。

3.1 containerProcess.go

当不提供 tty 而是 detach 的时候,container.NewProcess 函数的代码如下:

if tty {
        // if tty
        // attach to stdio
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
} else {
        // if detach
        // generate log file
        dirPath := fmt.Sprintf(DefaultInfoLocation, containerName)
        if err:= os.MkdirAll(dirPath, 0622); err != nil {
                logrus.Errorf("mkdir %s fails %v", dirPath, err)
                return nil, nil
        }
        filePath := dirPath + ContainerLogName
        file, err := os.Create(filePath)
        if err != nil {
                logrus.Errorf("create file %s fails: %v", filePath, err)
                return nil, nil
        }
        // 注意这一行,就是把 stdout 全部输入至 log 文件
        cmd.Stdout = file
}
复制代码

3.2 dockerCommands/logs.go

package dockerCommands

import (
	"fmt"
	"github.com/oyishyi/docker/container"
	"github.com/sirupsen/logrus"
	"io/ioutil"
	"os"
)

func LogContainer(containerName string) {
	// find the dir path
	dirPath := fmt.Sprintf(container.DefaultInfoLocation, containerName)
	filePath := dirPath + container.ContainerLogName

	// open the log file
	file, err := os.Open(filePath)
	defer file.Close()
	if err != nil {
		logrus.Errorf("open file %s fails: %v", filePath, err)
		return
	}

	// read the file content
	content, err := ioutil.ReadAll(file)
	if err != nil {
		logrus.Errorf("read file %s fails: %v", filePath, err)
		return
	}

	// print log to stdout
	fmt.Fprint(os.Stdout, string(content))
}
复制代码

4. 实现 docker exec

想要实现 docker exec 就需要进入 namespace,就要使用一个 setns 的系统调用,但可惜这个系统调用不能在 go 中使用。因为 golang 进程一定是多线程的,而 setns 是不允许一个多线程的进程调用的。因此就要用到 cgo,使其在 c 中运行而不是在 go 中运行。

4.1 cgo 代码

这里我们使用一个会在 go 程序编译前运行的 cgo,以免由于多线程而被 setns 拒绝。

  1. 这里我们新建一个文件夹,里面存放该 cgo 代码文件。
  2. 因为我们要把这串代码作为一个包导入。
// setns/setns.go
package setns

/*
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>

// __attribut__((constructor)) means this function will be called right after the package is imported
// in other words, this function will run before the program run
__attribute__((constructor)) void enter_namespace(void) {
	char *mydocker_pid;
	// get pid from env
	mydocker_pid = getenv("mydocker_pid");
	if (mydocker_pid) {
		fprintf(stdout, "got env: mydocker_pid=%s\n", mydocker_pid);
	} else {
		// using env to control whether run this bunch of codes
		// if env not exist, than this function will not run
		// so that command other than "docker exec" will not run this bunch of cgo code
		fprintf(stdout, "missing env: mydocker_pid\n");
		return;
	}
	char *mydocker_cmd;
	mydocker_cmd = getenv("mydocker_cmd");
	if (mydocker_cmd) {
		fprintf(stdout, "got env: mydocker_cmd=%s\n", mydocker_cmd);
	} else {
		fprintf(stdout, "missing env: mydocker_cmd\n");
		return;
	}


	// five namespaces that need to enter
	char *namespaces[] = {"ipc", "uts", "net", "pid", "mnt"};

	char nspath[1024];

	int i; // old c compiler style
	for (i = 0; i < 5; i++) {
	    sprintf(nspath, "/proc/%s/ns/%s", mydocker_pid, namespaces[i]);
	    int fd = open(nspath, O_RDONLY);
	    // call setns to enter namespace
	    if (setns(fd, 0) == -1) {
	        fprintf(stderr, "setns %s fails: %s\n", namespaces[i], strerror(errno));
	    } else {
	        fprintf(stdout, "setns %s succeed\n", namespaces[i]);
	    }
	    close(fd);
	}
	// after enter the namespaces, run the cmd
	int res = system(mydocker_cmd);
	exit(0);
	return;
}

*/
import "C"
复制代码

以上的代码做的就是单纯调用 setns。pid 和 command 从环境变量里提取。
这串代码会被运行两次,第一次失败,第二次成功。
为什么会这样,我们接着看。

4.2 commands.go

我们来看看 execCommand 的代码:

  1. 主要注意 action 的前几行代码。
    • 第一次运行 docker exec 的时候,这段代码不会运行。
    • 因为环境变量明显还没有设置好。
    • 然后在 dockerCommands.ExecContainer(containerName, containerCmd) 中,会再次调用 /proc/self/exe exec 也就是再次使用 docker exec,只不过这一次环境变量已经设置好了,这一次 cgo 代码就会运行了。
var execCommand = cli.Command{
	Name:  "exec",
	Usage: "enter into a running container",
	Action: func(context *cli.Context) error {

		// won't execute on the first call
		// execute when callback
		if os.Getenv(dockerCommands.ENV_EXEC_PID) != "" {
			logrus.Infof("exec pid %d", os.Getppid())
			return nil
		}

		args := context.Args()
		if args.Len() < 2 {
			logrus.Errorf("exec what?")
			return nil
		}
		containerName := args.Get(0)
		containerCmd := make([]string, args.Len()-1)
		for index, cmd := range args.Tail() {
			containerCmd[index] = cmd
		}
		dockerCommands.ExecContainer(containerName, containerCmd)
		return nil
	},
}
复制代码

4.3 dockerCommands/exec.go

这一段核心代码主要做以下事情:

  1. 导入 ##4.1 中我们创建的那个 setns 包,重命名为 —,也就是说只导入不使用。
  2. 根据容器名称从 config(写 docker ps 时候设置的那个) 中获取容器进程的 pid。
  3. 创建 /proc/self/exe exec 也就是 docker exec 命令。
  4. 设置环境变量。
  5. 运行该命令。
  6. (在该命令再次运行时,cgo 也运行了)
package dockerCommands

import (
	"encoding/json"
	"fmt"
	"github.com/oyishyi/docker/container"
	_ "github.com/oyishyi/docker/setns"
	"github.com/sirupsen/logrus"
	"io/ioutil"
	"os"
	"os/exec"
	"strings"
)

const ENV_EXEC_PID = "mydocker_pid"
const ENV_EXEC_CMD = "mydocker_cmd"

func ExecContainer(containerName string, containerCmd []string) {
	pid, err := getContainerPidByName(containerName)
	if err != nil {
		logrus.Errorf("get container pid of %s fails: %v", containerName, err)
		return
	}

	containerCmdStr := strings.Join(containerCmd, " ")
	logrus.Infof("container pid: %s", pid)
	logrus.Infof("container cmd: %s", containerCmdStr)

	// run docker exec again
	// with env this time, so that bunch of cgo codes will execute
	cmd := exec.Command("/proc/self/exe", "exec")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	//cmd.Env = []string{fmt.Sprintf("%s=%s", ENV_EXEC_PID, pid), fmt.Sprintf("%s=%s", ENV_EXEC_CMD, containerCmdStr)}

	os.Setenv(ENV_EXEC_PID, pid)
	os.Setenv(ENV_EXEC_CMD, containerCmdStr)

	if err := cmd.Run(); err != nil {
		logrus.Errorf("run the second docker exec with env fails: %v", err)
		return
	}

}

func getContainerPidByName(containerName string) (string, error) {
	dirPath := fmt.Sprintf(container.DefaultInfoLocation, containerName)
	configPath := dirPath + container.ConfigName

	contentBytes, err := ioutil.ReadFile(configPath)
	if err != nil {
		return "", err
	}

	var config container.ContainerInfo
	if err := json.Unmarshal(contentBytes, &config); err != nil {
		return "", err
	}

	return config.Pid, nil
}
复制代码

5. docker stop 停止容器

之前我们创建了那么多的后台容器,但是没法停止它们。

5.1 传统艺能

添加 cli.command 并导入 cli.App。
没什么好说的,你能看到这里大概已经做过无数遍这种事了。

var stopCommand = cli.Command{
	Name: "stop",
	Usage: "stop a container",
	Action: func(context *cli.Context) error {
		args := context.Args()
		if args.Len() < 1 {
			logrus.Errorf("stop what?")
			return nil
		}
		containerName := args.Get(0)
		dockerCommands.StopContainer(containerName)
		return nil
	},
}
复制代码

5.2 dockerCommand/stop.go

  1. 找到 pid
  2. 停止 pid
  3. 获取 containerInfo 并改变
  4. 将改变后的 containerInfo 写入 config.json
package dockerCommands

import (
	"encoding/json"
	"fmt"
	"github.com/oyishyi/docker/container"
	"github.com/sirupsen/logrus"
	"io/ioutil"
	"strconv"
	"syscall"
)

func StopContainer(containerName string) {
	containerInfo, err := container.GetContainerInfoByName(containerName)
	if err != nil {
		logrus.Errorf("get containerInfo of %s fails: %v", containerName, err)
		return
	}

	pid, err := strconv.Atoi(containerInfo.Pid)
	if err != nil {
		logrus.Errorf("convert Pid to int fails: %v", err)
		return
	}

	if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
		logrus.Errorf("stopping container fais: %v", err)
		return
	}

	containerInfo.Status = container.STOP
	containerInfo.Pid = ""
	containerInfoBytes, err := json.Marshal(containerInfo)
	if err != nil {
		logrus.Errorf("encode containerInfo to UTF-8(bytes) fails: %v", err)
		return
	}
	dirPath := fmt.Sprintf(container.DefaultInfoLocation, containerName)
	configFilePath := dirPath + container.ConfigName
	if err := ioutil.WriteFile(configFilePath, containerInfoBytes, 0622); err != nil {
		logrus.Errorf("change config file %s fails: %v", configFilePath, err)
		return
	}
	return
}
复制代码

6. docker rm 删除容器

就是把 config 文件夹删除。

// commands.go
var removeCommand = cli.Command{
	Name: "rm",
	Usage: "delete a container",
	Action: func(context *cli.Context) error {
		args := context.Args()
		if args.Len() < 1 {
			logrus.Errorf("remove what?")
			return nil
		}
		containerName := args.Get(0)
		dockerCommands.RemoveContainer(containerName)
		return nil
	},
}
复制代码
// dockerCommands/remove.go
package dockerCommands

import (
	"fmt"
	"github.com/oyishyi/docker/container"
	"github.com/sirupsen/logrus"
	"os"
)

func RemoveContainer(containerName string) {
	containerInfo, err := container.GetContainerInfoByName(containerName)
	if err != nil {
		logrus.Errorf("get container info of %s fails: %v", containerName, err)
		return
	}

	if containerInfo.Status != container.STOP {
		logrus.Errorf("couldn't remove not stopped container")
		return
	}

	dirPath := fmt.Sprintf(container.DefaultInfoLocation, containerName)
	if err:= os.RemoveAll(dirPath); err != nil {
		logrus.Errorf("remove dir %s fails: %v", dirPath, err)
		return
	}
}
复制代码

7. 修复之前的 bug

但凡你运行过之前的代码,就会发现一堆 bug。比如什么后台容器怎么没有 umount 啊,什么镜像都没个名字都是 busybox 啊,等等,这一小节就来修复这些问题。

7.1 commands.go

更改 runCommand 的 Action 函数:
将 imageName 分离出来并传入 dockerCommand.Run 函数。
image.png

7.2 dockerCommand/run.go

太多了,具体看书吧。不过可以说一下一些可能会出现的问题:
mount aufs 可能会出现一些问题,但是抛出的错误又很难理解,这个时候使用 dmesg 便可以查看具体错误,非常好用。

8. docker images

书上没有这一命令,我自己添加的。有了之前的经验,这一命令简直就是小菜。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享