☰
Docker部署Spring Boot jar包:镜像优化与JVM调优实战
2026/10/27 4:15:19 网站建设 项目流程

1. 把问题定义清楚:jar 包放进容器,真正要解决的是什么

第一次把 Spring Boot 的 jar 包部署进 Docker 的人,多半会停在同一步:docker run -p 8080:8080敲下去,容器几秒后退出了,docker logs里只剩下 Spring 的启动 banner 和一句Communications link failure。看起来是“Docker 部署”这个动作出了问题,实际上绝大多数情况是环境假设没有对齐:宿主机上能跑,是因为宿主机上有一堆你没注意到的默认值,而容器里没有。

我用 Docker 部署 Spring Boot jar 包这几年,最大的体会是:Docker 本身不复杂,复杂的是把“一台机器上手工维护的运行环境”变成“一份可以重复构建的声明”。Java 进程对内存、时区、编码、文件路径、信号处理都有一套自己的假设,容器把这些假设全部打散,你必须在 Dockerfile 和启动命令里重新固化一遍。所以这篇内容不会只给一段 Dockerfile,我会把每一个参数为什么这么写、不这么写会出什么事讲清楚。

这篇文章主要面向两类读者:一类是刚接手项目、需要把现有的xxx.jar搬到服务器上的后端开发,手上有 jar 包但没有容器化经验;另一类是已经在用 Docker、但镜像动辄 400MB、启动要一分多钟、线上偶发 OOM 的运维或全栈。读完你应该能够独立写出一个体积可控、启动可预测、日志可追踪、能优雅下线的 Java 应用镜像,并且知道出问题时从哪几个角度下手。

先说清楚适用边界。这套方案适合单体或中等规模的 Spring Boot 服务:一个进程、一个 jar、一到两个外部依赖(数据库、缓存、消息队列)。如果你在做多模块微服务,这套东西依然成立,只是外部配置管理和编排部分要换成更重的方案。另外有一个常见误解要先打破:不是所有项目都值得容器化。如果你的项目一年只发两次版、机器就是一台、没有任何横向扩容需求,写个 systemd unit 文件 +java -jar反而更省事,出问题也好排查。容器化真正带来收益的场景是:部署环境需要多套且必须一致、需要快速回滚、需要限制资源、需要和其他中间件一起编排。

1.1 三种运行方式的真实成本对比

在动手写 Dockerfile 之前,值得先把几种跑法摆在一起看,因为选错路线后面全是补丁。

运行方式环境一致性升级回滚资源隔离上手成本典型适用场景
宿主机java -jar+ systemd差,靠人肉记手工替换 jar,回滚靠备份无,一个进程吃满 CPU最低单机内部工具、变动极少的服务
单镜像docker run好,镜像即环境换 tag 重启,秒级回滚可限制内存与 CPU中大多数中小型后端服务
compose 编排多容器好同上,且能整体升降可限制中高应用 + 数据库 + 缓存一起管
容器编排平台很好滚动更新、自动扩缩强高多实例、有 SLA 要求的服务

表格里最容易被低估的是“回滚”这一列。手工替换 jar 的回滚,实际耗时往往不是几分钟,而是“发现新版本有问题 → 找上一个版本的包在哪 → 确认配置有没有一起改 → 重启 → 验证”这一整条链路,半夜出事的时候这条链路会变得极其漫长。镜像的 tag 天然就是版本快照,docker run换个 tag 就回去了,这是容器化最实际的收益之一,比“环境一致”这种抽象说法更值钱。

1.2 开始之前需要定下的四个约定

我踩过的坑里,至少一半来自“没提前约定”。以下四个约定建议在写第一行 Dockerfile 之前就定死,后面所有配置都围绕它们展开。

  • 端口约定:容器内固定用 8080,对外映射按环境区分。容器内端口写死的好处是 Dockerfile 不用带构建参数,镜像可以跨环境复用;对外的-p在运行期决定,想开几个实例就映射几个端口。
  • 配置外置:jar 包内的application.yml只放不会变的默认值,环境相关的数据库地址、密钥、日志级别全部通过挂载文件或环境变量注入。这样可以做到一个镜像跑遍所有环境。
  • 日志出口:容器内日志一律输出到标准输出,由 Docker 收集,不在容器里写滚动文件。理由很直接:容器是临时的,写进容器文件系统的日志随容器删除一起消失,而且会在宿主机磁盘上悄悄堆积。
  • 数据目录:应用产生的上传文件、临时文件、导出报表,全部挂载宿主机目录或独立数据卷。容器重启等于换了一块新硬盘,这一点和传统部署的心智完全不同。

