cover

備份策略與災難復原:「不會發生在我們身上」是最危險的假設

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 原則的完整實現。

核心概念

  1. RTO(Recovery Time Objective)— 你能容忍多久的停機? RTO 定義的是從災難發生到服務完全恢復的最大可接受時間。如果你的 RTO 是 4 小時,代表資料庫掛掉後,你必須在 4 小時內讓服務重新上線。RTO 越短,需要的基礎設施成本越高(需要 hot standby、自動 failover)。對於大部分中小型團隊,RTO 設定在 1-4 小時是合理的起點。關鍵是你要知道這個數字,而不是災難發生時才開始算。

  2. RPO(Recovery Point Objective)— 你能容忍丟多少資料? RPO 定義的是災難發生時,最多可以接受遺失多長時間的資料。如果你每天凌晨 3 點做一次 pg_dump,你的 RPO 就是 24 小時,因為最壞情況下你會遺失前一天 3 點到災難發生這段時間的所有寫入。如果業務要求 RPO < 1 小時,就需要用 WAL archiving 做持續備份,甚至 streaming replication。RPO 和備份頻率直接相關:RPO = 備份間隔 + 備份傳輸時間。

  3. 3-2-1 備份原則:這是備份策略的黃金法則。3 份資料:原始資料 + 2 份備份。2 種不同介質:例如 SSD + 物件儲存,或磁碟 + 磁帶。1 份異地存放:至少一份備份放在不同的地理位置(不同機房、不同雲端區域、甚至不同國家)。為什麼要異地?因為火災、水災、勒索軟體攻擊可以同時摧毀同一個機房裡的所有資料,包括你以為安全的備份。CloudNordic 的案例就是慘痛的教訓:備份和正式資料在同一個網路裡,勒索軟體一次全部加密。

  4. 備份類型:Full / Incremental / Differential

    • Full Backup(完整備份):每次備份全部資料。優點是還原最簡單(只需要一份檔案),缺點是每次備份量大、耗時。
    • Incremental Backup(增量備份):只備份上次備份(不管是 Full 或 Incremental)之後變更的資料。備份量最小,但還原時需要 Full + 所有後續的 Incremental,中間任何一份損毀就斷鏈。
    • Differential Backup(差異備份):只備份上次 Full Backup 之後變更的資料。備份量介於兩者之間,還原只需要 Full + 最新的 Differential。
    • 常見策略:每週日做 Full,週一到週六做 Incremental 或 Differential。
  5. Hot / Warm / Cold 備份

    • Hot Backup:在資料庫正常運行中進行的備份,不影響讀寫。PostgreSQL 的 pg_dump 和 WAL archiving 都是 Hot Backup。這是生產環境的標準做法。
    • Warm Backup:資料庫處於唯讀模式時進行的備份。適合在維護時段使用,可以確保資料一致性。
    • Cold Backup:停止資料庫後複製資料檔案。最簡單但需要停機,只適合非關鍵系統或排定的維護窗口。
  6. 資料庫備份策略

    • 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)。
  7. 檔案 / Volume 備份:Docker volumes(應用資料、上傳檔案)、MinIO 資料目錄、設定檔(docker-compose.yml、nginx.conf、.env)都需要備份。最直接的方式是打 tar.gz 壓縮包然後上傳。設定檔應該存在 Git 裡(Infrastructure as Code),但 secrets(密碼、API key)不能進 Git,需要另外備份,參考 Secrets & Config

  8. 應用狀態備份

    • 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-backups bucket,同時用 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.sql

Docker 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
fi

Disaster Recovery Plan(災難復原計畫)

備份只是 DR 計畫的一部分。完整的 DR 計畫還包括場景分類、處理流程(Runbook)、演練計畫、和通訊機制。

DR 場景分類

場景嚴重等級RTO 目標RPO 目標主要風險
硬體故障(硬碟壞、主機板故障)High4 小時< 24 小時服務停機、資料遺失
資料損毀(誤刪、程式 bug 寫壞資料)Critical2 小時< 5 分鐘 (PITR)資料不一致、業務中斷
安全事件(勒索軟體、入侵)Critical8 小時< 1 小時資料外洩、全面失控
自然災害(火災、水災、地震)Critical24 小時< 24 小時整個機房不可用
人為操作失誤(刪錯資料庫、砍掉 volume)High1 小時< 5 分鐘 (PITR)資料遺失
雲端供應商故障(Region outage)Medium4 小時< 1 小時服務不可用

Runbook 模板

每個 DR 場景都應該有對應的 Runbook,記錄在團隊的 wiki 或 Git repo 裡。以下是模板:

