SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor · 已更新 2026-08-13

資料庫該跑在 Docker 還是主機上?

PostgreSQL、MySQL、MongoDB 與 Redis 在 production 使用 Docker 沒問題,但 volume、版本升級、備份還原與記憶體限制才是主要風險。

資料庫應在 Docker 中執行,還是直接在主機上執行?

請在 Docker 中執行資料庫。對於單一 VPS 上的單一應用程式堆疊而言,使用容器化的 PostgreSQL、MySQL、MongoDB 或 Redis 是常見的正式環境選擇。人們通常爭論的重點並不正確。容器是具備 namespace 和 cgroup 隔離的 Linux 程序,不是虛擬機器。因此,資料庫與磁碟之間沒有 hypervisor。使用 bind mount 或本機 named volume 時,讀寫會落在主機檔案系統上,也就是套件安裝會使用的同一個檔案系統。

真正的成本在於營運管理。有 4 件事會決定這種架構是穩定的選擇,還是緩慢形成的災難:資料存放位置、該目錄的擁有者、主要版本升級的方式,以及你是否曾經還原過備份。這些事項處理正確,容器本身只是實作細節。處理錯誤,最後你會把問題歸咎於容器。

每種伺服器資料庫都適用相同的決策方式。以下範例使用 PostgreSQL、MySQL、MongoDB 和 Redis,並在相關處說明各產品的差異。

容器實際改變的內容

只要掛載儲存區,儲存路徑就不會改變。核心、頁面快取與檔案系統都相同。

真正的效能陷阱只有一個,就是完全不掛載儲存區。未使用 volume 時,資料目錄會寫入容器的可寫入層。這是疊加在 image 上的 overlay filesystem。寫入速度較慢,而且移除容器時,整個可寫入層都會刪除。「今天早上資料庫怎麼是空的」就是由此造成的。

實際會改變的內容如下:

  • 生命週期。docker compose down 會刪除容器。未儲存在 volume 中的資料也會一併刪除。
  • 版本。image tag 就是版本。資料庫容器內沒有能持續到下一次 docker compose pullapt upgrade
  • 記憶體計量。cgroup 限制是由核心強制執行的硬性上限,資料庫並不知道這項限制存在。
  • 使用者。程序會在容器內以數值 user id 執行,而該 user id 可能不擁有主機上的任何檔案。

資料儲存位置會決定一切

有 2 個適當選項,以及 1 個常見錯誤。

  • Named volume:pgdata:/var/lib/postgresql/data。Docker 會在 /var/lib/docker/volumes/<project>_pgdata/_data 建立目錄,而 image entrypoint 會在首次執行時設定擁有者。這是預設選項。
  • Bind mount:/srv/appname/pg:/var/lib/postgresql/data。路徑由你選擇,因此權限問題也由你負責。
  • 完全不掛載。請參閱上文。資料會留在 container 中。

完整取捨另有專題說明,bind mount 與 named volume 的比較涵蓋這部分。資料庫的簡短結論是:除非有明確理由必須知道 host path,否則請使用 named volume;如果確實使用 bind mount,請將其放在 /srv/appname/pg 這類穩定位置,而不要放在 git clean 可存取的 project directory 內。

有一項硬性限制:不要將資料庫資料目錄放在 NFS(network file system)或任何尚未測試其鎖定與 fsync 行為的 network mount 上。資料庫假設成功的 fsync 代表位元組已寫入穩定儲存體。這項假設不成立時,可能產生數週後才顯現的資料損毀。

固定 volume 名稱,避免 volume 消失

Compose 會為 volume 命名為 <project>_<volume>,而專案名稱預設為目錄名稱。因此,volume 的識別取決於目錄名稱;人們常常會不經意修改目錄名稱。

/srv/app 移至 /srv/app-old,或重新命名 compose 檔案中的 pgdata key,下一次執行 docker compose up -d 時就會建立全新的空 volume。Postgres 會在其中初始化新的 cluster。容器狀態正常,應用程式也會啟動,但所有資料表都消失了。舊 volume 仍以舊名稱保留在磁碟上,這是好消息。

