1. 为什么容器一运行到生产就绕不开数据

前四篇已经完成了 Docker 的基本运行链路:理解镜像和容器,能安装 Docker Engine,能用 Dockerfile 构建应用镜像,也能把容器接入自定义网络并发布端口。从这一篇开始,容器不再只是“可启动的进程”,而要承载真实业务状态。只要应用需要保存数据库文件、用户上传内容、队列状态、索引文件、插件目录、运行日志、证书、配置覆盖或缓存,就会遇到持久化问题。

Docker 的默认行为很适合交付不可变应用,却不适合保存长期数据。容器启动后对文件系统的写入,默认落在容器自己的可写层;容器被删除后,这些数据也随之消失。对于无状态 Web 服务,这种行为很理想,因为容器可以随时重建;对于数据库、对象上传目录、构建缓存和业务附件,这种行为就是事故来源。

持久化设计的目标不是“把目录挂进去”这么简单,而是回答一组更具体的问题:

  1. 哪些数据必须在容器删除后继续存在?
  2. 哪些数据应该跟宿主机目录、备份系统、权限模型或审计流程绑定?
  3. 哪些数据只需要容器运行期间存在,停止后应该自动丢弃?
  4. 哪些路径应该只读挂载,避免容器反向修改宿主机?
  5. 数据如何备份、恢复、迁移和验证?
  6. 当容器写不进去、数据不见了、目录被覆盖时,如何快速定位边界?

本文采用“实战 walkthrough + 持久化运维 runbook”的形式。读完后,你应该能为单机 Docker 应用选择合适的挂载方式,能用命令创建、查看、备份、恢复数据卷,能识别 bind mount、named volume、anonymous volume、tmpfs 与容器可写层的差异,并能把这些规则迁移到后续 Docker Compose 和编排平台实践中。

本文验证环境假设如下:

项目 说明
操作系统 Ubuntu 24.04 LTS 或 Ubuntu 22.04 LTS
Docker 组件 Docker Engine、Docker CLI
示例镜像 postgres:16-alpinenginx:alpinebusybox:latest
操作权限 当前用户具备 Docker socket 访问权限,生产环境建议使用受控 sudo
文章范围 单机 Docker Engine 持久化;Compose 只做预告,不展开完整编排

不同 Linux 发行版、Docker Engine 版本、rootless 模式、Docker Desktop、企业镜像仓库和主机安全策略可能影响细节。本文的命令适合在测试主机验证,不要直接对生产数据库执行破坏性清理命令。

2. 先把 Docker 存储分成两类

很多 Docker 存储问题之所以难排,是因为“存储”这个词同时指向两件事:

  1. Docker daemon 如何保存镜像层、容器可写层和元数据。
  2. 应用数据如何离开容器可写层,持久保存在外部位置。

第一类由 storage driver 或 containerd image store 负责,属于 Docker 自己管理镜像和容器层的内部机制。第二类由 volumes、bind mounts、tmpfs 等挂载方式负责,属于应用数据持久化机制。日常运维里,数据库文件、上传目录、配置文件和备份迁移,主要讨论第二类。

这个架构视图把容器可写层和外部持久化位置分开看。后文所有选择都围绕一个原则展开:业务长期数据不要依赖容器可写层。

2.1 容器可写层适合什么

容器由只读镜像层和运行时可写层组成。应用在容器内写入但没有挂载到外部位置的数据,会进入可写层。可写层有几个特点:

特点 运维含义
随容器存在 容器删除后数据通常不再保留
每个容器独立 同一镜像启动多个容器时,写入内容互不共享
受存储后端影响 写入路径可能经过 copy-on-write,性能和行为不应等同宿主机原生目录
不方便备份迁移 可以用 docker cp 临时取出文件,但不应作为长期备份策略

因此,容器可写层适合保存临时 PID 文件、短期运行状态、可以重建的缓存、应用启动时生成的非关键文件。它不适合保存数据库数据目录、用户上传文件、长期日志、索引文件、证书私钥和业务账本。

一个最小实验可以直观看到这个边界:

bash
docker run --name layer-test busybox sh -c 'date > /data.txt && cat /data.txt'
docker rm layer-test
docker run --name layer-test busybox sh -c 'ls -l /data.txt || true'
docker rm layer-test

第二个容器虽然使用同一个镜像和同一个名字,但它不是前一个容器的延续。没有挂载外部存储时,前一个容器写入的 /data.txt 不会自动出现在新容器中。

2.2 Docker 支持的主要挂载方式

Docker 官方文档把容器数据持久化的常用方式分为 volume mounts、bind mounts 和 tmpfs mounts。三者从容器内部看都像普通文件或目录,但控制权、生命周期和风险完全不同。

