🕌

uvで管理するFastAPIプロジェクトの本番イメージのMulti Stage Build

に公開

はじめに

Nauhの池田です。

以前はPythonのプロジェクトはpoetryで管理することが多かったのですが、最近はuvを使用することが増えてきました。
FastAPIのバックエンドサービスをKubernetes, CloudRun, ECSなどでホストする際はContainer Imageのサイズをできるだけ削減するために最低限の依存関係だけをインストールしたContainerを用意することが一般的ですが、uvで管理するプロジェクトの場合はどのように実現できるのかを調べたのでその結果をまとめておきます。

uvについて

uvの特徴としてはRustで開発されており高速に動作することなどがありますが、個人的にPythonのプロジェクトの開発をしていてpoetryより便利だなと思うのはPythonのバージョンもuvで管理できることです。
開発環境のPythonのバージョンを固定するためにDevcontainerを利用するなどといった方法もありますが、どうしても開発環境の立ち上げにワンテンポかかるため、ローカルのPythonの仮想環境だけで済むのは楽です。poetrypyenvと組み合わせることで似たようなことはできますが、uvの手軽さには叶わないと思います。

Container Imageのビルド方針

方針

コンテナオーケストレーターでPodを立ち上げる際、Imageのサイズは小さい方がPullが早くなるので有利です。また、同梱するツールが多いと不具合を引き起こす確率も増加します。
Pythonで開発している製品のContainer Imageを作成する際はMulti Stage Buildを利用して、builder環境でPythonの仮想環境を作成し必要な依存パッケージをインストールし、本番Imageに仮想環境と製品コードをコピーして利用するというのが基本的なやり方となります。

プロジェクトの準備

FastAPIのサンプルプロジェクトを準備します。

$ uv init fastapi-uv-image-build  --python 3.10.17
$ cd fastapi-uv-image-build
$ uv add fastapi[standard]

今回は検証用に最低限のAPIがあればいいのでmain.pyに以下のようなAPIを実装します。

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

このコマンドでlocalhost:8080にAPIを立ち上げることができます。

$ uv run fastapi run main.py --port 8080

curlでリクエストを投げてみて、Hello Worldが返ってくればOKです。

$ curl localhost:8080
{"message":"Hello World"}%   

Dockerfileの作成

ここから本題であるDockerfileを作成していきます。
まずはベースイメージを選定します。ベースイメージにはDockerHubのPython OfficialのImageを利用するのが便利です。
OSはDebianを選択したほうが無難です。Alpineはglibcでなくmusl-libcを採用していることもあり、Debianベースでは発生しないエラーが発生することもあります。
Pythonのバージョンは開発環境で使用しているPythonのバージョンを指定します。開発環境ではローカルに指定のPythonのバージョンがなければuvが自動でPythonをダウンロードしてきますが、本番イメージにはuvを含めないためImage内にあらかじめ使用するバージョンのPythonが存在するイメージを利用します。
今回はpython:3.10.17-slim-bookwormを利用します。

以下のようなDockerfileを作成します。

FROM python:3.10.17-slim-bookworm AS base

ENV DEBIAN_FRONTEND=noninteractive


FROM base AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml .
COPY uv.lock .

ENV UV_COMPILE_BYTECODE=1 \
    UV_NO_INSTALLER_METADATA=1 \
    UV_LINK_MODE=copy

RUN uv sync --frozen

FROM base AS app
WORKDIR /app

COPY --from=builder /app/.venv /app/.venv
COPY main.py .

ENV PATH=/app/.venv/bin:$PATH

EXPOSE 8000

CMD ["fastapi", "run", "main.py",  "--port", "8000"]

まずはbaseステージを定義します。今回はないですがapt-getなどでパッケージを入れる必要がある場合はこのステージに処理を追加します。

builderではPythonの仮想環境を用意して依存パッケージをインストールします。
この行はuvのオフィシャルガイドに紹介されている方法で、uvのオフィシャルイメージからuvの実行ファイルをコピーしてくることができます。

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

pyproject.toml, uv.lockをイメージにコピーして

RUN uv sync --frozen

を実行すると仮想環境が作成されて依存パッケージがインストールされます。

最後にappステージにFastAPIの実行に必要なものをコピーして終わりです。

Dockerfileを作成した上で以下のコマンドを実行すると、Docker Imageをビルドしてサーバーを実行することができます。

$ docker build -t fastapi-sample .
$ docker run --rm -it -p 8000:8000 fastapi-sample:latest

localhost:8000からAPIにアクセスできるので、以下のようにレスポンスが返ってくれば成功です。

$ curl localhost:8000
{"message":"Hello World"}%                                                                     

今回作成したイメージのサイズは175MBでした。

$ docker image ls
REPOSITORY                                  TAG                             IMAGE ID       CREATED          SIZE
fastapi-sample                              latest                          16dfd8ad9113   16 minutes ago   175MB

仕組み

本番イメージの中身を少し見てみます。

$ docker run --rm -it fastapi-sample:latest bash
root@ac05d2961865:/app# ls -la
total 16
drwxr-xr-x 1 root root 4096 May 15 06:58 .
drwxr-xr-x 1 root root 4096 May 15 06:58 ..
drwxr-xr-x 4 root root 4096 May 15 06:14 .venv
-rw-rw-r-- 1 root root  117 May 15 06:17 main.py

/appには実行ファイルmain.pyと仮想環境.venvが存在します。

pythonfastapiの実行ファイルの場所を確認すると仮想環境内に存在することがわかります。

root@ac05d2961865:/app# which python
/app/.venv/bin/python
root@ac05d2961865:/app# which fastapi
/app/.venv/bin/fastapi

このpythonの実行ファイルは/usr/local/bin/python3へのシンボリックリンクになっています。

root@ac05d2961865:/app# ls -l $(which python)
lrwxrwxrwx 1 root root 22 May 15 06:14 /app/.venv/bin/python -> /usr/local/bin/python3

uvは仮想環境を作成する際に指定のバージョンのPython interpreterがPATHに存在するかを確認し、存在したらそのPython interpreterを利用し、
見つからなければそのバージョンのPython interpreterをダウンロードしてきます。
よってあらかじめ環境内に目的のバージョンのPython interpreterを持つイメージをベースイメージとすることでinterpreterのダウンロードが発生しないようにしています。
Pythonのバージョン周りの正確な動作については公式ドキュメントを参照してください。

まとめ

本記事では、uvで管理するFastAPIプロジェクトのコンテナイメージをMulti Stage Buildを使って効率的に作成する方法を紹介しました。この方法の主なポイントは以下の通りです:

  1. uvの活用: Rustで実装された高速なパッケージマネージャであるuvを利用することで、開発環境でのPythonバージョン管理も含めた効率的な環境構築が可能になります。

  2. Multi Stage Buildの採用: ビルド環境と実行環境を分離することで、最終的なイメージサイズを175MB程度まで削減できました。これにより、コンテナのデプロイ速度の向上や運用コストの削減が期待できます。

  3. 効率的な依存関係管理: uv sync --frozenコマンドで依存パッケージを厳密にインストールし、本番環境に必要な最小限のコンポーネントだけを含めることができます。

  4. uvの実行ファイルの取得: uvのオフィシャルイメージから実行ファイルをコピーするという手法を使うことで、ビルド環境でuvを簡単に利用できます。

この方法は他のPythonフレームワークを使用したプロジェクトでも応用できるので、ぜひ試してみてください。今回紹介したパターンを活用することで、軽量かつ効率的なコンテナイメージの作成が可能になると思います。

Nauh(ナウア)テックブログ

Discussion