容器:隔离是手段,交付是目的

一、引言

一个开发者在本地调通了服务,推到测试环境崩了。排查三小时,发现是 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 指令里完成安装和清理(分开会产生额外层)
  • 优先用 distrolessalpine 作为运行时基础镜像

运行配置:资源限制、健康检查、非 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,看应用报什么错
125Docker 命令本身失败检查 docker run 参数是否有误
126CMD 无法执行检查 CMD 路径和权限
127CMD 找不到检查命令是否存在于镜像内
137OOM kill(128+9)docker inspectOOMKilled: true,提高内存限制
139Segfault(128+11)应用崩溃,查应用日志或 core dump
143SIGTERM(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 -a

docker 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 管理的基础设施上跑的是容器。下一个问题:容器多了,谁来管?

关联