寝たきりLate Night

GitHub Flowに一石を投じてみる

目次を開く

 

Git を使ってバージョン管理していたら、一度はブランチ戦略(ブランチモデル)について考えたり、学習したりすることがあるのではないでしょうか。

ブランチ戦略は調べたらいろいろ出てきます。git-flow、GitHub Flow、GitLab Flow などなど・・・。

現代でよく使われているのは GitHub Flow でしょうか。私も一番好きです。

シンプルで、覚えることが少なくて、CI/CD とも相性がいい。git-flow のような複雑な多層構造に疲れた経験がある人ほど、GitHub Flow のミニマルさに救われた記憶があるんじゃないでしょうか。

さて、そんな GitHub Flow ですが、

「小さく PR を刻んで main にマージしていく」という思想と、「main は常にデプロイ可能」という思想、これ、両方同時に守れない場面があるんじゃないでしょうか?

前提

一応断っておくと、GitHub Flow をディスるつもりはありません。私自身も好んで使っている前提の上で「じゃあこの穴、みんなどうしてるんだろう」を考えてみる記事です。

必要な前提知識は以下。

  • git mergegit rebase らへんがなんとなくわかる
  • git cherry-pick がなんとなくわかる
  • GitHub Flow(feature branch を main から切って Pull Request でマージ、というアレ)をなんとなく知っている / 運用したことがある

逆に、git-flow を隅々まで理解している必要はないです。むしろ git-flow 信者にはこの記事は刺さらないと思う。

問題提起:small step と main 常時デプロイ可能、両立してますか?

具体例を出します。

中〜大規模なリポジトリで、案件 A と案件 B を同時に進めているとしましょう。

  • 案件 A:3 つの PR をマージする規模。今 1 つ目がマージ済み
  • 案件 B:1 つの PR で完結。マージ済みで、もうリリースしたい
main ─●A1─●B1(完成)──────→
            └ 次のタグをここで切ると、A の未完成分(A1)まで含んでしまう

GitHub Flow の標準的な運用だと、ここでタグを切れば A の中途半端な変更まで一緒にリリース範囲へ入ってしまいます。

「PR は小さく刻んでどんどん main に入れましょう。大きな PR は犯罪です」という思想を忠実に守るほど、この事故は起きやすくなる。ちょっと皮肉な話です。

しかもこれ、完成してないなら使われないからいいのでは、というものでもありません。困るのはインフラ側です。Terraform などでインフラ管理していると、例えば Lambda × EventBridge のバッチ処理システムなんかだとすれば、apply した瞬間にリソースが動き出したり、スケジュールされるものもあります。アプリケーションのように「コードは入っているけど動いていない」という中間状態を作りにくいんですよね。しかもインフラ変更には大抵、情シスの承認などの開発チームだけではコントロールできないめんどくせえタイミング調整が絡んできます。

今タグを切ったら A の中途半端なリソースが本番環境へリリースされ、障害が起こり、さっきしたツイートが万バズしているかの如く通知が社用携帯に飛ぶでしょう。さて、この状態は「main は常にデプロイ可能」と言えるでしょうか?

そして、GitHub Flow はこの問題への解決策を持っているでしょうか?

そもそも「デプロイ可能」って何を保証してるんだっけ

ここで一度、GitHub Flow の生みの親である Scott Chacon の 2011 年のオリジナル記事まで遡ってみます。ここでいう「常にデプロイ可能」の定義を確認したいので。

彼は master(main) ブランチのルールについて、次のように説明しています。

The master branch is stable and it is always, always safe to deploy from it or create new branches off of it. If you push something to master that is not tested or breaks the build, you break the social contract of the development team and you normally feel pretty bad about it.

(訳)master ブランチは安定しており、そこからデプロイしたり、そこから新しいブランチを作成したりすることは、常に、絶対に安全です。テストされていないものやビルドを壊すものを master にプッシュすると、開発チームの「社会的契約」を破ることになり、通常はかなりの罪悪感を抱くことになります。

 

・・・待てよ、これ、思っていたより限定的な話かも(ここまで書いておいてこの記事ボツになるかもと焦っている)

「常にデプロイ可能」の定義は、「テストが通っている」「ビルドが壊れていない」 という、CI が通っていることを指しているんです。つまり厳密には、「デプロイという操作が技術的に成功するかどうか」 の話であって、「デプロイした結果、本番で正しく動作するかどうか」の話ではない、ということになります。たぶんね。

これ、私が最初に例に出した Lambda のケースに当てはめるとどうなるでしょうか。

ちゃんとテスト・検証が行われ、レビューも通っていれば、terraform apply はエラーなく完了するでしょう。「デプロイという操作」としては完全に成功です。

でも、その Lambda が起動した瞬間に権限不足でエラーを吐き続ける、あるいは Scheduler が想定外の頻度でバッチを回してしまう・・・という結果は、Apply の成功/失敗という判定軸には一切現れません。

つまり GitHub Flow の「main は常にデプロイ可能」は、暗黙のうちに 「CI が通ること」を「正しく動作することの十分な代理指標」として扱っている んですよね。この仮定、アプリケーションコードでテストカバレッジが高ければそれなりに機能します。ロジックの誤りは単体テスト・結合テストがある程度拾ってくれるので、「ビルド成功 ≒ 正しく動く可能性が高い」という相関がそこそこ強いからです。

でもインフラだとこの代理指標がかなり弱くなります。Plan や Apply が成功することは、構文的な正しさと依存関係の整合性しか保証してくれず、「IAM 権限が足りているか」「Scheduler の実行頻度が想定と合っているか」みたいな運用上の正しさについては、ほとんど何も語ってくれません。インフラ構築したことある人なら、Apply 通ってからが本番、ちゃんと想定通りに動いているか確認するまでが遠足、というのわかってくれますよね。

