1. 引言
在之前的章节中,我们已经完成了 Docker 基础镜像构建、多阶段构建以及 Compose 编排等核心内容。从这一章开始,我们将把视角从「部署」转向「本地开发」,探讨如何用 Docker 打造一套高效、一致的本地开发工作流。
很多团队在引入 Docker 后,会遇到一个尴尬的局面:部署环境已经容器化,但开发环境还是老一套——本地装 JDK、装 MySQL、装 Redis,靠 IDE 直接跑应用。这样做的后果是:开发环境与生产环境不一致,环境配置问题层出不穷,新成员 onboarding 成本高。
本章将围绕三个核心主题展开:
- 热更新:通过绑定挂载 + Spring DevTools / 前端 HMR,让容器内的代码改动即时生效;
- 远程调试:通过 JDWP 端口映射,让 IDEA / VS Code 直接调试容器内的 Java 进程;
- Testcontainers:用 Docker 动态起 MySQL / Redis 跑集成测试,让测试环境与生产环境保持一致。
最后,我们会把这些能力串成一个完整的本地开发循环:改代码 → 热更新 → 单测 → 集成测试。
前置依赖:建议先完成第 03 章(Docker 基础命令与镜像管理)和第 07 章(Compose 多容器编排),本章会大量复用其中的概念。
2. 为什么本地开发也要容器化
在讨论具体方案之前,先明确一个问题:本地开发容器化的价值到底是什么?
2.1 环境一致性
传统开发模式下,每个开发者的机器环境各不相同:有人用 macOS、有人用 Windows、有人用 Linux;JDK 版本可能是 8、11、17;MySQL 可能是 5.7 也可能是 8.0。这些差异会导致「在我机器上能跑」的经典问题。
容器化之后,开发环境、测试环境、生产环境共享同一份镜像定义,从根本上消除了环境漂移。
2.2 依赖隔离
一个开发者可能同时维护多个项目:项目 A 需要 MySQL 5.7,项目 B 需要 MySQL 8.0,项目 C 需要 Redis 6。如果全部装在宿主机上,版本冲突是迟早的事。
用 Docker 起依赖服务,每个项目有自己独立的容器实例,互不干扰,用完即焚。
2.3 快速 onboarding
新成员加入团队时,不需要花半天时间阅读「环境搭建文档」,也不需要手动安装各种依赖。只需要:
gitclone<repo>dockercompose up-d一条命令,开发环境就绪。
3. 热更新:绑定挂载 + 开发工具
热更新(Hot Reload)是本地开发体验的关键。没有热更新,每次改代码都要重启容器,等待时间会严重打断开发节奏。
3.1 绑定挂载(Bind Mount)
绑定挂载是 Docker 提供的一种数据卷类型,它把宿主机上的目录直接映射到容器内的目录。与命名卷(Named Volume)不同,绑定挂载实时同步宿主机与容器之间的文件变化。
在docker-compose.yml中,绑定挂载的写法如下:
services:app:image:openjdk:17-jdk-slimvolumes:-./backend:/appworking_dir:/app这里把宿主机上的./backend目录挂载到容器的/app目录。宿主机上任何文件改动,容器内立即可见。
注意:绑定挂载会覆盖镜像中对应路径的内容。如果镜像构建时在
/app下放了编译产物,挂载后这些产物会被宿主机目录内容遮蔽。
3.2 Spring Boot 后端热更新:Spring DevTools
Spring Boot 提供了spring-boot-devtools依赖,它内置了**自动重启(Automatic Restart)**机制:当 classpath 中的文件发生变化时,应用会自动重启。
在pom.xml中添加依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</scope><optional>true</optional></dependency>配合绑定挂载,完整的开发流程如下:
- 宿主机上修改 Java 源码;
- IDE 自动编译,生成新的
.class文件; - 绑定挂载将
.class文件同步到容器; - Spring DevTools 检测到 classpath 变化,自动重启应用。
这里有一个关键点:IDE 必须开启「自动编译」。IDEA 中通过Build → Build Project(快捷键Ctrl+F9)手动触发,或开启Build project automatically选项。
3.3 前端热更新:Vite HMR
前端开发中,Vite 的 HMR(Hot Module Replacement)是目前体验最好的方案之一。它能在不刷新页面的情况下,实时替换修改的模块。
在docker-compose.yml中配置前端服务:
services:frontend:image:node:18-alpinevolumes:-./frontend:/appworking_dir:/appcommand:npm run devports:-"5173:5173"Vite 默认监听5173端口。为了让容器内的 Vite 能被宿主机访问,需要配置server.host:
// vite.config.jsexportdefault{server:{host:true,port:5173,watch:{usePolling:true}}}usePolling: true是容器环境下的关键配置。在 Docker 中,文件系统事件(inotify)的传递可能不可靠,开启轮询模式可以确保文件变化被 Vite 正确感知。
3.4 坑位:热更新与卷缓存冲突
这是热更新场景下最常见的坑。绑定挂载会遮蔽镜像中的目录内容,如果镜像构建时已经安装了依赖(如node_modules),挂载后这些依赖会被宿主机目录遮蔽。
解决方案有两种:
方案一:匿名卷技巧
services:frontend:image:node:18-alpinevolumes:-./frontend:/app-/app/node_modules第二行/app/node_modules是一个匿名卷,它会把容器内原有的node_modules保留下来,不被宿主机目录遮蔽。这样宿主机上的node_modules不会干扰容器内已安装的依赖。
方案二:分离依赖目录
在宿主机上把node_modules放在项目目录之外,或者使用 pnpm 的全局存储。这种方式更彻底,但配置复杂度更高。
对于 Java 项目,类似的坑出现在target/目录。建议在.dockerignore中排除编译产物,避免宿主机与容器的编译产物互相干扰。
4. 远程调试:JDWP 端口映射
调试是开发者的刚需。容器化之后,IDE 无法直接 attach 到容器内的 JVM 进程,需要通过 JDWP(Java Debug Wire Protocol)协议建立连接。
4.1 开启 JDWP
在启动 Java 应用时,通过 JVM 参数开启调试端口:
java-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005-jarapp.jar参数说明:
transport=dt_socket:使用 Socket 传输;server=y:JVM 作为调试服务器,等待 IDE 连接;suspend=n:启动时不挂起,立即运行(如果设为y,JVM 会等待调试器连接后才启动);address=*:5005:监听所有网卡的 5005 端口。
4.2 Compose 配置
在docker-compose.yml中映射调试端口:
services:app:image:openjdk:17-jdk-slimports:-"5005:5005"environment:JAVA_TOOL_OPTIONS:"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"使用JAVA_TOOL_OPTIONS环境变量的好处是:不需要修改 Dockerfile 或启动命令,JVM 启动时会自动读取该环境变量并应用。
4.3 IDEA 连接
- 打开
Run → Edit Configurations; - 点击
+,选择Remote JVM Debug; - 填写 Host 为
localhost,Port 为5005; - 使用默认的
Attach to remote JVM模式; - 点击
Debug按钮,即可连接。
4.4 VS Code 连接
在.vscode/launch.json中配置:
{"version":"0.2.0","configurations":[{"type":"java","name":"Attach to Docker JVM","request":"attach","hostName":"localhost","port":5005}]}4.5 坑位:JDWP 端口暴露安全
JDWP 协议没有任何认证机制,任何能访问该端口的人都可以连接并控制 JVM,甚至可以执行任意代码。因此:
- 不要在生产环境开启 JDWP;
- 不要将调试端口暴露到公网;
- 仅在本地开发时映射调试端口,且建议绑定到
127.0.0.1:
ports:-"127.0.0.1:5005:5005"这样只有本机可以访问调试端口,局域网内其他机器无法连接。
5. Testcontainers:让测试环境与生产一致
Testcontainers 是一个 Java 测试库,它允许在测试运行时动态创建 Docker 容器作为依赖服务。这意味着:测试环境与生产环境使用完全相同的数据库、缓存中间件,彻底告别「测试用 H2,生产用 MySQL」的尴尬。
5.1 为什么需要 Testcontainers
传统集成测试的痛点:
- 测试环境依赖本地安装的 MySQL / Redis,版本与生产不一致;
- CI 环境需要手动安装依赖服务,配置复杂;
- 测试数据污染本地开发数据库。
Testcontainers 的解决方案:
- 测试启动时自动拉取镜像、启动容器;
- 测试结束自动销毁容器,不留残留;
- 每个测试类可以使用独立的容器实例,互不干扰。
5.2 添加依赖
在pom.xml中添加:
<dependency><groupId>org.testcontainers</groupId><artifactId>junit-jupiter</artifactId><version>1.19.7</version><scope>test</scope></dependency><dependency><groupId>org.testcontainers</groupId><artifactId>mysql</artifactId><version>1.19.7</version><scope>test</scope></dependency>5.3 编写集成测试
以 ValidX 项目的用户服务为例,测试用户注册接口:
@TestcontainersclassUserServiceIntegrationTest{@ContainerstaticMySQLContainer<?>mysql=newMySQLContainer<>("mysql:8.0").withDatabaseName("validx").withUsername("test").withPassword("test");@ContainerstaticGenericContainer<?>redis=newGenericContainer<>("redis:7-alpine").withExposedPorts(6379);@DynamicPropertySourcestaticvoidregisterProperties(DynamicPropertyRegistryregistry){registry.add("spring.datasource.url",mysql::getJdbcUrl);registry.add("spring.datasource.username",mysql::getUsername);registry.add("spring.datasource.password",mysql::getPassword);registry.add("spring.data.redis.host",redis::getHost);registry.add("spring.data.redis.port",()->redis.getMappedPort(6379));}@TestvoidtestRegisterUser(){// 调用注册接口,验证数据库写入}}关键点说明:
@Testcontainers注解管理容器的生命周期;@Container注解标记的静态字段会在测试类加载时启动容器;@DynamicPropertySource将容器的动态端口注入 Spring 配置,无需硬编码端口。
5.4 复用容器实例
如果多个测试类使用相同的容器配置,可以提取公共父类:
publicabstractclassAbstractIntegrationTest{staticfinalMySQLContainer<?>MYSQL;static{MYSQL=newMySQLContainer<>("mysql:8.0").withDatabaseName("validx").withUsername("test").withPassword("test");MYSQL.start();}@DynamicPropertySourcestaticvoidregisterProperties(DynamicPropertyRegistryregistry){registry.add("spring.datasource.url",MYSQL::getJdbcUrl);registry.add("spring.datasource.username",MYSQL::getUsername);registry.add("spring.datasource.password",MYSQL::getPassword);}}子类继承该父类即可复用同一个 MySQL 容器,避免每个测试类都启动一个新容器,显著缩短测试时间。
5.5 坑位:Testcontainers 的 Docker 依赖与 CI 适配
Testcontainers 依赖本机的 Docker 环境。在本地开发时,需要确保 Docker Desktop 正在运行。在 CI 环境中,需要额外配置:
GitHub Actions 示例:
jobs:test:runs-on:ubuntu-latestservices:docker:image:docker:24options:--privilegedsteps:-uses:actions/checkout@v4-uses:actions/setup-java@v4with:distribution:'temurin'java-version:'17'-name:Run testsrun:mvn verifyJenkins 示例:
pipeline{agent{docker{image'maven:3.9-eclipse-temurin-17'}}stages{stage('Test'){steps{sh'mvn verify'}}}}在 Jenkins 中,需要确保 Jenkins 节点上有可用的 Docker 环境,并且 Jenkins 用户有权限访问 Docker socket。
注意:在 CI 中运行 Testcontainers 时,需要设置
TESTCONTAINERS_RYUK_DISABLED=true环境变量(如果使用 Ryuk 资源回收机制),或者确保 CI 环境支持 Ryuk 容器的运行。
6. 完整本地开发循环
把前面的能力串起来,一个完整的本地开发循环如下:
6.1 启动开发环境
# 启动依赖服务(MySQL、Redis)dockercompose up-dmysql redis# 启动后端(热更新模式)dockercompose up-dbackend# 启动前端(HMR 模式)dockercompose up-dfrontend6.2 日常开发流程
- 改代码:在宿主机上编辑源码;
- 热更新:后端通过 Spring DevTools 自动重启,前端通过 Vite HMR 实时刷新;
- 单测:
mvn test运行单元测试,不依赖外部服务; - 集成测试:
mvn verify运行集成测试,Testcontainers 自动拉起 MySQL / Redis; - 调试:遇到问题时,通过 JDWP 端口连接 IDE 调试。
6.3 一键脚本
为了简化操作,可以写一个 Makefile:
.PHONY: dev up down test itest debug dev: docker compose up -d mysql redis docker compose up -d backend frontend up: docker compose up -d down: docker compose down test: mvn test itest: mvn verify debug: docker compose logs -f backend7. 总结
本章我们完成了 Docker 本地开发工作流的完整搭建:
| 能力 | 技术方案 | 关键配置 |
|---|---|---|
| 热更新 | 绑定挂载 + Spring DevTools / Vite HMR | volumes+usePolling |
| 远程调试 | JDWP 端口映射 | JAVA_TOOL_OPTIONS+ 端口映射 |
| 集成测试 | Testcontainers | @Testcontainers+@DynamicPropertySource |
三个核心坑位需要牢记:
- 热更新与卷缓存冲突:绑定挂载会遮蔽镜像目录,用匿名卷或分离依赖目录解决;
- JDWP 端口暴露安全:调试端口无认证,仅绑定
127.0.0.1,生产环境禁用; - Testcontainers 的 Docker 依赖:本地需 Docker Desktop,CI 需额外配置 Docker 环境。
Testcontainers 部分可以直接复用到 ValidX 项目的测试改造中,让集成测试与生产环境保持一致,从根源上消除「测试通过、上线失败」的问题。