โปรเจกต์งานอดิเรก → appdata ของ Unraid โดยไม่ต้องหยุดระบบ
คู่มือ · Unraid 7.3 · Btrfs · Appdata.Backup · อัปเดต 23 กันยายน 2026

สำรองข้อมูล appdata ของ Unraid โดยไม่ต้องหยุดคอนเทนเนอร์

การสำรอง appdata มาตรฐานของ Unraid จะหยุดทุกคอนเทนเนอร์ คัดลอกโฟลเดอร์ แล้วเริ่มใหม่ เพราะสำเนาสดของฐานข้อมูล SQLite หรือ Postgres ที่กำลังทำงานคือสำเนาที่ขาดกลางคันและกู้คืนไม่ได้ ทางออกไม่ใช่ Unraid รุ่นใหม่ แต่คือสแนปช็อตของระบบไฟล์ ถ้า appdata ของคุณอยู่บนพูลแคช Btrfs คุณมีทุกอย่างที่ต้องใช้อยู่แล้ว: การแปลงโฟลเดอร์ appdata เป็น subvolume ครั้งเดียว สคริปต์ฮุกสองตัว และค่าตั้งไม่กี่อย่างในปลั๊กอิน Appdata.Backup ทุกอย่างด้านล่างทำจากหน้าเว็บ — User Scripts คือเชลล์ — และผลลัพธ์คือการสำรองรายวัน แบบบีบอัดและหมุนเวียนบนอาร์เรย์ หนึ่งไฟล์เก็บต่อหนึ่งคอนเทนเนอร์ กู้คืนได้ด้วย Zip Manager และตัวจัดการไฟล์ ขณะที่ทุกคอนเทนเนอร์ยังทำงานอยู่

อ่านก่อนเริ่ม — วิธีนี้รับประกันอะไรและไม่รับประกันอะไร การสำรองด้วยสแนปช็อตเป็นแบบ crash-consistent: ทุกคอนเทนเนอร์ถูกเก็บในสภาพเดียวกับหลังไฟดับ ไม่ใช่หลังการปิดเครื่องอย่างเรียบร้อย ฐานข้อมูลที่เปิด journaling ไว้ (SQLite ในโหมดเริ่มต้นหรือโหมด WAL, PostgreSQL, MariaDB) ถูกสร้างมาให้กู้คืนจากสภาพนั้นพอดี และกู้คืนได้จริงในการเริ่มครั้งถัดไป การรับประกันนี้มีขอบเขต และเป็นหน้าที่ของคุณที่จะตรวจกับคอนเทนเนอร์ของคุณเอง:

  • ฐานข้อมูลที่ปิด journaling (journal_mode=OFF หรือ MEMORY, synchronous=OFF — หายากในซอฟต์แวร์เซิร์ฟเวอร์ แต่มีอยู่) ไม่มีอะไรให้กู้คืน และอาจกลับมาในสภาพเสียหาย การหยุดอย่างนุ่มนวลคือการสำรองที่ปลอดภัยเพียงอย่างเดียวสำหรับมัน ตั้งให้คอนเทนเนอร์นั้นถูกหยุดในปลั๊กอิน
  • แอปที่เก็บสถานะไว้ในหน่วยความจำ และเขียนลงดิสก์เฉพาะตอนปิดอย่างเรียบร้อย จะถูกเก็บ ณ การบันทึกครั้งล่าสุด ไม่ใช่สถานะปัจจุบัน ใช้ได้ แต่เก่ากว่าที่การหยุดจะให้
  • ข้อมูลที่แยกอยู่สองที่ — ฐานข้อมูลใน appdata และไฟล์หรือ WAL ของมันใน Docker named volume — ถ่ายสแนปช็อตแบบอะตอมมิกไม่ได้ named volume ไม่อยู่ในการสำรองนี้เลย (และก็ไม่อยู่ในการสำรองแบบหยุดทุกอย่างด้วย)
  • ทุกคอนเทนเนอร์ถูกพัก ชั่วขณะที่ถ่ายสแนปช็อต ปกติไม่ถึงสองวินาที อะไรที่ไวต่อความหน่วงจะเห็นช่องว่างนั้น สคริปต์ปล่อยคอนเทนเนอร์เมื่อเกิดข้อผิดพลาดใด ๆ ถ้าคุณเจอตัวไหนยังพักอยู่ docker unpause <name> คือวิธีแก้ทั้งหมด
  • สแนปช็อตใช้พื้นที่ของพูล ตราบที่มันยังอยู่ บนพูลที่รับไฟล์ดาวน์โหลดด้วย การสำรองอาจทำให้พูลพื้นที่เต็ม และพูล Btrfs ที่เต็มอาจพาฐานข้อมูลของคอนเทนเนอร์ล่มไปด้วย อ่านส่วนเรื่องไฟล์ดาวน์โหลดก่อนอย่างอื่น
  • การแปลงต้องหยุด Docker หนึ่งครั้ง และต้องมีพื้นที่ว่างบนพูลเท่ากับขนาด appdata ระหว่างที่ทำ ขั้นตอน “อัปเดตคอนเทนเนอร์หลังสำรอง” ยังคงรีสตาร์ตตัวที่มันอัปเดต

ทดสอบการกู้คืนกับโฟลเดอร์ทดลองก่อนจะพึ่งพาสิ่งใดในนี้ และเปิดการสำรองแบบเดิมของคุณไว้จนกว่าคุณจะกู้คืนคอนเทนเนอร์จากระบบนี้ได้สำเร็จ นี่คือคอนเทนเนอร์และข้อมูลของคุณ ไม่มีอะไรในนี้ที่ถูกทดสอบกับของคุณ