方式 数据位置 生命周期 典型用途 主要风险
命名卷 named volume Docker 管理的 volume 区域 独立于容器,需显式删除 数据库、应用数据目录、可迁移运行数据 容易忘记备份;默认路径不应被人工随意改
匿名卷 anonymous volume Docker 自动创建但没有业务化命名 独立于容器,容易遗留 镜像声明 VOLUME 后的临时场景 容易找不到来源,清理困难
绑定挂载 bind mount 指定宿主机路径 由宿主机目录决定 开发时代码挂载、配置文件、宿主机采集目录 容器可直接修改宿主机文件,路径和权限强耦合
tmpfs 宿主机内存中的临时文件系统 容器停止后消失 敏感临时文件、高频临时写入 不持久;可能受内存和 swap 策略影响
容器可写层 容器自身 layer 随容器删除 可丢弃缓存、非关键临时文件 误放关键数据导致重建丢失

生产环境的默认建议很简单:业务数据目录优先使用命名卷;必须和宿主机路径集成时再使用 bind mount;只需要运行期临时数据时使用 tmpfs;不要把长期数据留在容器可写层。

3. 挂载方式怎么选

选择挂载方式时,不要从命令参数出发,而要从数据性质出发。下面这张流程图适合做团队评审时的快速检查。

Docker 挂载方式选择流程

图中的关键分支是“是否需要宿主机路径语义”。如果不需要,命名卷通常更稳;如果需要读取宿主机上的配置、证书、日志目录或开发代码,bind mount 才是更直接的选择。

3.1 数据库和业务数据目录:优先命名卷

数据库文件、队列数据、搜索索引、应用上传附件等,通常应该放进命名卷。原因有四个:

  1. 命名卷由 Docker 管理,生命周期独立于容器。
  2. 容器重建时,只要挂载同一个卷,数据目录可以继续使用。
  3. 备份和迁移可以围绕卷名建立标准流程。
  4. 不需要把应用强绑定到某个宿主机绝对路径。

创建一个 PostgreSQL 数据卷:

bash
docker volume create pgdata
docker volume inspect pgdata

启动数据库容器:

bash
docker run -d \
  --name pg-demo \
  --network app-net \
  -e POSTGRES_PASSWORD='change-me-in-lab' \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16-alpine

验证挂载:

bash
docker inspect pg-demo --format '{{json .Mounts}}'
docker volume ls
docker exec pg-demo sh -c 'ls -la /var/lib/postgresql/data | sed -n "1,20p"'

删除容器后重新挂载同一个卷:

bash
docker rm -f pg-demo

docker run -d \
  --name pg-demo-2 \
  --network app-net \
  -e POSTGRES_PASSWORD='change-me-in-lab' \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16-alpine

只要卷没有删除,数据目录仍然存在。这里的重点不是 PostgreSQL 本身,而是“容器生命周期”和“数据生命周期”已经解耦。

3.2 配置文件和开发代码:谨慎使用 bind mount

bind mount 会把宿主机上的文件或目录直接挂进容器。它非常适合开发环境,也适合把宿主机上的只读配置文件、证书、公钥、采集目录暴露给容器。但它的风险也更高:容器可以直接修改宿主机文件,路径不存在时的行为可能导致误创建目录,权限和 SELinux/AppArmor 策略也会影响写入。

创建一个只读 Nginx 配置示例:

bash
mkdir -p ~/docker-persistence-lab/nginx

cat > ~/docker-persistence-lab/nginx/default.conf <<'EOF'
server {
    listen 80;
    location / {
        return 200 "hello from bind mount\n";
    }
}
EOF

启动 Nginx,并用只读方式挂载配置文件:

bash
docker run -d \
  --name nginx-bind-demo \
  -p 18080:80 \
  --mount type=bind,source="$HOME/docker-persistence-lab/nginx/default.conf",target=/etc/nginx/conf.d/default.conf,readonly \
  nginx:alpine

验证:

bash
curl http://127.0.0.1:18080
docker inspect nginx-bind-demo --format '{{json .Mounts}}'

如果容器内应用不需要修改配置文件,就应该加 readonly。把配置、证书、静态规则文件以只读方式挂载,是比读写挂载更安全的默认做法。

清理:

bash
docker rm -f nginx-bind-demo

3.3 临时敏感数据:使用 tmpfs

tmpfs 把数据放在宿主机内存中的临时文件系统里。容器停止后,tmpfs 中的数据不会作为普通挂载内容持久保存。它适合保存运行期临时密钥、会话中间文件、高频短生命周期缓存,或者你明确不希望写入容器可写层和宿主机目录的数据。

示例:

bash
docker run --rm \
  --mount type=tmpfs,target=/run/app,tmpfs-size=67108864,tmpfs-mode=1770 \
  busybox sh -c 'mount | grep /run/app && echo secret > /run/app/token && ls -l /run/app'

需要注意三点:

  1. tmpfs 不是持久化方案,容器停止后数据就不应该再被期待存在。
  2. Linux 内核可能因为 swap 策略让临时数据落到 swap,所以敏感场景还要结合主机内存和 swap 基线评估。
  3. tmpfs 不能像 volume 那样用于多个容器之间的持久共享。

3.4 --mount-v 怎么选

Docker 支持 --mount-v 两种常见语法。生产文档和团队标准里建议优先使用 --mount,因为它显式写出 typesourcetarget,更不容易误读。

命名卷示例:

bash
docker run -d \
  --name app-with-volume \
  --mount type=volume,source=app-data,target=/var/lib/app \
  busybox sleep 1d

bind mount 示例:

bash
docker run --rm \
  --mount type=bind,source="$PWD",target=/work,readonly \
  busybox ls -la /work

-v 很短,适合临时命令:

bash
docker run --rm -v app-data:/var/lib/app busybox ls -la /var/lib/app

但在自动化脚本、SOP、变更单和团队知识库里,--mount 的可读性更好。尤其是 bind mount 与 volume 混用时,--mount type=... 能减少误操作。

4. 命名卷实战:把数据库数据留在容器外

本节用 PostgreSQL 演示命名卷的完整工作流:创建卷、启动容器、写入数据、重建容器、确认数据仍然存在。这个实验可以作为任何有状态容器上线前的最小验证模板。

4.1 准备网络和卷

如果前一篇网络实验已经创建过 app-net,可以复用;没有则创建:

bash
docker network create app-net 2>/dev/null || true
docker volume create pgdata

查看卷详情:

bash
docker volume inspect pgdata

输出中常见字段包括:

字段 意义
Name 卷名
Driver volume driver,默认通常是 local
Mountpoint Docker daemon 管理的宿主机路径
Labels 业务标签,可用于治理
Options driver 相关参数

不要把 Mountpoint 当成普通业务目录去手工修改。对于默认 local driver,宿主机上确实能看到一个路径,但生产运维应优先通过 Docker 命令、备份工具或受控脚本访问数据,而不是随意进入该目录改文件。

4.2 启动数据库并写入测试数据

启动容器:

bash
docker run -d \
  --name pg-demo \
  --network app-net \
  -e POSTGRES_PASSWORD='change-me-in-lab' \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16-alpine

等待数据库就绪:

bash
docker logs pg-demo --tail 30
docker exec pg-demo pg_isready -U postgres

写入测试数据:

bash
docker exec -i pg-demo psql -U postgres <<'SQL'
CREATE TABLE IF NOT EXISTS lab_note (
  id serial PRIMARY KEY,
  message text NOT NULL,
  created_at timestamptz DEFAULT now()
);
INSERT INTO lab_note(message) VALUES ('volume keeps data outside container');
SELECT id, message, created_at FROM lab_note;
SQL

这里不要把示例密码带到生产环境。生产数据库至少应使用受控 secret、独立用户、网络隔离、备份策略和访问审计。本文只演示 Docker 挂载边界。

4.3 删除容器并重建

删除容器:

bash
docker rm -f pg-demo

确认卷仍在:

bash
docker volume ls | grep pgdata

用同一个卷启动新容器:

bash
docker run -d \
  --name pg-demo-new \
  --network app-net \
  -e POSTGRES_PASSWORD='change-me-in-lab' \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16-alpine

验证数据:

bash
docker exec pg-demo-new pg_isready -U postgres
docker exec -i pg-demo-new psql -U postgres -c 'SELECT id, message, created_at FROM lab_note;'

如果数据仍然存在,说明容器重建和数据保留已经解耦。这是有状态容器上线前必须验证的最小闭环。

4.4 用标签治理卷

当主机上容器越来越多,卷名会变得难以管理。创建卷时可以加 label:

bash
docker volume create \
  --label app=demo \
  --label env=lab \
  --label owner=platform \
  demo-data

筛选:

bash
docker volume ls --filter label=app=demo
docker volume inspect demo-data

卷命名建议包含应用、环境和用途,例如:

场景 示例卷名
单机测试数据库 demo-postgres-data
生产附件目录 erp-prod-upload-data
搜索索引 kb-search-index
反向代理证书缓存 gateway-acme-cache

不要使用只有 datadbvolume1 这种无法追溯来源的名字。运维事故中,最难处理的往往不是技术命令,而是没人知道某个卷能不能删。

