Docker 基础体系 · 第 9/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 存储:Volume、Bind Mount、tmpfs、权限和备份恢复

容器文件系统并不是一个天然持久化的目录。理解 Docker 存储,首先要区分三类位置:

  1. 镜像层和容器可写层:由镜像和存储驱动管理,容器删除后通常随之删除。
  2. Volume:由 Docker 管理的持久化存储。
  3. Bind mount:把主机上的指定路径直接挂载进容器。
  4. tmpfs mount:把内存中的临时文件系统挂载进容器,容器停止后数据消失。

可以把容器访问文件的路径抽象为:

进程
  │
  ▼
容器挂载命名空间中的 /var/lib/app
  │
  ├── 若没有挂载:容器可写层
  ├── Volume:Docker 管理的主机存储
  ├── Bind mount:主机指定路径
  └── tmpfs:内核内存文件系统

挂载发生后,容器内原本位于目标路径的内容可能被隐藏。因此,存储问题通常不是“文件是否存在”这么简单,而是要同时判断:

  • 文件实际位于哪一层;
  • 该层的生命周期是什么;
  • 容器进程以哪个 UID/GID 访问;
  • 挂载是否覆盖了镜像中的目录;
  • 应用停止或崩溃时,数据是否处于一致状态;
  • 备份是否覆盖了真正的数据来源。

一、容器可写层为什么不适合作为持久化存储

使用镜像创建容器时,Docker 通常通过存储驱动提供一个由多层组成的联合文件系统:

只读镜像层 1
只读镜像层 2
只读镜像层 3
容器可写层

读取文件时,存储驱动从上到下查找。修改只读层中的文件时,不会直接改写镜像,而是发生 copy-up:先把文件复制到容器可写层,再在可写副本上修改。

因此:

docker run --name demo alpine sh -c 'echo hello >/data.txt'
docker rm demo

/data.txt 通常只存在于 demo 的容器可写层中。删除容器后,这一层也被删除。

即使执行:

docker commit demo demo:snapshot

也不能把挂载进来的 Volume 或 Bind Mount 数据自动提交到新镜像。docker commit 主要保存容器自身文件系统层,不保存挂载点背后的外部数据。

容器可写层还有两个工程问题:

  • 它通常经过存储驱动的联合文件系统路径,随机写和大量小文件写入可能有额外开销;
  • 容器生命周期和数据生命周期被绑定,docker rm、Compose 的 down 或重新部署可能使数据消失。

因此,容器可写层适合临时状态和调试产生的修改,不应作为数据库、上传文件或队列数据的正式持久化位置。

可以使用下面的命令观察容器自身文件系统的变化:

docker diff demo

但如果 /data 是挂载点,挂载点背后的变化不一定按容器可写层的方式显示。查看整体磁盘占用时:

docker system df -v

它可以帮助区分镜像、容器可写层和 Volume 的占用,但仍不能替代对具体挂载路径的检查。


二、Volume:由 Docker 管理的持久化存储

2.1 Volume 的定义和生命周期

Volume 是 Docker 创建和管理的命名存储对象。容器只通过一个挂载目标访问它,例如:

docker volume create app-data

docker run -d \
  --name app \
  --mount type=volume,source=app-data,target=/var/lib/app \
  alpine \
  sh -c 'while true; do date >> /var/lib/app/events.log; sleep 5; done'

这里有三个不同的名称:

  • app-data:Docker Volume 的名称;
  • /var/lib/app:容器内的挂载目标;
  • Volume 在主机上的实际路径:由 Docker 管理,不应由应用直接假定。

查看 Volume 的元数据:

docker volume inspect app-data

典型结果包含:

[
  {
    "Name": "app-data",
    "Driver": "local",
    "Mountpoint": "/var/lib/docker/volumes/app-data/_data",
    "Scope": "local"
  }
]

Mountpoint 是常见 Linux Engine 配置下的路径,但它不是 Docker 跨平台应用程序接口。应用和备份脚本应优先通过 Docker 挂载 Volume,而不是直接操作这个目录。

删除容器不会自动删除普通 Volume:

docker rm -f app
docker volume ls

app-data 仍然存在。只有显式删除时才会释放:

docker volume rm app-data

以下命令具有更高风险:

docker rm -v app
docker volume prune
docker compose down -v

