一句话速览:Docker 的心智模型只有一句话——镜像分层 + 容器可写层 + 数据卷,三层各有什么生命周期想清楚了,”插件丢失””数据丢失””重建后配置还原”这类问题全部可以提前预判;编排层面则要记住:depends_on 管顺序不管可用,up -d 成功不代表健康

涉及原始记录:07-面试问题/验证问题/03-基础设施与中间件问题.md P2-02、P1-03、P1-05;
07-面试问题/开发问题/02-框架与中间件集成问题.md P1-05(ES 插件持久化);
07-面试问题/开发问题/01-依赖与配置问题.md P1-06、P1-07、P1-08(多服务脚本管理、Nacos 内存调优、健康检查超时)

最近修订:2026-08-07

目录


技术点 1:数据卷(Volume)—— 决定什么东西能在容器重建后存活

难度:⭐⭐ | 掌握要求:可写层 vs 数据卷生命周期、restart vs recreate 的区别

核心概念

理解 Docker 容器的存储模型,关键是分清”容器的可写层”和”数据卷”两种东西的生命周期:

  • 容器可写层:容器运行时在镜像基础上叠加的一层可写文件系统,容器删除(不是 stop,是 rm)或者被”重建”(--force-recreate、重新 up 一个同名但配置变了的服务)时,这一层连同里面的所有修改会被丢弃,回到镜像原始状态。
  • 数据卷(named volume / bind mount):显式挂载的存储,生命周期独立于容器实例,容器怎么重建,只要 volume 还在,数据就还在。

任何不是通过 volumes 显式挂载出来的目录,写进去的东西都只活在容器可写层里,本质上是”临时的”,即使这个容器可能会正常运行很长时间不重启——它随时可能因为某次部署变更、镜像升级而被重建,届时所有没有落到数据卷里的修改都会消失。

我们踩过的坑

开发问题 P1-05:ES 插件目录没挂载数据卷,容器重建后插件丢失

docker exec livin-es elasticsearch-plugin install ... 这条命令是在容器内部执行的,装好的 IK 插件文件写在了容器的可写层(/usr/share/elasticsearch/plugins 这个路径当时没有被 volumes 挂载出来)。容器只是简单 restart 时插件还在,但只要触发了”重建”(比如改了 compose 配置后 up,或者显式 --force-recreate),插件就消失了,依赖这个插件的自定义分词器索引也会变成不可用状态(red)。

根治方案是把插件目录也纳入数据卷管理:

1
2
3
4
5
6
7
8
9
volumes:
es-data:
es-plugins: # 新增:让插件的生命周期独立于容器实例

services:
elasticsearch:
volumes:
- es-data:/usr/share/elasticsearch/data
- es-plugins:/usr/share/elasticsearch/plugins

迁移已经装好的插件到新卷(避免重装)的操作顺序值得记住,这是一个通用的”给已运行容器补挂载卷”的操作模式:

1
2
3
4
5
6
7
8
9
10
# 1. 从旧容器里把数据先备份到宿主机
docker cp livin-es:/usr/share/elasticsearch/plugins /tmp/es-plugins-backup

# 2. 应用新的 compose 配置重建容器(这一步之后,新的数据卷是空的)
docker compose -f docker-compose-infra.yml up -d --no-deps elasticsearch

# 3. 把备份的数据拷贝进新容器(此时实际是写入新的数据卷),修正文件属主
docker cp /tmp/es-plugins-backup/analysis-ik livin-es:/usr/share/elasticsearch/plugins/analysis-ik
docker exec -u root livin-es chown -R elasticsearch:elasticsearch /usr/share/elasticsearch/plugins/analysis-ik
docker restart livin-es

验证问题 P1-03:容器没起来但 compose 命令没报错

docker compose up -d 是后台执行的,如果某个容器因为内存不足或启动超时失败,命令本身可能不会有明显报错,容易让人误以为所有服务都正常启动了。必须显式检查容器的实际状态,不能只看 docker compose up 命令有没有报错:

1
docker ps -a --filter name=elastic   # 确认容器状态,而不是假设它成功了

