<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jimmy</title>
    <description>The latest articles on DEV Community by Jimmy (@jimmy_3c703d).</description>
    <link>https://dev.to/jimmy_3c703d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4178874%2F6970ff37-9525-47b9-b4be-bde759b864a9.jpg</url>
      <title>DEV Community: Jimmy</title>
      <link>https://dev.to/jimmy_3c703d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jimmy_3c703d"/>
    <language>en</language>
    <item>
      <title>Bitnami 映像搬家後：漏改 exporter 的坑，以及用原映像加上 pgvector</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:58:19 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/bitnami-ying-xiang-ban-jia-hou-lou-gai-exporter-de-keng-yi-ji-yong-yuan-ying-xiang-jia-shang-pgvector-22ph</link>
      <guid>https://dev.to/jimmy_3c703d/bitnami-ying-xiang-ban-jia-hou-lou-gai-exporter-de-keng-yi-ji-yong-yuan-ying-xiang-jia-shang-pgvector-22ph</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：映像來源搬家時，先盤點 chart 裡每個容器；後來自己 build，也要分清楚「加了功能」和「有人持續維護底層」是兩件事。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  當初怎麼做
&lt;/h2&gt;

&lt;p&gt;用 Helm 裝 PostgreSQL、Redis，最方便的就是沿用 chart 預設映像。資料庫、metrics exporter、必要時調整目錄權限的初始化容器，都幫你接好了。&lt;/p&gt;

&lt;p&gt;直到上游調整映像目錄，原本 chart 指向的來源不再適用。當時先把映像 repository 換成 &lt;code&gt;bitnamilegacy&lt;/code&gt;，讓既有版本繼續跑。&lt;/p&gt;

&lt;p&gt;以為已經改完，後來卻還要補一個修正：PostgreSQL 和 Redis 的 metrics exporter 沒有一起改來源。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼只改主映像不夠
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bitnami/charts/issues/35164" rel="noopener noreferrer"&gt;Bitnami 官方公告&lt;/a&gt; 說明，歷史映像搬到 &lt;code&gt;docker.io/bitnamilegacy&lt;/code&gt;，Legacy 不再獲得更新或支援，只適合作為過渡。這是當時的遷移背景，不代表現在應該把新部署都指向 Legacy。&lt;/p&gt;

&lt;p&gt;一個 chart 通常不只一個 &lt;code&gt;image.repository&lt;/code&gt;。主容器改完，metrics sidecar 或 init container 仍可能沿用原來的預設值。&lt;/p&gt;

&lt;p&gt;翻回 Git，補修的內容其實只有兩個設定：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 各自放在 PostgreSQL 與 Redis chart 的 values 裡&lt;/span&gt;
&lt;span class="c1"&gt;# PostgreSQL&lt;/span&gt;
&lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnamilegacy/postgres-exporter&lt;/span&gt;

&lt;span class="c1"&gt;# Redis 請放另一份 values，不能把兩個 metrics 合併貼到同一份&lt;/span&gt;
&lt;span class="c1"&gt;# metrics:&lt;/span&gt;
&lt;span class="c1"&gt;#   image:&lt;/span&gt;
&lt;span class="c1"&gt;#     repository: bitnamilegacy/redis-exporter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;教訓很直接：values 看起來改了，不代表最後產生的每個容器都改了。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手：先看渲染結果
&lt;/h2&gt;

&lt;p&gt;這次用本機保存的 PostgreSQL chart &lt;code&gt;16.4.1&lt;/code&gt; 與 Redis chart &lt;code&gt;20.6.2&lt;/code&gt; 重做檢查。以下版本是歷史案例的重現條件，不是新部署的版本建議。&lt;/p&gt;

&lt;h3&gt;
  
  
  1. 把用到的 image 分開列出
&lt;/h3&gt;

&lt;p&gt;下面是 PostgreSQL 單獨 chart 的 values 節錄，檔名設為 &lt;code&gt;image-migration.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowInsecureImages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnamilegacy/postgresql&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;17.2.0-debian-12-r5&lt;/span&gt;

&lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnamilegacy/postgres-exporter&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.16.0-debian-12-r2&lt;/span&gt;

&lt;span class="na"&gt;volumePermissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnamilegacy/os-shell&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12-debian-12-r34&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這裡特意開啟 &lt;code&gt;volumePermissions&lt;/code&gt;，是為了讓初始化容器出現在渲染結果，不表示每個部署都需要開它。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;allowInsecureImages&lt;/code&gt; 也要講清楚：這版 chart 會拒絕不認得的替代映像，這個設定讓它跳過來源識別檢查；它沒有替映像做安全認證，也不是修復漏洞。&lt;a href="https://github.com/bitnami/charts/issues/30850" rel="noopener noreferrer"&gt;官方說明&lt;/a&gt; 有這個參數的範圍。&lt;/p&gt;

&lt;h3&gt;
  
  
  2. 渲染後逐一核對
&lt;/h3&gt;

&lt;p&gt;假設你已經有對應的 chart 壓縮檔，在該檔案所在資料夾執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm template demo ./postgresql-16.4.1.tgz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; image-migration.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; rendered.yaml
rg &lt;span class="s1"&gt;'^ *image:'&lt;/span&gt; rendered.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這次對照結果如下。tag 沒變，變的是 repository：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;容器&lt;/th&gt;
&lt;th&gt;只改主映像&lt;/th&gt;
&lt;th&gt;補齊設定後&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnamilegacy/postgresql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnamilegacy/postgresql&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;metrics exporter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnami/postgres-exporter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnamilegacy/postgres-exporter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;權限初始化&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnami/os-shell&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bitnamilegacy/os-shell&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Redis 同樣要看主容器、exporter 與初始化容器。這次也用相同方式渲染確認，但沒有啟動 Redis 或 exporter 做執行期測試。&lt;/p&gt;

&lt;p&gt;先維持原 chart 與映像 tag，只調整來源，比同時升 chart、換資料庫版本更容易確認問題在哪。至於 tag 在遠端是否還能拉取，要另外驗證；本次使用本機已存在的基底映像，沒有重做從 Docker Hub 冷拉的測試。&lt;/p&gt;

&lt;h2&gt;
  
  
  後來為什麼又自己 build
&lt;/h2&gt;

&lt;p&gt;之後需要 PostgreSQL 的 &lt;code&gt;pgvector&lt;/code&gt; 擴充。這跟映像目錄搬家是兩個不同需求，只是都碰到同一個映像。&lt;/p&gt;

&lt;p&gt;直接換另一個 PostgreSQL 發行映像，還要一起確認 entrypoint、環境變數、資料目錄和執行使用者是否符合原 chart 的期待。當時選擇在原本同一個 Bitnami PostgreSQL 基底上加擴充，把變動縮小。&lt;/p&gt;

&lt;p&gt;Dockerfile 可以拆成兩個階段：前面編譯，後面從乾淨的同一基底開始，只拿編譯產物。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; BASE=docker.io/bitnamilegacy/postgresql:17.2.0-debian-12-r5&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;${BASE}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; PGVECTOR_VERSION=v0.8.6&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; root&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; build-essential git ca-certificates &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 &lt;span class="nt"&gt;--branch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGVECTOR_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://github.com/pgvector/pgvector.git /tmp/pgvector &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/pgvector &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nv"&gt;PG_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/bitnami/postgresql/bin/pg_config &lt;span class="nv"&gt;OPTFLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nv"&gt;PG_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/bitnami/postgresql/bin/pg_config &lt;span class="nv"&gt;OPTFLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ${BASE}&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /opt/bitnami/postgresql/lib/vector.so /opt/bitnami/postgresql/lib/vector.so&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /opt/bitnami/postgresql/share/extension/ /opt/bitnami/postgresql/share/extension/&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; 1001&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;存成 &lt;code&gt;Dockerfile&lt;/code&gt; 後，可以在該資料夾建置：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; local-postgresql-vector:17.2.0-0.8.6 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PG_CONFIG&lt;/code&gt; 要指向這個 PostgreSQL 安裝，避免編到另一套版本。&lt;code&gt;OPTFLAGS=""&lt;/code&gt; 則避免預設的 &lt;code&gt;-march=native&lt;/code&gt; 把建置機特有的 CPU 指令帶進擴充；這是 &lt;a href="https://github.com/pgvector/pgvector#portability" rel="noopener noreferrer"&gt;pgvector 官方提供的可攜性做法&lt;/a&gt;，不等於可以跨 CPU 架構直接使用。&lt;/p&gt;

&lt;p&gt;第二個 &lt;code&gt;FROM&lt;/code&gt; 很重要：編譯階段安裝的套件不會自動進入最終映像。&lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Docker multi-stage build 文件&lt;/a&gt; 說明了這種選擇性複製的方式。&lt;/p&gt;

&lt;h2&gt;
  
  
  怎麼確認相容
&lt;/h2&gt;

&lt;p&gt;這次重新 build 後，以斷開外部網路的隔離容器和全新的測試 volume 驗證：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;用原始 PostgreSQL 映像啟動，建立一筆純測試資料。&lt;/li&gt;
&lt;li&gt;停止並移除舊容器，讓自建映像掛上同一個測試 volume。&lt;/li&gt;
&lt;li&gt;讀回資料，確認 &lt;code&gt;SHOW data_directory&lt;/code&gt; 的結果沒變。&lt;/li&gt;
&lt;li&gt;建立擴充並執行向量運算。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SQL 驗證的核心是：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;extversion&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_extension&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;extname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'vector'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="s1"&gt;'[1,0,0]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0,1,0]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;原始映像沒有可用的 &lt;code&gt;vector&lt;/code&gt; 擴充；自建映像回報版本 &lt;code&gt;0.8.6&lt;/code&gt;，兩個正交向量的 cosine distance 是 &lt;code&gt;1.000&lt;/code&gt;。原先的測試資料仍讀得到，映像的 entrypoint、啟動命令與使用者也維持一致，最終映像內沒有額外留下 gcc。&lt;/p&gt;

&lt;p&gt;這驗證的是同一個 PostgreSQL 基底加上擴充，沒有測跨大版本資料格式遷移，也沒有碰正式資料。不能把這個結果延伸成「任意 PostgreSQL 映像都能直接互換」。&lt;/p&gt;

&lt;h2&gt;
  
  
  換了之後的代價
&lt;/h2&gt;

&lt;p&gt;自建映像沒有讓舊基底恢復更新。編譯階段即使跑了 &lt;code&gt;apt-get update&lt;/code&gt;，最終階段仍從原本的 Legacy 基底開始；它增加的是擴充功能，不是替整個執行環境補齊安全更新。&lt;/p&gt;

&lt;p&gt;後續仍要維護基底與擴充版本、確認新映像符合 chart 的啟動慣例，並處理映像保存與版本固定。若要更嚴格重現建置，還要進一步固定基底 digest、原始碼 commit 與建置依賴；只固定 tag 不代表完全可重現。&lt;/p&gt;

&lt;p&gt;當時先換來源，再沿用原基底加功能，是為了減少同時變動的地方。長期要換到持續維護的映像，仍然是一項獨立工作，不能因為現在能成功啟動就當作已經完成。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bitnami/charts/issues/35164" rel="noopener noreferrer"&gt;Bitnami 映像目錄調整公告&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bitnami/charts/issues/30850" rel="noopener noreferrer"&gt;Bitnami chart 的替代映像檢查&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pgvector/pgvector#portability" rel="noopener noreferrer"&gt;pgvector 編譯與可攜性&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Docker multi-stage builds&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>helm</category>
      <category>postgres</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>把 AI CLI 包成 OpenAI 相容 API：從 stdin、JSON 輸出到逾時處理（附可跑範例）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Thu, 05 Nov 2026 06:33:29 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/ba-ai-cli-bao-cheng-openai-xiang-rong-apicong-stdin-json-shu-chu-dao-yu-shi-chu-li-fu-ke-pao-fan-li--3he9</link>
      <guid>https://dev.to/jimmy_3c703d/ba-ai-cli-bao-cheng-openai-xiang-rong-apicong-stdin-json-shu-chu-dao-yu-shi-chu-li-fu-ke-pao-fan-li--3he9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：把 CLI 接上 HTTP，核心是輸入、程序生命週期和輸出格式；路徑叫 &lt;code&gt;/v1/chat/completions&lt;/code&gt;，不代表已經支援整份協定。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;終端機裡已經有一個能回答問題的 AI CLI，但想接的程式只會送 HTTP 請求，兩邊講的是不同語言。&lt;/p&gt;

&lt;p&gt;最先想到的是收到問題後跑一次 CLI，再把答案回傳。真的開始接才發現，容易出錯的地方全在中間：長 prompt 要怎麼傳？CLI 印出的 JSON 哪一段才是答案？指令卡住時，HTTP 要等多久？&lt;/p&gt;

&lt;p&gt;這篇把實際包裝服務用到的做法縮成一個能在本機跑的範例。先用假的 CLI 驗證程序與協定，不需要模型金鑰，也不會消耗模型用量。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;有實測的是 Python 包裝流程，沒有在這次測試中呼叫真實模型。&lt;/strong&gt; 換成真實 CLI 時，登入、旗標與輸出格式仍要另外核對。&lt;/p&gt;

&lt;h2&gt;
  
  
  先決定相容到哪裡
&lt;/h2&gt;

&lt;p&gt;範例只接受一則文字 &lt;code&gt;user&lt;/code&gt; 訊息，回傳一次完整 JSON。&lt;code&gt;model&lt;/code&gt; 固定用 &lt;code&gt;cli-demo&lt;/code&gt;，只是這個服務的識別名稱。&lt;/p&gt;

&lt;p&gt;串流、工具呼叫、圖片、多輪歷史與其他參數，都明確拒絕。這樣呼叫端才不會以為參數已生效，實際卻被悄悄忽略。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create" rel="noopener noreferrer"&gt;OpenAI 的 Chat Completions 文件&lt;/a&gt; 定義了請求與回應欄位；這裡只實作其中一小部分，目的是讓需要「送一段文字、拿一段答案」的 HTTP 客戶端能接進來。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. 先準備一支假 CLI
&lt;/h3&gt;

&lt;p&gt;把下面存成 &lt;code&gt;demo_cli.py&lt;/code&gt;。它從 stdin 讀文字，回一個含 &lt;code&gt;result&lt;/code&gt; 的 JSON，讓我們能檢查收到多少字元。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;收到 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; 個字元：&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這個 &lt;code&gt;result&lt;/code&gt; 是假 CLI 的格式，跟 HTTP 回應裡的 &lt;code&gt;choices&lt;/code&gt; 是兩層不同的東西。包裝服務要負責轉換。&lt;/p&gt;

&lt;h3&gt;
  
  
  2. 加上 HTTP 包裝
&lt;/h3&gt;

&lt;p&gt;把下面存成同一個資料夾裡的 &lt;code&gt;gateway.py&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;

&lt;span class="n"&gt;COMMAND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLI_COMMAND_JSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo_cli.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])))&lt;/span&gt;
&lt;span class="n"&gt;TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLI_TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json; charset=utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrapper_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown endpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;413&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body must be between 1 byte and 1 MiB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Only model, messages and stream are supported&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cli-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use model=cli-demo and stream=false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;UnicodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected one user message with string content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;COMMAND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutExpired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLI timed out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CalledProcessError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLI failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;UnicodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLI returned invalid output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chatcmpl-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat.completion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cli-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;流程很直接：檢查請求，將文字送進 CLI 的 stdin，確認程序成功退出，再解析 &lt;code&gt;result&lt;/code&gt;，最後包進 &lt;code&gt;choices[0].message.content&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這裡刻意把幾種失敗分開：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;狀況&lt;/th&gt;
&lt;th&gt;HTTP 回應&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;請求格式錯誤或要求未支援功能&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;宣告的 request body 超過上限&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI 無法啟動、非零退出，或輸出格式錯誤&lt;/td&gt;
&lt;td&gt;502&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI 執行逾時&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;不把 CLI 的完整 stderr 直接回傳，避免把本機路徑或診斷細節送給呼叫端。實際服務可在受控的伺服器日誌記錄錯誤種類與耗時，不必把 prompt 全文一起記下來。&lt;/p&gt;

&lt;h3&gt;
  
  
  3. 啟動並送一個請求
&lt;/h3&gt;

&lt;p&gt;在放這兩個檔案的資料夾執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 gateway.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再開一個終端機：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; http://127.0.0.1:8080/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"cli-demo","messages":[{"role":"user","content":"你好"}],"stream":false}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;回傳的 ID 與時間每次不同，答案應該在這裡：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"收到 2 個字元：你好"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;上面是回應節錄。完整回應還有 &lt;code&gt;id&lt;/code&gt;、&lt;code&gt;object&lt;/code&gt;、&lt;code&gt;created&lt;/code&gt; 與 &lt;code&gt;model&lt;/code&gt;。沒有模型用量資料，就不捏造 &lt;code&gt;usage&lt;/code&gt;。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;h3&gt;
  
  
  長 prompt 不要塞在命令列參數裡
&lt;/h3&gt;

&lt;p&gt;原本的服務曾把很長的上下文塞進 argv，碰到 &lt;code&gt;Argument list too long&lt;/code&gt;。命令列有系統限制，不適合拿來搬大量文字。&lt;/p&gt;

&lt;p&gt;範例用的是：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMAND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;input&lt;/code&gt; 透過 stdin 傳入，命令本身用參數陣列，也沒有開 &lt;code&gt;shell=True&lt;/code&gt;。所以文字中的 &lt;code&gt;$()&lt;/code&gt; 或反引號會當成普通內容傳給 CLI，不會先被包裝層當 shell 指令執行。這不代表 CLI 本身不能使用工具，兩者要分開看。&lt;a href="https://docs.python.org/3/library/subprocess.html" rel="noopener noreferrer"&gt;Python subprocess 文件&lt;/a&gt; 有各參數的行為。&lt;/p&gt;

&lt;p&gt;這次以 20 萬個 ASCII 字元實測，假 CLI 回報收到完整的 200,000 個字元。它驗證的是傳輸方式，不代表真實模型的 context window 一定裝得下。&lt;/p&gt;

&lt;h3&gt;
  
  
  stdout 不一定就是答案
&lt;/h3&gt;

&lt;p&gt;有的 CLI 回一整個 JSON，有的回 JSONL，也就是一行一個事件。啟動訊息、工具結果與最後答案，不能全部原封不動塞進 &lt;code&gt;content&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;範例只接受 &lt;code&gt;{"result":"文字"}&lt;/code&gt;，缺欄位、空答案或 &lt;code&gt;is_error: true&lt;/code&gt; 都回 502。換成 JSONL 型 CLI，就要另外寫事件解析器，找出最後的 assistant 訊息，不能只拿最後一行。&lt;/p&gt;

&lt;p&gt;例如 &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code 非互動模式&lt;/a&gt; 提供 &lt;code&gt;-p&lt;/code&gt; 與結構化輸出；如果要接它，可將命令改成參數陣列 &lt;code&gt;['claude', '-p', '--output-format', 'json']&lt;/code&gt;，再依所用版本的輸出確認解析器。這是接入示意，本文沒有實測這個真實 CLI 的呼叫，也沒有處理它的登入流程。&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP 成功，不代表 CLI 成功
&lt;/h3&gt;

&lt;p&gt;CLI 非零退出時，不能照樣回 200，再把「發生錯誤」當成模型答案。就算退出碼是零，也要檢查應用層錯誤與答案格式。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check=True&lt;/code&gt; 負責非零退出，後面的 JSON 驗證負責輸出內容。兩關都過了，才回成功。&lt;/p&gt;

&lt;p&gt;逾時也是同樣的道理。模型可能慢，但請求不能無限等下去；呼叫端的等待時間也要比這裡的 CLI timeout 留出一些餘裕。若 CLI 還會產生其他子程序，則要另做整組程序的清理，不能假設直接子程序結束就全部結束。&lt;/p&gt;

&lt;h2&gt;
  
  
  實測結果與限制
&lt;/h2&gt;

&lt;p&gt;這次在 Python 3.14.4，以本機 HTTP 請求與假 CLI 跑過 17 個情境：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;中文回覆、20 萬字元 stdin，以及含 shell 語法的文字都正確傳遞。&lt;/li&gt;
&lt;li&gt;壞掉的 JSON、非物件請求、多輪訊息、工具、串流、未知模型與非文字內容都回 400。&lt;/li&gt;
&lt;li&gt;超大 &lt;code&gt;Content-Length&lt;/code&gt; 在送出 body 前就被拒絕，回 413。&lt;/li&gt;
&lt;li&gt;CLI 非零退出、無效 JSON、缺少或空白答案、應用層錯誤都回 502。&lt;/li&gt;
&lt;li&gt;假 CLI 刻意拖延，回 504。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這份範例只綁 &lt;code&gt;127.0.0.1&lt;/code&gt;，使用標準函式庫的簡單 HTTP server，依序處理請求。它沒有驗證身分、請求讀取期限、輸出大小限制或正式的並行管理，適合本機理解流程，不能直接當成公開服務。&lt;/p&gt;

