从名字上就可以很明显的看出容器就是盛放东西的实体,比如盛放饮料的杯子☕️。
在计算机的世界里并没有饮料,计算机世界中只有资源,比如cpu、内存、磁盘等等,而容器的作用正是盛放我们的各种计算机资源。容器是从container翻译过来的,但是其实container的另一个翻译’集装箱‘可能更能符合语义。举个例子,汽车🚗(我们的程序)从天津港(开发环境)装进集装箱箱运输到新加坡港口(生产环境),中间不会损失任何零件,而汽车🚘运输到新加坡港后落地就可以直接启动。这就是容器化的第一个优势,打包环境(namespace),我们直接将汽车及需要的汽油(内存、cpu)等打包放在一个集装箱内,落地就可以开动,而不需要使用当地的一些可能型号不匹配的汽油。而容器的另一个优势就是汽车的大小灵活的选择使用的集装箱大小,避免浪费(control group)。至于容器的由来嘞,而这就设计到容器的前世今生了。
在容器出现前,隔离方案都会采用虚拟机(virtual mechine)。大部分虚拟机的隔离方案是基于hypervisor的方式(在物理硬件和操作系统中增加虚拟层)进行硬件物理层面的隔离,每一个虚拟机都是一个操作系统的完整实现包含一个操作系统、应用程序、必要的二进制文件和库的完整副本——这些文件占用了数十gb,而vm启动也可能很慢。
虚拟机的实现架构图,图片来源于网络。虚拟机是将一台服务器转换为多台服务器的物理硬件的抽象。
有句非常经典的话:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,如果不能解决就再加一层(ps:经典套娃2333)。这样可以在linux的vm上运行linux程序,在windows的vm上运行windows程序,在linux上安装低版本的linux vm来运行不兼容高版本的程序。一切听起来都是那么美好,虚拟机最大的问题就是他太“重”了,一个虚拟机会包括计算机的全部基础组成单元(cpu、内存、一个完整的操作系统实现及其所附带的全部应用程序),这对运维工作来说就是噩梦,比如你只是想单纯的启动一个app,但是却要启动一台“物理”计算机(包含cpu、内存、操作系统)等等。
容器(docker)的实现架构图,图片来源于网络。
与虚拟机不同的是,容器是在操作系统层级进行的虚拟化,相较于虚拟机,更加轻便和轻量。
容器是应用程序层的一个抽象,它将代码和依赖关系打包在一起。多个容器可以运行在同一台机器上,并与其他容器共享操作系统内核,每个容器都作为用户空间中的独立进程运行。容器比vm占用更少的空间(容器映像通常也就几十MB),可以处理更多的应用程序。
每一个容器使用的资源依然依赖于宿主机os,但是具体的资源配额/环境隔离由docker来保证。
而运维部署也变得简单了起来,打包资源->上线->启动容器。
control what you can see
Linux Namespace是Linux提供的一种内核级别环境隔离的方法。linux 现在实现了了6种不同类型的namespace,每一种namespace都是将特定的操作系统资源封装在一个抽象内,使得namespace中的进程看起来拥有了独属自己的操作系统资源。而支持实现容器就是namespaces一个非常重要的目标。目前的6种namespace实现如下表。
名称 | 内核参数 | 内核版本 | 作用 |
---|---|---|---|
Mount namespaces | CLONE_NNENS | linux 2.4.19 | 提供文件系统层隔离 |
UTS namespaces | CLONE_NEWUTS | linux 2.6.19 | 隔离sethostname()和setdomainname()系统调用 |
IPC namespaces | CLONE_NEWIPC | linux 2.6.19 | 隔离unix IPC(interprocess communication) |
PID namespaces | CLONE_NEWPID | linux 2.6.24 | 隔离 pid |
Network namespaces | CLONE_NEWNET | started in linux 2.6.24 and largely completed by linnux 2.6.29 | 隔离网络,例如ip、路由表等 |
User namespaces | CLONE_NEWUSER | started in linux 2.6.23 and largely completed by linnux 3.8 | 隔离user ID、groupId |
Namespace有3个关键系统调用
以下所有示例使用的环境 golang 版本:go1.15.3 linux 内核版本:4.14.81 我们的目录结构非常简单,只包含我们的main文件和mod依赖文件:
├── go.mod
└── main.go
0 directories, 2 files
在docker中启动容器的命令是docker run {image} <cmd> <paramas>
。
所以我们也参考docker的启动,对于我们的容器,启动命令是 go run main.go run {our image}
。
依照程序员的惯例👀,emmmm,我上来就是一个hello world。
package main
import (
"fmt"
"os"
)
//docker run image <cmd> <params>
//go run main.go run image
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("bad command")
}
}
func run() {
fmt.Printf("Running %v\n", os.Args[2:])
}
func must(err error) {
if err != nil {
panic(err)
}
}
让我们尝试运行一下~
go run main.go run echo hello container
我们会得到输出: Running [echo hello container]
。
我们的第一个容器已经跑起来了,他只是简单的接受命令行参数并把它原样打出来,接下来我们会不断完善它。
接下来为我们的容器添加一点”动态“的内容吧~,show you the code
// 我们的main函数保持不变
func run() {
fmt.Printf("Running %v\n", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
运行 go run main.go run echo hello container
我们会得到输出:
Running [echo hello container]
hello container
在run函数中我们获取的命令行参数(echo),并执行了它,所以在屏幕上我们会看到echo输出的一行信息,有了exec 之后就可以玩一些更好玩的了,比如我们可以启动一个shell,go run main.go run /bin/bash
,这是非常关键的一步,有了shell之后我们能更清晰的看到我们的“容器”。
继续完善我们的main函数,先来尝试第一个namespace -> 「UTS namespace」
UTS namespace 隔离了uname()系统调用返回的两个系统标识符nodename和domainname,在容器的上下文中,UTS 允许每个容器拥有自己的hostname和domainname,有了hostname做区分,可以针对的定制很多脚本,在我们的例子中就更容易区分我们的宿主机和容器。
func run() {
fmt.Printf("Running %v\n", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:syscall.CLONE_NEWUTS,
}
}
继续 go run main.go run /bin/bash
,在这可能会失败,因为namespace要求root权限,可以先build,再sudo去执行二进制就好啦
让容器跑起来,我们的容器hostname是继承自宿主机的,所以直接执行hostname会发现与宿主机一致的,但是后续的修改不会同步到宿主机。
在linux中有一个特殊的进程就是/proc/self/exe
,他始终指向当前正在运行的进程,所以可以很方便的使用它来愉快的套娃了。
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
//docker run image <cmd> <params>
//go run main.go run image
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("bad command")
}
}
func run() {
fmt.Printf("Running run %v\n", os.Args[2:])
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[3:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
err := cmd.Run()
if err != nil {
panic(err)
}
}
func child() {
fmt.Printf("Running child %v\n", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
syscall.Sethostname([]byte("container"))
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
panic(err)
}
}
PID namespace 隔离进程PID namespace。换句话说,不同PID namespace 中的进程可以使用相同的PID。PID namespace的最大的好处是可以在主机之间迁移容器,同时保持容器内进程的相同进程id。PID namespace还允许每个容器拥有自己的init
进程(既PID为1的进程),它是“所有进程的祖先”,管理各种系统初始化任务,并在孤立的子进程终止时回收它们的资源(防止僵尸进程)。
在上边的UTS namespace中我们有使用到/proc/self/exe
,它指向的是我们当前的运行进程。为了更方便的进行下面的pid介绍,先简单介绍下linux的/proc
目录和pid是什么。
PID 代表进程标识号process identification,它是在操作系统中创建时自动分配给每个进程的唯一标识号。而/proc
目录是一个在内存中的虚拟文件系统,记录了操作系统的所有运行时信息。
上图就是我们本机的/proc
目录,可以看到目录名都是当前正在运行的进程的pid,而/proc/self
指向的就是当前运行的进程!
下图是我们执行起容器内的进程,可以看到有sudo、/proc/self/exe、我们的容器、zsh、和当前的ps,让我们来尝试一下PID namespace,预期中exe会变成我们的init进程(pid = 1)。
func run() {
fmt.Printf("Running run %v\n", os.Args[2:])
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[3:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | //利用bit位,使用|操作符来指定多个clone flag
syscall.CLONE_NEWPID,
}
err := cmd.Run()
if err != nil {
panic(err)
}
}
再次执行一下程序,在程序的打印中可以看到实际进程的pid已经改了,但是通过ps依然看到的另一套pid。
其实在pid namespace中的进程会拥有两种pid,分别是进程在宿主机中的pid和进程在容器中的pid,而ps
命令查看的就是/proc
目录,所以我们在容器内使用ps命令查看的其实是宿主机的pid。这里我们可以把内存中的proc目录挂载一下就可以看到当前的运行进程了。下图中的1,14,6分别是我们的exe,ls,zsh进程。
但是通过ps命令查看的依然是我们的宿主机情况。由于在我们的容器中挂载会影响到我们的宿主机,所以很自然的就想到了使用CLONE_NEWNS
来隔离挂载点,但是由于直接挂载/proc
目录会影响到我们的宿主机,所以是不是可以直接挂载新的fs呢?
为了不影响我们宿主机的proc目录,这里选择了apline
的文件系统(找不到linux资源的同学可以见文末的github地址),放到当前目录下了,目前的文件结构是这样的。
tree -L 2
.
├── alpine
│ ├── bin
│ ├── dev
│ ├── etc
│ ├── home
│ ├── lib
│ ├── media
│ ├── mnt
│ ├── opt
│ ├── proc
│ ├── root
│ ├── run
│ ├── sbin
│ ├── srv
│ ├── sys
│ ├── tmp
│ ├── usr
│ └── var
├── go.mod
├── implement-container
└── main.go
linux继承了unix系统的一个非常重要的sys call->chroot
,在这里我们就可以利用chroot
+chdir
来改变容器的根目录,再将proc目录挂载一下就可以啦。
func child() {
fmt.Printf("Running %v as %d\n", os.Args[2:], os.Getpid())
syscall.Chroot("./implement-container/apline")
syscall.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")
defer syscall.Unmount("proc", 0)
cmd := exec.Command(os.Args[2], os.Args[3:]...)
syscall.Sethostname([]byte("container"))
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
panic(err)
}
}
再次编译、执行我们的程序。进程的process id已经变成了我们预期内的id,pid=1是我们的self进行,pid=6是我们启动的shell,pid=8是我们的当前ps进程。
这是因为systemd 将默认的mount的事件传播机制定义成了 MS_SHARED,这里就不做过多的展开了,感兴趣的可以自己去查一下mount的机制。解决方法也很简单,在clone时将Unshareflags
设置为CLONE_NEWNS
就可以啦。
func run() {
... some code
cmd.SysProcAttr = &syscall.SysProcAttr{
//利用bit位,使用|操作符来指定多个clone flag
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
Unshareflags: syscall.CLONE_NEWNS,
}
... some code
}
有用过docker的同学可能会发现,我们现在的container
已经和docker
容器长得非常像了,有独立的pid,有独立的hostname,有独立的文件系统(ps:其实我们用的这个alpine
的文件系统就是我从docker apline中cp出来的)。剩下的IPC namespace、NET namespace、USER namespace也是基本相同的用法,而namespace的作用就是:namespace control what you can see。
control what you can use简介
在上文中我们已经使用namespace技术来实现了环境隔离,但是我们的容器依然可以可以无限制的使用宿主机的各种资源,比如CPU、I/O、网络等等,所以我们也需要对我们的容器增加资源的限制,而linux cgroups
就可以实现这一点。
CGroups全称:Control Groups,是Linux内核提供的一种可以限制单个进程或者多个进程所使用资源的机制,可以对 cpu,内存等资源实现精细化的控制。cgroups中有四个非常重要的概念:
cgorup是linux内核态的控制机制,而cgroup如何暴露给用户进程使用呢?答案就是VFS(virtual file system),
通过VFS系统,在用户态对文件层级的操作转换为对cgroup层次结构的操作。具体的路径在/sys/fs/cgroup
,可以看到该目录下有很多子系统。
更多control groups的介绍就自行查阅啦,下面进入实战环节。
pid subsystem 控制我们容器内的进程数量,若创建进程数已达上限,再创建时内核会返回资源不足。pid cg的路径在/sys/fs/cgroup/pid
。让我们先来创建一个pid group吧。
func child() {
//记得要在chroot之前调用,否则新建的就不是宿主机的cg了
pidControl(20)
//other code...
}
func pidControl(maxPids int) {
pidCg := "/sys/fs/cgroup/pids"
groupPath := filepath.Join(pidCg, "/gocg")
//创建gocg组
err := os.Mkdir(groupPath, 0775)
if err != nil && !os.IsExist(err) {
panic(err)
}
//最多的pid数量
must(ioutil.WriteFile(filepath.Join(groupPath, "pids.max"), []byte(strconv.Itoa(maxPids)), 0700))
//将当前进程加入到gocg组
must(ioutil.WriteFile(filepath.Join(groupPath, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}
上述程序,我们新建了名为gocg的pid cgroup,将最大进程数限制为10,并将当前进程加入到gocg控制组内。
执行程序,在宿主机查看gocg的状态,发现除了我们新建的pids.max
和cgroup.procs
,还有一些os自动创建的文件,查看pids.max和cgroup.procs,发现我们的控制数据已经正常写入了。
接下来让我们来测试一下pid的最大限额是否生效吧,有一串非常经典的shell脚本,叫做fork bomb。
: () { :|: & };:
含义是
:
的函数()
{:|:&}
递归调用自己,并利用管道创建进程;
:
就是执行调用命令了
结果就是不断的创建子进程,直到崩溃。让我们来测试下。
cpu subsysytem 限制系统使用的cpu时间片,最基本是使用cpu.cfs_quota_us
和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.限制只能使用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 cg的魅力吧。首先还是启动我们的容器,这里直接写了一个shell死循环脚本,方便我们查看cpu消耗。
while true; do ; done;
可以看到我们的进程直接拉满了一核,接下来让我们加上cpu cgroup
限制,将cpu限制到0.5核。
func child() {
//限制20个进程
pidControl(20)
//限制使用的cpu为0.5核
cpuControl(0.5)
//other code ...
}
func cpuControl(core float64) {
pidCg := "/sys/fs/cgroup/cpu"
groupPath := filepath.Join(pidCg, "/gocg")
//创建gocg组
err := os.Mkdir(groupPath, 0775)
if err != nil && !os.IsExist(err) {
panic(err)
}
//10ms
cfs := float64(10000)
//cpu配额
must(ioutil.WriteFile(filepath.Join(groupPath, "cpu.cfs_quota_us"), []byte(strconv.Itoa(int(cfs*core))), 0700))
//时间周期
must(ioutil.WriteFile(filepath.Join(groupPath, "cpu.cfs_period_us"), []byte(strconv.Itoa(int(cfs))), 0700))
//将当前进程加入到gocg组
must(ioutil.WriteFile(filepath.Join(groupPath, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}
再次执行相同的命令,可以看到已经被限制到0.5核了。
剩下的各种cgroups
的subsystem也是基本类似的用法,而cgroups的作用就是我们开篇的那句话:cgroups control what you can use
以上我们实现了一个非常简单的容器,我们学会了通过namespace这个容器有独立于宿主机的hostname、pid、root fs。学会通过cgroups,限制了最大的进程数为20,cpu的核心限制到0.5核。业界的容器的实现核心也是namespace+cgroup的各种组合,linux的api设计是高度正交的,通过各种参数的配置我们就能获得各种各样的容器。非常感慨,写一篇技术文章真的是太累了,自己的知识储备太少了,还是需要多加强日常的知识储备,到写文章时就可以非常流畅的完成了,本篇全部的源代码和精简的alpine的实现已上传到github,需要的自取呀。https://github.com/yinpeihao/go-container
暂时告别linux了,太难了,接下来会尝试梳理下golang的源码包~
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。