如果服务有健康检查配置,更可靠的做法是轮询等待健康检查通过,而不是固定 sleep 一个时间就假设已经就绪:

1
2
3
4
5
for i in (<spanclass="builtin">seq</span>130);<spanclass="keyword">do</span></span><br><spanclass="line">code=(<span class="built_in">seq</span> 1 30); <span class="keyword">do</span></span><br><span class="line">  code=(curl -sf -o /dev/null -w "%{http_code}" http://localhost:9200 2>/dev/null || echo "000")
[ "$code" = "200" ] && break
sleep 2
done

必须记住的结论

  • 任何通过 docker exec 进入容器内部做的修改(装软件、改配置文件、写文件),只要对应路径没有挂载数据卷,这个修改就是”一次性”的,下次容器重建就会消失。养成习惯:手动进容器改了什么东西之后,先问自己”这个目录持久化了吗”,没有的话要么补上 volume 配置,要么这次修改注定要在下次重建时重做一遍。
  • docker compose up -d 命令执行成功,不代表容器真的健康运行,尤其是后台模式下,启动失败可能被悄悄吞掉。判断服务是否就绪,永远以 docker ps 查看容器实际状态、或者主动探测健康检查端点为准,不能只看命令的退出码。

技术点 2:容器编排的依赖顺序(depends_on + healthcheck)

难度:⭐⭐ | 掌握要求:启动顺序 vs 就绪的区别、service_healthy 条件、脚本层等待逻辑

核心概念

多容器应用之间往往有依赖关系(业务服务要等 MySQL 就绪才能连接),但 Docker Compose 默认的 depends_on 只保证启动顺序,不保证”依赖的服务已经可以正常提供服务”——一个 MySQL 容器”启动了”(进程起来了)和”已经初始化完成、可以接受连接了”是两个不同的时间点,中间可能差好几秒到几十秒。

要让 Compose 真正等到依赖服务”可用”再启动下一个,需要给依赖的服务配置 healthcheck,并且在 depends_on 里声明 condition: service_healthy

1
2
3
4
5
6
mysql:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10

我们踩过的坑

验证问题 P2-02:中间件未就绪时业务服务提前启动失败

这条记录本身在文档里更多是作为”最佳实践”被提前预防,而不是一次线上事故复盘,但值得强调的一点是:Docker Compose 层面的 depends_on: service_healthy 只能约束 Compose 管理的容器之间的启动顺序,管不到”Compose 外部、由脚本单独启动的 Java 微服务”。本项目的微服务不是用 Compose 管理的,是用 start-all.sh 脚本单独拉起的 Java 进程,所以真正的等待逻辑要写在这个脚本里,而不能指望 Compose 的健康检查机制覆盖到微服务层面。

这一点在实际操作中体现为一个反复出现的踩坑记录(验证问题 P1-05):start-all.sh 本身不会启动 Docker 中间件容器,只负责检测状态和直接拉起 Java 微服务。如果没有先手动确认中间件(尤其是 Nacos)健康,就直接跑这个脚本,Java 微服务会因为连不上 Nacos/MySQL 而集体启动失败。而且 Nacos 的健康检查通过(HTTP 端口响应)和它的 gRPC 端口(微服务注册发现实际用的协议)真正就绪之间,还有大约 50 秒的滞后——这是本项目实践中总结出的一个具体的经验数值,不是 Nacos 官方文档写的标准值,仅供参考。

必须记住的结论

  • depends_on 默认只管启动顺序(谁先谁后),不管”是否真的可用”;要精确控制”等到真正可用再继续”,必须配 healthcheck + condition: service_healthy
  • 这套依赖等待机制只在 Compose 管理的服务范围内有效。如果应用服务是脚本单独管理、不在同一个 compose 文件里,等待逻辑需要在脚本层面自己实现(轮询健康检查端点),不能假设 Compose 会帮忙处理。
  • 中间件的”健康检查通过”和”所有子功能都真正可用”之间可能存在滞后(比如这里 Nacos 的 HTTP 健康检查和 gRPC 注册端口就绪不是同一时刻),涉及这类中间件时,如果启动脚本里只等健康检查通过就立刻启动依赖它的服务,遇到偶发的连接失败要考虑加一点缓冲等待时间,而不是第一反应怀疑代码逻辑有问题。

技术点 3:资源过载是环境问题,不要误判成代码 bug

难度:⭐⭐ | 掌握要求:排查顺序——先看资源再看代码、docker stats 用法

核心概念

本地开发环境用容器同时跑十几个中间件加十几个 Java 微服务,是对宿主机资源的极限压榨。当 CPU/内存严重超载时,会引发一系列表现上像是代码 bug、实际是资源不足导致的间接故障:容器健康检查超时被判定 unhealthy、服务间 RPC 调用超时、消息消费延迟等等。

我们踩过的坑

验证问题 P1-05:资源过载导致的连锁反应,一度被误以为是业务代码问题

阶段7验证合同链路时,同时运行 19+ 容器和 9~10 个 Java 微服务,出现了两个看起来很像代码 bug 的现象:支付回调后租约状态没有正常流转(消费者没有消费日志),以及某个接口偶发返回网关兜底的”系统内部错误”。

排查过程本身是一个值得记住的方法论:没有一头扎进业务代码找 bug,而是先用 docker stats 看了一眼资源占用,发现多个容器 CPU 占用破百,据此判断更可能是资源过载导致 Nacos/RocketMQ 探测超时,进而引发 Feign 调用超时、消息消费延迟。验证方式是完整走一遍 stop-all.sh alldocker compose down,比单独 docker kill 更彻底)重启整个环境,再重新走一遍完整链路——两个问题都没有再复现,从而确认是资源问题而不是代码逻辑问题。

