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

Docker Compose stack 備份與升級完整指南

了解 Docker Compose stack 備份必須保存的 4 項內容:compose 檔案、.env、必要 volume 與 database dump,並學會驗證還原及安全升級。

Docker Compose stack 備份必須包含的內容

Docker Compose stack 備份必須包含 4 項獨立內容。任何一項遺失,都會導致應用程式無法還原:compose 檔案、同一目錄中的 .env、每個 volume 的內容,以及使用資料庫自身 client 匯出的 database dump。在 container 執行期間直接複製資料庫檔案,不算備份。升級時使用相同的清單,另須遵守一項規則:在 pull 前先完成備份,因為 schema migration 的設計是向前套用,而且大多數專案沒有提供回復方式。

以下內容均假設 stack 已經部署完成,且 docker compose ps 顯示服務正在執行。範例使用位於 /srv/myapp 的 project directory,服務名稱為 appdb。請替換成自己的名稱。命令刻意保持通用,因為真正重要的部分,也就是 volume 和 database,不論應用程式為何,運作方式都相同。

確認堆疊實際儲存的內容

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 檔案宣告之具名 volume 的簡稱。docker volume ls 會列出這些 volume 在磁碟上實際使用的名稱。兩份清單會有所不同,因為 Compose 會在名稱前加上 project name:檔案中的 db_data,實際存在時會是 myapp_db_data。project name 預設為目錄名稱,因此重新命名目錄會讓堆疊改用一組全新的空 volume,而舊 volume 仍會保留其中的資料。以下每個指令都需要使用 docker volume ls 中的實際名稱。

Bind mount 不會出現在上述任一清單中。在 compose 檔案中,冒號左側是主機路徑的項目就是 bind mount,例如 ./config:/app/config。這些是主機上的一般目錄,因此可使用一般工具存取。具名 volume 位於 /var/lib/docker/volumes/ 下方,docker volume inspect --format '{{.Mountpoint}}' myapp_db_data 會列出其中一個 volume 的確切路徑。堆疊使用哪一種類型,會影響備份方式;bind mount 與具名 volume 的比較會完整說明兩者的取捨。

接著將找到的項目分成兩組。有些 volume 儲存的是無法重新建立的狀態資料,例如上傳檔案、產生的金鑰、資料庫本身,以及使用者在應用程式中輸入的任何內容。其他 volume 儲存的是縮圖與搜尋索引等衍生資料,應用程式可自行重新建立。備份第二組資料只會增加磁碟空間需求與還原時間,沒有實際收益。Redis cache volume 是最明顯的例子:遺失它只會讓第一次請求變慢。

備份 compose 檔案與 .env 檔案

兩個檔案都位於主機上彼此相鄰的位置,且都不在任何 volume 內。.env 包含資料庫密碼、應用程式 secret 與各種 API token,因此它是將一組 volume 還原成可運作應用程式的關鍵檔案。它通常也會列在 .gitignore 中,因此「我的設定已放在 git」這項備份方式,反而排除了最重要的單一檔案。將 secret 存放在 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

備份 stack 使用的每個 compose 檔案,不要只備份第一個檔案。使用 -f compose.yaml -f compose.prod.yaml 啟動的 stack 必須還原所有檔案,才能以相同方式恢復,而多個 compose 檔案如何合併會決定哪些值實際傳入 container。

有一項注意事項會同時涉及 .env 與 volume。官方 Postgres image 只會在初始化空的資料目錄時讀取 POSTGRES_PASSWORD。之後修改該值,不會變更資料庫內的密碼。若將上個月的 volume 與今天的 .env 一起還原,應用程式會因 FATAL: password authentication failed for user "appuser" 而無法連線,儘管檢查時兩個檔案看起來都正確。請將同一時間點的 .env 與 volume 一起保存在同一份備份中。

使用資料庫原生用戶端匯出資料庫

資料庫伺服器會持續寫入檔案。伺服器執行期間取得的 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 會寫入 custom format。此格式會在寫入時壓縮,之後可使用 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

custom-format 匯出檔開頭是 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 image 中,命令是 mysqldump,變數則是 MYSQL_ROOT_PASSWORDMYSQL_DATABASE。在目前的 MariaDB image 中,mysqldump 仍可作為 mariadb-dump 的相容名稱使用。請注意,只要匯出仍在執行,直接附加在命令列上的密碼就會出現在容器的程序清單中。

SQLite 需要另外處理。資料庫雖然是單一檔案,但最近的交易可能仍位於旁邊的獨立 -wal 檔案中,因此只複製 .db 會遺漏最新寫入內容。若 image 內含用戶端,sqlite3 /data/app.db ".backup '/data/app-backup.db'" 可在應用程式執行期間寫入一致的副本。若不含用戶端,請停止容器,並將 .db 檔案與其 -wal-shm 同伴檔案一併複製。

