使用Golang实现自己的Docker(一)

一、Linux Namespace介绍

1.1 概念

Linux Namespace 是 Kernel 的一个功能,它可以隔离一系列的系统资源,比如 PIO ( ProcessID )、 User ID 、 Network 等。

我们常购买的云服务器等资源,也就是使用了资源隔离。使用 Namespace,就可以做到 UID 级别的隔离,也就是说,可以以 UID 为 n 的用户,虚拟化出来一个 Namespace , 在这个 Namespace 里面,用户是具有 root 权限的 。但是,在真实的物理机器上,他还是那个以UID 为 n 的用户,这样就解决了用户之间隔离的问题。

每个虚拟出的空间都可以表现为一台物理机,都有PID=1的父进程。而这些命名空间的PID为1的进程是映射到父命名空间的,

image-20221009165301472

所以命名空间NameSpace就是用来隔离资源的,linux除了User NameSpace,还有其他5种NameSpace:

image-20221010211823298

Namespace 的 API 主要使用如下 3 个系统调用 。

  • clone()创建新进程。根据系统调用参数来判断哪些类型的 Namespace 被创建,而且它们 的子进程也会被包含到这些 Namespace 中。
  • unshare()将进程移出某个 Namespace
  • setns()将进程加入到 Namespace 中。

1.2 UTS NameSpace

UTS Namespace 主要用来隔离 nodename 和 domainname 两个系统标识。在UTS Namespace 里面 , 每个 Namespace 允许有自己的 hostname

即每个UTS NameSpace中的hostname都可以不一样。

下面一个Go测试UTS NameSpace例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

执行上面的代码会进入一个sh的运行环境中。使用pstree -pl查看进程之间的关系:

image-20221010220227867

验证一下父进程和子进程是否在一个UTS中:

image-20221010220353176

子进程和父进程并不在同一个UTS中,这里就做到了隔离,所以修改hostname也不会影响宿主机的hostname(左边为sh环境,右边为宿主机环境):

image-20221010220711135

1.3 IPC NameSpace

IPC Namespace 用来隔离 System V IPCPOSIX message queues。 每一个IPC Namespace都有自己的 System V IPCPOSIX message queue

IPC即Inter-Process Communication,进程间通信,IPC NameSpace即实现进程间的通信隔离。

我们知道进程通信有三种方式:消息队列、管道、共享内存。通信隔离即该消息队列或其他方式只能在该NameSpace中看到,其他NameSpace无法使用。

我们只需要修改上面UTS NameSpace代码Cloneflags即可模拟IPC NameSpace隔离。

1
2
3
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
}

我们开两个shell,第一个运行我们的程序进入sh环境,创建一个ipc的message queue,可以发现在宿主机上是看不到的,这就实现了IPC隔离

image-20221011103117756

ipcs -q : 查看当前namespace所有的Message Queue

ipcmk -Q:在当前namespace创建一个Message Queue

1.4 PID Namespace

顾名思义, PID Namespace用来隔离进程的PID。

根据PID Namespace,我们创建的每个Namespace环境都可以拥有PID为1的进程,其实是挂载到宿主机上的一个普通进程。

同样修改一下之前的代码即可创建一个PID Namespace。

1
2
3
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,
}

同样可以看到在右边宿主机上使用pstree -pl查看go进程的pid不为1,但是左边创建的PID namespace中pid为1.

image-20221011104002945

但是此时还不能使用ps来查看进程的PID,因为还需要MountNamespace。

image-20221011104917365

1.5 Mount Namespace

顾名思义Mount Namespace即进行文件系统挂载的隔离。我们知道Docker每个容器都有自己的文件系统,而这些文件系统之间的隔离就是使用了Mount Namepsace。

Mount Namespace 中调用 mount()umount()仅仅只会影响当前 Namespace 内的文件系统,而对全局的文件系统是没有影响的。

Mount Namespace 是 Linux 第 一个实现 的Namespace 类型 , 因此,它的系统调用参数是 NEWNS ( New Namespace 的缩写)。

1
2
3
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}

proc 是一个文件系统,提供额外的机制 ,可以通过内核和内核模块将信息发送给进程。

查看宿主机proc内容:

image-20221011105059343

将 /proc mount 挂载到我们自己的namespace中:

image-20221011105245382

可以看到挂载后有了PID为1的进程。说明当前namespace和外部空间是隔离的,mount操作并没有影响到外部,Docker Volume也是利用了这个特性。

其实这样只是创建了Mount Namespace,没有真正实现隔离,因为只是将shared subtrees的原因。可以通过执行mount --make-rprivate /来取消mount共享来实现真正的隔离。具体可以参考mount namespace 真正隔离了你的mount信息吗? - 知乎 (zhihu.com)

1.6 User Namepsace

User Namespace主要用来隔离用户的用户组ID。我们知道每个进程都有一个所属的User ID或Group ID,那么创建一个User Namespace后,该namspace里的进程所属的ID可以和外部宿主机进程的ID不同。

注意的是,从 Linux Kernel3.8开始,非 root 进程也可 以创建 User Namepace , 并且此用户在Namespace里面可以被映射成 root , 且在 Namespace 内有 root 权限

