はじめに(対象読者・この記事でわかること)

本記事は、GoでWeb APIサーバ、バッチ、CLIツールなど複数のバイナリを1つのGitリポジトリで保守したいと考えている方を対象にしています。
「1リポジトリ1バイナリ」が基本とされるGoですが、マイクロサービスが増えてくるとリポジトリが散在し、CI/CDや依存管理が煩雑になりがちです。
この記事を読むと、以下のことがわかります。

  • マルチコマンドを自然に配置するディレクトリ構成
  • ビルドタグとMakefileを使って、必要なバイナリだけを高速ビルドする方法
  • リリース用イメージに無駄を含めないDockerビルド戦略
  • GitHub Actionsでターゲットごとに並列ビルド&配布するまでの実装例

前提知識

  • Go Modulesの基本操作(go mod init / go get
  • Makefileに慣れていること(ターゲットと変数の書き方)
  • Dockerでマルチステージビルドを行った経験

なぜ「1リポジトリ複数バイナリ」が必要なのか

Goはgo buildで単一のmainパッケージをコンパイルする設計です。
そのため、プロジェクトごとにリポジトリを切ると以下の弊害が出ます。

  • 共通のロギング、設定、DBドライバなどが重複し、バージョンが離れる
  • CI/CDのYAMLが各リポジトリに必要で、変更時に横展開が大変
  • 社内ライブラリのバージョンアップがリポジトリ数分のPRを生む

マルチバイナリ化すれば、コードの重複を防ぎ、一元的にリリースができます。
ただし、ビルドスピードや成果物のサイズを気にしなければなりません。以下では、それを解決する実践的なテクニックを紹介します。

実践:マルチコマンドをスケールさせるディレクトリ構成とビルド

ステップ1:ディレクトリを「機能軸」で切る

Goらしくcmd/以下に各バイナリのmain.goを置きますが、機能ごとにサブパッケージを切るとビルドタグを効かせやすくなります。

go-multi-bin/
├── go.mod
├── Makefile
├── README.md
├── internal/
│   ├── config/     # 環境変数読み出し(共用)
│   ├── logger/     # ロギングラッパー(共用)
│   └── repository/   # DBアクセス(共用)
├── cmd/
│   ├── api/        # Web APIサーバ
│   │   └── main.go
│   ├── migrate/    # マイグレーションツール
│   │   └── main.go
│   └── job/        # バッチ(cron用)
│       └── main.go
├── pkg/
│   └── validator/  # 社内ライブラリとして切り出したパッケージ
├── build/          # ビルド成果物(.gitignore)
└── docker/
    ├── api.Dockerfile
    ├── migrate.Dockerfile
    └── job.Dockerfile

cmd/以下のディレクトリ名をバイナリ名と一致させると、Makefileが短く書けます。

ステップ2:Makefileで「何を」「どこに」ビルドするを宣言する

以下の3行を守るだけで、以降のコマンドが短縮できます。

  • BIN_DIR:ビルド成果物を置くディレクトリ
  • CMD_DIR:バイナリのソースがあるルート
  • TARGETS:ビルド対象のディレクトリ名をスペース区切り
Makefile
BIN_DIR := ./build CMD_DIR := ./cmd TARGETS := api migrate job # クロスコンパイル用変数(CIで上書きしやすい) GOOS ?= linux GOARCH ?= amd64 CGO ?= 0 # デフォルトで全バイナリを並列ビルド .PHONY: build build: $(TARGETS) # 個別ビルドターゲット(%はTARGETSの要素にマッチ) $(TARGETS): @echo "===> building $@" @CGO_ENABLED=$(CGO) GOOS=$(GOOS) GOARCH=$(GOARCH) \ go build -ldflags "-s -w" -o $(BIN_DIR)/$@ $(CMD_DIR)/$@/main.go

make apiとすればbuild/apiが生成されます。
ldflagsで-s -wを付けると、シンボルテーブルとデバッグ情報を削除し、バイナリが約30 %縮む効果もあります。

ステップ3:ビルドタグで「本番はAPI、ローカルはjobまで」にする

例えば、ローカル開発時はjobをビルドしたくないケースがあります。
Makefileに条件分岐を書くより、ビルドタグで制御するとCI/CDのYAMLがすっきりします。

  1. 対象のmain.goにビルドタグを書く
    cmd/job/main.go go //go:build include_job package main

  2. Makefileでタグを付与する変数を用意
    makefile BUILD_TAGS ?= ifdef include_job BUILD_TAGS := -tags include_job endif

  3. ビルドコマンドを修正
    makefile go build $(BUILD_TAGS) -o $(BIN_DIR)/$@ $(CMD_DIR)/$@/main.go

GitHub Actionsのワークフローでは、ジョブマトリクスでinclude_jobを渡すだけで、本番用イメージにはjobバイナリが含まれなくなります。

ステップ4:イメージサイズを最小にするDockerfile

apiを例に、マルチステージビルドで最適化します。

Dockerfile
# ---- build stage ---- FROM golang:1.22-alpine AS builder RUN apk add --no-cache make git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . ARG BIN_NAME RUN make ${BIN_NAME} # ---- runtime stage ---- FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata COPY --from=builder /src/build/${BIN_NAME} /usr/local/bin/${BIN_NAME} ENTRYPOINT ["/usr/local/bin/api"]

.dockerignoreを事前に作成しておくと、ビルドコンテキストが小さくなります。

# .dockerignore
.git
.gitignore
build/
README.md
Makefile
docker/

ハマった点:「ビルドキャッシュが効かない」

マルチバイナリリポジトリにすると、DockerfileのCOPY . .で依存レイヤーが無効化され、毎回go mod downloadが走ってしまいました。

解決策:依存キャッシュ用の中間イメージを切る

go.modだけ先にコピーしてモジュールキャッシュレイヤーを独立させます(上記Dockerfile参照)。
また、GitHub Actionsではactions/cache@v4~/go/pkg/modをキャッシュキーに含めると、ビルド時間が約60 %短縮されました。

Yaml
- uses: actions/cache@v4 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

まとめ

本記事では、Goで複数のバイナリを1リポジトリで保守するための構成とビルド戦略を紹介しました。

  • cmd/以下にバイナリ別ディレクトリを置くことで、Makefileの%ルールで楽にビルド
  • ビルドタグを使って本番・開発でビルド対象を切り替え
  • マルチステージビルドとキャッシュ戦略で、イメージサイム・ビルド時間を削減

この記事を通して、リポジトリ数を減らしながら高速リリースができる体制を築けるはずです。
次回は、マルチバイナリ構成でのテスト戦略(統合テスト・ビルドタグごとのユニットテスト)を取り上げます。

参考資料