提示:这四个约定看起来琐碎,但它们是后面所有配置的地基。等到线上出问题再改,成本要翻好几倍。

2. Dockerfile 逐行拆解:一份可以直接抄的写法

我见过最多的 Dockerfile 写法是这样的:FROM openjdk:8、COPY app.jar /app.jar、ENTRYPOINT ["java","-jar","/app.jar"],三行搞定。能跑,但会带出四个后续麻烦:镜像接近 500MB、每次改一行业务代码就重新拷贝整个 jar 导致构建缓存全废、容器里是 root 用户、时区和宿主机对不上。下面这份写法把这些都处理掉,我按段解释。

# ---------- 构建阶段:把 jar 拆成分层目录 ---------- FROM eclipse-temurin:17-jre-jammy AS builder WORKDIR /build COPY target/app.jar app.jar # Spring Boot 2.3 ~ 3.2 用 layertools;3.3 及以后用 tools RUN java -Djarmode=layertools -jar app.jar extract # ---------- 运行阶段 ---------- FROM eclipse-temurin:17-jre-jammy ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 \ JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 -XX:+UseG1GC -Duser.timezone=Asia/Shanghai" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ && groupadd -r app && useradd -r -g app app \ && mkdir -p /app/logs /app/files && chown -R app:app /app WORKDIR /app # 顺序不能乱:依赖层放前面,应用层放最后 COPY --from=builder /build/dependencies/ ./ COPY --from=builder /build/spring-boot-loader/ ./ COPY --from=builder /build/snapshot-dependencies/ ./ COPY --from=builder /build/application/ ./ USER app EXPOSE 8080 ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]

2.1 基础镜像怎么选:别为了省 100MB 换来一周的排查

基础镜像的选项大致有这么几类,我把体积和风险的对应关系列出来,方便你权衡:

基础镜像类型大致体积优点隐藏成本
openjdk:8这类老标签450MB 以上不用想,直接能跑已停止维护、安全补丁缺失、镜像层大
eclipse-temurin:17-jre-jammy约 250MBJDK 与 OS 都在维护周期内,glibc 完整体积中等
eclipse-temurin:17-jre-alpine约 160MB体积小musl libc,部分依赖 glibc 的原生库会报错,排查起来非常费时间
distroless/java17约 180MB攻击面最小、无 shell进容器没法sh,调试手段几乎为零

我的建议很明确:生产环境优先选 Debian 系的 JRE 镜像。用 Alpine 省下那 90MB,通常换来的是某个加解密库、某个图像处理库、某个本地缓存组件在 musl 环境下崩溃或静默出错,而这类问题的报错信息往往毫无指向性。除非你的镜像要分发到带宽极差的边缘节点,否则不值得。至于 distroless,它适合安全要求极高、且团队已经有完善的可观测体系(日志、指标、链路追踪都不依赖进容器排查)的场景,小团队用了大概率会把调试能力一起丢掉。

另外注意jre和jdk的区别。JRE 镜像跑应用足够,但如果你想在容器里用jcmd、jstack、jmap做现场诊断,JRE 镜像里可能没有这些工具,需要额外安装openjdk-17-jdk-headless或者单独准备一个带工具的调试镜像。我的做法是:业务镜像用 JRE,另建一个带 JDK 的debugtag,出问题时把容器临时换成 debug 镜像,问题复现完再换回来。

2.2 把 jar 拆成分层结构,构建速度差出十倍

这一段是整篇文章里性价比最高的优化。默认的COPY app.jar /app.jar会把整个 jar 打成一个 Docker 层,而 Spring Boot 的 fat jar 里,依赖占了 90% 以上的体积。结果就是:你只改了一行业务代码,重新打包后 jar 的哈希变了,整个依赖层跟着失效,docker build又要把几百 MB 完整重算一遍。

Spring Boot 从 2.3 开始支持分层 jar,把 jar 内部按变更频率拆成四层:

  1. dependencies/:所有 release 版本的第三方依赖,几乎不变。
  2. spring-boot-loader/:Spring Boot 的启动引导类,跟着框架版本走。
  3. snapshot-dependencies/:SNAPSHOT 依赖,内部联调阶段常变。
  4. application/:你自己的 classes 和 resources,改一次变一次。