它们可能删除未再被容器引用的 Volume。尤其是 compose down -v,会删除 Compose 项目创建的命名卷和匿名卷;生产环境执行前必须确认卷的归属和备份状态。

2.2 空 Volume 的初始化复制

当一个新建且为空的 Volume 挂载到镜像中原本已有文件的目录时,Docker 通常会把该目录中的内容复制到 Volume 中。例如,假设镜像包含:

/etc/example/default.conf

执行:

docker volume create example-config

docker run --rm \
  --mount type=volume,source=example-config,target=/etc/example \
  alpine \
  find /etc/example -maxdepth 1 -type f -print

在符合条件的情况下,default.conf 会被复制到 Volume 中。

这个过程的因果关系是:

  1. 镜像的 /etc/example 中已有文件;
  2. example-config 是新建空 Volume;
  3. Docker 将 Volume 挂载到该目标;
  4. 为避免用户因挂载而丢失镜像默认文件,Docker 进行初始化复制;
  5. 后续容器再次使用该 Volume 时,不会每次都重新覆盖已有数据。

如果不希望复制镜像目录内容,可以使用 volume-nocopy

docker run --rm \
  --mount type=volume,source=example-config,target=/etc/example,volume-nocopy \
  alpine \
  ls -la /etc/example

这不是“把镜像目录内容删除”,而是禁止首次挂载时的自动复制。Volume 仍然会遮蔽目标路径原来的镜像内容。

2.3 Volume 驱动和边界

默认的 local 驱动把数据放在 Docker 主机本地。它解决的是:

  • Volume 对象的创建;
  • 生命周期管理;
  • 在容器之间复用;
  • 与 Docker API 集成。

它不自动解决:

  • 跨主机复制;
  • 数据库事务一致性;
  • 异地容灾;
  • 加密和密钥管理;
  • 备份验证。

其他 Volume 驱动可以把后端接到网络存储或第三方存储系统,但具体一致性、锁、故障行为和恢复方式取决于驱动和后端,不能因为“它叫 Volume”就推断出统一的分布式语义。


三、Bind Mount:把主机路径直接暴露给容器

Bind mount 不由 Docker 创建数据目录,而是把主机上的一个具体路径挂载到容器中:

mkdir -p "$PWD/dev-config"

docker run --rm \
  --mount type=bind,source="$PWD/dev-config",target=/etc/myapp \
  alpine \
  sh -c 'echo "inside container" >/etc/myapp/example.conf'

执行后,主机上可以直接看到:

cat dev-config/example.conf

数据流是:

容器进程写 /etc/myapp/example.conf
        │
        ▼
挂载命名空间映射
        │
        ▼
主机 $PWD/dev-config/example.conf

Bind mount 适合以下场景:

  • 开发环境把源代码映射进容器;
  • 需要明确控制主机路径的配置;
  • 主机上的某个目录本身就是外部系统管理的数据目录;
  • 需要让容器输出文件到主机指定位置。

但它也直接扩大了容器对主机文件系统的访问范围。下面的挂载允许容器修改主机目录:

--mount type=bind,source=/srv/app-data,target=/var/lib/app

如果只需要读取,应使用只读挂载:

docker run --rm \
  --mount type=bind,source="$PWD/config",target=/etc/myapp,readonly \
  alpine \
  cat /etc/myapp/app.conf

readonly 限制的是该挂载点内通过容器挂载访问的写操作,不等同于“容器对主机完全没有影响”,也不是对主机管理员或拥有足够权限的进程的安全隔离。

3.1 --mount-v 的重要差异

推荐使用结构更明确的 --mount

docker run --rm \
  --mount type=bind,source=/srv/app,target=/var/lib/app \
  alpine

传统短语法是:

docker run --rm \
  -v /srv/app:/var/lib/app \
  alpine

当 Bind Mount 的源路径不存在时,两者行为常有关键差异:

  • --mount type=bind,... 通常会报错,要求源路径先存在;
  • -v /not/exist:/data 通常会在主机上创建源路径,常见情况下创建为目录。

例如:

docker run --rm \
  --mount type=bind,source=/not/exist,target=/data \
  alpine

可能得到类似:

invalid mount config for type "bind": bind source path does not exist

