iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 26

Day 26:CLI scripts first,Bicep later:轉換是兩個軸,不是一個開關

  • 分享至 

  • xImage
  •  

這個 lab 的 infra 目前是 24 支 bash 腳本加一片 Bicep 模板,這個比例很容易被讀成技術債——都 2026 年了,怎麼還在寫 shell。這一篇要說清楚它是怎麼出來的:哪些東西宣告式接得住、哪些接不住、分水嶺畫在哪裡。讀完你會得到一個可以帶走的判斷框架——從 scripts 到 IaC 的轉換是兩個獨立的軸,不是一個開關;而把它壓成開關,正是我自己在盤點時犯過的錯。

第一版為什麼是腳本

先講前提,因為它決定了後面所有的權重。本系列自費、自訂每月 US$20 上限,所以資源 ephemeral-by-default:AI Search 只在測試時段存在、Content Safety 用完就拆、Day 24 部署的 Container Apps 在 live session 收尾時砍掉。會計費或會留 session 殘留的資源,每一個都有明確的拆除路徑——這不是整潔癖,是預算模型的直接後果。

(精確講不是「每支 create 都有同名 delete」:create-budget-alert.shcreate-resource-group.sh 就沒有配對的 delete 腳本,因為 budget alert 不計費、resource group 是其他 teardown 的載體。)

在這個前提下量一次現狀(lab @ day-26,2026-08-20):

腳本數/總行數 24 支/5,214 行
直接建立 ARM 資源的呼叫點 16 個

16 個 create、5,000 多行。這組數字有兩種誤讀。第一種:「一個 az 呼叫要這麼多儀式,換 Bicep 就好了」。第二種反過來:「才 16 個資源,一片小模板就收掉了」。

第二種錯得比較隱蔽,值得點名:用 create 這個動詞去盤點,會嚴重低估宣告式接得住的範圍。role assignment、APIM policy、container app 的 image 更新、Entra 目錄物件,全都是可宣告的 ARM 或 Graph 狀態,但沒有一個長成 az <group> create。最極端的例子是 APIM 的 demo-client subscription,它是用 az rest --method put 建的,任何以 create 動詞為基礎的盤點根本看不到它

那其餘五千行在做什麼?

  • fail-closed 讀回驗證:幾乎每次 mutation 後跟一次 showlist,空輸出一律中止
  • 順序契約:role assignment 早於 identity 刪、federated credential 早於 app registration
  • per-run 唯一命名:用固定名字辨識資源的 cleanup,會把並行那個 run 的資源一起 purge 掉——Day 21 用一次真實事故換來的教訓
  • 三種語意各不相同的 soft-delete purge,以及半完成狀態的復原路徑

delete-github-oidc.sh 有 852 行,比它要拆的那支 create 腳本還長 167 行

這些類別不會因為改用 Bicep 而自動消失——會消失的是 provisioning 那一段:main.bicep 已經接走 create-openai.sh 的建立形狀,連同建立側的讀回。留下來的 purge、順序契約、secret 產生與圍著它們的 fail-closed 讀回,是「拆得乾淨」與「壞掉時看得出來」的成本,不是「把資源建起來」的成本。

能不能宣告?幾乎都能

把 lab 建過的每一種物件逐一過一遍,會發現「能不能用 Bicep 建」跟「能不能把拆除交出去」是兩個問題,答案不一樣,所以矩陣要有兩欄。完整的 21 列在 docs/infra-evolution.md,這裡取最有代表性的:

物件 可宣告? 拆除需要什麼 拆除可交給 stack?
AOAI 帳號+2 個 model deployment 是(main.bicep 刪+purge 推論:否
Role assignments/managed identity assignment 早於 identity 未驗證
Key Vault/Content Safety/APIM 刪+purge(語意各不同) 推論:否
Entra app/SP/federated credential 是(Graph extension) 刪+recycle bin purge (官方明文)
Entra client secret 隨 app 隨 app(Graph 整類=官方明文否)
Search index schema 否(data plane) 隨 service 不適用(data plane)
GitHub environment/secrets 否(不是 Azure) 手動 不適用(不是 Azure)

三個結論。

第一,「可宣告」那一欄幾乎全是「是」——「Bicep 接不住這個 lab」不成立。

連直覺以為不行的 Entra 物件都接得住:Microsoft Graph Bicep extension v1.0 支援 applicationsservicePrincipalsfederatedIdentityCredentials,本系列 Day 19/25 建的型別幾乎全在清單上(查核 2026-08,Graph Bicep reference)。

第二,真正不可宣告的只集中在三類狀態:data plane 的索引結構、secret 的產生(Graph extension 明文不支援 passwordCredentials,官方 workaround 是用 DeploymentScript 去呼叫 Graph API),以及不是 Azure 的系統。完整矩陣裡另有一列根本不是狀態而是動作——container image build——宣告式的問題對它不成立,不算進這三類。

第三,分水嶺在最右欄,而那一欄整欄沒有驗證過。這個 lab 從來沒跑過 deployment stack,每個「推論:否」都是從 actionOnUnmanage 的文件行為推的,每個「未驗證」就是字面意思。

這裡藏著本次查核最重要的發現(查核 2026-08,Graph Bicep limitations):Bicep 宣告得了的那些 Entra 物件,正好是 deployment stacks 管不到、what-if 也預覽不了的那批。把 Entra 搬進 Bicep,換到的是宣告式的建立,換不到宣告式的拆除或變更預覽——而本系列的 Entra 物件恰恰是拆除紀律最嚴的一批。

兩個軸,不是一個開關

把上面的觀察收攏,轉換其實是兩個獨立的決定:

軸一——provisioning ownership。建立與更新交給 Bicep,拆除留在腳本。條件三個:(a) 型別能表達它;(b) 你在意的屬性不是 resource function 或 secret 產出;(c) 變更可預覽——該資源型別受 what-if 支援,文件背書即達標,live 跑過一次只是把證據升級。依 (c),Graph 物件沒過軸一:型別宣告得了,what-if 明文不支援。

軸二——full lifecycle ownership。連拆除都交出去,靠 deployment stacks。** 前置是先對該資源型別實測過 deployment stack**;在軸一之上額外要求:(d) 拆除語意能被 actionOnUnmanage 完整表達——不需要 purge、不需要跨系統的順序;(e) ownership 可追蹤。

兩軸判斷流程:一個 infra 物件先過軸一(provisioning ownership)的三個條件——型別可宣告、在意的屬性不經 resource function 或 secret 產出、變更可用 what-if 預覽;任一不成立就把建立與更新留在腳本(例:Entra client secret、Search index schema、GitHub;Graph 物件過得了型別那關卻沒有 what-if,卡在條件 c),三條全成立才把建立與更新交給 Bicep、拆除仍留腳本(main.bicep 的 AOAI 帳號加兩個 deployment 在這格)。之後才是軸二(full lifecycle ownership):前置是對該資源型別實測過 deployment stack,再問 actionOnUnmanage 表達得了它的拆除、ownership 可追蹤——未實測或任一不成立,teardown 留在腳本(本 lab 今天全部物件都在這裡);實測過且兩條成立,生命週期才整包交給 deployment stack(本 lab 今天零個)。旁註:預設 actionOnUnmanage 是 detach,猜錯等於以為拆了的資源繼續計費

為什麼一定要拆成兩個?因為壓成一個開關,會得出一個聽起來很果斷、但錯的結論:「這個 lab 的資源要 purge,所以 Bicep 不適合這個 lab」。purge 是軸二的問題,它對「模板該不該負責把帳號建起來」什麼都沒說。我在第一版盤點裡就是這樣推的,推出「ephemeral 的期望狀態是不存在,所以宣告式沒東西可宣告」——這句話錯在 ephemeral 資源在 session 期間的期望狀態就是「存在」,而 deployment stacks 的官方文件甚至拿短期測試環境當宣傳場景。ephemeral 不是宣告式的禁區。

軸二今天一格都沒過,但要把量詞講準:需要 purge 的資源是推論的否,Graph 物件是官方明文的否,其餘全部是未驗證——未驗證不等於不成立,只是沒有證據能拿來裁決,而拆除是這個 lab 最不能用猜的部分。

官方明文的否不只 Graph 一類。deployment stacks 也明文刪不了 Key Vault secrets(查核 2026-08,stacks known issues),從模板移除已納管的 secret 要走 detach。

而且 CLI 那三個 actionOnUnmanage 值也不是完整的 schema——底層另有 resourcesWithoutDeleteSupport: detach | fail 專門處理這類資源。

這不代表 vault 本身刪不掉,但它示範了行級例外的存在:整類資源的判定,替代不了逐列查。

猜錯的代價也不對稱。deployment stacks 預設的 actionOnUnmanagedetach 不是 delete(查核 2026-08,deployment stacks 文件):設錯了不會有東西壞掉,是你以為拆掉的資源安靜地繼續跑、繼續計費——對一個靠拆除控制預算的 lab,這是最貴的一種失敗模式。

https://ithelp.ithome.com.tw/upload/images/20260826/20168288ykaEQTwQiT.png
忍喵:最貴的資源是你以為已經刪掉的那個。detachAll 不會弄壞任何東西——它只是讓帳單替你記得。

Day 24 那個隱式 Log Analytics workspace,在宣告式世界裡也有對應的一課。當時的教訓是「沒宣告的東西平台會替你建,teardown 找不到它」;搬到 Bicep 世界,正確的表述不是「宣告式管不了隱式資源」,而是把它明確宣告出來,它就不再是隱式資源

軸一真的跨了:一片 128 行的模板

裁決不能只停在紙上,所以這個 milestone 交付了一片真的 Bicep:infra/bicep/main.bicep,內容是 create-openai.sh 的翻譯——同樣的 AOAI 帳號、同樣的兩個 model deployment、同樣的屬性,多的一個都沒有

delete-openai.sh 一行沒動:purge 留在腳本裡,這正是軸一的定義。

resource account 'Microsoft.CognitiveServices/accounts@2026-05-01' = {
  name: openAiName
  location: location
  kind: 'OpenAI'
  sku: {
    name: 'S0'
  }
  properties: {
    customSubDomainName: openAiName
  }
}

一個看起來只是格式的細節其實是裁決:API 版本釘 2026-05-01。對 accounts 型別,它是 Bicep CLI 0.46.1 有型別的最新 stable 版(2026-08-20 對 provider 的 26 版清單逐版核對);「最新」這個排序只對 accounts 成立——子型別 accounts/deployments 在 provider manifest 裡根本沒有自己的版本清單(2026-08-21 查核),對它成立的主張是逐版掃描找到 6 版無型別、2026-05-01 不在其中。

provider 廣告的最新兩版在這個工具鏈裡沒有型別。沒有型別的版本照樣編譯得過——只多一個 BCP081 警告,然後所有屬性檢查歸零,這等於把「寫模板而不是寫腳本」的理由整個丟掉。所以釘版是刻意的:釘一個有型別的版本,並把查核日期寫進註解。

轉換不是只有得到,也有失去的,而且失去得很安靜。create-openai.sh 開頭有一條守衛:兩個 deployment 名字相同就拒絕執行——因為 az cognitiveservices account deployment create 是 upsert,第二次呼叫會把第一個 deployment 重新設定成 embedding 模型,而兩行成功訊息照印。

翻譯成 Bicep 時我以為型別系統會接走這條防線,實測(Bicep CLI 0.46.1,2026-08-21)發現沒有:Bicep 的重名檢查只看字面——兩個資源用同一個字面名稱,BCP121、build 失敗;名稱來自同一個參數,零診斷、build 全綠。這條守衛在宣告式版本裡就這樣消失了,而且不發出任何聲音。

模板裡對應的處理是把這件事寫進註解,標明是缺口、不是已解——至於 ARM 在實際部署時會不會攔下兩個同名子資源,本次不部署,不主張。

軸一跨過之後,留在 bash 裡的東西值得逐項點名,免得有人以為轉 Bicep=腳本歸零:三種 purge(purge 是操作,不是期望狀態)、secret 的產生、listKeyslistSecrets 這類取值鏈(what-if 明文解析不了 resource function——你最想預覽的那一步,正好是預覽不到的那一步)、data plane 的索引結構、GitHub、以及所有 fail-closed 讀回。

what-if 給你的是線索,不是判決

轉過去以後日常會依賴的工具是 az deployment group what-if,所以它的輸出品質值得一次實測校準。以下全部在 japaneast、azure-cli 2.89.0、Bicep CLI 0.46.1 執行,零部署、零資源變動、零成本——首測 2026-08-20,對出貨的 main.bicep 於 2026-08-21 重跑 what-if,結果逐項相同:

az deployment group what-if \
  --resource-group "$AZ_RESOURCE_GROUP" \
  --template-file infra/bicep/main.bicep \
  --parameters openAiName="$AZ_OPENAI_NAME" \
  --subscription "$AZ_SUBSCRIPTION_ID"

對一個沒有任何人動過的資源群組,what-if 回報 3 個資源 Modify、12 項 property 層級的移除。標準解釋是「模板沒寫的欄位被誤報成刪除,是 noise」——官方文件也承認這件事,但量詞是 "some" properties(查核 2026-08,what-if 文件),不是全部。把 12 項逐一分類,分出三條線:

  • 模板省略、但 API 真的接受的屬性deploymentState 官方文件明寫它控制「deployment 是否接受 inference 請求」,值域 PausedRunning——這不是無關痛癢的 drift。
  • 現行型別與匯出都不涵蓋的欄位a365LoggingEnableda365Status)——高度疑似 noise。
  • 實際部署的效果:未測。要知道答案就得真的部署,動到常駐資源上與模型行為有關的屬性。

