
<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh-Hant"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ryangtr.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ryangtr.github.io/" rel="alternate" type="text/html" hreflang="zh-Hant" /><updated>2026-09-14T21:09:09+08:00</updated><id>https://ryangtr.github.io/feed.xml</id><title type="html">Ryan 的交付鏈筆記</title><subtitle>OpenShift／Tekton／Harbor／供應鏈安全——在自己的筆電上把企業交付鏈拆開來看。</subtitle><author><name>Ryan Chen</name></author><entry><title type="html">所以這個平台到底能不能用</title><link href="https://ryangtr.github.io/2026/09/so-can-you-actually-use-it/" rel="alternate" type="text/html" title="所以這個平台到底能不能用" /><published>2026-09-29T09:00:00+08:00</published><updated>2026-09-29T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/so-can-you-actually-use-it</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/so-can-you-actually-use-it/"><![CDATA[<p>三十篇寫完了。這篇回答那個所有人真正想問的問題。</p>

<p><strong>先給答案：能用。</strong> 我今天把原本壞掉的三個功能都修好了，
現在模型服務、pipeline、workbench、監控、台帳全部跑得起來。</p>

<p><strong>但「能用」這兩個字，藏了一天的工和七個坑。</strong></p>

<hr />

<h2 id="-一個評估者真正需要的數字">⭐ 一個評估者真正需要的數字</h2>

<p>不是「這個平台有沒有 X 功能」——那個答案永遠是「有」。</p>

<p>是這個：<strong>從「官方說有」到「我手上能用」，中間要跨幾關。</strong></p>

<table>
  <thead>
    <tr>
      <th>功能</th>
      <th>官方</th>
      <th>實際跨幾關</th>
      <th>是哪幾關</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>模型上線</strong>（KServe）</td>
      <td>有</td>
      <td><strong>3</strong></td>
      <td>依賴在叢集外／3.x 不幫你開 Route／服務答不出自己是誰</td>
    </tr>
    <tr>
      <td><strong>Pipeline</strong>（DSPA）</td>
      <td>有</td>
      <td><strong>1</strong></td>
      <td>caching 預設開著，你會看到全綠但什麼都沒跑</td>
    </tr>
    <tr>
      <td><strong>Workbench</strong></td>
      <td>有</td>
      <td><strong>3</strong></td>
      <td>admission webhook 擋下／CPU 排不進去／鏡像 digest 不符</td>
    </tr>
    <tr>
      <td><strong>Model Registry</strong></td>
      <td>有</td>
      <td><strong>3</strong></td>
      <td>namespace 被寫死／CR 與 API 版本號不一致／<strong>camelCase 欄位被靜默丟成零值</strong></td>
    </tr>
    <tr>
      <td><strong>監控</strong>（Prometheus+Grafana）</td>
      <td>有</td>
      <td><strong>1</strong></td>
      <td>datasource 沒給 <code class="language-plaintext highlighter-rouge">uid</code>，九個面板全查不到資料</td>
    </tr>
    <tr>
      <td><strong>離線鏡像</strong></td>
      <td>有</td>
      <td><strong>2</strong></td>
      <td><code class="language-plaintext highlighter-rouge">relatedImages</code> 只宣告 2 顆而實際 76 顆／IDMS 用 digest 比對</td>
    </tr>
    <tr>
      <td><strong>GPU</strong></td>
      <td>有</td>
      <td>?*</td>
      <td>pod 要到卡 ≠ 模型算在卡上</td>
    </tr>
    <tr>
      <td><strong>Kueue</strong></td>
      <td>有</td>
      <td><strong>1+</strong></td>
      <td><code class="language-plaintext highlighter-rouge">Managed</code> 被拒，要自己裝 RHBOK</td>
    </tr>
    <tr>
      <td><strong>llm-d</strong></td>
      <td>有</td>
      <td><strong>3+</strong></td>
      <td>Connectivity Link ＋ LeaderWorkerSet ＋ 多 GPU 多節點</td>
    </tr>
  </tbody>
</table>

<p><small>* GPU 那條我的環境驗不了（CRC 看不到卡），但那個「要到卡 ≠ 算在卡上」的陷阱是在主機上實測過的。</small></p>

<p><strong>八個功能，十七道以上的關卡。</strong></p>

<p>而其中<strong>沒有任何一關寫在官方文件上</strong>。</p>

<hr />

<h2 id="這些關卡長什麼樣">這些關卡長什麼樣</h2>

<p>我把它們分類之後，發現只有三種：</p>

<p><strong>① 平台自己的 bug</strong>（4 關）
Workbench 的 webhook（<a href="https://github.com/opendatahub-io/opendatahub-operator/pull/3792">有 JIRA 編號，PR 還 open</a>）、
Model Registry 的 camelCase 靜默丟值、KServe controller 的 RBAC 缺漏、
內建 imagestream 指向不存在的 tag。</p>

<p><strong>這類你修不了根，只能繞。</strong> 而繞的方法要自己找。</p>

<p><strong>② 設計如此，但沒人告訴你</strong>（7 關）
3.x 不幫你開 Route、模型和 image 分開放、Connection 是貼 label 的 Secret、
IDMS 用 digest 比對、Kueue 要走 <code class="language-plaintext highlighter-rouge">Unmanaged</code>、caching 預設開著、
Model Registry 要你自己準備資料庫。</p>

<p><strong>這類最花時間</strong>，因為你會以為自己做錯了，而其實是不知道規則。</p>

<p><strong>③ 資源與環境</strong>（6 關）
CPU 不夠排不進去、鏡像不完整、GPU 沒 passthrough、多節點做不到。</p>

<hr />

<h2 id="誰來跨這些關卡這才是驗收該問的">誰來跨這些關卡——這才是驗收該問的</h2>

<p>上面那張表對你有用的地方不是數字，是<strong>下一個問題</strong>：</p>

<blockquote>
  <p><strong>這十七關，誰來跨？</strong></p>
</blockquote>

<p>三種可能，而且代價差非常多：</p>

<table>
  <thead>
    <tr>
      <th>誰跨</th>
      <th>發生在什麼時候</th>
      <th>代價</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>廠商</strong>（在交付前）</td>
      <td>PoC 階段</td>
      <td>已包含在合約裡</td>
    </tr>
    <tr>
      <td><strong>你</strong>（在驗收時發現）</td>
      <td>驗收階段</td>
      <td>要求補件，時程延後</td>
    </tr>
    <tr>
      <td><strong>你</strong>（在上線後撞到）</td>
      <td>正式環境</td>
      <td><strong>最貴。而且通常在半夜</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>如果驗收只問「功能有沒有」，那十七關就會全部落到第三種。</strong></p>

<p>因為每一關都符合「功能是有的」這個描述。</p>

<hr />

<h2 id="所以驗收該怎麼問">所以驗收該怎麼問</h2>

<p>把整個系列壓成一句：</p>

<blockquote>
  <p><strong>不要問「有沒有 X」，要問「請當著我的面，把 X 從零做一次」。</strong></p>
</blockquote>

<p>而這個「做一次」有三個必要條件，缺一個就無效：</p>

<ol>
  <li><strong>當著你的面</strong>——不接受截圖，因為所有壞掉的東西都能截出一張好看的圖</li>
  <li><strong>從零</strong>——不是操作一個已經配好的環境</li>
  <li><strong>完整走完</strong>——包括最後那個「真的能用」的動作（打端點、開 workbench、看面板）</li>
</ol>

<p>我在這三十篇裡整理出來的六步驗收腳本是：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>建 project → 設 connection → 建 workbench 並開啟
→ 上傳並跑一次 pipeline → 看 run 的 input parameters
→ 部署模型並從叢集外打一發推論
</code></pre></div></div>

<p><strong>六步，每一步都會踩到上面那張表裡的某幾關。</strong></p>

<hr />

<h2 id="平心而論它好的地方">平心而論：它好的地方</h2>

<p>寫了三十篇的坑，也該說公道話。</p>

<p><strong>① 3.x 比 2.x 好，而且是結構性的好。</strong>
把 Knative／Service Mesh 那層拿掉之後，排錯回到 <code class="language-plaintext highlighter-rouge">oc get deploy/pod/svc</code>——
你原本會的 Kubernetes 技能直接可用，不用先學一整套中介層。
代價是路由和伸縮要自己接，<strong>但那是可預期的成本，不是黑盒子</strong>。</p>

<p><strong>② 所有東西都是標準 k8s 物件。</strong>
DSC、ISvc、Notebook、webhook、CSV——全部查得到、改得動、看得懂。
今天我能在四個指令內查出 Workbench 的根因，<strong>就是因為它沒有把資訊藏起來</strong>。</p>

<p><strong>③ 錯誤訊息雖然常常誤導，但底層資訊都在。</strong>
「scaled to zero」不是真正的原因，但 <code class="language-plaintext highlighter-rouge">oc describe pod</code> 有 <code class="language-plaintext highlighter-rouge">FailedScheduling</code>；
「cache sync timeout」很誤導，但 <code class="language-plaintext highlighter-rouge">oc auth can-i --as=</code> 一問就知道。
<strong>它不會騙你，只是不會主動告訴你。</strong></p>

<hr />

<h2 id="我的建議">我的建議</h2>

<p><strong>如果你在評估要不要導入：</strong></p>

<p>值得導入，但<strong>把上面那張「跨幾關」的表帶進合約談判</strong>。
明確約定哪幾類關卡由誰負責——尤其是第②類（設計如此但沒人告訴你），
那類最容易變成「這不在我們的範圍內」。</p>

<p><strong>如果你已經在導入：</strong></p>

<p>先跑那六步。<strong>每一步的失敗都比你想的更早出現，而且更容易修</strong>——
在 PoC 階段修一個 webhook 是半天，在正式環境是一個變更視窗。</p>

<p><strong>如果你是被交辦要驗收的人：</strong></p>

<p>你真正的產出不是一份「全部通過」的清單，
是<strong>一份「這幾關是誰的責任」的紀錄</strong>。</p>

<p>因為半年後系統出事時，沒有人會問「當初驗收有沒有通過」，
<strong>他們會問「這件事當初有沒有人知道」。</strong></p>

<hr />

<h2 id="最後">最後</h2>

<p>這三十篇裡我自己量錯了四次，其中一次差點把錯誤寫進要交出去的驗收條件。</p>

<p><strong>所以最後這句同時是給你的，也是給我自己的：</strong></p>

<blockquote>
  <p><strong>每一個「已經確認過」的結論，都有一個沒有被重新驗證的日期。</strong></p>
</blockquote>

<p>而那個日期，通常比你以為的更久以前。</p>

<hr />

<p><strong>如果你導入過類似的平台，你的「跨幾關」大概是多少？哪一關最貴？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="openshift-ai" /><category term="rhoai" /><category term="odh" /><category term="mlops" /><category term="assessment" /><category term="acceptance" /><category term="verdict" /><summary type="html"><![CDATA[三十天、八個功能、十七道關卡。這篇是總評——不是「好不好」，是一個評估者真正需要的數字：從「官方說有」到「我手上能用」，中間要跨幾關，以及那些關卡誰來跨。]]></summary></entry><entry><title type="html">怎麼把資料放上平台——以及放上去之後你答不答得出它哪來的</title><link href="https://ryangtr.github.io/2026/09/getting-data-onto-the-platform/" rel="alternate" type="text/html" title="怎麼把資料放上平台——以及放上去之後你答不答得出它哪來的" /><published>2026-09-28T09:00:00+08:00</published><updated>2026-09-28T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/getting-data-onto-the-platform</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/getting-data-onto-the-platform/"><![CDATA[<p>模型要有資料才能訓練。而資料在你的筆電、在資料庫、在某個 NAS 上——
<strong>怎麼弄到平台上，讓 pipeline 和 workbench 讀得到？</strong></p>

<p>四種方式，然後是一個更重要的問題。</p>

<hr />

<h2 id="四種方式">四種方式</h2>

<h3 id="-手動上傳到-s3最快也最沒紀錄">① 手動上傳到 S3（最快，也最沒紀錄）</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mc <span class="nb">cp </span>corpus.txt <span class="nb">local</span>/models/llm/clean_corpus.txt
</code></pre></div></div>

<p><strong>三十秒搞定，適合 lab 和第一次試。</strong>
我自己就是這樣放的——而這篇後半就是在講它的代價。</p>

<h3 id="-從-workbench-拉">② 從 workbench 拉</h3>

