从一家酒店讲懂Docker

从一家酒店讲懂Docker

0. 导读:为什么用酒店来比喻

很多 Docker 教程会让你"更快地晕掉":上来就是 namespace、cgroup、OverlayFS、容器运行时,每个词都认识,连起来不知道在说什么。

原因在于:这些概念都长在 Linux 内核里,而很多人对 Linux 本身只有"敲过几条命令"的经验。本文反其道而行------先用一章搭好 Linux 地基(酒店模型),再把 Docker 的所有概念一个个"译"回这个模型。

先宣布全文贯穿的那个比喻,后面会反复用到:

一台 Linux 机器 = 一家酒店。

  • 内核 = 酒店唯一的后台总管团队(管水电、仓库、门禁、电话总机)
  • 用户程序(进程) = 入住客人(nginx、MySQL、你的 Java 服务、甚至 docker 自己,全是客人)
  • 系统调用 = 客人给前台打的电话(客人永远不能自己进配电房,一切需求打电话)
  • 容器 = 办了三样"特殊服务"的普通客人:专属楼层 (只能看到自己)、消费限额账本 、一套样板间外观
  • Docker = 长驻酒店的金牌代办(它自己也是普通客人,专门替你跑腿办理上述特殊服务)
  • 镜像 = 一车自带家具的行李包------唯独不带"总管团队",入住哪家酒店就归哪家总管管

记住这七行,全文的每一次推导,最终都会落回它们。


1. 地基:一台 Linux 就是一家酒店

1.1 三层世界:硬件、内核、用户程序

把一台 Linux 机器竖着切成三层:

最底层是硬件 (CPU、内存、磁盘、网卡)------酒店的实体。谁都不能直接碰它,拉闸断电不是客人的事。

中间层是 Linux 内核------酒店唯一的管理团队。它有且只有它有权操作硬件。它管五件事:进程调度(谁先用电梯)、内存分配(房间怎么分)、文件系统(仓库货架)、网络收发(电话总机)、权限检查(房卡验证)。

最上层是用户程序 ------所有入住客人。你平时说的"软件"------浏览器、nginx、MySQL、你的 Spring Boot 服务、甚至 docker 命令本身------全是客人,没有例外。

两层之间只有一条通道:系统调用(syscall)。客人想要任何东西------读个文件、发个网络包、开个新进程------都必须打电话给前台,由内核去办。

为什么这一点如此重要? 因为这是理解容器一切的前提:客人永远不是酒店,客人永远归酒店管。

1.2 内核与 CPU 核心:手册与工人

初学 Linux 最常见的误会:"内核是个一直在后台运行的神秘程序,CPU 核心是它手下干活的"。错了一半。

内核是软件,CPU 核心是硬件。内核自己一步都跑不了------它的每条指令,最终都必须由某个 CPU 核心执行。

准确的模型:

  • 内核 = 一本常驻内存的《总管手册》+ 一套账本(代码 + 数据结构),不是一群"人";
  • CPU 核心 = 真正的工人。4 核机器 = 4 个工人;
  • 任一时刻,每个工人只有一种身份:要么在干客人的活(用户态 ,帮你算订单、拼 JSON),要么在翻手册执行内核代码(内核态,分配内存、转发网络包);
  • 身份切换的时机:客人打电话(系统调用)、闹钟响(时钟中断)、门铃响(硬件中断)。

于是那句著名的话有了精确含义:"内核在运行"= 此刻某些工人正在执行内核代码。当工人翻到手册的"调度器"一章时,他自己就在当总管,决定接下来换谁上工。几百个进程共享 8 个工人,靠的就是工人们以毫秒级轮换手上的活------轮换快到你以为所有程序"同时在跑"。

这个模型马上要派大用场:后面讲 cgroups 限 CPU 时你会看到,所谓"给容器限 1 个核"不是分走一个工人,而是调度器在账本上给这组客人记工时限额------超了就不给排工。

1.3 文件系统:一棵树、挂载与两场"骗局"

Linux 没有 C 盘 D 盘。 整个磁盘上的所有东西,长在一棵从 /(根目录)出发的树里:

arduino 复制代码
/
├── bin/     ← 谁都能用的基础命令
├── lib/     ← 公共依赖库(比如 glibc,客人们共用的工具箱)
├── etc/     ← 全酒店配置文件(nginx.conf、hosts 都在这)
├── home/    ← 各客人的私人储物柜
├── var/     ← 运行时产生的杂物(日志、缓存)
├── proc/    ← ★ 一场"骗局",下面单独讲
└── sys/     ← ★ 另一场"骗局"

"挂载(mount)"------接枝,不是拷贝。 酒店仓库有几个货架区(多块磁盘/U 盘)。总管做一次"挂载":把 2 号货架的内容整棵接到树上 /var 这个枝头。此后你逛到 /var 看到的仿佛是天然长在那的东西,实际是另一块盘上接 过来的。要点:数据原地不动,一个字节都不复制 ;U 盘一拔,挂载点瞬间枯萎。牢记这对概念:拷贝 = 数据搬家;挂载 = 换个门牌看同一堆数据。(后面 OverlayFS 靠它理解。)

/proc------一场"伪装成文件的表演"。 内核把自己的账本伪装成文件放在 /proc 下:你想查 PID 12345 的进程信息,不用任何 API,直接 cat /proc/12345/status------内核现场编出 这份"文件"递给你,上一毫秒它还不存在。磁盘上一个字节都不占。理解成:走廊尽头的活页台账,翻开自动浮现实时状态。/sys 同理 ,cgroups 的配置"文件"就在 /sys/fs/cgroup/ 里------所以"写一个文件 = 下达一条配置",这就是后面 cgroups 没有专用 API、全靠读写文件管理的根本原因。

1.4 root:持有万能房卡的特殊客人

每个进程(客人)都持有一张身份卡:一个数字,叫 UID 。UID 0 = root,万能房卡:进任何房间、改任何配置、杀任何进程。

关键认知:权限检查发生在每一次系统调用上------不是文件"长腿挡你",而是你打电话要开门时,内核先验你的房卡够不够格。

后文第 7 章你会看到,这张"万能房卡"其实可以被拆成几十张子卡(capability)------"容器里的 root 为什么缺胳膊少腿",答案就藏在这个拆分里。

1.5 动手热身:三行命令看一眼你的 Linux

