iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
AI Engineering

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

Day 24:部署到 Azure Container Apps:「沒寫」不等於「預設」

  • 分享至 

  • xImage
  •  

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。

為什麼是 Container Apps

這個系列需要的是一個 solo 工程師能在一個下午建起來、拆乾淨的 container runtime,預算天花板 US$20/月,而且不想營運一座 cluster。Container Apps 對上這個系列三個既有的約束:

  • 它直接跑 Day 23 的封裝。 同一份 Dockerfile、同一個 non-root user、同一個 /health、同一個 SAMPLE_DOCS_DIR 機制,build graph 一個 stage 都沒加。image 本身則由 ACR 重建(下一節)——keyless 路徑帶進一個 runtime 依賴(aiohttpazure.identity.aio 需要的 async transport)加 managed identity 的接線,所以位元組跟 Day 23 本機那顆不同。
  • 它有成文的 shutdown 契約。 SIGTERM,grace 到期後 SIGKILL,grace 可逐 app 設定(查核 2026-08,ACA application lifecycle,ms.date 2025-11-07)。Day 23 的預算就是從 30 秒 grace 推導的,一個真的實作這份契約的平台才讓那筆帳可以量(「還債」一節)。
  • 它按用量計費,而且能 scale to zero。 官方明文「scale 到零就沒有 usage charges」(查核 2026-08,Scaling in ACA,ms.date 2026-05-19)。本篇的部署刻意 scale to zero(scaling 一節講為什麼),但這個選項存在,是 lab 等級的部署在量測窗以外還付得起的原因。

也先講清楚它不是什麼:不是 Bicep(Day 26)、不是 CI/CD pipeline(Day 25)、沒有 APIM。本篇的可重現單位是一支 operator 手跑的 shell script。

一個 identity、三個 role

身份照 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 裡明明點名了兩次。

Image 從哪來:az acr build

本機是 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,每個都有機械判準:

  1. Build:stage 5 的 az acr buildRun … succeeded 收尾,image 進 registry。
  2. Ready:stage 10 控制面讀回全過(identity、三個 role assignments、secret、image、workspace、environment、app);stage 11 /health 回 200。
  3. Authenticated gate:最後一行是 PASS gate: authenticated POST /api/v1/chat returned 200Deployed and gated.——這才叫部署完成。
  4. Teardown 歸零:收尾跑 delete-container-app.sh,第 6 步讀回該 principal 名下 assignments=0 才會刪 identity(Teardown 一節講為什麼)。

一個副作用要先知道:stage 1 會 az account set 把你 shell 的預設訂閱指過去,script 會在 stdout 明講——跑完記得切回去。

Ingress

deploy-container-app.sh 配的是 external HTTP ingress、targetPort: 8000transport: auto。幾個邊界值得知道(查核 2026-08,Ingress in ACA,ms.date 2025-05-02):

  • TLS 在 ingress 終結,HTTPS endpoint 一律 TLS 1.2/1.3,port 80 預設轉 443。容器自己繼續講 plain HTTP 的 8000——它從頭到尾沒看過憑證。
  • FQDN 是平台配的。script 把它從 Azure 讀回來properties.configuration.ingress.fqdn),之後每一步都用 Azure 回報的值,不用命名慣例拼字串。
  • HTTP ingress 有 240 秒的 request timeout(同頁)。/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(tidoidgroups),401/403 契約照 Day 19。規則一句話:headers mode 不離開筆電。

Revisions

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: 2periodSeconds: 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 一節講。

還債:shutdown 量測

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):

  • Cleanup 實測 0.001 秒,對 8.0 秒預算。 app 自己的 monotonic clock,單一來源,精確可驗。預算離綁定非常遠,維持 8.0 不縮——它是從 30 − 20 − 2 推導的設計點。
  • 整體終止約 2 秒,落在 30 秒 grace 內。 跨兩個 log 來源,所以只是 window 不是 duration——量測契約禁止跨時鐘相減。
  • 2 秒 margin 依舊沒量到,而且現在知道在自己的規則下量不到:要隔離它就得跨兩個時鐘相減。它維持保守配置的地位。

量測還推翻了我自己文件裡的一句話。原本把 --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 的第一項是自己釘的設計點,不是賭平台預設永遠不變。

Scaling

部署釘 minReplicas: 1maxReplicas: 1。這是量測決策,不是建議:shutdown 時間軸必須歸因到一個已知的 replica,平台若能在量測底下自由增減 replica,「最後一行 log」可能來自另一個 replica。一個固定的 replica 把這個歧義拿掉。

代價要誠實記帳:平台預設是 minReplicas: 0maxReplicas: 10,配 HTTP scale rule 的 app 沒流量就 scale 到零、不產生 usage charges(scale-app,ms.date 2026-05-19)。釘死一個 replica 的部署從 provision 那一刻計費到 teardown——這是本 session 刻意做的取捨,也是 session 一定以 delete-container-app.sh 收尾的又一個理由。

https://ithelp.ithome.com.tw/upload/images/20260824/20168288Q1uNAAe5Uv.png
忍喵:scale-to-zero 省下的每一塊錢,都以 minReplicas: 1 為前提還回去。量完就拆,別讓量測決策過夜。

官方文件標了一個本篇沒踩到、但讀者可能踩到的組合:ingress 關掉、又沒設 minReplicas 也沒有自訂 scale rule 的 app,會 scale 到零然後再也醒不過來——沒有任何訊號能把它叫起來。

Env vars 與 secrets