<p>在 Jupyter 裡直接抓：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">boto3</span>
<span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="n">client</span><span class="p">(</span><span class="s">"s3"</span><span class="p">,</span> <span class="n">endpoint_url</span><span class="o">=</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"AWS_S3_ENDPOINT"</span><span class="p">])</span>
<span class="n">s3</span><span class="p">.</span><span class="n">download_file</span><span class="p">(</span><span class="s">"models"</span><span class="p">,</span> <span class="s">"llm/corpus.txt"</span><span class="p">,</span> <span class="s">"/opt/app-root/src/corpus.txt"</span><span class="p">)</span>
</code></pre></div></div>

<p>憑證來自你掛上去的 <a href="/2026/09/connection-is-just-a-labelled-secret/">Connection</a>，
<strong>不用寫死在程式裡</strong>。</p>

<p>適合探索階段。缺點是<strong>那個過程留在某個人的 notebook 裡</strong>——
別人重現不了，而且 notebook 常常不進版控。</p>

<h3 id="-用-pipeline-的一棒去抓推薦">③ 用 pipeline 的一棒去抓（推薦）</h3>

<p>把「取得資料」變成 pipeline 的第一棒，跟訓練、評估同一條鏈：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">dsl</span><span class="p">.</span><span class="n">container_component</span>
<span class="k">def</span> <span class="nf">ingest_data</span><span class="p">(</span><span class="n">source_url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">run_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">dsl</span><span class="p">.</span><span class="n">ContainerSpec</span><span class="p">(</span><span class="n">image</span><span class="o">=</span><span class="n">IMAGE</span><span class="p">,</span> <span class="n">command</span><span class="o">=</span><span class="p">[</span><span class="s">"bash"</span><span class="p">,</span><span class="s">"-c"</span><span class="p">,</span> <span class="n">INGEST</span><span class="p">],</span> <span class="n">args</span><span class="o">=</span><span class="p">[</span><span class="n">source_url</span><span class="p">,</span> <span class="n">run_id</span><span class="p">])</span>
</code></pre></div></div>

<p><strong>好處是它會留下紀錄</strong>：哪一次 run、用了什麼參數、產出放哪。
這是唯一能讓「資料怎麼來的」變成可查詢事實的做法。</p>

<h3 id="-直接掛外部儲存pvc--nfs--csi">④ 直接掛外部儲存（PVC / NFS / CSI）</h3>

<p>資料太大、或本來就在企業儲存上時用這個。</p>

<p>⚠️ 但它有一個治理問題:<strong>掛進來的東西不受平台版本控制</strong>。
NFS 上那個檔案被別人改了，你的訓練結果就變了，而且沒有任何紀錄。</p>

<hr />

<h2 id="-但真正的問題不是怎麼放">⭐ 但真正的問題不是「怎麼放」</h2>

<p>我去查了自己平台上那份訓練語料，<strong>能查到的全部資訊只有這些</strong>：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mc <span class="nb">stat local</span>/models/llm/clean_corpus.txt
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Name      : clean_corpus.txt
Date      : 2026-08-24 03:35:32 UTC
Size      : 99 MiB
ETag      : 9e4a738f546d71860ae83a1ac165112f-7
Metadata  :
  Content-Type: text/plain
</code></pre></div></div>

<p><strong>檔名、日期、大小、ETag。就這樣。</strong></p>

<p>查不到的東西：</p>

<table>
  <thead>
    <tr>
      <th>問題</th>
      <th>答案</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>這是什麼資料？</td>
      <td>？（檔名叫 clean_corpus，那是處理後的名字）</td>
    </tr>
    <tr>
      <td>從哪裡來的？</td>
      <td>？</td>
    </tr>
    <tr>
      <td>什麼時候抓的？</td>
      <td>？（8/24 是<strong>上傳</strong>時間，不是抓取時間）</td>
    </tr>
    <tr>
      <td>用什麼腳本處理的？哪一版？</td>
      <td>？</td>
    </tr>
    <tr>
      <td>授權是什麼？可以拿來訓練嗎？</td>
      <td>？</td>
    </tr>
    <tr>
      <td>誰放上去的？</td>
      <td>？</td>
    </tr>
  </tbody>
</table>

<p><strong>而這份資料訓練出來的模型，現在正在對外服務。</strong></p>

<hr />

<h2 id="為什麼這件事比它看起來嚴重">為什麼這件事比它看起來嚴重</h2>

<p><strong>模型會把資料的一切學進去——包括你不知道自己收了什麼。</strong></p>

<p>出事的時候，你會被問這些：</p>

<ul>
  <li>「這個模型講出了不該講的東西，訓練資料裡有什麼？」</li>
  <li>「這批資料的授權允許商業使用嗎？」</li>
  <li>「有個人資料在裡面嗎？」</li>
  <li>「重跑一次能不能得到同一顆模型？」</li>
</ul>

<p><strong>這四題都不是模型的問題，是資料的問題。</strong>
而如果你的資料只有「檔名、日期、大小」，<strong>你一題都答不出來。</strong></p>

<p>⚠️ 更麻煩的是：這些問題通常在<strong>你最不想回答的時候</strong>被問到。</p>

<hr />

<h2 id="補法給資料一張身分證">補法：給資料一張身分證</h2>

<p>跟資料一起放一個 <code class="language-plaintext highlighter-rouge">dataset_card.json</code>：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"zhwiki-clean"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://dumps.wikimedia.org/zhwiki/20260601/..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"downloaded_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-21T08:05:00+08:00"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"raw_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sha256:..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"raw_size_bytes"</span><span class="p">:</span><span class="w"> </span><span class="mi">110234567</span><span class="p">,</span><span class="w">
  </span><span class="nl">"license"</span><span class="p">:</span><span class="w"> </span><span class="s2">"CC BY-SA 4.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"processing"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"script"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pipeline/01_prepare_data.py"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"code_commit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2d4f691"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"steps"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"collect"</span><span class="p">,</span><span class="w"> </span><span class="s2">"clean"</span><span class="p">,</span><span class="w"> </span><span class="s2">"quality-filter"</span><span class="p">,</span><span class="w"> </span><span class="s2">"exact-dedup"</span><span class="p">,</span><span class="w"> </span><span class="s2">"near-dedup"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"dropped"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"quality"</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="p">,</span><span class="w"> </span><span class="nl">"exact"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="nl">"near"</span><span class="p">:</span><span class="w"> </span><span class="mi">161</span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"output_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sha256:..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"docs_out"</span><span class="p">:</span><span class="w"> </span><span class="mi">11126</span><span class="p">,</span><span class="w">
  </span><span class="nl">"chars"</span><span class="p">:</span><span class="w"> </span><span class="mi">39850979</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>六個欄位是最低限度</strong>：</p>

<table>
  <thead>
    <tr>
      <th>欄位</th>
      <th>為什麼要</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">source_url</code></td>
      <td>出事時要能回到源頭</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">downloaded_at</code></td>
      <td>資料有時效性；<strong>上傳時間 ≠ 抓取時間</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">raw_sha256</code></td>
      <td>證明「我抓的就是那一份」</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">license</code></td>
      <td><strong>法遵</strong>。這一欄沒有，前面五欄都沒意義</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">code_commit</code></td>
      <td>同一份原始資料 + 不同版本的清理腳本 = 不同的訓練資料</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">output_sha256</code></td>
      <td>讓模型的 lineage 指得到這一份</td>
    </tr>
  </tbody>
</table>

<p>⭐ <strong>然後把 <code class="language-plaintext highlighter-rouge">output_sha256</code> 寫進模型台帳的 lineage。</strong>
這樣「線上這顆模型用了哪份資料」就是一個可查詢的事實，不是一段回憶。</p>

<hr />

<h2 id="怎麼開始不用一次做完">怎麼開始（不用一次做完）</h2>

<p><strong>第一步不是建系統，是補現有那幾份的身分證。</strong></p>

<p>你平台上大概有三到五份重要的資料。<strong>手寫那幾張卡，半天就能寫完。</strong>
半天換到「出事時答得出來」，這個交易很划算。</p>

<p><strong>第二步才是把它自動化</strong>——在 pipeline 加一棒 <code class="language-plaintext highlighter-rouge">ingest</code>，
產出資料的同時產出那張卡。</p>

<p>⚠️ 順序不要反過來。先做自動化，你會花兩週寫一個沒人用的東西；
先手寫幾張，你會知道哪些欄位真的需要。</p>

<hr />

<h2 id="誠實標一下">誠實標一下</h2>

<p><strong>我也還沒做。</strong></p>

<p>我的 lab 上那份 99 MB 語料，到今天為止仍然只有檔名、日期、大小。
我知道它是中文維基（因為是我自己下載的），
<strong>但那份知識在我腦子裡，不在平台上。</strong></p>

<p>而這正是問題所在：<strong>「我知道」和「查得到」是兩件事，
而只有後者能在三個月後、或在我不在的時候，回答問題。</strong></p>

<hr />

<h2 id="給驗收的一句">給驗收的一句</h2>

<blockquote>
  <p><strong>「請給我看訓練資料的來源、下載日期、和授權。」</strong></p>
</blockquote>

<p>如果對方指著一個 S3 路徑說「就在那裡」，那你們有的是<strong>一個檔案</strong>，
不是<strong>一份可以追溯的訓練資料</strong>。</p>

<p>而模型的所有問題，最後都會追回到這裡。</p>

<hr />

<p><strong>你們平台上的訓練資料，查得到「哪來的、什麼時候抓的、誰處理過」嗎？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="openshift-ai" /><category term="mlops" /><category term="data" /><category term="s3" /><category term="lineage" /><category term="governance" /><category term="dataset-card" /><summary type="html"><![CDATA[四種把資料弄上平台的方式，各自的代價。但真正的問題不是「怎麼放」——是我查了自己平台上那份 99 MB 的語料，除了檔名、日期、大小之外，什麼都查不到。]]></summary></entry><entry><title type="html">從 gate 放行到真的上線，中間那一步是空的</title><link href="https://ryangtr.github.io/2026/09/the-gap-between-passing-the-gate-and-going-live/" rel="alternate" type="text/html" title="從 gate 放行到真的上線，中間那一步是空的" /><published>2026-09-27T09:00:00+08:00</published><updated>2026-09-27T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/the-gap-between-passing-the-gate-and-going-live</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/the-gap-between-passing-the-gate-and-going-live/"><![CDATA[<p>前面我寫了<a href="/2026/09/four-step-pipeline-on-openshift-ai/">四棒 pipeline</a>、
寫了 <a href="/2026/09/three-identical-models-one-shipped/">gate 擋不擋得住</a>、
寫了 <a href="/2026/09/what-inferenceservice-actually-creates/">InferenceService 怎麼上線</a>。</p>

<p>但有一段我一直沒寫，因為我以為它是顯而易見的：</p>

<blockquote>
  <p><strong>模型訓練完、gate 放行之後，它怎麼變成線上服務？</strong></p>
</blockquote>

<p>今天我去查了。<strong>答案是：不會。中間那一步是空的。</strong></p>

<hr />

<h2 id="先看程式碼說什麼">先看程式碼說什麼</h2>

<p>gate 那一棒通過之後，最後做的事是：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">print</span><span class="p">(</span><span class="s">"  ✓ 放行"</span><span class="p">)</span>
<span class="n">reg</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">entry</span><span class="p">)</span>
<span class="n">put</span><span class="p">(</span><span class="n">REG_LOCAL</span><span class="p">,</span> <span class="n">MODELS</span><span class="p">,</span> <span class="n">REG_KEY</span><span class="p">)</span>                       <span class="c1"># 寫台帳
</span><span class="n">put</span><span class="p">(</span><span class="n">ART</span> <span class="o">/</span> <span class="s">"ckpt.pt"</span><span class="p">,</span>        <span class="n">MODELS</span><span class="p">,</span> <span class="s">"llm-candidate/ckpt.pt"</span><span class="p">)</span>
<span class="n">put</span><span class="p">(</span><span class="n">ART</span> <span class="o">/</span> <span class="s">"tokenizer.json"</span><span class="p">,</span> <span class="n">MODELS</span><span class="p">,</span> <span class="s">"llm-candidate/tokenizer.json"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"  已註冊進台帳並送進候選區 s3://models/llm-candidate/"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>放行 = 把模型複製到 <code class="language-plaintext highlighter-rouge">llm-candidate/</code>。</strong></p>

<p>而 InferenceService 讀的是哪裡？</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get isvc llm-scratch <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.spec.predictor.containers[0].env[?(@.name=="STORAGE_URI")].value}'</span>
<span class="c"># s3://models/llm/</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">llm/</code>，不是 <code class="language-plaintext highlighter-rouge">llm-candidate/</code>。</strong></p>

<hr />

<h2 id="-時間戳說得更清楚">⭐ 時間戳說得更清楚</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mc <span class="nb">ls</span> <span class="nt">--recursive</span> <span class="nb">local</span>/models
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[2026-08-28 08:13]  5.5 MiB   llm-candidate/ckpt.pt      ← gate 放行的那顆
[2026-08-28 08:13]   36 KiB   llm-candidate/tokenizer.json
[2026-08-24 02:41]   32 MiB   llm/ckpt.pt                ← 線上服務實際讀的
[2026-08-24 03:35]   99 MiB   llm/clean_corpus.txt
[2026-08-24 02:41]   97 KiB   llm/tokenizer.json
</code></pre></div></div>

<p><strong>候選區那顆是 8/28 的，5.5 MB。線上那顆是 8/24 的，32 MB。</strong></p>

<p>不只時間差四天——<strong>大小差六倍，那根本是兩顆完全不同的模型。</strong></p>

<p><strong>gate 放行的東西，從來沒有上線過。</strong></p>

<hr />

<h2 id="更麻煩的服務講的是另一份台帳">更麻煩的：服務講的是另一份台帳</h2>

<p>去問服務它自己是誰：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> localhost:18000/model | jq
</code></pre></div></div>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"serving_digest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sha256:4d694be9342d…"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"in_registry"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"production"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"metrics"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"test_loss"</span><span class="p">:</span><span class="w"> </span><span class="mf">3.462</span><span class="p">,</span><span class="w"> </span><span class="nl">"test_bpc"</span><span class="p">:</span><span class="w"> </span><span class="mf">4.9946</span><span class="p">},</span><span class="w">
  </span><span class="nl">"data_quality_gate"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">status: production</code>。看起來一切正常。</strong></p>