如果資料庫是在主機上執行,而不是在 stack 內執行,則套用相同命令,但不需要 docker compose exec 前綴。下次重新建置前,建議先閱讀在 Docker 或主機上執行資料庫

擷取 volume

Named volume 沒有應手動編輯的主機路徑,因此請將它掛載到暫時容器,再從容器內建立封存檔。

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

輔助容器會以唯讀方式將 volume 掛載到 /data,並將備份目錄掛載到 /backup,然後把封存檔寫回主機端。--rm 會在 tar 結束後立即移除輔助容器。:ro 很重要,因為即使 tar 指令打錯,也不會損壞來源資料。-C /data . 可確保還原時使用正確的路徑:它會將每個路徑儲存為相對於 volume 根目錄的路徑。若改用 tar czf /backup/uploads.tar.gz /data,每個路徑都會多出開頭的 data/,還原後便會在 volume 內建立 /data/data,應用程式看到的會是空目錄。封存檔的擁有者是 root,因為 tar 在容器內以 root 身分執行。若這造成問題,請執行 sudo chown "$USER" /srv/backups/myapp/uploads.tar.gz;如果還原後的檔案無法由應用程式讀取,請參閱PUID 與 PGID 如何決定檔案擁有權

每個 named volume 執行一次即可。Bind mount 完全不需要容器:tar czf /srv/backups/myapp/config.tar.gz -C /srv/myapp/config . 在主機上即可完成相同工作。

請針對每個 volume 判斷應用程式是否必須停止。若應用程式會在原位置持續改寫 volume,對其執行中的資料使用 tar,可能會擷取到寫入一半的檔案。對於上傳目錄,檔案通常只寫入一次,之後只會讀取,因此風險很低。其他資料則應使用 docker compose stop app 停止服務,直到複製完成,再執行 docker compose start appstop 會保留容器與 volume,這正是此處需要的行為;在輸入任一指令前,請先確認down 與 stop 的差異

不要把資料庫 volume 的 tar 封存檔當成資料庫備份。dump 才是備份。已停止資料庫的 volume 封存檔,只適合作為快速重建途徑,除此之外沒有其他用途。

操作順序

  1. 將 compose 檔案與 .env 複製到備份目錄。
  2. 在資料庫仍在執行時匯出資料庫。
  3. 如果應用程式容器的 volume 會在原處變更,請停止該容器。
  4. 將每個具名 volume 與每個 bind mount 目錄封存。
  5. 啟動先前停止的服務,然後使用 docker compose ps 確認。
  6. 記錄 stack 目前使用的 image tags 與 digests。
  7. 將整個備份目錄複製到這台伺服器以外的位置。

第 7 步最容易被留到稍後才處理。

將副本移出這台伺服器

儲存在與服務堆疊相同磁碟上的備份,只能防止你自己操作錯誤,無法防範其他問題。只要有一個磁碟區故障、伺服器遭刪除或帳號遺失,兩份副本就會同時消失。請排程將目錄推送到不是這台 VPS 的儲存空間,並設定保留政策。VPS 的 restic 備份涵蓋 repository 設定、保留旗標與檢查命令,因此這裡不再重複說明。

restic 也能直接從 pipe 讀取 dump,完全不將純文字資料庫寫入磁碟:

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 job 中,並讓工作將失敗狀態回報到你會查看的位置。輸出無人查看的備份腳本,可能停止運作半年而沒有人發現。

透過還原演練驗證備份可用

從未還原過的備份只是推測。以下演練會將備份還原至與原本服務並行的第二組 stack,因此 production 持續提供服務,且你輸入的任何指令都無法觸及 production。

關鍵在於 project name。Compose 會從目錄名稱取得 project name,並將其套用至所建立的每個 container 和 volume。將備份複製到新目錄後,還原的 stack 就會自動取得專用的 volume。

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 檔案,讓發布的 host port 不會與執行中的 stack 衝突:將 18080:8080 改成 8080:8080,或修改複製的 .env 中設定該 port 的變數。接著建立 container 和空的 volume,但不要啟動任何服務:

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

第二個指令應列出與 production 相同、但前面加上 myapp-restore_ 的 volume 名稱。填入這些 volume,單獨啟動 database,然後載入 dump:

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 會在重新建立每個 object 前先刪除該 object,因此還原可以重複執行。若沒有這個選項,第二次還原至已包含這些 table 的 database 時,會因為 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 會等到每個 service 都回報 running 或 healthy;如果其中一個始終未達成條件,就會以非零狀態碼結束,因此這個步驟可以納入 script。當某個 service 始終未變成 healthy 時,docker compose ps 會顯示其狀態;Compose healthchecks 說明該欄位顯示的內容。接著透過替代 port 開啟 app,並使用實際帳號登入。寫入一筆記錄,再開啟一個位於 volume 中的檔案。這兩項操作才能證明 dump 已還原、volume 已還原,而且兩者內容彼此一致。只證明登入頁面能顯示,無法證明資料已還原。

