見出し画像

Now in REALITY Tech #90 サーバサイドのリポジトリをmonorepoにした話

 こんにちわ!REALITYのサーバエンジニアの落合と申します。
 最近はめっきり寒くな…ってないですね。むしろ寒くならなくて困っているまであります。昼夜の寒暖差、きついです…もう寒くなってくれ…という思いです。まあ趣味のキャンプをするにはいい季節なのですが、それにしてももう少し寒くなってきた方が服装なども選びやすくなるというものです。

 さて今回は、最近、サーバサイドのリポジトリをmonorepoでまとめた話をしようと思います。

monorepoとは

 monorepoとは、サービスやアプリケーションに必要な全てのコードを1つのリポジトリに保存することを指します。
 これ自体はなんら特別なことではないですよね。
 VCSでコードを管理している会社では、全社で1つのリポジトリというところも昔(特にsubversionやCVSとかの時代)は多かったと思います。
 monorepoの対としてmultirepo(polyrepoとも言います)という管理方法があります。こちらのほうが現在ではより一般的かと思います。

polyrepo vs monorepo

 通常、なにかのプロダクトやサービスを作ろうとした時、各言語や、サーバや、クライアントアプリごとにレポジトリを作ります。
 これはリポジトリのrootからファイルを配置した時の管理のしやすさや、CI/CDによるコミット検知による処理のしやすさ、管理上そのリポジトリを特定のチームが管理していた場合に権限管理がしやすいなどさまざまなメリットがあります。
 またプログラマーとしては自分が触らない・実装に関係ないチームや言語のコードが同じリポジトリに含まれているというのは単純に面倒さがあります。なので、チームや言語ごとに分かれたmultirepoから始まるのが一般的かと思います。
 ただ、最初はこれでいいのですが、サービスやプロダクトが育ってくると、multirepoは管理上の問題を引き起こします。
 リポジトリというのは基本的には増えていくだけで、何もしなければ減るということはありませんから、新たなツールを作ったらリポジトリを作る、新しいサーバが立ったらそのコードのリポジトリを作る…という風にリポジトリというのは増えていきます。
 特にサーバサイドでmicroserviceな構成を採用している場合や、クライアントサイドで細かくpackageやlibraryをリポジトリで分けて管理していると数年もするとリポジトリの数は十数〜数十へと肥大化していきます。
 1つの修正を行うのに、リポジトリごとに修正が別れてしまって十数個のpull requestが作成されることも珍しくないでしょう。作業の煩雑さは馬鹿にできないものになってきます。
 こうなると、皆、考えるようになります。リポジトリが1つだったらこんな面倒な、開発とは関係ない作業から解放されるのに…と。
 そうしてmonorepoが必要となってくるわけです。
 ちなみに、monorepoはあくまでコードの管理を1つのリポジトリにまとめる、と言う話なので、microserviceでサーバごとにリポジトリが別れていると言う場合に、monorepoにしたからといってサーバもまとめるとかの話ではありません。

monorepo化の前の状況

 さて弊社ではどんな状況だったのかと言いますと、前章で書いた通りのことが起きていました。
 REALITYもサーバサイドではmicroservice的にサーバを分けていました。
 REALITYのサービス開始当初、サーバはAPIサーバが数個、ツールやwebサイト用のサーバが数個、という風にせいぜい両手の数で数えられる程度でした。
 また、APIを修正するときなども、サーバが少なかったため、1つの修正で1リポジトリのpull requestしかないということも多かったです。
 時間は経ち、現在(数ヶ月前ですが)のREALITYではどうだったかというと、

  • 12個のAPIサーバ

  • 4つのwebsocketサーバ

  • いくつかのwebサーバ

  • いくつかのfunctions

  • 内部向けのツール

  • k8sやTerraformなどのインフラ定義を管理

 などなど…これらが全て別リポジトリになっていました。
 サーバサイドだけでもこの状態です。
 こうなってくると、以下のような問題がでてきました。

  • 1つの修正のために1pull requestということはほとんどなく、大抵は数個、全体に関わるような修正の時にはpull requestが十数個となる。

  • 修正がいくつものpull requestに分かれるため、修正の全体像を把握するのが大変。

  • システム全体に関わるような変更をするときに、多くのリポジトリを触らなくてはならなくなる。

  • リポジトリの権限変更なども面倒。細かく権限を管理できるともいえるが、似たような設定のリポジトリが大半のため、単純に作業が増える。

  • CI/CDの定義の管理もリポジトリ別になっているため、定義の管理・修正が面倒。

 またサーバごとにmergeやdeployタイミングも気をつけなくてはならず、かつそれをチーム開発で調整しながら行うとなるとなかなか大変です。
「リポジトリをまとめたい!減らしたい!」という圧はどんどん強くなり、monorepo化することに決まりました。

