Docker 卷挂载后是空的?etcd 数据全在可写层,容器重建即丢
磁盘清理时用 docker ps -as 巡检各容器可写层,etcd 容器赫然 385M——而它挂载的数据卷 etcd_data 里只有 8K。当时磁盘清理没敢动它:一旦 recreate,Milvus 的全部元数据就随可写层一起蒸发。
在开发 AI客服 时遇到此问题——7×24小时AI客服,快速解答产品使用问题;它的知识库检索跑在 Milvus 上,而 Milvus 的元数据全靠这个 etcd。
TL;DR
compose 的 volumes: 只负责「把卷挂到容器某个路径」,应用往哪写由它自己的参数决定。etcd 没设 data-dir 时默认写到工作目录下的 default.etcd——挂载点 /etcd 它根本没用,385M 数据全在可写层,docker compose up -d 的任何一次重建都会把它带走。修复用 etcd 原生迁移路径:在线快照 → restore → 停机换数据 → 补 ETCD_DATA_DIR → 带卷重建,零丢失,全程停机不到两分钟。
问题现象
compose 里的 etcd 服务看起来是「正确」的——卷挂了:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
# ... 没有任何 data-dir 相关配置
volumes:
- etcd_data:/etcd
但两个数字对不上:
$ docker ps -as --format "{{.Names}}\t{{.Size}}" | grep etcd
rag-service-etcd-1 385MB (virtual 199MB) ← 可写层 385M
$ docker exec rag-service-etcd-1 du -sh /etcd
8K /etcd ← 挂载的卷是空的
$ docker exec rag-service-etcd-1 ls -la / | grep etcd
drwx------ 3 root root 4096 default.etcd ← 数据在这:容器根目录
根因
挂载 ≠ 使用。 volumes: etcd_data:/etcd 只保证「卷被挂到 /etcd 这个路径」,至于 etcd 往哪写,由它自己的 --data-dir 参数决定。etcd 的缺省 data-dir 是工作目录下的 default.etcd——这份 compose 既没设 ETCD_DATA_DIR 环境变量,也没传 --data-dir 命令行参数,于是 etcd 在容器根目录自建了 default.etcd,把全部数据写进了可写层。挂载点 /etcd 从第一天起就是空的。
这类「卷是摆设」最阴险的地方在于平时完全无症状:服务正常跑、数据正常读写、监控全绿。只有当你需要 docker compose up -d --force-recreate(换配置、回收可写层、迁移宿主机)的那一刻,可写层被整体丢弃,数据才消失——而那通常是你在做紧急运维、最不需要第二起事故的时候。
解决方案
用 etcd 原生的快照迁移路径,在线快照保证一致性,短暂停机只出现在最后的数据切换。
步骤 1:在线一致性快照
docker exec rag-service-etcd-1 sh -c \
'ETCDCTL_API=3 etcdctl --endpoints=http://127.0.0.1:2379 snapshot save /tmp/etcd-snap.db'
docker cp rag-service-etcd-1:/tmp/etcd-snap.db /root/etcd-snap.db
snapshot save 对运行中的 etcd 是安全的(不走文件复制,走 Raft 后端快照),不停机、不锁写。
步骤 2:restore 成目标 data-dir 结构
docker run --rm -v /root:/host quay.io/coreos/etcd:v3.5.5 \
etcdctl snapshot restore /host/etcd-snap.db --data-dir /host/etcd-restored
restore 会生成带完整 member/ 结构的数据目录(快照本身不能直接当 data-dir 用)。
步骤 3:停机切换,数据入卷
docker stop rag-service-etcd-1
rm -rf /var/lib/docker/volumes/etcd_data/_data/* # 卷是空的,清掉挂载残留
cp -a /root/etcd-restored/. /var/lib/docker/volumes/etcd_data/_data/
步骤 4:补上缺失的配置,带卷重建
etcd:
environment:
- ETCD_DATA_DIR=/etcd # ← 缺的就是这一行
# ...
volumes:
- etcd_data:/etcd
docker compose up -d etcd # 配置变更触发 recreate,新容器以卷为 data-dir
步骤 5:验证
docker exec rag-service-etcd-1 etcdctl --endpoints=http://127.0.0.1:2379 endpoint health
du -sh /var/lib/docker/volumes/etcd_data/_data # 数据应在卷里
docker ps -as | grep etcd # 可写层应回落到 KB 级
本次修复后:可写层 385M → 8KB,数据落卷 123M,上游 Milvus 自动重连、元数据读写正常。
注意事项
- 给 stateful 容器(etcd/postgres/redis/minio)做巡检时,把「可写层大小 vs 卷大小」当固定对账项:
docker ps -as的 SIZE 一栏异常膨胀而卷很空,几乎必然是数据没落卷。 - 判断「数据在哪」要追进程实际读写的路径(
docker top看参数、进容器找数据目录),不能只看 compose 有没有 volumes 行——有挂载不等于被使用。 - 单节点 etcd 的快照 restore 会重建 member 元数据,只适用于单节点场景;多节点集群的迁移请走成员变更流程。
- 同类排查方法论见 容器日志吃满服务器磁盘?docker system df 的 reclaimable 是误报——
docker ps -as可写层观察是同一把刀;挂载路径被覆盖的另一形态见 Docker Volume 覆盖 Bind Mount。
常见问题
Docker 挂载卷后目录是空的怎么回事?
两种常见原因:一是空卷挂载遮盖了镜像内原有目录(Docker 的既有行为);二是应用的数据目录参数根本没指向挂载点,数据写进了容器可写层——本文的 etcd 案例就是这种。用 du 分别看卷目录和容器可写层的大小,一眼可辨。
etcd 数据怎么安全迁移到新的 data-dir?
etcdctl snapshot save 在线做一致性快照(不停机),etcdctl snapshot restore --data-dir 生成带 member 结构的目标目录,停容器后把数据放到新位置、以新 data-dir 启动,最后 endpoint health 加上游重连验证。全程停机只在切换那一段。
compose 里挂了卷为什么没生效?
volumes: 只把卷挂到容器的某个路径;应用写哪里由它自己的配置决定——etcd 看 data-dir,postgres 看 PGDATA,redis 看配置文件里的 dir。挂载点和应用配置对不上,卷就只是个空目录,数据全在可写层,重建即丢。