Bỏ qua để đến nội dung

Backup & restore

Quy trình Phase S thay thế một phần hướng dẫn backup cũ của Task F.5; xem Task F.5, runbook khóa age và runbook khôi phục thảm họa.

Cài age, rsync (có rrsync) và Python 3 trên VPS. Chỉ cài/cấu hình rclone nếu bật OFFSITE_REMOTE; mặc định backup ở trên VPS và người vận hành tự kéo về máy cá nhân đã mã hóa ổ đĩa. Backup mã hóa age được giữ 30 ngày (BACKUP_RETENTION_DAYS).

/opt/backups/YYYYMMDD/
├── db_<timestamp>.dump.age
├── db_<timestamp>.manifest.json
├── media_<timestamp>.tar.age
├── media_<timestamp>.manifest.json
├── config_<timestamp>.tar.age
├── config_<timestamp>.manifest.json
├── SHA256SUMS
└── predeploy/<timestamp>/ # bundle trước deploy/rollback, nếu có

DB, ảnh và cấu hình được mã hóa trước khi ghi ra file. Config archive chứa .env, credentials/, docker-compose.prod.yml, nginx/ và crontab.txt; manifest chỉ ghi tên/hash, image tags và metadata, không ghi nội dung secrets. Image Docker được build/export riêng theo tag trong manifest. Let’s Encrypt certificate không nằm trong backup; có thể xin lại theo §4.

Ba job hằng ngày chạy lúc 03:30 (DB), 03:45 (media) và 04:00 (config). Job kiểm tra lúc 09:00 cảnh báo khi backup quá hạn, integrity sai, credential bị thiếu, máy operator chưa kéo hoặc dung lượng thấp; xem §10.

scripts/deploy_local.sh tự tạo đủ bộ backup DB, media và config trước khi đổi image; nếu backup lỗi, deploy dừng trước khi khởi động image mới. Restart với đúng hai tag đang chạy không tạo backup. Lần cài mới không có container Compose nào đang chạy và chưa có volume PostgreSQL được bỏ qua backup tự động. Nếu còn volume DB nhưng container đang dừng, cần khởi động DB để backup có thể chạy. Khi cần bỏ qua backup cho một lần thay đổi có lý do vận hành, dùng ./scripts/deploy_local.sh --skip-backup "<lý do>"; lý do được ghi vào $BACKUP_DIR/predeploy_skips.log. Rollback image cũng tạo bundle predeploy trước khi sửa compose và khởi động image đích. Mỗi bundle nằm tại YYYYMMDD/predeploy/<timestamp>/; lưu lệnh restore.sh in trong log deploy. Các bundle cũ hơn 30 ngày được dọn, ngoại trừ ba bundle predeploy mới nhất.

