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

Docker Compose 堆栈如何备份并安全升级

备份 Docker Compose 不只是复制卷:本文列出 Compose 文件、.env、卷和数据库转储4项内容,并说明如何验证恢复后再升级。

Docker Compose 堆栈备份必须包含哪些内容

Docker Compose 堆栈备份必须包含4项独立内容。缺少其中任何一项,应用都无法恢复:Compose 文件、与其同目录的 .env、每个卷中的内容,以及使用数据库自身客户端生成的数据库转储。在数据库容器运行时直接复制数据库文件不算备份。升级时也使用同一份清单,但还要遵循一条规则:先创建备份,再执行 pull,因为架构迁移通常只支持向前执行,大多数项目都没有回滚方式。

以下内容均假设堆栈已经部署,并且 docker compose ps 显示其正在运行。示例使用 /srv/myapp 作为项目目录,服务名称为 appdb。请替换为您自己的名称。命令特意保持通用,因为关键部分——卷和数据库——无论应用是什么,操作方式都相同。

明确堆栈实际存储的内容

cd /srv/myapp
docker compose ps
docker compose config --volumes
docker volume ls --filter label=com.docker.compose.project=myapp

docker compose config --volumes会输出 compose 文件声明的命名卷短名称。docker volume ls会输出这些卷在磁盘上实际使用的名称。两个列表不同,因为 Compose 会在名称前加上项目名:文件中写作 db_data 的卷,实际存在为 myapp_db_data。项目名默认为目录名,因此重命名目录会让堆栈指向一组全新的空卷,同时留下包含原有数据的旧卷。下面的每条命令都需要使用 docker volume ls 中的实际名称。

绑定挂载不会出现在这两个列表中。在 compose 文件中,它们是冒号左侧包含主机路径的条目,例如 ./config:/app/config。它们是主机上的普通目录,因此可以使用普通工具访问。命名卷位于 /var/lib/docker/volumes/ 下,docker volume inspect --format '{{.Mountpoint}}' myapp_db_data 会输出其中一个卷的确切路径。堆栈使用哪种卷会影响复制方式;绑定挂载与命名卷将完整介绍两者之间的取舍。

现在将找到的内容分为两组。有些卷存储着无法重建的状态数据:上传的文件、生成的密钥、数据库本身,以及用户在应用中输入的任何内容。另一些卷存储派生数据,例如缩略图和搜索索引,应用可以自行重建。备份第二组数据会增加磁盘占用和恢复时间,但没有实际收益。Redis 缓存卷就是最明显的例子:丢失它只会让第一个请求变慢。

备份 compose 文件和 .env 文件

这两个文件都位于主机上彼此相邻的位置,且都不在任何卷中。.env中保存数据库密码、应用程序密钥和各种 API 令牌,因此它是将一组卷恢复为可运行应用程序的关键文件。它通常也会列在 .gitignore 中,这意味着“我的配置在 git 中”的方案排除了最重要的那个文件。在 env 文件中保存密钥是正确的做法,同时也意味着备份必须包含该文件。

sudo install -d -m 700 -o "$USER" -g "$(id -gn)" /srv/backups/myapp
cp -a compose.yaml .env /srv/backups/myapp/
chmod 600 /srv/backups/myapp/.env

复制堆栈使用的每个 compose 文件,而不只是第一个文件。使用 -f compose.yaml -f compose.prod.yaml 启动的堆栈需要恢复所有这些文件,才能保持原有行为;多个 compose 文件的合并方式决定哪些值最终会传递到容器中。

有一点需要同时考虑 .env 和卷。官方 Postgres 镜像只会在初始化空数据目录时读取 POSTGRES_PASSWORD。之后修改该值,不会改变数据库内部的密码。将上个月的卷与今天的 .env 放在一起恢复时,应用程序会因 FATAL: password authentication failed for user "appuser" 而无法连接,即使检查时两个文件看起来都正确。应将同一时刻的 .env 和卷保存在同一份备份中。

使用数据库自身的客户端导出数据库

数据库服务器会持续写入自身文件。服务器运行期间执行的 tar /var/lib/postgresql/data 可能会复制写入前的部分页面和写入后的部分页面,因此归档文件包含不同时刻的数据,可能无法用于恢复。导出工具会在单个事务中读取数据,因此文件代表同一个一致时刻。备份与复制的区别就在于此。

