“我机器上能跑啊”,这句话我在Java后端开发这行当里听了太多年了。换台服务器部署、给测试环境重新拉一套依赖、帮新同事把本地Java环境配起来,看上去都是小事,但JDK版本对不上、Maven仓库没配全、MySQL实例密码不一致,任何一个环节出问题,就是几小时的排查。Docker出现以后,Java部署这件事终于从“反复装修房子”变成了“直接搬活人进去住”。这篇内容我从零开始,把怎么理解Docker、怎么装环境、怎么写Dockerfile、怎么用docker-compose把Java服务连同MySQL、Redis一起编排起来,以及上线前那些坑都过一遍,适合刚接触容器化的Java开发,也适合正在规划项目交付部署方案的团队参考。
1. 为什么非要用Docker:Java部署的痛我替你总结好了
1.1 “我这台机器能跑”背后的环境不一致困境
Java应用天生依赖环境。JVM用8还是17,Spring Boot打的jar包和依赖版本,配置里连的数据库地址,甚至Linux系统里某个glibc版本,都会影响服务能不能正常起来。在传统部署方式下,开发、测试、生产三套环境的差异会被一次次放大:开发机上是Windows,测试服务器是CentOS 7,生产又是某云厂商的镜像,结果同一个jar包,在三个环境里跑出三种行为。
最典型的是数据库版本不一致。本地MySQL 5.7跑得好好的,到服务器上一看是MySQL 8.0,一些SQL查询结果变了,编码排序方式也不一样,于是你开始怀疑代码、怀疑配置、怀疑人生。这类问题本质上不是代码逻辑问题,而是环境没有和代码一起被“固化”下来。代码可以交给Git管起来,环境却一直是手工维护的状态,谁改了什么没人完全清楚。
1.2 传统部署流程每一步都是重复劳动
我们过去上线一个Java项目,大致是这么一套流程:
- 准备服务器:安装JDK,配置JAVA_HOME与PATH环境变量。
- 安装中间件:MySQL、Redis、Nginx,逐个下载、解压、配置开机自启。
- 上传构建产物:把本地打好的jar或war包用ftp、scp传到服务器。
- 编写启动脚本:处理PID文件、日志输出、JVM参数,有的还要注册成systemd服务。
- 升级回滚:停旧包、备份旧包、启新包,一旦启动失败再切回旧包。
单机做一次倒还好,但如果你有5套环境,就要把安装JDK、配置中间件这套体力活重复5遍。更麻烦的是,每次环境升级或补丁,都是一次新的不确定性。时间久了,服务器上可能残留多个JDK版本,不同的中间件实例,没人能准确说出这套环境到底是怎么搭起来的。
1.3 Docker给出的解法:把环境一并打包
Docker的核心思路,是把应用和它运行所需要的环境一起打包成一个镜像。这个镜像可以在任何装了Docker的机器上跑出几乎一致的行为。容器本身很轻,不像虚拟机一样要模拟一整套硬件,它直接共享宿主机的内核,启动一个Java容器通常只需要几百毫秒到一两秒。
如果还用搬家来类比:传统部署是每次搬家都要把家具拆了重新组装,换个城市还得重新买螺丝刀;Docker是把整个房间打包带走,到了新地方直接打开箱子就能住。你把jar包、JVM、依赖、配置文件打成一个镜像,到哪都能一键启动。这个过程不需要你在新机器上重新安装JDK、重新配环境变量,CI/CD流水线也因此简单了很多,只要 push 一个镜像,运行环境就和开发时完全对齐。
2. 先搞懂这四个概念,后面所有操作都不绕了
2.1 镜像和容器:程序与进程的关系
很多人一上来就翻部署教程,结果被“镜像”“容器”这两个词绕晕。我的理解方式是这样的:镜像是一个只读的模板,里面包含了文件系统、依赖、JVM和你的jar包;容器是镜像的一个运行实例,相当于镜像被启动后变成的那个进程。镜像可以同时启动多个容器,每个容器相互隔离,这很像“类与对象”的关系——镜像就是类,容器就是对象。
容器运行期间产生的临时文件、日志、数据,默认都写在容器的可写层里,一旦容器被删除,这些内容也会跟着消失。这也是后面为什么要引入数据卷的原因。镜像本身是分层保存的,拉取时如果本地已经有相同的底层宿层镜像,往往只需要下载差异部分,这也是Docker比虚拟机部署高效的原因之一。
2.2 仓库与镜像拉取:Docker Hub 相当于Maven中央仓库
Docker镜像的下载靠仓库来分发,最常用的是Docker Hub,你可以把它理解为“镜像的Maven中央仓库”。当你执行docker pull mysql:8.0,Docker就会从Docker Hub把MySQL 8.0镜像拉到本地。Java项目里有Maven坐标,Docker则用“仓库名:标签”来唯一定位一个镜像,比如eclipse-temurin:17-jre,冒号前面是仓库名和镜像名,冒号后面是标签。
在公司内部,团队一般会搭建私有镜像仓库,比如Harbor,用于统一管理和分发内部镜像,避免所有人都直接依赖外网Docker Hub,也便于做安全扫描。理解了仓库的含义,就不会对docker push、docker tag这些命令感到陌生,它们本质上是给镜像打标签和上传到仓库。
2.3 Dockerfile:容器的施工图纸
Dockerfile是一个文本文件,里面的每条指令告诉Docker怎么一步步搭建镜像。核心指令就那么几个:
- FROM:指定基础镜像,比如
eclipse-temurin:17-jre。 - WORKDIR:设置工作目录。
- COPY:把文件复制进镜像。
- RUN:在构建镜像时执行命令,比如安装依赖或执行打包。
- CMD / ENTRYPOINT:指定容器启动时的命令。
你可以把Dockerfile理解成建筑图纸,图纸画好了,执行docker build就按图施工,最终得到一个可运行的镜像。好记的方式是:Dockerfile描述“怎么做出来”,容器启动时执行的命令描述“怎么跑起来”。
2.4 和虚拟机的本质区别:共享内核 vs 模拟硬件
虚拟机装的是完整操作系统,里面有一个Guest OS,底层要模拟CPU、内存、硬盘这些硬件,所以镜像动辄几个GB,启动速度以分钟计。容器则直接复用宿主机的内核,镜像里只包含应用和它的运行依赖,体积通常在几十到几百MB,启动速度是秒级。
但这并不意味着容器万能。因为容器共享宿主机内核,所以不能在Linux宿主机上直接跑Windows容器;反过来,Windows宿主机上跑Linux容器也需要借助轻量级虚拟机或WSL2来实现。这个区别在Java项目里最直接的体现就是:镜像小了,部署快了,但生产环境扩容时,多个Java容器的内存总和需要整体规划,不能拿虚拟机时代的经验直接照搬。
3. 本地环境准备:从安装Docker到跑通第一个Java容器
3.1 Windows用户:先把WSL2搞定,再装Docker Desktop
Windows环境下,大多数人使用Docker Desktop,但它的安装有一个隐藏门槛:需要本机开启虚拟化,并安装好WSL2。很多人卡在“Virtualization support not detected”这个报错,要么是BIOS没开虚拟化,要么是WSL内核没有更新。
建议按这个顺序来:
- 在BIOS里打开Intel VT-x或AMD SVM虚拟化。
- 以管理员身份打开PowerShell,执行
wsl --install安装WSL2。 - 重启后,进入Windows设置确认WSL2是默认版本:
wsl --set-default-version 2。 - 再下载安装Docker Desktop,安装向导会自动识别WSL2。
装完之后,把Docker Desktop的Settings里面“Use the WSL 2 based engine”选项打开,这是默认选项。我第一次给一台老笔记本装Docker时,就是漏了WSL内核更新这个步骤,翻文件发现报错信息指向了内核版本太低,更新完WSL内核之后一切正常。
3.2 macOS和Linux:安装命令和验证方法
macOS用户可以用Homebrew一行搞定:
brew install --cask docker安装完成后打开Docker Desktop,等右下角的小鲸鱼图标稳定下来,再继续。
Linux用户更推荐用发行版各自的包管理器。以Ubuntu为例,比较稳妥的方式是安装官方发布的docker-ce包,而不是直接apt install docker.io,因为官方包版本更新更及时。大致流程是添加官方GPG key和软件源,然后:
sudo apt update sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin sudo systemctl enable --now docker验证是否装好,最简单的方法是:
docker --version docker compose version docker run hello-world能输出hello-world信息,说明Docker守护进程正常,容器运行链路畅通。
3.3 跑一个Java验证容器:确认JVM能在容器里跑起来
有同学问,是不是要先装个JDK才能跑Java容器?不用。你只需要拉一个带JDK的官方镜像,然后让容器执行一行Java版本命令就能验证:
docker run --rm eclipse-temurin:17-jre java -version--rm表示容器退出后自动删除,不会在磁盘上留下垃圾容器。如果这行命令能正常打印Java版本号,说明你的Docker环境已经可以承载Java运行时环境了。这也体现了Docker的好处:本地不需要预装JDK,不用折腾环境变量,想要哪个JDK版本都可以临时用容器来跑。
4. 写一份能用的Dockerfile:Java项目容器化的核心环节
4.1 基础镜像选型:为什么我不建议用openjdk老镜像
很多教程第一行就写FROM openjdk:8-jdk-alpine,这个镜像在早期很流行,但现在已经不建议新项目用了。原因是官方openjdk镜像已经停止维护,而且alpine这类基于musl libc的极小系统在跑Java时,有时会遇到DNS解析、字体库、localedata等兼容性问题。
更省心的选择是Eclipse Temurin。它是Eclipse基金会维护的OpenJDK发行版,有严格的TCK认证,镜像标签齐全,在Java 17和Java 21时代几乎是事实标准。linux系统上建议这样选:
- 需要完整运行时:
eclipse-temurin:17-jre-jammy - 追求体积更小:
eclipse-temurin:17-jre-alpine - 构建阶段用:
maven:3.9-eclipse-temurin-17
-jammy代表基于Ubuntu 22.04的系统层,兼容性稳;-alpine代表基于Alpine的小体积系统,镜像可以压得很小,但要注意glibc相关依赖,运行时一般够用。
4.2 第一版Dockerfile:先跑通再谈优化
很多人一上来就直接上多阶段构建,结果抄错了镜像版本,编译半天失败,心态直接崩了。我的建议是,第一版先保证能跑通,再逐步优化。
先用一个最简单的单阶段Dockerfile:
FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY target/app.jar . EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里假设你的Java项目已经用Maven打好了jar包,名字叫app.jar,并放在了target目录下。构建命令是:
docker build -t demo-java:v1 .启动命令是:
docker run -d --name java-demo -p 8080:8080 demo-java:v1访问http://localhost:8080,看到应用响应,说明基础流程已经跑通。注意这里的ENTRYPOINT用的是JSON数组形式,也就是exec form。它直接以java进程作为容器的一号进程,能正确接收SIGTERM信号,这对后面说到的优雅停机很重要。如果用ENTRYPOINT java -jar app.jar这种shell写法,docker stop时信号会被中间shell进程吞掉,应用往往得不到体面的退出时机。
4.3 多阶段构建:把镜像从300MB压到80MB的关键
单阶段构建有个明显缺点:jar包是用本机Maven打的,如果本机关了多环境配置或依赖仓库的问题,构建环节还是会依赖开发环境。而且最终镜像里只有jre,不算大,但如果你把maven镜像作为基础镜像直接放jar进去跑,体积就是几百MB,因为Maven镜像自带一套完整JDK和依赖工具链。
更优雅的做法是让Docker自己完成编译,然后只把运行需要的部分拷贝到最终镜像。这被称为多阶段构建:
# 第一阶段:编译打包 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行 FROM eclipse-temurin:17-jre-jammy RUN useradd -r -u 1001 appuser WORKDIR /app COPY --from=builder /build/target/app.jar . USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里有几个关键点:
RUN mvn dependency:go-offline会提前下载依赖并缓存,后续只要源码不涉及pom变动,这一层都能复用镜像缓存,打包速度会快很多。COPY --from=builder表示从上一阶段拷贝文件,避免了把整个maven镜像带进最终镜像。- 使用普通用户运行Java进程,减小安全攻击面,这一点后面会展开。
- 最终镜像只包含JRE、jar包和依赖库,体积通常能控制在150MB以内,如果再用jlink裁剪JDK模块,甚至可以压到80MB以下。
4.4 健康检查不能省:让Docker和编排工具知道服务是否真的就绪
你有没有遇到过这种情况:docker ps显示容器是Up状态,但服务实际上不可用。容器“活着”不等于“就绪”。Docker本身只看进程在不在,Java进程起来了就算Up,但Spring Boot的内嵌Web服务器可能还在初始化,数据库连接池也没ready。
解决办法是在Dockerfile里设置健康检查,建议依赖Spring Boot Actuator的健康端点:
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget -q -O - http://localhost:8080/actuator/health || exit 1如果项目里没有引入Actuator,也可以用nc -z localhost 8080直接检测端口,但无法反映应用层面的真实状态。引入Actuator后,健康端点能返回数据库连接情况、磁盘空间等指标,编排工具调度的时候才不至于把流量打到没就绪的实例上。这个细节在单机跑的时候看不出区别,一旦上了docker-compose或K8s,作用就非常明显了。
5. docker-compose实战:Java+MySQL+Redis一键起全套环境
5.1 为什么非要上compose:Java项目不是单打独斗
一个常规Java后端项目,最少都要依赖一个数据库,很多还要用Redis做缓存,用Kafka做消息队列。如果每个组件都用docker run单独启动,命令行会非常长,而且需要管理多个网络、多个端口映射,极易出错。
docker-compose把多容器定义写进一个YAML文件里,用一条命令完成构建和启动。它并不负责集群调度,但解决了单机多容器的编排问题。尤其是本地开发时,一条docker compose up -d就能把MySQL、Redis、Java服务都拉起来,体验和以前手动起一堆进程完全是两个世界。
5.2 网络与服务发现:容器之间怎么互相找
这是compose最容易被Git上旧配置坑的地方。在同一个compose项目里,默认会创建一个bridge网络,所有容器都加入这个网络,并且可以用“服务名”作为主机名互相访问。也就是说,Java容器里连MySQL,jdbc地址不是localhost:3306,而是mysql:3306。
很多同学第一次跑compose,Java服务启动时报数据库连接失败,就是因为配置文件里写的是localhost。容器外面的localhost和容器里面的localhost不是同一个环境。MySQL容器自己内部有MySQL进程,但你Java服务在另一个容器里,访问localhost自然连不上。要解决这个问题,Spring Boot的配置可以这样写:
spring: datasource: url: jdbc:mysql://mysql:3306/demo?useSSL=false&characterEncoding=utf8 username: demo password: demo1235.3 完整示例:Java + MySQL + Redis一套起
下面给一份可以直接参考的compose文件:
version: "3.8" services: mysql: image: mysql:8.0 container_name: demo-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: demo MYSQL_USER: demo MYSQL_PASSWORD: demo123 ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7.2-alpine container_name: demo-redis ports: - "6379:6379" app: build: . container_name: demo-app depends_on: mysql: condition: service_healthy redis: condition: service_started environment: SPRING_PROFILES_ACTIVE: prod MYSQL_HOST: mysql REDIS_HOST: redis ports: - "8080:8080" volumes: mysql-data:启动命令:
docker compose up -d查看状态:
docker compose ps看所有服务日志:
docker compose logs -f之前提到“容器删了数据不能丢”,这里的volumes: mysql-data就是给MySQL的数据目录提供持久化存储。MySQL镜像是全新启动的,首次初始化会执行初始化脚本并创建数据库,但一旦容器被docker rm删除,如果没挂载数据卷,所有数据都会随容器消失。挂载数据卷后,数据会保存在宿主机路径中,重建容器仍然保留。
5.4 环境变量与配置:密码别硬编码进镜像
把数据库密码直接写进Dockerfile或镜像里,是明显的安全漏洞。镜像可以被任何能访问仓库的人拉取,镜像里的敏感信息也会直接暴露。正确做法是通过compose的environment或.env文件注入配置。
Spring Boot本身对环境变量有很强的映射支持。你在compose里写:
environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/demo SPRING_DATASOURCE_USERNAME: demo SPRING_DATASOURCE_PASSWORD: demo123Spring Boot会自动把SPRING_DATASOURCE_URL映射到spring.datasource.url配置项,不需要改任何代码。敏感信息更建议放在compose同级的.env文件里,然后在compose里引用:
environment: SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}.env文件不要提交进Git仓库,加入.gitignore。这样开发和生产的密钥互不混淆,也不用担心代码仓库泄露。
6. 上线前避坑:时区、内存、日志、优雅停机我都替你踩过了
6.1 时区问题:为什么日志时间比北京时间晚了8小时
很多Java镜像默认采用UTC时间,容器里日志打出来的时间比北京时间整整少8小时。排查线上问题的时候,看着一串UTC时间对不上告警时间,非常难受。
解决方式有两种。最简单的是在Dockerfile里设置时区环境变量:
ENV TZ=Asia/Shanghai但这个在alpine系镜像上需要额外安装tzdata包,否则只是设置了一个空环境变量。更通用的做法是在Java启动命令里设置用户时区:
ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-jar", "app.jar"]这个方案不依赖系统层时区数据,JDK本身就能正确处理。如果你的应用用的是jdk内置的ZoneInfo,连tzdata都不用安装。我自己的项目就是直接在启动参数里加-Duser.timezone=Asia/Shanghai,再配合logback里显示的时区,日志时间就和服务器时间对齐了。
6.2 内存问题:容器限了内存,JVM却还是按宿主机内存配置
这是Java容器化里翻车率最高的问题。你在compose里设置了mem_limit: 1g,但JVM默认最大堆会按宿主机物理内存的一定比例来计算。如果在16G内存的服务器上跑,JVM可能默认-Xmx取了4G甚至更多,容器内存被撑爆,然后被内核OOM Killer杀掉,现象就是服务莫名其妙重启。
JDK 8u191以后,容器环境默认开启了-XX:+UseContainerSupport,JVM能感知容器内存限制,堆大小会跟着限制调整,但这毕竟只是“默认行为”,生产环境还是要显式控制。建议在启动参数里明确定义最大堆和初始堆:
ENTRYPOINT ["java", "-Xms512m", "-Xmx512m", "-jar", "app.jar"]如果对内存规划没有把握,可以先用-Xmx512m起步,加上JVM参数-XX:+PrintFlagsFinal观察运行时实际堆大小,再根据监控调整。不要只盯着宿主机空余内存,要按每个容器的配额来设定。
6.3 优雅停机:让Spring Boot体面地退出
Kubernetes、docker-compose在滚动升级时,都会先给容器发送SIGTERM信号,再等一小段时间后强制SIGKILL。如果Java应用不处理优雅停机,Spring Boot的线程池、数据库连接池、消息队列会突然中断,造成业务数据不一致或请求被异常切断。
Spring Boot 2.3之后原生支持优雅停机,配置server.shutdown=graceful,Spring Boot会停止接收新请求,并等待已处理请求完成。但容器能不能把这个SIGTERM正确传给Java进程,取决于Dockerfile的ENTRYPOINT写法。如果用的是shell form:
ENTRYPOINT java -jar app.jarDocker会把命令交给shell执行,shell进程成为一号进程,Java反而是子进程,SIGTERM信号被shell拦截,Java进程可能收不到。要用exec form写:
ENTRYPOINT ["java", "-Xms512m", "-Xmx512m", "-jar", "app.jar"]这样java进程直接作为容器的PID 1,能正确接收SIGTERM。还可以在compose里加stop_grace_period: 60s,给Spring Boot留足处理收尾请求的时间,避免默认10秒后就被强制杀掉。
6.4 非root运行:Java进程不需要root权限
容器里的root约等于宿主机的root,一旦镜像被利用,攻击者可能直接控制宿主机。安全最佳实践是让进程以非root用户运行。上面多阶段构建示例里的:
RUN useradd -r -u 1001 appuser USER appuser就是创建普通用户并切换过去。Java 8之后以普通用户运行没有任何问题,内嵌Tomcat监听8080端口也不需要root权限。需要注意的是,如果应用需要写挂载目录,比如上传文件到/app/upload,要确保宿主机挂载目录对uid 1001有写权限,否则会出现Permission denied。处理方式是在宿主机制定目录权限,或者用chown在Dockerfile里调整镜像内目录归属。
6.5 日志输出与采集:别写文件,写到stdout
Java项目很多习惯了用logback写日志文件,比如日志写到/logs/app.log。这在传统部署里没问题,但在容器里就会带来麻烦:日志文件不透明,docker logs不展示,日志轮转、采集都需要额外处理。
更Docker化的做法是,让日志直接输出到标准输出stdout。Spring Boot起服务时默认日志就是控制台输出,logback配置里只要不配置FileAppender,日志就会打到容器日志收集系统。Docker会把stdout日志统一接进docker logs,K8s里的日志方案也会自动从容器标准输出采集。如果确实需要落盘保存,建议用bind mount挂载宿主机目录,再配合外部日志轮转。但实操下来,日志输出到stdout + 集中采集是最省心的,也最符合“容器不可变”的运维理念。
我自己的项目在改造Docker部署时,就是先把logback的FileAppender去掉,保留ConsoleAppender,再把compose里面加好stop_grace_period和健康检查,然后才敢推到生产。这个过程不是一天改完的,先在小服务上验证,再推广到其他团队。容器化部署真正带来的好处不是“省去了安装JDK”这一件小事,而是整个交付过程变得可预测、可回滚、可审计,这对Java项目这种重依赖的系统来说,价值是实实在在的。