按这个顺序COPY,构建时前几层能命中缓存,只有最后的应用层需要重算。实测在一个 120MB 左右的中型项目上,完整构建从 40 多秒降到 10 秒以内,改代码后的增量构建基本在 3 到 5 秒。这个差异在 CI 里更明显,因为它直接决定了每次提交的流水线时长和构建机负载。

分层提取的写法有一个必须先验证的细节:EXTRACT出来的目录名和 Spring Boot 版本强绑定。Spring Boot 2.3 到 3.2 用-Djarmode=layertools,Spring Boot 3.3 起改成-Djarmode=tools,提取出来的目录结构也不同。启动类名同样变过:2.x 是org.springframework.boot.loader.JarLauncher,3.x 移到了org.springframework.boot.loader.launch.JarLauncher。写错了不会报“版本不对”,只会报ClassNotFoundException,然后容器悄无声息地退出。所以我的习惯是先本地跑一次java -Djarmode=layertools -jar app.jar extract,用ls看清目录名,再往 Dockerfile 里写。

2.3 时区、编码、启动类:几行容易被跳过但代价很高的配置

时区这一行,我见过太多项目根本没写。容器默认是 UTC,而你在代码里new Date()、日志里打时间戳、和数据库做时间比较,全都是按 UTC 走。表现出来就是:日志时间比北京时间慢 8 小时;定时任务在凌晨 8 点触发而不是凌晨 0 点;某张表里的create_time和业务表对不上,排查半天最后发现是时区。修的时候要注意两层:操作系统层用ln -snf /usr/share/zoneinfo/$TZ /etc/localtime加/etc/timezone文件;JVM 层用-Duser.timezone=Asia/Shanghai。只改一层有时候不够,尤其是某些老版本的日期库会直接读操作系统时区。

编码同样如此。LANG=C.UTF-8这一行建议加上,否则某些场景下文件名里的中文会变成问号,或者读配置文件时出现乱码。这个问题在本地几乎不可能复现,因为你的开发机默认就是 UTF-8。

ENTRYPOINT我用了["sh","-c","exec java $JAVA_OPTS ..."]这种写法,而不是直接["java","-jar",...]。原因是:直接写数组形式时,JAVA_OPTS这个环境变量不会被展开,你在docker run -e JAVA_OPTS=...里传的参数完全无效。改成sh -c之后变量能展开,而前面的exec保证 java 进程替换掉 shell 成为主进程,这样容器的信号才能直达 JVM。这个exec不加,docker stop时信号发给 shell,java 进程收不到,10 秒后被SIGKILL强杀,正在处理的事务直接断在半路。

注意:USER app这行放在COPY之后。反过来写的话,后续COPY会因为权限不足失败。切到非 root 用户之后,挂载进来的宿主机目录权限也要对应调整(容器内 uid 需对宿主机目录有读写权限),这是使用数据卷时最常见的一个障碍。

3. 从 build 到 run:每个参数都有它的位置

镜像构建只是第一步,运行时的参数决定这个容器能不能稳定活下去。这一段我把构建命令、运行命令、外部配置覆盖三件事讲透。

3.1 docker build 的缓存、tag 与构建上下文

构建命令本身很简单:

docker build -t demo-app:1.2.0 -t demo-app:latest .

但有两个细节要提醒。第一,-t打多个标签时,永远不要只依赖latest。容器化最值钱的能力是回滚,而回滚依赖可追溯的版本号。我习惯同时打两个标签:一个是版本号(最好带上提交号,比如demo-app:1.2.0-a3f9c1),一个是环境标签(demo-app:prod)。部署时用版本号 tag,排查时用提交号确认代码来源。

第二,构建上下文。docker build .末尾那个点会把当前目录整个打包发给 Docker 引擎,如果你的项目目录里有target/、logs/、node_modules/,这些都会被传过去,构建慢不说,还可能因为缓存失效被反复重传。加一个.dockerignore:

target/ logs/ *.log .git/ .idea/ node_modules/ files/