Terminal window
# Backup thủ công hằng ngày (cron chạy tự động: DB 03:30, ảnh 03:45, cấu hình 04:00)
./scripts/backup_database.sh
./scripts/backup_media.sh
./scripts/backup_config.sh
# Tạo riêng một bundle predeploy thủ công (các script cùng chia sẻ timestamp)
export BACKUP_PREDEPLOY_TIMESTAMP="$(date -u +%Y%m%d_%H%M%S)"
export BACKUP_TARGET_IMAGES='{"backend":"admin_portal_backend:vX","frontend":"admin_portal_frontend:vY"}'
./scripts/backup_database.sh --predeploy
./scripts/backup_media.sh --predeploy
./scripts/backup_config.sh --predeploy
# Kiểm tra nhanh một bộ đã kéo về; chỉ tạo Postgres container dùng một lần
export BACKUP_AGE_IDENTITY_FILE=/path/to/age-identity.txt
./scripts/restore_drill.sh ~/admin_portal_backups/20261009
# Restore qua Docker SSH context (operator chạy; identity/archive ở local)
BACKUP_ROOT="$HOME/admin_portal_backups"
BACKUP_DAY="20261009"
export BACKUP_AGE_IDENTITY_FILE="$HOME/keys/age-portal-ops.txt"
(cd "$BACKUP_ROOT/$BACKUP_DAY" && sha256sum -c SHA256SUMS)
CONFIG_ARCHIVE="$(find "$BACKUP_ROOT/$BACKUP_DAY" -maxdepth 1 -type f \
-name 'config_*.tar.age' -print | sort | tail -n 1)"
umask 077
WORK_CONFIG="$(mktemp -d)"
chmod 700 "$WORK_CONFIG"
age -d -i "$BACKUP_AGE_IDENTITY_FILE" "$CONFIG_ARCHIVE" \
| tar -xpf - -C "$WORK_CONFIG"
chmod 600 "$WORK_CONFIG/.env"
# Add to ~/.ssh/config using the restore account approved for Docker and lock access.
# Keep an optional Port here, never in the ssh:// URI.
# Host portal-prod-restore
# HostName <VPS-IP-or-domain>
# User <restore-user>
# Port <optional-SSH-port>
# IdentityFile ~/.ssh/portal-prod-restore
# IdentitiesOnly yes
RESTORE_SSH_USER='<restore-user>'
docker context create portal-prod-restore \
--docker "host=ssh://${RESTORE_SSH_USER}@portal-prod-restore"
export DOCKER_CONTEXT=portal-prod-restore
unset DOCKER_HOST
export RESTORE_REMOTE_LOCK_PATH=/opt/backups/.lock
export RESTORE_ENV_FILE="$WORK_CONFIG/.env"
export RESTORE_COMPOSE_FILE="$WORK_CONFIG/docker-compose.prod.yml"
export RESTORE_PROJECT_NAME='<live Compose project name>'
# Media-only path was exercised by a disposable Docker SSH integration test.
./scripts/restore.sh "$BACKUP_ROOT/$BACKUP_DAY" --media-only --media-replace
# This DB command is for a dedicated DR VM after the blank-VM restore flow has
# been validated; that full flow is still pending. At the prompt, review the
# summary and type exactly POSTGRES_DB.
export POSTGRES_USER='<verified database user>'
export POSTGRES_DB='<verified target database>'
./scripts/restore.sh "$BACKUP_ROOT/$BACKUP_DAY" --db-only
# Remove decrypted config after restore and post-restore checks finish.
# chmod -R u+rwX "$WORK_CONFIG" && rm -rf -- "$WORK_CONFIG"

Cảnh báo: restore.sh thay toàn bộ database được chọn bằng snapshot trong backup, phục hồi ảnh (mặc định gộp; --media-replace sẽ xóa volume ảnh trước), đặt backend image theo manifest, chạy migration/replay xóa người chơi và bật lại backend/frontend. Xác nhận đường dẫn backup và tên database tại prompt. Không thử nghiệm restore trên production. Chỉ khôi phục production khi incident đã được xác nhận, người chịu trách nhiệm đã quyết định thời điểm và chấp nhận dữ liệu sẽ mất, theo runbook DR.

Theo S-D8, private age identity không bao giờ đặt trên VPS; xem runbook khóa Phase S.3. Ưu tiên DOCKER_CONTEXT; có thể dùng DOCKER_HOST=ssh://user@alias qua SSH config alias. Đặt port/identity trong ~/.ssh/config, không gắn port vào URI. RESTORE_REMOTE_LOCK_PATH phải đúng BACKUP_DIR/.lock trên VPS (mặc định /opt/backups/.lock); SSH restore user cần quyền Docker và quyền mở/ghi lock bằng flock. Restore từ chối chạy khi thiếu lock, SSH lỗi, lock đang bị giữ hoặc endpoint TCP/không hỗ trợ. DOCKER_CONTEXT được ưu tiên hơn DOCKER_HOST; chỉ local unix:///... bỏ qua remote lock.

Không source .env đã giải mã. restore.sh chỉ parse whitelist dotenv literal; Compose tự đọc file để nội suy. RESTORE_TRUSTED_ENV=1 chỉ dành cho shell dotenv tin cậy khi restore cục bộ và luôn bị bỏ qua với engine remote. Identity và archive mã hóa luôn ở máy operator. Media-only SSH integration đã xác minh context precedence, project/volume, locality identity/archive, lock contention và release trên Docker fixture. Đây chưa phải bằng chứng cho DB/config full restore trên VM trắng, VPS thật hoặc RTO. Xem thêm runbook DR.

