写这篇东西是因为我见过太多人“会用 Docker”但没用好 Docker。随手写一个 Dockerfile 能跑起来,和写一个既小、又快、又安全的 Dockerfile,中间差的远不止几条命令的距离。这期就聊聊我实际项目里几乎每套 CI/CD 都在用的组合拳:.dockerignore配合多阶段构建,这两样东西是控制镜像体积、加快构建速度、降低部署安全隐患的最直接手段。适合刚入门 Docker、准备把镜像体积压一压、或者正在被“构建上下文传输慢”和“镜像几百兆”折磨的朋友,看完可以直接把你的项目提一版。
1. 内容整体设计与思路拆解
1.1 镜像为什么越搞越大:先搞清楚体积从哪来
很多人第一次用 Docker 部署项目,第一反应是“我的镜像怎么这么大”。实际上镜像体积的膨胀来源非常集中。
最典型的一个就是构建上下文。你在项目根目录执行docker build -t xxx .的时候,Docker 会把当前目录下所有文件都发送到 Docker 守护进程,这些文件构成了构建上下文。如果你在项目目录里塞了.git、node_modules、target、dist、venv、__pycache__、*.tar.gz、日志文件等等,Docker 会把这些全部打包送过去。哪怕 Dockerfile 里只用到了COPY target/app.jar /app.jar,但传输数据量可能是几百兆甚至上G。这就是很多人感觉docker build慢得离谱的直接原因。
另一个膨胀来源是构建过程中的遗留物。典型操作用的是apt-get install或者yum install装编译工具、装依赖,装完不清理缓存;或者把源代码 COPY 进镜像后,运行环境根本不需要源码,却原封不动留着;再比如编译完的中间产物、包管理器缓存、临时下载文件,全堆在镜像层里。镜像每多一层,最终体积就多一份。
最后一个来源容易忽略:运行环境中混入了编译工具链。比如一个 Java 项目,你为了编译在镜像里装了 JDK、Maven、Gulp 等一大堆构建工具,但运行阶段只需要一个 JRE 就够。传统写法往往把编译环境直接拿来当运行环境,等于把一整套施工队都搬进了一家只需要开业的店面。
1.2 这两个方案的定位:一个是节流,一个是分治
.dockerignore和多阶段构建解决的其实是两个不同维度的问题。
.dockerignore解决的是输入端问题,它控制的是“什么内容允许进入构建上下文”。它的原理很简单,就是定义一组排除规则,类似.gitignore,但它的生效时机是docker build读取上下文时。没有它,上下文体积大、传输慢、容易把敏感文件带进镜像;有它,构建环境干干净净。
多阶段构建解决的是流程端问题,它把一个 Dockerfile 拆成多个 FROM 阶段,每个阶段用不同的基础镜像、执行不同的职责,最后只把需要的内容 COPY 到最终镜像。它的核心思想是“编译环境与运行环境分离”,既保持了本地构建的便捷性,又拿到了精简镜像的结果。
两个工具结合使用,才能让镜像构建全链路可控。单用.dockerignore,镜像里可能还是塞满工具链;单用多阶段构建但不写.dockerignore,后端构建过程一堆垃圾文件还是会传过去,构建速度和敏感信息问题依然存在。这两者不是二选一的关系,而是前端过滤、后端瘦身的整体设计。
1.3 设计这套方案时我考虑的核心指标
我自己在设计这套方案时,关心的指标只有三个:构建速度、镜像体积、可维护性。
构建速度看的是上下文传输时间和层缓存命中率。加.dockerignore能让上下文体积从几十MB甚至数百MB降到几百KB,这是立竿见影的速度提升;多阶段构建则可以把不常变化的依赖安装放在前面,把经常变化的源码 COPY 放在后面,利用 Docker 的 layer cache 机制让日常构建只在最后一两层重建。
镜像体积看的是最终镜像瘦身程度。体积小了,推送到仓库、拉取到服务器、启动容器的时间都能优化。有些项目优化前后从 1GB 降到 200MB 以内,并不夸张。
可维护性看的是 Dockerfile 结构是否清晰。多阶段构建把编译、测试、打包、运行的环境阶段分开,每个阶段职责单一,出问题只需看对应的那一段,排查成本显著降低。
2. Dockerignore 的细节与避坑
2.1 基础语法与匹配规则
.dockerignore文件必须放在构建上下文的根目录下,也就是你执行docker build时那个.的位置。它支持以下几种匹配规则。
*:匹配任意字符,但不跨越目录分隔符。*.log匹配根目录下的.log文件,但不会匹配logs/app.log。**:可以跨越目录层级。logs/**匹配logs下任意层级的文件;**/node_modules匹配任何层级下的node_modules目录。?:匹配单个字符,比如file?.txt。!:取反,用于“先排除此目录下大部分内容,再保留特定文件”的场景。比如*.md然后再!README.md。- 以
/开头表示锚定根目录;以/结尾表示匹配目录。 - 以
#开头是注释,空行忽略。
注意一个 Docker 官方文档里写了但是很多人不知道的细节:.dockerignore的匹配规则基于 Go 的filepath.Match实现,跟.gitignore在细节上有差异。比如.gitignore里node_modules会自动匹配所有层级的node_modules目录,但.dockerignore里如果你写了node_modules,它匹配的是构建上下文根目录下的node_modules,以及它之下的所有内容。
提示:如果你想让任意层级的
node_modules目录都被忽略,用**/node_modules,这是我在项目里确认过的写法。
2.2 一个能直接抄的通用模板
下面这个模板是我在不同项目里反复用的,覆盖了绝大多数场景。
# 版本控制 .git .gitignore .svn .hg # 构建缓存与依赖 node_modules target build dist venv __pycache__ *.class *.jar *.war .idea .vscode # 环境变量与密钥(重要) .env .env.* *.pem *.key secrets/ # 日志与临时文件 *.log npm-debug.log* yarn-debug.log* yarn-error.log* Dockerfile* .dockerignore *.md !README.md # 压缩包与备份 *.tar *.tar.gz *.zip *.sql *.bak # 测试与文档 test tests docs *.html写这个模板的时候有几个取舍值得说一下。Dockerfile*本身也忽略了,因为 Dockerfile 文件内容在构建时会被单独读取,不需要作为上下文的一部分传输。.dockerignore本身也一样。*.md通常也要忽略,但对README.md做例外保留,因为有些构建流程会在镜像里打上项目说明信息。
2.3 忽略规则写反了会出什么问题
.dockerignore看起来简单,但写错会带来非常隐蔽的后果。
最常见的问题是把重要文件误忽略了。有一次我在一个多模块 Maven 项目里写了**/target,本来是要忽略编译输出,结果有一个模块的 Dockerfile 里有COPY target/lib/*.jar /app/lib/,构建直接报错找不到文件。原因就是target目录被COPY源路径引用了,而它在上下文里已经被过滤掉。这就暴露出一个关键思路:.dockerignore里的规则,要能“想象构建过程”,被忽略的是构建上下文里不可见的内容,COPY指令能访问到的范围只限于上下文。你在 Dockerfile 里写了COPY target/...,这条路径就必须在扫描结果里可见,否则就会失败。
另一个反例是取反规则失效。比如你写了**/*.jar,然后又写了!target/app.jar,你以为能保留后者,实际上前面的**/*.jar把整个jar文件树都过滤了,后面的!target/app.jar能把它加回来,但如果它的父目录target也已经被忽略规则删除了,这个取反就不生效。Docker 的实现是从上到下逐条处理,取反规则必须确保父目录本身没有被忽略掉,否则文件还是进不来。
还有一个很多人踩过的坑:忽略规则导致上下文里缺目录,容器内挂载行为异常。如果一个目录整个被忽略,运行时又想用-v挂载宿主机的这个目录进去,构建阶段没问题,但运行阶段会发现目录是空的,因为镜像里根本没有这个路径,挂载只会创建空目录,不会带上原来构建阶段打算预置的内容。
2.4 在 Windows 上需要注意的换行问题
在 Windows 环境下用 VS Code 或记事本编辑.dockerignore时,很容易默认保存成 CRLF 换行格式。这个在大多数情况下没问题,但如果你用 Docker Desktop 的 Linux 容器模式,某些旧版本对 CRLF 的处理不够健壮,会出现匹配失效或者构建报错。我建议所有在 Windows 上开发的项目,.dockerignore和.gitignore统一使用 LF 换行。可以设置 VS Code 的files.eol为\n,或者在项目根目录加一个.editorconfig:
root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true3. 多阶段构建:把“编译用的”和“跑起来的”分开
3.1 传统单阶段 Dockerfile 的核心痛处
我拿 Java 项目举个例子。早期写 Dockerfile 为了省事,直接在同一个镜像里完成“装 JDK + 装 Maven + 编译 + 打包 + 运行”的全过程:
FROM maven:3.8.4-openjdk-11 WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/app.jar"]这个写法有几个问题。
第一,基础镜像maven:3.8.4-openjdk-11本身就包含完整 JDK 和 Maven 运行环境,体积通常在 500MB 以上。第二,构建过程中mvn dependency:go-offline下载的依赖、Maven 仓库缓存的 JAR、编译产生的target目录,全部留在镜像里。第三,运行java -jar只需要 JRE,但镜像带着全套编译工具链在跑,攻击面无形中扩大,镜像里的确存在很多用不到的可执行文件,比如javac、jar等,这些在容器安全扫描时会被标记出来。
这些问题的根本原因是:构建镜像和运行镜像承担了完全不同的职责,却被挤在一个镜像里。
3.2 多阶段构建的核心机制
多阶段构建在 Docker 17.05 及之后版本可用。它的精髓在于一个 Dockerfile 里可以有多个FROM,每个FROM都是一个独立的构建阶段,阶段之间相互隔离。前一个阶段的文件,只有被COPY --from=阶段名显式复制到后一个阶段时才会被保留;没有被复制的任何东西,最终都不会进入镜像。
这样最终镜像只包含最后一个FROM阶段的内容,以及被显式 COPY 过来的产物。
# 第一阶段:编译 FROM maven:3.8.4-openjdk-11 AS builder WORKDIR /workspace COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行 FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /workspace/target/app.jar . EXPOSE 8080 CMD ["java", "-jar", "app.jar"]注意这里出现了AS builder,这是给阶段命名,后面COPY --from=builder就通过这个名字引用。如果阶段没有命名,可以用索引引用,比如COPY --from=0 /workspace/target/app.jar .,但命名的方式在涉及阶段增减时更不容易出错。
最终镜像里只有openjdk:11-jre-slim这个精简版 JRE 运行时,以及一个app.jar。Maven 本身的体积、源码、编译中间产物全部被丢弃。我实际测得同一个 Spring Boot 项目,优化前镜像体积 700MB 左右,优化后 280MB 左右,而这个差距在很多大型项目里会被拉得更大。
3.3 基于常见语言的阶段划分打法
不同语言生态使用多阶段构建的姿势不太一样,我分别说一下我踩过且验证有效的做法。
Java / Maven 项目
第一阶段用带 JDK 和 Maven 的镜像做构建,第二阶段用 JRE 精简镜像做运行。JRE 镜像选择很讲究,openjdk:11-jre-slim是基于 Debian 的精简版;如果想再小,可以选带-distroless的镜像,体积甚至可以压到 100MB 左右,但那样镜像里没有 shell,调试会变得麻烦,需要取舍。
Node.js 项目
第一阶段用node:18-alpine执行npm ci或者npm install,顺便做一些构建,比如npm run build生成dist目录。第二阶段用node:18-alpine只拷贝dist、package.json,然后把node_modules里生产环境依赖单独装一遍。
这里有一个很多新手踩的坑:如果直接在第二阶段COPY package*.json ./然后RUN npm ci --production,那第二阶段还需要完整的 npm 环境。所以常见做法是两个阶段都用同一个 Node 基础镜像,第一阶段负责全部编译,第二阶段按需复制产物和依赖元数据。注意 npm ci 和 npm install 的区别:npm ci 严格按 lock 文件安装,适合 CI;npm install 可能更新 package-lock,在构建镜像里做依赖缓存时如果锁文件变化,容易触发缓存失效,这属于细节。
Go 项目
Go 对多阶段构建特别友好,因为它能编译出静态二进制文件。第一阶段用golang:1.21-alpine执行CGO_ENABLED=0 GOOS=linux go build,第二阶段用alpine或者更极端的scratch,直接把编译好的二进制 COPY 过去就能跑。Go 项目的最终镜像压缩下来可以到 10MB 级别,这在单阶段写法的镜像里几乎不可能。
Python 项目
第一阶段用带 Python 和 pip 的镜像安装依赖、收集site-packages,第二阶段用精简版 Python 运行时把依赖和源码复制进去。要注意的是 Python 原生扩展(比如 psycopg2、numpy 这类带编译过程的包)在构建阶段会下载工具链并编译,第二阶段只需要把编译好的 .so 文件复制过去,并不需要完整的 GCC 工具链。
各语言情况我整理成了一个小表,方便对照参考:
| 语言 | 构建阶段基础镜像 | 运行阶段基础镜像 | 需要 COPY 的核心文件 |
|---|---|---|---|
| Java | maven + jdk | jre-slim / distroless | target/*.jar |
| Node | node:alpine | node:alpine | dist + node_modules(production) |
| Go | golang:alpine | alpine / scratch | 编译后的二进制 |
| Python | python:alpine + gcc | python:alpine | 源码 + site-packages |
3.4 基础镜像版本怎么选
第二阶段基础镜像的选择会直接影响镜像体积和构建成功率。这里我分享几个实操结论。
优先选择 alpine 变体:alpine 基于 musl libc,体积远小于 Debian 的 slim 系列。但要注意,如果你在第一阶段编译时链接了 glibc 的动态库,比如 CGO 启用的 Go 程序,运行阶段用 alpine 会直接报 “no such file or directory”,那是因为 musl 和 glibc 的动态链接器路径不同。这种问题排查起来非常迷惑,因为文件明明在,但执行时报“找不到”。这个我后面在问题排查章节里详细展开。
慎选 scratch:scratch 是空镜像,只有内核提供的运行环境。它适合纯静态编译的 Go 程序,但如果你需要容器内执行 shell、查看进程、下载调试工具,scratch 里什么命令都没有,只能通过docker exec进入容器,而进入后没有 shell 可用。我的建议是,除非你对产物非常自信,否则用 alpine 加一层安全兜底,调试时痛苦少一半。
Java 运行阶段不要选 JDK:我遇到有人为了省事,把openjdk:11-jre-slim换成openjdk:11-jdk-alpine,理由是“万一要在容器里调试”。结果镜像体积从 160MB 涨到 300MB 以上,而实际上调试本地代码根本不需要在容器里跑 javac。如果真的要调试,那就用 docker-compose 额外起一个带 JDK 的开发调试容器,生产镜像保持精简。
版本 tag 尽量具体:不要用node:latest、maven:latest这种写法。过一次 CI,基础镜像更新了,构建出来的镜像第三方依赖版本可能完全不同,问题会变得不可复现。我通常把镜像版本精确到小版本,并用 Dependabot 或 Renovate 这类工具做自动升级,升级本身是受控的,而不是碰运气式的。
4. 实操:一个 Java Spring Boot 项目从 700MB 压到 280MB
4.1 准备一个标准项目结构
我以一个典型的 Spring Boot 项目为例,项目结构大概是这样的:
demo-app/ ├── .dockerignore ├── Dockerfile ├── pom.xml └── src/ ├── main/ │ ├── java/ │ └── resources/ └── test/这里的.dockerignore我没有放在子目录里,而是和 Dockerfile 平级放在项目根目录,因为构建上下文就是这个根目录。
4.2 编写项目的 .dockerignore
针对这个项目,我写了这样一份配置文件:
# 版本控制 .git .gitignore # IDE 配置 .idea .vscode # Maven 构建产物 target *.class # 日志 *.log # 测试和文档 src/test/ docs/ *.md有人会问:src/test/要不要忽略?忽略它会加快构建上下文传输,而且运行阶段完全不需要测试代码。但注意,如果你在 CI 里用 Dockerfile 构建时希望能运行测试,那src/test/就不能忽略。我这里选择的场景是编译时mvn package -DskipTests,所以测试目录可以安全忽略。
4.3 编写多阶段 Dockerfile
下面是完整的 Dockerfile:
# 阶段一:构建 FROM maven:3.8.6-eclipse-temurin-11 AS builder WORKDIR /build # 先拷贝 pom.xml,利用 Docker 层缓存 COPY pom.xml . RUN mvn dependency:go-offline # 再拷贝源码并打包 COPY src ./src RUN mvn clean package -DskipTests # 阶段二:运行时 FROM eclipse-temurin:11-jre-jammy WORKDIR /app # 创建非 root 用户,降低容器运行权限 RUN groupadd -r app && useradd -r -g app -d /app appuser # 从构建阶段只拷贝最终 jar COPY --from=builder /build/target/demo-app-1.0.0.jar app.jar # 显式指定时区,避免日志时间和本地时间错乱 ENV TZ=Asia/Shanghai USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]写这个 Dockerfile 的时候,我考虑了这几个点。
RUN mvn dependency:go-offline放在COPY src之前,是为了让“下载 Maven 依赖”这一步尽量享受 Docker 层缓存。只要pom.xml不变,依赖下载就不会重新执行;每次代码改动只需要重新执行打包那一层,构建速度会快很多。如果你把pom.xml和src一起 COPY,那每次源码变更依赖下载都会重新跑一遍,非常耗时。
第二阶段的镜像我选了eclipse-temurin:11-jre-jammy,没有选openjdk:11-jre-slim,原因是 OpenJDK 官方 Docker 镜像从 JDK 11 之后维护策略调整,更新不如 Eclipse Temurin 稳定。运行时镜像选了 jammy(Ubuntu 22.04 LTS 的代号),是因为它有groupadd/useradd工具,方便创建非 root 用户。
非 root 用户这件事很重要。容器默认用 root 运行,一旦应用有漏洞,攻击者直接拿到容器里的 root 权限。我在 Dockerfile 里创建了appuser用户,并通过USER appuser切换,这属于一种低成本的安全加固手段。要注意的是,如果应用需要写挂载卷,挂载目录的所有权必须给到appuser,否则会报权限不足。这个我在问题排查一节里详细说。
4.4 构建并验证结果
在项目根目录执行:
docker build -t demo-app:0.1.0 .构建完成后,查看镜像信息:
docker images demo-app你会看到 REPOSITORY 是 demo-app,TAG 是 0.1.0,SIZE 取决于基础镜像和 jar 大小,通常会在 280MB 到 300MB 之间。
对比一下单阶段构建的版本,体积差异非常直观。我这边同一个 Spring Boot 项目,2.7 的版本,单阶段构建 700MB 左右,多阶段优化后 280MB 左右,压掉差不多 60%。
用docker history可以直观看到镜像层的变化:
docker history demo-app:0.1.0多阶段构建的镜像 history 最上层只是一个 COPY 的 jar 文件,底层是 JRE 镜像自带的基础层,不会有 Maven 下载的几百 MB 依赖层。
运行容器做个简单验证:
docker run -d --name demo-app -p 8080:8080 demo-app:0.1.0 curl http://localhost:8080/actuator/health如果应用本身暴露了健康检查接口,这一步就能确认镜像内部状态是正常的。我一般在验证阶段会顺手执行一个docker exec -it demo-app sh进去查看进程,确认运行用户不是 root。
4.5 优化前后差异复盘
我这里把优化前后的关键指标列了个表,方便直观理解:
| 指标 | 单阶段 | 多阶段 + .dockerignore |
|---|---|---|
| 构建上下文传输量 | 包含 src、target、.git 等,较大 | 只有 pom.xml 和 src,小一个数量级 |
| 镜像体积 | 700MB+ | 280MB 左右 |
| 运行进程用户 | root | appuser |
| Maven 依赖缓存 | 留在镜像内 | 构建阶段丢弃 |
| 构建层缓存复用 | 代码变更常导致全量重建 | pom.xml 不变时依赖层可复用 |
5. 常见问题与排查技巧实录
5.1 问题速查表
我把过去一年多帮同事排查 Docker 构建问题过程中比较高频的几类汇总成一个速查表。遇到问题先对号入座:
| 现象 | 可能的原因 | 解决办法 |
|---|---|---|
| Permission denied while trying to connect to the Docker daemon socket | 用户不在 docker 组,或 Docker Desktop 未启动 | macOS/Windows 检查 Docker Desktop 运行状态;Linux 执行sudo usermod -aG docker $USER后重新登录 |
| COPY 报文件找不到 | .dockerignore 把源路径过滤掉了 | 检查基础规则匹配范围,用docker build --no-cache清缓存冲突后确认上下文内容 |
| 容器启动后应用看不到时区 | 基础镜像里没装 tzdata | 在 Dockerfile 里安装 tzdata 或用ENV TZ配合挂载/etc/localtime |
| 挂载卷目录权限不足 | 运行用户不是 root,目录属主不匹配 | 在 Dockerfile 里提前RUN mkdir -p /data && chown -R appuser:appuser /data,或在 docker-compose 里设置 user |
| 构建上下文太大,build 很慢 | .dockerignore 没生效或规则太少 | 检查文件是否在根目录;直接用docker build -t test .加--progress=plain查看上下文发送大小 |
| 多阶段构建 COPY --from 报 target 目录不存在 | 构建阶段里目录没生成或命名错误 | 检查阶段名是否一致,路径是否正确区分绝对和相对路径 |
| alpine 镜像运行 go 程序报 no such file or directory | 二进制动态链接到 glibc | 用CGO_ENABLED=0编译静态二进制,或改用 glibc 变体镜像 |
5.2 上下文过大问题怎么定位
如果你觉得构建慢,但说不清慢在哪,最简单的方法是加--progress=plain参数重新构建:
docker build --progress=plain -t demo-app:0.1.0 .构建日志里有一行叫context transferred,比如显示context transferred 842.1MB,这就是上下文传输的真实大小。如果这个数字远超预期,基本上就是.dockerignore没生效或者规则覆盖不全。另一个方法是把.dockerignore临时改成一个最小测试文件,只保留*(忽略一切),然后执行构建看上下文变成多大。如果数字骤降,说明原规则漏了东西。
5.3 .dockerignore 里写错规则导致 COPY 失败
我印象最深的一次事故是两天前还发生过,同事问我说“明明文件存在,但 COPY 一直报找不到文件”。排查到最后,原因是项目目录下的.dockerignore里写了一行**/logs/**,而 Dockerfile 里有COPY src/main/resources/logback-spring.xml /app/,这个文件恰好落在src/main/resources/logs/下。规则把整个 logs 目录过滤掉了,而 Docker 构建上下文里根本没有这个源文件,COPY 自然失败。
这种问题的排查靠肉眼看不出来,怀疑到了就先构建一个忽略所有规则的小镜像,或者直接检查上下文内容:
docker build --progress=plain -t debug-context .然后进到一个临时容器里看上下文目录:
docker run --rm -it debug-context sh ls -la /docker build的上下文实际上不会直接暴露成一个可见映射关系,但如果你用--progress=plain把step输出打开,可以看到每个COPY执行的源文件清单,这样就能确认文件是否真的被过滤。
5.4 多阶段构建的缓存失效问题
多阶段构建中,层缓存失效有一个非常常见的场景:你在第一阶段RUN mvn dependency:go-offline,但之后COPY pom.xml的变动会使得后面所有阶段全部重跑。理论上这是正确的,但如果你频繁修改 pom.xml,那依赖下载就会被反复执行,每次构建时间都很长。
有没有办法让这部分缓存尽量持久?有一个思路是把依赖层分成两层:第一层放基本依赖,第二层放频繁变动的插件。比如:
COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests这个已经把依赖和源码拆开了,pom.xml 不变时依赖层不会重建,pom.xml 变了依赖层才会重建。如果你希望更细粒度地控制缓存,还可以把 pom.xml 中不常变的插件版本单独提取,但过度优化其实没必要,实用层面把 pom.xml 和 src 拆开就已经收益很大了。
5.5 镜像构建好了,但镜像仓库推送慢怎么办
很多人在推送镜像到仓库时也会遇到“下载镜像慢、推送慢”的问题,这时候分两种情况处理。一种是网络层面的问题,适当配置镜像加速源能显著改善拉取速度,这是我在多个项目中验证过的有效做法。另一种是体积问题,推送到仓库的是压缩后的镜像层,体积越大推送越难。这时候.dockerignore和多阶段构建能让推送进度明显变快,因为镜像本身变小了。
我测过一个 Node 项目,原始镜像 1.1GB,优化后 380MB,推送到仓库的时间从几分钟缩短到几十秒,效果非常直观。
5.6 构建 DOCKERFILE 时的“伪缓存”问题
还有一个我踩过很多次的坑,就是 Dockerfile 里的指令顺序导致缓存失效。有一种像是缓存住了,但其实每次构建都重新执行的情况。
比如这个错误顺序:
COPY . . RUN npm install只要你改任何源码,COPY . .的校验就会变,导致RUN npm install重新跑,很多依赖不带锁文件或者不缓存的话,每次构建都是地狱。
正确顺序:
COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build依赖安装层在整个源码目录改变之前完成,源码变更时npm ci层可以复用缓存。这个顺序观看起来很简单,但很多人在写多阶段构建时依然会犯,因为他们只拆了阶段,没有拆 COPY 的粒度。
5.7 时区和 locale 等“环境细节”问题
Java 应用在容器里跑,最常见的两个环境细节问题是时区和字符集。
时区问题表现是:容器里打印日志的时间和宿主机时间差 8 小时。原因是最小化的基础镜像通常没有配置时区。解决方式是在运行阶段指定:
ENV TZ=Asia/Shanghai但这只是一个环境变量,有些 Java 版本仍然会忽略系统时区文件。更稳妥的方式是在镜像里安装 tzdata:
RUN apt-get update && apt-get install -y tzdata \ && rm -rf /var/lib/apt/lists/*这个坑我在 alpine 镜像里最常遇到。alpine 默认不装 tzdata,而且ENV TZ=xxx在 JVM 里不一定生效,反正我遇到过的场景是必须apk add tzdata才能让时区正确。
字符集问题也一样,openjdk:11-jre-slim默认不装中文 locale,如果你要让应用输出中文,可能需要显式安装locales并设置LANG=C.UTF-8。不过大部分服务器场景我都建议统一用 UTF-8,也不需要装一堆 locale,够用就行。
6. 写在最后的几个实操心得
这套组合技我用了将近三年,真正让我觉得值的不是某一个指标,而是整个维护体验变了。以前每次改完代码构建一次长考几十分钟,拉镜像部署也慢,镜像体积一大还老担心被人扒出什么不想暴露的文件。现在每次改动只在“代码打包”那一步重建,几秒到十几秒就能完成,镜像瘦下来以后,部署到服务器也好、离线分发也好,都轻松很多。
另外我有个特别想提醒的点:.dockerignore不是一劳永逸的文件,项目加了新目录、新依赖、新构建产物,要记得同步更新规则。我见过好多次,项目一开始规则写得好好的,后来加了大数据模块,多出来几个 GB 的数据文件,构建上下文又偷偷膨胀了,整体构建时间又回到解放前。建议大家把这个文件放进 code review 的常规检查项里,改 Dockerfile 的时候顺手看一眼,成本很低,收益很大。
最后再分享一个小技巧:如果你在一个大型团队里维护多个镜像,可以把.dockerignore和 Dockerfile 一起统一配置管理,并让 CI 在构建前专门跑一个 lint,检查有没有把包含敏感信息的文件放进去,比如.env、*.pem。用工具把这些事固化下来,比靠人自觉靠谱得多。镜像体积、构建速度、系统安全这几件事,本质上都不难,难的是每次都做对,所以把它们沉淀为规范,省下的时间足够你多写很多真正有价值的代码。