而短语法可能静默创建 /not/exist。生产脚本更适合使用 --mount,因为路径拼写错误会尽早失败,而不是生成一个空目录后让应用以错误配置启动。

3.2 Bind Mount 会隐藏镜像内容

假设镜像中有:

/app/config/default.yaml

执行:

docker run --rm \
  --mount type=bind,source="$PWD/empty-config",target=/app/config \
  image-name

容器内的 /app/config 显示的是主机 empty-config 的内容,镜像中的 default.yaml 被挂载点遮蔽,而不是被删除。

因此,应用可能出现:

config file not found

解决方法不是重新构建镜像,而是确认:

docker inspect <container>

查看 Mounts,并检查主机源目录是否包含应用所需文件。


四、tmpfs:只存在于内存中的临时文件系统

tmpfs mount 是由 Linux 内核提供的内存文件系统。容器可以像操作普通目录一样读写,但数据不写入持久化 Volume 或主机普通目录:

docker run --rm \
  --mount type=tmpfs,target=/run/cache,tmpfs-size=64m,tmpfs-mode=1770 \
  alpine \
  sh -c 'echo secret >/run/cache/token && cat /run/cache/token'

这里:

  • tmpfs-size=64m 设置该挂载的大小上限;
  • tmpfs-mode=1770 设置初始目录权限;
  • token 在容器退出后不再保留。

也可以使用短语法:

docker run --rm \
  --tmpfs /run/cache:rw,noexec,nosuid,size=64m,mode=1770 \
  alpine

tmpfs 的生命周期是:

容器启动 → 创建或挂载 tmpfs → 进程读写
容器停止/删除 → tmpfs 生命周期结束 → 数据消失

适合放入 tmpfs 的内容包括:

  • 短期缓存;
  • 临时解压目录;
  • 不希望写入持久化磁盘的中间文件;
  • 运行时生成的敏感临时数据。

不适合放入 tmpfs 的内容包括:

  • 数据库主数据;
  • 用户上传文件;
  • 需要跨容器重启恢复的队列;
  • 需要进行离线备份的业务状态。

tmpfs 使用内存资源,但“内存”不应简单理解为永远只占物理 RAM。Linux 可能根据内核配置和压力情况使用交换空间,因此如果数据不能进入交换区,需要结合主机的 swap 策略、容器运行环境和安全要求验证,不能只依赖 tmpfs 这个名称。

tmpfs 还可能增加内存压力。设置 tmpfs-size 可以限制挂载大小,但不应把它误认为完整的容器内存治理方案;容器整体内存限制仍需要单独配置和监控。

Docker 的 tmpfs mount 主要针对 Linux 容器。Docker Desktop 中 Linux 容器运行在 Linux 虚拟机内,其实际存储边界是该 Linux VM;Windows 容器不能直接套用 Linux tmpfs 语义。


五、三种挂载方式的行为对比

特性 Volume Bind Mount tmpfs
数据位置 Docker 管理的存储 主机指定路径 内核内存文件系统
容器删除后 通常保留 保留在主机 消失
是否依赖具体主机路径 通常不依赖 强依赖 不依赖普通主机路径
是否适合数据库主数据 可以,但需处理一致性 可以,但需明确主机管理责任 不适合
是否适合开发代码映射 一般不如 Bind Mount 直接 适合 不适合
是否适合临时秘密或缓存 通常不是首选 暴露到主机 适合短期数据
默认是否跨主机共享
备份方式 挂载 Volume 后导出 直接备份主机路径 通常无持久化备份对象

“数据持久化”只说明数据跨越了容器生命周期,并不说明数据已经有备份。一个没有备份的 Volume 仍然是单点故障。


六、Compose 中的 Volume 和挂载

Compose 把服务声明和存储声明分开。下面是一个最小示例:

services:
  app:
    image: alpine:3.20
    command: >
      sh -c 'while true; do
      date >> /var/lib/app/events.log;
      sleep 10;
      done'
    volumes:
      - app-data:/var/lib/app
      - ./config:/etc/myapp:ro
      - type: tmpfs
        target: /run/cache
        tmpfs:
          size: 67108864
          mode: 1770

volumes:
  app-data:

含义如下:

  • app-data:/var/lib/app 使用一个 Compose 管理的命名 Volume;
  • ./config:/etc/myapp:ro 把 Compose 文件所在目录下的 config Bind Mount 为只读;
  • tmpfs 只在服务运行期间提供 /run/cache
  • 顶层 volumes 声明了 app-data 这个 Volume。

