仅作为自己学习过程的记录,不具备参考价值
前言
看到一段非常有意思的话:
很多人刚接触
docker
的时候就会感觉非常神奇,感觉这个技术非常新颖,其实并不然,docker
使用到的技术都是之前已经存在过的,只不过旧酒换了新瓶罢了。简单来说docker
本质其实是一个特殊的进程,这个进程特殊在它被Namespace
和Cgroup
技术做了装饰,Namespace
将该进程与Linux
系统进行隔离开来,让该进程处于一个虚拟的沙盒中,而Cgroup
则对该进程做了一系列的资源限制,两者配合模拟出来一个沙盒的环境。
本文的学习地址/参考文档:
- 从零自制docker
- manpages.ubuntu.com
- github
- segmentfault.com
- juejin.im
- 地鼠文档
感谢大佬的写作,受益良多。自认一介尘民做喜欢且能安身立命之本乃人生一大幸事
本文只是对照其进行的拙劣模仿 以及自己半猜测式的研究记录。如有疑问 欢迎指出,感谢
代码环境配置
因为我是在Windows
里面写代码,然后进行交叉编译到Linux
,所以这里要更改下环境,因为在不同的环境中,go
导入的文件也是不同,如果我们的环境使用的Windows
,那么使用os/exec
包时,导入的将是exec_windows.go
,而如果我们的环境是Linux
,那么将会导入exec_linux.go
文件,因为只有Linux
才会给创建进程时提供这个隔离参数,所以我们需要把环境改成Linux
。
进程隔离
clone系统调用
- CLONE_NEWPID:
当程序代码调用clone时,设定了CLONE_NEWPID,就会创建一个新的PID Namespace,clone出来的新进程将成为Namespace里的第一个进程。一个PID Namespace为进程提供了一个独立的PID环境,PID Namespace内的PID将从1开始,在Namespace内调用fork,vfork或clone都将产生一个在该Namespace内独立的PID。新创建的Namespace里的第一个进程在该Namespace内的PID将为1,就像一个独立的系统里的init进程一样。该Namespace内的孤儿进程都将以该进程为父进程,当该进程被结束时,该Namespace内所有的进程都会被结束。PID Namespace是层次性,新创建的Namespace将会是创建该Namespace的进程属于的Namespace的子Namespace。子Namespace中的进程对于父Namespace是可见的,一个进程将拥有不止一个PID,而是在所在的Namespace以及所有直系祖先Namespace中都将有一个PID。系统启动时,内核将创建一个默认的PID Namespace,该Namespace是所有以后创建的Namespace的祖先,因此系统所有的进程在该Namespace都是可见的。
- CLONE_NEWIPC:
当调用clone时,设定了CLONE_NEWIPC,就会创建一个新的IPC Namespace,clone出来的进程将成为Namespace里的第一个进程。一个IPC Namespace有一组System V IPC objects 标识符构成,这标识符有IPC相关的系统调用创建。在一个IPC Namespace里面创建的IPC object对该Namespace内的所有进程可见,但是对其他Namespace不可见,这样就使得不同Namespace之间的进程不能直接通信,就像是在不同的系统里一样。当一个IPC Namespace被销毁,该Namespace内的所有IPC object会被内核自动销毁。
- PID Namespace和IPC Namespace:
PID Namespace和IPC Namespace可以组合起来一起使用,只需在调用clone时,同时指定CLONE_NEWPID和CLONE_NEWIPC,这样新创建的Namespace既是一个独立的PID空间又是一个独立的IPC空间。不同Namespace的进程彼此不可见,也不能互相通信,这样就实现了进程间的隔离。
- CLONE_NEWNS:
当调用clone时,设定了CLONE_NEWNS,就会创建一个新的mount Namespace。每个进程都存在于一个mount Namespace里面,mount Namespace为进程提供了一个文件层次视图。如果不设定这个flag,子进程和父进程将共享一个mount Namespace,其后子进程调用mount或umount将会影响到所有该Namespace内的进程。如果子进程在一个独立的mount Namespace里面,就可以调用mount或umount建立一份新的文件层次视图。该flag配合pivot_root系统调用,可以为进程创建一个独立的目录空间。
- CLONE_NEWNET:
当调用clone时,设定了CLONE_NEWNET,就会创建一个新的Network Namespace。一个Network Namespace为进程提供了一个完全独立的网络协议栈的视图。包括网络设备接口,IPv4和IPv6协议栈,IP路由表,防火墙规则,sockets等等。一个Network Namespace提供了一份独立的网络环境,就跟一个独立的系统一样。一个物理设备只能存在于一个Network Namespace中,可以从一个Namespace移动另一个Namespace中。虚拟网络设备(virtual network device)提供了一种类似管道的抽象,可以在不同的Namespace之间建立隧道。利用虚拟化网络设备,可以建立到其他Namespace中的物理设备的桥接。当一个Network Namespace被销毁时,物理设备会被自动移回init Network Namespace,即系统最开始的Namespace。
- CLONE_NEWUTS:
当调用clone时,设定了CLONE_NEWUTS,就会创建一个新的UTS Namespace。一个UTS Namespace就是一组被uname返回的标识符。新的UTS Namespace中的标识符通过复制调用进程所属的Namespace的标识符来初始化。Clone出来的进程可以通过相关系统调用改变这些标识符,比如调用sethostname来改变该Namespace的hostname。这一改变对该Namespace内的所有进程可见。CLONE_NEWUTS和CLONE_NEWNET一起使用,可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。
- 集合
以上所有clone flag都可以一起使用,为进程提供了一个独立的运行环境。LXC正是通过在clone时设定这些flag,为进程创建一个有独立PID,IPC,FS,Network,UTS空间的container。一个container就是一个虚拟的运行环境,对container里的进程是透明的,它会以为自己是直接在一个系统上运行的。
Namespace隔离
package main
import (
"log"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("sh")
// 设置新的Namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
//设置了系统调用的属性,特别是Cloneflags,它指定了新进程将使用哪些Namespace。
//syscall.CLONE_NEWNS隔离了挂载点
//syscall.CLONE_NEWUTS隔离了主机名和域名
//syscall.CLONE_NEWPID隔离了进程ID
//syscall.CLONE_NEWNET隔离了网络
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET,
}
//将新命令的输入、输出和错误输出重定向到当前进程的对应文件描述符
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
构建过程不多赘述,直接丢到ubuntu
上测试一下:
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build -o main
效果如下,外部的主机名并没有被改变,说明我们的go进程成功将自身的hostname
与外部hostname
进行了隔离。
设置容器的UID和GID
在Linux
系统中,每个进程都与特定的用户ID(UID)
和组ID(GID)
关联,这些ID
决定了进程对文件、设备和系统资源的访问权限。在传统的Linux
系统中,这些ID
是全局的,意味着系统中的每个UID
和GID
在任何时候都指向相同的用户或用户组。
随着容器技术的发展,出现了一种对这些ID
进行隔离的需求,以便在容器环境中提供安全性和多租户隔离。用户命名空间(User Namespaces)
是Linux
内核的一个特性,使这种隔离成为可能。
当你创建一个新的用户命名空间时,可以定义一个UID
和GID
的映射,这个映射告诉内核如何将命名空间内的ID
转换为命名空间外的主机系统ID
。这样,即使是容器内部以root
身份运行的进程,在宿主机中也可以被限制为非特权用户,从而提高了安全性。
UidMappings 和 GidMappings 字段
UidMappings
和GidMappings
字段是在创建新的用户命名空间时使用的,它们定义了容器内部ID和宿主机ID之间的映射关系。这些字段是syscall.SysProcAttr
结构体的一部分,当使用CLONE_NEWUSER
标志创建新的用户命名空间时,需要设置这些字段。
举个栗子
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1,
HostID: 0,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 1,
HostID: 0,
Size: 1,
},
},
这里的映射定义了以下关系:
ContainerID: 1
:这是命名空间内部使用的UID/GID。在此例中,我们使用的是编号为1的ID。HostID: 0
:这是宿主机上的UID/GID,编号0通常代表root用户/组。Size: 1
:这表示映射的范围。大小为1意味着只有一个UID/GID被映射。
在这个映射中,我们说命名空间内部的UID/GID 1对应于宿主机的root用户/组。这意味着,在此用户命名空间中运行的进程,尽管它可能以UID 1执行,但它在命名空间外部(宿主机上)被视为root
用户。因此,这个进程在宿主机角度看来有root
权限,但是这通常不是我们期望的。通常,我们希望在容器内部拥有较高权限的进程,在宿主机上对应为较低权限的用户,以提供更强的安全隔离。
实践
在实际的容器环境中,通常会将容器内部的root
用户(UID 0)映射到宿主机上的一个非特权用户。例如,UID映射可能如下所示:
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0, // 容器内的root用户
HostID: 1000, // 宿主机上的非特权用户
Size: 1,
},
},
在这个例子中,容器内部的root
用户(ContainerID: 0)实际上是宿主机上的UID 1000
,这通常是一个普通用户。这样,即使容器内部的进程以root
身份运行,它也只有宿主机上普通用户的权限,从而限制了它可能造成的安全风险。
在新增用户映射后代码如下:
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET,
// 设置容器的UID和GID
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 1000,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 1000,
Size: 1,
},
},
}
资源限制
cgroups(控制组)
是一种内核特性,用于限制、记录和隔离进程组所使用的物理资源(如CPU、内存、磁盘I/O等)。在Go语言中,我们可以通过操作/sys/fs/cgroup
下的文件来与cgroups交互。这涉及到文件系统的操作,比如创建目录、写入文件等,这些都可以通过Go标准库中的os
包来实现。
基础概念
在开始编写代码之前,我们需要了解cgroups
的一些基础概念:
Cgroup
子系统:cgroups
将其功能按资源类型划分为多个子系统,如cpu
、memory
、blkio
等Cgroup
层级:每个子系统可以挂载到一个或多个层级,每个层级可以创建多个cgroup
Cgroup
:每个cgroup
代表一组进程,并且每个cgroup
都可以设置资源限制或统计
使用Go操作cgroups
大致可以分为以下几个步骤:
- 挂载
cgroup
子系统:通常在Linux系统启动时,cgroup
子系统就已经被挂载。你可以在/sys/fs/cgroup
目录下看到各种资源类型的目录 - 创建
cgroup
:通过在相应的子系统目录下创建新目录来创建cgroup
- 添加进程到
cgroup
:将进程ID
写入到cgroup
目录的cgroup.procs
文件中 - 设置资源限制:通过修改或写入特定的配置文件(如
memory.limit_in_bytes
)来设置资源限制 - 清理:任务完成后,删除
cgroup
以释放资源
实际在ubuntu
上操作一下,首先创建一个挂载点目录:
mkdir CgroupTest
挂载hierarchy
:
mount -t cgroup -o none,name=CgroupTest CgroupTest ./CgroupTest/
查看内容:
cgroup.clone_children
:subsystem
会读取该文件,如果该文件里面的值为1
的话,那么子cgroup
将会继承父cgroup
的cpuset
配置cgroup.procs
:记录当前节点cgroup
中的进程组ID
task
: 标识该cgroup
下的进程ID
,如果将某个进程的ID
写到该文件中,那么便会将该进程加入到当前的cgroup
中
新建子cgroup
:
只要在挂载了hierarchy
的目录下,新建一个子目录,那么新的子目录会被自动标记为该cgroup
的子cgroup
这个目录1
就是CgroupTest
的子cgroup
,默认情况下,他会继承父cgroup
的配置
通过subsystem
限制cgroup
中进程的资源
上述创建的hierarchy
并没有关联到任何的subsystem
,所以没办法通过上面的hierarchy
中的cgroup
节点来限制进程的资源占用,其实系统默认已经为每个subsystem
创建了一个默认的hierarchy
,它在Linux
的/sys/fs/cgroup
路径下:
如果想限制某个进程ID
的内存,那么就在/sys/fs/cgroup/memory
目录下创建一个限制mermory
的cgroup
,只要创建一个文件夹即可,kernel
会自动把该文件夹标记为一个cgroup
,我们来尝试一下:
可以看到该目录下,自动给我们创建出来了很多限制资源文件,我们只要将进程ID
写到该文件夹下的task
文件中,然后修改名叫meory.limit_in_bytes
的文件内容,就能限制该进程的内存使用。
如果在这时你打算删掉这些测试目录,可能会发现:即使你使用了root
用户,依旧无法删除/sys/fs/cgroup/memory/
下的目录文件:
此时你可以去检查该cgroup
的连接:
lsof | grep /sys/fs/cgroup/memory/CgroupTest1
如果查到有连接直接kill
掉,然后使用rmdir
命令进行删除:
rmdir CgroupTest1/
Go中使用Cgroup
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"strconv"
"syscall"
)
const (
// 挂载memory subsystem的hierarchy的根目录位置
cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"
)
func main() {
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.Printf("Error running stress command: %v\n", err)
return
}
}
cmd := exec.Command("/proc/self/exe")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNET,
}
if err := cmd.Start(); err != nil {
fmt.Printf("Error starting process: %v\n", err)
return
}
// 得到 fork出来进程映射在外部命名空间的pid
fmt.Printf("New process PID: %+v\n", cmd.Process.Pid)
// 创建子cgroup
newCgroup := path.Join(cgroupMemoryHierarchyMount, "Cgroup-Test")
if err := os.Mkdir(newCgroup, 0755); err != nil {
fmt.Printf("Error creating cgroup: %v\n", err)
return
}
defer os.RemoveAll(newCgroup)
// 将容器进程放到子cgroup中
if err := ioutil.WriteFile(path.Join(newCgroup, "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
fmt.Printf("Error adding process to cgroup: %v\n", err)
return
}
// 限制cgroup的内存使用
if err := ioutil.WriteFile(path.Join(newCgroup, "memory.limit_in_bytes"), []byte("100m"), 0644); err != nil {
fmt.Printf("Error setting memory limit: %v\n", err)
return
}
}
在运行该代码之前需要确保服务器上已经安装了stress
命令,且该程序的执行结果应该为报错结束。因为我们只给了cgroup
100M的内存,但是我们模拟了200M的内存压力。