由此也沉淀出一条实际的操作经验:测试非全链路功能时,没必要每次都启动全部中间件,只启动当前验证场景真正需要的核心组件(比如验证合同/租约/支付链路,只需要 MySQL/Redis/Nacos/RocketMQ/MinIO/Seata,完全不需要 ES/Kibana/各种 Dashboard/SkyWalking/Prometheus/Grafana),可以显著降低资源压力。

必须记住的结论

  • 排查”偶发的、没有明确异常信息、看起来毫无规律的故障”时,先看资源使用情况(CPU/内存/磁盘 IO),而不是立刻扎进业务代码逻辑。资源过载引发的连锁故障往往没有清晰的因果链,代码层面怎么看都找不出毛病,因为问题根本不在代码里。
  • 遇到”这个问题上次出现过,这次重启环境后又没了”的情况,第一反应不是”问题玄学消失了”,而是要考虑是不是资源过载/环境不稳定导致的偶发现象,stop-all.sh all 完整重启环境是一个简单但有效的排查/规避手段。
  • 本地开发环境资源有限时,按需启动中间件(而不是无脑全量启动)是一个能减少很多”莫名其妙”问题的实用习惯。

技术点 4:多服务脚本管理——服务清单是唯一事实来源,且要随规模持续演进

难度:⭐⭐ | 掌握要求:清单漂移问题、JVM 参数与规模的关系、僵尸容器与 init

核心概念

本项目的微服务不是用 Docker Compose 编排的,而是用 start-all.sh/stop-all.sh 两个 Shell 脚本,靠一个写死的 SERVICES=(...) 数组 + PID 文件来管理生命周期。这种”轻量脚本 + PID 文件”的管理方式简单直接,但有一个天然的脆弱点:它没有像 Kubernetes/Compose 那样从声明式配置(如 pom.xml 的 module 列表、Gateway 路由表)自动推导出服务清单,SERVICES 数组是一份完全独立维护的”第二份配置”。任何”新增一个微服务模块”的操作,本质上都需要同时改好几个地方保持一致:根 pom.xml<module>、Gateway 的路由配置、以及这里的 SERVICES 数组——它们之间没有任何强制校验,改漏一个不会有任何编译或运行时报错。

此外,这类脚本里常见的两个”魔法数字”——JVM 内存参数(如 JVM_XMX=256m)、健康检查探测超时(如 curl --max-time 3)——都是在项目早期、服务规模较小时按当时的实际情况设定的,它们的合理性是和”当时的服务数量、当时的宿主机负载”绑定的,不会随着服务规模增长自动调整,也很容易被遗忘在角落里,直到规模涨到某个临界点集中爆发问题。

