<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Config on Tarragon</title><link>https://tarrragon.github.io/blog/tags/config/</link><description>Recent content in Config on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 26 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/config/index.xml" rel="self" type="application/rss+xml"/><item><title>主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/</guid><description>&lt;p>這篇把 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/" data-link-title="服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序" data-link-desc="一台主機上的服務要暫停一段時間再恢復：只停服務、停止主機、終止主機保留設定、全部刪除靠 repo 重建各自保留什麼與付出什麼，怎麼依保留的月費、重建的成本與漂移量選擇，暫停期間仍在計費的資源（含在作業系統裡 shutdown 不停止計費），仍被 DNS 與 CDN 指向的結果（Cloudflare 521、522，釋放 IP 之後的懸空 DNS），恢復前要重新確認的漂移，以及依元件依賴排出的恢復順序與驗證">服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序&lt;/a> 要檢查的項目，寫成一份機器讀得懂、人也讀得懂的資料檔，並用一台實際的主機示範：為什麼要從文件改成資料檔；這份清單對檔案格式有哪些需求，以及 JSON、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML&lt;/a>、TOML 逐項比對的結果（含 YAML 與 TOML 隱式型別的逐行實測）；清單用到的 TOML 寫法（表格、表格陣列、巢狀的表格陣列、寫在同一行的表格、兩種字串與 regex 的跳脫）；TOML 1.0 與 1.1 的差異，以及解析器版本對不上時的兩種結果；欄位依讀者分成檢查腳本要讀的與人要讀的兩類，各自怎麼設計；最後是讀這份清單的只讀檢查腳本，它的白名單要擋下哪些寫法才能保證不改動受檢查的設定與服務、哪些寫入白名單管不到而要靠系統層限制、輸出與 exit code 怎麼讀、結果檔怎麼跟上一次比對，以及它第一次在真主機上跑出的結果。TOML 的語法速查在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML&lt;/a> 知識卡。示範用的清單（&lt;code>host.toml&lt;/code>）放在作者的私有專案裡，完整內容沒有公開，本篇摘錄其中的段落，網域、IP 與帳號相關的值在摘錄裡用佔位值；檢查腳本（&lt;code>host-check.py&lt;/code>）的完整原始碼與逐段說明在 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/host-check-script-source/" data-link-title="只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制" data-link-desc="要讀主機維護清單的只讀檢查腳本實際怎麼切分各工具的選項、怎麼驗證清單、怎麼判定 PASS、WARN 與 MANUAL、怎麼寫結果檔，或要照它的做法寫自己的檢查腳本時，查完整的 Python 原始碼與每一段這樣寫的理由">只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制&lt;/a>。同一份清單要套用到好幾台主機時，每台相同、依角色不同、每台不同的值怎麼分層，以及用 Ansible 的範本或 Terraform 的輸出產生每台清單的做法，在 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/multi-host-manifest/" data-link-title="多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍" data-link-desc="把單台的主機維護清單擴充到多台主機：每台相同、依角色不同、每台不同的值怎麼分層，合併放在部署時產生每台一份、由檢查腳本讀多份、依角色過濾還是執行時覆寫，用 Ansible 的 inventory、群組變數與範本產生 TOML 清單時的變數合併、兼任角色、型別、語法撞名、跳脫與產生時的驗證，由 Terraform 的輸出產生 inventory、terraform plan 能取代哪些主控台上的確認，對多台執行檢查腳本時 exit code 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍&lt;/a>。&lt;/p>
&lt;h2 id="從維護文件改成資料檔">從維護文件改成資料檔&lt;/h2>
&lt;p>維護的項目寫在文件裡時，執行的人每次讀一遍、挑出這次要做的、照著執行；文件跟實際的檢查之間沒有任何機制保證一致：文件說 SSH 只放行管理者 IP，而沒有任何一條指令在檢查這件事。把項目寫成資料檔，同一份內容有兩個讀者：維護的人照註解讀每一段，檢查腳本照欄位執行主機上量得到的項目，把主機外的項目（雲端主控台上的規則、從另一台機器才量得到的入口）印成待人工確認。主機上量得到的那些項目，清單寫了什麼，腳本就檢查什麼；主機外的項目，腳本只負責每次都把它們列出來，確認仍然靠人。新增一個檢查項目，等於同時更新了文件與檢查。&lt;/p>
&lt;p>示範清單涵蓋維護與暫停兩篇的主要項目：對外入口與每一層的防護、定期讀的 log 與它的計數門檻、會累積的資料與上限、檔案系統的剩餘空間、自動更新的設定，以及暫停與恢復上線的每一步；會到期的外部設定（憑證效期、CDN 位址段、管理者 IP）分散在入口與恢復上線的段落裡，syslog 的檔案與暫存檔沒有收進這份清單。項目的種類多、每一種的欄位不同，而且大部分是同一種項目列很多筆：示範清單裡有五個入口、六份 log、六類累積的資料、十六個暫停與恢復的步驟。&lt;/p>
&lt;h2 id="主機維護清單對檔案格式的需求jsonyamltoml-的比對">主機維護清單對檔案格式的需求：JSON、YAML、TOML 的比對&lt;/h2>
&lt;p>從清單的用途推出它對格式的需求：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>可以寫註解&lt;/strong>：清單的讀者有一個是人，每一段要寫出它是什麼、欄位怎麼讀；註解要跟資料寫在同一份檔案裡，分開放就會分歧。&lt;/li>
&lt;li>&lt;strong>型別由寫法決定&lt;/strong>：門檻是數字、regex 與路徑是字串、開關是布林值；一個值被解析器讀成錯的型別時，檢查會照錯的值執行，而且不報錯。&lt;/li>
&lt;li>&lt;strong>同一種項目可以列很多筆&lt;/strong>：每一筆有自己的一組欄位。&lt;/li>
&lt;li>&lt;strong>主機上不必另裝套件就能解析&lt;/strong>：檢查腳本在主機上執行，主機上已經有的直譯器要讀得懂它。&lt;/li>
&lt;li>&lt;strong>巢狀不深&lt;/strong>：清單的結構是「一類項目 → 很多筆 → 每筆幾個欄位 → 少數幾筆子項目」。&lt;/li>
&lt;/ul>
&lt;p>拿這幾項比對三種常見的設定檔格式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>需求&lt;/th>
 &lt;th>JSON&lt;/th>
 &lt;th>YAML&lt;/th>
 &lt;th>TOML&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>註解&lt;/td>
 &lt;td>不支援&lt;/td>
 &lt;td>支援&lt;/td>
 &lt;td>支援&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>型別由寫法決定&lt;/td>
 &lt;td>是，規則嚴格&lt;/td>
 &lt;td>不加引號的值由解析器推測，YAML 1.1 與 1.2 的規則不同&lt;/td>
 &lt;td>是；不屬於任何型別的寫法解析失敗，看起來像字串、卻剛好合乎某個型別的寫法照那個型別讀&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同一種項目列很多筆&lt;/td>
 &lt;td>陣列&lt;/td>
 &lt;td>序列&lt;/td>
 &lt;td>表格陣列 &lt;code>[[名稱]]&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>主機上的 Python 解析&lt;/td>
 &lt;td>標準函式庫 &lt;code>json&lt;/code>&lt;/td>
 &lt;td>要另裝 PyYAML 這類第三方套件&lt;/td>
 &lt;td>3.11 起標準函式庫 &lt;code>tomllib&lt;/code>；3.10 以下要另裝 &lt;code>tomli&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>巢狀很深時&lt;/td>
 &lt;td>括號多但結構清楚&lt;/td>
 &lt;td>縮排表示結構，深的時候最簡潔&lt;/td>
 &lt;td>表頭要寫完整路徑，深的時候冗長&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>型別由寫法決定這一項，兩種格式的差別要實測才看得清楚。用 PyYAML 6.0.3（實作 YAML 1.1）的 &lt;code>safe_load&lt;/code> 解析下面這幾行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">country&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">no&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">mode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">010&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">port_map&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">22&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">22&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>得到的是 &lt;code>{'country': False, 'mode': 8, 'port_map': 1342, 'version': 1.1, True: True}&lt;/code>：&lt;code>no&lt;/code> 被讀成布林值、&lt;code>010&lt;/code> 被讀成八進位的 8、&lt;code>22:22&lt;/code> 被讀成六十進位的 1342、&lt;code>1.10&lt;/code> 被讀成浮點數 1.1，連鍵名 &lt;code>on&lt;/code> 都變成了布林值 &lt;code>True&lt;/code>。每一個值都解析成功，沒有任何錯誤訊息；&lt;code>no&lt;/code> 被讀成 &lt;code>false&lt;/code> 這一種，英文資料常稱為 Norway problem（挪威的國家代碼 &lt;code>NO&lt;/code> 變成布林值）。放回這份清單：&lt;code>firewall-cmd --query-service=https&lt;/code> 印出的是字串 &lt;code>yes&lt;/code>，清單用 YAML 寫成 &lt;code>expect_stdout: yes&lt;/code> 時，PyYAML 把值讀成布林值 &lt;code>True&lt;/code>，跟輸出的字串比對永遠不相等，這一項每次都報警告；&lt;code>billed_when_stopped: no&lt;/code> 也一樣被讀成 &lt;code>False&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>這篇把 <a href="/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</a> 與 <a href="/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/" data-link-title="服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序" data-link-desc="一台主機上的服務要暫停一段時間再恢復：只停服務、停止主機、終止主機保留設定、全部刪除靠 repo 重建各自保留什麼與付出什麼，怎麼依保留的月費、重建的成本與漂移量選擇，暫停期間仍在計費的資源（含在作業系統裡 shutdown 不停止計費），仍被 DNS 與 CDN 指向的結果（Cloudflare 521、522，釋放 IP 之後的懸空 DNS），恢復前要重新確認的漂移，以及依元件依賴排出的恢復順序與驗證">服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序</a> 要檢查的項目，寫成一份機器讀得懂、人也讀得懂的資料檔，並用一台實際的主機示範：為什麼要從文件改成資料檔；這份清單對檔案格式有哪些需求，以及 JSON、<a href="/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML</a>、TOML 逐項比對的結果（含 YAML 與 TOML 隱式型別的逐行實測）；清單用到的 TOML 寫法（表格、表格陣列、巢狀的表格陣列、寫在同一行的表格、兩種字串與 regex 的跳脫）；TOML 1.0 與 1.1 的差異，以及解析器版本對不上時的兩種結果；欄位依讀者分成檢查腳本要讀的與人要讀的兩類，各自怎麼設計；最後是讀這份清單的只讀檢查腳本，它的白名單要擋下哪些寫法才能保證不改動受檢查的設定與服務、哪些寫入白名單管不到而要靠系統層限制、輸出與 exit code 怎麼讀、結果檔怎麼跟上一次比對，以及它第一次在真主機上跑出的結果。TOML 的語法速查在 <a href="/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML</a> 知識卡。示範用的清單（<code>host.toml</code>）放在作者的私有專案裡，完整內容沒有公開，本篇摘錄其中的段落，網域、IP 與帳號相關的值在摘錄裡用佔位值；檢查腳本（<code>host-check.py</code>）的完整原始碼與逐段說明在 <a href="/blog/devops/operations/09-host-lifecycle/hands-on/host-check-script-source/" data-link-title="只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制" data-link-desc="要讀主機維護清單的只讀檢查腳本實際怎麼切分各工具的選項、怎麼驗證清單、怎麼判定 PASS、WARN 與 MANUAL、怎麼寫結果檔，或要照它的做法寫自己的檢查腳本時，查完整的 Python 原始碼與每一段這樣寫的理由">只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制</a>。同一份清單要套用到好幾台主機時，每台相同、依角色不同、每台不同的值怎麼分層，以及用 Ansible 的範本或 Terraform 的輸出產生每台清單的做法，在 <a href="/blog/devops/operations/09-host-lifecycle/multi-host-manifest/" data-link-title="多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍" data-link-desc="把單台的主機維護清單擴充到多台主機：每台相同、依角色不同、每台不同的值怎麼分層，合併放在部署時產生每台一份、由檢查腳本讀多份、依角色過濾還是執行時覆寫，用 Ansible 的 inventory、群組變數與範本產生 TOML 清單時的變數合併、兼任角色、型別、語法撞名、跳脫與產生時的驗證，由 Terraform 的輸出產生 inventory、terraform plan 能取代哪些主控台上的確認，對多台執行檢查腳本時 exit code 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍</a>。</p>
<h2 id="從維護文件改成資料檔">從維護文件改成資料檔</h2>
<p>維護的項目寫在文件裡時，執行的人每次讀一遍、挑出這次要做的、照著執行；文件跟實際的檢查之間沒有任何機制保證一致：文件說 SSH 只放行管理者 IP，而沒有任何一條指令在檢查這件事。把項目寫成資料檔，同一份內容有兩個讀者：維護的人照註解讀每一段，檢查腳本照欄位執行主機上量得到的項目，把主機外的項目（雲端主控台上的規則、從另一台機器才量得到的入口）印成待人工確認。主機上量得到的那些項目，清單寫了什麼，腳本就檢查什麼；主機外的項目，腳本只負責每次都把它們列出來，確認仍然靠人。新增一個檢查項目，等於同時更新了文件與檢查。</p>
<p>示範清單涵蓋維護與暫停兩篇的主要項目：對外入口與每一層的防護、定期讀的 log 與它的計數門檻、會累積的資料與上限、檔案系統的剩餘空間、自動更新的設定，以及暫停與恢復上線的每一步；會到期的外部設定（憑證效期、CDN 位址段、管理者 IP）分散在入口與恢復上線的段落裡，syslog 的檔案與暫存檔沒有收進這份清單。項目的種類多、每一種的欄位不同，而且大部分是同一種項目列很多筆：示範清單裡有五個入口、六份 log、六類累積的資料、十六個暫停與恢復的步驟。</p>
<h2 id="主機維護清單對檔案格式的需求jsonyamltoml-的比對">主機維護清單對檔案格式的需求：JSON、YAML、TOML 的比對</h2>
<p>從清單的用途推出它對格式的需求：</p>
<ul>
<li><strong>可以寫註解</strong>：清單的讀者有一個是人，每一段要寫出它是什麼、欄位怎麼讀；註解要跟資料寫在同一份檔案裡，分開放就會分歧。</li>
<li><strong>型別由寫法決定</strong>：門檻是數字、regex 與路徑是字串、開關是布林值；一個值被解析器讀成錯的型別時，檢查會照錯的值執行，而且不報錯。</li>
<li><strong>同一種項目可以列很多筆</strong>：每一筆有自己的一組欄位。</li>
<li><strong>主機上不必另裝套件就能解析</strong>：檢查腳本在主機上執行，主機上已經有的直譯器要讀得懂它。</li>
<li><strong>巢狀不深</strong>：清單的結構是「一類項目 → 很多筆 → 每筆幾個欄位 → 少數幾筆子項目」。</li>
</ul>
<p>拿這幾項比對三種常見的設定檔格式：</p>
<table>
  <thead>
      <tr>
          <th>需求</th>
          <th>JSON</th>
          <th>YAML</th>
          <th>TOML</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>註解</td>
          <td>不支援</td>
          <td>支援</td>
          <td>支援</td>
      </tr>
      <tr>
          <td>型別由寫法決定</td>
          <td>是，規則嚴格</td>
          <td>不加引號的值由解析器推測，YAML 1.1 與 1.2 的規則不同</td>
          <td>是；不屬於任何型別的寫法解析失敗，看起來像字串、卻剛好合乎某個型別的寫法照那個型別讀</td>
      </tr>
      <tr>
          <td>同一種項目列很多筆</td>
          <td>陣列</td>
          <td>序列</td>
          <td>表格陣列 <code>[[名稱]]</code></td>
      </tr>
      <tr>
          <td>主機上的 Python 解析</td>
          <td>標準函式庫 <code>json</code></td>
          <td>要另裝 PyYAML 這類第三方套件</td>
          <td>3.11 起標準函式庫 <code>tomllib</code>；3.10 以下要另裝 <code>tomli</code></td>
      </tr>
      <tr>
          <td>巢狀很深時</td>
          <td>括號多但結構清楚</td>
          <td>縮排表示結構，深的時候最簡潔</td>
          <td>表頭要寫完整路徑，深的時候冗長</td>
      </tr>
  </tbody>
