理解 Docker,要先理解“程序通过内核使用资源”。普通 Linux 容器让一组进程拥有相对独立的文件目录、进程编号和网络视图,并配合资源限额与权限约束,但仍共享宿主机内核。镜像是应用文件与配置的交付物;构建器是执行制作过程的工具,两者都不等于一台独立计算机。多个系统可以共用构建主机,是否需要独立虚拟机或物理机,取决于信任关系、权限、资源和故障影响范围。
一、把问题串成一条主线
先把待学习的问题记下来,后文按概念依赖关系展开:
- [ ] 理解 Linux namespace:为什么同一台机器上的进程可以“看见不同的世界”?
- [ ] 理解 Docker 的隔离边界,以及
--privileged、--pid=host、--network=host分别改变什么。 - [ ] 分清镜像、运行容器、构建任务、构建器和构建服务器。
- [ ] 判断多个业务系统共用构建环境时,哪些东西会冲突、怎样分开、何时需要独立机器。
- [ ] 理解普通容器与虚拟机的内核边界为何不同。
- [ ] 分清宿主机用户、Docker 服务用户、容器用户和镜像仓库账号。
贯穿全文的一条思考方法是:
每遇到“独立环境”四个字,都继续追问:文件独立了吗?进程独立了吗?网络独立了吗?资源有限额吗?管理权限分开了吗?内核独立了吗?
“独立”必须落到具体对象上,才有意义。
本文的讨论范围 主要讨论 Linux 服务器上的普通 Linux 容器。讲默认行为时,采用常见的 rootful Docker Engine、未启用用户 ID 重映射、普通网络模式这一前提。Docker Desktop、Rootless Docker、自定义安全策略等会改变部分细节,涉及处会单独说明。资料核对日期为 2026-10-22。
二、先认识计算机:硬件、内核、程序和进程
1. 服务器首先是一台提供资源的计算机
服务器有 CPU、内存、硬盘和网卡。它可能是物理机器,也可能是别人分给你的一台虚拟机。
CPU 负责运算,内存保存正在使用的数据,硬盘长期保存文件,网卡负责收发网络数据。多个程序一起工作时,必须有人协调:谁先用 CPU?哪段内存属于谁?谁可以删除文件?一个网络连接应该交给哪个程序?
这位协调者就是操作系统内核。
把服务器想成一栋办公楼:硬件是房屋、电力、仓库和道路;内核是掌握调度和门禁规则的管理中心;程序是准备入驻的工作方案;进程是已经开始工作的团队。
2. 程序是存放着的代码,进程是正在执行的活动
磁盘上的 python、java 或某个可执行文件,是程序。启动它以后,系统为这次执行安排内存、身份、打开的文件和网络连接,就有了进程。
同一个程序可以同时启动很多次,产生不同进程。进程之间还可以建立父子关系,例如一个构建脚本启动编译器,编译器再启动多个工作进程。
容器里的程序最终也以进程的形式运行。 容器没有把进程变成另一种神秘对象;它为这些进程安排了特殊的视图和限制。
3. 应用不能随心所欲地直接操作硬件
应用读取文件、创建进程或打开网络连接时,需要通过系统调用,请内核处理。
例如,一个 Java 服务的关系大致是:
业务代码
↓ 由 Java 运行时执行
JVM、系统库等用户空间软件
↓ 通过系统调用申请服务
Linux 内核
↓ 管理
CPU、内存、磁盘、网卡
这里的“用户空间”,指内核之外的应用运行区域,与“登录了哪个用户”不是同一个概念。JVM、Python、Shell 和数据库通常都属于用户空间。
这为后面的 Docker 打下关键基础:可以给应用换一整套用户空间文件,同时继续使用原来的内核。
三、文件系统和挂载:同一个路径,为什么未必是同一份文件
*图中两个容器的配置文件各自独立,而 /data 与 /reports 通过挂载通向同一个宿主目录。*
1. 目录树是文件的地址体系
Linux 通常从 / 开始组织目录:
/
├── etc/ 系统和软件配置
├── usr/ 大量用户空间程序和库
├── home/ 普通用户的个人目录
├── root/ root 用户的个人目录
├── var/ 日志、缓存、服务数据等可变内容
├── tmp/ 临时文件
├── proc/ 内核提供的进程、系统信息接口
└── dev/ 设备接口
/ 是根目录;/root 是一个用户的家目录。它们不能混为一谈。/proc 中的很多内容也不是硬盘上普通的文本文件,而是内核动态呈现的信息。
/app/config.yml 是绝对路径;config.yml 是相对路径,需要结合当前工作目录才能找到目标。更进一步,绝对路径的起点也是该进程所使用的根目录,并非不论在哪都指向宿主机的同一位置。Linux 路径解析手册
2. 目录是入口,文件系统是组织与存取文件的机制
可以把目录理解为地址和入口,把文件系统理解为整理文件、记录属性、找到内容的一整套机制。常见磁盘文件系统包括 ext4、XFS;也有在内存中存放内容的 tmpfs,以及把远程存储接入本机的网络文件系统。
所以,不能看到一个目录,就认定它一定对应本机硬盘上的一个普通文件夹。
3. 挂载:把一份存储内容接到目录树的某个入口
假设把一个磁盘分区挂载到 /data,以后进入 /data,访问的就是那个分区提供的内容。
一个形象的比喻是:挂载是在某个门牌后面接上一个仓库。 仓库可以在本地,也可以在远处;门牌只是访问入口。
Linux 还有 bind mount,可以把一个已经存在的目录或文件,接到另一个入口上。它不会自动复制内容。挂载到一个原本有内容的目录上时,原内容通常只是被遮住,并没有因挂载而删除。Linux mount 手册
4. Docker 的目录映射,就是非常常见的挂载应用
例如下面这个运行参数:
--mount type=bind,src=/srv/order-data,dst=/data
它表达的是:
宿主机上的入口 容器里的入口
/srv/order-data/report.csv ←→ /data/report.csv
同一份底层文件
容器里改写 /data/report.csv,可以直接影响宿主机看到的 /srv/order-data/report.csv,前提是挂载可写且权限允许。给挂载加 readonly 可以限制通过该挂载写入,但读取权限仍然存在。Docker bind mount 文档
这也解释了两种相反情况:
- 两个容器都有
/app/config.yml,可能各自访问各自的文件,互不影响。 - 两个容器路径完全不同,却可能通过挂载访问同一份数据,彼此影响。
路径看起来相同,不足以证明共享;路径看起来不同,也不足以证明隔离。
5. Volume 与 bind mount:谁负责管理这份数据
bind mount 通常由你明确指定宿主机路径;Docker volume 则由 Docker 管理存储位置,并可以用一个卷名挂到容器里。
| 存储位置 | 可以怎样理解 | 删除容器后通常怎样 |
|---|---|---|
| 容器自己的可写层 | 容器随身携带的工作纸 | 随容器删除而丢失;仅停止、重启容器不会自动丢失 |
| Bind mount | 直接借用宿主机指定的文件柜 | 宿主机上的内容仍在 |
| Docker volume | Docker 管理的持久文件柜 | 通常独立保留,除非另外删除卷 |
Volume 不会天然变成一块独立硬盘,也不是自动备份机制。同一个卷被多个容器挂载,就会形成共享;多个数据库实例指向同一数据目录,还可能把数据写坏。Docker volume 与生命周期
四、Linux 用户与权限:身份是数字,名字是便于人理解的标签
1. 用户不只代表“登录的人”
Linux 会给进程安排用户和组身份,后台服务也有身份。UID 是用户编号,GID 是组编号;权限判断会用到有效身份、附加组等信息。
因此,“这个进程由谁启动”和“这个进程当前用什么身份工作”需要区分。通过 sudo、服务配置等方式,启动者可以让程序以另一种身份运行。Linux 进程身份手册
2. 文件有主人,也有访问规则
例如:
-rw-r----- alice developers config.yml
简化理解:alice 可以读写,developers 组可以读,其他人无权访问。常说的 r/w/x,对应读、写、执行;对目录而言,x 主要表示能否搜索、穿过这个目录。访问深层文件也需要沿途目录的相应权限。
文件属性里记录的是 UID/GID 等数字。用户名通常由系统把数字翻译而来,所以两个环境里都叫 app 的用户,未必是同一个 UID;两个名字不同的用户,也可能映射到相同的数字身份。Linux 文件属性与权限
这就是容器挂载经常出现“目录看得见但写不了”的原因之一:容器进程使用 UID 10001,宿主目录却只允许 UID 1000 写入。目录隔离和文件访问权限是两回事。
3. root 很强,但现代 Linux 已把许多特权拆开
root 通常指 UID 为 0 的用户。为了细化授权,Linux 把许多传统 root 特权拆成 capabilities:
CAP_NET_ADMIN:管理网络配置等。CAP_SYS_PTRACE:涉及检查、调试其他进程的权限。CAP_SYS_ADMIN:涉及挂载等广泛管理操作,权限范围很大。
可以把 root 想成管理身份,把 capabilities 想成具体钥匙。安全策略还能继续约束操作,不能只凭终端显示 root 就认定拥有无限权限。Linux capabilities 手册
五、Namespace:内核为什么能给不同进程安排不同的世界
*PID 卡片展示同一个进程在里外两种视图中的编号;User 卡片展示可配置的 UID 映射示例,不表示 Docker 默认启用这种映射。*
1. 它为某类系统资源建立独立的视图和作用范围
没有隔离时,进程常常处在相同的进程编号空间、网络空间和挂载视图中。Linux namespace 允许内核把这些资源分成不同的范围。
例如:同样调用“列出进程”,A 组看到自己的进程,B 组看到自己的进程;同样访问 /,不同组可以进入不同的目录结构。
这并非在界面上偷偷隐藏几行信息,而是内核按进程所属的 namespace 处理资源名称、可见性和相关操作。每种 namespace 负责特定领域;一个进程可以同时属于多种不同类型的 namespace。Linux namespaces 总览
| 类型 | 主要分开什么 | 形象理解 |
|---|---|---|
| PID namespace | 进程编号与可见范围 | 每个部门都有自己的工号表 |
| Mount namespace | 挂载点的视图 | 每个部门拿到不同的仓库入口图 |
| Network namespace | 网络接口、协议栈、路由、端口等 | 每个部门有自己的电话交换系统 |
| UTS namespace | 主机名等 | 每个部门可以挂自己的招牌 |
| IPC namespace | 某些进程间通信资源 | 部门内部有自己的公共留言设施 |
| User namespace | 用户、组 ID 和特权的映射范围 | 部门内部的管理员身份可映射为外部普通身份 |
| Cgroup namespace | 看到的 cgroup 层级视图 | 部门看到以自己为起点的资源管理目录 |
| Time namespace | 部分时钟的视图 | 可以有不同的运行时间起点,不等于任意修改系统时间 |
这是 Linux 的能力清单,不代表 Docker 会为每个容器默认启用所有类型。尤其不能从“Linux 有 user namespace”推导出“所有 Docker 容器默认都做了 UID 重映射”。
2. PID:同一个进程,在里外可以有不同编号
宿主机看到:进程 24860 是某个容器的主进程
容器内看到:同一个进程的编号是 1
这是同一个正在执行的进程,只是在不同编号体系中有不同号码。宿主机的祖先 PID namespace 可以看到下级 namespace 中的进程;容器通常看不到宿主机的完整进程列表,隔离并不是对称的。
容器中的 PID 1 还承担特殊的信号处理、孤儿进程回收等职责。主进程退出,容器通常就停止;把容器想成“开着机但没人用的完整电脑”,容易误解它的生命周期。Linux PID namespace 手册
3. Mount:不同地图,不代表地图上的东西都是副本
Mount namespace 分开的是挂载视图。要形成容器的文件世界,还需要安排根目录、镜像层和挂载内容。
只创建一个 mount namespace,不会自动复制所有文件,也不会自动获得安全的独立根文件系统。两个 namespace 仍然可以挂载到同一份底层文件。挂载事件还存在可配置的传播规则;不要把“挂载点变化是否传播”和“文件内容是否共享”混为一谈。Linux mount namespace 手册
4. Network:端口是某个网络空间里的号码
不同 network namespace 可以各自有 IP、路由、端口和回环接口。A、B 都监听自己的 TCP 8080,可以不冲突。
localhost 或 127.0.0.1 指向当前网络空间的本地端点。因此普通网络模式下,容器里的 localhost 通常指容器自己,不会自动指向宿主机或另一个容器。
有独立网络空间也不等于相互断网。通过虚拟网卡、网桥和路由,容器仍能按规则通信,像不同电脑接到同一网络里。Linux network namespace 手册
5. User:里面的管理员,可以是外面的普通用户
User namespace 可以建立这样的映射:
容器内 UID 0 → 宿主机 UID 231072
容器内 root 宿主机上的非特权身份
这里的数字只是示例,实际以映射配置为准。容器里的管理权限有作用范围,并不能自动跨越到外层 namespace。
如果没有这种映射,普通 rootful 容器里的 UID 0 在宿主机侧仍是 UID 0,只是受容器的可见范围、capabilities、安全策略等约束。所以“容器内 root 完全等于宿主机无限权限”和“容器内 root 与宿主机 root 完全无关”,都过于粗糙。 Linux user namespace 手册
六、Docker 把这些机制组装成了什么
1. 镜像:应用的交付包与启动说明
镜像通常包含应用、运行时、系统库、配置默认值和启动元数据。它本身不执行程序。
例如,一个 Java 镜像可以带着 Java 17 和应用文件,另一个带着 Java 21。它们使用不同的用户空间文件,就不必都去争抢宿主机上的 /usr/bin/java。
镜像常由多层不可变的文件系统变更组成,运行容器时再为实例提供自己的可写层。多个容器可以共享底下相同的只读镜像层,同时保留各自的修改。Docker 镜像分层说明
同一镜像:基础文件 + Python + 应用代码(只读,可复用)
↙ ↘
容器 A 的可写层 容器 B 的可写层
A 创建的临时文件 B 创建的临时文件
如果 A 修改自己的文件,通常不会改掉 B 的文件,也不会改掉原镜像;但如果两者写的是同一个挂载卷,就重新形成了共享。
2. 容器:配置好的运行实例,里面可以运行一组进程
容器可以创建、启动、停止和删除。严格说,停止的容器仍然是 Docker 管理的对象;说“容器是一组进程”是在解释它的运行本质。
Docker 启动容器时,会安排根文件系统、namespace、资源管理和安全设置,再启动应用。日常使用常以一个主服务为中心,但并非只能有一个进程,也不要求每个容器启动一整套操作系统服务。
3. Docker 命令与 Docker 服务,是分开的
flowchart LR
U[终端里的 docker 命令<br/>客户端] --> D[Docker daemon<br/>管理服务 dockerd]
D --> I[镜像与存储]
D --> R[容器运行时]
R --> P[容器内的应用进程]
P --> K[Linux 内核]
客户端负责表达请求,服务端负责执行管理操作。服务端可能在本机,也可能在远程服务器;只安装一个 docker 客户端,并不等于本机已经拥有构建与运行服务。Docker 架构
因此,在笔记本终端输入命令,实际占用的 CPU、磁盘,可能来自远程构建机器。
4. 容器隔离需要几类机制一起工作
| 机制 | 回答的问题 | 它不能单独保证什么 |
|---|---|---|
| Namespace | 我能看到、使用哪一组资源? | 不负责自动给 CPU、内存设置合理限额 |
| Cgroup | 我的资源使用如何计量和限制? | 不负责把文件变成彼此独立的副本 |
| UID/GID、capabilities | 我是谁,拥有哪些权限? | 不会自动给进程提供完整的私有网络和目录树 |
| Seccomp | 哪些系统调用允许使用? | 不等于为容器安装另一套内核 |
| AppArmor、SELinux 等 | 哪些访问和行为符合系统策略? | 是否启用、采用什么策略,要看实际系统配置 |
Docker 利用这些机制构建安全边界,隔离强度也随配置变化。它能很好地分开许多应用依赖和运行视图,但不能把所有同机风险一并消除。Docker 安全架构
Seccomp 可以理解为给应用一张“允许向内核提出哪些请求”的清单;它限制系统调用接口,减少暴露给应用的内核功能。Docker seccomp 文档
资源限额则需要具体配置。Docker 支持限额,不代表每个容器默认都有足够严格的限额。 没有合适约束的编译任务仍可能吃光内存、挤占 CPU;磁盘容量、日志和缓存还需要额外治理。Docker 资源约束文档
七、三个特殊参数:分别打开了哪一扇门
*按维度理解这些参数:共享进程空间、共享网络空间,以及大幅放宽权限,是不同的变化。*
先记住:隔离是多个维度的组合。一个参数可能主要改变一个维度,不应笼统理解成“开了就完全没有容器了”。
1. --pid=host:共用宿主机的进程编号空间
普通容器里的进程通常只能看到自己范围内的进程。使用这个参数后,就可以看到宿主机进程,常用于监控和诊断。
可以把它想成:原来你只有本部门的工号表,现在拿到了整栋楼的工号表。
但“知道对方工号”不等于“能任意操作对方”。发送信号、调试、读取进程信息等操作仍有各自的 UID、capability 和安全策略检查。它扩大了可见性和可能的影响面;并非单靠这个参数就能读写所有进程内存。
它也不会自动共享网络空间或把容器根目录换成宿主机根目录。Docker PID 模式
2. --network=host:共用宿主机的网络空间
在本文讨论的原生 Linux Docker Engine 上,这会让容器共用宿主机的网络栈,不再拥有通常意义上的独立容器 IP。
直接后果是:
- 容器程序监听的端口进入宿主机的端口空间,容易与现有服务冲突。
- 容器里的
localhost可以指向宿主机网络中的本地服务。 -p端口发布不再起原先作用,因为已经共用网络空间。
它像把部门独立的电话交换系统撤掉,直接接入全楼的号码系统。文件系统和 PID 等其他隔离维度并不会因此自动取消;管理网卡和防火墙的权限仍需另外判断。Docker Desktop 的 host 网络是另一种实现,存在平台限制,不能照搬原生 Linux 的所有细节。Docker host 网络文档
3. --privileged:大幅放宽权限和设备约束
在常见 rootful Linux Docker 中,这个参数会授予全部 Linux capabilities、允许访问宿主设备,并撤销默认 seccomp、AppArmor 等多项约束;SELinux 相关约束也会被放宽。
它更像交出一大串管理钥匙。即使某些 namespace 仍然存在,也不能继续把这样的容器当成可靠的安全沙箱。
它不等于自动设置了 --pid=host 和 --network=host,但容器可以获得足以严重影响宿主机的能力。 把它解释为“只是容器内部能多装几个软件”会严重低估权限范围。Rootless 或额外隔离产品中的具体效果还受外层边界约束。Docker privileged 模式
| 配置 | 主要改变的边界 | 仍不能据此推出的结论 |
|---|---|---|
| 普通容器 | 保留配置好的多维隔离 | 完全不共享资源、完全不会越界 |
--pid=host | 共享进程编号空间 | 自动获得全部宿主机权限 |
--network=host | 共享网络空间 | 自动共享所有文件和进程 |
--privileged | 大幅放宽特权、设备和安全限制 | 所有 namespace 都被自动改成 host 模式 |
4. 另一条关键通道:Docker socket
把 /var/run/docker.sock 挂进容器,意味着容器里的程序可能直接请求宿主机 Docker 服务创建、删除和管理容器。
假设流水线容器没有编译工具,却能调用 Docker 服务启动另一个容器,并让后者挂载宿主机目录:它就能借管理服务的能力触及容器外部。
这不是利用软件漏洞“逃出去”,而是取得了管理员原本开放的管理接口。即使把 socket 挂载标成只读,也不能把 Docker API 变成只读 API:向 socket 发送管理请求与修改普通文件不是一回事。能否调用哪些 API,需要另外的授权机制控制。Docker daemon 的攻击面
八、虚拟机为什么通常隔得更深
*左边的普通容器共用一个内核;右边的虚拟机各有客体内核,但仍可能共用物理硬件。*
1. 普通容器共享一个内核,虚拟机各有自己的内核
普通容器
应用 A + 库 A 应用 B + 库 B
↘ ↙
同一个 Linux 内核
↓
硬件
虚拟机
应用 A + 库 A 应用 B + 库 B
↓ ↓
客体内核 A 客体内核 B
↘ ↙
虚拟化层 / Hypervisor
↓
硬件
虚拟化层为虚拟机提供虚拟 CPU、内存和设备,虚拟机在这些条件下运行自己的操作系统。两台虚拟机可以使用不同内核,甚至不同操作系统。Google Cloud 的容器与虚拟机架构说明
办公楼的比喻到这里可以这样延伸:容器是在同一套管理中心里划分部门;虚拟机则让每个单位拥有自己的内部管理中心,外面再由园区层管理资源。
2. 差异在于故障和特权先落在哪一层
容器应用向共享的宿主机内核发出系统调用。若绕过容器约束或攻破相关内核、运行时边界,影响可能扩大到同机其他工作负载。
虚拟机里的应用先与客体内核交互。即使取得客体 root 或攻破客体内核,要继续影响宿主机和其他虚拟机,通常仍需跨越虚拟化边界。Microsoft 的容器与虚拟机隔离说明
因此,虚拟机通常提供更强的安全和内核故障隔离。但这不能被量化成“强多少倍”:虚拟化层也有漏洞,虚拟机也可能共享存储、网络和物理资源。客体内核崩溃通常局限在客体内;物理机断电仍可能影响所有客体。
3. Ubuntu 容器为什么可以跑在另一种 Linux 发行版上
容器镜像里的 Ubuntu,通常指 Ubuntu 的用户空间文件、工具和库;它没有在启动时再启动一套 Ubuntu 内核。
所以可以出现:宿主机使用某种 Linux 发行版,容器里有 Ubuntu 文件系统,而 uname -r 仍显示宿主机提供的内核版本。前提是 CPU 架构、内核接口和应用要求相容,不能理解为任意操作系统、架构都能无条件混跑。
Windows 或 macOS 上运行 Linux 容器,通常还隔着 Linux 虚拟机或 WSL 2 之类的环境。此时 Linux 容器共享的是那一层 Linux 内核,不能理解为 Linux 程序直接向 macOS 内核发出 Linux 系统调用。WSL 2 后端的具体关系可参见 Docker Desktop WSL 2 文档。
虚拟机与容器可以叠加使用:先用虚拟机划定团队或信任边界,再在内部用容器管理应用依赖。
九、“打镜像的环境”到底是哪一种环境
*沿着原料、构建工具、镜像产品和运行实例依次看,就能分清构建环境与部署环境。*
这句话在工作中常指一整套条件,不能默认理解成“某个已经运行的 Docker 容器”。先把几个层次摊开:
| 名称 | 具体指什么 | 类比 |
|---|---|---|
| 构建主机 | 提供 CPU、内存、磁盘和网络的物理机或虚拟机 | 工厂所在的场地 |
| Docker Engine | 接收管理请求、管理容器等的服务体系 | 工厂的管理系统 |
| Builder / BuildKit | 解析并执行构建任务的构建器 | 执行生产的设备 |
| 构建任务 | 某一次制作镜像的过程 | 一张具体生产订单 |
| Dockerfile | 构建步骤和配置的文本说明 | 工艺单 |
| Build context | 提供给构建使用的文件集合 | 这一单允许领取的原料 |
| 基础镜像与构建阶段 | 编译、安装依赖时使用的软件文件与执行条件 | 当前工序的工具台 |
| 镜像输出 | 构建产生的应用文件层和配置 | 出厂产品 |
| 运行容器 | 基于镜像启动的实例 | 产品投入使用 |
1. Buildx 发出任务,BuildKit 负责执行
现代 Docker 构建主要围绕 Buildx 与 BuildKit:前者是构建客户端和管理入口,后者执行构建。构建器可以在本机,也可以在远端。Docker Build 架构
flowchart LR
A[代码、Dockerfile<br/>构建上下文] --> B[Buildx 客户端]
B --> C[BuildKit 构建器]
C --> D[执行构建步骤<br/>使用隔离执行环境与缓存]
D --> E[输出镜像]
E --> F[加载到镜像存储<br/>或推送镜像仓库]
F --> G[部署机器启动容器]
镜像构建完成,不等于业务已经启动;业务部署机器也不必是镜像构建机器。
2. Builder 有时在容器里,但不能据此把二者等同
使用 docker driver 时,BuildKit 组件集成在 Docker Engine 中,构建结果通常直接进入该 Engine 的镜像存储。Docker driver
使用 docker-container driver 时,BuildKit 服务运行在一个专门的容器里,状态和缓存可以保存在专用卷中。一个主机上可以创建多个命名的 builder。默认配置下,构建结果可能只留在构建缓存里;需要 --load 加载到本地 Engine,或用 --push 推送到仓库。Docker container driver
注意这里至少有三类东西:运行构建服务的容器、执行某一步 RUN 的环境、最终业务容器。它们职责不同,生命周期也不同。
3. 构建“环境”还包含网络、账号和外部依赖
即使 Dockerfile 完全相同,构建也可能因为下列条件不同而表现不同:
- 能否访问基础镜像仓库、Maven 仓库、npm 源或私有 Git。
- 使用哪个 CPU 架构,是否需要模拟执行。
- 有没有代理、证书和下载凭据。
- 基础镜像标签、软件包版本是否变化。
- 构建服务与缓存如何配置,是否给了特殊权限。
所以,“镜像里带了依赖”有助于交付一致,但“同一 Dockerfile 永远得到逐字节相同的产物”仍需要额外的版本固定和可复现构建设计。
4. 一个服务器可以有多个环境吗
可以,而且“多个”可以发生在不同层次:同一个 builder 执行多个任务、多个 builder 使用不同配置和缓存、多个容器使用不同工具链、多个虚拟机拥有不同内核。
依赖版本不同,往往只需分开镜像和构建阶段;缓存和构建配置需要分开,可以使用多个 builder;安全信任和内核边界需要分开,通常要考虑独立虚拟机或更强方案。
同机运行多个 Docker daemon 也在技术上可行,但 Docker 官方将其列为实验性用法,需要分别安排 socket、运行状态、数据目录和网络等资源。它不是“多建几个 builder”的同义词,也不会自动获得不同的内核。Docker 多 daemon 配置
十、沿着一个具体构建,看看文件和进程到底在哪里
1. 一个小例子:把网页装进镜像
假设项目目录中只有两个文件:
hello-site/
├── Dockerfile
└── index.html
index.html 可以写成:
<h1>Hello, container</h1>
Dockerfile 写成:
FROM python:3.12-slim
WORKDIR /app
COPY index.html ./index.html
RUN python --version
USER 10001:10001
EXPOSE 8000
CMD ["python", "-m", "http.server", "8000", "--bind", "0.0.0.0", "--directory", "/app"]
这是教学示例,Python 自带的 HTTP 服务用于观察机制。这里的镜像标签表示示例选择的版本系列,实际工程还应管理镜像更新与摘要固定。
这些指令的分工是:FROM 选择起点;WORKDIR 设置后续工作目录;COPY 放入文件;RUN 在构建阶段执行命令;USER 设置后续身份;CMD 记录未来启动容器时默认执行什么。
EXPOSE 是端口元数据,单写它不会自动把服务发布到宿主机。Dockerfile 指令参考
2. docker build -t study/hello-site:demo . 在做什么
最后的 . 表示当前目录作为构建上下文。构建器按需要获取上下文文件,处理 Dockerfile,准备基础文件系统,执行未被缓存命中的步骤,并输出镜像。
COPY index.html 的来源是上下文中的文件,不是随意读取宿主机任何目录。上下文过大可能无意带入日志、个人文件或凭据,所以要选择正确目录,并用 .dockerignore 排除无关内容。Docker build context
RUN python --version 使用构建阶段提供的 Python。即使宿主机没有安装 Python,也可以完成这一步。
构建期间确实在运行程序,但不是启动了最终网页服务;CMD 所描述的 HTTP 服务,要等创建并启动业务容器时才执行。
3. RUN 的环境变量和文件,生命周期不同
RUN 可以创建文件,后续步骤能够基于文件系统结果继续构建;但某一次 Shell 里临时 export 的变量,不会自动传给后面的独立 RUN。
例如,把“创建一个配置文件”和“在本次终端设置一个临时变量”都称为“改环境”,就会误判它们是否能保存。需要区分文件系统结果、镜像配置和进程临时状态。
4. 多阶段构建:制作工具不必跟产品一起发货
对 Java 项目,可以用带 JDK 和 Maven 的阶段编译得到 app.jar,再把产物复制到只带所需 Java 运行时的最终阶段。
构建阶段:JDK + Maven + 源代码 → app.jar
↓ 只取需要的产物
最终阶段:Java 运行时 + app.jar → 交付镜像
这样既不要求在宿主机全局安装所有版本的 Maven,也不必把编译工具和源代码全部留在最终镜像。Docker 多阶段构建
5. 文件被打进镜像,与文件被挂进容器,后果不同
COPY 是把所需文件纳入镜像构建;运行时 bind mount 是把外部数据接入容器目录。外部挂载数据通常不会因为构建、保存镜像,就自动被收进去。
敏感信息还要单独处理:不能以为“先把密码文件复制进去,后面再删掉”就一定清除了痕迹,旧层可能仍保留内容。构建凭据应使用专门的 secret/SSH mount 等机制,并避免被构建命令写进日志和产物。Docker build secrets
十一、多个系统共用构建服务器,到底会在哪些地方冲突
这里先按“订单系统、报表系统等多个业务系统”来理解。若说的是多个操作系统,也要继续区分:只是容器里不同的发行版文件,还是各自拥有独立内核的虚拟机。它们的边界不同。
1. 依赖冲突:Docker 很擅长缓解的一类问题
假设订单系统需要 Java 17,报表系统需要 Java 21。如果都依赖宿主机的全局 Java 配置,改一次 JAVA_HOME 或默认命令链接,可能影响另一套任务。
把工具链放进各自构建镜像或多阶段构建后,两者分别使用自己的文件系统与依赖,通常可以共存。
但若流水线仍然在宿主机直接执行 Maven,或者把同一个可写的工具目录挂给所有构建,就重新引入了共享。是否真的隔开,要沿着实际执行路径检查,不能只看项目里有没有 Dockerfile。
2. 名称和标签冲突:共用一个管理服务,就会共用一些命名范围
例如,两个项目都把结果标成 app:latest:在同一个镜像存储中,同名标签可能被后构建的结果重新指向。已经运行的容器不会因此立刻变成新版本,但下次按该标签启动时,可能拿到另一份镜像。
更清楚的命名可以是:
team-a/order-api:git-a1b2c3d
team-b/report-api:git-d4e5f6a
标签仍可能被重新赋值;严格绑定同一份内容需要使用镜像摘要,或配合仓库的标签不可变策略。
容器名、网络名和卷名也要管理。Compose 的项目名可以为相关资源加上项目范围,减少同名服务互相影响;但显式指定的全局名称、外部资源、宿主端口和共享目录仍需单独安排。项目名也不是恶意租户之间的权限边界。Compose 项目名
3. 端口冲突:要区分容器内部端口与宿主机发布端口
两个普通网络空间中的服务都监听容器内的 8080,通常不冲突。需要对外发布时,可以分配:
宿主机 18080 → 订单容器 8080
宿主机 18081 → 报表容器 8080
若都要求绑定同一个宿主机 IP、同一协议的同一端口,就会冲突。使用 --network=host 后,应用更是直接在宿主网络空间中竞争端口。
-p 18080:8080 的左边是宿主端口,右边是容器端口。显式写 -p 127.0.0.1:18080:8080,则指定发布到宿主回环地址,适合本机学习验证;外部访问还涉及实际版本、路由、防火墙和代理设置。Docker 端口发布
单纯构建镜像通常不需要发布业务端口;如果构建流程另行启动集成测试服务,才要考虑这些服务的监听、网络和端口安排。不能把部署时端口冲突,直接当作所有镜像构建任务必然存在的问题。
4. 数据冲突:不同容器仍可能在写同一个文件柜
两个构建任务都写宿主机的 /workspace/output/app.jar,就可能互相覆盖。两套数据库共用一个卷,风险更大。
常见做法是为每个项目、任务安排独立工作目录和输出位置;确需共享的依赖缓存则使用工具支持的并发管理方式。BuildKit 自己管理的缓存复用,与把任意宿主目录同时交给所有任务读写,是不同情况。
5. 资源争抢:逻辑上互不相干,物理上仍在抢同一条路
并发编译会争抢 CPU 和内存;拉取镜像会争抢网络;缓存和日志会消耗磁盘容量与 inode。即使目录完全分开,同一块文件系统写满后,也可能让其他任务无法保存文件。
需要治理的不只是单个应用容器,还包括构建器、并发任务数、构建缓存和磁盘保留策略。给最终业务容器设了内存上限,不代表构建它的过程也自动拥有同样上限。
6. 管理冲突:共享 daemon 不会自动按登录用户分项目
如果 A、B 都能不受限制地访问同一个 Docker daemon,那么即使使用不同 Linux 登录账号,也通常可以管理同一批 Docker 资源。谁执行全局清理、重启服务或修改 daemon 配置,都可能影响别人。
不同用户在同一台机器上分别打开终端,并不会自动获得两套 Docker 引擎。
Docker 的持久数据由服务端管理,具体路径随版本、镜像存储后端和配置变化。不要把客户端的 ~/.docker 当成整个镜像仓库,也不要手工修改服务端的底层存储目录。Docker daemon 配置与数据目录
7. 安全与故障:决定“是否需要更强边界”的部分
构建不是简单压缩文件。Dockerfile 中的 RUN、包安装脚本、编译器和测试都会执行代码。来源不可信的构建,应作为执行不可信代码来设计边界。
| 风险 | 容器化通常能否直接缓解 | 还需要什么 |
|---|---|---|
| Java、Python、系统库版本互相覆盖 | 通常能很好缓解 | 各自使用镜像,减少宿主全局工具依赖 |
| 镜像标签、容器名和卷名混用 | 不会自动全部解决 | 项目命名、唯一版本标识、生命周期管理 |
| 宿主端口或共享工作目录冲突 | 不会自动解决 | 端口分配、独立工作区、明确挂载 |
| CPU、内存、磁盘被挤占 | 需要额外约束 | 限额、并发控制、容量管理 |
| 用户借同一 daemon 管理别人的资源 | 普通命名规则不能解决 | 管理接口授权或不同管理边界 |
| 高权限、恶意代码或共享内核风险 | 普通容器边界可能不足 | 独立虚拟机、受控执行器等更强方案 |
十二、是否有必要单独申请一台机器
*这是一张工程判断图:先找需要分开的共享点,再决定使用工作区、构建器、虚拟机还是物理机边界。*
1. 先描述需要的边界,再决定购买什么资源
“单独一台机器”可能只是独立虚拟机,也可能是独立物理机。两者预算和能解决的问题不同。
下面是基于前文机制做出的工程判断,不是 Docker 官方给出的统一分配规定:
| 具体情况 | 通常可以考虑的安排 | 原因 |
|---|---|---|
| 同一可信团队,项目主要差在依赖版本 | 共用受管理的构建机,按项目分开工具链和工作区 | 依赖差异通常不需要独立内核 |
| 构建配置、缓存和配额需分别维护 | 多个受管理 builder 或执行器 | 提供运维上的分工,但仍需检查共享权限和资源 |
| 不同团队互不信任,构建代码来源不可控 | 分离到独立构建虚拟机或适当隔离的临时执行器 | 需要更清楚的权限与故障边界 |
| 构建频繁需要 privileged、宿主设备或改内核设置 | 独立评估的构建虚拟机、专用构建资源 | 高权限行为对共享主机影响更大 |
| 同机运行重要生产服务 | 优先把构建迁到独立构建环境 | 编译尖峰、清理和管理变更不应牵连生产 |
| 需要专用硬件、稳定物理资源或组织明确要求 | 专用物理机或指定隔离基础设施 | 虚拟机仍可能共享物理故障与资源路径 |
2. 一个适合很多团队的起点
生产运行环境:运行已经验证的镜像
↑ 拉取
镜像仓库
↑ 推送
构建专用环境:拉代码 → 构建 → 检查 → 生成镜像
├── 订单项目工作区与工具链
└── 报表项目工作区与工具链
多个可信项目可以先共用构建专用虚拟机,配合各自工作区、版本命名、限额和管理规则。需要分离信任关系时,再拆分虚拟机或执行器。
这比“每个业务系统必定申请一台物理服务器”更贴近问题本身。反过来,明明存在高权限与信任问题,也不能以“已经用了 Docker”为理由认定共用就足够。
3. 向平台同事确认时,可以直接问这些信息
- 代码在哪里执行:宿主机 Shell、普通容器、特权容器,还是独立虚拟机?
- 构建请求发给哪个 daemon、哪个 BuildKit builder?管理服务由谁控制?
- 是否挂载 Docker socket、宿主目录或设备,是否需要 host PID、host 网络?
- 各项目是否有独立工作区、镜像命名、输出和凭据范围?
- 构建并发、CPU、内存、磁盘和缓存由谁限制和维护?
- 这台机器上是否还有生产服务,重启和清理会影响谁?
若需要申请资源,可以这样表达需求:
需要与生产服务分离的镜像构建环境,并明确 Docker 管理权限、项目工作区、构建凭据、资源限额和缓存清理责任。若与其他团队共享宿主机,应确认信任关系;需要隔离共享内核或高权限构建风险时,申请独立虚拟机或更强边界。
十三、为什么有的服务器只有 root 能运行 Docker 命令
*登录身份、服务身份、应用身份和仓库账号分属不同层面;socket 是管理通信入口,并不是第五种用户。*
1. 通常不是命令文件只允许 root 执行
普通用户往往能够执行 docker --version,却在 docker ps 或 docker build 时遇到“无法连接 daemon”或“permission denied”。
前者主要是客户端本地动作,后者需要连接管理服务。典型 rootful Docker 使用 Unix socket,例如 /var/run/docker.sock。
Unix socket 可以理解为“用文件路径标识的本机通信入口”。它不是镜像数据文件,而是管理服务接收请求的通道。
例如:
srw-rw---- root docker /var/run/docker.sock
开头的 s 表明这是 socket;后面的权限意味着 root 和 docker 组成员可以连接,普通无关用户不行。
执行 sudo docker ps 相当于用获准身份向服务发请求;把用户加入 docker 组,则可通过组权限访问 socket。在常见 rootful 部署中,docker 组通常授予接近宿主机 root 级别的管理能力,不能理解成只能管理“自己项目”的普通用户组。 Docker Linux 安装后权限说明
因此,“只有 root 能用”可能是管理策略,而不一定是安装错误。也可能来自自定义 socket 权限、远程端点授权或客户端配置问题,需要看实际报错。
2. 把四类用户彻底分开
| 身份 | 决定什么 | 不会自动决定什么 |
|---|---|---|
| 宿主机登录用户 | 你在服务器上能访问哪些文件、能否连接管理接口 | 容器内应用必须以同一 UID 工作 |
| Docker daemon 的运行用户 | 管理服务可以动用哪些宿主权限 | 所有容器内应用都必须使用相同用户 |
| 容器内应用用户 | 应用以哪个 UID/GID 访问文件、执行操作 | 你是否有权连接宿主机 Docker 服务 |
| 镜像仓库账号 | 能否拉取、推送仓库中的镜像 | 是否拥有宿主机 root 或本地 daemon 权限 |
于是下面这种组合完全成立:
alice 登录服务器
↓ alice 被允许连接 socket
root 身份运行的 dockerd 接收请求
↓ 启动
UID 10001 的容器应用
alice 不必在命令前写 sudo,不代表 daemon 已经不是 root。应用在容器里使用普通 UID,也不代表运行管理服务的用户随之改变。
同样,你改 Dockerfile 的 USER,不能修复宿主机连接 socket 的权限;执行 docker login 登录仓库,也不能授予本地 daemon 的访问权。
3. 三种经常被混用的“非 root”
| 做法 | 实际改变什么 | daemon 是否仍可能是 root |
|---|---|---|
Dockerfile USER 10001 或 docker run --user ... | 应用进程使用普通身份 | 是 |
userns-remap | 把容器内 UID 映射到宿主机另一组 UID | 是 |
| Rootless Docker | daemon 与容器整体在非 root 权限框架内运行 | 通常不再以宿主机 root 运行 |
userns-remap 可以降低容器内 root 在宿主机侧的身份权限,但 daemon 本身仍是 root。Docker userns-remap
Rootless 模式进一步把 daemon 也放在非 root 的权限范围内,需满足用户命名空间、ID 映射等前提。它仍共享内核,也仍可能访问运行它的宿主用户有权访问的资源,不能当成独立虚拟机。Docker Rootless 模式
4. Context 是连接配置,不是一台新机器
Docker context 保存客户端要连接哪个 Engine 等信息。切换 context,可能只是从本机 Engine 换到远程 Engine;创建两个 context 指向同一个端点,并不会获得两套隔离环境。Docker contexts
Build context 与 Docker context 虽然都有 context 这个词,含义完全不同:前者是构建输入文件集,后者是客户端连接配置。
sudo docker ... 还可能使用 root 的客户端配置、环境和凭据,与普通用户的默认 context 不同。看到普通用户和 root 列出的镜像不同,不能立即认定“Docker 默认按用户分镜像”,应先核对它们连到哪里。
十四、用三个小实验,把抽象概念变成可观察的现象
以下是供后续学习使用的命令示例,本文整理时没有在你的电脑或任何服务器上执行 Docker 命令。命令按 Linux Shell 编写,使用已经配置好的 Docker 客户端与服务;需要下载基础镜像,并占用少量构建和运行资源。
实验一:确认“我到底在操作谁”
whoami
id
docker context ls
docker context show
docker buildx ls
docker info --format '{{json .SecurityOptions}}'
依次观察:当前登录身份、用户组、Engine 连接配置、构建器列表,以及 Engine 报告的安全选项。
如果连接的是本机 rootful Engine,再看:
ls -l /var/run/docker.sock
如果使用 Rootless 或远程服务,这个本机路径可能根本不是正在使用的入口。权限不足时,先根据端点和策略定位原因,不能靠修改 socket 为所有人可写来替代理解。
实验二:同一份镜像启动两个实例
先在前文 hello-site 项目目录构建:
docker build -t study/hello-site:demo .
再启动两个容器,使用不同名称和宿主机端口:
docker run -d --name study-hello-a --memory=128m --cpus=0.5 -p 127.0.0.1:18080:8000 study/hello-site:demo
docker run -d --name study-hello-b --memory=128m --cpus=0.5 -p 127.0.0.1:18081:8000 study/hello-site:demo
在 Engine 所在主机访问 http://127.0.0.1:18080 和 http://127.0.0.1:18081,可以看到相同网页。若 Engine 在远端,这里的回环地址属于远端主机,需要在那边访问或使用合适的转发方式。
观察身份、内核和 PID namespace:
docker exec study-hello-a id
docker exec study-hello-a uname -r
docker exec study-hello-b uname -r
docker exec study-hello-a readlink /proc/self/ns/pid
docker exec study-hello-b readlink /proc/self/ns/pid
docker top study-hello-a
预期看到:应用 UID 是示例设置的 10001;两个容器报告同一宿主内核;两个独立容器的 PID namespace 标识不同。docker top 则让你从管理服务侧观察容器进程。
这几个现象一起说明:共享内核、独立进程编号空间、普通应用身份,可以同时成立。
实验三:修改实例,不会自动修改另一个实例或原镜像
在 A 的临时目录写一个文件,再在 B 里查:
docker exec study-hello-a sh -c 'printf "created in A\n" > /tmp/container-note.txt'
docker exec study-hello-a cat /tmp/container-note.txt
docker exec study-hello-b sh -c 'if [ -e /tmp/container-note.txt ]; then cat /tmp/container-note.txt; else echo "B has no such file"; fi'
在这个没有共享该目录的示例中,A 有文件,B 没有。这说明两个实例的可写层分开;如果改成把同一宿主目录挂给 A、B,就可以观察到相反结果。
完成后,只停止并删除本实验创建的两个实例:
docker stop study-hello-a study-hello-b
docker rm study-hello-a study-hello-b
停止本身不删除可写层;这里后续的 docker rm 才删除容器。示例镜像仍保留,可以以后继续使用。无须为这个实验执行全局镜像或缓存清理。
十五、以后再听到“环境”时,按这张地图理解
可以把整套知识压缩成下面的关系图:
flowchart TD
A[物理资源<br/>CPU、内存、磁盘、网络] --> B[操作系统内核]
B --> C[进程实际执行程序]
C --> D[文件、目录与挂载<br/>数据从哪里来]
C --> E[UID、GID 与权限<br/>能做什么]
C --> F[Namespace<br/>在哪个资源范围里]
C --> G[Cgroup<br/>如何计量与限制资源]
D --> H[Docker 容器运行环境]
E --> H
F --> H
G --> H
H --> I[构建也可以使用容器化环境]
I --> J[BuildKit 执行 Dockerfile]
J --> K[生成镜像文件层与配置]
K --> L[在部署环境启动新的容器实例]
再配上几个固定的问题:
| 听到的说法 | 需要落到实处的问题 |
|---|---|
| “环境不一样” | 是依赖文件、环境变量、网络、用户、架构,还是内核不一样? |
| “放在 Docker 里就隔离了” | 分开了什么,挂载和管理接口又共享了什么? |
| “这个用户能用 Docker” | 能连接哪个 daemon,能调用哪些管理操作? |
| “给你一个独立 builder” | 是分开配置和缓存,还是也分开了权限、资源和内核? |
| “单独申请一台机器” | 需要的是工作区、构建器、虚拟机,还是物理故障边界? |
最值得反复建立的几条直觉是:
- 程序干活要通过内核;普通容器没有自己的独立内核。
- Namespace 组织资源的视图与作用范围,cgroup 管资源使用,权限机制管允许的操作。
- 目录是入口;挂载决定它背后连到哪里,用户身份决定能不能操作。
- 镜像是交付物,容器是实例,builder 是执行制作过程的工具,主机是提供资源的地方。
- 依赖独立,不代表资源独占;工作区分开,不代表管理权限分开。
- 虚拟机通常把内核边界也分开;是否需要专用物理机,还要继续看硬件、资源与组织要求。
后续学习可以按“读懂一个进程 → 读懂目录和权限 → 观察 namespace → 跑两个普通容器 → 观察挂载与端口 → 走完一次构建 → 检查真实流水线边界”的顺序推进。先把普通情况看明白,再研究 host 模式、特权模式和多租户构建,概念会稳得多。
相关笔记:从 JavaScript 到 Node、npm、npx、模块化与构建工具:一条前端工程化入门主线、一条请求是如何穿过 DNS、Nginx、网关与微服务的:从域名到接口的完整链路讲透。