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 | 约 250MB | JDK 与 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 内部按变更频率拆成四层:
dependencies/:所有 release 版本的第三方依赖,几乎不变。spring-boot-loader/:Spring Boot 的启动引导类,跟着框架版本走。snapshot-dependencies/:SNAPSHOT 依赖,内部联调阶段常变。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 已经把它强杀了,配置写了等于没写。
| 现场表现 | 最可能的原因 | 处理方式 |
|---|---|---|
| 停止时日志里没有优雅停机记录 | 信号没到 JVM | ENTRYPOINT 加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-streamjcmd 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: trueshow-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 平滑迁移到集群编排,把配置、密钥、探针都交给平台管。但在这两步之前,先把一个容器的内存、时间、信号、日志这四件事管明白,收益远比追新工具大得多。