Copier × Ruff でスケールするLint管理
0. はじめに
本記事では、ELYZAのML開発における、マルチリポジトリ構成でスケールするLint管理の取り組みについてご紹介します。
機械学習プロジェクトでは、顧客要件や機能ごとにリポジトリが分かれることが多く、リポジトリ数が増えるにつれて設定管理の複雑性が増していきます。私たちは、Copier × Ruffを組み合わせることで、リポジトリが増えても局所最適に陥らず、組織として成熟し続けるスケーラブルな仕組みを構築しました。
ここで言う「スケールする」とは、単に作業コストが増えないということではありません。より本質的には、議論を集約し知見を統合することで、規模が拡大しても組織全体の品質が向上し続けることを意味しています。
マイクロサービス的な構成や複数リポジトリでの設定管理に課題を感じている方、組織の成長に伴うコード品質管理を考えている方の参考になれば幸いです。
なお、本記事の内容はELYZAの複数の事業部を跨いで、曽我部(@sog4be)、堀江(@eemon18
)、堤(@ozro_223)らで取り組み、代表して曽我部が執筆しました。
1. TL;DR
Pythonプロジェクトにおいて、リポジトリ数が増えてもスケールするLint管理の仕組みを構築しました。要点は以下の通りです。
- 議論の集約: Lint設定の議論を単一リポジトリに集約し、局所最適を回避
- 知見の統合: PRベースの意思決定により、組織全体の経験を統合して洗練
- 透明性の確保: 誰もが過去の議論を参照でき、意思決定の背景を理解できる
-
設定の配布: Copierで各プロジェクトへ配布、
copier updateで1コマンド更新 - 新ルールの自動キャッチアップ: 全ルール有効→ignore方式で、Ruffの進化を組織に取り込む
- 導入の加速: 30分ワークショップで組織全体への浸透を加速
この仕組みにより、リポジトリが10個でも50個でも、組織として洗練された設定を維持し、継続的に品質を向上させることができます。
2. 背景:リポジトリ数の増加と管理コストの課題
2.1 マイクロサービス的なリポジトリ構成
ELYZAでは、基盤システム上で動作するMLモジュールを、マイクロサービス的に複数のリポジトリに分けて管理しています。これは個別のセキュリティ要件を満たしやすくしたり、顧客特性の強い実装を含むモジュールを分離して基盤システムとの疎結合を保ちやすくしたりするための設計選択です。
この構成には明確なメリットがあります。変更の影響範囲が限定されるため、新機能の追加や既存機能の改善がしやすく、MLエンジニアは高い自由度を持って開発を進められます。しかし同時に、エンジニアが日常的に異なるリポジトリを横断して作業することになり、それぞれのリポジトリで設定ファイルも分散してしまうというデメリットも抱えていました。
2.2 スケールしない管理:局所最適と知見の分散
事業が成長しリポジトリが増えると、組織が成熟できないという問題が顕在化します。
個別のリポジトリでLint設定を議論すると、プロジェクト固有の文脈が優先されます。「納期が厳しいから、このルールは緩めよう」「特殊な実装が必要だから、このチェックは外そう」――そうした固有の文脈による判断が、各リポジトリで独立に行われてしまうと、組織全体として最適な判断が統合されません。
プロジェクトAで「このルールは不要」と判断され、プロジェクトBでは「このルールは必須」と判断される。議論はSlackのスレッド、個別リポジトリのPRコメント、口頭での合意など様々な場所に散らばり、3ヶ月前の議論はプロジェクトAに埋もれたまま、プロジェクトBで同じ議論が再び始まることすらあります。
リポジトリが50個に増えれば、局所最適な判断が50箇所で独立に行われ、知見は50箇所に散らばります。規模が拡大すればするほど、品質のばらつきと暗黙知の散在が進み、管理コストも増大します。
3. アプローチ:スケールする管理の仕組み
3.1 全体像:単一リポジトリでの集約管理
私たちの解決策は、Lint設定を単一のリポジトリで管理し、Copierを使って各プロジェクトへ配布するというシンプルな構成です。
仕組みの全体像:
- 設定管理リポジトリ: pyproject.tomlなどのLint設定をテンプレートとして管理
- 議論の集約: すべてのルール変更はこのリポジトリでPRを通じて議論・承認
-
自動配布: 各プロジェクトで
copier updateを実行するだけで最新設定を取り込み
この仕組みがスケールする理由は2つあります。第一に、議論を一箇所に集約することで、局所最適を避け、組織全体の知見を統合できること。第二に、配布を自動化することで、規模が拡大してもコストが増えないことです。

