Faber Company 開発ブログ

ファベルカンパニーと読みます。

Python Lambda + Docker でも uv を使いたい!

先日公開されたベトナム出張(日本への一時帰国からの帰り)で朝寝坊した、Faber Vietnam の菅原です。小林さん赤堀さん、申し訳ありませんでした……

小林さんのレポートを読む ↓

fabercompany-dev.hatenablog.com

たまには技術らしい話を書こうということで、今回は Python の Lambda 関数で uv を使う方法について書きたいと思います。

完成品の Dockerfile だけ見る

対象の関数

今回の関数は、ミエルカSEO で稼働している「AI Chat 機能」で使われているものです。

プロンプトの中にある名前や住所など、個人情報と思われる文字列を PERSONLOCATION などに置き換え、社内規定などで個人情報を LLM に入力できないお客様にも AI Chat 機能を使っていただけるようにするもので、開発チームでは「匿名化機能(anonymizer)」と呼んでいます。

以前にインターンとして参加していた T君が、Docker 未経験の状態から調べながら作ってくれました。

この関数は内部で Microsoft の Presidio を使っており、処理できる文字列に約49KBの制限があります。

このサイズであらゆるプロンプト入力に対応できると考えていたのですが、新機能開発が進むにつれて、他の機能で生成したプロンプトが入力されるケースが出てきました。 日本語 UTF-8 で数万文字に及ぶようなテキストが入力されると、制限長の 49KB を超えて AI Chat が応答しなくなるという問題が発生しました。

LLM 側の入力トークン数を増やす対応とともに、匿名化機能でも Presidio の制限を超えて処理できるようにすることになりました。

匿名化関数の改善

当初は pip だけで依存関係が管理されており、開発者がそれぞれ好きな環境分離ツールを使っている状況でした。

これを機にベトナムメンバーもメンテナンスできるように、uv を前提に環境構築ガイドを整備しました。

Docker イメージのサイズ削減

匿名化関数は、Presidio に spaCy を組み合わせて使っています。処理速度よりも精度を優先する方針で、トランスフォーマーモデルである ja_core_news_trf を採用しました。

さてこの spaCy + ja_core_news_trf ですが、依存関係のライブラリやそれをビルドするためのツールチェーン(spaCy 内部の Cython 用に C++、transformers 内部の tokenizers 用に Rust が必要になります)を含めると、最終的な Docker のイメージサイズが 11GB 超になってしまいます。

しかし AWS Lambda は 10GB を超えるイメージをデプロイできないので、このままでは動かせません。最初そのままデプロイしようとして CloudFormation で UPDATE_ROLLBACK_FAILED エラーが出て詰まったのですが、イメージサイズが大きすぎるのが原因でした。

Lambda の制限である 10GB に収まるように、マルチステージ構成でダイエットに励むことにしました。完成したものがこちらです。

# --- Build stage ---
FROM public.ecr.aws/lambda/python:3.12 AS build

# Install build dependencies
RUN dnf update -y && dnf install -y \
    gcc \
    gcc-c++ \
    make \
    && dnf clean all

ENV PATH="/root/.cargo/bin:$PATH" \
    UV_PROJECT_ENVIRONMENT=${LAMBDA_TASK_ROOT}/.venv

# Install Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal

# Install uv
COPY --from=ghcr.io/astral-sh/uv /uv /uvx /bin/

# Copy and install Python dependencies
WORKDIR ${LAMBDA_TASK_ROOT}
COPY uv.lock pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

# --- Runtime stage ---
FROM public.ecr.aws/lambda/python:3.12

# Copy Python libraries from the build stage
COPY --from=build ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}

# Copy application code
COPY ./src ${LAMBDA_TASK_ROOT}/src

# Activate the virtual environment
ENV PYTHONFAULTHANDLER=1 \
    PYTHONPATH=${LAMBDA_TASK_ROOT}/src:${LAMBDA_TASK_ROOT}:${LAMBDA_TASK_ROOT}/.venv/lib/python3.12/site-packages \
    PATH=${LAMBDA_TASK_ROOT}/.venv/bin:${PATH} \
    VIRTUAL_ENV=${LAMBDA_TASK_ROOT}/.venv

# Assume handler function is in src/main.py
CMD ["src.main.handler"]

Dockerfile の構成

この Dockerfile は build と runtime の 2 つのステージで構成されています。

  • build ステージ
    • public.ecr.aws/lambda/python:3.12 イメージをベースに、ビルドに必要なツール(C++ コンパイラや Rust)をインストールします。
    • uv をインストールし、Python の依存関係を uv sync でインストールします。
  • runtime ステージ
    • public.ecr.aws/lambda/python:3.12 イメージ(build ステージと同じイメージです)をベースに、build ステージでインストールした Python のライブラリとアプリケーションコードをコピーします。
    • C++ コンパイラや Rust などのビルドツールは不要なので、runtime ステージには含めません。
    • 仮想環境を有効化し、Lambda ハンドラを指定します。

