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

数据库运行在 Docker:持久化、初始化、备份、资源和生产边界

数据库放进容器后,真正需要解决的不是“如何启动一个 PostgreSQL 容器”,而是以下几个状态问题:

  • 数据究竟存在哪里,容器删除后是否还存在;
  • 初始化脚本什么时候执行,为什么改了脚本却没有效果;
  • 正在写入的数据库怎样备份,备份是否可恢复;
  • 容器看到的 CPU、内存、共享内存和磁盘是否足够;
  • 哪些能力适合开发环境,哪些边界决定了生产环境不能只依赖一个 Docker 容器。

本文以 Linux 容器和现代 Docker Engine、BuildKit、Compose 规范为背景,使用 PostgreSQL 作为主要示例。不同数据库镜像的目录、初始化变量和备份工具可能不同,因此示例中的 PostgreSQL 路径和命令不能机械地套用到 MySQL、MariaDB 或 MongoDB。


一、先区分三个对象:镜像、容器和数据库数据

1. 镜像是模板,不是数据库实例

镜像包含:

  • 数据库服务器程序;
  • 默认配置;
  • 启动脚本;
  • 可能存在的初始化文件;
  • 文件系统中的静态内容。

镜像本身通常是只读分层。容器启动后,Docker 会在镜像层之上增加一个容器可写层。数据库进程在容器中创建或修改的文件,如果没有额外挂载存储,就会进入这个可写层。

因此,下面这种运行方式通常只适合临时测试:

docker run --name pg \
  -e POSTGRES_PASSWORD=devpass \
  -e POSTGRES_DB=appdb \
  postgres:16

数据库文件写在容器文件系统中。执行:

docker rm -f pg

容器及其可写层一起被删除,数据库数据也随之丢失。

docker stop 只停止容器,不删除可写层;但这不代表容器可写层适合作为长期数据库存储。容器重建、清理脚本、迁移和回滚都会使这种数据保存方式变得脆弱。

2. 容器是进程和运行状态,不是数据本身

容器可以被停止、删除和重新创建。只要数据库数据位于独立的 Volume 或主机目录中,就可以用新容器重新挂载同一份数据:

数据库进程
    │
    ▼
容器文件系统视图
    │
    ▼
/var/lib/postgresql/data
    │
    ▼
Volume 或 Bind Mount
    │
    ▼
Linux 主机上的持久化存储

这里的关键是“挂载点”。容器内的 /var/lib/postgresql/data 只是数据库进程看到的路径;真正的数据可能位于 Docker 管理的 Volume,也可能位于主机指定目录。

3. 数据库的持久化要求比“文件没有消失”更严格

数据库持久化至少包含三层含义:

  1. 生命周期持久化:容器删除后,数据仍然存在;
  2. 崩溃恢复:数据库进程或主机突然停止后,WAL、日志和数据页能够用于恢复;
  3. 备份持久化:主存储损坏、误删或主机丢失后,仍有独立副本可恢复。

Volume 主要解决第一层,并为第二层提供存储位置;它不会自动解决第三层。一个没有备份的 Volume 仍然是单份数据。


二、Volume、Bind Mount 和 tmpfs 的差异

1. Named Volume:Docker 管理的持久化存储

Compose 可以声明一个命名卷:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data

volumes:
  dbdata:

启动:

docker compose up -d

查看卷:

docker volume ls
docker volume inspect <实际卷名>

在默认 Compose 项目中,卷名通常会带项目名前缀,例如:

myproject_dbdata

这里有一个重要的生命周期规则:

docker compose down

默认停止并删除容器、网络,但通常保留 Named Volume。

而:

docker compose down -v

会删除 Compose 声明的卷。这个命令对开发环境很方便,但对数据库来说可能造成不可逆的数据删除。

Named Volume 的优点是:

  • 不需要把主机目录结构暴露给项目;
  • Docker 负责创建和挂载;
  • 更容易在容器重建后复用;
  • 可以通过 Volume Driver 接入某些外部存储。

local Volume 默认仍然只是当前 Docker 主机上的本地目录。它不是跨主机复制,也不是高可用存储。

2. Bind Mount:把主机目录直接映射进去

Bind Mount 示例:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - ./postgres-data:/var/lib/postgresql/data