Compose 默认可能根据项目名对资源命名。例如逻辑名为 app-data 的卷,实际 Docker 名称可能包含项目名前缀。查看实际名称:

docker compose config
docker volume ls
docker compose ps

如果要使用已经由管理员创建的 Volume,而不是让 Compose 创建项目专属卷:

services:
  app:
    image: alpine:3.20
    volumes:
      - shared-data:/var/lib/app

volumes:
  shared-data:
    external: true

此时 shared-data 必须预先存在:

docker volume create shared-data
docker compose up -d

否则 Compose 启动会失败。external: true 的语义是“不由当前 Compose 项目管理其生命周期”,不是“自动跨主机共享”。


七、权限:挂载成功不等于进程可读写

Linux 文件权限判断的核心是:内核根据进程的有效 UID、GID、补充组以及文件的模式位和 ACL 判断访问权

容器内的 UID 是数字身份。例如:

docker run --rm \
  --user 1001:1001 \
  --mount type=volume,source=app-data,target=/data \
  alpine \
  sh -c 'id; touch /data/test'

如果 /data 的所有者和权限不允许 UID 1001 写入,可能得到:

touch: /data/test: Permission denied

容器内显示的用户名并不能决定权限。内核首先看数字 UID/GID:

docker run --rm alpine id

典型输出:

uid=0(root) gid=0(root) groups=0(root)

如果镜像声明了非 root 用户,例如 UID 10001,那么挂载目录必须允许 UID 10001 写入。用户名 app 在主机和容器中是否同名并不重要,数字身份才是关键。

7.1 Volume 的初始化权限

可以显式初始化 Volume:

docker volume create app-data

docker run --rm \
  --mount type=volume,source=app-data,target=/data \
  alpine \
  sh -c 'chown -R 1001:1001 /data && chmod 0750 /data'

之后用相同 UID 运行:

docker run --rm \
  --user 1001:1001 \
  --mount type=volume,source=app-data,target=/data \
  alpine \
  sh -c 'echo ok >/data/health.txt && ls -ln /data'

但初始化容器中的 chown -R 可能扫描大量文件,造成启动延迟;更重要的是,若误把它用于 Bind Mount,修改的是主机真实目录。初始化逻辑还必须区分“第一次创建数据目录”和“已有生产数据”,不能每次启动都递归修改所有者。

7.2 Bind Mount 的权限来源

Bind Mount 直接使用主机路径上的 UID/GID 和模式位:

mkdir -p host-data
sudo chown 1001:1001 host-data
sudo chmod 0750 host-data

然后:

docker run --rm \
  --user 1001:1001 \
  --mount type=bind,source="$PWD/host-data",target=/data \
  alpine \
  sh -c 'echo ok >/data/file'

如果主机目录属于 UID 1000,而容器进程使用 UID 1001,容器中可能无法写入。开发环境中常见的“容器写出的文件属于 root”,通常是因为容器进程以 UID 0 运行,而不是 Docker 自动改变了主机所有权。

7.3 rootless、user namespace 和 SELinux

在 rootless Docker 或启用 user namespace remapping 的环境中,容器内 UID 与主机看到的 UID 可能不是一一相等的。于是“容器里是 UID 1000,主机上也应该是 UID 1000”的经验可能失效。应以实际运行模式和主机上的数字 UID 为准,并通过最小写入测试验证。

启用 SELinux 的 Linux 主机还可能出现:

Permission denied

即使 ls -l 显示传统 Unix 权限允许访问。这是因为 SELinux 标签也参与访问控制。Docker 的 Bind Mount 语法支持常见的 :z:Z 标记:

docker run --rm \
  -v "$PWD/config:/etc/myapp:ro,Z" \
  alpine

z 通常用于允许多个容器共享重新标记后的内容,Z 通常用于私有标签场景。具体行为受 Docker、SELinux 策略和主机发行版影响;重新标记主机目录可能影响主机上其他程序的访问,因此不能机械添加。诊断时应同时检查:

ls -Zd ./config
getenforce
ausearch -m avc -ts recent

如果系统没有 SELinux,这些命令可能不可用。


