1. 为什么“jar包直接扔服务器”越来越不够用了
刚入行那几年,我部署一个 Java 后端服务的标准动作就是:本地mvn package打出 jar,scp传到服务器,nohup java -jar xxx.jar &一挂,收工。这套流程在单机、单服务、低并发的年代确实够用,甚至到今天很多内部小系统还在这么跑。但只要你经历过一次“服务器上 JDK 版本和本地不一致导致启动报错”“换台机器忘了装某个系统依赖”“升级时把旧 jar 覆盖了想回滚却发现没备份”这类事故,就会明白:jar 包本身没问题,问题出在“运行环境没有被一起打包”。
Docker 部署 jar 包,解决的正是这个核心痛点。它把“Java 运行时 + 你的 jar + 启动参数 + 系统时区 + 字体库 + 依赖库”这一整套东西固化成一个镜像,镜像在哪儿跑,环境就长什么样。你本地测通了,测试环境、生产环境基本就是同一份东西,不会再出现“在我机器上是好的”这种经典扯皮。
这篇文章面向的是这样一类人:手里有一个能跑起来的 Spring Boot(或其他 Java)jar 包,服务器上已经装了 Docker,但不确定镜像该怎么写、jar 该怎么放、端口和日志怎么配、内存怎么限制、出问题怎么排查。我会从镜像构建思路讲到 Dockerfile 逐行拆解,再到实际部署、参数调优、常见故障排查,把我在多个项目里踩过的坑一次性讲清楚。核心关键词就是 jar 包、Docker、镜像构建、容器部署、Dockerfile,全文围绕这几个点展开,读完你应该能独立把一个 jar 包稳稳当当地跑在 Docker 里。
先说一个结论性的判断:用 Docker 跑 jar,收益最大的不是“部署”这个动作本身,而是“环境一致性”和“可回滚性”。理解了这一点,后面所有的设计取舍你都能自己想明白。
2. 部署方案的整体设计与选型思路
2.1 三种常见方案对比:别一上来就写 Dockerfile
很多人一提到“jar 包 Docker 部署”,第一反应就是打开编辑器写 Dockerfile。其实在动手之前,先想清楚你要走哪条路。我梳理了三种主流做法,各有适用场景。
| 方案 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 基础镜像 + COPY jar | 基于 JDK/JRE 镜像,把 jar 拷进去 | 简单直接,可控性最强 | 镜像偏大,需自己管基础镜像 | 绝大多数自建服务 |
| 多阶段构建 | 构建阶段编译,运行阶段只放 jar | 镜像小,构建产物干净 | Dockerfile 稍复杂 | 需要在容器内编译的项目 |
| 挂载 jar 到通用镜像 | 镜像只装 JRE,jar 用 volume 挂载 | 换 jar 不用重建镜像 | 环境与 jar 分离,回滚麻烦 | 频繁换包、调试阶段 |
我的建议很明确:生产环境优先选第一种或第二种,调试阶段可以用第三种。原因在于,Docker 最大的价值就是“镜像即产物”,如果你把 jar 挂在外面,那镜像就退化成了一个“JRE 容器”,环境一致性这个核心优势就打了折扣。每次换 jar 你都得重新确认挂载路径、权限、版本,反而更容易出错。
多阶段构建适合什么情况?适合你的构建环境不统一、CI 机器上不想装一堆编译工具的场景。比如构建阶段用 Maven 镜像编译,运行阶段只拷贝target/*.jar,最终镜像能小一半以上。但如果你的 jar 是 CI 流水线已经打好的,直接 COPY 更省事。
2.2 基础镜像怎么选:JDK 还是 JRE,alpine 还是 slim
这是第二个关键决策。基础镜像选错,后面全是坑。
JDK vs JRE:运行 jar 只需要 JRE,但很多排查工具(比如jstack、jmap、jcmd)在 JRE 里是没有的。生产环境如果追求极致体积,用 JRE;如果希望出问题时能进容器抓线程栈、看堆内存,用 JDK。我个人的习惯是生产用 JDK 的 slim 版本,体积和排查能力兼顾。
alpine vs slim vs 完整版:alpine 镜像极小(几十 MB),但它用的是 musl libc 而不是 glibc,某些依赖本地库的 Java 应用(比如用到 JNI、字体渲染、图像处理的)会出问题。我踩过一次坑:一个导出 PDF 的服务在 alpine 镜像里中文全是方块,因为缺字体库。后来换成eclipse-temurin:17-jre-jammy这类基于 Ubuntu 的镜像就正常了。所以如果你的应用涉及字体、图像、本地库,别图省事用 alpine。
版本选择:基础镜像的 JDK 版本必须和你编译 jar 的版本匹配或更高。用 JDK 8 编译的 jar 跑在 JDK 17 上,大概率会因为模块化、反射限制等问题报错。稳妥做法是编译用什么版本,运行就用什么版本。
2.3 镜像分层与构建缓存:让每次构建快起来
Docker 镜像是分层的,每条指令一层,层会被缓存。这个特性用好了,构建速度能快很多。关键原则是:变化频率低的内容放前面,变化频率高的放后面。
对 jar 部署来说,jar 包是每次构建都会变的,所以它应该放在 Dockerfile 的最后。基础镜像、时区设置、字体安装这些几乎不变的操作放前面。这样你改一次代码重新构建时,前面几层直接命中缓存,只有 COPY jar 那一层重新执行,几秒钟就完事。
反过来,如果你把COPY . .放在最前面,那每次改一行代码,后面所有层全部失效,构建时间直接翻倍。这个细节很多人不注意,但在 CI 频繁构建的场景下,差距非常明显。
3. Dockerfile 逐行拆解与核心参数详解
3.1 一份可直接抄的 Dockerfile 模板
先上模板,再逐行讲为什么这么写。这是一个基于 JDK 17 的 Spring Boot jar 部署模板,其他 Java 项目改改路径就能用。
FROM eclipse-temurin:17-jre-jammy LABEL maintainer="ops-team" \ app="demo-service" \ version="1.0.0" ENV TZ=Asia/Shanghai \ JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200" \ SPRING_PROFILES_ACTIVE=prod RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY target/demo-service.jar /app/app.jar EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app/app.jar"]这份模板看起来简单,但每一行都有讲究。下面拆开说。
3.2 FROM 与基础镜像:版本锁定是底线
FROM eclipse-temurin:17-jre-jammy这一行,我强烈建议不要用latest标签。用latest意味着你今天构建的镜像和三个月后构建的镜像可能基于不同的基础环境,某天突然构建出来的镜像跑不起来,排查半天发现是基础镜像悄悄升级了。锁定具体版本(比如17.0.9_9-jre-jammy)虽然看起来“不追新”,但换来的是可复现性,这在生产环境里比什么都重要。
如果你公司有内部的基础镜像仓库,优先用内部的,因为拉取速度快、可控。没有的话,用官方维护的镜像,别用来路不明的第三方镜像,安全风险太大。
3.3 ENV 环境变量:时区和 JVM 参数一起管
ENV这行我放了三个东西:时区、JVM 参数、Spring Profile。为什么放这里而不是写在启动命令里?
时区必须设,否则容器默认是 UTC,你日志里的时间会比北京时间少 8 小时,排查问题时对时间线能对到崩溃。设置时区有两种方式,一种是装tzdata包再设,一种是直接软链/etc/localtime。上面模板用的是软链方式,前提是基础镜像里已经有 zoneinfo 数据(Ubuntu 系镜像一般都有)。
JVM 参数放 ENV 的好处是,你可以在docker run时用-e JAVA_OPTS="..."覆盖它,不用改 Dockerfile 重新构建。这在调整堆内存时特别方便。关于参数怎么算,后面单独讲。
Spring Profile 放 ENV 也是同理,不同环境用不同的 profile,启动时覆盖即可。
3.4 COPY 与 WORKDIR:路径和权限的坑
WORKDIR /app设定了工作目录,后面的相对路径都基于它。COPY target/demo-service.jar /app/app.jar把 jar 拷进去,并且重命名为 app.jar。为什么要重命名?因为 Maven 打出来的 jar 通常带版本号(demo-service-1.0.0.jar),每次升级版本号都变,Dockerfile 就得跟着改。重命名成固定的app.jar,Dockerfile 就永远不用动,升级只是替换源文件。
这里有个权限坑:默认 COPY 进去的文件属主是 root。如果你的容器用非 root 用户运行(安全最佳实践),需要加--chown参数,比如COPY --chown=1000:1000 target/demo-service.jar /app/app.jar。否则非 root 用户读不了 jar,启动直接失败。
3.5 EXPOSE 与 ENTRYPOINT:端口声明和启动方式
EXPOSE 8080只是声明容器打算用哪个端口,它不会真的把端口映射出去,映射还得靠docker run -p。它的作用是给使用者和编排工具一个提示。别以为写了 EXPOSE 就能从外部访问了,这是新手最常见的误解。
ENTRYPOINT用sh -c包一层,是为了让$JAVA_OPTS这个环境变量能被解析。如果你写成ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", ...]这种 exec 形式,变量不会被展开,java 会把$JAVA_OPTS当成一个非法参数报错。用 shell 形式(sh -c)才能让变量生效。代价是 PID 1 变成了 sh 而不是 java,信号传递会有点问题,但对大多数服务影响不大。追求完美的可以用exec java ...让 java 接管 PID 1。
-Djava.security.egd=file:/dev/./urandom这个参数是加速随机数生成的,能明显缩短 Spring Boot 启动时间,尤其是低配服务器上。这个技巧知道的人不多,但实测有效。
3.6 JVM 内存参数怎么算:别拍脑袋写 -Xmx
-Xmx1024m不是随便写的。容器里的 JVM 内存要分几块算:
- 堆内存:
-Xms初始堆、-Xmx最大堆 - 元空间:
-XX:MaxMetaspaceSize,默认不限制,建议设个上限比如 256m - 线程栈:每个线程约 1MB,线程多了也是笔开销
- 直接内存:NIO、Netty 会用
- JVM 自身开销:几百 MB
容器总内存 = 堆 + 元空间 + 线程栈 + 直接内存 + JVM 开销 + 系统预留。经验公式是:-Xmx设为容器内存限制的 50%~70%。比如容器限 2G,-Xmx设 1G~1.4G 比较稳。设太高,容器容易被 OOM Killer 干掉;设太低,频繁 GC 影响性能。
JDK 10 以后有个-XX:MaxRAMPercentage参数,可以按容器内存百分比自动算堆大小,比如-XX:MaxRAMPercentage=70.0。这个在容器内存经常调整的场景下很方便,不用每次改-Xmx。但要注意,它依赖容器正确识别内存限制,老版本 JDK 在容器里可能读不到 cgroup 限制,需要加-XX:+UseContainerSupport(JDK 8u191 之后默认开启)。
4. 从零到跑起来的完整实操流程
4.1 构建镜像:命令和上下文
假设你的项目结构是标准的 Maven 项目,jar 在target/下。在项目根目录(也就是 Dockerfile 所在目录)执行:
docker build -t demo-service:1.0.0 .最后的.是构建上下文,Docker 会把这个目录下的文件打包发给 Docker 守护进程。这里有个大坑:如果你的项目根目录下有node_modules、.git、target里一堆中间产物,构建上下文会非常大,传输慢、构建慢。解决办法是加一个.dockerignore文件:
.git .idea *.iml target/classes target/test-classes node_modules *.log注意,不要把target/*.jar也忽略掉,否则 COPY 找不到 jar。只忽略中间产物就行。
构建完成后用docker images看一眼,镜像大小应该在 300MB 左右(JDK 17 基础镜像本身就 200 多 MB)。如果超过 1G,八成是 COPY 了不该 COPY 的东西。
4.2 启动容器:端口、挂载、环境变量
构建好镜像,启动命令长这样:
docker run -d \ --name demo-service \ -p 8080:8080 \ -e JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC" \ -e SPRING_PROFILES_ACTIVE=prod \ -v /data/logs/demo:/app/logs \ -v /etc/localtime:/etc/localtime:ro \ --restart unless-stopped \ --memory 2g \ --cpus 2 \ demo-service:1.0.0逐项解释:
-d后台运行--name给容器起名,方便后续docker logs、docker stop操作-p 8080:8080把宿主机 8080 映射到容器 8080-e覆盖环境变量,这里覆盖了 JVM 参数和 profile-v挂载日志目录,强烈建议挂出来,否则容器删了日志就没了--restart unless-stopped让容器异常退出后自动重启,服务器重启也能自动拉起--memory 2g限制容器内存,配合前面的-Xmx1024m使用--cpus 2限制 CPU,防止单个容器吃满整机
关于日志挂载,还有个细节:Spring Boot 默认日志输出到控制台,docker logs能看到。但如果你配置了输出到文件,就得挂载对应目录。我一般两者都保留,控制台日志方便docker logs快速看,文件日志方便长期留存和分析。
4.3 验证部署:三步确认服务真的活了
容器起来不代表服务能用。我习惯按三步验证:
第一步,看容器状态:docker ps看容器是不是Up状态。如果是Restarting或者Exited,说明启动就失败了,直接docker logs demo-service看报错。
第二步,看应用日志:docker logs -f --tail 100 demo-service,确认看到 Spring Boot 的启动完成标志(比如Started DemoApplication in X seconds)。如果卡在某一步不动,多半是连数据库、连中间件超时。
第三步,探活接口:curl http://localhost:8080/actuator/health(如果集成了 actuator),返回{"status":"UP"}才算真正可用。没有 actuator 的话,curl 一个业务接口看返回。
这三步走完,基本能确认部署成功。别小看这个流程,我见过太多人docker ps看到 Up 就以为完事了,结果服务根本没起来。
4.4 升级与回滚:镜像 tag 管理是核心
升级服务的正确姿势不是docker stop再docker run新容器,而是用新 tag 构建新镜像,起新容器,验证通过后再停旧容器。这样出问题能秒回滚。
具体做法:每次构建用版本号或 git commit 短哈希做 tag,比如demo-service:1.0.1、demo-service:a3f9c2e。升级时:
docker build -t demo-service:1.0.1 . docker run -d --name demo-service-new -p 8081:8080 ... demo-service:1.0.1 # 验证 8081 正常后 docker stop demo-service && docker rm demo-service docker rename demo-service-new demo-service回滚就是把旧 tag 的镜像重新起一个容器。前提是你别把旧镜像删了。我一般保留最近 5 个版本的镜像,更早的定期清理。
如果用了 Nginx 或网关做前置,升级可以更平滑:新容器起来后,改网关配置指向新端口,reload 网关,再停旧容器。整个过程用户无感知。
5. 常见问题与排查技巧实录
5.1 容器启动就退出:先看日志再看退出码
容器Exited是最常见的问题。排查顺序:
docker logs <容器名>看应用报错docker inspect <容器名> | grep -i exitcode看退出码- 退出码 1 一般是应用异常,退出码 137 是被 OOM Killer 杀了,退出码 143 是收到 SIGTERM 正常停止
退出码 137 特别常见,就是内存超了。这时候要么调大--memory,要么调小-Xmx。别只调一个,两个要匹配。
5.2 中文乱码和时区不对:字体和 locale 的坑
日志里中文变问号、时间差 8 小时,是两类问题。
时间差 8 小时:容器时区没设对。检查docker exec <容器名> date,如果显示 UTC,就是时区没生效。用前面模板里的软链方式,或者启动时挂载-v /etc/localtime:/etc/localtime:ro。
中文乱码:如果是日志输出乱码,检查 JVM 的file.encoding,加-Dfile.encoding=UTF-8。如果是生成 PDF、图片里的中文变方块,那是缺字体库,需要在 Dockerfile 里装中文字体,比如RUN apt-get update && apt-get install -y fonts-noto-cjk。这个坑我在导出报表的服务上踩过,排查了大半天。
5.3 端口映射了却访问不通:四个检查点
-p 8080:8080写了,外面却连不上,按这个顺序查:
| 检查点 | 命令 | 说明 |
|---|---|---|
| 容器内服务是否监听 | docker exec <容器名> netstat -tlnp | 确认 java 进程在监听 8080 |
| 应用绑定地址 | 看配置文件 | 绑定127.0.0.1的话容器外访问不了,要绑0.0.0.0 |
| 宿主机防火墙 | firewall-cmd --list-ports | 端口没放行 |
| 云服务器安全组 | 控制台查看 | 安全组没开对应端口 |
其中应用绑定127.0.0.1是最隐蔽的坑。Spring Boot 默认绑0.0.0.0,但如果你在配置文件里写了server.address=127.0.0.1,容器内自己访问没问题,容器外就死活连不上。这个我排查过一次,最后发现是配置文件里一行不起眼的设置。
5.4 内存持续增长被 OOM:堆dump和监控
容器跑几天被 OOM Killer 干掉,退出码 137。这种问题不能只靠调大内存糊弄,得找到内存泄漏点。
第一步,让 JVM 在 OOM 时自动 dump 堆:加参数-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/heapdump.hprof,并把/app/logs挂载出来。这样下次 OOM 时能拿到堆快照。
第二步,用jmap或 MAT 工具分析 dump 文件,找占用内存最多的对象。
第三步,如果暂时定位不了,先用-XX:MaxRAMPercentage把堆控制住,配合--memory留足余量,避免容器被杀。同时加上监控,用docker stats观察内存曲线,或者接入 Prometheus 之类的监控系统。
5.5 常见问题速查表
| 现象 | 可能原因 | 快速排查 |
|---|---|---|
| 容器启动即退出 | 应用异常 / 内存不足 | docker logs+ 退出码 |
| 端口访问不通 | 绑定地址 / 防火墙 / 安全组 | 容器内 netstat + 宿主机防火墙 |
| 中文乱码 | 缺字体 / 编码不对 | 装字体 +-Dfile.encoding=UTF-8 |
| 时间差 8 小时 | 时区未设置 | 检查容器内date |
| 内存被 OOM | 堆设置过大 / 内存泄漏 | 调-Xmx+ dump 分析 |
| 构建镜像特别慢 | 上下文太大 | 加.dockerignore |
| 升级后无法回滚 | 旧镜像被删 | 保留最近几个版本 tag |
6. 生产环境进阶:让部署更稳的几个实践
6.1 用非 root 用户运行容器
默认容器里是 root,一旦应用被攻破,攻击者就是 root 权限,风险很大。正确做法是在 Dockerfile 里创建普通用户并切换:
RUN groupadd -r appuser && useradd -r -g appuser appuser COPY --chown=appuser:appuser target/demo-service.jar /app/app.jar USER appuser注意COPY要加--chown,否则文件属主还是 root,普通用户读不了。这个改动很小,但安全收益很大,值得每个生产镜像都加上。
6.2 健康检查:让编排工具知道服务活没活
Docker 支持健康检查,配合--restart或编排工具能实现“不健康自动重启”:
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1--start-period=60s是给应用启动留的时间,启动期间健康检查失败不算数。这个参数很重要,不设的话应用还没启动完就被判定不健康,容器会被反复重启,陷入死循环。
6.3 日志驱动配置:别让日志撑爆磁盘
Docker 默认的日志驱动会把容器标准输出写到宿主机文件里,默认不限制大小。一个疯狂打日志的服务,几天就能把磁盘写满。启动时加日志限制:
docker run ... \ --log-driver json-file \ --log-opt max-size=100m \ --log-opt max-file=5 \ demo-service:1.0.0这样单个日志文件最大 100MB,最多保留 5 个,总共 500MB 封顶。这个配置我建议所有生产容器都加上,血的教训。
6.4 镜像仓库与 CI 集成:从手动到自动
手动docker build只适合初期。项目稳定后,应该把构建和推送集成到 CI 流水线:代码提交触发构建,构建成功打 tag 推送到镜像仓库,部署时从仓库拉取。这样每次部署的镜像都有据可查,回滚就是拉旧 tag。
镜像仓库用公司内部的 Harbor 或云厂商的容器镜像服务都行。关键是镜像 tag 要有意义,用 git commit 哈希或语义化版本号,别用latest。latest在生产环境是灾难,因为你永远不知道它指向哪个版本。
7. 我在实际部署中的几点体会
踩了这么多坑,有几个体会是反复验证过的。第一,基础镜像的版本一定要锁死,别图省事用latest,可复现性比追新重要得多。第二,JVM 内存参数和容器内存限制必须匹配着设,只设一个等于没设,OOM 该来还是来。第三,日志一定要挂载出来并限制大小,容器删了日志没了、磁盘被日志写满,这两件事我都经历过,都是可以提前避免的。
还有一个容易被忽略的点:别在容器里存任何需要持久化的数据。容器是无状态的,删了就没了。数据库、上传文件、日志,统统挂载到宿主机或外部存储。把容器当成“一次性的运行单元”,这个思维转变过来,很多设计决策就顺了。
最后分享一个小技巧:如果你不确定某个 JVM 参数在容器里生效没有,进容器执行java -XX:+PrintFlagsFinal -version | grep 参数名,能直接看到最终生效值。比翻文档猜要靠谱得多。部署这件事,能验证的就别猜。