5. bind mount 实战:把宿主机路径受控暴露给容器

bind mount 的优势是直观:宿主机上哪个路径,容器里就看到哪个路径。它适合开发调试、挂载配置文件、读取宿主机日志、接入已有目录结构。但它也会让容器和宿主机强耦合。

5.1 开发环境代码热更新

开发时经常把当前目录挂进容器:

bash
mkdir -p ~/docker-persistence-lab/web
cd ~/docker-persistence-lab/web
echo '<h1>Hello from host path</h1>' > index.html

docker run -d \
  --name nginx-dev \
  -p 18081:80 \
  --mount type=bind,source="$PWD",target=/usr/share/nginx/html,readonly \
  nginx:alpine

访问:

bash
curl http://127.0.0.1:18081

修改宿主机文件:

bash
echo '<h1>Changed on host</h1>' > index.html
curl http://127.0.0.1:18081

这种模式适合开发和只读静态内容,但不应该被直接理解成生产部署标准。生产环境更常见的做法是把静态文件构建进镜像,或者将数据目录用命名卷、对象存储、共享存储、配置中心管理。

5.2 配置文件只读挂载

把配置文件作为 bind mount 时,建议默认只读:

bash
docker run --rm \
  --mount type=bind,source="$HOME/docker-persistence-lab/nginx/default.conf",target=/etc/nginx/conf.d/default.conf,readonly \
  nginx:alpine nginx -t

如果应用确实需要写配置,先问三个问题:

  1. 写入内容是应用状态,还是配置源?
  2. 这个写入是否应该被版本管理或审计?
  3. 宿主机上是否有其他进程依赖同一文件?

多数情况下,容器修改宿主机配置文件不是好设计。配置应来自镜像、环境变量、secret、配置中心或只读挂载;运行状态应放入命名卷或业务存储。

5.3 bind mount 的路径陷阱

bind mount 最大的坑是路径。错误路径可能导致容器看到空目录,或者把镜像中原本存在的目录遮住。

检查宿主机路径:

bash
test -e "$HOME/docker-persistence-lab/nginx/default.conf" && echo ok

检查容器挂载结果:

bash
docker inspect nginx-dev --format '{{json .Mounts}}'
docker exec nginx-dev sh -c 'mount | grep /usr/share/nginx/html || true'

如果你把一个空宿主机目录挂载到容器中已有内容的目录上,容器原本的文件会被挂载遮住。例如把空目录挂到 /usr/share/nginx/html,Nginx 镜像中默认页面就不可见了。这不是文件被删除,而是挂载覆盖了视图。移除挂载并重建容器后,镜像里的原始文件才会重新可见。

清理:

bash
docker rm -f nginx-dev

6. 匿名卷和镜像 VOLUME:能用但要看得见

有些镜像的 Dockerfile 会声明 VOLUME。当你没有显式指定卷名时,Docker 可能自动创建匿名卷。匿名卷不是临时目录,它也可能在容器删除后继续留在主机上,只是名字是一串不具业务含义的 ID。

查看所有卷:

bash
docker volume ls

查看某个容器使用了哪些卷:

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

匿名卷的问题不是不能用,而是治理困难。你很难从卷名判断它属于哪个应用、哪个环境、哪个变更。如果生产环境确实需要持久化,应该显式命名:

bash
docker run -d \
  --name app-prod \
  --mount type=volume,source=app-prod-data,target=/var/lib/app \
  your-image:tag

删除容器时,docker rm 默认不会删除命名卷。docker rm -v 会删除与容器关联的匿名卷,但不会删除命名卷。清理前务必确认:

bash
docker inspect <container-name> --format '{{json .Mounts}}'
docker volume ls

对于不确定来源的匿名卷,不要直接 docker volume prune。先通过 docker ps -adocker inspect、卷创建时间、宿主机目录大小、备份状态和业务窗口判断是否可以清理。

7. 备份、恢复与迁移:数据卷必须有出口

只创建卷不等于完成持久化。真正的持久化能力包括备份、恢复、迁移和验证。如果没有恢复演练,备份只能算“可能有用的文件”。

Docker 数据卷备份恢复流程

这张图展示了一个最小可执行流程:先让写入停止或进入一致状态,再通过辅助容器挂载卷并打包,最后在新卷上恢复并验证应用能启动。

7.1 文件级备份命名卷

下面示例使用 busybox 辅助容器把 pgdata 打包到宿主机备份目录。对于真实数据库,文件级备份前应确认数据库一致性;更推荐使用数据库原生备份工具,例如 pg_dumppg_basebackup 或存储层快照方案。这里展示的是 Docker volume 操作模式。

准备备份目录:

bash
mkdir -p ~/docker-volume-backups

停止写入。实验环境可以停止容器:

bash
docker stop pg-demo-new

打包卷内容:

bash
docker run --rm \
  --mount type=volume,source=pgdata,target=/source,readonly \
  --mount type=bind,source="$HOME/docker-volume-backups",target=/backup \
  busybox sh -c 'cd /source && tar czf /backup/pgdata-$(date +%F-%H%M%S).tgz .'

查看备份:

bash
ls -lh ~/docker-volume-backups

重新启动容器:

bash
docker start pg-demo-new

这个流程适合说明卷备份边界,但不能替代数据库一致性备份。对于数据库,至少要明确:

检查项 说明
写入状态 是否停写、锁表、进入备份模式或使用原生 dump
备份内容 是完整数据目录、逻辑导出、WAL/归档,还是应用上传文件
恢复目标 原主机、新主机、新卷、灾备环境
验证方式 应用启动、查询样本、校验表数量、业务接口抽测
保留策略 保留几份、是否异地、是否加密、是否定期演练

7.2 恢复到新卷

创建新卷:

bash
docker volume create pgdata-restore

选择一个备份文件:

bash
BACKUP_FILE="$(ls -t ~/docker-volume-backups/pgdata-*.tgz | head -1)"
echo "$BACKUP_FILE"

恢复:

bash
docker run --rm \
  --mount type=volume,source=pgdata-restore,target=/target \
  --mount type=bind,source="$HOME/docker-volume-backups",target=/backup,readonly \
  busybox sh -c "cd /target && tar xzf /backup/$(basename "$BACKUP_FILE")"

用恢复卷启动容器:

bash
docker run -d \
  --name pg-restore-test \
  --network app-net \
  -e POSTGRES_PASSWORD='change-me-in-lab' \
  --mount type=volume,source=pgdata-restore,target=/var/lib/postgresql/data \
  postgres:16-alpine

验证:

bash
docker exec pg-restore-test pg_isready -U postgres
docker exec -i pg-restore-test psql -U postgres -c 'SELECT id, message, created_at FROM lab_note;'

恢复验证必须在独立卷或独立环境中做,不要拿生产卷直接反复覆盖。一次合格的恢复演练,至少要记录备份文件、恢复命令、恢复耗时、验证 SQL 或接口、异常处理和回滚方式。

7.3 迁移到另一台 Docker 主机

单机 Docker volume 的迁移本质上是“备份、传输、恢复、验证”。流程可以是:

  1. 在源主机停止写入或进入一致状态。
  2. 使用辅助容器把命名卷打包到宿主机备份目录。
  3. 将备份文件传到目标主机,例如通过受控 scp、对象存储、制品库或备份系统。
  4. 在目标主机创建同名或新命名卷。
  5. 使用辅助容器解包到目标卷。
  6. 用相同镜像版本或经过验证的新镜像启动容器。
  7. 执行业务验证和回滚确认。

不要只复制 /var/lib/docker/volumes/... 目录当作标准迁移流程。这样做容易绕开 Docker 元数据、权限、driver 差异和主机版本差异。除非你非常清楚底层路径和停机状态,否则应优先采用 Docker 命令可解释的备份恢复流程。

8. 权限、属主与安全边界

数据卷排障中,Permission denied 是最常见的报错之一。它通常不是 Docker “坏了”,而是容器内进程的 UID/GID 与挂载目录权限不匹配。

8.1 先确认容器内进程身份

查看容器内用户:

bash
docker exec <container-name> id
docker exec <container-name> ps -o pid,user,group,args

查看目录权限:

bash
docker exec <container-name> sh -c 'ls -ld /path/to/data && touch /path/to/data/.write-test'

对于命名卷,常见做法是让镜像入口脚本初始化目录权限,或在第一次启动前用辅助容器调整属主:

bash
docker run --rm \
  --mount type=volume,source=app-data,target=/data \
  busybox sh -c 'chown -R 1000:1000 /data'

这里的 1000:1000 必须来自目标镜像实际运行用户,不要机械套用。某些官方镜像使用固定 UID,某些镜像默认 root 启动后降权,某些企业镜像完全禁止 root。先看镜像文档和 docker exec id,再改权限。

8.2 bind mount 更要关注宿主机权限

bind mount 直接使用宿主机路径,容器内 UID/GID 会映射到宿主机文件权限。开发机上一个 chmod -R 777 看似解决问题,生产上却会造成安全隐患。更好的做法是:

  1. 为应用创建专用宿主机目录。
  2. 明确运行用户 UID/GID。
  3. 只给必要读写权限。
  4. 能只读挂载就只读挂载。
  5. 用自动化脚本创建目录和权限,而不是人工临时修。