八、挂载目标的遮蔽效应和路径诊断

挂载目标会遮蔽原来的文件。下面的镜像假定 /app 中有 config.yaml

docker run --rm \
  --mount type=tmpfs,target=/app \
  image-name \
  ls -la /app

容器内看到的是空的 tmpfs,而不是镜像里的 /app/config.yaml。这类问题经常表现为:

  • 应用启动时报告配置文件不存在;
  • 默认目录看起来突然为空;
  • 镜像构建阶段明明复制了文件,运行时却找不到;
  • 容器之间共享目录时,某个容器看到的内容与另一个不同。

检查挂载关系:

docker inspect <container-name> \
  --format '{{json .Mounts}}'

检查容器内最终视图:

docker exec <container-name> mount
docker exec <container-name> ls -la /app

再检查主机路径或 Volume:

docker volume inspect app-data
ls -la ./host-data

诊断时必须明确比较三处内容:

  1. 镜像构建产物;
  2. Docker 配置的源路径;
  3. 容器挂载后的最终目录。

仅仅检查镜像或仅仅检查主机目录,都可能错过遮蔽关系。


九、只读挂载、应用写入和安全边界

只读挂载可以降低误写风险:

docker run --rm \
  --mount type=volume,source=app-data,target=/data,readonly \
  alpine \
  sh -c 'echo x >/data/test'

预期会失败:

sh: can't create /data/test: Read-only file system

但应用可能仍然需要写入以下位置:

  • /tmp
  • /run
  • 日志目录;
  • 缓存目录;
  • Unix socket 所在目录。

一种常见组合是把业务数据设为只读,把临时目录放到 tmpfs:

docker run --rm \
  --mount type=volume,source=app-data,target=/var/lib/app,readonly \
  --mount type=tmpfs,target=/tmp,tmpfs-size=32m \
  image-name

这仍不是完整的安全隔离。主机 root、Docker daemon 管理者和拥有相应能力的进程可以改变容器或挂载配置;Bind Mount 还可能让容器修改主机关键文件。因此,挂载权限控制属于减少误操作和缩小访问范围,不应被当作主机级安全边界。


十、备份前先区分“文件备份”和“应用一致性备份”

文件系统层面的备份,解决的是:

在备份时刻,挂载路径中的字节能否被复制出来?

应用一致性备份,解决的是:

恢复这些字节后,应用能否识别并继续使用一个逻辑上完整的状态?

二者不等价。

例如,数据库正在执行事务时,直接复制其数据目录可能得到:

  • 数据文件已经写入;
  • WAL、redo log 或事务日志只写入一部分;
  • 多个文件之间对应不同时间点;
  • 恢复时需要数据库自己的崩溃恢复;
  • 某些复制出的文件无法组成可用实例。

因此,数据库备份通常应优先使用数据库原生机制:

停止写入或建立一致性快照
        │
        ├── 逻辑备份:mysqldump、pg_dump 等
        ├── 物理备份:数据库支持的物理备份工具
        └── 存储快照:后端明确提供一致性语义
        │
        ▼
加密、传输、保留、校验
        │
        ▼
在隔离环境恢复并验证

如果只是普通文件应用,可以在停止应用后备份;如果应用支持原子写入和快照机制,也可以采用相应方案,但必须由应用或存储系统的语义保证一致性,而不能仅根据 tar 命令成功就判定备份可靠。


十一、Volume 的文件级备份

对普通文件型 Volume,可以使用一个临时容器同时挂载:

  • 待备份的 Volume;
  • 主机上的备份目录。

先创建备份目录和 Volume:

mkdir -p ./backups
docker volume create app-data

执行备份:

docker run --rm \
  --mount type=volume,source=app-data,target=/from,readonly \
  --mount type=bind,source="$PWD/backups",target=/to \
  alpine \
  sh -c 'cd /from && tar czpf /to/app-data.tgz .'

命令各部分的作用:

  • --mount type=volume,...,readonly:以只读方式访问待备份数据,避免备份命令误改文件;
  • --mount type=bind,...:把当前目录下的 backups 暴露给临时容器;
  • cd /from:让归档中的路径从 . 开始,而不是包含容器内部的 /from 前缀;
  • tar czpf
    • c 创建归档;
    • z 使用 gzip;
    • p 尽量保留权限;
    • f 指定输出文件。

