
從 Day 19 到 Day 25 的建置過程中,每一篇都少不了一句「寫進變更單」:
pve-firewall.md 都明確寫著:「這三個指令都寫進 Day 26 變更單的回退欄」。變更單被不斷引用,但這套機制此前卻一直只存在於口頭與註解中。
今天正式把它實作出來。一個合格的生產環境變更管理,必須回答內部控制與 ITGC(IT 一般控制)的四個核心問題:誰提的?誰核准的?什麼時候執行的?怎麼驗證做對了?
本篇的目標不談抽象的 ITIL 流程圖,而是落實到真實的系統與可觀測性管線:
相關程式碼已開源於 onprem-changes v0.1.0,告警規則與儀表板正本則位於 onprem-metrics v0.3.1。

在我的架構中,一張變更單就是一個獨立檔案:changes/CR-2026-0001-qdevice-debian-13.md。檔頭以 YAML Front Matter 供自動化腳本解析,內文則以六個固定 Markdown 章節供工程師閱讀與稽核。

每一段對應一個關鍵工程問題,任何一段留空,CI 中的 check.py 就會報錯退件:
risk)強制升級為 high。assets:指向 Day 25 的 eol/assets.tsv,格式為 資產/元件。check.py --assets 會嚴格校驗該資產是否存在。變更完成後,資產清單的 commit 訊息會帶上變更單號(例如 eol/assets.tsv: qdevice is on Debian 13.7 (CR-2026-0001))。無論從資產查變更,還是從變更查資產,都能雙向追溯。silence:本次變更預期會被觸發的告警規則。QDevice 重新開機期間,Day 20 設定的 PveQdeviceAbsent 可能觸發。單子事先聲明此規則,執行時腳本會自動向 Alertmanager 註冊有效期限至 window_end 的 Silence。未寫在單子上的異常告警照常響起,這才是監控的真諦。approval_exception(一人維運的真實誠實原則):ITGC 要求「提出、核准、執行」職責分離(SoD)。但在單人維運場域中,偽造另一個核准帳號是自欺欺人。我的規則是:若核准者與執行者相同,必須顯式填寫此欄位說明補償控制(例如:PR 自審且強制 24 小時冷卻期)。
實測插曲:本次 CR-2026-0001 於 18:14 開 PR、22:15 合併(冷卻約 4 小時),因為當晚需連同腳本進行場域端實測。我如實將「冷卻未滿 24 小時」寫入
approval_exception。誠實記錄例外,遠比假裝一切合規更有工程價值。
enforce_admins,由專案擁有者以管理員身分略過限制合併。在 Push 到 main 分支時,GitHub 會在 Audit Log 印出顯著的 Bypassed rule violations for refs/heads/main 標記。若未來團隊擴充至多人,只要清空 approval_exception 並啟用 enforce_admins,即無縫轉換為嚴格的雙人同儕審查機制。變更的推進完全對應至 Git Commit 歷程:
狀態 (status) |
操作者 | check.py 驗證規範 |
|---|---|---|
draft |
提案者發起 PR | 六大章節齊全、回退步驟不為空 |
approved |
同儕 Review 或作者例外處理並合併 | approver 與 implementer 異人,或具備有效的 approval_exception |
scheduled |
執行者確定排程 | 填寫 window_start、window_end(RFC 3339 帶時區),結束時間必須大於開始時間 |
in_progress |
執行者啟動變更 | 執行 change-mark.sh begin 後推送 |
done / rolled_back |
執行者結案 | 紀錄段的 begin、end、result 完整填寫 |
cancelled |
提案者 / 執行者取消 | 必須填寫取消原因 |
執行 git log --follow changes/CR-2026-0001-*.md,便能得到這張變更單分秒不差、無法後製竄改的時間線。

