📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段2·Day 42:Dockerfile 实战 --- 把你的应用打包成镜像
FDE 学习系列教程 · 第二阶段 · 第 5 周 · Day 2 预计时长:5.5 小时(可分两次)| 难度:★★★★☆ | 前置知识:Day 41(容器生命周期、端口映射、数据卷)、FastAPI、pip 依赖管理
📌 一句话目标:写出一份能过安全评审的 Dockerfile ------ 体积从 1.5GB 降到 178MB、改代码 0.4 秒重建、容器内不是 root、密钥不落进镜像。
⏱️ 这 6 小时怎么用(§零 可单独先读 35 分钟)
| 时段 | 内容 | 这一段结束时你手上应该有 |
|---|---|---|
| 0:00-0:20 | 开场:评审退回的三条 | 知道"能跑 ≠ 能交付" |
| 0:20-0:55 | §零 零基础地基 + 手把手第一次构建 | 能答出"build 时谁在干活" |
| 0:55-1:15 | §一 14 条指令分三组 | 知道哪些是构建期、哪些是运行期 |
| 1:15-1:50 | §二 上下文 + 构建时序 + 日志逐行读 | 看得懂 CACHED、[3/6]、exporting |
| 1:50-2:50 | §三 三版对照 + 命令参数 + 逐行精讲 | 能说出每行"为什么这么写" |
| 2:50-3:30 | §四 实跑 lint(0/84/100) | 16 条规则清单 + 自己项目的缺口 |
| 3:30-3:50 | §五 CMD 与 ENTRYPOINT | 不再混用两种形式 |
| 3:50-4:30 | §六 实跑体积 + 多阶段 | "该不该拆两阶段"的判据 |
| 4:30-5:05 | §七 实跑构建成本 + cache mount | 42s → 3.5s 的配法 |
| 5:05-5:35 | §八~§十 非 root / 健康检查 / 密钥 | 三条安全底线 |
| 5:35-6:00 | §十一~§十三 可复现 + 16 坑 + 报错 8 例 + 新手五问 | 交付前自查表 |
🧑🤝🧑 开场:镜像能跑,但过不了评审
第一版 Dockerfile 你写得挺顺,docker run 一跑,接口全通。交付前一周,客户安全评审退回,三条意见:
① 镜像 1.5GB,我们的镜像仓库有配额,且每次部署要拉十分钟
② 容器以 root 运行,不符合我们的容器安全基线
③ 你们把数据库口令写在了环境变量里,docker history 能直接看到
你回去查,发现三件事都真的:
$ docker images
fde-api 1.0 a1b2c3d4e5f6 1.5GB ← 因为把 venv/ 和 .git/ 都 COPY 进去了
$ docker exec fde-api whoami
root ← 因为没写 USER
$ docker history --no-trunc fde-api:1.0 | grep -i pass
ENV DB_PASSWORD=changeme ← 明晃晃写在镜像历史里
★ "能跑"和"能交付"之间,隔着一份 Dockerfile 的自我修养。
三条意见对应三件事:.dockerignore、USER、以及"密钥绝不用 ENV"。
📖 零、先弄明白五件事(零基础必读)
这一节不写一行能跑的代码,只回答"我敲下
docker build之后,到底发生了什么"。 如果这五个问题你能用自己的话答出来,后面 19 节都会顺很多。
0.1 先认识 12 个词
| 术语 | 大白话 | 类比 |
|---|---|---|
| 镜像(image) | 一个"装好了应用和依赖"的只读包裹 | 一张刻好的光盘 |
| 容器(container) | 镜像跑起来的那个进程 | 把光盘放进播放机 |
| Dockerfile | 造镜像的配方,一行一个动作 | 菜谱 |
| 构建上下文(context) | 执行 docker build 时,你交给 Docker 的那个目录 |
交给厨师的一筐食材 |
| 层(layer) | 每条指令产生的一层只读文件快照 | 一页透明胶片,叠起来才是完整画面 |
| 基础镜像 | FROM 指定的起点 |
现成的汤底 |
| tag | 镜像的"便利贴"名字,如 myapi:1.0 |
文件名的后缀 |
| digest | 镜像内容的 SHA256,永不变 | 身份证号 |
| registry | 存放镜像的仓库,如 Docker Hub | 应用商店 |
| daemon | 真正干活的 Docker 后台服务 | 后厨 |
| BuildKit | 新一代构建引擎(现在默认) | 新式灶台,火更稳 |
| cache mount | 构建期共享的缓存目录,不进镜像 | 后厨的备菜台,上菜时不端出去 |
💡 记住三个"不是": 镜像不是 整个操作系统的拷贝;容器不是 小虚拟机;
CACHED不是"没执行"。
0.2 你敲 docker build 之后,谁在干活
docker build 做的事是:读 Dockerfile,把每条指令翻译成一层,逐层执行并缓存,最后叠成一个镜像。
分五步:
-
收上下文 --- 把
docker build .里的目录(排除.dockerignore)打包发给 daemon。COPY/ADD只能访问这个范围。 -
解析指令 --- 把 Dockerfile 拆成有序指令列表。
RUN/COPY/ADD产生文件层;ENV/CMD/WORKDIR等只改配置,不产生层。 -
逐层查缓存 --- 每层算
hash(父层key + 指令 + 输入文件内容)。命中就跳过执行;没命中就执行。父层一变,后面全失效------这就是指令顺序决定构建成本的原因。 -
执行并成层 ---
RUN起临时容器跑命令、提交成只读层;COPY把文件写进新层。层与层用 overlayfs 叠加成一个文件系统。 -
出镜像 --- 所有层 + 配置(entrypoint、env 等)组成镜像 manifest,按内容寻址存储。
你(客户端) Docker daemon(后台服务) registry
│ │ │
│ ① docker build -t x . │ │
├─────────────────────────────►│ │
│ │ ② 读 Dockerfile │
│ │ ③ 打包上下文(tar) │
│ │ ④ 需要基础镜像?────────────►│
│ │◄─────────── 拉取 python:slim │
│ │ ⑤ 逐条执行指令,逐层生成 │
│◄──── ⑥ 输出构建日志 ─────────┤ │
│ │ ⑦ 打上 tag,存进本地镜像库 │★ 三个反直觉的事实:
① Dockerfile 不是"被上传执行"的脚本,它是一份声明,daemon 按它逐步执行
② 真正占体积的是【构建上下文】;Dockerfile 只有几 KB,会一起传但几乎不花时间
(所以上下文里有 1GB 的 venv,光传就要几十秒)
③ 镜像构建完存在【本机】,没有 push 就不会去任何地方
0.3 镜像到底是什么:三层结构
一个镜像 = 若干只读层 + 一份配置(JSON) + 一份清单(manifest)
manifest ── 说明"这个镜像由哪些层、按什么顺序组成"
│
├── config ── 记录:默认启动命令、环境变量、暴露端口、工作目录
│
└── layers ── 真正的文件内容(tar.gz),一层压一层
L1 基础镜像 python:3.12-slim
L2 WORKDIR /app
L3 COPY requirements.txt .
L4 RUN pip install ...
L5 COPY . .
★ 所以你 `docker save` 出来的 tar 里,装的是"层 + 配置 + 清单",
不是一个能直接双击运行的系统。
0.4 层是怎么"叠"起来的:overlayfs 与写时复制
容器看到的文件系统(merged)
┌───────────────────────────────────────┐
│ /app/main.py /usr/local/lib/... │ ← 看起来是一个完整目录树
└───────────────────────────────────────┘
▲ 由内核"合并"出来
┌──────────┴───────────┐
│ 可写层(容器层) │ ← 容器里改的文件写这里;容器删了就没
├──────────────────────┤
│ L5 COPY . . │ 只读
│ L4 pip install │ 只读
│ L3 requirements.txt │ 只读
│ L2 WORKDIR │ 只读
│ L1 python:3.12-slim │ 只读(多个镜像共享同一份)
└──────────────────────┘
读文件 :从上往下找,找到就返回。 写文件 :不修改下面的层,而是把该文件复制到可写层 再改 ------ 这就是写时复制(copy-on-write)。
★ 两个直接后果:
① 10 个基于 python:3.12-slim 的镜像,磁盘上只存一份基础层
② 容器里删掉一个文件,镜像层里它还在 ------ 只是被"遮住"了
(所以"在容器里删日志"并不能减小镜像)
0.5 为什么"层"决定了构建速度和体积
构建时,Docker 对每条指令算一个"缓存键":
缓存键 = hash(父层的键 + 这条指令 + 这条指令用到的文件内容)
键一样 → 直接复用(日志里显示 CACHED)
键不一样 → 执行,并且【后面所有层】的键都会变 → 全部重做
★ 这一条能解释你 90% 的困惑:
· 为什么改一行代码要重装依赖 → 因为 COPY . . 那层变了,后面全废
· 为什么调换两行顺序能快 100 倍 → 因为把"常变的"挪到了最后
· 为什么 touch 一下文件不影响缓存 → 因为键看内容,不看修改时间
这一节的一句话总结:
Dockerfile 不是脚本,是一份"分层施工图";构建的快慢和大小,几乎全由"层的顺序"决定。
0.6 手把手:你的第一次构建(30 秒能跑完)
# ① 建一个只有三个文件的小项目
mkdir -p ~/demo && cd ~/demo
echo 'print("hello from image")' > main.py
echo 'FROM python:3.12-slim' > Dockerfile
echo 'COPY main.py /main.py' >> Dockerfile
echo 'CMD ["python", "/main.py"]' >> Dockerfile
# ② 构建
docker build -t demo:0.1 .
你应该看到(节选,版本不同略有差异):
[+] Building 3.4s (6/6) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [1/3] FROM docker.io/library/python:3.12-slim@sha256:...
=> [2/3] COPY main.py /main.py
=> [3/3] CMD ["python", "/main.py"]
=> exporting to image
=> => naming to docker.io/library/demo:0.1
★ 三行 [1/3][2/3][3/3] 就是三层;最后两行不是层。
# ③ 跑起来
docker run --rm demo:0.1
# hello from image
# ④ 看它由几层组成
docker history demo:0.1
docker images demo
★ 到这里,你已经完成了"写配方 → 造镜像 → 跑容器"全流程。
后面 19 节,都是在这个循环上加东西:
更小的体积、更快的构建、更安全的身份、更可靠的启动。
三个"第一次必踩"的点:
| 现象 | 原因 | 一句话修法 |
|---|---|---|
pull access denied |
基础镜像名写错(如 python:3.12-slim 拼成 python3.12-slim) |
冒号前面是镜像名 ,后面是标签 |
docker: 'build' requires exactly 1 argument |
忘写末尾的 . |
补上 . |
构建成功但 docker run 报 exec format error |
架构不符(在 ARM 机器上用了 amd64 镜像) | 加 --platform,见 §十二 |
📖 一、14 条指令,其实只有三组
1.1 三组
| 组 | 指令 | 什么时候执行 | 会不会进镜像 |
|---|---|---|---|
| 元信息 | FROM LABEL ARG EXPOSE VOLUME STOPSIGNAL |
构建期 | 会(写进镜像配置) |
| 构建动作 | WORKDIR COPY ADD RUN USER ENV |
构建期 | 会(成为层) |
| 运行动作 | CMD ENTRYPOINT HEALTHCHECK SHELL |
运行期 | 只存"配置",不执行 |
1.2 14 条速览
| 指令 | 一句话 | 什么时候必须用 |
|---|---|---|
FROM |
起点,可多次(多阶段) | 每个 Dockerfile |
ARG |
构建期变量,构建完整就没了 | 传版本号、镜像源 |
ENV |
运行期变量,进镜像 | 设 PYTHONUNBUFFERED |
WORKDIR |
切工作目录,自动建 | 几乎总是 |
COPY |
拷文件,支持 --chown、--from |
拷代码、拷产物 |
ADD |
比 COPY 多"解压 tar / 拉 URL" | 基本不用,行为不透明 |
RUN |
构建期执行命令 | 装依赖、建用户 |
USER |
切运行身份 | 生产必须 |
EXPOSE |
声明端口(纯文档) | 配合编排可读性 |
VOLUME |
声明匿名卷 | 数据库类镜像 |
HEALTHCHECK |
定义健康探测 | 生产必须 |
STOPSIGNAL |
改停止信号 | 应用要 SIGINT 时 |
CMD |
默认命令,可被覆盖 | 给默认启动方式 |
ENTRYPOINT |
入口,不易被覆盖 | 要做成"可执行程序"时 |
SHELL |
改 RUN 用的 shell | Windows 镜像、要 set -e |
1.3 一条最容易混的线
ARG → 构建时存在,docker history 里能看到值,容器里【看不到】
ENV → 构建时和运行时都存在,容器里【看得到】,镜像历史里也【看得到】
★ 所以:版本号用 ARG,运行配置用 ENV,
任何密钥两者都【不能用】(§十细讲)。
📖 二、构建上下文:docker build . 那个点传了什么
2.1 你传的不是 Dockerfile,是整个目录
docker build -t fde-api:1.0 .
这个 "." 叫构建上下文(build context)。
Docker 会把【整个目录】打包发给构建引擎 ------ 除了 .dockerignore 排除的。
你的目录 1.2GB(含 venv 310MB、.git 85MB、日志、数据集)
│
▼ 打包(tar)
daemon 收到 1.2GB,解在自己的临时目录
│
▼ 按 Dockerfile 逐条执行
镜像里只有 COPY 指定的那些
★ 关键区别:镜像大小由 COPY 决定,构建快慢由 context 大小决定。
COPY 只拷了 20KB 代码,镜像是小的;
但 context 若是 1.2GB,每次 build 都要先传 1.2GB ------ 这才是"卡在 sending build context"。
2.2 实测一个反例(示意)
$ docker build -t demo .
[+] Building 62.4s (2/2)
=> [internal] load build context
=> => transferring context: 1.24GB ← 光传就 50 秒
加上 .dockerignore 之后:
$ docker build -t demo .
[+] Building 1.8s (2/2)
=> => transferring context: 24.3kB ← 从 1.24GB 到 24KB
2.3 .dockerignore 三档写法
# ---- 第一档:必排(安全和体积)----
.env
.env.*
*.pem
*.key
venv/
.venv/
.git/
# ---- 第二档:该排(体积和速度)----
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
*.log
data/
models/
# ---- 第三档:可排(整洁)----
*.md
.vscode/
.idea/
Dockerfile
docker-compose.yml
⚠️ 注意第三档里排掉
Dockerfile本身是安全的:Dockerfile 是通过-f单独传的,不在 context 里。 但别排掉requirements.txt------排了COPY requirements.txt .会直接失败。
2.4 一条反直觉
.dockerignore 不会"让 COPY 少拷" ------ COPY 本来只拷你写的那些。
★ 它真正的两个作用:
① 让 context 变小 → 构建快
② 让 .env / venv 不可能被 COPY . . 顺手带进去 → 安全
2.5 一次 build 的完整时序(把上一节拆到秒)
t=0.0s docker build -t fde-api:1.0 .
→ 客户端开始遍历当前目录,套用 .dockerignore,打成 tar
t=0.3s → 把 tar 发给 daemon(日志里 "transferring context: 24.3kB")
t=0.4s daemon 读 Dockerfile,逐条解析
t=0.5s L1 FROM python:3.12-slim
→ 本地有?直接用;没有?去 registry 拉(几十秒~几分钟)
t=1.0s L2 WORKDIR /app → 创建目录,生成层
t=1.1s L3 COPY requirements.txt . → 把文件写进层
t=1.2s L4 RUN pip install ... → 真正跑命令,最慢(几十秒)
t=43s L5 COPY . . → 只拷代码,很快
t=43.2s CMD [...] → 只写配置,不执行
t=43.3s exporting to image → 把层组装成镜像
t=43.5s naming to .../fde-api:1.0 → 打 tag,结束
★ 注意最后两步不是"层":
exporting 是把散落的层和配置组装成镜像对象;
naming 只是给它贴个名字。两者都不产生新层。
2.6 构建日志逐行读
[+] Building 18.2s (10/10) FINISHED
│ ▲ [内部任务] load build definition from Dockerfile
│ → 读 Dockerfile,几个字节,很快
│ ▲ [内部任务] load .dockerignore
│ → 读忽略规则(这一步能看到,说明规则生效了)
│ ▲ [内部任务] load metadata for docker.io/library/python:3.12-slim
│ → 去 registry 问"这个 tag 对应哪个 digest"
│
├─ [1/6] FROM python:3.12-slim@sha256:...
│ → 6 是"要执行的步数",1 是第几步(不等于层数)
├─ [2/6] WORKDIR /app
├─ [3/6] COPY requirements.txt .
├─ [4/6] RUN pip install --no-cache-dir -r requirements.txt
│ → 这层最耗时,也是你最该优化的一层
├─ [5/6] COPY . .
├─ [6/6] CMD ["python", "main.py"]
│
├─ exporting to image
│ → 把上面 6 步的结果组装成镜像
└─ => => naming to docker.io/library/fde-api:1.0
→ 打上 tag;docker.io/library/ 是默认前缀(你自己 build 的镜像实际不会推到这里)
| 日志片段 | 人话 |
|---|---|
[+] Building 18.2s (10/10) |
一共 10 个动作,全做完了,用了 18.2 秒 |
CACHED [3/6] |
第 3 步命中缓存,没有重新执行 |
=> => transferring context: 24.3kB |
上下文只有 24KB(.dockerignore 生效了) |
=> => transferring context: 1.24GB |
你把 venv 或数据一起传上去了 |
exporting to image |
组装镜像(不是再跑一遍命令) |
naming to ... |
贴 tag,结束 |
2.7 三个最常见的误解
误解一:"镜像 = 整个系统快照"
❌ 以为 python:3.12-slim 里有一个完整的 Debian
✅ 它只有运行 Python 需要的那部分文件(约 130MB),连 vim 都没有
★ 所以进入容器想用 curl/vim,通常要先 apt-get install
误解二:"COPY . . 就是把文件复制进镜像"
它复制的范围由【构建上下文 + .dockerignore】决定,不是"你磁盘上的全部"。
★ 上下文里没有的东西,COPY 也拿不到(哪怕它就在硬盘上)。
误解三:"CACHED 说明这步被跳过了,结果可能不对"
CACHED 的含义是:这一层的输入(父层 + 指令 + 文件内容)和上次完全一样,
所以可以安全复用上次的结果。
★ 反过来说:只要有一丁点不同(哪怕只是 requirements 里多了个空格),
缓存就会失效 ------ 它比你想的严格,不是"大概一样就复用"。
🖥️ 三、三版 Dockerfile
3.1 项目结构
fde_api/
├── main.py
├── requirements.txt
├── Dockerfile
└── .dockerignore
main.py (加了一个 /build 接口,用来验证"镜像里的版本"):
本机未装
fastapi,这段只做了python -m py_compile语法校验,没有实际跑起来。
python
from fastapi import FastAPI
import os
import sys
app = FastAPI(title="FDE API", version="1.0")
@app.get("/")
def root():
return {"msg": "Hello from Docker!", "python": sys.version.split()[0],
"env": os.getenv("APP_ENV", "dev")}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/build")
def build():
return {"version": os.getenv("APP_VERSION", "dev"),
"revision": os.getenv("GIT_SHA", "unknown")}
requirements.txt(锁死版本 + 锁 hash 见 §十一):
fastapi==0.115.0
uvicorn==0.30.0
3.2 第一版:naive(能跑,但会被退回)
FROM python:latest
COPY . .
RUN pip install -r requirements.txt
ENV DB_PASSWORD=changeme
CMD python main.py
这 5 行里有 3 个 ERROR。别急着笑------这是大多数人第一次写的 Dockerfile。
3.3 第二版:improved(上一版教程的水平)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "main.py"]
已经解决了"缓存顺序、非 slim、shell CMD"三个问题,但还剩三个:
| 还差 | 后果 |
|---|---|
没有 USER |
容器内 root |
没有 HEALTHCHECK |
编排层分不清"起来了"和"能用了" |
没设 PYTHONUNBUFFERED |
docker logs 看不到实时输出 |
3.4 第三版:production(能过评审)
# ---------- 阶段一:builder,只负责把依赖装好 ----------
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---------- 阶段二:runtime,只带运行需要的东西 ----------
FROM python:3.12-slim
# 运行时库(编译期才需要的 gcc 留在 builder 里,不进这一层)
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 先建用户,后面 COPY --chown 才有主可归
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app . .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
3.5 三版差在哪(一张表)
| 维度 | naive | improved | production |
|---|---|---|---|
| 基础镜像 | python:latest(1GB) |
slim(130MB) |
slim + 多阶段 |
| context | 含 venv/.git | 已排 | 已排 |
| 缓存顺序 | ✗ | ✓ | ✓ |
| pip 缓存 | 留在镜像 | --no-cache-dir |
--no-cache-dir + 挂载复用 |
| 运行身份 | root | root | uid 10001 |
| 健康检查 | ✗ | ✗ | ✓ |
| 密钥 | 在 ENV 里 | 无 | 无(用 secret 挂载) |
| 得分(§四) | 0 | 84 | 100 |
3.6 构建命令怎么敲:docker build 逐个参数
docker build -t fde-api:1.0 .
# └────┬────┘ └┬┘
# │ └─ 构建上下文:把当前目录交给 daemon
# └─ -t = --tag,格式是 名字:标签
| 参数 | 全称 | 作用 | 什么时候用 |
|---|---|---|---|
-t name:tag |
--tag |
给镜像起名+标签 | 几乎总是 (不写就是 <none>:<none>) |
-f 路径 |
--file |
指定 Dockerfile 位置 | Dockerfile 不叫这名、或在子目录 |
--build-arg K=V |
传给 ARG 的值 |
传 git sha、镜像源地址 | |
--no-cache |
完全不用缓存 | 排查"缓存背锅"的怪问题 | |
--progress=plain |
输出纯文本日志 | CI 里看完整日志(默认是花哨的 TUI) | |
--platform |
指定目标架构 | 给 ARM 服务器构建 | |
--target |
只构建到某个阶段 | 多阶段里只建 builder 调试 | |
. |
上下文,不是 Dockerfile | 忘了它 = requires exactly 1 argument |
四种常见写法,对照记:
# ① 最常见:当前目录 + 默认 Dockerfile
docker build -t fde-api:1.0 .
# ② Dockerfile 在子目录(注意:上下文仍是 .,只是去子目录找文件)
docker build -t fde-api:1.0 -f docker/Dockerfile .
# ③ 想换个目录当上下文(构建上下文变小,速度更快)
docker build -t fde-api:1.0 ./app
# ④ 带构建参数
docker build -t fde-api:1.0 \
--build-arg GIT_SHA=$(git rev-parse --short HEAD) .
★ 一个特别容易混的点:
`-f docker/Dockerfile` 只改变"去哪儿读配方",
`COPY` 的源路径仍然相对于【上下文】。所以 `COPY app/ .` 找的是 ./app/。
忘写 . 会怎样:
$ docker build -t fde-api:1.0
ERROR: "docker buildx build" requires exactly 1 argument.
★ 报错里出现 "requires exactly 1 argument",先看命令末尾有没有那个点。
3.7 三版 Dockerfile 逐行精讲
每一行回答三个问题:这行做什么 / 为什么要这么写 / 写错会怎样。
第一版:naive(5 行,能跑但会被退回)
FROM python:latest
COPY . .
RUN pip install -r requirements.txt
ENV DB_PASSWORD=changeme
CMD python main.py
| 行 | 做什么 | 为什么错 / 写错会怎样 |
|---|---|---|
FROM python:latest |
用最新版 Python 基础镜像 | latest 会漂移:今天 3.12、明天 3.13,build 结果不可复现;且完整版约 1GB |
COPY . . |
把上下文全拷进 / |
① 拷进 venv/.git(体积暴涨)② 位置是根目录,不是 /app,容易撞系统目录 ③ 放在 pip 之前 → 改一行代码就重装依赖 |
RUN pip install ... |
装依赖 | 没有 --no-cache-dir(多 38MB);没锁版本(不同时间装到不同版本) |
ENV DB_PASSWORD=... |
设环境变量 | 密钥进镜像历史 ,docker history 直接可读;改密码必须重建镜像 |
CMD python main.py |
启动命令 | shell 形式:docker stop 时信号发给 /bin/sh 而不是 python,等 10 秒后被 SIGKILL |
第二版:improved(7 行,语义正确)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "main.py"]
| 行 | 做什么 | 为什么这么写 |
|---|---|---|
FROM python:3.12-slim |
锁小版本 + 精简版 | 体积从 ~1GB 降到 ~130MB;3.12 保证次版本一致 |
WORKDIR /app |
建目录并切进去 | 后面 COPY、RUN 的相对路径都以它为基准;代替 RUN cd /app (cd 对后续指令无效) |
COPY requirements.txt . |
只拷依赖清单 | 单独一层,与代码解耦 ------ 这是缓存命中的关键 |
RUN pip install --no-cache-dir ... |
装依赖 | --no-cache-dir 不把 wheel 缓存写进层(省 38MB);此层只在依赖变化时重跑 |
COPY . . |
拷代码 | 放在依赖之后:改代码不触发重装 |
EXPOSE 8000 |
声明端口 | 纯文档作用,不会真的开端口 (端口靠 -p 映射) |
CMD ["python", "main.py"] |
启动 | exec 形式,PID 1 就是 python,信号直达 |
💡 关于
WORKDIR:它等价于"进容器后先cd /app",但比RUN cd可靠 ------RUN的cd只在那一条命令的 shell 里有效,下一条指令又回到/。
第三版:production(17 行,能过评审)
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app . .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
阶段一(builder):只负责把依赖装好
| 行 | 做什么 | 为什么 |
|---|---|---|
FROM ... AS builder |
起一个阶段并命名 | AS builder 让后面能用 --from=builder 取产物 |
WORKDIR /build |
工作目录 | 与运行阶段分开,避免路径混淆 |
COPY requirements.txt . |
拷清单 | 与第二版同理:让依赖层独立可缓存 |
RUN --mount=type=cache,... |
挂载 pip 缓存后安装 | 缓存不写进镜像,但下次构建能复用 wheel(42s → 3.5s) |
--prefix=/install |
把包装到 /install |
便于整目录拷到运行阶段(比 COPY site-packages 更干净) |
★ `--mount=type=cache` 只有 BuildKit 支持(现在默认开启)。
老版本 Docker 会报 "unknown flag: --mount",升级或加 DOCKER_BUILDKIT=1。
阶段二(runtime):只带运行需要的东西
| 行 | 做什么 | 为什么 |
|---|---|---|
FROM python:3.12-slim |
重新从干净基础镜像开始 | 关键:编译工具、源码、pip 缓存全部留在 builder,不进最终镜像 |
apt-get ... libpq5 |
装运行时库 | psycopg2 运行时要 libpq,但不需要 gcc |
rm -rf /var/lib/apt/lists/* |
清 apt 索引 | 省约 25MB;且必须与 install 写在同一层,否则清不掉 |
useradd -r -u 10001 app |
建非 root 用户 | 必须在 COPY --chown 之前,否则"用户不存在"直接构建失败 |
WORKDIR /app |
工作目录 | 运行阶段的路径基准 |
COPY --from=builder /install /usr/local |
从 builder 拿装好的包 | 只搬产物,不搬编译器 |
COPY --chown=app:app . . |
拷代码并改属主 | 非 root 进程要能读;没有 --chown 时文件属主是 root |
USER 10001 |
切身份 | 容器内不再是 root,逃逸损失小一个数量级 |
ENV PYTHONUNBUFFERED=1 |
关掉 Python 输出缓冲 | 否则 docker logs 里日志会"憋着"不出来 |
EXPOSE 8000 |
声明端口 | 文档作用 |
HEALTHCHECK ... |
定义健康探测 | 编排层靠它区分"起来了"和"能用了" |
--start-period=15s |
启动宽限期 | 15 秒内的失败不计入重试次数,避免启动慢被误杀 |
CMD ["uvicorn", ...] |
启动 | exec 形式;--host 0.0.0.0 必须写,否则容器外访问不到 |
★ 两个"顺序陷阱",初学者最常踩:
① `useradd` 必须在 `COPY --chown` 之前
② `apt-get install` 与 `rm -rf /var/lib/apt/lists/*` 必须在【同一条 RUN】里
(分成两条 RUN,后一条删的是另一层,前一层里的索引照样占空间)
3.8 如果只能改三行,改哪三行
面对一份别人的 Dockerfile(或你自己的第一版),按这个顺序动手,性价比最高。
| 优先级 | 改什么 | 从 → 到 | 收益 |
|---|---|---|---|
| 第 1 行 | 基础镜像 | FROM python:latest → FROM python:3.12-slim |
体积 -85%,构建结果可复现 |
| 第 2 处 | 调换两行顺序 | 先 COPY . . → 改成先 COPY requirements.txt . + RUN pip + 再 COPY . . |
改代码的重建从 42s 到 0.4s |
| 第 3 行 | 加运行身份 | 末尾加 RUN useradd -r -u 10001 app + USER 10001 |
从"root 跑"变成"普通用户跑" |
★ 只做这三处,一份"能跑"的 Dockerfile 就能变成"敢给人看"的。
其余(多阶段、cache mount、健康检查)是第二轮的优化。
改完怎么确认有效(三条命令):
docker images myapi # 体积变了没
docker build -t myapi:1.1 . # 第二次构建是否 CACHED 到 pip 层
docker run --rm myapi:1.1 whoami # 是不是非 root
🖥️ 四、实跑:16 条规则给三版打分
4.1 dockerfile_lint.py
python
# dockerfile_lint.py ------ 16 条规则静态检查三个 Dockerfile,看分数怎么从 0 涨到 100
from __future__ import annotations
NAIVE = """FROM python:latest
COPY . .
RUN pip install -r requirements.txt
ENV DB_PASSWORD=changeme
CMD python main.py
"""
IMPROVED = """FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "main.py"]
"""
PROD = """FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \\
pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \\
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app . .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
"""
SECRET_WORDS = ("PASSWORD", "PASSWD", "TOKEN", "SECRET", "API_KEY", "PRIVATE_KEY")
BUILD_TOOLS = ("gcc", "build-essential", "g++", "make", "python3-dev")
def parse(text: str) -> list[tuple[int, str, str]]:
"""返回 [(起始行号, 指令, 参数)],合并反斜杠续行,跳过注释与空行。"""
out, buf, start = [], "", 0
for no, raw in enumerate(text.splitlines(), 1):
line = raw.rstrip()
if not buf and (not line.strip() or line.lstrip().startswith("#")):
continue
if not buf:
start = no
if line.endswith("\\"):
buf += line[:-1] + " "
continue
buf += line
head = buf.strip().split(None, 1)
out.append((start, head[0].upper(), head[1] if len(head) > 1 else ""))
buf = ""
return out
def lint(text: str, has_ignore: bool) -> list[tuple[str, str, str]]:
ins = parse(text)
cmds = [i for _, i, _ in ins]
joined = "\n".join(f"{i} {a}" for _, i, a in ins)
last_from = max((k for k, (_, i, _) in enumerate(ins) if i == "FROM"), default=0)
findings: list[tuple[str, str, str]] = []
def add(level: str, code: str, msg: str) -> None:
findings.append((level, code, msg))
def bare_args(arg: str) -> list[str]:
return [t for t in arg.split() if not t.startswith("--")]
# R01 基础镜像标签
for _, i, a in ins:
if i == "FROM":
img = a.split()[0]
if ":" not in img or img.endswith(":latest"):
add("WARN", "R01", f"基础镜像未钉版本:{img}(用 python:3.12-slim)")
# R02 WORKDIR
if "WORKDIR" not in cmds:
add("WARN", "R02", "缺 WORKDIR:文件散在根目录 /,还容易踩权限问题")
# R03 ADD
if "ADD" in cmds:
add("WARN", "R03", "用了 ADD:本地文件用 COPY,ADD 会偷偷解压 tar / 拉 URL")
# R04/R05 apt
if "apt-get install" in joined:
if "--no-install-recommends" not in joined:
add("WARN", "R04", "apt-get install 缺 --no-install-recommends:多装几十 MB")
if "rm -rf /var/lib/apt/lists" not in joined:
add("WARN", "R05", "apt 列表没清:/var/lib/apt/lists 白占约 25MB")
# R06 pip 缓存
if "pip install" in joined and "--no-cache-dir" not in joined:
add("WARN", "R06", "pip install 缺 --no-cache-dir:缓存白占约 38MB")
# R07 缓存顺序
def idx(pred) -> int:
for k, (_, i, a) in enumerate(ins):
if pred(i, a):
return k
return -1
copy_all = idx(lambda i, a: i == "COPY" and bare_args(a)[:2] == [".", "."])
pip = idx(lambda i, a: i == "RUN" and "pip install" in a)
if copy_all != -1 and pip != -1 and copy_all < pip:
add("ERROR", "R07", "COPY . . 在 pip install 之前:改一行代码就重装全部依赖")
# R08 CMD 形式
cmds_arg = [a for _, i, a in ins if i == "CMD"]
if cmds_arg and not cmds_arg[0].strip().startswith("["):
add("WARN", "R08", "CMD 用 shell 形式:信号传不到,docker stop 会等 10 秒后强杀")
# R09 非 root
if "USER" not in cmds:
add("WARN", "R09", "没切非 root:逃逸后直接拿宿主 root")
# R10 密钥进 ENV/ARG
for _, i, a in ins:
if i in ("ENV", "ARG") and "=" in a:
key = a.split("=")[0].upper()
if any(w in key for w in SECRET_WORDS):
add("ERROR", "R10", f"{i} 里出现疑似密钥:{key}(docker history 能看见)")
# R11 无 .dockerignore 且 COPY . .
if copy_all != -1 and not has_ignore:
add("ERROR", "R11", "COPY . . 但没 .dockerignore:venv/.git/.env 会一起进镜像")
# R12 单阶段 + 编译工具
if any(t in joined for t in BUILD_TOOLS) and " AS " not in joined.upper():
add("INFO", "R12", "装了编译工具却单阶段:运行镜像里留着 gcc,改用多阶段")
# R13 HEALTHCHECK
if "HEALTHCHECK" not in cmds:
add("INFO", "R13", "没有 HEALTHCHECK:编排层分不清"起来了"和"能用了"")
# R14 EXPOSE 与启动端口
exp = [a for _, i, a in ins if i == "EXPOSE"]
if exp:
ports = {p.split("/")[0] for p in exp[0].split()}
digits = set("".join(ch if ch.isdigit() else " " for ch in joined).split())
if ports and not (ports & digits):
add("WARN", "R14", f"EXPOSE {exp[0]} 与启动命令里的端口对不上")
# R15 最终阶段 COPY . . 缺 --chown(只在最后一个 FROM 之后检查)
if "USER" in cmds:
for k, (_, i, a) in enumerate(ins):
if k > last_from and i == "COPY" and bare_args(a)[:2] == [".", "."]:
if "--chown" not in a:
add("INFO", "R15", "最终阶段 COPY . . 缺 --chown:属主 root,非 root 进程可能读不了")
break
# R16 PYTHONUNBUFFERED
if "python" in joined and "PYTHONUNBUFFERED" not in joined:
add("INFO", "R16", "没设 PYTHONUNBUFFERED=1:日志被缓冲,docker logs 看不到实时输出")
return findings
PENALTY = {"ERROR": 25, "WARN": 10, "INFO": 3}
def main() -> None:
cases = [
("naive(新手版)", NAIVE, False),
("improved(够用版)", IMPROVED, True),
("production(生产版)", PROD, True),
]
scores = []
for title, text, has_ignore in cases:
f = lint(text, has_ignore)
raw = 100 - sum(PENALTY[lv] for lv, _, _ in f)
score = max(0, raw)
scores.append(score)
print(f"\n=== {title} ===")
print(f"{'级别':<7}{'规则':<6}说明")
for lv, code, msg in f:
print(f"{lv:<7}{code:<6}{msg}")
err = sum(1 for x in f if x[0] == "ERROR")
warn = sum(1 for x in f if x[0] == "WARN")
info = sum(1 for x in f if x[0] == "INFO")
tail = f"(原始 {raw},按 0 计)" if raw < 0 else ""
print(f"→ 命中 {len(f)} 条(ERROR {err} / WARN {warn} / INFO {info})|得分 {score}{tail}")
print(f"\n三份合计:{scores[0]} → {scores[1]} → {scores[2]}")
print("★ 分数不是重点。naive 版那 3 个 ERROR 才要紧:")
print(" 密钥写进 ENV、没有 .dockerignore、COPY 在 pip 之前------")
print(" 前两个是安全事故,第三个是每天浪费半小时。")
if __name__ == "__main__":
main()
4.2 【实跑】输出
=== naive(新手版) ===
级别 规则 说明
WARN R01 基础镜像未钉版本:python:latest(用 python:3.12-slim)
WARN R02 缺 WORKDIR:文件散在根目录 /,还容易踩权限问题
WARN R06 pip install 缺 --no-cache-dir:缓存白占约 38MB
ERROR R07 COPY . . 在 pip install 之前:改一行代码就重装全部依赖
WARN R08 CMD 用 shell 形式:信号传不到,docker stop 会等 10 秒后强杀
WARN R09 没切非 root:逃逸后直接拿宿主 root
ERROR R10 ENV 里出现疑似密钥:DB_PASSWORD(docker history 能看见)
ERROR R11 COPY . . 但没 .dockerignore:venv/.git/.env 会一起进镜像
INFO R13 没有 HEALTHCHECK:编排层分不清"起来了"和"能用了"
INFO R16 没设 PYTHONUNBUFFERED=1:日志被缓冲,docker logs 看不到实时输出
→ 命中 10 条(ERROR 3 / WARN 5 / INFO 2)|得分 0(原始 -31,按 0 计)
=== improved(够用版) ===
级别 规则 说明
WARN R09 没切非 root:逃逸后直接拿宿主 root
INFO R13 没有 HEALTHCHECK:编排层分不清"起来了"和"能用了"
INFO R16 没设 PYTHONUNBUFFERED=1:日志被缓冲,docker logs 看不到实时输出
→ 命中 3 条(ERROR 0 / WARN 1 / INFO 2)|得分 84
=== production(生产版) ===
级别 规则 说明
→ 命中 0 条(ERROR 0 / WARN 0 / INFO 0)|得分 100
三份合计:0 → 84 → 100
★ 分数不是重点。naive 版那 3 个 ERROR 才要紧:
密钥写进 ENV、没有 .dockerignore、COPY 在 pip 之前------
前两个是安全事故,第三个是每天浪费半小时。
4.3 五条最该记住的规则
R11(无 .dockerignore):这是唯一一条"模型能闻到"的安全问题。
COPY . . + 没有 .dockerignore
= 你的 .env、venv、.git 全都进镜像
★ 而 .env 里的口令,等于随镜像发给了所有能 docker pull 的人。
R07(COPY 在 pip 之前):不是"慢一点",是"每次都慢 42 秒"。§七会算总账。
R10(密钥进 ENV):很多人以为"ENV 只在构建时存在"。
$ docker history --no-trunc fde-api:1.0 | grep -i pass
ENV DB_PASSWORD=changeme
★ 镜像历史是可读的。删掉那一行再 build 也没用------旧层还在。
正确做法是 §十 的 secret 挂载。
R09(非 root) :USER 一行,决定容器逃逸后是"拿到一个普通用户"还是"拿到宿主 root"。
R13(无 HEALTHCHECK) :这条在单机时不痛,上了编排就是事故------因为 docker ps 显示 Up 只代表进程在 ,不代表能服务。
📖 五、CMD 与 ENTRYPOINT:四个概念一次分清
5.1 四者定位
| 概念 | 何时生效 | 能否被覆盖 | 典型用途 |
|---|---|---|---|
ARG |
构建期 | 构建时 --build-arg |
版本号、镜像源地址 |
ENV |
构建 + 运行 | 运行时 -e |
运行配置、PYTHONUNBUFFERED |
CMD |
运行时 | docker run <镜像> <新命令> 整体替换 |
默认启动命令 |
ENTRYPOINT |
运行时 | --entrypoint 才能换 |
把镜像做成"命令" |
5.2 关键区别:CMD 是被"替换"的
# 镜像里 CMD ["python", "main.py"]
docker run myapi # → python main.py
docker run myapi bash # → bash(CMD 整个被替换掉,不是追加)
★ 这是新手最迷惑的点:docker run 后面的参数,会【替换】CMD,
而不是接在 CMD 后面。想"追加",得用 ENTRYPOINT + CMD 组合。
5.3 ENTRYPOINT + CMD 的正确组合
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0"]
CMD ["--port", "8000"]
docker run myapi # uvicorn main:app --host 0.0.0.0 --port 8000
docker run myapi --port 9000 # uvicorn main:app --host 0.0.0.0 --port 9000
★ 分工:ENTRYPOINT 定"是什么程序",CMD 定"默认参数"。
只做业务服务时,单用 CMD 就够,简单不容易错。
5.4 exec 形式与 shell 形式的差别(不只是格式)
# exec 形式:进程直接是 PID 1,能收到 SIGTERM
CMD ["python", "main.py"]
# shell 形式:实际执行的是 /bin/sh -c "python main.py"
CMD python main.py
exec 形式 shell 形式
PID 1 = python main.py PID 1 = /bin/sh -c "python main.py"
│ └── PID 7 = python main.py
docker stop → SIGTERM 直达应用 docker stop → SIGTERM 给了 sh
sh 不一定转发 → 等 10 秒 → SIGKILL → 退出码 137
★ 判断标准:docker stop 是 1 秒内结束,还是等满 10 秒。
等满 10 秒 = 你的进程没收到信号 = 用户请求被硬切断。
5.5 STOPSIGNAL 与 --init
# 有些应用只认 SIGINT(比如某些交互式程序)
STOPSIGNAL SIGINT
# 你的进程不擅长当 PID 1?加个小 init 帮你回收僵尸、转发信号
docker run --init -d myapi
🖥️ 六、实跑:五个变体,体积差 15 倍
6.1 image_size.py
python
# image_size.py ------ 五个变体的镜像体积拆解:多阶段省掉的到底是哪一块
from __future__ import annotations
# 组件体积(MB,课程估算口径,以 docker images 实测为准)
BASE = {"full": 1020, "slim": 130, "alpine": 48}
VENV, GIT, DOCS = 310, 85, 2
PIP_CACHE, DEPS, TOOLS, LIBPQ, CODE = 38, 45, 340, 3, 0.02
VARIANTS = [
("① naive(完整版基础 + 啥都拷)", {
"基础镜像 python:3.12": BASE["full"],
"venv/ 被拷进去": VENV,
".git/ 被拷进去": GIT,
"第三方依赖": DEPS,
"pip 缓存": PIP_CACHE,
"代码": CODE,
}),
("② 够用版(slim + --no-cache-dir)", {
"基础镜像 python:3.12-slim": BASE["slim"],
"第三方依赖": DEPS,
"文档与测试": DOCS,
"代码": CODE,
}),
("③ 单阶段 + 编译工具(要装 psycopg2)", {
"基础镜像 python:3.12-slim": BASE["slim"],
"gcc / build-essential": TOOLS,
"第三方依赖": DEPS,
"代码": CODE,
}),
("④ 多阶段(builder 编译,runtime 只留产物)", {
"基础镜像 python:3.12-slim": BASE["slim"],
"第三方依赖": DEPS,
"libpq5 运行时库": LIBPQ,
"代码": CODE,
}),
("⑤ 多阶段 + alpine 基础", {
"基础镜像 python:3.12-alpine": BASE["alpine"],
"第三方依赖": DEPS,
"libpq5 运行时库": LIBPQ,
"代码": CODE,
}),
]
def main() -> None:
totals = []
for title, parts in VARIANTS:
total = sum(parts.values())
totals.append(total)
print(f"\n{title} 合计 {total:,.1f} MB")
for k, v in sorted(parts.items(), key=lambda kv: -kv[1]):
pct = v / total * 100
bar = "█" * max(1, round(pct / 4))
print(f" {k:<28}{v:>8.1f} MB {pct:>5.1f}% {bar}")
base = totals[0]
print("\n相对 ① 的体积:")
for (title, _), t in zip(VARIANTS, totals):
print(f" {title:<38}{t:>8,.1f} MB {t / base * 100:>5.1f}%")
print("\n两条结论:")
print(f" · ② 比 ① 小 {(1 - totals[1] / base) * 100:.0f}%:靠的是 slim + .dockerignore + --no-cache-dir")
print(f" · ④ 比 ③ 小 {(1 - totals[3] / totals[2]) * 100:.0f}%:多阶段省掉的是 {TOOLS}MB 编译工具,依赖一个都没少")
print(f" · ④ 比 ② 反而大 {totals[3] - totals[1]:.0f}MB:多了 libpq5 运行时库 ------")
print(" 多阶段只在"构建需要额外工具"时才划算,否则就是白拆")
print(f" · ⑤ 再省 {totals[3] - totals[4]:.0f}MB:换 alpine,但 C 扩展要重新编译,风险自担")
print("\n★ 判断该不该上多阶段,问一句:运行时要 gcc 吗?")
print(" 不需要 → 多阶段;不需要且没装过工具 → 单阶段就够,别为了"看起来专业"硬拆。")
if __name__ == "__main__":
main()
6.2 【实跑】输出
① naive(完整版基础 + 啥都拷) 合计 1,498.0 MB
基础镜像 python:3.12 1020.0 MB 68.1% █████████████████
venv/ 被拷进去 310.0 MB 20.7% █████
.git/ 被拷进去 85.0 MB 5.7% █
第三方依赖 45.0 MB 3.0% █
pip 缓存 38.0 MB 2.5% █
代码 0.0 MB 0.0% █
② 够用版(slim + --no-cache-dir) 合计 177.0 MB
基础镜像 python:3.12-slim 130.0 MB 73.4% ██████████████████
第三方依赖 45.0 MB 25.4% ██████
文档与测试 2.0 MB 1.1% █
代码 0.0 MB 0.0% █
③ 单阶段 + 编译工具(要装 psycopg2) 合计 515.0 MB
gcc / build-essential 340.0 MB 66.0% █████████████████
基础镜像 python:3.12-slim 130.0 MB 25.2% ██████
第三方依赖 45.0 MB 8.7% ██
代码 0.0 MB 0.0% █
④ 多阶段(builder 编译,runtime 只留产物) 合计 178.0 MB
基础镜像 python:3.12-slim 130.0 MB 73.0% ██████████████████
第三方依赖 45.0 MB 25.3% ██████
libpq5 运行时库 3.0 MB 1.7% █
代码 0.0 MB 0.0% █
⑤ 多阶段 + alpine 基础 合计 96.0 MB
基础镜像 python:3.12-alpine 48.0 MB 50.0% ████████████
第三方依赖 45.0 MB 46.9% ████████████
libpq5 运行时库 3.0 MB 3.1% █
代码 0.0 MB 0.0% █
相对 ① 的体积:
① naive(完整版基础 + 啥都拷) 1,498.0 MB 100.0%
② 够用版(slim + --no-cache-dir) 177.0 MB 11.8%
③ 单阶段 + 编译工具(要装 psycopg2) 515.0 MB 34.4%
④ 多阶段(builder 编译,runtime 只留产物) 178.0 MB 11.9%
⑤ 多阶段 + alpine 基础 96.0 MB 6.4%
两条结论:
· ② 比 ① 小 88%:靠的是 slim + .dockerignore + --no-cache-dir
· ④ 比 ③ 小 65%:多阶段省掉的是 340MB 编译工具,依赖一个都没少
· ④ 比 ② 反而大 1MB:多了 libpq5 运行时库 ------
多阶段只在"构建需要额外工具"时才划算,否则就是白拆
· ⑤ 再省 82MB:换 alpine,但 C 扩展要重新编译,风险自担
★ 判断该不该上多阶段,问一句:运行时要 gcc 吗?
不需要 → 多阶段;不需要且没装过工具 → 单阶段就够,别为了"看起来专业"硬拆。
6.3 三个读数
| 数 | 含义 |
|---|---|
| 1,498 → 177(-88%) | 靠的是三件小事 :换 slim、写 .dockerignore、--no-cache-dir |
| 515 → 178(-65%) | 多阶段省的是编译工具,依赖一个没少 |
| 178 > 177 | 多阶段不是免费的:多了运行时库,还多了一个阶段要维护 |
🧠 第三行最容易忽略:多阶段的收益只在"构建需要额外工具"时成立。 你的项目如果没装 gcc,拆两阶段只是把 Dockerfile 从 8 行变成 16 行。
📎 口径差异说明:原稿里记的是 198MB(真机
docker images实测),本文的 177MB 是分层估算 。 差 20MB 来自基础镜像具体版本与依赖传递项差异 ------ 量级和结论不变,但交付文档请写实测值。
6.4 一个真实的体积陷阱:COPY . . 拷了数据集
某项目镜像 3.2GB,查了半天:
COPY . . 把 tests/fixtures/ 里 2.6GB 的样本 PDF 全拷进去了
★ 它不是"依赖大",是"你的目录里有不该进镜像的东西"。
这种问题 .dockerignore 一行就能解决:
tests/fixtures/
🖥️ 七、实跑:600 次构建的账
7.1 build_cost.py
python
# build_cost.py ------ 600 次构建的账:单阶段 vs 多阶段 + cache mount
from __future__ import annotations
# 层耗时(秒)
WORKDIR, COPY_REQ, PIP, COPY_CODE, CMD = 0.1, 0.2, 42.0, 0.3, 0.1
PIP_WITH_CACHE_MOUNT = 3.5 # BuildKit 缓存挂载:wheel 还在,不重新下载
COPY_FROM = 1.2 # 多阶段 COPY --from=builder
# 场景:哪些层会重建
SCENARIOS = {
"什么都没改": {"req": False, "pip": False, "code": False, "from": False},
"改一行代码": {"req": False, "pip": False, "code": True, "from": False},
"加一个依赖": {"req": True, "pip": True, "code": True, "from": True},
"换基础镜像 tag": {"req": True, "pip": True, "code": True, "from": True},
"只改 CMD": {"req": False, "pip": False, "code": False, "from": False},
"改 .dockerignore": {"req": False, "pip": False, "code": True, "from": False},
}
# 20 天里 600 次构建的构成
MIX = [("改一行代码", 468), ("加一个依赖", 90), ("什么都没改", 30),
("只改 CMD", 6), ("换基础镜像 tag", 6)]
def cost(sc: dict, multi: bool, cache_mount: bool) -> tuple[float, list[str]]:
parts, t = [], 0.0
if sc["req"]:
t += COPY_REQ; parts.append(f"COPY req {COPY_REQ}")
if sc["pip"]:
p = PIP_WITH_CACHE_MOUNT if cache_mount else PIP
t += p; parts.append(f"pip {p}")
if sc["code"]:
t += COPY_CODE; parts.append(f"COPY . . {COPY_CODE}")
t += CMD; parts.append(f"CMD {CMD}")
if multi and sc["from"]:
t += COPY_FROM; parts.append(f"COPY --from {COPY_FROM}")
return t, parts
def main() -> None:
styles = [
("A 单阶段 · 无缓存挂载", False, False),
("B 多阶段 · 无缓存挂载", True, False),
("C 多阶段 · 有缓存挂载", True, True),
]
print(f"{'场景':<18}" + "".join(f"{n:<24}" for n, _, _ in styles))
for sc_name, sc in SCENARIOS.items():
row = f"{sc_name:<18}"
for _, multi, cm in styles:
t, _ = cost(sc, multi, cm)
row += f"{t:>7.1f}s{'':<17}"
print(row)
print(f"\n{'600 次构建合计(20 天 × 30 次)':<34}")
totals = []
for name, multi, cm in styles:
total = sum(cost(SCENARIOS[s], multi, cm)[0] * n for s, n in MIX)
totals.append(total)
print(f" {name:<26}{total / 60:>8.1f} 分钟 (平均 {total / 600:.1f}s/次)")
a, b, c = totals
print(f"\nA → C:从 {a / 60:.0f} 分钟降到 {c / 60:.0f} 分钟,"
f"省 {(a - c) / 60:.0f} 分钟({(1 - c / a) * 100:.0f}%)")
print(f"B 比 A 多 {(b - a) / 60:.1f} 分钟 ------ 多阶段买的是体积,不是速度")
code_cost = sum(cost(SCENARIOS[s], False, False)[0] * n for s, n in MIX if s == "改一行代码")
dep_cost = sum(cost(SCENARIOS[s], False, False)[0] * n for s, n in MIX if s in ("加一个依赖", "换基础镜像 tag"))
code_n = sum(n for s, n in MIX if s == "改一行代码")
dep_n = sum(n for s, n in MIX if s in ("加一个依赖", "换基础镜像 tag"))
print(f"\n同样是单阶段:{code_n} 次改代码共 {code_cost:.0f}s,"
f"{dep_n} 次改依赖共 {dep_cost:.0f}s ------ 差 {dep_cost / code_cost:.0f} 倍")
print("\n三个反直觉的点:")
print(f" ① 改代码几乎免费({code_n} 次共 {code_cost:.0f} 秒),改依赖很贵({dep_n} 次共 {dep_cost:.0f} 秒)")
print(f" ② 多阶段在改依赖时反而慢 1.2s(多一次 COPY --from)------ 它买的是体积,不是速度")
print(f" ③ 缓存挂载把 pip 从 {PIP:.0f}s 压到 {PIP_WITH_CACHE_MOUNT}s,且不增加镜像体积")
if __name__ == "__main__":
main()
7.2 【实跑】输出
场景 A 单阶段 · 无缓存挂载 B 多阶段 · 无缓存挂载 C 多阶段 · 有缓存挂载
什么都没改 0.1s 0.1s 0.1s
改一行代码 0.4s 0.4s 0.4s
加一个依赖 42.6s 43.8s 5.3s
换基础镜像 tag 42.6s 43.8s 5.3s
只改 CMD 0.1s 0.1s 0.1s
改 .dockerignore 0.4s 0.4s 0.4s
600 次构建合计(20 天 × 30 次)
A 单阶段 · 无缓存挂载 71.3 分钟 (平均 7.1s/次)
B 多阶段 · 无缓存挂载 73.3 分钟 (平均 7.3s/次)
C 多阶段 · 有缓存挂载 11.7 分钟 (平均 1.2s/次)
A → C:从 71 分钟降到 12 分钟,省 60 分钟(84%)
B 比 A 多 1.9 分钟 ------ 多阶段买的是体积,不是速度
同样是单阶段:468 次改代码共 187s,96 次改依赖共 4090s ------ 差 22 倍
三个反直觉的点:
① 改代码几乎免费(468 次共 187 秒),改依赖很贵(96 次共 4090 秒)
② 多阶段在改依赖时反而慢 1.2s(多一次 COPY --from)------ 它买的是体积,不是速度
③ 缓存挂载把 pip 从 42s 压到 3.5s,且不增加镜像体积
7.3 怎么读这张表
| 观察 | 结论 |
|---|---|
| 468 次改代码只花 187 秒 | 代码层放最后是对的,它几乎不花钱 |
| 96 次改依赖花 4090 秒 | 真正的成本在依赖层,不在代码层 |
| B 比 A 多 1.9 分钟 | 多阶段不是提速手段 |
| C 省 84% | 缓存挂载才是那个"快 10 倍"的东西 |
7.4 cache mount 怎么写
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
它做了什么:把 /root/.cache/pip 挂成"构建期间共享的目录",
这个目录【不属于任何镜像层】,所以:
· 不增加镜像体积
· 下次构建同一台机器上还在
· wheel 不用重新下载,42s → 3.5s
★ 注意 --no-cache-dir 和缓存挂载不冲突:
--no-cache-dir 管的是"别把缓存写进镜像层",
挂载管的是"缓存写到构建机的另一个地方"。两个一起用才是最优。
7.5 CI 里怎么保住缓存
# 本地/单机:BuildKit 自己管
DOCKER_BUILDKIT=1 docker build -t myapi:1.0 .
# CI(每次都是新机器):把缓存导出到 registry
docker buildx build \
--cache-from type=registry,ref=registry.local/myapi:buildcache \
--cache-to type=registry,ref=registry.local/myapi:buildcache,mode=max \
-t registry.local/myapi:1.0 --push .
★ 没有这一步,CI 上的构建永远是"首次构建"------
本地 0.4 秒的快乐,在流水线上变成 43 秒。
🖥️ 八、非 root:一行 USER 的代价与收益
8.1 为什么必须
容器内 root + 内核漏洞 → 宿主 root
容器内 root + 挂载了宿主目录 → 宿主目录里所有文件都能改
★ 客户安全基线第一条通常就是"容器不得以 root 运行"。
这不是洁癖,是"万一"发生时的损失差一个数量级。
8.2 正确顺序(顺序错了会失败)
# ✅ 先建用户,再 COPY --chown,最后 USER
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --chown=app:app . .
USER 10001
# ❌ 先 COPY 再建用户:文件属主是 root,非 root 进程可能读不了
COPY . .
RUN useradd -r -u 10001 app
USER 10001
# ❌ 用了 --chown=app 但 app 还不存在:构建直接失败
COPY --chown=app:app . .
RUN useradd -r app
8.3 非 root 之后常见的三个新问题
| 问题 | 原因 | 解法 |
|---|---|---|
| 写日志失败 | 应用要写 /app/logs |
建目录并 chown,或改写到 stdout |
| 装包失败 | pip install 要写 site-packages |
构建期装好,运行期不装 |
| 端口 <1024 起不来 | 非 root 不能绑特权端口 | 容器内用 8000,映射到宿主 80 |
# 需要可写目录时的写法
RUN mkdir -p /app/logs && chown -R app:app /app/logs
8.4 更狠的一档:只读根文件系统
docker run -d --read-only --tmpfs /tmp -p 8000:8000 myapi:1.0
★ 只读根 + tmpfs,等于告诉攻击者"你写不进来"。
代价是应用不能往镜像里写任何东西 ------ 正好逼你把日志打到 stdout。
🖥️ 九、健康检查与优雅退出
9.1 Up 不等于"能服务"
docker ps 显示:
STATUS
Up 3 seconds ← 进程在
Up 3 seconds (healthy) ← 进程在,且 /health 返回 200
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
| 参数 | 含义 | 建议 |
|---|---|---|
--interval |
多久探一次 | 30s |
--timeout |
单次超时 | 3s(别超过 interval) |
--start-period |
启动宽限期,期间失败不算 | 应用启动时间的 2 倍 |
--retries |
连续失败几次判 unhealthy | 3 |
★ 为什么这条命令用 python -c 而不是 curl:
slim 镜像里没有 curl。用镜像里本来就有的解释器,不额外装包。
★ 退出码约定:0 = healthy,1 = unhealthy,2 = 保留。
所以"能连上就退出 0,抛异常就非 0"正好符合约定。
9.2 这套机制我在本机验证过(std 库服务)
起一个本地 http.server,/health 返回 200,再用同一句 urllib 去探:
status: 200 body: {"status":"ok"}
healthcheck exit code: 0
★ 命令形状是对的:200 → 退出 0。真实容器里只是把 127.0.0.1:18080 换成 8000。
9.3 优雅退出三件事
① CMD 用 exec 形式 → 信号能到达应用
② 应用注册 SIGTERM 处理 → 停止收新请求,处理完在途请求
③ 给足超时 → docker stop -t 30
缺任何一条,用户看到的就是"发布时 502"。
# FastAPI 里最简的一种写法
import signal, sys
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
yield
# 这里做收尾:关连接池、flush 缓冲
print("shutting down gracefully", flush=True)
app = FastAPI(lifespan=lifespan)
★ 不算优雅,但至少"收到信号会打日志"。
真要做在途请求排空,得上 uvicorn 的 --timeout-graceful-shutdown。
📖 十、密钥绝不进镜像
10.1 三种泄露方式
| 方式 | 为什么泄露 | 能不能救 |
|---|---|---|
ENV DB_PASSWORD=... |
写进镜像配置,docker history 可读 |
不能,重建镜像 |
ARG DB_PASSWORD + RUN echo |
值留在层的元数据里 | 不能 |
COPY .env . |
文件在层里,docker save 能解出来 |
不能,除非重建 |
# 亲眼看一下:镜像历史是完全可读的
docker history --no-trunc myapi:1.0
★ "我在 Dockerfile 里删掉那一行再 build" 是没用的:
旧层还在旧镜像里,而旧镜像往往还在仓库里躺着。
10.2 正确做法:构建期用 secret 挂载
# 需要私有源认证时:把 token 挂进来,用完就没了,不进层
RUN --mount=type=secret,id=pip_token \
pip install --no-cache-dir \
--index-url https://$(cat /run/secrets/pip_token)@pypi.example.com/simple \
-r requirements.txt
docker build --secret id=pip_token,src=$HOME/.pip_token -t myapi:1.0 .
10.3 运行期密钥:用环境变量,但别写进镜像
✅ 运行期注入:docker run -e DB_PASSWORD=... (值不进镜像)
✅ 更好:--env-file(值不进 shell history)
✅ 最好:客户的密钥管理服务 / K8s Secret / 云厂商 KMS
★ 判据一句话:这个值会不会出现在 docker history 或 docker save 里?
会 → 不能写进 Dockerfile。
📖 十一、可复现:把"应该一样"变成"一定一样"
11.1 三个层次的锁
| 层次 | 做法 | 解决什么 |
|---|---|---|
| 依赖版本 | fastapi==0.115.0 |
同一时间不同机器装到不同版本 |
| 依赖内容 | pip install --require-hashes -r req.lock |
上游重发同名包(供应链攻击) |
| 基础镜像 | FROM python:3.12-slim@sha256:... |
上游把同一个 tag 指向新镜像 |
11.2 --require-hashes 的实际写法
fastapi==0.115.0 \
--hash=sha256:1111111111111111111111111111111111111111111111111111111111111111
uvicorn==0.30.0 \
--hash=sha256:2222222222222222222222222222222222222222222222222222222222222222
# 生成带 hash 的锁文件
pip freeze > req.txt
pip hash req.txt # 单个文件;批量用 pip-tools 的 pip-compile --generate-hashes
★ 代价:每次升级依赖都要重新生成 hash。
收益:即使上游仓库被投毒,hash 对不上就装不上。
11.3 钉 digest
FROM python:3.12-slim@sha256:8f9a1b... # 永不变
# 拿到当前 digest
docker pull python:3.12-slim
docker inspect --format '{{index .RepoDigests 0}}' python:3.12-slim
11.4 打上可追溯的标签
ARG GIT_SHA=unknown
ARG BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=$GIT_SHA \
org.opencontainers.image.created=$BUILD_TIME \
org.opencontainers.image.source="https://git.example.com/fde/api"
docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) \
--build-arg BUILD_TIME=$(date -Iseconds) \
-t myapi:1.0 .
★ 出事时第一个问题永远是"线上跑的是哪个版本"。
有了这两个标签,docker inspect 一句话答完。
📖 十二、buildx 与多架构
# 启用 buildx(Docker Desktop 默认就有)
docker buildx ls
# 构建同时支持 x86 和 ARM 的镜像(客户可能是 ARM 服务器/信创机器)
docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.local/myapi:1.0 --push .
★ FDE 现场会用到的场景:
客户机房是 ARM(鲲鹏/飞腾),你笔记本是 x86 ------ 镜像直接拉过去会报
"exec format error"。
多架构构建能一份 tag 适配两边,但构建时间约为单架构的 2 倍。
❌ 十三、常见 16 坑 + 构建报错 8 例 + 新手五问
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | 服务监听 127.0.0.1 | 容器 Up,宿主机访问不到 | --host 0.0.0.0 |
| 2 | 忘写 .dockerignore | 镜像 1.5GB,构建慢 | 排除 venv/.git/.env |
| 3 | COPY 在 pip 前 | 改一行重装 42 秒 | 先拷 requirements |
| 4 | CMD shell 形式 | stop 等满 10 秒,退出码 137 |
exec 数组形式 |
| 5 | 完整版基础镜像 | 1GB 起步 | slim / alpine |
| 6 | 依赖不锁版本 | 今天明天构建结果不同 | == + hash |
| 7 | context 太大 | 卡 sending build context |
.dockerignore + 换个更小的构建目录 |
| 8 | 改代码没重新 build | 容器里还是旧代码 | 重新 build + 重建容器 |
| 9 | COPY --chown=app 但用户不存在 |
构建失败 | 先 useradd |
| 10 | 非 root 绑 80 端口 | Permission denied |
容器内用 8000,映射到 80 |
| 11 | apt 没清 lists | 白占 25MB | rm -rf /var/lib/apt/lists/* |
| 12 | pip 没 --no-cache-dir |
白占 38MB | 加上,并配合 cache mount |
| 13 | 密钥写 ENV | 镜像历史可读 | secret 挂载 / 运行期注入 |
| 14 | 多阶段 COPY 了 . 而非产物 |
体积没降 | 只拷 /install、/dist |
| 15 | alpine 装不上 C 扩展 | error: command 'gcc' failed |
装 build-base,或换 slim |
| 16 | 没设 PYTHONUNBUFFERED | docker logs 空着 |
ENV PYTHONUNBUFFERED=1 |
13.1 第 14 条值得单独说
# ❌ 多阶段了,但把源码又拷了一遍 ------ 白拆
COPY --from=builder /install /usr/local
COPY . . ← 源码进了运行镜像
# ✅ 只拷产物
COPY --from=builder /install /usr/local
COPY --chown=app:app main.py . ← 只拷运行需要的
13.2 第 15 条:alpine 不是免费午餐
alpine 用 musl libc,不是 glibc。
· 纯 Python 包:没问题
· 带 C 扩展的包(psycopg2、numpy、lxml):要么有 musllinux wheel,
要么现场编译 ------ 编译要 build-base,体积又涨回来
★ 判据:你的依赖里有没有需要编译的?
有 → 用 slim;没有 → alpine 才真的省。
13.3 构建期报错 8 例:原文 → 人话 → 修法
| # | 报错原文(节选) | 人话 | 原因与修法 |
|---|---|---|---|
| 1 | failed to compute cache key: "/requirements.txt" not found |
我要拷的文件不在上下文里 | COPY 的源路径相对上下文 ;检查 -f 与 . 是否指错目录 |
| 2 | ERROR: Could not find a version that satisfies the requirement fastapi==9.9.9 |
这个版本不存在 | 版本号写错,或该版本不支持当前 Python;先 pip index versions fastapi 查 |
| 3 | pip: command not found |
这个镜像里没有 pip | 基础镜像选错(如 alpine 要装 py3-pip);用 python:3.12-slim 最稳 |
| 4 | no space left on device |
磁盘满了 | docker system df 看占用;docker builder prune 清构建缓存 |
| 5 | exec format error |
架构不对(amd64 镜像跑在 arm 上) | 用 --platform linux/arm64 重新构建,或用 buildx 出多架构 |
| 6 | failed to create LLB definition: ... permission denied |
权限不够 | COPY --chown=app 但 app 用户还没建;先 useradd |
| 7 | The command '/bin/sh -c pip install ...' returned a non-zero code: 1 |
某条 RUN 失败了 | 往上翻看真正的错误(这条只是"它失败了");常见是网络/编译缺库 |
| 8 | failed to resolve source metadata ... 403 Forbidden |
拉不到基础镜像 | 私有镜像没登录(docker login);或 Docker Hub 限流(配镜像加速) |
★ 读报错的两条通用规则:
① 只看最后一行会误判 ------ 真正的因往往在上面 5~20 行
② `returned a non-zero code` 不是原因,是"结果",要找它上面第一条 ERROR
13.4 新手五问:这些现象到底为什么
问 1:为什么我的 build 要 10 分钟?
按可能性排序:
① 上下文太大(日志里 transferring context 是几百 MB)→ 补 .dockerignore
② 依赖层每次重装(COPY . . 在 pip 之前)→ 调换顺序
③ 没配国内镜像源,pip 在等超时 → 加 -i 或配 pip.conf
④ 首次拉基础镜像(只会慢一次)→ 第二次看是否 CACHED
问 2:为什么镜像 1.5GB?
八成是这三样被拷进去了:venv/、.git/、数据文件。
docker history <镜像> 按 SIZE 排序,最大的那层就是答案。
问 3:为什么容器起来就退出(Exited (0) 或 (1))?
容器的生命周期 = 主进程的生命周期。
主进程一结束,容器就退出 ------ 这是设计,不是 bug。
① Exited (0):命令正常跑完就结束了(比如 CMD ["echo","hi"])
② Exited (1):命令报错,看 docker logs
③ 服务类应用必须"前台运行":uvicorn 不加 & 、不 nohup
问 4:为什么改了代码,容器里还是旧的?
因为代码是【构建时】打进镜像的,镜像是一份快照。
改了代码必须重新 build + 重建容器:
docker build -t myapi:1.2 . && docker run ... myapi:1.2
★ 开发期想"改完立即生效",用绑定挂载:
docker run -v $PWD:/app myapi:dev
(生产不要这么做,见 §十三 第 14 条)
问 5:为什么一定要 -t 给名字?
不加 -t 会得到 <none>:<none>,你只能靠 IMAGE ID 找它:
docker images # 一堆 <none>,分不清哪个是哪个
docker run <id> ... # 复制 12 位 ID,容易抄错
★ 名字是给人用的,ID 是给机器用的。
📊 十四、速查表
14.1 指令定位
| 需求 | 用 |
|---|---|
| 传版本号 | ARG |
| 设运行配置 | ENV |
| 拷文件 | COPY(--chown、--from) |
| 装依赖 / 建用户 | RUN |
| 切身份 | USER |
| 默认命令 | CMD(exec 形式) |
| 做成可执行程序 | ENTRYPOINT + CMD |
| 健康探测 | HEALTHCHECK |
| 声明端口 | EXPOSE(文档作用) |
14.2 构建命令
docker build -t myapi:1.0 . # 基本
docker build -t myapi:1.0 -f Dockerfile.prod . # 指定文件
docker build --no-cache -t myapi:1.0 . # 不用缓存(排障时)
docker build --progress=plain -t myapi:1.0 . # 看完整日志
docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) -t myapi:1.0 .
docker buildx build --platform linux/amd64,linux/arm64 -t ... --push .
14.3 交付前 8 条自检
□ 1. 有 .dockerignore,且不含 .env / venv / .git
□ 2. COPY requirements 在 pip install 之前
□ 3. pip 有 --no-cache-dir(+ cache mount 更好)
□ 4. 有 USER,且不是 root
□ 5. 有 HEALTHCHECK
□ 6. CMD 是 exec 形式
□ 7. 镜像里没有密钥(docker history 查一遍)
□ 8. 打上了 git sha / 构建时间标签
💬 十五、客户会怎么问(8 问 8 答)
Q1:镜像为什么这么大?能小点吗?
现在 178MB,基础镜像占 130MB。再小就要换 alpine(96MB),但 C 扩展可能要现场编译------我建议先接受 178MB,因为它可复现。
Q2:为什么不能用 root 跑?我们用 root 很多年了。
因为容器内 root 逃逸等于宿主 root 。改成非 root 只多两行(
useradd+USER),代价是端口不能用 80------映射一下就行。
Q3:镜像里有没有我们的数据?
没有。代码是从你们仓库拉的,运行时数据全在卷里,镜像里不含任何业务数据。 你们可以
docker save后解包自查。
Q4:怎么确认部署的是我们审过的那个版本?
三重保证:镜像钉 digest、标签里带 git sha、交付包含 sha256 清单。 三个对得上,就是同一个东西。
Q5:改了代码要多久能上线?
构建 0.4 秒 (依赖不变时),加推送和重启约 2 分钟。如果改了依赖,构建 5 秒左右(有缓存挂载)。
Q6:为什么你们的 Dockerfile 有两个 FROM?
多阶段:第一个阶段负责编译,第二个阶段只带运行需要的东西。这样 340MB 的编译工具不会进最终镜像。
Q7:健康检查会不会误报,导致容器被反复重启?
会,所以设了
--start-period=10s宽限期,启动期间失败不计。误报通常是因为探得太早或超时太短。
Q8:能不能不做镜像,直接拷代码上去跑?
能,但要在你们机器上装 Python 3.12、处理 glibc 和 openssl 差异。那是改生产环境。镜像是"不动你们机器"的方案。
✅ 十六、今日交付物与自检
| # | 交付物 | 验收标准 |
|---|---|---|
| 1 | dockerfile_lint.py |
三份得分 0 / 84 / 100,输出一致 |
| 2 | image_size.py |
1,498 / 177 / 515 / 178 / 96 |
| 3 | build_cost.py |
A 71.3 分、C 11.7 分 |
| 4 | 生产版 Dockerfile | lint 100 分 |
| 5 | .dockerignore |
三档齐全 |
| 6 | docs/docker/checklist.md |
8 条自检 + 16 坑 |
□ 1. 说得出 ARG 和 ENV 的区别(构建期 / 运行期)
□ 2. 说得出 CMD 会被 docker run 参数【替换】而不是追加
□ 3. 知道构建上下文大小影响的是速度,不是镜像大小
□ 4. 能解释多阶段省的是哪一块(编译工具)
□ 5. 知道多阶段不一定更小(多了运行时库)
□ 6. 能写出 cache mount 那一行
□ 7. 知道 USER 要写在 COPY --chown 之前
□ 8. 知道 HEALTHCHECK 的退出码约定(0/1/2)
□ 9. 说得出密钥为什么不能进 ENV
□ 10. 能用三条命令证明"线上是哪个版本"
📝 十七、本课小结
| # | 知识点 | 一句话 |
|---|---|---|
| 1 | 指令三组 | 元信息 / 构建动作 / 运行动作 |
| 2 | ARG vs ENV | 构建期 vs 运行期,都不能放密钥 |
| 3 | context | 打包整个目录,决定构建快慢 |
| 4 | .dockerignore | 既瘦身,也防 .env 被顺手拷进去 |
| 5 | 缓存顺序 | 变化慢的放前面,代码放最后 |
| 6 | 体积 | 1,498 → 177(三件小事),515 → 178(多阶段) |
| 7 | 多阶段 | 只在"构建要额外工具"时划算 |
| 8 | 构建成本 | 468 次改代码 187s,96 次改依赖 4090s |
| 9 | cache mount | 42s → 3.5s,且不增体积 |
| 10 | 非 root | 先建用户,再 --chown,最后 USER |
| 11 | 健康检查 | Up ≠ 能服务;0/1/2 退出码 |
| 12 | 密钥 | 进过镜像就永远在里面 |
| 13 | 可复现 | 锁版本 + 锁 hash + 钉 digest |
| 14 | 可追溯 | 标签带 git sha,inspect 一句话答完 |
🧠 核心认知 :Dockerfile 是一份可复现 + 可审计的环境说明书。
可复现:同样的输入,任何人任何时间 build 出同样的镜像
可审计:出了事能回答"跑的是哪个版本、谁改的、改了什么"
★ 三条判据查一份 Dockerfile 够不够格交付:
① 镜像里有没有密钥?
② 容器里是不是 root?
③ 改一行代码要多久重建?
📋 十八、课后练习
练习 1:三版对照跑一遍(约 40 分钟)
-
跑
dockerfile_lint.py,确认 0 / 84 / 100 -
把 improved 版加一行
USER 10001,看分数变成多少(提示:会掉 R15?) -
把 production 版的
COPY --chown=app:app . .改成COPY . .,看分数变化并解释 -
回答:为什么 naive 版原始分是 -31,而不是 0?
练习 2:真机构建并量体积(约 50 分钟)
-
用 improved 版 build,
docker images记录体积 -
用 production 版 build,再记录;差值是否符合 §六 的估算
-
docker history myapi:1.0看每一层多大,找出最大的一层 -
用
docker run --rm myapi:1.0 whoami确认不是 root
练习 3:缓存实验(约 30 分钟)
-
连续 build 两次,第二次应该全是
CACHED -
改 main.py 一个字符,再 build,记录耗时
-
在 requirements.txt 加
requests==2.32.3,再 build,记录耗时 -
加上 cache mount 再重复第 3 步,对比耗时
验收:三次耗时能对上 §七 的数量级(0.4s / 42s / 3.5s)。
练习 4:把密钥从镜像里赶出去(约 30 分钟)
-
故意写
ENV DB_PASSWORD=changeme构建一版 -
docker history --no-trunc找出它,docker save后解包 grep 一次 -
改成运行期
-e注入,重新构建,再 grep 一次确认没有 -
用自己的话写三句:为什么"删掉那行再 build"也不安全?
🧪 十九、小白自检:12 个问题
不用翻上文,能用自己的话答出来,就算这一课过了。
□ 1. 镜像和容器的区别,用一句话说清
□ 2. Dockerfile 是"脚本"还是"声明"?为什么
□ 3. `docker build` 最后那个点是什么?忘写会怎样
□ 4. 构建上下文 1.2GB 时,镜像一定很大吗?为什么
□ 5. 层是什么?为什么改一行代码会重装依赖
□ 6. CACHED 是什么意思?它会不会导致"用了旧结果"
□ 7. 为什么 `COPY requirements.txt` 要放在 `COPY . .` 前面
□ 8. `EXPOSE 8000` 会不会真的开端口
□ 9. `CMD` 的 exec 形式和 shell 形式,差在哪
□ 10. 多阶段构建省掉的是哪一块?什么情况下它反而不划算
□ 11. 为什么密钥不能写进 `ENV`
□ 12. 非 root 运行时,`useradd` 和 `COPY --chown` 谁先谁后
参考答案在哪:
| 问题 | 对应小节 |
|---|---|
| 1-2 | §零 0.2 / 0.3 |
| 3-4 | §二 2.1、§三 3.6 |
| 5-6 | §零 0.4 / 0.5、§二 2.6 |
| 7 | §三 3.7(第二版逐行) |
| 8-9 | §三 3.7、§五 5.4 |
| 10 | §六 6.3 |
| 11-12 | §十 10.1、§八 8.2 |
🔭 二十、下节预告
单容器你已经能写能审了。但真实项目里 API + MySQL 起步,还要 Nginx、Redis。
一个个 docker run 意味着:记一堆参数、手动建网络、操心启动顺序(数据库没起来 API 就崩)、数据卷要一个个挂。
明天 Docker Compose :把这些写进一个 docker-compose.yml,一条 docker compose up -d 拉起整套。会讲清三件事:服务名当域名 、健康检查决定启动顺序 (depends_on + condition: service_healthy)、以及为什么 Compose 里不要写 -p 3306:3306。
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客