一、Linux Namespace介绍
1.1 概念
Linux Namespace 是 Kernel
的一个功能,它可以隔离一系列的系统资源,比如 PIO ( ProcessID )、 User
ID 、 Network 等。
我们常购买的云服务器等资源,也就是使用了资源隔离。使用
Namespace,就可以做到 UID 级别的隔离,也就是说,可以以 UID 为 n
的用户,虚拟化出来一个 Namespace , 在这个 Namespace 里面,用户是具有
root 权限的 。但是,在真实的物理机器上,他还是那个以UID 为 n
的用户,这样就解决了用户之间隔离的问题。
每个虚拟出的空间都可以表现为一台物理机,都有PID=1的父进程。而这些命名空间的PID为1的进程是映射到父命名空间的,
image-20221009165301472
所以命名空间NameSpace就是用来隔离资源的,linux除了User
NameSpace,还有其他5种NameSpace:
image-20221010211823298
Namespace 的 API 主要使用如下 3 个系统调用 。
clone()创建新进程。根据系统调用参数来判断哪些类型的 Namespace
被创建,而且它们 的子进程也会被包含到这些 Namespace 中。
unshare()将进程移出某个 Namespace
setns()将进程加入到 Namespace 中。
1.2 UTS NameSpace
UTS Namespace
主要用来隔离
nodename 和 domainname
两个系统标识。在UTS Namespace
里面 , 每个
Namespace
允许有自己的 hostname
。
即每个UTS NameSpace
中的hostname
都可以不一样。
下面一个Go测试UTS NameSpace
例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package mainimport ( "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) } }
执行上面的代码会进入一个sh的运行环境中。使用pstree -pl
查看进程之间的关系:
image-20221010220227867
验证一下父进程和子进程是否在一个UTS中:
image-20221010220353176
子进程和父进程并不在同一个UTS中,这里就做到了隔离,所以修改hostname
也不会影响宿主机的hostname
(左边为sh环境,右边为宿主机环境):
image-20221010220711135
1.3 IPC NameSpace
IPC Namespace
用来隔离 System V IPC
和
POSIX message queues
。
每一个IPC Namespace
都有自己的 System V IPC
和
POSIX message queue
。
IPC即Inter-Process Communication
,进程间通信,IPC NameSpace
即实现进程间的通信隔离。
我们知道进程通信有三种方式:消息队列、管道、共享内存。通信隔离即该消息队列或其他方式只能在该NameSpace中看到,其他NameSpace无法使用。
我们只需要修改上面UTS NameSpace代码
的Cloneflags
即可模拟IPC NameSpace
隔离。
cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC, }
我们开两个shell,第一个运行我们的程序进入sh环境,创建一个ipc的message queue
,可以发现在宿主机上是看不到的,这就实现了IPC隔离
image-20221011103117756
ipcs -q : 查看当前namespace所有的Message Queue
ipcmk -Q:在当前namespace创建一个Message Queue
1.4 PID Namespace
顾名思义, PID Namespace用来隔离进程的PID。
根据PID
Namespace,我们创建的每个Namespace环境都可以拥有PID为1的进程,其实是挂载到宿主机上的一个普通进程。
同样修改一下之前的代码即可创建一个PID Namespace。
cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID, }
同样可以看到在右边宿主机上使用pstree -pl
查看go进程的pid不为1,但是左边创建的PID
namespace中pid为1.
image-20221011104002945
但是此时还不能使用ps来查看进程的PID,因为还需要MountNamespace。
image-20221011104917365
1.5 Mount Namespace
顾名思义Mount Namespace
即进行文件系统挂载的隔离。我们知道Docker每个容器都有自己的文件系统,而这些文件系统之间的隔离就是使用了Mount
Namepsace。
在 Mount Namespace
中调用 mount()
和
umount()
仅仅只会影响当前 Namespace
内的文件系统 ,而对全局的文件系统是没有影响的。
Mount Namespace
是 Linux 第 一个实现
的Namespace
类型 , 因此,它的系统调用参数是 NEWNS ( New
Namespace 的缩写)。
cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, }
proc 是一个文件系统,提供额外的机制
,可以通过内核和内核模块将信息发送给进程。
查看宿主机proc内容:
image-20221011105059343
将 /proc mount 挂载到我们自己的namespace中:
image-20221011105245382
可以看到挂载后有了PID为1的进程。说明当前namespace
和外部空间是隔离的,mount
操作并没有影响到外部,Docker
Volume也是利用了这个特性。
其实这样只是创建了Mount Namespace
,没有真正实现隔离,因为只是将shared subtrees
的原因。可以通过执行mount --make-rprivate /
来取消mount共享来实现真正的隔离。具体可以参考mount namespace
真正隔离了你的mount信息吗? - 知乎 (zhihu.com)
1.6 User Namepsace
User
Namespace主要用来隔离用户的用户组ID。我们知道每个进程都有一个所属的User
ID或Group ID,那么创建一个User
Namespace后,该namspace里的进程所属的ID可以和外部宿主机进程的ID不同。
注意的是,从 Linux Kernel3.8开始,非 root 进程 也可
以创建 User Namepace , 并且此用户在Namespace里面可以被映射成 root ,
且在 Namespace 内有 root 权限 。
cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER, } cmd.SysProcAttr. Credential = &syscall . Credential{Uid: uint32 (1 ), Gid : uint32 (1 )}
但是执行会报错:
image-20221011111507538
centos7默认禁用了用户名空间,使用下面命令开启:
# echo 640 > /proc/sys/user/max_user_namespaces
Linux kernel3.19版本对User Namepsace做了些修改,修改代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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: 0 , HostID: 0 , Size: 1 , }, }, GidMappings: []syscall.SysProcIDMap{ { ContainerID: 0 , HostID: 0 , Size: 1 , }, }, }
1.7 Network Namespace
Network Namespace 是用来隔离网络设备、 IP 地址端口 等网络械的
Namespace 。 NetworkNamespace
可以让每个容器拥有自己独立的(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个
Namespace 内的端口都不会互相冲突。
在宿主机上搭建网桥后就可以很方便地实现容器之间的通信。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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: 0 , HostID: 0 , Size: 1 , }, }, GidMappings: []syscall.SysProcIDMap{ { ContainerID: 0 , HostID: 0 , Size: 1 , }, }, }
进入容器后查看网络可以看到容器内ifconfig
无结果,而宿主机有,说明容器和宿主机网络隔离了。
image-20221012155654607
二、Linux CGroups介绍
2.1概念
Linux Cgroups (Control Groups
)提供了对一组进程及将来子进程的资源限制、控制和统计的能力,这些资源包括
CPU、内存、存储、网络等 。 通过
Cgroups,可以方便地限制某个进程的资源占用,并且可以实时地监控进程的监控和统计信息
。
作用:管理进程的资源分配,从而可以管理容器的资源分配。
2.2 Cgroups的三个组件
cgroup
是对进程分组管理的一种机制,
一个cgroup
包含一组进程,井可以在这个
cgroup
上增加 Linux subsystem
的各种参数配置,将一组进程和一组 subsystem
的系统参数关联起来。
subsystem
是一组资源控制的模块。每个 subsystem
会关联到定义了相应限制的 cgroup 上,并对这个cgroup
中的进程做相应的限制和控制 。
可以安装 cgroup 的命令行工具( apt-get install cgroup-bin
),然后通过 lssubsys 看到 Kernel 支持的 subsystem 。
hierarchy
的功能是把 一 组 cgroup 串成 一
个树状的结构 ,一个这样的树便是 一 个 hierarchy
,通过这种树状结构, Cgroups 可以做到继承 。
比如通过创建cgroup1
对某个进程限制了CPU使用率。其某个子进程还需要限制磁盘IO,那么可以创建cgroup2
继承于cgroup1
,然后通过cgroup2
限制某个子进程的磁盘IO,并且自动继承了CPU的限制。
2.3 三个组件的关系
系统在创建了新的 hierarchy
之后,系统中所有的进程 都会加入这个 hierarchy 的
cgroup根节点 ,这个 cgroup 根节点是 hierarchy
默认创建的,在这个 hierarchy 中创建的 cgroup 都是这个 cgroup
根节点的子节点。
一个 subsystem 只能附加到一个 hierarchy 上面。
一个 hierarchy 可以附加多个 subsystem 。 一般包含下面几项:
blkio
设置对块设备(比如硬盘)输入输出的访问控制
cpu
设置 cgroup 中进程的 CPU 被调度的策略。
cpuacct
可以统计 cgroup 中进程的 CPU 占用
cpuset
在多核机器上设置 cgroup 中进程可以使用的 CPU
和内存(此处内存仅使用于 NUMA 架构)
devices
控制 cgroup 中进程对设备的访问
freezer
用于挂起( suspend )和恢复( resume) cgroup
中的进程
memory
用于控制 cgroup 中进程的内存占用
net_cls
用于将 cgroup 中进程产生的网络包分类,以便
Linux tc (traffic controller)可 以根据分类区分出来自某个 cgroup
的包并做限流或监控
net_prio
设置 cgroup 中进程产生的网络流量的优先级
ns
这个 subsystem 比较特殊,它的作用是使 cgroup
中的进程在新的 Namespace fork 新进程 (NEWNS)时,创建出一个新的 cgroup
,这个 cgroup 包含新的 Namespace中的进程
一个进程可以作为多个 cgroup 的成员,但是这些 cgroup
必须在不同的 hierarchy 中 。
一个进程 fork 出子进程时,子进程是和父进程在同一个 cgroup
中的,也可以根据需要 将其移动到其他 cgroup 中 。
通俗理解:Cgroup是规则的制定者,声明了哪些进程需要遵守哪些规则。subsystem是规则的执行者,根据Cgroup制定的规则来限制进程使用的资源。hierarchy是制定者的树状集合,用来区分制定者的辈分。
Cgroups三个组件的关系
上图中黄色三角形是subsystem,可以根据上面的规则对照理解。
2.4 操作Cgroups
Linux内核通过一个虚拟的树状文件系统配置 Cgroups
的,通过层级的目录虚拟出 cgroup 树。
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 $ mkdir cgroup-test $ mount -t cgroup -o none,name=cg-root-1 cg-root-1 ./cgroup-test/ $ mount cg-root-1 on /data/test/cgroups/cgroup-test type cgroup (rw,relatime,name=cg-root-1) $ ls ./cgroup-test/ cgroup.clone_children cgroup.procs notify_on_release tasks cgroup.event_control cgroup.sane_behavior release_agent $ mkdir cg-1 cg-2 $ tree . ├── cg-1 │ ├── cgroup.clone_children │ ├── cgroup.event_control │ ├── cgroup.procs │ ├── notify_on_release │ └── tasks ├── cg-2 │ ├── cgroup.clone_children │ ├── cgroup.event_control │ ├── cgroup.procs │ ├── notify_on_release │ └── tasks ├── cgroup.clone_children ├── cgroup.event_control ├── cgroup.procs ├── cgroup.sane_behavior ├── notify_on_release ├── release_agent └── tasks
cgroup.clone_children
, cpuset subsystem
会读取这个配置文件,如果这个值是1(默认是0),子cgroup 才会继承父 cgroup
cpuset 的配置;
cgroup.procs
是hierarchy树中当前cgroup节点中的进程组 ID
,当前cgroup的位置是在hierarchy树的根节点,这个文件中会有现在系统中所有进程组的
ID
notify_on _release
和 release agent
会一起使用。 notify_on_release
标识当这个
cgroup最后一个进程退出的时候是否执行了 release_agent
;
release_ agent
则是
个路径,通常用作进程退出之后自动清理掉不再使用的 cgroup;
tasks
标识 cgroup 下面的进程 ID ,如果把 个进程 ID 写到
tasks 文件中,便会将相应的进程加入到这个 cgroup; 注意:
tasks有时也区分线程还是进程id
我们创建了cgroup-test
的hirerachy
,包含了一个cgroup
根节点,其下面有两个cgroup
:cg-1
和cg-2
。
下面我们可以向cgroup
中添加进程,添加进程只需要在cgroup
下的tasks
文件中添加进程的pid即可。
image-20221013154214093
可以看到将当前的shell
进程加入到了cg-1
中。
注意:将一个进程加入到cg-1
后,如果将其再加入到cg-2
,那么cg-1
会自动移除该进程的pid。也说明了:一个进程可以作为多个
cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy中;
2.5 限制进程资源
cgroups
通过subsystem
来限制进程的资源。系统默认已经为每个subsystem
创建了一个默认的 hierarchy
,比如
memory hierarchy
:
image-20221013155158953
可以看到,
memory subsystem
的hierarchy
挂载到了
/sys/fs/cgroup/memory
,
我们就在这个hierarchy
下创建cgroup
,
限制进程占用的内存.
一种方式是和上面一样在这个文件夹下创建子文件夹即会自动创建cgroup
。
还可以使用cgcreate -g memory:test-memory-limit
来创建cgroup
$ echo '100m' > memory.limit_in_bytes $ cat memory.limit_in_bytes 104857600 $ echo $$ 16570 $ echo 16570 > tasks $ stress --vm-bytes 128m --vm-keep -m 1 stress: info: [335] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd stress: FAIL: [335] (415) <-- worker 336 got signal 9 stress: WARN: [335] (417) now reaping child worker processes stress: FAIL: [335] (451) failed run completed in 0s $ stress --vm-bytes 99m --vm-keep -m 1 stress: info: [351] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
上面给cgroup
限制了100m
的内存使用,然后将当前shell
添加进cgroup
,启动一个128m的进程会报错,实现了资源限制。
2.6 Docker是如何使用Cgroups的
$ docker run -itd -m 128m ubuntu 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29 $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 64b8f2e16f30 ubuntu "bash" 2 seconds ago Up 1 second stupefied_bhaskara $ cd /sys/fs/cgroup/memory/docker $ cd 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29 $ ls ... 省略一些内容 memory.failcnt memory.limit_in_bytes memory.kmem.tcp.limit_in_bytes memory.move_charge_at_immigrate tasks $ cat memory.limit_in_bytes 134217728 $ cat memory.usage_in_bytes 778240
可以看到, Docker 通过为每个容器创建 cgroup 并通过 cgroup
去配置资源限制和资源监控。 那除了cgroup
还需要进程pid才可以进行限制,那么限制的是哪个进程的pid呢?是docker的吗?明显不可能。
事实上, 每一个Docker容器,
在操作系统上是对应到进程 ,所以一个容器对应一个进程,这个进程对应容器的cgroup
,就可以通过cgroup
配置容器的进程pid来达到对单个容器进行资源限制的效果。
2.7 使用Go限制进程资源
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 package mainimport ( "fmt" "io/ioutil" "os" "os/exec" "path" "strconv" "syscall" )const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory" func main () { fmt.Println("os orgs:" , os.Args) if os.Args[0 ] == "/proc/self/exe" { fmt.Printf("current pid:%v\n" , syscall.Getpid()) cmd := exec.Command("sh" , "-c" , "stress --vm-bytes 200m --vm-keep -m 1" ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() checkErr(err, "/proc/self/exe run" ) } 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 err := cmd.Start() checkErr(err, "/proc/self/exe start" ) fmt.Printf("host pid: %v\n" , cmd.Process.Pid) err = os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit" ), 0755 ) checkErr(err, "Mkdir" ) err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit" , "tasks" ), []byte (strconv.Itoa(cmd.Process.Pid)), 0644 ) checkErr(err, "WriteFile tasks" ) err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit" , "memory.limit_in_bytes" ), []byte ("100m" ), 0644 ) checkErr(err, "WriteFile limit_in_bytes" ) _, err = cmd.Process.Wait() checkErr(err, "cmd.Process.Wait" ) }func checkErr (err error , reason string ) { if err != nil { panic (fmt.Sprintf("err:%v, reason:%s" , err, reason)) } }
image-20221014121646501
三、Linux Union File System
Union File System
简称 UnionFS
,是一种为
Linux 、 FreeBSD 和 NetBSD 操作系统设计的,
把其他文件系统联合到一个联合挂载点 的文件系统服务。
即将多个文件系统逻辑上联合组成为一个文件系统,因此在这个文件系统上的读写会通过写时复制 的方式复制一份进行修改,而不会对原文件进行修改。
Docker构建速度非常快的原因就是采用了镜像分层 ,而镜像分层底层采用的技术就是Union
File System,Linux 支持多种 Union File System,比如 aufs、OverlayFS、ZFS
等。
Docker 入门教程:Docker 基础技术 Union
File System
3.1 AUFS
img
AUFS
,英文全称是Advanced Multi-Layered Unification Filesystem
,字面意思是高级多层联合文件系统 。它重写了早期的UnionFS1.x,并且加入了新功能,Docker也就使用的AUFS。
AUFS 是 Docker 选用的第 一种存储驱动 。 AUFS
具有快速启动容器、高效利用存储和内 存的优点 。 直到现在,AUFS 仍然是
Docker 支持的 一种存储驱动类型。
但AUFS是Docker早期使用的一种存储驱动,目前默认使用OverlayFS
。
Ubuntu/Debian(Stretch之前的版本)上的Docker-CE可以通过配置DOCKER_OPTS="-s=aufs"
进行修改,同时内核中需要加载AUFS
module,image的增删变动都会发生在/var/lib/docker/aufs
目录下。
img
对于AUFS来说,每一 个Docker image
都是由 一 系列
read-only layer
组成的 。 image layer
的内容都存储在Docker hosts filesystem
的/var/lib/docker/aufs/diff
目录下。而/var/lib/docker/aufs/layers
目录,则存储着image layer
如何堆栈这些 layer 的
metadata
。
虽然Docker已经没有使用AUFS
了,但是AUFS
和overlayFS
原理差不多。
3.2 动手实践OverlayFS
由于CentOS7已经默认不支持AUFS了,我们可以使用OverlayFS
用来练习。
Overlayfs通过三个目录:lower
目录、upper
目录、以及work
目录实现,其中lower目录可以是多个 ,work目录为工作基础目录 ,挂载后内容会被清空, 且在使用过程中其内容用户不可见 ,最后联合挂载完成给用户呈现的统一视图称为为merged
目录。
挂载选项支持(即“-o”参数):
1)lowerdir:指定用户需要挂载的lower层目录(支持多lower,最大支持500层)
2)upperdir:指定用户需要挂载的upper层目录;
3)workdir:指定文件系统的工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见;
注:lowerdir、upperdir和workdir为基本的挂载选项
首先创建overlaytest
文件夹,然后创建以下结构的文件。
image-20221016120718645
然后使用下面的命令挂载overlay文件系统:
mount -t overlay overlay -o lowerdir=image-layer1:image-layer2:image-layer3:image-layer4,upperdir=container-layer,workdir=work merge
参数说明 -t 文件系统类型 -o 表示options,多个参数间不可以有任何空格 workdir 必须是一个与 upperdir 相同文件系统的空文件夹。 merge 联合挂载点所在的目录。 lowerdir 可以指定多个文件夹,用 : 隔开,从从左往右级别依次降低(左边在上面,右边在下面)。 upperdir、workdir 参数可以省略,表示只读。
lowerdir 为只读层
upperdir
为最上层,可读可写。但它并不是融合层,在这一层读不到其他层的数据。
merge
是联合挂载点,可以读到所有层的融合数据。也可以对它进行写操作,但实际上数据是写入了upperdir
挂载完成后可以看到merge
文件夹的内容即是联合文件系统的内容:
image-20221016120800725
可以使用df -hl
查看挂载:
image-20221016121436713
由于挂载的时候从左到右层级从上往下,那么如果遇到文件名或文件夹名冲突怎么处理呢?
OverlayFS的合并策略如下:
名称不冲突,直接合并
文件夹名冲突,合并文件夹下所有文件到一个文件夹
文件名冲突,只显示上层的同名文件。
直观的图如下:
img
接下来我们测试在联合文件系统上修改文件,使用下面的命令往merge
的image-layer1.txt
写入内容:
echo -e "\nwrite to image-layerl.txt" >./merge/image-layer1.txt
然后查看目录结构,发现在我们的upperdir
中多了一个image-layer1.txt
。
image-20221016152257359
然后发现虽然我们在联合文件上修改了文件,merge
中的文件内容也的确被修改了,但是
原文件image-lay1/image-layer1.txt
的内容并没有被修改。
image-20221016152453009
也就是说, 当尝试向 merge/image-layer1.txt
文件进行写操作的时候 , 系统首先在 merge
目录下 查找名为
image-layer1.txt
的文件,将其拷贝到
read-write(upperdir)
层的 container-layer
目录中,接着对container-layer
目录中的
image-layer1.txt
文件进行写操作
,也正是写时复制的思想。
四、构建实现Run命令的容器
4.1 Linux proc文件系统介绍
Linux 下的 /proc
文件系统是
由内核提供的,它其实不是一个真正的文件系统,只包含了系统运行时的信息(
比如系统内存、 mount
设备信息、一些硬配置等),它只存在于内存 中,而不占用外存空间
。
它以文件系统的形式,为访问内核数据的操作提供接口 。
目录中的数字即进程的PID,里面的内容即进程创建的空间。
image-20221016154620727
详细介绍可查看Linux系统proc目录说明 -
知乎 (zhihu.com)
4.2 实现run命令
首先实现一个简单run命令的容器,类似docker run -it [cmd]
。
目录结构如下:
image-20221017151012128
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 package mainimport ( log "github.com/sirupsen/logrus" "github.com/urfave/cli" "os" )const usage = `mydocker is a simple container runtime implementation. The purpose of this project is to learn how docker works and how to write a docker by ourselves Enjoy it, just for fun.` func main () { app := cli.NewApp() app.Name = "GoDocker" app.Usage = usage app.Commands = []cli.Command{ initCommand, runCommand, } app.Before = func (context *cli.Context) error { log.SetFormatter(&log.JSONFormatter{}) log.SetOutput(os.Stdout) return nil } if err := app.Run(os.Args); err != nil { log.Fatal(err) } }
"github.com/urfave/cli"
是go的一个命令行程序,使用go build .
后会生成一个可执行文件,从而可以启动时添加参数。
app.Commands
设定initCommand
和runCommand
两个命令函数。
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 package mainimport ( "GoDocker/container" "fmt" log "github.com/sirupsen/logrus" "github.com/urfave/cli" )var runCommand = cli.Command{ Name: "run" , Usage: `create a container with namespace and cgroups limit godocker run -it [command]` , Flags: []cli.Flag{ cli.BoolFlag{ Name: "it" , Usage: "enable tty" , }, }, Action: func (ctx *cli.Context) error { if len (ctx.Args()) < 1 { return fmt.Errorf("missing container command" ) } cmd := ctx.Args().Get(0 ) tty := ctx.Bool("it" ) Run(tty, cmd) return nil }, }var initCommand = cli.Command{ Name: "init" , Usage: "Init container, create an user's process for container, call inside only" , Action: func (ctx *cli.Context) error { log.Infof("Init container" ) cmd := ctx.Args().Get(0 ) log.Infof("command:%s" , cmd) err := container.RunContainerInitProcess(cmd, nil ) return err }, }
main_command.go
中定义了command需要执行的操作。
tty
是虚拟控制台,用于容器启动的交互模式。
runCommand
中会调用Run(tty, cmd)
来创建一个容器,创建容器完成后,再使用container.RunContainerInitProcess(cmd, nil)
来初始化容器。
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 func Run (tty bool , command string ) { parent := container.NewParentProcess(tty, command) if err := parent.Start(); err != nil { log.Error(err) } err := parent.Wait() if err != nil { return } os.Exit(-1 ) }func NewParentProcess (tty bool , command string ) *exec.Cmd { args := []string {"init" , command} cmd := exec.Command("/proc/self/exe" , args...) cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET, } if tty { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } return cmd }
NewParentProcess()
通过执行/proc/self/exe
自己调用自己,就会创建一个自己进程的子进程(也就是容器的进程),同时args
关联了新初始化的一些参数,后面通过Cloneflags
的namespace
创建了一个隔离环境的子进程。
后面的if tty
即如果用户run
时指定了-it
,则需要将当前进程的输入输出导入到标准输入输出上。
容器进程创建好了,就该进行初始化操作了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func RunContainerInitProcess (cmd string , args []string ) error { logrus.Infof("command: %s" , cmd) defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV syscall.Mount("proc" , "/proc" , "proc" , uintptr (defaultMountFlags), "" ) argv := []string {cmd} if err := syscall.Exec(cmd, argv, os.Environ()); err != nil { logrus.Errorf(err.Error()) } return nil }
MS_NOEXEC
等是挂载文件系统的一些限制条件,
MS_NOEXEC
:在本文件系统中不允许运行其他程序。
MS_NOSUID
:在本系统中运行程序的时候, 不允许
set-user-ID 或 set-group-ID 。
MS_NODEV
:自 从 Linux 2.4 以来,所有 mount
的系统都会默认设定的参数。
关键的就是syscall.Exec(cmd, argv, os.Environ())
这句代码,这句代码执行了一次系统调用,调用内核int execve()
这个函数,作用是执行我们指定的程序,而将当前进程原来的信息(镜像、数据、堆栈、PID)覆盖掉。
那有什么效果呢?我们容器创建完成后第一个执行的程序是init()
,那么执行init()
的进程就会成为PID=1
的进程,但是init()
只是内部可见的,并且Docker里每个容器PID=1
的也不是init()
的进程呀。所以需要将其替换掉。
我们启动容器会通过docker run -it /bin/sh
类似的命令进行启动,后面的/bin/sh
是我们执行的程序,那么/bin/sh
的进程才应该是pid=1
的进程,执行完上面的系统调用后,就可以达到这个效果。
还有另外一个原因是:我们指定的用户进程创建完成后就无法对其进行文件挂载 了,所以在执行系统调用前是主进程可管控 的子进程,可以对其进行文件挂载和资源限制,限制完成后执行系统调用将其转换为用户进程。
类似于创建一个参数设定好 的子进程,创建完成后再退位给用户进程,起到一个代理进程的作用。
这其实也是目前 Docker 使用的容器引擎 runC 的实现方式之一。
流程图如下:
image-20221017153706241
然后在linux上使用go build .
编译,会生成一个可执行文件。
image-20221017153809050
然后使用./GoDockerrun -it /bin/sh
启动容器,并用ps -ef
查看进程pid:
image-20221017153852391
可以看到PID=1
的进程的确是我们指定的/bin/sh
,执行ps -ef
的进程是pid=1
进程创建的子进程。
Docker要求容器必须有一个前台进程,即不会自动结束的进程,不然容器就会自动停止。我们pid=1
的/bin/sh
进程也就是一个前台进程。
可以通过./GoDockerrun -it /bin/ls
验证效果,执行完后容器就停止了。
4.3 增加容器资源限制
这一节将通过godocker run -it -m 100m -cpuset 1 -cpushare 512 /bin/sh
的方式控制容器内存和CPU资源。
4.3.1 定义Cgroups数据结构
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 type ResourceConfig struct { MemoryLimit string CpuShare string CpuSet string }type SubSystem interface { Name() string Set(cgroupPath string , res *ResourceConfig) error Apply(cgroupPath string , pid int ) error Remove(cgroupPath string ) error }var ( SubsystemsIns = []SubSystem{ &CpuSetSubsystem{}, &MemorySubsystem{}, &CpuSubsystem{}, } )
上面代码定义了资源限制的配置项、Subsystem
的操作接口,通过[]SubSystem
定义限制资源的具体结构体,均实现Subsystem
接口。
需要注意的是Cgroup
以
string
类型的cgroupPath
来表示,因为一个cgroup
是一个文件夹,只需要知道其路径即可。
4.3.2 资源限制操作代码
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 package subsystemsimport ( "fmt" "io/ioutil" "os" "path" "strconv" )type MemorySubsystem struct { }func (m MemorySubsystem) Name() string { return "memory" }func (m MemorySubsystem) Set(cgroupPath string , res *ResourceConfig) error { if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true ); err != nil { if res.MemoryLimit != "" { if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "memory.limit_in_bytes" ), []byte (res.MemoryLimit), 0644 ); err != nil { return fmt.Errorf("set cgroup memory fail %v" , err) } } return nil } else { return err } }func (m MemorySubsystem) Apply(cgroupPath string , pid int ) error { if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true ); err != nil { if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "tasks" ), []byte (strconv.Itoa(pid)), 0644 ); err != nil { return fmt.Errorf("set process pid to cgroup failed %v" , err) } return nil } else { return fmt.Errorf("get cgroup %s error: %v" , cgroupPath, err) } }func (m MemorySubsystem) Remove(cgroupPath string ) error { if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, false ); err == nil { return os.RemoveAll(subsysCgroupPath) } else { return err } }
上面的代码是对于内存进行限制的具体代码,其中用到了utils
工具类的代码来寻找Cgroup
的根目录
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 func FindCgroupMountPoint (subsystem string ) string { f, err := os.Open("/proc/self/mountinfo" ) if err != nil { return "" } defer func (f *os.File) { err := f.Close() if err != nil { } }(f) scanner := bufio.NewScanner(f) for scanner.Scan() { txt := scanner.Text() fields := strings.Split(txt, " " ) for _, opt := range strings.Split(fields[len (fields)-1 ], "," ) { if opt == subsystem { return fields[4 ] } } } if err := scanner.Err(); err != nil { return "" } return "" }func GetCgroupPath (subsystem string , cgroupPath string , autoCreate bool ) (string , error ) { cgroupRoot := FindCgroupMountPoint(subsystem) if _, err := os.Stat(path.Join(cgroupRoot, cgroupPath)); err == nil || (autoCreate && os.IsNotExist(err)) { if os.IsNotExist(err) { if err := os.Mkdir(path.Join(cgroupRoot, cgroupPath), 0755 ); err == nil { } else { return "" , fmt.Errorf("error create cgroup %v" , err) } } return path.Join(cgroupRoot, cgroupPath), nil } else { return "" , fmt.Errorf("cgroup path error %v" , err) } }
"/proc/self/mountinfo"
这个文件里存储了所有文件挂载信息,通过查询这个文件,和传入的subsystem
名称进行匹配,就可以得到subsystem
的绝对路径,也就是cgroup
的根目录。
mountinfo内容示例:406 30 7:26 / /snap/gnome-system-monitor/178 ro,nodev,relatime shared:207 - squashfs /dev/loop26 ro
,倒数第二项就是挂载的地址。
结果如下:
image-20221018161019058
然后我们写一个cgroup_manager
来将不同subsystem
的cgroup
管理起来,并与容器建立连接。
4.3.3 CgroupManager统一管理
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 type CgroupManager struct { CgroupPath string Resource *subsystems.ResourceConfig }func NewCgroupmanager (path string ) *CgroupManager { return &CgroupManager{CgroupPath: path} }func (c *CgroupManager) Apply(pid int ) error { for _, subSysIns := range subsystems.SubsystemsIns { err := subSysIns.Apply(c.CgroupPath, pid) if err != nil { return err } } return nil }func (c *CgroupManager) Set(res *subsystems.ResourceConfig) error { for _, subSysIns := range subsystems.SubsystemsIns { err := subSysIns.Set(c.CgroupPath, res) if err != nil { return err } } return nil }func (c *CgroupManager) Destroy() error { for _, subSysIns := range subsystems.SubsystemsIns { if err := subSysIns.Remove(c.CgroupPath); err != nil { logrus.Warnf("remove cgroup fail %v" , err) } } return nil }
调用过程如下:
image-20221018154536979
4.3.4 添加命令行参数
最后需要添加命令行的参数识别:
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 var runCommand = cli.Command{ Name: "run" , Usage: `create a container with namespace and cgroups limit godocker run -it [command]` , Flags: []cli.Flag{ cli.BoolFlag{ Name: "it" , Usage: "enable tty" , }, cli.StringFlag{ Name: "m" , Usage: "memory limit" , }, cli.StringFlag{ Name: "cpushare" , Usage: "cpushare limit" , }, cli.StringFlag{ Name: "cpuset" , Usage: "cpuset limit" , }, }, Action: func (ctx *cli.Context) error { if len (ctx.Args()) < 1 { return fmt.Errorf("missing container command" ) } var cmdArray []string for _, arg := range ctx.Args() { cmdArray = append (cmdArray, arg) } tty := ctx.Bool("it" ) resConf := &subsystems.ResourceConfig{ MemoryLimit: ctx.String("m" ), CpuSet: ctx.String("cpuset" ), CpuShare: ctx.String("cpushare" ), } Run(tty, cmdArray, resConf) return nil }, }
4.3.5 测试效果
仅凭上面列举的代码还无法运行,额外的改造部分没有进行说明。
测试内存限制:
使用./GoDocker run -it -mm 100m stress --vm-bytes 200m --vm-keep -m 1
使用stress创建一个200m的应用,但是限制了内存为100m。
由于-m
和stress的 -m
冲突了,所以启动会有问题,这里先改为-mm
image-20221018164758360
top
可以看到stress
内存占用为5%
,机器内存为2g
,也就是100m
image-20221018164955561
测试CPU时间片:
由于CPU限制只在CPU忙的时候有用,机器的两核CPU,所以启动两个stress进程。
启动两个个不限制时间片的进程:nohup stress --vm-bytes 200m --vm-keep -m 1 &
再启动一个限制时间片为256
的进程:./GoDocker run -it -cpushare 256 stress --vm-bytes 200m --vm-keep -m 1
cpu默认shares
为1024
,则限制进程能使用CPU最大为:256/(256 + 1024) = 1/5
,所以能用40%
的CPU资源。通过top查看的确进行了限制。
image-20221018182052322
4.4 增加管道及环境变量识别
4.4.1 管道基本知识
本节会为run命令增加管道和环境变量识别的功能。
当在 Linux
上创建两个进程时,进程之间的通信一般就会使用管道的机制。所谓管道,就是一个连接两个进程的通道,它是
Linux 支持 IPC
的其中一种方式。一般来说,管道都是半双工的 ,一端进行写操作,另外一端进行读操作。
常用的管道分为两种类型。一种类型是无名管道 ,
它一般用于具有亲缘关系的进程之间 。
另外一种是有名管道 ,或者叫 FIFO
管道 ,
它是一种存在于文件系统 的管道,可以被两个没有
任何亲缘关系的进程进行访问。有名管道一般可以通过
mkfifo()
函数来创建。
从本质上来说,管道也是文件的一种 , 但是它和文件通信的区别在于
,管道有一个固定大小的缓冲区 ,大小一般是
4KB。当管道被写满时,写进程就会被阻塞 ,直到有读进程把管道的内容读出来。同样地,当读进程从管道内拿数据的时候,如果这时管道的内容是空的,那么读进程同样会被阻塞
,一直等到有写进程向管道内写数据。
之前父进程和子进程进行传递是通过是通过调用命令后面跟上参数
,也就是/proc/self/exe init args
这种方式进行的,然后,在init
进程内去解析这个参数 ,
执行相应的命令。但是如果参数很长或者带有特殊字符,那么就会失败了。
runC的实现是通过匿名管道来实现传参的。
4.4.2
将管道两端链接到父进程和子进程
使用下面的函数创建一个匿名管道
func NewPipe () (*os.File, *os.File, error ) { read, write, err := os.Pipe() if err != nil { return nil , nil , err } return read, write, nil }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func NewParentProcess (tty bool ) (*exec.Cmd, *os.File) { readPipe, writePipe, err := NewPipe() if err != nil { log.Errorf("New pipe error %v" , err) return nil , nil } 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, } if tty { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } cmd.ExtraFiles = []*os.File{readPipe} return cmd, writePipe }
创建了管道,就要将管道两端连接起来。这里将管道的读取端文件句柄传入
cmd.ExtraFiles
,表明主进程带着这个句柄去创建子进程 。因为
1 个进程默认会有 3 个文件描述符,分别是标准输入、标准输出、标准错误。
这样子进程就有4个句柄了,也就接通了管道的读取端。
可以看到多了一个fd。
image-20221020105132133
接下来看子进程的改动。
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 func readUserCommand () []string { pipe := os.NewFile(uintptr (3 ), "pipe" ) msg, err := ioutil.ReadAll(pipe) if err != nil { log.Errorf("init read pipe error %v" , err) return nil } msgStr := string (msg) return strings.Split(msgStr, " " ) }func RunContainerInitProcess () error { cmdArray := readUserCommand() log.Infof("command: %s" , cmdArray) if cmdArray == nil || len (cmdArray) == 0 { return fmt.Errorf("run container get user command error, cmdArray is nil" ) } path, err := exec.LookPath(cmdArray[0 ]) if err != nil { log.Errorf("Exec loop path error %v" , err) return err } log.Infof("Find path %s" , path) if err := syscall.Exec(path, cmdArray[0 :], os.Environ()); err != nil { log.Errorf(err.Error()) } return nil }
通过exec.LookPath(cmdArray[0])
,就可以在系统环境变量里寻找命令,例如以前启动时必须写成bin/ls
,现在就可以只写ls
。
到此子进程通过管道读取命令已经完成了,下面看下父进程在哪里通过管道发送命令。
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 func Run (tty bool , commandArray []string , res *subsystems.ResourceConfig) { parent, writePipe := container.NewParentProcess(tty) if parent == nil { log.Errorf("New parent process error" ) return } if err := parent.Start(); err != nil { log.Error(err) } manager := cgroups.NewCgroupManager("godocker-cgroup" ) defer func (manager *cgroups.CgroupManager) { err := manager.Destroy() if err != nil { } }(manager) manager.Set(res) log.Infof("resourceConfig:%s" , res) log.Infof("parent.process.pid:%d" , parent.Process.Pid) manager.Apply(parent.Process.Pid) sendInitCommand(commandArray, writePipe) parent.Wait() }func sendInitCommand (comArray []string , writePipe *os.File) { command := strings.Join(comArray, " " ) log.Infof("commands are %s" , command) writePipe.WriteString(command) writePipe.Close() }
writePipe.WriteString(command)
就可以将命令写入管道。注意的是在parent.Start()
后才写入管道,说明写入管道的时候,子进程已经在读等待了。完整流程图如下:
image-20221020110121445
4.4.3 测试效果
使用./GoDocker run -it ls -l
查看执行效果,发现程序找到了bin/ls
,执行了ls
并返回。说明容器通过管道读到了ls
命令,并且在环境变量中找到了ls
。
image-20221020110809329