1
2
3
4
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER,
}
cmd.SysProcAttr. Credential = &syscall . Credential{Uid: uint32(1 ), Gid : uint32 (1)}

但是执行会报错:

image-20221011111507538

centos7默认禁用了用户名空间,使用下面命令开启:

# echo 640 > /proc/sys/user/max_user_namespaces

Linux kernel3.19版本对User Namepsace做了些修改,修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 0,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 0,
Size: 1,
},
},
}

1.7 Network Namespace

Network Namespace 是用来隔离网络设备、 IP 地址端口 等网络械的 Namespace 。 NetworkNamespace 可以让每个容器拥有自己独立的(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个 Namespace 内的端口都不会互相冲突。

在宿主机上搭建网桥后就可以很方便地实现容器之间的通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 0,
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: 0,
Size: 1,
},
},
}

进入容器后查看网络可以看到容器内ifconfig无结果,而宿主机有,说明容器和宿主机网络隔离了。

image-20221012155654607

二、Linux CGroups介绍

2.1概念

Linux Cgroups (Control Groups )提供了对一组进程及将来子进程的资源限制、控制和统计的能力,这些资源包括 CPU、内存、存储、网络等 。 通过 Cgroups,可以方便地限制某个进程的资源占用,并且可以实时地监控进程的监控和统计信息 。

作用:管理进程的资源分配,从而可以管理容器的资源分配。

2.2 Cgroups的三个组件

  • cgroup 是对进程分组管理的一种机制, 一个cgroup包含一组进程,井可以在这个 cgroup上增加 Linux subsystem 的各种参数配置,将一组进程和一组 subsystem 的系统参数关联起来。

  • subsystem是一组资源控制的模块。每个 subsystem 会关联到定义了相应限制的 cgroup 上,并对这个cgroup 中的进程做相应的限制和控制 。

    可以安装 cgroup 的命令行工具( apt-get install cgroup-bin ),然后通过 lssubsys 看到 Kernel 支持的 subsystem 。

  • hierarchy的功能是把 一 组 cgroup 串成 一 个树状的结构,一个这样的树便是 一 个 hierarchy ,通过这种树状结构, Cgroups 可以做到继承

    比如通过创建cgroup1对某个进程限制了CPU使用率。其某个子进程还需要限制磁盘IO,那么可以创建cgroup2继承于cgroup1,然后通过cgroup2限制某个子进程的磁盘IO,并且自动继承了CPU的限制。

2.3 三个组件的关系

  • 系统在创建了新的 hierarchy 之后,系统中所有的进程都会加入这个 hierarchy 的 cgroup根节点,这个 cgroup 根节点是 hierarchy 默认创建的,在这个 hierarchy 中创建的 cgroup 都是这个 cgroup 根节点的子节点。
  • 一个 subsystem 只能附加到一个 hierarchy 上面。
  • 一个 hierarchy 可以附加多个 subsystem 。 一般包含下面几项:
    • blkio 设置对块设备(比如硬盘)输入输出的访问控制
    • cpu 设置 cgroup 中进程的 CPU 被调度的策略。
    • cpuacct 可以统计 cgroup 中进程的 CPU 占用
    • cpuset 在多核机器上设置 cgroup 中进程可以使用的 CPU 和内存(此处内存仅使用于 NUMA 架构)
    • devices 控制 cgroup 中进程对设备的访问
    • freezer 用于挂起( suspend )和恢复( resume) cgroup 中的进程
    • memory 用于控制 cgroup 中进程的内存占用
    • net_cls 用于将 cgroup 中进程产生的网络包分类,以便 Linux tc (traffic controller)可 以根据分类区分出来自某个 cgroup 的包并做限流或监控
    • net_prio 设置 cgroup 中进程产生的网络流量的优先级
    • ns 这个 subsystem 比较特殊,它的作用是使 cgroup 中的进程在新的 Namespace fork 新进程 (NEWNS)时,创建出一个新的 cgroup ,这个 cgroup 包含新的 Namespace中的进程
  • 一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy 中 。
  • 一个进程 fork 出子进程时,子进程是和父进程在同一个 cgroup 中的,也可以根据需要 将其移动到其他 cgroup 中 。

通俗理解:Cgroup是规则的制定者,声明了哪些进程需要遵守哪些规则。subsystem是规则的执行者,根据Cgroup制定的规则来限制进程使用的资源。hierarchy是制定者的树状集合,用来区分制定者的辈分。

Cgroups三个组件的关系

上图中黄色三角形是subsystem,可以根据上面的规则对照理解。

2.4 操作Cgroups

