LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

This post is also available in the following languages. Korean

Poetryを利用したマルチプロジェクトPythonアプリケーションの開発方法

はじめに

こんにちは。LINE PlusのLINE GAME PLATFORM Dev2チームのHyeonseop Lee、Hyungjung Leeです。LINE GAME PLATFORMでは、ゲーム開発に必要なさまざまなプラットフォームサービスを開発しています。私たちのチームはその中でも、ゲームデータを収集して分析するためのプロダクトやMLOps、LLMOpsを開発しています。この記事では、LLMOpsを開発するためにPythonベースの大規模なプロジェクトを進める中で経験した、プロジェクトの規模が大きくなるにつれて発生する構造的な問題とそれを解決した方法を紹介します。

Pythonの普及とともに巨大化したPythonプロジェクト

Pythonは簡潔で柔軟というメリットから、長い間使われているスクリプト言語です。初期には主に簡単な自動化やプロトタイプの開発に使用されていました。ところがここ数年、データサイエンスと人工知能(AI)分野が急速に発展するにつれ、Pythonの役割も大きく変化しています。最近では、単純なスクリプト言語を超え、大規模なプロジェクトの主要言語として使用されています。

これは、データ分析や機械学習、ディープラーニングなどさまざまなAIフレームワークがPythonを中心に開発されており、強力なライブラリと活発なコミュニティの支援を受けたおかげです。pandasやNumPy、TensorFlow、PyTorchなどの強力なライブラリやツールなどがPythonエコシステム内で進化し、PythonはデータサイエンスとAIプロジェクトの必須言語として定着しました。

それに伴い、前述のライブラリやツールを使用するPythonアプリケーションも、ますます複雑かつ多様なビジネスロジックで実装されています。このようにビジネスロジックが複雑になり、その規模が大きくなると、必然的にプロジェクトも複雑になり、その規模も大きくなります。単一プロジェクトの規模が大きくなったり、外部ライブラリまたは内部で開発したライブラリを参照したりすることで、プロダクトが肥大化してしまいます。

巨大化したPythonプロジェクトの問題

私たちもPythonベースのLLM Opsを開発しながら、私たちが従来管理していたプロジェクトより大きな規模のPythonプロジェクトを開発することになりました。しかし、LLM Opsはデータやプロンプト、モデル、デプロイメントなどさまざまな要素で構成されているため、開発やメンテナンスを考慮し、巨大な単一プロジェクトではなく、マルチプロジェクトにして進める必要があります。そのため、マルチプロジェクトで各コンポーネントを構成しようとするとしましたが、プロジェクトの構造や共通機能の共有、一緒に使うライブラリのバージョン管理問題などを考えるようになりました。

プロジェクトのコードや依存関係管理に関する問題はPythonだけでなく、C++やJava、Kotlinなどの他の言語でも発生します。しかし、それらの言語については、すでに試行錯誤を経てエレガントな解決方法を見つけました。例えば、プロジェクト管理の場合、JVMのビルドツールであるGradleがその例になります。Gradleはプロジェクトの宣言を明示的に行い、ライブラリの依存関係を明確に表現します。また、アプリケーションのパッケージングも簡単にできる関連機能を提供しています。

私たちの組織はGradleに慣れた状態でPython開発を始めたので、前述の問題をGradleと同様の方法で解決したいと思いました。そこで、Pythonエコシステムにも同様の機能を提供するツールはないか調査した結果、Poetryを発見したのでそれを使用することにしました。

また、プロジェクトの規模が大きくなると、特定の機能をカプセル化して使うことになりますが、その際、再利用に必要な設定やオブジェクト作成などを主に依存性注入(dependency injection、以下DI)を利用して解決します。DIはよく実装されたライブラリとそのオブジェクトを簡単に利用するために使うもので、Java環境ではSpringフレームワークを使ってとても簡単に使用できます。私たちのプロジェクトも共通機能が多く、DIを利用する必要があり、Pythonでそれを簡単に使用するためにDependency Injectorを使いました。
Dependency Injectorは、DIをSpringフレームワークとほぼ似た形で使えるように提供しているため、開発過程でプロジェクトごとに依存関係を分離し、開発をより簡単で楽にするのに大いに役立ちました。