restore_drill.sh là kiểm tra nhanh an toàn: từ thư mục ngày (hoặc thư mục gốc nhiều ngày), chọn bộ DB/media/config mới nhất cùng ngày, kiểm SHA256SUMS, giải mã media/config để so file list/hash với manifest, restore DB vào Postgres 16 tạm và so toàn bộ row_counts cùng Alembic revision. Script xóa container và file tạm khi xong, không dùng Docker Compose app hay volume hiện có. Chạy sau mỗi lần kéo nếu thuận tiện; tối thiểu mỗi tháng.

Diễn tập đầy đủ trên một VM/VPS trắng là bước riêng bắt buộc trước go-live và ít nhất mỗi quý. Làm theo runbook DR và ghi thời gian/bằng chứng vào drill log. DB/ config restore trên VM trắng, live VPS checks và RTO hiện chưa thực hiện; tài liệu/script này không phải bằng chứng production đã dựng lại thành công.

Sau restore thật, đối soát giao dịch IAP bị ảnh hưởng trong khoảng backup đến incident bằng nguồn xác nhận của store; xem quy trình đối soát trong backend và lịch sử đơn Google Play/App Store. Ghi incident, thời gian phục hồi và thông báo stakeholder theo SLA đã được thống nhất.

Script làm theo thứ tự dưới đây và dừng ở lỗi đầu tiên:

  1. Chọn bản DB mới nhất trong thư mục (phải có manifest và checksum) và bản ảnh mới nhất cùng ngày UTC. Kiểm SHA256 của đúng các file đó và kiểm image backend/frontend ghi trong manifest đã có trên máy.
  2. In thông tin bản backup và bắt gõ lại tên database.
  3. Lấy lock, giải mã hết file và kiểm cấu trúc (pg_restore --list, tar -t). Tới đây chưa đụng vào dữ liệu.
  4. Dừng backend và frontend, rồi lưu danh sách người chơi đã xóa (status = 'erased') từ DB hiện tại.
  5. Xóa và tạo lại DB, chạy pg_restore, rồi so số dòng từng bảng và revision Alembic với manifest. Lệch thì dừng, app vẫn tắt.
  6. Restore ảnh từ cùng thư mục.
  7. Sinh một file override tạm đặt tag image backend/frontend theo manifest (docker-compose.prod.yml không bị sửa). Trên container tạm, chạy alembic upgrade head rồi xóa lại những người chơi trong danh sách ở bước 4 (audit player_erase_replay).
  8. up -d backend và frontend, chờ /health, in thời gian từng bước.
Tùy chọnÝ nghĩa
--db-only / --media-onlyChỉ restore một phần
--db-file <tên> / --media-file <tên>Ghim một file cụ thể trong thư mục thay vì bản mới nhất
--media-replaceXóa sạch volume ảnh trước khi giải nén. Mặc định là gộp: file trùng tên bị ghi đè, file khác giữ nguyên
--backend-image <tag> / --frontend-image <tag>Dùng tag khác với manifest (vd bản backup không ghi được tag)
--project-name <tên>Tên Compose project của stack đích (mặc định: tên thư mục chứa compose file)
--erased-from <file.csv>Thêm danh sách người chơi đã xóa từ nguồn khác (xem dưới)
--no-erasure-replayBỏ bước xóa lại người chơi. Chỉ dùng khi diễn tập trên DB tạm

Khi restore dừng giữa chừng:

  • Dừng trước bước 4: chưa có gì thay đổi.
  • Lỗi hoặc Ctrl-C ở bước 5: DB đang dở dang, app vẫn tắt. Chạy lại đúng lệnh cũ.
  • Migration hoặc bước xóa lại người chơi lỗi (bước 7): DB đã đúng bản backup nhưng app vẫn tắt. Danh sách người chơi cần xóa và tag image của bản backup được giữ ở restore_erasures_<ts>.csv và restore_images_<ts>.override.yml trong thư mục cài đặt (quyền 0600), và script in sẵn lệnh để chạy riêng phần còn lại rồi up. Xóa hai file này sau khi xong.
  • Health check không qua trong 60 giây: app bị dừng lại, xem docker compose -f docker-compose.prod.yml logs backend.

Không đọc được DB hiện tại (VPS mới, DB hỏng): script ghi rõ là không có danh sách người chơi đã xóa nào mới hơn bản backup. Nếu còn một bản backup mới hơn vẫn giải mã được (vd phải restore bản cũ vì nghi bản mới có vấn đề), lấy danh sách từ bản đó:

Terminal window
./scripts/export_erased_players.sh /opt/backups/<ngày-mới-hơn> /root/erased.csv
./scripts/restore.sh /opt/backups/<ngày-cũ> --erased-from /root/erased.csv
rm -f /root/erased.csv

export_erased_players.sh restore bản mới hơn vào một Postgres tạm (Docker), không đụng tới DB đang chạy.

Backup kiểu cũ (*.sql.gz.age ở gốc /opt/backups, trước Phase S) vẫn restore được. Ghim cả hai file của cùng một ngày:

Terminal window
./scripts/restore.sh /opt/backups \
--db-file admin_portal_<ts>.sql.gz.age --media-file admin_portal_media_<ts>.tar.age

Bản cũ không có manifest nên script không so được số dòng, và image lấy theo docker-compose.prod.yml hiện tại (hoặc --backend-image). restore_database.sh và restore_media.sh vẫn chạy được nhưng chỉ chuyển tiếp sang restore.sh --db-only / --media-only.

Luôn restore DB và ảnh của cùng một ngày. restore.sh tự chặn khi DB và ảnh lệch ngày. Ảnh mới hơn DB chỉ để lại file thừa, còn ảnh cũ hơn DB có thể làm thiếu icon.

Tag image của bản backup chỉ nằm trong file override của lần restore đó. Muốn các lần docker compose up sau vẫn giữ tag này, sửa tag trong docker-compose.prod.yml cho khớp (hoặc deploy bản mới như §7).

9.3. Kéo backup về máy người vận hành (Linux/macOS; Windows qua WSL)

Phần tiêu đề “9.3. Kéo backup về máy người vận hành (Linux/macOS; Windows qua WSL)”

Backup chỉ nằm trên VPS thì mất VPS là mất luôn backup. Người vận hành phải kéo bản sao về máy mình (S-D1); check_backups.sh cảnh báo khi quá 3 ngày chưa kéo (BACKUP_MAX_PULL_AGE_DAYS). Kết nối là một chiều, chỉ đọc: key kéo backup không mở được shell, không ghi/xóa được gì trên VPS và không đọc được .env. Ví dụ dưới dùng giá trị production (VPS 20.51.243.159, user deploy huynqn, thư mục cài đặt /home/huynqn/web/web-portal).

Bước 1 — Trên máy bạn: tạo SSH key riêng cho việc kéo backup. Không dùng lại key deploy. Để trống passphrase nếu muốn lên lịch tự động (bước 6); key này chỉ đọc được backup đã mã hóa.

Terminal window
sudo apt install rsync # macOS: có sẵn; nên cài thêm age để kiểm tra (bước 5)
ssh-keygen -t ed25519 -f ~/.ssh/admin-portal-backup-pull -C "backup-pull $(hostname)"

Bước 2 — Trên VPS: tạo user backup-pull chỉ đọc. Chép public key lên rồi chạy script một lần bằng sudo (VPS cần gói rsync, đã có rrsync):

Terminal window
scp ~/.ssh/admin-portal-backup-pull.pub huynqn@20.51.243.159:/tmp/
ssh huynqn@20.51.243.159 'cd /home/huynqn/web/web-portal \
&& sudo ./scripts/setup_backup_pull_user.sh /tmp/admin-portal-backup-pull.pub \
&& rm /tmp/admin-portal-backup-pull.pub'

Script tạo nhóm và user backup-pull (khóa mật khẩu), ghi key với lệnh bắt buộc rrsync -ro /opt/backups. Chủ của /opt/backups (ở đây là huynqn, user chạy cron backup) được giữ nguyên và thêm vào nhóm backup-pull; thư mục được đặt setgid nên mọi backup tạo sau đều tự mang nhóm này và user kéo đọc được. Chạy lại script khi đổi key (key mới thay key cũ).

Bước 3 — Trên máy bạn: thêm alias SSH vào ~/.ssh/config:

Host portal-backup
HostName 20.51.243.159
User backup-pull
IdentityFile ~/.ssh/admin-portal-backup-pull
IdentitiesOnly yes
BatchMode yes

Kiểm tra: lệnh đầu phải liệt kê các thư mục ngày, lệnh sau phải bị từ chối (không có shell):

Terminal window
rsync --list-only portal-backup:/
ssh portal-backup # phải báo lỗi, không vào được shell