ทำงานอย่างไร

สแนปช็อตเกิดขึ้นทันทีและเป็นอะตอมมิก Btrfs แช่แข็ง subvolume ณ จุดเวลาหนึ่งภายในไม่กี่มิลลิวินาทีแบบ copy-on-write คอนเทนเนอร์ที่ทำงานอยู่จึงเขียนลงต้นไม้สดต่อไป ขณะที่การสำรองอ่านต้นไม้ที่ถูกแช่แข็ง การสำรองใช้เวลานานเท่าไรก็ได้ ไม่มีอะไรต้องรอ

มีแค่ subvolume ที่ถ่ายสแนปช็อตได้ ใน Unraid ส่วนใหญ่ appdata เป็นโฟลเดอร์ธรรมดาบนพูล ไม่ใช่ subvolume และ btrfs subvolume snapshot ปฏิเสธมัน การแปลงเป็นงานครั้งเดียวที่ต้องหยุด Docker หนึ่งครั้ง — การหยุดครั้งสุดท้ายที่คุณจะทำเพื่อการสำรอง

คอนเทนเนอร์ถูกพักชั่วขณะที่ถ่ายสแนปช็อต นี่คือสิ่งที่ Proxmox ทำเมื่อสำรองคอนเทนเนอร์ LXC ในโหมดสแนปช็อต: พักคอนเทนเนอร์ ถ่ายสแนปช็อตพื้นที่เก็บข้อมูล ให้ทำงานต่อ แล้วเก็บสแนปช็อตเป็นไฟล์ Docker มีตัวควบคุมแบบเดียวกันคือ docker pause ซึ่งแช่แข็งทุกโปรเซสในคอนเทนเนอร์ระหว่าง syscall ในขณะที่สแนปช็อตบันทึก จึงไม่มีอะไรกำลังเขียนค้างอยู่ การพักนานเท่ากับที่สแนปช็อตใช้ — ปกติไม่ถึงสองวินาทีสำหรับทุกคอนเทนเนอร์รวมกัน — และคอนเทนเนอร์จะกลับมาไม่ว่าสแนปช็อตจะสำเร็จหรือไม่ (การ fsfreeze ทั้งพูลแบบ VM เป็นเครื่องมือที่ผิดตรงนี้: สร้างสแนปช็อต Btrfs บน Btrfs ที่ถูกแช่แข็งไม่ได้ และการแช่แข็งพูลจะแช่แข็ง docker.img ไปด้วย)

ปลั๊กอินจัดตารางเวลา ฮุกสองตัวทำงาน Appdata.Backup (ฟอร์กที่ยังดูแลอยู่ของ Commifreak) มีฮุกสคริปต์ก่อนรันและหลังรัน และส่งโฟลเดอร์ปลายทางของแต่ละรอบให้ ฮุกก่อนรันพักคอนเทนเนอร์ที่ทำงานอยู่ ถ่ายสแนปช็อต แล้วให้ทำงานต่อ ฮุกหลังรันเขียนไฟล์เก็บบีบอัดหนึ่งไฟล์ต่อโฟลเดอร์คอนเทนเนอร์จากสแนปช็อตลงโฟลเดอร์ของรอบนั้น แล้วลบสแนปช็อต ตัวปลั๊กอินเองสำรองไดรฟ์แฟลชและข้อมูลเมตาของ VM หมุนเวียนรอบเก่า และอัปเดตคอนเทนเนอร์หลังจากนั้น ตัวเลือก “skip stopping” ของมันเปิดอยู่ จึงไม่เคยหยุดคอนเทนเนอร์ที่ทำงานอยู่

สแนปช็อตสัญญาอะไรและไม่สัญญาอะไร มันเป็นแบบ crash-consistent: ฐานข้อมูลถูกเก็บในสภาพเดียวกับหลังไฟดับ ซึ่งเป็นสภาพที่เอกสารของ PostgreSQL เองอธิบายไว้สำหรับการสำรองด้วยสแนปช็อตระบบไฟล์ของเซิร์ฟเวอร์ที่กำลังทำงาน และเป็นสภาพที่ SQLite ถูกสร้างมาให้ย้อนกลับในการเปิดครั้งถัดไป การพักเพิ่มความนิ่งระดับระบบไฟล์ เหมือน VM Linux ที่ติดตั้ง guest agent ไว้ แต่ทั้งสองอย่างไม่ใช่การดัมป์แบบนิ่งสนิท: ธุรกรรมที่กำลังเกิดขึ้นตอนถ่ายสแนปช็อตจะถูกย้อนกลับตอนกู้คืน สำหรับฐานข้อมูลที่คุณยอมเสียไม่กี่วินาทีสุดท้ายไม่ได้ ให้เพิ่มการสำรองแบบ hot backup ของมันเองลงในสคริปต์ก่อนรันก่อนการพัก — docker exec <name> pg_dumpall -U postgres > /mnt/cache/appdata/<name>/dump.sql สำหรับ Postgres และ sqlite3 <db> ".backup <db>.bak" สำหรับ SQLite — ดัมป์จึงเดินทางไปในสแนปช็อตด้วย นั่นคือสิ่งที่เทียบเท่า VSS ของเครื่อง Windows ในฝั่ง Linux และทำรายแอป

สิ่งที่ต้องมี

