Gitで開発していると、ブランチをどう切るかという話には必ず行き当たる。GitFlow、GitHub Flow、Feature Branch。流派はいくつもあるが、その中に「Trunk-Based Development(トランクベース開発)」と呼ばれるものがある。
初めてこの言葉を聞いたとき、僕は「mainに直接コミットする流儀のことだろう」くらいに思っていた。ところが調べてみると、肝心なのはそこではないらしい。本質は、コードをできるだけ小さな単位で、頻繁に共通のブランチへ統合することにある。そしてこの考え方は、CI/CDと切っても切れない関係にある。
木にたとえるなら、幹から伸びた枝を長く放っておかず、こまめに剪定する開発だ。以下、この流儀が広まった背景、GitFlowとの違い、実際に採用している企業の事例を順に見ていく。
幹と枝
Trunk-Based Developmentは、共通のメインブランチ(Trunk)を中心に開発を進める手法である。Gitでは、通常main ブランチがTrunkにあたる。
名前の由来は、Subversion(SVN)で使われていた trunk というディレクトリ名だ。SVNのリポジトリは、開発の中心となる trunk/、分岐した開発を置く branches/、リリースの印を置く tags/ の三つに分けるのが一般的だった。Gitになってブランチの扱いは変わったが、中心となる開発ラインをTrunkと呼ぶ習慣は引き継がれている。
この流儀で避けるのは、Trunkから長く離れて開発することだ。feature/login を切ってマージし、feature/profile を切ってマージし、feature/settings を切ってまたマージする。Feature Branchを作ること自体は構わない。重要なのは、どの枝も短命に保ち、頻繁に main へ戻すことである。
DORA(DevOps Research and Assessment)は、Trunk-Based Developmentの特徴として次の四つを挙げている。ブランチの変更を少なくとも1日に1回はTrunkに統合すること。長く存続する開発ブランチを避けること。同時に動いているブランチを少なく保つこと。そして、コードフリーズや長期間の統合作業を避けることだ。(DORA - Trunk-based development)
要するに、ブランチを使わない開発ではない。ブランチを長く分離させない開発なのだ。
統合という難所
この流儀が生まれた理由を知るには、ソフトウェア開発における「統合」の問題に遡る必要がある。
いまでこそ、複数人でGitを使い、Pull Requestでレビューし、CIでテストするのは当たり前になった。しかし昔からそうだったわけではない。かつての大規模開発では、各チームが担当機能を長期間かけて作り込み、最後にまとめて統合する進め方も珍しくなかった。
たとえば三つのチームが、同じシステムをそれぞれ3ヶ月かけて開発したとする。各チームの作業が終わり、いざ統合してみると、大量の不整合が噴き出す。そこから修正と再テストを繰り返し、ようやくリリースに漕ぎつける。年末の大掃除を、一年分まとめて大晦日にやるようなものだ。
不整合の典型は、こういうものである。Team AがAPIのレスポンス形式を変更していた。
// Team Aが変更したレスポンス
type User = {
id: string;
displayName: string;
};
一方、Team Bは古い形式を前提にフロントエンドを書いていた。
// Team Bの実装
function getUserName(user: User) {
return user.name;
}
それぞれの環境では問題なく動く。だが統合した途端に動かない。むろんこれは単純化した例で、実際にはデータベーススキーマ、ライブラリの依存関係、共通インターフェースと、不整合はあらゆる場所に潜んでいる。しかも数週間から数ヶ月ぶんの変更が溜まっていれば、どれが原因かを突き止めるだけで一苦労だ。
Martin Fowlerは、2000年に公開したContinuous Integrationの記事で、自動化されたビルドとテストを繰り返しながら日常的にコードを統合することの重要性を説いている。(Continuous Integration(2000年版))開発が終わってから大がかりな統合をするのではなく、開発の途中で少しずつ統合し、問題を早く見つける。それが狙いである。
CIツールとContinuous Integrationは別物
ここでCI(Continuous Integration)の話につながる。CIと聞くと、GitHub ActionsやJenkinsを思い浮かべる人が多いだろう。たとえば次のような設定だ。
name: CI
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
Pull Requestを作ると、テストとビルドが自動で走る。こういう仕組みをCIと呼ぶことは多い。もっとも、厳密に言えば、これはCIを実現するための道具の一部に過ぎない。
CIの「Integration」は統合という意味だ。本来のContinuous Integrationは、複数の開発者の変更を継続的に統合し、その統合結果が正常に動くことを確かめる開発プラクティスを指す。
たとえば、feature/a というブランチで2週間開発したとする。その間、Feature Branch上のCIは毎日成功していたかもしれない。それでも、2週間のあいだ他の開発者の変更とは一度も混ざっていない。継続的にテストはしているが、継続的に統合しているとは言いがたい。
Fowlerも、Feature BranchでCIツールを回すだけでは本来のContinuous Integrationにはならないと述べている。(Continuous Integration Certification)この違いは存外大きい。CIツールを導入することと、Continuous Integrationを実践することは同じではないのだ。そしてTrunk-Based Developmentは、この本来のContinuous Integrationを実現するための代表的なブランチ戦略になっている。
両者はかなり近い概念だが、整理するとこうなる。
| 概念 | 主な役割 |
|---|---|
| Trunk-Based Development | コードを短い間隔で共通ブランチに統合する |
| Continuous Integration | 頻繁にコードを統合し、自動ビルドやテストで検証する |
| CIツール | ビルドやテストなどの処理を自動実行する |
Trunk-Based DevelopmentはCIを成り立たせる重要なプラクティスの一つで、CIツールはその実践を支える道具という位置づけである。
GitFlowという枝の多い木
ブランチ戦略の違いは、GitFlowと比べると分かりやすい。GitFlowでは、役割ごとに次のようなブランチを使い分ける。
| ブランチ | 用途 |
|---|---|
| main | 本番リリース済みのコード |
| develop | 開発中のコードを統合する |
| feature/* | 個別機能の開発 |
| release/* | リリース準備 |
| hotfix/* | 本番環境の緊急修正 |
develop から feature/* を切ってそこへ戻し、リリースの準備が整ったら release/* を経て main へ入れる。開発中のコードと本番リリース済みのコードをブランチで区別できるので、リリース対象をきっちり管理したい場合には便利だ。
一方で、複数のブランチを長く維持すると、統合作業も入り組んでくる。本番で緊急修正が必要になれば、main に直すだけでは済まず、develop にも同じ修正を反映しなければならない。release ブランチを切ったあとに develop で機能追加が進めば、それぞれの枝に違う変更が積もっていく。それ自体が悪いわけではない。ただ、開発ラインが増えるほど、面倒を見る対象も増える。
Trunk-Based Developmentでは、開発の中心を基本的に一本にまとめる。feature/a、feature/b、feature/c と短い枝を出しては、すぐに main へ戻す。GitFlowがブランチによって開発とリリースの状態を管理する色合いが強いのに対し、Trunk-Based Developmentは開発ラインの分岐を最小限にすることを重視している。
とはいえ、Trunk-Based DevelopmentでもRelease Branchを使うことはある。「Trunk-Based DevelopmentならRelease Branchは禁止」という理解は正しくない。
通知機能を小分けにする
具体例で考えてみる。Webアプリケーションに通知機能を足すとしよう。やることは、通知テーブルの追加、通知を取得するAPI、通知一覧のUI、既読にする機能、そしてテストである。
これを一本のFeature Branchで作ると、1日目にDB設計、2日目にAPI、3日目にUI、4日目に既読処理、5日目にテストを書き、6日目にようやくmain へマージすることになる。完成するまで、すべての変更は枝の上にある。その間に誰かがAPIや認証処理を変えれば、最後の統合はたちまち面倒になる。
では、Trunk-Based Developmentならどうするか。
1日目は、既存機能に影響しない形で通知テーブルを足す。
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
message TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL
);
この段階では、ユーザーにはまだなにも見えない。続いて通知取得APIを追加する。
@Get('/notifications')
async getNotifications(
@CurrentUser() user: User,
) {
return this.notificationService.findByUserId(
user.id,
);
}
APIのテストまで通ったら main へマージする。機能全体は未完成だが、足した変更そのものは正しく動く状態だ。なお、実際には認可、ページネーション、インデックス設計なども考える必要があるが、ここでは省いている。
2日目は通知一覧のUIを足す。
function NotificationList() {
const { data } = useNotifications();
return (
<ul>
{data?.map((notification) => (
<li key={notification.id}>
{notification.message}
</li>
))}
</ul>
);
}
まだ画面への導線は付けず、コンポーネント単体のテストだけ済ませて main へ統合する。3日目は既読処理のAPIとUIを足し、テストを書いてまた統合する。
4日目に、ようやく通知画面への導線を付ける。
function Header() {
return (
<header>
<Logo />
<NotificationButton />
</header>
);
}
ここで初めて、ユーザーが通知機能を使えるようになる。大きな機能を小さな変更に分け、段階的に統合していくわけだ。
勘所は、機能全体が完成していなくても安全に統合できる単位を見つけることにある。これはGitの操作方法というより、機能をどう分割して設計するかという話に近い。
未完成のコードを幹に入れる作法
ここで当然、疑問が湧く。未完成の機能をmain に入れて大丈夫なのか。数週間かかる決済機能を作っていれば、作りかけのコードが本番にデプロイされることもありうる。
そこで効いてくるのが、DeployとReleaseを分けるという考え方だ。Deployはコードを実行環境に置くこと、Releaseは機能をユーザーが使えるようにすることを指す。普段はこの二つが同時に起きることが多いが、必ずしも同時である必要はない。
代表的な手段がFeature Flagである。
function PaymentPage() {
const enabled = useFeatureFlag('new-payment');
if (enabled) {
return <NewPaymentPage />;
}
return <CurrentPaymentPage />;
}
新しい決済機能のコードが main に入り、本番にデプロイされていても、new-payment のフラグが false なら既存の画面が出る。完成したらフラグを true に切り替えればよい。最初は社内ユーザーだけに公開し、1%、10%、100%と段階的に広げることもできる。
ただし、フラグが無効だからといって、未完成のコードが無害になるわけではない。データベースのスキーマ変更やアプリケーション起動時の処理は、フラグとは無関係に効いてしまう。フロントエンドでUIを隠しても、APIへのアクセス制御にはならない。フラグを使うにしても、正しく動く変更だけを統合するという前提は変わらないのだ。
それに、Feature Flagはタダではない。フラグが5つあり、それぞれがON/OFFの2状態を持てば、組み合わせは理論上32通りになる。10個なら1,024通りだ。全部を律儀に試していたら、テストが終わる頃には次のメジャーバージョンが出ている。むろん全組み合わせをテストする必要はないが、分岐が増えるほどテストもコードも複雑になる。役目を終えたフラグを消し忘れれば、使われない条件分岐がコードベースに居座り続ける。
Fowlerも、Feature Flagは有用だとしつつ、まずは機能を小さく分割して安全にリリースできないかを考えるべきだと述べている。(Feature Flag - Martin Fowler)Trunk-Based Developmentを採用したからといって、あらゆる機能にフラグを生やせばよいわけではない。
CDの土台として
Trunk-Based Developmentは、CIだけでなくCDとも関係が深い。ここでのCDには、Continuous DeliveryとContinuous Deploymentの二つの意味がある。
Continuous Deliveryは、ソフトウェアをいつでも本番へリリースできる状態に保つという考え方だ。変更をmain に統合し、自動テストを通し、ビルドして検証環境で確かめ、リリース可能な状態にしておく。本番へのデプロイ自体は人間が判断しても構わない。大事なのは、リリースのために長い統合や修正の作業を要しない状態を保つことである。
一方のContinuous Deploymentでは、必要な自動検証を通った変更を、そのまま自動で本番へデプロイする。ここでTrunk-Based Developmentが効いてくる。複数の開発者が2週間ごとに大量の変更をまとめて統合していたら、そのたびに自動で本番へ出すのは危うい。逆に、変更が小さく頻繁に統合されていれば、1回のデプロイに含まれる変更量を抑えやすい。問題が起きても、変更範囲が狭ければ原因を追いやすい。
もちろん、Trunk-Based Developmentを採用すれば自動的にContinuous Deploymentが手に入るわけではない。自動テスト、デプロイの自動化、監視、ロールバックといった仕組みも要る。それでも、安全な変更を頻繁に統合するという考え方は、Continuous DeliveryやContinuous Deploymentを支える土台になっている。DORAも、Continuous Deliveryを支える技術的プラクティスの一つとしてTrunk-Based Developmentを挙げている。(DORA - Continuous Delivery)
MetaとMicrosoftの場合
Trunk-Based Developmentは、机上の空論ではない。MetaやMicrosoftのような大規模な開発組織でも、採用事例が公開されている。
Metaは2018年のエンジニアリングブログで、開発者の変更を共通のTrunkに統合し、他の開発者がすぐに最新のコードを使えるようにする開発モデルを説明している。その一方で、変更量の多い大組織では、問題のある変更をTrunkに入れないことが重要になる。そこでMetaは、変更によって影響を受けそうなテストを予測して実行する仕組みを作っている。(Predictive test selection to ensure reliable code changes - Engineering at Meta)
2017年には、FacebookのWebサービスのリリース方法の変更も公開されている。以前はmaster からRelease Branchへ変更を取り込み、1日に複数回リリースしていた。ところが開発規模が膨らみ、1日に1,000件以上の変更が master に入るようになって、リリース管理の負担が重くなった。そこで2016年から段階的にやり方を変えた。2017年4月には、Webサービスの本番サーバー全体で master から直接デプロイする仕組みへ移ったという。(Rapid release at massive scale - Engineering at Meta)
この事例を見ると、ブランチを減らせば済む話ではないと分かる。大量の変更を安全にさばくテスト基盤やデプロイ基盤と、セットで考える必要があるのだ。
Microsoftも、Trunk-Based Developmentにもとづく開発手法を公開している。開発者は短命なTopic Branchを切り、Pull Requestを通してmain へ統合する。その一方で、リリースのときには release/129、release/130 のようにRelease Branchを切る。開発の統合ラインは main に集約しつつ、リリースのタイミングは別に管理しているわけだ。公開ドキュメントでは、200件以上のPull Requestを扱う規模の開発や、1日に数百回のCIビルドが走るケースにも触れている。(How Microsoft develops with DevOps - Microsoft Learn)
この事例は、Trunk-Based DevelopmentとRelease Branchが必ずしも対立しないことを示している。Microsoftは加えて、Feature Flagでデプロイとユーザーへの公開を切り分けている。開発の統合方法とリリース管理を別々に考えている点が興味深い。
剪定の効き目
ここまでを踏まえて、利点を整理してみる。
まず、マージコンフリクトを小さく保ちやすい。長く離れたブランチには大量の変更が積もり、main に戻すときには、衝突の解消に加えて意味的な不整合まで直す羽目になる。小さな変更を頻繁に統合すれば、問題を早いうちに見つけられる。とはいえ、コンフリクトが消えてなくなるわけではない。
次に、問題の原因を特定しやすい。100ファイルを変えたPull Requestのマージ後にテストが落ちたら、犯人探しは骨が折れる。数ファイル程度の変更なら、見るべき範囲はぐっと絞れる。問題のある変更だけをRevertするのも容易だ。もっとも、データベースの破壊的変更や外部システムへの副作用があれば、GitのRevertだけでは元に戻らないこともある。
三つ目は、コードレビューの負担が軽くなることだ。個人的には、これがいちばん効くと思っている。2,000行の変更と100行の変更なら、行数だけで難易度は決まらないにせよ、一般には後者のほうが読みやすい。小さなPull Requestなら、レビューする側も時間を割きやすい。ただし、毎回のレビューに数日かかる体制では、枝を短命に保つことなど到底できない。レビューの運用そのものも見直す必要がある。
四つ目は、リリース頻度を上げやすいことである。変更が日頃からmain に入っていれば、リリース前に大量のブランチをマージする必要がない。自動テストとデプロイの自動化が整っていれば、小さな変更を継続的に本番へ届けられる。リリースが、特別な儀式でなくなるかもしれない。
最後に、複数人での開発が進めやすくなる。全員が最新のTrunkを基準にするので、他人の変更を早く取り込める。共通APIの変更やリファクタリングがあっても、古いコードを前提に作り続ける事態を防ぎやすい。もちろん、頻繁な変更に耐えるテストとコミュニケーションの仕組みがあってこその話だ。
剪定の手間
利点は多いが、導入すれば必ず開発が速くなるわけではない。
第一に、自動テストへの依存が強くなる。main を常に正常に保つには、自動テストの整備が欠かせない。テストが手薄なまま頻繁に統合すれば、不具合が main に紛れ込みやすくなる。テストが不安定で頻繁に落ちるのも困りものだ。失敗が本物の不具合なのか、テスト環境の気まぐれなのか、判別がつかなくなる。
第二に、CIが遅いと開発が詰まる。Pull RequestのたびにCIが30分かかるとしよう。1日に5回統合すれば、単純計算で150分ぶんのCI待ちが発生する。その間に別の作業はできる。しかし、CIが終わるまでマージできなかったり、修正のたびに再実行が要ったりすれば、待ち時間はたちまちボトルネックになる。だからテストの高速化、並列実行、影響範囲に応じたテストの選別が重要になる。先ほどのMetaのテスト選択の仕組みも、この問題への一つの答えである。
第三に、大きな機能を分割する設計力が要る。実のところ、ここがいちばん難しいのかもしれない。新しい認証方式への移行や、データベースの大規模なリファクタリングは、数時間で終わる変更に都合よく切り分けられるとは限らない。そういうときは、古い仕組みと新しい仕組みを一時的に共存させる設計が必要になる。
たとえばカラム名を変えるなら、いきなり古いカラムを消したりはしない。まず新しいカラムを足す。次にアプリケーションを新旧両方に対応させる。続いて既存データを移し、新しいカラムへ切り替え、最後に古いカラムを消す。一般にExpand and Contractと呼ばれる移行パターンに近い。小さく分ければ安全に移行しやすくなるが、そのぶん設計と実装は込み入ってくる。
第四に、Feature Flagの管理が要る場合がある。未完成の機能を安全に統合するためにフラグを使えば、設定もテスト対象も増える。消し忘れれば、使われないコードが長く残る。フラグを導入するなら、作るところだけでなく、消すところまで運用に組み込む必要がある。
そして第五に、チーム全体の協力が要る。個々の開発者が枝を短くしても、それだけでは足りない。レビューに3日かかれば、どれだけ小さな変更を作っても統合は遅れる。CIが落ちたまま誰も直さなければ、ほかの開発者の統合も止まってしまう。Trunk-Based Developmentは、個人のGitの使い方ではなく、チームの開発プロセス全体の話なのだ。
向き不向き
ここまでを踏まえると、相性がよさそうなのは、WebアプリケーションやSaaSのように継続的に機能を足していくプロダクトだ。自動テストとCI/CDが整っていて、小さな単位でリリースでき、レビューを短時間で回せる。障害が起きればRevertやRollbackで戻せる。こういう環境なら、Trunk-Based Developmentはすんなり馴染むだろう。
一方で、テストの自動化が難しい場合や、リリースのたびに長い認証・検証が要る場合は工夫が要る。複数バージョンを長く保守する製品、外部ベンダーや複数組織との調整が多いプロジェクト、開発者が頻繁にコードを統合できない体制も同様である。
ただし、こうした環境だからといって採用できないわけではない。複数バージョンを保守する製品でも、開発はTrunkを中心に進め、リリース済みのバージョンだけをRelease Branchで保守するやり方がある。先ほどのMicrosoftの事例も、その参考になる。
一足飛びに移る必要はない
実際に導入するとしても、最初からすべての枝を短命にする必要はないと僕は考えている。Feature Branchを1週間ほど維持しているチームなら、まずはPull Requestを小さくするところから始めればよい。
順序としては、まずPull Requestの変更範囲を小さくし、Feature Branchを数日以内にマージできるようにする。次に、CIの実行時間を縮め、自動テストを整える。続いて、機能を段階的に統合できる設計を増やし、必要ならFeature Flagを入れる。そうして徐々に、1日1回以上の統合を目指していく。GitFlowを一夜にして廃止するより、長いブランチ分離を少しずつ減らしていくほうが現実的な場合も多い。
また、Trunk-Based Developmentを採用しても、Pull Requestやコードレビューをなくす必要はない。GitHubのBranch Protectionを使い、CIが成功してレビューが承認された変更だけをmain に入れる運用もできる。結局のところ、どのブランチ戦略を名乗るかは重要ではない。問題は、実際にどれくらいの頻度で変更を統合できているかなのである。
GitFlowは間違いなのか
ここまでTrunk-Based Developmentの利点を中心に書いてきたが、GitFlowが間違っているわけではない。
GitFlowには、開発中の変更とリリース対象をブランチで明確に分けられるという強みがある。リリースの時期が決まっている製品や、複数のバージョンを管理しなければならない場合には、ブランチによる分離が役に立つこともある。一方、継続的に改善していくWebサービスでは、長いブランチ分離が開発速度を落とす原因になりうる。
比べると、次のようになる。
| 観点 | GitFlow | Trunk-Based Development |
|---|---|---|
| 開発の中心 | developなど複数の役割別ブランチ | mainなど単一のTrunk |
| Feature Branch | 機能完成まで維持することが多い | 短命にする |
| 統合頻度 | ブランチ運用による | 少なくとも毎日が目標 |
| リリース管理 | Release Branchを活用 | 必要に応じてRelease Branchを活用 |
| 未完成機能 | ブランチで分離しやすい | 小さな変更やFeature Flagなどで管理 |
| CI/CDとの相性 | 運用方法に左右される | 高頻度のCI/CDと相性が良い |
| 主な課題 | ブランチ間の統合・同期コスト | テスト基盤・変更分割・レビュー速度 |
main へ統合していれば、Trunk-Based Developmentの考え方を実践していると言ってよい。これらの手法は、名前だけできれいに切り分けられるものではないのだ。
以上、Trunk-Based Developmentについて調べたことを書いてきた。最初はブランチを減らすだけの話だと思っていたが、背景を辿ると、Continuous Integrationという考え方に深く根を張っていた。複数人で開発すれば、変更はいずれ統合しなければならない。その統合を先延ばしにするほど、変更は積もり、問題はこじれる。だから変更を小さくして、こまめに統合する。それがこの流儀の中心にある。
実践するには、自動テスト、CIの高速化、Feature Flag、段階的に統合できる設計と、さまざまな仕組みが要る。とりわけ、大きな機能をどう小さな変更に分けるかは、Gitの知識ではなく、ソフトウェアの設計そのものの問題だろう。Trunk-Based Developmentの目的は、ブランチの数を減らすことではない。統合の負担を小さくして、ソフトウェアを改善し続けやすい状態を作ることにある。GitFlowかTrunk-Based Developmentかという二択に悩む前に、自分たちの開発にどれだけ統合の負担があり、それを減らすにはなにが要るのかを考えるべきなのだ。枝ぶりの良し悪しは、結局のところ、木が育ち続けられるかどうかで決まる。
参考文献
- DORA - Trunk-based development
- DORA - Continuous delivery
- DORA - Working in small batches
- Martin Fowler - Continuous Integration
- Martin Fowler - Continuous Integration(2000年版)
- Martin Fowler - Continuous Integration Certification
- Martin Fowler - Patterns for Managing Source Code Branches
- Martin Fowler - Feature Flag
- Trunk Based Development
- Engineering at Meta - Predictive test selection to ensure reliable code changes
- Engineering at Meta - Rapid release at massive scale
- Microsoft Learn - How Microsoft develops with DevOps