<p>但那份台帳是用 ConfigMap 掛進容器的本機檔，裡面兩筆，
<strong>建立時間都是 2026-06-21</strong>：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>00b47fc84755   archived     2026-06-21
4d694be9342d   production   2026-06-21     ← 線上這顆
</code></pre></div></div>

<p>而 gate 寫的台帳應該在 S3 上。我去找：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mc <span class="nb">ls</span> <span class="nt">--recursive</span> <span class="nb">local</span>/models | <span class="nb">grep </span>registry.json
<span class="c"># （沒有）</span>
</code></pre></div></div>

<p><strong>兩份台帳，互不相干。</strong></p>

<ul>
  <li><strong>服務讀的那份</strong>：ConfigMap，兩筆，六月的</li>
  <li><strong>gate 寫的那份</strong>：S3，而我在 bucket 裡找不到它</li>
</ul>

<p>所以 <code class="language-plaintext highlighter-rouge">/model</code> 回的 <code class="language-plaintext highlighter-rouge">production</code> 是真的——<strong>它只是在講六月那顆模型，
跟這條 pipeline 跑出來的東西完全沒有關係。</strong></p>

<hr />

<h2 id="整條鏈畫出來長這樣">整條鏈畫出來長這樣</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  pipeline train  →  s3://models/runs/&lt;run_id&gt;/ckpt.pt
                            ↓
  gate 放行       →  s3://models/llm-candidate/ckpt.pt      8/28, 5.5 MB
                            ↓
                    ┌───────────────┐
                    │  ？？？        │  ← 這裡什麼都沒有
                    └───────────────┘
                            ↓
  ISvc 讀取       →  s3://models/llm/ckpt.pt                8/24, 32 MB
</code></pre></div></div>

<p><strong>中間那個框，就是「上線」這件事。而它沒有被實作。</strong></p>

<hr />

<h2 id="為什麼會這樣而且為什麼很常見">為什麼會這樣——而且為什麼很常見</h2>

<p>我不是忘了寫。我是<strong>做到「候選區」就停下來了，因為那一步之後的東西不是技術問題</strong>。</p>

<p>從候選到上線，需要決定的是：</p>

<table>
  <thead>
    <tr>
      <th>問題</th>
      <th>這是誰的決定</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>誰有權按下「上線」</td>
      <td><strong>不是工程問題</strong></td>
    </tr>
    <tr>
      <td>上線要不要人工確認</td>
      <td>流程問題</td>
    </tr>
    <tr>
      <td>換上去之後怎麼回滾</td>
      <td>要先有回滾機制</td>
    </tr>
    <tr>
      <td>舊的那顆留多久</td>
      <td>保存政策</td>
    </tr>
    <tr>
      <td>出事了誰負責</td>
      <td><strong>組織問題</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>技術上，「上線」可以是一行 <code class="language-plaintext highlighter-rouge">mc cp llm-candidate/ llm/</code>。</strong></p>

<p>而正因為它技術上這麼簡單，<strong>它幾乎總是最後才被做</strong>，
或者被做成「某個人手動跑一下」——然後那個人變成單點。</p>

<blockquote>
  <p>⭐ <strong>這就是「治理只做到一半」的具體長相：
前面每一棒都自動化、有紀錄、有 gate，
而最後那個真正改變線上狀態的動作，是人工的、沒有紀錄的。</strong></p>
</blockquote>

<hr />

<h2 id="兩種補法">兩種補法</h2>

<h3 id="a-自動-promote適合換版頻繁">A. 自動 promote（適合換版頻繁）</h3>

<p>gate 通過就直接複製到 <code class="language-plaintext highlighter-rouge">llm/</code>，然後重啟 ISvc。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mc <span class="nb">cp local</span>/models/llm-candidate/ckpt.pt <span class="nb">local</span>/models/llm/ckpt.pt
oc rollout restart deploy/llm-scratch-predictor
</code></pre></div></div>

<p>⚠️ <strong>這樣做的前提是你的 gate 真的擋得住東西。</strong>
如果 gate 的門檻是 run 參數（<a href="/2026/09/three-identical-models-one-shipped/">誰都能填</a>），
自動 promote 等於把「上線」的權限交給任何能建 run 的人。</p>

<h3 id="b-人工放行但留下紀錄適合金融業">B. 人工放行，但留下紀錄（適合金融業）</h3>

<p>候選區保持不動，上線是一個<strong>明確的、有簽核的動作</strong>——
而且那個動作應該是 <strong>git commit</strong>，不是 <code class="language-plaintext highlighter-rouge">mc cp</code>。</p>

<p>把 ISvc 的 <code class="language-plaintext highlighter-rouge">STORAGE_URI</code> 或模型 digest 寫進 GitOps repo，
上線 = 改那個檔案 + PR + merge。</p>

<p><strong>這樣「誰放行的」這個問題，答案就在 git log 裡。</strong></p>

<blockquote>
  <p>這跟容器 image 的 <code class="language-plaintext highlighter-rouge">demo-tmp → demo</code> 放行機制是同一個形狀：
<strong>待審區和放行區分開，而搬動的那個動作要留痕。</strong></p>
</blockquote>

<hr />

<h2 id="最誠實的一段">最誠實的一段</h2>

<p><strong>我兩種都還沒做。</strong></p>

<p>我的 lab 現在的狀態是：pipeline 會跑、gate 會擋、台帳會寫、
候選區會更新——<strong>而線上那顆是六月手動放上去的，四天前的候選從來沒上去過。</strong></p>

<p>而我一直沒發現，因為：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">oc get isvc</code> → <code class="language-plaintext highlighter-rouge">READY=True</code> ✅</li>
  <li>推論打得通 ✅</li>
  <li><code class="language-plaintext highlighter-rouge">/model</code> 回 <code class="language-plaintext highlighter-rouge">production</code> ✅</li>
  <li>pipeline 的 run 是綠的 ✅</li>
</ul>

<p><strong>每一個檢查都通過，而這條鏈中間是斷的。</strong></p>

<p>要發現它，唯一的辦法是<strong>去比對兩個路徑的時間戳</strong>——
而那不在任何一份健康檢查清單上。</p>

<hr />

<h2 id="給驗收的一句">給驗收的一句</h2>

<blockquote>
  <p><strong>「請告訴我，線上這顆模型是哪一次 run 產出的。」</strong></p>
</blockquote>

<p>如果對方要去翻聊天記錄、或者說「應該是上次那個」，
<strong>那條鏈就是斷的</strong>——不管前面的 pipeline 做得多漂亮。</p>

<p>而這一題比「有沒有 gate」有用得多，因為它問的是<strong>結果</strong>不是<strong>流程</strong>。</p>

<hr />

<p><strong>你們的模型從「訓練完」到「線上服務」，中間那一步是誰做的？自動的還是人工的？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="mlops" /><category term="governance" /><category term="promotion-gate" /><category term="kserve" /><category term="openshift-ai" /><category term="deployment" /><summary type="html"><![CDATA[我的 pipeline 有完整的四棒、有 gate、有台帳。gate 也真的放行了一顆模型。然後我去查線上跑的是哪一顆——是四天前的另一顆，而且跟這條 pipeline 完全無關。]]></summary></entry><entry><title type="html">我的驗收清單自己有三個 bug</title><link href="https://ryangtr.github.io/2026/09/my-checklist-had-three-bugs/" rel="alternate" type="text/html" title="我的驗收清單自己有三個 bug" /><published>2026-09-26T09:00:00+08:00</published><updated>2026-09-26T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/my-checklist-had-three-bugs</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/my-checklist-had-three-bugs/"><![CDATA[<p>我為一個平台導入案寫了一份 42 條的驗收清單：每一條都有「指令／畫面」、
「預期輸出」、「不接受什麼」。全部照官方文件寫的。</p>

<p>然後我在自己的 lab 上把它跑了一遍。</p>

<p><strong>三條有問題。而且如果我沒跑，它們會直接被帶進驗收現場。</strong></p>

<hr />

<h2 id="bug-1一個會吐出-80-行假警報的指令">Bug 1：一個會吐出 80 行假警報的指令</h2>

<p>原本這樣寫：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-A</span> | <span class="nb">grep</span> <span class="nt">-i</span> opendatahub
</code></pre></div></div>

<p>在我的 lab 上，它吐 <strong>82 行</strong>。</p>

<p>看起來像裝了 82 套 operator。第一反應是「環境壞了」。</p>

<p><strong>實際上只裝了一套。</strong></p>

<p>原因：operator 用 <strong>AllNamespaces 模式</strong>安裝時，
<strong>它的 CSV（ClusterServiceVersion）會被複製到每一個 namespace</strong>。
那 82 行是同一個 CSV 在 82 個 namespace 的副本。</p>

<p>修正：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-A</span> <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{range .items[*]}{.metadata.name}{"\n"}{end}'</span> <span class="se">\</span>
  | <span class="nb">sort</span> <span class="nt">-u</span> | <span class="nb">grep</span> <span class="nt">-i</span> opendatahub
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opendatahub-operator.v3.5.0        ← 就一套
</code></pre></div></div>

<p>⭐ <strong>這條 bug 的危害不是「數字不對」，是它會製造恐慌。</strong>
在驗收現場，你拿一個吐 82 行的指令去問廠商，
<strong>對方會花半小時解釋一件根本沒發生的事</strong>——而你們兩邊都會覺得對方有問題。</p>

<blockquote>
  <p><strong>教訓</strong>：任何會回傳「一堆」的指令，先確認它回的是<strong>不同的東西</strong>，
還是<strong>同一個東西的多份副本</strong>。</p>
</blockquote>

<hr />

<h2 id="bug-2問錯了問題">Bug 2：問錯了問題</h2>

<p>原本 L5-2 寫的是：「比對鏡像的 digest 清單 vs 實際拉取的」。</p>

<p>看起來很嚴謹。但實際跑過才發現，<strong>那個問題問得太客氣了</strong>。</p>

<p>真正該問的是：</p>

<blockquote>
  <p><strong>「現在正在跑的 pod，有幾顆 image 根本不在你的私有 registry？」</strong></p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get pods <span class="nt">-A</span> <span class="nt">--field-selector</span><span class="o">=</span>status.phase<span class="o">=</span>Running <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{range .items[*].status.containerStatuses[*]}{.imageID}{"\n"}{end}'</span> <span class="se">\</span>
  | <span class="nb">sed</span> <span class="s1">'s|@.*||'</span> | <span class="nb">sort</span> <span class="nt">-u</span> | <span class="nb">grep</span> <span class="nt">-vE</span> <span class="s1">'^&lt;你的私有 registry&gt;'</span>
</code></pre></div></div>

<p><strong>離線環境的正確答案是：空的。</strong></p>

<p>任何一行輸出，都代表那顆 image 是從外網拉的——
也就是<strong>這個環境現在還連得到外網，或者它根本沒有真正離線過</strong>。</p>

<blockquote>
  <p><strong>差別在哪</strong>：原本的問法是「你的清單完整嗎」（比對兩份文件）。
新的問法是「<strong>這個環境現在有沒有依賴外網</strong>」（直接量事實）。</p>

  <p>前者可以用一份漂亮的文件回答，後者不行。</p>
</blockquote>

