大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
编写 Dockerfile
这一课你将能做到
- 为一个 Spring Boot 项目手写最小可用 Dockerfile,构建出可运行的镜像并访问到接口
- 准确说出 FROM、WORKDIR、COPY、EXPOSE、ENV、ENTRYPOINT 每个指令的作用与坑
- 分清 CMD 与 ENTRYPOINT 的分工,并解释为什么必须用 exec 格式(优雅停机的前提)
为什么要学
Dockerfile 是「把你的应用变成镜像」的唯一配方,也是后面构建优化(第 6 课)、CI/CD、K8s 的一切起点。面试里「说说你的项目是怎么容器化的」,答案就是一份你亲手写的 Dockerfile 加上你对每个指令的理解。
本课以 Spring Boot 为主角:一个 mvn package 产出的 fat jar,配上十来行 Dockerfile,就能在任何装了 Docker 的机器上跑起来——不再依赖对方装没装 JDK 21。
先建立图像
Dockerfile 的每条指令 ≈ 在镜像里盖一层:
FROM eclipse-temurin:21-jre # 第 1 层:地基(OS + JRE),别人的镜像
WORKDIR /app # 第 2 层:设置工作目录(元数据)
COPY target/app.jar app.jar # 第 3 层:把你的代码放进去
EXPOSE 8080 # 元数据:声明容器监听 8080(文档作用)
ENTRYPOINT ["java", "-jar", "app.jar"] # 元数据:容器启动命令
构建与运行的关系:
Dockerfile + 构建上下文(. 目录) ──docker build──→ 镜像 ──docker run──→ 容器
跟着做
步骤 0:准备一个 Spring Boot 项目
没有现成项目就去 https://start.spring.io 生成一个 Web 项目(Maven、JDK 21、勾选 Spring Web)。先本地打包:
mvn clean package -DskipTests
# 产物:target/demo-0.0.1-SNAPSHOT.jar
步骤 1:写最小 Dockerfile
在项目根目录(与 pom.xml 同级)新建 Dockerfile:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/demo-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
步骤 2:加 .dockerignore
同目录新建 .dockerignore,避免把无关文件送进构建上下文:
.git
.idea
*.iml
target/classes
target/test-classes
logs
*.log
步骤 3:构建镜像
docker build -t demo-app:1.0 .
# -t 名字:标签 末尾的 . 是构建上下文(Dockerfile 里的相对路径以此为基准)
docker images # 确认 demo-app 1.0 已生成
步骤 4:运行并验证
docker run -d --name demo -p 8080:8080 demo-app:1.0
docker logs -f demo # 看到 Spring Boot 启动横幅
curl http://localhost:8080/actuator/health # 或你自己的接口
步骤 5:用环境变量切换配置(不重新打镜像)
Spring Boot 的宽松绑定支持环境变量覆盖配置:spring.profiles.active → SPRING_PROFILES_ACTIVE。不改一行代码:
docker rm -f demo
docker run -d --name demo -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SERVER_PORT=8080 \
demo-app:1.0
docker exec demo printenv | findstr SPRING # 确认注入成功
数据库连接同理可被覆盖:SPRING_DATASOURCE_URL、SPRING_DATA_REDIS_HOST(第 9、10 课大量使用)。
步骤 6:理解 CMD 与 ENTRYPOINT 的分工
# 方案 A:只有 CMD —— docker run 后面跟的参数会整体【替换】CMD
CMD ["java", "-jar", "app.jar"]
# 方案 B:ENTRYPOINT 固定 + CMD 做默认参数 —— run 时追加的参数会【接在后面】
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--spring.profiles.active=dev"]
方案 B 下,docker run demo-app:1.0 用默认 dev 配置启动;docker run demo-app:1.0 --spring.profiles.active=test 则变成 java -jar app.jar --spring.profiles.active=test。这就是「固定入口 + 可变参数」的正规写法。
步骤 7:给镜像加上时区
FROM eclipse-temurin:21-jre
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY target/demo-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
不加这个,日志时间戳默认是 UTC,排查问题时和北京时间对不上,非常折磨。
讲清楚要点
- FROM 选择:运行 Spring Boot 只需 JRE,不需要 JDK。
eclipse-temurin:21-jre是 T Katrina 认证 JDK 的官方镜像,比裸openjdk:21维护更活跃。想更小可用21-jre-alpine(注意 musl libc 兼容性)。 - exec 格式 vs shell 格式(重点):
ENTRYPOINT ["java","-jar","app.jar"]—— exec 格式,java 是 PID 1,能直接收到docker stop的 SIGTERM,优雅停机生效。ENTRYPOINT java -jar app.jar—— shell 格式,实际是/bin/sh -c "java -jar ...",java 是 sh 的子进程,收不到 SIGTERM,stop 等 10 秒后直接被强杀。
- COPY 与 ADD:COPY 语义单纯(复制文件),ADD 额外支持解压 tar 和远程 URL——行为隐式易踩坑。规范:一律用 COPY,需要解压时显式写 RUN tar。
- EXPOSE 是声明不是映射:它只把「本镜像监听 8080」写入元数据,供人和
docker run -P使用;真正让宿主机能访问的是-p 8080:8080。 - 每条产生文件系统变化的指令一层:RUN/COPY/ADD 生成新层;ENV/EXPOSE/WORKDIR/USER 等只改元数据不占空间。配置类的 ENV 写在 COPY 之前也不影响缓存(第 6 课细讲顺序)。
- 一个 Dockerfile 只有一条 CMD/ENTRYPOINT 生效:写多条只有最后一条起作用,前面的会被静默忽略。
你可能会懵的地方
-
COPY 报
no such file or directory,文件明明存在? 两个常见原因:① 忘了先mvn package,target 里没有 jar;② 路径相对「构建上下文」而非 Dockerfile 所在盘符——COPY target/xxx.jar的起点是docker build命令末尾那个.。 -
jar 名带版本号不好维护怎么办? 在
pom.xml里固定产物名:<build> <finalName>app</finalName> </build>Dockerfile 就可以稳定写
COPY target/app.jar app.jar;或者用通配COPY target/*.jar app.jar。 -
docker run 镜像名后面的参数去哪了? 覆盖 CMD(方案 A),或追加到 ENTRYPOINT 之后(方案 B)。想让镜像同时支持「默认行为 + 运行时改参数」,用方案 B。
-
为什么我的容器 stop 要等 10 秒才停? shell 格式吞了信号(见要点 2),java 进程只能等超时强杀。改 exec 格式,再配合 Spring Boot 的
server.shutdown: graceful。 -
需要把整个项目目录 COPY 进镜像吗? 不需要。Java 是编译型产物交付:宿主机(或 CI)先
mvn package,镜像里只要 jar。整个源码 COPY 进去属于 Node/Go 前端式做法的误用;想连编译一起做进镜像,用第 6 课的多阶段构建。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
FROM openjdk:latest | 版本漂移,换台机器行为不同 | FROM eclipse-temurin:21-jre 固定版本 |
shell 格式 ENTRYPOINT java -jar app.jar | 收不到 SIGTERM,无法优雅停机 | exec 格式 ENTRYPOINT ["java","-jar",...] |
EXPOSE 8080 后以为就能访问 | EXPOSE 不做端口映射 | docker run -p 8080:8080 |
把数据库密码写死 ENV DB_PASSWORD=123456 | docker history 可见,且进镜像分发 | 运行时 -e 注入或 compose/.env(第 11 课) |
COPY . /app 全量拷贝 | 源码、.git、日志全进镜像 | 只 COPY 构建产物 + .dockerignore |
| 多条 CMD 都想执行 | 只有最后一条生效 | 合并成一条,或用启动脚本 |
忘了 ENV TZ=Asia/Shanghai | 日志时间全是 UTC,排障对不上 | 基础镜像统一加 TZ |
卡点急救
docker build报COPY failed: file not found:先mvn clean package -DskipTests确认 jar 存在;再核对 Dockerfile 里路径与构建上下文是否匹配。- 容器起了马上 Exited:
docker logs 容器名。高频原因是 jar 路径错(Error: Unable to access jarfile)或端口被占。 - 镜像构建成功但访问 8080 不通:确认三处一致——应用监听端口(
server.port)、EXPOSE、-p映射;Spring Boot 默认只绑定 8080。 - Windows 下 ENTRYPOINT 执行 .sh 脚本报
\r: command not found:CRLF 换行符问题,VS Code 右下角把脚本切到 LF 再提交。 - 构建极慢、Sending build context 很大:
.dockerignore没生效(检查文件名拼写与位置必须在上下文根目录),把.git、node_modules、logs排除掉。
面试常问
-
CMD 和 ENTRYPOINT 的区别?(必考) ENTRYPOINT 定义容器的固定入口命令;CMD 提供默认参数。运行时
docker run 镜像 参数会替换 CMD;在 ENTRYPOINT 存在时则追加为参数。最佳实践:ENTRYPOINT 写死java -jar,CMD 放默认 profile 等参数。 -
COPY 和 ADD 的区别? COPY 只做纯复制;ADD 额外自动解压本地 tar、支持远程 URL。行为隐式、可读性差,规范上优先 COPY,需要解压时显式 RUN。
-
EXPOSE 的作用是什么? 纯元数据声明:告诉使用者和工具「容器应用监听该端口」。不做任何网络映射,宿主机访问必须
-p。 -
如何让 Spring Boot 容器优雅停机? 三件套:Dockerfile 用 exec 格式(保证 java 收到 SIGTERM);Spring Boot 配
server.shutdown: graceful;docker stop -t或 compose 里stop_grace_period给足等待时间。 -
镜像里为什么用 JRE 而不是 JDK? 运行时不需要编译器与构建工具,JRE 镜像小、攻击面小;编译放在本地或 CI 完成,或用多阶段构建(下一课)。
自测三问
- 写出把一个 fat jar 容器化的最小 Dockerfile(5 条指令),并说明每条是否产生新层。
ENTRYPOINT ["java","-jar","app.jar"]+CMD ["--foo=bar"]时,docker run 镜像 --bar=baz最终执行的完整命令是什么?- 为什么 shell 格式的 ENTRYPOINT 会导致
docker stop等满 10 秒?
小结一句话
Dockerfile 用「基础镜像 + COPY 产物 + exec 格式入口」三步把 Spring Boot 变成镜像;入口格式决定优雅停机成败,环境变量决定一份镜像跑多环境。
延伸
- Dockerfile 官方参考
- Spring Boot 容器化官方指南
- 下一课预告:构建与优化——利用层缓存把构建从 3 分钟压到 20 秒,用多阶段构建砍掉一半镜像体积。