&lt;p&gt;每次 HTTP 請求也都是新 CLI 呼叫，不會自動保留前一輪歷史。要支援多輪、工具或串流，必須逐項定義轉換與錯誤行為，不能只把請求欄位加進來就算完成。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;當時的需求是讓原本只認 HTTP 的程式能呼叫既有 CLI，所以先加一層薄薄的轉接，保留已經能工作的執行方式。&lt;/p&gt;

&lt;p&gt;若你需要的是模型供應商完整的 API 功能，直接使用原生 API 會少掉一層格式轉換。CLI 包裝的價值，是讓既有工具接得上；代價則是 CLI 旗標、輸出格式與程序生命週期，都變成這層服務要維護的事。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create" rel="noopener noreferrer"&gt;OpenAI Chat Completions API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3/library/subprocess.html" rel="noopener noreferrer"&gt;Python subprocess&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;Claude Code 非互動模式&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>api</category>
      <category>automation</category>
    </item>
    <item>
      <title>ingress-nginx 退役之後：在臨時叢集實測 Traefik 的 ingress-nginx 相容模式與 Gateway API</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:23:06 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/ingress-nginx-tui-yi-zhi-hou-zai-lin-shi-cong-ji-shi-ce-traefik-de-ingress-nginx-xiang-rong-mo-shi-yu-gateway-api-5f1f</link>
      <guid>https://dev.to/jimmy_3c703d/ingress-nginx-tui-yi-zhi-hou-zai-lin-shi-cong-ji-shi-ce-traefik-de-ingress-nginx-xiang-rong-mo-shi-yu-gateway-api-5f1f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：用 ingress-nginx 的小叢集，不必急著重寫所有 Ingress。實測下來，Traefik 的 ingress-nginx 相容模式可以原封不動吃下常見的 annotation；想一步到位改 Gateway API 也行，但轉址狀態碼、IP 白名單這類細節要自己補。這篇是評估，不是遷移紀錄。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;單機叢集常見的配置是：k3s 的內建 Traefik 關掉，自己用 Helm 裝 ingress-nginx，所有服務掛 &lt;code&gt;ingressClassName: nginx&lt;/code&gt;，再用幾個 &lt;code&gt;nginx.ingress.kubernetes.io/...&lt;/code&gt; annotation 處理 HTTPS 轉址、逾時、IP 白名單。&lt;/p&gt;

&lt;p&gt;這個專案已經退役了。&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;官方 repo 的說明&lt;/a&gt;寫得很直接：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2026 年 3 月之後，不再有新版本、bug 修正，也不再修補新發現的安全漏洞。&lt;/li&gt;
&lt;li&gt;已經部署的不會壞，Helm chart 和映像也還在。&lt;/li&gt;
&lt;li&gt;沒有安裝過的人，建議直接找一個 Gateway API 的實作。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;也就是說現有的叢集不會馬上出事，但每一天都在累積「沒人修」的風險。接下來有兩種做法：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;換 controller，Ingress 不動。&lt;/strong&gt; 讓別的 controller 認得 nginx 的 annotation。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;換 API。&lt;/strong&gt; 改寫成 Gateway API 的 &lt;code&gt;Gateway&lt;/code&gt; 與 &lt;code&gt;HTTPRoute&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;這篇不下結論要選哪個，而是在隔離的臨時叢集把兩條路都實際跑一遍，記下哪些順利、哪些要注意。&lt;/p&gt;

&lt;h2&gt;
  
  
  測試環境
&lt;/h2&gt;

&lt;p&gt;整個實驗用一個臨時的 k3d 單節點叢集，不佔主機的埠，測完就刪。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;項目&lt;/th&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;k3s（k3d 預設）&lt;/td&gt;
&lt;td&gt;v1.31.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik（Helm chart 42.2.0）&lt;/td&gt;
&lt;td&gt;v3.7.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway API CRD（standard）&lt;/td&gt;
&lt;td&gt;v1.7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager&lt;/td&gt;
&lt;td&gt;v1.21.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ingress2gateway&lt;/td&gt;
&lt;td&gt;v1.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;測試用的後端是 &lt;code&gt;traefik/whoami&lt;/code&gt;，它會回傳請求的標頭，還有 &lt;code&gt;?wait=25s&lt;/code&gt; 這種可以讓後端故意慢回應的參數，以及 &lt;code&gt;/echo&lt;/code&gt; 的 WebSocket 端點，很適合拿來測逾時。&lt;/p&gt;

&lt;p&gt;要先講清楚的限制：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;叢集裡用自簽憑證，沒有真的向 Let's Encrypt 申請。&lt;/li&gt;
&lt;li&gt;從叢集內的 Pod 發請求，沒有經過 NodePort 或 LoadBalancer，所以「來源 IP 會不會被改掉」沒有驗證。&lt;/li&gt;
&lt;li&gt;單節點，沒做效能比較。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  前置：k3s 內建的 Traefik 不能直接用
&lt;/h3&gt;

&lt;p&gt;先查了 k3s 自帶的 Traefik。k3d 預設的 k3s v1.31.5，內建的是 Traefik &lt;strong&gt;2.11.18&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;Traefik 的 ingress-nginx 相容 provider，&lt;a href="https://doc.traefik.io/traefik/migrate/nginx-to-traefik/" rel="noopener noreferrer"&gt;官方文件&lt;/a&gt;寫明需要 &lt;strong&gt;v3.6.2 以上&lt;/strong&gt;。所以 v1.31.5 附的版本沒有這個功能（用 &lt;code&gt;traefik --help&lt;/code&gt; 也確實只看到 &lt;code&gt;kubernetesgateway&lt;/code&gt;，而且還需要 &lt;code&gt;--experimental.kubernetesgateway&lt;/code&gt;）。&lt;/p&gt;

&lt;p&gt;比較新的 k3s 會附 Traefik 3.7.x：查 k3s v1.37.1 的內建 manifest，chart 是 41.4.0，對應 Traefik v3.7.12。但舊版 k3s 的叢集，得自己用 Helm 裝新版 Traefik。&lt;/p&gt;

&lt;p&gt;下面的實驗都是這樣做：建叢集時停用內建 Traefik，再用 Helm 裝。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d cluster create demo &lt;span class="nt"&gt;--servers&lt;/span&gt; 1 &lt;span class="nt"&gt;--agents&lt;/span&gt; 0 &lt;span class="nt"&gt;--no-lb&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--k3s-arg&lt;/span&gt; &lt;span class="s2"&gt;"--disable=traefik@server:0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  路線一：讓 Traefik 直接吃 nginx 的 Ingress
&lt;/h3&gt;

&lt;p&gt;Helm values 只要打開 provider：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesIngressNGINX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;     &lt;span class="c1"&gt;# 預設 ingressClass 是 nginx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;原本的 Ingress 完全不用改。測試用的是三種最常見的寫法，原封不動貼進去：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. HTTPS 轉址&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/force-ssl-redirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/ssl-redirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;

&lt;span class="c1"&gt;# 2. 長時間逾時&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-read-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3600"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-send-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3600"&lt;/span&gt;

&lt;span class="c1"&gt;# 3. IP 白名單&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/whitelist-source-range&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.0.2.0/24"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;實測結果：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;項目&lt;/th&gt;
&lt;th&gt;結果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP → HTTPS 轉址&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;308 Permanent Redirect&lt;/code&gt;，&lt;code&gt;Location&lt;/code&gt; 正確帶回原本的路徑，和 ingress-nginx 的預設一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;whitelist-source-range&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;名單外的來源收到 &lt;code&gt;403 Forbidden&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;proxy-read-timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;有生效。為了方便驗證，改成 5 秒、後端 25 秒才回應，結果在 5.0 秒收到 &lt;code&gt;504&lt;/code&gt;；改成 60 秒就正常通過&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backend-protocol: "HTTPS"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;後端用自簽憑證的 HTTPS，收到 &lt;code&gt;200&lt;/code&gt;；拿掉 annotation 就變成 &lt;code&gt;400&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;透過這個 Ingress 連線、閒置 30 秒後仍可收發&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;這個 provider 可以和 Gateway API 的 provider 同時開著。Traefik 官方文件沒有說明兩者能不能並存，所以實測了：同一個 Traefik 裡，Ingress 和 &lt;code&gt;HTTPRoute&lt;/code&gt; 都正常回應。&lt;/p&gt;

&lt;p&gt;有一個小地方：Ingress 的 &lt;code&gt;ADDRESS&lt;/code&gt; 欄位是空的。這個 provider 預設沒有回填 &lt;code&gt;status.loadBalancer&lt;/code&gt;，chart 有 &lt;code&gt;publishService&lt;/code&gt; 這個選項，我沒有測它有沒有用。依賴 Ingress status 的工具（例如自動建立 DNS 記錄的）要留意。&lt;/p&gt;

&lt;p&gt;另外，如果打算讓 ingress-nginx 和 Traefik 並行一段時間再切換，官方文件提醒兩個 controller 會互相覆寫 Ingress 的 status，路由不受影響，但監看 status 的工具會被干擾。這一點我沒有實測。&lt;/p&gt;

&lt;h3&gt;
  
  
  路線二：改寫成 Gateway API
&lt;/h3&gt;

&lt;p&gt;先裝 Gateway API 的標準 CRD，再裝 Traefik 並打開 Gateway provider：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.7.0/standard-install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesGateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;       &lt;span class="c1"&gt;# 自己建 Gateway&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik 的 chart 輸出訊息建議安裝 v1.5.1，我裝的是 v1.7.0，GatewayClass 仍然正常被接受。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Gateway&lt;/code&gt; 的 listener 埠必須對應 Traefik 的 entrypoint（預設是 HTTP 8000、HTTPS 8443），不是對外的 80 和 443：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;All&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8443&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
        &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;demo-tls&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;All&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  HTTP 轉 HTTPS
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;app-redirect&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;demo&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;app.example.test&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RequestRedirect&lt;/span&gt;
          &lt;span class="na"&gt;requestRedirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;https&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;statusCode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;308&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這一段有三個發現：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;不要寫 &lt;code&gt;port&lt;/code&gt;。&lt;/strong&gt; 一開始寫了 &lt;code&gt;port: 443&lt;/code&gt;，&lt;code&gt;Location&lt;/code&gt; 變成 &lt;code&gt;https://app.example.test:443/x&lt;/code&gt;。拿掉之後才是乾淨的 &lt;code&gt;https://app.example.test/x&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statusCode&lt;/code&gt; 預設是 302。&lt;/strong&gt; 沒寫的話，原本 ingress-nginx 的 308 會變成臨時轉址。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;308 可以用，但屬於 Extended。&lt;/strong&gt; CRD 的說明是「301 與 302 是 Core，其餘是 Extended」。實測 Traefik 3.7.16 接受並回傳 &lt;code&gt;308 Permanent Redirect&lt;/code&gt;。換別的 Gateway 實作就不一定支援，要看該實作的文件。&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  IP 白名單
&lt;/h4&gt;

&lt;p&gt;Gateway API 標準沒有 IP 白名單的欄位。Traefik 的做法是建一個 &lt;code&gt;Middleware&lt;/code&gt;，再用 &lt;code&gt;ExtensionRef&lt;/code&gt; 掛到 &lt;code&gt;HTTPRoute&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;only-office&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;demo&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAllowList&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sourceRange&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.0.2.0/24"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;demo&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;internal.example.test&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExtensionRef&lt;/span&gt;
          &lt;span class="na"&gt;extensionRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;traefik.io&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Middleware&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;only-office&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;whoami&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;80&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;名單外的來源收到 &lt;code&gt;403 Forbidden&lt;/code&gt;，把 &lt;code&gt;sourceRange&lt;/code&gt; 換成 Pod 網段就通過。這是 Traefik 專屬的寫法，換 Gateway 實作就得重寫。&lt;/p&gt;

&lt;h4&gt;
  
  
  cert-manager 整合
&lt;/h4&gt;

&lt;p&gt;憑證的位置從 Ingress 的 annotation，搬到 Gateway 的 listener。cert-manager 要打開 Gateway 支援：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;controller.config.cert-manager.io/v1alpha1&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ControllerConfiguration&lt;/span&gt;
  &lt;span class="na"&gt;enableGatewayAPI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gateway 加上 issuer 的 annotation，listener 指向一個「還不存在」的 Secret：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;selfsigned&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto.example.test&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8443&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
        &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;auto-tls&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;大約 12 秒後，cert-manager 自動建出 &lt;code&gt;Certificate&lt;/code&gt;（&lt;code&gt;READY=True&lt;/code&gt;）和 &lt;code&gt;auto-tls&lt;/code&gt; 這個 Secret，Traefik 開始用它提供 HTTPS，請求回 &lt;code&gt;200&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這裡用的是自簽的 issuer。真正用 Let's Encrypt 時，DNS-01 或 HTTP-01 的流程這次沒有驗證。&lt;/p&gt;

&lt;h3&gt;
  
  
  長連線與逾時
&lt;/h3&gt;

&lt;p&gt;原本 annotation 裡的 &lt;code&gt;proxy-read-timeout: 3600&lt;/code&gt;，主要是為了 WebSocket 和長時間回應。到了 Traefik，要分三種情況看。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;預設值。&lt;/strong&gt; 不調任何逾時：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebSocket 連上之後閒置 400 秒，再送一次訊息，仍然收到回應。&lt;/li&gt;
&lt;li&gt;後端 100 秒才回應的 HTTP 請求，等到 100.1 秒成功收到 &lt;code&gt;200&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;3600 秒本身沒有實際等待測試，只能說預設值下，閒置幾百秒不會被切斷。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;調得很短的 entrypoint 逾時。&lt;/strong&gt; 為了確認哪些設定會影響，把 HTTPS entrypoint 的 &lt;code&gt;readTimeout&lt;/code&gt;、&lt;code&gt;writeTimeout&lt;/code&gt;、&lt;code&gt;idleTimeout&lt;/code&gt; 都設成 10 秒：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;respondingTimeouts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;readTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
        &lt;span class="na"&gt;writeTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
        &lt;span class="na"&gt;idleTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;WebSocket 閒置 30 秒，仍然正常。升級成 WebSocket 之後，連線不受這幾個逾時影響。&lt;/li&gt;
&lt;li&gt;一般 HTTP 請求，後端 25 秒才回應，在第 10 秒被切斷（客戶端看到連線被中斷，沒有收到 HTTP 狀態）。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;也就是說，要讓「長時間才回應」的請求（例如串流輸出）不被切掉，要看的是 entrypoint 的 &lt;code&gt;writeTimeout&lt;/code&gt;。預設值下 100 秒的回應沒有被切斷（前面實測過），所以一般不用動，但一旦被調過就會是問題。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx annotation 不能蓋過 entrypoint。&lt;/strong&gt; 在 entrypoint 設了 10 秒的情況下，即使 Ingress 寫了 &lt;code&gt;proxy-read-timeout: "3600"&lt;/code&gt;，後端 25 秒的請求還是在 10 秒被切斷。annotation 控制的是後端那一段的逾時，不會改寫 entrypoint 的設定。&lt;/p&gt;

&lt;h3&gt;
  
  
  ingress2gateway 轉出來的東西
&lt;/h3&gt;

&lt;p&gt;官方的轉換工具 ingress2gateway 可以把現有的 Ingress 轉成 Gateway API。拿上面的三個 Ingress 餵給 v1.2.0：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ingress2gateway print &lt;span class="nt"&gt;--input-file&lt;/span&gt; ing.yaml &lt;span class="nt"&gt;--providers&lt;/span&gt; ingress-nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;轉換器會印出警告，值得逐條看：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;force-ssl-redirect&lt;/code&gt; 被標為 &lt;strong&gt;Unsupported annotation&lt;/strong&gt;。不過因為 Ingress 有 TLS，工具仍然自動產生了 &lt;code&gt;308&lt;/code&gt; 的 HTTP → HTTPS 轉址路由，結果是對的，警告看起來只是沒有逐個 annotation 處理。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;whitelist-source-range&lt;/code&gt; 轉換失敗：&lt;strong&gt;IP-based authorization is not supported&lt;/strong&gt;。白名單會直接掉，轉完的路由是沒有限制的。&lt;/li&gt;
&lt;li&gt;逾時 annotation 會做「best-effort」轉成 &lt;code&gt;timeouts.request&lt;/code&gt;，但&lt;strong&gt;數字不對&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最後一點值得單獨講。同樣的 annotation，轉出來的結果：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;proxy-read-timeout&lt;/code&gt;（秒）&lt;/th&gt;
&lt;th&gt;轉出的 &lt;code&gt;timeouts.request&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;10m0s&lt;/code&gt;（應該是 1 分鐘）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;16m40s&lt;/code&gt;（應該是 1 分 40 秒）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;10h0m0s&lt;/code&gt;（應該是 1 小時）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;每一筆都是預期的 10 倍。從原始碼看，解析 annotation 的那一步是正確的（秒數轉成 Duration），所以問題出在後面哪一步，這裡沒有追到，也沒找到對應的 issue。這是 v1.2.0 的觀察，之後版本可能已經修掉，&lt;strong&gt;重點是轉換結果一定要人工逐項核對，不要直接 apply&lt;/strong&gt;。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;整理一下實測中遇到的：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;k3s 內建的 Traefik 可能太舊。&lt;/strong&gt; v1.31.5 附的是 2.11.18，沒有 ingress-nginx 相容 provider，要自己用 Helm 裝 3.6.2 以上。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway 的 listener 埠是 entrypoint 的埠。&lt;/strong&gt; 寫成 80、443 會對不上。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RequestRedirect&lt;/code&gt; 不要寫 &lt;code&gt;port&lt;/code&gt;，也別忘了 &lt;code&gt;statusCode&lt;/code&gt;。&lt;/strong&gt; 前者會讓網址多出 &lt;code&gt;:443&lt;/code&gt;，後者預設是 302，不是 ingress-nginx 的 308。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP 白名單在 Gateway API 沒有標準寫法。&lt;/strong&gt; 得靠實作專屬的 Middleware，轉換工具也不會幫你處理。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;轉換工具的輸出要核對。&lt;/strong&gt; 至少逾時數字要自己重算。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  為什麼這樣評估
&lt;/h2&gt;

&lt;p&gt;如果現有叢集的 Ingress 很多，而且多半只用到轉址、逾時、白名單這幾個 annotation，路線一的成本最低：換掉 controller 就好，YAML 一行不改，風險也最小。缺點是 annotation 的相容只涵蓋「一部分」。Traefik 官方文件也寫了某些 annotation 的行為有差異，所以還是要逐項驗證。&lt;/p&gt;

&lt;p&gt;路線二是長期比較乾淨的方向，因為 Kubernetes 官方文件說 Ingress API 已經凍結，並建議改用 Gateway。但每個服務都得重寫，白名單、逾時這類能力要換成實作專屬的寫法，等於把鎖定換了一個地方。&lt;/p&gt;

&lt;p&gt;所以比較務實的順序是：先用路線一把 controller 換掉，擺脫退役的專案；再依需求，一個服務一個服務搬到 Gateway API。這篇只是把可行性驗過一遍，哪一天真的動手，仍然要在自己的叢集重新驗證，尤其是真實憑證、來源 IP 與多節點的情境。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;Ingress NGINX 退役說明&lt;/a&gt;（官方 repo）&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/services-networking/ingress/" rel="noopener noreferrer"&gt;Kubernetes：Ingress（含 API 已凍結的說明）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://doc.traefik.io/traefik/migrate/nginx-to-traefik/" rel="noopener noreferrer"&gt;Traefik：從 ingress-nginx 遷移&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx/" rel="noopener noreferrer"&gt;Traefik：Kubernetes Ingress NGINX provider&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes-sigs/ingress2gateway" rel="noopener noreferrer"&gt;ingress2gateway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cert-manager.io/docs/usage/gateway/" rel="noopener noreferrer"&gt;cert-manager：Gateway API 支援&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>traefik</category>
      <category>gatewayapi</category>
      <category>ingress</category>
    </item>
    <item>
      <title>單機 Kubernetes 的管理介面連換兩次：Rancher Portainer Headlamp，以及 sessionTTL 與 token 的坑</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:35:06 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/dan-ji-kubernetes-de-guan-li-jie-mian-lian-huan-liang-ci-rancher-portainer-headlampyi-ji-sessionttl-yu-token-de-keng-3jp8</link>
      <guid>https://dev.to/jimmy_3c703d/dan-ji-kubernetes-de-guan-li-jie-mian-lian-huan-liang-ci-rancher-portainer-headlampyi-ji-sessionttl-yu-token-de-keng-3jp8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：單機或小叢集只想要一個「看得到所有 workload」的介面，Headlamp 夠用而且很輕。Rancher 和 Portainer 都不是不好，是對這個場景太重，或剛好踩到記憶體的坑。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  當初怎麼做