docker compose exec -T db sh -c \
  'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  > /srv/backups/myapp/db-$(date +%F).dump

请保留 -T。它会关闭 TTY 分配。如果连接了 TTY,Docker 会在输出流返回 shell 的过程中对其进行转换,从而损坏二进制导出文件。直到恢复失败时,您才会发现这一点。单引号同样重要:它会阻止主机 shell 展开 $POSTGRES_USER,改为由容器内的 shell 使用 compose 文件已在容器中设置的值进行展开。-Fc 会写入自定义格式;该格式会边导出边压缩,之后可使用 pg_restore 从中选择对象。

角色及其密码不属于任何单个数据库,因此也要导出:

docker compose exec -T db sh -c 'pg_dumpall -U "$POSTGRES_USER" --globals-only' \
  > /srv/backups/myapp/globals.sql

然后确认该文件确实是导出文件,而不是错误消息:

ls -lh /srv/backups/myapp/
head -c 5 /srv/backups/myapp/db-$(date +%F).dump

自定义格式导出文件以 5 个字节 PGDMP 开头。0 字节文件,或以 pg_dump: 开头的文件,表示命令失败。shell 会在命令运行前创建输出文件,因此导出失败后仍会留下一个文件,其名称和时间戳看起来都合理。这是最常见的静默备份失败原因。

对于 MariaDB 或 MySQL,客户端会改变,但操作形式不变:

docker compose exec -T db sh -c \
  'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --databases "$MARIADB_DATABASE"' \
  > /srv/backups/myapp/db-$(date +%F).sql

--single-transaction 会一致地导出 InnoDB 表,且不会阻塞写入操作。在 MySQL 镜像中,命令是 mysqldump,变量是 MYSQL_ROOT_PASSWORDMYSQL_DATABASE。在当前的 MariaDB 镜像中,mysqldump 仍可作为 mariadb-dump 的兼容名称使用。请注意,在导出运行期间,通过命令行提供的密码会一直显示在容器的进程列表中。

SQLite 需要单独处理。数据库虽然是一个文件,但最近的事务可能仍位于旁边单独的 -wal 文件中,因此只复制 .db 会得到一个缺少最新写入内容的数据库。如果镜像包含客户端,sqlite3 /data/app.db ".backup '/data/app-backup.db'" 会在应用运行期间写出一致副本。如果不包含客户端,请停止容器,并将 .db 文件与其 -wal-shm 伴随文件一起复制。

如果数据库运行在主机上,而不是 stack 内部,则无需使用 docker compose exec 前缀,其他命令相同。下次重建前,建议阅读在 Docker 或主机上运行数据库

捕获卷

命名卷没有可供您手动编辑的主机路径,因此请将其挂载到临时容器中,再从容器内创建归档。

docker run --rm \
  -v myapp_uploads:/data:ro \
  -v /srv/backups/myapp:/backup \
  alpine:3 tar czf /backup/uploads.tar.gz -C /data .

辅助容器以只读方式将卷挂载到 /data,并将备份目录挂载到 /backup,然后将归档写入主机端。--rm 会在 tar 退出后立即删除辅助容器。:ro 很重要,因为即使命令中的 tar 写错,也不会损坏源数据。-C /data . 决定还原后文件是否位于正确位置:它会将所有路径按卷根目录存储为相对路径。如果改用 tar czf /backup/uploads.tar.gz /data,每条路径都会带有开头的 data/,因此还原时会在卷内创建 /data/data,应用看到的将是空目录。归档文件归 root 所有,因为 tar 在容器内以 root 身份运行。如果这影响您的操作,请运行 sudo chown "$USER" /srv/backups/myapp/uploads.tar.gz;如果还原后的文件对应用不可读,请阅读PUID 和 PGID 如何决定文件所有权

每个命名卷运行一次即可。绑定挂载完全不需要容器:tar czf /srv/backups/myapp/config.tar.gz -C /srv/myapp/config . 可在主机上完成相同操作。

请针对每个卷决定是否必须停止应用。对正在原地改写卷内容的应用执行实时 tar,可能会捕获写入到一半的文件。对于上传目录,文件通常只写入一次,之后只读,因此风险较小。对于其他目录,请使用 docker compose stop app 停止相关服务,完成复制后再运行 docker compose start appstop 会保留容器和卷,这正是此处所需的行为;在输入其中任何一个命令前,请先确认down 与 stop 的区别