次は、プロジェクトが巨大化した場合に発生しうる問題を解決するために導入したPoetryとDependency Injectorについてより詳しく説明し、どのような方法で使用したのかを紹介したいと思います。その前に理解を助けるために、Pythonでよく使われる仮想環境であるvenvcondaを簡単に紹介します。

Pythonプロジェクトにおける仮想環境の使用

通常、Pythonプロジェクトを進める場合、Pythonのための仮想環境を作成します。Pythonのパッケージ管理方法はインストールされたパッケージに強く依存するので、それをプロジェクトごとに分けて管理するためです。主に使うのはvenvとcondaです。

venvは最も基本的な仮想環境機能を提供し、Python標準ライブラリに含まれているため、追加インストールが必要ありません。速度は速いですが、依存関係の問題は解決されず、私たちに必要なプロジェクト構造の管理機能も提供されません。

condaの場合、Anacondaディストリビューションの一部として提供されます。Pythonだけでなく、他の言語のパッケージも管理でき、venvのデメリットであるパッケージ間の依存関係の問題を解決する機能もあります。しかし、condaもプロジェクト構造の管理機能はサポートしません。

そこで、私たちはプロジェクト構造の管理機能をサポートするツールを探すリサーチを行い、依存関係の管理とプロジェクト管理機能がすべて含まれているPoetryを使うことにしました。

Poetryの紹介

PoetryはPython環境における依存関係の管理やパッケージングに使うツールで、以下のようにさまざまなメリットがあります。

  • パッケージ管理:pyproject.tomlファイルによってパッケージ情報(作成者、バージョン、パッケージ名、ビルド情報、依存関係など)を管理できる
  • 相対パスを使った依存関係管理:pipリポジトリを利用したパッケージのインストールだけでなく、相対パスを利用したパッケージのインストールにも対応
  • 一貫性のある実行環境の管理:poetry.lockファイルを利用したバージョン管理機能を提供し、どこからでも同じ環境で実行できる
  • 柔軟なバージョン管理:セマンティックバージョニング(semantic versioning)をサポートするため、バージョン範囲を柔軟に指定して、互換性のあるさまざまなバージョンを設定・管理できる
  • 簡単なパッケージングとデプロイメント

上記のメリットのうち、マルチプロジェクトで重要な依存関係の管理についてさらに詳しく説明します。

依存関係の管理

Poetryで依存関係を管理する方法は非常に直感的です。以下の3つのファイルを使って依存関係と実行環境を管理します。

pyproject.tomlとpoetry.lock

JavaScript開発者にとっては慣れている仕組みかもしれません。JavaScriptではpackage.jsonファイルで依存パッケージのバージョンを管理し、package-lock.jsonファイルで一貫性のある実行環境を提供します。Poetryではpyproject.tomlファイルとpoetry.lockファイルを使います。

まず、基本的にプロジェクトのルートにあるpyproject.tomlファイルを使ってパッケージを管理します。そのファイルを使ってパッケージの基本的な説明と依存関係を管理し、ビルドシステムを設定できます。poetry.lockファイルは一貫性のある実行環境を提供するために使います。

以下はpyproject.tomlファイルの例です。

[tool.poetry]
name = "poetry-demo"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
readme = "README.md"
packages = [{include = "poetry_demo"}]

[tool.poetry.dependencies]
python = "^3.7"

[tool.poetry.group.dev.dependencies]
black = "^23.12.1"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

pyproject.tomlは複数のセクションで構成され、各セクションの役割は以下のとおりです。

セクション説明
tool.poetryパッケージ情報
tool.poetry.dependencies依存関係リスト
tool.poetry.group.dev.dependencies開発の依存関係リスト
build-systemビルドするためのシステム設定

Poetryでは依存関係を管理するさまざまな方法を提供していますが、その中でマルチプロジェクトを構築して管理するために使った2つの方法を紹介します。

依存関係をグループごとに管理

Poetryでは依存関係をグループに分けて管理する方法を提供します(参照)。それぞれの使用目的に応じて依存関係を組み合わせ、グループを構成した後、特定のグループに対する依存関係のみをインストールまたは除外できます。これを利用すると、各プロジェクトをグループで追加し、ルートで目的のグループの依存関係のみをインストールする方法で依存関係を分離できます。