以下命令请在 Linux 服务器 (或 Docker Desktop 里随便一个容器)里执行;macOS 用户直接 docker run -it alpine sh 进去看也是一样的世界。

bash 复制代码
# 第一眼:你的"总管团队"是谁(内核版本)
uname -r                    # 例:5.15.0-91-generic

# 第二眼:有哪些"客人"在(进程表)
ps -e -f | head            # PID 1 是 systemd/init,后面全是客人

# 第三眼:那棵"目录树"长什么样
ls /                       # bin、lib、etc、proc、sys......对上了吗?
cat /proc/self/status | head -3   # 看一眼自己的"活页台账"

三行命令看到的东西,就是 1.1~1.3 的全部实体。后面每章的动手环节,都是在这里验证刚学的理论。


2. 容器是什么:被三件套包裹的普通进程

2.1 暴力起点:没有 Docker 的世界

先忘记 Docker 这个词。在 Linux 上裸跑一个进程,它天生长在四种"共享"里:

  1. 它看到的文件系统 = 整台机器的文件系统 (/etc、/usr 大家共用);
  2. 它看到的进程表 = 整台机器的进程表 (ps 能看到所有进程);
  3. 它能用的资源 = 没人拦它(一个失控进程能吃光 128G 内存);
  4. 它的网络 = 直插宿主机网卡(大家抢同一个 8080 端口)。

"在我机器上能跑"的全部悲剧------环境不一致、依赖冲突、端口打架------根源就是这四条。而打断这四条,不需要任何虚拟化技术:Linux 内核自己就提供了三件原生工具。

2.2 Namespace:换一副眼睛

内核允许一个进程在创建时,换一副"眼睛"------传几个参数,内核就给它一套全新的"视图":

arduino 复制代码
新 PID 视图   → 它成了自己世界里的 PID 1,看不到别人的进程
新网络视图    → 它看到一块崭新的空网卡,而不是宿主机 eth0
新挂载视图    → 它有自己的挂载点表,可以被"关"进一个目录里
新主机名视图  → 它可以有自己的 hostname

这就是 namespace 。要点只有一句:namespace 隔离的不是资源,而是"看得见什么" 。宿主机视角里它还是那个普通进程,ps aux 看得清清楚楚------只是它自己看不见别人。

不用 Docker 就能验证------Linux 上一条命令手工造半个"容器":

bash 复制代码
# 新 PID 空间 + 新挂载空间 + 新 hostname,然后跑个 shell
unshare --pid --fork --mount-proc -u sh

2.3 Cgroups:记账员与限额器

namespace 管"看不见",管不住"吃太多"。cgroups 是内核的另一套机制:把一批进程放进一个组,给这个组记账、上锁:

ini 复制代码
memory.max = 512M        → 组内所有进程合计最多 512M,超了触发 OOM 杀进程
cpu.max    = 100000/100000 → 每 100ms 窗口内最多累计用 100ms CPU 时间(=1 个核)

三个容易踩的误解,一次说清:

  1. cgroups 不是"资源调度",是"记账 + 限额"。调度(谁上哪个核心)是调度器的活;cgroups 只管"这组客人的配额是多少、超了怎么办"。
  2. cgroups 一个字节磁盘都不分 。"容器最多占 10G 磁盘"这种事不归它管(那是存储的事);它管 CPU 工时、内存额度、磁盘 IO 流速------注意是流速不是容量。
  3. 接口形态就是 1.3 说的"假文件":echo 512M > /sys/fs/cgroup/xxx/memory.max 就是全部 API,任何语言都不需要 SDK。

2.4 OverlayFS:拼房术

被关进 namespace 的进程,推开窗得看到一套完整的世界 (有 /usr、/bin、/lib),不然 nginx 连启动都做不到。最笨的办法:每个容器复制一整套 Linux 系统文件------一百个容器复制一百份(这就是虚拟机镜像 GB 级的根源)。

OverlayFS 的聪明方案(看图左侧那栋剖面):家具不复制,叠着共享。

  • 镜像只读层 = 搬家公司的标准家具包,所有同镜像容器共用一份实物;
  • 可写层 = 这个容器自己的私人物品,唯一能动手的一层;
  • 两者叠成一个 merged 合并视图,再用 1.3 学的"挂载"接到宿主机目录树上------容器进程看到的就是这间拼出来的完整房间。

由此两条规则(图右侧两张卡):

  • 改东西 → 整件搬上楼(copy_up) :想改下层公共家具?先把整件 搬到可写层再改。镜像里一个 100M 大文件,容器里只追加一行,磁盘瞬间多出 100M。推论:频繁写的数据(数据库、日志)必须挂 volume,不要写在容器可写层。
  • 删东西 → 贴遮挡贴(whiteout) :公共家具删不得(别的容器还在用),只能在可写层贴一张"视而不见"贴纸。所以容器里删文件,磁盘占用不减反增。

2.5 三个立刻成立的推论

① 为什么容器秒级启动? 因为根本没有"启动"什么------创建一个容器就是 clone() 出一个进程,和 fork() 一样快。虚拟机的分钟级耗时花在引导完整内核上;容器没有内核要引导。

② 为什么容器隔离弱于虚拟机? 所有容器共享同一个内核。内核出漏洞,容器逃逸就可能在宿主机执行代码。这是容器安全边界的根本所在(第 7 章展开)。

③ 为什么 Mac 上跑容器要先起一个 Linux 虚拟机? macOS 内核不是 Linux,没有 namespace/cgroups 这些"服务项目"。所以 Docker Desktop 在 Mac 上先起一台 Linux 轻量虚拟机,你的容器全住在那台 VM 里------容器里 uname -r 看到的 Linux 版本号就是这么来的。

这一章的全部结论浓缩成一句话:容器的隔离能力 100% 由 Linux 内核提供(Namespace 管看得见什么、Cgroups 管记账限额、OverlayFS 管文件系统外观);镜像是"缺了内核的电脑",运行时永远借用宿主机的内核。

2.6 动手:亲手造一个半成品容器

实验 1:容器内外的 PID 是两本账。

bash 复制代码
docker run -it --name test busybox sh
# 容器里:
ps              # 你是 PID 1!整个世界只有你和 ps
# 开另一个终端,宿主机(或 Docker Desktop 里进 VM)上:
docker inspect test | grep -i pid    # 找到宿主机视角的真实 PID,如 12345
ps -ef | grep 12345                  # 宿主机看得见它,它就是普通进程

