跳转到帖子
在手机APP中查看

一个更好的浏览方法。了解更多

PHP论坛人

主屏幕上的全屏APP,带有推送通知、徽章等。

在iOS和iPadOS上安装此APP
  1. 在Safari中轻敲分享图标
  2. 滚动菜单并轻敲添加到主屏幕
  3. 轻敲右上角的添加按钮。
在安卓上安装此APP
  1. 轻敲浏览器右上角的三个点菜单 (⋮) 。
  2. 轻敲添加到主屏幕安装APP
  3. 轻敲安装进行确认。

Cron基礎、Cron排程、IPS論壇背景任務、自動備份、備份監控、log日誌、logrotate日誌輪替

精选回复

Cron基礎、Cron排程、IPS論壇背景任務、自動備份、備份監控、log日誌、logrotate日誌輪替



適用環境:Debian 13+ Nginx + MariaDB 11.8 + PHP 8.3-FPM

VPS規格:2 vCPU / 2GB RAM(本篇所有效能門檻、資源禮讓策略與排程時間安排皆以此規格為基準)



本篇涵蓋範圍

本篇是本系列教學中份量最重的一篇,一次講完「讓論壇的例行工作準時自己跑」「每天晚上自動存檔」「每天早上自動確認存檔沒有壞掉」這三件事。

全篇建議依序閱讀,不建議跳讀——後面章節會直接沿用前面章節建立的帳號、目錄與排程檔:


章節:1~3
內容:cron/systemd timer 分工、crontab 基礎環境、語法與撰寫地雷
大致對應:前置知識


章節:4
內容:IPS論壇背景任務 Cron 化
大致對應:讓論壇的例行工作準時自己跑


章節:5~8
內容:備份策略設計、資料庫帳號、backup.sh、check_backup.sh
大致對應:每天晚上自動存檔+每天早上自動確認沒壞掉


章節:9
內容:統一排程檔 /etc/cron.d/forum-backup、日誌輪替
大致對應:把前面所有排程整合起來


章節:10
內容:驗收清單
大致對應:逐項確認本篇設定


章節:11~13
內容:Recovery/Rollback SOP、異地備份與加密、Logging 與 ELK 整合
大致對應:延伸與強化


章節:14~16
內容:常見錯誤排查、新手老手盲點總整理、附錄速查表
大致對應:疑難排解與速查




backup.sh 與 check_backup.sh 經過實測與稽核,修掉的都是正式環境遲早會踩到、但初次撰寫時完全想不到的坑(第 14、15 章會逐一說明)。

若只想要「能動就好」的最小版本,可先看 16.4 節的簡化建議,但正式站台仍建議部署完整版。





---------------------------------------------------------------
第 1 章:前置知識——cron 與 systemd timer 的分工
---------------------------------------------------------------

本系列教學中,acme.sh 的憑證自動續簽已改用 systemd timer 機制(.timer / .service 單元),不再依賴 crontab。

但這不代表 crontab 在 Debian 13 的 LNMP 環境已無用武之地——crontab 仍是以下日常維運工作最直覺的排程方式:


資料庫與網站檔案自動備份(本篇第 7 章實作)

IPS論壇背景任務(Task)的定時觸發(本篇第 4 章實作)

SSH MOTD 監控面板資料更新(GeoLite2 資料庫更新、線上人數快取等,見本系列其他章節)

系統巡檢、磁碟用量報告等輕量腳本






分工原則:


情境:單純執行一支腳本,沒有相依服務、沒有重試需求
建議使用:crontab
原因:設定簡單,一行搞定


情境:需要在執行前後啟動/重啟其他服務(reload nginx、重啟 php-fpm)
建議使用:systemd timer + service
原因:Wants= / After= 可宣告服務相依


情境:需要詳細執行日誌、執行狀態查詢(systemctl status)
建議使用:systemd timer
原因:與 journalctl 整合更完整


情境:需要執行逾時、失敗自動重試
建議使用:systemd timer
原因:OnFailure=、Restart=


情境:多人協作、需要版本控管排程設定
建議使用:兩者皆可,但 /etc/cron.d/ 下的檔案比 crontab -e 更適合納入版控
原因:



本篇第 4、7~9 章(IPS論壇背景任務、資料庫與網站備份、備份監控)全部整合於 /etc/cron.d/forum-backup 統一管理,

理由是:backup.sh 與 check_backup.sh 都已在腳本內部自行處理鎖定(flock)、逾時(timeout)、結構化日誌(JSON Lines + logger),

systemd timer 能提供的「日誌整合」「逾時控制」優勢,腳本已用更細緻的方式自行實作;

把這三件事整合於單一 cron 檔案,也比拆成五、六組 .service / .timer 檔案更容易通篇檢視、納入 Git 版控與 Ansible 管理。



這不代表本系列教學否定 systemd timer——憑證續簽選用 systemd timer,是因為那個情境確實需要相依服務重啟等 systemd 特有能力;

本篇日誌輪替(第 9 章)也會沿用 Debian 13 系統預設的 logrotate.timer,而不會另外改成 cron 觸發。

兩種機制在本系列裡是依情境並用,不是互斥選擇。







---------------------------------------------------------------
第 2 章:crontab 基礎環境建置
---------------------------------------------------------------

2.1 確認並安裝 cron 套件

dpkg -l | grep -E '^ii\s+cron\b'


若沒有任何輸出,代表尚未安裝:

apt update


apt install cron -y


Debian 套件庫的 cron 套件是 Vixie cron 系列的延伸版本,依賴 cron-daemon-common 提供設定檔骨架,

安裝 cron 時會一併裝入,不需要另外手動安裝。

市面上另有相容套件 cronie(同樣相容 ISC cron 語法,並多了 inotify、PAM 整合特性)。

本篇全程以 Debian 官方預設的 cron 套件為基準,請不要混用 cron 與 cronie,兩者互相衝突(dpkg 會擋下同時安裝)。






2.2 啟用並啟動 cron 服務

systemctl enable --now cron


systemctl enable 只是建立開機啟用的軟連結,並不會啟動服務;

systemctl start 才是真正啟動服務本身,

--now 讓兩個動作一次完成。







2.3 確認 cron 執行狀態

systemctl status cron


正常應看到:
Loaded: loaded (/usr/lib/systemd/system/cron.service; enabled; preset: enabled)
Active: active (running) since ...


Loaded 那行的 enabled 代表開機會自動啟動;

Active 那行的 active (running) 代表服務目前正在執行中。

若顯示 inactive (dead),執行 systemctl start cron;

若顯示 failed,用 journalctl -u cron -n 50 --no-pager 查看詳細錯誤。



補充:cron 在 Debian 13 上本身也是由 systemd 管理的常駐服務(cron.service),所以這裡才能用 systemctl 查詢狀態;

這與第 1 章「本篇備份/監控任務改用 cron 排程、不建立獨立 .timer/.service 單元」的決定並不衝突,兩者是不同層次的事。






2.4 crontab 編輯環境(crontab -e)

本篇後續章節的排程都會寫進系統層級的 /etc/cron.d/forum-backup(見 3.3 節),不會用到個人的 crontab -e;

但為求完整,這裡仍簡單說明編輯環境,供日後臨時排程使用。


第一次執行 crontab -e 時,系統會詢問要用哪個編輯器作為預設,選單內容取決於系統當下已安裝的編輯器:全新安裝、未額外安裝完整版 vim 的 Debian 13 VPS,

通常只會看到 nano 與 vim.tiny;vim.basic 僅在 apt install vim 之後才會出現在選單中。

之後想更換編輯器,執行 select-editor 即可重新選擇(設定寫在 ~/.selected_editor,每個使用者各自獨立)。



若選用 vim,基本離開操作:

情境:未修改,直接離開
指令:Esc → :q → Enter


情境:已修改,存檔離開
指令:Esc → :wq → Enter(或 ZZ)


情境:已修改,放棄修改強制離開
指令:Esc → :q! → Enter



存檔離開後,crontab -e 會自動驗證語法並提示 crontab: installing new crontab;

若語法有誤(欄位數量不對、時間範圍超出合法值),系統會直接擋下並要求重新編輯,不會讓壞掉的排程被安裝進去。






---------------------------------------------------------------
第 3 章:crontab 語法與撰寫地雷
---------------------------------------------------------------

3.1 標準格式


分鐘 小時 日期 月份 星期 要執行的指令
*    *    *    *    *    /path/to/script.sh


欄位:分鐘
合法範圍:0-59
說明:


欄位:小時
合法範圍:0-23
說明:24 小時制


欄位:日期
合法範圍:1-31
說明:當月第幾天


欄位:月份
合法範圍:1-12
說明:也可用英文縮寫如 jan、feb


欄位:星期
合法範圍:0-7
說明:0 與 7 都代表星期日;也可用 sun、mon



特殊字元:
*(每一個)
,(列舉)
-(範圍)
/(間隔,如 /15)
@reboot(開機後執行一次)



時區提醒:cron 預設使用系統時區(/etc/localtime,可用 timedatectl 查詢)。

VPS 預設多為 UTC,若維運排程是以 UTC+8 規劃,務必先確認系統時區設定,否則排程時間會整體偏移 8 小時。






3.2 撰寫時的常見錯誤

1. 一定要用絕對路徑。 

cron 執行環境的 PATH 遠比互動式登入 shell 簡略,預設通常只有 /usr/bin:/bin,不會載入 ~/.bashrc 額外加上的路徑:


# 錯誤:在終端機測試正常,cron 裡執行可能失敗
30 3 * * * root mariadb-dump forum_db > /backup/forum_db.sql


# 正確
15 3 * * * root /usr/bin/mariadb-dump forum_db > /backup/forum_db.sql



自 MariaDB 11.0 起,mysqldump 已被標記為棄用(deprecated)的相容符號連結,官方建議統一改用新名稱 mariadb-dump。

本篇全程使用 mariadb-dump,請勿沿用舊指令;

同理,mysql / mysqladmin 也一律改用 mariadb / mariadb-admin。

可用 which mariadb-dump、which php8.3、which flock、which pigz 快速查出絕對路徑。

腳本內部呼叫的所有外部指令同樣要用絕對路徑,不要只在 crontab 那一行注意。



2. 工作目錄不可信任。cron 執行腳本時的當前工作目錄通常是執行使用者的家目錄,不是腳本檔案所在目錄。

若腳本內用到相對路徑,容易因工作目錄不對而讀不到檔案,建議腳本開頭明確 cd 或全部使用絕對路徑。



3. 2GB RAM VPS環境下的排程時間安排。避免把多個重量級任務排在同一時間點。

資料庫備份、logrotate、apt 自動更新若同時在凌晨搶 CPU 與記憶體,可能拖慢甚至打斷正在運作的 Nginx/PHP-FPM/MariaDB 服務。

本篇最終排程時間安排請見第 9 章。



4. 任務輸出與通知。cron 若無 MTA(postfix、exim4、s-nail 等),寄信通知會直接失敗,你完全不會知道任務有沒有出錯,

這是生產環境中危險的「靜默失敗」。本篇的排程已把所有輸出導向日誌檔並不設定 MAILTO,靠腳本自身的結構化日誌與 journalctl 監控,

不依賴 cron 寄信(若需要主動告警,見 13.7 節的包裝腳本)。



5. 用 flock 避免任務重疊執行。若任務理論上每分鐘或每幾分鐘執行一次,但執行時間偶爾拉長,就可能發生前一次還沒跑完、下一次又被觸發,

導致同一支腳本同時有兩個以上的行程搶資源,這在 2GB RAM VPS環境特別危險:

*/15 * * * * root /usr/bin/flock -n /run/lock/motd_cache_update.lock /usr/local/bin/motd_cache_update.sh


-n(non-blocking):若鎖定檔案已被佔用,立刻放棄本次執行,不堆積等待。

鎖定檔建議放在 /run/lock/(systemd 管理的 tmpfs,重開機後自動清空,不需擔心 stale lock 殘留),

而非 /tmp(清理策略因系統而異,可能留下殘留)。



重要例外:第 7 章的 backup.sh 已自行處理鎖定,crontab 層不應該再對同一顆鎖定檔包一層 flock,

否則會造成死結(第 9.4 節有完整說明),這裡先建立印象即可。



6. % 符號在 cron 指令欄位中有特殊意義。cron 會把指令欄位裡未跳脫的 % 視為換行符號,

只取第一行當指令,% 之後的內容會被當成標準輸入餵給指令,而非原樣傳遞。

這在直接於 crontab 行內使用 date +\%Y\%m\%d 之類語法、或指令參數本身含有 % 字元(例如某些 URL 編碼過的字串)時最容易踩到,

且通常只有指令「執行結果跟預期不一樣」,不會有任何錯誤訊息可查。

若指令行內確實需要用到 %,必須以 \% 跳脫;

本篇 9.3 節整合的排程統一呼叫獨立腳本檔案,把組字串/日期運算都留在腳本內部處理,crontab 那一行本身不含 %,

因此不受影響,但這是撰寫任何 cron 指令時都值得留意的通則。






3.3 系統層級排程位置:/etc/cron.d/

除了 crontab -e 這種「每個使用者各自一份」的排程方式,Debian 的 cron 套件另外提供系統層級排程位置 /etc/cron.d/。

本篇的所有排程(IPS論壇任務、備份、監控)全部整合於 /etc/cron.d/forum-backup,不使用個別使用者的 crontab -e。



選擇 /etc/cron.d/ 而非 crontab -e 的原因:

1. 可納入版本控制:crontab -e 編輯的內容存放在 /var/spool/cron/crontabs/,不建議直接用 Git 追蹤;

/etc/cron.d/ 下的檔案是普通文字檔,可以連同其他設定檔一起放進 Git 倉儲。

2. Ansible 可直接管理:copy 或 template 模組可直接部署,重建主機時一個 playbook 還原所有排程。

3. 可以明確標註用途:一個檔案對應一個維護目的,比所有排程都塞在同一份 crontab -l 更易讀、易稽核。

4. 重建伺服器時更容易還原:只要備份 /etc/cron.d/ 底下的檔案,重建時複製回去即可。



權限要求:檔案擁有者必須是 root,且不可被 group 或 other 寫入,否則 cron 會直接忽略該檔案,

這是常見的「明明寫對了排程卻完全沒被觸發」的原因之一。

一般 /etc/cron.d/ 檔案常見設為 644(root 可讀寫、其他人僅可讀),這對大多數不含機敏資訊的排程檔沒有問題:


chown root:root /etc/cron.d/範例


chmod 644 /etc/cron.d/範例



本篇的例外:稍後第 9.3 節組裝的 /etc/cron.d/forum-backup 內會直接寫入IPS論壇背景任務的驗證金鑰(明碼),屬於機敏資訊。

cron daemon 是以 root 身份讀取 /etc/cron.d/ 底下的檔案,並不需要該檔案對 other 可讀就能正常運作,

因此這份檔案請改用 640(root 可讀寫、root 群組可讀、other 完全不可讀)而非慣例的 644,避免主機上其他有 shell 帳號的使用者直接讀到金鑰。

本篇後續章節皆以 640 示範這份檔案的權限。


另一個容易忽略的細節:檔案結尾必須有換行符。

/etc/cron.d/ 底下的檔案若最後一行沒有以換行字元結尾(常發生於用某些編輯器存檔、或用指令拼接檔案內容時漏加),cron 會安靜地忽略檔案中的最後一行排程,

其餘行仍正常運作,因此很容易被誤以為「大部分排程都生效了,應該沒問題」,直到某天發現最後一項任務從未執行過。

用 tail -c 1 檔名 | xxd 確認最後一個位元組是否為換行符(0a),或直接用 vi/nano 存檔時保留檔案結尾的空行。


給習慣用 Windows 編輯器的人的提醒:留意 UTF-8 BOM。

本篇排程檔內含中文註解,若用某些 Windows 上的編輯器另存新檔,可能會在檔案最開頭悄悄加上 3 個位元組的 UTF-8 BOM(EF BB BF)。

若 BOM 出現在檔案第一行(例如 SHELL=/bin/bash 那一行)之前,cron 解析這行時會因為開頭多了看不見的位元組而視為語法錯誤,導致整份排程檔被忽略,

且錯誤訊息通常只在 journalctl -u cron 裡才看得到,不會有任何畫面提示。

用 head -c 3 /etc/cron.d/forum-backup | xxd 確認開頭三個位元組不是 efbbbf;

建議直接在 VPS 上用 vi/nano 編輯,或上傳後執行 sed -i '1s/^\xef\xbb\xbf//' /etc/cron.d/forum-backup 清除 BOM。


另一個新手極易踩到、且完全沒有錯誤訊息的地雷:檔名不可包含「.」(點號)。

Debian 的 cron 套件要求 /etc/cron.d/ 底下的檔名必須只由英數字、底線、連字號組成,

這個限制沿用自 run-parts(8) 的命名規則(目的是避免 dpkg 升級時留下的 .dpkg-dist/.dpkg-old 等殘留檔被誤當成排程執行)。

凡是檔名中含有點號(例如 forum-backup.conf、forum_backup.cron)的檔案,cron 會直接靜默忽略,既不會出現在 journalctl -u cron 的錯誤訊息中,

也不會有任何提示——你只會發現排程「就是沒有被觸發」。

本篇統一使用的檔名 forum-backup(不含副檔名)完全符合這項規則,之後若要新增其他排程檔,請比照辦理,不要圖方便加上 .conf、.cron 這類副檔名。



/etc/cron.d/ 的寫法與 crontab -e 不同,每行多一欄「使用者」:

# /etc/cron.d/範例
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

30 3 * * * www-data /usr/local/bin/forum_cache_clean.sh


補充:/etc/cron.daily/、/etc/cron.weekly/ 等目錄底下的腳本,是透過 run-parts 統一執行,檔名不可有副檔名(.sh 會被跳過),

且執行時間無法精確設定(依賴 /etc/crontab 與 anacron)。

本篇的備份腳本需要在凌晨 3:15 這種精確時間窗口執行,因此改用 /etc/cron.d/ 搭配精確時間控制,而非放進 /etc/cron.daily/。


完整的 /etc/cron.d/forum-backup 內容會在第 9 章逐步組裝完成。






---------------------------------------------------------------
第 4 章:IPS論壇背景任務 Cron 化
---------------------------------------------------------------

4.1 為什麼需要改用 Cron 觸發

IPS論壇內部有大量「背景任務(Task)」需要定期執行,例如寄送摘要信件、清理過期 Session、處理排程貼文、重建搜尋索引等。

預設情況下(依訪客瀏覽觸發),任務是靠真實會員瀏覽網站時順帶觸發——每次有請求進來,系統會順便檢查「現在是不是該執行某個任務了」。

這種方式對流量不高的論壇有明顯缺點:若某段時間沒有訪客(例如半夜),任務就會一直累積、等到下一位訪客出現才補跑,造成延遲,

論壇後端甚至會跳出「任務出現延遲」的警示。

因此官方建議、也是本章要設定的方式:改用 Cron 觸發(By Cron Job),由系統層級排程定時呼叫 IPS 提供的 CLI 腳本,不依賴訪客瀏覽,任務會準時被觸發。






4.2 在 AdminCP 啟用「使用 Cron」並取得執行指令