<p>我在 lab 上跑這條，結果是 <a href="/2026/09/offline-mirroring-is-not-copying-images/">operator 宣告 2 顆、實際 76 顆</a>。</p>

<hr />

<h2 id="bug-3把兩件無關的事寫成同一條">Bug 3：把兩件無關的事寫成同一條</h2>

<p>原本的清單把 <code class="language-plaintext highlighter-rouge">insecureRegistries</code> 和 IDMS 混在一條裡，
好像設了其中一個就代表「私有 registry 接好了」。</p>

<p><strong>它們完全無關：</strong></p>

<table>
  <thead>
    <tr>
      <th>機制</th>
      <th>作用</th>
      <th>沒有它會怎樣</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">insecureRegistries</code>／憑證信任</td>
      <td>叢集<strong>敢不敢</strong>連你的 registry</td>
      <td>TLS 錯誤，連不上</td>
    </tr>
    <tr>
      <td><strong>IDMS</strong>（ImageDigestMirrorSet）</td>
      <td>把 <code class="language-plaintext highlighter-rouge">quay.io/x@sha256:…</code> <strong>改導向</strong>私有 registry</td>
      <td><strong>照樣往外網拉</strong> → 離線環境直接失敗</td>
    </tr>
  </tbody>
</table>

<p>我的 lab 一開始只設了 <code class="language-plaintext highlighter-rouge">insecureRegistries</code>（讓 CRC 信任 HTTP 的 Harbor），
而 <code class="language-plaintext highlighter-rouge">oc get imagedigestmirrorset</code> 是 <strong><code class="language-plaintext highlighter-rouge">No resources found</code></strong>。</p>

<p><strong>當時我以為「私有 registry 已經接好了」——因為推得上去、拉得下來。</strong>
那只證明「連得到」，不證明「叢集會去那裡拿」。</p>

<p>（現在補上了：<code class="language-plaintext highlighter-rouge">odh-workbench-mirror</code>。）</p>

<p>⚠️ 再補一個後來才知道的：<strong>IDMS 是用 digest 比對的</strong>。
如果你的 pod spec 寫的是 tag 不是 digest，<strong>IDMS 不會生效</strong>——要用 ITMS。
這又是一個「設了但沒作用」。</p>

<hr />

<h2 id="三條的共同點">三條的共同點</h2>

<table>
  <thead>
    <tr>
      <th>bug</th>
      <th>錯在哪</th>
      <th>一句話</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>指令回的是副本不是實體</td>
      <td><strong>數量對不代表意義對</strong></td>
    </tr>
    <tr>
      <td>2</td>
      <td>問了一個可以用文件回答的問題</td>
      <td><strong>要量事實，不要比對文件</strong></td>
    </tr>
    <tr>
      <td>3</td>
      <td>把兩件事寫成一條</td>
      <td><strong>一條只驗一件事</strong></td>
    </tr>
  </tbody>
</table>

<p>而它們的<strong>共同來源只有一個：這份清單是照文件寫的，沒有跑過。</strong></p>

<hr />

<h2 id="-所以清單也是程式也要測試">⭐ 所以：清單也是程式，也要測試</h2>

<p>我後來把這件事訂成規矩：</p>

<blockquote>
  <p><strong>任何驗收清單，在拿去用之前，要先在一個你控制得了的環境跑一遍。</strong></p>
</blockquote>

<p>理由跟寫程式一樣：</p>

<ul>
  <li><strong>你寫的指令可能有 bug</strong>（bug 1）</li>
  <li><strong>你的預期輸出可能是錯的</strong>（bug 1 的「預期一行」其實是 82 行）</li>
  <li><strong>你問的問題可能問錯方向</strong>（bug 2）</li>
  <li><strong>你可能把兩個概念混在一起</strong>（bug 3）</li>
</ul>

<p>而這些<strong>只有跑過才會發現</strong>。</p>

<h3 id="沒跑過的清單會怎樣">沒跑過的清單會怎樣</h3>

<p>拿一份沒跑過的清單去驗收，最好的情況是浪費時間解釋假警報。</p>

<p><strong>最壞的情況是：對方照著你的清單交付，而你的清單漏掉了真正的問題</strong>——
然後雙方都有一份「全部通過」的紀錄，而系統是壞的。</p>

<p><strong>那份紀錄會在出事時保護不了任何人。</strong></p>

<hr />

<h2 id="附帶跑清單的成本比想像低">附帶：跑清單的成本比想像低</h2>

<p>我在 CRC 上跑完 42 條，花了大約兩天——而且<strong>順便發現了平台本身的四個坑</strong>。</p>

<p>那兩天的產出不只是「清單修好了」，還有：</p>

<ul>
  <li>每一條的<strong>實際輸出</strong>可以貼進清單當「預期」的範例</li>
  <li>對方看到範例輸出，就知道你真的跑過——<strong>談判位置完全不同</strong></li>
  <li>你自己在現場看到異常輸出時，<strong>認得出來</strong></li>
</ul>

<p><strong>最後那點最值錢。</strong> 驗收現場對方跑一個指令給你看，
你要能在三秒內判斷「這個輸出正不正常」——
而那個能力只能靠自己先跑過一遍。</p>

<hr />

<p><strong>你們的驗收清單有沒有實際跑過一遍？還是寫完就直接拿去用？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="acceptance" /><category term="openshift-ai" /><category term="offline" /><category term="methodology" /><category term="verification" /><summary type="html"><![CDATA[照文件寫了一份 42 條的驗收清單，實際跑過之後發現三條會誤導——其中一條會讓你以為裝了 80 套 operator。清單也是程式，也要測試。]]></summary></entry><entry><title type="html">棘輪失效：每一步都合法，二十步之後系統爛掉</title><link href="https://ryangtr.github.io/2026/09/the-ratchet-that-does-not-hold/" rel="alternate" type="text/html" title="棘輪失效：每一步都合法，二十步之後系統爛掉" /><published>2026-09-25T09:00:00+08:00</published><updated>2026-09-25T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/the-ratchet-that-does-not-hold</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/the-ratchet-that-does-not-hold/"><![CDATA[<p>我的 promotion gate 有一條回歸檢查：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">new_bpc</span> <span class="o">&gt;</span> <span class="n">base_bpc</span> <span class="o">+</span> <span class="n">tol</span><span class="p">:</span>
    <span class="n">reasons</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"回歸：比基準差"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">base</code> 是<strong>現行 production 那一顆</strong>。意思是：<strong>新模型不能比現在線上的那顆差超過 <code class="language-plaintext highlighter-rouge">tol</code>。</strong></p>

<p>聽起來很合理。而且我前一篇才花力氣把 <code class="language-plaintext highlighter-rouge">tol</code> 量出依據
（<a href="/2026/09/measure-your-sigma-first/">σ = 0.0211，所以 2σ = 0.042</a>）。</p>

<p><strong>但這條規則有一個結構性的缺陷，而且改小 <code class="language-plaintext highlighter-rouge">tol</code> 治不好。</strong></p>

<hr />

<h2 id="-用真的-gate-程式碼跑一次">⭐ 用真的 gate 程式碼跑一次</h2>

<p>假設每次都剛好退步 <code class="language-plaintext highlighter-rouge">tol</code>（= 0.042），連續二十次，每一次都送進 gate：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  次     新模型 BPC     基準 BPC     gate       距離原點
   1      5.0366      4.9946     ✅ 通過      +0.0420
   2      5.0786      5.0366     ✅ 通過      +0.0840
   3      5.1206      5.0786     ✅ 通過      +0.1260
  10      5.4146      5.3726     ✅ 通過      +0.4200
  19      5.7926      5.7506     ✅ 通過      +0.7980
  20      5.8346      5.7926     ✅ 通過      +0.8400
</code></pre></div></div>

<p><strong>二十次之後，BPC 從 4.9946 退到 5.8346——累積退步 0.84。</strong></p>

<p><strong>而每一步都通過了 gate。</strong></p>

<p>如果拿第 20 顆直接跟最初那顆比：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❌ 回歸：test_bpc 5.8346 比基準 4.9946 差（容忍 0.042）
</code></pre></div></div>

<p><strong>同一個 gate、同一個 <code class="language-plaintext highlighter-rouge">tol</code>，只是換了比較對象，結論完全相反。</strong></p>

<hr />

<h2 id="為什麼改小-tol-沒有用">為什麼改小 <code class="language-plaintext highlighter-rouge">tol</code> 沒有用</h2>

<p>直覺反應是「那把 <code class="language-plaintext highlighter-rouge">tol</code> 調嚴一點」。</p>

<p><strong>沒有用，只是把二十次變成一百次。</strong> 因為問題不在容忍度的大小，
在<strong>比較的對象</strong>——每次都跟「剛剛才被放行的那一顆」比，
於是每一次退步都成為下一次的新基準。</p>

<blockquote>
  <p><strong>這是棘輪，只是方向反了。</strong>
棘輪的設計是「只能往一個方向走，退不回去」。
而這個 gate 讓每一步的退步都變成不可逆的新起點——
<strong>它棘住的是退步，不是進步。</strong></p>
</blockquote>

<p>用 Java 講：像 code review 規定每個 PR 最多只能多欠 5 行技術債。
每一次 review 都是對的，一年後系統爛掉，
<strong>而且你找不到哪一次該負責</strong>——因為每一次都符合規則。</p>

<hr />

<h2 id="修法兩個基準不是一個">修法：兩個基準，不是一個</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">擋下的條件</span> <span class="o">=</span> <span class="p">(</span><span class="n">比上一筆差</span> <span class="o">&gt;</span> <span class="n">tol</span><span class="p">)</span>  <span class="n">OR</span>  <span class="p">(</span><span class="n">比凍結基準差</span> <span class="o">&gt;</span> <span class="n">tol_total</span><span class="p">)</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>基準</th>
      <th>是什麼</th>
      <th>容忍度</th>
      <th>防的是</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>滾動基準</strong></td>
      <td>現行 production</td>
      <td><code class="language-plaintext highlighter-rouge">tol</code>（2σ）</td>
      <td>單次的大幅退步</td>
    </tr>
    <tr>
      <td><strong>凍結基準</strong></td>
      <td><strong>人工釘住的某一版</strong></td>
      <td><code class="language-plaintext highlighter-rouge">tol_total</code>（例如 3×tol）</td>
      <td><strong>累積漂移</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>凍結基準的重點是「人工」</strong>——它不會自動更新。
要動它，得有人明確做一次決定：
「我承認新的水準就是這樣了，把基準往下移。」</p>

<p><strong>那一個動作應該留下紀錄，而且應該有人簽。</strong>
這就是它和滾動基準的差別：滾動基準是自動的，凍結基準是<strong>要有人負責的</strong>。</p>

<hr />

<h2 id="這個模式到處都是">這個模式到處都是</h2>

<p>一旦看懂，你會在很多地方看到同一個形狀：</p>

<table>
  <thead>
    <tr>
      <th>領域</th>
      <th>「跟上一版比」的規則</th>
      <th>累積之後</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>模型治理</td>
      <td>不能比現行版差 &gt; tol</td>
      <td>效能慢慢退</td>
    </tr>
    <tr>
      <td>效能測試</td>
      <td>這次不能比上次慢 &gt; 5%</td>
      <td>一年慢一倍</td>
    </tr>
    <tr>
      <td>技術債</td>
      <td>每個 PR 最多加 N 行</td>
      <td>系統爛掉</td>
    </tr>
    <tr>
      <td>資安例外</td>
      <td>每次只放行一個例外</td>
      <td>例外變常態</td>
    </tr>
    <tr>
      <td>專案時程</td>
      <td>每次只延一週</td>
      <td>延了半年</td>
    </tr>
  </tbody>
</table>

<p><strong>共同點：每一次決定都是合理的，而總和是災難。</strong></p>

<p>而且這類問題<strong>不會有人負責</strong>，因為責任被切成二十份，
每一份都小到不值得反對。</p>

<hr />

<h2 id="所以驗收要問什麼">所以驗收要問什麼</h2>

<p>看到任何「不能比上一版差超過 X」的規則，問一句：</p>

<blockquote>
  <p><strong>「這條規則跑二十次之後會怎樣？」</strong></p>
</blockquote>

<p>如果對方沒想過，那條規則防的是<strong>單次的意外</strong>，
不是<strong>持續的退化</strong>——而後者才是真正會發生的那種。</p>

<p>再問第二句：</p>

<blockquote>
  <p><strong>「有沒有一個不會自動更新的基準？誰有權改它？」</strong></p>
</blockquote>

<p>沒有的話，這條鏈上只有滾動基準，也就是<strong>沒有底線</strong>。</p>

<hr />

