一、从跑一个容器到跑一堆容器
上篇学完,我已经能把 hm-service 打成镜像、用docker run跑起来了。但黑马商城是微服务项目,订单、商品、网关好几个服务,外加 MySQL、Redis 这些中间件,每个都手敲一遍docker run,参数又长又容易抄错,改一次配置就得全部重来。
所以下篇解决两件事:容器里的数据怎么不丢(数据卷),多个容器怎么一起管(Compose),最后把 hm-service 完整部署一遍。
二、数据卷:容器的"外置硬盘"
最早起 MySQL 容器的时候忘了挂-v。中途 restart 了几次都没事,数据还在,我就以为没问题了。后来rm重跑,库和表全没了。
当时我盯着空荡荡的数据库,脑子里只有一个想法:我明明没删数据啊。愣了几秒才反应过来——数据一直在容器里,容器没了,数据就没了。
restart 不会丢数据,rm才会。这一点我后来才弄清楚:restart 只是重启容器,可写层还在;rm才是把容器和它的可写层一起删掉。
数据卷就是为这个场景准备的,把宿主机目录挂载进容器:
dockerrun-vmysql-data:/var/lib/mysql mysql-v后面的格式是"宿主机路径(或卷名):容器路径"。容器往这个路径里写的数据,其实落到了宿主机上。容器删了数据还在,重建一个容器挂同一个目录,数据接着用。
用法上大概分两类:MySQL 数据目录、Redis 持久化文件这类必须保住的,挂命名卷或固定目录;配置文件、日志目录这类经常改的,用绑定挂载,宿主机上改完容器里立刻生效,不用重新打镜像。
命名卷和绑定挂载的区别也弄清楚了:docker volume create出来的命名卷由 Docker 统一管理,位置藏在/var/lib/docker/volumes下面,省心但不好直接摸到;绑定挂载就是自己指定宿主机路径,位置透明。数据求稳用命名卷,配置求好选用绑定挂载。
从那以后起中间件之前,我会先问自己一句:这容器里有什么数据是不能丢的。
三、Docker Compose:用 YAML 管理多容器
服务一多,一个个docker run就撑不住了:命令越敲越长,启动顺序没法保证,参数也没个记录。Compose 的思路很直接——把所有docker run收进一个docker-compose.yml:
services:hm-service:build:.ports:-"8081:8080"environment:-DB_HOST=host.docker.internaldepends_on:mysql:condition:service_healthyrestart:alwaysmysql:image:mysql:8volumes:-mysql-data:/var/lib/mysqlhealthcheck:test:["CMD","mysqladmin","ping"]interval:5sretries:10volumes:mysql-data:我挑几个容易和docker run搞混的字段说一下:
services:下面每个键就是一个容器image/build:用现成镜像写image,要从 Dockerfile 构建写buildports:等价于-p,“宿主机:容器”environment:等价于-e,塞环境变量depends_on:声明启动顺序。默认只保证"mysql 容器先启动",配合condition: service_healthy才能真正等到 MySQL 就绪volumes:数据卷,用法和-v一样;顶层的volumes用来声明命名卷restart:容器挂了自动拉起,always表示除非手动停,否则一直重启healthcheck:定期检查容器里的服务是否真的可用
剩下的字段用到的时候查文档就行,不用背。
depends_on 只管启动,不管就绪
一开始只写了depends_on: - mysql,以为这样 hm-service 就会等 MySQL 准备好了再启动。结果 hm-service 起来之后还是报连接失败——因为它只保证"mysql 容器启动了",不保证"mysql 服务能连了"。
后来在 mysql 上加了 healthcheck,再用condition: service_healthy,才能真正等到 MySQL 可以接受连接了再启动 hm-service:
depends_on:mysql:condition:service_healthyhealthcheck 里的test是检查命令,mysqladmin ping能通就说明 MySQL 活着。interval是检查间隔,retries是失败重试次数,超过次数 Docker 就认为容器不健康。
还有一个点:restart: always是生产部署的标配。学习阶段可能感觉不到它的价值,但容器因为意外挂了、服务器重启了,这行配置能让服务自动恢复,不用人手动去拉。部署实战里,这行配置比任何优化都实在。
常用命令
| 命令 | 作用 |
|---|---|
docker compose up -d | 后台启动所有服务 |
docker compose down | 停止并删除容器(默认保留命名卷) |
docker compose logs -f | 实时查看日志 |
Compose 不是"另一个 Docker",它只是把散落的 docker 命令文件化。yml 里每个字段几乎都能对应到上篇学过的某个命令。
四、部署实战:把 hm-service 跑起来
这次把上篇零散学的东西串了一遍。先mvn package -DskipTests打出 jar,然后基于openjdk:17-jdk-slim写 Dockerfile,docker build构建,最后:
dockerrun-d--namehm-service-p8081:8080-eDB_HOST=host.docker.internal hm-service:1.0起容器。端口映射 8081:8080,浏览器访问宿主机 8081,流量转进容器的 8080。
构建和启动都没出问题,结果服务一起来就抛反射错误——MyBatis-Plus 序列化 lambda 表达式时反射访问 SerializedLambda,抛出InaccessibleObjectException:
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.lang.Class java.lang.invoke.SerializedLambda.capturingClass accessible: module java.base does not "opens java.lang.invoke" to unnamed module @27abe2cd at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354) at com.baomidou.mybatisplus.core.toolkit.SetAccessibleAction.run(SetAccessibleAction.java:18) at com.baomidou.mybatisplus.core.toolkit.SerializedLambdaMeta.<clinit>(SerializedLambdaMeta.java:19) at com.baomidou.mybatisplus.core.toolkit.LambdaUtils.extract(LambdaUtils.java:55)查了一下,根源在 JDK 9 引入的模块系统:JDK 16 之后强封装变成默认行为,java.base不再对未命名模块开放java.lang.invoke这些内部包,MyBatis-Plus 反射读 SerializedLambda 内部字段时正好被拦住。平时在本地 IDEA 里跑感觉不到,IDE 往往自带各种宽容参数;换成干净的容器,就现原形了。
报错信息里写清了是哪个包没开,对着加--add-opens就行:
ENTRYPOINT ["java", "--add-opens", "java.base/java.lang.invoke=ALL-UNNAMED", "-jar", "/app.jar"]改完重新 build,服务顺利起来。
那一刻我有点后怕。如果不是容器逼着我把环境暴露出来,我可能一直不知道自己的项目依赖了 IDE 的宽容参数。"在我电脑上能跑"这句话,在容器面前毫无意义。
五、小结
下篇补上了数据卷、Compose、hm-service 部署实战,外加一个印象很深的 JDK 17 反射坑。连同上篇的镜像、容器、Dockerfile 和网络,Docker 基础部分到这里闭环了。
两篇写完回头看,Docker 最核心的东西其实就两个:分层和隔离。镜像分层叠加,容器各自隔离。Compose 管的是把这一堆隔离的东西组合起来,数据卷管的是隔离边界上的持久化。上篇的镜像、容器、网络,下篇的数据卷、Compose,说到底都是围绕这两个词在转。
难的真不是命令,是理解背后的设计思想。命令忘了随时能查文档,思想通了,遇到没见过的报错也知道往哪儿查。