查看归档内容:

tar -tzf ./backups/app-data.tgz | head
sha256sum ./backups/app-data.tgz

这里的校验和只能证明“当前文件的内容没有被意外改变”,不能证明归档内容在业务上是一致的。

11.1 备份期间如何处理并发写入

如果应用仍在写入,tar 可能在不同时间读取不同文件。对于普通日志目录,这可能可以接受;对于需要原子状态的应用,不能默认接受。

简单的停机流程:

docker stop app

然后执行上述备份,确认命令退出码为 0,最后启动:

docker start app

docker stop 会先发送停止信号,再等待配置的宽限时间;应用是否真正完成刷盘、关闭数据库和写入元数据,取决于应用是否正确处理信号。可以通过日志和应用健康检查确认停止完成,而不是只看到 Docker 命令返回成功。

对于数据库,停容器后复制数据目录通常比在线复制更安全,但仍应遵循数据库本身的备份和恢复要求。优先使用数据库原生备份格式。


十二、Volume 的恢复

恢复时先创建目标 Volume:

docker volume create app-data-restored

再将归档解压到 Volume:

docker run --rm \
  --mount type=volume,source=app-data-restored,target=/to \
  --mount type=bind,source="$PWD/backups",target=/from,readonly \
  alpine \
  sh -c 'tar xzpf /from/app-data.tgz -C /to'

验证恢复结果:

docker run --rm \
  --mount type=volume,source=app-data-restored,target=/data,readonly \
  alpine \
  find /data -maxdepth 2 -type f -print

恢复到新 Volume 而不是直接覆盖生产 Volume,具有几个好处:

  • 原始数据仍然保留;
  • 可以先验证目录结构和权限;
  • 可以启动隔离的测试容器;
  • 验证失败时容易回滚到旧 Volume。

如果归档由 root 创建,解压时也应以足够权限运行,否则可能无法恢复原始所有者。示例中的 Alpine 容器默认以 root 运行。恢复完成后,应检查:

docker run --rm \
  --mount type=volume,source=app-data-restored,target=/data,readonly \
  alpine \
  sh -c 'find /data -maxdepth 2 -printf "%M %u:%g %p\n" | head -30'

恢复不仅要检查“文件存在”,还要检查:

  • 所有者和组;
  • 模式位;
  • 符号链接;
  • 应用所需的目录结构;
  • 文件名编码和特殊文件;
  • 应用能否实际启动并读取数据。

恢复到生产前,可以把新 Volume 替换进服务配置,或暂时启动一个诊断容器:

docker run --rm -it \
  --mount type=volume,source=app-data-restored,target=/var/lib/app \
  image-name \
  sh

十三、Bind Mount 的备份和恢复

Bind Mount 的数据已经位于主机路径,通常直接使用主机备份工具:

tar czpf ./backups/host-data.tgz -C /srv app-data

这条命令归档的是:

/srv/app-data

恢复到临时目录:

mkdir -p /srv/restore-test
tar xzpf ./backups/host-data.tgz -C /srv/restore-test

如果主机路径是应用的活动数据目录,备份前仍需处理并发写入:

docker compose stop app
tar czpf ./backups/host-data.tgz -C /srv app-data
docker compose start app

Bind Mount 的恢复责任比 Volume 更直接地落在主机管理员身上:

  • 目录必须存在;
  • 所有者和组必须正确;
  • SELinux 标签可能需要恢复;
  • 主机路径必须挂载到正确的文件系统;
  • 恢复脚本不能误覆盖其他主机目录。

如果使用了 ACL、扩展属性或 SELinux 标签,普通 tar 选项可能不足以完整保存它们,需要根据主机文件系统和安全策略使用相应的 ACL、xattr 和标签保存选项,并在恢复后验证。不能把“普通文件已恢复”当作“主机访问控制已恢复”。


十四、tmpfs 为什么通常没有备份恢复流程

tmpfs 的数据生命周期短于容器或应用运行周期:

应用运行期间存在
容器重启后通常重新创建
容器删除后消失

因此,正确做法通常不是备份 tmpfs,而是:

  1. 明确 tmpfs 中的数据是否真的应该持久化;
  2. 如果需要跨重启恢复,把它迁移到 Volume 或 Bind Mount;
  3. 如果只是缓存,删除后让应用重新生成;
  4. 如果是敏感临时数据,确认应用退出时是否需要显式清理。

