手写docker—核心概念(一)

发布时间 2024-01-07 17:39:32作者: Stitches

一、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

https://juejin.cn/post/6971335828060504094

http://mcyou.cc/archives/xie-yi-ge-jian-dan-de-docker