LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

多数のインフラ関連リポジトリをモノレポ構成にまとめたTips

前書き

リブセンス インフラエンジニアの中野(etsxxx)です。VPoEという肩書きのそいつと同一人物です。

言うまでもなく写真と本文にはあまり関係ありません。コロナ禍前の、弊社のオフィスでのモノレポ化の風景です。

写真のそれとは異なりますが、私はTeacher'sというウィスキーを家に常備しています。Zoomで烏龍茶を飲んでるように見えたらそれはTeacher'sです。これ、2,700mlサイズのペットボトルが売られていて、それを徒歩5分以内の店で2,700円ほどで買えることを知ってから、そればかり買っています。2,700mlもあれば当分大丈夫だろうと思っていると、いつのまにか空になっているから、リモートワークはなかなか気が抜けません。


さて、Google、Facebookが、モノレポ(monolithic repository/単一リポジトリ)を採用しているという噂は広く知られている話かもしれません。

本記事は、リブセンス インフラグループにおいて、Infrastructure as Codeなプロビジョニング関連のGitリポジトリをモノレポ構成に変えた話を記そうと思います。

なお、モノレポとメニーレポ(many repository/複数リポジトリ)のどちらが良いかという議論はググれば世の中にたくさん溢れており、それについてはここでは触れません。体験記として読んで頂ければと思います。


些末なことですが、 リポジトリとかモノレポとか、repositoryのカタカナ表記がブレるの少し変ですね。でもこのまま行きます。

どう変わったのか

ここではメニーレポからモノレポになることでどう変わったのか、before/afterを記載します。

before: メニーレポ

フレームワークや変更対象、管理するチームやプロジェクトに応じてリポジトリを作っていました。数年間のInfrastructure as Codeの結果、そのリポジトリ数はなかなかの数となっていました。 そのため、1つの目的の実現には複数のリポジトリへの変更が必要でした。


たとえば、新たなサブネットをAWSに作成し、そこにEC2を作り、それに対するオンプレからのルートを作る場合:

  • サブネットとEC2を作るTerraformリポジトリへの変更
  • ルーティングを追加するネットワーク機器用Ansibleリポジトリへの変更
  • そのEC2にアクセスするホストの設定を変更するためのAnsibleリポジトリの変更

というように、3つのリポジトリに変更とプルリクエストが必要でした。

※ ちなみに3つというのはまだ良い方です。


これは、それぞれの関係性が分かりにくい点、レビュワーの指摘で直される対象がプルリクエストをまたぐという点、マージタイミングによって整合性が取りにくくなる点や、順序の問題でコードフリーズが必要になる点など、運用上のややこしさを生み出していました。

何より、多くのリポジトリに変更を加えてプルリクエストをいくつも用意して、それぞれの関係性を説明するだけで一苦労な状態でした。レビュワー側も、積もったプルリクエストのリストを見るだけでうんざりだったことでしょう。


欠点ばかり書きましたが、この頃のメリットを挙げるなら「分かりやすかった」ことです。CIはシンプルに作れましたし、設計思想がリポジトリ毎に違っていても自然でした。部署をまたぐ権限設定やバージョンコントロールなどがシンプルだったのもメリットでした。

after: モノレポ

Infrastructure as Codeに関連するものを1つのリポジトリにディレクトリ違いでまとめたモノレポ構成としました。

$ tree -L 2
.
├── ansible-ubuntu
│   ├── 略
│   └── README.md
├── ansible-hogefuga
│   ├── 略
│   └── README.md
├── 詳細は秘密
└── terraform
    ├── README.md
    ├── 略
    ├── modules
    └── resources

例として挙げているものでも分かる通り、TerraformもAnsibleもその他のものも同じGitリポジトリで管理しています。


その結果、先ほどの例のような更新は1つのリポジトリの中で閉じ、まとめて1つのプルリクエストにできるようになりました。

同一内容のファイルを作成するときは複製だということが分かりやすくなりましたし、アーキテクチャ間をリソースが移動するときは削除と追加が一目で分かるようになりました。

同時期に異なるメンバーによって複数の変更プルリクエストが並走していても、1つの目的の変更が1つのプルリクエストにまとまっているため、関係性や順序、適用・未適用が分かりやすいのがメリットです。

