Docker 教程:从镜像分层到生产编排

一、容器到底解决了什么:镜像、容器与仓库三件套

先还原一个每天都在上演的场景。你在自己机器上跑通了服务,python app.py 一切正常;交给测试同学,报错 ModuleNotFoundError: No module named 'pandas';测试环境装了 pandas,你本地是 1.1 而那边是 2.0,接口返回的日期格式对不上;终于版本对齐了,生产机是 ARM 架构,而依赖里有一个需要现场编译 C 扩展的包。

「在我机器上能跑」这句话之所以让人头疼,不是因为机器有坏有好,而是因为运行环境是一组隐式依赖的集合:操作系统版本、C 库(glibc 还是 musl)、解释器与依赖库的具体版本、时区、环境变量、字体、可用的系统调用、CPU 架构。传统部署方式里,这堆东西散落在各台机器上,靠文档和运维的记忆维持一致。容器要解决的正是这个:把「应用 + 它需要的一切」打成一个不可变的包,让隐式依赖变成显式内容。

镜像、容器、仓库:三个名词先把边界划清

概念它是什么类比关键性质
镜像 image只读的文件系统快照 + 启动参数安装光盘 / class不可变、分层、可按内容寻址(digest)
容器 container镜像加上一个可写层,跑起来的进程进程 / new 出来的对象可创建、可停止、可删除,数据默认随它消失
仓库 repository存放某个应用的一组镜像(按 tag 区分)Maven 仓库nginx、ghcr.io/example/shop-api 都是一个仓库
注册中心 registry提供仓库服务的服务器私服 Nexusdocker.io、ghcr.io、自建 registry:2

最容易混的是最后两个:仓库是一组 tag,注册中心是那个服务。而镜像与容器的关系,第三章讲分层时会具体化:镜像是只读的,容器启动时在这一叠只读层上面盖了一层自己的读写层。你往容器里写的任何文件都只在那一层里,docker rm 之后一并消失——这是新手最常踩的第一个坑(第九章)。

容器不是轻量虚拟机:共享内核是全部差异的来源

很多人把容器理解成「小一点的虚拟机」,这个类比会让你后面所有判断都跑偏。

维度虚拟机容器
内核每个 VM 自己一个完整内核所有容器共享宿主内核
启动几十秒(走完整 BIOS/systemd)几十到几百毫秒(只是起一个进程)
内存开销预先分配整个 guest 内存就是进程本身,用多少占多少
镜像大小几 GB几十 MB 到几百 MB
单机密度几台到十几台几十到几百个
隔离强度硬件虚拟化,边界很硬内核特性 + 能力裁剪,边界是「软」的
跨内核Linux VM 可以跑在 Windows 宿主上Linux 容器必须跑在 Linux 内核上

最后一行值得单独强调:容器不是把硬件虚拟化了一遍,它只是把一个进程的可见世界做了限制。所以 Windows 上跑 Linux 容器必须在 Linux 内核上面垫一层虚拟机——第二章讲 Docker Desktop 时这个事实会造成一堆「奇怪现象」(bind mount 慢、host 网络不生效)。

namespace 管「看得见什么」,cgroup 管「用得了多少」

容器为什么能「像独立机器」?靠的是内核里两套机制,分工非常清楚。

namespace 负责可见性的隔离,让同一个进程看到的世界上少了一半东西:

namespace隔离了什么你在容器里的直观感受
mnt挂载点根目录是镜像的文件系统,宿主的 /home 看不见
pid进程号ps 里自己的应用是 PID 1,看不见宿主的 sshd
net网络栈有自己的网卡、IP、路由表、iptables
uts主机名hostname 返回容器名
ipc信号量 / 共享内存只能和同组容器通信
userUID/GID 映射rootless 模式下容器 root 其实是宿主普通用户

cgroup(control group)负责资源的计量与限制:CPU 时间、内存上限、块设备 IO、进程数、网络带宽(通过 cgroup v2)。docker stats 看到的数字、--memory 的限制、第十四章那个 Exited (137),全部来自 cgroup。

一句话记住分工:namespace 决定容器「不知道宿主存在」,cgroup 决定容器「不能把宿主吃穷」。也正因为隔离是靠内核特性而不是硬件,容器才又快又省,但也才需要第十五章那整套安全加固——默认的容器不是一个足够硬的安全边界。

「一次构建,到处运行」的边界在哪里

这句宣传语是真的,但它有明确的适用范围。下面这些东西不在镜像里,因此换机器就可能变行为:

不受镜像控制后果示例正确做法
宿主内核版本与模块老内核跑新镜像报 unknown mount option / overlay 不支持统一宿主基线,CI 里用目标内核测
CPU 架构exec format error(第十八章)多架构镜像(第五章)或固定 --platform
设备与驱动GPU 容器缺少 nvidia 运行时设备透传 + 对应 runtime
资源上限同镜像在小机器上 OOM 被杀--memory / Compose 显式声明(第十四章)
文件系统属主 UIDbind mount 进去写不进去对齐 UID 或用卷(第九章)
外部地址与密钥连不上数据库、密钥泄漏环境变量与 secrets 注入(第十三章)
并发与连接池大小单机调好的参数在 8 核机上打爆 DB从 --cpus 推导,不写死

所以准确的说法是:镜像保证「应用及其用户空间依赖」一致,不保证「运行时环境」一致。剩下的差异要么由编排层注入,要么由你在配置里显式声明。理解了这条边界,后面十七章的所有「为什么要这样写」都有了落点。

下一章开始动手:装好它,跑通第一个容器,并看清那条命令背后到底发生了什么。

二、安装与第一次运行:daemon、客户端与虚拟机的本质

daemon 与客户端:docker 命令其实只是个 HTTP 客户端

很多人以为 docker run 是本机执行了容器,其实不是。真实链路是:docker CLI 发 HTTP 请求 → /var/run/docker.sock → dockerd(常驻服务,负责接收请求、拉镜像、管网络与卷)→ containerd(真正的容器运行时管理层,镜像解包、任务生命周期归它)→ runc(一次性小工具,做完 namespace/cgroup 那套系统调用把容器进程生出来就退出,不留常驻进程)。

关键推论有三个:

  1. 客户端与 daemon 可以完全分离。设 DOCKER_HOST=tcp://build-server:2375,你的 docker 命令就在操控远端机器。CI 里的 docker:24-dind 镜像就是这么用的。
  2. daemon 挂了,容器不一定挂。已运行的容器由 runc 生成的进程继续跑;重启 dockerd 会重新接管它们(这也是为什么 docker restart dockerd 比想象中有用,但也可能造成短暂失联)。
  3. 能访问那个 socket 就等于宿主 root。/var/run/docker.sock 的权限、挂载它的风险,第十五章会专门算这笔账。

安装方式上,Linux 建议用发行版的官方仓库装 docker-ce(snap 版本会限制挂载路径,坑很多),装完 sudo systemctl enable --now docker,再用 systemctl status docker --no-pager 确认是 active (running)。

docker version 与 docker info:先学会读字段

装完先跑这两条,它们是你后面所有排障的信息源。docker version 输出分 Client 和 Server 两段:ApiVersion 决定客户端能说什么话,Version 是引擎版本,GitCommit 是构建标识(提 issue 时要给),Os/Arch 是这台机器的目标平台——跨架构问题第一个看这里。如果只有 Client 段然后报错 Cannot connect to the Docker daemon,说明 daemon 没跑或你没权限,这是第十八章表格的第一行。

docker info 要重点读的字段:

字段看它干什么
Server Version引擎版本,决定可用特性
Storage Driver几乎总是 overlay2;vfs 说明没加速层,又慢又占盘
Cgroup Driver / Cgroup Versionsystemd 还是 cgroupfs、v1 还是 v2——与 k8s 混用时驱动必须一致,v1/v2 的内存与 swap 语义有差异(第十四章)
Security Optionsseccomp、apparmor、rootless 都在这里体现
Docker Root Dir镜像与卷的真实落盘位置,磁盘爆了对着它 du(第九章)
Registry Mirrors、NCPU、Total Memory加速器是否生效;宿主真实上限(--memory/--cpus 是相对它而言)

docker run hello-world:拆开看它做了六件事

跑一条 docker run hello-world,屏幕上那段 "Hello from Docker!" 背后是一条完整链路:

  1. 客户端把「跑 hello-world」的请求发给 daemon;
  2. daemon 本地查 docker.io/library/hello-world:latest 的镜像记录,没有,于是向 registry-1.docker.io 发认证与拉取;
  3. 按 manifest 找到匹配 linux/amd64 的那一组层,逐层下载并校验 sha256;
  4. 把层解压到 /var/lib/docker/overlay2/,用联合挂载叠成一个只读根文件系统;
  5. 在上面盖一层容器可写层,配上 namespace/cgroup,让 runc 拉起镜像 CMD 指定的 /hello;
  6. 进程打印一段文本后 exit 0,容器进入 exited 状态——docker ps 看不见它,docker ps -a 能看见(第六章)。

这条链路解释了三个常见困惑:第一次跑慢是拉镜像(不是启动慢);跑完就没了是应用正常退出(容器活着的时间 = 主进程活着的时间);docker run 其实是 pull + create + start 三个动作的合称。

sudo usermod -aG docker $USER:为什么要重新登录

把用户加进 docker 组,是为了不用每条命令都 sudo:sudo usermod -aG docker $USER,然后 newgrp docker 或直接退出终端重新登录,用 id 确认 groups 里能看到 docker。

必须重新登录(或 newgrp)的原因是:组的成员关系是进程凭证的一部分,只在登录那一刻被读进进程。你改的是 /etc/group,而当前这个 shell 进程的凭证是登录时抄的一份快照,不重新登录它不会更新。新手在这里卡住的典型症状是明明执行过 usermod,docker ps 依旧报 permission denied while trying to connect to the Docker daemon socket。

-aG 的 a 不能省:usermod -G docker $USER(没有 a)会把用户从其他所有附加组里移除,这是能把机器权限改坏的经典事故。

还要清楚这件事的实质:加入 docker 组不是「降权」,而是把宿主 root 能力授给了你。能起容器就能挂载任意宿主目录,包括 /。生产机器上谁该进这个组,要当成 sudoers 来审。

rootless 模式:把 daemon 也变成普通用户

rootless 模式让 dockerd 自己跑在普通用户下(靠 newuidmap/newgidmap 与 user namespace)。它换来的是「即使容器逃逸,攻击者拿到的也是普通用户」,代价是一串限制:

项目rootfulrootless
绑定 80/443 特权端口可以不行(>1024,除非改 sysctl)
--network=host / macvlan可以受限或不可用
bind mount 宿主文件直接对应受 subuid 映射,属主看着是 100000+(第九章、第十五章)
数据目录/var/lib/docker~/.local/share/docker
开机自启systemd 服务需要用户级 lingering(loginctl enable-linger)

装法是官方脚本 rootlesskit + dockerd-rootless-setuptool.sh install,然后 systemctl --user 管理。对个人开发机和「只跑自己代码」的构建机很合适;给外部内容做隔离时它确实提高了一档门槛。

Docker Desktop 与那台看不见的虚拟机

macOS 和 Windows 上你装的是 Docker Desktop,它不是「原生支持容器」,而是在宿主上偷偷起了一台 Linux 虚拟机(macOS 用 Apple 虚拟化框架 / HyperKit,Windows 用 WSL2 或 Hyper-V),daemon 和所有容器都跑在那台 VM 里。你敲的 docker 命令通过它暴露的 socket 转发过去。

由此产生的三个「为什么」必须提前知道,否则后面会一直被折磨:

  1. 为什么 bind mount 这么慢? 容器在 Linux VM 里,而你的代码在 macOS/Windows 文件系统上,每次读写都要跨一次虚拟机文件共享(virtiofs / gRPC-FUSE / WSL2 计划)。stat 密集型操作(npm install、大量小文件)慢一个数量级。解法:把代码放在命名卷里、或用 WSL2 内的文件系统(Windows 侧)、或直接在 VM 里跑构建。
  2. 为什么 --network=host 不生效? 那个 host 是 VM 的 host,不是你 Mac 的。同理 127.0.0.1 在容器里也不指向你的浏览器(第八章)。
  3. 为什么内存把 Mac 吃满? VM 会尽量占满分配给它的内存并不归还,需要在 Desktop 设置里给 Docker VM 分配上限(第八章的资源视角)。

Linux 上没有这层虚拟机,性能与语义都是「原生」的——这也是生产环境几乎清一色 Linux 容器的原因。

下一章来看镜像的真实结构:它是一叠只读层,理解这一点之后,Dockerfile 里所有「为什么要按这个顺序写」都会自动说得通。

三、镜像分层:只读层、联合挂载与容器可写层

镜像不是一个大文件,而是一叠只读层。这一章的目标是让你能从命令输出里亲眼看到这些层——看懂了层,第五章的缓存、第十四章的瘦身、第十五章的密钥泄漏才是「显然」的结论。

一叠只读层 + 一个读写层

启动容器时,Docker 用联合挂载(Linux 上默认 overlay2)把这叠只读层叠成一个根文件系统,再在最上面加一层属于这个容器的可写层:

┌────────────────────────────────┐
│ 容器可写层(每个容器一份)     │ ← 所有写入都发生在这里,docker rm 即消失
├────────────────────────────────┤
│ CMD ["node", "server.js"]   0B │ 元数据层:不占空间,但参与缓存判定
│ COPY . .                 1.8MB │
│ RUN npm ci                212MB│
│ WORKDIR /srv/shop           0B │
│ FROM node:22-bookworm-slim 78MB│
└────────────────────────────────┘
  下面全是只读层:可被多个镜像、多个容器共享

三个直接后果:

  • 共享。跑 20 个基于同一镜像的容器,磁盘上只有一份镜像层,20 个可写层而已。这就是容器密度高、镜像「下载快」的物理原因(很多层早就在本地了,只拉缺的)。
  • 写时复制(copy-up)。你要改下层的 app.conf,文件系统先把整份文件复制到可写层,你改的是副本;上层「遮住」下层。删文件同理,是在可写层放一个 whiteout 标记,下层那个字节还在——所以在 Dockerfile 里 RUN rm bigfile 不会让镜像变小,必须和产生它的指令合并在同一层(第十四章瘦身核心)。
  • 容器可写层是易逝的。docker rm 就没了。数据库要活着必须走卷(第九章)。

docker history:把层和 Dockerfile 指令对上

docker history ghcr.io/example/shop-api:1.8.3 --no-trunc 的输出里每条指令对应一层,SIZE 是该层新增的字节数。你会看到大量 SIZE 为 0 的层——它们是元数据层(CMD、ENTRYPOINT、ENV、EXPOSE、WORKDIR、USER、LABEL、VOLUME),只改配置不写文件,不占空间但仍然算一层、仍然参与缓存判定。这个区分在第四章讲指令顺序时是关键。

示例 03 把这几条真实输出摆在一起,可以逐字段点开看:层是从下往上叠加的,history 是从上往下打印的,方向感要建起来。

示例 03:分层观察——images / history / inspect / system df在新标签页打开

另外两个观察入口是 docker image inspect ghcr.io/example/shop-api:1.8.3 --format '{{json .RootFS.Layers}}'(层的内容哈希数组,从上到下即从最底层到最上层)和 docker system df(给四行账:Images、Containers、Local Volumes、Build Cache,以及各自的「可回收」比例——第十四章讨论清理策略时,它就是你每次决策前该看的那张表)。

改一行代码为什么要重建:内容哈希决定失效点

构建时每一层的 ID 由「父层 ID + 本条指令 + 本指令涉及的输入内容(对 COPY/ADD 是文件内容哈希)」算出来。因此:

FROM node:22-bookworm-slim           # 层 1
WORKDIR /srv/shop                    # 层 2(元数据)
COPY package.json package-lock.json ./  # 层 3:只依赖这两个文件
RUN npm ci                           # 层 4:npm 只依赖上一份 package-lock
COPY . .                             # 层 5:整个源码目录 —— 改任何一行源码,从这层起重跑
ENV NODE_ENV=production              # 层 6(元数据)
EXPOSE 3000                          # 层 7(元数据)
CMD ["node", "server.js"]            # 层 8(元数据)

改一行源码 → 层 1–4 命中缓存,层 5 起重建。但如果你把 COPY . . 写在 COPY package.json 前面,或者干脆第一行就 COPY . .,那么改一行代码就要重新 npm ci(因为 npm ci 那层的父层哈希变了)——一个几秒的改动变成几分钟的构建。这一条顺序调整,是 Dockerfile 性能优化里性价比最高的一件事,没有之一。

镜像就是一个 tar 的集合

镜像在磁盘上是散的目录,但在网络上和 docker save 的结果里,它是一组 tar。执行 docker save -o shop-api-1.8.3.tar ghcr.io/example/shop-api:1.8.3 && tar tf shop-api-1.8.3.tar | head -8,你会看到 blobs/sha256/... 一堆层文件加一个 manifest.json、index.json、oci-layout。也就是说:「镜像」本质上是一份带元数据的文件系统 tar 包集合,OCI 规范只是把这格式标准化了。这带来两个实用能力:

  • docker save / docker load:离线分发。内网机器没外网、气隔环境、应急回滚包,都靠它(第十章细讲用法与风险)。
  • 层可以被任何兼容 OCI 的工具消费:Podman、containerd、Kubernetes 直接用 docker build 出来的镜像(第十七章讲 dockershim 移除时这条是重点)。

对应命令组:

命令作用注意
docker images本地镜像列表SIZE 是解压后共享大小,不等于它占的磁盘
docker history层与指令对应表--no-trunc 才看得到完整指令,也是查密钥泄漏的第一现场
docker image inspect完整元数据 JSONRootFS.Layers、Config.Env、RepoDigests、Architecture
docker image ls -q只要 ID配合 docker rmi $(...) 批量删
docker system df磁盘账本清理前先跑它,别反过来
docker save / docker load导出 / 导入 tar不跨架构;tar 体积按未压缩算

这一章的收获应该是:你从此看 docker build 的输出不再是「它在忙」,而是「它重跑到第几层、为什么这层没命中」。下一章把 Dockerfile 的每一条指令讲透,那正是控制分层的唯一手段。

四、Dockerfile 逐条讲透:指令语义、四种组合与元数据层

Dockerfile 是镜像的源代码,也是本教程密度最高的一章。逐条讲不是因为记不住就没法用,而是因为每条指令都有「看起来对、跑起来炸」的陷阱,而陷阱基本都藏在默认行为里。

FROM:起点,也是体积与漏洞的主要来源

FROM node:22-bookworm-slim AS builder     # 带别名,供多阶段构建引用
FROM golang:1.23-alpine
FROM scratch                             # 空镜像:只有你显式复制进去的东西
FROM ghcr.io/example/shop-api:1.8.3@sha256:9c1f0e... # 标签 + digest 双锁

要点:

  • 一个 Dockerfile 可以有多个 FROM,这就是多阶段构建的骨架(第五章);最终镜像只包含最后一个 FROM 之后的内容。
  • FROM 决定了一切上游:glibc 还是 musl、有没有 apt/apk、默认用户是不是 root、有多少 CVE 等着被打。基础镜像选型在第十五章展开。
  • 版本要写具体。FROM node:latest 意味着你的构建今天绿、下个月不知道因为什么红。生产用「大版本 tag + digest」。

COPY vs ADD:默认选 COPY,没有例外