1. 瀏覽器登入IPS論壇後端(AdminCP)。

2. 前往「系統」→「進階配置」→ 勾選「使用 Cron(推薦)」。

3. 按下儲存。

儲存後會顯示一段類似格式的指令:

/usr/bin/php -d memory_limit=-1 -d max_execution_time=0 /var/www/域名.com/applications/core/interface/task/task.php 你的驗證金鑰


最後那串英數字混合字串是系統隨機產生的驗證金鑰,作用類似密碼,用來確認呼叫真的是你(或你信任的排程)發出的。

請勿在論壇求助文、截圖、公開教學或版本控制記錄中外洩這組金鑰,處理方式請比照一般密碼等級的機密資訊。

建議直接複製貼上,不要手動輸入——長串英數字人工輸入容易打錯,導致 cron 任務「看起來有跑、但其實一直驗證失敗」。



PHP 版本提醒:上方指令中的 /usr/bin/php 在多數環境下會依 update-alternatives 指向目前預設的 PHP 版本。

本篇鎖定 PHP 8.3,請把指令改為明確呼叫 /usr/bin/php8.3,避免日後主機上安裝其他 PHP 版本、update-alternatives 指向改變時,任務悄悄用錯誤的 PHP 版本執行。



新手常見遺漏:/usr/bin/php8.3 可能根本不存在。

本系列前面章節安裝的是 php8.3-fpm(FPM SAPI,供 Nginx 透過 socket 呼叫),這個套件不會一併安裝命令列可執行的 php8.3 指令;

命令列執行需要另外安裝 php8.3-cli。

執行以下指令確認:


which php8.3 || echo "尚未安裝 php8.3-cli"


dpkg -l | grep -E '^ii\s+php8\.3-cli\b'



若尚未安裝:

apt install php8.3-cli -y



重要版本提醒:本系列全程鎖定 PHP 8.3,前提是本系列基礎 LNMP 安裝章節已改用 Ondřej Sury 的第三方 PHP 套件庫(packages.sury.org/php/)安裝 php8.3-fpm / php8.3-cli,

而非官方套件庫的預設版本。若你是從本章才開始接手一台全新、尚未依本系列前面章節設好 Sury 套件庫的 Debian 13 主機,上方 apt install php8.3-cli -y 會直接找不到套件而失敗,請先確認:

apt-cache policy php8.3-cli 2>/dev/null | head -5

grep -r "packages.sury.org" /etc/apt/sources.list.d/ 2>/dev/null


若兩者皆無輸出,代表 Sury 套件庫尚未設定,請先回頭完成基礎安裝章節的步驟,再繼續本章後續設定。






4.3 決定執行身份:為什麼是 www-data 而非 root

這是生產環境中最容易被忽略的一個決策點。

許多教學會直接把上面IPS論壇那行指令貼到 root 的 crontab 裡,功能上可以運作,但埋下隱藏地雷:

IPS論壇背景任務執行過程中,有些任務會寫入論壇程式目錄底下的檔案(快取檔、附件處理暫存檔、搜尋索引檔案等)。

若任務以 root 身份執行,這些新建檔案的擁有者就會變成 root;

但你的 Nginx + PHP-FPM 平常是以 www-data 身份讀寫這個目錄。

久而久之,論壇目錄底下會混雜 www-data 與 root 兩種擁有者的檔案,可能造成:


PHP-FPM(www-data 身份)想覆寫某個由 root 任務建立的檔案時,遇到 Permission denied。

日後做網站搬遷或還原時,tar/rsync 複製出來的檔案權限混亂,還原到新機器後論壇出現大量「無法寫入」錯誤。

排查問題時很難第一時間意識到是「cron 任務用錯身份執行」造成的,因為錯誤往往在完全不相關的操作中才浮現。



正確做法:讓IPS論壇背景任務以 www-data(PHP-FPM/Nginx 實際運作的身份)執行,任務寫入的任何檔案擁有者天生就跟網站本身一致,

符合最小權限原則。確認你的 PHP-FPM 與 Nginx 實際執行身份(依本系列前面章節設定,預設應為 www-data):


ps aux | grep php-fpm


ps aux | grep nginx


新手常見誤解:www-data 的登入 shell 是 /usr/sbin/nologin,這樣 cron 還能用這個帳號執行任務嗎?

可以,完全不受影響。

nologin 只會擋下「互動式登入」(例如 su - www-data 或 SSH 登入),

cron//etc/cron.d/ 這類排程機制是由 cron daemon 直接以指定使用者身份 fork 出行程執行指令,並不透過登入流程,因此不受 /etc/passwd 裡的 shell 欄位限制。

這也是為什麼 9.3 節的 /etc/cron.d/forum-backup 檔案開頭要明確宣告 SHELL=/bin/bash ——這行指定的是「cron 執行這行指令時要用哪個 shell 解讀語法」,

與 www-data 本身能不能登入是兩件互不相關的事。






4.4 建立任務日誌目錄

/var/log/ 預設只有 root 可以建立新檔案,若任務以 www-data 身份執行卻把輸出導向 /var/log/xxx.log,會因無寫入權限而失敗,需先建立專屬目錄:


mkdir -p /var/log/ips-task


chown www-data:www-data /var/log/ips-task


chmod 750 /var/log/ips-task



IPS論壇背景任務的 crontab 設定,已整合進第 9 章的 /etc/cron.d/forum-backup 統一管理,

屆時一次貼上即可,不需要另外執行 crontab -u www-data -e






4.5 確認 AdminCP 已切換為 Cron 模式

這是常見的遺漏點:很多管理員設定完排程後,忘記回到 AdminCP 確認論壇後端是否真的切換到了「Cron」模式,導致兩套機制同時並行。

請再次確認「系統」→「進階配置」中背景任務觸發方式已選擇「使用 Cron(推薦)」,而非「依訪客瀏覽觸發」。

若兩套機制同時開著,背景任務會被重複觸發,在 2 vCPU / 2GB RAM VPS 上是不必要的資源浪費。






4.6 驗證任務確實有在運作

設定完成後,建議透過以下方式交叉確認,而不是只憑「指令貼上去了」就假設一定正常:


查看今天的 Cron 系統日誌
journalctl -u cron --since today



查看任務自己的輸出日誌(平時應該是空的或內容很少)
tail -f /var/log/ips-task/cron.log



搜尋論壇目錄底下最近被建立、且擁有者是 root 的檔案(正常應該完全沒有結果)
find /var/www/域名.com -user root -newer /var/log/ips-task/cron.log



回到 AdminCP 的「進階配置」附近,通常有可檢視目前所有背景任務清單

與各自下次/上次執行時間的入口(依版本不同,可用 AdminCP 搜尋功能尋找「Task」關鍵字)。

若改用 Cron 後一段時間,這裡的「上次執行時間」持續在更新,且論壇後端不再跳出任務延遲警示,就代表 Cron 觸發已確實生效。






4.7 本章常見地雷整理

#1
地雷:誤把驗證金鑰當成不重要的「數值」公開貼出
說明:這組金鑰功能上等同密碼,請比照機密資訊保管


#2
地雷:直接用 root crontab 設定此任務
說明:會導致論壇目錄出現 root 擁有的檔案,未來搬遷或還原時造成權限混亂


#3
地雷:高頻率(每分鐘)任務沒有搭配 flock
說明:一旦某次執行時間意外拉長,下一分鐘的觸發會與前一次重疊,在 2 vCPU / 2GB RAM VPS 環境特別容易引發連鎖資源緊繃


#4
地雷:flock 鎖定檔放在 /tmp
說明:建議統一改用 /run/lock/,/tmp 的清理策略因系統而異,可能留下 stale lock


#5
地雷:輸出沒有導向 www-data 真正有寫入權限的目錄
說明:若導向到沒有寫入權限的位置,cron 任務會因「重新導向失敗」而整個無法執行,且這個錯誤通常很難第一時間聯想到


#6
地雷:同時開著「Cron」與「依訪客瀏覽觸發」
說明:請確認 AdminCP 裡只選擇了一種


#7
地雷:手動輸入金鑰時打錯字
說明:建議務必直接複製貼上


#8
地雷:設定完就再也沒驗證過
說明:只憑「指令貼上去了」就假設一切正常,沒有透過日誌或 AdminCP 任務清單交叉確認






---------------------------------------------------------------
第 5 章:備份策略設計
---------------------------------------------------------------

5.1 備份範疇

生產級的論壇備份必須同時涵蓋三個層次,缺一不可:

層次:資料庫
內容:所有帖子、會員、設定
備份工具:mariadb-dump
遺失影響:論壇完全失效


層次:網站目錄
內容:PHP 程式碼、附件、快取
備份工具:tar + pigz
遺失影響:無法執行 PHP


層次:元資料
內容:備份環境快照
備份工具:backup_latest.json
遺失影響:還原時缺乏上下文



為什麼需要元資料 JSON?

半年後還原時,你可能已不記得備份當天的 PHP 版本、MariaDB 版本、InnoDB 頁面大小。

這份 JSON 讓還原工程師一眼知道備份環境,避免版本不相容的踩坑(詳見第 11 章 Recovery SOP)。






5.2 3-2-1 原則與本篇涵蓋範圍

生產級備份的業界基本原則是 3-2-1:至少保留 3 份備份、存放在 2 種不同媒介/位置、其中至少 1 份是異地(off-site)備份。

本篇要實作的,是 3-2-1 原則中最基礎、卻也最容易被忽略的第一步:在VPS本機上建立穩定、可驗證、有保留天數管理的自動備份;

第 12 章再說明異地備份的延伸做法。



核心原則:沒有監控的備份,等於沒有備份。

僅設定好腳本「能跑」還不夠,你需要主動確認每次備份都真的成功、且備份檔案真的能拿來還原(第 8 章 check_backup.sh 正是為此而生)。



備份保留策略建議(符合 3-2-1 原則):


儲存位置:本機(VPS)
建議保留天數:7~92 天(GFS 分層,見下)
說明:快速還原最近誤操作,本篇實作範圍


儲存位置:NAS/第二台主機
建議保留天數:30 天
說明:中期保留,應對本機磁碟損毀


儲存位置:異地雲端(S3 等)
建議保留天數:90 天以上
說明:長期保存,應對機房或主機商級別的重大事故






5.3 GFS 三層輪替策略

每日(Son):保留最近 7 份 → 每天備份,覆蓋上週

每週(Father):保留最近 4 份 → 每週日的備份額外多保留

每月(Grandfather):保留最近 3 份 → 每月 1 日的備份額外多保留



為什麼不能只保留「最近 7 天」?

論壇遭受惡意刪帖、SQL Injection 或邏輯損壞,若 8 天後才發現,單純的 7 天保留策略將讓你無法復原。

GFS 策略讓可回溯的時間範圍延伸至 3 個月。


backup.sh 的 GFS 實作採「扁平目錄+依日期標記保留」設計,理解這個設計,第 8 章監控腳本的 GFS 檢查邏輯才看得懂:


所有備份檔(daily/weekly/monthly)都直接存放在 BACKUP_HOME 底下,並不會另外搬到 weekly/、monthly/ 子目錄。


腳本依檔案的星期幾/幾號,決定哪些「順便」多保留一份;其餘超出 KEEP_DAILY 天數、且不屬於週日/月初標記的舊檔案才會被刪除。


同一份週日或月初的備份,可能同時符合「每日」與「每週/每月」的保留條件而被重複保留——這是刻意設計。

同一個實體檔案只存在一份,只是被兩種保留原因同時「認領」,不會佔用雙倍空間。




設計限制(非 Bug):若一天內執行備份超過一次,KEEP_DAILY 的「每日」實際上是「最近 N 次執行」,而非嚴格的「每個不同日期各留一份」。

若排程是標準的每日一次,不會有任何問題;

若改成每小時備份,請重新評估 KEEP_DAILY 的語意是否仍符合預期。






5.4 RTO / RPO 目標

指標:RPO(可容忍資料遺失量)
目標值:≤ 24 小時
說明:最多遺失一天的資料


指標:RTO(可容忍停機時間)
目標值:≤ 2 小時
說明:從開始還原到服務恢復


指標:備份驗證
目標值:每次備份後自動執行
說明:gzip -t + tar -tf + sha256sum -c + Restore Test



建議每季執行一次完整演練:假設主機已毀,從備份開始在新VPS上完整還原論壇,計時並記錄實際 RTO,持續優化直到 ≤ 2 小時(完整流程見第 11 章)。






---------------------------------------------------------------
第 6 章:資料庫備份帳號與認證設定檔
---------------------------------------------------------------

6.1 安裝必要套件

apt update

apt install -y pigz shellcheck jq uuid-runtime coreutils tar smartmontools util-linux rclone rsync pv



說明:

pigz:backup.sh 預設使用 pigz 取代單執行緒 gzip 進行壓縮。pigz 與 gzip 產生的格式完全相容,任何能解開 .gz 的工具都能直接解壓;
差別僅在於 pigz 能並行利用多個 CPU 核心,在 2 vCPU 環境下壓縮速度提升顯著(實測通常有 1.5~1.8 倍),對附件較多的論壇備份效果尤其明顯。

jq:JSON 結構化日誌安全輸出(自動 escape 特殊字元)。

uuid-runtime:提供 uuidgen,每次備份產生唯一追蹤 UUID。

smartmontools:磁碟 SMART 健康偵測(選用;VPS上多半偵測不到底層實體磁碟,因虛擬化隔離,腳本會自動偵測並優雅跳過)。

util-linux:提供 flock、findmnt。

rclone / rsync:異地備份(第 12 章)。

pv:還原時顯示匯入進度(第 11 章)。



確認安裝成功:

有版本號輸出即代表安裝成功(Debian 13 官方版本)
pigz --version


Debian 13 為 jq 1.7.1 系列
jq --version


util-linux 系列版本,Debian 13 隨套件更新可能略有差異
flock --version


應輸出 /usr/bin/pigz(腳本內需要絕對路徑)
which pigz



為什麼不直接把 gzip 換成 pigz 就好?

若改用 pigz 進行 tar 壓縮,需透過 --use-compress-program 參數指定,而非 tar -z(後者固定呼叫系統 gzip)。

本篇腳本已整合此細節;

若環境因故無法安裝 pigz,腳本內有 fallback 邏輯,會自動降回單執行緒 gzip,不影響備份功能,且 pigz 輸出的 .gz 完全相容標準 gzip 工具鏈。



以上套件在 Debian 13 官方 repo 中均為現行版本,無需第三方來源。

指令命名原則(mariadb-dump 而非 mysqldump)已於 3.2 節說明,本篇全程一致採用。






6.2 建立最小權限的資料庫備份帳號

舊版本做法直接拿 MariaDB 的 root 帳號密碼寫進備份腳本,功能上沒問題,但安全性是隱憂——腳本(或存放它的伺服器)一旦外洩,等同整個資料庫被完全控制,

而備份作業其實只需要「讀取」權限。

建立一個僅供備份使用、權限最小化的專用帳號 forum_backup


產生高強度密碼,給資料庫備份專用帳號 forum_backup 使用
openssl rand -base64 24


登入資料庫
mariadb -u root -p


進入 MariaDB 互動式命令列後執行(請替換成上面產生的高強度密碼,並把「你的論壇資料庫名」換成實際資料庫名稱):


CREATE USER 'forum_backup'@'localhost' IDENTIFIED BY '請替換為高強度密碼';

GRANT PROCESS ON *.* TO 'forum_backup'@'localhost';

GRANT
    SELECT,
    LOCK TABLES,
    SHOW VIEW,
    EVENT,
    TRIGGER
ON `你的論壇資料庫名`.*
TO 'forum_backup'@'localhost';

-- Restore Test 專用:restore_test_ 前綴的暫存資料庫需要完整建表/寫入/刪除權限
-- 萬用字元寫法:restore_test_% 會涵蓋 backup.sh 使用的 restore_test_<PID>
-- 與 check_backup.sh(見第 8 章)使用的 restore_test_check_ 前綴
GRANT ALL PRIVILEGES ON `restore_test_%`.* TO 'forum_backup'@'localhost';

FLUSH PRIVILEGES;
EXIT






常見誤解:很多人以為備份帳號只需要 SELECT、LOCK TABLES、PROCESS 這幾個 mariadb-dump 需要的權限就夠了。

但本腳本額外會在臨時資料庫(restore_test_$$)內完整 import 備份內容以驗證可還原性,

這一步需要 CREATE DATABASE/DROP DATABASE/CREATE TABLE/INSERT 等權限。

若略過此步驟,Restore Test 會在 restore_test_db_create_fail 這個事件上失敗;

備份本身仍會成功,但你會失去「備份其實可以還原」這個最重要的保證。

驗證 GRANT 是否足夠的實務做法:與其憑空猜測 mariadb-dump 匯出檔會用到哪些權限,

不如直接執行一次 backup.sh,

完成後查看 forum_backup.log 中 restore_test_* 系列事件是否出現 restore_test_import_partial 或任何 Access denied 字樣:

grep -i 'access denied\|restore_test' /var/log/forum_backup.log


若你的論壇版本另外安裝了使用 MariaDB 觸發器/事件的第三方外掛,匯出檔可能包含更多物件類型,屆時請依實際錯誤訊息再補強對應權限。


不確定實際使用的資料庫名稱?

可從論壇設定檔查詢:

grep -i "SQL_DATABASE" /var/www/域名.com/conf_global.php






6.3 建立資料庫認證設定檔

若直接在指令中用 -p你的密碼 提供密碼,

執行當下系統的程序列表(ps aux 之類)可能短暫看到完整指令內容、連帶密碼一起曝光,這是資安稽核常見的扣分項目。

更安全的做法,是把帳號密碼寫進獨立、權限受限的設定檔,

再用 --defaults-extra-file 讓 mariadb-dump 自動讀取:


install -m 600 -o root -g root /dev/null /root/.my_backup.cnf
cat >| /root/.my_backup.cnf << 'EOF'
[client]
user     = forum_backup
password = 請替換為高強度密碼
host     = localhost
socket   = /var/run/mysqld/mysqld.sock
EOF

chmod 600 /root/.my_backup.cnf
chown root:root /root/.my_backup.cnf






為什麼用 localhost + Unix Socket,而非 127.0.0.1?

這與本系列 phpMyAdmin 篇章的原則一致:本機連線一律使用 Unix Socket,效能更好、且不需要開放 TCP 監聽即可運作;

127.0.0.1 會強制走 TCP,在 MariaDB 關閉 skip-networking 的情況下兩者均可用,但混用兩種連線方式容易在故障排查時增加變數。

驗證認證設定:

mariadb --defaults-extra-file=/root/.my_backup.cnf -e "SELECT VERSION();"






---------------------------------------------------------------
第 7 章:部署 backup.sh(自動備份腳本)
---------------------------------------------------------------

7.1 本章目標與腳本設計總覽

本章要解決「網站活著,但資料會不會有一天救不回來」這個問題:每天定時把資料庫與網站檔案完整備份下來,

並且當場驗證備份真的可以還原,而不是只確認「檔案有產生」。


常見誤解是「備份不就是 mariadb-dump 加 tar 嗎」。