&lt;/h2&gt;

&lt;p&gt;單機的 Kubernetes 叢集，總是想要一個網頁介面：不用每次都敲 &lt;code&gt;kubectl get pods -A&lt;/code&gt;，看 log、看資源用量也比較直覺。&lt;/p&gt;

&lt;p&gt;第一個選的是 Rancher。理由很單純：名氣大、功能完整，感覺「正式的叢集就該有」。&lt;/p&gt;

&lt;h2&gt;
  
  
  哪裡不行
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rancher：用不到的功能太多，壞了又很難修
&lt;/h3&gt;

&lt;p&gt;Rancher 預設會帶一整組週邊元件：多叢集管理、Fleet、Turtles、內嵌的 Cluster API controller。&lt;/p&gt;

&lt;p&gt;某一天 Rancher 就卡在 CrashLoopBackOff，連續好幾天。原因是兩套內嵌的 CAPI controller 的 CRD，&lt;code&gt;storedVersions&lt;/code&gt; 版本衝突。官方文件有記載這類問題，正式的解法是跑它自己的清理工具，而那是破壞性的。&lt;/p&gt;

&lt;p&gt;回頭看，這個判斷就很明顯：一台機器、一個叢集，從頭到尾沒用過下游叢集、Fleet、Turtles。為了一個「看叢集」的需求，裝了一整個多叢集管理平台，出問題時要追的東西比要看的東西還多。&lt;/p&gt;

&lt;p&gt;所以沒有去追這個衝突，直接整個換掉。&lt;/p&gt;

&lt;h3&gt;
  
  
  Portainer：輕量，但看 Helm 頁面會吃光記憶體
&lt;/h3&gt;

&lt;p&gt;換成 Portainer CE，用 Helm 安裝，單機叢集夠用。&lt;/p&gt;

&lt;p&gt;直到點進 Kubernetes 的 Helm 頁面。這個頁面會讀 Helm repository 的 index，記憶體瞬間飆高。&lt;/p&gt;

&lt;p&gt;原本 limit 是 512Mi，pod 連續被 OOMKilled 三次，前端出現「Unable to retrieve helm repositories」和一堆連線被重置的錯誤。&lt;/p&gt;

&lt;p&gt;調到 1Gi 之後還是撐不住，反覆 OOM。與其繼續加記憶體，不如承認它的行為不適合這台機器。&lt;/p&gt;

&lt;h2&gt;
  
  
  換成什麼
&lt;/h2&gt;

&lt;p&gt;換成 &lt;a href="https://github.com/kubernetes-sigs/headlamp" rel="noopener noreferrer"&gt;Headlamp&lt;/a&gt;。它是 Kubernetes SIG 的專案，只做 Kubernetes，介面簡單，資源限制設 300m CPU、256Mi 記憶體就夠。&lt;/p&gt;

&lt;p&gt;用 Helm 安裝，重點設定是 ingress 和一個 &lt;code&gt;clusterRoleBinding&lt;/code&gt; 綁 &lt;code&gt;cluster-admin&lt;/code&gt;，這樣可視範圍就是整個叢集。&lt;/p&gt;

&lt;h2&gt;
  
  
  遷移怎麼進行
&lt;/h2&gt;

&lt;p&gt;遷移本身很單純，因為這類管理介面幾乎沒有自己的狀態要搬：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;新增 Headlamp 的部署設定，移除 Portainer 的。&lt;/li&gt;
&lt;li&gt;確認舊的 namespace 和 PVC 真的被清掉，沒清就手動刪。&lt;/li&gt;
&lt;li&gt;把文件、儀表板裡寫死的舊 namespace 名稱全部換掉。&lt;/li&gt;
&lt;li&gt;產生登入 token。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;接下來才是真正花時間的部分，登入。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;Headlamp 的登入頁需要貼上一個 ServiceAccount 的 bearer token。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑一：token 的有效期限
&lt;/h3&gt;

&lt;p&gt;最先想到的是用 &lt;code&gt;kubectl create token&lt;/code&gt; 產生 token。預設的有效期限只有 1 小時，所以文件上寫的是加 &lt;code&gt;--duration=8760h&lt;/code&gt;（一年）。&lt;/p&gt;

&lt;p&gt;後來發現隔天還是要重新登入，就修改文件，說「API Server 通常會限制手動產生 token 的存活時間，&lt;code&gt;--duration&lt;/code&gt; 無法突破上限」，改用一個永久的 Secret：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp-permanent-token&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portal&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes.io/service-account.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headlamp&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/service-account-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;建立之後，Kubernetes 的 controller 會自動把 token 填進這個 Secret，而且不會過期。取出來用：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; portal get secret headlamp-permanent-token &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.token}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secret 本身不含明文憑證，可以放進 Git。&lt;/p&gt;

&lt;p&gt;這個做法是對的，但&lt;strong&gt;當時的診斷是錯的&lt;/strong&gt;。為了驗證，這次在一個全新的 k3s（v1.31.5）叢集重做一遍：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方式&lt;/th&gt;
&lt;th&gt;token 的有效期限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kubectl create token&lt;/code&gt;（預設）&lt;/td&gt;
&lt;td&gt;1 小時&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl create token --duration=8760h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8760 小時（一年），沒被截短&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kubernetes.io/service-account-token&lt;/code&gt; 的 Secret&lt;/td&gt;
&lt;td&gt;沒有 &lt;code&gt;exp&lt;/code&gt;，不會過期&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;也就是說，在預設的 k3s 上，&lt;code&gt;--duration=8760h&lt;/code&gt; 其實會生效。隔天又被要求重新登入，真正的原因是下一個坑。當時那個叢集有沒有額外的 token 期限上限，已經無法確認，所以不能說前面的說法一定是錯的，但至少「&lt;code&gt;--duration&lt;/code&gt; 無法突破上限」不是普遍成立的結論。&lt;/p&gt;

&lt;p&gt;永久 Secret 仍然比較省事：不用記得重新產生，也能放進 Git 管理。要記得這個 token 綁的是 &lt;code&gt;cluster-admin&lt;/code&gt;，等於整個叢集的鑰匙，不要貼在聊天或截圖裡。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑二：token 沒過期，卻還是要重新登入
&lt;/h3&gt;

&lt;p&gt;Headlamp 前端有自己的 session TTL，預設只有 24 小時，跟 token 有效期限無關。到期之後被踢回登入頁，要重貼同一組還有效的 token。預設值 24 小時是照上游文件與 &lt;a href="https://github.com/kubernetes-sigs/headlamp/issues/4538" rel="noopener noreferrer"&gt;相關 issue&lt;/a&gt;（新增 session TTL 設定）的說法，這次沒有另外量測。&lt;/p&gt;

&lt;p&gt;chart 有 &lt;code&gt;config.sessionTTL&lt;/code&gt; 可以調，文件說上限是一年，於是設成 &lt;code&gt;31536000&lt;/code&gt; 秒。&lt;/p&gt;

&lt;p&gt;結果 Headlamp 直接 CrashLoopBackOff：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid value "3.1536e+07" for flag -session-ttl: parse error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;原因出在 chart 的 template：它把 &lt;code&gt;sessionTTL&lt;/code&gt; 直接插進 args，沒有轉成整數。Helm 讀 values 檔裡的數字時是 float64，Go 預設的浮點數輸出格式，數值一到 7 位數就改用科學記號。&lt;/p&gt;

&lt;p&gt;不用部署，本機 &lt;code&gt;helm template&lt;/code&gt; 就能重現。測 chart 0.43.0，傳進去的值和渲染結果如下：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;config.sessionTTL&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;渲染出的參數&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;999999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-session-ttl=999999&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-session-ttl=1e+06&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;31536000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-session-ttl=3.1536e+07&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;分界就在 7 位數。再用寫這篇時最新的 0.46.0 測一次，&lt;code&gt;31536000&lt;/code&gt; 仍然渲染成 &lt;code&gt;3.1536e+07&lt;/code&gt;，問題還在。&lt;/p&gt;

&lt;p&gt;所以只能取 6 位數以內的值。設成 &lt;code&gt;999999&lt;/code&gt;，約 11.5 天，是不踩到科學記號的最長值。&lt;/p&gt;

&lt;p&gt;這類「數字在 YAML 裡是整數，進了 template 變成浮點數」的坑，不只在 Headlamp，任何 chart 把數字裸插進 command line 都可能遇到。覺得數字很大的時候，先 &lt;code&gt;helm template&lt;/code&gt; 看一眼渲染結果再部署。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑三：Application 永遠 OutOfSync
&lt;/h3&gt;

&lt;p&gt;如果用 GitOps 工具管理，Headlamp 部署後可能一直顯示 &lt;code&gt;OutOfSync&lt;/code&gt;，雖然服務完全正常。&lt;/p&gt;

&lt;p&gt;chart 固定在 Deployment 的 pod spec 帶入 &lt;code&gt;hostUsers: true&lt;/code&gt;。這個欄位依賴 user namespace 的 feature gate，k3s v1.31.5 預設沒開，API server 收到會直接丟掉這個欄位，結果 live 的物件永遠沒有它，對不上期望的內容。&lt;/p&gt;

&lt;p&gt;在乾淨的 k3s v1.31.5 上實測：chart 渲染出 &lt;code&gt;hostUsers: true&lt;/code&gt;，套用之後讀回 Deployment，這個欄位是空的。&lt;/p&gt;

&lt;p&gt;解法是在 Application 加 &lt;code&gt;ignoreDifferences&lt;/code&gt; 忽略這個欄位。&lt;/p&gt;

&lt;h2&gt;
  
  
  換了之後的代價
&lt;/h2&gt;

&lt;p&gt;Portainer 還能順便管主機上的 Docker，Headlamp 只管 Kubernetes。這個功能隨著替換一起捨棄，需要看 Docker 時回到命令列。&lt;/p&gt;

&lt;p&gt;另外，Headlamp 比較陽春。沒有 Rancher 的多叢集、也沒有 Portainer 那種一站式的應用範本。對單機叢集來說，這些剛好都用不到。&lt;/p&gt;

&lt;p&gt;如果日後真的要管多個叢集，或需要比較完整的權限管理，Rancher 這類工具還是會回來，只是這一次不會為了一個「看 pod」的需求先裝它。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/kubernetes-sigs/headlamp" rel="noopener noreferrer"&gt;Headlamp&lt;/a&gt; 專案&lt;/li&gt;
&lt;li&gt;Kubernetes 文件：&lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#manual-secret-management-for-serviceaccounts" rel="noopener noreferrer"&gt;ServiceAccount 的長期 API token&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>helm</category>
      <category>headlamp</category>
      <category>devops</category>
    </item>
    <item>
      <title>Helm umbrella chart 多加一個上游 chart，結果整批 Application 都渲染失敗：改用本地 wrapper chart（實測）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:31:14 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/helm-umbrella-chart-duo-jia-ge-shang-you-chartjie-guo-zheng-pi-application-du-xuan-ran-shi-bai-gai-yong-ben-di-wrapper-chartshi-ce--753</link>
      <guid>https://dev.to/jimmy_3c703d/helm-umbrella-chart-duo-jia-ge-shang-you-chartjie-guo-zheng-pi-application-du-xuan-ran-shi-bai-gai-yong-ben-di-wrapper-chartshi-ce--753</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：umbrella chart 要多裝一個新上游 chart 時，別把新的 repo 網址直接加進 umbrella 的 dependencies。把 tgz 放進一個本地 wrapper chart 的 &lt;code&gt;charts/&lt;/code&gt;，umbrella 只依賴 &lt;code&gt;file://&lt;/code&gt; 的 wrapper，渲染環境就不需要認識那個新 repo。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;這個做法是用一次翻車學來的。&lt;/p&gt;

