Web Day 30 Docker 化:從開發環境到容器
執行需求:需 Docker。這是「上線」章節的第一天,我們要把 Day 29 用環境變數管理設定的那支 FastAPI 服務,裝進 Docker 容器裡。整篇會走一條完整路線:先解釋 image / container / layer / volume 的差別,再寫一份精簡可跑的 Dockerfile 與 docker-compose.yml,最後用 curl 驗證容器內的服務能回應請求。為了照顧手上沒有 Docker 的讀者,文末有一段「不安裝 Docker」的替代流程;如果你能裝 Docker Desktop 或 OrbStack,建議同步對照,會更直觀。
引言
寫後端的同事常會遇到一種尷尬:在自己電腦跑得好好的程式,丟到同事的筆電或測試機就出錯。原因幾乎都脫離不了「環境不一樣」這四個字——Python 版本差一個小號、系統套件少裝一個、SQLite 路徑寫死、timezone 預設是 UTC,但部署機的時區是 Asia/Taipei。Docker 的核心價值就是把「執行環境」連同程式碼一起打包成一個可攜的單位,讓本機、測試機、正式機跑的是同一份 image。這一篇我們就要把那個單位做出來。
今天是上線章節的起點,後面 Day 31 會把 SQLite 換成 PostgreSQL 並用容器跑資料庫、Day 33 會在前面再加一層 Caddy 反向代理、Day 41 會把所有東西用 Docker Compose 一鍵打包。先把今天這份最小可跑的 Dockerfile 與 compose 檔案讀熟,後面幾天會在這個基礎上疊東西上去。
讀完今天,你會拿到四個具體的產物:一份可以一鍵 build 的 Dockerfile、一份帶 healthcheck 的 docker-compose.yml、一組驗證容器健康的 curl 指令、以及一份沒有 Docker 時的純本機替代流程。即使公司筆電沒開 Hyper-V,也能用替代流程把進度跟上;之後換到能跑 Docker 的環境,馬上就能切換成容器版本。
在開始之前,請確認手上的專案結構跟本篇範例一致:app/ 資料夾放程式碼,pyproject.toml 放依賴,Dockerfile 與 docker-compose.yml 放在專案根目錄。今天的所有指令都從這個根目錄執行,相對路徑就會跟範例一致。
容器化前要先懂的觀念
在動手寫 Dockerfile 之前,先把幾個關鍵名詞釐清,這能幫你理解後面每一行為什麼這樣寫。
Image 與 Container 的差別:image 是唯讀的樣板,類似「光碟片」或「程式安裝檔」;container 是 image 跑起來之後的執行實例,類似「光碟片裝進主機後正在執行的程式」。同一個 image 可以同時啟動多個 container,每個 container 都有自己的可寫層與獨立狀態(檔案系統、行程、網路)。正式環境通常會把同一個 image 開成多個 container,再由 load balancer 把流量分散過去,這就是水平擴展的基本動作。
Layer 的快取邏輯:Dockerfile 每一行指令都會產生一個 layer;build 時如果某一層的輸入沒變,Docker 會直接重用快取。這個特性決定了我們應該「先複製依賴檔、再複製原始碼」,因為原始碼改動是家常便飯,而依賴清單相對穩定。實務上常用 `docker build -t name:tag --no-cache .` 在 CI 強制重 build,確保不會被過期的 layer 影響。
Bind mount 與 Named volume:兩者都是把資料從主機放進容器的方式。bind mount 直接掛主機上的某個目錄(例如 ./data:/app/data),適合開發時即時看到程式改動;named volume 由 Docker 自己管理(通常放在 /var/lib/docker/volumes/),適合正式環境保存資料庫檔案,避免容器刪除後資料一起消失。今天的範例會用 named volume 保存 SQLite 檔。bind mount 在 Windows 與 macOS 上因為檔案系統差異,效能會略差,正式環境一律用 named volume 才穩定。
Docker Compose v2:從 Docker Desktop 4.x 開始,docker-compose v1(Python 寫的獨立 CLI)已經停止維護,官方推薦使用 Docker Engine 內建的 `docker compose`(沒有連字號)作為 Compose v2。設定檔仍叫 docker-compose.yml,但啟動指令從 `docker-compose up` 變成 `docker compose up`。本系列一律使用 Compose v2 語法。
Port 與網路:Docker 容器有自己的虛擬網路,預設不對外開放任何 port。要讓外面的瀏覽器或 curl 能打到容器內的服務,必須在 docker run 或 compose 用 `-p 主機:容器` 顯式把 port 對應出去。今天範例把容器內的 8000 對應到主機的 8000,因此可以從 `http://127.0.0.1:8000` 存取。同時,compose 內不同 service 之間可以用 service name 直接互通,不需要走主機的網路,這個特性會在 Day 31 把 PostgreSQL 變成獨立 service 時用到。
容器與虛擬機的差別:容器共用主機的 kernel,只有應用層隔離;虛擬機則連 kernel 都各自一份。容器的啟動時間通常在一秒內,虛擬機要數十秒到數分鐘;容器需要的資源(CPU、RAM)也比虛擬機少一個量級。這也是為什麼現代後端部署幾乎都走容器,VM 留給需要特定 kernel 版本或強隔離的場景。
專案結構與依賴
為了讓 Dockerfile 與 compose 檔的相對路徑容易理解,先把我們要打包的專案攤開來。整個範例只有五個檔案,分別是:pyproject.toml 宣告依賴、app/main.py 是 FastAPI 程式、app/__init__.py 讓資料夾成為套件、Dockerfile 是 image 的組裝說明、docker-compose.yml 是多容器編排設定。
booking-api/
├── app/
│ ├── __init__.py
│ └── main.py
├── pyproject.toml
├── .dockerignore
├── Dockerfile
└── docker-compose.yml
pyproject.toml 用來宣告這個專案用到的 Python 套件。我們選擇 PEP 621 標準格式,後續可以無痛接 uv 或 poetry。今天的範例只需要 fastapi、uvicorn 與 pydantic-settings 三個套件。
[project]
name = "booking-api"
version = "0.1.0"
description = "預約管理系統 API(範例)"
requires-python = ">=3.13"
dependencies = [
"fastapi==0.116.*",
"uvicorn[standard]==0.35.*",
"pydantic-settings==2.11.*",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
接著是 FastAPI 主程式。為了聚焦在容器化這件事,路由刻意簡化,只有 / 與 /healthz 兩個端點;前者回應歡迎訊息,後者做為容器的健康檢查端點,後面 docker-compose.yml 會用到。
from fastapi import FastAPI
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "booking-api"
db_path: str = "/data/booking.db"
settings = Settings()
app = FastAPI(title=settings.app_name)
@app.get("/")
def root() -> dict[str, str]:
return {"service": settings.app_name, "status": "ok"}
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "healthy"}
撰寫 Dockerfile
Dockerfile 是 image 的組裝說明書。我們用 python:3.13-slim 作為基底——slim 變體只保留 Python 直譯器與必要系統套件,image 大小約 150 MB,比完整的 python:3.13(接近 1 GB)小很多。對正式環境來說,image 每小 100 MB,pull 與 push 就省幾秒到幾分鐘。
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN groupadd --system app
RUN useradd --system --gid app --create-home --home-dir /app app
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir .
COPY app ./app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
這份 Dockerfile 有幾個刻意的小地方值得說明:第一,`PYTHONDONTWRITEBYTECODE=1` 防止 .pyc 寫進容器,減少 image 體積;第二,`PYTHONUNBUFFERED=1` 讓 uvicorn 的 log 直接送到 stdout,方便 `docker logs` 即時看到;第三,把安裝依賴與複製原始碼分成兩段,這樣只改 main.py 時 pip install 那一層會被 cache 住;第四,最後 `USER app` 切到非 root,這是容器安全的基本功;第五,`EXPOSE 8000` 是文件性質的宣告,真正把 port 對外是 docker compose 或 docker run 的工作。
.dockerignore 與 docker-compose.yml
.dockerignore 的角色類似 .gitignore,告訴 Docker build 時哪些檔案不要送進 context。沒設定這個檔案會讓 .env、.venv、__pycache__、測試報告等不該進 image 的東西全部被 COPY 進去,既增加 image 大小,也可能意外把 secrets 包進 image 推上 registry。
.git
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
.coverage
.env
.env.*
README.md
docs
tests
data
docker-compose.yml 是多容器編排的核心。我們只啟動一個服務(api),但用 named volume 把 SQLite 檔案保存起來,並加上 healthcheck 讓 Docker 自己判斷容器是否健康。
services:
api:
build:
context: .
dockerfile: Dockerfile
image: booking-api:dev
container_name: booking-api
restart: unless-stopped
ports:
- "8000:8000"
environment:
APP_NAME: booking-api
DB_PATH: /data/booking.db
volumes:
- booking-data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status == 200 else 1)"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
volumes:
booking-data:
這份 compose 檔有三個關鍵設計:`volumes` 用 named volume `booking-data` 保存 SQLite 檔,容器被刪除後資料還在;`healthcheck` 每 10 秒打一次 /healthz,連續失敗 5 次才標記為 unhealthy;`restart: unless-stopped` 讓主機重開機後容器會自動重啟,模擬正式環境的行為。健康檢查的指令刻意用 Python 而非 curl,因為 slim 映像沒有裝 curl,能減少 image 體積。
建置、啟動與驗證
前面準備好之後,剩下幾個指令就能把整套東西跑起來。第一次 build 需要下載 python:3.13-slim 並安裝套件,大約 1 到 2 分鐘;之後改 code 再 build,只會重做有變動的 layer,通常 5 秒內完成。
docker compose build # 建置 image
docker compose up -d # 背景啟動 container
docker compose ps # 查看狀態(STATUS 應為 healthy)
curl http://127.0.0.1:8000/ # 輸出:{"service":"booking-api","status":"ok"}
curl http://127.0.0.1:8000/healthz # 輸出:{"status":"healthy"}
docker compose logs api # 查看 API log
docker compose down # 停止並移除 container(volume 保留)
驗證時請特別看 `docker compose ps` 的 STATUS 欄。如果顯示 `Up X seconds (healthy)` 表示 healthcheck 通過;如果停在 `Up X seconds (health: starting)` 表示容器剛啟動還在等 start_period;如果變成 `Up X seconds (unhealthy)` 表示 healthcheck 連續失敗,需要用 `docker compose logs api` 看 log 找原因。如果想看更詳細的 log,可以加 `docker compose logs -f api` 持續追蹤。
進入容器內除錯時,`docker compose exec api /bin/bash` 可以開一個互動 shell;這個指令常用在「程式在容器裡跑得好好的,本機跑卻壞掉」的情境,讓你確認容器內的環境是否符合預期。SQLite 檔可以從主機用 `docker compose exec api sqlite3 /data/booking.db` 開 shell,但今天的範例還沒用到資料庫,先當作備忘。
如果你想要看容器內的 Python 環境,底下的腳本會列出容器內已安裝的套件與版本,並把它跟主機的 Python 比較。差異通常就是「為什麼程式在本機能跑、容器內不能跑」的線索來源。
import subprocess
import sys
def list_packages_in_container(container: str) -> str:
"""呼叫 docker compose exec 在容器內列出 pip 套件。"""
cmd = ["docker", "compose", "exec", container, "pip", "list", "--format=json"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout
def parse_packages(payload: str) -> dict[str, str]:
"""把 pip list 的 JSON 輸出轉成名稱→版本的字典。"""
import json
return {item["name"].lower(): item["version"] for item in json.loads(payload)}
if __name__ == "__main__":
payload = list_packages_in_container("api")
container_pkgs = parse_packages(payload)
key = "fastapi"
print(f"容器內 fastapi 版本:{container_pkgs.get(key, '未安裝')}")
print(f"主機 fastapi 版本:{__import__('fastapi').__version__}")
if container_pkgs.get(key) != __import__("fastapi").__version__:
sys.exit("版本不一致,請重新 build image")
這個小工具的概念是「把容器當黑盒,用 subprocess 呼叫 docker CLI 觀察它的狀態」。`subprocess.run` 在容器不存在時會拋出 `CalledProcessError`,可以把那段包成 try/except 變成更友善的錯誤訊息。實務上這個手法常用在 CI 跑整合測試時的環境驗證,例如在 GitHub Actions 裡比對容器與本機的 Python 版本是否符合預期。
另一個驗證 named volume 真的有保存資料的小腳本:用 docker compose down 把容器刪掉,再啟動一次,看 SQLite 檔案是否還在。
import os
import subprocess
from pathlib import Path
def volume_ls(volume_name: str) -> list[Path]:
"""列出 named volume 內的檔案,用 docker volume inspect 取得掛載點。"""
cmd = ["docker", "volume", "inspect", volume_name, "--format", "{{.Mountpoint}}"]
mountpoint = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip()
if not mountpoint:
return []
return sorted(Path(mountpoint).iterdir())
if __name__ == "__main__":
files_before = volume_ls("booking-data")
print(f"關閉容器前的 volume 內容:{[f.name for f in files_before]}")
subprocess.run(["docker", "compose", "down"], check=True)
subprocess.run(["docker", "compose", "up", "-d"], check=True)
files_after = volume_ls("booking-data")
print(f"重啟容器後的 volume 內容:{[f.name for f in files_after]}")
assert files_before == files_after, "named volume 沒有保存資料"
這個腳本示範了 named volume 的核心價值:容器可以反覆重建,但資料不會跟著消失。我們用 `docker volume inspect` 拿到 volume 在主機上的掛載點,再列舉裡面的檔案。這個模式在 Day 31 換成 PostgreSQL 之後會更重要,因為資料庫檔案通常都是放在 named volume 裡,容器一升級資料就掉可不是開玩笑。
沒有 Docker 時的替代流程
不是每台電腦都能裝 Docker(特別是公司配的 Windows 筆電,可能沒開 Hyper-V)。如果你目前無法跑 Docker,可以改用下面這套完全在本機執行的流程,所有功能與容器內版本一致,只是少了「環境封裝」這一層。
python -m venv .venv # 建立虛擬環境
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows PowerShell
pip install --upgrade pip
pip install -e . # 以可編輯模式安裝(依 pyproject.toml)
uvicorn app.main:app --reload --port 8000
這條流程用 Python 官方 venv + pip 安裝,跟 Dockerfile 裡的安裝步驟用同一份 pyproject.toml,所以套件版本一定一致。差別只在「應用直接跑在主機 Python 上」而不是容器裡。等之後拿到能裝 Docker 的環境,再切回 `docker compose up` 就好,程式碼完全不需要改。SQLite 檔也從容器內的 /data/booking.db 變成 ./booking.db,路徑由 .env 裡的 DB_PATH 控制。
常見錯誤與踩雷
把 .env 一起 COPY 進 image。如果 .dockerignore 沒寫好,build context 裡的 .env 會被 COPY 進 image,之後 image 推到 registry 就會把 secrets 一起送出去。務必在 .dockerignore 加上 .env,並在 compose 裡用 `environment` 或 `env_file` 顯式注入;千萬別把 .env 寫進 image。如果真的不小心把 secrets 推上 Docker Hub,記得立刻 rotate 並從 registry 刪除,否則可能被自動化掃描工具撈走。
用 root 跑應用。預設 Docker container 內的 process 是 root,這代表容器被入侵時攻擊者直接拿到主機 root。今天的 Dockerfile 已經用 `useradd` 建了 app 帳號並切換過去,但很多人會忘記這一步,請記得加上 `USER app`。如果 image 已有 root 寫的檔案,記得在 COPY 後用 `chown -R app:app /app`,不然非 root 帳號會因為沒寫入權限而啟動失敗。
bind mount 在容器內寫入 SQLite 的權限問題。如果用 `./data:/app/data` 這種 bind mount,容器內的 app 帳號可能沒辦法寫入主機的 ./data 目錄(特別是 UID 不對應時)。解法有三種:把目錄 chown 給對應的 UID、用 named volume、或者用 `:cached` / `:delegated` 等 mount option。範例程式為了簡化,採用 named volume 是最不容易踩雷的做法。
build 完 image 卻沒重啟 container。改了程式碼之後只跑 `docker compose build` 不會自動重啟,要再跑 `docker compose up -d` 才行。如果想要「改 code 後自動重啟」,可以掛上 volumes 並把 uvicorn 換成 `--reload`,但 production 不要這樣做。`--reload` 會額外開檔案監聽 process,浪費記憶體且可能影響 hot path 效能。正式環境的更新流程應該是:build 新 image → push 到 registry → 用新 image 啟動新 container → 流量切換 → 關掉舊 container,這個流程在 Day 32 與 Day 41 都會再示範。
沒有指定 image tag。如果 docker-compose.yml 沒寫 `image: booking-api:dev`,compose 會用「目錄名-服務名」組合的 `latest` 作為預設 tag,例如 `booking-api_api:latest`。這個 latest tag 容易被覆寫、難以追蹤版本,正式部署一定要明確標 tag(例如對應 Git commit SHA)。
忘記設定 timezone。容器預設使用 UTC 時區,跟台灣(UTC+8)差 8 小時,log 時間戳記會跟使用者的當地時間對不上。可以在 Dockerfile 加上 `ENV TZ=Asia/Taipei`,或在 compose 用 `environment: TZ: Asia/Taipei` 顯式注入。後者比較好維護,因為不同環境(測試、正式)可以各自覆寫。
效能與實務提醒
image 大小會直接影響 CI 的 build 時間與部署時的 pull 時間。三個常用瘦身手段依序是:用 slim 或 alpine 基底(從 1 GB 降到 150 MB)、用 multi-stage build 把編譯工具留在 builder stage(最終 image 不需要)、用 `pip install --no-cache-dir` 與 `PYTHONDONTWRITEBYTECODE=1` 減少中間產物。今天的範例已經做了前兩者,後續章節若需要 C 編譯(例如某些加密套件),會再示範 multi-stage。
layer 快取的正確順序很重要:「不常變動的層放前面,常變動的放後面」。所以才會先 COPY pyproject.toml、再 COPY app/。如果你反過來寫,每次改 main.py 都要重跑 pip install,build 時間會從 5 秒膨脹到 1 分鐘以上,CI 跑久了成本驚人。要看哪一層佔空間可以用 `docker history booking-api:dev`,每一層都會列出大小與建立指令。
正式環境務必把 uvicorn 的 `--reload` 拿掉,並考慮改用 `uvicorn --workers 4` 啟動多個 worker 撐住流量。容器內一般建議搭配 gunicorn 或 uvicorn 的 process 管理方式,今天先用最簡的單 worker 範例走完流程,後續章節會再談水平擴展。另一個實務提醒是「不要在容器裡跑多個無關服務」:每個容器只做一件事,擴展與故障隔離都比較好處理,這也是後面 docker-compose.yml 把資料庫拆成獨立服務的原因。
小結
今天我們從「環境一致」的需求出發,寫了第一份 Dockerfile 與 docker-compose.yml,把 FastAPI 服務裝進容器並驗證健康檢查。重點觀念包括:image 與 container 的差別、layer 的快取邏輯、bind mount 與 named volume 的取捨、為什麼要用 .dockerignore 與非 root 帳號。文末也提供了不安裝 Docker 時的替代本地流程,確保每位讀者都能在今天的進度上往前推進。明天我們會把容器內的 SQLite 換成正式環境等級的 PostgreSQL,並用容器跑資料庫。
結語
Docker 是上線章節的地基,今天打地基、明天疊牆。掌握好 Dockerfile 與 compose 的基本寫法,後面 Day 33 的反向代理、Day 41 的多容器一鍵部署都會在這個基礎上擴充。明天,我們會把 SQLite 換成 PostgreSQL、用容器跑資料庫,並討論連線池與遷移指令怎麼串進容器啟動流程。
延伸資源
- Docker 官方文件:
https://docs.docker.com/engine/(Docker Engine 27.x 世代) - Docker Compose 規格說明:
https://docs.docker.com/compose/compose-file/(Compose v2) - python:3.13-slim 映像說明:
https://hub.docker.com/_/python - FastAPI 官方部署指南:
https://fastapi.tiangolo.com/deployment/docker/ - PEP 621 套件中繼資料標準:
https://peps.python.org/pep-0621/
留言
張貼留言