</table>
<p>型別由寫法決定這一項，兩種格式的差別要實測才看得清楚。用 PyYAML 6.0.3（實作 YAML 1.1）的 <code>safe_load</code> 解析下面這幾行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">country</span><span class="p">:</span><span class="w"> </span><span class="kc">no</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="m">010</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="nt">port_map</span><span class="p">:</span><span class="w"> </span><span class="m">22</span><span class="p">:</span><span class="m">22</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1.10</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div><p>得到的是 <code>{'country': False, 'mode': 8, 'port_map': 1342, 'version': 1.1, True: True}</code>：<code>no</code> 被讀成布林值、<code>010</code> 被讀成八進位的 8、<code>22:22</code> 被讀成六十進位的 1342、<code>1.10</code> 被讀成浮點數 1.1，連鍵名 <code>on</code> 都變成了布林值 <code>True</code>。每一個值都解析成功，沒有任何錯誤訊息；<code>no</code> 被讀成 <code>false</code> 這一種，英文資料常稱為 Norway problem（挪威的國家代碼 <code>NO</code> 變成布林值）。放回這份清單：<code>firewall-cmd --query-service=https</code> 印出的是字串 <code>yes</code>，清單用 YAML 寫成 <code>expect_stdout: yes</code> 時，PyYAML 把值讀成布林值 <code>True</code>，跟輸出的字串比對永遠不相等，這一項每次都報警告；<code>billed_when_stopped: no</code> 也一樣被讀成 <code>False</code>。</p>
<p>同樣五個值寫成 TOML、不加引號，逐行各自交給 Python 的 <code>tomllib</code> 解析，結果是：兩個版本都報錯（<code>no</code>、<code>010</code>）、只有 TOML 1.1 的解析器接受並讀成另一個型別（<code>22:22</code>）、兩個版本都照合法的型別讀（<code>1.10</code> 讀成浮點數、<code>on</code> 是普通的鍵名）。表格的「同左」代表 3.15 的結果跟 3.12 相同：</p>
<table>
  <thead>
      <tr>
          <th>TOML 這一行</th>
          <th>Python 3.12（TOML 1.0）</th>
          <th>Python 3.15（TOML 1.1）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>country = no</code></td>
          <td><code>TOMLDecodeError: Invalid value</code></td>
          <td>同左</td>
      </tr>
      <tr>
          <td><code>mode = 010</code></td>
          <td><code>TOMLDecodeError: Expected newline or end of document after a statement</code>（整數不允許前導零，解析器讀完 <code>0</code> 就結束這個值）</td>
          <td>同左</td>
      </tr>
      <tr>
          <td><code>port_map = 22:22</code></td>
          <td><code>TOMLDecodeError</code></td>
          <td><code>datetime.time(22, 22)</code></td>
      </tr>
      <tr>
          <td><code>version = 1.10</code></td>
          <td>浮點數 <code>1.1</code></td>
          <td>同左</td>
      </tr>
      <tr>
          <td><code>on = true</code></td>
          <td><code>{'on': True}</code>（<code>on</code> 是普通的鍵名）</td>
          <td>同左</td>
      </tr>
  </tbody>
