<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Self-Hosted on Tarragon</title><link>https://tarrragon.github.io/blog/tags/self-hosted/</link><description>Recent content in Self-Hosted on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/self-hosted/index.xml" rel="self" type="application/rss+xml"/><item><title>自架 vs 商業的判斷決策表</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/</guid><description>&lt;p>自架監控和商業方案之間的選擇取決於四個維度的組合。每個維度有明確的閾值 — 超過閾值時自架的成本開始高於商業方案的訂閱費。&lt;/p>
&lt;h2 id="四個判斷維度">四個判斷維度&lt;/h2>
&lt;h3 id="使用者數">使用者數&lt;/h3>
&lt;p>自架方案的成本和使用者數幾乎無關（JSONL + grep 處理 1 個和 100 個使用者的成本差異很小）。商業方案按事件量或使用者數計費，使用者數增長直接推高費用。&lt;/p>
&lt;p>&lt;strong>經驗估算&lt;/strong>：使用者數在百人以下時，自架的總成本（開發 + 維護 + 硬體）通常低於商業方案的年費（以典型商業方案年費 $300-$600 和自架的開發維護時間估算）。使用者數在千人以上時，自架需要投入的基礎設施維護（高可用、擴容、備份）成本上升，商業方案的規模經濟開始有優勢。具體的交叉點取決於選用的 vendor 定價（Sentry Developer plan 免費額度 5000 events/月、PostHog 免費到 1M events/月）和自架的維護時間成本。&lt;/p>
&lt;p>兩者之間是灰色地帶 — 取決於功能需求和團隊能力。&lt;/p>
&lt;h3 id="網路範圍">網路範圍&lt;/h3>
&lt;p>使用者和 collector 是否在同一個網路內。&lt;/p>
&lt;p>&lt;strong>同一網路&lt;/strong>（自用工具、內部工具）：自架方案直接 HTTP POST 到本機或內網 endpoint，不需要 DNS、TLS 憑證、CDN。成本極低。&lt;/p>
&lt;p>&lt;strong>外部網路&lt;/strong>（公開 app、SaaS）：自架方案需要處理公網暴露、DDoS 防護、TLS 憑證管理、高可用（多區域部署）。商業方案把這些基礎設施問題內化了。&lt;/p>
&lt;h3 id="功能需求">功能需求&lt;/h3>
&lt;p>自架方案的功能上限是開發者願意投入的工程量。grep + jq 能做基礎查詢和 funnel 分析（&lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 自架 funnel&lt;/a>）。Dashboard、告警、session replay、A/B test 分群每個功能都是數週到數月的開發量。&lt;/p>
&lt;p>商業方案的功能開箱即用。如果需求包含 session replay、A/B test dashboard、自動 issue 分群，商業方案的功能完成度遠高於自架。&lt;/p>
&lt;h3 id="合規要求">合規要求&lt;/h3>
&lt;p>資料必須存放在特定地區（GDPR data residency）或不能離開公司網路（金融、醫療）。&lt;/p>
&lt;p>&lt;strong>自架&lt;/strong>：資料完全在自己的基礎設施上，資料位置由自己控制。適合最嚴格的合規要求。&lt;/p>
&lt;p>&lt;strong>商業方案&lt;/strong>：資料存放在 vendor 的基礎設施上。部分 vendor 提供 data residency 選項（Sentry 的 EU hosting、Datadog 的 EU region），但仍然是第三方持有資料。&lt;/p>
&lt;h2 id="決策表">決策表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>自架有利&lt;/th>
 &lt;th>商業方案有利&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者數&lt;/td>
 &lt;td>&amp;lt; 100&lt;/td>
 &lt;td>&amp;gt; 1000&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路範圍&lt;/td>
 &lt;td>同一網路&lt;/td>
 &lt;td>外部網路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>功能需求&lt;/td>
 &lt;td>查詢 + 基礎分析&lt;/td>
 &lt;td>Dashboard + 告警 + replay&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規要求&lt;/td>
 &lt;td>資料不能離開自有設施&lt;/td>
 &lt;td>無特殊限制&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四個維度中三個以上指向同一方向 → 選那個方向。兩兩對半 → 從自架開始（成本低、可逆），需求增長後再評估切換。&lt;/p>
&lt;p>決策表指向商業方案後，&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &amp;#43; performance monitoring &amp;#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入&lt;/a>和 &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &amp;#43; Analytics &amp;#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件&lt;/a>分別展開兩個主流方案的架構和能力邊界。決策表指向自架時，&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>提供從 HTTP endpoint 到 rule engine 的完整實作藍圖。Server-side 的可觀測性（OTLP、Prometheus、Grafana）見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四 可觀測性&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>自架監控和商業方案之間的選擇取決於四個維度的組合。每個維度有明確的閾值 — 超過閾值時自架的成本開始高於商業方案的訂閱費。</p>
<h2 id="四個判斷維度">四個判斷維度</h2>
<h3 id="使用者數">使用者數</h3>
<p>自架方案的成本和使用者數幾乎無關（JSONL + grep 處理 1 個和 100 個使用者的成本差異很小）。商業方案按事件量或使用者數計費，使用者數增長直接推高費用。</p>
<p><strong>經驗估算</strong>：使用者數在百人以下時，自架的總成本（開發 + 維護 + 硬體）通常低於商業方案的年費（以典型商業方案年費 $300-$600 和自架的開發維護時間估算）。使用者數在千人以上時，自架需要投入的基礎設施維護（高可用、擴容、備份）成本上升，商業方案的規模經濟開始有優勢。具體的交叉點取決於選用的 vendor 定價（Sentry Developer plan 免費額度 5000 events/月、PostHog 免費到 1M events/月）和自架的維護時間成本。</p>
<p>兩者之間是灰色地帶 — 取決於功能需求和團隊能力。</p>
<h3 id="網路範圍">網路範圍</h3>
<p>使用者和 collector 是否在同一個網路內。</p>
<p><strong>同一網路</strong>（自用工具、內部工具）：自架方案直接 HTTP POST 到本機或內網 endpoint，不需要 DNS、TLS 憑證、CDN。成本極低。</p>
<p><strong>外部網路</strong>（公開 app、SaaS）：自架方案需要處理公網暴露、DDoS 防護、TLS 憑證管理、高可用（多區域部署）。商業方案把這些基礎設施問題內化了。</p>
<h3 id="功能需求">功能需求</h3>
<p>自架方案的功能上限是開發者願意投入的工程量。grep + jq 能做基礎查詢和 funnel 分析（<a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">模組八 自架 funnel</a>）。Dashboard、告警、session replay、A/B test 分群每個功能都是數週到數月的開發量。</p>
<p>商業方案的功能開箱即用。如果需求包含 session replay、A/B test dashboard、自動 issue 分群，商業方案的功能完成度遠高於自架。</p>
<h3 id="合規要求">合規要求</h3>
<p>資料必須存放在特定地區（GDPR data residency）或不能離開公司網路（金融、醫療）。</p>
<p><strong>自架</strong>：資料完全在自己的基礎設施上，資料位置由自己控制。適合最嚴格的合規要求。</p>
<p><strong>商業方案</strong>：資料存放在 vendor 的基礎設施上。部分 vendor 提供 data residency 選項（Sentry 的 EU hosting、Datadog 的 EU region），但仍然是第三方持有資料。</p>
<h2 id="決策表">決策表</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>自架有利</th>
          <th>商業方案有利</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者數</td>
          <td>&lt; 100</td>
          <td>&gt; 1000</td>
      </tr>
      <tr>
          <td>網路範圍</td>
          <td>同一網路</td>
          <td>外部網路</td>
      </tr>
      <tr>
          <td>功能需求</td>
          <td>查詢 + 基礎分析</td>
          <td>Dashboard + 告警 + replay</td>
      </tr>
      <tr>
          <td>合規要求</td>
          <td>資料不能離開自有設施</td>
          <td>無特殊限制</td>
      </tr>
  </tbody>
</table>
<p>四個維度中三個以上指向同一方向 → 選那個方向。兩兩對半 → 從自架開始（成本低、可逆），需求增長後再評估切換。</p>
<p>決策表指向商業方案後，<a href="/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &#43; performance monitoring &#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入</a>和 <a href="/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &#43; Analytics &#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件</a>分別展開兩個主流方案的架構和能力邊界。決策表指向自架時，<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a>提供從 HTTP endpoint 到 rule engine 的完整實作藍圖。Server-side 的可觀測性（OTLP、Prometheus、Grafana）見 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 模組四 可觀測性</a>。</p>
<h2 id="中間路線">中間路線</h2>
<p>上表是「完全自架 vs 專業監控 SaaS」的兩端。中間還有兩條路徑 — 用 BaaS（Supabase + Vercel）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。APP 上線初期用免費方案零成本起步、保留自訂 schema 彈性是常見的起步策略。完整的四條路徑比較、架構差異、免費方案限額和遷移路線見<a href="/blog/monitoring/06-commercial-comparison/deployment-spectrum/" data-link-title="部署光譜：從 BaaS 到自架的四條路徑" data-link-desc="監控方案的部署選擇不是二元的 — BaaS &#43; Serverless 和 PaaS 是完全自架和商業 SaaS 之間兩條常被忽略的中間路徑">部署光譜</a>。</p>
]]></content:encoded></item><item><title>部署光譜：從 BaaS 到自架的四條路徑</title><link>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/deployment-spectrum/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/deployment-spectrum/</guid><description>&lt;p>監控方案的選擇不是「完全自架 Go collector」和「買 Sentry 訂閱」的二元決策。中間存在兩條路徑 — 用 BaaS（Supabase / Firebase）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。四條路徑的本質差異在「哪些層自己管、哪些交給平台」。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表&lt;/a>用四個維度（使用者數 / 網路範圍 / 功能需求 / 合規）做二元分流。本章把光譜展開成四條路徑，讓中間的 BaaS 和 PaaS 選項浮現。Backend 選型模組已建立了完整的交付形態光譜（&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型&lt;/a>）和逐能力判斷外包深度的框架（&lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建&lt;/a>）。本章把那個框架特化到監控場景。&lt;/p>
&lt;h2 id="四條路徑">四條路徑&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>路徑&lt;/th>
 &lt;th>代表方案&lt;/th>
 &lt;th>Collector 是什麼&lt;/th>
 &lt;th>Storage 是什麼&lt;/th>
 &lt;th>自己管什麼&lt;/th>
 &lt;th>平台管什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>A. 商業監控 SaaS&lt;/td>
 &lt;td>Sentry / Datadog / Firebase Analytics&lt;/td>
 &lt;td>vendor 提供&lt;/td>
 &lt;td>vendor 提供&lt;/td>
 &lt;td>SDK 埋點&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B. BaaS + Serverless&lt;/td>
 &lt;td>Supabase + Vercel / Cloudflare Workers&lt;/td>
 &lt;td>serverless function（自己寫）&lt;/td>
 &lt;td>managed PostgreSQL（Supabase）&lt;/td>
 &lt;td>collector 邏輯、schema&lt;/td>
 &lt;td>server 維運、DB 維運、TLS、HA&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C. PaaS&lt;/td>
 &lt;td>Railway / Fly.io / Render&lt;/td>
 &lt;td>Go binary（自架 collector 原始碼）&lt;/td>
 &lt;td>SQLite（同 binary）或 managed DB&lt;/td>
 &lt;td>collector 邏輯、storage&lt;/td>
 &lt;td>server 維運、TLS、deploy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D. 完全自架&lt;/td>
 &lt;td>VPS + Go binary&lt;/td>
 &lt;td>Go binary&lt;/td>
 &lt;td>SQLite 或自管 PostgreSQL&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>路徑 A 和 D 分別是光譜的兩端 — &lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &amp;#43; performance monitoring &amp;#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &amp;#43; Analytics &amp;#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件&lt;/a>和&lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計&lt;/a>已完整討論。以下展開路徑 B 和 C。&lt;/p></description><content:encoded><![CDATA[<p>監控方案的選擇不是「完全自架 Go collector」和「買 Sentry 訂閱」的二元決策。中間存在兩條路徑 — 用 BaaS（Supabase / Firebase）搭出託管版 collector，或用 PaaS（Railway / Fly.io）跑自架 collector 原始碼但不管 server。四條路徑的本質差異在「哪些層自己管、哪些交給平台」。</p>
