文章摘要
飞宇 GPT

Docker 存储卷与数据持久化实战:别让容器删了数据也没了

引言

我第一次把生产数据库搬进 Docker,是在两年前一个内部工具的容器化项目里。当时的心态很轻——docker run 一个 Postgres 镜像,端口一映射,业务跑起来,完事。直到某天夜里同事为了”清理环境”,顺手 docker rm -f 了那个容器,第二天全组发现昨天写的工单记录全没了。那一刻我才真正理解一句 Docker 圈的老话:容器是临时的,数据不能跟着临时

这篇复盘,把我们在存储持久化上踩过的坑、想明白的事、最后定下来的方案,一次讲透。核心要回答三个问题:容器里的数据到底存在哪?怎么保证它不丢?数据库这种有状态服务,到底该怎么容器化才稳?

如果你正在把任何”数据不能丢”的服务往容器里搬,这篇文章能帮你少走至少两次弯路。

一、先搞懂:容器的可写层为什么不能放数据

要理解 Docker 的存储,第一件事是搞懂一个容器在磁盘上到底长什么样。

Docker 采用了”分层镜像”设计:镜像是只读层,叠加若干只读层之后,最上面再盖一层可写层(writable layer)。容器运行期间所有对文件系统的写操作——新建、修改、删除——都落在这层可写层上,由存储驱动(storage driver,overlay2 是默认)管理。这层东西有几个致命的属性:

  1. 生命周期等于容器。容器一删(docker rm),可写层立刻跟着没了。这是设计如此,不是 bug。
  2. 性能差。可写层用的是写时复制(copy-on-write),一个 1.5 GB 的大文件你要改一个字节,存储驱动得先把整个文件从只读层复制到可写层再改,写放大严重。在 overlay2 下我们在一台 NVMe 机器上实测,直接写可写层的随机写 IOPS 大概只能跑到宿主机裸盘的 40%;如果换回老的 aufs/devicemapper,会更惨。
  3. 跨容器无法共享。A 容器写的文件,B 容器看不见,因为可写层是 per-container 的。
  4. 绑定迁移困难。可写层混在 Docker 的内部目录里(/var/lib/docker/overlay2/...),路径又长又随机,你想手动拷贝它做迁移,基本是自找麻烦。

所以结论非常干脆:凡是要持久化的数据,一律不能放在可写层,必须挂出去。这就是 Docker 提供三种挂载方式(volume / bind mount / tmpfs)的根本原因。

二、三种挂载方式:选错一个,麻烦一年

Docker 提供三种把”外部存储”接到容器里的方式。名字看着像近亲,行为和适用场景差得很远。

2.1 一张图说清三者差别

对比项 volume(数据卷) bind mount(绑定挂载) tmpfs(内存盘)
管理方 Docker daemon 宿主机文件系统 主机内存
存储位置 /var/lib/docker/volumes/ 宿主机任意路径 内存(无落盘)
创建方式 docker volume create-v name:/path -v /host/path:/container/path --tmpfs /path--mount type=tmpfs
跨主机迁移 支持,配合 volume driver 可上 NFS/云盘 不支持(强绑定宿主机路径) 不支持
权限模型 Docker 管,容器内可任意 UID 依赖宿主机目录的真实 UID/GID 内存,重启即失
备份/迁移 官方支持,命令清晰 直接当宿主机文件备份 不需要备份(临时数据)
生命周期 独立于容器,docker rm 不删卷 独立于容器 容器停就没了
推荐场景 数据库、有状态服务、需分享的数据 开发挂源码、配置文件 临时缓存、敏感 token

下面这张对比是我在团队内部培训时反复强调的一句话:生产环境里有状态的数据,默认一律走 volume;bind mount 留给开发态;tmpfs 留给”绝对不能落盘”的临时数据

2.2 volume:官方推荐的生产级方案

