はじめに
はじめまして。株式会社サイバーエージェントでソフトウェアエンジニアをしている唐木稜生(@karamaru_alpha)です。本連載では、Goでのアプリケーション開発を高速化・効率化するテクニックをお届けします。細かな言語仕様の深掘りに留まらず、現場ですぐに活用できる・プロダクトのドメインに依存しない実践的な情報を発信することを目標とします。
第1回目の本記事では、Goのバイナリサイズを小さくするTipsを紹介します。実際のコードをチューニングしながら、主にバイナリの解析方法と、依存の整理方法について解説します。
チューニング対象アプリケーションの紹介
本記事では、以下アプリケーションを対象にチューニングを行います。1、2、3、4から偶数を出力するシンプルなコードです。
// main.go
package main
import (
"fmt"
"github.com/karamaru-alpha/think-it/slim-go-binary/util"
)
func main() {
// 1~4から偶数を出力する
nums := []int{1, 2, 3, 4}
evenNums := util.Filter(nums, func(num int) bool {
return num%2 == 0
})
fmt.Printf("evenNums: %v\n", evenNums) // [2, 4]
}// util/slice.go
package util
import "github.com/samber/lo"
// スライスから条件に合致した要素を返す関数
func Filter[T any](slice []T, fn func(T) bool) []T {
return lo.Filter(slice, func(item T, _ int) bool {
return fn(item)
})
}$ tree .
├── go.mod
├── go.sum
├── main.go # [1,2,3,4]から偶数のみを出力するmain関数
├── Makefile
└── util
├── slice.go # sliceのフィルター関数
└── uuid.go # 本アプリケーションでは使わないファイル(後述)初期状態のバイナリサイズは筆者の環境で3.5Mです。
$ CGO_ENABLED=0 go build -o bin/plain main.go
$ du -h bin/plain
# 3.5M本記事ではいくつかの改善を通して、このバイナリを1.6Mまで小さくすることを目指します。
本アプリケーションはGitHubに公開されています。計測スクリプトなども同梱しているので、改善を体感したい方はぜひcloneしてみてください。
cf. https://github.com/karamaru-alpha/think-it/tree/main/slim-go-binary
go-size-analyzerでバイナリを解析する
チューニングに取り掛かる前に、まずはバイナリの内訳を確認しましょう。
go-size-analyzerは、Goバイナリの内訳とそれぞれのサイズをビジュアルに表示してくれる便利なツールです。
早速対象アプリケーションをビルドし、中身を確認してみます。
$ CGO_ENABLED=0 go build -o bin/plain main.go
$ go run github.com/Zxilly/go-size-analyzer/cmd/gsa@v1.11.0 bin/plain --web --open
# リポジトリをcloneした方は以下でも実行できます
# $ make build-plainセクションごとに色分けされていてとても見やすいですね。ここから、このアプリケーションには以下のような依存・内容物の種類があることを確認できます。
runtime、fmtなどの標準パッケージ(赤紫色部分)github.com/samber/loなどのサードパーティパッケージ(緑色部分)
定数 (青色部分)
デバッグ情報 (青紫色部分)
また、サードパーティパッケージのセクションに焦点を当てると、golang.org/x/textがその9割を占めていることが分かります。
それでは、これらの情報を参考にバイナリを小さくしていきましょう。
シンボルテーブルなどのデバッグ情報を削除する (3.5M→2.4M)
まずは、デバッグセクションの削除です。go build時にldflagsに-sを指定すると、シンボルテーブルなどのデバッグ情報が削除されバイナリサイズを削減できます。
デバッグ情報は主にデバッガーを使ってステップ実行やブレークポイント設定を行う際に必要となる情報で、本番環境で動作させるバイナリでは基本的に不要です。
ちなみに、昔はよく-s -wという記法を見ましたが、Go 1.22 からは -s のみで完結するようになっています。
> -s
> Omit the symbol table and debug information.
> Implies the -w flag, which can be negated with -w=0.それでは、デバッグ情報を除外したビルドを行い、バイナリサイズを確認してみましょう。
$ CGO_ENABLED=0 go build -trimpath -ldflags '-s' -o bin/exclude-debug main.go
$ du -h bin/exclude-debug
# 2.4M
# リポジトリをcloneした方は以下でも実行できます
# $ make build-exclude-debug3.5Mから2.4Mにバイナリサイズが小さくなることを確認できました。次に、go-size-analyzerを用いて実際にデバッグセクションが省略されていることを確認しましょう。
$ go run github.com/Zxilly/go-size-analyzer/cmd/gsa@v1.11.0 bin/exclude-debug --web --openデバッグセクションが内訳から消えていることを確認できましたね。続いて、依存を整理してバイナリを小さくするTipsを紹介します。
依存パッケージを削除し自作処理に置き換える (2.4M→1.9M)
さて、先ほどの成果物におけるgo-size-analyzerのサードパーティセクションに注目すると、 golang.org/x/textという依存がサイズの9割を占めていることが分かります。
まずは、このパッケージがどのような経路で存在しているのかを確認しましょう。
依存も遡って検索するにはgo mod graphコマンドが有用です。
$ go mod graph | grep golang.org/x/text
github.com/karamaru-alpha/think-it/slim-go-binary golang.org/x/text@v0.22.0
github.com/samber/lo@v1.53.0 golang.org/x/text@v0.22.0出力結果から、このアプリケーションで使用しているgithub.com/samber/loがgolang.org/x/textに依存していることが分かりました。
依存パッケージのサイズが大きいと感じたなら、処理を自作に置き換えることができないかを検討しましょう。関数やメソッドの活用が限定的であれば、このような間接依存を削減することでバイナリサイズの削減が見込めます。
それでは、util関数に存在していた「スライスから条件に合致する要素を返却する関数」を自作し、github.com/samber/loへの依存を削除しましょう。
package util
- import "github.com/samber/lo"
-
- func Filter[T any](slice []T, fn func(T) bool) []T {
- return lo.Filter(slice, func(item T, _ int) bool {
- return fn(item)
- })
- }
+ func Filter[T any](slice []T, fn func(T) bool) []T {
+ result := make([]T, 0, len(slice))
+ for _, elem := range slice {
+ if fn(elem) {
+ result = append(result, elem)
+ }
+ }
+ return result
+ }それでは、go mod tidyを使用して不要になった依存を削除した後、アプリを再ビルドしてみましょう。
$ go mod tidy
$ CGO_ENABLED=0 go build -trimpath -ldflags '-s' -o bin/remove-dependency main.go
$ du -h bin/remove-dependency
# 1.9M
# リポジトリをcloneした方は以下でも実行できます
# $ make build-remove-dependencyサイズが2.4M→1.9Mになったことが確認できました。また、バイナリの内訳を表示すると、対象サードパーティパッケージが存在しなくなったことが確認できます。
次のセクションでは、モノレポ環境で見落とされがちなパッケージ分割によるバイナリ肥大について解決しましょう。
パッケージを適切に分割し不要な依存が入らないようにする (1.9M→1.6M)
さて、go-size-analyzerのサードパーティセクションに再度着目すると、実処理では使わないgithub.com/google/uuidが依存に含まれていることを確認できます。
// NOTE: モノレポで別のアプリケーションから呼ばれるが、本ビルドには必要ないファイル
// util/uuid.go
package util
import (
"github.com/google/uuid"
)
var GlobalUUID = uuid.New()実処理では呼ばれないコードなのに、どうして依存に含まれるのでしょうか?
Go言語では、依存を「パッケージ」という(ここではディレクトリという理解で問題ない)単位で管理しています。よって、./utilというパッケージをimportした場合、そのパッケージに含まれる全てのファイル(./util/*.go)とそれらが依存するパッケージがビルド対象として評価されます。
もちろん、コンパイル時の最適化で使用されないコードをバイナリに含めない「デッドコード削除」機能は存在しますが、init関数やグローバル変数に関連するもの、副作用がありうると評価されたものなどについては、除外が行われないという限界があります。
したがって、パッケージ分割の段階から目的別・用途別に細かく切ることでコンパイラに評価させるコードの量を減少させ、参照のないコードが不本意に依存に入ってしまうことを防ぐことができます。
それでは、2つのutilファイルを別パッケージに切り直した上で、再度バイナリサイズを計測してみましょう。
$ tree util
util
├── slice
│ └── util.go # util/slice.go → util/slice/util.go
└── uuid
└── util.go # util/uuid.go → util/uuid/util.go$ CGO_ENABLED=0 go build -trimpath -ldflags '-s' -o bin/split-package main.go
# 1.6M
# リポジトリをcloneした方は以下でも実行できます
# $ make build-split-package1.9M→1.6Mに小さくなることを確認できました。内訳を確認すると、サードパーティパッケージへの依存がなくなると共に、それらが依存していたcryptやnetといった標準パッケージへの依存も合わせて削除されていることが分かります。
今回は分かりやすい例ですが、特にモノレポ構成で複数のアプリケーションから使用される共通処理が1つのパッケージにまとめられている場合、意図しない依存が混入していても気づきにくくなります。
バイナリの内訳を計測して不要な依存関係が判明した際は、適切な粒度でのパッケージ分割やビルドタグの活用を検討してください。これにより、コンパイル対象となるコード/依存量を最小限に抑えることが可能です。
まとめ
本記事では、実際のアプリケーションのチューニングを通じて、Goバイナリを軽量化するテクニックを紹介しました。
バイナリの内訳を確認した上で、デバッグ情報の除去や依存関係の整理、パッケージ分割といった手法が、サイズ削減にどう寄与するかを実証しました。
バイナリサイズの削減は、コンテナイメージのサイズ削減やデプロイ時間の短縮など、本番環境での運用効率向上に直結しますので、ぜひあなたのプロジェクトでも試してみてください。
次回は「コンテナイメージ自体のサイズ縮小」について解説する予定です。お楽しみに!
- この記事のキーワード