我们踩过的坑

开发问题 P1-06:新增服务模块忘记同步登记到启动脚本,一键启动静默漏起

新增 livin-adminlivin-file 两个模块时,pom.xmlapplication.yml、Gateway 路由都配置齐全,jar 也编译成功,唯独忘了把它们加进 start-all.sh/stop-all.shSERVICES 数组。后果是这两个服务在”一键启动全部”时被完全跳过,而且脚本不会有任何报错或 [FAIL] 提示,因为 for 循环压根不会遍历到它们——这是最隐蔽的一类问题:所有静态检查(编译、配置文件语法)都通过,只有在”实际有没有对应进程在跑”这个运行时事实层面才能发现异常。排查时还顺带发现 livin-filelivin-notify 的端口配置意外撞车(都是 8090),如果两个服务真的同时被启动,会有一个绑定端口失败退出。

开发问题 P1-07:Nacos 容器 JVM 堆内存配置没有随服务数增长而调整,触发 OOM

docker-compose-infra.yml 里 Nacos 的 JVM_XMX=256m 是服务只有 6~7 个时定的,随着微服务数量涨到 13 个,注册表项、长轮询连接、心跳线程都线性增长,256MB 堆内存长时间运行后必然 OOM——容器整体的内存上限(7.8G)完全够用,问题出在 JVM 参数本身太保守,物理资源和 JVM 实际能用的资源是两回事,扩容宿主机内存对这类问题毫无帮助,只能针对性调大 -Xmx。更麻烦的是,OOM 后容器一度进入”僵尸态”,普通 docker stop/kill 都无法结束,需要给容器补充 init: true 让 Docker 注入专职的 PID 1 进程去正确回收异常退出的子进程。

开发问题 P1-08:健康检查探测超时是按早期负载设定的,服务规模涨上来后频繁误判

start-all.shcurl --max-time 3 探测各服务健康检查,在服务数量少、宿主机负载低的阶段完全够用。规模涨到 13 个微服务 + 9 个中间件容器同时运行后,宿主机负载偶尔冲到 Load Average 15+,明明所有服务都在正常返回 HTTP 200,仅仅因为 3 秒内来不及响应,就被脚本整批误判成 [WAITING],看起来像是”全部服务都启动失败了”,实际上只是探测方法本身对高负载场景没有留出容错空间。

必须记住的结论

  • 多服务管理脚本里任何形式的”服务清单”(数组、配置列表)都要当成和 pom.xml 模块声明、网关路由表同等重要的配置,新增服务的检查清单里必须包含”是否同步更新了启动/停止脚本”这一项,最好和其他改动放在同一次提交里,减少遗漏窗口。
  • 判断”服务是否启动成功”要以运行时事实(进程是否存活、健康检查是否通过)为准,不能只看编译是否成功、配置文件是否完整——配置齐全但没人启动进程,是一种和”启动失败”完全不同、但表现上同样让人困惑的状态。
  • JVM 内存参数、超时阈值这类和”当前系统规模”强相关的配置,不是设一次就一劳永逸的,服务数量、并发量、数据量出现数量级变化时,应该主动回头检查一遍这些历史遗留的参数是否还合理,而不是等到 OOM/大面积误判发生了才回头查。
  • 容器 OOM 后可能进入无法被普通信号结束的僵尸态,给长期运行、内存敏感的容器(尤其是 JVM 类应用)默认加上 init: true,是一个能减少”容器卡死必须 docker rm -f 兜底”情况的低成本加固手段。

技术点 5:镜像分层、网络与资源限制 —— 必补的地基

难度:⭐⭐ | 掌握要求:镜像分层与缓存、多阶段构建、bridge 网络、内存限制行为

镜像分层:为什么 Dockerfile 指令顺序很重要

