容器:隔离是手段,交付是目的
一、引言
一个开发者在本地调通了服务,推到测试环境崩了。排查三小时,发现是 Python 版本差了一个小版本,某个依赖的行为有细微不同。修好推上去,生产又崩。这次是 glibc 版本不一致。
这不是个案,是软件交付的常态矛盾:代码是确定的,但环境是历史的产物。每台服务器都带着自己的历史——不同的人在不同时间装了不同的东西,没有人能完整重现这台机器的状态。
Docker 在 2013 年给出了一个系统性的回答。但很多人误解了这个回答的本质。
二、为什么:根本矛盾是不可重复
配置漂移的真实代价
传统运维的标准解法是文档和规范:“所有服务器必须装 Python 3.9.7……”。文档会过时,人会犯错,规范执行不统一。结果是配置漂移(configuration drift):随着时间推移,本该一致的机器悄悄变得不同,而没有人知道。
问题的本质不是权限管理松散,也不是运维不认真。是可变的基础设施在时间轴上必然积累无法追溯的历史状态。你可以记录”昨天做了什么”,但你无法记录”今天的状态是怎么从两年前一步步演变过来的”。每一次手动操作都是往系统里写入了无法被完整还原的状态变更。
VM 为什么没有根治这个问题
虚拟机(VM)是第一次系统性的回答:把整个操作系统打包,保证环境一致。但 VM 解决的是”初始环境的一致性”,没有解决”交付之后的环境演变”。
你把一个 VM 镜像交给运维,运维登录进去装了几个调试工具,改了一个配置文件——这个 VM 已经不再是你打包时的那个环境了。状态漂移只是被推迟,没有被消灭。
而且 VM 太重:启动需要分钟级,一台物理机跑几十个 VM 就到上限,镜像动辄几 GB,分发成本高。
Docker 的真正创新是镜像,不是隔离
Linux 的 namespace 和 cgroups 早在 2008 年前后就有了,LXC 在 Docker 出现前已经用这套机制做容器。Docker 没有发明容器技术,它发明了镜像。
Docker 的镜像是内容寻址的不可变制品:一旦构建完成,任何机器拉取同一个 image digest,得到的是完全相同的文件系统层叠加。没有历史,没有漂移,没有”我以为和你那台一样”。
FROM python:3.9.7-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . /app
CMD ["python", "/app/main.py"]这不是安装脚本,是环境的规格说明书。每次构建都从相同的起点出发,经过相同的步骤,产出内容哈希相同的镜像。交付的不是代码,是代码加运行所需的完整环境的整体快照。
这就是”隔离是手段,交付是目的”的含义:namespace 和 cgroups 解决运行时的边界问题,是手段;镜像解决可重复交付问题,才是目的。
三、怎么设计:三层机制
Docker 的底层由三层机制构成,各自解决不同的问题:namespace 做隔离,cgroups 做限制,OCI 镜像做打包。
namespace——六维隔离,让进程看到”裁剪过的世界”
Namespace 是 Linux 内核提供的一种视图隔离机制:同一个内核,给不同的进程看到不同的”世界”。容器不是独立的操作系统,而是一组被精心裁剪了视野的进程。
Linux 提供六种 namespace,各自隔离一个维度:
| Namespace | 隔离的是 | 哲学意义 |
|---|---|---|
| pid | 进程号空间 | 容器内的 pid 1 不是宿主机的 pid 1——每个容器有自己的进程树 |
| net | 网络栈 | 独立的网卡、IP、路由表——容器的网络是虚构的 |
| mnt | 挂载点视图 | 决定”这个进程能看到哪些文件系统”——镜像的根文件系统从这里挂入 |
| uts | 主机名 / 域名 | 容器可以有自己的 hostname,而不影响宿主机 |
| ipc | 进程间通信 | 信号量、消息队列——容器之间默认不能用这些通信 |
| user | 用户和组 ID | 容器内的 root 可以映射到宿主机的普通用户——这是 rootless 容器的基础 |
核心判断:这六种隔离都是视图级别的——内核只有一个,它只是给不同进程看到不同的切片。隔离不是绝对的,是一组可以被撤销、可以被穿透的内核约束。容器的安全边界,本质上依赖内核的完整性。
cgroups——资源不是无限的
Namespace 解决了”看到什么”,cgroups(control groups)解决了”能用多少”。没有 cgroups,一个容器可以把宿主机的 CPU 和内存吃光,影响同机所有其他容器。
cgroups 把进程分组,对每个组施加资源限制:
- cpu:这组进程最多用多少 CPU 时间(
cpu.shares软限制,cpu.cfs_quota_us硬限制) - memory:内存上限。超过触发 OOM killer——内核会杀掉组内的进程,退出码 137
- blkio:磁盘 I/O 的带宽限制
cgroups 和 namespace 的组合,使容器得以在共享内核的前提下,对同机其他租户做到”视图隔离 + 资源隔离”。这和 VM 的根本差异在于:VM 用 hypervisor 在硬件层做了真正的隔离,容器没有。
OCI 镜像——层模型、内容寻址、运行时标准化
UnionFS 与 OverlayFS:镜像是分层文件系统的叠加。每一条 Dockerfile 指令产出一层(layer),层与层之间是只读的,运行时在最顶层加一个可写层。多个容器可以共享底层的只读层,只有最上层的写时复制(copy-on-write)是独属于每个容器实例的。
flowchart TB W["可写层 ← 容器运行时的修改落在这里,容器删除后消失"]:::write L4["COPY . /app ← 只读层"]:::ro L3["RUN pip install ← 只读层"]:::ro L2["COPY req.txt ← 只读层"]:::ro L1["python:3.9.7 ← 只读层,来自基础镜像"]:::ro W --- L4 --- L3 --- L2 --- L1 classDef write fill:#fef3c7,stroke:#f59e0b classDef ro fill:#e0f2fe,stroke:#0284c7
内容寻址:每一层的身份是它内容的 SHA256 哈希。镜像的 digest 是所有层哈希树的根哈希。两个 digest 相同的镜像,内容必然相同,不可能被篡改而哈希不变。这是镜像不可变性的密码学基础。
OCI 标准化:Open Container Initiative 把镜像格式和运行时接口标准化,容器从 Docker 的私有实现变成了开放规范。运行时从 Docker Engine 分离出 containerd,再有 CRI-O——Kubernetes 通过 CRI(Container Runtime Interface)接入任意符合 OCI 标准的运行时。标准化的意义是:镜像成为基础设施的通用货币,任何符合规范的工具都可以构建、分发、运行它。
四、上手:写一个容器,把它跑起来
写第一个 Dockerfile
一个 Dockerfile 由三部分构成:起点(FROM)、构建过程(RUN / COPY)、启动命令(CMD / ENTRYPOINT)。
# 起点:选一个官方基础镜像
FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
# 先复制依赖文件(利用层缓存,后面会解释为什么这样排)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 再复制业务代码
COPY . .
# 容器启动时执行的命令
CMD ["python", "main.py"]选基础镜像的原则:
python:3.11是完整 Debian,体积大但依赖齐全,适合开发阶段python:3.11-slim裁剪了非必要包,体积缩小约 60%,适合大多数生产场景python:3.11-alpine基于 musl libc,体积最小,但某些 C 扩展可能编译失败
构建与运行
# 构建镜像,-t 指定名字和标签
docker build -t myapp:v1 .
# 查看构建结果
docker images myapp
# 运行容器
docker run \
-d \ # 后台运行
--name myapp \ # 给容器起名
-p 8080:80 \ # 宿主机 8080 → 容器 80
-e APP_ENV=production \ # 注入环境变量
-v /host/data:/app/data \ # 挂载数据卷
myapp:v1
# 查看运行状态
docker ps
docker stats myapp # 实时资源占用核心参数:-p 是端口映射(宿主机端口:容器端口),-e 注入环境变量,-v 挂载宿主机目录到容器内,-d 让容器在后台运行。
进去看看:exec / logs / inspect 三件套
容器跑起来之后,排查的三个基本工具:
# 查看日志(最常用)
docker logs myapp # 输出所有日志
docker logs -f myapp # 实时追踪日志
docker logs --tail 100 myapp # 只看最后 100 行
# 进入容器执行命令(容器必须在运行中)
docker exec -it myapp /bin/sh # 交互式 shell
docker exec myapp env # 查看容器内环境变量
docker exec myapp cat /etc/hosts
# 查看容器的详细配置与状态
docker inspect myapp
docker inspect myapp | grep -i "ipaddress\|ports\|mounts"docker inspect 输出的是完整的容器配置 JSON,包括网络 IP、挂载点、环境变量、重启次数等——排查问题时是最完整的第一手资料。
推到 Registry
# 本地镜像打标签(格式:registry/namespace/name:tag)
docker tag myapp:v1 registry.example.com/team/myapp:v1
# 登录私有 Registry
docker login registry.example.com
# 推送
docker push registry.example.com/team/myapp:v1
# 在另一台机器上拉取
docker pull registry.example.com/team/myapp:v1公共 Registry 用 Docker Hub(docker.io),私有 Registry 常见选择是 Harbor(自托管)或云厂商的 ACR / ECR / GCR。
五、走向生产:让容器跑得好
Dockerfile 优化:层缓存策略与多阶段构建
层缓存策略:Docker 构建时,一旦某一层的内容发生变化,其后所有层的缓存全部失效。所以规则是:把变化频率低的层放前面,把变化频率高的层放后面。
# 错误示范:代码层在依赖层之前
COPY . /app # 改任何一行业务代码,下面的 pip install 都要重跑
RUN pip install -r /app/requirements.txt
# 正确示范:依赖层与代码层分离
COPY requirements.txt . # 只有 requirements.txt 变了才重跑 pip install
RUN pip install -r requirements.txt
COPY . /app # 业务代码改动只影响这一层及之后多阶段构建:把构建环境和运行环境分离,避免把编译工具链、测试依赖打进最终镜像。
# 构建阶段:使用完整的构建环境
FROM golang:1.22 AS builder
WORKDIR /build
COPY . .
RUN go build -o app .
# 运行阶段:只把编译产物复制进来
FROM gcr.io/distroless/static
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]Go 应用从 ~900MB(带编译环境)缩减到 ~10MB(只有静态二进制 + distroless 基础层)。
镜像瘦身原则:
- 用
--no-cache-dir(pip)/--no-install-recommends(apt)避免缓存和可选包 - 在同一个
RUN指令里完成安装和清理(分开会产生额外层) - 优先用
distroless或alpine作为运行时基础镜像
运行配置:资源限制、健康检查、非 root 用户
这三项是生产容器的最低配置要求,缺一不可。
资源限制:没有限制的容器可以悄悄把同节点所有容器拖死。
docker run \
--memory 512m \ # 内存硬限制,超过触发 OOM kill
--memory-reservation 256m \ # 内存软限制,内存紧张时优先收缩
--cpus 0.5 \ # 最多使用 0.5 个 CPU 核
myapp:v1健康检查:让 Docker 知道容器内的应用是否真正就绪,而不只是进程存活。
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1健康检查失败的容器会被标记为 unhealthy——在 Kubernetes 里这会触发 readinessProbe 失败,停止向该容器发送流量。
非 root 用户:默认容器内进程以 root 运行,一旦发生容器逃逸,攻击者直接获得宿主机 root 权限。
# 创建专用用户
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
# 切换到非 root 用户
USER appuser本地多容器:docker compose 的定位与边界
一个应用通常不只有一个容器——还有数据库、缓存、消息队列。docker compose 是本地开发和测试阶段的多容器编排工具。
# compose.yaml
services:
app:
build: .
ports:
- "8080:80"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/mydb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
retries: 5
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:docker compose up -d # 启动所有服务
docker compose logs -f app # 查看某个服务的日志
docker compose down # 停止并清理
docker compose down -v # 同时清理数据卷compose 和 Kubernetes 的边界:compose 是本地开发工具,不具备跨节点调度、自愈、滚动更新等能力。生产环境的多容器编排是 Kubernetes 的职责——02-Kubernetes-声明意图,控制器维持世界。
六、故障剧场:亲手把它弄坏
理解容器安全边界的最好方式,不是读文档,是亲手把它弄坏。
实验一:用 unshare 手搓一个 namespace
# 创建新的 pid + mount + net namespace,在里面启动 shell
sudo unshare --pid --mount --net --fork --mount-proc /bin/bash
# 在这个 shell 里:
ps aux # 只看到两个进程:bash 和 ps
ip addr # 只有 loopback,没有 eth0
hostname new-world
exit
# 回到宿主机
hostname # 没变——uts namespace 没有被修改你刚刚手动做了 docker run 时自动做的事。感受边界在哪:exit 之后,那个”隔离的世界”就消失了。Namespace 不是持久化的实体,是挂在进程上的视图——进程退出,视图消失。
实验二:--privileged 让隔离消失
# 普通容器
docker run --rm alpine cat /proc/1/status | grep CapEff
# CapEff: 00000000a80425fb ← 有限的 capability
# privileged 容器
docker run --rm --privileged alpine cat /proc/1/status | grep CapEff
# CapEff: 0000003fffffffff ← 几乎全部 capability--privileged 把 Linux capability 全部还给容器内的进程。容器内的 root 和宿主机的 root 几乎等价——namespace 的墙还在,但你拿到了翻墙的梯子。大多数容器逃逸不是内核漏洞,而是 --privileged、--cap-add SYS_ADMIN、挂载 /var/run/docker.sock 这类配置错误导致的。隔离不是一个开关,是一组可以被一条参数撤销的约束。
实验三:cgroup OOM kill
docker run --rm -m 50m python:3.11-slim python -c "
x = []
while True:
x.append(' ' * 1024 * 1024)
print(f'allocated {len(x)}MB')
"
# 分配到约 50MB 时,进程被 OOM killer 杀掉
# 容器退出码 137 = 128 + 9(SIGKILL)资源限制不是建议,是内核级别的硬约束——超出就死,没有宽限,没有商量余地。生产环境不设 --memory 的容器,是在赌同节点其他容器的命运。
实验四:层缓存失效对比
# 写法 A:代码层在依赖层之前
time docker build -t test-a -f Dockerfile.bad . # 第一次构建
# 改一行业务代码后再构建
time docker build -t test-a -f Dockerfile.bad . # pip install 重跑,耗时 60s+
# 写法 B:依赖层与代码层分离
time docker build -t test-b -f Dockerfile.good . # 第一次构建
# 改同一行业务代码后再构建
time docker build -t test-b -f Dockerfile.good . # 命中缓存,耗时 < 5s同样的改动,因为 Dockerfile 的层顺序不同,构建时间相差十倍以上。层的顺序不只是代码风格问题,是构建效率的关键决策。
七、日常排障:容器坏了怎么查
启动失败:exit code 速查
容器退出后,第一步是看退出码:
docker ps -a # 查看所有容器(包括已退出的)
docker inspect myapp --format='{{.State.ExitCode}}'| 退出码 | 含义 | 排查方向 |
|---|---|---|
| 1 | 应用内部错误 | docker logs myapp,看应用报什么错 |
| 125 | Docker 命令本身失败 | 检查 docker run 参数是否有误 |
| 126 | CMD 无法执行 | 检查 CMD 路径和权限 |
| 127 | CMD 找不到 | 检查命令是否存在于镜像内 |
| 137 | OOM kill(128+9) | docker inspect 查 OOMKilled: true,提高内存限制 |
| 139 | Segfault(128+11) | 应用崩溃,查应用日志或 core dump |
| 143 | SIGTERM(128+15) | 容器被正常停止(通常不是错误) |
# 容器启动失败时最有用的命令
docker logs myapp # 看应用自己输出了什么
docker inspect myapp | grep -A5 '"State"' # 看退出状态服务跑起来但不通:网络排查思路
# 第一步:确认容器本身是通的
docker exec myapp curl -s http://localhost:80/health
# 第二步:确认端口映射是否正确
docker port myapp # 查看端口映射
docker inspect myapp | grep -i ports
# 第三步:确认宿主机可达
curl http://127.0.0.1:8080/health # 用宿主机访问映射端口
# 第四步:检查容器网络
docker network inspect bridge # 查看默认网络里的容器 IP
docker exec myapp ip addr # 查看容器内网络接口
# 第五步:多容器之间不通时(compose 场景)
docker network ls # 列出所有网络
docker exec app ping db # 用服务名 ping(compose 网络里服务名即 DNS)常见原因:应用监听了 127.0.0.1 而不是 0.0.0.0(容器内只有 loopback 会被暴露);端口映射写反了(宿主机端口和容器端口顺序搞混);防火墙规则阻断。
镜像拉不下来
# 认证问题
docker login registry.example.com # 重新登录
cat ~/.docker/config.json # 查看当前认证信息
# 网络问题
docker pull hello-world # 先测公共 Registry 能否访问
# digest 不匹配(镜像被篡改或 tag 被覆盖)
docker pull registry.example.com/myapp:v1@sha256:abc123... # 用 digest 而非 tag 拉取磁盘被容器吃光
docker system df # 查看各类资源的磁盘占用
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 23 5 12.4GB 9.1GB (73%)
# Containers 8 3 120MB 80MB
# Volumes 12 4 4.2GB 2.1GB
# Build Cache - - 3.1GB 3.1GB
# 清理停止的容器、未使用的镜像、悬空的网络和构建缓存
docker system prune
# 同时清理未被任何容器引用的卷(危险!确认前先 docker volume ls 确认)
docker system prune --volumes
# 只清理悬空镜像(没有 tag 的中间层镜像)
docker image prune
# 强制清理所有未使用镜像(包括有 tag 但没有容器在用的)
docker image prune -adocker system prune 是定期维护的必备操作,CI 机器如果不定期清理,构建缓存和中间层镜像会在几周内吃满磁盘。
八、往哪走
容器解决了”可重复交付”,但没有完全解决”可信交付”和”大规模运行”的问题。
镜像签名与供应链安全:docker pull nginx:latest 拉到的是最新的 nginx,但谁能证明这个镜像没有被篡改?Cosign(Sigstore 项目)给镜像引入了数字签名:构建时签名,部署时验签,签名记录在透明日志里不可篡改。SBOM(Software Bill of Materials)把镜像里每一个依赖包都列清楚,让安全扫描从”猜测”变成”核对清单”。这是”交付可信”这条线的下一步。
Rootless 容器:Docker Daemon 以 root 身份运行是历史遗留问题——任何能与 Daemon 通信的用户都有潜在的提权路径。Rootless 模式(Podman 默认支持,Docker 也已支持)把整个容器运行时放在用户 namespace 里,普通用户可以创建和运行容器,不需要任何 root 权限。
这条线的终点:容器的本质贡献是把”环境”变成了”可版本化的不可变制品”,把”交付代码”升级为”交付代码加环境的整体声明”。它是整个云原生体系的原子单元——Kubernetes 调度的是 pod(容器的封装),GitOps 交付的是容器镜像,IaC 管理的基础设施上跑的是容器。下一个问题:容器多了,谁来管?
关联
- → 02-Kubernetes-声明意图,控制器维持世界:K8s 的调度单元是 pod,pod 是容器的封装,控制循环维持的是容器的期望状态
- → 06-eBPF-内核变成可编程的基础设施:容器的网络隔离和安全策略的底层是 Linux 内核,eBPF 在这层做增强
- → Linux 系统 MOC:namespace 和 cgroups 是 Linux 内核特性,容器是它们的应用层组合