这个文件的价值在 CI 环境里尤其明显,因为它直接影响每次构建的文件传输时间和构建机的 IO 压力。写的时候注意别把target/app.jar一起忽略掉了,我见过为了“忽略 target”把整个目录排除,结果COPY找不到 jar 包直接构建失败的场面。

3.2 run 阶段的参数逐项说明

docker run -d --name demo-app \ --restart unless-stopped \ --init \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ -e JAVA_OPTS="-XX:MaxRAMPercentage=70 -Duser.timezone=Asia/Shanghai" \ -v /data/demo/config:/app/config:ro \ -v /data/demo/logs:/app/logs \ -v /data/demo/files:/app/files \ --memory 1g --memory-swap 1g --cpus 1.5 \ --log-opt max-size=50m --log-opt max-file=5 \ demo-app:1.2.0-a3f9c1

逐项说一下理由,这些理由决定了你是不是在给自己埋问题:

  • --restart unless-stopped:宿主机重启后自动拉起,但手工docker stop的容器不会被重新启动。always看起来更省事,可它会在你手工停掉容器排查问题时又把容器拉起来,反而添乱。
  • --init:给容器加一个极简的 init 进程,负责回收僵尸进程并把信号正确转发。Java 应用自己基本不 fork 子进程,但如果你的项目里调用了外部命令(比如导出 PDF 时调用的工具),这行能省掉一堆莫名其妙的资源泄漏。
  • -e SPRING_PROFILES_ACTIVE=prod:比在 Dockerfile 里写死 profile 好得多。同一个镜像,测试环境传test,生产传prod,镜像本身不用动。
  • -v ...:ro:配置文件挂载加只读,防止程序意外写回配置。这个细节在多次重启之后能救你一次。
  • --memory 1g --memory-swap 1g:限制容器内存,并且禁止用 swap。只写--memory不写--memory-swap时,swap 上限默认是内存的两倍,进程在超限时会先被换页到 swap,表现为响应时间突然飙升而不是干脆地 OOM,这种“慢死”比“快死”难查得多。
  • --cpus 1.5:限制 CPU 配额。不限制的话,一个死循环的定时任务能把宿主机拖垮,如果同一台机器上还跑着数据库,后果更严重。
  • --log-opt:日志轮转。Docker 默认的 json-file 驱动没有任何大小限制,一个高频打日志的应用能在两周内把宿主机磁盘写满,而写满之后的表现是“所有容器同时异常”,这种雪崩现场没有几个人第一次就能定位到日志文件上。

3.3 外部配置怎么覆盖 jar 内的 application.yml

这是新手最容易卡住的地方:application.yml打包在 jar 里,改配置难道要重新构建镜像?显然不是。Spring Boot 的配置文件加载有一套明确的优先级,从高到低大致是:命令行参数 → 环境变量 → 外部配置文件 → jar 内配置文件。实战中我用得最多的是两种。

第一种,挂载外部配置文件并指定加载位置。把配置放到宿主机的/data/demo/config/,然后用环境变量SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/config/让 Spring Boot 额外加载这个目录。注意这是“额外加载”,jar 内的默认配置依然生效,外部文件只需要写需要覆盖的那几项,这样升级时新增的默认配置不会被老的外部文件整个盖掉。

第二种,直接用环境变量覆盖单个配置项。规则是:配置项spring.datasource.url对应环境变量SPRING_DATASOURCE_URL,点换下划线、横线换下划线、全部大写。这种方式在编排文件里很直观:

environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: demo SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} SPRING_DATA_REDIS_HOST: redis SPRING_DATA_REDIS_PORT: "6379" LOGGING_LEVEL_COM_EXAMPLE: info

有一点要特别注意:环境变量的值一律是字符串。SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: 20在 compose 里不写引号可能被解析成数字,问题不大;但像SPRING_PROFILES_ACTIVE: 001这种会被解析成数字再转字符串,得到1,profile 就对不上了。我的习惯是所有值统一加引号,避免这类无从下手的偏差。

还有一类配置不适合走环境变量:比如一大段 JSON 格式的密钥文件、证书内容、复杂的对象结构。这类东西老老实实挂载文件,别硬塞进环境变量里折腾转义。

4. 容器里跑 JVM 的三道坎:内存、时间、信号

这三件事的共同点是:本地开发完全正常,一进容器就出问题,而且报错信息都不指向真正的原因。我带人做容器化的时候,这三块是必然会踩一遍的。

