Dockerfile 能工作只是起点。项目每天都会改代码,如果每次都重新下载依赖、重新安装工具,构建会变慢,也更容易受网络波动影响。
FROM、RUN、COPY 等指令会产生镜像层或元数据。构建器根据指令和输入判断某一层能否复用。
在我们的 Dockerfile 中,依赖下载位于源码复制之前:
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY main.go ./只修改 main.go 时,前两步输入没有变化,依赖层仍可复用。如果先 COPY . .,任何源码变化都可能使后续依赖下载失去缓存。
构建器从 Dockerfile 顶部向下检查每一步是否存在可复用结果。普通指令会比较指令本身与相关输入;COPY、ADD 还会根据涉及的文件元数据计算校验信息。一旦某一步无法命中,后续依赖它的步骤也需要重新评估或执行。
因此下面两种改动影响范围不同:
main.go:COPY go.mod go.sum 的输入不变,依赖下载可以复用;源码复制和编译变化。go.mod:依赖文件复制的结果变化,下载依赖和后面的编译都要重新执行。缓存复用的正确目标不是“不惜一切避免执行”,而是让变化频率不同的输入尽量晚地相遇。依赖清单通常比业务源码稳定,所以先处理依赖。
RUN --mount=type=cache,target=/go/pkg/mod ... 提供一个可跨构建复用的缓存目录,但目录内容不会成为最终镜像层的一部分。指令缓存命中时,整个 RUN 可以跳过;指令缓存未命中时,Go 仍可从 cache mount 中复用已经下载的模块。
这形成两级加速:
指令缓存命中:整个步骤不执行
指令缓存未命中:步骤执行,但工具复用下载缓存cache mount 只应影响性能,不应成为构建正确性的唯一来源。缓存被清理后,构建仍必须能从声明的依赖重新得到相同功能结果。
不改文件,直接重新构建:
$ docker build --progress=plain -t docker-task-api:1.0 .
#11 [build 4/6] RUN --mount=type=cache,target=/go/pkg/mod go mod download
#11 CACHED
#13 [build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build ...
#13 CACHED
#17 naming to docker.io/library/docker-task-api:1.0CACHED 表示构建器确认输入没有变化,因此没有重新执行该步骤。第二次构建通常会明显更快。
现在只改欢迎语,再构建一次。你会看到依赖下载仍是 CACHED,但复制源码和编译步骤会重新执行。这就是合理排列 Dockerfile 的直接收益。
为了得到更明确的证据,可以给 main.go 加一处不影响依赖的空白或注释后执行:
$ docker build --progress=plain -t docker-task-api:1.0 . 2>&1 | \
grep -E 'go mod download|COPY main.go|go build|CACHED'这里筛选输出只是为了学习观察,完整构建日志仍应在失败时保留。预期是依赖相关步骤命中缓存,COPY main.go 与 go build 重新执行。
再执行一次不改文件的构建,所有相关步骤重新变为 CACHED。这证明缓存键跟输入有关,而不是“第二次永远缓存”。
可以用四个问题检查指令顺序:
.dockerignore 排除版本库、构建结果、临时文件和秘密。不要为了减少层数把所有事情塞进一个巨大的 RUN。层少不等于缓存好、镜像小或更安全。把逻辑相关且需要在同一层清理的包管理命令合并是合理的;把依赖安装、源码复制和编译全部混在一起,则会扩大缓存失效范围并降低可读性。
latest 为什么不是更新策略基础镜像标签是引用,不会因为远端内容更新就自动让已有镜像层改变。构建器可能复用已经取得的基础镜像。需要检查更新时,可以有意识地执行:
$ docker build --pull -t docker-task-api:1.0 .--pull 尝试解析并拉取更新的基础镜像;它仍然不等于应用依赖审计,也不保证更新一定兼容。稳定交付应该在受控流程中更新、构建、测试并记录摘要,而不是每天无条件使用 --no-cache。
查看最终镜像历史:
$ docker image history docker-task-api:1.0
IMAGE CREATED BY SIZE
... ENTRYPOINT ["task-api"] 0B
... EXPOSE map[8080/tcp:{}] 0B
... USER app 0B
... COPY /out/task-api /usr/local/bin/task-api ... 11.4MB
... RUN /bin/sh -c addgroup ... 4.7kB
... /bin/sh -c结果中没有 Go SDK 层。编译阶段确实参与了构建,但只有 /out/task-api 被复制到运行阶段。
镜像显示的各层大小不能简单相加来估算磁盘总占用。多个镜像可能共享相同基础层,Docker 会复用它们。
history 展示的是构建线索,不是完整文件审计docker image history 适合查看层的创建命令和大致大小,但零字节的元数据指令也会出现,BuildKit 生成的历史信息还可能经过处理。它不能替代漏洞扫描、软件物料清单或对最终文件系统的检查。
同一基础层被十个镜像引用,设备上通常只存一份内容;某个镜像列表显示的“大小”却可能包含它的逻辑总大小。因此讨论磁盘占用时,要区分镜像逻辑大小、层共享后的实际占用和构建缓存占用。
镜像不需要先启动也能检查:
$ docker image inspect docker-task-api:1.0 \
--format 'user={{.Config.User}} entrypoint={{json .Config.Entrypoint}} ports={{json .Config.ExposedPorts}}'
user=app entrypoint=["task-api"] ports={"8080/tcp":{}}这条输出确认了三件事:默认用户是 app,主进程是 task-api,镜像声明监听 8080/tcp。这些都是镜像配置,而不是某个容器的运行状态。
COPY failed 或找不到文件检查当前目录、最后的构建上下文参数和 .dockerignore。COPY 的相对路径从构建上下文开始计算。
先确认网络和代理,再单独重试。不要为了临时成功删除 go.sum,它承担依赖校验作用。
确认文件确实位于构建上下文,并执行 docker build --no-cache ... 做一次对照。--no-cache 适合诊断,不应成为每次构建的默认选项。
先找第一条从 CACHED 变成重新执行的步骤。它通常就是缓存失效起点。再问:Dockerfile 指令是否改变、COPY 涉及的文件是否改变、基础镜像是否更新、构建器是否换了、旧缓存是否被清理。不要只看最后一个耗时最长的编译步骤,因为它可能只是被更早的输入变化连带触发。
检查构建上下文是否指对目录、.dockerignore 是否排除了需要的文件、标签是否覆盖到了正在运行容器使用的镜像。已有容器不会因为同名标签被重新构建就自动换镜像;需要基于新镜像重建容器。
按下面顺序各构建一次,并记录第一条未命中缓存的指令:
main.go 的欢迎语。go.mod 增加一个空行。--no-cache 构建一次。你应该观察到:第一组几乎全命中;第二组从源码复制处变化;第三组从依赖文件复制处变化;第四组主动不使用常规指令缓存。实验价值在于把“缓存快”变成可预测的因果关系。
真正理解构建缓存的标志,不是看到 CACHED,而是能在改文件之前预测哪一步会失效,并能从输出找到第一条与预测不一致的证据。