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 的两个决定,直接决定了交付包的大小和升级时的搬运量。
那两个决定是:
- 依赖与代码分成两层:
app/requirements.txt单独COPY一次,再COPY app/board /app/board。改代码不会触发依赖重装——下一节量化它。 - 构建阶段不进运行阶段:运行阶段只从构建阶段拷走
/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
这张图里有两个必须看清的东西:
- 判定
CACHED的依据是「这一层的输入变没变」,不是「这次构建做什么」。 依赖层唯一的输入是requirements.txt的内容;只要它没变,那一层就直接复用,哪怕你改了十个源文件。 - 两条路径的代价差一个数量级:走左边只重做一层代码,走右边要重跑一次依赖安装。而升级时打的包是增量层——第五节那个 51M 说的就是这件事。
四、镜像里被「故意不写」的三件事
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}}'
要观察三件事,而不是「看到有输出就算跑过」:
User不是空的(基础镜像那个是空的)。这一行决定了客户chown数据目录时该写哪个数字。Cmd是数组形式([python -m board config]),不是一串带 shell 的字符串。
如果这里是一个字符串,停机窗口那一章的现象会在你身上重演。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 基础镜像 |
按现场合规要求评估:有些现场要求带完整工具链或指定基线的镜像 | 合规检查通不过,整包被退回 |
| 探针只在编排文件里 | 编排文件与镜像同版本发布,探针随服务调整 | 换了镜像但编排文件没换,探针检查的还是上一版的语义 |
自测题
- 用一句话说清「61.4kB 的代码」和「215MB 的镜像」之间的落差由哪几块构成,
并指出哪一块是你改代码改不动的。 - 为什么
Healthcheck是null反而是对的?如果这个镜像只有一个角色,
结论会不一样吗? Cmd是数组还是字符串,为什么会影响停机是否干净?- 改一行代码之后构建仍然飞快,说明哪两层的顺序是对的?把顺序反过来会怎样?
- 为什么包内的镜像 tar 不压缩,而外面要压一层?
现在能解释什么
- 一个镜像 215MB 由哪几块构成,以及「加出来的数对不上量出来的数」为什么是正常的;
- 依赖层与代码层分开的收益边界在哪里——注释也算改动;
- 为什么探针不在镜像里,以及「一个镜像四种角色」这个决定带来的连锁后果;
- uid/gid 写死、
Cmd用数组这两件事各自在防什么现场事故; - 交付包 160M、包内 tar 未压缩、增量层约 51M 这三个读数的关系——
它们将在 03 章决定「怎么搬」。