</table>
<p>TOML 擋下的是不屬於任何型別的寫法（<code>no</code>、<code>010</code>），屬於另一個合法型別的寫法它照樣接受：<code>1.10</code> 是合法的浮點數，所以被讀成 1.1；<code>22:22</code> 在 TOML 1.1 是合法的時間（1.1 允許省略秒），所以 1.1 的解析器把它讀成時間 22:22。差別在範圍：YAML 1.1 的解析器會替幾乎任何不加引號的值找一個型別，TOML 只在寫法剛好合乎某個型別時才這樣做，其餘的拒絕整份檔案。由實測得出這份清單的寫法：版本號、時間字串、連接埠對應這類「看起來像數字或時間、內容是字串」的值，在兩種格式裡都一律加引號，不依賴解析器拒絕它。</p>
<p>YAML 1.2 把布林值限定成 <code>true</code> 與 <code>false</code> 及其大小寫變體，部分隱式型別的問題因此消失，但一份 YAML 檔被讀成哪個版本的規則，由讀它的解析器決定，寫檔的人控制不了。</p>
<p>由比對的結果，這份清單選 TOML：它滿足全部需求，付出的代價是巢狀的子項目要寫完整的表頭（<code>[[entrypoint.verify]]</code>），以及 Python 的標準函式庫只能讀、不能寫，要由程式產生 TOML 時得另裝套件。這個選擇有兩個前提：主機上的 Python 是 3.11 以上，以及清單巢狀不深、由人手動維護。RHEL 9、Debian 11、Ubuntu 22.04、Amazon Linux 2023 內建的 Python 更舊，在那些主機上 TOML 一樣要另裝 <code>tomli</code>：主機上可以裝套件，或可以把 <code>tomli</code> 這個純 Python 的套件跟檢查腳本放在一起部署時，仍然選 TOML；兩者都不行時選 JSON，人要讀的說明另外寫成一個欄位（例如每一筆的 <code>note</code>），讓說明跟資料仍然放在同一份檔案裡。巢狀很深（例如 Kubernetes 的 manifest）或要由程式大量產生時，YAML 或 JSON 會比較合適。</p>
<h2 id="清單用到的-toml-寫法">清單用到的 TOML 寫法</h2>
<p>示範清單的頂層有這幾段：<code>[vars]</code>（主機上的路徑與網域，給其他欄位引用）、<code>[host]</code>（這台主機是什麼）、<code>[listen_audit]</code>（loopback 以外允許接聽的連接埠）、<code>[[entrypoint]]</code>（對外入口與每一層防護）、<code>[[log]]</code>（定期讀的 log 與計數門檻）、<code>[[accumulation]]</code>（會累積的資料與上限）、<code>[[filesystem]]</code>（檔案系統的剩餘空間）、<code>[updates]</code>（自動更新的設定）、<code>[[suspend]]</code> 與 <code>[[resume]]</code>（暫停與恢復上線的步驟）。</p>
<p>清單的每一類項目寫成一個表格陣列（array of tables），每一筆以 <code>[[類別]]</code> 開頭；這裡的表格（table）是 TOML 的一組鍵值，等於程式裡的一個物件，定義見 <a href="/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML</a> 知識卡。一筆項目底下又有好幾筆子項目時，用巢狀的表格陣列。下面是示範清單對外入口那一段的節錄，原檔這個入口有五層防護，這裡只留兩層，<code>enforced_by</code> 也照節錄改成兩層：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">[[</span><span class="nx">entrypoint</span><span class="p">]]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;https&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">port</span> <span class="p">=</span> <span class="mi">443</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nx">protocol</span> <span class="p">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nx">exposure</span> <span class="p">=</span> <span class="s2">&#34;cdn-only&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nx">where</span> <span class="p">=</span> <span class="s2">&#34;host&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nx">enforced_by</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;oci-nsg&#34;</span><span class="p">,</span> <span class="s2">&#34;firewalld&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">[[</span><span class="nx">entrypoint</span><span class="p">.</span><span class="nx">verify</span><span class="p">]]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nx">layer</span> <span class="p">=</span> <span class="s2">&#34;oci-nsg&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">where</span> <span class="p">=</span> <span class="s2">&#34;console&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">console_path</span> <span class="p">=</span> <span class="s2">&#34;OCI Console → Networking → Virtual cloud networks → &lt;vcn&gt; → Network security groups → &lt;nsg&gt; → Security rules&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nx">expect</span> <span class="p">=</span> <span class="s2">&#34;TCP 443 的 ingress 規則只有 Cloudflare 的 IPv4 位址段（每段一條），沒有 0.0.0.0/0&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">[[</span><span class="nx">entrypoint</span><span class="p">.</span><span class="nx">verify</span><span class="p">]]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nx">layer</span> <span class="p">=</span> <span class="s2">&#34;firewalld&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="nx">where</span> <span class="p">=</span> <span class="s2">&#34;host&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nx">command</span> <span class="p">=</span> <span class="s2">&#34;firewall-cmd --query-service=https&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="nx">expect_stdout</span> <span class="p">=</span> <span class="s2">&#34;yes&#34;</span></span></span></code></pre></div><p><code>[[entrypoint]]</code> 每出現一次，就在 <code>entrypoint</code> 陣列裡加一筆；它下面的 <code>[[entrypoint.verify]]</code> 屬於最近的那一筆 <code>entrypoint</code>，所以每個入口各有自己的一組驗證方式。縮排只是給人讀的，TOML 不用縮排表示結構，縮排寫錯不改變解析的結果。兩層都有 <code>where</code>，意思不同：入口那一層的 <code>where = &quot;host&quot;</code> 代表接聽程式在這台主機上，腳本會確認有程序在聽這個連接埠；每一筆 <code>verify</code> 的 <code>where</code> 決定那一層防護由誰檢查。</p>
<p>只有幾個欄位的子項目可以寫成同一行的表格（inline table），例如暫停步驟裡確認資料庫完整的那一項：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">verify</span> <span class="p">=</span> <span class="p">{</span> <span class="nx">command</span> <span class="p">=</span> <span class="s2">&#34;sqlite3 -init /dev/null -readonly -safe {{collector_db}} &#39;pragma quick_check&#39;&#34;</span><span class="p">,</span> <span class="nx">expect_stdout</span> <span class="p">=</span> <span class="s2">&#34;ok&#34;</span> <span class="p">}</span></span></span></code></pre></div><p><code>{{collector_db}}</code> 是清單自己的替換語法，檢查腳本在執行前換成 <code>[vars]</code> 段寫的路徑；TOML 本身沒有變數，這兩個大括號在 TOML 看來只是字串裡的普通字元。清單改由 Jinja2 的範本（Ansible 的 <code>template</code> 模組）產生時，這個寫法跟範本自己的替換語法同形，產生的那一步會先處理它，處理方式見 <a href="/blog/devops/operations/09-host-lifecycle/multi-host-manifest/" data-link-title="多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍" data-link-desc="把單台的主機維護清單擴充到多台主機：每台相同、依角色不同、每台不同的值怎麼分層，合併放在部署時產生每台一份、由檢查腳本讀多份、依角色過濾還是執行時覆寫，用 Ansible 的 inventory、群組變數與範本產生 TOML 清單時的變數合併、兼任角色、型別、語法撞名、跳脫與產生時的驗證，由 Terraform 的輸出產生 inventory、terraform plan 能取代哪些主控台上的確認，對多台執行檢查腳本時 exit code 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍</a>〈範本的 {{ }} 與清單自己的替換語法同形〉。TOML 1.0 的 inline table 要寫在一行內、結尾不能有逗號；1.1 放寬了這兩條，解析器版本不同時會怎樣，寫在〈TOML 1.0 與 1.1 的差異與解析器的版本〉。</p>
<p>字串有兩種寫法，差在跳脫：雙引號的基本字串（basic string）會處理反斜線跳脫，regex 裡的 <code>\.</code> 要寫成 <code>\\.</code>；單引號的字面字串（literal string）不處理任何跳脫，寫什麼就是什麼。清單裡的 regex 用基本字串寫成 <code>&quot;(example\\.com|www\\.example\\.com)&quot;</code>，解析出來是 <code>(example\.com|www\.example\.com)</code>。檢查腳本另外提供 <code>--set 名稱=值</code> 參數，讓維護的人在執行時覆寫清單 <code>[vars]</code> 段裡已有的值（<code>[vars]</code> 沒有那個名稱時，腳本把它判成清單格式錯）；這個值不經過 TOML 解析，腳本原樣代入，所以反斜線只寫一個。值經過 shell 傳給腳本時，雙引號裡的反斜線會被 shell 處理一次，經 SSH 在遠端執行時，本機與遠端的 shell 可能各處理一次，送進腳本的值會少掉反斜線；經 SSH 傳時，外層用雙引號、值本身用單引號包住，遠端的 shell 就不再處理裡面的反斜線。另一個要注意的是這個值被代入的位置：示範清單把 <code>host_re</code> 嵌進一條更長的 regex 中間（先跳過前面幾個以 tab 分隔的欄位，再比對 <code>Host</code> 那一欄），值本身帶了 <code>^</code>、<code>$</code> 錨點時，錨點出現在 pattern 的中間，一行都比對不到。要覆寫 regex 這類值時，先讀清單裡引用它的那一行，知道它被嵌在哪裡，再決定值要怎麼寫。</p>
<p>數字寫成整數時就是整數：位元組上限寫 <code>limit_bytes = 1073741824</code>，解析出來就是 Python 的 <code>int</code>，檢查腳本不必再轉型。TOML 1.0 允許用底線分隔位數（<code>1_073_741_824</code>），讓人讀得出數量級。</p>
<h2 id="toml-10-與-11-的差異與解析器的版本">TOML 1.0 與 1.1 的差異與解析器的版本</h2>
<p>TOML 1.0.0 在 2021 年 1 月發佈，1.1.0 在 2025 年 12 月發佈。1.1 新增的語法有：inline table 可以跨行並在結尾加逗號、基本字串的 <code>\xHH</code> 與 <code>\e</code> 跳脫、時間可以省略秒。這些語法只有支援 1.1 的解析器讀得懂。Python 的 <code>tomllib</code> 在 3.11 加入時支援 1.0，3.15 起支援 1.1。</p>
<p>這個版本差異在開發機與主機的直譯器版本不同時出現，結果有兩種：</p>
<ul>
<li><strong>解析失敗</strong>：同一行 <code>a = {x = 1, y = 2,}</code>，Python 3.15 解析成 <code>{'a': {'x': 1, 'y': 2}}</code>，Python 3.12 丟出 <code>TOMLDecodeError: Invalid initial character for a key part</code>。開發機是 3.15、主機是 Oracle Linux 10 內建的 Python 3.12 時，清單在開發機上測試通過，部署到主機之後解析失敗，腳本一項都不執行。</li>
<li><strong>同一個值在一個版本報錯、在另一個版本被讀成別的型別</strong>：〈主機維護清單對檔案格式的需求：JSON、YAML、TOML 的比對〉那張逐行解析表裡的 <code>port_map = 22:22</code>，Python 3.12 報錯，3.15 讀成時間。開發機是 3.15 時，作者在開發機上測不到任何錯誤，而 3.15 讀到的時間 22:22 本身也不是作者要的值（要的是字串 <code>&quot;22:22&quot;</code>）；換成兩邊都是 3.15 的環境，這個錯讀就一直不會被發現。</li>
</ul>
<p>由此推出兩條寫法：清單照所有讀者裡最舊的那個解析器支援的版本寫（這次是 1.0），並在檔頭的註解寫明；測試時用那個最舊的版本實際解析一次。示範清單的單元測試另外用 <code>uv run --python 3.12</code> 解析清單，開發機的直譯器版本較新時，這一項會擋下 1.1 的語法。主機上用的是另外部署的 <code>tomli</code> 時，支援 1.0 還是 1.1 由 <code>tomli</code> 的版本決定，跟 Python 的版本無關（用 <code>a = {x = 1, y = 2,}</code> 實測，<code>tomli</code> 2.4.0 接受、2.3.0 拒絕），最舊的解析器就是部署上去的那個 <code>tomli</code>，測試時用同一個版本解析。</p>
<h2 id="清單欄位的設計檢查腳本要讀的與人要讀的">清單欄位的設計：檢查腳本要讀的與人要讀的</h2>
<p>清單的每個欄位，讀者是檢查腳本或人。設計時逐個欄位先決定它的讀者，因為兩種讀者對欄位的要求不同：腳本要的是可以照著執行、可以比對的值；人要的是判斷時需要的脈絡。</p>
<p><strong><code>where</code> 決定一個項目誰能檢查</strong>：<code>&quot;host&quot;</code> 是主機上執行得了的只讀指令，腳本會執行它；<code>&quot;external&quot;</code> 要在主機外的機器上做（例如從開發機直連源站，確認繞過 CDN 時連不進來）；<code>&quot;console&quot;</code> 要在雲端主控台上看（Oracle Cloud 的網路安全群組 Network Security Group，簡稱 NSG，裡的規則、CDN 的 SSL 模式）。<code>external</code> 與 <code>console</code> 兩種，腳本只印出來，標成待人工確認。分出這個欄位，是因為入口的防護有幾層不在主機上（雲端的 NSG、CDN 的 SSL 模式），只從主機裡面量的話，雲端那一層的設定改了也看不出來。</p>
<p><strong><code>enforced_by</code> 與 <code>verify</code> 互相對得上</strong>：每個入口宣告它由哪幾層擋住，每一層至少要有一筆驗證方式，每一筆驗證的 <code>layer</code> 都要在 <code>enforced_by</code> 裡。腳本載入清單時比對兩邊，宣告了某一層而沒有寫怎麼驗證的、或寫了驗證而沒有宣告的，都算清單格式錯；同一層寫兩筆驗證是允許的。這條規則讓「這個入口有幾層防護」從一句宣稱變成每一層都可以查的事實。</p>
<p><strong>探針的欄位</strong>（<code>command</code>、<code>expect_exit</code>、<code>ok_exit</code>、<code>expect_stdout</code>、<code>expect_contains</code>、<code>expect_not_contains</code>）描述一次檢查：執行什麼指令、什麼結果算通過。exit code 怎麼判通過分兩種情形：寫了 <code>expect_exit</code> 時，只有等於它的 exit code 算通過，<code>ok_exit</code> 不作用；沒有寫 <code>expect_exit</code> 時，exit code 落在 <code>ok_exit</code> 列的任何一個值都算通過，<code>ok_exit</code> 也沒有寫時只有 0 算通過。<code>ok_exit</code> 是為了 <code>grep -c</code> 這類指令而設的：找不到時它印 0、exit code 是 1，這是正常的結果，不是失敗。</p>
<p><strong>計數的欄位</strong>（<code>measure</code> 與 <code>warn_above</code>）讓 log 的檢查變成數字的比對：探針的指令執行之後，<code>measure</code> 決定怎麼從輸出取出數字（<code>lines</code> 算非空行數、<code>value</code> 取印出的數值、<code>age_days</code> 算印出的時間距今幾天；輸出是 <code>鍵=值</code> 的形式時，<code>value</code> 與 <code>age_days</code> 取等號後面那一段，<code>systemctl show -p NRestarts</code> 印出的 <code>NRestarts=0</code> 就是這種），取出的數字大於 <code>warn_above</code> 時警告。清單在 log 那一段的註解寫明「門檻是第一次上線時的起點，不是量過的基準」：讀到警告的人要知道，門檻本身可能需要調整。</p>
<p><strong>只印不執行的指令寫在 <code>manual_command</code></strong>：<code>command</code> 一律會被執行，而且要通過白名單；只印給人讀、不執行的指令寫在 <code>manual_command</code>，清理資料的做法寫在 <code>purge_command</code>（只在那一項警告時印在同一行）。<code>manual_command</code> 出現在 <code>[[log]]</code> 段落本身（人讀那份 log 用的指令）、<code>[[suspend]]</code> 與 <code>[[resume]]</code> 的步驟（要人執行的動作），以及 <code>where</code> 不是 <code>&quot;host&quot;</code> 的 <code>[[entrypoint.verify]]</code>（在主機外做的確認）。欄位名分開的理由是讀清單的人只看一行就分得出 <code>command</code> 與 <code>manual_command</code> 會不會被執行：示範清單的第一版裡，會執行的指令與只印的指令都叫 <code>command</code>，一行 <code>command</code> 會不會被執行由它所在的段落決定；在 <code>[[entrypoint.verify]]</code> 這張表裡，同一個欄位名又依那一筆的 <code>where</code> 決定：<code>where = &quot;host&quot;</code> 的會執行，其他的只印。讀的人要記得這些規則才分得出來。改名之後，腳本在只印的位置讀到舊名 <code>command</code> 時判成清單格式錯（exit code 2），沿用第一版清單的人不會讓一行只印的指令被安靜地略過。<code>manual_command</code> 與 <code>purge_command</code> 寫的是清理資料與停止主機這類要人判斷時機的動作，腳本裡沒有執行這兩個欄位的程式碼。名字不帶 <code>manual_</code> 而會被執行的欄位還有兩個：會累積的資料用 <code>oldest_command</code> 查最舊一筆資料的時間，<code>[updates]</code> 用 <code>reboot_check</code> 查要不要重開機，兩者同樣在載入清單時比對白名單。</p>
<p><strong>允許「不知道」的值</strong>：暫停步驟的 <code>billed_when_stopped</code> 欄位，除了 <code>yes</code>、<code>no</code>、<code>n/a</code> 之外，還允許 <code>unknown</code>；清單的註解約定，寫 <code>unknown</code> 時在 <code>billing_basis</code> 寫出要去哪裡查（腳本只檢查這個欄位存在）。只給是與否兩個值時，查不到答案的人只能二選一，寫進清單的就是一個猜測；有 <code>unknown</code> 這個值，查不到的項目留下的是一個待查的標記。這個欄位只描述停止主機這一種暫停範圍，終止主機或全部刪除時各項資源的去留與計費，要照 <a href="/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/" data-link-title="服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序" data-link-desc="一台主機上的服務要暫停一段時間再恢復：只停服務、停止主機、終止主機保留設定、全部刪除靠 repo 重建各自保留什麼與付出什麼，怎麼依保留的月費、重建的成本與漂移量選擇，暫停期間仍在計費的資源（含在作業系統裡 shutdown 不停止計費），仍被 DNS 與 CDN 指向的結果（Cloudflare 521、522，釋放 IP 之後的懸空 DNS），恢復前要重新確認的漂移，以及依元件依賴排出的恢復順序與驗證">服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序</a>〈暫停期間仍在計費的資源〉逐項決定；要讓清單涵蓋其他範圍，欄位就要依範圍分開寫。</p>
<p><strong>清單沒有存的資料：上一次的結果</strong>。維護時 access log 的計數要跟前一次比，恢復時要跟暫停之前的狀態比，而清單只描述「要檢查什麼、允許的範圍是什麼」，沒有任何欄位存上一次量到的值。這是刻意的：<code>tomllib</code> 只能讀不能寫，清單放在 repo 裡、只在有人改它時才改變，量到的值每次執行都不同，不屬於 repo。上一次的結果由檢查腳本寫進它自己的結果檔，預設是主機上的 <code>/var/lib/host-check/last.json</code>，不在 repo 裡；結果檔的格式與比對寫在〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。結果檔只在同一個階段之間比較（這一次的維護跟上一次的維護比，這一次的恢復跟上一次的恢復比），所以要比暫停前後的入口與計數，暫停之前跑一次維護的檢查、恢復之後再跑一次，恢復後那一次的差異就是跟暫停前的比較。公開 IP、管理者 IP、憑證效期這類不是數字的值不在結果檔裡，要照 <a href="/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/" data-link-title="服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序" data-link-desc="一台主機上的服務要暫停一段時間再恢復：只停服務、停止主機、終止主機保留設定、全部刪除靠 repo 重建各自保留什麼與付出什麼，怎麼依保留的月費、重建的成本與漂移量選擇，暫停期間仍在計費的資源（含在作業系統裡 shutdown 不停止計費），仍被 DNS 與 CDN 指向的結果（Cloudflare 521、522，釋放 IP 之後的懸空 DNS），恢復前要重新確認的漂移，以及依元件依賴排出的恢復順序與驗證">服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序</a>〈恢復上線前要重新確認的漂移〉另外記下來。</p>
<h2 id="只讀檢查腳本的白名單輸出exit-code-與結果檔">只讀檢查腳本的白名單、輸出、exit code 與結果檔</h2>
<p>檢查腳本 <code>host-check.py</code> 只用 Python 標準函式庫（<code>tomllib</code>、<code>subprocess</code>、<code>shlex</code> 這類），在主機上以 root 執行，讀清單、執行 <code>where = &quot;host&quot;</code> 的項目、印出報告，並把這次的結果寫進自己的結果檔；每一段程式這樣寫的理由在 <a href="/blog/devops/operations/09-host-lifecycle/hands-on/host-check-script-source/" data-link-title="只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制" data-link-desc="要讀主機維護清單的只讀檢查腳本實際怎麼切分各工具的選項、怎麼驗證清單、怎麼判定 PASS、WARN 與 MANUAL、怎麼寫結果檔，或要照它的做法寫自己的檢查腳本時，查完整的 Python 原始碼與每一段這樣寫的理由">只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制</a>。腳本這一層能保證的範圍是不改動受檢查的設定與服務狀態：不改設定檔、不啟停服務、不安裝或移除套件、不刪資料。白名單上的工具自己照常寫的檔案不在這個範圍裡：在 Oracle Linux 10 的容器實測，<code>dnf -q history list</code> 與 <code>dnf needs-restarting -r</code> 每次都寫 <code>/var/log/dnf.log</code>、<code>dnf.librepo.log</code>、<code>dnf.rpm.log</code>、<code>hawkey.log</code> 與 <code>/var/cache/dnf/expired_repos.json</code>，加 <code>-C</code> 時仍寫那四份 log；<code>rpm -q</code> 會更新 rpm 資料庫的 <code>rpmdb.sqlite-shm</code>；<code>sqlite3</code> 讀一個 WAL 模式的資料庫時，資料庫旁邊的 <code>-wal</code> 與 <code>-shm</code> 檔不存在的話（服務停掉之後就是這個狀態）會把它們建出來。要讓這些寫入也不發生，得靠下面講的系統層限制，其中 dnf 要寫的整個 <code>/var/log</code> 與 <code>/var/cache/dnf</code> 是實測之後要刻意放行的兩個目錄。腳本這一層的保證靠的是三件事：</p>
<ul>
<li><strong>不經過 shell</strong>：指令用 <code>shlex</code> 切成參數之後直接執行，沒有管線、轉向與命令替換，清單裡的字串在腳本這一層組不出第二條指令。白名單裡的工具自己仍可能有執行 shell 的選項：<code>rpm</code> 帶 <code>--pipe &lt;指令&gt;</code> 時，會把輸出交給 <code>/bin/sh</code> 執行那條指令，所以這一點要靠白名單的選項規則擋下。</li>
<li><strong>執行任何一條之前先比對白名單</strong>：清單欄位裡的指令，以及腳本自己用清單的值組出來的指令（用 <code>systemctl is-enabled &lt;timer&gt;</code> 與 <code>systemctl is-active &lt;timer&gt;</code> 查自動更新的排程、用 <code>journalctl -u &lt;unit&gt;</code> 查服務的紀錄、用 <code>ss</code> 列接聽的連接埠），都在載入清單時比對一次，執行前再比對一次。載入時有一條不符，整份清單算格式錯，一條指令都不執行：一條不符代表清單裡寫了一條白名單不認得的指令，可能是筆誤，也可能是有人寫進了會改動主機的指令，在分清楚之前，同一份清單的其他項目也不能信。代價是清單裡一個筆誤會讓全部檢查停掉，所以接上排程時要對 exit code 2 告警（見下面 exit code 那一段）。執行前那一次比對是保險，正常情形下載入時已經擋下；萬一它不通過，腳本立刻停下、以 exit code 2 結束，在它之前的指令已經執行過。</li>
<li><strong>清理與暫停的動作只印出來</strong>：它們寫在 <code>manual_command</code>、<code>purge_command</code> 這些給人讀的欄位裡，腳本裡沒有執行這些欄位的程式碼。</li>
</ul>
<p>腳本這一層之外，另一種做法是在系統層限制腳本能碰到的範圍，有兩條路：用 systemd 的 service unit 或 <code>systemd-run</code> 執行腳本，由 systemd 在啟動程序之前套上檔案系統、網路與權限的限制；或不以 root 執行，改用專用帳號。兩條路都在一個以 systemd 257 為 init、用 <code>--privileged --cgroupns=private</code> 執行的 Oracle Linux 10 容器裡實測過。量之前先確認限制已經套上：<code>ProtectSystem=strict</code> 底下 <code>touch /var/x</code> 回 <code>Read-only file system</code>，<code>IPAddressDeny=any</code> 底下 <code>curl</code> 逾時（exit 28）。量測環境本身可能讓限制失效而不報錯：OrbStack 的 Linux machine 是 LXC 容器（<code>systemd-detect-virt</code> 印 <code>lxc</code>），systemd 在裡面沒有套上命名空間，也沒有印出任何錯誤或警告，<code>ProtectSystem=strict</code> 底下照樣寫得進 <code>/var</code>，在那種環境量到的是沒有限制的結果。同一組屬性由 Ansible 以 <code>-b</code> 對多台主機執行、以及不加 <code>-b</code> 時 <code>systemd-run</code> 以 <code>Access denied</code> 失敗的輸出，在 <a href="/blog/devops/operations/09-host-lifecycle/hands-on/multi-host-manifest-walkthrough/" data-link-title="多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理" data-link-desc="要照著部署多台主機的維護清單並執行檢查腳本，或要讀跨主機執行時 ansible 的 exit code 與每台的 rc、門檻造成的 WARN、清單格式錯與連不上的主機各自印出什麼時，或要確認 Terraform 寫出的 inventory 的檔案權限、`--check` 預覽與實際部署結果不同的情形時，查在可拋棄的容器上實際跑過的指令、輸出與遇到的錯誤">多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理</a>〈以 -b 與 systemd-run 執行檢查腳本：每台的輸出與 ansible 的 exit code〉。</p>
<p>systemd 的限制用 <code>systemd-run</code> 套上的寫法如下，各屬性實測的效果列在後面的表裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 以 root 執行；腳本與清單的路徑換成主機上實際的位置</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemd-run --wait --pipe --collect <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -p <span class="nv">ProtectSystem</span><span class="o">=</span>strict -p <span class="s1">&#39;ReadWritePaths=/var/log /var/cache/dnf&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -p <span class="nv">StateDirectory</span><span class="o">=</span>host-check -p <span class="nv">StateDirectoryMode</span><span class="o">=</span><span class="m">0700</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  -p <span class="nv">NoNewPrivileges</span><span class="o">=</span>yes -p <span class="nv">PrivateTmp</span><span class="o">=</span>yes -p <span class="nv">IPAddressDeny</span><span class="o">=</span>any <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  python3 &lt;host-check.py 的路徑&gt;</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>實測的效果</th>
          <th>要另外加的設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ProtectSystem=strict</code></td>
          <td>整個檔案系統唯讀。dnf 每次都要寫 log，<code>dnf needs-restarting -r</code> 以 exit 1 結束，錯誤是 <code>Read-only file system: '/var/log/dnf.log'</code>；<code>dnf -C check-update</code> 只用快取時同樣因為寫 log 而失敗</td>
          <td><code>ReadWritePaths=</code> 放行 <code>/var/log</code> 與 <code>/var/cache/dnf</code> 之後 exit 0；只放行 <code>/var/log</code> 時改報 <code>/var/cache/dnf/metadata_lock.pid</code> 寫不進去；<code>/var/lib/dnf</code> 不必可寫</td>
      </tr>
      <tr>
          <td><code>StateDirectory=host-check</code></td>
          <td>結果檔的目錄 <code>/var/lib/host-check</code> 可寫，其餘仍唯讀</td>
          <td>目錄預設建成 0755，而且每次啟動都被改回 0755，腳本建目錄時給的 0700 只在目錄不存在時有效；加 <code>StateDirectoryMode=0700</code> 之後目錄 0700、結果檔 0600</td>
      </tr>
      <tr>
          <td><code>NoNewPrivileges=yes</code>、<code>PrivateTmp=yes</code>、<code>IPAddressDeny=any</code></td>
          <td>腳本不提權、不用 <code>/tmp</code>、不連網，對每一項檢查都沒有看得到的影響</td>
          <td>無</td>
      </tr>
      <tr>
          <td><code>PrivateNetwork=yes</code></td>
          <td><code>ss -tlnp</code> 只印出表頭，看不到主機上任何接聽的程式；四個入口的檢查全部變成 <code>WARN</code>；<code>listen_audit</code> 在一個 socket 都看不到時印 <code>WARN</code>（判斷不了有沒有多開的連接埠），修正之前的腳本判成 <code>PASS</code></td>
          <td>不開這一項；禁止對外連線由 <code>IPAddressDeny=</code> 負責</td>
      </tr>
  </tbody>
