ABEJA Tech Blog

中の人の興味のある情報を発信していきます

Pants で決める python monorepo

ABEJA で Research Engineer をやっている中川です.普段は論文読んだり,機械学習モデルを実装したり,インフラを構築したりしています.今回のブログでは3,4ヶ月の間遊び9割仕事1割で取り組んできた Python で実装された機械学習マイクロサービスたちの monorepo 化について紹介します.

モチベーション

小売業向けに店舗解析ソリューションを提供している ABEJA Insight for Retail では以下のような理由から機械学習システムをマイクロサービスの polyrepo (multi-repo) で運用してきました.

  • 様々なフレームワークで書かれた最新の研究成果を取り入れやすい.
  • 負荷特性の全く異なる機械学習モデルをスケールさせやすい.
  • モデルごとに容易にデプロイできる.
  • 障害耐性や保守性を高め日々の運用負荷を下げる.

手前味噌ですが,マイクロサービスのモチベーションについては以下の資料に詳しく書いてあります.

マイクロサービスを採用したことは,変化に強く運用に煩わされることなく,より重要なことに集中できるという意味で非常に有意義でした.一方で開発が進みメンバーも増えてくるに連れて polyrepo のツラみが出てきました.具体的には,

  • モジュール間のインターフェースが変わった時に複数レポジトリを修正するコストが高い.
  • 軽微なバグ修正や改善でも複数レポジトリを修正する必要があり心理的ハードルが上がり放置されがちになる.
  • デプロイの依存関係があり,慣れた人しかリリース作業ができない.
  • 各モジュールのどのバージョンで本番が動いているか一見すると分かりにくい.

そこで,最近ちょこちょこ聞くようになってきた monorepo を試してみようと思ったのがきっかけです.まず最初は遊びで monorepo 化を始め,やっていくうちにこれは便利だぞ!となり本番へもマージしていく形になりました.monorepo についての思想やメリット・デメリットはググれば様々な記事がヒットするので本記事では Python で具体的にどのようにして monorepo 化したのかについて記載していきます.なお,monorepo の思想やメリット・デメリットについては例えば以下の記事で最初にぼくは勉強しました.

www.graat.co.jp

Phase1: 各レポジトリを1レポジトリにまとめる

まず最初に,各レポジトリに散らばっているコードを1レポジトリにまとめ,monorepo のデイレクトリ構成を以下のようにしました.

.
├── 3rdparty
│   └── python
├── src
│   ├── __init__.py
│   ├── face_alignment
│   ├── face_detection
...
│   ├── interfaces
...
└── test
    ├── __init__.py
    ├── conftest.py
    ├── face_alignment
    ├── face_detection
...

src 配下に各レポジトリのソースコードをまとめ,test 配下に各レポジトリのテストコードをまとめています.各レポジトリごとにディレクトリを掘り,その配下にsrctestを配置する方法も検討したのですが,各モジュールはお互いに独立したサービスというよりも,連携し合って1つのサービスを作り上げているのでルートディレクトリでsrctestを分けるような構成を採用しました.また,それぞれが依存する外部モジュールについては,3rdparty/pythonというディレクトリを掘り,その下にまとめて管理するようにしました.ちなみに,pythonというディレクトリで1階層深くなっているのは後述するビルドツールの仕様です.こうすることにより,各種モジュールが依存している外部モジュールを一元管理することができ,例えばセキュリティパッチが出た際などに全モジュールへの一斉適用を楽に実施できるようにしました.

さらにinterfacesというディレクトリに各モデルのインターフェースを記述した Python ファイルを格納しています.これは polyrepo では出来ない工夫点で,各モデルのインターフェースを一元管理できるようになりましたし,各モデルが他のモデルを呼び出す際にinterfacesだけに依存すればよく,モジュール間の依存関係も薄くすることが出来ました.

ここまでで polyrepo だったマイクロサービスを monorepo にまとめることができました.一方で,monorepo についてありていな課題ですが,テストに時間がかかりすぎるという課題が出てきました.特に機械学習のモデルが内部で実行されているため全テストを実行すると30分以上かかってしまいます.このままだとテスト実行時間が長すぎて開発が鈍化してしまうため Phase2 の工夫に取り掛かりました.

Phase2: 差分テスト

monorepo にしたからといって毎回全てのテストを実施する必要はありません.差分があったモジュールだけテストすればよいはずです.そして,monorepo のエコシステムも整いつつあり,そういった機能をサポートするビルドツールがいくつかあります.そこでまずビルドツールの選定から行いました.

ビルドツール選定

