从 Dockerfile 到容器:部署一个 SpringBoot 应用

本文直接从一个 SpringBoot 项目的 Dockerfile 开始,在实际构建过程中理解每条指令的作用。最终我们会完成一个完整流程:编写 Dockerfile、构建镜像,并启动一个运行中的 SpringBoot 容器。

目录

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,后续的 COPYRUN 等指令都在这个目录下执行。

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 srcmvn 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.xmlsrc 目录替换成你自己的项目结构。如果是 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 项目的容器化需求就都能覆盖了。

更多推荐