Docker 実戦 #1 FastAPI コンテナ化 — uv・マルチステージ・non-root
Docker トラックの最後のシリーズ — 実戦 です。基礎・中級・上級の 18 編で築いた道具を実際のプロジェクトに一つずつ適用しながら、本当にデプロイに乗せられる形を作ります。
このシリーズは Docker 実戦 6 編です。
- #1 FastAPI コンテナ化 — uv・マルチステージ・non-root ← この記事
- #2 Django + PostgreSQL compose セットアップ
- #3 React/Next.js ビルドコンテナ
- #4 CI でのイメージビルド — GitHub Actions
- #5 レジストリ push とタグ戦略
- #6 クラウドデプロイ — Fly.io / Railway / ECS
この記事は FastAPI アプリをコンテナで束ねる流れ を最初から最後まで追います。出発は最もシンプルな Dockerfile、終点はマルチステージ + non-root + HEALTHCHECK が付いた運用親和的なイメージ。モダン Python 実戦 — FastAPI シリーズ のコードを直接触らなくても追えるように、最小限のアプリを新しく書きます。
出発点 — 小さな FastAPI アプリ #
まずコンテナ化するアプリを手にします。uv で新しいプロジェクトを作って FastAPI と uvicorn だけ入れます。
uv init fastapi-docker-demo
cd fastapi-docker-demo
uv add fastapi 'uvicorn[standard]'そして app/main.py 一ファイル。
from fastapi import FastAPI
app = FastAPI(title="docker demo")
@app.get("/")
def root() -> dict[str, str]:
return {"message": "hello from container"}
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}/healthz はわざと分けました。HEALTHCHECK が叩くエンドポイントであり、クラウド LB が見るエンドポイントです。
ローカルで一度立ち上げて動作を確認しておけばコンテナで詰まったとき比較が楽です。
uv run uvicorn app.main:app --reload
# http://127.0.0.1:8000/ → {"message":"hello from container"}最もシンプルな Dockerfile — 出発点 #
まず運用親和性などは全部忘れて、一回で動く最も短い Dockerfile から始めます。
FROM python:3.14-slim
WORKDIR /app
COPY . .
RUN pip install fastapi 'uvicorn[standard]'
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]ビルド → 実行 → 応答確認。
docker build -t fastapi-demo .
docker run -p 8000:8000 fastapi-demo
# 別のターミナルで
curl localhost:8000/
# {"message":"hello from container"}ここで押さえておく二つ。
--host 0.0.0.0が抜けるとコンテナの中の 127.0.0.1 だけにバインドされてホストからアクセスできません。コンテナから外に開放するなら常に 0.0.0.0。- ポートマッピング
-p 8000:8000は「ホストの 8000 → コンテナの 8000」。EXPOSE 8000だけでは自動マップされません (基礎 #4 参考)。
このイメージでも動作はします。しかし運用に乗せるには三つが気になります。
pip installがDockerfileの中に刻まれていて依存性がコードと同期しない- ビルド道具 (
gccのようなものはまだ入っていませんが、キャッシュ効率が悪い) とランタイムが一レイヤーに混ざる - コンテナが root で動いていてヘルスチェックがない
一つずつ捉えていきます。
uv で依存性をロック — uv.lock を活用
#
uv init がすでに pyproject.toml と uv.lock を作ってくれました。これをコンテナビルドにそのまま持っていけば依存性が自然にロックされます。
fastapi-docker-demo/
├── app/
│ └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfileuv は Docker の中でもよく動きます。公式イメージ (ghcr.io/astral-sh/uv) もありますが、ここでは python:3.14-slim の上に uv バイナリだけコピーして使う小さなトリックを使います — ベースが慣れた Python イメージなのでデバッグが楽です。
FROM python:3.14-slim
# uv バイナリだけコピー
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
# 1) 依存定義だけ先にコピー → キャッシュ効率
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
# 2) その次にコード
COPY . .
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]核心は 二段階に分けて COPY と RUN を配置したこと です。
- まず
pyproject.toml+uv.lockだけコピーして依存性をインストール — このレイヤーは依存性が変わらなければキャッシュされます。 - その次にコードをコピー — コードだけ変われば依存性インストール段階はキャッシュからそのまま。
この順序を逆にするとコードを一行変えるだけで毎回 uv sync がまた回ります。ビルド時間が分単位で長くなります。これは 中級 #2 ビルドキャッシュ で深く扱ったテーマで、実戦でも同じ原理です。
--no-dev は dev 依存 (テスト、リンター) を抜くオプション。--frozen はロックファイルと正確に一致するバージョンを使うという意味です。CI / プロダクションでは常にオン。
マルチステージでスリミング #
今は uv sync までしたイメージがそのままランタイムになります。ビルド時のキャッシュ (~/.cache/uv) も一緒に入ってイメージが不必要に大きい。マルチステージ でビルドとランタイムを分けるときれいになります。(中級 #1 のパターン。)
# ─── 1. builder ─────────────────────────────
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev
# ─── 2. runtime ─────────────────────────────
FROM python:3.14-slim AS runtime
WORKDIR /app
# builder で作られた venv とコードだけ持ってくる
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]違い:
- 二つの stage とも同じベース (
python:3.14-slim) を使います。venv の中の Python インタプリタが指すパスが同じでなければなりません。ベースが違うと venv が壊れる可能性。 UV_LINK_MODE=copy— uv がキャッシュから venv に hardlink を作るのがデフォルトですが、マルチステージで stage 間では hardlink が壊れるのでcopyを強制。UV_COMPILE_BYTECODE=1—.pycを予め作っておけば最初のリクエスト応答時間が短くなります。- runtime stage には
uv自体がありません。ランタイムに依存性をまた入れるわけではないので持っていく必要なし。 PYTHONUNBUFFERED=1は Docker ログが即時に流れるように — オフだと print/logging がバッファに縛られて見えないことがあります。
ビルドしてサイズを比較すると差が体感できます。
docker build -t fastapi-demo:multi .
docker images fastapi-demo
# fastapi-demo multi ... ~120MB (cache が抜けて減る)non-root で動かす #
デフォルトでは、コンテナの中のプロセスは root で動きます。ホストに直接届くわけではありませんが、コンテナが破られたとき root 権限をそのまま使えるという点が問題です。(上級 #3 イメージセキュリティ で扱ったテーマ。)
runtime stage にユーザを一つ作ってそれに切り替えます。
FROM python:3.14-slim AS runtime
# 非 root ユーザ生成
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]三つが追加されました。
groupadd+useradd—--systemは UID をシステム範囲 (< 1000) で取って/etc/passwdに入ります。COPY --chown=app:app— コピーしながら所有権を一緒に変えます。これをしないと root 所有のファイルを非 root が触れません。USER app— この時点から実行される全ての命令 (以降のRUNとCMD全て) がappで。
ここでよく詰まるポイント:
- 1024 未満ポート (
80、443) は非 root が開けません。uvicorn を 8000 のような高いポートで立ち上げ、80/443 は LB / プロキシに任せるのが定石です。 - アプリがディスクに何かを書くなら (ログファイル、アップロード) そのディレクトリも
chown app:appする必要があります。chown -Rよりも必要なディレクトリだけ指す方が良い — レイヤーが肥大化しないように。
HEALTHCHECK — 死んだか生きてぶら下がっているか区別 #
Docker はコンテナの PID 1 が生きていれば「実行中」と見ます。しかし PID 1 が立ち上がっていても応答できない状態がありえます — デッドロック、DB 接続切れ、外部 API 無限待ち。HEALTHCHECK がこれを区別してくれます。
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"オプションの意味:
--interval=30s— 30 秒ごとにチェック--timeout=3s— 3 秒以内に応答できなければ失敗とみなす--start-period=10s— コンテナ起動後 10 秒間は失敗してもカウントしない (ウォームアップ時間)--retries=3— 3 回連続失敗で unhealthy 状態に遷移
curl が入っていない slim イメージなので python -c で HTTP 呼び出しを書きます。もっと綺麗にするなら httpx のようなものを dev 依存に入れて短いヘルスチェックスクリプトを置く方法もあります。
状態確認:
docker run -d --name api -p 8000:8000 fastapi-demo
docker ps
# STATUS カラムに (health: starting) → (healthy) に変わる
docker inspect --format='{{.State.Health.Status}}' api
# healthyDocker 自体は unhealthy になったからといってコンテナを自動再起動してくれません。再起動は 上級 #6 の restart 方針側の仕事で、普通は ECS/Kubernetes/Compose の healthcheck がこの信号を受けてコンテナを置き換えます。
完成された Dockerfile #
ここまでの断片を集めた最終形です。
# ─── 1. builder ─────────────────────────────
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev
# ─── 2. runtime ─────────────────────────────
FROM python:3.14-slim AS runtime
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]ここに .dockerignore 一ファイルが加われば、ビルドコンテキストが整理されます。
.git
.venv
__pycache__/
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
.DS_Store
.env
.env.local.venv は特に大事 — 抜かないとホストの venv がまるごとコンテナにコピーされてビルドが壊れたり遅くなったりします。(基礎 #6 .dockerignore 参考。)
環境変数の扱い — ENV はビルド時、--env は実行時
#
設定値をコンテナに渡す方法は二つあります。
| 方法 | タイミング | 用途 |
|---|---|---|
ENV (Dockerfile) | ビルド / ランタイム | 変わらないデフォルト値 (PYTHONUNBUFFERED など) |
--env / -e (docker run) | ランタイム | 環境ごとに変わる値 (DATABASE_URL、API_KEY) |
--env-file | ランタイム | .env ファイルをまるごと |
DATABASE_URL のような秘密は絶対に ENV に刻まないでください。イメージレイヤーに平文で残り、誰でも docker history や docker inspect で見られます。
docker run -d -p 8000:8000 \
-e DATABASE_URL="postgresql://..." \
--env-file .env.production \
fastapi-demoビルドコンテキストとよくある落とし穴 #
ここまで一回でうまくいったなら、最後によく発火するポイントだけ触れておきます。
コンテナが即座に終了する — ほぼ十中八九 --host 0.0.0.0 漏れか、CMD が何かの理由で即座に終わるケース。docker logs <container> で stderr を確認。
uv sync が毎回最初から — pyproject.toml や uv.lock をコードより先にコピーしているか確認。または、キャッシュが壊れる位置 (例: 前の RUN の変更) があるか。
non-root なのに権限エラー — アプリが書こうとしているディレクトリの所有権が app で取れていないとき。COPY --chown=app:app または RUN chown -R app:app /必要な/パス。
Apple Silicon でビルドしたイメージがクラウド (amd64) で立ち上がらない — ビルドターゲットプラットフォームの違い。docker buildx build --platform linux/amd64,linux/arm64 ... (上級 #2 マルチアーキ 参考)。
まとめ #
- FastAPI コンテナ化の運用親和的な形は uv + マルチステージ + non-root + HEALTHCHECK の組み合わせ。
- 依存定義 (
pyproject.toml/uv.lock) とコードを 二段階に分けてCOPYすればビルドキャッシュが生きる。 - builder stage で venv を作って runtime stage に venv とコードだけコピー。uv 自体は runtime になくて良い。
USER app一行で非 root 切り替え。1024 未満ポートを開けない点だけ覚えておけば。HEALTHCHECKは Docker 自体が自動復旧してくれるわけではないが ECS/Compose/K8s がこの信号を受けて置き換える。- 秘密は
ENVではなく--env/--env-file/--secret側で。
次の記事 (#2 Django + PostgreSQL compose) ではコンテナ一個を回す段階から、複数のコンテナを一束で立ち上げる構成 に進みます。Django アプリと PostgreSQL の二つのコンテナを docker compose 一ファイルにまとめ、マイグレーション・healthcheck・ボリュームまで運用形に整理します。