&lt;p&gt;前提是這樣的：一個 umbrella chart 把所有元件包在一起，上游的 chart 一律「vendor」進 repo，也就是 &lt;code&gt;helm dependency update&lt;/code&gt; 之後，把 &lt;code&gt;charts/*.tgz&lt;/code&gt; 和 &lt;code&gt;Chart.lock&lt;/code&gt; 一起 commit。這樣 GitOps 的渲染環境不用每次都上網抓東西。&lt;/p&gt;

&lt;p&gt;接著有人要加一個新的上游 chart。照習慣做：在 umbrella 的 &lt;code&gt;Chart.yaml&lt;/code&gt; 加一筆 dependency，repo 填新的網址，本機 &lt;code&gt;helm dependency update&lt;/code&gt;，tgz 和 lock 一起 commit，推上去。&lt;/p&gt;

&lt;p&gt;結果是所有用這個 umbrella 渲染的 Application 同時壞掉，不只新加的那個元件。&lt;/p&gt;

&lt;p&gt;原因是渲染環境的設定。這類環境（例如 ArgoCD 的 config management plugin）通常會在 &lt;code&gt;helm dependency build&lt;/code&gt; 之前，先替「已知的幾個 repo」跑 &lt;code&gt;helm repo add&lt;/code&gt;。新的 repo 不在清單裡，&lt;code&gt;dependency build&lt;/code&gt; 就停在這一步，連已經 vendor 好的 tgz 都沒機會用上。&lt;/p&gt;

&lt;p&gt;最後只好 revert，再換一個做法重來。&lt;/p&gt;

&lt;h2&gt;
  
  
  先重現一次錯誤
&lt;/h2&gt;

&lt;p&gt;不用整套叢集，一個本機的 chart repo 就能重現。&lt;/p&gt;

&lt;p&gt;先做一個很小的上游 chart &lt;code&gt;operator&lt;/code&gt;，打包後用 &lt;code&gt;python3 -m http.server&lt;/code&gt; 當成 repo 來源，再建一個 umbrella 去依賴它：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# umbrella/Chart.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umb&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:18765&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;在有做過 &lt;code&gt;helm repo add&lt;/code&gt; 的環境跑 &lt;code&gt;helm dependency update&lt;/code&gt;，會產生 &lt;code&gt;Chart.lock&lt;/code&gt; 和 &lt;code&gt;charts/operator-0.1.0.tgz&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;接著把環境換成全新的（獨立的 &lt;code&gt;HELM_CACHE_HOME&lt;/code&gt;、&lt;code&gt;HELM_CONFIG_HOME&lt;/code&gt;、&lt;code&gt;HELM_DATA_HOME&lt;/code&gt;，等於沒有加過任何 repo），&lt;code&gt;charts/&lt;/code&gt; 裡的 tgz 留著不動，直接 build：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm dependency build umbrella
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: no repository definition for http://127.0.0.1:18765. Please add the missing repos via 'helm repo add'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;就是這個錯誤。&lt;code&gt;Chart.lock&lt;/code&gt; 在、tgz 也在，但 &lt;code&gt;helm dependency build&lt;/code&gt; 還是要求 repo 必須先被加進本機設定。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;charts/&lt;/code&gt; 裡有沒有 tgz，結果都一樣（兩種情況都實測過）。&lt;code&gt;build&lt;/code&gt; 會先照 lock 檢查 repo，不會直接拿現成的 tgz 來用。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;p&gt;解法是多包一層。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟一：建一個 wrapper chart
&lt;/h3&gt;

&lt;p&gt;wrapper 是一個很薄的 chart，只負責把上游 chart 的 tgz 帶進來。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;components/operator-wrapper/
├── Chart.yaml
├── values.yaml
├── charts/
│   └── operator-0.1.0.tgz   # helm pull 下來的上游 tgz
└── templates/               # 需要的話放自己的資源
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tgz 用 &lt;code&gt;helm pull&lt;/code&gt; 取得，直接放進 wrapper 的 &lt;code&gt;charts/&lt;/code&gt;，這個目錄要 commit：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm pull operator &lt;span class="nt"&gt;--repo&lt;/span&gt; https://charts.example.org/ &lt;span class="nt"&gt;--version&lt;/span&gt; 0.1.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destination&lt;/span&gt; components/operator-wrapper/charts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟二：wrapper 的 Chart.yaml 要宣告 dependencies
&lt;/h3&gt;

&lt;p&gt;這一步最容易漏，下一節會用實測說明為什麼不能省。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator-wrapper&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://charts.example.org/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這裡的 repo 只是記錄用途，wrapper 自己不會被 &lt;code&gt;dependency build&lt;/code&gt;。Helm 展開 wrapper 時，直接讀 &lt;code&gt;charts/&lt;/code&gt; 裡的 tgz。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟三：umbrella 只依賴 wrapper
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# umbrella/Chart.yaml&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator-wrapper&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://../components/operator-wrapper&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator-wrapper.enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;file://&lt;/code&gt; 的 repo 不需要 &lt;code&gt;helm repo add&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;在乾淨環境跑 &lt;code&gt;helm dependency build umbrella&lt;/code&gt;，會直接把 wrapper 打包進 umbrella，沒有任何 repo 檢查。umbrella 的依賴清單裡完全沒有新的上游 repo，渲染環境自然不用認識它。&lt;/p&gt;

&lt;p&gt;升級上游版本時，重新 &lt;code&gt;helm pull&lt;/code&gt; 蓋掉 wrapper 的 tgz，同步改 wrapper 的 &lt;code&gt;Chart.yaml&lt;/code&gt; 版本即可。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑：巢狀子 chart 的 condition 沒生效
&lt;/h2&gt;

&lt;p&gt;很多上游 chart 自己還帶子 chart，並用 &lt;code&gt;condition&lt;/code&gt; 開關，例如：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# operator 自己的 Chart.yaml&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidecar&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
    &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://../sidecar&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidecar.enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;預設 &lt;code&gt;sidecar.enabled: true&lt;/code&gt;。我們想關掉它，就在 wrapper 的 values 寫：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sidecar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;實測發現一個差異。同一個 tgz、同一份 values，只差 wrapper 的 &lt;code&gt;Chart.yaml&lt;/code&gt; 有沒有宣告 &lt;code&gt;operator&lt;/code&gt; 這個 dependency：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;wrapper 的 Chart.yaml&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;helm template&lt;/code&gt; 渲染出的資源&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;沒有宣告 dependencies，只是把 tgz 丟進 &lt;code&gt;charts/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;operator-main&lt;/code&gt;、&lt;code&gt;sidecar-extra&lt;/code&gt;（sidecar 還是被裝了）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;有宣告 &lt;code&gt;operator&lt;/code&gt; 這個 dependency&lt;/td&gt;
&lt;td&gt;只有 &lt;code&gt;operator-main&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;沒宣告時，Helm 把 &lt;code&gt;charts/&lt;/code&gt; 裡的 tgz 當成「手動放進來的子 chart」，不會處理它內部的 &lt;code&gt;condition&lt;/code&gt;，&lt;code&gt;sidecar.enabled: false&lt;/code&gt; 被默默忽略。&lt;/p&gt;

&lt;p&gt;這個差異很難察覺，因為不會報錯，只是多了一個不想要的資源。最初就是這樣多裝了一個不需要的元件，才發現要把 tgz 同時列在 dependencies 裡。&lt;/p&gt;

&lt;p&gt;上面的結果是用 Helm v3.21.4 實測的。其他版本沒有逐一確認，升級 Helm 之後建議用同樣方式對一遍。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;先講代價。wrapper 多了一層，values 的路徑也多一層：想改上游的設定，得寫在 &lt;code&gt;operator-wrapper.operator.…&lt;/code&gt; 底下，而不是直接寫 &lt;code&gt;operator.…&lt;/code&gt;。每升級一次上游，都要手動 &lt;code&gt;helm pull&lt;/code&gt; 並 commit 新的 tgz。&lt;/p&gt;

&lt;p&gt;換來的好處有兩個：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;渲染環境的 repo 清單不需要再改。只要把環境設定放在叢集外的地方（例如 ArgoCD 的 plugin 設定），每多一個上游 repo 就得動一次，而且動了之後還得重新部署那個設定。&lt;/li&gt;
&lt;li&gt;新增上游 chart 的失敗範圍縮小。加進 wrapper 的 chart 有問題，頂多是 wrapper 自己渲染失敗，不會讓 umbrella 被牽連。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;另一個做法是直接去改渲染環境，多加一筆 &lt;code&gt;helm repo add&lt;/code&gt;。這能行，缺點是環境設定和 chart 內容分兩處維護，漏了其中一邊，就會再來一次整批壞掉。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Helm 文件：&lt;a href="https://helm.sh/docs/helm/helm_dependency/" rel="noopener noreferrer"&gt;Chart Dependencies&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Helm 文件：&lt;a href="https://helm.sh/docs/topics/charts/#tags-and-condition-fields-in-dependencies" rel="noopener noreferrer"&gt;dependencies 的 condition 與 tags 欄位&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>helm</category>
      <category>kubernetes</category>
      <category>argocd</category>
      <category>gitops</category>
    </item>
    <item>
      <title>用 Drain3 把 log 變成「新樣板才通知」：不用寫一堆關鍵字規則（附可跑的範例）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 16:14:56 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/yong-drain3-ba-log-bian-cheng-xin-yang-ban-cai-tong-zhi-bu-yong-xie-dui-guan-jian-zi-gui-ze-fu-ke-pao-de-fan-li--4la4</link>
      <guid>https://dev.to/jimmy_3c703d/yong-drain3-ba-log-bian-cheng-xin-yang-ban-cai-tong-zhi-bu-yong-xie-dui-guan-jian-zi-gui-ze-fu-ke-pao-de-fan-li--4la4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：與其替每種錯誤寫關鍵字規則，不如讓 Drain3 把 log 歸納成「樣板」，只在出現沒看過的樣板時通知一次。已知的問題不吵你，新的問題才會冒出來。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;最常見的 log 告警做法是：log 裡出現 &lt;code&gt;error&lt;/code&gt;，就通知。&lt;/p&gt;

&lt;p&gt;這種規則在第一週還算好用，很快就會變成噪音。一個依賴服務重啟，幾百行 &lt;code&gt;connection refused&lt;/code&gt; 一起噴出來，通知響不停。看完才發現是已知問題，下次又響。&lt;/p&gt;

&lt;p&gt;接著開始加例外：這種錯誤不要報，那種超過幾次才報。規則越寫越多，卻還是會漏掉真正重要的事，也就是&lt;strong&gt;第一次出現的新錯誤&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;其實真正想問的是：「這行 log 跟我以前看過的，是不是同一種？」&lt;/p&gt;

&lt;p&gt;這就是 log 樣板（template）要解決的事。&lt;code&gt;connection refused addr=10.0.0.8:5432 attempt=2&lt;/code&gt; 和 &lt;code&gt;connection refused addr=10.0.0.41:5432 attempt=4&lt;/code&gt; 其實是同一種，只是變數不同。把變數抽掉，剩下的骨架就是樣板。每個樣板記下來，之後只要出現沒見過的，才值得通知。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/logpai/Drain3" rel="noopener noreferrer"&gt;Drain3&lt;/a&gt; 是一個做這件事的 Python 套件，演算法是 Drain，本身不用訓練資料，一行一行餵進去就會即時歸納。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  準備環境
&lt;/h3&gt;

&lt;p&gt;只需要裝一個套件：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;&lt;span class="nv"&gt;drain3&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;0.9.11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;下面的結果都是在 &lt;code&gt;drain3==0.9.11&lt;/code&gt;、Python 3.12 實測的。&lt;/p&gt;

&lt;h3&gt;
  
  
  先準備一些假的 log
&lt;/h3&gt;

&lt;p&gt;為了讓結果可以重現，先用一支小腳本產生假 log。第一批有三種問題，第二批是同樣的三種，外加一種全新的 &lt;code&gt;disk quota exceeded&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# gen.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-0&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;T0&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; level=error msg=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; addr=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:5432 attempt=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; level=error msg=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; path=/api/items/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; status=500 took=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; level=warn msg=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; job=sync-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=timeout after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; level=error msg=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disk quota exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; volume=pvc-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; used=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 gen.py 1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; batch1.log   &lt;span class="c"&gt;# 760 行&lt;/span&gt;
python3 gen.py 2 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; batch2.log   &lt;span class="c"&gt;# 785 行&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  核心腳本：只印出新樣板
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# novelty.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;drain3&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TemplateMiner&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;drain3.file_persistence&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FilePersistence&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;drain3.masking&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MaskingInstruction&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;drain3.template_miner_config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TemplateMinerConfig&lt;/span&gt;

&lt;span class="n"&gt;STATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TemplateMinerConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;masking_instructions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;MaskingInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;MaskingInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;MaskingInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b0x[0-9a-fA-F]+\b|\b[0-9a-f]{12,}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HEX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;MaskingInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;![\w.])[-+]?\d+(?:\.\d+)?(?:ms|s|m|%)?(?![\w.])&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drain_sim_th&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drain_depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_interval_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;   &lt;span class="c1"&gt;# 不要中途存檔，結尾才存
&lt;/span&gt;&lt;span class="n"&gt;miner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TemplateMiner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FilePersistence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;miner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;

&lt;span class="n"&gt;miner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run-end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;templates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cluster_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;miner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lines=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; total_templates=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; new_templates=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  NEW #&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;重點只有三個。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;add_log_message&lt;/code&gt; 的回傳值。&lt;/strong&gt; 它會告訴你這行 log 是 &lt;code&gt;cluster_created&lt;/code&gt;（建了新樣板）、&lt;code&gt;cluster_template_changed&lt;/code&gt;（既有樣板被泛化）還是 &lt;code&gt;none&lt;/code&gt;。只看 &lt;code&gt;cluster_created&lt;/code&gt;，就是「新樣板」。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FilePersistence&lt;/code&gt;。&lt;/strong&gt; 樣板庫存在檔案裡，下一次執行會接著用。這是「以前看過」的記憶來源，沒有它每次執行都會把所有樣板當成新的。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;masking（遮蔽）。&lt;/strong&gt; 先用正規表示式把時間戳、IP、數字換成 &lt;code&gt;&amp;lt;TS&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;IP&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;NUM&amp;gt;&lt;/code&gt;，再交給 Drain。後面的坑會看到它為什麼重要。&lt;/p&gt;

&lt;h3&gt;
  
  
  跑起來看結果
&lt;/h3&gt;

&lt;p&gt;先餵第一批，建立基準：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 novelty.py state.bin &amp;lt; batch1.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lines=760 total_templates=3 new_templates=3
  NEW #1: &amp;lt;TS&amp;gt; level=error msg="connection refused" addr=&amp;lt;IP&amp;gt; attempt=&amp;lt;NUM&amp;gt;
  NEW #2: &amp;lt;TS&amp;gt; level=error msg="request failed" path=/api/items/&amp;lt;NUM&amp;gt; status=&amp;lt;NUM&amp;gt; took=&amp;lt;NUM&amp;gt;
  NEW #3: &amp;lt;TS&amp;gt; level=warn msg="retry failed" job=sync-&amp;lt;NUM&amp;gt; reason=timeout after &amp;lt;NUM&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;760 行 log 被歸納成 3 個樣板，變數都被抽掉了。&lt;/p&gt;

&lt;p&gt;同一批再跑一次：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lines=760 total_templates=3 new_templates=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;沒有任何新樣板，所以不通知。&lt;/p&gt;

&lt;p&gt;換第二批，裡面多了一種新錯誤：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 novelty.py state.bin &amp;lt; batch2.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lines=785 total_templates=4 new_templates=1
  NEW #4: &amp;lt;TS&amp;gt; level=error msg="disk quota exceeded" volume=pvc-&amp;lt;NUM&amp;gt; used=&amp;lt;NUM&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;785 行裡，前面三種問題的幾百行完全沒有動靜，只有 25 行的 &lt;code&gt;disk quota exceeded&lt;/code&gt; 被抓出來。實際要接通知，就是把這幾行輸出送去聊天工具或 webhook 即可。&lt;/p&gt;

&lt;h3&gt;
  
  
  把 Loki 接進來
&lt;/h3&gt;

&lt;p&gt;實際使用時，log 不是檔案，而是從 Loki 撈。排程（例如 Kubernetes CronJob）每隔幾小時跑一次，用 Loki 的 &lt;code&gt;query_range&lt;/code&gt; API 撈上一輪到現在的錯誤類 log（LogQL 先用 &lt;code&gt;|~ "(?i)(error|exception|fail|fatal|panic)"&lt;/code&gt; 篩一次），再餵進上面的流程。&lt;/p&gt;

&lt;p&gt;這一段沒有包含在這篇的範例裡。樣板庫的檔案放在 PVC 上，Pod 重啟也不會遺失。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;h3&gt;
  
  
  坑一：變數沒遮蔽，樣板數量爆炸
&lt;/h3&gt;

&lt;p&gt;這是最容易中的坑。Drain 預設會把「含數字的 token」當成變數，所以前面的假 log 就算不寫 masking，也只會得到 3 個樣板（實測結果也一樣）。&lt;/p&gt;

&lt;p&gt;但如果變數是&lt;strong&gt;純字母&lt;/strong&gt;，又剛好出現在行首附近，情況就不同。例如行首是 8 個隨機小寫字母的主機名：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hsreltpu worker sctapi failed to fetch rhgwp from upstream
rrpmuehu worker eqmxav failed to fetch ycfys from upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這其實是同一種錯誤，但餵 500 行進去，不遮蔽的話會得到：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lines=500 total_templates=100 new_templates=100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 個樣板，每一個都被當成「新樣板」，通知會被洗版。數字剛好是 Drain 預設單一節點最多 100 個分支，超過後才開始合併。&lt;/p&gt;

&lt;p&gt;加一條遮蔽規則把行首那段換成 &lt;code&gt;&amp;lt;ID&amp;gt;&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;MaskingInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[a-z]{8}(?= worker)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;同樣 500 行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lines=500 total_templates=1 new_templates=1
  NEW #1: &amp;lt;ID&amp;gt; worker &amp;lt;*&amp;gt; failed to fetch &amp;lt;*&amp;gt; from upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 個變成 1 個。要訣是：&lt;strong&gt;觀察輸出的樣板數，如果遠比預期的問題種類多，八成是有一種變數沒被遮蔽&lt;/strong&gt;。檢查幾個看起來很像、只差一個詞的樣板，就能知道要補什麼規則。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑二：第一次執行，所有樣板都是「新」的
&lt;/h3&gt;

&lt;p&gt;樣板庫是空的，第一輪跑完，每一個樣板都是新樣板，你會一次收到幾十則通知。&lt;/p&gt;

&lt;p&gt;做法是：第一輪只學不通知。可以在狀態裡記一個 &lt;code&gt;bootstrapped&lt;/code&gt; 旗標，第一輪結束才設成 true，之後才開始通知。&lt;/p&gt;

&lt;h3&gt;
  
  
  坑三：偵測器自己的輸出也被當成 log
&lt;/h3&gt;

&lt;p&gt;如果偵測器把通知內容印到 stdout，而 Loki 又收集了偵測器所在的 namespace，下一輪就會撈到它自己上一輪的輸出，把通知文字學成新樣板，然後又通知一次，無限循環。&lt;/p&gt;

&lt;p&gt;查詢時要把偵測器自己的 container 排除（&lt;code&gt;container!="detector"&lt;/code&gt;）。Loki 自己也會把查詢語句寫進它的 log，裡面剛好包含 &lt;code&gt;error&lt;/code&gt; 這類關鍵字，同樣要排除。&lt;/p&gt;

&lt;p&gt;這一點沒有在範例裡重現，是實際跑偵測器時遇到的。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;這個做法的限制要先講清楚。&lt;/p&gt;

&lt;p&gt;「新樣板」只代表「以前沒出現過」，不代表「很嚴重」。一個無害的警告第一次出現，一樣會通知。它回答的是「有沒有新的事發生」，不回答「該不該緊張」。&lt;/p&gt;

&lt;p&gt;另外，如果某個問題已經持續很久，因為早就學過它的樣板，之後不會再通知你。要補這個缺口，可以另外加一條「同一個樣板的出現次數突然暴增」的判斷，但那是另一個主題，這篇不展開。&lt;/p&gt;

&lt;p&gt;換來的好處是維護成本很低：沒有關鍵字清單要更新，新服務上線不用先寫規則，而且通知量跟「新問題的種類數」成正比，而不是跟 log 量成正比。&lt;/p&gt;

&lt;p&gt;如果 log 格式很固定、種類也有限，直接寫關鍵字規則可能更簡單。Drain3 比較適合服務很多、格式各異、不想一個個盯的情況。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Drain3 專案：&lt;a href="https://github.com/logpai/Drain3" rel="noopener noreferrer"&gt;logpai/Drain3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drain 論文：&lt;a href="https://jiemingzhu.github.io/pub/pjhe_icws2017.pdf" rel="noopener noreferrer"&gt;Drain: An Online Log Parsing Approach with Fixed Depth Tree&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>logging</category>
      <category>python</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>告警來了，讓 LLM 先做第一輪診斷：Grafana webhook 加一個小接收端就能做到（附可跑的範例）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:05:11 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/gao-jing-lai-liao-rang-llm-xian-zuo-di-lun-zhen-duan-grafana-webhook-jia-ge-xiao-jie-shou-duan-jiu-neng-zuo-dao-fu-ke-pao-de-fan-li--3ilj</link>
      <guid>https://dev.to/jimmy_3c703d/gao-jing-lai-liao-rang-llm-xian-zuo-di-lun-zhen-duan-grafana-webhook-jia-ge-xiao-jie-shou-duan-jiu-neng-zuo-dao-fu-ke-pao-de-fan-li--3ilj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：告警通知別等 LLM。原始告警先照常送出，再另外補一則「診斷補充」，LLM 逾時或壞掉，頂多少一則補充，不會漏掉告警。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;告警通知通常只有一行：&lt;code&gt;PostgreSQL down&lt;/code&gt;、&lt;code&gt;PodNotReady&lt;/code&gt;。收到之後，下一步幾乎都一樣：打開 Grafana 或 Loki，撈那個服務最近的錯誤 log，看有沒有線索。&lt;/p&gt;

&lt;p&gt;這個「撈 log、看一遍、猜原因」的動作很機械，剛好是 LLM 擅長的事。如果通知送到手機時，底下已經附上「最近 15 分鐘出現大量 connection refused，可能是資料庫掛了，先看資料庫 Pod」，至少可以決定要不要現在就爬起來處理。&lt;/p&gt;

&lt;p&gt;這篇做一個最小版本：Grafana 的 webhook 打到一個小接收端，接收端撈 Loki、問 LLM、把診斷當作第二則訊息送出去。&lt;/p&gt;

&lt;p&gt;先說清楚這篇驗證了什麼。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;有實測&lt;/strong&gt;：接收端的流程。用假的通知出口、假的 Loki、假的 OpenAI 相容 LLM 服務跑過，驗證了送出順序、去重、數量上限、逾時處理。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;沒有評估&lt;/strong&gt;：LLM 診斷的品質。假的 LLM 只會回固定的三句話，診斷準不準，取決於你用的模型和 log 的內容，這篇不做保證。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;沒有實測&lt;/strong&gt;：接上真正的 Grafana 與 Loki。Grafana webhook 的 payload 格式是照&lt;a href="https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/" rel="noopener noreferrer"&gt;官方文件&lt;/a&gt;的欄位寫的（&lt;code&gt;status&lt;/code&gt;、&lt;code&gt;alerts[].labels&lt;/code&gt;、&lt;code&gt;annotations&lt;/code&gt;、&lt;code&gt;fingerprint&lt;/code&gt;），Loki 用的是標準的 &lt;code&gt;query_range&lt;/code&gt; API。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  設計原則：四個決定
&lt;/h3&gt;

&lt;p&gt;先講四個決定，後面的程式碼就是照這個寫的。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;原始告警永遠先送。&lt;/strong&gt; 不要讓 LLM 擋在告警前面。LLM 慢、逾時、API 額度用完都是常態，這時候不能漏告警。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;診斷是另外一則訊息。&lt;/strong&gt; 不是改寫原本的通知，而是補在後面。診斷失敗，就補一則「診斷失敗」。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;只做 firing，而且同一個告警只診斷一次。&lt;/strong&gt; Grafana 會依 &lt;code&gt;repeat_interval&lt;/code&gt; 重複送同一個告警，每次都問 LLM 又花錢又洗版。用 &lt;code&gt;fingerprint&lt;/code&gt; 去重。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM 只讀不動。&lt;/strong&gt; prompt 明確寫「不要建議會改動系統的指令」，接收端也沒有給它任何執行能力。它只負責回答「可能是什麼」。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  接收端程式
&lt;/h3&gt;

&lt;p&gt;下面是完整的程式，只用標準函式庫：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Grafana webhook 接收端：先轉發原始告警，再補一則 LLM 做的第一輪診斷。&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ThreadingHTTPServer&lt;/span&gt;

&lt;span class="n"&gt;NOTIFY_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NOTIFY_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 通知出口，收 {"text": "..."}
&lt;/span&gt;&lt;span class="n"&gt;LOKI_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOKI_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;LLM_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# OpenAI 相容的 /v1/chat/completions
&lt;/span&gt;&lt;span class="n"&gt;LLM_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;LLM_TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;MAX_RCA_PER_REQUEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="n"&gt;SEEN_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;

&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# fingerprint -&amp;gt; 上次做 RCA 的時間
&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;post_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOTIFY_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recent_errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time_ns&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{namespace=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}} |~ &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?i)(error|exception|fail|fatal|panic)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;direction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backward&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;LOKI_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/loki/api/v1/query_range?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][:&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;diagnose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;labels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annotations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;recent_errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;namespace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;namespace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;你是維運助手，只做第一輪診斷，不要建議任何會改動系統的指令。&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;告警：&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;摘要：&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ann&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;標籤：&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;最近 15 分鐘的錯誤 log：&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(沒有撈到)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;請用三點回答：1. 可能原因 2. 影響範圍 3. 先看哪裡。總共不超過 200 字。&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LLM_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
                      &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LLM_TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;alerts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alerts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="c1"&gt;# 第一步：原始告警立刻送出，LLM 失敗也不會漏
&lt;/span&gt;    &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;labels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;annotations&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                     &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_RCA_PER_REQUEST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;labels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;SEEN_TTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# 同一個告警的 repeat 不重跑
&lt;/span&gt;            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔎 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;labels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; 診斷補充&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;diagnose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 逾時、連線失敗、格式不對都一樣處理
&lt;/span&gt;            &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔎 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;labels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; 診斷失敗或逾時（&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;），請直接看原始告警&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 先回 200，Grafana 才不會因為等 LLM 而逾時重送
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadingHTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;幾個地方值得講。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;先回 200，再處理。&lt;/strong&gt; &lt;code&gt;do_POST&lt;/code&gt; 一收到就回 200，診斷丟到背景執行緒。LLM 動輒幾十秒，不先回應的話，Grafana 那邊的 webhook 連線會一直被佔著。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;handle&lt;/code&gt; 第一步就是送原始告警。&lt;/strong&gt; 順序是整個設計的重點：&lt;code&gt;notify(...)&lt;/code&gt; 在 &lt;code&gt;diagnose(...)&lt;/code&gt; 之前。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;去重靠 &lt;code&gt;fingerprint&lt;/code&gt;。&lt;/strong&gt; Grafana 的 webhook 每個告警都帶一個 &lt;code&gt;fingerprint&lt;/code&gt;，同一個告警每次都一樣。&lt;code&gt;seen&lt;/code&gt; 記下上次診斷的時間，4 小時內不重做。這份記錄在記憶體裡，程式重啟就清空，對這個用途夠用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;每次請求最多診斷 3 條。&lt;/strong&gt; 一次大故障可能同時冒出幾十條告警，全部送去問 LLM 不合理。多出來的只收到原始告警。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;撈哪些 log？&lt;/strong&gt; 範例是以告警 label 裡的 &lt;code&gt;namespace&lt;/code&gt; 去撈 Loki 近 15 分鐘的錯誤類 log，最多 30 行。這一段要依自己的 label 慣例改。告警沒有 &lt;code&gt;namespace&lt;/code&gt; 就不撈，直接讓 LLM 憑告警本身回答。&lt;/p&gt;

&lt;h3&gt;
  
  
  設定與啟動
&lt;/h3&gt;

&lt;p&gt;四個環境變數：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;變數&lt;/th&gt;
&lt;th&gt;意義&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOTIFY_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;通知出口，接收 &lt;code&gt;{"text": "..."}&lt;/code&gt; 的 POST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOKI_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Loki 位址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LLM_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI 相容的 &lt;code&gt;/v1/chat/completions&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LLM_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;模型名稱&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NOTIFY_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://notify.example.internal/send &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;LOKI_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://loki.example.internal:3100 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;LLM_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://llm.example.internal/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
python3 rca_webhook.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana 這邊建一個 webhook 類型的聯絡點，網址指到這個接收端（預設 8080 埠，可用 &lt;code&gt;PORT&lt;/code&gt; 改），再把通知策略指到它即可。&lt;/p&gt;

&lt;h3&gt;
  
  
  實測結果
&lt;/h3&gt;

&lt;p&gt;用 &lt;code&gt;Notify&lt;/code&gt;、&lt;code&gt;Loki&lt;/code&gt;、&lt;code&gt;LLM&lt;/code&gt; 三個假服務跑五個情境，每個情境結果如下。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. 新的 firing 告警。&lt;/strong&gt; 先收到原始告警，緊接著收到「診斷補充」。webhook 本身 0.01 秒就回應了，診斷是背景處理的。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; [firing] DbDown: DbDown 觸發
&amp;gt; 🔎 DbDown 診斷補充
  1. 資料庫連線被拒絕
  2. 依賴它的服務都會受影響
  3. 先看資料庫 Pod 是否在重啟
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. 同一個 fingerprint 再來一次。&lt;/strong&gt; 只收到原始告警，LLM 的呼叫次數維持 1，沒有重跑。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. resolved。&lt;/strong&gt; 只送原始的 resolved 通知，沒有診斷。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. 一次進來 5 條不同的告警。&lt;/strong&gt; 5 條原始告警合成一則送出，之後只有 3 則診斷（&lt;code&gt;Many0&lt;/code&gt; 到 &lt;code&gt;Many2&lt;/code&gt;），LLM 呼叫剛好 3 次。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. LLM 逾時。&lt;/strong&gt; 這裡把逾時設成 2 秒、假 LLM 睡 5 秒。原始告警照常送出，之後補一則：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; 🔎 SlowLLM 診斷失敗或逾時（TimeoutError），請直接看原始告警
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;最後貼一下實際送給 LLM 的 prompt，可以看出給它的資料有多簡單：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;你是維運助手，只做第一輪診斷，不要建議任何會改動系統的指令。
告警：DbDown
摘要：DbDown 觸發
標籤：{"alertname": "DbDown", "namespace": "demo", "severity": "critical"}
最近 15 分鐘的錯誤 log：
level=error msg="connection refused" addr=db:5432
level=error msg="connection refused" addr=db:5432

請用三點回答：1. 可能原因 2. 影響範圍 3. 先看哪裡。總共不超過 200 字。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 把 LLM 放在告警的必經之路上。&lt;/strong&gt; 最直覺的做法是「先問 LLM，再把結果跟告警一起送」。一旦 LLM 掛了，連告警都收不到，那就是用複雜度換來更低的可靠度。所以才會先送原始告警。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. repeat 造成重複診斷。&lt;/strong&gt; 沒有做去重的話，同一個還沒恢復的告警每隔 &lt;code&gt;repeat_interval&lt;/code&gt; 就會再問一次 LLM。用 &lt;code&gt;fingerprint&lt;/code&gt; 去重是最簡單的解法。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 限制要放在接收端，不是只靠 prompt。&lt;/strong&gt; 「不要建議會改動系統的指令」寫在 prompt 裡只是請求。真正的限制是接收端根本沒有執行任何東西的能力，LLM 的輸出只會被當成文字送出去。如果之後要讓它真的去查東西，工具也應該是唯讀的。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. 大故障時的數量上限。&lt;/strong&gt; 資料庫掛掉時，依賴它的十幾個服務可能同時告警。沒有上限的話，一瞬間打出十幾次 LLM 請求。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;這個做法的價值在「降低半夜判斷的成本」，不在「取代人」。診斷可能是錯的，也可能只是把 log 裡最顯眼的那行重述一遍。它適合拿來決定「要不要現在處理」，不適合直接拿來決定「怎麼修」。&lt;/p&gt;

&lt;p&gt;做這個小接收端的成本也要算進去：多一個要維護的服務、每次告警一次 LLM 呼叫的費用，還有 log 內容會送給模型供應商這件事。log 裡如果有敏感資料，要先在撈出來的階段遮蔽，或者改用地端模型。&lt;/p&gt;

&lt;p&gt;換來的好處是：不用改告警規則、不用改 Grafana，只是在 webhook 的後面多接一段，不滿意隨時可以拿掉。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Grafana 文件：&lt;a href="https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/" rel="noopener noreferrer"&gt;Webhook contact point&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Loki 文件：&lt;a href="https://grafana.com/docs/loki/latest/reference/loki-http-api/#query-logs-within-a-range-of-time" rel="noopener noreferrer"&gt;HTTP API 的 query_range&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>grafana</category>
      <category>llm</category>
      <category>alerting</category>
      <category>python</category>
    </item>
    <item>
      <title>小規模叢集可以不裝 Alertmanager：用 Grafana 內建告警同時管 metrics 與 logs（實測）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:25:24 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/xiao-gui-mo-cong-ji-ke-yi-bu-zhuang-alertmanageryong-grafana-nei-jian-gao-jing-tong-shi-guan-metrics-yu-logsshi-ce--4270</link>
      <guid>https://dev.to/jimmy_3c703d/xiao-gui-mo-cong-ji-ke-yi-bu-zhuang-alertmanageryong-grafana-nei-jian-gao-jing-tong-shi-guan-metrics-yu-logsshi-ce--4270</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：小規模叢集可以把 Alertmanager 關掉，改用 Grafana 內建的告警引擎，用同一套規則、同一套通知策略處理指標與日誌。代價是 Prometheus 自己那一百多條預設規則，不會再通知任何人，要的規則得在 Grafana 裡重寫。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;kube-prometheus-stack 預設的告警路徑是這樣：Prometheus 評估規則，把觸發的告警送給 Alertmanager，再由 Alertmanager 分組、路由、發通知。&lt;/p&gt;

&lt;p&gt;指標告警這樣做沒問題。但叢集裡通常還有日誌，想對「log 出現某個錯誤關鍵字」告警，就變成另一條路：要有 Loki 自己的 ruler，或另外想辦法。&lt;/p&gt;

&lt;p&gt;結果是：規則寫在 PrometheusRule 的 CRD 裡，路由寫在 Alertmanager 的設定檔裡，日誌告警又在別的地方。小規模的叢集，光是維護這三處就嫌多。&lt;/p&gt;

&lt;p&gt;另一個選擇是：只用 Grafana。Grafana 本身就內建告警引擎，規則可以同時查 Prometheus 和 Loki，通知策略也在同一個地方設定。&lt;/p&gt;

&lt;p&gt;這篇用實測帶一遍怎麼做，也把關掉 Alertmanager 的代價講清楚。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  前提與測試版本
&lt;/h3&gt;

&lt;p&gt;手邊要有一個用 kube-prometheus-stack 安裝的 Grafana 與 Prometheus。要測日誌告警的話，還需要一個 Loki 資料來源（uid 為 &lt;code&gt;loki&lt;/code&gt;），沒有 Loki 的話，把下面的日誌規則略過即可。&lt;/p&gt;

&lt;p&gt;下面的 values，是在這些版本上實測的：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元件&lt;/th&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kube-prometheus-stack&lt;/td&gt;
&lt;td&gt;chart 92.10.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;內含的 Grafana chart&lt;/td&gt;
&lt;td&gt;14.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grafana/loki&lt;/td&gt;
&lt;td&gt;chart 7.3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;叢集是 k3s v1.31.5（單節點）。版本不同的話，欄位可能不一樣，請對一下 chart 的 &lt;code&gt;values.yaml&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟一：關掉 Alertmanager
&lt;/h3&gt;

&lt;p&gt;在 kube-prometheus-stack 的 values 加上：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;alertmanager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;defaultRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 沒有 Alertmanager 時這條一定會亮&lt;/span&gt;
    &lt;span class="na"&gt;PrometheusNotConnectedToAlertmanagers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;第二段很容易漏掉。chart 有一條預設規則 &lt;code&gt;PrometheusNotConnectedToAlertmanagers&lt;/code&gt;，內容就是「Prometheus 沒有連到任何 Alertmanager」，等待時間 10 分鐘。&lt;/p&gt;

&lt;p&gt;關掉 Alertmanager 又不關這條規則，10 分鐘後它就會一直亮著，而且永遠不會好。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟二：用 chart values 定義聯絡點與通知策略
&lt;/h3&gt;

&lt;p&gt;Grafana 的 chart 支援用 &lt;code&gt;alerting:&lt;/code&gt; 直接把告警資源寫進 values，部署時就會載入。放在 kube-prometheus-stack 的 &lt;code&gt;grafana:&lt;/code&gt; 底下：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;alerting&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;contactpoints.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;contactPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;orgId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo-webhook&lt;/span&gt;
          &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo-webhook&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;webhook&lt;/span&gt;
              &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://receiver.monitoring.svc.cluster.local:8080/alert&lt;/span&gt;
                &lt;span class="na"&gt;httpMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;

    &lt;span class="na"&gt;policies.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;orgId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo-webhook&lt;/span&gt;
          &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertname"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
          &lt;span class="na"&gt;group_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
          &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;聯絡點&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;聯絡點就是「通知要送去哪裡」。這裡用最簡單的 webhook，後面會放一個接收端來驗證。實際使用時，換成 email、聊天工具等其他類型即可。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;通知策略&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;策略決定哪些告警送給哪個聯絡點。這裡只有一條預設策略，全部都送給 &lt;code&gt;demo-webhook&lt;/code&gt;，並以 &lt;code&gt;alertname&lt;/code&gt; 分組。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;group_wait: 10s&lt;/code&gt; 是為了測試時快一點看到結果，平常用預設值就好。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟三：用 values 定義告警規則
&lt;/h3&gt;

&lt;p&gt;同樣放在 &lt;code&gt;grafana.alerting&lt;/code&gt; 底下，再加一個 &lt;code&gt;rules.yaml&lt;/code&gt;。這裡放兩條規則：一條查 Prometheus，一條查 Loki。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;rules.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;orgId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
          &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
          &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
          &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# 指標規則：容器重啟（Prometheus 資料來源）&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container-restarts&lt;/span&gt;
              &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ContainerRestarts&lt;/span&gt;
              &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
              &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0s&lt;/span&gt;
              &lt;span class="na"&gt;noDataState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OK&lt;/span&gt;
              &lt;span class="na"&gt;execErrState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Error&lt;/span&gt;
              &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
              &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;有容器在最近&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;分鐘內重啟過"&lt;/span&gt;
              &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                  &lt;span class="na"&gt;relativeTimeRange&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;300&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(namespace,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pod)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(increase(kube_pod_container_status_restarts_total{namespace="demo"}[5m]))'&lt;/span&gt;
                    &lt;span class="na"&gt;instant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__expr__&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reduce&lt;/span&gt;
                    &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                    &lt;span class="na"&gt;reducer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;
                    &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__expr__&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
                    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;threshold&lt;/span&gt;
                    &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                    &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;evaluator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;gt&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
                    &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;# 日誌規則：log 裡出現 ERROR_DEMO（Loki 資料來源）&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error-logs&lt;/span&gt;
              &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ErrorLogs&lt;/span&gt;
              &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
              &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0s&lt;/span&gt;
              &lt;span class="na"&gt;noDataState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OK&lt;/span&gt;
              &lt;span class="na"&gt;execErrState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Error&lt;/span&gt;
              &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
              &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;最近&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;分鐘內&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;log&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;出現錯誤關鍵字"&lt;/span&gt;
              &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                  &lt;span class="na"&gt;relativeTimeRange&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;120&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sum(count_over_time({namespace="demo"}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"ERROR_DEMO"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;[1m]))'&lt;/span&gt;
                    &lt;span class="na"&gt;queryType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;instant&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__expr__&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reduce&lt;/span&gt;
                    &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A&lt;/span&gt;
                    &lt;span class="na"&gt;reducer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;
                    &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
                  &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__expr__&lt;/span&gt;
                  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;refId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
                    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;threshold&lt;/span&gt;
                    &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
                    &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;evaluator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;gt&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
                    &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;__expr__&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;規則的結構三段式，兩條規則都一樣。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A 是查詢&lt;/strong&gt;：對資料來源下 PromQL 或 LogQL。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B 是縮減（reduce）&lt;/strong&gt;：把查詢結果壓成單一數字。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C 是閾值（threshold）&lt;/strong&gt;：B 大於 0 就算觸發。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;condition: C&lt;/code&gt; 指的是以 C 的結果當作這條規則的判斷依據。&lt;/p&gt;

&lt;p&gt;兩條規則查的是不同資料來源，但規則的寫法、通知策略、聯絡點完全一樣，這就是「一套引擎管指標與日誌」的樣子。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;datasourceUid&lt;/code&gt; 要對上資料來源的 uid。kube-prometheus-stack 預設的 Prometheus 資料來源 uid 是 &lt;code&gt;prometheus&lt;/code&gt;，Loki 是自己加的，這裡設成 &lt;code&gt;loki&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟四：套用並確認載入
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade monitoring prometheus-community/kube-prometheus-stack &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 92.10.1 &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;-f&lt;/span&gt; prom-values.yaml &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana 有提供 provisioning API，可以直接問它載入了什麼。先 port-forward，再用 &lt;code&gt;admin&lt;/code&gt; 帳號查：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:&amp;lt;密碼&amp;gt; http://127.0.0.1:3000/api/v1/provisioning/alert-rules
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:&amp;lt;密碼&amp;gt; http://127.0.0.1:3000/api/v1/provisioning/contact-points
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:&amp;lt;密碼&amp;gt; http://127.0.0.1:3000/api/v1/provisioning/policies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;實測看到的是：兩條規則（&lt;code&gt;ContainerRestarts&lt;/code&gt;、&lt;code&gt;ErrorLogs&lt;/code&gt;）、一個 &lt;code&gt;demo-webhook&lt;/code&gt; 聯絡點，以及預設策略的 receiver 是 &lt;code&gt;demo-webhook&lt;/code&gt;、&lt;code&gt;group_by&lt;/code&gt; 是 &lt;code&gt;alertname&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;也可以確認 Prometheus 真的沒有連 Alertmanager：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"/api/v1/namespaces/monitoring/services/monitoring-kube-prometheus-prometheus:9090/proxy/api/v1/alertmanagers"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;實測結果 &lt;code&gt;activeAlertmanagers&lt;/code&gt; 與 &lt;code&gt;droppedAlertmanagers&lt;/code&gt; 都是 0。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟五：放一個接收端，製造事件驗證
&lt;/h3&gt;

&lt;p&gt;先放一個會把收到的 POST 印出來的小接收端：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;r&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:3.12-alpine&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-u&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-c&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;from http.server import BaseHTTPRequestHandler, HTTPServer&lt;/span&gt;
          &lt;span class="s"&gt;class H(BaseHTTPRequestHandler):&lt;/span&gt;
              &lt;span class="s"&gt;def do_POST(self):&lt;/span&gt;
                  &lt;span class="s"&gt;n = int(self.headers.get('content-length', 0))&lt;/span&gt;
                  &lt;span class="s"&gt;print('POST', self.path, self.rfile.read(n).decode()[:2500], flush=True)&lt;/span&gt;
                  &lt;span class="s"&gt;self.send_response(200); self.end_headers()&lt;/span&gt;
              &lt;span class="s"&gt;def log_message(self, *a): pass&lt;/span&gt;
          &lt;span class="s"&gt;HTTPServer(('', 8080), H).serve_forever()&lt;/span&gt;
&lt;span class="s"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再製造兩種事件：一個會一直重啟的容器，和一個一直印出錯誤關鍵字的容器。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace demo

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo run crasher &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Always &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo starting; sleep 15; exit 1'&lt;/span&gt;

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo run noisy &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Always &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'while true; do echo "ERROR_DEMO something broke"; sleep 5; done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;等大約一兩分鐘，看接收端的 log：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring receiver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;會看到兩筆 &lt;code&gt;firing&lt;/code&gt; 的 POST，內容是 Grafana 的 webhook 格式，重點欄位長這樣：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"receiver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo-webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"firing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"alerts"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"firing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"labels"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"alertname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ErrorLogs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"grafana_folder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"最近 1 分鐘內 log 出現錯誤關鍵字"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;另一筆是 &lt;code&gt;ContainerRestarts&lt;/code&gt;，label 多了 &lt;code&gt;namespace="demo"&lt;/code&gt; 和 &lt;code&gt;pod="crasher"&lt;/code&gt;，因為規則的查詢有 &lt;code&gt;sum by (namespace, pod)&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟六：確認恢復通知
&lt;/h3&gt;

&lt;p&gt;把兩個測試 Pod 刪掉：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo delete pod noisy crasher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;等約三分鐘，接收端會收到 &lt;code&gt;ErrorLogs&lt;/code&gt; 的 &lt;code&gt;resolved&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ContainerRestarts&lt;/code&gt; 在我測的這三分鐘內還沒有恢復，這是正常的：它的查詢是 &lt;code&gt;increase(...[5m])&lt;/code&gt;，窗口有 5 分鐘，要等重啟的紀錄滑出窗口才會變成 0。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 關掉 Alertmanager，但沒關那條「沒連到 Alertmanager」的規則&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這條規則在 chart 裡是預設就有的（等待 10 分鐘，severity 是 warning）。我特地比對過：關掉 Alertmanager 之後它仍然會被渲染出來，要在 &lt;code&gt;defaultRules.disabled&lt;/code&gt; 裡明確關掉，渲染結果裡才會消失。&lt;/p&gt;

&lt;p&gt;不關的話，10 分鐘後它就會一直亮著。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 預設那一百多條規則還在跑，但不會通知任何人&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這是關掉 Alertmanager 最大的代價，一定要知道。&lt;/p&gt;

&lt;p&gt;實測：Prometheus 載入了 148 條告警規則，但 Alertmanager 數量是 0。其中 &lt;code&gt;Watchdog&lt;/code&gt; 這種永遠觸發的規則，在 Prometheus 裡是 &lt;code&gt;firing&lt;/code&gt;，但接收端一次都沒收到。&lt;/p&gt;

&lt;p&gt;也就是說，kube-prometheus-stack 預設附的 KubePodCrashLooping、NodeFilesystemSpaceFillingUp 這類規則，狀態會在 Prometheus 的 Alerts 頁亮起來，卻沒有任何通知出去。&lt;/p&gt;

&lt;p&gt;要這些規則的通知，就得在 Grafana 裡自己寫（或匯入）。Grafana 文件有 Import to Grafana-managed rules 這個功能，可以把資料來源管理的規則轉成 Grafana 管理的規則，這部分我沒有實測。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. values 裡的雙大括號要跳脫&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Grafana chart 會把 &lt;code&gt;alerting:&lt;/code&gt; 底下的內容當成 Helm 模板處理。如果規則的 annotation 裡想寫 Grafana 的樣板語法（例如用 &lt;code&gt;{{ $labels.pod }}&lt;/code&gt; 帶出 Pod 名稱），那組雙大括號會先被 Helm 當成它自己的模板。&lt;/p&gt;

&lt;p&gt;chart 的說明有寫：不是要給 Helm 處理的雙大括號必須跳脫。本篇為了簡單，annotation 全部寫成固定文字，避開這個問題，要用樣板語法的話，請照 chart 的說明處理跳脫。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;先說一個誠實的點：&lt;strong&gt;省的記憶體很少&lt;/strong&gt;。Alertmanager 在這個叢集大約只吃 38 MiB，不是為了省資源才關它。&lt;/p&gt;

&lt;p&gt;真正的理由是這兩個：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;一個地方管指標和日誌&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Grafana 官方文件說，Grafana 管理的告警規則可以查很多種資料來源，甚至在單一規則裡同時用多個資料來源。所以指標和日誌用同一套寫法、同一個 UI、同一套通知策略。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;規則可以跟著 values 一起進版本控制&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;規則、聯絡點、策略都寫在 values 裡，跟著 Helm 部署，改動走 Git，不需要登入 UI 手動設定。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;什麼情況應該保留 Alertmanager&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prometheus 官方文件對 Alertmanager 的定位，是接收 Prometheus 之類的用戶端送來的告警，負責去重、分組、路由到各種通知管道，也處理靜音與抑制。&lt;/p&gt;

&lt;p&gt;如果符合下面任一種情況，保留它比較合適：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;手上有很多現成的 PrometheusRule（或直接使用 kube-prometheus-stack 的預設規則），不想重寫。&lt;/li&gt;
&lt;li&gt;需要 Alertmanager 的功能，例如 inhibition。&lt;/li&gt;
&lt;li&gt;多個團隊共用 Prometheus，每個團隊有自己的路由設定。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;反過來，單一叢集、規則不多、又想同時管日誌告警的話，只用 Grafana 比較簡單。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/" rel="noopener noreferrer"&gt;Grafana：Alert rule types&lt;/a&gt;：Grafana 管理的規則與資料來源管理的規則有什麼不同，以及 Import 功能。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/grafana/latest/alerting/fundamentals/alertmanager/" rel="noopener noreferrer"&gt;Grafana：Alertmanager&lt;/a&gt;：Grafana 內建的 Alertmanager，以及評估與通知的分工。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/file-provisioning/" rel="noopener noreferrer"&gt;Grafana：File provisioning of alerting resources&lt;/a&gt;：規則、聯絡點、通知策略的檔案格式。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://prometheus.io/docs/alerting/latest/alertmanager/" rel="noopener noreferrer"&gt;Prometheus：Alertmanager&lt;/a&gt;：Alertmanager 做的事：去重、分組、路由、靜音、抑制。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack" rel="noopener noreferrer"&gt;kube-prometheus-stack 的 chart 說明&lt;/a&gt;：&lt;code&gt;alertmanager.enabled&lt;/code&gt; 與 &lt;code&gt;defaultRules&lt;/code&gt; 的設定。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇的 values 在上面列的版本實測過。換版本的話，記得先看 chart 的 &lt;code&gt;values.yaml&lt;/code&gt; 欄位有沒有改名。&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>prometheus</category>
      <category>alerting</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>用 kube-prometheus-stack 在單機 Kubernetes 上架 Prometheus 與 Grafana：k3s 要調的 values（實測）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:14:22 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/yong-kube-prometheus-stack-zai-dan-ji-kubernetes-shang-jia-prometheus-yu-grafanak3s-yao-diao-de-valuesshi-ce--1k3l</link>
      <guid>https://dev.to/jimmy_3c703d/yong-kube-prometheus-stack-zai-dan-ji-kubernetes-shang-jia-prometheus-yu-grafanak3s-yao-diao-de-valuesshi-ce--1k3l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：單機 k3s 叢集用 kube-prometheus-stack 一個 chart 就能裝好 Prometheus 與 Grafana，但要記得關掉 kube-proxy、scheduler、controller-manager 這三個元件，不然對應的規則會一直誤報。整套大約吃 850 MiB 記憶體。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;叢集跑起來之後，下一個問題是：它現在健康嗎？哪個 Pod 吃了太多資源？&lt;/p&gt;

&lt;p&gt;Prometheus 加 Grafana 幾乎是標準答案。但要自己一個一個裝 Prometheus、Grafana、kube-state-metrics、node-exporter，再接起來、再寫規則，工作量不小。&lt;/p&gt;

&lt;p&gt;kube-prometheus-stack 把這些打包成一個 chart。問題是它的預設值，是以標準的 Kubernetes 叢集為前提，換成 k3s 就會冒出一些意料之外的東西。&lt;/p&gt;

&lt;p&gt;這篇整理出在單機 k3s 上需要調整的地方，所有 values 都在一個全新的單節點叢集上實際跑過。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  前提與測試版本
&lt;/h3&gt;

&lt;p&gt;手邊要有一個 Kubernetes 叢集和 Helm。下面的指令與 values，是在這些版本上實測的：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元件&lt;/th&gt;
&lt;th&gt;Chart 版本&lt;/th&gt;
&lt;th&gt;應用程式版本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kube-prometheus-stack&lt;/td&gt;
&lt;td&gt;92.10.1&lt;/td&gt;
&lt;td&gt;Prometheus Operator v0.94.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;叢集是 k3s v1.31.5（單節點）。版本不同的話，values 的欄位可能不一樣，請對一下 chart 的 &lt;code&gt;values.yaml&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟一：加 Helm repo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟二：寫 values
&lt;/h3&gt;

&lt;p&gt;kube-prometheus-stack 一次帶來 Prometheus Operator、Prometheus、Grafana、Alertmanager、kube-state-metrics、node-exporter 和一整套預設規則與儀表板。新建 &lt;code&gt;prom-values.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# k3s 把 kube-proxy／scheduler／controller-manager 編進 k3s 主程序，不是獨立的 Pod，&lt;/span&gt;
&lt;span class="c1"&gt;# chart 預設找不到對應的目標，不關的話對應的規則會一直誤報&lt;/span&gt;
&lt;span class="na"&gt;kubeProxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;kubeScheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;kubeControllerManager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 預設只選同一個 release 的 ServiceMonitor／規則，其他 namespace 的 exporter 會漏抓&lt;/span&gt;
    &lt;span class="na"&gt;serviceMonitorSelectorNilUsesHelmValues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;podMonitorSelectorNilUsesHelmValues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;ruleSelectorNilUsesHelmValues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;7d&lt;/span&gt;
    &lt;span class="na"&gt;retentionSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4GB&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;500m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;storageSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;volumeClaimTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;說明一下。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;關掉三個 k3s 沒有的元件&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這三個元件在 k3s 上是編在主程序裡的，不是獨立的 Pod，chart 預設的做法找不到它們。不關的話，後面「踩到的坑」會講到對應的規則會怎麼樣。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;selector 三行&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;serviceMonitorSelectorNilUsesHelmValues&lt;/code&gt; 這三個設成 &lt;code&gt;false&lt;/code&gt;，Prometheus 才會抓其他 release、其他 namespace 的 ServiceMonitor 和規則。&lt;/p&gt;

&lt;p&gt;預設是只選跟這個 chart 同一個 release 標籤的資源，之後如果別的 Helm release 帶了自己的 ServiceMonitor，就會被忽略。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;保留與容量&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;metrics 留 7 天，並用 &lt;code&gt;retentionSize: 4GB&lt;/code&gt; 當第二道保險，避免磁碟被吃滿。兩個條件，先到的先生效。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;資源與儲存&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resources&lt;/code&gt; 把 Prometheus 的記憶體限制在 1 GiB，&lt;code&gt;storageSpec&lt;/code&gt; 給它一顆 10 GiB 的 PVC。單機磁碟有限，這兩個數字請依自己的機器調整。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟三：安裝
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; monitoring prometheus-community/kube-prometheus-stack &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 92.10.1 &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; prom-values.yaml &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這個 chart 比較大，第一次拉映像大概要幾分鐘。&lt;/p&gt;

&lt;p&gt;完成後確認：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;應該會看到 Prometheus、Alertmanager、Grafana、Operator、kube-state-metrics、node-exporter 都是 Running。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟四：驗證
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;先取得密碼，帳號是 &lt;code&gt;admin&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secret &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/component&lt;span class="o"&gt;=&lt;/span&gt;admin-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.items[0].data.admin-password}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再 port-forward：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring svc/monitoring-grafana 3000:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;打開 &lt;code&gt;http://localhost:3000&lt;/code&gt; 登入，到 &lt;strong&gt;Dashboards&lt;/strong&gt;，chart 已經預設放好一批 Kubernetes 相關的儀表板。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus 目標與告警&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring svc/monitoring-kube-prometheus-prometheus 9090:9090
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;打開 &lt;code&gt;http://localhost:9090&lt;/code&gt;，&lt;strong&gt;Status → Targets&lt;/strong&gt; 應該全部是 &lt;code&gt;UP&lt;/code&gt;（API server、CoreDNS、kubelet、kube-state-metrics、node-exporter、Grafana 等）。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alerts&lt;/strong&gt; 頁只會有一條在跑：&lt;code&gt;Watchdog&lt;/code&gt;。這是 kube-prometheus-stack 刻意放的一條「永遠觸發」的心跳規則，用來確認告警管線本身活著，看到它是正常的。&lt;/p&gt;

&lt;p&gt;如果看到其他規則在 &lt;code&gt;firing&lt;/code&gt;，通常就是下面「踩到的坑」會講的情況。&lt;/p&gt;

&lt;h3&gt;
  
  
  資源用量
&lt;/h3&gt;

&lt;p&gt;裝完幾分鐘後，用 &lt;code&gt;kubectl top pods -n monitoring&lt;/code&gt; 量到的數字：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元件&lt;/th&gt;
&lt;th&gt;記憶體&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Grafana&lt;/td&gt;
&lt;td&gt;約 465 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;約 292 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alertmanager&lt;/td&gt;
&lt;td&gt;約 38 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus Operator&lt;/td&gt;
&lt;td&gt;約 26 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kube-state-metrics&lt;/td&gt;
&lt;td&gt;約 20 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;約 9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;加起來約 850 MiB。整個節點（含 k3s 本身）當時用了約 2.4 GB。&lt;/p&gt;

&lt;p&gt;這是剛裝好、幾乎沒有流量的數字。Prometheus 的用量會隨著抓取的目標與時間序列數量增加，上線之後請再觀察一陣子。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. k3s 上不關那三個元件，規則會一直誤報&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這個我特地驗證過：把 &lt;code&gt;kubeProxy&lt;/code&gt;、&lt;code&gt;kubeScheduler&lt;/code&gt;、&lt;code&gt;kubeControllerManager&lt;/code&gt; 都打開，過一分鐘去看規則，&lt;code&gt;KubeProxyDown&lt;/code&gt;、&lt;code&gt;KubeSchedulerDown&lt;/code&gt;、&lt;code&gt;KubeControllerManagerDown&lt;/code&gt; 三條都進入 &lt;code&gt;pending&lt;/code&gt;，等待時間是 900 秒。&lt;/p&gt;

&lt;p&gt;也就是說 15 分鐘後它們就會變成 &lt;code&gt;firing&lt;/code&gt;，而且永遠不會好，因為預設的設定在 k3s 上找不到任何目標。&lt;/p&gt;

&lt;p&gt;要說明的是，k3s 並不是沒有這三個元件的 metrics。k3s 文件的 Requirements 頁，在網路埠的表格裡就列了 kube-proxy（10249）、kube-controller-manager（10257）、kube-scheduler（10259）的 metrics 埠。&lt;/p&gt;

&lt;p&gt;只是想真的監控它們，需要自己處理綁定位址與抓取設定，這部分沒有實測，這裡選擇直接關掉。&lt;/p&gt;

&lt;p&gt;另外用 &lt;code&gt;helm template&lt;/code&gt; 比對渲染結果：打開時會多出這三條規則和對應的 ServiceMonitor，關掉之後兩者都是 0。&lt;/p&gt;

&lt;p&gt;所以關掉這三個元件，是同時把目標、ServiceMonitor 和規則一起移除。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 別的 Helm release 的 ServiceMonitor 抓不到&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;chart 預設只選有自己 release 標籤的 ServiceMonitor、PodMonitor 和規則。&lt;/p&gt;

&lt;p&gt;如果之後用另一個 Helm release 安裝資料庫或其他應用，它們帶的 ServiceMonitor 不會被這個 Prometheus 抓到，Targets 頁上就是看不到。&lt;/p&gt;

&lt;p&gt;步驟二的那三行 &lt;code&gt;...NilUsesHelmValues: false&lt;/code&gt; 就是用來解這個問題的：改成不依 release 標籤過濾。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 叢集一大，可能撞到 inotify 上限&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;節點與 Pod 多了之後，系統可能開始隨機報 &lt;code&gt;Too many open files&lt;/code&gt;，而且不是檔案描述符不夠，而是 inotify 的配額用完。&lt;/p&gt;

&lt;p&gt;kind 的 Known Issues 文件對這點有說明：Ubuntu 預設的 &lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt; 是 128、&lt;code&gt;max_user_watches&lt;/code&gt; 是 8192，對有很多節點的叢集不夠。&lt;/p&gt;

&lt;p&gt;暫時調高：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_watches&lt;span class="o"&gt;=&lt;/span&gt;524288
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_instances&lt;span class="o"&gt;=&lt;/span&gt;512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;要永久生效，把兩行寫進 &lt;code&gt;/etc/sysctl.conf&lt;/code&gt;（或 &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; 底下的檔案）。這個設定是整台主機共用的，用容器當節點的叢集（例如 k3d、kind）會吃到同一份配額。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;kube-prometheus-stack，而不是自己組&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;一個 chart 就帶來 Operator、Prometheus、Grafana、kube-state-metrics、node-exporter 和一整套預設規則與儀表板。&lt;/p&gt;

&lt;p&gt;自己一個一個裝也可以，但光是把它們接起來、再維護規則，就是不少工作。&lt;/p&gt;

&lt;p&gt;代價是這個 chart 很大，預設值是為標準叢集設計的，換成 k3s 這種精簡發行版就要像上面這樣調整。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;retention 7 天加容量上限&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;單機磁碟有限，所以同時設了時間（7 天）和容量（4 GB）兩個上限。要長期保存 metrics 的話，需要調大這兩個值和 PVC，或是把資料送到遠端儲存。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;保留預設的 Alertmanager&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這份 values 沒有動 Alertmanager，它會跟著預設一起裝起來。這是最省事的起點，之後想怎麼處理告警，再另外調整。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack" rel="noopener noreferrer"&gt;kube-prometheus-stack 的 chart 說明&lt;/a&gt;：所有 values 的來源，不同 Kubernetes 發行版的注意事項也在裡面。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://prometheus-operator.dev/docs/developer/getting-started/" rel="noopener noreferrer"&gt;Prometheus Operator：ServiceMonitor 與 selector&lt;/a&gt;：Operator 怎麼選出要抓的目標。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.k3s.io/installation/requirements" rel="noopener noreferrer"&gt;K3s：Requirements&lt;/a&gt;：網路埠表格裡列出 kube-proxy、kube-controller-manager、kube-scheduler 的 metrics 埠。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kind.sigs.k8s.io/docs/user/known-issues/" rel="noopener noreferrer"&gt;kind：Known Issues&lt;/a&gt;：「too many open files」與 inotify 上限的處理。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇的 values 在上面列的版本實測過。換版本的話，記得先看 chart 的 &lt;code&gt;values.yaml&lt;/code&gt; 欄位有沒有改名。&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>grafana</category>
      <category>kubernetes</category>
      <category>k3s</category>
    </item>
    <item>
      <title>在 Kubernetes 上用 Loki + Alloy 收集 Pod log：單機小規模的 Helm values（實測）</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:13:53 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/zai-kubernetes-shang-yong-loki-alloy-shou-ji-pod-logdan-ji-xiao-gui-mo-de-helm-valuesshi-ce--1jkm</link>
      <guid>https://dev.to/jimmy_3c703d/zai-kubernetes-shang-yong-loki-alloy-shou-ji-pod-logdan-ji-xiao-gui-mo-de-helm-valuesshi-ce--1jkm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：單機叢集想集中看 Pod 的 log，可以用單一二進位模式的 Loki，加一個 Alloy 透過 Kubernetes API 收 log。兩份 values 就能跑起來，合計大約只吃 130 MiB 記憶體。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;Pod 出事的時候，最常做的事就是 &lt;code&gt;kubectl logs&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;但 Pod 重啟之後，上一次的 log 就看不到了。想跨 Pod、跨時間搜尋，更是做不到。&lt;/p&gt;

&lt;p&gt;Loki 是這類需求常見的選擇。麻煩的是，官方 chart 的預設值是為正式環境設計的，直接 &lt;code&gt;helm install&lt;/code&gt; 在單機叢集上，不是跑不起來，就是太重。&lt;/p&gt;

&lt;p&gt;這篇把 Loki 和收集器 Alloy 需要調整的地方整理出來，所有 values 都在一個全新的單節點 k3s 叢集上實際跑過。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  前提與測試版本
&lt;/h3&gt;

&lt;p&gt;手邊要有一個 Kubernetes 叢集和 Helm。下面的指令與 values，是在這些版本上實測的：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元件&lt;/th&gt;
&lt;th&gt;Chart 版本&lt;/th&gt;
&lt;th&gt;應用程式版本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;grafana/loki&lt;/td&gt;
&lt;td&gt;7.3.0&lt;/td&gt;
&lt;td&gt;Loki 3.6.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grafana/alloy&lt;/td&gt;
&lt;td&gt;1.13.1&lt;/td&gt;
&lt;td&gt;Alloy v1.20.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;叢集是 k3s v1.31.5（單節點）。版本不同的話，values 的欄位可能不一樣，請對一下 chart 的 &lt;code&gt;values.yaml&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟一：加 Helm repo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;兩個元件都放在 &lt;code&gt;monitoring&lt;/code&gt; namespace，下面的指令會自動建立。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟二：裝 Loki（單一二進位模式）
&lt;/h3&gt;

&lt;p&gt;Loki 的 chart 預設是 SimpleScalable 模式，需要物件儲存（例如 S3），還會拆成 backend、read、write 好幾組。&lt;/p&gt;

&lt;p&gt;單機小規模用不到，改成單一二進位加本機檔案系統。新建 &lt;code&gt;loki-values.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploymentMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SingleBinary&lt;/span&gt;

&lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;auth_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;commonConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;replication_factor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;schemaConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-04-01"&lt;/span&gt;
        &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tsdb&lt;/span&gt;
        &lt;span class="na"&gt;object_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
        &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v13&lt;/span&gt;
        &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;index_&lt;/span&gt;
          &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
  &lt;span class="na"&gt;limits_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retention_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
  &lt;span class="na"&gt;compactor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retention_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;delete_request_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
    &lt;span class="na"&gt;compaction_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;

&lt;span class="na"&gt;singleBinary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;500m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;

&lt;span class="c1"&gt;# 小規模單機用不到的元件，全部關掉&lt;/span&gt;
&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;chunksCache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;resultsCache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;lokiCanary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;sidecar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selfMonitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;serviceMonitor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;幾個重點。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;單一二進位&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;deploymentMode: SingleBinary&lt;/code&gt; 配上 &lt;code&gt;backend&lt;/code&gt;、&lt;code&gt;read&lt;/code&gt;、&lt;code&gt;write&lt;/code&gt; 的 &lt;code&gt;replicas: 0&lt;/code&gt;，才會只跑一個 Pod。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;保留 24 小時&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;retention_period: 24h&lt;/code&gt; 只是設定了保留時間，真正去刪舊資料的是 compactor，所以 &lt;code&gt;compactor.retention_enabled: true&lt;/code&gt; 要一起開，而且要指定 &lt;code&gt;delete_request_store&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;少了這兩項，log 不會被清掉，磁碟會慢慢長大。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;關掉一堆東西&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;gateway、兩個 cache、canary、測試 Pod、規則 sidecar 在正式環境有用，單機用不到，關掉可以少跑好幾個 Pod。&lt;/p&gt;

&lt;p&gt;安裝：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; loki grafana/loki &lt;span class="nt"&gt;--version&lt;/span&gt; 7.3.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; loki-values.yaml &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 6m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;確認：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;loki-0&lt;/code&gt; 應該是 &lt;code&gt;1/1 Running&lt;/code&gt;。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟三：裝 Alloy 收 log
&lt;/h3&gt;

&lt;p&gt;Alloy 是 Grafana 現在主推的收集器，用 Kubernetes API 把 Pod 的 log 轉送到 Loki。新建 &lt;code&gt;alloy-values.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;controller&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;serviceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;alloy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;512Mi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;discovery.kubernetes "pods" {&lt;/span&gt;
        &lt;span class="s"&gt;role = "pod"&lt;/span&gt;
      &lt;span class="s"&gt;}&lt;/span&gt;

      &lt;span class="s"&gt;discovery.relabel "pods" {&lt;/span&gt;
        &lt;span class="s"&gt;targets = discovery.kubernetes.pods.targets&lt;/span&gt;

        &lt;span class="s"&gt;rule {&lt;/span&gt;
          &lt;span class="s"&gt;source_labels = ["__meta_kubernetes_namespace"]&lt;/span&gt;
          &lt;span class="s"&gt;target_label  = "namespace"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;rule {&lt;/span&gt;
          &lt;span class="s"&gt;source_labels = ["__meta_kubernetes_pod_name"]&lt;/span&gt;
          &lt;span class="s"&gt;target_label  = "pod"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;rule {&lt;/span&gt;
          &lt;span class="s"&gt;source_labels = ["__meta_kubernetes_pod_container_name"]&lt;/span&gt;
          &lt;span class="s"&gt;target_label  = "container"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;rule {&lt;/span&gt;
          &lt;span class="s"&gt;source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]&lt;/span&gt;
          &lt;span class="s"&gt;target_label  = "app"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
      &lt;span class="s"&gt;}&lt;/span&gt;

      &lt;span class="s"&gt;loki.source.kubernetes "pods" {&lt;/span&gt;
        &lt;span class="s"&gt;targets    = discovery.relabel.pods.output&lt;/span&gt;
        &lt;span class="s"&gt;forward_to = [loki.write.default.receiver]&lt;/span&gt;
      &lt;span class="s"&gt;}&lt;/span&gt;

      &lt;span class="s"&gt;loki.write "default" {&lt;/span&gt;
        &lt;span class="s"&gt;endpoint {&lt;/span&gt;
          &lt;span class="s"&gt;url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
      &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;設定檔是一條管線，從上往下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;discovery.kubernetes&lt;/code&gt; 找出所有 Pod。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;discovery.relabel&lt;/code&gt; 把 namespace、Pod 名稱、容器名稱、app 標籤變成 Loki 的 label。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki.source.kubernetes&lt;/code&gt; 讀取這些 Pod 的 log。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki.write&lt;/code&gt; 推送到 Loki。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;最後的 &lt;code&gt;url&lt;/code&gt; 是上一步 Loki 的 Service 名稱，release 名稱不是 &lt;code&gt;loki&lt;/code&gt; 或 namespace 不同的話，這裡要跟著改。&lt;/p&gt;

&lt;p&gt;因為是用 Kubernetes API 讀 log，這個元件不需要特權容器，也不用掛載主機的目錄，所以用一個 Deployment 就夠，不需要每個節點一個 DaemonSet。&lt;/p&gt;

&lt;p&gt;安裝：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; alloy grafana/alloy &lt;span class="nt"&gt;--version&lt;/span&gt; 1.13.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; monitoring &lt;span class="nt"&gt;-f&lt;/span&gt; alloy-values.yaml &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 5m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟四：驗證
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Loki 有沒有收到 log&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Loki 沒有網頁介面，用 Kubernetes 的 service proxy 直接問它：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"/api/v1/namespaces/monitoring/services/loki:3100/proxy/loki/api/v1/labels"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;收到 log 之後會看到這些 label：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"container"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"instance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"job"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"pod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"service_name"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再實際查幾行 log。下面的查詢是 &lt;code&gt;{namespace="monitoring"}&lt;/code&gt;（已經 URL 編碼），換成自己有 Pod 的 namespace 也可以：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"/api/v1/namespaces/monitoring/services/loki:3100/proxy/loki/api/v1/query_range?query=%7Bnamespace%3D%22monitoring%22%7D&amp;amp;limit=3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;回傳的 JSON 裡，&lt;code&gt;data.result[].stream&lt;/code&gt; 是 label，&lt;code&gt;values&lt;/code&gt; 是 &lt;code&gt;[時間戳, log 內容]&lt;/code&gt; 的陣列。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;接上 Grafana（選用）&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;如果叢集裡已經有 Grafana，新增一個 Loki 資料來源就能用 Explore 查 log：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type&lt;/strong&gt;：Loki&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;：&lt;code&gt;http://loki.monitoring.svc.cluster.local:3100&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;按 &lt;strong&gt;Save &amp;amp; test&lt;/strong&gt;，看到 &lt;code&gt;Data source successfully connected.&lt;/code&gt; 就接通了。接著在 &lt;strong&gt;Explore&lt;/strong&gt; 查 &lt;code&gt;{namespace="monitoring"}&lt;/code&gt;，查詢語言是 LogQL。&lt;/p&gt;

&lt;h3&gt;
  
  
  資源用量
&lt;/h3&gt;

&lt;p&gt;裝完幾分鐘後，用 &lt;code&gt;kubectl top pods&lt;/code&gt; 量到的數字：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;元件&lt;/th&gt;
&lt;th&gt;記憶體&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;約 73 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alloy&lt;/td&gt;
&lt;td&gt;約 55 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;合計約 130 MiB。這是剛裝好、流量很小的數字，log 量變大之後 Loki 的用量會增加，上線之後請再觀察一陣子。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Loki 的 chart 用預設值根本裝不起來&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;我直接 &lt;code&gt;helm install grafana/loki&lt;/code&gt;，不加任何設定，會出現：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: execution error at (loki/templates/write/statefulset-write.yaml:50:28):
Please define loki.storage.bucketNames.chunks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;補上檔案系統的儲存設定、但仍用預設的部署模式，則是：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot run scalable targets (backend, read, write) or distributed targets
without an object storage backend.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;原因就是預設的 SimpleScalable 模式需要物件儲存。單機請明確設定 &lt;code&gt;deploymentMode: SingleBinary&lt;/code&gt;，並且把 &lt;code&gt;backend&lt;/code&gt;、&lt;code&gt;read&lt;/code&gt;、&lt;code&gt;write&lt;/code&gt; 的 &lt;code&gt;replicas&lt;/code&gt; 設成 0。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Loki 啟動時的 memberlist 警告可以忽略&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;單一二進位模式第一次啟動時，log 裡會出現類似這樣的警告：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to resolve loki-memberlist.monitoring.svc.cluster.local ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這是 Loki 在嘗試加入叢集，單副本的情況下找不到其他成員，過幾秒就會繼續正常啟動。只要 &lt;code&gt;loki-0&lt;/code&gt; 最後是 &lt;code&gt;1/1 Running&lt;/code&gt; 就沒事。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 要有 compactor，保留時間才會生效&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;只設 &lt;code&gt;retention_period&lt;/code&gt; 不會刪資料。要同時開 &lt;code&gt;compactor.retention_enabled: true&lt;/code&gt; 並指定 &lt;code&gt;delete_request_store&lt;/code&gt;，這點在 Loki 官方的 Log retention 文件裡有說明。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. 看不到節點本身的 log&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;loki.source.kubernetes&lt;/code&gt; 只能收 Pod 的 log。官方文件特別註明，它收不到 Kubernetes 節點本身的 log（例如 systemd 的 log）。&lt;/p&gt;

&lt;p&gt;如果也需要節點層級的 log，要另外加其他來源。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Loki 單一二進位，而不是 SimpleScalable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;單機沒有高可用的需求，檔案系統加單一副本最簡單。&lt;/p&gt;

&lt;p&gt;代價是：這個模式沒有水平擴展，也沒有備援，資料就放在一顆 PVC 上。log 量大到單機撐不住的時候，才需要換成物件儲存加分散式部署。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alloy，而不是 Promtail&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Grafana 的 Promtail 文件上寫得很清楚：Promtail 已經在 2026 年 3 月 2 日結束生命週期（EOL），不再有任何支援與更新，之後的功能開發都在 Alloy。所以新的部署直接用 Alloy。&lt;/p&gt;

&lt;p&gt;用 &lt;code&gt;loki.source.kubernetes&lt;/code&gt; 的好處，官方文件也寫了：它透過 Kubernetes API 讀 log，不需要特權容器，也不需要 root。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;保留只有 24 小時&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這是刻意的。單機磁碟有限，log 主要用來排查剛發生的問題，要長期保存的話，需要調大 &lt;code&gt;retention_period&lt;/code&gt; 和 PVC。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/loki/latest/setup/install/helm/install-monolithic/" rel="noopener noreferrer"&gt;Grafana Loki：Install the monolithic Helm chart&lt;/a&gt;：單一二進位模式的官方安裝說明。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/loki/latest/operations/storage/retention/" rel="noopener noreferrer"&gt;Grafana Loki：Log retention&lt;/a&gt;：&lt;code&gt;compactor.retention_enabled&lt;/code&gt; 與 &lt;code&gt;delete_request_store&lt;/code&gt; 的設定。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/alloy/latest/reference/components/loki/loki.source.kubernetes/" rel="noopener noreferrer"&gt;Grafana Alloy：loki.source.kubernetes&lt;/a&gt;：用 Kubernetes API 收 log 的元件，以及它收不到節點 log 的說明。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/alloy/latest/set-up/install/kubernetes/" rel="noopener noreferrer"&gt;Grafana Alloy：Deploy on Kubernetes&lt;/a&gt;：用 Helm 安裝 Alloy。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://grafana.com/docs/loki/latest/send-data/promtail/" rel="noopener noreferrer"&gt;Grafana Loki：Promtail agent&lt;/a&gt;：Promtail 的 EOL 公告與遷移到 Alloy 的說明。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇的 values 在上面列的版本實測過。換版本的話，記得先看 chart 的 &lt;code&gt;values.yaml&lt;/code&gt; 欄位有沒有改名。&lt;/p&gt;

</description>
      <category>loki</category>
      <category>grafana</category>
      <category>kubernetes</category>
      <category>logging</category>
    </item>
    <item>
      <title>k3d 別再用 image import 了：改用本機 registry，磁碟再也不會莫名其妙爆掉</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:06:46 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/k3d-bie-zai-yong-image-import-liao-gai-yong-ben-ji-registryci-die-zai-ye-bu-hui-mo-ming-qi-miao-bao-diao-3jhn</link>
      <guid>https://dev.to/jimmy_3c703d/k3d-bie-zai-yong-image-import-liao-gai-yong-ben-ji-registryci-die-zai-ye-bu-hui-mo-ming-qi-miao-bao-diao-3jhn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：在 k3d 裡自己 build 的映像，不要一直 &lt;code&gt;k3d image import&lt;/code&gt;，建一個本機 registry，再把 &lt;code&gt;docker.io&lt;/code&gt; 的 mirror 指過去，節點需要時才拉，磁碟用量和舊版本回收都會好處理很多。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;我在本機用 k3d 跑著一個多節點的叢集，裡面有不少服務是自己 build 的映像。&lt;/p&gt;

&lt;p&gt;一開始都是 build 完就 &lt;code&gt;k3d image import&lt;/code&gt;，簡單、不用架任何東西，用起來也很順。&lt;/p&gt;

&lt;p&gt;直到有一天，系統碟快滿了。&lt;/p&gt;

&lt;p&gt;一查才發現，每個節點的 &lt;code&gt;/var/lib/rancher/k3s&lt;/code&gt; 都長到 20 多 GB。原因有兩個：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;每個節點都存一份。&lt;/strong&gt; &lt;code&gt;k3d image import&lt;/code&gt; 是把映像塞進「每一個」節點各自的 containerd，節點越多，存的份數越多。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;舊版本永遠不會被回收。&lt;/strong&gt; tag 固定寫 &lt;code&gt;0.1.0&lt;/code&gt;，每次重新 build 都覆蓋同一個 tag，舊的那份就變成 &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt;，沒有任何東西會去清它。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;而 kubelet 預設要等到磁碟用量 85% 才開始回收映像（回收到 80%）。&lt;/p&gt;

&lt;p&gt;另外「閒置太久就回收」的 &lt;code&gt;imageMaximumGCAge&lt;/code&gt; 預設是關閉的。&lt;/p&gt;

&lt;p&gt;系統碟夠大的話，這個門檻幾乎永遠不會觸發，舊映像就這樣一路堆上去。&lt;/p&gt;

&lt;p&gt;後來改成用本機 registry，下面是實際的做法，可以跟著做。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  步驟一：建叢集的時候順便建 registry
&lt;/h3&gt;

&lt;p&gt;k3d 的設定檔可以直接宣告 registry，一次到位。新建一個 &lt;code&gt;cluster.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k3d.io/v1alpha5&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Simple&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;agents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;registries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k3d-dev-registry&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1"&lt;/span&gt;
    &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5001"&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/library/registry:3&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/k3d-registry:/var/lib/registry&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;mirrors:&lt;/span&gt;
      &lt;span class="s"&gt;"docker.io":&lt;/span&gt;
        &lt;span class="s"&gt;endpoint:&lt;/span&gt;
          &lt;span class="s"&gt;- http://k3d-dev-registry:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;有幾個地方先說明一下。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;兩個埠&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hostPort: "5001"&lt;/code&gt; 是在&lt;strong&gt;主機上&lt;/strong&gt; push 用的埠。節點之間互相連是走容器內的 &lt;code&gt;5000&lt;/code&gt;，所以 mirror 的 endpoint 寫 &lt;code&gt;:5000&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這兩個埠很容易搞混。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;資料放哪裡&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;volumes&lt;/code&gt; 把 registry 的資料放到主機上，叢集砍掉重建，映像也還在。路徑請換成自己的。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;registry 版本&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;image&lt;/code&gt; 指定 &lt;code&gt;registry:3&lt;/code&gt;，原因放在後面「踩到的坑」講。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mirror&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;重點是 &lt;code&gt;mirrors&lt;/code&gt;：把 &lt;code&gt;docker.io&lt;/code&gt; 的 mirror 指到這個本機 registry。&lt;/p&gt;

&lt;p&gt;之後 Manifest 裡寫的 &lt;code&gt;myorg/app:0.1.0&lt;/code&gt;（等於 &lt;code&gt;docker.io/myorg/app:0.1.0&lt;/code&gt;）會&lt;strong&gt;先&lt;/strong&gt;去本機 registry 找，找不到才自動回退到 Docker Hub。&lt;/p&gt;

&lt;p&gt;k3s 文件寫得很清楚：containerd 對每個 registry 都有一個隱含的「預設 endpoint」，不管 mirror 怎麼設，最後一定會試它。&lt;/p&gt;

&lt;p&gt;所以 Helm chart 或 YAML 完全不用改映像名稱。&lt;/p&gt;

&lt;p&gt;然後建叢集：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d cluster create &lt;span class="nt"&gt;--config&lt;/span&gt; cluster.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟二：確認 registry 起來了
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:5001/v2/_catalog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;剛建好應該會看到：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"repositories"&lt;/span&gt;&lt;span class="p"&gt;:[]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟三：build 之後 push 到本機 registry
&lt;/h3&gt;

&lt;p&gt;先 build 你的映像，再 tag 成本機 registry 的位址然後 push：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myorg/app:0.1.0 &lt;span class="nb"&gt;.&lt;/span&gt;
docker tag  myorg/app:0.1.0 127.0.0.1:5001/myorg/app:0.1.0
docker push 127.0.0.1:5001/myorg/app:0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;注意 push 的路徑是 &lt;code&gt;myorg/app:0.1.0&lt;/code&gt;，&lt;strong&gt;不要&lt;/strong&gt;帶 &lt;code&gt;docker.io/&lt;/code&gt;。因為節點是依「&lt;code&gt;docker.io&lt;/code&gt; 底下的 &lt;code&gt;myorg/app&lt;/code&gt;」去 mirror 找的，路徑要對得上。&lt;/p&gt;

&lt;p&gt;再看一次 catalog，應該就有東西了：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:5001/v2/_catalog
&lt;span class="c"&gt;# {"repositories":["myorg/app"]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  步驟四：部署，Manifest 照舊
&lt;/h3&gt;

&lt;p&gt;你的 Deployment 繼續寫：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myorg/app:0.1.0&lt;/span&gt;
&lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;部署之後，建議直接到節點裡驗證，確認它真的是從本機 registry 拉的，而不是剛好從 Docker Hub 拉到同名映像：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;k3d-dev-server-0 crictl pull myorg/app:0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;再去 registry 看一下有沒有被請求過（或者乾脆把 Docker Hub 上不存在的名字拿來測，能拉到就一定是本機的）。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟五：同一個 tag 重新 build 的時候
&lt;/h3&gt;

&lt;p&gt;如果 tag 不變（例如固定用 &lt;code&gt;0.1.0&lt;/code&gt;），重新 push 之後，節點上可能還留著舊的 &lt;code&gt;0.1.0&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;這時 &lt;code&gt;IfNotPresent&lt;/code&gt; 不會重新去拉。解法是 push 完順手把各節點上同名的 tag 刪掉：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;node &lt;span class="k"&gt;in &lt;/span&gt;k3d-dev-server-0 k3d-dev-agent-0 k3d-dev-agent-1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; crictl rmi docker.io/myorg/app:0.1.0 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這樣下次 Pod 重建時就會重新拉新的。&lt;/p&gt;

&lt;p&gt;正在跑的容器不受影響。&lt;/p&gt;

&lt;p&gt;被你覆蓋掉的舊版本，內容在 registry 裡還是會佔空間，要跑 garbage collect 才會回收。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;跑 GC 之前，請先確定沒有人正在 push。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Distribution 官方文件明確警告：GC 進行時如果同時上傳映像，layer 可能被誤刪，造成映像損壞。&lt;/p&gt;

&lt;p&gt;建議先用 &lt;code&gt;--dry-run&lt;/code&gt; 預覽會刪什麼：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;k3d-dev-registry &lt;span class="se"&gt;\&lt;/span&gt;
  registry garbage-collect &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--delete-untagged&lt;/span&gt; /etc/distribution/config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;確認沒問題再正式執行：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;k3d-dev-registry &lt;span class="se"&gt;\&lt;/span&gt;
  registry garbage-collect &lt;span class="nt"&gt;--delete-untagged&lt;/span&gt; /etc/distribution/config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;如果你的 tag 每次都不一樣（用 git SHA 之類），其實就不需要步驟五的第一段，只要偶爾跑 GC 就好。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟六：把 kubelet 的映像回收調積極一點
&lt;/h3&gt;

&lt;p&gt;映像改由 registry 提供之後，節點上的映像被回收了也沒關係，要用時再拉回來就好。&lt;/p&gt;

&lt;p&gt;所以可以把回收門檻調低。新增一個 &lt;code&gt;kubelet-image-gc.conf&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubelet.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubeletConfiguration&lt;/span&gt;
&lt;span class="na"&gt;imageGCHighThresholdPercent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;span class="na"&gt;imageGCLowThresholdPercent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;
&lt;span class="na"&gt;imageMaximumGCAge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;意思是：磁碟用到 50% 就開始回收，一直回收到 40%（kubelet 預設是 85% 和 80%）；另外，閒置超過 7 天的映像，不管磁碟用量多少都回收。&lt;/p&gt;

&lt;p&gt;有一點要知道：Kubernetes 文件提到，&lt;code&gt;imageMaximumGCAge&lt;/code&gt; &lt;strong&gt;不會跨 kubelet 重啟追蹤&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;kubelet 一重啟，計時就歸零，要重新等滿這段時間。&lt;/p&gt;

&lt;p&gt;所以節點常常重啟的話，這個條件很難真正觸發，主要還是靠磁碟門檻在回收。&lt;/p&gt;

&lt;p&gt;然後在 &lt;code&gt;cluster.yaml&lt;/code&gt; 把它掛進每個節點，並讓 kubelet 讀取那個目錄：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;volume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/kubelet-image-gc.conf:/etc/rancher/k3s/kubelet.conf.d/50-image-gc.conf&lt;/span&gt;
    &lt;span class="na"&gt;nodeFilters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;k3s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;arg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--kubelet-arg=config-dir=/etc/rancher/k3s/kubelet.conf.d&lt;/span&gt;
        &lt;span class="na"&gt;nodeFilters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  實際效果
&lt;/h3&gt;

&lt;p&gt;改用 registry、調整 kubelet 回收之後，節點的磁碟用量差很多。&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;改之前&lt;/th&gt;
&lt;th&gt;改之後&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;每個節點的 &lt;code&gt;/var/lib/rancher/k3s&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;約 25 GB&lt;/td&gt;
&lt;td&gt;約 5 到 11 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;本機 registry&lt;/td&gt;
&lt;td&gt;沒有&lt;/td&gt;
&lt;td&gt;約 3.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;這組數字要稍微保守看待。&lt;/p&gt;

&lt;p&gt;「改之前」是當時量到的，現在已經沒辦法重現。&lt;/p&gt;

&lt;p&gt;「改之後」是現在量的。這個目錄除了映像，還包含容器的可寫層、log 等，所以不同節點差異會比較大，取決於上面跑了哪些 Pod。&lt;/p&gt;

&lt;p&gt;所以拿來看趨勢就好，不是嚴格的前後對照實驗。&lt;/p&gt;

&lt;h3&gt;
  
  
  如果你已經有叢集了
&lt;/h3&gt;

&lt;p&gt;不用重建。&lt;/p&gt;

&lt;p&gt;做法是在每個節點寫一份 &lt;code&gt;/etc/rancher/k3s/registries.yaml&lt;/code&gt;（內容就是上面的 mirror 設定），再逐一重啟節點。&lt;/p&gt;

&lt;p&gt;要注意重啟節點時，上面的 Pod 也會跟著重啟，跟主機重開機差不多，挑個不忙的時間做。&lt;/p&gt;

&lt;p&gt;流程可以寫成一個小 script：先把節點上現有的映像推進 registry，再寫設定，最後一個一個重啟。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;registry:2&lt;/code&gt; 的 GC 會把映像刪壞&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這個坑我踩得最久。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--delete-untagged&lt;/code&gt; 在 &lt;code&gt;registry:2&lt;/code&gt; 上，會把 OCI index 底下的子 manifest 當成沒有 tag 的東西刪掉。而用較新版 Docker（containerd image store）push 上去的映像，剛好就是 OCI index。&lt;/p&gt;

&lt;p&gt;結果就是 GC 跑完，映像整個拉不下來。&lt;/p&gt;

&lt;p&gt;換成 &lt;code&gt;registry:3&lt;/code&gt; 之後就正常了，所以一開始就選 v3。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;imageMaximumGCAge&lt;/code&gt; 沒有對應的命令列參數&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;本來想用 &lt;code&gt;--kubelet-arg&lt;/code&gt; 直接傳，結果 kubelet 直接起不來。&lt;/p&gt;

&lt;p&gt;它只能寫在 &lt;code&gt;KubeletConfiguration&lt;/code&gt; 檔案裡，所以才要用 &lt;code&gt;config-dir&lt;/code&gt; 這種 drop-in 的方式。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 先別在節點上跑 &lt;code&gt;crictl rmi --prune&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;在映像還沒進 registry 之前，這個指令會把節點上「目前沒在使用」的映像一起刪掉。&lt;/p&gt;

&lt;p&gt;而那些映像又沒有別的地方可以拉回來，之後排到那個節點的排程工作就會 &lt;code&gt;ImagePullBackOff&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;我就是這樣被咬過一次，最後從別的節點把映像複製回來才解決。&lt;/p&gt;

&lt;p&gt;順序應該是：先把映像都放進 registry，再交給 kubelet 的 GC 去清。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;考慮過兩種做法。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;直接用 &lt;code&gt;localhost:5001/myorg/app&lt;/code&gt; 這種名稱&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;好處是很明確，不會有「這個映像到底從哪來」的疑問。&lt;/p&gt;

&lt;p&gt;缺點是 Manifest 要改，而且 chart 要同時給本機和其他環境用，不想為了這個維護兩套映像名稱。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mirror &lt;code&gt;docker.io&lt;/code&gt;（最後選的）&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest 一個字都不用改，要換環境也不受影響。&lt;/p&gt;

&lt;p&gt;但缺點要記得：只要本機 registry 沒有，就會回退去 Docker Hub 拉&lt;strong&gt;同名&lt;/strong&gt;的映像。&lt;/p&gt;

&lt;p&gt;所以映像的命名空間（上面的 &lt;code&gt;myorg&lt;/code&gt;）一定要是自己在 Docker Hub 上擁有的。不然有人在那邊註冊同名映像，registry 沒東西時就會拉到別人的。&lt;/p&gt;

&lt;p&gt;如果只是做一次性的實驗，&lt;code&gt;k3d image import&lt;/code&gt; 其實完全夠用，不需要為了這個架 registry。&lt;/p&gt;

&lt;p&gt;映像很多、又會頻繁覆蓋同一個 tag 的情況下，才覺得值得。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://k3d.io/stable/usage/registries/" rel="noopener noreferrer"&gt;k3d：Using image registries&lt;/a&gt;：&lt;code&gt;registries.create&lt;/code&gt; 與 &lt;code&gt;registries.config&lt;/code&gt;（mirrors）的範例。這一頁的範例只示範了 &lt;code&gt;name&lt;/code&gt;，&lt;code&gt;host&lt;/code&gt;、&lt;code&gt;hostPort&lt;/code&gt;、&lt;code&gt;image&lt;/code&gt;、&lt;code&gt;volumes&lt;/code&gt; 這些欄位要看 &lt;a href="https://github.com/k3d-io/k3d/blob/main/pkg/config/v1alpha5/schema.json" rel="noopener noreferrer"&gt;JSON Schema&lt;/a&gt;；順便一提，schema 裡 &lt;code&gt;image&lt;/code&gt; 的預設值是 &lt;code&gt;registry:2&lt;/code&gt;，所以想用 v3 要自己指定。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.k3s.io/installation/private-registry" rel="noopener noreferrer"&gt;K3s：Private Registry Configuration&lt;/a&gt;：節點上 &lt;code&gt;registries.yaml&lt;/code&gt; 的 mirror 寫法（既有叢集要手動寫的就是這個），以及預設 endpoint 一定會當最後手段的說明。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/" rel="noopener noreferrer"&gt;Kubernetes：Garbage collection&lt;/a&gt;：kubelet 依門檻回收映像的機制，以及 &lt;code&gt;imageMaximumGCAge&lt;/code&gt; 要寫在 kubelet 設定檔、重啟後計時歸零。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kubernetes.io/docs/reference/config-api/kubelet-config.v1beta1/" rel="noopener noreferrer"&gt;Kubernetes：Kubelet Configuration (v1beta1)&lt;/a&gt;：&lt;code&gt;imageGCHighThresholdPercent&lt;/code&gt;（預設 85）、&lt;code&gt;imageGCLowThresholdPercent&lt;/code&gt;（預設 80）、&lt;code&gt;imageMaximumGCAge&lt;/code&gt;（預設 &lt;code&gt;0s&lt;/code&gt;＝關閉）。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://distribution.github.io/distribution/about/garbage-collection/" rel="noopener noreferrer"&gt;Distribution：Garbage collection&lt;/a&gt;：&lt;code&gt;garbage-collect&lt;/code&gt; 的 &lt;code&gt;--dry-run&lt;/code&gt;、&lt;code&gt;--delete-untagged&lt;/code&gt;，以及「GC 時 registry 要唯讀或停止」的警告。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇的環境是 k3d v5.x、k3s 1.31。你的版本不同的話，記得對一下設定欄位。&lt;/p&gt;

</description>
      <category>k3d</category>
      <category>kubernetes</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>ArgoCD App of Apps 實作：一個 root Application 管理叢集裡所有的 App</title>
      <dc:creator>Jimmy</dc:creator>
      <pubDate>Fri, 16 Oct 2026 04:00:12 +0000</pubDate>
      <link>https://dev.to/jimmy_3c703d/argocd-app-of-apps-shi-zuo-ge-root-application-guan-li-cong-ji-li-suo-you-de-app-dcg</link>
      <guid>https://dev.to/jimmy_3c703d/argocd-app-of-apps-shi-zuo-ge-root-application-guan-li-cong-ji-li-suo-you-de-app-dcg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;一句話結論：把 ArgoCD 裝好之後，只手動 apply &lt;strong&gt;一個&lt;/strong&gt; root Application，讓它掃描 Git 裡的某個目錄；之後要新增、修改、移除任何 App，都只要改 Git，不用再碰 &lt;code&gt;kubectl apply&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  遇到的問題
&lt;/h2&gt;

&lt;p&gt;GitOps 的第一步，通常是為每個服務建立一個 ArgoCD Application。&lt;/p&gt;

&lt;p&gt;服務少的時候沒問題。服務一多，就變成每新增一個，都要手動 &lt;code&gt;kubectl apply&lt;/code&gt; 一份 Application 的 YAML。&lt;/p&gt;

&lt;p&gt;這等於在 GitOps 裡留了一個沒被 GitOps 管的缺口：Application 本身不在 Git 的管理範圍內。&lt;/p&gt;

&lt;p&gt;解法是 &lt;strong&gt;App of Apps&lt;/strong&gt;：用一個 Application，去管理其他所有 Application。&lt;/p&gt;

&lt;p&gt;這篇用最小的例子，從安裝 ArgoCD 開始，走完整個流程。&lt;/p&gt;

&lt;h2&gt;
  
  
  手把手步驟
&lt;/h2&gt;

&lt;h3&gt;
  
  
  步驟一：用 Helm 裝 ArgoCD
&lt;/h3&gt;

&lt;p&gt;前提是手邊已經有一個 Kubernetes 叢集，minikube、kind 或雲端的都可以。&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add argo https://argoproj.github.io/argo-helm
helm repo update

helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; argocd argo/argo-cd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; argocd &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 15m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;確認 ArgoCD 起來了：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get pods
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout status deploy/argocd-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;官方的 Getting Started 是用 &lt;code&gt;kubectl apply&lt;/code&gt; 安裝 manifest。這裡改用 Helm，之後想調整參數（資源限制、Ingress 等）比較方便，也方便升級。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟二：登入 UI 看一下
&lt;/h3&gt;

&lt;p&gt;最簡單的方式是 port-forward：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward svc/argocd-server &lt;span class="nt"&gt;-n&lt;/span&gt; argocd 8080:443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;然後用瀏覽器打開 &lt;code&gt;https://localhost:8080&lt;/code&gt;（憑證是自簽的，瀏覽器會警告）。&lt;/p&gt;

&lt;p&gt;帳號是 &lt;code&gt;admin&lt;/code&gt;，初始密碼這樣取：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd admin initial-password &lt;span class="nt"&gt;-n&lt;/span&gt; argocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;沒有裝 &lt;code&gt;argocd&lt;/code&gt; CLI 的話，也可以直接讀 Secret：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-initial-admin-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;官方文件提醒：登入並改完密碼之後，要把 &lt;code&gt;argocd-initial-admin-secret&lt;/code&gt; 刪掉。這個 Secret 只是初始用，留著沒有意義。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  步驟三：準備 Git repo 的結構
&lt;/h3&gt;

&lt;p&gt;建一個 Git repo，結構長這樣：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gitops/
├── bootstrap/
│   └── root-app.yaml     # 手動 apply 一次
└── apps/                  # root 會掃描這個目錄
    └── guestbook.yaml     # 一個子 Application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;兩個目錄要分開：&lt;code&gt;bootstrap/&lt;/code&gt; 放只需要手動套用一次的東西，&lt;code&gt;apps/&lt;/code&gt; 放 root 要持續掃描的東西。&lt;/p&gt;

&lt;p&gt;不要把 root 自己放進 &lt;code&gt;apps/&lt;/code&gt;，否則會變成 root 在管理自己，行為難以預期，也很難理解。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟四：寫一個子 Application
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;apps/guestbook.yaml&lt;/code&gt;，使用 ArgoCD 官方的範例 repo，大家都能拿到：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;guestbook&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;finalizers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;resources-finalizer.argocd.argoproj.io&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/argoproj/argocd-example-apps.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HEAD&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;guestbook&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;guestbook&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;幾個欄位值得說明一下。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;namespace&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Application 這個物件本身預設要放在 &lt;code&gt;argocd&lt;/code&gt; namespace。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spec.destination.namespace&lt;/code&gt; 才是這個 App 的資源要部署到哪裡。兩個很容易搞混。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;finalizer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resources-finalizer.argocd.argoproj.io&lt;/code&gt; 的作用是：刪除這個 Application 時，連它部署出來的資源也一起刪掉（預設是前景的 cascading deletion）。&lt;/p&gt;

&lt;p&gt;沒有加的話，刪掉 Application 只會移除 ArgoCD 的紀錄，資源還留在叢集裡。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;syncPolicy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;automated&lt;/code&gt; 打開自動同步。&lt;code&gt;prune: true&lt;/code&gt; 代表 Git 裡移除的資源，叢集裡也會被刪掉。&lt;code&gt;selfHeal: true&lt;/code&gt; 代表有人手動改了叢集內容，ArgoCD 會把它改回 Git 的樣子。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CreateNamespace=true&lt;/code&gt; 讓 ArgoCD 在目標 namespace 不存在時自動建立。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟五：寫 root Application
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;bootstrap/root-app.yaml&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;finalizers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;resources-finalizer.argocd.argoproj.io&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/&amp;lt;你的帳號&amp;gt;/gitops.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;recurse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這份 YAML 跟子 Application 長得幾乎一樣，差別有兩個。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;path: apps&lt;/code&gt; 加 &lt;code&gt;directory.recurse: true&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;path&lt;/code&gt; 指向要掃描的目錄。ArgoCD 預設只讀該目錄第一層的檔案，要往下一層一層讀，要設 &lt;code&gt;recurse: true&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;所以之後可以在 &lt;code&gt;apps/&lt;/code&gt; 底下用子資料夾整理，root 一樣讀得到。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;destination 的 namespace 是 &lt;code&gt;argocd&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;因為 root 部署出來的東西就是「Application 這種物件」，而它們預設要在 &lt;code&gt;argocd&lt;/code&gt; namespace。&lt;/p&gt;

&lt;p&gt;把這個 repo 推到 Git，repoURL 換成自己的。如果是私有 repo，要先在 ArgoCD 設定 repo 的存取憑證。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟六：套用 root，看它長出子 App
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; bootstrap/root-app.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;這是&lt;strong&gt;唯一&lt;/strong&gt;一次要手動 apply 的東西。&lt;/p&gt;

&lt;p&gt;看一下結果：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get applications &lt;span class="nt"&gt;-n&lt;/span&gt; argocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;預期會看到 &lt;code&gt;root&lt;/code&gt; 與 &lt;code&gt;guestbook&lt;/code&gt; 兩個，狀態慢慢變成 &lt;code&gt;Synced&lt;/code&gt; 與 &lt;code&gt;Healthy&lt;/code&gt;：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME        SYNC STATUS   HEALTH STATUS
guestbook   Synced        Healthy
root        Synced        Healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArgoCD 預設大約每 3 分鐘輪詢一次 Git，所以 push 之後可能要等一下。急的話，在 UI 上按 Refresh 就會立刻檢查。&lt;/p&gt;

&lt;h3&gt;
  
  
  步驟七：新增、移除 App，只改 Git
&lt;/h3&gt;

&lt;p&gt;要新增一個 App，在 &lt;code&gt;apps/&lt;/code&gt; 加一個 Application 的 YAML，commit、push。&lt;/p&gt;

&lt;p&gt;等下一輪輪詢（或按 Refresh），root 就會偵測到新檔案，建立對應的 Application，新的 App 再各自同步。&lt;/p&gt;

&lt;p&gt;要移除，把檔案刪掉再 push。因為 root 有 &lt;code&gt;prune: true&lt;/code&gt;，那個 Application 會被刪除，加上子 Application 的 finalizer，它部署的資源也會一起清掉。&lt;/p&gt;

&lt;p&gt;全程不需要再對叢集下 &lt;code&gt;kubectl apply&lt;/code&gt;。&lt;/p&gt;

&lt;h2&gt;
  
  
  踩到的坑
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 刪掉 root，會連帶刪光所有子 App 和它們的資源&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;root 有加 finalizer，所以刪除它會級聯往下刪：先刪子 Application，子 Application 因為也有 finalizer，再刪它們部署的資源。&lt;/p&gt;

&lt;p&gt;換句話說，一個 &lt;code&gt;kubectl delete application root&lt;/code&gt;，就能把整個叢集的服務清空。&lt;/p&gt;

&lt;p&gt;官方的 Cluster Bootstrapping 文件有專門一節在講這件事。&lt;/p&gt;

&lt;p&gt;如果不想要這種風險，可以考慮不給 root 加 finalizer，或是關掉 root 的 &lt;code&gt;prune&lt;/code&gt;。取捨是：以後刪除 root 或移除子 Application 時，要自己處理殘留的資源。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 子 Application 本身的設定，是由 root 負責同步的&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;這個坑很隱蔽。假設改了某個子 Application 的設定（例如 plugin 的環境變數、&lt;code&gt;syncOptions&lt;/code&gt;），push 之後，如果 root 還沒同步這個改動，子 Application 會繼續用舊的設定渲染，狀態還顯示 &lt;code&gt;Synced&lt;/code&gt;，但新開的資源就是沒有出現。&lt;/p&gt;

&lt;p&gt;我就是這樣被騙過：看起來一切正常，卻怎麼等都沒有東西。&lt;/p&gt;

&lt;p&gt;處理方式是：先確認 root 已經是 &lt;code&gt;Synced&lt;/code&gt;，再對子 Application 做一次 hard refresh：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd annotate application &amp;lt;名稱&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  argocd.argoproj.io/refresh&lt;span class="o"&gt;=&lt;/span&gt;hard &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hard&lt;/code&gt; 的意思是連 manifest 快取都清掉再重新評估。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;recurse: true&lt;/code&gt; 會讀到目錄裡所有的 YAML&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;root 掃描的目錄裡，只應該放 Application 的 manifest。&lt;/p&gt;

&lt;p&gt;如果裡面混了其他 YAML（文件、範例、值檔），ArgoCD 會把它們都當成要部署的資源，可能直接報錯，或是意外建立不該存在的東西。&lt;/p&gt;

&lt;p&gt;把「手動套用一次的東西」和「持續掃描的東西」分在不同目錄，就是為了避免這個問題。&lt;/p&gt;

&lt;h2&gt;
  
  
  為什麼這樣選
&lt;/h2&gt;

&lt;p&gt;官方的 Cluster Bootstrapping 文件，推薦的其實是 &lt;strong&gt;ApplicationSet&lt;/strong&gt;，App of Apps 被列為替代方案。&lt;/p&gt;

&lt;p&gt;ApplicationSet 適合「很多個長得很像的 App」或「要部署到很多個叢集」，用 generator 一次產生一堆 Application，不用寫一堆 YAML。&lt;/p&gt;

&lt;p&gt;我選 App of Apps 的原因很單純：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;一個 App 一個檔案，看檔案就知道它的設定，容易讀。&lt;/li&gt;
&lt;li&gt;每個 App 的設定可以各自不同（有的自動同步，有的手動）。&lt;/li&gt;
&lt;li&gt;概念簡單，不需要先學 generator 和模板語法。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;單一叢集、App 數量不多、每個差異不小的情況，這樣就夠了。要管很多叢集或很多相似的 App，可以直接看 ApplicationSet。&lt;/p&gt;

&lt;p&gt;另外有一個官方的安全警告值得記住：&lt;strong&gt;App of Apps 是管理員專用的工具&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;能在任意 Project 底下建立 Application，等於是管理員等級的權限。所以只有管理員能對 root 所指向的 repo 推送，改動也應該經過審核。&lt;/p&gt;

&lt;h2&gt;
  
  
  延伸閱讀
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/" rel="noopener noreferrer"&gt;Argo CD：Cluster Bootstrapping&lt;/a&gt;：App of Apps 的官方說明、Helm 範例、級聯刪除，以及 ApplicationSet 的推薦做法。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/" rel="noopener noreferrer"&gt;Argo CD：Automated Sync Policy&lt;/a&gt;：&lt;code&gt;prune&lt;/code&gt;、&lt;code&gt;selfHeal&lt;/code&gt;，以及輪詢間隔（預設 120 秒加最多 60 秒的抖動）。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/app_deletion/" rel="noopener noreferrer"&gt;Argo CD：App Deletion&lt;/a&gt;：&lt;code&gt;resources-finalizer.argocd.argoproj.io&lt;/code&gt; 與級聯刪除的行為。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/directory/" rel="noopener noreferrer"&gt;Argo CD：Directory Application Source&lt;/a&gt;：&lt;code&gt;directory.recurse&lt;/code&gt; 的用法。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/getting_started/" rel="noopener noreferrer"&gt;Argo CD：Getting Started&lt;/a&gt;：初始管理員密碼、port-forward 與登入。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/annotations-and-labels/" rel="noopener noreferrer"&gt;Argo CD：Annotations and Labels&lt;/a&gt;：&lt;code&gt;argocd.argoproj.io/refresh&lt;/code&gt; 的 &lt;code&gt;normal&lt;/code&gt; 與 &lt;code&gt;hard&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/argoproj/argocd-example-apps" rel="noopener noreferrer"&gt;argocd-example-apps&lt;/a&gt;：官方範例 repo，裡面的 &lt;code&gt;guestbook&lt;/code&gt; 就是本篇用的，&lt;code&gt;apps&lt;/code&gt; 目錄是官方的 App of Apps 範例。&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/argoproj/argo-helm/tree/main/charts/argo-cd" rel="noopener noreferrer"&gt;argo-helm：argo-cd chart&lt;/a&gt;：用 Helm 安裝與調整參數。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;這篇用的是 ArgoCD 的 &lt;code&gt;stable&lt;/code&gt; 文件與 argo-helm 的 &lt;code&gt;argo-cd&lt;/code&gt; chart。版本不同的話，記得對一下欄位名稱。&lt;/p&gt;

</description>
      <category>argocd</category>
      <category>gitops</category>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