2020年5月時点ではデファクトといったようなツールはなく,テックジャイアントたちが各社OSSとして公開しているような状態でした.例えば,

この中で Python をネイティブにサポートしているのは Pants だけであったため,こちらを採用することに決めました.また,Pants はちょうど v1 から v2 へ大幅に改善されている最中で,各種言語を扱えるプラットフォームとしての可能性は残しつつ,まずは Python で monorepo を実現することに集中しているような状況でした.こういった開発に好感が持てたことも採用した一つの理由です.

Pants を導入したことで,以下のようなことが簡単に実施できるようになりました.

  • 依存関係やテスト結果をキャッシュし,変更のあったコードだけを実施する.
  • テストごとに仮想環境を作り独立性を高めることにより Works on My Machine 状態を避ける.
  • テストの並列実行によってテスト実行時間を短縮する.

次は上記の恩恵を得るために具体的に追加した実装について紹介します.

Pants による実装

Pants がモジュール間の依存関係を正しく解釈するために,ユーザは Pants にモジュール間の依存関係を伝えてあげる必要があります.そこで以下のようなBUILDファイルを各モジュールのディレクトリ直下に置くことで Pants がモジュール間の依存関係を解釈できるようにしました.

python_library(
  dependencies=[
    '3rdparty/python:numpy',
    '3rdparty/python:pillow',
    '3rdparty/python:torch',
    '3rdparty/python:torchvision',
    'src/face_alignment',
    'src/interfaces',
    'src/model_client',
    ':models'
  ]
)

例えば,3rdparty/python:numpyは上記の3rdparty/pythonで一元管理された numpy に依存していることを示しています.そして,src/face_alignmentは独自実装されたface_alignmentというモジュールに依存していることを表しています.こうすることで,例えばface_alignmentに更新があった際に上記モジュールもテストする必要があることを Pants に知らせることができます.ちなみに,BUILDファイルに依存関係を書かずにimport 文から依存関係を推論する方法もあるようですが,まだまだ不安定な部分もあるようなので今回は使用していません.

ローカルでテストを実施する分にはこれだけで十分なのですが,チームで CI を回そうとするともうひと工夫必要でした.例えば,feature ブランチで機能追加を実装している時,develop ブランチとの差分だけテストしたいというようなことが発生しました.Pants にはそういったケースをサポートする便利機能があります.Pants は git と連携して差分を検出する機能が実装されており,--changed-sinceというパラメータを指定してあげることで特定のブランチやコミットからの差分だけをテストすることが可能です.さらに--changed-include-dependees=transitiveと指定してあげることで依存関係のグラフを解決し差分のあったモジュールとそれに依存するモジュールを再帰的に検出し,それらのテストだけを実施してくれます.これにより,自分の変更が全体を破壊することなく確かに正しく動作することを保証してくれるので非常に開発が楽になりました.具体的な CircleCI の組み込みとしては以下のような実装を取っています.

# Choose base commit from where the diffs are tested.
case << parameters.stage >> in
  "feat") export BASE_COMMIT=origin/develop ;;
  "stg") export BASE_COMMIT=origin/master ;;
  *) export BASE_COMMIT=`git rev-parse HEAD~` ;;
esac
# Test.
./pants --no-dynamic-ui \
  --changed-since=$BASE_COMMIT \
  --changed-include-dependees=transitive \
  test

Pants を導入することで差分テストが実現され大幅に開発を効率化することができました.また,差分に応じた適切な CI も実現されコードの品質も担保することができるようになりました.最後に取り掛かるのは差分デプロイについてです.こちらもテスト同様差分だけデプロイしたいというモチベーションを叶えるために Phase3 の工夫に入っていきます.

Phase3: 差分デプロイ

差分デプロイができることで,デプロイ容易性や運用・保守性などのマイクロサービスの利点を失うことなく monorepo を使うことができます.しかし,ABEJA Insight for Retail は ABEJA Platform 上にデプロイしているのですが,当然 Pants は ABEJA Platform をサポートしていません.そこで,Pants のプラグイン機構を用いて ABEJA Platform にデプロイするplatform-deployを実装する必要があります.

プラグインの概要

Pants におけるプラグイン開発は以下のドキュメントを読めば雰囲気わかります.

www.pantsbuild.org

ただし,実際に実装していこうと思うと書ききれていない内容が多く,Python と Rust で書かれた Pants の実装を読み解く必要がありました.そこで学んだ Pants のプラグインの概要を Python wheel package を作るsetup-py2のコマンドを例に紹介します.