整理するとこんな感じ。

技術的な意味での「デプロイ成功」 運用上の意味での「安全」
定義 ビルド/Apply がエラーなく完了する 本番で意図通りに、かつ害なく動作する
アプリでの検証手段 CI(テストスイート)である程度カバーできる 同左(テストの質次第ですが)
IaCでの検証手段 Plan/Apply の成否 ほぼ検証手段がない(実環境で動かすまで分からない)

原則の「文字」は満たしているのに、原則が本来意図していたはずの「精神」は満たされていない。この乖離こそが、今回の Lambda の話が突いていた部分だと思います。

「small step」の意味を疑ってみる

ここで一度、原理原則に立ち返ってみます。

Martin Fowler の記事に、統合は頻度を上げるほど難易度が下がるという、地味だけど核心を突く指摘があります。統合の単位が小さいほど、悲劇的な大合流にはなりにくい、という話です。

これ自体は GitHub Flow の精神そのものです。じゃあなぜ、その精神を忠実に実践しているはずなのに、今回のような事故が起きるのか。

ここまでの議論を踏まえ、GitHub Flow が暗黙に要求している「small step」は、「時間的に小さい」ではなく「それ単体で安全にデプロイできる」 という意味の方が近そうに感じますね。この 2 つ、全然違います。私はこの理解を、たった今までできていなかったがために、見切り発車でここまでグダグダな記事を書く羽目になってしまいました。

この「small step」、IaC だとうまくいかないケースが結構あるはずです。「宣言したら即動く」という性質上、コードとして存在するけど動いていない、という第三の状態を作りにくい。破壊的変更だとなおさらです。

つまり、「小さく PR を刻んで main にマージしていく」「main は常にデプロイ可能」という 2 つの原則が衝突するのは、運用の失敗ではなく、「変更を安全な単位に分解できるかどうか」という、あまり言語化されてこなかった前提が崩れているだけ、というのが今のところの理解です。

・・・で、何の話だっけ?

というわけでここからが本題です。

この問題、実は結構いろんな文脈で既に議論されていて、対処法もいくつか存在します。ただ、どれも一長一短で、正直「これが正解」と言い切れるものがありませんでした。今回は選択肢を並べるだけにしておきます。どれが刺さるかはたぶん人それぞれです。

選択肢1:Feature Flag で安全な断面を作る

案件 A の未完成コードもフラグでガードしてどんどん main に入れてしまう方式。トランクベース開発の本流の解法です。

  • 👍 GitHub Flow の思想を崩さずに済む
  • 👎 IaC だと「フラグでガードできない変更」が結構あるかも?
  • 👎 結局フラグの寿命管理・テストマトリクスの増加などのコストがついてくる

選択肢2:Revert(推し1)

これが一番シンプルでしょうか?優先したい案件以外がすでにマージされていたら、Revert してからタグを切り、後で再度取り込む。

  • 👍 特別な仕組みがいらない。GitHub Flow の構造を一切変えなくていい
  • 👍 発生頻度が低いなら、これで十分割に合う
  • 👎 発生頻度が高いチームだと、毎回この判断と作業が発生しコストが積み上がる

学習コストなどコミコミで一番よさそうです。

選択肢3:案件ブランチ+FI自動化(推し2)

ついに git-flow と手を組む時が来ました。git-flow の develop ブランチを、リポジトリ全体ではなく案件単位に局所化する方式です。実はこれ、TFVC の世界だと「Feature Isolation」として昔から語られている考え方で、FI(Forward Integration:親→子への取り込み)と RI(Reverse Integration:子→親への反映)という語彙まで用意されています。

main ─●A1─●B1─●A2─●B2─●A3(完成)→
         └ project/A ブランチ ─────RI→ main

あるいは、

main ─●B1─●C1─●B2─●B3────────────────────●→
         └ project/A ─●A-1─●A-2─●A-3─●A-4─┘
                       ↑PR   ↑PR   ↑PR   ↑PR
  • 👍 main が常にクリーンに保たれる。IaC との相性はかなりいい
  • 👍 main→案件ブランチへの追従は GitHub Actions で自動化できる(FI の自動化)
  • 👎 統合リスクが「RI するタイミング」まで先送りされる。GitHub Flow が本来避けたかった問題がここで再発する。これが一番痛い
  • 👎 案件ブランチ同士は互いを見ていないので、共有リソースへの同時変更はやはり検知が遅れる

割と良い方法だと思っているのですが、メンバー全員が Git 及びその運用に精通していないとヤバいことが起きそうな気はします。

まとめ

GitHub Flow は「small step」と「main 常時デプロイ可能」という思想を掲げていますが、この 2 つが両立するのは、変更を安全な単位に分解できる場合に限られる、というのが今回考えてみた結論(のようなもの)です。しかもその手前で、「デプロイ可能」という言葉自体が「CI が通ること」という技術的な意味でしか保証されておらず、「本番で安全に動くこと」までは保証していない、という点も見逃せません。字面上は原則を守っていても、精神までは守れていない場面がある、ということですね。分解できるアプリケーションコードでは問題が表面化しにくく、分解しづらい IaC や、規模の大きい案件では、この前提が崩れて「あれ、思ってたのと違う」という場面に出会うことになります。

じゃあどうするか、については選択肢を並べるだけにしておきます。どれも銀の弾丸ではなく、発生頻度やチームによっても向き不向きが変わるトレードオフです。

GitHub Flow、これからもよろしくね!

参考