AI 組織のモノレポ紹介

AI 組織のモノレポ紹介

はじめに

こんにちは、西原です。AI Lab の MLOps チームでエンジニアとプロダクトオーナーを兼任しています。私たちは、日々機械学習(ML)の成果を素早くシステムに取り入れ、安定した運用を実現するための仕組み作りに取り組んでいます。この一環として 2022 年秋からはモノレポ構成での開発に移行しました。モノレポの採用背景やモノレポでの取り組みについて紹介します。

TL;DR

  • 車輪の再発明を防ぎ、開発効率を向上することを目的にモノレポへ移行
  • モノレポのビルドシステム Pants を使って、異なる Python バージョンのプロジェクトを管理
  • モノレポ移行によって開発効率の向上を実感しており、今後もモノレポの運用と改善を継続していく

モノレポの概要

モノレポは複数のプロジェクトやアプリケーションのソースコード、リソースを分割せずに、全てを 1 つのリポジトリで管理する開発手法です。プロジェクトやアプリケーションを個別のリポジトリで管理する方法(poly repo/multi repo)に対して、モノレポを採用することで次のようなことが期待できます。

  • コード共有が容易になるため、同じ機能を複数のプロジェクトで実装する必要がなくなる
  • コードの品質を統一的に管理できるため、バグやセキュリティの問題を早期に発見できる
  • ビルドやテスト、デプロイなどの自動化が容易になる
  • 開発者が 1 つのリポジトリに集まることで開発者同士のコミュニケーションが促進され、開発効率やコード品質の向上につながる

対照的に、モノレポを採用するにあたって気をつける点として同じコードベースを複数人で編集することによるコンフリクトへの注意や依存関係の管理、ビルドシステムを使った効率的な設計等があります。

モノレポに移行するまで

AI Lab では 2022 年秋まで poly repo で開発していました。poly repo ではリポジトリに関わるメンバーの得意・不得意によって、リンター、フォーマッター、テスト、CI/CD の作り込みに差がありました。リポジトリを横断して最低限の仕組みを構築するようにしましたが、当時はOrganization 横断の workflowもまだなく、似たような作業の繰り返しで生産的な作業とは思えませんでした。一方で、これらの作業を怠るとメンテナンス性が低下し、継続的な改善・運用が難しくなるため放置もできません。

GitHub のテンプレートリポジトリや base となるコンテナイメージの利用による Docker イメージの共通化を試みましたが問題を解決できず、モノレポへの移行を検討しました。モノレポに移行することで開発に必要なリソースが全て 1 つの場所に集約され、リポジトリに関わる全員が共通の開発プロセスやツールを使用できます。これによりリンター、フォーマッター、テスト、CI/CD などの開発ツールの統一が容易になり、メンテナンス性や開発効率の向上が期待できます。

モノレポに移行するにあたって依存関係の管理やビルド効率に関する懸念があったため、これらを解決できそうなツール(ビルドシステム)を調査したところ Bazel と Pants が選択肢として挙がりました。最終的に AI Lab の中心的な技術スタックである Python をネイティブでサポートしている Pants を採用することに決めました。

Pants とは

Pants(pantsbuild/pants) はあらゆる規模のコードベースに対応できるスケーラブルなビルドシステムで、特にモノレポとの相性が良いです。依存関係解決ツール、テストランナー、リンター、フォーマッター、パッケージャーなど数十の基本ツールをとりまとめ、扱えるようにします。記事執筆時点の Pants v2.15.0 では Python、Go、JavaScala、Shell、Docker をサポートしています。Pants の特徴として、静的解析による依存関係モデリング、実行結果のキャッシング、並列実行やリモート実行等があります。

モノレポへの移行

移行方針

モノレポへの移行は Pants のキャッチアップと並行して進めることになりました。AI Lab が抱えている問題を モノレポと Pants が本当に解決できるのか不安があったため、モノレポ移行が失敗した場合に切り戻せるように AI Lab が所有するコードの一部を対象に移行を始めました。AI Lab には主にアプリケーション用、ML モデル作成用、インフラ用のコードがあります。このうち、アプリケーションとインフラのコードは CI/CD が整っており、失敗した場合でも元に戻すことが容易だったためこれらのコードを対象にモノレポ移行を進めました。

アプリケーションコードの移行

移行するアプリケーションは Poetry と Docker を使用して構成されていましたが、アプリケーションごとに異なる Python バージョンを使用していました。Pants の事前調査で Poetry をサポートしていることや、異なる Python バージョンを管理できることがわかっていたため、移行の際は事前の作業なしにディレクトリ構成含む全てをそのままモノレポに取り込みました。Pants は requirements.txt を使った管理、 pex を使ったコード実行もできますが移行のハードルを下げるために Poetry を使った構成をそのまま引き継ぎました。コード例のようにpants.tomlの記述を行い、モノレポ移行した各アプリケーションに BUILD ファイルを追加して Pants が動くようにしていきます。./pants tailor ::コマンドを使うと BUILD ファイルの追加を補助してくれます。 ./pants test コマンドを実行してテストが通過すればアプリケーションの移行は終わりです。

