1. 为什么我最终选择了Docker来搭Java环境
做了七八年后端开发,换过的电脑少说也有五六台,每换一次机器最头疼的事情不是装IDE,而是配Java环境。JDK版本要选、环境变量要配、Maven要调、数据库要装、Redis要起,一套流程走下来小半天就没了。更别提团队协作的时候,你本地跑得好好的项目,同事拉下来就是各种版本冲突,JDK 8和JDK 17的语法差异、Maven仓库地址不一致、MySQL字符集不对,这些问题排查起来比写业务代码还累。
后来我开始认真用Docker来管理开发环境,说实话一开始是抗拒的,觉得多了一层容器反而麻烦。但真正跑通之后发现,这东西解决的不是“能不能用”的问题,而是“能不能每次都一样”的问题。Docker一站式安装Java开发环境,核心价值就一句话:把JDK、构建工具、中间件、数据库全部打包成可复现的容器组合,换机器只需要一条命令。
这篇文章适合谁看?如果你是刚入行的Java开发者,还在手动配环境变量、被CLASSPATH折磨,那这套方案能帮你省掉大量试错时间。如果你是有经验的工程师,团队里环境不一致的问题反复出现,那这套思路可以直接拿去用。哪怕你之前没怎么接触过Docker,跟着走一遍也能跑起来,我会把每个参数为什么这么设讲清楚。
我自己的实际场景是这样的:主力开发机是Linux,但偶尔要在Windows上做演示,团队里还有人用Mac。三种操作系统,如果各自手动配环境,光同步版本就要命。用Docker之后,我写了一份docker-compose.yml,所有人拉下来直接docker compose up -d,五分钟之内环境就绪,JDK版本、Maven配置、MySQL参数完全一致。这个体验一旦用过就回不去了。
下面我会从整体设计思路开始拆,然后逐个讲核心组件的配置细节,再给一套完整的实操流程,最后把我踩过的坑和排查方法整理出来。内容比较长,但都是实际跑过的方案,你可以直接抄作业。
2. 整体方案设计与核心思路拆解
2.1 为什么不用“一个容器装所有”的方案
很多人第一次用Docker搭Java环境,直觉反应是搞一个Ubuntu镜像,然后把JDK、Maven、MySQL、Redis全装进去。这个思路能跑通,但问题很多。首先是镜像体积会膨胀到好几个G,每次重建都慢得要命。其次是耦合度太高,你想换个JDK版本,整个容器都得重建,MySQL里的数据还得想办法迁移。最后是启动顺序不可控,Java应用启动的时候MySQL可能还没准备好,你得在启动脚本里写一堆等待逻辑。
我采用的方案是多容器编排,每个组件跑在独立容器里,通过Docker网络互相通信。JDK和Maven放在一个“构建容器”里,MySQL一个容器,Redis一个容器,Nacos或者RabbitMQ按需再加。这样做的好处是每个容器职责单一,升级某个组件不影响其他部分,数据用Volume持久化,容器删了数据还在。
提示:不要把所有东西塞进一个容器。容器设计的哲学是一个进程一个容器,虽然开发环境可以适当放宽,但数据库和构建工具混在一起绝对是个坏主意。
2.2 镜像选型:为什么基础镜像选Eclipse Temurin而不是Oracle JDK
JDK镜像的选择上我对比过好几个方案。Oracle官方的JDK镜像曾经是主流,但后来License政策变化,商用场景有法律风险,而且镜像体积偏大。OpenJDK官方镜像更新不够及时,有些版本已经停止维护了。Alpine版本的JDK镜像体积最小,但用的是musl libc而不是glibc,某些依赖native库的Java应用会出问题,比如Netty的某些特性、JNA调用等。
最终我选的是Eclipse Temurin的镜像,也就是原来的AdoptOpenJDK。理由很实在:第一,它是社区驱动的,没有商业License风险;第二,更新频率高,安全补丁跟得紧;第三,提供多种基础镜像变体,有基于Ubuntu的完整版,也有基于Alpine的轻量版,可以按需选择;第四,也是最重要的一点,它的行为跟标准OpenJDK完全一致,不会出现某些国产JDK那种“看起来一样但跑起来有差异”的情况。
具体到版本,我一般用eclipse-temurin:17-jdk-jammy这个标签。17是LTS版本,jammy是Ubuntu 22.04的代号,这个组合既有长期支持,又有完整的系统库。如果你还在用JDK 8,可以用eclipse-temurin:8-jdk-focal,focal是Ubuntu 20.04。JDK 11的话用eclipse-temurin:11-jdk-jammy。
2.3 构建工具容器化:Maven和Gradle怎么选
Maven和Gradle我都用过,最终在容器方案里我倾向于Maven。原因不是Maven更好,而是Maven在容器里更好管理。Maven的本地仓库就是一个目录,挂载Volume就行,配置就是一个settings.xml文件,复制进去就完事。Gradle的缓存目录结构更复杂,有caches、wrapper、daemon等多个目录,而且Gradle Daemon在容器里有时候会有权限问题。
当然如果你团队用Gradle,也不是不行,只是配置稍微麻烦一点。我后面会主要讲Maven的方案,Gradle的差异点会单独提一下。
Maven容器我不建议用官方的maven:3.9-eclipse-temurin-17镜像直接跑,因为这个镜像默认的本地仓库在容器内部,每次重建都要重新下载依赖,慢得让人崩溃。我的做法是基于Temurin JDK镜像自己写一个Dockerfile,把Maven解压进去,然后通过Volume把本地仓库挂载到宿主机上。这样依赖只下载一次,后续构建都是秒级。
2.4 网络与数据持久化的设计
Docker网络方面,我创建一个自定义的bridge网络,名字叫java-dev-net。所有容器都加入这个网络,容器之间可以直接用容器名作为主机名通信。比如MySQL容器的名字是mysql-dev,那Java应用连接数据库的URL就是jdbc:mysql://mysql-dev:3306/dbname,不需要记IP地址,也不受宿主机网络变化的影响。
数据持久化用命名Volume而不是绑定挂载(bind mount)。绑定挂载是把宿主机的一个目录映射到容器里,好处是直接在宿主机就能看到文件,坏处是权限问题多,尤其是Linux上UID/GID不一致的时候。命名Volume由Docker管理,权限问题少,备份和迁移也方便。MySQL的数据、Redis的持久化文件、Maven的本地仓库,我都用命名Volume。
注意:命名Volume在Linux上的默认位置是
/var/lib/docker/volumes/,直接去这个目录找文件需要root权限。如果你想方便地查看文件,可以用docker volume inspect命令查看挂载点,或者临时起一个容器挂载这个Volume来查看。
3. 核心组件配置与实操要点
3.1 JDK与Maven构建容器的Dockerfile编写
先看我实际用的Dockerfile,基于Temurin JDK 17:
FROM eclipse-temurin:17-jdk-jammy ARG MAVEN_VERSION=3.9.6 ARG MAVEN_HOME=/opt/maven RUN apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates && \ curl -fsSL https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ | tar -xz -C /opt && \ mv /opt/apache-maven-${MAVEN_VERSION} ${MAVEN_HOME} && \ ln -s ${MAVEN_HOME}/bin/mvn /usr/local/bin/mvn && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* ENV MAVEN_HOME=${MAVEN_HOME} ENV PATH=${MAVEN_HOME}/bin:${PATH} WORKDIR /workspace这个Dockerfile有几个细节值得说。第一,我用ARG来定义Maven版本,这样升级版本只需要改一个参数,不用改Dockerfile逻辑。第二,下载Maven用curl管道到tar,避免中间文件残留,镜像层更小。第三,安装完curl之后清理了apt缓存,这是减小镜像体积的标准操作。第四,创建了软链接到/usr/local/bin/mvn,这样在任何目录下都能直接执行mvn命令。
构建命令:
docker build -t java-dev-base:17 .这个镜像构建一次就行,后续所有Java项目都用它作为基础。
3.2 Maven settings.xml的关键配置
Maven的配置文件我放在宿主机的一个目录里,通过Volume挂载到容器中。这样改配置不用重建镜像。settings.xml的核心配置项:
<settings> <localRepository>/root/.m2/repository</localRepository> <mirrors> <mirror> <id>aliyun-central</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/central</url> </mirror> </mirrors> <profiles> <profile> <id>jdk17</id> <activation> <activeByDefault>true</activeByDefault> <jdk>17</jdk> </activation> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <maven.compiler.release>17</maven.compiler.release> </properties> </profile> </profiles> </settings>镜像地址我用了国内源,下载速度从原来的几十KB每秒提升到几MB每秒,这个提升非常明显。localRepository指向/root/.m2/repository,这个路径后面会通过Volume挂载到宿主机,保证依赖持久化。
提示:如果你所在网络环境访问国内源有问题,可以换回Maven中央仓库,但速度会慢很多。另外,有些公司有内部私服,把mirror地址改成私服地址即可。
3.3 MySQL容器的参数调优
MySQL我用的是mysql:8.0镜像,配置通过命令行参数和环境变量传入:
mysql-dev: image: mysql:8.0 container_name: mysql-dev environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: dev_db TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-authentication-plugin=mysql_native_password - --max_connections=500 - --innodb_buffer_pool_size=512M - --slow_query_log=1 - --long_query_time=2 ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./init-sql:/docker-entrypoint-initdb.d networks: - java-dev-net几个关键参数解释一下。character-set-server=utf8mb4和collation-server=utf8mb4_unicode_ci解决中文乱码问题,这个不设的话默认是latin1,存中文直接变问号。default-authentication-plugin=mysql_native_password是因为MySQL 8.0默认用了caching_sha2_password,老版本的JDBC驱动连不上,设成native_password兼容性最好。innodb_buffer_pool_size=512M是开发环境的合理值,生产环境要根据内存调整,开发机一般给512M到1G就够了。
./init-sql:/docker-entrypoint-initdb.d这个挂载很有意思,MySQL镜像在首次启动时会自动执行这个目录下的所有.sql文件。我把建库建表的脚本放在这里,容器第一次启动就自动初始化好数据库,省去手动导入的步骤。
3.4 Redis容器的轻量配置
Redis的配置相对简单:
redis-dev: image: redis:7-alpine container_name: redis-dev command: redis-server --appendonly yes --requirepass redis123456 ports: - "6379:6379" volumes: - redis-data:/data networks: - java-dev-netappendonly yes开启AOF持久化,开发环境虽然数据不重要,但有时候重启容器后缓存全没了也挺烦的。requirepass设个密码,避免本地其他程序误连。Alpine镜像体积只有几十MB,启动飞快。
3.5 docker-compose.yml的完整编排
把上面这些串起来,完整的docker-compose.yml:
version: '3.8' services: java-dev: build: context: . dockerfile: Dockerfile image: java-dev-base:17 container_name: java-dev volumes: - ./workspace:/workspace - maven-repo:/root/.m2/repository - ./settings.xml:/root/.m2/settings.xml working_dir: /workspace command: tail -f /dev/null networks: - java-dev-net mysql-dev: image: mysql:8.0 container_name: mysql-dev environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: dev_db TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --default-authentication-plugin=mysql_native_password - --max_connections=500 - --innodb_buffer_pool_size=512M ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./init-sql:/docker-entrypoint-initdb.d networks: - java-dev-net redis-dev: image: redis:7-alpine container_name: redis-dev command: redis-server --appendonly yes --requirepass redis123456 ports: - "6379:6379" volumes: - redis-data:/data networks: - java-dev-net volumes: maven-repo: mysql-data: redis-data: networks: java-dev-net: driver: bridgejava-dev容器的command是tail -f /dev/null,这是让容器保持运行不退出。因为JDK容器本身没有常驻进程,如果不加这个命令,容器启动后立刻就会退出。进入容器执行构建命令用docker exec -it java-dev bash。
4. 完整实操流程与关键环节实现
4.1 环境准备与目录结构
在开始之前,宿主机需要装好Docker和Docker Compose。Linux上一条命令搞定:
curl -fsSL https://get.docker.com | sh sudo systemctl enable docker && sudo systemctl start dockerWindows和Mac直接下载Docker Desktop安装包,图形化安装即可。装完之后验证:
docker --version docker compose version两个命令都能输出版本号就说明环境OK。
接下来创建项目目录结构:
java-dev-env/ ├── docker-compose.yml ├── Dockerfile ├── settings.xml ├── init-sql/ │ └── init.sql └── workspace/ └── (你的Java项目放这里)workspace目录挂载到容器的/workspace,你在宿主机上用IDE改代码,容器里立刻就能看到,不需要重新构建镜像。这个设计很关键,开发体验跟本地开发几乎没区别。
4.2 启动容器与验证
在java-dev-env目录下执行:
docker compose up -d第一次执行会下载镜像、构建java-dev镜像,大概需要几分钟,取决于网络速度。看到所有容器状态是running就成功了。用docker compose ps查看状态:
docker compose ps应该看到三个容器都在运行。如果某个容器状态是exited,用docker compose logs 容器名查看日志排查。
验证JDK和Maven:
docker exec -it java-dev java -version docker exec -it java-dev mvn -version验证MySQL连接:
docker exec -it mysql-dev mysql -uroot -proot123456 -e "show databases;"验证Redis:
docker exec -it redis-dev redis-cli -a redis123456 ping返回PONG就说明Redis正常。
4.3 在容器内构建Java项目
进入java-dev容器:
docker exec -it java-dev bash进入workspace目录,假设你有一个Spring Boot项目:
cd /workspace/my-project mvn clean package -DskipTests第一次构建会下载所有依赖,时间取决于项目大小和网络速度。因为maven-repo是命名Volume,依赖下载一次之后,后续构建就很快了。构建完成后,jar包在target目录下,可以直接在容器里运行:
java -jar target/my-project-0.0.1-SNAPSHOT.jar应用启动后,连接MySQL的配置要改成容器名:
spring: datasource: url: jdbc:mysql://mysql-dev:3306/dev_db?useUnicode=true&characterEncoding=utf8mb4 username: root password: root123456 redis: host: redis-dev port: 6379 password: redis123456注意URL里的主机名是mysql-dev和redis-dev,这是docker-compose里定义的服务名,在同一个网络里可以直接解析。
4.4 用IDE连接容器内的中间件
虽然代码在容器里构建,但开发的时候还是习惯用IDE。IDEA和VS Code都可以直接连接容器内的MySQL和Redis。MySQL的宿主机端口映射是3306,所以IDE里连接localhost:3306就行,用户名root,密码root123456。Redis同理,连接localhost:6379,密码redis123456。
这样开发的时候,IDE负责写代码和调试,容器负责运行环境和中间件,两边通过端口映射和Volume挂载打通。你改完代码,在容器里执行mvn命令重新构建,或者用spring-boot-devtools做热部署,体验很流畅。
4.5 数据备份与迁移
命名Volume的数据备份很简单,用docker run起一个临时容器挂载Volume,然后打包:
docker run --rm -v mysql-data:/data -v $(pwd):/backup alpine tar czf /backup/mysql-data-backup.tar.gz -C /data .恢复的时候反过来:
docker run --rm -v mysql-data:/data -v $(pwd):/backup alpine tar xzf /backup/mysql-data-backup.tar.gz -C /dataMaven仓库的备份同理,把mysql-data换成maven-repo即可。这个操作在换机器的时候特别有用,把备份文件拷到新机器上恢复,依赖不用重新下载。
5. 常见问题与排查技巧实录
5.1 容器启动失败排查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| mysql-dev容器反复重启 | 数据目录权限不对 | docker compose logs mysql-dev | 删除Volume重建,或修复目录权限 |
| java-dev容器启动后立即退出 | 没有常驻进程 | docker compose logs java-dev | 确认command是tail -f /dev/null |
| 端口被占用 | 宿主机3306已被使用 | netstat -tlnp | grep 3306 | 改端口映射为3307:3306 |
| Maven下载依赖超时 | 网络问题或镜像源不可用 | 容器内curl测试镜像源 | 换镜像源或配置代理 |
| Java应用连不上MySQL | 网络不通或认证失败 | 容器内ping mysql-dev | 检查是否在同一网络,认证插件是否为native_password |
5.2 Maven依赖下载慢的优化技巧
即使配了国内镜像源,有时候还是会遇到某些依赖下载慢的问题。我的经验是,在settings.xml里多配几个mirror,用mirrorOf的优先级来控制。另外,maven-repo这个Volume一定要持久化,不要每次重建容器都重新下载。
如果某个依赖死活下载不下来,可以手动下载jar包放到本地仓库对应的目录里。Maven本地仓库的路径结构是groupId的斜杠分隔加artifactId加version。比如com.example:demo:1.0.0,路径就是com/example/demo/1.0.0/demo-1.0.0.jar。放进去之后Maven就能识别。
注意:手动放jar包的时候,
.pom文件也要一起放,否则Maven解析依赖关系会出问题。如果实在找不到pom,可以用mvn install:install-file命令安装。
5.3 容器内中文乱码的处理
中文乱码一般出现在两个地方:MySQL存储和日志输出。MySQL的字符集前面已经配了utf8mb4,基本不会有问题。日志输出的乱码通常是容器的locale没设对。在Dockerfile里加一行:
ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8这样Java程序输出的中文日志就是正常的。如果还不行,检查一下JVM参数有没有设-Dfile.encoding=UTF-8。
5.4 容器时间与宿主机不一致
默认情况下容器用的是UTC时间,比北京时间晚8小时。这会导致日志时间戳对不上,排查问题的时候很迷惑。解决办法是在docker-compose.yml里给每个容器加环境变量:
environment: TZ: Asia/Shanghai或者在Dockerfile里加:
RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone两种方式都行,我一般用环境变量方式,改起来方便。
5.5 磁盘空间被Docker占满的清理方法
Docker用久了,镜像层、停止的容器、未使用的Volume会占很多空间。定期清理:
docker system prune -a --volumes这个命令会删除所有未使用的镜像、容器、网络和Volume。注意--volumes会删除命名Volume,如果里面有重要数据,先备份。如果只想清理悬空镜像:
docker image prune查看磁盘占用:
docker system df -v这个命令会列出每个镜像、容器、Volume的大小,方便定位是谁占了空间。
5.6 我的独家避坑经验
第一个坑是Volume权限问题。在Linux上,容器里的root用户和宿主机的普通用户UID可能不一致,导致挂载的目录容器里写不进去。解决办法是在Dockerfile里创建一个跟宿主机UID一致的普通用户,或者简单粗暴地用root跑容器(开发环境可以接受)。我一般用后者,省事。
第二个坑是MySQL初始化脚本只执行一次。docker-entrypoint-initdb.d目录下的脚本只在数据目录为空的时候执行,也就是第一次启动的时候。如果你改了init.sql,想重新执行,必须先删除mysql-data这个Volume,让MySQL重新初始化。命令是docker compose down -v,注意-v会删除所有Volume,数据会丢,确认不需要了再执行。
第三个坑是Maven构建时的内存不足。默认JVM堆内存可能不够,构建大项目的时候会OOM。可以在MAVEN_OPTS里加大内存:
export MAVEN_OPTS="-Xmx2g -Xms512m"或者在docker-compose.yml里给java-dev容器加内存限制:
deploy: resources: limits: memory: 4G第四个坑是容器间网络不通。有时候明明在同一个网络里,但就是ping不通。检查一下docker-compose.yml里的networks配置,确保每个服务都加入了同一个网络。另外,如果用了network_mode: host,容器就不在自定义网络里了,服务名解析会失效。
6. 进阶扩展与个性化定制
6.1 按需添加Nacos、RabbitMQ等中间件
基础环境跑通之后,加中间件就是往docker-compose.yml里加service的事。以Nacos为例:
nacos-dev: image: nacos/nacos-server:v2.3.0 container_name: nacos-dev environment: MODE: standalone SPRING_DATASOURCE_PLATFORM: mysql MYSQL_SERVICE_HOST: mysql-dev MYSQL_SERVICE_DB_NAME: nacos_config MYSQL_SERVICE_USER: root MYSQL_SERVICE_PASSWORD: root123456 ports: - "8848:8848" - "9848:9848" networks: - java-dev-netNacos用MySQL做配置存储,所以要先在MySQL里建好nacos_config库并导入Nacos的建表脚本。这个脚本在Nacos的GitHub仓库里有,下载下来放到init-sql目录即可。
RabbitMQ的配置:
rabbitmq-dev: image: rabbitmq:3.12-management container_name: rabbitmq-dev environment: RABBITMQ_DEFAULT_USER: admin RABBITMQ_DEFAULT_PASS: admin123456 ports: - "5672:5672" - "15672:15672" networks: - java-dev-net15672是管理界面端口,浏览器打开http://localhost:15672就能看到管理后台。
6.2 多JDK版本共存的方案
有时候需要同时维护JDK 8和JDK 17的项目。我的做法是构建两个基础镜像,java-dev-base:8和java-dev-base:17,然后在docker-compose.yml里定义两个服务,分别用不同的镜像。项目A用JDK 8的容器构建,项目B用JDK 17的容器构建,互不干扰。
java-dev-8: image: java-dev-base:8 container_name: java-dev-8 volumes: - ./workspace-jdk8:/workspace - maven-repo:/root/.m2/repository command: tail -f /dev/null networks: - java-dev-net java-dev-17: image: java-dev-base:17 container_name: java-dev-17 volumes: - ./workspace-jdk17:/workspace - maven-repo:/root/.m2/repository command: tail -f /dev/null networks: - java-dev-net两个容器共享同一个maven-repo Volume,依赖不用重复下载。需要构建哪个项目就进哪个容器。
6.3 把环境配置纳入版本控制
docker-compose.yml、Dockerfile、settings.xml、init-sql这些文件都应该提交到Git仓库。新同事入职,克隆仓库,执行docker compose up -d,五分钟环境就绪。这比写一堆环境搭建文档靠谱得多,文档会过时,代码不会。
提示:settings.xml里如果有私服密码等敏感信息,不要直接提交到仓库。可以用环境变量替换,或者用
.gitignore忽略掉,提供一个settings.xml.example模板。
6.4 资源限制与性能调优
开发机资源有限的时候,给容器加上资源限制,避免某个容器吃光内存:
deploy: resources: limits: cpus: '2' memory: 2G reservations: cpus: '0.5' memory: 512MMySQL的innodb_buffer_pool_size根据宿主机内存调整,一般给宿主机内存的25%到50%。如果宿主机16G内存,MySQL给4G到8G都合理。Redis的maxmemory也可以设一下,避免缓存无限增长。
这套方案我从去年开始用,中间换过两次电脑,每次都是半小时内恢复完整开发环境。团队里新来的同事,以前配环境要折腾一整天,现在照着文档走,午饭前就能开始写代码。Docker带来的环境一致性,在多人协作场景下价值尤其明显,再也不会出现“在我机器上是好的”这种扯皮。如果你还在手动配Java环境,真心建议花一个下午把这套东西搭起来,后面省下的时间远超这个投入。