4.1 堆内存与容器限制的错位

早期 JVM 判断可用内存的方式是读宿主机的物理内存,于是出现过一个经典场景:容器限制 1GB,JVM 看到宿主机有 64GB,默认堆大小按四分之一算就是 16GB,容器一启动就被内核 OOM Killer 干掉,docker inspect里看到OOMKilled: true,而应用日志里什么都没有,因为进程是被瞬间杀掉的,来不及写日志。

JDK 8u191 和 JDK 10 之后默认开启容器感知(UseContainerSupport),这个问题基本消失,但仍有细节需要注意。第一,确认你的 JDK 版本足够新,JDK 8 的老版本仍然有这个坑。第二,不要用-Xmx写死一个绝对值,改用-XX:MaxRAMPercentage,这样调整容器内存限额时不需要同步改启动参数。比例我一般给 70% 到 75%:

容器限额 1024MB → 堆上限约 700~750MB 剩余 250~300MB 留给元空间、线程栈、直接内存、JIT 代码缓存、GC 结构

剩下的这 25% 不能省。Spring Boot 应用的堆外内存占用比很多人想象的大:Netty 的直接内存、NIO 的文件映射、每个线程约 1MB 的栈(线程池默认 200 个线程就是 200MB)、JIT 编译产物的代码缓存、还有元空间里加载的类。如果你的项目用了大量动态代理或者反射(很多框架都会),元空间也要几十到上百 MB。我遇到过把MaxRAMPercentage设成 90% 的服务,堆从没满过,但容器每周被 OOM 杀一次,原因就是堆外那部分没有余量。

启动参数我通常这样写:

JAVA_OPTS="-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=40 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError"

InitialRAMPercentage给 40% 而不是等比例,是为了让 JVM 启动时不必频繁扩容堆。ExitOnOutOfMemoryError这个参数值得加:堆内存耗尽时直接退出让容器重启,比留着一个半死不活、接口全超时的进程要好,至少--restart策略能把它救回来,而且重启行为会体现在监控上。

4.2 时间差 8 小时的真实影响

时区问题我在第 2 节提过配置方式,这里说一下它的真实表现,因为很多人分不清“日志时间不对”和“业务逻辑不对”。

最轻的表现是日志时间戳差 8 小时,排查时对着 UTC 时间心算,很容易搞错事件顺序。稍重一点的是定时任务:用@Scheduled(cron = "0 0 0 * * ?")写的每日统计,本来期望零点执行,在容器里变成早上 8 点执行,等到第二天运营发现数据不对,你回头改的时候还要重新补一天的数据。

最重的是数据一致性。如果数据库连接串里serverTimezone配的是 UTC,而 JVM 是 UTC,应用写入的时间是 UTC 时间;但另一个老服务连同一个库、JVM 是东八区,两边写进去的时间差了 8 小时,做时间范围查询时就会漏数据。这种问题往往在跨服务联调时才暴露,定位成本极高。

我的处理方式是把时区当成一个必须显式声明的配置项:镜像的TZ、JVM 的user.timezone、数据库连接串的serverTimezone,三处要么统一为Asia/Shanghai,要么统一为 UTC 并在展示层转换,绝对不能让它靠默认值。日志里的时间格式也建议在配置里显式指定带时区的格式,能省掉很多口舌。

4.3 信号与优雅停机:kill之后事务断在半路

这个坑最隐蔽,因为它平时不出问题,只在流量较大的时候暴露。典型的现场是:发版时执行docker stop,日志里出现Shutting down ExecutorService,但紧接着进程就没了,几笔正在处理的下单请求要么事务回滚、要么扣了库存没生成订单。

原因有两层。第一层是容器里的 1 号进程。如果ENTRYPOINT是["sh","-c","java -jar app.jar"],那么 1 号进程是 sh,java 是它的子进程。docker stop发送SIGTERM给 1 号进程,sh 默认不转发信号,java 收不到,10 秒后 Docker 发SIGKILL,一切戛然而止。解决办法就是第 2 节写的exec,让 java 直接成为 1 号进程。

第二层是 Spring Boot 本身没有开启优雅停机。从 2.3 开始支持,需要在配置里显式打开:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

