自己动手写Docker学习笔记
2023/5/22 14:22:14
本文主要是介绍自己动手写Docker学习笔记,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
零、前言
本文为《自己动手写 Docker》的学习,对于各位学习 docker 的同学非常友好,非常建议买一本来学习。
书中有摘录书中的一些知识点,不过限于篇幅,没有全部摘录 (主要也是懒)。项目仓库地址为:JaydenChang/simple-docker (github.com)
一、概念篇
1. 基础知识
1.1 kernel
kernel (内核) 指大多数操作系统的核心部分,由操作系统中用于管理存储器、文件、外设和系统资源的部分组成。操作系统内核通常运行进程,并提供进程间通信。
1.2 namespace
namespace 是 Linux 自带的功能来隔离内核资源的机制。
Linux 中有 6 种 namespace
1.2.1 UTS Namespace
UTS,UNIX Time Sharing,用于隔离 nodeName (主机名) 和 domainName (域名)。在该 Namespace 下修改 hostname 不会影相其他的 Namespace。
1.2.2 IPC Namespace
IPC,Inter-Process Communication (进程间通讯),用于隔离 System V IPC 和 POSIX message queues (一种消息队列,结构为链表)。
两种 IPC 本质上差不多,System V IPC 随内核持续,POSIX IPC 随进程持续。
1.2.3 PID Namespace
PID,Process IDs,用于隔绝 PID。同样的进程,在不同 Namespace 里是不同的 PID。新建的 PID Namespace 里第一个 PID 是1。
1.2.4 Mount Namespace
用于隔绝文件系统,挂载了某一目录,在这个 Namespace 下就会把这个目录当作根目录,我们看到的文件系统树就会以这个目录为根目录。
mount 操作本身不会影响到外部,docker 中的 volume 也用到了这个特性。
1.2.5 User Namespace
用于 隔离用户组 ID。
1.2.6 Network Namespace
每个 Namespace 都有一套自己的网络设备,可以使用相同的端口号,映射到 host 的不同端口。
1.3 Linux Cgroups
Cgroups 全称为 Control Groups,是 Linux 内核提供的物理资源隔离机制。
1.3.1 Cgroups 的三个组件
- cgroup:一个 cgroup 包含一组进程,且可以有 subsystem 的参数配置,以关联一组 subsystem。
- subsystem:一组资源控制的模块。
- hierarchy:把一组 cgroups 串成一个树状结构,以提供继承的功能。
1.3.2 这三个组件的关联
Linux 有一些限制:
- 首先,创建一个 hierarchy。这个 hierarchy 有一个 cgroup 根节点,所有的进程都会被加到这个根节点上,所有在这个 hierarchy 上创建的节点都是这个根节点的子节点。
- 一个 subsystem 只能加到一个 hierarchy 上。
- 但是一个 subsystem 可以加到同一个 hierarchy 的多个 cgroups 上。
- 一个 hierarchy 可以有多个 subsystem。
- 一个进程可以在多个 cgroups 中,但是这些 cgroup 必须在不同的 hierarchy 中。
- 一个进程 fork 出子进程时,父进程和子进程属于同一个 cgroup。
1.3.3 cgroup 和 subsystem 和 hierarchy 之间的联系
- hierarchy 就是一颗 cgroups 树,由多个 cgroups 构成。每一个 hierarchy 建立时会包含 所有 的Linux 进程。这里的 “所有” 就是当前系统运行中的所有进程,每个 hierarchy 上的全部进程都是一样的,不同的 hierarchy 指的其实只是不同的分组方式,这也是为什么一个进程可以存在于多个 hierarchy 上;准确来说,一个进程一定会同时存在于所有的 hierarchy 上,区别在被放在的 cgroup 可能会有差异。
- Linux 的 subsystem 只有一个的说法,没有一种的说法,也就是在一个 hierarchy 上使用了 memory subsystem,那么在其他 hierarchy 就不能使用 memory subsystem 了。
- subsystem 是一种资源控制器,有很多个 subsystem,每个 subsystem 控制不同的资源。subsystem 和 cgroups 关联。新建一个 cgroups 文件夹时,里面会自动生成一堆配置文件,那个就是 subsystem 配置文件。但
subsystem 配置文件
不是subsystem
,就像.git
不是git
一样,就像没安装 git 也可以从别人那里获得.git
文件夹,只是不能用罢了。subsystem 配置文件
也是如此,新建一个 cgroup 就会生成cgroup 配置文件
,但并不代表你关联了一个 subsystem。只有当改变了一个cgroup 配置文件
,里面要限制某种资源时,就会自动关联到这个被限制的资源所对应的 subsystem 上。 - 假设我的 Linux 有 12 个 subsystem,也就是说我最多只能建 12 个 hierarchy (不加 subsystem 的情况下可以建更多 hierarchy,这样 cgroup 就变成纯分组使用)。每一个 hierarchy 上一个 subsystem。如果在某个 hierarchy 放多个 subsystem,能建立的 hierarchy就更少了。
- subsystem 和 cgroup 是关联的,不是和 hierarchy 关联的,但经常看到有人说把某个 subsystem 和某个 hierarchy 关联。实质上一般指的是 hierarchy 中的某一个 cgroup 或多个 cgroup 关联。
1.3.4 cgroup 的 kernel 接口
kernel 接口,就是在 Linux 上调用 api 来控制 cgroups。
-
首先创建一个 hierarchy,而 hierarchy 要挂载到一个目录上,这里创建一个目录:
mkdir hierarchy-test
-
然后挂载:
sudo mount -t cgroup -o none,name=hierarchy-test hierarchy-test ./hierarchy-test
-
可以在这个目录下看到一大堆文件,这些文件就是 cgroup 根节点的配置。
-
然后在这个目录下创建新的空目录,会发现,新的目录里也会有很多 cgroup 配置文件,这些目录已成为 cgroup 根节点的子节点 cgroup。
. ├── cgroup.clone_children ├── cgroup.procs ├── cgroup.sane_behavior ├── notify_on_release ├── release_agent ├── tasks └── temp # 这是新创建的文件夹 ├── cgroup.clone_children ├── cgroup.procs ├── notify_on_release └── tasks
-
在 cgroup 中添加和移动进程:系统的所有进程都会被放到根节点中,可以根据需要移动进程:
-
只需将进程 ID 写到对应的 cgroup 的 tasks 文件即可。
sudo sh -c "echo $$ >> tasks"
该命令就是将当前终端的这个进程加到当前所在的 cgroup 的目录的 tasks 文件中。
-
-
通过 subsystem 限制 cgroup 中进程的资源:
- 上面的方法有个问题,因为这个 hierarchy 没有关联到任何 subsystem,因此不能够控制资源。
- 不过其实系统会自动给每个 subsystem 创建一个 hierarchy,所以通过控制这个 hierarchy 里的配置,可以达到控制进程的目的。
1.3.5 docker 是怎么使用 Cgroups 的
docker 会给每个容器创建一个 cgroup,再限制该 cgroup 的资源,从而达到限制容器的资源的作用。
其实写了这么多,综合上面的前置知识,不难猜测,docker 的原理是:隔离主机。
1.4 Demo
package main import ( "fmt" "io/ioutil" "os" "os/exec" "path" "strconv" "syscall" ) const cgroupMemoryHierarchyCount = "/sys/fs/cgroup/memory" func main() { // 第二次会运行这段代码 // 这段代码运行的地方就可以看做是一个简易的容器 // 这里只是对进程进行了隔离 // 但是可以看到 pid 已经变成了 1,因为我们有 PID Namespace if os.Args[0] == "/proc/self/exe" { fmt.Printf("current pid %d\n", syscall.Getpid()) cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`) cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Println(err) os.Exit(1) } } // 第一次运行这段 // **command 设置为当前进程,也就是这个 go 程序本身,也就是说 cmd.Start() 会再次运行该程序 cmd := exec.Command("/proc/self/exe") // 在 start 之前,修改 cmd 的各种配置,也就是第二次运行这个程序的时候的配置 // 创建 namespace cmd.SysProcAttr = &syscall.SysProcAttr { Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // 因为之后要打印 process 的 id,所以用 start // 如果这里用 run 的话,那么 else 里的代码永远不会执行,因为 stress 永远不会结束 if err := cmd.Start(); err != nil { fmt.Println("Error", err) os.Exit(1) } else { // 打印 new process id fmt.Printf("%v\n", cmd.Process.Pid) // 接下来三段对 cgroup 操作 // the hierarchy has been already created by linux on the memory subsystem // create a sub cgroup os.Mkdir(path.Join( cgroupMemoryHierarchyCount, "testMemoryLimit", ), 0755) // place container process in this cgroup ioutil.WriteFile(path.Join( cgroupMemoryHierarchyCount, "testMemoryLimit", "tasks", ), []byte(strconv.Itoa(cmd.Process.Pid)), 0644) // restrict the stress process on this cgroup ioutil.WriteFile(path.Join( cgroupMemoryHierarchyCount, "testMemoryLimit", "memory.limit_int_bytes", ), []byte("100m"), 0644) // cmd.Start() 不会等待进程结束,所以需要手动等待 // 如果不加的话,由于主进程结束了,子进程也会被强行结束 cmd.Process.Wait() } }
1.5 UFS
1.5.1 UFS 概念
UFS,Union File System,联合文件系统。docker 在下载一个 image 文件时,会看到一次下载很多个文件,这就是 UFS。它是一种分层、轻量、高性能的文件系统。UFS 类似 git,每次修改文件时,都是一次提交,并有记录,修改都反映在一个新的文件上,而不是修改旧文件。
UFS 允许多个不同目录挂载到同一个虚拟文件系统下,这就是为什么 image 之间可以共享文件,以及继承镜像的原因。
1.5.2 AUFS
AUFS,Advanced Union File System,是 UFS 的一个改动版本。
笔者本身使用的是 WSL 做日常开发,WSL 内核不支持 AUFS,后面会提到更换内核。
1.5.3 docker 和 AUFS
docker 在早期使用 AUFS,直到现在也可以选择作为一种存储驱动类型。
1.5.4 image layer
image 由多层 read-only layer 构成。
当启动一个 container 时,就会在 image 上再加一层 init layer,init layer 也是 read-only 的,用于储存容器的环境配置。此外,docker 还会创建一个 read-write 的 layer,用于执行所有的写操作。
当停止容器时,这个 read-write layer 依然保留,只有删除 container 时才会被删除。
那么,怎么删除旧文件呢?
docker 会在 read-write layer 生成一个 .wh.<fileName>
文件来隐藏要删除的文件。
1.5.5 实现一个 AUFS
我们先创建一个如下的文件夹结构:
. ├── container-layer │ └── container.txt ├── image-layer │ └── image.txt └── mnt
然后挂载到 mnt 文件夹上:
sudo mount -t aufs -o dirs=./container-layer:./image-layer none ./mnt
如果没有手动添加权限的话,默认 dirs 左边第一个文件夹有 write-read 权限,其他都是 read-only。
我们可以发现,imageLayer1 和 writeLayer 的文件出现在 mnt 文件夹下:
. ├── container-layer │ └── container.txt ├── image-layer │ └── image.txt └── mnt ├── container.txt └── image.txt
然后我们修改一下 image.txt 的内容,然后再看看整个目录,会发现,container-layer
目录下多了一个 image.txt
,然后我们看看 container-layer
的 image.txt
的内容,有添加前后的的文字。
也就是说,实际上,当修改某一个 layer 的时候,实际上不会改变这个 layer,而是将其复制到 container-layer 中,然后再修改这个新的文件。
二、容器篇
2. Linux 的 /proc 文件夹
2.1 PID
在 /proc
文件夹下可以看到很多文件夹的名字都是个数字,其实就是个 PID。是 Linux 为每个进程创建的空间。
2.2 一些重要的目录
/proc/N # PID 为 N 的进程 /proc/N/cmdline # 进程的启动命令 /proc/N/cwd # 链接到进程的工作目录 /proc/N/environ # 进程的环境变量列表 /proc/N/exe # 链接到进程的执行命令 /proc/N/fd # 包含进程相关的所有文件描述符 /proc/N/maps # 与进程相关的内存映射信息 /proc/N/mem # 进程持有的内存,不可读 /proc/N/root # 链接到进程的根目录 /proc/N/stat # 进程的状态 /proc/N/statm # 进程的内存状态 /proc/N/status # 比上面两个更可读 /proc/self # 链接到当前正在运行的进程
3. 简单实现
3.1 工具
获取帮助编写 command line app 的工具:
go get github.com/urfave/cli
3.2 实现代码
代码结构:
. ├── command.go ├── container │ └── init.go ├── dockerCommand │ └── run.go ├── go.mod ├── go.sum └── main.go
3.2.1 runCommand
command.go
用于放置各种 command 命令,这里先只写一个 runCommand 命令。
首先用 urfave/cli 创建一个 runCommand 命令:
// command.go var runCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ // integrate -i and -t for convenience &cli.BoolFlag{ Name: "it", Usage: "open an interactive tty(pseudo terminal)", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) == 0 { return errors.New("Run what?") } cmdArray := args.Get(0) // command // check whether type `-it` tty := context.Bool("it") // presudo terminal // 这个函数在下面定义 dockerCommand.Run(tty, cmdArray) return nil }, }
3.2.2 run
上面的 Run 函数在 dockerCommand/run.go
下定义。当运行 docker run
时,实际上主要是 Action 下的这个函数在工作:
// dockerCommand/run.go // This is the function what `docker run` will call func Run(tty bool, cmdArray string) { // this is "docker init <cmdArray>" initProcess := container.NewProcess(tty, cmdArray) // start the init process if err := initProcess.Start(); err != nil{ logrus.Error(err) } initProcess.Wait() os.Exit(-1) }
但其实这个函数做的也只是去跑一个 initProcess。这个 command process 在另一个包里定义。
3.2.3 NewProcess
上面提到的 container.NewProcess
在 container/init.go
里定义:
// container/init.go func NewProcess(tty bool, cmdArray string) *exec.Cmd { // create a new command which run itself // the first arguments is `init` which is the below exported function // so, the <cmd> will be interpret as "docker init <cmdArray>" args := []string{"init", cmdArray} cmd := exec.Command("/proc/self/exe", args...) // new namespaces, thanks to Linux cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET, } // this is what presudo terminal means // link the container's stdio to os if tty { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } return cmd }
这个函数的作用是生成一个新的 command process,但这个 command 是 /proc/self/exe
这个程序本身,也就是,我们最后生成的可执行文件,但这次我们不运行 docker run
,而是 docker init
,这个 init 命令在下面定义。
3.2.4 init
initCommand 和 runCommand 在同一个文件里定义,也是一个 command,但是注意这个 command 不面向用户,只用于协助 runCommand。
// command.go // docker init, but cannot be used by user var initCommand = cli.Command{ Name: "init", Usage: "init a container", Action: func(context *cli.Context) error { logrus.Infof("Start initiating...") cmdArray := context.Args().Get(0) logrus.Infof("container command: %v", cmdArray) return container.InitProcess(cmdArray, nil) }, }
这里使用了 container.InitProcess 函数,这个函数是真正用于容器初始化的函数。
3.2.5 InitProcess
这里的是 InitProcess,也就是容器初始化的步骤。
注意 syscall.Exec 这里:
- 就是
mount /
并指定 private,不然容器里的 proc 会使用外面的 proc,即使在不同 namespace 下。 - 所以如果没有加这一段,其实退出容器后还需要在外面再次 mount proc 才能使用 ps 等命令
// already in container // initiate the container func InitProcess(cmdArray string, args []string) error { defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV // mount if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil { logrus.Errorf("mount / fails: %v", err) return err } // mount proc filesystem syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "") argv := []string{cmdArray} if err := syscall.Exec(cmdArray, argv, os.Environ()); err != nil { logrus.Errorf("mount /proc fails: %v", err) } return nil }
一般来说,我们都是想要这个 cmdArray 作为 PID=1 的进程。but,我们有 initProcess 本身的存在,所以 PID = 1 的其实是 initProcess,那如何让 cmdArray 作为 PID=1 的存在呢?
这里有一个 syscall.Exec 神器,Exec 内部会调用 kernel 的 execve 函数,这个函数会把当前进程上运行的程序替换为另一个程序,这正是我们想要的,在不改变 PID 的情况下,替换程序 (即使 kill PID 为 1 的进程,新创建的进程也会是 PID=2)。
为什么要第一个命令的 PID 为 1?
- 因为这样,退出这个进程后,容器就会因为没有前台进程,而自动退出,这也是 docker 的特性。
4. 给 docker run 增加对容器的资源限制功能
这里要用到 subsystem 的知识。
4.1 subsystem.go
- 根据 subsystem 的特性,和接口很搭。
- 此外再定义一个 ResourceConfig 的类型,用于放置资源控制的配置。
- subsystemInstance 里包括 3 个 subsystem,分别对 memory,cpu,cpushare 进行限制。因为我们只需要对整个容器进行限制,所以这一套 3 个够了。
看到这里,有个 cpu,cpushare,cpuset 等等,有点晕,查了下,有关 CPU 的 cgroup subsystem,这里列举常见的 3 个:
- cpu:经常看到的 cpushares 在其麾下,share 即相对权重的 cpu 调度,用来限制 cgroup 的 cpu 的使用率
- cpuacct:统计 cgroup 的 cpu 使用率
- cpuset:在多核机器上设置 cgroups 可使用的 cpu 核心数和内存
通常前两者可以合体
package subsystems type ResourceConfig struct { MemoryLimit string CPUShare string CPUSet string } type Subsystem interface { // return the name of which type of subsystem Name() string // set a resource limit on a cgroup Set(cgroupPath string, res *ResourceConfig) error // add a processs with the pid to a group AddProcess(cgroupPath string, pid int) error // remove a cgroup RemoveCgroup(cgroupPath string) error } // instance of a subsystems var SubsystemsInstance = []Subsystem{ &CPU{}, &CPUSet{}, &Memory{}, }
4.2 MemorySubsystem
4.2.1 Name()
很简单,返回 “memory” 字符串,表示这个 subsystem 是 memorySubsystem。
func (ms *MemorySubsystem) Name() string { return "memory" }
4.2.2 Set()
Set() 用于对 cgroup 设置资源限制,因此参数为 cgroup 的 path 和 resourceConfig。
- 其中
GetCgroupPath
后面会提及,作用是获取这个 subsystem 所挂载的 hierarchy 上的虚拟文件系统下的 从group 路径。 - 获取到 cgroupPath 在虚拟文件系统中的位置后,只需要写入 "memory.limit_in_bytes" 文件中即可。
// set the memory limit to this cgroup with cgroupPath func (ms *Memory) Set(cgroupPath string, res *ResourceConfig) error { if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, true); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit), 0644); err != nil { return fmt.Errorf("set cgroup memory fail: %v", err) } } return nil }
4.2.3 AddProcess()
- 和上面基本一样,只不过是写到 tasks 里。
- pid 变成 byte slice 之前要用 Itoa 转化一下。
func (ms *Memory) AddProcess(cgroupPath string, pid int) error { if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, false); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil { return fmt.Errorf("cgroup add process fail: %v", err) } } return nil }
4.2.4 RemoveCgroup()
- 使用
os.Remove
可以移除参数所指定的文件或文件夹。 - 这里移除整个 cgroup 文件夹,就等于是删除 cgroup 了。
func (ms *Memory) RemoveCgroup(cgroupPath string) error { if subsystemCgroupPath, err := GetCgroupPath(ms.Name(), cgroupPath, false); err != nil { return err } else { return os.Remove(subsystemCgroupPath) } }
4.3 CPUSubsystem
这里的设计和上面没什么区别,直接贴参考代码
// cpu.go func (c *CPU) Name() string { return "CPUShare" } func (c *CPU) Set(cgroupPath string, res *ResourceConfig) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, true); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "cpu.shares"), []byte(res.CPUShare), 0644); err != nil { return fmt.Errorf("set cpu share limit failed: %s", err) } } return nil } func (c *CPU) AddProcess(cgroupPath string, pid int) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil { return fmt.Errorf("cgroup add cpu process failed: %v", err) } } return nil } func (c *CPU) RemoveCgroup(cgroupPath string) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil { return err } else { return os.Remove(subsystemCgroupPath) } }
4.4 CPUSetSubsystem
// cpuset.go func (c *CPUSet) Name() string { return "CPUSet" } func (c *CPUSet) Set(cgroupPath string, res *ResourceConfig) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, true); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "cpuset.cpus"), []byte(res.CPUSet), 0644); err != nil { return fmt.Errorf("set cgroup cpuset failed: %v", err) } } return nil } func (c *CPUSet) AddProcess(cgroupPath string, pid int) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil { return err } else { if err := ioutil.WriteFile(path.Join(subsystemCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil { return fmt.Errorf("cgroup add cpuset process failed: %v", err) } } return nil } func (c *CPUSet) RemoveCgroup(cgroupPath string) error { if subsystemCgroupPath, err := GetCgroupPath(c.Name(), cgroupPath, false); err != nil { return err } else { return os.Remove(path.Join(subsystemCgroupPath)) } }
4.5 GetCgroupPath()
GetCgroupPath()
用于获取某个 subsystem 所挂载的 hierarchy 上的虚拟文件系统 (挂载后的文件夹) 下的 cgroup 的路径。通过对这个目录的改写来改动 cgroup。
首先我们抛开 cgroup,在此之前我们要知道 这个 hierarchy 的 cgroup 根节点的路径。那可以在 /proc/self/mountinfo
中获取。
下面是一些实现细节:
- 首先定义一个
FindCgroupMountpoint()
来找到 cgroup 的根节点。 - 然后在
GetCgroupPath
将其和 cgroup 的相对路径拼接从而获取 cgroup 的路径。如果autoCreate
为 true 且该路径不存在,那么就新建一个 cgroup。(在 hierarchy 环境下,mkdir 其实会隐式地创建一个 cgroup,其中包括很多配置文件)
点击这里回顾
// as the function name shows, find the root path of hierarchy func FindCgroupMountpoint(subsystemName string) string { f, err := os.Open("/proc/self/mountinfo") // get info about mount relate to current process if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { txt := scanner.Text() fields := strings.Split(txt, " ") // find whether "subsystemName" appear in the last field // if so, then the fifth field is the path for _, opt := range strings.Split(fields[len(fields)-1], ",") { if opt == subsystemName { return fields[4] } } } return "" } // get the absolute path of a cgroup func GetCgroupPath(subsystemName string, cgroupPath string, autoCreate bool) (string, error) { cgroupRootPath := FindCgroupMountpoint(subsystemName) expectedPath := path.Join(cgroupRootPath, cgroupPath) // find the cgroup or create a new cgroup if _, err := os.Stat(expectedPath); err == nil || (autoCreate && os.IsNotExist(err)) { if os.IsNotExist(err) { if err := os.Mkdir(expectedPath, 0755); err != nil { return "", fmt.Errorf("error when create cgroup: %v", err) } } return expectedPath, nil } else { return "", fmt.Errorf("cgroup path error: %v", err) } }
4.6 cgroupsManager.go
- 定义 CgroupManager 类型,其中的 path 要注意是相对路径,相对于 hierarchy 的 root path。所以一个 CgroupManager 是有可能表示多个 cgroups 的,或准确说,和对应的 hierarchy root path 的相对路径一样的多个 cgroups。
- 因为上述原因,
Set()
可能会创建多个 cgroups,如果 subsystems 们在不同的 hierarchy 就会这样。 - 这也是为什么
AddProcess()
和Remove()
要在每个 subsystem 上执行一遍。因为这些 subsystem 可能存在于不同的 hierarchies。 - 注意
Set()
和AddProcess()
都不是返回错误,而是发出警告,然后返回 nil。因为有些时候用户只指定某一个限制,例如 memory,那样的话修改 cpu 等其实会报错 (正常的报错),因此我们不 return err 来退出。
package cgroups import "simple-docker/subsystem" type CgroupManager struct { Path string // relative path, relative to the root path of the hierarchy // so this may cause more than one cgroup in different hierarchies Resource *subsystems.ResourceConfig } func NewCgroupManager(path string) *CgroupManager { return &CgroupManager{ Path: path, } } // set the three resource config subsystems to the cgroup(will create if the cgroup path is not existed) // this may generate more than one cgroup, because those subsystem may appear in different hierarchies func (cm CgroupManager) Set(res *subsystems.ResourceConfig) error { for _, subsystem := range subsystems.SubsystemsInstance { if err := subsystem.Set(cm.Path, res); err != nil { logrus.Warnf("set resource fail: %v", err) } } return nil } // add process to the cgroup path // why should we iterate all the subsystems? we have only one cgroup // because those subsystems may appear at different hierarchies, which will then cause more than one cgroup, 1-3 in this case. func (cm *CgroupManager) AddProcess(pid int) error { for _, subsystem := range subsystems.SubsystemsInstance { if err := subsystem.AddProcess(cm.Path, pid); err != nil { logrus.Warn("app process fail: %v", err) } } return nil } // delete the cgroup(s) func (cm *CgroupManager) Remove() error { for _, subsystem := range subsystems.SubsystemsInstance { if err:= subsystem.RemoveCgroup(cm.Path); err != nil { return err } } return nil }
4.7 管道处理多个容器参数
限制容器运行的命令不再像是 /bin/sh
这种单个参数,而是多个参数,因此需要使用管道来对多个参数进行处理。那么需要修改以下文件:
4.7.1 container/init.go
- 管道原理和 channel 很像,read 端和 write 端会在另一边没响应时堵塞。
- 使用
os.Pipe()
获取管道。返回的 readPipe 和 writePipe 都是*os.File
类型。 - 如何把管道传给子进程 (也就是容器进程) 变成了一个难题,这里用到了
ExtraFile
这个参数来解决。cmd 会带着参数里的文件来创建新的进程。(这里除了 ExtraFile,还会有类似 StandardFile,也就是 stdin,stdout,stderr) - 这里把 read 端传给容器进程,然后 write 端保留在父进程上。
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) { readPipe, writePipe, err := os.Pipe() if err != nil { logrus.Errorf("new pipe error: %v", err) return nil, nil } // create a new command which run itself cmd := exec.Command("/proc/self/exe", "init") // new namespaces cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET, } // link the container's stdio to os if tty { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } cmd.ExtraFiles = []*os.File{readPipe} return cmd, writePipe }
除了 NewProcess()
,InitProcess()
也要改变下。
- 使用 readCommand 来读取 pipe。
- 实际运行中,当进程运行到
readCommand()
时会堵塞,直到 write 端传数据进来。 - 因此不用担心我们在容器运行后再传输参数。因为再读取完参数之前,
InitProcess()
也不会运行到syscall.Exec()
这一步。 - 这里添加了 lookPath,这个是用于解决每次我们都要输入
/bin/ls
的麻烦,这个函数会帮我们找到参数命令的绝对路径。也就是说,只要输入 ls 即可,lookPath 会自动找到/bin/ls
。然后我们再把这个 path 作为argv()
传给syscall.Exec
// already in container // initialize the container func InitProcess() error { cmdArray := readCommand() if len(cmdArray) == 0 { return fmt.Errorf("init process fails, cmdArray is nil") } defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV // mount proc filesystem syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "") path, err := exec.LookPath(cmdArray[0]) if err != nil { logrus.Errorf("initProcess look path failed: %v", err) return err } // log path info logrus.Infof("find path: %v", path) if err := syscall.Exec(path, cmdArray, os.Environ()); err != nil { logrus.Errorf(err.Error()) } return nil } func readCommand() []string { pipe := os.NewFile(uintptr(3), "pipe") msg, err := ioutil.ReadAll(pipe) if err != nil { logrus.Errorf("read pipe failed: %v", err) return nil } return strings.Split(string(msg), " ") }
4.7.2 dockerCommand/run.go
- 在 run.go 向 writePipe 写入参数,这样容器就会获取到参数。
- 关闭 pipe,使得 init 进程继续进行。
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig) { initProcess, writePipe := container.NewProcess(tty) // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // create container manager to control resource config on all hierarchies cm := cgroups.NewCgroupManager("simple-docker") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side sendInitCommand(cmdArray, writePipe) initProcess.Wait() os.Exit(-1) } func sendInitCommand(cmdArray []string, writePipe *os.File) { cmdString := strings.Join(cmdArray, " ") logrus.Infof("whole init command is: %v", cmdString) writePipe.WriteString(cmdString) writePipe.Close() }
4.7.3 command.go
var RunCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "it", Usage: "open a interactive tty(pre sudo terminal)", }, &cli.StringFlag{ Name: "m", Usage: "limit the memory", }, &cli.StringFlag{ Name: "cpu", Usage: "limit the cpu amount", }, &cli.StringFlag{ Name: "cpushare", Usage:"limit the cpu share", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) == 0 { return errors.New("run what?") } cmdArray := make([]string,len(args)) // command copy(cmdArray,args) // checkout whether type `-it` tty := context.Bool("it") // pre sudo terminal // get the resource config resourceConfig := subsystem.ResourceConfig { MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } dockerCommand.Run(tty, cmdArray, &resourceConfig) return nil }, } // docker init, but cannot be used by user var InitCommand = cli.Command{ Name: "init", Usage: "init a container", Action: func(context *cli.Context) error { logrus.Infof("start initializing...") return container.InitProcess() }, }
4.7.4 main.go
除了上面的修改,我们还要定义一个程序的入口:
package main import ( "os" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) const usage = `Usage` func main() { app := cli.NewApp() app.Name = "simple-docker" app.Usage = usage app.Commands = []cli.Command{ RunCommand, InitCommand, } app.Before = func(context *cli.Context) error { logrus.SetFormatter(&logrus.JSONFormatter{}) logrus.SetOutput(os.Stdout) return nil } if err := app.Run(os.Args); err != nil { logrus.Fatal(err) } }
4.8 运行 demo
go run . run -it stress -m 100m --vm-bytes 200m --vm-keep -m 1
效果如下:
不过这个运行方式不能进行交互,我们可以使用这个命令来验证我们写的 docker 是否与宿主机隔离:
go run . run -it /bin/sh
可以看到,pid,ipc,network 方面都与宿主机进行了隔离。
三、镜像篇
5. 构造镜像
5.1 编译 aufs 内核
因为电脑硬盘空间不太够,就不使用虚拟机来做实验了,笔者这里使用 WSL2 来完成后续工作,然而,WSL2 Kernel 没有把 aufs 编译进去,那只能换内核了,查阅资料,有两种更换内核的方法:
-
直接替换
C:\System32\lxss\tools\kernel
文件 -
在 users 目录下新建
.wslconfig
文件:[wsl2] kernel="要替换kernel的路径"
很明显,我是不会满足于使用别人编译好的内核的,那我也来动手做一个。
5.1.1 准备代码库
我们先在 WSL 上准备好相关软件包:
apt update #更新源 apt install build-essential flex bison libssl-dev libelf-dev gcc make
编译内核需要从 GitHub 上 clone 微软官方的 WSL 代码和 AUFS-Standalone 的代码库
git clone https://github.com/microsoft/WSL2-Linux-Kernel kernel git clone https://github.com/sfjro/aufs-standalone aufs5
然后查看 WSL 内核版本:在 wsl 下运行命令 uname -r
例如我的内核版本是 5.15.19,那 kernel 和 aufs 都要切换到相应的分支去 (kernel 默认就是 5.15.19,故不用切换)
cd aufs5 git checkout aufs5.15.36
然后退回到 kernel 文件夹给代码打补丁:
cat ../aufs5/aufs5-mmap.patch | patch -p1 cat ../aufs5/aufs5-base.patch | patch -p1 cat ../aufs5/aufs5-kbuild.patch | patch -p1
三个 Patch 的顺序无关。
然后再复制一点配置文件:
cp ../aufs5/Documentation . -r cp ../aufs5/fs/ . -r cp ../aufs5/include/uapi/linux/aufs_type.h ./include/uapi/linux
接下来我们来修改一下编译配置,在 Microsoft/config-wsl
中任意位置增加一行:
CONFIG_AUFS_FS=y
最后,就可以开始编译了!
make KCONFIG_CONFIG=Microsoft/config-wsl -j8
过程中会问你一些问题,我除了 AUFS Debug 都选了 y。
最后会在当前目录生成 vmlinuz
,在 arch/x86/boot
下生成 bzImage
。
关闭 WSL 后更换内核,重启 WSL 输入 grep aufs /proc/filesystems
验证结果,如果出现 aufs 的字样,说明操作成功。
5.2 使用 busybox 创建容器
5.2.1 busybox
先在 docker 获取 busybox 镜像并打包成一个 tar 包:
docker pull busybox docker run -d busybox top -b docker export -o busybox.tar <container_id>
将其复制到 WSL 下并解压。
5.2.2 pivot_root
pivot_root 是一个系统调用,作用是改变当前 root 文件系统。pivot_root 可以将当前进程的 root 文件系统移动到 put_old 文件夹,然后使 new_root 成为新的 root 文件系统。
func pivotRoot(root string) error { // remount the root dir, in order to make current root and old root in different file systems if err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil { return fmt.Errorf("mount rootfs to itself error: %v", err) } // create 'rootfs/.pivot_root' to store old_root pivotDir := filepath.Join(root, ".pivot_root") if err := os.Mkdir(pivotDir, 0777); err != nil { return err } // pivot_root mount on new rootfs, old_root mount on rootfs/.pivot_root if err := syscall.PivotRoot(root, pivotDir); err != nil { return fmt.Errorf("pivot_root %v", err) } // change current work dir to root dir if err := syscall.Chdir("/"); err != nil { return fmt.Errorf("chdir / %v", err) } pivotDir = filepath.Join("/", ".pivot_root") // umount rootfs/.rootfs_root if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil { return fmt.Errorf("umount pivot_root dir %v", err) } // del the temporary dir return os.Remove(pivotDir) }
有了这个函数就可以在 init 容器进程时,进行一系列的 mount 操作:
func setUpMount() error { // get current path pwd, err := os.Getwd() if err != nil { logrus.Errorf("get current location error: %v", err) return err } logrus.Infof("current location: %v", pwd) pivotRoot(pwd) // mount proc defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV if err := syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), ""); err != nil { logrus.Errorf("mount /proc failed: %v", err) return err } if err := syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755"); err != nil { logrus.Errorf("mount /dev failed: %v", err) return err } return nil }
tmpfs 是一种基于内存的文件系统,用 RAM 或 swap 分区来存储。
在 NewParentProcess()
中加一句 cmd.Dir="/root/busybox"
。
写完上述函数,然后在 initProcess()
中调用一下:
if err := setUpMount(); err != nil { logrus.Errorf("initProcess look path failed: %v", err) }
然后来运行测试一下:
root@Jayden: ~# go run . run -it sh ###### dividing live {"level":"info","msg":"Start initiating...","time":"2023-05-04T11:27:04+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-04T11:27:04+08:00"} {"level":"info","msg":"current location: /root/busybox","time":"2023-05-04T11:27:04+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-04T11:27:04+08:00"} / #
可以看到,容器当前目录被虚拟定位到了根目录,其实是在宿主机上映射的 /root/busybox
。
5.2.3 用 AUFS 包装 busybox
前面提到了,docker 使用 AUFS 存储镜像和容器。docker 在使用镜像启动一个容器时,会新建 2 个 layer:write layer 和 container-init-layer。write layer 是容器唯一的可读写层,container-init-layer 是为容器新建的只读层,用来存储容器启动时传入的系统信息。
-
CreateReadOnlyLayer()
新建busybox
文件夹,解压busybox.tar
到busybox
目录下,作为容器只读层。 -
CreateWriteLayer()
新建一个writeLayer
文件夹,作为容器唯一可写层。 -
CreateMountPoint()
先创建了mnt
文件夹作为挂载点,再把writeLayer
目录和busybox
目录 mount 到mnt
目录下。
// extra tar to 'busybox', used as the read only layer for container func CreateReadOnlyLayer(rootURL string) { busyboxURL := rootURL + "busybox/" busyboxTarURL := rootURL + "busybox.tar" exist, err := PathExists(busyboxURL) if err != nil { logrus.Infof("fail to judge whether dir %s exists. %v", busyboxURL, err) } if !exist { if err := os.Mkdir(busyboxURL, 0777); err != nil { logrus.Errorf("mkdir dir %s error. %v", busyboxURL, err) } if _, err := exec.Command("tar", "-xvf", busyboxTarURL, "-C", busyboxURL).CombinedOutput(); err != nil { logrus.Errorf("unTar dir %s error %v", busyboxTarURL, err) } } } // create a unique folder as writeLayer func CreateWriteLayer(rootURL string) { writeURL := rootURL + "writeLayer/" if err := os.Mkdir(writeURL, 0777); err != nil { logrus.Errorf("mkdir dir %s error %v", writeURL, err) } } func CreateMountPoint(rootURL string, mntURL string) { // create mnt folder as mount point if err := os.Mkdir(mntURL, 0777); err != nil { logrus.Errorf("mkdir dir %s error %v", mntURL, err) } // mount 'writeLayer' and 'busybox' to 'mnt' dirs := "dirs=" + rootURL + "writeLayer:" + rootURL + "busybox" cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { logrus.Errorf("%v", err) } } func NewWorkSpace(rootURL, mntURL string) { CreateReadOnlyLayer(rootURL) CreateWriteLayer(rootURL) CreateMountPoint(rootURL, mntURL) }
接下来在 NewParentProcess()
将容器使用的宿主机目录 /root/busybox
替换为 /root/mnt
,这样使用 AUFS 系统启动容器的代码就完成了。
cmd.ExtraFiles = []*os.File{readPipe} mntURL := "/root/mnt/" rootURL := "/root/" NewWorkSpace(rootURL, mntURL) cmd.Dir = mntURL return cmd, writePipe
docker 会在删除容器时,把容器对应的 write layer 和 container-init-layer 删除,而保留镜像中所有的内容。
-
DeleteMountPoint()
中 umountmnt
目录。 - 删除
mnt
目录。 - 在
DeleteWriteLayer()
删除writeLayer
文件夹。
func DeleteMountPoint(rootURL string, mntURL string) { cmd := exec.Command(rootURL, mntURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { logrus.Errorf("%v", err) } if err := os.RemoveAll(mntURL); err != nil { logrus.Errorf("remove dir %s error %v", mntURL, err) } } func DeleteWriteLayer(rootURL string) { writeURL := rootURL + "writeLayer/" if err := os.RemoveAll(writeURL); err != nil { logrus.Errorf("remove dir %s error %v", writeURL, err) } } func DeleteWorkSpace(rootURL, mntURL string) { DeleteMountPoint(rootURL, mntURL) DeleteWriteLayer(rootURL) }
现在来启动一个容器测试:
root@Jayden: ~# go run . run -it sh dirs=/root/writeLayer:/root/busybox {"level":"info","msg":"Start initiating...","time":"2023-05-04T15:16:43+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-04T15:16:43+08:00"} {"level":"info","msg":"current location: /root/mnt","time":"2023-05-04T15:16:43+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-04T15:16:43+08:00"} / #
测试在容器内创建文件:
/ # mkdir aaa / # touch aaa/test.txt
此时我们可以在宿主机终端查看 /root/mnt/writeLayer
,可以看到刚才新建的 aaa
文件夹和 test.txt
,在我们退出容器后,/root/mnt
文件夹被删除,伴随着刚才创建的文件夹和文件都被删除,而作为镜像的 busybox 仍被保留,且内容未被修改。
5.3 实现 volume 数据卷
上节实现了容器和镜像的分离,但是如果容器退出,容器可写层的所有内容就会被删除,这里使用 volume 来实现容器数据持久化。
先在 command.go
里添加 -v
标签:
var RunCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ // integrate -i and -t for convenience &cli.BoolFlag{ Name: "it", Usage: "open an interactive tty(pseudo terminal)", }, &cli.StringFlag{ Name: "m", Usage: "limit the memory", }, &cli.StringFlag{ Name: "cpu", Usage: "limit the cpu amount", }, &cli.StringFlag{ Name: "cpushare", Usage: "limit the cpu share", }, // add `-v` tag &cli.StringFlag{ Name: "v", Usage: "volume", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) <= 0 { return errors.New("run what?") } // 转化 cli.Args 为 []string cmdArray := make([]string, len(args)) // command copy(cmdArray, args) // check whether type `-it` tty := context.Bool("it") // presudo terminal // get the resource config resourceConfig := subsystem.ResourceConfig{ MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } // send volume args to Run() volume := context.String("v") dockerCommand.Run(tty, cmdArray, &resourceConfig,volume) return nil }, }
在 Run()
中,把 volume 传给创建容器的 NewParentProcess()
和删除容器文件系统的 DeleteWorkSpace()
:
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume string) { // this is "docker init <cmdArray>" initProcess, writePipe := container.NewParentProcess(tty, volume) if initProcess == nil { logrus.Errorf("new parent process error") return } // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // create container manager to control resource config on all hierarchies cm := cgroup.NewCgroupManager("simple-docker-container") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side // will close the plug sendInitCommand(cmdArray, writePipe) initProcess.Wait() rootURL := "/root/" mntURL := "/root/mnt/" container.DeleteWorkSpace(rootURL, mntURL, volume) os.Exit(0) }
在 NewWorkSpace()
中,继续把 volume 传给创建容器文件系统的 NewWorkSapce()
。
创建容器文件系统过程如下:
- 创建只读层。
- 创建容器读写层。
- 创建挂载点并把只读层和读写层挂载到挂载点上。
- 判断 volume 是否为空,如果是,说明用户没有使用挂载标签,结束创建过程。
- 不为空,就用
volumeURLExtract()
解析。 - 当
volumeURLExtract()
返回字符数组长度为 2,且数据元素均不为空时,则执行MountVolume()
来挂载数据卷。- 否则提示用户创建数据卷输入值不对。
func NewWorkSpace(rootURL, mntURL, volume string) { CreateReadOnlyLayer(rootURL) CreateWriteLayer(rootURL) CreateMountPoint(rootURL, mntURL) if volume != "" { volumeURLs := volumeUrlExtract(volume) length := len(volumeURLs) if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" { MountVolume(rootURL, mntURL, volumeURLs) logrus.Infof("%q", volumeURLs) } else { logrus.Infof("volume parameter input is not correct") } } } func volumeUrlExtract(volume string) []string { // divide volume by ":" return strings.Split(volume, ":") }
挂载数据卷过程如下:
- 读取宿主机文件目录 URL,创建宿主机文件目录 (
/root/${parentURL}
) - 读取容器挂载点 URL,在容器文件系统里创建挂载点 (
/root/mnt/${containerURL}
) - 把宿主机文件目录挂载到容器挂载点,这样启动容器的过程,对数据卷的处理就完成了。
func MountVolume(rootURL, mntURL string, volumeURLs []string) { // create host file catalog parentURL := volumeURLs[0] if err := os.Mkdir(parentURL, 0777); err != nil { logrus.Infof("mkdir parent dir %s error. %v", parentURL, err) } // create mount point in container file system containerURL := volumeURLs[1] containerVolumeURL := mntURL + containerURL if err := os.Mkdir(containerVolumeURL, 0777); err != nil { logrus.Infof("mkdir container dir %s error. %v", containerVolumeURL, err) } // mount host file catalog to mount point in container dirs := "dirs=" + parentURL cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { logrus.Errorf("mount volume failed. %v", err) } }
删除容器文件系统过程如下:
- 在 volume 不为空,且使用
volumeURLExtract()
解析 volume 字符串返回的字符数组长度为 2,数据元素均不为空时,才执行DeleteMountPointWithVolume()
来处理。 - 其余情况仍使用前面的
DeleteMountPoint()
。
func DeleteWorkSpace(rootURL, mntURL, volume string) { if volume != "" { volumeURLs := volumeUrlExtract(volume) length := len(volumeURLs) if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" { DeleteMountPointWithVolume(rootURL, mntURL, volumeURLs) } else { DeleteMountPoint(rootURL, mntURL) } } else { DeleteMountPoint(rootURL, mntURL) } DeleteWriteLayer(rootURL) }
DeleteMountPointWithVolume()
处理逻辑如下:
- 卸载 volume 挂载点的文件系统 (
/root/mnt/${containerURL}
),保证整个容器挂载点没有再被使用。 - 卸载整个容器文件系统挂载点 (
/root/mnt
)。 - 删除容器文件系统挂载点。
func DeleteMountPointWithVolume(rootURL, mntURL string, volumeURLs []string) { // umount volume point in container containerURL := mntURL + volumeURLs[1] cmd := exec.Command("umount", containerURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { logrus.Errorf("umount volume failed. %v", err) } // umount the whole point of the container cmd = exec.Command("umount", mntURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { logrus.Errorf("umount mountpoint failed. %v", err) } if err := os.RemoveAll(mntURL); err != nil { logrus.Infof("remove mountpoint dir %s error %v", mntURL, err) } }
接下来启动容器测试:
# go run . run -it -v /root/volume:/containerVolume sh {"level":"info","msg":"[\"/root/volume\" \"/containerVolume\"]","time":"2023-05-05T09:25:43+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-05T09:25:43+08:00"} {"level":"info","msg":"Start initiating...","time":"2023-05-05T09:25:43+08:00"} {"level":"info","msg":"current location: /root/mnt","time":"2023-05-05T09:25:43+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-05T09:25:43+08:00"} / # ls bin dev home lib64 root tmp var containerVolume etc lib proc sys usr / #
进入 containerVolume
,创建一个 文本文件,并随便写点东西:
cd containerVolume echo -e "test" >> test.txt
此时我们能在宿主机的 /root/volume
找到我们刚才创建的文本文件。退出容器后,volume 文件夹也没有被删除。再次进入容器:
r# go run . run -it -v /root/volume:/containerVolume sh {"level":"info","msg":"mkdir parent dir /root/volume error. mkdir /root/volume: file exists","time":"2023-05-05T09:29:24+08:00"} {"level":"info","msg":"[\"/root/volume\" \"/containerVolume\"]","time":"2023-05-05T09:29:24+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-05T09:29:24+08:00"} {"level":"info","msg":"Start initiating...","time":"2023-05-05T09:29:24+08:00"} {"level":"info","msg":"current location: /root/mnt","time":"2023-05-05T09:29:24+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-05T09:29:24+08:00"} / # ls bin dev home lib64 root tmp var containerVolume etc lib proc sys usr / # ls containerVolume/ test.txt
此时这里会提示 volume 文件夹存在,我们在 test.txt
内追加内容:
cd containerVolume echo -e "###" >> test.txt
此时再次退出容器,能看到修改过后的文件内容,可以看到 volume 文件夹没有被删除。
5.4 简单镜像打包
容器在退出时会删除所有可写层的内容,commit 命令可以把运行状态容器的内容存储为镜像保存下来。
在 main.go
里添加 commit
命令:
app.Commands = []cli.Command{ InitCommand, RunCommand, CommitCommand, }
然后在 command.go
里实现 CommitCommand
命令:
var CommitCommand = cli.Command{ Name: "commit", Usage: "commit a container into image", Action: func(context *cli.Context) error { if len(context.Args()) < 1 { return fmt.Errorf("missing container name") } imageName := context.Args()[0] // commitContainer(containerName) commitContainer(imageName) return nil }, }
添加 commit.go
,通过 commitContainer()
实现将容器文件系统打包成 ${imagename}.tar
。
package main import ( "os/exec" "github.com/sirupsen/logrus" ) func commitContainer(imageName string) { mntURL := "/root/mnt" imageTar := "/root/" + imageName + ".tar" if _, err := exec.Command("tar", "-czf", imageTar, "-C", mntURL, ".").CombinedOutput(); err != nil { logrus.Errorf("tar folder %s error %v", mntURL, err) } }
运行测试:
# go run . run -it sh
然后在另一个终端运行:
# go run . commit image
这时候可以在 root 目录下看到多了一个 image.tar
,解压后可以发现压缩包的内容和 /root/mnt
一致。
tips:一定要先运行容器!如果不运行容器直接打包,会提示
/root/mnt
不存在。
6. 构建容器进阶
6.1 实现容器后台运行
容器,放在操作系统层面,就是一个进程,当前运行命令的 simple-docker 是主进程,容器是当前 simple-docker 进程 fork 出来的子进程。子进程的结束和父进程的运行是一个异步的过程,即父进程不会知道子进程在什么时候结束。如果创建子进程时,父进程退出,那这个子进程就是孤儿进程 (没人管),此时进程号为 1 的进程 init 就会接受这些孤儿进程。
先在 command.go
添加 -d
标签,表示这个容器启动时在后台运行:
var RunCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ // integrate -i and -t for convenience &cli.BoolFlag{ Name: "it", Usage: "open an interactive tty(pseudo terminal)", }, &cli.StringFlag{ Name: "m", Usage: "limit the memory", }, &cli.StringFlag{ Name: "cpu", Usage: "limit the cpu amount", }, &cli.StringFlag{ Name: "cpushare", Usage: "limit the cpu share", }, &cli.StringFlag{ Name: "v", Usage: "volume", }, &cli.BoolFlag{ Name: "d", Usage :"detach container", }, &cli.StringFlag{ Name: "cpuset", Usage: "limit the cpuset", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) <= 0 { return errors.New("run what?") } // 转化 cli.Args 为 []string cmdArray := make([]string, len(args)) // command copy(cmdArray, args) // check whether type `-it` tty := context.Bool("it") // presudo terminal detach := context.Bool("d") // detach container // tty cannot work with detach if tty && detach { return fmt.Errorf("it and d paramter cannot both privided") } // get the resource config resourceConfig := subsystem.ResourceConfig{ MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } volume := context.String("v") dockerCommand.Run(tty, cmdArray, &resourceConfig, volume) return nil }, }
然后也要修改一下 run.go
的 Run()
:
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume string) { // this is "docker init <cmdArray>" initProcess, writePipe := container.NewParentProcess(tty, volume) if initProcess == nil { logrus.Errorf("new parent process error") return } // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // create container manager to control resource config on all hierarchies cm := cgroup.NewCgroupManager("simple-docker-container") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side // will close the plug sendInitCommand(cmdArray, writePipe) // if background process, parent process won't wait if tty { initProcess.Wait() } rootURL := "/root/" mntURL := "/root/mnt/" container.DeleteWorkSpace(rootURL, mntURL, volume) os.Exit(0) }
测试一下:
# go run . run -d top {"level":"info","msg":"whole init command is: top","time":"2023-05-05T15:32:44+08:00"}
根据书上的提示,ps -ef
用来查找 top 进程:
# ps -ef | grep top root 3713 751 0 14:42 pts/2 00:00:00 top
前面几次运行命令,都找不到 top 这个进程,于是我后面多跑了几次,终于看到了这个进程。。。
可以看到,top 命令的进程正在运行着,不过运行环境是 WSL,父进程 id 不是 1,然后 ps -ef
查看一下,top 的父进程是一个 bash 进程,而 bash 进程的父进程是一个 init 进程,这样应该算过了吧 (偶尔的一两次不严谨)。
6.2 实现查看运行中的容器
6.2.1 name 标签
前面创建的容器里,所有关于容器的信息,例如 PID、容器创建时间、容器运行命令等,都没有记录,这导致容器运行完后就在也不知道它的信息了,因此要把这部分信息保留。先在 command.go
里加一个 name 标签,方便用户指定容器的名字:
var RunCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ // integrate -i and -t for convenience &cli.BoolFlag{ Name: "it", Usage: "open an interactive tty(pseudo terminal)", }, &cli.StringFlag{ Name: "m", Usage: "limit the memory", }, &cli.StringFlag{ Name: "cpu", Usage: "limit the cpu amount", }, &cli.StringFlag{ Name: "cpushare", Usage: "limit the cpu share", }, &cli.StringFlag{ Name: "v", Usage: "volume", }, &cli.BoolFlag{ Name: "d", Usage :"detach container", }, &cli.StringFlag{ Name: "cpuset", Usage: "limit the cpuset", }, &cli.StringFlag { Name: "name", Usage: "container name", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) <= 0 { return errors.New("run what?") } // 转化 cli.Args 为 []string cmdArray := make([]string, len(args)) // command copy(cmdArray, args) // check whether type `-it` tty := context.Bool("it") // presudo terminal detach := context.Bool("d") // detach container if tty && detach { return fmt.Errorf("it and d paramter cannot both privided") } // get the resource config resourceConfig := subsystem.ResourceConfig{ MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } volume := context.String("v") containerName := context.String("name") dockerCommand.Run(tty, cmdArray, &resourceConfig, volume, containerName) return nil }, }
添加一个方法来记录容器的相关信息,这里用先用一个 10 位的数字来表示容器的 id:
func randStringBytes(n int) string { letterBytes := "1234567890" rand.Seed(time.Now().UnixNano()) b := make([]byte, n) for i := range b { b[i] = letterBytes[rand.Intn(len(letterBytes))] } return string(b) }
这里用时间戳为种子,每次生成一个 10 以内的数字作为 letterBytes 数组的下标,最后拼成整个容器的 id。容器的信息默认保存在 /var/run/simple-docker/${containerName}/config.json
,容器基本格式如下:
type ContainerInfo struct { Pid string `json:"pid"` Id string `json:"id"` Name string `json:"name"` Command string `json:"command"` // the command that init process execute CreatedTime string `json:"created_time"` Status string `json:"status"` } var ( RUNNING string = "running" STOP string = "stopped" Exit string = "exited" DefaultInfoLocation string = "/var/run/simple-docker/%s" ConfigName string = "config.json" )
下面是记录容器信息:
func recordContainerInfo(containerPID int, commandArray []string, containerName string) (string, error) { // create an ID that length is 10 id := randStringBytes(10) createTime := time.Now().Format("2006-01-02 15:04:05") // format must like this command := strings.Join(commandArray, "") // if containerName is nil, make containerID as name if containerName == "" { containerName = id } containerInfo := &container.ContainerInfo{ Id: id, Pid: strconv.Itoa(containerPID), Command: command, CreatedTime: createTime, Status: container.RUNNING, Name: containerName, } // trun containerInfo info string jsonBytes, err := json.Marshal(containerInfo) if err != nil { logrus.Errorf("record container info error: %v", err) return "", err } jsonStr := string(jsonBytes) // container path dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) if err := os.MkdirAll(dirURL, 0622); err != nil { logrus.Errorf("mkdir error %s error: %v", dirURL, err) return "", err } fileName := dirURL + "/" + container.ConfigName // create config.json file, err := os.Create(fileName) if err != nil { logrus.Errorf("create %s error %v", fileName, err) return "", err } defer file.Close() // write jsonify data to file if _, err := file.WriteString(jsonStr); err != nil { logrus.Errorf("write %s error %v", fileName, err) return "", err } return containerName, nil }
这里格式化的时间必须是 2006-01-02 15:04:05
,不然格式化后的时间会是几千年后 doge。
详细可以看这篇文章:goland时间格式化time.Now().Format_golang time.now().format_好狗不见的博客-CSDN博客
在主函数加上调用:
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName string) { // this is "docker init <cmdArray>" initProcess, writePipe := container.NewParentProcess(tty, volume) if initProcess == nil { logrus.Errorf("new parent process error") return } // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // container info containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName) if err != nil { logrus.Errorf("record container info error: %v", err) return } // create container manager to control resource config on all hierarchies cm := cgroup.NewCgroupManager("simple-docker-container") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side // will close the plug sendInitCommand(cmdArray, writePipe) if tty { initProcess.Wait() deleteContainerInfo(containerName) } rootURL := "/root/" mntURL := "/root/mnt/" container.DeleteWorkSpace(rootURL, mntURL, volume) os.Exit(0) }
如果创建 tty 方式的容器,在容器退出后,就会删除相关信息:
func deleteContainerInfo(containerID string) { dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerID) if err := os.RemoveAll(dirURL); err != nil { logrus.Errorf("remove dir %s error %v", dirURL, err) } }
测试一下:
# go run . run -d top # go run . run -d --name jay top
执行完成后,可以在 /var/run/simple-docker/
找到两个文件夹,一个是随机 id,一个是 jay,文件夹下各有一个 config.json
,记录了容器的相关信息。
6.2.2 实现 docker ps
在 main.go
加一个 listCommand
:
app.Commands = []cli.Command{ RunCommand, InitCommand, CommitCommand, ListCommand, }
在 command.go
添加定义:
var ListCommand = cli.Command{ Name: "ps", Usage: "list all the containers", Action: func(context *cli.Context) error { ListContainers() return nil }, }
新建一个 list.go
,实现记录列出容器信息:
func ListContainers() { // get the path that store the info of the container dirURL := fmt.Sprintf(container.DefaultInfoLocation, "") dirURL = dirURL[:len(dirURL)-1] // read all the files in the directory files, err := ioutil.ReadDir(dirURL) if err != nil { logrus.Errorf("read dir %s error %v", dirURL, err) return } var containers []*container.ContainerInfo for _, file := range files { tmpContainer, err := getContainerInfo(file) // .Println(tmpContainer) if err != nil { logrus.Errorf("get container info error %v", err) continue } containers = append(containers, tmpContainer) } // use tabwriter.NewWriter to print the containerInfo w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0) fmt.Fprintf(w, "ID\tNAME\tPID\tSTATUS\tCOMMAND\tCREATED\n") for _, item := range containers { fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", item.Id, item.Name, item.Pid, item.Status, item.Command, item.CreatedTime) } // refresh stdout if err := w.Flush(); err != nil { logrus.Errorf("flush stdout error %v",err) return } } func getContainerInfo(file os.FileInfo) (*container.ContainerInfo, error) { containerName := file.Name() // create the absolute path configFileDir := fmt.Sprintf(container.DefaultInfoLocation, containerName) configFileDir = configFileDir + "/" + container.ConfigName // read config.json content, err := ioutil.ReadFile(configFileDir) if err != nil { logrus.Errorf("read file %s error %v", configFileDir, err) return nil, err } var containerInfo container.ContainerInfo // turn json to containerInfo if err := json.Unmarshal(content, &containerInfo); err != nil { logrus.Errorf("unmarshal json error %v", err) return nil, err } return &containerInfo, nil }
接上小节的测试,我们运行以下命令:
# go run . run -d top {"level":"info","msg":"whole init command is: top","time":"2023-05-05T19:29:11+08:00"} # go run . run -d --name jay top {"level":"info","msg":"whole init command is: top","time":"2023-05-05T19:29:25+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 6675792962 6675792962 4317 running top 2023-05-05 19:29:11 5553437308 jay 4404 running top 2023-05-05 19:29:25
现在就可以通过 ps 来看到所有创建的容器状态和它们的 init 进程 id 了。
6.3 查看容器日志
在 main.go
加一个 logCommand
:
app.Commands = []cli.Command{ RunCommand, InitCommand, CommitCommand, ListCommand, LogCommand, }
然后在 command.go
里添加 logCommand
:
var LogCommand = cli.Command{ Name: "logs", Usage: "print logs of a container", Action: func(context *cli.Context) error { if len(context.Args()) < 1 { return fmt.Errorf("missing container name") } contianerName := context.Args()[0] logContainer(contianerName) return nil }, }
新建一个 log.go
,定义 logContainer()
:
func logContainer(containerName string) { // get the log path dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) logFileLocation := dirURL + "/" + container.ContainerLogFile // open log file file, err := os.Open(logFileLocation) if err != nil { logrus.Errorf("log container open file %s error: %v", logFileLocation, err) return } defer file.Close() // read log file content content, err := ioutil.ReadAll(file) if err != nil { logrus.Errorf("log container read file %s error: %v", logFileLocation, err) return } // use Fprint to transfer content to stdout fmt.Fprint(os.Stdout, string(content)) }
测试一下,先用 detach 方式创建一个容器:
# go run . run -d --name jay top {"level":"info","msg":"whole init command is: top","time":"2023-05-06T14:26:32+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 1837062451 jay 2065 running top 2023-05-06 14:26:32 # go run . logs jay Mem: 3265116K used, 4568420K free, 3256K shrd, 71432K buff, 1135692K cached CPU: 0.3% usr 0.2% sys 0.0% nic 99.3% idle 0.0% io 0.0% irq 0.0% sirq Load average: 0.03 0.09 0.08 1/521 5 PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND
可以看到,logs 命令成功运行并输出容器的日志。(这里之前出现过前几次创建容器,而后台却没运行的情况,导致一开始运行 logs 时报错了,建议在运行 logs 前多检查下 top 是否后台运行中)
6.4 进入容器 Namespace
在 6.3 小节里,实现了查看后台运行的容器的日志,但是容器一旦创建后,就无法再次进入容器,这一次来实现进入容器内部的功能,也就是 exec。
6.4.1 setns
setns 是一个系统调用,可根据提供的 PID 再次进入到指定的 Namespace。它要先打开 /proc/${pid}/ns
文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中。对于 go 来说,一个有多线程的进程使无法使用 setns 调用进入到对应的命名空间的,go 没启动一个程序就会进入多线程状态,因此无法简单在 go 里直接调用系统调用,这里还需要借助 C 来实现这个功能。
6.4.2 Cgo
在 go 里写 C:
package rand /* #include <stdlib.h> */ import "C" func Random() int { return int(C.random()) } func Seed(i int) { C.srandom(C.uint(i)) }
6.4.3 实现
先使用 C 根据 PID进入对应 Namespace:
package nsenter /* #define _GNU_SOURCE #include <errno.h> #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> // if this package is quoted, this function will run automatic __attribute__((constructor)) void enter_namespace(void) { char *simple_docker_pid; // get pid from system environment simple_docker_pid = getenv("simple_docker_pid"); if (simple_docker_pid) { fprintf(stdout, "got simple docker pid=%s\n", simple_docker_pid); } else { fprintf(stdout, "missing simple docker pid env skip nsenter"); // if no specified pid, the func will exit return; } char *simple_docker_cmd; simple_docker_cmd = getenv("simple_docker_cmd"); if (simple_docker_cmd) { fprintf(stdout, "got simple docker cmd=%s\n", simple_docker_cmd); } else { fprintf(stdout, "missing simple docker cmd env skip nsenter"); // if no specified cmd, the func will exit return; } int i; char nspath[1024]; char *namespace[] = {"ipc", "uts", "net", "pid", "mnt"}; for (i = 0; i < 5; i++) { // create the target path, like /proc/pid/ns/ipc sprintf(nspath, "/proc/%s/ns/%s", simple_docker_pid, namespace[i]); int fd = open(nspath, O_RDONLY); printf("===== %d %s\n", fd, nspath); // call sentns and enter the target namespace if (setns(fd, 0) == -1) { fprintf(stderr, "setns on %s namespace failed: %s\n", namespace[i], strerror(errno)); } else { fprintf(stdout, "setns on %s namespace succeeded\n", namespace[i]); } close(fd); } // run command in target namespace int res = system(simple_docker_cmd); exit(0); return; } */ import "C"
那如何使用这段代码呢,只需要在要加载的地方引用这个 package 即可,我这里是 nenster
。
其实也可以,单独放在一个 C 文件里,go 文件可以这样写:
package nsenter import "C"
下面增加 ExecCommand
:
var ExecCommand = cli.Command{ Name: "exec", Usage: "exec a command into container", Action: func(context *cli.Context) error { if os.Getenv(ENV_EXEC_PID) != "" { logrus.Infof("pid callback pid %v", os.Getgid()) return nil } if len(context.Args()) < 2 { return fmt.Errorf("missing container name or command") } containerName := context.Args()[0] cmdArray := make([]string, len(context.Args())-1) for i, v := range context.Args().Tail() { cmdArray[i] = v } ExecContainer(containerName, cmdArray) return nil }, }
新建一个 exec.go
下面实现获取容器名和需要的命令,并且在这里引用 nsenter
:
const ENV_EXEC_PID = "simple_docker_pid" const ENV_EXEC_CMD = "simple_docker_cmd" func getContainerPidByName(containerName string) (string, error) { // get the path that store container info dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) configFilePath := dirURL + "/" + container.ConfigName // read files in target path contentBytes, err := ioutil.ReadFile(configFilePath) if err != nil { return "", err } var containerInfo container.ContainerInfo // unmarshal json to containerInfo if err := json.Unmarshal(contentBytes, &containerInfo); err != nil { return "", err } return containerInfo.Pid, nil } func ExecContainer(containerName string, comArray []string) { // get the pid according the containerName pid, err := getContainerPidByName(containerName) if err != nil { logrus.Errorf("exec container getContainerPidByName %s error %v", containerName, err) return } // divide command by blank space and combine as a string cmdStr := strings.Join(comArray, " ") logrus.Infof("container pid %s", pid) logrus.Infof("command %s", cmdStr) cmd := exec.Command("/proc/self/exe", "exec") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = os.Setenv(ENV_EXEC_PID, pid) if err != nil { logrus.Errorf("set env exec pid %s error %v", pid, err) } err = os.Setenv(ENV_EXEC_CMD, cmdStr) if err != nil { logrus.Errorf("set env exec command %s error %v", cmdStr, err) } if err := cmd.Run(); err != nil { logrus.Errorf("exec container %s error %v", containerName, err) } }
测试一下:
# go run . run --name jay -d top {"level":"info","msg":"whole init command is: top","time":"2023-05-07T13:23:09+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 6530018751 jay 146639 running top 2023-05-07 13:23:09 # go run . logs jay Mem: 4355160K used, 3478372K free, 3272K shrd, 208844K buff, 1581396K cached CPU: 1.2% usr 0.6% sys 0.0% nic 97.9% idle 0.0% io 0.0% irq 0.1% sirq Load average: 0.12 0.14 0.16 1/574 6 PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND # go run . exec jay sh / # ls bin dev etc home lib lib64 proc root sys tmp usr var / # ps -ef PID USER TIME COMMAND 1 root 0:00 top 13 root 0:00 sh 15 root 0:00 ps -ef / #
可以看到,成功进入容器内部,且与宿主机隔离。
这里出现了一个很奇怪的 bug,就是通过 cgo 去 setns,执行到 mnt 时,抛出个错误:Stale file handle
,当时找了全网,也找不到答案,于是陷入了两天的痛苦 debug,在重新敲代码时,发现又不报错了,切换回那个有错误的分支,也不报错了。既然暂时找不到错误,先搁着吧,如果有看到这篇文章的朋友,也遇到了这个错误,可以留意下。(感觉会是一个雷)
(应该是容器的 mnt 没有 mount 上去,才会导致 stale file handle)
6.5 停止容器
定义 StopCommand
:
var StopCommand = cli.Command{ Name: "stop", Usage: "stop a container", Action: func(context *cli.Context) error { if len(context.Args()) < 1 { return fmt.Errorf("missing container name") } containerName := context.Args()[0] stopContainer(containerName) return nil }, }
然后声明一个函数,通过容器名来获取容器信息:
func getContainerInfoByName(containerName string) (*container.ContainerInfo, error) { dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) configFilePath := dirURL + "/" + container.ConfigName contentBytes, err := ioutil.ReadFile(configFilePath) if err != nil { logrus.Errorf("read config file %s error %v", configFilePath, err) return nil, err } var containerInfo container.ContainerInfo // unmarshal json to container info if err := json.Unmarshal(contentBytes, &containerInfo); err != nil { logrus.Errorf("unmarshal json to container info error %v", err) return nil, err } return &containerInfo, nil }
然后是停止容器:
func stopContainer(containerName string) { // get pid by containerName pid, err := getContainerPidByName(containerName) if err != nil { logrus.Errorf("get container pid by name %s error %v", containerName, err) return } // turn pid(string) to int pidInt, err := strconv.Atoi(pid) if err != nil { logrus.Errorf("convert pid from string to int error %v", err) return } // kill container main process if err := syscall.Kill(pidInt, syscall.SIGTERM); err != nil { logrus.Errorf("stop container %s error %v", containerName, err) return } // get info of the container containerInfo, err := getContainerInfoByName(containerName) if err != nil { logrus.Errorf("get container info by name %s error %v", containerName, err) return } // process is killed, update process status containerInfo.Status = container.STOP containerInfo.Pid = " " // update info to json nweContentBytes, err := json.Marshal(containerInfo) if err != nil { logrus.Errorf("json marshal %s error %v", containerName, err) return } dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) configFilePath := dirURL + "/" + container.ConfigName // overwrite containerInfo if err := ioutil.WriteFile(configFilePath, nweContentBytes, 0622); err != nil { logrus.Errorf("write config file %s error %v", configFilePath, err) } }
测试:
# go run . stop jay # go run . ps ID NAME PID STATUS COMMAND CREATED 6883605813 jay stopped top # ps -ef | grep top root 43588 761 0 20:00 pts/0 00:00:00 grep --color=auto top
可以看到,jay 这个进程被停止了,且 pid 号设为空。
6.6 删除容器
定义 RemoveCommand
:
var RemoveCommand = cli.Command{ Name: "rm", Usage: "remove a container", Action: func(context *cli.Context) error { if len(context.Args()) < 1 { return fmt.Errorf("missing container name") } containerName := context.Args()[0] removeContainer(containerName) return nil }, }
实现删除容器:
func removeContainer(containerName string) { containerInfo, err := getContainerInfoByName(containerName) if err != nil { logrus.Errorf("get container %s info failed: %v", containerName, err) return } // only remove the stopped container if containerInfo.Status != container.STOP { logrus.Errorf("cannot remove running container %s", containerName) return } dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) // remove all the info including sub dir if err := os.RemoveAll(dirURL); err != nil { logrus.Errorf("cannot remove dir %s error: %v", dirURL, err) return } }
测试一下:
# go run . rm jay # go run . ps ID NAME PID STATUS COMMAND CREATED
可以看到,jay 这个容器被删除了。
6.7 通过容器制作镜像
这一节,根据书上的内容,有许多函数需要改动。建议这里对着作者给出的源码 debug,书上有部分内容有明显错误。
之前的文件系统如下:
- 只读层:busybox,只读,容器系统的基础
- 可写层:writeLayer,容器内部的可写层
- 挂载层:mnt,挂载外部的文件系统,类似虚拟机的文件共享
修改后的文件系统如下:
- 只读层:不变
- 可写层:再加容器名为目录进行隔离,也就是
writeLayer/${containerName}
- 挂载层:再加容器名为目录进行隔离,也就是
mnt/${containerName}
因此,本节要实现为每个容器分配单独的隔离文件系统,以及实现对不同容器打包镜像。
修改 run.go
在 Run 函数参数列表添加一个 imageName
:
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName, imageName string) { containerID := randStringBytes(10) if containerName == "" { containerName = containerID } // this is "docker init <cmdArray>" initProcess, writePipe := container.NewParentProcess(tty, volume, containerName, imageName) if initProcess == nil { logrus.Errorf("new parent process error") return } // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // container info containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName, volume) if err != nil { logrus.Errorf("record container info error: %v", err) return } // create container manager to control resource config on all hierarchies cm := cgroups.NewCgroupManager("simple-docker-container") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side // will close the plug sendInitCommand(cmdArray, writePipe) if tty { initProcess.Wait() deleteContainerInfo(containerName) container.DeleteWorkSpace(volume, containerName) } os.Exit(0) }
同时也在 command.go
的 runCommand 里修改:
Action: func(context *cli.Context) error { args := context.Args() if len(args) <= 0 { return errors.New("run what?") } // 转化 cli.Args 为 []string cmdArray := make([]string, len(args)) // command copy(cmdArray, args) // check whether type `-it` tty := context.Bool("it") // presudo terminal detach := context.Bool("d") // detach container if tty && detach { return fmt.Errorf("it and d paramter cannot both privided") } // get the resource config resourceConfig := subsystem.ResourceConfig{ MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } volume := context.String("v") containerName := context.String("name") imageName := cmdArray[0] cmdArray = cmdArray[1:] Run(tty, cmdArray, &resourceConfig, volume, containerName, imageName) return nil },
在 recordContainerInfo
函数的参数列表添加 volume:
func recordContainerInfo(containerPID int, commandArray []string, containerName, volume string) (string, error) { // create an ID that length is 10 id := randStringBytes(10) createTime := time.Now().Format("2006-01-02 15:04:05") command := strings.Join(commandArray, "") // if containerName is nil, make containerID as name if containerName == "" { containerName = id } containerInfo := &container.ContainerInfo{ Id: id, Pid: strconv.Itoa(containerPID), Command: command, CreatedTime: createTime, Status: container.RUNNING, Name: containerName, Volume: volume, } // trun containerInfo info string jsonBytes, err := json.Marshal(containerInfo) if err != nil { logrus.Errorf("record container info error: %v", err) return "", err } jsonStr := string(jsonBytes) // container path dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) if err := os.MkdirAll(dirURL, 0622); err != nil { logrus.Errorf("mkdir error %s error: %v", dirURL, err) return "", err } fileName := dirURL + "/" + container.ConfigName // create config.json file, err := os.Create(fileName) if err != nil { logrus.Errorf("create %s error %v", fileName, err) return "", err } defer file.Close() // write jsonify data to file if _, err := file.WriteString(jsonStr); err != nil { logrus.Errorf("write %s error %v", fileName, err) return "", err } return containerName, nil }
给 ContainerInfo 添加 Volume 成员:
type ContainerInfo struct { Pid string `json:"pid"` //容器的init进程在宿主机上的 PID Id string `json:"id"` //容器Id Name string `json:"name"` //容器名 Command string `json:"command"` //容器内init运行命令 CreatedTime string `json:"createTime"` //创建时间 Status string `json:"status"` //容器的状态 Volume string `json:"volume"` }
然后将 RootURL
,MntURL
,WriteLayer
设为常量:
var ( RUNNING string = "running" STOP string = "stopped" Exit string = "exited" DefaultInfoLocation string = "/var/run/simple-docker/%s/" ConfigName string = "config.json" ContainerLogFile string = "container.log" RootURL string = "/root/" MntURL string = "/root/mnt/%s/" WriteLayerURL string = "/root/writeLayer/%s" )
相应地,NewParentProcess
函数也要修改:
func NewParentProcess(tty bool, volume string, containerName, imageName string) (*exec.Cmd, *os.File) { readPipe, writePipe, err := os.Pipe() if err != nil { logrus.Errorf("New Pipe Error: %v", err) return nil, nil } // create a new command which run itself // the first arguments is `init` which is in the "container/init.go" file // so, the <cmd> will be interpret as "docker init <cmdArray>" cmd := exec.Command("/proc/self/exe", "init") cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC, } cmd.Stdin = os.Stdin if tty { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } else { dirURL := fmt.Sprintf(DefaultInfoLocation, containerName) if err := os.MkdirAll(dirURL, 0622); err != nil { logrus.Errorf("NewParentProcess mkdir %s error %v", dirURL, err) return nil, nil } stdLogFilePath := dirURL + ContainerLogFile stdLogFile, err := os.Create(stdLogFilePath) if err != nil { logrus.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err) return nil, nil } cmd.Stdout = stdLogFile } cmd.ExtraFiles = []*os.File{readPipe} NewWorkSpace(volume, imageName, containerName) cmd.Dir = fmt.Sprintf(MntURL, containerName) return cmd, writePipe }
NewWorkSpace
函数的三个参数分别改为:volume
,imageName
,containerName
:
func NewWorkSpace(volume, imageName, containerName string) { CreateReadOnlyLayer(imageName) CreateWriteLayer(containerName) CreateMountPoint(containerName, imageName) if volume != "" { volumeURLs := volumeUrlExtract(volume) length := len(volumeURLs) if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" { MountVolume(volumeURLs, containerName) logrus.Infof("%q", volumeURLs) } else { logrus.Infof("volume parameter input is not correct") } } }
下面来修改 CreateReadOnlyLayer
,CreateWriteLayer
,CreateMountPoint
这三个函数:
首先是 CreateReadOnlyLayer
,参数名改为 imageName
,镜像解压出来的只读层以 RootURL+imageName
命名:
func CreateReadOnlyLayer(imageName string) error { unTarFolderURL := RootURL + "/" + imageName + "/" imageURL := RootURL + "/" + imageName + ".tar" exist, err := PathExists(unTarFolderURL) if err != nil { logrus.Infof("fail to judge whether dir %s exists. %v", unTarFolderURL, err) return err } if !exist { if err := os.MkdirAll(unTarFolderURL, 0777); err != nil { logrus.Errorf("mkdir dir %s error. %v", unTarFolderURL, err) return err } if _, err := exec.Command("tar", "-xvf", imageURL, "-C", unTarFolderURL).CombinedOutput(); err != nil { logrus.Errorf("unTar dir %s error %v", unTarFolderURL, err) return err } } return nil }
CreateWriteLayer
为每个容器创建一个读写层,把参数改为 containerName,容器读写层修改为 WriteLayerURL+containerName
命名:
func CreateWriteLayer(containerName string) { writeUrl := fmt.Sprintf(WriteLayerURL, containerName) if err := os.MkdirAll(writeUrl, 0777); err != nil { logrus.Infof("Mkdir write layer dir %s error. %v", writeUrl, err) } }
CreateMountPoint
创建容器根目录,然后把镜像只读层和容器读写层挂载到容器根目录,成为容器文件系统,参数列表改为 containerName
和 imageName
:
func CreateMountPoint(containerName, imageName string) error { // create mnt folder as mount point mntURL := fmt.Sprintf(MntURL, containerName) if err := os.MkdirAll(mntURL, 0777); err != nil { logrus.Errorf("mkdir dir %s error %v", mntURL, err) return err } // mount 'writeLayer' and 'busybox' to 'mnt' tmpWriteLayer := fmt.Sprintf(WriteLayerURL, containerName) tmpImageLocation := RootURL + "/" + imageName dirs := "dirs=" + tmpWriteLayer + ":" + tmpImageLocation _, err := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL).CombinedOutput() if err != nil { logrus.Errorf("run command for creating mount point failed: %v", err) return err } return nil }
MountVolume
根据用户输入的 volume 参数获取相应挂载宿主机数据卷 URL 和容器的挂载点 URL,并挂载数据卷。参数列表改为 volumeURLs
和 containerName
:
func MountVolume(volumeURLs []string, containerName string) error { // create host file catalog parentURL := volumeURLs[0] if err := os.Mkdir(parentURL, 0777); err != nil { logrus.Infof("mkdir parent dir %s error. %v", parentURL, err) } // create mount point in container file system containerURL := volumeURLs[1] mntURL := fmt.Sprintf(MntURL, containerName) containerVolumeURL := mntURL + "/" + containerURL if err := os.Mkdir(containerVolumeURL, 0777); err != nil { logrus.Infof("mkdir container dir %s error. %v", containerVolumeURL, err) } // mount host file catalog to mount point in container dirs := "dirs=" + parentURL _, err := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeURL).CombinedOutput() if err != nil { logrus.Errorf("mount volume failed. %v", err) return err } return nil }
然后在删除容器的 removeContainer
函数最后加一行 DeleteWorkSpace
:
func removeContainer(containerName string) { containerInfo, err := getContainerInfoByName(containerName) if err != nil { logrus.Errorf("get container %s info failed: %v", containerName, err) return } // only remove the stopped container if containerInfo.Status != container.STOP { logrus.Errorf("cannot remove running container %s", containerName) return } dirURL := fmt.Sprintf(container.DefaultInfoLocation, containerName) // remove all the info including sub dir if err := os.RemoveAll(dirURL); err != nil { logrus.Errorf("cannot remove dir %s error: %v", dirURL, err) return } container.DeleteWorkSpace(containerInfo.Volume, containerName) }
然后 DeleteWorkSpace
也要修改,DeleteWorkSpace
作用是当容器退出时,删除容器相关文件系统,参数列表改为 containerName 和 volume:
func DeleteWorkSpace(volume, containerName string) { if volume != "" { volumeURLs := volumeUrlExtract(volume) length := len(volumeURLs) if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" { DeleteMountPointWithVolume(volumeURLs, containerName) } else { DeleteMountPoint(containerName) } } else { DeleteMountPoint(containerName) } DeleteWriteLayer(containerName) }
DeleteMountPoint
函数作用是删除未挂载数据卷的容器文件系统,参数修改为 containerName
:
func DeleteMountPoint(containerName string) error { mntURL := fmt.Sprintf(MntURL, containerName) _, err := exec.Command("umount", mntURL).CombinedOutput() if err != nil { logrus.Errorf("%v", err) return err } if err := os.RemoveAll(mntURL); err != nil { logrus.Errorf("remove dir %s error %v", mntURL, err) return err } return nil }
DeleteMountPointWithVolume
函数用来删除挂载数据卷容器的文件系统,参数列表改为 volumeURLs
和 containerName
:
func DeleteMountPointWithVolume(volumeURLs []string, containerName string) error { // umount volume point in container mntURL := fmt.Sprintf(MntURL, containerName) containerURL := mntURL + "/" + volumeURLs[1] if _, err := exec.Command("umount", containerURL).CombinedOutput(); err != nil { logrus.Errorf("umount volume failed. %v", err) return err } // umount the whole point of the container _, err := exec.Command("umount", mntURL).CombinedOutput() if err != nil { logrus.Errorf("umount mountpoint failed. %v", err) return err } if err := os.RemoveAll(mntURL); err != nil { logrus.Infof("remove mountpoint dir %s error %v", mntURL, err) } return nil }
DeleteWriteLayer
函数用来删除容器读写层,参数改为 containerName
:
func DeleteWriteLayer(containerName string) { writeURL := fmt.Sprintf(WriteLayerURL, containerName) if err := os.RemoveAll(writeURL); err != nil { logrus.Errorf("remove dir %s error %v", writeURL, err) } }
然后修改 command.go
中的 commitCommand
:输入参数名改为 containerName
和 imageName
:·
var CommitCommand = cli.Command{ Name: "commit", Usage: "commit a container into image", Action: func(context *cli.Context) error { if len(context.Args()) < 1 { return fmt.Errorf("missing container name") } containerName := context.Args()[0] imageName := context.Args()[1] // commitContainer(containerName) commitContainer(containerName, imageName) return nil }, }
修改 commit.go
的 commitContainer
函数,根据传入的 containerName 制作 imageName.tar
镜像:
func commitContainer(containerName, imageName string) { mntURL := fmt.Sprintf(container.MntURL, containerName) mntURL += "/" imageTar := container.RootURL + "/" + imageName + ".tar" if _, err := exec.Command("tar", "-czf", imageTar, "-C", mntURL, ".").CombinedOutput(); err != nil { logrus.Errorf("tar folder %s error %v", mntURL, err) } }
测试一下,用 busybox 启动两个容器 test1 和 test2,test1 把宿主机 /root/from1
挂载到容器 /to1
,test2 把宿主机 /root/from2
挂载到 /to2
下:
# go run . run -d --name test1 -v /root/from1:/to1 busybox top {"level":"info","msg":"[\"/root/from1\" \"/to1\"]","time":"2023-05-11T10:04:42+08:00"} {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:04:42+08:00"} # go run . run -d --name test2 -v /root/from2:/to2 busybox top {"level":"info","msg":"[\"/root/from2\" \"/to2\"]","time":"2023-05-11T10:04:51+08:00"} {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:04:51+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 4010011034 test1 11570 running top 2023-05-11 10:04:42 5746376093 test2 11684 running top 2023-05-11 10:04:51
打开另一个终端,可以看到 /root
目录下多了 from1
和 from2
两个目录,我们看看 mnt
和 writeLayer
,mnt
下多了两个 busybox 的挂载层,writeLayer
下分别挂载了两个容器的目录:
# tree writeLayer/ writeLayer/ ├── test1 │ └── to1 └── test2 └── to2
下面进入 test1 容器,创建 /to1/test1.txt
:
# go run . exec test1 sh {"level":"info","msg":"container pid 11570","time":"2023-05-11T10:16:33+08:00"} {"level":"info","msg":"command sh","time":"2023-05-11T10:16:33+08:00"} / # echo -e "test1" >> /to1/test1.txt / # mkdir to1-1 / # echo -e "test111111" >> /to1-1/test1111.txt
这时候再来看看可写层:
# tree writeLayer/ writeLayer/ ├── test1 │ ├── root │ ├── to1 │ └── to1-1 │ └── test1111.txt └── test2 └── to2 # cat writeLayer/test1/to1-1/test1111.txt test111111
多了 to1-1/test1111.txt
,那刚刚创建的 test1.txt
去哪了呢?这时候我们看看 from1
,在这里,新创建的文件写入了数据卷。
下面来验证 commit 功能:
# go run . commit test1 image1
导出的镜像路径为 /root/image1.tar
。
下面测试停止和删除容器:
# go run . stop test1 # go run . ps ID NAME PID STATUS COMMAND CREATED 4010011034 test1 stopped top 2023-05-11 10:04:42 5746376093 test2 11684 running top 2023-05-11 10:04:51 # go run . rm test1 # go run . ps ID NAME PID STATUS COMMAND CREATED 5746376093 test2 11684 running top 2023-05-11 10:04:51
我们看看容器根目录和可读写层:
# ls mnt test2 # tree writeLayer/ writeLayer/ └── test2 └── to2
test1 的容器根目录和可读写层被删除。
下面来试一下用镜像创建容器:
# go run . run -d --name test3 -v /root/from3:/to3 image1 top {"level":"info","msg":"[\"/root/from3\" \"/to3\"]","time":"2023-05-11T10:32:44+08:00"} {"level":"info","msg":"whole init command is: top","time":"2023-05-11T10:32:44+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 5746376093 test2 11684 running top 2023-05-11 10:04:51 4713076733 test3 13056 running top 2023-05-11 10:32:44
这时我们可以看到 /root
多了一个 image1
目录:
# ls image1 bin dev etc home lib lib64 proc root sys tmp to1 to1-1 usr var
在这里发现了刚才创建的 to1-1
,用 image1.tar
启动的容器 test3,进入容器后发现我们刚刚写入的文件,至此,我们成功把容器 test1 的数据卷 to1 信息,重新写入了容器 test3 数据卷 to3。
在次小节后,进入容器都要指定镜像名,不然都会报错。
6.8 实现容器指定环境变量运行
本节来实现让容器内运行的程序可以使用外部传递的环境变量。
6.8.1 修改 runCommand
在原来基础上增加 -e
选项,允许用户指定环境变量,由于环境变量可以是多个,这里允许用户多次使用 -e
来传递,同时添加对环境变量的解析,整体修改如下:
var RunCommand = cli.Command{ Name: "run", Usage: "Create a container", Flags: []cli.Flag{ // integrate -i and -t for convenience &cli.BoolFlag{ Name: "it", Usage: "open an interactive tty(pseudo terminal)", }, &cli.StringFlag{ Name: "m", Usage: "limit the memory", }, &cli.StringFlag{ Name: "cpu", Usage: "limit the cpu amount", }, &cli.StringFlag{ Name: "cpushare", Usage: "limit the cpu share", }, &cli.StringFlag{ Name: "v", Usage: "volume", }, &cli.BoolFlag{ Name: "d", Usage: "detach container", }, &cli.StringFlag{ Name: "cpuset", Usage: "limit the cpuset", }, &cli.StringFlag{ Name: "name", Usage: "container name", }, &cli.StringSliceFlag{ Name: "e", Usage: "set environment", }, }, Action: func(context *cli.Context) error { args := context.Args() if len(args) <= 0 { return errors.New("run what?") } // 转化 cli.Args 为 []string cmdArray := make([]string, len(args)) // command copy(cmdArray, args) // check whether type `-it` tty := context.Bool("it") // presudo terminal detach := context.Bool("d") // detach container if tty && detach { return fmt.Errorf("it and d paramter cannot both privided") } // get the resource config resourceConfig := subsystem.ResourceConfig{ MemoryLimit: context.String("m"), CPUShare: context.String("cpushare"), CPUSet: context.String("cpu"), } volume := context.String("v") containerName := context.String("name") envSlice := context.StringSlice("e") imageName := cmdArray[0] cmdArray = cmdArray[1:] Run(tty, cmdArray, &resourceConfig, volume, containerName, imageName, envSlice) return nil }, }
6.8.2 修改 Run 函数
参数里新增一个 envSlice
,然后传递给 NewParentProcess
函数。
func Run(tty bool, cmdArray []string, res *subsystem.ResourceConfig, volume, containerName, imageName string, envSlice []string) { containerID := randStringBytes(10) if containerName == "" { containerName = containerID } // this is "docker init <cmdArray>" initProcess, writePipe := container.NewParentProcess(tty, volume, containerName, imageName, envSlice) if initProcess == nil { logrus.Errorf("new parent process error") return } // start the init process if err := initProcess.Start(); err != nil { logrus.Error(err) } // container info containerName, err := recordContainerInfo(initProcess.Process.Pid, cmdArray, containerName, volume) if err != nil { logrus.Errorf("record container info error: %v", err) return } // create container manager to control resource config on all hierarchies cm := cgroups.NewCgroupManager("simple-docker-container") defer cm.Remove() cm.Set(res) cm.AddProcess(initProcess.Process.Pid) // send command to write side // will close the plug sendInitCommand(cmdArray, writePipe) if tty { initProcess.Wait() deleteContainerInfo(containerName) container.DeleteWorkSpace(volume, containerName) } os.Exit(0) }
6.8.3 修改 NewParentProcess 函数
参数新增一个 envSlice
,给 cmd 设置环境变量。
func NewParentProcess(tty bool, volume string, containerName, imageName string, envSlice []string) (*exec.Cmd, *os.File) { readPipe, writePipe, err := os.Pipe() if err != nil { logrus.Errorf("New Pipe Error: %v", err) return nil, nil } // create a new command which run itself // the first arguments is `init` which is in the "container/init.go" file // so, the <cmd> will be interpret as "docker init <cmdArray>" cmd := exec.Command("/proc/self/exe", "init") cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC, } cmd.Stdin = os.Stdin if tty { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } else { dirURL := fmt.Sprintf(DefaultInfoLocation, containerName) if err := os.MkdirAll(dirURL, 0622); err != nil { logrus.Errorf("NewParentProcess mkdir %s error %v", dirURL, err) return nil, nil } stdLogFilePath := dirURL + ContainerLogFile stdLogFile, err := os.Create(stdLogFilePath) if err != nil { logrus.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err) return nil, nil } cmd.Stdout = stdLogFile } cmd.ExtraFiles = []*os.File{readPipe} cmd.Env = append(os.Environ(), envSlice...) NewWorkSpace(volume, imageName, containerName) cmd.Dir = fmt.Sprintf(MntURL, containerName) return cmd, writePipe }
测试一下:
# go run . run -it --name test -e test=123 -e luck=test busybox sh {"level":"info","msg":"Start initiating...","time":"2023-05-11T14:14:52+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-11T14:14:52+08:00"} {"level":"info","msg":"Current location is /root/mnt/test","time":"2023-05-11T14:14:52+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-11T14:14:52+08:00"} / # env | grep test test=123 luck=test
可以看到,手动指定的环境变量在容器内可见。后面创建一个后台运行的容器:
# go run . run -d --name test -e test=123 -e luck=test busybox top {"level":"info","msg":"whole init command is: top","time":"2023-05-11T14:19:31+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 9649354121 test 29524 running top 2023-05-11 14:19:31 # go run . exec test sh {"level":"info","msg":"container pid 29524","time":"2023-05-11T14:20:12+08:00"} {"level":"info","msg":"command sh","time":"2023-05-11T14:20:12+08:00"} / # ps -ef PID USER TIME COMMAND 1 root 0:00 top 7 root 0:00 sh 8 root 0:00 ps -ef / # env | grep test / #
查看环境变量,没有我们设置的环境变量。
这里不能用 env 命令获取设置的环境变量,原因是 exec 可以说 go 发起的另一个进程,这个进程的父进程是宿主机的,这个,并不是容器内的。在 cgo 内使用了 setns 系统调用,才使得进程进入了容器内部的命名空间,但由于环境变量是继承自父进程的,因此这个 exec 进程的环境变量其实是继承自宿主机,所以在 exec 看到的环境变量其实是宿主机的环境变量。
但只要是容器内 pid 为 1 的进程,创造出来的进程都会继承它的环境变量,下面来修改 exec 命令来直接使用 env 命令来查看容器内环境变量的功能。
6.8.4 修改 exec 命令
提供一个函数,可根据指定的 pid 来获取对应进程的环境变量。
func getEnvsByPid(pid string) []string { path := fmt.Sprintf("/proc/%s/environ", pid) contentBytes ,err := ioutil.ReadFile(path) if err != nil { logrus.Errorf("read file %s error %v", path, err) return nil } // divide by '\u0000' envs := strings.Split(string(contentBytes),"\u0000") return envs }
由于进程存放环境变量的位置是 /proc/${pid}/environ
,因此根据给定的 pid 去读取这个文件,可以获取环境变量,在文件的描述中,每个环境变量之间通过 \u0000
分割,因此可以以此标记来获取环境变量数组。
func ExecContainer(containerName string, comArray []string) { // get the pid according the containerName pid, err := getContainerPidByName(containerName) if err != nil { logrus.Errorf("exec container getContainerPidByName %s error %v", containerName, err) return } // divide command by blank space and combine as a string cmdStr := strings.Join(comArray, " ") logrus.Infof("container pid %s", pid) logrus.Infof("command %s", cmdStr) cmd := exec.Command("/proc/self/exe", "exec") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = os.Setenv(ENV_EXEC_PID, pid) if err != nil { logrus.Errorf("set env exec pid %s error %v", pid, err) } err = os.Setenv(ENV_EXEC_CMD, cmdStr) if err != nil { logrus.Errorf("set env exec command %s error %v", cmdStr, err) } // get target pid environ (container environ) containerEnvs := getEnvsByPid(pid) // set host environ and container environ to exec process cmd.Env = append(os.Environ(), containerEnvs...) if err := cmd.Run(); err != nil { logrus.Errorf("exec container %s error %v", containerName, err) } }
这里由于 exec 命令依然要宿主机的一些环境变量,因此将宿主机环境变量和容器环境变量都一起放置到 exec 进程中:
# go run . run -d --name test -e test=123 -e luck=test busybox top {"level":"info","msg":"whole init command is: top","time":"2023-05-11T14:30:03+08:00"} # go run . ps ID NAME PID STATUS COMMAND CREATED 9729397397 test 50040 running top 2023-05-11 14:30:03 # go run . exec test sh {"level":"info","msg":"container pid 50040","time":"2023-05-11T14:30:17+08:00"} {"level":"info","msg":"command sh","time":"2023-05-11T14:30:17+08:00"} / # env | grep test test=123 luck=test / #
现在可以看到 exec 进程可以获取前面 run 时设置的环境变量了。
四、网络篇
7. 容器网络
7.1 网络虚拟化技术
7.1.1 Linux 虚拟网络设备
Linux 是用网络设备去操作和使用网卡的,系统装了一个网卡后就会为其生成一个网络设备实例,例如 eth0。Linux 支持创建出虚拟化的设备,可通过组合实现多种多样的功能和网络拓扑,这里主要介绍 Veth 和 Bridge。
Linux Veth
Veth 时成对出现的虚拟网络设备,发送到 Veth 一端虚拟设备的请求会从另一端的虚拟设备中发出。容器的虚拟化场景中,常会使用 Veth 连接不同的网络 namespace:
# ip netns add ns1 # ip netns add ns2 # ip link add veth0 type veth peer name veth1 # ip link set veth0 netns ns1 # ip link set veth1 netns ns2 # ip netns exec ns1 ip link 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 4: veth0@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/ether 02:bf:18:99:77:ed brd ff:ff:ff:ff:ff:ff link-netns ns2
在 ns1 和 ns2 的namespace 中,除 loopback 的设备以外就只看到了一个网络设备。当请求发送到这个虚拟网络设备时,都会原封不动地从另一个网络 namespace 的网络接口中出来。例如,给两端分别配置不同地址后,向虚拟网络设备的一端发送请求,就能达到这个虚拟网络设备对应的另一端。
# ip netns exec ns1 ifconfig veth0 172.18.0.2/24 up # ip netns exec ns2 ifconfig veth1 172.18.0.3/24 up # ip netns exec ns1 route add default dev veth0 # ip netns exec ns2 route add default dev veth1 # ip netns exec ns1 ping -c 1 172.18.0.3 PING 172.18.0.3 (172.18.0.3) 56(84) bytes of data. 64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.395 ms --- 172.18.0.3 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.395/0.395/0.395/0.000 ms
Linux Bridge
进行下一步之前,先删除上一小节创建的 netns:
# ip netns del ns1 # ip netns del ns2 # ip netns list
此时之前创建的两个 netns 被删除。
Bridge 虚拟设备时用来桥接的网络设备,相当于现实世界的交换机,可以连接不同的网络设备,当请求达到 Bridge 设备时,可以通过报文中的 Mac 地址进行广播或转发。例如,创建一个 Bridge 设备,来连接 namespace 中的网络设备和宿主机上的网络:
# ip netns add ns1 # ip link add veth0 type veth peer name veth1 # ip link set veth1 netns ns1 ########## 创建网桥 # brctl addbr br0 ########## 挂载网络设备 # brctl addif br0 eth0 # brctl addif bro veth0
7.1.2 Linux 路由表
路由表是 Linux 内核的一个模块,通过定义路由表来决定在某个网络 namespace 中包的流向,从而定义请求会到哪个网络设备上:
# ip link set veth0 up # ip link set br0 up # ip netns exec ns1 ifconfig veth1 172.18.0.2/24 up # ip netns exec ns1 route add default dev veth1 # route add -net 172.18.0.0/24 dev br0
通过设置路由,对 IP 地址的请求就能正确被路由到对应的网络设备上,从而实现通信:
# ifconfig eth0 eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 inet 172.31.93.218 netmask 255.255.240.0 broadcast 172.31.95.255 inet6 fe80::215:5dff:fe4e:a16a prefixlen 64 scopeid 0x20<link> ether 00:15:5d:4e:a1:6a txqueuelen 1000 (Ethernet) RX packets 829 bytes 394161 (394.1 KB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 90 bytes 10335 (10.3 KB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 ########## 在namespace访问宿主机 # ip netns exec ns1 ping -c 1 172.31.93.218 PING 172.31.93.218 (172.31.93.218) 56(84) bytes of data. 64 bytes from 172.31.93.218: icmp_seq=1 ttl=64 time=0.556 ms --- 172.31.93.218 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.556/0.556/0.556/0.000 ms ######### 从宿主机访问namespace的网络地址 # ping -c 1 172.18.0.2 PING 172.18.0.2 (172.18.0.2) 56(84) bytes of data. 64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.113 ms --- 172.18.0.2 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.113/0.113/0.113/0.000 ms
7.1.3 Linux iptables
iptables 是对 Linux 内核的 netfilter 模块进行操作和展示的工具,用来管理包的流动和转送。iptables 定义了一套链式处理的结构,在网络包传输的各个阶段可以使用不同的策略和包进行加工、传送或丢弃。在容器虚拟化技术里,常会用到两种策略,MASQUERADE 和 DNAT,用于容器和宿主机外部的网络通信。
MASQUERADE
MASQUERADE 策略可以将请求包中的源地址转换为一个网络设备的地址,例如 [7.1.2 Linux 路由表](#7.1.2 Linux 路由表) 这一小节里,namespace 中网络设备的地址是 172.18.0.2,这个地址虽然在宿主机可以路由到 br0 的网桥,但是到底宿主机外部后,是不知道如何路由到这个 IP 的,所以如果请求外部地址的话,要先通过 MASQUERADE 策略将这个 IP 转换为宿主机出口网卡的 IP:
# sysctl -w net.ipv4.conf.all.forwarding=1 net.ipv4.conf.all.forwarding = 1 # iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -o eth0 -j MASQUERADE
在 namespace 中请求宿主机外部地址时,将 namespace 中源地址转换为宿主机的地址作为源地址,就可以在 namespace 中访问宿主机外的网络了。
DAT
iptables 中的 DNAT 策略也是做网络地址的转换,不过它是要更换目标地址,常用于将内部网络地址的端口映射出去。例如,上面例子的 namespace 如果要提供服务给宿主机之外的应用要怎么办呢?外部应用没办法直接路由到 172.18.0.2 这个地址,这时候可以用 DNAT 策略。
# iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 172.18.0.2:80
这样就可以把宿主机上的 80 端口的 TCP 请求转发到 namespace 的 172.18.0.2:80,从而实现外部应用的调用。
7.2 构建容器网络模型
7.2.1 基本模型
网络
网络是容器的一个集合,在这个网络上的容器可以相互通信。
type Network struct { Name string // network name IpRange *net.IPNet // address Driver string // network driver name }
网络端点
网络端点用于连接网络与容器,保证容器内部与网络的通信。
type Endpoint struct { ID string `json:"id"` Device netlink.Veth `json:"dev"` IPAddress net.IP `json:"ip"` MacAddress net.HardwareAddr `json:"mac"` Network *Network PortMapping []string }
网络端点的信息传输需要靠网络功能的两个组件配合完成,分别为网络驱动和 IPAM。
网络驱动
网络驱动是网络功能的一个组件,不同驱动对网络的创建、连接、销毁策略不同,通过在创建网络时指定不同的网络驱动来定义使用哪个驱动做网络的配置。
type NetworkDriver interface { Name() string // driver name Create(subnet string, name string) (*Network, error) Delete(network Network) error Connect(network *Network, endpoint *Endpoint) error Disconnect(network Network, endpoint *Endpoint) error }
IPAM
IPAM 也是网络功能的一个组件,用于网络 IP 地址的分配和释放,包括容器的 IP 和网络网关的 IP。主要功能如下:
-
ipam.Allocate(*net.IPNet)
从指定的 subnet 网段中分配 IP -
ipam.Release(*net.IPNet, net.IP)
从指定的 subnet 网段中释放掉指定的 IP
在构建下面的函数之前,先来补充一些书上没写的:
var ( defaultNetworkPath = "/var/run/simple-docker/network/network/" // 默认网络配置信息存储位置 drivers = map[string]NetworkDriver{} // 驱动字典,存储驱动信息 networks = map[string]*Network{} // 网络字段,存储网络信息 )
7.2.2 调用关系
创建网络
func CreateNetwork(driver, subnet, name string) error { _, cidr, _ := net.ParseCIDR(subnet) // allocate gateway ip by IPAM gatewayIP, err := ipAllocator.Allocate(cidr) if err != nil { return err } cidr.IP = gatewayIP nw, err := drivers[driver].Create(cidr.String(), name) if err != nil { return err } // save network info return nw.dump(defaultNetworkPath) }
其中,network.dump 和 network.load 方法是将这个网络的配置信息保存在文件系统中,或从网络的配置目录中的文件读取到网络的配置。
func (nw *Network) dump(dumpPath string) error { if _, err := os.Stat(dumpPath); err != nil { if os.IsNotExist(err) { os.MkdirAll(dumpPath, 0644) } else { return err } } nwPath := path.Join(dumpPath, nw.Name) // create file while empty file, write only, no file nwFile, err := os.OpenFile(nwPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { logrus.Errorf("error: %v", err) return err } defer nwFile.Close() nwJson, err := json.Marshal(nw) if err != nil { logrus.Errorf("error: %v", err) return err } _, err = nwFile.Write(nwJson) if err != nil { logrus.Errorf("error: %v", err) return err } return nil } func (nw *Network) load(dumpPath string) error { nwConfigFile, err := os.Open(dumpPath) if err != nil { return err } defer nwConfigFile.Close() nwJson := make([]byte, 2000) n, err := nwConfigFile.Read(nwJson) if err != nil { return err } err = json.Unmarshal(nwJson[:n], nw) if err != nil { logrus.Errorf("error load nw info: %v", err) return err } return nil }
创建容器并连接网络
func Connect(networkName string, cinfo *container.ContainerInfo) error { network, ok := networks[networkName] if !ok { return fmt.Errorf("no Such Network: %s", networkName) } ip, err := ipAllocator.Allocate(network.IpRange) if err != nil { return err } ep := &Endpoint{ ID: fmt.Sprintf("%s-%s", cinfo.Id, networkName), IPAddress: ip, Network: network, PortMapping: cinfo.PortMapping, } if err = drivers[network.Driver].Connect(network, ep); err != nil { return err } if err = configEndpointIpAddressAndRoute(ep, cinfo); err != nil { return err } return configPortMapping(ep, cinfo) }
展示网络列表
从网络配置的目录中加载所有的网络配置信息:
func Init() error { var bridgeDriver = BridgeNetworkDriver{} drivers[bridgeDriver.Name()] = &bridgeDriver if _, err := os.Stat(defaultNetworkPath); err != nil { if os.IsNotExist(err) { os.MkdirAll(defaultNetworkPath, 0644) } else { return err } } filepath.Walk(defaultNetworkPath, func(nwPath string, info os.FileInfo, err error) error { // skip if dir if info.IsDir() { return nil } if strings.HasSuffix(nwPath, "/") { return nil } // load filename as network name _, nwName := path.Split(nwPath) nw := &Network{ Name: nwName, } if err := nw.load(nwPath); err != nil { logrus.Errorf("error load network: %s", err) } // save network info to network dic networks[nwName] = nw return nil }) return nil }
遍历展示创建的网络:
func ListNetwork() { w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0) fmt.Fprint(w, "NAME\tIpRange\tDriver\n") for _, nw := range networks { fmt.Fprintf(w, "%s\t%s\t%s\n", nw.Name, nw.IpRange.String(), nw.Driver, ) } if err := w.Flush(); err != nil { logrus.Errorf("Flush error %v", err) return } }
删除网络
func DeleteNetwork(networkName string) error { nw, ok := networks[networkName] if !ok { return fmt.Errorf("no Such Network: %s", networkName) } if err := ipAllocator.Release(nw.IpRange, &nw.IpRange.IP); err != nil { return fmt.Errorf("error Remove Network gateway ip: %s", err) } if err := drivers[nw.Driver].Delete(*nw); err != nil { return fmt.Errorf("error Remove Network DriverError: %s", err) } return nw.remove(defaultNetworkPath) }
删除网络的同时也删除配置目录的网络配置文件:
func (nw *Network) remove(dumpPath string) error { if _, err := os.Stat(path.Join(dumpPath, nw.Name)); err != nil { if os.IsNotExist(err) { return nil } else { return err } } else { return os.Remove(path.Join(dumpPath, nw.Name)) } }
7.3 容器地址分配
现在转到 ipam.go
。
7.3.1 数据结构定义
const ipamDefaultAllocatorPath = "/var/run/simple-docker/network/ipam/subnet.json" type IPAM struct { SubnetAllocatorPath string Subnets *map[string]string } // 初始化一个IPAM对象,并指定默认分配信息存储位置 var ipAllocator = &IPAM{ SubnetAllocatorPath: ipamDefaultAllocatorPath, }
反序列化读取网段分配信息和序列化保存网段分配信息:
func (ipam *IPAM) load() error { if _, err := os.Stat(ipam.SubnetAllocatorPath); err != nil { if os.IsNotExist(err) { return nil } else { return err } } subnetConfigFile, err := os.Open(ipam.SubnetAllocatorPath) if err != nil { return err } defer subnetConfigFile.Close() subnetJson := make([]byte, 2000) n, err := subnetConfigFile.Read(subnetJson) if err != nil { return err } err = json.Unmarshal(subnetJson[:n], ipam.Subnets) if err != nil { logrus.Errorf("Error dump allocation info, %v", err) return err } return nil } func (ipam *IPAM) dump() error { ipamConfigFileDir, _ := path.Split(ipam.SubnetAllocatorPath) if _, err := os.Stat(ipamConfigFileDir); err != nil { if os.IsNotExist(err) { os.MkdirAll(ipamConfigFileDir, 0644) } else { return err } } subnetConfigFile, err := os.OpenFile(ipam.SubnetAllocatorPath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { return err } defer subnetConfigFile.Close() ipamConfigJson, err := json.Marshal(ipam.Subnets) if err != nil { return err } _, err = subnetConfigFile.Write(ipamConfigJson) if err != nil { return err } return nil }
7.3.2 地址分配
func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error) { ipam.Subnets = &map[string]string{} err = ipam.load() if err != nil { logrus.Errorf("error dump allocation info, %v", err) } _, subnet, _ = net.ParseCIDR(subnet.String()) one, size := subnet.Mask.Size() if _, exist := (*ipam.Subnets)[subnet.String()]; !exist { // 用0填满网段的配置,1<<uint8(size-one)表示这个网段中有多少个可用地址 // size-one时子网掩码后面的网络位数,2^(size-one)表示网段中的可用IP数 // 2^(size-one)等价于1<<uint8(size-one) (*ipam.Subnets)[subnet.String()] = strings.Repeat("0", 1<<uint8(size-one)) } // 这里的原理建议大家看看原著 for c := range (*ipam.Subnets)[subnet.String()] { if (*ipam.Subnets)[subnet.String()][c] == '0' { ipalloc := []byte((*ipam.Subnets)[subnet.String()]) // go的字符串创建后不能修改,先用byte存储 ipalloc[c] = '1' (*ipam.Subnets)[subnet.String()] = string(ipalloc) // ip = subnet.IP // 通过网段的IP与上面的偏移相加得出分配的IP,由于IP是一个uint的一个数组,需要通过数组中的每一项加所需要的值,例 // 如网段是172.16.0.0/12,数组序号是65555,那就要在[172,16,0,0]上依次加 // [uint8(65555 >> 24), uint8(65555 >> 16), uint8(65555 >> 8), uint(65555 >> 4)],即[0,1,0,19], // 那么获得的IP就是172.17.0.19 for t := uint(4); t > 0; t-- { []byte(ip)[4-t] += uint8(c >> ((t - 1) * 8)) } // 由于此处IP是从1开始分配的,所以最后再加1,最终得到分配的IP是172.16.0.20 ip[3]++ break } } ipam.dump() return }
7.3.3 地址释放
func (ipam *IPAM) Release(subnet *net.IPNet, ipaddr *net.IP) error { ipam.Subnets = &map[string]string{} _, subnet, _ = net.ParseCIDR(subnet.String()) err := ipam.load() if err != nil { logrus.Errorf("Error dump allocation info, %v", err) } c := 0 // 将IP转换为4个字节的表示方式 releaseIP := ipaddr.To4() // 由于IP是从1开始分配的,所以转换成索引减1 releaseIP[3] -= 1 for t := uint(4); t > 0; t -= 1 { // 和分配IP相反,释放IP获得索引的方式是IP的每一位相减后分别左移将对应的数值加到索引上 c += int(releaseIP[t-1]-subnet.IP[t-1]) << ((4 - t) * 8) } ipalloc := []byte((*ipam.Subnets)[subnet.String()]) ipalloc[c] = '0' (*ipam.Subnets)[subnet.String()] = string(ipalloc) ipam.dump() return nil }
根据书上,写到这里就开始测试了,但是我们看看 IDE,红海一片,所以我们接着实现。
7.4 创建 bridge 网络
7.4.1 实现 Bridge Driver Create
func (d *BridgeNetworkDriver) Create(subnet string, name string) (*Network, error) { ip, ipRange, _ := net.ParseCIDR(subnet) ipRange.IP = ip n := &Network{ Name: name, IpRange: ipRange, Driver: d.Name(), } err := d.initBridge(n) if err != nil { logrus.Errorf("error init bridge: %v", err) } return n, err }
7.4.2 Bridge Driver 初始化 Linux Bridge
func (d *BridgeNetworkDriver) initBridge(n *Network) error { // 创建bridge虚拟设备 bridgeName := n.Name if err := createBridgeInterface(bridgeName); err != nil { return fmt.Errorf("eror add bridge: %s, error: %v", bridgeName, err) } // 设置bridge设备的地址和路由 gatewayIP := *n.IpRange gatewayIP.IP = n.IpRange.IP if err := setInterfaceIP(bridgeName, gatewayIP.String()); err != nil { return fmt.Errorf("error assigning address: %s on bridge: %s with an error of: %v", gatewayIP, bridgeName, err) } // 启动bridge设备 if err := setInterfaceUP(bridgeName); err != nil { return fmt.Errorf("error set bridge up: %s, error: %v", bridgeName, err) } // 设置iptables的SNAT规则 if err := setupIPTables(bridgeName, n.IpRange); err != nil { return fmt.Errorf("error setting iptables for %s: %v", bridgeName, err) } return nil }
创建 bridge 设备
func createBridgeInterface(bridgeName string) error { _, err := net.InterfaceByName(bridgeName) if err == nil || !strings.Contains(err.Error(), "no such network interface") { return err } // create *netlink.Bridge object la := netlink.NewLinkAttrs() la.Name = bridgeName br := &netlink.Bridge{LinkAttrs: la} if err := netlink.LinkAdd(br); err != nil { return fmt.Errorf("bridge creation failed for bridge %s: %v", bridgeName, err) } return nil }
设置 bridge 设备的地址和路由
func setInterfaceIP(name string, rawIP string) error { retries := 2 var iface netlink.Link var err error for i := 0; i < retries; i++ { iface, err = netlink.LinkByName(name) if err == nil { break } logrus.Debugf("error retrieving new bridge netlink link [ %s ]... retrying", name) time.Sleep(2 * time.Second) } if err != nil { return fmt.Errorf("abandoning retrieving the new bridge link from netlink, Run [ ip link ] to troubleshoot the error: %v", err) } ipNet, err := netlink.ParseIPNet(rawIP) if err != nil { return err } addr := &netlink.Addr{ IPNet: ipNet, Peer: ipNet, Label: "", Flags: 0, Scope: 0, Broadcast: nil, } return netlink.AddrAdd(iface, addr) }
启动 bridge 设备
func setInterfaceUP(interfaceName string) error { iface, err := netlink.LinkByName(interfaceName) if err != nil { return fmt.Errorf("error retrieving a link named [ %s ]: %v", iface.Attrs().Name, err) } if err := netlink.LinkSetUp(iface); err != nil { return fmt.Errorf("error enabling interface for %s: %v", interfaceName, err) } return nil }
设置 iptables Linux Bridge SNAT 规则
func setupIPTables(bridgeName string, subnet *net.IPNet) error { iptablesCmd := fmt.Sprintf("-t nat -A POSTROUTING -s %s ! -o %s -j MASQUERADE", subnet.String(), bridgeName) cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...) //err := cmd.Run() output, err := cmd.Output() if err != nil { logrus.Errorf("iptables Output, %v", output) } return err }
7.4.3 Bridge Driver Delete 实现
func (d *BridgeNetworkDriver) Delete(network Network) error { bridgeName := network.Name br, err := netlink.LinkByName(bridgeName) if err != nil { return err } return netlink.LinkDel(br) }
7.5 在 bridge 网络创建容器
7.5.1 挂载容器端点
连接容器网络端点到 Linux Bridge
func (d *BridgeNetworkDriver) Connect(network *Network, endpoint *Endpoint) error { bridgeName := network.Name br, err := netlink.LinkByName(bridgeName) if err != nil { return err } la := netlink.NewLinkAttrs() la.Name = endpoint.ID[:5] la.MasterIndex = br.Attrs().Index endpoint.Device = netlink.Veth{ LinkAttrs: la, PeerName: "cif-" + endpoint.ID[:5], } if err = netlink.LinkAdd(&endpoint.Device); err != nil { return fmt.Errorf("error Add Endpoint Device: %v", err) } if err = netlink.LinkSetUp(&endpoint.Device); err != nil { return fmt.Errorf("error Add Endpoint Device: %v", err) } return nil }
配置容器 Namespace 中网络设备及路由
回到 network.go
func configEndpointIpAddressAndRoute(ep *Endpoint, cinfo *container.ContainerInfo) error { peerLink, err := netlink.LinkByName(ep.Device.PeerName) if err != nil { return fmt.Errorf("fail config endpoint: %v", err) } defer enterContainerNetns(&peerLink, cinfo)() interfaceIP := *ep.Network.IpRange interfaceIP.IP = ep.IPAddress if err = setInterfaceIP(ep.Device.PeerName, interfaceIP.String()); err != nil { return fmt.Errorf("%v,%s", ep.Network, err) } if err = setInterfaceUP(ep.Device.PeerName); err != nil { return err } if err = setInterfaceUP("lo"); err != nil { return err } _, cidr, _ := net.ParseCIDR("0.0.0.0/0") defaultRoute := &netlink.Route{ LinkIndex: peerLink.Attrs().Index, Gw: ep.Network.IpRange.IP, Dst: cidr, } if err = netlink.RouteAdd(defaultRoute); err != nil { return err } return nil }
进入容器 Net Namespace
func enterContainerNetns(enLink *netlink.Link, cinfo *container.ContainerInfo) func() { f, err := os.OpenFile(fmt.Sprintf("/proc/%s/ns/net", cinfo.Pid), os.O_RDONLY, 0) if err != nil { logrus.Errorf("error get container net namespace, %v", err) } nsFD := f.Fd() runtime.LockOSThread() if err = netlink.LinkSetNsFd(*enLink, int(nsFD)); err != nil { logrus.Errorf("error set link netns , %v", err) } origns, err := netns.Get() if err != nil { logrus.Errorf("error get current netns, %v", err) } if err = netns.Set(netns.NsHandle(nsFD)); err != nil { logrus.Errorf("error set netns, %v", err) } return func() { netns.Set(origns) origns.Close() runtime.UnlockOSThread() f.Close() } }
配置宿主机到容器的端口映射
func configPortMapping(ep *Endpoint, cinfo *container.ContainerInfo) error { for _, pm := range ep.PortMapping { portMapping := strings.Split(pm, ":") if len(portMapping) != 2 { logrus.Errorf("port mapping format error, %v", pm) continue } iptablesCmd := fmt.Sprintf("-t nat -A PREROUTING -p tcp -m tcp --dport %s -j DNAT --to-destination %s:%s", portMapping[0], ep.IPAddress.String(), portMapping[1]) cmd := exec.Command("iptables", strings.Split(iptablesCmd, " ")...) //err := cmd.Run() output, err := cmd.Output() if err != nil { logrus.Errorf("iptables Output, %v", output) continue } } return nil }
7.5.2 修补 bug
写到这里,代码还是有很多 bug 的,例如,BridgeNetworkDriver
未完全继承 NetworkDriver
的所有函数。
func (d *BridgeNetworkDriver) Disconnect(network Network, endpoint *Endpoint) error { return nil }
7.5.3 测试
现在终于可以测试了。
首先创建一个网桥:
# go run . network create --driver bridge --subnet 192.168.10.1/24 testbridge
然后启动两个容器:
# go run . run -it -net testbridge busybox sh {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:24:53+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:24:53+08:00"} {"level":"info","msg":"Current location is /root/mnt/8116248511","time":"2023-05-20T19:24:53+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:24:53+08:00"} / # ifconfig cif-81162 Link encap:Ethernet HWaddr 16:62:68:81:E0:A9 inet addr:192.168.10.2 Bcast:192.168.10.255 Mask:255.255.255.0 inet6 addr: fe80::1462:68ff:fe81:e0a9/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:14 errors:0 dropped:0 overruns:0 frame:0 TX packets:6 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:1820 (1.7 KiB) TX bytes:516 (516.0 B) lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 inet6 addr: ::1/128 Scope:Host UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B) / #
记住这个 IP:192.168.10.2
,然后进入另一个容器:
# go run . run -it -net testbridge busybox sh {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:26:24+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:26:24+08:00"} {"level":"info","msg":"Current location is /root/mnt/9558830402","time":"2023-05-20T19:26:24+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:26:24+08:00"} / # ifconfig cif-95588 Link encap:Ethernet HWaddr 42:18:0A:73:33:CA inet addr:192.168.10.3 Bcast:192.168.10.255 Mask:255.255.255.0 inet6 addr: fe80::4018:aff:fe73:33ca/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:10 errors:0 dropped:0 overruns:0 frame:0 TX packets:6 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:1248 (1.2 KiB) TX bytes:516 (516.0 B) lo Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 inet6 addr: ::1/128 Scope:Host UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:0 errors:0 dropped:0 overruns:0 frame:0 TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:0 (0.0 B) TX bytes:0 (0.0 B) / # ping 192.168.10.2 PING 192.168.10.2 (192.168.10.2): 56 data bytes 64 bytes from 192.168.10.2: seq=0 ttl=64 time=2.619 ms 64 bytes from 192.168.10.2: seq=1 ttl=64 time=0.086 ms ^C --- 192.168.10.2 ping statistics --- 2 packets transmitted, 2 packets received, 0% packet loss round-trip min/avg/max = 0.086/1.352/2.619 ms / #
可以看到,两个容器网络互通。
下面来试一下访问外部网络。我用的 WSL,默认的 nat 是关闭的,前期各种设置 iptables 规则什么的,都无法访问容器外部的网络,直到发现一篇帖子里说到,需要打开内核的 nat 功能,要将文件/proc/sys/net/ipv4/ip_forward
内的值改为1(默认是0)。执行 sysctl -w net.ipv4.ip_forward=1
即可。
修改之后,继续测试。
容器默认是没有 DNS 服务器的,需要我们手动添加:
/ # ping cn.bing.com ping: bad address 'cn.bing.com' / # echo -e "nameserver 8.8.8.8" > /etc/resolv.conf / # ping cn.bing.com PING cn.bing.com (202.89.233.101): 56 data bytes 64 bytes from 202.89.233.101: seq=0 ttl=113 time=38.419 ms 64 bytes from 202.89.233.101: seq=1 ttl=113 time=39.011 ms ^C --- cn.bing.com ping statistics --- 3 packets transmitted, 2 packets received, 33% packet loss round-trip min/avg/max = 38.419/38.715/39.011 ms / #
然后再来测试容器映射端口到宿主机供外部访问:
# go run . run -it -p 90:90 -net testbridge busybox sh {"level":"info","msg":"Start initiating...","time":"2023-05-20T19:39:07+08:00"} {"level":"info","msg":"whole init command is: sh","time":"2023-05-20T19:39:07+08:00"} {"level":"info","msg":"Current location is /root/mnt/3445154844","time":"2023-05-20T19:39:07+08:00"} {"level":"info","msg":"Find path: /bin/sh","time":"2023-05-20T19:39:07+08:00"} / # nc -lp 90
然后访问宿主机的 80 端口,看看能不能转发到容器里:
# telnet 172.31.93.218 90 Trying 172.31.93.218... telnet: Unable to connect to remote host: Connection refused
开始我以为是我哪里码错了,然后拿作者的代码来跑,并放到虚拟机上跑,发现并不是自己的问题,那只能这样测试了:
# telnet 192.168.10.3 90 Trying 192.168.10.3... Connected to 192.168.10.3. Escape character is '^]'.
出现这样的字眼后,容器和宿主机之间就可以通信了。
参考链接
七天用 Go 写个 docker(第一天) | Go 技术论坛 (learnku.com)
使用 GoLang 从零开始写一个 Docker(概念篇)-- 《自己动手写 Docker》读书笔记 - 掘金 (juejin.cn)
编译带有 AUFS 支持的 WSL 内核 - 徐天乐 :: 个人博客 (xtlsoft.top)
如何让WSL2使用自己编译的内核 - 知乎 (zhihu.com)
goland时间格式化time.Now().Format_golang time.now().format_好狗不见的博客-CSDN博客
自己动手写Docker系列 -- 5.7实现通过容器制作镜像 - 掘金 (juejin.cn)
iptable端口重定向 MASQUERADE_tycoon1988的博客-CSDN博客
这篇关于自己动手写Docker学习笔记的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-14Docker端口:你真的公开了哪些东西?
- 2024-11-14用DOCKER在家里的实验室里搞些酷炫的玩意儿
- 2024-11-05掌握Docker:高效安全的十大最佳实践
- 2024-11-05在 Docker Compose 中怎么设置端口映射-icode9专业技术文章分享
- 2024-11-05在 Docker Compose 中怎么设置环境变量-icode9专业技术文章分享
- 2024-11-04Docker环境部署项目实战:新手入门教程
- 2024-11-04Docker环境部署资料:新手入门教程
- 2024-11-01Docker环境部署教程:新手入门指南
- 2024-11-01超越Docker:苹果芯片上的模拟、编排和虚拟化方案讲解
- 2024-11-01Docker环境部署:新手入门教程