KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 一个能送进内网的镜像里装了什么 — keel 龙骨

这一章要回答一个看起来很朴素的问题:为什么你的代码只有 61.4kB,送进去的那个镜像却有 215MB? 答案不是「依赖太多」,而是「一个能自己跑起来的环境」本来就有这个重量。 把这份重量拆开,才能判断哪些是必须搬的、哪些是可以不搬的。

这一章要回答一个看起来很朴素的问题:为什么你的代码只有 61.4kB,送进去的那个镜像却有 215MB?
答案不是「依赖太多」,而是「一个能自己跑起来的环境」本来就有这个重量。
把这份重量拆开,才能判断哪些是必须搬的、哪些是可以不搬的。


一、先量一次重心

一套系统要送进没有外网的机房,第一个要回答的问题不是「怎么送」,而是**「送过去的是什么」**。

把它量出来:

读数 值
基础镜像 python:3.12-slim 179MB
应用镜像 board:1.0.0 215MB
其中:依赖层(一个只装了 psycopg[binary]==3.2.9 的 venv) 28.2MB
其中:应用代码层 61.4kB
打成压缩包之后的交付包 160M

先把这三个数字的落差记住:应用代码 61.4kB,它的运行环境 215MB。 也就是说,这个镜像的重量里,有 99.97% 和你写的代码无关。

这不是这套系统特殊,而是这类形态的常态:一个 Python 解释器、一套 C 运行库、一个装了依赖的虚拟环境,就是上百 MB 的地板。真正的含义是——交付的重量不在你的代码上。所以:

二、215MB 是怎么一层一层堆出来的

从 179 到 215 中间那 36MB,由三部分组成。对着多阶段构建的两个阶段看:

进来的东西 大小 是谁放进去的
python:3.12-slim 179MB 基础镜像,整块继承
/opt/venv(依赖) 28.2MB 构建阶段那个 RUN 装出来的,运行阶段整目录拷过来
/app/board(代码) 61.4kB COPY app/board /app/board
RUN groupadd … useradd … + WORKDIR /app 各 61.4kB / 4.1kB 建用户、建目录
USER / EXPOSE / CMD / ENV 0B 只改元数据,不产生文件

⚠️ 别急着做加法。 把上面几行加起来是 207.3MB,而 docker images 显示 215MB —— 差的 7.7MB 不是「结构成本」,是两把尺子的差:

$ docker images board:1.0.0                → 215MB      (展开后占磁盘多少)
$ docker history --no-trunc board:1.0.0    → 各层合计 162.4MB (压缩后的层体积)
$ docker images python:3.12-slim           → 179MB
$ docker history --no-trunc python:3.12-slim → 各层合计 134.1MB

docker images 的 SIZE 是展开后的磁盘占用,docker history 给的是压缩后的层体积,同一个基础镜像在两把尺子下差 44.9MB(179 vs 134.1)。所以「算出来和量出来对不上」是正常的,不要为了凑这个数去改代码 —— 那 7.7MB 里没有一行是你写的。

这里有一条边界必须划清:本课不讲 Dockerfile 语法。 FROM ... AS builder、多阶段为什么要用、COPY --from= 怎么工作,见《Docker 应用课》。本课关心的是面向交付的那部分后果:这个 Dockerfile 的两个决定,直接决定了交付包的大小和升级时的搬运量。

那两个决定是:

  1. 依赖与代码分成两层:app/requirements.txt 单独 COPY 一次,再 COPY app/board /app/board。改代码不会触发依赖重装——下一节量化它。
  2. 构建阶段不进运行阶段:运行阶段只从构建阶段拷走 /opt/venv,不带 pip 缓存、不带构建时的工作目录。这不是为了小,是为了少一样客户看不懂的东西。

三、改了代码之后,构建要重做几层

先看一次「改点东西再构建」的真实路径。第二次构建的读数是一整片 CACHED,直到导出阶段:

#6 CACHED ... #12 CACHED
#13 exporting layers done
#13 DONE 0.1s

作为对照,第一次构建里有这么一步:

#8 RUN python -m venv /opt/venv && pip install -r requirements.txt

这两段的差别就是「依赖层」和「代码层」被分开的收益。展开成路径:

flowchart TD
    A["改了 app/board/web.py 一行"] --> B["docker build"]
    C["改了 app/requirements.txt"] --> B
    B --> D{"requirements.txt 的内容变了吗"}
    D -->|"没变"| E["依赖层 CACHED:不建 venv、不装依赖,导出阶段 0.1s"]
    D -->|"变了"| F["重跑构建阶段那一步:建 venv + 装依赖"]
    E --> G["只重做 COPY app/board 那一层"]
    F --> G
    G --> H["导出镜像"]
    D -.->|"若 COPY 顺序写反:改代码也会让依赖层失效,全部重做"| F

这张图里有两个必须看清的东西:

四、镜像里被「故意不写」的三件事

docker inspect 能读出三个值,它们恰好都是刻意为之的:

User        10001:10001
Cmd         ["python","-m","board","config"]
Healthcheck null

User 是 10001:10001,而基础镜像是空——它没有设 USER。 也就是说,如果这个 Dockerfile 什么也不做,容器里跑的就是 root。这里的做法是把 uid/gid 写死成 10001,理由在 Dockerfile 的注释里写得很清楚:客户挂进来的卷权限要可预期。用动态分配的 uid 的话,每台机器上的数字都不一样,客户没法提前 chown。这条约束只在内网交付里存在——公网环境里没有人会去 chown 你的数据目录。

