我们在配置环境或者日常开发中,经常遇到的一大痛点就是,“明明它在我的电脑上是好好的!”。
一个程序需要依赖底层的OS、各种库、配置文件,开发环境,但凡不同环境之间出现了微小的差异(比如python库版本不同),都可能导致程序无法运行。那么如何解决这个问题呢?
1.从虚拟机到容器
为了解决这个问题,我们最早使用了虚拟机技术。虚拟机通过实现一个Hypervisor中间层,来模拟一整套硬件(包括CPU、内存、硬盘、网卡等等)。但是正因为虚拟机需要通过中间层来模拟所有硬件,所以启动一个虚拟机就像启动一台新电脑一样,会占用大量的CPU和内存资源。
因此,Docker提出了一种更轻量级的解决方案——容器。
Docker不会去模拟硬件,他会直接利用主机上的Linux内核,其本质就是主机上的一个被隔离的进程。Docker利用 Linux内核的特性,为这个进程创造了一个“它独占了整个操作系统”的假象。
2.Docker的核心原理:Namespace、Cgroups和Rootfs
1. Linux Namespace
Namespace是Linux kernel实现资源隔离的一种技术,其能够对一个进程(及其子进程)进行隔离,让这个进程(及其子进程)以为它所看到的系统资源(如进程列表、网络、文件系统)是它独占的。
系统层面上,Namespace 是如何实现的?
在Linux kernel中,每个进程都由一个叫task_struct的结构体来描述。这个结构体里,包含一个指向nsproxy结构体的指针,这个nsproxy里又包含了指向该进程所属的各种Namespace指针。例如:
struct tast_struct
{
// ...
struct nsproxy *nsproxy;
};
struct nsproxy
{
atomic_t count;
struct uts_namespace *uts_ns; // UTS (主机名)
struct ipc_namespace *ipc_ns; // IPC (进程间通信)
struct mnt_namespace *mnt_ns; // Mount (挂载点)
struct pid_namespace *pid_ns; // PID (进程)
struct net *net_ns; // Network (网络)
struct user_namespace *user_ns; // User (用户)
}
当我们启动一个普通的Linux进程时,它的nsproxy会指向系统默认的Root Namespace。
那么,Docker时如何创建新的Namespace的呢?
答案是clone()系统调用。
clone()类似fork(),但是不同的是,fork()创建的子进程默认继承父进程所有的Namespace。而clone()创建子进程时,允许传入标志位,告诉kernel创建新的Namespace。
例如:
CLONE_NEWPID:创建新的 PID Namespace。CLONE_NEWNET:创建新的 Network Namespace。CLONE_NEWNS:创建新的 Mount Namespace。
当 docker run 启动一个容器时,Docker的runc就会调用 clone() 并同时指定上述所有(或大部分)标志位。
内核收到这个请求后,会:
- 创建新的
task_struct(新进程)。 - 为这个新进程分配全新的
nsproxy结构体。 - 根据
CLONE_NEW...标志位,创建全新的pid_namespace,net_namespace等结构体,并让nsproxy指向它们。
[ 什么是
runc?]
在了解runc之前,我们首先要知道什么是程序的“运行时”(runtime)。runtime描述了程序运行时候需要的运行环境,即所需要的软件/指令。在不同的语言有着不同的实现。例如,在C中,runtime是C runtime library,一系列C程序运行所需的函数;在Java中,runtime还提供了Java程序运行所需的虚拟机等。
更详细的解释可以参考知乎文章[https://www.zhihu.com/question/20607178]。“运行时”这个词,就像 Java 的“JVM”或 Python 的“解释器”一样,是真正负责去“运行”代码的那个组件。同理,
runc就是那个真正负责去“创建和运行”容器的底层工具。那么什么是
runc呢?
runc的核心工作只有一个:接受一个容器配置包,并用它来运行一个容器。这个“配置包”通常包含配置文件config.json和rootfs。
runc启动时,会读取config.json,其中包括但不限于各种Namespace、Cgroups以及要运行的命令等。然后runc会按照其内容去调用 Linux 的clone(),pivot_root(),setns()等系统调用,设置 Cgroups,最后exec()config里指定的命令。
重要的几个命名空间
下面我们来着重分析Docker主要使用的一些Namespace, 以及他们工作的具体方式。
① PID Namespace
在Linux的传统设计中,系统里只有一个全局的进程树。所有进程都在这个树中,只要权限足够(比如root),任何一个进程都可以跟踪、检查甚至直接kill掉系统中的任何其他进程。
Linux Namespace机制的出现,让嵌套的进程树成为了可能。每个Namesapce都可以由自己相对隔离的一套进程。这种隔离确保了子Namespace里的进程无法感知到外部(比如父Namespace或者同级的Namespace)的进程。
Linux启动时,永远只会启动一个进程,它的PID永远是1(即init进程或systemd进程,在wsl中为init(ubuntu)进程)。他是进程树的根节点,系统上的其他所有进程,无论是系统服务(如 sshd、cron)还是用户启动的程序(如bash shell),都是由PID 1直接或间接(通过子进程)启动的。
我们可以通过pstree清晰地看到进程派生的关系。
wsl ~$ ps -a
PID TTY TIME CMD
1 hvc0 00:00:00 init(Ubuntu)
6 hvc0 00:00:00 init
160 pts/0 00:00:00 ps
wsl ~$ pstree
init(Ubuntu)─┬─SessionLeader───Relay(11)───bash───pstree
├─init───{init}
└─{init(Ubuntu)}
PID Namespace则允许我们“拉”一个全新的进程树分支。这个新分支也会有它自己的 PID 1 进程。创建这个新 Namespace 的那个进程,它本身在“外面”(父Namespace)还是一个普通的进程(比如 PID 是 5000),但它创建的这个子进程,在“里面”(子Namespace)看来,自己就是 PID 1,是这个新分支的根节点。
但是这种隔离是不对称的:子Namespace里看不到父Namespace里的进程,但是父Namespace里的进程可以看到子Namespace里的所有进程。所以一个处于子Namespace的进程,实际上拥有至少两个PID:
- 内部 PID : 进程在自己所属的 PID Namespace中看到的 PID。
- 全局 PID : 同一个进程,在主机(根Namespace) 中看到的 PID。
比如,我们在容器内部查看和在外部查看:
root@VM-8-6-ubuntu:~# docker exec ed00e00b0*** ps aux | grep "bash entrypoint.sh"
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4356 680 ? Ss Oct27 0:00 bash entrypoint.sh
root@VM-8-6-ubuntu:~# ps aux | grep "bash entrypoint.sh"
root 617739 0.0 0.0 4356 680 ? Ss Oct27 0:00 bash entrypoint.sh
可以看到,在容器内部和外部,该进程拥有不同的PID。
② Network Namespace
Network Namespace的核心作用是隔离出一套全新的、独立于主机的网络协议栈。
也就是说每个Network Namespace都拥有自己独立的:
- 网络接口 (Network Interfaces)
- 路由表 (Routing Table)
- 防火墙规则 (iptables / nftables)
- 端口号池 (Port Numbers)
甚至每个Network Namespace都有它自己的loopback,这意味着容器里的127.0.0.1和主机中的127.0.0.1根本不是一回事。
但是,我们新建一个Network Namespace之后,其唯一拥有的网络设备就是自己的loopback。此时我们必须对其建立连接,否则它将无法与主机、其他Network Namespace或外部网络进行任何通信。
那么我们如何手动建立连接呢?
整个过程分为两步:配置veth pair和配置网络协议。
veth pair(虚拟以太网对)总是在Linux kernel中成对创建。它是一个纯粹的数据链路层设备,就像一根真实的网线。从一端(比如 veth-A)塞进去的数据包,会原封不动地从另一端(veth-B)冒出来。
# 1. 创建一对 veth,名叫 veth-host 和 veth-container
ip link add veth-host type veth peer name veth-container
# 2. 激活 veth-host 这一端
ip link set veth-host up
# 3. 把 veth-container 这一端“扔”进子 Namespace(假设其 PID 为 <pid>)
ip link set veth-container netns <pid>
现在,主机(父 Namespace)上有了一个叫 veth-host 的网卡。子 Namespace(容器)里有了一个叫 veth-container 的网卡。物理连接(数据链路层) 建立了。
下一步就是配置IP地址了。
# 1. 在主机上,给 veth-host 分配 IP
ip addr add 192.168.1.1/24 dev veth-host
# 2. 在子 Namespace 内部执行(!!)
# (需要通过 setns() 系统调用或 ip netns exec <ns_name> ... 命令进入)
# 2a. 激活子 Namespace 里的 lo (这个经常被忘记)
ip link set lo up
# 2b. 激活 veth-container
ip link set veth-container up
# 2c. 给 veth-container 分配 IP
ip addr add 192.168.1.2/24 dev veth-container
# 2d. 设置路由,告诉它“网关”是 192.168.1.1
ip route add default via 192.168.1.1 dev veth-container
此时,容器 (192.168.1.2) 已经可以 ping 通主机上的 veth-host 接口 (192.168.1.1) 了。
但是这样配置十分糟糕,如果我们有100个容器,那就要创建100个 veth对,设置100个子网,这非常的不优雅。并且,容器A 192.168.1.2无法与容器B 192.168.2.2通信,除非你在主机上开启路由转发并为它们设置复杂的路由表。
那么Docker帮我们做了什么?
Docker采用了一种最经典的组网方案——bridge模式。
1.创建docker0
在安装Docker的时候,Docker就会在主机上提前创建号虚拟的交换机docker0。
root@VM-8-6-ubuntu:~# ip addr show docker0
5: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
link/ether be:4b:04:64:91:91 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
valid_lft forever preferred_lft forever
然后,Docker给docker0这个交换机本身,分配一个IP地址,通常是172.17.0.1(从上面的输出也能看到)。
2.创建并连接veth
这样,我们创建了一个容器后,Docker首先会创建一个新的Network Namespace,然后创建一对veth,一头连接在容器上,重命名为eth0;另一头连接到docker0上。这样容器就可以通过下面的流程连接到docker0了。
[ 容器 ] <--> [ eth0 (A头) ] <== (虚拟网线) == > [ veth-xxx (B头) ] <--> [ docker0 交换机 ]
3.分配IP并设置路由
接下来,Docker会进入容器内部,自动分配IP并设置路由。
首先Docker从172.17.0.x网段中找到一个空闲IP,分配给容器的eth0。接着,Docker在容器内部设置一条路由规则,规定所有出站流量,全部转发给172.17.0.1(即docker0)。
现在容器已经能够ping通docker0了,两个不同的容器(比如172.17.0.2和172.17.0.3)也能相互ping通了,因为他们都连接在了docker0上。但是容器现在还不能上网。因为我们的NAT还未配置。
4.伪装IP地址
Docker的最后一步,就是在主机上,通过iptables配置一条伪装规则。简单来说,就是告诉Linux kernel,凡是从docker0来的数据包,在发出去之前,都必须把它的发出IP(比如172.17.0.2)改成主机的公网IP。
最后,Docker也让容器能够接收从外界入站的包。
-p 8080:80表示将主机8080端口的所有流量,路由到本容器的80端口。
③ Mount Namespace
简单来说,Mount Namespace隔离了一组进程所看到的文件系统挂载点的集合。因此,在不同Mount Namespace的进程看到的文件系统层次结构也不同。
所以,Mount Namespace的核心目标是隔离文件系统视图。他并不是虚拟化出一个新的硬盘,而是让一个进程拥有一个完全独立的挂载表。
首先我们必须明确一个概念:Linux kernel 通过一个全局的“挂载表” 来维护系统中所有的挂载点。这张表就像一个数据结构,记录了:
- 什么设备:比如物理分区
/dev/sda1或内存文件系统tmpfs。 - 挂载到哪里:比如
/或/dev/shm。 - 文件系统类型:比如
ext4或proc。 - 挂载选项:比如
ro(只读) 或rw(读写)。
在没有Mount Namespace隔离的情况下,系统中的所有进程共享这一张全局的挂载表。这意味着,任何有权限的进程(如 root)执行的 mount 或 umount 操作,都会立即对系统中的所有其他进程可见,这无疑是灾难性的。
Mount 命名空间的作用,就是通过 clone() 系统调用创建新进程时,为这个新进程“复制”一份父进程的挂载表。从此,这个新进程(及其后代)对这张“复制表”所做的任何修改,都只在它自己的Mount 命名空间内部生效,不会“泄露” 到父命名空间(主机)或其他同级命名空间中。
要创建一个新的 Mount Namespace, clone() 系统调用需要传入 CLONE_NEWNS 标志(NS 是 Namespace 的历史缩写,特指 Mount Namespace)。
// CLONE_NEWNS 标志告诉内核:
// “请为这个新进程创建一份私有的 Mount Namespace”
clone(child_fn, ... , CLONE_NEWNS | ... , NULL);
值得注意的是,Mount Namespace具有一个最关键、也最容易被误解的特性:继承性。
当 clone() 完成时,子进程(容器)的新挂载表并不是空的,而是对父进程(主机)挂载表的一份完整浅拷贝。 这意味着,在子进程启动的第一瞬间 ,它所看到的文件系统视图(/, /home, /proc, /sys…)与主机是一模一样的。
这样做的利弊很明显:
好处就是,现在子进程现在可以随意 umount /home 或 mount 新设备到 /mnt,这些操作都只修改它自己的“私有表”,不会影响主机。但是这样也有很大的安全隐患,子进程继承了主机所有“脏”的、危险的挂载点。比如,它继承了主机的 /proc(能看到所有进程)、主机的 /sys(能调整内核参数)、主机的 /(能读写主机文件)。
如果 Docker 只是简单地 clone(CLONE_NEWNS, "xxxx"),那所有将直接暴露在主机的完整文件系统视图下,这完全违背了“隔离”的初衷。
为了解决上面的问题,所有容器的运行时(runc)都采用了一个精妙的、分阶段的“初始化”流程。
这个流程发生在 clone() 之后、exec() 启动用户应用之前。
阶段 1:创建 init 进程并隔离挂载
- Docker会请求
runc来启动容器。 runc调用clone(CLONE_NEWNS, ...)创建一个临时的“init 进程”。- 这个
init进程现在处于新的mnt命名空间中,但持有主机的“脏”挂载表副本。
阶段 2:init 进程重塑文件系统
这个 init 进程的核心任务就是“打扫”这份“脏”挂载表,将其彻底重塑为容器应有的、干净的视图。
- (可选)设为私有挂载
为了确保后续的挂载/卸载事件不会“传播”回主机(这是mnt命名空间的另一个高级特性:挂载传播)。init进程首先可能会执行:mount(NULL, "/", NULL, MS_PRIVATE | MS_REC, NULL) - 准备容器根目录
runc在主机上,使用UnionFS(如OverlayFS)将所有只读的镜像层和一个可写层叠加在一起,形成一个统一的目录(如/var/lib/docker/overlay2/.../merged)。 - 切换根(
pivot_root)
这是最核心的一步。init进程会执行pivot_root()系统调用:`pivot_root(new_root, put_old)`- 作用:它告诉内核:“在当前 Mount Namespace内,请将根目录(
/)切换到new_root(即.../merged目录),并将旧的根目录(主机的/)移动到put_old(比如.../merged/.old_root)。” - 结果:执行完毕后,
init进程的/已经指向了容器的镜像根目录。而主机的整个文件系统,现在被“藏”在了容器内的/.old_root目录下。
- 作用:它告诉内核:“在当前 Mount Namespace内,请将根目录(
- 彻底“断舍离”(
umount旧根):init进程现在可以安全地执行umount("/.old_root")。- 这一步至关重要:它将主机的文件系统(旧根)彻底从容器的“挂载表”中分离出去。
- 至此,容器无法再通过任何路径(
chdir(".."))访问到主机的文件系统。
[UnionFS、镜像与容器]
什么是UnionFS?
Union File System(联合文件系统)是Docker镜像和容器文件系统的核心技术,它可以不同物理位置的目录合并、挂载到同一个目录中,而实际上目录的物理位置是分开的。UnionFs把文件系统的每一次修改作为一个个层进行叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。如果一次同时加载多个文件系统,UnionFs 会把各层文件叠加起来,最终文件系统会包含所有底层文件和目录,从外部视角看,就是一个完成的文件系统。
![[Pasted image 20251030202956.png]]
例如,我们现在在Dockerfile写了一些指令:
FROM ubuntu:latest
这就像一张最底层的、只读的透明胶片。上面画着一个基础的 Ubuntu 系统(/bin,/etc,/lib…)。RUN apt-get update && apt-get install nginx
这条指令会在上一张胶片之上,再叠加一张新的、只读的透明胶片。新的胶片上只画着apt命令新添加或修改的文件(比如 Nginx 的二进制文件/usr/sbin/nginx和配置文件/etc/nginx/nginx.conf)。COPY ./my-app /app
这又会再叠加一张新的、只读的透明胶片。上面只画着你的/app目录和里面的文件。值得注意的是,当我们视图删除只读层里的文件时,UnionFS并不会真的去删掉它。而是在可写层创造已发个白标文件(whiteout),告诉UnionFS忽略该文件。
了解完UnionFS,现在我们就能理解什么是镜像和容器了。
镜像就是这一叠(stack)只读的透明胶片。当你docker pull时,你就是在下载这一叠胶片。
而容器,就是当你运行docker run的时候,Docker会在这一叠制度胶片的最顶上,再放一张空白的、可写的胶片。
阶段 3:重新挂载内核虚拟文件系统
“房间”打扫干净了,但容器也丢失了必要的内核接口(如 /proc)。init 进程必须重新创建它们:
-
挂载
/proc:mount("proc", "/proc", "proc", 0, NULL)- 关键:由于这个
mount命令是在新的pid命名空间和新的net命名空间中执行的,因此内核会自动创建一个只反映当前pid/net命名空间状态的/proc视图。 - 容器在
/proc里只会看到自己的进程,且自己的主进程 PID 为 1。
-
挂载
/sys、/dev等:init进程会以类似的方式,重新挂载一个(通常是只读的)/sys(sysfs)。- 它会挂载一个
tmpfs到/dev,并(或由runc)在其中创建必要的设备节点(如/dev/null,/dev/console等)。
阶段 4:exec() 启动用户进程
当 init 进程将“挂载表”彻底重塑为一个干净、隔离、功能完备的状态后,它最后一步会调用 exec()(例如 execvp("nginx", ...))。
这样Nginx进程就会被启动,它继承了 init 进程这份完美的、被“魔改”过的Mount Namespace。在 Nginx 看来:
- 它的
/就是它的镜像。 - 它的
/proc里只有它自己。 - 它完全感知不到主机文件系统的存在。
④ UTS Namespace
UTS Namespace解决的问题非常简单,即“我是谁”的问题。它主要用来隔离hostname和domainame,使得容器能够拥有独立主机名。
如果你在同一台主机上运行 100 个 Nginx 容器。如果没有UTS隔离,这 100 个容器在内部执行 hostname 命令,看到的都会是宿主机的 hostname(比如 HEU-Linux-Server)。这在服务发现和日志记录中会造成巨大的混乱。
例如,我们可以看到主机和容器的hostname是不同的:
root@VM-8-6-ubuntu:~# docker exec ed00e00b04d8 hostname
ed00e00b04d8
root@VM-8-6-ubuntu:~# hostname
VM-8-6-ubuntu
⑤ IPC Namespace
IPC全称为Inter-Process Communication,即进程间通信机制。
如果没有隔离,可能会引起严重的安全问题。比如,假设我们有两个容器,容器A运行着数据库,容器B运行着一个被入侵的Web应用。
如果没有IPC隔离:容器A创建了一个共享内存段,ID 为 999。入侵容器B的黑客在主机上执行 ipcs -m(列出所有共享内存)。它能赫然看到那个 ID 为 999 的内存段。容器 B 随后就可以附加(attach)到这个共享内存段,直接读取、篡改、甚至破坏数据库正在内存中处理的所有数据。这是灾难性的安全漏洞。
[IPC]
什么是进程间通信?
进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。IPC的方式通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享内存、Socket(套接字)等。其中 Socket和支持不同主机上的两个进程IPC。
但是值得注意的是,Socket实在Network Namespace中被隔离的。
对于System V家族,IPC Namespace提供了私有的ID表。对于POSIX家族,IPC Namespace保证Mount Namespace在挂载/dev/shm /dev/mqueue时,得到的时私有的文件系统实例。
⑥ User Namespace
顾名思义,这个Namespace就是用来隔离用户ID(UID)和用户组ID(GID)的。
试想一下,如果没有User Namespace,那么容器内的root就是容器外部的root。虽然上述五个Namespace能够限制容器的使用,但是如果黑客通过某个应用漏洞(比如Nginx上的漏洞,或者其他应用/系统级的漏洞)逃逸出容器,或者用户错误地docker run -v /:/host-root挂在了主机的目录,那么容器内就可以直接窃取主机的数据。
User Namespace允许你创建一个UID/GID映射表,启动容器时,首先runc调用clone(CLONE_NEWUSER)创建一个新的User Namespace,然后runc向内核的uid_map和gid_map写入一个映射规则,比如0 100000 65536。这句映射告诉内核,在这个新的User Namespace中,内部的UID 0(容器内的root)映射为主机的UID 100000(无权限的普通用户),同时这个映射规则对65536个ID都有效(即内部 UID 0-65535 映射到外部 UID 100000-165535)。
2. Cgroups
Cgroups(Control Groups)是Linux kernel提供的一种可以限制单个进程或者多个进程所使用资源的机制,可以对CPU、内存等资源进行精细化的控制。
Cgroups允许我们把任意多个进程放进一个组里,然后对这个组进行资源的配置。比如说,我们可以把Nginx的逐渐成和他的worker进程全部放进一个组里,然后要求这个组最多只能使用2个CPU核心、512MB内存、硬盘I/O速度不能高于50MB/s等等。
Cgroups是如何工作的?
在现代 Linux 系统上,Cgroups v1 和 v2 都在使用。v1 将每个控制器(如 cpu, memory)挂载到不同的目录,而 v2 倾向于使用一个统一的层级(unified)。我们可以在/sys/fs/cgroup目录下找到Cgroups的配置文件。
下面便是Cgroups v1的相应目录:
root@VM-8-6-ubuntu:/sys/fs/cgroup# ls
blkio cpu cpuacct cpu,cpuacct cpuset devices freezer hugetlb memory net_cls net_cls,net_prio net_prio perf_event pids rdma systemd unified
Cgroups的创建很简单,只需要在相应的子系统下创建目录即可。只要把进程的PID写入相应目录中的cgroup.procs文件中,就可以将该进程放进相应的Cgroups中。
Docker干了什么?
例如,当我们执行下面的命令时:
docker run -d --name my-nginx --memory 512m --cpus 1.5 nginx
runc会自动捕获这些参数,并写入对应的cgroup控制文件中。
3.Rootfs
rootfs (Root File System) 顾名思义,它就是根目录 / 以及其下的所有文件和目录(如 /bin, /etc, /proc)。我们的主机Linux系统有一个 rootfs。而一个 Ubuntu 镜像也“看起来”有一个 rootfs。
但这体现了容器和虚拟机最核心的区别:
- 虚拟机 = 一个完整的操作系统 = rootfs + kernel
- 容器 = 仅有 rootfs(没有单独内核,而是共享主机的内核)
rootfs 不是一个单一的文件,它是一个“动态组合”的概念。它的生命周期将我们之前提到的 UnionFS、Mount Namespace 和 runc 完美地串联了起来。
阶段1:准备Rootfs
我们在介绍UnionFS的时候提到的“镜像”,就是rootfs的只读组件。Dockerfile 中的每一条 FROM, RUN, COPY 指令都会创建一个只读层。例如:
FROM ubuntu-> 第一层(基础rootfs)RUN apt install nginx-> 第二层(包含nginx文件的rootfs变更)
当我们运行容器时,Docker会在这一叠只读层的最顶上添加一个空白的可写层,UnionFS会将这些所有的层叠加起来,在主机上形成一个统一的目录视图(例如/var/lib/docker/overlay2/.../merged)。
而这个merged目录,就是已经准备好、即将交付给容器的 rootfs。
阶段2:激活Rootfs
现在所需的rootfs已经准备好了,runc创建的init进程创建的Mount Namespace会把根目录切换到.../merge目录,把主机的/挂载到.../merge/.old_root里,然后再umount掉主机的/目录。这样,此时,init 进程的 / 已经不再是宿主机的 /,而是那个由 UnionFS 专门为它准备的 rootfs。
阶段3:exec()用户指令
init 进程完成了切换 rootfs、重新挂载私有 /proc 和 /dev 等所有“打扫”工作后,它会调用 exec()(如 exec("nginx"))“变身”为真正的用户应用。
例如,我们启动Nginx进程,它的视角是:
- 它
ls /,看到的是UnionFS叠加后的文件系统。 - 它读取
/etc/nginx/nginx.conf,UnionFS会从底下的“只读层”中找到文件并返回。 - 它写入
/var/log/nginx/access.log,UnionFS会将这个新文件被创建在最顶部的可写层中。
综上,我们介绍了Docker的三个核心原理。