這在測試環境沒問題,但正式環境至少要多考慮以下幾件事,而這些正是 backup.sh 的設計重點:


正式環境的真實風險:備份「有產生」不代表「可以還原」
本章腳本的對應設計:每次備份後在 tmpfs 中實際 import 並執行 CHECK TABLE / CHECKSUM TABLE / ANALYZE TABLE(Restore Test,見 7.7 節)


正式環境的真實風險:cron 重疊執行(前一次還沒跑完,下一次又啟動)
本章腳本的對應設計:flock 鎖定 + PID 存活驗證 + stale lock watchdog


正式環境的真實風險:2GB RAM VPS 在備份高峰期容易被 OOM killer 砍掉
本章腳本的對應設計:TMPDIR=/var/tmp(避開 tmpfs)、nice -n 19 + ionice -c3、pigz 多核心壓縮但仍保守使用資源


正式環境的真實風險:備份檔案本身損壞卻沒人發現
本章腳本的對應設計:SHA256/SHA512 產生後立即自我驗證、gzip -t、tar -tf、SQL 標頭驗證


正式環境的真實風險:舊備份無限累積,把磁碟塞滿
本章腳本的對應設計:GFS(Grandfather-Father-Son)保留策略:每日 7 份、每週 4 份、每月 3 份


正式環境的真實風險:備份「悄悄」失敗好幾天都沒人發現
本章腳本的對應設計:獨立的 check_backup.sh 監控腳本(第 8 章)






腳本每次執行會依序完成以下 12 個步驟,任何一步失敗都會被記錄並反映在最終 Exit Code 上,

但不會讓整支腳本中途莫名其妙地半途而廢——後續步驟即使前面出錯,仍會盡可能完整執行,好讓管理者拿到「完整的一份診斷結果」:

步驟 1. Preflight
做什麼:檢查必要指令、認證檔、網站目錄、樣板值是否忘記修改、巢狀掛載點
對應的防呆設計:提早攔截設定錯誤,不要備份到一半才發現


步驟 2. 資源快照
做什麼:讀取核心版本、Debian/PHP/MariaDB 版本、記憶體、CPU load、Filesystem UUID
對應的防呆設計:供 manifest/JSON 記錄,供跨主機還原比對


步驟 3. SMART 磁碟健康
做什麼:若 smartctl 存在則檢查底層磁碟健康度
對應的防呆設計:磁碟即將故障時提早示警


步驟 4. 磁碟/inode 空間檢查
做什麼:低於門檻(預設剩餘 15% 空間、5% inode)即拒絕執行
對應的防呆設計:避免備份寫到一半把磁碟塞爆


步驟 5. 備份大小趨勢
做什麼:統計最近 7 次備份大小的平均值/中位數/標準差
對應的防呆設計:備份檔忽然變超小或超大,通常代表資料庫或網站出了問題


步驟 6. 資料庫健康檢查
做什麼:CHECK TABLE +基本查詢測試
對應的防呆設計:資料庫已損壞時仍會備份,但會明確標記「此份備份不適合拿來還原」


步驟 7. 網站檔案備份
做什麼:tar --acls --xattrs --one-file-system --numeric-owner --sort=name + pigz 平行壓縮
對應的防呆設計:保留 ACL/擴充屬性,避免誤打包 /proc、/sys、bind mount


步驟 8. 資料庫備份
做什麼:mariadb-dump --single-transaction --quick ...(完整參數見下)
對應的防呆設計:不鎖表、逐行讀取避免 OOM


步驟 9. 完整性驗證
做什麼:gzip -t/tar -tf/SQL 標頭比對/SHA256+SHA512 產生並立即自我驗證
對應的防呆設計:「備份檔存在」≠「備份檔可用」,兩者必須分開驗證


步驟 10. Restore Test
做什麼:於 tmpfs 中實際 CREATE DATABASE + import 抽樣資料表 + CHECKSUM TABLE/CHECK TABLE/ANALYZE TABLE
對應的防呆設計:唯一能回答「這份備份到底能不能還原」的步驟


步驟 11. Manifest/Metadata
做什麼:產生 manifest_*.txt(人眼可讀稽核清單)與 backup_*.json(結構化元資料)
對應的防呆設計:供異地稽核、法遵留存、check_backup.sh 讀取


步驟 12. GFS 輪替與 fsync
做什麼:每日/每週/每月分層保留舊備份,並 sync --file-system
對應的防呆設計:避免備份無限累積;避免「看起來寫完但其實還在 page cache」






關鍵設計原則:Health Check(步驟 6)發現異常時,備份仍會繼續執行到底(即使資料庫已損壞,那份損壞的資料本身也是重要的診斷證據,不應該連備份都不留)。

這個決策拆成兩條完全獨立的判斷線:

EXIT_CODE 誠實回報「這次執行有沒有問題」(Health Check 異常仍會讓最終 Exit Code = 50);

BACKUP_ARTIFACTS_OK 只反映「DB/Web/SHA256/Restore Test 這幾個備份檔本身」是否正常,不受 Health Check 結果影響。

這代表:資料庫只是有一點小毛病,並不會連累 Restore Test、manifest、metadata 這些「證明備份可用」的關鍵稽核步驟被無辜跳過。



腳本另外具備以下基礎設施層級的設計,是新手最容易低估其重要性的部分:

Shell 安全選項:set -Eeuo pipefail + shopt -s inherit_errexit、固定 IFS=$'\n\t'、LANG=C、PATH 鎖定為 readonly、

TMPDIR=/var/tmp(避免大型備份塞爆 tmpfs 造成 OOM)、umask 027、

set -C(noclobber,> 重定向若目標檔已存在會直接報錯,逼你在「有意覆寫」的地方明確使用 >|)。


零 fork 初始化:大量使用 Bash 內建 Parameter Expansion 與 /proc 直接讀取,取代外部指令 fork,降低 2 vCPU VPS 的行程建立開銷。


鎖定機制:exec 200>"$LOCK_FILE" + flock -w 30,並附帶 PID 檔與 stale lock watchdog(用 kill -0 驗活,若前次備份被 SIGKILL 強制中止導致 lock 殘留,

會自動清除)。啟動時會先讀取 PID 檔、驗證該 PID 是否仍存活;

讀取 PID 檔時務必使用 cat,$(< file 2>/dev/null) 這種寫法一旦附加 2>/dev/null 就不再是 Bash 內建的零 fork 讀檔語法,會永遠回傳空字串,

讓 watchdog 形同虛設而不報任何錯誤——這是實測驗證過的真實陷阱。


雙軌 Logging:JSON Lines 寫入 /var/log/forum_backup.log(供 ELK/Filebeat)+ logger 寫入 systemd journal(含 MESSAGE_ID、CODE_FILE、

CODE_LINE 結構化欄位),詳見第 13 章。


精細 Exit Code:見 7.2 節與第 16 章附錄的 Exit Code 矩陣。






7.2 Exit Code 對照表

腳本使用精細化 Exit Code,讓監控系統(check_backup.sh/Prometheus Alertmanager/Email)可以不必解析日誌內容,僅憑結束碼就能判斷失敗類型:

Exit Code:0
意義:全部成功
該備份是否可信賴?可,且已通過 Restore Test
建議處置:無需處理


Exit Code:1
意義:其他未預期錯誤(set -E 觸發)
該備份是否可信賴?否
建議處置:查看 journalctl -t forum-backup 的完整錯誤堆疊


Exit Code:10
意義:資料庫備份失敗(DB fail)
該備份是否可信賴?否
建議處置:檢查 mariadb-dump 連線、帳號權限、磁碟空間


Exit Code:20
意義:網站檔案備份失敗(Web fail)
該備份是否可信賴?否
建議處置:檢查 tar、WEB_DIR 權限、磁碟 I/O


Exit Code:30
意義:SHA256/SHA512 校驗失敗
該備份是否可信賴?否,備份檔可能已損壞
建議處置:依訊息提示手動執行 sha256sum -c


Exit Code:40
意義:Restore Test 失敗
該備份是否可信賴?否,備份檔存在但無法保證可還原
建議處置:最需要立即處理


Exit Code:50
意義:資料庫健康檢查異常(Health WARN)
該備份是否可信賴?視情況
建議處置:執行 REPAIR TABLE 等修復,不應以此份備份作為復原依據前先排查資料庫本身






7.3 部署前,請先確認並修改這些變數

readonly BACKUP_HOME="/var/www/backup"            # 備份檔存放目錄
readonly WEB_DIR="/var/www/域名.com"               # 論壇網站目錄(絕對路徑)
readonly DB_NAME="你的論壇資料庫名"                  # 論壇使用的資料庫名
readonly DB_DEFAULTS_FILE="/root/.my_backup.cnf"  # 資料庫認證設定檔



WEB_DIR 與 DB_NAME 是樣板值,腳本啟動時會主動檢查,忘記修改會直接拒絕執行並提示明確錯誤訊息。

BACKUP_HOME 沒有樣板值檢查(預設值為 /var/www/backup),請務必手動確認它真的指向你要的磁碟/路徑,

尤其若你打算把備份存到獨立掛載的資料磁碟。



其餘依實際論壇規模調整的變數:

GFS 保留份數(KEEP_DAILY / KEEP_WEEKLY / KEEP_MONTHLY)、

磁碟空間門檻(MIN_FREE_PERCENT / MIN_FREE_INODES)、

各項 timeout(DB_DUMP_TIMEOUT 預設 2h、TAR_TIMEOUT 預設 4h)。






7.4 上傳並部署 backup.sh 腳本

本篇把備份目錄設在 /var/www/backup,

與論壇目錄 /var/www/域名.com 是同一層的兄弟目錄:


mkdir -p /var/www/backup

chown root:root /var/www/backup

chmod 750 /var/www/backup

backup.sh

由於腳本的檔案太大了,建議用SFTP上傳到指定目錄,而不是複製貼上到SSH

使用 SFTP 上傳 backup.sh 至 /usr/local/sbin/backup.sh,

設定以下權限:

chmod 700 /usr/local/sbin/backup.sh

chown root:root /usr/local/sbin/backup.sh

700 表示僅 root 可讀寫執行,避免一般使用者讀取到腳本路徑、目錄結構等資訊。


7.5 使用 ShellCheck 與 bash -n 靜態檢查

apt install shellcheck -y



bash -n:語法解析乾跑,不執行任何指令,只確認語法正確


bash -n /usr/local/sbin/backup.sh
echo "bash -n exit code: $?"   # 0 = 語法正確


預期輸出 bash -n exit code: 0



shellcheck:靜態分析,偵測危險寫法、引號遺漏、SC2155 等問題

shellcheck /usr/local/sbin/backup.sh


若 ShellCheck 輸出完全沒有任何警告,代表腳本通過靜態檢查。


常見警告類型:
SC2086(變數展開未加引號)
SC2155(declare/export/readonly 與賦值同行,導致 exit code 無法捕捉)
SC2164(cd 失敗未保護)
SC2206(陣列賦值未處理含空格元素)


腳本使用 set -C(noclobber),ShellCheck 在部分版本可能對此提示資訊,屬正常現象,不影響功能。


readonly VAR="$(cmd)" 的 SC2155 陷阱:cmd 若執行失敗,其非 0 結束碼會被 readonly 這個複合指令吸收掉,set -e 因此無法偵測到這行其實失敗了。

正確寫法是先賦值、確認成功後再單獨下 readonly。

這是老手也容易在快速修改腳本時無意踩到的陷阱。






7.6 手動測試 備份腳本

bash /usr/local/sbin/backup.sh

你需要執行 3次 ~ 7次,才有足夠的 日誌 與 備份檔,讓接下來的 監控腳本 check_backup.sh 去分析



正常會看到類似以下的 JSON Lines 輸出(同步寫入 /var/log/forum_backup.log),並在結尾呈現純文字摘要表:

====== 備份開始(PID: 25822,主機:域名.com,backup.sh v7.24,UUID:a12ed58d-1d9f-426e-b55b-49b97be7bb62)======
所有必要工具均已確認存在。
未偵測到 WEB_DIR 底下有獨立掛載點,--one-file-system 不會排除任何子目錄。
SMART 檢查略過(VPS 虛擬化環境或無法偵測實體裝置)。
磁碟可用空間:76%(門檻:15%),通過檢查。Filesystem UUID:unknown,類型:unknown
磁碟可用 inode:97%(門檻:5%),通過檢查。inode 統計:總計 2588672,已用 72874
--- 最近 7 次備份大小趨勢 ---
DB 備份大小趨勢(最近 7 次):Db_域名_com_20260712_233005.sql.gz  14.3 MB|Db_域名_com_20260712_232920.sql.gz  14.3 MB
Web 備份大小趨勢(最近 7 次):Web_域名_com_20260712_233005.tar.gz  201.8 MB|Web_域名_20260712_232920.tar.gz  201.8 MB
開始資料庫健康檢查(timeout: 30s)...
Health Check:core_sys_conf_settings CHECK TABLE QUICK 通過。
Health Check:core_members 共 364 筆,基本查詢正常。
資料庫健康檢查通過,開始備份。
壓縮工具:pigz -6 --rsyncable(多核心 + rsync 友好)
開始備份網站檔案(timeout: 4h)...
網站檔案備份完成:/var/www/backup/Web_域名_com_20260712_233052.tar.gz(大小:201 MB,耗時:7s)
開始備份資料庫(timeout: 2h)...
資料庫備份完成:/var/www/backup/Db_域名_com_20260712_233052.sql.gz(壓縮後:14 MB,原始:93 MB,耗時:1s,壓縮比:97894400:14948280)
執行 fsync(sync --file-system)確保備份資料已寫入磁碟...
fsync 完成,備份資料已確認寫入 unknown filesystem(UUID:unknown)。
產生 SHA256 / SHA512 校驗檔並立即驗證...
Web_域名_com_20260712_233052.tar.gz SHA256 校驗檔已產生。
Web_域名_com_20260712_233052.tar.gz SHA256 自動驗證:PASS
Web_域名_com_20260712_233052.tar.gz SHA512 校驗檔已產生。
Web_域名_20260712_233052.tar.gz SHA512 自動驗證:PASS
Db_域名_com_20260712_233052.sql.gz SHA256 校驗檔已產生。
Db_域名_com_20260712_233052.sql.gz SHA256 自動驗證:PASS
Db_域名_com_20260712_233052.sql.gz SHA512 校驗檔已產生。
Db_域名_com_20260712_233052.sql.gz SHA512 自動驗證:PASS
產生備份 manifest.txt...
manifest.txt 已產生:/var/www/backup/manifest_20260712_233052.txt
驗證備份檔完整性...
網站備份檔壓縮格式驗證:OK
網站備份檔 tar 標頭驗證:OK
資料庫備份檔壓縮格式驗證:OK
資料庫備份檔 SQL 內容驗證:OK(偵測到有效的 MariaDB dump 標頭)
開始 Enterprise 級可還原性測試(tmpfs 完整 import + 多層驗證,timeout: 10m)...
DB 備份 core_sys_conf_settings CREATE TABLE 語句抽取成功,以此進行 import 驗證。
臨時 DB(restore_test_25822)import 成功,共建立 1 個資料表。
core_sys_conf_settings COUNT(*)=0
core_sys_conf_settings CHECKSUM=restore_test_25822.core_sys_conf_settings	0
core_sys_conf_settings CHECK TABLE:OK
core_sys_conf_settings ANALYZE TABLE:OK
臨時測試 DB 已刪除:restore_test_25822
Enterprise 可還原性測試通過(驗證 table:core_sys_conf_settings)。建議每週執行一次完整 Full Restore Test(見 §10)。
產生 backup.json 元資料...
backup.json 元資料已產生:/var/www/backup/backup_20260712_233052.json
開始 GFS 備份保留輪替(每日 7份 / 每週 4份 / 每月 3份,輪替 UUID:909f9bcf-5b55-476b-a820-0cebf88cd669)...
GFS 輪替完成:Db_*.sql.gz 共刪除 0 個舊備份(輪替 UUID:909f9bcf-5b55-476b-a820-0cebf88cd669)。
GFS 輪替完成:Web_*.tar.gz 共刪除 0 個舊備份(輪替 UUID:909f9bcf-5b55-476b-a820-0cebf88cd669)。
GFS 輪替完成:backup_[0-9]*_[0-9]*.json 共刪除 0 個舊備份(輪替 UUID:909f9bcf-5b55-476b-a820-0cebf88cd669)。
GFS 輪替完成:manifest_*.txt 共刪除 0 個舊備份(輪替 UUID:909f9bcf-5b55-476b-a820-0cebf88cd669)。

Backup Result  [域名.com | backup.sh v7.21 | UUID: a12ed58d-1d9f-426e-b55b-49b97be7bb62]
─────────────────────────────────────────────────────────
Health    : OK
Database  : OK  (93 MB raw→14 MB, 1s)
Website   : OK  (201 MB, 7s)
Restore   : OK
SHA256/512: OK
Manifest  : OK
Metadata  : OK
fsync     : OK
Cleanup   : OK (GFS: daily×7 wkly×4 mthly×3)
Total     : 18 sec  |  Exit Code: 0
─────────────────────────────────────────────────────────
備份完成摘要
====== 備份結束:全部成功(耗時 18s)======






備份目錄結構:
/var/www/backup/
├── Db_域名_com_20260701_031501.sql.gz         ← 資料庫備份(pigz 壓縮)
├── Db_域名_com_20260701_031501.sql.gz.sha256  ← SHA256 校驗檔
├── Db_域名_com_20260701_031501.sql.gz.sha512  ← SHA512 校驗檔
├── Web_域名_com_20260701_031501.tar.gz                 ← 網站檔案備份
├── Web_域名_com_20260701_031501.tar.gz.sha256
├── Web_域名_com_20260701_031501.tar.gz.sha512
├── manifest_20260701_031501.txt               ← 稽核清單
├── backup_20260701_031501.json                ← 本次備份元資料
└── backup_latest.json                         ← 最新備份元資料(固定路徑,供監控與還原引用)



Web 備份檔名中間那段 域名_com,是腳本取 WEB_DIR(/var/www/域名.com)的最後一層目錄名稱、

把點號轉為底線後自動產生(內部變數 WEB_DOMAIN_SLUG),與 Db 備份檔名 Db_${DB_NAME}_... 的命名風格保持一致。

這在同一台備份伺服器集中存放多個網站備份時特別有用:光看檔名就能分辨某份 Web_*.tar.gz 屬於哪個站台,不需要另外開檔確認。

GFS 輪替與備份大小趨勢比對皆使用 Web_*.tar.gz 萬用字元,不受檔名中段內容影響。



手動驗證:

cd /var/www/backup


gzip -t Db_*.sql.gz && echo "DB 壓縮格式:OK"


sha256sum -c Db_*.sql.gz.sha256


sha256sum -c Web_*.tar.gz.sha256


確認 SQL 內容可讀
zcat Db_*.sql.gz | head -20



確認網站備份可列出內容
for f in Web_*.tar.gz; do
    echo "=== $f ==="
    tar -tzf "$f" | head -20
done



確認 ACL/xattr 保留

for f in Web_*.tar.gz; do
    echo "=== $f ==="
    tar --acls --xattrs --numeric-owner -tzvf "$f" | head -20
