SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

数据库应该运行在 Docker 容器中还是宿主机上?

在生产环境运行 PostgreSQL、MySQL 或 Redis 等数据库时,容器化是标准做法。本文分析了数据卷挂载、版本升级、内存限制及备份策略等核心运维难点,帮助你规避容器化数据库的数据丢失风险,确保生产环境的稳定性与高性能。

数据库应运行在 Docker 中还是宿主机上?

请将数据库运行在 Docker 中。对于部署在单台 VPS 上的应用栈,使用容器化的 PostgreSQL、MySQL、MongoDB 或 Redis 是生产环境的常规选择,关于此做法的争议通常偏离了重点。容器本质上是受命名空间(namespaces)和控制组(cgroups)限制的 Linux 进程,而非虚拟机,因此数据库与磁盘之间不存在虚拟化管理程序(hypervisor)。通过绑定挂载(bind mount)或本地命名卷(named volume),读写操作直接作用于宿主机文件系统,这与通过软件包管理器安装的效果一致。

真正的成本在于运维。以下四个因素决定了这种架构是稳健的方案还是缓慢的灾难:数据存储位置、目录所有权、大版本升级流程,以及是否进行过备份恢复测试。只要处理好这些环节,容器化方式本身只是一个技术细节。如果处理不当,容器反而会成为你归咎的对象。

对于服务器上的所有数据库,决策逻辑均相同。以下示例以 PostgreSQL、MySQL、MongoDB 和 Redis 为例,并在必要处说明了各产品的具体差异。

容器实际改变的内容

只要挂载了存储路径,存储路径本身就不会改变。内核、页缓存和文件系统均保持一致。

有一个真正的性能陷阱:如果不挂载任何卷。没有卷时,数据目录会进入容器的可写层,这是一个叠加在镜像之上的 overlay 文件系统。该层的写入速度较慢,且容器删除时整个层也会被删除。这就是“为什么我的数据库今天早上变空了”的原因。

真正改变的内容:

  • 生命周期。docker compose down 会销毁容器。任何未存储在卷中的数据都会随之消失。
  • 版本。镜像标签即版本。数据库容器内部不存在能存活到下一次 docker compose pullapt upgrade
  • 内存记账。cgroup 限制是由内核强制执行的硬性边界,数据库本身无法感知该限制。
  • 用户。进程以容器内的数字用户 ID 运行,该 ID 在宿主机上可能不拥有任何权限。

数据存储位置决定一切

有两个推荐方案和一个常见错误。

  • 命名卷:pgdata:/var/lib/postgresql/data。Docker 会在 /var/lib/docker/volumes/<project>_pgdata/_data 创建目录,镜像的入口点会在首次运行时设置所有权。这是默认推荐方案。
  • 绑定挂载:/srv/appname/pg:/var/lib/postgresql/data。路径由您指定,因此权限问题需由您自行处理。
  • 不挂载。参考上述内容。数据将保留在容器内。

完整的权衡分析是一个独立的话题,绑定挂载与命名卷的对比 中有详细介绍。对于数据库,简而言之:除非有明确理由需要指定宿主机路径,否则请使用命名卷;如果必须使用绑定挂载,请将其放置在 /srv/appname/pg 等稳定位置,而不是放置在 git clean 可以访问的项目目录内。

一个硬性限制:不要将数据库数据目录放在 NFS(网络文件系统)或任何未经测试其锁定和 fsync 行为的网络挂载上。数据库假设成功的 fsync 意味着字节已写入稳定存储。如果该假设不成立,会导致数周后才显现的数据损坏。

在卷丢失前固定卷名称

Compose 将卷命名为 <project>_<volume>,且项目名称默认为目录名。因此,卷的标识依赖于目录名,而人们经常会随意更改目录名。

/srv/app 移动到 /srv/app-old,或者重命名 compose 文件中的 pgdata 键,下一次执行 docker compose up -d 时就会创建一个全新的空卷。Postgres 会在其中初始化一个全新的集群。容器状态正常,应用启动,但所有数据表都消失了。好消息是,旧卷仍然以旧名称存在于磁盘上。

docker volume ls
docker volume inspect app_pgdata

固定名称以防止此类情况发生。请显式设置项目名称和卷名称:

name: myapp

services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
    name: myapp_pgdata

如果现有的孤立卷中已包含数据,请在停止数据库后将其复制过去:

docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start db

如果在数据库运行时进行复制,会导致正在写入的文件出现损坏。请务必先停止数据库。

谁拥有数据目录