示例:

bash
sudo mkdir -p /srv/app/upload
sudo chown 1000:1000 /srv/app/upload
sudo chmod 0750 /srv/app/upload

启动容器:

bash
docker run -d \
  --name upload-app \
  --mount type=bind,source=/srv/app/upload,target=/var/lib/app/upload \
  your-image:tag

如果主机启用了 SELinux、AppArmor、rootless Docker 或企业安全代理,还需要结合对应策略检查。不要只盯着 Unix 权限位。

8.3 只读挂载、最小写权限和敏感数据

持久化不是越开放越好。建议按数据类型定义默认权限:

数据类型 建议方式 权限建议
应用配置 bind mount 或配置系统 只读
TLS 证书公钥 bind mount 或 secret 只读
私钥和 token secret 或受控只读挂载 最小权限,避免写日志
数据库数据目录 命名卷 仅数据库进程可写
上传附件 命名卷或受控目录 应用用户可写,避免执行权限
临时文件 tmpfs 限制大小和权限
日志 stdout/stderr 优先,必要时挂载 避免容器反向改系统日志

一个常见反模式是把宿主机根目录、Docker socket、SSH 密钥目录或整个 /etc 挂进容器。除非是经过安全评审的运维工具,否则这类挂载会显著扩大容器逃逸和误操作风险。

9. 日志、配置与业务文件的边界

并不是所有“应用写出来的东西”都应该放进 volume。生产设计需要区分日志、配置、业务数据和缓存。

9.1 日志优先走标准输出

容器化应用的常规建议是把应用日志写到 stdout/stderr,由 Docker logging driver、宿主机日志代理或平台采集器统一处理。这样容器重建不会影响日志采集链路,也不需要在每个应用里设计日志文件目录。

查看日志:

bash
docker logs <container-name> --tail 100

只有在应用确实只能写文件日志、需要兼容旧系统或有审计留存要求时,才考虑把日志目录挂载出来。即使挂载,也要处理轮转、保留、权限和容量限制。

9.2 配置不要和运行数据混在一起

配置文件应当来自镜像默认值、环境变量、只读挂载、配置中心或 secret。运行数据应当进入命名卷或业务存储。把配置和数据混在一个可写目录里,会带来三个问题:

  1. 备份恢复时难以判断哪些文件是业务数据。
  2. 升级镜像时可能被旧配置阻塞。
  3. 排障时无法区分“配置漂移”和“数据损坏”。

比较清晰的目录约定是:

路径 用途 挂载建议
/etc/app 配置 只读 bind mount 或镜像内默认配置
/var/lib/app 业务数据 命名卷
/var/cache/app 可重建缓存 可写层或独立缓存卷
/run/app 运行期临时文件 tmpfs
/var/log/app 文件日志 优先不用;必要时挂载并轮转

9.3 上传文件不一定要放在容器主机

如果应用是单机部署,上传目录可以先用命名卷或受控 bind mount。但当应用将来需要多副本、跨主机、蓝绿发布或灾备时,本地 Docker volume 会成为扩展瓶颈。更通用的选择是对象存储、共享文件系统、数据库大对象或专门的附件服务。

因此在设计上传目录时,要提前问:

  1. 应用是否未来会多副本运行?
  2. 上传文件是否需要跨主机访问?
  3. 文件是否需要版本、生命周期、权限、审计和防病毒?
  4. 备份恢复是否允许只恢复元数据,不恢复对象?

Docker volume 解决的是单机容器数据落点,不自动解决分布式文件一致性。

10. 容量、性能与清理

持久化上线后,容量治理会变成长期工作。Docker 不会自动理解某个卷里的业务数据是否重要,也不会帮你决定什么时候归档。

10.1 查看磁盘占用

查看 Docker 总体占用:

bash
docker system df
docker system df -v

查看卷列表:

bash
docker volume ls

对于具体卷,可以用辅助容器查看大小:

bash
docker run --rm \
  --mount type=volume,source=pgdata,target=/data,readonly \
  busybox du -sh /data

如果是 bind mount,则直接在宿主机上查看:

bash
sudo du -sh /srv/app/upload

容量告警应落在宿主机磁盘、Docker 数据目录、关键业务卷和备份目录上。只监控容器是否运行,不监控卷容量,是有状态容器的常见缺口。

10.2 清理要区分镜像、容器和卷

Docker 清理命令很多,但风险差异很大:

命令 作用 风险
docker container prune 删除已停止容器 可能影响事后取证
docker image prune 删除悬空镜像 通常风险较低,但影响回滚速度
docker system prune 综合清理未使用对象 需要确认范围
docker volume prune 删除未被容器使用的卷 高风险,可能删掉暂时未挂载但仍有业务价值的数据