實現操作的驗證:本次實測依序歷經
draft(18:11)$\rightarrow$approved(18:14 於分支,22:15 合併)$\rightarrow$scheduled(22:37)$\rightarrow$done(22:55)。
中間漏掉了一步:在begin之後忘記 Commitin_progress狀態。這個疏忽導致後續第五節的 Prometheus 指標ChangeInProgress一次都沒觸發。這證明了自動化管線的忠實性——Textfile 收集器只認 Git Repo 的狀態,人若偷懶沒 Commit,監控系統便不會平白編造。
change-mark.sh變更執行當下,維運人員最常犯的錯誤是:事後憑記憶補填變更時間。我透過收集端的 change-mark.sh,在鍵入 begin 與 end 的當下,將狀態同步廣播至三處:
sudo -u metrics /opt/onprem-changes/scripts/change-mark.sh begin CR-2026-0001
# ... 執行維護步驟 ...
sudo -u metrics /opt/onprem-changes/scripts/change-mark.sh end CR-2026-0001
| 廣播通道 | begin 動作 |
end 動作 |
查核位置與效果 |
|---|---|---|---|
| Systemd Journal | logger -t change -p notice "CR-2026-0001 begin <title>" |
同一行格式,標註 end |
透過 Promtail 送入 VictoriaLogs(Day 21 架構) |
| Grafana Annotation | 呼叫 HTTP API POST /api/annotations,打上 change、CR-2026-0001 標籤 |
PATCH 補上 timeEnd 結束時間戳記 |
在 Day 20 叢集儀表板上渲染出一條半透明的維護灰色區間 |
| Alertmanager Silence | 依變更單內的 silence 欄位建立規則,過期時間為 window_end |
執行 amtool silence expire 提早結束靜音 |
透過 amtool silence query 隨時檢視,註解包含變更單號 |
腳本執行時會將 Grafana 產生的 Annotation ID 與 Alertmanager 的 Silence ID 保存在 /var/lib/onprem-changes/CR-2026-0001.state,以便 end 時精準關閉。
grafana: annotation 2
alertmanager: silence 751e35fd-322b-4914-a1bf-d80121872d46 until 2026-10-10T23:45:00+08:00
change-mark: CR-2026-0001 begin recorded in the journal (tag change)
Access denied to save the annotation。在沒有 Enterprise RBAC 的環境下,只能給予 Editor 角色,並嚴格控管 Token 僅限變更腳本使用。amtool(Day 25 是透過 docker exec 呼叫),但 metrics 系統帳號無 Docker 權限。我直接從容器提取同版本(0.28.1)的二進位檔置於 /usr/local/bin。logger,意外在 Journal 灌入 8 筆測試用開始紀錄,並被 Promtail 送進 VictoriaLogs 生產日誌庫!隨後在 v0.1.0 重構時,全面導入假 Logger 環境變數(LOGGER),防止測試日誌玷污稽核軌跡。
在 VictoriaLogs 中查詢 CR-2026-0001:
SYSLOG_IDENTIFIER:change,精確顯示 4 行:PVE 節點與收集端各自記錄的 begin(22:45:04 / 22:45:06)與 end(22:55:07)。sudo 稽核日誌,記錄了誰在何時提權執行 change-mark.sh。CR-2026-0001-before 的建立。這印證了一項最佳實踐:所有快照、備份任務與暫時性資源,一律帶上變更單號命名。維護事件與系統動作自然在日誌庫中聚合為一條連貫的故事線。
這台在 Day 25 觸發 AssetEolPassed 告警長達 90 天的 QDevice VM,僅運行 corosync-qnetd,作為雙節點 PVE 叢集的第三票仲裁。
完整單據見 CR-2026-0001,以下記錄關鍵決策與實測數據:
pvecm qdevice setup。因此升級前直接在 NAS 建立快照 CR-2026-0001-before,出錯時可在 1 分鐘內就地還原且憑證不變。check.sh 回傳 0、pvecm status 仲裁票數維持 1 票、VictoriaLogs 存在完整標記,最後更新 Day 25 的 assets.tsv 清除 EOL 告警。升級遵循 Debian 13 官方發行指南,將 APT 來源由 bookworm 置換為 trixie,並使用 systemd-run 執行更新,確保即使 SSH 連線中斷,升級行程也不會中途腰斬。

