Docker 基础体系 · 第 1/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
Docker 的学习不能停留在“会写 Dockerfile、会执行 docker run”。一个可用于生产的 Docker 知识体系,至少要回答以下问题:
- 镜像究竟保存了什么,容器启动时又增加了什么?
dockerCLI 发出的请求经过哪些组件,最终如何成为 Linux 进程?- Docker 的隔离边界是什么,哪些资源被隔离,哪些仍然共享宿主机?
- Dockerfile、BuildKit 和 Compose 分别解决构建、编排中的什么问题?
- 如何处理凭据、权限、网络暴露、供应链和内核攻击面?
- 容器出了问题,如何从日志、指标、事件、进程和内核限制中定位原因?
- 如何把一个镜像安全地交付到生产环境,并支持灰度、回滚、容量管理和故障恢复?
本文以现代 Docker Engine、BuildKit 和 Compose 规范为背景,重点讨论 Linux 容器。Docker Desktop 在 macOS 和 Windows 上通常通过 Linux 虚拟机运行 Linux 容器,因此“容器看到的宿主机”可能是 Docker Desktop 管理的 Linux 虚拟机,而不是用户操作系统本身。
一、先建立 Docker 的整体模型
1. 镜像、容器和镜像仓库
**镜像(image)**是一个不可变的、可分发的文件系统和运行配置模板。它通常包含:
- 根文件系统中的目录和文件;
- 默认启动命令;
- 环境变量;
- 工作目录;
- 暴露端口声明;
- 用户、入口点等元数据。
**容器(container)**是镜像的一次运行实例。容器不是“一个被压缩的镜像”,而是:
其中:
- 镜像层通常只读;
- 容器可写层保存运行期间对文件系统的修改;
- volume、bind mount、tmpfs 等挂载会覆盖容器内对应路径;
- 容器删除后,可写层通常也会删除;
- volume 的生命周期可以独立于容器存在。
**镜像仓库(registry)**负责存储和分发镜像。仓库不是镜像本身,而是镜像的远程分发服务。一个镜像引用通常形如:
registry.example.com/team/api:2025-03-01
它包含:
- registry:
registry.example.com - repository:
team/api - tag:
2025-03-01
tag 是可变的人类可读名称,摘要(digest)才是内容寻址的不可变身份:
registry.example.com/team/api@sha256:abc...
生产部署若只保存 tag,可能在后续拉取时得到不同内容;保存 digest 才能明确部署的二进制输入。
2. 镜像层不是运行时快照
镜像通常由多个层组成。Dockerfile 中多数会改变文件系统的指令,例如 RUN、COPY 和 ADD,可能形成新的层。下层内容不被修改,上层通过覆盖关系呈现最终文件系统。
例如:
FROM alpine:3.20
RUN echo "v1" > /message
RUN echo "v2" > /message
最终读取 /message 时得到 v2,但第二个 RUN 并没有修改第一层;它在更上层记录了新的文件内容。删除文件也不是让下层文件真正消失,而是在上层增加删除标记。因此:
RUN apt-get update
RUN apt-get install -y curl
与:
RUN apt-get update && apt-get install -y curl
不仅影响缓存,还可能影响最终层中残留的包索引文件。第一种写法中,包索引可能留在前一个层里,即使后续删除,镜像历史中仍然存在。
镜像层的共享带来空间效率:多个镜像可以复用相同的基础层。但层共享不等于容器之间共享可写状态。默认情况下,一个容器的 /tmp/a 不会直接出现在另一个容器中。
3. 容器的生命周期
容器至少有以下几个重要状态:
created -> running -> exited
| |
+-> paused +-> removed
常用操作与状态关系如下:
docker create --name demo alpine:3.20 sh
docker start demo
docker exec demo sh
docker stop demo
docker rm demo
create创建容器及其元数据,但不启动主进程;start启动已经创建的容器;run通常等价于create加start,并可附加前台等待;exec在运行中的容器内创建另一个进程,不会替换主进程;stop先发送终止信号,等待超时后再强制杀死;rm删除容器对象及其可写层,但默认不删除独立 volume。
容器是否“存活”,主要取决于容器的主进程,也称为 PID 1。下面的容器会立即退出:
docker run --name short alpine:3.20 echo done
因为 echo done 执行结束,主进程退出,容器状态变为 Exited。这不是 Docker 出错,而是容器生命周期的直接结果。
下面的容器会持续运行:
docker run -d --name long alpine:3.20 sleep 3600
docker ps
docker inspect -f '{{.State.Status}} {{.State.Pid}}' long
-d 只表示客户端后台等待,不改变容器内部的进程语义。容器仍然会在 sleep 结束后退出。
二、Docker 的组件与 Linux 执行路径
1. 从 CLI 到容器进程
典型的 Linux Docker Engine 包含以下组件:
- Docker CLI:用户侧命令行客户端,例如
docker build、docker run; - Docker daemon(
dockerd):接收 API 请求,管理镜像、容器、网络和存储; - containerd:负责容器生命周期、镜像传输和管理;
- containerd-shim:让容器运行时与 daemon 解耦,使 daemon 重启不必然终止容器;
- runc:常见的 OCI runtime,负责依据 OCI 配置创建 Linux 容器进程;
- Linux 内核:真正提供 namespace、cgroup、capability、seccomp 等机制。
简化后的路径是:
sequenceDiagram
participant U as 用户
participant C as Docker CLI
participant D as dockerd
participant T as containerd
participant S as containerd-shim
participant R as runc
participant K as Linux 内核
participant P as 容器进程
U->>C: docker run image command
C->>D: Docker API 请求
D->>T: 创建容器、准备镜像和挂载
T->>S: 创建 shim
S->>R: 传递 OCI runtime 配置
R->>K: 创建 namespace/cgroup/挂载/进程
K->>P: 启动容器 PID 1
P-->>S: 退出状态
S-->>T: 生命周期事件
T-->>D: 状态更新
D-->>C: 返回结果
这不是说每一个 Docker 版本和运行时组合都严格只有这些调用,但它准确表达了职责边界:CLI 不直接创建 Linux namespace,runc 也不负责完整的镜像仓库、网络和 Docker API 管理。
2. OCI 的位置
**OCI(Open Container Initiative)**定义了容器镜像格式、运行时规范和分发相关规范。OCI 规范描述了标准化的内容,例如:
- 镜像 manifest 如何指向配置和层;
- 运行时配置如何描述 root filesystem、进程、环境和 namespace;
- runtime 如何接收 bundle 并启动容器。
Docker 是 OCI 生态中的一种产品和工具链,但 Docker 的全部能力不等于 OCI。Docker CLI、Docker daemon、Compose、Docker Hub、构建缓存管理等都属于 Docker 体系;OCI 负责其中的开放格式和运行时接口。
因此,下面两句话都不准确:
- “Docker 容器就是 Docker 私有格式,不能被其他工具运行。”
- “只要是 OCI 镜像,所有 Docker 行为都完全相同。”
更准确的说法是:Docker 生成和使用符合 OCI 生态的镜像与运行时结构,但上层管理能力、默认安全策略、网络实现和用户体验仍由具体工具决定。
3. Linux 容器的隔离边界
容器不是虚拟机。虚拟机通常运行独立的 guest kernel;Linux 容器中的进程使用宿主机 Linux 内核,只是被内核机制限制了视图和资源。
核心机制包括:
Namespace:隔离“看见什么”
常见 namespace 包括:
- PID:进程编号视图;
- Mount:挂载点视图;
- Network:网络设备、路由和端口空间;
- UTS:主机名;
- IPC:进程间通信对象;
- User:用户和 UID/GID 映射。
容器中的进程通常看到自己是 PID 1,但宿主机可以看到它对应的真实 PID。这是视图隔离,而不是创建了另一个内核。
Cgroup:限制“能用多少”
cgroup 用于限制和统计资源,例如:
- CPU 配额和权重;
- 内存上限;
- 进程数上限;
- 块设备 I/O;
- PIDs 数量。
如果设置:
docker run --rm --memory=128m --cpus=0.5 alpine:3.20 sh -c '
echo "container started"
sleep 10
'
容器最多可使用约 128 MiB 的内存和半个 CPU 的配额。这里的“约”很重要:内核记账、文件页、运行时开销和版本差异会影响实际表现。内存超限时,可能发生 cgroup OOM,容器状态通常可通过以下命令检查:
docker inspect CONTAINER_ID \
--format '{{.State.OOMKilled}} exit={{.State.ExitCode}}'
Capability:缩小 root 权限
Linux root 不是一个不可分割的权限。Docker 默认会去除一部分 capability,但仍会保留若干常见能力。可以显式降低权限:
docker run --rm \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
alpine:3.20 id
--cap-drop=ALL 会使进程缺少很多需要内核特权的操作;no-new-privileges 防止进程通过 setuid 或文件 capability 获得更多权限。它们不是绝对安全保证,因为应用仍可能利用内核漏洞或已有权限。
Seccomp、AppArmor 和 SELinux:限制系统调用与访问
Docker 通常使用默认 seccomp 配置限制一部分危险系统调用。AppArmor 或 SELinux 则可以进一步限制进程访问路径、对象和权限。
这些机制依赖宿主机内核与安全模块配置。--privileged 会显著放宽容器权限,通常应视为高风险例外,而不是普通故障修复方式。它可能让容器获得更多设备访问、capability 和安全策略豁免,不能简单等同于“加一个调试参数”。
4. 容器边界的反例
以下命令展示了容器并不会自动成为安全边界:
docker run --rm -v /:/host alpine:3.20 ls /host
这个命令将宿主机根目录以读写方式挂载到容器的 /host。即使容器进程没有完整 root 权限,挂载本身也可能暴露大量敏感内容;如果使用读写挂载,则可能修改宿主机文件。
因此,隔离边界由多个因素共同决定:
Docker 的默认配置降低了风险,但不承诺容器可以承受恶意宿主机级攻击。多租户、强对抗场景需要进一步评估 rootless、专用节点、虚拟化运行时或其他隔离方案。
三、命令行、镜像和容器的基本操作
1. 用一个可验证的例子开始
docker run --rm --name web \
-p 8080:80 \
nginx:alpine
执行后:
- Docker 若本地没有镜像,会先拉取;
- 容器内 nginx 监听
80; -p 8080:80将宿主机 TCP 8080 映射到容器网络命名空间中的 80;- 前台进程保持 CLI 附着;
Ctrl-C会触发停止流程;--rm使容器退出后自动删除。
另开终端验证:
curl -i http://127.0.0.1:8080/
docker ps
docker logs web
docker logs 读取的是容器标准输出和标准错误。若 nginx 被配置为把日志写入容器内部文件,而不是 stdout/stderr,Docker 日志驱动可能看不到这些日志。
2. 端口声明和端口发布不同
Dockerfile 中的:
EXPOSE 8080
只是镜像元数据,表示应用预期使用该端口,不会自动把端口发布到宿主机。运行时必须显式发布:
docker run --rm -p 127.0.0.1:8080:8080 image
绑定 127.0.0.1 只允许本机访问;绑定 0.0.0.0 通常意味着监听宿主机所有接口。生产环境应结合主机防火墙、云安全组和反向代理确认实际暴露范围。
3. 环境变量不是秘密存储
docker run --rm \
-e APP_MODE=production \
-e DATABASE_PASSWORD='example-password' \
image
-e 适合普通配置,但密码可能出现在:
- shell 历史;
- 进程环境;
- Docker inspect 输出;
- 调试信息;
- 崩溃转储或日志。
因此,环境变量不应被当作自动加密的秘密管理系统。运行时秘密应根据部署环境使用 Docker secrets、编排系统 Secret、云密钥服务或文件权限控制。单机 Docker Compose 的 secrets 机制可以将秘密以文件形式挂载,但其安全性仍取决于主机权限、daemon 权限和 Compose 部署方式。
四、Dockerfile 与 BuildKit:从“能构建”到“可复现”
1. 构建上下文是什么
执行:
docker build -t example-api:dev .
最后的 . 是构建上下文。客户端或 BuildKit 会将上下文中的文件提供给构建过程,Dockerfile 中的:
COPY . /app
只能复制上下文内的路径,不能引用上下文父目录中的文件。
.dockerignore 用于减少上下文:
.git
node_modules
dist
*.log
.env
它有三项作用:
- 减少上传和扫描内容;
- 提高构建速度;
- 避免把本地秘密、依赖缓存和无关文件复制进镜像。
它不是秘密保护的唯一边界。若秘密已经被 COPY 进某个镜像层,即使后续删除,旧层仍可能保留内容。
2. Dockerfile 指令的语义
一个适合编译型应用的多阶段 Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o /out/server ./cmd/server
FROM alpine:3.20
RUN adduser -D -H -u 10001 app
WORKDIR /app
COPY --from=build /out/server /app/server
USER 10001
EXPOSE 8080
ENTRYPOINT ["/app/server"]
关键点如下:
FROM ... AS build创建构建阶段;COPY go.mod go.sum先复制依赖声明,使源代码修改不必重新下载依赖;RUN --mount=type=cache使用 BuildKit 的构建缓存挂载;COPY --from=build只把编译产物复制到运行阶段;- 最终镜像不包含 Go 编译器和源代码;
USER 10001让应用不以 root 身份运行;ENTRYPOINT使用 exec form,应用能更直接地接收信号。
ENTRYPOINT ["..."] 与 ENTRYPOINT ... 不同。前者不会经过 shell,适合让应用成为容器主进程;后者可能经过 shell,信号转发、参数处理和退出码可能出现额外行为。
3. 缓存命中不是“源码没变”
BuildKit 会根据指令、输入文件和相关参数决定是否复用缓存。下面的顺序通常比直接 COPY . . 更容易复用依赖缓存:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
但是缓存并不等于正确性证明。以下因素可能使缓存结果与期望不一致:
- 构建脚本依赖当前时间;
- 远程依赖使用浮动版本;
- 基础镜像 tag 被重新指向;
- 构建读取了未声明的外部状态;
- 缓存挂载目录被错误地当作最终产物;
- 不同架构下复用不兼容内容。
RUN --mount=type=cache 的目录是构建缓存,不会自动进入最终镜像,也不应被当作可靠持久化存储。缓存丢失后,构建必须仍然能够从声明的依赖重新完成。
4. Secret 不应写入镜像层
BuildKit 支持将构建秘密临时挂载到单个 RUN:
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN --mount=type=secret,id=token \
token="$(cat /run/secrets/token)" && \
wget --header="Authorization: Bearer $token" \
-O /tmp/private.tar.gz \
https://example.invalid/private.tar.gz && \
tar -xzf /tmp/private.tar.gz -C /opt && \
rm /tmp/private.tar.gz
构建命令:
printf '%s' "$PRIVATE_TOKEN" > /tmp/build-token
DOCKER_BUILDKIT=1 docker build \
--secret id=token,src=/tmp/build-token \
-t private-client:dev .
rm -f /tmp/build-token
秘密挂载只在该 RUN 步骤可见,不会像:
ARG TOKEN
RUN wget -H "Authorization: Bearer $TOKEN" ...
那样容易进入构建参数、历史记录或层内容。即使使用 secret mount,也必须避免把秘密复制到 /opt、日志或生成文件中。
5. 多架构构建不是“换一个 tag”
构建 ARM64 和 AMD64 镜像时,编译器、基础镜像和二进制架构都必须匹配。例如:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/team/api:1.0.0 \
--push .
这会生成一个多平台 manifest,客户端根据节点架构选择对应镜像。交叉编译时还要考虑:
- CGO 是否依赖目标架构的系统库;
- 构建过程是否执行目标架构二进制;
- 是否通过 emulation 执行,导致行为或速度变化;
- 本地缓存是否能安全复用。
“构建命令成功”不代表每个平台的运行时行为都一致,必须在目标架构或兼容环境中测试。
6. 可重复构建的条件与边界
若希望相同源码得到相同镜像内容,需要控制:
- 基础镜像使用 digest,而不是只使用可变 tag;
- 依赖版本锁定;
- 构建工具版本固定;
- 生成文件不嵌入当前时间、随机数和主机路径;
- 构建上下文明确且不包含未跟踪的外部输入;
- 不依赖不可控的网络内容;
- 对构建结果进行 digest 比较。
形式上,若构建函数表示为:
其中:
- :源代码和构建上下文;
- :基础镜像内容;
- :依赖和工具链;
- :构建环境和外部状态;
只有当这些输入在语义上固定时,才有希望得到相同的镜像 。固定 Dockerfile 本身不够;若 FROM alpine:latest 每天变化, 仍然是不稳定输入。
五、数据、网络和 Compose
1. 三类常见挂载
Bind mount
docker run --rm \
-v "$PWD/config:/etc/app:ro" \
image
宿主机路径直接映射到容器。它适合开发时挂载源代码或生产时挂载明确的配置文件,但强依赖宿主机目录结构,并可能造成容器修改宿主机文件。
Named volume
docker volume create app-data
docker run -d --name db \
-v app-data:/var/lib/data \
image
Docker 管理 volume 的位置和生命周期,适合需要持久化但不想绑定具体宿主路径的数据。volume 仍然存储在宿主机上,不等于跨节点复制或备份。
tmpfs
docker run --rm \
--tmpfs /run:rw,noexec,nosuid,size=64m \
image
数据存于内存,容器停止后消失。它适合临时文件、运行时 socket 等场景,但会消耗宿主机内存。
2. 容器网络
默认情况下,容器拥有自己的 network namespace。Docker bridge 网络通过虚拟网卡、网桥和 NAT 将容器连接到宿主机网络。
创建自定义网络:
docker network create app-net
docker run -d --name backend --network app-net \
alpine:3.20 sleep 3600
docker run --rm --network app-net \
alpine:3.20 getent hosts backend
在同一用户自定义网络中,容器通常可以通过服务名或容器名进行 DNS 解析。不要把容器 IP 写死,因为容器重建后 IP 可能变化。
EXPOSE、容器内部监听地址和宿主机端口发布是三个不同概念:
- 应用监听
127.0.0.1:只接受容器自身回环接口连接; - 应用监听
0.0.0.0:接受容器网络接口连接; -p host:container:将宿主机端口转发到容器端口;--network host:在 Linux 上让容器使用宿主机网络 namespace,隔离性和端口行为都会改变。
3. Compose 的职责
Compose 用一个 YAML 文件描述多容器应用的配置、网络、volume 和依赖关系。示例:
services:
web:
build:
context: .
target: runtime
image: example/web:dev
ports:
- "127.0.0.1:8080:8080"
environment:
DATABASE_HOST: db
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
interval: 10s
timeout: 3s
retries: 5
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: local-only-password
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:
启动并验证:
docker compose up -d --build
docker compose ps
docker compose logs -f web
docker compose down
这里的 depends_on 只能表达 Compose 层面的启动顺序或健康条件,不能保证数据库初始化完成、迁移完成或业务真正可用。应用仍应具备连接重试、超时和错误恢复能力。
Compose 适合本地开发、测试和单机部署;它不是完整的跨节点调度器。它通常不负责:
- 多节点故障转移;
- 全局副本调度;
- 跨节点服务发现的一致性控制;
- 自动容量扩展;
- 完整的灰度流量治理。
生产是否使用 Compose,取决于部署平台和运维能力,不能仅由文件格式决定。
六、安全:从镜像内容到运行时边界
1. 镜像安全不是只扫描 CVE
镜像安全至少包含四个层面:
- 来源:基础镜像和依赖是否来自可信来源;
- 内容:是否包含高风险漏洞、调试工具、秘密和多余文件;
- 构建链:构建过程是否可审计、可重复、可追溯;
- 运行时:进程是否以非 root 运行、权限是否最小化、网络是否最小暴露。
镜像扫描可以发现已知漏洞,但不能证明:
- 应用业务逻辑安全;
- 所有漏洞都已被数据库收录;
- 容器一定不会越权;
- 配置和秘密没有泄露;
- 依赖版本在当前业务中一定可接受。
2. 一个更小权限的运行示例
docker run --rm \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
image
这些选项的作用分别是:
- 指定非 root 用户;
- 删除 Linux capabilities;
- 禁止通过特定机制获得新增权限;
- 将根文件系统设为只读;
- 为确实需要写入的
/tmp提供临时文件系统。
但这组参数可能使应用启动失败。诊断顺序应是:
docker run ... image
docker logs CONTAINER_ID
docker inspect CONTAINER_ID
如果应用需要写 /var/cache/app,应明确挂载一个临时目录或 volume,而不是直接取消只读根文件系统。安全限制必须结合应用实际写路径验证。
3. rootless 的边界
Rootless Docker 或 rootless containerd 让 daemon 和容器进程以普通用户身份运行,降低 daemon 被攻破后直接获得宿主机 root 的风险。但它不是“无权限容器”的同义词,也可能受到:
- 网络端口限制;
- cgroup 管理能力;
- 文件系统和 id mapping;
- 某些存储驱动;
- 宿主机内核配置
的影响。是否采用 rootless,需要在目标发行版、内核和部署方式上做实际验证。
4. Docker daemon 本身是高价值权限边界
能够访问 Docker daemon 的用户,通常可以请求 daemon 创建挂载宿主机目录、访问镜像和启动高权限容器。因此,把用户加入 docker 组不应轻率视为“普通开发权限”。远程暴露 Docker API 时必须使用认证和加密,并配合网络访问控制;不应直接将未保护的 daemon socket 暴露到公网。
七、可观测性:知道容器“为什么死”和“为什么慢”
可观测性通常分为:
- 日志(logs):记录离散事件和上下文;
- 指标(metrics):记录可聚合的时间序列;
- 追踪(traces):记录一次请求跨服务的调用路径;
- 事件(events):记录容器、镜像、网络等 Docker 对象状态变化。
1. 先区分应用状态和容器状态
下面的容器可能处于 Up,但应用实际上不可用:
docker run -d --name fake-service alpine:3.20 \
sh -c 'sleep 3600'
Docker 只能知道 sleep 还在运行,不知道它是否实现了 HTTP API。HEALTHCHECK 可以增加应用级探测:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q -O - http://127.0.0.1:8080/health || exit 1
健康检查的退出码通常有这些意义:
0:健康;1:不健康;- 启动期间可能显示
starting。
健康检查不是自动修复机制,也不是完整的 readiness 系统。探测接口若依赖数据库,数据库短暂抖动可能导致实例被判定不健康;探测过重则会反过来增加负载。
2. 诊断一条失败路径
假设服务退出:
docker ps -a --filter name=api
docker inspect api --format '
status={{.State.Status}}
exit={{.State.ExitCode}}
oom={{.State.OOMKilled}}
error={{.State.Error}}
started={{.State.StartedAt}}
finished={{.State.FinishedAt}}'
docker logs --timestamps --tail=200 api
docker events --since 10m
分析顺序是:
Status判断是运行中、退出还是重启;ExitCode判断进程自身退出还是被信号终止;OOMKilled判断是否触发内存限制;Error查看 daemon 或运行时创建失败;- 日志确认应用在退出前做了什么;
- events 确认是否发生了 kill、restart、health_status 等对象事件。
退出码只能缩小范围,不能单独证明根因。例如 137 常见于进程收到 SIGKILL,但可能来自 OOM,也可能来自人工 kill -9;必须结合 OOMKilled、内核日志和 Docker 事件判断。
3. CPU、内存和 I/O 指标
docker stats --no-stream api
docker top api
docker stats 适合快速观察容器的 CPU、内存、网络和块 I/O;它不是长期监控系统。生产环境应将应用指标和容器/主机指标发送到监控系统,并记录:
- 请求量、延迟分位数、错误率;
- 工作队列长度;
- 内存工作集和 OOM 次数;
- CPU throttling;
- 文件描述符和进程数;
- 网络连接数;
- 磁盘空间与 inode;
- 重启次数和健康检查失败次数。
容器层指标无法替代主机层指标。容器磁盘写入失败,可能是容器可写层、volume、宿主机文件系统或 inode 耗尽;必须分别检查。
4. 日志的工程约束
推荐应用将结构化日志写到 stdout/stderr,例如:
{"level":"error","request_id":"r-123","path":"/orders","error":"timeout"}
这样 Docker 日志驱动和上层采集系统可以统一收集。日志中不应写入密码、token、完整 Cookie 或不必要的个人数据。
日志轮转必须明确配置。若使用 json-file 且没有合理轮转,高流量服务可能让宿主机磁盘被日志填满。磁盘耗尽不仅影响日志,还可能阻止容器写入、数据库刷盘和系统正常运行。
八、生产交付:从提交到可回滚版本
1. 生产交付的基本对象
生产交付应围绕不可变制品展开:
源码提交
-> CI 测试
-> BuildKit 构建
-> 镜像扫描与签名/证明
-> 推送 registry
-> 记录镜像 digest
-> 部署 digest
-> 健康验证
-> 灰度扩大
不要把“构建”和“部署”绑定成每台机器现场编译。推荐由 CI 生成一次镜像,然后所有环境部署同一个 digest。环境差异通过显式配置注入,而不是重新构建不同内容。
例如:
docker buildx build \
--platform linux/amd64 \
-t registry.example.com/team/api:git-8f31c2a \
--push .
docker buildx imagetools inspect \
registry.example.com/team/api:git-8f31c2a
部署记录应保存:
- Git commit;
- Dockerfile 和构建器版本;
- 基础镜像 digest;
- 最终镜像 digest;
- 配置版本;
- 数据库迁移版本;
- 发布人和时间;
- 验证结果。
2. 为什么不能只用 latest
假设:
10:00 latest -> sha256:A
10:30 latest -> sha256:B
节点甲在 10:05 拉取得到 A,节点乙在 10:35 拉取得到 B。此时同一个服务名下运行了两个不同版本,问题难以复现,回滚也无法根据 tag 唯一确定内容。
更可靠的部署方式是:
registry.example.com/team/api@sha256:A
回滚就是把服务定义恢复到旧 digest,而不是猜测某个 tag 现在指向什么。
3. 灰度和回滚的逻辑
灰度发布的核心不是“先启动一个容器”,而是控制流量和观测范围。可以把版本流量表示为:
其中 和 是新旧版本承载的请求量。灰度期间至少比较:
- 新旧版本错误率;
- 延迟分位数;
- 业务成功率;
- 资源消耗;
- 下游依赖错误;
- 日志中的特定异常。
若新版本错误率显著升高,回滚动作应是:
- 停止增加新版本流量;
- 将流量切回旧版本;
- 确认旧版本实例健康;
- 保留新版本日志、事件和镜像 digest;
- 评估是否需要回滚数据库迁移或执行补偿操作。
数据库变更是回滚的难点。应用回滚不等于数据库自动回滚。更安全的迁移通常采用向后兼容步骤:
先增加可选字段
-> 发布同时兼容旧字段和新字段的代码
-> 回填数据
-> 切换读写路径
-> 在确认无旧代码后再删除旧字段
直接删除旧字段后,代码回滚可能因查询失败而无法恢复。
4. 容量规划:限制不是容量
容器设置 --cpus 和 --memory 是资源边界,不是容量规划本身。容量估算可从需求反推:
设:
- :峰值请求率;
- :单实例稳定处理能力;
- :期望利用率;
- :实例数量。
则一个粗略下界是:
例如峰值为 900 req/s,单实例在目标延迟下稳定处理 120 req/s,期望利用率为 0.75:
这只是计算下界,还要考虑:
- 单实例故障后的剩余容量;
- 发布期间新旧版本并存;
- 节点故障;
- 下游数据库连接上限;
- 内存峰值和 GC;
- 突发流量;
- 日志和临时文件增长。
如果每个实例占用 100 个数据库连接,10 个实例并不代表数据库只需要支持 10 个连接;还要加上连接池上限、迁移任务、管理连接和重试放大效应。
5. 重启策略不能替代故障修复
restart: always 或类似自动重启配置可以处理短暂故障,但会掩盖配置错误并形成重启风暴。启动失败时应查看:
docker inspect api --format '{{json .HostConfig.RestartPolicy}}'
docker logs api
docker events --since 30m
若进程每秒崩溃一次,自动重启会持续消耗 CPU、写日志并制造告警噪声。生产系统应配合退避、启动失败阈值、健康检查和明确的人工介入流程。
九、故障诊断的分层方法
1. 创建阶段失败
表现:
pull access denied
no matching manifest
failed to solve
permission denied
优先检查:
docker pull image:tag
docker image inspect image:tag
docker version
docker info
常见原因包括:
- registry 认证失败;
- 当前架构没有对应 manifest;
- 构建上下文缺少文件;
- daemon 无法访问网络;
- 文件权限或 SELinux 标签不允许访问;
- BuildKit 缓存或磁盘空间不足。
2. 启动阶段失败
表现为容器迅速 Exited,或 docker run 直接返回错误。
检查:
docker ps -a
docker logs CONTAINER
docker inspect CONTAINER
重点查看:
- 入口文件是否存在;
- 工作目录是否正确;
- 非 root 用户是否有权限读写;
- 环境变量是否完整;
- 端口是否已被占用;
- CPU 架构是否匹配;
- 动态链接库是否缺失。
3. 运行阶段失败
容器仍然 Up,但请求超时或错误。此时应分开检查:
docker exec CONTAINER ps
docker exec CONTAINER sh -c 'cat /proc/1/status'
docker inspect CONTAINER
docker stats --no-stream CONTAINER
再从网络路径逐段验证:
客户端
-> 宿主机监听端口
-> Docker 端口转发
-> 容器网络接口
-> 应用监听地址
-> 应用线程/事件循环
-> 下游服务
例如应用只监听 127.0.0.1:8080,而外部连接通过容器网卡访问 172.x.x.x:8080,即使端口发布正确,也可能连接失败。应用应根据网络模型监听合适地址,通常是容器内的 0.0.0.0,同时由宿主机或代理控制外部暴露。
4. 存储阶段失败
表现可能包括:
- 数据重启后消失;
no space left on device;- 数据库无法写入;
- 新容器读不到旧数据。
判断路径:
docker inspect CONTAINER --format '{{json .Mounts}}'
docker volume ls
docker system df
df -h
df -i
必须确认实际写入路径是否位于 volume。仅仅“启动了一个带 volume 的容器”不代表应用写入的数据都在该 volume 中;应用可能写到了另一个目录,或启动参数覆盖了默认数据路径。
十、建议的完整学习路线
阶段一:Linux 与进程基础
先掌握:
- 文件权限、UID/GID、signals;
- PID 1、进程组、标准输入输出;
- mount、网络接口、DNS;
- cgroup、namespace 的基本观察方式;
- systemd、iptables/nftables 的基本概念。
验证容器问题时,如果不了解 Linux 进程和信号,就很难解释 docker stop、僵尸进程、信号转发和优雅退出。
阶段二:Docker 基本对象
完成以下练习:
docker pull alpine:3.20
docker image ls
docker image inspect alpine:3.20
docker run --name shell -it alpine:3.20 sh
docker inspect shell
docker diff shell
docker commit shell shell:experimental
docker rm -f shell
重点理解:
- 镜像配置和容器配置的差异;
- 容器可写层;
docker diff展示了哪些文件变化;commit为什么不适合作为可审计的生产构建流程。
阶段三:Dockerfile 与 BuildKit
练习:
- 构建上下文和
.dockerignore; - 层缓存顺序;
- 多阶段构建;
- cache mount;
- secret mount;
- 非 root 运行;
- 固定依赖和基础镜像 digest;
- 多架构构建。
每次构建都检查:
docker history image:tag
docker image inspect image:tag
docker run --rm image:tag
不要只关注“构建成功”,还要确认秘密没有进入层、最终镜像是否包含不必要的编译器和包管理器、入口进程是否能接收信号。
阶段四:网络、存储和 Compose
完成一个包含 Web、API 和数据库的 Compose 项目,要求:
- 服务通过名称通信;
- 数据库使用 named volume;
- Web 仅发布到本机;
- API 有健康检查;
- 数据库凭据不写入 Git;
docker compose down后确认哪些数据保留;- 删除 volume 后确认数据确实丢失。
这组实验能直接揭示“容器删除”和“数据删除”并不总是同一个动作。
阶段五:安全与可观测性
为同一个镜像分别运行:
docker run --rm image
docker run --rm --user 10001:10001 image
docker run --rm --cap-drop=ALL image
docker run --rm --read-only --tmpfs /tmp image
记录每次失败的原因和需要开放的最小目录或 capability。再为应用增加:
- 结构化 stdout 日志;
/health和/ready端点;- 请求延迟、错误率、活动连接数指标;
- request ID;
- graceful shutdown;
- 退出码和异常分类。
阶段六:生产交付
建立一个最小 CI 流程:
提交代码
-> 单元测试
-> Dockerfile 构建
-> 镜像扫描
-> 生成 digest
-> 推送 registry
-> 测试环境部署
-> 冒烟测试
-> 生产灰度
-> 观测
-> 扩大或回滚
最后形成运行手册,至少包含:
- 如何确认当前运行的 image digest;
- 如何查看最近 30 分钟事件和日志;
- 如何判断 OOM、磁盘耗尽和端口冲突;
- 如何停止流量并回滚;
- 回滚后如何验证业务;
- 数据库迁移失败时谁负责决策;
- 哪些操作需要 root 或 daemon 管理权限;
- 故障证据如何保留。
Docker 的完整能力最终体现在这条闭环中:用可追溯的输入构建不可变镜像,用明确的隔离边界运行它,用日志、指标、追踪和事件观察它,再用可验证、可回滚的流程把它交付到真实环境。
完整学习目录
一、容器基础
- Docker 与 OCI 架构:CLI、Daemon、containerd、runc 和隔离边界
- Docker 镜像与分层:内容寻址、OverlayFS、缓存和镜像体积
- Docker 容器生命周期:创建、启动、信号、退出、重启与清理
二、镜像构建
- Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
- Docker 多阶段构建:最小运行时、依赖固定、调试层和体积优化
- Docker 多架构构建:Buildx、QEMU、Manifest 与跨平台发布
三、运行与编排
- Docker 网络完整指南:Bridge、端口映射、DNS、Overlay 和排障
- Docker 存储:Volume、Bind Mount、tmpfs、权限和备份恢复
- Docker Compose 完整指南:服务、网络、卷、依赖、Profile 和生产边界
- Docker 资源治理:CPU、内存、PID、I/O、cgroups 与 OOM
四、安全与供应链
- Docker 配置与 Secret:环境变量、文件注入、轮换和泄漏防护
- Docker 容器安全:非 root、Capabilities、Seccomp、只读根和隔离
- Docker 软件供应链:SBOM、签名、扫描、可信基础镜像和策略门禁
- Docker Registry 与镜像分发:Tag、Digest、认证、缓存和清理
五、生产运维
- Docker 日志与监控:Logging Driver、Metrics、事件和容量告警
- Docker 健康检查与优雅关闭:PID 1、Probe、Stop Signal 和超时
- Docker 数据备份与主机迁移:卷、数据库、一致性和恢复演练
- Docker 故障排查:启动失败、网络、磁盘、OOM、构建和 Daemon
- Docker Engine API 与自动化:SDK、事件、权限和幂等操作
- Docker 生产交付体系:CI、灰度、回滚、容量和运行手册
六、开发与命令行
- Docker CLI 与对象模型:Container、Image、Network、Volume 和 Filter
- Docker Inspect、Exec 与调试:Namespace、环境、进程和最小镜像
- Docker Context 与远程 Daemon:SSH、TLS、权限和环境隔离
- Rootless Docker:User Namespace、网络、存储、限制和迁移
- Docker Desktop 架构:Linux VM、文件共享、网络、资源和企业边界
- Dev Containers 开发环境:配置、Feature、缓存、Secret 和复现
- Docker Compose 开发工作流:Bind、Watch、Override、Profile 和调试
- Docker 容器化测试:一次性依赖、健康等待、Fixture 和资源清理
七、构建深入
- Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏
- BuildKit 缓存深入:Layer、Cache Mount、远程缓存和失效诊断
- Docker Build Secret 与 SSH Mount:凭据注入、缓存和泄漏防护
- Docker ARG、ENV、LABEL 与 Metadata:作用域、继承和敏感边界
- Docker 可重复依赖安装:锁文件、镜像源、缓存、校验和离线
- Docker Alpine、Distroless 与 Scratch:libc、证书、时区和调试取舍
- Go 应用 Docker 镜像:静态链接、CA、时区、非 root 和优雅退出
- Java 应用 Docker 镜像:JLink、Class Data Sharing、内存和 JVM 参数
- Node.js 应用 Docker 镜像:依赖、构建产物、PID 1、信号和安全
- Vue 与 React 静态站点镜像:构建、Nginx、缓存、路由和运行时配置
- Docker Buildx Bake:多目标、矩阵、缓存、平台和 CI 编排
- Docker 构建证明:Provenance、SBOM、Attestation 和 SLSA
八、运行时深入
- Docker 运行时隔离:namespaces、cgroups、Mount、PID 和网络
- Docker Storage Driver:overlay2、Copy-on-Write、Inode 和磁盘诊断
- Docker Volume 权限:UID/GID、umask、Rootless、SELinux 和共享
- Docker Bind Mount 深入:传播、递归、只读、符号链接和平台差异
- Docker Bridge 与 iptables/nftables:NAT、端口发布、转发和冲突
- Docker 容器 DNS:嵌入式解析、Service Name、Search Domain 和故障
- Docker IPv6、Macvlan 与 Host 网络:场景、隔离和路由边界
- Docker GPU 容器:NVIDIA Runtime、设备、驱动、资源和可观测
- Docker PID 1 与 Init:信号、子进程回收、Shell Form 和 Tini
- Docker 时区、Locale 与 CA 证书:最小镜像中的运行时基础
- 数据库运行在 Docker:持久化、初始化、备份、资源和生产边界
九、Compose 与交付
- Compose 服务发现与依赖:DNS、Healthcheck、启动顺序和重连
- Compose 配置合并:Override、include、extends、变量插值和验证
- Compose 多环境治理:开发、测试、生产差异、Secret 和不可变制品
- Compose 扩容与并发:Replica、端口、负载均衡、共享状态和边界
- Compose 反向代理架构:Nginx、TLS、服务发现、路由和零停机
- Docker CI 构建流水线:缓存、并行、扫描、签名、推送和晋级
- Docker 不可变制品晋级:Digest、环境配置、验收、灰度和回滚
- Docker Swarm 基础与边界:Service、Task、Overlay、Secret 和滚动更新
十、Daemon 与运维深入
- Docker Daemon 配置:data-root、日志、代理、镜像源、TLS 和验证
- Docker User Namespace Remap:UID 映射、Volume、权限和迁移
- Docker Live Restore 与重启恢复:Daemon 故障、容器存活和限制
- Docker Engine 升级:兼容、存储驱动、API、灰度、回滚和验证
- Docker 离线与受限网络:镜像同步、依赖缓存、签名和补丁
- Docker 磁盘治理:Layer、Build Cache、Volume、日志和安全清理
- Docker 网络故障诊断:Namespace、Bridge、DNS、NAT、MTU 和抓包
- Docker OOM 与性能诊断:cgroups、PSI、CPU Throttle、I/O 和内存
- Docker 在 Linux、macOS 与 Windows 的差异:VM、路径、网络和性能
- Docker 安全事件响应:隔离容器、保全证据、镜像追溯和恢复
系列导航与关联阅读
- 下一篇:Docker 与 OCI 架构:CLI、Daemon、containerd、runc 和隔离边界
- 延伸:Dockerfile 与 BuildKit:构建上下文、缓存挂载、Secret 和可重复构建
- 延伸:Docker 生产交付体系:CI、灰度、回滚、容量和运行手册
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论