生产环境不要把 docker volume prune -f 放进通用清理脚本。很多卷在停机迁移、故障恢复、蓝绿切换期间可能暂时没有被容器引用,但仍然是重要数据。

更稳妥的清理流程是:

  1. 列出候选卷。
  2. 查看 label、创建时间、占用大小和最近备份。
  3. 检查是否有容器引用。
  4. 找业务负责人确认。
  5. 在维护窗口内先做备份或快照。
  6. 删除后记录变更单。

10.3 性能不要只看容器内路径

对容器内应用来说,写 /var/lib/app 看起来都是普通路径。但底层可能是可写层、local volume、bind mount、网络文件系统、加密磁盘或云盘。性能差异可能很大。

基本原则:

  1. 高频写入和数据库数据不要放在容器可写层。
  2. 数据库优先使用官方镜像推荐的数据目录挂载方式。
  3. bind mount 到网络文件系统前,应验证锁、延迟、fsync、权限和故障恢复。
  4. 不要在没有测试的情况下把数据库数据目录放到不支持其一致性要求的共享目录。
  5. 性能测试要用应用真实访问模式,不要只用简单 dd 结果下结论。

Docker 官方文档也强调,写入容器可写层需要经过存储驱动处理,适合尽量少写;长期或高频业务数据应使用 volume 或其他外部存储。

11. 故障排查 runbook

持久化故障大多不是单点命令错误,而是“挂载类型、目标路径、权限、生命周期、备份状态”之间的边界没看清。下面的排障图适合在值班时快速定位。

Docker 持久化故障排查

排障时先确认容器实际挂载,再判断权限和路径覆盖。不要在不知道卷来源的情况下直接清理或重建。

11.1 数据不见了

症状:容器重建后数据为空,或者应用像第一次启动一样初始化。

检查:

bash
docker inspect <container-name> --format '{{json .Mounts}}'
docker volume ls
docker ps -a --filter name=<container-name>

可能原因:

原因 判断方法 处理
原来数据写在容器可写层 旧容器已删除且没有挂载记录 如果无备份,通常无法完整恢复
新容器挂载了新卷 比较旧变更记录和 Mounts 使用正确卷名重建
使用匿名卷但没记录 docker volume ls 有不明长 ID 逐个 inspect 和查看大小,找回后重命名迁移
bind mount 路径变了 Source 指向错误宿主机路径 停容器,用正确路径重建
备份恢复到错误卷 应用挂载的不是恢复卷 修改启动参数并验证

如果旧容器还在,不要急着删除。先导出证据:

bash
docker inspect <old-container> > old-container-inspect.json
docker cp <old-container>:/path/to/data ./recovered-data

docker cp 只适合临时救援,不应替代正式备份。

11.2 权限拒绝

症状:应用日志出现 Permission deniedread-only file system、无法创建目录或无法写入数据库文件。

检查:

bash
docker exec <container-name> id
docker exec <container-name> sh -c 'ls -ld /data /data/. 2>/dev/null || true'
docker inspect <container-name> --format '{{json .Mounts}}'

判断:

  1. 如果 RWfalse,说明挂载是只读,应用不能写。
  2. 如果 bind mount 指向宿主机目录,检查宿主机目录属主和权限。
  3. 如果命名卷第一次由 root 写入,后续非 root 进程可能无法写。
  4. 如果启用了 SELinux/AppArmor/rootless,还要检查安全策略。

修复前先备份。权限修复可以用辅助容器或宿主机命令,但必须以目标应用 UID/GID 为准。

11.3 镜像里的文件被“清空”

症状:应用镜像里本来有默认文件,挂载后目录变空。

这通常是挂载覆盖,而不是文件被删除。任何 volume、bind mount 或 tmpfs 挂载到容器内已有目录时,都会让该挂载点下镜像原有内容在容器视角中被遮住。处理方式是:

  1. 查看 docker inspectDestination
  2. 确认挂载目标是否正好覆盖了镜像内置目录。
  3. 如果需要保留镜像默认文件,改挂载路径,或在初始化流程中复制默认文件到卷。
  4. 重建容器,不要试图在运行中的容器里“卸载 Docker mount”。

很多数据库和中间件镜像会在空数据目录时执行初始化脚本;如果你挂载了一个非空目录,初始化逻辑可能不会执行。上线前必须阅读目标镜像的数据目录和初始化规则。

11.4 容器删了但磁盘没有释放

症状:删除容器后,/var/lib/docker 或宿主机磁盘占用仍然很高。

检查:

bash
docker system df -v
docker volume ls

