KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 容器与构建上下文:那个 `.` 到底是什么 — keel 龙骨

这一章回答:docker build . 传给引擎的是什么?为什么它同时决定了构建速度、缓存稳定性和密钥安全?

这一章回答:docker build . 传给引擎的是什么?为什么它同时决定了构建速度、缓存稳定性和密钥安全?

三个问题看起来归三个部门管——性能、构建系统、安全。在 Docker 里它们是同一件东西的三个后果:构建上下文(build context)。

现场

一个多服务项目的仓库,业务代码放在 app/ 下。有人接手后做了两件事:

  1. 在 app/ 里加了一条 debug.log 的日志输出(本地调试用,已经加进 .gitignore,但文件还在磁盘上);
  2. 忘了写 .dockerignore。

于是出现两个现象,同事的第一反应是两个不同的猜测:

现象 A:每次 docker build 都要等四分钟,明明只改了一行注释
  → 猜测:Docker 太慢 / 机器该换了

现象 B:改了日志级别,构建日志里「COPY . .」这一层每次都重新执行
  → 猜测:Docker 的缓存坏了

两个猜测都不对。真正的答案在第 4 节,但那之前得先把"上下文"这个词搞清楚——它不是一个比喻,它是网络上传的一段字节。

一、容器不是小虚拟机

先纠正一个最容易带偏后续判断的说法。容器不是"轻量的虚拟机",因为它和虚拟机解决的不是同一个问题:

虚拟机 容器
隔离单位 一台完整的(虚拟)硬件 + 一个独立内核 一个进程,加上约束它的命名空间与 cgroup
内核 每个 VM 自带一个 共享宿主内核
启动代价 走一遍引导流程 启动一个进程
能装别的内核模块吗 能 不能——内核是宿主的
隔离强度 硬件级 进程级(内核漏洞即逃逸面)

这张表里最该记住的是最后两行。它们直接推出两条工程结论:

具体到应用层:容器给的是文件系统 + 进程视图 + 资源视图的隔离。你的进程能看到一个"独立的根目录"和"一台独占的机器",但它知道的网络、内存、CPU 都是宿主的切片。判断某件事能不能在容器里做,问一句"它需要动内核吗",答案就出来了。

二、镜像与容器:一份只读模板,加一层可写纸

镜像是只读的层堆叠,加上一份配置(config):

镜像 = [ 层 0: FROM 的基础镜像 ]
       [ 层 1: RUN 装系统包 ]
       [ 层 2: COPY 依赖清单 + RUN 安装依赖 ]
       [ 层 3: COPY 业务代码 ]
       + config(环境变量默认值 / CMD / ENTRYPOINT / EXPOSE / USER / STOPSIGNAL …)

容器 = 这份只读堆叠 + 一个可写层 + 一个进程。

关键性质只有一条,但它是后面 02、03 两章的全部前提:

镜像是不可变的,容器的每次改动都写在最上面那个可写层里。

由此推两个日常现象,都不用再背:

  1. docker run 之后在容器里 rm -rf 一个文件,镜像体积一点没变。 因为你删的是可写层里的一个标记,下面那个只读层里的文件还在。
  2. 容器删掉,容器里的改动就没了(除非写进了挂载的卷)。这是"容器无状态"这句话的真实含义——不是"不存数据",而是"可写层不持久"。

三、构建上下文:. 是路径,也是要发送的字节

现在回答标题里的问题。

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 的缓存键就变了,这一层连同它下面的所有层全部作废。于是:

两条都指向同一件事:上下文里任何内容会变的文件,都会变成缓存杀手。 .git/ 里的 index、.env 的时间戳、IDE 的临时文件,全都算。

这条判断可以收成一句可执行的话:

判断一个文件该不该进上下文,问它的内容会不会在两次构建之间变化。会变的(日志、.git、本地配置)必须在 .dockerignore 里;真正需要的(源码、依赖清单)留下。

六、上下文也是安全边界

app/.env 里放着这个项目的开发凭据:

