Day 23 結尾開了兩張支票:把 image 放上 Azure Container Apps,和償還「8 秒 cleanup 預算、2 秒 margin」這兩個未量測假設。這天兌現:沿用 Day 23 的 Dockerfile 交給 ACR 重建,部署成對外的 Container Apps app——公開 FQDN 進來的請求驗 Entra token,出去對 Azure OpenAI 的呼叫用 managed identity,唯一剩下的 key(Search)由平台從 Key Vault 解析注入。
但這篇真正想讓你帶走的是一個判準:在宣告式部署裡,「沒寫」從來不等於「預設」。 省略的欄位可能被工具送成明確的 null,沒配的 probes 不會有人替你補,刪掉的 identity 不會帶走它的 role assignments。下面五個設計面會反覆撞上這個問題。
部署完成的標準是 readiness gate 用真請求打通整條鏈,不是 az containerapp create exit 0。
這個系列需要的是一個 solo 工程師能在一個下午建起來、拆乾淨的 container runtime,預算天花板 US$20/月,而且不想營運一座 cluster。Container Apps 對上這個系列三個既有的約束:
/health、同一個 SAMPLE_DOCS_DIR 機制,build graph 一個 stage 都沒加。image 本身則由 ACR 重建(下一節)——keyless 路徑帶進一個 runtime 依賴(aiohttp,azure.identity.aio 需要的 async transport)加 managed identity 的接線,所以位元組跟 Day 23 本機那顆不同。也先講清楚它不是什麼:不是 Bicep(Day 26)、不是 CI/CD pipeline(Day 25)、沒有 APIM。本篇的可重現單位是一支 operator 手跑的 shell script。
身份照 Day 20 定好的 plan 走:一個 user-assigned managed identity,是這個 app 對每個方向的身份。
選 user-assigned 而非 system-assigned 的理由是順序:system-assigned 的 principal 要等 app 存在才存在,角色只能事後補——script 做得到(建 app→讀回 principal→授權→重啟),代價是多一個階段,而且 workload 第一次啟動可能發生在角色就位之前。user-assigned 讓三個 role assignment 在 app 第一次跑起來之前就全部存在。
| 消費者 | Role | Scope | 誰負責解析 |
|---|---|---|---|
| Container Apps 拉 image | AcrPull |
container registry | 平台(registries[].identity) |
| Search admin key | Key Vault Secrets User |
key vault | 平台(Key Vault reference) |
| App 呼叫 Azure OpenAI | Cognitive Services OpenAI User |
Azure OpenAI 帳號 | app 自己(ManagedIdentityCredential) |
只有第三條在容器裡執行——app 程式碼真的要自己 mint token,所以 identity 不能對容器隱藏。
這裡就是第一個「沒寫」:identity 要在 YAML 的 top-level identity block 掛上(type: UserAssigned 加 resource id 的 map)。
registries[] 和 secrets[] 底下的 identity: 欄位是引用一個已經掛上的 identity,不是掛載動作本身(查核 2026-08,ACA ARM/YAML spec,ms.date 2025-04-09)。
漏掉 top-level block,image pull 和 Key Vault reference 會一起失敗——而那個 identity,YAML 裡明明點名了兩次。
本機是 Apple Silicon,image 卻要 linux/amd64。與其在本機 QEMU 交叉編譯,不如讓 registry 自己 build:az acr build 把 build context 上傳到雲端、在那邊 build 完直接進 registry,不需要本機 Docker daemon、不需要 push 憑證。
ACR Tasks 預設就是 Linux/AMD64(查核 2026-08,ACR Tasks overview),script 仍顯式傳 --platform linux/amd64——預設值不是拿來賭部署的。
有兩件事,是 2026-08-17 真的去部署才學到的,各自收斂成一條紀律:
--file 要傳絕對路徑。 --help 說 --file 相對於 source code root 解析,實作卻是相對於呼叫者的工作目錄——live run 的工作目錄剛好不在 repo root,stage 5 當場找不到 Dockerfile。紀律:--help 是主張,原始碼和實測才是證據(這個系列從 Day 13 就在講同一件事);絕對路徑在任何工作目錄下都穩。
.dockerignore 在這條路徑上修剪得比你以為的少得多。 az acr build 上傳了 41.081 MiB 的 context——azure-cli 對帶尾斜線的目錄規則有 bug,本 repo 的排除規則在這條路徑上一條都沒生效。
原始碼細節與逐位重現都收在 docs/container-apps.md。紀律:換一個 build 環境,context 的語意就要重新量一次,不能拿本機的數字外推。
前提:az CLI 加 containerapp extension、az login 後對工作訂閱有權限,Day 19/20 的周邊資源先就位(Key Vault、Azure OpenAI、Search、Entra app registrations——各自的 create script 與順序見 runbook)。所有命令從 repo root 跑:
export AZ_SUBSCRIPTION_ID=… AZ_RESOURCE_GROUP=… AZ_LOCATION=japaneast
export AZ_OPENAI_NAME=… AZ_SEARCH_NAME=… AZ_KEYVAULT_NAME=… ENTRA_TENANT_ID=…
export AZ_ACR_NAME=… # create-acr.sh 印出的 per-run 唯一名
export ENTRA_AUDIENCE=… # API app registration 的 application id
export ENTRA_CLIENT_APP_ID=…
export ENTRA_CLIENT_SECRET=… # 只走環境變數,不進參數、不落 log
infra/scripts/deploy-container-app.sh
四個 checkpoint,每個都有機械判準:
az acr build 以 Run … succeeded 收尾,image 進 registry。/health 回 200。PASS gate: authenticated POST /api/v1/chat returned 200 與 Deployed and gated.——這才叫部署完成。delete-container-app.sh,第 6 步讀回該 principal 名下 assignments=0 才會刪 identity(Teardown 一節講為什麼)。一個副作用要先知道:stage 1 會 az account set 把你 shell 的預設訂閱指過去,script 會在 stdout 明講——跑完記得切回去。
deploy-container-app.sh 配的是 external HTTP ingress、targetPort: 8000、transport: auto。幾個邊界值得知道(查核 2026-08,Ingress in ACA,ms.date 2025-05-02):
properties.configuration.ingress.fqdn),之後每一步都用 Azure 回報的值,不用命名慣例拼字串。/api/v1/chat/stream 是一條長連線,撐過這個窗會被平台切掉,不是被 app 切掉。本 lab 沒量測這件事,也不繞它,先寫下來。Ingress 對外,代表 trust boundary 必須跟著換檔。Day 23 才寫過:預設的 AUTH_MODE=headers 信任 caller 自報的 tenant/user/groups,是 demo default 不是 safe default。
所以這個部署跑 AUTH_MODE=entra——public ingress 配已驗證身份,Principal 全部來自驗過簽的 claim(tid/oid/groups),401/403 契約照 Day 19。規則一句話:headers mode 不離開筆電。
Revision 是 app 每個版本的不可變快照:你不編輯 revision,你產生下一個(查核 2026-08,Update and deploy changes,ms.date 2025-10-27)。本篇跑 single revision mode(也是平台預設)。兩個後果都會咬人。
Zero-downtime 換版是有前提的。 Single revision mode 下「新 revision ready 之前,舊的不會被停用」且繼續收 100% 流量——而 ready 的定義包含所有 replica 通過 startup 與 readiness probes。
這就接到第二個「沒寫」:CLI/IaC 部署不會有預設 probes。ACA 不支援 exec probes,預設 TCP probes 只在 portal 部署+ingress+main container+非 GPU 四個條件齊備時才自動補(Day 23 已核)。
所以 YAML 顯式配三個 probes,全部 HTTP GET /health:startup(initialDelaySeconds: 2/periodSeconds: 3)、liveness 與 readiness(各 10 秒一次)。/health 不驗證是設計(它不在 /api/v1 底下),probe 才打得進去。
Revision deactivation 是可控的 shutdown 觸發器。 容器在 scale-in、app 刪除、revision 停用三種情況下都會關機(lifecycle 頁,ms.date 2025-11-07),其中只有停用是你能挑時間、挑對象執行的。single revision mode 部署新版會自動停用舊版——所以「刻意停用一個 revision」就是 shutdown 量測的觸發器。
另一個容易踩的分界:properties.template 底下的變更(image、env、probes)產生新 revision;properties.configuration 底下的(secret 值、ingress 設定)不產生。轉 Key Vault secret 的值因此不是 revision 事件——它觸發的是另一種東西,secrets 一節講。
Day 23 承諾的量測這天跑了。觸發器是 az containerapp revision deactivate,觸發前先記下 revision 與 replica 身份,事後每一行 log 都綁回那個 replica;log 從 Log Analytics 讀(replica 消失後 log stream 就沒了,workspace 還在)。三個 marker 齊全,量測有效:
02:09:06,351 lifespan shutdown started
02:09:06,351 shutdown cleanup started budget_seconds=8.0
02:09:06,352 shutdown cleanup finished elapsed_seconds=0.001
對照三個帳目(單次量測,2026-08-17,japaneast):
30 − 20 − 2 推導的設計點。量測還推翻了我自己文件裡的一句話。原本把 --timeout-graceful-shutdown 20 寫得像「先等 20 秒 drain 完才進 lifespan shutdown」。實測:平台側最早的 teardown 事件(KEDA 停止監看該 revision)記在 02:09:06.21,app 的 lifespan shutdown started 在 02:09:06.351——兩個時鐘不能相減,但同落在一秒內,20 秒的等待不存在。
那個 flag 是 drain 的上限,不是要花掉的延遲;idle 的 app 立刻 drain 完。Day 23 本機掛著 SSE stream 量到的 ~20 秒是「上限真的被碰到」的情形,兩筆資料一致。
grace 本身也是一個「沒寫」:ARM 的 template.terminationGracePeriodSeconds 是 nil 才落回 30 秒預設。本系列把它顯式寫成 30——推導式 30 − 20 − 2 = 8 的第一項是自己釘的設計點,不是賭平台預設永遠不變。
部署釘 minReplicas: 1、maxReplicas: 1。這是量測決策,不是建議:shutdown 時間軸必須歸因到一個已知的 replica,平台若能在量測底下自由增減 replica,「最後一行 log」可能來自另一個 replica。一個固定的 replica 把這個歧義拿掉。
代價要誠實記帳:平台預設是 minReplicas: 0/maxReplicas: 10,配 HTTP scale rule 的 app 沒流量就 scale 到零、不產生 usage charges(scale-app,ms.date 2026-05-19)。釘死一個 replica 的部署從 provision 那一刻計費到 teardown——這是本 session 刻意做的取捨,也是 session 一定以 delete-container-app.sh 收尾的又一個理由。
忍喵:scale-to-zero 省下的每一塊錢,都以minReplicas: 1為前提還回去。量完就拆,別讓量測決策過夜。
官方文件標了一個本篇沒踩到、但讀者可能踩到的組合:ingress 關掉、又沒設 minReplicas 也沒有自訂 scale rule 的 app,會 scale 到零然後再也醒不過來——沒有任何訊號能把它叫起來。
設定分兩種形狀進容器,而這個分法本身就是重點。不是 secret 的全走 plain env var:
| 變數 | 本次部署的值 |
|---|---|
AZURE_OPENAI_AUTH |
entra——mint bearer token,不讀 key |
AZURE_CLIENT_ID |
user-assigned identity 的 client id(公開 GUID,不是 secret) |
AUTH_MODE |
entra——caller 身份來自驗證過的 token(Day 19) |
ENTRA_TENANT_ID/ENTRA_AUDIENCE |
API app registration 的 tenant 與 application id |
USE_FAKE_LLM/USE_FAKE_SEARCH/USE_FAKE_EMBEDDINGS |
全 false——三個 seam 都接真服務 |
SAMPLE_DOCS_DIR |
/app/data/sample-docs(Day 23 的修痕,原樣沿用) |
AZURE_SEARCH_ENDPOINT 等 endpoint/deployment 名 |
從 Azure 讀回,或逐 run 覆寫 |
AZURE_OPENAI_API_KEY 不在表上,這就是頭條:entra 模式下 resolve_aoai_auth() 建一個顯式的 ManagedIdentityCredential(client_id=...),把 token provider callable 交給 openai SDK——app 定義裡、環境裡、image 裡都沒有 key。該模式下缺 AZURE_CLIENT_ID 則 startup fail fast。
唯一的 secret 也不在 app 定義裡。 Search admin key 由 deploy script 寫進 Key Vault,YAML 裡放的是 reference:
secrets:
- name: search-admin-key
keyVaultUrl: https://<vault>.vault.azure.net/secrets/azure-search-admin-key
identity: <managed-identity-resource-id>
env var 再用 secretRef: search-admin-key 消費它。三個細節(查核 2026-08,Manage secrets in ACA,ms.date 2026-03-31):
secretRef 是接點。然後是這一篇最尖銳的那個「沒寫」。deploy 第一次跑到 create app 就收到一個不指名任何欄位的 400:
The JSON value could not be converted to System.Boolean.
Path: $ | LineNumber: 0 | BytePositionInLine: 4
用 --debug 抓 PUT body 才看懂:containerapp extension 把 YAML 讀進 SDK model,再把整個 model 序列化回去——所有沒寫的欄位都變成明確的 JSON null,這個 app 的 payload 裡有 84 個。API 版本 2025-10-02-preview 對 non-nullable boolean ingress.allowInsecure 收到 null 就拒絕,而它是 84 個 null 裡唯一的 boolean。
錯誤訊息的兩個座標都是 server 對那個值的 parse context:byte 4 是 null 四個字元的結尾,$ 是那個值自己的 root——對整份文件毫無指向性。
修法是在生成的 YAML 裡顯式寫 allowInsecure: false,並用 regression test 釘住「生成的 YAML 含這個欄位」。但比修法更重要的是它教的事,也是本篇從頭貫穿的那條:你的 YAML 不是你的 payload。 中間隔著一個 serializer,它替你決定「沒寫」的意思——而它的決定是「送 null」。宣告式工具鏈裡,每一個你以為的「不作為」,都值得抓一次實際送出的 payload 驗證。
這個系列 ephemeral-by-default,teardown 跟 deploy 是用同等力氣設計的。核心是最後一個「沒寫」——準確說是「刪了之後不會自動消失的東西」。
Azure 不會隨 identity 的刪除移除它的 role assignments(查核 2026-08,MI best practices § Maintenance)。
它們會留在 ACR、Key Vault、Azure OpenAI 三個資源上,各自顯示「Identity not found」,portal 沒有任何東西指回它們。對已成孤兒的 assignments,官方給的解法是逐 scope 盤查 ObjectType 為 Unknown 的項目再清(同一頁 Maintenance 節)——清得掉,但已經對不回是哪個 identity 留下的。沒人會發現,因為沒有東西壞掉。
忍喵:沒有東西壞掉,不代表沒有東西留下。你 production 的資源上現在有幾條「Identity not found」,敢查嗎?
所以 delete-container-app.sh 的七步順序就是契約:先讀回 principal id(它跟 identity 一起死,是把 assignments 歸因回這個 identity 的把手)→ 刪三個 scope 的 assignments → **讀回該 principal 名下所有 assignments 歸零(fail-closed)**→ 才刪 identity。
讀回那步的 --all 是 load-bearing 的:az role assignment list 預設只看 subscription scope,而這三個 assignments 全在 resource scope——沒有 --all,這一步永遠回 0,整個保證是空的。

