5年ほど前にPythonのコンテナ化について2つの記事を書きましたがFastAPI側もDocker側もアップデートがあり、当時よりもかなりシンプルになってきたのを感じたので少し調べてまとめてみました。
書き方の部分は別としてPythonにおけるコンテナイメージ選択の考え方とかは2020年に書いたときとは変わっていませんので、適宜そちらを参照してください。
- 仕事でPythonコンテナをデプロイする人向けのDockerfile (1): オールマイティ編
- 仕事でPythonコンテナをデプロイする人向けのDockerfile (2): distroless編
(1)の方からのアップデートとしてはDebianのバージョンですね。stretch(9), buster(10)はすでにEOLです。その次に出たbullseye(11)は2026年8月でEOLです。今からならbookworm(12)がおすすめです。
(2)の方のアップデートもほぼ同じで、Debianの新バージョンを使ったイメージが追加されています。それにともってPythonのバージョンも新しくなっています。
- gcr.io/distroless/python3-debian11: Python 3.9.2
- gcr.io/distroless/python3-debian12: Python 3.11.2
Dockerfileの書き方自体のアップデートとしては以下に書いた内容がベースとなります。本エントリーでもちょくちょくDockerの説明はありますが、詳細はこちらも併読してもらえると良いかと思います。
本エントリーではDockerfileを使ったdebian-slim, Chainguard, Distrolessベースのイメージ作成だけを取り上げます。コンテナ用のイメージについては次のページにまとめがあります。
FastAPIのアプリケーションを作る
まずはコンテナイメージに焼き込むアプリケーションを作ります。以前のFastAPI記事ではPoetryを使った環境構築を紹介しましたが、ここ数年でRye、uvと出てきて、今ではuvが覇権を取りそうですので、uvで作ってみます。ただ、ツールは変わっても基本的なメンタルモデルはあんまり変わらないですね。早くなんでもいいから標準に入ってほしい。
環境構築からパッケージのインストールまで一気に終わります。
$ mkdir pyapp
$ cd pyapp
$ uv init
$ uv add fastapi --extra standard
とりあえずファイルを作ります。
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
async def read_root():
return {"Hello": "World"}
次のコマンドを実行するとポート番号8000で開発モードで起動します。開発モードだとファイル変更を検知して自動再起動します。
$ uv run fastapi dev
起動するスクリプトとかも自動探索してくれますし、gunicornでuvicornワーカーを使って起動(5年前にブログに書いたやつ)とか、uvicornコマンドで起動(FastAPIの本家の日本語訳はまだこれだった)とかではなく、fastapi
コマンド一発で裏でuvicornを使って非同期IOを活用したモードで立ち上がります。Next.jsとかそういうのと近い感触。
本番モードはdevの代わりにrunを使います。自動探索ではなく明示的に初期スクリプトを指定したり、ポート番号を与えるのも良いでしょう。こちらの方が明示的になるし検索でひっかかるようになるので長期運用されるものに対してはこうする方が個人的には好きです。
$ uv run fastapi run main.py --port 8000
ブラウザでアクセスしてみて大丈夫だったら次に進みます。
Docker化
効率の良いDockerイメージ化にはマルチステージビルドが必要で、キャッシュのマウントやら何やら、というのは一度以上見かけたことがある方は多いでしょう。しかし、Dockerの機能追加のおかげで、言語によってはマルチステージビルドは不要になりました。
基本なPythonベースのイメージの作成
docker init
コマンドが追加され、よくコンテナと一緒に使われる言語であれば、Dockerfileが自動生成できます。Pythonもその対象の言語の1つですので、特別な要件がなければ書き方に頭を悩ませる必要はありません。以下のDockerfile
はこのコマンでほぼ一発で出力したものです。debian-slimベースなのでサイズはそこそこ小さく、速度や性能は問題ないです。ウィザードの最後のコマンドとポートだけちょっと直したぐらい。
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.13.3
FROM python:${PYTHON_VERSION}-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt
USER appuser
COPY main.py main.py
EXPOSE 80
CMD ["fastapi", "run", "main.py", "--port", "80"]
これの実行前にはrequirements.txtの生成が必要です。uvは良いのですがライブラリ更新のたびに自動で出力してくれるオプションとかあったらなぁ、と画竜点睛感はありますが。
$ uv pip compile pyproject.toml > requirements.txt
このDockerfileはマルチステージビルドではありません。新しいbind/cacheマウントが入る前は次のような手順でやっていました。 requirements.txtやロックファイル、インストール用のツールはデプロイ用イメージにはいらないのでマルチステージビルドビルドで分離していたわけです。
- requirements.txtやロックファイルをCOPYする
- インストール用のツールを入れる
- インストールする(キャッシュが残る)
- 別のデプロイ用イメージに必要なファイルをコピーする
しかし、このDockerfileではこれらが不要となっています。
-
python -m pip install
: uvを使えば開発用ツールはrequirements.txtに入らないので標準ライブラリの範疇で十分 -
--mount=type=cache,target=/root/.cache/pip
: キャッシュはそもそもイメージに入らない -
--mount=type=bind,source=requirements.txt,target=requirements.txt
: インストールだけに必要なファイルだがこれもビルド時にだけ存在し、イメージに入らない
最初から余計なものが入らない工夫をしているため、マルチステージビルド自体が不要です。GoとかRustとかTypeScriptの静的コンパイル言語だったりだとまだまだ必要ですが、そのまま実行するスクリプト言語だとかなりシンプルです。レイヤーキャッシュ芸とか&&でつながりまくったRUN
は過去のものに。
RUNのマウント周りの引数、よくわからん、自分で書ける気がしない、と思われるかもしれませんが、心配する必要はありません。pipなり、aptなり、npmなり、ビルドでたくさん中間ファイルを撒き散らすコマンドごとに正解のオプションは調べれば出てきます。定型句です。
Chainguard
以前も紹介したDistrolessはGoogleのプロダクトですが、それをメインのビジネスとしているのがChainguardです。無料だとlatestのみが選べ、有償サービスに入るとバージョン固定ができる、という感じのようです。ChainguardベースのイメージもDistroless同様にシェルがないのでセキュリティに穴があってもそもそも稼働中のコンテナの中で悪さができない(ログインできない)から強い(アタックサーフェースが狭い)、というのが理屈です。なお、Pythonの標準ライブラリも、攻撃の足掛かりにされるようなビルドしたりインストールするためのものは省かれています。そのためpython -m pip
でインストールを行った標準的なやり方はでず、必然的にマルチステージビルドが必要になります。
Distrolessはdebugにするとシェル入りになる以外の選択肢がなく、イメージ作成にはDebianベースの標準のPythonイメージを使いましたが、Chainguardはシェルなしのlatestと、開発ツールやシェル、pipモジュールなどの開発用の標準ライブラリも揃っているlatest-devの2つがあり、どちらもChainguardで揃えられます。なお、ChainguardのOSはDebianではなく、Wolfiというもので、これもChainguardがメンテナンスしています。apkコマンドがあるのでAlipne系な雰囲気ですが、muslではなくreadelfで見てみるとlibcを使ってそうなところもPython的には良いですね。
Chainguardの°ドキュメントを見ると、venvを持ってきてそれを使っています。以前のエントリーではDistrolessはシェルがないのでvenvを使う方式は避けて無理やりsite-packagesに入れる方針でやりましたがバージョン番号固定のDockerfileになってしまうのでこちらの方が良いですね。
# syntax=docker/dockerfile:1
# ビルド用イメージ
FROM cgr.dev/chainguard/python:latest-dev AS builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /opt/app
RUN python -m venv /opt/app/venv
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
/opt/app/venv/bin/pip install -r requirements.txt
# 実行用イメージ
FROM cgr.dev/chainguard/python:latest AS runner
WORKDIR /opt/app
ENV PYTHONUNBUFFERED=1
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /opt/app/venv /venv
COPY main.py /opt/app/main.py
EXPOSE 80
ENTRYPOINT ["python", "/venv/bin/fastapi", "run", "main.py", "--port", "80"]
Chainguardのドキュメントだと古いCOPYとRUNを組み合わせた書き方になっていますが、マルチステージビルドであったとしても、cache/bindマウントを活用する方がキャッシュ効率を上げつつ、キャッシュのために余計なパズルを組み立てる必要はないというメリットは得られます。
Distroless
前回書いたDistrolessのイメージ作成方法はsite-packagesを丸ごと持ってくるちょっと無理やりな方法で実現していました。Chainguardのやり方がスマートだったので、それと同様のvenvを使った方式でやってみます。
なお、前回書き忘れましたが、:debug
付きイメージはエントリポイントとしてシェル(busybox)を起動できます。DistrolessのPythonイメージは、本来シェルが指定されるENTRYPOINTにPythonインタプリタが指定されているのでシェルを起動する場合はイメージ名の後ろにコマンドを書いてもダメで、--entrypoint
引数でシェルを渡す必要があります。
$ docker run --rm -it --entrypoint=sh gcr.io/distroless/python3-debian12:debug
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.11.2
ARG DISTROLESS=python3-debian12
# ビルド用イメージ
FROM python:${PYTHON_VERSION}-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt
# debianとdistrolessでPythonのフォルダが違うのでvenvの中のシンボリックリンクを修正
RUN rm /opt/app/venv/bin/python
RUN ln -s /usr/bin/python /opt/app/venv/bin/python
# 実行用イメージ
FROM gcr.io/distroless/${DISTROLESS}:debug AS runner
WORKDIR /opt/app
ENV PYTHONUNBUFFERED=1
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /opt/app/venv /venv
COPY main.py /opt/app/main.py
EXPOSE 80
ENTRYPOINT ["python", "/venv/bin/fastapi", "run", "main.py", "--port", "80"]
Chainguardと違ってビルド用イメージと実行用イメージが違う関係でPythonのパスが違っており、それによりvenvフォルダ(中でPythonインタプリタへのシンボリックリンクを持っている)をそのまま持ってきてもうまくいきませんのでちょっとリンクを貼り直す行が必要です。おかげでvenvに詳しくなりました。
まとめ
Dockerの更新そのものは追っかけてましたが、それによりPythonのイメージの作成がどう変わったのかというのは把握できていなかったので「なぜそうなっているのか」「今の時代どうすべきか」を調べながら書いてみました。また、FastAPI自身もアップデートがあり、実行方法が簡単になっていた(簡単になりすぎて不安になった)ので、それも盛り込んでみました。