一. 前言:容器能跑,不代表它可重現 #
你已經把 Python API 放進 Docker。 本機 docker build 成功,CI 也亮綠燈,看起來天下太平。 直到某天你只改了一行 route,建構卻重新下載全部依賴;或同一個 commit 隔週重建,竟然裝到不同版本。 常見症狀還有:
pyproject.toml改了,uv.lock卻忘記更新- 每次
COPY . .後,dependency layer 都失效 - 本機的
.venv被一起塞進 Linux image - production image 裡留下 compiler、uv cache 與測試工具
- 專案在 container 內以 editable mode 執行,source layout 跟部署狀態黏在一起
latesttag 今天和下個月指向不同內容
這篇不會再講一次 Image、Container、docker run 是什麼。 如果你第一次接觸容器,先看 Docker for Python 入門;如果你還不熟 uv.lock,先看 uv workspace 與 lockfile。 今天拍拍君只處理一件事:
把一個 uv project 裝進 Docker,而且讓依賴解析、build cache 與 runtime image 都有清楚契約。
最後會得到一份 production-oriented Dockerfile,具備:
- 固定版本的官方 uv binary
uv.lock驗證- dependency layer 與 source layer 分離
- BuildKit uv cache mount
- non-editable project install
- 不帶 uv binary 的 runtime stage
- 非 root 執行與基本健康檢查
二. 這篇和既有文章差在哪裡? #
拍拍君先把邊界畫清楚,免得只是換標題炒冷飯。
| 文章 | 核心問題 |
|---|---|
| Docker for Python | 如何從零寫 Dockerfile、理解 layer 與 multi-stage |
| uv workspace | 如何管理 uv.lock、workspace 與本機專案環境 |
| uv pip-compile migration | 如何保留 requirements.txt 並從 pip-tools 漸進遷移 |
| 本篇 | 如何直接以 pyproject.toml + uv.lock 建構 production image |
我們不輸出 requirements.txt,也不把 container 當成一般虛擬環境教學。 範例使用 uv 的 project mode,讓 lockfile 成為 image build 的輸入契約。
三. 準備最小 FastAPI 專案 #
先建立範例:
uv init --package pypy-service
cd pypy-service
uv add fastapi 'uvicorn[standard]'
專案結構整理成:
pypy-service/
├── .dockerignore
├── .python-version
├── Dockerfile
├── pyproject.toml
├── uv.lock
└── src/
└── pypy_service/
├── __init__.py
└── main.py
src/pypy_service/main.py:
from fastapi import FastAPI
app = FastAPI(title="拍拍君 API")
@app.get("/")
def index() -> dict[str, str]:
return {"message": "拍拍君的 uv container 正常運作"}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
ASGI server 可以直接用 module path 啟動,所以 Docker CMD 會使用:
uvicorn pypy_service.main:app --host 0.0.0.0 --port 8000
先在本機確認:
uv sync --locked
uv run uvicorn pypy_service.main:app --host 127.0.0.1 --port 8000
另一個 terminal 測試:
curl --fail http://127.0.0.1:8000/health
# {"status":"ok"}
四. 不要在 image 裡 curl 安裝 uv #
最直覺的寫法可能是:
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
開發機上這很方便,但 production build 多了幾個問題:
- base image 必須先安裝
curl與 CA certificates - shell pipeline 讓下載內容不容易稽核
- 沒固定 installer 版本時,重建結果會漂移
- 安裝殘留可能進入 image layer
uv 官方提供 distroless image,裡面只有可複製的 uv binary。
FROM python:3.12-slim-trixie AS builder
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/
這裡固定 0.12.10,避免無意間追到移動中的 latest。 更嚴格的供應鏈環境,應把 tag 再固定到經驗證的 sha256 digest,並交給 Dependabot、Renovate 或內部流程定期更新。 重點不是「永遠不要升級」。 重點是:升級應該留下 review 得到的 diff,而不是在背景偷偷發生。
五. uv.lock 是 build input,不是裝飾品
#
Dockerfile 需要同時複製:
COPY pyproject.toml uv.lock ./
然後執行:
RUN uv sync --locked --no-install-project
--locked 會要求現有 lockfile 與專案 metadata 一致。 如果有人改了依賴卻忘記提交新的 uv.lock,build 應該直接失敗,而不是在部署機器上順手重算一份。 這和 --frozen 的意義不同:
| 選項 | lockfile 過期時 | 適合情境 |
|---|---|---|
uv sync |
可以更新 | 本機開發 |
uv sync --locked |
失敗 | CI 與 image build gate |
uv sync --frozen |
不檢查 freshness,直接使用 | 已在前一步驗證的部署流程 |
在單一 Dockerfile 裡,使用 --locked 最直觀。 如果 CI 已經另外跑過 uv lock --check,後續封閉 build 階段才可能選擇 --frozen。 不要為了「build 能過」而拿掉驗證開關。
六. 第一層只裝 dependencies #
最重要的 cache 設計,是不要一開始就 COPY . .。 先做這一層:
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync \
--locked \
--no-dev \
--no-install-project
--no-install-project 只裝第三方依賴,不安裝目前專案本身。 因此你只修改 src/pypy_service/main.py 時,pyproject.toml 和 uv.lock 沒變,Docker 可以沿用 dependency layer。 這比「先複製全部 source,再執行 sync」穩定得多。 依賴真的改變時,這層仍然會重建;那是正確的 invalidation,不是 cache 失靈。
七. Layer cache 和 uv cache 是兩回事 #
這裡有兩套 cache,別把它們混在一起。
Docker layer cache #
輸入與指令完全相同時,整個 RUN uv sync ... layer 可以直接重用。
BuildKit cache mount #
layer 必須重跑時,/root/.cache/uv 還能保留先前下載的 wheel 與 artifact。
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
例如你只升級一個套件,uv 不必重新下載整個 dependency graph。 cache mount 不會自動成為最終 image 內容;它是 build 過程的外部暫存。 這也解釋了為什麼 production Dockerfile 不必為了清 cache 而加入:
RUN rm -rf /root/.cache/uv
如果 cache 本來就是 mount,它沒有被固化進 layer。
八. 再複製 source,安裝 project #
依賴層完成後,再複製會頻繁變動的內容:
COPY README.md ./
COPY src ./src
接著執行第二次 sync:
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync \
--locked \
--no-dev \
--no-editable
--no-editable 會把目前 project 以非 editable 方式安裝。 本機開發用 editable install 很合理:改 source 後立刻生效。 production image 則比較適合把安裝結果當成不可變 artifact,避免 runtime 行為依賴工作目錄裡的 source link。 第一個 sync 已經裝好 dependencies,所以第二次通常只需要安裝目前 project。
九. 避免 hard link 警告 #
uv 預設會盡量從 cache hardlink 檔案,以減少複製成本。 但 BuildKit cache mount 和 image filesystem 可能位於不同檔案系統,hardlink 會失敗並退回 copy,產生警告。 可以明確設定:
ENV UV_LINK_MODE=copy
這不是在關掉 cache。 它只告訴 uv:把 cache artifact 放進 environment 時直接複製,不要先嘗試跨 filesystem hardlink。 建構 log 會乾淨,也避免團隊誤以為 dependency 安裝壞掉。
十. Production 是否要 compile bytecode? #
Python 通常在首次 import 時才產生 .pyc。 如果 container 啟動延遲很敏感,可以在 build 時先 compile:
ENV UV_COMPILE_BYTECODE=1
或只套用到 sync:
RUN uv sync --locked --no-dev --no-editable --compile-bytecode
取捨很簡單:
- 優點:減少第一次 import 的工作,冷啟動可能更快
- 缺點:build 較久,image 也會稍微變大
不是所有 API 都需要它。 先測量 cold start,再決定是否開啟;不要看到「最佳化」三個字就全部加上去。
十一. Builder 與 runtime stage 分工 #
到目前為止,builder 內有 uv binary 與完整 .venv。 runtime 不一定需要 uv。 可以只複製 environment 與必要檔案:
FROM python:3.12-slim-trixie AS runtime
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
啟動時直接呼叫 environment 裡的 executable:
CMD ["uvicorn", "pypy_service.main:app", "--host", "0.0.0.0", "--port", "8000"]
這樣 runtime stage 不必保留 /uv、下載 cache 或 build-only 工具。 注意:builder 和 runtime 應使用相容的 Python base、CPU architecture 與系統 ABI。 不要在 Debian builder 產生含 native extension 的 environment,再隨手複製到 Alpine runtime。
十二. 完整 Dockerfile #
把前面的策略合起來:
# syntax=docker/dockerfile:1
FROM python:3.12-slim-trixie AS builder
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1
WORKDIR /app
# 只有 dependency metadata 改變時,才重建第三方依賴層。
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync \
--locked \
--no-dev \
--no-install-project
# Source 常改,最後才複製。
COPY README.md ./
COPY src ./src
# Production 使用非 editable install。
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync \
--locked \
--no-dev \
--no-editable
FROM python:3.12-slim-trixie AS runtime
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --no-create-home app
COPY --from=builder --chown=app:app /app/.venv /app/.venv
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["uvicorn", "pypy_service.main:app", "--host", "0.0.0.0", "--port", "8000"]
這份 Dockerfile 刻意不在 runtime 使用 uv run。 不是因為 uv run 不好,而是 runtime 已經有完整 .venv,直接執行 console script 更精簡。
十三. .dockerignore 不是可選配件
#
至少排除:
.git/
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
.env
dist/
build/
最關鍵的是 .venv/。 macOS 或 Windows 建立的 environment 不能直接當 Linux environment 使用,而且它會讓 build context 變得巨大。 .env 也不應進 image。 production secret 請在 runtime 透過 orchestrator、secret manager 或 docker run --env-file 注入,不要寫進 Dockerfile。
十四. 建構與執行 #
一般 build:
docker build --progress=plain -t pypy-service:dev .
執行:
docker run --rm \
--name pypy-service \
-p 8000:8000 \
pypy-service:dev
測試:
curl --fail http://127.0.0.1:8000/health
docker inspect \
--format '{{json .State.Health}}' \
pypy-service
確認 runtime 沒有 uv:
docker run --rm pypy-service:dev sh -c 'command -v uv || true'
確認服務不是 root:
docker run --rm --entrypoint=id pypy-service:dev
# uid=10001(app) gid=10001(app) groups=10001(app)
十五. 怎麼知道 cache 真的有用? #
第一次 build 完成後,只修改 route 回傳文字,再跑一次:
docker build --progress=plain -t pypy-service:dev .
理想結果:
COPY pyproject.toml uv.lock ./命中 cache- 第一次
uv sync --no-install-project命中 cache COPY src ./src之後的步驟重建- 第二次 sync 重新安裝 project,但不重新下載所有 dependencies
接著執行:
uv add rich
docker build --progress=plain -t pypy-service:dev .
此時 dependency layer 應該重建,但 cache mount 可以重用未變動的 artifacts。 這兩個實驗比盯著一張「build 變快了」截圖更可靠。
十六. --no-install-package 的進階拆層
#
大型專案可能還有一個常改、但安裝成本高的 workspace package。 uv 也能排除指定 package:
uv sync \
--locked \
--no-dev \
--no-install-package pypy-models
不過要小心:排除 package 也可能連帶讓它的獨有 dependencies 不被安裝。 這是 cache tuning 工具,不是遇到 build 慢就亂加的魔法參數。 先用最簡單的「dependencies / project」兩層結構;只有 profiling 證明值得,再拆得更細。
十七. Tag 固定還不等於完全可重現 #
我們已經固定:
uv.lock- uv image 版本
- Python minor base tag
但 tag 仍可能移動,系統套件 repository 也會更新。 若需求是 bit-for-bit 或高度可稽核的 build,還要考慮:
- 把 uv image 固定到 digest
- 把 Python base image 固定到 digest
- 記錄 multi-platform build 的各平台 digest
- 控制 apt repository snapshot
- 產生 SBOM 與 provenance attestation
- 設計固定節奏的安全更新 PR
「固定 digest」和「保持安全更新」不是二選一。 正確流程是由自動化提出 digest 更新,測試通過、review 後再合併。
十八. 常見錯誤 #
錯誤一:把 .venv COPY 進 image
#
症狀可能是 executable 找不到、native wheel ABI 不相容,或 image 突然肥大數 GB。 解法:.dockerignore 排除 .venv,在 image 裡重新 sync。
錯誤二:只複製 pyproject.toml
#
沒有 uv.lock,build 會重新解析,失去 lockfile 契約。 解法:兩個檔案一起作為 dependency layer input。
錯誤三:一開始就 COPY . .
#
任何 source 修改都使昂貴的 dependency layer 失效。 解法:先複製 lockfile 與 metadata,最後才複製 source。
錯誤四:production 還裝 dev dependencies #
測試、lint、notebook 套件會放大 image 與攻擊面。 解法:使用 --no-dev;若採 dependency groups,則明確選擇要安裝的 group。
錯誤五:builder 和 runtime ABI 不一致 #
純 Python package 可能僥倖能跑,含 native extension 的 package 往往直接爆炸。 解法:兩階段選用相容的 distribution、Python 版本與 architecture。
錯誤六:為了最小而盲選 Alpine #
musl 與 glibc 差異可能讓 scientific、database 或影像套件必須本機編譯。 解法:一般 Python service 先從 slim 開始,實測後再縮。
十九. Production 檢查清單 #
建構前:
-
pyproject.toml與uv.lock一起 commit - CI 跑過
uv lock --check -
.venv、.env、cache 已加入.dockerignore - uv 與 base image 有明確版本策略
Dockerfile:
- dependency metadata 比 source 先複製
- 使用
--locked - dependency sync 使用
--no-install-project - project install 使用
--no-editable - uv cache 透過 BuildKit mount 提供
- runtime stage 不含 compiler 與無用 build tools
- process 以非 root 使用者執行
發佈前:
- 實際啟動 image 並呼叫 health endpoint
- 檢查 image architecture 與目標平台
- 掃描 OS package 與 Python dependency 漏洞
- 記錄最終 image digest
- 保留可回滾的上一版 tag
結語 #
uv 放進 Docker 的價值,不只是「比 pip 快」。 真正有用的是把依賴流程變得清楚:
uv.lock決定要安裝什麼--locked阻止 build 偷偷改答案--no-install-project把穩定 dependencies 與常變 source 分層- BuildKit cache mount 避免重複下載 artifacts
--no-editable讓 production install 成為固定成果- multi-stage 只把
.venv帶進 runtime
容器可重現不是一個神奇 flag,而是一串彼此配合的小契約。 先讓同一個 commit 能穩定重建,再追求更小、更快、更華麗的 pipeline。這才是拍拍君認可的工程順序。🐍