go build を使わずにGoプログラムをビルドする
go build
を使わずにGoのプログラムをビルドすることはできるのでしょうか。
結論から言うとできます。
シェルスクリプトで go build
を自作してみたら、2週間ほどでkubectl
[1]がビルドできるところまでいけました。
-
kubectl
,uber-go/zap
,spf13/cobra
,golang/protobuf
など有名どころのモジュールをビルドできる - クロスコンパイルをサポート(下記4通り。CPUは
amd64
)- Mac → Mac
- Mac → Linux
- Linux → Mac
- Linux → Linux
ちなみに自作Goコンパイラ babygo と自作アセンブラ goas をこの自作ビルダでビルドしてみたら成功しました。なかなか感慨深いものがあります。
ビルド速度が遅かったり[2]キャッシュの扱いが雑なので実用性はないですが、学習用素材としての価値はあると思っています。コードをなるべくわかりやすく書いたのと、bashスクリプトなのでGoに詳しくないひとでも読めるようになっています。特にログの見やすさにはこだわりました。
hello world
をビルドしたときのログがこちらです。処理の内容が非常にわかりやすくなっているのでぜひ見てみてください。
本記事では、公式go build
が何をやっているのか、それを自力で再現するにはどうするかを解説します。
go build
は何をやっているのか
公式 大まかな流れはこんな感じです。
- 指定したパッケージのソースを見て
import
宣言を取得し、さらにimport先のパッケージのソースを見て同じことを繰り返す。これにより依存関係のツリー状態(依存グラフといいます)を把握する。 - 依存グラフについて、レイヤーの低い方から高い方にパッケージを並べる (例:
runtime
->reflect
->fmt
->main
) - パッケージ単位でGoコードをコンパイルしてアーカイブファイルにまとめる
- パッケージにアセンブリファイルがある場合はアセンブルしてアーカイブファイルに追加する
- 最後に各パッケージのアーカイブファイルを全部まとめてバイナリ実行ファイルを作る
上の処理をさらに詳しく見ると、
- 「パッケージ単位」が全ての基本になっている
- パッケージをコンパイルするときは、ひとつ前の依存パッケージだけを見てコンパイルしている
- クロスコンパイルとは、単にソースファイルの取捨選択である
- パッケージ内の複数ファイルは一括でコンパイラに渡される[3]
などがわかります。この特徴のおかげで並列化(パッケージ間の並列 x パッケージ内の並列)やキャッシュが効きやすくなっており、ビルド時間の短縮につながっています。
そもそもGo言語が誕生した目的のひとつに「ビルド時間を短くする」というのがあり、文法や言語仕様のレベルでもそのような工夫が織り込まれているようです。[4]
わかりやすい例でいうと、import
したパッケージを使っていないとコンパイラに怒られるのはビルドを速くするためです。またimport
宣言はファイルの冒頭以外では書けない[5]ように定められていますが、ビルダ側からするとこの仕様はめちゃめちゃありがたいのです(依存グラフを取得する際にファイル全体を構文解析しなくてよい)。
私のお気に入りネタはunsafe
がビルドログに出てこないことです。実はあれはパッケージのふりをした別のなにかなのです。[6]
そのようなことがビルダを自作することではっきりと体験できます。
hello world をビルドして処理を追ってみる
それでは実際に公式 go build
を使って処理を追ってみましょう。
まず必要なファイルを作成します。 ( main.go
と go.mod
)
$ cat > main.go <<EOF
package main
import "fmt"
func main() {fmt.Println("hello world")}
EOF
$ go mod init example.com/hello
ビルドして実行できることを確認します。
$ go build
$ ./hello
hello world
実行ログを出力する
go build
に -x
オプションをつけるとログを見ることができます。
$ go build -x
WORK=/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build2336838040
あれ、ログが1行しかありません。
これはキャッシュが効いているからです。hello
のビルドは2回目なので、1回目の結果が使い回されているということです。
ここで -a
オプションをつけるとキャッシュが全て無効になり、標準ライブラリ含めて全パッケージがソースからビルドされます。
$ go build -x -a
WORK=/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build4274470276
mkdir -p $WORK/b005/
mkdir -p $WORK/b012/
cat >/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build4274470276/b005/importcfg << 'EOF' # internal
# import config
EOF
cat >/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build4274470276/b012/importcfg << 'EOF' # internal
# import config
EOF
cd /tmp/birudo
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/compile -o $WORK/b005/_pkg_.a -trimpath "$WORK/b005=>" -p internal/goarch -std -+ -complete -buildid NeMeTvvWBf8p5uHSGfak/NeMeTvvWBf8p5uHSGfak -goversion go1.20.4 -c=4 -nolocalimports -importcfg $WORK/b005/importcfg -pack /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch_amd64.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/zgoarch_amd64.go
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/compile -o $WORK/b012/_pkg_.a -trimpath "$WORK/b012=>" -p internal/coverage/rtcov -std -+ -complete -buildid mI6xNmP8pxnOcrWlN_qn/mI6xNmP8pxnOcrWlN_qn -goversion go1.20.4 -c=4 -nolocalimports -importcfg $WORK/b012/importcfg -pack /usr/local/Cellar/go/1.20.4/libexec/src/internal/coverage/rtcov/rtcov.go
mkdir -p $WORK/b014/
(略)
実行すると長大なログが出力されます。読んでみると、ぐちゃぐちゃで非常に読みづらいです。理由は、複数パッケージのビルドが並列で走っているためです。
ここで -p 1
オプションをつけると並列数を1に制限することができます。
$ go build -x -a -p 1
WORK=/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build3299870493
mkdir -p $WORK/b005/
cat >/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build3299870493/b005/importcfg << 'EOF' # internal
# import config
EOF
cd /tmp/birudo
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/compile -o $WORK/b005/_pkg_.a -trimpath "$WORK/b005=>" -p internal/goarch -std -+ -complete -buildid NeMeTvvWBf8p5uHSGfak/NeMeTvvWBf8p5uHSGfak -goversion go1.20.4 -c=8 -nolocalimports -importcfg $WORK/b005/importcfg -pack /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch_amd64.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/zgoarch_amd64.go
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/buildid -w $WORK/b005/_pkg_.a # internal
cp $WORK/b005/_pkg_.a /Users/DQNEO/Library/Caches/go-build/79/799f3b0680ae6929fbd8bc4eea9aa74868623c9e216293baf43e5e1a3c85aa84-d # internal
mkdir -p $WORK/b006/
cat >/var/folders/bq/2mhmkrcn59dd9t7pq5_6hbw80000gp/T/go-build3299870493/b006/importcfg << 'EOF' # internal
# import config
ビルドの流れが一本の線になって見やすくなります。
面白いことに、このログは実行可能なシェルスクリプトになっています。試しにログファイルを保存して、 bashスクリプトとして実行してみましょう。
$ go build -x -a -p 1 2> buildx.sh
$ bash < buildx.sh
$ ./hello
hello world
ちゃんと実行できます。
ここでさらにコツがあって、-x
のかわりに -n
オプションを渡すと、ビルドを実行せずにログだけ吐いてくれるので超高速になります(いわゆる dry-run)。おまけにログにコメントが付いて読みやすくなります。ビルド内容を調査したいときにはこれがおすすめです。
(なお、-n
をつけると 強制的に -p 1
になるので -p
は不要です)
$ go build -n -a
#
# internal/goarch
#
mkdir -p $WORK/b005/
cat >$WORK/b005/importcfg << 'EOF' # internal
# import config
EOF
cd /tmp/birudo
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/compile -o $WORK/b005/_pkg_.a -trimpath "$WORK/b005=>" -p internal/goarch -std -+ -complete -buildid NeMeTvvWBf8p5uHSGfak/NeMeTvvWBf8p5uHSGfak -goversion go1.20.4 -c=8 -nolocalimports -importcfg $WORK/b005/importcfg -pack /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch_amd64.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/zgoarch_amd64.go
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/buildid -w $WORK/b005/_pkg_.a # internal
(以下略)
ただし一点注意があって、この -n
のログはそのままではシェルで実行できません。実行可能にするには少し加工が必要です。
- 変数
$WORK
をセット -
'EOF'
のクォートを除去
$ go build -n -a 2> buildn.sh
$ cat buildn.sh | sed -e "s/'EOF'.*$/EOF/g" | WORK=/tmp/go-build bash
実行できました。
この buildn.sh
のスクリプトをリファクタリング (繰り返し処理をfor文にまとめる等)すると理解が深まるのでおすすめです。実際私はこのリファクタリング作業を極限までおしすすめて、その結果できたものが冒頭で紹介した自作ビルダーです。
実行ログにあらわれないロジックを推定する
このログを全部読めばビルドを理解できるのかというと、残念ながらそうはいきません。ログに現れない隠れたロジックがいつくかあります。
- パッケージのソースコードをどこから探してくるのか
- コンパイルするファイルをどうやって選択するのか
- コンパイルオプションの決め方
- ビルドするパッケージの順番をどうやって決めるのか
- embedタグがある場合のファイルの埋め込み
例えば 先程の hello
をビルドしたログの冒頭で internal/goarch
パッケージのコンパイル処理が出てきますが、
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/compile -o $WORK/b005/_pkg_.a -trimpath "$WORK/b005=>" -p internal/goarch -std -+ -complete -buildid NeMeTvvWBf8p5uHSGfak/NeMeTvvWBf8p5uHSGfak -goversion go1.20.4 -c=8 -nolocalimports -importcfg $WORK/b005/importcfg -pack /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/goarch_amd64.go /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch/zgoarch_amd64.go
-
internal/goarch
を最初にビルドすればよいとなぜわかったのか -
/usr/local/Cellar/go/1.20.4/libexec/src
にソースがあるとなぜわかったのか
などの疑問が浮かびます。
compile
にわたすファイルリストについても、ログには goarch.go
goarch_amd64.go
zgoarch_amd64.go
の3ファイルだけが登場していますが、internal/goarch
のソースディレクトリを見てみると.goファイルが39個もあります。
$ ls /usr/local/Cellar/go/1.20.4/libexec/src/internal/goarch
gengoarch.go goarch_arm.go goarch_mips64.go goarch_ppc64le.go zgoarch_386.go zgoarch_arm64be.go zgoarch_mips64.go zgoarch_mipsle.go zgoarch_riscv.go zgoarch_sparc.go
goarch.go goarch_arm64.go goarch_mips64le.go goarch_riscv64.go zgoarch_amd64.go zgoarch_armbe.go zgoarch_mips64le.go zgoarch_ppc.go zgoarch_riscv64.go zgoarch_sparc64.go
goarch_386.go goarch_loong64.go goarch_mipsle.go goarch_s390x.go zgoarch_arm.go zgoarch_loong64.go zgoarch_mips64p32.go zgoarch_ppc64.go zgoarch_s390.go zgoarch_wasm.go
goarch_amd64.go goarch_mips.go goarch_ppc64.go goarch_wasm.go zgoarch_arm64.go zgoarch_mips.go zgoarch_mips64p32le.go zgoarch_ppc64le.go zgoarch_s390x.go
39個から3個を選択するロジックはどうなっているのでしょうか。
また、パッケージによってコンパイルオプション -complete
や -+
がついてたりついてなかったりします。これはどういう基準なのでしょうか。
また、パッケージにアセンブリファイルがある場合は処理内容が大きく変わります。
さらに、大きめのパッケージ (kubectl
とか) をビルドしてみるとembed
に関する特別な処理があらわれたりします。
ビルダーを自作する場合はこれらの処理を再現する必要があります。
私はソースを読むよりもリバースエンジニアリングする方が得意なので、今回もログだけを見て処理内容を推測して自作しました。
ビルドの処理内容を再現する
パッケージのソースディレクトリをどこから探してくるのか
大まかには
- 標準ライブラリは
$(go env GOROOT)/src
から - 自モジュール内のパッケージは自モジュールのルートディレクトリ(
go.mod
のある場所)から - それ以外は
vendor
ディレクトリから
探索すればOKです。
ビルドするパッケージの順番をどうやって決めるのか
import宣言を芋づる式にたどって得られる依存グラフ(key => valueのmapで表現できる)に対して、トポロジカルソート というアルゴリズムを適用するとビルド順を決定できます。
簡単に説明すると、
- ツリーの枝の末端ノードを切り落とす
- 残った枝のいくつかが新たに末端となるので、それを切り落とす
を繰り返すだけです。
私のビルドツールのログではこのように (https://gist.github.com/DQNEO/7b0710b08baa4eb2fc6fb8bde8c432e1#file-build_hello-log-L681-L769) ソートの前後の状態を可視化しているので参考にしてみてください。
コンパイルするファイルをどうやって選択するのか
パッケージのソースディレクトリの中からコンパイルすべきファイルを選択するロジックは下記のようになっています。
-
*_tes.go
ファイルを除外 -
_{OS}.*
,_{CPU}.*
,_{OS}_{CPU}.*
などのsuffix付きのファイルについて、ビルドターゲット ($GOOS, $GOARCH) にマッチしないものは除外 - 残りのファイルについて、ビルドタグ (例
//go:build windows || (linux && amd64)
) を解析して、論理演算の結果マッチしないものは除外 - さらに残りのファイルについて、旧ビルドタグ (
// +build linux,amd64
) を解析して、マッチしないものは除外
除外されずに残ったファイルがコンパイラに渡されます。
こんなシンプルな仕組みでクロスコンパイルを実現できているのは素晴らしいですね。
新旧ビルドタグの違いについてはこちらの記事がわかりやすいです。 https://zenn.dev/team_soda/articles/golang-build-tags-history
ちなみにビルドタグの論理演算(!
, &&
,||
等) は bashでもほぼそのまま解釈できるので移植が楽でした。
旧ビルドタグ (// +build
) は仕様がめちゃくちゃ複雑なので無視したいところですが、これに依存している古いモジュールがちらほら残っています。私は妥協して新式と同じルールを適用することにしました。一応そこそこ動いています。2-3年後くらいには旧ビルドタグがなくなっているといいですね。
コンパイルオプションの決め方
パッケージの属性によってコンパイルオプションがかわるものがあります。
-
-std
compiling standard library -
-complete
compiling complete package (no C or assembly) -
-symabis
read symbol ABIs from file -
-embedcfg
read go:embed configuration from file -
-+
compiling runtime
-std
は標準ライブラリをコンパイルするときには必ずつけます。
-complete
は bodyのない関数宣言をはじくオプションです。普段はつけておいて特殊なケース[7]だけはずすというのが go build
の流儀です。解説すると長くなるのではしょりますが、まあ面倒ならなしでもよいでしょう[8]。
-symabis
はパッケージにアセンブリファイルが含まれるときにつけます(後述)。
-embedcfg
は go:embed
を実現するための設定ファイルです(後述)。
-+
はよくわかっていないのでわかったら追記します。
アセンブリファイルがあるときの扱い
パッケージのソースにアセンブリファイルがある場合は下記のようにします。
symabis
ファイルを作成
どのアセンブリ関数がどのABI仕様に準拠しているかをコンパイラに伝えるためのものです。
asm -gensymabis
で自動生成してくれるので、中身については意識しなくて大丈夫です。
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/asm -p internal/cpu -trimpath "$WORK/b011=>" -I $WORK/b011/ -I /usr/local/Cellar/go/1.20.4/libexec/pkg/include -D GOOS_darwin -D GOARCH_amd64 -D GOAMD64_v1 -gensymabis -o $WORK/b011/symabis ./cpu.s ./cpu_x86.s
アセンブルする
いわゆる狭義のアセンブル処理です。アセンブリのソースをオブジェクトファイルに変換します。入力ファイルと出力ファイルが1対1対応してるのでわかりやすいですね。
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/asm -p internal/cpu -trimpath "$WORK/b011=>" -I $WORK/b011/ -I /usr/local/Cellar/go/1.20.4/libexec/pkg/include -D GOOS_darwin -D GOARCH_amd64 -D GOAMD64_v1 -o $WORK/b011/cpu.o ./cpu.s
オブジェクトファイルをアーカイブに追加
pack r
(append files (from the file system) to the archive) を使ってオブジェクトファイルをアーカイブに追加します。
/usr/local/Cellar/go/1.20.4/libexec/pkg/tool/darwin_amd64/pack r $WORK/b012/_pkg_.a $WORK/b012/cpu.o $WORK/b012/cpu_x86.o # internal
ちなみにアーカイブファイル (pkg.a) の内容を知りたい場合は pack t
でオブジェクトファイルの一覧を見ることができます。
$ go tool pack t _pkg_.a
__.PKGDEF
_go_.o
cpu.o
cpu_x86.o
embedタグがある場合のファイルの埋め込み
ソースコード中に go:embed
がある場合は、ファイルシステムを探索してマッピング情報をJSONにまとめてコンパイラに教えてあげる必要があります。
パターンが何種類かあるので実例を示します。
単一ファイル埋め込み
//go:embed p256_asm_table.bin
var p256PrecomputedEmbed string
当該ファイルの絶対パスを組み立ててJSONに記述します。
{
"Patterns": {
"p256_asm_table.bin": [
"p256_asm_table.bin"
]
},
"Files": {
"p256_asm_table.bin": "/usr/local/Cellar/go/1.20.4/libexec/src/crypto/internal/nistec/p256_asm_table.bin"
}
}
globを展開して埋め込み
//go:embed templates/*.tmpl
var rawBuiltinTemplates embed.FS
globを展開した上で絶対パスを取得してJSONに記述します。
{
"Patterns": {
"templates/*.tmpl": [
"templates/plaintext.tmpl"
]
},
"Files": {
"templates/plaintext.tmpl": "/Users/DQNEO/src/github.com/DQNEO/go-build-bash/examples/kubectl/vendor/k8s.io/kubectl/pkg/explain/v2/templates/plaintext.tmpl"
}
}
ディレクトリを展開して埋め込み
//go:embed translations
var translations embed.FS
ディレクトリ内のファイルを最下層まですべてリストアップして絶対パスをJSONに記述します。
{
"Patterns": {
"translations": [
"translations/OWNERS",
"translations/README.md",
(中略)
"translations/test/en_US/LC_MESSAGES/k8s.po"
]
},
"Files": {
"translations/OWNERS": "/Users/DQNEO/src/github.com/DQNEO/go-build-bash/examples/kubectl/vendor/k8s.io/kubectl/pkg/util/i18n/translations/OWNERS",
"translations/README.md": "/Users/DQNEO/src/github.com/DQNEO/go-build-bash/examples/kubectl/vendor/k8s.io/kubectl/pkg/util/i18n/translations/README.md",
(中略)
"translations/test/en_US/LC_MESSAGES/k8s.po": "/Users/DQNEO/src/github.com/DQNEO/go-build-bash/examples/kubectl/vendor/k8s.io/kubectl/pkg/util/i18n/translations/test/en_US/LC_MESSAGES/k8s.po"
}
}
このへんの処理をbashで書いてるときに「goで書いとけばよかった」と一瞬ひるみましたが、まあこういうのは勢いで突き進むのみです。
こうやって組み立てたJSONをファイルに書き出して、コンパイルオプションで指定してあげるとバイナリに組み込んでくれます。
compile -embedcfg $WORK/b050/embedcfg ...
embedに関して build のレイヤーがやることはこれだけです。実際にファイルをバイナリに埋め込む処理はコンパイラがやってくれます。
以上で説明したすべての処理、つまりパッケージのソースディレクトリを探索し、ファイルを取捨選択し、コンパイルオプションを決定し、パッケージ群をソートし、embedファイルを埋め込むことで、ついに動くバイナリファイルを手に入れることができます。
まとめ
ここまでやれば kubectl
などの大規模モジュールをビルドできるようになりました。
本記事で書いていない細かいところは上で紹介したビルドログと go-build-bash のコードを見てもらえればわかるかと思います。公式 go build のソースを読むのもありだと思います。
最後にひとこと
go build
は作れる!
-
k8sのクライアント。依存パッケージが800個もある ↩︎
-
kubectlのフルビルドをやってみたら公式goより4倍遅かった。何の高速化の工夫もしてないのに4倍程度ですんでいるとも言える ↩︎
-
コンパイラのソースを見ると、複数ファイルが並列で構文解析されてることがわかります。 https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/cmd/compile/internal/noder/noder.go;l=43-60 ↩︎
-
2009年のGo言語の発表で言及があります https://youtu.be/rKnDgT73v8s?t=839 ↩︎
-
正確に言うと、package宣言の直後に書かないといけない ↩︎
-
コンパイラの機能の一部であり疑似パッケージと呼ばれている。https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/cmd/compile/internal/gc/main.go;l=90-91 ↩︎
-
アセンブリファイルが含まれる場合と、bodyなし関数のある少数のパッケージ ↩︎
-
言語仕様的にはつける必要なし ↩︎
Discussion
追記1
-n
の場合は 強制的に-p 1
になることに気づいたので、記述を修正しました。公式 go build のソースコードを読んだのでメモ。
(あとで別の記事として独立させるかも)
go buildのエントリポイント
import先パッケージ探索
からはじまって
を経て
と で相互再帰している。この相互再帰により、import先のパッケージを芋づる式に取得している。
依存グラフ構築後の並列ビルド処理
で依存ツリーをいったん単純なリストに並び替えて、
で末端ノードをタスクキューに突っ込み
でN並列の中でパッケージのビルドを実行(タスクキューから1個とってきてビルド)
パッケージのビルドが終わると依存元のリンクカウントを1減らす。リンクカウントゼロになったパッケージはビルド可能なのでタスクキューに突っ込む
ちなみに go build には隠しオプションが2つあります。面白かったので紹介。
こちらに解説記事があります。