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).
9.1. Cấu trúc backup
Phần tiêu đề “9.1. Cấu trúc backup”/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.
# 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ầnexport 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 077WORK_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 yesRESTORE_SSH_USER='<restore-user>'docker context create portal-prod-restore \ --docker "host=ssh://${RESTORE_SSH_USER}@portal-prod-restore"export DOCKER_CONTEXT=portal-prod-restoreunset DOCKER_HOSTexport RESTORE_REMOTE_LOCK_PATH=/opt/backups/.lockexport 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.
9.2. restore.sh làm gì
Phần tiêu đề “9.2. restore.sh làm gì”Script làm theo thứ tự dưới đây và dừng ở lỗi đầu tiên:
- 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.
- In thông tin bản backup và bắt gõ lại tên database.
- 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. - 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. - 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. - Restore ảnh từ cùng thư mục.
- Sinh một file override tạm đặt tag image backend/frontend theo manifest
(
docker-compose.prod.ymlkhông bị sửa). Trên container tạm, chạyalembic upgrade headrồi xóa lại những người chơi trong danh sách ở bước 4 (auditplayer_erase_replay). up -dbackend và frontend, chờ/health, in thời gian từng bước.
| Tùy chọn | Ý nghĩa |
|---|---|
--db-only / --media-only | Chỉ 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-replace | Xó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-replay | Bỏ 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>.csvvàrestore_images_<ts>.override.ymltrong 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ồiup. 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 đó:
./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.csvrm -f /root/erased.csvexport_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:
./scripts/restore.sh /opt/backups \ --db-file admin_portal_<ts>.sql.gz.age --media-file admin_portal_media_<ts>.tar.ageBả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.
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):
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 yesKiể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):
rsync --list-only portal-backup:/ssh portal-backup # phải báo lỗi, không vào được shellBướ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):
./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ướcMặ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):
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>&1Nế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):
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ứng | Nguyên nhân | Xử lý |
|---|---|---|
Permission denied (publickey) | Sai key/alias, hoặc chưa chạy bước 2 với đúng file .pub | Kiểm IdentityFile trong alias; chạy lại bước 2 |
rsync báo Permission denied / mã lỗi 23 trên file mới | Backup 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éo | User cron không đọc được journal hệ thống | Thêm user đó vào nhóm adm hoặc systemd-journal |
Có thư mục <ngày>.corrupt | Bản trên VPS không khớp checksum lúc kéo | Khô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 đúng | sshd_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