它的语义是:主机上的 ./postgres-data 直接成为容器内的数据目录。

Bind Mount 适合以下场景:

  • 需要明确控制数据存放位置;
  • 需要使用主机备份系统、快照系统;
  • 数据目录必须与某个主机路径绑定;
  • 本地开发中希望直接查看文件。

但它会引入更多主机耦合:

  • 主机目录的属主和权限必须适合容器内数据库用户;
  • 相对路径依赖 Compose 项目目录;
  • 目录可能被误删、误覆盖;
  • Linux、Docker Desktop 和远程 Docker Engine 的路径语义不同。

如果主机目录非空,挂载后会覆盖容器原本同一路径下的内容。这个现象称为挂载遮蔽:路径仍然存在,但镜像层中原有的文件从容器视图中不可见。

3. tmpfs:内存中的临时文件系统

Linux 容器可以挂载 tmpfs:

docker run --rm \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  alpine sh

tmpfs 的数据保存在内存中,容器停止或删除后消失。它适合:

  • 临时文件;
  • 对磁盘落盘敏感的中间数据;
  • 明确不需要持久化的缓存。

它不适合数据库主数据目录。把 PostgreSQL 的数据目录放在 tmpfs 上,容器重启后数据库自然会丢失。tmpfs 也会消耗主机内存,不能把它当成“无限制的快磁盘”。

4. 一个选择决策

类型 容器删除后数据 主机路径是否显式指定 典型用途
Named Volume 保留 数据库主数据、Docker 管理的持久化
Bind Mount 保留 主机目录、备份系统、明确路径
tmpfs 不保留 临时文件、缓存、敏感的短期数据
容器可写层 容器删除后丢失 临时实验,不适合数据库

真正的判断条件不是“Volume 一定好”或“Bind Mount 一定好”,而是:

数据库数据
  → 是否独立于容器生命周期
  → 是否位于可靠存储
  → 是否有独立备份
  → 是否经过恢复验证

三、初始化:空数据目录只会被初始化一次

官方数据库镜像通常包含一个入口脚本。以 PostgreSQL 官方镜像为例,启动过程可以抽象为:

容器启动
  │
  ├─ 检查数据目录是否已初始化
  │
  ├─ 如果为空:
  │    ├─ 执行 initdb
  │    ├─ 设置 POSTGRES_* 环境变量对应的初始对象
  │    ├─ 执行 /docker-entrypoint-initdb.d 下的脚本
  │    └─ 停止临时数据库并启动正式服务器
  │
  └─ 如果已有 PG_VERSION:
       └─ 直接启动已有数据库,不重复初始化

PG_VERSION 是 PostgreSQL 数据目录已经初始化的一个重要标志。具体镜像版本的入口脚本可能有所变化,但“只对空数据目录执行初始化”是这类镜像的核心行为。

1. 一个完整的 Compose 初始化示例

目录结构:

demo/
├── compose.yaml
└── init/
    ├── 01-schema.sql
    └── 02-seed.sh

compose.yaml

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  dbdata:

init/01-schema.sql

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

init/02-seed.sh

#!/bin/sh
set -eu

psql -v ON_ERROR_STOP=1 \
  --username "$POSTGRES_USER" \
  --dbname "$POSTGRES_DB" <<'SQL'
INSERT INTO users (email)
VALUES ('alice@example.com')
ON CONFLICT (email) DO NOTHING;
SQL

赋予脚本执行权限:

chmod +x init/02-seed.sh
docker compose up -d
docker compose logs db

预期可以看到初始化日志,随后数据库进入可连接状态。验证:

docker compose exec db \
  psql -U app -d appdb -c 'SELECT id, email FROM users;'

可能输出:

 id |       email
----+-------------------
  1 | alice@example.com
(1 row)

这里每一步成立的原因是:

  1. dbdata 第一次使用时为空;
  2. 官方入口脚本执行 initdb
  3. SQL 文件和可执行 Shell 脚本按文件名排序执行;
  4. 初始化完成后,数据写入 Volume;
  5. 后续重启时,目录不再为空,脚本不会再次运行。

2. 为什么修改初始化脚本没有效果

执行:

docker compose restart db

或:

docker compose up -d

不会重新执行初始化脚本,因为 dbdata 已经包含数据库集群。