docker volume ls
docker volume inspect app_pgdata

固定名稱,避免發生這種情況。明確設定專案名稱與 volume 名稱:

name: myapp

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

volumes:
  pgdata:
    name: myapp_pgdata

如果已有未預期的 volume 儲存資料,請先停止資料庫,再將資料複製過去:

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 啟動時,entrypoint 會先將資料目錄的擁有權變更為該使用者,接著降級權限。這就是為什麼空的 bind mount 通常第一次就能運作。

只要在 compose 檔案中設定 user:,就會立即失效,因為 entrypoint 已沒有足夠權限修正任何項目。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 擁有的 bind mount 上會因 lock file 而啟動失敗:

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 的帳號彼此無關:kernel 會比較數值,而名稱則分別在兩端查詢。PUID 和 PGID 如何將主機使用者對應至容器 會完整說明這項對應關係。使用 rootless Docker 或 user namespace remapping 時,數值還會再次變更,因此請從執行中的容器讀取 ID,不要直接假設為 999。

首次執行時,named volume 會讓本節的問題完全消失,因為 Docker 會建立空目錄,且 entrypoint 會取得該目錄的擁有權。

升級:套件升級與映像標籤變更的比較

在主機上,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,容器就會再次啟動。這種回復能力是容器在升級方面真正具備的優勢。

支援的方式是傾印後還原。PostgreSQL 建議使用較新的用戶端建立傾印,因此請從新映像對 compose network 上仍在執行的舊伺服器執行:

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 結尾。若檔案只有幾百 bytes,表示傾印失敗;此時刪除 volume 只會造成無謂損失。完成這項檢查後,才執行:

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 會在啟動時升級自身的資料字典,因此次要版本標籤變更通常只需重新啟動。在不同發行系列之間升級前,請先閱讀 release notes;無論如何,都應先建立傾印。
  • MariaDB 預期伺服器以新版本啟動後執行 mariadb-upgrade
  • MongoDB 必須一次升級一個主要版本。每完成一個步驟,都要先設定 feature compatibility version,再繼續下一步。跳過版本會導致 mongod 拒絕啟動,並記錄包含 featureCompatibilityVersionUPGRADE PROBLEM 訊息。從 MongoDB 7.0 開始,該命令需要明確的確認旗標:db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true })
  • Redis 可以順利載入較舊的 snapshot 檔案,但無法載入較新的檔案。因此,升級只需重新啟動;降級則可能無法載入資料。

一般原則是:容器讓降級變得容易,但不會讓升級變得更簡單。

為什麼我的資料庫容器會以代碼 137 結束?

因為 kernel 的 out of memory (OOM) killer 將它終止。137 等於 128 加上 signal 9。

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

docker compose ps 顯示 Exited (137),inspect 輸出中的該行為 "OOMKilled": true,而 kernel log 也包含相符的項目:

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

以下是其運作機制,而且常讓人感到意外。PostgreSQL 和 MySQL 會根據 host 回報的總記憶體大小,決定其 buffer 大小。cgroup limit 不會改變它們取得的這個數值。在 16 GB host 上設定 2 GB limit 時,資料庫會按照擁有 16 GB 記憶體的情況規劃,cgroup 會在 host 本身出現任何記憶體壓力前很久就將它終止。因此,單獨設定 memory limit 並不足夠。您還必須告知資料庫可用的記憶體大小:

  • PostgreSQL:設定 shared_buffers,並注意 work_memwork_mem 會針對每個 sort operation、每個 connection 個別配置,因此將其設得過大,再乘上 50 個 connection,通常會造成容器在負載下而非啟動時結束。
  • MySQL 和 MariaDB:設定 innodb_buffer_pool_size,其預設值為 128M。在容器中關閉 innodb_dedicated_server,因為它的全部用途就是根據偵測到的 machine memory 決定自身大小。
  • MongoDB:明確設定 WiredTiger cache size,不要讓它根據 host memory 自行估算。
  • Redis:maxmemory 預設為 unlimited,因此 Redis 會持續成長,直到 cgroup 將它停止。將 maxmemory 設定為明顯低於 container limit 的值,並選擇 maxmemory-policy