同一个进程两本账:容器内叫 1 号,宿主机叫 12345------"隔离的是视角,不是存在"。

实验 2:不用 Docker,内核原生的半成品容器(仅 Linux)。

bash 复制代码
# unshare = 把自己搬进新的 namespace:新 PID 空间 + 新挂载表 + 新主机名
unshare --pid --fork --mount-proc -u sh
hostname my-mini-container
ps                 # 又是 PID 1

实验 3:cgroups 账本就在 /sys 下。

bash 复制代码
docker run -d --memory=512m --cpus=1 --name limited nginx
# 宿主机上找到它的 cgroup 目录(Linux;Docker Desktop 需先进 VM),验证限额真实存在:
cat /sys/fs/cgroup/system.slice/docker-*/memory.max   # 536870912 = 512M

实验 4:秒级启动是真的。

bash 复制代码
time docker run --rm hello-world   # 全程零点几秒
# 对照:任何一台虚拟机从开机到可用,都是分钟级

3. Docker 是什么:三件套与一张行业地图

3.1 最容易混淆的三个角色

"docker 是一个进程""docker 被隔离在容器里"------这类说法错得五花八门,因为它们把三个东西搅在了一起:

角色 身份 酒店版
docker 命令(CLI) 普通进程,跑完就退出的临时客人 你派去前台递单子的跑腿
dockerd 守护进程 普通进程,常驻后台、root 权限、从未被隔离 长驻酒店的金牌代办
容器 普通进程,被 namespace/cgroups 特殊服务包裹 办了特殊服务的普通客人

被隔离的从来不是 docker,是容器。 dockerd 恰恰相反------它是隔离服务的"经办人",自己就光天化日站在大厅里(任何宿主机 ps aux | grep dockerd 都能看到它)。它调用内核的 namespace/cgroups 能力,替你把某个新进程办成"隔离客人"。

3.2 一个 dockerd 管理 n 个容器

装一次 Docker = 雇一位 长驻代办,管这台机器上所有 容器------1 对 n,不是一容一 docker。n 个容器各住各的楼层(各自 namespace)、各记各的账(各自 cgroups)、各拼各的房间(各自 OverlayFS),互相不知道彼此存在。

由此推出一个生产要点:dockerd 是管理单点 。它挂了,docker ps 没人应答、新容器没人代办------但已经在跑的容器不会死,因为包裹它们的是内核,不是 dockerd。失去的只是"管理能力",不是"运行能力"。(下一章的 shim 会把这个答案升级到完整版。)

3.3 容器行业版图:四大板块

理解 Docker 的前世今生,先看这张地图------容器行业就四块生意 ,判别任何工具属于哪块,只问一句:它是造 包裹的、存 包裹的、跑 包裹的、还是跨机器管包裹的?

板块 干什么 Docker 家的产品 其他玩家 最终格局
① 引擎(运行时) 造容器、跑容器 dockerd + containerd + runc LXC、podman、CRI-O、Kata 底层 runc/containerd 已成行业标准件
② 镜像(构建) 把应用+依赖打成标准包裹 Dockerfile + docker build kaniko、podman build OCI 镜像格式统一,谁构建的都能跑
③ 分发(仓库) 镜像的超市与冷链 Docker Hub Harbor、quay.io、云厂商仓库 企业私有仓库 Harbor 流行,Hub 仍是公共默认
④ 编排(集群调度) 跨多台机器调度管理成百上千容器 Swarm Kubernetes★、Mesos K8s 全面胜出,Swarm 淡出主流

2013 年 Docker 出道时四块生意全自己干 ------引擎是它的、Dockerfile 是它的、Docker Hub 是它的、Swarm 也是它的。而谷歌和社区的立场是:底层(引擎、镜像格式)应该像水电一样标准化,上层才允许各凭本事竞争。

结局你已经看到了:runc 交出去(2015)、containerd 交出去(2017)、编排战场正面输给 K8s。一句话史观:容器行业底层能力本来就在内核,Docker 做出了最好的第一代产品;社区把"造容器"和"镜像格式"标准化成了公共品,把"集群调度"的桂冠给了 K8s。Docker 公司输掉了行业,但 Docker 生态赢了世界------你今天用的每一层几乎都是它发明或孵化的。

3.4 动手:在宿主机上找到三件套

bash 复制代码
# 1. 找 dockerd------光天化日站大堂的金牌代办
ps aux | grep dockerd
#    root ... /usr/bin/dockerd -H fd:// ...   ← 看见了?root 身份、常驻

# 2. 起一个容器,再找它的"楼层管家"
docker run -d --name web nginx
ps aux | grep -E 'containerd-shim'
#    ... containerd-shim-runc-v2 -namespace moby -id <容器ID> ...
#    一个容器一位管家,常驻到容器死亡

# 3. 验证"1 对 n":再起两个容器,shim 变三位,dockerd 还是那一位
docker run -d --name db redis
docker run -d --name mq rabbitmq
ps aux | grep dockerd          # 还是 1 个
ps aux | grep containerd-shim  # 3 个

给 Mac/Docker Desktop 用户 :dockerd 跑在那台 Linux 轻量虚拟机里,宿主机 ps 看不到------用 docker run -it alpine sh 进任意容器,或 docker info 看运行时信息同样能验证。


4. 运行时分层:一次 docker run 的完整链路

4.1 从命令到容器进程:六个方块

2015 年后,一条 docker run 命令实际要经过六个方块(图左侧链路,自上而下):

  1. docker CLI(传话筒):你敲的命令。把单子递给 dockerd,然后退出;
  2. dockerd(总代办) :Docker 公司产品本体。管"增值业务"------镜像构建(Dockerfile)、卷管理、bridge 网络、对外 REST API。注意:容器隔离的核心流程已经不在它手里,它是个转包的包工头;
  3. containerd(大堂经理) :行业通用的容器生命周期管理者------启停容器、管镜像仓库、收日志。它不姓 Docker,K8s、podman 都在用它;
  4. containerd-shim(楼层管家,每个容器一位) :runc 建完容器就退场(下一点讲),总得有个常驻的人替容器进程"当爹"------收尸、传信号、上报退出码。这就是 shim,一容器一位,常驻到容器死亡;
  5. runc(装修队) :最底层、最忠实于内核的施工队。读 OCI 配置 → 调 clone() 加 namespace、写 cgroups 文件、挂 OverlayFS → 把容器进程拉起来 → 自己立刻退出;
  6. 容器进程:被三件套包裹的普通进程。