volume 是 Docker 亲儿子,由 daemon 统一管理在 /var/lib/docker/volumes/<卷名>/_data。它有几个 bind mount 给不了的好处:

  • 跨容器共享:多个容器可以挂同一个卷,常用于”一个容器写数据 + 一个 sidecar 容器做备份”。
  • 跨主机可迁移:配合 volume driver(plugin),volume 的后端可以是 NFS、Ceph、AWS EBS、阿里云云盘,容器在哪台机器,数据就跟到哪台机器。这是 bind mount 永远做不到的。
  • 生命周期独立docker rm 不会删 volume,你得显式 docker volume rm 才行——这条规则后来救过我们好几次。

volume 又分两种,必须分清:

命名卷(named volume)——有名字,好管理,推荐:

1
2
3
4
5
6
7
8
9
10
# 先建卷,再挂载
docker volume create pg_data
docker run -d \
--name postgres \
-v pg_data:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=xxx \
postgres:16

# 查看卷在哪
docker volume inspect pg_data

匿名卷(anonymous volume)——没名字,只是一串 hash。某些镜像(比如官方 Postgres)的 Dockerfile 里会 VOLUME /var/lib/postgresql/data,你启动时不挂命名卷,Docker 就会自动给它分配一个匿名卷。docker rm 容器时这个匿名卷会变成”悬空卷”(dangling volume),日积月累会占满磁盘——这是我们后面会重点讲的坑。

1
2
3
4
# 这一行跑完,会默默生成一个 hash 命名的匿名卷
docker run -d -e POSTGRES_PASSWORD=xxx postgres:16
# 看悬空卷
docker volume ls -f dangling=true

我们的规范是:生产环境禁止匿名卷,所有卷必须有 项目_角色 的命名约定(如 crm_pg_datacrm_redis_data),方便巡检和备份。

2.3 bind mount:强大但危险

bind mount 直接把宿主机的一个目录挂进容器。它的本质是”路径映射”:

1
2
3
4
5
# 把宿主机 /data/pg 挂到容器内的数据目录
docker run -d \
-v /data/pg:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=xxx \
postgres:16

优点是直观——数据就在 /data/pg,备份就是 tar,迁移就是 rsync,谁都会。但它有两个让生产环境我不敢用的特性:

第一,强绑定宿主机路径。容器一换机器,数据不会跟着走,得你自己保证目标机器上有同样的 /data/pg。这跟”容器随处漂”的理念是冲突的。

第二,权限是宿主机文件系统的真实权限,Docker 不插手。这一条是无数 UID/GID 痛苦的根源,后面单独复盘。

所以我们的定位是:bind mount 只在开发态用——比如把本地源码目录挂进容器做热重载,或者把一份配置文件挂进去改改看效果。生产环境一律 volume。

2.4 tmpfs:给”不能落盘”的数据

tmpfs 把挂载点放在主机内存里,容器一停数据就没了。听起来像鸡肋,但它的用途很特定:

  • 安全敏感数据:API token、TLS 私钥这种”绝对不能写磁盘”的东西,挂成 tmpfs,容器一销毁内存里就没了,没有残留风险。
  • 高频临时缓存:比如某些会话缓存,写穿磁盘不值得,tmpfs 速度极快又自清理。
1
2
3
4
docker run -d \
--name api \
--tmpfs /app/cache:size=512m,mode=1777 \
my-api:latest

需要留意 tmpfs 吃的是宿主机内存,size 一定要给上限,不然一个失控的缓存能把宿主机内存吃光。

三、数据库容器化的存储方案:数据放哪、怎么不丢

讲清楚三种挂载,回到最初的问题:数据库这种”数据就是命”的服务,到底该怎么容器化?下面是我们团队最终定下来的方案,跑在一个内部 CRM(约 200 人用)的 Postgres 上稳定了半年多

3.1 落点设计:命名卷 + 严格禁止匿名卷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1. 建专用网络
docker network create crm_net

# 2. 建命名卷(数据 + 日志分离)
docker volume create crm_pg_data
docker volume create crm_pg_wal