官方的 Postgres、MySQL 和 MongoDB 镜像均以非特权用户 ID(通常为 999)运行服务。当容器以 root 身份启动时,入口点脚本会将数据目录的所有权更改为该用户,随后放弃特权。这就是为什么空的绑定挂载(bind mount)通常能直接成功的原因。

一旦你在 compose 文件中设置了 user:,此过程就会失败,因为此时入口点脚本已失去修复权限的特权。Postgres 会直接报错:

initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permitted

如果数据目录存在但权限模式错误,会显示另一条消息。识别此错误很重要,因为修复方法是 chmod,而不是 chown

FATAL:  data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL:  Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).

MongoDB 在 root 拥有的绑定挂载上会因锁文件问题而失败:

Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.

修复方法是将宿主机目录的属主更改为对应的数字 ID,而非用户名:

sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pg

ls -ldn 会显示数字而非名称,它应该显示 999 999。宿主机上名为 postgres 的账户与镜像内名为 postgres 的账户并无关联:内核仅比较数字,而名称在两侧是分别查找的。PUID 和 PGID 如何将宿主机用户映射到容器 一节详细介绍了该映射过程。在无根(rootless)Docker 或用户命名空间重映射环境下,这些数字会再次发生偏移,因此请从正在运行的容器中读取 ID,不要假设其固定为 999。

使用命名卷(named volumes)可以避免本节所述的所有问题,因为 Docker 会在首次运行时创建空目录,且入口点脚本会自动获取其所有权。

升级:针对镜像标签变更的软件包升级

在宿主机上,apt upgrade 可将您升级到次要版本。发行版不会在您不知情的情况下自动跳跃数据库的主版本;当您选择升级时,两套二进制文件可以同时安装,这正是 pg_upgrade 所需的。

在容器中,标签即版本,因此升级只需修改一行配置。这使得次要版本升级变得简单,而主版本升级则成为一个标准流程。

postgres:16 修改为 postgres:17,运行 docker compose up -d,容器会立即退出:

PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.

数据不会损坏。新版本的二进制文件无法读取旧版本的磁盘目录布局,因为该布局在主版本之间会发生变化。将标签改回 postgres:16,服务即可重新启动。这种回滚能力是容器在升级方面提供的唯一真正优势。

受支持的升级路径是导出(dump)并还原(restore)。PostgreSQL 建议使用较新版本的客户端进行导出,因此请在 compose 网络中,从新镜像运行导出命令,连接到仍在运行的旧服务器:

docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
  postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sql

导出的文件至少应有几十 KB,并以 PostgreSQL database cluster dump complete 结尾。如果文件只有几百字节,说明导出失败,此时删除卷将导致数据丢失。请务必在确认文件完整后执行:

docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sql

其他引擎的情况有所不同:

  • MySQL 8 会在启动时自动升级其数据字典,因此次要标签的变更通常只需重启即可。在跨发布系列升级前请阅读发行说明,无论如何都应先进行备份导出。
  • MariaDB 要求在服务器升级到新版本启动后运行 mariadb-upgrade
  • MongoDB 必须逐个主版本进行升级,每完成一步后,在继续下一步之前需设置功能兼容性版本。跳过版本会导致 mongod 拒绝启动,并在日志中记录 UPGRADE PROBLEM,其中会指明 featureCompatibilityVersion。从 MongoDB 7.0 开始,该命令需要显式的确认标志:db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true })
  • Redis 可以顺利加载旧版本的快照文件,但无法加载新版本的快照,因此升级仅需重启,而降级则可能导致数据加载失败。

通用规则:容器使降级变得容易,但并不会简化升级过程。

为什么我的数据库容器会以 137 状态码退出?

这是因为内核的内存溢出(OOM)杀手终止了该进程。137 等于 128 加上信号 9。

docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20

docker compose ps 显示 Exited (137),检查行显示 "OOMKilled": true,且内核日志中包含匹配条目:

Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kB

