Docker 実戦 #1 FastAPI コンテナ化 — uv・マルチステージ・non-root

読了 9分

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 一ファイル。

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 から始めます。

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 参考)。

このイメージでも動作はします。しかし運用に乗せるには三つが気になります。

  1. pip installDockerfile の中に刻まれていて依存性がコードと同期しない
  2. ビルド道具 (gcc のようなものはまだ入っていませんが、キャッシュ効率が悪い) とランタイムが一レイヤーに混ざる
  3. コンテナが root で動いていてヘルスチェックがない

一つずつ捉えていきます。

uv で依存性をロック — uv.lock を活用 #

uv init がすでに pyproject.tomluv.lock を作ってくれました。これをコンテナビルドにそのまま持っていけば依存性が自然にロックされます。

プロジェクト構造
fastapi-docker-demo/
├── app/
│   └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfile

uv は Docker の中でもよく動きます。公式イメージ (ghcr.io/astral-sh/uv) もありますが、ここでは python:3.14-slim の上に uv バイナリだけコピーして使う小さなトリックを使います — ベースが慣れた Python イメージなのでデバッグが楽です。

Dockerfile (uv 導入)
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"]

核心は 二段階に分けて COPYRUN を配置したこと です。

  1. まず pyproject.toml + uv.lock だけコピーして依存性をインストール — このレイヤーは依存性が変わらなければキャッシュされます。
  2. その次にコードをコピー — コードだけ変われば依存性インストール段階はキャッシュからそのまま。

この順序を逆にするとコードを一行変えるだけで毎回 uv sync がまた回ります。ビルド時間が分単位で長くなります。これは 中級 #2 ビルドキャッシュ で深く扱ったテーマで、実戦でも同じ原理です。

--no-dev は dev 依存 (テスト、リンター) を抜くオプション。--frozen はロックファイルと正確に一致するバージョンを使うという意味です。CI / プロダクションでは常にオン。

マルチステージでスリミング #

今は uv sync までしたイメージがそのままランタイムになります。ビルド時のキャッシュ (~/.cache/uv) も一緒に入ってイメージが不必要に大きい。マルチステージ でビルドとランタイムを分けるときれいになります。(中級 #1 のパターン。)

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

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 にユーザを一つ作ってそれに切り替えます。

non-root 追加
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 — この時点から実行される全ての命令 (以降の RUNCMD 全て) が app で。

ここでよく詰まるポイント:

  • 1024 未満ポート (80443) は非 root が開けません。uvicorn を 8000 のような高いポートで立ち上げ、80/443 は LB / プロキシに任せるのが定石です。
  • アプリがディスクに何かを書くなら (ログファイル、アップロード) そのディレクトリも chown app:app する必要があります。chown -R よりも必要なディレクトリだけ指す方が良い — レイヤーが肥大化しないように。

HEALTHCHECK — 死んだか生きてぶら下がっているか区別 #

Docker はコンテナの PID 1 が生きていれば「実行中」と見ます。しかし PID 1 が立ち上がっていても応答できない状態がありえます — デッドロック、DB 接続切れ、外部 API 無限待ち。HEALTHCHECK がこれを区別してくれます。

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 依存に入れて短いヘルスチェックスクリプトを置く方法もあります。

状態確認:

health 状態を見る
docker run -d --name api -p 8000:8000 fastapi-demo
docker ps
# STATUS カラムに (health: starting) → (healthy) に変わる

docker inspect --format='{{.State.Health.Status}}' api
# healthy

Docker 自体は unhealthy になったからといってコンテナを自動再起動してくれません。再起動は 上級 #6restart 方針側の仕事で、普通は ECS/Kubernetes/Compose の healthcheck がこの信号を受けてコンテナを置き換えます。

完成された Dockerfile #

ここまでの断片を集めた最終形です。

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 一ファイルが加われば、ビルドコンテキストが整理されます。

.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 historydocker 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.tomluv.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・ボリュームまで運用形に整理します。

X