# 3. 启 Postgres,数据目录和 WAL 分别挂两个卷
docker run -d \
--name crm_pg \
--restart unless-stopped \
--network crm_net \
-v crm_pg_data:/var/lib/postgresql/data \
-v crm_pg_wal:/var/lib/postgresql/wal \
-e POSTGRES_PASSWORD=xxx \
-e POSTGRES_DB=crm \
-e PGDATA=/var/lib/postgresql/data/pgdata \
postgres:16

几个细节值得说明:

  • 数据和 WAL 分卷:方便单独备份,也方便 WAL 卷单独用更高 IOPS 的存储(volume driver 换成云盘 SSD)。
  • PGDATA 显式指定子目录:Postgres 官方镜像要求 PGDATA 必须是挂载点下的子目录,不是挂载点本身,否则初始化会报错。这是个新手必踩的坑。
  • --restart unless-stopped:避免容器意外退出后没人拉起来。
  • 独立 network:让后续要连库的应用容器都进 crm_net,通过容器名 crm_pg 解析,省去暴露端口的风险。

3.2 有状态服务的容器化清单

我把有状态服务容器化归纳成一张 checklist,每次新上一个服务都过一遍:

  1. 数据落点是不是命名 volume(不是匿名卷,不是可写层)?
  2. 卷名是否符合命名规范(项目_角色[_用途])?
  3. WAL/日志和数据是否分卷,方便独立备份和扩容?
  4. 是否配置了 volume 的定期备份(见下一节)?
  5. 容器 --restart 策略是否设置?
  6. 备份文件是否落到另一台机器 / 对象存储(本地备份等于没备份)?
  7. 删除容器前是否确认过它挂的卷不被删?
  8. 是否有监控告警覆盖卷的容量使用率?

最后两条是吃过亏才加上的。

四、volume 的备份、迁移与恢复

这是整篇文章最该抄走的部分。volume 既然独立于容器,备份恢复就是标准的”把卷里的文件打包”操作。

4.1 备份

思路:临时启一个容器,挂上目标卷,再挂一个宿主机目录,把卷内容 tar 出来。

1
2
3
4
5
6
# 把 crm_pg_data 卷打包成宿主机上的 tar 包
docker run --rm \
-v crm_pg_data:/data:ro \
-v /backup:/backup \
alpine \
tar czf /backup/crm_pg_data_$(date +%F).tar.gz -C /data .

关键点:

  • 源卷以 :ro 只读挂载,备份过程绝不碰原数据。
  • 输出落到宿主机 /backup,下一步用 rcloneaws s3 cp 推到对象存储。
  • 对运行中的数据库,热备要先 dump 再 tar——直接 tar 一个正在写的 Postgres 数据目录,恢复出来可能不一致。正确做法是 pg_dumppg_basebackup
1
2
3
4
5
# 逻辑备份(轻量、跨大版本)
docker exec crm_pg pg_dump -U postgres -Fc crm > /backup/crm_$(date +%F).dump

# 物理基础备份(可用于 PITL 时间点恢复)
docker exec crm_pg pg_basebackup -U postgres -D - -Ft -z -P > /backup/base_$(date +%F).tar.gz

4.2 迁移

把一个卷从一台机器搬到另一台机器,标准流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 源机:打包
docker run --rm -v crm_pg_data:/data:ro -v /tmp:/backup alpine \
tar czf /backup/crm_pg_data.tar.gz -C /data .

# scp 到目标机
scp /tmp/crm_pg_data.tar.gz target:/tmp/

# 目标机:先建空卷,再灌数据
docker volume create crm_pg_data
docker run --rm \
-v crm_pg_data:/data \
-v /tmp:/backup \
alpine \
tar xzf /backup/crm_pg_data.tar.gz -C /data

4.3 恢复

