
備份策略與災難復原:「不會發生在我們身上」是最危險的假設
2017 年 GitLab 的一位工程師在疲勞狀態下執行了 rm -rf 刪除了 production 資料庫目錄,300GB 的資料瞬間消失。他們有五種備份機制,但事後發現其中四種都沒有正常運作,最後靠一個六小時前偶然手動做的 staging snapshot 救回大部分資料,仍然永久遺失了約六小時的資料。2019 年 Myspace 宣布遺失了用戶在 2003-2015 年間上傳的所有音樂檔案,約 5000 萬首歌曲,原因是一次伺服器遷移過程中備份損毀且無人驗證。更近的例子:2023 年丹麥的雲端供應商 CloudNordic 遭到勒索軟體攻擊,所有客戶資料(包括備份)都被加密,公司直接宣布無法復原,建議客戶另尋供應商。
這些事件的共同點:不是沒有備份,而是備份策略有致命漏洞。備份沒有測試、備份和正式資料放在同一個故障域、備份頻率跟不上業務需求。這篇文章建立一套完整的備份策略與災難復原(Disaster Recovery, DR)計畫,涵蓋從概念定義、實作腳本、到演練流程,確保當災難真的發生時,你不只有備份,還有能力把資料真的救回來。
架構概覽
flowchart TD subgraph Strategy["備份策略"] Full["Full Backup\n每週一次"] Incr["Incremental Backup\n每日差異"] Diff["Differential Backup\n每日累積"] end Full --> Storage1["本地儲存\nNAS / Disk"] Incr --> Storage1 Diff --> Storage1 Storage1 --> Storage2["異地備援\nS3 / GCS"] Storage2 --> Storage3["離線備份\nTape / Cold Storage"] subgraph Timeline["RPO / RTO 時間軸"] RPO["RPO\n可容忍資料遺失"] -.-> RTO["RTO\n服務恢復時間"] end
架構概覽
flowchart TD subgraph Sources["資料來源"] PG[PostgreSQL\n:5432] Redis[Redis\n:6379] ES[Elasticsearch\n:9200] Vol[Docker Volumes\n應用資料] Conf[Config Files\n設定檔 / Secrets] end subgraph Local["本地備份 (第 1 份)"] PGDump[pg_dump\n每日邏輯備份] WAL[WAL Archiving\n持續歸檔] RDB[Redis RDB/AOF\nSnapshot] ESSnap[ES Snapshot\nRepository] VolTar[Volume tar.gz\n壓縮歸檔] end subgraph Onsite["本地異介質 (第 2 份)"] MinIO[MinIO / NAS\nS3 相容儲存] end subgraph Offsite["異地備份 (第 3 份)"] Cloud[Cloud S3\nAWS / GCP / Azure] RemoteNAS[Remote NAS\n異地機房] end subgraph Verify["還原驗證"] TestRestore[定期還原測試\n自動化驗證] Alert[驗證失敗告警\n→ Slack / PagerDuty] end PG --> PGDump PG --> WAL Redis --> RDB ES --> ESSnap Vol --> VolTar Conf --> VolTar PGDump --> MinIO WAL --> MinIO RDB --> MinIO ESSnap --> MinIO VolTar --> MinIO MinIO -->|mc mirror / rclone| Cloud MinIO -->|rsync| RemoteNAS Cloud --> TestRestore TestRestore -->|失敗| Alert
資料從各個來源(PostgreSQL、Redis、Elasticsearch、Docker Volumes、設定檔)透過對應的備份機制產出備份檔,先存到本地的 MinIO,再同步到異地儲存。定期從異地備份拉回測試環境做還原驗證,確保備份真的能用。這就是 3-2-1 原則的完整實現。
核心概念
-
RTO(Recovery Time Objective)— 你能容忍多久的停機? RTO 定義的是從災難發生到服務完全恢復的最大可接受時間。如果你的 RTO 是 4 小時,代表資料庫掛掉後,你必須在 4 小時內讓服務重新上線。RTO 越短,需要的基礎設施成本越高(需要 hot standby、自動 failover)。對於大部分中小型團隊,RTO 設定在 1-4 小時是合理的起點。關鍵是你要知道這個數字,而不是災難發生時才開始算。
-
RPO(Recovery Point Objective)— 你能容忍丟多少資料? RPO 定義的是災難發生時,最多可以接受遺失多長時間的資料。如果你每天凌晨 3 點做一次
pg_dump,你的 RPO 就是 24 小時,因為最壞情況下你會遺失前一天 3 點到災難發生這段時間的所有寫入。如果業務要求 RPO < 1 小時,就需要用 WAL archiving 做持續備份,甚至 streaming replication。RPO 和備份頻率直接相關:RPO = 備份間隔 + 備份傳輸時間。 -
3-2-1 備份原則:這是備份策略的黃金法則。3 份資料:原始資料 + 2 份備份。2 種不同介質:例如 SSD + 物件儲存,或磁碟 + 磁帶。1 份異地存放:至少一份備份放在不同的地理位置(不同機房、不同雲端區域、甚至不同國家)。為什麼要異地?因為火災、水災、勒索軟體攻擊可以同時摧毀同一個機房裡的所有資料,包括你以為安全的備份。CloudNordic 的案例就是慘痛的教訓:備份和正式資料在同一個網路裡,勒索軟體一次全部加密。
-
備份類型:Full / Incremental / Differential:
- Full Backup(完整備份):每次備份全部資料。優點是還原最簡單(只需要一份檔案),缺點是每次備份量大、耗時。
- Incremental Backup(增量備份):只備份上次備份(不管是 Full 或 Incremental)之後變更的資料。備份量最小,但還原時需要 Full + 所有後續的 Incremental,中間任何一份損毀就斷鏈。
- Differential Backup(差異備份):只備份上次 Full Backup 之後變更的資料。備份量介於兩者之間,還原只需要 Full + 最新的 Differential。
- 常見策略:每週日做 Full,週一到週六做 Incremental 或 Differential。
-
Hot / Warm / Cold 備份:
- Hot Backup:在資料庫正常運行中進行的備份,不影響讀寫。PostgreSQL 的
pg_dump和 WAL archiving 都是 Hot Backup。這是生產環境的標準做法。 - Warm Backup:資料庫處於唯讀模式時進行的備份。適合在維護時段使用,可以確保資料一致性。
- Cold Backup:停止資料庫後複製資料檔案。最簡單但需要停機,只適合非關鍵系統或排定的維護窗口。
- Hot Backup:在資料庫正常運行中進行的備份,不影響讀寫。PostgreSQL 的
-
資料庫備份策略:
- pg_dump(邏輯備份):將資料庫匯出為 SQL 或 custom format。優點是可以選擇性還原特定 table/schema,跨版本相容性好,備份檔案可以壓縮到很小。缺點是大資料庫(>50GB)dump 時間長,且還原也慢。適合中小型資料庫的每日備份。
- WAL Archiving(物理備份 + 連續歸檔):PostgreSQL 的 Write-Ahead Log(WAL)記錄了每一筆資料變更。啟用 WAL archiving 後,每個 WAL segment(預設 16MB)寫滿就會被歸檔到指定位置。搭配
pg_basebackup做一次完整的物理備份後,後續只要保留 WAL 檔案,就可以重放到任意時間點。 - Point-in-Time Recovery(PITR):基於 WAL archiving 的精確恢復能力。例如有人在下午 2:30 誤刪了一張 table,你可以指定恢復到 2:29,精確到秒。這是 RPO 幾乎為零的解決方案,代價是需要持續儲存 WAL 檔案(每天可能數 GB 到數十 GB)。
-
檔案 / Volume 備份:Docker volumes(應用資料、上傳檔案)、MinIO 資料目錄、設定檔(docker-compose.yml、nginx.conf、.env)都需要備份。最直接的方式是打 tar.gz 壓縮包然後上傳。設定檔應該存在 Git 裡(Infrastructure as Code),但 secrets(密碼、API key)不能進 Git,需要另外備份,參考 Secrets & Config。
-
應用狀態備份:
- Redis:如果 Redis 只當 cache 用,不需要備份(重啟後自動重建)。如果 Redis 存了 session 或隊列資料,需要備份 RDB snapshot 或 AOF 檔案。
- Elasticsearch:使用 Snapshot API 建立 snapshot repository(可以指向 MinIO/S3),定期做 snapshot。ES 的 snapshot 是增量的,第一次之後的 snapshot 只會存差異。
使用情境
-
每日備份基線:凌晨 2 點 cron job 執行
pg_dump,壓縮後上傳到 MinIO 的db-backupsbucket,同時用mc mirror同步到雲端 S3。保留策略:每日備份保留 30 天,每月 1 號的備份保留 1 年。WAL archiving 持續運行,每個 WAL segment 歸檔到 MinIO,保留 7 天。 -
誤操作救援:開發者在 production 執行了
DELETE FROM orders WHERE status = 'pending',忘了加LIMIT,刪除了所有 pending 訂單。因為有 WAL archiving 和 PITR,DBA 可以建立一個臨時的 PostgreSQL instance,恢復到 DELETE 執行前一秒,把被刪的資料匯出再 INSERT 回 production。整個過程不需要停機。 -
整機重建:Host 的硬碟壞了,需要在新機器上重建所有服務。從 Git 拉 docker-compose 和設定檔(IaC),從 MinIO/S3 下載最新的資料庫備份和 volume 備份,按照 Runbook 的步驟還原。RTO 目標:4 小時內服務上線。
實作範例 / 設定範例
PostgreSQL 自動備份腳本(pg_dump + 壓縮 + 上傳 MinIO)
#!/bin/bash
# backup-postgres.sh — PostgreSQL 每日自動備份
# 建議放在 cron: 0 2 * * * /opt/scripts/backup-postgres.sh >> /var/log/backup-postgres.log 2>&1
set -euo pipefail
# === 設定 ===
BACKUP_DIR="/tmp/pg-backups"
DB_CONTAINER="postgres"
DB_NAME="myapp"
DB_USER="myapp"
MINIO_ALIAS="myminio" # mc alias name
MINIO_BUCKET="db-backups"
RETENTION_DAYS=30
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="pg_${DB_NAME}_${DATE}.dump.gz"
# === 建立暫存目錄 ===
mkdir -p "${BACKUP_DIR}"
echo "[$(date)] Starting PostgreSQL backup: ${DB_NAME}"
# === 執行 pg_dump(custom format + 壓縮) ===
docker exec "${DB_CONTAINER}" pg_dump \
-U "${DB_USER}" \
-d "${DB_NAME}" \
-Fc \
-Z 6 \
--verbose \
> "${BACKUP_DIR}/${BACKUP_FILE}"
FILESIZE=$(du -h "${BACKUP_DIR}/${BACKUP_FILE}" | cut -f1)
echo "[$(date)] Backup created: ${BACKUP_FILE} (${FILESIZE})"
# === 上傳到 MinIO ===
mc cp "${BACKUP_DIR}/${BACKUP_FILE}" "${MINIO_ALIAS}/${MINIO_BUCKET}/daily/${BACKUP_FILE}"
echo "[$(date)] Uploaded to MinIO: ${MINIO_ALIAS}/${MINIO_BUCKET}/daily/${BACKUP_FILE}"
# === 如果是每月 1 號,額外複製一份到 monthly 目錄 ===
DAY_OF_MONTH=$(date +%d)
if [ "${DAY_OF_MONTH}" = "01" ]; then
mc cp "${BACKUP_DIR}/${BACKUP_FILE}" "${MINIO_ALIAS}/${MINIO_BUCKET}/monthly/${BACKUP_FILE}"
echo "[$(date)] Monthly backup copied"
fi
# === 清理本地暫存 ===
rm -f "${BACKUP_DIR}/${BACKUP_FILE}"
# === 清理 MinIO 上超過保留天數的每日備份 ===
mc rm --recursive --force --older-than "${RETENTION_DAYS}d" \
"${MINIO_ALIAS}/${MINIO_BUCKET}/daily/"
echo "[$(date)] Cleaned up daily backups older than ${RETENTION_DAYS} days"
# === 同步到異地(雲端 S3) ===
mc mirror --overwrite "${MINIO_ALIAS}/${MINIO_BUCKET}/" "clouds3/${MINIO_BUCKET}/"
echo "[$(date)] Mirrored to cloud S3"
echo "[$(date)] Backup completed successfully"WAL Archiving 設定(Point-in-Time Recovery)
# === Step 1: 修改 postgresql.conf 啟用 WAL archiving ===
# 加入以下設定到 postgresql.conf
# WAL 基本設定
wal_level = replica # 必須是 replica 或 logical
archive_mode = on # 啟用歸檔
archive_command = 'test ! -f /archive/%f && cp %p /archive/%f'
# 將 WAL 複製到 /archive 目錄
# 生產環境建議改用上傳到 MinIO/S3:
# archive_command = 'mc cp %p myminio/wal-archive/%f'
archive_timeout = 300 # 5 分鐘內沒有新 WAL 也強制歸檔(RPO ≈ 5 min)
# === Step 2: docker-compose 掛載 archive volume ===
# docker-compose.yml 片段:
#
# postgres:
# image: postgres:16-alpine
# volumes:
# - pg-data:/var/lib/postgresql/data
# - pg-archive:/archive # WAL 歸檔目錄
# - ./postgresql.conf:/etc/postgresql/postgresql.conf
# command: postgres -c config_file=/etc/postgresql/postgresql.conf
#
# volumes:
# pg-data:
# pg-archive:
# === Step 3: 做一次 base backup(物理備份基底) ===
docker exec postgres pg_basebackup \
-U myapp \
-D /tmp/basebackup \
-Ft -z \
--wal-method=stream \
--checkpoint=fast \
--label="basebackup_$(date +%Y%m%d)"
# 將 base backup 上傳到 MinIO
docker cp postgres:/tmp/basebackup ./basebackup
mc cp --recursive ./basebackup/ myminio/wal-archive/basebackup/
# === Step 4: PITR 還原步驟 ===
# 假設需要恢復到 2024-09-15 14:29:00(誤操作發生在 14:30)
# 4a. 準備新的 PostgreSQL instance(使用 base backup)
mkdir -p /tmp/pitr-restore
mc cp --recursive myminio/wal-archive/basebackup/ /tmp/pitr-restore/
# 4b. 解壓 base backup
cd /tmp/pitr-restore
tar xzf base.tar.gz
# 4c. 建立 recovery.signal 和設定恢復參數
cat > /tmp/pitr-restore/postgresql.auto.conf << 'EOF'
restore_command = 'mc cp myminio/wal-archive/%f %p'
recovery_target_time = '2024-09-15 14:29:00+08'
recovery_target_action = 'promote'
EOF
touch /tmp/pitr-restore/recovery.signal
# 4d. 啟動臨時的 PostgreSQL(使用還原的資料目錄)
docker run -d --name pg-pitr \
-v /tmp/pitr-restore:/var/lib/postgresql/data \
-p 5433:5432 \
postgres:16-alpine
# 4e. 驗證資料,確認恢復到正確的時間點
docker exec pg-pitr psql -U myapp -d myapp \
-c "SELECT count(*) FROM orders WHERE status = 'pending';"
# 4f. 從臨時 instance 匯出需要的資料,INSERT 回 production
docker exec pg-pitr pg_dump -U myapp -d myapp \
-t orders --data-only \
--column-inserts \
> /tmp/orders_recovered.sqlDocker Volume 備份腳本
#!/bin/bash
# backup-volumes.sh — Docker volume 和設定檔備份
# cron: 0 3 * * * /opt/scripts/backup-volumes.sh >> /var/log/backup-volumes.log 2>&1
set -euo pipefail
BACKUP_DIR="/tmp/volume-backups"
MINIO_ALIAS="myminio"
MINIO_BUCKET="volume-backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=14
mkdir -p "${BACKUP_DIR}"
echo "[$(date)] Starting volume backup"
# === 備份指定的 Docker volumes ===
VOLUMES=(
"myapp_uploads"
"myapp_minio-data"
"myapp_redis-data"
"myapp_es-data"
"myapp_grafana-data"
)
for VOL in "${VOLUMES[@]}"; do
ARCHIVE="${BACKUP_DIR}/${VOL}_${DATE}.tar.gz"
echo "[$(date)] Backing up volume: ${VOL}"
# 用一個暫時的 container 掛載 volume 並打包
docker run --rm \
-v "${VOL}:/source:ro" \
-v "${BACKUP_DIR}:/backup" \
alpine:3.19 \
tar czf "/backup/$(basename ${ARCHIVE})" -C /source .
FILESIZE=$(du -h "${ARCHIVE}" | cut -f1)
echo "[$(date)] Volume backup: ${VOL} → ${ARCHIVE} (${FILESIZE})"
# 上傳到 MinIO
mc cp "${ARCHIVE}" "${MINIO_ALIAS}/${MINIO_BUCKET}/${VOL}/${DATE}.tar.gz"
# 清理本地
rm -f "${ARCHIVE}"
done
# === 備份設定檔(docker-compose, nginx, .env 等) ===
CONFIG_DIRS=(
"/opt/myapp" # docker-compose.yml, .env
"/etc/nginx" # nginx config
"/etc/prometheus" # prometheus config
"/etc/grafana/provisioning" # grafana dashboards
)
CONFIG_ARCHIVE="${BACKUP_DIR}/configs_${DATE}.tar.gz"
tar czf "${CONFIG_ARCHIVE}" "${CONFIG_DIRS[@]}" 2>/dev/null || true
mc cp "${CONFIG_ARCHIVE}" "${MINIO_ALIAS}/${MINIO_BUCKET}/configs/${DATE}.tar.gz"
rm -f "${CONFIG_ARCHIVE}"
echo "[$(date)] Config files backed up"
# === 清理舊備份 ===
mc rm --recursive --force --older-than "${RETENTION_DAYS}d" \
"${MINIO_ALIAS}/${MINIO_BUCKET}/"
# === 同步到異地 ===
mc mirror --overwrite "${MINIO_ALIAS}/${MINIO_BUCKET}/" "clouds3/${MINIO_BUCKET}/"
echo "[$(date)] Volume backup completed"備份驗證腳本(自動還原到測試環境並檢查)
#!/bin/bash
# verify-backup.sh — 備份還原驗證(建議每週執行一次)
# cron: 0 6 * * 0 /opt/scripts/verify-backup.sh >> /var/log/verify-backup.log 2>&1
set -euo pipefail
MINIO_ALIAS="myminio"
DB_BUCKET="db-backups"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"
VERIFY_DIR="/tmp/backup-verify"
VERIFY_CONTAINER="pg-verify-$$"
VERIFY_PORT=5499
cleanup() {
echo "[$(date)] Cleaning up..."
docker rm -f "${VERIFY_CONTAINER}" 2>/dev/null || true
rm -rf "${VERIFY_DIR}"
}
trap cleanup EXIT
notify() {
local status="$1"
local message="$2"
local color
if [ "${status}" = "success" ]; then color="good"; else color="danger"; fi
curl -s -X POST "${SLACK_WEBHOOK}" \
-H 'Content-Type: application/json' \
-d "{
\"attachments\": [{
\"color\": \"${color}\",
\"title\": \"Backup Verification: ${status}\",
\"text\": \"${message}\",
\"ts\": $(date +%s)
}]
}"
}
echo "[$(date)] Starting backup verification"
mkdir -p "${VERIFY_DIR}"
# === Step 1: 從 MinIO 下載最新的備份 ===
LATEST_BACKUP=$(mc ls "${MINIO_ALIAS}/${DB_BUCKET}/daily/" --json \
| jq -r '.key' | sort | tail -1)
if [ -z "${LATEST_BACKUP}" ]; then
notify "FAIL" "No backup found in ${DB_BUCKET}/daily/"
exit 1
fi
echo "[$(date)] Latest backup: ${LATEST_BACKUP}"
mc cp "${MINIO_ALIAS}/${DB_BUCKET}/daily/${LATEST_BACKUP}" "${VERIFY_DIR}/backup.dump.gz"
# === Step 2: 啟動臨時的 PostgreSQL container ===
docker run -d --name "${VERIFY_CONTAINER}" \
-e POSTGRES_DB=myapp \
-e POSTGRES_USER=myapp \
-e POSTGRES_PASSWORD=verify_temp_pwd \
-p "${VERIFY_PORT}:5432" \
postgres:16-alpine
echo "[$(date)] Waiting for PostgreSQL to start..."
sleep 10
# 確認 PostgreSQL 已就緒
for i in $(seq 1 30); do
if docker exec "${VERIFY_CONTAINER}" pg_isready -U myapp > /dev/null 2>&1; then
echo "[$(date)] PostgreSQL is ready"
break
fi
if [ "$i" -eq 30 ]; then
notify "FAIL" "PostgreSQL verify container failed to start"
exit 1
fi
sleep 2
done
# === Step 3: 還原備份 ===
echo "[$(date)] Restoring backup..."
docker exec -i "${VERIFY_CONTAINER}" pg_restore \
-U myapp \
-d myapp \
--no-owner \
--no-privileges \
--verbose \
< "${VERIFY_DIR}/backup.dump.gz"
RESTORE_EXIT=$?
if [ ${RESTORE_EXIT} -ne 0 ]; then
notify "FAIL" "pg_restore failed with exit code ${RESTORE_EXIT} for ${LATEST_BACKUP}"
exit 1
fi
echo "[$(date)] Restore completed"
# === Step 4: 驗證資料完整性 ===
echo "[$(date)] Verifying data integrity..."
# 檢查關鍵 table 是否存在且有資料
TABLES=("users" "orders" "products" "payments")
ERRORS=0
for TABLE in "${TABLES[@]}"; do
COUNT=$(docker exec "${VERIFY_CONTAINER}" psql -U myapp -d myapp -t \
-c "SELECT count(*) FROM ${TABLE};" 2>/dev/null | tr -d ' ')
if [ -z "${COUNT}" ] || [ "${COUNT}" -eq 0 ]; then
echo "[$(date)] WARNING: Table ${TABLE} is empty or missing!"
ERRORS=$((ERRORS + 1))
else
echo "[$(date)] OK: ${TABLE} has ${COUNT} rows"
fi
done
# 檢查備份年齡(不能太舊)
BACKUP_AGE_HOURS=$(( ( $(date +%s) - $(date -d "$(echo ${LATEST_BACKUP} | grep -oP '\d{8}_\d{6}' | head -1 | sed 's/_/ /' | sed 's/\(..\)\(..\)\(..\)$/\1:\2:\3/')" +%s) ) / 3600 ))
if [ "${BACKUP_AGE_HOURS}" -gt 48 ]; then
echo "[$(date)] WARNING: Latest backup is ${BACKUP_AGE_HOURS} hours old!"
ERRORS=$((ERRORS + 1))
fi
# === Step 5: 回報結果 ===
if [ ${ERRORS} -eq 0 ]; then
notify "success" "Backup verification passed. File: ${LATEST_BACKUP}, all ${#TABLES[@]} tables verified."
echo "[$(date)] Backup verification: PASSED"
else
notify "FAIL" "Backup verification found ${ERRORS} issue(s). File: ${LATEST_BACKUP}. Check logs."
echo "[$(date)] Backup verification: FAILED (${ERRORS} errors)"
exit 1
fiDisaster Recovery Plan(災難復原計畫)
備份只是 DR 計畫的一部分。完整的 DR 計畫還包括場景分類、處理流程(Runbook)、演練計畫、和通訊機制。
DR 場景分類
| 場景 | 嚴重等級 | RTO 目標 | RPO 目標 | 主要風險 |
|---|---|---|---|---|
| 硬體故障(硬碟壞、主機板故障) | High | 4 小時 | < 24 小時 | 服務停機、資料遺失 |
| 資料損毀(誤刪、程式 bug 寫壞資料) | Critical | 2 小時 | < 5 分鐘 (PITR) | 資料不一致、業務中斷 |
| 安全事件(勒索軟體、入侵) | Critical | 8 小時 | < 1 小時 | 資料外洩、全面失控 |
| 自然災害(火災、水災、地震) | Critical | 24 小時 | < 24 小時 | 整個機房不可用 |
| 人為操作失誤(刪錯資料庫、砍掉 volume) | High | 1 小時 | < 5 分鐘 (PITR) | 資料遺失 |
| 雲端供應商故障(Region outage) | Medium | 4 小時 | < 1 小時 | 服務不可用 |
Runbook 模板
每個 DR 場景都應該有對應的 Runbook,記錄在團隊的 wiki 或 Git repo 裡。以下是模板:
場景:PostgreSQL 資料庫損毀 / 不可恢復
- 確認問題:嘗試連線 PostgreSQL,檢查 logs,確認是硬碟故障還是資料損毀。
- 通知團隊:在 Slack incidents 頻道宣告事件,指派 Incident Commander。
- 評估影響:哪些服務依賴這個資料庫?影響多少用戶?
- 選擇復原策略:
- 如果需要精確恢復(誤刪資料)→ 使用 PITR
- 如果資料庫整個損毀 → 使用最近的 pg_dump 還原
- 如果本地備份也損毀 → 從異地 S3 下載
- 執行復原:按照上面的還原步驟執行,全程在 Slack 回報進度。
- 驗證:還原後執行資料完整性檢查,確認關鍵 table 的 row count、最新一筆資料的時間。
- 復原後檢討:寫 Post-Mortem,找出根因,更新 Runbook。
DR 演練流程(每季一次)
DR 演練不是可有可無的活動。如果你的備份沒有經過還原測試,那它就只是一堆佔用空間的檔案,你不知道它能不能用。
演練流程:
- 演練前(T-7 天):公告演練時間、範圍、參與人員。準備一台隔離的測試環境。
- Day 1 — 資料庫還原演練:
- 從 MinIO/S3 下載最新的 PostgreSQL 備份
- 在測試環境還原
- 驗證資料完整性(row count、關鍵資料抽樣比對)
- 記錄還原時間(實際 RTO)
- Day 2 — 全服務重建演練:
- 模擬整台 Host 損毀
- 從 Git 拉設定檔,從備份還原 volumes
- 啟動所有服務,驗證功能
- 記錄完整重建時間
- Day 3 — PITR 演練:
- 在測試環境做一些寫入操作
- 模擬誤操作(DELETE 一張 table 的資料)
- 用 PITR 恢復到誤操作前
- 驗證恢復精確度
- 演練後:寫演練報告,紀錄實際 RTO/RPO 與目標的差距,更新 Runbook。
事件通訊計畫
災難發生時最怕的是混亂:多人同時操作互相衝突、沒人知道目前狀態、客戶沒收到通知。
- Incident Commander(IC):一個人主導整個事件處理,負責協調和決策。其他人執行 IC 的指令,不要各自為政。
- 通訊頻道:在 Slack 開一個臨時的 incident channel,所有操作和進度在裡面回報。不要用 DM,要讓所有相關人看到。
- 客戶通知:如果服務中斷超過 15 分鐘,透過 Status Page 或 Email 通知客戶目前狀態和預計恢復時間。不要等到完全恢復才通知,客戶寧可知道你在處理。
- Post-Mortem:事件結束後 48 小時內寫 Post-Mortem,重點在於找到 systemic issue,而不是指責個人。
常見問題與風險
-
備份從來沒有測試過:這是最常見也最致命的問題。你以為備份在跑,但可能腳本早就壞了、備份檔案是空的、或者還原步驟根本跑不通。解法:每週自動執行備份驗證腳本(上面的
verify-backup.sh),失敗就發告警到 Slack。每季做一次完整的 DR 演練。 -
備份和正式資料在同一個故障域:備份放在和 production 同一台 Host 的不同目錄?那硬碟壞了兩個一起走。備份放在同一個機房的 NAS?火災或勒索軟體一樣全滅。一定要有至少一份異地備份,而且異地不能只是「同一個雲端供應商的不同 AZ」,因為帳號被入侵時整個帳號下的所有資源都可能被摧毀。
-
RPO 不符合業務需求:每天備份一次但業務不能容忍遺失超過 1 小時的資料?那備份頻率和 RPO 目標之間有 gap。解法:重新評估 RPO,如果需要 RPO < 1 小時就必須上 WAL archiving;如果需要 RPO = 0 就需要 streaming replication。RPO 越小成本越高,需要和業務部門一起決定。
-
備份佔用空間無限增長:備份如果沒有 retention policy(保留策略),空間會持續增長直到磁碟滿。MinIO 的 lifecycle rule 或腳本裡的
mc rm --older-than都能解決,但要設定好並監控。建議在 Prometheus 加入 MinIO bucket size 的 metric 和告警。 -
還原時間超過 RTO:50GB 的資料庫用
pg_restore可能要跑 2-3 小時,如果你的 RTO 是 1 小時就來不及。解法:用pg_restore --jobs=4並行還原、使用 physical backup(pg_basebackup)代替 logical backup、或是建立 hot standby 做即時切換。定期在演練中測量實際還原時間,確保在 RTO 之內。 -
Secret 沒有備份:.env 檔案裡的密碼、API key、TLS 憑證私鑰。這些東西不能進 Git,但如果遺失了服務也起不來。需要有獨立的 secret 備份機制(加密後存到異地),或使用 HashiCorp Vault 之類的 secret management 工具,參考 Secrets & Config。
延伸閱讀
- Database PostgreSQL — PostgreSQL 部署基線、連線池、效能調校
- Storage Management — MinIO 物件儲存、資料分層、lifecycle rule
- Alerts & ChatOps — 告警策略與通知整合,備份失敗告警的設定