モジュール数 100+ の Go Monorepo の CI を改善した話
株式会社ナレッジワーク SRE の tapih(@_tapih) です。
本記事では、 Go Monorepo の CI を最適化し、インスタンスコストをピーク時から50%以上削減できた具体的な事例をご紹介します。
本記事で得られる点は以下の通りです。
- Go Monorepo CI の事例を学ぶこと
- GitHub Actions の Tips を学ぶこと
- 施策の進め方・考え方を学ぶこと
背景
弊社ではバックエンドの言語に Go を採用しており、以下の特徴を持つ Monorepo で開発を行っています。
- モジュール数 100+ のマルチモジュール構成
- モジュール間のローカルでの依存関係を
go.mod
のreplace
ディレクティブで解決 (go work
の依存解決機能は未使用) - Go 以外のコードも同じ Monorepo 内で管理
弊社では、各モジュールごとに CI のワークフロー定義を自動生成し、on.pull_request.paths
で必要な CI のみを起動する方式を採用していました。具体的には、 go.mod
の replace
ディレクティブを利用して依存関係を解析し、ローカルで直接依存しているモジュールを on.pull_request.paths
に指定していました。 Pull Request(以降、 PR) にコードがプッシュされると GitHub Actions 側で実行するワークフローのチェックが実行され、必要な CI のみが起動するイメージです。
課題
CI インスタンスコストの増加
まず発端となったのは、 GitHub Actions の GitHub Hosted Runner のインスタンスコストが大幅に増加したことでした。 Monorepo では、一つのコミットで実行される CI が多くなり、実行時間が長くなりがちです。さらに、開発者の増加に伴い、CI の実行頻度も上がり、コストは徐々に膨らんでいました。結果、設定した Budget Limit にある時期を境に頻繁に到達するようになり、改善の必要性が一気に高まりました。
コスト増加による悲鳴
GitHub Actions では、ワークフロー実行時にジョブ単位で Runner が割り当てられます。従って、例えば 10 のモジュールに対してテストとリントのジョブをそれぞれ定義している場合、合計 20(= 10 × 2)個の Runner が必要になります。また、 GitHub Hosted Runner は 利用時間を 1 分単位で切り上げて課金されます。
今回の施策に関係するインスタンスコスト増加の原因は以下の 3 点です。
- CI 環境構築の処理 (例: Go SDK のインストール) がジョブの数と同数実行されていて無駄が大きい
- Cache API Call の並列数の増加により 429 が頻発 & キャッシュ容量制限 超過によりキャッシュが無効に
- 直接コードを変更していないモジュールに対してもリントを全部実行していて無駄が大きい
CI 待ち時間の増加
CI 実行対象となるモジュール数が多い PR では多数の Runner のインスタンス確保が同時に走ります。結果、 Runner 確保のキューが混雑してしまい、 CI 開始・完了までの時間がのびる問題が発生していました。
CI 実行状況把握の認知コストの増加
GitHub Actions の YAML がモジュールごとに分かれていてワークフローが別々に実行されており、どの CI が起動しているのか / していないのかを一目で把握しづらいという課題がありました。
- 同時に起動するワークフローが多いと GitHub Actions の Web UI 上でワークフローが一気に埋まってしまう
- GitHub Actions の Web UI の左サイドバーに大量の Go CI が表示されてしまい、目的の CI を探すのに何度もスクロールしなければならない
方針
現在、Platform & SRE Group は 3 名体制(うち 1 名はプレイングマネージャ)で運営されており、開発・運用リソースは限られています。まずは Goals / Non Goals を整理した上で施策の大枠を決定しました。
Goals
- CI の並列数を制御する仕組みをミニマルに実装する
- CI の実行時間・失敗率(≒SLO)を低運用コストで維持し、開発生産性を保つ
Non Goals
- 高機能な技術を導入し、コストを増やして解決すること
本施策の完了がスタートではなくゴールに
特に気を付けたのは「本施策の完了がスタートではなくゴールになる」ことでした。大規模 Monorepo の CI では Bazel や Nx を活用する選択肢もありますし、インスタンスコスト削減に特化するなら Self Hosted Runner も有力です。一方で、これらのパワフルな技術の導入は組織内に別の課題を生むリスクも伴います。ミニマルに問題解決をすることで、施策完了後にやることが極力少なくなっている状態を目指しました。
関連して、 Go のレイヤには極力踏み込まないこととしました。 Go のビルド・テストフローにまで手を入れると関連する仕組みの再設計や他チームとの調整が必要となり、スコープが拡大してしまいます。CI に閉じた形で施策を進めることで、自分たちのリソース内で完結させることを意識しました。
いかにコストのバランスをとるか
次に考えたことは、いかにバランスをとるかでした。
- インスタンスコスト を抑えすぎると、開発者の CI 待ち時間が増加し、結果として 開発コスト が増加する
- CI 待ち時間を短縮するために CI 改善に時間をかけると、自分たちの 運用コスト が増加する
このバランスをとるため、設計段階から CI の実行時間と失敗率に対して非公式な目標値を設定しました。
概要
以下の構成としました。
- ワークフロー設定の自動生成を辞めて CI 実行時に CI を流すべきモジュールを解析
- 変更のあったモジュールを事前に定義した JSON のルールに従ってグループ化
- テストとリントでそれぞれ Workflow Call を実装 & Matrix でテストとリントをそれぞれグループごとに並列実行
- 並列実行される 1 つのジョブの中で複数モジュールを直列実行
- 直接変更がない依存先のモジュールのリントは一部のルールのみを実行
GitHub Actions Workflow Diagram
移行前 | 移行後 | |
---|---|---|
ワークフロー | モジュール毎に 1 つ | モジュール全体で 1 つ |
ジョブ | モジュール毎に 2 つ (テストとリント) | 複数モジュールでジョブを共有 |
依存解析 | Build time | Runtime |
テスト | 依存する全モジュールで実行 | 依存する全モジュールで実行 |
リント | 依存する全モジュールで実行 | 依存するモジュールには一部のルールのみ実行 直接変更のあったモジュールは全ルールを実行 |
詳細
実装したワークフローは大きく以下の 3 つです。複雑な処理は Composite Actions として以下のワークフローとは別で切り出しています。
- 依存解析用のワークフロー:
.github/workflows/pr--golang.yaml
- テスト用の Workflow Call:
.github/workflows/call--golang-test.yaml
- リント用の Workflow Call:
.github/workflows/call--golang-lint.yaml
.github/workflows/pr--golang.yaml
以下、処理の流れがわかるよう抜粋したコードです。
name: PR / Go
on:
push:
branches:
- main
pull_request:
branches:
- main
# 🦏
concurrency:
group: ${{ github.event_name == 'push' && github.run_id || github.workflow_ref }}
cancel-in-progress: true
jobs:
analyze:
name: Prepare
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
test-grouped: ${{ steps.analyze.outputs.test-grouped }}
test-ungrouped: ${{ steps.analyze.outputs.test-ungrouped }}
lint-grouped: ${{ steps.analyze.outputs.lint-grouped }}
lint-ungrouped: ${{ steps.analyze.outputs.lint-ungrouped }}
defaults:
run:
shell: bash
steps:
# 🐦
- name: PR commits + 1
id: commits
if: github.event_name == 'pull_request'
env:
commits: ${{ github.event.pull_request.commits }}
run: echo "fetch-depth=$(( commits + 1 ))" >> "${GITHUB_OUTPUT}"
# 🐦
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
fetch-depth: ${{ github.event_name == 'pull_request' && steps.commits.outputs.fetch-depth || 2 }}
# 🐦
- name: Fetch base (origin/${{ github.base_ref }})
if: ${{ github.event_name == 'pull_request' }}
run: |
git fetch --depth 50 --no-tags --progress origin "+refs/heads/${GITHUB_BASE_REF}:refs/heads/${GITHUB_BASE_REF}"
# 🦍
- name: Get changed files
id: changed
uses: tj-actions/changed-files@dcc7a0cba800f454d79fff4b993e8c3555bcc0a8 # v45.0.7
with:
skip_initial_fetch: true
output_dir: /tmp/changed
write_output_files: true
json: true
files_yaml: |
go:
- '**.go'
- '**/go.mod'
- '**/go.sum'
- '**/.tool-versions'
# 🦍
- name: Analyze diff
id: analyze
uses: ./.github/actions/analyze-golang-diff
with:
input-json: /tmp/changed/go_all_modified_files.json
test:
name: Test
needs: [analyze]
uses: ./.github/workflows/call--golang-test.yaml
secrets: inherit
permissions:
contents: read
packages: read
with:
grouped: ${{ needs.analyze.outputs.test-grouped }}
restore-cache: ${{ github.event_name == 'pull_request' }}
save-cache: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
# 🦖
fail-fast: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
junit-artifact-prefix: junit-test
test-report-xml: test.xml
lint-report-xml: lint1.xml
lint:
name: Lint
needs: [analyze]
uses: ./.github/workflows/call--golang-lint.yaml
secrets: inherit
permissions:
contents: read
packages: read
with:
grouped: ${{ needs.analyze.outputs.lint-grouped }}
restore-cache: ${{ github.event_name == 'pull_request' }}
save-cache: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
# 🦖
fail-fast: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
junit-artifact-prefix: junit-test
lint-report-xml: lint2.xml
postprocess:
name: Post process
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
actions: write
needs:
- analyze
- test
- lint
if: always()
steps:
- name: Download JUnit test results
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
continue-on-error: true
if: ${{ !contains(needs.*.result, 'cancelled') }}
with:
pattern: junit-*
merge-multiple: true
# 🐷
- name: Comment on PR
uses: tapihdev/junit-monorepo-go@aa7ec350310ec061b2145f26f98ac8ceaa31dcd8 # v0.5.2
continue-on-error: true
if: ${{ !contains(needs.*.result, 'cancelled') }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
config: |
test:
title: Test
type: gotestsum
fileName: test.xml
directories: ${{ needs.analyze.outputs.test-ungrouped }}
lint1:
title: Lint1
file: .golangci.dep.toml
type: golangci-lint
fileName: lint1.xml
directories: ${{ needs.analyze.outputs.test-ungrouped }}
lint2:
title: Lint2
file: .golangci.toml
type: golangci-lint
fileName: lint2.xml
directories: ${{ needs.analyze.outputs.lint-ungrouped }}
- name: Fail if any job failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
./.github/workflows/call--golang-test.yaml
以下、同じく処理の流れがわかるよう抜粋したコードです。 ./.github/workflows/call--golang-lint.yaml
は大体同じため割愛します。
name: Go / Test
on:
workflow_call:
inputs:
grouped:
type: string
required: false
default: '[]'
description: |
JSON string of grouped directories
Example:
```
[{"name":"group1","directories":"go/app1,go/app2"},{"name":"group2","directories":"go/app3"}]
```
fail-fast:
type: boolean
required: false
default: false
description: "fail fast if true"
restore-cache:
type: boolean
required: false
default: true
description: "restore cache if true"
save-cache:
type: boolean
required: false
default: false
description: "save cache if true"
junit-artifact-prefix:
type: string
required: true
description: "prefix for artifact name of JUnit xml"
test-report-xml:
type: string
required: false
default: "test.xml"
description: "test report xml file"
lint-report-xml:
type: string
required: false
default: "lint.xml"
description: "test report xml file"
env:
HOME_DIR: /home/runner
# 🦁
GOLANGCI_TOML: .golangci.dep.toml
jobs:
main:
# 🐝
name: ${{ matrix.name }}
runs-on: ubuntu-latest${{ contains(matrix.name, '[L]') && '-l' || '-m' }}
if: inputs.grouped != '' && inputs.grouped != '[]'
timeout-minutes: 15
permissions:
contents: read
packages: read
strategy:
fail-fast: ${{ inputs.fail-fast }}
# 🪸
matrix:
include: ${{ fromJson(inputs.grouped) }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install tools
uses: ./.github/actions/install-tools
- name: Setup infrastructure
uses: ./.github/actions/setup-infra
- name: Read golang-version
uses: ./.github/actions/get-tool-version
id: golang-version
with:
dir-path: .
tool-name: golang
- name: Setup Go ${{ steps.golang-version.outputs.version }}
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version: ${{ steps.golang-version.outputs.version }}
cache: false
- name: Set vars
id: vars
env:
group_name: ${{ matrix.name }}
run: |
echo "group-name=$(echo "${group_name}" | tr "/" "_")" >> "${GITHUB_OUTPUT}"
- name: Fetch GitHub App Token
uses: actions/create-github-app-token@67e27a7eb7db372a1c61a7f9bdab8699e9ee57f7 # v1.11.3
id: fetch-token
with:
app-id: ${{ vars.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
# 🦄
- name: Restore mod cache
id: restore-mod
if: inputs.restore-cache || inputs.save-cache
continue-on-error: true
uses: ./.github/actions/artifact-cache-restore
with:
working-directory: ${{ env.HOME_DIR }}
github-token: ${{ steps.fetch-token.outputs.token }}
branch: main
lookup-only: ${{ (!inputs.restore-cache && inputs.save-cache) && 'true' || 'false' }}
key: |-
${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
runner.os,
runner.arch,
'golang',
steps.golang-version.outputs.version,
'mod',
steps.vars.outputs.group-name
) }}
# 🦄
- name: Restore test cache
id: restore-test
if: inputs.restore-cache || inputs.save-cache
continue-on-error: true
uses: ./.github/actions/artifact-cache-restore
with:
working-directory: ${{ env.HOME_DIR }}
github-token: ${{ steps.fetch-token.outputs.token }}
branch: main
lookup-only: ${{ (!inputs.restore-cache && inputs.save-cache) && 'true' || 'false' }}
key: |-
${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
runner.os,
runner.arch,
'golang',
steps.golang-version.outputs.version,
'test',
steps.vars.outputs.group-name
) }}
# 🦍
- name: Run tests one by one
uses: ./.github/actions/go-test-lint
with:
test: true
lint: true
directories: ${{ matrix.directories }}
fail-fast: ${{ inputs.fail-fast }}
test-xml-file: ${{ inputs.test-report-xml }}
lint-xml-file: ${{ inputs.lint-report-xml }}
golangci-toml-path: ${{ env.GOLANGCI_TOML }}
# 🐷
- name: Upload JUnit test results
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
if: success() || failure()
timeout-minutes: 1
with:
name: ${{ inputs.junit-artifact-prefix }}-${{ steps.vars.outputs.group-name }}
path: |
**/${{ inputs.test-report-xml }}
**/${{ inputs.lint-report-xml }}
# 🦄
- name: Save test and lint cache
uses: ./.github/actions/artifact-cache-save
if: inputs.save-cache
continue-on-error: true
timeout-minutes: 3
with:
github-token: ${{ steps.fetch-token.outputs.token }}
working-directory: ${{ env.HOME_DIR }}
key: |-
${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
runner.os,
runner.arch,
'golang',
steps.golang-version.outputs.version,
'test',
steps.vars.outputs.group-name
) }}
path: |-
./.cache/go-build
./.cache/golangci-lint
existing-cache-url: ${{ steps.restore-test.outputs.url }}
# 🦄
- name: Save mod cache
uses: ./.github/actions/artifact-cache-save
if: inputs.save-cache
continue-on-error: true
timeout-minutes: 3
with:
github-token: ${{ steps.fetch-token.outputs.token }}
working-directory: ${{ env.HOME_DIR }}
key: |-
${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
runner.os,
runner.arch,
'golang',
steps.golang-version.outputs.version,
'mod',
steps.vars.outputs.group-name
) }}
path: |-
./go/pkg/mod
existing-cache-url: ${{ steps.restore-mod.outputs.url }}
引き続き、コード内コメントに従って解説していきます (Ctrl+F -> 🦍 のようにすると該当のコードに飛ぶことができます)。
🦍 CI 対象のモジュールをグループ化 -> 直列で CI 実行
前段の tj-actions/changed-files
によって Git Diff によるコード差分が得られます。また、 Go モジュールの依存関係マップを go.mod
の replace
ディレクティブで DFS して構築します。このマップにコード差分を適用することで間接的に変更のあったモジュールを取得します。
-
tj-actions/changed-files
により直接変更のあったファイルの一覧を取得 - 各ファイルパスを上に辿っていき
go.mod
が存在するモジュールのルートディレクトリパスの一覧を取得 (例:product/example/main.go
->product/example
) - レポジトリ内の
go.mod
のreplace
を解析し、依存関係のマップを構築 - 2 のディレクトリパスを 3 の依存関係のマップに入力し、間接的に変更のあったモジュールを抽出
次に複数モジュールをグルーピングします。事前に JSON に定義した正規表現 match
のリストを順に処理し、最初にマッチした group
にモジュールを分類します。
以下の例だと product/example
と middleware/example
に属するモジュールは example [L]
というグループにまとめられます。また、 CI Skip したいグループには <skip ci>
というグループ名をつける、インスタンスサイズを変えたいグループには [L]
をグループ名に含む、変更時にどのようなグルーピングがされるかを確認するための簡易なスクリプトも用意する等により、 JSON 側の変更だけである程度の CI の調整を完結できるようにしています。
[
{"match": "^playground/.+", "group": "<skip ci>"},
{"match": "^(product/example|middleware/example)/.+$", "group": "example [L]"},
{"match": "^(product/[^/]+|middleware/[^/]+)/.+$", "group": "$1"},
{"match": "^(.+)$", "group": "$1"}
]
そして各グループのジョブ内で複数モジュールの処理を直列に実行します。
DFS 部分は自動生成で利用していたコードを流用しました。グルーピング部分のコードは for
で正規表現によるマッチを回すだけなので低コストで開発できました。また、 (社内で利用しているロガー関連のパッケージを除き) Go SDK 以外への依存がない形で実装しているため、今後 Go のバージョンアップ対応だけで安定して動き続けてくれることを期待しています。
🦁 依存先モジュールでは一部のリントのみを実行
テストでは依存関係を考慮して CI を実行する必要がありますが、リントの大部分は直接変更があったモジュールでのみ実行しておけば十分です。 golangci-lint の社内で適用されていたルールうち、以下のリントのみを依存先の CI で実行するようにしました。
🦄 Artifact 上にキャッシュを保存
GitHub Actions のキャッシュ容量はレポジトリ毎に 10GB で、 Monorepo においてはこの容量制限は割とタイトです。
そこで、比較的サイズの大きい一部のキャッシュを GitHub Artifact Storage にアップロードするようにしました。 AWS S3 や Google Cloud Storage も候補に上がりましたが、ネットワーク転送コストが高くなることが懸念されたため不採用としました。
アーティファクトはキャッシュと比較して保存・復元が遅い点が悩みどころでしたが、 zstd で事前に圧縮/解凍して高速化することで (依然キャッシュよりは遅いものの) 実用的な速さで処理が完了しています。また、デフォルトブランチのキャッシュを利用すれば十分なため、キャッシュキーの prefix マッチ 相当の機能は実装せずに運用しています。
🐷 JUnit report を PR 上にコメント
新しい CI 実装では一つのインスタンスで複数モジュールの CI が実行される関係で、 CI の実行結果を以前よりも確認しづらくなる懸念がありました。
そこで、 gotestsum や golangci-lint が出力する複数モジュールの JUnit レポートを、一つの PR コメントに集約して表示する仕組みを導入しました。既存の 3rd-party Action では要件を満たせなかったため、 Action を自作しました。
その他細かい Tips
本稿では割愛しますが、他にもいろいろな Tips があります。以下に一部を記載しているので、興味がある方は DM やカジュアル面談で質問いただけると嬉しいです。
- 🦏 PR では
concurrency
を利用して重複ワークフローをキャンセルするが、 Merge 時はキャンセルしない - 🐦 必要なコミット履歴をミニマルに取得することで fetch にかかる時間を最小化
- 🪸 Matrix を Workflow Call 側に定義することで GitHub Web UI 上での見やすさを調整
- 🐝 各 Workflow の Name を調整することで GitHub Web UI 上での見やすさを調整
- 🦖 PR では fail fast しないが、 Merge 時は fail fast する
- ARM インスタンスの活用によるインスタンスコスト削減
- Datadog CI Visibility による低導入コスト but 非常にリッチな可視化
- Go のバージョン切り替えは公式の手順を利用
- Workflow Call や Composite Run Steps が変更されたときのテストを整備
移行
既存 CI に大量に変更を入れると、変更に失敗したときに他のメンバーの開発に影響が出てしまう可能性があります。この点を踏まえ、新 CI は旧 CI とは完全に別で実装を進めました。
- 新 CI の実装完了後に一部モジュールのみを有効にした状態で旧 CI と並行稼働
- 並行稼働期間中に努力目標値の達成状況を確認 & 利用者からフィードバックを収集
- 新 CI に切り替えつつ旧 CI はスケジュール実行してしばらくの正常動作を確認
この手順で作業を進めたことで、開発を止めることなくかつ切り替え後も大きな問題が起きることなく移行を完了できました 🎉
結果
以上の施策により当初に想定していた通りの成果が得られました。
コスト
Push 回数の影響もあるため単純な比較は難しいものの、インスタンスコストはピーク時と比べて最低でも 50% は削減できました。主に不要なリントの実行を抑えたことと Arm インスタンスの活用による影響が支配的だったと思います。
また、新しい CI をリリースした後は、安定化のためのパッチを細かく適用している程度で、ほぼ手を加えることなく運用できています。極力仕様が安定したものに依存して CI を構築するようにしているため、 (一部の考慮漏れにより実装が当初の 1.5 倍ほど膨らんだものの) 今後もそこまで手を加えずに運用できる状態が続くことを期待しています。
パフォーマンス
移行後 1 カ月間で集計したところ、以下の結果となりました。
- Latency (PR to default branch)
- P50: 4.5 min
- P95: 7.2 min
- Error Rate (CI 起因のエラーのみを仕分けて計算した概算値)
- リリース後の問題対処前: 2.4%
- リリース後の問題対処後: 0.7%
当初はコスト面からジョブをまとめることを検討したのですが、実際は CI 待ち時間の短縮に寄与したように感じています。
まとめと今後の展望
顕在化した CI に関する問題に対してミニマルな解決策を提示し、対応することができました。
今後はチーム内リソースの拡充とともに、 Go のレイヤーに踏み込んだ施策の実行やビルドツールの導入等で仕組みを進化させていければと考えています。
もし似たような環境で CI コストや運用負荷に悩んでいる方がいれば、ぜひフィードバックやご質問をいただけると嬉しいです!
Discussion