done




jq -r '[.ts, .level, .msg] | @tsv' /var/log/forum_backup.log | tail -20






若想在不刪除任何舊備份的情況下,先確認 GFS 輪替邏輯會刪除哪些檔案(Dry Run):

GFS_DRY_RUN=1 /usr/local/sbin/backup.sh


journalctl -t forum-backup --since "-5min" | grep gfs_dryrun


第一次手動執行請務必全程盯著終端機輸出,並確認摘要表中的 Database、Website、Restore 三欄位皆為 OK。

若其中任何一項不是 OK,在設定排程之前先解決問題——排程只會讓「每天默默失敗」變成一種習慣,而不會讓問題自動消失。






7.7 容易被忽略的驗證盲點

1. 測試 lock 機制:備份執行期間,開啟另一個終端機再次執行 bash /usr/local/sbin/backup.sh,

第二次應看到「等待 30 秒後仍無法取得鎖定」的訊息,而非兩個備份同時執行。若未看到,確認 /run/lock/ 存在且可寫入。


2. 測試 timeout 機制:可先臨時把 DB_DUMP_TIMEOUT 改小(如 10s)快速驗證逾時邏輯是否正確觸發 db_backup_timeout_term 事件,而非永遠卡住。


3. 驗證 --one-file-system 效果:若網站目錄下有 bind mount 或外部掛載點,tar 加上此旗標後應略過並輸出警告 file system boundary not crossed,

這是正確行為。這有一個容易忽略的副作用:如果附件/上傳目錄另外掛載了一顆獨立資料磁碟或 NFS,tar 會安靜地跳過該子目錄,備份檔案大小完全正常、

gzip -t/tar -tf 都會通過驗證,只有在真正需要還原時才會暴露。腳本在 Preflight 階段會主動列舉 WEB_DIR 之下是否存在獨立掛載點並以 WARN 明確列出路徑。


4. Health Check 失敗時備份仍繼續執行:這是刻意設計。

若 Health Check 失敗,應立即登入資料庫執行 REPAIR TABLE core_sys_conf_settings; 並調查損壞原因,且不應以該次備份作為還原依據。


5. 暫存 SQL 的清除時機:腳本有雙重清除保障——測試完成後主動刪除,以及 cleanup() 函式在 EXIT trap 中清除。

即使腳本中途被 Ctrl+C 或 SIGTERM 終止,EXIT trap 仍會觸發,確保暫存 SQL 不會遺留在磁碟上。



管線 SIGPIPE + pipefail 誤判陷阱:腳本多處使用 zcat 檔案 | awk '...{exit}...' 這種「讀到所需內容即提早結束」的寫法(例如 SQL 標頭驗證、Restore Test 抽樣)。

只要 DB dump 解壓後大小超過 Linux 一條 pipe 預設的 64 KB 緩衝區(幾乎所有正式站台皆然),

awk 提早結束時 zcat 會收到 SIGPIPE 並以 exit 141 終止。

在 pipefail 開啟的情況下,即使 awk 本身判斷完全正確,管線整體仍會回報非 0,導致驗證被誤判為失敗。

正確作法是改用 ${PIPESTATUS[1]} 讀出 awk 自身的結束碼,而不是直接判斷整條管線的回傳值。

這是純粹的執行期訊號時序問題,ShellCheck 與 bash -n 完全無法發現,只有在真實資料量夠大時才會穩定重現。



mariadb-dump 參數選擇(MariaDB 11.8):

--single-transaction --quick --routines --triggers --events \
--hex-blob --tz-utc --order-by-primary --extended-insert \
--create-options --add-drop-table --no-tablespaces


參數:--single-transaction
目的:InnoDB 專用,以交易方式備份,不鎖表


參數:--quick
目的:逐行讀取而非整個結果集載入記憶體,2GB RAM VPS 備份大表時的必要選項


參數:--hex-blob
目的:BLOB 欄位以 16 進位輸出,避免二進位資料損壞


參數:--tz-utc
目的:強制以 UTC 輸出時間戳,避免跨時區還原偏移


參數:--order-by-primary
目的:以主鍵順序 dump,對 InnoDB 還原效率較佳;MariaDB 11.x 仍受支援,無棄用警告


參數:--extended-insert
目的:多行合併 INSERT,還原速度提升 3~5 倍


參數:--create-options
目的:備份 CREATE TABLE 選項(含 ENGINE、CHARSET 等)


參數:--add-drop-table
目的:還原前自動 DROP TABLE,避免衝突


參數:--no-tablespaces
目的:一般 VPS 不需要 tablespace 資訊,且需要額外權限;MariaDB 10.4+ 支援,Debian 13 的 11.8 完全支援






7.8 Restore Test:唯一能回答「備份到底能不能用」的步驟

多數教學到「產生 SHA256 校驗檔」就結束了,但 SHA256 只能證明「檔案沒有在傳輸/儲存過程中損毀」,

不能證明這份備份能否被正確還原(例如字元集不相容、InnoDB page size 不一致、mariadb-dump 輸出格式有問題)。



本腳本的 Restore Test 會依序執行:

1. 從壓縮檔中依序嘗試抽取 RESTORE_TEST_TABLES 陣列內某一張核心資料表(core_sys_conf_settings、core_members、forums_topics 等)的 CREATE TABLE 語句。

2. 在 tmpfs(/dev/shm,若不可用則 fallback 至磁碟)建立臨時測試資料庫 restore_test_$$,真實 import(非模擬)抽取出的 SQL。

3. COUNT(*):確認資料列確實存在。

4. CHECKSUM TABLE:快速偵測資料是否損壞。

5. CHECK TABLE FAST:InnoDB 完整性掃描。

6. ANALYZE TABLE:確認統計資訊可正常更新(間接確認資料表結構對 optimizer 而言正常)。

7. 若命中 core_members,額外執行 SELECT member_id, name LIMIT 1 確認資料實際可讀。

8. 驗證完成後立即 DROP DATABASE;cleanup() trap(EXIT/ERR/各訊號)作為第二道防線,確保即使腳本中途異常終止,臨時資料庫與 tmpfs 暫存目錄都不會殘留。



為什麼只抽一張表,而不是完整還原整個資料庫?

完整還原整個論壇資料庫動輒數十萬到數百萬筆資料,在 2 vCPU / 2GB RAM VPS 上,若每次備份後都執行一次完整還原測試,

會嚴重拖慢備份排程、甚至排擠正式站台的資源。單表抽樣驗證是「日常自動」與「資源消耗」之間的平衡取捨。

若需要完整還原驗證(建議每週執行一次),請搭配第 8 章 check_backup.sh 提供的 Full Restore Test 排程一併使用,

該流程已透過 flock 與本腳本共用資源鎖,避免兩者同時搶佔 I/O 與記憶體。






7.9 manifest.txt 與 backup.json:兩份 metadata 的分工


檔案:forum_backup.log(JSON Lines)
定位:事件流水帳
典型使用情境:集中送往 Loki/ELK 做長期監控與告警


檔案:manifest_${NOW}.txt
定位:單次備份的稽核清單
典型使用情境:異地備份時隨備份檔一起複製,可獨立驗證該次備份的檔案組合是否完整、SHA 是否相符


檔案:backup_${NOW}.json / backup_latest.json
定位:單次備份的結構化元資料
典型使用情境:供程式化讀取(監控系統、check_backup.sh、還原腳本自動比對 Filesystem UUID/InnoDB 設定是否與目標主機相容)



backup.json 內容特別記錄了 Filesystem UUID/掛載選項與 InnoDB 關鍵變數(innodb_page_size、innodb_file_per_table、character_set_server 等),

這是為了支援跨主機還原情境:還原到新主機前,可先比對新舊主機的這些設定值,

避免「InnoDB page size mismatch」或字元集不一致導致還原後資料損壞或亂碼,卻要到還原完成才發現。

欄位完整說明見第 16 章附錄。






7.10 本章疑難排解速查表

症狀:preflight_missing_cmds 中止
可能原因:缺少必要工具
排查指令:apt install coreutils tar pigz jq uuid-runtime util-linux smartmontools -y


症狀:db_backup_timeout_term(exit 124)
可能原因:資料庫有卡住的 query,或資料量超過 DB_DUMP_TIMEOUT(預設 2h)
排查指令:mariadb -e "SHOW PROCESSLIST;"


症狀:web_backup_timeout_term(exit 124)
可能原因:NFS/CIFS 掛載點失效,或 I/O 瓶頸
排查指令:df -h、mount \| grep nfs、iostat -x 1


症狀:sha256_verified_fail
可能原因:磁碟 I/O 異常,或校驗檔產生後備份檔被異動
排查指令:依訊息提示手動執行 sha256sum -c


症狀:restore_test_db_create_fail
可能原因:備份帳號缺少 CREATE DATABASE 權限
排查指令:見 6.2 節補上 GRANT ALL PRIVILEGES ON restore_test_%.*


症狀:disk_space_low / inode_low 中止
可能原因:磁碟空間或 inode 即將耗盡
排查指令:df -h、df -i;檢查 GFS 保留份數是否過多


症狀:nested_mount_detected WARN
可能原因:WEB_DIR 底下有獨立掛載點未被備份
排查指令:見 7.7 節「巢狀掛載點偵測」


症狀:Exit Code 137(SIGKILL)
可能原因:timeout --kill-after=30s 觸發,SIGTERM 30 秒後仍未結束
排查指令:檢查是否有殭屍程序卡住 I/O,或記憶體是否已被佔滿導致 swap thrashing



與 check_backup.sh 的搭配關係:backup.sh 負責產生備份與當下的自我驗證;

check_backup.sh(第 8 章)負責持續監控備份的健康趨勢。

兩者共用同一組 restore_test_% 資料庫萬用字元權限與 GFS 命名慣例,請務必兩章一併部署。






---------------------------------------------------------------
第 8 章:部署 check_backup.sh(備份監控腳本)
---------------------------------------------------------------

8.1 為什麼「有備份」不等於「備份沒問題」

第 7 章已完成 backup.sh:一支會定期把論壇資料庫與網站檔案打包、壓縮,並加上雙重雜湊校驗的自動備份腳本。

但「排程有跑」「備份檔有產生」只回答了最低限度的問題,卻沒有回答一個更關鍵的問題:這份備份如果現在拿去還原,真的能用嗎?



實務上最危險、也最容易被忽略的失敗模式不是「備份失敗」,

而是「備份看起來成功,但其實不能用」——例如 mariadb-dump 因 DB 鎖等待逾時而只 dump 出一半資料,

但腳本本身正常結束、exit code 是 0;壓縮檔本身完整(gzip -t 通過),

但裡面的 SQL 是空的(例如資料庫連線帳號權限被誤改);磁碟已經滿了,備份檔案只寫入一半就被系統中斷,

但因為 df 讀取失敗被腳本誤判為「磁碟 100% 可用」;

監控腳本自己在某個環節卡死,於是連「這次沒有產生報告」這件事都沒有人知道。



check_backup.sh 的設計目的,就是把上述這些「安靜的失敗」逐一攔截下來,並在真正需要的時候,誠實地告訴你「備份不能用」。

貫穿全篇的設計原則:偵測不到「偵測不到」,是本工具鏈最不能接受的失敗模式。

任何一處「讀取失敗時安靜地當作沒事」的寫法,都視為需要修正的真實 Bug,而不是可接受的邊界情況。



與 backup.sh 的分工:


腳本:backup.sh(第 7 章)
角色:產生備份:dump、壓縮、雙重雜湊、GFS 輪替
執行時機:每日排程執行一次


腳本:check_backup.sh(本章)
角色:監控備份:驗證是否真的完整、可還原、生態系是否健康
執行時機:每日排程執行一次(與 backup.sh 錯開時間),並支援手動觸發 Full Restore Test



兩者共用同一顆 flock 鎖定檔(/run/lock/forum_backup.lock)、

同一組最小權限 MariaDB 帳號(/root/.my_backup.cnf),

並透過 backup_latest.json 與 /var/log/forum_backup.log(JSON Lines 格式)交換資訊。






8.2 前置需求與依賴套件

部署前請先確認:

1. 第 7 章的 backup.sh 已部署並至少成功執行過一次,

BACKUP_HOME(/var/www/backup)內已有 Db_*.sql.gz、Web_*.tar.gz 及對應的 .sha256 / .sha512 校驗檔。


2. MariaDB 最小權限帳號(/root/.my_backup.cnf)已建立,

且對 restore_test_% 具備 CREATE、DROP、SELECT 等權限(Full Restore Test 需要建立與刪除臨時測試資料庫)。


3. 主機為 systemd 環境(Debian 13 預設如此),因為本腳本會呼叫 systemctl、journalctl、timedatectl。



check_backup.sh 啟動時會先檢查必要指令是否存在,缺少任一項會直接以 FATAL(exit code 127)中止並提示套件名稱,

避免後續檢查因工具鏈缺失而產生誤導性的結果:



指令:jq
用途:JSON 產生與解析
所屬套件:jq


指令:mariadb / mariadb-admin
用途:資料庫連線、健康檢查、Restore Test
所屬套件:mariadb-client


指令:gzip
用途:壓縮格式驗證
所屬套件:gzip


指令:tar
用途:歸檔結構驗證
所屬套件:tar


指令:sha256sum / sha512sum
用途:雙重雜湊校驗
所屬套件:coreutils


指令:find / stat
用途:檔案搜尋與屬性讀取
所屬套件:findutils / coreutils


指令:awk
用途:數值運算、統計分析
所屬套件:mawk(Debian 預設實作)


指令:timeout
用途:逾時保護
所屬套件:coreutils


指令:zcat
用途:解壓縮串流
所屬套件:gzip


指令:df / flock
用途:磁碟空間、鎖定機制
所屬套件:coreutils / util-linux


指令:nice / ionice
用途:CPU/I/O 排程禮讓
所屬套件:coreutils / util-linux


指令:systemctl
用途:服務狀態、NTP 同步查詢
所屬套件:systemd



若在最小化安裝環境中部署,可一次補齊:

apt update


apt install -y jq mariadb-client coreutils findutils util-linux gzip tar mawk systemd



指令命名原則沿用 3.2 節:一律使用 mariadb 系列官方名稱,不使用 mysql 系列別名。

journalctl 與 logger 屬於非必要功能(缺少時腳本仍可正常運作,僅該項檢查自動跳過),不列入硬性依賴。






8.3 檢查項目總覽

check_backup.sh 將檢查項目劃分為 15 個群組(A~O),每一項檢查結果只會是 PASS / WARN / FAIL / SKIP 四種狀態之一,

並全數反映在結尾輸出的 backup-status.json 中。

恆常執行 vs. 條件式執行:僅邏輯上必須依賴備份檔本身存在的 Group C(完整性驗證)與 Group E(大小趨勢)為條件式執行,

其餘群組一律恆常執行,不受當下有無備份檔影響:



群組:A
內容:MariaDB/Nginx/PHP 8.3-FPM 服務健康、今日 journal ERROR 事件掃描
是否依賴備份檔存在:否


群組:B
內容:flock 卡死偵測(存在時間超過門檻視為異常)、lock 檔內 PID 存活驗證
是否依賴備份檔存在:否


群組:C
內容:SHA256/SHA512/gzip -t/tar -tf/SQL 標頭驗證、備份年齡(RPO)
是否依賴備份檔存在:是


群組:D
內容:磁碟可用空間、inode 可用量與成長趨勢
是否依賴備份檔存在:否


群組:E
內容:備份大小最低門檻、DB/Web 大小趨勢、位元組級極端縮水偵測、σ 偏差統計
是否依賴備份檔存在:是


群組:F
內容:GFS Daily/Weekly/Monthly 輪替驗證
是否依賴備份檔存在:否


群組:G
內容:Backup Metadata(hostname/UUID)、備份耗時趨勢
是否依賴備份檔存在:否


群組:H
內容:備份目錄權限安全性(others 位元)
是否依賴備份檔存在:否


群組:I
內容:Full Restore Test(真實還原+CHECK TABLE)
是否依賴備份檔存在:否(無備份檔時明確標記不可執行並記錄原因)


群組:J
內容:備份日誌 UUID、上一次備份 exit code
是否依賴備份檔存在:否


群組:K
內容:備份份數合理性(GFS Daily 保留量)
是否依賴備份檔存在:否


群組:L
內容:記憶體與 Swap 可用量(含 SwapTotal=0 判斷)
是否依賴備份檔存在:否


群組:M
內容:故障診斷(掃描日誌中的已知失敗模式,共 9 種 pattern)
是否依賴備份檔存在:否


群組:N
內容:監控腳本自身健康度(forum_backup.log 檔案大小)
是否依賴備份檔存在:否


群組:O
內容:系統時間 NTP 同步狀態(timedatectl)
是否依賴備份檔存在:否






各項檢查門檻值一覽(皆針對 2 vCPU / 2GB RAM VPS 保守設定,可修改腳本開頭的 readonly 常數調整):


常數:MAX_BACKUP_AGE_HOURS
預設值:25 小時
意義:備份年齡(RPO)超過視為 FAIL


常數:MIN_BACKUP_SIZE_MB
預設值:5 MB
意義:備份小於此值視為異常(可能是空備份)


常數:SIZE_DROP_RATIO
預設值:50%
意義:本次比前次縮小超過此百分比 → FAIL


常數:CRITICAL_BYTES_RATIO
預設值:1/100
意義:不足前次位元組數 1% → CRITICAL


常數:MIN_DISK_FREE_PERCENT
預設值:15%
意義:磁碟剩餘空間低於此百分比告警


常數:MIN_INODE_FREE_PERCENT
預設值:5%
意義:inode 剩餘百分比低於此值告警


常數:MIN_BACKUP_COUNT_DAILY
預設值:3 份
意義:最少應保留的每日備份份數


常數:LOCK_STALE_HOURS
預設值:2 小時
意義:lock 檔存在超過此時數視為卡死


常數:DURATION_WARN_RATIO
預設值:300%(3 倍)
意義:備份耗時超過前次 3 倍 → WARN


常數:MEM_WARN_KB
預設值:128 MB
意義:記憶體可用量警告門檻


常數:SWAP_WARN_KB
預設值:64 MB
意義:Swap 可用量警告門檻


常數:MARIADB_QUERY_TIMEOUT
預設值:30 秒
意義:Restore Test 中每次 mariadb 查詢的逾時保護


常數:LOG_SIZE_WARN_BYTES
預設值:100 MB
意義:監控腳本自身日誌檔大小告警門檻



腳本結尾會計算一個 0~100 的 Health Score:(PASS 項目數 × 5 + WARN 項目數 × 2) / (計分項目總數 × 5) × 100。

PASS 記 5 分、WARN 記 2 分、FAIL 記 0 分;

SKIP 不計分也不列入分母(例如首次執行、資料不足以做趨勢比較等合理跳過情境,不應拖累分數)。

Health Score 適合作為 Grafana 儀表板上的單一數字指標,快速掌握備份生態系整體健康度,

但不能取代逐項檢查結果——90 分仍可能包含一項關鍵的 FAIL(例如 restore_result),請務必同時檢視 checks 區塊的細項。