DATABASE_URL=postgresql://app:supersecret@db:5432/orders
JWT_SECRET=dev-only-secret

(不用担心这串密码——它是本系列实验用的假值,而且你马上会看到它确实没能进去。)

.dockerignore 里有 .env 这一行,所以上面那次统计里它落在"被排除"的一栏。但请把"应该有这一行"当成一个需要验证的假设,而不是一个常识:

所以"密钥进没进镜像"的判据不是"运行时 ls 看不看得见",而是"它在不在构建上下文里"。这条判据的好处是:它可以在构建之前用一次体积统计就验证掉,不需要跑起来看。第 05 章会把三个边界(上下文 / 镜像层 / 运行时环境变量)并排列出来。

生产边界

教学替身 真实替换点 要注意什么
用体积统计脚本判断上下文内容 CI 里把上下文大小做成构建日志的一个指标 只看总量会漏掉"总量小但含密钥"的情况,要同时维护 .dockerignore 的审查
app/.dockerignore 里的最小排除集 项目根 .dockerignore + 按服务覆写(Compose 的 context 指向哪个目录,.dockerignore 就在哪个目录) context 换了目录,.dockerignore 的生效位置也跟着换,这是多服务项目里最常见的一处错位
共享宿主内核的容器 需要内核模块 / 特定内核版本的负载 这种负载走虚拟机或裸机,不要在容器里硬凑

动手

目标:把你手上任意一个项目的构建上下文量化出来,并让它缩小一个数量级。

  1. 统计全量上下文体积与文件数(含 node_modules、.git 等子目录);
  2. 写一份 .dockerignore,逐行写清排除理由("容器里会重新装" / "是构建时才读的" / "是密钥");
  3. 再统计一次,得到两个数字:排除前的体积 与 收缩到原体积的百分比;
  4. 判断标准:能说出每一个被排除项属于下面哪一类——
    • 容器内会重新生成(node_modules、dist);
    • 只对构建过程有用、不需要进镜像(Dockerfile、compose.yml);
    • 本地才有的东西,不该被带出去(.env、.git、日志、个人笔记)。
  5. 完成标志:能在不看文件的情况下说出"如果我现在删掉 .dockerignore 里 .git 那一行,代价是什么"(答案应包含"体积上升"和"缓存稳定性下降"两条)。

故障注入

注入方式 观察什么 说明的现象
删掉 .dockerignore 里的 .env 一行,只留 node_modules 上下文统计里 .env 出现在哪一栏 密钥进入上下文 → 进入镜像某层 → 镜像里的历史层可被提取
在 app/ 下 touch 一个每秒写入的日志文件 COPY . . 那一层的缓存命中情况 内容是变的文件会让 COPY 层每次作废,即使代码一行没改
把 .git/ 从 .dockerignore 里去掉 上下文文件数(本机实测 40 个文件、195.8 KB)+ 镜像层里能否读到提交记录 上下文把"开发过程的全部历史"带出去
让 context 指向仓库根而不是 app/ 体积统计数字的跳变 .dockerignore 只在上下文根目录被读取;换了 context 就等于换了一份规则

自测

  1. 用一句话说清"容器不是轻量虚拟机"的判据是什么,并举一个"这件事不能在容器里做"的例子。
  2. docker build . 里那个 . 发给谁?它是在哪一侧被 .dockerignore 过滤的——发送侧还是接收侧?这个区别在安全上意味着什么?
  3. 为什么"在容器里 rm 一个文件"不会让镜像变小?这条性质和"删掉当前层的密钥文件"之间的关系是什么?
  4. 一个项目的上下文里有 notes.local.md(内容几乎不变,0.0 KB)。它不进镜像,且体积可以忽略——它还应该被排除吗?说出你的判断依据。
  5. 有人告诉你"我们的构建慢是因为 Docker 太慢"。列出你能用一次统计证伪或证实这句话的两个数字。

现在能解释什么

下一章把图上那个菱形("缓存键是否命中")拆开:缓存键由什么组成,以及为什么同样的指令换个顺序就决定了构建时间。

进入 keel 阅读