打开之后,收到SIGTERM时应用会先停止接收新请求,等待正在处理的请求完成,最多等 30 秒,然后关闭线程池和数据源。注意这个 30 秒是 Spring 内部的等待上限,而 Docker 的默认等待时间是 10 秒,两者必须对齐:要么把docker stop -t 40显式指定,要么在 compose 里写stop_grace_period: 40s,让 Docker 的等待时间长于应用的自愈时间。顺序反了的话,应用还在优雅停机,Docker 已经把它强杀了,配置写了等于没写。

现场表现最可能的原因处理方式
停止时日志里没有优雅停机记录信号没到 JVMENTRYPOINT 加exec或--init
有停机日志但请求仍失败Docker 等待时间短于应用超时docker stop -t或stop_grace_period
停机耗时很长长连接未关闭、线程池未收敛检查连接池与自定义线程池的 shutdown 逻辑

5. 排错实录:容器起不来的几种典型现场

这一节我按“现象 → 排查动作 → 根因”的顺序写,因为真实的排错就是这么走的,直接给答案反而记不住。

5.1 从退出码反推问题方向

容器刚启动就退出,第一件事不是看业务日志,而是看退出码:

docker ps -a --filter name=demo-app docker inspect demo-app --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
退出码含义常见根因
1应用主动退出,抛了未捕获异常配置缺失、端口被占用、启动类找不到、依赖冲突
137被SIGKILL(128+9)内存超限被 OOM Killer 杀、docker stop超时强杀
143被SIGTERM(128+15)正常停止流程,配合优雅停机日志看是否符合预期
127命令不存在ENTRYPOINT里的启动类名写错、路径不对
0正常退出应用不是常驻型,main 方法执行完就结束了

退出码 1 配OOMKilled: true是最有迷惑性的组合:明明是内存问题,日志里却只有应用自己抛的异常。判断方法是看docker inspect里的OOMKilled字段,如果为true,先不管异常内容,直接去调内存参数和容器限额。而退出码 137 但OOMKilled为false,多半是docker stop等超时强杀,回头查优雅停机。

还有一种“看起来起来了但就是不健康”的情况:容器状态是Up,接口却一直 502。这时候docker logs可能什么错都没有,因为应用确实启动成功了,问题在网络层,往下看。

5.2 端口映射了却访问不通

按这个顺序查,基本三分钟内能定位:

# 1. 容器内部自己通不通 docker exec -it demo-app sh -c "curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/actuator/health" # 2. 端口到底监听在哪 docker exec -it demo-app sh -c "netstat -tlnp 2>/dev/null || ss -tlnp" # 3. 宿主机上的映射情况 docker port demo-app

三种典型结果对应三个方向。内部通、外部不通,多半是宿主机防火墙或者端口只监听了127.0.0.1——注意 Spring Boot 默认监听0.0.0.0,但如果你在配置里写过server.address=127.0.0.1,容器内自己访问没问题,外部就永远打不进去。内部也不通,说明应用没起来或者端口不是 8080,去看docker logs里实际的Tomcat started on port。docker port显示为空,说明-p参数漏了。

还有一个容易忽略的点:如果应用配置了server.servlet.context-path,那么健康检查地址要加上前缀,/actuator/health会变成/api/actuator/health,探活一直失败但服务其实是好的。

5.3 容器内连不上数据库

这是容器化之后最高频的一类问题,根源几乎都是网络命名空间隔离。

宿主机上配的jdbc:mysql://127.0.0.1:3306/demo,在容器里127.0.0.1指的是容器自己,那里没有 MySQL,所以连接被拒绝。解决方式分两种。

如果数据库也在 Docker 里,用 compose 的服务名做主机名,并且确保两个服务在同一个自定义网络里。注意links或者默认的bridge网络不支持服务名解析,只有自定义网络(compose 自动创建的那种)才有内建 DNS。所以我一般会显式写networks,避免默认网络带来的不确定性。

如果数据库在宿主机上,容器要访问宿主机需要特殊处理。Linux 下宿主机在容器视角里不是一个固定 IP,需要在启动时加--add-host=host.docker.internal:host-gateway,然后用host.docker.internal连接。这种方式能用,但我个人不太推荐长期这么干:一旦容器迁到别的机器,配置就得跟着改。更稳妥的做法是使用宿主机在局域网里的实际 IP,或者干脆把数据库也纳入编排。