演練通過後將其移除:

docker compose down -v

這是唯一應使用 -v flag 的情況。在 production 目錄中執行相同指令,會刪除你要保護的 volume。

如何升級 Compose stack

閱讀目前版本與目標版本之間每個版本的 release notes,並搜尋 breakingmigration。不支援跨越多個 major version 升級的專案會在其中說明這點;而拒絕執行的 migration,通常要到已經修改部分 schema 後才會回報。

在變更前,先記錄目前的執行狀態:

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

docker compose images會列出每個服務目前使用的 image 與 tag。只有 digest 能精確識別 image,因為 tag 隨時可能被移動到其他位置。

使用上方章節建立的備份,並將備份複製到這台主機以外的位置。即使是 patch release,也要這樣做。真正容易升級的情況,正是人們停止準備的情況。

接著在 compose file 中固定版本,因為 latest 並不是版本:

services:
  db:
    image: postgres:16.4

使用 image: postgres:latest 時,docker compose pull 會抓取該 tag 今天所指向的內容,而你無法指定昨天執行的內容。固定 tag 可將升級變成一行可讀的修改;你可以在 git diff 中查看該修改,也能再修改一行來還原。應以相同方式固定應用程式 image,並從專案的 release page 取得確切版本。

Pull 並重新建立:

docker compose pull
docker compose up -d --wait

docker compose up -d 會比較該檔案與目前執行中的 container,只重新建立 image 或設定已變更的服務。它不會處理 named volume,因此新的 container 會在現有資料上啟動。這正是此操作的目的,也是風險所在,因為新版本通常會在第一次啟動時執行 schema migration。

監看執行結果:

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

失敗的 container 會在 docker compose psSTATUS 欄位中顯示 Exited (1),原因則位於其 log 的最後幾行。Migration 錯誤會在這裡明確顯示,在其他地方則不會出現。Log 穩定後,登入並使用應用程式幾分鐘。

如果 docker compose pullno space left on device 停止,通常是舊的 image layer 所造成;執行 清除未使用的 Docker image 即可釋放空間。等升級確認正常後再清除,不要事前清除,因為快速 rollback 需要依賴那些舊 layer。

升級失敗時如何回復

這分為兩種情況,所需成本差異很大。如果新版本沒有變更 schema,回復只需一行:在 compose 檔案中填回舊的 tag,然後執行 docker compose up -d。容器會被替換,volumes 會保留原位,舊版程式碼可以讀取它寫入的資料。

如果新版本已遷移 schema,舊版程式碼就無法讀取該 schema。Migration 的設計是向前執行,而且大多數專案完全不提供 downgrade script,因此舊版本雖然能啟動,卻會在第一次查詢已重新命名或刪除的欄位時失敗,並出現類似 ERROR: column "avatar_url" does not exist 的錯誤。回復方式是使用 pull 前建立的 dump:填回舊的 tag,移除 database volume,重新建立空的 volume,將 dump 還原其中,然後啟動服務。如果沒有該 dump,就完全沒有回復途徑;這正是備份必須先於 pull 的原因。

Postgres major version 是最容易出現這種問題的情況。它之所以讓人意外,是因為錯誤會發生在升級時,而不是回復時。磁碟上的格式會隨每個 major release 變更。將 postgres:16.4 改為 postgres:17.2,執行 docker compose up -d,新的 server 就會拒絕啟動:

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.

該 image 不會替你執行 pg_upgrade。在 Compose stack 中,支援的流程是 dump、替換、restore:在舊版本仍在執行時建立 dump,執行 docker compose down,移除 database volume,設定新的 tag,執行 docker compose create 建立全新的空資料目錄,啟動 database,還原 dump,再啟動其餘服務。保留舊的 dump,直到新的 major version 已處理真實網路流量一天為止。同一個 major version 內的 minor upgrade,例如 16.4 升級至 16.9,無須執行上述流程,因為兩者的格式保持一致,容器會直接啟動。

VPS snapshot 是備份嗎?

VPS snapshot 是備份的補充,兩者的失效方式不同。snapshot 會在 hypervisor 層複製整個磁碟,因此能在幾分鐘內還原整台機器,包括你忘記備份的部分。這使它適合一項特定工作:升級導致伺服器故障,而你想將伺服器還原到 20 分鐘前的狀態。