ใช้ User Scripts เป็นเชลล์ Settings → User Scripts → Add New Script → ตั้งชื่อ → ไอคอนเฟือง → Edit Script → วาง → Save รันด้วย Run Script (ผลลัพธ์ในหน้าต่าง) หรือ Run in Background (สำหรับงานนาน ๆ ผลลัพธ์ไปอยู่ในล็อกของสคริปต์ อ่านได้ด้วยปุ่ม Show Log) ปล่อยตารางเวลาของทุกสคริปต์ไว้ที่ Scheduled Disabled เว้นแต่ขั้นตอนจะบอกเป็นอย่างอื่น ถ้าคุณชอบเทอร์มินัล ทุกบล็อกด้านล่างรันในเทอร์มินัลได้โดยไม่ต้องแก้

ก่อนอื่น กับดักที่สำคัญที่สุด: ไฟล์ดาวน์โหลดบนพูล appdata

บนเครื่อง Unraid จำนวนมาก พูลแคชทำสองงานพร้อมกัน: เก็บ appdata และเป็นที่ที่ไฟล์ทอร์เรนต์ ดาวน์โหลด usenet การแปลงไฟล์ และวิดีโอจากกล้องลงมา ซึ่งดีต่อประสิทธิภาพ แต่แย่สำหรับวิธีนี้ ด้วยสามเหตุผล

วิธีแก้ที่ 1 — พูลที่สองสำหรับไฟล์ดาวน์โหลด (แนะนำ)

Unraid รองรับหลายพูล และ SATA SSD ราคาประหยัดหรือ NVMe ตัวที่สองทำให้ปัญหานี้แก้ด้วยโครงสร้างแทนพฤติกรรม: appdata อยู่พูลหนึ่ง ทุกอย่างที่ชั่วคราวอยู่อีกพูล Main → Add Pool ตั้งชื่อว่า scratch (หรือ downloads) กำหนดไดรฟ์ แล้วฟอร์แมต จากนั้นชี้แชร์ชั่วคราวไปที่มัน: สำหรับแชร์ downloads, incomplete, การแปลงไฟล์ และกล้อง ให้ไปที่ Shares → แชร์ → Primary storage = scratch, Secondary = Array ถ้าคุณอยากให้ mover ย้ายไฟล์ที่เสร็จแล้วไปอาร์เรย์ หรือ None สำหรับพื้นที่ชั่วคราวล้วน พูล appdata จะเห็นแต่ appdata สแนปช็อตเล็ก และพูล scratch ที่เต็มก็พาฐานข้อมูลคอนเทนเนอร์ล่มไปด้วยไม่ได้

ขนาด: ข้อมูลทอร์เรนต์และ usenet ที่ยังไม่เสร็จแทบไม่เคยต้องการเกินไม่กี่ร้อยกิกะไบต์ ถ้า mover รันทุกคืน ส่วนวิดีโอจากกล้องและการแปลงไฟล์เป็นไปตามระยะการเก็บของคุณ ไดรฟ์ 1 TB ครอบคลุมบ้านส่วนใหญ่

วิธีแก้ที่ 2 — ย้ายแชร์ก้อนใหญ่ไปอาร์เรย์