給新手的提醒:VPS 剛部署、第一次執行本腳本時,Group C/E/F/G/I/K 等大量檢查會因為沒有足夠歷史資料而回報 SKIP,

此時 Health Score 的分母會比後續執行小很多。

這是刻意的設計,不是 bug——連續執行數天、累積足夠歷史資料後,分數才具有參考意義。






8.4 GFS 輪替驗證的正確理解

因為 backup.sh 採「扁平目錄」設計(見 5.3 節),check_backup.sh 對 Weekly/Monthly 的驗證邏輯是直接在 BACKUP_HOME 掃描檔案 mtime,

篩選出「星期日」與「每月 1 日」產生的備份檔案來計數,而不是去檢查根本不存在的 weekly/、monthly/ 子目錄。

剛部署的頭幾週,Weekly/Monthly 檢查出現 WARN 屬於正常現象(因為累積份數還不夠 2 份),會隨著時間自然通過,不需要特別處理。






8.5 Restore Test 兩種模式

輕量模式(預設,RESTORE_TEST_FULL 未設定或為 0):僅讀取 forum_backup.log 中最近一次 Restore Test 事件的結果,不實際執行還原,適合每次排程執行。


完整模式(RESTORE_TEST_FULL=1):

sudo RESTORE_TEST_FULL=1 /usr/local/sbin/check_backup.sh

會觸發更深入的獨立還原測試:實際建立 restore_test_check_<pid> 沙箱資料庫、gunzip | mariadb 匯入,

對前 5 張表執行 CHECK TABLE,驗證後自動 DROP DATABASE 清除。

腳本已內建與 backup.sh 的資源競爭防護(非阻塞 flock 偵測),並以 nice -n 19 / ionice -c2 -n7 禮讓正式服務,

並依備份檔案大小動態計算合理的 timeout(基礎 300 秒 + 每 MB 壓縮檔 1.5 秒,上限 3600 秒,避免大型資料庫在固定門檻下被誤判失敗)。

建議每週執行一次即可,避免長期佔用 I/O 資源。



老手也容易忽略的一點:動態 timeout 是依「壓縮檔大小」而非「解壓後大小」估算。

若你的論壇資料庫有大量 TEXT/BLOB 欄位,壓縮率可能偏低,估算會相對寬鬆;

但若資料高度可壓縮,解壓後體積可能遠大於壓縮檔評估值,屆時仍可能觸及 timeout 上限。

若備份壓縮檔已接近 2GB,建議手動調高 RESTORE_TIMEOUT_SEC_PER_MB(例如提高到 2.5~3)或直接提高 RESTORE_TIMEOUT_MAX。

8.6 check_backup.sh 腳本 部署步驟

步驟一:上傳並部署 check_backup.sh 腳本

check_backup.sh

由於腳本的檔案太大了,建議用SFTP上傳到指定目錄,而不是複製貼上到SSH

使用 SFTP 上傳 check_backup.sh 至 /usr/local/sbin/check_backup.sh,

設定以下權限:

chmod 700 /usr/local/sbin/check_backup.sh

chown root:root /usr/local/sbin/check_backup.sh

700 表示僅 root 可讀寫執行,避免一般使用者讀取到腳本路徑、目錄結構等資訊。

步驟二:確認備份目錄不會被 Nginx 公開存取【重要安全提醒】

BACKUP_HOME(/var/www/backup)位在 Nginx 預設網站根目錄 /var/www 之下。

這是本篇刻意選用的路徑(方便與網站檔案共用磁碟配額與監控),但代表若未特別排除,

理論上有被 Nginx 當成靜態檔案公開提供下載的風險——備份檔案內含整份資料庫(使用者帳號雜湊、私訊內容等機敏資料),一旦外洩後果嚴重。

這是新手極容易忽略、老手也可能在多次改動 vhost 設定後意外鬆綁的環節,請務必確認你的 Nginx server block 包含類似以下的明確拒絕規則:


# /etc/nginx/sites-available/域名.com(你的正式網域設定檔)
server {
    # ...(其餘設定省略,請參照先前 Nginx 虛擬主機設定章節)...

    # 備份目錄一律拒絕外部存取,回傳 404 而非 403
    # (404 可避免洩漏「這裡有備份目錄」這件事本身)
    location ^~ /backup/ {
        return 404;
    }
}



設定後請務必實際驗證:

nginx -t && systemctl reload nginx


curl -I https://域名.com/backup/backup-status.json

應回應 404 Not Found,若回應 200 OK 請立即檢查 vhost 設定



給老手的提醒:即使目前的 vhost 設定已正確排除 /backup/,日後若因導入新的 location block(例如反向代理、靜態資源快取規則)而調整了比對順序,

仍有可能意外讓某個更寬鬆的 location 規則搶先比對到 /backup/ 路徑並繞過拒絕規則。

建議每次修改 Nginx 設定後都重新測試一次。



步驟三:手動執行一次(不含 Full Restore Test)


shellcheck:靜態分析,偵測危險寫法、引號遺漏、等問題
shellcheck /usr/local/sbin/check_backup.sh




手動測試腳本

bash -n /usr/local/sbin/check_backup.sh && echo "語法 OK"


bash /usr/local/sbin/check_backup.sh




預期輸出


[PASS] 未偵測到重疊執行的 check_backup.sh 實體,本次執行取得專屬鎖(/run/lock/forum_backup_check.lock)。
====== 備份監控開始(check_backup.sh v6.28,主機:域名.com)======
[PASS] MariaDB 可連線(mariadb-admin ping 通過,使用最小權限帳號)
[PASS] Nginx 服務正常運行。
[PASS] PHP 8.3-FPM 服務正常運行。
[WARN] 今日 journal 偵測到 16 個 ERROR 事件(偏多)。系統可能不穩定,請立即確認:journalctl -p err --since today --no-pager
[PASS] 記憶體可用量正常:892MB,Swap 可用:3556MB
[PASS] Swap 可用量正常:3556MB / 總量 4095MB(門檻:64MB)
[PASS] lock 檔存在但目前無 PID 檔,代表沒有備份程序執行中(正常狀態:PID 檔僅在備份執行期間存在)
[PASS] lock 檔存在但無 PID 檔,跳過 PID 驗證(正常:目前無備份程序執行中)
[PASS] 最新備份檔存在:DB=Db_域名_com_20260715_130923.sql.gz,Web=Web_域名_com_20260715_130923.tar.gz
[PASS] SHA256 校驗通過:DB 與 Web 備份檔完整性確認。
[PASS] SHA512 校驗通過:雙重雜湊驗證完成。
[PASS] gzip -t 壓縮格式驗證通過(DB 與 Web)。
[PASS] tar -tf 歸檔結構驗證通過。
[PASS] SQL 標頭驗證通過:偵測到有效的 MariaDB dump 標頭。(注意:標頭存在 ≠ 資料完整,見 Restore Test)
[PASS] 備份年齡正常:DB 2h,Web 2h(門檻:25h)
[PASS] 磁碟可用空間:74%(門檻:15%)
[PASS] inode 可用:97%(門檻:5%)
無昨日 inode 記錄(首次執行),跳過趨勢比較。
[PASS] 備份大小正常:DB 14MB,Web 201MB(最低門檻:5MB)
[PASS] DB 大小趨勢正常:前次 14MB → 本次 14MB(0% 變化)
[PASS] Web 大小趨勢正常:前次 201MB → 本次 201MB
[PASS] DB 位元組大小趨勢正常:14936683 bytes(前次:14936674 bytes)
[PASS] 備份大小統計正常:mean=14.2MB median=14.2MB stddev=0.0MB cur=14.2MB deviation=0.47 sigma
[PASS] GFS Daily:過去 7 天有 7 份備份(最低要求 3 份)
[WARN] GFS Weekly 備份不足!過去 31 天僅有 0 份週日備份(最低要求 2 份)。很多人 Daily 正常但 Weekly 消失,半年後才發現。
[WARN] GFS Monthly 備份不足!過去 92 天僅有 0 份每月 1 日備份(最低要求 2 份)。VPS 部署未滿 2 個月屬正常現象,之後應逐步累積。
[PASS] Backup Metadata 驗證通過:hostname=test.phpforumer.com,UUID=7449be56-64ad-4685-8725-e883a823c0ee,timestamp=20260715_130923
log 中無 backup_end / duration_ms 記錄,跳過耗時趨勢比較(需要 backup.sh v7.0+ 且今日已成功備份過至少一次)。
[PASS] 備份目錄權限安全:/var/www/backup(750 / root:root)
[PASS] 最近一次 Restore Test 結果:PASS(來自日誌)
[PASS] 最近一次備份 UUID:7449be56-64ad-4685-8725-e883a823c0ee(可用此 UUID 追查完整備份記錄)
[PASS] 上一次備份以 INFO(正常)結束。
[PASS] 備份日誌 JSON 完整性正常:586 行全數為合法 JSON。
[PASS] 備份份數正常:DB 7 份,Web 7 份(最低門檻:3 份)
[PASS] 故障模式掃描:未發現已知錯誤 pattern。
[PASS] 備份日誌檔大小正常:0MB(門檻:100MB)
[PASS] 系統時間已與 NTP 同步(timedatectl NTPSynchronized=yes)。
Health Score:95/100(PASS:33 WARN:3 FAIL:0,共 36 項)
JSON 報告已輸出:/var/www/backup/backup-status.json(供 Prometheus textfile_collector 或 ELK 讀取)
====== 備份監控結束:有失敗項目,Health Score 95/100,請立即檢查上方記錄!(耗時 20s)======






執行後可檢視:

結構化 JSON 報告
jq . /var/www/backup/backup-status.json


JSON Lines 格式的詳細記錄(供人工排查、ELK 匯入)
tail -50 /var/log/forum_backup.log | jq .


若出現 WARN/FAIL,立即查看日誌:
journalctl -t forum-backup -p warning --since today --no-pager

cat /var/log/forum_backup.log | jq 'select(.level == "ERROR" or .level == "WARN")' | jq -r '[.ts, .level, .msg] | @tsv'



步驟四:執行一次完整的 Restore Test(見 8.5 節完整模式)

完整模式(RESTORE_TEST_FULL=1):
sudo RESTORE_TEST_FULL=1 /usr/local/sbin/check_backup.sh


步驟五:設定 HOSTNAME_FQDN(可選)

腳本預設直接讀取 /etc/hostname(零額外 fork 成本),但該檔案預設只存放短主機名稱(例如 forum01),

並非完整網域名稱(FQDN)。若希望 JSON 報告顯示真正的 FQDN,可擇一:

方法一:直接將 FQDN 寫入 /etc/hostname
echo "forum01.域名.com" | tee /etc/hostname


方法二:在 /etc/hosts 設定對應項目,並保留原短名稱
127.0.1.1  forum01.域名.com  forum01






---------------------------------------------------------------
第 9 章:logrotate 日誌輪替與 Cron 排程整合
---------------------------------------------------------------

9.1 設定備份日誌自動輪替(logrotate)

backup.sh 每天都會向 /var/log/forum_backup.log 寫入若干行記錄,若不設定輪替,這個檔案會隨時間無限成長;

以每天約 50 行 JSON 計算,一年後累積約 18,000 行,若備份持續出錯則膨脹更快,最終可能在 2GB RAM 的 VPS 上撐爆磁碟、影響整體服務。


cat > /etc/logrotate.d/forum-backup << 'EOF'
/var/log/forum_backup.log {
    daily
    size 10M
    rotate 30
    compress
    delaycompress
    dateext
    dateformat -%Y%m%d
    missingok
    notifempty
    create 0640 root adm
    su root root
}
EOF



設定說明:
daily(每天輪替一次)

size 10M(超過此大小提前觸發輪替,避免腳本異常導致日誌暴增撐爆磁碟)

rotate 30(保留約一個月)

compress / delaycompress(壓縮舊日誌,但最近一份暫不壓縮,避免當天仍在寫入的日誌被壓縮)

dateext / dateformat(以日期命名輪替檔,例如 forum_backup.log-20260701.gz)。
JSON Lines 格式本身是純文字逐行記錄,與 logrotate 的輪替、壓縮機制完全相容,
zcat forum_backup.log-20260626.gz | jq . 維護方式與純文字 log 完全一致。







新增一個 forum_backup_cron.log(/var/log/forum_backup_cron.log)

cat > /etc/logrotate.d/forum-backup-cron << 'EOF'
/var/log/forum_backup_cron.log {
    daily
    size 5M
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    create 0640 root adm
    su root root
}
EOF






同理,IPS 論壇任務日誌也需要輪替:

cat > /etc/logrotate.d/ips-task << 'EOF'
/var/log/ips-task/cron.log {
    daily
    size 50M
    rotate 14
    compress
    delaycompress
    dateext
    dateformat -%Y%m%d
    missingok
    notifempty
    su www-data www-data
    create 0640 www-data www-data
}
EOF




su www-data www-data:由於 cron.log 擁有者是 www-data,logrotate 預設以 root 執行輪替操作,

在某些 Debian/logrotate 版本組合下,若日誌檔案擁有者不是 root,logrotate 會因「無法以 root 身份建立新日誌」而報錯或靜默跳過,此設定可避免權限衝突。

size 50M:IPS論壇任務每分鐘觸發一次,遇到錯誤時可能短時間產生大量輸出,額外容量門檻可提前觸發輪替。






9.2 驗證 logrotate 設定

語法模擬測試(只看,不執行)

logrotate --debug /etc/logrotate.d/forum-backup


logrotate --debug /etc/logrotate.d/forum-backup-cron 


logrotate --debug /etc/logrotate.d/ips-task



強制輪替驗證(實際執行一次)

logrotate -f /etc/logrotate.d/forum-backup


logrotate -f /etc/logrotate.d/forum-backup-cron 


logrotate -f /etc/logrotate.d/ips-task


ls -lah /var/log/forum_backup*


ls -lah /var/log/ips-task/



Debian 13 關鍵確認項目:logrotate 預設由 systemd timer 執行,而不是 cron 排程。

本篇維持 Debian 13 這個系統預設,不額外把 logrotate 改成 cron 觸發。

很多人只確認了 /etc/logrotate.d/ 的設定正確,卻沒確認 timer 本身是否真的在運行,導致設定沒問題但輪替永遠不執行:


正常應看到 Active: active (waiting) since ...
systemctl status logrotate.timer



若顯示 inactive:

systemctl enable --now logrotate.timer

systemctl list-timers | grep logrotate



同時建議確認系統既有服務的 logrotate 設定完整:

ls -l /etc/logrotate.d/


至少應包含
apt
fail2ban
mariadb
nginx
php8.3-fpm
rsyslog
unattended-upgrades

這些容易在整個維運過程被忽略,悄悄佔用大量磁碟空間。






9.3 建立統一排程檔 /etc/cron.d/forum-backup

本篇改用 /etc/cron.d/forum-backup 統一管理所有排程(IPS論壇任務、備份、監控),而非個別使用者的 crontab -e(原因見 3.3 節):


建立
vi /etc/cron.d/forum-backup


貼上內容


SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# IPS論壇背景任務(每分鐘觸發,以 www-data 身份執行,避免檔案擁有者混亂)
# crontab 層 flock -n:若上次任務尚未結束,立刻放棄本次,避免重疊執行
* * * * * www-data /usr/bin/flock -n /run/lock/ips_task_cron.lock /usr/bin/php8.3 -d memory_limit=-1 -d max_execution_time=0 /var/www/域名.com/applications/core/interface/task/task.php 你的驗證金鑰 >> /var/log/ips-task/cron.log 2>&1

# 每日凌晨 3 點 15 分,備份 DB 資料庫與 Web 網站目錄
# 注意:backup.sh 內部已自行處理鎖定,這裡刻意「不」再包一層 flock -n
# 包住同一顆鎖定檔,避免死結(原理見 9.4 節)。
# v14.7 修正:改導向獨立的 cron 純文字日誌,不再與 backup.sh 內部
# 以 flock 寫入的 JSON Lines 日誌(/var/log/forum_backup.log)共用檔案,
# 避免 stdout/stderr 的彩色文字訊息被重複 append 進 JSON log 造成損毀。
15 3 * * * root /usr/local/sbin/backup.sh >> /var/log/forum_backup_cron.log 2>&1


# 每日凌晨 3 點 45 分,執行備份監控腳本(輕量模式),確認今天已產出合格備份檔
# 比備份腳本晚 30 分鐘,確保一般情況下備份已完成
45 3 * * * root /usr/local/sbin/check_backup.sh >> /var/log/forum_backup_cron.log 2>&1


# 每週日凌晨 4 點 30 分,額外執行一次完整 Restore Test
# check_backup.sh 的 Restore Test 逾時上限最長可達 60 分鐘,故本篇未把任何
# 其他重量級任務排在 04:30~05:30 之間,logrotate 由 systemd timer 於 00:00 執行,與此不衝突。
30 4 * * 0 root RESTORE_TEST_FULL=1 /usr/local/sbin/check_backup.sh >> /var/log/forum_backup_cron.log 2>&1








儲存檔案並離開vi編輯器
按 Esc,輸入 :wq,按 Enter






更改擁有者與權限

chown root:root /etc/cron.d/forum-backup


chmod 640 /etc/cron.d/forum-backup



權限沿用 3.3 節的例外規則:640 而非一般 cron.d 檔案常見的 644,因為這行IPS論壇任務指令內含明碼驗證金鑰。

版控提醒:3.3 節建議把 /etc/cron.d/ 底下的檔案納入 Git 版控,但 forum-backup 含有明碼金鑰,

整份提交等於把機敏資訊寫進 Git 歷史(即使日後刪除,舊 commit 仍留有痕跡)。

建議兩種作法擇一:

(1) 納入版控前先把金鑰替換成佔位符(例如 __IPS_TASK_KEY__),部署時再用 sed 或 Ansible 樣板變數替換回實際值;

(2) 把 /etc/cron.d/forum-backup 整檔列入 .gitignore,改用密碼管理工具或 Ansible Vault 另外保管。



本篇維持第 3.2 節說明的原則,不設定 MAILTO,一律靠日誌與 journal 監控(若需要主動告警,見 13.7 節的包裝腳本)。



為什麼備份與監控那兩行還要加 >> /var/log/forum_backup_cron.log 2>&1?

腳本本身的 log() 函式已透過 flock(fd 9)序列化寫入的方式,把結構化 JSON 直接 append 進 $LOG_FILE(/var/log/forum_backup.log),

因此腳本「正常執行」期間的結構化記錄不需要這個cron 層重導向。

但若腳本檔案本身被誤刪、執行權限被移除、或 flock 直接放棄本次執行並印出一行 stderr,

這些在腳本啟動前就發生的錯誤不會被腳本內部的 log() 捕捉到(因為此時腳本根本還沒開始執行)。

加上這層 cron 層重導向作為外層兜底,可確保這類情況仍有記錄可查——但務必導向獨立的forum_backup_cron.log,

而不是跟腳本內部寫入的 JSON Lines 日誌(forum_backup.log)共用同一個檔案,

否則腳本本身印給人看的彩色 stdout/stderr 訊息會被重複 append 進 JSON log,造成逐行解析損毀(詳見 14.7 節)。