Linux内核通过一个虚拟的树状文件系统配置 Cgroups 的,通过层级的目录虚拟出 cgroup 树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ mkdir cgroup-test # 创建一个hierarchy挂载点
$ mount -t cgroup -o none,name=cg-root-1 cg-root-1 ./cgroup-test/ # 挂载一个hierarchy
$ mount # 查看, 发现我们的系统上多了一个名为cg-root-1, 类型为cgroup的文件系统(即cgroupfs), 挂载点为/data/test/cgroups/cgroup-test
# ...省略一些内容...
cg-root-1 on /data/test/cgroups/cgroup-test type cgroup (rw,relatime,name=cg-root-1)
$ ls ./cgroup-test/ # 挂载后我们就可以看到系统在这个目录下生成了一些默认文件
cgroup.clone_children cgroup.procs notify_on_release tasks
cgroup.event_control cgroup.sane_behavior release_agent
# 创建子cgroup: cg-1和cg-2
$ mkdir cg-1 cg-2
# 注意cg-1和cg-2下的文件也是自动生成的, 相当于继承了父cgroup(cgroup-test)的一些设置
$ tree
.
├── cg-1
│ ├── cgroup.clone_children
│ ├── cgroup.event_control
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
├── cg-2
│ ├── cgroup.clone_children
│ ├── cgroup.event_control
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
├── cgroup.clone_children
├── cgroup.event_control
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks
  1. cgroup.clone_children, cpuset subsystem 会读取这个配置文件,如果这个值是1(默认是0),子cgroup 才会继承父 cgroup cpuset 的配置;
  2. cgroup.procs 是hierarchy树中当前cgroup节点中的进程组 ID ,当前cgroup的位置是在hierarchy树的根节点,这个文件中会有现在系统中所有进程组的 ID
  3. notify_on _releaserelease agent 会一起使用。 notify_on_release 标识当这个 cgroup最后一个进程退出的时候是否执行了 release_agent; release_ agent 则是 个路径,通常用作进程退出之后自动清理掉不再使用的 cgroup;
  4. tasks 标识 cgroup 下面的进程 ID ,如果把 个进程 ID 写到 tasks 文件中,便会将相应的进程加入到这个 cgroup; 注意: tasks有时也区分线程还是进程id

我们创建了cgroup-testhirerachy,包含了一个cgroup根节点,其下面有两个cgroup:cg-1cg-2

下面我们可以向cgroup中添加进程,添加进程只需要在cgroup下的tasks文件中添加进程的pid即可。

image-20221013154214093

可以看到将当前的shell进程加入到了cg-1中。

注意:将一个进程加入到cg-1后,如果将其再加入到cg-2,那么cg-1会自动移除该进程的pid。也说明了:一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy中;

2.5 限制进程资源

cgroups通过subsystem来限制进程的资源。系统默认已经为每个subsystem 创建了一个默认的 hierarchy ,比如 memory hierarchy:

image-20221013155158953

可以看到, memory subsystemhierarchy挂载到了 /sys/fs/cgroup/memory, 我们就在这个hierarchy下创建cgroup, 限制进程占用的内存.

一种方式是和上面一样在这个文件夹下创建子文件夹即会自动创建cgroup

还可以使用cgcreate -g memory:test-memory-limit来创建cgroup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ echo '100m' > memory.limit_in_bytes 
$ cat memory.limit_in_bytes
104857600 # 100m=100 * 1024 * 1024
$ echo $$
16570
$ echo 16570 > tasks
$ stress --vm-bytes 128m --vm-keep -m 1 # 启动一个占用128m的进程报错了
stress: info: [335] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: FAIL: [335] (415) <-- worker 336 got signal 9
stress: WARN: [335] (417) now reaping child worker processes
stress: FAIL: [335] (451) failed run completed in 0s
$ stress --vm-bytes 99m --vm-keep -m 1 # 启动一个占用99m的进程成功了
stress: info: [351] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd

上面给cgroup限制了100m的内存使用,然后将当前shell添加进cgroup,启动一个128m的进程会报错,实现了资源限制。

2.6 Docker是如何使用Cgroups的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ docker run -itd -m 128m ubuntu # -m 128m 限制容器使用的内存最大为128m
64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29
$ docker ps # 确保容器正常运行
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
64b8f2e16f30 ubuntu "bash" 2 seconds ago Up 1 second stupefied_bhaskara
$ cd /sys/fs/cgroup/memory/docker # docker的memory cgroup
$ cd 64b8f2e16f30c4cde22846dd07ef4c65cd272c3a482eb527c4cd8c90657dcd29 # 容器子cgroup
$ ls
... 省略一些内容
memory.failcnt memory.limit_in_bytes
memory.kmem.tcp.limit_in_bytes memory.move_charge_at_immigrate tasks
$ cat memory.limit_in_bytes # 查看容器内存限制
134217728 # 128m=134217728=128 * 1024 * 1024
$ cat memory.usage_in_bytes # 查看容器已经使用的内存
778240

可以看到, Docker 通过为每个容器创建 cgroup 并通过 cgroup 去配置资源限制和资源监控。那除了cgroup还需要进程pid才可以进行限制,那么限制的是哪个进程的pid呢?是docker的吗?明显不可能。

事实上, 每一个Docker容器, 在操作系统上是对应到进程,所以一个容器对应一个进程,这个进程对应容器的cgroup,就可以通过cgroup配置容器的进程pid来达到对单个容器进行资源限制的效果。

2.7 使用Go限制进程资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"strconv"
"syscall"
)

const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"