實測數據分析:
apt upgrade --without-new-pkgs 與 apt full-upgrade)歷時約 6 分半鐘。systemctl 一度回報 libcrypto.so.3: cannot open shared object file,此為 Debian 13 進行 64-bit time_t 遷移(libssl3 替換為 libssl3t64)的短暫幾秒現象,隨後平穩完成。check.sh 於 22:54:07 探測到離線(回傳 1),22:54:36 即恢復正常(回傳 0)——實際服務中斷僅約 30 秒!apt modernize-sources,成功將傳統 sources.list 無痛遷移為標準 deb822 格式(sources.list.d/debian.sources)。由於重開機僅離線 30 秒,並未達到 Prometheus 規則 PveQdeviceAbsent 所設定的持續 2 分鐘門檻(for: 2m)。因此,Alertmanager 雖然依變更單掛載了 Silence,但最終顯示 No affected alerts。

Silence 沒被觸發算是白做工嗎?
絕非如此。事前設定 Silence 是為維護期間超出預期的延遲買保險,避免假警報干擾值班人員。
在準備進入維護窗前,我發現了一個嚴重疏漏:這台最小化安裝的 VM 既沒有安裝 sudo,歷史紀錄中也遺失了 root 密碼,而 SSH 早在 Day 11 就被加固為只允許公鑰登入且禁止 root 登入。
為了推進升級,維運人員於 22:31 從 NAS 主控台透過 GRUB init=/bin/bash 暴力重設 root 密碼,導致 VM 在排程維護窗開始前重開了兩次。

看這張 Grafana 儀表板:
PveQdeviceAbsent 於 22:34:50 至 22:36:35 處於真實 Firing 狀態!這張圖完美展現了變更治理的威力:任何未經申報、沒有 Silence 防護、不在維護窗內的改動,在可觀測性系統下一覽無遺。依照規定,我於 24 小時內補開了一張緊急變更單(type: emergency):CR-2026-0006。
為了讓 Prometheus 也能感知變更狀態,我撰寫了 changes-textfile.py,由排程每 15 分鐘拉取 Git Repo 並產生 changes.prom:
change_record{id="CR-2026-0001", status="done", type="standard", risk="medium", implementer="ivan", approver="ivan"} 1
change_window_start_timestamp{id="CR-2026-0001"} 1791643500
change_window_end_timestamp{id="CR-2026-0001"} 1791647100
搭配四條精確的 Prometheus 警報規則(正本位於 prometheus/rules/changes.yml):
| 告警名稱 | 觸發條件與等級 | 設計意圖 |
|---|---|---|
ChangeWindowOverrun |
關窗逾 60 分鐘仍為 scheduled 或 in_progress (Warning) |
抓出「忘記結案」或「變更嚴重逾時」,逼維護團隊面對超時問題 |
ChangeInProgress |
單子處於 in_progress (Info) |
併入每日營運日報,與當日告警清單相互對照 |
ChangeStartsSoon |
維護窗將於 24 小時內啟動 (Info) | 預先提醒維運團隊即將迎來變更窗 |
ChangesTextfileStale |
Textfile 超過 1 小時未更新 (Warning) | 捕捉 Cron 排程靜默失效,同時 Inhibit 前面三條告警 |