Pants のプラグインはベーシックには Python で定義された関数を Rust がいい感じに解釈して関数の呼び出しグラフを作成し,ユーザが指定したインプットのもとゴールを達成できるようにグラフを巡って各種関数を呼んでいく方式で実装されています.そのためにはまずユーザが指定するインプットとなる情報が必要です.Pants のインプットはBUILDファイルに集約されており,以下のようにpython_libraryというターゲットを指定する必要があります.

python_library(
  dependencies=[
    '3rdparty/python:numpy'
  ],  
  provides = setup_py(
    name='utils.json',
    version='0.0.1',
    description='JSON utilities for video analysis.'
  )
)

この一つ一つのdependenciesprovidesといったフィールドが呼び出しグラフのインプットとなります.

次に,上記のインプットをもとにゴールを達成するためのエントリーポイントが必要です.エントリーポイントとなる関数は以下のように@goal_ruleとアノテートされている必要があります.

@goal_rule
async def run_setup_pys(
    targets_with_origins: TargetsWithOrigins,
    options: SetupPyOptions,
    console: Console,
    python_setup: PythonSetup,
    distdir: DistDir,
    workspace: Workspace,
    union_membership: UnionMembership,
) -> SetupPy:

ここで,ユーザがどのようにこのエントリーポイントを呼び出すかと言えば,以下のようなクラスを定義し,関数の引数に指定してあげることで,nameに指定された名前でコマンドラインから呼び出せます.

class SetupPyOptions(GoalSubsystem):
    """Run setup.py commands."""

    name = "setup-py2"

このゴールがユーザによって指定されると,TargetsWithOriginsが関数の引数に設定されているため,これを事前に取得する必要があり,以下のTargetsWithOriginsを戻り値に持つ関数が呼び出しグラフに暗黙的に追加されます.

@rule
async def resolve_targets_with_origins(
    addresses_with_origins: AddressesWithOrigins,
) -> TargetsWithOrigins:

このように暗黙的に呼び出しグラフに追加されるものもあれば,以下のように

setup_py_results = await MultiGet(
    Get[RunSetupPyResult](RunSetupPyRequest(exported_target, chroot, tuple(args)))
    for exported_target, chroot in zip(exported_targets, chroots)
)

RunSetupPyRequestを引数の一部に持ち,RunSetupPyResultを戻り値に持つ関数を明示的に呼び出しグラフに追加する方法もあります.なお,ここでMultiGetという構文がありますが,これにより Rust が非同期実行してよいと解釈しMultiGet内のGetを並列に実行することを意味しています.

以上のような形でユーザが指定したゴールを達成するための呼び出しグラフが作成できました.実行時には,それぞれの関数のキャッシュの存在を確かめつつ,いい感じに Pants が依存関係を解消しつつ実行してくれます.ここまでで雰囲気プラグインの使い方がわかったので実際にplatform-deployの実装について紹介していきます.

platform-deploy の実装

Pants のプラグインも以下のように monorepo に含める形で実装しています.例えば OSS として公開することを考えると,別レポジトリに分けることもありえたのですが,デプロイ機構が熟れてくるまでは polyrepo の時と同様にレポジトリ間をまたいだ変更が発生する可能性が考えられたため一旦は monorepo に含める形にしました.今後,monorepo におけるデプロイまわりが熟れてきたら Pants のプラグインとして OSS 化するかも分かりません.

.
├── plugin
│   ├── lfs
│   └── model

ここで Pants にプラグインの存在を教えてあげる必要があるので,以下のように pants.toml に設定を追加しておきます.

backend_packages2 = [
  'model'
]

では,具体的な実装に入っていきます.まず,platform-deployという新しいコマンドの存在を Pants に教えてあげる必要があります.そのために以下のようにGoalSubsystemを継承したクラスを定義します.

class DeployPlatformOptions(GoalSubsystem):
    """Deploy model to ABEJA Platform."""

    name = 'deploy-platform'

    @classmethod
    def register_options(cls, register):
        super().register_options(register)
        register('--organization-id', type=str, help='Platform organization Identifier.')
        register('--user-id', type=str, help='Platform user Identifier (which starts with user-).')
        register('--personal-access-token', type=str, help='Platform user\'s personal access token.')

こうすることにより,platform-deployというコマンドが実行できるようになりました.

$ ./pants deploy-platform ::

また,register_optionsにて登録した文字列はコマンドライン引数として利用することができます.今回は CI からデプロイするケースなどを想定してクレデンシャル情報をコマンドライン引数から指定できるようにしました.