前兩條的界線是用一個對照實驗劃出來的:Bicep 對真正唯讀的屬性會發 BCP073(宣告 endpointprovisioningState 三處全發),對 currentCapacitydeploymentState 零診斷——它們是可寫的真屬性,只是模板沒說話,而 what-if 把沉默呈現成移除。所以結論是:what-if 值得用,但 property 層級的輸出要當成待查證的線索讀,不能當判決。

同一天還校準了另外兩件事。az group export(想把既有資源反向變成模板的那條路)fail-open:印了一個 WARNING、兩行 ERROR,exit code 是 0——任何相信 exit code 的腳本會拿到殘缺模板而毫無知覺。而且腳本只宣告了 3 個資源,匯出回來 6 個,多的是 Azure 隱式建立的 defenderForAISettings 和兩份 raiPolicies,帶著完整的 content-filter 設定。

官方自己說 export「不是把既有資源變成可用於 production 的模板的可靠方法」(查核 2026-08,export 文件),照單全收即可。

為了不只挑對自己有利的證據,反例也記一個:這次的匯出丟給 az bicep decompile,固定免責聲明一條、Bicep 診斷零條——「export 再 decompile 會很亂」這句流傳的話,在這個案例上不成立。

最後一條線索直接接回 Day 24。az cognitiveservices account show 看不到 a365* 欄位,但用 CLI 自己那個 api-version 直接對 ARM 發 GET,欄位就在——所以不是 ARM 沒回,是 CLI 自己的處理鏈把欄位丟掉了;最可能的縫隙是 SDK 的 typed response model,但 capture 沒有鎖定確切丟棄點。

