ดึง Backup ออกนอกห้อง Server ด้วย Pull Architecture + rclone/rsync
Part 1 ผมจบที่ backup 2 ชั้นใน server room เดียวกัน แล้วก็ยังนอนไม่หลับ เพราะของที่วางใกล้กันเกินไปมันไม่ใช่ disaster recovery มันคือ convenience copy. ตอนนี้คือเรื่องดึง backup ออกไปนอกห้องให้ครบ 3-2-1.
ตอนแรกผมตั้งใจจะจบที่ Part 1 — ออกแบบ backup ของ DirectAdmin ใหม่ จากเดิมที่พึ่ง FTP มาเป็น NFS บวกกับ ACL trick แล้ววางไว้สองชั้น คือ DA daily backup กับ weekly VM snapshot. แต่พอนั่งคิดดี ๆ ก็ยังไม่สบายใจอยู่ดี เพราะทั้งสองชั้นมันอยู่ใน server room เดียวกัน hardware ก็วางใกล้กัน ใช้ปลั๊กไฟชุดเดียวกันด้วยซ้ำ
ลองนึกถึงกรณีที่ซวยจริง ๆ — ไฟไหม้ห้อง server, ไฟดับยาวจน battery สำรองหมด, ของถูกขโมยไปพร้อมกันทั้งคู่, หรือ ransomware เดินเรื่องลบสองเครื่องพร้อมกัน. ในกรณีพวกนี้ผมเสียของทั้งคู่ในคราวเดียว. backup ที่วางใกล้ของจริงเกินไป มันไม่ใช่ disaster recovery — มันคือสำเนาที่เอาไว้กู้สะดวกตอนเครื่องเดียวพังเท่านั้น
ผมเลยทำ Layer 3 เพิ่มขึ้นมา — ดึง backup ออกจาก server room ไปเก็บไว้อีกสถานที่หนึ่ง เพื่อให้เข้าเงื่อนไข 3-2-1 backup จริง ๆ. บทความตอนนี้คือเรื่องของ pull architecture, เครื่องมือที่เลือกใช้, และ trip ที่เจอระหว่างทางซึ่งกินเวลาไปไม่น้อย
ผมใช้ชื่อสมมติต่อจาก Part 1 แล้วเพิ่ม host ใหม่เข้ามาหนึ่งตัว: web1 = DA server, bak1 = backup host (Proxmox + NFS), pull1 = pull host ตัวใหม่ที่จะดึง backup จาก bak1. IP สมมติ web1 = 203.0.113.10, bak1 = 203.0.113.20, pull1 = 192.168.1.100 (อยู่คนละ LAN). user ฝั่ง bak1 สำหรับให้ pull1 ดึงคือ backupuser ซึ่งจำกัดสิทธิ์ไว้แล้ว.
หลัก 3-2-1 — เช็คว่า Part 1 ขาดอะไร
หลัก 3-2-1 หลายคนคงรู้กันอยู่แล้ว แต่ทวนกันสั้น ๆ:
- มี 3 copies ของข้อมูล
- เก็บบน 2 สื่อ/ที่อยู่ต่างกัน
- มี 1 copy off-site คืออยู่ห่างจาก primary location
ของผมที่ทำใน Part 1 พอมานับจริง ๆ มันออกมาเป็น 2 copies + 2 media + 0 off-site:
- Copy 1 = live data บน web1
- Copy 2 = backup ที่ bak1 ซึ่งเป็น host ตัวที่สอง แต่ความจริงคืออยู่ห้อง server เดียวกัน hardware เหมือนกันมาก ใช้ปลั๊กไฟชุดเดียวกัน
ตามหลักก็ยังขาดอยู่หนึ่งข้อคือ "1 off-site". ผมเลยมาทำ Layer 3 ต่อ — pull ข้อมูลจาก bak1 ออกไปอีกเครื่องที่อยู่นอก server room คนละไฟฟ้า คนละ network egress คนละ physical location. ถ้าซวยถึงขั้นเสีย host ทั้งสองตัวพร้อมกัน ก็ยังเหลือของที่ pull1 ให้กู้กลับมาได้
Pull vs Push — ผมเลือก pull เพราะอะไร
ตอนออกแบบ ผมมีสองทางเลือก:
bak1 ส่งออกเอง
- cron อยู่บน bak1
- bak1 sync ของออกไปฝั่งนอก
- bak1 ต้องรู้ว่า pull1 อยู่ที่ไหน
- bak1 ต้องเก็บ credential ของ pull1
pull1 มาดึงเอง
- cron อยู่บน pull1
- pull1 ssh เข้ามาดึง
- bak1 ไม่รู้ว่า pull1 อยู่ที่ไหน
- SSH key อยู่ที่ pull1 ฝั่งเดียว
ผมเลือก (B) Pull ด้วยเหตุผลด้านความปลอดภัยล้วน ๆ:
- bak1 ไม่ต้องรู้ว่า pull1 อยู่ที่ไหน — ถ้า bak1 โดน compromise attacker ก็ไม่มี path เดินไป pull1
- pull1 เป็นฝั่งที่ initiate connection เอง แปลว่า firewall ฝั่ง pull1 ปิด inbound ได้หมด เปิดแค่ outbound พอ
- SSH key อยู่ที่ pull1 เท่านั้น — ถ้า bak1 โดน hack key ของ pull1 ก็ยังปลอดภัย
- ถ้าฝั่ง pull1 โดน hack attacker จะ pull ของจาก bak1 ได้จริง แต่ bak1 ไม่ได้ถูก write — write protection ของ data ฝั่ง primary ยังอยู่ครบ
Push มีข้อเสียที่หลายคนมองข้าม คือ bak1 ต้องเก็บ SSH key หรือ credential ของ pull1 ไว้กับตัว. ถ้า bak1 โดน ransomware ก็เดินเรื่อง forward attack เข้า pull1 แล้วลบ backup off-site ของเราหายเกลี้ยงได้ในคราวเดียว. การออกแบบแบบ pull กันตรงจุดนี้ตั้งแต่ต้น
ให้เครื่องที่ปลอดภัยกว่า (off-site) เป็นฝ่ายเอื้อมไปดึง ไม่ใช่ให้เครื่องที่เสี่ยงกว่า (อยู่ในห้อง server) เป็นฝ่ายยื่นของออกไป. ทิศทางของ connection คือเรื่องความปลอดภัย ไม่ใช่แค่เรื่องสะดวก.
Pull host เป็น host อะไรก็ได้ — ไม่ต้องเป็น Windows
ของผมเองรัน Windows Server เพราะมีเครื่องนี้อยู่แล้วใน LAN ใช้ทำงานอย่างอื่นด้วย และมี disk เหลืออยู่ราว 10 TB. แต่ผมเขียน code ให้ portable ไว้ตั้งแต่ต้น รันบน Linux, Mac, Windows ได้หมด:
| ส่วน | Tool ที่ใช้ | Portable? |
|---|---|---|
| Scheduler + daemon | Python 3 + apscheduler | ทุก OS |
| Per-file transfer (DA tarballs) | rclone | binary มีทุก OS |
| Big file resume (vzdump ขนาดใหญ่) | rsync (cwRsync บน Windows) | Linux native, Windows ผ่าน cwRsync |
| Config | YAML | ทุก OS |
| Logging | loguru (Python lib) | ทุก OS |
ใครจะรัน Linux dedicated เป็น pull host ก็ได้ ไม่ต้องลง cwRsync ใช้ rsync ของ distro ตรง ๆ ได้เลย. ความต่างหลักที่ผมเขียนแยกไว้คือ path conversion จาก D:\... ไปเป็น /cygdrive/d/... ซึ่งฝั่ง Linux ไม่ต้องสนเรื่องนี้ ใช้ /mnt/backup/... ตามปกติได้
Pull host requirements ขั้นต่ำ:
- Disk เพียงพอเก็บ data ราว 4 สัปดาห์ (ของผมประมาณ 4 TB)
- Network — เปิด outbound port
<ssh-port>(SSH) ไปยัง bak1 - Python 3.10+ พร้อม apscheduler, loguru, yaml
- rclone binary (
apt install rcloneหรือchoco install rclone) - rsync — Linux native หรือ cwRsync บน Windows
- ทำงาน 24/7 — เป็น server หรือ workstation ที่เปิดทิ้งไว้ก็ได้
Architecture 3 layers (อัพเดทจาก Part 1)
นี่คือภาพรวมหลังเพิ่ม Layer 3 เข้ามา:
┌──────────────────────────────────────────────────────────┐
│ Layer 1: PRODUCTION (live) │
│ web1 — DirectAdmin │
│ - DA daily backup → /mnt/dabak (NFS ไปยัง bak1) │
└──────────────────────────────────────────────────────────┘
│ NFSv4 (รายวัน)
▼
┌──────────────────────────────────────────────────────────┐
│ Layer 2: ON-SITE BACKUP │
│ bak1 — Proxmox + NFS server │
│ - /mnt/backup/dabak/admin/{Day}/ (DA per-day tars) │
│ - /mnt/backup/vmdump/ (weekly VM snap) │
└──────────────────────────────────────────────────────────┘
│ SSH/SFTP (pull)
│ initiated by pull1
▼
┌──────────────────────────────────────────────────────────┐
│ Layer 3: OFF-SITE │
│ pull1 — pull host (Linux/Win/Mac) │
│ /home/<user>/pull1-data/ │
│ ├── daily/YYYY-MM-DD-Day/ (DA tarballs, 28d) │
│ └── weekly/YYYY-MM-DD/ (VM snapshots, 28d) │
└──────────────────────────────────────────────────────────┘
SSH key setup ฝั่ง bak1 — limited backup user
ผมใช้ user แยกสำหรับ backup pull โดยเฉพาะ — ไม่ใช่ root และไม่ใช่ regular user. ฝั่ง bak1 สร้าง user แล้วให้สิทธิ์อ่านเฉพาะ backup dirs ผ่าน ACL:
# สร้าง user
useradd -m -s /bin/bash backupuser
mkdir -p /home/<user>/.ssh
chmod 700 /home/<user>/.ssh
touch /home/<user>/.ssh/authorized_keys
chmod 600 /home/<user>/.ssh/authorized_keys
# ให้สิทธิ์อ่าน backup dirs
setfacl -m u:backupuser:rx /mnt/backup
setfacl -m u:backupuser:rx /mnt/backup/dabak
setfacl -R -m u:backupuser:rx /mnt/backup/dabak/admin
setfacl -m u:backupuser:rx /mnt/backup/vmdump
setfacl -R -m u:backupuser:rx /mnt/backup/vmdump
# default ACL สำหรับไฟล์ที่จะถูกสร้างใหม่ภายหลัง
setfacl -d -m u:backupuser:rx /mnt/backup/dabak/admin
setfacl -d -m u:backupuser:rx /mnt/backup/vmdump
ฝั่ง pull1 สร้าง key คู่ใหม่:
# Linux/Mac
ssh-keygen -t ed25519 -f /home/<user>/.ssh/pull1_ed25519 -N ""
# Windows (PowerShell)
ssh-keygen -t ed25519 -f D:\Backups\.ssh\pull1_ed25519 -N '""'
แล้วเอา public key ไป append บน bak1 พร้อม restriction:
# บน bak1 — แก้ /home/<user>/.ssh/authorized_keys
cat >> /home/<user>/.ssh/authorized_keys <<EOF
from="192.168.1.100",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA<public-key-here> pull1
EOF
จุดสำคัญของบรรทัดนี้:
from="192.168.1.100"— lock เฉพาะ IP ของ pull1. ใครที่ไม่ได้มาจาก IP นี้ ใช้ key นี้ไม่ได้no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty— ปิด shell, ปิด port forward, ปิด tunnel ทุกอย่าง. key นี้ใช้ได้แค่ SFTP เท่านั้น- ตอน setup test ผมยังไม่ได้ใส่
command=restriction เพราะอยากทดสอบให้ผ่านก่อน. แต่พอขึ้น production แล้วควรเพิ่มcommand="/usr/lib/openssh/sftp-server"เข้าไป จำกัดให้ key นี้ทำได้แค่ SFTP จริง ๆ
Trip ที่เจอตอน setup from=
ตอนแรกผมใส่ from="192.168.1.100" ลงไปใน key แล้ว pull1 ssh เข้ามากี่ครั้งก็ fail ทุกครั้ง. งมอยู่สองชั่วโมงกว่าจะเข้าใจ
สาเหตุคือ pull1 อยู่หลัง NAT egress. ตอน packet ออกไป LAN ข้างนอก source IP ไม่ใช่ 192.168.1.100 อีกแล้ว มันถูก NAT translate เป็น public IP ของ ISP. ฝั่ง bak1 จึงเห็น source เป็น public IP ของ ISP ไม่ใช่ LAN IP ที่ผมล็อกไว้
วิธีแก้คือเปลี่ยน from= ให้เป็น public IP ของ egress ที่ pull1 ใช้จริง:
# เช็ค public IP ขาออกของ pull1
curl ifconfig.me
# เอา IP ที่ได้ไปใส่ใน from= แทน LAN IP
# from="203.0.113.50"
สรุปคือ from= ต้องเป็น IP ที่ bak1 มองเห็นจริง ไม่ใช่ LAN IP ภายในของ pull1
ถ้า public IP ขาออกของ pull1 เป็นแบบ dynamic (ISP เปลี่ยนให้เรื่อย ๆ) การล็อก IP ตายตัวจะพังเมื่อ IP เปลี่ยน. กรณีนี้ควรพิจารณาใช้ static IP, fixed egress, หรือพึ่ง key restriction อื่น ๆ แทน แล้วยอมไม่ล็อก from=.
ทำไมเลือก 2 tools ไม่ใช่ตัวเดียว — rclone + rsync
ผมใช้ rclone สำหรับ daily DA backup ซึ่งเป็น folder ที่มีหลายไฟล์ (per-user tar.zst) และใช้ rsync สำหรับ weekly VM snapshot ซึ่งเป็นไฟล์เดียวขนาดใหญ่มาก. เหตุผลที่แยกคือธรรมชาติของงานสองอย่างนี้ต่างกัน:
| Use case | Tool | เหตุผล |
|---|---|---|
| Daily DA tarballs (หลายไฟล์) | rclone | parallel transfer, --bwlimit ตามเวลา, progress สวย |
| Weekly VM snapshot (ไฟล์เดียวขนาดใหญ่) | rsync | --append-verify resume ไฟล์ใหญ่ที่ค้างกลางทาง |
rclone เด่นตรงนี้:
- มี SFTP backend native ไม่ต้องผ่าน mount
--bwlimit "03:00,80M 08:00,50M"switch ตาม wall clock ให้เอง--statsแสดง progress + ETA แบบ machine-parsable เอาไป parse ต่อได้- parallel transfer หลายไฟล์พร้อมกัน
rsync เด่นตรงนี้:
--append-verify— ถ้า partial file ที่ปลายทางมี size เท่าต้นทางพอดี มันจะ verify checksum แล้ว skip; ถ้าน้อยกว่า มัน resume ต่อจากท้ายไฟล์- mature มากกับไฟล์ระดับ TB
--partial --partial-dir=.partial-tmp— เก็บ partial transfer แยกที่ปลายทางไว้ resume
Trip 1: rclone + Go SSH library + RSA-only host key
ผมเจอ trip นี้ตอน setup ครั้งแรก. rclone connect bak1 แล้วขึ้น error:
ERROR : Failed to copy: NewFs: couldn't connect SSH:
ssh: handshake failed: ssh: no key found
ทั้งที่ ssh ตรง ๆ จาก command line ผ่านสบาย. งงอยู่พักใหญ่
สาเหตุคือ rclone เขียนด้วย Go ใช้ package golang.org/x/crypto/ssh ซึ่งมี issue กับ known_hosts file ที่มีแค่ RSA host key (ไม่มี ed25519). ของผม bak1 เป็น Proxmox host ที่ install มานานแล้ว มีแค่ RSA host key อย่างเดียว
ผมลองสองทาง. ทางแรก workaround สั้น ที่ผมใช้ตอน setup เริ่มแรก คือบอก rclone ให้ skip host key check:
[bak1-sftp]
type = sftp
host = 203.0.113.20
user = backupuser
key_file = /home/<user>/.ssh/pull1_ed25519
known_hosts_file = "" # skip
แต่อันนี้ไม่ปลอดภัย มันเปิดช่องให้โดน MITM ได้. ทำชั่วคราวเพื่อ test เท่านั้น
Fix ถาวรคือเพิ่ม ed25519 host key บน bak1 ซะ:
# บน bak1
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
systemctl restart ssh
# กลับมาที่ pull1 — re-trust host key
ssh-keygen -R 203.0.113.20
ssh -i /home/<user>/.ssh/pull1_ed25519 [email protected]
# ตอบ yes เพื่อบันทึก ed25519 fingerprint
หลังจากนี้ rclone connect ได้ปกติและ secure ด้วย
Trip 2: cwRsync บน Windows (Linux user ข้ามได้)
ถ้ารัน pull host บน Linux ตรงนี้ข้ามได้เลย — rsync native ของ distro ใช้สบาย. แต่ของผมเป็น Windows ต้องผ่าน cwRsync ซึ่งเป็น Cygwin port ของ rsync แล้วเจอสองกับดัก
กับดัก 1 — flag -a archive:
# Linux ปกติ
rsync -a src/ dst/ # ทำงานปกติ
# Windows cwRsync คำสั่งเดียวกัน
rsync -a src/ dst/
# Error: chown: Operation not permitted
# Error: chmod: ...
-a มันคือ -rlptgoD ซึ่งรวม -p (perms), -o (owner), -g (group) เข้าไปด้วย. Windows filesystem ไม่มี concept ของ POSIX perms/owner/group ตรง ๆ มันเลย fail. แก้โดยใช้ -rltD แทน คือ drop p/g/o ออก:
rsync -rltD src/ dst/
# -r recursive -l symlinks -t times -D devices/specials
กับดัก 2 — Windows path กับ Cygwin path:
# Windows raw — พัง
rsync ... user@host:/src/ D:\Backups\dest\
# Error: path not found
# Cygwin format — ถูก
rsync ... user@host:/src/ /cygdrive/d/Backups/dest/
ผมเลยเขียน helper function ใน Python ไว้แปลง path ให้อัตโนมัติ:
def to_cygdrive(path):
"""D:\\Backups\\foo → /cygdrive/d/Backups/foo for cwRsync."""
s = str(path).replace("\\", "/")
if len(s) >= 2 and s[1] == ":":
return f"/cygdrive/{s[0].lower()}{s[2:]}"
return s
ฝั่ง Linux ไม่ต้องมีฟังก์ชันนี้ ส่ง path ตรง ๆ ได้เลย
Daemon orchestrator — Python + apscheduler + loguru
แทนที่จะใช้ cron บน pull host (Windows ไม่มี cron native, Linux มีแต่ไม่ flexible พอ) ผมเขียน Python daemon ขึ้นมาทำงานพวกนี้:
- Schedule jobs แบบ cron-like ด้วย apscheduler
- Run rclone/rsync เป็น subprocess แล้ว parse stdout เก็บ progress
- Atomic write ลง
current-progress.txtให้ console UI อ่าน - Logging ผ่าน loguru (rotation รายสัปดาห์, เก็บ 90 วัน)
- Lockfile กันสอง jobs ทับกัน
- Catch-up logic เผื่อ daemon ดับช่วงที่มี scheduled job
Config (YAML)
source:
host: bak1
ip: 203.0.113.20
port: <ssh-port>
user: backupuser
key_file: /home/<user>/.ssh/pull1_ed25519
known_hosts_file: /home/<user>/.ssh/known_hosts
destination:
base: /home/<user>/pull1-data # หรือ D:\Backups\pull1-data บน Windows
# Time-of-day bandwidth caps
bandwidth:
- { time: "03:00", mbps: 80 } # 03-08: full night
- { time: "08:00", mbps: 50 } # 08-17: throttled (office hours)
- { time: "17:00", mbps: 80 } # 17-03: full evening
retention:
weekly_keep_days: 28
daily_keep_days: 28
log_keep_days: 90
jobs:
- name: weekly_vmdump
enabled: true
transport: rsync
cron_dow: "wed" # วันที่ host ทำ VM snapshot
cron_hour: 4
cron_minute: 0
source_path: /mnt/backup/vmdump/
includes:
- "vzdump-*.vma.zst"
- "vzdump-*.log"
dest_subpath: weekly/{date}
max_duration_hours: 22
- name: daily_da
enabled: true
transport: rclone
cron_dow: "*" # ทุกวัน
cron_hour: 3
cron_minute: 0
source_path: /mnt/backup/dabak/admin/{Day}/
dest_subpath: daily/{date}-{Day}
max_duration_hours: 5
Bandwidth schedule reasoning
ผมตั้ง bandwidth ไว้ 3 window ตามชั่วโมงที่ user น้อยหรือมาก:
- 03:00-08:00 = 80 Mbps เต็ม — ช่วง night band ใช้ได้สูง pull จบเร็ว
- 08:00-17:00 = 50 Mbps — ช่วง office hours ไม่อยากไปแย่ง bandwidth ของ users
- 17:00-03:00 = 80 Mbps — ช่วงเย็นถึงดึก เร่งจบของที่ยังค้างอยู่
rclone รับ bwlimit schedule แบบนี้ตรง ๆ:
rclone --bwlimit "03:00,80M 08:00,50M 17:00,80M" ...
มัน switch ตาม wall clock ให้เอง ผมไม่ต้องเขียน logic เพิ่มเลย
Trip 3: Stale lockfile + PID recycle race
อันนี้เจอตอน daemon ผมตายค้างครั้งหนึ่ง (โดน process kill หรือ system reboot) แล้ว lockfile ไม่ถูกลบทิ้ง
อาการคือ daemon ตัวใหม่ start ขึ้นมา เห็น lockfile เก่า เลยคิดว่ามี daemon อื่นรันอยู่แล้ว แล้ว exit เงียบ ๆ ไป
Naive fix แรกที่ผมทำคือตรวจ PID ใน lockfile ว่ายัง alive ไหม:
def is_pid_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
อันนี้ใช้บน Linux ได้ แต่บน Windows เจอปัญหา PID recycle. หลัง Windows reboot, PID ที่ daemon เก่าใช้ (สมมติเป็น 3564) อาจถูก recycle ไปให้ process อื่นใช้ (เช่น svchost.exe). PID 3564 ตอนนี้ "alive" จริง แต่มันไม่ใช่ daemon ของผมแล้ว
ของผมเจอ outage 2 วันเพราะ trip นี้ — Windows boot ขึ้นมา, backup-daemon.bat fire, เช็ค PID file เห็น "PID 3564 alive — already running" แล้ว exit. แต่จริง ๆ daemon ไม่ได้ start เลย เพราะ PID 3564 ตอนนั้นคือ svchost ของ Windows
การเช็คแค่ว่า PID ยัง alive ไหม ไม่พอ. PID อาจถูก recycle (Windows reboot) หรือ reuse (Linux process death) ให้ process อื่น. ต้อง verify ด้วยว่า process นั้นเป็น daemon ของเราจริง โดยเช็ค ProcessName + CommandLine ประกอบกัน.
Fix คือเช็คทั้ง PID และ verify ว่า ProcessName = python.exe แล้ว CommandLine มีคำว่า daemon.py อยู่ด้วย. บน Windows ใช้ wmic:
for /f %%L in ('wmic process where "ProcessId=%RUNPID% and Name='python.exe'" get CommandLine /value 2^>nul ^| find /i "daemon.py" /c') do set ISDAEMON=%%L
if "%ISDAEMON%"=="1" (
echo Already running — exit
) else (
echo PID is recycled or random — break stale lock
del "%PIDFILE%"
)
บน Linux ทำแบบเดียวกันผ่าน /proc:
def verify_is_our_daemon(pid):
try:
cmdline = open(f"/proc/{pid}/cmdline").read()
return "daemon.py" in cmdline
except (FileNotFoundError, PermissionError):
return False
บทเรียนคือ lockfile pattern ที่ปลอดภัยจริง ๆ ต้อง verify identity ไม่ใช่แค่ liveness. edge case คนละแบบทั้ง Linux และ Windows แต่ pattern แก้เหมือนกัน
Catch-up logic — daemon ดับนาน ๆ แล้วกลับมา
ตัวอย่างที่เจอจริง: daemon ดับไป 2 วัน (เช่น Windows update reboot แล้วไม่ได้ติด Startup), miss daily job ไป 2 รอบ. พอ daemon start ใหม่ตอน boot ผมไม่อยากรอ scheduled time ครั้งหน้า เพราะของ 2 วันที่ผ่านมายังไม่ได้ pull เลย
ผมเลยเขียน catch-up logic ลงไปใน daemon: ตอน start มันจะ scan ย้อนหลัง 2 วัน ดูว่ามี job ไหนที่ควรจะทำแล้วแต่ยังไม่ได้ทำ แล้ว queue ขึ้นมา run เรียงกันหลังจาก schedule ปกติ:
# Pseudocode
def detect_missed_runs(job, lookback_days=2):
"""หา dates ที่ job นี้ควรทำใน N วันที่ผ่านมา แต่ไม่มี .complete marker"""
today = datetime.now().date()
missed = []
for delta in range(1, lookback_days + 1):
check_date = today - timedelta(days=delta)
if not job_should_run_on(job, check_date):
continue
dest_folder = compute_dest_folder(job, check_date)
marker = dest_folder / ".complete"
if not marker.exists():
missed.append(check_date)
return missed
.complete marker คือไฟล์เปล่าที่ daemon สร้างขึ้นมาหลัง job สำเร็จ. มันทำหน้าที่เป็น "ตรา" บอกว่า folder นั้น pull จบครบแล้ว ไม่ใช่ partial ค้างกลางทาง
ในมุมของ user — pull host เปิดทิ้งไว้ daemon บางทีก็ดับแล้วลืม restart. catch-up logic นี้ช่วยให้ระบบยังครบโดยที่ผมไม่ต้องคอยดูแลเอง
Live console + progress
ตอน job run อยู่ ผม render progress ลง current-progress.txt ทุก 2 วินาที. หน้าตาประมาณนี้:
══════════════════════════════════════════════════════
Job: daily_da (Thursday) [RUNNING]
══════════════════════════════════════════════════════
Started: 2026-04-12 22:30:15
Source: bak1:/mnt/backup/dabak/admin/Thursday/
Total size: 109.3 GB
──────────────────────────────────────────────────────
[██████░░░░░░░░░░░░░░░░░░░░░░░░] 20.3%
22.2 GB / 109.3 GB
──────────────────────────────────────────────────────
Speed now: 9.4 MB/s (≈ 78 Mbps)
Average: 9.1 MB/s
BW window: 80 Mbps
Elapsed: 40m 12s
ETA: 00:18 (~2h 12m remaining)
══════════════════════════════════════════════════════
ผม launch console window แยกเอาไว้อ่านไฟล์นี้ตลอด refresh ทุก 1 วินาที. ดู status ได้ทันทีจาก desktop ของ pull host. ฝั่ง Linux ใช้ watch -n 1 cat /home/<user>/pull1-data/state/current-progress.txt ก็พอ. Windows ใช้ batch file loop:
@echo off
:loop
cls
type D:\Backups\pull1-data\state\current-progress.txt
timeout /t 1 /nobreak >nul
goto loop
จุดดีของ pattern นี้คือ daemon เขียน status ลงไฟล์ ส่วน console UI อ่านวนไป มัน decouple display logic ออกจาก daemon — daemon โฟกัสทำงานของมัน ส่วน UI จะไปอยู่ที่ไหนก็ได้
Recovery — restore จาก Layer 3
คำถามสำคัญคือกรณีไหนถึงต้องใช้ Layer 3 ไม่ใช่ Layer 2. ตารางนี้คือ decision ที่ผมใช้:
| Scenario | Restore source |
|---|---|
| 1 ไฟล์ของ 1 user หาย | Layer 2 (bak1) — ใกล้กว่า copy ตรง ๆ |
| User DB ตาราง corrupt | Layer 2 — extract per-user tar แล้ว import SQL |
| Multiple users / wide damage | Layer 2 — vzdump บน bak1 |
| web1 host crash + disk fail | Layer 2 — qmrestore vzdump บน bak1 |
| bak1 host fail / data corruption | Layer 3 (pull1) — copy กลับไป bak1 ใหม่ → qmrestore |
| web1 + bak1 fail พร้อมกัน (ไฟไหม้ห้อง) | Layer 3 (pull1) — ตั้ง host ใหม่จาก 0 + restore |
ขั้นตอน restore จาก Layer 3 ในกรณี worst case:
# 1. ตั้ง host ใหม่ (Proxmox + DA)
# 2. SCP vzdump file จาก pull1 ขึ้น new bak1
scp /home/<user>/pull1-data/weekly/2026-04-10/vzdump-qemu-101-*.vma.zst root@new-bak1:/var/lib/vz/dump/
# 3. qmrestore บน new bak1
qmrestore /var/lib/vz/dump/vzdump-qemu-101-*.vma.zst <new_VMID>
# 4. VM up → restore latest DA daily ทับ vzdump อีกที
# (vzdump = ของวัน Wed ที่แล้ว, DA daily ของ Sat/Sun ใหม่กว่า)
จุดที่ต้องไม่ลืมคือ vzdump เป็น snapshot ระดับ VM ของวัน Wed ที่แล้ว ส่วน DA daily ใหม่กว่า. กู้ vzdump ขึ้นมาก่อนแล้วค่อย restore DA daily ทับ จะได้ data ที่ใหม่ที่สุด
บทเรียนของ Part 2 — เก็บไว้ให้คนอื่นใช้
ระหว่างทำ Part 2 ผมก็มี AI ช่วย iterate หลายรอบเหมือนเดิม. ครั้งนี้ trip เยอะกว่า Part 1 ด้วยซ้ำ เพราะมีทั้ง cross-OS issue, network NAT issue และ race condition. สรุปบทเรียนรวม ๆ:
- Pull มากกว่า Push สำหรับ off-site backup เป็น secure design พื้นฐาน ลด attack surface ของฝั่ง source
- Pull host ไม่ต้องผูก OS — เลือกใช้ portable tools (Python + rclone + rsync). Linux dedicated host ก็ดี recycle workstation เก่ามาทำก็ได้
from=ใน authorized_keys ต้องเป็น public IP ของ egress ไม่ใช่ LAN IP เพราะ NAT แปลง source ออกไปแล้ว- rclone Go SSH library มี issue กับ RSA-only host key — แก้ด้วยการเพิ่ม ed25519 host key. การ skip host key check เปิดช่อง MITM ระยะยาว
- cwRsync บน Windows ใช้
-rltDไม่ใช่-aและ path ต้องเป็น/cygdrive/d/...ไม่ใช่D:\... - Lockfile ต้อง verify identity ไม่ใช่แค่ liveness — PID recycle (Windows reboot) และ PID reuse (Linux). เช็ค ProcessName + cmdline ประกอบกัน
.completemarker pattern เป็นตรารับรองว่า folder pull เสร็จครบ. ตอน catch-up scan, marker หาย = partial = ต้องทำใหม่- Bandwidth schedule ตามชั่วโมง user เป็นของง่ายที่ทำได้เลย — กลางคืนกินเต็ม กลางวันถอย. ผู้ใช้ปกติไม่รู้สึกเลยว่ามี backup job รัน
- Catch-up lookback — daemon ดับยาวกลับมาต้อง scan ย้อน N วัน. lookback 2 วันคือ sweet spot ของผม ไม่นานจนงาน redo ไม่สั้นจนพลาด
- Progress file pattern — daemon เขียน status ลงไฟล์ console UI อ่านวน. decouple display ออกจาก daemon ทำให้ daemon โฟกัสงานได้
สรุปภาพรวม backup architecture เต็มระบบ (ตอน 1 + 2):
Layer 1: web1 (DA live)
│ NFSv4 hard mount
▼
Layer 2: bak1 (Proxmox + NFS + weekly vzdump)
│ SFTP/SSH pull (cron บน pull host)
▼
Layer 3: pull1 (off-site, 28-day retention)
- Daily: DA per-user tar.zst → มีครบทั้ง 3 layers
- Weekly: VM-level vzdump → มีที่ Layer 2 + 3
- Recovery 4 ระดับ: file → user → multi-user → full VM → off-site rebuild
- Resilience: hard mount + ACL health check + pull host catch-up + retention 28 วัน
ระบบนี้ไม่ได้ออกแบบเสร็จในกระดาษ — มัน evolve มาจากการเจอปัญหาแล้วแก้ทีละรอบอย่างที่บทความนี้สรุปไว้. ผมใช้เวลาราว 2 สัปดาห์ในการออกแบบ แก้ปัญหา และ iterate กว่าจะลงตัว
ใครเอา architecture นี้ไปทำใส่ของตัวเอง น่าจะใช้ได้เลย ปรับ tool ตามสะดวก เช่น Borg หรือ Restic แทน rsync, ZFS replication แทน rclone. pattern ของ pull + 3 layers + retention + catch-up ใช้ได้กับทุก stack หลักการเหมือนกันหมด
ใครมี trick ที่เก่งกว่านี้ เช่น immutable object storage, write-once-read-many setup, หรือ encrypted backup ลง comment แชร์กันได้. ของผมยังไม่ได้แตะเรื่อง encryption เลย เป็นโจทย์ของ next iteration. ตรงไหนผิดพลาดผมจะกลับมาแก้ในภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย