《自己动手写Docker》笔记x01:基础知识

这是《自己动手写Docker》的读书笔记,本篇为第一、二章的笔记,主要的内容为Docker的简介、安装,以及它涉及到的一些Linux系统知识。

实验环境参数如下

  • 虚拟机 Parallel14
  • Ubuntu 16.04
  • Linux内核 4.10.0-28-generic
  • Golang v1.12.4

Docker介绍及安装

一键安装Docker

1
sudo curl -sSL https://get.docker.com | sh

几句话概括特点:

  • 容器共享内核
  • 容器之间相互隔离
  • 不和任何基础设施绑定
  • 就像集装箱一样,帮你打包好了一切,开箱即用

Linux Namespace

用来隔离一系列资源,以下为其六个可隔离的Namespace

Namespace类型 系统调用参数 内核版本
Mount CLONE_NEWNS 2.4.19
UTS CLONE_NEWUTS 2.6.19
IPC CLONE_NEWIPC 2.6.19
PID CLONE_NEWPID 2.6.24
NetWork CLONE_NEWNET 2.6.29
User CLONE_NEWUSER 3.8

以下为三个系统调用

  • clone() 创建新进程
  • unshare() 将进程移出某个Namespace
  • setns() 将进程加入某个Namespace中

UTS

隔离 nodenamedomainname 这两个系统标识,每个Namespace允许用于自己的 hostname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

执行程序后将进入一个被fork出的sh环境中,在其中执行 echo $$ 查看当前被fork出的进程的id,同时查看父进程的id,假设分别为7906和7902。执行如下命令

1
2
3
4
sudo readlink /proc/7906/ns/uts
# uts[4026532246]
sudo readlink /proc/7902/ns/uts
# uts[4026531838]

发现两者所属的uts不一样。也可以修改子进程中的hostname,会发现父进程不受影响

1
hostname -b 233

IPC

隔离System V IPC和POSIX message queues,每一个IPC命名空间都有自己的System V IPC和POSIX message queues。以下为两个IPC指令

  • 创建IPC message queue:ipcmk -Q
  • 查看IPC message queue:ipcs -q
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

PID

隔离PID,也就是进程的。容器中,前台允许的进程PID为1,但是在容器外,同样的进程却有着不同的ID。

可以使用pstree -pl查看进程树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

运行代码进入sh环境后,执行echo $$,就会发现其根进程的PID为1


Mount

隔离各个进程看到的挂载点视图,执行 mount()umount() 只会影响到当前Namespace内的文件系统,高配版chroot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

在按照书中执行 mount -t proc proc /proc 后会出现一些小问题,详见这个issue


User

User Namespace主要是用于隔离用户的用户组ID。宿主机上非root用户映射成命名空间内的root用户

这里也不能按照书中的代码写,因为内核版本不一致,需要做一些就该,详见这个issue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1234,
HostID: 0,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1234,
HostID: 0,
Size: 1,
},
},
}
// 这段为书中的代码,由于内核升级而失效
// https://github.com/xianlubird/mydocker/issues/3
//cmd.SysProcAttr.Credential = &syscall.Credential{
// Uid:uint32(1),
// Gid:uint32(1),
//}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
os.Exit(-1)
}

运行代码,可以使用id来查看用户的信息,会发现他的UID发生了改变


Network

隔离网络设备、UP地址端口等网络栈的Namespace。让每个容器拥有自己独立的(虚拟的)网络设备,并且容器内的应用可以绑定到自己的端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER|
syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1234,
HostID: 0,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1234,
HostID: 0,
Size: 1,
},
},
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
os.Exit(-1)
}

执行 ifconfig 查看子进程的网络栈发现为空


Linux Cgroups

提供了对一组进程及将来子进程的资源限制、控制和统计的能力。包括CPU、内存、存储、网络等

三个组件

  • cgroup 对进程分组管理的一种机制
  • subsystem 一组资源控制模块
    • 在根目录执行 lssubsys -a查看
  • hierarchy 把一组cgroup串成一个树状结构

三者的关系

  • 系统创建了新的 hierarchy 之后,系统中的所有进程都会加入这个 hierarchycgroup 根节点,这个 cgroup 根节点是 hierarchy 默认创建的。
  • 一个 subsystem 只能附加到一个 hierarchy 上面
  • 一个 hierarchy 可以附加到多个 subsystem 上面
  • 父进程fork出子进程,两者在用一个 cgroup

命令行实战

1
2
mkdir cgroup-test
sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test

此时 cgroup-test 文件夹中生成了一些列默认文件

  • cgroup.clone_children 内容为1时子进程才会继承父进程的cpuset
  • cgroup.procs 当前节点cgroup的进程组ID
  • notify_on_release 和 release_agent 一起使用,详细用法略
  • tasks 标识该cgroup下的进程ID