ไม่มีไดรฟ์ตัวที่สอง: ย้ายแชร์ชั่วคราวออกจากพูลไปไว้บนอาร์เรย์ ดูก่อนว่าบนพูลมีอะไร — ด้วย User Script แบบ Run Script
User Script: pool-usage
#!/bin/bash
# what is on the pool, largest last, and how full the pool is
du -sh /mnt/cache/* 2>/dev/null | sort -h | tail -8
btrfs fi df /mnt/cache | grep -i "^Data"

วิธีย้ายแชร์ผ่านหน้าเว็บคือ mover: Shares → แชร์ → Primary storage = Cache, Secondary storage = Array, mover action Cache → Array, Apply แล้ว Main → Move หยุดสิ่งที่เขียนลงแชร์ก่อน (คอนเทนเนอร์ ซอฟต์แวร์กล้อง) และอ่านกับดักสองข้อด้านล่างก่อนกด Apply

กับดักสองข้อในค่าตั้งของแชร์ ข้อแรก: ในโมเดลพื้นที่เก็บข้อมูล Primary/Secondary การตั้งแชร์เป็น Primary = Array ตรง ๆ ไม่ย้ายข้อมูลเดิม แค่เปลี่ยนที่เขียนใหม่ mover จะทำงานก็ต่อเมื่อแชร์มี Primary = Cache และ Secondary = Array ข้อสอง: การเปลี่ยนชั้นพื้นที่เก็บข้อมูลของแชร์อาจหยุดและเริ่มอาร์เรย์ใหม่ อะไรที่กำลังเขียนลง /mnt/user ในขณะนั้นจะตายด้วยข้อผิดพลาดเรื่องสิทธิ์ หยุดตัวเขียนก่อนกด Apply
mover ไม่มีหน้าแสดงความคืบหน้า และอาจใช้เวลาสักพักกว่าจะถึงแชร์ที่ต้องการ การคัดลอกตรงไปยังชั้นอาร์เรย์ของแชร์เดียวกันทำงานแน่นอนและแสดงอัตราความเร็ว ในรูป User Script แบบ Run in Background ความคืบหน้าอยู่ในล็อก หยุดตัวเขียนก่อน
User Script: move-share-to-array (แก้ชื่อแชร์; Run in Background)
#!/bin/bash
SHARE=downloads          # the share to move off the pool
# /mnt/user0 is the array without the cache overlay: same share, array tier
rsync -a --info=progress2 --remove-source-files "/mnt/cache/$SHARE/" "/mnt/user0/$SHARE/"
find "/mnt/cache/$SHARE" -type d -empty -delete
du -sh "/mnt/cache/$SHARE" 2>/dev/null || echo "cache copy gone"
du -sh "/mnt/user0/$SHARE"
echo "now set the share to Primary = Array, Secondary = None and Apply (nothing writing when you click)"
ความเร็ว การเขียนจากแคชไปอาร์เรย์ต้องจ่ายค่า parity Settings → Disk Settings → Tunable (md_write_method) → reconstruct write เพิ่มความเร็วการเขียนอาร์เรย์ราวเท่าตัว ตั้งกลับเป็น auto หลังจากนั้นเพื่อให้ดิสก์หยุดหมุนได้ คาดว่าได้ 60–100 MB/s กับไฟล์เล็ก และเร็วกว่ากับไฟล์ใหญ่
ถ้ามีเครื่องลูกข่ายเมานต์แชร์ผ่าน NFS การรีสตาร์ตอาร์เรย์จะทำให้การเมานต์ของมันค้าง และ Docker บนเครื่องลูกข่ายจะเริ่มคอนเทนเนอร์ไม่ได้ด้วยข้อความชวนสับสน mkdir /mnt/<share>: file exists บนเครื่องลูกข่าย: umount -l /mnt/<share> && mount /mnt/<share> แล้วเริ่มคอนเทนเนอร์ วิธีแก้เดียวกันใช้หลังการหยุด/เริ่มอาร์เรย์ของ Unraid ทุกครั้ง

ขั้นที่ 1 — appdata เป็น subvolume หรือยัง?

User Script แบบ Run Script ถ้ามันพิมพ์ UUID และหมายเลข generation แปลว่า appdata เป็น subvolume อยู่แล้ว: ข้ามไปขั้นที่ 3 ถ้ามันบอกว่า Not a Btrfs subvolume ขั้นที่ 2 คือการแปลง มันยังพิมพ์ขนาด ซึ่งคือพื้นที่ว่างที่ขั้นที่ 2 ต้องใช้
User Script: appdata-check
#!/bin/bash
btrfs subvolume show /mnt/cache/appdata 2>&1 | head -3
echo "--- size of appdata (step 2 needs this much free on the pool):"
du -sh /mnt/cache/appdata
echo "--- pool:"
btrfs fi df /mnt/cache | grep -i "^Data"

ขั้นที่ 2 — แปลง appdata เป็น subvolume

การหยุดคอนเทนเนอร์ครั้งเดียวในวิธีทั้งหมดนี้ User Script หนึ่งตัวแบบ Run in Background: หยุด Docker เปลี่ยนชื่อโฟลเดอร์เก่า (ไม่เคยลบ) สร้าง subvolume ด้วยชื่อเดิม คัดลอกทุกอย่างเข้าไปโดยรักษาเจ้าของและสิทธิ์ ตรวจสำเนา แล้วเริ่ม Docker อีกครั้ง โฟลเดอร์เก่ายังอยู่เป็นทางย้อนกลับจนกว่าคุณจะลบมันในวันถัดไป NVMe ไป NVMe บนพูลเดียวกันใช้ราว 10 นาทีต่อ 100 GB อ่านล็อกเมื่อเสร็จ
User Script: appdata-convert (Run in Background; ดูด้วย Show Log)
#!/bin/bash
set -u
POOL=/mnt/cache
echo "$(date '+%T') stopping Docker"
/etc/rc.d/rc.docker stop
if btrfs subvolume show "$POOL/appdata" >/dev/null 2>&1; then
  echo "appdata is already a subvolume; starting Docker and stopping here"; /etc/rc.d/rc.docker start; exit 0
fi
echo "$(date '+%T') size: $(du -sh "$POOL/appdata" | cut -f1)"
mv "$POOL/appdata" "$POOL/appdata.old" || exit 1
btrfs subvolume create "$POOL/appdata" || exit 1
echo "$(date '+%T') copying (this is the long part)"
cp -a "$POOL/appdata.old/." "$POOL/appdata/" 2> /root/appdata-convert.log
echo "$(date '+%T') copy done; cp errors: $(wc -l < /root/appdata-convert.log)  (expect 0)"
btrfs subvolume show "$POOL/appdata" | head -2
echo "$(date '+%T') comparing old and new (dangling symlinks and sockets are expected noise)"
diff -qr "$POOL/appdata.old" "$POOL/appdata" > /root/appdata-diff.txt 2>&1
echo "diff lines: $(wc -l < /root/appdata-diff.txt)   of which 'No such file' (symlinks): $(grep -c 'No such file or directory' /root/appdata-diff.txt)"
echo "--- lines that are NOT symlink or socket noise (a real difference would show here):"
grep -vE "No such file or directory|is a socket" /root/appdata-diff.txt | head -10
echo "$(date '+%T') starting Docker"
/etc/rc.d/rc.docker start
echo "done. Check the containers; delete $POOL/appdata.old tomorrow (User Script: appdata-cleanup)."
อ่านล็อก คาดว่าจะมีบรรทัด diff นับพัน เกือบทั้งหมดเป็นเสียงรบกวน: No such file or directory สำหรับ symlink ที่ชี้ไม่ถึง (ธีมไอคอน Python virtualenv ที่ชี้ไปยังตัวแปลภาษาที่มีอยู่แค่ในคอนเทนเนอร์ ลิงก์ไดรเวอร์และใบรับรอง) และ is a socket สำหรับซ็อกเก็ต ซึ่ง cp -a คัดลอกเป็นซ็อกเก็ต บล็อกที่ขึ้นหัวว่า “lines that are NOT symlink or socket noise” คือสิ่งที่สำคัญ: บรรทัด Only in appdata.old หรือ Files … differ ใด ๆ ใต้โฟลเดอร์ฐานข้อมูล หมายถึงสำเนาที่ขาด บล็อกนั้นว่าง และข้อผิดพลาดของ cp เป็น 0 คือไฟเขียว
คอนเทนเนอร์ที่ไม่ยอมกลับมา: ไฟล์ PID ค้าง คอนเทนเนอร์ใดที่ถูก SIGKILL ระหว่างปิด (Plex มักใช้เวลานานกว่าช่วงผ่อนผัน 10 วินาทีของ Docker) จะทิ้งไฟล์ PID ไว้ การคัดลอกเก็บมันไว้ด้วย และคอนเทนเนอร์ใหม่จะปฏิเสธด้วย “already running” ในตัวจัดการไฟล์ ให้ลบ appdata/<plex-folder>/Plex Media Server/plexmediaserver.pid แล้วรีสตาร์ตคอนเทนเนอร์ คาดว่าการเริ่มครั้งแรกจะช้าสำหรับอะไรก็ตามที่ไล่อ่านข้อมูลของมันตอนบูต
User Script: appdata-cleanup (Run Script วันถัดไป เมื่อทุกอย่างทำงานสะอาดแล้ว)
#!/bin/bash
rm -rf /mnt/cache/appdata.old && echo "appdata.old removed"
btrfs fi df /mnt/cache | grep -i "^Data"

ขั้นที่ 3 — สคริปต์ฮุกสองตัว

User Script สองตัว ตั้งชื่อตรงตามนี้ appdata-snap-pre และ appdata-snap-post ตารางเวลา Scheduled Disabled — ปลั๊กอินสำรองข้อมูลเป็นผู้เรียก ไม่ควรมีอย่างอื่นเรียก ก่อนรันจะพักทุกคอนเทนเนอร์ที่ทำงานอยู่ ถ่ายสแนปช็อตแบบอ่านอย่างเดียวข้าง appdata (ไม่เคยไว้ข้างใน ไม่อย่างนั้นทุกสแนปช็อตจะมีตัวก่อนหน้าอยู่ข้างใน) แล้วให้ทำงานต่อ มันบันทึกสิ่งที่พักไว้ในไฟล์รายการ และ trap จะปล่อยพวกมันไม่ว่าสคริปต์จะจบแบบไหน หลังรันจะปล่อยอะไรก็ตามที่รายการนั้นยังมีชื่ออยู่ก่อน (กันไว้สองชั้น สำหรับกรณีฮุกก่อนรันถูกฆ่ากลางคัน) แล้วเขียนไฟล์เก็บหนึ่งไฟล์ต่อโฟลเดอร์คอนเทนเนอร์จากสแนปช็อตลงโฟลเดอร์ที่ลงวันที่ของรอบนั้น — containers/<name>.tar.zst โฟลเดอร์คอนเทนเนอร์อยู่ระดับบนสุด ไม่มีอะไรซ้อน — แล้วจึงลบสแนปช็อต
User Script: appdata-snap-pre
#!/bin/bash
#description=Appdata.Backup PRE-run hook: pause running containers, read-only Btrfs snapshot of appdata beside it, unpause. Called by the plugin; do not schedule.
SNAP=/mnt/cache/.snapshots/appdata-backup
LIST=/mnt/cache/.snapshots/paused.list
mkdir -p /mnt/cache/.snapshots
btrfs subvolume delete "$SNAP" 2>/dev/null

# only containers that are running now; one you paused on purpose stays as it is
RUNNING=$(docker ps -q --filter status=running)
echo "$RUNNING" > "$LIST"
unpause() { [ -n "$RUNNING" ] && docker unpause $RUNNING >/dev/null 2>&1; rm -f "$LIST"; }
trap unpause EXIT          # whatever happens below, they come back

t0=$(date +%s%3N)
[ -n "$RUNNING" ] && docker pause $RUNNING >/dev/null
sync -f /mnt/cache
btrfs subvolume snapshot -r /mnt/cache/appdata "$SNAP"
rc=$?
unpause; trap - EXIT
msg="paused $(echo $RUNNING | wc -w) containers for $(( $(date +%s%3N) - t0 )) ms; snapshot rc=$rc"
echo "$msg"; logger -t appdata-snap-pre "$msg"   # the plugin discards hook output: the system log keeps it
exit $rc
User Script: appdata-snap-post
#!/bin/bash
#description=Appdata.Backup POST-run hook: one <container>.tar.zst per top-level appdata folder, from the snapshot, into the run's folder; then delete the snapshot. Called by the plugin with the destination as $2; do not schedule.
SNAP=/mnt/cache/.snapshots/appdata-backup
# belt and braces: if the pre hook was ever killed mid-pause, release exactly what it paused
LIST=/mnt/cache/.snapshots/paused.list
[ -s "$LIST" ] && docker unpause $(cat "$LIST") >/dev/null 2>&1; rm -f "$LIST"
DEST="${2:-}"                                  # the plugin passes: post-run <destination> <success>
OUT="$DEST/containers"
if [ -z "$DEST" ] || [ ! -d "$DEST" ]; then echo "no destination given; snapshot left in place"; exit 1; fi
if [ ! -d "$SNAP" ]; then echo "no snapshot at $SNAP"; exit 1; fi
mkdir -p "$OUT"
fail=0; n=0
for d in "$SNAP"/*/; do
  name=$(basename "$d")
  if tar -I 'zstd -T0 -3' -cf "$OUT/$name.tar.zst" -C "$SNAP" "$name"; then n=$((n+1)); else fail=1; echo "FAILED: $name"; fi
done
{ echo "per-container archives $(date '+%F %T')"; ls -la "$OUT"; } > "$OUT/MANIFEST.txt"
btrfs subvolume delete "$SNAP"
echo "archived $n folders to $OUT (failures: $fail)"
logger -t appdata-snap-post "archived $n folders to $OUT (failures: $fail)"   # the plugin discards hook output: the system log keeps it
if [ "$fail" = 1 ]; then
  /usr/local/emhttp/webGui/scripts/notify -e "Appdata backup" -s "per-container archives: failures" -d "see $OUT/MANIFEST.txt" -i alert
  exit 1
fi
มีข้อติดอยู่หนึ่งข้อ: ปลั๊กอินรันสคริปต์ฮุกโดยตรง และไม่มีอะไรบนไดรฟ์แฟลช (FAT32) ที่รันโดยตรงได้ — หน้าค่าตั้งของปลั๊กอินเองบอกไว้ ปลั๊กอินจึงได้ wrapper บรรทัดเดียวสองตัวบนพูล ซึ่งเรียกสำเนาใน User Scripts ด้วย bash User Scripts ยังเป็นต้นฉบับเดียว: แก้ที่นั่น ส่วน wrapper ไม่เคยเปลี่ยน User Script ตัวที่สามสร้าง wrapper ให้ รันมันครั้งเดียว
User Script: appdata-backup-install (Run Script ครั้งเดียว)
#!/bin/bash
S=/boot/config/plugins/user.scripts/scripts
W=/mnt/cache/appdata/appdata.backup-scripts
mkdir -p "$W"
for h in pre post; do
  [ -f "$S/appdata-snap-$h/script" ] || { echo "User Script appdata-snap-$h not found — create it first"; exit 1; }
  printf '#!/bin/bash\nexec bash %s "$@"\n' "$S/appdata-snap-$h/script" > "$W/appdata-snap-$h.sh"
  chmod 755 "$W/appdata-snap-$h.sh"