次に以下のようにBUILDファイルからデプロイ対象を定義できるようにする必要があります.

webapi(
  stage='dev',
  model=':face_feature',
  instance_type='cpu-1'
)

当然,webapiなんてターゲットは Pants に実装されているわけもなく,こちらも実装する必要があります.具体的にはまず以下のようにstagemodel,instance_typeを表すフィールドを定義してやります.今回は例としてinstance_typeを現す文字列型のフィールド定義を紹介します.

class PlatformModelWebApiInstanceType(StringField):
    """The web api instance type of ABEJA Platform model."""

    alias = 'instance_type'
    default = None
    value: Optional[str]
    valid_choices = ('cpu-0.25', 'cpu-1', 'cpu-2', 'cpu-4', 'gpu-1')

aliasにフィールド名を指定し,valueに Type Annotation をすることで Rust が解釈できるようにしています.またデフォルト値を持たせたい場合にはdefaultに指定します.今回は指定できるインスタンスタイプが決まっているので,valid_choicesでバリデーションを与えています.

次に,上記のフィールドを持つターゲットを定義する必要があります.本来はもっとたくさんのフィールドがありますが,簡単のため以下のターゲットを定義します.

class PlatformModel(Target):
    """A self-contained Python function suitable for deploying to ABEJA Platform as HTTP Service."""

    alias = 'platform_model'
    core_fields = (
        Dependencies,
        Tags,
        PlatformModelCode,
        PlatformModelWebApiInstanceType,
    )

aliasにターゲット名を指定しており,こちらの名前でBUILDファイルから利用できるようになります.ここで先ほどのBUIlDではwebapiという指定をしておりplatform_modelではないことに疑問を抱くかも分かりません.ここはマクロという機能を用いて以下のように変換しています.

def _platform_model(model_type, stage, **kwargs):
    # Add model to dependencies resolving dependency build graph.
    kwargs.setdefault('dependencies', []).append(kwargs['model'])
    # Set platform model type.
    kwargs['type'] = model_type
    # Make name field in the form of {stage}_{model_name}.
    name = stage
    if 'model_name' in kwargs:
        name += ('_' + kwargs['model_name'])
    kwargs['name'] = name
    # Set stage to tags and environment variables.
    kwargs.setdefault('tags', []).append(stage)
    kwargs.setdefault('environment', {})['STAGE'] = stage
    # Make new target.
    platform_model(**kwargs)


def webapi(**kwargs):
    _platform_model('webapi', **kwargs)

このような形を取っているのは ABEJA Platform では Web API や trigger など様々な方法でデプロイする手段が用意されており,共通する設定項目も多いのでplatform_modelといういわゆるベースクラスのようなものを用意することで共通設定と独自設定を実装しやすくしました.

ここまででBUILDファイルにデプロイ設定を定義しコマンドラインからplatform-deployを実行できるようになりました.次はplatform-deployの実体を実装する番です.上記のsetup-py2で見てきたようにまずは@goal_ruleを指定します.

@goal_rule
async def deploy_platform_goal(
    options: DeployPlatformOptions
) -> DeployPlatformGoal:

setup-py2と比較すると,かなり単純な関数の引数になっています.理由としては全てのターゲットを対象にするのではなく,例えばplatform_modelなど必要な項目をもったものだけを対象に実行したかったため,Pants が暗黙的に解決する対象ではなく,自分で明示的に解決してあげる必要があったからです.具体的には以下のように必要な項目を定義し,それらを持ったターゲットだけを集める実装をしました.

@union
@dataclass(frozen=True)
class PlatformModelFieldSet(FieldSet, metaclass=ABCMeta):
    """The fields necessary to deploy model from a target."""

    required_fields = (PlatformModelType, PlatformModelCode)

    # Common fields.
    name: PlatformModelName
    type: PlatformModelType
    model: PlatformModelCode
# Get valid targets.
targets_to_valid_field_sets = await Get[TargetsToValidFieldSets](
    TargetsToValidFieldSetsRequest(
        PlatformModelFieldSet,
        goal_description=f'the `{options.name}` goal',
        error_if_no_valid_targets=False
    )   
)

ツール選定の部分でも触れているのですが,Pants はあくまでマルチ言語のプラットフォームを目指しているため,@goal_ruleであるdeploy_platform_goalに定義できるのは言語間で同じ共通処理までです.なので,この関数の最後には以下のように各言語ごとの実際のデプロイ処理を実装した関数を呼び出しグラフに追加する処理で終わります.