pyproject.tomlファイルに tool.poetry.group.<group> 設定を追加し、セクションを分けてグループを追加できます。

[tool.poetry.group.service1.dependencies]
black = "^23.12.1"

[tool.poetry.group.service1]
optional = true

上のように分けたグループセクションでは、以下のようなコマンドを実行できます。

# testグループにpytestをインストール
poetry add pytest --group test

# testグループの依存関係のみをインストール
poetry install --with test

# testグループの依存関係を除外してインストール
poetry install --without test

このようにマルチプロジェクトを開発する場合、依存関係グループの特性を活用して複数のパッケージを1つのリポジトリで管理できます。それについては、以下のマルチプロジェクトの適用セクションでより詳しく説明します。

内部ライブラリの管理

Poetryのもう一つの特徴としては、内部パッケージに対する依存関係も相対パスで指定できるということがあります(参照)。Poetryではpyproject.tomlを基準にパッケージディレクトリのルートが指定され、そのルートを基準にした相対パスでパッケージを見つけるように設計されています。ライブラリパッケージをメインプロジェクトのルートディレクトリに置いて、それを基準に各サービスパッケージの目的のライブラリパッケージを相対パスで参照して使用できます。

[tool.poetry.group.groupName.dependencies]
library1 = { path = "../../libraries/library1", develop = true }

私たちはこの2つの機能を使ってマルチプロジェクトを構築しました。では、例と一緒にマルチプロジェクトを構築する方法について説明します。

Poetryを利用したマルチプロジェクトの構築

前述のとおり、Poetryを利用して一つのプロジェクトを簡単かつ直感的に管理できるようになりました。では、それを利用して複数のプロジェクトを簡単かつ直感的に管理するにはどうしたらいいでしょうか?

私たちはマルチプロジェクトを構成するため、まず、以下のように全体を大きく2つのディレクトリに分けました。

  • libraries:共通で使用するライブラリ
  • services:1つのコンポーネントで提供するサービス

ディレクトリを分離した後、各ディレクトリのサブパッケージをまた独立して分離し、依存関係を分けました。

上のように構造を分けましたが、サービスごとに個別に依存パッケージをインストールし、独立して実行するにはどうしたらいいでしょうか? 独立して実行できる環境を構築する場合、前述のPoetryの依存関係グループ機能を使います。依存関係グループを使って各サービスを独立して実行するためには、まず、ルートパスにpyproject.tomlファイルを作成して関連設定を行う必要があります。

ルートのpyproject.tomlファイルを作成

では、ルートに位置するpyproject.tomlファイルを作成してみましょう。設定する内容を図で表現すると以下のとおりです。

サービスごとにグループを追加し、各グループはそのサービスの依存関係のみを持つように設定します。以下は、上の図のように設定したpyproject.tomlです。

[tool.poetry]
name = "sample-multi-project"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.10"


[tool.poetry.group.library1.dependencies]
library1 = { path = "libraries/library1", develop = true }

[tool.poetry.group.library1]
optional = true

[tool.poetry.group.library2.dependencies]
library2 = { path = "libraries/library2", develop = true }

[tool.poetry.group.library2]
optional = true

[tool.poetry.group.library3.dependencies]
library3 = { path = "libraries/library3", develop = true }

[tool.poetry.group.library3]
optional = true

[tool.poetry.group.service1.dependencies]
service1 = { path = "services/service1", develop = true }

[tool.poetry.group.service1]
optional = true

[tool.poetry.group.service2.dependencies]
service2 = { path = "services/service2", develop = true }

[tool.poetry.group.service2]
optional = true

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

うん? 何かおかしくありませんか? なぜ各サービスの詳細パッケージの依存関係は、グループに追加しないのでしょうか? その理由は、マルチプロジェクトを構成する場合、管理しやすくするために仮想環境を統合して使うように設定しますが、ここでルートパスのpyproject.tomlファイルはその統合仮想環境の管理のみを担当するためです。各プロジェクトの詳細な依存関係は、各パッケージの独立性を確保しながらルートの役割を最小限に抑えるために、各パッケージのpyproject.tomlファイルで管理します。