done
ls -la "$W"
echo "wrappers ready: $W/appdata-snap-pre.sh and appdata-snap-post.sh"
User Script: appdata-backup-rehearsal (Run in Background; นานเท่าการสำรองจริง)
#!/bin/bash
# dress rehearsal into a scratch folder: snapshot, one archive per container, snapshot deleted
W=/mnt/cache/appdata/appdata.backup-scripts
mkdir -p /mnt/user/backups/ab_test
"$W/appdata-snap-pre.sh" && "$W/appdata-snap-post.sh" post-run /mnt/user/backups/ab_test true
ls -la /mnt/user/backups/ab_test/containers | head
echo "now open one small archive in ab_test/containers with Zip Manager: it must extract to a <name>/ folder. Then delete /mnt/user/backups/ab_test in the file manager."
คาดว่าจะเห็นบรรทัด “socket ignored” สองสามบรรทัดจาก tar Unix socket (ซ็อกเก็ต IPC ของโปรแกรมทอร์เรนต์ ซ็อกเก็ต Wayland ของคอนเทนเนอร์เดสก์ท็อป) เก็บเป็นไฟล์ไม่ได้และไม่จำเป็นต้องเก็บ คอนเทนเนอร์สร้างมันใหม่ตอนเริ่ม
ทำไมก่อนรันต้องลบแล้วค่อยสร้าง ถ้าการสำรองถูกขัดจังหวะ สแนปช็อตก่อนหน้ายังอยู่ สคริปต์ก่อนรันต้องไม่ล้มเหลวเพราะเรื่องนั้น การลบก่อน (ไม่สนข้อผิดพลาด) ทำให้มันรันซ้ำได้อย่างปลอดภัย
การพักทำอะไรกับคอนเทนเนอร์ docker pause คือ cgroup freezer: โปรเซสหยุดตรงที่มันอยู่ และทำงานต่อจากตรงที่มันเคยอยู่ ไม่มีสัญญาณถูกส่ง ไม่มีอะไรจบการทำงาน การเชื่อมต่อ TCP ที่เปิดอยู่จะรอ สตรีมสื่อบัฟเฟอร์หนึ่งวินาที healthcheck ที่ถึงกำหนดระหว่างการพักจะรันหลังจากนั้น แตะเฉพาะคอนเทนเนอร์ที่ทำงานอยู่ตอนฮุกเริ่ม — ตัวที่คุณพักไว้เองจะถูกปล่อยไว้แบบพัก — และไฟล์รายการระบุชื่อเฉพาะพวกนั้น การปล่อยจึงไม่เคยแตะอย่างอื่น

ขั้นที่ 4 — ค่าตั้งของ Appdata.Backup