Teardown 這次也挖到一個缺陷,收斂成一句設計結論。az containerapp env delete 回傳時,環境還在列表上(provisioningState: ScheduledForDelete,實測 26 秒後才消失),單次讀回的 guard 當場 exit 1。fail-closed 沒有錯,錯在對「還在」的定義——刪除是非同步的,讀回要 bounded poll 到收斂,而不是抓一個瞬間快照。
這條特別要緊,因為在順序中間中止比整個失敗更糟:卡在那一步等於把 identity、三個 assignments、workspace 全留在雲上。
同一個「沒寫就沒人管」還有一個便宜但陰險的變體:az containerapp env create 在你沒指定時會自動生出一個 Log Analytics workspace——官方 CLI reference 的第一個範例就叫「auto-generated Log Analytics workspace」(查核 2026-08,ms.date 2026-08-04)。
那是一個有自己帳單與 lifecycle 的獨立資源;本輪沒走過 auto-provision 路徑,也就不主張它被刪 environment 時的下場——script 根本不賭這件事:顯式建 workspace,用 per-run 唯一名(前綴+CSPRNG 後綴,Day 21 用資源名認錯人的教訓),teardown 才擁有它的 lifecycle。
把「沒寫不等於預設」收成三個動作:部署前查文件裡的預設值到底是誰定義的;部署中抓一次實際送出的 payload;部署後把資源讀回來對帳——包括 teardown 之後那次「什麼都不該剩」的讀回。
這條部署鏈現在是一支人手跑的 script:十一個 stage、每一步讀回驗證、順序即契約。Day 25 把「人」從迴圈裡拿掉——GitHub Actions 跑測試、build image、部署到這篇建好的目標,用 OIDC 對 Azure 登入(又少一把 secret),加上 environment approval 把「該有人看一眼」留在該留的地方。這篇手工走過的每一步,都是下一篇 pipeline 的 spec。
完整程式碼與文件在 day-24 tag,CI 三 job 全綠:
infra/scripts/deploy-container-app.sh——十一個 stage 的部署本體infra/scripts/delete-container-app.sh——七步 teardown 與 --all 讀回docs/container-apps.md——完整 runbook、量測契約與全部引文出處用到的 Azure 服務:
chat-mini+embed-small)Session 結束時資源群組只剩常駐的 Azure OpenAI 帳號:orphan role assignment 0、soft-deleted vault 0、app registration 0。動到的計費 meter 如上表;本篇不引未查核的價格數字,帳務權威是 Cost Management 與發票(Day 9 的規矩)。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。