KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 容器镜像:把"运行环境"也打包进产物 — keel 龙骨

这一章回答:Dockerfile 里每一行到底在决定什么——层、缓存、体积、安全边界与启动行为;以及为什么同一个应用,不同人写出的镜像能差一个数量级。

这一章回答:Dockerfile 里每一行到底在决定什么——层、缓存、体积、安全边界与启动行为;以及为什么同一个应用,不同人写出的镜像能差一个数量级。

容器解决的老问题是"环境不一致"。但它引入了新问题:镜像一旦做错,错误会被复制到每一个环境。这一章给出一套可直接抄写的写法与一套检查清单。

一、层的本质:一个增量文件系统 + 一份不可叠加的配置

FROM base            ← 只读层(可复用)
RUN  ...             ← 每一条指令产生一个新层
COPY ...             ← 同上
ENTRYPOINT / CMD     ← 不产生数据层,写入镜像 config

关键性质:

由此得到 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 没有隐式依赖缓存。

五、镜像也可以做供应链安全:四件事

  1. 基础镜像按 digest 固定,定期接受升级 PR(交给机器人 + 与人工 PR 同样的门禁)。
  2. 构建 SBOM:docker buildx build --sbom=true 直接把 SBOM 写入 registry 作为附带产物,或用 syft 单独生成。
  3. 签名与来源证明:cosign sign + actions/attest-build-provenance;签名对象必须是 digest(@sha256:),签 tag 等于签一个会动的靶子。
  4. 扫描 + 部署侧校验: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 部署的,都是在为一个随时可被替换的目标背书

自测题

  1. 为什么 Dockerfile 的指令顺序要"按变化频率从低到高"?
  2. "在后面的层里删除文件不会让镜像变小"这句话的机制是什么?正确的清理写法是什么?
  3. 多阶段构建解决了什么问题?builder 阶段留下的哪些东西是不允许进入 runtime 的?
  4. --mount=type=cache 与 registry cache 分别处理哪一类缓存?
  5. 为什么要按 digest 而不是 tag 签名镜像?(提示:tag 可以被移动)

进入 keel 阅读