<h2 id="誠實標一下">誠實標一下</h2>

<p><strong>我還沒實作這個修法。</strong></p>

<p>上面那個二十次的演示是拿我真正的 <code class="language-plaintext highlighter-rouge">gate_reasons()</code> 跑出來的——
程式碼是真的，退化的過程是模擬的（我沒有真的訓練二十顆愈來愈爛的模型）。</p>

<p>而我的 gate 現在<strong>只有滾動基準</strong>。所以這篇的結論同樣適用於我自己：
<strong>我知道這個洞在哪裡，但它還開著。</strong></p>

<hr />

<p><strong>你們有哪個規則是「跟上一版比」的？有沒有可能被這樣累積繞過？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="mlops" /><category term="governance" /><category term="promotion-gate" /><category term="methodology" /><category term="technical-debt" /><summary type="html"><![CDATA[我的 gate 規則是「不能比現行版差超過 tol」。這條規則有一個結構性缺陷：每次退一點點都合法，二十次之後累積退步 0.84——而每一步都通過了檢查。]]></summary></entry><entry><title type="html">三十天對帳：我量錯的四次</title><link href="https://ryangtr.github.io/2026/09/thirty-days-reconciliation/" rel="alternate" type="text/html" title="三十天對帳：我量錯的四次" /><published>2026-09-24T09:00:00+08:00</published><updated>2026-09-24T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/thirty-days-reconciliation</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/thirty-days-reconciliation/"><![CDATA[<p>三十篇寫完了。這一篇不談平台，算我自己的帳。</p>

<p>我在這個系列裡反覆說「不要相信沒驗過的數字」。
那句話對我自己一樣成立——而且我犯了四次。</p>

<hr />

<h2 id="第一次用錯的量法差點讓工作量翻倍">第一次：用錯的量法，差點讓工作量翻倍</h2>

<p>我給稿子訂了字數上限，量完發現<strong>12 篇全部超標</strong>，最長的是上限的 1.76 倍。</p>

<p>於是有人問：<strong>「那要不要每篇拆成兩篇？」</strong></p>

<p>12 拆成 24。那會吃掉整個檔期，後面真正想寫的東西一篇都放不進去。</p>

<p>我正要開始評估怎麼拆，停了一下，回去看量法——
<strong>我是拿整份 Markdown 直接數的，把兩種讀者永遠看不到的東西算了進去</strong>：
發文前要刪的註解，和程式碼區塊。<strong>合計約佔每篇 40%。</strong></p>

<p>重量之後：平均 1,553 字，<strong>沒有一篇超過 2,000</strong>。一篇都不用拆。</p>

<blockquote>
  <p><strong>教訓</strong>：這個錯誤最危險的地方不是它錯，是它<strong>看起來剛好合理</strong>。
如果它回報「每篇 50 萬字」，我立刻會懷疑。
但「超標 1.76 倍」正好落在「有點糟但可信」的區間。</p>
</blockquote>

<hr />

<h2 id="第二次消融實驗的評估沒有套用消融">第二次：消融實驗的評估，沒有套用消融</h2>

<p>我做了三個拆零件的實驗（拆掉 causal mask、residual、<code class="language-plaintext highlighter-rouge">/√d</code>），
用 monkeypatch 改模型再訓練。</p>

<p><strong>但評估是另一個 process 跑的。</strong> <code class="language-plaintext highlighter-rouge">03_eval.py</code> 重新 <code class="language-plaintext highlighter-rouge">import</code> 原始的模型結構——
所以我是拿「<strong>沒有 residual 訓練出來的權重</strong>」餵進「<strong>有 residual 的結構</strong>」去評估。</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>錯的</th>
      <th>修正後</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>no-resid test_loss</td>
      <td>4.1130</td>
      <td><strong>3.3576</strong></td>
    </tr>
    <tr>
      <td>no-scale test_loss</td>
      <td>2.6724</td>
      <td><strong>1.9331</strong></td>
    </tr>
    <tr>
      <td>baseline</td>
      <td>1.9534</td>
      <td>1.9446（差 0.009＝評估雜訊）</td>
    </tr>
  </tbody>
</table>

<p>baseline 幾乎沒動，正好證明問題出在消融不匹配。</p>

<p>⭐ <strong>而抓到它的不是我的檢查。</strong> 是第三個實驗跑出一組矛盾的數字
（val 比 baseline 好、test 卻爛掉），我追那個矛盾才發現前兩篇全錯。</p>

<blockquote>
  <p><strong>教訓</strong>：<strong>錯誤的數字不會自己招供。</strong>
通常是另一個實驗的矛盾把它逼出來——而如果我沒做第三個實驗，
那兩篇會就這樣發出去。</p>
</blockquote>

<hr />

<h2 id="第三次我猜錯了而且方向相反">第三次：我猜錯了，而且方向相反</h2>

<p>拆掉 attention 的 <code class="language-plaintext highlighter-rouge">/√d</code> 之前，我把預測寫死在檔案裡：</p>

<blockquote>
  <p>分數會放大 5.66 倍 → softmax 過度尖銳 → 訓練不穩 → val loss 掉到 2.0~2.6</p>
</blockquote>

<p><strong>實際跑出來是 1.8090——比 baseline 的 1.8479 還低。</strong></p>

<p>四點預測錯了三點，方向也錯。</p>

<p>但這裡有第二層：<strong>我差點寫成「拿掉 √d 反而更好」。</strong></p>

<p>因為我另外量過這條產線的自然波動：只換 random seed 跑五次，
<code class="language-plaintext highlighter-rouge">test_loss</code> 的 <strong>σ = 0.0211</strong>。而這兩顆模型的差距是 <strong>0.0115</strong>——<strong>小於一個標準差</strong>。</p>

<p>正確的結論是第三種：<strong>「在這個設定下量不出差異」</strong>，不是「更好」也不是「更差」。</p>

<blockquote>
  <p><strong>教訓</strong>：<strong>沒有 σ，你手上的每一個比較都是意見。</strong></p>
</blockquote>

<hr />

<h2 id="第四次我把一個錯誤傳進了要交出去的文件">第四次：我把一個錯誤傳進了要交出去的文件</h2>

<p>這個最嚴重，因為它已經流出去了。</p>

<p>早期的紀錄裡有一條：</p>
<blockquote>
  <p>「pipeline 內建的 MariaDB 是 <code class="language-plaintext highlighter-rouge">latin1_swedish_ci</code>，含中文的 pipeline 上傳即失敗」</p>
</blockquote>

<p>我沒有重驗就把它寫進了<strong>兩篇文章</strong>和<strong>一份驗收條件</strong>。</p>

<p>寫第 28 篇之前我去實查——<strong>那條在這個版本上不成立</strong>：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>schema mlpipeline           utf8mb4_unicode_ci
run_details.Name/Description  utf8mb4
metadata 表（Artifact/Execution/Context）  utf8mb4
</code></pre></div></div>

<p><strong>中文存得進去，dashboard 也顯示得正確。</strong>
而且我早就有反證沒注意到：<strong>我自己那些 run 的中文描述，一直好好地存在裡面。</strong></p>

<p>⭐ <strong>但真正的風險還在，只是位置不同</strong>——在連線端：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>character_set_client      latin1
character_set_connection  latin1
character_set_results     latin1
</code></pre></div></div>

<p>所以自己寫的查詢會拿到 <code class="language-plaintext highlighter-rouge">?????</code>，而且不報錯。
<strong>更嚴重的是 <code class="language-plaintext highlighter-rouge">mysqldump</code> 沒指定字元集，會產生一份「看起來成功但中文全毀」的備份。</strong></p>

<p>那對災難復原的影響，比原本那條敘述更嚴重。</p>

<blockquote>
  <p><strong>教訓</strong>：<strong>繼承來的結論最危險。</strong>
它有一份紀錄背書，看起來已經被驗證過——
而你不會去驗一件「已經寫在文件上」的事。</p>
</blockquote>

<hr />

<h2 id="四次的共同點">四次的共同點</h2>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>錯在哪</th>
      <th>被誰抓到</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>量法把不該算的算進去</td>
      <td><strong>別人問了一個問題</strong></td>
    </tr>
    <tr>
      <td>2</td>
      <td>評估沒套用被測的改動</td>
      <td><strong>另一個實驗的矛盾</strong></td>
    </tr>
    <tr>
      <td>3</td>
      <td>用雜訊內的差異下結論</td>
      <td><strong>我自己量了 σ</strong></td>
    </tr>
    <tr>
      <td>4</td>
      <td>沿用沒驗過的舊紀錄</td>
      <td><strong>動筆前的例行重驗</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>四次裡只有兩次是我自己的機制抓到的。</strong> 另外兩次是運氣。</p>

<p>而這正好對應到我在這個系列裡罵別人的話：
<strong>檢查在它自己的位置上是通過的。</strong></p>

<hr />

<h2 id="我最後留下的兩個習慣">我最後留下的兩個習慣</h2>

<p><strong>① 當一個數字要求你做一個很貴的決定時，先回頭驗那個數字。</strong></p>

<p>驗證的力氣應該跟決定的代價成正比。「拆成 24 篇」是高成本難回頭的動作，
在付出之前回頭花十分鐘驗輸入，是我做過投資報酬率最高的一次除錯。</p>

<p>反過來，如果指標建議的動作很便宜（改個標題、調個參數），不驗也還好。</p>

<p><strong>② 動筆前重跑一次，即使那件事寫在自己的筆記上。</strong></p>

<p>第四個錯誤就是這樣抓到的——<strong>而它已經進了要交出去的文件</strong>。
如果我沒有訂「每篇提到的東西動筆前必須重跑」這條規矩，它會就這樣出去。</p>

<hr />

<h2 id="為什麼要寫這一篇">為什麼要寫這一篇</h2>

<p>因為這個系列唯一難被複製的，不是那些指令。</p>

<p>指令會過期——ODH 3.6 出來，一半的路徑會變。
<strong>但「我量錯了四次，這是怎麼發現的」不會過期。</strong></p>

<p>而且它是這整批文章可信度的來源：<strong>一個會公開自己量錯過的人，
比一個只展示成功的人可信。</strong></p>

<hr />

<p><strong>如果你也在寫技術文章或做評估報告，你怎麼確認自己沒搞錯？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="methodology" /><category term="mlops" /><category term="openshift-ai" /><category term="retrospective" /><category term="verification" /><summary type="html"><![CDATA[這個系列每一篇都在講「別人的檢查沒在檢查」。這篇算我自己的帳：三十天裡我量錯四次，其中兩次差點寫進要交出去的文件。]]></summary></entry><entry><title type="html">假綠的五種形態</title><link href="https://ryangtr.github.io/2026/09/five-shapes-of-false-green/" rel="alternate" type="text/html" title="假綠的五種形態" /><published>2026-09-23T09:00:00+08:00</published><updated>2026-09-23T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/five-shapes-of-false-green</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/five-shapes-of-false-green/"><![CDATA[<p>這個系列從頭到尾在講同一件事：<strong>你以為的檢查，其實沒在檢查。</strong></p>

<p>三十天下來，我在同一套平台上撞到五種不同形態。把它們排在一起之後，
我發現它們不是五個 bug——<strong>是五種檢查設計上的缺陷</strong>，而且可以歸納。</p>

<hr />

<h2 id="形態一看-log不看產物">形態一：看 log，不看產物</h2>

<p><strong>症狀</strong>：後訓練鏈的 log 印著 <code class="language-plaintext highlighter-rouge">ALL DONE</code>，每一段都有耗時，評估數字漂亮。
<strong>實際</strong>：checkpoint 的 mtime 全是三個月前，<strong>一步都沒跑</strong>。</p>

<p><strong>為什麼騙得過人</strong>：log 是程式印的，程式印什麼是程式決定的。
而且那條鏈的腳本沒有 <code class="language-plaintext highlighter-rouge">set -e</code>，加上 <code class="language-plaintext highlighter-rouge">make ... | tail -6</code> 讓
<strong>exit code 被 pipe 尾端接管</strong>——每一步都失敗，整條鏈回 0。</p>

<blockquote>
  <p><strong>戳破它的一句</strong>：<strong>看產物的時間戳，不看 log。</strong>
<code class="language-plaintext highlighter-rouge">ls -la</code> 比一千行 log 誠實。</p>
</blockquote>

<hr />

<h2 id="形態二看結果不看門檻是誰填的">形態二：看結果，不看門檻是誰填的</h2>

<p><strong>症狀</strong>：promotion gate 顯示 <code class="language-plaintext highlighter-rouge">SUCCEEDED</code>，模型上線。
<strong>實際</strong>：模型沒變好，是<strong>門檻從 6.0 被改成 8.0</strong>。</p>