runc 为什么建完就走? 两个原因:容器进程一旦建好,包裹它的 namespace/cgroups 是内核在管 的,不再需要任何用户态进程"维持";且施工队(runc)和当爹的(shim)分离,施工队就能随便升级更换------这就是"dockerd 或 containerd 升级重启,线上容器不受影响"的完整答案:容器进程的爹是独立的 shim。

4.2 OCI:两份"插座标准"

2015 年成立的 OCI(开放容器倡议) 产出了两份规范:

  • Runtime Spec:定义"容器运行时"必须怎么造容器(读什么配置、调什么内核接口)------runc 是参考实现;
  • Image Spec:定义镜像这个包裹的内部结构(第 5 章拆开看)。

为什么要标准?插座形状统一,电器厂商就自由了:runc 可换成 crun(更快)、Kata(微型虚拟机,堵共享内核的安全洞);镜像在哪家仓库存、被哪家运行时拉起,全都互通。"容器不姓 Docker",制度化保证就是 OCI。

4.3 "K8s 抛弃 Docker"的真相

完整链条(这是高频面试题):

  1. K8s 要在各节点启停容器,需要统一接口,于是定义了自己的标准插座 CRI;
  2. Docker 的 dockerd 没有实现 CRI (它有自己的私有 API)。K8s 只好在 kubelet 里塞一块翻译垫片 dockershim,把 CRI 翻译成 Docker API------代价是多绕两层,还夹带一堆 K8s 用不上的功能;
  3. K8s 1.24(2022 年 5 月)移除 dockershim,kubelet 的 CRI 插座直插 containerd ------从此跳过 docker 两层。

准确翻译:"K8s 抛弃的是 dockerd 这层翻译开销,不是容器、更不是镜像。 容器照样是内核三件套包裹的进程,镜像照样是 OCI 镜像------docker build 构建的镜像在 K8s 上照跑不误。受影响的只是"在 K8s 节点上还依赖 docker 命令做运维操作"的老习惯(替代品 crictl / nerdctl)。

4.4 动手:盯一次 docker run 的完整链路

bash 复制代码
# 1. 挂一只耳朵听事件流
docker events &

# 2. 另一终端启动容器
docker run -d --name chain nginx

# 3. 看进程树(Linux;Mac 用户进 VM 或跳过这步)
pstree -p $(pidof dockerd)
# dockerd(1234)─┬─containerd(1300)
#               └─containerd-shim(1350)───nginx(1400)─┬─nginx(1401)
#                                                     └─nginx(1402)

这棵进程树就是 4.1 那六个方块的真身:dockerd → containerd → shim → nginx。

最值得观察的一个细节:runc 不在树上。 它建完容器就退出了(4.1 讲过原因)。验证:

bash 复制代码
docker run --rm -it alpine sh   # 另开一个容器
# 宿主机另开终端,立刻执行:
ps aux | grep runc              # 要么刚闪过、要么已经不在------建完就走

"升级不断电"实验(生产知识,可放心验证):

bash 复制代码
# Linux:重启 dockerd(容器进程的爹是 shim,不是 dockerd)
systemctl restart docker
ps aux | grep nginx          # chain 容器的 nginx 还活着!
docker ps                    # 重新接上代办后,容器列表照旧

5. 镜像解剖:拍照、Maven 与一个包裹的一生

这一章有一个给 Java 开发者的免费外挂 :你天天用的 Maven 生态里,装着镜像机制的原型。踩稳它,本章毫无门槛。

本章主题:镜像这个包裹是怎么被造出来的、里面装了什么、怎么运输到货。上图是全程地图,六步走完。

5.1 Dockerfile:你递给工厂的图纸

Dockerfile 是一个纯文本文件 ,逐行声明"我的镜像怎么组装"。它和 docker 的关系,完全等于 pom.xml 和 Maven 的关系:

Maven 世界 Docker 世界
工具 mvn docker
你写的文本 pom.xml:描述 jar 怎么组装 Dockerfile:描述镜像怎么组装
执行 mvn package 读 pom → 产 jar docker build 读 Dockerfile → 产镜像
文件放哪 项目根目录 项目根目录(和 pom.xml 并排)
dockerfile 复制代码
FROM ubuntu:22.04            # 第 1 行:从哪个基础镜像开始
COPY app.jar /app/           # 第 2 行:把我的文件放进去
RUN apt-get install curl     # 第 3 行:装我要的软件
CMD ["java", "-jar", "/app/app.jar"]   # 最后:容器启动时执行什么

整条链用 Java 钉死:Dockerfile ≈ .java 源码,docker build ≈ mvn package,镜像 ≈ jar,docker run ≈ java -jar,容器 ≈ 运行中的 JVM 进程。一个 jar 能跑出很多 JVM 进程;一个镜像能跑出很多容器------一对多关系一模一样。

5.2 docker build 的唯一条规则:拍照

docker build 逐行执行 Dockerfile,规则只有一条:每条指令执行完,给当前整个文件树拍一张快照 ,然后把"这张比上一张多出来的部分"打成一个压缩包:

  • 第 1 行拍完 → 照片里是整棵 ubuntu 文件树 → layer1.tar.gz(约 30MB)
  • 第 2 行拍完 → 只多了 app.jar → layer2.tar.gz(只装这 1 个文件)
  • 第 3 行拍完 → 只多了 curl 及依赖 → layer3.tar.gz(只装这些差异)

"拍照规则"是本章的灵魂,后面所有推论都从它长出来。

为什么要拍照?(三个目的)

  1. 省空间:每行都存整棵文件树,一个 10 行的镜像会膨胀成几百 MB,99% 是重复内容。只存差异------这就是"容器镜像比虚拟机镜像小"的物理根源;
  2. 可共享 :差异包的内容算指纹,指纹相同 = 同一个包。如果整个 Dockerfile 只拍一张总照片,A 镜像(ubuntu+redis)和 B 镜像(ubuntu+mysql)整张必然不同,30MB 的 ubuntu 部分一点都共享不了。行级拍照 = 把"共同的部分"切成可独立共享的块(就像 Maven 按 jar 共享而不是按整个项目共享);
  3. 可缓存 :改第 5 行,前 4 张照片不变 → 直接复用旧照片,只从第 5 行重拍 。这就是 docker build 第二次以后明显变快的构建缓存。