不要将数据库卷的 tar 归档当作数据库备份。数据库导出才是备份。停止数据库后创建的卷归档,可用于快速重建,但用途仅限于此。

操作顺序

  1. 将 compose 文件和 .env 复制到备份目录。
  2. 在数据库仍运行时导出数据库。
  3. 如果应用容器的卷会在原位置发生变化,则停止应用容器。
  4. 分别归档每个命名卷和每个 bind mount 目录。
  5. 启动已停止的服务,然后使用 docker compose ps 确认。
  6. 记录该堆栈正在运行的镜像标签和摘要。
  7. 将整个备份目录复制到此服务器之外。

第 7 步是人们最容易推迟的步骤。

将副本传输到服务器外部

与服务栈位于同一磁盘上的备份,只能防止人为误操作,无法防范其他故障。卷损坏、服务器被删除或账户丢失,都会同时影响两份副本。应按计划将目录推送到不属于此 VPS 的存储位置,并设置保留策略。VPS 备份 restic介绍了仓库设置、保留参数和检查命令,因此这里不再重复。

restic 也可以直接从管道读取转储内容,从而完全避免将明文数据库写入磁盘:

docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  | restic backup --stdin --stdin-filename db.dump

无论使用哪种工具,都应将计划任务配置为 systemd timer 或 cron 作业,并确保作业失败时将报告发送到您能看到的位置。没有任何输出的备份脚本,可能停止工作六个月而无人发现。

通过恢复演练验证备份可用

从未执行过恢复的备份只是一个假设。下面的演练会将备份恢复到与原堆栈并行运行的第二个堆栈中,因此生产环境可以继续提供服务,您输入的任何内容都不会触及生产环境。

其机制是项目名称。Compose 从目录名称获取项目名称,并将其写入创建的每个容器和卷。将备份复制到新目录后,恢复的堆栈会自动获得独立的卷。

sudo install -d -m 700 -o "$USER" -g "$(id -gn)" /srv/myapp-restore
cd /srv/myapp-restore
cp /srv/backups/myapp/compose.yaml /srv/backups/myapp/.env .

编辑复制的 compose 文件,确保发布的主机端口不会与运行中的堆栈冲突:将 8080:8080 替换为 18080:8080,或者修改复制的 .env 中设置该端口的变量。然后创建容器及其空卷,但不要启动任何服务:

docker compose create
docker volume ls --filter label=com.docker.compose.project=myapp-restore

第二条命令应列出与生产环境相同的卷名,并在前面加上 myapp-restore_。填充这些卷,单独启动数据库,然后导入转储:

docker run --rm -v myapp-restore_uploads:/data -v /srv/backups/myapp:/backup \
  alpine:3 tar xzf /backup/uploads.tar.gz -C /data
docker compose up -d db
docker compose exec -T db sh -c \
  'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists' \
  < /srv/backups/myapp/db-2026-08-16.dump

--clean --if-exists 会在重新创建每个对象前先删除它,因此恢复过程可以重复执行。如果不使用该选项,第二次恢复到已经包含这些表的数据库时会因 pg_restore: error: could not execute query: ERROR: relation "users" already exists 而停止。

然后启动其余服务,并按照用户的方式进行检查:

docker compose up -d --wait
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'
docker compose logs --tail=50

docker compose up -d --wait 会一直等待,直到每个服务都报告为 running 或 healthy;如果某个服务始终未达到这些状态,它会以非零状态退出,因此此步骤可以编写脚本自动执行。当某个服务始终无法变为 healthy 时,docker compose ps 会显示其状态;Compose 健康检查会解释该列显示的内容。然后通过备用端口打开应用,并使用真实账户登录。写入一条记录,并打开一个存储在卷中的文件。这两项共同构成验证依据:转储已恢复,卷已恢复,而且二者内容一致。只验证登录页面能够显示,无法证明任何数据已恢复。

演练通过后将其拆除:

docker compose down -v

这里使用 -v 标志才是正确做法。在生产目录中执行同一命令,会删除您要保护的卷。

如何升级 Compose 堆栈

