【FDE系列】阶段2:Day 42(UP详细版):Dockerfile 实战 — 把你的应用打包成镜像

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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,把每条指令翻译成一层,逐层执行并缓存,最后叠成一个镜像。

分五步:

  1. 收上下文 --- 把 docker build . 里的目录(排除 .dockerignore)打包发给 daemon。COPY/ADD 只能访问这个范围。

  2. 解析指令 --- 把 Dockerfile 拆成有序指令列表。RUN/COPY/ADD 产生文件层;ENV/CMD/WORKDIR 等只改配置,不产生层。

  3. 逐层查缓存 --- 每层算 hash(父层key + 指令 + 输入文件内容)。命中就跳过执行;没命中就执行。父层一变,后面全失效------这就是指令顺序决定构建成本的原因。

  4. 执行并成层 --- RUN 起临时容器跑命令、提交成只读层;COPY 把文件写进新层。层与层用 overlayfs 叠加成一个文件系统。

  5. 出镜像 --- 所有层 + 配置(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 分钟)

  1. 跑 dockerfile_lint.py,确认 0 / 84 / 100

  2. 把 improved 版加一行 USER 10001,看分数变成多少(提示:会掉 R15?)

  3. 把 production 版的 COPY --chown=app:app . . 改成 COPY . .,看分数变化并解释

  4. 回答:为什么 naive 版原始分是 -31,而不是 0?

练习 2:真机构建并量体积(约 50 分钟)

  1. 用 improved 版 build,docker images 记录体积

  2. 用 production 版 build,再记录;差值是否符合 §六 的估算

  3. docker history myapi:1.0 看每一层多大,找出最大的一层

  4. 用 docker run --rm myapi:1.0 whoami 确认不是 root

练习 3:缓存实验(约 30 分钟)

  1. 连续 build 两次,第二次应该全是 CACHED

  2. 改 main.py 一个字符,再 build,记录耗时

  3. 在 requirements.txt 加 requests==2.32.3,再 build,记录耗时

  4. 加上 cache mount 再重复第 3 步,对比耗时

验收:三次耗时能对上 §七 的数量级(0.4s / 42s / 3.5s)。

练习 4:把密钥从镜像里赶出去(约 30 分钟)

  1. 故意写 ENV DB_PASSWORD=changeme 构建一版

  2. docker history --no-trunc 找出它,docker save 后解包 grep 一次

  3. 改成运行期 -e 注入,重新构建,再 grep 一次确认没有

  4. 用自己的话写三句:为什么"删掉那行再 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博客

Docker

【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客

相关推荐
MarkHD1 小时前
超分辨率与图像放大:从ESRGAN的纹理重建到UltimateSDUpscale的4K分块实战
人工智能·深度学习·计算机视觉
武子康1 小时前
H3-World 的动作没按时发生,先检查时间线变成了什么
人工智能·后端·agent
jimmyleeee1 小时前
Agent安全之五:当语言变成执行------深入理解Unexpected Code Execution (ASI05)
人工智能·安全
刘马想放假1 小时前
Copyparty 轻量文件共享:Docker Compose 部署、权限与多协议使用全指南
docker·容器
谁在黄金彼岸1 小时前
Ollama 还是 LM Studio:在 24GB 的 M6 Mac 上,我把 Ollama 卸载了
人工智能
方方洛1 小时前
ray教程-18-性能调优与反模式
人工智能·分布式·机器学习
人工智能培训1 小时前
知识图谱:结构化表达实体与关系的知识组织技术
大数据·人工智能·算法·重构·生活·知识图谱
GIS数据转换器2 小时前
基于CIM的城市智慧供热监管服务平台
java·大数据·开发语言·人工智能·后端·物联网·struts
专注仿真2 小时前
动态规划算法详解
人工智能·python·大模型训练·控制算法