如果只是开发环境,想从头初始化,可以:

docker compose down -v
docker compose up -d

这会删除 Volume 中的数据。生产环境不能用这种方式“修复初始化问题”,因为它的实际效果是删除数据库。

更安全的诊断顺序是:

docker compose ps
docker compose logs db
docker compose exec db \
  psql -U app -d appdb -c '\dt'

如果需要把已有数据库升级到新结构,应使用版本化迁移工具,例如应用迁移框架或专门的数据库迁移流程,而不是再次依赖 /docker-entrypoint-initdb.d

3. 初始化脚本中的失败状态

初始化脚本不是事务边界的统一保证。一个 SQL 文件可以使用事务,但多个文件之间不一定构成一个整体事务;Shell 脚本执行外部命令也可能只完成一半。

例如:

set -eu
psql ... -f /path/01.sql
psql ... -f /path/02.sql

如果 02.sql 失败,01.sql 可能已经提交。下次容器启动时,数据目录已经初始化,两个脚本通常都不会自动重跑。

因此,初始化脚本应当:

  • 在 SQL 中使用 ON CONFLICT 等幂等语句;
  • 使用 psql -v ON_ERROR_STOP=1,避免 SQL 错误后继续执行;
  • 把不可重复的操作放入明确的迁移系统;
  • 把初始化失败视为需要人工检查的状态,而不是简单重启。

四、Volume 初始化复制与权限问题

1. 空 Volume 可能获得镜像目录的初始内容

Docker 在某些挂载场景下,会将容器镜像中挂载目标路径的已有内容复制到一个空 Volume 中。数据库官方镜像通常也依赖入口脚本创建数据目录。

但这不等于“镜像里的数据库数据会自动成为可靠备份”。复制只发生在特定的空 Volume 初始化阶段,后续镜像升级不会自动把新文件合并进已有数据目录。

数据库数据目录的升级必须由数据库自己的升级工具、入口逻辑或迁移流程完成。

2. 权限问题的本质

容器内的 postgres 用户是一个 Linux 用户,具有 UID 和 GID。Bind Mount 使用的是主机实际文件权限;容器内看到的 UID 数字需要对主机目录有写权限。

诊断命令:

docker compose exec db id postgres
docker compose exec db ls -ld /var/lib/postgresql/data

在主机上检查:

ls -ld ./postgres-data
stat -c '%u:%g %a %n' ./postgres-data

如果使用 Bind Mount,而目录归属不正确,日志可能出现:

initdb: error: could not change permissions of directory
Permission denied

修复方式必须基于实际镜像 UID,而不是盲目假设。可以先获取容器中的 UID:

PG_UID=$(docker run --rm postgres:16 id -u postgres)
PG_GID=$(docker run --rm postgres:16 id -g postgres)

sudo chown -R "$PG_UID:$PG_GID" ./postgres-data

然后再启动容器。

Named Volume 通常更容易处理权限,因为它由 Docker 创建,并且官方镜像入口脚本可以在初始化阶段调整目录权限。但这只是减少主机目录管理问题,不代表权限、SELinux 或存储驱动问题永远不存在。


五、数据库为什么通常不应把主数据放在容器可写层

容器可写层通常由 OverlayFS 等存储驱动实现。数据库会反复修改数据页、WAL、临时文件和目录元数据,而写时复制和分层文件系统会增加额外的路径复杂度。

更重要的是,容器可写层与容器生命周期绑定:

写入容器可写层
  ├─ stop:暂时保留
  ├─ start:仍可见
  ├─ rm:删除
  └─ recreate:得到新的空可写层

Volume 或 Bind Mount 的数据流则是:

写入数据库目录
  → 挂载存储
  → 数据独立于容器对象
  → 新容器重新挂载

这不是说 OverlayFS 在技术上完全不能保存文件,而是数据库需要清晰、可管理、可备份的数据生命周期。将数据库数据目录放在专用 Volume 或主机存储中,能把“容器版本变化”和“数据版本变化”分开。

BuildKit 也不能改变这个事实。Dockerfile 中的 RUN 发生在镜像构建阶段,BuildKit 的缓存挂载是构建缓存,不是运行时数据库 Volume:

# 这是构建缓存,不是生产数据库数据
RUN --mount=type=cache,target=/root/.cache \
    some-build-command