<p>三顆能力幾乎相同的模型（7.2343 / 7.2370 / 7.2428），
一顆上線兩顆被擋。決定的不是模型。</p>

<p><strong>為什麼騙得過人</strong>：那是一條全綠的四棒交付鏈，
在任何看板、報表、稽核截圖上都是健康的。</p>

<blockquote>
  <p><strong>戳破它的一句</strong>：<strong>去看 run 的 input parameters。</strong>
不要問「有沒有品質閘門」，要問「<strong>這次的門檻是多少、誰填的</strong>」。</p>
</blockquote>

<hr />

<h2 id="形態三檢查的範圍小於系統的範圍">形態三：檢查的範圍小於系統的範圍</h2>

<p><strong>症狀</strong>：DSC 說 <code class="language-plaintext highlighter-rouge">KserveReady=True</code>，<code class="language-plaintext highlighter-rouge">opendatahub</code> 的 pod 零異常。
<strong>實際</strong>：模型服務是死的，<code class="language-plaintext highlighter-rouge">Init:Error</code>。</p>

<p><strong>根因在叢集外面</strong>——模型在 MinIO、image 在 Harbor，
兩個都是主機上的 podman 容器。<code class="language-plaintext highlighter-rouge">crc start</code> 不會把它們拉起來，
而<strong>叢集的健康檢查只看得到叢集裡的東西</strong>。</p>

<blockquote>
  <p><strong>戳破它的一句</strong>：<strong>要求對方列出「不在叢集內的依賴」。</strong>
外部 S3、私有 registry、外掛 DB、授權伺服器——那些就是盲區。</p>
</blockquote>

<hr />

<h2 id="形態四檢查在資料層壞在呈現層">形態四：檢查在資料層，壞在呈現層</h2>

<p><strong>症狀</strong>：Grafana pod Running、Prometheus target <code class="language-plaintext highlighter-rouge">up</code>、指標有值、dashboard 開得起來。
<strong>實際</strong>：<strong>九個面板全部查不到資料</strong>，因為 datasource 沒指定 <code class="language-plaintext highlighter-rouge">uid</code>，
Grafana 自動產了一個隨機值，而 dashboard JSON 裡 20 處都寫死 <code class="language-plaintext highlighter-rouge">uid: "prometheus"</code>。</p>

<p><strong>為什麼騙得過人</strong>：每一個自動化檢查都在資料層，而壞的地方在呈現層。
<strong>「有沒有人真的打開過那個面板」——沒有任何檢查在問這件事。</strong></p>

<blockquote>
  <p><strong>戳破它的一句</strong>：<strong>打開它，看一眼。</strong>
一個沒有人打開過的 dashboard，跟沒有 dashboard 的差別，
只在於前者讓你以為自己有在監控。</p>
</blockquote>

<hr />

<h2 id="形態五寫入成功讀得回來內容是空的">形態五：寫入成功、讀得回來、內容是空的</h2>

<p><strong>症狀</strong>：把模型登記進 Model Registry，HTTP 200，條目建立，讀得回來。
<strong>實際</strong>：<code class="language-plaintext highlighter-rouge">test_bpc: 0</code>、<code class="language-plaintext highlighter-rouge">model_digest: ""</code>、<code class="language-plaintext highlighter-rouge">data_quality_gate: false</code>——
<strong>每一個治理數據都是零值</strong>。</p>

<p>原因：API 吃 <code class="language-plaintext highlighter-rouge">snake_case</code>，我送 <code class="language-plaintext highlighter-rouge">camelCase</code>，
<strong>它不認識那個欄位就用零值填上，而且不報錯。</strong></p>

<p><strong>為什麼這個最陰</strong>：那筆紀錄看起來完全正常——模型名對、版本名對、
時間對、S3 路徑對。<strong>只有治理數據是空的。</strong>
而 <code class="language-plaintext highlighter-rouge">data_quality_gate</code> 從 <code class="language-plaintext highlighter-rouge">true</code> 變成 <code class="language-plaintext highlighter-rouge">false</code>，
任何讀這個欄位做決定的流程，會拿一個假的值去判斷。</p>

<blockquote>
  <p><strong>戳破它的一句</strong>：<strong>寫進去之後，讀回來逐欄比對。</strong>
不要相信 HTTP 200。</p>
</blockquote>

<hr />

<h2 id="補記後來又撞到兩個而且都是形態五的變種">補記：後來又撞到兩個，而且都是形態五的變種</h2>

<p>寫完這篇之後，我在同一套平台上又撞到兩個，形狀跟形態五一樣——
<strong>格式正確、內容錯誤、沒有任何提示</strong>：</p>

<p><strong>① Connection 的憑證是錯的，但 dashboard 顯示正常。</strong>
UI 檢查的是 label 和必填欄位有沒有填，<strong>不會拿那組憑證去連一次</strong>。
直到在 workbench 裡真的用它，才拿到 <code class="language-plaintext highlighter-rouge">SignatureDoesNotMatch</code>。</p>

<p><strong>② 鏡像的 repo 對、IDMS 也生效，但 digest 不對。</strong>
Harbor 裡有那個 repo、名字完全正確、IDMS 設了而且改導向成功——
<strong>只是裡面那顆是另一個版本</strong>。<code class="language-plaintext highlighter-rouge">oc</code> 只會說 <code class="language-plaintext highlighter-rouge">ImagePullBackOff</code>。</p>

<p><strong>所以形態五可以推廣成一句：</strong></p>

<blockquote>
  <p><strong>「東西在那裡」和「東西是對的」是兩件事，
而所有只檢查前者的機制，都會放行後者的錯誤。</strong></p>
</blockquote>

<p>而這類錯誤有一個共同的偵測法：<strong>不要驗證它存在，驗證它能用。</strong>
不要看 connection 在不在，去連一次；不要看 image 在不在，去拉一次；
不要看台帳有沒有那筆，把它讀回來比對。</p>

<hr />

<h2 id="五種的共同結構">五種的共同結構</h2>

<p>排在一起就看得出來，它們是同一個東西的五個側面：</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>檢查看的</th>
      <th>實際壞的</th>
      <th>距離</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>程式說什麼</td>
      <td>程式做了什麼</td>
      <td><strong>宣告 vs 行為</strong></td>
    </tr>
    <tr>
      <td>2</td>
      <td>結果</td>
      <td>判準</td>
      <td><strong>輸出 vs 標準</strong></td>
    </tr>
    <tr>
      <td>3</td>
      <td>叢集內</td>
      <td>叢集外</td>
      <td><strong>範圍</strong></td>
    </tr>
    <tr>
      <td>4</td>
      <td>資料層</td>
      <td>呈現層</td>
      <td><strong>層級</strong></td>
    </tr>
    <tr>
      <td>5</td>
      <td>寫入動作</td>
      <td>寫入內容</td>
      <td><strong>形式 vs 內容</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>每一種都是「檢查的位置」和「失敗的位置」之間有一段距離。</strong></p>

<p>而那段距離不會自己被發現——<strong>因為檢查在它自己的位置上，是通過的。</strong></p>

<hr />

<h2 id="所以驗收要問什麼">所以驗收要問什麼</h2>

<p>把五種各壓成一句，就是一份可以帶去會議的清單：</p>

<ol>
  <li><strong>產物的時間戳是什麼時候？</strong>（不要給我 log）</li>
  <li><strong>這次用的門檻是多少、誰填的？</strong>（不要問「有沒有閘門」）</li>
  <li><strong>哪些依賴不在叢集內？</strong>（那些是盲區）</li>
  <li><strong>請當著我的面打開那個面板／建一個 workbench。</strong>（不接受截圖）</li>
  <li><strong>寫進去的東西，讀回來一模一樣嗎？</strong>（不要相信 200）</li>
</ol>

<p>⭐ 而最通用的那一句，我在寫 gate 那篇時整理出來的：</p>

<blockquote>
  <p><strong>「這條鏈上，哪一個點會因為檢查沒過而讓部署失敗？」</strong></p>
</blockquote>

<p>不要問「有沒有做 X」——那個答案永遠是「有」。
<strong>要問失敗會在哪裡發生。</strong> 如果對方講不出一個具體的點，
那條鏈上的檢查全部是<strong>產生報告</strong>，不是<strong>擋事</strong>。</p>

<p>而這兩者在稽核截圖上看起來一模一樣。</p>

<hr />

<h2 id="最後其中兩種是我自己犯的">最後：其中兩種是我自己犯的</h2>

<p>形態一是我踩到別人的，形態二、三、四是我在平台上撞到的。
但這三十天我自己也製造了兩個：</p>

<ul>
  <li>用錯的字數量法，差點把 12 篇稿子拆成 24 篇</li>
  <li>消融實驗的<strong>評估沒套用消融</strong>，兩篇文章的數字全錯</li>
</ul>

<p><strong>兩個都不是我的檢查抓到的。</strong> 一個是有人問我「要不要拆」我才回頭重量，
另一個是另一個實驗跑出矛盾數字才追出來。</p>

<blockquote>
  <p><strong>錯誤的數字不會自己招供。</strong></p>
</blockquote>

<p>這是為什麼我最後留下的不是一份檢查清單，是一個習慣：
<strong>當一個數字要求你做一個很貴的決定時，先回頭驗那個數字。</strong></p>

<hr />

<p><strong>你遇過最久沒被發現的「假綠」是哪一個？後來是怎麼發現的？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="mlops" /><category term="openshift-ai" /><category term="verification" /><category term="acceptance" /><category term="false-green" /><category term="methodology" /><summary type="html"><![CDATA[這三十天我在同一套平台上撞到五種「顯示正常但實際壞掉」。它們的共同點不是 bug，是檢查本身的設計缺陷——而且每一種都有一句話可以戳破。]]></summary></entry><entry><title type="html">私有 registry：為什麼 image 跟模型要分開放</title><link href="https://ryangtr.github.io/2026/09/private-registry-for-ai-platform/" rel="alternate" type="text/html" title="私有 registry：為什麼 image 跟模型要分開放" /><published>2026-09-22T09:00:00+08:00</published><updated>2026-09-22T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/private-registry-for-ai-platform</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/private-registry-for-ai-platform/"><![CDATA[<h2 id="1-這是什麼">1. 這是什麼</h2>

<p><strong>一個放容器 image 的地方，而且是你自己的。</strong></p>

<p>我 lab 用 Harbor（開源、可自架）。企業常見的還有 JFrog Artifactory、
Nexus、或雲端的 ECR/ACR/GAR。</p>

<p><img src="/assets/img/rhoai/harbor-projects.png" alt="Harbor projects" /></p>

<blockquote>
  <p><strong>Java 類比</strong>：Nexus 對 jar 的角色。差別在 image 更大、更難掃，
而且它是<strong>執行環境</strong>不只是相依——jar 有問題你重打包，
image 有問題可能是底層 OS 的 CVE。</p>
</blockquote>

<hr />

<h2 id="2-什麼時機需要它">2. 什麼時機需要它</h2>

<p><strong>第一個時機</strong>：你的叢集連不到外網。這時候不是「要不要」，是「沒有就裝不起來」。</p>

<p><strong>第二個時機</strong>（更常見）：你需要回答這三題其中一題——</p>

<ul>
  <li>「線上跑的這顆 image 是誰建的、什麼時候？」</li>
  <li>「它掃過 CVE 了嗎？」</li>
  <li>「誰核准它進正式環境的？」</li>
</ul>

<p>公開 registry 一題都答不了。</p>

<hr />

<h2 id="3--為什麼-image-跟模型不能放同一個地方">3. ⭐ 為什麼 image 跟模型不能放同一個地方</h2>

<p>這是這篇的重點，而且它會決定你後面整個流程的形狀。</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>容器 image</th>
      <th>模型權重</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>放哪</td>
      <td><strong>registry</strong>（Harbor）</td>
      <td><strong>S3</strong>（MinIO）</td>
    </tr>
    <tr>
      <td>多久換一次</td>
      <td>幾週～幾個月</td>
      <td><strong>可能一天三次</strong></td>
    </tr>
    <tr>
      <td>誰產的</td>
      <td>CI/CD</td>
      <td>訓練 pipeline</td>
    </tr>
    <tr>
      <td>要不要簽章</td>
      <td><strong>要</strong></td>
      <td>看你</td>
    </tr>
    <tr>
      <td>要不要掃 CVE</td>
      <td><strong>要</strong></td>
      <td>掃不了（那不是程式）</td>
    </tr>
    <tr>
      <td>改一次的成本</td>
      <td>重 build + 重掃 + 重簽</td>
      <td>上傳一個檔案</td>
    </tr>
  </tbody>
</table>