Settings → Backup/Restore Appdata โมเดลของปลั๊กอินอิงตาม volume: volume ของคอนเทนเนอร์เป็น “ภายใน” ถ้าอยู่ข้างในแหล่ง Appdata เมื่อแหล่งชี้ไปที่สแนปช็อต ไม่มี volume ของคอนเทนเนอร์ไหนอยู่ข้างใน ปลั๊กอินจึงบันทึกว่า does not have any volume to back up สำหรับทุกคอนเทนเนอร์ — ซึ่งเป็นไปตามคาด ในการออกแบบนี้ ตัวปลั๊กอินดูแลการสำรองแฟลชและเมตาของ VM ตารางเวลา ระยะการเก็บ และการอัปเดตคอนเทนเนอร์ ส่วนไฟล์เก็บของ appdata มาจากฮุกหลังรัน ปล่อย Include extra files/folders ว่างไว้ ไม่อย่างนั้นคุณจะได้สำเนาขนาดยักษ์ชุดที่สองของทุกอย่าง
ค่าตั้งค่าเหตุผล
Backup typeStop all containers, backup, start allไม่มีผลเมื่อเปิด skip-stopping ไว้ ปล่อยค่าเริ่มต้น
Appdata source(s)/mnt/cache/.snapshots/appdata-backupต้นไม้ที่ถูกแช่แข็ง ไม่ใช่ต้นไม้สด
Backup destination/mnt/user/backups/ปลั๊กอินสร้างโฟลเดอร์ ab_… ที่ลงวันที่ต่อรอบ ไฟล์เก็บอยู่ในนั้น
Use compressionYes, multicoreใช้กับไฟล์เก็บของปลั๊กอินเอง (แฟลช) ไม่มีพิษภัย
Backup the flash driveYesลงในโฟลเดอร์ที่ลงวันที่เดียวกัน ระยะการเก็บเดียวกัน
Backup VM metaYesใช้ทรัพยากรน้อย
Advanced → Skip stopping of containers?Yesนี่คือจุดประสงค์ทั้งหมด เป็นค่าเริ่มต้นส่วนกลาง ปรับแยกรายคอนเทนเนอร์ได้
Pre-run script/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.shWrapper → ถ่ายสแนปช็อต
Post-run script/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.shWrapper → เขียนไฟล์เก็บรายคอนเทนเนอร์ และลบสแนปช็อต
Include extra files/foldersว่างฮุกหลังรันทำการเก็บไฟล์ พาธตรงนี้จะเพิ่มสำเนาเต็มชุดที่สอง
Scheduleทุกวัน ชั่วโมงไหนก็ได้มันไม่หยุดอะไรเลย จึงรันวันไหนเวลาไหนก็ได้
Delete backups older than / keep at least5 / 5appdata และแฟลชของห้าวันล่าสุด ปรับขนาดตามอาร์เรย์ของคุณ
Update containers after backup?ใช่ — มีข้อยกเว้นดูด้านล่าง
การอัปเดตคือการรีสตาร์ตเดียวที่เหลืออยู่ในรอบ เมื่อปิดการหยุดแล้ว สิ่งเดียวที่ขัดจังหวะคอนเทนเนอร์ได้คือขั้นตอน “update containers after backup” ซึ่งดึงอิมเมจใหม่และสร้างตัวที่มีอิมเมจใหม่ขึ้นใหม่ สำหรับคอนเทนเนอร์ที่ทำงานยาว ๆ — เซิร์ฟเวอร์สื่อที่กำลังสแกน การนำเข้าคลัง การบิลด์ — ให้คลิกชื่อมันในรายการคอนเทนเนอร์ของปลั๊กอิน แล้วตั้ง Update container: No และอัปเดตมันเองด้วยมือ
ถ้ากด Save แล้วดูเหมือนไม่เกิดอะไร ฟอร์มค่าตั้งตรวจทุกพาธ: สคริปต์ที่ยังไม่มีอยู่ หรือโฟลเดอร์ปลายทางที่ยังไม่มี จะทำให้การบันทึกทั้งหมดถูกปฏิเสธแบบเงียบ ๆ และหน้าจะโหลดใหม่พร้อมค่าเดิม รันสคริปต์ติดตั้งและสร้างโฟลเดอร์ปลายทางก่อน แล้วค่อยบันทึก

ตรวจสอบ