場景:PostgreSQL 資料庫損毀 / 不可恢復

  1. 確認問題:嘗試連線 PostgreSQL,檢查 logs,確認是硬碟故障還是資料損毀。
  2. 通知團隊:在 Slack incidents 頻道宣告事件,指派 Incident Commander。
  3. 評估影響:哪些服務依賴這個資料庫?影響多少用戶?
  4. 選擇復原策略
    • 如果需要精確恢復(誤刪資料)→ 使用 PITR
    • 如果資料庫整個損毀 → 使用最近的 pg_dump 還原
    • 如果本地備份也損毀 → 從異地 S3 下載
  5. 執行復原:按照上面的還原步驟執行,全程在 Slack 回報進度。
  6. 驗證:還原後執行資料完整性檢查,確認關鍵 table 的 row count、最新一筆資料的時間。
  7. 復原後檢討:寫 Post-Mortem,找出根因,更新 Runbook。

DR 演練流程(每季一次)

DR 演練不是可有可無的活動。如果你的備份沒有經過還原測試,那它就只是一堆佔用空間的檔案,你不知道它能不能用。

演練流程

  1. 演練前(T-7 天):公告演練時間、範圍、參與人員。準備一台隔離的測試環境。
  2. Day 1 — 資料庫還原演練
    • 從 MinIO/S3 下載最新的 PostgreSQL 備份
    • 在測試環境還原
    • 驗證資料完整性(row count、關鍵資料抽樣比對)
    • 記錄還原時間(實際 RTO)
  3. Day 2 — 全服務重建演練
    • 模擬整台 Host 損毀
    • 從 Git 拉設定檔,從備份還原 volumes
    • 啟動所有服務,驗證功能
    • 記錄完整重建時間
  4. Day 3 — PITR 演練
    • 在測試環境做一些寫入操作
    • 模擬誤操作(DELETE 一張 table 的資料)
    • 用 PITR 恢復到誤操作前
    • 驗證恢復精確度
  5. 演練後:寫演練報告,紀錄實際 RTO/RPO 與目標的差距,更新 Runbook。

事件通訊計畫

災難發生時最怕的是混亂:多人同時操作互相衝突、沒人知道目前狀態、客戶沒收到通知。

  • Incident Commander(IC):一個人主導整個事件處理,負責協調和決策。其他人執行 IC 的指令,不要各自為政。
  • 通訊頻道:在 Slack 開一個臨時的 incident channel,所有操作和進度在裡面回報。不要用 DM,要讓所有相關人看到。
  • 客戶通知:如果服務中斷超過 15 分鐘,透過 Status Page 或 Email 通知客戶目前狀態和預計恢復時間。不要等到完全恢復才通知,客戶寧可知道你在處理。
  • Post-Mortem:事件結束後 48 小時內寫 Post-Mortem,重點在於找到 systemic issue,而不是指責個人。

常見問題與風險

  1. 備份從來沒有測試過:這是最常見也最致命的問題。你以為備份在跑,但可能腳本早就壞了、備份檔案是空的、或者還原步驟根本跑不通。解法:每週自動執行備份驗證腳本(上面的 verify-backup.sh),失敗就發告警到 Slack。每季做一次完整的 DR 演練。

  2. 備份和正式資料在同一個故障域:備份放在和 production 同一台 Host 的不同目錄?那硬碟壞了兩個一起走。備份放在同一個機房的 NAS?火災或勒索軟體一樣全滅。一定要有至少一份異地備份,而且異地不能只是「同一個雲端供應商的不同 AZ」,因為帳號被入侵時整個帳號下的所有資源都可能被摧毀。

  3. RPO 不符合業務需求:每天備份一次但業務不能容忍遺失超過 1 小時的資料?那備份頻率和 RPO 目標之間有 gap。解法:重新評估 RPO,如果需要 RPO < 1 小時就必須上 WAL archiving;如果需要 RPO = 0 就需要 streaming replication。RPO 越小成本越高,需要和業務部門一起決定。

  4. 備份佔用空間無限增長:備份如果沒有 retention policy(保留策略),空間會持續增長直到磁碟滿。MinIO 的 lifecycle rule 或腳本裡的 mc rm --older-than 都能解決,但要設定好並監控。建議在 Prometheus 加入 MinIO bucket size 的 metric 和告警。

  5. 還原時間超過 RTO:50GB 的資料庫用 pg_restore 可能要跑 2-3 小時,如果你的 RTO 是 1 小時就來不及。解法:用 pg_restore --jobs=4 並行還原、使用 physical backup(pg_basebackup)代替 logical backup、或是建立 hot standby 做即時切換。定期在演練中測量實際還原時間,確保在 RTO 之內。

  6. Secret 沒有備份:.env 檔案裡的密碼、API key、TLS 憑證私鑰。這些東西不能進 Git,但如果遺失了服務也起不來。需要有獨立的 secret 備份機制(加密後存到異地),或使用 HashiCorp Vault 之類的 secret management 工具,參考 Secrets & Config

延伸閱讀