どのようなmonorepoにするのか

 とはいえ、monorepoにすると一言に言っても、どのようなgoalにするのかは考えなくてはなりません。
 monorepoを調べていくと他社の事例では、プロダクトの全て(サーバ、クライアントも含めすべて)を1つにまとめていたりもするようです。

 気をつけなければならないのは、monorepoはメリットだけでなく当然デメリットもあることです。先にも書いた通り、リポジトリは分けておいた方が、権限の管理がきめ細かくできたり、あやまって関係ないコードに手が入ってしまったりのリスクを排除できます。
 また、CI/CDはクライアントアプリやサーバアプリでは全然別になるため、分けておいたほうがメリットあることも多そうです。
 なので、いきなりプロダクトに関わる全てのコードをまとめる!とかはせず、課題解決に必要なリポジトリのみまとめることにしました。
 ポイントとしてはサーバのgolangリポジトリのみをターゲットとしたところでしょうか。
 node.jsのサーバなどもある中でgolangのみとしたのは、golangのサーバはmicroservice構成でAPIサーバを分けていたため、1つの修正でAPIを作った時とかでも複数のサーバ(リポジトリ)を触らねばならないこと(共通化されたpackage、APIのrequest側とresponse側)で特にリポジトリが複数あることで作業が増えていたためです。逆にいえばそれ以外はまとめてもそれほどメリットはないためです。
 新しいリポジトリの構成は以下のようにしました。
 

.
├── go                        # goコードを管理するためのディレクトリ
│   ├── lib                   # goのserviceで共通化されたコードを管理するためのディレクトリ
│   │   └── common            # 共通packageを展開する
│   │       └── ...
│   └── services              # goのserviceごとのコードを配置するディレクトリ
│       ├── service1          # service1のコードを展開するディレクトリ
│       │   └── ...
│       ├── service2          # service2のコードを展開するディレクトリ
│       │   └── ...
│       └── service3          # service3のコードを展開するディレクトリ
│           └── ...
└── tools                     # 各種toolの置き場
    └── ...

 今回はgolangのリポジトリのみを取り込みますが将来的に別言語のリポジトリを取り込んだ時に見通しをよくするために、root直下にはgoというディレクトリを置いています。

golang特有の問題

 monorepo化するということは、リポジトリのpathも変わると言うことですが、golangにおいては1つ他の言語にはない対応が必要になります。
 golangでは、アプリケーションで外部のpackageを使うときに、ファイルごとにpackageのimportパスを記述する必要があります。

package main

import (
	"github.com/alecthomas/log4go"
	"github.com/gorilla/mux"
)

func main() {
    ...
}

 お気づきかと思いますが、これはコードを管理するリポジトリが変わった場合は別packageと認識されますので、全て変更しなくてはなりません。
 つまり、社内のgolang package全て、です。
 今回のように新しいリポジトリに変わった場合、それを取り込んでいるファイルは全て書き換える必要があります。
 これはなかなか面倒ですが、言語仕様なので仕方ありません。
 ただ、このimport文の変更を、取り込んだブランチ全てで行うと、元のブランチのコード差分とは別に、全ブランチで機能的な修正とは関係のない変更が大量に発生してしまいます。
 この無用な修正が履歴に入らないようにするために、今回はまずベースとなるdevelopブランチを取り込み、そちらでimport文を変更してから、featureブランチに古いリポジトリからcommitのみを取り込むようにします。

作業の流れ

 まず新しく作ったリポジトリに古いリポジトリを全てremote登録します。
 古いリポジトリのremote名にはわかりやすいようにprefixなどつけると良いでしょう。
 これからremote名は何度も使うので、originとは間違えないprefixがよいですね。

cd reality-demo-services
git remote add repo-common https://github.com/reality-demo/common.git
git remote add repo-service1 https://github.com/reality-demo/service1.git
git remote add repo-service2 https://github.com/reality-demo/service2.git

これで以下のようにgit/configには登録されます。