Day 24 是同一道縫隙的另一個方向、而且有原始碼佐證:YAML 沒寫的欄位在送出時被物化成明確的 null。一來一回,都有一層 typed 的東西站在你和 API 中間,而你看到的都不是 ARM 說的原話。

什麼時候轉:條件,不是日期

最後把裁決收攏成兩條判準。

軸一:條件齊了就轉,不必等拆除的問題有答案。 型別可宣告+在意的屬性不經 resource function+what-if 涵蓋——今天 AOAI 齊備(而且有 live what-if 背書),budget 與 resource group 齊備(型別與文件層面;沒做過 live probe,證據強度自己標清楚)。

軸二:先要有人實測過,再談條件。 這個 lab 排進 roadmap 的下一步是一次有邊界的 live probe:獨立資源群組、per-run 唯一名稱、最低權限 role、部署前後各一份 resource-ID 清單、失敗時的 cleanup 路徑,拿 deployment stack 實際拆一次 managed identity 與它的 role assignments。

probe 會先跑 stack 專用的 what-if 當 pre-mutation check——Microsoft 於 2026-08-14 為它出了專頁,而 known-issues 頁同期仍寫不可用(查核 2026-08),這個文件衝突照實記、不擅自選邊。結果只用來關閉矩陣裡那兩列,不外推到 purge、不外推到 Graph。在那之前,teardown 留在腳本不是保守,是因為沒有證據支持交出去。