报错信息本身也能缩小范围:Connection refused一般是网络可达但端口没开;Communications link failure后面带connect timed out是网络不通或被防火墙拦;Access denied for user是账号密码或来源限制(数据库的host字段不允许该网段);Unknown database是库名错。看到Public Key Retrieval is not allowed这种,是 MySQL 8 的认证插件问题,连接串加allowPublicKeyRetrieval=true&useSSL=false(仅限内网环境)。

5.4 现场诊断的几条命令

线上容器出问题时,我常用的就这几条,不依赖额外工具:

# 进程与 JVM 参数确认 docker exec -it demo-app jcmd 1 VM.flags # 线程栈,看卡在哪 docker exec -it demo-app jstack 1 > /tmp/thread.txt # 堆直方图,看谁占内存 docker exec -it demo-app jcmd 1 GC.class_histogram | head -30 # 实时资源 docker stats demo-app --no-stream

jcmd 1里的1是容器内 java 进程的 PID。因为我们用exec让 java 成了 1 号进程,这个 PID 是确定的,这也是坚持用exec的一个额外好处。如果容器里跑的是 JRE 镜像没有这些工具,就临时用 debug 镜像替换,或者从宿主机通过nsenter进入容器的命名空间操作。

6. 走向生产:编排、健康检查与资源限额

单机docker run跑一个服务够用,但真实项目通常是应用加数据库加缓存,这时候手写一堆docker run会迅速失控:启动顺序、网络、密码传递、日志配置全都散落在 shell 历史里。用 compose 把这些固化下来,是成本最低的进步。

6.1 一份可以直接改的 compose 配置

services: app: image: demo-app:1.2.0-a3f9c1 container_name: demo-app restart: unless-stopped init: true ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: "prod" SPRING_CONFIG_ADDITIONAL_LOCATION: "file:/app/config/" SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" SPRING_DATASOURCE_USERNAME: "demo" SPRING_DATASOURCE_PASSWORD: "${DB_PASSWORD}" JAVA_OPTS: "-XX:MaxRAMPercentage=70 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" volumes: - ./config:/app/config:ro - ./logs:/app/logs - ./files:/app/files deploy: resources: limits: memory: 1g cpus: "1.5" healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/actuator/health"] interval: 15s timeout: 3s retries: 3 start_period: 60s stop_grace_period: 40s logging: driver: json-file options: max-size: "50m" max-file: "5" networks: - backend mysql: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" MYSQL_DATABASE: "demo" TZ: "Asia/Shanghai" volumes: - ./mysql-data:/var/lib/mysql - ./mysql-conf:/etc/mysql/conf.d:ro networks: - backend networks: backend: driver: bridge

几个实践要点。DB_PASSWORD这种敏感值放在同目录的.env文件里,.env不进版本库,这样 compose 文件本身可以提交、可以 review。start_period: 60s是给 Spring Boot 冷启动留的缓冲,不设的话,探活从容器一启动就开始计数,JVM 还在加载 Bean 的阶段就已经连续失败三次被判定为不健康,某些部署方式会直接把它重启掉,形成“启动中被杀 → 重启 → 又被杀”的死循环。我遇到过启动需要 90 秒的项目,start_period设小了就是这个症状。

6.2 健康检查到底在检查什么

healthcheck的作用不是“让服务更健康”,而是给外部一个判断依据:这个容器现在能不能接流量。所以检查的地址要和真实可用性对齐。

如果只写curl -f http://127.0.0.1:8080/,那么只要 Tomcat 端口开了就返回 200,哪怕数据库连不上、缓存全挂,健康检查依然是通过的。更合理的做法是用 Actuator 的健康端点,并且把关键依赖纳入检查:

management: endpoint: health: show-details: never probes: enabled: true health: db: enabled: true redis: enabled: true

