适合的读者,对Docker有过简单了解的朋友,想要进一步了解Docker容器的朋友。
前言
回想我这两年,一直都是在使用 Docker,看过的视频、拜读过的博客,大都是在介绍 Docker 的由来、使用、优点和发展趋势,但对于 Docker 底层到底是如何实现,却是没有提起太多,当然也是我太菜啦,哈哈哈~
便想借本次技术专题的机会,一方面希望能满足自己心底的那份好奇心,另外也想编写一篇关于 Docker 实现原理的文章来让更多的小伙伴知道和了解自己所使用 Docker 底层到底是怎么样的。
本文更偏向科普,能不能写好,坦白说,我心里也没底,希望大佬们给点建议,我加油改改
本文大纲:
那么接下来就进入正文吧。
一、Docker 基本架构图
图片来源:Docker 官方文档
Docker 采用了 C/S
架构,包括客户端和服务端。Docker 守护进程 (Daemon
)作为服务端接受来自客户端的请求,并处理这些请求(创建、运行、分发容器)。
客户端和服务端既可以运行在一个机器上,也可通过 socket
或者 RESTful API
来进行通信。
Docker 守护进程一般在宿主主机后台运行,等待接收来自客户端的消息。
Docker 客户端则为用户提供一系列可执行命令,用户用这些命令实现跟 Docker 守护进程交互。
另外这一点,也可以在执行 docker info
时看出来。
关于这个更详细的内容,官网说的更加详细,我就没多写啦。
二、Docker Client 和 Docker Server 如何连接?
docker 底层是通过套接字的方式去连接的。
Docker 守护进程可以通过三种不同类型的 Socket 侦听Docker Engine API :unix
, tcp
, and fd
.
默认情况下,unix
域套接字(或 IPC 套接字)在 处创建 /var/run/docker.sock
,需要root
权限或docker
组成员身份。
- 补充:linux sock文件是指通过 shell 编程后形成的套接口文件;socket 是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口;在设计模式中,Socket 其实就是一个门面模式,它把复杂的TCP/IP 协议族隐藏在 Socket 接口后面。
如果需要远程访问Docker守护进程,则需要启用tcp
Socket。这也是我们实现远程操作Docker的实现方式。
下面我们借助 socat 来直观的感受一下,本机中的 client 和 server 如何连接的吧
sudo apt install socat
补充:什么是socat
socat是一个用于数据转发的命令行工具,它可以在两个端口之间建立虚拟通道,将数据从一个端口转发到另一个端口,同时支持很多网络协议,如常见的 TCP、UDP、HTTP、HTTPS等等。
在此我们就是借助它来进行tcp报文的转发。
socat -v UNIX-LISTEN:/tmp/dockerapi.sock UNIX-CONNECT:/var/run/docker.sock &
1)-v 参数的作用是表示在执行过程中输出详细信息。 -v
选项会让 socat
输出更多的信息,方便调试和监控。
2)UNIX-LISTEN:/tmp/dockerapi.sock
: 这部分指定了 socat
要监听的 Unix 域套接字路径。它告诉 socat
在 /tmp/dockerapi.sock
这个路径上监听传入的连接请求。
3)UNIX-CONNECT:/var/run/docker.sock
: 这部分指定了 socat
要连接的目标 Unix 域套接字路径。它告诉 socat
要将传入的数据流连接到 /var/run/docker.sock
这个路径上。
4) &
: 这个符号将命令放入后台运行.
简单点说就是将从 /tmp/dockerapi.sock
接收到的数据流连接到 /var/run/docker.sock
上。
我们来查看一下是否成功启动监听啦。
ps -e | grep socat
docker client 可以通过指定 -H
来指定需要连接的服务端。下面是我们指定为本机的 Server 来进行测试。上面我们已经使用 socat 监听来自unix://tmp/dockerapi.sock
数据输入啦,它会帮我们连接到 /var/run/docker.sock
上去。看结果吧
docker -H unix://tmp/dockerapi.sock ps
不过这个数据没有格式化,有点难看,但还是可以看出它将我本地在运行的容器显示出来啦。
我也尝试了使用 docker client 来管理其他机器的 docker 服务。(需要修改其他机器上的docker服务配置文件,并重启)
可参考:Docker开放2375端口,实现远程访问
注意:这并不是一个安全的操作,因为并没有加密和验证之类,云服务器请谨慎操作或及时关闭,我是本地虚拟机测试。
开胃小菜结束了,下面的才是有意思的,但是我想通过上面两节小小案例的演示,大家对于 Docker 的客户端和服务端之间的交互应该了解了一些了吧~
三、Docker 核心原理的三大底座
- 在容器进程启动之前重新挂载它的整个根目录“/”,用来为容器提供隔离后的执行环境文件系统(rootfs)。
- 通过 Liunx Namespace 创建隔离,决定进程能够看到和使用哪些东西。
- 通过 control groups 技术来约束进程对资源的使用。
四、Rootfs
详细的请阅读这篇文章:Docker 原理剖析(三)rootfs,这一小节主要内容均来自于此篇文章,作者写的太好啦。
这是我在看这方面博客写的最好的一篇啦,真心建议阅读,文章深度和内容都足够好。
4.1、rootfs 介绍
rootfs 是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。所以宿主机操作系统的内核,它对于该机器上的所有容器来说是一个全局变量,牵一发而动全身。
由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。
有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整执行环境就被重现出来了。
在 Docker 架构中,当 Docker daemon 为 Docker 容器挂载 rootfs 时,沿用的 liunx 内核启动时的方法,即将 rootfs 设为只读模式。在挂载完毕之后,利用联合挂载(union mount )技术在已有的只读 rootfs 上再挂载一个读写层。这样,可读写层处于Docker容器文件系统的最顶层,其下可能联合挂载了多个只读层,只有在Docker容器运行过程中文件系统发生变化时,才会把变化的文件内容写到可读写层,并且隐藏只读层的老版本文件。
我们可以看一个 Ubuntu 镜像,实际上它是 Ubuntu 操作系统的 rootfs,包含了 Ubuntu 操作系统的所有文件和目录。不过这个 rootfs,由多个层组成,每一个层都是一个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分。在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上,这个挂载点就是 /var/lib/docker/aufs/mnt/。(镜像的层都放置在 /var/lib/docker/aufs/diff 目录下)
4.2、rootfs 组成
rootfs 由三部分组成,由上往下分别是:可读写层,init 层,只读层。
我们以之前使用的 Ubuntu 镜像为例。
只读层是容器的 rootfs 的下五层,它们的挂载方式都是只读的,可见这些层都以增量的方式分别包含了 Ubuntu 操作系统的一部分。
可读写层是容器的 rootfs 的最上面一层,在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。
但是,如果我现在要做的,是删除只读层里的一个文件呢?为了实现这样的删除操作,会在可读写层创建一个 whiteout 文件,把只读层里的文件遮挡起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。
这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件遮挡起来,消失了。综上所述,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里,而原先的只读层里的内容则不会有任何变化。
相当于你做的所有操作都只会影响到读写层,并不会影响到在此之前的只读层,这一层的可读写层也就是我们的容器啦。
Init 层在只读层与可读写层的中间,是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
4.3、文件联合系统(UnionFS)
UnionFS 是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。
Union文件系统是Docker镜像的基础。
Unios FS 在 Docker 中的使用大致如下图:
镜像可以通过分层来进行继承,基于基础镜像。可以制作各种具体的应用镜像。 分层最大的一个优点是共享资源;多个镜像都从相同的base镜像构建而来,那么宿主机只需在磁盘上保存一份base镜像即可;同时内存中也只需要加载一份base镜像,就可以为所有容器服务,而且镜像的每一层都可以被共享。
4.4、Docker 镜像原理
所以当我们使用用docker run命令启动某个容器时,实际上在镜像的顶部添加了一个新的可写层,而这个新的可写层,被我们称为了容器。
容器启动后,其内的应用所有对容器的改动,文件的增删改操作都只会发生在容器层中,对容器层下面的所有只读镜像层没有影响。
这也就是写时复制。
五、Linux Namespace
5.1、namespace 是什么?
Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。
5.2、namespace 解决了什么问题?
Linux 内核出现 namespace 的一个主要目的就是实现轻量级虚拟化(容器)服务。在同一个 namespace 下的进程可以感知彼此的变化,而对外界的进程一无所知,从而达到隔离的目的。
其实换个说法,Linux 内核所提供的 namespace 技术为 docker 等容器技术的出现和发展提供了基础条件。没有 linux 内核中的 namespace 的出现,可能 docker 容器化技术还不会那么快出现。
5.3、namespace 具体有哪些呢?
1.Mount Namespace
文件系统隔离。隔离了一组进程所看到的文件系统挂载点的集合,在不同的Mount Namespace 的进程所看到的文件是不同的。
2.UTS Namespace
隔离主机和域名信息。隔离了 uname() 系统调用返回的两个系统标识符 nodename 和 domainname. 在容器的上下文中,UTS namespace 允许每个容器拥有自己的hostname 和 UNIX domainname,这对于初始化和配置脚本是十分有用的,这些脚本根据这些名称来定制他们的操作。
3.IPC Namespace
隔离进程间通信。隔离了某些IPC资源(interprocess community,进程间通信)使划分到不同IPC Namespace 的进程组通信上隔离,无法通过消息队列、共享内存、信号量方式通信,这样,只有在同一个Namespace下的进程才能相互通信。如果你熟悉IPC的原理的话,你会知道,IPC需要有一个全局的ID,即然是全局的,那么就意味着我们的Namespace需要对这个ID隔离,不能让别的Namespace的进程看到。 要启动IPC隔离,我们只需要在调用clone时加上CLONE_NEWIPC参数就可以了。 int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD, NULL);
4.PID Namespace
进程隔离。隔离了进程ID空间,不同的 PID Namespace 中的进程可以拥有相同的 PID。PID Namespace 的好处之一是,容器可以在主机之间迁移,同时容器内的进程保持相同的进程ID。PID Namespace 空间还允许每个容器拥有自己的 init (PID 1),它是“所有进程的祖先”,负责管理各种系统初始化任务,并在子进程终止时收割孤儿进程。
5.Network Namespace
网络资源隔离。每个 Network Namespace 都有自己的网络设备、IP地址、IP路由表、/proc/net目录、端口号等。
6.User Namespace
用户和用户组隔离。一个进程的用户和组ID在 User Namespace 空间外可以是不同的,一个进程可以在用户命名空间外拥有一个正常的无权限用户 ID,同时在命名空间内拥有一个(root 权限) 的用户ID。
上面说到了一句,要启动IPC隔离,我们只需要在调用clone时加上CLONE_NEWIPC参数就可以了。
这主要是因为 Linux 的 namespace 主要是利用下面三个系统调用:
clone
() – 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述参数达到隔离。unshare
() – 使某进程脱离某个namespacesetns
() – 把某进程加入到某个namespace
深处我也不会~
参考文章来自于:https://coolshell.cn/articles/17010.html
也是在这里,我才知道 Docker 流行的真的好早好早,而我真的知道 Docker 的时候已经在2020年啦。
5.4、通过实践来证明
理论结合实践才能更好的理解这些到底在说什么,我用一个简单的例子来简单阐述一下吧。
docker run -it busybox /bin/sh
1、我们以交互式的方式进入容器,执行 ls
命令,可以看到常规的 unix 系统目录结构,但这并非是宿主机的文件系统。
有了文件系统隔离,我们在当前容器内对文件所做的操作并不会影响到外部宿主机的文件,另外我们启动不同的容器,我们所看到的文件系统也是隔离的。
2、隔离主机和域名信息。这点其实很好验证,当执行 hostname 命令时,控制台会返回当前主机名称给我们。
前者是在容器内部执行,返回的是容器ID,后者是在宿主机的控制台执行,输出的是ubuntu。
3、当我们执行 ifconfig 命令,我们会看到和我们宿主机不同的网卡和网关信息等
除了上面的方式,我们也可以通过 docker inspect <容器名|容器ID>
来看容器的相关信息,其中也包含了容器网络相关信息。
4、进程隔离也是同样如此,我们在宿主机和容器中分别执行 ps -ef | grep sh
命令,看看结果就知道啦
在容器内明显看不到宿主机其他进程情况,并且容器内的 /bin/sh
PID 为1,PID =1 的进程是系统启动时的第一个进程,也称 init 进程。其他的进程,都是由它管理产生的。而此时,PID=1 却是 /bin/sh 进程。
而它在宿主机上所展示时,它的PID变成了 2456。
这一点也可以换成下面这条命令来准确定位
docker inspect -f '{{.State.Pid}}' <容器名称或ID> # 查看容器的PID
这个结论就非常好说了,在容器中,它确确实实是完成了 PID 的隔离,明明宿主机上是 2456 的进程,变成了容器内的 1 号进程,同时在容器中还看不到宿主机其他进程。
5.5、Docker 中的 User Namespace 详解
Docker 中引入的 user namespace 可以让容器中有一个 “假”的 root 用户,它在容器内是 root,在容器外是一个非 root 用户。也就是 user namespace 实现了 host user 和 container user 之间的映射。
我们拿一个简单的例子来说明:
docker run -it -v /bin:/host/bin busybox /bin/sh
先来解释下这条命令,现在是 dj(uid=1000,gid=1000)的用户,将本机上的 /bin
目录,挂载到容器中 /host/bin
目录下啦,并以交互式的方式启动了容器 busybox ,进入到容器内部。
那么现在有个问题:我在容器中的 /host/bin
目录下可以修改文件或者增加文件吗? 见下图。
答案是可以的。为什么呢?我一个非root用户,为什么可以操作root权限的文件呢?
原因:Docker容器运行的时候,如果没有专门指定user, 默认以root用户运行。它并不是说按照你现在的登录的用户去分配权限的,而是没有指定就默认使用root用户运行。
另外有没有觉得这是非常恐怖的一件事情,我明明没有 root 权限,却突然之间通过 docker 给容器挂载一个文件目录,虽然我一下没想起来可以做什么,但还是有点恐怖的哈。
你看到这里也许会觉得有些疑惑,为什么和此小节说的第一段话是自相矛盾的呢?
因为在 Docker中默认并没有开启 user namespace,这并不是说 Linux 机器没有支持 user namespace ,而是 docker 中没有开启。
在说如何让 Docker 启用 user namespace 之前,我们先用另一种方式来曲线救国一下。
我之前使用 id 命令, 看到了我当前用户 dj
的(uid=1001),我们在执行 docker run
记得指定一下即可。
上面的命令改为:
docker run -it -u 1001:1001 -v /bin:/host/bin busybox /bin/sh
我们使用了 -u 1001:1001
来指定了容器内所使用的用户。我们再重复一下上面的操作。
显示权限不足啦。
5.6、如何让 Docker启用 User namespace 呢?
1、备份 docker 配置文件
因为我之前没有配置文件,所以我直接新建了一个文件
2、修改 docker 配置文件
添加 User Namespace 配置: 在配置文件中添加以下内容以启用 User Namespace。这将告诉 Docker 守护进程使用 User Namespace 功能。
{"userns-remap": "default"}
3、保存文件,重启启动 Docker 服务,以使配置文件生效
sudo systemctl restart docker
4、验证配置
cat /etc/subuidcat /etc/subgid
dockermap 是默认的映射名称。
补充: /etc/subuid
是一个系统配置文件,用于管理用户命名空间中用户的子用户标识(sub-IDs)。不多占篇幅介绍了,/etc/subgid
相应就是用户组的标识。具体感兴趣可以去了解。
现在我们再执行上面之前测试的命令。
docker run -it -v /bin:/host/bin busybox /bin/sh
我们还是使用 dj
这个用户,但是它已经没有权限修改从宿主机 /bin
映射到容器中的/host/bin
的目录了。
并且使用 id
命令,可以看到容器内部是 root 用户,但实际上它在容器外并不是root 用户。
另外还可以查找容器的PID(进程号),通过容器的进程号,来查看它的命名空间。
docker inspect -f '{{.State.Pid}}' <容器名称或ID> # 查看容器的PID
查看进程命名空间:
cat /proc/${容器PID}/uid_map
/proc/5517/uid_map
它表示了容器内外用户的映射关系即将host 上的 231072 用户映射为容器内的 0 即 root 用户。
这说明通过使用 user namespace 使得容器内的进程运行在非 root 用户上,我们成功地限制了容器内进程的权限。
5.7、Docker 为什么不默认启用 User namespace呢?
编写这一小章节的时候,我原打算去找资料啦,因为我已经猜测到一些原因,但需要验证一下,但是看到还没关闭的 GPT窗口,就打算拿它试一下。答案如下:
结论也很容易得出,Docker 希望降低复杂性,获取更强的兼容性,降低故障排查难度,也希望降低普通开发人员的使用门槛,更好的推广。
5.8、Namespace 存在的问题
我们都知道 Namespace 的隔离是轻量化的,比起虚拟化的隔离,它的缺陷也很明显,就是无法进行彻底的隔离。
因为不管如何隔离,它都是依赖于同一个内核的,那么此时内核就成了所有容器的共享变量,改动了一次,就会影响到全部。
5.9、检查 linux 操作系统是否启用了 namespace
运行下面的命令即可检查是否启用了:
root@ubuntu:/home# uname -aLinux ubuntu 5.15.0-78-generic #85~20.04.1-Ubuntu SMP Mon Jul 17 09:42:39 UTC 2023 x86_64 x86_64 x86_64 GNU/Linuxroot@ubuntu:/home# cat /boot/config-5.15.0-7config-5.15.0-76-generic config-5.15.0-78-generic root@ubuntu:/home# cat /boot/config-5.15.0-78-generic | grep CONFIG_USER_NSCONFIG_USER_NS=yroot@ubuntu:/home#
如果是 「y」,则启用了,否则未启用。同样地,可以查看其它 namespace:
CONFIG_UTS_NS=yCONFIG_IPC_NS=yCONFIG_USER_NS=yCONFIG_PID_NS=yCONFIG_NET_NS=y
六、Linux Control Cgroups
无论 Docker 如何进行隔离,无法否认的是我们在当前宿主机中运行的所有容器,它依赖的硬件资源都只是当前机器。
另外在上文中我们也谈到,其实启动的每一个容器进程,它本身其实就是当前宿主机的进程之一,那么本质上来说,它也会和宿主机中的其他进程进行资源的竞争。
6.1、Control Cgroups
所以我们就要针对Docker运行的容器进行资源的限制,Cgroups
就是 Linux 内核中用来为进程设置资源的一个技术。
Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
还可以对进程进行优先级设置,审计,挂起和恢复等操作。
Linxu中为了方便用户使用cgroups,已经把其实现成了文件系统,其目录在/sys/fs/cgroup下,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。Cgroups 的每一个子系统都有其独有的资源限制能力,比如:
cpu
:限制进程在一段时间内能够分配到的 CPU 时间 blkio
:为块设备设定 I/O 限制,一般用于磁盘等设备 cpuset
:为进程分配单独的 CPU 核和对应的内存节点 memory
:为进程设定内存使用的限制
完整子系统如下图:
补充:当然这里面还牵扯到许多其他的问题,比如是如何实现的,资源如何分配等等,我也不会啦。
我们可以把 Linux Cgroups 理解成一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了。
6.2、Cgroups 存在的问题
跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。
我们知道,Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。
但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。
我在容器中执行 free 命令,展示的是我宿主机的相关信息。
造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即: /proc 文件系统不了解 Cgroups 限制的存在。
最后
写到这里的时候,看着好像是写完了,但其实还只是浅浅的开了个端罢了,很多想写,能力上知识面上都有所欠缺,还在加油学,希望能一起交流和学习~
在写相关内容的时候,拜读了许多大佬写的文章,同时也借鉴和整合多位大佬的文章内容,真的是知道的越多,也就是知道的越少。
以往认为对 Linux 已经有所了解,但到要一探究竟的时候,就纯纯一小白了,也是因为这次偶然的对 Docker 的好奇,让我对 Linux 有了更深刻的了解。
好奇永远都是探究式学习的动力之一。
~
参考文章
https://moelove.info/2021/11/17/一篇搞懂容器技术的基石-cgroup/#contents:cgroup-的核心文件—张晋涛
https://www.cnblogs.com/rexcheny/p/11017146.html 重点参考
https://developer.aliyun.com/article/377862 Linux user namespace 重点参考
https://developer.aliyun.com/article/377862 非常优秀,给了很多思路。
https://www.cnblogs.com/michael9/p/13039700.html#容器的文件系统容器镜像—rootfs
https://zhuanlan.zhihu.com/p/445258335
https://www.liaoxuefeng.com/article/1481991528644643
https://blog.csdn.net/Yosigo_/article/details/119124013
https://blog.csdn.net/qq_43380180/article/details/125953218
https://www.bilibili.com/video/BV163411G7vb/?spm_id_from=333.999.0.0&vd_source=f9d1f15d0ed8efc261af664b960ef668
https://zhuanlan.zhihu.com/p/392508816 rootfs 文件系统