大概是最后一篇了,后面的容器网络部分看心情填坑吧。
顺便附上学习时提交的源代码。
系列:
- 使用 GoLang 从零开始写一个 Docker(概念篇)– 《自己动手写 Docker》读书笔记
- 使用 GoLang 从零开始写一个 Docker(容器篇)– 《自己动手写 Docker》读书笔记
- 使用 GoLang 从零开始写一个 Docker(镜像篇)– 《自己动手写 Docker》读书笔记
- 使用 GoLang 从零开始写一个 Docker(容器进阶篇/完结篇?)– 《自己动手写 Docker》读书笔记
本文是系列的第四篇–容器进阶篇。
前面几篇文章下来,一个非常简单的 docker 其实已经写完了。但是依旧缺少很多东西。比如后台运行,查看输出,停止和再运行容器,等等我们常用的 docker 命令。虽然我们本来也就准备写一个简单的 docker,但现在的结果实在是过于简单,还得完善一下才能看。
1. 容器的后台运行
- 在 commands.go 里添加 -d 标签:
- 判断 -it 和 -d 不能同时出现。
- 改变 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
- 生成随机字母或数字构成的 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
- 添加 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
}
复制代码
- 添加 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)
}
}
复制代码
- 在 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. 查看容器日志
容器在后台运行的时候,看不到输出,但是很明显我们需要记录后台的输出。
因此进如下改动:
- 生成 init 进程的时候生成 log 文件。
- 运行 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 拒绝。
- 这里我们新建一个文件夹,里面存放该 cgo 代码文件。
- 因为我们要把这串代码作为一个包导入。
// 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 的代码:
- 主要注意 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
这一段核心代码主要做以下事情:
- 导入 ##4.1 中我们创建的那个 setns 包,重命名为 —,也就是说只导入不使用。
- 根据容器名称从 config(写 docker ps 时候设置的那个) 中获取容器进程的 pid。
- 创建
/proc/self/exe exec
也就是 docker exec 命令。 - 设置环境变量。
- 运行该命令。
- (在该命令再次运行时,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
- 找到 pid
- 停止 pid
- 获取 containerInfo 并改变
- 将改变后的 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 函数。
7.2 dockerCommand/run.go
太多了,具体看书吧。不过可以说一下一些可能会出现的问题:
mount aufs 可能会出现一些问题,但是抛出的错误又很难理解,这个时候使用 dmesg 便可以查看具体错误,非常好用。
8. docker images
书上没有这一命令,我自己添加的。有了之前的经验,这一命令简直就是小菜。