這個 repo 的 docker/Dockerfile 從第一天就在:單 stage、root 執行、沒有 HEALTHCHECK、uv 留在最終 image 裡。這天的任務是把它變成能上 Azure 的形狀——多階段 build、non-root、健康檢查、graceful shutdown。但這篇想講的不是 Dockerfile 怎麼寫,是 image 零設定跑起來的那一刻:挖出兩個潛伏了 22 天的 bug,而其中一個反過來推翻了我自己前一天才做的 CI 裁決。
讀完你會得到一份能抄的多階段 Dockerfile,和一個比 Dockerfile 值錢的判準:「build 成功」證明不了「起得來」。
先把結論擺出來。完整檔案(含每個決定的註解)在 docker/Dockerfile @ day-23 tag,這裡只省略註解:
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src/ src/
COPY README.md ./
RUN uv sync --frozen --no-dev --no-editable
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY data/sample-docs /app/data/sample-docs
ENV SAMPLE_DOCS_DIR=/app/data/sample-docs
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["python", "-c", "import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"]
CMD ["/app/.venv/bin/uvicorn", "azgenai_lab.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "20"]
骨架照 uv 官方 Docker 指南(查核 2026-08)的多階段 pattern,幾個決定值得說為什麼:
--no-editable 是 venv-only copy 成立的前提。 預設的 uv sync 用 editable install,venv 裡只放一個指回 src/ 的指標——copy venv 不 copy src/,import 就斷了。--no-editable 把專案本體(含 azgenai_lab/prompts/*.md,Day 8 的 prompt loader 在 startup 就要讀)真的裝進 site-packages,venv 從此不依賴原始碼樹。
於是 runtime stage 不需要 uv、不需要 src/、不需要 build graph——它們全留在 builder。
兩個 stage 同一個 base、同一個路徑。 venv 不是 relocatable 的東西,uv 指南的解法很務實:builder 和 runtime 都用 python:3.13-slim、venv 都在 /app/.venv,讓「搬家」根本不發生。UV_PYTHON_DOWNLOADS=0 是同一件事的另一半——強制 uv 用 base image 的直譯器建 venv,不准它自己下載一顆,保證 venv 綁的就是 runtime stage 會有的那顆 python。
venv 是 root-owned,對執行使用者唯讀。 COPY --from=builder 在 USER app 之前執行,所以檔案屬 root;uvicorn 之後以 system user app(uid 999)跑。這不是疏忽,是特性:一個寫不了自己程式碼的 process,被攻破之後能做的事就少一截。Day 21 才寫過 prompt injection——如果模型被騙著去改 prompts/*.md,這一層讓它連檔案系統這條路都沒有。
尺寸對照(2026-08-12,Docker 29.4.0):舊單 stage image 321MB,多階段 202MB。差額包含沒跟過來的那些東西——uv binary、src/ 原始碼樹與 build 的中間產物;未逐項歸因,總量對照為準。
注意上面那份 Dockerfile 裡有一行不在「只帶 venv」的敘事裡:COPY data/sample-docs /app/data/sample-docs。它就是第一個 bug 的修痕。
多階段 image build 出來之後,我做了一件在這個系列既有驗證紀錄裡找不到前例的事:不給任何環境變數,直接跑。
$ docker run --rm -p 127.0.0.1:8000:8000 azgenai-lab
...
azgenai_lab.services.document_loader.SourceDocumentError: no documents found in /app/.venv/lib/python3.13/data/sample-docs
Startup 直接崩。崩的位置乍看莫名其妙——/app/.venv/lib/python3.13/data/sample-docs 是什麼鬼路徑?
追下去是 document_loader.py 裡的這行常數:
SAMPLE_DOCS_DIR = Path(__file__).resolve().parents[3] / "data" / "sample-docs"
從模組自己的位置往上爬三層、再往下找 data/sample-docs。在原始碼樹裡這完全成立:src/azgenai_lab/services/document_loader.py 往上三層是 repo root,data/sample-docs 就在那。但 --no-editable 把模組裝進了 site-packages——同一個 parents[3] 現在指向 venv 內部,那裡當然沒有 data/。
而預設設定 use_fake_search=True 會在 startup 就從這個目錄 seed fake retriever(Day 17 加的),所以不是某個 endpoint 壞,是整個 process 起不來。
第一反應是「多階段 build 弄壞了它」。所以做了對照組:拿 Day 23 開工前的舊單 stage Dockerfile,同樣零 env var 跑——一樣崩(路徑是 /app/data/sample-docs,一樣不存在)。這個 bug 不是這天造成的,它從 parents[3] 寫下那天就潛伏著;只是既有的可查紀錄裡,每一次跑容器都掛著一堆 env var、或根本在原始碼樹裡跑——沒有留下任何一次零設定跑 installed layout 的紀錄。container 只是第一個誠實的執行環境。
忍喵:所以「在我機器上可以跑」的完整版是「在我機器的原始碼樹裡可以跑」——範圍比你以為的又小一圈。
修法當時擺上桌的是三案(事後的外部 review 補了第四條變體——hatchling force-include 可以把 corpus 打進 wheel 而不搬目錄,「B 但不搬家」;不改變裁決):
/app/.venv/lib/python3.13/data/sample-docs)。否決——把 venv 的內部佈局硬編進 image,uv 或 python 版本一動就斷。src/ 隨 wheel 打包。 否決——動 repo 頂層結構,牽連所有既有路徑指涉,為了一個 loader 太貴。sample_docs_dir(env SAMPLE_DOCS_DIR,預設 None=沿用原常數),由 composition 側傳進 loader;image 用自然路徑 COPY data/sample-docs /app/data/sample-docs 加一行 ENV 指過去。還有一刀是後續 review 補的:load_documents(base_dir) 的參數改成必填、刪掉預設值。那個 module-relative 預設在任何 installed layout 都是錯的,留著只是等下一個 caller 無聲踩進同一個坑。一個在多數環境下都錯的預設值,比沒有預設值危險。
這個 bug 最大的後座力不在 code,在 CI 裁決。
Day 23 開工時我拍板:CI 加一個 build-only 的 docker job——只驗 image build 得出來,不 push、不跑容器,跑起來的驗證留給 Day 25 的部署 pipeline。當時的理由聽起來很合理:省 CI 時間,部署鏈本來就會蓋到。
然後外部 code review 一句話把它推翻:本次最重要的 bug,性質正好是「build 成功、服務起不來」——build-only gate 對這一類 failure 結構上就是盲的。 docker build 只證明每一層指令跑完了;parents[3] 的錯誤解析要到 process startup 才爆炸。維持 build-only,等於明知有這一類 bug、還留兩天已知盲點給 Day 24–25。
改後的 docker job:build 完直接 docker run 起容器,bounded poll /health 並比對 exact body(不是只看 status code——{"status":"ok","service":"azure-genai-backend-lab"} 整串比對),加上 poll docker inspect 的 .State.Health.Status 直到 healthy。
後者是另一輪 review 加的:原本用獨立的 docker exec 打 /health,但那樣 HEALTHCHECK 指令本身壞掉也照樣過——驗證的是「服務健康」,不是「Dockerfile 宣告的健康檢查有效」。補了一個鑑別性實驗釘住差異:把 HEALTHCHECK 指向錯誤的 port,exec 探測照樣成功,inspect poll 正確轉 unhealthy。
Gate 要對準的是你最怕的 failure class,不是最容易驗的那個。build-only 是最容易驗的;「起得來」才是這個 milestone 真正怕的。
.dockerignore 只擋了根層第二個 bug 藏在一個大家都會抄的檔案裡。舊 .dockerignore 有這兩行:
__pycache__/
*.pyc
看起來天經地義。但 .dockerignore 的 pattern 走 Go filepath.Match 規則(docs.docker.com build context 頁明載,查核 2026-08),而 filepath.Match 的 * 不跨 /。
所以這兩行只匹配 build context 根層的 __pycache__/ 和 *.pyc,src/azgenai_lab/**/__pycache__/ 底下的東西全數放行。
(文件沒有「不加 **/ 就只匹配根層」這句字面,這是從匹配規則推導出來的——但實測站在推導這邊。)
後果有兩層。第一層是量:444,079 bytes 的陳舊 bytecode 跟著 COPY src/ src/ 進了 builder stage。第二層比較陰險:只要巢狀 .pyc 有新增或更新(在本機跑測試就常發生),COPY src/ src/ 的 cache key 就變了——這層連同之後的安裝層全部重跑。CI 的 fresh checkout 沒有 bytecode 所以無感,這個 bug 專咬開發者的日常迭代。
修法一行:改成 **/__pycache__/ 和 **/*.pyc。同一棵樹冷量測,build context 從 1.12MB 掉到 671.22kB,差額跟「src 底下的 .pyc 總量」對得上(誤差 0.65%)。順手把 .mypy_cache/、.pytest_cache/、.ruff_cache/ 也加了 **/ 版本——現行 Dockerfile 的具名 COPY 其實碰不到它們,加這幾行是 defense-in-depth:哪天有人寫下 COPY . .,這幾行就是最後一道網。
附帶一個量測教訓:那對數字是用拋棄式 buildx builder 冷量出來的——transferring context 在同一個 builder 上是增量同步,暖 builder 的讀數(22.33kB 這種)是 partial-sync artifact,跟 .dockerignore 改沒改幾乎無關。
而且 baseline 量在乾淨 worktree、新版量在帶 bytecode 的工作樹,兩個數字不可比,所以公開文件只發 image size 對照和同一棵樹的冷量 pair。量錯方法的數字比沒有數字更糟:它會帶著自信指向錯的結論。
Docker 化這天還有一個跟 Dockerfile 同等重要的題目:這個容器要怎麼餵設定。
Bug ① 那句「不給任何環境變數,直接跑」之所以是合理的期待,是因為零設定能跑從 Day 2 起就是這個 backend 的架構性質:USE_FAKE_LLM/USE_FAKE_EMBEDDINGS/USE_FAKE_SEARCH 三個開關預設全 true,composition point 據此選 fake adapter——image 拉下來 docker run 就是一個可以打的 API,demo、教學、CI 的 boot smoke 全靠這個性質。
真要接 Azure,是開關各自翻、每翻一個帶出自己那組必填 env:USE_FAKE_LLM=false 帶出 AZURE_OPENAI_ENDPOINT/AZURE_OPENAI_API_KEY/AZURE_OPENAI_DEPLOYMENT_NAME,USE_FAKE_EMBEDDINGS=false 多要 embedding deployment,USE_FAKE_SEARCH=false 帶出 AZURE_SEARCH_* 一組。
呼叫端身分另有自己的開關:AUTH_MODE=entra 帶出 ENTRA_* 那組(tenant、audience,加 scope 或 app role 至少擇一,Day 19)。
完整的表——含 timeout、token budget(Day 9)、agent guardrail(Day 17)這些 knob——在 docs/docker.md,每個欄位都標了「什麼時候需要」。
注意這個 env 面今天還有 AZURE_OPENAI_API_KEY:對 Azure 的憑證仍是 key,Day 20 定案的 managed identity 要到 Day 24 落地,進了 Container Apps 就換掉。
表裡另外兩個 env 有自己的故事。SAMPLE_DOCS_DIR 是 Bug ① 的修痕——不設就退回 checkout-relative 的推算,只在原始碼樹裡成立,所以 image 一定要設。SHUTDOWN_CLEANUP_BUDGET_SECONDS 預設 8.0 秒,而且預設值就是最大值——這個數字是算出來的,下一節講。
CMD 最後那個 --timeout-graceful-shutdown 20,這個旗標很容易被讀成「關機的 timeout」。它不是。
對釘住的 uvicorn 0.50.2 原始碼複核:這個 timeout 只包 asyncio.wait_for(self._wait_tasks_to_complete(), ...)——只框 in-flight request 的 drain;await self.lifespan.shutdown() 在 timeout 之外(server.py)。這個 backend 的 lifespan 要關四個 client(Day 14 review 加的 close 鏈),任何一個卡死,20 秒管不到它。
整體的死線屬於 runtime,不屬於 uvicorn:docker stop 預設只給 10 秒 grace(查核 2026-08,docker docs)。
Azure Container Apps 給得長一些:SIGTERM 後預設 30 秒才 SIGKILL(查核 2026-08,ACA lifecycle)。
注意「預設」兩個字——ARM template 的 terminationGracePeriodSeconds 可以覆寫這個數字(nil 才落回 30;查核 2026-08,template reference)。
本系列把 30 秒釘成自己的設計點:Day 24 的 IaC 會明確寫 terminationGracePeriodSeconds: 30,不賭今天的預設永遠不變。
所以文件一律寫 docker stop -t 30——本地的 grace 對齊這個設計點,測到的行為才能外推。
於是 lifespan cleanup 需要自己的預算,而且這個預算的上界是推導出來的,不是憑感覺訂的 knob:
platform grace (30s) − request drain (20s) − margin (2s) = 8.0s
SHUTDOWN_CLEANUP_BUDGET_SECONDS 預設 8.0,預設值即最大值。第一版寫的是 le=30,review 抓出它跟自己的推導矛盾:drain 和 cleanup 是先後兩段、共用同一段 grace,獨立的 le=30 允許「20+30=50 名目秒」去打一個 30 秒的天花板。實作是單一 monotonic deadline 跨四個 closer 共用——要 fit 進剩餘 grace 的是總和,四個獨立的 per-closer timeout 加起來還是可以超。

實測錨點(本地、docker stop -t 30):idle 時 SIGTERM 到 exit 0 共 0.669 秒;掛著一條 in-flight streaming request 時 20.8 秒——正好是 drain cutoff 起作用的形狀。in-flight 的上游是本地的 cooperative slow mock(LLM 走正式 adapter path、USE_FAKE_LLM=false,只是 endpoint 指向 mock),這個證據層級在下面誠實邊界再說。
Dockerfile 裡那行 HEALTHCHECK 用 python urllib 打 /health(slim base 沒有 curl,與其多裝一個套件不如用 image 裡現成的直譯器)。但這行指令的適用範圍比它看起來的小:
docker run 和 Compose。 Azure Container Apps 跑自己的 startup/liveness/readiness probes,僅支援 HTTP/TCP,exec probes 明文不支援(查核 2026-08,ACA health probes)。/health。USER 執行」這件事 Dockerfile reference 也沒寫,答案在 moby 原始碼 daemon/health.go 的 execConfig.User = cntr.Config.User——引原始碼就標明是原始碼,不包裝成「文件說的」。寫下邊界的理由跟寫 HEALTHCHECK 本身一樣重要:一行沒人執行的健康檢查,只會留下「已經有健康檢查了」的假安全感。
最後一件事關於怎麼「說」這份 image。第一版的 run 文件寫著 -p 8000:8000,env 表上方一句「Every variable has a safe default」。Review 指出這句話在說謊:預設 AUTH_MODE=headers 信任 caller 自報的 tenant/user/groups(Day 15 的 trusted headers 模式)——這是 demo default,不是 safe default。
把它 publish 到 host 所有位址,等於對整個網段開放一個「自己說自己是誰就是誰」的 API。
忍喵:demo default 的壽命永遠比 demo 長。貼進文件之前,先想像它被抄進某台對外的 VM。
修正後的 quick start:
$ docker build -t azgenai-lab -f docker/Dockerfile .
$ docker run --rm -p 127.0.0.1:8000:8000 azgenai-lab
$ curl -s http://127.0.0.1:8000/health
{"status":"ok","service":"azure-genai-backend-lab"}
$ docker stop -t 30 <container>
bind 127.0.0.1、文件明寫 trust boundary。要對外有兩條合規路:容器直接暴露就換 AUTH_MODE=entra(Day 19);或者放在一個會 strip/覆寫那三個 identity header、而且繞不過去的 gateway 後面——後面這條也在 docs 明列,條件是「繞不過去」要真的成立。
starting → healthy 的轉換沒有直接觀察到。 首次取樣時已是 healthy,轉換時間是由 log timestamp 回推的——是 inference,不是 observation。COPY . /ctx 對照過一次(43.60MB vs 具名 COPY 的 1.12MB),沒測 classic builder,也只對「具名 COPY」的 Dockerfile 成立——寫下 COPY . . 的那天它就不成立。ghcr.io/astral-sh/uv:0.11 是浮動 minor tag,不是真 pin(本次解析到 0.11.33)。教學 repo 接受這個漂移換可讀性;production 應該 python 和 uv 都用 tag@sha256: 釘死。Image 有了,/health 有了,shutdown 的預算算清楚了,連「哪些健康檢查在雲上不算數」都先講明了——Day 24 把它放上 Azure Container Apps:ingress、revisions、scaling,probes 顯式配到 /health,身分照 Day 20 定好的 plan 走 user-assigned managed identity。到時候本篇兩個「未量測」的數字,也該還債了。
完整程式碼在 day-23 tag,CI 三 job(python/site/docker)全綠:
docker/Dockerfile——多階段 build 本體docs/docker.md——run instructions、shutdown 算術、HEALTHCHECK 邊界全文.github/workflows/ci.yml——boot smoke 的實際寫法本篇全程本地驗證,無新增雲端資源、增量 Azure 費用為零——燒的只有本機算力(Azure Container Registry 要到推 image 那天才登場)。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。