func main() {
fmt.Println("os orgs:", os.Args)
if os.Args[0] == "/proc/self/exe" {
// 容器进程
fmt.Printf("current pid:%v\n", syscall.Getpid())
cmd := exec.Command("sh", "-c", "stress --vm-bytes 200m --vm-keep -m 1")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
checkErr(err, "/proc/self/exe run")

}
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
err := cmd.Start()
checkErr(err, "/proc/self/exe start")

// 得到 fork 出来进程映射在外部命名空间的 pid
fmt.Printf("host pid: %v\n", cmd.Process.Pid)
// 在系统默认创建挂载了 memory subsystem Hierarchy 上创建 cgroup
err = os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
checkErr(err, "Mkdir")
// 将容器进程加入到这个 cgroup
err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
checkErr(err, "WriteFile tasks")
//限制 cgroup 进程使用
err = ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"), []byte("100m"), 0644)
checkErr(err, "WriteFile limit_in_bytes")

_, err = cmd.Process.Wait()
checkErr(err, "cmd.Process.Wait")
}

func checkErr(err error, reason string) {
if err != nil {
panic(fmt.Sprintf("err:%v, reason:%s", err, reason))
}
}

image-20221014121646501

三、Linux Union File System

Union File System 简称 UnionFS,是一种为 Linux 、 FreeBSD 和 NetBSD 操作系统设计的, 把其他文件系统联合到一个联合挂载点的文件系统服务。

即将多个文件系统逻辑上联合组成为一个文件系统,因此在这个文件系统上的读写会通过写时复制的方式复制一份进行修改,而不会对原文件进行修改。

Docker构建速度非常快的原因就是采用了镜像分层,而镜像分层底层采用的技术就是Union File System,Linux 支持多种 Union File System,比如 aufs、OverlayFS、ZFS 等。

Docker 入门教程:Docker 基础技术 Union File System

3.1 AUFS

img

AUFS ,英文全称是Advanced Multi-Layered Unification Filesystem,字面意思是高级多层联合文件系统。它重写了早期的UnionFS1.x,并且加入了新功能,Docker也就使用的AUFS。

AUFS 是 Docker 选用的第 一种存储驱动 。 AUFS 具有快速启动容器、高效利用存储和内 存的优点 。 直到现在,AUFS 仍然是 Docker 支持的 一种存储驱动类型。 但AUFS是Docker早期使用的一种存储驱动,目前默认使用OverlayFS

Ubuntu/Debian(Stretch之前的版本)上的Docker-CE可以通过配置DOCKER_OPTS="-s=aufs"进行修改,同时内核中需要加载AUFS module,image的增删变动都会发生在/var/lib/docker/aufs目录下。

img

对于AUFS来说,每一 个Docker image 都是由 一 系列 read-only layer组成的 。 image layer 的内容都存储在Docker hosts filesystem/var/lib/docker/aufs/diff目录下。而/var/lib/docker/aufs/layers 目录,则存储着image layer如何堆栈这些 layer 的 metadata

虽然Docker已经没有使用AUFS了,但是AUFSoverlayFS原理差不多。

3.2 动手实践OverlayFS

由于CentOS7已经默认不支持AUFS了,我们可以使用OverlayFS用来练习。

Overlayfs通过三个目录:lower目录、upper目录、以及work目录实现,其中lower目录可以是多个,work目录为工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见,最后联合挂载完成给用户呈现的统一视图称为为merged目录。

挂载选项支持(即“-o”参数): 1)lowerdir:指定用户需要挂载的lower层目录(支持多lower,最大支持500层) 2)upperdir:指定用户需要挂载的upper层目录; 3)workdir:指定文件系统的工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见;

注:lowerdir、upperdir和workdir为基本的挂载选项

首先创建overlaytest文件夹,然后创建以下结构的文件。

image-20221016120718645

然后使用下面的命令挂载overlay文件系统:

mount -t overlay overlay -o lowerdir=image-layer1:image-layer2:image-layer3:image-layer4,upperdir=container-layer,workdir=work merge

1
2
3
4
5
6
7
参数说明
-t 文件系统类型
-o 表示options,多个参数间不可以有任何空格
workdir 必须是一个与 upperdir 相同文件系统的空文件夹。
merge 联合挂载点所在的目录。
lowerdir 可以指定多个文件夹,用 : 隔开,从从左往右级别依次降低(左边在上面,右边在下面)。
upperdir、workdir 参数可以省略,表示只读。
  • lowerdir 为只读层
  • upperdir 为最上层,可读可写。但它并不是融合层,在这一层读不到其他层的数据。
  • merge 是联合挂载点,可以读到所有层的融合数据。也可以对它进行写操作,但实际上数据是写入了upperdir

挂载完成后可以看到merge文件夹的内容即是联合文件系统的内容:

image-20221016120800725

可以使用df -hl查看挂载:

image-20221016121436713

由于挂载的时候从左到右层级从上往下,那么如果遇到文件名或文件夹名冲突怎么处理呢?

OverlayFS的合并策略如下:

  1. 名称不冲突,直接合并
  2. 文件夹名冲突,合并文件夹下所有文件到一个文件夹
  3. 文件名冲突,只显示上层的同名文件。

直观的图如下:

img

接下来我们测试在联合文件系统上修改文件,使用下面的命令往mergeimage-layer1.txt写入内容:

echo -e "\nwrite to image-layerl.txt" >./merge/image-layer1.txt

然后查看目录结构,发现在我们的upperdir中多了一个image-layer1.txt

image-20221016152257359