误删数据或换新机器,恢复就是上面迁移的后半段。但有两条铁律:

  • 恢复前一定先停掉正在用这个卷的容器,避免文件冲突。
  • 恢复后用同名容器重新挂载,不要改路径,应用代码里写死的连接串才不会错。
1
2
3
4
5
6
docker stop crm_pg
docker volume rm crm_pg_data # 删空卷(如果是恢复到新机器这步省略)
docker volume create crm_pg_data
docker run --rm -v crm_pg_data:/data -v /backup:/backup alpine \
tar xzf /backup/crm_pg_data_2026-06-30.tar.gz -C /data
docker start crm_pg

我们在团队里把这些封装成一个 volbak 脚本,cron 每天凌晨跑一次全量,再推到 OSS。脚本不复杂,关键是让备份这件事不依赖人记性

五、踩坑复盘:这三件事我都没躲过

讲了正确的做法,再讲讲我们是怎么”知道”这些做法是对的——全是被坑出来的。

5.1 坑一:bind mount 的 UID/GID 错位

那是上 Redis 的时候。我图省事用 bind mount:

1
2
3
docker run -d --name redis \
-v /data/redis:/data \
redis:7

起是起来了,但 Redis 容器内的 redis 用户 UID 是 999,而宿主机 /data/redis 是我 root 建的,属主 root。结果 Redis 一写 dump.rdb 直接 Permission denied,起来又退出,起来又退出。

更阴的版本是这样:容器跑起来了(因为我 chmod 777),但宿主机上那些数据文件的真实属主变成了 999:999——一个宿主机上根本不存在的 UID。等我想用宿主机上的脚本去操作这些文件,权限模型一团乱,备份脚本也跑不动。

后来定下的规矩是:

  • 生产环境一律用 volume,不用 bind mount。volume 由 Docker 管,权限问题最少。
  • 真要用 bind mount,必须显式指定容器内运行用户的 UID,并保证宿主机目录的属主 UID 一致
1
2
3
4
5
6
7
8
9
# 宿主机建一个专用账户,UID 提前规划
useradd -u 1099 redis_host
mkdir -p /data/redis && chown 1099:1099 /data/redis

# 容器内也用 1099
docker run -d --name redis \
-v /data/redis:/data \
-u 1099:1099 \
redis:7
  • 或者直接用 --user 跑容器,让两边 UID 对齐,别让 Docker 默认的随机高 UID 偷偷搞乱宿主机文件系统。

5.2 坑二:磁盘被匿名卷撑爆

某天监控告警一台机器磁盘 95%。上去 df -h 一看,/var/lib/docker 占了 80%docker system df 一查:

1
2
3
4
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images 24 8 18GB 12GB
Containers 12 12 800MB 0B
Local Volumes 47 6 220GB 198GB

47 个卷,活跃的只有 6 个,198 GB 都是可回收的悬空卷。原因就是官方 Postgres/MySQL 镜像的 Dockerfile 里写了 VOLUME 指令,团队同学每次 docker run 不挂命名卷,Docker 就默默建一个匿名卷;docker rm 容器时这个匿名卷不会跟着删,越积越多。

清理方法:

1
2
3
4
5
# 删所有未被任何容器引用的卷(一定要先确认,删了不可逆!)
docker volume prune -f

# 看看具体有哪些悬空卷
docker volume ls -f dangling=true

但这只是擦屁股。真正的预防是规范:所有有状态容器启动时必须挂命名卷,并在镜像扫描里加一条检查——Dockerfile 里有 VOLUME 指令的服务,部署模板里必须提供对应的命名卷映射。后来这条规范让我们再没出现过匿名卷堆积。

5.3 坑三:误删 volume 丢数据

这是最痛的一次。一次环境清理,同事执行了:

1
2
docker rm -f crm_pg
docker volume rm crm_pg_data # 想着"反正要重建"

他不知道的是,那天的逻辑备份脚本因为 OSS 凭据过期已经三天没成功推过了。于是这一条 volume rm 把最近三天的工单数据全送走了。我们花了一整天从应用层的操作日志里一点点复原关键数据,那滋味,谁经历谁知道。

