容器与开发语言
容器
随着云计算领域的兴起,容器这个词出现了,但是什么是容器?
容器英文名Container,是基于Linux Namespace以及Cgroups技术实现的具备隔离特性的一组进程。
OK,他是一组具备隔离特性的进程。
虚拟机
虚拟机是使用Hypervisor技术提供的虚拟化硬件的操作系统。
OK,虚拟机是一个操作系统。
操作系统和进程的区别
操作系统是管理软件、硬件的一组进程。
GO
这里不做介绍(其实我只能看懂一点点Go代码,没时间学,后面有机会再出这方面的Blog吧)
基础技术
Linux Namespace
Namespace即为名称空间,这是一个树状的结构,父名称空间可以看到子名称空间的所有内容,反之则不行。这类似于Spring框架的父子Beanfactory。
Linux 一个实现了6个不同的Namespace
Namespace 类型 | 系统调用参数 | 备注 |
---|---|---|
Mount Namespace | CLONE NEWNS | 文件系统挂载点 |
UTS Namespace | CLONE NEWUTS | 主机名 |
IPC Namespace | CLONE NEWIPC | 进程通信 |
PID Namespace | CLONE NEWPID | 进程ID |
Network Namespace | CLONE NEWNET | 网络 |
User Namespace | CLONE NEWUSER | 用户 |
对于这些Namespace,Linux提供了3个系统调用。
API | 备注 |
---|---|
clone | 创建新进程,并为其分配6个名称空间 |
unshare | 把进程移出名称空间 |
setns | 把进程加入名称空间 |
UTS Namespace
下面是一个main.go
文件,我们使用指令go run main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package main
import (
"os/exec"
"syscall"
"os"
"log"
)
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)
}
}取自原书第10页
然后我们发现我们进入到了一个shell命令中。
1 | [root@VM-4-4-centos tmp]# go run main.go |
接下来我们查看hostname并更改然后再次查看。
1 | [root@VM-4-4-centos tmp]# go run main.go |
回到宿主机上使用指令hostname,发现宿主机的hostname并没有发生改变。
1 | [root@VM-4-4-centos src]# hostname |
我们使用指令ps -ef | grep $$
查看当前进程的pid为1539189, ppid为1539185。
1 | sh-4.4# ps -ef | grep $$ |
然后分别查看他们的ns空间, 不难发现只有uts空间不一样。
1 | sh-4.4# ls -l /proc/$$/ns |
1 | sh-4.4# ls -l /proc/1539185/ns |
其他的Namespace
更多的例子可以查看原书11-19页,即可实现其他5个空间的隔离,其实只需要修改代码为下面这样即可。我们只需要使用符号 |
就能同时开启多个资源的隔离。
1 | cmd.SysProcAttr = &syscall.SysProcAttr{ |
Linux Cgroups
有了资源隔离,还差一点东西才能实现容器,那就是资源限制、控制、统计(包括CPU、Memory、IO等)。Linux Cgroups就是干这个事的。
cgroups(Control Groups)最初叫 Process Container,由 Google 工程师(Paul Menage 和 Rohit Seth)于 2006 年提出,后来因为 Container 有多重含义容易引起误解,就在 2007 年更名为 Control Groups,并被整合进 Linux 内核。顾名思义就是把进程放到一个组里面统一加以控制。
引用自: 原文链接
Task
在Cgroups术语中,Task就是一个进程。
Cgroup
即一个控制组,可以对一组进程进行配置。
Subsystem
具体的配置子系统,例如cpu子系统可以配置Cgroup中进程被调度的策略,memory子系统可以控制Cgroup中进程的内存占用。
Hierarchy
hierarchy把cgroup描述为一个树状结构,在这个树状结构中,Cgroups完成了继承,就和前面的Linux Namespace一样。
安装Cgroup库
1 | yum install -y libcgroup-tools.x86_64 |
查看cgroup
我们可以看到这里有很多cgroup,冒号左边是子系统,右边是cgroup。
1 | [root@VM-4-4-centos cpu]# lscgroup | head -n 15 |
查看子系统
下面的指令会列出所有的子系统,一般就几个子系统。
1 | [root@VM-4-4-centos cpu]# lssubsys -a |
查看子系统挂载
1 | [root@VM-4-4-centos src]# lssubsys -m |
Cgroup例子
如下图所示,cgroup(粉色) 是一个树状结构,组成了一个Hierarchy(绿色),而每一个子系统(蓝色)可以分配到一个Hierarchy上。
Cgroups三个组件的约束
系统在创建了新的 hierarchy之后,系统中所有的进程都会加入这个 hierarchy的 cgroup根节点,这个 cgroup根节点是 hierarchy默认创建的。
一个 subsystem只能附加到一个 hierarchy上面。
一个 hierarchy可以附加多个 subsystem。
一个进程可以作为多个 cgroup的成员,但是这些 cgroup必须在不同的 hierarchy中。
一个进程fork出子进程时,子进程是和父进程在同一个 cgroup中的,也可以根据需要将其移动到其他 cgroup中。
Cgroups 实战
我们安装下面的方式即可创建一个cgroup,这个cgroup在子系统cpu所附着的Hierarchy上。
只需要创建一个文件夹,cgroup就被创建了。
1 | [root@VM-4-4-centos cpu]# cd /sys/fs/cgroup/cpu |
接下来,我们来看两个文件
1 | [root@VM-4-4-centos my-cpu]# cat cpu.cfs_period_us |
cfs_period_us用来配置时间周期长度,cfs_quota_us用来配置当前cgroup在设置的周期长度内所能使用的CPU时间数,两个文件配合起来设置CPU的使用上限。两个文件的单位都是微秒(us),cfs_period_us的取值范围为1毫秒(ms)到1秒(s),cfs_quota_us的取值大于1ms即可,如果cfs_quota_us的值为-1(默认值),表示不受cpu时间的限制。下面是几个例子:
1
2
3
4
5
6
7
8
9
10
11 1.限制只能使用1个CPU(每250ms能使用250ms的CPU时间)
# echo 250000 > cpu.cfs_quota_us /* quota = 250ms */
# echo 250000 > cpu.cfs_period_us /* period = 250ms */
2.限制使用2个CPU(内核)(每500ms能使用1000ms的CPU时间,即使用两个内核)
# echo 1000000 > cpu.cfs_quota_us /* quota = 1000ms */
# echo 500000 > cpu.cfs_period_us /* period = 500ms */
3.限制使用1个CPU的20%(每50ms能使用10ms的CPU时间,即使用一个CPU核心的20%)
# echo 10000 > cpu.cfs_quota_us /* quota = 10ms */
# echo 50000 > cpu.cfs_period_us /* period = 50ms */引用: 原文链接
紧接着我们编写一个CPU密集型的算法,计算斐波拉契数列第100000000项的最后4位数字。
1 |
|
我们运行他,发现大概执行了1秒钟
1 | [root@VM-4-4-centos tmp]# g++ main.cpp -o main && time ./main |
现在我们构建一个只占用10%CPU的Cgroups,并让这个进程运行在这个Cgroups中。我们可以看到,这个进程在9.610秒内占用了0.963秒的CPU时间,这和我们希望看到的10%的CPU时间是相符合的。
1 | [root@VM-4-4-centos tmp]# echo 10000 > /sys/fs/cgroup/cpu/my-cpu/cpu.cfs_quota_us |
类似如内存占用的实战,在这里可以看到更多
Union File System
联合文件系统(Union File System):2004年由纽约州立大学石溪分校开发,它可以把多个目录(也叫分支)内容联合挂载到同一个目录下,而目录的物理位置是分开的。UnionFS允许只读和可读写目录并存,就是说可同时删除和增加内容。
作者:一叶
写时复制(copy-on-wrie,下文简称CoW),也叫隐式共享,是一种对可修改资源实现高效复制的资源管理技术。它的思想是,如果一个资源是重复的,但没有任何修改,这时并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少未修改资源复制带来的消耗,但是也会在进行资源修改时增加小部分的开销。
引用: 原书27页
AUFS
Advanced Multi-Layered Unification Filesystem 改写了UFS,提高其可靠性和性能。
AUFS实战
我们先创建如下目录
1 | [root@VM-4-4-centos aufs]# tree |
内容如下
1 | [root@VM-4-4-centos aufs]# cat image-layer1/file1.txt |
开始挂载
1 | [root@VM-4-4-centos aufs]# sudo mount -t aufs -o dirs=container-layer:image-layer1:image-layer2:image-layer3:image-layer4 none mnt |
GG 现在的centos上的docker,使用的不是aufs,而是OverlayFS,不信你看
1 | [root@VM-4-4-centos aufs]# df |
AUFS细节
读文件
下图是一个AUFS挂载情况,当我们对/mnt处进行读取文件的时候,他会从顶层依次向下寻找,如果在layer4层找到了这个文件,则直接读取,如果找不到就递归向下寻找。
写文件
写文件比较特殊,如果底层文件无法写入,则通过COW技术直接写mnt层即可。如果底层文件可读写,则直接写入底层文件。
删除文件
- 文件在mnt层,下层只读,且下层无此文件,直接删除
- 文件在mnt层,下层只读,且下层有此文件,删除mnt层,然后创建一个隐藏文件.wh.{filename}表示该文件被删除
- 文件在下层读写层,直接删除。
了解更多
OverlayFS
如下图所示,Overlay在主机上用到2个目录,这2个目录被看成是overlay的层。
upperdir为容器层、lowerdir为镜像层使用联合挂载技术将它们挂载在同一目录(merged)下,提供统一视图原文: 链接
了解更多
构造容器
Proc文件系统
Linux下的proc文件系统是由内核提供的,它其实不是一个真正的文件系统只包含了系统运行时的信息(比如系统内存、 mount设备信息、一些硬件配置等),它只存在于内存中,而不占用外存空间。它以文件系统的形式,为访问内核数据的操作提供接口。实际上,很多系统工具都是简单地去读取这个文件系统的某个文件内容,比如 Ismo,其实就是cat
proc/modules当遍历这个目录的时候,会发现很多数字,这些都是为每个进程创建的空间,数字就是它们的PID。
1 | [root@VM-4-4-centos aufs]# ll /proc | head -n 15 |
目录结构
目录 | 备注 |
---|---|
/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 | 进程状态信息,比stat/ statm更具可读性 |
/proc/self/ | 链接到当前正在运行的进程 |
有Go我不用
就用C++,哎,就是玩。
3.1版本
直接看main.cpp
中的主函数, 首先是解析参数,然后使用clone 系统调用制造一个进程。
1 |
|
然后看main.cpp
的子进程, 这里挂在proc目录是为了隔离,然后由于我们并没有编写镜像,所以我们mock了一个只支持centos的镜像。紧接着就是子进程调用exec替换掉自己的代码。
1 | int doContainer(void *param) { |
编译运行3.1
1 | mkdir build |
1 | [root@wsx pocker]# mkdir build |
默认的提示
1 | [root@wsx build]# ./pocker |
pocker run
的提示
1 | [root@wsx build]# ./pocker run --help |
调用ls指令
1 | [root@wsx build]# ./pocker run -it centos ls . |
调用ps指令
1 | [root@wsx build]# ./pocker run -it centos ps aux |
调用bash指令
1 | [root@wsx build]# ./pocker run -it centos bash |
pocker3.1 in docker
笔者还为大家准备了一份开箱即用的3.1版本。大家可以一起来学习。
1 | s@s ~ % docker run --privileged -it --rm 1144560553/pocker:3.1 bash |
3.2-cpu版本
这个版本笔者主要增加了一些cpu的限制,先看main.cpp中多了一个函数,这个函数是在容器启动前就已经调用了的,目的就是创建CPU子系统,我们可以看到这里主要使用了system系统调用,在目录/sys/fs/cgroup/cpu
下用容器的id为文件夹名字创建了一个cpu子系统,在其中修改cfs_quota_us和cfs_period_us来解决cpu资源的问题。具体可见[Cgroups 实战](#Cgroups 实战)。
1 | void prepareContainer(RunParam *runParam) { |
然后就是在容器启动后增加了下面这一段代码, 依然使用系统调用system,把容器所在的进程ID加入到tasks文件中,其目的就是让当前进程加入cpu子系统,来限制cpu的使用率。然后为了测试cpu子系统的有效性,笔者还在其中加了一个0到1e9的for循环来完成一个计算密集型任务。pocker会输出这个任务所占用的时间(单位为秒)。
1 | // add cpu subsystem |
当然笔者依然准备了一份开箱即用的docker版本,大家可以自行尝试。下面是笔者测试的结果。 我们可以看到当cpus取值为0.5的时候,花费了7秒,当取值为0.25的时候,花费了18秒。
1 | s@s ~ % docker run --privileged -it --rm 1144560553/pocker:3.2-cpu bash |
3.2-mem版本
新增的主要部分还是在main.cpp中,这个部分完成了内存子系统,这里直接看到,我们在位置/sys/fs/cgroup/memory
新建了一个文件夹,并限制了内存和交换内存的大小,注意到一旦进程发生了内存溢出,默认将会被kill
1 | // create memory subsystem |
笔者还是准备了一份开箱即用的docker版本(以后的docker版本都将直接转移到账号fightinggg下,而不是1144560553),大家可以直接尝试。
这里首先使用docker创建了一个pocker,然后使用pocker创建了一个内存空间10mb的容器,最后在容器中使用大量的内存,之后发现这个容器被Killed。
1 | s@s ~ % docker run --privileged -it --rm fightinggg/pocker:3.2-mem bash |
构造镜像
4.1-busybox版本
这个版本中,我们实现了容器根目录的隔离,首先从docker容器中导出busybox的文件系统,然后将其挂载到pocker所构造的容器中,主要代码在main.cpp中,这里增加了系统调用SYS_pivot_root
,把busybox的文件系统挂在到当前根目录,然后卸载旧的根目录。
1 | // mount busyBox 1. change workdir |
笔者还是准备了一个docker版本,可以看到这时候使用ls,已经能发现根目录下的文件系统发生了变化。
1 | s@s hexo-blog % docker run -it --rm --privileged fightinggg/pocker:4.1-busybox |
4.2-overlay版本
这部分笔者并没有选择和原书中一样的aufs文件系统,而是使用了overlay文件系统,哎就是玩。主要的修改还是在main.cpp
中。
大概是先创建一个disk.img文件,然后将其挂载到目录disk下,在disk目录下创建三个文件夹,upper、tmp和overlay,最后使用overlay文件系统以busybox为lower构造出一个两层文件系统。
这里有一个很重要的点,为什么要挂载disk.img,首先注意到一个事实,docker默认使用overlay文件系统来构造容器,所以我们下面这个程序是有可能运行在overlay文件系统下的。
overlay文件系统挂载时对upper有一定的要求,这导致了overlay文件系统没办法做为upper层挂载在另一个overlay文件系统下。
所以我们必须虚拟一个文件系统。
挂载流程和代码都在下面
1 |
|
笔者依旧准备了一份开箱即用的docker版本。如下。首先演示了在第一个容器中创建文件abc,然后退出容器,接着在第二个进入第二个容器,查看目录,是看不到文件abc的。
1 | s@s pocker % docker run -it --rm --privileged fightinggg/pocker:4.2-overlay bash |
4.3-volumes版本
今天去拍毕业照了,累了一整天,哎,真累,无聊中得到了两个点:
- 仔细想了想,4.2中让pocker去适配操作系统的文件系统,有点不太合理,我觉得应该pocker还是不应该管操作系统的文件系统是哪个具体的类型,让用户自己去挂载ext4就可以了,没必要在程序中搞。
- Java代码写C++真是shit到家了,我全给他改成了下划线命名。
然后步入正题,在4.2版本中我们注意到换根以后,卸载privot以前,可以做一些文件映射,于是笔者直接使用mount指令将其挂载,实现了容器数据的持久化,但是由于笔者使用的命令行框架似乎不支持数组格式的flag,所以目前只能映射一个目录。这个以后应该会修复,新增代码如下,非常简单。
1 | void pre_umount_privot(run_param *arg_run_param, string privot_root_name) { |
当然笔者还是准备了一个非常nice的docker版本,专门给懒人用的,下面是例子。
1 | s@s ~ % docker run -it --rm --privileged fightinggg/pocker:4.3-volumes |
容器进阶
笔者不准备实现这部分代码了。
5.1实现后台运行只需要将容器变为守护进程,让init收管即可
5.2实现查看运行中的容器是crud,把本地json文件当做了数据库
5.3实现查看容器日志只需要重定向容器的输出到文件中即可
5.4实现进入容器还是有点意思的,需要使用系统调用setns进入和容器相同的名称空间即可
5.5停止容器实际上只是发了一个kill信号
5.6删除容器清理一下就好了
5.7通过容器制作镜像只需要提前保留容器的upper文件系统,之后自己合并即可
5.8配置环境变量也只需要简简单单的在宿主进程进入容器时配置即可
容器网络
这部分过于偏向于计算机网络,对于这部分,笔者后面会专门出一篇Blog进行介绍。
这部分代码笔者也不准备实现。关于容器网络部分,笔者第一次接触到是在《Kubernetes权威指南:从Docker到Kubernetes实践全接触》(第2版)-2016.10-电工-P519-龚正,吴治辉,王伟 等 的第三章第七节网络原理中,这两本书的这两个地方中有很多重复的地方。
至此,笔者的Pocker项目也将会告一段落了。
高级实践
这部分介绍一些名词,希望可以体会到其中的设计理念。
OCI
Open Container Initiative 是一个组织,是Linux基金会在2015年成立的,是一个定义容器标准的组织。
runC
runC是一个容器引擎,他主要用于构造、运行容器。
runC是个轻量级的容器运行引擎, 包括所有 Docker 使用的和容器相关的系统调用的代码。
runC的目标就是去构造到处都可以运行的标准容器。
引用: 原书177页
runC的流程和pocker的流程是比较类似的,pocker只是个小demo,毕竟全部加起来也就几百行代码,他没有使用任何设计模式。
代码读到这里,应该可以大概理解runC创建容器的整个过程了,如下
1.读取配置文件。
2.设置 rootfilesystem
3.使用 factory创建容器,各个系统平台均有不同实现。
4.创建容器的初始化进程 process
5.设置容器的输出管道,主要是Go的 pipes
6.执行 Container. Start)启动物理的容器
7.回调init方法重新初始化容器进程。
runC父进程等待子进程初始化成功后退出。
可以看到,具体的执行流程涉及3个概念: process、 container、 factory。 factory用来创建容器, process 负责进程之间的通信和启动容器
引用: 原书185页
Containerd
containerd是一个守护进程,专注于容器的生命周期管理,方便容器编排。当然containerd只是docker的一部分,他不负责镜像的构造。
每个 contained只负责一台机器,Pul镜像、对容器的操作(启动、停止等)、网络、存储都
是由 container完成的。具体运行容器由runC负责。
CRI容器引擎
CRI是 Container Runtime Interface ,即容器运行时,众所周知,容器不只docker这一个软件支持,还有很多其他的支持容器的软件,k8s是负责容器编排的,k8s定义了一套CRI标准,只要你的容器支持CRI,那么k8s就可以帮助你管理他。
1 | classDiagram |
- 本文作者: fightinggg
- 本文链接: http://fightinggg.github.io/yilia/yilia/QRN6OO.html
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!