![見出し画像](https://assets.st-note.com/production/uploads/images/97659385/rectangle_large_type_2_b8618b759d7f44f8c0e9398b8c4cdc12.png?width=1200)
Poetry の Dependency Group を利用したモノレポ Python 環境
こんにちは、カウシェで ML Engineer をしている白川です。
以前に一人目 ML Engineer としての入社エントリーを書かせていただいたのですが、実はアプリケーションで Python を使用する開発者としても一人目だったので、機械学習系の機能開発をしつつ Python 環境の設計・構築も行っています。開拓するのは楽しいです。
今回、Poetry の dependency group 機能を使うことでモノレポにおいて軽量でクリーンに動作する、なかなかよい開発環境を作ることができたので、思い切って公開してみます!
なお、本格的な運用はこれから始めるところなので、気持ちのこもった速報版くらいの気持ちで受け取っていただけるとありがたいです。
Poetry の dependency group 便利ですよ!
モノレポ
カウシェは基本的にはモノレポで開発をしています。そのため、何か開発する用事(プロジェクト/アプリケーション、以下プロジェクトで統一します)があると、単一のリポジトリ内に専用のディレクトリを掘り、そこに開発物を配置することになります。プロジェクトごとの独立性は低くなりますが、リポジトリ全体でのガバナンスが利かせやすかったり、ツールやライブラリを共有しやすかったり、複数のアプリケーション間でリソースを共有しやすかったりなどの複数のメリットがあります。
これまではバックエンドはほぼ Go のみで開発していたのでそれほど困りはしなかったのですが、今回機械学習系の機能(もうちょっとしたら公開できると思います!)を開発するにあたり、Python を導入することになりました。
ただ、カウシェは創業してからまだ 3 年に満たないスタートアップ企業なので、Bazel や Buck、Pants のような本格的な言語横断的なモノレポ志向なビルドツールを入れる体力も余裕もありません。そのため、Go と共存しながらもクリーンで快適に使える Python モノレポ体験が提供できる仕組みが必要となり、今回その設計をしてみました。
The One Version Rule
カウシェの開発において特徴的なのが、The One Version Rule を志向していることです。
The One Version Rule というのは平たく言うと、「すべてのアプリケーションで可能な限り同一の環境を使おう」というようなルールで、Python の場合で言えば、
Python 自体のバージョン
プロジェクトが依存するパッケージのバージョン
あたりが可能な限りいつでも共通であるようにしようという思想です。将来的にコードの脆弱性に本気で向き合わないといけなくなったときはなるべく依存性をミニマムにしておきたいというようなセキュリティ上の理由もありますが、標準的な環境を定めることにより、組織として認知しメンテナンスしないといけないプラクティスを最小限にできるメリットもあります。
一方、厳格な The One Version Rule を Python 環境において実現するのは下記のような理由から困難だと考えています。
Python では、パッケージの依存性のコンフリクトがしばしば発生する(Python 環境はプロジェクトごとに作っては捨てるものだと思っています)
Python は動的な言語で Go のように依存ライブラリの必要な部分だけ取捨選択するような賢い静的コンパイルができないため、万一不要なパッケージをインストールしてしまうと環境が肥大化する(コンテナを焼いたりするときに問題になります)
そのため、The One Version Rule を厳格には守れないながらも、Python のバージョンやパッケージのバージョンはなるべく揃えつつ、プロジェクトごとに必要なパッケージの取捨選択ができる…というような環境を作りたいと考えました。
Poetry の dependency group 機能
ここで朗報です。2022 年 8 月に Poetry に dependency group というパッケージの取捨選択をサポートするような機能が導入されました。
dependency group はパッケージをグループ単位で管理できる機能です。例えば下記のような pyproject.toml があるとします。
...
[tool.poetry.dependencies]
python = "3.8.16"
[tool.poetry.group.core]
optional = true
[tool.poetry.group.core.dependencies]
python-json-logger = "^2.0.4"
python-dotenv = "^0.21.1"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
pytest = "^7.2.1"
pytest-cov = "^4.0.0"
pytest-mock = "^3.10.0"
[tool.poetry.group.lint]
optional = true
[tool.poetry.group.lint.dependencies]
pysen = "^0.10.3"
このとき、
$ poetry install --with core,test
とすると、core, test に記載されたパッケージを選択的にインストールした Python 3.8.16 環境を構築することができるようになります。 さらに --sync をつけることで、--with で記載されたパッケージのみをインストールしたクリーンな環境を再現することができるようになります(このとき optional 指定しなかったグループも同時にインストールされます)。
$ poetry install --sync --with core,test
--without というオプションもあり、これを使えば特定のグループのパッケージを選択的に排除することもできます。
$ poetry install --sync --with core,test --without foo,bar,piyo
この仕組みによって、リポジトリのルートにリポジトリグローバルな pyproject.toml をおき、そこで適切にパッケージをグルーピングしておけば、プロジェクトごとにグループを取捨選択することにより、ある程度のガバナンスを利かせつつもプロジェクトごとに固有の環境を構築しやすくなります。
パッケージのグループの3分類
グループをどう作るのかが迷うと思ったため、パッケージのグループを下記の 3 種に分類することにしました。
core グループ群
開発において標準的に使って欲しい軽量なパッケージを記載したグループ。このグループに記載されたパッケージは開発において不要であってもインストールしておいても良いものとする。標準で使用するロギング用のパッケージなどを対象としています。
機能別グループ群
その機能を使いたい場合に標準的にインストールして欲しい一連のパッケージが記載されたグループ。pytest のようなテストツールや pysen などの linter、kfp や LightGBM などの特定用途で使われる(しばしば重たいが)有用なパッケージ等を対象としています。パッケージの依存関係の制約がセンシティブなパッケージなどもあるので、グループ単位で一括に管理し、安定動作するグループを提供できるようにするつもりです。
プロジェクト別グループ群
最終手段としてプロジェクト固有に独立してパッケージを管理したい場合に使うグループです。よほどのことがなければ使わないですが、逃げ道として用意してあります。なお、Pythonのバージョンすら変えないといけない場合は、個別のプロジェクト別のディレクトリに Poetry 環境を作って良いことにもしています。
パッケージの取捨選択をわかりやすくするため、core グループ群を含むいずれのグループも optional で作成することにしています。
プロジェクトごとの依存グループ管理
プロジェクトごとに依存するグループを管理する標準的な方法が見つからなかったので、プロジェクトごとに dependencies.txt という名前で依存するグループを記載したファイルを作ることにしました。これは下記のように、
$ poetry install --sync
に後続するフラグをそのまま記述したファイルです。
--with core,test,lint
dependencies.txtファイルを用意しておくことにより、下記のようにプロジェクトに必要な環境を再現できるようになります。
$ poetry install --sync $(cat dependencies.txt)
dependencies.txt など作らずに Makefile で install できるようにすれば良いのではという議論も出たのですが、dependencies.txt があれば Makefile は必要であれば後から簡単に作れるし、dependencies.txt はただのオプションの羅列なので
$ poetry install --sync $(cat dependencies1.txt) $(cat dependencies2.txt) ...
みたいなこともできて便利なときもあるかもしれないので、一旦は dependencies.txt で運用していくことを考えています。
ただし、dependencies.txt は思いつきで作った方法で公式サポートされた標準的な方法ではないので、個人的には、グループの入れ子などが将来的に定義できるようになって、
[tool.poetry.group.my_project]
optional = true
[tool.poetry.group.my_project.dependent_groups]
core
test
lint
のように書けると pyproject.toml に集約できるので嬉しいです。
Poetry 的には想定外の用途なのかも知れませんが、モノレポで開発するときには便利だと思うので、 Poetry に Issue も出してみました。
poetry.lock の扱い
ただ、これだけだと poetry.lock の扱いに困ります。poetry.lock にはpyproject.toml に記載された内容に従って、実際にパッケージのバージョンがどう解決されたのかが記載されており、環境の再現性を確保するためにプロジェクトごとにリポジトリにコミットしておきたいです。
しかしpoetry.lock は pyproject.toml と同じディレクトリに生成されてしまうので、今回の場合であればリポジトリのルートに生成されることになります。これではプロジェクトごとに固有に環境を構築・再現するのが難しくなります。
そこで、プロジェクトのルートディレクトリにリポジトリのルートにある pyproject.toml のシンボリックリンクを作成するようにしました。
$ ln -s {リポジトリのルート}/pyproject.toml {プロジェクトのルート}
これにより、プロジェクトのルート配下で poetry install や poetry lock を行った場合でも、プロジェクトのルートに poetry.lock が作成されるようになります。
最終的に出来上がったもの
こんな感じになりました。リポジトリグローバルな pyproject.toml を共通で使用しつつ、プロジェクト単位で自由に Poetry による仮想環境が作れます。
リポジトリ単位でやること:
リポジトリのトップの poetry.lock を .gitignore に追加して、コミットされないようにしておく。
pyproject.toml に必要なグループをすべて optional で追加する。
pysen などの開発用ツールの設定も pyproject.toml に記載する。
プロジェクト単位でやること
プロジェクトのディレクトリを掘り、そこにリポジトリのトップの pyproject.toml へのシンボリックリンクを同名で作る。
プロジェクトで必要なパッケージで pyproject.toml に追加が必要なものはグループを作りつつ追加する。このときグループを追加した場合は 必ず optional で作る。
プロジェクトで使用するグループを記載した dependencies.txt ファイルを作る。
poetry install --sync $(cat dependencies.txt) で環境構築をする。
プロジェクトのトップディレクトリにできた poetry.lock をコミットする。
いつもどおり開発をする!
まとめ
どうでしょうか。Poetry の dependency group 機能を使うことで、モノレポでもある程度のガバナンスを聞かせながらプロジェクトごとの環境を構築できる仕組みがうまく作れた気がします。
「Python でも The One Version Rule やりたいんですよね〜」と相談され、「無理でしょ…」と思いつつ議論を重ねつつ検討をした結果、たまたま見つけた dependency group を使うとうまくデザインできそうなことに気づいて作ってみたものなのですが、個人的にはピースがピタッとハマった感じがして、結構満足感があります。
これで Python での開発もかなりスムーズに進められるはずなので、Python 使いの Data Engineer や ML Engineer の方がいらっしゃればぜひご一緒しましょう!やりたいことだらけで自分の手には余る状況です。一緒に新しいソーシャルショッピング体験を開拓して世界を変えてやりましょう!!
本記事やカウシェ(もしくは私?)にご興味持っていただけた方は Twitter @s_tat1204 もしくは YOUTRUST などでお気軽にお声がけください。
お待ちしています!!!