附帶修正:原文「透過 tee -a 寫入」是已經修掉的舊機制的殘留說法,目前的實作是 flock+printf,程式碼裡雖然還留著提及 tee -a 的註解,

但那只是保留的歷史變更說明,不代表現況,說明文字不應再沿用這個描述。






排程時間總覽:


時間:每分鐘
任務:IPS論壇背景任務
說明:以 www-data 執行,crontab 層加 flock -n


時間:03:15
任務:backup.sh
說明:自動備份,腳本內部自行處理鎖定


時間:03:45
任務:check_backup.sh(輕量模式)
說明:備份完成 30 分鐘後執行監控


時間:週日 04:30
任務:check_backup.sh(RESTORE_TEST_FULL=1)
說明:每週一次完整 Restore Test


時間:00:00(systemd timer)
任務:logrotate
說明:沿用 Debian 13 系統預設,不改為 cron 觸發






9.4 為什麼IPS論壇任務要在 crontab 層加 flock,備份卻不加?

兩者差異在於「腳本本身是否已自行管理鎖定」。

IPS論壇的 task.php 是官方核心程式,我們不會也不該修改其原始碼替它加上鎖定機制,因此需要在 crontab 這一層外掛 flock -n,作為它唯一的重疊執行防護。

而 backup.sh 是我們自己撰寫、可控的腳本,內部已用 exec 200>"$LOCK_FILE" 搭配 flock -w 30 完整處理鎖定,還額外做了 stale lock watchdog。

若在 crontab 層再包一層 flock -n 包住同一顆鎖定檔,會直接造成死結:外層 flock(1) 這個行程會持有鎖定直到子行程(backup.sh)整個執行完畢才釋放,

而 backup.sh 內部重新開啟並嘗試鎖定同一個檔案時,該檔案早已被外層行程鎖住,

只能乾等到 LOCK_WAIT=30 秒逾時後放棄——也就是說,備份腳本透過 cron 觸發時將每一次都失敗,但用手動執行(bash /usr/local/sbin/backup.sh)測試時卻完全正常,

因為手動執行時沒有外層 flock 搶先持有鎖定。

這種「測試正常、排程必掛」的落差極難排查,是本輪稽核發現的最關鍵問題之一。



結論:一支腳本若已經自行處理鎖定,crontab 層就不要再用 flock 包住同一顆鎖定檔;

只有像 task.php 這類完全無法修改、自身不具備鎖定機制的程式,才需要在 crontab 層外掛 flock。

若你偏好保留 crontab 層防護,可以改用兩顆不同的鎖定檔:crontab 層鎖 /run/lock/forum_backup_cron.lock,

腳本內部維持鎖 /run/lock/forum_backup.lock,兩者互不衝突;

但這麼做的實益有限——backup.sh 內部鎖定的取得時機已經非常早(腳本第一段就執行),能防堵的重疊時間窗口極短,

多一層鎖定換來的是多一組設定需要同步維護。

本篇建議直接信任腳本自身的鎖定機制。


確認設定檔已正確被 cron 識別:

ls -l /etc/cron.d/forum-backup

journalctl -u cron --since "1 minute ago"






---------------------------------------------------------------
第 10 章:驗收清單
---------------------------------------------------------------

完成本篇設定後,請逐一確認以下項目:

1. cron 服務正常運行
systemctl status cron | grep -E "Active:|Loaded:"


2. /etc/cron.d/forum-backup 存在且權限正確(因含明碼金鑰,應為 -rw-r----- root root)
ls -l /etc/cron.d/forum-backup


3. 備份腳本與監控腳本存在且可執行
ls -l /usr/local/sbin/backup.sh /usr/local/sbin/check_backup.sh


4. 資料庫認證設定檔存在且權限受限(應為 -rw------- root root)
ls -l /root/.my_backup.cnf


5. 備份目錄存在且權限正確(應為 drwxr-x--- root root)
ls -ld /var/www/backup


6. IPS論壇任務日誌目錄存在且屬於 www-data
ls -ld /var/log/ips-task


7. 確認 pigz 已安裝(備份腳本將自動使用多核心壓縮)
pigz --version


8. ShellCheck 靜態檢查
shellcheck /usr/local/sbin/backup.sh


shellcheck /usr/local/sbin/check_backup.sh


9. 手動測試備份腳本一次,確認無錯誤輸出
bash /usr/local/sbin/backup.sh && echo "備份腳本測試:OK"


10. 確認備份目錄有產生備份檔與 SHA256 校驗檔
ls -lh /var/www/backup/


11. 以人類可讀格式確認備份日誌
jq -r '[.ts, .level, .msg] | @tsv' /var/log/forum_backup.log | tail -20


12. 確認日誌中無 ERROR 事件
jq 'select(.level == "ERROR")' /var/log/forum_backup.log


13. 從 systemd journal 確認備份記錄(雙軌 log 驗證)
journalctl -t forum-backup --since today --no-pager | tail -20


14. 手動測試備份監控腳本
/usr/local/sbin/check_backup.sh && echo "備份監控腳本測試:OK"


15. 確認備份腳本的 exit code 正確反映結果(正常應為 0,有錯誤則非 0)
bash /usr/local/sbin/backup.sh; echo "exit code: $?"


16. 驗證備份檔案的 SHA256 校驗檔

cd /var/www/backup

sha256sum -c Web_*.tar.gz.sha256


sha256sum -c Db_*.sql.gz.sha256



17. 確認備份包含 ACL/xattr 資訊

cd /var/www/backup


for f in Web_*.tar.gz; do
    echo "=== $f ==="
    tar --acls --xattrs --numeric-owner -tzvf "$f" | head -20
done



18. 確認壓縮格式完整
gzip -t /var/www/backup/Web_*.tar.gz


gzip -t /var/www/backup/Db_*.sql.gz



19. 確認 SQL 內容可讀性
zcat /var/www/backup/Db_*.sql.gz | head -20


20. logrotate 設定無語法錯誤

logrotate --debug /etc/logrotate.d/forum-backup

logrotate --debug /etc/logrotate.d/ips-task


21. logrotate.timer 確實在執行(Debian 13 關鍵確認項目)

systemctl status logrotate.timer | grep "Active:"

systemctl list-timers | grep logrotate


22. 確認排程中未設定 MAILTO
grep -i "MAILTO" /etc/cron.d/forum-backup && echo "警告:偵測到 MAILTO 設定,請移除!" || echo "MAILTO 未設定:OK"


23. 確認備份帳號的資料庫連線正常
mariadb --defaults-extra-file=/root/.my_backup.cnf -e "SELECT 1;"


24. 確認備份帳號擁有 PROCESS 權限
mariadb -u root -p -e "SHOW GRANTS FOR 'forum_backup'@'localhost';" | grep -i "PROCESS"


25. 確認 GRANT 的 restore_test_% 授權範圍與腳本前綴一致
mariadb -u root -p -e "SHOW GRANTS FOR 'forum_backup'@'localhost';" | grep -i "restore_test_%"


26. 確認備份腳本無殘留行程(極端情況下 flock 異常時可能殘留)
pgrep -fl backup.sh || echo "無殘留 backup.sh 行程:OK"


27. 確認 backup_latest.json 存在且可被 check_backup.sh 正確讀取
jq '.uuid, .hostname, .timestamp' /var/www/backup/backup_latest.json


28. 確認 JSON 報告存在
jq . /var/www/backup/backup-status.json


29. 確認 Nginx 已阻擋備份目錄的公開存取

curl -I https://域名.com/backup/backup-status.json

應回應 404 Not Found


30. 使用 bash -x 偵錯模式驗證腳本流程(排查問題時使用)

bash -x /usr/local/sbin/backup.sh 2>&1 | head -80






全部確認無誤後,本篇設定即告完成。

建議翌日凌晨 4 點後,再次執行第 14 步驟確認備份監控輸出為「OK」,確保首次自動執行已成功。






--------------------------------------------------------------------------
第 11 章:Recovery SOP(還原標準作業程序)與 Rollback SOP
--------------------------------------------------------------------------

11.1 還原前必讀:確認備份環境資訊

還原前必須先確認 backup_latest.json 的以下資訊,確保目標環境與備份來源相容:

cat /var/www/backup/backup_latest.json | jq '{
    mariadb_version: .mariadb_version,
    innodb_page_size: .mariadb_config.innodb_page_size,
    innodb_file_per_table: .mariadb_config.innodb_file_per_table,
    character_set_server: .mariadb_config.character_set_server,
    collation_server: .mariadb_config.collation_server,
    filesystem_uuid: .filesystem.uuid,
    filesystem_type: .filesystem.type
}'



跨版本還原地雷:

innodb_page_size 不一致(16384 vs 32768)→ InnoDB 無法開啟資料表。

character_set_server 不一致 → 中文資料可能亂碼。

MariaDB 大版本跨越(如 10.x → 11.x)→ 可能需要 mariadb-upgrade。



另一個容易被忽略的跨版本地雷:dump 檔開頭的「sandbox mode」防護指令。

自 MariaDB 10.5.25 / 10.6.18 / 10.11.8 / 11.0.6 / 11.1.5 / 11.2.4 / 11.4.2 起(本篇使用的 11.8 亦包含在內),

mariadb-dump 基於資安考量,會在匯出檔最前面自動加入一行只有新版用戶端才能識別的指令,用來啟用「sandbox mode」保護機制。

這代表:用本篇腳本(MariaDB 11.8)產生的備份檔,若要匯入到版本明顯較舊的 MariaDB(例如 10.4 或更早),

或匯入到 MySQL(而非 MariaDB)的伺服器,還原時可能會直接報錯中止,因為舊版用戶端無法解析這行指令。

這與本篇 Restore Test 使用「同一台機器、同一個 mariadb 版本」匯入並不衝突,不影響本篇的日常自動驗證;

但若你的 Recovery SOP 情境是「搬遷到版本較舊的另一台伺服器」或「從 MariaDB 遷移回 MySQL」,請留意此限制。



常見排除方式(三選一):

1. 建議做法:確認還原目標同樣使用具備此更新的 MariaDB 版本用戶端匯入(生產環境應優先考慮升級用戶端,而非降級 dump 相容性)。


2. 匯出時用 mariadb-dump ... | tail -n +2 移除開頭那一行後再保存(會失去 sandbox mode 的額外保護,僅建議在確知還原對象版本較舊時使用)。


3. 匯入時用 tail -n +2 backup.sql | mariadb ... 略過該行後再匯入。



一般情況下(還原到相同或更新版本的 MariaDB,即本篇預設情境)完全不受影響,這裡只是提醒:

若日後你的 Recovery SOP 需要「降級」或「跨產品」還原,請先注意這個差異,避免深夜還原時被這個看似無關的錯誤訊息卡住。






11.2 完整還原流程(九步驟)

步驟一:確認備份檔案完整性

cd /var/www/backup

ls -lh Db_*.sql.gz Web_*.tar.gz | tail -4

sha256sum -c Db_*.sql.gz.sha256

sha256sum -c Web_*.tar.gz.sha256

gzip -t Db_*.sql.gz && echo "DB OK"

gzip -t Web_*.tar.gz && echo "Web OK"



步驟二:停止論壇服務(避免還原期間寫入衝突)

systemctl stop nginx php8.3-fpm


mariadb -u root -e "SHOW PROCESSLIST;" | grep -v Sleep



步驟三:還原前快照備份(防止還原失敗無法回頭)

RESTORE_PRE_TS="$(date +%Y%m%d_%H%M%S)"
mariadb-dump \
    --defaults-extra-file=/root/.my_backup.cnf \
    --single-transaction --quick \
    你的論壇資料庫名 \
    > "/var/www/backup/pre_restore_${RESTORE_PRE_TS}.sql"

echo "快照完成:/var/www/backup/pre_restore_${RESTORE_PRE_TS}.sql"



步驟四:還原資料庫

DB_BACKUP="/var/www/backup/Db_你的論壇資料庫名_YYYYMMDD_HHMMSS.sql.gz"

# 刪除並重建資料庫(確保乾淨環境)
mariadb -u root << 'SQL'
DROP DATABASE IF EXISTS `你的論壇資料庫名`;
CREATE DATABASE `你的論壇資料庫名`
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;
SQL

# 匯入備份(用 pv 顯示進度;若未安裝:apt install pv -y)
zcat "$DB_BACKUP" | pv | mariadb \
    --defaults-extra-file=/root/.my_backup.cnf \
    你的論壇資料庫名

echo "資料庫還原完成,Exit Code: $?"



步驟五:驗證資料庫還原結果

mariadb --defaults-extra-file=/root/.my_backup.cnf \
    -e "SELECT COUNT(*) AS 會員數 FROM 你的論壇資料庫名.core_members;"

mariadb --defaults-extra-file=/root/.my_backup.cnf \
    -e "SELECT COUNT(*) AS 貼文數 FROM 你的論壇資料庫名.forums_posts;"

# 確認無損壞的資料表
mariadb --defaults-extra-file=/root/.my_backup.cnf \
    你的論壇資料庫名 \
    -e "CHECK TABLE core_sys_conf_settings, core_members, forums_posts QUICK;"



步驟六:還原網站檔案

WEB_BACKUP="/var/www/backup/Web_域名_com_YYYYMMDD_HHMMSS.tar.gz"

mv /var/www/域名.com "/var/www/域名.com.bak_${RESTORE_PRE_TS}"
tar --acls --xattrs --numeric-owner -xzf "$WEB_BACKUP" -C /var/www/
ls -la /var/www/域名.com/



步驟七:修復檔案權限

find /var/www/域名.com -type d -exec chmod 755 {} \;


find /var/www/域名.com -type f -exec chmod 644 {} \;


chown -R www-data:www-data /var/www/域名.com


上面兩行 find 會把 conf_global.php 也一起打回 644(other 可讀),
這支設定檔明碼存放資料庫密碼與 Cookie 加密金鑰,批次權限復原後
務必單獨把它收緊回 640(詳見 15.2 節「批次 chmod 蓋掉個別檔案權限」):

chmod 640 /var/www/域名.com/conf_global.php


chown www-data:www-data /var/www/域名.com/conf_global.php


驗證:應輸出「640 www-data:www-data」
stat -c '%a %U:%G' /var/www/域名.com/conf_global.php



步驟八:啟動服務並驗證

systemctl start mariadb php8.3-fpm nginx


systemctl status nginx php8.3-fpm mariadb


curl -sI https://域名.com | head -5


curl -sI https://域名.com/admin/



步驟九:清理臨時檔案

確認論壇正常後,移除還原前快照(避免佔用磁碟空間;建議保留 24 小時後再刪除):

rm "/var/www/backup/pre_restore_${RESTORE_PRE_TS}.sql"


rm -rf "/var/www/域名.com.bak_${RESTORE_PRE_TS}"






還原後檢查清單:

[ ] 論壇首頁可正常開啟,無 500 / 502 錯誤

[ ] 後台管理介面可登入

[ ] SHOW TABLE STATUS 確認資料表引擎、字元集與備份前一致(utf8mb4 / InnoDB)

[ ] 搜尋功能正常(Elasticsearch/OpenSearch 索引可能需要重建:ACP → 支援 → 搜尋 → 重建索引)

[ ] Cron/IPS論壇背景任務恢復執行(見 4.6 節驗證方式)

[ ] TLS憑證仍有效(憑證自動化與網站目錄無關,通常不受影響,但建議仍確認一次)

[ ] getfacl -R 抽查關鍵目錄,確認 ACL/擴充屬性是否確實還原

[ ] conf_global.php 權限已收斂為 640(而非批次 chmod 後殘留的 644)

[ ] 通知使用者/管理員本次資料還原的時間點






11.3 Rollback SOP(還原失敗時回滾)

若還原失敗,使用還原前快照回滾:

回滾資料庫至還原前狀態
mariadb -u root -e "DROP DATABASE \`你的論壇資料庫名\`; CREATE DATABASE \`你的論壇資料庫名\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb --defaults-extra-file=/root/.my_backup.cnf 你的論壇資料庫名 \
    < "/var/www/backup/pre_restore_${RESTORE_PRE_TS}.sql"



回滾網站目錄

rm -rf /var/www/域名.com

mv "/var/www/域名.com.bak_${RESTORE_PRE_TS}" /var/www/域名.com


重啟服務
systemctl restart nginx php8.3-fpm


記錄 Rollback 事件(供事後 RCA 使用)
logger -t "forum-backup" -p "user.crit" \
    "ROLLBACK executed at $(date '+%F %T'), reason: restore failed"






--------------------------------------------------------------------------
第 12 章:異地備份、加密與不可篡改備份
--------------------------------------------------------------------------

12.1 為什麼本機備份不夠


風險:主機硬碟損壞
本機備份能防護?✗
異地備份能防護?✓


風險:VPS 主機商問題
本機備份能防護?✗
異地備份能防護?✓


風險:勒索病毒加密所有檔案
本機備份能防護?✗
異地備份能防護?✓(Immutable 備份)


風險:誤刪備份目錄
本機備份能防護?✗
異地備份能防護?✓


風險:主機整個被刪除
本機備份能防護?✗
異地備份能防護?✓



本機備份(第 7、8 章)只解決了「人為誤刪、論壇程式或資料庫本身發生問題」這類情境,

完全無法應對「VPS 整顆磁碟故障」或「主機商發生重大事故」——因為備份檔案跟論壇本體放在同一台主機、甚至同一顆磁碟上,一起遭殃。

這是只做本機備份最容易被忽略、但風險最高的盲點。建議至少擇一實作異地備份。






12.2 rsync/scp 同步

本輪稽核發現的設計風險:不要對異地備份使用 rsync --delete。

--delete 會讓遠端目錄「鏡像」本機目錄,本機經 GFS 輪替刪除的舊檔會被同步從遠端一併刪除;

若本機備份目錄遭勒索病毒加密或誤刪,下一次排程觸發時,--delete 會把這個「刪除/污染」動作原封不動複製到遠端,

直接抵銷本章開頭表格中「異地備份可防護勒索病毒/誤刪」的初衷。

異地端建議維持只新增、不刪除的行為,讓遠端自行依照 5.2 節的保留天數(例如 30 天)做獨立清理,與本機的 GFS 週期脫鉤。

rsync -avz /var/www/backup/ user@另一台主機:/path/to/remote/backup/

若擔心遠端空間無限成長,可在遠端主機另外排程一支獨立的清理腳本(例如 find /path/to/remote/backup -name '*.gz' -mtime +30 -delete),

而不是靠本機 rsync --delete 去同步刪除;

如此一來,遠端的保留週期與本機完全獨立,才能真正發揮異地備份「多一層安全網」的效果。

同步完成後,在遠端用 SHA256 校驗檔驗證傳輸完整性:

ssh user@另一台主機 'cd /path/to/remote/backup && sha256sum -c Db_*.sql.gz.sha256'


SHA256 校驗檔在異地備份場景下特別有價值:不論 rsync 或 rclone,網路中斷或儲存異常都可能在不被察覺的情況下造成資料損毀。

有了 .sha256 校驗檔,可在傳輸完成後獨立驗證,確保「傳到遠端的備份」和「本機產生的備份」完全一致,避免需要還原時才發現備份損壞。






12.3 rclone 推送至 S3/Backblaze B2/MinIO

apt install -y rclone