Docker 镜像是只读层的堆叠:Dockerfile 里每条指令(FROM/RUN/COPY…)生成一个新层,层有缓存——某一层变了,它之后的所有层缓存全部失效。由此得到两条 Dockerfile 最佳实践:

  1. 变化频率低的内容放前面:基础依赖安装(apt-get install)放前面,频繁变化的代码拷贝(COPY . /app)放最后,最大化利用构建缓存。
  2. 多阶段构建(multi-stage build)控制镜像体积:第一阶段用 JDK/Maven 完整镜像编译打包,第二阶段只 COPY 产物 jar 到一个精简 JRE 镜像运行——最终镜像不带编译工具链,体积从 GB 级降到几百 MB,攻击面也更小。
1
2
3
4
5
6
7
8
9
10
# 阶段1:构建
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn -pl livin-house -am package -DskipTests

# 阶段2:运行(只带产物,不带 Maven 和源码)
FROM eclipse-temurin:21-jre
COPY --from=build /app/livin-house/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

容器网络

  • bridge(默认):每个容器分配虚拟网卡和私有 IP,同一 bridge 网络内的容器可以用容器名互相解析(Compose 自动把服务名注册成 DNS)——这就是 compose 文件里 mysql:3306nacos:8848 能直接写服务名的原因。
  • 端口映射 -p 8080:8080 只影响”宿主机访问容器”,不影响容器之间互访——容器间通信走内部网络,不需要映射。
  • 容器访问宿主机:用 host.docker.internal(本项目 prometheus.yml 抓微服务指标就是这么配的),不能写 localhost——容器里的 localhost 是容器自己。这是容器化新手的高频坑。

资源限制与 JVM 的特殊性

  • mem_limit/cpus 可以限制容器资源,超限的行为是内存超限直接 OOM Kill(不是变慢,是杀掉)、CPU 超限只是限流变慢。
  • JVM 与容器限制的版本陷阱:老版本 JDK(8u131 之前)读的是宿主机内存而不是容器 limit,容器限 512M 但 JVM 按宿主机 64G 算堆,必然 OOM Kill。JDK 8u191+/10+ 默认支持容器感知(UseContainerSupport),JDK 21 无此问题——但 JVM_XMX 这类显式参数会覆盖容器感知,本项目 Nacos OOM 案例的本质就是”显式参数太小”而不是”容器内存不够”。
  • 配套认知:容器里 JVM 的 OOM 分两层——Java 堆 OOM(OutOfMemoryError)和容器级 OOM Kill(进程直接被 SIGKILL,日志里什么 Java 异常都没有),后者排查要看 docker inspectOOMKilled 字段和 dmesg。OOM 排查完整方法详见 10-JVM与并发编程.md

从 Compose 到 K8s 的演进认知

Compose 解决的是”单机多容器编排”,K8s 解决的是”多机集群调度 + 自愈 + 弹性伸缩”。概念上有一组对应关系,提前建立映射学 K8s 会快很多:Compose service → K8s Deployment/Service、depends_on+healthcheck → 探针(liveness/readiness probe)、数据卷 → PV/PVC、服务名 DNS → Service 发现。本项目 deploy/k8s/ 目录已开始积累相关配置,作为后续演进方向了解即可。


术语表

术语 含义
容器可写层 容器运行时叠加在镜像上的可写文件层,容器删除/重建即丢失
数据卷(Volume) 生命周期独立于容器的持久化存储,named volume 由 Docker 管理
restart vs recreate 重启保留可写层 / 重建从镜像重新创建、可写层重置
healthcheck + service_healthy 让 Compose 等到依赖”真正可用”再启动后续服务的机制
镜像分层 / 构建缓存 每条 Dockerfile 指令生成一个只读层;某层变化则其后缓存全失效
多阶段构建 编译环境与运行环境分离的 Dockerfile 写法,控制产物镜像体积
bridge 网络 默认容器网络模式,同网络内容器名即 DNS 名
host.docker.internal 容器内访问宿主机服务的特殊 DNS 名
OOM Kill 容器内存超限被内核直接杀掉,区别于 JVM 堆 OOM
init: true 给容器注入专职 PID 1 进程,正确回收僵尸子进程
liveness / readiness probe K8s 探针:判断容器要不要重启 / 要不要接流量