前置阅读:建议先阅读 Docker Volume 与网络:数据持久化、容器通信与生产存储策略 与 容器资源限制与 cgroup 深入:CPU/内存/IO 配额与 OOM。本篇聚焦 数据库类有状态服务的容器化持久化、备份恢复与生产调优。
把 Postgres 塞进容器只需要一条 docker run,但要让它 在重启、崩溃、磁盘写满、镜像升级之后依然不丢一个字节,需要理解一整套存储语义。数据库是容器化道路上最难啃的骨头:应用容器随时可以杀掉重建,数据库容器里躺着的是不可再生的数据。本文用 Postgres、MySQL、Redis 三个最典型的镜像,把数据目录、初始化脚本、持久化配置、备份恢复、性能陷阱和升级路径一次讲透。
1. 数据库容器化的核心约束
1.1 数据目录与 VOLUME 语义
官方镜像几乎都在 Dockerfile 里声明了 VOLUME。以 postgres 为例,其数据目录是 /var/lib/postgresql/data;mysql 是 /var/lib/mysql;redis 是 /data。一旦声明 VOLUME,Docker 在容器创建时会做两件事:
- 若你在
docker run时没有显式挂载该路径,Docker 会 自动创建一个匿名卷(64 位随机 ID 命名),把镜像里该目录的初始内容复制进去。 - 若你显式挂载了命名卷或 bind mount,则 镜像中的初始内容会被复制到该卷(仅当卷为空时)。
这个「自动匿名卷」是最大的隐形陷阱:很多人 docker run -d postgres 之后直接 docker rm 容器,以为数据没了,其实数据还躺在 /var/lib/docker/volumes/<随机ID>/ 里,磁盘被慢慢吃满;反之,如果你以为数据在容器里,删了容器才发现匿名卷还在但找不到——docker volume ls 里一堆 64 位哈希,无从对应。
# 反例:隐式匿名卷,无法追踪、无法复用
docker run -d --name pg postgres:16
docker rm -f pg # 数据仍在匿名卷里,成了孤儿卷
# 正例:显式命名卷,生命周期可控
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
一句话:任何持久化路径都必须显式挂载命名卷或 bind mount,绝不要依赖
VOLUME声明带来的匿名卷。
1.2 存储驱动 CoW 对数据库 IO 的影响
overlay2 的写时复制(Copy-on-Write)机制对「大文件顺序写、就地更新」的数据库极其不友好:首次写入一个块要先把整个文件从 lowerdir 复制到 upperdir,且每次容器层改动都会产生新层。数据库的随机小写入在这种语义下会被放大成大量复制。
| 存储位置 | 底层机制 | 数据库 IO 表现 | 适用场景 |
|---|---|---|---|
| 容器可写层(overlay2) | 写时复制,按文件复制 | 首次写放大严重,随机写差 | 严禁存放数据库数据 |
| 命名卷(默认 local 驱动) | 直接落在宿主 /var/lib/docker/volumes | 接近裸盘,支持直接 IO | 生产默认选择 |
| bind mount 到数据盘 | 直接挂宿主目录 | 与裸盘一致,可指定 XFS/ext4 | 需要精确控制挂载参数时 |
| tmpfs | 内存文件系统 | 极快但不持久 | 仅用于临时排序空间 |
结论很明确:数据库数据目录必须落在卷或 bind mount 上,绝不允许写在容器可写层。此外,宿主文件系统建议使用 XFS 或 ext4,并确保挂载时没有 noatime 之外的额外限制;对于 pg_basebackup 这类依赖 fsync 的场景,底层存储必须真正支持 fsync 落盘。
2. Postgres 容器化实战
2.1 数据目录、PGDATA 与权限
Postgres 镜像默认以 postgres 用户(UID 999)运行,数据目录默认 /var/lib/postgresql/data。有两个高频坑:
- 挂载点吞掉 PGDATA:如果你把卷挂到
/var/lib/postgresql/data,而PGDATA又指向该目录下的子目录,某些编排工具(尤其是 Kubernetes 挂载空目录时)会让初始化失败。稳妥做法是显式设置PGDATA为挂载点下的子目录,或直接用PGDATA=/var/lib/postgresql/data/pgdata。 - 权限错乱:bind mount 到宿主目录时,宿主目录属主往往不是 999,导致
initdb报could not change permissions of directory。此时需要chown -R 999:999,或用--user指定,但--user又要保证该用户对 PGDATA 有写权限。
# 推荐:命名卷 + 显式 PGDATA 子目录
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-e PGDATA=/var/lib/postgresql/data/pgdata \
-e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8" \
-v pgdata:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
POSTGRES_INITDB_ARGS 只在 数据目录为空、首次初始化 时生效;后续重启会被忽略,改它不会影响已存在的集群。POSTGRES_INITDB_ARGS 常用来固定 --encoding 与 --locale,避免宿主环境变量 LANG 漂移导致 collation 不一致。
2.2 初始化脚本 /docker-entrypoint-initdb.d
镜像入口脚本会在首次初始化完成后,按文件名字典序执行 /docker-entrypoint-initdb.d/ 下的 .sh、.sql、.sql.gz 文件。这是建库、建用户、装扩展的标准位置:
-- initdb/01-schema.sql
CREATE DATABASE appdb;
CREATE USER app WITH PASSWORD 'app_pass';
GRANT ALL PRIVILEGES ON DATABASE appdb TO app;
\connect appdb
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE TABLE t_orders (id bigserial PRIMARY KEY, amount numeric(12,2));
# docker-compose.yml 片段
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- pgdata:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro
关键点:这些脚本只在数据目录为空时执行一次。卷里已有数据时它们被完全跳过——这是「我改了初始化脚本为什么没生效」的根因。要重跑只能删卷重建,或手工 docker exec 执行。
2.3 共享内存与 shm-size
Postgres 使用 shared_buffers 之外的共享内存做并行查询、大排序和 hash join。Docker 默认给 /dev/shm 仅 64MB,一旦查询超过该阈值会报 could not resize shared memory segment 或 No space left on device。解决办法是提高 --shm-size:
docker run -d --name pg \
--shm-size=1g \
-v pgdata:/var/lib/postgresql/data \
postgres:16 -c shared_buffers=512MB -c max_parallel_workers=4
在 Compose 中对应 shm_size: 1gb。经验值:shm_size 至少与 shared_buffers 同量级,高并发并行查询场景建议 1~2GB。
3. MySQL 容器化实战
3.1 环境变量与初始化
MySQL 官方镜像用一组环境变量完成初始化,语义与 Postgres 类似,但也只在数据目录为空时生效:
| 变量 | 作用 | 备注 |
|---|---|---|
| MYSQL_ROOT_PASSWORD | 设置 root 密码 | 必填(除非用随机密码或 socket 认证) |
| MYSQL_DATABASE | 自动创建数据库 | 首次初始化执行 |
| MYSQL_USER / MYSQL_PASSWORD | 创建普通用户并授权该库 | 两变量必须成对出现 |
| MYSQL_ALLOW_EMPTY_PASSWORD | 允许空 root 密码 | 仅限本地测试,生产禁用 |
| MYSQL_RANDOM_ROOT_PASSWORD | 随机生成 root 密码并打印到日志 | 需要从 docker logs 捞取 |
docker run -d --name mysql \
-e MYSQL_ROOT_PASSWORD=root_pass \
-e MYSQL_DATABASE=appdb \
-e MYSQL_USER=app -e MYSQL_PASSWORD=app_pass \
-v mysqldata:/var/lib/mysql \
-p 3306:3306 \
mysql:8.0 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
3.2 mysql_native_password 认证坑
MySQL 8.0 默认认证插件改为 caching_sha2_password。老客户端(旧版 JDBC、PHP 5.x、部分中间件)不支持该插件,连接时报 Authentication plugin 'caching_sha2_password' cannot be loaded。两种处理方式:
-- 方式一:把用户改为旧插件(兼容性优先)
ALTER USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY 'app_pass';
FLUSH PRIVILEGES;
# 方式二:服务端默认插件改为 native password
docker run -d --name mysql mysql:8.0 \
--default-authentication-plugin=mysql_native_password
注意:MySQL 8.4 起 mysql_native_password 被默认禁用,需要 --mysql-native-password=ON 显式开启。新项目应优先升级客户端而非回退插件。
3.3 字符集与时区
字符集必须在初始化时定好,事后改库级字符集无法修复已有数据的排序规则。建议一律 utf8mb4 + utf8mb4_unicode_ci(或 utf8mb4_0900_ai_ci)。时区则通过 TZ 环境变量与 --default-time-zone 双管齐下,否则容器默认 UTC,业务侧写入的时间会与预期差 8 小时:
services:
db:
image: mysql:8.0
environment:
TZ: Asia/Shanghai
LANG: C.UTF-8
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-time-zone=+08:00
LANG 影响排序与大小写比较的本地化行为,TZ 影响 NOW() 与日志时间戳,两者要同时设置保持一致。
4. Redis 持久化:AOF 与 RDB
4.1 appendonly 与 save 的取舍
Redis 容器默认的数据目录是 /data。它有两套持久化:RDB(定时快照)与 AOF(追加日志)。默认镜像的启动参数是纯 RDB,重启间隙会丢数据;生产建议开启 AOF 并配 everysec 刷盘。
docker run -d --name redis \
-v redisdata:/data \
-p 6379:6379 \
redis:7 redis-server \
--appendonly yes \
--appendfsync everysec \
--save 900 1 --save 300 10 \
--dir /data
| 模式 | 落盘时机 | 数据安全 | 恢复速度 | 文件体积 |
|---|---|---|---|---|
| RDB save 900 1 | 900 秒内至少 1 次写 | 最多丢 15 分钟 | 快 | 小 |
| AOF appendfsync everysec | 每秒 fsync | 最多丢 1 秒 | 慢(重放日志) | 大 |
| AOF appendfsync always | 每次写都 fsync | 基本不丢 | 最慢 | 大 |
| AOF + RDB 混合 | 两者并存 | 最多丢 1 秒 | 快(混合格式) | 中 |
生产推荐 appendonly yes + appendfsync everysec,并开启 aof-use-rdb-preamble yes(Redis 4.0 起默认)让 AOF 重写时使用 RDB 前言,兼顾恢复速度。
4.2 快照备份与恢复
Redis 备份不必停机。redis-cli --rdb 会触发一次 BGSAVE 并把 RDB 流式传输到本地,全程不阻塞主线程:
# 从运行中的容器导出 RDB 到宿主
docker exec redis redis-cli --rdb /tmp/dump.rdb
docker cp redis:/tmp/dump.rdb ./backup/redis-$(date +%F).rdb
# 或者直接从宿主管道拿到 stdout(无需容器内落盘)
docker exec redis redis-cli --rdb - > ./backup/redis.rdb
恢复时把 RDB 放回 /data/dump.rdb 并重启容器即可;AOF 模式下 Redis 会优先加载 AOF,需注意两者的一致性。
5. 数据卷选型:命名卷 vs bind mount
| 维度 | 命名卷 | bind mount |
|---|---|---|
| 管理方式 | Docker 统一管理,docker volume 系列命令 | 宿主目录,手工管理 |
| 可移植性 | 高,跨宿主需备份迁移 | 低,强依赖宿主路径 |
| 权限控制 | 由 Docker 处理,UID 映射简单 | 需手工 chown,易踩权限坑 |
| 备份 | docker run --rm -v vol:/data alpine tar | 直接对宿主目录操作 |
| 性能 | local 驱动接近裸盘 | 取决于宿主文件系统与挂载参数 |
| 适用场景 | 生产默认、Compose 编排 | 需要 NFS/云盘/特殊挂载参数 |
一句话:默认选命名卷,只有当你需要挂载 NFS、云盘或精确控制文件系统参数时才用 bind mount。
6. 备份与恢复实战
6.1 Postgres 逻辑与物理备份
# 逻辑备份:跨版本可用,体积小,恢复慢
docker exec -t pg pg_dump -U postgres -Fc appdb > appdb.dump
# 恢复
docker exec -i pg pg_restore -U postgres -d appdb --clean --if-exists < appdb.dump
# 物理备份:整集群二进制一致快照,恢复快,需同大版本
docker exec pg pg_basebackup -U replicator -D /tmp/base -Ft -z -P
docker cp pg:/tmp/base/base.tar.gz ./backup/
-Fc 自定义格式支持并行恢复与选择性还原;pg_basebackup 需要复制权限的连接账号,适合搭建从库或做整实例灾备。
6.2 MySQL 逻辑备份
# 单库导出(含建表与数据),注意容器内管道不要用 -t
docker exec mysql sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers appdb' \
> appdb.sql
# 恢复
docker exec -i mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" appdb' < appdb.sql
--single-transaction 利用 InnoDB 的 MVCC 快照实现一致性备份且不锁表,是 InnoDB 场景的标配。MyISAM 表则必须加 --lock-tables。
6.3 定时备份到对象存储
用一个 sidecar 容器跑 cron,把备份直接推到对象存储,避免宿主机成为单点:
#!/bin/sh
# backup.sh —— 由 cron 容器每天调用
set -e
STAMP=$(date +%Y%m%d-%H%M)
docker exec -t pg pg_dump -U postgres -Fc appdb \
| gzip > /backup/appdb-$STAMP.dump.gz
aws s3 cp /backup/appdb-$STAMP.dump.gz s3://my-db-backups/appdb/
find /backup -name 'appdb-*.dump.gz' -mtime +7 -delete
配合 docker-compose 中的 restart: unless-stopped 与健康检查,形成「备份、上传、清理」闭环。备份策略建议 3-2-1:三份副本、两种介质、一份异地。
7. 性能陷阱与调优
7.1 fsync、WAL 与 redo log
数据库的持久性最终由 fsync 保证。容器本身不改变 fsync 语义,但底层存储会:如果卷落在不支持真正 fsync 的网络文件系统或某些虚拟化磁盘上,数据库会「以为」落盘了。因此 不要为了性能把 synchronous_commit 或 innodb_flush_log_at_trx_commit 调到 0,除非明确接受崩溃后丢最近若干秒的数据。
# Postgres:适度降低 WAL 写入频率,但不牺牲事务持久性
docker run -d postgres:16 \
-c wal_compression=on -c checkpoint_timeout=15min \
-c max_wal_size=4GB -c synchronous_commit=on
# MySQL:默认 1 表示每次事务提交都刷 redo log(最安全)
# 2 表示每秒刷一次,崩溃可能丢 1 秒;0 性能最好但最不安全
innodb_flush_log_at_trx_commit = 1
7.2 连接池与 max_connections
每个应用进程直连数据库都会占用一个后端进程/线程,Postgres 尤其昂贵——每个连接是一个操作系统进程。容器化环境下应用实例数容易失控,max_connections 很快被打满。正确姿势是在应用与数据库之间放连接池(PgBouncer、ProxySQL),把 max_connections 控制在合理范围:
# Postgres 侧:给连接数设上限,把并发交给 PgBouncer
docker run -d postgres:16 -c max_connections=200 -c shared_buffers=1GB
; pgbouncer.ini 关键项:transaction 模式最省连接
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
transaction 模式下连接只在事务期间绑定,能显著提升连接复用率,但要注意它不支持会话级特性(如 SET、prepared statement 跨事务复用)。
8. 大版本升级与健康检查编排
8.1 升级路径:dump-restore 与 pg_upgrade
容器化数据库升级的核心原则:数据目录不能跨大版本直接复用。 Postgres 数据目录有版本号,16 的数据目录无法被 17 直接打开;MySQL 同样如此。
| 方式 | 停机时间 | 适用场景 | 风险 |
|---|---|---|---|
| dump-restore(逻辑) | 长,取决于数据量 | 跨大版本、跨平台 | 低,可校验 |
| pg_upgrade(物理) | 短 | 同机同架构大版本升级 | 中,需 --link 谨慎使用 |
| 主从切换 | 几乎为零 | 生产在线升级 | 需搭从库与切换流程 |
# 逻辑升级:旧容器导出,新容器导入
docker exec -t pg16 pg_dumpall -U postgres > all.sql
docker run -d --name pg17 -v pgdata17:/var/lib/postgresql/data postgres:17
docker exec -i pg17 psql -U postgres < all.sql
在线升级的稳妥做法是先用新版本容器搭一个从库,等复制追上后切换主从,再回滚旧版本容器。
8.2 健康检查与依赖启动顺序
应用容器常常在数据库还没就绪时就启动,导致连接失败。正确做法是给数据库定义健康检查,并让应用依赖它:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
volumes:
- pgdata:/var/lib/postgresql/data
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
depends_on 配合 condition: service_healthy 才能真正等到数据库可接受连接。注意健康检查本身要轻量,pg_isready 不会建立真实会话,是最合适的选择;用 psql -c 'select 1' 会产生额外连接,高频率下反而成为负担。
9. 总结
| 主题 | 关键结论 | 一句话记忆 |
|---|---|---|
| 数据目录与 VOLUME | 必须显式挂载,避免匿名卷孤儿 | 不显式挂载就等于没持久化 |
| 初始化脚本 | 仅在数据目录为空时执行一次 | 空目录才跑 initdb.d |
| 权限与 PGDATA | 属主要匹配镜像 UID,子目录避开挂载点吞并 | 999 属主与子目录 PGDATA |
| MySQL 认证 | 8.0 默认 caching_sha2,旧客户端需 native | 老客户端先查认证插件 |
| Redis 持久化 | AOF everysec 兼顾安全与性能 | 要少丢数据就开 AOF |
| 备份恢复 | 逻辑备份跨版本、物理备份快 | dump 通用,basebackup 快 |
| 性能陷阱 | 卷落裸盘、别关 fsync、连接交给池 | 卷要裸盘,连接要池 |
| 升级与编排 | 大版本不可复用数据目录,健康检查定序 | 升级走 dump,启动等 healthy |
数据库容器化不是把 docker run 换成 docker-compose 就结束,而是一整套围绕 数据生命周期 的工程实践。第一层是存储语义:数据目录必须落在卷或 bind mount 上,避开 overlay2 的写时复制,理解 VOLUME 声明带来的匿名卷陷阱。第二层是初始化与权限:Postgres 的 PGDATA 与 UID 999、MySQL 的认证插件与字符集、Redis 的 AOF 与 RDB,都必须在首次初始化时一次定好,事后补救成本极高。第三层是备份与恢复:逻辑备份通用、物理备份快,但都要真正演练过恢复才算数,并遵循 3-2-1 原则把副本推到对象存储。第四层是性能与演进:fsync 语义不能妥协,连接数交给连接池,大版本升级永远走 dump-restore 或主从切换,而不是直接复用数据目录。把这几层做扎实,容器里的数据库才真正具备生产可信度。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。