rclone config


依畫面指示填入:

S3 選 "Amazon S3",填 access_key / secret_key / region;

B2 選 "Backblaze B2",填 account_id / application_key;

MinIO 選 "S3",填自架 MinIO 的 endpoint。


rclone ls 你的remote名稱:你的bucket名稱/



cat > /usr/local/sbin/rclone_backup.sh << 'RCLONE_EOF'
#!/usr/bin/env bash
set -Eeuo pipefail

BACKUP_HOME="/var/www/backup"
RCLONE_REMOTE="b2backup"        # rclone remote 名稱(自行替換)
RCLONE_BUCKET="my-forum-backup" # Bucket 名稱
RCLONE_PATH="forum01"           # Bucket 內的路徑

# 僅上傳 7 天內的最新備份,避免每次全量上傳
rclone copy \
    --min-age 0s \
    --max-age 7d \
    --include "*.gz" \
    --include "*.sha256" \
    --include "*.sha512" \
    --include "*.json" \
    --progress \
    "$BACKUP_HOME/" \
    "${RCLONE_REMOTE}:${RCLONE_BUCKET}/${RCLONE_PATH}/"

logger -t "forum-rclone" -p "user.info" "rclone 異地備份上傳完成"
RCLONE_EOF

chmod 750 /usr/local/sbin/rclone_backup.sh



注意:rclone_backup.sh 的Cron排程需要自行決定(建議修改為 每日凌晨 05:15,備份完成後 2 小時才執行,避免與凌晨 3:15 的主備份搶頻寬)。






12.4 不可篡改備份(Immutable Backup)

一旦上傳,在保留期間內無法被刪除或修改——即使攻擊者取得了你的所有憑證。這是對抗勒索病毒最有效的備份策略。


**Backblaze B2 Object Lock:**

# 編輯 /root/.config/rclone/rclone.conf,在 b2backup 段落加入:
# object_lock_mode = COMPLIANCE
# object_lock_retention_period = 30d

# 或在 rclone copy 命令加入:
rclone copy \
    --b2-object-lock-mode COMPLIANCE \
    --b2-object-lock-retain-until-date "2026-10-01" \
    "$BACKUP_HOME/backup_latest.json" \
    "b2backup:my-forum-backup/forum01/immutable/"






**AWS S3 Object Lock:**

# 在 AWS Console 建立 Bucket 時啟用 Object Lock,或透過 AWS CLI:
aws s3api put-object-retention \
    --bucket my-forum-backup \
    --key "forum01/Db_forum_20260701_031501.sql.gz" \
    --retention '{"Mode":"COMPLIANCE","RetainUntilDate":"2026-10-01T00:00:00Z"}'






12.5 備份檔案加密

資料庫備份檔內含會員的 Email、帳號資料等個人資料,若傳輸至異地或存放在第三方服務時未加密,一旦儲存空間遭未授權存取,等同個資直接外洩。建議使用 GPG 對稱式加密:


apt install gnupg -y


加密(會要求輸入並確認通行密碼)
gpg --symmetric --cipher-algo AES256 \
    /var/www/backup/Db_你的論壇資料庫_20260701_031501.sql.gz


解密(輸入通行密碼後還原為原始 .sql.gz)
gpg --decrypt \
    /path/to/Db_你的論壇資料庫_20260701_031501.sql.gz.gpg \
    > /tmp/Db_restore.sql.gz



通行密碼管理:加密通行密碼本身需要妥善保管(例如密碼管理軟體),否則加密後的備份等於永久無法還原。

建議設定加密流程後,立即完整演練一次「加密 → 解密 → 還原驗證」,確認整個流程可行。



若要把加密納入 cron 自動流程,務必改用非互動模式。

上方 gpg --symmetric 手動測試沒問題,但直接照樣寫進 rclone_backup.sh 或 crontab 會整個卡死:cron 執行環境沒有 TTY,

gpg 預設呼叫的 pinentry 互動式密碼輸入視窗根本無法顯示,腳本會停在原地等待一個永遠不會出現的輸入,

直到 cron 逾時或被下一輪排程重疊觸發——這是新手與老手都容易漏掉的地雷,因為手動測試時完全正常,只有排程觸發時才會出問題。

改用密碼檔+批次模式:


密碼檔僅 root 可讀,內容為通行密碼(不含結尾換行更保險)
install -m 600 -o root -g root /dev/null /root/.gpg_backup_passphrase
printf '%s' '請替換為高強度通行密碼' >| /root/.gpg_backup_passphrase
chmod 600 /root/.gpg_backup_passphrase


非互動加密:--batch 關閉所有互動提示,--passphrase-file 讀取密碼檔
gpg --batch --yes --passphrase-file /root/.gpg_backup_passphrase \
    --symmetric --cipher-algo AES256 \
    "$BACKUP_HOME/Db_你的論壇資料庫_20260701_031501.sql.gz"


非互動解密,用法相同
gpg --batch --yes --passphrase-file /root/.gpg_backup_passphrase \
    --decrypt "/path/to/Db_你的論壇資料庫_20260701_031501.sql.gz.gpg" \
    > /tmp/Db_restore.sql.gz



寫入排程前,務必先手動執行一次上面這兩行非互動版指令並確認 exit code 為 0;

若看到腳本卡住沒有任何輸出,通常就是漏了 --batch 或密碼檔路徑寫錯。






--------------------------------------------------------------------------
第 13 章:Logging 架構與 ELK/Filebeat 整合
--------------------------------------------------------------------------

13.1 雙軌 Logging 架構

backup.sh 與 check_backup.sh 的每一筆事件都會同時走兩條路徑:


jq(產生 JSON)
    ├── flock(fd 9)序列化寫入 /var/log/forum_backup.log   ← JSON Lines 格式,供 ELK / Filebeat 收集
    └── logger -t forum-backup --id=$$                        ← systemd journal,RFC5424 syslog priority
            -p user.{info|warning|err}


這裡的路徑本來就是對的(forum_backup.log 是腳本內部寫入的目標,不是 cron 重導向,不需要改成 forum_backup_cron.log),

問題只在機制描述沿用了舊的 tee -a 說法。