Postgres 也會從自身端回報此事件,您會在 log 中找到以下這兩行:

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

其中一個 backend 被終止後,會迫使其他所有 backend 重新啟動,因為 shared memory 可能已不一致。這會讓您的應用程式發生 connection storm,不是可以忽略的事件。在 Docker Compose 中設定 memory limit 說明語法,以及 mem_limitdeploy.resources 形式之間的差異。

這些問題不會在 host 上消失,只是轉移了位置。沒有 cgroup 時,資料庫會與主機上的所有其他程序競爭資源,而 host OOM killer 會依分數選擇受害者,可能包括 sshd。能以可預期方式終止資料庫的 limit,比會讓您失去登入能力的 host OOM 更容易維運。

備份:內部使用 dump,外部執行備份

不要直接複製執行中資料庫的資料目錄來建立備份。伺服器寫入資料時進行檔案層級複製,會產生不完整的副本,直到還原時才會發現問題。

有兩種可靠的方法:在資料庫執行期間使用資料庫自己的工具建立 dump,再備份該 dump;或停止容器後,離線複製 volume。

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 可能會將終端機附加到命令,而終端機層會在輸出串流中加入 carriage return。文字 dump 還原時會出現奇怪的錯誤,二進位 dump 則會直接損毀。備份時不會明顯失敗,卻可能在一個月後還原時才暴露問題。

--single-transaction 可讓 mysqldump 在不鎖定整台伺服器的情況下,取得 InnoDB 資料表的一致快照。

這些命令各自只會寫入一個檔案。它們不是備份系統:沒有保留政策、異機副本,也沒有驗證。請將 dump 目錄交給同時處理這三項工作的工具;從 VPS 使用 restic 備份就是這種用途。請備份 /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.,表示這個 dump 可能不是你以為的內容。完成後請刪除 restore_test

刪除所有內容的命令

docker compose down -v

單純執行 down 會移除容器與網路。-v 還會移除該 compose 檔案中宣告的所有具名 volume,以及連接至這些容器的所有匿名 volume。此命令不會提示,也無法復原。這是自架資料庫最常見的損毀方式,通常發生在排查無關問題時,因為論壇回覆要求執行該命令。

以下 4 項措施可降低影響範圍:

  • 將資料庫 volume 宣告為 external: true。Compose 不會移除不屬於它管理的 volume,因此 -v 無法移除該 volume。使用 docker volume create myapp_pgdata 建立一次即可。
  • 日常重新啟動使用 docker compose stopdocker compose startCompose 中 down 與 stop 的差異 說明各命令會移除哪些內容。
  • 將 dump 儲存在主機路徑中,且該路徑不得位於 compose 管理的任何 volume 內。
  • 絕不要將疑難排解回覆中的 -v 貼到存放重要資料的 stack 中。

不要公開資料庫埠

這一行會將資料庫公開到網際網路:

    ports:
      - "5432:5432"

它會繫結至所有網路介面。Docker 會在封包進入防火牆的 input 規則前,先改寫封包的目的地來發布埠,而 ufw 的規則位於 input chain,因此 ufw deny 5432 完全不起作用。Docker 發布的埠為何會繞過 ufw說明了封包通過各 chain 的順序。

同一個 compose project 中的應用程式,可透過 compose network 上的服務名稱連線至資料庫,因此不需要發布埠。刪除這個區塊。如果需要讓主機上的 client 連線,請只繫結至 loopback:

    ports:
      - "127.0.0.1:5432:5432"

檢查目前實際處於 listening 狀態的項目:

sudo ss -ltnp | grep 5432

127.0.0.1:5432才是所需設定。0.0.0.0:5432表示任何人都可以嘗試輸入你的密碼。

在哪裡執行哪些內容

一個 VPS 執行一個應用程式。 使用容器。配置固定名稱的 named volume、不發布連接埠、設定記憶體限制,並搭配相符的資料庫設定。每晚將資料庫傾印到由 restic 收集的主機路徑。從 在 VPS 上完成乾淨的 Docker 安裝開始,並將整個 stack 放在一個提交至 git 的 compose 檔案中。這樣做的實際好處是:資料庫版本會成為 git 中可供審查的一行設定。