顺带:连 git 也是这个思路------git 不存"每次改动的文字记录",而是把文件内容按哈希存成对象(blob),相同内容全仓库只存一份。"内容快照 + 内容指纹"是现代工程界去重共享的标准打法。

5.3 成品:3 个压缩包 + 2 份 JSON

三行 Dockerfile 执行完,docker 收尾写两份 JSON,整个"镜像"就齐了:

arduino 复制代码
layer1.tar.gz + layer2.tar.gz + layer3.tar.gz   ← 家具包(层层差异快照)
manifest.json                                   ← 装箱单:本镜像 = 这 3 个包,各带指纹
config.json                                     ← 说明书:CMD、ENV、WORKDIR 等启动元数据

对照 Maven 世界(第二张图),就是 3 个 jar + 1 个 pom + 1 个 Main-Class 说明。没有更多东西了:

  • layers ≈ jar 文件------都是"压缩包里装一堆文件";
  • manifest.json ≈ pom.xml / MANIFEST.MF------写清这个镜像由哪几个包组成;
  • config.json ≈ Main-Class------告诉运行时"启动命令是什么"。分工记忆:layer 管"硬盘上有什么",config 管"启动时怎么跑"。

5.4 上架与到货:push / pull

  • docker push = 把这五样东西存进 Registry(Docker Hub / Harbor)------相当于 Maven deploy 到中央仓库/私服;
  • docker pull = 照单收货四步:先下装箱单(manifest)→ 按单逐箱下载 → 每箱验指纹 → 解压;
  • 解压出来的目录,就是第 2.4 节拼房术里那些只读家具层 ------docker run 时叠上可写层挂给 OverlayFS,容器进程的房间就拼出来了。镜像解剖与拼房术在此合拢 ;最后 runc 翻开 config 说明书点火启动(第 4 章的链路)。D4 的产物是 D2 的输入,D4 的 config 是 D3 的启动参数。

5.5 内容寻址:为什么 30MB 只下载一次

每个压缩包都有一个 sha256 指纹,指纹是用包的内容算出来的 ------内容不变、指纹不变。这带来一个机制:内容即身份。

镜像 A(ubuntu+redis)和镜像 B(ubuntu+mysql)都用 ubuntu:22.04,那箱 ubuntu 30MB 指纹相同:pull A 下载一次,pull B 时 registry 一对指纹------"这箱你有了,跳过"。和 ~/.m2 里 guava 只下一遍完全同构(Redis 缓存 key、Git 对象哈希,全是这个思想)。

5.6 快照只看期末:一桩悬案的终审

对镜像层最大的误解 :把它当 git log 那样的"操作流水账"。真相:每层是"期末快照的差异包",只看结果不看过程。

由此终审 Dockerfile 经典悬案------"下载的临时文件要在同一条 RUN 里删掉":

arduino 复制代码
写在同一行:  RUN 下载200M && 安装 && 删除
              → 下载物"来过又走了",拍照时不在画面里 → 这层真小 ✔

拆成两行:    RUN 下载 && 安装
              RUN 删除
              → 第一张照片里 200M 已入镜焊死;第二行只能贴"遮挡贴"(whiteout)
              → 没瘦,200M 白占 ✘

docker history 里那些 0B 的层,就是"这张照片比上一张几乎没多任何文件"的层------ENV 这种只往 config 写元数据、不碰文件树的指令,拍出来的就是 0B。

5.7 tag 是绰号,digest 是身份证

nginx:latest 里的 latest 是可变标签 ------今天拉和明天拉,背后可能不是同一个包裹。而整个镜像还有一个总指纹:nginx@sha256:abc123...(digest),内容一变指纹就变。

  • latest ≈ Maven 的 SNAPSHOT------方便人,但内容可漂移;
  • digest ≈ 固定版本 + 内容校验------方便机器,钉死版本、防调包。

生产/CI 锁定镜像用 digest,正如你们从不让 SNAPSHOT 上生产。

5.8 动手:构建、分层、悬案,一次做实

实验 1:亲手打一个包,观察"拍照"现场。 任意目录下新建 Dockerfile:

dockerfile 复制代码
FROM busybox
ENV TZ=Asia/Shanghai
COPY hello.txt /hello.txt
bash 复制代码
echo hi > hello.txt
docker build -t my-first-image .
# 输出里每一行 "Step x/3" 就是一次拍照------和 5.2 讲的一一对应:
#   Step 1/3 : FROM busybox        → 复用/拉取基础层
#   Step 2/3 : ENV TZ=Asia/Shanghai
#   Step 3/3 : COPY hello.txt /hello.txt

实验 2:用 docker history 验证"每行一层"与"0B 层"。

bash 复制代码
docker history my-first-image
# IMAGE          CREATED      CREATED BY                       SIZE
# <镜像ID>       刚刚          COPY hello.txt /hello.txt        3B      ← 只有这个文件
# <层ID>         刚刚          ENV TZ=Asia/Shanghai             0B      ← 5.7 预言:0B!
# <层ID>         1年前         busybox                         1.24MB  ← 基础层

实验 3:悬案终审现场版。 两个 Dockerfile 对比:

dockerfile 复制代码
# big-bad/Dockerfile(拆两行)          # big-good/Dockerfile(同一行)
FROM alpine                            FROM alpine
RUN dd if=/dev/zero of=/big bs=1M \   RUN dd if=/dev/zero of=/big bs=1M \
    count=100                              count=100 && rm /big
RUN rm /big
bash 复制代码
docker build -t big-bad big-bad
docker build -t big-good big-good
docker images | grep big
# big-bad   ~100MB   ← 100M 已焊进层快照,删不掉
# big-good  ~7MB     ← 快照里压根没它,真瘦了

实验 4:内容寻址与重复下载。

bash 复制代码
docker pull alpine         # 第一次:正常下载
# 再 pull 一个同样基于 alpine 的镜像,观察输出:
docker pull nginx:alpine   # 你会看到一行 "Already exists" ------ 30MB 没再下!
docker images --digests    # 顺手看一眼 digest 身份证

