การกู้คืนบริการที่ติดตั้งและดูแลเอง: กลยุทธ์สำรองข้อมูลของเรา
การติดตั้งและดูแลระบบเองให้คุณควบคุมฮาร์ดแวร์ เครือข่าย ที่ตั้งของข้อมูล และโครงสร้างต้นทุนได้มากกว่าบริการที่ผู้ให้บริการดูแลให้ แต่ก็มาพร้อมความรับผิดชอบ: เมื่อเกิดปัญหา คุณต้องจัดการเองทั้งหมด สำหรับเซิร์ฟเวอร์เดียวที่รันบริการใช้งานจริงหลายอย่าง เช่น Supabase, CRM, DAM และเซิร์ฟเวอร์ Git กลยุทธ์สำรองและกู้คืนข้อมูลเป็นสิ่งที่แยกระหว่างเหตุการณ์ที่กู้คืนได้กับการสูญเสียข้อมูลถาวร
บทความนี้บันทึกกลยุทธ์สำรองข้อมูลของสภาพแวดล้อมใช้งานจริงที่เราติดตั้งและดูแลเอง ประกอบด้วยชุดระบบ Supabase ที่ใช้ PostgreSQL เป็นแหล่งข้อมูลหลัก Twenty CRM ที่ใช้ PostgreSQL แยก เซิร์ฟเวอร์ Git ของ Gitea และระบบจัดการสินทรัพย์ดิจิทัล ทั้งหมดรันเป็นคอนเทนเนอร์ Docker บน Hetzner VPS เครื่องเดียว กลยุทธ์ต้องครอบคลุมทุกบริการโดยไม่สร้างภาระงานเกินรับได้หรือความสัมพันธ์ระหว่างงานที่ซับซ้อน
เหตุใดเซิร์ฟเวอร์เดียวจึงเพิ่มความเสี่ยง
เซิร์ฟเวอร์เดียวไม่มีระบบสำรองซ้ำซ้อนในตัว ไม่มีฐานข้อมูลสำเนาพร้อมใช้ ไม่มีการสลับระบบสำรองข้ามหลายโซนความพร้อมใช้งาน และไม่มีการกู้คืนฐานข้อมูลอัตโนมัติเมื่อการตรวจสถานะพบปัญหา หากเซิร์ฟเวอร์ล้มจากฮาร์ดแวร์ การอัปเกรด พื้นที่จัดเก็บ หรือเหตุการณ์ความปลอดภัย ทางกู้คืนมีเพียงข้อมูลสำรอง หากไม่ครบ ไม่เป็นปัจจุบัน หรือไม่เคยทดสอบ การกู้คืนก็มีข้อจำกัดตามนั้น
รูปแบบภัยคุกคามนี้ต่างจากบริการคลาวด์ที่ผู้ให้บริการดูแลให้ ซึ่งความเสียหายของฮาร์ดแวร์มักไม่ปรากฏต่อผู้ใช้เพราะผู้ให้บริการสลับระบบสำรองให้ บน VPS เดียว คุณต้องวางแผนรับความล้มเหลวเหล่านั้นเอง การวางแผนไม่ได้ซับซ้อนมาก แต่ต้องทำก่อนเกิดเหตุ
สิ่งที่ต้องสำรองข้อมูล
แต่ละบริการมีข้อมูลต่างกัน ต้องรู้ว่าข้อมูลใดสร้างทดแทนไม่ได้และเก็บไว้ที่ใดก่อนออกแบบกลยุทธ์สำรอง
Supabase เก็บข้อมูลในอินสแตนซ์ PostgreSQL ของชุดระบบ ทั้งข้อมูลแอป ผู้ใช้ และข้อมูลเมตาของการยืนยันตัวตนและพื้นที่จัดเก็บ ส่วนบัคเก็ตที่เก็บไฟล์อัปโหลดอยู่บนดิสก์ ต้องสำรองแยกจากฐานข้อมูล
Twenty CRM ใช้ PostgreSQL ของตนเองแยกจาก Supabase เก็บผู้ติดต่อ โอกาสการขาย และสถานะเวิร์กโฟลว์ แม้ขนาดเล็กกว่าแต่ข้อมูลอ่อนไหวมาก การสูญเสียกระทบการดำเนินงานมากกว่าข้อมูลแอปที่สร้างใหม่ได้
Gitea เก็บที่เก็บโค้ดเป็นออบเจ็กต์ Git แบบ bare บนดิสก์ โดยหลักแล้วกู้ข้อมูลที่เก็บโค้ดจากเครื่องนักพัฒนาได้ เพราะ clone แบบเต็มที่ไม่ใช่ shallow clone มีประวัติที่ดึงมาแล้ว แต่ไม่รวมออบเจ็กต์ Git LFS หรือ refs ที่ไม่เคยดึงมา แต่ข้อมูลติดตามปัญหา ความคิดเห็นใน pull request และการกำหนดค่าทีมมีเฉพาะในฐานข้อมูล Gitea ไม่อยู่ใน clone ต้องสำรองทั้งออบเจ็กต์ Git และฐานข้อมูล
DAM (Digital Asset Management) เก็บไฟล์ต้นฉบับ ไฟล์ที่แปรรูปแล้ว และข้อมูลเมตา ต้นฉบับทดแทนไม่ได้ ส่วนไฟล์ที่แปรรูปสร้างใหม่ได้ ข้อมูลเมตาอยู่ในฐานข้อมูลที่บันทึกความสัมพันธ์ของไฟล์ แท็ก และสิทธิ์การใช้งาน
การ ประสานการทำงาน pg_dump
pg_dump ของ PostgreSQL เหมาะกับการสำรองฐานข้อมูลเชิงตรรกะ โดยส่งออกฐานข้อมูลในรูป SQL ที่กู้คืนไปยัง PostgreSQL เวอร์ชันที่เข้ากันได้ จึงย้ายได้สะดวกกว่าการสำรองทางกายภาพที่ต้องใช้เวอร์ชันและรูปแบบไบนารีเดียวกัน
สคริปต์สำรองแต่ละอินสแตนซ์ PostgreSQL ใช้รูปแบบเดียวกัน:
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
docker exec "${CONTAINER_NAME}" pg_dump -U postgres -d "${DB_NAME}" | gzip -9 > "${OUTPUT_FILE}"
echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"
set -euo pipefail ด้านบนทำให้สคริปต์หยุดเมื่อคำสั่งใดล้มเหลว รวมถึงในไปป์ไลน์ หากไม่มี pipefail การที่ pg_dump ล้มเหลวแต่ gzip สำเร็จจะเหลือไฟล์บีบอัดที่ว่างหรือขาดช่วง (ข้อความผิดพลาดส่งไปที่ stderr ไม่ใช่ในไฟล์) แต่สคริปต์ยังรายงานว่าสำเร็จ ปัญหาจึงปรากฏตอนกู้คืนเท่านั้น
บีบอัดผลด้วย gzip -9 ระหว่างส่งออก แทนบีบอัดภายหลัง เพื่อลดพื้นที่ดิสก์และไม่ต้องเขียนไฟล์ dump ที่ยังไม่บีบอัดก่อน เหมาะกับเซิร์ฟเวอร์ที่ต้องจัดการพื้นที่ว่างอย่างใกล้ชิด
การกำหนดเวลาแบบสลับกัน
การรันงานสำรองพร้อมกันทำให้แย่ง I/O และลดประสิทธิภาพบริการ อินสแตนซ์ Supabase ที่มีภาระงานไม่ควรต้องแข่งอ่านเขียนดิสก์กับ pg_dump จึงเริ่มงานต่างเวลาและเว้นระยะให้แต่ละงานเสร็จก่อนงานถัดไป
ตารางที่ใช้ในรูปแบบรายการ cron:
# Supabase PostgreSQL backup — 02:00 ทุกวัน
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db
# Twenty CRM PostgreSQL backup — 02:30 ทุกวัน
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db
# Gitea database backup — 03:00 ทุกวัน
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db
# DAM database backup — 03:30 ทุกวัน
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db
# Gitea repository objects — 04:00 ทุกวัน
0 4 * * * /opt/scripts/git-objects-backup.sh
# Storage files (Supabase + DAM originals) — 04:30 ทุกวัน
30 4 * * * /opt/scripts/files-backup.sh
เราเว้น 30 นาทีระหว่างงานเผื่อไว้ แม้ฐานข้อมูลขนาดนี้มักสำรองเสร็จในไม่กี่นาที ระยะนี้รวมการอัปโหลดไฟล์บีบอัดไปยังพื้นที่จัดเก็บออบเจ็กต์ก่อนเริ่มงานถัดไป เพื่อไม่ให้ดิสก์ในเครื่องสะสมข้อมูลสำรองหลายวันพร้อมกัน
การอัปโหลด พื้นที่จัดเก็บออบเจ็กต์ และการเก็บรักษา
สำเนาที่อยู่เฉพาะบนเซิร์ฟเวอร์เดียวกับบริการไม่ใช่ข้อมูลสำรอง แต่เป็นภาพสถานะที่จะสูญหายในเหตุเดียวกับข้อมูลหลัก ต้องอัปโหลดไปยังพื้นที่แยกก่อนจึงถือว่าการสำรองเสร็จสมบูรณ์
เราใช้พื้นที่จัดเก็บออบเจ็กต์ที่เข้ากันได้กับ S3 จากผู้ให้บริการแยกจาก VPS และอัปโหลดด้วย rclone ซึ่งจัดการการลองใหม่และการตรวจสอบค่าเช็กซัมหลังถ่ายโอนโดยอัตโนมัติ:
rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/" --checksum --transfers 1 --log-level INFO
ตัวเลือก --checksum ทำให้ rclone เปรียบเทียบไฟล์ด้วยค่าแฮชและขนาด (ใช้ MD5 สำหรับปลายทางระยะไกลที่เข้ากันได้กับ S3) แทนเวลาแก้ไขและขนาด เมื่อใช้ร่วมกับการตรวจค่าแฮชหลังถ่ายโอนที่ rclone ทำตามค่าเริ่มต้น จึงตรวจพบความเสียหายระหว่างถ่ายโอนได้เมื่อปลายทางเก็บค่าแฮชที่ใช้งานได้
งานล้างข้อมูลแยกรันรายสัปดาห์เพื่อบังคับนโยบายเก็บรักษา: ข้อมูลสำรองรายวันเก็บเจ็ดวัน รายสัปดาห์ใช้สำเนาวันอาทิตย์ที่งานรายสัปดาห์เปลี่ยนชื่อและเก็บสี่สัปดาห์ ส่วนรายเดือนใช้สำเนาอาทิตย์แรกของเดือนและเก็บหกเดือน จึงมีช่วงเวลาพอให้พบการสูญเสียที่ไม่เห็นทันที เช่น แถวเสียหายเมื่อสามสัปดาห์ก่อนยังอาจกู้จากสำเนารายสัปดาห์ได้
ใช้ rclone delete กรองตามเวลาแก้ไข (--min-age) แทนการอาศัยชื่อไฟล์เพียงอย่างเดียว จึงเชื่อถือได้กว่าเมื่อชื่อไฟล์ไม่สม่ำเสมอ:
rclone delete "backup-remote:tva-backups/postgres/" --min-age 7d --include "*_daily_*" --dry-run
ใช้ --dry-run ตอนทดสอบงานล้างข้อมูล ลบตัวเลือกนี้เมื่อยืนยันแล้วว่าตัวกรองเลือกเฉพาะข้อมูลที่ควรลบ รูปแบบของ --include สมมติว่าชื่อไฟล์สำรองรายวันมีเครื่องหมาย _daily_ แต่สคริปต์สำรองด้านบนตั้งชื่อไฟล์เป็น DB_TIMESTAMP.sql.gz จึงต้องปรับรูปแบบหรือชื่อไฟล์ให้ตรงกันก่อน
การทดสอบการกู้คืน
ข้อมูลสำรองที่ไม่เคยทดสอบยังเป็นเพียงสมมติฐาน ต้องลองกู้คืนจึงรู้ว่าใช้ได้ เราทดสอบรายเดือนสำหรับแต่ละฐานข้อมูลด้วยคอนเทนเนอร์ Docker ชั่วคราวแยกจากระบบใช้งานจริง:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"
# เริ่ม PostgreSQL container ชั่วคราว
docker run -d --name "${TEST_CONTAINER}" -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=testdb postgres:15-alpine
# ลบ container เมื่อสคริปต์จบ แม้มีขั้นตอนใดล้มเหลว
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT
# รอให้ PostgreSQL พร้อม
for _ in $(seq 1 30); do
docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
sleep 1
done
# pg_dump ไม่รวม roles: กู้คืน roles ก่อนจากไฟล์ pg_dumpall --roles-only (อาร์กิวเมนต์ที่ 2 ไม่บังคับ)
if [ -n "${ROLES_FILE}" ]; then
docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi
# กู้คืน backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -v ON_ERROR_STOP=1
# ตรวจสอบ row counts ว่าตรงตามที่คาดหวัง
docker exec "${TEST_CONTAINER}" psql -U postgres -d testdb -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
การตรวจจำนวนแถวไม่ได้ตรวจครบทุกอย่าง เพียงยืนยันว่าตารางหลักมีจำนวนแถวโดยประมาณที่สมเหตุสมผล (n_live_tup เป็นค่าประมาณจากสถิติ ไม่ใช่จำนวนที่นับได้จริง) ไม่ใช่ทุกแถวถูกต้อง การตรวจละเอียดกว่าจะใช้การตรวจสถานะของแอปกับฐานข้อมูลที่กู้แล้ว แต่วิธีนับแถวพบปัญหาทั่วไปได้ เช่น สำเนาขาดช่วง กู้ไม่สำเร็จจนฐานข้อมูลว่าง หรือเวอร์ชันไม่เข้ากันจนข้อมูลสูญหายโดยไม่แจ้ง การกู้คืนทำงานด้วย ON_ERROR_STOP=1 คำสั่งแรกที่ล้มเหลวจึงทำให้การทดสอบล้มเหลว และ trap จะลบ container ไม่ว่าผลจะเป็นอย่างไร pg_dump ไม่รวม roles หาก dump อ้างถึง roles อื่นนอกจาก postgres ให้ส่งผลลัพธ์ของ pg_dumpall --roles-only จากเซิร์ฟเวอร์ต้นทางเป็นอาร์กิวเมนต์ที่สอง (ในขั้นตอนนี้ข้อผิดพลาดสำหรับ role postgres ที่มีอยู่แล้วเป็นเรื่องที่คาดไว้) image ที่ใช้ทดสอบควรตรงกับเวอร์ชันหลักของ PostgreSQL ต้นทาง ส่วน dump ของ Supabase ยังอ้างถึง schema และ extension เฉพาะของ Supabase ที่ image postgres ปกติไม่มี จึงต้องทดสอบกู้คืนลงอินสแตนซ์ Supabase ตามขั้นตอนการกู้คืนของ Supabase เอง
การกู้คืนจริงมีลักษณะอย่างไร
กลยุทธ์สำรองจะมีประโยชน์เท่ากับขั้นตอนกู้คืนที่ใช้ข้อมูลนั้น คู่มือขั้นตอนการกู้คืนเขียนเป็น Markdown ในที่เก็บการกำหนดค่าของเซิร์ฟเวอร์ ระบุลำดับกู้แต่ละบริการบน VPS ใหม่อย่างแน่นอน
เมื่อเซิร์ฟเวอร์เสียทั้งเครื่อง ให้จัดสรร VPS ใหม่ ติดตั้ง Docker และชุดระบบแอปจากที่เก็บการกำหนดค่า ดาวน์โหลดสำเนาล่าสุด กู้ฐานข้อมูลตามลำดับการพึ่งพา โดย Supabase ก่อน ตามด้วย CRM และบริการอื่น กู้ไฟล์ อัปเดต DNS แล้วตรวจทุกบริการ คาดว่าใช้ไม่ถึงสองชั่วโมงหากสำเนาเป็นปัจจุบันและทำตามคู่มือโดยไม่คิดขั้นตอนใหม่หน้างาน
เงื่อนไขสำคัญคือ “ทำตามคู่มือโดยไม่คิดขั้นตอนใหม่หน้างาน” คู่มือต้องละเอียดพอให้คนที่ไม่เคยดูแลระบบทำได้ คำสั่งต้องคัดลอกไปใช้ได้ ไม่ใช่แค่อธิบายด้วยข้อความ และทุกขั้นมีการตรวจสอบ ความคลุมเครือทำให้ความเสี่ยงเพิ่มเมื่ออยู่ภายใต้ความกดดันของเหตุจริง
เราทดสอบคู่มือรายปีด้วยการกู้ทั้งระบบในสภาพแวดล้อมทดสอบก่อนใช้งานจริง มักพบอย่างน้อยหนึ่งขั้นที่เปลี่ยนไป เช่น เวอร์ชันอิมเมจ Docker คีย์การกำหนดค่าที่เปลี่ยนชื่อ หรือตัวแปรสภาพแวดล้อมที่เพิ่ม การพบในการทดสอบตามกำหนดดีกว่าพบระหว่างกู้จริงมาก
บทความที่เกี่ยวข้อง
- การตรวจสอบความปลอดภัยแบบหลายขั้นตอน: การเสริมความแข็งแกร่งให้แอปพลิเคชัน Next.js – ชั้นการเสริมความแข็งแกร่งด้านความปลอดภัยที่เสริมกลยุทธ์การสำรองข้อมูล ลดความน่าจะเป็นของเหตุการณ์ที่ทำให้การกู้คืนจำเป็น
- การสร้างแอปเตรียมสอบที่มีคำถามหลายพันข้อ – การใช้ Supabase เป็น backend สำหรับใช้งานจริง รวมถึงการตัดสินใจออกแบบ RLS และ สคีมา ที่ส่งผลต่อสิ่งที่ต้องสำรองข้อมูล