手写docker—核心概念(一)

一、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 地址。

[root@localhost ~]# clear
[root@localhost ~]# docker inspect -f "{{.State.Pid}}" 1e0352aa667f
1636
[root@localhost ~]# nsenter --target 1636 --net
[root@localhost ~]# ip a show eth0
4: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

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

Linux Cgroups 提供了对一组进程及将来的子进程的资源限制、控制和统计的能力,包括CPU、内存、网络、存储。

Linux Cgroups 技术将系统中所有进程组织成一颗一颗独立的树,每棵树都包含系统的所有进程。树的每个节点对应一个进程组,每棵树又和一个或多个 subsystem 关联,Cgroups 树的作用就是进程分组,而 subsystem 的作用是对这些组进行操作。

1.1 核心组件

Cgroups 包括如下三个组件:

  • cgroup:对进程分组管理的一种机制,一个 cgroup 对应一组进程,并可以在这个 cgroup 上增加 Linux subsystem 的各种参数配置,将一组进程和一组 subsystem 的系统参数关联起来。
  • subsystem:一组资源控制的内核模块,当它被关联到一棵 cgroup 树之后就会在树的每个节点上做具体限制操作。subsystem 可以实现如下资源限制:
    • net_cls:将 cgroup 中进程产生的网络包分类,以便 Linux 的 tc(traffic controller) 可以根据分类区分出来自某个 cgroup 的包并做限流或监控。这个子系统使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序 (tc)识别从具体 cgroup 中生成的数据包。
    • net_prio:设置 cgroup 中进程产生的网络流量的优先级。
    • memory:控制 cgroup 中进程的内存占用。
    • cpuset:在多核机器上设置 cgroup 中进程可以使用的 cpu 和内存。这个子系统为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
    • freezer:挂起(suspend)和恢复(resume) cgroup 中的进程。这个子系统挂起或者恢复 cgroup 中的任务。
    • blkio:设置对块设备(如硬盘)输入输出的访问控制。这个子系统为块设备设定输入/输出限制,比如物理设备(磁盘,固态硬盘,USB 等等)。
    • cpu:设置 cgroup 中进程的 CPU 占用。这个子系统使用调度程序提供对 CPU 的 cgroup 任务访问。
    • cpuacct:统计 cgroup 中进程的 CPU 占用。这个子系统自动生成 cgroup 中任务所使用的 CPU 报告。
    • devices:控制 cgroup 中进程对设备的访问 16 这个子系统可允许或者拒绝 cgroup 中的任务访问设备。
  • hierarchy:把一组 cgroup 串成一个树状的结构,简单理解就是一个 hierarchy 对应一棵 cgroup 树,树的每个节点就是一个进程组,这样 cgroups 就可以做到继承。一棵 cgroup 树中包含 Linux 系统的所有进程,但是每个进程只能属于一个 Cgroup 节点(进程组)。例如系统对一组定时的任务进程通过 cgroup1 限制了CPU的使用率,然后其中有一个定时 dump 日志进程需要额外限制磁盘IO,为了避免限制了磁盘IO之后影响到其它进程,可以创建 cgroup2,使其继承自 cgroup1并限制其磁盘IO。

1.2 linux 如何配置 Cgroups

上面我们知道 Cgroups 中的 hierarchy 是一种树状的组织结构,我们需要模拟出 cgroup 在 hierarchy 上的层级结构,需要按以下步骤进行:

  • 创建并挂载一个 hierarchy;
# 创建 hierarchy(Cgroup树) 的目录
mkdir cgroup-test
# 挂载一个 cgroup树到该目录,但是不关联子系统
mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test

完成挂载后可以发现挂载目录下包含文件:

  • cgroup.clone_children :值为1 或者 0,如果为1表示子 cgroup 会继承父 cgroup 的资源配置;
  • cgroup.procs :记录当前节点 cgroup 中的所有进程ID;
  • notify_on_release :和 release_agent 一起使用,标识 cgroup 最后一个进程退出时是否执行 release_agent ;
  • release_agent :记录可执行程序的路径,用作进程退出后自动清理掉不再使用的 cgroup;
  • tasks:当前 cgroup 中的所有线程ID,不保证是顺序排列的。
  • 在 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 中添加和移动进程,也就是将进程ID 写入或移除 tasks 文件:
