把Maven编译完的项目直接打包成Docker镜像再启动,这条链路我已经在不少Java后端项目里来回跑过很多趟了。如果你刚接触容器化,或者已经能跑通但总被一些奇奇怪怪的问题卡住,这篇文章应该能帮你把这套流程一次性理顺。我会拿一个Spring Boot项目当例子,从Maven的pom.xml配置开始,到写Dockerfile、构建镜像、启动容器,每一步都交代清楚,包括我实际踩过的坑和调整思路。
1. 这套链路到底在解决什么问题
1.1 环境一致性比你想的更值钱
先说一个每个Java开发都经历过的场景:本地代码跑得好好的,换一台机器编译,要么JDK版本不对,要么Maven仓库缺依赖,要么系统环境变量没配,折腾半天还是起不来。就算编译过了,部署到服务器上,运行环境又可能不一样。这背后的问题本质是:从源码到可运行进程,中间隔着一大堆环境假设。
把Maven项目打包成Docker镜像并启动,核心就是把“怎么编译、怎么配置运行环境”这个问题一朝解决。镜像里包含了操作系统层、JDK、依赖库、配置文件和Jar包,进程启动方式也被固定下来。别人拿到这个镜像,不需要关心宿主机上装了什么,只需要一个能跑Docker容器的环境,就能把服务拉起来。这种一致性对于开发、测试、预发、生产多环境流转来说,省掉的沟通成本相当可观。
1.2 整条流水线的四个环节
从操作层面看,这条链路其实就四步:
- Maven把Java源码编译打包成可执行Jar包。
- 编写Dockerfile,描述镜像里要有什么、怎么启动进程。
- 执行docker build,把Jar包和相关环境做成一个私有镜像。
- 用docker run启动容器,把服务暴露到指定端口。
看起来很简单,但每一步都有很多容易被忽略的细节。比如Maven的skipTests到底该不该带,Dockerfile用基础镜像还是多阶段构建,装的时区对不对,JVM能不能正确识别容器给的内存上限。这些细节单独拿出来都不难,但凑在一起就很容易让人卡住。文章后面我会逐个展开。
2. 动手前的准备:工具版本与Dockerfile基础
2.1 工具链怎么配最省心
为了少踩版本坑,我先说下我自己常用的版本组合,这是基于兼容性和社区维护状态考虑后比较稳的方案:
- JDK:项目用的什么版本,就尽量匹配对应的基础镜像。我这边Spring Boot项目多数用的JDK 17,镜像直接用Temurin 17发行版。
- Maven:建议3.8以上。如果你的打包环境就是Docker容器本身(多阶段构建会用到),可以用Maven官方基础镜像,省得在宿主机上装一堆东西。
- Docker:20.10以上版本,尽量保持更新。更早版本对BuildKit和容器内存感知的支持不够好,会导致后面说的某些坑更明显。
这里有个小建议:如果你用的是macOS或者Windows,注意Docker Desktop的资源设置,给容器分配的内存不要太小,否则Maven编译阶段或者JVM启动时容易OOM。我之前在Windows环境跑过一次,Docker Desktop只分了2GB内存,Maven编译到一半直接被杀掉,日志里什么错误都没有,排查起来很莫名其妙。
2.2 Dockerfile里容易搞混的指令
Dockerfile是镜像的“配方”,里面有几个指令非常容易搞混,我先讲清楚,后面实操部分你会反复看到它们:
- FROM:指定基础镜像,也就是你站在谁的肩膀上。选好了,能省下大量配置工作。比如带JDK的镜像,就不用自己再去装Java。
- WORKDIR:设置容器内的工作目录。相当于cd到某个路径,后面执行命令都在这个路径下。
- COPY:把文件从宿主机复制进镜像。比如把Jar包放进去。
- EXPOSE:声明容器内哪个端口提供服务。它只是“声明”,真正要让外部访问,还得靠docker run时用-p做端口映射。
- ENTRYPOINT:容器启动时执行的固定命令。适合“进程启动命令不会变”的场景。
- CMD:提供默认参数,或者直接定义启动命令。注意如果用ENTRYPOINT,CMD会以参数形式追加进去。
一个常用的启动方式就是:
ENTRYPOINT ["java", "-jar", "/app/app.jar"]这样容器一启动,就直接执行java -jar。如果后面你想传JVM参数,可以再结合CMD或者环境变量来实现。
3. 核心实操:把Spring Boot项目变成镜像
3.1 Maven侧的三个关键点
动手写Dockerfile之前,先把Maven打包这步做好。我这边的经验是关注三个点:产物名、测试跳过方式、多环境配置。
第一,产物名。在pom.xml里设置finalName,保证target目录下生成的文件名固定,Dockerfile里引用起来方便。
<build> <finalName>demo-api</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>打包后生成target/demo-api.jar,后续Dockerfile里直接写这个文件名就行,省得每次都要看版本号。
第二,跳过Test的方式。很多项目本地打包时不跑测试,命令一般是这样:
mvn clean package -DskipTests如果连测试代码都不编译,用下面这个:
mvn clean package -Dmaven.test.skip=true区别在于:skipTests只是不执行测试用例,但会编译测试代码;maven.test.skip直接把测试代码的编译也省了。整体构建速度会快一点。但如果你是本地开发环境,别默认加这个参数,不然测试代码被破坏了都不自知。
第三,多环境配置。Spring Boot项目一般有dev/test/prod等环境的配置文件,打包时用-P参数指定:
mvn clean package -Pprod -DskipTests对应的profile会在pom.xml里定义,激活后会带上对应环境配置文件。这个习惯建议从一开始就养成,否则镜像到了生产环境,启动时还加载本地开发配置,连数据库地址都是错的,那就尴尬了。
3.2 一份能过生产的Dockerfile写法
Dockerfile有两种常见流派:单阶段和多阶段。
单阶段Dockerfile长这样:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/demo-api.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]逻辑很直白:拿一个带JRE的镜像,把Jar包放进去,启动。适合本地验证、Demo场景。
多阶段Dockerfile则是在一个镜像里完成Maven构建,再另起一个干净镜像只放运行产物:
# 第一阶段:构建 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B # 第二阶段:运行 FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]多阶段的好处有两个:一是构建过程中下载的依赖、编译产生的临时文件都不会进入最终镜像;二是不依赖你宿主机装了什么Maven环境。我亲眼见过某个项目在开发机本地打包一切正常,换到一台干净的构建机上就找不到settings里的私有仓库地址,最后用多阶段构建才绕开这个环境差异问题。
关于dependency:go-offline这一步,我多说一句。它会把pom.xml里声明的依赖先下载一遍,生成一个可复用的依赖缓存层。因为我把pom.xml单独COPY进去,Docker层缓存机制就能判断:只要pom.xml没变化,这层缓存就不会失效,后续构建不用每次重新下依赖,能省很多时间。
3.3 用.dockerignore给构建减负
和.gitignore类似,.dockerignore用来声明哪些文件不需要发送到Docker构建上下文。这个文件很重要,很多新手不建,结果把target目录、.git目录全打包进构建上下文,构建特别慢,甚至上传一些敏感文件。
我常用的模板:
target/ .git/ .idea/ *.iml .DS_Store logs/构建上下文越小,docker build把文件发送给Docker引擎的时间越短。几百MB的target目录不是开玩笑的,不忽略掉每次构建都像在传一个大文件。
4. 构建镜像和启动容器:命令背后的细节
4.1 docker build:缓存、标签与上下文
Maven打包和Dockerfile都准备好之后,开始构建镜像:
docker build -t demo-api:1.0 .-t给镜像打标签,方便后续定位。最后的.代表构建上下文路径,意思是以当前目录为准,把需要COPY的文件都发送给Docker引擎。如果Dockerfile不叫这个名字,用-f参数指定:
docker build -f docker/Dockerfile.prod -t demo-api:prod .构建过程中,Docker会利用层缓存。你修改了哪一个文件,就只重建受影响的那几层。利用好这个特性,可以大幅度缩短构建耗时。最理想的状态是:基础镜像层不变,依赖层不变,每次只更新应用代码那一层,几秒就能构建完。
这一点在多阶段构建里尤其重要。我前面把pom.xml单独COPY进去再下载依赖,就是充分利用了缓存。如果你把所有文件一次性COPY进去,那pom.xml一改,整个依赖缓存层就失效了,构建时间直接拉满。
构建完成后,用docker images看一下镜像是否存在:
docker images | grep demo-api如果看到demo-api 1.0,就说明构建成功了。
4.2 docker run:端口、挂载、环境变量
启动容器的命令很简短:
docker run -d --name demo-api -p 8080:8080 demo-api:1.0拆开看,-d表示后台运行,--name后面是容器名,-p做端口映射,宿主机8080端口进到容器8080端口,最后一个参数是镜像标签。
对生产环境,我通常会把JVM参数和环境变量也加进去:
docker run -d \ --name demo-api \ -p 8080:8080 \ -e JAVA_OPTS="-Xms256m -Xmx512m -XX:MaxRAMPercentage=75" \ -e TZ=Asia/Shanghai \ --restart unless-stopped \ demo-api:1.0这里我要提醒一个容易忽略的点:环境变量不会自动生效到ENTRYPOINT里,除非你的启动脚本主动读取。比如上面写的JAVA_OPTS,Java命令不会自己读取这个名字的环境变量,所以我一般建议直接在ENTRYPOINT里写实际参数,或者套一层启动脚本,由脚本解析JAVA_OPTS再拼进java命令。否则配置写了半天,容器里没生效,问题定位起来很绕。
挂载目录也是个常见需求。配置文件、日志目录如果不想写进镜像,用-v挂载宿主机目录:
-v /data/conf/app.yml:/app/conf/app.yml -v /data/logs:/app/logs挂载的好处是修改配置不用重新构建镜像,重新启动容器就行。缺点是容器环境对宿主机目录有依赖,换一台机器部署时得保证目录存在,否则容器会自己建一个空目录,行为可能会变得很怪。
4.3 启动后怎么确认服务真的起来了
容器启动不等于服务就绪。Spring Boot启动要一些时间,依赖了数据库、配置中心的话,可能更慢。确认这件事我一般分三步走。
第一步,看日志:
docker logs -f demo-api看到 Started 这样的字样,说明启动完成。如果容器反复重启,看docker ps -a里STATUS那一列,Exited表示已经退出,这时候docker logs是最有效的信息来源。
第二步,请求健康检查端点:
curl http://localhost:8080/actuator/health如果项目引入了spring-boot-starter-actuator,返回类似{"status":"UP"}的JSON,说明服务可用。
第三步,用docker exec进容器,观察进程和网络:
docker exec -it demo-api sh ps aux | grep java这一步主要用来确认Java进程确实跑着,以及容器环境变量、配置文件挂载是否正常。有时候服务器端口通了,但实际连的是别的进程,只有进容器看才能发现。
5. 实际踩过的坑:从镜像肥到时区乱
5.1 镜像体积从500MB降到不足200MB
第一次搞容器化的人,十有八九会被镜像体积吓到。一个编译好的镜像动辄四五百MB,磁盘占用大,推送拉取也慢。
体积大的根源一般有两个:一是在同一个镜像里既做编译又做运行;二是基础镜像选得太重。解决办法就是多阶段构建,以及换更轻量、仅含JRE的基础镜像,不一定非要带完整JDK。
我做的一个对比:
| 方案 | 体积(约) | 说明 |
|---|---|---|
| 完整JDK + 单阶段镜像 | 450MB+ | 包含编译工具链,运行不需要的部分也打包了 |
| JRE基础镜像 + 单阶段 | 250MB+ | 体积降了,但构建仍依赖宿主机Maven |
| 多阶段构建 + JRE镜像 | 180MB | 构建和运行隔离,体积最小 |
如果想进一步压缩,可以基于jlink裁剪出项目最小运行所需JDK模块,体积能压到100MB以内。不过这个方案有点进阶,项目依赖的反射、动态代理比较多时,模块配置容易漏,多数普通项目没必要做到这一步。
5.2 容器时区与JDK的随机数问题
时区问题特别典型。宿主机明明是北京时间,容器里日志时间却差8小时。原因在于容器基础镜像默认使用UTC,不会自动继承宿主机的时区。解决方式是在Dockerfile里显式设置:
ENV TZ=Asia/Shanghai但光设环境变量不一定能成功,还得看基础镜像里有没有安装tzdata。有些精简镜像连时区数据都没有,得先安装:
RUN apt-get update && apt-get install -y tzdata \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime即使不做这些操作,也可以在docker run启动时加上:
-e TZ=Asia/Shanghai这算是一个方便的动态调整手段,修改后重新启动容器就生效,不用重新构建镜像。
再说一个Java进程常见的细节:JDK在高并发下启动时,SecureRandom初次生成随机数可能会因为等待熵源而变慢。启动命令里一般会加上:
-Djava.security.egd=file:/dev/./urandom这个我在早期排查容器启动慢、启动过程卡顿的时候遇到过。加上之后,启动时间明显稳定下来。别小看这个参数,在非容器环境可能不明显,但在容器里经常被触发。
5.3 JVM对容器内存的感知与配置
JDK 8官方在8u191之后才真正支持容器内存限制感知,JDK 11及以后默认就感知了。如果你用的是高版本JDK,基本不用操心Java进程在容器里看不到可用内存上限的问题。
但如果启动参数里显式写了-Xmx2g,而容器内存限制只有1g,那Java进程启动时可能直接报错或者很快被系统杀掉。正确做法是让JVM在容器限制范围内动态调整堆大小。
我常用的参数组合:
-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50意思是,容器可用内存的75%用作最大堆,初始堆按50%设置。这样部署时只需要调整docker run的-m参数,不用改启动脚本。要注意的是,如果你用了低版本JDK,这些参数可能不识别,会直接启动失败。所以我一般建议容器内直接使用JDK 17以上的基础镜像,省掉一堆兼容问题。
5.4 容器起不来的排查顺序
服务起不来,不要急着翻业务代码,先按下面顺序排查,大部分问题都能定位:
- docker ps -a 看容器状态。Exited和Restarting含义不一样,Restarting说明进程反复崩溃,Exited说明启动后完全退出了。
- docker logs --tail 50 demo-api 看最后的日志。最要紧的是看有没有明显异常栈,比如端口占用、配置缺失、依赖服务连不上。
- docker inspect demo-api 检查挂载、环境变量、映射端口是否正确。特别是挂载目录权限不足导致的启动失败,日志里往往只有一句Permission denied。
- docker exec -it demo-api sh 进入容器,手动执行启动脚本或java命令,看交互输出。
我发现一种情况,是经验不足的人最容易卡住的:宿主机端口已经被另一个进程占用,docker run -p 8080:8080启动时会报端口冲突,但镜像本身没问题。这种问题只要换一个宿主机端口,比如8081映射到容器8090,就能解决。排查时先确认端口,别一上来就怀疑自己的代码。
6. 一点个人经验
Maven编译项目打包到Docker镜像并启动,这套流程我在多个项目里反复走过,最大的体会是:真正让部署变得顺利的,不是哪一条命令写得多完美,而是你对环境细节的掌控。时区、内存参数、基础镜像版本、构建缓存,这些看起来很小的东西,串起来就是一次部署的成败。
如果你刚开始接触容器化,我建议先拿一个最简单的Spring Boot项目,不要带数据库中间件配置,完整走一遍从Maven打包到容器启动的流程。跑通之后,再逐步增加环境变量、挂载目录、健康检查这些内容。一次只引入一个新变量,出问题时方向会很明确。
最后分享一个很实用的小技巧:把这条链路用脚本固化下来。我一般是维护一个deploy.sh,里面串起来Maven打包、Docker构建、Docker启动三步,再用带版本的标签管理镜像。这样就不会每次手动敲一堆命令、还容易敲漏参数。等这个流程稳定下来,再考虑引入编排工具去管理多个容器的依赖关系,那就是另一个话题了。