然后发现虽然我们在联合文件上修改了文件,merge中的文件内容也的确被修改了,但是 原文件image-lay1/image-layer1.txt的内容并没有被修改。

image-20221016152453009

也就是说, 当尝试向 merge/image-layer1.txt 文件进行写操作的时候 , 系统首先在 merge 目录下 查找名为 image-layer1.txt 的文件,将其拷贝到 read-write(upperdir) 层的 container-layer 目录中,接着对container-layer 目录中的 image-layer1.txt 文件进行写操作 ,也正是写时复制的思想。

四、构建实现Run命令的容器

4.1 Linux proc文件系统介绍

Linux 下的 /proc 文件系统是 由内核提供的,它其实不是一个真正的文件系统,只包含了系统运行时的信息( 比如系统内存、 mount 设备信息、一些硬配置等),它只存在于内存中,而不占用外存空间 。 它以文件系统的形式,为访问内核数据的操作提供接口

目录中的数字即进程的PID,里面的内容即进程创建的空间。

image-20221016154620727

详细介绍可查看Linux系统proc目录说明 - 知乎 (zhihu.com)

4.2 实现run命令

首先实现一个简单run命令的容器,类似docker run -it [cmd]

目录结构如下:

image-20221017151012128
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// main.go
package main

import (
log "github.com/sirupsen/logrus"
"github.com/urfave/cli"
"os"
)

const usage = `mydocker is a simple container runtime implementation.
The purpose of this project is to learn how docker works and how to write a docker by ourselves
Enjoy it, just for fun.`

func main() {
app := cli.NewApp()
app.Name = "GoDocker"
app.Usage = usage

app.Commands = []cli.Command{
initCommand,
runCommand,
}
app.Before = func(context *cli.Context) error {
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(os.Stdout)
return nil
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

"github.com/urfave/cli"是go的一个命令行程序,使用go build .后会生成一个可执行文件,从而可以启动时添加参数。

app.Commands设定initCommandrunCommand两个命令函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// main_command.go
package main

import (
"GoDocker/container"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/urfave/cli"
)

var runCommand = cli.Command{
// 定义了 runCommand 的 Flags ,其作用类似于运行命令时使用一来指定参数
Name: "run",
Usage: `create a container with namespace and cgroups limit godocker run -it [command]`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "it",
Usage: "enable tty",
},
},
/**
run命令执行的函数
*/
Action: func(ctx *cli.Context) error {
//缺少run参数
if len(ctx.Args()) < 1 {
return fmt.Errorf("missing container command")
}
// 获取用户指定的command,调用Run启动容器
cmd := ctx.Args().Get(0)
tty := ctx.Bool("it")
Run(tty, cmd)
return nil
},
}

var initCommand = cli.Command{
Name: "init",
Usage: "Init container, create an user's process for container, call inside only",
Action: func(ctx *cli.Context) error {
log.Infof("Init container")
cmd := ctx.Args().Get(0)
log.Infof("command:%s", cmd)
err := container.RunContainerInitProcess(cmd, nil)
return err
},
}

main_command.go中定义了command需要执行的操作。

tty是虚拟控制台,用于容器启动的交互模式。

runCommand中会调用Run(tty, cmd)来创建一个容器,创建容器完成后,再使用container.RunContainerInitProcess(cmd, nil)来初始化容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// run.go
func Run(tty bool, command string) {
parent := container.NewParentProcess(tty, command)
if err := parent.Start(); err != nil {
log.Error(err)
}
err := parent.Wait()
if err != nil {
return
}
os.Exit(-1)
}