原因通常是命名卷或匿名卷仍然存在。Docker 这样设计是为了避免误删数据。处理方式不是立即 prune,而是先确认卷是否还有业务价值。

查看卷占用:

bash
for v in $(docker volume ls -q); do
  echo "== $v =="
  docker run --rm --mount type=volume,source="$v",target=/data,readonly busybox du -sh /data 2>/dev/null || true
done

这个循环在卷很多时会比较慢,生产环境建议用受控脚本和变更窗口执行。

12. 从单机 Docker 走向 Compose 和生产平台

后续进入 Docker Compose 时,持久化配置会从命令行参数变成 YAML。思想不变:显式命名、明确挂载目标、定义只读边界、把数据和配置分开。

一个简化 Compose 片段如下:

yaml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - app-net

  web:
    image: nginx:alpine
    ports:
      - "18080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - app-net

volumes:
  pgdata:

networks:
  app-net:

这里 pgdata 是命名卷,./nginx/default.conf 是 bind mount 且只读。两者意图不同,不能混为一谈。

进入 Kubernetes、Harvester、VMware 或云平台后,概念会继续演化为 PVC、StorageClass、CSI、云盘、对象存储、快照策略和备份控制器。但最底层的问题仍然一样:数据生命周期是否独立于计算实例,是否能备份恢复,是否知道谁有写权限,是否能解释故障时数据在哪里。

13. 上线前检查清单

有状态容器上线前,至少完成下面这些检查:

检查项 问题 合格标准
数据分类 哪些数据必须持久化 每个关键路径都有明确挂载方案
挂载方式 volume、bind、tmpfs 是否选对 业务数据优先命名卷,配置优先只读
卷命名 卷名是否可追溯 包含应用、环境、用途或有 label
权限 容器 UID/GID 是否能读写 不靠 777 解决生产权限
备份 是否能导出数据 有命令、位置、保留策略
恢复 是否演练过恢复 新卷恢复后应用可启动并验证
清理 是否避免误删卷 不把 volume prune -f 放入通用脚本
监控 是否监控容量和备份 宿主机磁盘、卷大小、备份结果可见
安全 是否限制宿主机暴露 不挂载高危路径,能只读就只读
文档 变更记录是否完整 卷名、路径、恢复方法写入运维文档

这个清单比单条命令更重要。持久化事故往往不是因为某个参数不会写,而是因为上线时没有定义数据归属、恢复路径和清理边界。

14. 实验清理

如果你在测试主机完整执行了本文实验,可以按下面方式清理。清理前确认没有业务数据需要保留。

停止并删除实验容器:

bash
docker rm -f pg-demo pg-demo-new pg-restore-test nginx-bind-demo nginx-dev upload-app 2>/dev/null || true

删除实验网络:

bash
docker network rm app-net 2>/dev/null || true

删除实验卷:

bash
docker volume rm pgdata pgdata-restore demo-data app-data 2>/dev/null || true

删除宿主机实验目录:

bash
rm -rf ~/docker-persistence-lab
rm -rf ~/docker-volume-backups

生产环境不要照抄清理命令。尤其是 docker volume rm,必须确认卷名和业务归属。

15. 小结

Docker 持久化的核心不是记住所有参数,而是建立清晰的数据生命周期模型。容器可写层适合可丢弃状态;命名卷适合容器重建后仍需保留的业务数据;bind mount 适合和宿主机路径明确集成的配置、代码和目录;tmpfs 适合运行期临时数据。真正可上线的持久化方案,还必须包括权限、备份、恢复、容量、清理和安全边界。

上线前可以做一次删除容器演练:停止并删除业务容器,保留卷或宿主机数据,再用同样配置重建容器,确认数据、权限和应用启动状态都符合预期。如果这一步失败,说明数据仍然被容器生命周期绑住,不能直接进入生产。

持久化方案还需要定期复验。备份文件是否真的能恢复,卷是否还被当前服务使用,宿主机目录权限是否被临时修改,清理脚本是否可能误删数据,都应该纳入巡检。数据一旦进入容器平台,运维责任不会消失,只是从“看服务器目录”变成“看卷、挂载、备份和恢复链路”。

恢复演练应至少覆盖一次异机恢复:把备份复制到另一台 Docker 主机,创建新卷并启动同版本容器,确认数据可读、权限正确、应用能正常启动。只有跨主机恢复通过,备份才真正可用。

这一步应形成记录并定期复验。

本文完成后,Docker 单机实战已经具备四个基础能力:镜像能构建、容器能运行、网络能连通、数据能保留。下一篇将继续进入 Docker Compose 编排,把网络、卷、环境变量和服务依赖写成可版本管理的声明式配置。

参考资料