cd ./cgroup-test/cgroup-1
# 将所在终端移入 cgroup-1 中
sudo sh -c "echo $$ >> tasks"
  • 通过 subsystem 限制 cgroup 中进程的资源

上述操作只是创建了 hierarchy ,并没有添加对资源限制的 subsystem 组件。系统默认为每个 subsystem 创建一个默认的 hierarchy,例如关于 memory 的 cgroup 树默认创建在 /sys/fs/cgroup/memory,关于 cpu 的 cgroup 树默认创建在 /sys/fs/cgroup/cpu

这里以 stress 命令启动一个进程为例来演示 subsystem 对资源限制的作用:

# 查看 memory 挂载目录
mount | grep memory

# 切换到系统内存对应的 cgroup树目录
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

加入 subsystem 约束前,stress 进程占用 CPU、内存百分比:

加入后占用 CPU、内存 百分比:

通过上述操作可以发现,Cgroups 对资源的限制操作就是子系统目录加上一组资源限制文件。

如果想对某一进程的内存限制,可以按如下步骤执行:

  • 首先切换到 /sys/fs/cgroup/memory 目录,这是 linux 系统对内存管理的挂载目录;
  • 创建子系统目录 test-limit-memory,此时系统会在该目录下自定义创建一些资源管理相关的子文件;
  • 修改其中的 memory.limit_in_bytes 文件可以起到限制进程内存的作用;
  • 将进程 Pid 加入到 tasks 文件中,实现对进程内存控制;

如果想对进程的 CPU 限制,可以按如下操作执行:

  • 切换到 /sys/fs/cgroup/cpu 目录;
  • 创建子系统目录 test-limit-cpu,系统默认初始化相关子文件,其中 cpu.cfs_quota_us 文件代表每个控制周期内进程可以使用的 cpu 时间;cpu.cfs_period_us 代表每个进程的控制周期;
  • 修改其中 cpu.cfs_quota_us 实现对进程的 CPU 的限制;
  • 将进程 Pid 加入到 tasks 文件中,实现对进程 CPU 的控制。

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;
  • 根据 docker run 提供的参数调整 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....")
    }
}

1.5 Cgroup 核心组件间关系

这里参考 https://tech.meituan.com/2015/03/31/cgroups.html 对 Cgroup 进一步讲解:

如上图代表两个 hierarchy,每个 hierarchy 是一棵树形结构,树的每一个节点是一个 cgroup(cpu_cgrp、memory_cgrp)。

  • 第一个 hierarchy 关联了 cpu子系统和 cpuacct子系统,因此当前 hierarchy 的 cgroup 可以对 CPU 的资源进行限制,并且对进程的 cpu 使用情况进行统计;
  • 第二个 hierarchy 关联了 memory子系统,可以对进程的 memory 使用情况进行统计和限制;

每一个 hierarchy 承担了对 cgroup 中进程资源的管理和限制,比如 cgrp1 进程使用 80% CPU,cgrp2 中使用 20%时间片。同时每个 hierarchy 关联一个或多个 subsystem,实现对资源管理限制。

  • 每一个进程的描述符中有一个指针指向辅助数据结构 css_set (cgroup subsystem set)。指向某一个 css_set 结构的进程会加入到 css_set 的进程链表中。一个进程只能属于一个 css_set,一个 css_set 可以包含多个进程,属于同一个 css_set 的进程受到同一个 css_set 所关联的资源限制;
  • css_set 和 cgroups 节点间是多对多的关联。但是 cgroups 不允许 css_set 同时关联同一个 cgroups 层级中多个节点,这样会导致对同一资源的多种限制;
  • 一个 cgroups 节点关联多个 css_set 时,表明多个 css_set 下的进程列表受到同一份资源的相同限制。

1.6 Cgroup 相关操作命令

1.6.1 hierarchy 创建

hierarchy 的创建是 cgroup 树的创建,其过程就是将 cgroup 类型文件系统挂载到指定的目录。

  • 语法为 mount -t cgroup -o subsystems name /cgroup/name
  • subsystems 表示需要挂载的 cgroups 子系统;
  • /cgroup/name 表示挂载点(文件目录);

上述命令的作用是在内核中创建一个 hierarchy 以及一个默认的 root cgroup,比如下面的例子就是挂载了一个 hierarchy 到 cg1 目录,如果对应的 hierarchy 不存在就会创建。

mkdir cg1
mount -t cgroup -o cpuset cg1 ./cg1

1.6.2 hierarchy 卸载