[remote "repo-common"]
    url = https://github.com/reality-demo/common.git
    fetch = +refs/heads/*:refs/remotes/repo-common/*
[remote "repo-service1"]
    url = https://github.com/reality-demo/service1.git
    fetch = +refs/heads/*:refs/remotes/repo-service1/*
[remote "repo-service2"]
    url = https://github.com/reality-demo/service2.git
    fetch = +refs/heads/*:refs/remotes/repo-service2/*

ここでfetch --allすると、remote追加した全てのリポジトリのブランチがfetchされます。

git fetch --all

 さて、ここからmonorepoとして取り込むために、1ブランチごとに作業を行なっていきます。
 まずは古いリポジトリの1つcommonのdevelopブランチです。
 developブランチを後から取り込むfeatureブランチのベースにしたいため、developが最初になります。

git merge repo-common/develop --allow-unrelated-histories  -s ours --no-commit
git read-tree --prefix=go/lib/common -u repo-common/develop

 repo-common の develop ブランチからmergeします。merge先は今いるブランチです。
 allow-unrelated-histories は違うリポジトリの履歴の取り込みを許可する指定です。これがないとエラーになります。
 -s はmergeストラテジーの指定です。 ours は現在いるブランチの変更を一切無視して取り込む戦略です。developブランチなので、そのまま取り込む指定をしています。今回の場合のように、monorepoで変更の取得の方向がはっきりしている場合はこれでも問題はありません。
 取り込みが終わったらcommitします。

git commit -m "merged common develop into go/lib/common"

 これで1つのブランチ(develop)の取り込みが完了しました。
 git logで履歴を見てみると、古いリポジトリの履歴が取り込まれているのがわかるはずです。

    *   commit aaaaa (HEAD -> develop)
    |\  Merge: bbbb cccc
    | | Author: hoge
    | | Date:   Tue Aug 22 11:53:55 2023 +0900
    | |
    | |     merged common develop into go/lib/common
    | |
    | *   commit ddd (repo-common/develop)
    | |\  Merge: eee fff
    | | | Author: hogehoge
    | | | Date:   Mon Aug 21 17:20:25 2023 +0900
    | | |
    | | |     Merge pull request #1974 from reality-demo/feature/update
    | | |
    | | |     update makefile

 さて次にdevelop以外のブランチも取り込んでみます…と言いたいところなのですが、golangの場合は先にimport文の変更を行っておいた方がいいでしょう。
 前述の通り、golangは各golangファイルに記載されているimport文をリポジトリ変更により書き換えないとなのですが、これを各ブランチでやると各ブランチごとにリポジトリのimport文変更の差分が履歴に入ってしまいます。
 本来のブランチの変更だけを残したいので、これはブランチの履歴には邪魔です。
 先にdevelop側でimport文を書き換えて、そこから派生したブランチに、古いリポジトリからの変更を取り込むことで、最小限のimport変更の差分だけで済むようにします。
 書き換え方法は、別になんでもいいのですが、ここではperlのワンライナーで書き換えます。

find . -name "*.go" -print | xargs perl -pe 's;github.com/reality-demo/common;github.com/reality-demo/rea-services/go/lib/common;g' -i

 取り込んだファイルにgo.modファイルがある場合は、package名も変更してcommitします。

go mod edit -module github.com/reality-demo/rea-services/go/lib/common
git add .
git commit -m "update"

 同じように他の古いリポジトリのremoteのdevelopブランチからも取り込みを行います。コマンドはまとめるとこんな感じです。

git merge repo-service1/develop --allow-unrelated-histories  -s ours --no-commit
git read-tree --prefix=go/services/service1 -u repo-service1/develop
git commit -m "merged service1 develop into go/services/service1"
cd go/services/service1
find . -name "*.go" -print | xargs perl -pe 's;github.com/reality-demo/common;github.com/reality-demo/rea-services/go/lib/common;g' -i
go mod edit -module github.com/reality-demo/rea-services/go/services/service1
git commit -m "update"

 ここまでやったら、developをベースとして、古いリポジトリから各ブランチを取り込んでいきます。
 ブランチの変更の取り込みは subtree merge という方法を使います。
 ここでは詳細は書きませんが、文字通りmergeの一種で、先ほど取り込んだ時とはまた別のストラテジー(merge戦略)です。
 subtree mergeはoursと違って通常のmergeと同じような取り込みが行われるため、コンフリクトが発生する場合はmergeが停止しますので注意してください。

git checkout -b feature/update
git merge --allow-unrelated-histories -s subtree repo-common/feature/update
git commit -m "subtree merged common featrure/update"

 このsubtree mergeをブランチごとに行っていきます。
 上記の例では、取り込んでいるのは3つほどのリポジトリですが、弊社の実作業では12ほどのリポジトリを取り込んだため、総ブランチ数は100を超えていましたので、作業にはあらかじめ上のコマンドを実行するバッチを作りました。

Github actionsの変更

 それ以外だと、github actoinsの変更も行いました。
 Github actionsについては、pull_request.paths指定で、特定のパス配下を監視し、lint & testを起動するように変更しました。

name: lint server
on:
  pull_request:
    paths:
      - 'go/services/**'
      - 'go/lib/common/**'
    types:
      - opened
      - synchronize
      - reopened
      - labeled

 また、lint対象となるコードも、元々はリポジトリの全てのコードを対象としていましたが、同じままでは修正したサーバとは関係ないコードのlintまで走ってしまいますので、変更内容から正規表現で関係あるサーバ名をとりだし、そのサーバ名の一覧をmatrixで他のjobに受け渡して、サーバ別のlintを並列job起動しています。
 以下は抜粋したactionsの定義です。

  setup:
    runs-on: ubuntu-latest

    outputs:
      go-target-services: ${{ steps.outputs-target-services.outputs.value }}

    steps:
      - name: Checkout to pull request
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Fetch base branch
        run: git fetch origin ${GIT_BASE_BRANCH}:${GIT_BASE_BRANCH}

      # base branchから更新のあったファイルから対象のserviceを正規表現で取り出して、outputsする
      - name: Run actions using diff service
        id: outputs-target-services
        run: |
          diff_files=$(git diff --name-only HEAD ${GIT_BASE_BRANCH} -- "go/services/**" | tr "\n" " ")
          declare -A matching_directories
          regex="go/services/([^/]+).*/"
          for file in $diff_files; do
            if [[ $file =~ $regex ]]; then matching_directories["${BASH_REMATCH[1]}"]=1; fi
          done
          i=0
          output=""
          for matched_directory in "${!matching_directories[@]}"; do
            if [ "${i}" != "0" ]; then output=${output}","; fi
            output=${output}"\"${matched_directory}\""
            i=$((i + 1))
          done
          list=$(echo "[${output}]" | jq -c)
          echo "value=${list}" >> $GITHUB_OUTPUT

  lint:
    needs: setup

    runs-on: ubuntu-latest

    strategy:
      matrix:
        go-target-service: ${{fromJson(needs.setup.outputs.go-target-services)}}

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - uses: ./.github/actions/setup
        with:
          token: ${{ secrets.GHEC_MACHINE_ACCESS_TOKEN }}
          working-directory: ./go/services/${{ matrix.go-target-service }}
          go-target-service: ${{ matrix.go-target-service }}

      - name: golangci-lint
        uses: golangci/golangci-lint-action@v3
        with:
          working-directory: ./go/services/${{ matrix.go-target-service }}