阅读当前版本与目标版本之间每个版本的发行说明,并搜索 breakingmigration。不支持跨越多个主版本直接升级的项目会在此处说明。迁移程序如果拒绝运行,通常要等它已经修改了部分架构后才会报告。

在修改任何内容前,记录当前运行的版本:

docker compose images
docker image inspect --format '{{index .RepoDigests 0}}' postgres:16.4

docker compose images会列出每个服务当前使用的镜像和标签。摘要是唯一能精确标识镜像的值,因为标签随时可能被移动到其他镜像。

使用前面章节中的备份,并将其复制到当前主机之外。即使是补丁版本升级,也要这样做。真正成本低的升级,是那些人们没有停止准备的升级。

然后在 Compose 文件中固定版本,因为 latest 不是版本:

services:
  db:
    image: postgres:16.4

使用 image: postgres:latest 时,docker compose pull 会获取该标签今天所指向的任意内容。这样就无法准确说明昨天运行的版本。固定标签可以将升级变成一行可读的编辑,你可以在 git diff 中查看,也可以再修改一行将其还原。应用镜像也要采用相同方式固定,并从项目发行页面获取确切版本。

拉取镜像并重新创建服务:

docker compose pull
docker compose up -d --wait

docker compose up -d 会将文件与正在运行的容器进行比较,只重新创建镜像或配置已更改的服务。它不会修改命名卷,因此新容器会使用现有数据启动。这正是操作的目的,也是风险所在,因为新版本通常会在首次启动时运行架构迁移。

观察升级过程:

docker compose ps
docker compose logs -f --tail=100 app

失败的容器会在 docker compose psSTATUS 列中显示 Exited (1),原因位于其日志的最后几行。迁移错误会在那里明确显示,在其他位置则通常不可见。日志稳定后,登录应用并使用一分钟。

如果 docker compose pullno space left on device 停止,通常是旧镜像层导致的。使用 清理未使用的 Docker 镜像即可释放空间。确认升级运行正常后再执行清理,不要提前清理,因为快速回滚需要使用这些旧镜像层。

升级出错时如何回滚

这里有两种情况,处理成本差异很大。如果新版本没有修改数据库模式,回滚只需一行命令:将旧标签改回 compose 文件,然后运行 docker compose up -d。容器会被替换,卷仍保留在原位置,旧代码可以读取它写入的数据。

如果新版本迁移了数据库模式,旧代码将无法再读取这些数据。迁移通常只支持向前执行,大多数项目根本不提供降级脚本。因此,旧版本启动后会在首次查询已重命名或删除的列时失败,并出现类似 ERROR: column "avatar_url" does not exist 的错误。回滚依赖拉取新版本前创建的转储:将旧标签改回,移除数据库卷,重新创建空卷,将转储恢复到其中,然后启动服务。如果没有该转储,就完全无法回滚。这正是必须在拉取新版本前创建备份的原因。

Postgres 主版本升级是最容易出问题的情况。它之所以容易让人误判,是因为错误发生在升级时,而不是回滚时。磁盘上的数据格式会随每个主版本变化。将 postgres:16.4 改为 postgres:17.2,运行 docker compose up -d,新服务器将拒绝启动:

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.2.

镜像不会自动为您运行 pg_upgrade。在 Compose 堆栈中,受支持的流程是转储、替换、恢复:在旧版本仍运行时创建转储,执行 docker compose down,移除数据库卷,设置新标签,执行 docker compose create 创建全新的空数据目录,启动数据库,恢复转储,然后启动其余服务。新主版本经过 1 天的实际流量验证前,请保留旧转储。同一主版本内的次版本升级(例如从 16.4 升级到 16.9)不需要这些步骤,因为数据格式在这些版本之间保持稳定,容器可以直接启动。

VPS 快照是备份吗?

快照是备份的补充,两者的失效方式不同。快照在 hypervisor 层复制整块磁盘,因此可以在几分钟内恢复整台机器,包括您忘记备份的部分。因此,快照适合一个特定场景:升级导致服务器故障,您希望将其恢复到二十分钟前的状态。

除此之外,快照并不是理想工具。其粒度是整台机器,因此恢复一张被删除的表时,您需要先在某处恢复整台服务器,再从中提取该表。保留时间通常较短。快照副本通常与服务器位于同一个服务商账户中,因此账户丢失时,服务器和快照也会同时丢失。此外,运行中的机器创建的快照可能捕获数据库正在写入的状态,因此数据库首次启动时会执行崩溃恢复,仍在进行中的事务也会丢失。