// container/container_process.go
// NewParentProcess 自己调用自己fork一个新进程,使用namespace隔离资源,如果指定了 -it 就需要将当前的输入输出导入到os的标准输入输出上
func NewParentProcess(tty bool, command string) *exec.Cmd {
args := []string{"init", command}
// 自己调用自己,对创建出来的进程进行初始化
cmd := exec.Command("/proc/self/exe", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd
}

NewParentProcess()通过执行/proc/self/exe自己调用自己,就会创建一个自己进程的子进程(也就是容器的进程),同时args关联了新初始化的一些参数,后面通过Cloneflagsnamespace创建了一个隔离环境的子进程。

后面的if tty即如果用户run时指定了-it,则需要将当前进程的输入输出导入到标准输入输出上。

容器进程创建好了,就该进行初始化操作了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// container/init.go
// RunContainerInitProcess
//这里的 init 函数是在容器内部执行的,也就是说 , 代码执行到这里后 , 容器所在的进程其实就已经创建出来了,
//这是本容器执行的第一个进程。
//使用 mount 先去挂载 proc 文件系统,以便后面通过 ps 等系统命令去查看当前进程资源的情况。/**
func RunContainerInitProcess(cmd string, args []string) error {
logrus.Infof("command: %s", cmd)

// 不允许运行其他程序、不允许设置userid或groupid
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
argv := []string{cmd}
// syscall.Exec 初始化并运行用户进程、覆盖pid=1的init进程信息
if err := syscall.Exec(cmd, argv, os.Environ()); err != nil {
logrus.Errorf(err.Error())
}
return nil
}

MS_NOEXEC等是挂载文件系统的一些限制条件,

  • MS_NOEXEC:在本文件系统中不允许运行其他程序。
  • MS_NOSUID:在本系统中运行程序的时候, 不允许 set-user-ID 或 set-group-ID 。
  • MS_NODEV:自 从 Linux 2.4 以来,所有 mount 的系统都会默认设定的参数。

关键的就是syscall.Exec(cmd, argv, os.Environ())这句代码,这句代码执行了一次系统调用,调用内核int execve()这个函数,作用是执行我们指定的程序,而将当前进程原来的信息(镜像、数据、堆栈、PID)覆盖掉。

那有什么效果呢?我们容器创建完成后第一个执行的程序是init(),那么执行init()的进程就会成为PID=1的进程,但是init()只是内部可见的,并且Docker里每个容器PID=1的也不是init()的进程呀。所以需要将其替换掉。

我们启动容器会通过docker run -it /bin/sh类似的命令进行启动,后面的/bin/sh是我们执行的程序,那么/bin/sh的进程才应该是pid=1的进程,执行完上面的系统调用后,就可以达到这个效果。

还有另外一个原因是:我们指定的用户进程创建完成后就无法对其进行文件挂载了,所以在执行系统调用前是主进程可管控的子进程,可以对其进行文件挂载和资源限制,限制完成后执行系统调用将其转换为用户进程。

类似于创建一个参数设定好的子进程,创建完成后再退位给用户进程,起到一个代理进程的作用。

这其实也是目前 Docker 使用的容器引擎 runC 的实现方式之一。

流程图如下:

image-20221017153706241

然后在linux上使用go build .编译,会生成一个可执行文件。

image-20221017153809050

然后使用./GoDockerrun -it /bin/sh启动容器,并用ps -ef查看进程pid:

image-20221017153852391

可以看到PID=1的进程的确是我们指定的/bin/sh,执行ps -ef的进程是pid=1进程创建的子进程。

Docker要求容器必须有一个前台进程,即不会自动结束的进程,不然容器就会自动停止。我们pid=1/bin/sh进程也就是一个前台进程。

可以通过./GoDockerrun -it /bin/ls验证效果,执行完后容器就停止了。

4.3 增加容器资源限制

这一节将通过godocker run -it -m 100m -cpuset 1 -cpushare 512 /bin/sh的方式控制容器内存和CPU资源。

4.3.1 定义Cgroups数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// cgroups/subsystems/subsystem.go
// ResourceConfig 传递资源配置
type ResourceConfig struct {
MemoryLimit string // 内存限制
CpuShare string // Cpu时间片权重
CpuSet string // Cpu核心数
}

// 定义Subsystem的接口
type SubSystem interface {
// Name 返回subsystem的名称
Name() string
// Set 设置cgroup在该subsystem的限制
Set(cgroupPath string, res *ResourceConfig) error
// Apply 将进程添加进cgroup, 将cgroup抽象成path
Apply(cgroupPath string, pid int) error
// Remove 移除某个cgroup
Remove(cgroupPath string) error
}

var (
SubsystemsIns = []SubSystem{
&CpuSetSubsystem{},
&MemorySubsystem{},
&CpuSubsystem{},
}
)

上面代码定义了资源限制的配置项、Subsystem的操作接口,通过[]SubSystem定义限制资源的具体结构体,均实现Subsystem接口。

需要注意的是Cgroupstring类型的cgroupPath来表示,因为一个cgroup是一个文件夹,只需要知道其路径即可。

4.3.2 资源限制操作代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// cgroups/subsystem/memory.go

package subsystems

import (
"fmt"
"io/ioutil"
"os"
"path"
"strconv"
)

type MemorySubsystem struct {
}

func (m MemorySubsystem) Name() string {
return "memory"
}

func (m MemorySubsystem) Set(cgroupPath string, res *ResourceConfig) error {
// 获取Cgroup根路径
if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true); err != nil {
if res.MemoryLimit != "" {
// 写入memory.limit_in_bytes内存限制
if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit), 0644); err != nil {
return fmt.Errorf("set cgroup memory fail %v", err)
}
}
return nil
} else {
return err
}
}

func (m MemorySubsystem) Apply(cgroupPath string, pid int) error {
if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, true); err != nil {
// 将进程pid写入tasks
if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil {
return fmt.Errorf("set process pid to cgroup failed %v", err)
}
return nil
} else {
return fmt.Errorf("get cgroup %s error: %v", cgroupPath, err)
}
}

func (m MemorySubsystem) Remove(cgroupPath string) error {
if subsysCgroupPath, err := GetCgroupPath(m.Name(), cgroupPath, false); err == nil {
// 删除对应的cgroup文件夹
return os.RemoveAll(subsysCgroupPath)
} else {
return err
}
}

上面的代码是对于内存进行限制的具体代码,其中用到了utils工具类的代码来寻找Cgroup的根目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// cgroups/subsystems/utils.go

// FindCgroupMountPoint mountinfo内容示例:406 30 7:26 / /snap/gnome-system-monitor/178 ro,nodev,relatime shared:207 - squashfs /dev/loop26 ro
func FindCgroupMountPoint(subsystem string) string {
f, err := os.Open("/proc/self/mountinfo")
if err != nil {
return ""
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
}
}(f)