กด Manual backup หนึ่งครั้งขณะที่คุณเฝ้าดู แล้วอ่านแท็บ Status / Log เวลาด้านล่างมาจาก appdata ขนาด 155 GB ที่มี 47 คอนเทนเนอร์
สิ่งที่ล็อกของปลั๊กอินแสดงในรอบที่ดี
[08:30:01][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.sh' 'pre-run' ...
[08:30:04][Main] Script executed!                        (pause + snapshot + unpause: three seconds)
[08:30:04][Main] Backing up from: /mnt/cache/.snapshots/appdata-backup
[08:31:06][Main] Auto-Update for 'jellyfin' is enabled but no update is available.
   ... one such line per container ...
[08:31:06][jellyfin] Stopping jellyfin... NOT stopping jellyfin because it should be backed up WITHOUT stopping!
   ... one such line per running container ...
[08:31:06][jellyfin] jellyfin does not have any volume to back up! Skipping. Please consider ignoring this container.
   ... one such warning per container: expected, the source is the snapshot, not the live folder ...
[08:31:06][Main] Backing up the flash drive.
[08:33:31][Main] VM meta backup enabled! Backing up...
[08:33:31][Main] Checking retention...
[08:33:31][Main] Delete old backup: /mnt/user/backups/ab_20260916_083001
[08:33:31][Main] DONE! Thanks for using this plugin and have a safe day ;)
[08:33:32][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.sh' 'post-run' ...
[08:56:15][Main] Script executed!                        (the archives are finished here, not at DONE)
สิ่งที่ล็อกระบบแสดง (Tools → System Log)
appdata-snap-pre: paused 40 containers for <n> ms; snapshot rc=0          (a few hundred to a couple of thousand ms)
appdata-snap-post: archived 47 folders to /mnt/user/backups/ab_20260924_083001/containers (failures: 0)
เวลาไปอยู่ที่ไหน และข้อความไปอยู่ที่ไหน ส่วนของปลั๊กอินเอง — แฟลชและเมตาของ VM — ใช้เวลาราวสามนาที จากนั้นฮุกหลังรันจะบีบอัดทุกโฟลเดอร์คอนเทนเนอร์: เผื่อเวลาราว 20–30 นาทีต่อ appdata 100 GB ด้วย zstd แบบหลายคอร์ (23 นาทีสำหรับบ้านนี้) ปลั๊กอินพิมพ์ DONE! ก่อนที่ฮุกหลังรันจะเริ่มด้วยซ้ำ ส่วน Script executed! ครั้งที่สองคือตอนที่ไฟล์เก็บมีอยู่จริง ปลั๊กอินทิ้งทุกอย่างที่ฮุกพิมพ์ออกมา นี่คือเหตุผลที่ฮุกทั้งสองเขียนสรุปหนึ่งบรรทัดของมันลงล็อกระบบด้วย logger ด้วย — ที่นั่นคือที่อยู่ของเวลาที่พักและจำนวนไฟล์เก็บ และความล้มเหลวจะส่งการแจ้งเตือนของ Unraid เพิ่มด้วย
สองอย่างที่คุณจะเห็นในล็อกนั้น คำเตือน “does not have any volume to back up! Skipping. Please consider ignoring this container” ปรากฏครั้งเดียวต่อคอนเทนเนอร์ — อย่าทำตามคำแนะนำ: คอนเทนเนอร์ที่ถูกตั้งว่า “skip” จะถูกตัดออกจากขั้นตอนอัปเดตด้วย และบรรทัดใดที่เขียนว่า “'some_volume' does NOT exist! Please check your mappings!” คือปลั๊กอินบอกคุณว่าคอนเทนเนอร์นั้นใช้ Docker named volume ซึ่งอยู่ในพื้นที่เก็บของ Docker เอง ไม่ใช่ใน appdata — มันไม่อยู่ในการสำรองนี้ และก็ไม่เคยอยู่ในการสำรองแบบหยุดทุกอย่างด้วย Nextcloud AIO คือกรณีที่พบบ่อย (ฐานข้อมูลและไฟล์ของมันเป็น named volume ให้ใช้การสำรอง Borg ในตัวจากคอนโซล AIO) อิมเมจ Postgres และฐานข้อมูลบางตัวก็เป็นแบบเดียวกัน สำรองพวกนั้นด้วยเครื่องมือของมันเอง

จากนั้นดูในตัวจัดการไฟล์: backups/ab_<date>/ มี containers/ ที่มี <name>.tar.zst หนึ่งไฟล์ต่อโฟลเดอร์ appdata และ MANIFEST.txt อยู่ข้างไฟล์ zip สำรองแฟลช เทมเพลต XML ของคอนเทนเนอร์ และ vm_meta.tgz ส่วน /mnt/cache/.snapshots/ ต้องว่างระหว่างรอบ — ไม่มีสแนปช็อตและไม่มี paused.list — และหน้า Docker ต้องแสดงทุกคอนเทนเนอร์ทำงานอยู่ ด้วยเวลาทำงานต่อเนื่องเท่าเดิมก่อนสำรอง

การกู้คืน

แต่ละไฟล์เก็บเปิดออกมาเป็นโฟลเดอร์ของคอนเทนเนอร์ตรง ๆ การกู้คืนจึงเป็นงานของตัวจัดการไฟล์ ทดสอบสักครั้งตอนนี้กับโฟลเดอร์ทดลอง ไม่ใช่ในวันที่คุณต้องใช้ — ขั้นตอนเดียวกัน แค่แตกไฟล์ไว้ข้างโฟลเดอร์จริงแทนการทับมัน
  1. หยุดคอนเทนเนอร์ในหน้า Docker
  2. เปลี่ยนชื่อโฟลเดอร์จริงในตัวจัดการไฟล์: appdata/<name> → appdata/<name>.broken เก็บไว้จนกว่าคอนเทนเนอร์ที่กู้คืนจะพิสูจน์ได้ว่าใช้งานได้
  3. แตกไฟล์เก็บด้วย Zip Manager: ไปที่ backups/ab_<date>/containers/<name>.tar.zst เลือก Extract แล้วแตกไปยังที่ชั่วคราวเช่น backups/restore ผลที่ได้คือโฟลเดอร์ <name>/ เดียวที่มีไฟล์ของคอนเทนเนอร์ โดยเจ้าของไฟล์ยังครบ
  4. ย้ายโฟลเดอร์นั้นเข้า appdata ด้วยตัวจัดการไฟล์ (Move ไม่ใช่ Copy: มันอยู่บนอาร์เรย์ และ appdata อยู่บนพูล เบื้องหลังจึงเป็นการคัดลอก และใช้เวลาสักครู่สำหรับคอนเทนเนอร์ใหญ่)
  5. เริ่มคอนเทนเนอร์ เมื่อมันทำงานปกติแล้ว ให้ลบ <name>.broken

การซ้อมคือขั้นที่ 3 และ 4 ลงโฟลเดอร์ทดลองเท่านั้น แล้วดูสองโฟลเดอร์เทียบกัน: จำนวนไฟล์และขนาดเท่ากัน และมีแค่ไฟล์ฐานข้อมูล (พร้อมไฟล์คู่ -wal/-shm) และล็อกที่ใหม่กว่าในโฟลเดอร์จริง — ทุกอย่างที่ถูกเขียนหลังสแนปช็อต

การกู้คืนเดียวกันในรูป User Script ตัวเดียว ถ้าคุณชอบแบบนั้นมากกว่า (แก้ NAME และ DATE; หยุดคอนเทนเนอร์ก่อน)
#!/bin/bash
NAME=sonarr
DATE=20260919_083002
mv "/mnt/cache/appdata/$NAME" "/mnt/cache/appdata/$NAME.broken"
tar --zstd -xf "/mnt/user/backups/ab_$DATE/containers/$NAME.tar.zst" -C /mnt/cache/appdata
ls -la "/mnt/cache/appdata/$NAME" | head
echo "start the container; delete $NAME.broken once it is proven"

กับดักที่ต้องระวัง