</table>
<p>其他工具在唯讀的檔案系統上：<code>rpm -q</code> 照常查得到，<code>rpmdb.sqlite-shm</code> 原本就在；<code>sqlite3 -init /dev/null -readonly -safe</code> 讀一個服務正在寫、<code>-wal</code> 與 <code>-shm</code> 都在的資料庫，照常讀得到。服務停掉之後那兩個檔被收掉，同一條指令以 <code>unable to open database file (14)</code> 失敗，因為它要建那兩個檔而檔案系統是唯讀的；暫停時停掉服務再跑的那一輪檢查就是這個情形，從這個 exit 14 推論那一項會是 <code>WARN</code>（實測只跑了單獨的 <code>sqlite3</code> 指令，沒有在服務停掉時跑整份清單）。沒有套限制時，同一條指令讀得到，代價是在資料庫旁邊建出 <code>-shm</code>（32768 位元組）與 <code>-wal</code>（0 位元組）。dnf 那兩個路徑是單獨對 <code>dnf needs-restarting -r</code> 量的；整份清單以 root 執行時只在沒有 <code>ReadWritePaths=</code> 與 <code>StateDirectoryMode=</code> 的設定下跑過（<code>StateDirectoryMode=</code> 在專用帳號那一組用過），那一次比沒套限制時多出的 <code>WARN</code> 只有 dnf 那一項，結果檔照常寫進 <code>StateDirectory</code>，第二次執行的差異段也照常印出。</p>
<p>接上限制之後，<code>reboot_check</code> 的 exit 1 有兩種成因：dnf 判定要重開機，以及 dnf 自己出錯（寫不了 log），只看 exit code 分不開。腳本因此再看輸出：含 dnf 判定要重開機時印的 <code>Reboot is required</code>，才印「更新之後需要重開機」；沒有這一句時印 <code>WARN</code>，理由是判斷不了要不要重開機，後面附上 dnf 的錯誤訊息（<code>Read-only file system: '/var/log/dnf.log'</code>），讀到這一行就去確認 <code>ReadWritePaths=</code> 有沒有放行 dnf 的兩個路徑。修正之前的腳本只看 exit code，把寫不了 log 印成需要重開機；這個判定與上面 <code>listen_audit</code> 的判定，修正之後都在套了同樣限制的容器裡重跑過，各自印出新的 <code>WARN</code>。</p>
<p>專用帳號實測換回的範圍很小。以 <code>useradd -r</code> 建的帳號、加上 <code>systemd-journal</code> 與 <code>adm</code> 兩個群組執行，root 下 37 項 <code>PASS</code> 的那份清單只剩 29 項，其餘變成 <code>WARN</code>。讀不到的有：firewalld 的查詢（<code>firewall-cmd</code> 以 exit 253 回 <code>NotAuthorizedException</code>）、<code>sshd -T</code>（<code>/etc/ssh/sshd_config</code> 的權限是 0600）、服務的資料庫（所在目錄 0750）、放在 <code>/home/opc</code> 底下的建置產物（家目錄 0700）、nginx 的 log 目錄（0711，進得去、列不出檔名）；audit log 在這個容器裡沒有，沒量到，主機上它是 0600 root，從權限推論同樣要 root。資料庫、nginx 的 log 目錄與建置產物這三項，腳本印成 <code>WARN</code>「讀不到 &lt;目錄&gt;（權限，用 sudo 執行）」，清單把那一項設成路徑不存在也算 <code>PASS</code> 時同樣印 <code>WARN</code>：目錄存在而列不出內容，跟目錄不存在是兩件事。修正之前的腳本把兩者都印成「路徑不存在」，前一種情形因此可能判成 <code>PASS</code>。加了群組換回來的只有讀 journal 的兩項，而且前提是 <code>/var/log/journal</code> 的群組與 ACL 由 systemd-tmpfiles 設好：持久 journal 的目錄由 journald 自己建出時是 root:root、檔案 0640，<code>systemd-journal</code> 群組的 <code>journalctl</code> 回 <code>No journal files were opened due to insufficient permissions.</code>，跑過 <code>systemd-tmpfiles --create --prefix /var/log/journal</code> 之後才讀得到。限定指令的 sudoers 這一條走不通，是從腳本的結構推的，沒有實測：腳本直接執行白名單裡的指令、不經過 sudo，sudoers 只能授權整支 <code>python3 host-check.py</code>，範圍跟用 root 執行相同。</p>
<p>所以實測之後的做法是以 root 執行，外面套上上面那段 <code>systemd-run</code> 的屬性。換到的是：白名單解析錯了，指令照樣寫不進受保護的路徑、連不到外面，sqlite 的 <code>-shm</code> 這類工具自己的寫入也一起擋下；為了 dnf 刻意放行的是整個 <code>/var/log</code> 與 <code>/var/cache/dnf</code> 兩個目錄，nginx 這類其他服務的 log 也在 <code>/var/log</code> 底下，白名單漏掉的指令寫得進這兩個目錄裡的任何檔案（只放行 dnf 那四份 log 檔的寫法沒有實測）。兩層的分工是：白名單擋下清單裡寫錯或寫壞的指令，系統層限制擋下白名單漏掉的寫入；下面記的兩輪缺口每一個都是白名單漏掉的寫入或執行，所以只靠白名單時，保證的強度就是白名單解析的正確度。</p>
<p>系統層限制管的是腳本這個程序自己的寫檔與連線，請別的程序代做的改動不在它的範圍裡。在 Ubuntu 24.04（systemd 255）上，以 <code>systemd-run --wait --pipe -p ProtectSystem=strict -p IPAddressDeny=any</code> 執行 <code>systemctl stop &lt;一個暫時的 unit&gt;</code>，exit 0，那個 unit 被停掉，再加上 <code>PrivateNetwork=yes</code> 結果相同：<code>systemctl</code> 經由 unix socket 請 PID 1 停掉服務，改變狀態的是 PID 1，而 PID 1 不受這個程序的限制。加上 <code>RestrictAddressFamilies=&quot;AF_INET AF_INET6&quot;</code>（不含 unix socket）之後，同一條指令以 exit 1 結束，錯誤是 <code>Failed to connect to bus: Address family not supported by protocol</code>；而 <code>systemctl is-active</code> 這類查詢走的是同一條 unix socket，<code>ss</code> 要用 netlink，拿掉這兩種位址族會讓清單裡這幾類檢查一起失敗，保留它們就擋不住代做的改動（整份清單在這個設定下沒有實測，是從這兩個機制推論）。所以 <code>systemctl stop</code>、<code>firewall-cmd --reload</code> 這類交給 PID 1 或 firewalld 代做的改動，只有白名單擋得住；系統層限制補的是白名單漏掉、由腳本自己動手的寫入與連線。</p>
<p>白名單列的是允許的寫法，不在表上的一律拒絕。另一種寫法是列禁止的寫法：只擋已知會改變狀態的選項，其餘放行。列禁止的寫法要把工具現有的每一個會改變狀態的選項都列進去，漏一個就放行一個，工具日後新增的選項也會被放行；列允許的寫法沒有這個問題，代價是清單要用到新的選項時，得先把它加進白名單。白名單分三層比對：</p>
<ul>
<li><strong>執行檔的路徑</strong>：指令的第一個參數只能是裸的工具名，或 <code>/usr/bin</code>、<code>/usr/sbin</code>、<code>/bin</code>、<code>/sbin</code> 底下的路徑。只比對檔名的話，<code>/tmp/evil/grep</code> 與 <code>./grep</code> 都會被當成 <code>grep</code> 放行，執行的是別的程式。裸的工具名依執行時的 <code>PATH</code> 找檔案，腳本又把呼叫者的環境變數傳給指令，所以腳本要經由 sudo（它的 <code>secure_path</code> 會重設 <code>PATH</code>）或 systemd 的 unit（環境是乾淨的）執行；從一個 <code>PATH</code> 被改過的 root shell 直接執行時，這一層擋不住 <code>PATH</code> 前面的同名檔案。</li>
<li><strong>工具</strong>：只有白名單上的工具可以執行。其中 <code>grep</code>、<code>wc</code>、<code>stat</code>、<code>du</code>、<code>df</code>、<code>test</code>、<code>id</code>、<code>uname</code>、<code>getenforce</code>、<code>sestatus</code>、<code>getsebool</code> 沒有寫檔或執行其他程式的選項（在 Oracle Linux 10 上逐支讀過 <code>--help</code>；<code>df --sync</code> 會先把快取寫回磁碟，不改任何檔案的內容），選項不另外比對；其餘的工具各自有選項的規則。</li>
<li><strong>工具的選項</strong>：有選項規則的工具，各自列出允許的子命令與選項，表外的拒絕；長選項的縮寫（<code>--cursor-fi</code> 這種只寫前幾個字元的寫法）也拒絕，因為 <code>journalctl</code> 這類工具接受沒有歧義的前綴，把 <code>--cursor-fi</code> 當成 <code>--cursor-file</code> 執行，只比對全名的規則會放行它。</li>
</ul>
<p>選項要照工具自己的參數規則切分，比對的結果才可靠。<code>systemctl</code>、<code>journalctl</code>、<code>ss</code> 用 <code>getopt_long</code> 的語法；<code>rpm</code> 用 popt、<code>dnf</code> 與 <code>needs-restarting</code> 用 Python 的 argparse，這三支各自的解析器在下面這幾點的切法跟 <code>getopt_long</code> 相同。腳本對這六支用同一套切分：短選項可以合併（<code>-qa</code>）；需要帶值的選項，值可以直接接在選項後面（<code>-pX</code>），也可以是下一個參數；長選項寫成 <code>--名稱</code> 或 <code>--名稱=值</code>；<code>--</code> 之後全部是位置參數。示範腳本的第一版把第一個不以 <code>-</code> 開頭的參數當成子命令，這套切分規則一條都沒有用上，於是放行了兩種寫法：<code>systemctl -p show stop nginx</code> 被第一版判成子命令是 <code>show</code>，而 systemctl 自己解析時把 <code>show</code> 當成 <code>-p</code> 的值，執行的子命令是 <code>stop</code>；<code>dnf -x history -x list install &lt;套件&gt;</code> 裡，兩個 <code>-x</code> 各把 <code>history</code>、<code>list</code> 當成要排除的套件，第一版看到的是允許的 <code>history list</code>，dnf 執行的是 <code>install</code>。現在的版本先把每個選項連同它的值切出來，再判斷剩下的參數裡哪一個是子命令。其餘的工具各有自己的語法：<code>find</code> 的運算式不是選項語法，腳本逐個參數比對；<code>sshd</code>、<code>openssl</code>、<code>sqlite3</code>、<code>ausearch</code> 用單一連字號的長選項或自己的解析器，腳本逐個參數整字比對；<code>firewall-cmd</code> 逐個參數比對允許的樣式。各工具允許的寫法：</p>
<ul>
<li><strong><code>systemctl</code></strong>：子命令只允許 <code>is-active</code>、<code>is-enabled</code>、<code>is-failed</code>、<code>show</code>、<code>status</code>、<code>cat</code>、<code>list-timers</code>、<code>list-units</code>、<code>list-unit-files</code>；需要帶值的選項只允許 <code>-p</code>／<code>--property</code>、<code>-P</code>、<code>-t</code>／<code>--type</code>、<code>--state</code>、<code>-n</code>／<code>--lines</code>、<code>-o</code>／<code>--output</code>、<code>--timestamp</code>。<code>-H</code>／<code>--host</code>（經由 <code>ssh</code> 在另一台主機上執行）與 <code>-M</code>／<code>--machine</code>（改在容器裡執行）不在表上，<code>--root</code>、<code>--user</code> 也不在。</li>
<li><strong><code>dnf</code> 與 <code>needs-restarting</code></strong>：<code>dnf</code> 只允許 <code>needs-restarting</code> 與 <code>history list</code>、<code>history info</code> 這幾個子命令，子命令之前只接受 <code>-q</code>、<code>-C</code>，子命令之後的選項只有 <code>needs-restarting</code> 的 <code>-r</code>、<code>-s</code>、<code>--reboothint</code>、<code>--services</code>；單獨呼叫的 <code>needs-restarting</code> 也只允許這四個。<code>-x</code>、<code>--setopt</code>、<code>--installroot</code>、<code>-y</code> 不在表上。</li>
<li><strong><code>rpm</code></strong>：第一個參數要是 <code>-q</code> 開頭或 <code>--query</code>（查詢模式），格式只能用 <code>--qf</code>／<code>--queryformat</code> 指定；<code>--pipe</code>、<code>--eval</code>、<code>-D</code>／<code>--define</code>、<code>--macros</code>、<code>--rcfile</code>、<code>--root</code>、<code>--dbpath</code> 不在表上。</li>
<li><strong><code>journalctl</code></strong>：需要帶值的選項只允許 <code>-u</code>、<code>--since</code>、<code>--until</code>、<code>-o</code>、<code>-g</code>、<code>-p</code>、<code>-t</code>，<code>-n</code> 的值可帶可不帶；<code>--cursor-file</code>（把這次讀到的位置標記 cursor 寫進指定的檔案）、<code>--vacuum-*</code>、<code>--rotate</code>、<code>--flush</code>、<code>-f</code> 不在表上。</li>
<li><strong><code>ss</code></strong>：只允許列出連線的選項（<code>-H</code>、<code>-l</code>、<code>-n</code>、<code>-t</code>、<code>-u</code>、<code>-a</code>、<code>-p</code>、<code>-4</code>、<code>-6</code> 這些），加上帶值的 <code>-f</code>／<code>--family</code>、<code>-A</code>／<code>--query</code>；<code>-K</code>（關掉符合條件的連線）、<code>-D</code>、<code>-N</code>（換到另一個網路命名空間）、<code>-F</code>（從檔案讀過濾條件）、<code>-r</code>（反查 DNS，會對外連線）不在表上。</li>
<li><strong><code>find</code></strong>：只允許比對條件（<code>-name</code>、<code>-path</code>、<code>-type</code>、<code>-mtime</code>、<code>-size</code>、<code>-user</code>、<code>-perm</code>、<code>-maxdepth</code> 這些）與 <code>-print</code>、<code>-print0</code>、<code>-printf</code>；<code>-delete</code>、<code>-exec</code>、<code>-execdir</code>、<code>-ok</code>、<code>-okdir</code>（執行指令或刪檔）與 <code>-fprint</code>、<code>-fls</code>（把輸出寫進檔案）不在表上。</li>
<li><strong><code>sshd</code></strong>：參數只能是單獨一個 <code>-T</code>（印出生效中的設定）；<code>-T -E &lt;檔案&gt;</code>（把 log 寫進那個檔案）、<code>-f</code>、<code>-o</code> 都拒絕。</li>
<li><strong><code>firewall-cmd</code>、<code>openssl</code>、<code>ausearch</code></strong>：<code>firewall-cmd</code> 只允許 <code>--query-*</code>、<code>--list-*</code>、<code>--get-*</code>、<code>--info-*</code>、<code>--state</code>，以及指定查詢對象的 <code>--permanent</code>、<code>--zone=</code>；<code>openssl</code> 只允許 <code>x509 -noout</code> 加上讀取憑證欄位的選項；<code>ausearch</code> 只允許篩選紀錄的選項（<code>-m</code>、<code>-k</code>、<code>-ts</code>、<code>-te</code>、<code>-i</code> 這些），<code>--checkpoint</code>（把讀到的位置寫進檔案）與 <code>-if</code>（改讀別的檔案）不在表上。</li>
<li><strong><code>sqlite3</code></strong>：一定要同時帶 <code>-init /dev/null</code>、<code>-readonly</code>、<code>-safe</code>，其餘只允許控制輸出格式與執行方式的選項（<code>-header</code>、<code>-csv</code>、<code>-json</code>、<code>-separator</code>、<code>-bail</code>、<code>-batch</code> 這些）；寫在 SQL 參數裡、以 <code>.</code> 開頭的 dot-command（<code>.shell</code>、<code>.output</code>）也拒絕，不只靠 <code>-safe</code>。理由寫在下面講 <code>sqlite3</code> 的那一段。</li>
</ul>
<p>上面這份白名單是兩次測試修出來的。對第一版的白名單函式逐條測試、再在 Oracle Linux 10 容器裡實際執行，找到的缺口是：<code>systemctl -p</code> 與 <code>dnf -x</code> 這兩種沒有照參數規則切分而放行的寫法；<code>journalctl</code> 的規則是列禁止的寫法，漏了 <code>--cursor-file</code>；<code>rpm</code> 與 <code>sshd</code> 的規則只檢查第一個參數，<code>rpm -q &lt;套件&gt; --pipe &lt;指令&gt;</code> 會以 root 執行 shell 指令，<code>sshd -T -E &lt;檔案&gt;</code> 會建立那個檔案；<code>systemctl -H</code> 會讓 systemctl 執行 <code>ssh</code>，當時只讀了 man page、沒有實跑。修補時在同一種容器裡實測，在 <code>PATH</code> 放一個會記錄參數的假 <code>ssh</code>，<code>systemctl -H &lt;使用者&gt;@&lt;主機&gt; status nginx</code> 讓 systemctl 以 root 執行了 <code>ssh -xT -- &lt;使用者&gt;@&lt;主機&gt; systemd-stdio-bridge</code>；第一版只因為把分開寫的 <code>-H &lt;主機&gt;</code> 裡的 <code>&lt;主機&gt;</code> 當成子命令而拒絕了它，<code>-H&lt;主機&gt;</code> 與 <code>--host=&lt;主機&gt;</code> 都會放行。修補時另外找到三個缺口：<code>rpm</code> 的 <code>--eval &quot;%(&lt;指令&gt;)&quot;</code> 與 <code>-D</code> 會經由巨集執行 shell 指令，實測建立了檔案；執行檔的路徑沒有檢查，就是上面「執行檔的路徑」那一層；<code>sqlite3</code> 啟動時讀的 init 檔會在 <code>-safe</code> 生效之前執行，見下面講 <code>sqlite3</code> 的那一段。</p>
<p>除了執行檔的路徑那一個，這些缺口有同一個成因：寫規則的人以為工具會怎麼解析這串參數，工具實際照它自己的解析規則解析，而且支援執行其他程式與寫檔的功能。一支工具要列出哪些選項規則，看它有沒有這幾類功能：執行 shell 的選項或中介機制（巨集、管線、清單能指定的設定檔）、寫檔的選項或函式、執行其他程式或連到別台主機的選項，以及哪些選項需要帶值；這次選項層的缺口全部落在這幾類裡，有這類功能的工具照樣可以進白名單，選項規則要把那些功能排除在允許的寫法之外。另外兩類功能選項規則管不到，要靠上面講的系統層限制：工具不帶任何選項也會寫的檔案（dnf 的 log 與快取、資料庫的 <code>-shm</code> 檔），以及讀取本身就有副作用的對象（在接了 LDAP 這類目錄服務的主機上，<code>id &lt;使用者&gt;</code> 與 <code>stat</code> 查使用者名稱時會連到目錄伺服器；讀裝置檔、碰到自動掛載點會觸發動作；這兩種是從機制推的，沒有實跑）。</p>
<p>白名單防的是清單的內容，所以清單能指定的設定檔都要擋：<code>rpm</code> 的 <code>--macros</code>、<code>--rcfile</code>，<code>sqlite3</code> 的 <code>-init &lt;檔案&gt;</code>。root 自己擁有的預設設定檔不在白名單的防護範圍內：容器實測，在 <code>/root/.rpmmacros</code> 寫進一個 <code>%(touch &lt;檔案&gt;)</code> 巨集，<code>rpm -q bash</code> 就建出了那個檔案，跟 <code>sqlite3</code> 的 <code>~/.sqliterc</code> 是同一類；<code>openssl</code> 讀的 <code>openssl.cnf</code>、<code>dnf</code> 載入的 plugin 也一樣。能改這些檔案的人已經有 root 權限，用不著經過清單。<code>sqlite3</code> 要求 <code>-init /dev/null</code> 的主要理由是清單本身寫得出 <code>-init &lt;檔案&gt;</code>，順帶蓋過了 <code>~/.sqliterc</code>。執行檔的路徑那一個缺口的成因在工具這一層之前：規則只比對了檔名，沒有比對要執行的是哪一個檔案。示範腳本的修補對照每一個缺口寫了單元測試，在 Python 3.12 與 3.15 上各跑一次，兩個版本都全部通過。</p>
<p><code>sqlite3</code> 執行 shell 與寫檔的途徑有三條：dot-command、SQL 函式與啟動時讀的 init 檔。<code>-readonly</code> 只讓資料庫檔案唯讀，<code>sqlite3</code> 的 dot-command（<code>.shell</code>、<code>.system</code>）照樣能執行 shell 指令，SQL 函式 <code>writefile()</code> 照樣能寫檔案系統；<code>-safe</code> 依 sqlite 的 CLI 文件會關掉除了指定資料庫檔案以外、所有可能改動主機的功能（<code>.shell</code>、<code>.system</code>、<code>.output</code> 這類 dot-command，<code>writefile()</code>、<code>readfile()</code>、<code>edit()</code> 這類 SQL 函式，以及 <code>ATTACH</code>），實測加上它之後 <code>.shell</code> 與 <code>writefile()</code> 都被拒絕。<code>-safe</code> 管不到的是啟動時讀的 init 檔：<code>sqlite3</code> 啟動時先執行 <code>-init &lt;檔案&gt;</code> 指定的檔案，沒有指定時執行執行者家目錄的 <code>~/.sqliterc</code>，兩者都在 <code>-safe</code> 生效之前執行，實測 init 檔裡的 <code>.shell touch &lt;檔案&gt;</code> 照樣建出了檔案。<code>-init /dev/null</code> 會取代 <code>~/.sqliterc</code>，寫在 <code>-readonly -safe</code> 之前或之後都擋得住；把 <code>HOME</code> 設成不存在的目錄擋不住，因為 <code>sqlite3</code> 先從系統的帳號資料庫（<code>/etc/passwd</code> 這一類，經由 <code>getpwuid()</code>）查執行者的家目錄，不看 <code>HOME</code>。白名單因此要求 <code>sqlite3</code> 同時帶 <code>-init /dev/null</code>、<code>-readonly</code> 與 <code>-safe</code>，<code>-init</code> 的值只接受 <code>/dev/null</code>。sqlite 的版本紀錄寫了 <code>--safe</code> 在 3.37.0 加入、3.40.1 修正成確實擋下 <code>writefile()</code> 這類 SQL 函式、3.41.0 再補強它擋下的危險 SQL 函式，所以較舊的主機認得這個選項，某些函式卻可能照樣執行得了：主機上的 sqlite 要是 3.41.0 以上（Debian 12 的 3.40.1 介於兩次修正之間），或在主機上對一個測試用的資料庫實際跑一次 <code>sqlite3 -init /dev/null -readonly -safe &lt;資料庫&gt; &quot;select writefile('/tmp/x','a')&quot;</code>，確認被拒絕（Oracle Linux 10 的 sqlite 3.46.1 符合）。</p>
<p>輸出每項一行 <code>PASS</code>、<code>WARN</code> 或 <code>MANUAL</code>，結尾印計數；exit code 0 代表沒有任何 <code>WARN</code>（只有 <code>PASS</code> 與 <code>MANUAL</code>），1 代表至少一項 <code>WARN</code>，2 代表清單格式錯（命令列參數寫錯、<code>--set</code> 的格式錯也是 2，那時 stderr 印的是用法說明而不是 <code>manifest error:</code>）。把格式錯與檢查失敗分成兩個 exit code，是因為兩者的處置不同：格式錯要改清單，檢查失敗要處理主機。把腳本接上排程或告警時，exit code 不是 0 就要告警：只對 1 告警的話，清單格式錯（2）的那段期間一項檢查都沒有跑，也收不到任何通知。<code>MANUAL</code> 不影響 exit code，所以主機外的項目永遠不會觸發告警；那些項目要另外排進維護時段。它們是有人隨時可能做的改動（在主控台改 NSG、改 CDN 的 SSL 模式），改變是<a href="/blog/backend/knowledge-cards/step-change/" data-link-title="Step Change（步階變化）" data-link-desc="判斷一個指標或一項設定的改變能不能從量測的趨勢預測、要用趨勢外推還是跟基準比對才偵測得到時，區分步階變化與逐步累積的變化">步階變化</a>、沒有變化速度可算，檢查的間隔由能接受那個狀態持續多久決定，會造成對外暴露的要另外排定期比對，見 <a href="/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</a>〈檢查間隔與哪些項目改成自動處理的推算〉裡「改變是步階變化」的那一類；腳本只把它們印成 <code>MANUAL</code>，比對要由那個排程或維護的人做。</p>
<p>結果檔讓每一次的報告能跟前一次比。腳本用 <code>--phase</code> 指定這次跑的是維護（<code>maintain</code>，預設）、暫停（<code>suspend</code>）還是恢復（<code>resume</code>）的檢查，每次執行結束時，把這一次的結果寫進結果檔裡同一個階段的那一段，其他階段的紀錄不動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span><span class="nt">&#34;format&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="nt">&#34;phases&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;maintain&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;finished_at&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-10-26T09:27:01.975703+00:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">                         <span class="nt">&#34;manifest&#34;</span><span class="p">:</span> <span class="s2">&#34;/path/to/host.toml&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">                         <span class="nt">&#34;counts&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;PASS&#34;</span><span class="p">:</span> <span class="mi">41</span><span class="p">,</span> <span class="nt">&#34;WARN&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;MANUAL&#34;</span><span class="p">:</span> <span class="mi">7</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">                         <span class="nt">&#34;items&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;filesystem/root&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="mi">72560254976</span><span class="p">}}}}}</span></span></span></code></pre></div><p>這段 JSON 是節錄：<code>items</code> 只留了一項，<code>manifest</code> 的路徑是佔位的值，<code>counts</code> 借用本節最後那台雲端主機修正之後再跑一次的結果，<code>filesystem/root</code> 的值取自下一段本機容器連跑兩次的第一次；實際的結果檔依鍵名排序、每層縮排兩格，這裡壓成幾行。<code>format</code> 是結果檔格式的版本號，腳本讀到不是 1 的版本時，把它當成壞掉的結果檔處理。<code>items</code> 每一項記下狀態與量到的數字，數字來自有 <code>measure</code> 的探針、會累積的資料的總位元組與檔案系統的可用位元組，其他項目是 <code>null</code>。</p>
<p>報告在每項的結果之後、計數之前，印出跟上一次的差異。第一行寫上一次的時間（<code>since: &lt;上一次的 finished_at&gt;</code>），之後每個不同的項目一行：狀態變了的印 <code>CHANGED &lt;項目&gt; WARN→PASS</code>，數字變了的印 <code>DELTA &lt;項目&gt; 舊值 → 新值（差）</code>，這次新增的與上一次有而這次沒有的項目各印 <code>NEW</code>、<code>GONE</code>。在本機的 Oracle Linux 10 容器裡連跑兩次，根檔案系統的可用空間印成 <code>DELTA filesystem/root 72560254976 → 72541392896（-18862080）</code>，也就是兩次之間少了 18862080 位元組（約 18 MiB）。結果檔裡沒有這個階段的上一次紀錄時（第一次執行、第一次跑這個 <code>--phase</code>、結果檔壞掉），這一段只印 <code>since: 沒有前一次的結果（&lt;路徑&gt;）</code>；兩次完全相同時印 <code>since: &lt;時間&gt;，與前一次相同</code>；帶 <code>--no-state</code> 時不印這一段。</p>
<p>結果檔預設在 <code>/var/lib/host-check/last.json</code>，<code>--state-file &lt;路徑&gt;</code> 換位置、<code>--no-state</code> 不讀也不寫。放在 <code>/var/lib</code> 而不放 <code>/run</code>，是因為 <code>/run</code> 在記憶體裡、重開機就清空，而停止主機再啟動等於重開機，放 <code>/run</code> 就比不到暫停前的紀錄；終止主機而不保留開機磁碟時 <code>/var/lib</code> 也一起消失，終止之前要把 <code>last.json</code> 拷出主機。腳本先把內容寫進同一個目錄裡的暫存檔並寫到磁碟，再用 <code>os.replace</code> 換上正式的檔名：同一個檔案系統裡的換名是一次完成的動作，所以程序在寫到一半時被中斷，正式的檔名仍然指著上一次的完整內容（中斷時可能留下一個暫存檔）。目錄以 0700、檔案以 0600 的權限建立，因為結果檔記著主機的連接埠、log 計數與磁碟用量，只給 root 讀。結果檔不是合法的 JSON、格式版本不認得、讀不到或寫不進去時（例如不是以 root 執行），腳本在 stderr 印以 <code>note:</code> 開頭的說明（讀與寫各一行），壞掉的檔案照常覆寫，exit code 照檢查的結果決定：結果檔是比對用的輔助資料，它出錯不代表主機有問題。</p>
<p>這支腳本先在開發機上的 Oracle Linux 10 容器裡測過，第一次在一台 Oracle Linux 10.2 的雲端主機上執行時，結果是 36 項 <code>PASS</code>、12 項 <code>WARN</code>、7 項 <code>MANUAL</code>。12 項 <code>WARN</code> 依成因分成四種：</p>
<ul>
<li><strong>主機的實際狀態跟清單不符</strong>（8 項）：映像檔預設啟用的 <code>rpcbind</code>（111，TCP 與 UDP）與效能監測套件 PCP 的 <code>pmcd</code>（44321）、<code>pmlogger</code>（4330），在 IPv4 與 IPv6 的所有介面上接聽，它們不在清單預期的入口裡。從開發機對公開 IP 實測，TCP 的三個連接埠都連不上，UDP 111 用 <code>rpcinfo -u</code> 查詢得到逾時，雲端規則與主機防火牆都擋住了它們；開發機上測試用的容器映像檔沒有裝這幾個套件，所以在容器裡沒有出現。</li>
<li><strong>紀錄的保存方式跟預期不同</strong>（1 項）：腳本在 journal 裡找不到自動更新的執行紀錄，因為這台主機的 journal 只存在記憶體裡，在那次執行之前的重開機就清空了；成因與兩種狀態的差別見 <a href="/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</a>〈會累積的資料：寫入者、清理機制與磁碟寫滿的後果〉。</li>
<li><strong>門檻把預期中的紀錄算進去</strong>（2 項）：error log 裡那一行是 413 防護攔下過大的 body 時記下的正常紀錄，要改的是清單的計數條件。access log 的 <code>Host</code> 那一項有兩個成因疊在一起：部署腳本自己的 <a href="/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">冒煙測試</a> 送出 <code>Host: 127.0.0.1</code>，讓計數本來就不是零，冒煙測試這個成因要改清單的計數條件（排除主機自己送的請求）；那一次執行又用 <code>--set</code> 傳了一個帶 <code>^</code>、<code>$</code> 錨點的網域 regex，這一項計數的是 <code>Host</code> 不是自己網域的行數，而錨點被嵌進 pattern 的中間之後，沒有一行比對得到自己的網域，於是 log 裡全部 130 行都被算成不是自己網域的請求。錨點造成的那一部分是執行時傳入的值寫錯了，改用清單裡的值就消失。</li>
<li><strong>腳本自己的解析錯誤</strong>（1 項）：systemd 回傳的時間格式（<code>Mon 2026-10-26 07:07:43 GMT</code>）腳本讀不懂。</li>
</ul>
<p>主機的實際狀態與紀錄的保存方式，都是讀文件推不出、要在那台主機上量才知道的事；門檻的計數條件與腳本的解析錯誤，是清單與腳本第一次碰到真實資料時要修正的地方。四種成因的處置都不一樣，讀警告時，成因決定要改的是主機、清單還是腳本。這一次的處置是：部署腳本停用並遮蔽 <code>rpcbind</code> 與 PCP、把 journal 改存到磁碟，清單的兩個計數條件排除主機自己的請求與防護的正常紀錄，腳本補上 systemd 時間格式的解析。修正部署之後在同一台主機上再跑一次，結果是 41 項 <code>PASS</code>、1 項 <code>WARN</code>、7 項 <code>MANUAL</code>；剩下的那一項是自動更新的執行紀錄：自動更新上一次執行時，這台主機的 journal 還只存在記憶體裡，那一次的紀錄在之後的重開機被清掉，要等下一次排程執行才會在磁碟上的 journal 留下紀錄。</p>
]]></content:encoded></item><item><title>TOML</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/toml/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/toml/</guid><description>&lt;p>TOML（Tom&amp;rsquo;s Obvious, Minimal Language）是一種給人讀寫的設定檔格式：每一行是 &lt;code>鍵 = 值&lt;/code>，值的型別由寫法本身決定（字串要加引號、數字與布林值不加），用方括號的表頭把鍵分組，可以寫 &lt;code>#&lt;/code> 開頭的註解，沒有用縮排表示結構。規格由 toml.io 維護，1.0.0 在 2021 年 1 月發佈，1.1.0 在 2025 年 12 月發佈；Python 的 &lt;code>pyproject.toml&lt;/code>、Rust 的 &lt;code>Cargo.toml&lt;/code> 都用這個格式，跟 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env&lt;/a> 同樣是描述設定的檔案：比起 HCL，TOML 只描述資料、沒有變數與函式；比起 .env，TOML 有型別、分組與巢狀結構。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>TOML 在設定檔格式裡的位置介於 JSON 與 YAML 之間：跟 JSON 一樣每個值的型別都由寫法明確決定，跟 YAML 一樣可以寫註解、適合人手動編輯。它只描述資料，需要變數、迴圈與條件來描述基礎設施時用的是 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL&lt;/a> 這類設定語言。它在本站被用來寫主機的維護清單，選它的理由與實測見 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/" data-link-title="主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本" data-link-desc="把一台主機的維護、暫停與恢復上線項目寫成一份資料檔：這份清單對格式的需求、JSON、YAML、TOML 逐項比對（YAML 把 no 讀成 false、010 讀成 8 的實測，以及 TOML 照樣把 1.10 讀成 1.1）、TOML 的表格與表格陣列寫法、1.0 與 1.1 的解析器差異（Python tomllib 的 TOMLDecodeError）、欄位怎麼依檢查腳本與人的需要設計，以及只讀檢查腳本的白名單條件、輸出與 exit code">主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本&lt;/a>；Python 打包設定裡的 TOML 用法見 &lt;a href="https://tarrragon.github.io/blog/programming/python/advanced/07-packaging/pyproject-toml/" data-link-title="6.1 pyproject.toml 完整指南" data-link-desc="理解現代 Python 套件的設定標準">6.1 pyproject.toml 完整指南&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要讀懂 TOML 的情境包括：打開一個副檔名是 &lt;code>.toml&lt;/code> 的設定檔、要判斷某個值會被程式讀成字串還是數字、在 JSON、YAML、TOML 之間選設定檔格式，以及解析器回報 &lt;code>TOMLDecodeError&lt;/code> 這類錯誤、而同一份檔案在另一台機器上解析得過。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>TOML 的基本結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># 註解&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="nx">name&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;web-demo&amp;#34;&lt;/span> &lt;span class="c"># 字串一定要加引號&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nx">port&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">443&lt;/span> &lt;span class="c"># 整數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="nx">enabled&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="c"># 布林值只有 true 與 false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="nx">checked_at&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="ld">2026-10-26T06:21:00Z&lt;/span> &lt;span class="c"># 有時區的日期時間，解析成帶時區的時間物件&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">host&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="c"># 表格（table）：底下的鍵屬於 host&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="nx">provider&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;oracle-cloud&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">[[&lt;/span>&lt;span class="nx">entrypoint&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="c"># 表格陣列（array of tables）：每出現一次就新增一筆&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="nx">port&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">443&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="nx">exposure&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;cdn-only&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">[[&lt;/span>&lt;span class="nx">entrypoint&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="nx">port&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">22&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="nx">exposure&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;admin-only&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>&lt;code>[名稱]&lt;/code>&lt;/strong>：表格（table），指一組鍵值，等於程式裡的一個物件，跟試算表的格子無關；底下的鍵到下一個表頭為止都屬於它。&lt;/li>
&lt;li>&lt;strong>&lt;code>[[名稱]]&lt;/code>&lt;/strong>：表格陣列，每出現一次就在陣列裡加一筆，適合同一種項目要列很多筆的清單（入口、檢查項目、步驟）。&lt;/li>
&lt;li>&lt;strong>型別由寫法決定&lt;/strong>：&lt;code>&amp;quot;no&amp;quot;&lt;/code> 是字串，不加引號的 &lt;code>no&lt;/code> 不屬於任何型別，解析器報錯；YAML 1.1 的解析器（例如 PyYAML）把不加引號的 &lt;code>no&lt;/code> 讀成 &lt;code>false&lt;/code>（英文資料常稱為 Norway problem）、&lt;code>010&lt;/code> 讀成 8。寫法剛好屬於另一個型別時，TOML 一樣照那個型別讀：不加引號的 &lt;code>1.10&lt;/code> 是浮點數 1.1。所以版本號這類看起來像數字的字串要加引號。&lt;/li>
&lt;li>&lt;strong>日期與時間是原生型別&lt;/strong>：有時區的日期時間、沒有時區的日期時間、日期、時間各是一種型別，不必用字串存再自己解析。&lt;/li>
&lt;li>&lt;strong>巢狀的表格陣列&lt;/strong>：&lt;code>[[entrypoint.verify]]&lt;/code> 屬於它上面最近的那一筆 &lt;code>[[entrypoint]]&lt;/code>，讓每一筆項目各有自己的一組子項目。&lt;/li>
&lt;li>&lt;strong>寫在同一行的表格（inline table）&lt;/strong>：&lt;code>verify = { command = &amp;quot;...&amp;quot;, expect_stdout = &amp;quot;ok&amp;quot; }&lt;/code>，適合只有幾個欄位的子項目；TOML 1.0 要寫在一行內、結尾不能有逗號。&lt;/li>
&lt;li>&lt;strong>兩種字串&lt;/strong>：雙引號的基本字串處理反斜線跳脫（regex 的 &lt;code>\.&lt;/code> 寫成 &lt;code>\\.&lt;/code>），單引號的字面字串不處理任何跳脫（寫成 &lt;code>'\.'&lt;/code>）。&lt;/li>
&lt;li>&lt;strong>數字的分隔&lt;/strong>：整數可以用底線分隔位數，&lt;code>1_073_741_824&lt;/code> 等於 &lt;code>1073741824&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>跟其他格式的差別：&lt;/p></description><content:encoded><![CDATA[<p>TOML（Tom&rsquo;s Obvious, Minimal Language）是一種給人讀寫的設定檔格式：每一行是 <code>鍵 = 值</code>，值的型別由寫法本身決定（字串要加引號、數字與布林值不加），用方括號的表頭把鍵分組，可以寫 <code>#</code> 開頭的註解，沒有用縮排表示結構。規格由 toml.io 維護，1.0.0 在 2021 年 1 月發佈，1.1.0 在 2025 年 12 月發佈；Python 的 <code>pyproject.toml</code>、Rust 的 <code>Cargo.toml</code> 都用這個格式，跟 <a href="/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL</a> 與 <a href="/blog/devops/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a> 同樣是描述設定的檔案：比起 HCL，TOML 只描述資料、沒有變數與函式；比起 .env，TOML 有型別、分組與巢狀結構。</p>
<h2 id="概念位置">概念位置</h2>
<p>TOML 在設定檔格式裡的位置介於 JSON 與 YAML 之間：跟 JSON 一樣每個值的型別都由寫法明確決定，跟 YAML 一樣可以寫註解、適合人手動編輯。它只描述資料，需要變數、迴圈與條件來描述基礎設施時用的是 <a href="/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL</a> 這類設定語言。它在本站被用來寫主機的維護清單，選它的理由與實測見 <a href="/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/" data-link-title="主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本" data-link-desc="把一台主機的維護、暫停與恢復上線項目寫成一份資料檔：這份清單對格式的需求、JSON、YAML、TOML 逐項比對（YAML 把 no 讀成 false、010 讀成 8 的實測，以及 TOML 照樣把 1.10 讀成 1.1）、TOML 的表格與表格陣列寫法、1.0 與 1.1 的解析器差異（Python tomllib 的 TOMLDecodeError）、欄位怎麼依檢查腳本與人的需要設計，以及只讀檢查腳本的白名單條件、輸出與 exit code">主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本</a>；Python 打包設定裡的 TOML 用法見 <a href="/blog/programming/python/advanced/07-packaging/pyproject-toml/" data-link-title="6.1 pyproject.toml 完整指南" data-link-desc="理解現代 Python 套件的設定標準">6.1 pyproject.toml 完整指南</a>。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要讀懂 TOML 的情境包括：打開一個副檔名是 <code>.toml</code> 的設定檔、要判斷某個值會被程式讀成字串還是數字、在 JSON、YAML、TOML 之間選設定檔格式，以及解析器回報 <code>TOMLDecodeError</code> 這類錯誤、而同一份檔案在另一台機器上解析得過。</p>
<h2 id="設計責任">設計責任</h2>
<p>TOML 的基本結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 註解</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;web-demo&#34;</span>          <span class="c"># 字串一定要加引號</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">port</span> <span class="p">=</span> <span class="mi">443</span>                 <span class="c"># 整數</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nx">enabled</span> <span class="p">=</span> <span class="kc">true</span>             <span class="c"># 布林值只有 true 與 false</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nx">checked_at</span> <span class="p">=</span> <span class="ld">2026-10-26T06:21:00Z</span>   <span class="c"># 有時區的日期時間，解析成帶時區的時間物件</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">[</span><span class="nx">host</span><span class="p">]</span>                     <span class="c"># 表格（table）：底下的鍵屬於 host</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">provider</span> <span class="p">=</span> <span class="s2">&#34;oracle-cloud&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">[[</span><span class="nx">entrypoint</span><span class="p">]]</span>             <span class="c"># 表格陣列（array of tables）：每出現一次就新增一筆</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nx">port</span> <span class="p">=</span> <span class="mi">443</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nx">exposure</span> <span class="p">=</span> <span class="s2">&#34;cdn-only&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">[[</span><span class="nx">entrypoint</span><span class="p">]]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">port</span> <span class="p">=</span> <span class="mi">22</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nx">exposure</span> <span class="p">=</span> <span class="s2">&#34;admin-only&#34;</span></span></span></code></pre></div><ul>
<li><strong><code>[名稱]</code></strong>：表格（table），指一組鍵值，等於程式裡的一個物件，跟試算表的格子無關；底下的鍵到下一個表頭為止都屬於它。</li>
<li><strong><code>[[名稱]]</code></strong>：表格陣列，每出現一次就在陣列裡加一筆，適合同一種項目要列很多筆的清單（入口、檢查項目、步驟）。</li>
<li><strong>型別由寫法決定</strong>：<code>&quot;no&quot;</code> 是字串，不加引號的 <code>no</code> 不屬於任何型別，解析器報錯；YAML 1.1 的解析器（例如 PyYAML）把不加引號的 <code>no</code> 讀成 <code>false</code>（英文資料常稱為 Norway problem）、<code>010</code> 讀成 8。寫法剛好屬於另一個型別時，TOML 一樣照那個型別讀：不加引號的 <code>1.10</code> 是浮點數 1.1。所以版本號這類看起來像數字的字串要加引號。</li>
<li><strong>日期與時間是原生型別</strong>：有時區的日期時間、沒有時區的日期時間、日期、時間各是一種型別，不必用字串存再自己解析。</li>
<li><strong>巢狀的表格陣列</strong>：<code>[[entrypoint.verify]]</code> 屬於它上面最近的那一筆 <code>[[entrypoint]]</code>，讓每一筆項目各有自己的一組子項目。</li>
<li><strong>寫在同一行的表格（inline table）</strong>：<code>verify = { command = &quot;...&quot;, expect_stdout = &quot;ok&quot; }</code>，適合只有幾個欄位的子項目；TOML 1.0 要寫在一行內、結尾不能有逗號。</li>
<li><strong>兩種字串</strong>：雙引號的基本字串處理反斜線跳脫（regex 的 <code>\.</code> 寫成 <code>\\.</code>），單引號的字面字串不處理任何跳脫（寫成 <code>'\.'</code>）。</li>
<li><strong>數字的分隔</strong>：整數可以用底線分隔位數，<code>1_073_741_824</code> 等於 <code>1073741824</code>。</li>
</ul>
<p>跟其他格式的差別：</p>
<table>
  <thead>
      <tr>
          <th>格式</th>
          <th>註解</th>
          <th>型別怎麼決定</th>
          <th>結構怎麼表示</th>
          <th>適合的場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JSON</td>
          <td>不能</td>
          <td>寫法決定，規則嚴格</td>
          <td>大括號與中括號</td>
          <td>程式之間交換資料</td>
      </tr>
      <tr>
          <td>YAML</td>
          <td>能</td>
          <td>不加引號的值由解析器推測（版本之間規則不同）</td>
          <td>縮排</td>
          <td>巢狀很深的設定（Kubernetes、CI 的 workflow）</td>
      </tr>
      <tr>
          <td>TOML</td>
          <td>能</td>
          <td>寫法決定，不加引號又不是合法值時報錯</td>
          <td>表頭與表格陣列</td>
          <td>人手動維護、層數不深的設定與清單</td>
      </tr>
  </tbody>
</table>
<p><strong>版本對解析器的影響</strong>：1.1.0 新增的語法（寫在同一行的表格 inline table 可以跨行並在結尾加逗號、<code>\xHH</code> 與 <code>\e</code> 跳脫、時間可以省略秒）只有支援 1.1 的解析器讀得懂。Python 的標準函式庫 <code>tomllib</code> 在 3.11 加入時支援 1.0，3.15 起支援 1.1，3.10 以下沒有 <code>tomllib</code>、要另裝 <code>tomli</code>。同一份用了 1.1 語法的檔案，在 Python 3.15 解析得過，在 3.12 會報錯；版本對不上時的兩種結果與寫檔時的對策，見 <a href="/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/" data-link-title="主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本" data-link-desc="把一台主機的維護、暫停與恢復上線項目寫成一份資料檔：這份清單對格式的需求、JSON、YAML、TOML 逐項比對（YAML 把 no 讀成 false、010 讀成 8 的實測，以及 TOML 照樣把 1.10 讀成 1.1）、TOML 的表格與表格陣列寫法、1.0 與 1.1 的解析器差異（Python tomllib 的 TOMLDecodeError）、欄位怎麼依檢查腳本與人的需要設計，以及只讀檢查腳本的白名單條件、輸出與 exit code">主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本</a>〈TOML 1.0 與 1.1 的差異與解析器的版本〉。<code>tomllib</code> 只能讀不能寫，要由程式寫出 TOML 時需要第三方套件。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a></li>
</ul>
]]></content:encoded></item><item><title>YAML</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/yaml/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/yaml/</guid><description>&lt;p>YAML（YAML Ain&amp;rsquo;t Markup Language）是一種用縮排表示結構的資料格式：&lt;code>鍵: 值&lt;/code> 組成對應表，&lt;code>- &lt;/code> 開頭的行組成清單，子項目往右縮排一層，可以寫 &lt;code>#&lt;/code> 開頭的註解，不加引號的值由解析器依寫法推測型別。Kubernetes 的 manifest、GitHub Actions 的 workflow、Ansible 的 playbook 與 inventory、Docker Compose 的設定檔都用這個格式（Ansible 的變數檔一樣受下面的隱式型別影響，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">Configuration Management（組態管理）&lt;/a>）。同樣是給人手動編輯的設定檔格式，&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML&lt;/a> 用表頭分組、型別完全由寫法決定；&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL&lt;/a> 另外有變數與函式，YAML 與 TOML 一樣只描述資料。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>YAML 在設定檔格式裡的位置是巢狀深、層數多的設定：縮排一層就是一層結構，寫到第五、六層仍然不必重複上層的名字，這是 Kubernetes 與 CI 工具選它的理由，&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/helm/" data-link-title="Helm" data-link-desc="Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源的部署定義">Helm&lt;/a> 的 chart 參數檔 &lt;code>values.yaml&lt;/code> 也是這個格式。付出的代價在型別：不加引號的值是什麼型別，由解析器實作的 YAML 版本決定，而一份檔案自己沒有辦法指定讀它的解析器。它跟 TOML、JSON 逐項比對的需求表與實測，見 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/" data-link-title="主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本" data-link-desc="把一台主機的維護、暫停與恢復上線項目寫成一份資料檔：這份清單對格式的需求、JSON、YAML、TOML 逐項比對（YAML 把 no 讀成 false、010 讀成 8 的實測，以及 TOML 照樣把 1.10 讀成 1.1）、TOML 的表格與表格陣列寫法、1.0 與 1.1 的解析器差異（Python tomllib 的 TOMLDecodeError）、欄位怎麼依檢查腳本與人的需要設計，以及只讀檢查腳本的白名單條件、輸出與 exit code">主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本&lt;/a>〈主機維護清單對檔案格式的需求：JSON、YAML、TOML 的比對〉。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要讀懂 YAML 的情境包括：打開一個副檔名是 &lt;code>.yaml&lt;/code> 或 &lt;code>.yml&lt;/code> 的檔案、程式讀到的值跟檔案上寫的字不一樣（寫 &lt;code>no&lt;/code> 讀成 &lt;code>false&lt;/code>、寫 &lt;code>010&lt;/code> 讀成 8）、同一份檔案在兩個工具裡讀出不同的結果，以及解析器回報 &lt;code>ScannerError&lt;/code> 或 &lt;code>found character '\t' that cannot start any token&lt;/code> 這類縮排錯誤。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>YAML 的基本結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># 註解&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">web-demo &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 對應表的一個鍵；不加引號的 web-demo 讀成字串&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 讀成整數&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">entrypoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 值是一份清單&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">443&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 清單的每一項以 &amp;#34;- &amp;#34; 開頭，項目本身可以是對應表&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exposure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cdn-only&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">22&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exposure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">admin-only&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">note&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">| &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># | 保留換行的多行字串&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="l">第一行&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="l">第二行&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>縮排就是結構&lt;/strong>：同一層的鍵左側對齊，子項目多縮排一層；縮排只能用空格，Tab 字元會讓解析器回報 &lt;code>ScannerError&lt;/code>。&lt;/li>
&lt;li>&lt;strong>不加引號的值由解析器推測型別&lt;/strong>：這是 YAML 最常讓設定出錯的位置，見下面的版本差異。字串加上引號（&lt;code>&amp;quot;no&amp;quot;&lt;/code>、&lt;code>'010'&lt;/code>）就不會被推測。&lt;/li>
&lt;li>&lt;strong>重複的鍵不報錯&lt;/strong>：PyYAML 6.0.3 解析 &lt;code>a: 1&lt;/code> 接著 &lt;code>a: 2&lt;/code>，得到 &lt;code>{'a': 2}&lt;/code>，前一個值被安靜地蓋掉；手動維護的長檔案裡重複一個鍵，前面那一筆設定就不生效。&lt;/li>
&lt;li>&lt;strong>錨點與別名&lt;/strong>：&lt;code>&amp;amp;名稱&lt;/code> 標記一個節點、&lt;code>*名稱&lt;/code> 引用它，用來在同一份檔案裡重用一段設定；讀的人要回頭找錨點才知道別名展開成什麼。&lt;/li>
&lt;li>&lt;strong>一個檔案可以有多份文件&lt;/strong>：&lt;code>---&lt;/code> 分隔多份文件，Kubernetes 常用這個寫法把多個資源放在同一個檔案裡。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>版本決定型別的推測規則&lt;/strong>：YAML 1.1（2005 年）與 1.2（2009 年）對不加引號的值規則不同。同樣這五行：&lt;/p></description><content:encoded><![CDATA[<p>YAML（YAML Ain&rsquo;t Markup Language）是一種用縮排表示結構的資料格式：<code>鍵: 值</code> 組成對應表，<code>- </code> 開頭的行組成清單，子項目往右縮排一層，可以寫 <code>#</code> 開頭的註解，不加引號的值由解析器依寫法推測型別。Kubernetes 的 manifest、GitHub Actions 的 workflow、Ansible 的 playbook 與 inventory、Docker Compose 的設定檔都用這個格式（Ansible 的變數檔一樣受下面的隱式型別影響，見 <a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">Configuration Management（組態管理）</a>）。同樣是給人手動編輯的設定檔格式，<a href="/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML</a> 用表頭分組、型別完全由寫法決定；<a href="/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL</a> 另外有變數與函式，YAML 與 TOML 一樣只描述資料。</p>
<h2 id="概念位置">概念位置</h2>
<p>YAML 在設定檔格式裡的位置是巢狀深、層數多的設定：縮排一層就是一層結構，寫到第五、六層仍然不必重複上層的名字，這是 Kubernetes 與 CI 工具選它的理由，<a href="/blog/devops/infra/knowledge-cards/helm/" data-link-title="Helm" data-link-desc="Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源的部署定義">Helm</a> 的 chart 參數檔 <code>values.yaml</code> 也是這個格式。付出的代價在型別：不加引號的值是什麼型別，由解析器實作的 YAML 版本決定，而一份檔案自己沒有辦法指定讀它的解析器。它跟 TOML、JSON 逐項比對的需求表與實測，見 <a href="/blog/devops/operations/09-host-lifecycle/host-manifest-in-toml/" data-link-title="主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本" data-link-desc="把一台主機的維護、暫停與恢復上線項目寫成一份資料檔：這份清單對格式的需求、JSON、YAML、TOML 逐項比對（YAML 把 no 讀成 false、010 讀成 8 的實測，以及 TOML 照樣把 1.10 讀成 1.1）、TOML 的表格與表格陣列寫法、1.0 與 1.1 的解析器差異（Python tomllib 的 TOMLDecodeError）、欄位怎麼依檢查腳本與人的需要設計，以及只讀檢查腳本的白名單條件、輸出與 exit code">主機維護清單寫成 TOML：JSON、YAML、TOML 的取捨、TOML 1.0 與 1.1 的版本差異、清單的欄位設計與只讀檢查腳本</a>〈主機維護清單對檔案格式的需求：JSON、YAML、TOML 的比對〉。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要讀懂 YAML 的情境包括：打開一個副檔名是 <code>.yaml</code> 或 <code>.yml</code> 的檔案、程式讀到的值跟檔案上寫的字不一樣（寫 <code>no</code> 讀成 <code>false</code>、寫 <code>010</code> 讀成 8）、同一份檔案在兩個工具裡讀出不同的結果，以及解析器回報 <code>ScannerError</code> 或 <code>found character '\t' that cannot start any token</code> 這類縮排錯誤。</p>
<h2 id="設計責任">設計責任</h2>
<p>YAML 的基本結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 註解</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">web-demo           </span><span class="w"> </span><span class="c"># 對應表的一個鍵；不加引號的 web-demo 讀成字串</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">443</span><span class="w">                 </span><span class="c"># 讀成整數</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">entrypoints</span><span class="p">:</span><span class="w">              </span><span class="c"># 值是一份清單</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">443</span><span class="w">             </span><span class="c"># 清單的每一項以 &#34;- &#34; 開頭，項目本身可以是對應表</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">exposure</span><span class="p">:</span><span class="w"> </span><span class="l">cdn-only</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">22</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">exposure</span><span class="p">:</span><span class="w"> </span><span class="l">admin-only</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="nt">note</span><span class="p">:</span><span class="w"> </span><span class="l">|                  </span><span class="w"> </span><span class="c"># | 保留換行的多行字串</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="l">第一行</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="l">第二行</span></span></span></code></pre></div><ul>
<li><strong>縮排就是結構</strong>：同一層的鍵左側對齊，子項目多縮排一層；縮排只能用空格，Tab 字元會讓解析器回報 <code>ScannerError</code>。</li>
<li><strong>不加引號的值由解析器推測型別</strong>：這是 YAML 最常讓設定出錯的位置，見下面的版本差異。字串加上引號（<code>&quot;no&quot;</code>、<code>'010'</code>）就不會被推測。</li>
<li><strong>重複的鍵不報錯</strong>：PyYAML 6.0.3 解析 <code>a: 1</code> 接著 <code>a: 2</code>，得到 <code>{'a': 2}</code>，前一個值被安靜地蓋掉；手動維護的長檔案裡重複一個鍵，前面那一筆設定就不生效。</li>
<li><strong>錨點與別名</strong>：<code>&amp;名稱</code> 標記一個節點、<code>*名稱</code> 引用它，用來在同一份檔案裡重用一段設定；讀的人要回頭找錨點才知道別名展開成什麼。</li>
<li><strong>一個檔案可以有多份文件</strong>：<code>---</code> 分隔多份文件，Kubernetes 常用這個寫法把多個資源放在同一個檔案裡。</li>
</ul>
<p><strong>版本決定型別的推測規則</strong>：YAML 1.1（2005 年）與 1.2（2009 年）對不加引號的值規則不同。同樣這五行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">country</span><span class="p">:</span><span class="w"> </span><span class="kc">no</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="m">010</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="nt">port_map</span><span class="p">:</span><span class="w"> </span><span class="m">22</span><span class="p">:</span><span class="m">22</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1.10</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div><p>交給兩個 Python 解析器，結果是：</p>
<table>
  <thead>
      <tr>
          <th>這一行</th>
          <th>PyYAML 6.0.3（實作 YAML 1.1）</th>
          <th>ruamel.yaml 0.19.1（實作 YAML 1.2）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>country: no</code></td>
          <td>布林值 <code>False</code></td>
          <td>字串 <code>'no'</code></td>
      </tr>
      <tr>
          <td><code>mode: 010</code></td>
          <td>八進位的 <code>8</code></td>
          <td>十進位的 <code>10</code></td>
      </tr>
      <tr>
          <td><code>port_map: 22:22</code></td>
          <td>六十進位的 <code>1342</code></td>
          <td>字串 <code>'22:22'</code></td>
      </tr>
      <tr>
          <td><code>version: 1.10</code></td>
          <td>浮點數 <code>1.1</code></td>
          <td>浮點數 <code>1.1</code></td>
      </tr>
      <tr>
          <td><code>on: true</code>（鍵名）</td>
          <td>鍵變成布林值 <code>True</code></td>
          <td>鍵是字串 <code>'on'</code></td>
      </tr>
  </tbody>
</table>
<p>兩個解析器都沒有報錯。<code>no</code> 被讀成 <code>false</code> 這一種，英文資料常稱為 Norway problem（挪威的國家代碼 <code>NO</code> 變成布林值）。YAML 1.2 把布林值限定成 <code>true</code> 與 <code>false</code> 及其大小寫變體，所以 1.1 的幾個陷阱消失；<code>1.10</code> 在兩個版本都是合法的浮點數，所以版本號不論哪一版都要加引號。一份 YAML 檔被讀成哪一版，由讀它的程式選的解析器決定：PyYAML 至今實作 1.1；Go 的 <code>gopkg.in/yaml.v3</code> 依它的 README 以 1.2 為主，而解碼到布林型別的欄位時仍接受 1.1 的 <code>yes</code>、<code>no</code>、<code>on</code>、<code>off</code>（這一點照文件，沒有實跑），所以寫檔的人無法只看檔案判斷結果，跨工具共用的值一律加引號。這張表在 <code>python:3.12-slim</code> 容器裡以兩個套件的 <code>safe_load</code> 實跑。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/hcl/" data-link-title="HCL" data-link-desc="Terraform 使用的宣告式設定語言，用 resource block 描述基礎設施的目標狀態，工具負責收斂差異">HCL</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/helm/" data-link-title="Helm" data-link-desc="Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源的部署定義">Helm</a></li>
</ul>
]]></content:encoded></item><item><title>Configuration Management（組態管理）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/</guid><description>&lt;p>組態管理（configuration management；其他中文資料也寫作設定管理）在主機維運的語境裡，指用工具把一群主機「內部」的狀態寫成宣告，再由工具把每台主機帶到那個狀態：要裝哪些套件、設定檔的內容、哪些服務要啟用、哪些使用者與權限要存在。Ansible、Puppet、Chef、Salt 是這一類工具。它跟 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">Infrastructure as Code (IaC)&lt;/a> 的分界在管理對象：IaC 管的是雲端業者那一端的資源（主機本身、網路、防火牆規則、DNS 紀錄），組態管理管的是主機開機之後作業系統裡面的套件、設定檔與服務；兩者常一起用，IaC 建出主機，組態管理把主機設定好。&lt;/p>
&lt;p>同名而所指不同的兩個詞：軟體工程裡的軟體組態管理（software configuration management，SCM）指原始碼的版本控制、基線與變更控制；中國大陸工業控制業界的「組態」（组态软件）指在 SCADA 或 HMI 軟體裡設定畫面與點位。本卡講的是主機設定的管理工具。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>組態管理工具都有兩個部分：主機清冊，與每台主機要套用的內容。Ansible 把主機清冊叫 inventory（列出受管主機與分組的檔案），跟商業領域的存貨（inventory）同字而無關；要套用的內容寫在 playbook（依序列出要對哪些主機套用哪些模組的 YAML 檔）與 role（把一類主機要用的任務、範本與變數收成一個可重用的目錄）裡，這個 playbook 跟事故處理的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/playbook/" data-link-title="Playbook" data-link-desc="說明場景化處置腳本如何降低事故處理不確定性">Playbook&lt;/a> 同名而所指不同。inventory 把主機分成群組（&lt;code>web&lt;/code>、&lt;code>db&lt;/code>），變數依層次放在 &lt;code>group_vars/&amp;lt;群組&amp;gt;.yml&lt;/code> 與 &lt;code>host_vars/&amp;lt;主機&amp;gt;.yml&lt;/code>，一台主機最後拿到的值是各層合併的結果，合併時越具體的層越優先：&lt;code>host_vars&lt;/code> 蓋過群組的值，子群組蓋過 &lt;code>all&lt;/code>。一台主機同時屬於兩個同層的群組、而兩個群組給了同一個變數不同的值時，依群組名稱的字母順序，排在後面的蓋過前面的；要指定誰優先，在 inventory 檔本身替群組設 &lt;code>ansible_group_priority&lt;/code>（預設 1，數字大的較晚合併而勝出），寫在 &lt;code>group_vars&lt;/code> 檔裡沒有效果；優先順序只會讓其中一組勝出，兼任兩種角色的主機需要兩組值都生效時，要讓兩個角色用不同的變數名、在範本裡取聯集。這幾條用 ansible-core 2.17 實測過，&lt;code>ansible-inventory --host &amp;lt;主機&amp;gt;&lt;/code> 印得出合併後的值，實測的檔案與輸出見 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/multi-host-manifest/" data-link-title="多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍" data-link-desc="把單台的主機維護清單擴充到多台主機：每台相同、依角色不同、每台不同的值怎麼分層，合併放在部署時產生每台一份、由檢查腳本讀多份、依角色過濾還是執行時覆寫，用 Ansible 的 inventory、群組變數與範本產生 TOML 清單時的變數合併、兼任角色、型別、語法撞名、跳脫與產生時的驗證，由 Terraform 的輸出產生 inventory、terraform plan 能取代哪些主控台上的確認，對多台執行檢查腳本時 exit code 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍&lt;/a>。inventory 與 &lt;code>group_vars&lt;/code> 是 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML&lt;/a>，所以 YAML 1.1 的隱式型別照樣作用：&lt;code>country: no&lt;/code> 讀出來是布林值 &lt;code>false&lt;/code>。&lt;/p>
&lt;p>主機清冊可以手寫，也可以由 IaC 產生：Terraform 知道它建的每台主機的 IP 與角色，可以用 &lt;code>terraform output -json&lt;/code> 輸出給別的工具讀，或用 &lt;code>local_file&lt;/code> 這類資源直接寫出一份 inventory 檔。那份檔案是 Terraform 管理的一個資源，它的內容記在 Terraform 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）&lt;/a> 裡，手動改了它，下一次 &lt;code>terraform plan&lt;/code> 會要求重建、&lt;code>apply&lt;/code> 會蓋掉手改的內容。由 &lt;code>local_file&lt;/code> 寫出 inventory、經由 SSH 部署到兩台容器並執行檢查的實際操作，以及 &lt;code>file_permission&lt;/code> 的預設值扣掉 umask 之後的實際權限，在 &lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/multi-host-manifest-walkthrough/" data-link-title="多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理" data-link-desc="要照著部署多台主機的維護清單並執行檢查腳本，或要讀跨主機執行時 ansible 的 exit code 與每台的 rc、門檻造成的 WARN、清單格式錯與連不上的主機各自印出什麼時，或要確認 Terraform 寫出的 inventory 的檔案權限、`--check` 預覽與實際部署結果不同的情形時，查在可拋棄的容器上實際跑過的指令、輸出與遇到的錯誤">多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理&lt;/a>〈由 Terraform 產生 inventory：init、plan、apply 與 local_file 的檔案權限〉。雲端資源的實際狀態跟 IaC 記錄的不一致叫 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift（設定漂移）&lt;/a>；主機裡的套件與設定檔跟組態管理的宣告不一致，有人同樣叫它 configuration drift，比對的兩邊換成了組態管理的宣告與主機內部的狀態；組態管理工具在下一次套用時把主機帶回宣告的狀態，兩次套用之間有人登入手改的部分，在那之前一直存在。&lt;/p>
&lt;p>Ansible 由執行指令的那台機器經由 SSH 連到每台主機，把要執行的模組送過去執行，主機上不必裝常駐的代理程式（agent）；Puppet 與 Chef 傳統上在每台主機跑一個代理程式，定期向伺服器拉取該套用的內容（Puppet 與 Chef 的運作方式取自兩者的文件，沒有實測）。Ansible 這種推送的做法由操作的人或排程決定什麼時候套用；代理程式拉取的做法讓主機自己定期回到宣告的狀態。&lt;/p></description><content:encoded><![CDATA[<p>組態管理（configuration management；其他中文資料也寫作設定管理）在主機維運的語境裡，指用工具把一群主機「內部」的狀態寫成宣告，再由工具把每台主機帶到那個狀態：要裝哪些套件、設定檔的內容、哪些服務要啟用、哪些使用者與權限要存在。Ansible、Puppet、Chef、Salt 是這一類工具。它跟 <a href="/blog/devops/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">Infrastructure as Code (IaC)</a> 的分界在管理對象：IaC 管的是雲端業者那一端的資源（主機本身、網路、防火牆規則、DNS 紀錄），組態管理管的是主機開機之後作業系統裡面的套件、設定檔與服務；兩者常一起用，IaC 建出主機，組態管理把主機設定好。</p>
<p>同名而所指不同的兩個詞：軟體工程裡的軟體組態管理（software configuration management，SCM）指原始碼的版本控制、基線與變更控制；中國大陸工業控制業界的「組態」（组态软件）指在 SCADA 或 HMI 軟體裡設定畫面與點位。本卡講的是主機設定的管理工具。</p>
<h2 id="概念位置">概念位置</h2>
<p>組態管理工具都有兩個部分：主機清冊，與每台主機要套用的內容。Ansible 把主機清冊叫 inventory（列出受管主機與分組的檔案），跟商業領域的存貨（inventory）同字而無關；要套用的內容寫在 playbook（依序列出要對哪些主機套用哪些模組的 YAML 檔）與 role（把一類主機要用的任務、範本與變數收成一個可重用的目錄）裡，這個 playbook 跟事故處理的 <a href="/blog/backend/knowledge-cards/playbook/" data-link-title="Playbook" data-link-desc="說明場景化處置腳本如何降低事故處理不確定性">Playbook</a> 同名而所指不同。inventory 把主機分成群組（<code>web</code>、<code>db</code>），變數依層次放在 <code>group_vars/&lt;群組&gt;.yml</code> 與 <code>host_vars/&lt;主機&gt;.yml</code>，一台主機最後拿到的值是各層合併的結果，合併時越具體的層越優先：<code>host_vars</code> 蓋過群組的值，子群組蓋過 <code>all</code>。一台主機同時屬於兩個同層的群組、而兩個群組給了同一個變數不同的值時，依群組名稱的字母順序，排在後面的蓋過前面的；要指定誰優先，在 inventory 檔本身替群組設 <code>ansible_group_priority</code>（預設 1，數字大的較晚合併而勝出），寫在 <code>group_vars</code> 檔裡沒有效果；優先順序只會讓其中一組勝出，兼任兩種角色的主機需要兩組值都生效時，要讓兩個角色用不同的變數名、在範本裡取聯集。這幾條用 ansible-core 2.17 實測過，<code>ansible-inventory --host &lt;主機&gt;</code> 印得出合併後的值，實測的檔案與輸出見 <a href="/blog/devops/operations/09-host-lifecycle/multi-host-manifest/" data-link-title="多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍" data-link-desc="把單台的主機維護清單擴充到多台主機：每台相同、依角色不同、每台不同的值怎麼分層，合併放在部署時產生每台一份、由檢查腳本讀多份、依角色過濾還是執行時覆寫，用 Ansible 的 inventory、群組變數與範本產生 TOML 清單時的變數合併、兼任角色、型別、語法撞名、跳脫與產生時的驗證，由 Terraform 的輸出產生 inventory、terraform plan 能取代哪些主控台上的確認，對多台執行檢查腳本時 exit code 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍</a>。inventory 與 <code>group_vars</code> 是 <a href="/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML</a>，所以 YAML 1.1 的隱式型別照樣作用：<code>country: no</code> 讀出來是布林值 <code>false</code>。</p>
<p>主機清冊可以手寫，也可以由 IaC 產生：Terraform 知道它建的每台主機的 IP 與角色，可以用 <code>terraform output -json</code> 輸出給別的工具讀，或用 <code>local_file</code> 這類資源直接寫出一份 inventory 檔。那份檔案是 Terraform 管理的一個資源，它的內容記在 Terraform 的 <a href="/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）</a> 裡，手動改了它，下一次 <code>terraform plan</code> 會要求重建、<code>apply</code> 會蓋掉手改的內容。由 <code>local_file</code> 寫出 inventory、經由 SSH 部署到兩台容器並執行檢查的實際操作，以及 <code>file_permission</code> 的預設值扣掉 umask 之後的實際權限，在 <a href="/blog/devops/operations/09-host-lifecycle/hands-on/multi-host-manifest-walkthrough/" data-link-title="多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理" data-link-desc="要照著部署多台主機的維護清單並執行檢查腳本，或要讀跨主機執行時 ansible 的 exit code 與每台的 rc、門檻造成的 WARN、清單格式錯與連不上的主機各自印出什麼時，或要確認 Terraform 寫出的 inventory 的檔案權限、`--check` 預覽與實際部署結果不同的情形時，查在可拋棄的容器上實際跑過的指令、輸出與遇到的錯誤">多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理</a>〈由 Terraform 產生 inventory：init、plan、apply 與 local_file 的檔案權限〉。雲端資源的實際狀態跟 IaC 記錄的不一致叫 <a href="/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift（設定漂移）</a>；主機裡的套件與設定檔跟組態管理的宣告不一致，有人同樣叫它 configuration drift，比對的兩邊換成了組態管理的宣告與主機內部的狀態；組態管理工具在下一次套用時把主機帶回宣告的狀態，兩次套用之間有人登入手改的部分，在那之前一直存在。</p>
<p>Ansible 由執行指令的那台機器經由 SSH 連到每台主機，把要執行的模組送過去執行，主機上不必裝常駐的代理程式（agent）；Puppet 與 Chef 傳統上在每台主機跑一個代理程式，定期向伺服器拉取該套用的內容（Puppet 與 Chef 的運作方式取自兩者的文件，沒有實測）。Ansible 這種推送的做法由操作的人或排程決定什麼時候套用；代理程式拉取的做法讓主機自己定期回到宣告的狀態。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<ul>
<li>repo 裡有 <code>inventory.yml</code>（或 <code>hosts.ini</code>）、<code>group_vars/</code>、<code>host_vars/</code>、<code>playbook.yml</code>、<code>roles/</code> 這類目錄。</li>
<li>維護的人改一台主機的設定時，改的是 repo 裡的變數或範本，再對那一組主機執行一次，不登入主機手改。</li>
<li>同一個設定檔在每台主機上的內容只有主機名、IP、角色相關的幾行不同。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<ul>
<li><strong>每個值放在哪一層</strong>：所有主機都相同的放 <code>all</code>，依角色不同的放群組，只有一台不同的放 <code>host_vars</code>。同一個值在兩層都寫了時，讀檔的人要知道合併規則才知道哪一個生效；對哪一個值生效有疑問時，用 <code>ansible-inventory --host</code> 查合併後的值。</li>
<li><strong>套用要能重複執行</strong>：每個步驟執行第二次時不改變結果（<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">Idempotency</a>），排程定期套用時才不會每次都重啟服務或重寫檔案。Ansible 的模組多半這樣設計，<code>command</code> 與 <code>shell</code> 模組例外：它們每次都執行、每次都回報 changed，要用 <code>creates</code>、<code>removes</code> 或 <code>changed_when</code> 寫出什麼情形下不必執行、什麼結果算有改變。</li>
<li><strong>範本的語法與產出的格式分開</strong>：Ansible 的範本是 Jinja2，<code>{{ }}</code> 是它的替換語法；產出的檔案自己也用 <code>{{ }}</code> 時（例如另一支程式的變數替換），那一段要包在 <code>{% raw %}</code> 裡，否則 Jinja2 會先替換它。值寫進 TOML、JSON 這類有引號規則的格式時，用 <code>to_json</code> 過濾器輸出（含非 ASCII 字元時加 <code>ensure_ascii=False</code>），引號與反斜線才會被正確跳脫；直接輸出時，Python 的布林值寫成 <code>False</code>，字串不帶引號，含反斜線的字串陣列被寫成兩個反斜線，TOML 解析失敗或讀到被改掉的值。</li>
<li><strong>主機清冊的唯一來源</strong>：主機由 IaC 建立時，inventory 由 IaC 的輸出產生，不另外手寫一份；兩份各自維護時，新增或刪除主機只改了其中一份，另一份就指著不存在的主機，或漏掉新的主機。</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/devops/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">Infrastructure as Code (IaC)</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift（設定漂移）</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML</a></li>
<li><a href="/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）</a></li>
</ul>
]]></content:encoded></item></channel></rss>