また、すべてのグループは基本パッケージに含まれないようにoptionalで設定します。これは、ルートのグループ依存パッケージをインストールするとき、他のグループの依存パッケージが一緒にインストールされてしまうのを避けるためです。このように設定することで、ルートを通して特定のグループの依存関係のみをインストールするように命令し、1つの仮想環境で複数のプロジェクトの依存関係を管理して実行できます。

仮想環境の設定

デフォルトでは、Poetryは~/Library/Caches/pypoetry/virtualenvsのパスで仮想環境を管理します。そのパスに、各パッケージの名前に合わせて仮想環境が作成され、パッケージごとに違う名前の仮想環境が作成されるため、完全に分離された環境で依存関係が管理されます。

仮想環境にどのような依存関係が含まれているか確認したい場合は、上のパスで確認できますが、より簡単に確認するため、以下のように~/Library/Caches/pypoetry/virtualenvsの代わりにローカルパスに仮想環境が作成されるように設定しました。

[virtualenvs]
in-project = true

Poetryのさまざまな設定は、poetry.tomlファイルを利用して設定できます。より詳しくは、Poetryの公式ドキュメントを参照してください。

各サービスのpyproject.tomlを作成

ルートでサービスごとに仮想環境を選択する準備が終わったら、次は各サービスで使う依存関係を定義します。各サービスのpyproject.tomlでは、各サービスで使用するパッケージの詳細バージョンなどの依存関係を管理します。

以下はservice1のpyproject.tomlの例です。

[tool.poetry]
name = "service1"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]

[tool.poetry.dependencies]
python = "^3.10"
library1 = {path = "../../libraries/library1", develop = true}
 
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

service1パッケージではlibrary1の依存関係を持つように構成しました。

[tool.poetry]
name = "service2"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]

[tool.poetry.dependencies]
python = "^3.10"

library2 = {path = "../../libraries/library2", develop = true}
library3 = {path = "../../libraries/library3", develop = true}

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

service2パッケージはlibrary2とlibrary3の依存関係を持つように構成しました。

仮想環境のインストールと実行

サービス単位でグループを複数追加したら、グループごとに独立して依存パッケージをインストールする準備ができました。特定のグループのみをインストールしたい場合は、以下のようにコマンドを実行します。

poetry install --sync --with service1
  • --sync:仮想環境にインストールされている依存パッケージを現在のコマンドを基準として同期します(過去にインストールされた依存パッケージのうち不要になったものは削除します)。
  • --with:特定のグループ(例:service1)を基本インストールオプションと一緒にインストールします。基本インストールオプションがない場合、指定された特定のグループのみをインストールします。

ちなみに、各グループが独立してインストールされるようにしたい場合は、以下のようにoptional = trueに設定する必要があります。デフォルトではインストールが必須となっています。

[tool.poetry.group.service1] 
optional = true

上で説明したルートパッケージを図式化すると以下のとおりです。service1をインストールするとlibrary1が一緒にインストールされ、service2をインストールするとlibrary2とlibrary3が一緒にインストールされます。

service1のインストール後、service2グループで依存パッケージをインストールすると、以下のように今までservice1と関係してインストールされた依存パッケージは削除され、service2の依存パッケージが新しくインストールされます。

実際に.venvパスにインストールされたパッケージリストを確認すると、以下のとおりです。

インストールが完了したら、次は実行することになりますが、実行する際に注意すべき点があります。ルートパスでPoetryを実行することになるため、以下のように一緒に実行するPythonファイルの相対パスを入力する必要があります。

poetry run python /path/to/your-package

DIの導入

Poetryを使って複数のプロジェクトを一箇所で管理する方法とデプロイの一貫性を確保したら、大規模プロジェクトの開発を始める準備が半分くらいできたと言えます。ここでもう一つ、コードレベルでの依存関係管理もサービスの長期的なメンテナンスのためには必要になります。

動的型付け言語であるPythonは非常に柔軟性が高く、時にはその柔軟性がデメリットと感じることもあります。Pythonは依存関係を持っているすべてのモジュールを参照でき、その性質のため、どのようにコードを書くかによって非常に強い結合(coupling)が発生することもあります。端的な例として、以下のようにグローバル変数を宣言して使用した場合、コードを変更するにはすべての変更点を見つけて変更しなければならないという不便さがあります。

# settings.py 
GLOBAL_CONFIG = {"feature_flag": True} 