Bước 4 — Kéo backup. Chạy từ bản checkout repo trên máy bạn (pull_backups.sh không nằm trong gói deploy). Nên đặt thư mục đích trên ổ đã mã hóa (VeraCrypt/LUKS/FileVault, S-D7):

Terminal window
./scripts/pull_backups.sh --dest /đường/dẫn/ổ-mã-hóa/admin_portal_backups
./scripts/pull_backups.sh --dest /đường/dẫn/ổ-mã-hóa/admin_portal_backups --dry-run # chỉ xem trước

Mặc định (--host portal-backup, --dest ~/admin_portal_backups). Mỗi lần chạy chỉ tải file mới (file đã nhận được giữ nguyên), tải tiếp file dở dang, xác minh SHA256SUMS rồi mới nhận một ngày, không xóa gì trên VPS, giữ bản local 30 ngày và luôn giữ bộ đầy đủ mới nhất. Bản hỏng được cất riêng thành <ngày>.corrupt; lần kéo thành công ghi thời điểm vào .last_pull.

Bước 5 — Kiểm tra một bản đã kéo giải mã và restore được (cần Docker và private key age; file khóa không được nằm trong thư mục backup):

Terminal window
BACKUP_AGE_IDENTITY_FILE=~/age-identity.txt \
./scripts/restore_drill.sh /đường/dẫn/ổ-mã-hóa/admin_portal_backups/<YYYYMMDD>

Script restore DB vào một Postgres tạm, so số dòng với manifest, kiểm danh sách file của ảnh và cấu hình, rồi xóa container tạm; không đụng hệ thống thật. Làm sau lần kéo đầu tiên và tối thiểu mỗi tháng.

Bước 6 — Lên lịch kéo tự động trên máy bạn (máy phải đang bật lúc chạy). Backup trên VPS chạy 03:30–04:00 UTC (10:30–11:00 giờ Việt Nam), nên kéo sau 11:00. Ví dụ với crontab -e, 12:00 các ngày làm việc:

0 12 * * 1-5 cd /đường/dẫn/repo && ./scripts/pull_backups.sh --dest /đường/dẫn/ổ-mã-hóa/admin_portal_backups >> "$HOME/admin_portal_pull.log" 2>&1

Nếu thư mục đích nằm trên ổ VeraCrypt chỉ mở khi cần, lịch tự động sẽ lỗi lúc ổ chưa mở; khi đó kéo tay mỗi ngày làm việc.

Bước 7 — Xác nhận trên VPS. Sau lần kéo đầu, check_backups.sh hết cảnh báo operator pull (nó đọc log SSH của VPS, vì key kéo không có quyền ghi):

Terminal window
ssh huynqn@20.51.243.159 'cd /home/huynqn/web/web-portal && ./scripts/check_backups.sh'
# mong đợi: OK: operator pull — last accepted backup-pull SSH key at ...
Triệu chứngNguyên nhânXử lý
Permission denied (publickey)Sai key/alias, hoặc chưa chạy bước 2 với đúng file .pubKiểm IdentityFile trong alias; chạy lại bước 2
rsync báo Permission denied / mã lỗi 23 trên file mớiBackup tạo ra không mang nhóm backup-pull (VPS chưa có bản script mới, hoặc user cron chưa thuộc nhóm)ls -l /opt/backups/<ngày> trên VPS phải thấy nhóm backup-pull; chạy lại bước 2
check_backups vẫn báo chưa kéo dù đã kéoUser cron không đọc được journal hệ thốngThêm user đó vào nhóm adm hoặc systemd-journal
Có thư mục <ngày>.corruptBản trên VPS không khớp checksum lúc kéoKhông xóa bản tốt đã có; báo người vận hành kiểm tra backup ngày đó trên VPS
SSH bị từ chối dù key đúngsshd_config có AllowUsers/AllowGroups không gồm backup-pull (script đã cảnh báo lúc chạy)Thêm backup-pull vào danh sách rồi sudo systemctl reload ssh

restore_database.sh và restore_media.sh là wrapper tương thích cũ; ưu tiên restore.sh. Backup DB và media khi restore phải cùng ngày UTC. Cách xử lý từng tình huống (mất VPS, deploy lỗi, mất volume ảnh, backup hỏng hoặc mất khóa) nằm trong runbook DR.


Áp dụng cho Portal v1.2.2