scanner := bufio.NewScanner(f)
for scanner.Scan() {
txt := scanner.Text()
fields := strings.Split(txt, " ")
for _, opt := range strings.Split(fields[len(fields)-1], ",") {
if opt == subsystem {
// 第四项是路径信息
return fields[4]
}
}
}
if err := scanner.Err(); err != nil {
return ""
}
return ""
}

// GetCgroupPath 获取当前subsystem在虚拟文件系统中的路径
func GetCgroupPath(subsystem string, cgroupPath string, autoCreate bool) (string, error) {
cgroupRoot := FindCgroupMountPoint(subsystem)
if _, err := os.Stat(path.Join(cgroupRoot, cgroupPath)); err == nil || (autoCreate && os.IsNotExist(err)) {
if os.IsNotExist(err) {
if err := os.Mkdir(path.Join(cgroupRoot, cgroupPath), 0755); err == nil {
} else {
return "", fmt.Errorf("error create cgroup %v", err)
}
}
return path.Join(cgroupRoot, cgroupPath), nil
} else {
return "", fmt.Errorf("cgroup path error %v", err)
}
}

"/proc/self/mountinfo"这个文件里存储了所有文件挂载信息,通过查询这个文件,和传入的subsystem名称进行匹配,就可以得到subsystem的绝对路径,也就是cgroup的根目录。

mountinfo内容示例:406 30 7:26 / /snap/gnome-system-monitor/178 ro,nodev,relatime shared:207 - squashfs /dev/loop26 ro,倒数第二项就是挂载的地址。

结果如下:

image-20221018161019058

然后我们写一个cgroup_manager来将不同subsystemcgroup管理起来,并与容器建立连接。

4.3.3 CgroupManager统一管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// cgroups/cgroup_manager.go 

type CgroupManager struct {
// cgroup在hierarchy中的路径
CgroupPath string
// 资源配置
Resource *subsystems.ResourceConfig
}

func NewCgroupmanager(path string) *CgroupManager {
return &CgroupManager{CgroupPath: path}
}

// Apply 将进程pid加入到这个cgroup中
func (c *CgroupManager) Apply(pid int) error {
for _, subSysIns := range subsystems.SubsystemsIns {
err := subSysIns.Apply(c.CgroupPath, pid)
if err != nil {
return err
}
}
return nil
}

// Set 设置cgroup资源限制
func (c *CgroupManager) Set(res *subsystems.ResourceConfig) error {
// 创建Subsystem实例
for _, subSysIns := range subsystems.SubsystemsIns {
err := subSysIns.Set(c.CgroupPath, res)
if err != nil {
return err
}
}
return nil
}

// Destroy 释放cgroup
func (c *CgroupManager) Destroy() error {
for _, subSysIns := range subsystems.SubsystemsIns {
if err := subSysIns.Remove(c.CgroupPath); err != nil {
logrus.Warnf("remove cgroup fail %v", err)
}
}
return nil
}

调用过程如下:

image-20221018154536979

4.3.4 添加命令行参数

最后需要添加命令行的参数识别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// main_command.go
var runCommand = cli.Command{
// 定义了 runCommand 的 Flags ,其作用类似于运行命令时使用一来指定参数
Name: "run",
Usage: `create a container with namespace and cgroups limit godocker run -it [command]`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "it",
Usage: "enable tty",
},
cli.StringFlag{
Name: "m",
Usage: "memory limit",
},
cli.StringFlag{
Name: "cpushare",
Usage: "cpushare limit",
},
cli.StringFlag{
Name: "cpuset",
Usage: "cpuset limit",
},
},
/**
run命令执行的函数
*/
Action: func(ctx *cli.Context) error {
//缺少run参数
if len(ctx.Args()) < 1 {
return fmt.Errorf("missing container command")
}
// 获取用户指定的command,调用Run启动容器
var cmdArray []string
for _, arg := range ctx.Args() {
cmdArray = append(cmdArray, arg)
}
tty := ctx.Bool("it")
resConf := &subsystems.ResourceConfig{
MemoryLimit: ctx.String("m"),
CpuSet: ctx.String("cpuset"),
CpuShare: ctx.String("cpushare"),
}
Run(tty, cmdArray, resConf)
return nil
},
}

4.3.5 测试效果

仅凭上面列举的代码还无法运行,额外的改造部分没有进行说明。

测试内存限制:

使用./GoDocker run -it -mm 100m stress --vm-bytes 200m --vm-keep -m 1 使用stress创建一个200m的应用,但是限制了内存为100m。

由于-mstress的 -m冲突了,所以启动会有问题,这里先改为-mm

image-20221018164758360

top可以看到stress内存占用为5%,机器内存为2g,也就是100m

image-20221018164955561

测试CPU时间片:

由于CPU限制只在CPU忙的时候有用,机器的两核CPU,所以启动两个stress进程。

启动两个个不限制时间片的进程:nohup stress --vm-bytes 200m --vm-keep -m 1 &

再启动一个限制时间片为256的进程:./GoDocker run -it -cpushare 256 stress --vm-bytes 200m --vm-keep -m 1

cpu默认shares1024,则限制进程能使用CPU最大为:256/(256 + 1024) = 1/5,所以能用40%的CPU资源。通过top查看的确进行了限制。

image-20221018182052322

