
你在自己电脑上启动 Spring Boot 微服务三秒起来接口全部正常。一旦部署到服务器要么 JDK 版本不对要么依赖缺了几个要么容器里时间是 UTC日志和数据库差了八小时。折腾半天最后发现根本不是代码问题是环境问题。这几乎是每个做微服务的人都会撞上的墙。把 Spring Boot 微服务打包成 Docker 镜像解决的正是这件事把应用、运行环境、依赖、配置一次性打包进一个标准交付物在哪运行结果都一样。这篇文章会围绕这个主题把方案选型、基础镜像选择、Dockerfile 写法、Maven 插件自动化、常见故障排查和镜像瘦身完整讲一遍。适合刚接触容器化的后端开发者也适合正在把传统部署往容器环境迁移的运维同学。1. 镜像打包的本质与方案选型1.1 为什么非要用 Docker 镜像不可先说个直观感受没有容器的时候部署一个微服务要准备哪些东西装 JDK、装依赖、调时区、配环境变量、改端口、再担心和别的应用端口冲突。三个服务还好三十个微服务上线的时候每台机器都要手工维护一遍环境谁也不敢保证每台机器环境完全一致。Docker 镜像解决的就是这个“环境一致性”问题。你可以把镜像理解为一个只读模板代码、JRE 运行时、依赖库、配置文件、时区设置全部固化在里面。用同一个镜像启动容器不管是一台低配虚拟机还是高配物理机应用看到的环境完全一样。我更喜欢用“集装箱”类比以前搬家是零散地搬家具电器路上磕碰、丢失、尺寸不匹配样样都可能出问题集装箱出现之后所有东西统一装箱标准的吊装和码头设备直接搬运效率和质量都大幅提升。Docker 镜像就是软件世界的标准集装箱构建一次处处运行。更重要的是版本回滚和发布效率。镜像带了不可变版本上一版出问题直接把发布版本回退到上一个镜像即可几秒钟的事。没有容器化的时候回滚可能意味着重新上传 jar、重新配环境、再赌一次环境没问题。长期维护过微服务系统的同学应该都有切身体会。1.2 四种主流打包方案横向对比目前 Spring Boot 项目打包成 Docker 镜像主流路径无非四种手写 Dockerfile、Maven 插件调用 Dockerfile、Spring Boot 自带的构建能力、纯 Java 的 Jib 方案。先给一个对比表格再逐个讲清楚适用场景。方案是否需要 Dockerfile典型命令适用场景主要缺点手写 Dockerfile需要docker build想完全掌控镜像内容、理解原理需要自己维护构建逻辑Maven 插件调 Dockerfile需要mvn package 时联动构建已有 Dockerfile、要接入 CI需要额外插件配置Spring Boot build-image不需要mvn spring-boot:build-image希望零配置出镜像首次拉构建依赖慢定制受限Jib不需要纯 Java 插件mvn compile jib:build严格 CI 流水线、注重层缓存本地调试不够直观先说明一下这里说的“手写 Dockerfile”不是说所有事情都靠手搓而是把镜像构建逻辑显式写进 Dockerfile 文件由你自己控制。它最核心的价值是透明——每一行指令都清楚知道在做什么依赖变更、基础镜像升级、JVM 参数调整全部一目了然。对于绝大多数团队从手写 Dockerfile 入手是最稳的路径。Spring Boot 自带的build-image走的是另一套思路它完全不需要 Dockerfile构建过程由 Buildpacks 自动探测项目类型自动选择构建器和基础镜像。好处是零配置坏处是定制能力弱而且第一次构建要从远端拉取构建器镜像网络差的时候能等很久。Jib 是纯 Java 实现不依赖本地 Docker 守护进程构建时可以并行推送镜像层对 CI 场景很友好。但它有个共性门槛如果团队对 Docker 本身不熟出了问题会比较难排查。所以一般建议团队里至少有一个能徒手写 Dockerfile 的人再考虑上 Jib 这类自动化工具。1.3 不同团队怎么选如果你是第一次做容器化或者团队规模不大我建议老老实实从手写 Dockerfile 开始完整经历一次构建、运行、排查的过程。这个过程会逼你搞清楚 JVM 运行时选型、时区、非 root 用户、日志等一堆细节而这些细节恰恰是后面所有自动化方案都会遇到的基础问题。如果团队已经有一套跑得很顺的 Dockerfile接下来只是想减少人工操作那就用 Maven 插件把它接进构建流程让mvn package的时候镜像自动生成并推到仓库。这是目前我认为投入产出比最高的组合。如果团队每天有大量版本发布又不想在每个服务里维护 Dockerfile可以考虑 spring-boot:build-image 或 Jib但前提是你们能接受它们默认的构建行为和镜像结构。选型没有绝对正确答案关键是知道自己的限制在哪里。2. 动手前的版本与基础镜像决策2.1 先对齐 Java 和 Spring Boot 版本开始写 Dockerfile 之前第一件事不是敲代码而是确认你项目用的 JDK 版本和 Spring Boot 版本是否匹配。很多问题都是版本错位导致的比如项目用 Spring Boot 3.x它要求 JDK 17 起步结果你基础镜像选了 JDK 8打包出来的镜像根本启动不了。我的建议很直接新项目一律走 LTS 版本JDK 17 是当前最稳妥的选择。Spring Boot 2.7 支持 JDK 8 到 17Spring Boot 3.x 要求 JDK 17也就是说 17 是这两代框架都能覆盖的范围。选 17 意味着无论你当前用的是 2.7 还是已经升级到 3.x镜像里只需要放一套 JDK 17 运行时就够了不用为不同服务维护多套镜像。另一个细节是构建环境和运行环境可以分离。构建阶段用完整的 JDK因为要编译、要跑单元测试运行阶段只放 JRE因为只需要 java 命令能执行 jar 即可。这样镜像体积能明显缩小。后面写 Dockerfile 也会按这个思路来做。2.2 基础镜像到底怎么选基础镜像是整个 Dockerfile 的地基这个选不好后面全是坑。先说一个很多人不知道的背景早期教程爱用openjdk:8-jdk-alpine但官方 OpenJDK 镜像已经停止更新很久了安全漏洞没人管我强烈不建议再用。现在社区事实上的首选是 Temurin 系基础镜像也就是基于 Eclipse Temurin JDK/JRE 构建的那些镜像。Temurin 镜像主要分两类一类基于 Ubuntu一类基于 Alpine。我的看法是默认用 Ubuntu 系除非你明确知道自己为什么非 Alpine 不可。原因在于 Alpine 底层用的是 musl libc和大多数 Linux 发行版的 glibc 不一样某些 Java 原生库、网络组件、JVM 的特定特性在 musl 环境下可能出现奇怪问题。这属于“偶尔不踩坑踩一次就很头疼”的类型。给你一个参考对比基础镜像类型体积优点注意点Temurin JREUbuntu 系较大兼容性最好、行为最贴近生产 Linux 环境镜像体积大一些Temurin JREAlpine 系较小体积小、启动快musl 可能导致部分原生能力异常Distroless 类无 shell 镜像最小攻击面小、体积很小容器内没有 shell排查问题困难如果你第一次做不想在环境兼容性上花时间直接选eclipse-temurin:17-jre-jammy这类 Ubuntu 系镜像。体积大一点但换来的是省心。等整套流程跑顺了再考虑用瘦身手段压低镜像体积也不迟。2.3 三个细节现在不配后面必踩坑第一个是时区。Docker 容器默认时区是 UTC而国内业务数据库、日志分析全部默认东八区。如果不做任何处理你会在容器日志里看到所有时间都比实际时间晚 8 小时排查线上问题的时候非常痛苦。解决方式是在镜像里设置环境变量ENV TZAsia/Shanghai。注意有些基础镜像缺时区数据如果设置后没生效需要在构建阶段同步安装 tzdata 包。第二个是字符集。虽然 Linux 环境默认 UTF-8 一般问题不大但如果你在环境变量里覆盖了LANG或LC_ALL或者应用里解析了外部文件就可能出现中文乱码。最稳妥的做法是在 Dockerfile 里显式设置ENV LANGC.UTF-8JVM 启动参数里再加一个-Dfile.encodingUTF-8双保险。第三个是镜像加速。构建镜像需要去镜像仓库拉基础镜像很多网络环境直连官方仓库会很慢甚至超时。构建之前记得在 Docker 的配置里配好镜像加速器。这个不是技术难点但很多新人上来就卡在这一步还以为是自己的 Dockerfile 写错了。3. 核心实操手写一份能上生产的 Dockerfile3.1 完整示例先放出来下面这份 Dockerfile 是我目前最常用的一套模板多阶段构建构建阶段用完整 JDK 打包运行阶段用精简 JRE创建独立用户设置时区并配置容器感知的 JVM 参数。# 构建阶段使用带 Maven 和 JDK 17 的镜像 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app # 先把 pom.xml 复制进去提前下载依赖利用层缓存 COPY pom.xml . RUN mvn dependency:go-offline -B # 再复制源码执行打包 COPY src ./src RUN mvn clean package -DskipTests -B # 运行阶段使用精简 JRE 镜像不保留编译工具 FROM eclipse-temurin:17-jre-jammy ENV TZAsia/Shanghai \ LANGC.UTF-8 \ LC_ALLC.UTF-8 # 创建非 root 用户提升容器安全性 RUN groupadd -r app useradd -r -g app app WORKDIR /app COPY --frombuild /app/target/*.jar app.jar USER app EXPOSE 8080 # 容器感知的 JVM 内存设置让 Java 根据容器内存自动调整堆大小 ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, -Djava.security.egdfile:/dev/./urandom, -jar, /app/app.jar]下面一段一段拆开讲重点是“为什么这样写”。3.2 构建阶段的三个小心机第一个心机是多阶段构建。整个 Dockerfile 分成build和运行两个阶段构建阶段用了带 Maven 的完整 JDK 镜像运行阶段只复制出最终的 jar 包不包含 Maven、不包含编译器、不包含任何构建缓存。这样做的好处是最终镜像体积大幅缩小同时攻击面也小。你只需要关心运行时要什么而不是构建时用了什么。第二个心机是“先复制 pom.xml后复制源码”。Dockerfile 里的每个 COPY、RUN 指令都会形成一层Docker 在构建时会尝试复用未变更的层。你把源码 COPY 放在依赖下载后面意味着如果只是改了 Java 代码pom.xml 没变那么mvn dependency:go-offline那一步会命中缓存不用重新下载所有依赖。这个优化在大项目里效果极为明显能省下好几分钟构建时间。第三个心机是mvn dependency:go-offline -B这个命令。它的作用是提前把项目需要的所有依赖下载到本地仓库这样做能让“下载依赖”和“编译打包”分成两个独立阶段进一步巩固层缓存的优势。-B是批处理模式避免 Maven 在非交互环境下卡在输入提示上。3.3 运行阶段用户、时区、JVM 参数运行阶段第一件重要的事是创建非 root 用户。容器里默认以 root 运行听起来方便但安全风险很大一旦应用被攻破攻击者拿到的是容器里最高权限。所以在镜像里创建普通用户并通过USER app切换过去是生产环境的基本要求。很多安全扫描工具也会把“容器以 root 运行”标记为高风险项。第二件是 JVM 参数重点讲-XX:MaxRAMPercentage75.0。如果你在传统服务器上部署习惯是写-Xmx512m这种固定值但容器环境里内存是动态分配的写死大小容易出现“容器明明有 2G 内存Java 堆只用了 512M其余内存空置”或者反过来“堆设置过大容器内存不足被内核杀掉”。MaxRAMPercentage75.0的意思是 JVM 会把容器可用内存的 75% 分配给堆剩下 25% 留给元空间、线程栈、JIT 编译等 JVM 自身开销。还有一个前提JDK 8u191 以上的版本默认开启了UseContainerSupportJVM 能自动识别容器内存限制。如果你还在用很老的 JDK 8 版本需要显式加-XX:UseContainerSupport。第三件是-Djava.security.egdfile:/dev/./urandom。这个参数很多人忽略但它影响的是启动速度。Java 在生成安全随机数时会读取/dev/random如果系统熵源不足启动过程会卡顿很久。将其指向/dev/urandom能让启动明显变快。在容器环境里这个参数几乎是标配了。第四件是EXPOSE 8080。注意它只是声明“容器内应用监听 8080”并不代表宿主机就自动映射了端口。启动容器时仍然需要-p 8080:8080手动映射。但是如果你想做端口自动发现、或者在编排平台上让平台帮你做网络配置EXPOSE声明是必要的。3.4 Spring Boot 分层构建带来的缓存优化前面提到的层缓存优化主要靠 Docker 自身机制Spring Boot 这边还有一个更细的玩法分层构建。Spring Boot 2.3 之后可执行 jar 内部默认支持把依赖、框架类、应用类拆分成多个层构建镜像时可以把这些层一一分发给 Docker实现更精准的缓存。操作分两步。第一步先解出各层java -Djarmodelayertools -jar app.jar extract解出来之后目录结构大致是dependencies/、spring-boot-loader/、snapshot-dependencies/、application/这几部分。第二步在 Dockerfile 里按层复制FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --frombuild /app/target/dependencies/ ./ COPY --frombuild /app/target/spring-boot-loader/ ./ COPY --frombuild /app/target/snapshot-dependencies/ ./ COPY --frombuild /app/target/application/ ./ ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, -jar, /app/app.jar]这个写法的收益是业务代码通常频繁变更但第三方依赖不常变。代码一改只有最后application层会重建前面三层全走缓存。如果应用规模大、依赖很多这个优化能让镜像构建时间从“每次几十秒”降到“每次几秒”。不过说实话如果项目比较小层缓存带来的收益有限常规多阶段 Dockerfile 已经够用不必强行上分层。4. 进阶自动化一条命令构建镜像4.1 先认识 Spring Boot 自带的 build-image如果你不想维护 DockerfileSpring Boot Maven 插件自带了一个构建镜像的能力一条命令就能出镜像mvn spring-boot:build-image它会自动检测这是一个 Spring Boot 项目然后通过构建器生成镜像。期间不需要 Dockerfile不需要你指定基础镜像、JDK 版本这些信息它用自己的默认策略完成。初次执行时要从远端下载构建器相关组件网络差的时候确实等得很焦急但第二次开始会好很多。这个方案的限制也很明显第一镜像的基础镜像和结构由构建器决定你想换成自己的精简基础镜像需要做额外配置第二构建过程是在容器内完成的需要 Docker 环境可用某些严格隔离的 CI 环境不一定允许 Docker in Docker第三对镜像内容做定制比较绕不如直接改 Dockerfile 直观。所以它适合想快速拿到一个可用镜像的团队不适合对镜像有强诉求的团队。4.2 用 Maven 插件把现有 Dockerfile 跑起来如果你已经有一份 Dockerfile又希望mvn package完直接出镜像并推到仓库最常见的做法是使用 Docker Maven 插件。我一般建议把插件绑定到package阶段构建配置保持和mvn clean package的行为一致。plugin groupIdio.fabric8/groupId artifactIddocker-maven-plugin/artifactId version0.43.4/version configuration images image nameregistry.example.com/project/%{artifactId}:%{version}/name build dockerFile${project.basedir}/Dockerfile/dockerFile /build /image /images /configuration executions execution phasepackage/phase goals goalbuild/goal /goals /execution /executions /plugin这个配置有几个细节值得说。镜像名用了%{artifactId}和%{version}两个占位符插件会自动替换成项目坐标这样每个服务不用各自维护一份镜像名。仓库地址写的是registry.example.com实际使用时换成你们自己的私有仓库地址。绑定package阶段后每次执行mvn clean package就会自动构建镜像不需要额外记忆命令。如果希望连推送仓库也一起做了把绑定阶段改成deploy然后执行对应目标即可。这样开发本地构建和 CI 流水线走的是同一套逻辑减少环境差异。4.3 镜像仓库与 CI 的配合思路镜像接进流水线之后真正要设计好的是标签规则。我见过最混乱的情况是所有人都往latest上推结果线上回滚时根本不知道这个镜像对应哪个代码版本。我目前用下来比较顺的方案是本地开发直接用项目名:版本号发布到测试环境的镜像增加环境标签发布到生产环境的镜像用不可变的内容摘要或 git 提交号做标签。隐私和仓库地址其实有一个一直容易被忽略的点镜像名里不要硬编码仓库地址之外的敏感信息。很多团队把镜像私库的账号密码写进插件配置然后整个配置文件又提交进代码仓库这是很大的安全隐患。正确做法是 Maven 插件配置使用环境的变量替换或者利用~/.m2/settings.xml里的 server 配置保存认证信息代码库里只保留占位符。5. 运行验证、问题排查与镜像瘦身5.1 镜像跑起来之后先做这四件事镜像构建完成只是第一步真正上线前我建议按下面的顺序验证一遍。先看启动是否正常。用交互模式启动把端口映射出来观察启动日志。比如docker run --rm -p 8080:8080 --name demo registry.example.com/project/demo:1.0.0如果起不来立刻docker logs看报错。右键不要删容器因为容器一旦删除日志就没了排查会更困难。再看健康检查。Spring Boot 项目引入了actuator依赖的话可以用/actuator/health接口确认应用状态。如果容器编排平台会自动检测健康状态你可以在 Dockerfile 里通过HEALTHCHECK指令配合这个接口让平台感知容器是否真正可用而不只是进程活着。然后看配置文件是否正确加载。容器里没有 IDE也不一定有 vim推荐用docker exec -it 容器ID sh进入容器查看进程、环境变量和启动参数。这往往是定位“配置没生效”的最快方式。最后确认日志和时区。日志时间是不是东八区、中文是否乱码在刚启动时就要看一眼别等半个小时后日志滚动起来再发现全是 UTC 时间。前面 Dockerfile 里已经处理了时区这里只是验证是否生效。5.2 高频问题速查表以下问题是我在实际部署和帮人排查过程中反复遇到的整理成速查表现象可能原因处理方法容器启动后立刻退出ENTRYPOINT 写错、启动命令加了 shell 格式导致信号丢失使用 exec 格式[java, -jar, ...]检查 JVM 参数日志时间差 8 小时容器未设置时区构建时设置ENV TZAsia/Shanghai必要时安装 tzdata中文变为问号或乱码缺字符集环境变量或 JVM 编码未指定设置LANGC.UTF-8、增加-Dfile.encodingUTF-8容器能启动但宿主机访问不到端口未映射检查EXPOSE只是声明运行时要加-p 8080:8080应用连不上数据库用 localhost 连接宿主机服务改用宿主机内网 IP 或服务发现地址启动很慢卡在随机数生成/dev/random熵源不足启动参数加-Djava.security.egdfile:/dev/./urandom容器被 OOMKilled堆内存设置超过容器内存使用-XX:MaxRAMPercentage75.0不要写死-Xmx镜像构建时依赖下载超时网络问题或镜像源未配置配置镜像加速器或使用 Maven 镜像仓库这张表里我想重点提醒两个“看起来不严重但很坑”的点。第一个是容器被杀到 OOMKilled 之后你去看应用日志可能什么都看不到因为它不是应用自己抛异常退出的而是被内核直接终止。这时一定要看docker inspect里的OOMKilled字段是 true 还是 false。第二个是时区问题很多人一开始不以为意等做数据分析的时候发现所有时间戳偏移 8 小时只能重建数据代价非常大。5.3 镜像瘦身的两个实用手段镜像体积直接影响分发速度和启动速度但瘦身要有个度别为了体积牺牲稳定性。我自己实际用下来收益最大的是下面两个手段。第一个是基于多阶段构建做减法。前面已经实现了构建与运行分离但eclipse-temurin:17-jre-jammy本身还是有一定体积的。如果你希望进一步压缩可以退一步用 Alpine 系 JRE代价是需要再确认一次应用及依赖在 musl 环境下表现正常。这一步省心程度和体积成反比要自己做取舍。第二个是自行裁剪 JRE。JDK 提供了一个jlink工具可以根据你的实际需要组装一个最小 JRE。比如你的应用只需要java.base和少数几个模块就可以把它裁剪到几十 MB。参考做法FROM eclipse-temurin:17-jre-jammy AS java-runtime RUN jlink \ --add-modules java.base,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument \ --strip-debug \ --no-header-files \ --no-man-pages \ --compress2 \ --output /opt/jre-runtime FROM ubuntu:22.04 COPY --fromjava-runtime /opt/jre-runtime /opt/jre-runtime ENV PATH/opt/jre-runtime/bin:$PATH这个方案在我们某个内部项目上把运行镜像从两百多 MB 压到了一百 MB 以内启动速度也有提升。但要注意--add-modules的模块列表要根据你项目实际使用的功能和用什么库来确定刚开始裁剪后应用启动时报ClassNotFoundException是正常现象耐心把缺失的模块补上即可。这中间需要反复构建验证所以适合对镜像体积有明确指标要求的团队不适合第一次做容器化就折腾。从我的实践来看常规多阶段构建加 JVM 参数优化已经能覆盖大多数场景。先把流程跑通、把稳定性做好瘦身排在后面。生产环境追求的不是最小而是可预测和可排查。最后我再分享一点个人习惯我每次新建一个微服务项目都会先手动构建一遍镜像完整走一遍启动、验证、排查的过程确认基础镜像和 Dockerfile 都正常再决定要不要接 Maven 插件和 CI。这个习惯帮我避开了很多自动化带来的“黑盒”问题——自动化的前提是每一步都有人理解底层在做什么。Docker 镜像打包这件事本身并不难难的是那些边边角角的细节。把这份 Dockerfile 模板保存下来跑通一次后面基本就一通百通了。