KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 容器镜像:把"运行环境"也打包进产物 — keel 龙骨
这一章回答:Dockerfile 里每一行到底在决定什么——层、缓存、体积、安全边界与启动行为;以及为什么同一个应用,不同人写出的镜像能差一个数量级。
这一章回答:Dockerfile 里每一行到底在决定什么——层、缓存、体积、安全边界与启动行为;以及为什么同一个应用,不同人写出的镜像能差一个数量级。
容器解决的老问题是"环境不一致"。但它引入了新问题:镜像一旦做错,错误会被复制到每一个环境。这一章给出一套可直接抄写的写法与一套检查清单。
一、层的本质:一个增量文件系统 + 一份不可叠加的配置
FROM base ← 只读层(可复用)
RUN ... ← 每一条指令产生一个新层
COPY ... ← 同上
ENTRYPOINT / CMD ← 不产生数据层,写入镜像 config
关键性质:
- 层是只读且可缓存的:某一层没变,它下面的层都能复用构建缓存。这决定了指令的排列方式。
- 层是叠加的,删除并不减体积:在后面一层
rm -rf前面那一层的文件,镜像体积不会变小(被删文件仍在前一层里)。所以清理必须和产生垃圾的指令在同一条 RUN 里。 - 层数有代价:层数过多会让镜像元数据变大、拉取变慢;层数过少则丧失缓存粒度。目标是把"变化频率相近的东西"放在同一层。
由此得到 Dockerfile 的第一条排序原则:按变化频率从低到高排列。
系统包(几乎不变) → 语言运行时(偶尔变) → 依赖清单(经常变)
→ 安装依赖(随之变) → 业务代码(每次都变)
二、多阶段构建:最终镜像里不该有编译器
# ---------- 构建阶段:需要编译工具,体积大 ----------
FROM python:3.12-slim@sha256:... AS builder
WORKDIR /src
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project # 只装依赖,先利用缓存
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen # 再装项目本身
RUN uv build --out-dir /dist
# ---------- 运行阶段:只要产物 + 运行时 ----------
FROM python:3.12-slim@sha256:... AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN groupadd -r app && useradd -r -g app app # 非 root 运行
COPY --from=builder /dist/*.whl /tmp/
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir /tmp/*.whl \
&& rm -rf /tmp/*.whl
COPY --from=builder /src/entrypoint.sh /entrypoint.sh
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s \
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
ENTRYPOINT ["/opt/venv/bin/python", "-m", "app"]
这段脚本里每一处都有理由:
| 写法 | 理由 |
|---|---|
| 依赖先于代码 COPY | 只改代码时不重装依赖,缓存命中 |
--mount=type=cache |
构建缓存不进镜像层(比传统的先 COPY 依赖再删缓存更干净) |
| 基础镜像按 digest 固定 | tag 可被移动,等于把安全决定权交给上游(见第一门课 04 章) |
| 非 root 用户 | 容器逃逸时的第一道减损 |
HEALTHCHECK + EXPOSE |
让编排层知道怎么判断容器活着 |
--no-cache-dir + 同层清理 |
pip 缓存和临时文件不留在最终层 |
三、体积治理:不是"越小越好",而是"越小越安全、越快"
| 手段 | 收益 | 注意点 |
|---|---|---|
| 精简基础镜像(slim / alpine / distroless) | 攻击面、拉取时间、存储成本同时下降 | alpine 用 musl,某些 wheel 需编译;distroless 没有 shell,调试困难 |
.dockerignore |
避免 .git/、__pycache__/、.env、本地数据进构建上下文 |
很多人忘了它,这是最常见的"镜像莫名很大"的原因 |
| 不装运行时不需要的东西 | 少一个包少一堆 CVE | curl/wget 只调试用,多阶段方案里应留在 builder |
| binaries 静态编译(Go/Rust) | 可直接跑在 scratch 上 |
需要额外的 DNS/证书处理(静态链接或挂 CA) |
判断标准很直接:最终镜像里只应该有"运行必需的东西"。查法:起一个临时容器,ls 一圈,看有没有编译器、包管理器缓存、源码目录、测试文件。
四、缓存策略:本地构建 vs CI 构建的区别
本地 Docker 默认缓存:存在daemon,CI 每次新机器 → 冷启动,命中率低
解法:
① registry cache:直接 pull 上一次构建产出的缓存镜像
② BuildKit inline cache:把层缓存元数据写进镜像随推送走(`--cache-to`/`--cache-from`)
③ 包管理器缓存:用 `--mount=type=cache` 挂载 uv/pip/apt/npm 的下载目录
CI 上的推荐组合是:--mount=type=cache 处理下载型缓存,registry cache 处理层缓存。只做前者时,编译型项目每次仍要重新编译;只做后者时,依赖下载慢。两者解决的是不同的问题。
同时记住第一门课 02 章的那个原则:缓存命中与否必须结果一致。 定期做一次禁用缓存的构建,验证你的 Dockerfile 没有隐式依赖缓存。
五、镜像也可以做供应链安全:四件事
- 基础镜像按 digest 固定,定期接受升级 PR(交给机器人 + 与人工 PR 同样的门禁)。
- 构建 SBOM:
docker buildx build --sbom=true直接把 SBOM 写入 registry 作为附带产物,或用syft单独生成。 - 签名与来源证明:
cosign sign+actions/attest-build-provenance;签名对象必须是 digest(@sha256:),签 tag 等于签一个会动的靶子。 - 扫描 + 部署侧校验:Trivy/Grype 在 CI 里按严重等级阻断;生产集群用 Kyverno 之类的准入控制拒绝未签名镜像。扫描证明"没有已知 CVE",签名证明"这是我们自己构建的"—–两件事都要做。
六、容器化与你的三个项目
| 项目 | 容器化程度 | 面试可以说到什么程度 |
|---|---|---|
| agent-feed(CLI 工具) | 不需要容器交付,产物是 wheel | 讲 wheel 冒烟 + SBOM 就够了,别硬套镜像 |
| E 平台 | 有 Docker Compose、多服务(FastAPI + ARQ worker + Redis + MySQL) | 可讲服务编排关系与依赖顺序(谁先起、谁依赖谁健康检查),这是货真价实的运维认知 |
| 指挥平台 | Compose + Nginx + 日志轮转 | 讲脚本化部署与单机构成的边界,不要往上拔高成 K8s |
动手:可观察结果
| 动作 | 产出物 | 判断标准 |
|---|---|---|
| 为一个 Python 服务写多阶段 Dockerfile | 可构建的 Dockerfile | 最终镜像里 python -c "import gcc" 不存在、无 pip cache |
| 对比优化前后 | docker images 的体积数字 + 构建耗时 |
体积下降可量化;一并记录冷/热构建耗时差 |
配 .dockerignore |
文件本身 + 前后上下文大小 | docker build 输出的 context 大小明显下降 |
| 加非 root 用户 | Dockerfile 片段 | docker run ... id 输出的不是 uid 0 |
| 加一把 SBOM | SBOM 文件 | 能回答"镜像里有多少个系统包、多少个 Python 依赖" |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
删掉 .dockerignore |
构建上下文大小与是否泄漏 .git |
context 暴涨;.git 进镜像历史可查 |
| 把清理命令拆到独立 RUN | 镜像体积 | 不降反增——层是叠加的,后层的删除不会让前层的体积变小 |
| 把基础镜像改回 latest tag | 两周后重新构建的结果 | 同一 Dockerfile 产出不同基础层,非确定性由此而来 |
| 用 root 运行服务 | id 输出 |
逃逸风险;这是一种"一直在犯但没人注意"的默认错误 |
| 发布后手动覆盖 tag | digest 是否变化 | 变了 → 凡是按 tag 部署的,都是在为一个随时可被替换的目标背书 |
自测题
- 为什么 Dockerfile 的指令顺序要"按变化频率从低到高"?
- "在后面的层里删除文件不会让镜像变小"这句话的机制是什么?正确的清理写法是什么?
- 多阶段构建解决了什么问题?builder 阶段留下的哪些东西是不允许进入 runtime 的?
--mount=type=cache与 registry cache 分别处理哪一类缓存?- 为什么要按 digest 而不是 tag 签名镜像?(提示:tag 可以被移动)