构建结束后,运行中的数据库不会自动使用这个缓存来持久化数据。


六、备份:复制文件不等于得到一致备份

数据库正在并发写入时,数据目录包含多种相互关联的状态:

  • 数据文件中的数据页;
  • WAL 或 redo log;
  • 控制文件;
  • 临时文件;
  • 元数据和目录结构。

设数据库在时间 tt 的一致状态为:

S(t)=(D(t),W(t),C(t))S(t) = (D(t), W(t), C(t))

其中:

  • D(t)D(t) 是数据页集合;
  • W(t)W(t) 是 WAL 或重做日志状态;
  • C(t)C(t) 是控制文件和元数据状态。

一个可恢复备份需要满足:

t,BD=D(t),BWW(t) 所需的恢复信息,BC=C(t)\exists t,\quad B_D = D(t),\quad B_W \supseteq W(t)\text{ 所需的恢复信息},\quad B_C = C(t)

直观地说,备份中的数据文件、日志和控制信息必须能够对应某个可恢复的数据库状态。只是在数据库运行时执行:

tar czf db.tar.gz /var/lib/postgresql/data

不能自动保证这个条件成立。复制过程可能读到不同时间点的数据页,也可能漏掉尚未复制的文件。

1. 逻辑备份:使用数据库协议导出

PostgreSQL 的逻辑备份使用 pg_dump

mkdir -p backups

docker compose exec -T db \
  pg_dump \
    -U app \
    -d appdb \
    --format=custom \
  > backups/appdb-$(date +%Y%m%d-%H%M%S).dump

这里的 -T 很重要:它关闭伪终端,避免二进制或重定向输出被终端处理。> 是宿主机 Shell 执行的,因此备份文件直接落在主机的 backups 目录,而不是容器内部。

--format=custom 生成 PostgreSQL 自定义格式,适合使用 pg_restore 选择性恢复。验证文件不是空文件:

ls -lh backups/*.dump
docker compose exec -T db \
  pg_restore --list < backups/appdb-20240101-120000.dump | head

pg_dump 使用数据库的一致性读取机制,可以在数据库继续提供服务时导出一个逻辑一致的时间点视图。它不会阻止其他事务写入,但导出的是逻辑对象和数据,不是原始数据目录。

角色、表空间等全局对象不完全由单个数据库的 pg_dump 覆盖,可以单独导出:

docker compose exec -T db \
  pg_dumpall -U app --globals-only \
  > backups/globals.sql

恢复到新数据库的一个完整流程可以是:

docker compose exec -T db \
  createdb -U app restoredb

cat backups/appdb-20240101-120000.dump | \
  docker compose exec -T db \
  pg_restore -U app -d restoredb --exit-on-error

验证:

docker compose exec db \
  psql -U app -d restoredb -c \
  'SELECT count(*) FROM users;'

逻辑备份的取舍是:

  • 优点:与底层文件系统解耦,可跨主机、跨部分版本迁移;
  • 缺点:恢复需要重新创建对象和数据,速度通常受数据量及索引重建影响;
  • 边界:它不是任意时刻的物理快照,也不能代替完整的角色、扩展和外部对象管理。

2. 文件级 Volume 备份:先停止数据库

如果目标是打包 Volume 中的原始数据目录,最容易解释和验证的一种方法是先停止数据库:

docker compose stop db

假设 Compose 文件中使用固定卷名:

volumes:
  dbdata:
    name: project-dbdata

此时可以备份:

mkdir -p backups

docker run --rm \
  -v project-dbdata:/from:ro \
  -v "$PWD/backups:/to" \
  alpine:3.20 \
  sh -c 'tar czf /to/project-dbdata.tgz -C /from .'

这里的组件关系是:

  • project-dbdata:/from:ro:把数据库 Volume 只读挂到临时容器;
  • "$PWD/backups:/to":把宿主机备份目录挂到临时容器;
  • tar -C /from .:打包 Volume 根目录内容;
  • --rm:备份容器完成后自动删除。

停止数据库的原因是建立一个简单的静态状态:

停止数据库
  → 不再产生新的数据页、WAL 和目录变更
  → tar 读取完整数据目录
  → 启动数据库

恢复时不要直接覆盖一个正在运行的数据目录。可以先将备份解压到临时卷,验证目录,再切换容器:

docker compose stop db

docker volume create project-dbdata-restore

docker run --rm \
  -v project-dbdata-restore:/to \
  -v "$PWD/backups:/from:ro" \
  alpine:3.20 \
  sh -c 'tar xzf /from/project-dbdata.tgz -C /to'

docker run --rm \
  -v project-dbdata-restore:/data:ro \
  alpine:3.20 \
  sh -c 'test -f /data/PG_VERSION && cat /data/PG_VERSION'

确认数据目录存在后,需要让 Compose 使用这个卷。实际切换可以通过修改卷声明、使用固定外部卷,或在一次性恢复环境中挂载该卷。不要在未确认备份有效前执行:

docker volume rm project-dbdata

如果数据库在备份期间没有停止,文件级备份可能仍然在 PostgreSQL 的崩溃恢复能力范围内,但这依赖数据库版本、存储语义、WAL 是否完整以及备份工具的正确用法。仅凭“tar 命令成功”不能推出“数据库一定能恢复”。

3. 快照、物理备份和逻辑备份不是同一个概念

在支持快照的存储上,可以对 Volume 底层存储做快照;但快照的一致性取决于:

  • 数据库是否已暂停写入或执行了适当的 checkpoint;
  • 存储是否保证写入顺序和崩溃一致性;
  • 快照是否包含必要的 WAL;
  • 恢复时数据库是否能完成 crash recovery。

物理备份适合大数据库和快速恢复,但通常与数据库版本、架构、扩展和存储布局关联更强。逻辑备份更适合迁移、选择性恢复和小型数据库。

无论选择哪一种,备份系统至少应记录:

备份时间
数据库版本
镜像版本或摘要
备份类型
数据范围
加密状态
校验值
保留期限
恢复步骤
最近一次恢复验证结果

把备份文件留在同一台主机的另一个目录,不能抵御主机磁盘损坏;把备份留在同一个 Volume 中,也不能抵御 Volume 被误删。


七、恢复演练:备份的完成条件是“能恢复”

备份命令成功只说明命令没有返回错误。恢复验证应当在独立环境中进行,避免破坏线上数据。

一个逻辑备份验证流程可以使用临时 Compose 项目:

mkdir -p restore-test
cd restore-test

创建临时数据库:

docker run -d --rm \
  --name pg-restore-test \
  -e POSTGRES_PASSWORD=testpass \
  -e POSTGRES_DB=restoredb \
  postgres:16

等待数据库就绪:

until docker exec pg-restore-test \
  pg_isready -U postgres -d restoredb >/dev/null 2>&1
do
  sleep 1
done

恢复:

docker exec -i pg-restore-test \
  pg_restore -U postgres -d restoredb --exit-on-error \
  < ../backups/appdb-20240101-120000.dump

验证表和数据:

docker exec pg-restore-test \
  psql -U postgres -d restoredb -c '\dt'

docker exec pg-restore-test \
  psql -U postgres -d restoredb -c \
  'SELECT count(*) FROM users;'

完成后删除临时容器:

docker rm -f pg-restore-test

恢复演练应验证的不只是“能启动”,还包括:

  • 关键表是否存在;
  • 行数或业务校验值是否符合预期;
  • 角色和权限是否恢复;
  • 应用是否能连接;
  • 扩展是否可用;
  • 恢复耗时是否满足业务的 RTO。

其中:

  • RPO 表示最多可以丢失多长时间的数据;
  • RTO 表示故障后最多允许多长时间恢复服务。

例如,只有每天凌晨执行一次逻辑备份,理论上 RPO 可能接近 24 小时;即使恢复命令只需 10 分钟,RTO 也可能满足,但 RPO 不满足业务要求。备份类型和频率必须由这两个目标反推,而不能只看“是否有备份文件”。


八、资源限制:数据库看到的是 cgroup 里的世界

Docker 在 Linux 上通过 cgroup 对容器施加资源约束。数据库进程看到的 CPU 和内存,并不一定等于主机总资源。

一个 Compose 示例:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    cpus: 2.0
    mem_limit: 2g
    mem_reservation: 1g
    pids_limit: 512
    shm_size: 256m

这些字段的意义是:

  • cpus: 2.0:限制 CPU 使用上限,具体调度表现由 cgroup 和主机负载决定;
  • mem_limit: 2g:容器可用内存上限;
  • mem_reservation: 1g:软性内存需求,不能当成绝对保留;
  • pids_limit: 512:限制进程和线程数量;
  • shm_size: 256m:设置容器 /dev/shm 大小。

Compose 实际支持的资源字段和非 Swarm、Swarm 部署方式之间存在差异。deploy.resources 在不同 Compose 实现和部署目标上的处理不能一概而论,因此生产前应使用目标环境的 docker compose config 和运行时检查确认,而不是只看 YAML。

检查容器实际配置:

docker inspect <container_id> \
  --format '{{json .HostConfig.Memory}} {{json .HostConfig.NanoCpus}}'

查看运行时资源使用:

docker stats

1. 内存限制与数据库内部内存不是同一层

PostgreSQL 的内存由多个部分构成:

  • shared_buffers
  • 每个连接的工作内存;
  • 排序、哈希和维护操作使用的内存;
  • 后端进程、扩展和操作系统页缓存;
  • 临时文件和共享内存。

因此,不能简单地设置:

shared_buffers = 容器内存上限

因为 PostgreSQL 还需要其他内存,且连接数越高,并发工作内存的总和越难估计。一个粗略的容量约束可以写成:

MtotalMshared+Nconn×Mper-conn+Mprocess+MOS/cacheM_{\text{total}} \approx M_{\text{shared}} + N_{\text{conn}} \times M_{\text{per-conn}} + M_{\text{process}} + M_{\text{OS/cache}}

其中:

  • MsharedM_{\text{shared}} 是共享缓冲区;
  • NconnN_{\text{conn}} 是并发连接数;
  • Mper-connM_{\text{per-conn}} 是单连接可能使用的工作内存;
  • MprocessM_{\text{process}} 是后台进程和扩展开销;
  • MOS/cacheM_{\text{OS/cache}} 是页缓存等额外消耗。

这不是 PostgreSQL 的精确内存公式,而是说明为什么容器内存上限不能只减去一个配置项后就认为安全。

当 cgroup 内存不足时,可能出现:

  • 容器被内核 OOM killer 终止;
  • 数据库日志中断;
  • docker inspect 显示 OOMKilled;
  • 应用看到连接断开;
  • 数据库下次启动执行崩溃恢复。

诊断:

docker inspect <container_id> \
  --format '{{.State.OOMKilled}} {{.State.ExitCode}}'

docker compose logs --tail=200 db

2. /dev/shm 太小会造成看似无关的失败

Docker 容器默认的 /dev/shm 往往较小。某些数据库并发查询、并行执行或扩展会使用 POSIX 共享内存;共享内存不足时,错误可能类似:

could not resize shared memory segment
No space left on device

这不一定表示数据库 Volume 磁盘已满,可能只是 /dev/shm 满了。分别检查:

docker compose exec db df -h
docker compose exec db df -h /dev/shm

shm_size 只能解决共享内存空间问题,不能替代数据库内存规划。

3. CPU 限制会改变延迟和恢复时间

CPU 配额不足时,数据库可能仍然“正常运行”,但表现为:

  • 查询延迟增加;
  • checkpoint、VACUUM 或索引创建变慢;
  • 备份和恢复时间变长;
  • 高并发下请求排队。

CPU 限制通常比内存 OOM 更隐蔽,因为进程不会立即退出。监控中应区分:

CPU 使用率高
CPU 配额被 throttled
数据库锁等待
磁盘 I/O 等待

这些现象的处理方式不同。单纯增加容器 CPU 不一定能解决锁竞争或慢磁盘。

4. 磁盘空间和 I/O 是数据库资源的一部分

检查 Volume 所在文件系统:

docker system df
df -h
df -i

df -h 检查容量,df -i 检查 inode。数据库可能在还有可用容量时因为 inode、WAL、临时文件或日志目录耗尽而写入失败。

Docker 的资源限制不自动提供高质量数据库 I/O。local Volume 通常使用主机文件系统;磁盘类型、文件系统、宿主机负载和存储驱动都会影响延迟。数据库的 fsync、WAL 和存储设备的写入持久性仍然需要单独验证。


九、启动顺序、健康检查与应用连接

depends_on 主要描述容器启动顺序,不等价于数据库已经可以接受连接。应用容器可能在数据库进程刚启动、还未完成恢复时就发起连接。

Compose 可以通过健康检查表达“服务可用”:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20

  api:
    image: example/api:1.0
    depends_on:
      db:
        condition: service_healthy

但健康检查也有边界:

  • pg_isready 主要检查连接可用性;
  • 它不验证业务表是否存在;
  • 不验证迁移是否完成;
  • 不验证数据库是否有足够磁盘;
  • 不验证读写延迟是否符合要求。

应用自身仍应实现连接重试和退避。数据库重启、主机迁移和恢复期间,连接失败是正常故障路径的一部分,而不是只靠 Compose 配置就能消除的情况。


十、数据库版本升级:镜像升级不等于数据升级

把:

image: postgres:16

改成:

image: postgres:17

只改变了数据库服务器镜像,不一定能直接启动已有的 PostgreSQL 16 数据目录。PostgreSQL 的主版本升级通常需要逻辑导出导入、pg_upgrade 或其他官方支持的升级路径。

正确的思路是先确认:

docker compose exec db psql -U app -d appdb -c 'SHOW server_version;'

然后选择升级方法:

旧版本容器和旧数据
  → 备份并验证
  → 准备新版本数据库
  → 使用兼容的升级方式迁移
  → 校验数据、权限和扩展
  → 切换应用连接

不要把“拉取了新镜像”和“完成了数据库升级”混为一谈。镜像标签也可能移动,生产环境应记录并审查镜像摘要:

docker image inspect postgres:16 \
  --format '{{index .RepoDigests 0}}'

标签固定有助于复现,但不能替代数据库升级计划和恢复演练。


十一、哪些能力适合 Docker,哪些属于生产边界

1. Docker 能提供的能力

在单台 Linux 主机上,Docker 可以很好地提供:

  • 可重复的数据库软件环境;
  • 进程隔离;
  • 配置和环境变量注入;
  • Volume 挂载;
  • 健康检查和重启策略;
  • 资源限制;
  • 本地开发、测试和 CI 环境;
  • 通过备份工具实现可恢复的数据流程。

例如:

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  dbdata:

restart: unless-stopped 只能在容器退出时尝试重启。它不能修复数据损坏,不能跨主机迁移,也不能保证数据库已经完成恢复。

2. Docker 单容器不能自动提供的能力

单个容器和单个本地 Volume 默认不提供:

  • 跨主机高可用;
  • 自动故障转移;
  • 多副本一致性;
  • 跨区域灾备;
  • 时间点恢复;
  • 备份加密和异地保留;
  • 数据库主从复制管理;
  • 监控、告警和容量自动扩展。

尤其需要区分:

容器重启 ≠ 高可用
Volume 保留 ≠ 备份
数据库副本 ≠ 备份
健康检查通过 ≠ 业务正确
镜像升级 ≠ 数据库升级

数据库副本可能同步复制误删除、错误更新或逻辑损坏,因此复制改善可用性和部分 RPO,却不能单独取代不可变备份。

3. 生产部署前必须回答的边界问题

一个数据库容器进入生产前,至少要明确:

  1. 数据目录位于哪个主机和文件系统;
  2. 主机丢失后,Volume 如何恢复;
  3. 备份是否存放在独立故障域;
  4. 恢复到新主机需要哪些镜像、配置、密钥和权限;
  5. 数据库主版本如何升级;
  6. 磁盘满、内存 OOM、WAL 增长和备份失败如何告警;
  7. 需要的 RPO、RTO 是否能由当前备份策略满足;
  8. 谁负责执行恢复,恢复步骤是否经过演练;
  9. 是否需要副本、自动故障转移或托管数据库;
  10. 数据库密码和备份文件如何加密、轮换和访问控制。

当需求包含跨主机故障切换、严格 RPO、自动副本管理或异地灾备时,仅使用单机 Docker Compose 通常已经越过了合理边界。此时应考虑数据库托管服务、专业编排平台或经过验证的数据库集群方案,而不是继续堆叠容器参数。


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

1. 容器删除后数据消失

检查是否使用了挂载:

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

如果 Mounts 为空,数据库很可能写在容器可写层中。恢复旧数据的可能性取决于容器是否仍存在,以及底层存储是否已被清理;不能把这种恢复当作可靠方案。

2. 初始化脚本不执行

按以下顺序检查:

docker compose logs db
docker compose exec db ls -la /docker-entrypoint-initdb.d
docker compose exec db ls -la /var/lib/postgresql/data

常见原因包括:

  • Volume 已经初始化;
  • 脚本文件名排序不符合预期;
  • Shell 脚本没有执行权限;
  • SQL 语法错误;
  • 环境变量只在首次初始化时生效;
  • 挂载路径写错;
  • 脚本依赖的用户或数据库尚未创建。

3. 数据库启动失败

先看状态和日志:

docker compose ps
docker compose logs --tail=200 db

再区分故障类型:

Permission denied
  → 主机目录属主、权限、SELinux 或挂载方式

No space left on device
  → 文件系统容量、inode、/dev/shm 或临时目录

database files are incompatible
  → 数据目录与数据库主版本不兼容

OOMKilled
  → cgroup 内存上限或主机内存压力

connection refused
  → 数据库尚未就绪、端口错误或进程已退出

不要在没有备份和诊断的情况下删除数据目录、重新初始化或强制降级镜像。对数据库来说,“让容器启动起来”可能意味着牺牲原有数据。

4. 备份文件存在但无法恢复

验证:

file backups/appdb.dump
docker compose exec -T db \
  pg_restore --list < backups/appdb.dump

如果是文件级备份,则先在临时 Volume 中检查:

docker run --rm \
  -v project-dbdata:/data:ro \
  alpine:3.20 \
  sh -c 'test -f /data/PG_VERSION && cat /data/PG_VERSION'

恢复失败的常见原因包括:

  • 备份时数据库仍在并发写入,文件状态不一致;
  • 备份不完整;
  • 缺少角色、扩展或表空间;
  • 使用了不兼容的数据库版本;
  • 恢复目标权限不正确;
  • 备份文件被截断或损坏;
  • 只恢复了数据库内容,没有恢复全局对象。

因此,恢复验证必须使用实际的备份文件,而不是只验证备份命令的退出码。


十三、一个适合本地开发的完整最小方案

以下 Compose 配置覆盖了持久化、初始化、健康检查和资源边界:

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d:ro
    ports:
      - "127.0.0.1:5432:5432"
    cpus: 2.0
    mem_limit: 2g
    pids_limit: 512
    shm_size: 256m
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  dbdata:

这里把端口绑定到 127.0.0.1,表示只允许本机访问,避免把开发数据库直接暴露到所有网卡。若应用也运行在同一个 Compose 网络中,应用通常应通过服务名 db:5432 连接,而不是依赖宿主机端口映射。

启动和验证:

docker compose up -d
docker compose ps
docker compose exec db \
  psql -U app -d appdb -c 'SELECT current_database(), current_user;'

备份:

mkdir -p backups

docker compose exec -T db \
  pg_dump -U app -d appdb --format=custom \
  > backups/appdb.dump

查看卷:

docker volume ls
docker volume inspect "$(docker compose ps -q db | xargs docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}')"

开发环境需要重置时:

docker compose down -v

执行前必须确认目标确实是开发项目,因为这个命令会删除数据库 Volume。


结语:把数据库生命周期从容器生命周期中分离出来

数据库运行在 Docker 中的核心模型可以压缩为:

镜像:数据库软件和初始化逻辑
容器:数据库进程和运行时配置
Volume/Bind Mount:主数据生命周期
备份:跨存储故障的独立副本
恢复演练:证明备份真正可用
资源限制:约束数据库实际运行环境
生产方案:在单容器能力之外补齐高可用和灾备

只要数据库数据仍然依附于容器可写层,容器删除就可能等于数据删除;只要备份没有经过恢复验证,就不能声称具备可恢复能力;只要 Volume 仍在单台主机上,就不能把它当成跨主机高可用。

Docker 可以成为可靠数据库运行环境的一部分,但它本身不是数据库运维系统。可靠性来自明确的数据边界、一致的备份方法、可重复的恢复流程、经过计算的资源限制,以及对单机存储和容器生命周期边界的诚实认识。


系列导航与关联阅读

官方资料

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