<p><strong>綁在一起會怎樣</strong>：每次換模型，你要重跑一次完整的 image 建置、
CVE 掃描、簽章、以及所有相關的簽核。<strong>換一個權重檔要走一次發版流程。</strong></p>

<p>分開之後，<strong>同一個 image 可以服務不同的模型</strong>，
靠環境變數（<code class="language-plaintext highlighter-rouge">STORAGE_URI</code>）指到不同的 S3 路徑。</p>

<blockquote>
  <p>這就是 KServe 的 <code class="language-plaintext highlighter-rouge">storage-initializer</code> 存在的理由——
它讓 image 保持不可變，而模型可以換。</p>
</blockquote>

<hr />

<h2 id="4-怎麼用兩個-project-撐起一個放行流程">4. 怎麼用：兩個 project 撐起一個放行流程</h2>

<p>我的 Harbor 有這幾個 project：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tools       ← 平台自己要用的 image（serving runtime、buildah…）
odh         ← 鏡進來的上游 image
demo        ← 已放行
demo-tmp    ← 待審
</code></pre></div></div>

<p>⭐ <strong><code class="language-plaintext highlighter-rouge">demo-tmp</code> 和 <code class="language-plaintext highlighter-rouge">demo</code> 分開，是整個放行機制的關鍵。</strong></p>

<p>流程是這樣：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>build 完 → 推到 demo-tmp（待審）
           ↓  掃描、簽章、人工檢查
        管理員手動觸發 replication
           ↓
         demo（已放行）→ 正式環境只認這裡
</code></pre></div></div>

<p><strong>正式環境的叢集只被允許從 <code class="language-plaintext highlighter-rouge">demo</code> 拉。</strong> 所以「放行」這個動作
在技術上就是「<strong>把 image 從 demo-tmp 複製到 demo</strong>」。</p>

<h3 id="為什麼這比加一個-tag好">為什麼這比「加一個 tag」好</h3>

<p>常見的做法是用 tag 表示狀態：<code class="language-plaintext highlighter-rouge">myapp:staging</code> → <code class="language-plaintext highlighter-rouge">myapp:prod</code>。</p>

<p><strong>問題是 tag 可以被覆寫。</strong> 今天的 <code class="language-plaintext highlighter-rouge">prod</code> 和上週的 <code class="language-plaintext highlighter-rouge">prod</code> 可能是不同的東西，
而且沒有紀錄。</p>

<p>用兩個 project 的話：</p>

<ul>
  <li>進到 <code class="language-plaintext highlighter-rouge">demo</code> 的東西，<strong>digest 不變</strong>——它就是被審過的那一顆</li>
  <li>replication 的動作<strong>有紀錄</strong>，誰按的、什麼時候</li>
  <li>而且權限可以分開：<strong>開發者能推 <code class="language-plaintext highlighter-rouge">demo-tmp</code>，但不能推 <code class="language-plaintext highlighter-rouge">demo</code></strong></li>
</ul>

<blockquote>
  <p>我實測過這件事：從 <code class="language-plaintext highlighter-rouge">demo-tmp</code> replicate 到 <code class="language-plaintext highlighter-rouge">demo</code> 之後，
<strong>digest 完全相同，一顆 image 三個 tag</strong>。
那證明放行沒有改變成品本身——<strong>這正是稽核要的</strong>。</p>
</blockquote>

<hr />

<h2 id="5-一律用-digest不要用-tag">5. 一律用 digest，不要用 tag</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ✗ tag 會漂移</span>
image: myregistry/tools/llm-serve:cpu

<span class="c"># ✓ digest 是內容的雜湊，改了就是不同一顆</span>
image: myregistry/tools/llm-serve@sha256:4ed07b551253fef4f01...
</code></pre></div></div>

<p>我 lab 那顆 serving image：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tools/llm-serve   digest=sha256:4ed07b551253fef4f01…   tags=['cpu']   314MB
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">cpu</code> 這個 tag 明天可能指向別的東西，那個 digest 不會。</strong></p>

<p>⚠️ 而且前面提過：<strong>IDMS（離線鏡像的來源改寫）是用 digest 比對的</strong>。
你的 pod spec 如果寫 tag，IDMS 不會生效——那是一個典型的「設了但沒作用」。</p>

<hr />

<h2 id="6-關鍵指標">6. 關鍵指標</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>「跑完了」</th>
      <th>⭐「做對了」</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>私有 registry</td>
      <td>image 推得上去、拉得下來</td>
      <td><strong>拿線上跑的 digest，反查得到它是誰建的、掃過沒、誰放行的</strong></td>
    </tr>
  </tbody>
</table>

<p>第二欄是唯一有意義的驗收：<strong>給我一個 digest，你能不能回答那三個問題。</strong></p>

<p>如果答案是「要去問某某」，那你有的是一個檔案伺服器，不是 registry。</p>

<hr />

<h2 id="7-什麼時候不需要它">7. 什麼時候不需要它</h2>

<ul>
  <li>完全用公開 image、而且不需要回答上面那三題</li>
  <li>雲端環境、直接用託管的 registry（那也是私有 registry，只是別人管）</li>
</ul>

<p><strong>但如果你在企業內網、或有稽核要求，這一項沒有選擇。</strong></p>

<hr />

<p><strong>你們的 image 放哪？有沒有「待審」和「已放行」分開的機制？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="harbor" /><category term="registry" /><category term="openshift-ai" /><category term="supply-chain" /><category term="offline" /><category term="tutorial" /><summary type="html"><![CDATA[同一份東西，image 進 registry、模型進 S3。這個分法不是潔癖，是因為兩者的生命週期完全不同——以及它怎麼決定你的放行流程長什麼樣。]]></summary></entry><entry><title type="html">3.x 不會幫你開對外入口：「上線了」和「打得到」是兩件事</title><link href="https://ryangtr.github.io/2026/09/3x-does-not-give-you-a-route/" rel="alternate" type="text/html" title="3.x 不會幫你開對外入口：「上線了」和「打得到」是兩件事" /><published>2026-09-21T09:00:00+08:00</published><updated>2026-09-21T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/3x-does-not-give-you-a-route</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/3x-does-not-give-you-a-route/"><![CDATA[<h2 id="症狀">症狀</h2>

<p>模型部署完，<code class="language-plaintext highlighter-rouge">oc get isvc</code> 顯示綠的：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get isvc llm-scratch <span class="nt">-n</span> llm-serve-demo
<span class="c"># NAME          URL                                                            READY</span>
<span class="c"># llm-scratch   http://llm-scratch-predictor.llm-serve-demo.svc.cluster.local   True</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">READY=True</code>。</strong> 然後你把那個 URL 給前端工程師，他說連不到。</p>

<p>看清楚那個網址：<strong><code class="language-plaintext highlighter-rouge">.svc.cluster.local</code></strong>——那是叢集<strong>內部</strong>位址。
從叢集外面（你的筆電、前端伺服器、API gateway）打不到。</p>

<hr />

<h2 id="為什麼-2x-沒有這個問題">為什麼 2.x 沒有這個問題</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>2.x</th>
      <th>3.x</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>KServe 預設模式</td>
      <td><strong>Serverless</strong>（Knative）</td>
      <td><strong>RawDeployment</strong></td>
    </tr>
    <tr>
      <td>對外路由</td>
      <td><strong>Knative 幫你生</strong></td>
      <td><strong>你自己開</strong></td>
    </tr>
    <tr>
      <td>自動縮至零</td>
      <td>有</td>
      <td>沒有</td>
    </tr>
  </tbody>
</table>

<p>2.x 的 Knative 會連帶處理 ingress gateway 與對外網址，所以「部署完就打得到」。</p>

<p><strong>3.x 把 Knative 那層拿掉了</strong>——好處是排錯回到熟悉的 <code class="language-plaintext highlighter-rouge">oc get deploy/svc</code>，
不用先學 Knative；代價是<strong>路由與伸縮要自己接</strong>。</p>

<p>⚠️ 所以照 2.x 教學做的人，會在這裡卡住而且找不到原因——
因為那些教學根本沒有「開 Route」這一步。</p>

<hr />

<h2 id="怎麼開">怎麼開</h2>

<p>一行：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc create route edge llm-play <span class="se">\</span>
  <span class="nt">--service</span><span class="o">=</span>llm-scratch-predictor <span class="se">\</span>
  <span class="nt">--port</span><span class="o">=</span>8000 <span class="se">\</span>
  <span class="nt">-n</span> llm-serve-demo
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">edge</code> 是 TLS 終止在 Route 這一層（叢集內走 HTTP）。
其他選項：<code class="language-plaintext highlighter-rouge">passthrough</code>（TLS 直通到 pod）、<code class="language-plaintext highlighter-rouge">reencrypt</code>（重新加密）。</p>

<p>確認：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get route llm-play <span class="nt">-n</span> llm-serve-demo
<span class="c"># NAME       HOST/PORT                                        SERVICES</span>
<span class="c"># llm-play   llm-play-llm-serve-demo.apps-crc.testing         llm-scratch-predictor</span>
</code></pre></div></div>

<p>⚠️ <strong><code class="language-plaintext highlighter-rouge">--port</code> 要填容器實際開的 port，不是慣例值。</strong>
我的服務開在 <strong>8000</strong>（不是 8080），填錯的話 Route 建得起來但打不通。</p>

<hr />

<h2 id="-這個-route-沒有-owner">⭐ 這個 Route 沒有 owner</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get route llm-play <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.metadata.ownerReferences}'</span>
<span class="c"># （空的）</span>
</code></pre></div></div>

<p><strong>它不是 KServe 生的，是我自己建的。</strong></p>

<p>這一點有兩個實際後果：</p>

<p><strong>① 刪掉 ISvc，Route 不會跟著消失。</strong>
沒有 ownerReference 就沒有連帶刪除。你會留下一個指向不存在服務的 Route——
打過去得到 503，而且沒人知道它為什麼還在。</p>

<p><strong>② 它不在任何 GitOps 或備份的自動涵蓋範圍內</strong>，除非你自己把它寫進去。</p>

<blockquote>
  <p><strong>所以 Route 要跟 ISvc 放在同一份 manifest 裡管理</strong>，不要用 <code class="language-plaintext highlighter-rouge">oc create</code> 隨手開。
隨手開的東西，會變成三個月後沒人敢刪的東西。</p>
</blockquote>

<hr />

<h2 id="還有一個假網址要小心">還有一個假網址要小心</h2>

<p>ISvc 的 status 裡有兩個 URL：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get isvc llm-scratch <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.address.url}'</span>
<span class="c"># http://llm-scratch-predictor.llm-serve-demo.svc.cluster.local   ← 內部，真的</span>

oc get isvc llm-scratch <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.components.predictor.url}'</span>
<span class="c"># http://llm-scratch-predictor-llm-serve-demo.example.com          ← 假的</span>
</code></pre></div></div>

<p>第二個看起來像對外網址，但 <strong><code class="language-plaintext highlighter-rouge">example.com</code> 是 KServe 的預設 domain 沒被設定</strong>
留下的佔位值。<strong>拿它去打會 DNS 解不到。</strong></p>

<p>不要因為「status 裡有一個看起來像外部的 URL」就以為平台幫你開好了。</p>

<hr />

<h2 id="關鍵指標">關鍵指標</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>「跑完了」</th>
      <th>⭐「做對了」</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>對外入口</td>
      <td><code class="language-plaintext highlighter-rouge">oc get isvc</code> READY=True</td>
      <td><strong>從叢集外面 <code class="language-plaintext highlighter-rouge">curl</code> 得到回應</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>驗收就是這一句：從你自己的筆電打一次。</strong>
不要在 pod 裡打、不要用 port-forward、不要看截圖。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sk</span> https://llm-play-llm-serve-demo.apps-crc.testing/health
</code></pre></div></div>

<hr />

<h2 id="給驗收的提醒">給驗收的提醒</h2>

<p>這一項特別值得單獨列一條，因為它有一個很討厭的特性：</p>

<p><strong>在 lab 或 demo 環境，大家習慣用 <code class="language-plaintext highlighter-rouge">oc port-forward</code> 測試——那會通。
所以這個問題可以一路藏到正式環境上線當天。</strong></p>

<p>而上線當天發現「還要跟網路組申請對外入口」，
通常代表你要多等一個變更視窗。</p>

<hr />