結果

さて結果としてはどうなったか。事前に感じていた課題を元にお話すると、

・1つの修正のために1pull requestということはほとんどなく、大抵は数個、全体に関わるような修正の時にはpull requestが十数個となる。
・修正がいくつものpull requestに分かれるため、修正の全体像を把握するのが大変。

 これについては見ていただくとわかるのですが、monorepo化前は多い時では以下のように、pull requestが13にも分かれることがありました。

pull request多すぎだろの図

 それが今では1つだけになり、pull requestが減り、修正内容が集約されたので見通しはよくなりました。

pull requestが1つになった図


・リポジトリの権限変更なども面倒。細かく権限を管理できるともいえるが、似たような設定のリポジトリが大半のため、単純に作業が増える。

これについてはそれほど頻繁にいじるものではないのですが、monorepoの作業を進める中でリポジトリの設定を見直していたところ、結構想定していない差異があり、リポジトリが多すぎでそもそもちゃんと管理できてなかった…ということに気づいてしまい(すいません!)ましたが、今後は1つになったのでちゃんと管理できるかと思います。

・CI/CDの定義の管理もリポジトリ別になっているため、定義の管理・修正が面倒。

これは前章でactionsの定義についてお見せした通り、1つのactionsの定義で全サーバのlint, testが回せるようになりましたので、リポジトリごと(サーバごと)にいちいち似たような定義を書く必要がなくなりました。これも解決できたかと思います。deployについてはまた別の定義がありそちらは割愛しましたが、1つのcloudbuild定義に集約されたので管理しやすいようになったかと思います。

・サーバごとにmergeやdeployタイミングも気をつけなくてはならず、かつそれをチーム開発で調整しながら行うとなるとなかなか大変です。

ここは感覚値になりますが、上記したように大きな開発でpull requestの数が十数とある時と違い、pull requestが1つになったので、互いにどのタイミングでどのpull requestがmergeされるのかはわかりやすくなったかと思います。

また以下のような感想もチームメンバーからはもらいました。

  • サーバ間通信を行うAPIを修正する時に、複数のサーバのリポジトリを切り替えて作業する必要がなくなった。

  • microserviceの全コードが1つのリポジトリにあるので、コードの見通しが良くなった。

  • サーバ全体でのリファクタリングや、共通部分の変更がしやすくなった。

 必要最低限の部分のみ統合したこともあり、今のところ目に見えた弊害も起きておらず、うまく移行できたのではないかと思います。

まとめ

 今回はサーバのgolangリポジトリのmonorepo化について紹介しました。
 monorepo化については、弊社だけでなくいろんな会社の方が紹介しており、様々な方法があるかと思います。
 今回は特にgolangについてでしたが、言語によって方法もディレクトリ構成も変わるかと思います。
 もしmonorepo化に取り組んでみたいと思っている方がいたら、弊社の記事だけでなくネットのいろんな記事を読んで、自分たちに合う方法を選択するといいかもしれません。