[GLOBAL]
pants_version = "2.15.0"
backend_packages = [
    "pants.backend.python",
]

インフラコードの移行

次にインフラ用コードの移行について紹介します。もともと Terraform で書かれたコードはインフラ用のモノレポで管理されていましたが、今回の移行を機 にアプリケーションと同じリポジトリで管理することにしました。

移行前は開発用、検証用、本番用それぞれの tfstate が 1 つあり、その tfstate に複数のアプリケーションが含まれている状態でした。この状態では管理する state の数が増えるにつれて、planapply の実行時間が長くなるだけでなく、追加・変更・削除の際の tfstate の lock により、コンフリクトする恐れがあります。今後アプリケーションが増えると、この状況が開発のボトルネックになると考え、リポジトリ移行のタイミングで 1 つの tfstate で 、環境ごとの 1 アプリケーションを管理するように tfstate を分割をすることにしました。

コードを新しいリポジトリに集約した後、terraform stateコマンドを使用して tfstate をアプリケーションごとに分割してリポジトリ移行を終えました。移行後のディレクトリ構成を簡略化した例が以下になります。

.
├── .github
├── pants
├── pants.ci.toml
├── pants.toml
├── projects
│  ├── app_1
│  │  ├── src
│  │  │  ├── BUILD
│  │  │  └── main.py
│  │  ├── BUILD
│  │  ├── docker-compose.yaml
│  │  ├── Dockerfile
│  │  ├── poetry.lock
│  │  ├── pyproject.toml
│  │  └── tests
│  │     ├── BUILD
│  │     └── test_main.py
│  └── app_N
│     ├── src
│     │  ├── BUILD
│     │  └── main.py
│     ├── BUILD
│     ├── docker-compose.yaml
│     ├── Dockerfile
│     ├── poetry.lock
│     ├── pyproject.toml
│     └── tests
│        ├── BUILD
│        └── test_main.py
└── terraform
   ├── app_1
   │  ├── environments
   │  │  ├── development
   │  │  │  └── main.tf
   │  │  ├── staging
   │  │  └── ...
   │  └── modules
   │     ├── cloud_run.tf
   │     ├── iam.tf
   │     └── ...
   └── app_N
      ├── environments
      │  ├── development
      │  │  └── main.tf
      │  ├── staging
      │  └── ...
      └── modules
         ├── cloud_run.tf
         ├── iam.tf
         └── ...

モノレポの静的解析設定

モノレポ移行の当初の目的であったリンター、フォーマッター、テスト、CI/CD の作り込みと全体への適用を行いました。pants.toml にモノレポで有効にするリンターやフォーマッターの設定を記述します。以下は、Pants で使う静的解析の設定例です。

[GLOBAL]
pants_version = "2.15.0"
backend_packages = [
    "pants.backend.python",
    "pants.backend.python.lint.black",
    "pants.backend.python.lint.flake8",
    "pants.backend.python.lint.isort",
    "pants.backend.python.lint.pylint",
    "pants.backend.python.lint.docformatter",
    "pants.backend.python.lint.bandit",
    "pants.backend.python.lint.autoflake",
    "pants.backend.python.lint.pyupgrade",
    "pants.backend.python.typecheck.mypy",
    "pants.backend.docker",
    "pants.backend.docker.lint.hadolint",
]

Pants では、Python 開発で使うリンターやフォーマッターを一通りサポートしており、flake8 extension も利用できます。Pants でサポートしていないツールも、plugin 機能を使って自前でルールを書くことでモノレポに適用できます。Pants のfmtfixlintcheckコマンドを実行して動作確認できたら静的解析の設定は終わりです。

余談:Pants v2.16 では Ruff の導入が予定されています。Ruff は Rust で書かれた高速な Python リンターであり、Pants のリンター比較実験からも高速に動作することが期待できます。リンターの実行時間が長いと CI の実行が長くなったり、手元でリンターを実行するハードルも高くなりますが、高速に動作する Ruff によって改善されることを期待しています。

トランクベース開発とデプロイ

トランクベース開発