對照 backup.sh` 目前的實作(第 339-342 行):

以 flock -w 2 9(fd 9,鎖定檔 LOG_LOCK_FILE)序列化後再 printf ... >> "$LOG_FILE",而不是 tee -a——跟 9.3 節同一類型的殘留,只是這次出現在架構圖裡。



補充:journal 是 Debian 13 預設的系統日誌後端(journald),

與排程機制無關——不論排程是 cron 或 systemd timer 都會使用它,因此本篇雖選擇 cron 排程(見第 1 章),

腳本仍可正常把結構化日誌寫一份到 journal 供人工查閱。






13.2 RFC5424 Syslog Priority 對應

backup.sh level:INFO
logger -p:user.info
RFC5424 名稱:Informational (6)
用途:正常流程記錄


backup.sh level:WARN
logger -p:user.warning
RFC5424 名稱:Warning (4)
用途:非致命異常


backup.sh level:ERROR
logger -p:user.err
RFC5424 名稱:Error (3)
用途:需立即處理的錯誤






13.3 journalctl 常用查詢

查看今日所有備份日誌
journalctl -t forum-backup --since today


只查看錯誤等級
journalctl -t forum-backup -p err --since "7 days ago"


以 JSON 格式輸出(送往 ELK 前測試格式)
journalctl -t forum-backup --since today -o json-pretty | head -50


即時監控備份進度
journalctl -t forum-backup -f


查看過去 30 天的錯誤並統計次數
journalctl -t forum-backup -p err --since "30 days ago" \
    | grep -oP '"event":"\K[^"]+' | sort | uniq -c | sort -rn






13.4 確認 journal 持久化設定

grep -E "Storage|SystemMaxUse" /etc/systemd/journald.conf

若預設是 auto,建議改為 persistent 確保重開機後 journal 不遺失:

sed -i 's/#Storage=auto/Storage=persistent/' /etc/systemd/journald.conf
systemctl restart systemd-journald






13.5 ELK/Filebeat 整合

# /etc/filebeat/filebeat.yml 相關片段
filebeat.inputs:
  - type: filestream
    id: forum-backup
    paths:
      - /var/log/forum_backup.log
    parsers:
      - ndjson:
          target: ""
          overwrite_keys: true

output.elasticsearch:
  hosts: ["localhost:9200"]
  index: "forum-backup-%{+yyyy.MM}"



/var/log/forum_backup.log 是 JSON Lines 格式,每行一筆完整 JSON 事件,Filebeat 的 ndjson parser 可直接逐行解析,

不需要額外的 grok pattern。搭配 9.1 節的 logrotate 設定,Filebeat 的 filestream input 會自動偵測檔案輪替(inode 變化),不會漏讀或重複讀取。






13.6 JSON 報告與 Prometheus 整合(延伸)

check_backup.sh 每次執行都會產生 backup-status.json(schema 2.0),大致結構如下:

{
  "schema": "2.0",
  "ts": "2026-07-05T05:30:12+0800",
  "overall": "PASS",
  "elapsed_sec": 47,
  "health": { "score": 96, "pass": 28, "warn": 2, "fail": 0, "total_checked": 30 },
  "backup_count": { "db": 14, "web": 14 },
  "system": {
    "mem_available_kb": 512340,
    "disk_free_pct": 62,
    "fs_avail_bytes": 21474836480,
    "filesystem_type": "ext4"
  },
  "checks": {
    "restore_result": "PASS",
    "sha256": "PASS",
    "gfs_weekly": "PASS",
    "swap": "PASS"
  }
}



若已設定 Prometheus node_exporter 的 textfile_collector,可將此 JSON 轉換為 .prom 格式:

cat > /usr/local/sbin/backup_status_to_prom.sh << 'EOF'
#!/usr/bin/env bash
# /usr/local/sbin/backup_status_to_prom.sh
set -euo pipefail
JSON="/var/www/backup/backup-status.json"
OUT="/var/lib/node_exporter/textfile_collector/backup_status.prom"
TMP="$(mktemp)"

jq -r '
  "backup_health_score \(.health.score)",
  "backup_overall_pass \(if .overall == "PASS" then 1 else 0 end)",
  "backup_elapsed_seconds \(.elapsed_sec)",
  "backup_disk_free_percent \(.system.disk_free_pct)",
  "backup_count_db \(.backup_count.db)",
  "backup_count_web \(.backup_count.web)"
' "$JSON" > "$TMP"

mv "$TMP" "$OUT"
EOF

chmod 750 /usr/local/sbin/backup_status_to_prom.sh



建議直接在 9.3 節 check_backup.sh 那一行後面串接呼叫(&&),確保只在監控腳本執行完畢後才轉換 JSON:

45 3 * * * root /usr/local/sbin/check_backup.sh >> /var/log/forum_backup_cron.log 2>&1 && /usr/local/sbin/backup_status_to_prom.sh

重點是不使用 systemd timer 的 OnSuccess= 機制,維持本篇統一以 cron 排程的設計。






13.7 Email 告警包裝腳本 run_with_alert.sh

由於 cron 本身不設定 MAILTO,若要在 check_backup.sh 或 backup.sh 失敗時主動收到 Email,最簡單可靠的做法是寫一個「包裝腳本」:

先執行目標腳本,若 exit code 非 0,才呼叫 s-nail 寄信,避免每次成功執行都收到「一切正常」的信件造成疲勞轟炸。



cat > /usr/local/sbin/run_with_alert.sh << 'EOF'
#!/usr/bin/env bash
# /usr/local/sbin/run_with_alert.sh
# 用法:run_with_alert.sh <要執行的指令與參數...>
# 執行目標指令,若 exit code 非 0,寄送告警 Email(延續 s-nail + Gmail App Password 設定)
set -uo pipefail

ALERT_TO="你的Gmail帳號@gmail.com"
HOSTNAME_SHORT="$(cat /etc/hostname 2>/dev/null || echo unknown)"

OUTPUT="$("$@" 2>&1)"
EXIT_CODE=$?

if [ "$EXIT_CODE" -ne 0 ]; then
    printf '%s\n' "$OUTPUT" | tail -n 100 | \
        mail -s "[告警] ${1##*/} 於 ${HOSTNAME_SHORT} 失敗(exit ${EXIT_CODE})" \
            -S smtp-use-starttls \
            -S smtp=smtp://smtp.gmail.com:587 \
            -S from=你的Gmail帳號@gmail.com \
            -S smtp-auth=login \
            -S smtp-auth-user=你的Gmail帳號@gmail.com \
            -S smtp-auth-password-file=/root/.gmail_app_password \
            "$ALERT_TO"
fi

exit "$EXIT_CODE"
EOF

chmod 750 /usr/local/sbin/run_with_alert.sh
chown root:root /usr/local/sbin/run_with_alert.sh






此處沿用已設定好的 s-nail + Gmail App Password 機制,若尚未設定請先完成該章節。

mail 指令由 s-nail 提供,若尚未安裝:apt install s-nail -y


在 /etc/cron.d/forum-backup 中,把要接收告警的兩行改為透過這支包裝腳本呼叫(其餘行不變):

15 3 * * * root /usr/local/sbin/run_with_alert.sh /usr/local/sbin/backup.sh >> /var/log/forum_backup_cron.log 2>&1

45 3 * * * root /usr/local/sbin/run_with_alert.sh /usr/local/sbin/check_backup.sh >> /var/log/forum_backup_cron.log 2>&1





提醒:check_backup.sh 的設計是 WARN 與 FAIL 都會讓 exit code 回傳 1,這代表上面的包裝腳本對 WARN 也會寄信。

若只想針對真正致命的 FAIL 收到告警,可改寫包裝腳本,改用 jq 解析 backup-status.json 的 overall 欄位判斷,而不是單純看 exit code。






--------------------------------------------------------------------------
第 14 章:常見錯誤排查
--------------------------------------------------------------------------

14.1 備份中止:無法取得鎖定

症狀:等待 30s 後仍無法取得鎖定,前一輪備份可能卡住,本次略過。


查看 PID 檔案記錄的 PID
cat /run/lock/forum_backup.lock.pid


確認該 PID 是否仍在執行
ps aux | grep backup.sh | grep -v grep


若確認卡住,手動終止並解鎖
kill -TERM <PID>
rm -f /run/lock/forum_backup.lock /run/lock/forum_backup.lock.pid



若上述排查發現 PID 檔查無此程序,但備份卻「每次透過 cron 觸發都逾時、手動執行卻完全正常」,這是雙重 flock 死結的典型徵兆(原理見 9.4 節),確認方式:

grep backup.sh /etc/cron.d/forum-backup



移除 crontab 那一行開頭的 /usr/bin/flock -n /run/lock/forum_backup.lock 部分,

只留下 root /usr/local/sbin/backup.sh >> /var/log/forum_backup_cron.log 2>&1(或 13.7 節 run_with_alert.sh 包裝版本),

改完後等下個整分鐘自動生效即可,不需要重啟 cron 服務。






14.2 資料庫備份失敗:exit 124(timeout 到期)

症狀:{"event":"db_backup_timeout_term","msg":"資料庫備份逾時(SIGTERM,超過 2h)..."}


查看 MariaDB 是否有長時間執行的查詢
mariadb --defaults-extra-file=/root/.my_backup.cnf -e "SHOW PROCESSLIST;"


確認 InnoDB 是否有鎖死(deadlock)
mariadb --defaults-extra-file=/root/.my_backup.cnf \
    -e "SHOW ENGINE INNODB STATUS\G" 2>/dev/null | grep -A 20 "LATEST DEADLOCK"


確認磁碟 I/O 壓力
iostat -xz 2 5






14.3 SHA256 驗證失敗

症狀:{"event":"sha256_verified_fail","msg":"SHA256 自動驗證失敗!備份檔可能已被修改或損壞。"}


cd /var/www/backup

sha256sum -c "$(ls -t Db_*.sha256 | head -1)"


若顯示 FAILED,備份檔已損壞,使用前一次備份
ls -lt /var/www/backup/Db_*.sql.gz | head -5






14.4 --no-tablespaces 相關問題

若 MariaDB 11.x dump 時出現 InnoDB: 'tablespace' export/import requires... 之類的警告:

mariadb-dump --help | grep no-tablespaces

MariaDB 10.4+ 支援,Debian 13 的 11.8 版完全支援






14.5 --order-by-primary 警告

MariaDB 11.8 狀態:--order-by-primary 在 MariaDB 11.x 仍受支援,無棄用警告:

mariadb-dump --defaults-extra-file=/root/.my_backup.cnf \
    --order-by-primary --single-transaction --no-data \
    你的論壇資料庫名 2>&1 | head -5






14.6 Restore Test 因權限不足失敗

症狀:restore_test_db_create_fail,無法建立臨時測試 DB。

這通常代表資料庫帳號的 CREATE、DROP 授權範圍與腳本實際使用的暫存資料庫名稱前綴不一致。確認:

mariadb -u root -p -e "SHOW GRANTS FOR 'forum_backup'@'localhost';" | grep restore_test


應看到 GRANT ALL PRIVILEGES ON \`restore_test_%\`.*,

且 backup.sh 的 RESTORE_TEST_DB 與 check_backup.sh 的 RESTORE_TEST_DB 兩者都以 restore_test_ 開頭。

若你是自行修改過前綴,務必讓三者(GRANT pattern、backup.sh 前綴、check_backup.sh 前綴)保持一致;

若只看到 CREATE、DROP 兩項,代表 GRANT 尚未升級到 6.2 節修正後的版本,INSERT 權限不足會讓 Restore Test 產生「假通過」。






14.7 check_backup.sh 一直報 Metadata WARN

若 backup.sh / check_backup.sh 仍持續出現 metadata 檢查 WARN,先確認 backup_latest.json 是否存在且欄位完整:

ls -l /var/www/backup/backup_latest.json

jq '.uuid, .hostname, .timestamp' /var/www/backup/backup_latest.json



若檔案不存在,代表最近一次備份沒有順利跑到「產生元資料」的階段(可能中途 Exit Code 非 0),

請查看 journalctl -t forum-backup --since today -p err 定位失敗環節。






14.8 其他常見現象對照

現象:mariadb_health WARN(服務運行中但 ping 失敗)
可能原因:最小權限帳號認證檔遺失或密碼錯誤
排查指令:mariadb-admin ping --defaults-extra-file=/root/.my_backup.cnf


現象:flock_stale WARN
可能原因:上次 backup.sh 異常中止未釋放鎖
排查指令:ps aux \| grep backup,確認無殘留行程後 rm -f /run/lock/forum_backup.lock


現象:time_sync WARN
可能原因:NTP 未同步,可能影響所有以時間為基礎的判斷
排查指令:timedatectl status、systemctl status systemd-timesyncd


現象:log_size WARN
可能原因:logrotate 未正常運作
排查指令:logrotate -d /etc/logrotate.d/forum-backup、systemctl status logrotate.timer


現象:stats(σ 偏差偵測)WARN
可能原因:近期備份大小低於「最近 7 次備份」平均的 2 個標準差
排查指令:對照 size trend 前次比較判斷是否為單次異常或持續趨勢;統計基準會隨時間自然更新,不會被數月前的一次性大備份長期綁架






--------------------------------------------------------------------------
第 15 章:新手與老手常見盲點總整理
--------------------------------------------------------------------------

15.1 新手常見盲點

# 1
盲點:只備份 DB,忘了 Web 目錄
問題說明:IPS論壇的附件、自訂主題、上傳圖片全在 Web 目錄
正確做法:DB + Web 兩者都要備份


# 2
盲點:備份目錄沒設權限
問題說明:backup/ 目錄預設可能 755,非 root 使用者可以讀取備份
正確做法:chmod 750 /var/www/backup


# 3
盲點:認證設定檔明文密碼可讀
問題說明:/root/.my_backup.cnf 若為 644,所有使用者能看到密碼
正確做法:chmod 600 /root/.my_backup.cnf


# 4
盲點:備份中不停止服務
問題說明:備份期間有 PHP 寫入的附件可能不在 tar 備份中
正確做法:接受短暫不一致,或搭配 LVM snapshot


# 5
盲點:從未測試過還原
問題說明:備份成功 ≠ 備份可還原
正確做法:每週執行一次完整還原測試(8.5 節)


# 6
盲點:cron 未設定 MAILTO="" 或沒裝 MTA
問題說明:cron 每次執行都嘗試寄信、或寄信失敗形成靜默失敗
正確做法:不設定 MAILTO,靠日誌與 journal 監控


# 7
盲點:IPS論壇背景任務用 root crontab 執行
問題說明:論壇目錄出現 root 擁有的檔案,日後搬遷或還原權限混亂
正確做法:一律以 www-data 身份執行(4.3 節)


# 8
盲點:高頻任務沒搭配 flock
問題說明:任務重疊執行,2GB RAM VPS 容易資源緊繃
正確做法:搭配 flock -n(3.2 節)


# 9
盲點:樣板佔位符沒有替換
問題說明:域名.com、你的論壇資料庫名 等佔位符忘記換成實際值
正確做法:套用任何腳本前務必搜尋替換;backup.sh 已內建前置檢查


# 10
盲點:誤以為排程「貼上去就一定生效」
問題說明:沒有回頭用 journalctl/日誌/AdminCP 交叉驗證
正確做法:依第 10 章驗收清單逐項確認






15.2 老手容易忽略的盲點

# 1
盲點:--skip-comments 讓 SQL 驗證失效
問題說明:dump 標頭被移除後,grep "MariaDB dump" 無法偵測
正確做法:不要加 --skip-comments


# 2
盲點:--compress 在 localhost 無效且有警告
問題說明:MariaDB 11.x 已棄用,本機連線也沒有意義
正確做法:兩支腳本皆未使用此參數


# 3
盲點:--set-gtid-purged=OFF 用於 MariaDB
問題說明:此旗標是 MySQL 專屬(GTID 複寫),MariaDB 使用不同複寫機制
正確做法:mariadb-dump 會直接報錯,不應使用


# 4
盲點:tar --selinux 在 Debian 無效
問題說明:Debian 用 AppArmor,非 SELinux,加此參數為靜默無效
正確做法:Debian 環境不加此參數,--acls --xattrs --numeric-owner 已足夠


# 5
盲點:SHA256 只產生不驗證
問題說明:磁碟靜默錯誤可能讓校驗值與檔案不符
正確做法:產生後立即 sha256sum -c 驗證


# 6
盲點:只有本機備份
問題說明:VPS 主機商問題或勒索病毒一次清空所有備份
正確做法:加 rclone 異地備份 + Object Lock(第 12 章)


# 7
盲點:沒有元資料 JSON
問題說明:半年後還原時不知道備份當時的環境版本
正確做法:每次備份產生 backup_latest.json


# 8
盲點:readonly VAR="$(cmd)" 的 SC2155 陷阱
問題說明:cmd 失敗被 readonly 吸收,set -e 無法偵測
正確做法:先賦值再 readonly


# 9
盲點:Restore Test 抽樣 table 太少
問題說明:只抽 2、3 個 table,forums_posts / core_queue 損壞還原後才發現問題
正確做法:抽樣涵蓋足夠數量的關鍵 table(見 7.8 節)


# 10
盲點:沒有 Recovery SOP 文件
問題說明:凌晨三點出事找不到正確步驟,匆促操作造成二次傷害
正確做法:第 11 章完整 SOP(含 Rollback)


# 11
盲點:誤以為「腳本自身有鎖 + cron 層再加 flock」是雙重保險
問題說明:兩者鎖住同一檔案會造成死結,備份透過 cron 觸發時每次都失敗,手動執行卻正常,極難排查(原理與判斷原則見 9.4 節)
正確做法:腳本若已自行管理鎖定(如 backup.sh),crontab 層不要再包一次同一顆鎖定檔


# 12
盲點:監控腳本讀取的 PID 檔路徑與備份腳本實際寫入的路徑對不上
問題說明:check_backup.sh 讀 LOCK_FILE(flock 用的空白檔),但 PID 其實寫在 ${LOCK_FILE}.pid,PID 存活檢查因此永遠誤報
正確做法:兩支腳本的檔案路徑常數要逐一比對,尤其是「用途相近但實際指向不同檔案」的變數


# 13
盲點:Restore Test 的 GRANT 只給了 CREATE、DROP 就自認足夠
問題說明:mariadb-dump 帶 --routines --triggers --events 匯出的還原檔含 INSERT/LOCK TABLES 等陳述式,權限不足會讓匯入靜默失敗,
資料表建起來了但沒資料,Restore Test 卻可能仍回報 PASS
正確做法:GRANT 要對照 mariadb-dump 實際使用的參數推導出所需權限清單,並實際跑一次匯入驗證日誌無 Access denied(6.2 節)


# 14
盲點:監控腳本圖方便直接用系統維護帳號(如 debian-sys-maint)連 DB
問題說明:系統維護帳號權限遠超監控腳本實際需求,一旦腳本被竄改或誤用,波及範圍是整個 MariaDB 實例,而非單一沙箱資料庫
正確做法:監控腳本與備份腳本應共用同一顆最小權限帳號,不要因為「方便」而繞路使用系統帳號


# 15
盲點:兩份備份/監控腳本各自迭代、失去同步
問題說明:前綴、路徑、檔名各自演進,最終互相對不上
正確做法:兩支腳本應視為一組耦合元件,任何一方改動命名規則都要同步檢查另一方


# 16
盲點:$(< file) 這種零 fork 讀檔語法,附加 2>/dev/null 後靜默失效
問題說明:這個特殊語法只在「單純只有 < file、沒有其他子句」時才成立,只要加上 2>/dev/null、\|\| 等任何額外子句,Bash 就不再視其為此特殊語法,
導致無論檔案是否存在,永遠回傳空字串,且沒有任何錯誤訊息 
正確做法:需要錯誤處理時改用 cat file 2>/dev/null \|\| echo '';寫腳本時凡是看到 $(< ...) 又想加錯誤處理,先假設它不會如預期運作,動手測試一次再上線


# 17
盲點:在已開啟的雙引號字串裡,貼了另一段帶雙引號的變數展開
問題說明:bash -c "... --opt=\"$VAR\" ...":內層那組雙引號會直接把外層字串「解開」,$VAR 變成未加引號展開,路徑或參數若含空白/萬用字元行為就不可預期
正確做法:用 ShellCheck 掃過所有 bash -c "..." 或需要巢狀引號的地方;更保險的做法是改用位置參數(bash -c '...' _ "$VAR1" "$VAR2"),完全避開巢狀引號



# 18
盲點:異地備份用 rsync --delete 求「乾淨同步」,卻忘了本機也可能是被攻擊的一方
問題說明:--delete 讓遠端完全鏡像本機;本機備份若遭勒索病毒或誤刪,下次排程會把「刪除」也同步過去,異地備份因此失去防護意義
正確做法:異地端只允許新增,不允許本機遙控刪除;遠端的保留週期應獨立於本機的 GFS 輪替之外(12.2 節)


# 19
盲點:同一份設定寫在兩個地方,改了一處卻忘了另一處
問題說明:腳本啟動時查詢的變數與另一處寫死路徑各自獨立,修改其中一處不會連動另一處
正確做法:任何「理論上該與某個常數一致」的寫死值,都應該直接引用該常數,而非另外複製一份字面值;程式碼審閱時可用 grep 搜尋敏感路徑字串是否只出現一種來源


# 20
盲點:GFS 輪替刪除規則的檔名 pattern 沒有和實際產生的檔名逐一核對
問題說明:GFS 清理時試圖一併刪除舊備份對應的 metadata JSON,但 pattern 用的是 Db_*/Web_* 詞幹推導出來的檔名,與實際獨立產生的 backup_<時間戳>.json 對不上,
清理程式碼形同虛設,檔案隨時間無限累積且不易察覺
正確做法:撰寫「刪除關聯檔案」的清理邏輯時,務必實際印出該 pattern 展開後的檔名並與磁碟上的真實檔案核對一次,而非憑檔名規律推測


# 21
盲點:監控/備份腳本只顧「正常路徑」的清理,沒設 trap 保護異常中止時的殘留
問題說明:check_backup.sh 的 Full Restore Test 建立臨時測試 DB 後,僅在程式碼「順利跑到結尾」時才會清除;
若腳本中途被訊號中止或非預期出錯,會直接略過清理、留下殘留資料庫
正確做法:任何會建立臨時資源(暫存檔、臨時資料庫、鎖定檔)的腳本,都應該用全域變數搭配 trap ... EXIT 確保無論正常結束、腳本錯誤或收到訊號都會清理


# 22
盲點:把每週一次的 Full Restore Test 與其他固定排在同一時間點的每日任務排在完全相同的時間
問題說明:Restore Test 依備份檔大小動態計算逾時,最長可達 60 分鐘,與另一個任務同時起跑容易互搶資源
正確做法:用該任務「最長可能執行時間」而非「平常通常多快」來估算安全間隔(9.3 節排程時間總覽)


# 23
盲點:/etc/cron.d/forum-backup 沿用一般 cron.d 檔案慣例的 644 權限
問題說明:這份檔案內含IPS論壇任務的明碼驗證金鑰,644 代表主機上任何有 shell 帳號的使用者都能直接讀到金鑰;cron daemon 以 root 身份讀取該檔案,並不需要 other 可讀 
正確做法:改設 640(見 9.3 節),並避免把明碼金鑰直接提交進 Git 版控


# 24
盲點:還原 SOP 中,批次 find ... -exec chmod 644 蓋掉了個別檔案原本該有的較嚴格權限
問題說明:conf_global.php 明碼存放資料庫密碼與 Cookie 加密金鑰,理應維持 640;但還原流程為求方便對整個網站目錄批次執行 chmod 644,若這一步排在整個流程最後,
會把 conf_global.php 連帶打回 644(other 可讀),且不會有任何錯誤訊息提示這件事
正確做法:批次權限復原之後,務必再單獨對機敏設定檔收緊權限(見 11.2 節步驟七),並在驗收時實際 stat 確認,不要只憑「流程跑完沒報錯」就假設權限正確






--------------------------------------------------------------------------
第 16 章:附錄
--------------------------------------------------------------------------

16.1 Exit Code 矩陣(backup.sh)

Exit Code:0
含義:全部成功
處理建議:正常,無需處理


Exit Code:1
含義:一般錯誤(lock 無法取得、BACKUP_HOME 設定錯誤等)
處理建議:檢查腳本設定


Exit Code:10
含義:資料庫備份失敗(DB fail)
處理建議:立即確認 MariaDB 狀態與帳號權限


Exit Code:20
含義:網站備份失敗(Web fail)
處理建議:確認磁碟空間與 tar 執行權限


Exit Code:30
含義:SHA256/512 校驗失敗(SHA fail)
處理建議:備份可能損壞,勿用於還原


Exit Code:40
含義:Restore Test 失敗(Restore fail)
處理建議:立即手動驗證備份可還原性


Exit Code:50
含義:資料庫健康檢查失敗(Health fail)
處理建議:執行 REPAIR TABLE,檢查 InnoDB 狀態



check_backup.sh 的 Exit Code 對照請見 8.6 節。






16.2 backup_latest.json 欄位說明

backup.sh 每次備份成功後,會在備份目錄產生兩份 JSON 元資料:

backup_latest.json(永遠指向最新一次備份,還原時最常用)

backup_<時間戳>.json(歷史版本,對應各次備份)



{
  "uuid":            "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "timestamp":       "20260701_031501",
  "hostname":        "forum01",
  "kernel":          "6.12.15",
  "debian_version":  "trixie",
  "php_version":     "8.3.21",
  "mariadb_version": "11.8.2-MariaDB",
  "files": {
    "db":  { "name": "Db_forum_20260701_031501.sql.gz", "size": "18 MB", "bytes": 18874368, "sha256": "..." },
    "web": { "name": "Web_域名_com_20260701_031501.tar.gz",       "size": "210 MB", "bytes": 220200960, "sha256": "..." },
    "manifest": { "name": "manifest_20260701_031501.txt" }
  },
  "db_raw_size":  "180 MB",
  "restore_test": "PASS",
  "gfs_policy":   { "keep_daily": "7", "keep_weekly": "4", "keep_monthly": "3" },
  "filesystem":   { "uuid": "...", "type": "ext4", "source": "/dev/vda1", "mount_options": "rw,relatime" },
  "mariadb_config": {
    "innodb_page_size": "16384",
    "innodb_file_per_table": "ON",
    "innodb_buffer_pool_size": "536870912",
    "character_set_server": "utf8mb4",
    "collation_server": "utf8mb4_unicode_ci",
    "sql_mode": "..."
  }
}



還原前快速查閱:

cat /var/www/backup/backup_latest.json | jq .


jq '.php_version, .mariadb_version' /var/www/backup/backup_latest.json


jq '.files.db.size, .files.web.size, .db_raw_size' /var/www/backup/backup_latest.json


jq '.restore_test' /var/www/backup/backup_latest.json






16.3 完整檔案清單

檔案:backup.sh
部署路徑:/usr/local/sbin/backup.sh
建議權限:700 root:root


檔案:check_backup.sh
部署路徑:/usr/local/sbin/check_backup.sh
建議權限:750 root:root


檔案:rclone_backup.sh
部署路徑:/usr/local/sbin/rclone_backup.sh
建議權限:750 root:root


檔案:run_with_alert.sh
部署路徑:/usr/local/sbin/run_with_alert.sh
建議權限:750 root:root


檔案:/etc/cron.d/forum-backup
部署路徑:統一排程檔
建議權限:640 root:root(內含明碼金鑰,見 9.3 節)


檔案:/etc/logrotate.d/forum-backup
部署路徑:備份日誌輪替
建議權限:644 root:root


檔案:/etc/logrotate.d/ips-task
部署路徑:IPS論壇任務日誌輪替
建議權限:644 root:root


檔案:/root/.my_backup.cnf
部署路徑:資料庫認證檔
建議權限:600 root:root






16.4 簡化版建議(僅適用於低風險測試環境)

若這是純測試站台、資料遺失可以接受,且想要一個最精簡的版本,

可以只保留:backup.sh 的資料庫 dump + tar 打包 + gzip 壓縮 + flock 鎖定;

拿掉 Restore Test、SHA512、manifest.txt、SMART 檢查、備份大小趨勢統計。



但仍強烈建議保留:flock(避免 cron 重疊執行)、set -Eeuo pipefail、

SHA256 校驗(成本極低但能防止「備份檔案已損壞卻不自知」這個最常見的災難)。

Restore Test 可以拿掉,但完全不做的話,等於是在對一份「從未驗證過能否還原」的備份賭運氣——正式站台請務必評估後再決定是否精簡。



排程層面,即使精簡腳本本身,/etc/cron.d/forum-backup(第 9 章)與 logrotate(9.1 節)仍建議保留完整設定,

這兩者的維運成本很低,卻能避免「備份腳本能跑,但排程本身悄悄失效」或「日誌把磁碟塞滿」這類次生問題。






16.5 已知限制與定期演練建議

Health Score 是總覽指標,不是免責聲明,請勿只看整體分數。


Full Restore Test 只驗證「能否匯入且通過 CHECK TABLE」,不驗證應用層邏輯完整性(例如論壇特定資料表之間的關聯是否正確)。

建議每季安排一次人工完整還原演練。


系統時間可信度是多項判斷的共同前提:若 timedatectl 回報同步異常,備份年齡(RPO)、GFS 輪替日期判斷等仍可能同時失準,

此時應優先排除時間同步問題,再重新檢視其他檢查結果。


GFS Weekly/Monthly 驗證依賴檔案 mtime:若手動搬移、以 rsync -a 之外的方式複製,或以非 backup.sh 產生的方式建立備份檔,其 mtime 可能與實際備份時間不符。

搬移備份檔時請使用 cp -p / rsync -a(保留時間戳記),避免用 cp(不加 -p)或某些 GUI 檔案管理員的複製功能。






建議定期進行的驗證演練:

每季安排一次「故意破壞」演練——暫停 MariaDB(systemctl stop mariadb)、手動截斷一份備份檔(truncate -s 1K 某份 .sql.gz)、

填滿磁碟至 90% 以上——實際觀察 check_backup.sh 是否確實回報對應的 FAIL/WARN,

而不是等到真正的災難發生才第一次驗證監控腳本本身是否可靠。


告警不應只看 Health Score 這一個數字:請將 backup-status.json 接入監控告警系統(Prometheus Alertmanager/Grafana),

並針對 restore_result、file_exist、sha256、sql_header 這幾項關鍵檢查設定獨立告警規則。


RESTORE_TEST_FULL=1 執行期間請避開流量高峰:在 2 vCPU / 2GB RAM 規格下,Full Restore Test 仍會與正式流量競爭有限的 CPU 與記憶體資源,

建議固定排在深夜離峰時段。






--------------------------------------------------------------------------
結語
--------------------------------------------------------------------------

備份策略並不只是「每天跑一個腳本」。

完整的備份方案需要:自動執行 → 多層驗證 → 元資料記錄 → 異地保存 → 定期還原測試 → 完整 SOP 文件,

缺少任何一環,都可能在最糟糕的時刻讓你措手不及。



更容易被忽略的是:當備份腳本與監控腳本分成兩份文件、各自迭代時,

兩者之間的命名約定、檔案路徑、資料庫授權範圍很容易在演進過程中悄悄脫節——這正是本篇整合稽核中實際發現並修正的問題,也提醒我們:

備份系統本身的一致性,和備份內容的完整性同樣重要。



若你的環境與本篇預設不同(例如仍使用 IPS 4.x、或 BACKUP_HOME 不在同一顆磁碟),

請對照腳本內對應章節的註解說明調整變數,多數差異點腳本內都已用註解明確標示「若你的情況是 X,請改回 Y」。

本帖最后于,由Jack编辑

参与讨论

你可立刻发布并稍后注册。 如果你有帐户,立刻登录发布帖子。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
回帖…

帐户

导航

搜索

搜索

配置浏览器推送通知

Chrome (安卓)
  1. 轻敲地址栏旁的锁形图标。
  2. 轻敲权限 → 通知。
  3. 调整你的偏好。
Chrome (台式电脑)
  1. 点击地址栏中的挂锁图标。
  2. 选择网站设置。
  3. 找到通知选项并调整你的偏好。