ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot容器化部署实战:从JAR到Docker镜像与Compose编排

SpringBoot容器化部署实战:从JAR到Docker镜像与Compose编排 1. 先说清楚为什么我最终会把 SpringBoot 塞进容器1.1 传统的 java -jar 部署方式到底有什么问题一两年以前我还是个坚持java -jar app.jar走天下的人。第一次在几台 AlmaLinux 服务器上部署 SpringBoot 项目的时候环境差异、JDK 版本不一致、端口冲突这些老问题就折腾掉我整整两天。A 机器上用的 OpenJDK 8B 机器上装了 17同一个 JAR 包在两台机器上的表现完全不同更麻烦的是进程一崩就得手动ps找到 PID 再kill根本没有统一的启动、停止、重启流程。那时候我还在用最原始的方式写一个start.sh里面塞nohup java -jar app.jar app.log 21 几十个同事各自维护自己机器上的脚本时间一长谁都不知道哪台机器上跑的是哪个版本。后来把整个流程搬到 Docker 上从 JAR 包到镜像再到容器部署才真正变得可重复、可回滚。镜像把 JDK、系统依赖、应用本身全部打包在一起测试环境验过什么生产环境跑的就是什么不会再出现我本地好好的服务器上就是起不来的窘境。这篇实战记录就围绕JAR 包 → Dockerfile → 容器化部署这条主线展开覆盖 AlmaLinux 上 Docker 的安装配置、SpringBoot 项目的 Dockerfile 编写与深度优化。适合刚把项目搬到容器路上的同学参考也适合已经在用 Docker、但总被镜像体积、时区、资源限制这些细节折磨的人。1.2 为什么选 AlmaLinux 和 Docker 这个组合选 AlmaLinux首先是因为它和 RHEL 完全二进制兼容生命周期长dnf包管理用起来顺手。CentOS 8 停更之后AlmaLinux 是承接原有习惯最平滑的选择之一服务器上已有的运维脚本、安全基线大多可以直接搬过来。其次AlmaLinux 默认开启了 SELinux这一点很多人第一次碰到时会被卡住Docker 跑起来没问题但要挂载宿主机目录、对外监听端口时SELinux 策略会静默拦截日志里只有一句含糊的Permission denied。后面我会专门说到怎么处理。选 Docker 而不是其他方案理由是现阶段它在通用性上依然最省心镜像规范是事实标准开发机上 IDEA 打包出的 JAR 能直接进镜像CI 里也可以用同一套 Dockerfile 构建。Podman 在 AlmaLinux 上也能用但围绕 compose、健康检查、日志轮转这些运维习惯Docker 的生态资料更全团队协作时降低沟通成本。2. JAR 包这一步IDEA 打包与 Maven 配置的隐藏细节2.1 pom.xml 里必须出现的 spring-boot-maven-plugin容器化第一步还是先把 JAR 包构建对。很多人在这一步就翻车在 IDEA 里执行packagetarget 目录下确实生成了一个 JAR拖到服务器上java -jar却报no main manifest attribute。原因几乎都是同一个——pom.xml里的spring-boot-maven-plugin没有被正确配置。这个插件的作用不只是帮 SpringBoot 项目打包它会在 Maven 默认生成的普通 JAR 基础上执行一次repackage把依赖、内嵌 Tomcat 全部打进一个可执行的 fat JAR。所以正确配置是这样的plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId version3.2.5/version executions execution goals goalrepackage/goal /goals /execution /executions /plugin如果你用的是 spring-boot-starter-parent 作为父工程插件版本可以省略而且 repackage 这个 goal 已经默认绑定了。这种情况下只需要保留最简配置plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin我见过不少项目把executions里的goalrepackage/goal误写成goalpackage/goal或者干脆没写结果 target 下只有一个几百 KB 的普通 JAR里面没有BOOT-INF目录结构。判断 JAR 是否打对了最简单的办法就是看大小一个依赖稍多的 SpringBoot 项目fat JAR 一般都在 30MB 以上只装自己的 class 是不可能这么大的。同样在 IDEA 的 Terminal 里也可以运行mvn clean package -DskipTests和在 IDEA 图形界面的 Maven 面板里执行是完全等效的这条命令反而更能看清完整的构建日志。2.2 本地验证和常见打包失败的排查思路打包成功后我强烈建议在本地先跑一次java -jar target/xxx.jar --server.port8081确认端口占用、静态资源路径、数据库连接等都没问题再进 Docker。这一步能过滤掉至少一半的部署问题。常见打包失败的表现大致可以分成三类我列个表方便你对照排查失败现象可能原因处理方向JAR 启动报no main manifest attributespring-boot-maven-plugin 未配置或 repackage 未执行检查 plugin 配置重新clean package启动时报ClassNotFoundException或奇怪的NoSuchMethodError依赖冲突或编译时用 JDK 版本过高运行时环境版本过低统一编译与运行时 JDK 版本用mvn dependency:tree查冲突打包成功的 JAR 很小解压后没有 BOOT-INF打包命令被 IDE 的构建流程覆盖target 产物是普通 JAR在 IDEA 里执行 Maven Lifecycle 的 package而不是 Build Project还有一个容易被忽略的点SpringBoot 3.x 要求 JDK 17 起步如果你的服务器或基础镜像还是 JDK 8JAR 就会直接启动失败。这类问题不发生在打包阶段而发生在运行时所以极其迷惑。我的经验是从一个项目初期就锁定 JDK 版本在 pom.xml 里显式声明java.version17/java.version避免每个人机器上的默认 JDK 不一致。2.3 打包之前要做好的配置边界划分把配置和环境剥离是容器化部署的前提。JAR 包里只保留默认配置和公共部分环境相关的参数数据库地址、Redis、端口、日志级别全部放到外部通过--spring.profiles.activeprod或者环境变量传进去。具体到 SpringBoot 里我会在application.yml中保留一个基于环境变量的占位体系例如server: port: ${SERVER_PORT:8080} spring: datasource: url: ${DB_URL:jdbc:mysql://localhost:3306/app} username: ${DB_USERNAME:root} password: ${DB_PASSWORD:root}这样同一个 JAR开发、测试、生产用不同的.env文件或 compose 里的 environment 字段就能切换镜像本身完全和环境解耦。很多人一开始不习惯觉得在配置中心里写死不是更简单吗但等你在生产环境被一条写死的密码逼着重新出包的时候就知道这步的价值了。3. AlmaLinux 上安装 Docker仓库、镜像加速和权限一次到位3.1 添加 docker-ce 仓库时的坑AlmaLinux 默认源里没有 Docker只有兼容的 moby-engine版本落后且不推荐。正确做法是从 Docker 官方仓库安装 docker-ce。很多人在这步会踩一个坑Docker 官方的仓库是基于 CentOS 发布的AlmaLinux 虽然兼容但用dnf config-manager --add-repo添加官方 RHEL 仓库时$releasever变量的值可能对不上导致 yum 源解析到不存在的路径。稳妥的操作是添加 CentOS 版本的 docker-ce 仓库AlmaLinux 9 对应 CentOS 9 的仓库路径sudo dnf install -y dnf-utils sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo仓库添加成功后修改/etc/yum.repos.d/docker-ce.repo把配置里的$releasever替换成实际可用的版本号。AlmaLinux 9 上可以改成 9然后执行sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin sudo systemctl enable --now docker docker version安装后务必执行docker run hello-world验证一下这个命令同时会测试守护进程能否正常从 Docker Hub 拉取镜像。如果卡住不动或者一直超时说明镜像拉取网络有问题下一节的镜像加速就要安排上了。3.2 daemon.json 镜像加速与数据目录调整Docker 安装好第一件事就是调整守护进程配置。我会在/etc/docker/daemon.json里一次配好三件事镜像加速、默认日志轮转、数据目录。一个常见的配置长这样{ registry-mirrors: [https://docker.m.daocloud.io], data-root: /data/docker, log-driver: json-file, log-opts: { max-size: 50m, max-file: 5 } }镜像加速解决的是从 Docker Hub 拉镜像慢的问题。registry-mirrors可以配置多个地址Docker 会按顺序尝试。数据目录我习惯从系统盘挪到数据盘因为镜像、容器层、卷会持续增长放在根分区很容易把系统盘塞满。如果服务器没有多余的数据盘可以跳过这一步但日志轮转建议一定设置很多容器写日志不节制不限制大小几个月就能吃掉几十 GB 磁盘到时候排查起来非常被动。改完配置重启 Dockersudo systemctl restart docker docker info | grep -A 5 Registry Mirrors重启后一定要用docker info确认配置生效我曾经遇到过改完 daemon.json 但忘了重启的情况心里想着配好了实际上用的还是旧配置。3.3 用户组、开机自启和防火墙放行Docker 默认用 root 权限运行每次执行命令都要带sudo时间一长会非常烦躁。把当前用户加入 docker 组可以免 sudo 操作sudo usermod -aG docker $USER newgrp docker注意加入 docker 组等同于授予该用户 root 级别的系统操作权限因为 Docker 可以通过挂载宿主机目录直接读写任意文件。在多人服务器上要谨慎最好只对可信用户开放。AlmaLinux 默认开启 firewalldSpringBoot 服务要对外提供服务必须放行端口sudo firewall-cmd --permanent --add-port8080/tcp sudo firewall-cmd --reload如果你绑定的端口不在默认放行列表里容器内部端口映射做得再好外部也访问不到。这个坑非常常见很多人在容器里折腾半天最后发现是防火墙没放行。4. Dockerfile 从能跑到跑好多阶段构建与分层复用4.1 朴素版 Dockerfile 的问题一开始我写的 Dockerfile 相当朴素基本长这样FROM openjdk:8-jdk-alpine COPY target/app.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]这个版本能用但问题太多第一openjdk:8-jdk-alpine这类老镜像体积巨大且不再维护基础镜像本身就可能超过 300MB加上依赖和 SpringBoot 的 JAR动辄 500MB 起步第二任何一行代码改动都会让整个COPY target/app.jar这一层缓存失效每次构建都要完整重来第三默认用 root 用户跑 Java 进程容器里出问题的排查范围被放大了第四时区是 UTC日志时间和本地时间对不上排查线上问题的时候非常抓狂。所以 Dockerfile 的优化不是等交付之后再做的锦上添花而是从第一次写就要按生产标准来。4.2 基于 Boot 分层特性的 COPY 优化SpringBoot 从 2.3 开始支持镜像分层构建。用默认的 spring-boot-maven-plugin 打包出来的 JAR内部会把依赖、SpringBoot 加载器、静态资源和应用类分成独立的层。用下面的命令就能看到分层结构jar tf target/app.jar | head -20 # BOOT-INF/lib/... # BOOT-INF/classes/... # BOOT-INF/classpath.idx # BOOT-INF/layers.idx # META-INF/applications/...BOOT-INF/layers.idx文件就是分层索引。在 Dockerfile 里我们可以把不同层分开 COPY这样每一层都能独立走构建缓存依赖不变时dependencies层可以直接命中缓存重新构建只需要替换最薄的snapshot层。这是我的多阶段构建 DockerfileFROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /workspace COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuild /workspace/target/app.jar ./app.jar ENTRYPOINT [java, -jar, /app.jar]mvn dependency:go-offline的作用是先拉取全部依赖让 build 阶段后面的mvn package不再反复解析网络依赖。首次构建会慢一些但之后因为独立层缓存迭代速度快很多。如果你想进一步利用 SpringBoot 分层可以使用spring-boot-maven-plugin的 layered 特性配合专门的extract步骤。操作上复杂一点但日志构建缓存命中率更高适合构建频率很高的项目。实际部署中多数中小项目用上面的多阶段版本已经足够不必过度设计。4.3 JVM 参数、时区、非 root 用户在镜像里一次解决Dockerfile 里容易被忽略的东西我会在构建阶段全部写死ENV TZAsia/Shanghai \ JAVA_OPTS-Xms256m -Xmx256m RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone这解决了时区问题。如果基础镜像里没有tzdata可能还需要先安装对应的包。非 root 用户也是一个重点RUN useradd -m -u 1001 app USER app创建普通用户后容器里的 Java 进程就不再用 root 权限运行。你可能觉得容器本来就是隔离的无所谓但安全扫描和内部合规检查往往会卡这一条。另外如果应用需要写文件到工作目录记得把目录权限交给这个用户RUN mkdir -p /app/logs chown -R app:app /appJAVA_OPTS 这个环境变量在 ENTRYPOINT 里不会被 SpringBoot 自动解析除非你显式使用它。所以 ENTRYPOINT 要写成ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app.jar]这样后续在 docker compose 里只需改变环境变量就能调整堆内存不必重做镜像。5. docker compose 部署编排健康检查、日志与端口管理5.1 一个能直接用的 docker-compose.yml单独docker run一次两次还行服务一多、参数一长命令行的维护成本就上来了。我会在项目的deploy/目录下维护一份完整的docker-compose.yml把端口、环境变量、重启策略、健康检查全写进去services: app: image: registry.example.com/app:1.0.0 container_name: app ports: - 8080:8080 environment: - TZAsia/Shanghai - JAVA_OPTS-Xms512m -Xmx512m - SPRING_PROFILES_ACTIVEprod - DB_URLjdbc:mysql://192.168.1.10:3306/app volumes: - ./logs:/app/logs restart: unless-stopped healthcheck: test: [CMD, curl, -f, http://localhost:8080/actuator/health] interval: 30s timeout: 3s retries: 3 start_period: 30srestart: unless-stopped是我推荐的重启策略机器重启后服务会自动拉起但你要是手动docker stop过它它不会自作主张重新启动。start_period很关键它给 JVM 启动预留了时间避免健康检查在应用还没起来时就把容器标记为 unhealthy。5.2 healthcheck 让重启策略不再是空谈如果没有健康检查restart: unless-stopped其实很不可靠进程崩了重启没问题但应用假死——端口还在监听、线程池却已经耗尽这种情况 Docker 是看不出异常的。加上 healthcheck 之后Docker 会周期性请求 SpringBoot Actuator 的健康端点连续失败超过retries次数就会把容器标记为 unhealthy。要让这个端点生效SpringBoot 项目需要引入 actuator 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency我踩过一个具体的坑基础镜像eclipse-temurin:17-jre里默认没有curlhealthcheck 里的curl命令会直接报 command not foundDocker 每次检查都失败。要么在镜像里安装 curl要么把检查命令换成一个不依赖额外工具的方式。我更推荐在镜像里装上 curl因为排查问题的时候也经常用到USER root RUN apt-get update apt-get install -y curl rm -rf /var/lib/apt/lists/* USER app装完基础工具再切回普通用户这个顺序别搞反。5.3 日志轮转和日常运维命令日志是运维里的另一个大坑。我在前面 daemon.json 里已经配了全局的 json-file 日志轮转这能让docker logs app的输出不至于无限膨胀。但应用自身写的日志文件比如挂载出来的/app/logs不受这个限制需要靠 logrotate 管理宿主机目录sudo dnf install -y logrotate然后写一个/etc/logrotate.d/app配置/app/logs/*.log { daily rotate 14 compress delaycompress missingok copytruncate }copytruncate对 Java 应用特别重要Java 进程通常一直持有文件句柄直接 rename 日志文件会导致应用继续往旧的 inode 上写文件被删了磁盘却不释放。copytruncate 先复制再清空原文件能避免这个问题。日常更新的流程我用这几条命令cd deploy docker compose pull docker compose up -d docker compose ps docker compose logs -f app发布新版本时构建新镜像、推送镜像仓库然后在服务器上执行docker compose up -dcompose 会自动发现镜像变化并重新创建容器。回滚也简单指定上一个镜像标签重新拉起即可。这套流程的核心价值是发布和回滚都变成一条命令的事而不是在一堆服务器上手动复制 JAR。6. 深度优化与踩坑记录镜像体积、启动速度、资源限制6.1 用 jlink 裁剪 JRE 把镜像打薄SpringBoot 项目镜像体积大大头通常不是应用本身而是完整 JDK/JRE。JDK 里大量模块比如 javafx、applet、各种不常用的扩展对后端应用毫无用处。Java 9 之后的jlink工具可以生成一个定制化的运行时镜像只保留你需要的模块。我的做法是在多阶段构建的 build 阶段用 JDK然后用 jlink 生成精简运行时FROM eclipse-temurin:17-jdk AS jre-build RUN jlink \ --add-modules java.base,java.logging,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,java.sql,java.net.http \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress2 \ --output /jre模块列表要根据项目的实际依赖调整。判断缺哪些模块最简单的办法是直接运行后用 jlink 生成的java启动应用报哪个模块的ClassNotFoundException就补哪个--add-modules。启动没问题就说明模块够用。构建完的运行时可以直接复制到最终镜像FROM alpine:3.19 ENV JAVA_HOME/jre ENV PATH$JAVA_HOME/bin:$PATH COPY --fromjre-build /jre $JAVA_HOME裁剪之后一个含 SpringBoot 应用的镜像可以从接近 500MB 降到 150MB 左右。不同方案的体积差异我实测大概是这样方案典型镜像大小openjdk:8-jdk-alpine fat jar500MB 左右eclipse-temurin:17-jre fat jar280MB 左右裁剪 jlink 运行时 fat jar150MB 左右裁剪 jlink SpringBoot 分层 jar140MB 左右体积变小最直接的好处是拉取和分发镜像的时间大幅缩短内网仓库小水管环境下效果尤为明显。但注意jlink 裁剪后的运行时不再具备完整 JDK 的调试能力比如某些需要动态编译的工具类会失效所以这个优化只适合已确认不依赖这些特性的后端应用。6.2 资源限制与 JVM 内存参数设置容器里跑 Java最危险的行为是 JVM 完全不感知内存上限。默认情况下 JVM 会读取宿主机总内存来设置堆大小如果容器有 2GB 限制而宿主机有 32GBJVM 会把堆默认开到总内存的一部分一旦压测流量上来容器直接 OOMKilled。Docker 的--memory限制在 cgroup 层面但 JVM 不一定正确识别。所以在 compose 里我会同时给出容器内存限制和 JVM 参数两者要匹配environment: - JAVA_OPTS-XX:MaxRAMPercentage75.0 -Xms128m-XX:MaxRAMPercentage75.0让 JVM 根据容器 cgroup 的可用内存动态计算堆上限而不是按宿主机内存来。-Xms可以给一个较小的初始堆配合 JVM 的伸缩在保证性能的同时减少内存浪费。如果docker stats观察到的内存老是顶着上限跑不要一上来就加内存先看是不是堆外内存线程栈、metaspace、网络缓冲占了太多这些不属于-Xmx管控范围。实战中我处理过一个 SpringBoot 项目堆只用了 300MB整个容器却吃掉了 700MB后来调整了线程池大小才降下来。6.3 部署过程中我踩过的坑最后集中记录几个具体坑都是真实发生过、且会反复出现的第一个是 SELinux 拦截挂载目录。我用./logs:/app/logs挂载日志目录后容器写日志一直报 Permission denied但宿主机目录权限明明是可以写的。问题出在 AlmaLinux 的 SELinux 默认策略容器进程的挂载卷带svirt_sandbox_file_t标签和宿主机普通目录标签不匹配。解决方法有两种推荐在 compose 的 volumes 上挂:Zvolumes: - ./logs:/app/logs:Z:Z会让 Docker 自动修正目录的 SELinux 标签。如果你不想用这个参数也可以临时关闭 SELinux但在生产环境我不建议这么干。第二个是 Maven 构建慢到怀疑人生。多阶段构建的 build 阶段要在容器里下载依赖如果项目还了大量私服依赖每层网络波动都可能让构建失败。我的解决方法是把本机的 Maven 仓库缓存挂载到构建容器里docker build \ -v ~/.m2:/root/.m2 \ -t app:1.0.0 .-v参数在docker build里其实是个非标准用法不同版本 Docker 支持情况不完全一样。如果你用 BuildKit新版 Docker 默认就是更推荐的是在 Dockerfile 里用缓存指令或者docker build --build-arg传入私服认证信息。整体思路就是别让每次构建都从零开始拉依赖。第三个是spring-boot-maven-plugin忘了配置导致的镜像很小但起不来。这个问题在第 2 节已经说过但它太典型了我这里再强调一次判断一个镜像能不能用别只看 Docker 是否构建成功要看打包出的 JAR 是不是 fat JAR。第四个是 healthcheck 一直失败但应用完全正常。这个坑往往不是代码问题而是基础镜像缺少 curl 或 wget前面提到过。排查命令是docker inspect --format{{json .State.Health}} app能看到具体的失败日志别再凭猜的。最后一个跟网络有关。如果服务器要通过代理访问外网仓库记得给 Docker 守护进程配置 HTTP_PROXY 环境变量而不是只配 shell 的环境变量。docker build时构建容器不会继承你登录 shell 里的代理设置需要在/etc/systemd/system/docker.service.d/http-proxy.conf里配置然后 daemon-reload 重启 Docker这个细节找不到的时候特别浪费时间。我个人在实际操作中的体会是容器化部署这件事真正难的不是跑起来而是把跑起来之后的一堆细节——时区、用户权限、内存、日志、健康检查——全部处理到位。Dockerfile 和 compose 文件就是团队部署规范的载体这两份文件一旦维护好了后续每一次发布都会变得非常省心。哪怕项目再小也值得在第一版就按这个标准来写否则后面再回头补成本只会更高。
返回列表