モノレポではトランクベースの開発スタイルを採用しており、main ブランチ以外の永続的なブランチを作成しないようにしています。一般に、寿命の長いブランチを作成し、デプロイするタイミングで多数の変更をリリース用ブランチに取り込む手法もあります。この手法ではデプロイの影響範囲が大きくなり、デプロイの失敗率が高くなります。デプロイが失敗すると多数の変更の中から問題を特定することになり、調査にかかる負担が大きくなると考えました。そこでトランクベースの開発スタイルを採用し、小さなパッチを基本とした開発を目指しています。小さなパッチの場合、コードレビューの負荷も低減され、フロー効率も向上すると考えています。トランクベースの開発スタイルを採用することで、小さな単位のパッチを意識することと、main ブランチに積極的に変更を取り込むうえで本番環境に影響を出さない仕組み(feature flag や versioning など)を活用していくことを期待しています。

デプロイ

アプリケーションのデプロイやterraform applyGitHub Actions を使って行うようにしました。GitHub Actions の workflow ファイルは.github/workflows配下に置く必要があります(公式ドキュメント)。1 つの workflow ファイルで 1 つのアプリケーションのみデプロイできるようにするとファイル数が増加し、見通しが悪くなります。ファイルの増加を防ぐために、workflow_dispatch を使用して単一のファイルから複数のアプリケーションをデプロイできるようにしています。デプロイする際は、実行ブランチ、デプロイする環境、および対象のアプリケーションを選択して実行します。これにより、新しいアプリケーションが増えても、1 つの workflow ファイルで管理できるようになります。

ただし、1 つのファイルに複数のアプリケーションのデプロイ設定を書くとファイルが肥大化し、メンテナンスが難しくなります。メンテナンス性を保つために各アプリケーションごとにデプロイ用の設定ファイルを用意し、workflow 内からそれらのファイルを参照するようにしています。

main 以外のブランチからの実行をブロック

検証環境と本番環境では main ブランチからのみ実行できるようになっており、その他のブランチを指定して実行した場合はブロックされるようにしています。main ブランチは、コードレビューを通過したコードのみを取り込むようになっており、誤操作によって未レビューのコードが本番環境に反映されることを防ぎます。

run-name を使った概要表示

1 つの workflow ファイルから複数のアプリケーションをデプロイする場合、ジョブ実行一覧の画面でどのアプリケーションが誰によって、どの環境に、そしていつデプロイされたかの履歴を確認することが難しくなります。GitHub Actions の run-name を使用して、実行の概要を表示することで該当するジョブを見つけやすくしました。

同時実行制限

デプロイ時に同じ動作が連続して実行されないように同時実行を制限しています。下記のようにinputsの値を使って group を設定してみたのですがうまくできず、こちらのディスカッションにあるようにgithub.event.inputsを使うとうまく group の設定ができました。

name: Deploy Workflow

concurrency:
  # group: ${{ github.workflow }}---${{ inputs.appName }} これだと ${{ inputs.appName }} の値が常に空文字になる
  group: ${{ github.workflow }}---${{ github.event.inputs.appName }} # こっちだと入力した値が反映される
  cancel-in-progress: true

on:
  workflow_dispatch:
    inputs:
      appName:
        description: "Name of the App you want to deploy."
        required: true
# 省略

これまで紹介した取り組みをまとめた workflow の例が次のコードになります。

  1. run-name を使った概要表示
  2. 同時実行制限
  3. 実行ブランチ、デプロイする環境、対象のアプリケーションを選択
  4. main 以外のブランチからの実行をブロック
name: Deploy Workflow
run-name: Deploy to ${{ inputs.application }} by @${{ github.actor }} # ①

concurrency:
  group: ${{ github.workflow }}---${{ github.event.inputs.application }} # ②
  cancel-in-progress: true

on:
  workflow_dispatch:
    inputs: # ③
      environment:
        description: environment
        type: environment
        required: true
      application:
        type: choice
        description: application
        options:
          - app1
          - app2
jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ inputs.environment == 'development' || github.ref_name == 'main' }} # ④ development環境はどのブランチからでも実行できるが、それ以外はmainブランチからのみ実行できる
    steps:
      - run: echo "Deploy to ${{ inputs.application }} by @${{ github.actor }}"

モノレポにおける CI

差分テスト

モノレポ CI の構築において差分検知がポイントだと考えました。モノレポには複数のプロジェクトが存在し、毎回の CI 実行で変更と関係ないプロジェクトまでテストすると時間とお金がかかります。一方で、変更によって影響を受けるコードがテストされなかった場合、コードが壊れていることを検知できず広範囲に影響を及ぼすことがあります 。そして、デプロイと同様にプロジェクトが増えた際に何の工夫もしなければ CI 用の workflow ファイル数が増加していきます。

Pants には差分検知機能があるため、基本的に Pants を使って差分検知します。Docker Compose を使用した ML 推論の regression test や、Terraform など Pants で管理されていないものは paths-filterを使用して差分検知しています。

