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