在这个文件夹内执行如下shell命令

1
2
sudo mkdir cgroup-1
sudo mkdir cgroup-2

这样创建了两个子cgroup,可以将当前bash进程加入到子cgroup中

1
2
sudo sh -c "echo $$ >> tasks"
cat /proc/${PID}/cgroup

限制内存资源使用实战

执行如下shell指令

1
2
mount | grep memory
# cgroup on /sys/fs/cgroup/memory type cgroup

/sys/fs/cgroup/memory 目录挂在了 memory subsystemhierarchy

memory 下创建一个子cgroup,并加入内存限制

1
2
3
4
5
6
7
8
9
10
11
12
sudo mkdir test-limit-memory
cd test-limit-memor

sudo sh -c ” echo ” lOOm ” > memory.limit_in_bytes ”
sudo sh -c "echo $$ > tasks"

# 这里需要配置一下,否则会导致进程占用内容过多而被杀死
# 参考 https://segmentfault.com/a/1190000008125359#articleHeader6
sudo sh -c "echo 1 >> memory.oom_control"

# 启动
stress --vm-bytes 200m --vm-keep -m 1

可以在 /sys/fs/cgroup/memory/docker下看到Docker使用的CGroup


使用Golang

有一个bug还未修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const cgroupMemoryHierarchMount = "/sys/fs/cgroup/memory"

func main() {
if os.Args[0] == "/proc/self/exe" {
log.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 {
log.Fatal("stress has error", err)
}
}

cmd := exec.Command("/proc/self/exe")
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
if err := cmd.Start(); err != nil {
log.Fatal(err)
} else {
log.Printf("%v\n", cmd.Process.Pid)
cgName := "testmemorylimit"
var err error

// 区分fork
if err = os.Mkdir(path.Join(cgroupMemoryHierarchMount, cgName), 0755); err != nil {
if !strings.Contains(fmt.Sprintln(err), "file exists") {
log.Fatal("mkdir error", err)
} else {
log.Println("mkdir warning ", err)
}
}
if err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchMount, cgName, "tasks"),
[]byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
log.Fatal("write to tasks error", err)
}

//bs := make([]byte, 4)
//binary.LittleEndian.PutUint32(bs, 1)
// TODO: 老是写不进去
fspath := path.Join(cgroupMemoryHierarchMount, cgName, "memory.oom_control")
if err= exec.Command("echo", "1 >>"+ fspath).Run(); err != nil {
log.Fatal("write to memory.oom_control error", err)
}


if err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchMount, cgName, "memory.limit_in_bytes"),
[]byte("100m"), 0644); err != nil {
log.Fatal("write to memory.limit_in_bytes error", err)
}
}
if _, err := cmd.Process.Wait(); err != nil {
log.Fatal("cmd.Process.Wait error", err)
}
}

Union File System

概述

把其他文件系统联合到一个联合挂载点的文件系统服务。使用到 copy-on-write 技术,减少资源的占用。

AUFS 为其的升级版,为Docker支持的一种存储驱动类型。每一个Docker image 都是由一系列read-only layer组成的。image layer的内容存储在 /var/lib/docker/aufs/diff 下;layer的metadata信息在 /var/lib/docker/aufs/layers 下。


命令行模拟

执行一下bash脚本生成测试目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
mkdir mnt container-layer
touch container-layer/container-layer.txt
echo "I am container layer" >> container-layer/container-layer.txt

for i in {1..4}
do
dirname=image-layer${i}
filename=${dirname}/image-layer${i}.txt

mkdir $dirname
touch ${filename}
echo "I am image layer ${i}" >> ${filename}
done

之后再执行挂载至mnt下的操作

1
sudo mount -t aufs -o dirs=./container-layer:./image-layer4:./image-layer3:./image-layer2:./image-layer1 none ./mnt

在我个人的电脑上查看aufs的信息

1
2
3
4
5
6
cat /sys/fs/aufs/si_a236c06529c6ac94/* | less
# /home/parallels/Workspace/go/write-your-own-docker/cpt2/2_3_ufs/container-layer=rw
# /home/parallels/Workspace/go/write-your-own-docker/cpt2/2_3_ufs/image-layer4=ro
# /home/parallels/Workspace/go/write-your-own-docker/cpt2/2_3_ufs/image-layer3=ro
# /home/parallels/Workspace/go/write-your-own-docker/cpt2/2_3_ufs/image-layer2=ro
# /home/parallels/Workspace/go/write-your-own-docker/cpt2/2_3_ufs/image-layer1=ro

只有第一个是可写的

修改 mnt/image-layer4.txt 中的信息,会发现在文件,也就是 image-layer4/image-layer4.txt 中的内容并没有改变,而是在 container-layer/ 下,也就是可写层中多出了一个最新的 image-layer4.txt 文件