从 Dockerfile 到容器:部署一个 SpringBoot 应用
从 Dockerfile 到容器:部署一个 SpringBoot 应用
本文直接从一个 SpringBoot 项目的 Dockerfile 开始,在实际构建过程中理解每条指令的作用。最终我们会完成一个完整流程:编写 Dockerfile、构建镜像,并启动一个运行中的 SpringBoot 容器。
目录
- SpringBoot 镜像的最小构成
- Dockerfile 指令解析
- 从镜像构建到容器启动
- 为什么 SpringBoot 镜像这么大
- 多阶段构建:分离编译环境与运行环境
- ENTRYPOINT 与 CMD:启动命令如何选择
- 构建缓存:为什么改代码还要重新下载依赖
- SpringBoot 容器化中的常见问题
- 完整的 SpringBoot Dockerfile 模板
- 总结
SpringBoot 镜像的最小构成
假设你已经用 Maven 把项目打成了 jar 包,文件在 target/app.jar。一个最小可运行的 Dockerfile 其实只需要几行配置:
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
先把它构建出来看效果,再回头逐行解释。
docker build -t my-app:1.0 .
docker run -d -p 8080:8080 --name my-app my-app:1.0
容器启动后,docker ps 能看到运行状态,浏览器访问 http://localhost:8080 就能打开应用。
Dockerfile 指令解析
FROM
FROM eclipse-temurin:17-jre
FROM 决定了镜像构建的起点。Docker 镜像不是从空文件系统开始,而是在已有镜像基础上逐层构建。
这里用的是 Eclipse Temurin 提供的 JRE 17 镜像,只包含运行 Java 程序需要的 JVM 环境。JDK 镜像包含完整开发工具,例如 javac 和各种诊断工具,而运行 SpringBoot 应用实际上只需要 JVM 运行环境。用 JRE 作为基础镜像,体积比 JDK 小两百多 MB。
WORKDIR
WORKDIR /app
设置后续指令的工作目录。相当于在容器里先 mkdir /app && cd /app,后续的 COPY、RUN 等指令都在这个目录下执行。
COPY
COPY target/app.jar app.jar
把构建上下文中的 target/app.jar 复制到容器内的 /app/app.jar。
执行 docker build . 时,最后那个 . 就是构建上下文——当前目录。Docker 构建时只能访问构建上下文中的文件,因此 Dockerfile 无法直接复制宿主机任意目录中的文件。
EXPOSE
EXPOSE 8080
声明镜像预期监听的端口。EXPOSE 不会真的打开端口,它只是一个元数据声明。真正把端口映射出去的是 docker run -p 8080:8080。
ENTRYPOINT
ENTRYPOINT ["java", "-jar", "app.jar"]
定义容器启动时执行的命令。这里用的是 JSON 数组格式(exec 格式),不是 ENTRYPOINT java -jar app.jar(shell 格式)。shell 格式会额外启动一个 /bin/sh -c 进程,应用进程不会成为 PID 1,可能导致 Docker 的停止信号无法正确传递。
两种格式的区别会在后面的启动命令部分展开。
从镜像构建到容器启动
构建镜像:
docker build -t my-app:1.0 .
-t 给镜像打标签,格式是 名称:版本。构建过程会看到每一步的输出:
[+] Building 2.3s (8/8) FINISHED
=> [1/4] FROM eclipse-temurin:17-jre 0.0s
=> [2/4] WORKDIR /app 0.0s
=> [3/4] COPY target/app.jar app.jar 0.1s
=> [4/4] EXPOSE 8080 0.0s
=> exporting to image 0.2s
=> => naming to docker.io/library/my-app:1.0 0.0s
运行容器:
docker run -d -p 8080:8080 --name my-app my-app:1.0
-d 后台运行,-p 8080:8080 把容器的 8080 端口映射到宿主机的 8080 端口(宿主机端口:容器端口),--name my-app 给容器起个名字方便后续操作。
查看镜像大小:
docker images my-app
REPOSITORY TAG SIZE
my-app 1.0 272MB
272MB。jar 包本身可能只有几十 MB,镜像体积的大头来自基础镜像中的 JRE。这个大小已经比 JDK 版本小了不少,但还有优化空间。
为什么 SpringBoot 镜像这么大
问题出在基础镜像的选择上。如果用 JDK 而不是 JRE 作为基础镜像,体积会到 470MB 左右——编译器、调试工具等运行时完全用不上的东西也被打包进去了。
更麻烦的是,如果直接在 Dockerfile 里用 Maven 编译:
FROM eclipse-temurin:17-jdk
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/app.jar"]
Maven 缓存、源码、编译中间产物全都被打包进最终镜像,轻松突破 800MB。
多阶段构建:分离编译环境与运行环境
多阶段构建把"构建环境"和"运行环境"拆开。前一个阶段负责编译,后一个阶段只保留应用运行所需的文件。
# 阶段一:编译
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# 阶段二:运行
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
AS builder 给第一个阶段命名,COPY --from=builder 从编译阶段复制文件。最终镜像只包含第二个阶段的内容:JRE + jar 包。Maven、JDK、源码都不会出现在最终镜像中。
根据基础镜像和项目依赖不同,最终镜像体积通常可以明显下降。
镜像变小后,带来的直接收益是上传、下载和部署时间下降,同时减少了最终镜像中无关软件的数量。
ENTRYPOINT 与 CMD:启动命令如何选择
两者都可以定义容器启动行为,但最大的区别是:运行时参数如何覆盖。
CMD 是默认命令,可以被 docker run 后面的参数替换:
CMD ["java", "-jar", "app.jar"]
docker run my-app # 执行 java -jar app.jar
docker run my-app python app.py # 执行 python app.py,CMD 被覆盖
ENTRYPOINT 默认作为容器主进程执行,而 docker run 后面的参数会追加到 ENTRYPOINT 后面:
ENTRYPOINT ["java", "-jar", "app.jar"]
docker run my-app # 执行 java -jar app.jar
docker run my-app --spring.profiles.active=prod
# 执行 java -jar app.jar --spring.profiles.active=prod
实际项目中常见的写法是两者配合:
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--spring.profiles.active=default"]
不传参数时执行 java -jar app.jar --spring.profiles.active=default,传了参数就替换 CMD 部分。对于 SpringBoot 应用,推荐用 ENTRYPOINT 固定启动命令,CMD 放默认参数。
构建缓存:为什么改代码还要重新下载依赖
构建镜像时,Docker 会缓存每一层的结果。如果某一层的输入没有变化,Docker 会直接使用缓存,跳过执行。
但缓存有一个关键规则:一旦某一层失效,它之后的所有层都会重新执行。
看这个 Dockerfile:
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY . .
RUN mvn dependency:resolve
ENTRYPOINT ["java", "-jar", "target/app.jar"]
COPY . . 会把整个项目目录复制进去。你改了一行业务代码,COPY 这一层就失效了,后面的 RUN mvn dependency:resolve 也要重新执行——即使 pom.xml 根本没变。
优化方式是把不常变的东西先 COPY,常变的后 COPY:
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
先复制 pom.xml 并下载依赖。只要 pom.xml 没变,这一层就会命中缓存。之后再复制 src 目录。改业务代码时,只有 COPY src 和 mvn package 会重新执行,依赖下载被跳过。
在依赖没有变化的情况下,这种调整可以避免重复下载依赖,明显减少构建时间。
SpringBoot 容器化中的常见问题
容器化部署 SpringBoot 应用时,有几个问题经常在第一次部署时遇到。
配置文件中的 localhost
application.yml 里的数据库地址如果写的是 localhost:3306,在容器内会指向容器自己,而不是宿主机或其他容器。用 Compose 编排多服务时,应该用服务名替代 localhost。这个问题下一篇会展开。
JVM 参数需要通过环境变量传递
容器内的 JVM 参数不能写死在 Dockerfile 里。推荐的做法是通过环境变量传入:
ENTRYPOINT ["java", "-jar", "app.jar"]
docker run -e JAVA_OPTS="-Xmx512m" my-app
更常见的写法是在 ENTRYPOINT 中引用环境变量:
ENV JAVA_OPTS=""
ENTRYPOINT sh -c "java $JAVA_OPTS -jar app.jar"
容器时间和宿主机不一致
部分容器内默认使用 UTC 时区,和宿主机不同。日志时间会对不上,定时任务也可能出问题。挂载宿主机时区文件可以解决:
docker run -v /etc/localtime:/etc/localtime:ro my-app
jar 包名称变化导致 COPY 失败
Maven 打包默认生成的 jar 文件名带版本号,比如 my-app-1.0.0.jar。每次版本升级,Dockerfile 里的 COPY 路径也要跟着改。用通配符可以避免这个问题:
COPY target/*.jar app.jar
完整的 SpringBoot Dockerfile 模板
把上面的内容综合起来,一个生产可用的 SpringBoot Dockerfile:
# 编译阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
# 先复制依赖描述文件,利用缓存
COPY pom.xml .
RUN mvn dependency:go-offline -q
# 复制源码并打包
COPY src ./src
RUN mvn package -DskipTests -q
# 运行阶段
FROM eclipse-temurin:17-jre
WORKDIR /app
# 只复制编译产物
COPY --from=builder /app/target/*.jar app.jar
# 声明端口
EXPOSE 8080
# 启动命令
ENTRYPOINT ["java", "-jar", "app.jar"]
使用时只需要把 pom.xml 和 src 目录替换成你自己的项目结构。如果是 Gradle 项目,把 maven:3.9-eclipse-temurin-17 换成 gradle:jdk17,把 mvn 换成 gradle。
构建和运行:
docker build -t my-app:1.0 .
docker run -d -p 8080:8080 --name my-app my-app:1.0
总结
至此,一个 SpringBoot 应用从源码到 Docker 容器的基本流程已经完整走通。
Dockerfile 描述镜像构建过程,多阶段构建分离编译和运行环境,ENTRYPOINT 控制启动程序,COPY 顺序影响缓存命中。掌握了这几点,大部分 SpringBoot 项目的容器化需求就都能覆盖了。
更多推荐
所有评论(0)