ついでに Python のバージョンを 3.9 から 3.12 に更新しました。Python 3.9 は2025年10月にサポート期限が切れるので、そのとき慌てないようにしておきます。執筆時の最新版は 3.13 ですが、Presidio がまだ対応していないため使えません。

uv のインストール

uv の公式ドキュメント Using uv in Docker に従います。通常のセットアップスクリプトを使うより、Docker 側のキャッシュが効いて高速になることが期待できます。

COPY --from=ghcr.io/astral-sh/uv /uv /uvx /bin/
仮想環境の指定

仮想環境として ${LAMBDA_TASK_ROOT}/.venv を指定します。uv sync でインストールしたライブラリはすべてこのディレクトリに配置されます。

# build stage
ENV UV_PROJECT_ENVIRONMENT=${LAMBDA_TASK_ROOT}/.venv

runtime ステージでこの環境を使います。バージョン番号が PYTHONPATH に入っているのが要注意ポイントです。Python を更新するときは、ここも変える必要があります。

# runtime stage
ENV PYTHONPATH=${LAMBDA_TASK_ROOT}/src:${LAMBDA_TASK_ROOT}:${LAMBDA_TASK_ROOT}/.venv/lib/python3.12/site-packages \
    VIRTUAL_ENV=${LAMBDA_TASK_ROOT}/.venv
Python の依存関係のインストール

uv sync を使って、uv.lockpyproject.toml に定義された Python の依存関係を ${LAMBDA_TASK_ROOT}/.venv にインストールします。

# build stage
WORKDIR ${LAMBDA_TASK_ROOT}
COPY uv.lock pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

多少ビルド時間の短縮を期待しながら、--mount=type=cache,target=/root/.cache/uv オプションを付けて、uv のキャッシュを利用します

実行ディレクトリの準備

build ステージでインストールした Python のライブラリを runtime ステージにコピーします。

# runtime stage
COPY --from=build ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}

アプリケーションのソースコードがビルドコンテキストの src ディレクトリに配置されているものとして、これもコピーします。

# runtime stage
COPY ./src ${LAMBDA_TASK_ROOT}/src

ちなみに、COPY コマンドはこの順番で実行しないと src ディレクトリをコピーした後に ${LAMBDA_TASK_ROOT} をまるごと上書きしてしまい、アプリケーションが配置されません(1敗)。

これで、${LAMBDA_TASK_ROOT} のファイル構成は

${LAMBDA_TASK_ROOT} - /var/task
├── .venv
│   ├── bin
│   ├── lib
│   └── (略)
├── src
│   ├── main.py
│   └── (略)
├── uv.lock
└── pyproject.toml

のようになります。

これで、最終的なイメージサイズは 6.73GB になりました。まだ大きいですが、デプロイ可能な範囲に収まりました。

アプリケーションの変更

Presidio の制限を超える文字列を処理するために、長い入力を 20KB ごとに分割して匿名化処理を行うようにしました。具体的なソースコードは公開できないのですが、この対応で LLM 側のトークン数の上限まで入力できるようになりました。

分割した文字列を並列に処理することで速度を確保しつつ、入力中に同じ名前や場所が複数回出てきても文脈が途切れないよう、整合性を保つ処理を追加しました。

分割単位を短くすれば処理は高速化できますが、前後処理の都合でいくらでも性能が上がるわけではありません。メモリの使用量にも限界があります。

かといって分割が長すぎると実行時間が伸びてしまうので、お客様の待ち時間とコストのバランスを考えてチャンク長を調整する作業に神経を使いました。

今後も処理の高速化をはかるとともに、この機能の呼び出し状況や待ち時間を監視しながら、必要に応じて調整していく予定です。

ベトナムメンバーへの引き継ぎ

ベトナム側には Python を書いたことがないメンバーも多いです。将来改修が必要になったときに誰もメンテナンスできないという事態を避けるため、AI エージェントも効率的に働ける環境を意識しながら、開発を助けるリソースを整備しました。

  • テストケースの整備、テスト手順のドキュメント化
  • ドキュメントやコードコメントの英訳
  • ローカル実行用の Docker Compose ファイル
  • 動作検証用の不要なコードを削除
  • ステージング環境の作成
  • GitHub Actions を使った CD の整備

Faber Company はインターン生の成果もドンドン本番環境に反映されていく面白い会社です。

今いるメンバーも、これから参加するメンバーも、安心して初めてのことにチャレンジできる環境を整えていきたいと思います。