KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 容器与构建上下文:那个 `.` 到底是什么 — keel 龙骨
这一章回答:docker build . 传给引擎的是什么?为什么它同时决定了构建速度、缓存稳定性和密钥安全?
这一章回答:docker build . 传给引擎的是什么?为什么它同时决定了构建速度、缓存稳定性和密钥安全?
三个问题看起来归三个部门管——性能、构建系统、安全。在 Docker 里它们是同一件东西的三个后果:构建上下文(build context)。
现场
一个多服务项目的仓库,业务代码放在 app/ 下。有人接手后做了两件事:
- 在
app/里加了一条debug.log的日志输出(本地调试用,已经加进.gitignore,但文件还在磁盘上); - 忘了写
.dockerignore。
于是出现两个现象,同事的第一反应是两个不同的猜测:
现象 A:每次 docker build 都要等四分钟,明明只改了一行注释
→ 猜测:Docker 太慢 / 机器该换了
现象 B:改了日志级别,构建日志里「COPY . .」这一层每次都重新执行
→ 猜测:Docker 的缓存坏了
两个猜测都不对。真正的答案在第 4 节,但那之前得先把"上下文"这个词搞清楚——它不是一个比喻,它是网络上传的一段字节。
一、容器不是小虚拟机
先纠正一个最容易带偏后续判断的说法。容器不是"轻量的虚拟机",因为它和虚拟机解决的不是同一个问题:
| 虚拟机 | 容器 | |
|---|---|---|
| 隔离单位 | 一台完整的(虚拟)硬件 + 一个独立内核 | 一个进程,加上约束它的命名空间与 cgroup |
| 内核 | 每个 VM 自带一个 | 共享宿主内核 |
| 启动代价 | 走一遍引导流程 | 启动一个进程 |
| 能装别的内核模块吗 | 能 | 不能——内核是宿主的 |
| 隔离强度 | 硬件级 | 进程级(内核漏洞即逃逸面) |
这张表里最该记住的是最后两行。它们直接推出两条工程结论:
- 容器里不能跑"需要改内核"的东西(自己编译的驱动、依赖特定内核版本的模块、
sysctl调优)。这不是配置问题,是模型限制。 - 一旦你接受"共享内核",就要接受:宿主内核的版本是容器的隐性运行时依赖。一个在
node:22上跑得好的镜像,宿主机内核太老时可能起不来。
具体到应用层:容器给的是文件系统 + 进程视图 + 资源视图的隔离。你的进程能看到一个"独立的根目录"和"一台独占的机器",但它知道的网络、内存、CPU 都是宿主的切片。判断某件事能不能在容器里做,问一句"它需要动内核吗",答案就出来了。
二、镜像与容器:一份只读模板,加一层可写纸
镜像是只读的层堆叠,加上一份配置(config):
镜像 = [ 层 0: FROM 的基础镜像 ]
[ 层 1: RUN 装系统包 ]
[ 层 2: COPY 依赖清单 + RUN 安装依赖 ]
[ 层 3: COPY 业务代码 ]
+ config(环境变量默认值 / CMD / ENTRYPOINT / EXPOSE / USER / STOPSIGNAL …)
容器 = 这份只读堆叠 + 一个可写层 + 一个进程。
关键性质只有一条,但它是后面 02、03 两章的全部前提:
镜像是不可变的,容器的每次改动都写在最上面那个可写层里。
由此推两个日常现象,都不用再背:
docker run之后在容器里rm -rf一个文件,镜像体积一点没变。 因为你删的是可写层里的一个标记,下面那个只读层里的文件还在。- 容器删掉,容器里的改动就没了(除非写进了挂载的卷)。这是"容器无状态"这句话的真实含义——不是"不存数据",而是"可写层不持久"。
三、构建上下文:. 是路径,也是要发送的字节
现在回答标题里的问题。
docker build 的用法是 docker build [选项] <上下文>,那个 . 是上下文路径。它的语义不是"我在这个目录里执行构建",而是:
把这个路径下的文件打包,交给负责构建的那个进程。
这句话里有两处容易被忽略:
第一,谁在打包。 打包发生在你敲命令的那一侧(CLI 进程),不在构建引擎那一侧。也就是说 .dockerignore 是在上传之前生效的——被排除的文件根本不进入传输,也就从来没有出现在引擎的视野里。这和"传过去再删掉"是两件事:后者会把内容留在更早的层里。
第二,. 里的东西不等于「源码」。 它是这个目录下所有文件:node_modules/、.git/、本地日志、编辑器残留、.env。你以为你只传了源码,实际上传了开发机的整个现场。
全链路
下面这张图里的每一步都是跨进程或跨存储的,注意边界在哪:
flowchart TD
subgraph CLI["① 你的机器:docker CLI 进程"]
A["docker build -f Dockerfile.multi ."]
B["读 .dockerignore<br/>按规则过滤上下文"]
C["把过滤后的文件打成 tar 流"]
end
subgraph DAE["② 构建引擎进程(dockerd / BuildKit)"]
D["① 接收上下文 tar 流"]
E["逐条求值 Dockerfile"]
F{"该指令的缓存键<br/>是否命中?"}
G["复用已有层"]
H["执行指令<br/>产出新层"]
end
subgraph STORE["③ 镜像存储"]
I["镜像 = 层清单 + config"]
end
A --> B --> C
C -->|"跨进程:上下文传输"| D
D --> E
E --> F
F -->|"命中"| G
F -->|"未命中"| H
G --> I
H --> I
I -->|"docker run / compose up"| J["容器 = 只读层 + 可写层 + 一个进程"]
C -.->|"不写 .dockerignore 时<br/>node_modules / .git / .env 一起上"| K["上下文体积极大<br/>且缓存键不稳定"]
K -.-> H
图上第 ① 跳(A → B)在 CLI 进程内,第 ④ 跳(C → D)是跨进程的那一跳——上下文大小决定的是这一跳的成本。图上右侧那条虚线是本节的落点:该被排除的东西不是"慢一点",而是会反过来让缓存失效(下一节)。
四、实测:同一份目录,两个数量级的上下文
拿一个真实的、带 node_modules 和 .git 的 Node 项目目录做统计。先看不写 .dockerignore 时,这个 app/ 里到底有什么:
| 内容 | 文件数 | 体积 | 该不该进上下文 |
|---|---|---|---|
node_modules/ |
320 | 3133.7 KB | ❌ 容器里会重新装 |
.git/ |
40 | 195.8 KB | ❌ 版本历史,且含完整提交内容 |
debug.log |
1 | 5.4 KB | ❌ 本地日志 |
dist/ |
1 | 0.4 KB | ❌ 构建产物,镜像里会重新生成 |
.env |
1 | 0.1 KB | ❌ 密钥 |
Dockerfile.* |
2 | 0.7 KB | ❌ 构建时才读,不需要进镜像 |
notes.local.md |
1 | 0.0 KB | ❌ 个人笔记 |
package.json / build.js / src/index.js / .dockerignore |
4 | 1.1 KB | ✅ 真正需要的 |
| 合计 | 370 | 3337.2 KB |
app/.dockerignore 的内容(在系列课程里这份文件是可复用的最小集):
node_modules
.git
.env
*.log
notes.local.md
dist
Dockerfile*
compose*.yml
应用这份规则之后再统计同一个目录:
### .dockerignore 命中被排除 ###
node_modules 320 个 3133.7 KB
.git 40 个 195.8 KB
debug.log 1 个 5.4 KB
Dockerfile.multi 1 个 0.6 KB
dist 1 个 0.4 KB
Dockerfile.single 1 个 0.1 KB
.env 1 个 0.1 KB
notes.local.md 1 个 0.0 KB
排除合计: 3336.1 KB
### 实际进入构建上下文 ###
文件数: 4 体积: 1.1 KB
src 1 个 0.6 KB
package.json 1 个 0.2 KB
build.js 1 个 0.2 KB
.dockerignore 1 个 0.1 KB
缩减比: 0.03% → 省掉 3336.1 KB (100.0%)
370 个文件变成 4 个,3337.2 KB 变成 1.1 KB。 这里请只读比例,不要记绝对值——node_modules 的大小取决于项目的依赖树,一个装了 playwright 或 torch 的项目能轻松上到几百 MB。
五、上下文大不只是"慢"
如果只有"传输慢"这一个后果,那它顶多算个性能问题。真正让它变成工程问题的是第二个后果,它和缓存有关。
回到开头的现场:为什么"只改了日志级别",COPY . . 那一层还是重建了?
因为 COPY 指令的缓存键包含被复制文件的内容摘要。而 debug.log 在上下文里——应用每次跑都往里写,它的内容变了,COPY 的缓存键就变了,这一层连同它下面的所有层全部作废。于是:
- 现象 A(构建四分钟)的根因是
node_modules每次都被传输; - 现象 B(
COPY . .每次都重建)的根因是debug.log每次内容都不同。
两条都指向同一件事:上下文里任何内容会变的文件,都会变成缓存杀手。 .git/ 里的 index、.env 的时间戳、IDE 的临时文件,全都算。
这条判断可以收成一句可执行的话:
判断一个文件该不该进上下文,问它的内容会不会在两次构建之间变化。会变的(日志、
.git、本地配置)必须在.dockerignore里;真正需要的(源码、依赖清单)留下。
六、上下文也是安全边界
app/.env 里放着这个项目的开发凭据:
DATABASE_URL=postgresql://app:supersecret@db:5432/orders
JWT_SECRET=dev-only-secret
(不用担心这串密码——它是本系列实验用的假值,而且你马上会看到它确实没能进去。)
.dockerignore 里有 .env 这一行,所以上面那次统计里它落在"被排除"的一栏。但请把"应该有这一行"当成一个需要验证的假设,而不是一个常识:
- 上面那条
COPY . .会把上下文里的所有东西复制进层。.env若在上下文里,它就在镜像的某一层里。 - 而层是只读且可提取的:任何拿到镜像的人(哪怕只有 tar 文件,不需要能跑容器)都能把历史层的文件系统导出来,然后
grep一遍。删掉当前层里的文件不能清除历史层里的副本——这正是第 02 章"层是叠加的"那条性质的另一半。
所以"密钥进没进镜像"的判据不是"运行时 ls 看不看得见",而是"它在不在构建上下文里"。这条判据的好处是:它可以在构建之前用一次体积统计就验证掉,不需要跑起来看。第 05 章会把三个边界(上下文 / 镜像层 / 运行时环境变量)并排列出来。
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
| 用体积统计脚本判断上下文内容 | CI 里把上下文大小做成构建日志的一个指标 | 只看总量会漏掉"总量小但含密钥"的情况,要同时维护 .dockerignore 的审查 |
app/.dockerignore 里的最小排除集 |
项目根 .dockerignore + 按服务覆写(Compose 的 context 指向哪个目录,.dockerignore 就在哪个目录) |
context 换了目录,.dockerignore 的生效位置也跟着换,这是多服务项目里最常见的一处错位 |
| 共享宿主内核的容器 | 需要内核模块 / 特定内核版本的负载 | 这种负载走虚拟机或裸机,不要在容器里硬凑 |
动手
目标:把你手上任意一个项目的构建上下文量化出来,并让它缩小一个数量级。
- 统计全量上下文体积与文件数(含
node_modules、.git等子目录); - 写一份
.dockerignore,逐行写清排除理由("容器里会重新装" / "是构建时才读的" / "是密钥"); - 再统计一次,得到两个数字:排除前的体积 与 收缩到原体积的百分比;
- 判断标准:能说出每一个被排除项属于下面哪一类——
- 容器内会重新生成(
node_modules、dist); - 只对构建过程有用、不需要进镜像(
Dockerfile、compose.yml); - 本地才有的东西,不该被带出去(
.env、.git、日志、个人笔记)。
- 容器内会重新生成(
- 完成标志:能在不看文件的情况下说出"如果我现在删掉
.dockerignore里.git那一行,代价是什么"(答案应包含"体积上升"和"缓存稳定性下降"两条)。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
删掉 .dockerignore 里的 .env 一行,只留 node_modules |
上下文统计里 .env 出现在哪一栏 |
密钥进入上下文 → 进入镜像某层 → 镜像里的历史层可被提取 |
在 app/ 下 touch 一个每秒写入的日志文件 |
COPY . . 那一层的缓存命中情况 |
内容是变的文件会让 COPY 层每次作废,即使代码一行没改 |
把 .git/ 从 .dockerignore 里去掉 |
上下文文件数(本机实测 40 个文件、195.8 KB)+ 镜像层里能否读到提交记录 | 上下文把"开发过程的全部历史"带出去 |
让 context 指向仓库根而不是 app/ |
体积统计数字的跳变 | .dockerignore 只在上下文根目录被读取;换了 context 就等于换了一份规则 |
自测
- 用一句话说清"容器不是轻量虚拟机"的判据是什么,并举一个"这件事不能在容器里做"的例子。
docker build .里那个.发给谁?它是在哪一侧被.dockerignore过滤的——发送侧还是接收侧?这个区别在安全上意味着什么?- 为什么"在容器里
rm一个文件"不会让镜像变小?这条性质和"删掉当前层的密钥文件"之间的关系是什么? - 一个项目的上下文里有
notes.local.md(内容几乎不变,0.0 KB)。它不进镜像,且体积可以忽略——它还应该被排除吗?说出你的判断依据。 - 有人告诉你"我们的构建慢是因为 Docker 太慢"。列出你能用一次统计证伪或证实这句话的两个数字。
现在能解释什么
- 那个
.不是"当前目录",是一份要传输的文件集合;.dockerignore在传输前生效。 - 上下文大的两个后果是传输慢和缓存不稳定,后者更隐蔽——一个不断追加的日志文件就足以让
COPY层每次重建。 - 上下文是安全边界:判据是"它在不在上下文里",而不是"运行时看得见吗"。
- 容器共享宿主内核,这限定了它在哪些负载上根本不适用。
下一章把图上那个菱形("缓存键是否命中")拆开:缓存键由什么组成,以及为什么同样的指令换个顺序就决定了构建时间。