3.2 議論の集約:PRベースの意思決定プロセス
重要なポイントは、すべての議論を設定管理リポジトリに集約することです。
運用ルール:
- ルールの追加・削除は、すべて設定管理リポジトリでPRを作成
- 2名以上のPython開発者の承認を必須
- PRのコメントで議論し、合意形成のプロセスを可視化
全ルール有効化→ignore追加方式:
特にRuffでは、最初にすべてのルールを有効にした状態から始め、不採用にしたいルールをignoreリストに追加していきます。
この方式を採用した理由は2つあります。第一に、Ruffは活発に開発が続けられており、新しいルールが継続的に追加されています。全ルール有効化方式を取ることで、新ルールを自動的にキャッチアップできます。第二に、Ruffのルール数は非常に多く、採用するルールを一つひとつ議論するよりも、不採用にするルールについて「なぜ不採用なのか」を議論する方が効率的です。
実際の運用例:
例えば、あるエンジニアが「ルールD100(モジュールレベルのdocstringを必須にする)は厳しすぎる」と感じた場合、設定管理リポジトリにPRを作成します。複数のプロジェクト経験者が議論に参加し、「小規模な内部モジュールでは負担が大きい」という合意が得られれば、D100がignoreリストに追加されます。
この議論と結論は、PRの履歴として永続的に記録されます。6ヶ月後、別のエンジニアが疑問に思ったとき、そのPRを見れば背景を理解できます。同じ議論が別のリポジトリで繰り返されることはありません。
3.3 なぜCopierか?: Cruft / Cookiecutterとの「更新戦略」の比較
集約された設定を全リポジトリに展開するにあたり、私たちは「どのテンプレートツールを使うか」を比較検討しました。候補は Cookiecutter、Cruft、そして Copier です。
私たちの要件は「リポジトリの初期生成」だけではなく、「テンプレート(中央設定)の変更を、既存の全リポジトリに継続的に反映(更新) できること」でした。
Cookiecutter / Cruft:更新機能の課題
Cookiecutterは、プロジェクトの初期生成ツールとしてはデファクトスタンダードですが、一度生成したプロジェクトに対して、テンプレート側の変更を賢くマージするupdate機能を標準では持っていません。そのため、今回の要件には合いませんでした。
Cruftは、まさにこのCookiecutterの更新問題を解決するために登場したツールです。CruftはCookiecutterテンプレートと完全互換であり、cruft update コマンドを提供します。Cruftの更新アプローチは、diff(差分)を算出し、それを既存のプロジェクトにパッチ(patch)として適用するものです(参考:Cruft公式ドキュメント)。更新はできるものの、pyproject.tomlのような単一の設定ファイルを、テンプレート側とプロジェクト側の両方で並行して変更する可能性がある今回のケースでは懸念がありました。
パッチベースのアプローチでは、両者の変更が近接している場合にコンフリクトが多発したり、意図せずプロジェクト固有の変更を上書きしてしまったりするリスクがあると考えました。
Copier:堅牢な 3-way merge による更新
Copierの copier update は、パッチを当てるのではなく、ファイルごとに 3-way merge を実行します(参考:Copier公式ドキュメント)。これはGitがブランチをマージする際に行う処理と非常によく似ています。Copierは .copier-answers.yml に記録された「前回更新時のテンプレートの状態」を基準(Base)として、以下の3者を比較します。
- Base (基準): 前回更新した時点のテンプレートの状態
- Ours (プロジェクト側): 開発者がプロジェクト固有に加えた現在の状態
- Theirs (テンプレート側): 設定管理リポジトリの最新の状態
この3者比較により、pyproject.toml の中でテンプレート側とプロジェクト側がそれぞれ別の箇所に変更を加えていた場合、Copierはそれを正しく統合できます。
(補足:もしテンプレート側とプロジェクト側が同じ行を編集していて自動マージできない場合、コンフリクトが発生します。その際は、Gitのコンフリクト解決と同様に、開発者が手動でファイルを修正することができます。)
4. 導入の実践:組織への浸透施策
4.1 課題:「みんな忙しい」問題
せっかく仕組みを構築しても、それが組織全体に浸透しなければ意味がありません。しかし、組織への浸透も一筋縄では行きませんでした。
設定リポジトリを公開し、社内で導入を呼びかけても、現実にはなかなか導入が進みません。エンジニアは誰もが日々の開発タスクに追われており、既存のリポジトリに新しい設定を導入することは、優先順位が下がりがちです。
4.2 解決策:30分ワークショップ
この状況を打破するために、私たちは30分のワークショップを企画しました。タイトルは『30分で整うコードベースの秩序 〜pyproject.toml大適用会〜』です。
ワークショップの内容はシンプルです。参加者には各自が担当しているリポジトリを持ち寄ってもらい、その場で導入方法を説明し、全員で一斉に導入PRを作成するところまで実施します。疑問点があればその場で質問でき、つまずいたら隣の人や主催者がサポートします。
このアプローチが効果的だった理由は、心理的なハードルを下げたことにあります。「30分だけ時間を確保すればいい」という明確なコミットメントのラインと、「その場で完了できる」という安心感が、行動を促しました。結果として、ワークショップを通じて組織全体への導入が一気に進みました。
4.3 導入コストの軽減
新しいLintルールを既存のコードベースに適用すると、当然ながら修正が必要な箇所が大量に検出されます。これも導入を躊躇させる要因の一つでした。
しかし、現在ではClaude Codeのような高性能なAIツールが利用可能です。新ルール適用に伴う修正作業をClaude Codeに依頼することで、ほぼ自動的に修正を完了できます。
このように、ツールを適切に組み合わせることで、導入の心理的・時間的コストを大幅に削減することができました。
5. 運用のポイントと得られた効果
5.1 透明性の確保
PRベースのルール変更管理により、すべての意思決定プロセスが可視化されています。新しくチームに加わったメンバーが「なぜこのルールが採用されているのか」「このignoreは何のために追加されたのか」と疑問に思ったとき、PRの履歴を辿ることで、当時の議論や背景を理解することができます。
この透明性は、単にドキュメントを残すことよりも強力です。ドキュメントは更新されなくなり陳腐化しがちですが、PRの履歴はコードと共に生きた記録として残り続けます。
5.2 スケールする管理の実現
大きな成果は、リポジトリが増えても組織として成熟し続けられることです。
新しいルールの追加や既存ルールの見直しは、すべて集約リポジトリのPRで議論されます。複数のプロジェクトを経験したエンジニアが、様々な視点から意見を出し合い、組織として最適な判断を下します。その議論と結論は、PRの履歴として永続的に記録され、将来の意思決定の土台となります。
規模が拡大しても、品質は向上し続けます。 リポジトリが10個のときも、50個になったときも、議論は同じ一箇所で行われ、組織全体の経験が統合されます。新しいプロジェクトは、過去の知見を自動的に継承します。
配布コストもほぼゼロです。 新しいリポジトリが立ち上がっても、Copierテンプレートを適用するだけで、組織標準の設定が導入されます。既存のリポジトリに新しいルールを展開する場合も、各リポジトリでcopier updateを実行するだけです。
5.3 現時点での評価
正直に申し上げると、この取り組みの効果を定量的に計測するには至っておらず、「リポジトリ間移動時の認知負荷が何%削減された」といった数値は持ち合わせていません。
しかし、定性的には明確な改善を実感しています。リポジトリを移動する際の「この設定は何だろう」という戸惑いが減り、新しいリポジトリを立ち上げる際のルール議論が不要になりました。また、新メンバーのオンボーディングもスムーズになり、「このリポジトリでは何に気をつければいいか」といった暗黙知の共有にかかる時間が短縮されました。
6. おわりに
本記事では、マルチリポジトリ構成でスケールするLint管理の取り組みをご紹介しました。
議論を単一のリポジトリに集約し、PRベースで透明性を確保することで、組織全体の視点でルールを洗練させる。そしてCopier × Ruffにより、集約された設定を全リポジトリに効率的に展開する。この2つの組み合わせが、スケールする管理の鍵となりました。
同様の課題に直面している方の参考になれば幸いです。
ELYZAでは、今後も実効的なエンジニアリングを追求し、組織としての生産性向上に取り組んでいきます。本記事の内容に興味を持たれた方や、こうした取り組みに共感いただける方は、ぜひお気軽にカジュアル面談にお越しください。
Appendix. 設定管理リポジトリの構成例
設定管理リポジトリの基本的な構成は以下の通りです。
python-coding-standards/
├── .github
| └── workflows
| ├── tagging.yml
| └── ci.yml
├── copier.yml
├── pyproject.toml.jinja
├── {{ _copier_conf.answers_file }}.jinja
├── {% if use_gitignore %}.gitignore{% endif %}.jinja
└── README.md
pyproject.tomlの設定例
pyproject.tomlの設定例を以下に示します。Ruffに関しては、不採用のルールをignoreリストで管理しています。
[tool.ruff]
target-version = "py{{ python_version | replace('.', '') }}"
line-length = 119
indent-width = 4
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
"D203",
"D213",
"G004",
"Q000",
"Q003",
"EM101",
"EM102",
"COM812",
"FBT001",
"FBT002",
"TRY003"
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101",
"PLR2004",
]
[tool.ruff.lint.isort]
section-order = [
"future",
"standard-library",
"third-party",
"first-party",
"local-folder",
]
split-on-trailing-comma = true
[tool.ruff.format]
quote-style = "single"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.mypy]
python_version = "{{ python_version }}"
files = "src"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
allow_redefinition = false
show_error_codes = true
pretty = true
[tool.pytest.ini_options]
filterwarnings = ["ignore::DeprecationWarning"]
copier.ymlの例
# Configuration
_templates_suffix: .jinja
_exclude:
- .github
- .git
- .gitignore
- README.md
- src
- generated
- copier.yml
# Prompt
project_name:
type: str
help: What's your project's name?
python_version:
type: str
help: What's your project's Python version? Please enter in x.y format.
default: 3.12
use_gitignore:
type: bool
default: false
help: Do you want to use `.gitignore` from template?
Discussion