<p><a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a>用四個維度（使用者數 / 網路範圍 / 功能需求 / 合規）做二元分流。本章把光譜展開成四條路徑，讓中間的 BaaS 和 PaaS 選項浮現。Backend 選型模組已建立了完整的交付形態光譜（<a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型</a>）和逐能力判斷外包深度的框架（<a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建</a>）。本章把那個框架特化到監控場景。</p>
<h2 id="四條路徑">四條路徑</h2>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>代表方案</th>
          <th>Collector 是什麼</th>
          <th>Storage 是什麼</th>
          <th>自己管什麼</th>
          <th>平台管什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 商業監控 SaaS</td>
          <td>Sentry / Datadog / Firebase Analytics</td>
          <td>vendor 提供</td>
          <td>vendor 提供</td>
          <td>SDK 埋點</td>
          <td>全部</td>
      </tr>
      <tr>
          <td>B. BaaS + Serverless</td>
          <td>Supabase + Vercel / Cloudflare Workers</td>
          <td>serverless function（自己寫）</td>
          <td>managed PostgreSQL（Supabase）</td>
          <td>collector 邏輯、schema</td>
          <td>server 維運、DB 維運、TLS、HA</td>
      </tr>
      <tr>
          <td>C. PaaS</td>
          <td>Railway / Fly.io / Render</td>
          <td>Go binary（自架 collector 原始碼）</td>
          <td>SQLite（同 binary）或 managed DB</td>
          <td>collector 邏輯、storage</td>
          <td>server 維運、TLS、deploy</td>
      </tr>
      <tr>
          <td>D. 完全自架</td>
          <td>VPS + Go binary</td>
          <td>Go binary</td>
          <td>SQLite 或自管 PostgreSQL</td>
          <td>全部</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>路徑 A 和 D 分別是光譜的兩端 — <a href="/blog/monitoring/06-commercial-comparison/sentry-deep-dive/" data-link-title="Sentry 深入" data-link-desc="Error tracking &#43; performance monitoring &#43; session replay 的架構 — Sentry 從 error-first 出發如何擴展到全面可觀測性">Sentry 深入</a>、<a href="/blog/monitoring/06-commercial-comparison/firebase-suite/" data-link-title="Firebase 套件" data-link-desc="Crashlytics &#43; Analytics &#43; Remote Config 的整合 — Firebase 把 error tracking 和行為分析拆成獨立產品的設計取捨">Firebase 套件</a>和<a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a>已完整討論。以下展開路徑 B 和 C。</p>
<h2 id="路徑-bbaas--serverless">路徑 B：BaaS + Serverless</h2>
<p>APP 上線初期用 Supabase + Vercel（或 Cloudflare Workers）搭監控後端：serverless function 接收 SDK 送來的事件、驗證 schema 後寫入 Supabase 的 PostgreSQL。整條鏈路在免費方案額度內可以零成本運作。</p>
<h3 id="架構差異">架構差異</h3>
<p>Serverless function 沒有常駐 process。模組四假設的 Go single binary 架構 — channel 背壓、single-writer goroutine pattern、in-memory buffer — 在 serverless 環境都不適用。每個 HTTP request 是獨立的 function invocation，沒有跨 request 的記憶體狀態。</p>
<p>背壓機制需要重新設計：Go collector 用 channel 容量做背壓（channel 滿回 429），serverless 版改用 DB-level 的 rate limit（PostgreSQL 的 advisory lock 或外部 rate limiter 如 Upstash Redis）或 platform-level 的 quota（Vercel 的 concurrency limit）。SDK 端的 429 處理邏輯不需要改 — 不管背壓訊號來自 channel 還是 DB quota，SDK 都是收到 429 後降採樣。</p>
<p>Downsample 和 purge 在 Go collector 是 background goroutine 定期執行。Serverless 沒有 background job — 需要外部 cron trigger（Vercel Cron / Supabase pg_cron / GitHub Actions scheduled workflow）。</p>
<h3 id="免費方案限額">免費方案限額</h3>
<p>以下為 2026-06 查詢的各平台免費方案限額。平台定價會變動，決策前以官方定價頁為準。</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>免費方案限額</th>
          <th>對監控場景的意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Supabase Free</td>
          <td>500MB DB、50K MAU、500K Edge Function invocations/月</td>
          <td>500MB 約 50-100 萬筆事件（每筆 ~500 bytes）、自用場景可用數月</td>
      </tr>
      <tr>
          <td>Vercel Hobby</td>
          <td>100GB bandwidth、10s function timeout、無明確 invocation 上限</td>
          <td>瓶頸在 bandwidth 和 execution duration、非 invocation 數；timeout 對 ingestion 足夠</td>
      </tr>
      <tr>
          <td>Cloudflare Workers</td>
          <td>100K requests/天（免費）、D1 5GB</td>
          <td>100K requests/天 x 100 筆/batch = 10M events/天、D1 的 SQLite 可替代 Supabase</td>
      </tr>
  </tbody>
</table>
<p>Audit date: 2026-06。平台免費方案限額可能調整，決策前以官方定價頁為準。</p>
<h3 id="適合情境">適合情境</h3>
<p>路徑 B 適合以下組合：APP 上線初期（使用者數 &lt; 100）、團隊熟悉前端和 SQL 但不想管 server、想保留自訂 schema 和查詢彈性（商業 SaaS 的 schema 是 vendor 定義的）、零成本起步但未來可能遷到自架。</p>
<h3 id="撞牆訊號">撞牆訊號</h3>
<p>以下訊號出現時，代表路徑 B 的天花板已到、該評估遷到路徑 C 或 D：</p>
<p><strong>連線數瓶頸</strong>：Supabase Free 的 PostgreSQL 約 20 個 concurrent connection。Serverless function 每次 invocation 開新連線，高併發時可能耗盡連線池。Supabase 內建 PgBouncer 做 connection pooling 可緩解，但免費方案的 pooler 有自己的連線上限。</p>
<p><strong>Cold start 延遲</strong>：Vercel serverless function 的 cold start 約 200ms、Supabase Edge Function 約 100ms。對監控 ingestion（不是使用者面向 API）通常可接受，但如果 SDK 的 flush timeout 設得很短（&lt; 1s），cold start 可能造成偶發超時。</p>
<p><strong>Background job 限制</strong>：Downsample 和 purge 需要外部 cron。Vercel Hobby 支援最多 2 個 cron job、每個最頻繁每天觸發 1 次 — 如果需要每小時 downsample，要用 Supabase pg_cron（Free 方案支援）或外部 scheduler。</p>
<p><strong>免費額度耗盡</strong>：Supabase 的 500K Edge Function invocations/月 ≈ 每天 16K requests。如果每個 request 攢批 100 筆事件，可處理每天 160 萬筆事件。超過後進入按量付費。Vercel Hobby 無明確 invocation 上限、瓶頸在 bandwidth（100GB/月）和 execution duration。</p>
<p><strong>合規限制</strong>：Supabase Free 的 PostgreSQL 部署在特定 region。有 GDPR data residency 需求的 app（歐盟使用者的資料必須留在 EU）需確認 vendor 的 region 支援 — 免費方案的 region 選擇可能有限。</p>
<h2 id="路徑-cpaas">路徑 C：PaaS</h2>
<p>PaaS 跑的是和完全自架相同的 Go collector 原始碼，差異只在部署方式。<code>git push</code> 觸發自動 build 和 deploy，平台管 server provisioning、TLS 憑證、process supervision。Collector 的 channel 背壓、single-writer pattern、SQLite storage 全部適用 — 和本機開發環境的行為一致。</p>
<p>Railway 和 Fly.io 都支援 persistent volume — Railway Hobby 含 1GB、Fly.io Free 含 1GB（限單 region）。SQLite 的 WAL 檔案需要持久化，persistent volume 是必要條件。Render 的免費方案沒有 persistent disk — SQLite 在每次 deploy 後重置，不適合需要保留歷史事件的場景。PaaS 平台以 container 形式運行 collector，SQLite 在 container 中的 I/O 和持久化考量見 <a href="/blog/monitoring/04-collector/container-deployment/" data-link-title="Container 部署設計" data-link-desc="Docker 部署 collector 的設計 — SQLite 在 overlay filesystem 的 I/O 考量、volume mount、graceful shutdown、資源限制">Container 部署設計</a>。</p>
<p>路徑 C 適合：想用自架 collector 但不想管 server / TLS / systemd 的團隊。程式碼完全相同，遷到自架（路徑 D）的成本接近零 — 把 binary 複製到 VPS、設定 systemd service 就完成。</p>
<p>路徑 C 的天花板在平台定價 — Railway Hobby 有 $5/月的資源上限、Fly.io Free 有 3 個 shared VM。流量成長到免費額度不夠時，PaaS 的按量付費和 VPS 月租費的交叉點是遷到自架的判讀訊號。</p>
<h2 id="路徑間的遷移">路徑間的遷移</h2>
<p>遷移成本取決於起點和終點之間有多少層需要重寫。</p>
<table>
  <thead>
      <tr>
          <th>遷移方向</th>
          <th>成本</th>
          <th>主要工作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>B → C</td>
          <td>中</td>
          <td>Serverless function → Go binary（重寫 collector 邏輯）；DB 可保留或遷移</td>
      </tr>
      <tr>
          <td>B → D</td>
          <td>中</td>
          <td>同上 + 自己管 server</td>
      </tr>
      <tr>
          <td>C → D</td>
          <td>低</td>
          <td>同程式碼不同部署（複製 binary + systemd）</td>
      </tr>
      <tr>
          <td>D → C</td>
          <td>低</td>
          <td>同程式碼推到 PaaS</td>
      </tr>
      <tr>
          <td>D → A</td>
          <td>低</td>
          <td>SDK 改 endpoint 指向商業方案、不改 SDK 程式碼</td>
      </tr>
      <tr>
          <td>A → D</td>
          <td>高</td>
          <td>從零建 collector + storage + dashboard</td>
      </tr>
      <tr>
          <td>A → B</td>
          <td>高</td>
          <td>從零寫 serverless collector + 設定 managed DB</td>
      </tr>
      <tr>
          <td>A → C</td>
          <td>高</td>
          <td>從零寫 Go collector + 推到 PaaS</td>
      </tr>
  </tbody>