语法为 umount /cgroup/name,其中 /cgroup/name 为具体的挂载目录,卸载后相关的 cgroup 都会被删除。

1.6.3 release_agent 触发

在创建完 hierarchy 后会默认创建资源相关的配置文件,包括 cgrouo.clone_childrencgroup.procscgroup.sane_behaviornotify_on_releaserelease_agenttasks

其中 notify_on_release=1 时在 cgroup 退出时将调用 release_agent 中的命令。

1.6.4 cgroup 创建

cgroup 创建就是在 hierarchy 下新建一个目录。

1.6.5 cgroup 删除

Union File System

Docker 的存储驱动是基于 Union FileSystem 来实现的,它是一种将其它文件系统联合到一个联合挂载点的文件系统服务,简单来说就是将多个物理位置不同的文件目录联合起来,挂载到某一个目录下形成一个抽象的文件系统。它使用了写时复制的管理技术,在不修改资源时不需要重复创建资源,可以复用资源,docker 正式利用了这一点来创建镜像和容器。

AUFS

AUFS 为 Docker 选取的存储驱动,它具有快速启动容器、高效利用存储和内存的优点。这里举个例子来介绍 AUFS

  • 首先建立 companyhome 两个目录及其下的文件:
root@home:~/root/aufs# tree .
|-- company
|   |-- code
|   `-- meeting
`-- home
    |-- eat
    `-- sleep
  • 通过 mount 命令将两个目录挂载到 mnt 目录下:
root@home:~/root/aufs# mkdir mnt

root@home:~/root/aufs# ll
total 20
drwxr-xr-x 5 root root 4096 Oct 25 16:10 ./
drwxr-xr-x 5 root root 4096 Oct 25 16:06 ../
drwxr-xr-x 4 root root 4096 Oct 25 16:06 company/
drwxr-xr-x 4 root root 4096 Oct 25 16:05 home/
drwxr-xr-x 2 root root 4096 Oct 25 16:10 mnt/

root@home:~/root/aufs# mount -t aufs -o dirs=./home:./company none ./mnt

root@home:~/root/aufs# ll
total 20
drwxr-xr-x 5 root root 4096 Oct 25 16:10 ./
drwxr-xr-x 5 root root 4096 Oct 25 16:06 ../
drwxr-xr-x 4 root root 4096 Oct 25 16:06 company/
drwxr-xr-x 6 root root 4096 Oct 25 16:10 home/
drwxr-xr-x 8 root root 4096 Oct 25 16:10 mnt/

root@home:~/root/aufs# tree ./mnt/
./mnt/
|-- code
|-- eat
|-- meeting

通过 mnt 目录下结果我们发现原来的两个目录合并挂载到同一个目录下,同时注意文件读写权限,默认情况下内核将根据从左到右的顺序将第一个目录指定为可读写,其它都为只读。

如果向只读目录执行一些修改操作,实际修改内容不会保存到原始目录中,而是在可读写目录下创建对应的修改目录,然后记录修改内容。

所以我们可以将服务的源代码和修改代码分别放到两个目录,然后利用 mount 将其联合挂载到同一个目录,前者设置只读权限,后者设置读写权限。那么针对源代码的一切修改都只会记录到修改目录下。

AUFS 和 Docker image 的关系

image 是 Docker 部署的基本单位,镜像运行在容器中,镜像包含了我们的程序文件,以及这个程序依赖的资源的环境,docker image 对外是以一个文件的形式展示的(实际看到的是文件目录挂载点)。

一个镜像是通过 dockerfile 定义的,然后利用 docker build 命令构建,dockerfile 中每一条命令的执行结果都会成为镜像中的一个镜像层。Docker 镜像是层级结构,最底层为 baseImage(一般为操作系统ISO镜像),然后顺序执行每一条指令生成 layer 并按照入栈的顺序逐渐累加,最终形成一个镜像。如果 dockerfile 中的内容没有变动,那么 build 过程会复用之前的镜像层;同时即使文件内容有修改,也只会重新 build 对应的镜像层。

每一个 Docker 镜像都是由一系列的 read-only layer 组成的,镜像层内容都是存储在 /var/lib/docker/aufs/diff 目录下;/var/lib/docker/aufs/layer 路径下存储了如何堆栈这些镜像层的元数据信息。

如果我们执行以下命令构建新容器,会发现它对历史镜像的复用:

# 以 ubuntu 为基础镜像构建 changed-ubuntu 镜像

# dockerfile 文件内容
FROM ubuntu:15.04
RUN echo "Hello World" > /tmp/newfile

# 构建自定义镜像 changed-ubuntu
docker build -t changed-ubuntu .
# Sending build context to Docker daemon 10.75 kB 
# Step 1 : FROM ubuntu:lS. 04 ---> dlb55fd07600 
# Step 2 : RUN echo ” Hello world" > /tmp/newfile ---> Running in c72100f8lddl ---> 9d8602c9aeel 
# Removing intermediate container c72100f8lddl 
# Successfully built 9d8602c9aeel

执行完上述步骤后查看 /var/lib/docker/aufs/diff/var/lib/docker/aufs/mnt 目录,会发现只是新增了一个文件夹,说明对历史镜像复用。

AUFS 和 container layer

  • Docker Container 也使用 AUFS 作为文件系统,容器中环境等配置信息也以层级的形式存储在 /var/lib/docker/aufs/xxx 目录下。
  • Docker 在启动一个容器时,会为其创建一个只读的初始化层,用来存储这个容器内环境的相关内容;同时 docker 还会为其创建一个可读写层来执行读写操作。
  • container layer 的 mount 目录也是 /var/lib/docker/aufs/mnt,容器的元数据和配置文件存放在 /var/lib/docker/container/<container-id> 目录下。即使容器停止,这个读写层依然存在,所有重启重启不会丢失容器数据,只有当容器被删除时,可读写层才会一起删除。

container layerimage layer 有一些联系:

  • 查找某个文件时先从 container layer 下查找,不存在就逐层向下搜索直到找到该文件;
  • container layer 中已存在的文件修改会直接在该文件中操作;对 image-layer 中文件修改会将该文件完整复制到 container layer 中,因为镜像层只是可读的;
  • 删除 container layer 文件直接删除,删除 image layer 文件会在容器层的可读写层创建对应的 whitefile 文件来隐藏对应文件。

实现自己的 AUFS

  • 首先创建一个 aufs 目录并在该目录下创建如下文件夹和文件:
root@hecs-349201:/home/root/software/aufs# tree .
.
├── container-layer
│?? └── container-layer.txt
├── image-layer1
│?? └── image-layer1.txt
├── image-layer2
│?? └── image-layer2.txt
├── image-layer3
│?? └── image-layer3.txt
├── image-layer4
│?? └── image-layer4.txt
└── mnt
  • mount 联合挂载各目录:
mount -t aufs -o dirs=./container-layer:./image-layer1:./image-layer2:./image-layer3:./image-layer4 none ./mnt
  • 查看各文件夹权限:
ls
/home/xjx/aufs/container-layer=rw 
/home/xjx/aufs/image-layer4=ro
/home/xjx/aufs/image-layer3=ro 
/home/xjx/aufs/image-layer2=ro 
/home/xjx/aufs/image-layerl=ro 
  • 默认第一个文件夹 container-layer 可读写,其余均只可读,这里向 image-layer4.txt 文件写入数据:
echo -e "hosdfosndgs" >> ./mnt/image-layer4.txt

会发现修改对原始目录 image-layer4/image-layer4.txt 下不可见,反而会在可读写文件夹 ./container-layer 下创建 image-layer4.txt 文件并写入数据。

Rootfs

修改容器中的文件并不会影响宿主机,正是因为 Mount Namespace 的作用。

容器中的文件系统经过 Mount Namespace 的隔离是独立的,它修改了容器进程对文件系统 “挂载点” 的认知。只有在挂载操作发生后,进程的视图才会改变,在此之前新创建的容器会直接继承宿主机的各个挂载点。
Linux 的 chroot 命令可以实现修改挂载点的功能。

挂载点在容器中有个更专业的名字,rootfs(根文件系统),rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这二者是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

而同一台机器上的所有容器都共享了宿主机的系统内核。这区别于虚拟机,虚拟机会模拟机器硬件。

容器中的文件系统在构建镜像的时候就被打包进去,在容器启动时挂载到根目录下。

镜像层 Layer

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 在镜像基础上新增了可读写层和 init 层。

容器 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

https://coolshell.cn/articles/17061.html

https://zhuanlan.zhihu.com/p/603780174

posted @ 2024-01-07 17:36  Stitches  阅读(210)  评论(0)    收藏  举报