show-details: never是为了避免把数据库地址、版本号这些信息暴露在无鉴权的端点上。如果健康端点要对外暴露给探活组件,记得在安全配置里放行/actuator/health/**,但只放行这一个路径,别图省事放行整个/actuator。

probes.enabled: true会额外提供/actuator/health/liveness和/actuator/health/readiness。区别在于:存活探针只关心进程是否还活着(挂了就重启),就绪探针关心是否能接流量(比如启动未完成、依赖未就绪时不接流量)。在单机 compose 场景里这个区分意义不大,但如果你后面要迁移到编排平台,提前把这两个端点分好,迁移时几乎不用改配置。

要注意镜像里得有curl。eclipse-temurin:17-jre-jammy默认不一定带,需要在 Dockerfile 里装:

RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*

不想装curl的话,也可以用bash的/dev/tcp做纯端口探测,但那样就只能检查端口,检查不了应用层的真实状态,取舍看你更在意镜像体积还是探活准确度。

6.3 资源限额与日志轮转的配套关系

限额和日志这两件事经常被分开讨论,实际上它们是绑在一起的。容器内存吃紧时,最先出问题的往往是写日志这一步:磁盘空间不足导致日志写入阻塞,线程卡在 IO 上,请求堆积,内存进一步上涨,最后 OOM。所以限制容器内存的同时,必须给日志设定上限。

主机层面的日志轮转已经在 compose 里配了(max-size+max-file,50MB × 5,单个容器最多 250MB)。应用内部如果再写文件日志,也要在logback-spring.xml里配RollingFileAppender,设置maxFileSize和maxHistory。我的原则是:容器里优先只往标准输出打,由 Docker 统一收集和轮转,应用自己不管文件;只有在需要保留更长时间的业务审计日志时,才写到挂载出来的目录,并且必须配轮转。

CPU 限额有个容易搞错的点:--cpus 1.5是配额而不是绑核。JVM 看到availableProcessors可能是宿主机核数(取决于 JDK 版本和 cgroup 版本),于是 GC 线程数、ForkJoinPool 的并行度都会按宿主机核数来配,配出来一大堆线程在 1.5 核的配额里互相抢,GC 停顿反而变长。JDK 10 以后基本能正确识别 cgroup 配额,如果你还在用老版本,可以用-XX:ActiveProcessorCount=2显式指定。这个参数在容器化环境里被低估得很厉害,我见过把 GC 停顿从 800ms 压到 120ms 的案例。

7. 我在实际项目里反复验证过的几个习惯

写完上面这些,最后说几条不太容易在文档里找到、但确实影响日常效率的做法。

第一条,镜像构建放进 CI,本地只做验证。本地手工docker build的结果依赖你本机的 Maven 缓存、JDK 版本、甚至系统时间,同一个提交在两台机器上打出来的镜像可能不一样。让 CI 统一构建、统一打 tag、统一推到镜像仓库,本地只负责拉下来跑一遍验证。这样可以彻底消除“我本地是好的”这类对话。

第二条,保留一个“最小可复现”的排查镜像。我的做法是给每个服务额外打一个debugtag,内容和正式镜像一样,只是基础镜像换成带 JDK 的版本、并且不切换非 root 用户。线上出问题时把容器换成 debug tag,用完再换回来。比起临时docker exec却发现容器里连jstack都没有,这个准备成本几乎为零,收益很高。

第三条,升级 Spring Boot 版本时重新检查启动类名。前面提过,3.x 把JarLauncher挪了包路径。我经历过一次框架大版本升级,Dockerfile 没跟着改,镜像构建成功、推送成功、部署时容器秒退,docker logs里连一行 Spring 的日志都没有——因为类加载器在 main 之前就失败了。现在我的做法是在 Dockerfile 里显式写一个RUN java -Djarmode=layertools -jar app.jar extract && ls,构建阶段就能看到目录结构对不对,把错误拦在构建时而不是部署时。

第四条,给每个容器都设start_period,并且留足余量。启动慢的根源往往不是应用本身,而是首次连接数据库建连接池、加载本地缓存、预热线程池。这些在本地有缓存所以很快,容器里是全新环境,慢个二三十秒很正常。start_period设小了带来的问题比不设更大,因为它会主动把正在启动的容器判成不健康然后重启,形成循环。

第五条,把“停机”当成一个需要测试的功能。发版前专门做一次docker stop并观察日志:有没有优雅停机的记录、正在处理的请求有没有正常返回、耗时多久。这一步花五分钟,能避免上线当晚的一堆事务回滚。我在几个项目里都把这个动作写进了发版清单,成效比事后排查明显得多。

这些内容后续还能往两个方向延展:一是把镜像做得更小更快,比如引入 jlink 裁剪运行时;二是把单机 compose 平滑迁移到集群编排,把配置、密钥、探针都交给平台管。但在这两步之前,先把一个容器的内存、时间、信号、日志这四件事管明白,收益远比追新工具大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询