Pants の差分検知は実行時にオプションを追加するだけで実行できるため導入負荷が低く、Pants を初めて使う人でも簡単に差分実行できます。AI Lab モノレポで全体テストをすると 30 分かかっていましたが、差分実行を導入すると CI の時間が p90 で 3 分になりました。Pants 採用後はできるだけ早く差分実行を導入することをお勧めします。

# origin/mainとの差分を計算するオプション
# --changed-since=origin/main --changed-dependees=transitive

# origin/mainとの差分をテストする例
./pants test --changed-since=origin/main --changed-dependees=transitive

CI の実行時間を短縮するためにキャッシュの活用にも取り組んでいます。Pants でキャッシュを使って CI を高速化する方法が公式のドキュメントサンプルコードで紹介されており、こちらを参考にして AI Lab モノレポでもキャッシュを活用して CI の時間が短くなるように努めています。

先述したとおり CI では差分実行を採用していますが、差分実行だけだとリポジトリ全体が正常に動作しているか不安もあります。そこで、リポジトリ全体のテストを日次で実行してリポジトリ全体が壊れてないか確認することで、不具合を発見できるようにしました。このように差分実行によって時間と費用を節約し、全体テストによってリポジトリ全体が正常か確認することで QCD のバランスが取れた CI システムを実現しました。

テストカバレッジの計測

Pants にはテストカバレッジを計測する機能があり、MishaKav/pytest-coverage-commentと組み合わせてカバレッジをレポートしています。ML 推論の regression test は Pants で管理していないため、このカバレッジに反映されていませんがモノレポ全体のテストカバレッジを計測したところ 86%でした。組織として数値目標は設定していませんが、テストを書いて当たり前の文化が数字として表れていると思います。次の yaml は差分テストを実施した後テストカバレッジを Pull Request にコメントする例です。

# 省略
steps:
  - uses: actions/checkout@v3
  - uses: pantsbuild/actions/init-pants@v2

  - name: Setup Python
    uses: actions/setup-python@v4
    with:
      python-version: 3.9
  - name: Test
    run: ./pants --changed-since=origin/main --changed-dependees=transitive test

  - name: Pytest coverage comment
    uses: MishaKav/pytest-coverage-comment@<sha>
    with:
      pytest-xml-coverage-path: dist/coverage/python/coverage.xml

ML モデルの推論は前処理によって結果が変わります。前処理で使っているライブラリに変更があった場合、推論に影響があるかどうかを判断できるように Pants を使ったテストや regression test をしています。モノレポでは renovate を活用して外部ライブラリのバージョン更新をしており、これらのテストが通れば安心して main ブランチへマージできる仕組みになっています。

今後の展望

ここまでモノレポ移行の背景やモノレポでの取り組みについて紹介してきました。モノレポ移行によって開発効率が向上したことを実感していますが、まだまだやりたいことが残っています。今後、検討・検証したいことの一部を紹介します。

  • ML モデル作成用コードをモノレポで管理
    • 現在はモデル作成用コードは別リポジトリで管理している。モデル作成用コードもモノレポで管理することで品質を統一的に管理し、デプロイまでの繋ぎこみをスムーズにしたい
  • 各プロジェクトごとに管理している外部ライブラリをまとめて管理
    • 各プロジェクトごとにやっている外部ライブラリ管理を辞め、リポジトリ内で使用している外部ライブラリを一箇所に集約して管理することでメンテナンス性が向上する見込み
  • Pants との相性から Poetry を継続するか、Poetry を辞めて requirements.txt で外部ライブラリを管理するかの判断
    • (余談: 最近 Poetry 1.4 がリリースされて install が高速になりましたね)
  • コードの依存関係整備
    • コードがプロジェクト内で完結しているため、レポジトリ内に似たようなコードが存在する
    • 同じことをやっているコードをまとめ、プロジェクト横断で使えるようにする
    • ML の前処理も、一つのコードを使いまわすことで前処理で差分がでることを防ぎ、推論結果を安定させられる見込み
  • custom plugin の作成
    • モノレポの中で共通の処理を plugin 化して統一のインターフェースで実行できるようにする
  • pex を使った Python 実行

まとめ

AI Lab では車輪の再発明を避け、開発効率を向上するためにモノレポへ移行しました。モノレポ移行により移行前に抱えていた課題を解消でき、開発効率の向上を実感しています。Pants を使用して Python バージョンの異なるプロジェクトを管理しています。モノレポでは、何か 1 つのプロジェクトが終了してもリポジトリのメンテナンスが止まることはなく、永続的にメンテナンスすることが前提となります。そのため、開発効率を向上させるための投資がしやすくなりました。ML パイプラインの改善などの他の取り組みと併せて、今以上に事業への価値提供ができるように今後も改善に取り組んでいきます。

参考