View on GitHub

我的极简博客

记录学习与生活

Docker 知识手册


第一卷:软件哲学 - 知识的根基

导读:从一个问题开始

在深入技术细节之前,让我们先问一个根本问题:

为什么软件交付这么难?

这个问题的答案,将引导我们推导出整个 Docker 知识体系。


第 1 章:元问题 - 软件交付的困境

1.1 问题的本质

相信每个人都有这样的经历:

“在我机器上能跑,上线就报错。”

这背后是什么问题?

让我们从第一性原理思考。

什么是程序?从最本质的层面说,程序是状态的集合:

程序的执行,就是状态转换的过程。用计算机科学的话说:

“Everything is a state machine.” —— 一切皆是状态机。

那么,”在我机器上能跑”意味着什么?

意味着你的开发环境的状态,和生产环境的状态不一样。

具体哪里不一样?

所以,同样的代码,在不同的状态下执行,结果自然不同。

1.2 解决方案的推导

既然问题是状态不一致,那么解决方案就清晰了:

我们需要提供一个标准化的状态机运行环境。

这个环境需要满足三个条件:

  1. 一致的初始状态 —— 不管在哪里运行,起始状态都一样
  2. 隔离的状态转换空间 —— 你的程序在一个沙箱里运行,不会干扰其他程序
  3. 可预测的资源边界 —— 你用多少资源是有限制的

这个标准化的状态机运行环境,就是容器

形式化地说,容器可以定义为:

容器 = (镜像,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': 不同基础镜像

分层的好处有三点:

  1. 减少存储 —— 相同层只存一份
  2. 加速构建 —— 复用未变化的层,只重建变化的层
  3. 节省带宽 —— 传输时只传变化的层

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 有三大优势:

  1. 独立的生命周期 —— Volume 不随容器销毁而消失
  2. 高性能 —— 直接写入宿主机文件系统
  3. 支持备份、迁移和共享

操作示例:

# 创建 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。

必排除项:

生产环境红线:使用.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 关键洞见

排查是系统性工程,预防胜于治疗。


附录:生产环境红线清单

以下是生产环境的八条红线,请务必遵守:

  1. 必须配置内存限制,防止内存泄漏耗尽宿主机资源。

  2. 数据库必须挂载 Volume,防止数据丢失。

  3. 避免使用 –privileged,防止容器获得过高权限。

  4. 限制端口绑定到特定 IP,增强网络安全。

  5. 配置日志轮转,防止日志耗尽磁盘空间。

  6. 使用具体版本号而非 latest,确保镜像可追溯。

  7. 使用多阶段构建减小镜像,减小攻击面。

  8. 使用.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