零、前言
书中有摘录书中的一些知识点,不过限于篇幅,没有全部摘录 (主要也是懒)。项目仓库地址为:JaydenChang/simple-docker (github.com)
一、概念篇
1. 基础知识
1.1 kernel
1.2 namespace
namespace 是 Linux 自带的功能来隔离内核资源的机制。
1.2.1 UTS Namespace
UTS,UNIX Time Sharing,用于隔离 nodeName (主机名) 和 domainName (域名)。在该 Namespace 下修改 hostname 不会影相其他的 Namespace。
1.2.2 IPC Namespace
两种 IPC 本质上差不多,System V IPC 随内核持续,POSIX IPC 随进程持续。
1.2.3 PID Namespace
1.2.4 Mount Namespace
用于隔绝文件系统,挂载了某一目录,在这个 Namespace 下就会把这个目录当作根目录,我们看到的文件系统树就会以这个目录为根目录。
1.2.5 User Namespace
用于 隔离用户组 ID。
1.2.6 Network Namespace
1.3 Linux Cgroups
Cgroups 全称为 Control Groups,是 Linux 内核提供的物理资源隔离机制。
1.3.1 Cgroups 的三个组件
- cgroup:一个 cgroup 包含一组进程,且可以有 subsystem 的参数配置,以关联一组 subsystem。
- subsystem:一组资源控制的模块。
- hierarchy:把一组 cgroups 串成一个树状结构,以提供继承的功能。
1.3.2 这三个组件的关联
- 首先,创建一个 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。
-
然后挂载:
sudo mount -t cgroup -o none,name=hierarchy-test hierarchy-test ./hierarchy-test
-
可以在这个目录下看到一大堆文件,这些文件就是 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 中添加和移动进程:系统的所有进程都会被放到根节点中,可以根据需要移动进程:
sudo sh -c "echo $$ >> tasks"
该命令就是将当前终端的这个进程加到当前所在的 cgroup 的目录的 tasks 文件中。
-
- 上面的方法有个问题,因为这个 hierarchy 没有关联到任何 subsystem,因此不能够控制资源。
- 不过其实系统会自动给每个 subsystem 创建一个 hierarchy,所以通过控制这个 hierarchy 里的配置,可以达到控制进程的目的。
mkdir hierarchy-test
1.3.5 docker 是怎么使用 Cgroups 的
docker 会给每个容器创建一个 cgroup,再限制该 cgroup 的资源,从而达到限制容器的资源的作用。
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,每次修改文件时,都是一次提交,并有记录,修改都反映在一个新的文件上,而不是修改旧文件。
1.5.2 AUFS
AUFS,Advanced Union File System,是 UFS 的一个改动版本。
1.5.3 docker 和 AUFS
docker 在早期使用 AUFS,直到现在也可以选择作为一种存储驱动类型。
1.5.4 image layer
当启动一个 container 时,就会在 image 上再加一层 init layer,init layer 也是 read-only 的,用于储存容器的环境配置。此外,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。
.
├── container-layer
│ └── container.txt
├── image-layer
│ └── image.txt
└── mnt
├── container.txt
└── image.txt
然后我们修改一下 image.txt 的内容,然后再看看整个目录,会发现,container-layer
目录下多了一个 image.txt
,然后我们看看 container-layer
的 image.txt
的内容,有添加前后的的文字。
二、容器篇
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 命令。// 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
// 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
注意 syscall.Exec 这里:
- 就是
- 所以如果没有加这一段,其实退出容器后还需要在外面再次 mount proc 才能使用 ps 等命令
mount /
并指定 private,不然容器里的 proc 会使用外面的 proc,即使在不同 namespace 下。
// 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 的存在呢?
为什么要第一个命令的 PID 为 1?
- 因为这样,退出这个进程后,容器就会因为没有前台进程,而自动退出,这也是 docker 的特性。
4. 给 docker run 增加对容器的资源限制功能
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。
- 其中
- 获取到 cgroupPath 在虚拟文件系统中的位置后,只需要写入 "memory.limit_in_bytes" 文件中即可。
GetCgroupPath
后面会提及,作用是获取这个 subsystem 所挂载的 hierarchy 上的虚拟文件系统下的 从group 路径。
// 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()
- 使用
- 这里移除整个 cgroup 文件夹,就等于是删除 cgroup 了。
os.Remove
可以移除参数所指定的文件或文件夹。
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。/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
效果如下:
go run . run -it /bin/sh
三、镜像篇
5. 构造镜像
5.1 编译 aufs 内核
因为电脑硬盘空间不太够,就不使用虚拟机来做实验了,笔者这里使用 WSL2 来完成后续工作,然而,WSL2 Kernel 没有把 aufs 编译进去,那只能换内核了,查阅资料,有两种更换内核的方法:
-
.wslconfig 文件:
[wsl2] kernel="要替换kernel的路径"
C:\System32\lxss\tools\kernel 文件
很明显,我是不会满足于使用别人编译好的内核的,那我也来动手做一个。
5.1.1 准备代码库
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
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
。
grep aufs /proc/filesystems验证结果,如果出现 aufs 的字样,说明操作成功。
5.2 使用 busybox 创建容器
5.2.1 busybox
docker pull busybox
docker run -d busybox top -b
docker export -o busybox.tar <container_id>
将其复制到 WSL 下并解压。
5.2.2 pivot_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
-
CreateWriteLayer()
新建一个writeLayer
文件夹,作为容器唯一可写层。 -
CreateMountPoint()
先创建了mnt
文件夹作为挂载点,再把writeLayer
目录和busybox
目录 mount 到mnt
目录下。
CreateReadOnlyLayer()
新建 busybox
文件夹,解压 busybox.tar
到 busybox
目录下,作为容器只读层。
// 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 删除,而保留镜像中所有的内容。
- 删除
mnt
目录。 - 在
DeleteWriteLayer()
删除writeLayer
文件夹。
DeleteMountPoint()
中 umount mnt
目录。
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 数据卷
先在 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,创建宿主机文件目录 (
- 读取容器挂载点 URL,在容器文件系统里创建挂载点 (
/root/mnt/${containerURL}
) - 把宿主机文件目录挂载到容器挂载点,这样启动容器的过程,对数据卷的处理就完成了。
/root/${parentURL}
)
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 不为空,且使用
- 其余情况仍使用前面的
DeleteMountPoint()
。
volumeURLExtract()
解析 volume 字符串返回的字符数组长度为 2,数据元素均不为空时,才执行 DeleteMountPointWithVolume()
来处理。
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
)。 - 删除容器文件系统挂载点。
/root/mnt/${containerURL}
),保证整个容器挂载点没有再被使用。
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 简单镜像打包
在 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
一致。
/root/mnt 不存在。
6. 构建容器进阶
6.1 实现容器后台运行
先在 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 这个进程,于是我后面多跑了几次,终于看到了这个进程。
ps -ef 查看一下,top 的父进程是一个 bash 进程,而 bash 进程的父进程是一个 init 进程,这样应该算过了吧 (偶尔的一两次不严谨)。
6.2 实现查看运行中的容器
6.2.1 name 标签
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。
在主函数加上调用:
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.4.1 setns
setns 是一个系统调用,可根据提供的 PID 再次进入到指定的 Namespace。它要先打开 /proc/${pid}/ns
文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中。对于 go 来说,一个有多线程的进程使无法使用 setns 调用进入到对应的命名空间的,go 没启动一个程序就会进入多线程状态,因此无法简单在 go 里直接调用系统调用,这里还需要借助 C 来实现这个功能。
6.4.2 Cgo
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
。
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
/ #
可以看到,成功进入容器内部,且与宿主机隔离。
Stale file handle,当时找了全网,也找不到答案,于是陷入了两天的痛苦 debug,在重新敲代码时,发现又不报错了,切换回那个有错误的分支,也不报错了。既然暂时找不到错误,先搁着吧,如果有看到这篇文章的朋友,也遇到了这个错误,可以留意下。(感觉会是一个雷)
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 通过容器制作镜像
之前的文件系统如下:
- 只读层:busybox,只读,容器系统的基础
- 可写层:writeLayer,容器内部的可写层
- 挂载层:mnt,挂载外部的文件系统,类似虚拟机的文件共享
- 只读层:不变
- 可写层:再加容器名为目录进行隔离,也就是
writeLayer/${containerName}
- 挂载层:再加容器名为目录进行隔离,也就是
mnt/${containerName}
因此,本节要实现为每个容器分配单独的隔离文件系统,以及实现对不同容器打包镜像。
修改 run.go
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
,在这里,新创建的文件写入了数据卷。# 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 / #
查看环境变量,没有我们设置的环境变量。
但只要是容器内 pid 为 1 的进程,创造出来的进程都会继承它的环境变量,下面来修改 exec 命令来直接使用 env 命令来查看容器内环境变量的功能。
6.8.4 修改 exec 命令
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 Veth
# ip netns add ns1 # ip netns add ns2 # ip link add veth0 type veth peer name veth2 # ip link set veth0 netns ns1 # ip link set veth2 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 veth2 172.18.0.3/24 up # ip netns exec ns1 route add default dev veth0 # ip netns exec ns2 route add default dev veth2 # 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
# ip netns del ns1 # ip netns del ns2 # ip netns list
此时之前创建的两个 netns 被删除。
# ip netns add ns1 # ip link add veth0 type veth peer name veth2 # ip link set veth2 netns ns1 ########## 创建网桥 # brctl addbr br0 ########## 挂载网络设备 # brctl addif br0 eth0 # brctl addif bro veth0
7.1.2 Linux 路由表
# ip link set veth0 up # ip link set br0 up # ip netns exec ns1 ifconfig veth2 172.18.0.2/24 up # ip netns exec ns1 route add default dev veth2 # route add -net 172.18.0.0/24 dev br0
# 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
# 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 -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.Release(*net.IPNet, net.IP)
从指定的 subnet 网段中释放掉指定的 IP
ipam.Allocate(*net.IPNet)
从指定的 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
/ #
可以看到,两个容器网络互通。
/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 '^]'.
出现这样的字眼后,容器和宿主机之间就可以通信了。
参考链接
使用 GoLang 从零开始写一个 Docker(概念篇)-- 《自己动手写 Docker》读书笔记 - 掘金 (juejin.cn)
如何让WSL2使用自己编译的内核 - 知乎 (zhihu.com)
自己动手写Docker系列 -- 5.7实现通过容器制作镜像 - 掘金 (juejin.cn)
iptable端口重定向 MASQUERADE_tycoon1988的博客-CSDN博客