两者都应使用。快照是升级窗口中的撤销按钮。转储则是即使账户被删除也能保留的副本。快照与备份的区别介绍了两者分别能够应对哪些实际故障。同一个备份目录也能让将服务栈迁移到新的 VPS变成例行操作,而不是凭记忆重新构建。

出现的问题及其现象

执行 down 时使用 volumes 标志。 docker compose down -v 会删除文件声明的命名卷,Compose 会输出一行 Volume myapp_db_data Removed 进行确认。此操作无法撤销。普通的 docker compose down 不会删除这些卷。请使用完整形式 docker compose down --volumes,这样必须明确输入这个破坏性标志。

转储文件没有魔数。 pg_restore: error: did not find magic string in file header 表示该文件不是归档文件。通常的原因是在 docker compose exec 上缺少 -T,因为连接 TTY 后,数据流在传递到 shell 的过程中会被转换,导致二进制转储损坏。使用 -T 重新生成转储,然后使用 head -c 5 检查前 5 个字节。

密码无法更改。 还原后出现 FATAL: password authentication failed for user "appuser",表示 .env 与数据目录来自不同时间点。镜像只会在创建空数据目录时设置该密码,因此之后编辑 .env 不会改变数据库中的密码。请还原匹配的 .env,或使用 ALTER USER 在数据库内部更改密码。

出现第二个空卷。 Docker 会按需创建卷,因此在 s 缺失时使用 docker run -v myapp_upload:/data,会写入一个全新的空卷,并报告操作成功。随后 docker volume ls 会显示两个名称,其中一个不包含任何数据。请从 docker volume ls 复制卷名称,不要凭记忆输入。

还原目标指向生产环境。/srv/myapp 中而不是 /srv/myapp-restore 中运行还原命令,会使用备份覆盖正在运行的数据,而且两处的命令看起来完全相同。每次运行还原命令前都检查 pwd,并将演练放在独立目录中。

FAQ

docker compose down 会删除我的数据吗?

不会。docker compose down 会删除容器和默认网络,但不会删除命名卷和绑定挂载。docker compose down -v 会删除文件中声明的命名卷,而且此操作不可逆。绑定挂载对应主机目录,因此 Compose 不会删除它们。如果您希望在备份期间停止服务,同时保留其他内容不变,请改用 docker compose stop

可以直接复制 Postgres 数据目录,而不运行 pg_dump 吗?

可以,但必须先停止容器。服务器运行时,文件会持续变化,复制结果可能包含不同时间点的数据,无法可靠恢复。文件级复制还绑定到某个 Postgres 主版本,因此无法在其他主版本下启动。停止容器,归档卷,重新启动容器,并将此结果视为快速重建路径,而不是唯一的备份方式。转储文件才是可移植的副本,也是您应当用于恢复的副本。

如何在 Compose 中将 Postgres 升级到新的主版本?

仅修改标签不够。新服务器无法使用旧数据目录启动,并会记录 The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.2。在旧版本仍运行时执行 pg_dump,然后执行 docker compose down;删除数据库卷,设置新标签,执行 docker compose create 创建全新的空卷,启动数据库,再将转储恢复到其中。在新版本处理过真实流量之前,请保留旧转储。

备份应多久运行一次,应保留多长时间?

备份间隔应根据您愿意重新执行的工作量确定。个人或小团队的服务栈可以每天备份一次,并在任何升级前立即额外手动备份一次。保留策略应覆盖您未能及时发现的损坏,因为周五发现的损坏表无法通过周四晚上的副本修复。restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune 是一个合理的起始策略。无论计划如何,都应每季度执行一次恢复测试。在完成测试之前,您拥有的只是文件,而不是备份。

备份时必须停止整个服务栈吗?

通常不需要。数据库转储在服务器运行时仍可保持一致,因此数据库不需要停机。真正需要考虑的是卷。如果应用只新增文件,例如 uploads 目录,在线归档通常足够安全。如果应用会原地改写文件,请使用 docker compose stop app 停止该服务,直到复制完成,然后重新启动。数据库继续运行,同时只停止应用,通常是您能够安排的最短安全窗口。