インフラの変更は対象が多岐にわたることが多く、それがひとまとめになることのメリットをとても大きく感じています。Issueを切るにも、さまざまなシステムに対して横断的な記述ができるようになりました。


モノレポ化したデメリットとして、コミットログが長大になり分かりにくくなったという面はあります。リポジトリ(ディレクトリ)間のバージョン齟齬や設計思想の齟齬などにも気を配るようになりました。また、後述しますがCIによるテストなどでは気を遣うようになりました。

今のところ問題は起きていませんが、いつかは同一リポジトリ内で異なる言語のバージョンを利用するようになったりして混乱の元になるかもしれません。

また、git cloneすると必要以上に大量のコードが複製される点も、権限管理の面では注意事項だと言えます。

運用のために工夫したこと

パス毎にテストを組む

モノレポの全プルリクエストに対して全てのテストを行うのは勿体無い行為です。

特に、Terraformのような重いテストは、Terraformへの変更時のみ実行すれば十分でしょう。増してやREADMEの更新くらいでテストを行うのは、時間もお金も無駄になりますし、テスト結果が見難いですし、無駄にリソースを使って地球に優しくないし、半導体不足を加速するし、だからPS5も入手困難だし・・・つまり面倒だなという気持ちで更新を怠ることに繋がります。


GitHub Actionsは発火条件をパス毎に定義できるため、このモノレポでは paths パラメータを利用してテストを分割しています。Terraformの変更なら terraform planを、Ansibleの変更なら ansible-lint を実行するというような具合です。

Terraformの変更に対するGitHub Actions Workflowの例:

name: "Terraform Test - Production"

on:
  pull_request:
    paths:
      - '.github/workflows/terraform-test-production*'
      - 'terraform/modules/**'
      - 'terraform/resources/production/**'
      - '!**/README.md'
      - '!**/.gitignore'

上記の例では、Terraform内でもパス毎に異なるテストをしていることが示されています。 resources/production の変更に対してproductionのテストをするのはもちろんですが、modules(環境非依存のモジュール)の更新に関してもproductionのテストを行うということになります。また、このGitHub Actions Workflowの定義が変更された場合もテストするように設定されています。

さらには、negative pattern ( ! で始まるパターン)も定義することで、テストする必要がない変更に対してはテストを行わないようにしています。上記の例では README.md.gitignore に対する変更はテストしないという発火条件としています。

このモノレポでは、このようにテストの時短と分かりやすさを実現しています。

マージ前に最新化を必須とする

GitHubのブランチ保護ルールには Require branches to be up to date before merging というものがあります。マージをするためには、マージ先ブランチの最新コミットを取り込む必要がある、というものです。

このリポジトリでは、CIによるテストとして、例えばTerraformの変更のプルリクエストには terraform plan の結果を付与して、 terraform apply によって起こりうる変化をレビュワーが確認しやすいようにしています。

しかし、プルリクエスト作成から時間が経つと、他の変更によって差分が生じることがあります。この時のリスクを低減するための保護設定というわけです。


しかし、この設定の位置が困り物で、有効化するには Require status checks to pass before merging の保護設定も有効化しなければなりません。

ブランチ保護設定

この status checks とは、つまりはCIのテストのことです。先ほどの例なら、テストとしてterraform planしてるから、それを status checks として指定したら良さそうに思えますよね?でもそうじゃないんですよ。


実は、成功させるべきCIのテストは、リポジトリで共通の設定 にしなければなりません。

つまり、Ansibleに対する変更でも、Terraformに対する変更でも、はたまたREADMEの変更ですらも、同じテストを行なって結果を『成功』にしないといけません。

これは上記のパス毎に異なるテストの設計と相反します。


結局どうしたか。

必ず通さなければならないテストとして、ダミーテストを組みました。

name: "Test - Dummy Test"

on:
  pull_request:
    branches:
      - main

jobs:
  test:
    name: "Dummy Test"
    runs-on: ubuntu-latest
    steps:
      - name: Do nothing
        run: exit 0

必須のテストはダミーテストなので、ブランチの保護ルールとしては機能しません。必須以外のテストは上述の通りパスに応じて適したものが発火しており、視覚的には、失敗したテストがあればChecksの欄に赤色が灯るので気付くことができます。でも、無視してマージできてしまいます。