教训后来凝结成三条硬性规则:

  1. docker volume rm 在生产环境默认禁用,要走变更单、要双人确认。
  2. volume 删除前先 docker volume inspect 看挂载点,把数据先 tar 一份到 /backup 再删
  3. 备份成功率要监控——脚本”跑了”和”成功了”是两回事,必须对备份产物的大小、上传 OSS 的 HTTP 200 做校验告警。

具体到一条命令的保险丝,可以在 shell 里给 docker volume rm 包一个 alias:

1
2
3
4
5
6
7
8
9
# 危险命令加二次确认
alias docker='docker'
safe_volume_rm() {
local v=$1
echo "即将删除卷: $v"
docker volume inspect "$v" | grep Mountpoint
read -p "已备份? 输入卷名确认删除: " confirm
[ "$confirm" = "$v" ] && docker volume rm "$v" || echo "取消"
}

工具层面的兜底永远不如流程层面——但每多一道关卡,就少一次半夜被叫起来。

六、存储驱动:overlay2 为什么是默认

最后聊一个偏底层但绕不开的话题:Docker 用什么存储驱动管理镜像层和可写层。

通过 docker info | grep "Storage Driver" 能看到当前用的驱动。常见的几种:

存储驱动 后端文件系统 稳定性 性能 适用场景
overlay2 ext4 / xfs 极稳,官方推荐 最优 Linux 4.0+,生产默认
fuse-overlayfs 任意 良好 rootless 场景
devicemapper 块设备 老牌,需调参 一般 老系统,已不推荐新用
btrfs btrfs 良好 良好 已用 btrfs 做根分发的系统
zfs zfs 良好 良好 已用 zfs 的系统(如 SmartOS)
vfs 任意 极慢 仅调试用,无 CoW

选型结论只有一句话:除非你已经在用 btrfs/zfs 做根文件系统,否则一律 overlay2。它是 overlayfs 的第二代实现,Linux 主线原生支持,性能最接近裸盘,社区投入最大。我们在两种文件系统上都跑过同样的镜像构建基准,overlay2 比 devicemapper loop-lvm 快接近一倍,而 devicemapper 在 loop 模式下还容易遇到池满不可恢复的问题,是历史包袱。

btrfs 和 zfs 各有强项——btrfs 对大量小镜像的去重很好,zfs 的快照和端到端校验是它的招牌。但它们要求宿主机根文件系统就是它本身,否则装 volume driver 的复杂度不划算。对绝大多数团队来说,ext4/xfs + overlay2 是性价比最高的组合。

切换驱动的命令(注意:会清掉所有镜像和容器,操作前务必备份):

1
2
3
4
5
6
7
# /etc/docker/daemon.json
{
"storage-driver": "overlay2"
}

# 重启 docker
sudo systemctl restart docker

结语:容器是临时的,数据不是

写到这里,Docker 存储持久化这件事其实就这么几条主线:

  • 可写层不能放数据——性能差、随容器删除丢失、迁移困难。
  • 三种挂载各司其职——volume 给生产,bind mount 给开发,tmpfs 给临时敏感数据。
  • 数据库容器化,命名卷 + 数据/WAL 分离 + 定期备份 + 异地存放,四件套缺一不可。
  • 备份不验证等于没备份,清理脚本不确认等于埋雷——这是我们用两次事故换来的认知。
  • 存储驱动一律 overlay2,除非你的根文件系统已经是 btrfs/zfs。

容器技术本身把”环境”和”数据”做了一次彻底的解耦,这本来是好事。但解耦的代价是:你得主动把”数据怎么活下来”这件事想清楚、写进流程、跑进脚本、盯进监控。任何一件依赖人记性的事,早晚都会出问题——这是我做完这一整轮容器化存储改造后,最想留给同行的一句话。

希望这篇复盘能让你在凌晨三点,少接一个”数据没了”的电话。