在部署本組規則時,我順手揪出了一個從 Day 25 潛伏至今的隱蔽 Bug:
Alertmanager 的 alertmanager.yml 是透過 Docker 單檔 Bind Mount 掛載進入容器。Day 25 我直接覆寫修改了該設定檔(產生了新的 Inode),但 Docker 單檔掛載認的是建立容器時的 Inode!
這導致容器內部讀取的永遠是舊 Inode 的過期檔案,即使對 Alertmanager 發送 SIGHUP 重載信號,它也只是重新讀取舊檔案。這意味著 Day 25 設定的 EolTextfileStale Inhibit 規則在此之前從未真正生效!
解法:重啟 Alertmanager 容器重新綁定 Inode。後續版本將全數改為「掛載目錄」而非掛載單一檔案,以徹底規避此機制陷阱。
引進健全體系的第一天,必須先面對過去欠下的技術債。將前幾天文章中執行的重要改動,全面補發變更單並鎖定為 done 狀態:
| 單號 | 變更主旨 | 歷史證據鏈追溯點 |
|---|---|---|
| CR-2026-0002 | Day 22:FortiGate 60F 啟用 Syslogd2、Policy 4 開啟記錄 | Git 設定備份 Diff,VictoriaLogs 首次收到 60F 日誌(11:37:57) |
| CR-2026-0003 | Day 23:兩台 PVE 節點部署 pcap@,收集端啟用擷取 |
Journal 記錄 Started pcap@corosync(01:43:24),WORM 首批檔案(08:35) |
| CR-2026-0004 | Day 24:掛載 AUP Profile、啟用 UTM Status | Git 設定備份 Diff,VictoriaLogs 首筆 Webfilter 日誌(00:14:46) |
| CR-2026-0005 | Day 25:接受 20 項授權過期風險,設定長期 Silence | TSV 備註,amtool silence query 歷史查詢 |
| CR-2026-0006 | Day 26:主控台重設 QDevice root 密碼(Emergency) | PveQdeviceAbsent 警報 firing 紀錄(22:34:50),VM last -x 重開機日誌 |
在補寫 CR-2026-0004 時,剛好也深刻反省 XD,因為 Day 24 的改動若遺漏了 error-allow 選項,整個內網的連外網頁將全數中斷。這種錯一個參數就全滅的變更,當時理應被標註為 risk: high 並具備嚴謹的維護窗與回退策略。補單不是形式主義,而是強迫我重新用風險思維審視過去的每一個決定或按下的enter。
在本次實踐後,為地端環境立下幾條變更治理鐵則:
git pull --ff-only),所有單據修改必須經由 Git PR。approval_exception 必須強制留空。sudo),必須提前包含在前置驗證或獨立變更中,避免窗外的臨時搶修。git clone https://github.com/ivanusto/onprem-changes && cd onprem-changes
cp changes/TEMPLATE.md changes/CR-2026-0007-<slug>.md
$EDITOR changes/CR-2026-0007-<slug>.md
# 本地驗證(校驗語法、格式與資產清單引用)
python3 scripts/check.py --assets ../onprem-metrics/eol/assets.tsv
python3 scripts/render.py
# 發起 PR
git checkout -b CR-2026-0007
git add -A && git commit -m "CR-2026-0007: <title> (draft)"
git push -u origin HEAD
sudo install -d -o metrics -g metrics /opt/onprem-changes /var/lib/onprem-changes
sudo -u metrics git clone https://github.com/ivanusto/onprem-changes /opt/onprem-changes
# 配置具備 Editor 角色的 Grafana Token
sudo install -d -m 0755 /etc/onprem-changes
sudo install -m 0600 -o metrics grafana.token /etc/onprem-changes/grafana.token
# 安裝 Cron 排程與確認 amtool 存在於本機
sudo install -m 0644 /opt/onprem-changes/collector.cron /etc/cron.d/onprem-changes
command -v amtool || echo "amtool 必須安裝於宿主機 /usr/local/bin"
# 1. 宣告變更開始(自動寫入日誌、打 Grafana 註記、開啟 Silence)
sudo -u metrics /opt/onprem-changes/scripts/change-mark.sh begin CR-2026-0007
# (至 Repo 將狀態改為 in_progress 並 commit)
# 2. 依變更步驟執行生產作業...
# 3. 宣告變更結束(自動結束註記、關閉 Silence)
sudo -u metrics /opt/onprem-changes/scripts/change-mark.sh end CR-2026-0007
# (至 Repo 填寫「紀錄」章節、狀態改為 done 並 commit)
今天把「變更管理」從企業管理口號,實體化為工程師熟悉的 GitOps 工作流、日誌標記與可觀測性閉環。每一次進入機房或敲下 sudo,不再是隨性而為的探險,而是一次受到監控防護網保護、進退有據的標準作業。
然而,今天的單子記錄的是「人自稱做了什麼」。
在零信任與合規稽核的世界裡,稽核員更相信「機器記錄你做了什麼」。
明天 Day 27,將探討稽核軌跡(Audit Trail):
整合 FortiGate 的設定變更事件(type="event")、PVE 虛擬化平台與 QNAP NAS 的管理動作日誌,以及 Linux 主機的 sudo 提權審計,全數接入 VictoriaLogs 日誌管線,並與今天的變更單自動比對——任何沒單子的敲擊,都將無所遁形。
apt modernize-sources
pvecm qdevice setup 與 remove
POST /api/annotations、PATCH /api/annotations/:id、service account tokensilence add --end、silence expire
Day 1|系列規劃與場域總覽:從跑得起來到可長期維運,先談為什麼要封裝
Day 2|Container Station 薄殼架構:QPKG 骨架、生命週期腳本與狀態頁
Day 3|版本鎖定與供應鏈:image digest、SHA256SUMS 與授權檔
Day 4|GitHub Actions 自動打包與 release:從 git tag 到可驗證的套件
Day 5|案例 Jellyfin 與 Roon:硬體轉碼的裝置直通與主機網路
Day 6|案例 changedetection.io 與 Homepage:資安與資訊公告監看 + 整合入口
Day 7|在地自建 ComfyUI 的生存指南:自建 Image 鎖定、GPU 直通與三層目錄架構
Day 8|案例實戰:Open WebUI + Ollama 重構記,把專案回到通用框架會省下什麼呢?
Day 9|容量規劃:用模型與硬體搭配矩陣決定地端節點與儲存規格
Day 10|上游引擎一改版,地端 AI 環境用的配方壞了怎麼辦 ? patch 移植實戰紀錄
Day 11|DGX Spark 節點基線:DGX OS 初始設定、帳號與 SSH 加固
Day 12|熱與統一記憶體守護:取樣器、自動降載與長時間負載實測
Day 13|別讓節點踩爆模型!NFS 集中模型庫的單寫多讀、權限凍結與完整性治理
Day 14|儲存規劃與路徑實測:RAID 60 與 RAIDZ2 試算,SSD、NFS、iSCSI 載入時間解析
Day 15|Proxmox VE 雙節點 HA:Virtualization Station 上的 QDevice
Day 16|Proxmox VE 備份與還原:HDP for Business Beta 的排程、保留與還原演練
Day 17|儲存設備備份策略與還原演練:快照、HBS 3 與 3-2-1 備份策略
Day 18|3-2-1 的最後一步:異地物件儲存、保留政策與冷熱資料成本精算
Day 19|指標收集:把前十八天的取樣器接進同一個時間序列
Day 20|儀表板與告警,打造一個簡潔的單一面板
Day 21|日誌集中與保存:節點不裝新代理,封存要能比對
Day 22|被擋下的封包才是便宜情報:從防火牆靜默盲點到日誌降噪與自動化告警
Day 23|日誌找不到原因?從 tcpdump 最小權限封包擷取到 WORM 封存的網路排錯實戰
Day 24|稽核問「某台工作站上個月連到哪裡了」?FortiGate AUP 雙軌日誌實踐:線上 LogsQL 檢索與離線對帳修煉
Day 25|資產生命週期與授權 EOL 監看:到期日只是一個數字,通通收進 Prometheus 時間序列!
Day 26|變更管理實戰:串起 Git 變更單、監控與日誌,讓每次改動都有跡可循
Day 27|自動化稽核比對:當機器留下的日誌,對上工程師寫的變更單(VictoriaLogs × FortiGate × PVE)
Day 28|Rollback 實戰演練:沒真正執行過的復原步驟,永遠只是一場美麗的假設
Day 29|打造地端維運的 AI 圍欄:Agent 授權閘道實作,無工單即無工具,無核准即無不可逆變更
Day 30|完賽總結|算清一年的電費、硬體折舊與雲端代價:地端 AI 維運 30 天留下的真實資產