4.4 增加管道及环境变量识别

4.4.1 管道基本知识

本节会为run命令增加管道和环境变量识别的功能。

当在 Linux 上创建两个进程时,进程之间的通信一般就会使用管道的机制。所谓管道,就是一个连接两个进程的通道,它是 Linux 支持 IPC 的其中一种方式。一般来说,管道都是半双工的,一端进行写操作,另外一端进行读操作。

常用的管道分为两种类型。一种类型是无名管道 , 它一般用于具有亲缘关系的进程之间 。

另外一种是有名管道,或者叫 FIFO 管道 , 它是一种存在于文件系统的管道,可以被两个没有 任何亲缘关系的进程进行访问。有名管道一般可以通过 mkfifo()函数来创建。

从本质上来说,管道也是文件的一种 , 但是它和文件通信的区别在于 ,管道有一个固定大小的缓冲区,大小一般是 4KB。当管道被写满时,写进程就会被阻塞,直到有读进程把管道的内容读出来。同样地,当读进程从管道内拿数据的时候,如果这时管道的内容是空的,那么读进程同样会被阻塞 ,一直等到有写进程向管道内写数据。

之前父进程和子进程进行传递是通过是通过调用命令后面跟上参数 ,也就是/proc/self/exe init args 这种方式进行的,然后,在init进程内去解析这个参数 , 执行相应的命令。但是如果参数很长或者带有特殊字符,那么就会失败了。

runC的实现是通过匿名管道来实现传参的。

4.4.2 将管道两端链接到父进程和子进程

使用下面的函数创建一个匿名管道

1
2
3
4
5
6
7
8
// container/container_process.go
func NewPipe() (*os.File, *os.File, error) {
read, write, err := os.Pipe()
if err != nil {
return nil, nil, err
}
return read, write, nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// container/container_process.go
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
// 自己调用自己,对创建出来的进程进行初始化
cmd := exec.Command("/proc/self/exe", "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}

// 将管道文件读取端的句柄传给子进程。
cmd.ExtraFiles = []*os.File{readPipe}
return cmd, writePipe
}

创建了管道,就要将管道两端连接起来。这里将管道的读取端文件句柄传入 cmd.ExtraFiles,表明主进程带着这个句柄去创建子进程。因为 1 个进程默认会有 3 个文件描述符,分别是标准输入、标准输出、标准错误。 这样子进程就有4个句柄了,也就接通了管道的读取端。

可以看到多了一个fd。

image-20221020105132133

接下来看子进程的改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// container/init.go
func readUserCommand() []string {
// intptr(3) 表示索引为3的fd,即管道的读取端fd
pipe := os.NewFile(uintptr(3), "pipe")
// 从管道读取所有的命令
msg, err := ioutil.ReadAll(pipe)
if err != nil {
log.Errorf("init read pipe error %v", err)
return nil
}
msgStr := string(msg)
return strings.Split(msgStr, " ")
}

func RunContainerInitProcess() error {
cmdArray := readUserCommand()
log.Infof("command: %s", cmdArray)
if cmdArray == nil || len(cmdArray) == 0 {
return fmt.Errorf("run container get user command error, cmdArray is nil")
}

// 在系统环境变量里寻找命令的绝对路径
path, err := exec.LookPath(cmdArray[0])
if err != nil {
log.Errorf("Exec loop path error %v", err)
return err
}
log.Infof("Find path %s", path)
if err := syscall.Exec(path, cmdArray[0:], os.Environ()); err != nil {
log.Errorf(err.Error())
}
return nil
}

通过exec.LookPath(cmdArray[0]),就可以在系统环境变量里寻找命令,例如以前启动时必须写成bin/ls,现在就可以只写ls

到此子进程通过管道读取命令已经完成了,下面看下父进程在哪里通过管道发送命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// run.go
func Run(tty bool, commandArray []string, res *subsystems.ResourceConfig) {
parent, writePipe := container.NewParentProcess(tty)
if parent == nil {
log.Errorf("New parent process error")
return
}
if err := parent.Start(); err != nil {
log.Error(err)
}

// 创建CgroupManager
manager := cgroups.NewCgroupManager("godocker-cgroup")
defer func(manager *cgroups.CgroupManager) {
err := manager.Destroy()
if err != nil {
}
}(manager)
// 设置资源限制
manager.Set(res)
log.Infof("resourceConfig:%s", res)
log.Infof("parent.process.pid:%d", parent.Process.Pid)
manager.Apply(parent.Process.Pid)
// 通过管道发送用户输入的命令
sendInitCommand(commandArray, writePipe)
parent.Wait()
}
func sendInitCommand(comArray []string, writePipe *os.File) {
command := strings.Join(comArray, " ")
log.Infof("commands are %s", command)
writePipe.WriteString(command)
writePipe.Close()
}

writePipe.WriteString(command)就可以将命令写入管道。注意的是在parent.Start()后才写入管道,说明写入管道的时候,子进程已经在读等待了。完整流程图如下:

image-20221020110121445

4.4.3 测试效果

使用./GoDocker run -it ls -l查看执行效果,发现程序找到了bin/ls,执行了ls并返回。说明容器通过管道读到了ls命令,并且在环境变量中找到了ls

image-20221020110809329