COPYADD
复制本地文件到镜像✓✓
源是 URL(ADD http://…)✗✓(但不是为它设计的功能)
源是 tar 自动解压✗✓(隐式行为,容易踩)
支持 --chown / --chmod✓✓

官方文档自己就写着「通常用 COPY」。ADD 那两个额外能力都有更好的替代品:下载用 RUN curl + RUN rm(能校验、能清理、能合进一层),解压用 RUN tar -xzf。一个细节值得记:ADD <tar-url> 不解压(网络源不触发自动解压),这种不一致正是它该被避开的原因。

COPY 的两个易错点:COPY --chown=app:app --chmod=0644 . /srv/shop/ 避免整棵目录被 root 占有;目标以 / 结尾是「拷进目录」,不以 / 结尾且源是单个文件则是「重命名」。另外 COPY 只能看见构建上下文,COPY ../x 一律失败;.dockerignore 会同时影响 COPY . . 的内容与缓存哈希(第五章)。

RUN:shell 形式与 exec 形式,以及 PID 1 的伏笔

RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*          # 三层合一:一条 RUN 一个层,必须链起来
SHELL ["/bin/bash", "-o", "pipefail", "-c"] # 改默认 shell(见本章 SHELL)
RUN set -o pipefail && make 2>&1 | tee build.log
  • 每多一条 RUN 就多一层,而层里的东西删不掉。所以「装包 → 清理」必须用 && 写在同一条 RUN 里;分开写等于用下一层去遮蔽,磁盘上原文件还在。
  • apt-get update 单独成层会长期缓存,之后装到的永远是那次快照的旧包;和 install 同层才能一起失效。
  • 每条 RUN 都是全新 shell 进程,互不继承状态:RUN cd /tmp 下一层回到工作目录,RUN export FOO=1 下一层消失(要跨指令请用 ENV)。

形式有两种,差别很重要:RUN npm ci(shell 形式,实际执行 /bin/sh -c "npm ci")与 RUN ["npm", "ci"](exec 形式,没有 shell)。shell 形式能用管道、&&、通配符、变量展开,代价是你真正想跑的程序不是 PID 1——这直接决定信号能不能收到(第十六章的优雅停机整章在讲这个坑)。exec 形式里 node 就是 PID 1。

CMD vs ENTRYPOINT:四种组合必须一次讲清

这两个概念之所以绕,是因为它们一个管「主命令的参数」,一个管「主命令本身」,而且只有最后一层生效。

Dockerfile 里写了容器启动时执行docker run img python -m http.server 时执行
CMD ["node","app.js"],无 ENTRYPOINTnode app.jspython -m http.server(整条被替换)
ENTRYPOINT ["node"],无 CMDnodenode python -m http.server(参数追加)
ENTRYPOINT ["node"] + CMD ["app.js"]node app.jsnode python -m http.server(只替换参数——最常用的组合)
ENTRYPOINT ["sh","-c","node $FLAG app.js"] + CMDshell 解释shell 解释,追加的参数变成 $0 $1,CMD 被忽略

实践结论:

  • 一个纯服务型镜像:ENTRYPOINT 定命令、CMD 定默认参数。这样使用者可以用 docker run img --help 换参数,而不是被迫重写整个命令。
  • 一个工具型镜像(ubuntu、alpine):只写 CMD ["bash"],因为用户就是要随便跑点什么,ENTRYPOINT 会碍事。
  • docker run 后面的参数永远覆盖 CMD,--entrypoint 永远覆盖 ENTRYPOINT:docker run --rm --entrypoint sh ghcr.io/example/shop-api:1.8.3 -c "node -v" 是第七章调试的起手式。
  • 只有最后一层生效:Dockerfile 里写两条 CMD,第一条静默失效;继承自基础镜像的 CMD/ENTRYPOINT 也会被你覆盖。想确认最终值:docker image inspect img --format '{{.Config.Entrypoint}} {{.Config.Cmd}}'。
  • ENTRYPOINT/CMD 是元数据层,不占空间,把它们写在最后能最大化缓存命中。

ENV / ARG:构建期与运行期,以及它们都会留在历史里

指令何时可见是否留在最终镜像典型用途
ARG仅构建期(--build-arg 传入)否(值不留,但指令文本留)版本号、代理、构建目标
ENV构建期 + 运行期是(Config.Env)NODE_ENV、PATH、TZ
ARG NODE_VERSION=22.11.0        # 有默认值;ARG 只在声明之后的指令生效
FROM node:${NODE_VERSION}-bookworm-slim   # FROM 之前的 ARG 是「全局作用域」,不进任何层
ENV NODE_ENV=production

两条铁律:

  1. ARG 不产生数据层,但 docker history 里能看到那行指令文本,所以 --build-arg PASSWORD=... 依然是泄漏。
  2. ENV/ARG/RUN 都禁止放密钥:RUN curl -H "token: $SECRET" 里字面量写死的 token,会原样出现在层历史与构建日志里(第十五章会演示用 docker history --no-trunc 把它捞出来)。密钥走 --mount=type=secret 或运行时注入。

ENV 的另一个用途是运行期可覆盖的默认值:ENV TZ=Asia/Shanghai 进镜像,但 docker run -e TZ=UTC 仍能盖掉它——这是第十三章「配置优先级」的第一层。

WORKDIR、USER、VOLUME、EXPOSE、LABEL、HEALTHCHECK、SHELL

WORKDIR /srv/shop      # 比 RUN cd 好:后续 RUN/COPY/ADD 的相对路径基准,且是元数据层
USER 1001:1001         # 之后的 RUN 与容器运行都以该身份(rootless 与第十五章相关)
VOLUME /var/lib/mysql  # 声明「这里该有独立存储」,构建时不生效、运行时无名卷兜底
EXPOSE 3000            # 纯文档:不发布端口、不写 iptables,只是给人和 tool 看的
LABEL org.opencontainers.image.source="https://github.com/example/shop"  # 治理用
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD node healthcheck.js
SHELL ["/bin/bash", "-eo", "pipefail", "-c"]   # 改默认 shell,让管道失败可见

逐条说清「为什么这么设计」和「不这么做会怎样」:

  • WORKDIR 而不是 RUN cd:RUN cd /x && make 那条指令内确实生效,但下一层又回到原地;而 WORKDIR 会自动创建目录、跨指令生效、且只是元数据(不新增大小)。用绝对路径,别用 ~。
  • USER 放在能放的位置:如果 USER app 之后的 RUN 需要往系统目录写,构建就会失败。正确姿势是 root 完成安装、chown/--chown 好、最后 USER。
  • VOLUME 有副作用:它保证挂载点里不会被 RUN 写入的内容进入镜像(构建时对该目录的写会被丢弃),也让 docker run 忘了挂卷时悄悄建一个匿名卷(第九章讲怎么认出这种「幽灵数据」)。数据库镜像里 VOLUME /var/lib/mysql 就是这个意思:那目录里的数据不该属于镜像。也正因为 VOLUME 会丢写,如果你想在镜像里预置模板数据,得换个路径。
  • EXPOSE 只是文档:docker run -p 才是真的开端口映射;EXPOSE 的作用是让 docker ps 显示、让 --publish-all(-P)有东西可发布、让 Compose 的 expose 语义有依据。不写它不会连不上,写错它也不会连不上——很多人被这点反复误导。
  • LABEL 用于治理:镜像多了以后,「这是谁建的、源码在哪、什么构建时间」全靠 OCI 标准标签(org.opencontainers.image.*)。别把动态值(如 git rev)放进第一层,否则每次构建所有层全失效。
  • HEALTHCHECK 是「自证活着」的声明:没有它,Docker 只知道进程在不在,不知道服务能不能用。它是第十六章就绪判断和 depends_on: service_healthy 的基础。注意:unhealthy 不会自动重启容器,得配合重启策略或外部 watcher。
  • SHELL 改默认 shell:/bin/sh 各发行版行为不同(dash vs bash),管道失败与否、set -e 支持都不同。想写严谨的构建脚本就显式切 bash 并开 pipefail。

指令顺序:缓存的分水岭

把上面的知识合起来就是一条排序原则——变化频率从低到高。下面这个左右对比能背下来:

FROM node:22-bookworm-slim                # 很少变
WORKDIR /srv/shop                          # 很少变
COPY package.json package-lock.json ./     # 依赖变更时才变
RUN npm ci --omit=dev                       # 跟着依赖锁变
COPY . .                                    # 每次都变 ← 分水岭
ENV NODE_ENV=production                     # 元数据
EXPOSE 3000                                 # 元数据
CMD ["node", "server.js"]                   # 元数据,永远放最后

反过来写(先 COPY . . 再 RUN npm ci)的话,改一行注释就要重装依赖。示例 04 把这份 Dockerfile 做成了可点交互:点任意一条指令,右侧的层堆叠会即时高亮「新增层 / 元数据层 / 复用缓存」,并演示改动源码时的失效范围。

示例 04:Dockerfile 指令 → 分层效果可视化在新标签页打开

一个可以直接抄的最小 Dockerfile

FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /srv/shop
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --npm-config.ignore-scripts=false
COPY src ./src
EXPOSE 3000
USER 1001:1001
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD node -e "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["node", "src/server.js"]

里面每一行的理由:ENV 提前让依赖只装生产包;两次 COPY 切出缓存分水岭;USER 用数字 UID(不依赖镜像里恰好存在那个用户名);HEALTHCHECK 走 exec 形式并显式 || exit;CMD 用 exec 形式以便收信号。下一章讲构建流程时,我们围绕它做缓存实验。

五、构建流程与缓存:BuildKit、.dockerignore 与多阶段构建

docker build 的最小形态与必给参数

docker build -t ghcr.io/example/shop-api:1.8.3 .
docker build -f Dockerfile.prod --target runtime -t shop-api:1.8.3 ./backend
参数作用什么时候必须用
-t name:tag命名并打标签每次;没 tag 的镜像是悬空镜像,难找难删
-f / --target指定 Dockerfile / 多阶段只做到某阶段一仓库多构建;单独调试 builder 阶段
--build-arg K=V传 ARG版本号、代理、构建开关
--pull强制重新拉基础镜像追基础镜像的安全补丁(否则用本地旧副本)
--no-cache全量重建复现 CI 的干净构建,日常别用(慢)
--platform指定目标平台在 x86 上给 ARM 设备构建
--push / --load构建完推送 / 载入本地多架构时 --load 不支持多平台镜像
--progress=plain展开完整日志构建失败时看清是哪条指令哪一行

结尾那个 . 是构建上下文:Docker 会把它整个目录打包传给 daemon。目录里塞了 2GB 的 node_modules 或 .git,你每次构建就白传 2GB——这是 .dockerignore 存在的第一理由。

BuildKit 与 # syntax=

从 Docker 23 起 BuildKit 是默认构建器。它相对老构建器的差异不是「快一点」,而是三条实质能力(--mount、heredoc、并行构建)。启用新语法靠文件第一行的解析器指令:

#syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --link . .                         # 不因父层权限反复失效,也不重置时间戳
RUN <<EOF                             # heredoc:多行脚本不用拼 && 与转义
  addgroup -g 1001 app && adduser -S -D -u 1001 -G app app
EOF

#syntax= 必须放在 ARG/FROM 之前才生效(规范写法哈希后带空格也合法,解析器允许)。写 docker/dockerfile:1 表示跟随 1.x,写 docker/dockerfile:1.7 是固定版本、CI 可复现。--mount=type=cache 的缓存不进镜像,只在构建环境保留,能把「每次重装依赖」从 90 秒压到 5 秒;代价是它不受层缓存控制,换基础镜像后可能读到旧包(必要时 docker builder prune)。

示例 05 用同一个 Dockerfile 做三种改动——改注释、改依赖锁、改源码——对比 docker build 的逐层输出,失效起点完全不同。

示例 05:构建缓存——改哪一层就从哪一层重跑在新标签页打开

层缓存命中的判定规则

一句话规则:命中 = 父层 ID 相同 + 指令文本相同 + (对 COPY/ADD)被复制文件的内容哈希相同。三条任何一条变了,这一层以及它之后的所有层全部重建。

变更内容从哪一层开始重建
只改 CMD/ENV/EXPOSE 的值从那条元数据指令起(前面全命中,重建很快)
改 README.md(没被 COPY 到)通常不重建;但若它在上下文里且被 COPY . . 覆盖,就重建
改 package-lock.jsonCOPY package-lock 那层起重建
改 src/server.jsCOPY . . 那层起重建
基础镜像更新(--pull)全部重建
文件权限/mode 变化也算内容变化(COPY 把 mode 纳入哈希)

两个反直觉细节:

  • COPY . . 的哈希是整个上下文(扣除 .dockerignore)算的。于是 .gitignore 改了、dist/ 多了临时文件、生成了 *.log 都会击穿缓存。把 COPY . . 收窄成 COPY src ./src,既减小上下文又稳定缓存。
  • RUN 只看指令文本,不看外部世界。RUN apt-get install -y curl=7.88.1-1+deb12u1 带版本写法能按需失效;而 RUN git clone https://github.com/example/shop 只要文本不变就永远命中旧缓存,拿到的是半年前的代码——「代码明明更新了镜像里却是旧的」的成因。解法:用 COPY 放进去,或加 --build-arg COMMIT= 让文本变化。

.dockerignore:不是可选项

.git
**/node_modules
dist
coverage
*.log
.env
.env.*
!.env.example
compose*.yaml
tests

不写它的三个后果:构建变慢(上下文几 GB)、缓存被击穿(任何杂文件都改内容哈希)、泄密(.env、id_rsa 被 COPY . . 打进镜像,docker history 与 docker save 之后都能翻出来)。语法接近 .gitignore,有一处常见「误报错」:.env 被 ignore 后 COPY .env /app/ 直接失败——这不是 bug,而是提示:镜像里不该有需要 .env 的东西(第十三章)。

多阶段构建:编译阶段与运行阶段彻底分开

问题的形状:构建 Node/Go/Java 应用需要编译器、包管理器、源码、测试依赖、头文件,运行只需要运行时和产物。把它们留在一个镜像里,等于把攻击面和体积一起打包上线。

#syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /srv/shop
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-bookworm-slim AS build
COPY --from=deps /srv/shop/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime        # 只有最后一个 FROM 之后的内容进最终镜像
ENV NODE_ENV=production
WORKDIR /srv/shop
COPY --from=build /srv/shop ./
USER 1001:1001
EXPOSE 3000
CMD ["node", "server.js"]

要点四条:COPY --from=<阶段名|序号|外部镜像> 从别的阶段取产物,未被最终阶段引用的中间阶段会被跳过;产物里不要夹带构建工具(npm prune --omit=dev;Go 用 CGO_ENABLED=0 静态编译后拷进 FROM scratch);阶段命名可读(AS deps)才方便 --target deps 单独构建调试;各阶段共享同一基础镜像的层,多阶段不额外吃磁盘。常见量级:1.2GB → 180MB。

buildx 与多架构

docker buildx create --name multi --driver docker-container --use
docker buildx inspect --bootstrap
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/example/shop-api:1.8.3 --push .
docker manifest inspect ghcr.io/example/shop-api:1.8.3

跨架构构建靠 QEMU 用户态模拟(binfmt 注册解释器),慢且某些编译会段错误,稳妥做法是用原生 runner(CI 上的 ARM 机器或云端构建)。一次产出多架构的结果是一个 manifest list(镜像索引),--load 只能载入单个平台,多架构必须 --push(或 --output type=oci,dest=out.tar)。exec format error 的根因就在这里:拿 amd64 镜像去 ARM 上跑;查平台用 docker image inspect --format '{{.Os}}/{{.Architecture}}',验证可以用 docker pull --platform linux/arm64 busybox。

构建参数与 secrets mount

需要凭据才能构建(私有 npm registry、GitHub Packages)时,不要用 --build-arg TOKEN=xxx,它会留在层历史里;用 BuildKit 的 secret mount:

printf '%s' "${NPM_TOKEN}" | docker buildx build --secret id=npm_token,src=- -t shop-api:1.8.3 .
RUN --mount=type=secret,id=npm_token,target=/run/secrets/npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci --omit=dev

--mount=type=secret 的文件只在这条指令执行期间存在,不进任何层、不出现在 docker history。另外 --progress=plain 的日志会被 CI 存档,别把 secret echo 出来。

构建侧的收获总结成三句:Dockerfile 的顺序决定速度,.dockerignore 决定速度与安全性,多阶段决定体积与攻击面。下面进入运行侧。

六、容器生命周期与 run 参数:一张表把命令行讲清

镜像是死的,容器是活的。这一章把 docker run 的几十个参数按用途分组,再解决「docker ps -a 里那些状态字符串到底在说什么」。

状态机:created → running → (paused) → exited → removed

docker create --name shop-web nginx:1.27-alpine    # 只建不跑:created
docker start shop-web                              # running
docker pause shop-web; docker unpause shop-web     # 冻结 / 解冻(cgroup freezer)
docker stop -t 10 shop-web                         # 先 SIGTERM,超时后 SIGKILL → exited
docker kill -s SIGTERM shop-web                    # 直接发信号,默认 SIGKILL
docker rm shop-web                                 # 删除容器(连同可写层)
docker container prune                             # 清掉所有已停止容器

三条要点:

  • stop 是请求,kill 是强制。stop 发 SIGTERM 并等 -t 秒(默认 10),到点没退就 SIGKILL。这段等待期是应用收尾(放连接、刷缓冲、提交 offset)的唯一窗口——第十六章整章讲这条链路为什么经常失效。
  • pause 不是「暂停服务」。它冻结进程,TCP 连接会一直挂着直到超时;而且暂停中的容器不能 exec 也不能 stop(要先 unpause)。生产里几乎不用。
  • --rm 让容器一次性:主进程退出即自动 docker rm,可写层与匿名卷一起销毁。好处是不留垃圾,坏处是排障现场消失了——调试阶段永远别加 --rm,有数据的容器永远不要加。

run 参数分组速查

组参数说明与坑
运行控制-d后台运行并打印容器 ID
-it-i 保持 stdin、-t 分配伪终端,通常两个一起用;cron/CI 里不要加 -it,否则 the input device is not a TTY
--rm退出即删,别用于有数据的容器
标识--name / --hostname前者是容器名(也是网络里的 DNS 名),重名报 Conflict. The container name ... is already in use;后者只改容器内 hostname 的值,两者是两回事
--label env=prod打标签,配合 --filter label=env=prod 批量管理
端口-p 8080:80宿主 0.0.0.0:8080 → 容器 80;写 -p 8080:80/udp 才是 UDP,不写默认 tcp
-p 127.0.0.1:8080:80只绑回环,数据库与管理面板的默认写法
-p 8080 / -P前者宿主端口随机、后者把所有 EXPOSE 过的端口都随机发布(docker ps 看结果)
环境-e KEY=VAL可重复;-e KEY 表示从当前 shell 透传
--env-file .env.production每行 K=V,# 开头是注释;别把 .env 提交
挂载-v name:/path命名卷(省略 name 则是匿名卷);-v ./conf:/etc/app:ro 是 bind mount + 只读,相对路径基于当前目录
--mount type=volume,source=db,target=/var/lib/mysql等价但可写 readonly、volume-opt,拼错会直接报错(第九章对照表)
网络--network shop-net / --network host自定义网络(有 DNS,第八章);host 共享宿主栈(Mac/Windows 上不生效,且让 --dns、--network-alias 失效)
--add-host db:10.0.0.5往 /etc/hosts 追加一条
重启--restart unless-stopped四种策略见第十六章
资源--memory 512m / -m内存上限,超了就 Exited (137);--memory-swap 768m 是总内存 + swap,语义特殊,第十四章专门讲
--cpus 1.5 / --cpuset-cpus 0,1 / --pids-limit 256CPU 配额 / 绑核 / 防 fork 炸弹
安全--user 1001:1001以该身份运行(对齐 bind mount 属主时用)
--read-only --tmpfs /tmp根文件系统只读 + 临时可写目录
--cap-drop ALL --cap-add NET_BIND_SERVICE能力裁剪;--security-opt no-new-privileges 禁止提权(第十五章)
--init用 tini 当 PID 1,转发信号、回收僵尸进程
--entrypoint sh覆盖 ENTRYPOINT 来调试(第七章)

docker ps -a 的 STATUS 怎么读

STATUS 片段含义下一步查什么
Up 3 hours正常运行 3 小时—
Up 3 hours (healthy)健康检查通过第十六章 HEALTHCHECK
Up 3 hours (health: starting)还在 --start-period 内连续通过 retries 次才转 healthy
Up 3 hours (unhealthy)检查连续失败容器还在跑但服务不可用;Docker 不会自动重启它
Restarting (1) 5 seconds ago崩溃后被重启策略反复拉起docker logs --tail 50;1 是退出码
Exited (0) 2 hours ago正常退出服务型容器退 0 多半是「前台命令跑完了」,检查 CMD
Exited (1) 5 minutes ago应用自身报错logs → --entrypoint sh(第七章三步法)
Exited (126)命令不可执行(权限/不是可执行文件)检查 ENTRYPOINT 指向的文件与 +x
Exited (127)命令找不到(路径错/缺动态库)--entrypoint sh 进去 ls、ldd
Exited (137) = 128+9被 SIGKILL:OOM、docker kill、或 stop 超时inspect 看 OOMKilled(第十四章)
Exited (143) = 128+15被 SIGTERM 终止(没自己处理)优雅停机没做好(第十六章)
Created只 create 过,从未成功启动docker start 看它报什么
Dead清理时出错,异常状态docker rm -f,并查内核日志

退出码只需记一条:128 以上是「被信号 X 杀死」,X = 码 − 128。137−128=9(KILL)、143−128=15(TERM)。看到 Exited (137) 先怀疑资源限制,别急着改业务代码。

观察一个正在跑的容器

docker logs -f --tail 100 --since 30m shop-api     # -f 跟随,-t/--timestamps 加时间戳
docker stats --no-stream                            # 一次快照:CPU%、MEM USAGE/LIMIT、NET、BLK IO、PIDS
docker top shop-api                                 # 宿主视角看容器进程(真实 PID 与 USER)
docker diff shop-web                                # 可写层里增(A)/改(C)/删(D) 的文件
docker events --since 30m --filter event=die        # 生命周期事件

各自的用处:

  • logs 读的是容器的 stdout/stderr,不是容器里的文件。应用若把日志写进容器内文件,docker logs 永远空白——这就是 12-factor 要求「日志打到标准输出」的原因(第十三章)。
  • stats 的 MEM USAGE / LIMIT,LIMIT 是 cgroup 上限而不是宿主内存,百分比按上限算——判断「是不是被限制死了」的最快入口。--no-stream 只采一次,适合脚本。
  • top 解决「容器里跑着 java 但镜像没装 ps」的尴尬(第七章)。
  • diff 是个便宜的侦探:让容器跑一轮业务再看 diff,就知道该挂哪个卷、--read-only 会挡住哪些路径。

示例 06 把这套观察串成一次真实的排查流:从 docker ps -a 里那行 Restarting (1) 开始,逐步定位到 Exited (137)。

示例 06:生命周期与 STATUS——把状态字符串读成结论在新标签页打开

生命周期搞清楚了,下一章学「进去看」——以及当镜像里什么都没有时怎么调试。

七、进入容器与调试:exec、inspect 与「镜像里什么都没有」

调试容器的心法只有一句:容器就是一个进程,进不去就用外部的眼睛看。这一章把两种眼睛都给你。

docker exec 与 -- 的坑

docker exec -it shop-api sh          # 最常用:alpine/busybox 只有 sh;Debian 系镜像才有 bash
docker exec -u root -it shop-api sh  # 镜像里 USER 非 root 时临时提权
docker exec shop-api cat /etc/os-release        # 非交互,适合脚本
docker exec shop-api ls -- -p                   # 文件名以 - 开头时必须用 -- 分隔

为什么很多镜像没有 bash?因为基础镜像是 alpine/busybox(体积小、攻击面小),甚至 distroless/scratch 连 sh 都没有。这不是作者恶意,而是第十五章的取舍结果。遇到没 shell 的镜像,看本章后半部分。

-- 的规则只有一条:「选项解析到此为止」。docker exec -it c -- node -v 里 -- 之后全部交给容器;docker run --rm img -- --version 里 docker 会把第二个 -- 之后的内容当成「镜像自己的参数」,行为和你预期不同。当你要传的参数本身带 -(-p、--version),不加分隔符时 docker 会先把它当成自己的选项,报出的错误通常很难看。

inspect:用 --format 只要你要的字段

docker inspect 一次吐几 KB JSON,硬读会累死,--format 才是正解:

docker inspect shop-api --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
docker inspect shop-api --format '{{json .Config.Env}}'      # 同样套路:{{json .Mounts}}、.State.*
docker inspect --format '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' shop-api shop-db
docker ps --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'   # ps 同样支持,脚本神器

--format 是 Go 模板:{{.X}} 取值、{{json .X}} 打印结构、{{range}} 遍历、{{if}} 条件。报 nil can't be fields 一般是路径写错(比如 NetworkSettings.Networks 是 map,得 range 进去),先原样 inspect 一次看清结构再改模板。

docker cp:双向搬文件

docker cp shop-api:/srv/shop/config/app.yaml ./app.yaml 是容器 → 宿主,docker cp ./nginx.conf shop-web:/etc/nginx/conf.d/site.conf 是宿主 → 容器。

用途是把配置塞进去做一次性试验、把日志与产物捞出来。两个限制:运行中改的文件容器重建就没了(所以它是调试手段,不是部署手段);cp 目录时的合并语义与 rsync 不同,路径末尾的 / 要写清楚。

镜像里没有编辑器、没有 ps、没有 curl 怎么办

按代价从小到大三个方案:

方案做法适合代价
从外部看docker top、docker logs、docker cp、宿主 /proc/<pid>/、nsenter只想看状态与文件需要宿主权限
临时装工具docker exec -it shop-api sh -c 'apk add --no-cache curl tcpdump'一次性排查破坏「镜像不可变」,只允许在开发/临时环境
调试镜像单独 Dockerfile.debug(含 strace/lsof/jq),或把 nicolaka/netshoot 连进同一网络反复排查网络与进程多一份镜像要维护

从宿主直接看容器内部,是很值钱的一招(Linux 上):

PID=$(docker inspect --format '{{.State.Pid}}' shop-api)
sudo ls -l /proc/$PID/root/srv/shop; sudo cat /proc/$PID/cgroup   # 容器的根文件系统 / 它归属哪个 cgroup
sudo nsenter -t $PID -n ss -lntp                                 # 进入它的 net namespace 看监听端口
sudo nsenter -t $PID -m -p -- cat /proc/1/environ | tr '\0' '\n'

nsenter 借用容器的 namespace,看到的端口、路由、文件都是容器视角,而工具装在宿主上——镜像里什么都没有也能查。

起来就退出:--entrypoint sh 是唯一可靠的入口

容器 Exited (1) 且没有日志时,你连 exec 的机会都没有(它已经不跑了)。正确做法是把主命令换成 shell,再手工跑原来那条:

docker run --rm -it --entrypoint sh ghcr.io/example/shop-api:1.8.3
/srv/shop $ node server.js        # 错误现在直接打到你的终端上
/srv/shop $ env | sort; id; ls -l .

三步定位法(第十八章的排障表也按它走):docker logs --tail 50 <name> → 没输出就 --entrypoint sh 手工启动 → 怀疑环境差异就在 shell 里核对 env、id、路径与权限。

docker events:谁在什么时候动了什么

docker events --since 30m --until 10m --filter type=container,或者按事件与标签收窄:docker events --filter 'event=die' --filter 'label=app=shop-api'。

die 事件带 exitCode,--since/--until 支持相对时间。它能回答「谁杀了我的容器」:如果 die 前面有 kill 事件,那是人为或脚本发的信号;只有一个 die 且 OOMKilled=true,那是内核干的(第十四章)。

为什么容器里通常没有 systemd

因为容器不是虚拟机。systemd 的职责是管理一整台机器(init、服务、cgroup、日志、登录会话),而容器的定位是「一个进程 + 它需要的东西」:

  • PID 1 应该留给你的应用;给了 systemd,信号、退出码、日志转发全乱(第十六章的信号问题会加倍)。
  • systemd 要写 cgroup 树、访问 /sys/fs/cgroup、可能需要 CAP_SYS_ADMIN——在共享内核的容器里做这些,等于和宿主的管理者(dockerd、kubelet)抢 cgroup。这就是第二章 docker info 里 Cgroup Driver 必须统一的深层原因。
  • 需要多个服务时的答案是多容器 + Compose(第十一章),不是在一个容器里跑五个 daemon。

确有特例(跑完整发行版做系统级测试),社区做法是带 systemd 的特权镜像,形如 --cgroupns=host --tmpfs /run --tmpfs /run/lock -v /sys/fs/cgroup:/sys/fs/cgroup:ro。生产里把它当代码异味看待。

示例 07 把 exec、inspect --format、top、cp、--entrypoint 串成一次完整排查演练,全部输出是真实样本。

示例 07:进入容器与调试——exec / inspect / cp / entrypoint在新标签页打开

调试能力有了,下一章处理运行侧最容易出事的一块:网络。

八、端口与网络:bridge、自定义网络与那个会变 IP 的坑

网络是 Docker 里概念最密的一块,因为在 Linux 上网络本来就是内核子系统。路线:先讲驱动有哪些,再讲默认网桥为什么不适合生产,然后是自定义网络的 DNS,最后是端口与暴露边界。

五种网络驱动与取舍

驱动机制典型用途代价与限制
bridge(默认)Linux bridge + veth + NAT本地开发、单机多容器默认网络无 DNS、有 NAT 开销
host容器直接用宿主网络栈追求极致性能、需要看宿主网卡直接占用宿主端口、与其他容器 hostname 混在一起、Mac/Windows 无效
none只有 lo 回环纯计算任务、彻底断网完全没网络
overlayVXLAN 隧道跨宿主Swarm 多机服务只有 Swarm 才用得上(第十七章)
macvlan / ipvlan容器在局域网里拿到独立 MAC/IP需要以「独立网络设备」身份存在与宿主通信受阻、交换机端口安全/802.1X 会拒、AP 隔离网络无效

选择顺序基本是:自定义 bridge(单机首选) → host(明确要性能或要宿主栈) → macvlan(明确要求二层可达) → overlay(跨机且用 Swarm)。「默认 bridge」不在可选之列,理由见下一节。

默认网桥 docker0 的真实行为

装完 Docker,宿主上会出现 docker0 网桥,默认网段 172.17.0.0/16。不加 --network 的容器都接在上面。它有两个坑:

  1. 没有基于名字的 DNS。默认网络里容器只能靠 IP 互相访问,而 IP 每次重启都可能变:docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' shop-api 今天拿到 172.17.0.2,重启后可能变成 172.17.0.3——任何把 IP 写进配置的用法都是定时炸弹。
  2. 同网段一律互通(icc=true),且所有容器混在一张广播域里,噪音与端口冲突随容器数量增长。

结论:生产与多容器开发一律用自定义网络,默认 bridge 只适合「临时跑一个孤立容器」。

自定义网络与 DNS 解析

docker network create --driver bridge --subnet 172.28.0.0/16 --gateway 172.28.0.1 shop-net
docker network connect shop-net shop-api     # 断开是 network disconnect;network inspect 看细节
docker network ls; docker network rm shop-net

自定义网络里,容器名就是主机名,可以直接 curl http://shop-api:3000。原理是每个网络内嵌一个 DNS 服务器(容器里 cat /etc/resolv.conf 会看到 nameserver 127.0.0.11),它把容器名与网络别名解析成当前 IP——IP 变了名字不变,配置不用改。这就是「不要靠 IP,要靠名字」的技术依据。

docker run -d --name shop-api --network shop-net --network-alias api ghcr.io/example/shop-api:1.8.3
docker exec shop-web curl -s http://api:3000/healthz        # 别名同样可用
docker exec shop-api getent hosts shop-db                    # 看 DNS 实际解析到什么

--link 是老写法(默认网络时代的补丁),现在忘掉它:它靠注入环境变量和改 /etc/hosts 工作,行为隐晦、已废弃,Compose 里写了也不起作用。

network create 值得知道的开关:--internal(完全不出外网,只让容器间互通)、--ipv6、--ip-range、--opt com.docker.network.driver.mtu=1400(overlay/VXLAN 必须减 MTU,否则出现「小请求正常、大请求卡死」的经典诡异现象)、--attachable(Swarm,第十七章)。

端口映射:四种写法与「port is already allocated」

docker run -d -p 8080:80 shop-web             # 宿主 0.0.0.0:8080 → 容器 80
docker run -d -p 127.0.0.1:8080:80 shop-web   # 只监听回环(推荐的默认姿势)
docker run -d -p 8080 shop-web                # 宿主端口随机;`-p 80/udp` 则是显式指定协议

背后是 docker-proxy 进程 + iptables DNAT。因此端口不通的排查顺序是固定的四步:

docker ps --format '{{.Names}} {{.Ports}}'                # ① 映射到底存在吗
docker exec shop-web sh -c 'ss -lntp || netstat -lntp'    # ② 容器里真的在听吗?听的是 0.0.0.0 吗
sudo ss -lntp | grep 8080                                 # ③ 宿主上这个端口被谁占了
sudo iptables -t nat -S DOCKER | grep 8080                # ④ NAT 规则真的写进去了吗

第②步是新手最大的盲区:应用只 bind 127.0.0.1 时,端口映射永远不会通——从 veth 进来的包不是本地回环来源。所以「容器里的服务必须 bind 0.0.0.0」是硬性要求,而很多框架默认绑 localhost(Uvicorn、部分 Spring 配置、老版 webpack devServer),必须显式改。

Error response from daemon: driver failed programming external connectivity ... port is already allocated 的三类原因:别的容器占了(docker ps 查)、宿主进程占了(ss -lntp 查)、daemon 上次异常退出留下僵尸 docker-proxy(重启 docker 解决)。示例 08 是一次完整演练:从「连不上」一路查到「应用只 bind 了 127.0.0.1」。

容器互访、宿主访问容器

  • 容器 → 容器:同一个自定义网络,用容器名/别名 + 容器内端口(http://shop-db:5432)。不要绕道宿主映射端口,也不要用 IP。
  • 宿主 → 容器:curl localhost:8080(走映射);Linux 上还能直接 curl 172.28.0.5:5432(容器 IP,只用于调试)。
  • 容器 → 宿主上的服务:用 host.docker.internal。Docker Desktop 自带该域名;Linux 上要手动加:
docker run -d --name shop-api --add-host=host.docker.internal:host-gateway ghcr.io/example/shop-api:1.8.3
docker exec shop-api curl -s http://host.docker.internal:5432/

host-gateway 是特殊值,让 Docker 自己填宿主在容器网络里的网关 IP,比写死 172.17.0.1 可靠。Compose 里对应 extra_hosts: ["host.docker.internal:host-gateway"](第十二章)。注意 macOS/Windows 上容器里的 127.0.0.1 不是你本机(第二章那台虚拟机),别拿 localhost 指宿主。

  • --network=host 为什么在 Mac 上不生效:那个 host 是 Docker VM 的 host,不是你 Mac 的;表现为容器内部能通、外部访问不了、-p 被忽略。Linux 上则真的共享栈:ss -lntp 能直接看到容器进程,同时端口冲突会打到宿主服务——宿主跑着 nginx 又 --network host 起一个 nginx,就是 address already in use。

暴露到公网的安全边界

写法可达范围评价
-p 8080:80所有网卡(含公网 IP)只在真正对外服务时用
-p 127.0.0.1:8080:80仅本机数据库、管理面板、内部 API 的默认写法
-p 10.0.0.5:8080:80指定网卡(如内网口)内外网分流时用
不写 -p,只在网络内互通同网络容器最稳妥的默认

还有一条只在 Docker 出现的经典事故:iptables 让 ufw/firewalld 的规则形同虚设。Docker 在 PREROUTING/FORWARD 里插了自己的链,优先级高于 ufw,于是你 ufw deny 5432 之后公网照样连得上数据库。解法是把访问控制放进 DOCKER-USER 链(Docker 不会重写它),或者从一开始就不给容器绑 0.0.0.0。

示例 08:网络与容器互访——网段、DNS 与端口排查在新标签页打开

网络通了,数据该往哪儿放?下一章讲四种存储方式,以及「容器删了数据没了」这个事故是怎么发生的。

九、数据持久化:匿名卷、命名卷、bind mount 与 tmpfs

四种存储,先分清「谁拥有这块空间」

容器可写层随 docker rm 消失,所以要持久化的数据必须放到容器文件系统之外。Docker 给了四条路:

类型谁创建与管理宿主上的位置典型用途容器删了数据还在吗
匿名卷Docker 自动建(a1b2c3... 这种名字)/var/lib/docker/volumes/<随机名>/_data临时容器、忘了挂卷时的兜底在(成为孤儿卷,prune 才清)
命名卷你显式命名,或 Compose 声明/var/lib/docker/volumes/shop_db-data/_data数据库、需要备份与迁移的数据在,且可预期
bind mount你指向宿主任意路径就是那个路径本身开发时挂载源码、挂配置文件在(本来就在宿主)
tmpfs内存/swap,不落盘内存密钥、会话、临时上传缓冲不在(重启即清零)

选择依据只有一句:要不要被 Docker 生命周期管理。要 → 卷(跨机器一致、可备份、不受宿主目录权限影响);要直接读写宿主上的真实文件(编辑即生效)→ bind mount;不能落盘 → tmpfs。

-v 与 --mount 语法对照

两种写法能表达的集合不同:

docker run -d --name shop-db -v db-data:/var/lib/mysql postgres:16.4
docker run -d --name shop-db --mount type=volume,source=db-data,target=/var/lib/mysql postgres:16.4
docker run -d --name shop-web -v /srv/shop/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:1.27-alpine
docker run -d --name shop-web --mount type=bind,source=/srv/shop/nginx.conf,target=/etc/nginx/conf.d/default.conf,readonly nginx:1.27-alpine
-v 写法--mount 等价说明
-v /abs/path--mount type=volume,target=/abs/path只给目标 → 匿名卷(最容易长出孤儿卷)
-v name:/path--mount type=volume,source=name,target=/path命名卷;name 不以 .// 开头即判定为卷名
-v ./host:/cont--mount type=bind,source=$(pwd)/host,target=/contbind;-v 相对路径基于当前工作目录
-v a:b:ro,z--mount type=bind,...,readonly,z选项:ro、rw、z/Z(SELinux)、volume-opt

实践建议:脚本与 Compose 用 --mount——参数拼错(比如卷名写了相对路径)时 --mount 会明确报错,而 -v 会静默地给你建一个空目录,最后表现为「配置没生效」。手工敲命令时 -v 省事。tmpfs 则只能用长语法:--mount type=tmpfs,tmpfs-size=64m,target=/run/redis(-v 表达不了大小限制)。

一个必须提前知道的坑:-v ./conf:/etc/app 若宿主的 ./conf 不存在,Docker 会创建它并 chown 给 root;容器内原本 /etc/app 里的文件会被空目录遮住,于是出现「镜像里有配置,挂载后配置没了」。

--volumes-from:把别人的卷借过来

docker run --rm --volumes-from shop-db -v "$PWD:/backup" alpine \
  tar czf /backup/db-data-20261004.tar.gz -C /var/lib/mysql .

--volumes-from 复用另一个容器声明过的所有挂载(包括匿名卷),是「备份/恢复容器数据」的标准套路。注意它继承的是那个容器创建时的挂载组合,改了原容器的挂载配置要重建才生效。

卷在宿主上的实际位置,和怎么看清它

docker volume ls; docker volume inspect db-data     # inspect 里的 Mountpoint 是宿主真实路径
sudo du -sh /var/lib/docker/volumes/* | sort -h | tail
docker volume rm db-data; docker volume prune       # prune 会删掉所有没被任何容器引用的卷

inspect 的 Mountpoint 就是宿主真实路径(rootless 下是 ~/.local/share/docker/volumes/...)。docker volume prune 会删掉所有没被任何容器引用的卷——包括你上周测试留下的数据库,所以先 ls 再 rm,别直接 prune(第十四章把这条列入危险命令)。

两种备份/迁移方式:

docker run --rm --volumes-from shop-db -v "$PWD:/backup" alpine tar czf /backup/db.tar.gz /var/lib/mysql
docker run -d --name shop-db-new -v db-data-new:/var/lib/mysql postgres:16.4
docker run --rm -v "$PWD:/backup" -v db-data-new:/var/lib/mysql alpine tar xzf /backup/db.tar.gz -C /

第二种把卷挂进一个临时容器再解包,好处是不依赖原容器存在,跨机器迁移时只需要那个 tar。生产上更常见的是走数据库自身的备份(pg_dump),卷快照只作灾备——文件级备份对运行中的数据库常常是不一致的。

bind mount 的权限坑:UID 对不上是最常见故障

现象:挂载成功、目录可见,但写进去报 EACCES: permission denied。原因是容器内的 UID 和宿主目录属主对不上——权限检查用的是数字 UID,不看用户名。

docker exec shop-api id                # uid=1001(app) gid=1001(app)
ls -ld /srv/shop/uploads               # drwxr-xr-x 2 root root ... /srv/shop/uploads
docker exec shop-api touch /srv/shop/uploads/t   # permission denied → sudo chown -R 1001:1001 对齐数字 UID

四种解法与取舍:

解法做法备注
改宿主目录属主chown 1001:1001(数字 UID)最直接;要知道镜像里应用跑在哪个 UID(docker inspect 或 Dockerfile 的 USER)
用 --user 对齐--user "$(id -u):$(id -g)"开发机神器:容器进程就是你的宿主用户,写出的文件属主正确
改组权限chgrp + chmod g+w + --group-add需要共享写时更协作友好
改用命名卷让 Docker 自己管理目录卷首次挂到空目录时会把镜像里该目录的内容复制进卷(bind mount 不会!)——这条差异能救「配置丢了」的故障

rootless 模式再多一层:subuid 映射。容器里的 UID 会被平移,docker exec 看到 uid=0 实际是宿主的 100000,宿主目录属主要按 /etc/subuid 的偏移算(grep $(whoami) /etc/subuid)。这也是 rootless + bind mount 反复出权限问题的原因,第十五章还会遇到它。

为什么数据库必须用卷,镜像里为什么不该有数据

三个理由:

  • 性能:往镜像层所在的 overlayfs 写随机 IO 明显慢于专用文件系统目录(卷或 bind)。数据库这种写密集负载,overlay2 的 copy-up 会让每次首次写都变成「整文件复制」。
  • 生命周期:容器是可丢弃的,数据库不是。挂卷之后 docker rm 掉容器,重新 run 同名卷,数据回来了。
  • 可移植与备份:卷能被 volume inspect、被快照、被 --volumes-from 备份;容器可写层这三样都做不好。

而「镜像里的 /var/lib/mysql 为什么不该写进镜像」:镜像层是只读的,运行时对它的一切写入都落在容器层;把初始化数据打进镜像会导致镜像体积暴涨、每个容器各存一份、VOLUME 声明处还会丢弃写入(第四章)。更关键的是镜像会泄密——初始化 SQL 里往往带着默认密码、示例账号,任何能 pull 的人都能 docker save 后翻出来。正确做法是首次启动时用 POSTGRES_* 环境变量或 initdb 钩子建库(第十三章)。

示例 09 用一次完整流程演示这四种存储、diff 发现写入位置、以及那个权限坑的现场与修法。

示例 09:卷与 bind mount——数据去哪了与权限坑在新标签页打开

数据落盘的问题解决了,下一章解决「镜像从哪来、到哪去」:仓库、tag 规范与私有 registry。

十、镜像仓库与 tag 规范:拉、推、锁定与私有 registry

一个镜像的完整名字长什么样

docker pull nginx:1.27-alpine
docker pull registry-1.docker.io/library/nginx:1.27-alpine      # 上面那行的完整展开
docker pull ghcr.io/example/shop-api:1.8.3
docker pull localhost:5000/shop-api:1.8.3

名字被解析成三段:<registry>/<namespace>/<repo>:<tag>。省略 registry 时默认 docker.io,省略 namespace 时默认 library(官方镜像命名空间)。所以 nginx 的真实身份是 docker.io/library/nginx:latest——不写 tag 就是 latest,这一点在下一节会付出代价。

docker pull 时屏幕上打印的 1.27-alpine: Pulling from library/nginx 后面跟着一串 Layer ID 的下载进度;同一层已在本地会显示 Already exists(第三章的分层共享)。

tag 不是版本,是「可变的指针」

事实后果
tag 可以反复指向不同的镜像(覆盖)今天的 shop-api:1.8.3 和昨天的可能不是同一个东西
latest 永远指向「最后一次 push 的」无法回滚、CI 不可复现、A/B 两台机器可能跑不同版本
digest(sha256:...)由内容算出,永不变唯一可靠的锁定手段

生产规范:用不可变 tag + digest 双锁。

docker pull nginx@sha256:2bc60bb1c9d0c4b7aa220796840eab3d18d613059be13b63bea490dc8a32fc36
docker image inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}'
docker run -d --name shop-web nginx:1.27-alpine@sha256:2bc60bb1c9d0...

Compose 里同样支持 image: nginx:1.27-alpine@sha256:2bc6...。落地建议:tag 给人读(语义版本 1.8.3、1.8.3-build.20261004),digest 给机器锁(部署清单里记 digest,回滚 = 用旧 digest 再起一次)。

latest 唯一合理的使用场景是本地开发随手试。另一种常见误用是 1.8(大版本 tag):它会被上游重新指向 1.8.4,你的生产库一夜之间换了小版本。要固定就固定到具体 patch 版本 + digest。

多架构与 manifest

docker manifest inspect ghcr.io/example/shop-api:1.8.3
docker manifest inspect -v nginx:1.27-alpine | grep -A3 '"platform"'
docker pull --platform linux/arm64 busybox

单个名字背后可以是一个 manifest list(每种架构一个子镜像),docker pull 会按宿主平台自动挑。看不到 manifests 数组说明这是单架构镜像——把它跑在别的架构上就是 exec format error(第七章)。国内机器上 docker manifest inspect 常因 registry 认证/代理失败,加 --insecure 或换 docker buildx imagetools inspect。

私有 registry:起一个、认证、以及 insecure-registries 的取舍

docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 \
  -v registry-data:/var/lib/registry registry:2
docker tag shop-api:1.8.3 localhost:5000/shop-api:1.8.3
docker push localhost:5000/shop-api:1.8.3
curl -s http://localhost:5000/v2/_catalog

推到 myreg.internal:5000 这种域名时,Docker 要求 HTTPS;没证书就得在 /etc/docker/daemon.json 里声明:

{ "insecure-registries": ["myreg.internal:5000"] }

改完 systemctl restart docker。insecure-registries 的含义是明文 HTTP 传输:镜像内容与认证 token 都在网上裸奔,只适合封闭内网 + 有物理隔离保证的环境;一旦跨公网就必须上 TLS(用 Caddy/nginx 反代 + Let's Encrypt,或 registry 自带 tls 配置)。

认证相关:

操作命令
登录docker login ghcr.io -U <user> --password-stdin(别在命令行里明文写密码,会进 shell history)
登出docker logout ghcr.io
凭证存放~/.docker/config.json 里的 auths;接 pass / secretservice 时用 credsStore
CI 里用 secret 变量喂 --password-stdin,或云厂商的短期 token

镜像加速与代理

国内机器直连 Docker Hub 经常超时或限速,daemon.json 里配 mirror:

{
  "registry-mirrors": ["https://docker.m.daocloud.io"],
  "dns": ["114.114.114.114", "8.8.8.8"],
  "log-driver": "json-file",
  "log-opts": { "max-size": "20m", "max-file": "3" }
}

要点:mirror 只是缓存代理,只加速公开镜像的 pull,push 与私有镜像不走它;配完 docker info 里能看到 Registry Mirrors 才算生效。企业环境走 HTTP 代理用 ~/.docker/config.json 的 proxies(只影响客户端)或 systemd drop-in(影响 daemon,拉镜像真正吃代理的是 daemon):

sudo mkdir -p /etc/systemd/system/docker.service.d
printf '[Service]\nEnvironment="HTTPS_PROXY=http://10.0.0.9:3128"\nEnvironment="NO_PROXY=localhost,127.0.0.1"\n' \
  | sudo tee /etc/systemd/system/docker.service.d/proxy.conf
sudo systemctl daemon-reload && sudo systemctl restart docker

离线分发:save / load

docker save -o shop-images.tar ghcr.io/example/shop-api:1.8.3 postgres:16.4 redis:7.2-alpine
scp u@build:/tmp/shop-images.tar prod:/tmp/
docker load -i /tmp/shop-images.tar

内网/气隔环境的常规手段。三个提醒:save 出的 tar 是未压缩的(体积按解压后算),用 docker save img | gzip > img.tar.gz 减一半;tar 绑定架构,别指望 amd64 的 tar 能在 ARM 上跑;load 之后镜像没有 registry 前缀的 RepoDigest,pull_policy 相关行为要注意(第十二章)。

Docker Hub 的限流

未登录 6 小时内每 IP 约 100 次 pull,免费账号 200 次;超了报:

toomanyrequests: You have reached your pull rate limit. You may enable and increase limits by upgrading to Team or Enterprise.

CI 里最容易撞上。缓解手段按有效性排:把公共基础镜像同步到自己的 registry(最稳,skopeo copy 或 CI 里一次性 mirror)、CI 用云厂商提供的免费 mirror、给构建加缓存层(避免每次重新解析 latest)、docker login(把限额从匿名 IP 池换成账号)。

下一章把「一条一条 docker run」升级成「一个文件描述整套服务」:Compose。

十一、Docker Compose 入门:compose.yaml 的骨架

为什么需要 Compose

手工 docker run 起四个服务,你要记住每条命令的 20 个参数、顺序(谁先起)、网络名、卷名,还要在改代码后逐个重建。Compose 把这些写进一个文件:声明式地描述一组服务,然后 up 一下就把状态对齐。它是「单机多容器」的事实标准,也是学 Swarm/K8s 的概念入口。

docker compose version     # v2 是 docker 子命令,注意不是 docker-compose
docker compose up -d

顶层结构:五个键

services:            # 必填;每个键是一个容器(或一组副本)
  web: { image: nginx:1.27-alpine }
networks:            # 自定义网络;不写则自动有一个 <project>_default
  shop-net: { driver: bridge }
volumes:             # 命名卷;不写则匿名卷或带项目前缀的自动卷
  db-data:
configs:             # 文本配置以文件形式注入容器
  nginx-conf: { file: ./nginx/conf.d/site.conf }
secrets:             # 密钥(Compose 里默认是 file driver)
  db_password: { file: ./secrets/db_password.txt }

services 之外四个都可选,但生产风格的文件会显式声明 networks 与 volumes,因为隐式资源的名字带项目前缀、难以在外部复用(第十二章的 external)。

service 的常用字段

字段含义关键细节
image直接用现成镜像与 build 互斥或共存(共存时 up 用构建产物)
build现场构建context、dockerfile、args、target、shm_size、pull
ports宿主→容器列表,"127.0.0.1:8080:80" 完全等价于 -p
expose仅文档只告诉别人「这个端口对内开放」,不产生任何映射
environment环境变量map 或 list 两种写法,值必须是字符串("true" 不是 true)
env_file从文件注入列表按顺序,后者覆盖前者;与 .env 是两回事(第十三章)
volumes挂载短语法 "db-data:/var/lib/mysql" 或长语法 type/target
depends_on启动顺序只保证顺序,不保证就绪(下一节)
healthcheck健康检查等价于 HEALTHCHECK,可在 Compose 里覆盖镜像的
restart重启策略unless-stopped 是单机默认答案(第十六章)
command / entrypoint覆盖镜像分别覆盖 CMD / ENTRYPOINT(第四章四组合)
network_mode / networks网络优先用 networks,network_mode: host 会忽略 ports
container_name固定名字慎用:与 --scale 冲突,多实例必炸
profiles按需启动第十二章

一段完整可用的例子

services:
  api:
    build: { context: ., dockerfile: Dockerfile, args: { NODE_ENV: production } }
    image: ghcr.io/example/shop-api:1.8.3
    ports: ["127.0.0.1:8080:3000"]
    environment: { DATABASE_URL: postgres://shop:secret@db:5432/shop, REDIS_URL: redis://cache:6379/0 }
    volumes: [uploads:/srv/shop/uploads]
    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 15s
      retries: 3
      start_period: 20s
    restart: unless-stopped
  web:
    image: nginx:1.27-alpine
    ports: ["80:80"]
    volumes: ["./nginx/conf.d:/etc/nginx/conf.d:ro"]
    depends_on: [api]
  db:
    image: postgres:16.4
    environment: { POSTGRES_USER: shop, POSTGRES_DB: shop }
    volumes: [db-data:/var/lib/postgresql/data]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U shop"], interval: 10s, retries: 5 }
  cache:
    image: redis:7.2-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes: [cache-data:/data]
volumes: { db-data: {}, cache-data: {}, uploads: {} }

db 那段里 /var/lib/postgresql/data 这类目标路径必须抄官方文档,不能凭印象。写错成 /var/lib/mysql 时 Compose 不会报错,只会在镜像里建一个空目录,数据静默落在容器可写层,up 看着正常、down 之后全丢——这是教程里最难自查的一类错误。用 docker diff shop-db 或 docker exec shop-db ls /var/lib/postgresql/data 一眼就能看出数据没进卷。

depends_on 只保证顺序,不保证就绪

这是 Compose 最坑人的一个字段。depends_on: [db] 的含义只是「先创建并启动 db 容器」,而容器启动 ≠ 数据库能接受连接。Postgres 初始化要几秒,你的 API 在此期间 ECONNREFUSED,然后崩溃退出。

正确写法是 depends_on + condition,而 condition 依赖健康检查:

    depends_on:
      db: { condition: service_healthy }        # 等 db 的 healthcheck 通过才起 api
      cache: { condition: service_started }     # 只要起来了(等价于旧写法)

可选值有三种:service_started(默认)、service_healthy(要求被依赖方有 healthcheck,否则 Compose 报错)、service_completed_successfully(等对方退出码 0,适合 migration/init 容器)。

condition 解决的是「应用启动时的瞬时失败」,但运行期依赖方挂了依然要你自己的重连逻辑。别指望 depends_on 提供高可用。

日常命令与那个危险的 -v

docker compose config            # 校验并打印合并渲染后的最终配置(排错第一步)
docker compose up -d             # 对齐期望状态:缺的建、变的换
docker compose up -d --build     # 顺带重新构建 build 的服务
docker compose up -d --force-recreate api   # 配置没变但想换镜像/环境变量时强制重建
docker compose ps                # 只看本项目的容器(含 health 状态)
docker compose logs -f --tail 100 api
docker compose exec api sh       # 进容器(exec 不是 run,不会新建)
docker compose run --rm api node scripts/migrate.js   # 起一次性容器,可覆盖 command
docker compose scale api=3
docker compose stop / start / restart
docker compose down              # 停并删容器和网络(卷和数据不动)
docker compose down -v          # 连命名卷一起删 —— 数据库清空
  • down -v 会删除本项目声明的所有命名卷,不可恢复。习惯是永远写 down,要清数据时明确地用 docker volume rm <精确名字>。
  • up 的变更判定很保守:只改镜像 tag、只改 environment 的值都会重建容器;只改 ports 的宿主侧也一样。但它不会因为 image 内容变了(同 tag 重新 push)自动重建——这时要 --pull always + --force-recreate。
  • config 是理解 Compose 的第一工具:插值、多文件叠加、锚点展开的最终结果都在它输出里,出问题先看它。
  • 项目名决定资源前缀(shop_api_1、shop_db-data);来源优先级是 -p/--project-name > COMPOSE_PROJECT_NAME > 顶层 name: > 当前目录名。同一个目录不同路径跑起来会变成两个项目,这是「明明 up 了但 ps 看不到」的常见原因。

示例 11 是 up -d 的完整输出流:从网络创建、卷创建、拉镜像、按依赖顺序起四个容器,到 ps 里 health: starting 转 healthy 的全过程。

示例 11:Compose up 的输出流——从 yaml 到四个容器在新标签页打开

一个文件能描述服务,但还不够「生产」。下一章处理多环境、变量、锚点与那些必须显式声明的开关。

十二、Compose 进阶:变量插值、多文件叠加与生产风格配置

变量与 .env:插值语法

services:
  api:
    image: ghcr.io/example/shop-api:${APP_VERSION:-1.8.3}
    ports:
      - "${HTTP_PORT:-8080}:3000"
    environment:
      DATABASE_URL: postgres://${DB_USER:-shop}:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/shop
      REPLICAS: ${SHARD_COUNT}
语法行为
${VAR}未定义时替换为空字符串
${VAR:-默认}未定义或为空 → 用默认(值不进容器,只在 Compose 层展开)
${VAR-default}仅未定义时用默认(空串保留)
${VAR:?错误信息}未定义或为空 → 直接报错退出(强制必填,很好的守卫)
${VAR?错误信息}仅未定义时报错
${VAR:?} 与 $$$$ 是转义,输出一个 $(防止应用密钥里带 $ 被二次展开)

.env(与 compose.yaml 同目录)里的值供给上面的插值,作用范围是整个 Compose 文件;env_file: 注入的是容器内环境变量,不参与文件插值。两者都叫「环境变量」但层次完全不同,这是第十三章的第一条主线。

插值发生在 Compose 层,因此 docker compose config 输出里你看到的一定是展开后的值——检查变量到底吃到没有,看 config。

多文件叠加:base + override

docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.override.yaml config   # 看合并结果

同名文件自动叠加规则(Compose v2):compose.yaml + compose.override.yaml(开发用,自动加载);显式 -f 时按给定顺序合并,后者覆盖前者。合并语义很关键:

类型合并规则
标量(image、restart)后面的直接覆盖
environment / labels按键合并,同名覆盖
ports / volumes / command 等列表一般整体替换(不是追加!)——想追加要写全
build.args按键合并
healthcheck子字段逐个覆盖;disable: true 可关掉
deploy深度合并

「列表是替换不是追加」这点最容易被误解:override 文件里写一条 ports 并不会给 base 加一个端口,而是替换掉整个 ports 列表。

典型分层:compose.yaml(公共)+ compose.override.yaml(本地开发:bind mount 源码、开调试端口、command 换成 nodemon)+ compose.prod.yaml(生产:镜像 digest、restart、资源限制、只挂卷)。把 compose.override.yaml 加进 .gitignore 或不提交,避免开发者机器上的配置跑到生产。

扩展字段与锚点:复用又不产生隐式行为

x-common: &common
  restart: unless-stopped
  logging: { driver: json-file, options: { max-size: "20m", max-file: "3" } }
  security_opt: ["no-new-privileges:true"]
  networks: [shop-net]

services:
  api:
    <<: *common
    environment: { TZ: Asia/Shanghai, NODE_ENV: production, PORT: "3000" }
  cache:
    <<: *common
    image: redis:7.2-alpine

x- 前缀的顶层键是扩展字段,Compose 忽略它,但允许在里面放可复用片段;&anchor 定义锚点、*alias 引用、<<: 做映射合并。收益是少抄 20 行,代价是改一处会同时影响多个服务(生产上这常常正是你想要的),以及覆盖顺序的心智负担(<<: 中已显式写出的键优先)。锚点是 YAML 特性,别指望它做复杂逻辑——真要参数化就换 envsubst 模板或 Helm。

profiles:按需启动

services:
  adminer: { image: adminer:4, profiles: ["debug"] }
  migrate: { build: ., command: ["node", "scripts/migrate.js"], profiles: ["ops"] }
docker compose --profile debug up -d
docker compose --profile ops run migrate

没有 profile 的服务总是启动;有 profile 的只有匹配 --profile 才启动。用途是把「调试面板、一次性任务、测试桩」从常规 up 里摘出去,而不是靠注释代码。

网络与外部资源

networks:
  shop-net:
    name: shop-net           # 显式命名,不带项目前缀,便于项目外复用
  edge:
    external: true           # 引用已存在的网络,up/down 都不许删它
  db-only:
    internal: true           # 出不去外网,只能容器间互访

默认网络(<project>_default)由 Compose 自动创建与销毁,服务默认全接进去、互相可按名字访问。显式自定义网络的好处是:能用 name: 固定名字给项目外用、能 internal、能用多张网做隔离(前端接 edge、数据库只接 db-only,于是 web 根本 ping 不到 db——网络层强制了架构分层)。

external: true 把资源和容器生命周期解耦,down 不会删它。生产里数据库卷推荐显式声明(带 name: 或 external),防止换项目名(换目录)后「数据凭空消失」(其实还在旧卷里,这类事故很常见)。

那些必须显式写的开关

字段作用为什么要显式写
pull_policymissing(默认)/ always / build / never同 tag 覆盖镜像时,always 才能确保拉到新的;离线环境用 never
init: true容器里跑 tini 当 PID 1否则僵尸进程不收尸、SIGTERM 转发有问题(第十四、十六章)
stop_grace_period: 30sSIGTERM 后等多久发 SIGKILL默认 10s;长任务/大流量服务要加长
shm_size: 256m/dev/shm 大小默认 64MB,Chrome/Puppeteer、Postgres 并行扫描会撑爆并报 No space left on device
ulimitsnofile: 65536、nproc宿主默认 1024 文件描述符,高并发必调
extra_hostshost.docker.internal:host-gatewayLinux 上访问宿主服务的前提(第八章)
readonly: true + tmpfs根文件系统只读第十五章安全基线
deploy.resources.limitsmemory: 512M、cpus: "1.5"Compose v2 在单机 up 时也会应用 limits(K8s 迁移友好,但 replicas 等仍需 Swarm)
loggingmax-size / driver默认 json-file 不轮转,磁盘必爆(第十六章)

一个生产风格的完整例子

name: shop
x-service: &service
  restart: unless-stopped
  init: true
  stop_grace_period: 30s
  security_opt: ["no-new-privileges:true"]
  cap_drop: ["ALL"]
  read_only: true
  tmpfs: ["/tmp"]
  logging: { driver: json-file, options: { max-size: "20m", max-file: "3" } }
  ulimits: { nofile: 65536 }
  networks: [shop-net]
services:
  web:
    <<: *service
    image: nginx:1.27-alpine@sha256:2bc60bb1c9d0
    ports: ["80:80", "127.0.0.1:443:443"]
    volumes: ["./nginx/conf.d:/etc/nginx/conf.d:ro", "./nginx/certs:/etc/nginx/certs:ro"]
    depends_on: { api: { condition: service_healthy } }
  api:
    <<: *service
    image: ghcr.io/example/shop-api:1.8.3@sha256:9c1f0ea7b2d4
    expose: ["3000"]
    environment: { NODE_ENV: production, TZ: Asia/Shanghai }
    configs: [{ source: app-conf, target: /etc/shop/config.yaml }]
    secrets: [database_url]
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 20s
    deploy: { resources: { limits: { memory: 512M, cpus: "1.5" } } }
    depends_on: { db: { condition: service_healthy }, cache: { condition: service_started } }
  db:
    <<: *service
    image: postgres:16.4@sha256:6d7df8c5b0f8
    environment: { POSTGRES_PASSWORD_FILE: /run/secrets/db_password }
    volumes: [db-data:/var/lib/postgresql/data]
    secrets: [db_password]
    networks: [shop-net, db-only]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U shop"], interval: 10s, retries: 5 }
  cache:
    <<: *service
    image: redis:7.2-alpine@sha256:1a2b3c4d5e6f
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb"]
    volumes: [cache-data:/data]
    healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 10s, retries: 3 }
networks:
  shop-net: {}
  db-only: { internal: true }
volumes: { db-data: {}, cache-data: {} }
configs: { app-conf: { file: ./config/app.prod.yaml } }
secrets:
  db_password: { file: ./secrets/db_password.txt }
  database_url: { file: ./secrets/database_url.txt }

这份配置里每一条都能在前面章节找到理由:digest 锁版本(第十章)、expose 而非 ports 让 api 不对外(第八章)、internal 网络隔离数据库、read_only + tmpfs(第十五章)、init(本章)、healthcheck + service_healthy(第十一章)、日志轮转(第十六章)、密钥走 secrets 文件(下一章主题)。

下一章就专门讲这条线:配置与密钥怎么进容器。

十三、配置与密钥:优先级、secrets 与「别把配置打进镜像」

环境变量注入的四条路径与优先级

同一个变量在四个地方都能写,最终生效的是优先级最高的那个:

printf 'LOG_LEVEL=debug\nPORT=3000\n' > .env.runtime
docker run --rm -e LOG_LEVEL=warn --env-file .env.runtime myimg:1.0 printenv LOG_LEVEL
优先级(低 → 高)来源特点
1Dockerfile ENV进镜像,所有环境共享,只能被覆盖不能删
2--env-file / Compose env_file文件里的值;Compose 中多个文件后者覆盖前者
3-e KEY=VAL / Compose environment命令行级,覆盖 env_file
4Compose environment 里的 ${VAR} 插值 + 宿主真实环境.env 只做文本展开,不影响上面的覆盖次序

一句话记忆:environment 压过 env_file,env_file 压过镜像 ENV。而 Compose 的 .env(插值用)和 env_file(注入容器用)根本不在同一层——.env 里的 APP_VERSION 只会出现在 yaml 里,不会自动进容器。这是新手最常见的「我在 .env 写了怎么容器里读不到」。

验证永远是同一条命令:docker exec shop-api printenv,或 docker inspect --format '{{json .Config.Env}}'。

12-factor 在容器里的落地

原则容器里的做法反例
配置存环境环境变量 / secrets 文件config.prod.yaml 打进镜像
日志到标准输出不写日志文件,交给驱动与采集容器内 /var/log/app/*.log(docker logs 全空)
一个进程一个容器web/api/worker 分开一个容器跑 nginx + php-fpm + cron
快速启动与优雅停机就绪探针 + SIGTERM 处理启动要跑 30 秒初始化脚本
无状态状态放卷/对象存储/DBsession 存进程内存(扩容即掉线)
构建一次多环境跑镜像不变,只换环境变量生产用「生产版 Dockerfile」重新构建

核心是最后一条:镜像不可变,环境差异全靠注入。这也回答了「为什么不要把配置 COPY 进镜像」——一旦 COPY,每个环境都要构建一个镜像,缓存、回滚、审计全部翻倍;而且配置文件里通常藏着连接串和密钥,镜像一旦被 pull 就等于泄密(第十五章演示 docker history 捞密钥)。

secrets:Swarm 原生 vs Compose 的 file driver

secrets:
  db_password:
    file: ./secrets/db_password.txt     # file driver:本质是把这个文件挂到 /run/secrets/db_password
  jwt_key:
    source: jwt_key                      # 外部已存在的 secret(Swarm 或外部管理)

必须知道的区别:非 Swarm 环境下 Compose 的 secret 就是 bind mount 一个文件,落盘、可读、宿主上明文存在;只有 Swarm 才把它放进内存(tmpfs)并加密分发。docker secret create 在非 Swarm 节点会报 This node is not a swarm manager。所以「用了 secrets 就安全」是不成立的——真正的收益是约定路径(/run/secrets/<name>)与应用用 *_FILE 变量读(如 POSTGRES_PASSWORD_FILE),让密钥不进环境变量列表(docker inspect、printenv、/proc/1/environ 都能读到环境变量,而 secret 文件需要能访问挂载点才行)。

临时与内存型的配置用 tmpfs(第九章):--mount type=tmpfs,target=/run/secrets,tmpfs-size=1m。

挂载配置目录 + 只读,以及 .gitignore

docker run -d --name shop-web -v /srv/shop/nginx/conf.d:/etc/nginx/conf.d:ro nginx:1.27-alpine

ro 不是可选的礼貌:配置文件被容器改坏(某些镜像会在启动脚本里 envsubst 写回 /etc/...)时,只读挂载能让失败在第一次写就暴露,而不是悄悄污染宿主。宿主机上把配置放在 /srv/<project>/config,用 chmod 0640 root:shop 收紧,容器以 --group-add 或 --user 匹配读组。

.gitignore 里和容器相关的必备项:

.env
.env.*
!.env.example
secrets/
*.pem
*.key
docker-compose.override.yaml
compose.override.yaml
/data/

.env.example(只有键名和假值)应该提交,它是给同事的「配置契约」。

模板化配置:一行 envsubst

配置里需要变量替换时(nginx upstream、数据库连接串),把模板留在镜像里、启动时渲染,是 12-factor 与「不重新构建」的折中:

envsubst '${DB_HOST} ${DB_PORT}' < /etc/shop/app.yaml.tpl > /etc/shop/app.yaml

官方 nginx:1.27-alpine 已经内置这个机制(/docker-entrypoint.envsubst.sh 目录约定)。两个坑:envsubst 不带参数会替换所有 $VAR(把应用里的 $2、shell 变量一起吃掉),所以一定限定变量名单;渲染目标是 bind mount 来的路径时会因 :ro 失败——那种情况请挂模板目录、输出到容器内可写路径或 tmpfs(第九章的 diff 可以帮你找出所有需要可写的路径)。

十四、资源限制与性能:内存、CPU 与 137 的定位路径

内存:--memory 与 --memory-swap 的语义陷阱

写法含义
-m 512m(不写 swap)内存 512m + 等量 swap(实际默认 swap 上限 = 2× 内存,即总 1024m)
-m 512m --memory-swap 512m禁用 swap(总可用就是 512m)
-m 512m --memory-swap 768m512m 内存 + 256m swap
-m 512m --memory-swap -1swap 不限(危险,磁盘 IO 可能拖垮宿主)
--memory-reservation 256m软下限,内存紧张时才尝试回收

--memory-swap 是总量(内存 + swap),不是 swap 量——这个反直觉定义让「写 0」有特殊意义:--memory-swap 0 无效,想禁 swap 要写成与 -m 相等。生产建议显式禁 swap(--memory-swap 等于 --memory),因为被换出的容器进程会出现「没被 OOM 但响应巨慢」的诡异状态,比直接 OOM 更难诊断。

cgroup v1 与 v2 的差异要知道:v2 下没有独立的 memory.memsw.limit_in_bytes,Docker 用 memory.swap.max 表示「swap 部分」,同样的参数在不同机器上落点不同(docker info 看 Cgroup Version)。

CPU 与进程数

docker run -d --cpus 1.5 --cpuset-cpus 2,3 --cpu-shares 512 --pids-limit 256 shop-api
参数机制要点
--cpus 1.5cgroup quota(cpu.max)硬上限;1.5 = 每秒最多用 1.5 核时间。写 0.5 表示半核
--cpu-shares(默认 1024)权重只在 CPU 争抢时生效;空闲时容器能用满宿主。别把它当上限
--cpuset-cpus绑核减少调度迁移与 NUMA 跨节点,低延迟场景用
--pids-limit进程/线程总数防 fork 炸弹打爆宿主(默认 unlimited 是个隐患)

--cpus 与 --cpu-shares 的区别是「天花板」和「分蛋糕比例」——把后者当限制用的人,会发现 docker stats 里 CPU 偶尔 800%,那不是 bug。

OOM 与 exit 137 的定位路径

看到 Exited (137) 时的四步(顺序不能乱):

docker inspect shop-api --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'
docker stats --no-stream shop-api                   # 看 LIMIT 是不是本来就设小了
dmesg -T | grep -i -E 'oom|killed process' | tail   # 或 journalctl -k
docker events --since 2h --filter event=die

OOMKilled=true → 内存配额不够或应用泄漏,调 -m 或修应用;OOMKilled=false 而码是 137 → 有人(或脚本、docker kill、stop 超时)发了 SIGKILL,用 docker events 找时间点。注意内核 OOM 也可能杀宿主进程:没设 -m 的容器可以一路吃到宿主内存,最后 OOM killer 挑了个 sshd——这就是「慎用 --oom-kill-disable」的理由:它让容器在内存不足时不可被杀,于是内核去杀邻居。

容器里看到的免费内存/核数是宿主的

docker run -it --rm --cpus 1 --memory 256m node:22-bookworm-slim sh
/srv/shop $ free -h          # 显示宿主总内存
/srv/shop $ nproc            # 显示宿主核数
/srv/shop $ cat /sys/fs/cgroup/memory.max      # 268435456  ← 真实上限在这
/srv/shop $ cat /sys/fs/cgroup/cpu.max          # 100000 25000 ← quota/period = 4 核?其实是 1 核

原因是 /proc 与 free 读的是全局视图,namespace 没隔离它们(lxcfs 就是专门伪造这些文件的方案,K8s 环境偶尔引入)。所以:依赖 os.totalmem()、Runtime.availableProcessors() 的库在容器里都会算错。

  • JVM:新版默认按 cgroup 限制算堆(-XX:+UseContainerSupport),显式配 -XX:MaxRAMPercentage=70.0 比写死 -Xmx 更可移植;老 8u 之前的版本会按宿主内存开堆,然后被 OOM Kill。
  • Node:--max-old-space-size 要小于容器内存(留 20–25% 给 Buffer/线程栈),否则 V8 觉得还有空间、内核先动手;uv.defaultConcurrency、os.cpus().length 决定 worker/线程池大小,按 --cpus 自己算而不是读 cpus()。
  • Go:GOMAXPROCS 默认读宿主核数,CPU 限额 0.5 核时会出现大量线程被 throttle,用 automaxprocs 或显式设。

清理与镜像瘦身:prune 的杀伤范围

docker system df                 # 先看账本,永远先跑这个
docker container prune           # 只删已停止容器
docker image prune               # 只删悬空镜像(无 tag 的)
docker image prune -a            # 删所有「没有被任何容器使用」的镜像 —— 会拉回重下
docker builder prune             # 只清构建缓存(BuildKit),层缓存没了但镜像还在
docker system prune              # 上述前三类的组合
docker system prune -a --volumes  # 加上所有未使用卷 —— 数据库没了

最后一行是 Docker 世界里的 rm -rf:--volumes 会删掉所有当前没被容器引用的命名卷,包括你昨天测试留下的生产库副本(第九章匿名卷那段)。安全替代:

docker image prune -a --filter "until=336h"     # 只清 7 天前的,留回滚余地
docker ps -a --filter status=exited --format '{{.Names}}'   # 先列清单再决定
docker volume ls --filter name=shop              # 精确定位,然后 volume rm 具体名字

镜像体积影响的不只是磁盘:容器首次启动要等层下载+解压完成(第三章),1GB 与 80MB 的镜像在扩缩容、故障重调度时的恢复时间差一个量级。多阶段构建(第五章)+ slim/alpine 基础镜像 + 把 RUN 链起来减少层数(第四章的 rm -rf /var/lib/apt/lists/*),三招通常能砍掉 70%。

示例 14 演示一次完整的定位:容器反复 Exited (137),从 inspect 到 stats 到内核日志,最后落到 JVM 堆参数与 -m 的错配。

示例 14:资源限制与 137——OOM 的定位路径在新标签页打开

十五、安全实践:非 root、能力裁剪、只读根文件系统与扫描

为什么默认是 root,rootless 能不能真解决

镜像默认以 root 跑是历史决定:装包、写系统目录、绑 80 端口都方便。后果是应用一旦被 RCE,攻击者拿到的是容器内的 root——它可以调用镜像允许的所有 capability、访问所有挂载。

关键认知:容器内 root ≠ 宿主 root,但也绝不是安全的。能不能逃逸取决于能力集、seccomp、挂载了什么。rootless 模式(第二章)把「逃逸后拿到的身份」从 root 降级成普通用户,这是真实的收益;但它不改变容器内应用仍以自己的 UID 拥有全部权限这一事实,也不解决 secret 泄漏与网络暴露。两者是不同层次的缓解,都要做。

Dockerfile 里的 USER 与非 root 镜像

FROM node:22-bookworm-slim
RUN groupadd --gid 1001 app \
    && useradd --uid 1001 --gid app --shell /usr/sbin/nologin --home-dir /srv/shop --no-create-home app
WORKDIR /srv/shop
COPY --chown=1001:1001 . .
USER 1001:1001

要点:

  • 用数字 USER 1001:1001,别写 USER app——写名字时依赖镜像里恰好有那个用户,基础镜像一换就 unable to find user app: no matching entries in passwd file(Exited (127) 家族)。
  • Alpine 系是 addgroup -g 1001 app && adduser -S -D -u 1001 -G app app,参数名与 Debian 完全不同,抄错就构建失败。
  • 先装东西、再切用户(第四章)。切完之后 RUN apt-get 必然权限不足;需要写目录时提前 chown 或 COPY --chown,别在运行时用 root 去修。
  • 现成的非 root 镜像更省事:node 自带 node 用户(UID 1000)、nginxinc/nginx-unprivileged:1.27-alpine 直接听 8080。

能力裁剪与安全选项

docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE --security-opt no-new-privileges:true shop-api:1.8.3
选项作用说明
--cap-drop=ALL去掉所有 Linux capability默认容器给了约 14 个(含 CHOWN、NET_RAW、SETUID…),够用但偏宽
--cap-add=NET_BIND_SERVICE单独加回「绑 1024 以下端口」需要 80/443 时唯一该加的;更优做法是监听高位端口由反代转发
--security-opt=no-new-privileges禁止 setuid 提权挡住 sudo/SUID 程序这条最常见路径,成本几乎为零,建议默认开
--security-opt=seccomp=default系统调用白名单默认档已屏蔽 ~44 个危险调用;个别应用(老 Docker 里的 pthreads、JVM 某些诊断)需要自定义 profile
--security-opt=apparmor=docker-defaultLSM 强制访问控制有 AppArmor 的发行版默认启用;SELinux 环境用 -v 加 :z/:Z
--privileged放开一切加全部 capability、关 seccomp 与 AppArmor、能访问所有设备。永远不要为了「让它跑起来」而加它,那是把宿主双手交出

只读根文件系统与最小写入面

docker run --rm --read-only --tmpfs /tmp --tmpfs /run --user 1001:1001 shop-api:1.8.3

--read-only 让「写文件」这件事只在显式挂载的路径上可能,等于把入侵者的持久化路径(放马、改二进制、写 crontab)砍掉。落地顺序很实际:先用 docker diff + 一段试运行收集所有写入路径(第九章),再逐条给 --tmpfs 或卷,最后打开 read_only: true(第十二章)。

镜像扫描与最小基础镜像

trivy image --severity HIGH,CRITICAL ghcr.io/example/shop-api:1.8.3
docker scout quickview nginx:1.27-alpine        # Docker Desktop/Hub 内置
docker image inspect ghcr.io/example/shop-api:1.8.3 --format '{{.Created}} {{len .RootFS.Layers}}'

扫描解决「已知 CVE」,不解决「未知漏洞」与配置错误;把 trivy 放进 CI 当门(--exit-code 1),比事后在服务器上发现强。基础镜像取舍:

选择体积好处代价
*-bookworm-slim中glibc 兼容、包全、CVE 少仍有 apt 与 shell,调试方便
alpine小极小、apk 快musl 而非 glibc:需要 glibc 的二进制直接失败;预编译 wheel 可能退回源码编译;历史上 musl 的 DNS(线程栈、getaddrinfo)行为与 glibc 有差异,出现「只有生产偶发解析超时」
distroless很小无 shell、无包管理器,攻击面最小无法 exec 调试(第七章的方案全换:用 docker run --rm --entrypoint 或调试专用镜像、nsenter)
scratch最小只有你的二进制要自己处理 CA 证书、时区文件、DNS(静态编译才可行)

glibc vs musl 的坑在 Python(numpy/psycopg 类需编译的包)和任何自带 .so 的 SDK 上出现最多。判断办法:docker exec c ldd /usr/local/bin/app 看链接的是 libc.musl* 还是 libc.so.6。

密钥泄漏:为什么 ARG / ENV / RUN 都不行

ARG TOKEN
RUN curl -H "Authorization: token ${TOKEN}" https://api.github.com/...   # 错误示范
ENV DB_PASSWORD=hunter2                                                   # 错误示范
RUN git clone https://user:password@github.com/example/private.git        # 错误示范
RUN npm ci --token=${NPM_TOKEN} && rm -rf /root/.npmrc                    # rm 也救不了

三条都能被捞出来:docker history --no-trunc 直接打印指令文本;docker save 后的层 tar 里文件还在(写时复制:rm 只是遮住);镜像被 pull 走后你无从审计。「后面删掉」不是修复,因为它只是在上层放了 whiteout。正确做法只有两条:构建期用 --mount=type=secret(第五章),运行期注入(第十三章)。同理,COPY 一个带 .git 的目录会把整个提交历史(含密钥)留在镜像里——.dockerignore 加 .git。

其余几条硬规则:

  • --init / init: true:PID 1 负责收尸,否则僵尸进程累积到 --pids-limit 就起新进程失败。
  • --pid=container:x / --ipc=container:x 共享:共享 PID 意味着能看见甚至影响邻居的进程(kill、读 /proc/*/environ 里的密钥);共享 IPC 意味着能读写对方共享内存。除非明确需要(如 sidecar 调试),不要共享。
  • 挂载 /var/run/docker.sock 等于交出宿主 root:容器里一句 docker run -v /:/host alpine chroot /host 就能拿到宿主。真需要「容器里管容器」(CI、Portainer、watchtower),用只读 socket 代理(tecnativa/docker-socket-proxy)白名单最小 API,或者用 rootless daemon。

十六、生产运行:重启策略、健康检查、优雅停机与备份

--restart 四种策略怎么选

策略daemon 重启后应用崩溃你手动 docker stop 后
no(默认)不拉起不拉起不拉起
on-failure:5拉起拉起,最多 5 次不拉起
unless-stopped拉起(除非它此前是被手动停的)拉起不拉起
always拉起拉起也会拉起(daemon 重启时)

单机生产的答案基本都是 unless-stopped:它尊重你的「我就是要停掉它」这个意图,always 则会在你重启 Docker 之后把已经手动停掉的容器又拉起来(升级维护时这会很难堪)。on-failure 适合一次性任务与迁移脚本(退出码非 0 才重试)。

要注意的盲区:--restart 不处理 unhealthy。健康检查连续失败只会让状态变成 (unhealthy),容器照跑(第六章)。自愈要么靠应用自己崩溃退出(配合 restart),要么外部加 watcher,要么上编排(第十七章)。

HEALTHCHECK 与就绪判断

docker inspect --format '{{.State.Health.Status}}' shop-api
docker inspect --format '{{json .State.Health}}' shop-api | jq '.Log[-3:]'   # 最近三次检查记录

HEALTHCHECK 的四个参数各自管什么:--interval 检查频率、--timeout 单次超时、--retries 连续失败几次算 unhealthy、--start-period 启动宽限期(期间失败不计、状态是 health: starting)。生产最常见的事故是没设 start-period:应用冷启动要 25 秒,前两次检查失败被判 unhealthy,编排层不停重建,永远起不来。

inspect 的 .State.Health.Log 里有每次执行的 Exit、Output、Start/End,这是判断「到底是超时还是返回码不对」的唯一依据。另外 Compose 的 condition: service_healthy(第十一章)依赖它。

优雅停机:信号怎么传,PID 1 问题与解法

docker stop 的完整时序是:发 SIGTERM → 等 --stop-timeout(默认 10s)→ 还没退出就 SIGKILL。应用要在窗口内做完:停止接受新连接 → 等手头请求结束 → 关连接池 → flush 日志 → exit 0。

失效的原因在第四章埋的伏笔:

CMD ["sh", "-c", "node app.js"]      # PID 1 = sh,node 是它的子进程

sh -c 起的子进程不是 PID 1,而 shell 默认不转发收到的 SIGTERM(它只等子进程结束),于是:容器收到信号 → sh 无动作 → 10 秒后 Docker 发 SIGKILL → node 被硬杀 → 退出码 137、请求被截断、事务未提交。同样地,shell 形式 CMD node app.js(不带 JSON 数组)也是经 /bin/sh -c 执行,症状一致。

三种解法:

CMD ["node", "app.js"]                 # ① exec 形式:node 就是 PID 1
ENTRYPOINT ["tini", "--"]              # ② 装 tini(或直接 --init)
CMD ["node", "src/server.js"]
docker run -d --init --stop-timeout 30 shop-api:1.8.3    # ③ --init 由 Docker 注入 tini
  • exec 形式最省事,但要求应用自己处理 SIGTERM(Node:process.on('SIGTERM', …);Python:signal.signal;Go:signal.NotifyContext;Java:ShutdownHook)。
  • --init / tini 除了转发信号还负责收尸(僵尸进程),是通用建议默认开的选项(第十二章 init: true)。
  • 若一定要在 CMD 里做变量替换,用 exec 内联替换当前 shell:CMD ["sh","-c","exec node $ENTRY_FILE"]——exec 让 node 顶替 sh 成为 PID 1。

验证是否真的收到信号:docker stop 时看日志有没有你的 graceful-shutdown 行,以及 docker inspect --format '{{.State.ExitCode}}' 是 143/137(没处理)还是 0(处理了)。示例 16 把这套对比做成终端演示:同一个镜像,shell 形式 CMD 与 exec 形式 CMD 在 docker stop -t 15 下的不同结局。

示例 16:优雅停机——信号到不到 PID 1在新标签页打开

日志:json-file 的轮转必须设

默认 json-file 驱动不限制大小:一台机器跑三个月,/var/lib/docker/containers/<id>/<id>-json.log 能长到几十 GB,把磁盘写满(第十八章的 no space left on device 一大半是这个)。

{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "3", "compress": "true" } }

