Docker 基础体系 · 第 9/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker 存储:Volume、Bind Mount、tmpfs、权限和备份恢复
容器文件系统并不是一个天然持久化的目录。理解 Docker 存储,首先要区分三类位置:
- 镜像层和容器可写层:由镜像和存储驱动管理,容器删除后通常随之删除。
- Volume:由 Docker 管理的持久化存储。
- Bind mount:把主机上的指定路径直接挂载进容器。
- 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 中。
这个过程的因果关系是:
- 镜像的
/etc/example中已有文件; example-config是新建空 Volume;- Docker 将 Volume 挂载到该目标;
- 为避免用户因挂载而丢失镜像默认文件,Docker 进行初始化复制;
- 后续容器再次使用该 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 文件所在目录下的configBind 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
诊断时必须明确比较三处内容:
- 镜像构建产物;
- Docker 配置的源路径;
- 容器挂载后的最终目录。
仅仅检查镜像或仅仅检查主机目录,都可能错过遮蔽关系。
九、只读挂载、应用写入和安全边界
只读挂载可以降低误写风险:
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,而是:
- 明确 tmpfs 中的数据是否真的应该持久化;
- 如果需要跨重启恢复,把它迁移到 Volume 或 Bind Mount;
- 如果只是缓存,删除后让应用重新生成;
- 如果是敏感临时数据,确认应用退出时是否需要显式清理。
如果把数据库临时日志、队列或会话状态放进 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_dump、mysqldump 等是数据库语义层面的工具;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 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 网络完整指南:Bridge、端口映射、DNS、Overlay 和排障
- 下一篇:Docker Compose 完整指南:服务、网络、卷、依赖、Profile 和生产边界
- 延伸:Docker 数据备份与主机迁移:卷、数据库、一致性和恢复演练
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论