この議論はGitHubコミュニティでもされており、多少難ある解決策も示されています。オフィシャルにもいつかは対策がされることでしょう。それまでは、残念ながらリスクがある運用とせざるを得ません。

変更内容に応じたLabelを付与する

GitHub ActionsにはlabelerというActionがあり、条件に応じてLabelを付与することができます。

このモノレポでは、プルリクエストに対して以下のようなルールで自動的にLabelを付与しています。


Label付のルール(.github/labeler.yml)の抜粋:

gha:
  - any:
    - '.github/**'

ansible-ubuntu:
  - any:
    - 'ansible-ubuntu/**'
    - '!**/README.md'

terraform:
  - any:
    - 'terraform/**'
    - '!**/README.md'

documentation:
  - any:
    - '**/*.md'

GitHub ActionsのWorkflow:

name: Add Labels

on:
  pull_request:

jobs:
  add_labels:
    name: Add Labels
    runs-on: ubuntu-latest
    timeout-minutes: 3

    steps:
      - uses: actions/labeler@v3
        with:
          repo-token: "${{ secrets.GITHUB_TOKEN }}"
          sync-labels: true


このLabelは、プルリクエストの変更対象の視認性を良くする意味もあります。

プルリクエストに自動付与されたラベル

異なるフレームワークのコミットが入り混じるモノレポだからこそ、一覧にしたときにわかりやすいというのはメリットとなります。


それともうひとつ、以下のような自動生成リリースノートで利用する目的もあります。

.github/release.ymlへの記載:

changelog:
  categories:
    - title: Changes in terraform
      labels:
        - terraform
    - title: Changes in ansible-ubuntu
      labels:
        - ansible-ubuntu
    - title: Changes in xxxx
      labels:
        - xxxx
    - title: Other Changes
      labels:
        - "*"

この機能により、Labelを付けておくとリリースノートを作るときにもわかりやすくて楽であるというメリットとなります。

統合時にhistoryを引き継ぐ

統合作業の直前に知ったのですが、異なるGitリポジトリであっても、手順を気をつければ、historyを引き継いで統合することが可能でした。

このため「モノレポ化するなら過去の歴史は捨てないといけない」と嘆く必要はありませんでした。おかげで、この数ヶ月でできたリポジトリながら、過去からの積み重ねでコミット数は2500を超えています。

もちろん持って行けるのはコミットログのみで、プルリクエストの議論の歴史は捨ててしまうことにはなるのですが。


以下に参考までにhistoryの統合方法をメモしておきます。


マージ先のGitリポジトリで準備をします。

$ cd {{マージ先のGitリポジトリ}}

### 新しいブランチを切る
$ git checkout -b 'xxxx'
 
$ mkdir {{格納先ディレクトリ}}
$ touch {{格納先ディレクトリ}}/.gitkeep

### 一度Commit
$ git add {{格納先ディレクトリ}}
$ git commit -m 'create xxx directory'

リモートブランチとして元のGitリポジトリをgit configに設定します。

$ git remote add tmprepo {{元のgitリポジトリ}}
$ git fetch --all

格納先ディレクトリをsubtreeに指定してhistoryをマージします。

$ git merge --allow-unrelated-histories  -X subtree={{格納先ディレクトリ}} tmprepo/main

コミットの状態を確認します。

$ git log

問題なければgit configを整理しておきましょう。

$ git remote rm tmprepo

あとはPUSHしてプルリクエスト&マージします。

マージするときにsquashすると台無しになるので、squashしないように気をつけましょう。

最後に

本記事ではインフラグループのGitリポジトリのモノレポ化の事例の話をしました。

我々の管理するGitリポジトリはまだまだたくさんあり、それをどこまでひとまとめにするのかという点では、まだ議論が続いています。例えば、アプリケーションのコードまで統合するかというと、現時点の感触ではNoかなと思っているところです。一方で、まとめることのメリットもわからなくはありません。本当の意味で”全リポジトリをモノレポ化する”のは、相当な覚悟が必要に感じています。

本記事で伝えたいのは、GitHubにおいてGitHub Actionsを使ったCIをする分には、モノレポ化は簡単で現実味があること、我々は感触として上手くいっているように感じていること、です。リポジトリ構成で悩んでいるチームへの1つの情報となれば幸いです。