6. 容器网络:一次敲门的旅行

6.1 三件新设备:veth、网桥、iptables

回顾第 2 章:每个容器在创建时拿到一套专属对讲系统 (网络 namespace)------自己的网卡、自己的 IP、自己的端口表。所以两个容器各跑 nginx 都监听 80 端口互不冲突:两套对讲系统里各有一个"80 号分机"。

这套对讲系统靠三件设备接入酒店:

  1. veth pair(虚拟网线) :内核造的一根"两头都连设备"的网线。一头插宿主机侧,另一头插进容器的网络 namespace(容器里它就叫 eth0)。数据从一头进、另一头必出------容器接入网络的物理形态:没有真网卡真网线,全是虚拟的;
  2. docker0(虚拟网桥) :一台多口交换机。所有容器的 veth 宿主机侧都插在它身上,接到一个口的数据按目标 IP 转给正确的口------容器间通信都靠它交换;
  3. iptables(总机接线员) :内核网络栈里的规则表,干最关键的活------转接(NAT) 。docker run -p 时,docker 替你往内核写一条转接规则。

6.2 -p 8080:80 完整走查

按图从左到右,一次真实请求的旅行:

arduino 复制代码
① 外部访客敲宿主机 8080 门
   → 数据包到达宿主机真实网卡 eth0

② iptables 接线员翻规则表:有条 DNAT 规则写着
   "凡是来 8080 的,改写目的地 → 容器 IP 的 80 口"
   (这条规则正是 docker run -p 8080:80 时 docker 替你写的)

③ 改写后的包交给 docker0 网桥交换机
   → 交换机按目标 IP 找到容器插在哪个口

④ 沿 veth 网线送进容器
   → 容器里 eth0 收到,nginx 在 80 分机接起

关键认知:容器在宿主机上没有"真实端口",只有这条转接链。 -p 8080:80 不是"给容器开个 8080",而是"在总机登记一条转接规则"。docker ps 里的 0.0.0.0:8080->80/tcp,本质就是这张转接表的可视化。

6.3 出网与互访

容器主动访问外网 (图里虚线回路):容器内网 IP(172.17.0.x)外面没人认识,iptables 再干一件事------MASQUERADE(换装出厂):出门前把源地址统一改成宿主机 IP,回来再换回。这就是"bridge 网络能上外网"的底层原因。

容器互访 :同在 docker0 上 = 同一交换机内网,用容器 IP 直拨;自定义网络还能按容器名拨号(内置 DNS 通讯录)------这就是"自定义网络比默认 bridge 好用"的 why。

最后两个模式一句话:host 模式 = 不装专属对讲、直插宿主线路(最快但撞号);none = 不发电话(纯离线计算)。

6.4 动手:自定义网络与 Redis 主从

实验 1:同网容器按"名字"互拨(内置 DNS 通讯录)。

bash 复制代码
docker network create mynet
docker run -d --network mynet --name box1 alpine sleep 3600
docker run -d --network mynet --name box2 alpine sleep 3600
docker exec box1 ping box2     # 通!直接喊名字,不用记 IP
# 而默认 docker0 上则不行------默认 bridge 没有 DNS 通讯录,只能查 IP 再 ping

实验 2:Redis 主从集群(实战高频场景)。

bash 复制代码
# 主库
docker run -d --network mynet --name redis-master redis
# 从库:启动时喊一句"我跟主库混"
docker run -d --network mynet --name redis-slave \
  redis redis-server --slaveof redis-master 6379
# 验证:主库写、从库读
docker exec redis-master redis-cli set hello world
docker exec redis-slave  redis-cli get hello    # → world

这套实验的每个动作都在验证 6.1:容器间的所有通信,都是 docker0/自定义网桥这台"交换机"+ 每容器一根 veth 网线完成的。


7. 容器安全:被卸了武装的 root

7.1 先回收一颗雷:共享内核

第 2 章埋的雷现在引爆:namespace 和 cgroups 都不是安全屏障 ------它们是资源管理工具,设计初衷不是防坏人。真正要防的是:容器进程利用共享的内核里的漏洞,在宿主机上执行代码(容器逃逸)。

所以本章主题不是"容器有多安全",而是**"既然共享内核,怎么让容器里的进程做不成坏事"------靠限制权限,而不是相信隔离**。

7.2 三道默认闸门

闸门 1:capability------root 的"超能力"被拆成了卡片。 root 的能力不是"一个开关",而是几十张权限卡:CAP_NET_ADMIN(改网络)、CAP_SYS_ADMIN(挂载/改系统)、CAP_SYS_MODULE(动内核模块)......Docker 默认只给容器内的 root 发约 14 张常用卡,最危险的几十张全部扣下。 所以容器里 root 想 mount 文件系统、想改宿主机网络------全被前台拒绝。容器 root 不等于宿主机 root,不是因为中间有墙,而是因为这个 root 是"被卸了武装的 root"。(这解释了第 1.4 节说的"万能房卡可以拆成子卡"。)

闸门 2:seccomp------系统调用的白名单。 capability 管"权限卡",seccomp 管更底层的东西:Docker 默认挂一个过滤器,拦截 300+ 个高危系统调用 (很多历史上出过内核漏洞的调用)。容器进程想打白名单外的电话,前台直接挂断------这是防内核漏洞利用的最后一道大闸。

闸门 3(可选):user namespace------账本上就不是 root。 把容器内 UID 0 翻译成宿主机 UID 100000。哪怕前两道全被攻破逃出来,执行权限也只是个普通用户。代价是文件权限映射麻烦,所以 Docker 默认没开(--userns-remap 显式开启)。

7.3 生产四戒

戒 1:别把 docker.sock 挂进容器。 /var/run/docker.sock 是 dockerd 的遥控器。把它 -v 进容器 = 把遥控器塞给容器里的进程------它可以再开新容器、读宿主机所有容器数据、逃出资源限制。这是真实世界最常见的"容器提权到宿主 root"路径。

戒 2:别用 --privileged。 这个参数等于一键关掉三道闸门:所有 capability 全发、seccomp 全放开。容器几乎裸奔在宿主机上。只有极少数场景(如容器里跑嵌套容器)才考虑,且永远先想替代方案。