Cmd 是一个数组(["python","-m","board","config"]),不是字符串。数组形式意味着启动时不套一层 shell,进程直接就是 python。这对停机窗口很重要:套了 shell 的话,SIGTERM 先到 shell,会不会转给真正的进程取决于 shell 的实现——一个「停不干净」的容器,很多时候问题就在这一层。

Healthcheck 是 null——镜像里故意不写探针。 理由不是省事,而是这个镜像要扮演四种角色:web / worker / migrate / config。四个角色对「健康」的定义完全不同:

角色 健康意味着什么
web 能接连接、能响应 /healthz
worker 进程在,且最近一轮轮询没把数据库连挂
migrate 它是跑完就退的一次性进程,「健康」不适用
config 它只打印配置然后退出

把探针写死在镜像里,等于让 worker 去回答 web 的问题。所以探针属于编排文件,不属于制品——它在 04 章那份 compose.yml 里,一个服务一套。

五、制品、包体积与增量

镜像做出来了,但「镜像」和「客户拿到的东西」还不是一回事。三个读数:

读数 值 含义
交付包 board-delivery-1.0.0.tar.gz 160M 客户实际要搬的那一份
包内的 board__1.0.0.tar 未压缩 docker save 直出,不是 tar.gz
board:1.2.0(在 1.1.0 之上再加一层) 52MB 增量层约 51M

第二行值得解释:为什么包内的镜像是个未压缩的 tar? 因为它要能被 docker load 直接吃进去,而 docker load 吃的是 docker save 的原样输出。所以「压缩」这件事只做一层——在外面的 .tar.gz 上做。这也意味着解开包的那一刻,磁盘上会先出现一个 215MB 左右的未压缩 tar,磁盘不够就是在这里炸的(install.sh 的前置检查里那条「可用磁盘不足 2GB」的警告就是为它准备的)。

第三行是升级的代价:从 1.1.0 到 1.2.0,只多了一层,镜像变成 52MB。 但这不代表升级包只有 52MB——如果客户现场没做过「增量传输」,你搬过去的往往是一个完整的新镜像 tar。这也是为什么升级脚本(06 章)要先备份、再导入,而不是「覆盖一下」。

动手:可观察结果

对着这个镜像跑两条命令,读出三个值:

docker image inspect board:1.0.0 --format '{{.Config.User}}'
docker image inspect board:1.0.0 --format '{{.Config.Cmd}}'
docker image inspect board:1.0.0 --format '{{.Config.Healthcheck}}'

要观察三件事,而不是「看到有输出就算跑过」:

  1. User 不是空的(基础镜像那个是空的)。这一行决定了客户 chown 数据目录时该写哪个数字。
  2. Cmd 是数组形式([python -m board config]),不是一串带 shell 的字符串。
    如果这里是一个字符串,停机窗口那一章的现象会在你身上重演。
  3. Healthcheck 是 <no value> 或空。它是空的才是对的——探针在 compose.yml 里,
    一个服务一套。

再看一眼尺寸构成:

docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}'

完成标志:你能说出「如果想把这个镜像压到 180MB 以下,该动哪一层、不该动哪一层」。

故障注入

把代码里某个源文件改一行(随便改,比如打印一行字),重新构建一次:

docker build -t board:probe .

预期结果是整片 CACHED,只有 COPY app/board 那一层之后的部分重做,导出阶段在一秒以内。
然后改一下 app/requirements.txt(末尾加一行注释,内容要真的变),再构建一次:

预期结果是从构建阶段那一步开始全部重做,RUN python -m venv /opt/venv && pip install -r requirements.txt
重新跑一遍。这就是「依赖层与代码层分开」的收益边界——注释也算改动,因为它进的是
COPY 的那份文件的内容。

如果第一次改代码就触发了依赖重装,说明 COPY requirements.txt 和 COPY app/board
的顺序反了:代码层先落地,依赖层的输入就跟着一起变了。

生产边界

教学里这样 真实交付要换成 不换会出什么事
固定 10001:10001,客户按这个数字 chown 按客户现场的账号策略定,并写进安装文档 客户挂进来的卷属主不对,容器能起、写不进去——doctor 会报「工作目录不可写」
单架构镜像(本机构建) 按客户机器架构分别构建(如 linux/amd64 与 linux/arm64) 包搬进去 docker load 成功,容器一跑就 exec format error
slim 基础镜像 按现场合规要求评估:有些现场要求带完整工具链或指定基线的镜像 合规检查通不过,整包被退回
探针只在编排文件里 编排文件与镜像同版本发布,探针随服务调整 换了镜像但编排文件没换,探针检查的还是上一版的语义

自测题

  1. 用一句话说清「61.4kB 的代码」和「215MB 的镜像」之间的落差由哪几块构成,
    并指出哪一块是你改代码改不动的。
  2. 为什么 Healthcheck 是 null 反而是对的?如果这个镜像只有一个角色,
    结论会不一样吗?
  3. Cmd 是数组还是字符串,为什么会影响停机是否干净?
  4. 改一行代码之后构建仍然飞快,说明哪两层的顺序是对的?把顺序反过来会怎样?
  5. 为什么包内的镜像 tar 不压缩,而外面要压一层?

现在能解释什么

进入 keel 阅读