如果把数据库临时日志、队列或会话状态放进 tmpfs,主机断电或容器重建后数据会消失。即使应用可以重新启动,也可能产生业务层面的数据丢失。


十五、数据库备份:不要把 tar 当成事务备份

假设 PostgreSQL 数据目录挂载到:

/var/lib/postgresql/data

直接执行:

docker run --rm \
  --mount type=volume,source=pg-data,target=/var/lib/postgresql/data,readonly \
  --mount type=bind,source="$PWD/backups",target=/backups \
  postgres-image \
  tar czpf /backups/pg-data.tgz -C /var/lib/postgresql/data .

即使命令成功,也不能自动证明它是一个可恢复的 PostgreSQL 备份。数据库可能在归档过程中同时修改多个文件。

更合理的逻辑备份流程是让数据库客户端调用数据库服务:

docker exec -t postgres \
  pg_dump -U appuser -d appdb \
  > ./backups/appdb.sql

恢复时:

cat ./backups/appdb.sql | docker exec -i postgres \
  psql -U appuser -d appdb

这要求:

  • 数据库容器正在运行;
  • 用户、数据库和认证配置正确;
  • 恢复目标处于可接受的空库或清理状态;
  • 恢复过程中的错误被检查,而不是忽略管道退出码;
  • 恢复后执行应用级验证。

不同数据库的命令、锁、权限和备份格式不同。pg_dumpmysqldump 等是数据库语义层面的工具;Volume 备份只是文件系统层面的工具。生产备份方案应选择与恢复目标匹配的层级。


十六、迁移到另一台 Docker 主机

Volume 默认属于某个 Docker Engine 主机。把 Compose 文件复制到新主机,并不会自动复制 Volume 数据。

一个基本的本地迁移流程是:

1. 在旧主机导出

docker run --rm \
  --mount type=volume,source=app-data,target=/from,readonly \
  --mount type=bind,source="$PWD/backups",target=/to \
  alpine \
  sh -c 'tar czpf /to/app-data.tgz -C /from .'

2. 传输归档并校验

sha256sum ./backups/app-data.tgz

在新主机上重新计算,并比较校验和。

3. 在新主机创建 Volume

docker volume create app-data

4. 导入

docker run --rm \
  --mount type=volume,source=app-data,target=/to \
  --mount type=bind,source="$PWD/backups",target=/from,readonly \
  alpine \
  sh -c 'tar xzpf /from/app-data.tgz -C /to'

5. 启动并验证

docker compose up -d
docker compose ps
docker compose logs --tail=100 app

迁移不只是复制文件,还要检查:

  • 新主机的 CPU 架构和镜像是否兼容;
  • UID/GID 是否仍然匹配;
  • SELinux、AppArmor 和 rootless 模式是否不同;
  • Compose 中实际使用的 Volume 名称是否一致;
  • 数据库版本是否兼容;
  • 外部网络、DNS、密钥和配置是否已经迁移。

如果旧主机使用 Bind Mount,必须迁移主机目录本身,并在 Compose 中使用新主机上的正确绝对路径或相对路径。不能把 Bind Mount 的主机路径误当成 Docker Volume 名称。


十七、备份和恢复的验证条件

一份备份只有在能够恢复并通过验证时才具有实际价值。至少应验证三层:

文件层

tar -tzf ./backups/app-data.tgz >/dev/null
echo $?

返回 0 只能说明归档格式可读取。

权限层

恢复到测试 Volume 后:

docker run --rm \
  --user 1001:1001 \
  --mount type=volume,source=app-data-restored,target=/data \
  alpine \
  sh -c 'test -r /data/config.yaml && test -w /data && echo permission-ok'

这可以验证目标运行 UID 是否真的能访问。

应用层

启动隔离实例,执行:

  • 应用健康检查;
  • 读取关键配置;
  • 查询关键业务数据;
  • 写入一条测试数据;
  • 重启后再次读取;
  • 检查日志中是否有恢复或校验错误。

数据库还应执行数据库自身的校验、表数量或关键记录比对。只检查文件数量是不够的,因为文件可能存在但数据库无法打开。


十八、常见失败表现与诊断路径

1. 容器重建后文件消失