戒 3:别用 root 跑应用。 三道闸门防"逃逸",但应用以 root 运行会放大任何漏洞(写错日志路径的 root 能覆盖 /etc)。Dockerfile 里 USER appuser + 只读根文件系统,出事也炸不深。

戒 4:镜像要体检。 镜像 = 别人打好的家具包,可能藏着挖矿木马、过时组件。只 pull 可信源、镜像扫描(Trivy 等)进 CI、latest 换 digest 钉版本。

7.4 终极事实与出路

底层红线不变(图右下角):共享内核意味着内核漏洞一炸,所有容器一起沦陷------三道闸门只是让"炸"的门槛变高,不是不存在。 对隔离要求极高的场景(多租户、跑不可信代码),业界方案是 Kata Containers / gVisor:给每个容器配一个微型虚拟机或用户态内核替身------这时才回到"容器 vs 虚拟机"的对比:安全换代价。

隔离强度光谱:裸进程 < Docker 容器 < Kata/gVisor < 真虚拟机。

7.5 动手:亲手验证"被卸了武装的 root"

bash 复制代码
# 实验 1:容器里的 root,连"挂载"都干不了
docker run --rm -it alpine sh
whoami                  # root
mount /dev/sda1 /mnt    # mount: permission denied(are you root?)
# ↑ 你是 root,但 CAP_SYS_ADMIN 这张卡被扣下了------"缺胳膊少腿"实锤

# 实验 2:补齐一张卡,能力立刻回来(危险动作示范,看对比即可)
docker run --rm -it --cap-add=SYS_ADMIN alpine sh
mount /dev/sda1 /mnt    # 这次可以了------证明"能力=卡片",与身份无关

# 实验 3:默认允许的卡有哪些(约 14 张)
docker run --rm -it alpine sh -c 'cat /proc/self/status | grep CapEff'
# 对照 root 全集,明显少了一大截

# 实验 4:只读根文件系统(戒 3 的实操)
docker run --rm -it --read-only alpine sh
touch /test             # Read-only file system ------ 应用想乱写也写不了

8. 数据卷与 Docker Compose:补上最后两块拼图

8.1 为什么容器删了数据就没了

第 2.4 节拼房术的直接推论:容器里的一切修改都发生在可写层 (金色那层),而容器删除 = 可写层连同里面的一切消失,公共家具(镜像层)完好无损。所以:

  • 一次性任务(跑批处理、编译)→ 可写层随便用,删了正好;
  • 任何要活过容器死亡的业务数据(MySQL 的库、Redis 的 RDB、日志)→ 必须放在可写层之外。

把数据放出去的动作,就是挂载(1.3 学过:接枝,不是拷贝)。

8.2 三种挂载:volume、bind mount、tmpfs

volume(卷) bind mount(目录挂载) tmpfs
位置 Docker 管理(/var/lib/docker/volumes/) 你指定的宿主机路径 内存
谁说了算 Docker 帮你选、帮你管 你指哪接哪 Docker
跨平台/迁移 ✅ 干净(不依赖宿主路径) ❌ 依赖路径存在 ✅
生命周期 独立于容器,需手动删 跟随宿主机目录 容器停即消失
适用 生产持久化首选 开发时挂代码/配置 敏感临时数据

一句话选型:生产数据用 volume,开发时挂本地代码用 bind mount,密码类临时文件用 tmpfs。

8.3 动手:MySQL 的数据在容器死后活下来

bash 复制代码
# 1. 创建卷(相当于申请一个"外部储物柜")
docker volume create mysql-data

# 2. 把卷接到容器的数据目录上跑 MySQL
docker run -d --name mysql -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=root \
  -v mysql-data:/var/lib/mysql \
  mysql:8.0

# 3. 写点数据(进容器执行 SQL 建库建表插一行)
docker exec -it mysql mysql -uroot -proot \
  -e "CREATE DATABASE demo; USE demo; CREATE TABLE t(id INT); INSERT INTO t VALUES(1);"

# 4. 删掉容器------按 8.1,可写层没了
docker rm -f mysql

# 5. 换一个全新容器,接上同一个卷
docker run -d --name mysql2 -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=root \
  -v mysql-data:/var/lib/mysql \
  mysql:8.0
docker exec -it mysql2 mysql -uroot -proot -e "SELECT * FROM demo.t;"
# → 数据还在。容器死了,储物柜没死。

8.4 Docker Compose:一页 YAML 启动全家桶

单容器一条 docker run 就够;但一个应用 = MySQL + Redis + 应用本身三四个容器,还要网络互联、卷挂载、环境变量------靠手工敲 docker run 会变成一场灾难,且不可重复。

Docker Compose 的解法:把"所有容器怎么启动"写进一个 YAML 文件,一条命令全部拉起。它的角色定位:开发环境与单机部署的编排工具(多机集群的编排是 K8s,行业地图板块④)。

8.5 动手:Compose 一键起 MySQL + Redis + 应用

任意目录新建 docker-compose.yml:

yaml 复制代码
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: demo
    volumes:
      - mysql-data:/var/lib/mysql      # 卷(8.2 学的)
    networks:
      - appnet

  redis:
    image: redis:7-alpine
    networks:
      - appnet

  app:
    image: your-app:1.0               # 换成你的 Spring Boot 镜像
    ports:
      - "8080:8080"                   # 总机转接(6.2 学的)
    environment:
      DB_HOST: mysql                  # 直呼容器名------自定义网络的 DNS 通讯录
      REDIS_HOST: redis
    depends_on: [mysql, redis]
    networks:
      - appnet

volumes:
  mysql-data:
networks:
  appnet:
bash 复制代码
docker compose up -d        # 全家桶一键启动(新版命令;老版本 docker-compose up -d)
docker compose ps           # 看状态
docker compose logs -f app  # 盯日志
docker compose down         # 全停全删(卷默认保留,数据不丢)

注意 YAML 里的每一行都能对上前面各章:volumes → 本章 8.2 节、networks + 容器名互访 → 第 6.4 节、ports → 第 6.2 节的转接链。Compose 不是新魔法,它是把已学机制的"填表"自动化。


9. 总图、自测与下篇预告

9.1 全文收成一句话

