一、容器到底解决了什么:镜像、容器与仓库三件套
先还原一个每天都在上演的场景。你在自己机器上跑通了服务,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 | 提供仓库服务的服务器 | 私服 Nexus | docker.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 | 信号量 / 共享内存 | 只能和同组容器通信 |
user | UID/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 显式声明(第十四章) |
| 文件系统属主 UID | bind mount 进去写不进去 | 对齐 UID 或用卷(第九章) |
| 外部地址与密钥 | 连不上数据库、密钥泄漏 | 环境变量与 secrets 注入(第十三章) |
| 并发与连接池大小 | 单机调好的参数在 8 核机上打爆 DB | 从 --cpus 推导,不写死 |
所以准确的说法是:镜像保证「应用及其用户空间依赖」一致,不保证「运行时环境」一致。剩下的差异要么由编排层注入,要么由你在配置里显式声明。理解了这条边界,后面十七章的所有「为什么要这样写」都有了落点。
下一章开始动手:装好它,跑通第一个容器,并看清那条命令背后到底发生了什么。
二、安装与第一次运行:daemon、客户端与虚拟机的本质
daemon 与客户端:docker 命令其实只是个 HTTP 客户端
很多人以为 docker run 是本机执行了容器,其实不是。真实链路是:docker CLI 发 HTTP 请求 → /var/run/docker.sock → dockerd(常驻服务,负责接收请求、拉镜像、管网络与卷)→ containerd(真正的容器运行时管理层,镜像解包、任务生命周期归它)→ runc(一次性小工具,做完 namespace/cgroup 那套系统调用把容器进程生出来就退出,不留常驻进程)。
关键推论有三个:
- 客户端与 daemon 可以完全分离。设
DOCKER_HOST=tcp://build-server:2375,你的docker命令就在操控远端机器。CI 里的docker:24-dind镜像就是这么用的。 - daemon 挂了,容器不一定挂。已运行的容器由 runc 生成的进程继续跑;重启 dockerd 会重新接管它们(这也是为什么
docker restart dockerd比想象中有用,但也可能造成短暂失联)。 - 能访问那个 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 Version | systemd 还是 cgroupfs、v1 还是 v2——与 k8s 混用时驱动必须一致,v1/v2 的内存与 swap 语义有差异(第十四章) |
Security Options | seccomp、apparmor、rootless 都在这里体现 |
Docker Root Dir | 镜像与卷的真实落盘位置,磁盘爆了对着它 du(第九章) |
Registry Mirrors、NCPU、Total Memory | 加速器是否生效;宿主真实上限(--memory/--cpus 是相对它而言) |
docker run hello-world:拆开看它做了六件事
跑一条 docker run hello-world,屏幕上那段 "Hello from Docker!" 背后是一条完整链路:
- 客户端把「跑 hello-world」的请求发给 daemon;
- daemon 本地查
docker.io/library/hello-world:latest的镜像记录,没有,于是向registry-1.docker.io发认证与拉取; - 按 manifest 找到匹配
linux/amd64的那一组层,逐层下载并校验 sha256; - 把层解压到
/var/lib/docker/overlay2/,用联合挂载叠成一个只读根文件系统; - 在上面盖一层容器可写层,配上 namespace/cgroup,让 runc 拉起镜像
CMD指定的/hello; - 进程打印一段文本后
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)。它换来的是「即使容器逃逸,攻击者拿到的也是普通用户」,代价是一串限制:
| 项目 | rootful | rootless |
|---|---|---|
| 绑定 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 转发过去。
由此产生的三个「为什么」必须提前知道,否则后面会一直被折磨:
- 为什么 bind mount 这么慢? 容器在 Linux VM 里,而你的代码在 macOS/Windows 文件系统上,每次读写都要跨一次虚拟机文件共享(virtiofs / gRPC-FUSE / WSL2 计划)。stat 密集型操作(npm install、大量小文件)慢一个数量级。解法:把代码放在命名卷里、或用 WSL2 内的文件系统(Windows 侧)、或直接在 VM 里跑构建。
- 为什么
--network=host不生效? 那个 host 是 VM 的 host,不是你 Mac 的。同理127.0.0.1在容器里也不指向你的浏览器(第八章)。 - 为什么内存把 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 是从上往下打印的,方向感要建起来。
另外两个观察入口是 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 | 完整元数据 JSON | RootFS.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,没有例外
COPY | ADD | |
|---|---|---|
| 复制本地文件到镜像 | ✓ | ✓ |
源是 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"],无 ENTRYPOINT | node app.js | python -m http.server(整条被替换) |
ENTRYPOINT ["node"],无 CMD | node | node python -m http.server(参数追加) |
ENTRYPOINT ["node"] + CMD ["app.js"] | node app.js | node python -m http.server(只替换参数——最常用的组合) |
ENTRYPOINT ["sh","-c","node $FLAG app.js"] + CMD | shell 解释 | 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
两条铁律:
ARG不产生数据层,但docker history里能看到那行指令文本,所以--build-arg PASSWORD=...依然是泄漏。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 做成了可点交互:点任意一条指令,右侧的层堆叠会即时高亮「新增层 / 元数据层 / 复用缓存」,并演示改动源码时的失效范围。
一个可以直接抄的最小 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 的逐层输出,失效起点完全不同。
层缓存命中的判定规则
一句话规则:命中 = 父层 ID 相同 + 指令文本相同 + (对 COPY/ADD)被复制文件的内容哈希相同。三条任何一条变了,这一层以及它之后的所有层全部重建。
| 变更内容 | 从哪一层开始重建 |
|---|---|
只改 CMD/ENV/EXPOSE 的值 | 从那条元数据指令起(前面全命中,重建很快) |
改 README.md(没被 COPY 到) | 通常不重建;但若它在上下文里且被 COPY . . 覆盖,就重建 |
改 package-lock.json | COPY package-lock 那层起重建 |
改 src/server.js | COPY . . 那层起重建 |
基础镜像更新(--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 256 | CPU 配额 / 绑核 / 防 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)。
生命周期搞清楚了,下一章学「进去看」——以及当镜像里什么都没有时怎么调试。
七、进入容器与调试: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 串成一次完整排查演练,全部输出是真实样本。
调试能力有了,下一章处理运行侧最容易出事的一块:网络。
八、端口与网络:bridge、自定义网络与那个会变 IP 的坑
网络是 Docker 里概念最密的一块,因为在 Linux 上网络本来就是内核子系统。路线:先讲驱动有哪些,再讲默认网桥为什么不适合生产,然后是自定义网络的 DNS,最后是端口与暴露边界。
五种网络驱动与取舍
| 驱动 | 机制 | 典型用途 | 代价与限制 |
|---|---|---|---|
bridge(默认) | Linux bridge + veth + NAT | 本地开发、单机多容器 | 默认网络无 DNS、有 NAT 开销 |
host | 容器直接用宿主网络栈 | 追求极致性能、需要看宿主网卡 | 直接占用宿主端口、与其他容器 hostname 混在一起、Mac/Windows 无效 |
none | 只有 lo 回环 | 纯计算任务、彻底断网 | 完全没网络 |
overlay | VXLAN 隧道跨宿主 | Swarm 多机服务 | 只有 Swarm 才用得上(第十七章) |
macvlan / ipvlan | 容器在局域网里拿到独立 MAC/IP | 需要以「独立网络设备」身份存在 | 与宿主通信受阻、交换机端口安全/802.1X 会拒、AP 隔离网络无效 |
选择顺序基本是:自定义 bridge(单机首选) → host(明确要性能或要宿主栈) → macvlan(明确要求二层可达) → overlay(跨机且用 Swarm)。「默认 bridge」不在可选之列,理由见下一节。
默认网桥 docker0 的真实行为
装完 Docker,宿主上会出现 docker0 网桥,默认网段 172.17.0.0/16。不加 --network 的容器都接在上面。它有两个坑:
- 没有基于名字的 DNS。默认网络里容器只能靠 IP 互相访问,而 IP 每次重启都可能变:
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' shop-api今天拿到172.17.0.2,重启后可能变成172.17.0.3——任何把 IP 写进配置的用法都是定时炸弹。 - 同网段一律互通(
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。
网络通了,数据该往哪儿放?下一章讲四种存储方式,以及「容器删了数据没了」这个事故是怎么发生的。
九、数据持久化:匿名卷、命名卷、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=/cont | bind;-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 发现写入位置、以及那个权限坑的现场与修法。
数据落盘的问题解决了,下一章解决「镜像从哪来、到哪去」:仓库、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 的全过程。
一个文件能描述服务,但还不够「生产」。下一章处理多环境、变量、锚点与那些必须显式声明的开关。
十二、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_policy | missing(默认)/ always / build / never | 同 tag 覆盖镜像时,always 才能确保拉到新的;离线环境用 never |
init: true | 容器里跑 tini 当 PID 1 | 否则僵尸进程不收尸、SIGTERM 转发有问题(第十四、十六章) |
stop_grace_period: 30s | SIGTERM 后等多久发 SIGKILL | 默认 10s;长任务/大流量服务要加长 |
shm_size: 256m | /dev/shm 大小 | 默认 64MB,Chrome/Puppeteer、Postgres 并行扫描会撑爆并报 No space left on device |
ulimits | nofile: 65536、nproc | 宿主默认 1024 文件描述符,高并发必调 |
extra_hosts | host.docker.internal:host-gateway | Linux 上访问宿主服务的前提(第八章) |
readonly: true + tmpfs | 根文件系统只读 | 第十五章安全基线 |
deploy.resources.limits | memory: 512M、cpus: "1.5" | Compose v2 在单机 up 时也会应用 limits(K8s 迁移友好,但 replicas 等仍需 Swarm) |
logging | max-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
| 优先级(低 → 高) | 来源 | 特点 |
|---|---|---|
| 1 | Dockerfile ENV | 进镜像,所有环境共享,只能被覆盖不能删 |
| 2 | --env-file / Compose env_file | 文件里的值;Compose 中多个文件后者覆盖前者 |
| 3 | -e KEY=VAL / Compose environment | 命令行级,覆盖 env_file |
| 4 | Compose 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 秒初始化脚本 |
| 无状态 | 状态放卷/对象存储/DB | session 存进程内存(扩容即掉线) |
| 构建一次多环境跑 | 镜像不变,只换环境变量 | 生产用「生产版 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 768m | 512m 内存 + 256m swap |
-m 512m --memory-swap -1 | swap 不限(危险,磁盘 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.5 | cgroup 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 的错配。
十五、安全实践:非 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-default | LSM 强制访问控制 | 有 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 下的不同结局。
日志: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风格的本地构建。
概念对照表(迁移时最有用的东西):
| Compose | Kubernetes | 说明 |
|---|---|---|
services 的一个服务 | Deployment(+ ReplicaSet + Pod) | K8s 的最小单元是 Pod(可多容器共享 net/IPC,对应「sidecar」) |
ports / expose | Service(ClusterIP/NodePort/LoadBalancer)+ Ingress/Gateway | 名字即 DNS,跨命名空间用 svc.ns.svc.cluster.local |
networks | Namespace + NetworkPolicy | 默认全通,靠 NetworkPolicy 收紧(对应第十二章的 internal) |
volumes 命名卷 | PV / PVC + StorageClass | 「谁拥有存储」变成集群对象,跨节点可挂 |
| bind mount | hostPath(几乎不该用) | 需要它通常说明架构有问题 |
depends_on + healthcheck | initContainer + readiness/liveness/startup probe | readiness 决定「进不进 Service 端点」,比 unhealthy 不重启前进一大步 |
scale / --scale | spec.replicas + HPA | 自动扩缩是控制器,不是命令 |
environment / env_file | ConfigMap + Secret(envFrom) | Secret 默认只是 base64,不是加密;要信封加密或外部密钥系统 |
configs | ConfigMap 挂载 | 对应「只读挂配置目录」(第十三章) |
restart: unless-stopped | 由 kubelet/控制器保证,无需声明 | Pod 挂了自动重建,这是编排层最大差异 |
profiles | 无直接对应(用不同清单/Helm values) | |
docker compose up | kubectl 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 没 --load | docker 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 limit | Docker 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 network | default-address-pools 与宿主网段冲突 | daemon.json 调整 bip/default-address-pools 避开 192.168.x |
error while mounting volume: permission denied | bind 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 是全教程的收口演练:给出一批真实报错原文,每张卡片对应「先跑哪条命令、看到什么、结论是什么」,点按钮按顺序走完一次线上排障。
回望这十八章:第一到三章解释了「为什么容器能到处跑」,四到五章是造镜像的手艺,六到九章是让一个容器活着并把数据留住,十章解决镜像从哪来,十一到十三章把单容器扩展成一组服务,十四到十五章谈限制与加固,十六章把「能跑」变成「敢上生产」,十七到十八章给出更大规模的答案与出事时的路线。接下来的提升没有捷径:把 /srv/shop 这个例子自己敲一遍,然后用 docker system df 和 docker history 去观察每一个决定留下的痕迹。