設定分兩種形狀進容器,而這個分法本身就是重點。不是 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_IDENTRA_AUDIENCE API app registration 的 tenant 與 application id
USE_FAKE_LLMUSE_FAKE_SEARCHUSE_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):

  • ACA secret 名與 env 名是兩個命名空間,secretRef 是接點。
  • URI 刻意 versionless——30 分鐘內自動抓最新版,轉 key 不用改 app 定義;釘版本就是整個放棄這件事。
  • Secret 被 env var 引用時,轉值會自動重啟 active revision——rotation 在這裡同時是可用性事件,這是設計好的行為(Day 20 講過整套 rotation 帳)。

然後是這一篇最尖銳的那個「沒寫」。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 驗證。

Teardown 的順序是契約

這個系列 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 盤查 ObjectTypeUnknown 的項目再清(同一頁 Maintenance 節)——清得掉,但已經對不回是哪個 identity 留下的。沒人會發現,因為沒有東西壞掉。

https://ithelp.ithome.com.tw/upload/images/20260824/20168288VCoMIuHQEo.png
忍喵:沒有東西壞掉,不代表沒有東西留下。你 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 七步順序圖:前三步刪 Container App、environment(讀回=bounded poll,ScheduledForDelete 視窗實測 26 秒)、Log Analytics workspace;後四步拆身份——先讀回 principal id(它跟 identity 一起死),逐 scope 刪 ACR/Key Vault/Azure OpenAI 的 role assignments,用 --all 讀回該 principal 名下 assignments 歸零才刪 identity,非零則 fail-closed 中止(identity 還在,assignments 還查得到、還救得回);虛線標出反事實——跳過讀回與歸零檢查直接刪 identity,會留下 Identity not found 的 orphan assignments,沒留 principal id,清理退化成逐 scope 盤查 Unknown 型 assignments

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。

這天的誠實邊界

  • Role assignment propagation 這輪沒量到。 readiness gate 第一次認證嘗試就過,但三個 assignments 是約 40 分鐘前建的——快 gate 不構成證據。Day 20 的 14 分 44 秒仍是唯一實測值。
  • Shutdown 量測是 idle 情況的下界。 當時無 in-flight request、無開著的上游 stream、無持有的 conversation lock。0.001 秒是 cleanup 路徑的地板不是行為;streaming 中的 shutdown 可能長得很不一樣,本次量測對它沒有發言權。
  • IMDS 失敗行為與 24 小時 token cache 未量測——session 長度根本到不了 token refresh。keyless 鏈驗過的是 happy path。
  • Key Vault reference 的失敗路徑沒看過。 這輪一次就解析成功,「解析失敗時 revision 呈現什麼 state」我們沒有觀測,不寫。
  • 委派身份的 smoke 記 12/13,不宣稱 13/13。 唯一 FAIL 是取證工具輪詢時序的產物(該行 log 事後確認存在),但工具刻意不記 correlation id,那行無法綁回它自己的請求——證據綁不上就不記全過。
  • Search 仍用 admin key。 keyless Search 的文件自相矛盾且未量測(Day 20),這也是 Key Vault reference 在本篇有真實 consumer 而非純演示的原因。另:這天 Free tier 查詢面再度不可用(與 2026-08-02 同症狀的 503,第二次具日期觀測,仍不外推),session 以 Basic tier 重建、收尾拆除。
  • 單次量測紀律全面適用:本篇標為實測的數字都釘著 2026-08-17、japaneast 與當時的 API/CLI 版本,是觀測不是通則;官方契約值(240 秒 timeout、TLS 版本、預設 replica 範圍)、政策值(US$20)與他日量測(Day 20 的 14 分 44 秒)各自標了來源。

把「沒寫不等於預設」收成三個動作:部署前查文件裡的預設值到底是誰定義的;部署中抓一次實際送出的 payload;部署後把資源讀回來對帳——包括 teardown 之後那次「什麼都不該剩」的讀回。

下一篇

這條部署鏈現在是一支人手跑的 script:十一個 stage、每一步讀回驗證、順序即契約。Day 25 把「人」從迴圈裡拿掉——GitHub Actions 跑測試、build image、部署到這篇建好的目標,用 OIDC 對 Azure 登入(又少一把 secret),加上 environment approval 把「該有人看一眼」留在該留的地方。這篇手工走過的每一步,都是下一篇 pipeline 的 spec。

完整程式碼與文件在 day-24 tag,CI 三 job 全綠:

用到的 Azure 服務

  • Azure Container Apps(Consumption;environment 與 app 皆 ephemeral,已拆)
  • Azure Container Registry(Basic,per-run 唯一名,已拆)
  • Log Analytics(顯式建立,已拆)
  • Azure Key Vault(已 delete+purge)
  • Azure AI Search(Free 掛掉後以 Basic ephemeral 重建,收尾拆除)
  • Azure OpenAI in Microsoft Foundry(常駐 chat-miniembed-small
  • Microsoft Entra ID(ephemeral app registrations,已 delete+purge;managed identity 已刪,role assignments 以清單讀回歸零確認)

Session 結束時資源群組只剩常駐的 Azure OpenAI 帳號:orphan role assignment 0、soft-deleted vault 0、app registration 0。動到的計費 meter 如上表;本篇不引未查核的價格數字,帳務權威是 Cost Management 與發票(Day 9 的規矩)。


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


上一篇
Day 23:Docker 化 FastAPI GenAI Backend——build 會綠,容器起不來
下一篇
Day 25:用 GitHub Actions 建立 CI/CD:每道防線都比它的名字窄
系列文
Backend 工程師的 Azure GenAI 實戰35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言