写进 /etc/docker/daemon.json 是全局兜底;单个服务在 Compose 里写 logging(第十二章)。改完只影响之后创建的容器,已有容器要 docker compose up -d --force-recreate。

其他驱动与取舍:syslog(--log-opt syslog-address=udp://10.0.0.7:514 --log-opt tag={{.Name}},走集中式,注意 UDP 会丢)、journald(能 journalctl -u docker 一起看)、local(Docker 自带的轻量二进制日志,daemon 重启不丢,但生态工具读不了)、fluentd(采集侧最常用,配合 Fluent Bit 直接读 json-file 也行)、none(一次性任务用)。采集侧的通用做法:Agent 以 DaemonSet/sidecar 方式挂 /var/lib/docker/containers:ro 与 /var/run/docker.sock,按 label 打标(第六章 --label)。

时区、locale 与镜像版本固定

FROM node:22-bookworm-slim
ENV TZ=Asia/Shanghai
RUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf /var/lib/apt/lists/*

TZ 环境变量在 glibc/musl 上都生效,但镜像里必须有 tzdata,否则 TZ 无效退化成 UTC(alpine 要 apk add tzdata)。更稳的做法是把宿主 /etc/localtime 与 /usr/share/zoneinfo 只读挂进去。日志时间戳与数据库时区不一致,九成是这个原因。locale 上 slim/alpine 默认 C.UTF-8,够 JSON 与 ASCII 用;要中文排序/strcoll 才需要生成 locale(或换 Debian 完整版)——别为了一个报错在镜像里装全套语言包。

版本固定与回滚的思路(第十章的 tag 语义在这里落地):部署清单里记 digest,up -d 前先拉新再切;单机土法滚动:

docker compose -f compose.yaml -f compose.prod.yaml up -d --no-recreate api   # 新 api 起在 8081
docker inspect --format '{{.State.Health.Status}}' shop-api-2                  # 等 healthy
docker exec shop-web nginx -s reload                                          # 上游切到新实例
docker stop shop-api && docker rm shop-api

回滚就是把旧 digest 再 up 一次——前提是镜像还留着、且数据库迁移是向后兼容的(迁移脚本要能回滚或双写,这是应用层设计,Docker 帮不了你)。蓝绿在单机 Docker 下的实质就是「两套端口 + 反代切换」,别指望 --restart 帮你做发布。

备份与监控

单机备份三件套,缺一不可:

tar czf /backup/shop-${DATE}.tar.gz -C /srv/shop compose.yaml compose.prod.yaml config nginx secrets/db_password.txt
docker run --rm --volumes-from shop-db -v /backup:/backup alpine tar czf /backup/db-${DATE}.tar.gz /var/lib/postgresql/data
docker save ghcr.io/example/shop-api:1.8.3 | gzip > /backup/shop-api-1.8.3.tar.gz

配置(文本,进 git 或对象存储)+ 数据(卷,pg_dump 优先于文件级)+ 镜像(tar,保证能重建当时的运行态)。恢复演练比备份本身重要:季度性地在一台干净机器上「只用备份」把站起回来(第十章的 docker load 在这里用)。

监控侧:docker stats 是即时快照、无历史、无告警(第十四章),单机最小方案是 cAdvisor(容器指标)+ node_exporter(宿主)+ Prometheus + Grafana:

docker run -d --name cadvisor --restart unless-stopped \
  --volume=/:/rootfs:ro --volume=/var/run:/var/run:ro \
  --volume=/sys:/sys:ro --volume=/var/lib/docker/:/var/lib/docker:ro \
  --publish 127.0.0.1:8081:8080 --detach=true gcr.io/cadvisor/cadvisor:v0.49.1

cAdvisor 需要看到宿主的 /sys、/var/lib/docker 与 docker socket——这正好是上面「挂 socket 等于交出 root」的现实权衡:监控系统要有边界(只绑回环、单独网络、--read-only 应用不到它但 no-new-privileges 要加)。告警至少配三条:容器 OOMKilled 次数、unhealthy 状态持续、/var/lib/docker 使用率。

十七、编排与生态:Compose 的天花板、Swarm 与 Kubernetes

单机 Compose 的天花板在哪

Compose 是「一台机器上的应用描述器」。它的限制不是功能少,而是模型里没有「集群」这个概念:

想做Compose 单机能做到吗缺口
跨机器调度✗没有节点资源视图与放置逻辑
副本自动重分布✗(机器挂了服务就挂)无健康重建、无 failover
滚动更新与自动回滚土法(第十六章)无 rollout 状态机
端口冲突(两机同端口)✗ports 是宿主级的,扩容即冲突
密钥加密分发✗(file driver 落盘)第十三章那条区别在这里最要命
资源配额与优先级部分(limits)无抢占、无队列
服务发现跨机靠外部 LB无内置 VIP/负载均衡

所以判断标准很直接:一台机器能装下、且你能接受它挂了就手动恢复,Compose 够;否则要编排。

Swarm 的最小可用集

Swarm 是 Docker 原生的编排,命令风格与 Compose 连续,上手成本远低于 K8s:

docker swarm init --advertise-addr 10.0.0.11
docker swarm join-token worker          # 拿到 join 命令给其他节点
docker node ls
docker node update --availability drain node-2        # 下线维护(先迁移副本)
docker network create --driver overlay --attachable shop-net
docker secret create db_password ./secrets/db_password.txt
docker service create --name shop-api --replicas 3 -p 8080:3000 \
  --network shop-net --mount type=volume,source=uploads,target=/srv/shop/uploads \
  --limit-cpu 1 --limit-memory 512M --update-parallelism 1 --update-delay 10s \
  --constraint node.labels.zone==cn-east-a \
  ghcr.io/example/shop-api:1.8.3@sha256:9c1f0ea7b2d4

docker service scale shop-api=6
docker service update --image ghcr.io/example/shop-api:1.9.0 shop-api
docker service ps shop-api --no-trunc           # 每个副本在哪台、为什么失败
docker service logs -f shop-api
docker service inspect shop-api --pretty

要点:service 而不是容器——你要的是「期望 3 个副本」,不是「起 3 个容器」;滚动更新由 --update-parallelism/--update-delay/--update-failure-action rollback 三个参数决定;--constraint(node.labels.*)做放置约束,--limit-cpu/--limit-memory 让调度器算得准。

overlay 网络的 --attachable 是让单机 docker run 也能加入这个网络(不加则只有 service 能进),排障时非常需要。stack 则是「把 Compose 文件部署成 Swarm 服务」:

docker stack deploy -c compose.yaml -c compose.prod.yaml shop
docker stack services shop
docker stack rm shop

stack deploy 只支持 Compose 规范的一个子集:build 被忽略(镜像必须先在 registry)、ports 只能用目标模式、container_name/volumes_from/network_mode: host 不支持、depends_on 被忽略(服务是无顺序的集合)。这几条限制解释了为什么生产上「Compose 文件不等于 Swarm 文件」,需要单独维护。

Kubernetes 与 Docker 的关系:dockershim 为什么被移除

时间线:K8s 需要一个能「按 CRI 接口管理容器生命周期」的运行时。Docker Engine 不实现 CRI,于是社区写了 dockershim(kubelet 里的翻译层,把 CRI 调用转成 Docker API)。因为维护成本高、行为与 Docker 强耦合,K8s 1.24 起把它从 kubelet 移除。

三个常被误解的事实:

  • 不是「K8s 不支持 Docker 了」。Docker 构建的镜像是标准 OCI 镜像,K8s 照样跑;docker build/docker compose(本地开发)继续可用。
  • K8s 用的是 containerd(正是第一章架构图里 Docker 下面的那一层,第二章)或 CRI-O——Docker 自己也是通过 containerd 跑容器,移除的只是最上面那层 shim。
  • 生态定位:containerd 是库/守护进程(被 Docker、K8s、Singularity 复用);CRI-O 是专为 K8s 实现的轻量 CRI 运行时;Podman 是无 daemon、可 rootless、CLI 与 Docker 兼容的替代(alias docker=podman 通常就能用,podman compose/podman play kube 走 Compose/K8s 文件);nerdctl 是 containerd 的 Docker 兼容 CLI,常用于 buildx 风格的本地构建。

概念对照表(迁移时最有用的东西):

ComposeKubernetes说明
services 的一个服务Deployment(+ ReplicaSet + Pod)K8s 的最小单元是 Pod(可多容器共享 net/IPC,对应「sidecar」)
ports / exposeService(ClusterIP/NodePort/LoadBalancer)+ Ingress/Gateway名字即 DNS,跨命名空间用 svc.ns.svc.cluster.local
networksNamespace + NetworkPolicy默认全通,靠 NetworkPolicy 收紧(对应第十二章的 internal)
volumes 命名卷PV / PVC + StorageClass「谁拥有存储」变成集群对象,跨节点可挂
bind mounthostPath(几乎不该用)需要它通常说明架构有问题
depends_on + healthcheckinitContainer + readiness/liveness/startup probereadiness 决定「进不进 Service 端点」,比 unhealthy 不重启前进一大步
scale / --scalespec.replicas + HPA自动扩缩是控制器,不是命令
environment / env_fileConfigMap + Secret(envFrom)Secret 默认只是 base64,不是加密;要信封加密或外部密钥系统
configsConfigMap 挂载对应「只读挂配置目录」(第十三章)
restart: unless-stopped由 kubelet/控制器保证,无需声明Pod 挂了自动重建,这是编排层最大差异
profiles无直接对应(用不同清单/Helm values)
docker compose upkubectl apply -f声明式对齐这一思想一致

什么时候真的需要编排

需要:多台机器且要自动分布与 failover、副本数会随负载变化、多团队共享集群、需要滚动发布与自动回滚、需要 RBAC 与审计、有状态服务要跨节点存储。

不需要:单机或两三台、服务数量个位数、变更频率低、团队没有 K8s 运维经验(这条最重要——K8s 的运维成本会吃掉它的收益)。中间态「Swarm 或 Compose + 一个反代 + 外部健康检查」常常是最优解。工程上的正确姿势是:先把应用做成容器友好的(第十三章无状态、配置注入、优雅停机、健康检查),这时上不上编排都是可逆决定;反过来,用一个「什么都做不了」的有状态容器去换 K8s,只会把问题从部署搬到运维。

十八、排障与速查:报错原文、原因与命令

报错 → 原因 → 怎么办

报错原文(片段)真实原因怎么办
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?daemon 没起 / socket 路径不对 / 远程上下文指错systemctl status docker;docker context ls;ls -l /var/run/docker.sock;Desktop 用户先看图标是否在跑
permission denied while trying to connect to the Docker daemon socket当前用户不在 docker 组,或加了组没重新登录getent group docker;usermod -aG docker $USER 后重登(第二章),或 newgrp docker
no space left on device宿主磁盘满(多半是容器日志或镜像层),或 tmpfs/shm 小df -h + docker system df;先清 docker builder prune、日志(第十六章)、旧镜像;shm_size 单独调
Error response from daemon: driver failed programming external connectivity ... port is already allocated宿主端口被别的容器/进程占(第八章四步)docker ps --format 找占用者;ss -lntp 找宿主进程;僵尸 docker-proxy 靠重启 docker
exec format error镜像架构与宿主不符(amd64 镜像跑在 ARM)docker image inspect --format '{{.Os}}/{{.Architecture}}';用 --platform 拉对应架构或构建多架构(第五章)
Error: No such image: xxx:tag / image with reference ... was not found构建失败没产出 tag / 名字或 tag 拼错 / 用了 --output type=oci 没 --loaddocker images 核对;docker build 加 --progress=plain 看是否真的成功;buildx 记得 --load 或 --push
pull access denied for xxx / repository does not exist or may require 'docker login'私有仓库未登录 / 名字少了 registry 前缀 / 镜像确实不存在docker login <registry>;补全 ghcr.io/、localhost:5000/ 前缀(第十章);确认命名空间大小写
toomanyrequests: You have reached your pull rate limitDocker Hub 匿名限额(第十章)CI 登录、把基础镜像同步到自建 registry、加缓存
error during connect: ... open //./pipe/docker_engine: The system cannot find the file specified(Windows/macOS)Docker Desktop 的 VM 没起来 / 命名管道丢失 / WSL2 未启用启动 Desktop 等到图标稳定;docker context use desktop-linux;wsl --update;确认 BIOS 虚拟化
Error response from daemon: container ... is not running对已停止容器执行 exec/stats先 docker ps -a 看状态 → logs → --entrypoint sh 手工跑(第七章三步法)
OCI runtime create failed: ... apply cgroup /sys/fs/cgroup/...cgroup 驱动冲突(systemd vs cgroupfs)、cgroup v1/v2 不匹配、或宿主 systemd 版本与 docker 不兼容docker info 看 Cgroup Driver/Version;统一驱动;宿主重启后残留 cgroup 未清理时重启 docker
docker: 'compose' is not a docker command装的是 v1 独立二进制,或 CLI 插件目录缺失docker compose version 失败则装 docker-compose-plugin;旧环境用 docker-compose(带横线);Linux 检查 ~/.docker/cli-plugins/
network ... not found网络被 down 删了但容器还在引用,或用了默认网络名docker network ls;重建网络并 network connect;Compose 项目名变了(第十一章)
failed to start daemon: error initializing network allocator: invalid IPv4 networkdefault-address-pools 与宿主网段冲突daemon.json 调整 bip/default-address-pools 避开 192.168.x
error while mounting volume: permission deniedbind mount 源目录 SELinux/AppArmor 拦或属主不符(第九章)加 :z/:Z(SELinux);对齐 UID;rootless 检查 subuid
容器起来就 Exited (1),logs 空白启动即失败且错误写进了文件而非 stdout;或 CMD 不是前台进程logs --tail 50 → --entrypoint sh 手工跑 → docker inspect .Path .Args 核对命令(第七、十三章)
容器起来就 Exited (137)OOM、docker kill、或 stop 超时(第十四章)inspect 看 OOMKilled;调 -m;看是否 swap 语义写错

Dockerfile 指令速查表

指令一句话语义进数据层?最常犯的错
FROM起点/阶段—用 latest;忘写 AS 名字
ARG构建期变量否当密钥用;FROM 后忘记重新声明
ENV构建+运行期变量元数据当密钥用;放在第一层导致全失效
COPY复制上下文文件是不放 .dockerignore;写太靠前击穿缓存
ADD复制 + 自动解压 tar + 拉 URL是用它下载(无法清缓存、不校验)
RUN执行命令生成一层是多条 apt-get 分开写;rm 前一层的东西
WORKDIR设置后续相对路径元数据用 RUN cd 代替;用相对/~ 路径
USER切换身份元数据放太靠前导致后续装包失败;写用户名不写 UID
VOLUME声明挂载点元数据以为它会保留数据;构建期写该目录内容被丢
EXPOSE文档化端口元数据以为它真的开了端口
LABEL元数据元数据放动态值(commit 号)击穿缓存
HEALTHCHECK自证可用元数据不设 start-period;检查打到 127.0.0.1 之外的地址
CMD默认参数/命令元数据用 shell 形式导致 PID 1 收不到信号(第十六章)
ENTRYPOINT主命令元数据与 CMD 语义混用;run 传参被整条替换
SHELL改默认 shell元数据切 bash 后镜像里没有 bash
#syntax=选 BuildKit 前端—没放在文件最前面

顺序铁律:变化频率从低到高,COPY . . 尽量靠后、元数据指令(ENV/EXPOSE/CMD/ENTRYPOINT/USER)放最后。

命令速查:六组

docker build -t repo/app:1.0.0 --pull --no-cache-filter deps .
docker buildx build --platform linux/amd64,linux/arm64 -t repo/app:1.0.0 --push .
docker images; docker image inspect img; docker history --no-trunc img
docker pull repo/app:1.0.0@sha256:...; docker tag a b; docker push b
docker save -o a.tar img; docker load -i a.tar
docker rmi img; docker image prune -a --filter until=336h
docker run -d --name app --restart unless-stopped -p 127.0.0.1:8080:3000 \
  -e TZ=Asia/Shanghai --env-file .env.prod -v app-data:/srv/app/data \
  --network shop-net --init --read-only --tmpfs /tmp \
  --memory 512m --memory-swap 512m --cpus 1.5 --pids-limit 256 \
  --user 1001:1001 --cap-drop ALL --security-opt no-new-privileges img:1.0.0
docker ps -a; docker start/stop/restart/kill/pause/unpause/rm [-f] app
docker container prune; docker container ls --filter ancestor=img:1.0.0
docker logs -f --since 10m --timestamps app; docker stats --no-stream
docker top app; docker diff app; docker events --since 1h --filter event=die
docker exec -it app sh; docker cp app:/path ./; docker inspect --format '{{.State.Status}}' app
docker run --rm -it --entrypoint sh img -c 'node server.js'
docker network create --driver bridge --subnet 172.28.0.0/16 shop-net
docker network connect [--alias api] shop-net app; docker network disconnect shop-net app
docker network ls; docker network inspect shop-net; docker network rm shop-net
docker network prune
docker volume create --name db-data --opt type=nfs --opt o=addr=...,rw --opt device=:/srv/nfs
docker volume ls; docker volume inspect db-data; docker volume rm db-data
docker run --rm --volumes-from shop-db -v "$PWD:/backup" alpine tar czf /backup/db.tar.gz /var/lib/postgresql/data
docker run -d --mount type=volume,source=db-data,target=/var/lib/postgresql/data postgres:16.4
docker run -d -v ./conf:/etc/app:ro nginx:1.27-alpine          # bind mount
docker run -d --mount type=tmpfs,tmpfs-size=64m,target=/run app img:1.0.0
docker compose config                                      # 排错第一步
docker compose -f compose.yaml -f compose.prod.yaml up -d --build --force-recreate
docker compose ps; docker compose logs -f --tail 100 api
docker compose exec api sh; docker compose run --rm migrate node scripts/migrate.js
docker compose up -d --scale api=3 --profile debug
docker compose stop; docker compose down            # 数据保留
docker compose down -v --remove-orphans             # -v 删卷,先想清楚
docker system df                # 动手清理前先看这个
docker builder prune; docker container prune; docker image prune
docker system prune                        # 相对安全:只清已停止/悬空/未用网络
docker system prune -a --volumes           # 高危:删所有未引用镜像与卷
docker login ghcr.io -U user --password-stdin; docker logout ghcr.io
docker manifest inspect img:1.0.0; docker buildx inspect --bootstrap

示例 18 是全教程的收口演练:给出一批真实报错原文,每张卡片对应「先跑哪条命令、看到什么、结论是什么」,点按钮按顺序走完一次线上排障。

示例 18:排障演练——从报错原文到结论在新标签页打开

回望这十八章:第一到三章解释了「为什么容器能到处跑」,四到五章是造镜像的手艺,六到九章是让一个容器活着并把数据留住,十章解决镜像从哪来,十一到十三章把单容器扩展成一组服务,十四到十五章谈限制与加固,十六章把「能跑」变成「敢上生产」,十七到十八章给出更大规模的答案与出事时的路线。接下来的提升没有捷径:把 /srv/shop 这个例子自己敲一遍,然后用 docker system df 和 docker history 去观察每一个决定留下的痕迹。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~