4、上下文与 .dockerignore:构建上下文概念、排除文件的最佳实践
聊到 Docker 构建,很多人上来就写 docker build -t my-img .,结果镜像又大又慢。问题在哪?出在构建上下文。说白了,构建上下文就是你把哪个目录交给 Docker 守护进程去处理。我刚开始做 Docker 时也吃过亏,直接 docker build .,结果把整个 node_modules 和 .git 都打了进去,镜像体积飙到 2GB。嗯,从那以后我每次写 Dockerfile 都会先看一眼 .dockerignore。
4.1 构建上下文是什么?
当你执行 docker build 时,Docker 客户端会把指定的目录打包,然后发送给 Docker 守护进程。这个目录就是构建上下文。守护进程只能访问这个上下文里的文件。你的 Dockerfile 里的 COPY、ADD 指令,全是从上下文里拿文件。
为什么会这样?因为 Docker 是 C/S 架构。客户端(你敲命令的地方)和守护进程(真正干活的)可能不在同一台机器。你必须把需要的文件先传给守护进程。所以,构建上下文越精简,传输越快,构建速度也越快。
核心结论:构建上下文 = 发送给 Docker 守护进程的目录压缩包。守护进程只能看到这个包里的文件。
# 常见构建命令
docker build -t my-app:latest ./app
# 这里 ./app 就是构建上下文
# Docker 会递归打包 ./app 下的所有文件和子目录
注意,docker build 还支持从 URL 或 stdin 构建,但最常用的是本地目录。我个人习惯在项目根目录下执行 docker build -f docker/Dockerfile .,这样可以灵活指定 Dockerfile 位置,上下文仍然是 . 当前目录。
4.2 .dockerignore 的作用
.dockerignore 跟 .gitignore 很像。它告诉 Docker 在打包上下文时,跳过哪些文件或目录。你想想看,如果你项目里有一堆 __pycache__、.git、node_modules,这些文件对镜像构建完全没用,但 Docker 还是会一股脑打包进去。
一次真实经历:有个同事的构建上下文里有 3GB 的日志文件,每次构建都要等 10 分钟。加了 .dockerignore 后,上下文缩到 200MB,构建时间降到 30 秒。差别巨大。
常见陷阱:.dockerignore 只影响 发送给守护进程的上下文,不影响 Dockerfile 中 RUN 指令的行为。如果你在 RUN 里从网络下载文件,那些文件不在上下文检查范围内。
4.3 排除文件的最佳实践
我总结了一套常用规则,你可以直接拿来用。关键是想到哪些文件是构建时不需要的,以及哪些文件包含敏感信息。
- 版本控制目录:
.git/、.svn/、.hg/ - 依赖和包缓存:
node_modules/、vendor/、__pycache__/、*.pyc、.m2/、.gradle/ - 本地开发文件:
.env、.idea/、.vscode/、*.swp、*.swo - 构建产物:
dist/、build/、target/、*.o、*.so - 日志和临时文件:
*.log、*.tmp、*.bak - 敏感信息:
*.pem、*.key、credentials、secrets/
我的建议:把 .dockerignore 放在项目根目录,并保持与 .gitignore 互相配合。有些文件只在构建时需要(比如 Makefile),就在 .dockerignore 里留一条注释说明。
4.4 .dockerignore 语法速览
.dockerignore 使用 Go 的 filepath.Match 和 docker/pkg/fileutils 的扩展规则。支持通配符、负模式(!),以及 ** 匹配多级目录。
来看一份典型的 .dockerignore 文件:
# 排除所有 .git 目录
.git/
# 排除 node_modules 目录
node_modules/
# 排除日志文件
*.log
# 排除临时文件
*.tmp
# 但保留 data 目录下的 .gitkeep(负模式示例)
!data/.gitkeep
# 排除整个 dist 目录
dist/
# 排除 .idea 开发配置
.idea/
注意优先级:Docker 会逐行应用规则。先排除后包含,用 ! 可以重新包含被排除的路径。但如果先全局排除 * 再试图包含 !data/,可能因为递归规则出现问题。我曾经遇到一个坑:* 排除了所有,然后 !Dockerfile 却因为父目录也被排除而失效。解决办法:用 **/ 模式代替。
另一个常见错误:很多人以为 *.log 会匹配 logs/ 目录下的 app.log,实际上它只匹配当前目录下的 *.log 文件,不会递归。要递归排除所有 .log 文件,用 **/*.log。
4.5 如何验证上下文是否精简
构建前可以先看看上下文的大小。Docker 提供了 docker build --no-cache --progress=plain 会显示发送上下文的大小。但更方便的是用 docker build -t test . --no-cache -q,注意看输出中的 context 行。
如果上下文太大,建议先本地 tar 模拟打包看哪些文件:
# 在项目根目录
tar -cvf /dev/null --exclude-from=.dockerignore . | wc -c
# 或者用 find 检查
find . -type f | wc -l
我个人习惯在 CI 脚本里加一步检查:当上下文超过 500MB 时自动告警。
4.6 上下文与多阶段构建的关系
.dockerignore 控制的是 发送给守护进程的初始文件集,而多阶段构建控制的是 镜像层之间的文件传递。两者是正交概念,经常一起用。比如第一阶段需要源代码和依赖,第二阶段只复制构建产物。这时候 .dockerignore 可能只排除无关的 IDE 配置和日志,保留源码和包锁文件。第二阶段再用 COPY --from=0 只拿产物。
最佳组合:.dockerignore 减小上下文尺寸 + 多阶段构建减小最终镜像尺寸。两个一起上,效果翻倍。
4.7 常见误区与避坑
- 误区一:.dockerignore 会降低构建缓存命中率。错!它只影响上下文打包,不影响缓存键的计算。实际上,排除无关文件反而让缓存更稳定,因为文件变化更少。
- 误区二:把 .dockerignore 写在子目录里。Docker 只读取构建上下文根目录下的
.dockerignore。如果你放在subdir/.dockerignore,它根本不会被使用。 - 误区三:用
!dir/排除dir/*但想保留dir/sub。Docker 的负模式只作用于被排除的路径,不能恢复父目录不存在的路径。父目录必须先被包含。
我之前在做一个 Java 项目时,误将 *.class 排除,导致构建时找不到编译好的 class 文件。后来才发现 *.class 是构建产物,但 Dockerfile 里需要 COPY target/*.class。修正:改为 target/*.class 只排除 target 下的 class,或者保留 target/ 但排除 target/surefire-reports/。
4.8 知识结构图
下面这张图帮你理清上下文、.dockerignore 和镜像构建之间的关系。
4.9 实际案例:前后端项目
假设你有一个 monorepo 项目,包含前端(React)、后端(Go)、数据库迁移脚本。Dockerfile 在 build/ 目录下。你的 .dockerignore 应该这样写:
# 排除所有版本控制
.git/
.gitignore
# 排除 IDE 配置
.idea/
.vscode/
# 排除前端依赖和构建产物
frontend/node_modules/
frontend/build/
# 排除后端依赖和构建产物
backend/vendor/
backend/bin/
# 排除日志
*.log
# 排除环境变量(可能有敏感信息)
.env*
# 排除临时文件
*.swp
*.swo
*.tmp
# 保留 Dockerfile 和构建脚本
!build/
!build/Dockerfile
!build/scripts/
然后构建命令:
docker build -f build/Dockerfile -t my-app:latest .
这样上下文只包含未被排除的目录,大大减小了发送体积。我个人建议在 CI 中把 .dockerignore 也纳入代码审查,因为它直接影响构建效率和安全性。
小技巧:用 docker build --no-cache --progress=plain . 2>&1 | grep "context" 可以快速看到发送的上下文大小。如果超过 1GB,你得赶紧检查 .dockerignore 了。
4.10 总结
构建上下文不是越小越好,而是越精确越好。你只想把真正需要的文件发给守护进程。.dockerignore 就是你的剪刀,剪掉所有不必要的包袱。一开始写 Dockerfile 时,先写 .dockerignore,再写构建指令,养成习惯。
嗯,篇幅有限,但核心就一句话:上下文体积 = 构建速度,.dockerignore 让你掌控这个体积。记住这句话,你就能少踩我当年踩过的坑。