<p><strong>你們的模型端點是誰開的？平台自動生的，還是網路組另外開的？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="openshift-ai" /><category term="kserve" /><category term="route" /><category term="networking" /><category term="acceptance" /><summary type="html"><![CDATA[ISvc 顯示 READY=True，你拿到的網址卻是 .svc.cluster.local。這是 2.x 換到 3.x 之後最容易忽略的一個差別，而它會在驗收當天才爆出來。]]></summary></entry><entry><title type="html">Kueue：CRD 說可以，webhook 說不行</title><link href="https://ryangtr.github.io/2026/09/kueue-managed-is-no-longer-supported/" rel="alternate" type="text/html" title="Kueue：CRD 說可以，webhook 說不行" /><published>2026-09-20T09:00:00+08:00</published><updated>2026-09-20T09:00:00+08:00</updated><id>https://ryangtr.github.io/2026/09/kueue-managed-is-no-longer-supported</id><content type="html" xml:base="https://ryangtr.github.io/2026/09/kueue-managed-is-no-longer-supported/"><![CDATA[<h2 id="1-這是什麼">1. 這是什麼</h2>

<p><strong>Kueue 是 Kubernetes 的工作排隊器。</strong></p>

<p>一般的 k8s 排程是「有資源就跑，沒資源就 Pending」。
Kueue 多了一層：<strong>先排隊，輪到你而且資源夠了才送進去跑</strong>。</p>

<blockquote>
  <p><strong>Java 類比</strong>：<code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code> 的那個 queue。
沒有它，每個任務都直接搶執行緒；有了它，任務先排隊，
而你可以決定誰先誰後、每個團隊最多能佔多少。</p>
</blockquote>

<p>在 AI 平台上這件事特別重要，因為訓練工作<strong>又大又長</strong>——
一個沒排隊機制的叢集，先送出的大任務會把 GPU 全部佔住，
後面的人只能等，而且不知道要等多久。</p>

<hr />

<h2 id="2-什麼時機需要它">2. 什麼時機需要它</h2>

<p><strong>當「誰能用 GPU」開始需要規則的時候。</strong></p>

<p>具體的訊號：</p>

<ul>
  <li>有人抱怨「我的訓練排不進去」，而你查不出是誰佔住的</li>
  <li>兩個團隊共用一批 GPU，開始為了先後順序吵架</li>
  <li>有人一次送十個任務把卡佔滿，其他人整天做不了事</li>
</ul>

<p><strong>如果你只有一個團隊、GPU 也只有一兩張——<code class="language-plaintext highlighter-rouge">ResourceQuota</code> 就夠了，
不需要 Kueue。</strong> 排隊機制的價值在「多方競用」，不在「資源不夠」。</p>

<hr />

<h2 id="3--怎麼用以及它為什麼一開始就擋你">3. ⭐ 怎麼用：以及它為什麼一開始就擋你</h2>

<p>照直覺，開一個元件就是把它設成 <code class="language-plaintext highlighter-rouge">Managed</code>：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc patch dsc default-dsc <span class="nt">--type</span><span class="o">=</span>merge <span class="se">\</span>
  <span class="nt">-p</span> <span class="s1">'{"spec":{"components":{"kueue":{"managementState":"Managed"}}}}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error from server (Forbidden): admission webhook
"datasciencecluster-v2-validator.opendatahub.io" denied the request:
Managed is no longer supported as a managementState
</code></pre></div></div>

<p><strong>「不再支援」——但它沒告訴你該用什麼。</strong></p>

<p>而更混亂的是，去看 CRD 的 schema：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get crd datascienceclusters.datasciencecluster.opendatahub.io <span class="nt">-o</span> json | <span class="se">\</span>
  jq <span class="nt">-r</span> <span class="s1">'.spec.versions[] | select(.name=="v2") |
         .schema.openAPIV3Schema.properties.spec.properties.components.properties
         | to_entries[] | "\(.key)\t\(.value.properties.managementState.enum)"'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kueue          ["Managed","Unmanaged","Removed"]     ← Managed 還在 enum 裡
kserve         ["Managed","Removed"]
aipipelines    ["Managed","Removed"]
...
</code></pre></div></div>

<p><strong>CRD 的 enum 裡明明列著 <code class="language-plaintext highlighter-rouge">Managed</code>。</strong></p>

<p>所以你會遇到這個狀況：<strong>照 schema 寫是合法的，但 webhook 會擋。</strong></p>

<h3 id="正解是-unmanaged">正解是 <code class="language-plaintext highlighter-rouge">Unmanaged</code></h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc patch dsc default-dsc <span class="nt">--type</span><span class="o">=</span>merge <span class="se">\</span>
  <span class="nt">-p</span> <span class="s1">'{"spec":{"components":{"kueue":{"managementState":"Unmanaged"}}}}'</span>
<span class="c"># patched</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Unmanaged</code> 的意思是：<strong>「我要用 Kueue，但 operator 不負責裝它，我自己裝。」</strong></p>

<p>在 RHOAI 上，你要另外裝 <strong>RHBOK</strong>（Red Hat Build of Kueue）這個獨立 operator。</p>

<p>設完之後：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KueueReady   False   PreConditionFailed: pre-conditions not met
</code></pre></div></div>

<p><strong>這是正確的狀態</strong>——它在說「你選了 Unmanaged，但我還沒看到你裝的那個 Kueue」。</p>

<hr />

<h2 id="4--這件事真正的教訓兩層驗證會不一致">4. ⭐ 這件事真正的教訓：兩層驗證會不一致</h2>

<p>Kubernetes 的資源驗證有兩層：</p>

<table>
  <thead>
    <tr>
      <th>層</th>
      <th>誰做</th>
      <th>依據</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>① Schema 驗證</strong></td>
      <td>API server</td>
      <td>CRD 的 OpenAPI schema（<code class="language-plaintext highlighter-rouge">enum</code>、<code class="language-plaintext highlighter-rouge">required</code>、型別）</td>
    </tr>
    <tr>
      <td><strong>② Admission webhook</strong></td>
      <td>operator 自己寫的程式</td>
      <td>任意邏輯</td>
    </tr>
  </tbody>
</table>

<p><strong>第二層可以拒絕第一層允許的東西</strong>，而且<strong>兩層可能不同步</strong>——
CRD schema 是宣告在 YAML 裡的，webhook 是程式碼，
改了程式碼忘了更新 schema，就會出現這種情況。</p>

<h3 id="對你的實際影響">對你的實際影響</h3>

<p><strong>你不能只靠 <code class="language-plaintext highlighter-rouge">oc explain</code> 或 CRD schema 來決定怎麼寫 YAML。</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain dsc.spec.components.kueue.managementState
<span class="c"># 會告訴你可以填 Managed —— 而那是錯的</span>
</code></pre></div></div>

<p><strong>唯一可靠的驗證是 <code class="language-plaintext highlighter-rouge">--dry-run=server</code>：</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> my-dsc.yaml <span class="nt">--dry-run</span><span class="o">=</span>server
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--dry-run=server</code> 會<strong>真的送到 API server 跑完整個 admission 流程</strong>
（包含所有 webhook），只是不寫進 etcd。</p>

<p>⚠️ 注意不要用 <code class="language-plaintext highlighter-rouge">--dry-run=client</code>——那個只做本機的 schema 檢查，
<strong>完全不會碰到 webhook</strong>，也就抓不到這個問題。</p>

<blockquote>
  <p>這一條可以直接寫進你的變更流程：
<strong>所有要進正式環境的 YAML，先在測試叢集 <code class="language-plaintext highlighter-rouge">--dry-run=server</code> 過一次。</strong>
那比人工 review 有效得多，因為它跑的是真正會擋你的那段程式。</p>
</blockquote>

<hr />

<h2 id="5-關鍵指標">5. 關鍵指標</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>「跑完了」</th>
      <th>⭐「做對了」</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Kueue</td>
      <td><code class="language-plaintext highlighter-rouge">KueueReady=True</code></td>
      <td><strong>送一個超過配額的任務，它真的排隊而不是直接跑</strong></td>
    </tr>
  </tbody>
</table>

<p>第二欄跟這個系列其他篇是同一個模式：<strong>排隊機制的價值在它會擋東西。
沒擋過任何東西的排隊器，跟沒有排隊器一樣。</strong></p>

<hr />

<h2 id="6-什麼時候不需要它">6. 什麼時候不需要它</h2>

<ul>
  <li>單一團隊、GPU 不需要搶</li>
  <li>用 <code class="language-plaintext highlighter-rouge">ResourceQuota</code> 就能表達你的規則（例如「這個 namespace 最多兩張卡」）</li>
</ul>

<p><strong>Kueue 解決的是「順序」與「公平」，不是「上限」。</strong>
如果你要的只是上限，<code class="language-plaintext highlighter-rouge">ResourceQuota</code> 更簡單而且不用多裝一個 operator。</p>

<hr />

<h2 id="7-我沒驗過的部分">7. 我沒驗過的部分</h2>

<p>我<strong>沒有實際裝 RHBOK 也沒有跑過排隊</strong>——我的叢集只有一個節點、
沒有 GPU、CPU 還已經用到 99%，跑不了有意義的排隊實驗。</p>

<p>所以這篇能給你的是：<strong>怎麼開、為什麼被擋、正解是什麼</strong>，
以及那個兩層驗證的教訓。<strong>排隊行為本身我沒有證據。</strong></p>

<hr />

<p><strong>你們有在用 Kueue 或其他排隊機制嗎？還是靠 namespace quota 硬擋？</strong></p>

<details class="lab-env" style="margin:2.5rem 0 1rem;padding:.9rem 1.1rem;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;font-size:.92rem;line-height:1.75;">
<summary style="cursor:pointer;font-weight:600;">🧪 這篇的實驗環境與 lab 檔案（最後更新 2026-08-30）</summary>

<p style="margin:.8rem 0 .4rem;"><strong>叢集</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>CRC 2.63.0 · OpenShift <strong>4.22.7</strong> · Kubernetes v1.35.6</li>
  <li>單節點：<strong>13 vCPU / 40 GiB RAM / 120 GB disk</strong></li>
  <li>宿主：<strong>Framework Laptop 16</strong>（Ryzen AI 7 350 · 8C/16T · 64 GB RAM ·
      1 TB NVMe · RTX 5070 顯卡模組）</li>
  <li>⚠️ <strong>叢集內看不到 GPU</strong>（CRC 是 VM，RTX 5070 未 passthrough）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>Operator</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>opendatahub-operator.v3.5.0</code> ← 即 RHOAI 3.x 的上游開源版</li>
  <li><code>cert-manager-operator.v1.20.0</code>（3.x 的必要相依；2.x 不需要）</li>
  <li><code>openshift-pipelines-operator-rh.v1.23.2</code>、<code>openshift-gitops-operator.v1.21.3</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>DataScienceCluster 開啟的元件</strong></p>
<ul style="margin:0 0 .8rem;">
  <li><code>kserve</code>、<code>aipipelines</code>、<code>dashboard</code>、<code>workbenches</code>、<code>modelregistry</code>、<code>kueue</code>（Unmanaged）</li>
  <li>其餘（<code>ray</code>／<code>trustyai</code>／<code>feast</code>／<code>aigateway</code>…）為 <code>Removed</code></li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>叢集外的依賴</strong>（跑在宿主的 podman 上，<code>crc start</code> 不會帶起來）</p>
<ul style="margin:0 0 .8rem;">
  <li>Harbor v2.15.2（私有 registry）· MinIO（S3）</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>模型端</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>Python 3.12.13 · PyTorch <strong>2.11.0+cu128</strong> · FastAPI + uvicorn · prometheus-client</li>
</ul>

<p style="margin:.8rem 0 .4rem;"><strong>lab 檔案</strong></p>
<ul style="margin:0 0 .8rem;">
  <li>YAML／Containerfile／腳本：<a href="https://github.com/ryanGTR/openshift-ai-30days"><strong>github.com/ryanGTR/openshift-ai-30days</strong></a>
      （含 Day 對照表；主機名是佔位符，跑 <code>set-lab-host.sh</code> 換成你自己的）</li>
  <li>服務的那個模型：<a href="https://github.com/ryanGTR/llm-from-scratch">llm-from-scratch</a>（從零手刻的小 GPT）</li>
</ul>

<p style="margin:.6rem 0 0;color:#666;">
⚠️ <strong>ODH ≠ RHOAI</strong>：元件同源，但 <strong>namespace 與部分名稱不同</strong>
（我這裡是 <code>opendatahub</code>，商用版是 <code>redhat-ods-*</code> 那一套）。
<strong>指令的邏輯可以照用，字串要自己對一次。</strong>
</p>
</details>]]></content><author><name>Ryan Chen</name></author><category term="openshift-ai" /><category term="odh" /><category term="kueue" /><category term="crd" /><category term="webhook" /><category term="troubleshooting" /><summary type="html"><![CDATA[把 kueue 設成 Managed 被拒絕，但 CRD 的 enum 裡明明還列著 Managed。這篇是兩層驗證不一致的實例，以及它對「照文件做」的意義。]]></summary></entry></feed>