對其他用途而言,snapshot 並不理想。其粒度是整台機器,因此要復原單一遭刪除的資料表,就必須先在某處還原整台伺服器,再從中取出該資料表。保留期限通常很短。副本通常與伺服器位於同一個 provider 帳戶,因此帳戶遺失時,伺服器與 snapshot 也會同時遺失。此外,執行中機器的 snapshot 可能擷取到資料庫正在寫入的狀態,因此資料庫首次啟動時會執行 crash recovery,任何當時尚未完成的交易都會遺失。

兩者都使用。snapshot 是升級期間的復原按鈕。dump 則是即使帳戶遭刪除仍能保留的副本。snapshot 與備份的差異會說明兩者實際能涵蓋哪些故障。相同的備份目錄也能讓將 stack 搬移到新的 VPS成為例行工作,而不必憑記憶重新建置。

發生的問題與你會看到的現象

down 時使用 volumes 旗標。 docker compose down -v 會移除 Compose 檔案宣告的 named volumes,Compose 會顯示一行 Volume myapp_db_data Removed 進行確認。此操作無法復原。單純執行 docker compose down 不會影響這些 volumes。請輸入完整形式 docker compose down --volumes,讓破壞性旗標必須明確輸入完整單字。

不含 magic string 的 dump。 pg_restore: error: did not find magic string in file header 表示該檔案不是 archive。最常見的原因是在 docker compose exec 上遺漏 -T,因為連接 TTY 時,資料流在傳送至 shell 的過程中會被轉換,導致 binary dump 損毀。請使用 -T 重新建立 dump,再以 head -c 5 檢查前 5 個位元組。

無法變更的密碼。 還原後執行 FATAL: password authentication failed for user "appuser",表示 .env 與資料目錄來自不同時間點。image 只會在建立空白資料目錄時設定該密碼,因此之後編輯 .env 不會變更資料庫內的內容。請還原相符的 .env,或使用 ALTER USER 在資料庫內變更密碼。

第二個空白 volume。 Docker 會在需要時建立 volume,因此在缺少 s 的情況下執行 docker run -v myapp_upload:/data,會將資料寫入全新且空白的 volume,並回報成功。接著執行 docker volume ls 會顯示兩個名稱,其中一個沒有任何內容。請從 docker volume ls 複製 volume 名稱,不要憑記憶輸入。

將還原操作指向 production。/srv/myapp-restore 以外的 /srv/myapp 中執行還原命令,會使用 backup 覆寫執行中的資料,而兩處的命令看起來完全相同。每次執行還原命令前,請先確認 pwd,並將演練放在專用目錄中。

FAQ

docker compose down 會刪除我的資料嗎?

不會。docker compose down 會移除容器與預設網路,但不會動到具名 volume 和 bind mount。docker compose down -v 會移除檔案中宣告的具名 volume,而且這是永久性的。bind mount 是主機目錄,因此 Compose 永遠不會移除它們。如果您希望在備份期間停止服務,但保留其他所有內容,請改用 docker compose stop

我可以直接複製 Postgres 資料目錄,而不執行 pg_dump 嗎?

只能在容器停止時這麼做。伺服器執行期間,檔案會持續變更,複製結果可能混合不同時間點的內容,無法正常重播。檔案層級的複製也綁定單一 Postgres major version,因此無法在不同版本下啟動。請停止容器、封存 volume,再重新啟動容器;並將這項複製視為快速重建途徑,不要把它當成唯一的備份。dump 才是可攜式副本,也是您要還原的來源。

如何在 Compose 中將 Postgres 升級至新的 major version?

只變更 tag 並不足夠。新的伺服器會拒絕使用舊的資料目錄啟動,並記錄 The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.2。在舊版本仍執行時,執行 pg_dump,接著執行 docker compose down;移除資料庫 volume、設定新的 tag,再執行 docker compose create 建立全新的空 volume。啟動資料庫,然後將 dump 還原至其中。在新版本實際處理過網路流量前,請保留舊的 dump。

備份應多久執行一次?應保留多久?

請依照您願意重新處理的工作量決定間隔。個人或小型團隊的 stack 適合每日備份,並在任何升級前立即再手動備份一次。保留期限應涵蓋您未能立即發現的損害,因為週五才發現損壞的資料表,無法由週四晚上的副本協助復原。restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune 是合理的起始政策。無論排程為何,每季都應從備份執行一次還原。在完成這項測試前,您擁有的不是備份,而只是檔案。

備份時必須停止整個 stack 嗎?

通常不必。資料庫 dump 在伺服器執行期間仍能保持一致,因此資料庫不需要停機。真正需要考量的是 volume。如果應用程式只會新增檔案,例如 uploads 目錄,線上封存通常已足夠安全。如果應用程式會原地改寫檔案,請使用 docker compose stop app 在複製期間停止該服務,完成後再重新啟動。讓應用程式停止、資料庫繼續執行,通常是能安排的最短安全時段。