一、Namespace、Cgroups、Rootfs
进程和容器有什么区别?
- 进程作为计算机程序运行起来后资源管理的总和,内部包含了程序计数器、堆栈、各种变量指令等等;
- 容器就是对进程做一些限制和约束,从而形成一个边界。Cgroups 技术是用来制造约束的主要手段,Namespace 技术用来修改进程视图的主要方法。
Namespace
处于不同 namespace 的进程拥有独立的全局系统资源,修改某一个 namespace 的系统资源只会影响到当前 namespace 的进程,对其它 namespace 的进程没有影响。
Linux 下根据隔离的属性分为不同的 Namespace:
- PID Namespace; 隔离 Process ID,系统调用参数为 CLONE_NEWPID;
- Mount Namespace;隔离挂载点,系统调用参数为 CLONE_NEWNS;
- UTS Namespace;隔离主机名和域名,系统调用参数为 CLONE_NEWUTS;
- IPC Namespace;隔离系统消息队列,系统调用参数为 CLONE_NEWIPC;
- Network Namespace;隔离网络、端口、网桥等等,系统调用参数为 CLONE_NEWNET;
- User Namespace;隔离用户和用户组ID,系统调用参数为 CLONE_NEWUSER;
例如 docker run -it xxx 进入容器内部后 ip a 命令只能看到容器内网络情况,就是利用了 Network Namesapce。
可以通过 docker inspect -f {{.State.Pid}} 容器ID 查询到容器对应进程 Pid,然后执行 nsenter pid 进入到指定进程空间内,执行 ip a 查看容器内 IP 地址。
1.1 API操作
-
clone
:创建一个新进程,并把它放到新的 namespace 中:int clone(int (*fn)(void *), void *stack, int flags, void *arg, ... /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
-
setns
:将当前进程加入到已有的 namespace 中:int setns(int fd, int nstype);
- fd:指向 /proc/[pid]/ns/ 目录下的 namespace 对应的文件;
- nstype: namespace 的类型
- 1.如果当前进程不能根据 fd 得到它的类型,就需要通过 nstype来指定;
- 2.如果进程能够根据fd得到 namespace 类型,那么 nstype 设置为 0
-
unshare
:使当前进程退出指定类型的 namespace,并加入到新创建的 namespace(相当于创建并加入新的 namespace)int unshare(int flags);
- flags:上面指定的 CLONE_NEW*
-
ioctl_ns
:查询 namespace 信息new_fd = ioctl(fd, request);
- fd: 指向/proc/[pid]/ns/目录里相应namespace对应的文件
- request:
NS_GET_USERNS: 返回指向拥有用户的文件描述符namespace fd引用的命名空间
NS_GET_PARENT: 返回引用父级的文件描述符由fd引用的命名空间的命名空间。
1.2 Namespace 实例
- UTC Namespace 隔离 hostname
package main
import (
"log"
"os"
"os/exec"
"syscall"
)
func main() {
// 生成 cmd 命令
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS, //设置clone函数标志位 CLONE_NEWUTS
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
执行后进入新的 shell 客户端,在控制台大于查看父子进程是否处于同一个 UTS Namespace 中:
- IPC Namespace 隔离消息队列、PID Namespace 隔离进程
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)
}
}
IPC Namespace 用来隔离不同进程的消息队列;PID Namespace 用来隔离不同进程 ID,同一个进程在不同的 PID Namespace 可以拥有不同的 PID,在 docker container 里面通过 ps -ef 可以发现同样的进程有不同的 PID,这就是 PID Namespace 所做的事情。
- Mount Namespace
Mount Namespace 用来隔离各个进程看到的挂载点视图,在不同的 Namespace 进程中看到的文件系统层次是不一样的。Mount Namespace 和 chroot() 函数类似,都是将一个子目录变成根节点,但是 Mount Namespace 实现起来更方便和安全。
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 命令将 /proc 挂载到我们自己的 Namespace 下来,可以发现文件少了很多,同时通过 ps -ef 命令查看当前环境的进程列表。
Sh 进程是 PID 为1 的进程,说明当前 Mount Namespace 中的 mount 和外部空间是隔离的,并不会影响外部。
sh-4.2# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 12:11 pts/0 00:00:00 sh
root 8 1 0 12:12 pts/0 00:00:00 ps -ef
- User Namespace
User Namespace 主要是隔离用户的用户组ID,一个进程的 UserID 和 GroupID 在 User Namespace 内外可以是不同的。在宿主机上以一个非 root 用户运行 User Namespace,然后在 Namespace 中映射为 root 用户。这意味着进程在 Namespace 中具有 root 权限,在原宿主机却没有,做到了权限隔离。 - Network Namespace
Network Namespace 是用来隔离网络设备、IP地址端口等网络栈,让每个容器拥有自己独立的网络设备。且每个Namespace 内的端口都不会互相冲突,在宿主机上搭建网桥就很容易实现容器间的通信。
Cgroups
Cgroups 提供了对一组进程及将来的子进程的资源限制、控制和统计的能力,包括CPU、内存、网络、存储。
1.1 核心组件
Cgroups 包括如下三个组件:
- cgroup:对进程分组管理的一种机制,一个 cgroup 包含一组进程,并可以在这个 cgroup 上增加 Linux subsystem 的各种参数配置,将一组进程和一组 subsystem 的系统参数关联起来。
- subsystem:一组资源控制的模块,包括如下部分:
- cpu:设置进程被 cpu 调度策略;
- cpuacct: 统计 cgroup 中进程的 cpu 占用;
- devices :控制 cgroups 中进程对设备的访问;
- memory:控制 cgroups 中进程的内存占用;
- blkio:设置对块设备的输入输出访问;
- 等等.....
- hierarchy:把一组 cgroup 串成一个树状的结构,这样 cgroups 就可以做到继承。例如系统对一组定时的任务进程通过 cgroup1 限制了CPU的使用率,然后其中有一个定时 dump 日志进程需要额外限制磁盘IO,为了避免限制了磁盘IO之后影响到其它进程,可以创建 cgroup2,使其继承自 cgroup1并限制其磁盘IO。
三个组件间的关系如下:
- 系统创建了新的 hierarchy 之后,系统中所有进程都会加入到这个 hierarchy的 cgroup根节点,这个 cgroup 是默认创建的;
- 一个 subsystem只能附加到一个 hierarchy上面;
- 一个 hierarchy可以附加多个 subsystem;
- 一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须位于不同的 hierarchy中;
- 一个进程 fork 出子进程时,父子进程是在同一个 cgroup中的,也可以根据需要转移到其它 cgroup中;
1.2 linux 如何配置 Cgroups
上面我们知道 Cgroups 中的 hierarchy 是一种树状的组织结构,我们需要模拟出 cgroup 在 hierarchy 上的层级结构,需要按以下步骤进行:
- 创建并挂载一个 hierarchy;
// 创建 hierarchy 目录
mkdir cgroup-test
// 挂载一个 hierarchy
mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test
完成挂载后可以发现挂载目录下包含文件:cgroup.clone_children
:值为1/0,如果为1表示子 cgroup 会继承父 cgroup 的 cpuset 配置;cgroup.procs
:记录当前节点 cgroup 中的进程组ID;notify_on_release
:和 release_agent 一起使用,标识 cgroup 最后一个进程退出时是否执行 release_agent ;release_agent
:记录可执行程序的路径,用作进程退出后自动清理掉不再使用的 cgroup;tasks
:标识 cgroup 下的进程ID,如果把一个进程ID 写入tasks 文件,会将相应的进程加入到这个 cgroup 中;
- 在 hierarchy基础上扩展出子 cgroup:
mkdir cgroup-1
mkdir cgroup-2
tree
.
├── cgroup-1
│ ├── cgroup.clone_children // 继承父 cgroup 的属性
│ ├── cgroup.event_control
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
├── cgroup-2
│ ├── cgroup.clone_children
│ ├── cgroup.event_control
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
- 在 cgroup 中添加和移动进程
cd ./cgroup-test/cgroup-1
sudo sh -c "echo $$ >> tasks"
-
通过 subsystem 限制 cgroup 中进程的资源
系统默认为每个 subsystem 创建一个默认的 hierarchy,例如
/sys/fs/cgroup/memory
目录挂载到了 memory subsystem 的 subsystem上。
cd /sys/fs/cgroup/memory
# 启动一个不做约束的 stress进程,进程占用200MB内存
stress --vm-bytes 200m --vm-keep -m 1
# 创建cgroup
sudo mkdir test-limit-memory && cd test-limit-memory
# 设置cgroup 最大内存占用 100MB
sudo sh -c "echo "100m" > memory.limit_in_bytes"
# 将当前进程移动到该 cgroup中
sudo sh -c "echo $$ > tasks"
# 再次运行stress进程
stress --vm-bytes 200m --vm-keep -m 1
通过上述操作可以发现,stress 进程占用的内存下降一半,内存限制起到了作用。
1.3 docker 如何使用 Cgroups
# docker启动容器,返回 cgroup 文件夹名
docker run -itd -m 128m ubuntu
957459145e9092618837cf94alcb356e206f2f0da560b40cb31035e442d3dfll
# docker为每个容器在系统的hierarchy中创建 cgroup
cd /sys/fs/cgroup/memory/docker/957459145e9092618837cf94alcb356e206f2f0da560b40cb310 35e442d3dfl1
# 查看内存限制
cat memory.limit_in_bytes
134217728
所以docker就是通过为每个容器创建 cgroup,并通过 cgroup去配置资源限制和资源监控。
1.4 通过 golang 操作 Cgroups 限制容器资源
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"strconv"
"syscall"
"time"
)
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"
func main() {
if os.Args[0] == "/proc/self/exe" {
// 容器进程触发
fmt.Printf("current pid %d", syscall.Getpid())
fmt.Println()
// 启动stress进程,指定内存占用200MB
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.Println(err)
os.Exit(1)
}
}
// 创建 Namespace 容器
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 {
fmt.Println("ERROR", err)
os.Exit(1)
} else {
time.Sleep(3 * time.Second)
// 得到fork出子进程映射在外部命名空间的 pid
fmt.Println("exec first")
fmt.Printf("%v", cmd.Process.Pid)
// 系统默认创建挂载了 memory subsystem 的 hierarchy 上创建 cgroup (目录 testmemorylimit)
os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
// 将容器进程pid 加入到 cgroup tasks 管理
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
// 限制cgroup资源使用
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"), []byte("100m"), 0644)
// 等待子进程结束
cmd.Process.Wait()
fmt.Println("Process end....")
}
}
Rootfs
修改容器中的文件并不会影响宿主机,正是因为 Mount Namespace 的作用。
容器中的文件系统经过 Mount Namespace 的隔离是独立的,它修改了容器进程对文件系统 “挂载点” 的认知。只有在挂载操作发生后,进程的视图才会改变,在此之前新创建的容器会直接继承宿主机的各个挂载点。
Linux 的 chroot 命令可以实现修改挂载点的功能。
挂载点在容器中有个更专业的名字,rootfs(根文件系统),rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这二者是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
而同一台机器上的所有容器都共享了宿主机的系统内核。这区别于虚拟机,虚拟机会模拟机器硬件。
容器中的文件系统在构建镜像的时候就被打包进去,在容器启动时挂载到根目录下。
二、容器镜像
Docker 在镜像设计中采用了层的概念,用户制作镜像的每一步操作都会生成一个层,也就是一个增量 rootfs。层的引入实现了对 rootfs 的复用,不必每次生成一个新的 rootfs,只需要增量修改即可。
Docker 镜像层使用了一种叫做联合文件系统(Union File System) 的能力。它会将多个不同位置的目录联合挂载到同一个目录下。例如现在有 A目录、B目录、C目录,通过 UnionFS 将 A、B挂载到C目录,由于看不到 A、B目录的存在,C目录看来好像就拥有这些文件一样。
UnionFS 在不同系统中有不同实现,常见的有 aufs(ubuntu)、overlay2(centos)。
如上图,Union mount 提供了统一视图,用户看上去好像整个系统只有一层,实际下面包含了很多层。
容器 rootfs 包括:只读层(镜像rootfs) + init层(容器启动时初始化修改的部分数据) + 可读写层(容器中实时数据)
- 只读层:只读层是 rootfs 最下面几层,即镜像中所有层的总和,它们的挂载方式是只读(ro+wh readonly+whiteout)。
- 可读写层:可读写层为容器 rootfs 中最上面一层,它的挂载方式为 rw(read+write)。如果是删除操作,并不会实际删除文件,而是类似标记删除,比如要删除 foo 的文件,实际操作是在可读写层创建一个名为 .wh.foo 文件进行遮挡,实现对删除文件的隐藏。
- init 层:init 层位于只读层和读写层之间,用来存放容器启动时初始化修改的部分数据。
资料
https://juejin.cn/post/7134631844103847973?searchId=20240106134245C7E62D9528092A0F9DC5
https://github.com/lixd/mydocker