一台 Linux 就是一家酒店:一个内核总管(被 CPU 工人们轮流执行)、一群进程客人、一棵文件树、一张 root 万能房卡。容器 = 办了三样特殊服务的普通客人------namespace 换眼睛、cgroups 记额度账、OverlayFS 拼样板房;dockerd = 1 对 n 的经办人;镜像 = 缺了内核的行李包(3 个差异压缩包 + 装箱单 + 说明书);网络 = 总机转接链;安全 = 三道闸门下的"卸了武装的 root"。

9.2 自测题(每题一句话,答案在文末)

  1. 容器里的应用调用 clone(CLONE_NEWPID)------这个系统调用是谁处理的?镜像里的 glibc 还是宿主机内核?
  2. "dockerd 自己住在一个 namespace 里,所以它也被隔离着"------对吗?
  3. cgroups 能限制"某容器最多占 10GB 磁盘空间"吗?它到底管什么?
  4. ENV TZ=Asia/Shanghai 这行 build 完也拍一张快照,docker history 里它是多少 B?为什么?
  5. 镜像 A(ubuntu+redis)先 pull,镜像 B(ubuntu+mysql)后 pull------B 的 ubuntu 30MB 会再下载吗?
  6. 两个容器各跑 nginx 都监听 80 端口互不冲突------这句话成立吗?为什么?
  7. RUN 下载200M && 安装 && 删除 写在同一条 RUN 里,镜像会小吗?拆成两条呢?
  8. 容器里的 root 和宿主机 root 差在哪?(提示:不是差"身份",是差"卡片")

9.3 下篇预告

本文解决的是单台机器 上的一切:容器怎么造、镜像怎么运、网络怎么通、安全怎么守。但"一个代办管一台机器"的天花板就是这台机器------50 台服务器、每台各一位代办,谁来回答"新服务放哪台?机器炸了上面的 30 个容器谁来迁移?"这就是 Kubernetes 登场的空位:它不是更强的 docker,而是站在 n 个本地代办之上的集团总部。


附录:术语速查表(酒店对照版)

术语 酒店版翻译 一句话本质
Linux 内核 酒店唯一的总管团队 唯一有权碰硬件的软件,被 CPU 核心轮流执行
进程 入住的客人 正在运行的程序实例,有个 PID
系统调用 前台电话 用户程序向内核请求服务的唯一通道
挂载 把货架接到树上某枝头 数据不动,换个门牌看同一堆数据
/proc、/sys 实时活页台账 内核把状态伪装成文件,读它=实时查询
root / UID 0 万能房卡 权限在内核逐次系统调用时验证
namespace 专属楼层 隔离"看得见什么",不是资源本身
cgroups 消费限额账本 记账+限额:CPU 工时/内存额度/IO 流速
OverlayFS 拼样板房 只读公共家具 + 可写私人物品叠成完整房间
copy_up 整件家具搬上楼 改下层文件必须先完整复制到可写层
whiteout 遮挡贴纸 "删除"=贴一张视而不见贴,空间不减反增
容器 办了三样特殊服务的普通客人 被三件套包裹的进程,共享宿主机内核
镜像 缺了内核的行李包 差异压缩包们 + manifest 装箱单 + config 说明书
dockerd 金牌代办 普通进程、常驻、1 对 n,没被隔离
containerd 大堂经理 行业通用的容器生命周期管理者,不姓 Docker
shim 楼层管家 每容器一位,替容器进程"当爹"
runc 装修队 调内核造容器,干完就走
OCI 插座标准 Runtime Spec + Image Spec,统一后厂商自由竞争
Registry 镜像超市 Docker Hub / Harbor,管存与取
veth / bridge / iptables 网线 / 交换机 / 总机接线员 容器网络三件套
capability root 的子卡 root 能力被拆成几十张,容器默认只发 ~14 张
seccomp 电话白名单 默认拦 300+ 高危系统调用

附:自测题参考答案

  1. 宿主机内核 。glibc 只是用户态"发起方";容器共享宿主内核,所以老内核跑新镜像会因缺失新系统调用而失败(典型报错 ENOSYS)。反向成立:老镜像跑新内核永远安全。
  2. 错。dockerd 是"经办人",普通进程,没有任何隔离待遇;被隔离的只有它办出来的容器。
  3. 不能。cgroups 管 CPU 工时、内存额度、磁盘 IO 流速;磁盘"容量"是存储的事。它的接口长得像文件(/sys/fs/cgroup),不等于功能是分磁盘。
  4. 0B。ENV 不碰文件树,只往 config.json 写元数据;快照"多出来的文件"为零。
  5. 不会。两层指纹相同 = 同一个包,本地已解压就跳过------和 .m2 里 guava 只下一遍同构(内容寻址)。
  6. 成立。各自独立的网络 namespace = 两套对讲系统里各有一个"80 号分机";对外靠 iptables 转接链(DNAT),容器没有宿主机真实端口。
  7. 同一条 RUN:真瘦(快照只看期末,下载物不在画面里)。拆两条:没瘦(200M 焊死进第一层快照,第二层只能贴遮挡贴)。
  8. 容器 root 默认只发 ~14 张 capability 卡,最危险的(改系统、动内核、装设备)全被扣下;再加 seccomp 拦截 300+ 高危系统调用。是"被卸了武装的 root",不是"另一个 root"。
相关推荐
南归北隐1 小时前
Spring AI Alibaba Graph框架实现MCP数据库查询实战
java·后端·spring ai·mcp·spring ai mcp
IT_陈寒1 小时前
刚解决一个 Java 并发问题,这坑踩得我怀疑人生
前端·人工智能·后端
小兔子2 小时前
Unix domain socket 的四个边界:抽象地址没有权限,SO_PEERCRED 是快照
后端
小兔子2 小时前
no-cache 不是「不缓存」:RFC 9111 里几句常被读反的话
后端
Vecter2 小时前
于 Spring Boot 和 LangChain4j 的 Web 智能助手
后端
张一凡932 小时前
把“不要乱改”从提示词里拿出来,放进可校验的提案里
前端·后端·架构
一只爱撸猫的程序猿2 小时前
当任何人都能生成一万行代码:什么变得稀缺
人工智能·后端·程序员
Potato_土豆2 小时前
MCP Server 接入多个 AI 客户端:配置差异与 10 个真实踩坑
人工智能·后端·ai编程
低调滴开发2 小时前
3 行代码写崩一个函数?切片才是 Rust 字符串处理的正确姿势
开发语言·后端·rust