途中否決過兩個方案,值得留底。其一,直接把整包生命週期交給 deployment stacks——否決的理由很單純:它對這批資源型別的拆除行為零實測,而 detachAll 這個預設值讓「猜錯」的代價是安靜的持續計費。其二,把 bicep build 接進 CI——這片模板目前沒有部署 consumer,為它在 Day 25 剛收斂的 pipeline 上加 gate、還要在 runner 裝 Bicep,是無謂耦合;README 記錄工具版本、驗證日期與本機驗證指令即可。

https://ithelp.ithome.com.tw/upload/images/20260826/20168288u6MLBEChuz.png
忍喵:為一片沒人部署的模板加 CI gate,跟為空房間裝保全一樣虔誠。少一個綠勾勾,也是修行。

最後是誠實的適用範圍:這個 lab 是特例。它的資源 ephemeral-by-default,是 US$20 天花板逼出來的,所以拆除承擔了不成比例的權重。如果你的資源是常駐的——多數團隊是——拆除是罕見事件、drift 才是每天的事,那軸一早就該跨了,軸二對你也是一個比這裡容易得多的問題。

這天的誠實邊界

  • deployment stacks 零實測。矩陣「拆除可交給 stack?」欄沒有一格有一手證據,但等級不同:purge 類是推論的否、Graph 與 Key Vault secret 是官方明文的否、其餘是未驗證。
  • Graph Bicep extension 零一手驗證。支援清單與 limitations 全部來自官方文件(查核 2026-08)。
  • what-if 那 12 項移除的實際部署效果未測。列為邊界,不列為待辦——驗證它就得對常駐資源動手。
  • 重名守衛的缺口只驗證到 build 層。ARM 在部署時對兩個同名子資源會怎麼處理,未測。
  • 模板裡兩個 deployment 的 dependsOn 序列化是沿用腳本的既有形狀,未經這個 repo 的實際部署驗證。

用到的 Azure 服務

  • Azure OpenAI in Microsoft Foundry(常駐 chat-mini 所在的資源群組,僅作為 what-if/export/ARM GET 的唯讀查詢對象)

本篇無新增雲端資源、零 mutation、零成本:what-if 與 export 都是唯讀操作,重名守衛與型別覆蓋的實驗全在本機 bicep build 完成。

下一篇

Infra 這條線在這裡收束:資源怎麼生出來(scripts)、怎麼收回去(scripts)、哪一半值得交給宣告式(軸一,已跨)。Day 27 回到 request 本身——用 Application Insights 追一次 LLM request 的完整生命週期,Day 8 開始累積的 correlation id 終於要在 trace 裡兌現。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 25:用 GitHub Actions 建立 CI/CD:每道防線都比它的名字窄
下一篇
Day 27:用 Application Insights 追一次 LLM request:缺的自己補,多的自己關
系列文
Backend 工程師的 Azure GenAI 實戰35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言