其机制往往出人意料。PostgreSQL 和 MySQL 会根据宿主机报告的总内存来设定缓冲区大小。cgroup 限制不会改变它们获取的数值。在 16 GB 内存的宿主机上设置 2 GB 限制时,数据库仍会按 16 GB 规划内存,导致其在宿主机尚未承压时就被 cgroup 杀掉。因此,仅设置内存限制是不够的。你必须明确告知数据库其实际可用内存:

  • PostgreSQL:设置 shared_buffers,并注意 work_memwork_mem 是按每个连接的每次排序操作分配的,因此一个较大的数值乘以 50 个连接,通常就是导致容器在负载下而非启动时崩溃的原因。
  • MySQL 和 MariaDB:设置 innodb_buffer_pool_size,其默认值为 128M。在容器中应关闭 innodb_dedicated_server,因为该参数的作用正是根据检测到的机器内存来自动调整大小。
  • MongoDB:明确设置 WiredTiger 缓存大小,不要让它根据宿主机内存进行猜测。
  • Redis:maxmemory 默认为无限制,因此 Redis 会持续增长直到被 cgroup 终止。请将 maxmemory 设置在容器限制之下,并选择一个合适的 maxmemory-policy

Postgres 也会从自身侧报告该事件,你会在日志中发现以下成对出现的行:

LOG:  server process (PID 123) was terminated by signal 9: Killed
LOG:  terminating any other active sessions due to crash of another server process

一个后端进程被杀会导致所有其他后端进程重启,因为共享内存可能已变得不一致。这对你的应用程序而言是一场连接风暴,而非静默事件。在 Docker Compose 中设置内存限制 涵盖了相关语法以及 mem_limitdeploy.resources 形式之间的区别。

这些问题并不会在宿主机上消失,只是发生了转移。如果没有 cgroup 限制,数据库会与宿主机上的其他进程竞争资源,宿主机 OOM 杀手会根据评分选择受害者,这可能导致 sshd 被杀。相比于导致你无法登录的宿主机 OOM,一个能预测并终止数据库的限制更易于管理。

备份:内部转储,外部备份

不要通过复制数据目录来备份正在运行的数据库。在服务器写入数据时进行文件级复制会导致数据损坏(torn copy),且只有在恢复时才会发现。

有两种可靠的方法:在数据库运行时使用其自带工具进行转储并备份转储文件,或者停止容器后对卷进行冷备份。

docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVE

-T 至关重要。若缺少该参数,docker compose exec 可能会将终端附加到命令上,而终端层会在输出流中添加回车符。这会导致文本转储在恢复时出现异常错误,二进制转储则直接损坏。这种错误在备份时静默发生,却会在一个月后导致严重后果。

--single-transaction 可为 mysqldump 提供 InnoDB 表的一致性快照,且无需锁定整个服务器。

上述命令每次仅生成一个文件。它们并非备份系统:不具备保留策略、异地副本或验证机制。请将转储目录交给具备上述功能的工具处理,例如 使用 restic 从 VPS 备份。备份 /srv/backups,而不是 /var/lib/docker/volumes

随后执行恢复操作,因为从未验证过的备份等于没有备份:

docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'

\dt 应列出应用程序的表。如果结果为空或出现 Did not find any relations.,说明转储文件并非你预期的内容。操作完成后,请删除 restore_test

删除所有内容的命令

docker compose down -v

直接运行 down 会移除容器和网络。而 -v 还会移除 compose 文件中声明的所有命名卷,以及挂载到这些容器上的所有匿名卷。此操作不会提示确认,也无法撤销。这是自托管数据库被销毁的最常见方式,通常发生在排查无关问题时,因为论坛建议运行该命令。

以下四点可以减小影响范围:

  • 将数据库卷声明为 external: true。Compose 不会移除其不拥有的卷,因此 -v 无法触及该卷。请使用 docker volume create myapp_pgdata 创建一次即可。
  • 日常重启请使用 docker compose stopdocker compose startCompose 中 down 与 stop 的区别 详细说明了各自的移除范围。
  • 将数据转储文件保存在 Compose 管理的卷之外的主机路径中。
  • 切勿将故障排查建议中提到的 -v 直接粘贴到包含重要数据的栈中。

不要公开数据库端口

以下配置会将数据库暴露在公网:

    ports:
      - "5432:5432"

它会绑定到所有网络接口。Docker 通过在防火墙的 input 规则生效前重写数据包目标地址来发布端口,而 ufw 的规则位于 input 链中,因此 ufw deny 5432 不起任何作用。为什么 Docker 发布端口会绕过 ufw 展示了数据包的链遍历过程。

同一 compose 项目中的应用可以通过服务名称在 compose 网络内访问数据库,因此无需发布端口。请删除该配置块。如果需要从宿主机访问,请仅绑定到回环地址:

    ports:
      - "127.0.0.1:5432:5432"

检查实际的监听状态:

sudo ss -ltnp | grep 5432

127.0.0.1:5432 是预期的结果。0.0.0.0:5432 意味着任何人都可以尝试破解你的密码。