先判断写入路径:

docker inspect <container> --format '{{json .Mounts}}'

如果目标路径没有挂载,它写入的是容器可写层。应改为 Volume、Bind Mount 或明确的外部存储。

2. 挂载后镜像中的默认文件消失

检查是否发生了目标路径遮蔽:

docker inspect <container>

如果 Bind Mount 或空 Volume 挂载到了包含默认文件的目录,容器最终看到的就是挂载内容。对于 Volume,确认是否是首次空 Volume 初始化;必要时检查是否使用了 volume-nocopy

3. Permission denied

按以下顺序排查:

docker inspect <container>
docker exec <container> id
docker exec <container> ls -ln /data

然后对照:

  • 挂载类型;
  • 容器进程 UID/GID;
  • 目录所有者和模式位;
  • rootless 或 user namespace;
  • SELinux 标签和审计日志。

不要一开始就使用 chmod -R 777。它可能暂时绕过 Unix 权限,却扩大了访问范围,并掩盖 UID/GID 配置错误。

4. 备份归档存在,但恢复后应用无法启动

可能原因包括:

  • 备份时应用仍在并发写入;
  • 数据库需要原生恢复流程;
  • 所有者或权限没有恢复;
  • 符号链接、扩展属性或安全标签丢失;
  • 恢复到了错误的挂载路径;
  • 应用版本与数据格式不兼容。

诊断应从“应用日志 + 挂载关系 + 文件权限 + 数据库恢复日志”同时进行,而不是只重新解压一次归档。

5. docker compose down 后数据异常

检查是否使用了:

docker compose down -v

再检查实际 Volume:

docker volume ls
docker volume inspect <volume-name>

Compose 文件中的逻辑卷名和 Docker 实际资源名可能不同。需要持久保留的数据应明确声明,并在销毁命令前核对资源,而不是依赖名称猜测。


十九、一个完整的选择示例

假设一个 Web 服务有三类数据:

/app          应用代码
/etc/app      配置文件
/var/lib/app  用户上传文件
/run/cache    短期缓存

合理的开发环境可以这样组织:

services:
  web:
    image: example/web:1.0
    user: "1001:1001"
    volumes:
      - ./src:/app:ro
      - ./config:/etc/app:ro
      - uploads:/var/lib/app
      - type: tmpfs
        target: /run/cache
        tmpfs:
          size: 33554432
          mode: 1770

volumes:
  uploads:

数据流和生命周期分别是:

  • ./src:由主机工作区管理,代码改变立即映射到容器;
  • ./config:由主机配置目录管理,容器只能读取;
  • uploads:由 Docker 管理,容器删除后仍保留,需单独备份;
  • /run/cache:由内核内存提供,服务重启后丢失。

如果生产环境不希望把代码从主机映射进去,可以改为把代码构建进镜像,只保留:

services:
  web:
    image: example/web:1.0
    user: "1001:1001"
    read_only: true
    volumes:
      - uploads:/var/lib/app
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 33554432
          mode: 1777

volumes:
  uploads:

这里的 read_only: true 是容器根文件系统只读设置;应用仍需把可写状态放到明确的 Volume 或 tmpfs 路径。是否能这样运行,取决于应用是否会写入其他目录,必须通过实际启动和健康检查验证。


二十、最终判断原则

选择存储类型时,核心不是“哪一种性能最好”,而是数据的生命周期和责任边界:

  • 数据随容器消失:容器可写层或 tmpfs;
  • 数据要跨容器重建保留,且由 Docker 管理:Volume;
  • 数据必须对应主机明确目录:Bind Mount;
  • 数据只是运行期间的缓存或临时文件:tmpfs;
  • 数据属于数据库:优先数据库原生备份,不把文件复制成功等同于事务一致;
  • 数据需要恢复:必须在隔离环境执行真实恢复演练;
  • 数据涉及权限:先确定容器进程数字 UID/GID,再处理目录所有者、模式位和安全标签。

Volume、Bind Mount 和 tmpfs 解决的是“数据放在哪里以及如何挂载”;备份恢复还要额外解决“数据在什么时刻一致、恢复后谁能访问、应用是否认可这份状态”。只有把挂载关系、Linux 权限、应用一致性和恢复验证连接起来,Docker 存储才构成可操作的工程体系。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。