快轉到主要內容
  1. 教學文章/

uv + Docker 實戰:Locked Sync、Layer Cache 與可重現 Image

·9 分鐘· loading · loading · ·
Python Uv Docker Lockfile BuildKit Deployment Reproducible-Builds
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
DevOps - 本文屬於一個選集。
§ 6: 本文

featured

一. 前言:容器能跑,不代表它可重現
#

你已經把 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 跟部署狀態黏在一起
  • latest tag 今天和下個月指向不同內容

這篇不會再講一次 Image、Container、docker run 是什麼。 如果你第一次接觸容器,先看 Docker for Python 入門;如果你還不熟 uv.lock,先看 uv workspace 與 lockfile。 今天拍拍君只處理一件事:

把一個 uv project 裝進 Docker,而且讓依賴解析、build cache 與 runtime image 都有清楚契約。

最後會得到一份 production-oriented Dockerfile,具備:

  1. 固定版本的官方 uv binary
  2. uv.lock 驗證
  3. dependency layer 與 source layer 分離
  4. BuildKit uv cache mount
  5. non-editable project install
  6. 不帶 uv binary 的 runtime stage
  7. 非 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.tomluv.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,還要考慮:

  1. 把 uv image 固定到 digest
  2. 把 Python base image 固定到 digest
  3. 記錄 multi-platform build 的各平台 digest
  4. 控制 apt repository snapshot
  5. 產生 SBOM 與 provenance attestation
  6. 設計固定節奏的安全更新 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.tomluv.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。這才是拍拍君認可的工程順序。🐍

延伸閱讀
#

DevOps - 本文屬於一個選集。
§ 6: 本文

相關文章

uv pip-compile migration:從 requirements.txt 到可重現部署流程
·7 分鐘· loading · loading
Python Uv Requirements.txt Pip-Compile Dependency-Management Deployment
uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools
Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Python uv 進階:workspace、lockfile、script 與專案管理完全攻略
·8 分鐘· loading · loading
Python Uv Workspace Lockfile Packaging Dev Tools