</table>
<p>路徑 B → C 或 B → D 的遷移代價主要在 collector 邏輯的重寫 — serverless function 的 request-level 處理和 Go binary 的 channel-based pipeline 是不同的架構，不能直接搬。資料層的遷移代價較低 — Supabase 的 PostgreSQL 資料可以用 <code>pg_dump</code> 匯出、匯入自管 PostgreSQL。</p>
<p>交付形態遷出的通用框架（資產線盤點、並行期設計、回切窗口）見 <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">託管形態遷出</a>。</p>
<h2 id="外包深度對照">外包深度對照</h2>
<p>用 <a href="/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度</a> 的三層框架（managed 基礎設施 / feature SaaS / BaaS bundle）看四條路徑：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>外包深度</th>
          <th>控制權</th>
          <th>遷出代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 商業監控 SaaS</td>
          <td>feature SaaS（最深）</td>
          <td>SDK 埋點 API、vendor 定義 schema 和查詢</td>
          <td>高</td>
      </tr>
      <tr>
          <td>B. BaaS + Serverless</td>
          <td>managed 基礎設施 + 自寫 function（中間）</td>
          <td>自訂 schema、自訂查詢、自訂 collector 邏輯</td>
          <td>中</td>
      </tr>
      <tr>
          <td>C. PaaS</td>
          <td>managed 基礎設施（淺）</td>
          <td>和自架相同、只有部署平台交出去</td>
          <td>低</td>
      </tr>
      <tr>
          <td>D. 完全自架</td>
          <td>不外包</td>
          <td>完全控制</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>路徑 B 在外包深度上介於 managed 基礎設施和 BaaS bundle 之間 — DB 和 runtime 交給平台，但 collector 邏輯和 schema 仍由開發者控制。這和 <a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a> 的「前端 SDK 直連平台資料庫」模式不同 — 監控場景的路徑 B 仍然有一個自己寫的中間層（serverless function），只是這個中間層跑在平台上而非自己的 server。</p>
<h2 id="選擇建議">選擇建議</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>建議路徑</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用工具、同機或同網段</td>
          <td>D</td>
          <td>成本最低、複雜度最低</td>
      </tr>
      <tr>
          <td>APP 上線初期、使用者 &lt; 100、零成本起步</td>
          <td>B 或 A</td>
          <td>B 保留自訂彈性、A 開箱即用</td>
      </tr>
      <tr>
          <td>小型團隊、想用自架 collector 但不想管 server</td>
          <td>C</td>
          <td>程式碼相同、部署簡單、遷出成本低</td>
      </tr>
      <tr>
          <td>使用者 &gt; 1000、需要 dashboard + 告警 + replay</td>
          <td>A</td>
          <td>商業方案的功能完成度遠高於自建</td>
      </tr>
      <tr>
          <td>合規要求資料不離開自有設施</td>
          <td>D</td>
          <td>完全控制資料位置</td>
      </tr>
  </tbody>