# Call actual deployment model to ABEJA Platform for each languages.
for field_set, model in deploys:
    # Run in series because they have deploy dependencies.
    await Get[DeployPlatformResult](
        DeployPlatformRequest(args, field_set, model.target)
    )
return DeployPlatformGoal(exit_code=0)

各言語ではDeployPlatformRequestを引数に持ちDeployPlatformResultを戻り値に持つ関数を定義してやれば良いわけです.具体的には以下のような Python 特化のデプロイ処理を実装した関数を用意しました.

@rule
async def deploy_platform(
    req: DeployPlatformRequest,
    apex_setup: ApexSetup,
    python_setup: PythonSetup,
    subprocess_encoding_environment: SubprocessEncodingEnvironment,
    union_membership: UnionMembership
) -> DeployPlatformResult:

ApexSetupPythonSetupは暗黙的に呼び出しグラフに追加された関数によって得られます.この関数の中ではBUILDファイルに従い Python モジュール間の依存関係を整理しsetup.pyの準備などが実施されます.その後,具体的なデプロイ処理については,以下のようにapex.pexというデプロイ用の実際の処理を実装した Python スクリプトをプロセス起動してデプロイが実行されます.なお,apex は Abeja Platform for pEX の略です.また,PEX とは Python EXecutable の略で virtualenv のように Python の仮想実行環境を構築するツールです.

# Call apex deployment process.
process = apex_setup.requirements_pex.create_process(
    python_setup=python_setup,
    subprocess_encoding_environment=subprocess_encoding_environment,
    pex_path='./apex.pex',
    pex_args=map(str, args),
    input_digest=input_digest,
    description=f'Deploy {req.model.address.reference()} to ABEJA Platform with APEX.',
    env=env
)
await Get[ProcessResult](Process, process)

以上で Pants のプラグインを実装することができました.これにより,マイクロサービスのメリットを享受したまま monorepo でデプロイすることができるようになりました.プラグインまわりの全体感はこれである程度説明できたつもりですが,詳細な部分はかなり省いています.ぼく自身 Pants のプラグインを実装するのになかなか苦労したため,もし Pants でプラグインを実装してみたいけど具体的な方法がよく分からない,より詳しく知りたいという方はコメントください.もし,需要があったら Plugin 実装のチュートリアルも書こうかなと思います.

最後に

遊び9割で始めた monorepo 化も途中 Python と Rust のコードを読まないとプラグイン開発が進まないなどめちゃくちゃしんどかった時期もありましたが,無事本体にマージしてマイクロサービスのメリットを生かしたまま開発効率化が達成できたのはよかったです.実際にやってみると,まだまだ monorepo のエコシステムが未整備で,例えば Pants では週に一回のペースでプレリリース版が更新され,ドキュメントも日々更新されていっています.バージョンアップすると突然 Rust Panic でビルドがコケることもあったりと不安定感は否めません.だからこそ,仮に次に違うプロダクトを作るとして monorepo を採用するかは,その足回りを整える時間があるかを天秤にかけつつ判断するような気がします.とはいえ,monorepo 化されたことで日々開発が楽になったのは事実ですし,今まで polyrepo で変更の心理的ハードルが高く放置されていたバグ改修や改善が数日のうちに8 issue 片付いたことにはびっくりしました.やはり課題が出てきたタイミングでモダンな開発手法を取り入れることは非常に意義深いなと感じました.これからも monorepo のエコシステムとともに成長していきたいなと思います.

構想はしていたけど polyrepo だと実装コストが高いために諦めていた様々なことを monorepo 化したことで楽に実装できるようになったので今後はそういったアドバンストな機能を実装していこうと考えています.例えば,interfacesに各マイクロサービスのインターフェースをまとめることができたので,それらを Protocol Buffers にすることでマイクロサービス間のインターフェースを自動生成できるようにしたり,BUILDファイルに記述された依存関係を巡ることで実装者はそれがローカルで動いているのか ABEJA Platform 上で動いているのかを全く意識する必要がなく実装できるように,より透過性を増していこうかなと考えています.こういったシステムの安定性を高めつつ,どうやったら開発を効率化できるかを考えてアーキテクチャを設計し実装していく試みは非常に楽しいですね!

ABEJAでは一緒にチャレンジしていくメンバーを募集しています!!システム安定化や開発効率化,そういったことを考えながら,お客様に価値を届けるシステムを作っていくことに興味ある方はこちらもぜひ.

hrmos.co www.wantedly.com

ABEJAの中の人と話ししたい!オフィス見学してみたいも随時受け付けておりますので、気軽にポチッとどうぞ↓↓