Docker 知识手册
第一卷:软件哲学 - 知识的根基
导读:从一个问题开始
在深入技术细节之前,让我们先问一个根本问题:
为什么软件交付这么难?
这个问题的答案,将引导我们推导出整个 Docker 知识体系。
第 1 章:元问题 - 软件交付的困境
1.1 问题的本质
相信每个人都有这样的经历:
“在我机器上能跑,上线就报错。”
这背后是什么问题?
让我们从第一性原理思考。
什么是程序?从最本质的层面说,程序是状态的集合:
- 寄存器状态
- 内存状态
- 文件系统状态
- 环境变量状态
- 依赖库状态
程序的执行,就是状态转换的过程。用计算机科学的话说:
“Everything is a state machine.” —— 一切皆是状态机。
那么,”在我机器上能跑”意味着什么?
意味着你的开发环境的状态,和生产环境的状态不一样。
具体哪里不一样?
- 依赖库版本不同
- 配置文件不同
- 环境变量不同
- 操作系统版本不同
所以,同样的代码,在不同的状态下执行,结果自然不同。
1.2 解决方案的推导
既然问题是状态不一致,那么解决方案就清晰了:
我们需要提供一个标准化的状态机运行环境。
这个环境需要满足三个条件:
- 一致的初始状态 —— 不管在哪里运行,起始状态都一样
- 隔离的状态转换空间 —— 你的程序在一个沙箱里运行,不会干扰其他程序
- 可预测的资源边界 —— 你用多少资源是有限制的
这个标准化的状态机运行环境,就是容器。
形式化地说,容器可以定义为:
容器 = (镜像,Namespace 隔离,Cgroups 限制)
其中:
- 镜像提供一致的初始状态 s₀
- Namespace 提供隔离的状态转换空间
- Cgroups 提供可预测的资源边界
1.3 关键洞见
从这个推导中,我们得到第一个关键洞见:
容器不是新技术,而是标准化交付的工程实践。
它解决的是一个老问题:环境不一致导致的交付不可靠。
第 2 章:虚拟化演进 - 隔离技术的历史必然
2.1 问题的延伸
有了目标——创建标准化的状态机运行环境,接下来的问题是:
如何实现隔离?
让我们回顾历史,看看隔离技术是如何演进的。
2.2 第一阶段:物理机隔离
最早的隔离方式很直接:一台服务器跑一个应用。
物理机 1: 应用 A
物理机 2: 应用 B
物理机 3: 应用 C
优点:隔离性最好,完全物理隔离。
缺点:资源利用率不到 10%,成本太高。
2.3 第二阶段:软件模拟
既然物理机成本太高,那怎么降低成本?
一个思路是:用软件模拟硬件。
这就是 NEMU 等纯软件虚拟化方案。
物理机
└── 模拟层 (NEMU)
├── 虚拟机 1: 应用 A
├── 虚拟机 2: 应用 B
└── 虚拟机 3: 应用 C
优点:可以在一台物理机上跑多个应用。
缺点:性能开销太大,10 到 100 倍的开销,无法实用。
2.4 第三阶段:二进制翻译
怎么提升性能?
VMware 提出了一个关键优化:二进制翻译。
它的核心思想是区分快速路径和慢速路径:
Guest 指令执行流程:
↓
┌────┴────┐
│ 是用户态?│
└────┬────┘
Yes │ No
│ │
↓ ↓
┌────────┐ ┌────────────┐
│ Fast │ │ Slow Path │
│ Path │ │ 二进制翻译 │
│ 直接执行│ │ + 模拟 │
└────────┘ └────────────┘
什么意思呢?
应用程序 95% 的时间运行在用户态,这部分代码可以直接在宿主机 CPU 上执行——这叫快速路径。
只有 5% 的内核态代码需要翻译和模拟——这叫慢速路径。
这个优化把性能开销降到了 5% 到 20%。
2.5 第四阶段:硬件辅助虚拟化
但还有一个问题:为什么需要翻译?能不能让 CPU 直接支持虚拟化?
Intel 给出了答案:VT-x 硬件辅助虚拟化。
CPU 增加专门的虚拟化指令,性能开销降到 5% 以下。
2.6 第五阶段:操作系统自我虚拟化
但到这里,还有一个更根本的问题:
我们真的需要一个外部的 Hypervisor 层吗?
Linux 内核说:不需要。操作系统自己就能虚拟化自己。
这就是 Docker 的思路:利用 Linux 内核本身的 Namespace 机制,实现零开销的虚拟化。
Linux 内核
└── Namespace 隔离
├── 容器 1: 应用 A
├── 容器 2: 应用 B
└── 容器 3: 应用 C
2.7 演进总结
让我们回顾一下这个演进链条:
物理机隔离 → 软件模拟 → 二进制翻译 → 硬件辅助 → 操作系统自虚拟化
(100% 开销) (1000%) (5-20%) (<5%) (~0%)
每一次演进,都更接近硬件,性能损耗更低。
2.8 关键洞见
从这个演进中,我们得到第二个关键洞见:
虚拟化的本质是创建独立的状态转换空间。
Docker 的历史贡献是什么?
它没有发明新技术——Namespace、Cgroups、UnionFS 都是老技术。
它做的是:找到了最佳抽象层次,把 20 年的 Linux 内核机制,包装成了开发者友好的标准化产品。
这就是 Docker 的成功公式:
成功 = 技术成熟度 × 市场需求 × 用户体验
第 3 章:抽象的权衡
3.1 问题的延伸
有了虚拟化技术,还有一个问题:
为什么是容器而不是其他?
让我们看看各种抽象层次的权衡。
3.2 抽象层次对比
物理机 → 虚拟机 → 容器 → Pod → Function
↓ ↓ ↓ ↓ ↓
硬件 操作系统 进程 进程组 代码片段
3.3 各层次的特点
| 抽象层次 | 隔离性 | 性能 | 灵活性 | 适用场景 |
|---|---|---|---|---|
| 物理机 | 最好 | 原生 | 最低 | 高安全需求 |
| 虚拟机 | 好 | 95%+ | 中 | 多操作系统 |
| 容器 | 足够 | 原生 | 高 | 微服务 |
| Pod | 中等 | 原生 | 高 | Kubernetes |
| Function | 低 | 原生 | 最高 | 事件驱动 |
3.4 关键洞见
从这个对比中,我们得到第三个关键洞见:
好的工程不是追求最先进,而是找到最适合当前问题的抽象层次。
容器的成功,不是因为它最先进,而是因为它找到了最佳平衡点:
- 隔离性足够好
- 性能损耗几乎为零
- 灵活性高,适合微服务
第二卷:核心概念 - 从哲学到技术
导读
有了哲学基础,现在我们来推导三个核心概念。
回忆一下容器的定义:容器是标准化的状态机运行环境。
那么,这个环境怎么来?怎么运行?怎么分享?
对应三个概念:镜像、容器、仓库。
第 4 章:镜像 - 状态的快照
4.1 问题的提出
状态机需要一致的初始状态 s₀。这个初始状态从哪来?
答案是:从镜像来。
4.2 镜像的定义
镜像是什么?
简单说,就是状态的只读快照。它包含了应用运行所需的一切:
- 代码
- 运行时
- 库
- 环境变量
- 配置文件
4.3 为什么分层?
想象一下,如果每个镜像都存一份完整的副本,那得多大?
比如,你有 100 个镜像都基于 Ubuntu,难道要存 100 份 Ubuntu 吗?
显然不是。
答案是:共享相同的部分。这就是镜像分层。
镜像 C
└── 层 3: 应用代码
└── 层 2: 运行时
└── 层 1: Ubuntu 基础镜像
镜像 B
└── 层 2': 不同运行时
└── 层 1: Ubuntu 基础镜像 (共享)
镜像 A
└── 层 1': 不同基础镜像
分层的好处有三点:
- 减少存储 —— 相同层只存一份
- 加速构建 —— 复用未变化的层,只重建变化的层
- 节省带宽 —— 传输时只传变化的层
4.4 关键命令
# 查看镜像分层
docker history nginx:latest
# 构建镜像
docker build -t myapp:v1 .
# 查看本地镜像
docker images
第 5 章:容器 - 状态的运行
5.1 问题的提出
有了初始状态,接下来要运行。
运行需要什么?
答案是:隔离的状态转换空间。
5.2 容器的定义
容器是什么?
简单说,就是镜像的运行实例。
如果镜像是光盘,容器就是用这张光盘启动的计算机。
镜像和容器的关系,就是类和对象的关系:
- 镜像是静态的定义
- 容器是动态的运行
5.3 容器的生命周期
docker build → 镜像
↓
docker run → 容器 (运行中)
↓
docker stop → 容器 (已停止)
↓
docker rm → 容器 (已删除)
注意:容器删除后,镜像仍然存在。这就是为什么镜像可以复用。
5.4 关键命令
# 启动容器
docker run -d -p 8080:80 --name myapp myapp:v1
# 查看运行中的容器
docker ps
# 查看所有容器(包括已停止)
docker ps -a
# 停止容器
docker stop myapp
# 删除容器
docker rm myapp
第 6 章:仓库 - 状态的分发
6.1 问题的提出
有了镜像,怎么分享?怎么管理大量镜像?
答案是:仓库。
6.2 仓库的定义
仓库是什么?
简单说,镜像的存储和分发中心。
最常用的是 Docker Hub,你也可以搭建私有仓库。
6.3 工作流程
开发者 → docker push → 仓库
↓
docker pull
↓
其他开发者
6.4 关键命令
# 从仓库拉取镜像
docker pull nginx:latest
# 推送镜像到仓库
docker push myrepo/myapp:v1
# 登录仓库
docker login
第三卷:内核机制 - 技术根基
导读
有了核心概念,现在深入技术底层。
前面说了,容器需要隔离的状态转换空间。那隔离到底是怎么实现的?
答案是:Namespace、Cgroups、UnionFS。
这三大技术是 Docker 的根基。
第 7 章:Namespace - 隔离的实现
7.1 问题的提出
隔离到底是怎么实现的?
答案是:Namespace。
7.2 Namespace 的定义
什么是 Namespace?
简单说,它就是 Linux 内核提供的一种”限制视野”的机制。
想象你在一个房间里,你只能看到房间里的东西。Namespace 就是这个房间——它不创建新东西,只是限制你能看到什么。
7.3 六种 Namespace
Linux 提供了六种 Namespace,每种限制一种视野:
| Namespace | 隔离对象 | 系统调用标志 | 效果 |
|---|---|---|---|
| PID | 进程号 | CLONE_NEWPID | 容器内进程号从 1 开始 |
| NET | 网络设备 | CLONE_NEWNET | 独立网卡、路由表、端口空间 |
| MNT | 挂载点 | CLONE_NEWNS | 独立文件系统 |
| UTS | 主机名 | CLONE_NEWUTS | 独立主机名 |
| IPC | 信号量 | CLONE_NEWIPC | 独立 IPC 资源 |
| USER | 用户/组 | CLONE_NEWUSER | 容器内 root 在宿主机是普通用户 |
7.4 常见问题
问:容器内进程号和宿主机上的一样吗?
答:不一样。容器内的 PID 1,在宿主机上是一个普通的进程,有自己的进程号。这就是 PID Namespace 的隔离效果。
7.5 关键命令
# 查看容器的 Namespace
ls -l /proc/<pid>/ns/
# 进入容器的 Namespace
docker exec -it <container> bash
# 查看进程号
docker top <container>
7.6 关键洞见
隔离不是”创建新东西”,而是”限制视野”。
每个 Namespace 都是对进程视野的限制。
第 8 章:Cgroups - 资源的限制
8.1 问题的提出
有了 Namespace,隔离问题解决了。但还有一个问题:
只有隔离够吗?
想象一下:一个容器虽然看不到其他容器的资源,但它可以耗尽所有资源。比如,一个容器用光了所有内存,其他容器就都没法运行了。
那怎么防止?
答案是:资源限制。
8.2 Cgroups 的定义
这就是 Cgroups,中文叫控制组。
Namespace 负责隔离,Cgroups 负责限制资源使用。
隔离保证”看不到”,限制保证”用不超”。两者结合才是完整的虚拟化。
8.3 控制的资源
Cgroups 可以控制四种主要资源:
| 资源 | 说明 | 命令 |
|---|---|---|
| memory | 内存上限 | --memory="512m" |
| cpu | CPU 配额 | --cpus="1.0" |
| blkio | 块设备 IO | --device-read-bps |
| pids | 最大进程数 | --pids-limit=100 |
8.4 生产环境配置
docker run -d \
--memory="512m" \
--memory-swap="512m" \
--cpus="1.0" \
--pids-limit=100 \
--oom-kill-disable \
myapp
8.5 警告
生产环境红线:必须配置内存限制!
为什么?因为如果不配置内存限制,一个有内存泄漏的容器可能会耗尽宿主机所有内存,导致整个系统崩溃。
8.6 关键洞见
隔离保证”看不到”,限制保证”用不超”。
第 9 章:UnionFS - 分层存储
9.1 问题的提出
有了隔离和限制,容器可以安全运行了。但还有一个问题:
如何高效存储镜像?
想象一下,如果每个镜像都存一份完整的副本,那得多大?
显然不是。
答案是:共享相同的部分。这就是 UnionFS,联合文件系统。
9.2 UnionFS 的定义
Docker 默认使用 Overlay2 驱动,它的工作原理是这样的:
LowerDir 1 (只读) ← Ubuntu 基础镜像
↓
LowerDir 2 (只读) ← Python 运行时
↓
LowerDir 3 (只读) ← 应用代码
↓
UpperDir (可写) ← 容器运行时数据
↓
MergedDir (联合挂载) ← 容器看到的完整文件系统
9.3 写时复制(COW)
还有一个关键机制:写时复制(Copy-On-Write)。
什么意思呢?当容器需要修改一个只读层的文件时,Overlay2 会先把这个文件复制到可写层,然后在可写层修改。原文件保持不变。
只读层:file.txt (内容:A)
↓
容器要修改 file.txt
↓
可写层:file.txt (内容:B) ← 复制后修改
只读层:file.txt (内容:A) ← 保持不变
9.4 关键洞见
分层复用是效率的关键,写时复制是性能的核心。
第四卷:运行时 - 工程实践
导读
有了内核机制,容器可以运行了。现在我们来考虑实际问题:
- 数据怎么持久化?
- 容器之间怎么通信?
- 镜像怎么高效构建?
第 10 章:存储 - 数据的持久化
10.1 问题的提出
容器销毁后,数据怎么办?
容器的可写层有一个特点:它随容器销毁而丢失。
当你删除容器时,可写层的所有数据都会消失。
那如果数据很重要呢?比如数据库的数据,不能丢啊。
答案是:持久化存储。
10.2 存储方案对比
Docker 提供了三种存储方案:
| 类型 | 管理方式 | 性能 | 生命周期 | 适用场景 |
|---|---|---|---|---|
| 容器可写层 | Docker | 低 | 随容器 | 临时数据 |
| Volume | Docker | 高 | 独立 | 数据库数据 |
| Bind Mount | 用户 | 高 | 独立 | 配置文件、开发 |
10.3 Volume 数据卷
Volume 由 Docker 管理,数据存储在宿主机的 /var/lib/docker/volumes/ 目录下。
Volume 有三大优势:
- 独立的生命周期 —— Volume 不随容器销毁而消失
- 高性能 —— 直接写入宿主机文件系统
- 支持备份、迁移和共享
操作示例:
# 创建 Volume
docker volume create mysql-data
# 挂载 Volume
docker run -v mysql-data:/var/lib/mysql mysql:8
10.4 架构师箴言
严禁在容器可写层存储持久化数据!
什么是持久化数据?数据库的数据、用户上传的文件、日志等,这些都是需要长期保存的数据,不能放在容器可写层。
生产环境红线:数据库必须挂载 Volume!
第 11 章:网络 - 容器的通信
11.1 问题的提出
容器之间如何通信?
刚才讲了,每个容器有独立的 NET Namespace,也就是独立的网络环境。
那问题就来了:独立的网络意味着容器之间无法直接通信。这怎么办?
答案是:网桥。
11.2 Bridge 网络
Docker 默认使用 bridge 网络模式。工作原理是这样的:
容器 A (172.17.0.2) 容器 B (172.17.0.3)
│ │
└──── docker0 桥 ─────────┘
│
└──── iptables NAT ──── 外部访问
Docker 在宿主机上创建一个虚拟网桥,叫 docker0。所有容器都连接到这个网桥。
容器 A 发送数据给容器 B,数据先发到 docker0 网桥,然后网桥转发给容器 B。
11.3 服务发现
但还有一个问题:容器怎么知道对方的 IP 地址?
Docker 内置了一个 DNS 服务器,地址是 127.0.0.11。
当你创建容器时,可以给它指定一个名称,比如 app1、app2。
容器 A 要访问容器 B,直接用名称就行,比如 ping app2。Docker 的 DNS 服务器会把名称解析成 IP 地址。
11.4 端口映射
但还有一个问题:容器怎么对外提供服务?
答案是:端口映射。
docker run -d -p 8080:80 nginx
这条命令的意思是:把宿主机的 8080 端口映射到容器的 80 端口。外部访问宿主机的 8080 端口,请求会被转发到容器的 80 端口。
11.5 安全建议
生产环境红线:限制端口绑定到特定 IP!
# 绑定到特定 IP
docker run -d -p 127.0.0.1:8080:80 nginx
11.6 关键命令
# 创建网络
docker network create mynet
# 连接容器
docker run --net=mynet --name=app1 app
# 查看网络
docker network ls
docker network inspect mynet
第 12 章:镜像工程 - 高效构建
12.1 问题的提出
如何构建高质量的镜像?
刚才讲了镜像分层的好处,那怎么利用这个特性呢?
12.2 分层构建
第一个最佳实践:分层构建。
Dockerfile 的每一条指令都会生成一个层,合理的层顺序可以大幅加速构建。
原则是:把少变化的层放在前面,把常变化的层放在后面。
示例:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
这个 Dockerfile 先把 package.json 复制进去,然后安装依赖,最后才复制应用代码。
这样做的好处是:当你只修改代码时,Docker 可以复用之前安装依赖的层,大幅加速构建。
12.3 合并命令
第二个最佳实践:合并命令,减少层数。
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git curl wget && \
rm -rf /var/lib/apt/lists/*
这个命令把更新软件源、安装包和清理缓存合并成一层,避免产生多余的层,同时也减小了镜像体积。
12.4 多阶段构建
但还有一个问题:编译型语言怎么办?比如 Go、C++,编译完需要编译器,但运行时不需要啊。
答案是:多阶段构建。
基本思路是:用一个大镜像编译,用一个小镜像运行。
示例:
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
FROM alpine:3.19
COPY --from=builder /app/myapp .
CMD ["./myapp"]
第一阶段使用 Go 镜像进行编译,这个镜像有 900MB。
第二阶段使用 Alpine 镜像运行,这个镜像只有 5MB。通过 COPY --from=builder 把编译好的程序复制过来。
最终结果:镜像从 900MB 减小到 15MB,减少了 98%。
生产环境红线:使用多阶段构建减小镜像!
12.5 .dockerignore
但还有一个问题:构建上下文怎么处理?
当你运行 docker build 时,Docker 会把当前目录的所有文件发送给守护进程。如果目录很大,传输会很慢。
那怎么优化?
答案是:.dockerignore。
必排除项:
- 依赖目录(node_modules、pycache、vendor)
- 版本控制目录(.git、.svn)
- 构建产物(dist、build、*.pyc)
- 敏感文件(.env、secrets.、.pem)
生产环境红线:使用.dockerignore 排除敏感文件!
第五卷:工程实践 - 生产就绪
导读
有了前面的一切,容器可以运行了。但还有两个问题:
- 容器安全吗?
- 如何知道容器运行状态?
第 13 章:安全加固
13.1 问题的提出
容器安全吗?
答案是:容器不是完全安全的。
Namespace 和 Cgroups 提供了隔离,但这种隔离不是绝对的。容器逃逸漏洞时有发生。
那怎么增强安全?
答案是:纵深防御。
13.2 纵深防御体系
纵深防御的核心思想是:层层叠加安全措施,不依赖单一防线。
第 1 层:镜像安全
├─ 基础镜像扫描
├─ 多阶段构建
└─ 最小化安装
第 2 层:运行时安全
├─ 非 root 用户
├─ Capabilities 限制
└─ 只读文件系统
第 3 层:网络安全
├─ 网络隔离
├─ 端口限制
└─ mTLS 加密
第 4 层:监控响应
├─ 异常检测
├─ 审计日志
└─ 自动阻断
13.3 核心实践
实践 1:使用非 root 用户运行容器。
USER appuser
这样即使容器被攻破,攻击者也没有 root 权限。
实践 2:限制 Capabilities。
docker run --cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--cap-add=CHOWN \
myapp
删除所有权限,按需添加。
实践 3:避免使用特权模式。
# 禁止使用(生产环境红线)
docker run --privileged app
–privileged 参数会给容器所有权限,相当于 root。生产环境绝对禁止使用!
13.4 关键洞见
安全是层层叠加的,最小权限是核心原则。
第 14 章:可观测性
14.1 问题的提出
如何知道容器运行状态?
答案是:监控。
不可观测的系统是不可维护的。监控是运维的眼睛。
14.2 监控的三个方面
监控什么?三个方面:日志、指标、事件。
日志管理:
# 配置日志轮转
docker run --log-opt max-size=10m --log-opt max-file=3 myapp
# 查看日志
docker logs -f --tail=100 <container>
生产环境红线:必须配置日志轮转!
指标监控:
# 实时查看资源使用
docker stats
# 单次输出
docker stats --no-stream
关键指标和告警阈值:
| 指标 | 期望值 | 告警阈值 |
|---|---|---|
| CPU 使用率 | <70% | >90% |
| 内存使用率 | <80% | >95% |
| 磁盘使用率 | <70% | >85% |
事件审计:
# 查看容器事件
docker events --filter 'type=container'
第 15 章:故障排查
15.1 问题的提出
出问题了怎么办?
15.2 排查流程
问题报告
↓
1. 确认现象(复现问题)
↓
2. 检查状态(docker ps/inspect)
↓
3. 查看日志(docker logs)
↓
4. 分析指标(docker stats)
↓
5. 定位根因
↓
6. 实施修复
15.3 常见问题速查
| 问题 | 检查命令 | 期望值 |
|---|---|---|
| 容器退出 | docker inspect ExitCode |
0 |
| OOM Kill | ExitCode | 非 137 |
| 网络不通 | docker exec ping |
通 |
| 磁盘不足 | docker system df |
<80% |
| 端口冲突 | netstat -tlnp |
端口空闲 |
15.4 退出码含义
| ExitCode | 含义 | 处理 |
|---|---|---|
| 0 | 正常退出 | - |
| 1 | 应用错误 | 查看日志 |
| 137 | OOM Kill | 增加内存 |
| 143 | SIGTERM | 优雅关闭超时 |
| 125 | Docker 错误 | 检查命令 |
15.5 关键洞见
排查是系统性工程,预防胜于治疗。
附录:生产环境红线清单
以下是生产环境的八条红线,请务必遵守:
-
✅ 必须配置内存限制,防止内存泄漏耗尽宿主机资源。
-
✅ 数据库必须挂载 Volume,防止数据丢失。
-
✅ 避免使用 –privileged,防止容器获得过高权限。
-
✅ 限制端口绑定到特定 IP,增强网络安全。
-
✅ 配置日志轮转,防止日志耗尽磁盘空间。
-
✅ 使用具体版本号而非 latest,确保镜像可追溯。
-
✅ 使用多阶段构建减小镜像,减小攻击面。
-
✅ 使用.dockerignore 排除敏感文件,防止机密泄露。
附录:核心命令速查
# 架构检查
docker version
docker info | grep Storage
# 镜像操作
docker build -t myapp:v1 .
docker images
docker history nginx:latest
docker save -o image.tar image:tag
docker load -i image.tar
# 容器操作
docker run -d -p 8080:80 --name app myapp
docker ps -a
docker logs -f app
docker exec -it app bash
docker stop app
docker rm app
# 资源限制
docker run --memory="512m" --cpus="1.0" nginx
# 故障排查
docker inspect <container>
docker stats --no-stream
docker system df