</table>
<p>APP 上線初期選 B 或 A 取決於自訂需求 — 需要自訂 schema 和查詢邏輯（例如自定義 error fingerprint、行為事件命名規範）選 B，只需要開箱即用的 error tracking 或行為分析選 A。B 保留遷到自架的彈性（資料在自己的 PostgreSQL），A 的功能完成度更高（dashboard、告警、session replay 開箱即用）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>自架 vs 商業的詳細決策 → <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">自架 vs 商業的判斷決策表</a></li>
<li>自架 collector 的完整設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>Backend 交付形態光譜 → <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型</a></li>
<li>能力級買 vs 建判斷 → <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">能力級買 vs 建</a></li>
<li>外包深度概念 → <a href="/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度</a></li>
<li>BaaS 概念 → <a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a></li>
<li>遷出劇本 → <a href="/blog/backend/10-system-evolution/managed-platform-exit/" data-link-title="10.3 託管形態遷出：資產線盤點與並行期執行" data-link-desc="0.21 升級自建 tripwire 觸發後的執行劇本 — 把遷出拆成資料、身分、流量、整合各自的可攜性與斷點、設計舊平台與新系統的並行期與回切窗口、用部分遷出作為中繼形態">託管形態遷出</a></li>
<li>Vendor lock-in 概念 → <a href="/blog/backend/knowledge-cards/vendor-lock-in/" data-link-title="Vendor Lock-In" data-link-desc="說明採用供應商產品後，其 API 與格式滲入程式碼造成的退出成本">Vendor Lock-In</a></li>
</ul>
]]></content:encoded></item><item><title>自架 log endpoint vs 商業方案的取捨判斷</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/</guid><description>&lt;p>Log 收集方案的選擇取決於兩個因素：使用者在哪裡（同機 / 同網段 / 外部網路），以及 log 的消費者是誰（開發者自己 / 維運團隊 / 客服團隊）。自用工具和商業產品對這兩個因素的答案不同，適合不同的方案。&lt;/p>
&lt;h2 id="自架-log-endpoint-的適用場景">自架 log endpoint 的適用場景&lt;/h2>
&lt;p>自架 log endpoint 適合的前提是：client 和 server 在同一個網路內（同機、同 LAN、同 VPN/tailnet），log 的唯一消費者是開發者本人。&lt;/p>
&lt;p>app_tunnel 就是這個場景。Server（ttyd）和 client（Flutter app）在同一台機器或同一個 Tailscale tailnet 內。開發者同時是使用者和維運者。Log 的消費方式是 grep — 不需要 dashboard、不需要告警、不需要多人共享。&lt;/p>
&lt;p>在這個場景下，自架 log endpoint 的成本遠低於商業方案。一個 Go 程式開 HTTP endpoint 接收 JSON log 寫入檔案，20 行程式碼就能完成。Client 端的 &lt;code>AppLogger&lt;/code> 在 debug mode 同時寫 console 和 POST 到 endpoint。Debug 時用 &lt;code>grep&lt;/code> + &lt;code>jq&lt;/code> 查詢，不需要額外工具。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Client (Flutter) → HTTP POST /log → Go receiver → JSON file → grep/jq&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個方案沒有外部依賴、沒有帳號管理、沒有費用、沒有資料隱私顧慮（log 不離開本機網路）。&lt;/p>
&lt;h2 id="商業方案的適用場景">商業方案的適用場景&lt;/h2>
&lt;p>商業方案（Sentry、Crashlytics、Datadog）適合的前提是：使用者分佈在外部網路，log 的消費者包含非開發者（維運、客服、產品），且需要告警和趨勢分析。&lt;/p>
&lt;p>商業方案提供的能力包括：跨網路收集（SDK 自動處理網路不穩定和批次傳輸）、多人查看 dashboard、告警規則設定、crash 報告自動分群、用戶 session 重播。這些能力在自用工具場景下不需要，在商業產品場景下是基礎需求。&lt;/p>
&lt;p>商業方案的成本包括：SDK 整合和設定、帳號和權限管理、月費（依事件量計費）、資料隱私合規（log 傳到第三方伺服器）。&lt;/p>
&lt;h2 id="判斷流程">判斷流程&lt;/h2>
&lt;h3 id="使用者在哪裡">使用者在哪裡&lt;/h3>
&lt;p>使用者和 server 在同一個網路內（自用工具、內部工具、開發期測試）→ 自架 log endpoint 是成本最低的選擇。&lt;/p>
&lt;p>使用者在外部網路（上架 app store、SaaS 產品、B2B 部署）→ 商業方案的跨網路收集能力是必要的，自架需要處理的 edge case（離線緩衝、重試、批次傳輸）太多。&lt;/p>
&lt;h3 id="log-消費者是誰">Log 消費者是誰&lt;/h3>
&lt;p>只有開發者自己 → grep/jq 足夠，不需要 dashboard。&lt;/p>
&lt;p>包含非技術人員（客服、產品經理）→ 需要視覺化 dashboard 和搜尋介面，商業方案的 UI 是這個需求的標準答案。&lt;/p>
&lt;h3 id="是否需要告警">是否需要告警&lt;/h3>
&lt;p>開發者自己用、即時看 log → 不需要告警。&lt;/p>
&lt;p>有維運值班、需要被動發現問題 → 需要告警規則，商業方案內建。&lt;/p>
&lt;h2 id="混合方案">混合方案&lt;/h2>
&lt;p>開發期用自架 log endpoint（零成本、即時可用），production 切換到商業方案 — 這個策略可行的前提是 log 層的 API 設計足夠抽象。&lt;/p>
&lt;p>&lt;code>AppLogger&lt;/code> 提供統一的 log 介面（&lt;code>log(level, name, data)&lt;/code>），底層實作在 debug mode 寫 console + POST 到本機 endpoint，在 release mode 寫 console + 呼叫 Sentry/Crashlytics SDK。切換只改 &lt;code>AppLogger&lt;/code> 的底層實作，不改呼叫端。&lt;/p>
&lt;p>這個抽象的投資在自用工具階段就值得做 — 即使目前不需要商業方案，統一的 log 介面也讓 log 點的管理更一致。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>三層 log 的詳細設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>在功能規格中定義 log 點 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法&lt;/a>&lt;/li>
&lt;li>Log 收集後的 schema 設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Log 收集方案的選擇取決於兩個因素：使用者在哪裡（同機 / 同網段 / 外部網路），以及 log 的消費者是誰（開發者自己 / 維運團隊 / 客服團隊）。自用工具和商業產品對這兩個因素的答案不同，適合不同的方案。</p>
<h2 id="自架-log-endpoint-的適用場景">自架 log endpoint 的適用場景</h2>
<p>自架 log endpoint 適合的前提是：client 和 server 在同一個網路內（同機、同 LAN、同 VPN/tailnet），log 的唯一消費者是開發者本人。</p>
<p>app_tunnel 就是這個場景。Server（ttyd）和 client（Flutter app）在同一台機器或同一個 Tailscale tailnet 內。開發者同時是使用者和維運者。Log 的消費方式是 grep — 不需要 dashboard、不需要告警、不需要多人共享。</p>
<p>在這個場景下，自架 log endpoint 的成本遠低於商業方案。一個 Go 程式開 HTTP endpoint 接收 JSON log 寫入檔案，20 行程式碼就能完成。Client 端的 <code>AppLogger</code> 在 debug mode 同時寫 console 和 POST 到 endpoint。Debug 時用 <code>grep</code> + <code>jq</code> 查詢，不需要額外工具。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Client (Flutter) → HTTP POST /log → Go receiver → JSON file → grep/jq</span></span></code></pre></div><p>這個方案沒有外部依賴、沒有帳號管理、沒有費用、沒有資料隱私顧慮（log 不離開本機網路）。</p>
<h2 id="商業方案的適用場景">商業方案的適用場景</h2>
<p>商業方案（Sentry、Crashlytics、Datadog）適合的前提是：使用者分佈在外部網路，log 的消費者包含非開發者（維運、客服、產品），且需要告警和趨勢分析。</p>
<p>商業方案提供的能力包括：跨網路收集（SDK 自動處理網路不穩定和批次傳輸）、多人查看 dashboard、告警規則設定、crash 報告自動分群、用戶 session 重播。這些能力在自用工具場景下不需要，在商業產品場景下是基礎需求。</p>
<p>商業方案的成本包括：SDK 整合和設定、帳號和權限管理、月費（依事件量計費）、資料隱私合規（log 傳到第三方伺服器）。</p>
<h2 id="判斷流程">判斷流程</h2>
<h3 id="使用者在哪裡">使用者在哪裡</h3>
<p>使用者和 server 在同一個網路內（自用工具、內部工具、開發期測試）→ 自架 log endpoint 是成本最低的選擇。</p>
<p>使用者在外部網路（上架 app store、SaaS 產品、B2B 部署）→ 商業方案的跨網路收集能力是必要的，自架需要處理的 edge case（離線緩衝、重試、批次傳輸）太多。</p>
<h3 id="log-消費者是誰">Log 消費者是誰</h3>
<p>只有開發者自己 → grep/jq 足夠，不需要 dashboard。</p>
<p>包含非技術人員（客服、產品經理）→ 需要視覺化 dashboard 和搜尋介面，商業方案的 UI 是這個需求的標準答案。</p>
<h3 id="是否需要告警">是否需要告警</h3>
<p>開發者自己用、即時看 log → 不需要告警。</p>
<p>有維運值班、需要被動發現問題 → 需要告警規則，商業方案內建。</p>
<h2 id="混合方案">混合方案</h2>
<p>開發期用自架 log endpoint（零成本、即時可用），production 切換到商業方案 — 這個策略可行的前提是 log 層的 API 設計足夠抽象。</p>
<p><code>AppLogger</code> 提供統一的 log 介面（<code>log(level, name, data)</code>），底層實作在 debug mode 寫 console + POST 到本機 endpoint，在 release mode 寫 console + 呼叫 Sentry/Crashlytics SDK。切換只改 <code>AppLogger</code> 的底層實作，不改呼叫端。</p>
<p>這個抽象的投資在自用工具階段就值得做 — 即使目前不需要商業方案，統一的 log 介面也讓 log 點的管理更一致。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>三層 log 的詳細設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>在功能規格中定義 log 點 → <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a></li>
<li>Log 收集後的 schema 設計 → <a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema</a></li>
</ul>
]]></content:encoded></item><item><title>Keycloak</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/keycloak/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/keycloak/</guid><description>&lt;p>Keycloak 是 open source 自管 Identity Provider、Red Hat 主導維護（商業支援版本為 Red Hat build of Keycloak、前身 Red Hat SSO）。它承擔的責任跟 SaaS IdP 相同 — SSO、MFA、federation、user lifecycle — 但 &lt;em>整個控制面留在組織自己手上&lt;/em>：issuer signing key、support tooling、底層 PostgreSQL、HA cluster、CVE patch cadence 全部自管。決定上 Keycloak 不是技術偏好、是組織決定把 SaaS IdP 的「第三方信任成本」換成「自家 SRE 運維成本 + 安全責任」。在 &lt;a href="https://tarrragon.github.io/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 能力級買 vs 建&lt;/a> 的光譜上、Keycloak 是認證能力「建」側的 canonical 例子 — 把 feature SaaS（Auth0 / Okta）的第三方信任成本、換成自管控制面的運維成本；什麼訊號該翻到這一側、見 0.22 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度&lt;/a> 卡。&lt;/p>
&lt;h2 id="服務定位">服務定位&lt;/h2>
&lt;p>Keycloak 是 &lt;em>自管控制面&lt;/em> 的 human identity 與 federation engine、不是 cloud resource permission engine。跟 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/okta/" data-link-title="Okta" data-link-desc="SaaS Identity Provider 主流選項、SSO / MFA / lifecycle 整合、第三方信任邊界的代價">Okta&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/auth0/" data-link-title="Auth0" data-link-desc="B2C / B2B Customer Identity Provider、Universal Login、Action / Rule hook、屬 Okta 旗下 Customer Identity Cloud">Auth0&lt;/a> 的本質差異在於信任邊界落點：SaaS IdP 把 signing key、tenant 隔離、support workflow 都託管出去、客戶承擔「供應商出事我也跟著被打」的風險；Keycloak 把整條控制面收回自家機房或自家 VPC、客戶承擔「signing key 過期 / DB 崩 / Java app CVE 沒跟上」的運維風險。&lt;/p>
&lt;p>跟 cloud-native SSO（&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/aws-iam-identity-center/" data-link-title="AWS IAM Identity Center" data-link-desc="AWS 原生 workforce SSO、前 AWS SSO、Permission Set 跨帳號 access、可串外部 IdP federation">AWS IAM Identity Center&lt;/a>）相比、Keycloak 的核心優勢是 &lt;em>不綁雲廠 + 可深度客製 authentication flow + 資料不出境&lt;/em>。適合垂直：金融、政府、醫療某些不接受 SaaS IdP 的場景；以及預算敏感、員工數中等、SRE 量能足以接 24/7 on-call 的組織。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本頁、讀者能判斷：&lt;/p>
&lt;ol>
&lt;li>Keycloak 該承擔哪一段 identity 控制（SSO / MFA / federation / brokering）、哪一段該交給雲端 IAM 或下游應用&lt;/li>
&lt;li>自管 IdP 的最低運維基線（HA、DB DR、cert / signing key rotation、CVE cadence、SIEM 接點）&lt;/li>
&lt;li>Realm / Client / User Federation / Identity Broker / Authentication Flow / SPI 各自的決策時機與陷阱&lt;/li>
&lt;li>何時用 Keycloak、何時改走 SaaS（Okta / Auth0）或其他 OSS（Authentik / Zitadel）&lt;/li>
&lt;/ol>
&lt;h2 id="最短判讀路徑">最短判讀路徑&lt;/h2>
&lt;p>判斷 Keycloak 部署是否健康、最少看 SaaS IdP 的四件事加上自管特有的四個維度：&lt;/p></description><content:encoded><![CDATA[<p>Keycloak 是 open source 自管 Identity Provider、Red Hat 主導維護（商業支援版本為 Red Hat build of Keycloak、前身 Red Hat SSO）。它承擔的責任跟 SaaS IdP 相同 — SSO、MFA、federation、user lifecycle — 但 <em>整個控制面留在組織自己手上</em>：issuer signing key、support tooling、底層 PostgreSQL、HA cluster、CVE patch cadence 全部自管。決定上 Keycloak 不是技術偏好、是組織決定把 SaaS IdP 的「第三方信任成本」換成「自家 SRE 運維成本 + 安全責任」。在 <a href="/blog/backend/00-service-selection/capability-buy-vs-build/" data-link-title="0.22 能力級買 vs 建：feature-as-a-service 與 BaaS bundle 選型" data-link-desc="在交付形態決定整個系統要不要自建之後、逐能力判斷該外包還是自建：辨識 managed 基礎設施、feature SaaS 與 BaaS bundle 三種外包深度、no-code 到 dev-tool 的服務光譜、買 vs 建判準與權重浮動、整合接縫與遷出代價">0.22 能力級買 vs 建</a> 的光譜上、Keycloak 是認證能力「建」側的 canonical 例子 — 把 feature SaaS（Auth0 / Okta）的第三方信任成本、換成自管控制面的運維成本；什麼訊號該翻到這一側、見 0.22 與 <a href="/blog/backend/knowledge-cards/capability-outsourcing-depth/" data-link-title="Capability Outsourcing Depth（外包深度）" data-link-desc="說明外包一塊後端能力有三種深度（managed 基礎設施、feature SaaS、BaaS bundle）、深度決定保留多少控制權與遷出代價">外包深度</a> 卡。</p>
<h2 id="服務定位">服務定位</h2>
<p>Keycloak 是 <em>自管控制面</em> 的 human identity 與 federation engine、不是 cloud resource permission engine。跟 <a href="/blog/backend/07-security-data-protection/vendors/okta/" data-link-title="Okta" data-link-desc="SaaS Identity Provider 主流選項、SSO / MFA / lifecycle 整合、第三方信任邊界的代價">Okta</a> / <a href="/blog/backend/07-security-data-protection/vendors/auth0/" data-link-title="Auth0" data-link-desc="B2C / B2B Customer Identity Provider、Universal Login、Action / Rule hook、屬 Okta 旗下 Customer Identity Cloud">Auth0</a> 的本質差異在於信任邊界落點：SaaS IdP 把 signing key、tenant 隔離、support workflow 都託管出去、客戶承擔「供應商出事我也跟著被打」的風險；Keycloak 把整條控制面收回自家機房或自家 VPC、客戶承擔「signing key 過期 / DB 崩 / Java app CVE 沒跟上」的運維風險。</p>
<p>跟 cloud-native SSO（<a href="/blog/backend/07-security-data-protection/vendors/aws-iam-identity-center/" data-link-title="AWS IAM Identity Center" data-link-desc="AWS 原生 workforce SSO、前 AWS SSO、Permission Set 跨帳號 access、可串外部 IdP federation">AWS IAM Identity Center</a>）相比、Keycloak 的核心優勢是 <em>不綁雲廠 + 可深度客製 authentication flow + 資料不出境</em>。適合垂直：金融、政府、醫療某些不接受 SaaS IdP 的場景；以及預算敏感、員工數中等、SRE 量能足以接 24/7 on-call 的組織。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本頁、讀者能判斷：</p>
<ol>
<li>Keycloak 該承擔哪一段 identity 控制（SSO / MFA / federation / brokering）、哪一段該交給雲端 IAM 或下游應用</li>
<li>自管 IdP 的最低運維基線（HA、DB DR、cert / signing key rotation、CVE cadence、SIEM 接點）</li>
<li>Realm / Client / User Federation / Identity Broker / Authentication Flow / SPI 各自的決策時機與陷阱</li>
<li>何時用 Keycloak、何時改走 SaaS（Okta / Auth0）或其他 OSS（Authentik / Zitadel）</li>
</ol>
<h2 id="最短判讀路徑">最短判讀路徑</h2>
<p>判斷 Keycloak 部署是否健康、最少看 SaaS IdP 的四件事加上自管特有的四個維度：</p>
<ul>
<li><strong>誰能做什麼</strong>：master realm admin 的人數、是否走 access request workflow、admin console 是否限 IP / device trust、是否強制 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">phishing-resistant 認證</a></li>
<li><strong>憑證在哪裡</strong>：client secret 是否走 <a href="/blog/backend/knowledge-cards/secret-management/" data-link-title="Secret Management" data-link-desc="說明 token、key、password 與憑證如何保存、輪替與撤銷">secret management</a>、realm signing key 的 rotation 排程、admin token 的 TTL</li>
<li><strong>入口如何暴露</strong>：哪些 realm 對外、reverse proxy / Ingress 是否做 rate limit、admin console（/auth/admin）是否限內網或 zero trust</li>
<li><strong>證據是否可回查</strong>：Event Listener SPI 是否接 SIEM、admin event 跟 login event 是否分流、保留期是否符合稽核</li>
<li><strong>DB 健康</strong>：PostgreSQL / MySQL 是否跨 AZ、是否有 PITR、是否做過 restore 演練（不是只有備份成功訊息）</li>
<li><strong>Cert lifecycle</strong>：TLS cert 與 realm signing key 各自的 rotation 排程、是否走 <a href="/blog/backend/knowledge-cards/website-certificate-lifecycle/" data-link-title="Website Certificate Lifecycle" data-link-desc="說明網站 TLS 憑證從簽發到續期與撤銷的全流程責任">Website Certificate Lifecycle</a> 自動化</li>
<li><strong>HA topology</strong>：Keycloak cluster 是否多節點、Infinispan cache 是否跨 AZ、單節點重啟是否會踢掉所有 session</li>
<li><strong>Upgrade cadence</strong>：Keycloak 每年 major release、CVE patch 是否能在 SLA 內上、是否有 staging 跑 DB migration</li>
</ul>
<p>八個維度任一缺失、都是自管 IdP 常見事故的入口。</p>
<h2 id="日常操作與決策形狀">日常操作與決策形狀</h2>
<p><strong>Realm 設計</strong>：Realm 是 Keycloak 的隔離邊界、每個 realm 有獨立的 user store、client、role、signing key。multi-tenancy 走 realm 是正確選擇、但 <em>master realm 能管所有 realm</em>、master realm 的 admin compromise = 全公司 IdP compromise。把 master realm 鎖在內網、operational realm 才對外、是基本姿勢。</p>
<p><strong>Client 註冊與 secret</strong>：每個應用是一個 client、confidential client 有 secret、public client（SPA / mobile）走 PKCE 不存 secret。client secret 不存 source code、走 <a href="/blog/backend/knowledge-cards/secret-management/" data-link-title="Secret Management" data-link-desc="說明 token、key、password 與憑證如何保存、輪替與撤銷">secret management</a> 注入。client 數量爆炸時要設 naming convention 跟 ownership 標記、不然 stale client 會堆積。</p>
<p><strong>User Federation</strong>：把既有 LDAP / Active Directory 接進 Keycloak、user 還是住在原 directory、Keycloak 做 protocol 翻譯（LDAP → OIDC / SAML）。這是 Keycloak 強項之一 — 不需要 user migration、漸進接入。陷阱是 LDAP 連線健康 = IdP 健康、LDAP 慢 = 全公司 login 慢。</p>
<p><strong>Identity Brokering</strong>：把外部 IdP（Google、Microsoft、其他 SAML / OIDC provider）federate 進來、Keycloak 當中介。B2B 合作常見模式 — partner 用自己的 IdP、不在我的 user store 開帳號。決策點是 <em>trust mapping</em>：外部 claim 怎麼對應到內部 role、外部 IdP 的 MFA 狀態怎麼信任。</p>
<p><strong>Authentication Flow</strong>：Keycloak 把 login / registration / reset password 做成可編輯的 flow DAG、可以插入自訂 step。這是 Keycloak 跟 SaaS IdP 最大差異點之一 — 想要 step-up MFA、device fingerprint、risk-based 判斷都可以自己接。雙面刃是 <em>自訂 flow 容易留漏洞</em>：跳過必要步驟、condition 寫錯讓 MFA 變可選、custom Authenticator SPI 沒處理 race condition。</p>
<p><strong>Theme / 客製 UI</strong>：Keycloak 支援 theme override、可以改 login page HTML / CSS / JS。custom JS 在 login page = 自己注入 XSS 風險 — theme 寫進去之後就是 IdP 本體的攻擊面、不是普通網頁。CSP 跟 input sanitization 要當成 IdP 安全規範看待。</p>
<p><strong>Event Listener / Audit</strong>：Keycloak 預設只把 event 寫進 DB、UI 上能查、但 <em>不會自動推到外部 SIEM</em>。生產環境必須接 Event Listener SPI（內建 jboss-logging、或自寫 Kafka / file listener）把 admin event 跟 login event 推進 SIEM。沒接的話 audit trail 只在 IdP 本機、IdP 出事就拿不到 evidence。</p>
<p><strong>Exception / break-glass</strong>：master realm 留至少 2 個 break-glass admin、credential 離線存、走獨立 MFA（hardware key）。Keycloak cluster 整個失聯時、用 break-glass 直連 DB / 直連單一節點救回。</p>
<h2 id="核心取捨表">核心取捨表</h2>
<table>
  <thead>
      <tr>
          <th>取捨維度</th>
          <th>Keycloak（自管 OSS）</th>
          <th>Okta（SaaS）</th>
          <th>Auth0（SaaS / B2C）</th>
          <th>Authentik / Zitadel（其他 OSS）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>控制面責任</td>
          <td>自己跑 issuer / signing / HA / DB / upgrade</td>
          <td>Okta 託管</td>
          <td>Auth0 託管</td>
          <td>自己跑、但社群規模小於 Keycloak</td>
      </tr>
      <tr>
          <td>客製化深度</td>
          <td>高 — Authenticator SPI / theme / event listener</td>
          <td>中 — Workflows / Hooks、限定範圍</td>
          <td>高 — Actions（JS hook）</td>
          <td>中 — Authentik flow 視覺化、彈性中等</td>
      </tr>
      <tr>
          <td>第三方信任成本</td>
          <td>低 — 自管、自己承擔運維</td>
          <td>高 — 供應商事件直接波及</td>
          <td>高 — 同 Okta（同集團）</td>
          <td>低 — 自管</td>
      </tr>
      <tr>
          <td>運維成本</td>
          <td>高 — HA、DR、cert、DB、CVE 都自管</td>
          <td>低 — SaaS</td>
          <td>低 — SaaS</td>
          <td>高 — 同 Keycloak、生態系更小</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>資料主權、預算敏感、需深度客製、有 SRE 量能</td>
          <td>多雲、大量 SaaS、lifecycle 自動化</td>
          <td>B2C、消費者 identity、developer-centric</td>
          <td>規模小、Keycloak 太重、想要更現代 UI</td>
      </tr>
      <tr>
          <td>退場成本</td>
          <td>中 — 自己掌握資料、protocol 標準可遷移</td>
          <td>高 — SAML / SCIM 接線散在數百 app</td>
          <td>高 — Actions / Rules 客製綁定深</td>
          <td>中 — 同 Keycloak</td>
      </tr>
  </tbody>
</table>
<p>選 Keycloak 的核心訴求：<em>資料主權 + 預算控制 + 客製 flow 需求</em>、且有 SRE 團隊能 24/7 on-call、能接受自管的運維重量。團隊小於 50 人沒 SRE 量能、應用主要在 SaaS（pre-built integration 用不上 Keycloak 強項）、需要快速接 7000+ SaaS app — 都該回頭看 Okta / Auth0。</p>
<h2 id="進階主題">進階主題</h2>
<p><strong>User Federation 跟 LDAP 整合</strong>：企業環境常見「Active Directory 是 user source of truth、Keycloak 做 protocol 層」。注意 LDAP 同步策略（read-only / writable / import）、LDAP 健康直接影響 IdP 可用性、LDAP timeout 要設嚴格避免 login 卡住整個 cluster。</p>
<p><strong>Identity Brokering 跟外部 IdP</strong>：把 Google / Microsoft / 其他 SAML IdP federate 進來、外部 user 進來時 Keycloak 自動建 link。trust mapping 是關鍵 — 外部 IdP 宣稱「這個 user 已 MFA」、要不要信？外部 group claim 怎麼對應到內部 role？沒有預設答案、要用 <a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a> 邊界決定。</p>
<p><strong>Fine-Grained Authorization（UMA / Authorization Services）</strong>：Keycloak 內建 policy engine、可以做 resource-level 授權（不只是 role-based）。適合需要中央化 policy decision 的場景、但會把應用的授權邏輯綁進 Keycloak、退場成本變高。多數場景應該把 authorization 留在應用內、Keycloak 只做 authentication + role token 發行。</p>
<p><strong>Custom Authenticator SPI</strong>：用 Java 寫自訂 authenticator、插進 Authentication Flow。能做 step-up MFA、device posture、risk score 判斷。陷阱是 SPI 程式碼就是 IdP 本體的一部分、bug = IdP 漏洞、必須走完整 code review + 安全測試流程、不能當普通 feature 開發。</p>
<p><strong>Realm signing key rotation</strong>：每個 realm 有自己的 RSA / EC signing key、用來簽 ID token / SAML assertion。rotation 必須跟下游 client 協調（key rollover 期間 client 要能接受新舊 key）、否則 rotation 當天全公司 login 失敗。分域分批是必做的、參考 <a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">Failure: Credential Rotation Without Scope</a>。</p>
<h2 id="排錯與失敗快速判讀">排錯與失敗快速判讀</h2>
<ul>
<li><strong>DB 是 SPOF</strong>：Keycloak 所有 state 在 PostgreSQL / MySQL、DB 出事 = IdP 停 = 全公司 SSO 停。跨 AZ replication + PITR + 季度 restore 演練、不是 nice-to-have</li>
<li><strong>Cert / signing key 過期</strong>：自管 IdP 最常見事故、TLS cert 過期擋對外 endpoint、realm signing key 過期讓所有 token 變無效。走 <a href="/blog/backend/knowledge-cards/certificate-rotation-renewal/" data-link-title="Certificate Rotation and Renewal" data-link-desc="說明網站憑證如何安全續期與輪替以避免停機">Certificate Rotation</a> 自動化、過期前 30 天 alert</li>
<li><strong>Cluster split-brain</strong>：Infinispan cache 跨節點同步、網路分區時 session 狀態不一致、user 看起來登入但下一個 request 又被踢出。HA topology 設計要考慮 cache mode（distributed vs replicated）、network 健康監控要 alert split-brain</li>
<li><strong>Major upgrade 卡 DB migration</strong>：每年 major release 帶 schema migration、staging 沒跑過就 production 升級 = 數小時 downtime。upgrade plan 包含 rollback DB snapshot + staging full rehearsal</li>
<li><strong>Custom theme / Authenticator 留漏洞</strong>：theme JS 引入 XSS、custom Authenticator 跳過 MFA、SPI 沒處理 race condition。把 IdP 客製當成 <a href="/blog/backend/07-security-data-protection/supply-chain-integrity-and-artifact-trust/" data-link-title="7.12 供應鏈完整性與 Artifact 信任" data-link-desc="定義 build provenance、artifact 信任與交付鏈風險問題">supply chain</a> 看待、走 code review + 安全測試</li>
<li><strong>Event 沒進 SIEM</strong>：預設只在 Keycloak DB、IdP 出事就拿不到 evidence。Event Listener SPI 接 Kafka / file / SIEM、admin event 跟 login event 各自接 <a href="/blog/backend/knowledge-cards/alert-runbook/" data-link-title="Alert Runbook" data-link-desc="說明告警如何連到可執行的排障與恢復流程">alert runbook</a></li>
<li><strong>Master realm admin 過多</strong>：日常工作不該用 master realm admin、應該在 operational realm 開有限權限 admin。master realm 是 single point of compromise</li>
</ul>
<h2 id="何時改走其他服務">何時改走其他服務</h2>
<table>
  <thead>
      <tr>
          <th>需求形狀</th>
          <th>改走</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不想自管、要 SaaS IdP</td>
          <td><a href="/blog/backend/07-security-data-protection/vendors/okta/" data-link-title="Okta" data-link-desc="SaaS Identity Provider 主流選項、SSO / MFA / lifecycle 整合、第三方信任邊界的代價">Okta</a> / <a href="/blog/backend/07-security-data-protection/vendors/auth0/" data-link-title="Auth0" data-link-desc="B2C / B2B Customer Identity Provider、Universal Login、Action / Rule hook、屬 Okta 旗下 Customer Identity Cloud">Auth0</a></td>
      </tr>
      <tr>
          <td>AWS-only 員工 SSO</td>
          <td><a href="/blog/backend/07-security-data-protection/vendors/aws-iam-identity-center/" data-link-title="AWS IAM Identity Center" data-link-desc="AWS 原生 workforce SSO、前 AWS SSO、Permission Set 跨帳號 access、可串外部 IdP federation">AWS IAM Identity Center</a></td>
      </tr>
      <tr>
          <td>Cloud resource 權限</td>
          <td><a href="/blog/backend/07-security-data-protection/vendors/aws-iam/" data-link-title="AWS IAM" data-link-desc="AWS cloud resource permission engine、Role / Policy / STS、跨帳號信任邊界與 OIDC federation 的核心">AWS IAM</a> / <a href="/blog/backend/07-security-data-protection/vendors/google-cloud-iam/" data-link-title="Google Cloud IAM" data-link-desc="GCP cloud resource permission engine、Role Binding / Service Account / Workload Identity Federation、resource hierarchy 為核心的權限治理">Google IAM</a> / <a href="/blog/backend/07-security-data-protection/vendors/azure-rbac/" data-link-title="Azure RBAC &#43; Entra ID" data-link-desc="Azure 雙層身份/權限體系、Entra ID（IdP）&#43; Azure RBAC（resource permission）、Conditional Access、PIM、Managed Identity">Azure RBAC</a></td>
      </tr>
      <tr>
          <td>小團隊、Keycloak 太重</td>
          <td>Authentik / Zitadel / Ory Hydra（更輕量 OSS、生態系較小）</td>
      </tr>
      <tr>
          <td>事件偵測（不只 Keycloak event）</td>
          <td>04 SIEM / detection 工具（<a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 observability</a> 跟 07 SIEM 章節）</td>
      </tr>
      <tr>
          <td>Secret / signing key 治理</td>
          <td><a href="/blog/backend/07-security-data-protection/secrets-and-machine-credential-governance/" data-link-title="7.6 秘密管理與機器憑證治理" data-link-desc="以問題驅動方式整理 secret、token、key 與機器身份治理">7.6 秘密管理與機器憑證治理</a></td>
      </tr>
  </tbody>
</table>
<h2 id="不在本頁內的主題">不在本頁內的主題</h2>
<ul>
<li>Keycloak 完整 SAML / OIDC 規格細節、SPI Java API 文件</li>
<li>Red Hat build of Keycloak 商業支援的差異與授權細節</li>
<li>Keycloak Operator（Kubernetes deployment）的逐步部署教學</li>
<li>LDAP / Active Directory 各種 schema 對應規格</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<p>Keycloak 沒有直接的廠商級公開事件（OSS 沒有 vendor incident 的對應形態）、自管 IdP 的失效模式以下分兩類整理：跨 vendor 共通的 <em>同構失效</em> 用既有 case 對照、自管 IdP <em>特有</em> 的失效情境補敘事說明、避免案例表變成「同一個 frame 拼四個 case slug」。</p>
<p><strong>對照引用（跨 vendor 同構失效）</strong>：</p>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>跟 Keycloak 的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/cases/azure-ad-identity-control-plane-2021/" data-link-title="7.C3 Azure AD：2021 Identity Control-plane 事件" data-link-desc="身分控制面事件如何影響多服務信任鏈與回復優先序。">Azure AD Identity Control Plane 2021</a></td>
          <td>對所有自管 IdP 的啟示：IdP 控制面故障會外溢到下游所有依賴 SSO 的服務、降級策略（local fallback、cached session）必須事先設計</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">Failure: Credential Rotation Without Scope</a></td>
          <td>Keycloak realm signing key rotation 必須分域分批、一次 rotate 全部 realm = 全公司 login 同時失敗</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/red-team/cases/identity-access/uber-2022-mfa-fatigue/" data-link-title="7.R7.1.1 Uber 2022：MFA 疲勞與內部工具擴散" data-link-desc="從社交工程到內部工具存取，拆解身分流程與權限邊界的失效點">Uber 2022 MFA Fatigue</a></td>
          <td>純 push MFA 抗不過 fatigue、Keycloak 自訂 Authentication Flow 應該強制高風險操作走 phishing-resistant factor</td>
      </tr>
  </tbody>
</table>
<p><strong>自管 IdP 特有的失效情境</strong>（沒有對應公開 vendor case、來自自管運維常見事故樣態）：</p>
<ul>
<li><strong>Cert 過期讓全公司 SSO 卡死</strong>：Keycloak signing cert / TLS cert / 後端 DB cert 都自己管、任何一張過期 = login 全停。Okta / Auth0 客戶不會遇到這個失效面（vendor 自己 rotate）— 自管組織必須有 cert lifecycle monitoring（Prometheus exporter + alert）+ 季度 rotate rehearsal、不能等 Let&rsquo;s Encrypt / 公司 PKI 發過期通知才動</li>
<li><strong>Major upgrade 卡 DB migration 變數小時 downtime</strong>：Keycloak 每年 major release 帶 schema migration、若 staging 沒 full rehearsal 就 production 升級、可能遇到 migration 比預期慢 5-10 倍、整個維護視窗炸掉。對照 Okta / Auth0：vendor 自己升、客戶感知是 minutes-level、不是 hours-level</li>
<li><strong>Realm scope 在小規模時用法跟大規模衝突</strong>：<a href="/blog/backend/07-security-data-protection/cases/contrast-identity-governance-by-scale/" data-link-title="7.C10 對照：規模差異下的身份治理" data-link-desc="identity 控制面治理在不同規模服務下的失敗邊界差異。">Contrast: Identity Governance by Scale</a> 揭示不同規模治理模式差異 — 小團隊用單一 realm 順、團隊長大後該拆 realm 卻沒拆、最後 admin compromise blast radius 變整個組織。Keycloak 比 SaaS IdP 更容易踩到、因為 realm 拆分要自己決定時機、沒 vendor 推使用者升級 tier</li>
<li><strong>DB 是 SPOF、自管沒做好 = SSO 跟 DB 一起死</strong>：Keycloak 用 PostgreSQL / MySQL 存 user / session / signing key、DB 出事 = IdP 停。跨 AZ HA + 跨 region DR + 季度 failover 演練是硬性要求、不是 nice-to-have；SaaS IdP 客戶不會遇到這個層次的失效面</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/07-security-data-protection/identity-access-boundary/" data-link-title="7.2 身分與授權邊界" data-link-desc="以問題驅動方式整理身分、授權、會話與供應商身分鏈">7.2 身分與授權邊界</a>、<a href="/blog/backend/07-security-data-protection/detection-coverage-and-signal-governance/" data-link-title="7.13 偵測覆蓋率與訊號治理" data-link-desc="定義偵測覆蓋、訊號品質與誤報成本的治理問題">7.13 偵測覆蓋率與訊號治理</a></li>
<li>平行：<a href="/blog/backend/07-security-data-protection/vendors/okta/" data-link-title="Okta" data-link-desc="SaaS Identity Provider 主流選項、SSO / MFA / lifecycle 整合、第三方信任邊界的代價">Okta vendor</a>、<a href="/blog/backend/07-security-data-protection/vendors/auth0/" data-link-title="Auth0" data-link-desc="B2C / B2B Customer Identity Provider、Universal Login、Action / Rule hook、屬 Okta 旗下 Customer Identity Cloud">Auth0 vendor</a>、<a href="/blog/backend/07-security-data-protection/vendors/aws-iam-identity-center/" data-link-title="AWS IAM Identity Center" data-link-desc="AWS 原生 workforce SSO、前 AWS SSO、Permission Set 跨帳號 access、可串外部 IdP federation">AWS IAM Identity Center</a></li>
<li>下游：<a href="/blog/backend/07-security-data-protection/vendors/aws-iam/" data-link-title="AWS IAM" data-link-desc="AWS cloud resource permission engine、Role / Policy / STS、跨帳號信任邊界與 OIDC federation 的核心">AWS IAM</a> / <a href="/blog/backend/07-security-data-protection/vendors/google-cloud-iam/" data-link-title="Google Cloud IAM" data-link-desc="GCP cloud resource permission engine、Role Binding / Service Account / Workload Identity Federation、resource hierarchy 為核心的權限治理">Google Cloud IAM</a> / <a href="/blog/backend/07-security-data-protection/vendors/azure-rbac/" data-link-title="Azure RBAC &#43; Entra ID" data-link-desc="Azure 雙層身份/權限體系、Entra ID（IdP）&#43; Azure RBAC（resource permission）、Conditional Access、PIM、Managed Identity">Azure RBAC</a>（Keycloak 之後的 cloud resource permission 層）</li>
<li>跨模組：<a href="/blog/backend/08-incident-response/vendors/" data-link-title="事故處理 Vendor 清單" data-link-desc="規劃 on-call、incident response、status page 與 postmortem 工具的服務頁撰寫順序與判準">8 事故處理 vendor 清單</a>（自管 IdP 事件如何 routing 進 IR 流程）</li>
<li>官方：<a href="https://www.keycloak.org/documentation">Keycloak Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>斷網環境要自建的服務清單</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-self-hosted-services/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-self-hosted-services/</guid><description>&lt;p>連網環境的 infra 團隊消費數十個 SaaS 服務：程式碼放 GitHub、CI 用 GitHub Actions、套件從 npm 和 PyPI 拉、container image 從 Docker Hub pull、憑證用 Let&amp;rsquo;s Encrypt 自動簽、監控用 Datadog。這些服務的共同特性是「有人幫你維護」——infra 團隊只需要設定和使用，不需要部署、升級、備份。&lt;/p>
&lt;p>斷網環境裡這些服務全部要自建。每一個 SaaS 變成一個內部服務，infra 團隊承擔它的部署、設定、升級、備份、監控和使用者管理。這篇文章盤點完整的服務清單、推薦的自建工具、部署順序，以及容易被低估的維護成本。&lt;/p>
&lt;h2 id="服務清單與選型">服務清單與選型&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>服務類別&lt;/th>
 &lt;th>連網環境的 SaaS&lt;/th>
 &lt;th>自建替代&lt;/th>
 &lt;th>部署複雜度&lt;/th>
 &lt;th>維護頻率&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>版本控制&lt;/td>
 &lt;td>GitHub / GitLab.com&lt;/td>
 &lt;td>GitLab CE / Gitea&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI/CD&lt;/td>
 &lt;td>GitHub Actions&lt;/td>
 &lt;td>Jenkins / GitLab CI&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>週級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套件 registry&lt;/td>
 &lt;td>npm / PyPI / Maven / apt&lt;/td>
 &lt;td>Nexus Repository&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>容器 registry&lt;/td>
 &lt;td>Docker Hub / ECR&lt;/td>
 &lt;td>Harbor / Docker Registry&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 CA&lt;/td>
 &lt;td>Let&amp;rsquo;s Encrypt&lt;/td>
 &lt;td>step-ca / cfssl&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>季級輪替&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 DNS&lt;/td>
 &lt;td>Route 53 / Cloud DNS&lt;/td>
 &lt;td>CoreDNS / BIND&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>變更時維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>時間同步&lt;/td>
 &lt;td>pool.ntp.org&lt;/td>
 &lt;td>chrony&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>部署後極少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監控&lt;/td>
 &lt;td>Datadog / New Relic&lt;/td>
 &lt;td>Prometheus + Grafana + Loki&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>週級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>機密管理&lt;/td>
 &lt;td>AWS Secrets Manager&lt;/td>
 &lt;td>HashiCorp Vault&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>月級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IaC state 後端&lt;/td>
 &lt;td>S3 + DynamoDB&lt;/td>
 &lt;td>PostgreSQL / Consul&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>變更時維護&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「部署複雜度」指首次部署到可用狀態的工程量。「維護頻率」指部署完成後的持續性工作——安全更新、容量擴充、故障排查。&lt;/p>
&lt;h3 id="各服務的選型判斷">各服務的選型判斷&lt;/h3>
&lt;p>&lt;strong>版本控制&lt;/strong>：GitLab CE 功能完整（含 CI/CD、container registry、package registry），但資源消耗大（建議 4 核 / 8GB 以上）。Gitea 輕量（512MB 記憶體可跑），適合小團隊或只需要 Git hosting 的情境。如果選 GitLab CE，版控 + CI/CD + registry 可以用同一個實例，減少部署數量。&lt;/p>
&lt;p>&lt;strong>CI/CD&lt;/strong>：如果已部署 GitLab CE，內建的 GitLab CI 是最低成本的選擇——Runner 裝在同一網段的機器上即可。Jenkins 的生態更大（plugin 多），但 plugin 的離線安裝和更新需要額外的搬運流程。&lt;/p>
&lt;p>&lt;strong>套件 registry&lt;/strong>：Nexus Repository 是斷網環境的首選，因為它用一個實例同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——維護一個服務取代六個獨立的離線 repo mirror。Artifactory 是商業替代品，功能相似但需要授權費。&lt;/p>
&lt;p>&lt;strong>容器 registry&lt;/strong>：Harbor 提供映像掃描（整合 Trivy）、RBAC、複寫、稽核 log。如果只需要儲存和拉取映像、不需要掃描和稽核，Docker Registry（開源）足夠。&lt;/p>
&lt;p>&lt;strong>內部 CA&lt;/strong>：step-ca 支援 ACME 協定（跟 Let&amp;rsquo;s Encrypt 相同的自動簽發流程），內部服務可以用跟外部一樣的 certbot 工具自動續期。cfssl 是更輕量的選擇但沒有 ACME 支援、需要手動或腳本續期。&lt;/p></description><content:encoded><![CDATA[<p>連網環境的 infra 團隊消費數十個 SaaS 服務：程式碼放 GitHub、CI 用 GitHub Actions、套件從 npm 和 PyPI 拉、container image 從 Docker Hub pull、憑證用 Let&rsquo;s Encrypt 自動簽、監控用 Datadog。這些服務的共同特性是「有人幫你維護」——infra 團隊只需要設定和使用，不需要部署、升級、備份。</p>
<p>斷網環境裡這些服務全部要自建。每一個 SaaS 變成一個內部服務，infra 團隊承擔它的部署、設定、升級、備份、監控和使用者管理。這篇文章盤點完整的服務清單、推薦的自建工具、部署順序，以及容易被低估的維護成本。</p>
<h2 id="服務清單與選型">服務清單與選型</h2>
<table>
  <thead>
      <tr>
          <th>服務類別</th>
          <th>連網環境的 SaaS</th>
          <th>自建替代</th>
          <th>部署複雜度</th>
          <th>維護頻率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>版本控制</td>
          <td>GitHub / GitLab.com</td>
          <td>GitLab CE / Gitea</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>CI/CD</td>
          <td>GitHub Actions</td>
          <td>Jenkins / GitLab CI</td>
          <td>高</td>
          <td>週級維護</td>
      </tr>
      <tr>
          <td>套件 registry</td>
          <td>npm / PyPI / Maven / apt</td>
          <td>Nexus Repository</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>容器 registry</td>
          <td>Docker Hub / ECR</td>
          <td>Harbor / Docker Registry</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>內部 CA</td>
          <td>Let&rsquo;s Encrypt</td>
          <td>step-ca / cfssl</td>
          <td>低</td>
          <td>季級輪替</td>
      </tr>
      <tr>
          <td>內部 DNS</td>
          <td>Route 53 / Cloud DNS</td>
          <td>CoreDNS / BIND</td>
          <td>低</td>
          <td>變更時維護</td>
      </tr>
      <tr>
          <td>時間同步</td>
          <td>pool.ntp.org</td>
          <td>chrony</td>
          <td>低</td>
          <td>部署後極少</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>Datadog / New Relic</td>
          <td>Prometheus + Grafana + Loki</td>
          <td>高</td>
          <td>週級維護</td>
      </tr>
      <tr>
          <td>機密管理</td>
          <td>AWS Secrets Manager</td>
          <td>HashiCorp Vault</td>
          <td>高</td>
          <td>月級維護</td>
      </tr>
      <tr>
          <td>IaC state 後端</td>
          <td>S3 + DynamoDB</td>
          <td>PostgreSQL / Consul</td>
          <td>低</td>
          <td>變更時維護</td>
      </tr>
  </tbody>
</table>
<p>「部署複雜度」指首次部署到可用狀態的工程量。「維護頻率」指部署完成後的持續性工作——安全更新、容量擴充、故障排查。</p>
<h3 id="各服務的選型判斷">各服務的選型判斷</h3>
<p><strong>版本控制</strong>：GitLab CE 功能完整（含 CI/CD、container registry、package registry），但資源消耗大（建議 4 核 / 8GB 以上）。Gitea 輕量（512MB 記憶體可跑），適合小團隊或只需要 Git hosting 的情境。如果選 GitLab CE，版控 + CI/CD + registry 可以用同一個實例，減少部署數量。</p>
<p><strong>CI/CD</strong>：如果已部署 GitLab CE，內建的 GitLab CI 是最低成本的選擇——Runner 裝在同一網段的機器上即可。Jenkins 的生態更大（plugin 多），但 plugin 的離線安裝和更新需要額外的搬運流程。</p>
<p><strong>套件 registry</strong>：Nexus Repository 是斷網環境的首選，因為它用一個實例同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——維護一個服務取代六個獨立的離線 repo mirror。Artifactory 是商業替代品，功能相似但需要授權費。</p>
<p><strong>容器 registry</strong>：Harbor 提供映像掃描（整合 Trivy）、RBAC、複寫、稽核 log。如果只需要儲存和拉取映像、不需要掃描和稽核，Docker Registry（開源）足夠。</p>
<p><strong>內部 CA</strong>：step-ca 支援 ACME 協定（跟 Let&rsquo;s Encrypt 相同的自動簽發流程），內部服務可以用跟外部一樣的 certbot 工具自動續期。cfssl 是更輕量的選擇但沒有 ACME 支援、需要手動或腳本續期。</p>
<p><strong>內部 DNS</strong>：CoreDNS 用設定檔驅動、輕量、適合 Kubernetes 環境。BIND 是傳統選擇、功能完整但設定複雜。多數斷網環境的 DNS 需求簡單（幾十筆 A record），CoreDNS 的 file plugin 足夠。</p>
<p><strong>時間同步</strong>：chrony 是 NTP 的現代替代——啟動快、適應性強、低資源。內網裡指定一台機器當 NTP server（stratum 1 如果有 GPS 時鐘、stratum 2 如果手動校時），其他機器指向它。時間不同步會讓 log correlation 失效、TLS 憑證驗證失敗、Kerberos 認證拒絕。</p>
<p><strong>監控</strong>：Prometheus（metric 收集）+ Grafana（視覺化）+ Loki（log 聚合）是最常見的 self-hosted 監控組合。三者都支援離線部署、不需要外部依賴。詳見<a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>。</p>
<p><strong>機密管理</strong>：HashiCorp Vault 提供 secret 儲存、動態 secret 產生、PKI、加密即服務。部署和維護複雜度高——Vault 本身需要 unseal、HA 需要 Raft 或 Consul 後端、稽核 log 需要儲存規劃。如果機密數量少且變更不頻繁，加密的 ansible-vault 或 git-crypt 是輕量替代。</p>
<p><strong>IaC state 後端</strong>：PostgreSQL 是 Terraform 支援的 state backend 之一（<code>backend &quot;pg&quot;</code>），斷網環境裡用既有的 PostgreSQL 實例存 state、用 PostgreSQL 的 advisory lock 防並行。比自建 S3 + DynamoDB 簡單得多。Consul 是另一個選擇（Terraform 原生支援），但引入 Consul 只為了存 state 的 ROI 通常不划算、除非環境裡已經有 Consul 跑 service discovery。</p>
<h2 id="部署順序">部署順序</h2>
<p>服務之間有依賴關係，部署順序由依賴方向決定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">第一層（基礎設施服務）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  DNS → 所有服務都需要名稱解析
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  NTP → 所有服務都需要時間同步
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  CA  → 所有服務都需要 TLS 憑證
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">第二層（開發平台服務）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  版本控制 → 程式碼要有地方存才能跑 CI
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  套件 + 容器 registry → build 需要依賴
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">第三層（自動化服務）
</span></span><span class="line"><span class="ln">11</span><span class="cl">  CI/CD → 依賴版控 + registry
</span></span><span class="line"><span class="ln">12</span><span class="cl">  IaC state backend → Terraform 需要 state 存放處
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">第四層（營運服務）
</span></span><span class="line"><span class="ln">15</span><span class="cl">  機密管理 → 其他服務的 secret 集中管理
</span></span><span class="line"><span class="ln">16</span><span class="cl">  監控 → 監控所有上述服務的健康</span></span></code></pre></div><p>第一層的三個服務可以平行部署——它們彼此不依賴。第四層的監控放最後是因為它要監控的對象都還沒就位時、設定 target 沒有意義。</p>
<p>每一層部署完成後做一次整體驗證（所有服務能互相連通、TLS 正常、時間同步），再進下一層。</p>
<h2 id="統一管理-vs-個別部署">統一管理 vs 個別部署</h2>
<p>GitLab CE 把版控、CI/CD、container registry、package registry 打包在一個實例裡。用 GitLab CE 取代四個獨立服務的優缺點：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>統一（GitLab CE）</th>
          <th>個別部署</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署成本</td>
          <td>部署 1 個服務</td>
          <td>部署 4 個服務</td>
      </tr>
      <tr>
          <td>維護</td>
          <td>升級 1 個服務</td>
          <td>各自升級週期</td>
      </tr>
      <tr>
          <td>資源消耗</td>
          <td>單機 8GB+ 記憶體</td>
          <td>分散在多台</td>
      </tr>
      <tr>
          <td>故障半徑</td>
          <td>GitLab 掛 = 版控 + CI + registry 全停</td>
          <td>某一個掛不影響其他</td>
      </tr>
      <tr>
          <td>靈活性</td>
          <td>綁 GitLab 生態</td>
          <td>各服務可獨立替換</td>
      </tr>
  </tbody>
</table>
<p>小團隊（5-15 人）的斷網環境，GitLab CE 統一管理的 ROI 通常較高——維護一個服務比維護四個省力，故障半徑的風險靠備份和 HA（GitLab 支援 Geo replication）緩解。</p>
<p>大團隊或高安全環境，個別部署的隔離性較好——CI runner 跟版控分開、registry 跟 CI 分開，每個服務的存取控制和稽核獨立。</p>
<p>同樣的邏輯適用於 Nexus：它用一個實例服務 6 種格式的套件，比為每種格式各建一個離線 mirror 省力。</p>
<h2 id="維護的隱藏成本">維護的隱藏成本</h2>
<p>自建服務的維護成本容易被低估，因為部署完成時感覺「已經做完了」，但持續性維護才剛開始。每個自建服務需要：</p>
<table>
  <thead>
      <tr>
          <th>維護項目</th>
          <th>頻率</th>
          <th>漏做的後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>安全更新</td>
          <td>月級</td>
          <td>已知漏洞暴露在內網（斷網不代表零風險）</td>
      </tr>
      <tr>
          <td>備份</td>
          <td>日級</td>
          <td>服務掛了資料沒了</td>
      </tr>
      <tr>
          <td>容量監控</td>
          <td>週級</td>
          <td>磁碟滿了服務停擺</td>
      </tr>
      <tr>
          <td>憑證續期</td>
          <td>季級</td>
          <td>TLS 過期、服務拒絕連線</td>
      </tr>
      <tr>
          <td>使用者管理</td>
          <td>變更時</td>
          <td>離職員工仍有存取權</td>
      </tr>
      <tr>
          <td>監控的監控</td>
          <td>持續</td>
          <td>監控系統本身掛了沒人知道</td>
      </tr>
  </tbody>
</table>
<p>10 個自建服務各自都有這六項維護需求。時程參考：每月的例行維護（安全更新 + 備份驗證 + 容量檢查）約需 2-3 天工程師時間。這筆時間是隱性的——不在任何 sprint 或 ticket 裡，但不做的後果是累積的。</p>
<p>管理層溝通時的關鍵數字：自建 10 個服務的維護成本約等於 0.3-0.5 個全職工程師。這筆人力投入是斷網環境的結構性成本，跟應用開發無關。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：內容搬運、離線套件管理的共通模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a>：state backend（PostgreSQL）和 CI 的詳細設定</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：Harbor 和映像搬運的詳細操作</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>：Prometheus + Grafana + Loki 的部署</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Vault 的身分管理與 infra IAM 的關係</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：自建服務的 secret 管理與成本歸因</li>
</ul>
]]></content:encoded></item><item><title>從 collector 資料做基礎 funnel 分析</title><link>https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/08-business-analytics/self-hosted-funnel/</guid><description>&lt;p>自架 collector 收集的事件資料可以做基礎的 funnel 分析，不需要商業方案。分析的深度取決於 storage backend 的查詢能力 — SQLite 層能做每步事件計數，PostgreSQL 層能做 session 級轉換率分析。功能分層的完整定義見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>。&lt;/p>
&lt;h2 id="定義-funnel-步驟">定義 funnel 步驟&lt;/h2>
&lt;p>Funnel 分析的第一步是列出每一步和對應的事件名稱。以一個透過 WebSocket 連接遠端終端機的 app 連線流程為例：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>事件名稱&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>terminal.connect.start&lt;/td>
 &lt;td>使用者點擊連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>auth.biometric.success&lt;/td>
 &lt;td>生物辨識通過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>terminal.connect.done&lt;/td>
 &lt;td>WebSocket 連線成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>terminal.input.submit&lt;/td>
 &lt;td>使用者開始打字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="sqlite-層每步事件計數">SQLite 層：每步事件計數&lt;/h2>
&lt;p>SQLite backend 能做的 funnel 是「每步有多少事件觸發」— 單表 GROUP BY，不需要跨事件 JOIN。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.start&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;auth.biometric.success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.done&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.input.submit&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;now&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;-7 days&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>步驟 N 的轉換率 = 步驟 N 的事件數 / 步驟 N-1 的事件數。流失率 = 1 - 轉換率。&lt;/p>
&lt;h3 id="能做的">能做的&lt;/h3>
&lt;ul>
&lt;li>每步事件計數（單表 GROUP BY）&lt;/li>
&lt;li>按 source.version 或 source.platform 分群（加 WHERE 條件）&lt;/li>
&lt;li>按天/按週看趨勢（strftime 分桶 + GROUP BY）&lt;/li>
&lt;/ul>
&lt;h3 id="做不到的">做不到的&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Session 級轉換率&lt;/strong>：「同一個 session 完成步驟 1 到步驟 4 的比例」需要 JOIN 同 session 的多個事件、跨所有 session 聚合。SQLite 能做這個 JOIN，但在大量 session 時效能不足。&lt;/li>
&lt;li>&lt;strong>步驟間耗時&lt;/strong>：「使用者在步驟 1 和步驟 2 之間等了多久」需要 self-join on session_id + timestamp 差值計算。&lt;/li>
&lt;li>&lt;strong>漏斗順序驗證&lt;/strong>：確認使用者是按 1→2→3→4 順序完成、不是跳步。&lt;/li>
&lt;/ul>
&lt;h2 id="postgresql-層session-級-funnel">PostgreSQL 層：Session 級 funnel&lt;/h2>
&lt;p>PostgreSQL backend 提供 window function 和高效 JOIN，能做完整的 session 級 funnel 分析。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_steps&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">ROW_NUMBER&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">step_order&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.start&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;auth.biometric.success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.connect.done&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;terminal.input.submit&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NOW&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;7 days&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="n">session_max_step&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MAX&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">step_order&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_steps&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">as&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sessions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">session_max_step&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">reached&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="新增能力">新增能力&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Session 級轉換率&lt;/strong>：每個 session 到達了哪一步、在哪一步流失&lt;/li>
&lt;li>&lt;strong>步驟間耗時&lt;/strong>：LAG window function 計算相鄰步驟的 timestamp 差值&lt;/li>
&lt;li>&lt;strong>漏斗順序驗證&lt;/strong>：用 ROW_NUMBER + CASE 確認步驟順序&lt;/li>
&lt;li>&lt;strong>Cohort 分群的 funnel&lt;/strong>：按使用者註冊日期 / 版本 / 平台分群看不同 cohort 的 funnel 差異&lt;/li>
&lt;/ul>
&lt;h2 id="jsonl-匯出後的臨時分析">JSONL 匯出後的臨時分析&lt;/h2>
&lt;p>Collector 的 &lt;code>monitor export --format=jsonl&lt;/code> 可以匯出事件為 JSONL 格式。匯出後用 grep + jq 做一次性的臨時分析：&lt;/p></description><content:encoded><![CDATA[<p>自架 collector 收集的事件資料可以做基礎的 funnel 分析，不需要商業方案。分析的深度取決於 storage backend 的查詢能力 — SQLite 層能做每步事件計數，PostgreSQL 層能做 session 級轉換率分析。功能分層的完整定義見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>。</p>
<h2 id="定義-funnel-步驟">定義 funnel 步驟</h2>
<p>Funnel 分析的第一步是列出每一步和對應的事件名稱。以一個透過 WebSocket 連接遠端終端機的 app 連線流程為例：</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>事件名稱</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>terminal.connect.start</td>
          <td>使用者點擊連線</td>
      </tr>
      <tr>
          <td>2</td>
          <td>auth.biometric.success</td>
          <td>生物辨識通過</td>
      </tr>
      <tr>
          <td>3</td>
          <td>terminal.connect.done</td>
          <td>WebSocket 連線成功</td>
      </tr>
      <tr>
          <td>4</td>
          <td>terminal.input.submit</td>
          <td>使用者開始打字</td>
      </tr>
  </tbody>
</table>
<h2 id="sqlite-層每步事件計數">SQLite 層：每步事件計數</h2>
<p>SQLite backend 能做的 funnel 是「每步有多少事件觸發」— 單表 GROUP BY，不需要跨事件 JOIN。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="k">count</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;terminal.connect.start&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;auth.biometric.success&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">               </span><span class="s1">&#39;terminal.connect.done&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;terminal.input.submit&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">datetime</span><span class="p">(</span><span class="s1">&#39;now&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-7 days&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">name</span><span class="p">;</span></span></span></code></pre></div><p>步驟 N 的轉換率 = 步驟 N 的事件數 / 步驟 N-1 的事件數。流失率 = 1 - 轉換率。</p>
<h3 id="能做的">能做的</h3>
<ul>
<li>每步事件計數（單表 GROUP BY）</li>
<li>按 source.version 或 source.platform 分群（加 WHERE 條件）</li>
<li>按天/按週看趨勢（strftime 分桶 + GROUP BY）</li>
</ul>
<h3 id="做不到的">做不到的</h3>
<ul>
<li><strong>Session 級轉換率</strong>：「同一個 session 完成步驟 1 到步驟 4 的比例」需要 JOIN 同 session 的多個事件、跨所有 session 聚合。SQLite 能做這個 JOIN，但在大量 session 時效能不足。</li>
<li><strong>步驟間耗時</strong>：「使用者在步驟 1 和步驟 2 之間等了多久」需要 self-join on session_id + timestamp 差值計算。</li>
<li><strong>漏斗順序驗證</strong>：確認使用者是按 1→2→3→4 順序完成、不是跳步。</li>
</ul>
<h2 id="postgresql-層session-級-funnel">PostgreSQL 層：Session 級 funnel</h2>
<p>PostgreSQL backend 提供 window function 和高效 JOIN，能做完整的 session 級 funnel 分析。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">WITH</span><span class="w"> </span><span class="n">session_steps</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">         </span><span class="n">ROW_NUMBER</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">session_id</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">ts</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">step_order</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">events</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;terminal.connect.start&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;auth.biometric.success&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">                 </span><span class="s1">&#39;terminal.connect.done&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;terminal.input.submit&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="k">AND</span><span class="w"> </span><span class="n">ts</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">NOW</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nb">INTERVAL</span><span class="w"> </span><span class="s1">&#39;7 days&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="n">session_max_step</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">session_id</span><span class="p">,</span><span class="w"> </span><span class="k">MAX</span><span class="p">(</span><span class="n">step_order</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">reached</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">session_steps</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">session_id</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">reached</span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sessions</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">session_max_step</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">reached</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">reached</span><span class="p">;</span></span></span></code></pre></div><h3 id="新增能力">新增能力</h3>
<ul>
<li><strong>Session 級轉換率</strong>：每個 session 到達了哪一步、在哪一步流失</li>
<li><strong>步驟間耗時</strong>：LAG window function 計算相鄰步驟的 timestamp 差值</li>
<li><strong>漏斗順序驗證</strong>：用 ROW_NUMBER + CASE 確認步驟順序</li>
<li><strong>Cohort 分群的 funnel</strong>：按使用者註冊日期 / 版本 / 平台分群看不同 cohort 的 funnel 差異</li>
</ul>
<h2 id="jsonl-匯出後的臨時分析">JSONL 匯出後的臨時分析</h2>
<p>Collector 的 <code>monitor export --format=jsonl</code> 可以匯出事件為 JSONL 格式。匯出後用 grep + jq 做一次性的臨時分析：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> step in terminal.connect.start auth.biometric.success terminal.connect.done terminal.input.submit<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nv">count</span><span class="o">=</span><span class="k">$(</span>grep <span class="s2">&#34;\&#34;name\&#34;:\&#34;</span><span class="nv">$step</span><span class="s2">\&#34;&#34;</span> exported-events.jsonl <span class="p">|</span> wc -l<span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$step</span><span class="s2">: </span><span class="nv">$count</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>JSONL 臨時分析適合「快速看一眼大概數字」的場景。持續性的 funnel 監控應該用 SQLite 或 PostgreSQL 的 SQL 查詢，結果穩定且可重現。</p>
<h2 id="自架-vs-商業方案">自架 vs 商業方案</h2>
<table>
  <thead>
      <tr>
          <th>需求</th>
          <th>自架能力</th>
          <th>商業方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每步事件計數</td>
          <td>SQLite GROUP BY</td>
          <td>Mixpanel / Amplitude 內建</td>
      </tr>
      <tr>
          <td>Session 級轉換率</td>
          <td>PostgreSQL window function</td>
          <td>Mixpanel / Amplitude 內建</td>
      </tr>
      <tr>
          <td>視覺化 funnel 漏斗圖</td>
          <td>自建 dashboard</td>
          <td>商業方案內建、拖拉設定</td>
      </tr>
      <tr>
          <td>即時更新</td>
          <td>定期重算 + dashboard 刷新</td>
          <td>商業方案即時</td>
      </tr>
      <tr>
          <td>A/B test 分群 funnel</td>
          <td>PostgreSQL + feature flag</td>
          <td>Optimizely / LaunchDarkly 整合</td>
      </tr>
  </tbody>
</table>
<p>自用工具場景下，SQLite 層的每步事件計數通常足夠。商業產品需要 session 級分析時，PostgreSQL 層的 SQL 能力和商業方案的分析能力在功能上對等，差異在 UI 和設定便利性。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Funnel 分析的完整方法論 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>事件設計如何影響分析品質 → <a href="/blog/monitoring/08-business-analytics/behavior-event-design/" data-link-title="行為事件設計" data-link-desc="事件命名規範、屬性設計、funnel 定義 — 行為分析的品質取決於事件設計的品質">行為事件設計</a></li>
<li>功能分層定義 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>去識別化是分析的入場條件 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">模組七 資安與隱私</a></li>
</ul>
]]></content:encoded></item></channel></rss>