在哪里运行什么

在一台 VPS 上运行一个应用。 使用容器。配置一个名称固定的命名卷,不映射端口,设置内存限制并匹配数据库配置,通过每晚备份到宿主机路径供 restic 收集。从 VPS 上干净的 Docker 安装 开始,并将整个栈保存在一个可提交的 compose 文件中。这样做的好处显而易见:数据库版本在 git 中成为可审查的一行代码。

在一台宿主机上运行多个服务。 使用容器,每个应用对应一个数据库,而不是所有应用共享一个数据库服务器。共享服务器会将所有应用的升级周期耦合在一起,一旦出现失控查询,会导致所有应用中断。为每个容器设置独立的内存限制,以便将异常查询限制在产生该查询的应用内。多个小型 Postgres 实例会多占用一点磁盘空间,但能显著降低协调成本。

数据库是核心产品。 使用供应商提供的软件包仓库在宿主机上运行,或者购买托管服务。pg_upgrade 需要同时安装两个主要版本的二进制文件,软件包管理可以实现这一点,而单版本镜像则无法做到。当数据库独占机器和磁盘时,复制和基于 WAL(预写日志)归档的即时恢复会更容易实现。对于那些会在凌晨 03:00 触发报警的系统,请选择最稳妥的方案。

应用规模较小。 考虑完全不使用服务器数据库。对于单台 VPS 上的单写 Web 应用,通常更适合使用 生产环境中的 SQLite,此时备份只需处理一个文件,升级路径也仅涉及库版本更新。

FAQ

在 Docker 中运行生产环境数据库安全吗?

对于单服务器应用栈,是安全的。容器本质上是带有命名空间和 cgroups 隔离的 Linux 进程。通过挂载卷,数据库写入的是宿主机文件系统,这与直接安装软件包并无区别。风险主要在于运维层面而非性能:未固定的卷名、属主错误的绑定挂载、从未测试过的恢复流程,以及 docker compose down -v。解决这四个问题,容器运行即无虞。当数据库成为主要负载,且需要 pg_upgrade、复制或时间点恢复功能时,请迁移至宿主机安装。

数据库数据应使用绑定挂载还是命名卷?

除非有明确理由需要指定宿主机路径,否则请使用命名卷。Docker 会自动创建目录,且镜像入口点会在首次启动时设置权限,从而避免权限问题。请使用明确的 name: 锁定卷,或将其标记为 external: true,否则重命名项目目录会导致系统静默创建一个全新的空卷,进而导致数据库变为空白。若使用绑定挂载,必须将宿主机目录的属主更改为镜像运行所用的数字用户 ID(官方 Postgres、MySQL 和 MongoDB 镜像均为 999)。请使用 ls -ldn 进行验证,因为 ls -l 显示的是宿主机上该数字对应的名称,而该名称在容器内部没有意义。

docker compose down -v 会删除什么?

它会像普通的 down 一样移除容器和网络。而 -v 还会额外删除 compose 文件中声明的所有命名卷,以及挂载到这些容器上的所有匿名卷。这包括数据库数据。该操作没有确认提示,也无法恢复。标记为 external: true 的卷不会被删除,这也是将数据库卷标记为外部卷的主要原因。日常重启请使用 docker compose stopdocker compose start

如何在 Docker 中将 PostgreSQL 升级到新的大版本?

执行导出和导入。将 postgres:16 更改为 postgres:17 并重启,会因为新二进制文件无法读取旧目录布局而导致 FATAL: database files are incompatible with server,并出现 DETAIL 错误行,其中会列出两个版本号。数据不会损坏:改回旧标签即可正常启动。使用新版本的客户端对运行中的旧容器执行 pg_dumpall,确认文件以 PostgreSQL database cluster dump complete 结尾,然后在空卷上启动新标签的容器并导入备份。同一大版本内的次要升级只需拉取镜像并重启即可。

为什么我的数据库容器以 137 退出码停止?

137 等于 128 加上信号 9,意味着进程被强制终止。运行 docker inspect <container> | grep -i oomkilled;如果值为 true,说明容器触及了 cgroup 内存限制。常见原因是 PostgreSQL 和 MySQL 会读取宿主机的总内存,而无法识别容器的限制,导致它们按 16 GB 规划内存,却运行在 2 GB 的环境中。请设置 shared_bufferswork_mem,或 innodb_buffer_pool_size,以匹配分配给容器的限制。检查 journalctl -k 中的 Memory cgroup out of memory 行,确认内核终止了哪个进程。