# module_a.py from settings.py
import GLOBAL_CONFIG 
def feature_a(): 
  if GLOBAL_CONFIG["feature_flag"]: 
    print("Feature A enabled") 

# module_b.py from settings.py
import GLOBAL_CONFIG 
def toggle_feature(): 
  GLOBAL_CONFIG["feature_flag"] = not GLOBAL_CONFIG["feature_flag"]

この問題を解決するために、私たちはDIを導入することにしました。DIはサービス間の結合度を下げて凝集度を高めます。オブジェクトの作成と管理をコンテナに委任し、サービスはコンテナから必要なリソースを提供してもらい、それを使用します。

Dependency Injector

DIのための多くのパッケージのうち、以下のようなメリットを考慮してDependency Injectorを選択しました。

  • シンプルな用語:Container、Provider、Resource、Configurationなど理解しやすい用語の使用
  • コンテナ構造:リソースやサービスをコンテナ単位で管理
  • 統合性:FlaskやDjango、Boto3、FastAPIなど、さまざまなパッケージと統合可能
  • ユーザビリティ:クラスやメソッド、デコレータレベルでもDIを提供するため、簡単に適用可能

では、このようなメリットがあるDependency Injectorをどのように使うかを説明します。まず、以下のように依存パッケージをインストールします。

# pip install dependency-injector
poetry add dependency-injector

Dependency Injectorパッケージを構成するコンポーネントは大きく2つで構成されます。

  • ContainersProviderの集合で、役割によってProviderをまとめる単位になります。
  • ProvidersProviderは以下のようなものをすぐに使えるオブジェクト状態にして提供します。
  • Configuration:YAMLやJSON、OS環境変数などを使って設定値を管理するオブジェクト
  • Resource:リソースを管理するオブジェクト
  • Singleton:シングルトンオブジェクト
  • Factory:ファクトリオブジェクト

以下は1つのContainerを簡単に構成したサンプルコードです。

from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject


class Container(containers.DeclarativeContainer):

    config = providers.Configuration()

    api_client = providers.Singleton(
        ApiClient,
        api_key=config.api_key,
        timeout=config.timeout,
    )

    service = providers.Factory(
        Service,
        api_client=api_client,
    )


@inject
def main(service: Service = Provide[Container.service]) -> None:
    ...


if __name__ == "__main__":
    container = Container()
    container.config.api_key.from_env("API_KEY", required=True)
    container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
    container.wire(modules=[__name__])

    main()  # <-- dependency is injected automatically

    with container.api_client.override(mock.Mock()):
        main()  # <-- overridden dependency is injected automatically

Containerを使うと、上のように簡単にモジュール化でき、複数のProviderを宣言して使うことができます。

マルチプロジェクトでは、ライブラリとサービスの各パッケージを含むコンテナを構成し、サービスコンテナでライブラリコンテナを参照して使えるよう、以下のように構成します。

上のコードのようにservice2Containerlibrary2Containerを宣言して使います。Dependency Injectorのワイヤーリング(wiring)で @injectProvideを利用し、Containerのサービスを注入できます。より詳しいコードは、Dependency InjectorのDecoupled packages exampleにマルチパッケージを構成する例が記載されていますので、参照してください。

おわりに

この記事では、規模が大きいPythonプロジェクトを管理する上で発生するさまざまな問題と、その問題をPoetryやDependency Injectorを使って解決した事例を紹介しました。Python言語ベースのモノリポ、マルチプロジェクト環境を構築して大規模なビジネスロジックを実装する場合、特に、JavaやSpring環境に慣れている状態でPythonで作業する必要がある方の役に立つと思いますので、ご参考にしていただければ幸いです。長文でしたが、読んでいただきありがとうございました。

参考資料

Name:Hyungjung Lee

Description:LINE PlusのLINE GAME PLATFORM Dev 2チームで、LINE GAME PLATFORMが提供するさまざまなサービスの開発を担当しています。主にデータエンジニアリング、MLOps、LLM Opsとオープンチャット連携の開発に集中しています。

Name:Hyeonseop Lee

Description:LINE PlusのLINE GAME PLATFORM Dev 2チームで、LINE GAME PLATFORMが提供するさまざまなサービスを開発しています。主にデータエンジニアリングとBI Admin、LLM Opsの開発を担当しています。