主機執行多個服務。 使用容器,每個應用程式各自配置一個資料庫,不要讓所有應用程式共用同一個資料庫伺服器。共用伺服器會讓所有應用程式綁定同一個升級時程,而單一失控查詢可能導致所有應用程式中斷。為每個容器設定獨立的記憶體限制,讓錯誤查詢只影響產生該查詢的應用程式。多個小型 Postgres 執行個體會多耗用一些磁碟空間,但能大幅減少協調成本。

資料庫就是產品本身。 使用供應商套件儲存庫提供的套件,在主機上執行資料庫;或改用代管服務。pg_upgrade 需要同時安裝二進位檔的兩個 major version,而套件可以提供這項配置,單一版本的 image 則無法提供。資料庫自行管理主機與磁碟時,使用 WAL(write ahead log)封存進行 replication 與 point-in-time recovery 都較為容易。對於會在 03:00 觸發你的待命通知的系統,請選擇最單純、最不容易出問題的方案。

應用程式規模很小。 可以考慮完全不執行伺服器資料庫。對於在一個 VPS 上執行、只有單一寫入者的 Web 應用程式,在 VPS 的 production 環境使用 SQLite通常更合適;備份只需保存一個檔案,升級路徑則是更新 library version。

FAQ

在 Docker 中執行正式環境資料庫是否安全?

對單一伺服器的應用程式堆疊而言可以。容器本質上是由 namespace 與 cgroup 包覆的 Linux 程序,因此掛載 volume 後,資料庫會寫入與透過套件安裝時相同的主機檔案系統。風險在於維運,而非效能:未固定名稱的 volume、由錯誤 user id 擁有的 bind mount、從未測試過的還原流程,以及 docker compose down -v。排除這4項風險後,使用容器沒有問題。當資料庫是主要工作負載,且需要 pg_upgrade、複寫或時間點復原時,再改用直接安裝在主機上的方式。

資料庫資料應使用 bind mount 還是 named volume?

除非有特定理由需要知道主機路徑,否則使用 named volume。Docker 會建立目錄,映像檔的 entrypoint 會在首次啟動時設定擁有權,因此不會出現權限問題。使用明確的 name: 固定 volume,或將其標記為 external: true;否則重新命名專案目錄會靜默產生全新的空 volume,並建立空資料庫。若將主機目錄的擁有者變更為映像檔執行時使用的數值 user id,bind mount 也可以使用。官方 Postgres、MySQL 和 MongoDB 映像檔使用的 user id 是 999。使用 ls -ldn 進行確認,因為 ls -l 顯示的是主機對該數字使用的名稱,而該名稱在容器內沒有意義。

docker compose down -v 會刪除什麼?

它會像一般的 down 一樣移除容器與網路;-v 另外會移除該 compose 檔案宣告的所有 named volume,以及附加至這些容器的所有 anonymous volume。這也包括資料庫。此操作不會顯示確認提示,且無法復原。標記為 external: true 的 volume 不會被移除,這也是將資料庫 volume 標記為 external 的主要原因。例行重新啟動請改用 docker compose stopdocker compose start

如何在 Docker 中將 PostgreSQL 升級至新的 major version?

執行 dump 與 restore。將 postgres:16 改為 postgres:17 後重新啟動,會得到 FATAL: database files are incompatible with server,並顯示同時列出兩個版本的 DETAIL 行,因為新的二進位檔無法讀取舊的 catalog 格式。資料不會損壞:改回舊的 tag 即可啟動。使用新版本的 client,對執行中的舊容器執行 pg_dumpall,確認檔案以 PostgreSQL database cluster dump complete 結尾,接著在空的 volume 上啟動新的 tag,並載入 dump。同一 major version 內的 minor upgrade 只需要 pull 後重新啟動。

為什麼我的資料庫容器會以代碼 137 結束?

137 等於 128 加上 signal 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 行,以確認 kernel 終止了哪個程序。