<?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>Maintenance on Tarragon</title><link>https://tarrragon.github.io/blog/tags/maintenance/</link><description>Recent content in Maintenance on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 27 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/maintenance/index.xml" rel="self" type="application/rss+xml"/><item><title>多台主機維護清單的實作紀錄：在容器上安裝 ansible-core 與 Terraform、由 Terraform 產生 inventory、用 Ansible 的範本部署每台的清單、以 sudo 與 systemd-run 執行檢查腳本，以及清理</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/multi-host-manifest-walkthrough/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/multi-host-manifest-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-26，在一台 Apple Silicon 的 macOS 開發機上，用 OrbStack 提供的 Docker Engine 29.8.2（核心 &lt;code>7.2.12-orbstack&lt;/code>，arm64，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cgroup/" data-link-title="cgroup（控制群組）" data-link-desc="設定容器的 CPU 與記憶體上限、讀 cAdvisor 的指標、或查容器為什麼被暫停與結束時，查 cgroup 是什麼、v1 與 v2 的差別，以及 Docker 與 Kubernetes 的參數各自對應到它的哪個控制檔">cgroup（控制群組）&lt;/a> v2）建立可拋棄的容器，把多台主機的維護清單從 Terraform 產生 inventory、用 Ansible 部署到兩台主機並執行檢查腳本。受管主機（managed node，Ansible 連過去執行任務的主機）是兩個 Oracle Linux 10.2 容器（systemd 257 當 PID 1，OpenSSH 9.9p1、Python 3.12.15、sudo 1.9.17、dnf-automatic 4.20.0），以 &lt;code>--privileged --cgroupns=private&lt;/code> 執行。控制端（control node，執行 &lt;code>ansible&lt;/code> 與 Terraform 指令的機器）是一個 &lt;code>python:3.12-slim&lt;/code> 容器（Debian 13，OpenSSH 用戶端 10.0p2），裝 ansible-core 2.17.7（Jinja2 3.1.6）與 Terraform 1.9.8，Terraform 的 &lt;code>hashicorp/local&lt;/code> provider（Terraform 操作某一類資源的外掛）是 2.5.2。檢查腳本是 &lt;code>host-check.py&lt;/code> 在 2026-10-26 的版本（作者私有專案的 commit &lt;code>3fc41c0&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>。&lt;/p>&lt;/blockquote>
&lt;p>這篇把 &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> 的做法在兩台可拋棄的受管主機上從安裝走到清理，一台只當 web、另一台同時是 web 與 db。各節依序是：&lt;/p>
&lt;ul>
&lt;li>受管主機與控制端的容器怎麼建、裝了哪些套件。&lt;/li>
&lt;li>控制端經由 SSH 登入受管主機用的金鑰、&lt;code>known_hosts&lt;/code> 與 sudo。&lt;/li>
&lt;li>Terraform 由主機清單寫出 inventory（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;code>local_file&lt;/code> 的預設檔案權限。&lt;/li>
&lt;li>變數檔與範本照檢查腳本實際要求的欄位寫，兼任兩種角色的主機怎麼取得兩個角色的入口。&lt;/li>
&lt;li>用 playbook（依序執行多個任務的 YAML 檔）部署檢查腳本與每台的清單：&lt;code>validate&lt;/code> 改用檢查腳本自己的載入函式、在寫出之前就驗出欄位錯誤，以及 &lt;code>--check&lt;/code> 的預覽與實際執行結果不同的那一次。&lt;/li>
&lt;li>以 &lt;code>-b&lt;/code> 與 &lt;code>systemd-run&lt;/code> 對所有主機執行檢查腳本，每台的輸出與 &lt;code>ansible&lt;/code> 指令的 exit code。&lt;/li>
&lt;li>刻意讓一台的門檻變成 &lt;code>WARN&lt;/code>、讓一台的清單格式錯、讓一台連不上時，&lt;code>ansible&lt;/code> 的 exit code 與每台的 &lt;code>rc&lt;/code> 各是什麼。&lt;/li>
&lt;li>容器跟真主機不同的地方，以及在真主機上要另外確認的項目。&lt;/li>
&lt;li>清理。&lt;/li>
&lt;/ul>
&lt;p>值為什麼分成每台相同、依角色不同、每台不同三類，合併為什麼放在部署時，exit code 2 與 4 的意思，這些判斷依據寫在那篇概念文章，這裡只寫套在這次環境上的操作與結果。清單的格式、檢查腳本的白名單與 &lt;code>systemd-run&lt;/code> 各屬性的效果在 &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>。這次沒有碰雲端：主機是容器，雲端防火牆那一層的檢查在清單裡寫成要到主控台確認的項目，&lt;code>terraform plan&lt;/code> 對雲端資源的比對也沒有量。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-26，在一台 Apple Silicon 的 macOS 開發機上，用 OrbStack 提供的 Docker Engine 29.8.2（核心 <code>7.2.12-orbstack</code>，arm64，<a href="/blog/backend/knowledge-cards/cgroup/" data-link-title="cgroup（控制群組）" data-link-desc="設定容器的 CPU 與記憶體上限、讀 cAdvisor 的指標、或查容器為什麼被暫停與結束時，查 cgroup 是什麼、v1 與 v2 的差別，以及 Docker 與 Kubernetes 的參數各自對應到它的哪個控制檔">cgroup（控制群組）</a> v2）建立可拋棄的容器，把多台主機的維護清單從 Terraform 產生 inventory、用 Ansible 部署到兩台主機並執行檢查腳本。受管主機（managed node，Ansible 連過去執行任務的主機）是兩個 Oracle Linux 10.2 容器（systemd 257 當 PID 1，OpenSSH 9.9p1、Python 3.12.15、sudo 1.9.17、dnf-automatic 4.20.0），以 <code>--privileged --cgroupns=private</code> 執行。控制端（control node，執行 <code>ansible</code> 與 Terraform 指令的機器）是一個 <code>python:3.12-slim</code> 容器（Debian 13，OpenSSH 用戶端 10.0p2），裝 ansible-core 2.17.7（Jinja2 3.1.6）與 Terraform 1.9.8，Terraform 的 <code>hashicorp/local</code> provider（Terraform 操作某一類資源的外掛）是 2.5.2。檢查腳本是 <code>host-check.py</code> 在 2026-10-26 的版本（作者私有專案的 commit <code>3fc41c0</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>。</p></blockquote>
<p>這篇把 <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> 的做法在兩台可拋棄的受管主機上從安裝走到清理，一台只當 web、另一台同時是 web 與 db。各節依序是：</p>
<ul>
<li>受管主機與控制端的容器怎麼建、裝了哪些套件。</li>
<li>控制端經由 SSH 登入受管主機用的金鑰、<code>known_hosts</code> 與 sudo。</li>
<li>Terraform 由主機清單寫出 inventory（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>），以及 <code>local_file</code> 的預設檔案權限。</li>
<li>變數檔與範本照檢查腳本實際要求的欄位寫，兼任兩種角色的主機怎麼取得兩個角色的入口。</li>
<li>用 playbook（依序執行多個任務的 YAML 檔）部署檢查腳本與每台的清單：<code>validate</code> 改用檢查腳本自己的載入函式、在寫出之前就驗出欄位錯誤，以及 <code>--check</code> 的預覽與實際執行結果不同的那一次。</li>
<li>以 <code>-b</code> 與 <code>systemd-run</code> 對所有主機執行檢查腳本，每台的輸出與 <code>ansible</code> 指令的 exit code。</li>
<li>刻意讓一台的門檻變成 <code>WARN</code>、讓一台的清單格式錯、讓一台連不上時，<code>ansible</code> 的 exit code 與每台的 <code>rc</code> 各是什麼。</li>
<li>容器跟真主機不同的地方，以及在真主機上要另外確認的項目。</li>
<li>清理。</li>
</ul>
<p>值為什麼分成每台相同、依角色不同、每台不同三類，合併為什麼放在部署時，exit code 2 與 4 的意思，這些判斷依據寫在那篇概念文章，這裡只寫套在這次環境上的操作與結果。清單的格式、檢查腳本的白名單與 <code>systemd-run</code> 各屬性的效果在 <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>。這次沒有碰雲端：主機是容器，雲端防火牆那一層的檢查在清單裡寫成要到主控台確認的項目，<code>terraform plan</code> 對雲端資源的比對也沒有量。</p>
<h2 id="前置條件開發機的資源檔案配置與檢查腳本">前置條件：開發機的資源、檔案配置與檢查腳本</h2>
<ul>
<li>
<p><strong>開發機的記憶體</strong>：這次三個容器同時在跑時，<code>docker stats</code> 量到受管主機各用 36 MiB 與 52 MiB、控制端 5 MiB（檢查腳本執行時，systemd 回報那一次執行的記憶體高峰是 70 MiB 到 79 MiB）。這些值依映像檔與正在跑的服務而變；開發機上已經有別的容器在跑時，建容器之前先看 swap 已用多少（macOS 是 <code>sysctl vm.swapusage</code>）與 OrbStack 分到的記憶體上限（在 OrbStack 的設定裡）。</p>
</li>
<li>
<p><strong>主機的數量</strong>：兩台受管主機加一個控制端是這次需要的最小組合：一台只當 web、一台兼任 web 與 db，才看得到依角色取聯集的結果；控制端一個就夠。</p>
</li>
<li>
<p><strong>一個工作目錄</strong>：控制端容器把它掛在 <code>/work</code>，Terraform 的設定、Ansible 的變數檔、範本、產生出來的 inventory 與 Terraform 的 <a href="/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）</a> 都放在這裡；容器刪掉之後 State 仍在開發機上，清理時才查得到 Terraform 建過什麼。這次的配置：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">.
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── host-image/Containerfile       # 受管主機的映像檔
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── ctl-image/Containerfile        # 控制端的映像檔
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">└── work/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ├── tf/main.tf                 # Terraform：主機清單與寫出 inventory 的 local_file
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    └── ansible/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        ├── inventory.yml          # 由 Terraform 寫出，不手改
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        ├── group_vars/all.yml     # 每台相同的值
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ├── group_vars/web.yml     # web 角色的值
</span></span><span class="line"><span class="ln">10</span><span class="cl">        ├── group_vars/db.yml      # db 角色的值
</span></span><span class="line"><span class="ln">11</span><span class="cl">        ├── host_vars/mh-web1.yml  # 只有 mh-web1 不同的值
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── host.toml.j2           # 清單的範本
</span></span><span class="line"><span class="ln">13</span><span class="cl">        ├── files/host-check.py    # 檢查腳本（由原始碼那一篇的區塊接成）
</span></span><span class="line"><span class="ln">14</span><span class="cl">        ├── deploy.yml             # 部署腳本與清單的 playbook
</span></span><span class="line"><span class="ln">15</span><span class="cl">        └── check.sh               # 對每台執行檢查的 ad-hoc 指令（不寫 playbook、直接執行單一模組）</span></span></code></pre></div><p>下面各節程式碼區塊第一行的註解寫著檔名，照那個路徑在開發機上建檔，路徑的起點依檔案分三種：</p>
<ul>
<li><code>host-image/…</code>、<code>ctl-image/…</code>：從 <code>work/</code> 的上一層起算。兩個映像檔的建置目錄不必掛進控制端，所以不放進 <code>work/</code>。</li>
<li><code>tf/main.tf</code>：從工作目錄 <code>work/</code> 起算。</li>
<li>其餘的（<code>group_vars/…</code>、<code>host.toml.j2</code>、<code>deploy.yml</code>、<code>check.sh</code>）：從 <code>work/ansible/</code> 起算。</li>
</ul>
<p><code>ansible/</code> 與 <code>tf/</code> 目錄要在起控制端容器之前建好。<code>inventory.yml</code> 跟 <code>group_vars/</code>、<code>host_vars/</code> 放在同一個目錄，是 Ansible 載入這兩個目錄的條件：Ansible 從 inventory 檔所在的目錄（以及 playbook 所在的目錄）找 <code>group_vars/</code> 與 <code>host_vars/</code>。</p>
</li>
<li>
<p><strong>檢查腳本</strong>：<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>，把那一篇的 <code>python</code> 區塊照它開頭寫的接法接起來，存成 <code>files/host-check.py</code>。換成自己的檢查腳本時，這篇的步驟用到的腳本行為有下面這幾項，替代的腳本要全部滿足，或照它的行為改寫對應的步驟：</p>
<ul>
<li>有一個只載入與驗證清單、不執行檢查的函式或模式，清單格式錯時以非零結束（<code>host-check.py</code> 是 <code>load_manifest()</code>，格式錯時丟出例外）；部署時的 <code>validate</code> 用它。</li>
<li>exit code 0 代表沒有 <code>WARN</code>、1 代表至少一項 <code>WARN</code>、2 代表清單格式錯；跨主機的輸出靠每台的 <code>rc</code> 分出這三種情形（<code>rc</code> 是 Ansible 回報的、那台主機上指令的 return code，執行檢查時就是檢查腳本的 exit code）。</li>
<li>結尾一定印一行 <code>summary:</code>；腳本沒有開始或沒有跑完時，靠這一行分辨。</li>
<li>接受 <code>--manifest &lt;路徑&gt;</code> 指定清單的位置。</li>
<li><code>--no-state</code> 不讀也不寫結果檔。</li>
<li>結果檔放在 <code>/var/lib/host-check/</code>，由 <code>systemd-run</code> 的 <code>StateDirectory=host-check</code> 建立。</li>
<li>在沙箱底下跑得完：<code>ProtectSystem=strict</code>、只有 <code>ReadWritePaths</code> 列的 <code>/var/log</code> 與 <code>/var/cache/dnf</code> 兩個目錄（加上 <code>StateDirectory</code>）可寫、<code>IPAddressDeny=any</code> 擋掉所有網路連線。</li>
<li>給 <code>runpy</code> 載入時：模組層級沒有副作用、<code>__name__</code> 不是 <code>__main__</code> 時不執行主程式、載入函式以丟出例外表達格式錯；主機的 Python 是 3.11 以上（<code>tomllib</code>）。</li>
<li>清單的欄位：〈變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機〉那一節列的欄位與允許值是 <code>host-check.py</code> 自己的規格，換腳本時範本換成那支腳本要求的欄位。</li>
</ul>
<p>換成自己的腳本時，正文的預期輸出（<code>PASS</code>、<code>WARN</code>、<code>MANUAL</code> 的行格式、<code>since:</code> 與 <code>DELTA</code>、<code>manifest error:</code> 的訊息、<code>[vars]</code> 的替換）以那支腳本自己的輸出為準。</p>
</li>
<li>
<p><strong>容器、network 與映像檔的名稱一律 <code>mh-</code> 開頭</strong>：清理時 <code>docker ps -a --filter name=mh-</code> 這一類查詢列得出這次建的全部資源，開發機上其他專案的容器不會被誤刪。</p>
</li>
<li>
<p><strong>把這個 lab 固定成自動化測試時</strong>：可用的工具是 molecule 的 docker driver，它拿容器當受管主機，依序建立容器、套用 playbook（converge）、執行驗證。這次沒有用 molecule，每一步都手動執行。</p>
</li>
</ul>
<h2 id="受管主機與控制端的容器oracle-linux-10-加-systemd-與-sshdpython312-slim-加-ansible-core-與-terraform">受管主機與控制端的容器：Oracle Linux 10 加 systemd 與 sshd、python:3.12-slim 加 ansible-core 與 Terraform</h2>
<p>受管主機的映像檔以 <code>oraclelinux:10</code> 為底，裝 sshd、sudo、Python 與檢查腳本會查的 dnf-automatic，建一個 <code>ops</code> 帳號當 Ansible 登入的帳號：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># host-image/Containerfile：受管主機</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> oraclelinux:10</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="err"></span><span class="k">RUN</span> dnf -y install systemd procps-ng iproute openssh-server sudo python3 dnf-automatic dnf-plugins-core <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> dnf clean all <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> systemctl mask systemd-logind.service getty.target console-getty.service <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> systemctl <span class="nb">enable</span> sshd.service <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> useradd -m ops <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s1">&#39;ops ALL=(ALL) NOPASSWD: ALL&#39;</span> &gt; /etc/sudoers.d/ops <span class="o">&amp;&amp;</span> chmod <span class="m">0440</span> /etc/sudoers.d/ops<span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="err"></span><span class="k">STOPSIGNAL</span><span class="s"> SIGRTMIN+3</span><span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="err"></span><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;/usr/sbin/init&#34;</span><span class="p">]</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># ctl-image/Containerfile：控制端</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> python:3.12-slim</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="err"></span><span class="k">ARG</span> <span class="nv">TF_VERSION</span><span class="o">=</span><span class="m">1</span>.9.8<span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="err"></span><span class="c"># TARGETARCH 由 docker build 依開發機的架構填入（這台是 arm64），Terraform 的壓縮檔名照它選</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="err"></span><span class="k">ARG</span> TARGETARCH<span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="err"></span><span class="k">RUN</span> apt-get update <span class="o">&amp;&amp;</span> apt-get install -y --no-install-recommends openssh-client curl unzip <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> rm -rf /var/lib/apt/lists/* <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> pip install --no-cache-dir ansible-core<span class="o">==</span>2.17.7 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> curl -fsSLo /tmp/tf.zip https://releases.hashicorp.com/terraform/<span class="si">${</span><span class="nv">TF_VERSION</span><span class="si">}</span>/terraform_<span class="si">${</span><span class="nv">TF_VERSION</span><span class="si">}</span>_linux_<span class="si">${</span><span class="nv">TARGETARCH</span><span class="si">}</span>.zip <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> unzip /tmp/tf.zip terraform -d /usr/local/bin <span class="o">&amp;&amp;</span> rm /tmp/tf.zip<span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="err"></span><span class="k">WORKDIR</span><span class="s"> /work</span></span></span></code></pre></div>




<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"># 開發機上，在 work/ 的上一層執行；-f 指定檔名，因為檔名是 Containerfile 而 docker build 預設找 Dockerfile</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker build -f host-image/Containerfile -t mh-host:ol10 host-image
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker build -f ctl-image/Containerfile -t mh-ctl:1 ctl-image
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker run --rm mh-ctl:1 sh -c <span class="s1">&#39;ansible --version | head -1; terraform version | head -1&#39;</span></span></span></code></pre></div><p>最後一條印出 <code>ansible [core 2.17.7]</code> 與 <code>Terraform v1.9.8</code>。這台開發機上 <code>oraclelinux:10</code> 已經在本機，受管主機的映像檔建了 23 秒；控制端要下載 pip 套件與 Terraform 的壓縮檔，建了 101 秒。兩個時間依網路與本機的快取而變。</p>
<p>受管主機的 Containerfile 最後兩行讓 systemd 當容器的 PID 1：<code>CMD [&quot;/usr/sbin/init&quot;]</code> 啟動 systemd，<code>STOPSIGNAL SIGRTMIN+3</code> 讓 <code>docker stop</code> 送出 systemd 認得的關機訊號。systemd 收到 SIGRTMIN+3 才照正常的關機流程停下各個 unit；Docker 預設送的是 SIGTERM，systemd 不把它當成關機，<code>docker stop</code> 會等到逾時再強制結束容器（依 systemd 文件對訊號的說明推論，沒有實跑拿掉這一行的結果）。</p>
<p><strong>這次遇到的錯誤</strong>：第一次建受管主機的映像檔時套件名寫成 <code>'dnf-command(needs-restarting)'</code>，dnf 回 <code>Unable to find a match</code>，在 Oracle Linux 10 上，檢查腳本用的 <code>dnf needs-restarting</code> 子命令由 <code>dnf-plugins-core</code> 提供（外掛檔案在它依賴的 <code>python3-dnf-plugins-core</code> 裡），<code>oraclelinux:10</code> 的映像檔已經裝了它；另一個獨立的 <code>/usr/bin/needs-restarting</code> 指令屬於 <code>yum-utils</code>（2026-10-27 在 <code>oraclelinux:10</code> 容器裡用 <code>rpm -qf</code> 查到，Oracle Linux 10.2）。Containerfile 仍把 <code>dnf-plugins-core</code> 寫進安裝清單，讓檢查腳本依賴的套件明示在這一行：換成沒有預裝它的底層映像檔時，建映像檔那一步會裝上它，不會漏掉。</p>
<p>換成 VM 或別的發行版、不開特權、sudo 要密碼、改用別的安裝方式時的結果，在〈這一節的其他做法：容器或 VM、作業系統、遮蔽的 unit、privileged、sudo 密碼、安裝版本與雜湊、金鑰密碼〉。</p>
<p>兩台受管主機與控制端接在同一個 Docker network 上，控制端用容器名就連得到受管主機：</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">docker network create mh-net
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --name mh-web1  --hostname mh-web1  --network mh-net --privileged --cgroupns<span class="o">=</span>private mh-host:ol10
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker run -d --name mh-both1 --hostname mh-both1 --network mh-net --privileged --cgroupns<span class="o">=</span>private mh-host:ol10
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker <span class="nb">exec</span> mh-web1 systemctl is-system-running --wait    <span class="c1"># 等 systemd 啟動完成，印 running</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker <span class="nb">exec</span> mh-both1 systemctl is-system-running --wait</span></span></code></pre></div><p><code>docker network create</code> 另建一個 network，因為 Docker 預設的 bridge network 不提供容器名的 DNS 解析，接在上面的容器只能用 IP 互連；自建的 bridge network 由 Docker 內建的 DNS 把容器名解析成位址（依 Docker 文件〈Bridge network driver〉，沒有在預設的 bridge 上實跑）。</p>
<p><code>--privileged --cgroupns=private</code> 讓容器裡的 systemd 管得到自己的 cgroup，<code>systemd-run</code> 才建得出暫時的 unit（transient unit：執行時由 <code>systemd-run</code> 建立、不寫成 unit 檔的 unit）。Docker 在 cgroup v2 的主機上，cgroup namespace 的預設就是 <code>private</code>，所以 systemd 能否當 PID 1 啟動，取決於 <code>--privileged</code>，<code>--cgroupns=private</code> 只是把預設寫明（依 <code>docker run</code> 參考文件推論）。拿掉 <code>--privileged</code> 時，容器裡的 systemd 管不到自己的 cgroup，PID 1 起不來，或 <code>systemd-run</code> 建不出暫時的 unit（從 systemd 文件〈The Container Interface〉對 cgroup 階層的要求推論，沒有實跑）。<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> 在同一種容器裡量沙箱的效果時用的就是這組參數。<code>--privileged</code> 的代價與不開特權的做法，在〈這一節的其他做法：容器或 VM、作業系統、遮蔽的 unit、privileged、sudo 密碼、安裝版本與雜湊、金鑰密碼〉。</p>
<p><code>systemctl is-system-running --wait</code> 等到 systemd 的啟動結束才印出狀態：<code>running</code> 代表啟動完成、沒有失敗的 unit，<code>degraded</code> 代表有 unit 失敗，用 <code>systemctl --failed</code> 查是哪一個。不加 <code>--wait</code>、緊接在 <code>docker run</code> 之後執行時，這次印的是 <code>initializing</code>、exit code 1，systemd 還在啟動，sshd 可能還沒開始接聽。</p>
<p>兩台各再做三件事，這三件在真主機上分別由建立主機時的設定與服務自己的部署負責，這裡用 <code>docker exec</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"># 開發機上執行；&lt;暫存目錄&gt; 換成工作目錄以外的一個暫存目錄，金鑰的用法在〈控制端連到受管主機的 SSH：金鑰、known_hosts 與 sudo〉</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p &lt;暫存目錄&gt;/keys    <span class="c1"># ssh-keygen 不建上層目錄</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">ssh-keygen -q -t ed25519 -N <span class="s1">&#39;&#39;</span> -C mh-walkthrough -f &lt;暫存目錄&gt;/keys/id_ed25519
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">for</span> h in mh-web1 mh-both1<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  docker <span class="nb">exec</span> <span class="nv">$h</span> install -d -m <span class="m">0700</span> -o ops -g ops /home/ops/.ssh
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  docker cp &lt;暫存目錄&gt;/keys/id_ed25519.pub <span class="nv">$h</span>:/home/ops/.ssh/authorized_keys
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  docker <span class="nb">exec</span> <span class="nv">$h</span> sh -c <span class="s1">&#39;chown ops:ops /home/ops/.ssh/authorized_keys &amp;&amp; chmod 0600 /home/ops/.ssh/authorized_keys&#39;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  docker <span class="nb">exec</span> <span class="nv">$h</span> systemctl <span class="nb">enable</span> --now dnf-automatic.timer
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  docker <span class="nb">exec</span> <span class="nv">$h</span> systemd-run --unit<span class="o">=</span>stand-in-http python3 -m http.server <span class="m">80</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">docker <span class="nb">exec</span> mh-both1 systemd-run --unit<span class="o">=</span>stand-in-postgres python3 -m http.server <span class="m">5432</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">docker <span class="nb">exec</span> mh-both1 ss -H -l -n -t</span></span></code></pre></div><ul>
<li><strong>公鑰</strong>：<code>ssh-keygen</code> 在暫存目錄產生一把新的金鑰。暫存目錄要在工作目錄以外：工作目錄整個掛進控制端容器，私鑰放在裡面的話會跟著掛進去，而控制端只需要唯讀掛載的那一份金鑰目錄。<code>keys</code> 目錄不存在時它以 <code>Saving key … failed: No such file or directory</code> 失敗，所以先 <code>mkdir -p</code>。<code>-N ''</code> 讓私鑰不設密碼，控制端執行 Ansible 時才不必輸入。雲端主機在建立時由 cloud-init 把公鑰放進預設帳號，這裡用 <code>docker cp</code> 手動放。</li>
<li><strong>dnf-automatic 的 timer</strong>：清單要求它 <code>enabled</code> 而且 <code>active</code>，真主機上由部署腳本啟用。</li>
<li><strong>代替服務的接聽程式</strong>：web 角色的入口是 80，db 角色是 5432，檢查腳本確認那個連接埠有程序在聽。這次沒有裝 nginx 與 PostgreSQL，用 <code>python3 -m http.server</code> 在兩個連接埠上接聽；<code>systemd-run --unit=</code> 建的是暫時的 unit，容器重啟之後就不在，〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉那一節正好用到這個性質。</li>
</ul>
<p>最後一條在 <code>mh-both1</code> 上列出 <code>0.0.0.0:22</code>、<code>[::]:22</code>、<code>0.0.0.0:80</code> 與 <code>0.0.0.0:5432</code>，另有 Docker 內建 DNS 在 <code>127.0.0.11</code> 上的一個 TCP 連接埠（它的另一個連接埠是 UDP，不在 <code>-t</code> 的輸出裡）；Docker 內建 DNS 的位址在 loopback 上，檢查腳本的 <code>listen_audit</code> 只比對 loopback 以外的位址，所以 DNS 的連接埠不算多開的連接埠。</p>
<h3 id="這一節的其他做法容器或-vm作業系統遮蔽的-unitprivilegedsudo-密碼安裝版本與雜湊金鑰密碼">這一節的其他做法：容器或 VM、作業系統、遮蔽的 unit、privileged、sudo 密碼、安裝版本與雜湊、金鑰密碼</h3>
<ul>
<li><strong>受管主機用容器</strong>：容器建得快、刪得乾淨，代價是沙箱、SELinux、logind 這幾項跟真主機不同（〈容器跟真主機不同的地方，以及在真主機上要另外確認的項目〉）。換成完整的 VM（Lima、UTM、multipass）或雲端主機時，那一節列的 SELinux、logind 的 session 與持久的 journal 這幾項就量得到，代價是建立與清理都比較慢、雲端主機要計費（推論，這次沒有用 VM 跑）。</li>
<li><strong>受管主機的作業系統</strong>：檢查腳本的 <code>[updates]</code> 段讀的是 dnf-automatic 的設定檔（INI 格式的 <code>[commands]</code> 段），<code>reboot_check</code> 用 <code>dnf needs-restarting -r</code>，所以選 Oracle Linux；同為 dnf 系的 Rocky Linux、AlmaLinux 同樣適用（推論，沒有實跑）。換成 <code>debian:12</code> 這類 Debian 系的映像檔可以少下載一些，代價是 Debian 上沒有 dnf-automatic 的設定檔、timer 與 <code>dnf</code> 指令，<code>[updates]</code> 那幾項（timer、config、reboot）都不會是 <code>PASS</code>，<code>[updates]</code> 段要照 unattended-upgrades 的設定改寫，檢查腳本也要跟著改（從腳本的程式碼推的，沒有實跑）。</li>
<li><strong>systemd 當 PID 1 與遮蔽的 unit</strong>：檢查要用 <code>systemd-run</code> 套沙箱，<code>[updates]</code> 也要查 <a href="/blog/linux/dotfile/knowledge-cards/systemd-timer/" data-link-title="systemd timer" data-link-desc="排程的工作沒有在預期的時間執行、主機關機期間錯過的排程不知道會不會補跑、要在 cron 與 systemd timer 之間選一種寫定期工作，或讀到 *.timer 檔與 systemctl list-timers 的輸出時">systemd timer</a> 的狀態，所以容器裡要有 systemd。<code>systemctl mask</code> 那幾個 unit 是容器裡用不到的登入與終端機服務；遮蔽 <code>systemd-logind</code> 的副作用寫在〈容器跟真主機不同的地方，以及在真主機上要另外確認的項目〉。不遮蔽時，logind 會替每次 SSH 登入建一個 session scope，Ansible 模組的紀錄落在那個 scope 底下，<code>log/sshd</code> 的最新一筆就不再是 Ansible 的紀錄（推論，這次沒有實跑不遮蔽的版本）。</li>
<li><strong><code>--privileged</code> 的代價與不開特權的做法</strong>：<code>--privileged</code> 的代價在 Linux 開發機上最明顯：這種容器拿得到主機上所有的裝置與 Linux capability，約等於主機上的 root（<a href="/blog/backend/knowledge-cards/privileged-container/" data-link-title="Privileged Container（特權容器）" data-link-desc="在 Pod 設定裡看到 securityContext.privileged=true、要判斷某個除錯或系統元件為什麼需要它，或評估誰能建這種 Pod 等於持有節點 root 權限時，查特權容器能做什麼與它的權限邊界">Privileged Container（特權容器）</a>；Docker 的 <code>--privileged</code> 跟卡上寫的 <code>securityContext.privileged</code> 是同一種權限）。這次的 Docker Engine 跑在 OrbStack 的 Linux VM 裡，拿到的是那台 VM 的權限，不是 macOS 本身。不想開特權時，Podman 的 <code>--systemd=always</code> 不需要 privileged 也能讓 systemd 當 PID 1（依 Podman 文件，沒有實跑）。</li>
<li><strong><code>ops</code> 帳號的 sudo 不要密碼</strong>：Ansible 的 become（<code>-b</code>，讓 Ansible 登入之後經由 sudo 切成 root）因此不必另外給密碼。sudo 要密碼的主機，<code>ansible</code> 與 <code>ansible-playbook</code> 要加 <code>-K</code>（<code>--ask-become-pass</code>）在執行時輸入，或把密碼放進 Ansible Vault（Ansible 內建的加密功能，把變數檔加密存放、執行時用密碼解開）加密的變數；不給的話 become 那一步失敗（依 Ansible 文件〈Understanding privilege escalation: become〉，沒有實跑）。由排程執行時沒有人輸入，<code>-K</code> 用不了；改用 Vault 時，解開 Vault 的密碼檔要放在控制端上，控制端被入侵時密碼檔與私鑰一起被拿走，風險的範圍沒有變小（推論）。</li>
<li><strong>ansible-core 與 Terraform 固定版本</strong>：ansible-core 用 <code>pip install ansible-core==2.17.7</code>，Terraform 下載 1.9.8 的壓縮檔。改用 apt、pipx 或 HashiCorp 的套件庫安裝時拿到的版本可能不同，版本不同時 <code>ansible</code> 與 <code>terraform</code> 印出的格式與本文的輸出不保證相同（從版本差異推論，沒有實跑其他版本）。</li>
<li><strong>Terraform 的壓縮檔沒有驗證雜湊</strong>：HashiCorp 在同一個目錄放了 <code>terraform_&lt;版本&gt;_SHA256SUMS</code> 與它的簽章，下載之後用 <code>sha256sum -c</code> 比對，可以確認下載到的檔案沒有被換掉；這次沒有做這一步（依 HashiCorp 的下載頁，沒有實跑）。</li>
<li><strong>金鑰不設密碼</strong>：替代做法是設密碼，在控制端起 <code>ssh-agent</code> 並 <code>ssh-add</code> 解鎖一次：私鑰檔外洩時多一層密碼保護，代價是每次開一個新的 shell 都要先解鎖（推論，沒有實跑）。</li>
</ul>
<h2 id="控制端連到受管主機的-ssh金鑰known_hosts-與-sudo">控制端連到受管主機的 SSH：金鑰、known_hosts 與 sudo</h2>
<p>控制端用的金鑰是〈受管主機與控制端的容器：Oracle Linux 10 加 systemd 與 sshd、python:3.12-slim 加 ansible-core 與 Terraform〉那一節在暫存目錄產生的那一把，開發機上自己的金鑰沒有拿來用：這把金鑰只授權給這兩個可拋棄的容器，清理時連同容器一起刪掉。</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"># 開發機上；&lt;工作目錄&gt; 是〈前置條件：開發機的資源、檔案配置與檢查腳本〉的工作目錄，&lt;暫存目錄&gt; 是產生金鑰時用的那一個</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --name mh-ctl --network mh-net -v &lt;工作目錄&gt;:/work -v &lt;暫存目錄&gt;/keys:/keys:ro mh-ctl:1 sleep infinity
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker <span class="nb">exec</span> mh-ctl sh -c <span class="s1">&#39;mkdir -p ~/.ssh &amp;&amp; cp /keys/id_ed25519 ~/.ssh/ &amp;&amp; chmod 600 ~/.ssh/id_ed25519
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  for h in mh-web1 mh-both1; do ssh-keyscan -t ed25519 $h; done &gt; ~/.ssh/known_hosts
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">  for h in mh-web1 mh-both1; do ssh ops@$h &#34;id; sudo -n id -u&#34;; done&#39;</span></span></span></code></pre></div><p><code>-v &lt;工作目錄&gt;:/work</code> 把開發機上的工作目錄掛進控制端；金鑰的目錄唯讀掛載，再複製一份進容器改權限，因為 ssh 拒絕權限比 0600 寬的私鑰，而掛載進來的檔案權限照開發機那一份。私鑰複製到 <code>~/.ssh/id_ed25519</code>，是 ssh 沒有指定金鑰時預設會試的路徑之一，Ansible 經由 ssh 連線時也就用這一把，inventory 不必另外寫 <code>ansible_ssh_private_key_file</code>。最後那個迴圈在 <code>mh-web1</code> 上印出 <code>uid=1000(ops)</code> 與 <code>0</code>：<code>ops</code> 登入成功，<code>sudo -n</code>（不問密碼，要密碼就直接失敗）切成了 root。這次實跑時這一條只連了 <code>mh-web1</code>，<code>mh-both1</code> 的登入與 sudo 由後面的 <code>ansible -b</code> 指令確認。</p>
<p><code>ssh-keyscan</code> 把兩台主機出示的 host key 直接寫進 <code>known_hosts</code>，等於第一次連線時不比對指紋就信任（<a href="/blog/devops/infra/knowledge-cards/ssh-host-key/" data-link-title="SSH Host Key 與 known_hosts（主機金鑰）" data-link-desc="ssh 第一次連線問要不要信任指紋、出現 REMOTE HOST IDENTIFICATION HAS CHANGED 警告，或主機重建之後連不上時，查 host key 怎麼證明主機身分、known_hosts 記了什麼，以及首次信任的風險">SSH Host Key 與 known_hosts（主機金鑰）</a> 卡寫了首次信任的風險）。在一個只有這三個容器的 Docker network 裡，連線的另一端只可能是這次建的容器，所以這次接受這個風險。改用自己的金鑰、或改用別的方式寫入 <code>known_hosts</code> 時的結果，在〈這一節的其他做法：自己的金鑰與寫入 known_hosts 的方式〉。</p>
<p>之後的 Terraform 與 Ansible 指令都在控制端容器裡的 shell 執行，從開發機進入那個 shell：</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"># 開發機上；-w 把 shell 的工作目錄設在 /work/ansible</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker <span class="nb">exec</span> -it -w /work/ansible mh-ctl bash</span></span></code></pre></div><p>下文寫「控制端容器裡」的指令都在這個 shell 執行，工作目錄是 <code>/work/ansible</code>；Terraform 那幾條在 <code>/work/tf</code>，區塊裡先 <code>cd /work/tf</code>。</p>
<h3 id="這一節的其他做法自己的金鑰與寫入-known_hosts-的方式">這一節的其他做法：自己的金鑰與寫入 known_hosts 的方式</h3>
<ul>
<li><strong>用自己的金鑰</strong>：用自己的金鑰也連得上，代價是要把自己的私鑰掛進控制端容器，控制端容器裡的任何程式都讀得到它。</li>
<li><strong><code>ssh-keyscan</code> 寫進 <code>known_hosts</code></strong>（這次的做法）：之後的連線都會比對 host key，主機重建、host key 換了時 ssh 拒絕連線。</li>
<li><strong>不寫 <code>known_hosts</code></strong>：Ansible 預設檢查 host key，第一次連到沒有紀錄的主機時沒有人回答確認的提問，那一台失敗；在 <code>ansible.cfg</code> 設 <code>host_key_checking = False</code> 可以跳過，代價是之後每次連線都不比對，連到被冒充的主機也不會發現（依 Ansible 文件〈Connection methods and details〉的〈Managing host key checking〉，沒有實跑）。</li>
<li><strong><code>StrictHostKeyChecking=accept-new</code></strong>（OpenSSH 7.6 起）：第一次連到沒有紀錄的主機時自動把 host key 記進 <code>known_hosts</code>，之後 host key 變了照樣拒絕連線；首次連線同樣不比對指紋，跟 <code>ssh-keyscan</code> 承擔的是同一個風險，差別是不必先跑一次掃描（依 ssh_config 的文件，沒有實跑）。</li>
<li><strong>比對主控台或序列主控台印出的指紋再寫入</strong>：真主機建立時，業者的主控台或序列主控台的開機輸出印得出 host key 的指紋，比對過 <code>ssh-keyscan</code> 取回的 host key 再寫進 <code>known_hosts</code>，第一次連線也不必無條件信任。代價是每建一台主機都要人工比對一次（這次沒有真主機，沒有實跑）。</li>
<li><strong>SSH 憑證：只寫 CA 公鑰</strong>：用 <a href="/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH Certificate（SSH 憑證）</a> 簽主機金鑰的環境，<code>known_hosts</code> 只要寫一行 CA 的公鑰，新建的主機不必逐台加紀錄；代價是要先建好簽發主機憑證的 CA 與流程（從機制推論，沒有實跑）。</li>
</ul>
<h2 id="由-terraform-產生-inventoryinitplanapply-與-local_file-的檔案權限">由 Terraform 產生 inventory：init、plan、apply 與 local_file 的檔案權限</h2>
<p>Terraform 的設定（<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/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>〈主機由 Terraform 建立時由它的輸出產生 inventory〉的寫法，主機改成這次的兩個容器，<code>all</code> 群組（Ansible 內建、包含 inventory 裡每一台主機的群組）加上 Ansible 登入用的帳號。跟概念文章那份設定不同的兩處：每台主機的鍵名 <code>public_ip</code> 改成 <code>address</code>，因為這次填的是容器名、不是 IP；<code>filename</code> 指向 <code>ansible/</code> 目錄，inventory 寫在 Ansible 的變數檔旁邊。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># tf/main.tf
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">required_providers</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    local</span> <span class="o">=</span><span class="n"> { source</span> <span class="o">=</span><span class="n"> &#34;hashicorp/local&#34;, version</span> <span class="o">=</span> <span class="s2">&#34;2.5.2&#34;</span> }
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  }
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 實際的設定裡，address 來自主機資源的屬性（例如 public_ip）；這裡用容器名代替
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">locals</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  hosts</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    &#34;mh-web1&#34;</span>  <span class="o">=</span><span class="n"> { roles</span> <span class="o">=</span><span class="n"> [&#34;web&#34;], address</span> <span class="o">=</span> <span class="s2">&#34;mh-web1&#34;</span> }
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    &#34;mh-both1&#34;</span> <span class="o">=</span><span class="n"> { roles</span> <span class="o">=</span><span class="n"> [&#34;web&#34;, &#34;db&#34;], address</span> <span class="o">=</span> <span class="s2">&#34;mh-both1&#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></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;local_file&#34; &#34;inventory&#34;</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  filename</span>        <span class="o">=</span> <span class="s2">&#34;${path.module}/../ansible/inventory.yml&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  file_permission</span> <span class="o">=</span> <span class="s2">&#34;0644&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">  content</span> <span class="o">=</span> <span class="k">yamlencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">    all</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">      vars</span> <span class="o">=</span><span class="n"> { ansible_user</span> <span class="o">=</span> <span class="s2">&#34;ops&#34;</span> }
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">      children</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">for</span> <span class="k">role</span> <span class="k">in</span> <span class="k">distinct</span><span class="p">(</span><span class="k">flatten</span><span class="p">([</span><span class="k">for</span> <span class="k">h</span> <span class="k">in</span> <span class="k">values</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">hosts</span><span class="p">)</span> <span class="err">:</span> <span class="k">h</span><span class="p">.</span><span class="k">roles</span><span class="p">]))</span> <span class="err">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="n">        role</span> <span class="o">=</span><span class="n">&gt; { hosts</span> <span class="o">=</span><span class="n"> { for name, h in local.hosts : name</span> <span class="o">=</span><span class="n">&gt; { ansible_host</span> <span class="o">=</span> <span class="k">h</span><span class="p">.</span><span class="k">address</span> } <span class="k">if</span> <span class="k">contains</span><span class="p">(</span><span class="k">h</span><span class="p">.</span><span class="k">roles</span><span class="p">,</span> <span class="k">role</span><span class="p">)</span> } }
</span></span><span class="line"><span class="ln">25</span><span class="cl">      }
</span></span><span class="line"><span class="ln">26</span><span class="cl">    }
</span></span><span class="line"><span class="ln">27</span><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">}
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="n">output &#34;hosts&#34; { value</span> <span class="o">=</span> <span class="k">local</span><span class="p">.</span><span class="k">hosts</span> }</span></span></code></pre></div>




<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"># 控制端容器裡</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /work/tf
</span></span><span class="line"><span class="ln">3</span><span class="cl">terraform init
</span></span><span class="line"><span class="ln">4</span><span class="cl">terraform plan
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform apply -auto-approve
</span></span><span class="line"><span class="ln">6</span><span class="cl">ansible-inventory -i ../ansible/inventory.yml --graph
</span></span><span class="line"><span class="ln">7</span><span class="cl">terraform output -json hosts</span></span></code></pre></div><p><code>required_providers</code> 宣告這份設定要用的 provider（Terraform 操作某一類資源的外掛，<code>hashicorp/local</code> 管本機的檔案），<code>terraform init</code> 依它下載 provider；<code>${path.module}</code> 是 <code>main.tf</code> 所在的目錄，<code>filename</code> 因此指到 <code>tf/</code> 旁邊的 <code>ansible/</code>。<code>children</code> 那兩層 <code>for</code> 把「每台主機有哪些角色」反轉成「每個角色有哪些主機」：外層取出不重複的角色，每個角色成為一個群組，內層把角色串列含有這個角色的主機收進那個群組。<code>apply</code> 的 <code>-auto-approve</code> 跳過執行前的確認提問，這裡用它是因為剛跑過 <code>plan</code> 看過內容。</p>
<p><code>terraform init</code> 印出 <code>Installed hashicorp/local v2.5.2 (signed by HashiCorp)</code>，花了 8.8 秒（下載 provider 的時間，依網路而變）；<a href="/blog/devops/infra/knowledge-cards/terraform-plan-apply/" data-link-title="terraform plan / apply" data-link-desc="IaC 的兩個核心操作：plan 只看不動（產出差異報告）、apply 真的動（執行差異）"><code>plan</code></a> 印 <code>local_file.inventory will be created</code> 與 <code>Plan: 1 to add</code>；<code>apply</code> 之後，<code>--graph</code> 列出 <code>db</code> 群組裡有 <code>mh-both1</code>、<code>web</code> 群組裡有 <code>mh-both1</code> 與 <code>mh-web1</code>，<code>mh-both1</code> 同時在兩個群組裡。產生出來的 <code>inventory.yml</code>（以同一份設定在 <code>hashicorp/terraform:1.9.8</code> 容器裡另外重跑 <code>apply</code> 之後 <code>cat</code> 的輸出；<code>yamlencode</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="s2">&#34;all&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="s2">&#34;children&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="s2">&#34;db&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">      </span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">        </span><span class="s2">&#34;mh-both1&#34;</span><span class="p">:</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">&#34;ansible_host&#34;: </span><span class="s2">&#34;mh-both1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="s2">&#34;web&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="s2">&#34;mh-both1&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">&#34;ansible_host&#34;: </span><span class="s2">&#34;mh-both1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="s2">&#34;mh-web1&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">&#34;ansible_host&#34;: </span><span class="s2">&#34;mh-web1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="s2">&#34;vars&#34;</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">&#34;ansible_user&#34;: </span><span class="s2">&#34;ops&#34;</span></span></span></code></pre></div><p><code>address</code> 填的是容器名，Docker network 的內建 DNS 把它解析成容器的位址；真主機上填 <code>&lt;主機資源&gt;.public_ip</code> 這類屬性，或主機在私有網路時的私有位址，看控制端從哪一條網路連進去。</p>
<p><code>terraform output -json hosts</code> 印出同一份主機清單的 JSON：<code>{&quot;mh-both1&quot;:{&quot;address&quot;:&quot;mh-both1&quot;,&quot;roles&quot;:[&quot;web&quot;,&quot;db&quot;]},&quot;mh-web1&quot;:{&quot;address&quot;:&quot;mh-web1&quot;,&quot;roles&quot;:[&quot;web&quot;]}}</code>，給 Ansible 以外的工具讀。</p>
<p><code>local_file</code> 的 <code>file_permission</code> 預設值是 <code>&quot;0777&quot;</code>，實際的檔案權限是它扣掉建立檔案那個程序的 umask。照上面寫了 <code>file_permission = &quot;0644&quot;</code> 的設定做時，<code>inventory.yml</code> 的權限是 <code>644</code>；明寫 <code>0644</code> 讓權限不依賴執行環境的 umask，固定讓其他帳號改不了這份檔案。</p>
<p>這一行是這次第一次 apply 之後才加的。這個控制端容器的 umask 是 <code>0000</code>，所以沒有寫 <code>file_permission</code> 時，<code>stat -c %a ../ansible/inventory.yml</code> 印 <code>777</code>，同一台機器上的任何帳號都改得了 inventory，也就改得了 Ansible 要以 root 連去的主機清單；另一次在 umask 0022 的容器裡重跑是 <code>755</code>。</p>
<p>加上 <code>file_permission = &quot;0644&quot;</code> 之後，<code>plan</code> 印 <code>local_file.inventory must be replaced</code> 與 <code>file_permission = &quot;0777&quot; -&gt; &quot;0644&quot; # forces replacement</code>，apply 之後權限是 <code>644</code>。</p>
<p>產生出來的檔案不手改。下面這個實驗是可以略過的驗證，用來確認手改會被蓋掉：在 <code>inventory.yml</code> 末端加一行 <code># 手加的一行</code> 之後，<code>plan</code> 回報 <code>local_file.inventory will be created</code>，apply 之後那一行消失（<code>grep -c</code> 印 <code>0</code>），跟概念文章寫的結果相同。plan 寫的是重新建立，原因是 <code>local_file</code> 讀取時發現檔案內容跟 State 記的不同，就把這個資源當成不存在（從 plan 的訊息推論的機制）。</p>
<p>手寫 inventory、動態 inventory、直接讀 State 的 inventory plugin 這幾種做法的結果，在〈這一節的其他做法：產生 inventory 的方式〉。</p>
<h3 id="這一節的其他做法產生-inventory-的方式">這一節的其他做法：產生 inventory 的方式</h3>
<p>產生 inventory 的方式與這次的環境下各自的結果：</p>
<ul>
<li><strong>Terraform 的 <code>local_file</code></strong>（這次的做法）：主機清冊只有 <code>locals</code>（真實環境是主機資源）這一個來源，增刪主機時 inventory 跟著改。</li>
<li><strong>手寫 inventory</strong>：不需要 Terraform；主機由 Terraform 建立時，增刪主機要在 Terraform 與 inventory 兩邊各改一次，判斷依據在概念文章〈主機由 Terraform 建立時由它的輸出產生 inventory〉。這次沒有實跑手寫的版本。</li>
<li><strong>Ansible 的動態 inventory</strong>（雲端業者的 inventory plugin 直接查業者的 API）：主機由 autoscaling 這類服務建立、不在 State 裡時也列得出來，代價是控制端要有讀業者 API 的憑證，分組要依主機的標籤（依 Ansible 文件〈Working with dynamic inventory〉與各業者 plugin 的文件，沒有實跑）。</li>
<li><strong>Ansible 的 <code>cloud.terraform</code> collection</strong>：它的 inventory plugin 直接讀 Terraform 的 State 列出主機，不寫 <code>inventory.yml</code> 這份檔案，也就沒有 <code>local_file</code> 的檔案權限與手改被蓋掉這兩件事（依 collection 的文件推論，沒有實跑）。讀 State 與寫檔案兩種做法的判斷依據在概念文章〈主機由 Terraform 建立時由它的輸出產生 inventory〉。</li>
</ul>
<h2 id="變數檔與範本檢查腳本實際要求的欄位兼任兩種角色的主機">變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機</h2>
<p>變數檔照 <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>〈用 Ansible 的 inventory、群組變數與範本產生每台的清單〉的分層：每台相同的寫在 <code>group_vars/all.yml</code>，依角色不同的寫在角色的檔案，每台不同的寫在 <code>host_vars</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="c"># group_vars/all.yml：每台相同的預設值</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">host_provider</span><span class="p">:</span><span class="w"> </span><span class="l">container</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">host_region</span><span class="p">:</span><span class="w"> </span><span class="l">local</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">min_free_bytes</span><span class="p">:</span><span class="w"> </span><span class="m">1073741824</span><span class="w">       </span><span class="c"># 根檔案系統至少留 1 GiB</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">journal_limit_bytes</span><span class="p">:</span><span class="w"> </span><span class="m">1073741824</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">update_timer</span><span class="p">:</span><span class="w"> </span><span class="l">dnf-automatic.timer</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">manifest_path</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/host-check/host.toml</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">checker_path</span><span class="p">:</span><span class="w"> </span><span class="l">/opt/host-check/host-check.py</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">ansible_python_interpreter</span><span class="p">:</span><span class="w"> </span><span class="l">/usr/bin/python3</span></span></span></code></pre></div>




<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"># group_vars/web.yml：web 角色；依角色不同的串列用帶角色名的變數名</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">web_entrypoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span>- {<span class="w"> </span><span class="nt">name: http, port: 80, exposure</span><span class="p">:</span><span class="w"> </span><span class="l">cdn-only }</span></span></span></code></pre></div>




<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"># group_vars/db.yml：db 角色</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">db_entrypoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span>- {<span class="w"> </span><span class="nt">name: postgres, port: 5432, exposure</span><span class="p">:</span><span class="w"> </span><span class="l">admin-only }</span></span></span></code></pre></div>




<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"># host_vars/mh-web1.yml：只有 mh-web1 不同的值</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">min_free_bytes</span><span class="p">:</span><span class="w"> </span><span class="m">2147483648</span><span class="w">       </span><span class="c"># 這台的根檔案系統至少留 2 GiB</span></span></span></code></pre></div><p><code>web.yml</code> 與 <code>db.yml</code> 的入口串列用 <code>web_entrypoints</code>、<code>db_entrypoints</code> 兩個不同的變數名。兩個群組用同一個變數名時，同層的群組依名稱排序合併、後合併的蓋過先合併的，<code>mh-both1</code> 少掉一個角色的入口；合併規則與聯集的寫法在概念文章〈用 Ansible 的 inventory、群組變數與範本產生每台的清單〉。</p>
<p><code>ansible_python_interpreter</code> 指定受管主機上的 Python。沒有指定時 Ansible 自己找，每次執行都印一段 <code>[WARNING]: Platform linux on host mh-web1 is using the discovered Python interpreter at /usr/bin/python3.12</code>，執行結果相同；指定之後警告消失。<code>manifest_path</code> 與 <code>checker_path</code> 是清單與腳本在受管主機上的固定位置，playbook 與執行檢查的指令都用這兩個值。清單放在 <code>/etc/host-check/</code>，因為 <code>/etc</code> 是 FHS 規定放設定檔的位置，主機備份設定檔時會一起收進去。<code>host_provider</code> 與 <code>host_region</code> 寫進清單 <code>[host]</code> 段的 <code>provider</code> 與 <code>region</code>，這次填 <code>container</code> 與 <code>local</code>，真主機填業者與區域的名稱；主機分在幾家業者或幾個區域時，這兩個值就從每台相同改成依群組或每台不同。</p>
<p>檢查腳本載入清單時逐欄比對型別與允許的值，所以範本照腳本要求的欄位寫。<code>host-check.py</code> 要求的欄位：</p>
<ul>
<li><strong><code>[host]</code> 要有 <code>name</code>、<code>provider</code>、<code>region</code>、<code>role</code> 四個字串</strong>：<code>role</code> 只收一個字串，兼任兩種角色的主機寫成 <code>group_names | join(',')</code>，<code>mh-both1</code> 得到 <code>&quot;db,web&quot;</code>。<code>group_names</code> 是 Ansible 替每台主機算出的所屬群組清單（不含 <code>all</code>），依群組名排序；<code>name</code> 用的 <code>inventory_hostname</code> 是那台主機在 inventory 裡的名字（<code>mh-web1</code>、<code>mh-both1</code>）。</li>
<li><strong><code>[listen_audit]</code> 是 <code>protocols</code> 與 <code>allow</code></strong>：允許接聽的連接埠由兩處合起來，<code>where = &quot;host&quot;</code> 的每一筆 <code>[[entrypoint]]</code> 自動算進去，<code>allow</code> 只寫入口以外、刻意允許的連接埠（每一筆是 <code>{ port, protocol, reason }</code>）。所以入口的串列取聯集之後，<code>listen_audit</code> 就跟著有了兩個角色的連接埠，<code>allow</code> 這次是空的。</li>
<li><strong><code>[[filesystem]]</code> 的門檻是 <code>min_free_bytes</code></strong>：剩餘空間的位元組數下限。</li>
<li><strong><code>exposure</code> 只能是 <code>public</code>、<code>cdn-only</code>、<code>admin-only</code>、<code>loopback</code></strong>：寫了清單外的值（例如 <code>private</code>）時，檢查腳本把清單判成格式錯（exit code 2），〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉實測過。</li>
<li><strong><code>[[entrypoint]]</code> 要有 <code>protocol</code>、<code>where</code>、<code>enforced_by</code>，每一層防護至少一筆 <code>[[entrypoint.verify]]</code></strong>：<code>enforced_by</code> 列出這個入口靠哪幾種防護限制來源（雲端防火牆、主機防火牆這類），每一種是一「層」，每一層至少寫一筆 <code>[[entrypoint.verify]]</code> 說明怎麼確認那一層（同一層寫兩筆也可以，這次每層寫一筆）。<code>where</code> 寫在哪裡要確認：<code>host</code> 是檢查腳本在主機上自己確認，<code>console</code> 是要人到業者主控台確認。這次每個入口寫一層 <code>cloud-firewall</code>，<code>where = &quot;console&quot;</code>，執行時印成要到主控台確認的 <code>MANUAL</code>。</li>
<li><strong><code>[[log]]</code>、<code>[[accumulation]]</code>、<code>[updates]</code>、<code>[[suspend]]</code>、<code>[[resume]]</code> 都是必填</strong>：少了任何一段，腳本判成格式錯。這次各寫一筆最小的內容。<code>[[suspend]]</code> 與 <code>[[resume]]</code> 的每一步要寫出主機停下來時還計不計費（<code>billed_when_stopped</code> 這幾個欄位）；容器沒有計費，填 <code>unknown</code>，欄位的意思在 <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>〈清單欄位的設計：檢查腳本要讀的與人要讀的〉。</li>
</ul>
<p>跟概念文章那份範本對照：概念文章的範本是示範值怎麼代入的節錄，鍵名有幾個是那篇自己取的，例如它的 <code>[[filesystem]]</code> 用 <code>warn_percent</code>（使用率的百分比），檢查腳本不認得這個鍵。</p>
<p>下面是 <code>ansible/host.toml.j2</code> 的全文。字串值一律經過 <code>to_json</code> 輸出，引號與反斜線由它跳脫，含中文的值加 <code>ensure_ascii=False</code> 讓中文原樣寫出；直接輸出時會發生什麼，在概念文章〈值寫進 TOML 的方式：直接輸出、to_json 與 ensure_ascii〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jinja" data-lang="jinja"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="x"># 由 Ansible 依 host.toml.j2 產生，不在主機上手改。TOML 1.0。
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="x">[vars]
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="x">journal_dir = &#34;/var/log/journal&#34;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="x">[host]
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="x">name = </span><span class="cp">{{</span> <span class="nv">inventory_hostname</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="x">provider = </span><span class="cp">{{</span> <span class="nv">host_provider</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="x">region = </span><span class="cp">{{</span> <span class="nv">host_region</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="x">role = </span><span class="cp">{{</span> <span class="nv">group_names</span> <span class="o">|</span> <span class="nf">join</span><span class="o">(</span><span class="s1">&#39;,&#39;</span><span class="o">)</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="x">[listen_audit]
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="x">protocols = [&#34;tcp&#34;]
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="x">allow = []
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="x"></span><span class="cp">{%</span> <span class="k">set</span> <span class="nv">eps</span> <span class="o">=</span> <span class="o">[{</span><span class="s1">&#39;name&#39;</span><span class="o">:</span> <span class="s1">&#39;ssh&#39;</span><span class="o">,</span> <span class="s1">&#39;port&#39;</span><span class="o">:</span> <span class="m">22</span><span class="o">,</span> <span class="s1">&#39;exposure&#39;</span><span class="o">:</span> <span class="s1">&#39;admin-only&#39;</span><span class="o">}]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">   <span class="o">+</span> <span class="o">(</span><span class="nv">web_entrypoints</span> <span class="k">if</span> <span class="s1">&#39;web&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[])</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">   <span class="o">+</span> <span class="o">(</span><span class="nv">db_entrypoints</span> <span class="k">if</span> <span class="s1">&#39;db&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[])</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="x"></span><span class="cp">{%</span> <span class="k">for</span> <span class="nv">ep</span> <span class="k">in</span> <span class="nv">eps</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="x">[[entrypoint]]
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="x">name = </span><span class="cp">{{</span> <span class="nv">ep.name</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="x">port = </span><span class="cp">{{</span> <span class="nv">ep.port</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="x">protocol = &#34;tcp&#34;
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="x">exposure = </span><span class="cp">{{</span> <span class="nv">ep.exposure</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="x">where = &#34;host&#34;
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="x">enforced_by = [&#34;cloud-firewall&#34;]
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="x">  [[entrypoint.verify]]
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="x">  layer = &#34;cloud-firewall&#34;
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="x">  where = &#34;console&#34;
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="x">  console_path = &#34;業者主控台 → 網路 → 防火牆規則&#34;
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="x">  expect = </span><span class="cp">{{</span> <span class="o">(</span><span class="s2">&#34;TCP &#34;</span> <span class="o">~</span> <span class="nv">ep.port</span> <span class="o">~</span> <span class="s2">&#34; 的規則只對 &#34;</span> <span class="o">~</span> <span class="nv">ep.exposure</span> <span class="o">~</span> <span class="s2">&#34; 的來源開放&#34;</span><span class="o">)</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="x"></span><span class="cp">{%</span> <span class="k">endfor</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="x">[[log]]
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="x">name = &#34;sshd&#34;
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="x">source = &#34;journal:sshd.service&#34;
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="x">look_for = &#34;登入失敗與不認得的帳號&#34;
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="x">interval_days = 7
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="x">manual_command = &#34;journalctl -u sshd --since -7d&#34;
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="x">[[accumulation]]
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="x">name = &#34;journal&#34;
</span></span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="x">path = [&#34;</span><span class="cp">{%</span> <span class="k">raw</span> <span class="cp">%}</span>{{journal_dir}}<span class="cp">{%</span> <span class="k">endraw</span> <span class="cp">%}</span><span class="x">&#34;]
</span></span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="x">missing_ok = true
</span></span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="x">limit_bytes = </span><span class="cp">{{</span> <span class="nv">journal_limit_bytes</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">46</span><span class="cl"><span class="x">purge_command = &#34;journalctl --vacuum-size=500M&#34;
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="x">[[filesystem]]
</span></span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="x">name = &#34;root&#34;
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="x">mount = &#34;/&#34;
</span></span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="x">min_free_bytes = </span><span class="cp">{{</span> <span class="nv">min_free_bytes</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">52</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="x">[updates]
</span></span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="x">mode = &#34;download-only&#34;
</span></span></span><span class="line"><span class="ln">55</span><span class="cl"><span class="x">timer = </span><span class="cp">{{</span> <span class="nv">update_timer</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">56</span><span class="cl"><span class="x">config_files = [&#34;/etc/dnf/automatic.conf&#34;]
</span></span></span><span class="line"><span class="ln">57</span><span class="cl"><span class="x">expect = { download_updates = &#34;yes&#34;, apply_updates = &#34;no&#34; }
</span></span></span><span class="line"><span class="ln">58</span><span class="cl"><span class="x">reboot_check = &#34;dnf needs-restarting -r&#34;
</span></span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">60</span><span class="cl"><span class="x">[[suspend]]
</span></span></span><span class="line"><span class="ln">61</span><span class="cl"><span class="x">step = 1
</span></span></span><span class="line"><span class="ln">62</span><span class="cl"><span class="x">resource = &#34;主機&#34;
</span></span></span><span class="line"><span class="ln">63</span><span class="cl"><span class="x">billed_when_stopped = &#34;unknown&#34;
</span></span></span><span class="line"><span class="ln">64</span><span class="cl"><span class="x">billed_when_stopped_beyond_free = &#34;unknown&#34;
</span></span></span><span class="line"><span class="ln">65</span><span class="cl"><span class="x">billing_basis = &#34;依業者的計費文件&#34;
</span></span></span><span class="line"><span class="ln">66</span><span class="cl"><span class="x">where = &#34;console&#34;
</span></span></span><span class="line"><span class="ln">67</span><span class="cl"><span class="x">console_path = &#34;業者主控台 → 主機 → 停止&#34;
</span></span></span><span class="line"><span class="ln">68</span><span class="cl"><span class="x">verify = &#34;主機的狀態是已停止&#34;
</span></span></span><span class="line"><span class="ln">69</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">70</span><span class="cl"><span class="x">[[resume]]
</span></span></span><span class="line"><span class="ln">71</span><span class="cl"><span class="x">step = 1
</span></span></span><span class="line"><span class="ln">72</span><span class="cl"><span class="x">resource = &#34;主機&#34;
</span></span></span><span class="line"><span class="ln">73</span><span class="cl"><span class="x">billed_when_stopped = &#34;unknown&#34;
</span></span></span><span class="line"><span class="ln">74</span><span class="cl"><span class="x">billed_when_stopped_beyond_free = &#34;unknown&#34;
</span></span></span><span class="line"><span class="ln">75</span><span class="cl"><span class="x">billing_basis = &#34;依業者的計費文件&#34;
</span></span></span><span class="line"><span class="ln">76</span><span class="cl"><span class="x">where = &#34;console&#34;
</span></span></span><span class="line"><span class="ln">77</span><span class="cl"><span class="x">console_path = &#34;業者主控台 → 主機 → 啟動&#34;
</span></span></span><span class="line"><span class="ln">78</span><span class="cl"><span class="x">verify = &#34;主機的狀態是執行中&#34;</span></span></span></code></pre></div><p>範本裡每台相同的值（ssh 的入口、log、累積的資料、更新的設定、暫停與恢復的步驟）直接寫在範本本身：這些值在所有主機都相同、也不隨角色變，放進 <code>group_vars/all.yml</code> 只多一層間接；它開始依群組或主機而變時，才換成變數。依角色不同的入口由 <code>eps</code> 那一行依 <code>group_names</code> 取聯集，每台不同的只有主機名與 <code>min_free_bytes</code>。<code>[[accumulation]]</code> 的 <code>path</code> 用檢查腳本自己的替換語法 <code>{{journal_dir}}</code>，包在 <code>{% raw %}</code> 與 <code>{% endraw %}</code> 之間，Jinja2 原樣輸出，留給檢查腳本換成 <code>[vars]</code> 的值；理由在概念文章〈範本的 {{ }} 與清單自己的替換語法同形〉。</p>
<p>部署到主機之前，先在控制端用 <code>-c local</code> 產生一份到 <code>out/</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"># 控制端容器裡，在 /work/ansible 執行；out/ 要先存在</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p out
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">ansible all -i inventory.yml -c <span class="nb">local</span> -e <span class="nv">ansible_python_interpreter</span><span class="o">=</span>/usr/local/bin/python3 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  -m template -a <span class="s2">&#34;src=host.toml.j2 dest=out/{{ inventory_hostname }}.toml&#34;</span> -o
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">python3 -c <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">import sys, tomllib
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">for f in sys.argv[1:]:
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">    d = tomllib.load(open(f, &#34;rb&#34;))
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    print(f, d[&#34;host&#34;][&#34;role&#34;], [(e[&#34;name&#34;], e[&#34;port&#34;]) for e in d[&#34;entrypoint&#34;]], d[&#34;filesystem&#34;][0][&#34;min_free_bytes&#34;], d[&#34;accumulation&#34;][0][&#34;path&#34;])
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">&#39;</span> out/*.toml</span></span></code></pre></div><p><code>ansible</code> 這一條兩台都印 <code>CHANGED</code>，exit code 0。<code>-m</code> 指定要執行的模組（這裡是 <code>template</code>），<code>-a</code> 是給那個模組的參數，<code>-o</code> 把每台的結果擠成一行印出。<code>-c local</code> 讓 Ansible 在控制端自己身上執行模組，這時「受管主機」就是控制端，而 <code>group_vars/all.yml</code> 的 <code>ansible_python_interpreter</code>（<code>/usr/bin/python3</code>）照樣套用；<code>python:3.12-slim</code> 沒有 <code>/usr/bin/python3</code>，Python 裝在 <code>/usr/local/bin/python3</code>，所以用 <code>-e</code> 換成控制端的路徑，不換的話這一條以 exit code 2 失敗。<code>python3</code> 那一段用 <code>tomllib</code> 讀回兩份清單，印出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">out/mh-both1.toml db,web [(&#39;ssh&#39;, 22), (&#39;http&#39;, 80), (&#39;postgres&#39;, 5432)] 1073741824 [&#39;{{journal_dir}}&#39;]
</span></span><span class="line"><span class="ln">2</span><span class="cl">out/mh-web1.toml web [(&#39;ssh&#39;, 22), (&#39;http&#39;, 80)] 2147483648 [&#39;{{journal_dir}}&#39;]</span></span></code></pre></div><p><code>mh-both1</code> 有兩個角色的入口，門檻沿用 <code>all</code> 的 1 GiB；<code>mh-web1</code> 只有 web 的入口，門檻是 <code>host_vars</code> 的 2 GiB；兩份的 <code>path</code> 都留著 <code>{{journal_dir}}</code>。<code>tomllib</code> 讀得懂只代表語法正確；要確認欄位是否符合檢查腳本的規格，得讓檢查腳本自己載入一次，〈部署清單：建目錄、copy 與 template、validate 改用檢查腳本的載入函式、&ndash;check 的預覽〉裡 template 任務的 <code>validate</code> 做的就是這件事。<code>out/</code> 的檔案在這個 umask <code>0000</code> 的控制端上寫成 <code>0666</code>；它們只用來預覽，看完刪掉即可。省略這一步時的結果，在〈這一節的其他做法：省略本機的預覽〉。</p>
<h3 id="這一節的其他做法省略本機的預覽">這一節的其他做法：省略本機的預覽</h3>
<p>這一步可以省略，直接部署也會寫出同樣的內容；先在本機產生一份的好處是不連任何主機就看得到每台的清單，改範本時可以先比對 <code>out/</code> 裡新舊兩份的差異。</p>
<h2 id="部署清單建目錄copy-與-templatevalidate-改用檢查腳本的載入函式check-的預覽">部署清單：建目錄、copy 與 template、validate 改用檢查腳本的載入函式、&ndash;check 的預覽</h2>
<p>部署有三個任務：建放腳本與清單的目錄、把檢查腳本複製過去、依範本寫出每台的清單。三個任務都要以 root 寫進 <code>/opt</code> 與 <code>/etc</code>，所以整個 play 設 <code>become: true</code>，Ansible 以 <code>ops</code> 登入後經由 sudo 切成 root。</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"># deploy.yml</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">hosts</span><span class="p">:</span><span class="w"> </span><span class="l">all</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">become</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">tasks</span><span class="p">:</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">放腳本與清單的目錄</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">ansible.builtin.file</span><span class="p">:</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">path</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ item }}&#34;</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">state</span><span class="p">:</span><span class="w"> </span><span class="l">directory</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">owner</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0755&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">loop</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">        </span>- <span class="s2">&#34;{{ checker_path | dirname }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">        </span>- <span class="s2">&#34;{{ manifest_path | dirname }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">檢查腳本</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span><span class="nt">ansible.builtin.copy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">        </span><span class="nt">src</span><span class="p">:</span><span class="w"> </span><span class="l">files/host-check.py</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">dest</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ checker_path }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">        </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">        </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">        </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0755&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">每台的清單（寫出之前用檢查腳本自己的載入函式驗證）</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span><span class="nt">ansible.builtin.template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">        </span><span class="nt">src</span><span class="p">:</span><span class="w"> </span><span class="l">host.toml.j2</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">        </span><span class="nt">dest</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ manifest_path }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">        </span><span class="nt">owner</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">        </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">root</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">        </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0644&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">        </span><span class="nt">validate</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="sd">          python3 -I -c &#39;import runpy, sys;
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="sd">          runpy.run_path(sys.argv[1], run_name=&#34;hc&#34;)[&#34;load_manifest&#34;](sys.argv[2])&#39;
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="sd">          {{ checker_path }} %s</span></span></span></code></pre></div><p>建目錄的任務用 <code>loop</code> 對串列裡的每一項各執行一次，每一次的值放在 <code>item</code>；<code>dirname</code> 過濾器取路徑的上層目錄，<code>/opt/host-check/host-check.py</code> 得到 <code>/opt/host-check</code>。<code>validate: &gt;-</code> 是 YAML 的折疊寫法：底下幾行接成一行、行與行之間換成空白、結尾不留換行，長指令可以分行寫。</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"># 控制端容器裡</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible-playbook -i inventory.yml deploy.yml --check --diff   <span class="c1"># 預覽，不寫任何檔案</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ansible-playbook -i inventory.yml deploy.yml</span></span></code></pre></div><p>加上 <code>file</code> 任務之後，第一次執行三個任務都是 <code>changed</code>，再執行一次 <code>changed=0</code>，花 1.9 秒（兩台，控制端與主機在同一台開發機上，真主機要加上網路來回的時間）。</p>
<p><strong>這次遇到的錯誤</strong>：建目錄的任務是第一次實跑失敗之後加的。最初的 playbook 只有 copy 與 template 兩個任務，<code>--check --diff</code> 預覽時兩台都回報 <code>changed=2</code>、<code>failed=0</code>；實際執行時兩台都在 copy 那一步失敗，訊息是 <code>Destination directory /opt/host-check does not exist</code>，exit code 2。copy 與 template 都不建上層目錄（template 單獨執行時同樣以 <code>Destination directory … does not exist</code> 失敗，另外單獨實跑過一次確認），而 check 模式沒有實際寫檔，碰不到這個錯誤，所以預覽的 <code>failed=0</code> 不代表實際執行會成功。</p>
<p><strong><code>validate</code> 用的是檢查腳本自己的載入函式</strong>。<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>〈產生時驗證清單與比對主機上的清單〉的 <code>validate</code> 範例只用 <code>tomllib</code> 解析，驗得到語法、驗不到欄位。<code>host-check.py</code> 的 <code>load_manifest()</code> 讀清單並逐欄驗證（逐欄驗證的程式在原始碼那一篇〈清單的載入與驗證：Probe、_Validator、load_manifest 與 validate〉），格式錯時丟出 <code>ManifestError</code>，不執行任何檢查，所以 <code>validate</code> 用 <code>runpy.run_path</code> 載入腳本、只呼叫這個函式：函式丟出例外時 Python 以 exit 1 結束，Ansible 判成驗證失敗，那一台的清單維持原樣。</p>
<ul>
<li><strong>用 <code>runpy.run_path</code> 載入腳本</strong>：腳本開頭有 <code>from __future__ import annotations</code>，<code>dataclass</code> 要從 <code>sys.modules</code> 找到這個模組。<code>runpy.run_path</code> 執行期間會把它登記進去，<code>run_name=&quot;hc&quot;</code> 讓腳本結尾的 <code>if __name__ == &quot;__main__&quot;</code> 不成立，<code>main()</code> 不會跑。</li>
<li><strong><code>%s</code> 與 <code>{{ checker_path }}</code></strong>：<code>%s</code> 由 Ansible 換成暫存檔的路徑，<code>{{ checker_path }}</code> 換成上一個任務放好的腳本。<code>validate</code> 在受管主機上執行，用的是主機上的 Python 與主機上那一份腳本，所以 copy 的任務要排在 template 之前，主機上的 Python 也要是 3.11 以上（<code>tomllib</code>）。</li>
<li><strong><code>-I</code></strong>：Python 的隔離模式，忽略所有 <code>PYTHON*</code> 環境變數，不把目前目錄與使用者的 site-packages 加進模組的搜尋路徑；執行 <code>validate</code> 時的工作目錄裡放了 <code>json.py</code> 這類跟標準函式庫同名的檔案，也不會取代標準函式庫被載入。</li>
</ul>
<p><strong>這次遇到的錯誤</strong>：寫進 <code>validate</code> 之前，先在控制端拿 <code>out/</code> 的兩份清單試載入；最初用 <code>importlib.util.spec_from_file_location</code> 載入，在腳本的 <code>@dataclass</code> 那一行丟出 <code>AttributeError: 'NoneType' object has no attribute '__dict__'</code>，因為那樣載入的模組沒有登記進 <code>sys.modules</code>。</p>
<p><strong>check 模式不執行 <code>validate</code></strong>：另外在一個裝 ansible-core 2.17.7 的容器裡，以 <code>-c local</code> 對一個 template 任務設 <code>validate</code> 指向一條一定 exit 1、執行時會留下紀錄的指令，<code>--check --diff</code> 回報 <code>changed</code>、exit code 0，紀錄檔沒有出現；拿掉 <code>--check</code> 實際執行時以 <code>failed to validate</code> 失敗、exit code 2，紀錄檔有那一行。所以會被 <code>validate</code> 擋下的清單，<code>--check</code> 的預覽照樣顯示成功。</p>
<p><code>--check --diff</code> 對 copy 任務會印出整份腳本的差異（這次一千多行），清單的差異埋在後面；只想看清單時，用〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉那一節的 ad-hoc 指令，只對 template 模組跑 <code>--check --diff</code>。</p>
<p><code>validate</code> 的其他寫法、逐台驗證造成的部分部署、copy 任務的替代，在〈這一節的其他做法：validate 的寫法、部分部署與 copy 的替代〉。</p>
<h3 id="這一節的其他做法validate-的寫法部分部署與-copy-的替代">這一節的其他做法：validate 的寫法、部分部署與 copy 的替代</h3>
<p>這個 <code>validate</code> 擋得下的範圍在下面〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉實測過一次：db 角色寫了 <code>exposure: private</code> 時，只有 <code>mh-both1</code> 驗證失敗、維持舊的清單，<code>mh-web1</code> 照常部署。三種寫法的差別：</p>
<ul>
<li><strong>檢查腳本的載入函式</strong>（這次的做法）：型別、允許的值、必填欄位、白名單都在寫出之前驗過，主機上不會出現一份腳本拒絕載入的清單。需要腳本有這樣的函式，而且 <code>validate</code> 依賴腳本內部的函式名，腳本改名時這一行要跟著改。</li>
<li><strong>只用 <code>tomllib</code></strong>：只要求主機上有 Python 3.11 以上，不依賴腳本的內部；<code>exposure = &quot;private&quot;</code> 這種語法正確、值不允許的寫法會被寫到主機上，等下一次執行檢查時腳本以 exit code 2 結束（<code>exposure = &quot;private&quot;</code> 是合法的 TOML，所以 <code>tomllib</code> 照常解析，這一點是從 TOML 的語法推的，沒有另外實跑）。</li>
<li><strong>不寫 <code>validate</code></strong>：寫壞的清單直接上主機。〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉那一節在主機上手改清單的實驗就是這個結果：那台主機在修好之前一項檢查都沒有跑。</li>
</ul>
<p><code>validate</code> 逐台執行，一台驗證失敗時其他台照常寫出新版，結果是部分部署：這次 <code>exposure: private</code> 那一輪，<code>mh-web1</code> 照常部署、<code>mh-both1</code> 留在舊版；範本的改動同時涉及兩台時，兩台就會停在不同的版本。要全部主機一起成功或一起不動時，有兩種替代：先在控制端用 <code>-c local</code> 產生全部主機的清單，用同一個載入函式驗完再部署；或在 play 設 <code>any_errors_fatal: true</code>，有一台失敗就停下整個 play（兩種都是推論，沒有實跑；<code>any_errors_fatal</code> 停的是之後的任務，失敗那一刻之前已經寫出的主機不會退回）。</p>
<p>copy 那一個任務另有兩種做法：把檢查腳本放進主機的映像檔，或打包成套件由套件管理員安裝。兩種都讓腳本的版本跟著映像檔或套件的版本走，代價是改腳本要重建映像檔或重新打包；打包成 rpm 時，另外換到 <code>rpm -V</code> 查得出主機上那一份腳本有沒有被改過。這次腳本與清單一起由 Ansible 部署，改腳本與改範本是同一個步驟。這一點是從部署的流程推的，沒有實作另外兩種。</p>
<h2 id="以--b-與-systemd-run-執行檢查腳本每台的輸出與-ansible-的-exit-code">以 -b 與 systemd-run 執行檢查腳本：每台的輸出與 ansible 的 exit code</h2>
<p>執行檢查之前，先確認 <code>systemd-run</code> 的限制在這次的容器裡有沒有套上：<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> 記錄過 OrbStack 的 LXC 機器會讓限制靜默無效。</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"># 控制端容器裡</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -b -m <span class="nb">command</span> -a <span class="s2">&#34;systemd-detect-virt&#34;</span> -o
</span></span><span class="line"><span class="ln">3</span><span class="cl">ansible mh-web1 -i inventory.yml -b -m <span class="nb">command</span> -a <span class="s2">&#34;systemd-run --wait --pipe --collect -p ProtectSystem=strict touch /var/x&#34;</span></span></span></code></pre></div><p><code>systemd-detect-virt</code> 兩台都印 <code>docker</code>（Docker 容器；LXC 環境印的是 <code>lxc</code>）；<code>touch /var/x</code> 那一條在 <code>mh-web1</code> 上以 <code>rc=1</code> 失敗，輸出 <code>/bin/touch: cannot touch '/var/x': Read-only file system</code>，<code>ProtectSystem=strict</code> 套上了。印的是 <code>lxc</code>、或 <code>touch</code> 成功時，這個環境量不到沙箱的效果，真主機上的確認方式在〈容器跟真主機不同的地方，以及在真主機上要另外確認的項目〉。這一條的 <code>ansible</code> 指令本身以 exit code 2 結束，是預期的結果：<code>rc=1</code> 正是要看到的失敗（整批的 exit 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>〈用 Ansible 對多台主機執行檢查腳本：整批的 exit code、每台的 rc 與執行身分的代價〉）。</p>
<p>執行檢查的指令寫成一支小腳本，屬性照 TOML 那一篇〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉的 <code>systemd-run</code> 寫法。這次量輸出時 <code>check.sh</code> 寫的是固定路徑 <code>/opt/host-check/host-check.py</code> 與 <code>/etc/host-check/host.toml</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="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span><span class="c1"># check.sh：對 inventory 的每台主機以 root、在 systemd 沙箱裡執行檢查腳本；&#34;$@&#34; 接其他 ansible 參數（例如 --limit）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ansible all -i inventory.yml -b -m <span class="nb">command</span> -o -a <span class="s2">&#34;systemd-run --wait --pipe --collect \
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s2">  -p ProtectSystem=strict -p &#39;ReadWritePaths=/var/log /var/cache/dnf&#39; \
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s2">  -p StateDirectory=host-check -p StateDirectoryMode=0700 \
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s2">  -p NoNewPrivileges=yes -p PrivateTmp=yes -p IPAddressDeny=any \
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s2">  python3 {{ checker_path }} --manifest {{ manifest_path }}&#34;</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div><p><code>check.sh</code> 建在控制端的 <code>/work/ansible</code>（開發機上是工作目錄的 <code>ansible/</code>），建好之後在控制端容器裡加上執行權限，下文以 <code>./check.sh</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">chmod +x check.sh</span></span></code></pre></div><p>要依環境換的位置只有 <code>all</code>（要檢查的主機範圍）。腳本與清單的路徑寫成 <code>{{ checker_path }}</code> 與 <code>{{ manifest_path }}</code>，Ansible 執行時用每台主機的變數代入，跟部署時用的是同一組值；寫死路徑的話，<code>group_vars</code> 改了位置而 <code>check.sh</code> 沒改時，檢查會安靜地讀舊路徑上過期的清單。<code>command</code> 模組用 <code>shlex</code> 切參數、不經過 shell，所以 <code>'ReadWritePaths=/var/log /var/cache/dnf'</code> 的單引號讓兩個路徑留在同一個參數裡。變數代入與引號的切法另外在一個裝 ansible-core 2.17.7 的容器裡，以 <code>-c local</code> 對設了這兩個變數的 localhost 實跑過一次：<code>{{ checker_path }}</code> 與 <code>{{ manifest_path }}</code> 換成了變數的值，<code>'ReadWritePaths=/var/log /var/cache/dnf'</code> 留在同一個參數裡。不加 <code>--manifest</code> 時，腳本讀它自己所在目錄的 <code>host.toml</code>，這次清單放在 <code>/etc/host-check/</code>，所以要寫。</p>
<p>讀下面的輸出之前，先把兩個 exit code 分開：<code>ansible</code> 指令本身的 exit code 是整批的結果，每台的 <code>rc</code> 是那台主機上檢查腳本的 exit code。</p>
<table>
  <thead>
      <tr>
          <th><code>ansible</code> 指令的 exit code（整批）</th>
          <th>代表</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>每台的任務都成功</td>
      </tr>
      <tr>
          <td>2</td>
          <td>至少一台主機的任務失敗（標成 <code>FAILED!</code>）</td>
      </tr>
      <tr>
          <td>4</td>
          <td>有主機連不上（標成 <code>UNREACHABLE!</code>）</td>
      </tr>
  </tbody>
</table>
<table>
  <thead>
      <tr>
          <th>每台的 <code>rc</code>（檢查腳本的 exit code）</th>
          <th>代表</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>沒有 <code>WARN</code></td>
      </tr>
      <tr>
          <td>1</td>
          <td>至少一項 <code>WARN</code></td>
      </tr>
      <tr>
          <td>2</td>
          <td>清單格式錯，或命令列參數錯（stderr 印用法說明而不是 <code>manifest error:</code>）</td>
      </tr>
  </tbody>
</table>
<p>讀的順序：先看整批的值決定要不要告警，再看每台的 <code>rc</code> 決定處置；<code>rc</code> 是 1 時另看輸出裡有沒有 <code>summary:</code> 那一行。</p>
<p><code>./check.sh</code> 第一次執行，<code>ansible</code> 的 exit code 是 0，花 0.9 秒。兩台都印 <code>CHANGED | rc=0</code>；<code>command</code> 模組不論指令做了什麼一律回報 changed，所以這個 <code>CHANGED</code> 只代表指令執行了，不代表檢查改動了主機。<code>-o</code> 把每台的報告印成一行、換行寫成 <code>\n</code>；要看分行的報告時，拿掉 <code>-o</code> 再跑一次（<code>ansible all -i inventory.yml -b -m command -a &quot;…&quot;</code>，指令照 <code>check.sh</code>），每台的 stdout 會分行印出。<code>mh-both1</code> 的報告：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">PASS   entrypoint/ssh:22/tcp  有程序在聽（0.0.0.0, [::]），exposure=admin-only
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">MANUAL entrypoint/ssh:22/tcp/cloud-firewall  [主控台] 業者主控台 → 網路 → 防火牆規則 → 預期：TCP 22 的規則只對 admin-only 的來源開放
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">PASS   entrypoint/http:80/tcp  有程序在聽（0.0.0.0），exposure=cdn-only
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">MANUAL entrypoint/http:80/tcp/cloud-firewall  [主控台] 業者主控台 → 網路 → 防火牆規則 → 預期：TCP 80 的規則只對 cdn-only 的來源開放
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">PASS   entrypoint/postgres:5432/tcp  有程序在聽（0.0.0.0），exposure=admin-only
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">MANUAL entrypoint/postgres:5432/tcp/cloud-firewall  [主控台] 業者主控台 → 網路 → 防火牆規則 → 預期：TCP 5432 的規則只對 admin-only 的來源開放
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">PASS   listen-audit  loopback 以外只有清單列出的連接埠在聽（tcp）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">PASS   log/sshd  最新一筆：2026-10-26T13:44:47+00:00 mh-both1 python3[1904]: ansible-ansible.legacy.command Invoked …；每 7 天讀一次：journalctl -u sshd --since -7d
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">PASS   accumulation/journal  路徑不存在，算 0 bytes（/var/log/journal）
</span></span><span class="line"><span class="ln">10</span><span class="cl">PASS   filesystem/root  / 剩 77.7 GiB／共 117.6 GiB（下限 1.0 GiB）
</span></span><span class="line"><span class="ln">11</span><span class="cl">PASS   updates/dnf-automatic.timer/is-enabled  enabled（預期 enabled）
</span></span><span class="line"><span class="ln">12</span><span class="cl">PASS   updates/dnf-automatic.timer/is-active  active（預期 active）
</span></span><span class="line"><span class="ln">13</span><span class="cl">PASS   updates/config  download_updates = yes, apply_updates = no（mode download-only，讀了 /etc/dnf/automatic.conf）
</span></span><span class="line"><span class="ln">14</span><span class="cl">PASS   updates/reboot  不需要重開機
</span></span><span class="line"><span class="ln">15</span><span class="cl">since: 沒有前一次的結果（/var/lib/host-check/last.json）
</span></span><span class="line"><span class="ln">16</span><span class="cl">summary: 11 PASS, 0 WARN, 3 MANUAL（phase maintain，/etc/host-check/host.toml）</span></span></code></pre></div><p><code>mh-web1</code> 少了 postgres 的兩行，結果是 <code>10 PASS, 0 WARN, 2 MANUAL</code>，<code>filesystem/root</code> 的下限印成 <code>2.0 GiB</code>。<code>summary:</code> 裡的 <code>phase maintain</code> 是這次跑的檢查階段：檢查腳本分維護（<code>maintain</code>，預設）、暫停（<code>suspend</code>）與恢復（<code>resume</code>）三種階段，這次跑的是維護。報告之後的 stderr 是 <code>systemd-run</code> 印的那一次執行的資料：<code>Finished with result: success</code>、<code>Service runtime: 378ms</code>、<code>Memory peak: 79M</code>。三個值得讀的位置：</p>
<ul>
<li><strong><code>accumulation/journal</code> 判成 <code>PASS</code> 而路徑不存在</strong>：<code>[[accumulation]]</code> 寫了 <code>missing_ok = true</code>，這個容器的 <a href="/blog/linux/dotfile/knowledge-cards/journald/" data-link-title="journald（systemd-journald）" data-link-desc="用 journalctl 查不到重開機之前的紀錄、journal 佔掉多少磁碟不清楚、某個服務的 log 出現 Suppressed 訊息，或要判斷一筆紀錄該去 journal 還是 /var/log 底下的檔案找時">journald（systemd-journald）</a> 只把 journal 存在記憶體裡（<code>/run/log/journal</code>），<code>/var/log/journal</code> 沒有建出來。<code>path</code> 那一格印出 <code>/var/log/journal</code>，代表檢查腳本把 <code>{{journal_dir}}</code> 換成了 <code>[vars]</code> 的值。</li>
<li><strong><code>log/sshd</code> 的最新一筆是 Ansible 自己留下的</strong>：<code>ansible-ansible.legacy.command Invoked</code> 是這次執行檢查時 Ansible 的模組寫進 journal 的紀錄，而它被算在 <code>sshd.service</code> 底下，所以「journal 裡有 sshd 的紀錄」這一項由檢查的執行本身滿足。成因寫在〈容器跟真主機不同的地方，以及在真主機上要另外確認的項目〉。</li>
<li><strong>結果檔</strong>：<code>/var/lib/host-check</code> 是 <code>700</code>、<code>last.json</code> 是 <code>600</code>，擁有者都是 root，<code>StateDirectoryMode=0700</code> 生效了。第二次執行時 <code>since:</code> 那一行印出上一次的時間，量到的值都跟前一次相同時接著印 <code>，與前一次相同</code>（<code>since: &lt;上一次的時間&gt;，與前一次相同</code>），後面沒有其他行；<code>DELTA &lt;項目&gt; 舊值 → 新值（差）</code> 這種行只在量到的值變了時出現，例如〈一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc〉把門檻改成 1 TiB 的那一輪。</li>
</ul>
<p>不加 <code>-b</code>、不經過 <code>systemd-run</code>、改由每台的 timer 執行、接上排程時讀結果的方式，以及沙箱屬性寫在 <code>check.sh</code> 的代價，在〈這一節的其他做法：-b、systemd-run、執行的位置、排程讀結果與屬性的住址〉。</p>
<h3 id="這一節的其他做法-bsystemd-run執行的位置排程讀結果與屬性的住址">這一節的其他做法：-b、systemd-run、執行的位置、排程讀結果與屬性的住址</h3>
<p>執行檢查這一步，在 <code>-b</code>、<code>systemd-run</code> 與執行的位置上各有替代。「不加 <code>-b</code>」與「不加 <code>-b</code>、也不經過 <code>systemd-run</code>」只對 <code>mh-web1</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"># 控制端容器裡；不加 -b：check.sh 那一條拿掉 -b，只對 mh-web1</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible mh-web1 -i inventory.yml -m <span class="nb">command</span> -o -a <span class="s2">&#34;systemd-run --wait --pipe --collect \
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s2">  -p ProtectSystem=strict -p &#39;ReadWritePaths=/var/log /var/cache/dnf&#39; \
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s2">  -p StateDirectory=host-check -p StateDirectoryMode=0700 \
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s2">  -p NoNewPrivileges=yes -p PrivateTmp=yes -p IPAddressDeny=any \
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s2">  python3 /opt/host-check/host-check.py --manifest /etc/host-check/host.toml&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 不加 -b、也不經過 systemd-run：以 ops 直接執行檢查腳本</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">ansible mh-web1 -i inventory.yml -m <span class="nb">command</span> -o -a <span class="s2">&#34;python3 /opt/host-check/host-check.py --manifest /etc/host-check/host.toml --no-state&#34;</span></span></span></code></pre></div><ul>
<li><strong>不加 <code>-b</code></strong>：<code>systemd-run</code> 以 <code>ops</code> 執行，<code>ansible</code> 把 <code>mh-web1</code> 標成 <code>FAILED!</code>、<code>rc</code> 是 1，stderr 是 <code>Failed to start transient service unit: Access denied</code>，檢查腳本沒有執行，<code>ansible</code> 的 exit code 是 2。這個 <code>rc</code> 跟檢查腳本「至少一項 <code>WARN</code>」的 exit code 1 相同，只看 <code>rc</code> 分不開，要看輸出裡有沒有 <code>summary:</code> 那一行：腳本跑完一定印它，沒有這一行代表腳本沒有跑完或沒有開始。</li>
<li><strong>不加 <code>-b</code>、也不經過 <code>systemd-run</code></strong>（以 <code>ops</code> 直接執行檢查腳本；<code>--no-state</code> 不讀也不寫結果檔，這次的測試因此不改到 root 那份 <code>last.json</code>）：腳本照常跑完，stderr 印 <code>note: 不是 root，journal、audit.log 與 collector 的目錄可能讀不到（會印成 WARN）</code>，結果是 <code>9 PASS, 1 WARN, 2 MANUAL</code>，多出來的 <code>WARN</code> 是 <code>log/sshd</code>（<code>ops</code> 讀不到 journal）。這次的清單只有少數幾項需要 root；TOML 那一篇的完整清單在專用帳號（加了 <code>systemd-journal</code> 與 <code>adm</code> 兩個群組的非 root 帳號）下，從 37 項 <code>PASS</code> 掉到 29 項。</li>
<li><strong>以 root 執行而不套 <code>systemd-run</code></strong>：這次沒有跑。結果依 TOML 那一篇的量測：檢查照常，dnf 的 log 與 sqlite 的 <code>-shm</code> 這類工具自己的寫入不再被擋下，檢查過程會在主機上留下寫入，只讀的保證沒有了。</li>
<li><strong>Ansible 集中執行，或每台用自己的 timer 執行</strong>：這次用的是 Ansible 集中執行。<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>〈用 Ansible 對多台主機執行檢查腳本：整批的 exit code、每台的 rc 與執行身分的代價〉寫了集中執行的代價：控制端握有能 SSH 登入每台、再切成 root 的憑證。這次的 <code>ops</code> 帳號是 <code>NOPASSWD: ALL</code>，控制端的這把私鑰就等於兩台的 root。</li>
<li><strong>接上排程時讀結果的方式</strong>：<code>-o</code> 的一行輸出要用 <code>grep</code> 分出每台的 <code>rc</code>。設 <code>ANSIBLE_STDOUT_CALLBACK=json</code> 時，<code>ansible</code> 改印一份 JSON，每台的 <code>rc</code> 與 stdout 是其中的欄位，排程的腳本可以直接取值，代價是輸出不再適合人直接讀；改寫成 playbook，在任務上設 <code>failed_when: result.rc not in [0, 1]</code>（<code>result</code> 是 <code>register</code> 存下的結果），<code>WARN</code> 的 <code>rc=1</code> 就不算失敗，整批的 exit code 只在清單格式錯或腳本沒跑起來時變成 2，代價是 <code>WARN</code> 要另外從輸出裡讀（兩種都是推論，沒有實跑）。</li>
</ul>
<p>沙箱的屬性寫在 <code>check.sh</code> 裡有一個代價：同一組屬性在主機自己的 timer unit 裡還有一份，收緊其中一份時漏改另一份，沒有任何訊號。替代做法是把屬性放在主機上的一支包裝腳本或一個靜態的 service unit 裡，<code>check.sh</code> 與 timer 都呼叫它，屬性只有一份（推論，沒有實跑）。</p>
<h2 id="一台變成-warn一台清單格式錯一台連不上ansible-的-exit-code-與每台的-rc">一台變成 WARN、一台清單格式錯、一台連不上：ansible 的 exit code 與每台的 rc</h2>
<p>這一節依序製造門檻造成的 <code>WARN</code>、清單格式錯（被 <code>validate</code> 擋下的與在主機上手改的）、連不上的主機，每一種情形看 <code>ansible</code> 指令本身的 exit code，以及每台的標記與 <code>rc</code>。</p>
<p><strong>門檻讓 <code>mh-web1</code> 變成 <code>WARN</code></strong>。把 <code>host_vars/mh-web1.yml</code> 的 <code>min_free_bytes</code> 改成 1 TiB（<code>1099511627776</code>），行尾的註解 <code># 這台的根檔案系統至少留 2 GiB</code> 一起改成 1 TiB，之後讀這個檔的人才不會被舊的註解誤導。部署之前先只對 template 預覽：</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"># 控制端容器裡</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -b -m template -a <span class="s2">&#34;src=host.toml.j2 dest=/etc/host-check/host.toml&#34;</span> --check --diff</span></span></code></pre></div><p><code>mh-both1</code> 印 <code>SUCCESS</code>、<code>&quot;changed&quot;: false</code>；<code>mh-web1</code> 印 <code>--- before: /etc/host-check/host.toml</code> 與 <code>+++ after:</code> 兩邊的差異，只有一行從 <code>-min_free_bytes = 2147483648</code> 換成 <code>+min_free_bytes = 1099511627776</code>，標成 <code>CHANGED</code>。這條指令的 exit code 是 0，有主機標成 <code>CHANGED</code> 時也是 0，所以拿它定期比對主機上的清單時，要讀輸出裡有沒有 <code>CHANGED</code>，只看 exit 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>〈產生時驗證清單與比對主機上的清單〉。以 <code>ansible-playbook -i inventory.yml deploy.yml</code> 部署之後，再跑 <code>./check.sh</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">mh-both1 | CHANGED | rc=0 | (stdout) …summary: 11 PASS, 0 WARN, 3 MANUAL…
</span></span><span class="line"><span class="ln">2</span><span class="cl">mh-web1 | FAILED! =&gt; {&#34;changed&#34;: true, … &#34;rc&#34;: 1, … &#34;msg&#34;: &#34;non-zero return code&#34;, …}</span></span></code></pre></div><p><code>ansible</code> 的 exit code 是 2。<code>mh-web1</code> 的報告裡是 <code>WARN   filesystem/root  / 剩 77.7 GiB／共 117.6 GiB（下限 1024.0 GiB）</code>、<code>CHANGED filesystem/root  PASS→WARN</code> 與 <code>summary: 9 PASS, 1 WARN, 2 MANUAL</code>。<code>-o</code> 對成功的主機印一行 <code>CHANGED | rc=0 | (stdout) …</code>，對失敗的主機把整份結果印成一行 JSON，<code>rc</code> 是 JSON 裡的一個欄位，所以用 <code>grep</code> 分出每台的 <code>rc</code> 時，兩種形式都要比對。</p>
<p><strong>db 角色的 <code>exposure</code> 寫成 <code>private</code>，被 <code>validate</code> 擋下</strong>。把 <code>group_vars/db.yml</code> 的 <code>admin-only</code> 改成 <code>private</code> 再跑 <code>ansible-playbook -i inventory.yml deploy.yml</code>：exit code 2，<code>mh-both1</code> 在 template 那一步失敗，<code>&quot;msg&quot;: &quot;failed to validate&quot;</code>，stderr 的最後一行是 <code>hc.ManifestError: entrypoint[postgres]: exposure 要是 ['public', 'cdn-only', 'admin-only', 'loopback'] 之一，實際是 'private'</code>；<code>mh-web1</code> 不在 db 群組，<code>failed=0</code>。<code>mh-both1</code> 上的 <code>/etc/host-check/host.toml</code> 仍是上一版，三個 <code>exposure</code> 都沒有 <code>private</code>。把 <code>group_vars/db.yml</code> 改回 <code>admin-only</code>；主機上的清單沒有被改到，這裡不必重新部署。</p>
<p><strong>在主機上手改清單，檢查腳本以 exit code 2 結束</strong>。<code>validate</code> 擋下的是 Ansible 寫出的那一份；有人在主機上直接改檔，或另一條部署路徑沒有 <code>validate</code> 時，壞掉的清單照樣在主機上。用 <code>sed</code> 把 <code>mh-both1</code> 清單裡的 <code>exposure = &quot;admin-only&quot;</code> 改成 <code>exposure = &quot;private&quot;</code>，ssh 與 postgres 兩個入口都改到：</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"># 開發機上執行：在 mh-both1 上直接改清單</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker <span class="nb">exec</span> mh-both1 sed -i <span class="s1">&#39;s/exposure = &#34;admin-only&#34;/exposure = &#34;private&#34;/&#39;</span> /etc/host-check/host.toml</span></span></code></pre></div><p><code>mh-web1</code> 維持 1 TiB 的門檻，再跑 <code>./check.sh</code>：</p>
<table>
  <thead>
      <tr>
          <th>主機</th>
          <th><code>-o</code> 的標記</th>
          <th><code>rc</code></th>
          <th>報告</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>mh-both1</code></td>
          <td><code>FAILED!</code></td>
          <td>2</td>
          <td>stderr 兩行 <code>manifest error: entrypoint[…]: exposure 要是 … 之一，實際是 'private'</code>，最後一行 <code>manifest error: 2 項，沒有執行任何檢查</code></td>
      </tr>
      <tr>
          <td><code>mh-web1</code></td>
          <td><code>FAILED!</code></td>
          <td>1</td>
          <td><code>summary: 9 PASS, 1 WARN, 2 MANUAL</code></td>
      </tr>
  </tbody>
</table>
<p><code>ansible</code> 的 exit code 是 2。兩台都標成 <code>FAILED!</code>，<code>rc</code> 分別是 2 與 1：<code>mh-both1</code> 在修好清單之前一項檢查都沒有跑，<code>mh-web1</code> 跑完了檢查、有一項 <code>WARN</code>（這次是門檻寫錯）。整批的 exit code 與每台的 <code>rc</code> 各自用來決定什麼，判斷依據在概念文章〈用 Ansible 對多台主機執行檢查腳本：整批的 exit code、每台的 rc 與執行身分的代價〉。接著依序做四件事，把兩台帶回全部 <code>PASS</code>：</p>
<ol>
<li><strong>比對主機上的清單</strong>：用門檻那一段只對 template 跑的 <code>--check --diff</code> 指令。<code>mh-both1</code> 標成 <code>CHANGED</code>，差異是兩行 <code>-exposure = &quot;private&quot;</code> 換成 <code>+exposure = &quot;admin-only&quot;</code>；<code>mh-web1</code> 是 <code>SUCCESS</code>，範本與主機上的門檻都是 1 TiB。exit code 0。</li>
<li><strong>門檻改回 2 GiB</strong>：<code>host_vars/mh-web1.yml</code> 的 <code>min_free_bytes</code> 與註解改回 <code>2147483648</code> 與 2 GiB。</li>
<li><strong>重新部署</strong>：<code>ansible-playbook -i inventory.yml deploy.yml</code>。這一步同時照範本把 <code>mh-both1</code> 的清單寫回 <code>admin-only</code>。不重新部署、只在主機上改回時，替代做法是在開發機上跑反向的 <code>sed</code>：<code>docker exec mh-both1 sed -i 's/exposure = &quot;private&quot;/exposure = &quot;admin-only&quot;/' /etc/host-check/host.toml</code>；已經重新部署的話不必跑。</li>
<li><strong>再跑 <code>./check.sh</code></strong>：exit code 回到 0，<code>mh-web1</code> 的報告多一行 <code>CHANGED filesystem/root  WARN→PASS</code>。</li>
</ol>
<p><strong><code>mh-web1</code> 連不上</strong>。在開發機上 <code>docker stop mh-web1</code> 之後跑 <code>./check.sh</code>：exit code 4，花 0.6 秒；<code>mh-web1</code> 印 <code>UNREACHABLE!: Failed to connect to the host via ssh: ssh: Could not resolve hostname mh-web1: Name or service not known</code>，沒有 <code>rc</code>；<code>mh-both1</code> 照常 <code>CHANGED | rc=0</code>。停掉的容器從 Docker network 的 DNS 消失，所以錯誤是解析不到主機名；真主機關機或網路斷了時，錯誤多半是連線逾時或被拒，exit code 同樣是 4（依 ansible-core 2.17.7 的原始碼 <code>lib/ansible/executor/task_queue_manager.py</code> 的 <code>RUN_*</code> 常數與這次的結果推論，真主機的訊息沒有實測）。同一次執行裡一台失敗、一台連不上時，依 ansible-core 2.17.7 的原始碼（<code>plugins/strategy/__init__.py</code>），有主機連不上時優先回 4，沒有才回 2；這次沒有實跑。照本文做不會同時出現兩種情形；排程裡同時有連不上與 <code>FAILED!</code> 時才用得到。</p>
<p>在開發機上 <code>docker start mh-web1</code>，等 systemd 啟動完成、sshd 開始接聽之後（<code>docker exec mh-web1 systemctl is-system-running --wait</code>），再跑一次 <code>./check.sh</code>：exit code 2，<code>mh-web1</code> 的 <code>rc</code> 是 1，報告是 <code>WARN   entrypoint/http:80/tcp  沒有程序在聽這個連接埠，exposure=cdn-only</code> 與 <code>CHANGED entrypoint/http:80/tcp  PASS→WARN</code>：代替 nginx 的 <code>stand-in-http</code> 是 <code>systemd-run</code> 建的暫時的 unit，容器重啟之後沒有被拉起來。這跟真主機重開機之後某個服務沒有設成開機啟動是同一種結果，清單裡寫了那個入口，它就被報出來。在開發機上重新起代替的接聽程式之後，<code>./check.sh</code> 回到 exit code 0：</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"># 開發機上執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker <span class="nb">exec</span> mh-web1 systemd-run --unit<span class="o">=</span>stand-in-http python3 -m http.server <span class="m">80</span></span></span></code></pre></div><p>這次各情形的整批 exit code 與每台的結果：</p>
<table>
  <thead>
      <tr>
          <th>情形</th>
          <th><code>ansible</code> 的 exit code（整批）</th>
          <th>每台的標記與 <code>rc</code>（每台）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全部 <code>PASS</code> 與 <code>MANUAL</code></td>
          <td>0</td>
          <td>兩台 <code>CHANGED</code>、<code>rc=0</code></td>
      </tr>
      <tr>
          <td>一台的門檻造成 <code>WARN</code></td>
          <td>2</td>
          <td>該台 <code>FAILED!</code>、<code>rc</code> 1；另一台 <code>rc=0</code></td>
      </tr>
      <tr>
          <td>一台清單格式錯、另一台有 <code>WARN</code></td>
          <td>2</td>
          <td>兩台都 <code>FAILED!</code>，<code>rc</code> 分別是 2 與 1</td>
      </tr>
      <tr>
          <td>一台連不上</td>
          <td>4</td>
          <td>該台 <code>UNREACHABLE!</code>、沒有 <code>rc</code>；另一台 <code>rc=0</code></td>
      </tr>
      <tr>
          <td>不加 <code>-b</code>，<code>systemd-run</code> 起不來</td>
          <td>2（只對 <code>mh-web1</code> 跑）</td>
          <td>該台 <code>FAILED!</code>、<code>rc</code> 1，輸出沒有 <code>summary:</code></td>
      </tr>
      <tr>
          <td><code>--check --diff</code> 比對出清單被改過</td>
          <td>0</td>
          <td>該台 <code>CHANGED</code>，輸出有 <code>--- before</code> 與 <code>+++ after</code></td>
      </tr>
  </tbody>
</table>
<h2 id="容器跟真主機不同的地方以及在真主機上要另外確認的項目">容器跟真主機不同的地方，以及在真主機上要另外確認的項目</h2>
<p>照著在容器上做完不需要這一節；要把同樣的部署搬到真主機時，逐項確認。這次的受管主機是容器，有幾項結果在真主機上會不同，照著做在真主機上時，下面每一項要另外確認一次：</p>
<ul>
<li><strong>沙箱有沒有套上</strong>：這次的容器 <code>systemd-detect-virt</code> 印 <code>docker</code>，<code>ProtectSystem=strict</code> 底下 <code>touch /var/x</code> 回 <code>Read-only file system</code>。在 OrbStack 的 Linux machine 這類 LXC 環境裡同一條 <code>touch</code> 會成功、而且不報錯（<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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉記錄的 OrbStack Linux machine 實測），所以真主機上照樣先跑 <code>systemd-detect-virt</code> 與 <code>touch /var/x</code> 那兩條，再執行檢查。</li>
<li><strong>SELinux</strong>：容器裡沒有 SELinux（<a href="/blog/linux/dotfile/knowledge-cards/mandatory-access-control/" data-link-title="Mandatory Access Control（強制存取控制）：SELinux 與 AppArmor" data-link-desc="檔案權限看起來正確、程式以 root 執行，存取仍然被拒絕；從備份還原或搬移檔案之後服務回 403；或在 log 裡讀到 avc: denied、apparmor=&#34;DENIED&#34; 時">Mandatory Access Control（強制存取控制）：SELinux 與 AppArmor</a>），<code>getenforce</code> 這類檢查與 SELinux 對 <code>systemd-run</code> 執行的程序的限制都沒有量到。Oracle Linux 與 RHEL 的真主機預設是 enforcing（依 RHEL 文件〈Using SELinux〉，這次沒有在真主機上確認），部署到 <code>/opt/host-check</code> 的腳本由 <code>systemd-run</code> 執行時，SELinux 是否拒絕它的存取，要在真主機上跑一次檢查，再用 <code>ausearch -m avc -ts recent</code> 查有沒有拒絕的紀錄。</li>
<li><strong><code>log/sshd</code> 被 Ansible 自己的紀錄滿足</strong>：容器遮蔽了 <code>systemd-logind</code>，SSH 登入之後的程序留在 <code>sshd.service</code> 的 cgroup 裡，Ansible 模組寫進 journal 的紀錄因此算在 <code>sshd.service</code> 底下。真主機上 logind 會替每個登入建一個 <code>session-&lt;編號&gt;.scope</code>，那些紀錄落在 session 底下（從 logind 的機制推的，沒有在真主機上比對）；在真主機上用 <code>journalctl -u sshd -n 5</code> 看最新幾筆是 sshd 自己的紀錄還是 Ansible 的，確認這一項量的是 sshd。</li>
<li><strong>雲端防火牆</strong>：清單裡每個入口的 <code>cloud-firewall</code> 那一層都是 <code>MANUAL</code>，這次沒有任何雲端資源可以比對。主機與規則由 Terraform 建立時，<code>terraform plan</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>〈清單裡要到主控台確認的項目與 terraform plan 的比對範圍〉。</li>
<li><strong>主機防火牆</strong>：容器裡沒有 firewalld，清單也沒有寫主機防火牆那一層；真主機的清單在 <code>enforced_by</code> 加上 <code>firewalld</code>，並在那一層的 <code>[[entrypoint.verify]]</code> 寫 <code>where = &quot;host&quot;</code> 與探針（<code>command</code> 欄位寫要執行的 <code>firewall-cmd --query-*</code> 指令，<code>expect_exit</code> 這類欄位寫什麼結果算通過）。</li>
<li><strong>journal 只在記憶體裡</strong>：這次的容器與 Oracle Cloud 的 Oracle Linux 10.2 映像檔預設都只把 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>〈journald 的紀錄存在記憶體還是磁碟〉），<code>/var/log/journal</code> 不存在，<code>accumulation/journal</code> 以 <code>missing_ok</code> 判成 <code>PASS</code>。開了 <code>Storage=persistent</code>、或發行版預設建了 <code>/var/log/journal</code> 的主機（Debian 系；同一節寫了 Debian 12、13 與 Ubuntu 24.04 的 systemd 套件在安裝時建立這個目錄，以及在 Oracle Cloud 的 Ubuntu 24.04 映像檔上的確認）上，這一項才量得到實際大小，1 GiB 的上限要照那台主機的磁碟與 <code>SystemMaxUse=</code> 重訂。</li>
<li><strong>連線位址與延遲</strong>：<code>ansible_host</code> 這次是容器名，部署 1.9 秒、檢查 0.9 秒都沒有經過真正的網路；真主機上 <code>ansible_host</code> 填 IP 或 DNS 名稱，時間會加上每台的 SSH 連線。</li>
<li><strong>sudo 與 host key</strong>：這次的 <code>ops</code> 是 <code>NOPASSWD: ALL</code>，<code>known_hosts</code> 由 <code>ssh-keyscan</code> 直接寫入，兩者在真主機上都要重新決定；host key 的做法在〈這一節的其他做法：自己的金鑰與寫入 known_hosts 的方式〉，sudo 要密碼時的做法在〈這一節的其他做法：容器或 VM、作業系統、遮蔽的 unit、privileged、sudo 密碼、安裝版本與雜湊、金鑰密碼〉。</li>
</ul>
<h2 id="清理terraform-destroy容器network映像檔與金鑰">清理：terraform destroy、容器、network、映像檔與金鑰</h2>
<p>清理的順序是先 <code>terraform destroy</code>、再刪容器：destroy 要在控制端容器裡執行，控制端先刪掉的話 Terraform 不在了，State 留在工作目錄裡沒有人處理。這次 State 只記著一份本機檔案；真環境的 State 記著雲端主機，先 destroy 才不會留下還在計費的資源。</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"># 開發機上</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker <span class="nb">exec</span> -w /work/tf mh-ctl terraform destroy -auto-approve   <span class="c1"># 印 Destroy complete! Resources: 1 destroyed.</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker rm -f mh-ctl mh-web1 mh-both1
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker network rm mh-net
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker rmi mh-host:ol10 mh-ctl:1
</span></span><span class="line"><span class="ln">6</span><span class="cl">docker ps -a --filter <span class="nv">name</span><span class="o">=</span>mh- -q<span class="p">;</span> docker network ls --filter <span class="nv">name</span><span class="o">=</span>mh- -q<span class="p">;</span> docker images <span class="s1">&#39;mh-*&#39;</span> -q<span class="p">;</span> docker volume ls --filter <span class="nv">name</span><span class="o">=</span>mh- -q
</span></span><span class="line"><span class="ln">7</span><span class="cl">rm -rf &lt;工作目錄&gt; &lt;工作目錄的上一層&gt;/host-image &lt;工作目錄的上一層&gt;/ctl-image &lt;暫存目錄&gt;/keys</span></span></code></pre></div><p><code>terraform destroy</code> 刪掉的是 <code>local_file.inventory</code>，之後在開發機上 <code>ls &lt;工作目錄&gt;/ansible/inventory.yml</code> 回 <code>No such file or directory</code>。<code>docker ps -a --filter name=mh- -q</code> 那一行的四條查詢都沒有輸出，代表這次建的容器、network、映像檔與 volume 都清掉了（這次的容器沒有掛 named volume，<code>docker volume ls</code> 那一條是確認沒有遺漏）。<code>docker build</code> 留下的建置快取沒有清：清快取要用 <code>docker builder prune</code>，它不分是哪一次建置留下的，會一起清掉開發機上其他專案的快取，所以這次留著。底層映像檔 <code>oraclelinux:10</code> 與 <code>python:3.12-slim</code> 也留著：開發機上可能有別的專案在用它們，要刪時執行 <code>docker rmi oraclelinux:10 python:3.12-slim</code>。工作目錄裡有 Terraform 的 State 與產生的清單、暫存目錄裡有私鑰，<code>host-image/</code> 與 <code>ctl-image/</code> 是兩個映像檔的建置目錄，<code>rm -rf</code> 那一行把它們一起刪掉；<code>&lt;工作目錄&gt;</code>、<code>&lt;工作目錄的上一層&gt;</code> 與 <code>&lt;暫存目錄&gt;</code> 換成這次實際用的路徑，刪之前先 <code>ls</code> 一次確認是這次建的目錄。</p>
<h2 id="延伸閱讀分層與合併的判斷依據清單的欄位與檢查腳本terraform-與-ansible-的卡">延伸閱讀：分層與合併的判斷依據、清單的欄位與檢查腳本、Terraform 與 Ansible 的卡</h2>
<ul>
<li><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>：每台相同、依角色不同、每台不同的分類、合併放在部署時的理由、<code>to_json</code> 的跳脫與 YAML 的隱式型別、exit code 2 與 4 的讀法與集中執行的安全代價</li>
<li><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>：清單每一段的欄位、檢查腳本的白名單、<code>systemd-run</code> 各屬性在 Oracle Linux 10 容器上的實測效果與結果檔</li>
<li><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>：這篇部署與執行的那支腳本的完整原始碼，<code>load_manifest</code> 的逐欄驗證、<code>summary:</code> 與各個 exit code 的出口、結果檔的寫法，以及 <code>runpy</code> 載入時不執行主程式的條件</li>
<li><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>：inventory、群組變數與主機變數各放什麼；同一個變數在幾層都有值時的合併順序，在概念文章〈用 Ansible 的 inventory、群組變數與範本產生每台的清單〉</li>
<li><a href="/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）</a>：IaC 工具記錄每個納管資源上一次 apply 之後樣貌的快照，以及它的存放位置、加密與鎖機制</li>
<li><a href="/blog/devops/infra/knowledge-cards/terraform-plan-apply/" data-link-title="terraform plan / apply" data-link-desc="IaC 的兩個核心操作：plan 只看不動（產出差異報告）、apply 真的動（執行差異）">terraform plan / apply</a>：plan 比對 State、雲端現況與設定檔產出差異報告而不改動任何資源，apply 執行那份差異；手改的 inventory 被 plan 判成重新建立的機制，在概念文章〈主機由 Terraform 建立時由它的輸出產生 inventory〉</li>
</ul>
]]></content:encoded></item><item><title>長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/</guid><description>&lt;p>這篇整理一台單獨部署、要長期放在公開網路上的主機，在沒有人登入、也沒有部署新版本的期間仍然會改變的狀態，以及維護時對每一類狀態做的事：會累積的資料（nginx 的 log、&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/journald/" data-link-title="journald（systemd-journald）" data-link-desc="用 journalctl 查不到重開機之前的紀錄、journal 佔掉多少磁碟不清楚、某個服務的 log 出現 Suppressed 訊息，或要判斷一筆紀錄該去 journal 還是 /var/log 底下的檔案找時">journald&lt;/a>、syslog 的檔案、套件快取、容器的 log 與映像檔、應用程式自己寫的資料、建置產物）各自由誰寫入、由什麼機制清理，以及磁碟寫滿時不同寫入者的反應；定期要讀的 log 與每一份要看的欄位；對外入口在主機與雲端防火牆兩層怎麼盤點、哪些入口該關或該收窄；安全更新依軟體的安裝管道各自由什麼更新（含語言與容器映像檔的漏洞掃描工具在排程裡的 exit code）、更新後是否自動重開機的條件與序列主控台的準備；會到期與會改變的設定（憑證、網域、雲端 CLI 的登入、管理者的 IP、CDN 的位址段、免費額度的閒置回收）；怎麼從每一類狀態的變化速度推出檢查間隔、哪些項目要改成自動處理；最後是接手或第一次盤點一台已經上線的主機時，先處理哪幾項。&lt;/p>
&lt;p>範圍是一台主機加上它前面的 CDN 與雲端防火牆（主機有好幾台、要依角色分組時，清單的值怎麼分層、怎麼產生每台的清單，在 &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>），例子取自 Oracle Linux 10.2 上跑 nginx 與一個寫 SQLite 的服務的主機；Debian 系發行版與 Docker 的對照寫在各節。Debian 系的套件預設，是在 Debian 12、Debian 13 與 Ubuntu 24.04 的官方容器映像檔裡裝上同名套件之後讀出來的，再拿 Oracle Cloud 上一台 Ubuntu 24.04 的雲端主機對照（預先裝了哪些套件、journal 的目錄、核心更新前後的重開機判斷）；其他業者的映像檔與 Debian 12 的雲端映像檔預先裝了哪些套件，要在主機上用 &lt;code>dpkg -s &amp;lt;套件名&amp;gt;&lt;/code> 查。服務要暫停一段時間時的處理在 &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>，把本篇的項目寫成機器讀得懂的清單在 &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>。&lt;/p>
&lt;h2 id="沒有人操作時主機上會改變的狀態">沒有人操作時主機上會改變的狀態&lt;/h2>
&lt;p>一台主機上線之後，狀態的改變來自主機上一直在寫入的元件，以及主機內外一直在計時的期限。依改變的來源分成四類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>會累積的資料&lt;/strong>：nginx 每處理一個請求就在 access log 寫一行，journald 收下所有 systemd 服務的輸出，dnf 下載套件時留下中繼資料，應用程式寫自己的資料。寫入的元件一直在跑，而刪除舊資料的元件要另外存在。&lt;/li>
&lt;li>&lt;strong>會老化的軟體&lt;/strong>：已安裝的軟體版本是固定的，而已知漏洞的清單每天增加。同一個 nginx 版本，上線那天沒有已知漏洞，幾週之後可能有。&lt;/li>
&lt;li>&lt;strong>會漂移的入口&lt;/strong>：安裝一個新套件可能同時帶進一個開始接聽連接埠的服務；有雲端帳號權限的人可以在主控台單獨修改防火牆規則，主機上看不出這個改動。&lt;/li>
&lt;li>&lt;strong>會到期與會改變的外部設定&lt;/strong>：憑證、網域註冊、雲端 CLI 的登入有期限；管理者家裡的 IP 會被 ISP 重新分配；CDN 業者會增加自己的位址段；免費方案的業者可能回收閒置的主機。&lt;/li>
&lt;/ul>
&lt;p>維護就是定期把每一類狀態量一次，跟允許的範圍比對，超出時處理。允許的範圍以一個&lt;strong>警戒值&lt;/strong>為界：警戒值設在真正出事之前，跟出事之間留著處理要花的時間。磁碟的警戒值設在寫滿之前、離寫滿還夠清出空間或擴充磁碟的剩餘空間，憑證的警戒值設在到期之前、離到期還夠續期的天數。量測值超過警戒值的時候還來得及處理；到了真正出事的那一刻之後，後果依項目而定：有的清掉就恢復（套件快取把磁碟用量推過警戒值，清掉之後就回到警戒值以內），有的收不回來（磁碟完全寫滿的那段時間裡，所有寫入者沒寫進去的紀錄；已經被利用的漏洞）。每一類的警戒值設在哪裡、多久量一次，由那一類狀態改變的速度、處理要花的時間，以及越過出事那一刻之後收不收得回來決定，推算的方法寫在〈檢查間隔與哪些項目改成自動處理的推算〉。&lt;/p>
&lt;h2 id="會累積的資料寫入者清理機制與磁碟寫滿的後果">會累積的資料：寫入者、清理機制與磁碟寫滿的後果&lt;/h2>
&lt;p>會累積的資料各自有一個寫入者，而清理機制有三種情形：套件預設就裝好、要自己設定、或根本不存在。盤點時每一項資料寫出寫入者、清理機制與量測方式；清理機制那一格寫不出來的資料，就是要補上清理機制的那一項。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料&lt;/th>
 &lt;th>寫入者&lt;/th>
 &lt;th>清理機制&lt;/th>
 &lt;th>量測方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>nginx 的 access log 與 error log&lt;/td>
 &lt;td>nginx，每個請求一行&lt;/td>
 &lt;td>套件附的 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/logrotate/" data-link-title="logrotate" data-link-desc="/var/log 底下的檔案越長越大、輪替之後新檔是空的而舊檔還在長、刪了 log 檔磁碟空間沒有回來，或要讀懂 /etc/logrotate.d/ 底下某個設定檔時">logrotate&lt;/a> 設定；Oracle Linux 10.2 的 nginx 套件設為每天輪替、保留 10 份、壓縮舊檔&lt;/td>
 &lt;td>&lt;code>du -sh /var/log/nginx&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>journald&lt;/td>
 &lt;td>所有 systemd 服務的標準輸出&lt;/td>
 &lt;td>journald 自己依上限刪最舊的檔案；上限的設定依紀錄存在磁碟還是記憶體分兩組，見下一小節&lt;/td>
 &lt;td>&lt;code>journalctl --disk-usage&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>syslog 的檔案（&lt;code>/var/log/messages&lt;/code>、&lt;code>/var/log/secure&lt;/code>）&lt;/td>
 &lt;td>rsyslog，從 journald 轉出（有裝 rsyslog 時）&lt;/td>
 &lt;td>套件附的 logrotate 設定&lt;/td>
 &lt;td>&lt;code>du -sh /var/log/messages /var/log/secure&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套件快取&lt;/td>
 &lt;td>dnf（Debian 系是 apt）&lt;/td>
 &lt;td>Oracle Linux 10.2 的 dnf 預設 &lt;code>keepcache = 0&lt;/code>，裝完就刪掉下載的套件；中繼資料留在 &lt;code>/var/cache/dnf&lt;/code>，由定期的 makecache 更新&lt;/td>
 &lt;td>&lt;code>du -sh /var/cache/dnf&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>容器的 log、映像檔、建置快取與 volume&lt;/td>
 &lt;td>Docker 的 daemon（dockerd）&lt;/td>
 &lt;td>容器 log 預設的 json-file 驅動不輪替，要在 &lt;code>/etc/docker/daemon.json&lt;/code> 的 &lt;code>log-opts&lt;/code> 設 &lt;code>max-size&lt;/code> 與 &lt;code>max-file&lt;/code>；映像檔與建置快取要另外 prune&lt;/td>
 &lt;td>&lt;code>docker system df&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>應用程式的資料（例如 SQLite 資料庫）&lt;/td>
 &lt;td>應用程式本身&lt;/td>
 &lt;td>應用程式自己實作的保留期限，或作業系統這一層的容量隔離（把資料放在獨立的檔案系統或 volume、設 quota）；兩者都沒有就沒有上限&lt;/td>
 &lt;td>資料庫檔案的大小，以及最舊一筆資料的時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>建置產物（&lt;code>node_modules&lt;/code>、Go 的建置快取）&lt;/td>
 &lt;td>在主機上建置的部署腳本&lt;/td>
 &lt;td>沒有；部署腳本不清，就一直留著&lt;/td>
 &lt;td>&lt;code>du -sh&lt;/code> 對應的目錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>暫存檔&lt;/td>
 &lt;td>各程式&lt;/td>
 &lt;td>systemd-tmpfiles 依 &lt;code>tmpfiles.d&lt;/code> 的規則定期清理&lt;/td>
 &lt;td>&lt;code>du -sh /tmp /var/tmp&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表裡的預設值是一台 Oracle Linux 10.2 主機上查到的，換發行版或版本時會不同。查法是讀 &lt;code>/etc/logrotate.d/&lt;/code> 底下對應的檔案、&lt;code>systemd-analyze cat-config systemd/journald.conf&lt;/code>（印出主設定檔與全部 &lt;code>.d&lt;/code> 目錄合併後的內容）與套件管理器的設定檔；沒有寫在設定檔裡的值，要讀主機上那個版本自己的 man page（&lt;code>man journald.conf&lt;/code>），而不是線上最新版的文件。Docker 的 json-file 驅動預設不輪替，這一點寫在 Docker 的文件裡；&lt;code>daemon.json&lt;/code> 改了之後，既有的容器不會套用新的設定，要重新建立容器才生效。服務跑在容器裡時，量測的位置也要跟著換：nginx 在容器裡通常把 log 寫到標準輸出，主機上的 &lt;code>/var/log/nginx&lt;/code> 量到的是空目錄，這個零不代表 log 很小。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理一台單獨部署、要長期放在公開網路上的主機，在沒有人登入、也沒有部署新版本的期間仍然會改變的狀態，以及維護時對每一類狀態做的事：會累積的資料（nginx 的 log、<a href="/blog/linux/dotfile/knowledge-cards/journald/" data-link-title="journald（systemd-journald）" data-link-desc="用 journalctl 查不到重開機之前的紀錄、journal 佔掉多少磁碟不清楚、某個服務的 log 出現 Suppressed 訊息，或要判斷一筆紀錄該去 journal 還是 /var/log 底下的檔案找時">journald</a>、syslog 的檔案、套件快取、容器的 log 與映像檔、應用程式自己寫的資料、建置產物）各自由誰寫入、由什麼機制清理，以及磁碟寫滿時不同寫入者的反應；定期要讀的 log 與每一份要看的欄位；對外入口在主機與雲端防火牆兩層怎麼盤點、哪些入口該關或該收窄；安全更新依軟體的安裝管道各自由什麼更新（含語言與容器映像檔的漏洞掃描工具在排程裡的 exit code）、更新後是否自動重開機的條件與序列主控台的準備；會到期與會改變的設定（憑證、網域、雲端 CLI 的登入、管理者的 IP、CDN 的位址段、免費額度的閒置回收）；怎麼從每一類狀態的變化速度推出檢查間隔、哪些項目要改成自動處理；最後是接手或第一次盤點一台已經上線的主機時，先處理哪幾項。</p>
<p>範圍是一台主機加上它前面的 CDN 與雲端防火牆（主機有好幾台、要依角色分組時，清單的值怎麼分層、怎麼產生每台的清單，在 <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>），例子取自 Oracle Linux 10.2 上跑 nginx 與一個寫 SQLite 的服務的主機；Debian 系發行版與 Docker 的對照寫在各節。Debian 系的套件預設，是在 Debian 12、Debian 13 與 Ubuntu 24.04 的官方容器映像檔裡裝上同名套件之後讀出來的，再拿 Oracle Cloud 上一台 Ubuntu 24.04 的雲端主機對照（預先裝了哪些套件、journal 的目錄、核心更新前後的重開機判斷）；其他業者的映像檔與 Debian 12 的雲端映像檔預先裝了哪些套件，要在主機上用 <code>dpkg -s &lt;套件名&gt;</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>，把本篇的項目寫成機器讀得懂的清單在 <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>。</p>
<h2 id="沒有人操作時主機上會改變的狀態">沒有人操作時主機上會改變的狀態</h2>
<p>一台主機上線之後，狀態的改變來自主機上一直在寫入的元件，以及主機內外一直在計時的期限。依改變的來源分成四類：</p>
<ul>
<li><strong>會累積的資料</strong>：nginx 每處理一個請求就在 access log 寫一行，journald 收下所有 systemd 服務的輸出，dnf 下載套件時留下中繼資料，應用程式寫自己的資料。寫入的元件一直在跑，而刪除舊資料的元件要另外存在。</li>
<li><strong>會老化的軟體</strong>：已安裝的軟體版本是固定的，而已知漏洞的清單每天增加。同一個 nginx 版本，上線那天沒有已知漏洞，幾週之後可能有。</li>
<li><strong>會漂移的入口</strong>：安裝一個新套件可能同時帶進一個開始接聽連接埠的服務；有雲端帳號權限的人可以在主控台單獨修改防火牆規則，主機上看不出這個改動。</li>
<li><strong>會到期與會改變的外部設定</strong>：憑證、網域註冊、雲端 CLI 的登入有期限；管理者家裡的 IP 會被 ISP 重新分配；CDN 業者會增加自己的位址段；免費方案的業者可能回收閒置的主機。</li>
</ul>
<p>維護就是定期把每一類狀態量一次，跟允許的範圍比對，超出時處理。允許的範圍以一個<strong>警戒值</strong>為界：警戒值設在真正出事之前，跟出事之間留著處理要花的時間。磁碟的警戒值設在寫滿之前、離寫滿還夠清出空間或擴充磁碟的剩餘空間，憑證的警戒值設在到期之前、離到期還夠續期的天數。量測值超過警戒值的時候還來得及處理；到了真正出事的那一刻之後，後果依項目而定：有的清掉就恢復（套件快取把磁碟用量推過警戒值，清掉之後就回到警戒值以內），有的收不回來（磁碟完全寫滿的那段時間裡，所有寫入者沒寫進去的紀錄；已經被利用的漏洞）。每一類的警戒值設在哪裡、多久量一次，由那一類狀態改變的速度、處理要花的時間，以及越過出事那一刻之後收不收得回來決定，推算的方法寫在〈檢查間隔與哪些項目改成自動處理的推算〉。</p>
<h2 id="會累積的資料寫入者清理機制與磁碟寫滿的後果">會累積的資料：寫入者、清理機制與磁碟寫滿的後果</h2>
<p>會累積的資料各自有一個寫入者，而清理機制有三種情形：套件預設就裝好、要自己設定、或根本不存在。盤點時每一項資料寫出寫入者、清理機制與量測方式；清理機制那一格寫不出來的資料，就是要補上清理機制的那一項。</p>
<table>
  <thead>
      <tr>
          <th>資料</th>
          <th>寫入者</th>
          <th>清理機制</th>
          <th>量測方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>nginx 的 access log 與 error log</td>
          <td>nginx，每個請求一行</td>
          <td>套件附的 <a href="/blog/linux/dotfile/knowledge-cards/logrotate/" data-link-title="logrotate" data-link-desc="/var/log 底下的檔案越長越大、輪替之後新檔是空的而舊檔還在長、刪了 log 檔磁碟空間沒有回來，或要讀懂 /etc/logrotate.d/ 底下某個設定檔時">logrotate</a> 設定；Oracle Linux 10.2 的 nginx 套件設為每天輪替、保留 10 份、壓縮舊檔</td>
          <td><code>du -sh /var/log/nginx</code></td>
      </tr>
      <tr>
          <td>journald</td>
          <td>所有 systemd 服務的標準輸出</td>
          <td>journald 自己依上限刪最舊的檔案；上限的設定依紀錄存在磁碟還是記憶體分兩組，見下一小節</td>
          <td><code>journalctl --disk-usage</code></td>
      </tr>
      <tr>
          <td>syslog 的檔案（<code>/var/log/messages</code>、<code>/var/log/secure</code>）</td>
          <td>rsyslog，從 journald 轉出（有裝 rsyslog 時）</td>
          <td>套件附的 logrotate 設定</td>
          <td><code>du -sh /var/log/messages /var/log/secure</code></td>
      </tr>
      <tr>
          <td>套件快取</td>
          <td>dnf（Debian 系是 apt）</td>
          <td>Oracle Linux 10.2 的 dnf 預設 <code>keepcache = 0</code>，裝完就刪掉下載的套件；中繼資料留在 <code>/var/cache/dnf</code>，由定期的 makecache 更新</td>
          <td><code>du -sh /var/cache/dnf</code></td>
      </tr>
      <tr>
          <td>容器的 log、映像檔、建置快取與 volume</td>
          <td>Docker 的 daemon（dockerd）</td>
          <td>容器 log 預設的 json-file 驅動不輪替，要在 <code>/etc/docker/daemon.json</code> 的 <code>log-opts</code> 設 <code>max-size</code> 與 <code>max-file</code>；映像檔與建置快取要另外 prune</td>
          <td><code>docker system df</code></td>
      </tr>
      <tr>
          <td>應用程式的資料（例如 SQLite 資料庫）</td>
          <td>應用程式本身</td>
          <td>應用程式自己實作的保留期限，或作業系統這一層的容量隔離（把資料放在獨立的檔案系統或 volume、設 quota）；兩者都沒有就沒有上限</td>
          <td>資料庫檔案的大小，以及最舊一筆資料的時間</td>
      </tr>
      <tr>
          <td>建置產物（<code>node_modules</code>、Go 的建置快取）</td>
          <td>在主機上建置的部署腳本</td>
          <td>沒有；部署腳本不清，就一直留著</td>
          <td><code>du -sh</code> 對應的目錄</td>
      </tr>
      <tr>
          <td>暫存檔</td>
          <td>各程式</td>
          <td>systemd-tmpfiles 依 <code>tmpfiles.d</code> 的規則定期清理</td>
          <td><code>du -sh /tmp /var/tmp</code></td>
      </tr>
  </tbody>
</table>
<p>這張表裡的預設值是一台 Oracle Linux 10.2 主機上查到的，換發行版或版本時會不同。查法是讀 <code>/etc/logrotate.d/</code> 底下對應的檔案、<code>systemd-analyze cat-config systemd/journald.conf</code>（印出主設定檔與全部 <code>.d</code> 目錄合併後的內容）與套件管理器的設定檔；沒有寫在設定檔裡的值，要讀主機上那個版本自己的 man page（<code>man journald.conf</code>），而不是線上最新版的文件。Docker 的 json-file 驅動預設不輪替，這一點寫在 Docker 的文件裡；<code>daemon.json</code> 改了之後，既有的容器不會套用新的設定，要重新建立容器才生效。服務跑在容器裡時，量測的位置也要跟著換：nginx 在容器裡通常把 log 寫到標準輸出，主機上的 <code>/var/log/nginx</code> 量到的是空目錄，這個零不代表 log 很小。</p>
<p>Debian 系的對應預設在 Debian 12、Debian 13 與 Ubuntu 24.04 上相同（logrotate 3.21 與 3.22）：nginx 套件的 logrotate 設定是每天輪替、保留 14 份、壓縮舊檔，輪替之後執行 <code>invoke-rc.d nginx rotate</code> 讓 nginx 重新開檔；<code>/etc/logrotate.conf</code> 的全域預設是每週輪替、保留 4 份，沒有開 <code>dateext</code>，所以輪替出來的檔名是 <code>.1</code>、<code>.2.gz</code> 這種序號，Oracle Linux 10.2 開了 <code>dateext</code>，檔名帶日期。logrotate 由 <code>logrotate.timer</code> 每天觸發；<code>/etc/cron.daily/logrotate</code> 也在，但這支腳本在 <code>/run/systemd/system</code> 存在（systemd 是 init）時直接結束，所以實際執行的只有 timer 那一份。syslog 檔案那一列要先確認 rsyslog 在不在：這三個容器裝了 systemd、logrotate、nginx、unattended-upgrades 與 needrestart 之後都沒有 rsyslog，<code>/var/log/auth.log</code>、<code>/var/log/syslog</code> 也就不存在。</p>
<h3 id="journald-的紀錄存在記憶體還是磁碟">journald 的紀錄存在記憶體還是磁碟</h3>
<p>journald 的紀錄存在磁碟還是記憶體，由 <code>Storage</code> 決定，這個預設值正好是線上文件與主機不一致的例子：systemd 最新版的文件寫的預設是 <code>persistent</code>，而 Oracle Linux 10.2 的 systemd 257 預設是 <code>auto</code>，<code>/var/log/journal</code> 目錄存在時才寫到磁碟。兩種狀態的結果不同：</p>
<ul>
<li><strong><code>/var/log/journal</code> 存在，或設定了 <code>Storage=persistent</code></strong>：紀錄寫在磁碟上，跨得過重開機；上限由 <code>SystemMaxUse</code>（預設檔案系統大小的 10%）與 <code>SystemKeepFree</code>（預設保留 15%）決定。</li>
<li><strong>兩者都沒有</strong>：紀錄只存在 <code>/run</code> 底下的記憶體裡，每次重開機就清空；上限改由 <code>RuntimeMaxUse</code> 與 <code>RuntimeKeepFree</code> 依 <code>/run</code> 的大小計算。重開機之前的 sshd 失敗登入、自動更新的執行結果，在 journal 裡都查不到，只剩 rsyslog 轉出的 <code>/var/log/secure</code> 與 <code>/var/log/messages</code>。主機重開過機、<code>journalctl --list-boots</code> 卻只列出一次開機，或 <code>/var/log/journal</code> 不存在，就代表主機處在這個狀態；上線之後還沒重開過機的主機，紀錄存在磁碟上時同樣只列出一次開機，所以只看開機次數分不出兩種狀態。</li>
</ul>
<p>上面兩組上限各自另有 4G 的上限，算出來超過 4G 時以 4G 為準。Oracle Cloud 的 Oracle Linux 10.2 映像檔處在紀錄只存在記憶體的那一種狀態；在 <code>/etc/systemd/journald.conf.d/</code> 放一個寫了 <code>Storage=persistent</code> 的設定檔並重啟 systemd-journald 之後，那台主機的 <code>journalctl --list-boots</code> 在下一次重開機後列出兩次開機。</p>
<p>Debian 系的預設落在另一種狀態：Debian 12、Debian 13 與 Ubuntu 24.04 的 systemd 套件（252、257、255）在安裝時由套件的 postinst 腳本建立 <code>/var/log/journal</code>，<code>Storage</code> 同樣是預設的 <code>auto</code>，所以紀錄寫在磁碟上、跨得過重開機。雲端映像檔在 Oracle Cloud 的 Ubuntu 24.04 映像檔（2026 年 9 月的版本）上確認過：第一次開機之後 <code>ls -ld /var/log/journal</code> 印出 <code>drwxr-sr-x+ root systemd-journal</code>，目錄與裡面的 journal 檔（<code>-rw-r-----+ root systemd-journal</code>）已經套好群組與 ACL，只屬於 <code>systemd-journal</code> 群組的系統帳號跑 <code>journalctl -n 1</code> 以 exit 0 讀得到，不必另外處理；這個映像檔同時預先裝了 rsyslog，<code>/var/log/syslog</code> 與 <code>/var/log/auth.log</code> 另外有一份。Debian 12 的雲端映像檔沒有確認，<code>ls -ld /var/log/journal</code> 查得到。</p>
<h3 id="磁碟寫滿時各寫入者的反應">磁碟寫滿時各寫入者的反應</h3>
<p>磁碟寫滿時，每個寫入者都會寫入失敗，而失敗之後的反應依元件的設計分成三種，外面看得到的程度不同：</p>
<ul>
<li><strong>寫入失敗時繼續服務</strong>：nginx 寫不進 log 時照樣回應一般的請求，套件管理器下載更新失敗時，已經在執行的服務照樣跑在舊版本上。從主機外面看不出變化，代價是這段時間的紀錄缺掉、安全更新沒有套用。例外是要寫暫存檔的請求：request body 超過 nginx 的 <code>client_body_buffer_size</code>（預設 8k 或 16k）時，nginx 把 body 寫進暫存檔，上游的回應超過 proxy 的 buffer 時也一樣，這兩種請求在磁碟寫滿時會失敗，外面看得到錯誤。</li>
<li><strong>寫入失敗時回錯誤</strong>：應用程式寫資料庫失敗時，多半對送資料進來的請求回 5xx。外面看得到錯誤率上升，而那段時間收到的資料遺失。</li>
<li><strong>寫入失敗時停止</strong>：部分資料庫在交易紀錄寫不進去時會停止服務以保護資料（PostgreSQL 的 WAL 寫入失敗是這一種）。服務整個停掉，外面看得到，恢復要先清出空間再重啟。</li>
</ul>
<p>繼續服務的那一種，大部分請求在外面沒有徵兆，只有在主機上量磁碟用量才看得到；回錯誤與停止的兩種雖然看得到，看到的時候資料已經遺失或服務已經停了。這是磁碟用量要在寫滿之前定期量、而不是等出錯時才看的原因。</p>
<h3 id="對外寫入端點的增長速度">對外寫入端點的增長速度</h3>
<p>應用程式的資料是這幾項裡，增長速度最受外部請求影響的一項：access log 每個請求只多一行，而接受外部寫入的端點（收事件、收表單、收上傳），每個請求可以寫進一整個 request body。這一項的允許範圍要用最壞情況算，而最壞情況取決於端點前面有什麼樣的速率限制：</p>
<ul>
<li><strong>有全域的速率限制</strong>（整個端點共用一個計數，nginx 以 <code>$server_name</code> 這類固定值當 <code>limit_req_zone</code> 的鍵）：全域速率與 body 上限的乘積就是寫進應用程式資料的上限。例如每秒 20 次、每次最多 64 KB，每秒約 1.3 MB，一天約 110 GB。</li>
<li><strong>只有以來源位址為鍵的速率限制</strong>（<code>$binary_remote_addr</code>、或 CDN 後面以 <code>CF-Connecting-IP</code> 為鍵）：每個來源各自計數，上限還要乘上來源的數量，而來源的數量由送請求的人控制，所以算不出一個可以拿來跟磁碟比的上限。例如一個收前端事件的端點只依來源 IP 限流，一個爬蟲從上千個雲端位址各送幾個請求時，每個來源都在自己的限額內，access log 裡代表速率限制拒絕的狀態碼一直是零（nginx 預設回 503，示範主機的設定檔用 <code>limit_req_status 429</code> 改成 429），磁碟照樣在增長。以 <code>CF-Connecting-IP</code> 為鍵還有一個前提：源站只接受 CDN 送來的連線，否則這個標頭可以由請求的人自己填，每次換一個值就繞過計數。</li>
<li><strong>完全沒有速率限制</strong>：跟上一種相同，沒有可以比的上限。</li>
<li><strong>只在私有網路裡、來源數量固定</strong>（例如只有公司 VPN 內的幾台機器會送）：上限是來源數乘上每個來源的速率，再乘上 body 上限。</li>
</ul>
<p>算出來的上限只算了 request body；落到磁碟的量還要加上儲存層的放大（資料庫的索引、交易紀錄、應用程式自己的轉換），實際的倍數要用一批真實的請求量一次才知道。拿這個上限跟剩下的磁碟空間比，算得出最壞情況多久會寫滿：剩 15 GB、每秒寫入 1.3 MB 時，只算 body 大約 3 小時，加上放大會更短。這個時間比人的檢查間隔短時，保留期限、配額或容量隔離要由應用程式或作業系統自己執行，或在端點前面加上全域的速率限制，不能靠定期檢查。</p>
<p>全域的速率限制只限制寫進應用程式資料的量，限制不了 log 的量：被拒絕的請求照樣在 access log 寫一行，nginx 的 <code>limit_req</code> 預設在 error 等級替每一次拒絕再記一行（<code>limit_req_log_level</code> 預設 <code>error</code>）。送進來的請求越多，這兩份 log 寫得越快，寫入量由送請求的人決定；每秒上萬個被拒絕的請求，log 的寫入速度可以超過上面算出的資料上限，而 logrotate 一天才輪替一次。所以大量的請求要在 CDN 這一層就擋掉（CDN 的速率限制規則），或把這個端點的拒絕紀錄調低等級、不寫 access log，並另外設一條磁碟剩餘空間的告警。</p>
<p>清理的動作要分開判斷：log 與快取有現成的輪替機制，清掉的是已經過了保留期限的資料；應用程式的資料，以及事後要拿來跟 CDN 的事件紀錄逐筆比對請求來源的 access log（CDN 的每一筆事件帶著請求編號，源站的 access log 記下同一個編號），則可能是之後要查證的材料，保留多久是業務的決定（要回頭查多久以前的事件），磁碟只決定能存多少。所以維護時量測可以每次都做，刪除要依每一項事先定好的保留期限做。</p>
<h2 id="定期讀的-log每一份要看的欄位與它代表的狀態">定期讀的 log：每一份要看的欄位與它代表的狀態</h2>
<p>自動告警針對的是事先知道的壞狀態（服務沒有回應、錯誤率超過門檻）；定期讀 log 處理的是另外兩件事：確認部署時的假設仍然成立，以及發現還沒有被寫成告警的新模式。一份 log 適合哪一種，看它的欄位在正常狀態下的值能否預測：能預測的，偏離就代表某個假設失效；不能預測的，讀的目的是找出值得寫成告警的模式。</p>
<table>
  <thead>
      <tr>
          <th>Log</th>
          <th>要看的欄位</th>
          <th>值代表的狀態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>反向代理的 access log</td>
          <td>狀態碼的分布</td>
          <td>4xx 與 5xx 的比例跟前一次相比上升，代表有新的請求來源或後端開始失敗</td>
      </tr>
      <tr>
          <td></td>
          <td>速率限制的拒絕（nginx 預設回 503，設了 <code>limit_req_status 429</code> 時是 429）與 413 的次數</td>
          <td>速率限制或 body 上限實際被觸發；來源集中在少數位址時是單一來源灌入，來源分散而且是網站的正常路徑時，代表門檻可能比正常使用者的用量還低</td>
      </tr>
      <tr>
          <td></td>
          <td><code>Host</code> 標頭</td>
          <td>主機只放行 CDN 時，<code>Host</code> 應該只有自己的網域（主機自己送出的 <a href="/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">冒煙測試</a> 除外）；出現 IP 位址或別的網域，代表有請求繞過了自己的 CDN 設定（例如別人在他的 CDN 帳號把網域指到這台主機）</td>
      </tr>
      <tr>
          <td></td>
          <td>請求的路徑</td>
          <td><code>/wp-login.php</code>、<code>/.env</code> 這類網站不存在的路徑是掃描器的背景流量；同樣的掃描出現在網站實際存在的路徑上時，掃描的對象就是這個網站本身</td>
      </tr>
      <tr>
          <td>反向代理的 error log</td>
          <td>upstream 連線失敗、權限拒絕</td>
          <td>upstream 失敗代表後端服務沒有在聽；權限拒絕多半是 SELinux（<a href="/blog/linux/dotfile/knowledge-cards/mandatory-access-control/" data-link-title="Mandatory Access Control（強制存取控制）：SELinux 與 AppArmor" data-link-desc="檔案權限看起來正確、程式以 root 執行，存取仍然被拒絕；從備份還原或搬移檔案之後服務回 403；或在 log 裡讀到 avc: denied、apparmor=&#34;DENIED&#34; 時">強制存取控制</a>）或檔案權限在某次變更後不再符合；body 上限攔下過大請求、速率限制拒絕請求時，nginx 也在 error 等級記一行，那些是防護正常運作的紀錄</td>
      </tr>
      <tr>
          <td>sshd 的紀錄</td>
          <td>失敗的登入與它的來源位址</td>
          <td>SSH 只放行特定來源時，正常值是零，或只有來自允許來源的失敗（管理者打錯密碼、金鑰沒載入；經由雲端的 bastion 連進來時，sshd 看到的來源是 bastion 在虛擬網路裡的私有 IP）；出現允許來源以外的失敗登入，代表來源限制的那一層已經不成立</td>
      </tr>
      <tr>
          <td>強制存取控制的拒絕紀錄</td>
          <td>被拒絕的程序與動作</td>
          <td>正常值是零；在設定變更或套件更新之後出現，代表新版本的行為不在既有的政策裡</td>
      </tr>
      <tr>
          <td>自動更新的執行紀錄</td>
          <td>有沒有執行、裝了哪些套件、有沒有失敗</td>
          <td>沒有執行代表排程或服務本身停了；失敗多半是磁碟空間或套件庫連線；有執行而從來沒有裝到任何套件，要確認套件庫的設定（有些發行版的套件庫鎖在固定的版本，見〈會老化的軟體〉）</td>
      </tr>
      <tr>
          <td>應用程式自己的 log 與 systemd 的重啟計數</td>
          <td>錯誤與重啟次數</td>
          <td>unit 設了 <code>Restart=</code> 時，程序當掉會被 systemd 立刻拉回來，服務從外面看起來一直在，當掉前的錯誤訊息混在大量正常輸出裡；重啟計數不會被沖掉，所以它上升代表程序在反覆當掉</td>
      </tr>
  </tbody>
</table>
<p>各列的紀錄去哪裡讀：</p>
<ul>
<li><strong>sshd</strong>：RHEL 系的 unit 是 <code>sshd.service</code>、Debian 系是 <code>ssh.service</code>，名稱用 <code>systemctl list-units '*ssh*'</code> 查；unit 名稱寫錯時 <code>journalctl -u</code> 的輸出也是零，所以零要搭配確認 unit 名稱存在才算數。journal 不保留到重開機之後的主機，讀 rsyslog 寫的 <code>/var/log/secure</code>（RHEL 系）或 <code>/var/log/auth.log</code>（Debian 系，有裝 rsyslog 時才有這個檔案）。</li>
<li><strong>強制存取控制</strong>：SELinux 用 <code>ausearch -m AVC</code>；Debian 系的 AppArmor 在核心 log 裡找 <code>apparmor=&quot;DENIED&quot;</code>。</li>
<li><strong>自動更新</strong>：<code>journalctl -u dnf-automatic.service</code> 與 <code>dnf history</code>；journal 不保留時讀 <code>/var/log/messages</code>。Debian 系的 unattended-upgrades 寫在 <code>/var/log/unattended-upgrades/</code>。</li>
<li><strong>重啟計數</strong>：<code>systemctl show -p NRestarts &lt;unit&gt;</code>。</li>
</ul>
<p>sshd 與強制存取控制這兩列的正常值都是零，偏離零就可以直接寫成告警的條件；access log 的分布要跟前一次比較才看得出變化，所以每一次讀到的數字要存下來，下一次才有比較的基準。這些數字存在哪裡是另一個要決定的事：存在維護的紀錄裡、或讓檢查腳本把每次的計數寫到它自己的結果檔，跟描述「要檢查什麼」的清單分開放。</p>
<h2 id="對外入口主機上的接聽程式與每一層防火牆的放行範圍">對外入口：主機上的接聽程式與每一層防火牆的放行範圍</h2>
<p>一個入口要從外面連得進來，得同時滿足兩個條件：主機上有程式在接聽那個位址與連接埠，而且請求經過的每一層都放行它。常見的層有雲端的網路安全規則（Oracle Cloud 的 NSG 與 security list、AWS 的 security group，見 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a>）、主機的防火牆（firewalld、ufw、nftables）、服務自己的限制（只綁 loopback、systemd 的 <code>IPAddressDeny</code>），以及反向代理的路由（只把某些路徑轉給後端）。每一層都可以被單獨修改，所以盤點時兩個方向都要量：</p>
<ul>
<li><strong>從主機裡面量接聽的程式</strong>：<code>ss -lntup</code> 列出每一個在接聽的 TCP（<code>-t</code>）與 UDP（<code>-u</code>）位址、連接埠與程序（<code>-p</code>，要 root 才看得到其他使用者的程序）。只寫 <code>-t</code> 的話，UDP 的接聽程式不在輸出裡。拿這份清單跟部署時預期的清單比，多出來的接聽程式，用 <code>ss</code> 印出的程序名找到執行檔路徑，再以 <code>rpm -qf &lt;路徑&gt;</code>（Debian 系是 <code>dpkg -S &lt;路徑&gt;</code>）查是哪個套件帶進來的。例如 Oracle Cloud 的 Oracle Linux 10.2 映像檔預設啟用 <code>rpcbind</code>（RPC 服務的連接埠對應程式，TCP 與 UDP 111）與效能監測套件 PCP（4330、44321），都在所有介面上接聽；停用並遮蔽（<code>systemctl disable --now</code> 加 <code>mask</code>）之後，那台主機上這三個連接埠不再接聽。主機跑 Docker 時，<code>ss</code> 印出的程序是 <code>docker-proxy</code>，查到的套件是 Docker 本身，真正開這個入口的是某一個容器，要用 <code>docker ps</code> 的連接埠欄位對照；Docker 發佈的連接埠繞過 ufw 的規則（Docker 的文件寫明封包在 ufw 的規則套用之前就被轉送），在 firewalld 上也不受一般 zone 的規則約束（Docker 另建 <code>docker</code> zone 與從任何 zone 轉發進來的 policy；Docker 的文件只寫了這組設定，「一般 zone 的規則管不到」是從它推出、在連結那一篇實測過的），封包路徑與限制的寫法見 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</li>
<li><strong>從主機外面量連得到的入口</strong>：從另一台機器對公開 IP 逐一連線。只從主機裡面量看不到雲端那一層；只從外面量時，結果只代表量測那台機器的來源位址，管理入口只放行管理者 IP 時，從管理者的電腦量連得到是預期的，要換一個不在放行清單裡的來源才量得到別人看到的結果。UDP 沒有連線，<code>nc -u -z</code> 沒有收到拒絕就回報成功，量 UDP 要送一個那個服務會回應的請求（RPC 用 <code>rpcinfo -u &lt;IP&gt; 100000</code>），看有沒有回應。</li>
</ul>
<p>入口依用途分成四類，各自要收窄到的範圍不同：</p>
<ul>
<li><strong>業務入口</strong>（網站的 80、443，API 的連接埠）：它是服務本身，要收窄的是來源與路徑。前面有 CDN 時，只放行 CDN 的位址段（下面這一節寫它的限制）；服務只給私有網路用時（公司 VPN 內的 API），只放行那個網段；反向代理只轉發對外需要的路徑與方法。</li>
<li><strong>沒有業務用途的接聽程式</strong>：它不服務任何使用者，卻讓那個程式的漏洞多一條從外面利用的路徑，只要某一層防火牆被放寬就會暴露；停用它不影響任何使用者，所以停用並移除。</li>
<li><strong>管理入口</strong>（SSH、資料庫、管理後台）：它必須存在，而每個能連到它的來源都是一個可以嘗試登入的對象；只放行管理者的來源位址，把能嘗試的對象縮到管理者自己，或改成不開入站連接埠的做法（雲端業者的跳板主機服務 bastion，見 <a href="/blog/devops/infra/knowledge-cards/bastion-host/" data-link-title="Bastion Host（跳板機）" data-link-desc="內部主機不想各自對外開管理 port、要設定 ProxyJump 經由跳板機連線、判斷 agent forwarding 的風險，或要評估自建跳板機與改用身分感知代理的取捨時，查跳板機的連線方式、暴露面與維運責任">Bastion Host（跳板機）</a>；從主機往外建立的 tunnel，見 <a href="/blog/backend/knowledge-cards/outbound-tunnel/" data-link-title="Outbound Tunnel" data-link-desc="反向隧道把出站連線轉成可達入口、與傳統 port-forward 的責任倒轉">Outbound Tunnel</a>），代價是管理者 IP 改變時要更新規則或通道。管理後台跟業務共用同一個連接埠時（WordPress 的 <code>/wp-admin</code>、<code>/wp-login.php</code> 跑在 443 上），443 要對所有訪客開（前面有 CDN 時是對 CDN 的位址段開），雲端防火牆分不出路徑，收窄只能放在反向代理：依路徑只放行管理者 IP，或把後台改成只經 SSH 通道連得到。前面有 CDN 時，反向代理看到的來源位址是 CDN 的位址，依 IP 放行之前要先用 nginx 的 realip 模組從 <code>CF-Connecting-IP</code> 取回訪客的位址，而且 <code>set_real_ip_from</code> 只列 CDN 的位址段，否則這個標頭可以由請求的人自己填（從 realip 的機制推論，沒有實測）。各種做法的取捨與救援入口見 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>。</li>
<li><strong>只給同一台主機用的入口</strong>：應用程式內部的連接埠、查詢與管理用的 API，只有主機上的反向代理需要連它；綁在 loopback、反向代理只轉發對外需要的路徑與方法之後，外面的請求沒有路徑可以到達它。</li>
</ul>
<p>主機前面有 CDN 時，只放行 CDN 的位址段，確認的是請求經過了那家 CDN，不確認經過的是自己的 CDN 帳號：任何人都可以在自己的 CDN 帳號把網域指到這台主機的 IP，請求同樣從 CDN 的位址段進來，而自己帳號裡的 WAF 與 bot 規則管不到這些請求。維護時讀 access log 的 <code>Host</code> 標頭看得到這類繞過；在連線層擋下它，要讓源站驗證只屬於自己帳號的憑證，或改成源站不開入站連接埠的 tunnel，兩種做法的機制與 Cloudflare 上的設定差異見 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>〈限制放在哪一層：雲端防火牆、主機防火牆與 CDN 端的驗證〉。</p>
<h2 id="會老化的軟體各條更新管道與更新後的重開機">會老化的軟體：各條更新管道與更新後的重開機</h2>
<p>安全更新的維護包含兩件獨立的事：把更新裝上去，以及讓更新後的程式碼實際開始執行。兩件事都要依軟體是從哪一條管道裝上去的來判斷，因為每一條管道由不同的機制更新，主人得知有修補的方式與能不能交給排程也各不相同：</p>
<ul>
<li><strong>發行版套件庫裡的套件</strong>（nginx、OpenSSH、核心、函式庫）：修補由發行版發佈，套件管理器讀得到；可以交給排程自動套用，下面寫它的設定。</li>
<li><strong>第三方套件庫的套件</strong>（nginx.org 的官方套件庫、docker-ce 這類）：一樣由套件管理器更新，但要看那個套件庫有沒有發佈安全公告；只裝安全更新的設定依公告的類型挑套件，沒有發佈公告的套件庫，裡面的套件不會被挑到（從 dnf 依 updateinfo 公告篩選的機制推論，沒有實測）。dnf-automatic 的 <code>upgrade_type</code> 對整台主機生效，沒有逐一套件庫的設定，要對單一套件庫套用全部的更新，就另寫一個 <a href="/blog/linux/dotfile/knowledge-cards/systemd-timer/" data-link-title="systemd timer" data-link-desc="排程的工作沒有在預期的時間執行、主機關機期間錯過的排程不知道會不會補跑、要在 cron 與 systemd timer 之間選一種寫定期工作，或讀到 *.timer 檔與 systemctl list-timers 的輸出時">systemd timer</a> 執行 <code>dnf repository-packages &lt;套件庫&gt; upgrade</code>：它只升級那個套件庫的套件，相依的套件仍從全部套件庫取；<code>dnf upgrade --repo=&lt;套件庫&gt;</code> 會停用其他套件庫，新版本多了其他套件庫的相依套件時解析不到。在 Oracle Linux 10.2 的容器（dnf 4.20）用一個自建的本機套件庫實測：主機裝著 <code>demo-1</code>，套件庫裡的 <code>demo-2</code> 多了一個要從發行版套件庫取的相依套件 <code>jq</code>；<code>dnf upgrade --repo=localdemo</code> 以 <code>nothing provides jq needed by demo-2-1.noarch from localdemo</code> 失敗，<code>dnf repository-packages localdemo upgrade</code> 只升級 <code>demo</code>，<code>jq</code> 與它的相依套件從 <code>ol10_baseos_latest</code> 一起裝上。相依套件都已經裝好時（nginx.org 套件庫的 nginx 從 1.30.2 升到 1.30.6），兩條指令的結果相同；Debian 系在 unattended-upgrades 的 <code>Origins-Pattern</code> 加上這個來源。</li>
<li><strong>容器映像檔裡的軟體</strong>：主機的套件管理器管不到。修補要等上游的映像檔發新版，得知的方式是上游的發佈通知或映像檔的漏洞掃描工具；重新拉取、重新建立容器可以排程，代價是沒有人在場時換了版本，所以多半在維護時段做。</li>
<li><strong>應用程式自己的更新機制</strong>（WordPress 的核心與外掛這類 CMS）：由應用程式自己的更新功能處理，主機的排程碰不到。看應用程式有沒有自動更新的設定（WordPress 的外掛可以逐個開自動更新）；沒開的，列進維護時段。</li>
<li><strong>自己建置的程式</strong>（自己編譯的 Go、Rust 程式）：依賴的函式庫或語言的標準函式庫有漏洞時，要重新建置、重新部署才修得到。得知的方式是對依賴跑語言的漏洞掃描工具（Go 的 <code>govulncheck</code>），掃描可以排程，重建與部署多半由人在維護時段做。<code>govulncheck ./...</code> 只回報程式碼實際呼叫得到的漏洞，找到時 exit code 是 3，沒有時是 0（govulncheck 1.1.3 對一個依賴 <code>golang.org/x/text</code> v0.3.5 的程式實測，升到 v0.21.0 之後變成 0），所以排程的 timer 可以直接依 exit code 決定要不要告警。</li>
<li><strong>從上游下載的預先建置檔案</strong>（官方發佈頁下載的二進位檔、<code>curl | sh</code> 裝的工具）：沒有任何套件管理器記得它裝過，要人去換；得知的方式是訂閱上游的發佈通知，並把它列進清單，否則它會一直停在安裝那天的版本。</li>
</ul>
<p>容器映像檔的漏洞掃描排程時，要讀的是掃描工具的 exit code，而它的預設跟 govulncheck 不同。以 trivy 0.57.1（官方映像檔 <code>aquasec/trivy:0.57.1</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">trivy image --download-db-only                                     <span class="c1"># 只下載漏洞資料庫，獨立成一次執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">trivy image --skip-db-update --exit-code <span class="m">7</span> &lt;映像檔&gt;                <span class="c1"># 有漏洞回 7、沒有回 0、掃描失敗回 1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">trivy image --skip-db-update --exit-code <span class="m">7</span> --exit-on-eol <span class="m">5</span> &lt;映像檔&gt; <span class="c1"># 映像檔的發行版已經 EOL 時回 5</span></span></span></code></pre></div><ul>
<li><strong>exit code 的讀法</strong>：不帶 <code>--exit-code</code> 時，找到漏洞照樣回 0（一個 2023 年 6 月的 Debian 12 映像檔掃出 371 個漏洞，exit 0），所以排程一定要帶這個參數。掃描本身失敗（映像檔名稱打錯、逾時、離線抓不到映像檔、資料庫下載失敗）一律回 1，不受 <code>--exit-code</code> 影響；<code>--exit-code</code> 設成 1 時，「找到漏洞」與「沒有掃成」都是 1，timer 分不開，所以設成 1 以外的值；上面的 7 與 5 是這次挑的值，跟排程裡其他會用到的 exit code 不重複就可以。<code>--severity HIGH,CRITICAL</code> 與 <code>--ignore-unfixed</code> 只縮小計數，篩完還有漏洞時一樣回那個值。</li>
<li><strong>已經 EOL 的映像檔</strong>：發行版過了支援期限時，資料庫裡不再有它的新漏洞，trivy 掃出 0 個、回 0，只在 stderr 印兩行 WARN：<code>This OS version is no longer supported by the distribution</code> 與 <code>The vulnerability detection may be insufficient because security updates are not provided</code>（alpine 3.15 實測）。exit 0 在這裡看起來跟乾淨一樣，帶 <code>--exit-on-eol &lt;值&gt;</code> 才變成一個看得到的 exit code。</li>
<li><strong>資料庫</strong>：壓縮檔約 130 MiB，解開後的 <code>trivy.db</code> 約 1.6 GB（1,637,429,248 位元組），放在快取目錄（預設 <code>/root/.cache/trivy</code>）的 <code>db/</code>。<code>--timeout</code>（<code>--help</code> 印出的預設是 <code>5m0s</code>）的倒數包含下載資料庫的時間：快取是空的時，設 30 秒的那一次照樣下載了約 2 分鐘，跑完才以 <code>context deadline exceeded</code> 失敗，下載的內容沒有留下。資料庫已在本機時，一次掃描的時間主要花在從 registry 拉映像檔的 layer 與分析（一個 Debian 映像檔約 40 到 50 秒，layer 已在快取時幾秒）。所以下載資料庫與掃描分成兩次執行，各自有自己的逾時。</li>
<li><strong>資料庫的新舊與更新失敗</strong>：0.57.1 帶 <code>--skip-db-update</code>、用過期的資料庫掃描時（快取的 <code>metadata.json</code> 把 <code>UpdatedAt</code> 改成一年多前）不印任何警告，結果照常輸出，資料庫的新舊要由排程自己讀 <code>metadata.json</code> 的 <code>UpdatedAt</code>。分成兩次執行之後，掃描仍然會被更新的失敗拖累：離線時 <code>--download-db-only</code> 失敗，trivy 刪掉 <code>metadata.json</code>（<code>trivy.db</code> 留著）；連網而逾時失敗，整個 <code>db/</code> 目錄都被刪掉。兩種情形之後帶 <code>--skip-db-update</code> 的掃描都以 <code>The first run cannot skip downloading DB</code> 失敗、回 1，跟其他掃描失敗分不開。所以更新那一次的 exit 1 本身要告警；在更新之前備份 <code>metadata.json</code>、失敗後放回去，離線那一種掃描就恢復正常（實測），逾時那一種要整個 <code>db/</code> 一起備份。另一種寫法是把更新下載到另一個快取目錄（<code>--cache-dir</code>），成功之後才換掉掃描用的那一份，這個寫法沒有實跑。</li>
<li><strong>掃本機才有的映像檔</strong>：trivy 跑在容器裡時讀不到主機的映像檔，有兩種給法：把主機的 <code>/var/run/docker.sock</code> 掛進去，或在主機上 <code>docker save -o &lt;檔名&gt;.tar &lt;映像檔&gt;</code> 再用 <code>trivy image --input</code> 讀那個檔。兩種掃出的結果相同；掛 docker.sock 等於把主機 Docker daemon 的完整控制權交給這個容器（能啟動特權容器、掛主機的根目錄），<code>docker save</code> 那一種容器只拿到一個以 <code>:ro</code> 掛進去的檔案，代價是每次掃描前要重新匯出一次。</li>
</ul>
<p>發行版套件庫這一條可以交給排程。Oracle Linux 與 RHEL 系由 <code>dnf-automatic.timer</code> 定期觸發 <code>dnf-automatic</code>，它讀 <code>/etc/dnf/automatic.conf</code> 的兩個鍵：<code>upgrade_type</code> 決定裝哪一類更新（<code>security</code> 只裝安全更新），<code>apply_updates</code> 決定下載之後要不要安裝（預設 <code>no</code> 只下載）。timer 沒有啟用時，設定檔改了也不會執行；<code>systemctl list-timers dnf-automatic.timer</code> 列得出下一次執行的時間，才代表排程在跑。</p>
<p>Debian 系用 <code>unattended-upgrades</code>，排程由兩個 timer 分工：<code>apt-daily.timer</code>（每天 6 點與 18 點，加最多 12 小時的隨機延遲）更新套件清單，<code>apt-daily-upgrade.timer</code>（每天 6 點，加最多 60 分鐘）執行安裝；要不要安裝由 <code>/etc/apt/apt.conf.d/20auto-upgrades</code> 的 <code>APT::Periodic::Unattended-Upgrade</code> 決定，非互動安裝這個套件時，它的值預設是 <code>&quot;1&quot;</code>（套件的 debconf 問題 <code>enable_auto_updates</code> 預設 true）。裝哪些來源的更新寫在 <code>/etc/apt/apt.conf.d/50unattended-upgrades</code>，兩家的預設不同：Debian 12 與 13 的 <code>Origins-Pattern</code> 除了 <code>label=Debian-Security</code> 的兩筆，還有 <code>label=Debian</code> 那一筆，也就是穩定版的點版本更新（point release）同樣自動套用；Ubuntu 24.04 的 <code>Allowed-Origins</code> 是發行時的版本、<code>-security</code> 與兩個 ESM 的安全來源，不含 <code>-updates</code>，自動套用的只有安全更新。以上是三個發行版的容器裡裝上 unattended-upgrades 2.9 到 2.12 之後讀出的值；Oracle Cloud 的 Ubuntu 24.04 映像檔預先裝了 unattended-upgrades 與 needrestart，<code>20auto-upgrades</code> 的兩個值與 <code>Allowed-Origins</code> 的來源跟容器裡讀出的相同，兩個 timer 都是 enabled。</p>
<p>雲端映像檔是某一天建的，主機第一次開機時就已經落後：上面那個映像檔開機之後有 60 個來自 <code>-security</code> 的更新待裝，其中包括核心。手動觸發一次 <code>unattended-upgrade</code>（<code>apt-daily-upgrade.timer</code> 啟動的 <code>apt.systemd.daily install</code> 呼叫的也是它）花了 402 秒，升級了 64 個套件，其中包括新核心；裝完之後還有 18 個可以更新的套件，全部來自 <code>-updates</code>，照預設沒有裝。所以新建的主機在第一次排程執行之前，要先手動跑一次更新並重開機，否則從開機到排程第一次執行之間，映像檔建立之後公開的漏洞都還在。排程裝得到更新的前提是主機連得到套件來源：同一台主機放在只接了 Oracle 服務網路出口（service gateway）的私有子網路時，<code>apt-get update</code> 以 <code>connect (101: Network is unreachable)</code> 與連線逾時失敗；這個子網路加上 NAT gateway、主機放行出站的 80 與 443 之後，同一條指令從 <code>ports.ubuntu.com</code> 取得了套件清單，所以 Ubuntu 的套件來源在 Oracle 的服務網路之外。排程在這種主機上照常觸發而裝不到任何套件（從 <code>apt-get update</code> 的失敗推論，沒有等 timer 觸發實測），讀它的執行紀錄才看得出來。錯誤原文、成因與加出站的指令在 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>〈只接 service gateway 的私有子網路：apt 連到套件來源時的錯誤原文與成因〉與〈加出站：NAT gateway、路由規則與只掛在這台主機上的 NSG〉。</p>
<p>這裡有兩個獨立的選擇：</p>
<ul>
<li><strong>要不要讓排程自動套用</strong>：自動套用時，一個漏洞從修補發佈到這台主機套用，最多隔一個排程的間隔；不自動套用時，這段時間等於兩次人工更新之間的間隔，那段時間裡公開的漏洞在這台主機上都還在。</li>
<li><strong>只裝安全更新，還是連一般更新一起裝</strong>：安全更新多半是把修補移植回目前的版本，變更的範圍小，改變服務行為的機會比一般更新小，適合在沒有人看著的時候自動套用。只裝安全更新時，一般更新會長期落後，之後整批升級時一次跨過的版本差距大，那一次要安排在有人在場的維護時段做。</li>
</ul>
<p>套件庫的設定決定自動更新拿得到什麼：有些發行版的套件庫鎖在固定的版本，自動更新只會在那個版本裡更新，拿不到之後發佈的修補。Amazon Linux 2023 預設把套件庫鎖在建立那個 AMI 時的版本，要用 <code>dnf upgrade --releasever=&lt;版本&gt;</code> 才換到新版本的套件庫（AWS 文件〈Deterministic upgrades through versioned repositories on AL2023〉，沒有實測）。發行版本身過了支援期限（EOL）之後，套件庫不再發佈安全更新，排程照常執行而什麼都裝不到；EOL 的判斷與升級的做法見 <a href="/blog/devops/infra/upgrade/os-base-software-upgrade/" data-link-title="OS 與基礎軟體更換" data-link-desc="EOL 作業系統的遷移評估、目標 OS 選型、原地升級 vs 平行建置的取捨、應用層遷移清單，以及 Apache → nginx 等基礎軟體切換的操作要點">OS 與基礎軟體更換</a>〈EOL 風險評估〉。確認自動更新實際裝了套件，讀它的執行紀錄（〈定期讀的 log〉表格的自動更新那一列）。</p>
<p>裝上去的更新要等使用它的程序重新啟動才生效：已經在執行的程序把舊版的函式庫留在記憶體裡，核心更新要重開機才換成新核心。<code>needs-restarting</code>（Oracle Linux 10.2 由 <code>yum-utils</code> 套件提供）回報兩件事：<code>-r</code> 回報需不需要重開機，<code>-s</code> 列出用到了已更新函式庫、重啟就能生效的服務。只有函式庫更新時，重啟 <code>-s</code> 列出的服務就夠，不必重開機。核心更新要重開機時，可以交給 dnf-automatic 自己（<code>automatic.conf</code> 的 <code>reboot</code> 鍵：<code>never</code>、<code>when-changed</code>、<code>when-needed</code>），或另寫一個在維護時段執行的 systemd timer，依 <code>needs-restarting -r</code> 的結果決定要不要重開。</p>
<p>Debian 系把這兩件事分給兩個機制：</p>
<ul>
<li><strong>需不需要重開機</strong>：看 <code>/var/run/reboot-required</code> 存不存在，需要重開機的套件名寫在 <code>/var/run/reboot-required.pkgs</code>。這個檔案由核心套件安裝後執行的 <code>/etc/kernel/postinst.d/</code> 底下的腳本建立，在 Debian 12 與 13 上，那支腳本屬於 unattended-upgrades 套件，沒裝這個套件的主機，核心更新之後這個檔案不會出現，它不存在不代表不需要重開；Ubuntu 24.04 的 <code>/etc/kernel/postinst.d/</code> 底下另有一支 <code>update-notifier</code>，<code>dpkg -S</code> 查得到它與登入訊息（motd）的 <code>/etc/update-motd.d/98-reboot-required</code> 都屬於 update-notifier-common。在 Oracle Cloud 的 Ubuntu 24.04 主機上，<code>unattended-upgrade</code> 裝完新核心之後，<code>/var/run/reboot-required</code> 的內容是 <code>*** System restart required ***</code>，<code>.pkgs</code> 列出新核心的套件與 <code>linux-base</code>，重開機之後兩個檔案都不在了；兩支腳本都裝著，所以這次分不出是哪一支建的，登入訊息也沒有實際看過。</li>
<li><strong>哪些服務要重啟</strong>：needrestart 套件在每次 apt 安裝之後（<code>/etc/apt/apt.conf.d/99needrestart</code> 的 hook）檢查用到舊函式庫的服務；<code>needrestart -b</code> 用批次格式印出結果，<code>NEEDRESTART-SVC</code> 每行一個要重啟的服務，<code>NEEDRESTART-KSTA</code> 是核心的狀態（依 needrestart 的說明：<code>1</code> 沒有待生效的核心，<code>2</code> 與 <code>3</code> 有新核心等重開機，<code>0</code> 是判斷不出來）。容器裡跑 <code>needrestart -b</code> 時核心屬於宿主，KSTA 印出 <code>0</code>，其他值要在主機上才看得到。在 Oracle Cloud 的 Ubuntu 24.04 主機上量了三個時間點：開機時 <code>1</code>；<code>unattended-upgrade</code> 裝了下一個主要版本的核心之後是 <code>3</code>，<code>NEEDRESTART-KCUR</code> 是執行中的舊核心、<code>NEEDRESTART-KEXP</code> 是新裝的核心，同一次另外印出 <code>dbus.service</code>、<code>systemd-logind.service</code> 與 <code>unattended-upgrades.service</code> 三行 <code>NEEDRESTART-SVC</code>；重開機之後回到 <code>1</code>，KCUR 與 KEXP 相同。<code>2</code>（新核心與執行中的核心 ABI 相容）這次沒有遇到。</li>
</ul>
<p>unattended-upgrades 自己重開機的設定是 <code>50unattended-upgrades</code> 裡的 <code>Unattended-Upgrade::Automatic-Reboot</code>，預設 <code>&quot;false&quot;</code>；改成 <code>&quot;true&quot;</code> 時，安裝完成而 <code>reboot-required</code> 存在，它就在 <code>Automatic-Reboot-Time</code> 指定的時間重開；這個鍵的預設是 <code>&quot;now&quot;</code>，也就是安裝一完成就立刻重開，設定檔裡被註解掉的 <code>&quot;02:00&quot;</code> 是範例值，要固定在維護時段重開就得自己寫上時間（這是設定檔註解寫的行為，容器裡沒有實際重開機）。</p>
<p>是否讓它自動重開，取決於兩個條件：重開機之後服務會不會自己回來，以及服務沒回來時有沒有人知道。</p>
<ul>
<li><strong>服務會不會自己回來</strong>：看服務的 unit 是否 enabled（<code>systemctl is-enabled &lt;unit&gt;</code>）、容器的 restart policy、主機防火牆的規則是不是寫進了永久設定；最可靠的確認是在有人在場的維護時段手動重開一次。例如服務當初是在終端機裡手動啟動、一直沒有寫成 systemd unit，平常主機一直開著所以看不出來，排程在凌晨重開機之後服務就不在了。服務不會自己回來時，先把它改成會自己回來（enable unit、設 restart policy），再看下一個條件。這個條件跟更新無關也成立：主機因為停電或業者的硬體維護重開時，結果相同。</li>
<li><strong>服務沒回來時有沒有人知道</strong>：要有一個跑在這台主機以外的觀察者（另一台主機上的外部探測、或心跳監看：主機定期送訊號、停送時由外部服務告警），以及告警實際送得到人；跑在同一台主機上的監控，重開機時跟著停掉，報不出這台主機沒回來。外部探測的位置與判讀見 <a href="/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀</a>〈頻外訊號：心跳監看與外部探測〉。</li>
</ul>
<p>兩個條件都成立時，讓它在固定時段自動重開，代價是每次一段可預期的停機，服務沒回來時探測會叫人；新核心開不了機時，修復要從雲端業者的序列主控台進去，停機的長度就不再可預期，自動重開機的時段要選在救援得到的時候。序列主控台要在出事之前準備好三件事：帳號或主機層級已經啟用（部分業者要事先開啟）、有一個能在主控台登入的方式（雲端映像檔的使用者通常只有金鑰、沒有密碼）、開機選單（GRUB）選得回上一版核心；這幾件沒準備好時，救援只剩業者的救援開機，或把磁碟掛到另一台主機上修。三家業者文件寫的預設如下，Oracle Cloud 與 Google Cloud 實際連過，AWS 只取自文件：</p>
<ul>
<li><strong>AWS</strong>：帳號層級預設不允許使用序列主控台，要在每個區域分別開啟，使用者另外要有 <code>ec2-instance-connect:SendSerialConsoleSSHPublicKey</code> 的 IAM 權限；連得上之後要登入作業系統，主機上得有一個設了密碼的使用者，而 AWS 提供的 AMI 預設沒有，要事先在每台主機用 <code>passwd</code> 設好（AWS 文件〈Configure access to the EC2 Serial Console〉）。</li>
<li><strong>Google Cloud</strong>：互動式的序列埠存取預設關閉，在專案或主機的中繼資料設 <code>serial-port-enable</code> 為 <code>TRUE</code> 才開；連線用 SSH 金鑰驗證，出現登入提示時要本機使用者的密碼，Google 提供的 Linux 映像檔預設不允許密碼登入，要事先用 <code>sudo passwd</code> 或開機腳本設好（Google Cloud 文件〈Troubleshooting using the serial console〉）。在 Debian 12 的 VM 上先建一個設了密碼的救援帳號、再開啟序列埠登入的完整步驟與實測，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP</a>〈準備救援入口：Serial Console 與本機救援帳號〉。</li>
<li><strong>Oracle Cloud</strong>：要先替主機建立一個序列主控台連線（instance console connection），上傳一把 RSA 公鑰，經由 SSH 連進去；只看開機輸出不必登入，要登入作業系統時，Oracle Cloud 不替 <code>opc</code> 使用者設預設密碼，要事先替它或另一個使用者設好（Oracle Cloud 文件〈Instance Console Connections〉）。在一台 Ubuntu 24.04 的 A1 主機上實際連過：<code>oci compute instance-console-connection create</code> 帶 RSA 公鑰建立連線，CLI 給的連線字串是一條經由 443 埠跳板的 ssh 指令；連上之後讓主機重開機，看得到 UEFI 韌體的輸出、核心開機訊息，開機後約 27 秒在 <code>ttyAMA0</code> 印出登入提示，所以只看開機過程（核心卡在哪一步）時不必登入。這台主機的 <code>ubuntu</code> 使用者，<code>passwd -S ubuntu</code> 印出的狀態是 <code>L</code>（密碼鎖定），這次也沒有替任何使用者設密碼，所以登入那一步沒有做。金鑰、建立連線的指令與 ProxyJump 的設定檔寫在 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>〈序列主控台：RSA 金鑰、建立連線、ProxyJump 設定與看到的開機輸出〉。</li>
</ul>
<p>三家的共同點是登入作業系統要密碼，而雲端映像檔的使用者預設只有金鑰，所以「能登入」這一件要在主機正常時先做；設了密碼的使用者也讓經由 SSH 以外的登入多了一條路，密碼的強度與只在主控台使用的限制（例如 sshd 設成不接受密碼登入）要一起決定。</p>
<p>觀察者還沒有時，先加上它，外部探測與心跳監看都有免費的服務。暫時加不了時，不自動重開機，主機處在「更新已裝、但核心的修補還沒生效」的狀態，直到有人安排重開為止；這段時間要被看見，所以把 <code>needs-restarting -r</code> 的結果寫進人會讀的位置：登入時顯示的訊息（motd），或維護時段讀的檢查報告（例如清單裡的一項 <code>WARN</code>），讓它在下一次登入或維護時被看到。</p>
<h2 id="會到期與會改變的外部設定">會到期與會改變的外部設定</h2>
<p>這一類狀態的期限多半在主機外面，主機上的檢查量不到，要從持有那個期限的地方查：</p>
<ul>
<li><strong>憑證</strong>：公開憑證靠自動續期（ACME 協定，見 <a href="/blog/backend/knowledge-cards/acme-automation/" data-link-title="ACME Automation" data-link-desc="說明網站憑證如何透過 ACME 自動簽發與續期">ACME Automation</a>），要確認的是續期的排程實際成功，以及續期的時機依效期的比例計算，而不是寫死的天數。效期正在縮短：Let&rsquo;s Encrypt 公告從 2027 年 2 月 10 日起預設簽發 64 天的憑證、2028 年再縮到 45 天，並建議把寫死「簽發後第 N 天續期」的排程改成在效期約三分之二時續期，或改用支援 ACME Renewal Info（ARI）的客戶端，由 CA 通知續期時間（<a href="https://letsencrypt.org/2026/10/07/64-day-certs">Let&rsquo;s Encrypt 的公告</a>）；cron、包裝腳本與操作手冊裡寫死的天數，在效期縮短之後會讓憑證在續期之前就過期。CDN 邊緣的憑證由 CDN 管。</li>
<li><strong>源站給 CDN 用的自簽憑證</strong>：效期由產生它的人決定；CDN 的 SSL 模式只加密、不驗證源站憑證時（Cloudflare 的 Full），這張自簽憑證過期也不會讓連線失敗，所以它過期了也沒有人會發現。要改成驗證源站憑證的模式（Full (strict)）時，先把這張自簽憑證換成 CDN 驗得過的憑證（公開 CA 簽發的，或 CDN 業者發給源站用的憑證），否則一切換所有請求都會失敗。</li>
<li><strong>網域註冊</strong>：到期之後網域可能被別人註冊，所有指向它的紀錄跟著失效，而且收不回來；在註冊商那邊開自動續約，另設一條到期前的告警確認續約成功（付款方式過期時，自動續約會失敗）。</li>
<li><strong>雲端 CLI 的登入</strong>：session token 這類短效期的登入過期之後，維護用的指令全部失敗，主機本身不受影響；它影響的是維護的人能否從開發機執行雲端 CLI 的指令。</li>
<li><strong>管理者的 IP</strong>：雲端防火牆只放行管理者 IP 時，ISP 重新分配了那個 IP，管理者就連不進來；舊的 IP 被分配給別人時，放行規則變成放行了一個陌生人。換 IP 之後要同時改 SSH 與 bastion 的放行來源。</li>
<li><strong>CDN 的位址段</strong>：源站只放行 CDN 位址段時，CDN 新增的位址段不在放行清單裡，從那些位址回源的請求會連線失敗，訪客看到 CDN 的錯誤頁；CDN 業者公布位址段的清單（Cloudflare 的 <code>ips-v4</code> 與 <code>ips-v6</code>），定期拿它跟防火牆規則比對。取回清單、比對與套用時的陷阱見 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>〈只放行 CDN 位址的套用時機、範圍與清單的維護〉。</li>
<li><strong>免費額度的閒置回收</strong>：免費方案的業者可能回收長期閒置的主機，Oracle Cloud 寫出了判定閒置的使用率門檻，規則的出處與三家業者的比較見 <a href="/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目</a>〈出站流量與閒置回收〉。一台大部分時間沒有流量的實驗機會落在這個條件裡；前面有 CDN 的靜態網站也是，請求多半由 CDN 的快取回應，源站本身的使用率很低。被回收之後是停止還是刪除，官方沒有寫，在容量常常不足的機型上（Oracle Cloud 的 A1），被回收之後不一定建得回來，所以這一項歸在收不回來的那一類：主機上的資料要另外備份到主機以外，並確認照 repo 重建得出同樣的服務。回收在哪一刻發生沒有變化速度可算，備份多久做一次因此不由檢查間隔決定，而由能接受遺失多久的資料決定（<a href="/blog/backend/knowledge-cards/rpo/" data-link-title="RPO" data-link-desc="說明恢復點目標如何定義可接受資料損失範圍">RPO</a>）：資料庫用它自己的匯出工具（PostgreSQL 的 <code>pg_dump</code>、SQLite 的 <code>.backup</code>）排程，備份送到主機以外，並定期還原一次確認可用。</li>
</ul>
<p>這一類裡，到期時間可以預先查到的（憑證、網域、token），適合寫成到期前的自動告警，設計見 <a href="/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看</a>；改變時間無法預知的（管理者 IP、CDN 位址段、業者的回收），要靠定期比對與事先的備份。</p>
<h2 id="檢查間隔與哪些項目改成自動處理的推算">檢查間隔與哪些項目改成自動處理的推算</h2>
<p>每一類狀態的檢查間隔，從三個量推出來：警戒值（〈沒有人操作時主機上會改變的狀態〉定義的那個界線）設在哪裡、量測值從現在的值變化到警戒值要多久，以及到了真正出事的那一刻之後收不收得回來。</p>
<p><strong>警戒值設在哪裡</strong>：從出事的那一刻往回，留出處理期間最壞會增長的量（處理要花的時間乘上最壞的增長速度；清 log 幾分鐘，擴磁碟或搬資料要三天），再加上量測的誤差，警戒值就設在那個位置。處理時間只在設警戒值的時候算一次，之後的間隔推算不再扣它。</p>
<p><strong>到達警戒值的時間</strong>：量測值從現在的值變化到警戒值要花的時間，下文寫成 T。用兩次量測的差除以兩次之間的天數得到變化速度，再拿現在的值到警戒值的距離除以速度。有四種情形拿不到穩定的速度：</p>
<ul>
<li><strong>第一次盤點時只有一次量測</strong>：現值已經接近或超過警戒值的，當場處理；其餘的隔一小段時間（例如隔天）量第二次，拿到速度再算。</li>
<li><strong>有輪替的資料（log）</strong>：用量是鋸齒形，兩次量測之間剛好輪替過時差是零或負數。改成比較相鄰兩個輪替週期裡的最高值，差除以週期的長度得到速度；保留份數固定的 log，保留滿之後總量停在份數乘上每份的大小，之後的增長只來自流量變多。</li>
<li><strong>增長由外部請求決定</strong>（上面的寫入端點）：兩次量測之間的速度不代表下一段時間的速度，改用最壞情況的速度。</li>
<li><strong>改變是步階變化</strong>：量測值在某一刻一次就從一個值變成另一個值、之後停在新值，中間沒有過渡值（<a href="/blog/backend/knowledge-cards/step-change/" data-link-title="Step Change（步階變化）" data-link-desc="判斷一個指標或一項設定的改變能不能從量測的趨勢預測、要用趨勢外推還是跟基準比對才偵測得到時，區分步階變化與逐步累積的變化">Step Change（步階變化）</a>），相對的是上面幾種逐步累積的變化。例如有人在主控台把 SSH 的放行來源從管理者 IP 改成 <code>0.0.0.0/0</code>，規則在送出的那一刻就從只放行管理者變成對所有人開放；新套件帶進一個在所有介面上接聽的服務、CDN 新增一個位址段、管理者 IP 被 ISP 重新分配，也都是這一種。兩次量測之間看不到任何接近警戒值的過程，到達警戒值的時間是零，下面的公式算不出間隔，處理方式另列在分類裡。同一個量也可能兩種形態都有：磁碟用量平常逐步累積，有人一次複製進一個大檔案時就是步階變化；這一次的增加由下面收不回來那一類的剩餘空間告警發現，不靠檢查間隔。</li>
</ul>
<p><strong>檢查間隔</strong>：間隔為 I 時，漏掉一次檢查，下一次量測落在 2I，要在量測值超過警戒值之前量到，I 要小於 T 的一半。本篇取 T 的三分之一：漏掉一次檢查時，下一次量測落在 T 的三分之二，離到達警戒值還剩三分之一的 T，留給量測的波動與沒預料到的突增；這個比例是作者訂的安全係數，量測的波動大時取得更短。例如剩下 15 GB、log 與資料每天增加 50 MB、擴磁碟要三天：三天的增長只有 150 MB，警戒值取在剩 3 GB 的位置，比三天的增長量大得多，多出來的部分給突增；到達警戒值約 240 天，間隔可以到 80 天，每月量一次就在範圍內。這個間隔管的是這些資料的增長速度；檔案系統完全寫滿屬於收不回來的那一類，剩餘空間另外照下面的分類設一條告警。同一台主機的寫入端點在最壞情況下約 3 小時就寫滿，沒有任何人的檢查間隔來得及。</p>
<p><strong>維護節奏</strong>通常是先決定好的（每週或每月一次的維護時段），而不是每一項各自算出來的。每一項算出來的間隔跟這個節奏比：算出的間隔不短於維護節奏的，併進同一次維護；算出的間隔比維護節奏短的，有兩條路：另排一個比節奏短的排程檢查（例如每週執行一次只讀檢查，結果送到下一段說的接收端），或交給機制自動處理。到達警戒值的時間短於任何排程間隔時（寫入端點在最壞情況下約 3 小時就寫滿，到達警戒值還更早），只剩機制這一條路。</p>
<p>這幾條都要有人收到結果，而「收到」包含兩件事，各自的條件不同：</p>
<ul>
<li><strong>主機還在運作時才會發生的狀態</strong>（檔案系統的剩餘空間、寫成告警的 log 計數，例如 sshd 的失敗登入、憑證效期；主機開著而某個服務沒有起來也屬於這一種）：告警可以由主機上的程式發出（例如同一台主機上的 Prometheus 與 Alertmanager），條件是送達的管道在主機以外（推播服務、郵件）。</li>
<li><strong>主機整台停止、或重開機之後主機本身沒有回來</strong>（連不上、作業系統沒有開起來）：主機上的程式跟著停掉，要由跑在主機以外的觀察者發現：外部探測，或心跳監看（主機上的程式定期送訊號，例如 Prometheus 一條一直處在觸發狀態的告警，外部服務在訊號停止時叫人，見 <a href="/blog/backend/knowledge-cards/dead-mans-switch/" data-link-title="Dead Man&#39;s Switch（心跳監看）" data-link-desc="需要知道監控或排程工作自己停掉了、而它停掉時發不出任何告警時，以「時限內沒收到訊號就通知」反過來判斷">Dead Man&rsquo;s Switch</a>）。</li>
</ul>
<p>兩者都沒有時，告警等於沒有寫，這些項目的實際間隔就是人登入主機的間隔；建一個最小的接收端（送達管道在主機以外的推播通知，加上外部的心跳或探測服務）是比這一節任何一項都先做的事。由這幾個量，每一項的處理方式如下：</p>
<ul>
<li><strong>越過之後收不回來</strong>（安全漏洞被利用、檔案系統寫滿期間所有寫入者沒寫進去的紀錄、被回收的免費主機、到期被別人註冊的網域）：已經發生的入侵與已經遺失的紀錄沒有辦法收回，所以不等檢查間隔，優先自動化：安全更新依〈會老化的軟體〉的各條管道處理，能排程的交給排程，不能排程的列進維護時段並寫明多久查一次，需要重開機才生效的部分照那一節的條件；檔案系統的剩餘空間是全部寫入者共用的資源，一律寫成告警；網域開自動續約；會被回收的主機，資料依 RPO 定期備份到主機以外。</li>
<li><strong>改變是步階變化</strong>：比對的間隔不能超過維護的人能接受改變後的狀態持續的最長時間，不由維護節奏決定：每天比對一次，一條被放寬的規則最多開著一天才被發現。這個長度依暴露之後被利用的代價訂，直接把管理入口或資料庫開到外面的，比對要比只會讓部分回源失敗的更密，能在變更發生的當下收到事件（雲端的稽核紀錄、設定變更的通知）就更好。會造成對外暴露的（防火牆規則被放寬、新的接聽程式）與會讓回源失敗的（CDN 新增的位址段），交給定期比對的排程：主機上的接聽清單與暫停前或部署時的清單比、從另一台機器量公開 IP 上連得到的入口、拿 CDN 公布的位址段跟放行規則比，結果送到接收端。管理者 IP 被重新分配時，管理者下一次連線就發現自己連不進去，而舊的 IP 可能已經分給別人、放行規則對陌生人開著 SSH 的連線（登入仍要金鑰），所以發現的那一次就同時把 SSH 與 bastion 的放行來源改成新的 IP；別處有沒有寫死舊 IP（監控的放行來源、維護文件），這一項的確認併進維護時段。</li>
<li><strong>到達警戒值的時間比維護節奏短</strong>（對外寫入端點造成的資料增長）：維護時段來不及在量測值超過警戒值之前量到，超過的那一刻沒有人在場，所以這一類交給機制自動處理（應用程式的保留期限與配額、容量隔離、全域的速率限制），或另排比節奏短的排程檢查；短於任何排程間隔時只剩機制。維護時段量的是那個機制還在不在運作。</li>
<li><strong>期限可以預先查到</strong>（憑證、token）：到期日是已知的，等人想起來查才發現就太晚，寫成到期前的告警，維護時段確認告警還設著。</li>
<li><strong>算出的間隔不短於維護節奏，而且越過之後收得回來</strong>（套件快取、暫存檔、有輪替機制的 log 這些個別的寫入者）：個別的寫入者超過自己的警戒值，只是佔掉較多的空間，清掉就回到警戒值以內；收不回來的是整個檔案系統寫滿，那由全部寫入者一起造成，已經由收不回來那一類的剩餘空間告警處理。所以這一類在維護時段量測與清理，量的目的是找出是誰在長、長多快，交給人判讀的好處是看得到成長的趨勢與原因。</li>
</ul>
<p>同一個項目同時符合幾種時，照上面的順序取第一個符合的：收不回來優先於步階變化，步階變化優先於到達警戒值比維護節奏快，到達警戒值比維護節奏快優先於期限可預知，期限可預知優先於留給維護時段。哪些項目適合寫成告警、哪些留給人定期讀，在有人值班、每天接班檢查的團隊裡另有一套判斷，見 <a href="/blog/backend/04-observability/on-call-routine-checks/" data-link-title="4.29 值班的例行檢查：接班檢查與每週營運會議讀的訊號、交班紀錄的內容，以及例行檢查項目改寫成告警規則的條件" data-link-desc="告警刻意不呼叫值班的訊號由值班定時讀：接班時讀上一班的交班紀錄、生效中的告警靜音、只送通知的 warning、error budget 的走向、排程工作與心跳檢查項目、到期事件、容量走向、近期變更與客服單量，各自防的是哪一種情形；交班紀錄要寫的內容；每週營運會議讀的告警雜訊、值班負擔與重複出現的客服回報；以及一個檢查項目什麼時候該改寫成告警規則">4.29 值班的例行檢查：接班檢查與每週營運會議讀的訊號、交班紀錄的內容，以及例行檢查項目改寫成告警規則的條件</a>〈例行檢查項目改寫成告警規則的條件〉；本篇的節奏是一個人每週或每月的維護時段，所以留給人的項目只放變化慢的那些。</p>
<h2 id="接手或第一次盤點一台已經上線的主機">接手或第一次盤點一台已經上線的主機</h2>
<p>接手別人留下的主機、或第一次替自己的主機做盤點時，處理的順序依〈檢查間隔與哪些項目改成自動處理的推算〉的分類，以及每一步要用到的輸入，排成下面的順序：告警的接收端排在最前面，因為後面每一項寫成告警或排程比對的處置，都要靠它把結果送到人手上；接著只讀不改地記下主機的原狀，因為下一步要依這份清單判斷哪些接聽程式該停，而改動之前記下的才是前任留下的原狀；然後處理每多拖一天就多一天收不回的暴露或紀錄；最後量出之後計算變化速度要用的基準值。</p>
<ul>
<li><strong>先建告警的接收端</strong>：送達管道在主機以外的推播通知，加上外部的探測或心跳服務。</li>
<li><strong>只讀地記下原狀</strong>：從主機裡面列出接聽程式與各層的放行範圍（<code>ss -lntup</code>、主機防火牆的規則、雲端規則的匯出，跑 Docker 時加上 <code>docker ps</code> 的連接埠欄位），從另一台機器量公開 IP 上連得到的入口。沒有 repo 時，同一步把設定複製出來收進 repo：<code>docker-compose.yml</code> 與它引用的 <code>.env</code>、<code>/etc/docker/daemon.json</code>、正在跑的映像檔的 tag 或 digest（compose 寫 <code>latest</code> 時，重建會換成另一個版本）、反向代理的設定、systemd unit。之後停錯一個前任的服務時，這一份就是對照還原的依據，重建驗證與恢復時的比對也以它為基準。</li>
<li><strong>處理收不回來的暴露與紀錄</strong>：依記下原狀時列出的接聽清單與從外面量到的入口（前任開著對外的資料庫或管理後台時，它正是每拖一天就多一天暴露的項目），停用沒有業務用途的接聽程式、把管理入口收窄到管理者；依〈會老化的軟體〉的各條更新管道，列出這台主機上的軟體各自由什麼更新，能排程的排程，不能排程的列進維護時段並寫明多久查一次；journal 改存到磁碟，之後的紀錄才留得下來；設檔案系統剩餘空間的告警；網域開自動續約；資料依 RPO 備份到主機以外。</li>
<li><strong>量出基準值</strong>：處理完暴露之後，重新列一次接聽程式與各層的放行範圍，記成之後排程比對的基準；會累積的資料各自的大小與清理機制、各份 log 的計數，在接手當天量一次、隔一小段時間再量一次，作為之後計算變化速度與比較的起點。</li>
</ul>
<p>清單裡每一項要寫出量測的指令與允許的範圍，<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> 用一台實際的主機示範這份清單的欄位設計與只讀的檢查腳本；主機要暫停一段時間時，這些狀態在暫停期間繼續改變，恢復上線時要查哪幾項，寫在 <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>
]]></content:encoded></item><item><title>只讀檢查腳本 host-check.py 的原始碼與逐段說明：選項的切分與各工具的白名單規則、清單的載入與驗證、指令的執行、各項檢查、路徑與時間的量測、結果檔與命令列，以及這一版的已知限制</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/host-check-script-source/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/host-check-script-source/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-26，把作者私有專案裡的檢查腳本 &lt;code>host-check.py&lt;/code> 在這一天的版本（commit &lt;code>3fc41c0&lt;/code>）整理成這篇。這一版的單元測試在一台 Apple Silicon 的 macOS 開發機上，用 uv 分別起 Python 3.12 與 Python 3.15 各執行一次；這一版在 Oracle Linux 10 的容器裡執行過；一台 Oracle Linux 10.2 的雲端主機上執行過的，是修正「權限擋下的目錄被判成路徑不存在」之前的版本。&lt;/p>&lt;/blockquote>
&lt;p>這篇是主機維護清單的只讀檢查腳本 &lt;code>host-check.py&lt;/code> 的完整原始碼，依腳本自己的結構分節。每一個 &lt;code>##&lt;/code> 節先寫這一段程式負責什麼、它滿足的是清單或執行環境的哪一項需求，接著是這一段的原始碼，之後說明從程式碼不容易看出來的地方：為什麼這樣判斷、換成另一種寫法會讓哪一種寫入或誤判通過、哪一項單元測試鎖住這個行為。各節依序是：檔頭的說明與共用常數；照 &lt;code>getopt_long&lt;/code> 語法切分選項的函式；&lt;code>systemctl&lt;/code>、&lt;code>journalctl&lt;/code>、&lt;code>firewall-cmd&lt;/code>、&lt;code>ss&lt;/code>、&lt;code>find&lt;/code>、&lt;code>sqlite3&lt;/code>、&lt;code>openssl&lt;/code>、&lt;code>sshd&lt;/code>、&lt;code>needs-restarting&lt;/code>、&lt;code>dnf&lt;/code>、&lt;code>rpm&lt;/code>、&lt;code>ausearch&lt;/code> 各自的選項規則，以及不另外比對選項的工具；判定一條指令是否只讀的 &lt;code>readonly_violation&lt;/code> 與腳本自己組出來的指令；清單的載入與驗證；執行指令的 &lt;code>Runner&lt;/code> 與時鐘；&lt;code>Checker&lt;/code> 對入口與接聽中的連接埠、log、會累積的資料、檔案系統、自動更新、暫停與恢復步驟的檢查；路徑的展開與大小的量測；時間字串的解析與組輸出用的小函式；結果檔的讀寫與跟前一次的差異；命令列與 exit code；最後是這一版的已知限制與重現方式，依清單的驗證、指令的執行與 exit code、各項檢查、結果檔分組。&lt;/p>
&lt;p>各節的 &lt;code>python&lt;/code> 程式碼區塊照順序接起來就是完整的腳本。區塊內的空行照原檔保留；兩個區塊之間夾著說明的位置，原檔是空行，接的時候補回去：下一個區塊以縮排開頭（&lt;code>Checker&lt;/code> 類別裡的方法）時補一個空行，其餘補兩個空行。區塊之間補幾個空行只影響跟原檔逐字比對的結果，不影響執行。照這條規則接出來的檔案是 1442 行，sha256 是 &lt;code>22228d77ee7bc0d126401d04fc3a73b37e0a5768566abefa2b2b9dce69a0761d&lt;/code>；這是本篇版本（拿掉 &lt;code># i18n-exempt&lt;/code> 標記、換掉兩處票號之後）的值，不是私有專案裡原檔的值。跟原檔相比改了兩處：行尾給私有專案的翻譯檢查工具用的 &lt;code># i18n-exempt&lt;/code> 標記拿掉了；檔頭說明裡兩處專案內部的工作票號，換成不帶票號的寫法。腳本只用 Python 標準函式庫，需要 Python 3.11 以上（解析 &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>tomllib&lt;/code> 在 3.11 加入）。單元測試的檔案沒有收進這篇，說明裡提到的測試以測試函式的名字指稱，並寫出它斷言的行為；每一個 &lt;code>##&lt;/code> 節第一次提到時寫成「單元測試 &lt;code>test_…&lt;/code>（本篇沒有收錄）」。74 項單元測試在 Python 3.12 與 3.15 都通過；以 root 執行時，&lt;code>test_glob_in_unlistable_dir_is_unreadable&lt;/code>、&lt;code>test_unreadable_glob_with_missing_ok_does_not_pass&lt;/code>、&lt;code>test_literal_path_under_unreadable_parent_is_unreadable&lt;/code> 這三項會失敗：它們靠目錄權限擋住讀取，而 root 不受目錄權限限制，測試裡的前提檢查寫明以 root 執行時這幾項失敗、不跳過（讀碼）。&lt;/p>
&lt;p>文中的行為標兩種來源。「實跑」是實際執行過的結果：沒有另外註明環境的，是在一台 Apple Silicon 的 macOS 26.5 開發機上，以 Python 3.15.0 執行這一版的腳本，把它載入成模組、直接呼叫函式或方法，或從命令列跑整支腳本（Python 的版本差異另外用 uv 起 3.10.18、3.11.13、3.12.11）；在別的環境跑的，就地寫出環境。「讀碼」是從程式碼推得、沒有執行過的行為。&lt;/p>
&lt;p>清單的格式與欄位、白名單要擋下哪些寫法與理由、輸出與 exit code 的讀法、結果檔，以及 systemd 沙箱各屬性實跑的效果，寫在 &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>，這篇不重寫那些理由，各節連到對應的段落。把這支腳本與每台的清單部署到多台主機、以 sudo 與 &lt;code>systemd-run&lt;/code> 執行它的步驟與輸出，在 &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>。&lt;/p>
&lt;p>接起來的腳本要一份清單才跑得起來：它預設讀腳本所在目錄的 &lt;code>host.toml&lt;/code>，沒有清單或清單格式錯時以 exit 2 結束、一項檢查都不執行。最小的清單從 &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>〈變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機〉的 &lt;code>host.toml.j2&lt;/code> 取得：把裡面的 &lt;code>{{ }}&lt;/code> 換成這台主機實際的值，就是一份這支腳本載入得過的清單；哪些段落必填、哪些可以不寫，在本篇〈清單的載入與驗證：Probe、_Validator、load_manifest 與 validate〉。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-26，把作者私有專案裡的檢查腳本 <code>host-check.py</code> 在這一天的版本（commit <code>3fc41c0</code>）整理成這篇。這一版的單元測試在一台 Apple Silicon 的 macOS 開發機上，用 uv 分別起 Python 3.12 與 Python 3.15 各執行一次；這一版在 Oracle Linux 10 的容器裡執行過；一台 Oracle Linux 10.2 的雲端主機上執行過的，是修正「權限擋下的目錄被判成路徑不存在」之前的版本。</p></blockquote>
<p>這篇是主機維護清單的只讀檢查腳本 <code>host-check.py</code> 的完整原始碼，依腳本自己的結構分節。每一個 <code>##</code> 節先寫這一段程式負責什麼、它滿足的是清單或執行環境的哪一項需求，接著是這一段的原始碼，之後說明從程式碼不容易看出來的地方：為什麼這樣判斷、換成另一種寫法會讓哪一種寫入或誤判通過、哪一項單元測試鎖住這個行為。各節依序是：檔頭的說明與共用常數；照 <code>getopt_long</code> 語法切分選項的函式；<code>systemctl</code>、<code>journalctl</code>、<code>firewall-cmd</code>、<code>ss</code>、<code>find</code>、<code>sqlite3</code>、<code>openssl</code>、<code>sshd</code>、<code>needs-restarting</code>、<code>dnf</code>、<code>rpm</code>、<code>ausearch</code> 各自的選項規則，以及不另外比對選項的工具；判定一條指令是否只讀的 <code>readonly_violation</code> 與腳本自己組出來的指令；清單的載入與驗證；執行指令的 <code>Runner</code> 與時鐘；<code>Checker</code> 對入口與接聽中的連接埠、log、會累積的資料、檔案系統、自動更新、暫停與恢復步驟的檢查；路徑的展開與大小的量測；時間字串的解析與組輸出用的小函式；結果檔的讀寫與跟前一次的差異；命令列與 exit code；最後是這一版的已知限制與重現方式，依清單的驗證、指令的執行與 exit code、各項檢查、結果檔分組。</p>
<p>各節的 <code>python</code> 程式碼區塊照順序接起來就是完整的腳本。區塊內的空行照原檔保留；兩個區塊之間夾著說明的位置，原檔是空行，接的時候補回去：下一個區塊以縮排開頭（<code>Checker</code> 類別裡的方法）時補一個空行，其餘補兩個空行。區塊之間補幾個空行只影響跟原檔逐字比對的結果，不影響執行。照這條規則接出來的檔案是 1442 行，sha256 是 <code>22228d77ee7bc0d126401d04fc3a73b37e0a5768566abefa2b2b9dce69a0761d</code>；這是本篇版本（拿掉 <code># i18n-exempt</code> 標記、換掉兩處票號之後）的值，不是私有專案裡原檔的值。跟原檔相比改了兩處：行尾給私有專案的翻譯檢查工具用的 <code># i18n-exempt</code> 標記拿掉了；檔頭說明裡兩處專案內部的工作票號，換成不帶票號的寫法。腳本只用 Python 標準函式庫，需要 Python 3.11 以上（解析 <a href="/blog/devops/infra/knowledge-cards/toml/" data-link-title="TOML" data-link-desc="讀到 .toml 設定檔、要判斷一個值會被解析成什麼型別、要在 JSON、YAML、TOML 之間選設定檔格式，或解析器版本對不上而報錯時">TOML</a> 的 <code>tomllib</code> 在 3.11 加入）。單元測試的檔案沒有收進這篇，說明裡提到的測試以測試函式的名字指稱，並寫出它斷言的行為；每一個 <code>##</code> 節第一次提到時寫成「單元測試 <code>test_…</code>（本篇沒有收錄）」。74 項單元測試在 Python 3.12 與 3.15 都通過；以 root 執行時，<code>test_glob_in_unlistable_dir_is_unreadable</code>、<code>test_unreadable_glob_with_missing_ok_does_not_pass</code>、<code>test_literal_path_under_unreadable_parent_is_unreadable</code> 這三項會失敗：它們靠目錄權限擋住讀取，而 root 不受目錄權限限制，測試裡的前提檢查寫明以 root 執行時這幾項失敗、不跳過（讀碼）。</p>
<p>文中的行為標兩種來源。「實跑」是實際執行過的結果：沒有另外註明環境的，是在一台 Apple Silicon 的 macOS 26.5 開發機上，以 Python 3.15.0 執行這一版的腳本，把它載入成模組、直接呼叫函式或方法，或從命令列跑整支腳本（Python 的版本差異另外用 uv 起 3.10.18、3.11.13、3.12.11）；在別的環境跑的，就地寫出環境。「讀碼」是從程式碼推得、沒有執行過的行為。</p>
<p>清單的格式與欄位、白名單要擋下哪些寫法與理由、輸出與 exit code 的讀法、結果檔，以及 systemd 沙箱各屬性實跑的效果，寫在 <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>，這篇不重寫那些理由，各節連到對應的段落。把這支腳本與每台的清單部署到多台主機、以 sudo 與 <code>systemd-run</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>。</p>
<p>接起來的腳本要一份清單才跑得起來：它預設讀腳本所在目錄的 <code>host.toml</code>，沒有清單或清單格式錯時以 exit 2 結束、一項檢查都不執行。最小的清單從 <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>〈變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機〉的 <code>host.toml.j2</code> 取得：把裡面的 <code>{{ }}</code> 換成這台主機實際的值，就是一份這支腳本載入得過的清單；哪些段落必填、哪些可以不寫，在本篇〈清單的載入與驗證：Probe、_Validator、load_manifest 與 validate〉。</p>
<h2 id="檔頭的說明匯入的模組與共用常數">檔頭的說明、匯入的模組與共用常數</h2>
<p>檔頭的 docstring 是寫給維護的人讀的使用說明：用法、輸出的格式、結果檔、systemd 沙箱要加的屬性、exit code 與只讀的保證；這些行為的理由與實跑結果在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。docstring 之後是匯入的模組與整支腳本共用的常數。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s2">&#34;&#34;&#34;web-demo 主機的只讀維護檢查。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">在主機上讀同目錄的 host.toml，逐項執行 where = &#34;host&#34; 的只讀檢查，主機外
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">（OCI Console、Cloudflare dashboard、開發機）的項目印成 MANUAL。
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">用法（在主機上，以 root 執行；firewalld 查詢、sshd -T、collector 的資料庫、nginx 的 log 目錄、
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">audit.log 與 journal 一般帳號讀不到，專用帳號的實測見 README〈以專用帳號執行〉）：
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s2">    sudo python3 host-check.py                       # 例行維護：入口、log、累積的資料、更新
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s2">    sudo python3 host-check.py --phase suspend       # 暫停上線前要做的步驟
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s2">    sudo python3 host-check.py --phase resume        # 恢復上線後要做的步驟
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s2">    sudo python3 host-check.py --manifest other.toml --set repo_dir=/opt/monitor
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">    sudo python3 host-check.py --state-file PATH     # 結果檔換路徑（預設 /var/lib/host-check/last.json）
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s2">    sudo python3 host-check.py --no-state            # 不讀也不寫結果檔
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s2">輸出：每項一行 `PASS` / `WARN` / `MANUAL`、項目名與理由；接著是與前一次的差（`since:` 開頭，
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s2">其後每個差一行：`CHANGED` 狀態改變、`DELTA` 值改變、`NEW` 新出現、`GONE` 消失）；結尾一行
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s2">`summary: N PASS, M WARN, K MANUAL`；沒有印出 summary 代表腳本中途中止。
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s2">結果檔：每次執行結束把這次的計數與量到的值（探針值、累積的 bytes、可用 bytes）寫進結果檔
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s2">（JSON，每個 phase 一段，只替換本次的 phase），下一次拿它算差。目錄不存在時以 0700 建立，檔案
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s2">0600，用同目錄暫存檔加 os.replace 寫入。目錄已存在時不改它的權限：由 systemd 的 StateDirectory=
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s2">建立的目錄，權限照 StateDirectoryMode=（預設 0755，每次啟動都會把既有目錄改回這個值），沙箱裡
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s2">執行時要設 StateDirectoryMode=0700。讀不到、壞掉或寫不進去只在 stderr 印 `note:`，不改變
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="s2">exit code；清單本身只描述要檢查什麼，不存放結果。
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="s2">systemd 沙箱（在 Oracle Linux 10 容器裡實測）：ProtectSystem=strict 下要加 ReadWritePaths=/var/log /var/cache/dnf
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="s2">（dnf needs-restarting 要寫 dnf.log 與 metadata lock），StateDirectory=host-check 加 StateDirectoryMode=0700；
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="s2">NoNewPrivileges、PrivateTmp、IPAddressDeny=any 不影響檢查；不開 PrivateNetwork（ss 看不到主機的 socket，
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="s2">入口與 listen-audit 的檢查都會失準）。完整設定見 README〈在 systemd 沙箱裡執行〉。
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="s2">exit code：
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="s2">    0  全部 PASS，或只有 PASS 與 MANUAL
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="s2">    1  至少一項 WARN
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="s2">    2  清單格式錯（缺必填欄位、型別錯、要執行的指令不在只讀白名單裡），一項都沒有執行
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="s2">只讀的保證：
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="s2">    - 指令不經過 shell（shlex 切詞後直接 exec），沒有管線、轉向與命令替換。
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="s2">    - 要執行的每一條指令在執行任何一條之前先比對白名單（READONLY_RULES）；
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="s2">      不在白名單裡、或帶了會改變狀態的參數（find -delete、
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="s2">      firewall-cmd --add-*、sqlite3 沒有同時帶 -init /dev/null、-readonly 與 -safe 這類）時，整份清單判成格式錯。
</span></span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="s2">    - systemctl、dnf、rpm、journalctl、ss、find 的選項是列允許：依各工具自己的選項語法（getopt_long，
</span></span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="s2">      吃值的選項會吃掉下一個字，值可黏在選項後）切分，表外的選項一律拒絕，所以工具日後新增的
</span></span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="s2">      寫入選項預設就是拒絕（例：rpm --pipe/--eval/-D、systemctl -H/-M、dnf -x、journalctl --cursor-file）。
</span></span></span><span class="line"><span class="ln">46</span><span class="cl"><span class="s2">    - sshd 只接受單獨的 -T；指令的路徑只接受裸名或 /usr/bin、/usr/sbin、/bin、/sbin 下的檔案。
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="s2">    - command 一律會被執行（且必須通過白名單）；manual_command、purge_command 是要人執行的動作，
</span></span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="s2">      一律只印出來。只印的位置（[[log]]、[[suspend]] / [[resume]]、主機外的 [[entrypoint.verify]]）
</span></span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="s2">      寫舊名 command 判成格式錯。
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="s2">    - 寫結果檔之外不改動任何受檢狀態；結果檔是腳本自己唯一會寫的檔案。例外：collector 停止後
</span></span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="s2">      資料庫的 -shm、-wal 已被收掉，此時 sqlite3 -readonly 會自己建出這兩個檔（擁有者同資料庫）；
</span></span></span><span class="line"><span class="ln">52</span><span class="cl"><span class="s2">      在唯讀檔案系統（ProtectSystem=strict）上建不出來，sqlite3 exit 14，該項印成 WARN。
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="s2">    - 檔案大小與時間用 Python 的 os.lstat 量，不執行 du。
</span></span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">55</span><span class="cl"><span class="s2">只用標準函式庫，需要 Python 3.11 以上（tomllib）。
</span></span></span><span class="line"><span class="ln">56</span><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">
</span></span><span class="line"><span class="ln">58</span><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">
</span></span><span class="line"><span class="ln">60</span><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl"><span class="kn">import</span> <span class="nn">configparser</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl"><span class="kn">import</span> <span class="nn">datetime</span> <span class="k">as</span> <span class="nn">dt</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl"><span class="kn">import</span> <span class="nn">glob</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="ln">67</span><span class="cl"><span class="kn">import</span> <span class="nn">shlex</span>
</span></span><span class="line"><span class="ln">68</span><span class="cl"><span class="kn">import</span> <span class="nn">stat</span>
</span></span><span class="line"><span class="ln">69</span><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="ln">70</span><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="ln">71</span><span class="cl"><span class="kn">import</span> <span class="nn">tempfile</span>
</span></span><span class="line"><span class="ln">72</span><span class="cl"><span class="kn">import</span> <span class="nn">time</span>
</span></span><span class="line"><span class="ln">73</span><span class="cl"><span class="kn">import</span> <span class="nn">tomllib</span>
</span></span><span class="line"><span class="ln">74</span><span class="cl"><span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">dataclass</span><span class="p">,</span> <span class="n">field</span>
</span></span><span class="line"><span class="ln">75</span><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Any</span><span class="p">,</span> <span class="n">Callable</span>
</span></span><span class="line"><span class="ln">76</span><span class="cl">
</span></span><span class="line"><span class="ln">77</span><span class="cl"><span class="n">WHERE_VALUES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">,</span> <span class="s2">&#34;external&#34;</span><span class="p">,</span> <span class="s2">&#34;console&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">78</span><span class="cl"><span class="n">EXPOSURE_VALUES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;public&#34;</span><span class="p">,</span> <span class="s2">&#34;cdn-only&#34;</span><span class="p">,</span> <span class="s2">&#34;admin-only&#34;</span><span class="p">,</span> <span class="s2">&#34;loopback&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">79</span><span class="cl"><span class="n">PROTOCOL_VALUES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;tcp&#34;</span><span class="p">,</span> <span class="s2">&#34;udp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">80</span><span class="cl"><span class="n">BILLED_VALUES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;yes&#34;</span><span class="p">,</span> <span class="s2">&#34;no&#34;</span><span class="p">,</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span> <span class="s2">&#34;n/a&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">81</span><span class="cl"><span class="n">MEASURE_VALUES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;lines&#34;</span><span class="p">,</span> <span class="s2">&#34;value&#34;</span><span class="p">,</span> <span class="s2">&#34;age_days&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">82</span><span class="cl"><span class="n">PHASES</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;maintain&#34;</span><span class="p">,</span> <span class="s2">&#34;suspend&#34;</span><span class="p">,</span> <span class="s2">&#34;resume&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">83</span><span class="cl"><span class="n">COMMAND_TIMEOUT_S</span> <span class="o">=</span> <span class="mi">30</span>
</span></span><span class="line"><span class="ln">84</span><span class="cl"><span class="n">DEFAULT_STATE_FILE</span> <span class="o">=</span> <span class="s2">&#34;/var/lib/host-check/last.json&#34;</span>
</span></span><span class="line"><span class="ln">85</span><span class="cl"><span class="n">STATE_FORMAT</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">86</span><span class="cl">
</span></span><span class="line"><span class="ln">87</span><span class="cl"><span class="n">VAR_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}&#34;</span><span class="p">)</span></span></span></code></pre></div><p>docstring 裡的 README〈以專用帳號執行〉與 README〈在 systemd 沙箱裡執行〉是私有專案的 README，沒有公開；改用專用帳號執行時哪些檢查讀得到、哪些讀不到，以及沙箱每一個屬性的實跑效果，寫在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。docstring 裡的 <code>web-demo</code> 是示範清單所描述的那台主機上跑的服務，collector 是那台主機上寫 SQLite 資料庫的程式。</p>
<p>docstring 裡有幾句跟程式實際的行為不一致，程式碼照原檔保留，實際行為以後面的節為準：</p>
<ul>
<li><strong>「systemctl、dnf、rpm、journalctl、ss、find 的選項是列允許：依各工具自己的選項語法（getopt_long，……）切分」</strong>：「列允許」成立，<code>find</code> 也是列允許；而 <code>find</code> 不照 <code>getopt_long</code> 語法切分，它的規則自己逐個參數比對，寫在〈find 的運算式〉。照 <code>getopt_long</code> 語法切分的是 <code>systemctl</code>、<code>journalctl</code>、<code>ss</code>、<code>needs-restarting</code>、<code>dnf</code>、<code>rpm</code> 這六支，寫在〈選項的切分：OptionSpec 與 getopt_long 語法〉。</li>
<li><strong>exit code 2 的「清單格式錯（……），一項都沒有執行」</strong>：清單格式錯時成立。exit 2 另有一個出口：清單通過載入時的比對、執行時 <code>Runner</code> 的白名單又拒絕了一條指令，這時在它之前的指令已經執行過，寫在〈命令列與 exit code：main〉。</li>
<li><strong>exit code 1 的「至少一項 WARN」</strong>：檢查中途丟出沒有接住的例外時，Python 也以 exit 1 結束，這時 stdout 沒有 <code>summary:</code> 那一行，寫在〈host-check.py（3fc41c0）的已知限制與重現方式〉的〈指令的執行與 exit code：沒有接住的例外〉。</li>
<li><strong>結果檔的「目錄不存在時以 0700 建立」</strong>：0700 只套在 <code>os.makedirs</code> 這次新建的最後一層目錄，中間各層用預設的權限，寫在〈結果檔的讀寫與跟前一次的差異〉。</li>
<li><strong>只讀保證的「檔案大小與時間用 Python 的 os.lstat 量」</strong>：會累積的資料用 <code>os.lstat</code>；<code>check_logs</code> 確認檔案型的 log 時用 <code>os.stat</code>（會跟隨符號連結），檔案系統的剩餘空間用 <code>os.statvfs</code>，寫在〈定期讀的 log〉與〈檔案系統的剩餘空間〉。</li>
</ul>
<p><code>WHERE_VALUES</code> 到 <code>PHASES</code> 是清單欄位允許的值，下面的 <code>validate</code> 拿它們比對每個欄位，<code>PHASES</code> 同時是命令列 <code>--phase</code> 的選項。這幾個常數只是允許值的清單：加一個值之後，<code>validate</code> 就放行它，而用到那個值的分支沒有跟著改時，新的值在執行時出錯或被當成別的值處理。每個常數要一起改的位置：</p>
<ul>
<li><strong><code>WHERE_VALUES</code></strong>：<code>_manual_text</code> 用 <code>{&quot;console&quot;: &quot;主控台&quot;, &quot;external&quot;: &quot;主機外&quot;}</code> 查表，新的值在印 <code>MANUAL</code> 時丟出 <code>KeyError</code>（實跑：<code>_manual_text(&quot;bastion&quot;, …)</code> 丟出 <code>KeyError: 'bastion'</code>）；<code>validate</code> 與 <code>Checker</code> 都只分 <code>where == &quot;host&quot;</code> 與其餘，其餘的值一律走主機外的路徑（讀碼）。</li>
<li><strong><code>MEASURE_VALUES</code></strong>：<code>Checker.measure</code> 只分 <code>lines</code> 與 <code>value</code>，其餘一律當成時間解析；新的量法沒有在 <code>measure</code> 加分支時，被當成 <code>age_days</code> 算出天數（實跑：<code>measure(&quot;bytes&quot;, &quot;x=2026-10-26T00:00:00Z&quot;)</code> 回傳了一個天數）。</li>
<li><strong><code>PHASES</code></strong>：<code>Checker.run</code> 把 <code>maintain</code> 以外的值都交給 <code>check_phase</code>、讀 <code>self.m[phase]</code>，而 <code>validate</code> 只為 <code>for phase in (&quot;suspend&quot;, &quot;resume&quot;)</code> 這兩個建段落；新的階段要同時改這個迴圈，否則執行時丟出 <code>KeyError</code>（實跑：<code>Checker({}).run(&quot;drain&quot;)</code> 丟出 <code>KeyError: 'drain'</code>）。</li>
<li><strong><code>PROTOCOL_VALUES</code></strong>（讀碼）：<code>LISTEN_COMMAND</code> 只帶 <code>-t -u</code>，<code>listening_sockets</code> 只收這兩種協定，<code>audit_listening</code> 的提示用協定的第一個字母組 <code>ss -lnp{p[0]}</code>；新的協定要同時改這三處，以及 <code>ss</code> 規則允許的選項。</li>
<li><strong><code>EXPOSURE_VALUES</code>、<code>BILLED_VALUES</code></strong>（讀碼）：只在 <code>validate</code> 比對、在理由裡原樣印出，加值不必改其他位置。<code>COMMAND_TIMEOUT_S</code> 是 <code>Runner</code> 等一條指令結束的上限，超過時那一項記成 exit 124 並印成 <code>WARN</code>：一條卡住的指令（等網路、等鎖）只讓那一項失敗，其餘檢查照常跑完。<code>DEFAULT_STATE_FILE</code> 與 <code>STATE_FORMAT</code> 是結果檔的預設路徑與格式版本。<code>VAR_RE</code> 比對清單字串裡的 <code>{{名稱}}</code>，名稱只收 ASCII 的英文字母、數字與底線，第一個字元不能是數字；Python 的識別字另外收非 ASCII 的字母，<code>{{主機}}</code> 這種名稱在這裡對不上，原樣留在字串裡。</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">class</span> <span class="nc">ManifestError</span><span class="p">(</span><span class="ne">Exception</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s2">&#34;&#34;&#34;清單格式錯；errors 是逐條的原因。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">errors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">errors</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">errors</span> <span class="o">=</span> <span class="n">errors</span></span></span></code></pre></div><p><code>ManifestError</code> 帶著逐條的錯誤原因。驗證清單的程式把整份清單的錯誤都收齊之後才丟一次這個例外，命令列再逐行印出 <code>manifest error:</code>，所以改清單的人一次看到全部的錯誤，不必改一條、跑一次。用 Ansible 部署時，<code>template</code> 任務的 <code>validate</code> 在受管主機上載入這支腳本、呼叫 <code>load_manifest</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>〈部署清單：建目錄、copy 與 template、validate 改用檢查腳本的載入函式、&ndash;check 的預覽〉。</p>
<h2 id="選項的切分optionspec-與-getopt_long-語法">選項的切分：OptionSpec 與 getopt_long 語法</h2>
<p>白名單要比對一條指令的選項，得先分出一串參數裡哪些是選項、哪些是選項的值、哪些是位置參數。這一段照 <code>getopt_long</code>（GNU C 函式庫解析命令列選項的函式，多數 Linux 工具照它的規則解析參數）的語法切分，<code>systemctl</code>、<code>journalctl</code>、<code>ss</code>、<code>needs-restarting</code>、<code>dnf</code>、<code>rpm</code> 的規則共用它。這六支裡，<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；popt 與 argparse 在短選項合併、值黏在選項後面、<code>--名稱=值</code> 與 <code>--</code> 這幾點，切法跟 <code>getopt_long</code> 相同，所以共用同一套切分；為什麼要照工具自己的語法切、沒照語法切時放行了哪些寫法，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ── 只讀白名單 ──────────────────────────────────────────────</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 每條規則收 argv（argv[0] 已是 basename），回傳 None 代表放行，字串代表拒絕的理由。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 一支工具允許的選項（列允許，表外一律拒絕）；選項名連同開頭的 - 一起寫。</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nd">@dataclass</span><span class="p">(</span><span class="n">frozen</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">class</span> <span class="nc">OptionSpec</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">flags</span><span class="p">:</span> <span class="nb">frozenset</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">()</span>  <span class="c1"># 不吃值的旗標</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">values</span><span class="p">:</span> <span class="nb">frozenset</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">()</span>  <span class="c1"># 一定吃值的選項</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">optional</span><span class="p">:</span> <span class="nb">frozenset</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">()</span>  <span class="c1"># 選填值：只吃黏在一起的值，或下一個字是整數時</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">def</span> <span class="nf">_spec</span><span class="p">(</span><span class="n">flags</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">values</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">optional</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">OptionSpec</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="n">OptionSpec</span><span class="p">(</span><span class="nb">frozenset</span><span class="p">(</span><span class="n">flags</span><span class="o">.</span><span class="n">split</span><span class="p">()),</span> <span class="nb">frozenset</span><span class="p">(</span><span class="n">values</span><span class="o">.</span><span class="n">split</span><span class="p">()),</span> <span class="nb">frozenset</span><span class="p">(</span><span class="n">optional</span><span class="o">.</span><span class="n">split</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">def</span> <span class="nf">_parse_options</span><span class="p">(</span><span class="n">args</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">spec</span><span class="p">:</span> <span class="n">OptionSpec</span><span class="p">,</span> <span class="n">stop_at_first</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">False</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="c1"># 依 getopt_long 的語法把 args 切成（位置參數, 出現過的選項, 拒絕理由）：</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="c1"># 短選項可合併（-qa），吃值的短選項值黏在後面或在下一個字；長選項 --name[=value]；-- 之後全是位置參數。</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="c1"># stop_at_first 時，第一個位置參數起的全部字都放進位置參數（不再解析選項）。</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">positionals</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="n">seen</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="n">tok</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">if</span> <span class="n">tok</span> <span class="o">==</span> <span class="s2">&#34;--&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="n">positionals</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">:])</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">tok</span> <span class="o">==</span> <span class="s2">&#34;-&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="n">positionals</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">tok</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="k">if</span> <span class="n">stop_at_first</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">                <span class="n">positionals</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">:])</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="k">break</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">if</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;--&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="n">name</span><span class="p">,</span> <span class="n">eq</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">tok</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;=&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="n">consumed</span> <span class="o">=</span> <span class="n">_long_option</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="nb">bool</span><span class="p">(</span><span class="n">eq</span><span class="p">),</span> <span class="n">spec</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">consumed</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">                <span class="k">return</span> <span class="n">positionals</span><span class="p">,</span> <span class="n">seen</span><span class="p">,</span> <span class="n">consumed</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">            <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">            <span class="n">i</span> <span class="o">+=</span> <span class="n">consumed</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">        <span class="k">for</span> <span class="n">pos</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">tok</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">            <span class="n">name</span> <span class="o">=</span> <span class="s2">&#34;-&#34;</span> <span class="o">+</span> <span class="n">tok</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">            <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">spec</span><span class="o">.</span><span class="n">flags</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">                <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">            <span class="n">consumed</span> <span class="o">=</span> <span class="n">_value_option</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">pos</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">tok</span><span class="p">),</span> <span class="n">spec</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">            <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">consumed</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">                <span class="k">return</span> <span class="n">positionals</span><span class="p">,</span> <span class="n">seen</span><span class="p">,</span> <span class="n">consumed</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">            <span class="n">i</span> <span class="o">+=</span> <span class="n">consumed</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">    <span class="k">return</span> <span class="n">positionals</span><span class="p">,</span> <span class="n">seen</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">
</span></span><span class="line"><span class="ln">57</span><span class="cl">
</span></span><span class="line"><span class="ln">58</span><span class="cl"><span class="k">def</span> <span class="nf">_long_option</span><span class="p">(</span><span class="n">name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">has_value</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span> <span class="n">spec</span><span class="p">:</span> <span class="n">OptionSpec</span><span class="p">,</span> <span class="n">args</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">i</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span> <span class="o">|</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">    <span class="c1"># 回傳這個長選項多吃掉幾個後續字；拒絕時回傳理由。</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">    <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">spec</span><span class="o">.</span><span class="n">flags</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;選項 </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2"> 不吃值&#34;</span> <span class="k">if</span> <span class="n">has_value</span> <span class="k">else</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl">    <span class="k">return</span> <span class="n">_value_option</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">has_value</span><span class="p">,</span> <span class="n">spec</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl">
</span></span><span class="line"><span class="ln">64</span><span class="cl">
</span></span><span class="line"><span class="ln">65</span><span class="cl"><span class="k">def</span> <span class="nf">_value_option</span><span class="p">(</span><span class="n">name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">has_glued_value</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span> <span class="n">spec</span><span class="p">:</span> <span class="n">OptionSpec</span><span class="p">,</span> <span class="n">args</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">i</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span> <span class="o">|</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl">    <span class="k">if</span> <span class="n">name</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">spec</span><span class="o">.</span><span class="n">values</span> <span class="ow">and</span> <span class="n">name</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">spec</span><span class="o">.</span><span class="n">optional</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">67</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;選項 </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2"> 不在允許清單&#34;</span>
</span></span><span class="line"><span class="ln">68</span><span class="cl">    <span class="k">if</span> <span class="n">has_glued_value</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">69</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">70</span><span class="cl">    <span class="n">nxt</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">if</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">71</span><span class="cl">    <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">spec</span><span class="o">.</span><span class="n">optional</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">72</span><span class="cl">        <span class="k">return</span> <span class="mi">1</span> <span class="k">if</span> <span class="n">nxt</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">nxt</span><span class="o">.</span><span class="n">isdigit</span><span class="p">()</span> <span class="k">else</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">73</span><span class="cl">    <span class="k">return</span> <span class="mi">1</span> <span class="k">if</span> <span class="n">nxt</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;選項 </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2"> 缺少值&#34;</span></span></span></code></pre></div><p><code>OptionSpec</code> 把一支工具允許的選項分成三組，分組決定切分時這個選項是否吃掉下一個參數：<code>flags</code> 不吃值；<code>values</code> 一定吃值，值黏在選項後面（<code>-pX</code>）時吃黏著的那一段，否則吃下一個參數；<code>optional</code> 是值可帶可不帶的選項，值黏在選項後面時吃黏著的那一段，沒有黏著的值時，下一個參數全是數字才吃它，否則不吃。<code>journalctl</code> 的 <code>-n</code> 放在 <code>optional</code>，所以 <code>journalctl -n 20 -u sshd</code> 的 <code>20</code> 被當成 <code>-n</code> 的值，<code>journalctl -n -u sshd</code> 的 <code>-u</code> 照樣被當成選項比對。「全是數字」用 <code>str.isdigit()</code> 判斷：<code>-5</code>、<code>+5</code> 帶著正負號，不算數字，不會被當成值；<code>²</code>、<code>٣</code> 這類 Unicode 的數字字元，<code>isdigit()</code> 回 <code>True</code>，會被當成值（這幾個字串都在 Python 裡實跑過）。把一個吃值的選項錯放進 <code>flags</code>，它的值會被當成下一個位置參數，子命令的判定就跟工具實際執行的不同。</p>
<p>短選項的那個迴圈從第二個字元起逐字查：查到旗標就看下一個字元（<code>-qa</code> 是 <code>-q</code> 與 <code>-a</code>）；查到吃值的選項，這個字元後面還有字時（<code>-ppid</code>）把剩下的當成值，沒有時吃下一個參數，然後離開這個參數。單獨一個 <code>-</code> 算位置參數：照 <code>getopt</code> 的慣例，它是一個運算元（多半代表標準輸入），不是選項。長選項用 <code>partition(&quot;=&quot;)</code> 分出名稱與值，旗標帶了 <code>=值</code> 時拒絕（<code>--no-pager=1</code>）。名稱一律整字比對，<code>getopt_long</code> 接受的前綴縮寫（<code>--cursor-fi</code> 當成 <code>--cursor-file</code>）不在表上，所以被拒絕。表外的選項回傳拒絕理由、不往下切，三組都不在表上的選項就是被拒絕的那一類，這是列允許的寫法在程式裡的樣子。</p>
<p><code>stop_at_first</code> 給 <code>dnf</code> 用：切到第一個位置參數（子命令）就停，子命令之後的參數交給那個子命令自己的規則。單元測試 <code>test_allowed</code>（本篇沒有收錄）放行 <code>systemctl --no-pager -ppid status nginx</code>；<code>test_rejected</code> 拒絕 <code>systemctl -p show stop nginx</code>（<code>-p</code> 吃掉 <code>show</code>，子命令是 <code>stop</code>）、<code>systemctl -pshow stop nginx</code> 與 <code>systemctl --no-pager=1 status nginx</code>。</p>
<h2 id="各工具的選項規則systemctljournalctlfirewall-cmdssfindsqlite3opensslsshdneeds-restartingdnfrpmausearch">各工具的選項規則：systemctl、journalctl、firewall-cmd、ss、find、sqlite3、openssl、sshd、needs-restarting、dnf、rpm、ausearch</h2>
<p>每支有選項規則的工具一個函式，收切好的參數串列（第一個元素已經換成檔名），放行時回傳 <code>None</code>，拒絕時回傳理由的字串。每支工具允許與排除了哪些選項、排除的理由，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉的各工具清單；這一節寫程式怎麼實現那些規則，以及每一支的語法跟〈選項的切分：OptionSpec 與 getopt_long 語法〉的切分有哪裡不同。</p>
<h3 id="systemctl-的子命令與選項">systemctl 的子命令與選項</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">SYSTEMCTL_READ</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;is-active&#34;</span><span class="p">,</span> <span class="s2">&#34;is-enabled&#34;</span><span class="p">,</span> <span class="s2">&#34;is-failed&#34;</span><span class="p">,</span> <span class="s2">&#34;show&#34;</span><span class="p">,</span> <span class="s2">&#34;status&#34;</span><span class="p">,</span> <span class="s2">&#34;cat&#34;</span><span class="p">,</span> <span class="s2">&#34;list-timers&#34;</span><span class="p">,</span> <span class="s2">&#34;list-units&#34;</span><span class="p">,</span> <span class="s2">&#34;list-unit-files&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">SYSTEMCTL_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">flags</span><span class="o">=</span><span class="s2">&#34;--no-pager --no-legend --plain --value -a --all -l --full -q --quiet --failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">values</span><span class="o">=</span><span class="s2">&#34;-p --property -P -t --type --state -n --lines -o --output --timestamp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">)</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_systemctl</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="c1"># -p、-t 這類選項會吃掉下一個字，所以子命令要先依選項語法切出來，不能取第一個不以 - 開頭的字。</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1"># -H/--host 與 -M/--machine 會把操作送到別台主機或容器（-H 以 root 的 SSH 身分連出去），不在允許清單。</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">positionals</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">SYSTEMCTL_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="n">err</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;systemctl </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">positionals</span> <span class="ow">or</span> <span class="n">positionals</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">SYSTEMCTL_READ</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;systemctl 只允許 </span><span class="si">{</span><span class="nb">sorted</span><span class="p">(</span><span class="n">SYSTEMCTL_READ</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p>子命令取切分之後的第一個位置參數，查 <code>SYSTEMCTL_READ</code>。只寫 <code>systemctl</code> 而沒有子命令時，<code>positionals</code> 是空的，同樣拒絕：systemctl 沒有子命令時執行的是 <code>list-units</code>，規則要求清單把子命令寫出來，判定才不依賴工具的預設值。<code>-H</code> 與 <code>-M</code> 不在 <code>SYSTEMCTL_OPTIONS</code> 裡，所以黏在一起的 <code>-Ha@b</code>、長選項的 <code>--host=a@b</code> 與分開寫的 <code>--host a@b</code> 都在切分時被拒絕，單元測試 <code>test_rejected</code>（本篇沒有收錄）逐一列了這幾種寫法。</p>
<h3 id="journalctl-的選項與-match-條件">journalctl 的選項與 match 條件</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># --cursor-file 與 --vacuum-*、--rotate、--flush、-f 都會寫檔或改變 journal，不在允許清單。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">JOURNALCTL_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">flags</span><span class="o">=</span><span class="s2">&#34;-q --quiet --no-pager -r --reverse --utc --no-hostname -k --dmesg -a --all --disk-usage --list-boots&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">values</span><span class="o">=</span><span class="s2">&#34;-u --unit --since --until -o --output -g --grep -p --priority -t --identifier&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">optional</span><span class="o">=</span><span class="s2">&#34;-n --lines&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</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="k">def</span> <span class="nf">_rule_journalctl</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">JOURNALCTL_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;journalctl </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">err</span> <span class="k">else</span> <span class="kc">None</span></span></span></code></pre></div><p><code>journalctl</code> 的位置參數是篩選紀錄的 match 條件（<code>_SYSTEMD_UNIT=x.service</code>），只用來讀，所以規則只比對選項、不看位置參數。<code>-n</code> 放在 <code>optional</code>，<code>test_rejected</code> 的 <code>journalctl -n --vacuum-size=1G</code> 因此被拒絕：<code>-n</code> 不吃以 <code>-</code> 開頭的下一個參數，<code>--vacuum-size</code> 照樣被當成選項比對，而它不在表上。<code>-f</code>（持續讀新的紀錄、不會結束）也不在表上，它會讓指令一直跑到 <code>COMMAND_TIMEOUT_S</code> 的上限。</p>
<h3 id="firewall-cmd-的參數樣式">firewall-cmd 的參數樣式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">FIREWALL_READ</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^--(permanent|zone=\S+|state|query-\S+|list-\S+|get-\S+|info-\S+)$&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_firewall_cmd</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">FIREWALL_READ</span><span class="o">.</span><span class="k">match</span><span class="p">(</span><span class="n">a</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;firewall-cmd 只允許 --query-*、--list-*、--get-*、--info-*、--state、--permanent、--zone=，不允許 </span><span class="si">{</span><span class="n">a</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>firewall-cmd</code> 的查詢都寫成一個 <code>--名稱</code> 或 <code>--名稱=值</code> 的參數，所以規則逐個參數比對樣式，不切分。每個參數都要對上樣式，位置參數因此一個都不允許：分開寫的 <code>--zone public</code> 裡，<code>public</code> 對不上任何樣式而被拒絕，值要用 <code>=</code> 黏在選項上。改動設定的選項（<code>--add-port=</code>、<code>--remove-*</code>、<code>--reload</code>）對不上 <code>query-</code>、<code>list-</code>、<code>get-</code>、<code>info-</code> 這幾個前綴，<code>test_rejected</code> 列了 <code>firewall-cmd --add-port=8765/tcp</code> 與 <code>firewall-cmd --reload</code>。</p>
<h3 id="ss-的選項與過濾式">ss 的選項與過濾式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># -K（kill）、-D（寫檔）、-N（換 netns）、-F（讀過濾檔）、-r（反查 DNS，對外連線）、-E 都不在允許清單。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 位置參數是過濾式（state listening、sport = :443 這類），ss 只讀它。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">SS_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">flags</span><span class="o">=</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s2">&#34;-H --no-header -l --listening -n --numeric -t --tcp -u --udp -a --all -p --processes -4 --ipv4 -6 --ipv6 &#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s2">&#34;-x --unix -w --raw -e --extended -i --info -m --memory -s --summary -o --options&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">values</span><span class="o">=</span><span class="s2">&#34;-f --family -A --query&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_ss</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">SS_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;ss </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">err</span> <span class="k">else</span> <span class="kc">None</span></span></span></code></pre></div><p><code>ss</code> 的位置參數是過濾式，跟 <code>journalctl</code> 一樣只比對選項。合併的短選項逐字查，所以 <code>ss -tlnK</code> 在查到 <code>K</code> 時被拒絕，<code>-K</code> 不必單獨出現；<code>-f</code> 一定吃值，單獨的 <code>ss -f</code> 以「缺少值」拒絕。<code>-E</code>（持續印出 socket 的事件、不會結束）不在表上，理由跟 <code>journalctl -f</code> 相同。</p>
<h3 id="find-的運算式">find 的運算式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># find 不是 getopt 語法，所以自己走一遍：選項只認下面兩張表（-delete、-exec*、-ok*、-fprint*、-fls、</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># -D、-files0-from 都不在裡面）；值只吃下一個字；不以 - 開頭的字是路徑或運算子的括號、驚嘆號。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">FIND_LEADING</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span><span class="s2">&#34;-H&#34;</span><span class="p">,</span> <span class="s2">&#34;-L&#34;</span><span class="p">,</span> <span class="s2">&#34;-P&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">FIND_OPERATORS</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span><span class="s2">&#34;(&#34;</span><span class="p">,</span> <span class="s2">&#34;)&#34;</span><span class="p">,</span> <span class="s2">&#34;!&#34;</span><span class="p">,</span> <span class="s2">&#34;,&#34;</span><span class="p">,</span> <span class="s2">&#34;-not&#34;</span><span class="p">,</span> <span class="s2">&#34;-and&#34;</span><span class="p">,</span> <span class="s2">&#34;-or&#34;</span><span class="p">,</span> <span class="s2">&#34;-a&#34;</span><span class="p">,</span> <span class="s2">&#34;-o&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">FIND_FLAGS</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;-print -print0 -prune -empty -xdev -mount -true -false -readable -writable -executable&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">FIND_VALUES</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="s2">&#34;-name -iname -path -ipath -regex -iregex -type -xtype -mtime -mmin -atime -amin -ctime -cmin &#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="s2">&#34;-newer -size -user -group -uid -gid -perm -links -inum -maxdepth -mindepth -printf&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">)</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></span><span class="line"><span class="ln">16</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_find</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="ow">and</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="ow">in</span> <span class="n">FIND_LEADING</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="n">tok</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">if</span> <span class="n">tok</span> <span class="ow">in</span> <span class="n">FIND_FLAGS</span> <span class="ow">or</span> <span class="n">tok</span> <span class="ow">in</span> <span class="n">FIND_OPERATORS</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">if</span> <span class="n">tok</span> <span class="ow">in</span> <span class="n">FIND_VALUES</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">if</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">                <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;find 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 缺少值&#34;</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="k">if</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">tok</span> <span class="o">!=</span> <span class="s2">&#34;-&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;find 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 不在允許清單&#34;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>find</code> 的參數是起點路徑加一串運算式，運算式的元素是單一連字號的整字（<code>-name</code>、<code>-mtime</code>），不能照 <code>getopt_long</code> 合併或拆字，所以規則自己逐個參數比對。<code>-H</code>、<code>-L</code>、<code>-P</code> 只在最前面接受，跟 <code>find</code> 自己的語法一致；放在中間的 <code>-L</code> 對不上任何一張表而被拒絕。以 <code>-</code> 開頭而不在表上的參數一律拒絕，<code>-delete</code>、<code>-exec</code>、<code>-fprint</code> 這類動作因此不必逐一列出來擋；<code>-newerXY</code> 這種帶後綴的寫法也不在表上，<code>test_rejected</code> 列了它。<code>-printf</code> 在允許的表上，因為它把格式化的結果印到標準輸出；同類的 <code>-fprintf</code> 寫進檔案，不在表上。</p>
<h3 id="sqlite3-的必帶選項與-dot-command">sqlite3 的必帶選項與 dot-command</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># sqlite3 的長選項是單破折號整字（--x 等同 -x），不是 getopt 語法，所以自己走一遍：先把開頭的 -- 正規化成 -，再整字比對。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">SQLITE_FLAGS</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s2">&#34;-readonly -safe -bail -batch -header -noheader -csv -json -line -list -box -table -markdown -column -quote -tabs -nofollow&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">SQLITE_VALUES</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span><span class="s2">&#34;-separator -newline -nullvalue&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">SQLITE_REQUIRED</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span><span class="s2">&#34;-readonly&#34;</span><span class="p">,</span> <span class="s2">&#34;-safe&#34;</span><span class="p">,</span> <span class="s2">&#34;-init&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</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="k">def</span> <span class="nf">_rule_sqlite3</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1"># -readonly 只讓資料庫檔唯讀；SQL 函式 writefile()、edit() 與 ATTACH 仍能寫檔案系統。</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1"># -safe 擋下這些函式與會寫檔的 dot-command（OL10 的 3.46.1 實測支援）。</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="c1"># -init 檔與 ~/.sqliterc 在 safe mode 生效前就執行（.shell 照跑，實測會建檔），HOME 擋不住</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># （sqlite3 用 getpwuid 找家目錄）；唯一的擋法是 -init /dev/null 取代 ~/.sqliterc。三個都要帶。</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">seen</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">positionals</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">tok</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="n">positionals</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">tok</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="n">name</span> <span class="o">=</span> <span class="n">tok</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span> <span class="k">if</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;--&#34;</span><span class="p">)</span> <span class="k">else</span> <span class="n">tok</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">SQLITE_FLAGS</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">elif</span> <span class="n">name</span> <span class="o">==</span> <span class="s2">&#34;-init&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="k">if</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="ow">or</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">!=</span> <span class="s2">&#34;/dev/null&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">                <span class="k">return</span> <span class="s2">&#34;sqlite3 的 -init 的值必須是 /dev/null&#34;</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">            <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="k">elif</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">SQLITE_VALUES</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">if</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;sqlite3 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 缺少值&#34;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;sqlite3 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 不在允許清單&#34;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">SQLITE_REQUIRED</span> <span class="o">&lt;=</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;sqlite3 要同時帶 -init /dev/null、-readonly 與 -safe（~/.sqliterc 與 writefile() 都會寫檔）&#34;</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">for</span> <span class="n">sql</span> <span class="ow">in</span> <span class="n">positionals</span><span class="p">[</span><span class="mi">1</span><span class="p">:]:</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">        <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">line</span><span class="o">.</span><span class="n">lstrip</span><span class="p">()</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;.&#34;</span><span class="p">)</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">sql</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()):</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">            <span class="k">return</span> <span class="s2">&#34;sqlite3 的 dot-command（.backup、.output、.shell 這類）不允許&#34;</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>sqlite3</code> 的選項是單一連字號的整字，雙連字號的寫法（<code>--readonly</code>）跟單一連字號的意思相同，所以規則先把開頭的 <code>--</code> 換成 <code>-</code> 再比對，<code>test_sqlite_requires_init_devnull</code> 放行 <code>sqlite3 --init /dev/null --readonly --safe -json …</code>。三個必帶的選項用集合的子集比對（<code>SQLITE_REQUIRED &lt;= seen</code>），少任何一個都拒絕；<code>-init</code> 的值只接受 <code>/dev/null</code>，<code>-init -readonly -safe</code> 這種把下一個選項當成值的寫法也被拒絕，因為 <code>-readonly</code> 不等於 <code>/dev/null</code>。為什麼三個都要帶，在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉講 <code>sqlite3</code> 執行 shell 與寫檔途徑的那一段。</p>
<p>位置參數裡，第一個是資料庫檔，之後的每一個是要執行的 SQL。規則把每段 SQL 逐行看，去掉行首空白之後以 <code>.</code> 開頭的行就是 dot-command，一律拒絕，不只靠 <code>-safe</code> 擋。在 SQL 參數之外塞 dot-command 的 <code>-cmd</code> 選項不在表上；<code>-A</code>（操作 SQLite 封存檔）、<code>-interactive</code>、<code>-echo</code>、<code>-vfs</code> 也不在表上，<code>test_sqlite_requires_init_devnull</code> 逐一列了它們。程式碼註解裡的「OL10 的 3.46.1」指 Oracle Linux 10 上的 sqlite 3.46.1。</p>
<h3 id="openssl-與-sshd-的固定寫法">openssl 與 sshd 的固定寫法</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">OPENSSL_X509_OPTS</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;-noout&#34;</span><span class="p">,</span> <span class="s2">&#34;-in&#34;</span><span class="p">,</span> <span class="s2">&#34;-checkend&#34;</span><span class="p">,</span> <span class="s2">&#34;-enddate&#34;</span><span class="p">,</span> <span class="s2">&#34;-startdate&#34;</span><span class="p">,</span> <span class="s2">&#34;-dates&#34;</span><span class="p">,</span> <span class="s2">&#34;-subject&#34;</span><span class="p">,</span> <span class="s2">&#34;-issuer&#34;</span><span class="p">,</span> <span class="s2">&#34;-serial&#34;</span><span class="p">,</span> <span class="s2">&#34;-fingerprint&#34;</span><span class="p">,</span> <span class="s2">&#34;-sha256&#34;</span><span class="p">,</span> <span class="s2">&#34;-ext&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_openssl</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">argv</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">2</span> <span class="ow">or</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">!=</span> <span class="s2">&#34;x509&#34;</span> <span class="ow">or</span> <span class="s2">&#34;-noout&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">argv</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;openssl 只允許 x509 -noout&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">argv</span><span class="p">[</span><span class="mi">2</span><span class="p">:]:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">a</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">OPENSSL_X509_OPTS</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;openssl x509 不允許 </span><span class="si">{</span><span class="n">a</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>openssl</code> 只允許 <code>x509</code> 子命令，而且一定要帶 <code>-noout</code>（不印出憑證本身的編碼內容）；其餘以 <code>-</code> 開頭的參數要在 <code>OPENSSL_X509_OPTS</code> 裡。不以 <code>-</code> 開頭的參數是選項的值（<code>-in</code> 的憑證路徑、<code>-checkend</code> 的秒數），放行。寫出檔案的 <code>-out</code> 不在表上，<code>test_rejected</code> 列了 <code>openssl x509 -noout -in a -out b</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_sshd</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1"># -E 與 -f 會寫檔或換設定，所以只接受單獨的 -T。</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span> <span class="k">if</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span> <span class="o">==</span> <span class="p">[</span><span class="s2">&#34;-T&#34;</span><span class="p">]</span> <span class="k">else</span> <span class="s2">&#34;sshd 只允許單獨的 -T（印出生效中的設定）&#34;</span></span></span></code></pre></div><p><code>sshd</code> 的規則把參數串列跟 <code>[&quot;-T&quot;]</code> 整串比對：檢查要的只有「印出生效中的設定」這一種用法，整串比對比列出允許的選項更窄，<code>-T -E &lt;檔案&gt;</code> 與 <code>-E &lt;檔案&gt; -T</code> 兩種順序都被拒絕。</p>
<h3 id="needs-restarting-與-dnf-的子命令">needs-restarting 與 dnf 的子命令</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">NEEDS_RESTARTING_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span><span class="n">flags</span><span class="o">=</span><span class="s2">&#34;-r -s --reboothint --services&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_needs_restarting</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">positionals</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">NEEDS_RESTARTING_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="n">err</span> <span class="ow">or</span> <span class="n">positionals</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;needs-restarting 只允許 -r、-s、--reboothint、--services&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</span> <span class="kc">None</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">DNF_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span><span class="n">flags</span><span class="o">=</span><span class="s2">&#34;-q --quiet -C --cacheonly&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">NO_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">()</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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_dnf</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="c1"># -x、--setopt、-c、--installroot 這類吃值的選項不在允許清單；只允許子命令前的 -q、-C。</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">rest</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">DNF_OPTIONS</span><span class="p">,</span> <span class="n">stop_at_first</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="n">err</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;dnf </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="n">rest</span><span class="p">[:</span><span class="mi">1</span><span class="p">]</span> <span class="o">==</span> <span class="p">[</span><span class="s2">&#34;needs-restarting&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="n">_rule_needs_restarting</span><span class="p">(</span><span class="n">rest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="n">rest</span><span class="p">[:</span><span class="mi">2</span><span class="p">]</span> <span class="ow">in</span> <span class="p">([</span><span class="s2">&#34;history&#34;</span><span class="p">,</span> <span class="s2">&#34;list&#34;</span><span class="p">],</span> <span class="p">[</span><span class="s2">&#34;history&#34;</span><span class="p">,</span> <span class="s2">&#34;info&#34;</span><span class="p">]):</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">rest</span><span class="p">[</span><span class="mi">2</span><span class="p">:],</span> <span class="n">NO_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;dnf </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">err</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">return</span> <span class="s2">&#34;dnf 只允許 needs-restarting 與 history list/info&#34;</span></span></span></code></pre></div><p><code>dnf</code> 的規則分兩段切：子命令之前用 <code>DNF_OPTIONS</code> 切，切到第一個位置參數就停（<code>stop_at_first</code>）；剩下的參數以子命令開頭，交給子命令的規則。<code>needs-restarting</code> 這個子命令跟單獨呼叫的 <code>needs-restarting</code> 共用同一個規則函式，<code>rest</code> 的第一個元素正好是 <code>needs-restarting</code>，跟單獨呼叫時的參數串列形狀相同。<code>history list</code> 與 <code>history info</code> 之後用沒有任何允許選項的 <code>NO_OPTIONS</code> 切：位置參數（<code>dnf history info 12</code> 的交易編號）放行，任何選項都拒絕，<code>test_rejected</code> 的 <code>dnf history list -y</code> 就是這一種。<code>dnf -x history -x list install foo</code> 在子命令之前那一段就被拒絕，<code>-x</code> 不在 <code>DNF_OPTIONS</code> 裡。</p>
<h3 id="rpm-的查詢模式">rpm 的查詢模式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># --eval、-E、-D/--define 展開巨集，%(...) 會經 shell 執行；--pipe 直接執行指令；</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># --macros、--rcfile、--dbpath、--root、-p 換設定或資料來源。都不在允許清單。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">RPM_OPTIONS</span> <span class="o">=</span> <span class="n">_spec</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">flags</span><span class="o">=</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s2">&#34;-q -a -f -i -l -c -d -s -R --query --all --file --info --list --configfiles --docfiles &#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s2">&#34;--state --requires --provides --changelog --last --whatprovides --whatrequires&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">values</span><span class="o">=</span><span class="s2">&#34;--qf --queryformat&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_rpm</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># 第一個選項必須是查詢模式（-q、-qa、-qi 這類或 --query），不讓 -i、-U 這類模式排在前面。</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">argv</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">2</span> <span class="ow">or</span> <span class="ow">not</span> <span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;-q&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;--query&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;rpm 只允許查詢（第一個選項要是 -q 開頭或 --query）&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">seen</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">_parse_options</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:],</span> <span class="n">RPM_OPTIONS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="n">err</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;rpm </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="p">({</span><span class="s2">&#34;-q&#34;</span><span class="p">,</span> <span class="s2">&#34;--query&#34;</span><span class="p">}</span> <span class="o">&amp;</span> <span class="n">seen</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;rpm 只允許查詢（要帶 -q）&#34;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>rpm</code> 的 <code>-i</code> 在查詢模式裡是「印出套件資訊」，排在最前面時是「安裝套件」，同一個字母的意思由模式決定。規則因此先要求第一個參數是查詢模式（<code>-q</code> 開頭或 <code>--query</code>），再照選項表切分：<code>RPM_OPTIONS</code> 裡的 <code>-i</code>、<code>-f</code> 只在查詢模式之後才出現得了，<code>test_rpm_first_option_is_query</code> 拒絕的 <code>rpm -i tree.rpm -q</code>、<code>rpm -iq</code>、<code>rpm -U x.rpm -q</code> 都在查第一個參數時被拒絕。切分之後再確認出現過的選項裡有 <code>-q</code> 或 <code>--query</code>：以現在的寫法，通過第一個參數檢查的輸入，切分之後一定帶著 <code>-q</code> 或 <code>--query</code>，這一道擋不下任何多出來的輸入（讀碼）；它是冗餘的防護，日後有人放寬第一個參數的比對時，查詢模式的要求不會跟著消失。<code>-qp</code>（查詢一個 <code>.rpm</code> 檔而不是已安裝的套件）的 <code>-p</code> 不在表上，<code>test_rejected</code> 列了 <code>rpm -qp /tmp/a.rpm</code>。</p>
<h3 id="ausearch-的篩選選項與時間">ausearch 的篩選選項與時間</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ausearch 是單破折號多字母選項，整字比對。--checkpoint、-if/--input 這類寫檔或換輸入來源的選項不在允許清單。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">AUSEARCH_FLAGS</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span><span class="s2">&#34;-i --interpret -r --raw --just-one&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">AUSEARCH_VALUES</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;-m --message -k --key -c --comm -p --pid -ui --uid -x --executable -sc --syscall -sv --success --format&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">AUSEARCH_TIME</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">(</span><span class="s2">&#34;-ts --start -te --end&#34;</span><span class="o">.</span><span class="n">split</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">AUSEARCH_CLOCK</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^\d{1,2}:\d</span><span class="si">{2}</span><span class="s2">(:\d</span><span class="si">{2}</span><span class="s2">)?$&#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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">def</span> <span class="nf">_rule_ausearch</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">tok</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">name</span><span class="p">,</span> <span class="n">eq</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">tok</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;=&#34;</span><span class="p">)</span> <span class="k">if</span> <span class="n">tok</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;--&#34;</span><span class="p">)</span> <span class="k">else</span> <span class="p">(</span><span class="n">tok</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">AUSEARCH_FLAGS</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">eq</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">AUSEARCH_VALUES</span> <span class="ow">or</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">AUSEARCH_TIME</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">eq</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                <span class="k">if</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">                    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;ausearch 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 缺少值&#34;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">                <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">                <span class="c1"># -ts 後面是日期時，再下一個字可能是時間（-ts 10/26/2026 10:30）</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">                <span class="k">if</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">AUSEARCH_TIME</span> <span class="ow">and</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="ow">and</span> <span class="n">AUSEARCH_CLOCK</span><span class="o">.</span><span class="k">match</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="n">i</span><span class="p">]):</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">                    <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;ausearch 的選項 </span><span class="si">{</span><span class="n">tok</span><span class="si">}</span><span class="s2"> 不在允許清單&#34;</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>ausearch</code> 的短選項有多個字母（<code>-ui</code>、<code>-sc</code>），不能照 <code>getopt_long</code> 拆字，所以整字比對；只有雙連字號的長選項接受 <code>=值</code>，單一連字號的 <code>-i=1</code> 整串對不上表而被拒絕。<code>-ts</code>、<code>-te</code> 的值可以是一個詞（<code>today</code>、<code>recent</code>），也可以是日期加時間兩個參數，所以吃掉值之後，下一個參數長得像時刻（<code>10:30</code> 或 <code>10:30:00</code>）時再吃一個。<code>test_ausearch_allow_list</code> 放行 <code>ausearch -ts 10/26/2026 10:30:00 -te 11:00</code>，拒絕 <code>-m</code> 沒有值、<code>--checkpoint</code>、<code>--input</code> 與 <code>-if</code>。</p>
<h3 id="不另外比對選項的工具與規則表">不另外比對選項的工具與規則表</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">_allow</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">READONLY_RULES</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Callable</span><span class="p">[[</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]],</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;ss&#34;</span><span class="p">:</span> <span class="n">_rule_ss</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s2">&#34;systemctl&#34;</span><span class="p">:</span> <span class="n">_rule_systemctl</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="s2">&#34;journalctl&#34;</span><span class="p">:</span> <span class="n">_rule_journalctl</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="s2">&#34;firewall-cmd&#34;</span><span class="p">:</span> <span class="n">_rule_firewall_cmd</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="s2">&#34;find&#34;</span><span class="p">:</span> <span class="n">_rule_find</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="s2">&#34;sqlite3&#34;</span><span class="p">:</span> <span class="n">_rule_sqlite3</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="s2">&#34;openssl&#34;</span><span class="p">:</span> <span class="n">_rule_openssl</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="s2">&#34;sshd&#34;</span><span class="p">:</span> <span class="n">_rule_sshd</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="s2">&#34;needs-restarting&#34;</span><span class="p">:</span> <span class="n">_rule_needs_restarting</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="s2">&#34;dnf&#34;</span><span class="p">:</span> <span class="n">_rule_dnf</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="s2">&#34;rpm&#34;</span><span class="p">:</span> <span class="n">_rule_rpm</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="s2">&#34;ausearch&#34;</span><span class="p">:</span> <span class="n">_rule_ausearch</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="s2">&#34;grep&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="s2">&#34;wc&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="s2">&#34;stat&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="s2">&#34;du&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="s2">&#34;df&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="s2">&#34;test&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="s2">&#34;id&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="s2">&#34;uname&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="s2">&#34;getenforce&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="s2">&#34;sestatus&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="s2">&#34;getsebool&#34;</span><span class="p">:</span> <span class="n">_allow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>READONLY_RULES</code> 是白名單本身：鍵是執行檔的檔名，值是那支工具的規則函式；不在這張表上的工具在 <code>readonly_violation</code> 裡被拒絕。其餘對到 <code>_allow</code> 的工具，任何參數都放行，因為它們沒有寫檔或執行其他程式的選項，判斷的依據在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉白名單三層比對（執行檔的路徑、工具、工具的選項）裡的「工具」那一層。白名單要加一支工具時，在這張表加一列；那支工具有會寫檔或執行其他程式的選項時，同時寫一個規則函式，對到 <code>_allow</code> 等於宣告它的每一個選項都只讀。</p>
<h2 id="指令的只讀判定readonly_violation-與-checker-自己組的指令">指令的只讀判定：readonly_violation 與 Checker 自己組的指令</h2>
<p><code>readonly_violation</code> 把一條指令字串判定成放行或拒絕：先用 <code>shlex</code> 切詞，再依序做白名單的三層比對。執行檔的路徑那一層在這個函式裡比對；工具那一層依檔名查 <code>READONLY_RULES</code>，查不到就拒絕；工具的選項那一層交給查到的規則函式，寫在〈各工具的選項規則：systemctl、journalctl、firewall-cmd、ss、find、sqlite3、openssl、sshd、needs-restarting、dnf、rpm、ausearch〉。程式碼裡排在 <code>readonly_violation</code> 之前的，是腳本自己用清單的值組出來的指令（<code>LISTEN_COMMAND</code>、<code>timer_command</code>、<code>journal_command</code>），載入清單時與執行時用的是同一批函式，比對過的字串就是執行的字串。三層比對的理由，以及裸工具名依 <code>PATH</code> 找檔案而要經由 sudo 或 systemd 執行的條件，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Checker 實際會拼出來的指令；validate 載入時用同一批函式先比對白名單，兩邊不會不一致。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">LISTEN_COMMAND</span> <span class="o">=</span> <span class="s2">&#34;ss -H -l -n -t -u&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">UPDATE_TIMER_VERBS</span> <span class="o">=</span> <span class="p">((</span><span class="s2">&#34;is-enabled&#34;</span><span class="p">,</span> <span class="s2">&#34;enabled&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;is-active&#34;</span><span class="p">,</span> <span class="s2">&#34;active&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># needs-restarting -r 判定要重開機時印的句子（dnf 4 與 dnf 5 相同，Runner 以 LC_ALL=C 執行）。</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># exit 1 也是 dnf 自己出錯時的 exit（ProtectSystem=strict 下寫不了 /var/log/dnf.log），只看 exit 分不開。</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">REBOOT_REQUIRED_TEXT</span> <span class="o">=</span> <span class="s2">&#34;Reboot is required&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</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="k">def</span> <span class="nf">timer_command</span><span class="p">(</span><span class="n">verb</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">timer</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;systemctl </span><span class="si">{</span><span class="n">verb</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">shlex</span><span class="o">.</span><span class="n">quote</span><span class="p">(</span><span class="n">timer</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">def</span> <span class="nf">journal_command</span><span class="p">(</span><span class="n">unit</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;journalctl -u </span><span class="si">{</span><span class="n">shlex</span><span class="o">.</span><span class="n">quote</span><span class="p">(</span><span class="n">unit</span><span class="p">)</span><span class="si">}</span><span class="s2"> -n 1 -q --no-pager -o short-iso&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">TRUSTED_BIN_DIRS</span> <span class="o">=</span> <span class="nb">frozenset</span><span class="p">({</span><span class="s2">&#34;/usr/bin&#34;</span><span class="p">,</span> <span class="s2">&#34;/usr/sbin&#34;</span><span class="p">,</span> <span class="s2">&#34;/bin&#34;</span><span class="p">,</span> <span class="s2">&#34;/sbin&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">def</span> <span class="nf">readonly_violation</span><span class="p">(</span><span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="s2">&#34;&#34;&#34;command 不在只讀白名單時回傳理由。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="n">argv</span> <span class="o">=</span> <span class="n">shlex</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;指令切詞失敗：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">argv</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34;指令是空的&#34;</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="s2">&#34;/&#34;</span> <span class="ow">in</span> <span class="n">argv</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="ow">and</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">TRUSTED_BIN_DIRS</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">argv</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2"> 的路徑不在 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">sorted</span><span class="p">(</span><span class="n">TRUSTED_BIN_DIRS</span><span class="p">))</span><span class="si">}</span><span class="s2"> 之下（只接受裸名或系統目錄）&#34;</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="n">name</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="n">argv</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="n">rule</span> <span class="o">=</span> <span class="n">READONLY_RULES</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="n">rule</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2"> 不在只讀白名單（</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">sorted</span><span class="p">(</span><span class="n">READONLY_RULES</span><span class="p">))</span><span class="si">}</span><span class="s2">）&#34;</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="k">return</span> <span class="n">rule</span><span class="p">([</span><span class="n">name</span><span class="p">]</span> <span class="o">+</span> <span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:])</span></span></span></code></pre></div><p><code>timer_command</code> 與 <code>journal_command</code> 把清單裡的 <a href="/blog/linux/dotfile/knowledge-cards/systemd-timer/" data-link-title="systemd timer" data-link-desc="排程的工作沒有在預期的時間執行、主機關機期間錯過的排程不知道會不會補跑、要在 cron 與 systemd timer 之間選一種寫定期工作，或讀到 *.timer 檔與 systemctl list-timers 的輸出時">systemd timer</a> 名稱與 unit 名稱嵌進指令。<code>shlex.quote</code> 讓名稱裡的空白與引號在切詞之後仍是一個參數，名稱塞不進第二個參數；而名稱本身寫成選項的樣子時，<code>shlex.quote</code> 不改它，要靠白名單擋。單元測試 <code>test_evil_timer_rejected_at_load</code>（本篇沒有收錄）把清單的 timer 寫成 <code>--host=evil</code>，組出來的 <code>systemctl is-enabled --host=evil</code> 在載入時被 <code>systemctl</code> 的規則拒絕，腳本以 exit 2 結束，假的執行器一條指令都沒有收到。<code>REBOOT_REQUIRED_TEXT</code> 給自動更新那一項判斷要不要重開機，它跟 exit code 一起用的理由寫在註解裡，判定的程式在下面〈自動更新的排程、設定與重開機〉。註解寫這一句「dnf 4 與 dnf 5 相同」，原始碼與測試裡沒有 dnf 5 的輸出可以對照：單元測試用的是 dnf 4 格式的輸出，dnf 5 印的句子這次未實跑。</p>
<p>執行檔路徑那一層比對的是字串：<code>os.path.dirname</code> 取出的目錄要逐字等於四個系統目錄之一，沒有先正規化路徑。<code>./grep</code> 的目錄是 <code>.</code>、<code>/usr/local/bin/grep</code> 的目錄是 <code>/usr/local/bin</code>，都不在表上；<code>/usr/bin/../../tmp/grep</code> 的目錄是 <code>/usr/bin/../../tmp</code>，同樣不等於 <code>/usr/bin</code>，<code>..</code> 因此繞不過這一層。規則函式收到的第一個元素換成了檔名，<code>/usr/bin/grep</code> 與 <code>grep</code> 走同一個規則。<code>shlex.split</code> 遇到沒有收尾的引號時丟出 <code>ValueError</code>，這一條指令判成拒絕，不讓整支腳本中止。</p>
<p>白名單是擋得住「請 PID 1 這類別的程序代做」這種改動的那一層。systemd 沙箱的檔案系統與網路限制管的是腳本自己的程序，而 <code>systemctl stop</code> 經由 D-Bus 請 systemd（PID 1）去停服務，停服務的是不受這些限制的 systemd。在 Ubuntu 24.04（systemd 255）上實跑：<code>systemd-run --wait --pipe -p ProtectSystem=strict -p IPAddressDeny=any systemctl stop &lt;unit&gt;</code> 回 rc=0，unit 被停掉，加上 <code>PrivateNetwork=yes</code> 也一樣停得掉；只有用 <code>RestrictAddressFamilies=&quot;AF_INET AF_INET6&quot;</code> 拿掉 AF_UNIX 時，回 rc=1（<code>Failed to connect to bus: Address family not supported by protocol</code>）。所以 <code>systemctl</code> 的規則只放行查詢的子命令；白名單與沙箱怎麼分工，在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>
<h2 id="清單的載入與驗證probe_validatorload_manifest-與-validate">清單的載入與驗證：Probe、_Validator、load_manifest 與 validate</h2>
<p>這一段把 TOML 讀成 Python 的 dict 之後逐段驗證並正規化：每個欄位查存在、型別與允許值，字串裡的 <code>{{名稱}}</code> 換成 <code>[vars]</code> 的值，要執行的指令比對白名單；整份清單的錯誤收齊之後一次丟出 <code>ManifestError</code>，這時一條指令都還沒有執行。欄位怎麼依檢查腳本與人的需要設計，在 <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>〈清單欄位的設計：檢查腳本要讀的與人要讀的〉；照這段驗證實際要求的欄位寫成範本，在 <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>〈變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">  1</span><span class="cl"><span class="c1"># ── 清單解析與驗證 ─────────────────────────────────────────</span>
</span></span><span class="line"><span class="ln">  2</span><span class="cl">
</span></span><span class="line"><span class="ln">  3</span><span class="cl">
</span></span><span class="line"><span class="ln">  4</span><span class="cl"><span class="nd">@dataclass</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl"><span class="k">class</span> <span class="nc">Probe</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">  6</span><span class="cl">    <span class="n">command</span><span class="p">:</span> <span class="nb">str</span>
</span></span><span class="line"><span class="ln">  7</span><span class="cl">    <span class="n">expect_exit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">  8</span><span class="cl">    <span class="n">ok_exit</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="n">field</span><span class="p">(</span><span class="n">default_factory</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="p">[</span><span class="mi">0</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl">    <span class="n">expect_stdout</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="n">expect_contains</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl">    <span class="n">expect_not_contains</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">    <span class="n">measure</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 13</span><span class="cl">    <span class="n">warn_above</span><span class="p">:</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</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></span><span class="line"><span class="ln"> 16</span><span class="cl"><span class="k">class</span> <span class="nc">_Validator</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 17</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">variables</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]):</span>
</span></span><span class="line"><span class="ln"> 18</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">errors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 19</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">vars</span> <span class="o">=</span> <span class="n">variables</span>
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">
</span></span><span class="line"><span class="ln"> 21</span><span class="cl">    <span class="k">def</span> <span class="nf">err</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">msg</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">where</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">    <span class="k">def</span> <span class="nf">subst</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">        <span class="k">def</span> <span class="nf">repl</span><span class="p">(</span><span class="n">m</span><span class="p">:</span> <span class="n">re</span><span class="o">.</span><span class="n">Match</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 26</span><span class="cl">            <span class="n">name</span> <span class="o">=</span> <span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 27</span><span class="cl">            <span class="k">if</span> <span class="n">name</span> <span class="ow">not</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">vars</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="se">{{{{</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="se">}}}}</span><span class="s2"> 沒有定義在 [vars]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl">                <span class="k">return</span> <span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">            <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">vars</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 31</span><span class="cl">
</span></span><span class="line"><span class="ln"> 32</span><span class="cl">        <span class="k">return</span> <span class="n">VAR_RE</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="n">repl</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 33</span><span class="cl">
</span></span><span class="line"><span class="ln"> 34</span><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tbl</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">key</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">typ</span><span class="p">:</span> <span class="nb">type</span> <span class="o">|</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">type</span><span class="p">,</span> <span class="o">...</span><span class="p">],</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">,</span> <span class="n">enum</span><span class="p">:</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="o">...</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Any</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 35</span><span class="cl">        <span class="k">if</span> <span class="n">key</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">tbl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 36</span><span class="cl">            <span class="k">if</span> <span class="n">required</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 37</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;缺少必填欄位 </span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">        <span class="n">v</span> <span class="o">=</span> <span class="n">tbl</span><span class="p">[</span><span class="n">key</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">        <span class="c1"># bool 是 int 的子類別，數字欄位不收 true/false。</span>
</span></span><span class="line"><span class="ln"> 41</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">typ</span><span class="p">)</span> <span class="ow">or</span> <span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="nb">bool</span><span class="p">)</span> <span class="ow">and</span> <span class="nb">bool</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">typ</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">typ</span><span class="p">,</span> <span class="nb">tuple</span><span class="p">)</span> <span class="k">else</span> <span class="p">(</span><span class="n">typ</span><span class="p">,))):</span>
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2"> 的型別要是 </span><span class="si">{</span><span class="n">_typename</span><span class="p">(</span><span class="n">typ</span><span class="p">)</span><span class="si">}</span><span class="s2">，實際是 </span><span class="si">{</span><span class="nb">type</span><span class="p">(</span><span class="n">v</span><span class="p">)</span><span class="o">.</span><span class="vm">__name__</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 43</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 44</span><span class="cl">        <span class="k">if</span> <span class="n">enum</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">v</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">enum</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 45</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2"> 要是 </span><span class="si">{</span><span class="nb">list</span><span class="p">(</span><span class="n">enum</span><span class="p">)</span><span class="si">}</span><span class="s2"> 之一，實際是 </span><span class="si">{</span><span class="n">v</span><span class="si">!r}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 47</span><span class="cl">        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">            <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">subst</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">where</span><span class="si">}</span><span class="s2">.</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 49</span><span class="cl">        <span class="k">return</span> <span class="n">v</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">    <span class="k">def</span> <span class="nf">reject_old_command_name</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tbl</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">        <span class="c1"># command 一律會被執行；只印不執行的欄位叫 manual_command，舊名出現在只印的位置判成格式錯。</span>
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">        <span class="k">if</span> <span class="s2">&#34;command&#34;</span> <span class="ow">in</span> <span class="n">tbl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 54</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="s2">&#34;只印不執行的指令改用 manual_command（command 一律會被執行並過白名單）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 55</span><span class="cl">
</span></span><span class="line"><span class="ln"> 56</span><span class="cl">    <span class="k">def</span> <span class="nf">str_list</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tbl</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">key</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">        <span class="n">v</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="nb">list</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">        <span class="k">if</span> <span class="n">v</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 59</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">v</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2"> 的每一項都要是字串&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="bp">self</span><span class="o">.</span><span class="n">subst</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">where</span><span class="si">}</span><span class="s2">.</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">v</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">
</span></span><span class="line"><span class="ln"> 65</span><span class="cl">    <span class="k">def</span> <span class="nf">table_list</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">key</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">required</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]]:</span>
</span></span><span class="line"><span class="ln"> 66</span><span class="cl">        <span class="n">v</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 67</span><span class="cl">        <span class="k">if</span> <span class="n">v</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">            <span class="k">if</span> <span class="n">required</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;缺少 [[</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">]]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 70</span><span class="cl">            <span class="k">return</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="nb">list</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">v</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2"> 要是 [[</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s2">]] 表格陣列&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">            <span class="k">return</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">        <span class="k">return</span> <span class="n">v</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">    <span class="k">def</span> <span class="nf">probe</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tbl</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">allow_measure</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">False</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Probe</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 77</span><span class="cl">        <span class="n">command</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">        <span class="k">if</span> <span class="n">command</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 80</span><span class="cl">        <span class="n">reason</span> <span class="o">=</span> <span class="n">readonly_violation</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">        <span class="k">if</span> <span class="n">reason</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 82</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;command 不是只讀指令：</span><span class="si">{</span><span class="n">reason</span><span class="si">}</span><span class="s2">（</span><span class="si">{</span><span class="n">command</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">        <span class="n">p</span> <span class="o">=</span> <span class="n">Probe</span><span class="p">(</span><span class="n">command</span><span class="o">=</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">        <span class="n">p</span><span class="o">.</span><span class="n">expect_exit</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;expect_exit&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">        <span class="n">ok_exit</span> <span class="o">=</span> <span class="n">tbl</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;ok_exit&#34;</span><span class="p">,</span> <span class="p">[</span><span class="mi">0</span><span class="p">])</span>
</span></span><span class="line"><span class="ln"> 86</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">ok_exit</span><span class="p">,</span> <span class="nb">list</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">int</span><span class="p">)</span> <span class="ow">and</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">bool</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">ok_exit</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 87</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="s2">&#34;ok_exit 要是整數陣列&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">            <span class="n">p</span><span class="o">.</span><span class="n">ok_exit</span> <span class="o">=</span> <span class="n">ok_exit</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">        <span class="n">p</span><span class="o">.</span><span class="n">expect_stdout</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;expect_stdout&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">        <span class="n">p</span><span class="o">.</span><span class="n">expect_contains</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;expect_contains&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">        <span class="n">p</span><span class="o">.</span><span class="n">expect_not_contains</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;expect_not_contains&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 93</span><span class="cl">        <span class="k">if</span> <span class="n">allow_measure</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">            <span class="n">p</span><span class="o">.</span><span class="n">measure</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;measure&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">MEASURE_VALUES</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">            <span class="n">p</span><span class="o">.</span><span class="n">warn_above</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">tbl</span><span class="p">,</span> <span class="s2">&#34;warn_above&#34;</span><span class="p">,</span> <span class="p">(</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span><span class="p">),</span> <span class="n">where</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="n">p</span><span class="o">.</span><span class="n">measure</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">        <span class="n">has_expect</span> <span class="o">=</span> <span class="nb">any</span><span class="p">(</span><span class="n">x</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">expect_exit</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_stdout</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_contains</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_not_contains</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">measure</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">has_expect</span> <span class="ow">and</span> <span class="s2">&#34;ok_exit&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">tbl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 98</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="s2">&#34;探針至少要有一個預期值（expect_exit、ok_exit、expect_stdout、expect_contains、expect_not_contains 或 measure）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 99</span><span class="cl">        <span class="k">return</span> <span class="n">p</span>
</span></span><span class="line"><span class="ln">100</span><span class="cl">
</span></span><span class="line"><span class="ln">101</span><span class="cl">
</span></span><span class="line"><span class="ln">102</span><span class="cl"><span class="k">def</span> <span class="nf">_typename</span><span class="p">(</span><span class="n">typ</span><span class="p">:</span> <span class="nb">type</span> <span class="o">|</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">type</span><span class="p">,</span> <span class="o">...</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">103</span><span class="cl">    <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">typ</span><span class="p">,</span> <span class="nb">tuple</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">104</span><span class="cl">        <span class="k">return</span> <span class="s2">&#34; 或 &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="vm">__name__</span> <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">typ</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">105</span><span class="cl">    <span class="k">return</span> <span class="n">typ</span><span class="o">.</span><span class="vm">__name__</span></span></span></code></pre></div><p><code>Probe</code> 是一個探針：要執行的一條指令，加上怎樣算正常的預期值。<code>_Validator</code> 把驗證時共用的動作收在一起，每個方法查到錯誤時記一條、回傳 <code>None</code> 或空的結果（<code>table_list</code> 回空串列），不丟例外，所以一個欄位錯了之後，其他欄位照樣往下驗。</p>
<p><code>get</code> 的型別比對多了一個布林的判斷：Python 的 <code>bool</code> 是 <code>int</code> 的子類別，只用 <code>isinstance(v, int)</code> 的話，<code>port = true</code> 會被當成整數 1 收下。<code>get</code> 先比對型別與允許值，再換掉字串裡的 <code>{{名稱}}</code>：有允許值的欄位（<code>exposure</code>、<code>where</code>、<code>protocol</code> 這類）寫成 <code>{{名稱}}</code> 時，比對的是 <code>{{名稱}}</code> 這串字本身，判成格式錯，所以這些欄位不能用 <code>[vars]</code> 填。之後比對白名單的是換過的字串，所以命令列 <code>--set</code> 傳進來的值跟清單裡寫死的值走同一道白名單。<code>[vars]</code> 沒有的名稱記一條錯誤，原文的 <code>{{名稱}}</code> 留在字串裡。</p>
<p><code>reject_old_command_name</code> 管的是欄位名稱的意思：清單裡的 <code>command</code> 一律會被執行，只印給人看的指令寫在 <code>manual_command</code>。只印的位置（<code>[[log]]</code>、暫停與恢復的步驟、主機外的確認）出現 <code>command</code> 時判成格式錯，讀清單的人看到 <code>command</code> 就知道那一條會被執行，單元測試 <code>test_log_old_name_rejected</code>、<code>test_resume_old_name_rejected</code> 與 <code>test_external_verify_old_name_rejected</code>（本篇沒有收錄）各測一個位置。</p>
<p><code>probe</code> 要求探針至少寫出一個預期值，或明寫 <code>ok_exit</code>：<code>ok_exit</code> 的預設 <code>[0]</code> 不算宣告，清單的讀者要看得到這一項怎樣算正常。<code>measure</code> 與 <code>warn_above</code> 只在 <code>allow_measure</code> 為真時讀，也就是 log 的計數門檻（<code>[[log.alert]]</code>）；寫了 <code>measure</code> 就一定要有 <code>warn_above</code>，量到的值才有門檻可比。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">  1</span><span class="cl"><span class="k">def</span> <span class="nf">load_manifest</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">overrides</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">  2</span><span class="cl">    <span class="s2">&#34;&#34;&#34;讀取並驗證清單，回傳正規化後的結構；格式錯時丟 ManifestError。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">  3</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">  4</span><span class="cl">        <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">tomllib</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">  6</span><span class="cl">    <span class="k">except</span> <span class="ne">OSError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">  7</span><span class="cl">        <span class="k">raise</span> <span class="n">ManifestError</span><span class="p">([</span><span class="sa">f</span><span class="s2">&#34;讀不到清單 </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">])</span> <span class="kn">from</span> <span class="nn">e</span>
</span></span><span class="line"><span class="ln">  8</span><span class="cl">    <span class="k">except</span> <span class="n">tomllib</span><span class="o">.</span><span class="n">TOMLDecodeError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl">        <span class="k">raise</span> <span class="n">ManifestError</span><span class="p">([</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2"> 不是合法的 TOML 1.0：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">])</span> <span class="kn">from</span> <span class="nn">e</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="k">return</span> <span class="n">validate</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">overrides</span> <span class="ow">or</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl">
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">
</span></span><span class="line"><span class="ln"> 13</span><span class="cl"><span class="k">def</span> <span class="nf">validate</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">overrides</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 14</span><span class="cl">    <span class="n">raw_vars</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;vars&#34;</span><span class="p">,</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 15</span><span class="cl">    <span class="n">errors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 16</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">raw_vars</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">raw_vars</span><span class="o">.</span><span class="n">values</span><span class="p">()):</span>
</span></span><span class="line"><span class="ln"> 17</span><span class="cl">        <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="s2">&#34;vars: [vars] 的每個值都要是字串&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 18</span><span class="cl">        <span class="n">raw_vars</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 19</span><span class="cl">    <span class="n">variables</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">raw_vars</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">    <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">overrides</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 21</span><span class="cl">        <span class="k">if</span> <span class="n">k</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">variables</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">            <span class="n">errors</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;--set </span><span class="si">{</span><span class="n">k</span><span class="si">}</span><span class="s2">: [vars] 沒有這個名稱&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">    <span class="n">variables</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">overrides</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">    <span class="n">v</span> <span class="o">=</span> <span class="n">_Validator</span><span class="p">(</span><span class="n">variables</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">    <span class="n">v</span><span class="o">.</span><span class="n">errors</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">errors</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 26</span><span class="cl">    <span class="n">out</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;vars&#34;</span><span class="p">:</span> <span class="n">variables</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 27</span><span class="cl">
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">    <span class="n">host</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">,</span> <span class="s2">&#34;缺少 [host]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 31</span><span class="cl">        <span class="n">host</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 32</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;host&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span><span class="n">k</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="s2">&#34;provider&#34;</span><span class="p">,</span> <span class="s2">&#34;region&#34;</span><span class="p">,</span> <span class="s2">&#34;role&#34;</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 33</span><span class="cl">
</span></span><span class="line"><span class="ln"> 34</span><span class="cl">    <span class="n">la</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;protocols&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;tcp&#34;</span><span class="p">],</span> <span class="s2">&#34;allow&#34;</span><span class="p">:</span> <span class="p">[]})</span>
</span></span><span class="line"><span class="ln"> 35</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">la</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 36</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">,</span> <span class="s2">&#34;要是表格&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 37</span><span class="cl">        <span class="n">la</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">    <span class="n">protocols</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">str_list</span><span class="p">(</span><span class="n">la</span><span class="p">,</span> <span class="s2">&#34;protocols&#34;</span><span class="p">,</span> <span class="s2">&#34;listen_audit&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">    <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">protocols</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">        <span class="k">if</span> <span class="n">p</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">PROTOCOL_VALUES</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 41</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;protocols 只能是 </span><span class="si">{</span><span class="nb">list</span><span class="p">(</span><span class="n">PROTOCOL_VALUES</span><span class="p">)</span><span class="si">}</span><span class="s2">，實際有 </span><span class="si">{</span><span class="n">p</span><span class="si">!r}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">    <span class="n">allow_out</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 43</span><span class="cl">    <span class="n">allow</span> <span class="o">=</span> <span class="n">la</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;allow&#34;</span><span class="p">,</span> <span class="p">[])</span>
</span></span><span class="line"><span class="ln"> 44</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">allow</span><span class="p">,</span> <span class="nb">list</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 45</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">,</span> <span class="s2">&#34;allow 要是陣列&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">        <span class="n">allow</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 47</span><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">a</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">allow</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;listen_audit.allow[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln"> 49</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;要是 { port = ..., protocol = ..., reason = ... }&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">        <span class="n">allow_out</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;port&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="s2">&#34;port&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span> <span class="s2">&#34;protocol&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="s2">&#34;protocol&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">PROTOCOL_VALUES</span><span class="p">),</span> <span class="s2">&#34;reason&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="s2">&#34;reason&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;protocols&#34;</span><span class="p">:</span> <span class="n">protocols</span><span class="p">,</span> <span class="s2">&#34;allow&#34;</span><span class="p">:</span> <span class="n">allow_out</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 54</span><span class="cl">
</span></span><span class="line"><span class="ln"> 55</span><span class="cl">    <span class="n">eps</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 56</span><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ep</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">table_list</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="s2">&#34;entrypoint&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;entrypoint[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">        <span class="n">name</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 59</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;entrypoint[</span><span class="si">{</span><span class="n">name</span> <span class="ow">or</span> <span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">        <span class="n">e</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">            <span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">            <span class="s2">&#34;port&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;port&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">            <span class="s2">&#34;protocol&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;protocol&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">PROTOCOL_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">            <span class="s2">&#34;exposure&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;exposure&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">EXPOSURE_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 65</span><span class="cl">            <span class="s2">&#34;where&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;where&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">WHERE_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 66</span><span class="cl">            <span class="s2">&#34;enforced_by&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">str_list</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="s2">&#34;enforced_by&#34;</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 67</span><span class="cl">            <span class="s2">&#34;verify&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">        <span class="n">verifies</span> <span class="o">=</span> <span class="n">ep</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;verify&#34;</span><span class="p">,</span> <span class="p">[])</span>
</span></span><span class="line"><span class="ln"> 70</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">verifies</span><span class="p">,</span> <span class="nb">list</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">verifies</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;verify 要是 [[entrypoint.verify]] 表格陣列&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">            <span class="n">verifies</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">        <span class="n">layers_seen</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">        <span class="k">for</span> <span class="n">j</span><span class="p">,</span> <span class="n">vf</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">verifies</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">            <span class="n">vw</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s2">.verify[</span><span class="si">{</span><span class="n">j</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">            <span class="n">layer</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="s2">&#34;layer&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">vw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 77</span><span class="cl">            <span class="n">where</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="s2">&#34;where&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">vw</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">WHERE_VALUES</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">            <span class="n">layers_seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">layer</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">            <span class="n">item</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;layer&#34;</span><span class="p">:</span> <span class="n">layer</span><span class="p">,</span> <span class="s2">&#34;where&#34;</span><span class="p">:</span> <span class="n">where</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 80</span><span class="cl">            <span class="k">if</span> <span class="n">where</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">                <span class="n">item</span><span class="p">[</span><span class="s2">&#34;probe&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">probe</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="n">vw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 82</span><span class="cl">            <span class="k">elif</span> <span class="n">where</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">                <span class="n">item</span><span class="p">[</span><span class="s2">&#34;console_path&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="s2">&#34;console_path&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">vw</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">                <span class="n">v</span><span class="o">.</span><span class="n">reject_old_command_name</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="n">vw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">                <span class="n">item</span><span class="p">[</span><span class="s2">&#34;manual_command&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="s2">&#34;manual_command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">vw</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 86</span><span class="cl">                <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;console_path&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;manual_command&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 87</span><span class="cl">                    <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">vw</span><span class="p">,</span> <span class="s2">&#34;主機外的確認要有 console_path 或 manual_command&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">                <span class="n">item</span><span class="p">[</span><span class="s2">&#34;expect&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="s2">&#34;expect&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">vw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">            <span class="n">e</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">        <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;enforced_by&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">            <span class="k">if</span> <span class="n">layer</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">layers_seen</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">                <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;enforced_by 的 </span><span class="si">{</span><span class="n">layer</span><span class="si">!r}</span><span class="s2"> 沒有對應的 [[entrypoint.verify]]（layer = </span><span class="si">{</span><span class="n">layer</span><span class="si">!r}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 93</span><span class="cl">        <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="n">layers_seen</span> <span class="o">-</span> <span class="nb">set</span><span class="p">(</span><span class="n">e</span><span class="p">[</span><span class="s2">&#34;enforced_by&#34;</span><span class="p">])</span> <span class="o">-</span> <span class="p">{</span><span class="kc">None</span><span class="p">}:</span>
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;[[entrypoint.verify]] 的 layer </span><span class="si">{</span><span class="n">layer</span><span class="si">!r}</span><span class="s2"> 不在 enforced_by 裡&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">        <span class="n">eps</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">e</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;entrypoint&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">eps</span>
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">
</span></span><span class="line"><span class="ln"> 98</span><span class="cl">    <span class="n">logs</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 99</span><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">lg</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">table_list</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="s2">&#34;log&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">100</span><span class="cl">        <span class="n">name</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;log[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">101</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;log[</span><span class="si">{</span><span class="n">name</span> <span class="ow">or</span> <span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln">102</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">reject_old_command_name</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">103</span><span class="cl">        <span class="n">source</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="s2">&#34;source&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">104</span><span class="cl">        <span class="k">if</span> <span class="n">source</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">re</span><span class="o">.</span><span class="k">match</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^(file|journal):.+&#34;</span><span class="p">,</span> <span class="n">source</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">105</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;source 要寫成 file:&lt;路徑&gt; 或 journal:&lt;unit&gt;，實際是 </span><span class="si">{</span><span class="n">source</span><span class="si">!r}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">106</span><span class="cl">        <span class="n">item</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">107</span><span class="cl">            <span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">108</span><span class="cl">            <span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="n">source</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">109</span><span class="cl">            <span class="s2">&#34;look_for&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="s2">&#34;look_for&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">110</span><span class="cl">            <span class="s2">&#34;interval_days&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="s2">&#34;interval_days&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">111</span><span class="cl">            <span class="s2">&#34;manual_command&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">lg</span><span class="p">,</span> <span class="s2">&#34;manual_command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">112</span><span class="cl">            <span class="s2">&#34;alerts&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln">113</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">114</span><span class="cl">        <span class="n">alerts</span> <span class="o">=</span> <span class="n">lg</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;alert&#34;</span><span class="p">,</span> <span class="p">[])</span>
</span></span><span class="line"><span class="ln">115</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">alerts</span><span class="p">,</span> <span class="nb">list</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">alerts</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">116</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;alert 要是 [[log.alert]] 表格陣列&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">117</span><span class="cl">            <span class="n">alerts</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">118</span><span class="cl">        <span class="k">for</span> <span class="n">j</span><span class="p">,</span> <span class="n">al</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">alerts</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">119</span><span class="cl">            <span class="n">aw</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s2">.alert[</span><span class="si">{</span><span class="n">j</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln">120</span><span class="cl">            <span class="n">aname</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">al</span><span class="p">,</span> <span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">aw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">121</span><span class="cl">            <span class="n">item</span><span class="p">[</span><span class="s2">&#34;alerts&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="n">aname</span><span class="p">,</span> <span class="s2">&#34;probe&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">probe</span><span class="p">(</span><span class="n">al</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s2">.alert[</span><span class="si">{</span><span class="n">aname</span> <span class="ow">or</span> <span class="n">j</span><span class="si">}</span><span class="s2">]&#34;</span><span class="p">,</span> <span class="n">allow_measure</span><span class="o">=</span><span class="kc">True</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln">122</span><span class="cl">        <span class="n">logs</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">123</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;log&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">logs</span>
</span></span><span class="line"><span class="ln">124</span><span class="cl">
</span></span><span class="line"><span class="ln">125</span><span class="cl">    <span class="n">accs</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">126</span><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ac</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">table_list</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="s2">&#34;accumulation&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">127</span><span class="cl">        <span class="n">name</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;accumulation[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">128</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;accumulation[</span><span class="si">{</span><span class="n">name</span> <span class="ow">or</span> <span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln">129</span><span class="cl">        <span class="n">paths</span> <span class="o">=</span> <span class="n">ac</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;path&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">130</span><span class="cl">        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">paths</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">131</span><span class="cl">            <span class="n">paths</span> <span class="o">=</span> <span class="p">[</span><span class="n">paths</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">132</span><span class="cl">            <span class="n">ac</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="n">path</span><span class="o">=</span><span class="n">paths</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">133</span><span class="cl">        <span class="n">item</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">134</span><span class="cl">            <span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">135</span><span class="cl">            <span class="s2">&#34;paths&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">str_list</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;path&#34;</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln">136</span><span class="cl">            <span class="s2">&#34;missing_ok&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;missing_ok&#34;</span><span class="p">,</span> <span class="nb">bool</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span> <span class="ow">or</span> <span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">137</span><span class="cl">            <span class="s2">&#34;limit_bytes&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;limit_bytes&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">138</span><span class="cl">            <span class="s2">&#34;retention_days&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;retention_days&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">139</span><span class="cl">            <span class="s2">&#34;purge_command&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;purge_command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">140</span><span class="cl">            <span class="s2">&#34;oldest_command&#34;</span><span class="p">:</span> <span class="kc">None</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">141</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">142</span><span class="cl">        <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;limit_bytes&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;retention_days&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">143</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;limit_bytes 與 retention_days 至少要有一個&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">144</span><span class="cl">        <span class="n">oc</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">ac</span><span class="p">,</span> <span class="s2">&#34;oldest_command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">145</span><span class="cl">        <span class="k">if</span> <span class="n">oc</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">146</span><span class="cl">            <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;retention_days&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">147</span><span class="cl">                <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;有 oldest_command 時要有 retention_days&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">148</span><span class="cl">            <span class="n">reason</span> <span class="o">=</span> <span class="n">readonly_violation</span><span class="p">(</span><span class="n">oc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">149</span><span class="cl">            <span class="k">if</span> <span class="n">reason</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">150</span><span class="cl">                <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;oldest_command 不是只讀指令：</span><span class="si">{</span><span class="n">reason</span><span class="si">}</span><span class="s2">（</span><span class="si">{</span><span class="n">oc</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">151</span><span class="cl">            <span class="n">item</span><span class="p">[</span><span class="s2">&#34;oldest_command&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">oc</span>
</span></span><span class="line"><span class="ln">152</span><span class="cl">        <span class="n">accs</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">153</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;accumulation&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">accs</span>
</span></span><span class="line"><span class="ln">154</span><span class="cl">
</span></span><span class="line"><span class="ln">155</span><span class="cl">    <span class="n">fss</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">156</span><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">fs</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">table_list</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="s2">&#34;filesystem&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">157</span><span class="cl">        <span class="n">name</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">fs</span><span class="p">,</span> <span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;filesystem[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">158</span><span class="cl">        <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;filesystem[</span><span class="si">{</span><span class="n">name</span> <span class="ow">or</span> <span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln">159</span><span class="cl">        <span class="n">fss</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;name&#34;</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span> <span class="s2">&#34;mount&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">fs</span><span class="p">,</span> <span class="s2">&#34;mount&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span> <span class="s2">&#34;min_free_bytes&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">fs</span><span class="p">,</span> <span class="s2">&#34;min_free_bytes&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln">160</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;filesystem&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">fss</span>
</span></span><span class="line"><span class="ln">161</span><span class="cl">
</span></span><span class="line"><span class="ln">162</span><span class="cl">    <span class="n">up</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;updates&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">163</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">up</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">164</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;updates&#34;</span><span class="p">,</span> <span class="s2">&#34;缺少 [updates]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">165</span><span class="cl">        <span class="n">up</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">166</span><span class="cl">    <span class="n">expect</span> <span class="o">=</span> <span class="n">up</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;expect&#34;</span><span class="p">,</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln">167</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">expect</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">all</span><span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">expect</span><span class="o">.</span><span class="n">values</span><span class="p">()):</span>
</span></span><span class="line"><span class="ln">168</span><span class="cl">        <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;updates&#34;</span><span class="p">,</span> <span class="s2">&#34;expect 要是值為字串的 inline table&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">169</span><span class="cl">        <span class="n">expect</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">170</span><span class="cl">    <span class="n">reboot</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">up</span><span class="p">,</span> <span class="s2">&#34;reboot_check&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="s2">&#34;updates&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">171</span><span class="cl">    <span class="k">if</span> <span class="n">reboot</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">172</span><span class="cl">        <span class="n">reason</span> <span class="o">=</span> <span class="n">readonly_violation</span><span class="p">(</span><span class="n">reboot</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">173</span><span class="cl">        <span class="k">if</span> <span class="n">reason</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">174</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="s2">&#34;updates&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;reboot_check 不是只讀指令：</span><span class="si">{</span><span class="n">reason</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">175</span><span class="cl">    <span class="n">out</span><span class="p">[</span><span class="s2">&#34;updates&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">176</span><span class="cl">        <span class="s2">&#34;mode&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">up</span><span class="p">,</span> <span class="s2">&#34;mode&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="s2">&#34;updates&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">177</span><span class="cl">        <span class="s2">&#34;timer&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">up</span><span class="p">,</span> <span class="s2">&#34;timer&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="s2">&#34;updates&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">178</span><span class="cl">        <span class="s2">&#34;config_files&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">str_list</span><span class="p">(</span><span class="n">up</span><span class="p">,</span> <span class="s2">&#34;config_files&#34;</span><span class="p">,</span> <span class="s2">&#34;updates&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln">179</span><span class="cl">        <span class="s2">&#34;expect&#34;</span><span class="p">:</span> <span class="n">expect</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">180</span><span class="cl">        <span class="s2">&#34;reboot_check&#34;</span><span class="p">:</span> <span class="n">reboot</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">181</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">182</span><span class="cl">
</span></span><span class="line"><span class="ln">183</span><span class="cl">    <span class="k">for</span> <span class="n">phase</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;suspend&#34;</span><span class="p">,</span> <span class="s2">&#34;resume&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">184</span><span class="cl">        <span class="n">steps</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">185</span><span class="cl">        <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">st</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">table_list</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">phase</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">186</span><span class="cl">            <span class="n">w</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">phase</span><span class="si">}</span><span class="s2">[</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">]&#34;</span>
</span></span><span class="line"><span class="ln">187</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">188</span><span class="cl">                <span class="s2">&#34;step&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;step&#34;</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">189</span><span class="cl">                <span class="s2">&#34;resource&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;resource&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">190</span><span class="cl">                <span class="s2">&#34;billed_when_stopped&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;billed_when_stopped&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">BILLED_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">191</span><span class="cl">                <span class="s2">&#34;billed_when_stopped_beyond_free&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;billed_when_stopped_beyond_free&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">BILLED_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">192</span><span class="cl">                <span class="s2">&#34;billing_basis&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;billing_basis&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">193</span><span class="cl">                <span class="s2">&#34;where&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;where&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">enum</span><span class="o">=</span><span class="n">WHERE_VALUES</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">194</span><span class="cl">                <span class="s2">&#34;manual_command&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;manual_command&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">195</span><span class="cl">                <span class="s2">&#34;console_path&#34;</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="s2">&#34;console_path&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">196</span><span class="cl">                <span class="s2">&#34;verify&#34;</span><span class="p">:</span> <span class="kc">None</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">197</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">198</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">reject_old_command_name</span><span class="p">(</span><span class="n">st</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">199</span><span class="cl">            <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;manual_command&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;console_path&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">200</span><span class="cl">                <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;manual_command 或 console_path 至少要有一個&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">201</span><span class="cl">            <span class="n">vf</span> <span class="o">=</span> <span class="n">st</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;verify&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">202</span><span class="cl">            <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">203</span><span class="cl">                <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">204</span><span class="cl">                    <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;where = </span><span class="se">\&#34;</span><span class="s2">host</span><span class="se">\&#34;</span><span class="s2"> 的 verify 要是只讀探針的 inline table&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">205</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">206</span><span class="cl">                    <span class="n">item</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">probe</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s2">.verify&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">207</span><span class="cl">            <span class="k">elif</span> <span class="n">item</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">208</span><span class="cl">                <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">vf</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">209</span><span class="cl">                    <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="s2">&#34;主機外的 verify 要是一句要看到的結果（字串）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">210</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">211</span><span class="cl">                    <span class="n">item</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">subst</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s2">.verify&#34;</span><span class="p">,</span> <span class="n">vf</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">212</span><span class="cl">            <span class="n">steps</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">213</span><span class="cl">        <span class="n">nums</span> <span class="o">=</span> <span class="p">[</span><span class="n">s</span><span class="p">[</span><span class="s2">&#34;step&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">steps</span> <span class="k">if</span> <span class="n">s</span><span class="p">[</span><span class="s2">&#34;step&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">214</span><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">nums</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">nums</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">215</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">phase</span><span class="p">,</span> <span class="s2">&#34;step 有重複的編號&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">216</span><span class="cl">        <span class="n">out</span><span class="p">[</span><span class="n">phase</span><span class="p">]</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">steps</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">s</span><span class="p">:</span> <span class="n">s</span><span class="p">[</span><span class="s2">&#34;step&#34;</span><span class="p">]</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">217</span><span class="cl">
</span></span><span class="line"><span class="ln">218</span><span class="cl">    <span class="n">_check_builtin_commands</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">219</span><span class="cl">    <span class="k">if</span> <span class="n">v</span><span class="o">.</span><span class="n">errors</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">220</span><span class="cl">        <span class="k">raise</span> <span class="n">ManifestError</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">errors</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">221</span><span class="cl">    <span class="k">return</span> <span class="n">out</span>
</span></span><span class="line"><span class="ln">222</span><span class="cl">
</span></span><span class="line"><span class="ln">223</span><span class="cl">
</span></span><span class="line"><span class="ln">224</span><span class="cl"><span class="k">def</span> <span class="nf">_check_builtin_commands</span><span class="p">(</span><span class="n">v</span><span class="p">:</span> <span class="s2">&#34;_Validator&#34;</span><span class="p">,</span> <span class="n">out</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">225</span><span class="cl">    <span class="c1"># Checker 自己拼的指令也含清單的值（timer 名稱、journal unit），載入時一併比對白名單，不合就判成格式錯。</span>
</span></span><span class="line"><span class="ln">226</span><span class="cl">    <span class="n">cmds</span> <span class="o">=</span> <span class="p">[(</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">,</span> <span class="n">LISTEN_COMMAND</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">227</span><span class="cl">    <span class="n">timer</span> <span class="o">=</span> <span class="n">out</span><span class="p">[</span><span class="s2">&#34;updates&#34;</span><span class="p">][</span><span class="s2">&#34;timer&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">228</span><span class="cl">    <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">timer</span><span class="p">,</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">229</span><span class="cl">        <span class="n">cmds</span> <span class="o">+=</span> <span class="p">[(</span><span class="s2">&#34;updates.timer&#34;</span><span class="p">,</span> <span class="n">timer_command</span><span class="p">(</span><span class="n">verb</span><span class="p">,</span> <span class="n">timer</span><span class="p">))</span> <span class="k">for</span> <span class="n">verb</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">UPDATE_TIMER_VERBS</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">230</span><span class="cl">    <span class="k">for</span> <span class="n">lg</span> <span class="ow">in</span> <span class="n">out</span><span class="p">[</span><span class="s2">&#34;log&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">231</span><span class="cl">        <span class="n">source</span> <span class="o">=</span> <span class="n">lg</span><span class="p">[</span><span class="s2">&#34;source&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">232</span><span class="cl">        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="ow">and</span> <span class="n">source</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;journal:&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">233</span><span class="cl">            <span class="n">cmds</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="sa">f</span><span class="s2">&#34;log[</span><span class="si">{</span><span class="n">lg</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">].source&#34;</span><span class="p">,</span> <span class="n">journal_command</span><span class="p">(</span><span class="n">source</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)[</span><span class="mi">2</span><span class="p">])))</span>
</span></span><span class="line"><span class="ln">234</span><span class="cl">    <span class="k">for</span> <span class="n">where</span><span class="p">,</span> <span class="n">cmd</span> <span class="ow">in</span> <span class="n">cmds</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">235</span><span class="cl">        <span class="n">reason</span> <span class="o">=</span> <span class="n">readonly_violation</span><span class="p">(</span><span class="n">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">236</span><span class="cl">        <span class="k">if</span> <span class="n">reason</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">237</span><span class="cl">            <span class="n">v</span><span class="o">.</span><span class="n">err</span><span class="p">(</span><span class="n">where</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;自動執行的指令不是只讀指令：</span><span class="si">{</span><span class="n">reason</span><span class="si">}</span><span class="s2">（</span><span class="si">{</span><span class="n">cmd</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span></span></span></code></pre></div><p><code>load_manifest</code> 把兩種讀不進來的情形（檔案打不開、內容不是合法的 TOML）都轉成 <code>ManifestError</code>，跟欄位錯走同一個 exit 2。錯誤訊息寫「不是合法的 TOML 1.0」，因為示範清單描述的那台 Oracle Linux 10 主機上，Python 3.12 的 <code>tomllib</code> 只認 TOML 1.0：在開發機上用較新的 Python 驗證過的清單，到了只有 3.12 的主機上，1.1 才有的語法會在這裡失敗；兩個版本的差異與解析器的版本在清單寫成 TOML 的那篇概念文章的〈TOML 1.0 與 1.1 的差異與解析器的版本〉。</p>
<p><code>validate</code> 先處理 <code>[vars]</code> 與 <code>--set</code>：<code>--set</code> 給的名稱要已經寫在 <code>[vars]</code> 裡，打錯字的名稱算格式錯（<code>test_unknown_set_name_is_format_error</code>），不會被當成一個新的變數收下、而清單裡想覆寫的那個值照舊不變。同一份清單套到多台主機時，每台不同的值在執行時用 <code>--set</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>。之後依清單的段落逐段驗：</p>
<ul>
<li><strong>必填與選填的段落</strong>：<code>[host]</code>、<code>[updates]</code>、<code>[[entrypoint]]</code>、<code>[[log]]</code>、<code>[[accumulation]]</code>、<code>[[suspend]]</code>、<code>[[resume]]</code> 缺了都算格式錯；<code>[[filesystem]]</code>（<code>table_list</code> 的 <code>required=False</code>）、<code>[vars]</code>（預設是空表格）與 <code>[listen_audit]</code>（預設寫在 <code>[listen_audit]</code> 那一項）可以不寫。所以一份只想檢查入口的清單，其他段落也要寫出來；這是這支腳本的規格，換成自己的腳本時依需要放寬。</li>
<li><strong><code>[listen_audit]</code></strong>：整段沒寫時，預設檢查 TCP、沒有額外允許的連接埠，沒寫這一段的清單照樣檢查有沒有多開的 TCP 連接埠；寫了這一段就要寫出 <code>protocols</code>，寫成空陣列等於關掉這項檢查。</li>
<li><strong>錯誤的位置</strong>：入口、log、會累積的資料有名稱時，錯誤的位置寫成 <code>entrypoint[https]</code> 這種帶名稱的寫法，名稱缺了才退回索引，改清單的人照名稱找得到那一段。</li>
<li><strong>入口的 <code>enforced_by</code> 與 <code>[[entrypoint.verify]]</code></strong>：<code>enforced_by</code> 的每一層至少要有一筆 <code>[[entrypoint.verify]]</code>，每一筆 <code>verify</code> 的 <code>layer</code> 都要在 <code>enforced_by</code> 裡；比對用的是集合，同一層寫兩筆 <code>verify</code> 是允許的。宣告由某一層擋、卻沒寫那一層怎麼確認，或寫了確認方式、卻沒宣告由那一層擋，兩種都是格式錯（<code>test_enforced_layer_without_verify</code> 測宣告了卻沒有確認的那一種）。理由是清單宣告的防護層，每一層都要有一個確認的動作，欄位這樣設計的推導在清單寫成 TOML 的那篇概念文章的〈清單欄位的設計：檢查腳本要讀的與人要讀的〉。</li>
<li><strong>會累積的資料</strong>：<code>path</code> 可以寫一個字串或一個陣列，先統一成陣列；<code>limit_bytes</code> 與 <code>retention_days</code> 至少要有一個，沒有門檻的累積項目只能印出大小、判定不了 <code>WARN</code>；<code>oldest_command</code> 用來從資料庫查最舊一筆的時間，它跟保留天數一起才有意義，也要過白名單。</li>
<li><strong>暫停與恢復的步驟</strong>：<code>verify</code> 的型別依 <code>where</code> 而定，在主機上確認的是一個只讀探針，寫成寫在同一行的表格（inline table），在主機外確認的是一句人要看到的結果；步驟編號重複時判成格式錯，之後依編號排序，清單裡的寫出順序不影響執行順序。</li>
</ul>
<p>最後 <code>_check_builtin_commands</code> 把腳本自己會組的指令（接聽連接埠的查詢、timer 的兩個查詢、journal 的查詢；journal 是 <a href="/blog/linux/dotfile/knowledge-cards/journald/" data-link-title="journald（systemd-journald）" data-link-desc="用 journalctl 查不到重開機之前的紀錄、journal 佔掉多少磁碟不清楚、某個服務的 log 出現 Suppressed 訊息，或要判斷一筆紀錄該去 journal 還是 /var/log 底下的檔案找時">journald（systemd-journald）</a> 收下的紀錄）用清單的值組出來比對白名單，載入時就擋下 <code>--host=evil</code> 這種 timer 名稱。全部驗完才看 <code>v.errors</code>，有任何一條就丟出 <code>ManifestError</code>。</p>
<h2 id="執行指令的-runner-與時鐘">執行指令的 Runner 與時鐘</h2>
<p>腳本執行外部程式都經過 <code>Runner.run</code>：執行之前再比對一次白名單，以固定的環境變數與逾時執行，找不到指令與逾時都轉成一個結果、不丟例外。單元測試把它換成假的執行器，記錄每一次被呼叫的指令並回傳預先寫好的結果，所以測試不碰主機的狀態；取現在時間的 <code>now_utc</code> 也可以替換，讓跟時間有關的判定在測試裡有固定的答案。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ── 執行環境（測試時替換）─────────────────────────────────</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nd">@dataclass</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">class</span> <span class="nc">Result</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">exit</span><span class="p">:</span> <span class="nb">int</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">stdout</span><span class="p">:</span> <span class="nb">str</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">stderr</span><span class="p">:</span> <span class="nb">str</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">class</span> <span class="nc">Runner</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="s2">&#34;&#34;&#34;執行只讀指令。測試以假的 Runner 替換，記錄被呼叫的指令。&#34;&#34;&#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="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">command</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Result</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="c1"># 執行前再比對一次白名單：validate 之後到執行之間沒有別的路徑能塞進指令，這是第二道。</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">reason</span> <span class="o">=</span> <span class="n">readonly_violation</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">if</span> <span class="n">reason</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;拒絕執行非只讀指令：</span><span class="si">{</span><span class="n">reason</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">argv</span> <span class="o">=</span> <span class="n">shlex</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="n">env</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">,</span> <span class="n">LC_ALL</span><span class="o">=</span><span class="s2">&#34;C&#34;</span><span class="p">,</span> <span class="n">LANG</span><span class="o">=</span><span class="s2">&#34;C&#34;</span><span class="p">,</span> <span class="n">SYSTEMD_PAGER</span><span class="o">=</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">PAGER</span><span class="o">=</span><span class="s2">&#34;cat&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">argv</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="n">COMMAND_TIMEOUT_S</span><span class="p">,</span> <span class="n">env</span><span class="o">=</span><span class="n">env</span><span class="p">,</span> <span class="n">stdin</span><span class="o">=</span><span class="n">subprocess</span><span class="o">.</span><span class="n">DEVNULL</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">except</span> <span class="ne">FileNotFoundError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="k">return</span> <span class="n">Result</span><span class="p">(</span><span class="mi">127</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;找不到指令 </span><span class="si">{</span><span class="n">argv</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">except</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">TimeoutExpired</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="k">return</span> <span class="n">Result</span><span class="p">(</span><span class="mi">124</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;超過 </span><span class="si">{</span><span class="n">COMMAND_TIMEOUT_S</span><span class="si">}</span><span class="s2"> 秒沒有結束&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">return</span> <span class="n">Result</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">returncode</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">stdout</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="k">def</span> <span class="nf">now_utc</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">return</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">now</span><span class="p">(</span><span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span></span></span></code></pre></div><p>執行前那一次比對不通過時，<code>Runner</code> 丟出 <code>RuntimeError</code>，命令列把它當成格式錯以 exit 2 結束（單元測試 <code>test_runner_refuses_non_readonly</code> 與 <code>test_runtime_error_becomes_exit_2</code>，本篇沒有收錄）。正常情形下載入清單時已經擋下，這一道防的是之後有人加了一條不經過 <code>validate</code> 就執行指令的程式路徑。</p>
<p><code>subprocess.run</code> 收的是切好的參數串列，不帶 <code>shell=True</code>，指令不經過 shell。<code>shlex.split</code> 只切詞，不做 shell 會做的展開：<code>*</code> 這類 glob、<code>~</code>、<code>$VAR</code> 都原樣傳給工具。清單寫 <code>du -s /var/log/x/*</code> 時，<code>du</code> 收到的是字面的 <code>/var/log/x/*</code>（實跑：<code>du</code> 以 exit 1 結束，錯誤是 <code>No such file</code>）；要量多個檔案的大小，寫在 <code>[[accumulation]]</code> 的 <code>path</code>，那裡的 glob 由 <code>expand_path</code> 展開。環境變數從呼叫者的環境複製一份再蓋掉四個：<code>LC_ALL=C</code> 與 <code>LANG=C</code> 讓工具印英文，<code>REBOOT_REQUIRED_TEXT</code> 與 <code>enabled</code>、<code>active</code> 這類比對字串才對得上；<code>SYSTEMD_PAGER</code> 設成空字串、<code>PAGER=cat</code>，<code>systemctl</code> 與 <code>journalctl</code> 就不會開分頁程式等人按鍵；<code>stdin</code> 接 <code>/dev/null</code>，要讀輸入的工具立刻讀到結尾。<code>PATH</code> 沿用呼叫者的：裸工具名依 <code>PATH</code> 找檔案，<code>PATH</code> 前面的目錄放了一個同名的檔案時，執行的是那一個、不是系統目錄裡的那一支，白名單的執行檔路徑那一層只看指令字串，擋不住這一種。所以腳本要經由 sudo（它的 <code>secure_path</code> 會重設 <code>PATH</code>）或 systemd 的 unit 執行：systemd 啟動 unit 的程序時不繼承下指令那個 shell 的環境變數，<code>PATH</code> 用 systemd 自己的預設值（systemd.exec(5) 的環境變數一節；本篇未實跑），條件寫在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉白名單三層比對裡的「執行檔的路徑」那一層。</p>
<p>找不到指令時回 127、逾時回 124，跟 shell 與 GNU <code>timeout</code> 用的 exit code 相同，錯誤訊息放在 <code>stderr</code>。工具自己也可能以 127 或 124 結束，所以 <code>Checker</code> 的 <code>run_probe</code> 同時看 exit code 與 <code>stderr</code> 是否以 <code>找不到指令</code> 或 <code>超過</code> 開頭，分辨這是 <code>Runner</code> 造出來的結果、還是工具自己的 exit code。</p>
<h2 id="checker-的各項檢查">Checker 的各項檢查</h2>
<p><code>Checker</code> 依清單逐項執行檢查，每一項產生一行報告：狀態（<code>PASS</code>、<code>WARN</code>、<code>MANUAL</code>）、項目名稱、理由，有量到數字的項目另外帶著那個數字給結果檔用。維護時跑入口、log、會累積的資料、檔案系統與自動更新五類，暫停與恢復時只跑那個階段的步驟。各類檢查要量什麼、門檻怎麼定，在 <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>；輸出與 exit code 的讀法在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>
<h3 id="報告的一行與探針的判定">報告的一行與探針的判定</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ── 檢查 ───────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nd">@dataclass</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">class</span> <span class="nc">Line</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">status</span><span class="p">:</span> <span class="nb">str</span>  <span class="c1"># PASS / WARN / MANUAL</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">item</span><span class="p">:</span> <span class="nb">str</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="n">reason</span><span class="p">:</span> <span class="nb">str</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">    <span class="n">value</span><span class="p">:</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>  <span class="c1"># 量到的數字（探針值、累積的 bytes、可用 bytes）；結果檔拿它算與前一次的差</span></span></span></code></pre></div><p><code>Line</code> 的 <code>item</code> 是結果檔比對前後兩次的鍵（例如 <code>entrypoint/https:443/tcp</code>、<code>accumulation/journald</code>），由清單的名稱與欄位組出來；清單裡改了一個名稱，下一次的差異就印成舊名稱 <code>GONE</code>、新名稱 <code>NEW</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">Checker</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">manifest</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">runner</span><span class="p">:</span> <span class="n">Runner</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">clock</span><span class="p">:</span> <span class="n">Callable</span><span class="p">[[],</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="p">]</span> <span class="o">=</span> <span class="n">now_utc</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">m</span> <span class="o">=</span> <span class="n">manifest</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">runner</span> <span class="o">=</span> <span class="n">runner</span> <span class="ow">or</span> <span class="n">Runner</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">clock</span> <span class="o">=</span> <span class="n">clock</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">lines</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Line</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">def</span> <span class="nf">emit</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">status</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">item</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">reason</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">lines</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">Line</span><span class="p">(</span><span class="n">status</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1"># 探針：回傳 (ok, 理由, 量到的值)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">def</span> <span class="nf">run_probe</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">p</span><span class="p">:</span> <span class="n">Probe</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">bool</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">command</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">out</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="ow">in</span> <span class="p">(</span><span class="mi">124</span><span class="p">,</span> <span class="mi">127</span><span class="p">)</span> <span class="ow">and</span> <span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="o">.</span><span class="n">startswith</span><span class="p">((</span><span class="s2">&#34;找不到指令&#34;</span><span class="p">,</span> <span class="s2">&#34;超過&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_exit</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">!=</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_exit</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">（預期 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">expect_exit</span><span class="si">}</span><span class="s2">）</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span> <span class="ow">or</span> <span class="n">out</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">elif</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">p</span><span class="o">.</span><span class="n">ok_exit</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">（可接受 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">ok_exit</span><span class="si">}</span><span class="s2">）</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span> <span class="ow">or</span> <span class="n">out</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_stdout</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">out</span> <span class="o">!=</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;輸出 </span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="ow">or</span> <span class="s1">&#39;（空）&#39;</span><span class="si">}</span><span class="s2">（預期 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">expect_stdout</span><span class="si">!r}</span><span class="s2">）&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_contains</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_contains</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;輸出沒有 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">expect_contains</span><span class="si">!r}</span><span class="s2">：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">out</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_not_contains</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">p</span><span class="o">.</span><span class="n">expect_not_contains</span> <span class="ow">in</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;輸出含 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">expect_not_contains</span><span class="si">!r}</span><span class="s2">：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">out</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">measure</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="k">return</span> <span class="kc">True</span><span class="p">,</span> <span class="n">_brief</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="ow">or</span> <span class="sa">f</span><span class="s2">&#34;輸出為空，exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="n">value</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">measure</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">measure</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="k">except</span> <span class="ne">ValueError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">),</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="n">unit</span> <span class="o">=</span> <span class="s2">&#34;天&#34;</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">measure</span> <span class="o">==</span> <span class="s2">&#34;age_days&#34;</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="n">shown</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">value</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}{</span><span class="n">unit</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">measure</span> <span class="o">==</span> <span class="s2">&#34;age_days&#34;</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">value</span><span class="si">:</span><span class="s2">g</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">warn_above</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">value</span> <span class="o">&gt;</span> <span class="n">p</span><span class="o">.</span><span class="n">warn_above</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;量到 </span><span class="si">{</span><span class="n">shown</span><span class="si">}</span><span class="s2">，超過門檻 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">warn_above</span><span class="si">:</span><span class="s2">g</span><span class="si">}{</span><span class="n">unit</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">value</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">return</span> <span class="kc">True</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;量到 </span><span class="si">{</span><span class="n">shown</span><span class="si">}</span><span class="s2">（門檻 </span><span class="si">{</span><span class="n">p</span><span class="o">.</span><span class="n">warn_above</span><span class="si">:</span><span class="s2">g</span><span class="si">}{</span><span class="n">unit</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">,</span> <span class="n">value</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">def</span> <span class="nf">measure</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">kind</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">stdout</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">        <span class="k">if</span> <span class="n">kind</span> <span class="o">==</span> <span class="s2">&#34;lines&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">            <span class="k">return</span> <span class="nb">float</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="mi">1</span> <span class="k">for</span> <span class="n">ln</span> <span class="ow">in</span> <span class="n">stdout</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="n">text</span> <span class="o">=</span> <span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">        <span class="k">if</span> <span class="s2">&#34;=&#34;</span> <span class="ow">in</span> <span class="n">text</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">            <span class="n">text</span> <span class="o">=</span> <span class="n">text</span><span class="o">.</span><span class="n">rsplit</span><span class="p">(</span><span class="s2">&#34;=&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="k">if</span> <span class="n">kind</span> <span class="o">==</span> <span class="s2">&#34;value&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">                <span class="k">return</span> <span class="nb">float</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">            <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">                <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;輸出不是數字：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">stdout</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span> <span class="kn">from</span> <span class="bp">None</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">if</span> <span class="n">text</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="s2">&#34;n/a&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="c1"># systemctl show 的時間屬性沒有值時印空值或 n/a（timer 從未觸發就是這樣）。</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">            <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;沒有時間值（</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">stdout</span><span class="p">)</span> <span class="ow">or</span> <span class="s1">&#39;空的輸出&#39;</span><span class="si">}</span><span class="s2">；timer 從未觸發過時就是這樣）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">        <span class="n">ts</span> <span class="o">=</span> <span class="n">parse_time</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="k">if</span> <span class="n">ts</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">            <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;輸出不是時間值：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">stdout</span><span class="p">)</span> <span class="ow">or</span> <span class="s1">&#39;（空）&#39;</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">        <span class="k">return</span> <span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">clock</span><span class="p">()</span> <span class="o">-</span> <span class="n">ts</span><span class="p">)</span><span class="o">.</span><span class="n">total_seconds</span><span class="p">()</span> <span class="o">/</span> <span class="mi">86400</span></span></span></code></pre></div><p><code>run_probe</code> 依固定的順序判定一個探針，第一個不符的條件決定那一項的理由：<code>Runner</code> 造出來的找不到指令或逾時；exit 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>stderr</code>（沒有時附輸出）的開頭，<code>WARN</code> 那一行就帶著工具自己的錯誤訊息。</p>
<p><code>measure</code> 有三種量法。<code>lines</code> 數非空白的行，用在列出符合條件的紀錄、每筆一行的指令；<code>value</code> 與 <code>age_days</code> 先取最後一個 <code>=</code> 之後的字串，因為 <code>systemctl show -p NRestarts</code> 這類指令印的是 <code>NRestarts=0</code>。<code>age_days</code> 遇到空值或 <code>n/a</code> 時的理由是「沒有時間值」，跟解析失敗的「輸出不是時間值」分開：「沒有時間值」代表 timer 從來沒有觸發過，是主機的狀態；「輸出不是時間值」代表腳本讀不懂這個格式，是腳本要修的地方。單獨的 <code>0</code> 不在「沒有時間值」的條件裡，<code>measure(&quot;age_days&quot;, &quot;LastTriggerUSec=0&quot;)</code> 得到「輸出不是時間值」（實跑）。單元測試 <code>test_alert_age_days_never_triggered</code>（本篇沒有收錄）斷言 timer 從未觸發時的理由裡有「沒有時間值」。門檻的比較是「大於才 <code>WARN</code>」，量到的值等於門檻時是 <code>PASS</code>（<code>test_alert_value_at_threshold_passes</code>），清單裡的 <code>warn_above = 0</code> 因此讀成「出現任何一筆就 <code>WARN</code>」。只有量了數字的探針回傳那個值，其餘回傳 <code>None</code>。</p>
<h3 id="入口與接聽中的連接埠">入口與接聽中的連接埠</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_entrypoints</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="n">listening</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">e</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span> <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;entrypoint&#34;</span><span class="p">])</span> <span class="ow">or</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">][</span><span class="s2">&#34;protocols&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">listening</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">listening_sockets</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;entrypoint&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;entrypoint/</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;port&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;protocol&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">if</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="k">if</span> <span class="n">listening</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;讀不到監聽中的 socket（ss 失敗）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                    <span class="n">addrs</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">({</span><span class="n">a</span> <span class="k">for</span> <span class="p">(</span><span class="n">proto</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">port</span><span class="p">)</span> <span class="ow">in</span> <span class="n">listening</span> <span class="k">if</span> <span class="n">proto</span> <span class="o">==</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;protocol&#34;</span><span class="p">]</span> <span class="ow">and</span> <span class="n">port</span> <span class="o">==</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;port&#34;</span><span class="p">]})</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                    <span class="k">if</span> <span class="n">addrs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                        <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;有程序在聽（</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">addrs</span><span class="p">)</span><span class="si">}</span><span class="s2">），exposure=</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;exposure&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                        <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;沒有程序在聽這個連接埠，exposure=</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;exposure&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;MANUAL&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;主機外的入口，exposure=</span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s1">&#39;exposure&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">，逐層確認見下方&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">for</span> <span class="n">vf</span> <span class="ow">in</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="n">vitem</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">vf</span><span class="p">[</span><span class="s1">&#39;layer&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">if</span> <span class="n">vf</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                    <span class="n">ok</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">run_probe</span><span class="p">(</span><span class="n">vf</span><span class="p">[</span><span class="s2">&#34;probe&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span> <span class="k">if</span> <span class="n">ok</span> <span class="k">else</span> <span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">vitem</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;MANUAL&#34;</span><span class="p">,</span> <span class="n">vitem</span><span class="p">,</span> <span class="n">_manual_text</span><span class="p">(</span><span class="n">vf</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">],</span> <span class="n">vf</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;console_path&#34;</span><span class="p">),</span> <span class="n">vf</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;manual_command&#34;</span><span class="p">),</span> <span class="n">vf</span><span class="p">[</span><span class="s2">&#34;expect&#34;</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">if</span> <span class="n">listening</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">audit_listening</span><span class="p">(</span><span class="n">listening</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">def</span> <span class="nf">listening_sockets</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">set</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">]]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">LISTEN_COMMAND</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="n">socks</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">splitlines</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="n">parts</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">5</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="n">proto</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="n">local</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="n">addr</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">port</span> <span class="o">=</span> <span class="n">local</span><span class="o">.</span><span class="n">rpartition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">port</span><span class="o">.</span><span class="n">isdigit</span><span class="p">()</span> <span class="ow">or</span> <span class="n">proto</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">PROTOCOL_VALUES</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">            <span class="n">socks</span><span class="o">.</span><span class="n">add</span><span class="p">((</span><span class="n">proto</span><span class="p">,</span> <span class="n">addr</span><span class="p">,</span> <span class="nb">int</span><span class="p">(</span><span class="n">port</span><span class="p">)))</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="k">return</span> <span class="n">socks</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="k">def</span> <span class="nf">audit_listening</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">listening</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">]])</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="n">la</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;listen_audit&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">la</span><span class="p">[</span><span class="s2">&#34;protocols&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="n">known</span> <span class="o">=</span> <span class="p">{(</span><span class="n">e</span><span class="p">[</span><span class="s2">&#34;protocol&#34;</span><span class="p">],</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;port&#34;</span><span class="p">])</span> <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;entrypoint&#34;</span><span class="p">]</span> <span class="k">if</span> <span class="n">e</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">        <span class="n">known</span> <span class="o">|=</span> <span class="p">{(</span><span class="n">a</span><span class="p">[</span><span class="s2">&#34;protocol&#34;</span><span class="p">],</span> <span class="n">a</span><span class="p">[</span><span class="s2">&#34;port&#34;</span><span class="p">])</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">la</span><span class="p">[</span><span class="s2">&#34;allow&#34;</span><span class="p">]}</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="nb">any</span><span class="p">(</span><span class="n">p</span> <span class="ow">in</span> <span class="n">la</span><span class="p">[</span><span class="s2">&#34;protocols&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="ow">in</span> <span class="n">listening</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="c1"># ss 成功卻一個都沒列出：多半是看不到主機的網路（PrivateNetwork=yes 的沙箱），跟「沒有多開」分不開。</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="s2">&#34;listen-audit&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;ss 沒有列出任何 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">la</span><span class="p">[</span><span class="s1">&#39;protocols&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2"> 的監聽 socket，判斷不了有沒有多開的連接埠（在 PrivateNetwork=yes 這類隔離網路的環境裡執行時就是這樣）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="n">extra</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">({(</span><span class="n">p</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">port</span><span class="p">)</span> <span class="k">for</span> <span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">port</span><span class="p">)</span> <span class="ow">in</span> <span class="n">listening</span> <span class="k">if</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">la</span><span class="p">[</span><span class="s2">&#34;protocols&#34;</span><span class="p">]</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">_is_loopback</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="ow">and</span> <span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">port</span><span class="p">)</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">known</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">extra</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="s2">&#34;listen-audit&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;loopback 以外只有清單列出的連接埠在聽（</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">la</span><span class="p">[</span><span class="s1">&#39;protocols&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">        <span class="k">for</span> <span class="n">p</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">port</span> <span class="ow">in</span> <span class="n">extra</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;listen-audit/</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">p</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">a</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s2"> 在聽，但不在 [[entrypoint]] 或 [listen_audit].allow；用 ss -lnp</span><span class="si">{</span><span class="n">p</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2"> 查是哪個程序&#34;</span><span class="p">)</span></span></span></code></pre></div><p><code>ss</code> 在一次維護檢查裡只執行一次，入口的判定與多開連接埠的稽核共用它的結果。<code>listening_sockets</code> 讀 <code>ss -H -l -n -t -u</code> 的每一行：第一欄是協定（<code>tcp</code>、<code>udp</code>），第五欄是本機的位址加連接埠；用 <code>rpartition(&quot;:&quot;)</code> 從右邊切，<code>[::]:22</code> 與 <code>*:8765</code> 這種含冒號或萬用字元的位址也切得出連接埠。<code>ss</code> 的 exit code 不是 0 時回傳 <code>None</code>，主機上的每個入口都印成「讀不到」的 <code>WARN</code>，不判成沒有程式在接聽。</p>
<p>主機上的入口只要有任何一個位址在接聽那個協定與連接埠，就印 <code>PASS</code>，理由裡列出接聽的位址與清單寫的 <code>exposure</code>。腳本不拿位址去比對 <code>exposure</code>：一個 <code>cdn-only</code> 的入口接聽在 <code>0.0.0.0</code>，對外的範圍由防火牆與雲端規則決定，那幾層的確認寫在 <code>[[entrypoint.verify]]</code>，在主機上的逐層跑探針，在主機外的印成 <code>MANUAL</code>。</p>
<p><code>audit_listening</code> 找清單沒有列出的連接埠：清單已經列出的連接埠是主機上的入口加 <code>[listen_audit].allow</code>，loopback 位址上的接聽不算多開，其餘每個接聽的位址加連接埠各印一行 <code>WARN</code>，項目名是 <code>listen-audit/&lt;連接埠&gt;/&lt;協定&gt;</code>，理由附上查出是哪個程式的 <code>ss</code> 指令。同一個連接埠同時接聽 <code>0.0.0.0</code> 與 <code>[::]</code> 時印兩行，兩行的項目名相同，結果檔只留後一行（〈host-check.py（3fc41c0）的已知限制與重現方式〉）。<code>ss</code> 成功而一個要稽核的協定的 socket 都沒有時印 <code>WARN</code>：在 <code>PrivateNetwork=yes</code> 的沙箱裡，<code>ss</code> 看不到主機的網路，只看結果分不出「沒有多開」與「什麼都看不到」，實跑的結果在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉systemd 屬性表的 <code>PrivateNetwork=yes</code> 那一列。<code>test_extra_port_warns_and_missing_listener_warns</code> 給一份含 <code>0.0.0.0:3306</code> 與 <code>127.0.0.1:631</code> 的輸出，斷言 3306 印成 <code>WARN</code>、631 不出現，沒有程式接聽的 22 印成 <code>WARN</code>；<code>test_no_socket_listed_warns</code> 給空的輸出，斷言稽核那一行是 <code>WARN</code>。</p>
<h3 id="定期讀的-log">定期讀的 log</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_logs</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="k">for</span> <span class="n">lg</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;log&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;log/</span><span class="si">{</span><span class="n">lg</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">kind</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">target</span> <span class="o">=</span> <span class="n">lg</span><span class="p">[</span><span class="s2">&#34;source&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;每 </span><span class="si">{</span><span class="n">lg</span><span class="p">[</span><span class="s1">&#39;interval_days&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 天讀一次：</span><span class="si">{</span><span class="n">lg</span><span class="p">[</span><span class="s1">&#39;manual_command&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">if</span> <span class="n">kind</span> <span class="o">==</span> <span class="s2">&#34;file&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                    <span class="n">st</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">stat</span><span class="p">(</span><span class="n">target</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">target</span><span class="p">,</span> <span class="s2">&#34;rb&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                        <span class="k">pass</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">except</span> <span class="ne">FileNotFoundError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2"> 不存在；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                    <span class="k">continue</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">except</span> <span class="ne">PermissionError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2"> 讀不到（權限，用 sudo 執行）；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                    <span class="k">continue</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">st</span><span class="o">.</span><span class="n">st_size</span><span class="p">)</span><span class="si">}</span><span class="s2">；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">journal_command</span><span class="p">(</span><span class="n">target</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">==</span> <span class="mi">127</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="si">}</span><span class="s2">；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">                <span class="k">elif</span> <span class="ow">not</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;journal 裡沒有 </span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2"> 的紀錄（unit 沒跑過，或沒有權限讀 journal）；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;最新一筆：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">(),</span> <span class="mi">90</span><span class="p">)</span><span class="si">}</span><span class="s2">；</span><span class="si">{</span><span class="n">hint</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="k">for</span> <span class="n">al</span> <span class="ow">in</span> <span class="n">lg</span><span class="p">[</span><span class="s2">&#34;alerts&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                <span class="n">ok</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">run_probe</span><span class="p">(</span><span class="n">al</span><span class="p">[</span><span class="s2">&#34;probe&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span> <span class="k">if</span> <span class="n">ok</span> <span class="k">else</span> <span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">al</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span></span></span></code></pre></div><p>讀 log 的內容是人的工作，腳本只確認 log 在那裡、讀得到，並在每一行的理由後面附上清單寫的讀取間隔與讀的指令，維護的人照那一行去讀。檔案型的 log 除了 <code>os.stat</code>，還實際開一次檔案：<code>stat</code> 只要求能進入上層目錄，檔案本身的讀取權限要開檔才知道。journal 型的 log 讀最新的一筆；輸出是空的有兩種成因（那個 unit 沒有跑過、執行者沒有讀 journal 的權限），腳本分不開，理由裡兩種都寫出來。這條路徑除了 127 不看 exit code：逾時（124）或 <code>journalctl</code> 以非零結束而沒有輸出時，印的也是同一句理由。log 底下的計數門檻（<code>[[log.alert]]</code>）各自是一個探針，照上面的 <code>run_probe</code> 判定。<code>test_missing_file_log_warns</code> 斷言檔案不存在的 log 印成 <code>WARN</code>。</p>
<h3 id="會累積的資料">會累積的資料</h3>
<p>這一段檢查清單的 <code>[[accumulation]]</code>，每一筆的欄位是 <code>name</code>、<code>path</code>（一個或多個路徑，可以是 glob 樣式）、<code>missing_ok</code>、<code>limit_bytes</code>、<code>retention_days</code>、<code>oldest_command</code>、<code>purge_command</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_accumulations</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="k">for</span> <span class="n">ac</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;accumulation&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;accumulation/</span><span class="si">{</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">files</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="n">denied</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">for</span> <span class="n">pat</span> <span class="ow">in</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;paths&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="n">found</span><span class="p">,</span> <span class="n">blocked</span> <span class="o">=</span> <span class="n">expand_path</span><span class="p">(</span><span class="n">pat</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="n">files</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">found</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="k">if</span> <span class="n">blocked</span> <span class="ow">and</span> <span class="n">blocked</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">denied</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                    <span class="n">denied</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">blocked</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">files</span> <span class="ow">and</span> <span class="n">denied</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="c1"># 讀不到不等於不存在：missing_ok 只管真的不存在的路徑。</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;讀不到 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">denied</span><span class="p">)</span><span class="si">}</span><span class="s2">（權限，用 sudo 執行），量不到 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;paths&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">files</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                <span class="k">if</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;missing_ok&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;路徑不存在，算 0 bytes（</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;paths&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;路徑不存在：</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;paths&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="n">total</span><span class="p">,</span> <span class="n">oldest</span><span class="p">,</span> <span class="n">unreadable</span> <span class="o">=</span> <span class="n">measure_paths</span><span class="p">(</span><span class="nb">sorted</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">files</span><span class="p">)))</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="n">problems</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="n">facts</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">total</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="k">if</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;limit_bytes&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">                <span class="n">facts</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;／上限 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;limit_bytes&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">                <span class="k">if</span> <span class="n">total</span> <span class="o">&gt;</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;limit_bytes&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                    <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;大小 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">total</span><span class="p">)</span><span class="si">}</span><span class="s2"> 超過上限 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;limit_bytes&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="k">if</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;retention_days&#34;</span><span class="p">]</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">                <span class="k">if</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;oldest_command&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">                    <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">ac</span><span class="p">[</span><span class="s2">&#34;oldest_command&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">                    <span class="n">ts</span> <span class="o">=</span> <span class="n">parse_time</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">())</span> <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">==</span> <span class="mi">0</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">                    <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">                        <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;oldest_command exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                    <span class="k">elif</span> <span class="n">ts</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">                        <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;oldest_command 的輸出不是時間值：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">                    <span class="n">oldest_ts</span> <span class="o">=</span> <span class="n">ts</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">                    <span class="n">label</span> <span class="o">=</span> <span class="s2">&#34;最舊一筆&#34;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">                    <span class="n">oldest_ts</span> <span class="o">=</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">fromtimestamp</span><span class="p">(</span><span class="n">oldest</span><span class="p">,</span> <span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span> <span class="k">if</span> <span class="n">oldest</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">                    <span class="n">label</span> <span class="o">=</span> <span class="s2">&#34;最舊的檔案&#34;</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">                <span class="k">if</span> <span class="n">oldest_ts</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">                    <span class="n">age</span> <span class="o">=</span> <span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">clock</span><span class="p">()</span> <span class="o">-</span> <span class="n">oldest_ts</span><span class="p">)</span><span class="o">.</span><span class="n">total_seconds</span><span class="p">()</span> <span class="o">/</span> <span class="mi">86400</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">                    <span class="n">facts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">age</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}</span><span class="s2"> 天前／保留 </span><span class="si">{</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;retention_days&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 天&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">                    <span class="k">if</span> <span class="n">age</span> <span class="o">&gt;</span> <span class="n">ac</span><span class="p">[</span><span class="s2">&#34;retention_days&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">                        <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s2">是 </span><span class="si">{</span><span class="n">age</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}</span><span class="s2"> 天前，超過保留期 </span><span class="si">{</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;retention_days&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 天&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">                    <span class="n">facts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s2">：沒有資料&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">            <span class="k">if</span> <span class="n">unreadable</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">                <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">unreadable</span><span class="si">}</span><span class="s2"> 個路徑讀不到（權限，用 sudo 執行）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">            <span class="k">if</span> <span class="n">denied</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">                <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;讀不到 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">denied</span><span class="p">)</span><span class="si">}</span><span class="s2">（權限，用 sudo 執行），那些路徑沒有算進大小&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="k">if</span> <span class="n">problems</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;；&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">problems</span><span class="p">)</span> <span class="o">+</span> <span class="sa">f</span><span class="s2">&#34;。清理做法（不會自動執行）：</span><span class="si">{</span><span class="n">ac</span><span class="p">[</span><span class="s1">&#39;purge_command&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">total</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;，&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">facts</span><span class="p">),</span> <span class="n">total</span><span class="p">)</span></span></span></code></pre></div><p>一個累積項目的路徑可能是幾個 glob 樣式，每個樣式交給下面〈路徑的展開與大小的量測：expand_path 與 measure_paths〉的 <code>expand_path</code> 展開；展開時被權限擋下的目錄另外記在 <code>denied</code>。判定先分三種情形：一個檔案都沒找到而有目錄讀不到，印「讀不到」的 <code>WARN</code>；一個檔案都沒找到、也沒有目錄讀不到，才是路徑不存在，這時才看 <code>missing_ok</code>；找到檔案，量大小與時間。<code>missing_ok</code> 只管沒有被權限擋下的路徑（上層目錄列得出內容，或路徑根本不存在），讀不到的目錄即使設了 <code>missing_ok</code> 也印 <code>WARN</code>（<code>test_unreadable_glob_with_missing_ok_does_not_pass</code>）：資料可能就在那個讀不到的目錄裡，判成「不存在、算 0」會讓一個寫滿磁碟的目錄印成 <code>PASS</code>。</p>
<p>保留天數有兩種量法：寫了 <code>oldest_command</code> 時，執行它查資料最舊一筆的時間（例如 SQLite 裡某個時間欄位的最小值），沒有寫時取找到的檔案裡最舊的修改時間。<code>oldest_command</code> 成功而輸出是空的（資料表沒有資料）時，理由寫「沒有資料」，不算問題；輸出是 <code>n/a</code> 或 <code>0</code> 時，<code>parse_time</code> 回 <code>None</code> 而輸出不是空的，記成「oldest_command 的輸出不是時間值」的問題、那一項印 <code>WARN</code>（實跑）。超過上限、超過保留期、讀不到的路徑各記一條問題，任何一條都讓這一項印成 <code>WARN</code>，理由後面附上清單的 <code>purge_command</code>；<code>purge_command</code> 只印不執行，<code>test_accumulation_over_limit_warns</code> 斷言 <code>WARN</code> 那一行印出了清理指令，而要被清理的檔案仍然存在。這一項的 <code>value</code> 是總位元組，下一次執行時印成 <code>DELTA</code>。</p>
<h3 id="檔案系統的剩餘空間">檔案系統的剩餘空間</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_filesystems</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="k">for</span> <span class="n">fs</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;filesystem&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;filesystem/</span><span class="si">{</span><span class="n">fs</span><span class="p">[</span><span class="s1">&#39;name&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                <span class="n">st</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">statvfs</span><span class="p">(</span><span class="n">fs</span><span class="p">[</span><span class="s2">&#34;mount&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">except</span> <span class="ne">OSError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;讀不到 </span><span class="si">{</span><span class="n">fs</span><span class="p">[</span><span class="s1">&#39;mount&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">free</span> <span class="o">=</span> <span class="n">st</span><span class="o">.</span><span class="n">f_bavail</span> <span class="o">*</span> <span class="n">st</span><span class="o">.</span><span class="n">f_frsize</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">size</span> <span class="o">=</span> <span class="n">st</span><span class="o">.</span><span class="n">f_blocks</span> <span class="o">*</span> <span class="n">st</span><span class="o">.</span><span class="n">f_frsize</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="n">text</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">fs</span><span class="p">[</span><span class="s1">&#39;mount&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 剩 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">free</span><span class="p">)</span><span class="si">}</span><span class="s2">／共 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">size</span><span class="p">)</span><span class="si">}</span><span class="s2">（下限 </span><span class="si">{</span><span class="n">_human</span><span class="p">(</span><span class="n">fs</span><span class="p">[</span><span class="s1">&#39;min_free_bytes&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">）&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span> <span class="k">if</span> <span class="n">free</span> <span class="o">&gt;=</span> <span class="n">fs</span><span class="p">[</span><span class="s2">&#34;min_free_bytes&#34;</span><span class="p">]</span> <span class="k">else</span> <span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="n">text</span><span class="p">,</span> <span class="n">free</span><span class="p">)</span></span></span></code></pre></div><p><code>os.statvfs</code> 讀檔案系統的區塊計數，不執行 <code>df</code>。剩餘空間取 <code>f_bavail</code>（一般帳號可用的區塊數），不取 <code>f_bfree</code>（全部空閒的區塊數）：ext4 這類檔案系統預留給 root 的區塊算在 <code>f_bfree</code> 裡，而服務多半以一般帳號寫資料，<code>f_bavail</code> 用完時那些服務就寫不進去。區塊數乘上 <code>f_frsize</code>（區塊的大小）得到位元組。這一項的 <code>value</code> 是可用位元組（傳給 <code>emit</code> 的最後一個參數 <code>free</code>），兩次之間少了多少，差異段印得出來。可用空間幾乎每一次執行都不同，所以這一項幾乎每次都印 <code>DELTA</code>，要讀的是括號裡的差值。<code>test_filesystem_value_is_free_bytes</code> 只斷言結果檔裡這一項的值是數字、而且大於 0。</p>
<h3 id="自動更新的排程設定與重開機">自動更新的排程、設定與重開機</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_updates</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="n">up</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="s2">&#34;updates&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">for</span> <span class="n">verb</span><span class="p">,</span> <span class="n">want</span> <span class="ow">in</span> <span class="n">UPDATE_TIMER_VERBS</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">timer_command</span><span class="p">(</span><span class="n">verb</span><span class="p">,</span> <span class="n">up</span><span class="p">[</span><span class="s2">&#34;timer&#34;</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="n">got</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="ow">or</span> <span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span> <span class="ow">or</span> <span class="sa">f</span><span class="s2">&#34;exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span> <span class="k">if</span> <span class="n">got</span> <span class="o">==</span> <span class="n">want</span> <span class="k">else</span> <span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;updates/</span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;timer&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">verb</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">got</span><span class="si">}</span><span class="s2">（預期 </span><span class="si">{</span><span class="n">want</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">merged</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">read_files</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">for</span> <span class="n">pat</span> <span class="ow">in</span> <span class="n">up</span><span class="p">[</span><span class="s2">&#34;config_files&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">glob</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="n">pat</span><span class="p">))</span> <span class="k">if</span> <span class="n">glob</span><span class="o">.</span><span class="n">has_magic</span><span class="p">(</span><span class="n">pat</span><span class="p">)</span> <span class="k">else</span> <span class="p">[</span><span class="n">pat</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">if</span> <span class="ow">not</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">isfile</span><span class="p">(</span><span class="n">path</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                    <span class="k">continue</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                <span class="n">cp</span> <span class="o">=</span> <span class="n">configparser</span><span class="o">.</span><span class="n">ConfigParser</span><span class="p">(</span><span class="n">interpolation</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                    <span class="n">cp</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                <span class="k">except</span> <span class="p">(</span><span class="n">configparser</span><span class="o">.</span><span class="n">Error</span><span class="p">,</span> <span class="ne">OSError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</span><span class="p">)</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                    <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="s2">&#34;updates/config&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2"> 讀不到或格式錯：</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                    <span class="k">continue</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="n">read_files</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">if</span> <span class="n">cp</span><span class="o">.</span><span class="n">has_section</span><span class="p">(</span><span class="s2">&#34;commands&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                    <span class="n">merged</span><span class="o">.</span><span class="n">update</span><span class="p">({</span><span class="n">k</span><span class="p">:</span> <span class="n">v</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">cp</span><span class="o">.</span><span class="n">items</span><span class="p">(</span><span class="s2">&#34;commands&#34;</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">read_files</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="s2">&#34;updates/config&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;找不到 dnf-automatic 的設定檔（</span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;config_files&#39;</span><span class="p">])</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="n">wrong</span> <span class="o">=</span> <span class="p">{</span><span class="n">k</span><span class="p">:</span> <span class="n">merged</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">k</span><span class="p">)</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">up</span><span class="p">[</span><span class="s2">&#34;expect&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">items</span><span class="p">()</span> <span class="k">if</span> <span class="n">merged</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">k</span><span class="p">)</span> <span class="o">!=</span> <span class="n">v</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="s2">&#34;updates/config&#34;</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">if</span> <span class="n">wrong</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;；&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;[commands] </span><span class="si">{</span><span class="n">k</span><span class="si">}</span><span class="s2"> = </span><span class="si">{</span><span class="n">g</span><span class="si">!r}</span><span class="s2">（預期 </span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;expect&#39;</span><span class="p">][</span><span class="n">k</span><span class="p">]</span><span class="si">!r}</span><span class="s2">）&#34;</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">g</span> <span class="ow">in</span> <span class="n">wrong</span><span class="o">.</span><span class="n">items</span><span class="p">())</span> <span class="o">+</span> <span class="sa">f</span><span class="s2">&#34;，讀了 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">read_files</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">k</span><span class="si">}</span><span class="s2"> = </span><span class="si">{</span><span class="n">v</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">up</span><span class="p">[</span><span class="s2">&#34;expect&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">items</span><span class="p">())</span> <span class="o">+</span> <span class="sa">f</span><span class="s2">&#34;（mode </span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;mode&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">，讀了 </span><span class="si">{</span><span class="s1">&#39;, &#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">read_files</span><span class="p">)</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="k">if</span> <span class="n">up</span><span class="p">[</span><span class="s2">&#34;reboot_check&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="n">r</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runner</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">up</span><span class="p">[</span><span class="s2">&#34;reboot_check&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="s2">&#34;updates/reboot&#34;</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="s2">&#34;不需要重開機&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">            <span class="k">elif</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">==</span> <span class="mi">1</span> <span class="ow">and</span> <span class="n">REBOOT_REQUIRED_TEXT</span> <span class="ow">in</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span> <span class="o">+</span> <span class="n">r</span><span class="o">.</span><span class="n">stderr</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;更新之後需要重開機（</span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;reboot_check&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">）：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">(),</span> <span class="mi">120</span><span class="p">)</span><span class="si">}</span><span class="s2">；deploy.sh 不自動重開，排時間 sudo systemctl reboot&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="k">elif</span> <span class="n">r</span><span class="o">.</span><span class="n">exit</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;reboot_check&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> exit 1，但輸出沒有 </span><span class="si">{</span><span class="n">REBOOT_REQUIRED_TEXT</span><span class="si">!r}</span><span class="s2">，判斷不了要不要重開機：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span> <span class="ow">or</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">)</span> <span class="ow">or</span> <span class="s1">&#39;（沒有輸出）&#39;</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">up</span><span class="p">[</span><span class="s1">&#39;reboot_check&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> exit </span><span class="si">{</span><span class="n">r</span><span class="o">.</span><span class="n">exit</span><span class="si">}</span><span class="s2">：</span><span class="si">{</span><span class="n">_brief</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">stderr</span> <span class="ow">or</span> <span class="n">r</span><span class="o">.</span><span class="n">stdout</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div><p>自動更新分三項檢查。清單的 <code>[updates].mode</code> 不在其中：它只出現在 <code>updates/config</code> 那一行 <code>PASS</code> 的理由裡（<code>mode {up['mode']}</code>），腳本不拿它比對任何設定或輸出。timer 的兩個查詢比對 <code>systemctl</code> 印的整個字串：<code>is-enabled</code> 要印 <code>enabled</code>、<code>is-active</code> 要印 <code>active</code>，輸出是空的時，理由改用 <code>stderr</code> 或 exit code，<code>WARN</code> 那一行看得到 <code>systemctl</code> 回了什麼。</p>
<p>設定檔可以寫成 glob 樣式（主設定檔，加上放在附加目錄裡、依檔名順序蓋過主設定的片段檔，也就是 drop-in；這是 dnf 自己的設定目錄，不改主設定檔、用片段蓋過其中的值這個做法，跟 <a href="/blog/linux/dotfile/knowledge-cards/systemd-drop-in/" data-link-title="systemd drop-in（不改原檔的設定疊加）" data-link-desc="想覆寫套件裝的 systemd unit 又不被升級蓋掉、一次對所有 service 套一條設定、或搞不清 systemctl edit 改了哪個檔時讀">systemd drop-in（不改原檔的設定疊加）</a> 相同），展開之後依檔名排序、依序讀，<code>[commands]</code> 段的值後讀到的蓋過先讀到的。<code>ConfigParser</code> 關掉 interpolation：預設的寫法會把值裡的 <code>%</code> 當成變數替換的語法，設定值含一個不成語法的 <code>%</code> 時，<code>cp.items</code> 取值就丟出例外，而取值的那一行 <code>merged.update({k: v.strip() for k, v in cp.items(&quot;commands&quot;)})</code> 不在 <code>try</code> 裡（<code>try</code> 只包住 <code>cp.read</code>），整支腳本會中止。清單的 <code>expect</code> 只列要確認的鍵，設定檔裡其他的鍵不比對；理由裡列出讀了哪些檔案，比對錯了的時候，維護的人知道值是從哪一份讀來的。</p>
<p><code>reboot_check</code> 的 exit 1 有兩種成因：dnf 判定要重開機，以及 dnf 自己出錯（在唯讀的檔案系統上寫不了 log）。腳本因此同時看輸出裡有沒有 <code>Reboot is required</code>，有才印「需要重開機」，沒有時印「判斷不了」並附上 dnf 的錯誤訊息；兩種成因的實跑結果與沙箱的設定在清單寫成 TOML 的那篇概念文章的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。<code>test_reboot_required_message_warns_reboot</code>、<code>test_reboot_exit1_without_message_is_not_reboot</code> 與 <code>test_reboot_exit0_passes</code> 各給一種輸出，斷言三種理由。理由裡的 <code>deploy.sh</code> 是私有專案部署那台主機的腳本，它裝好自動更新、不自動重開機。</p>
<h3 id="暫停與恢復的步驟以及依-phase-選檢查">暫停與恢復的步驟，以及依 phase 選檢查</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl">    <span class="k">def</span> <span class="nf">check_phase</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">phase</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        <span class="k">for</span> <span class="n">st</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">m</span><span class="p">[</span><span class="n">phase</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">phase</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">st</span><span class="p">[</span><span class="s1">&#39;step&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">st</span><span class="p">[</span><span class="s1">&#39;resource&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">billing</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;停機計費：額度內 </span><span class="si">{</span><span class="n">st</span><span class="p">[</span><span class="s1">&#39;billed_when_stopped&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">，超出額度 </span><span class="si">{</span><span class="n">st</span><span class="p">[</span><span class="s1">&#39;billed_when_stopped_beyond_free&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">（</span><span class="si">{</span><span class="n">st</span><span class="p">[</span><span class="s1">&#39;billing_basis&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">）&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="n">action</span> <span class="o">=</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;manual_command&#34;</span><span class="p">]</span> <span class="ow">or</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;console_path&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">if</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;host&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;MANUAL&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;在主機上做（腳本不執行）：</span><span class="si">{</span><span class="n">action</span><span class="si">}</span><span class="s2">；</span><span class="si">{</span><span class="n">billing</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="n">ok</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">run_probe</span><span class="p">(</span><span class="n">st</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;PASS&#34;</span> <span class="k">if</span> <span class="n">ok</span> <span class="k">else</span> <span class="s2">&#34;WARN&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s2">/verify&#34;</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="bp">self</span><span class="o">.</span><span class="n">emit</span><span class="p">(</span><span class="s2">&#34;MANUAL&#34;</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="n">_manual_text</span><span class="p">(</span><span class="n">st</span><span class="p">[</span><span class="s2">&#34;where&#34;</span><span class="p">],</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;console_path&#34;</span><span class="p">],</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;manual_command&#34;</span><span class="p">],</span> <span class="n">st</span><span class="p">[</span><span class="s2">&#34;verify&#34;</span><span class="p">])</span> <span class="o">+</span> <span class="sa">f</span><span class="s2">&#34;；</span><span class="si">{</span><span class="n">billing</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">phase</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">&#34;maintain&#34;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">Line</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">if</span> <span class="n">phase</span> <span class="o">==</span> <span class="s2">&#34;maintain&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_entrypoints</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_logs</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_accumulations</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_filesystems</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_updates</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">check_phase</span><span class="p">(</span><span class="n">phase</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">lines</span></span></span></code></pre></div><p>暫停與恢復的每一步都印成 <code>MANUAL</code>，理由寫出要做的動作與那項資源停機時的計費；動作由人執行，腳本不執行 <code>manual_command</code>。在主機上的步驟另外執行 <code>verify</code> 的只讀探針，多印一行 <code>PASS</code> 或 <code>WARN</code>：維護的人做完那一步再跑一次腳本，<code>verify</code> 那一行確認那一步是否做成。<code>test_host_step_runs_only_verify</code> 斷言假的執行器只收到 <code>verify</code> 的指令，沒有收到 <code>manual_command</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><code>run</code> 依 <code>--phase</code> 選要跑的檢查。維護時五類檢查依固定順序執行，順序只決定報告裡各行的先後；結果檔以項目名稱比對前後兩次，不依賴行的順序。</p>
<h2 id="路徑的展開與大小的量測expand_path-與-measure_paths">路徑的展開與大小的量測：expand_path 與 measure_paths</h2>
<p>這一段給會累積的資料用：把清單的路徑樣式展開成實際的檔案，分出「不存在」與「讀不到」，再用 <code>os.lstat</code> 量總大小與最舊的修改時間，不執行 <code>du</code>：白名單裡的 <code>du</code> 是給清單的探針用的，腳本自己量大小時不呼叫它。分出讀不到的路徑，是為了讓一般帳號執行時讀不到的目錄印成 <code>WARN</code>，而不是被當成路徑不存在，改用專用帳號實跑時讀不到哪些目錄，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ── 小工具 ─────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">expand_path</span><span class="p">(</span><span class="n">pat</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;&#34;&#34;展開 [[accumulation]] 的一個路徑，回傳 (找到的路徑, 擋住讀取的目錄)。
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">    glob 與 os.path.lexists 遇到沒有權限的目錄都安靜地當成沒有（非 root 讀 0711 的 /var/log/nginx、
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">    0750 的 /var/lib/monitor-collector、0700 的家目錄底下），所以找不到時再查一次：
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">    glob 查萬用字元之前那一層目錄列不列得出來，一般路徑查 lstat 是不是被權限擋下。
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s2">    萬用字元出現在中間一層、而更深一層的目錄讀不到時，這裡查不出來，仍然算成不存在。
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="n">glob</span><span class="o">.</span><span class="n">has_magic</span><span class="p">(</span><span class="n">pat</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">found</span> <span class="o">=</span> <span class="n">glob</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="n">pat</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">if</span> <span class="n">found</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">return</span> <span class="n">found</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">parts</span> <span class="o">=</span> <span class="n">pat</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">sep</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">i</span> <span class="o">=</span> <span class="nb">next</span><span class="p">(</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">part</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="k">if</span> <span class="n">glob</span><span class="o">.</span><span class="n">has_magic</span><span class="p">(</span><span class="n">part</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">sep</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">parts</span><span class="p">[:</span><span class="n">i</span><span class="p">])</span> <span class="ow">or</span> <span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">sep</span> <span class="k">if</span> <span class="n">pat</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">sep</span><span class="p">)</span> <span class="k">else</span> <span class="n">os</span><span class="o">.</span><span class="n">curdir</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="n">os</span><span class="o">.</span><span class="n">listdir</span><span class="p">(</span><span class="n">base</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">except</span> <span class="ne">PermissionError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="k">return</span> <span class="p">[],</span> <span class="n">base</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="k">pass</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">return</span> <span class="p">[],</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">lstat</span><span class="p">(</span><span class="n">pat</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">except</span> <span class="ne">PermissionError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="p">[],</span> <span class="n">pat</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="k">return</span> <span class="p">[],</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">pat</span><span class="p">],</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">
</span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="k">def</span> <span class="nf">measure_paths</span><span class="p">(</span><span class="n">paths</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="nb">int</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="s2">&#34;&#34;&#34;回傳 (總 bytes, 最舊的 mtime, 讀不到的路徑數)；不跟隨 symlink，同一個 inode 只算一次。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="n">total</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="n">oldest</span><span class="p">:</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="n">unreadable</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="n">seen</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">]]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">
</span></span><span class="line"><span class="ln">42</span><span class="cl">    <span class="k">def</span> <span class="nf">visit</span><span class="p">(</span><span class="n">p</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="k">nonlocal</span> <span class="n">total</span><span class="p">,</span> <span class="n">oldest</span><span class="p">,</span> <span class="n">unreadable</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">            <span class="n">st</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">lstat</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">            <span class="n">unreadable</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="n">key</span> <span class="o">=</span> <span class="p">(</span><span class="n">st</span><span class="o">.</span><span class="n">st_dev</span><span class="p">,</span> <span class="n">st</span><span class="o">.</span><span class="n">st_ino</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">        <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">        <span class="k">if</span> <span class="n">stat</span><span class="o">.</span><span class="n">S_ISREG</span><span class="p">(</span><span class="n">st</span><span class="o">.</span><span class="n">st_mode</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">            <span class="n">total</span> <span class="o">+=</span> <span class="n">st</span><span class="o">.</span><span class="n">st_blocks</span> <span class="o">*</span> <span class="mi">512</span> <span class="k">if</span> <span class="n">st</span><span class="o">.</span><span class="n">st_blocks</span> <span class="k">else</span> <span class="n">st</span><span class="o">.</span><span class="n">st_size</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">            <span class="n">oldest</span> <span class="o">=</span> <span class="n">st</span><span class="o">.</span><span class="n">st_mtime</span> <span class="k">if</span> <span class="n">oldest</span> <span class="ow">is</span> <span class="kc">None</span> <span class="k">else</span> <span class="nb">min</span><span class="p">(</span><span class="n">oldest</span><span class="p">,</span> <span class="n">st</span><span class="o">.</span><span class="n">st_mtime</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">        <span class="k">elif</span> <span class="n">stat</span><span class="o">.</span><span class="n">S_ISDIR</span><span class="p">(</span><span class="n">st</span><span class="o">.</span><span class="n">st_mode</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">                <span class="n">entries</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">listdir</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">            <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">                <span class="n">unreadable</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl">            <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">entries</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl">                <span class="n">visit</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">name</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">
</span></span><span class="line"><span class="ln">65</span><span class="cl">    <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">paths</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl">        <span class="n">visit</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">67</span><span class="cl">    <span class="k">return</span> <span class="n">total</span><span class="p">,</span> <span class="n">oldest</span><span class="p">,</span> <span class="n">unreadable</span></span></span></code></pre></div><p><code>glob.glob</code> 遇到列不出內容的目錄時回傳空串列，不丟例外，結果跟「沒有符合的檔案」一樣。<code>expand_path</code> 因此在 glob 找不到時，取樣式裡第一個含萬用字元的那一層之前的目錄，試著列一次內容：被權限擋下就回傳那個目錄，讓呼叫的程式印「讀不到」；列得出來（目錄在、只是沒有符合的檔案）或目錄不存在，才算不存在。不含萬用字元的路徑用 <code>os.lstat</code> 查，上層目錄不讓進入時 <code>lstat</code> 丟出 <code>PermissionError</code>。docstring 寫了這個做法查不出的情形：萬用字元在中間一層、更深一層讀不到時，仍然算成不存在。單元測試 <code>test_glob_in_unlistable_dir_is_unreadable</code>（本篇沒有收錄）用權限 0311（擁有者可以進入、不能列出內容）的目錄重現一般帳號看 0711 的 <code>/var/log/nginx</code>，斷言理由是「讀不到」，同一個目錄擋住兩個樣式時只列一次；<code>test_literal_path_under_unreadable_parent_is_unreadable</code> 用權限 0000 的上層目錄測不含萬用字元的路徑。這幾項測試要以一般帳號執行，以 root 執行時權限擋不住讀取，測試會失敗。</p>
<p><code>measure_paths</code> 遞迴走過每個路徑，用 <code>lstat</code> 而不用 <code>stat</code>：符號連結本身不算進大小，也不會被跟進去，一個指向 <code>/</code> 的連結不會讓腳本量整個檔案系統。每個檔案以（裝置編號、inode 編號）為鍵只算一次，inode 是檔案系統裡代表一個檔案的節點，硬連結共用同一個 inode，兩個樣式找到同一個檔案時也只算一次。大小取 <code>st_blocks * 512</code>，也就是檔案實際佔用的磁碟區塊（<code>st_blocks</code> 以 512 位元組為單位），跟 <code>du</code> 量的是同一種值：稀疏檔案的長度可能遠大於它佔用的空間，而上限要比的是磁碟用量。量的過程中讀不到的路徑只計數，不中斷，呼叫的程式把計數寫進 <code>WARN</code> 的理由。</p>
<p><code>st_blocks</code> 是 0 時的退路，跟「上限要比的是磁碟用量」互相衝突。程式在 <code>st_blocks</code> 是 0 時改用檔案的長度 <code>st_size</code>；這個退路是為哪一種情形，原始碼與加入它的 commit 都沒有寫。而整個檔案都是空洞的稀疏檔案，<code>st_blocks</code> 正好是 0，會被算成它的長度，寫在〈host-check.py（3fc41c0）的已知限制與重現方式〉的〈各項檢查：ss 失敗、重複的項目名、loopback、特殊檔案、稀疏檔案與時間值〉。</p>
<h2 id="時間字串的解析與組輸出用的小函式">時間字串的解析與組輸出用的小函式</h2>
<p><code>parse_time</code> 把 log 計數門檻的探針（<code>[[log.alert]]</code> 裡 <code>measure = &quot;age_days&quot;</code> 的那些）與 <code>[[accumulation]]</code> 的 <code>oldest_command</code> 印出的時間字串轉成帶時區的時間，接受 systemd 的格式、Unix 時間戳與 ISO 8601；之後是判斷 loopback 位址、把位元組換成 KiB 與 MiB、把輸出壓成一行、組 <code>MANUAL</code> 理由的小函式。腳本第一次在雲端主機上執行時讀不懂 systemd 印的時間格式，那一次的結果與修正在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉裡記錄雲端主機第一次執行結果的那幾段。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># systemd 的時間格式：systemctl show 印時間屬性（LastTriggerUSec 這類）時用它，不理</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># --timestamp=unix。例：&#39;Mon 2026-10-26 07:07:43 GMT&#39;、&#39;Mon 2026-10-26 15:07:43.250000 CST&#39;。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 時區是縮寫：UTC、GMT 直接認；其他縮寫要等於這台主機目前時區的縮寫（time.tzname）才當成本地</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 時間，認不得的不猜（回傳 None）。</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">SYSTEMD_TIME_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^(?:[A-Z][a-z]</span><span class="si">{2}</span><span class="s2"> )?(\d</span><span class="si">{4}</span><span class="s2">-\d</span><span class="si">{2}</span><span class="s2">-\d</span><span class="si">{2}</span><span class="s2"> \d</span><span class="si">{2}</span><span class="s2">:\d</span><span class="si">{2}</span><span class="s2">:\d</span><span class="si">{2}</span><span class="s2">(?:\.\d{1,6})?) ([A-Za-z]{2,6}|[+-]\d</span><span class="si">{2}</span><span class="s2">:?\d</span><span class="si">{2}</span><span class="s2">)$&#34;</span><span class="p">)</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">_parse_systemd_time</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">m</span> <span class="o">=</span> <span class="n">SYSTEMD_TIME_RE</span><span class="o">.</span><span class="k">match</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">clock</span><span class="p">,</span> <span class="n">zone</span> <span class="o">=</span> <span class="n">m</span><span class="o">.</span><span class="n">groups</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">naive</span> <span class="o">=</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">fromisoformat</span><span class="p">(</span><span class="n">clock</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="n">zone</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;UTC&#34;</span><span class="p">,</span> <span class="s2">&#34;GMT&#34;</span><span class="p">,</span> <span class="s2">&#34;UCT&#34;</span><span class="p">,</span> <span class="s2">&#34;Z&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="n">naive</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="n">zone</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="ow">in</span> <span class="s2">&#34;+-&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">fromisoformat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">clock</span><span class="si">}{</span><span class="n">zone</span><span class="p">[:</span><span class="mi">3</span><span class="p">]</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">zone</span><span class="p">[</span><span class="o">-</span><span class="mi">2</span><span class="p">:]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="n">zone</span> <span class="ow">in</span> <span class="n">time</span><span class="o">.</span><span class="n">tzname</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="c1"># 本地時區：naive 的 astimezone() 依這個程序的時區規則換算（含夏令時間）。</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="n">naive</span><span class="o">.</span><span class="n">astimezone</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="k">def</span> <span class="nf">parse_time</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="n">text</span> <span class="o">=</span> <span class="n">text</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">text</span> <span class="ow">or</span> <span class="n">text</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;n/a&#34;</span><span class="p">,</span> <span class="s2">&#34;0&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="n">re</span><span class="o">.</span><span class="n">fullmatch</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;@?\d+(\.\d+)?&#34;</span><span class="p">,</span> <span class="n">text</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">fromtimestamp</span><span class="p">(</span><span class="nb">float</span><span class="p">(</span><span class="n">text</span><span class="o">.</span><span class="n">lstrip</span><span class="p">(</span><span class="s2">&#34;@&#34;</span><span class="p">)),</span> <span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="n">systemd</span> <span class="o">=</span> <span class="n">_parse_systemd_time</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">if</span> <span class="n">systemd</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="k">return</span> <span class="n">systemd</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="c1"># 小數秒超過 6 位（Go 的 RFC3339Nano）時截到微秒，fromisoformat 才吃得下。</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;(\.\d</span><span class="si">{6}</span><span class="s2">)\d+&#34;</span><span class="p">,</span> <span class="sa">r</span><span class="s2">&#34;\1&#34;</span><span class="p">,</span> <span class="n">text</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="s2">&#34; &#34;</span><span class="p">,</span> <span class="s2">&#34;T&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;Z$&#34;</span><span class="p">,</span> <span class="s2">&#34;+00:00&#34;</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">        <span class="n">ts</span> <span class="o">=</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="o">.</span><span class="n">fromisoformat</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">return</span> <span class="n">ts</span> <span class="k">if</span> <span class="n">ts</span><span class="o">.</span><span class="n">tzinfo</span> <span class="k">else</span> <span class="n">ts</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span></span></span></code></pre></div><p>systemd 的時間格式以時區縮寫結尾，而縮寫不唯一對應一個時區：<code>CST</code> 在 <code>Asia/Taipei</code> 是台灣的標準時間，在美國中部是另一個時區。<code>_parse_systemd_time</code> 因此只認 UTC 這一類縮寫，以及等於這台主機目前時區縮寫的那一個（<code>time.tzname</code>），也認 <code>+0800</code>、<code>+08:00</code> 這種數字的時區偏移，其餘回傳 <code>None</code>，讓那一項印成「輸出不是時間值」，不猜一個時區而算出差了幾小時的天數。單元測試 <code>test_alert_age_days_systemd_local_zone</code>（本篇沒有收錄）把時區設成 <code>Asia/Taipei</code>，斷言 <code>CST</code> 換算正確、不認得的 <code>XYZT</code> 判成不是時間值；<code>test_alert_age_days_systemd_format</code> 測 <code>GMT</code>、<code>UTC</code> 與帶小數秒的寫法。</p>
<p><code>parse_time</code> 接受三種格式：可以有 <code>@</code> 前綴的 Unix 時間戳（epoch 秒數）、systemd 的格式、ISO 8601。systemd 的格式要另外處理，因為 <code>systemctl show</code> 印時間屬性（<code>LastTriggerUSec</code> 這類）時不理 <code>--timestamp=unix</code>，印的是 systemd 自己的格式；程式碼註解記了這一點，<code>test_alert_age_days_systemd_format</code> 用的就是那種輸出。</p>
<p>單獨的 <code>0</code> 與 <code>n/a</code>，<code>parse_time</code> 回 <code>None</code>，<code>@0</code> 照樣當成 1970-01-01（實跑）。回 <code>None</code> 之後怎麼判由呼叫端決定，兩個呼叫端的判法不同：<code>measure</code> 在呼叫 <code>parse_time</code> 之前，先把空的輸出與 <code>n/a</code> 判成「沒有時間值」，<code>0</code> 不在其中，結果是「輸出不是時間值」；<code>check_accumulations</code> 只把空的輸出當成沒有資料，<code>oldest_command</code> 印 <code>n/a</code> 或 <code>0</code> 時印 <code>WARN</code>「輸出不是時間值」（兩者都實跑）。<code>measure</code> 與 <code>check_accumulations</code> 各自的結果寫在〈host-check.py（3fc41c0）的已知限制與重現方式〉的〈各項檢查：ss 失敗、重複的項目名、loopback、特殊檔案、稀疏檔案與時間值〉。</p>
<p>ISO 8601 的字串截掉超過微秒的小數秒，程式碼註解寫的理由是 <code>datetime.fromisoformat</code> 吃不下 Go 的 RFC3339Nano 印出的奈秒，那是 Python 3.10 以前的行為：實跑 Python 3.10.18 的 <code>fromisoformat</code> 對九位小數丟出 <code>ValueError: Invalid isoformat string</code>，3.11.13、3.12.11、3.15.0 都接受九位小數、自己截到微秒。腳本要求 3.11 以上，所以在它支援的版本上，這一行截斷是冗餘的，結果跟不截斷相同。</p>
<p>沒有時區的 ISO 時間當成 UTC：字串本身沒有時區，腳本沒有別的資訊可以判斷。資料來源寫進去的是不帶時區的本地時間時，算出的天數會差一個時區偏移（台北是 8 小時，約 0.33 天），保留期的判定在門檻附近會早或晚 8 小時翻轉成 <code>WARN</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">_is_loopback</span><span class="p">(</span><span class="n">addr</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">a</span> <span class="o">=</span> <span class="n">addr</span><span class="o">.</span><span class="n">strip</span><span class="p">(</span><span class="s2">&#34;[]&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;%&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">return</span> <span class="n">a</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;127.&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">a</span> <span class="o">==</span> <span class="s2">&#34;::1&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">def</span> <span class="nf">_human</span><span class="p">(</span><span class="n">n</span><span class="p">:</span> <span class="nb">int</span> <span class="o">|</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">unit</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;B&#34;</span><span class="p">,</span> <span class="s2">&#34;KiB&#34;</span><span class="p">,</span> <span class="s2">&#34;MiB&#34;</span><span class="p">,</span> <span class="s2">&#34;GiB&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nb">abs</span><span class="p">(</span><span class="n">n</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">1024</span> <span class="ow">or</span> <span class="n">unit</span> <span class="o">==</span> <span class="s2">&#34;GiB&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="s2">.0f</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">unit</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">unit</span> <span class="o">==</span> <span class="s2">&#34;B&#34;</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">unit</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">n</span> <span class="o">/=</span> <span class="mi">1024</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="s2">.1f</span><span class="si">}</span><span class="s2"> GiB&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</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="k">def</span> <span class="nf">_brief</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">160</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">one</span> <span class="o">=</span> <span class="s2">&#34; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">text</span><span class="o">.</span><span class="n">split</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="n">one</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">one</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">limit</span> <span class="k">else</span> <span class="n">one</span><span class="p">[:</span> <span class="n">limit</span> <span class="o">-</span> <span class="mi">1</span><span class="p">]</span> <span class="o">+</span> <span class="s2">&#34;…&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="k">def</span> <span class="nf">_manual_text</span><span class="p">(</span><span class="n">where</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">console_path</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">command</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">expect</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="n">place</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;console&#34;</span><span class="p">:</span> <span class="s2">&#34;主控台&#34;</span><span class="p">,</span> <span class="s2">&#34;external&#34;</span><span class="p">:</span> <span class="s2">&#34;主機外&#34;</span><span class="p">}[</span><span class="n">where</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">how</span> <span class="o">=</span> <span class="n">console_path</span> <span class="k">if</span> <span class="n">console_path</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;執行 </span><span class="si">{</span><span class="n">command</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;[</span><span class="si">{</span><span class="n">place</span><span class="si">}</span><span class="s2">] </span><span class="si">{</span><span class="n">how</span><span class="si">}</span><span class="s2"> → 預期：</span><span class="si">{</span><span class="n">expect</span><span class="si">}</span><span class="s2">&#34;</span></span></span></code></pre></div><p><code>_is_loopback</code> 先去掉 IPv6 位址外面的方括號與 <code>%</code> 之後的介面名稱（<code>ss</code> 印 <code>[::1]:631</code> 或 <code>127.0.0.53%lo:53</code> 這種寫法），再比對 <code>127.</code> 開頭或 <code>::1</code>。<code>_human</code> 的單位是 1024 進位的 KiB、MiB、GiB，跟 <code>du -h</code>、<code>df -h</code> 的換算方式相同，報告裡的大小拿去跟這兩支工具的輸出對照時不必換算。<code>_brief</code> 把多行的輸出壓成一行並截到 160 個字元，報告每一項維持一行，用 <code>grep</code> 篩報告時一項就是一行。</p>
<h2 id="結果檔的讀寫與跟前一次的差異">結果檔的讀寫與跟前一次的差異</h2>
<p>結果檔讓每一次的報告能跟同一個階段的前一次比對（階段就是 <code>--phase</code> 的 <code>maintain</code>、<code>suspend</code>、<code>resume</code>）：讀前一次的紀錄、算出差異、印在報告裡，最後把這一次的紀錄寫回去。寫回的做法是同一個目錄裡的暫存檔、<code>fsync</code>、<code>os.replace</code>，再 <code>fsync</code> 目錄，保證中斷時舊的紀錄仍然完整，說明在程式碼之後。結果檔的格式、印出的差異與預設路徑放在 <code>/var/lib</code> 的理由，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ── 結果檔（與前一次的差）──────────────────────────────────</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 腳本自己的結果檔，不是清單的一部分：清單只描述要檢查什麼，量到的值存在這裡。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 唯一會寫的檔案，而且只寫這一個（同目錄暫存檔 + os.replace，結束時目錄裡只有它）。</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">def</span> <span class="nf">load_state</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1"># 讀結果檔，回傳 (內容, 要印的 note)。不存在回空內容且不印 note；壞掉或格式不認得當成沒有。</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">except</span> <span class="ne">FileNotFoundError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="p">{},</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">OSError</span><span class="p">,</span> <span class="ne">ValueError</span><span class="p">)</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>  <span class="c1"># JSONDecodeError、UnicodeDecodeError 都是 ValueError</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="p">{},</span> <span class="sa">f</span><span class="s2">&#34;結果檔 </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2"> 讀不到或壞掉（</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">），當成沒有前一次，這次會覆寫&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="ow">or</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;format&#34;</span><span class="p">)</span> <span class="o">!=</span> <span class="n">STATE_FORMAT</span> <span class="ow">or</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;phases&#34;</span><span class="p">),</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="p">{},</span> <span class="sa">f</span><span class="s2">&#34;結果檔 </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2"> 的格式不認得，當成沒有前一次，這次會覆寫&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="n">data</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">def</span> <span class="nf">previous_phase</span><span class="p">(</span><span class="n">state</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">phase</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">prev</span> <span class="o">=</span> <span class="n">state</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;phases&#34;</span><span class="p">,</span> <span class="p">{})</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">phase</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">prev</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="ow">and</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">prev</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;finished_at&#34;</span><span class="p">),</span> <span class="nb">str</span><span class="p">)</span> <span class="ow">and</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">prev</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;items&#34;</span><span class="p">),</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="n">prev</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="k">def</span> <span class="nf">build_record</span><span class="p">(</span><span class="n">lines</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Line</span><span class="p">],</span> <span class="n">counts</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="n">manifest_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">finished_at</span><span class="p">:</span> <span class="n">dt</span><span class="o">.</span><span class="n">datetime</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="s2">&#34;finished_at&#34;</span><span class="p">:</span> <span class="n">finished_at</span><span class="o">.</span><span class="n">astimezone</span><span class="p">(</span><span class="n">dt</span><span class="o">.</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span><span class="o">.</span><span class="n">isoformat</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="s2">&#34;manifest&#34;</span><span class="p">:</span> <span class="n">manifest_path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="s2">&#34;counts&#34;</span><span class="p">:</span> <span class="n">counts</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="s2">&#34;items&#34;</span><span class="p">:</span> <span class="p">{</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="p">,</span> <span class="s2">&#34;value&#34;</span><span class="p">:</span> <span class="n">ln</span><span class="o">.</span><span class="n">value</span><span class="p">}</span> <span class="k">for</span> <span class="n">ln</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="k">def</span> <span class="nf">_num</span><span class="p">(</span><span class="n">n</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="s2">.0f</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">n</span><span class="p">)</span><span class="o">.</span><span class="n">is_integer</span><span class="p">()</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="k">def</span> <span class="nf">_prev_value</span><span class="p">(</span><span class="n">entry</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="nb">float</span> <span class="o">|</span> <span class="kc">None</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">entry</span><span class="p">,</span> <span class="nb">dict</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span><span class="p">,</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="n">value</span> <span class="o">=</span> <span class="n">entry</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;value&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="n">ok</span> <span class="o">=</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="p">(</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span><span class="p">))</span> <span class="ow">and</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="nb">bool</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="k">return</span> <span class="n">entry</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;status&#34;</span><span class="p">),</span> <span class="p">(</span><span class="n">value</span> <span class="k">if</span> <span class="n">ok</span> <span class="k">else</span> <span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">
</span></span><span class="line"><span class="ln">47</span><span class="cl">
</span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="k">def</span> <span class="nf">diff_lines</span><span class="p">(</span><span class="n">prev</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">lines</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Line</span><span class="p">],</span> <span class="n">state_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="c1"># 與前一次的差：第一行 since:，其後每個差一行（CHANGED / DELTA / NEW / GONE）。</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="k">if</span> <span class="n">prev</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="sa">f</span><span class="s2">&#34;since: 沒有前一次的結果（</span><span class="si">{</span><span class="n">state_path</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="n">out</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">    <span class="n">seen</span><span class="p">:</span> <span class="nb">set</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">    <span class="k">for</span> <span class="n">ln</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">        <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">item</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">prev</span><span class="p">[</span><span class="s2">&#34;items&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">            <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;NEW     </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">        <span class="n">old_status</span><span class="p">,</span> <span class="n">old_value</span> <span class="o">=</span> <span class="n">_prev_value</span><span class="p">(</span><span class="n">prev</span><span class="p">[</span><span class="s2">&#34;items&#34;</span><span class="p">][</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">        <span class="k">if</span> <span class="n">old_status</span> <span class="o">!=</span> <span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">            <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;CHANGED </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">old_status</span><span class="si">}</span><span class="s2">→</span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl">        <span class="k">if</span> <span class="n">old_value</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">ln</span><span class="o">.</span><span class="n">value</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">old_value</span> <span class="o">!=</span> <span class="n">ln</span><span class="o">.</span><span class="n">value</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl">            <span class="n">delta</span> <span class="o">=</span> <span class="n">ln</span><span class="o">.</span><span class="n">value</span> <span class="o">-</span> <span class="n">old_value</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">            <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;DELTA   </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">_num</span><span class="p">(</span><span class="n">old_value</span><span class="p">)</span><span class="si">}</span><span class="s2"> → </span><span class="si">{</span><span class="n">_num</span><span class="p">(</span><span class="n">ln</span><span class="o">.</span><span class="n">value</span><span class="p">)</span><span class="si">}</span><span class="s2">（</span><span class="si">{</span><span class="s1">&#39;+&#39;</span> <span class="k">if</span> <span class="n">delta</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="k">else</span> <span class="s1">&#39;-&#39;</span><span class="si">}{</span><span class="n">_num</span><span class="p">(</span><span class="nb">abs</span><span class="p">(</span><span class="n">delta</span><span class="p">))</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl">    <span class="n">out</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;GONE    </span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">prev</span><span class="p">[</span><span class="s2">&#34;items&#34;</span><span class="p">])</span> <span class="o">-</span> <span class="n">seen</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">out</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">67</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="sa">f</span><span class="s2">&#34;since: </span><span class="si">{</span><span class="n">prev</span><span class="p">[</span><span class="s1">&#39;finished_at&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">，與前一次相同&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">68</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="sa">f</span><span class="s2">&#34;since: </span><span class="si">{</span><span class="n">prev</span><span class="p">[</span><span class="s1">&#39;finished_at&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="o">*</span><span class="n">out</span><span class="p">]</span></span></span></code></pre></div><p>讀結果檔的三個函式都把讀到的內容當成不可信的輸入：檔案不存在是第一次執行，不印說明；JSON 壞掉、編碼不對、讀不到，回傳一條 <code>note</code>（由 <code>main</code> 印到 stderr）並當成沒有前一次；最外層不是 <code>format</code> 等於 <code>STATE_FORMAT</code>（1）、帶 <code>phases</code> 的物件時，同樣當成沒有前一次。<code>previous_phase</code> 與 <code>_prev_value</code> 再逐層確認型別，值是布林時不拿來算差。手改過或被別的程式寫壞的結果檔，最多讓差異段印成「沒有前一次的結果」，不會讓腳本中止（單元測試 <code>test_corrupt_state_does_not_abort</code>，本篇沒有收錄）。</p>
<p><code>diff_lines</code> 以項目名稱比對前後兩次，所以清單改了名稱的項目印成一筆 <code>GONE</code> 加一筆 <code>NEW</code>。同一個項目狀態與數字都變了時，<code>CHANGED</code> 與 <code>DELTA</code> 各印一行；<code>DELTA</code> 只在兩次都有數字時印，<code>value</code> 是 <code>null</code> 的項目只會出現在 <code>CHANGED</code>。<code>GONE</code> 依名稱排序，同樣的兩份紀錄每次印出的差異段順序相同。<code>test_new_and_gone_items</code> 斷言清單加進一個累積項目之後印 <code>NEW     accumulation/b  PASS</code>，拿掉之後印 <code>GONE    accumulation/b</code>；<code>test_second_run_prints_changed_and_delta</code> 斷言一個計數門檻 100 的探針從 42 變成 50 時印 <code>DELTA   log/edge/status-429  42 → 50（+8）</code>，變成 500 時同時印 <code>CHANGED … PASS→WARN</code> 與 <code>DELTA … 50 → 500（+450）</code>，再變成 20 時印 <code>DELTA … 500 → 20（-480）</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">_fsync_directory</span><span class="p">(</span><span class="n">directory</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="c1"># rename 要等目錄項寫進磁碟才耐斷電；Windows 不能開目錄，略過。</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="nb">hasattr</span><span class="p">(</span><span class="n">os</span><span class="p">,</span> <span class="s2">&#34;O_DIRECTORY&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">fd</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">directory</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">O_RDONLY</span> <span class="o">|</span> <span class="n">os</span><span class="o">.</span><span class="n">O_DIRECTORY</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">fsync</span><span class="p">(</span><span class="n">fd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">close</span><span class="p">(</span><span class="n">fd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">write_state</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">state</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># 原子地寫結果檔；失敗回傳 note（不改變 exit code）。</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">directory</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">abspath</span><span class="p">(</span><span class="n">path</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">tmp_path</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">makedirs</span><span class="p">(</span><span class="n">directory</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="mo">0o700</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">fd</span><span class="p">,</span> <span class="n">tmp_path</span> <span class="o">=</span> <span class="n">tempfile</span><span class="o">.</span><span class="n">mkstemp</span><span class="p">(</span><span class="n">prefix</span><span class="o">=</span><span class="s2">&#34;.last-&#34;</span><span class="p">,</span> <span class="n">suffix</span><span class="o">=</span><span class="s2">&#34;.tmp&#34;</span><span class="p">,</span> <span class="nb">dir</span><span class="o">=</span><span class="n">directory</span><span class="p">)</span>  <span class="c1"># mkstemp 建出來就是 0600</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">with</span> <span class="n">os</span><span class="o">.</span><span class="n">fdopen</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="s2">&#34;w&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="n">json</span><span class="o">.</span><span class="n">dump</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">f</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="n">f</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="n">f</span><span class="o">.</span><span class="n">flush</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="n">os</span><span class="o">.</span><span class="n">fsync</span><span class="p">(</span><span class="n">f</span><span class="o">.</span><span class="n">fileno</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="n">os</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="n">tmp_path</span><span class="p">,</span> <span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="n">tmp_path</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="n">_fsync_directory</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">except</span> <span class="ne">OSError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;結果檔 </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2"> 寫不進去（</span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">）；這次的結果沒有存下來，報告與 exit code 不受影響&#34;</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">if</span> <span class="n">tmp_path</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">                <span class="n">os</span><span class="o">.</span><span class="n">unlink</span><span class="p">(</span><span class="n">tmp_path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="k">pass</span>  <span class="c1"># 暫存檔已不在（沒建成功或已被取代），沒有東西要清</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">return</span> <span class="kc">None</span></span></span></code></pre></div><p><code>write_state</code> 的寫法依序是：在結果檔的同一個目錄用 <code>tempfile.mkstemp</code> 建暫存檔（權限 0600），寫完內容之後 <code>fsync</code>（要求作業系統把檔案內容寫到磁碟），再用 <code>os.replace</code> 換上正式的檔名，最後 <code>fsync</code> 那個目錄。暫存檔要在同一個目錄，因為 <code>os.replace</code> 在同一個檔案系統裡才是一次完成的換名；目錄的 <code>fsync</code> 讓換名這件事本身也寫進磁碟，斷電之後正式的檔名指著的是舊的或新的完整內容，不會是寫到一半的檔案。<code>os.replace</code> 擋的是中斷，擋不住兩次執行同時寫：程式裡沒有檔案鎖（<code>flock</code> 這類），兩次同時跑時，各自讀到的是開始時的結果檔，後換名的那一次蓋掉先換名的那一次寫進的紀錄（讀碼）。<code>test_write_state_fsyncs_file_and_directory</code> 斷言 <code>fsync</code> 至少呼叫兩次（檔案一次、目錄一次）。</p>
<p><code>os.makedirs</code> 的 <code>mode=0o700</code> 只套在這次新建的最後一層目錄上（中間各層用預設的權限），已經存在的目錄不改權限；<code>exist_ok=True</code> 只讓目錄已經存在時不丟例外。所以由 systemd 的 <code>StateDirectory=</code> 建出的目錄，權限照 <code>StateDirectoryMode=</code> 設定，腳本不跟 systemd 爭這個值。寫入失敗時，<code>finally</code> 刪掉留下的暫存檔，函式回傳一條 <code>note</code>，命令列印到 stderr，exit code 照檢查的結果決定（<code>test_unwritable_state_keeps_exit_code</code>）；成功時目錄裡只剩結果檔（<code>test_first_run_writes_only_last_json</code>）。</p>
<h2 id="命令列與-exit-codemain">命令列與 exit code：main</h2>
<p><code>main</code> 是命令列的入口：解析參數、載入清單、執行檢查、印報告與差異、寫結果檔，最後依報告決定 exit code。exit code 的讀法，以及接上排程時為什麼 exit code 不是 0 就要告警，在 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉；跨主機執行時每台的 exit 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>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">(</span><span class="n">argv</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">runner</span><span class="p">:</span> <span class="n">Runner</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">out</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="n">out</span> <span class="ow">or</span> <span class="n">sys</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">ap</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;web-demo 主機的只讀維護檢查&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--manifest&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">abspath</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)),</span> <span class="s2">&#34;host.toml&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--phase&#34;</span><span class="p">,</span> <span class="n">choices</span><span class="o">=</span><span class="n">PHASES</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;maintain&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--state-file&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">DEFAULT_STATE_FILE</span><span class="p">,</span> <span class="n">metavar</span><span class="o">=</span><span class="s2">&#34;PATH&#34;</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="sa">f</span><span class="s2">&#34;結果檔路徑（預設 </span><span class="si">{</span><span class="n">DEFAULT_STATE_FILE</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--no-state&#34;</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s2">&#34;store_true&#34;</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s2">&#34;不讀也不寫結果檔&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--set&#34;</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s2">&#34;append&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="p">[],</span> <span class="n">metavar</span><span class="o">=</span><span class="s2">&#34;NAME=VALUE&#34;</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s2">&#34;覆寫 [vars] 的值&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">ap</span><span class="o">.</span><span class="n">parse_args</span><span class="p">(</span><span class="n">argv</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">overrides</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">args</span><span class="o">.</span><span class="n">set</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">k</span><span class="p">,</span> <span class="n">sep</span><span class="p">,</span> <span class="n">val</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;=&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">sep</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;manifest error: --set 要寫成 NAME=VALUE：</span><span class="si">{</span><span class="n">s</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">overrides</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="o">=</span> <span class="n">val</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">manifest</span> <span class="o">=</span> <span class="n">load_manifest</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">manifest</span><span class="p">,</span> <span class="n">overrides</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">except</span> <span class="n">ManifestError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">for</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">e</span><span class="o">.</span><span class="n">errors</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;manifest error: </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;manifest error: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">errors</span><span class="p">)</span><span class="si">}</span><span class="s2"> 項，沒有執行任何檢查&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nb">hasattr</span><span class="p">(</span><span class="n">os</span><span class="p">,</span> <span class="s2">&#34;geteuid&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">os</span><span class="o">.</span><span class="n">geteuid</span><span class="p">()</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;note: 不是 root，journal、audit.log 與 collector 的目錄可能讀不到（會印成 WARN）&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="n">lines</span> <span class="o">=</span> <span class="n">Checker</span><span class="p">(</span><span class="n">manifest</span><span class="p">,</span> <span class="n">runner</span><span class="p">)</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">phase</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">except</span> <span class="ne">RuntimeError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="c1"># validate 已比對過 Checker 會拼的指令，正常路徑不該走到；保險：當格式錯處理。</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;manifest error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="n">counts</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;PASS&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s2">&#34;WARN&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s2">&#34;MANUAL&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">for</span> <span class="n">ln</span> <span class="ow">in</span> <span class="n">lines</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="n">counts</span><span class="p">[</span><span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">status</span><span class="si">:</span><span class="s2">&lt;6</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">item</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">ln</span><span class="o">.</span><span class="n">reason</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="n">state</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">args</span><span class="o">.</span><span class="n">no_state</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="n">state</span><span class="p">,</span> <span class="n">note</span> <span class="o">=</span> <span class="n">load_state</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">state_file</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="k">if</span> <span class="n">note</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;note: </span><span class="si">{</span><span class="n">note</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">        <span class="k">for</span> <span class="n">text</span> <span class="ow">in</span> <span class="n">diff_lines</span><span class="p">(</span><span class="n">previous_phase</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">phase</span><span class="p">),</span> <span class="n">lines</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">state_file</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;summary: </span><span class="si">{</span><span class="n">counts</span><span class="p">[</span><span class="s1">&#39;PASS&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> PASS, </span><span class="si">{</span><span class="n">counts</span><span class="p">[</span><span class="s1">&#39;WARN&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> WARN, </span><span class="si">{</span><span class="n">counts</span><span class="p">[</span><span class="s1">&#39;MANUAL&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> MANUAL（phase </span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">phase</span><span class="si">}</span><span class="s2">，</span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">manifest</span><span class="si">}</span><span class="s2">）&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">args</span><span class="o">.</span><span class="n">no_state</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">        <span class="n">phases</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;phases&#34;</span><span class="p">,</span> <span class="p">{}))</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="n">phases</span><span class="p">[</span><span class="n">args</span><span class="o">.</span><span class="n">phase</span><span class="p">]</span> <span class="o">=</span> <span class="n">build_record</span><span class="p">(</span><span class="n">lines</span><span class="p">,</span> <span class="n">counts</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">manifest</span><span class="p">,</span> <span class="n">now_utc</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="n">note</span> <span class="o">=</span> <span class="n">write_state</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">state_file</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;format&#34;</span><span class="p">:</span> <span class="n">STATE_FORMAT</span><span class="p">,</span> <span class="s2">&#34;phases&#34;</span><span class="p">:</span> <span class="n">phases</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">        <span class="k">if</span> <span class="n">note</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;note: </span><span class="si">{</span><span class="n">note</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="k">return</span> <span class="mi">1</span> <span class="k">if</span> <span class="n">counts</span><span class="p">[</span><span class="s2">&#34;WARN&#34;</span><span class="p">]</span> <span class="k">else</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">
</span></span><span class="line"><span class="ln">52</span><span class="cl">
</span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span></span></span></code></pre></div><p><code>main</code> 收 <code>argv</code>、<code>runner</code> 與 <code>out</code> 三個參數，單元測試直接呼叫它，傳入假的執行器並把報告寫進記憶體裡的字串，不必另起一個程序。<code>--manifest</code> 的預設是腳本所在目錄的 <code>host.toml</code>，腳本與清單放在同一個目錄時不用帶這個參數。exit 2 的出口有四個：<code>argparse</code> 遇到不認得的參數或不在 <code>PHASES</code> 裡的 <code>--phase</code>（<code>argparse</code> 自己以 2 結束）、<code>--set</code> 沒有寫成 <code>名稱=值</code>、清單格式錯、執行時白名單拒絕了一條指令；前三個出口在任何檢查執行之前，執行時的拒絕發生時，在它之前的指令已經執行過。走到執行時的拒絕時，<code>RuntimeError</code> 從 <code>Checker(...).run</code> 裡丟出來，<code>run</code> 沒有回傳，<code>lines</code> 沒有被設定，所以已經執行過的那些項目，報告行一行都不會印出，stderr 印一行 <code>manifest error:</code> 加上拒絕的理由。清單格式錯時，每一條錯誤印一行 <code>manifest error:</code>，最後一行寫出錯誤的條數與「沒有執行任何檢查」。<code>-h</code>／<code>--help</code> 由 <code>argparse</code> 印出用法之後以 exit 0 結束，一項檢查都不執行，也沒有 <code>summary:</code> 那一行（實跑）；這支腳本裡，exit 0 而沒有 <code>summary:</code> 的只有這一種（讀碼），跟中途中止的 exit 1 分得開。單元測試 <code>test_cli_format_error_exit_2</code>（本篇沒有收錄）把腳本當成獨立的程序執行一份 <code>name = 1</code> 的清單，斷言 exit code 是 2、stderr 有 <code>manifest error</code>。</p>
<p>不是 root 時，<code>main</code> 在 stderr 印一行 <code>note:</code>，不改變 exit code：哪些項目會讀不到，由那幾項自己印成 <code>WARN</code>。報告的順序是每一項一行、差異段、<code>summary:</code> 一行；結果檔在檢查跑完之後才讀，結果檔壞掉不影響檢查本身，寫結果檔排在印出 <code>summary:</code> 之後，寫入失敗時報告已經完整印出。exit code 只看 <code>WARN</code> 的數量，<code>MANUAL</code> 不影響它。<code>if __name__ == &quot;__main__&quot;</code> 讓這個檔案被當成模組載入時（部署時的 <code>validate</code> 只呼叫 <code>load_manifest</code>）不執行檢查。</p>
<p>照這支腳本的做法替自己的主機寫檢查腳本時，程式裡有幾處字串是寫給示範清單那台主機的，要換成自己的環境：</p>
<ul>
<li><strong><code>web-demo</code></strong>：docstring 的第一行與 <code>argparse</code> 的 <code>description</code>，寫的是示範主機上的服務名。</li>
<li><strong><code>collector</code>、<code>audit.log</code></strong>：docstring 的用法說明與只讀保證，以及 <code>main</code> 不是 root 時印的那一行 <code>note:</code>，寫的是示範主機上讀不到的目錄與檔案。</li>
<li><strong>OCI Console、Cloudflare dashboard</strong>：docstring 第二段，寫的是示範主機外的確認位置。</li>
<li><strong><code>dnf-automatic</code></strong>：<code>check_updates</code> 找不到設定檔時印的理由；自動更新用的是別的工具時，<code>REBOOT_REQUIRED_TEXT</code> 與 <code>config_files</code> 的讀法也要一起換。</li>
<li><strong><code>deploy.sh 不自動重開，排時間 sudo systemctl reboot</code></strong>：<code>check_updates</code> 判定需要重開機時印的理由，<code>deploy.sh</code> 是私有專案部署那台主機的腳本。</li>
<li><strong><code>/var/lib/host-check/last.json</code> 與 <code>host.toml</code></strong>：<code>DEFAULT_STATE_FILE</code> 是結果檔的預設路徑，<code>--manifest</code> 的預設是腳本所在目錄的 <code>host.toml</code>；路徑換了時，systemd 沙箱的 <code>StateDirectory=</code> 也要跟著換。</li>
</ul>
<h2 id="host-checkpy3fc41c0的已知限制與重現方式">host-check.py（3fc41c0）的已知限制與重現方式</h2>
<p>這一版的腳本沒有處理的行為，依腳本的結構分成四組：清單的驗證、指令的執行與 exit code、各項檢查、結果檔。每一條寫出觸發條件與結果，括號裡的「實跑」與「讀碼」照開場的定義讀。</p>
<p>實跑的方式以同一個連接埠在兩個位址接聽那一條為例。在放著 <code>host-check.py</code> 的目錄執行下面這段，假的執行器不管收到哪一條指令都回同一份 <code>ss</code> 的輸出；第一行印出兩筆 <code>('WARN', 'listen-audit/111/tcp')</code>，第二行印出結果檔的 <code>items</code> 只剩一個 <code>listen-audit/111/tcp</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">python3 - <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">import importlib.util
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">spec = importlib.util.spec_from_file_location(&#34;host_check&#34;, &#34;host-check.py&#34;)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">hc = importlib.util.module_from_spec(spec)
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">spec.loader.exec_module(hc)
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">class FakeRunner(hc.Runner):
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">    def run(self, command):
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">        out = &#34;tcp LISTEN 0 128 0.0.0.0:111 0.0.0.0:*\ntcp LISTEN 0 128 [::]:111 [::]:*\n&#34;
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">        return hc.Result(0, out, &#34;&#34;)
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">manifest = {&#34;listen_audit&#34;: {&#34;protocols&#34;: [&#34;tcp&#34;], &#34;allow&#34;: []}, &#34;entrypoint&#34;: []}
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">checker = hc.Checker(manifest, FakeRunner())
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">checker.check_entrypoints()
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">print([(ln.status, ln.item) for ln in checker.lines])
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">print(list(hc.build_record(checker.lines, {}, &#34;m&#34;, hc.now_utc())[&#34;items&#34;]))
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">EOF</span></span></span></code></pre></div><p>例子裡直接組出 <code>Checker</code> 收的 dict，因為 <code>listen_audit</code> 與空的 <code>entrypoint</code> 在 <code>validate</code> 正規化之後，形狀跟 TOML 的寫法相同。其餘段落正規化之後的形狀不同：<code>[[accumulation]]</code> 的 <code>path</code> 變成 <code>paths</code>（一律是串列），<code>[[log]]</code> 多一個 <code>alerts</code> 串列，每一個探針換成 <code>Probe</code> 物件，<code>[[entrypoint.verify]]</code> 在主機上的確認變成帶 <code>probe</code> 鍵的 dict。照 TOML 的欄位名組 dict 交給 <code>check_accumulations</code>，得到 <code>KeyError: 'paths'</code>（實跑）。所以其餘各條的重現，先寫一份 TOML 清單，用 <code>hc.load_manifest(&lt;清單的路徑&gt;)</code> 取得正規化之後的 dict，再交給 <code>hc.Checker</code>、呼叫對應的方法（<code>check_logs</code>、<code>check_updates</code>、<code>check_accumulations</code>、<code>check_entrypoints</code>）；<code>_Validator.probe</code>、<code>load_state</code>、<code>measure_paths</code>、<code>_is_loopback</code>、<code>parse_time</code> 這些函式可以直接呼叫；〈結果檔：壞掉時的其他階段與兩次同時執行〉的那幾條用 <code>hc.main([..., &quot;--state-file&quot;, &lt;路徑&gt;], runner=&lt;假的執行器&gt;)</code> 從頭跑。</p>
<h3 id="清單的驗證不認得的鍵同時寫-expect_exit-與-ok_exitexpect-的大寫鍵">清單的驗證：不認得的鍵、同時寫 expect_exit 與 ok_exit、expect 的大寫鍵</h3>
<ul>
<li><strong><code>[updates].expect</code> 的鍵有大寫字母</strong>（實跑）：<code>ConfigParser</code> 預設把設定檔裡的鍵轉成小寫，<code>merged</code> 的鍵因此全是小寫；清單的 <code>expect</code> 寫 <code>Upgrade_Type = &quot;security&quot;</code> 時，查 <code>merged.get(&quot;Upgrade_Type&quot;)</code> 得到 <code>None</code>，那一項永遠印 <code>WARN</code>。實跑時同一份設定檔，<code>expect</code> 的鍵寫 <code>Upgrade_Type</code> 時印 <code>WARN</code>（理由是 <code>Upgrade_Type = None</code>），寫 <code>upgrade_type</code> 時印 <code>PASS</code>。dnf-automatic 的設定檔裡 <code>[commands]</code> 的鍵寫成小寫（<code>upgrade_type</code>、<code>apply_updates</code>），清單照設定檔的寫法寫小寫就不會碰到。</li>
<li><strong>探針同時寫 <code>expect_exit</code> 與 <code>ok_exit</code></strong>（實跑）：<code>validate</code> 不拒絕這種寫法，<code>run_probe</code> 只比 <code>expect_exit</code>，<code>ok_exit</code> 不起作用。實跑時 <code>expect_exit = 0</code>、<code>ok_exit = [0, 1]</code> 的探針沒有任何格式錯，指令以 exit 1 結束時判成不符（理由 <code>exit 1（預期 0）</code>）。</li>
<li><strong><code>validate</code> 不認得的鍵與只在 <code>[[log.alert]]</code> 生效的 <code>measure</code></strong>（實跑）：<code>validate</code> 只讀它認得的鍵，其餘的鍵不報錯、當成沒寫。打錯字的鍵因此安靜地失效：實跑時一個寫了 <code>expect_exit = 0</code> 與 <code>expect_stdot = &quot;x&quot;</code> 的探針沒有任何格式錯，<code>expect_stdout</code> 讀出來是 <code>None</code>，輸出不會被比對；<code>[[accumulation]]</code> 的 <code>missing_okk</code> 照同一個機制被當成沒寫，<code>missing_ok</code> 取預設的 <code>false</code>（讀碼）。<code>measure</code> 與 <code>warn_above</code> 也屬於這一類：它們只在 log 的計數門檻（<code>[[log.alert]]</code>）讀，入口與暫停、恢復步驟的 <code>verify</code> 探針上寫了這兩個欄位、同一個探針又另外寫了別的預期值時，兩個欄位被略過（實跑：<code>measure</code> 讀出來是 <code>None</code>）；只寫 <code>measure</code> 而沒有其他預期值時，判成「探針至少要有一個預期值」的格式錯（實跑）。</li>
</ul>
<h3 id="指令的執行與-exit-code沒有接住的例外">指令的執行與 exit code：沒有接住的例外</h3>
<ul>
<li>
<p><strong><code>check_logs</code> 與 <code>Runner.run</code> 沒有接住的例外</strong>：兩處的 <code>try</code> 只接幾種指定的例外，沒有列出的例外一路往上，Python 印出 traceback、以 exit 1 結束，stdout 沒有 <code>summary:</code> 那一行。觸發的情形有三種：</p>
<ul>
<li><code>[[log]]</code> 的 <code>source</code> 寫成 <code>file:&lt;目錄&gt;</code>：<code>os.stat</code> 成功，接著 <code>open()</code> 丟出 <code>IsADirectoryError</code>（實跑，從命令列跑整支腳本：exit 1，stdout 沒有 <code>summary:</code>）。</li>
<li><code>source</code> 寫成 <code>file:/etc/hosts/x</code>，把一個檔案當成目錄：<code>os.stat</code> 丟出 <code>NotADirectoryError</code>（實跑，呼叫 <code>check_logs</code>）。<code>check_logs</code> 只接 <code>OSError</code> 底下的 <code>FileNotFoundError</code> 與 <code>PermissionError</code>，<code>IsADirectoryError</code> 與 <code>NotADirectoryError</code> 都不是這兩個子類別。</li>
<li><code>PATH</code> 上找到的同名檔案沒有執行權限、而 <code>PATH</code> 上沒有另一個可執行的同名檔案：<code>subprocess.run</code> 丟出 <code>PermissionError</code>，<code>Runner.run</code> 只接 <code>FileNotFoundError</code> 與逾時（實跑：<code>PATH</code> 只放一個權限 0644 的 <code>id</code>，<code>Runner().run(&quot;id&quot;)</code> 丟出 <code>PermissionError</code>）；經由命令列時的 exit 1 是讀碼。</li>
</ul>
<p>這個 exit 1 跟「至少一項 <code>WARN</code>」的 exit 1 相同，接上排程或告警的程式只看 exit code 時分不出兩者，要再看 stdout 有沒有 <code>summary:</code> 那一行（docstring 寫的「沒有印出 summary 代表腳本中途中止」）。替代的做法是在 <code>main</code> 外面包一層 <code>try</code>／<code>except</code>，把沒有接住的例外改回一個獨立的 exit code，這一版沒有實作。</p>
</li>
</ul>
<h3 id="各項檢查ss-失敗重複的項目名loopback特殊檔案稀疏檔案與時間值">各項檢查：ss 失敗、重複的項目名、loopback、特殊檔案、稀疏檔案與時間值</h3>
<ul>
<li><strong><code>updates/config</code> 的項目名重複</strong>（實跑）：<code>config_files</code> 裡有一個檔案讀不到或格式錯時，<code>check_updates</code> 先以 <code>updates/config</code> 印一行 <code>WARN</code>，讀完其餘的檔案之後比對 <code>expect</code>，又以同一個項目名印一行。<code>build_record</code> 以項目名當鍵，結果檔只留後一行。實跑時一個沒有段名的設定檔加一個正確的設定檔：報告印 <code>WARN</code> 與 <code>PASS</code> 兩行 <code>updates/config</code>，結果檔裡這一項是 <code>PASS</code>，讀不到設定檔的那一筆 <code>WARN</code> 不在結果檔裡，下一次的差異段也比不到它。</li>
<li><strong>會累積的資料裡的目錄與特殊檔案</strong>（實跑）：<code>measure_paths</code> 只把一般檔案算進大小與最舊的修改時間，目錄本身的修改時間不算；socket 與 FIFO 既不算進大小，也不算成讀不到的路徑。實跑時一個只放了一個 FIFO 的目錄，量到的是總大小 0、沒有最舊的時間、讀不到的路徑 0 個；這種目錄設了 <code>retention_days</code> 時，理由印「最舊的檔案：沒有資料」（讀碼）。</li>
<li><strong>全是空洞的稀疏檔案</strong>（實跑）：<code>measure_paths</code> 在 <code>st_blocks</code> 是 0 時改用 <code>st_size</code>，整個檔案都是空洞的稀疏檔案因此被算成它的長度，跟用 <code>st_blocks</code> 量磁碟用量的做法相反。實跑時在 macOS 上用 <code>truncate</code> 建一個 10 MiB 的稀疏檔案，<code>st_blocks</code> 是 0，<code>measure_paths</code> 量到 10485760 位元組；檔案只要有一個資料區塊，<code>st_blocks</code> 就不是 0，量到的是佔用的區塊（讀碼）。這個退路的理由原始碼沒有寫，<code>limit_bytes</code> 因此可能被一個沒有佔用磁碟的檔案觸發 <code>WARN</code>。</li>
<li><strong><code>ss</code> 以非零結束</strong>（實跑）：<code>listening_sockets</code> 回 <code>None</code>，主機上的每個入口印「讀不到監聽中的 socket」的 <code>WARN</code>，<code>listen-audit</code> 那一行整個不印。實跑時清單有一個主機上的入口，報告只有那個入口的 <code>WARN</code>；清單沒有 <code>where = &quot;host&quot;</code> 的入口時，報告裡一行跟 <code>ss</code> 有關的都沒有。前一次的結果有 <code>listen-audit</code> 那一項時，這一次的差異段印 <code>GONE listen-audit</code>（讀碼）。</li>
<li><strong>同一個連接埠在兩個位址接聽</strong>（實跑）：<code>audit_listening</code> 依協定、位址、連接埠各印一行，項目名只帶連接埠與協定；<code>0.0.0.0:111</code> 與 <code>[::]:111</code> 同時接聽時印兩行 <code>WARN</code>，項目名都是 <code>listen-audit/111/tcp</code>。<code>build_record</code> 以項目名當鍵，結果檔裡只留後一行，實跑時 <code>items</code> 裡只有一個 <code>listen-audit/111/tcp</code>；下一次的差異比的是留下的那一行。</li>
<li><strong>IPv4 對映的 loopback 位址</strong>（實跑）：<code>_is_loopback</code> 只認 <code>127.</code> 開頭與 <code>::1</code>，<code>[::ffff:127.0.0.1]</code> 回 <code>False</code>。一支程式在 IPv6 的 socket 上綁定 <code>::ffff:127.0.0.1</code> 時，<code>listen-audit</code> 把它當成 loopback 以外的接聽、印成 <code>WARN</code>（讀碼）。</li>
<li><strong>時間值寫成 <code>0</code> 或 <code>n/a</code></strong>（實跑）：<code>parse_time</code> 對單獨的 <code>0</code> 與 <code>n/a</code> 回 <code>None</code>，兩個呼叫端的判法不同。<code>measure</code> 先把空的輸出與 <code>n/a</code> 判成「沒有時間值」，<code>0</code> 不在其中：<code>measure(&quot;age_days&quot;, &quot;0&quot;)</code> 與 <code>measure(&quot;age_days&quot;, &quot;LastTriggerUSec=0&quot;)</code> 都得到「輸出不是時間值」，那一項的理由指向腳本讀不懂格式。<code>check_accumulations</code> 只把空的輸出當成沒有資料：<code>oldest_command</code> 印 <code>n/a</code> 或 <code>0</code> 時，記一條「oldest_command 的輸出不是時間值」、那一項印 <code>WARN</code>，只有空的輸出印 <code>PASS</code>（「最舊一筆：沒有資料」）。重現時呼叫 <code>hc.Checker({}, &lt;假的執行器&gt;).measure(&quot;age_days&quot;, &quot;0&quot;)</code>，或讓假的執行器對 <code>oldest_command</code> 回 <code>n/a</code>，再呼叫 <code>check_accumulations</code>。</li>
</ul>
<h3 id="結果檔壞掉時的其他階段與兩次同時執行">結果檔：壞掉時的其他階段與兩次同時執行</h3>
<ul>
<li><strong>結果檔壞掉時，其他階段的紀錄一起消失</strong>（實跑）：<code>load_state</code> 讀到不是合法 JSON 的結果檔時回傳空的內容，<code>main</code> 寫回時只帶這一次的階段。實跑時用 <code>hc.main([..., &quot;--state-file&quot;, &lt;路徑&gt;, &quot;--phase&quot;, &lt;階段&gt;], runner=&lt;假的執行器&gt;)</code> 依序跑 <code>suspend</code> 與 <code>maintain</code>，結果檔的 <code>phases</code> 是 <code>maintain</code> 與 <code>suspend</code>；把結果檔改成一段沒有收尾的 JSON 之後再跑一次 <code>maintain</code>，stderr 印「讀不到或壞掉」的 <code>note:</code>，寫回之後 <code>phases</code> 只剩 <code>maintain</code>。</li>
<li><strong>兩次執行同時寫結果檔</strong>（讀碼）：<code>write_state</code> 沒有檔案鎖，兩次同時跑時各自讀到開始時的結果檔，後換名的那一次蓋掉先換名的那一次；兩次跑的是不同的 <code>--phase</code> 時，先寫完的那個階段的紀錄消失。替代的做法是在腳本外面用 <code>flock</code> 這類工具讓兩次執行排隊，這一版沒有實作。</li>
</ul>
]]></content:encoded></item><item><title>服務暫停與恢復上線：只停服務、停止主機、終止主機到全部刪除各自保留的資源，暫停期間仍計費與仍被指向的資源，恢復前要查的漂移與恢復的順序</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/suspend-and-resume-service/</guid><description>&lt;p>這篇整理一台雲端主機上的服務要暫停一段時間、之後再恢復上線時要做的決定：暫停的範圍從小到大有只停服務、停止主機、終止主機但保留磁碟與網路設定、全部刪除再從 repo 重建，每一種保留哪些資源、恢復要多久、暫停期間付出什麼，以及怎麼拿保留的月費、重建的成本與暫停期間的漂移量來選；暫停時要先收窄的入口；暫停期間仍在計費的資源（磁碟、保留的 IP、備份、網路元件、網域與付費方案），以及仍然指向這台主機、或把這台主機的 IP 當成可信來源的外部設定（DNS 紀錄、CDN 的回源設定、監控的探測、別處的 IP 白名單）在主機不在時造成的結果。&lt;/p>
&lt;p>恢復上線時要做的事，這篇整理恢復之前要重新確認的漂移、沒有事先記下基準時從哪裡開始比對，以及依元件之間的依賴排出的恢復順序與每一段完成的驗證方式。範圍是預期會恢復的暫停；服務要永久退場、對外的使用者與資料要另外處理時，見 &lt;a href="https://tarrragon.github.io/blog/backend/10-system-evolution/staged-shutdown/" data-link-title="10.7 分階段退場：停止新建、唯讀、靜態封存、關閉與公告期" data-link-desc="服務結束時把一次全部關閉拆成幾個階段：停止新建、唯讀、靜態封存與關閉各保留什麼、對哪一類持有者有效、維運成本降到哪裡；依使用紀錄決定保留範圍；推動客戶遷移的手段；以及公告期長度、通知管道與補償的安排">10.7 分階段退場：停止新建、唯讀、靜態封存、關閉與公告期&lt;/a>。主機在運行期間會改變的狀態與維護方式在 &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>，本篇沿用那一篇的四類狀態來源：會累積的資料、會老化的軟體、會漂移的入口、會到期與會改變的外部設定。本篇的「漂移」指這四類狀態在暫停期間的改變：暫停當下記下的值（公開 IP、憑證效期、待裝的更新數），到恢復時已經不是同一個值；它比 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>（宣告的設定與雲端實際狀態不一致）範圍更寬，IaC 的那一種設定漂移是其中的一種。各家業者停止與終止主機的計費規則在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/shape-and-image/" data-link-title="機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統" data-link-desc="雲端主機的機型決定的 CPU 架構、核心與記憶體，計費模式（隨用隨付、可被收回、預留容量、專屬主機）各自的條件，映像檔與機型之間的架構相容性，以及哪些項目建立後可以調整">機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統&lt;/a>〈停止主機時的計費〉。&lt;/p>
&lt;h2 id="暫停時保留的範圍只停服務停止主機終止主機全部刪除">暫停時保留的範圍：只停服務、停止主機、終止主機、全部刪除&lt;/h2>
&lt;p>暫停一個服務，等於決定哪些狀態保留到恢復的時候。保留的狀態越多，恢復越快，暫停期間要付的費用與要管的漂移也越多；刪除的狀態越多，暫停期間越乾淨，恢復時要重建的越多。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>暫停的範圍&lt;/th>
 &lt;th>保留&lt;/th>
 &lt;th>停掉或刪除&lt;/th>
 &lt;th>暫停期間的成本&lt;/th>
 &lt;th>恢復的動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>只停服務&lt;/td>
 &lt;td>主機、磁碟、IP、防火牆、DNS&lt;/td>
 &lt;td>應用程式或反向代理的程序&lt;/td>
 &lt;td>主機照常計費；主機繼續暴露在網路上，安全更新照樣要裝&lt;/td>
 &lt;td>啟動程序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>停止主機&lt;/td>
 &lt;td>磁碟、網路與防火牆設定、DNS；公開 IP 的去留依業者與 IP 的種類而定，見表格下方&lt;/td>
 &lt;td>主機的運算資源&lt;/td>
 &lt;td>雲端業者的一般機型停止運算計費，磁碟繼續存在；停止的主機是否仍佔服務配額依業者而定&lt;/td>
 &lt;td>從主控台或 API 啟動主機，再照〈恢復的順序〉更新之後才對外開放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>終止主機、保留磁碟與網路設定&lt;/td>
 &lt;td>開機磁碟（或它的備份）、虛擬網路與子網路、防火牆規則、保留的 IP、DNS&lt;/td>
 &lt;td>主機本身、主機上的臨時公開 IP&lt;/td>
 &lt;td>磁碟與保留的 IP 繼續存在；配額釋放&lt;/td>
 &lt;td>用保留的磁碟或映像檔建新主機、重新附上 IP 或改 DNS&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>全部刪除、從 repo 重建&lt;/td>
 &lt;td>repo 裡的部署腳本與設定、要保留的資料的備份、DNS 紀錄（可選）&lt;/td>
 &lt;td>主機、磁碟、網路、防火牆、IP&lt;/td>
 &lt;td>接近零（只剩備份的儲存與網域）&lt;/td>
 &lt;td>重建網路與主機、跑部署腳本、還原資料、改 DNS&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表裡的臨時公開 IP 指業者在建立主機時分配、主機終止（有些業者是停止）時收回的位址，保留的 IP 指另外建立、存在到被刪除為止的位址（Oracle Cloud 叫 reserved public IP，AWS 叫 Elastic IP）。停止主機時臨時公開 IP 的去留，各家不同：Oracle Cloud 的文件寫明停止時仍留在主機上、終止時才刪除（一台 A1 主機停止 2 小時再啟動，公開 IP 前後相同，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序&lt;/a>）；AWS 的文件寫明停止時就收回、再啟動時分配一個新的，所以在 AWS 上沒有 Elastic IP 的主機，指向它的 DNS 紀錄要在停止之前就改掉（原因見〈暫停期間仍指向這台主機的外部設定〉）。其他業者的規則與欄位名稱見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則&lt;/a>〈公開 IP：網際網路上的入口〉；查不到的業者，當成停止時就會收回。「停止運算計費」是雲端業者一般機型的規則；按小時計費的 VPS，部分業者關機照常計費、要刪除實例才停止，要讀各家的計費說明確認。&lt;/p>
&lt;p>選哪一種，比的是三個量：保留下來的資源每個月的費用乘上暫停的月數、刪除之後重建要花的成本，以及保留期間要管的漂移：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>保留的月費&lt;/strong>：依計費模式而不同。雲端業者的一般機型停止時，是磁碟加保留 IP 加網路元件的月費，在免費額度內（例如 Oracle Cloud 的 Always Free 區塊儲存）這一項是零；關機照常計費的 VPS 與特殊機型，停止的月費就是整台主機的月費，停止主機省不下任何錢，要省錢只能做快照之後刪除實例（相當於「終止主機、保留磁碟的備份」那一列）。&lt;/li>
&lt;li>&lt;strong>重建的人力&lt;/strong>：人力時數要乘上自己的時間成本，才能跟月費比：重建一次要半天、半天的人力換算成某個金額時，保留的月費乘上月數超過那個金額之後，刪除比較划算；刪除之前先照 repo 驗證重建一次（下一項），這一次重建也算進刪除的成本。&lt;/li>
&lt;li>&lt;strong>repo 不完整的風險與事先的驗證&lt;/strong>：repo 不完整的風險是重建之後才發現少了設定：上線時在主機上手動調大了 nginx 的 body 上限、沒有收進 repo，半年後從 repo 重建，部署腳本跑完、&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">冒煙測試&lt;/a>（只確認服務起得來、首頁有回應的那一組最小檢查，涵蓋不到每一條路徑）全部通過，而上傳大檔案的請求回 413，要等使用者回報才發現。這個風險可以事先消除：刪除之前用一台新主機照 repo 重建一次，確認重建得出同樣的服務，做法見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/clean-machine-verification/" data-link-title="乾淨機器驗證：repo 宣告了什麼、機器實際依賴什麼" data-link-desc="覺得 dotfile 已經『可重現』、或想確認 install.sh 換一台新機器真的跑得起來時回來讀">乾淨機器驗證：repo 宣告了什麼、機器實際依賴什麼&lt;/a>。免費額度已經用滿、或那種機型沒有容量而建不出第二台時，驗證改在額度以外的環境做（本機上同一個發行版的容器或 VM），驗得到的是部署腳本與主機上的設定，驗不到的是雲端那一層（機型、網路與防火牆規則），那一層改成把規則寫進 repo 或匯出存檔。重建一次實際要花多久，要量過才算數，估計的時間跟實測常差很多，見 &lt;a href="https://tarrragon.github.io/blog/devops/operations/06-high-availability/disaster-recovery/" data-link-title="Disaster recovery 策略" data-link-desc="設計災難復原時，先確認恢復路徑走得通再談 RTO/RPO、用 restore drill 把估值變量值、以及知道恢復不是切回流量就結束">Disaster recovery 策略&lt;/a>〈Restore 驗證的三個層次〉。&lt;/li>
&lt;li>&lt;strong>保留期間的漂移&lt;/strong>：保留的主機在暫停期間沒有安裝任何更新，暫停越久，恢復時要補的更新越多；憑證、網域、token 可能到期；免費額度的主機可能被業者回收（Oracle Cloud 的文件寫了判定閒置的使用率門檻，沒有寫停止中的主機算不算閒置，也沒有寫回收是停止還是刪除）。&lt;/li>
&lt;/ul>
&lt;p>恢復時能接受等多久是另一個限制，也就是這次暫停的 RTO（復原時間目標，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO&lt;/a>）：重建一次的時間超過它時，不論月費多少都不能選刪除。暫停多久還不知道時（不確定會不會回來），月費乘上月數等於重建成本的那一個月，就是重新評估要不要改成刪除的時間點。由這三個量與這個限制，四種範圍各自適合的情形如下：&lt;/p></description><content:encoded><![CDATA[<p>這篇整理一台雲端主機上的服務要暫停一段時間、之後再恢復上線時要做的決定：暫停的範圍從小到大有只停服務、停止主機、終止主機但保留磁碟與網路設定、全部刪除再從 repo 重建，每一種保留哪些資源、恢復要多久、暫停期間付出什麼，以及怎麼拿保留的月費、重建的成本與暫停期間的漂移量來選；暫停時要先收窄的入口；暫停期間仍在計費的資源（磁碟、保留的 IP、備份、網路元件、網域與付費方案），以及仍然指向這台主機、或把這台主機的 IP 當成可信來源的外部設定（DNS 紀錄、CDN 的回源設定、監控的探測、別處的 IP 白名單）在主機不在時造成的結果。</p>
<p>恢復上線時要做的事，這篇整理恢復之前要重新確認的漂移、沒有事先記下基準時從哪裡開始比對，以及依元件之間的依賴排出的恢復順序與每一段完成的驗證方式。範圍是預期會恢復的暫停；服務要永久退場、對外的使用者與資料要另外處理時，見 <a href="/blog/backend/10-system-evolution/staged-shutdown/" data-link-title="10.7 分階段退場：停止新建、唯讀、靜態封存、關閉與公告期" data-link-desc="服務結束時把一次全部關閉拆成幾個階段：停止新建、唯讀、靜態封存與關閉各保留什麼、對哪一類持有者有效、維運成本降到哪裡；依使用紀錄決定保留範圍；推動客戶遷移的手段；以及公告期長度、通知管道與補償的安排">10.7 分階段退場：停止新建、唯讀、靜態封存、關閉與公告期</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>，本篇沿用那一篇的四類狀態來源：會累積的資料、會老化的軟體、會漂移的入口、會到期與會改變的外部設定。本篇的「漂移」指這四類狀態在暫停期間的改變：暫停當下記下的值（公開 IP、憑證效期、待裝的更新數），到恢復時已經不是同一個值；它比 IaC 所說的 <a href="/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift（設定漂移）</a>（宣告的設定與雲端實際狀態不一致）範圍更寬，IaC 的那一種設定漂移是其中的一種。各家業者停止與終止主機的計費規則在 <a href="/blog/devops/infra/cloud-providers/vm-options/shape-and-image/" data-link-title="機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統" data-link-desc="雲端主機的機型決定的 CPU 架構、核心與記憶體，計費模式（隨用隨付、可被收回、預留容量、專屬主機）各自的條件，映像檔與機型之間的架構相容性，以及哪些項目建立後可以調整">機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統</a>〈停止主機時的計費〉。</p>
<h2 id="暫停時保留的範圍只停服務停止主機終止主機全部刪除">暫停時保留的範圍：只停服務、停止主機、終止主機、全部刪除</h2>
<p>暫停一個服務，等於決定哪些狀態保留到恢復的時候。保留的狀態越多，恢復越快，暫停期間要付的費用與要管的漂移也越多；刪除的狀態越多，暫停期間越乾淨，恢復時要重建的越多。</p>
<table>
  <thead>
      <tr>
          <th>暫停的範圍</th>
          <th>保留</th>
          <th>停掉或刪除</th>
          <th>暫停期間的成本</th>
          <th>恢復的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只停服務</td>
          <td>主機、磁碟、IP、防火牆、DNS</td>
          <td>應用程式或反向代理的程序</td>
          <td>主機照常計費；主機繼續暴露在網路上，安全更新照樣要裝</td>
          <td>啟動程序</td>
      </tr>
      <tr>
          <td>停止主機</td>
          <td>磁碟、網路與防火牆設定、DNS；公開 IP 的去留依業者與 IP 的種類而定，見表格下方</td>
          <td>主機的運算資源</td>
          <td>雲端業者的一般機型停止運算計費，磁碟繼續存在；停止的主機是否仍佔服務配額依業者而定</td>
          <td>從主控台或 API 啟動主機，再照〈恢復的順序〉更新之後才對外開放</td>
      </tr>
      <tr>
          <td>終止主機、保留磁碟與網路設定</td>
          <td>開機磁碟（或它的備份）、虛擬網路與子網路、防火牆規則、保留的 IP、DNS</td>
          <td>主機本身、主機上的臨時公開 IP</td>
          <td>磁碟與保留的 IP 繼續存在；配額釋放</td>
          <td>用保留的磁碟或映像檔建新主機、重新附上 IP 或改 DNS</td>
      </tr>
      <tr>
          <td>全部刪除、從 repo 重建</td>
          <td>repo 裡的部署腳本與設定、要保留的資料的備份、DNS 紀錄（可選）</td>
          <td>主機、磁碟、網路、防火牆、IP</td>
          <td>接近零（只剩備份的儲存與網域）</td>
          <td>重建網路與主機、跑部署腳本、還原資料、改 DNS</td>
      </tr>
  </tbody>
</table>
<p>表裡的臨時公開 IP 指業者在建立主機時分配、主機終止（有些業者是停止）時收回的位址，保留的 IP 指另外建立、存在到被刪除為止的位址（Oracle Cloud 叫 reserved public IP，AWS 叫 Elastic IP）。停止主機時臨時公開 IP 的去留，各家不同：Oracle Cloud 的文件寫明停止時仍留在主機上、終止時才刪除（一台 A1 主機停止 2 小時再啟動，公開 IP 前後相同，見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序</a>）；AWS 的文件寫明停止時就收回、再啟動時分配一個新的，所以在 AWS 上沒有 Elastic IP 的主機，指向它的 DNS 紀錄要在停止之前就改掉（原因見〈暫停期間仍指向這台主機的外部設定〉）。其他業者的規則與欄位名稱見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a>〈公開 IP：網際網路上的入口〉；查不到的業者，當成停止時就會收回。「停止運算計費」是雲端業者一般機型的規則；按小時計費的 VPS，部分業者關機照常計費、要刪除實例才停止，要讀各家的計費說明確認。</p>
<p>選哪一種，比的是三個量：保留下來的資源每個月的費用乘上暫停的月數、刪除之後重建要花的成本，以及保留期間要管的漂移：</p>
<ul>
<li><strong>保留的月費</strong>：依計費模式而不同。雲端業者的一般機型停止時，是磁碟加保留 IP 加網路元件的月費，在免費額度內（例如 Oracle Cloud 的 Always Free 區塊儲存）這一項是零；關機照常計費的 VPS 與特殊機型，停止的月費就是整台主機的月費，停止主機省不下任何錢，要省錢只能做快照之後刪除實例（相當於「終止主機、保留磁碟的備份」那一列）。</li>
<li><strong>重建的人力</strong>：人力時數要乘上自己的時間成本，才能跟月費比：重建一次要半天、半天的人力換算成某個金額時，保留的月費乘上月數超過那個金額之後，刪除比較划算；刪除之前先照 repo 驗證重建一次（下一項），這一次重建也算進刪除的成本。</li>
<li><strong>repo 不完整的風險與事先的驗證</strong>：repo 不完整的風險是重建之後才發現少了設定：上線時在主機上手動調大了 nginx 的 body 上限、沒有收進 repo，半年後從 repo 重建，部署腳本跑完、<a href="/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">冒煙測試</a>（只確認服務起得來、首頁有回應的那一組最小檢查，涵蓋不到每一條路徑）全部通過，而上傳大檔案的請求回 413，要等使用者回報才發現。這個風險可以事先消除：刪除之前用一台新主機照 repo 重建一次，確認重建得出同樣的服務，做法見 <a href="/blog/linux/dotfile/00-dotfile-mindset/clean-machine-verification/" data-link-title="乾淨機器驗證：repo 宣告了什麼、機器實際依賴什麼" data-link-desc="覺得 dotfile 已經『可重現』、或想確認 install.sh 換一台新機器真的跑得起來時回來讀">乾淨機器驗證：repo 宣告了什麼、機器實際依賴什麼</a>。免費額度已經用滿、或那種機型沒有容量而建不出第二台時，驗證改在額度以外的環境做（本機上同一個發行版的容器或 VM），驗得到的是部署腳本與主機上的設定，驗不到的是雲端那一層（機型、網路與防火牆規則），那一層改成把規則寫進 repo 或匯出存檔。重建一次實際要花多久，要量過才算數，估計的時間跟實測常差很多，見 <a href="/blog/devops/operations/06-high-availability/disaster-recovery/" data-link-title="Disaster recovery 策略" data-link-desc="設計災難復原時，先確認恢復路徑走得通再談 RTO/RPO、用 restore drill 把估值變量值、以及知道恢復不是切回流量就結束">Disaster recovery 策略</a>〈Restore 驗證的三個層次〉。</li>
<li><strong>保留期間的漂移</strong>：保留的主機在暫停期間沒有安裝任何更新，暫停越久，恢復時要補的更新越多；憑證、網域、token 可能到期；免費額度的主機可能被業者回收（Oracle Cloud 的文件寫了判定閒置的使用率門檻，沒有寫停止中的主機算不算閒置，也沒有寫回收是停止還是刪除）。</li>
</ul>
<p>恢復時能接受等多久是另一個限制，也就是這次暫停的 RTO（復原時間目標，見 <a href="/blog/backend/knowledge-cards/rto/" data-link-title="RTO" data-link-desc="說明恢復時間目標如何約束事故回復策略">RTO</a>）：重建一次的時間超過它時，不論月費多少都不能選刪除。暫停多久還不知道時（不確定會不會回來），月費乘上月數等於重建成本的那一個月，就是重新評估要不要改成刪除的時間點。由這三個量與這個限制，四種範圍各自適合的情形如下：</p>
<ul>
<li><strong>只停服務</strong>：例如上游的第三方 API 停用幾天、要擋住寫入做資料修正。主機照常計費也照常暴露，<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>：例如一個 side project 要休眠幾個月。主機不執行，不會被攻擊、不會累積 log，而磁碟還在，恢復只要開機。保留的月費在免費額度內是零時，這是代價最低的選擇；但免費額度的主機可能在暫停期間被業者回收，而回收時是停止還是刪除沒有寫明，所以在免費額度上停止主機，同樣要先滿足全部刪除的前提：repo 重建得出同樣的服務、資料的備份放在這台主機以外。保留的月費不是零時，月費乘上暫停的月數會在某個時間點超過重建一次的成本，超過之後改成刪除比較划算。免費試用期要結束、要決定主機的去留時，各家業者在試用結束後怎麼處置帳號與資源，見 <a href="/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目</a>〈試用結束後帳號與資源的處置〉。</li>
<li><strong>終止主機但保留磁碟與網路設定</strong>：例如要把主機從 x86 換到 ARM 的機型，或把配額讓給另一台主機。恢復要重建主機，所以比停止慢，而網路與防火牆的設定不必重做。在容量常常不足的機型上（Oracle Cloud 的 A1 常回報 Out of host capacity），終止之後不一定建得回同樣的主機，這時停止比終止安全。</li>
<li><strong>全部刪除、從 repo 重建</strong>：例如確定之後會重建、但中間有好幾個月用不到。暫停期間幾乎沒有成本，也沒有要管的漂移，恢復時每一項都要重建，前提是部署腳本、設定與資料的備份都在 repo 或備份裡。暫停期長到保留的月費超過重建成本時也選它。不確定會不會回來、而服務有外部的使用者或被別處引用時，這已經是退場，網域的保管人與付款來源也要先確定，否則網域到期後可能被別人註冊，處理方式照 10.7 的退場階段與 <a href="/blog/backend/10-system-evolution/namespace-and-domain-disposition/" data-link-title="10.6 命名空間與網域的處置：續約、名稱保留與回收、懸空 DNS" data-link-desc="服務結束時網域與名稱的去向，以及它們決定外部引用是失效還是易手：網域的續約與出售、交給中立方的技術控制、套件與雲端資源名稱釋出後被重新申請與佔位、雲端資源刪除後的懸空 DNS 與子網域接管，以及綁在可過期網域上的帳號身分">10.6 命名空間與網域的處置：續約、名稱保留與回收、懸空 DNS</a>。</li>
</ul>
<h2 id="暫停時先收窄的入口">暫停時先收窄的入口</h2>
<p>停止主機時，雲端防火牆與主機防火牆的規則、DNS 紀錄都保留下來；主機在恢復時一開機，enabled 的服務自己起來，防火牆照舊放行，CDN 照舊把請求送進來，這台缺了暫停期間全部更新的主機，在恢復的第一分鐘就在接受外部連線。要讓恢復時有機會先更新再開放，入口要在暫停的時候就收窄：</p>
<ul>
<li><strong>雲端防火牆收回對外連接埠的放行</strong>，只留管理者 IP 連 SSH（或 bastion），恢復時在主機更新完之後再加回來。</li>
<li><strong>DNS 紀錄改指到維護頁</strong>（下面〈暫停期間仍指向這台主機的外部設定〉寫做法），或刪除紀錄。</li>
</ul>
<p>停止主機時，這兩項都要做；在停止時就收回 IP 的業者（AWS 沒有 Elastic IP 的主機），DNS 那一項要在停止之前做。只停服務時主機一直開著、自動更新照常執行，收窄的理由不成立，DNS 那一項只在要讓訪客看到維護頁時做。終止主機時，IP 是保留的就照下面〈暫停期間仍指向這台主機的外部設定〉裡「IP 還在自己手上」那一種處理；臨時公開 IP 被收回、或全部刪除時，DNS 紀錄一定要改（原因見同一節懸空 DNS 那一段），防火牆規則保不保留看要不要沿用。</p>
<h2 id="暫停期間仍在計費的資源">暫停期間仍在計費的資源</h2>
<p>主機停止或終止之後，帳單上可能還留著幾類跟主機分開計費的資源。它們在建立時跟主機一起出現，暫停時卻要各自處理，沒有處理的就成了 <a href="/blog/devops/infra/knowledge-cards/orphaned-resource/" data-link-title="Orphaned Resource（孤兒資源）" data-link-desc="刪掉或停掉一台雲端主機之後帳單還在、免費額度被不知名的磁碟佔著，或要盤點一個帳號裡哪些磁碟、IP、快照已經沒有主機在用時">孤兒資源</a>，也就是 <a href="/blog/devops/operations/08-cost-management/billing-models/" data-link-title="計費模式理解" data-link-desc="看懂雲端帳單怎麼生成時，先認清收費的維度、on-demand/reserved/savings plan/spot 的承諾差異、以及 egress 這類最常被忽略的隱藏成本">計費模式理解</a>〈隱藏成本：帳單上的意外〉所說的被遺忘的資源：</p>
<ul>
<li><strong>磁碟</strong>：開機磁碟與資料磁碟只要存在就佔用儲存，業者的區塊儲存依佈建的容量按月計價，文件沒有針對主機停止另寫例外。Oracle Cloud 上一台 A1 主機停止的那一小時，逐小時的帳務明細照樣記了開機磁碟的用量（額度內，金額 0），A1 的運算項目則沒有出現，停止中的主機也仍算在 A1 的核心數與記憶體用量裡（2026-10-27 實測，見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序</a>〈停止期間的計費：usage API 逐小時的明細〉）；其他業者是從價目的計法推論，沒有在帳單上看過。終止主機時開機磁碟的預設去留各家不同：預設保留的業者（Oracle Cloud 是這一種），終止之後磁碟仍在、仍佔免費帳號的區塊儲存額度。<a href="/blog/devops/infra/cloud-providers/vm-options/storage/" data-link-title="磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留" data-link-desc="雲端主機的磁碟選項：開機磁碟的大小與只能加大的限制、效能等級與費用的關係、靜態加密與傳輸中加密、另外掛載的區塊磁碟，以及刪除主機時開機磁碟是否預設一起刪除">磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留</a> 的〈刪除（終止）主機時開機磁碟的預設去留〉一節列出各家的預設與欄位名稱。</li>
<li><strong>公開 IP</strong>：保留的 IP 存在到被刪除為止，有些業者在 IP 沒有附在執行中的主機上時另外計費；AWS 對所有公開 IPv4 計費，包括附在執行中主機上的那些。各家要在價目表逐項查。</li>
<li><strong>備份與快照</strong>：依容量計費，刪除主機不會刪除它們。</li>
<li><strong>網路元件</strong>：負載平衡器、NAT 閘道、bastion 這類元件，在多數業者依存在的時間計費（例如 AWS 的 NAT Gateway），跟後面的主機有沒有在執行無關；也有業者的同類元件不另外收費（例如 Oracle Cloud 的價目表沒有 NAT gateway 這一項，見 <a href="/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目</a>〈查付費主機價格時要輸入的項目〉），各家要在價目表逐項查。</li>
<li><strong>主機以外的服務</strong>：網域的註冊費、DNS 的代管費（有些業者依 zone 按月收費）、CDN 與監控 SaaS 的付費方案。</li>
</ul>
<p>免費方案的額度同樣要算進來：免費的區塊儲存額度是開機磁碟與資料磁碟合計，保留下來的磁碟也佔著它，合計的範圍見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/account-and-home-region/" data-link-title="Oracle Cloud 的帳號結構與 home region：tenancy、Always Free 的範圍與主控台登入欄位" data-link-desc="OCI 的 tenancy、home region、compartment 與 availability domain 各自管什麼，home region 為什麼在申請當下就要決定，Always Free 額度的計算與回收規則，以及主控台登入時租用戶與使用者名稱兩個欄位的分工">Oracle Cloud 的帳號結構與 home region：tenancy、Always Free 的範圍與主控台登入欄位</a>〈Always Free 的額度與它綁在 home region 上的範圍〉。</p>
<p>在作業系統裡下 <code>shutdown</code> 關機，雲端業者那一端記錄的主機狀態不一定跟著變成停止；Oracle Cloud 官方文件〈<a href="https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/resource-billing-stopped-instances.htm">Resource Billing for Stopped Instances</a>〉寫明這樣關機不會停止計費，要從主控台或 API 停止主機。</p>
<p>要確認暫停期間不會被扣費，得在暫停前把這幾類逐項列出來、每一項決定保留或刪除：主控台的主機清單只列主機，看不到這些分開計費的資源；成本分析與預算告警看得到它們，但要等業者的計費資料延遲之後才出現，三家的延遲與出處整理在 <a href="/blog/devops/operations/08-cost-management/spend-planning-before-renting/" data-link-title="租用雲端主機之前的費用規劃：費用訊號的延遲、停止之前的最大超支、扣款的信用卡額度、配額與預算門檻的計算，以及停損停到哪一層" data-link-desc="租用雲端主機或把試用帳號升級成付費帳戶之前，費用失控時從觸發到停止之間的金額怎麼從配額與訊號延遲推出來，再用它推扣款的信用卡額度、配額、預算警示與觸發處置的門檻；Oracle Cloud、Google Cloud 與 AWS 的扣款時點、預先授權、扣款失敗的後果、擋下建立與停止用量的機制各自涵蓋什麼，以及停損停到哪一層的判斷">租用雲端主機之前的費用規劃：費用訊號的延遲、停止之前的最大超支、扣款的信用卡額度、配額與預算門檻的計算，以及停損停到哪一層</a>〈費用訊號的延遲：從資源建立到扣款〉。</p>
<h2 id="暫停期間仍指向這台主機的外部設定">暫停期間仍指向這台主機的外部設定</h2>
<p>主機停止或刪除之後，指向它的外部設定不會自己改變。它們在暫停期間造成的結果，看停止或終止之後那個公開 IP 還在不在自己手上：</p>
<ul>
<li><strong>IP 還在自己手上</strong>（只停服務、停止主機而業者停止時不收回 IP、IP 是保留的）：DNS 與 CDN 照樣把請求送到這個位址，請求得到的是連線被拒或逾時（保留的 IP 沒有附在任何主機上時，封包沒有對象可以回應）。經過 Cloudflare 時，訪客看到的是 Cloudflare 的錯誤頁，依源站的反應分成 521（源站拒絕連線，例如主機開著而 nginx 停了）與 522（連線逾時，例如主機停止、封包沒有回應）。Oracle Cloud 的一次演練量到兩種情形都是 522：把只放行 Cloudflare 的 NSG 從主機的網卡拿掉（主機還開著，封包被丟掉而沒有回應），以及主機停止之後，經 Cloudflare 的請求都在約 20 秒後回 522；521 那一種（主機開著、程序沒有在聽）這次沒有量到。演練的每一步與耗時見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序</a>。外部的監控探測在這段時間會連續失敗，要讓它們在暫停期間不發告警（<a href="/blog/backend/knowledge-cards/alert-silence/" data-link-title="Alert Silence（告警靜音）" data-link-desc="維護期間要暫停某些告警的通知、或懷疑告警該響而沒響時，查 Alertmanager 的 silence 做什麼、它與抑制及週期性靜音時段的差別、它過期之前會藏住什麼，以及怎麼列出與監看還沒過期的靜音">Alert Silence（告警靜音）</a>），暫停期間的告警才不會被當成雜訊，恢復之後出現的真告警也不會跟著被忽略。暫停期長達幾個月時，一個結束時間設得很遠的靜音容易被遺忘，比較清楚的做法是暫停期間停用那幾個探測，恢復時再啟用。</li>
<li><strong>IP 已經還給業者</strong>（停止主機而業者停止時收回 IP、終止主機時收回了臨時公開 IP、刪除了保留的 IP）：雲端業者會把收回的 IP 分配給別的客戶。DNS 紀錄還指著這個 IP 時，網域的流量會送到一台陌生人的主機，訪客看到的是對方的內容，對方的主機也收到原本送往這個網域的請求。對方控制了 DNS 指向的那台主機，就能通過 HTTP 驗證（ACME 的 HTTP-01），替這個網域申請到有效的憑證，所以直連 HTTPS 的訪客一樣會連到對方的內容、把 cookie 送過去。要讓對方較難申請到，網域的 CAA 紀錄用 <code>issue</code> 只列自己用的 CA（有 <code>issue</code> 紀錄時，沒列在裡面的 CA 不得簽發；<code>accounturi</code> 與 <code>validationmethods</code> 只約束它們所在那一筆 <code>issue</code> 的 CA），再用 <code>accounturi</code> 限定自己的 ACME 帳號，或用 <code>validationmethods</code> 限定 <code>dns-01</code>（兩個參數的寫法見 Let&rsquo;s Encrypt 的 CAA 文件；CA 要認得這兩個參數才會照它執行，文件沒有逐一寫明各家 CA 的支援）。設了之後，恢復時有兩個條件要成立：<code>accounturi</code> 綁的是 ACME 帳號，重建主機時要沿用同一個帳號（帳號的金鑰跟其他機密一起備份），新裝的客戶端註冊的新帳號申請不到；<code>validationmethods</code> 要包含續期實際用的驗證方式，只留 <code>dns-01</code> 時 HTTP 驗證一定失敗。網域經過 Cloudflare 而且開著 Universal SSL 時，使用者一加任何 CAA 紀錄，Cloudflare 就自動補上它用的幾家 CA 的 <code>issue</code> 紀錄，其中包括不帶參數的 <code>letsencrypt.org</code>（Cloudflare 的 CAA 文件）；這些紀錄在主控台上看不到，要用 <code>dig CAA &lt;網域&gt;</code> 查。同一家 CA 只要有一筆 <code>issue</code> 符合就准許簽發，所以在這種網域上，<code>accounturi</code> 與 <code>validationmethods</code> 限制不了 Let&rsquo;s Encrypt，防護只剩釋放 IP 之前先改 DNS。這是懸空 DNS（dangling DNS）的成因，處置是在釋放 IP 之前，先刪掉或改掉指向它的紀錄；子網域被接管的情形與處置見 10.6 的〈懸空 DNS 與子網域接管〉。</li>
</ul>
<p>方向相反的設定也會留下來：這台主機的公開 IP 可能被寫進別處的放行清單，例如第三方 API 或付款服務的 IP 白名單、另一台主機或資料庫的防火牆、郵件中繼的允許來源。IP 還給業者之後，拿到這個 IP 的人就繼承了這些放行。所以 IP 釋放之前，這些設定要一併移除；恢復時拿到新的 IP，它們要改成新的值。</p>
<p>CDN 的回源設定與 DNS 紀錄在 Cloudflare 上是同一筆資料：經過 Cloudflare 的網域，CDN 回源的位址就是 proxied DNS 紀錄的值。要讓訪客在暫停期間看到說明頁、而不是錯誤頁時，常見的做法是把紀錄改指到一個靜態的維護頁（CDN 業者的 Workers、Pages 這類服務，或另一台主機），恢復時再改回來。</p>
<h2 id="恢復上線前要重新確認的漂移">恢復上線前要重新確認的漂移</h2>
<p>主機或服務在暫停期間沒有執行，而它依賴的設定在主機外面照樣改變。暫停的人在暫停當下就知道哪些項目會漂移，所以暫停前把要確認的項目、確認的指令與暫停當下的值記下來（例如當時的公開 IP、管理者 IP、憑證效期），恢復時才有比對的基準。各項的狀態與數字可以交給檢查腳本的結果檔記：暫停前跑一次維護的檢查、恢復後再跑一次，報告會印出兩次之間的差異，做法見 <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>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉，結果檔依階段（<code>--phase</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>〈結果檔的讀寫與跟前一次的差異〉；IP 與憑證效期這類不是數字的值不在結果檔裡，照樣要另外記。依維護那一篇的四類狀態來源，再加上雲端業者那一端只在重建時才會碰到的狀態（從「會到期與會改變的外部設定」拆出閒置回收，加上機型容量與映像檔版本），恢復前要比對的對象如下：</p>
<table>
  <thead>
      <tr>
          <th>狀態來源</th>
          <th>暫停期間發生的改變</th>
          <th>恢復前要比對的對象與基準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>會累積的資料</td>
          <td>停止主機以上的範圍沒有新的寫入；只停服務時，系統的 log 與套件快取照常累積；從備份還原時，還原的是備份那一刻的資料</td>
          <td>資料的時間點跟預期的一致、資料完整（資料庫的完整性檢查、筆數或 checksum 跟備份時一致）；還原後資料庫的檔案權限與 SELinux 標籤（<a href="/blog/linux/dotfile/knowledge-cards/mandatory-access-control/" data-link-title="Mandatory Access Control（強制存取控制）：SELinux 與 AppArmor" data-link-desc="檔案權限看起來正確、程式以 root 執行，存取仍然被拒絕；從備份還原或搬移檔案之後服務回 403；或在 log 裡讀到 avc: denied、apparmor=&#34;DENIED&#34; 時">強制存取控制</a> 依標籤判斷存取）跟暫停前一致</td>
      </tr>
      <tr>
          <td>會老化的軟體</td>
          <td>停止的主機沒有安裝任何更新；暫停越久，恢復時已知漏洞越多</td>
          <td>待裝的安全更新數量與是否需要重開機；或用的映像檔是不是最新版。套件庫鎖在固定版本的發行版（Amazon Linux 2023），待裝數量是零不代表沒有新的修補，要先查有沒有新的套件庫版本；發行版過了 EOL 時待裝數量永遠是零（見維護那篇的〈會老化的軟體：各條更新管道與更新後的重開機〉）；發行版以外的管道（容器映像檔、應用程式、自己建置的程式）各自另查</td>
      </tr>
      <tr>
          <td>會漂移的入口</td>
          <td>主機停止期間，主機自己不會改動防火牆；有雲端帳號權限的人仍可能在主控台改規則；恢復時的重建或更新可能帶進新的接聽程式</td>
          <td>主機上的接聽程式與各層的放行範圍，跟暫停前的清單一致（量法見維護那篇的〈對外入口：主機上的接聽程式與每一層防火牆的放行範圍〉；防火牆規則由 Terraform 建立時，<code>terraform plan</code> 看得到既有規則被改、看不到主控台另外新增的規則，範圍見多台主機那一篇的〈清單裡要到主控台確認的項目與 terraform plan 的比對範圍〉）</td>
      </tr>
      <tr>
          <td>會到期與會改變的外部設定</td>
          <td>憑證、網域註冊、token、CLI 的登入可能到期；管理者的 IP 與 CDN 的位址段可能改變；停止再啟動或重建的主機可能拿到新的公開 IP</td>
          <td>憑證效期與續期方式；網域的效期與自動續約；CLI 能否登入；管理者 IP 與 CDN 位址段跟放行規則一致；DNS 紀錄、別處的 IP 白名單與寫死 IP 的設定（防火牆、監控、文件）指向現在的公開 IP</td>
      </tr>
      <tr>
          <td>雲端業者那一端的狀態</td>
          <td>免費額度的主機可能被回收；重建時同一種機型可能沒有容量；映像檔有新版本</td>
          <td>主機的狀態與配額；同一種機型在原區域有沒有容量；映像檔的版本</td>
      </tr>
  </tbody>
</table>
<p>資料完整那一格的完整驗證（資料完整性、服務可用性、恢復時間的量測三層）見 <a href="/blog/devops/operations/06-high-availability/disaster-recovery/" data-link-title="Disaster recovery 策略" data-link-desc="設計災難復原時，先確認恢復路徑走得通再談 RTO/RPO、用 restore drill 把估值變量值、以及知道恢復不是切回流量就結束">Disaster recovery 策略</a>〈Restore 驗證的三個層次〉。</p>
<p>主機不是照計畫暫停的時候（被業者閒置回收、試用結束被停用、管理者離職而沒有交接），手上沒有暫停當下記下的值。這時比對的基準改從三個地方取：repo 裡的部署腳本與設定（應該有的狀態）、DNS 與 CDN 目前的設定值（外面現在指向哪裡），以及雲端主控台上的資源清單（還剩下哪些資源、哪些已經不在）。三者對不起來的地方，就是要先處理的項目。repo 不存在時（前任沒有留下），基準只剩後兩個，而且只看得到外面現在的樣子、看不到應該有的狀態；這時先從主機萃取一份設定收進 repo（萃取的項目見維護那篇的〈接手或第一次盤點一台已經上線的主機〉），再開始比對。Oracle Cloud 上一台 A1 主機被閒置回收之後，停止與刪除兩種情形各自留下的資源，與照原樣重建的腳本，見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>〈被回收之後的重建：怎麼發現、留下與消失的資源、重建腳本〉。</p>
<h2 id="恢復的順序依元件之間的依賴排列">恢復的順序：依元件之間的依賴排列</h2>
<p>恢復時的順序由兩個依賴決定：每一段要用到前一段的產出，以及對外開放之前主機要先處在安全的狀態。後一個依賴成立的前提，是暫停時已經照〈暫停時先收窄的入口〉收窄了入口；沒有收窄時，主機一開機就對外開放，恢復之前要先在雲端防火牆收回對外的放行，或改成用最新的映像檔重建。照這兩個依賴，恢復分成四段，每一段的產出是下一段的前提：</p>
<ul>
<li><strong>主機與網路就緒</strong>：啟動或重建主機、確認公開 IP、確認管理者 IP 在放行規則裡。這一段的產出是管理者連得進主機，後面每一段都要登入主機。</li>
<li><strong>主機內部就緒</strong>：裝安全更新（套件庫鎖在固定版本時先換套件庫的版本，見上面漂移表會老化的軟體那一列）、需要時重開機、跑部署腳本、還原資料並驗證資料完整，再在主機上做只對 loopback 的冒煙測試（服務在聽、回應正確）。應用程式自己的更新（CMS 的核心與外掛）也在這一段做：這時防火牆只放行 SSH，用應用程式的命令列工具（WordPress 的 wp-cli）更新，或經由 SSH 通道連到管理後台（WordPress 會轉址到 <code>siteurl</code> 設定的網域，所以要在本機的 hosts 檔把網域指到 127.0.0.1、通道接在 443；從 WordPress 的轉址行為推論，沒有實測）；先開放 443 再從瀏覽器進後台更新，順序就倒過來了。容器映像檔的重新拉取、自己建置的程式跑漏洞掃描並重新建置，也在這一段做（各條管道見維護那篇〈會老化的軟體：各條更新管道與更新後的重開機〉）。這一段的產出是一台已經更新、服務在跑的主機；它是對外開放的前提，因為一台缺了幾週安全更新的主機，在更新裝好之前就接受外部連線，等於把已知漏洞直接放到網路上。恢復時間有壓力、或暫停期長到要補的更新很多時，另一個做法是用最新的映像檔重建，建好時已經是新版本，換到的是省下裝更新與重開機的時間，前提是要能從 repo 完整重建。</li>
<li><strong>對外入口開放</strong>：雲端防火牆與主機防火牆放行對外的連接埠，CDN 的位址段更新到最新，並從外面直連主機（繞過 CDN）確認入口只放行該放行的來源。這一段的產出是 CDN 連得到源站，所以排在 DNS 指回之前：DNS 先指回來的話，CDN 回源會失敗，訪客在這段時間看到錯誤頁。部署還沒完成、源站回 404 時（例如 nginx 的預設站），副檔名在 Cloudflare 預設快取清單裡的靜態檔，404 會被快取 3 分鐘；HTML 與 JSON 預設不快取，5xx 也不快取（Cloudflare 預設快取行為的文件）。</li>
<li><strong>流量導回</strong>：DNS 紀錄指回這台主機（或從維護頁改回來）、CDN 的設定還原，經過 CDN 從外面驗證網站與寫入端點。外部驗證要經過 CDN 才代表訪客看到的結果，所以排在 DNS 指回之後；監控的探測在驗證通過之後才恢復，否則恢復過程本身會觸發一串告警。</li>
</ul>
<p>憑證的續期要排在它的驗證方式需要的那一段之後（自動續期的協定見 <a href="/blog/backend/knowledge-cards/acme-automation/" data-link-title="ACME Automation" data-link-desc="說明網站憑證如何透過 ACME 自動簽發與續期">ACME Automation</a>）；網域設了 CAA 時，續期用的帳號與驗證方式要在 CAA 允許的範圍內（上面〈暫停期間仍指向這台主機的外部設定〉的 CAA 那一段）：</p>
<ul>
<li><strong>用 HTTP 驗證（ACME 的 HTTP-01）</strong>：CA 依網域的 DNS 連到主機的 80 埠，所以續期要等 DNS 指回這台主機、而且 CA 的連線放得進來之後才做，落在流量導回那一段、DNS 指回之後。源站只放行 CDN 位址段時，CA 的請求要經過 CDN 才送得到源站；DNS 指回之前續期，CA 連到的是維護頁或舊的位址，續期會失敗。</li>
<li><strong>憑證在暫停期間已經過期、又用 HTTP 驗證，前面有 CDN</strong>：CDN 回源驗證源站憑證時（Cloudflare 的 Full (strict)），DNS 指回到續期完成之間，CDN 拒絕這張過期的憑證，訪客看到錯誤頁；驗證請求在 CDN 或源站被轉成 HTTPS 時（Cloudflare 的 Always Use HTTPS，或源站 80 埠回 301），CA 跟著轉到 HTTPS、經 CDN 回源，同樣在 CDN 到源站這一段被擋下，續期失敗（CA 自己跟著轉址時不驗證憑證，依 Let&rsquo;s Encrypt 驗證方式的文件，失敗發生在 CDN 到源站這一段）。SSL 模式是 Full 時這一段不驗證，續期會成功。會失敗的這種情形，這一次續期改用 DNS 驗證，在主機內部就緒那一段先換好憑證。</li>
<li><strong>憑證已經過期、用 HTTP 驗證，前面沒有 CDN</strong>：CA 直接連到 80 埠，不受過期的憑證影響，續期照常在 DNS 指回之後做；從 DNS 指回到續期完成之間，直連的訪客看到憑證過期的錯誤，要縮短這段時間，就在恢復前先把 DNS 的 TTL 調低，或改用 DNS 驗證在主機內部就緒那一段先換好。</li>
<li><strong>用 DNS 驗證（DNS-01）</strong>：續期要的是 DNS 業者的 API token，在主機內部就緒那一段就可以做，而那個 token 本身也是會到期的設定。</li>
<li><strong>CDN 業者發給源站用、效期很長的憑證</strong>（Cloudflare 的 Origin CA 這類）：不必依 DNS 排續期的時機，恢復時查它的效期就好。</li>
<li><strong>源站用的是只給 CDN 驗的自簽憑證、CDN 又不驗證源站憑證</strong>：過期不會讓連線失敗，恢復時仍要記下它的效期。</li>
</ul>
<p>每一段完成的驗證寫成可以重複執行的指令（<code>ss -lntup</code> 的接聽清單、loopback 的 <code>curl</code>、從外面的 <code>curl</code>、DNS 查詢的結果），恢復就變成依序執行這些指令並確認每一段的結果，而不是依靠操作的人記得哪些地方要看。</p>
]]></content:encoded></item><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>多台主機的維護清單：值的分層與合併的位置、用 Ansible 的 inventory 與範本產生每台的清單、由 Terraform 的輸出產生 inventory、跨主機執行的 exit code，以及單台清單的適用範圍</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/multi-host-manifest/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/multi-host-manifest/</guid><description>&lt;p>這篇處理 &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> 那份單台清單要套用到好幾台主機時的做法：清單裡的值依「每台相同、依角色不同、每台不同」分類，以及每台各複製一份時，漏改的那一份為什麼沒有任何訊號；合併這些值的步驟可以放在部署時產生每台一份完整的清單、由檢查腳本自己讀多份檔案合併、一份清單裡的項目依角色過濾、或共用一份清單再由執行時的參數覆寫，各自的代價與適用的條件；用 &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 管理時">組態管理&lt;/a> 工具 Ansible 的 inventory、&lt;code>group_vars&lt;/code> 與 &lt;code>host_vars&lt;/code>、Jinja2 範本產生每台清單的實測，包括變數合併的優先順序（同層的兩個群組依名稱排序，&lt;code>ansible_group_priority&lt;/code> 要寫在 inventory 檔、寫在 &lt;code>group_vars&lt;/code> 裡沒有效果）、兼任兩種角色的主機、YAML 把 &lt;code>no&lt;/code> 讀成 &lt;code>false&lt;/code>、用 &lt;code>to_json&lt;/code> 把值寫進 TOML 時的跳脫與 BMP 以外字元被拒、範本的 &lt;code>{{ }}&lt;/code> 與清單自己的替換語法同形時的兩種結果，以及在產生的那一步就驗證清單；主機由 Terraform 建立時，由它的輸出產生 inventory、再交給 Ansible 產生清單，以及這個做法對 autoscaling 的主機不成立的原因；清單裡要到雲端主控台確認的防火牆規則，&lt;code>terraform plan&lt;/code> 比對得到與比對不到的範圍；用 Ansible 對多台主機執行檢查腳本時，&lt;code>ansible&lt;/code> 指令的 exit code（2、4）與每台的 &lt;code>rc&lt;/code> 怎麼讀、由 Ansible 執行時換來的安全代價；最後是單台清單夠用的條件與改成分層的時機。&lt;/p>
&lt;p>Ansible 與 Terraform 的指令都在容器裡實跑過（ansible-core 2.17.7、Terraform 1.9.8），產生的清單用 Python 3.12 的 &lt;code>tomllib&lt;/code> 解析過；主機的 IP 用文件保留的位址段（203.0.113.0/24）當佔位值。這篇的範本是簡化過的示範，確認的是清單解析得出預期的值；照檢查腳本實際要求的欄位寫範本、經由 SSH 部署到兩台容器、以 &lt;code>-b&lt;/code> 與 &lt;code>systemd-run&lt;/code> 執行 TOML 那一篇的只讀檢查腳本 &lt;code>host-check.py&lt;/code>，以及跨主機的 exit code，在 &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> 實際走過一次。用 Ansible 管理主機上的套件與服務設定（playbook 與 role）不在這篇的範圍。&lt;/p>
&lt;h2 id="主機變成多台時清單裡的值每台相同依角色不同每台不同">主機變成多台時清單裡的值：每台相同、依角色不同、每台不同&lt;/h2>
&lt;p>單台的清單裡每個值都只描述那一台主機；主機變成多台時，同一份清單的值依它跨主機的變化分成三類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>每台相同的值&lt;/strong>：檢查項目本身、檢查腳本的白名單、log 的 regex、會累積的資料的檢查方式、暫停與恢復的步驟（TOML 那一篇示範清單的 &lt;code>[[suspend]]&lt;/code> 與 &lt;code>[[resume]]&lt;/code> 段）。這些描述的是「怎麼檢查」，跟主機是哪一台無關。白名單是檢查腳本允許執行的工具與每個工具允許的選項，腳本載入清單時逐條比對，有一條不符就把整份清單判成格式錯、一項都不執行；它的規則與比對方式寫在 TOML 那一篇的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。&lt;/li>
&lt;li>&lt;strong>依角色不同的值&lt;/strong>：主機的角色決定它上面跑什麼服務，也就決定要檢查哪些入口與 log。跑 nginx 的 web 主機要檢查 443 與 80 的入口、讀 nginx 的 access log；跑 PostgreSQL 的資料庫主機要檢查 5432 只開放給管理者的來源（清單裡寫 &lt;code>exposure = &amp;quot;admin-only&amp;quot;&lt;/code>）、讀資料庫的 log。TOML 那一篇示範清單的 &lt;code>[[entrypoint]]&lt;/code>、&lt;code>[[log]]&lt;/code> 與 &lt;code>[listen_audit]&lt;/code> 段（loopback 以外允許接聽的連接埠）都是依角色不同的值。&lt;/li>
&lt;li>&lt;strong>每台不同的值&lt;/strong>：主機名、公開 IP、網域、資料庫檔案的路徑、依這台的磁碟大小訂的剩餘空間門檻。&lt;/li>
&lt;/ul>
&lt;p>同一個值可以跨兩類：先給所有主機一個共用的預設，再由角色或某一台覆寫，〈用 Ansible 的 inventory、群組變數與範本產生每台的清單〉範例裡的剩餘空間門檻就是這樣，&lt;code>all&lt;/code> 給 80、web 角色改成 85、&lt;code>web1&lt;/code> 再改成 90。分類看的是一個值最後在各台之間有沒有不同、在哪一層開始不同。&lt;/p></description><content:encoded><![CDATA[<p>這篇處理 <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> 那份單台清單要套用到好幾台主機時的做法：清單裡的值依「每台相同、依角色不同、每台不同」分類，以及每台各複製一份時，漏改的那一份為什麼沒有任何訊號；合併這些值的步驟可以放在部署時產生每台一份完整的清單、由檢查腳本自己讀多份檔案合併、一份清單裡的項目依角色過濾、或共用一份清單再由執行時的參數覆寫，各自的代價與適用的條件；用 <a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a> 工具 Ansible 的 inventory、<code>group_vars</code> 與 <code>host_vars</code>、Jinja2 範本產生每台清單的實測，包括變數合併的優先順序（同層的兩個群組依名稱排序，<code>ansible_group_priority</code> 要寫在 inventory 檔、寫在 <code>group_vars</code> 裡沒有效果）、兼任兩種角色的主機、YAML 把 <code>no</code> 讀成 <code>false</code>、用 <code>to_json</code> 把值寫進 TOML 時的跳脫與 BMP 以外字元被拒、範本的 <code>{{ }}</code> 與清單自己的替換語法同形時的兩種結果，以及在產生的那一步就驗證清單；主機由 Terraform 建立時，由它的輸出產生 inventory、再交給 Ansible 產生清單，以及這個做法對 autoscaling 的主機不成立的原因；清單裡要到雲端主控台確認的防火牆規則，<code>terraform plan</code> 比對得到與比對不到的範圍；用 Ansible 對多台主機執行檢查腳本時，<code>ansible</code> 指令的 exit code（2、4）與每台的 <code>rc</code> 怎麼讀、由 Ansible 執行時換來的安全代價；最後是單台清單夠用的條件與改成分層的時機。</p>
<p>Ansible 與 Terraform 的指令都在容器裡實跑過（ansible-core 2.17.7、Terraform 1.9.8），產生的清單用 Python 3.12 的 <code>tomllib</code> 解析過；主機的 IP 用文件保留的位址段（203.0.113.0/24）當佔位值。這篇的範本是簡化過的示範，確認的是清單解析得出預期的值；照檢查腳本實際要求的欄位寫範本、經由 SSH 部署到兩台容器、以 <code>-b</code> 與 <code>systemd-run</code> 執行 TOML 那一篇的只讀檢查腳本 <code>host-check.py</code>，以及跨主機的 exit 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> 實際走過一次。用 Ansible 管理主機上的套件與服務設定（playbook 與 role）不在這篇的範圍。</p>
<h2 id="主機變成多台時清單裡的值每台相同依角色不同每台不同">主機變成多台時清單裡的值：每台相同、依角色不同、每台不同</h2>
<p>單台的清單裡每個值都只描述那一台主機；主機變成多台時，同一份清單的值依它跨主機的變化分成三類：</p>
<ul>
<li><strong>每台相同的值</strong>：檢查項目本身、檢查腳本的白名單、log 的 regex、會累積的資料的檢查方式、暫停與恢復的步驟（TOML 那一篇示範清單的 <code>[[suspend]]</code> 與 <code>[[resume]]</code> 段）。這些描述的是「怎麼檢查」，跟主機是哪一台無關。白名單是檢查腳本允許執行的工具與每個工具允許的選項，腳本載入清單時逐條比對，有一條不符就把整份清單判成格式錯、一項都不執行；它的規則與比對方式寫在 TOML 那一篇的〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉。</li>
<li><strong>依角色不同的值</strong>：主機的角色決定它上面跑什麼服務，也就決定要檢查哪些入口與 log。跑 nginx 的 web 主機要檢查 443 與 80 的入口、讀 nginx 的 access log；跑 PostgreSQL 的資料庫主機要檢查 5432 只開放給管理者的來源（清單裡寫 <code>exposure = &quot;admin-only&quot;</code>）、讀資料庫的 log。TOML 那一篇示範清單的 <code>[[entrypoint]]</code>、<code>[[log]]</code> 與 <code>[listen_audit]</code> 段（loopback 以外允許接聽的連接埠）都是依角色不同的值。</li>
<li><strong>每台不同的值</strong>：主機名、公開 IP、網域、資料庫檔案的路徑、依這台的磁碟大小訂的剩餘空間門檻。</li>
</ul>
<p>同一個值可以跨兩類：先給所有主機一個共用的預設，再由角色或某一台覆寫，〈用 Ansible 的 inventory、群組變數與範本產生每台的清單〉範例裡的剩餘空間門檻就是這樣，<code>all</code> 給 80、web 角色改成 85、<code>web1</code> 再改成 90。分類看的是一個值最後在各台之間有沒有不同、在哪一層開始不同。</p>
<p>一台主機屬於哪個角色，寫在主機清冊裡（Ansible 那一節的 inventory），不從主機的現況推。從現況推的做法（偵測到裝了 nginx 就套 web 的檢查）讓新主機不必登記就拿到檢查，代價是檢查項目由被檢查的狀態決定：nginx 被誤刪或沒裝上時，web 的檢查跟著消失，報告全部是 <code>PASS</code>，正好是清單要抓的那種故障看不見。</p>
<p>每台各複製一份完整的清單是最直接的做法，代價落在每台相同的值上：要修改一個共用的檢查（例如收緊白名單的一條規則、修正一條 regex），就要改每一份；漏改一份時，那台主機照舊跑舊版的檢查。沒有另外的一致性檢查時（例如在 repo 的 CI 裡用 <code>tomllib</code> 讀出各份清單、比對共用段落是否相同），報告裡沒有任何訊號顯示它的檢查比別台舊。所以多台主機的清單要讓三類值各自只寫一次，再在某一個步驟把它們合併成每台主機實際讀到的清單。</p>
<h2 id="合併每台相同依角色不同每台不同的值的位置部署時產生檢查腳本讀多份依角色過濾或執行時覆寫">合併每台相同、依角色不同、每台不同的值的位置：部署時產生、檢查腳本讀多份、依角色過濾、或執行時覆寫</h2>
<p>合併放在哪裡，決定了合併的結果有沒有寫成一份檔案、合併由哪個元件負責，以及檢查腳本要不要改：</p>
<ul>
<li><strong>部署時產生每台一份完整的清單</strong>：共用的檢查寫成範本，依角色與主機的值逐台產生一份 <code>host.toml</code>，部署到主機上。主機上的檢查腳本照舊只讀一份檔案，不必改；載入時比對白名單的那一道檢查，比對的是最後實際要執行的清單；主機上那份檔案就是那台主機實際被檢查的內容，要知道一台主機檢查了什麼，讀那一份就夠。產生的工具可以是 Ansible（下一節），也可以是部署端的一支小程式：讀共用、角色、主機三份 TOML，合併成一個字典，再用 TOML 的序列化函式庫寫出（Python 的 <code>tomli_w</code>，不在標準函式庫裡），這樣寫出來的引號與跳脫由函式庫處理，下一節範本的跳脫問題不會出現（這支程式的寫法是推論，沒有實作）。代價有三項。一是多了一個產生的步驟。二是要決定產生出來的檔案放哪裡：不放進 repo 時，repo 裡只有範本與變數，要讀某一台的完整清單得先跑一次產生；放進 repo 時，每次改範本都要重新產生並一起提交，換到的是 code review 時看得到每台清單的 diff，所以審查的人要逐台確認改動範圍時放進 repo，主機多、每次改動都牽動每一份時不放。三是產生出來的清單一樣會變舊：部署時某一台連不上、用 <code>--limit</code> 只部署了一部分、新主機沒跑到部署，那台主機就照舊跑上一版的清單，報告裡仍然看不出它比 repo 舊。要讓這種落後看得見，在產生時把 repo 的 commit hash 寫進清單（例如 <code>[host]</code> 的一個欄位），或定期用 <code>--check --diff</code> 比對主機上的清單與範本算出來的內容（寫法在下一節）。</li>
<li><strong>檢查腳本自己讀多份檔案合併</strong>：主機上放一份共用的清單、一份角色的、一份主機自己的，腳本載入時合併。不必另外的工具，代價移到腳本身上：腳本要定義合併規則，同一個鍵出現在兩份檔案時誰勝出，表格陣列（<code>[[entrypoint]]</code> 這類）是接在後面還是整個取代。合併的結果只存在於腳本的記憶體裡，除非腳本另外把合併後的清單印出或寫進結果檔；沒有這一步時，規則寫錯或讀清單的人記錯規則，沒有一份檔案可以拿來對出一台主機實際檢查了什麼。白名單的比對要排在合併之後：比對排在合併之前時，比對的是合併前的片段，合併之後才組出來的指令、或被後一份檔案覆寫的值，沒有經過比對就被執行。</li>
<li><strong>一份清單，項目依角色過濾</strong>：所有主機讀同一份清單，每個依角色不同的項目標出它屬於哪些角色（例如 <code>roles = [&quot;web&quot;]</code>），腳本只執行符合這台主機角色的項目。只要過濾，不必定義覆寫的語意（誰蓋過誰、陣列接續還是取代），改一個共用檢查只改一份檔案，白名單比對的是整份清單，包括別的角色的項目。代價是腳本要改：TOML 那一篇的 <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>）沒有這個功能，要決定角色從哪裡來（寫在主機上的一個檔、或執行時傳入），每台的報告也會多出「略過」的項目。這是設計上的選項，示範腳本沒有實作。</li>
<li><strong>共用一份清單，執行時覆寫</strong>：主機上的清單每台相同，每台不同的值在執行時用參數覆寫。<code>host-check.py</code> 的 <code>--set 名稱=值</code> 取代 <code>[vars]</code> 段裡已經有的名稱（<code>[vars]</code> 沒有那個名稱時，腳本把它判成清單格式錯）；腳本讀清單時，把字串欄位裡的 <code>{{名稱}}</code> 換成 <code>[vars]</code> 的值，所以 <code>[host]</code> 的主機名、網域、路徑這類沒有限定允許值的字串欄位都能寫成 <code>{{名稱}}</code>（<code>exposure</code>、<code>where</code> 這類有允許值的欄位，比對允許值在替換之前，寫成 <code>{{名稱}}</code> 會判成格式錯）、由 <code>--set</code> 給值。數字欄位（連接埠、剩餘空間的門檻）不經過替換，表格陣列裡有幾筆項目、陣列裡有哪些值，也不是一個字串，<code>--set</code> 改不到它們，表達不了「多一筆入口」這種結構上的差異。覆寫的值寫在每台主機執行檢查的排程指令上，要讀一台實際檢查了什麼，得同時讀清單與那台的排程設定。</li>
</ul>
<p>選哪一個位置，看每台不同的值是什麼形狀，以及手上已經有什麼工具：</p>
<ul>
<li><strong>每台不同的只有字串（主機名、網域、路徑），而且所有主機的角色相同</strong>：執行時覆寫最省事，不需要任何新工具，清單不必分層；代價是覆寫的值散在各台的排程裡。</li>
<li><strong>角色不只一種，而每台不同的值只有字串</strong>：依角色過濾加上執行時覆寫就夠，角色決定執行哪些項目，<code>--set</code> 給每台的字串；所有主機讀同一份清單，規則最少，前提是檢查腳本可以改（示範腳本沒有過濾的功能）。</li>
<li><strong>每台不同的值有數字或結構（某一台的門檻、某一台多一個入口）</strong>：過濾與覆寫都表達不了，剩下部署時產生與檢查腳本讀多份合併。已經有產生設定檔的工具（Ansible、Terraform），或檢查腳本是別人維護的、不能改時，用部署時產生，換到的是腳本不必改、每台的完整清單看得到；沒有任何產生工具時，部署時產生要另外付引進工具（學一個工具、多一個步驟與一份 inventory）或在部署端寫一支合併程式的成本，而讀多份合併把這份成本換成在檢查腳本裡寫並維護合併規則，所以檢查腳本可以改、又不想在部署端多一個步驟時，讀多份合併是剩下的那一個。</li>
<li><strong>主機由 Terraform 建立</strong>：它負責的是產生 Ansible 要讀的 inventory，清單仍由 Ansible 依範本產生（做法在〈主機由 Terraform 建立時由它的輸出產生 inventory〉）；Terraform 自己的 <code>templatefile()</code> 也能直接寫出每台的清單，放進主機的 user_data 時由 cloud-init 在第一次開機寫到主機上；代價是它只在建立主機時寫一次，之後改清單時，業者不是要重建主機、就是不再套用新的 user_data，主機上的清單就落後了（從 cloud-init 只在第一次開機執行的機制推論，沒有實測），所以會改的值仍要另一個部署步驟。</li>
</ul>
<h2 id="用-ansible-的-inventory群組變數與範本產生每台的清單">用 Ansible 的 inventory、群組變數與範本產生每台的清單</h2>
<p>Ansible 從 inventory（主機清冊：列出受管主機與分組的檔案）讀出受管的主機與分組，變數依層次放在 <code>group_vars/</code> 與 <code>host_vars/</code> 兩個目錄，範本用 Jinja2 寫（Python 的範本引擎：<code>{{ }}</code> 代入變數，<code>{% %}</code> 寫迴圈與條件這類控制語法，<code>|</code> 把變數交給過濾器（filter）這類轉換函式）；inventory 的分層與合併規則在 <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> 卡。三類值的對應是：每台相同的值寫在範本本身與 <code>group_vars/all.yml</code>，依角色不同的寫在 <code>group_vars/&lt;角色&gt;.yml</code>，每台不同的寫在 <code>host_vars/&lt;主機&gt;.yml</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="c"># inventory.yml：兩台 web 主機、一台資料庫主機</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">all</span><span class="p">:</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">children</span><span class="p">:</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">web</span><span class="p">:</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">hosts</span><span class="p">:</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">web1</span><span class="p">:</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">web2</span><span class="p">:</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">db</span><span class="p">:</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">hosts</span><span class="p">:</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">db1:</span></span></span></code></pre></div><p>四個變數檔各自是一份檔案，檔名就是它所屬的群組或主機：</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"># group_vars/all.yml：每台相同的預設值</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">disk_warn_percent</span><span class="p">:</span><span class="w"> </span><span class="m">80</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">collector_path</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/collector/monitor.db</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">country</span><span class="p">:</span><span class="w"> </span><span class="kc">no</span><span class="w">    </span><span class="c"># 刻意示範：這一行會被讀成布林值 false，見下文</span></span></span></code></pre></div>




<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"># group_vars/web.yml：web 角色的值；依角色不同的串列用帶角色名的變數名</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">disk_warn_percent</span><span class="p">:</span><span class="w"> </span><span class="m">85</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">web_listen_ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="m">22</span><span class="p">,</span><span class="w"> </span><span class="m">80</span><span class="p">,</span><span class="w"> </span><span class="m">443</span><span class="p">]</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">web_entrypoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span>- {<span class="w"> </span><span class="nt">name: https, port: 443, 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">6</span><span class="cl"><span class="w">  </span>- {<span class="w"> </span><span class="nt">name: http, port: 80, exposure</span><span class="p">:</span><span class="w"> </span><span class="l">cdn-only }</span></span></span></code></pre></div>




<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"># group_vars/db.yml：資料庫角色的值</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">db_listen_ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="m">22</span><span class="p">,</span><span class="w"> </span><span class="m">5432</span><span class="p">]</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">db_entrypoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span>- {<span class="w"> </span><span class="nt">name: postgres, port: 5432, exposure</span><span class="p">:</span><span class="w"> </span><span class="l">admin-only }</span></span></span></code></pre></div>




<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"># host_vars/web1.yml：只有 web1 不同的值</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">disk_warn_percent</span><span class="p">:</span><span class="w"> </span><span class="m">90</span></span></span></code></pre></div><p>一台主機最後拿到的值是各層合併的結果，<code>ansible-inventory -i inventory.yml --host &lt;主機&gt;</code> 印出合併之後的值。這次的結果：<code>web1</code> 的 <code>disk_warn_percent</code> 是 90（<code>host_vars</code> 蓋過 web 群組的 85），<code>web2</code> 是 85（web 群組蓋過 <code>all</code> 的 80），<code>db1</code> 是 80（db 群組沒有寫，沿用 <code>all</code>）。<code>country</code> 想寫的是挪威的國碼 <code>no</code>（例如給地理封鎖的設定用），三台讀出來卻都是布林值 <code>false</code>：<code>group_vars</code> 是 YAML，Ansible 照 YAML 1.1 的規則把不加引號的 <code>no</code> 讀成 false（YAML 的隱式型別見 <a href="/blog/devops/infra/knowledge-cards/yaml/" data-link-title="YAML" data-link-desc="讀到 .yaml 或 .yml 設定檔、一個沒加引號的值被程式讀成布林值或別的數字、同一份檔案換一個解析器結果不同，或要在 JSON、YAML、TOML 之間選設定檔格式時">YAML</a> 卡），產生出來的清單拿到 <code>country = false</code>。值要當字串用時寫成 <code>country: &quot;no&quot;</code>。</p>
<p>連接埠與入口的變數名帶著角色名（<code>web_listen_ports</code>、<code>db_listen_ports</code>），是為了一台主機同時屬於兩個角色的情形。兩個群組用同一個變數名時（例如都叫 <code>listen_ports</code>），把同一台主機同時放進 web 與 db 兩個群組實測，它拿到的是 web 的 <code>[22, 80, 443]</code>：同一層的群組依名稱的字母順序合併，排在後面的蓋過前面的，web 排在 db 後面。要指定誰優先，替群組設 <code>ansible_group_priority</code>（預設是 1，數字大的較晚合併而勝出）；這個變數寫在 <code>group_vars/db.yml</code> 裡沒有改變結果，寫在 inventory 檔裡 db 群組的 <code>vars</code> 底下（<code>db: {vars: {ansible_group_priority: 10}, hosts: …}</code>）之後，結果變成 db 的 <code>[22, 5432]</code>。這台主機兩組連接埠都要允許，而合併規則只會挑其中一組，兩個群組都定義的每一個變數都會這樣。在 <code>host_vars</code> 替它寫一份完整的值，等於又做出一份副本：web 角色的連接埠改了，這一份就是漏改而沒有訊號的那一份。所以依角色不同的串列，每個角色用自己的變數名，範本依這台主機所屬的群組（Ansible 的 <code>group_names</code>）取聯集：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jinja" data-lang="jinja"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">{%</span> <span class="k">set</span> <span class="nv">eps</span> <span class="o">=</span> <span class="o">(</span><span class="nv">web_entrypoints</span> <span class="k">if</span> <span class="s1">&#39;web&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[])</span> <span class="o">+</span> <span class="o">(</span><span class="nv">db_entrypoints</span> <span class="k">if</span> <span class="s1">&#39;db&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[])</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="x"></span><span class="cp">{%</span> <span class="k">for</span> <span class="nv">ep</span> <span class="k">in</span> <span class="nv">eps</span> <span class="cp">%}</span><span class="x">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="x">[[entrypoint]]
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="x">name = </span><span class="cp">{{</span> <span class="nv">ep.name</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="x">port = </span><span class="cp">{{</span> <span class="nv">ep.port</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="x">exposure = </span><span class="cp">{{</span> <span class="nv">ep.exposure</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="x"></span><span class="cp">{%</span> <span class="k">endfor</span> <span class="cp">%}</span></span></span></code></pre></div><p>同時屬於 web 與 db 的主機，產生出來的清單有三筆 <code>[[entrypoint]]</code>（https、http、postgres），只屬於 web 的主機有前兩筆，<code>tomllib</code> 讀出來都是表格陣列。每個角色的值仍然只寫在那個角色的變數檔裡一次。連接埠的聯集兩邊都有 22，串接之後要用 <code>unique</code> 去掉重複（寫法在下面的範本節錄）。聯集只適用於串列；兩個角色各自給了一個單一值（例如各自的剩餘空間門檻）時，範本要寫出取法（取嚴的那一個，<code>[web_disk_warn, db_disk_warn] | min</code>），或把那個值放在 <code>host_vars</code>，因為兼任的主機只有一台，它的單一值本來就屬於每台不同的值。入口與 log 這類「一串項目、每項幾個欄位」的值，用 <code>{% for %}</code> 逐欄輸出成表格陣列，不直接 <code>to_json</code> 整串：<code>to_json</code> 輸出的是 JSON 物件，TOML 讀不懂（下面〈值寫進 TOML 的方式〉那幾種結果的最後一項）。</p>
<p>Ansible 的 <code>template</code> 模組依範本把這些值寫進每台的清單。下面是範本 <code>host.toml.j2</code> 其餘段落的節錄（上面的 <code>[[entrypoint]]</code> 迴圈也在同一份範本裡）。段名沿用 TOML 那一篇的示範清單；段裡的鍵分兩類：<code>[host]</code> 的 <code>name</code>、<code>[vars]</code> 的 <code>collector_db</code>、<code>[[suspend]]</code> 的 <code>verify</code>、<code>command</code>、<code>expect_stdout</code>，以及 <code>[[entrypoint]]</code> 的 <code>name</code>、<code>port</code> 與 <code>exposure</code> 是那份清單的欄位（<code>[[entrypoint]]</code> 在那份清單裡另有 <code>protocol</code>、<code>where</code>、<code>enforced_by</code> 與各層的 <code>verify</code>，這裡省略），其餘的（<code>allowed_ports</code>、<code>[[filesystem]]</code> 的 <code>mount</code> 與 <code>warn_percent</code>、<code>[vars]</code> 的 <code>country</code>、<code>[[suspend]]</code> 的 <code>name</code>）是為了這裡的示範取的。所以這份範本示範的是值怎麼代入，不能直接配 <code>host-check.py</code> 使用，要換成那份清單實際的鍵名；那份清單另有必填的段落與欄位（<code>[host]</code> 的 <code>provider</code>、<code>region</code>、<code>role</code>，以及 <code>[[log]]</code>、<code>[[accumulation]]</code>、<code>[updates]</code>、<code>[[suspend]]</code>、<code>[[resume]]</code>），其中 <code>role</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>〈變數檔與範本：檢查腳本實際要求的欄位、兼任兩種角色的主機〉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jinja" data-lang="jinja"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="x">[host]
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="x">name = </span><span class="cp">{{</span> <span class="nv">inventory_hostname</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="x">[listen_audit]
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="x">allowed_ports = </span><span class="cp">{{</span> <span class="o">((</span><span class="nv">web_listen_ports</span> <span class="k">if</span> <span class="s1">&#39;web&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[])</span> <span class="o">+</span> <span class="o">(</span><span class="nv">db_listen_ports</span> <span class="k">if</span> <span class="s1">&#39;db&#39;</span> <span class="k">in</span> <span class="nv">group_names</span> <span class="k">else</span> <span class="o">[]))</span> <span class="o">|</span> <span class="nf">unique</span> <span class="o">|</span> <span class="nf">to_json</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="x">[[filesystem]]
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="x">mount = &#34;/&#34;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="x">warn_percent = </span><span class="cp">{{</span> <span class="nv">disk_warn_percent</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="x">[vars]
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="x">collector_db = </span><span class="cp">{{</span> <span class="nv">collector_path</span> <span class="o">|</span> <span class="nf">to_json</span><span class="o">(</span><span class="nv">ensure_ascii</span><span class="o">=</span><span class="kp">False</span><span class="o">)</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="x">country = </span><span class="cp">{{</span> <span class="nv">country</span> <span class="o">|</span> <span class="nf">to_json</span> <span class="cp">}}</span><span class="x">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="x">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="x">[[suspend]]
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="x">name = &#34;verify-db&#34;
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="x">verify = { command = &#34;sqlite3 -init /dev/null -readonly -safe </span><span class="cp">{%</span> <span class="k">raw</span> <span class="cp">%}</span>{{collector_db}}<span class="cp">{%</span> <span class="k">endraw</span> <span class="cp">%}</span><span class="x"> &#39;pragma quick_check&#39;&#34;, expect_stdout = &#34;ok&#34; }</span></span></span></code></pre></div>




<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"># 在執行指令的這台機器上，依 inventory 逐台產生清單（out/ 目錄要先存在）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -c <span class="nb">local</span> -m template <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -a <span class="s2">&#34;src=host.toml.j2 dest=out/{{ inventory_hostname }}.toml&#34;</span></span></span></code></pre></div><p><code>ansible</code> 指令直接執行單一模組（ad-hoc 指令），不必另外寫 playbook。指令裡要依環境換的位置：<code>all</code> 是要產生清單的主機範圍，可以換成一個群組名（<code>web</code>）；<code>src</code> 是範本的檔名；<code>-c local</code> 讓三台的檔案都寫在執行指令的這台機器上，<code>dest</code> 裡的 <code>{{ inventory_hostname }}</code> 由 Ansible 換成每台的主機名，所以每台各得到一份。要直接部署到各台主機時拿掉 <code>-c local</code>，Ansible 改成經由 SSH 連到每台主機；上面手寫的 inventory 只寫了主機名，Ansible 會拿 <code>web1</code> 當成連線的位址，所以每台要加上 <code>ansible_host: &lt;IP 或 DNS 名稱&gt;</code>，或讓 <code>~/.ssh/config</code> 有同名的 <code>Host</code>。同時，<code>dest</code> 改成檢查腳本在主機上讀清單的固定位置（絕對路徑）；保留相對路徑的話，檔案會寫在遠端的工作目錄底下。<code>template</code> 與 <code>copy</code> 不會建立 <code>dest</code> 的上層目錄，目錄不存在時實際執行以 <code>Destination directory … does not exist</code> 失敗，而 <code>--check</code> 的預覽報不出這個錯，所以部署時先用 <code>file</code> 模組建目錄。部署到兩台容器的完整 playbook 在 <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>〈部署清單：建目錄、copy 與 template、validate 改用檢查腳本的載入函式、&ndash;check 的預覽〉。</p>
<h3 id="值寫進-toml-的方式直接輸出to_json-與-ensure_ascii">值寫進 TOML 的方式：直接輸出、to_json 與 ensure_ascii</h3>
<p>Jinja2 把變數寫進檔案時，用的是 Python 的寫法，不是 TOML 的寫法；<code>to_json</code> 過濾器改用 JSON 的寫法輸出，而 JSON 的字串、數字、布林值與陣列 TOML 都讀得懂。範本裡的字串、布林值與陣列因此都經過 <code>to_json</code>，<code>warn_percent</code> 與 <code>port</code> 那幾行是整數，Python 與 TOML 的寫法相同，直接輸出也解析得出來。各種輸出方式的實測結果：</p>
<ul>
<li><strong>直接輸出 <code>{{ 變數 }}</code></strong>：布林值寫成 <code>False</code>，<code>tomllib</code> 在那一行報 <code>Invalid value</code>；字串不帶任何引號，一律解析失敗；字串陣列依內容選引號，<code>['nginx']</code> 被 TOML 當成字面字串（literal string）而讀得懂，同時含單引號與雙引號的值解析失敗，含反斜線的值（<code>C:\dir</code>）被寫成兩個反斜線，而字面字串不處理跳脫，解析出來的值安靜地多了一個反斜線。</li>
<li><strong><code>to_json</code></strong>：引號與反斜線被跳脫成 TOML 也接受的形式，<code>a&quot;b\c.example</code> 輸出成 <code>&quot;a\&quot;b\\c.example&quot;</code>，<code>tomllib</code> 解析回來是原本的字串。非 ASCII 字元被寫成反斜線加 <code>u</code> 與四位十六進位碼的跳脫：基本多文種平面（Basic Multilingual Plane，BMP，碼位 U+0000 到 U+FFFF）以內的中文照樣讀得懂；平面以外的字元（emoji、CJK 擴充 B 區的字）在 JSON 的 <code>\u</code> 跳脫裡要拆成兩個 16 位元的碼（surrogate pair），而 TOML 的 <code>\u</code> 只接受一個完整的 Unicode 碼位，拆開的半個碼位被 <code>tomllib</code> 以 <code>Escaped character is not a Unicode scalar value</code> 拒絕。</li>
<li><strong><code>to_json(ensure_ascii=False)</code></strong>：非 ASCII 字元原字照寫，上面幾種字串都解析得出來，範本裡的字串因此都用這個寫法。</li>
<li><strong><code>to_json</code> 遇到 TOML 沒有的值</strong>：變數沒有設值時的 <code>null</code>，與值是字典時的 JSON 物件，<code>tomllib</code> 分別報 <code>Invalid value</code> 與 <code>Expected '='</code>。範本用到的變數都要在變數檔裡設值；字典與字典的串列照上面 <code>[[entrypoint]]</code> 的寫法逐欄輸出。</li>
</ul>
<h3 id="範本的---與清單自己的替換語法同形">範本的 {{ }} 與清單自己的替換語法同形</h3>
<p>示範清單用 <code>{{collector_db}}</code> 表示「執行前換成 <code>[vars]</code> 的 <code>collector_db</code>」，這個寫法跟 Jinja2 的替換語法同形，Ansible 產生清單時會先把它當成 Jinja2 的變數處理，實測有兩種結果：</p>
<ul>
<li><strong>產生失敗</strong>：Ansible 沒有叫 <code>collector_db</code> 的變數時，那一台的產生停下來，訊息是 <code>AnsibleUndefinedVariable: 'collector_db' is undefined</code>。</li>
<li><strong>安靜替換</strong>：Ansible 剛好有一個同名的變數（例如在 <code>host_vars</code> 寫了 <code>collector_db: /srv/other.db</code>）時，產生成功，指令裡的 <code>{{collector_db}}</code> 被換成 <code>/srv/other.db</code>，清單的 <code>[vars]</code> 寫的卻是另一個路徑，報告看不出這兩者不一致。</li>
</ul>
<p>兩種結果在產生的那一刻給的訊號不同：產生失敗讓產生停下來，安靜替換產生出一份看起來正確、卻檢查了另一個檔案的清單，所以安靜替換比較危險。範本裡把那一段包在 <code>{% raw %}</code> 與 <code>{% endraw %}</code> 之間，Jinja2 原樣輸出，產生的清單裡留著 <code>{{collector_db}}</code>，交給檢查腳本去換；上面的變數檔把 Ansible 這一端的變數取名 <code>collector_path</code>，跟清單的 <code>collector_db</code> 分開，也是為了避開同名。另一種避開撞名的做法是讓清單的替換語法換一個寫法（例如 <code>${collector_db}</code>，Jinja2 預設不處理），代價是改了既有清單與檢查腳本的格式。</p>
<h3 id="產生時驗證清單與比對主機上的清單">產生時驗證清單與比對主機上的清單</h3>
<p>上面的錯誤裡，布林值與字串直接輸出、<code>null</code>、字典、BMP 以外字元的跳脫這幾種會產生出解析不了的清單；另外三種產生出的清單解析得了、值卻是錯的（<code>country: no</code> 變成 <code>false</code>、字面字串多一個反斜線、<code>{{collector_db}}</code> 被安靜替換），<code>validate</code> 攔不到它們。解析不了的那幾種沒有攔下來的話，壞掉的清單照樣部署到主機，等下一次排程執行時，檢查腳本才以 exit code 2（清單格式錯）結束，那台主機在修好之前一項檢查都沒有跑。<code>template</code> 模組的 <code>validate</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"># validate 的值是一條指令，%s 由 Ansible 換成暫存檔的路徑；參數用 JSON 形式傳，避免 key=value 寫法把指令裡的空白切開</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -c <span class="nb">local</span> -m template -a <span class="s1">&#39;{&#34;src&#34;: &#34;host.toml.j2&#34;, &#34;dest&#34;: &#34;out/{{ inventory_hostname }}.toml&#34;, &#34;validate&#34;: &#34;python3 -c \&#34;import tomllib,sys; tomllib.load(open(sys.argv[1],&#39;</span><span class="s2">&#34;&#39;&#34;</span><span class="s1">&#39;rb&#39;</span><span class="s2">&#34;&#39;&#34;</span><span class="s1">&#39;))\&#34; %s&#34;}&#39;</span></span></span></code></pre></div><p>實測的結果：範本產生出 <code>flag = True</code> 這種 TOML 讀不懂的值時，那一台以 <code>failed to validate</code> 失敗，訊息帶著 <code>TOMLDecodeError: Invalid value</code>，目的地沒有寫出任何檔案；範本正常時照常寫出。用 <code>tomllib</code> 當 <code>validate</code> 只驗得到 TOML 的語法；清單的欄位與白名單是否符合，要讓檢查腳本自己讀一次才知道。檢查腳本的 <code>load_manifest()</code> 只載入與驗證清單、不執行任何檢查，用 Python 的 <code>runpy</code> 載入腳本再呼叫它當 <code>validate</code> 時，<code>exposure</code> 寫了不在允許值裡的值那一台以 <code>failed to validate</code> 失敗，主機上的清單維持舊版；<code>validate</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>〈部署清單：建目錄、copy 與 template、validate 改用檢查腳本的載入函式、&ndash;check 的預覽〉。</p>
<p>主機上的清單跟範本算出來的內容是否一致，用 <code>--check --diff</code> 比對：<code>ansible &lt;主機&gt; -i inventory.yml -m template -a &quot;src=host.toml.j2 dest=&lt;清單的路徑&gt;&quot; --check --diff</code> 不寫任何檔案，只印出兩者的差異。實測時先把產生好的檔案手改成別的內容，這條指令印出 <code>--- before</code> 與 <code>+++ after</code> 兩邊的差異行、把那一台標成 <code>CHANGED</code>，檔案維持手改後的內容。定期對所有主機跑一次，標成 <code>CHANGED</code> 的那幾台，就是清單落後於 repo、或在主機上被改過的主機。這條指令有主機標成 <code>CHANGED</code> 時，<code>ansible</code> 的 exit code 仍是 0，所以接上排程時要讀輸出裡的 <code>CHANGED</code>，不能只看 exit code（對兩台容器經由 SSH 實測，在其中一台手改清單之後）。</p>
<h2 id="主機由-terraform-建立時由它的輸出產生-inventory">主機由 Terraform 建立時由它的輸出產生 inventory</h2>
<p>主機由 Terraform 建立、而且每台主機各自是 Terraform 的一個資源時，它的 <a href="/blog/devops/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State（IaC 狀態檔）</a> 已經記著每台主機的 IP；inventory 另外手寫一份的話，新增或刪除主機時要改兩個地方，漏改的那一份就指著不存在的主機，或漏掉新的主機。由 Terraform 的輸出產生 inventory，主機清冊就只剩一個來源，Ansible 再讀這份 inventory 產生每台的清單。下面的設定用 <code>locals</code>（設定檔內部的具名常數）代替實際的主機資源（實際的設定裡，IP 來自主機資源的屬性，例如 Oracle Cloud 的 <code>oci_core_instance</code> 的 <code>public_ip</code>），每台主機的角色寫成串列，用 <code>local_file</code> 寫出 inventory：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  hosts</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    web1</span>  <span class="o">=</span><span class="n"> { roles</span> <span class="o">=</span><span class="n"> [&#34;web&#34;], public_ip</span> <span class="o">=</span> <span class="s2">&#34;203.0.113.10&#34;</span> }
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    db1</span>   <span class="o">=</span><span class="n"> { roles</span> <span class="o">=</span><span class="n"> [&#34;db&#34;], public_ip</span> <span class="o">=</span> <span class="s2">&#34;203.0.113.20&#34;</span> }
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    both1</span> <span class="o">=</span><span class="n"> { roles</span> <span class="o">=</span><span class="n"> [&#34;web&#34;, &#34;db&#34;], public_ip</span> <span class="o">=</span> <span class="s2">&#34;203.0.113.30&#34;</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="c1">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 依角色分組，寫出 Ansible 讀得懂的 inventory.yml
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;local_file&#34; &#34;inventory&#34;</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  filename</span>        <span class="o">=</span> <span class="s2">&#34;${path.module}/inventory.yml&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  file_permission</span> <span class="o">=</span> <span class="s2">&#34;0644&#34; # 預設值是 &#34;0777&#34;</span><span class="err">，</span><span class="k">實際權限再扣掉建立檔案那個程序的</span> <span class="k">umask</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  content</span> <span class="o">=</span> <span class="k">yamlencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">    all</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">      children</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">for</span> <span class="k">role</span> <span class="k">in</span> <span class="k">distinct</span><span class="p">(</span><span class="k">flatten</span><span class="p">([</span><span class="k">for</span> <span class="k">h</span> <span class="k">in</span> <span class="k">values</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">hosts</span><span class="p">)</span> <span class="err">:</span> <span class="k">h</span><span class="p">.</span><span class="k">roles</span><span class="p">]))</span> <span class="err">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">        role</span> <span class="o">=</span><span class="n">&gt; { hosts</span> <span class="o">=</span><span class="n"> { for name, h in local.hosts : name</span> <span class="o">=</span><span class="n">&gt; { ansible_host</span> <span class="o">=</span> <span class="k">h</span><span class="p">.</span><span class="k">public_ip</span> } <span class="k">if</span> <span class="k">contains</span><span class="p">(</span><span class="k">h</span><span class="p">.</span><span class="k">roles</span><span class="p">,</span> <span class="k">role</span><span class="p">)</span> } }
</span></span><span class="line"><span class="ln">18</span><span class="cl">      }
</span></span><span class="line"><span class="ln">19</span><span class="cl">    }
</span></span><span class="line"><span class="ln">20</span><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">}
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">output &#34;hosts&#34; { value</span> <span class="o">=</span> <span class="k">local</span><span class="p">.</span><span class="k">hosts</span> }</span></span></code></pre></div><p><code>yamlencode</code> 那一段的兩層 <code>for</code>：外層把每台主機的角色串列攤平、取出不重複的角色（<code>web</code>、<code>db</code>），每個角色成為 <code>children</code> 底下的一個群組；內層把角色串列裡含有這個角色的主機收進那個群組的 <code>hosts</code>，每台帶上自己的 <code>ansible_host</code>（Ansible 連線時用的位址）。<code>terraform apply</code> 之後，<code>inventory.yml</code> 裡 <code>both1</code> 同時出現在 db 與 web 兩個群組，上一節的範本就會替它取兩個角色的聯集。角色寫成單一字串時（<code>role = &quot;web&quot;</code>），一台主機只會進一個群組，兼任的主機拿不到另一個群組的任何變數，那個角色的入口與 log 整組不在它的清單裡，而且不會報錯。</p>
<p><code>ansible-inventory -i inventory.yml --graph</code> 列出的分組跟 <code>locals</code> 一致；<code>terraform output -json hosts</code> 印出同一份資料的 JSON，給 Ansible 以外的工具讀。這份 inventory 是 Terraform 管理的一個資源（<code>local_file</code>），它的內容記在 State 裡：手動在檔案末端加一行之後，<code>terraform plan</code> 回報 <code>local_file.inventory will be created</code>，下一次 <code>apply</code> 會把檔案重新寫回 Terraform 算出的內容，手加的那一行消失。plan 寫的是 <code>will be created</code> 而不是修改：<code>local_file</code> 讀取時發現檔案內容跟 State 記的不同，就把這個資源當成不存在，計畫裡改成重新建立（這是從 plan 的訊息推論的機制）。所以每台不同的值裡，Terraform 知道的那幾個（IP、角色）由它產生，其他的（資料庫路徑、門檻）寫在 Ansible 的 <code>host_vars</code>，兩邊各管各的，不在產生出來的檔案上手改；仍有變數要靠 <code>ansible_group_priority</code> 決定哪個群組勝出時，inventory 改由 Terraform 產生之後，它要寫進 <code>yamlencode</code> 裡那個群組的 <code>vars</code>；兼任兩種角色的主機照 Ansible 那一節的聯集寫法，不靠優先順序。</p>
<p>這個做法的前提是每台主機都在 State 裡。由 autoscaling 群組或 instance pool 依流量開關的主機，Terraform 只宣告群組本身，個別主機由業者的服務建立與刪除，不在 State 裡，從 State 產生的 inventory 是空的、或一直落後於實際的主機。這一類主機多半是同一個角色、彼此可以互相取代，每台不同的值只剩主機名這類字串，比較貼近〈合併每台相同、依角色不同、每台不同的值的位置〉的執行時覆寫：同一份清單放進主機的映像檔，或在開機時寫入，主機名由執行時的參數給（這是從機制推出的做法，沒有實測）。這類主機生命週期短，結果檔裡常常沒有上一次的紀錄可比，跨主機的比較要交給監控。</p>
<p><code>local_file</code> 寫出的 inventory 只存在執行 <code>apply</code> 的那台機器上。團隊共用 remote backend 的 State、或在 CI 上跑 <code>plan</code> 時，那台機器的工作目錄裡沒有這個檔（或內容不同），每一次 <code>plan</code> 都回報 <code>local_file.inventory will be created</code>；〈清單裡要到主控台確認的項目與 terraform plan 的比對範圍〉用 <code>plan</code> 偵測防火牆規則的漂移時，同一份 State 的 <code>plan</code> 因此永遠不是 <code>No changes</code>，漂移告警不是一直響、就是被忽略。這種情形下改用直接讀 State 的 inventory plugin（Ansible 的 <code>cloud.terraform</code> collection 的 <code>terraform_state</code>），inventory 不寫成檔案，每次執行時從 State 讀出主機（依那個 collection 的文件推論，沒有實測）；<code>local_file</code> 適合只有一個人、在同一台機器上 <code>apply</code> 與執行 Ansible 的情形。</p>
<h2 id="清單裡要到主控台確認的項目與-terraform-plan-的比對範圍">清單裡要到主控台確認的項目與 terraform plan 的比對範圍</h2>
<p>TOML 那一篇的清單裡，<code>where = &quot;console&quot;</code> 的項目（雲端的網路安全群組（Network Security Group，NSG）規則，概念見 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a>）只印出來、等人到主控台確認。主機與防火牆規則都由 Terraform 建立時，那些規則的宣告就在 Terraform 的設定裡，<a href="/blog/devops/infra/knowledge-cards/terraform-plan-apply/" data-link-title="terraform plan / apply" data-link-desc="IaC 的兩個核心操作：plan 只看不動（產出差異報告）、apply 真的動（執行差異）"><code>terraform plan</code></a> 會比對 State 裡每個資源的屬性跟雲端的實際值，有人在主控台改了某一條由 Terraform 建立的規則，plan 會列出差異（<a href="/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift（設定漂移）</a>）。plan 看不到的是 State 裡沒有的資源：規則在 Terraform 裡寫成各自獨立的資源時（Oracle Cloud 的 <code>oci_core_network_security_group_security_rule</code> 是一條規則一個資源），有人在主控台另外新增一條規則，那一條不在 State 裡，plan 照樣回報 <code>No changes</code>。所以 plan 能取代的是確認既有的規則沒有被改，確認沒有多出來的規則仍然要列出雲端上的規則跟宣告比對。這是從 plan 的比對機制推出的做法，這次沒有對雲端的防火牆規則實測。</p>
<h2 id="用-ansible-對多台主機執行檢查腳本整批的-exit-code每台的-rc-與執行身分的代價">用 Ansible 對多台主機執行檢查腳本：整批的 exit code、每台的 rc 與執行身分的代價</h2>
<p>清單部署到各台之後，檢查腳本可以照舊由每台自己的排程執行，也可以由 Ansible 對整個 inventory 一次執行。exit code 的讀法用一條會依主機名回不同 exit code 的指令（web1 回 0、web2 回 1、db1 回 2）在容器裡實測：</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"># 實測用的指令：-c local 在執行指令的這台機器上跑，-o 讓每台的結果印成一行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -c <span class="nb">local</span> -m <span class="nb">command</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -a <span class="s2">&#34;sh -c &#39;case {{ inventory_hostname }} in web2) exit 1;; db1) exit 2;; esac&#39;&#34;</span> -o</span></span></code></pre></div><p>輸出裡 web1 標成 <code>CHANGED</code>，web2 與 db1 標成 <code>FAILED!</code>，各自的 <code>&quot;rc&quot;</code> 欄位是 1 與 2；<code>ansible</code> 指令本身的 exit code 是 2，依 Ansible 的文件，這個值代表至少一台主機的任務失敗，跟失敗的是哪一台、各自回了什麼 <code>rc</code> 無關；有主機連不上時這個值是 4（停掉一台容器實測）。所以接上排程時，告警的條件寫成 <code>ansible</code> 的 exit code 不是 0，再讀每台的 <code>rc</code> 決定處置；連不上的主機標成 <code>UNREACHABLE!</code>、沒有 <code>rc</code>，處置是查連線與 SSH，跟清單或主機上的檢查無關。換成檢查腳本時，它的 exit code 1（至少一項 <code>WARN</code>）與 2（清單格式錯）在 Ansible 的輸出裡同樣標成 <code>FAILED!</code>，兩者的處置不同（處理主機或改清單），要讀每台的 <code>rc</code> 才分得開；<code>command</code> 模組每次執行都回報 <code>CHANGED</code>，這個字不代表主機被改動，只代表指令執行了。</p>
<p>換成檢查腳本時，執行的身分與限制要跟單台時相同：TOML 那一篇的腳本以 root 執行，並套上 <code>systemd-run</code> 的檔案系統與網路限制。Ansible 經由 SSH 登入的帳號不是 root，要加 <code>--become</code>（<code>-b</code>）讓 Ansible 在主機上經由 sudo 切成 root：</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"># 部署後對每台執行檢查；systemd-run 的屬性照 TOML 那一篇〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ansible all -i inventory.yml -b -m <span class="nb">command</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -a <span class="s2">&#34;systemd-run --wait --pipe --collect -p ProtectSystem=strict ... python3 &lt;host-check.py 的路徑&gt; --manifest &lt;清單在主機上的路徑&gt;&#34;</span> -o</span></span></code></pre></div><p><code>...</code> 是 TOML 那一篇那段 <code>systemd-run</code> 的其他屬性，<code>&lt;host-check.py 的路徑&gt;</code> 與 <code>&lt;清單在主機上的路徑&gt;</code> 換成部署時放檔案的位置。不加 <code>-b</code> 時，這條指令裡的 <code>systemd-run</code> 以登入帳號執行，以 <code>Access denied</code> 失敗、<code>rc</code> 是 1，跟檢查腳本至少一項 <code>WARN</code> 的 <code>rc</code> 相同；兩者要看輸出裡有沒有檢查腳本結尾的 <code>summary:</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〉）。拿掉 <code>systemd-run</code>、以登入帳號直接執行腳本時，腳本讀不到 firewalld、sshd 設定與幾個目錄：那一篇用加了 <code>systemd-journal</code> 與 <code>adm</code> 群組的專用帳號實測，<code>PASS</code> 的項目從 37 項掉到 29 項；沒有這兩個群組的登入帳號另外讀不到 journal（實作紀錄的同一節）。sudo 要不要密碼依主機的 sudoers 設定而定，要密碼時的兩種給法（<code>-K</code> 在執行時輸入、放進 Ansible Vault 加密的變數）在實作紀錄〈受管主機與控制端的容器：Oracle Linux 10 加 systemd 與 sshd、python:3.12-slim 加 ansible-core 與 Terraform〉。</p>
<p>由 Ansible 加 <code>-b</code> 執行的代價是安全上的：執行 Ansible 的那台機器（控制端）要握有能 SSH 登入每台主機、再經由 sudo 切成 root 的憑證。單台時，檢查是主機自己的 timer 以 root 在本機執行，沒有任何一個地方拿得到所有主機的 root；改由 Ansible 集中執行之後，控制端被入侵就等於每台主機都被入侵，檢查腳本「不改動主機」的保證管不到這一條路。縮小這個範圍的做法有三種，都沒有實測：</p>
<ul>
<li><strong>每台照舊用自己的 timer 執行，集中的只有結果</strong>：結果檔的目錄是 <code>StateDirectoryMode=0700</code>、擁有者是 root，不帶 <code>-b</code> 的帳號讀不到；要讓控制端用一般帳號讀，得把那個目錄改成 0750 並給一個讀取用的群組，或由監控在主機上收集結果再送出去。</li>
<li><strong>sudoers 只允許那一條檢查指令</strong>：Ansible 的 become 會把指令包成 <code>/bin/sh -c '…'</code> 再交給 sudo，限定指令的 sudoers 對不上，所以執行檢查時不加 <code>-b</code>，在 <code>command</code> 模組裡自己寫 <code>sudo -n systemd-run …</code>；部署用另一個帳號與另一把金鑰，只在部署時使用。排程用的那把金鑰被偷時，拿到的只是以 root 執行檢查（從 become 的機制推論）。</li>
<li><strong>ansible-pull</strong>：每台定時從 repo 拉 playbook，在本機產生自己的清單並執行檢查，沒有任何一個地方握有所有主機的 root；代價是每台主機要裝 Ansible、要有讀 repo 的憑證，而且主機要知道自己屬於哪些角色。</li>
</ul>
<p>每台主機的結果檔照舊留在那台主機上（<code>/var/lib/host-check/last.json</code>），每台跟自己的上一次比較；腳本不比較同一個角色的不同主機（web1 的 access log 計數遠高於 web2 這類），這種跨主機的比較要把每台的數字收集到一個地方，那是監控的範圍，各主機的指標怎麼收集見 <a href="/blog/backend/04-observability/vendors/prometheus/multi-host-scraping/" data-link-title="多台主機的指標收集：讀取架構、主機標籤、目標清單、監控主機的位置與主機消失的偵測" data-link-desc="服務分到多台主機之後，Prometheus 怎麼讀到每一台：exporter 的部署位置與連接埠、中央直接讀取與各主機先收再推送的架構、代表主機的 host 標籤加在哪裡、目標清單由什麼產生，主機停掉與主機從清單上消失各自怎麼偵測，以及監控元件放在哪一台、compose 檔怎麼依主機角色拆開；附在單機上模擬第二台主機的驗證">多台主機的指標收集：讀取架構、主機標籤、目標清單、監控主機的位置與主機消失的偵測</a>。</p>
<h2 id="單台清單夠用的條件與改成分層的時機">單台清單夠用的條件與改成分層的時機</h2>
<p>判斷要不要分層，數的是 repo 裡內容不同的清單有幾份，不是主機有幾台：二十台主機都是同一個角色、每台不同的只有主機名時，一份清單部署到每台、主機名用執行時覆寫，repo 裡只有一份，不需要分層；二十台裡只要有一台的門檻不同，內容不同的清單就變成兩份以上，回到下面的判斷。</p>
<p>內容不同的清單有好幾份時，漏改的代價由兩件事決定，兩件事要分開看：</p>
<ul>
<li><strong>漏改的次數</strong>：一年裡累計要改的份數（一次修改要改的份數乘上一年改幾次），每一份都是一次可能漏改的機會。兩份清單、每週改一次，一年要改一百多份；十份清單、一年改一次，一年改十份。</li>
<li><strong>漏改之後持續多久</strong>：漏改的那一份不會讓任何檢查失敗，只會讓那台主機繼續跑舊的檢查，直到有人下一次修改它、或一致性檢查比對到它為止。十份清單、一年改一次時，次數少，可是漏改一份就有一台主機安靜地跑舊檢查到下一年。</li>
</ul>
<p>改成分層的查得到的時機是下面兩者之一：一次修改要改的份數，多到改的人沒辦法在同一次提交裡逐份確認每一份都改了（要另外記「哪幾台已經改了」）；或 repo 已經需要一致性檢查（CI 裡比對各份清單共用段落的那一道）才知道有沒有漏改。兩份清單、每週改一次，每次只改兩份、同一次提交裡看得完，次數多而每一次都確認得了，不分層也行，代價是每週改兩次；十份清單、一年改一次，次數少而每次要確認十份、漏改會持續一年，到了需要一致性檢查的程度，改成分層的成本比加一道檢查低或相近。</p>
<p>主機由 Terraform 這類工具建立、而且每台主機都在 State 裡時，主機清冊的來源已經是 Terraform，inventory 由它產生；這時每台的清單也一起交給產生的步驟。repo 裡只有一份共用清單、每台只差主機名時，交給產生步驟的是每台的覆寫值，不是整份清單，上面「不需要分層」的判斷照樣成立。手寫的清單不會隨 Terraform 新增或刪除主機而增減，新開的主機沒有清單、已刪除的主機還留著一份，而清單與主機的對應只能靠人記得，所以 Terraform 每增刪一次主機，手寫的清單就有一次對不上的機會。</p>
<h2 id="延伸閱讀單台清單的格式與檢查腳本維護項目組態管理與-iac-的卡">延伸閱讀：單台清單的格式與檢查腳本、維護項目、組態管理與 IaC 的卡</h2>
<ul>
<li><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>：本篇沿用的段名、<code>{{名稱}}</code> 的替換語法與 <code>--set</code>、檢查腳本的白名單、exit code、結果檔與 <code>systemd-run</code> 的限制</li>
<li><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>：清單裡每一類檢查項目的來源，定期讀的 log 與對外入口的盤點方法；那一篇以單台主機為對象，依角色分組在本篇</li>
<li><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>：inventory 的分層與合併規則、組態管理工具跟 IaC 的分界</li>
<li><a href="/blog/devops/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">Infrastructure as Code (IaC)</a>：由 Terraform 建立主機、產生 inventory 的前提</li>
</ul>
]]></content:encoded></item><item><title>Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-27（本篇的時刻都是 UTC），在 Oracle Cloud 的 &lt;code>ap-batam-1&lt;/code> 區域，從一台 macOS 開發機用 OCI CLI 操作。tenancy（Oracle Cloud 的帳號層級）是付費帳戶。主機是 A1 機型（&lt;code>VM.Standard.A1.Flex&lt;/code>，1 OCPU、6 GB），Oracle Linux 10.2，放在公開子網路、有臨時公開 IP（ephemeral public IP）。網卡掛著一個 NSG（network security group：套在個別網卡上的一組防火牆規則，跟 AWS 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> 是同一類），只放行 Cloudflare IPv4 位址段連 80 與 443；子網路的 security list（套在整個子網路上的防火牆規則）只放行管理者的 IP 連 22。主機上跑 nginx（80 與 443，443 用自簽憑證）與一個接收監控事件的 systemd 服務。網域的根網域與 &lt;code>www&lt;/code> 兩筆 A 紀錄經過 Cloudflare 代理（Proxied：訪客連到 Cloudflare，再由 Cloudflare 回源到這台主機，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN&lt;/a>）指到這台主機，SSL/TLS 模式實際套用的是完整（Full）。這台主機與它的網路，是在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠&lt;/a>（下文稱「回源那篇」）的實測收掉之後，照那篇的步驟另建的，DNS 紀錄改成根網域與 &lt;code>www&lt;/code>。當時在做的操作是：這台實驗主機要收掉之前，先照 &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>（下文稱「暫停那篇」）的「停止主機」那一種範圍演練一次停機與恢復上線。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄在 Oracle Cloud 上把一台放在 Cloudflare 後面的主機停機、再恢復上線的每一步，以及演練完把它收掉的順序。各節依序是：&lt;/p>
&lt;ul>
&lt;li>暫停前記下的基準：接聽的程式、服務的狀態、待裝的安全更新、憑證效期、公開 IP 與 A1 的用量，以及記這些值的指令。&lt;/li>
&lt;li>收窄入口：把 NSG 從主機的網卡拿掉，以及 Cloudflare 在這之後回的錯誤與等待時間。&lt;/li>
&lt;li>停止主機：&lt;code>SOFTSTOP&lt;/code> 與 &lt;code>STOP&lt;/code> 的差別、停止花的時間、停止之後公開 IP 的去留，以及停止中的主機是否仍佔 A1 與區塊儲存的用量。&lt;/li>
&lt;li>停止期間的計費：用 usage API 逐小時讀帳務明細，停止的那一小時 A1 與開機磁碟各記了什麼。&lt;/li>
&lt;li>恢復上線：啟動與 SSH 連得上的時間、跟基準比對的漂移（暫停期間改變了的狀態）、裝安全更新與重開機的判斷、只對 loopback 的冒煙測試（&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">smoke test&lt;/a>：只確認服務起得來、首頁有回應的最小檢查）、重新掛回 NSG 之後從外面的驗證。&lt;/li>
&lt;li>收尾：服務要收掉時，DNS 紀錄、主機與網路資源的刪除順序、這個順序的理由與實際耗時。&lt;/li>
&lt;/ul>
&lt;p>每一步為什麼排在這個位置（入口要在停止之前收窄、更新要在開放入口之前裝），推導在暫停那篇；這裡寫的是在 Oracle Cloud 與 Cloudflare 上跑出來的結果。本篇的 OCID、IP 與網域一律是佔位值，各自的查法寫在第一次用到它的那條指令旁邊。&lt;/p>
&lt;h2 id="暫停前記下的基準接聽的程式服務待裝的更新憑證效期與公開-ip">暫停前記下的基準：接聽的程式、服務、待裝的更新、憑證效期與公開 IP&lt;/h2>
&lt;p>恢復的時候要拿一份暫停當下的值來比，才知道暫停期間變了什麼（暫停那篇〈恢復上線前要重新確認的漂移〉），所以基準在入口收窄之前、主機還在跑的時候記。下面這一條從開發機經 SSH 在主機上執行，輸出存成檔案留著比對：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># &amp;lt;登入設定&amp;gt; 是連到這台主機的 ssh 參數（-i 私鑰、使用者@公開 IP），這次是 security list 只放行管理者 IP 的直連&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">ssh &amp;lt;登入設定&amp;gt; &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s1"> sudo ss -lntup # 接聽的程式與連接埠
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s1"> systemctl is-enabled nginx monitor-collector # 開機時會不會自己起來
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s1"> systemctl is-active nginx monitor-collector
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s1"> curl -s -o /dev/null -w &amp;#34;loopback http %{http_code}\n&amp;#34; http://127.0.0.1/
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> curl -sk -o /dev/null -w &amp;#34;loopback https %{http_code}\n&amp;#34; https://127.0.0.1/
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> echo sec=$(sudo dnf -q check-update --security | grep -c .) # 待裝的安全更新（行數）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> sudo needs-restarting -r # 現在需不需要重開機
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> uname -r
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span> &amp;gt; pre-suspend.log&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>monitor-collector&lt;/code> 是這台主機上那個接收監控事件的服務名稱，換成自己主機上要確認的服務。&lt;code>dnf check-update --security&lt;/code> 每個待裝的套件印一行，&lt;code>grep -c .&lt;/code> 數的是非空的行數；這次是 18 行，恢復時實際裝了 18 個套件，兩個數字相同。&lt;code>needs-restarting&lt;/code> 這個指令在 Oracle Linux 10.2 由 &lt;code>yum-utils&lt;/code> 套件提供（&lt;code>oraclelinux:10&lt;/code> 容器裡 &lt;code>rpm -qf $(command -v needs-restarting)&lt;/code> 實跑），Debian 與 Ubuntu 上對應的是 &lt;code>needrestart -b&lt;/code> 與 &lt;code>/var/run/reboot-required&lt;/code>（&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;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-27（本篇的時刻都是 UTC），在 Oracle Cloud 的 <code>ap-batam-1</code> 區域，從一台 macOS 開發機用 OCI CLI 操作。tenancy（Oracle Cloud 的帳號層級）是付費帳戶。主機是 A1 機型（<code>VM.Standard.A1.Flex</code>，1 OCPU、6 GB），Oracle Linux 10.2，放在公開子網路、有臨時公開 IP（ephemeral public IP）。網卡掛著一個 NSG（network security group：套在個別網卡上的一組防火牆規則，跟 AWS 的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> 是同一類），只放行 Cloudflare IPv4 位址段連 80 與 443；子網路的 security list（套在整個子網路上的防火牆規則）只放行管理者的 IP 連 22。主機上跑 nginx（80 與 443，443 用自簽憑證）與一個接收監控事件的 systemd 服務。網域的根網域與 <code>www</code> 兩筆 A 紀錄經過 Cloudflare 代理（Proxied：訪客連到 Cloudflare，再由 Cloudflare 回源到這台主機，見 <a href="/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN</a>）指到這台主機，SSL/TLS 模式實際套用的是完整（Full）。這台主機與它的網路，是在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a>（下文稱「回源那篇」）的實測收掉之後，照那篇的步驟另建的，DNS 紀錄改成根網域與 <code>www</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></blockquote>
<p>這篇記錄在 Oracle Cloud 上把一台放在 Cloudflare 後面的主機停機、再恢復上線的每一步，以及演練完把它收掉的順序。各節依序是：</p>
<ul>
<li>暫停前記下的基準：接聽的程式、服務的狀態、待裝的安全更新、憑證效期、公開 IP 與 A1 的用量，以及記這些值的指令。</li>
<li>收窄入口：把 NSG 從主機的網卡拿掉，以及 Cloudflare 在這之後回的錯誤與等待時間。</li>
<li>停止主機：<code>SOFTSTOP</code> 與 <code>STOP</code> 的差別、停止花的時間、停止之後公開 IP 的去留，以及停止中的主機是否仍佔 A1 與區塊儲存的用量。</li>
<li>停止期間的計費：用 usage API 逐小時讀帳務明細，停止的那一小時 A1 與開機磁碟各記了什麼。</li>
<li>恢復上線：啟動與 SSH 連得上的時間、跟基準比對的漂移（暫停期間改變了的狀態）、裝安全更新與重開機的判斷、只對 loopback 的冒煙測試（<a href="/blog/testing/knowledge-cards/smoke-test/" data-link-title="Smoke Test（冒煙測試）" data-link-desc="部署或重建之後要決定先跑哪一組檢查、冒煙測試全部通過而使用者仍回報錯誤，或要判斷一組部署後檢查涵蓋到哪裡、沒有涵蓋到哪裡時">smoke test</a>：只確認服務起得來、首頁有回應的最小檢查）、重新掛回 NSG 之後從外面的驗證。</li>
<li>收尾：服務要收掉時，DNS 紀錄、主機與網路資源的刪除順序、這個順序的理由與實際耗時。</li>
</ul>
<p>每一步為什麼排在這個位置（入口要在停止之前收窄、更新要在開放入口之前裝），推導在暫停那篇；這裡寫的是在 Oracle Cloud 與 Cloudflare 上跑出來的結果。本篇的 OCID、IP 與網域一律是佔位值，各自的查法寫在第一次用到它的那條指令旁邊。</p>
<h2 id="暫停前記下的基準接聽的程式服務待裝的更新憑證效期與公開-ip">暫停前記下的基準：接聽的程式、服務、待裝的更新、憑證效期與公開 IP</h2>
<p>恢復的時候要拿一份暫停當下的值來比，才知道暫停期間變了什麼（暫停那篇〈恢復上線前要重新確認的漂移〉），所以基準在入口收窄之前、主機還在跑的時候記。下面這一條從開發機經 SSH 在主機上執行，輸出存成檔案留著比對：</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"># &lt;登入設定&gt; 是連到這台主機的 ssh 參數（-i 私鑰、使用者@公開 IP），這次是 security list 只放行管理者 IP 的直連</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">ssh &lt;登入設定&gt; <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s1">  sudo ss -lntup                                  # 接聽的程式與連接埠
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">  systemctl is-enabled nginx monitor-collector     # 開機時會不會自己起來
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">  systemctl is-active nginx monitor-collector
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">  curl -s -o /dev/null -w &#34;loopback http %{http_code}\n&#34; http://127.0.0.1/
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">  curl -sk -o /dev/null -w &#34;loopback https %{http_code}\n&#34; https://127.0.0.1/
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">  echo sec=$(sudo dnf -q check-update --security | grep -c .)   # 待裝的安全更新（行數）
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">  sudo needs-restarting -r                         # 現在需不需要重開機
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  uname -r
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s1">&#39;</span> &gt; pre-suspend.log</span></span></code></pre></div><p><code>monitor-collector</code> 是這台主機上那個接收監控事件的服務名稱，換成自己主機上要確認的服務。<code>dnf check-update --security</code> 每個待裝的套件印一行，<code>grep -c .</code> 數的是非空的行數；這次是 18 行，恢復時實際裝了 18 個套件，兩個數字相同。<code>needs-restarting</code> 這個指令在 Oracle Linux 10.2 由 <code>yum-utils</code> 套件提供（<code>oraclelinux:10</code> 容器裡 <code>rpm -qf $(command -v needs-restarting)</code> 實跑），Debian 與 Ubuntu 上對應的是 <code>needrestart -b</code> 與 <code>/var/run/reboot-required</code>（<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>〈會老化的軟體：各條更新管道與更新後的重開機〉）。</p>
<p>這次記到的值：nginx 聽 80 與 443、sshd 聽 22、監控事件的服務聽 8765，兩個服務都是 <code>enabled</code> 與 <code>active</code>；loopback 的 HTTP 與 HTTPS 都回 200；待裝的安全更新 18 行；<code>needs-restarting -r</code> 印 <code>Reboot should not be necessary.</code>；核心是 <code>6.12.0-207.111.5.6.el10uek.aarch64</code>。</p>
<p>憑證效期照 <code>nginx -T</code> 列出的 <code>ssl_certificate</code> 路徑讀。這台主機在建好之後換過網站，建立腳本裡寫的憑證路徑已經不在，照那個路徑讀會回 <code>No such file or directory</code>；<code>nginx -T</code> 印的是 nginx 實際載入的設定，所以讀到的是現在用的那一張：</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"># 列出 nginx 實際載入的憑證路徑；開頭是 # 的是註解掉的範例，這條 grep 只取沒有註解的那一行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh &lt;登入設定&gt; <span class="s1">&#39;sudo nginx -T 2&gt;/dev/null | grep -E &#34;^\s*ssl_certificate\s&#34;&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ssh &lt;登入設定&gt; <span class="s1">&#39;sudo openssl x509 -in &lt;上一條印出的路徑&gt; -noout -enddate&#39;</span></span></span></code></pre></div><p>這次讀到 <code>notAfter</code> 在 2036 年，是建網站時簽的十年自簽憑證；暫停只有 2 小時，恢復時沒有再讀。暫停期長到可能跨過效期的，恢復時要再讀一次。</p>
<p>公開 IP、私有 IP 與 A1 的用量在開發機上用 CLI 讀。<code>&lt;tenancy 的 OCID&gt;</code> 是 <code>~/.oci/config</code> 裡 <code>tenancy=</code> 那一行的值；<code>&lt;compartment 的 OCID&gt;</code> 是主機所在的 compartment（Oracle Cloud 裡把資源分組的單位），用 <code>oci iam compartment list -c &lt;tenancy 的 OCID&gt; --compartment-id-in-subtree true --all --query 'data[].{name:name,id:id}' --output table</code> 列出名稱與 OCID，主機建在最上層時就是 tenancy 的 OCID；<code>&lt;主機的 OCID&gt;</code> 用 <code>oci compute instance list -c &lt;compartment 的 OCID&gt; --query 'data[].{name:&quot;display-name&quot;,id:id}' --output table</code> 列出每台主機的名稱與 OCID 再挑；<code>&lt;可用網域&gt;</code>（availability domain，區域裡的一個資料中心，跨業者的概念見 <a href="/blog/devops/infra/knowledge-cards/availability-zone/" data-link-title="Availability Zone（可用區）" data-link-desc="建立雲端主機或磁碟時要選可用區、遇到某個可用區容量不足、規劃兩台主機要不要放在不同可用區，或對照各家把這一層叫 zone、AZ 還是 availability domain 時，查可用區的範圍、它跟區域與故障域的關係與它限制了哪些資源的搭配">Availability Zone</a>）用 <code>oci compute instance get --instance-id &lt;主機的 OCID&gt; --query 'data.&quot;availability-domain&quot;' --raw-output</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">oci compute instance list-vnics --instance-id &lt;主機的 OCID&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;data[0].{pub:&#34;public-ip&#34;,priv:&#34;private-ip&#34;,vnic:id}&#39;</span> --output table
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># A1 的核心數用量；記憶體把 limit 名稱換成 standard-a1-memory-count</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">oci limits resource-availability get -c &lt;tenancy 的 OCID&gt; --service-name compute <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --limit-name standard-a1-core-count --availability-domain &lt;可用網域&gt; --query <span class="s1">&#39;data.used&#39;</span> --raw-output</span></span></code></pre></div><p>這次 tenancy 裡有三台各 1 OCPU 的 A1，核心數的 <code>used</code> 回 3；記憶體與免費區塊儲存的用量這次在暫停前沒有讀（下面〈停止主機：SOFTSTOP、耗時、公開 IP 的去留與停止中的用量〉在停止中讀了）。暫停前沒有跑 <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> 的檢查：這台主機沒有寫過主機清單，上面那幾條指令是手動挑的，涵蓋不到清單會檢查的累積資料與到期設定。</p>
<h2 id="收窄入口把-nsg-從網卡拿掉以及-cloudflare-回的-522">收窄入口：把 NSG 從網卡拿掉，以及 Cloudflare 回的 522</h2>
<p>收窄入口是暫停那篇〈暫停時先收窄的入口〉的用詞，指收回對外的放行、只留管理者的連線。主機停止期間不回應任何連線，入口開不開沒有差別；入口要收窄，是為了下一次啟動的那一刻：主機一開機，enabled 的服務自己起來、防火牆照舊放行，入口還開著的話，主機在裝好更新之前就在接受外部連線。所以收窄只要在下一次啟動之前做完；暫停那篇把它排在暫停的當下，是因為恢復的人可能隔了很久、或換了一個人，到時不一定記得先收窄。Oracle Cloud 對一張網卡的放行是它的 NSG 與子網路 security list 兩處規則的聯集（<a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a>〈防火牆規則的位置與合併方式〉），所以先確認 80 與 443 在哪一處放行：這台主機的 security list 只放行 22，對外的 80 與 443 只靠那個 NSG，收窄入口的動作就是把 NSG 從網卡拿掉，規則本身留在 NSG 裡，恢復時再掛回去。security list 也放行 80 與 443 的主機，拿掉 NSG 之後入口照樣開著，下面那條 <code>curl</code> 會回 200 而不是 522，要另外收回 security list 那幾條（它管的是整個子網路，同一個子網路的其他主機會一起受影響）。拿掉之前先把網卡現在掛著的 NSG 清單存下來，恢復時要用：</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"># &lt;VNIC 的 OCID&gt; 是〈暫停前記下的基準：接聽的程式、服務、待裝的更新、憑證效期與公開 IP〉那條 list-vnics 印的 vnic 欄</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">oci network vnic get --vnic-id &lt;VNIC 的 OCID&gt; --query <span class="s1">&#39;data.&#34;nsg-ids&#34;&#39;</span> &gt; nsg-ids.json
</span></span><span class="line"><span class="ln">3</span><span class="cl">oci network vnic update --vnic-id &lt;VNIC 的 OCID&gt; --nsg-ids <span class="s1">&#39;[]&#39;</span> --force</span></span></code></pre></div><p><code>--nsg-ids</code> 是整份替換（<a href="/blog/devops/infra/knowledge-cards/vnic/" data-link-title="VNIC（虛擬網路卡）" data-link-desc="雲端主機的私有 IP、公開 IP 或網卡層的防火牆規則要查、要改，或主機重建之後私有 IP 與防火牆規則跟著不見時，查 VNIC 掛著哪些設定、它跟主機一起建立與刪除的關係，以及各家的名稱">VNIC</a> 卡寫了這個欄位的整份替換）。網卡上掛著好幾個 NSG、只要拿掉其中一個時，<code>'[]'</code> 要換成去掉那一個之後的清單，否則別的 NSG 會一起被拿掉；這次 <code>nsg-ids.json</code> 裡只有一個。沒有存下來的話，恢復時用 <code>oci network nsg list -c &lt;compartment 的 OCID&gt; --query 'data[].{name:&quot;display-name&quot;,id:id}' --output table</code> 列出名稱與 OCID 查回來。網卡上另有一個放行監控探測的 NSG 時，探測在暫停期間不論拿不拿掉它都會失敗，探測要在監控那一端先靜音或停用，恢復時等外部驗證通過之後再啟用（暫停那篇〈恢復的順序：依元件之間的依賴排列〉的流量導回那一段）。04:59:11 送出 <code>update</code>，1 秒後回應，<code>nsg-ids</code> 變成 <code>[]</code>。之後每 40 秒從開發機經 Cloudflare 請求一次首頁：</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"># &lt;網域&gt; 換成經過 Cloudflare 代理的網域；印回應碼與總耗時</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -s -o /dev/null -w <span class="s1">&#39;%{http_code} %{time_total}\n&#39;</span> --max-time <span class="m">40</span> https://&lt;網域&gt;/</span></span></code></pre></div><p>三次都回 <code>522</code>，耗時 20.3 到 20.7 秒。522 是 Cloudflare 連到源站逾時的錯誤碼：NSG 拿掉之後，security list 只放行管理者 IP 的 22，Cloudflare 送到 443 的封包沒有規則放行，被丟掉而沒有回應，所以 Cloudflare 等到逾時（源站拒絕連線時的 521 與逾時的 522 怎麼分，見暫停那篇〈暫停期間仍指向這台主機的外部設定〉）。約 20 秒是 Cloudflare 那一端等 TCP 連線的時間，源站這一端決定不了；方案或設定會不會改變它，這次沒有查。訪客在這段時間看到的是 Cloudflare 的 522 錯誤頁，每次要等約 20 秒才出現；DNS 紀錄不經 CDN、直接指公開 IP 時，訪客看到的是瀏覽器自己的連線逾時（從機制推論，這次沒有這種紀錄）。放行 22 的規則在 security list，不在這個 NSG，所以收窄之後管理者照樣連得進主機。</p>
<p>這一步的其他做法與換到的結果：</p>
<ul>
<li><strong>刪掉 NSG 裡 80 與 443 的規則</strong>：效果相同，恢復時要把整份 Cloudflare 位址段重新加回去。這個 NSG 有 30 條規則，回源那篇量到每次 API 呼叫最多加 25 條，所以要分兩次加；拿掉整個 NSG 時規則原封不動，恢復只要一條指令。</li>
<li><strong>改 security list</strong>：這台主機的 80 與 443 本來就不在 security list 裡，改它收窄不了這兩個連接埠。</li>
<li><strong>網卡一定要掛至少一個防火牆群組的業者</strong>：AWS 的網卡至少要有一個 security group，拿成零個的做法換不過去，改成掛一個沒有放行規則的空群組（依業者的規則推論，沒有實跑）。</li>
<li><strong>只在 Cloudflare 那一層擋</strong>：zone 上加一條 Worker route 回維護頁，或一條 WAF 自訂規則直接擋，訪客馬上拿到回應，不必等 20 秒的 522，DNS 也不必改。它只在 Cloudflare 這一層生效：NSG 放行的是整份 Cloudflare 位址段，別人在自己的 Cloudflare 帳號裡把一個網域指到這個 IP，回源照樣進得來，所以只能跟拿掉 NSG 並用（從回源那篇量到的 Worker 穿過 NSG 推論，這次沒有做）。</li>
<li><strong>改用 Cloudflare Tunnel</strong>：源站完全不開入站，暫停時停掉 <code>cloudflared</code> 就收窄了，恢復時也不必比對位址段（回源那篇〈Cloudflare Tunnel：源站不開入站，以及出站要放行的連接埠〉；這台主機沒有用 Tunnel）。</li>
<li><strong>DNS 紀錄改指到維護頁</strong>：訪客看到的是說明頁而不是 522，代價是要另外準備一個維護頁（Cloudflare Workers 或另一台主機）、恢復時再改回來（暫停那篇〈暫停期間仍指向這台主機的外部設定〉）。這次沒有做，訪客看到的是 522。</li>
<li><strong>不收窄</strong>：停止期間沒有差別，差別在恢復時開機的那一刻，見下面〈恢復上線：啟動、漂移比對、安全更新、冒煙測試與重新開放入口〉的對外入口開放那一段。</li>
</ul>
<h2 id="停止主機softstop耗時公開-ip-的去留與停止中的用量">停止主機：SOFTSTOP、耗時、公開 IP 的去留與停止中的用量</h2>
<p>停止之前先確認業者停止主機時收不收回公開 IP：Oracle Cloud 的臨時公開 IP 停止時留著（下面實測），DNS 紀錄可以不動；停止時就收回臨時公開 IP 的業者（暫停那篇寫的 AWS 沒有 Elastic IP 的主機），DNS 紀錄要在送出停止之前改指維護頁或刪除，否則停止那一刻舊 IP 就還給業者，之後被分給別人時就是懸空 DNS（dangling DNS：DNS 紀錄指著已經還給業者、可能分給別人的 IP）。經過 Cloudflare 代理、SSL/TLS 模式是完整（Full，不驗源站憑證）時，Cloudflare 會把訪客的請求送到那個 IP 的下一任主人。</p>
<p>主機上有寫入資料的服務時，先經 SSH 用 <code>systemctl stop</code> 停掉它、確認它已經結束，再停止主機；下面的 <code>SOFTSTOP</code> 最多等作業系統關機 15 分鐘，服務停不下來時照樣被切斷電源（這次沒有先停服務，69 秒就停好了）。從 API 停止主機。暫停那篇〈暫停期間仍在計費的資源〉引的 Oracle Cloud 官方文件寫明，在作業系統裡下 <code>shutdown</code> 關機不會停止計費，要從主控台或 API 停止；這樣關機之後主控台上顯示的狀態，這次沒有查。</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">oci compute instance action --instance-id &lt;主機的 OCID&gt; --action SOFTSTOP <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --wait-for-state STOPPED --max-wait-seconds <span class="m">1200</span> --query <span class="s1">&#39;data.&#34;lifecycle-state&#34;&#39;</span> --raw-output</span></span></code></pre></div><p><code>--action</code> 收 <code>STOP</code> 與 <code>SOFTSTOP</code> 兩種停止。依 <code>oci compute instance action --help</code>，<code>STOP</code> 直接切斷電源（Powers off the instance）；<code>SOFTSTOP</code> 先送關機指令給作業系統，等作業系統關機最多 15 分鐘，之後才切斷電源，所以作業系統上的程式有機會正常結束。這台主機上有寫入 SQLite 的服務，選 <code>SOFTSTOP</code>；<code>STOP</code> 這次沒有實跑，依說明，它跳過作業系統的關機，正在寫入的資料可能沒有寫完。<code>--wait-for-state STOPPED</code> 讓 CLI 等到主機的狀態變成 <code>STOPPED</code> 才回傳，<code>instance action</code> 收的是主機本身的狀態，跟 <code>instance terminate</code> 收 work request（Oracle Cloud 記錄一次非同步操作進度的物件）的狀態不同，下面〈收尾：DNS 紀錄、主機與網路資源的刪除順序〉會用到這個差別。上面那條的 <code>--max-wait-seconds</code> 寫 1200，比這次實跑時寫的 900 大：<code>SOFTSTOP</code> 最多等作業系統關機 15 分鐘（900 秒），作業系統拖滿 15 分鐘才關機時，等待上限也是 900 秒的話，CLI 會先以逾時結束，主機卻還在停（從這兩個 900 推論）。這次 69 秒就停好了，沒有碰到這個上限。</p>
<p>05:01:31 送出，05:02:40 回 <code>STOPPED</code>，共 69 秒。這個時間取決於主機上的服務停下來要多久，上限是上面那 15 分鐘。停止之後讀到的狀態：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>讀法</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>公開 IP</td>
          <td><code>list-vnics</code> 的 <code>public-ip</code>，與 <code>oci network vnic get</code> 的 <code>public-ip</code></td>
          <td>跟停止前相同，VNIC 的狀態是 <code>AVAILABLE</code></td>
      </tr>
      <tr>
          <td>經 Cloudflare 請求首頁</td>
          <td>〈收窄入口：把 NSG 從網卡拿掉，以及 Cloudflare 回的 522〉的 <code>curl</code></td>
          <td><code>522</code>，20.4 秒</td>
      </tr>
      <tr>
          <td>SSH</td>
          <td>同一組登入設定</td>
          <td><code>Operation timed out</code></td>
      </tr>
      <tr>
          <td>A1 的核心數與記憶體用量</td>
          <td>〈暫停前記下的基準：接聽的程式、服務、待裝的更新、憑證效期與公開 IP〉的 <code>limits resource-availability get</code>，記憶體把 limit 名稱換成 <code>standard-a1-memory-count</code></td>
          <td>3 與 18；核心數跟停止前相同，記憶體 18 等於三台各 6 GB：停止中的主機仍算在用量裡</td>
      </tr>
      <tr>
          <td>免費區塊儲存的用量</td>
          <td><code>oci limits resource-availability get -c &lt;tenancy 的 OCID&gt; --service-name block-storage --limit-name total-free-storage-gb-regional --query 'data.used' --raw-output</code>（區域層的這一條不帶 <code>--availability-domain</code>）</td>
          <td>141，等於三台的開機磁碟各 47</td>
      </tr>
      <tr>
          <td>開機磁碟</td>
          <td><code>oci bv boot-volume get --boot-volume-id &lt;開機磁碟的 OCID&gt;</code>；OCID 用 <code>oci compute boot-volume-attachment list -c &lt;compartment 的 OCID&gt; --availability-domain &lt;可用網域&gt; --instance-id &lt;主機的 OCID&gt; --query 'data[0].&quot;boot-volume-id&quot;' --raw-output</code> 查</td>
          <td>47 GB、<code>AVAILABLE</code></td>
      </tr>
  </tbody>
</table>
<p>公開 IP 留在主機上，跟暫停那篇引的 Oracle Cloud 文件一致：停止時臨時公開 IP 還在，終止時才刪除。所以這台主機停止期間，DNS 紀錄指著的仍是自己的 IP，訪客看到的是 522，不會連到別人的主機；本節開頭那段寫的前提（業者停止時不收回 IP）在這裡成立。</p>
<p>停止中的主機仍算在 A1 的 limits 用量裡，也就是決定還能再建多少 A1 的那個用量，跟計費是兩回事（計費見下面〈停止期間的計費：usage API 逐小時的明細〉）。用量已經到上限時，停機期間這份 A1 的額度不能拿去建另一台主機（上限與剩餘量用同一條指令把 <code>--query</code> 換成 <code>'data.{used:used,available:available}'</code> 讀），要讓出額度得終止主機，也就是暫停那篇表裡「終止主機、保留磁碟與網路設定」那一種範圍：主機刪掉，磁碟與網路設定留著。limits 用量也不等於實體的主機容量：停止中的 A1 再啟動時會不會遇到容量不足（A1 建立時常見的 <code>Out of host capacity</code>），Oracle 停止與啟動主機的<a href="https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/restartinginstance.htm">說明頁</a>（2026-10-27 讀）沒有寫，這次 2 小時後的啟動沒有遇到。暫停期長、而那一區的 A1 常常容量不足時，停止之前先備份開機磁碟，啟動失敗時才有從備份換到別的可用網域或機型重建的路（沒有實跑）。</p>
<h2 id="停止期間的計費usage-api-逐小時的明細">停止期間的計費：usage API 逐小時的明細</h2>
<p>停止期間 A1 是否計費，用 usage API 依 SKU（帳務明細上的計費品項）與資源逐小時讀。逐小時的明細大約落後 2 小時，所以這一段在恢復之後才查得到；<code>usage-api</code> 各參數的讀法寫在 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>〈費用與額度：A1、開機磁碟與區塊儲存額度、出站流量與 NAT gateway〉：</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">oci usage-api usage-summary request-summarized-usages --tenant-id &lt;tenancy 的 OCID&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --time-usage-started 2026-10-27T04:00:00Z --time-usage-ended 2026-10-27T09:00:00Z <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --granularity HOURLY --query-type USAGE --group-by <span class="s1">&#39;[&#34;skuName&#34;,&#34;skuPartNumber&#34;,&#34;resourceId&#34;]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;data.items[].{hour:&#34;time-usage-started&#34;,sku:&#34;sku-name&#34;,res:&#34;resource-id&#34;,qty:&#34;computed-quantity&#34;}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><p>起訖換成要看的時段（UTC 的整點）。這台主機與它的開機磁碟在各小時記到的項目（<code>res</code> 欄對回主機與開機磁碟的 OCID）：</p>
<table>
  <thead>
      <tr>
          <th>小時（UTC）</th>
          <th>主機的狀態</th>
          <th><code>Standard - A1</code>（OCPU 小時）</th>
          <th><code>Standard - A1 - Memory</code>（GB 小時）</th>
          <th>開機磁碟的 <code>Block Volume - Free</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>04 時</td>
          <td>整小時 <code>RUNNING</code></td>
          <td>1.0</td>
          <td>6.0</td>
          <td>0.0632</td>
      </tr>
      <tr>
          <td>05 時</td>
          <td>05:01:31 開始停止，05:02:40 停止</td>
          <td>0.0258</td>
          <td>0.1558</td>
          <td>0.0632</td>
      </tr>
      <tr>
          <td>06 時</td>
          <td>整小時 <code>STOPPED</code></td>
          <td>沒有這一項</td>
          <td>沒有這一項</td>
          <td>0.0632</td>
      </tr>
  </tbody>
</table>
<p>05 時的 0.0258 OCPU 小時約 93 秒，換算回去是 05:01:33，跟送出 <code>SOFTSTOP</code> 的 05:01:31 差 2 秒，所以這一次 A1 的計費大約停在送出停止動作的那一刻，比主機變成 <code>STOPPED</code> 的 05:02:40 早約 1 分鐘（從這一小時的數字推算，只量了這一次）。06 時整小時停止，明細裡沒有這台主機的 A1 項目。開機磁碟每小時照樣記 0.0632，等於 47 GB 除以 744 小時（31 天的小時數；10 月正好 31 天，Oracle 換算時是否固定用 744 沒有查），記在 <code>Block Volume - Free</code>，金額 0：磁碟在主機停止時繼續存在、繼續佔免費額度。這台主機的 A1 在付費 tenancy 每月的免費量裡，金額是 0，停止省下的是那幾個小時從每月免費量裡扣掉的 OCPU 與記憶體小時數；A1 用量超過免費量的帳戶，停止那幾個小時的費用就省下來了（從這三個小時的明細推論，沒有在超過免費量的帳戶上量）。這是 A1 這類標準機型的結果；Dense I/O、HPC 與多數 GPU 機型停止期間照樣計費，見 <a href="/blog/devops/infra/cloud-providers/vm-options/shape-and-image/" data-link-title="機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統" data-link-desc="雲端主機的機型決定的 CPU 架構、核心與記憶體，計費模式（隨用隨付、可被收回、預留容量、專屬主機）各自的條件，映像檔與機型之間的架構相容性，以及哪些項目建立後可以調整">機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統</a>〈停止主機時的計費〉。</p>
<h2 id="恢復上線啟動漂移比對安全更新冒煙測試與重新開放入口">恢復上線：啟動、漂移比對、安全更新、冒煙測試與重新開放入口</h2>
<p>主機停了 1 小時 59 分鐘之後恢復。恢復分成主機與網路就緒、主機內部就緒、對外入口開放三段，每一段的產出是下一段的前提（暫停那篇〈恢復的順序：依元件之間的依賴排列〉）；DNS 紀錄這次一直指著同一個 IP（公開 IP 停止前後沒有變），暫停那篇「流量導回」那一段（把 DNS 紀錄改回這台主機、經 CDN 從外面驗證）只剩經 CDN 的外部驗證，併在對外入口開放之後做。</p>
<p><strong>主機與網路就緒</strong>：</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">oci compute instance action --instance-id &lt;主機的 OCID&gt; --action START <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --wait-for-state RUNNING --max-wait-seconds <span class="m">900</span> --query <span class="s1">&#39;data.&#34;lifecycle-state&#34;&#39;</span> --raw-output
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 每 5 秒試一次 SSH，連得上就停</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">for</span> i in <span class="k">$(</span>seq <span class="m">1</span> 30<span class="k">)</span><span class="p">;</span> <span class="k">do</span> ssh &lt;登入設定&gt; <span class="nb">true</span> <span class="o">&amp;&amp;</span> break<span class="p">;</span> sleep 5<span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">oci network vnic get --vnic-id &lt;VNIC 的 OCID&gt; --query <span class="s1">&#39;data.{pub:&#34;public-ip&#34;,nsg:&#34;nsg-ids&#34;}&#39;</span></span></span></code></pre></div><p>07:01:28 送出 <code>START</code>，07:02:03 回 <code>RUNNING</code>（35 秒），SSH 在 07:02:21 第 4 次嘗試時連上（<code>RUNNING</code> 之後 18 秒），從送出到連得上共 53 秒。這幾個時間依機型、映像檔與開機時起來的服務而變（這次是 A1、Oracle Linux 10.2、兩個 enabled 的服務），上面的 <code>--wait-for-state</code> 與 SSH 迴圈都是依回應自己等，不依賴這個值。SSH 迴圈這次沒有帶 <code>-o ConnectTimeout</code>，主機還沒起來時一次嘗試可能等到連線逾時才換下一次；加上 <code>-o ConnectTimeout=5</code>，每次嘗試最多等 5 秒（沒有實跑）。迴圈跑完 30 次都沒連上時，它不會報錯，後面的指令照樣接著跑；這時除了主機還沒起來，也要比對 security list 放行的來源跟自己現在的對外 IP，暫停期間管理者的 IP 換了也是同樣的逾時。<code>vnic get</code> 讀到的公開 IP 跟停止前相同，<code>nsg-ids</code> 還是 <code>[]</code>；經 Cloudflare 請求首頁照樣回 522（20.2 秒），入口還收窄著。</p>
<p><strong>主機內部就緒</strong>：先跟基準比，再裝更新。</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>暫停前</th>
          <th>恢復後（裝更新之前）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>nginx 與監控事件的服務</td>
          <td><code>enabled</code>、<code>active</code></td>
          <td><code>active</code>：enabled 的服務開機時自己起來了</td>
      </tr>
      <tr>
          <td>接聽的 TCP 連接埠</td>
          <td>22、80、443、8765，共 7 行（22、80、443 的 IPv4 與 IPv6 各一行，8765 一行）</td>
          <td>只數了這四個連接埠的接聽行數，回 7，行數相同；沒有逐一列出連接埠</td>
      </tr>
      <tr>
          <td>待裝的安全更新</td>
          <td>18 行</td>
          <td>18 行</td>
      </tr>
      <tr>
          <td>核心</td>
          <td><code>6.12.0-207.111.5.6.el10uek</code></td>
          <td>相同</td>
      </tr>
      <tr>
          <td>公開 IP</td>
          <td>停止前的值</td>
          <td>相同</td>
      </tr>
  </tbody>
</table>
<p>這張表是手動逐項比的，接聽的連接埠只數了行數，行數相同而連接埠換了的情形看不出來。比較完整的做法是恢復時重跑暫停前那一整段指令、輸出存成 <code>post-resume.log</code>，再用 <code>diff pre-suspend.log post-resume.log</code> 看差異（這次沒有這樣做）。暫停期間只有 2 小時，待裝的安全更新數量沒有變。暫停幾週時這個數量會變多，裝更新的時間跟著變長（推論，這次沒有量長的暫停）。接著裝安全更新、判斷要不要重開機：</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">ssh &lt;登入設定&gt; <span class="s1">&#39;sudo dnf -y -q upgrade --security; sudo needs-restarting -r&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh &lt;登入設定&gt; <span class="s1">&#39;echo sec=$(sudo dnf -q check-update --security | grep -c .)&#39;</span></span></span></code></pre></div><p>07:03:12 開始、07:03:40 結束（28 秒），裝了 18 個套件：<code>bind</code> 的三個套件、<code>gcc-toolset-15</code> 的四個、<code>kernel-headers</code>、<code>kernel-tools</code>、<code>kernel-tools-libs</code>、<code>libbrotli</code>、<code>nodejs</code> 的五個、<code>perf</code> 與 <code>python3-perf</code>。<code>kernel-tools</code> 這幾個是核心相關的工具，核心本身（<code>kernel-uek-core</code>）不在這一批裡，<code>needs-restarting -r</code> 印 <code>No core libraries or services have been updated since boot-up.</code> 與 <code>Reboot should not be necessary.</code>，所以沒有重開機。裝完之後待裝的安全更新是 0。核心在一批更新裡的時候，<code>needs-restarting -r</code> 會列出要重開機的原因、以 1 結束：2026-10-26 在另一台 Oracle Linux 10.2 主機上裝了 <code>kernel-uek-core</code> 的更新之後，它印 <code>Core libraries or services have been updated since boot-up:</code>、列出 <code>kernel-uek-core</code>，以 1 結束（實跑）。遇到這種情形，重開機（<code>sudo systemctl reboot</code>）排在開放入口之前，重開之後重跑上面的 SSH 迴圈與下面的 loopback 冒煙測試。</p>
<p><code>-r</code> 只判斷要不要重開機；更新了服務已經載入的共用函式庫時（常見的是 <code>openssl-libs</code>；這一批裡的共用函式庫是 <code>libbrotli</code>，有沒有被 nginx 或那個服務載入這次沒有查），已經在跑的程序在重新啟動之前用的仍是記憶體裡的舊版。<code>needs-restarting -s</code> 列出受影響的 systemd 服務，重啟它們之後再開放入口（<code>-s</code> 在 <code>oraclelinux:10</code> 容器的 <code>--help</code> 列得出來，這次沒有在主機上跑）。</p>
<p><code>--security</code> 只認套件來源裡帶 updateinfo（安全公告）中繼資料的更新，nginx 官方套件來源這類第三方來源的安全修補不會算進去（依 dnf 的說明推論，這台主機只用 Oracle Linux 的套件來源）。</p>
<p>這一步的其他做法：<code>dnf upgrade</code>（不加 <code>--security</code>）把非安全性的更新一起裝上，裝的套件更多、時間更長，換到的是恢復之後不必另外安排一般更新；<code>dnf-automatic</code> 這類自動更新在主機停止期間不會執行，恢復時照樣要補裝。另一種做法是用最新的映像檔重建主機：省下裝更新與可能的重開機，代價是網路、nginx 與服務都要照回源那篇重建，臨時公開 IP 會換、DNS 紀錄要改（暫停那篇〈恢復的順序：依元件之間的依賴排列〉）。這次只比較了要裝的範圍，三種另外的做法都沒有實跑。</p>
<p>只對 loopback 的冒煙測試：<code>curl http://127.0.0.1/</code> 與 <code>curl -k https://127.0.0.1/</code> 都回 200。監控事件的服務對 <code>/</code> 回 404，它的根路徑本來就沒有內容；要驗證它，要打它實際接收事件的路徑，這次沒有做。</p>
<p><strong>對外入口開放</strong>：NSG 裡的規則是暫停前 Cloudflare 公布的 IPv4 位址段。暫停期間 Cloudflare 新增了位址段的話，從新位址段回源的請求沒有規則放行、會回 522，而且只有部分請求失敗。反方向也要看：Cloudflare 撤回的位址段，舊規則會繼續放行，而那些位址已經不屬於 Cloudflare。所以掛回之前，先把 Cloudflare 公布的 IPv4 清單與 NSG 規則的來源位址比對，兩個方向的差異都處理，有新增的照回源那篇的寫法補進規則（每次 API 呼叫最多加 25 條）。這次暫停 2 小時，沒有做這一步比對；下面兩條印出兩邊的清單（沒有實跑）：</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">curl -s https://www.cloudflare.com/ips-v4 <span class="p">|</span> sort &gt; cf-v4.txt
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># &lt;放行 Cloudflare 的 NSG 的 OCID&gt; 是 nsg-ids.json 裡那一個；80 與 443 各一條，所以每個來源出現兩次，sort -u 去重</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">oci network nsg rules list --nsg-id &lt;放行 Cloudflare 的 NSG 的 OCID&gt; --all <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;data[?direction==`INGRESS`].source&#39;</span> --raw-output <span class="p">|</span> tr -d <span class="s1">&#39;[]&#34;, &#39;</span> <span class="p">|</span> grep . <span class="p">|</span> sort -u &gt; nsg-v4.txt
</span></span><span class="line"><span class="ln">5</span><span class="cl">diff cf-v4.txt nsg-v4.txt    <span class="c1"># 「&lt;」是 Cloudflare 新增、NSG 還沒有的；「&gt;」是 NSG 有、Cloudflare 已經撤回的</span></span></span></code></pre></div><p>接著把存下來的 NSG 清單掛回網卡，再從外面驗證：</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"># &lt;NSG 的 OCID&gt; 是 nsg-ids.json 裡的值；原本掛著好幾個時，清單要寫回 nsg-ids.json 裡的全部，否則其餘的不會掛回去</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># （OCI CLI 的清單參數也收 --nsg-ids file://nsg-ids.json，直接讀存下來的檔，這種寫法沒有實跑）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">oci network vnic update --vnic-id &lt;VNIC 的 OCID&gt; --nsg-ids <span class="s1">&#39;[&#34;&lt;NSG 的 OCID&gt;&#34;]&#39;</span> --force
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># &lt;公開 IP&gt; 是〈暫停前記下的基準：接聽的程式、服務、待裝的更新、憑證效期與公開 IP〉那條 list-vnics 印的 pub 欄</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">curl -s -o /dev/null -w <span class="s1">&#39;direct %{http_code}\n&#39;</span> --max-time <span class="m">8</span> http://&lt;公開 IP&gt;/      <span class="c1"># 繞過 Cloudflare 直連</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">curl -s -o /dev/null -w <span class="s1">&#39;cf %{http_code} %{time_total}\n&#39;</span> --max-time <span class="m">40</span> https://&lt;網域&gt;/</span></span></code></pre></div><p>07:04:05 掛回，07:04:15 直連回 <code>000</code>（8 秒逾時）：NSG 只放行 Cloudflare 的位址段，開發機的 IP 進不來。07:04:16 經 Cloudflare 回 <code>200</code>（1.19 秒），<code>www</code> 的根路徑 07:04:24 也回 200（07:04:18 對 <code>www</code> 打的是網站改版前的舊路徑，回 404，跟入口無關）。第一次經 Cloudflare 的請求是在掛回之後約 10 秒才送出的（前面先跑了逾時 8 秒的直連），所以量到的只是 NSG 在 10 秒之內生效，生效最快要多久沒有量到。</p>
<p>入口要等更新裝完才開，是因為這台主機的服務在開機的那一刻就起來了（上表的 <code>active</code>）。恢復時沒有收窄入口的話，從 07:02:03 主機 <code>RUNNING</code> 到 07:03:40 更新裝完的 97 秒裡，主機上還有 18 個安全更新沒裝，nginx 與監控事件的服務已經在接受 Cloudflare 送來的連線。這一批的 18 個套件裡沒有 nginx 與那個服務本身的套件；它們載入的共用函式庫有沒有在這一批裡、要不要重啟，這次沒有用 <code>needs-restarting -s</code> 查。換一批更新、裡面有接聽的程式或它用的函式庫時，那段時間裡對外接聽的就是缺了修補的程式。暫停期越長、要裝的更新越多、要重開機時，這段時間越長。</p>
<p>從送出 <code>START</code> 到經 Cloudflare 回 200，整個恢復花了 2 分 48 秒；其中裝更新 28 秒，其餘是等主機啟動與手動逐步確認的時間。</p>
<h2 id="收尾dns-紀錄主機與網路資源的刪除順序">收尾：DNS 紀錄、主機與網路資源的刪除順序</h2>
<p>演練完，這台主機與它的網路要收掉。順序是先刪 DNS 紀錄、再終止主機、最後刪網路資源。先刪 DNS 紀錄，是因為終止主機時臨時公開 IP 會還給 Oracle Cloud，DNS 紀錄還指著它就是上面〈停止主機：SOFTSTOP、耗時、公開 IP 的去留與停止中的用量〉寫的懸空 DNS（完整的後果見暫停那篇〈暫停期間仍指向這台主機的外部設定〉）。網路資源排在主機之後，是因為它們被主機的網卡引用著，主機還在時刪不掉。指著這個 IP 的不只 DNS 紀錄：外部監控的探測、別處的 IP 白名單、CDN 的其他回源設定也要在終止之前一起移除（暫停那篇〈暫停期間仍指向這台主機的外部設定〉）；這台主機只有 DNS 紀錄指著它。要讓終止或重建前後 IP 不變，另一種做法是一開始就用保留的公開 IP（reserved public IP），它存在到被刪除為止，DNS 紀錄不必改，懸空 DNS 也不會發生（<a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a>〈公開 IP：網際網路上的入口〉；保留 IP 的計費這次沒有查）。</p>
<ol>
<li>
<p><strong>刪 DNS 紀錄</strong>：在 Cloudflare 主控台的 DNS → Records 頁，刪掉根網域與 <code>www</code> 兩筆指到這台主機的 A 紀錄（每一筆從右側的選單選 Delete record，再按確認），郵件的 MX、SPF、DKIM 紀錄不動。刪完之後直接問網域的權威 nameserver，避開遞迴解析器的快取，刪除當下就讀得到結果；<code>&lt;權威 nameserver&gt;</code> 用 <code>dig NS &lt;網域&gt; +short</code> 查。經過 Cloudflare 代理的紀錄，訪客解析到的是 Cloudflare 的位址，紀錄一刪，Cloudflare 就不再回源到這台主機，所以這次確認刪除之後 24 秒就終止主機。不經代理、直接指公開 IP 的紀錄，訪客的遞迴解析器在舊紀錄的 TTL（DNS 回應可以被快取多久，見 <a href="/blog/devops/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS</a> 卡）到期之前還會回這個 IP，要從刪除那一刻起等過舊紀錄的 TTL 再終止主機；TTL 要在刪除之前讀，刪掉之後就讀不到了，<code>dig A &lt;網域&gt; @&lt;權威 nameserver&gt;</code> 的 ANSWER 段第二欄就是它（從 DNS 快取的機制推論，這次的紀錄都經過代理）：</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">dig A &lt;網域&gt; @&lt;權威 nameserver&gt;        <span class="c1"># 看 status 與有沒有 ANSWER</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">dig A www.&lt;網域&gt; @&lt;權威 nameserver&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">dig MX &lt;網域&gt; @&lt;權威 nameserver&gt; +short  <span class="c1"># 郵件的紀錄還在</span></span></span></code></pre></div><p>07:19:54 查：根網域回 <code>NOERROR</code> 而沒有 A 紀錄（根網域上還有 MX 這些紀錄，所以名稱存在），<code>www</code> 回 <code>NXDOMAIN</code>；<code>dig MX</code> 照樣列出三筆。</p>
</li>
<li>
<p><strong>終止主機並刪掉開機磁碟</strong>：</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">oci compute instance terminate --instance-id &lt;主機的 OCID&gt; --preserve-boot-volume <span class="nb">false</span> --force <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --wait-for-state SUCCEEDED --wait-for-state FAILED --max-wait-seconds <span class="m">900</span></span></span></code></pre></div><p>07:20:18 送出，07:21:24 work request <code>SUCCEEDED</code>（66 秒）。<code>--preserve-boot-volume false</code> 讓開機磁碟一起刪掉，免費區塊儲存的 47 GB 才會釋放；不帶時開機磁碟留下來、繼續佔額度（回源那篇的清理寫了這一點；不帶的情形這次沒有實跑）。<code>terminate</code> 的 <code>--wait-for-state</code> 只收 work request 的四種狀態（<code>ACCEPTED</code>、<code>IN_PROGRESS</code>、<code>FAILED</code>、<code>SUCCEEDED</code>）。寫成主機的 <code>TERMINATED</code> 時 CLI 以 2 結束、請求不會送出（同一天對另一台主機實跑；後續網路資源跟著刪不掉的經過在回源那篇〈清理與 Cloudflare 設定的還原〉）。帶 <code>SUCCEEDED</code> 與 <code>FAILED</code> 兩個值時，CLI 等到其中一個就回傳，回傳的 work request 的 <code>status</code> 欄才分得出成功或失敗。</p>
</li>
<li>
<p><strong>刪網路資源</strong>：照回源那篇〈清理與 Cloudflare 設定的還原〉的順序刪 NSG、公開子網路、security list、route table、internet gateway，每一條都帶 <code>--wait-for-state TERMINATED</code>（這幾種資源的刪除收的是資源本身的狀態）。五條在 16 秒內跑完，之後每一個 <code>get</code> 都回 <code>NotAuthorizedOrNotFound</code>。</p>
</li>
</ol>
<p>終止之後，免費區塊儲存的用量少了這顆開機磁碟的 47（實跑讀到）；A1 的用量照理少 1 OCPU、6 GB，終止之後沒有讀。</p>
<h2 id="延伸閱讀暫停的範圍與恢復的順序回源的網路帳務明細的查法">延伸閱讀：暫停的範圍與恢復的順序、回源的網路、帳務明細的查法</h2>
<ul>
<li><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>：本篇演練的「停止主機」以外的暫停範圍（只停服務、終止主機但保留磁碟與網路設定、全部刪除再從 repo 重建）、怎麼依保留的月費與重建的成本選擇、恢復時憑證續期的時機</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a>：這台主機的公開子網路、NSG 與 nginx 怎麼建的，以及網路資源的刪除順序與各自的錯誤訊息</li>
<li><a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>：usage API 查帳務明細的每個參數、開機磁碟與免費區塊儲存額度的查法、超過額度的磁碟整顆計費與自動轉成免費</li>
<li><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>
</ul>
]]></content:encoded></item><item><title>模組九：單台主機的長期維護、服務暫停與恢復上線</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/</guid><description>&lt;p>模組四到模組八處理的是服務在運行期遇到的事件：程序掛掉、流量超過處理能力、節點故障、帳單成長。本模組處理的是另一條時間軸：一台主機在沒有人操作、也沒有事件發生的期間，它自己的狀態與它依賴的外部設定一直在改變。&lt;/p>
&lt;h2 id="推導源頭">推導源頭&lt;/h2>
&lt;p>一台長期上線的主機，狀態的改變來自四種來源：會累積的資料（log、journal、快取、應用程式資料由寫入者持續寫入，而刪除者不一定存在）、會老化的軟體（已安裝的版本固定，已知漏洞的清單每天增加）、會漂移的入口（新套件帶進新的接聽程式、某一層防火牆被單獨修改）、會到期與會改變的外部設定（憑證、網域、CLI 的登入、管理者的 IP、CDN 的位址段、免費額度的閒置回收）。&lt;/p>
&lt;p>由這四種來源推出本模組各篇的分工：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>維護&lt;/strong>是在主機運行期間，定期把每一種狀態量一次、跟允許的範圍比對；允許的範圍與檢查間隔由那種狀態改變的速度與超出之後的後果決定。&lt;/li>
&lt;li>&lt;strong>暫停&lt;/strong>是決定哪些狀態要保留：保留的部分繼續計費、繼續被 DNS 與 CDN 指向，也繼續漂移；刪除的部分在恢復時要重建。&lt;/li>
&lt;li>&lt;strong>恢復上線&lt;/strong>是把暫停期間漂移過的每一項重新驗過，並依元件之間的依賴決定順序。&lt;/li>
&lt;li>&lt;strong>清單&lt;/strong>是把前三者要檢查的項目寫成一份資料檔，讓同一份項目每次都被同樣地執行，而不是靠讀文件的人記得；主機變成多台時，清單的值再依每台相同、依角色不同、每台不同分層，由產生設定檔的工具合併成每台一份。&lt;/li>
&lt;/ul>
&lt;h2 id="章節">章節&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>交付&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&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）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目&lt;/a>&lt;/td>
 &lt;td>四種狀態來源各自的量測、清理機制、定期讀的 log、入口盤點、各條更新管道的安全更新與重開機、檢查間隔的推算，以及接手一台主機時先處理的項目&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>只停服務、停止、終止、全部刪除各自保留的資源、暫停時先收窄的入口、暫停期間仍計費與仍被指向的項目、恢復前要查的漂移、恢復的順序與驗證&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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&lt;/a>&lt;/td>
 &lt;td>JSON、YAML、TOML 的取捨與實測、TOML 1.0 與 1.1 的版本差異、清單的欄位設計、只讀檢查腳本的行為與 exit code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單&lt;/a>&lt;/td>
 &lt;td>每台相同、依角色不同、每台不同的值的分層；合併放在部署時產生、檢查腳本讀多份或執行時覆寫；Ansible 產生每台的清單、Terraform 產生 inventory、plan 的比對範圍、跨主機的 exit code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/" data-link-title="主機維護的實作紀錄" data-link-desc="要照著把主機維護清單部署到多台主機並執行檢查腳本，或要看 Terraform 產生 inventory、Ansible 的範本與 validate、sudo 與 systemd-run 的沙箱、跨主機的 exit code 在可拋棄的主機上實際跑出什麼結果時，查實際跑過的指令、輸出與除錯過程；要讀只讀檢查腳本的完整原始碼、照它的做法寫自己的檢查腳本時，查逐段的說明與這一版的已知限制">主機維護的實作紀錄&lt;/a>（&lt;code>hands-on/&lt;/code>）&lt;/td>
 &lt;td>多台主機維護清單在兩台容器上從安裝走到清理：Terraform 寫出 inventory、照檢查腳本實際欄位寫的範本與 validate、以 &lt;code>-b&lt;/code> 與 &lt;code>systemd-run&lt;/code> 執行檢查、WARN、清單格式錯與連不上的主機各自的 exit code；只讀檢查腳本 host-check.py 的完整原始碼與逐段說明&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="各家業者的實作">各家業者的實作&lt;/h2>
&lt;p>停止與終止主機的計費規則、磁碟的去留、免費額度的閒置回收屬於各家業者的事實，寫在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/" data-link-title="雲端業者的帳號申請與主機維運" data-link-desc="要向某一家雲端業者申請帳號、評估免費額度、看懂建立主機頁上的各個欄位與主控台的管理領域，或從本機用 CLI 管理主機時，查 vendor 中立的選項說明、各家帳號結構、免費額度規則與實測過的建置紀錄">雲端業者的帳號申請與主機維運&lt;/a> 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/shape-and-image/" data-link-title="機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統" data-link-desc="雲端主機的機型決定的 CPU 架構、核心與記憶體，計費模式（隨用隨付、可被收回、預留容量、專屬主機）各自的條件，映像檔與機型之間的架構相容性，以及哪些項目建立後可以調整">機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統&lt;/a>〈停止主機時的計費〉、&lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/storage/" data-link-title="磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留" data-link-desc="雲端主機的磁碟選項：開機磁碟的大小與只能加大的限制、效能等級與費用的關係、靜態加密與傳輸中加密、另外掛載的區塊磁碟，以及刪除主機時開機磁碟是否預設一起刪除">磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留&lt;/a>〈刪除（終止）主機時開機磁碟的預設去留〉與 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目&lt;/a>〈出站流量與閒置回收〉。&lt;/p>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;p>格式見 &lt;a href="https://tarrragon.github.io/blog/posts/backlog-format-spec/" data-link-title="教學模組的 Backlog 段格式規範" data-link-desc="要在模組大綱記錄未完成工作、或要跨模組盤點還有什麼沒寫時，用來決定段名、欄位與哪些內容不進 backlog">Backlog 段格式規範&lt;/a>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>前置條件&lt;/th>
 &lt;th>規模&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>暫停那篇的停機演練還沒做的部分（照主機清單跑維護檢查的兩次比對、DNS 改指維護頁、521、要重開機時的恢復耗時）；停止主機、計費、Cloudflare 的 522 與恢復的每一步已寫成 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄&lt;/a>，待辦的主列登記在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/" data-link-title="Oracle Cloud（OCI）" data-link-desc="準備申請或已經申請了 Oracle Cloud 免費帳號（home region 要在送出申請之前選）、要在 Always Free 額度內建主機並從本機管理時，查帳號結構、CLI 登入與實測紀錄">Oracle Cloud（OCI）&lt;/a> 的 Backlog&lt;/td>
 &lt;td>跨模組&lt;/td>
 &lt;td>一台放在 Cloudflare 後面、有主機清單的雲端主機&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護那篇還只取自文件或推論的幾項：序列主控台登入作業系統的那一步（Oracle Cloud 只看了開機輸出，登入要先替一個使用者設密碼）、AWS 的序列主控台、Debian 12 雲端映像檔預先裝的套件、&lt;code>NEEDRESTART-KSTA&lt;/code> 的 &lt;code>2&lt;/code>（ABI 相容的核心更新）、unattended-upgrades 在連不到套件來源的主機上由 timer 觸發時的紀錄&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>一台不在收流量的雲端主機；設密碼那一步由使用者決定&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多台主機維護清單的實作紀錄在真主機上補驗：容器裡驗不到的 SELinux、logind、firewalld、真實網路下 &lt;code>UNREACHABLE!&lt;/code> 的訊息與耗時；真主機上的沙箱確認（&lt;code>systemd-detect-virt&lt;/code> 與 &lt;code>touch /var/x&lt;/code>）；開了持久 journal 的主機上 &lt;code>accumulation/journal&lt;/code> 量到的大小；&lt;code>terraform plan&lt;/code> 對雲端防火牆規則的比對；同一次執行裡一台 &lt;code>FAILED!&lt;/code>、一台 &lt;code>UNREACHABLE!&lt;/code> 時 &lt;code>ansible&lt;/code> 的 exit code（目前依原始碼）；以 root 執行而不套 &lt;code>systemd-run&lt;/code>；補進 &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` 預覽與實際部署結果不同的情形時，查在可拋棄的容器上實際跑過的指令、輸出與遇到的錯誤">多台主機維護清單的實作紀錄&lt;/a>&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>兩台以上可拋棄的雲端主機&lt;/td>
 &lt;td>小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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&lt;/a>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉與〈清單欄位的設計〉補自寫腳本以外的做法與換到的結果：現成工具（Goss、InSpec 或 Cinc Auditor、osquery、Ansible 的 check 模式加 &lt;code>assert&lt;/code>）能否給出逐條比對選項的只讀保證、主機外項目的 MANUAL、依階段跟前一次比的差異；結果檔改用 sqlite（留得下歷史、有 transaction，順帶處理兩次執行同時寫入）或 journal（示範主機的 journal 一開始只在記憶體裡）；exit code 對照監控外掛的 0／1／2／3 慣例；執行時設 &lt;code>TZ=UTC&lt;/code>，取代不猜時區縮寫的做法；清單只收一種正規的選項寫法，取代模擬各工具的 &lt;code>getopt_long&lt;/code>；清單的指令寫成 TOML 陣列，省掉 shlex 切詞；&lt;code>SystemCallFilter=&lt;/code> 與唯讀 bind mount 為什麼不採用&lt;/td>
 &lt;td>主章&lt;/td>
 &lt;td>各工具的能力先查官方文件；能實跑的在可拋棄的主機上跑&lt;/td>
 &lt;td>小到中&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">backend 4.0.1 監控鏈事前偵測不到的異常&lt;/a>：到期事件、備份與成本這類該自動告警的項目；本模組寫的是定期由人量測與清理的項目&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/" data-link-title="模組四：服務探活與自動恢復" data-link-desc="服務掛了怎麼自動發現和恢復 — health check 設計、liveness vs readiness、systemd watchdog、process supervisor">operations 模組四 服務探活與自動恢復&lt;/a>：程序掛掉之後的自動重啟；本模組處理的是程序正常而狀態漂移&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/operations/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing 與成本監控告警；預算門檻、配額與停損層級的推算在本模組，各家的建置篇目前只有 GCP，放在雲端業者模組">operations 模組八 成本管理&lt;/a>：帳單層的監控；暫停那篇寫的是單台主機暫停時哪些資源還在計費&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/operations/06-high-availability/" data-link-title="模組六：高可用與災難復原" data-link-desc="一個節點掛了服務怎麼不中斷 — 冗餘設計、failover 機制、disaster recovery 策略">operations 模組六 高可用&lt;/a>：沒有預期的故障之後的恢復與 restore 驗證；本模組處理計畫內的暫停與恢復&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>模組四到模組八處理的是服務在運行期遇到的事件：程序掛掉、流量超過處理能力、節點故障、帳單成長。本模組處理的是另一條時間軸：一台主機在沒有人操作、也沒有事件發生的期間，它自己的狀態與它依賴的外部設定一直在改變。</p>
<h2 id="推導源頭">推導源頭</h2>
<p>一台長期上線的主機，狀態的改變來自四種來源：會累積的資料（log、journal、快取、應用程式資料由寫入者持續寫入，而刪除者不一定存在）、會老化的軟體（已安裝的版本固定，已知漏洞的清單每天增加）、會漂移的入口（新套件帶進新的接聽程式、某一層防火牆被單獨修改）、會到期與會改變的外部設定（憑證、網域、CLI 的登入、管理者的 IP、CDN 的位址段、免費額度的閒置回收）。</p>
<p>由這四種來源推出本模組各篇的分工：</p>
<ul>
<li><strong>維護</strong>是在主機運行期間，定期把每一種狀態量一次、跟允許的範圍比對；允許的範圍與檢查間隔由那種狀態改變的速度與超出之後的後果決定。</li>
<li><strong>暫停</strong>是決定哪些狀態要保留：保留的部分繼續計費、繼續被 DNS 與 CDN 指向，也繼續漂移；刪除的部分在恢復時要重建。</li>
<li><strong>恢復上線</strong>是把暫停期間漂移過的每一項重新驗過，並依元件之間的依賴決定順序。</li>
<li><strong>清單</strong>是把前三者要檢查的項目寫成一份資料檔，讓同一份項目每次都被同樣地執行，而不是靠讀文件的人記得；主機變成多台時，清單的值再依每台相同、依角色不同、每台不同分層，由產生設定檔的工具合併成每台一份。</li>
</ul>
<h2 id="章節">章節</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>交付</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目</a></td>
          <td>四種狀態來源各自的量測、清理機制、定期讀的 log、入口盤點、各條更新管道的安全更新與重開機、檢查間隔的推算，以及接手一台主機時先處理的項目</td>
      </tr>
      <tr>
          <td><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></td>
          <td>只停服務、停止、終止、全部刪除各自保留的資源、暫停時先收窄的入口、暫停期間仍計費與仍被指向的項目、恢復前要查的漂移、恢復的順序與驗證</td>
      </tr>
      <tr>
          <td><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</a></td>
          <td>JSON、YAML、TOML 的取捨與實測、TOML 1.0 與 1.1 的版本差異、清單的欄位設計、只讀檢查腳本的行為與 exit code</td>
      </tr>
      <tr>
          <td><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 的讀法與執行身分的代價，以及單台清單夠用的條件">多台主機的維護清單</a></td>
          <td>每台相同、依角色不同、每台不同的值的分層；合併放在部署時產生、檢查腳本讀多份或執行時覆寫；Ansible 產生每台的清單、Terraform 產生 inventory、plan 的比對範圍、跨主機的 exit code</td>
      </tr>
      <tr>
          <td><a href="/blog/devops/operations/09-host-lifecycle/hands-on/" data-link-title="主機維護的實作紀錄" data-link-desc="要照著把主機維護清單部署到多台主機並執行檢查腳本，或要看 Terraform 產生 inventory、Ansible 的範本與 validate、sudo 與 systemd-run 的沙箱、跨主機的 exit code 在可拋棄的主機上實際跑出什麼結果時，查實際跑過的指令、輸出與除錯過程；要讀只讀檢查腳本的完整原始碼、照它的做法寫自己的檢查腳本時，查逐段的說明與這一版的已知限制">主機維護的實作紀錄</a>（<code>hands-on/</code>）</td>
          <td>多台主機維護清單在兩台容器上從安裝走到清理：Terraform 寫出 inventory、照檢查腳本實際欄位寫的範本與 validate、以 <code>-b</code> 與 <code>systemd-run</code> 執行檢查、WARN、清單格式錯與連不上的主機各自的 exit code；只讀檢查腳本 host-check.py 的完整原始碼與逐段說明</td>
      </tr>
  </tbody>
</table>
<h2 id="各家業者的實作">各家業者的實作</h2>
<p>停止與終止主機的計費規則、磁碟的去留、免費額度的閒置回收屬於各家業者的事實，寫在 <a href="/blog/devops/infra/cloud-providers/" data-link-title="雲端業者的帳號申請與主機維運" data-link-desc="要向某一家雲端業者申請帳號、評估免費額度、看懂建立主機頁上的各個欄位與主控台的管理領域，或從本機用 CLI 管理主機時，查 vendor 中立的選項說明、各家帳號結構、免費額度規則與實測過的建置紀錄">雲端業者的帳號申請與主機維運</a> 的 <a href="/blog/devops/infra/cloud-providers/vm-options/shape-and-image/" data-link-title="機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統" data-link-desc="雲端主機的機型決定的 CPU 架構、核心與記憶體，計費模式（隨用隨付、可被收回、預留容量、專屬主機）各自的條件，映像檔與機型之間的架構相容性，以及哪些項目建立後可以調整">機型與映像檔：CPU 架構、核心與記憶體、計費模式與作業系統</a>〈停止主機時的計費〉、<a href="/blog/devops/infra/cloud-providers/vm-options/storage/" data-link-title="磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留" data-link-desc="雲端主機的磁碟選項：開機磁碟的大小與只能加大的限制、效能等級與費用的關係、靜態加密與傳輸中加密、另外掛載的區塊磁碟，以及刪除主機時開機磁碟是否預設一起刪除">磁碟：開機磁碟與區塊磁碟的大小、效能、加密與去留</a>〈刪除（終止）主機時開機磁碟的預設去留〉與 <a href="/blog/devops/infra/cloud-providers/pricing-comparison/" data-link-title="雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目" data-link-desc="Oracle Cloud、Google Cloud 與 AWS 的免費方案分別由哪幾種額度組成，比較時要換算的計費單位，免費主機能放在哪些區域，試用期結束後帳號與資源會怎樣，以及付費主機的價格該去哪裡查">雲端免費額度與小型主機的收費比較：試用金與固定額度、計費單位、區域限制、試用結束後的處置、出站流量與查價項目</a>〈出站流量與閒置回收〉。</p>
<h2 id="backlog">Backlog</h2>
<p>格式見 <a href="/blog/posts/backlog-format-spec/" data-link-title="教學模組的 Backlog 段格式規範" data-link-desc="要在模組大綱記錄未完成工作、或要跨模組盤點還有什麼沒寫時，用來決定段名、欄位與哪些內容不進 backlog">Backlog 段格式規範</a>。</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>類型</th>
          <th>前置條件</th>
          <th>規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>暫停那篇的停機演練還沒做的部分（照主機清單跑維護檢查的兩次比對、DNS 改指維護頁、521、要重開機時的恢復耗時）；停止主機、計費、Cloudflare 的 522 與恢復的每一步已寫成 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄</a>，待辦的主列登記在 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/" data-link-title="Oracle Cloud（OCI）" data-link-desc="準備申請或已經申請了 Oracle Cloud 免費帳號（home region 要在送出申請之前選）、要在 Always Free 額度內建主機並從本機管理時，查帳號結構、CLI 登入與實測紀錄">Oracle Cloud（OCI）</a> 的 Backlog</td>
          <td>跨模組</td>
          <td>一台放在 Cloudflare 後面、有主機清單的雲端主機</td>
          <td>小</td>
      </tr>
      <tr>
          <td>維護那篇還只取自文件或推論的幾項：序列主控台登入作業系統的那一步（Oracle Cloud 只看了開機輸出，登入要先替一個使用者設密碼）、AWS 的序列主控台、Debian 12 雲端映像檔預先裝的套件、<code>NEEDRESTART-KSTA</code> 的 <code>2</code>（ABI 相容的核心更新）、unattended-upgrades 在連不到套件來源的主機上由 timer 觸發時的紀錄</td>
          <td>主章</td>
          <td>一台不在收流量的雲端主機；設密碼那一步由使用者決定</td>
          <td>小</td>
      </tr>
      <tr>
          <td>多台主機維護清單的實作紀錄在真主機上補驗：容器裡驗不到的 SELinux、logind、firewalld、真實網路下 <code>UNREACHABLE!</code> 的訊息與耗時；真主機上的沙箱確認（<code>systemd-detect-virt</code> 與 <code>touch /var/x</code>）；開了持久 journal 的主機上 <code>accumulation/journal</code> 量到的大小；<code>terraform plan</code> 對雲端防火牆規則的比對；同一次執行裡一台 <code>FAILED!</code>、一台 <code>UNREACHABLE!</code> 時 <code>ansible</code> 的 exit code（目前依原始碼）；以 root 執行而不套 <code>systemd-run</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` 預覽與實際部署結果不同的情形時，查在可拋棄的容器上實際跑過的指令、輸出與遇到的錯誤">多台主機維護清單的實作紀錄</a></td>
          <td>主章</td>
          <td>兩台以上可拋棄的雲端主機</td>
          <td>小</td>
      </tr>
      <tr>
          <td><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</a>〈只讀檢查腳本的白名單、輸出、exit code 與結果檔〉與〈清單欄位的設計〉補自寫腳本以外的做法與換到的結果：現成工具（Goss、InSpec 或 Cinc Auditor、osquery、Ansible 的 check 模式加 <code>assert</code>）能否給出逐條比對選項的只讀保證、主機外項目的 MANUAL、依階段跟前一次比的差異；結果檔改用 sqlite（留得下歷史、有 transaction，順帶處理兩次執行同時寫入）或 journal（示範主機的 journal 一開始只在記憶體裡）；exit code 對照監控外掛的 0／1／2／3 慣例；執行時設 <code>TZ=UTC</code>，取代不猜時區縮寫的做法；清單只收一種正規的選項寫法，取代模擬各工具的 <code>getopt_long</code>；清單的指令寫成 TOML 陣列，省掉 shlex 切詞；<code>SystemCallFilter=</code> 與唯讀 bind mount 為什麼不採用</td>
          <td>主章</td>
          <td>各工具的能力先查官方文件；能實跑的在可拋棄的主機上跑</td>
          <td>小到中</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">backend 4.0.1 監控鏈事前偵測不到的異常</a>：到期事件、備份與成本這類該自動告警的項目；本模組寫的是定期由人量測與清理的項目</li>
<li>→ <a href="/blog/devops/operations/04-service-health/" data-link-title="模組四：服務探活與自動恢復" data-link-desc="服務掛了怎麼自動發現和恢復 — health check 設計、liveness vs readiness、systemd watchdog、process supervisor">operations 模組四 服務探活與自動恢復</a>：程序掛掉之後的自動重啟；本模組處理的是程序正常而狀態漂移</li>
<li>→ <a href="/blog/devops/operations/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing 與成本監控告警；預算門檻、配額與停損層級的推算在本模組，各家的建置篇目前只有 GCP，放在雲端業者模組">operations 模組八 成本管理</a>：帳單層的監控；暫停那篇寫的是單台主機暫停時哪些資源還在計費</li>
<li>→ <a href="/blog/devops/operations/06-high-availability/" data-link-title="模組六：高可用與災難復原" data-link-desc="一個節點掛了服務怎麼不中斷 — 冗餘設計、failover 機制、disaster recovery 策略">operations 模組六 高可用</a>：沒有預期的故障之後的恢復與 restore 驗證；本模組處理計畫內的暫停與恢復</li>
</ul>
]]></content:encoded></item><item><title>主機維護的實作紀錄</title><link>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/09-host-lifecycle/hands-on/</guid><description>&lt;p>這個資料夾收主機維護的操作紀錄（目前是多台主機維護清單的部署與檢查，以及檢查腳本的原始碼）：前置條件、每一步的指令與輸出、遇到的錯誤與除錯過程、量到的值。觀念與判斷依據寫在上一層的概念文章，這裡的文章連回去，不重寫。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>兩台 Oracle Linux 10 容器（一台兼任 web 與 db）加一個控制端、SSH 金鑰與 known_hosts、Terraform 的 local_file 寫出 inventory 與預設的檔案權限、照檢查腳本實際欄位寫的範本、validate 改用檢查腳本的載入函式、check 模式預覽不到的目錄錯誤、-b 與 systemd-run 的執行與沙箱確認、門檻造成的 WARN、清單格式錯、連不上的主機與 &amp;ndash;check &amp;ndash;diff 各自的 exit code、容器與真主機的差異、清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>host-check.py 的完整原始碼：依 getopt_long 語法切分選項、各工具列允許的選項規則、清單的驗證與 [vars] 替換、Runner 的環境與逾時、各項檢查的判定、讀不到與不存在的區分、systemd 時間格式、結果檔的 fsync 與換名、exit code 的出口與這一版的已知限制&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<p>這個資料夾收主機維護的操作紀錄（目前是多台主機維護清單的部署與檢查，以及檢查腳本的原始碼）：前置條件、每一步的指令與輸出、遇到的錯誤與除錯過程、量到的值。觀念與判斷依據寫在上一層的概念文章，這裡的文章連回去，不重寫。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><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></td>
          <td>兩台 Oracle Linux 10 容器（一台兼任 web 與 db）加一個控制端、SSH 金鑰與 known_hosts、Terraform 的 local_file 寫出 inventory 與預設的檔案權限、照檢查腳本實際欄位寫的範本、validate 改用檢查腳本的載入函式、check 模式預覽不到的目錄錯誤、-b 與 systemd-run 的執行與沙箱確認、門檻造成的 WARN、清單格式錯、連不上的主機與 &ndash;check &ndash;diff 各自的 exit code、容器與真主機的差異、清理</td>
      </tr>
      <tr>
          <td><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></td>
          <td>host-check.py 的完整原始碼：依 getopt_long 語法切分選項、各工具列允許的選項規則、清單的驗證與 [vars] 替換、Runner 的環境與逾時、各項檢查的判定、讀不到與不存在的區分、systemd 時間格式、結果檔的 fsync 與換名、exit code 的出口與這一版的已知限制</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</guid><description>&lt;p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 &lt;code>LEFT&lt;/code> 而不選 &lt;code>INNER&lt;/code>，選了 &lt;code>CROSS&lt;/code> 而不選 &lt;code>JOIN ... ON&lt;/code>，每一個選擇都在說明他預期資料長什麼樣。&lt;strong>引擎執行行為，而它從不檢查宣告有沒有兌現。&lt;/strong>&lt;/p>
&lt;p>宣告與行為分岔的時候，查詢照常回正確答案，測試照常通過，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>上也看不出異常。付出代價的是下一個改它的人：他讀到的是宣告，於是他手上的前提從一開始就是錯的。宣告與行為之外，還有一種落差在查詢與要問的問題之間：查詢在語法上完全合法、引擎照實執行，而它回答的不是要問的那一題，那一種在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a>。本篇的宣告落空時答案仍然正確，錯的是文字對下一個讀它的人說的話。&lt;/p>
&lt;p>本篇的查詢跑在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫&lt;/a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。讀這一篇需要先有三件事：&lt;code>LEFT&lt;/code> 保護了哪些列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a>，它與條件位置的互動在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>，連接怎麼把一列變成多列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。那三篇答的是「這個關鍵字做什麼」，這一篇不重寫那些機制，它問的是同一段查詢對下一個讀它的人說了什麼。&lt;/p>
&lt;h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態&lt;/h2>
&lt;p>&lt;code>LEFT JOIN&lt;/code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。&lt;/p>
&lt;p>&lt;code>CROSS JOIN&lt;/code> 說的是「我要每一種配對」。&lt;code>DISTINCT&lt;/code> 說的是「這一組值本身會重複，而重複對我沒有意義」。&lt;code>COALESCE&lt;/code> 說的是「這裡會有空值」。表的別名也是一種宣告：&lt;code>FROM 訂單 AS 早&lt;/code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。&lt;/p>
&lt;p>落空的樣子不只一種。宣告可能&lt;strong>被同一段查詢的別的部分撤銷&lt;/strong>（&lt;code>LEFT&lt;/code> 加上一個問右表的 &lt;code>WHERE&lt;/code>）、可能&lt;strong>描述一個引擎並沒有執行的動作&lt;/strong>（&lt;code>CROSS&lt;/code> 加等值條件）、可能&lt;strong>描述一個從一開始就不存在的情況&lt;/strong>（配對已有保證時的 &lt;code>LEFT&lt;/code>），也可能&lt;strong>指向錯的成因而答案仍然正確&lt;/strong>（&lt;code>DISTINCT&lt;/code> 收掉連接製造的重複），還可能&lt;strong>名字與它代表的那一次出現對調&lt;/strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：&lt;code>DISTINCT&lt;/code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 &lt;code>LEFT&lt;/code> 讓他改了 &lt;code>SELECT&lt;/code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 &lt;code>WHERE&lt;/code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。&lt;/p>
&lt;h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN&lt;/h2>
&lt;p>書店想找出下過大單的顧客。查詢寫成這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&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="c1">-- 佳穎、佳穎
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="c1">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&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="c1">-- 佳穎、佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LEFT&lt;/code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 &lt;code>WHERE&lt;/code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 &lt;code>NULL&lt;/code>，&lt;code>NULL &amp;gt; 200&lt;/code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。&lt;/p>
&lt;p>機制在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>：條件放 &lt;code>ON&lt;/code> 參與配對，放 &lt;code>WHERE&lt;/code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——&lt;strong>這段查詢的行為是內連接，而它的文字說的是外連接&lt;/strong>。&lt;/p>
&lt;p>下一個維護的人讀到 &lt;code>LEFT&lt;/code>，會以為沒下單的顧客在結果裡。他想加一個「顯示零元」的欄位：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">COALESCE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 他加的欄位：沒有訂單的顧客顯示 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 他沒有動的那一行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 300
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 500&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。&lt;/p></description><content:encoded><![CDATA[<p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 <code>LEFT</code> 而不選 <code>INNER</code>，選了 <code>CROSS</code> 而不選 <code>JOIN ... ON</code>，每一個選擇都在說明他預期資料長什麼樣。<strong>引擎執行行為，而它從不檢查宣告有沒有兌現。</strong></p>
<p>宣告與行為分岔的時候，查詢照常回正確答案，測試照常通過，<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>上也看不出異常。付出代價的是下一個改它的人：他讀到的是宣告，於是他手上的前提從一開始就是錯的。宣告與行為之外，還有一種落差在查詢與要問的問題之間：查詢在語法上完全合法、引擎照實執行，而它回答的不是要問的那一題，那一種在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>。本篇的宣告落空時答案仍然正確，錯的是文字對下一個讀它的人說的話。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。讀這一篇需要先有三件事：<code>LEFT</code> 保護了哪些列在 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a>，它與條件位置的互動在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>，連接怎麼把一列變成多列在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。那三篇答的是「這個關鍵字做什麼」，這一篇不重寫那些機制，它問的是同一段查詢對下一個讀它的人說了什麼。</p>
<h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態</h2>
<p><code>LEFT JOIN</code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。</p>
<p><code>CROSS JOIN</code> 說的是「我要每一種配對」。<code>DISTINCT</code> 說的是「這一組值本身會重複，而重複對我沒有意義」。<code>COALESCE</code> 說的是「這裡會有空值」。表的別名也是一種宣告：<code>FROM 訂單 AS 早</code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。</p>
<p>落空的樣子不只一種。宣告可能<strong>被同一段查詢的別的部分撤銷</strong>（<code>LEFT</code> 加上一個問右表的 <code>WHERE</code>）、可能<strong>描述一個引擎並沒有執行的動作</strong>（<code>CROSS</code> 加等值條件）、可能<strong>描述一個從一開始就不存在的情況</strong>（配對已有保證時的 <code>LEFT</code>），也可能<strong>指向錯的成因而答案仍然正確</strong>（<code>DISTINCT</code> 收掉連接製造的重複），還可能<strong>名字與它代表的那一次出現對調</strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：<code>DISTINCT</code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 <code>LEFT</code> 讓他改了 <code>SELECT</code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 <code>WHERE</code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。</p>
<h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN</h2>
<p>書店想找出下過大單的顧客。查詢寫成這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎</span></span></span></code></pre></div><p><code>LEFT</code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 <code>WHERE</code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 <code>NULL</code>，<code>NULL &gt; 200</code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。</p>
<p>機制在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>：條件放 <code>ON</code> 參與配對，放 <code>WHERE</code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——<strong>這段查詢的行為是內連接，而它的文字說的是外連接</strong>。</p>
<p>下一個維護的人讀到 <code>LEFT</code>，會以為沒下單的顧客在結果裡。他想加一個「顯示零元」的欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="n">COALESCE</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">金額</span><span class="w">   </span><span class="c1">-- 他加的欄位：沒有訂單的顧客顯示 0
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">                              </span><span class="c1">-- 他沒有動的那一行
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。</p>
<h2 id="cross-join-加等值條件與內連接的計畫">CROSS JOIN 加等值條件與內連接的計畫</h2>
<p>要比較同一批資料的兩列時，常見的寫法是把表跟自己配對再篩：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">昨天</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">今天</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">金額</span><span class="p">;</span></span></span></code></pre></div><p><code>CROSS JOIN</code> 宣告「我要每一種配對」，讀起來像是先造出所有配對再挑。而等值條件寫進去之後，<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>產出的計畫與內連接的計畫一模一樣。改寫成 <code>JOIN ... ON</code> 的版本把同一個條件搬進 <code>ON</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">昨天</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">今天</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">金額</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 102（CROSS JOIN 那一段也回 102）</span></span></span></code></pre></div><p>同一組資料上兩種寫法的計畫逐字相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- CROSS JOIN 加 WHERE 等值（SQLite 3.51，共用的範例資料庫，沒有另外建索引）
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">3</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">4</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">-- JOIN ... ON 同一個條件
</span></span><span class="line"><span class="ln">7</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">8</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">9</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p><code>AUTOMATIC</code> 表示這個索引是引擎為了這一次查詢臨時建的——訂單表上沒有 <code>顧客編號</code> 的索引，而它判斷建一個比逐列掃描划算。兩段查詢連這個決定都一樣。</p>
<p><code>CROSS JOIN</code> 在代數上確實對應<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>，而那張卡寫明展開由條件決定：條件把兩邊綁起來時全部組合一次都沒有被造出來。</p>
<p><code>CROSS JOIN</code> 這個宣告的誤導方向與 <code>LEFT</code> 相反。<code>LEFT</code> 那一節是宣告承諾了引擎沒做的事，這一節是宣告讓讀的人誤判了代價——看到 <code>CROSS</code> 就以為它是列數的平方，於是花力氣改寫一段本來就是內連接的查詢。兩段文字比不出快慢，快慢要向引擎要計畫——<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 在有無索引的兩種狀態下各量一次；而在這裡要先分清楚：<code>CROSS JOIN</code> 加等值條件與 <code>JOIN ... ON</code> 是同一段查詢的兩種寫法，換掉它省下的是讀的人的疑慮，不是執行時間。</p>
<h2 id="配對已有保證時的-left-join">配對已有保證時的 LEFT JOIN</h2>
<p>每一張訂單都帶著一個有效的顧客編號時，這樣寫也會過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p><code>LEFT</code> 宣告「訂單可能找不到顧客」。而兩列訂單都配到了，這個宣告一次都沒有生效過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">),</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">)</span><span class="w">   </span><span class="c1">-- count(*) 數列數，count(欄位) 只數那一欄不是 NULL 的列
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 2  2：沒有任何一列是 LEFT 補出來的</span></span></span></code></pre></div><p>它的代價要等到有人相信它才出現。下一個人想查有沒有孤兒訂單（指不到任何顧客的訂單），順手接上這段查詢：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">            </span><span class="c1">-- 配不到顧客的訂單，姓名會是 LEFT 補上的 NULL
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 零列</span></span></span></code></pre></div><p>拿到零列，於是回報「資料乾淨」。零列在這裡有兩種來源：確實沒有孤兒訂單，或者這個宣告本來就沒有內容。這段查詢分不出兩者。</p>
<p>真正保證配對的是<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>——外鍵存在時每一張訂單都指得到顧客，那時 <code>LEFT</code> 描述的是一個資料庫已經排除掉的情況。宣告與約束衝突時，讀的人多半相信宣告，因為它就在眼前，而約束在另一個檔案裡。</p>
<h2 id="收掉連接重複的-distinct">收掉連接重複的 DISTINCT</h2>
<p>顧客與訂單連起來之後，佳穎出現兩次，因為她有兩張訂單。要一份「下過單的顧客」名單，加 <code>DISTINCT</code> 就對了：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎</span></span></span></code></pre></div><p><code>DISTINCT</code> 宣告「姓名這一欄有重複值」。而顧客表裡佳穎只有一列——重複是連接製造出來的（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。宣告指向了錯的成因，答案卻是對的。</p>
<p>這種宣告落空的代價由下一次修改觸發，而觸發它的動作很小——多要一個欄位就夠：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p><code>DISTINCT</code> 作用在整列上，兩列的金額不同，於是它一列都收不掉。上一個人的修法在這裡失效，而失效的理由要回到「重複從哪裡來」這個問題——那個問題當初被 <code>DISTINCT</code> 跳過了。</p>
<h2 id="查證宣告的動作把關鍵字換成最弱的版本再跑一次">查證宣告的動作：把關鍵字換成最弱的版本再跑一次</h2>
<p>文字本身看不出宣告有沒有兌現——<code>LEFT</code> 寫在那裡，而它保護下來的列可能一列都不剩。差別要現形，得讓 <code>LEFT</code> 這類關鍵字消失一次：<strong>把它換成最弱的版本再跑一次，結果變不變就是它有沒有在做事。</strong> 這個動作在軟體測試裡叫 <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">mutation testing</a>：在現有資料上結果不變，是那張卡說的突變「存活」；它是不是等價突變（改寫之後行為在任何資料上都相同），要看造不造得出一筆讓改寫前後回不同答案的反例資料（<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 用的名字與造法）。</p>
<table>
  <thead>
      <tr>
          <th>宣告</th>
          <th>換成</th>
          <th>結果不變表示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>LEFT JOIN</code> / <code>RIGHT JOIN</code></td>
          <td><code>JOIN</code></td>
          <td>在現有資料上行為是內連接</td>
      </tr>
      <tr>
          <td><code>CROSS JOIN</code> 加 <code>WHERE</code> 等值</td>
          <td><code>JOIN ... ON</code> 同一個條件</td>
          <td>這段本來就是內連接（限等值）</td>
      </tr>
      <tr>
          <td><code>DISTINCT</code></td>
          <td>拿掉</td>
          <td>這一組值原本就各不相同</td>
      </tr>
      <tr>
          <td><code>COALESCE(欄位, 預設值)</code></td>
          <td>直接用該欄位</td>
          <td>這一欄在現有資料上沒有空值</td>
      </tr>
  </tbody>
</table>
<p>結果變了的時候，換掉關鍵字這個動作只證明被換掉的那個關鍵字在做事，還沒證明宣告是對的。<code>DISTINCT</code> 那一格就是這種：拿掉之後結果會變，而變的理由是連接把列複製了，與「這一欄有重複值」無關。所以結果變了之後還要問：<strong>變化的成因與宣告說的是不是同一件事</strong>。</p>
<p>配對已有保證時的 <code>LEFT</code>（〈配對已有保證時的 LEFT JOIN〉那一節）是換掉關鍵字再跑一次這個動作測不出來的：配對由外鍵保證的時候，<code>LEFT</code> 與 <code>JOIN</code> 在現有資料上結果相同，而換掉之後仍然看不出宣告當初有沒有意義。那一種要去查<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，判斷標準是資料庫有沒有替這個配對背書——而約束寫進 schema 也不一定生效，各家在哪一步把寫下與生效分開，在 <a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</a>。</p>
<p>對讀別人查詢的人來說，這個寫下與生效是否一致的差別回答的是「我可以相信這段文字說的話嗎」——而那個問題在讀的當下沒有別的入口。文字該先為哪一個讀者而寫——人還是引擎——是 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a> 的題目。</p>
]]></content:encoded></item></channel></rss>