Read OSS

`go` コマンドの内側:サブコマンド、モジュールロード、ビルドオーケストレーション

中級

前提知識

  • 第1回:リポジトリ構造とビルドシステム
  • go コマンドの使用経験(go build、go test、go mod)

go コマンドの内側:サブコマンド、モジュールロード、ビルドオーケストレーション

Go 開発者であれば、go buildgo testgo mod tidy といった go コマンドを毎日何度も使うでしょう。しかし、その内部がどう動いているかを調べたことがある人は少ないはずです。go コマンドは見かけによらず洗練されたソフトウェアです。モジュールの依存関係を管理し、ツールチェーンのバージョンを解決し、並列ビルドグラフを構築し、コンパイラとリンカをサブプロセスとして起動します。本記事では、ディスパッチから実行に至るまでのアーキテクチャを解剖していきます。

サブコマンドの登録とディスパッチ

go コマンドのエントリーポイントは、init() 関数の中ですべてのサブコマンドを登録し、base.Command オブジェクトのツリーを構築します。

src/cmd/go/main.go#L50-L92

このリストには、実行可能なコマンド(work.CmdBuild など)とヘルプトピック(help.HelpBuildConstraint など)の両方が含まれています。この分離はうまく設計されており、ヘルプトピックも同じ base.Command 型を使いながら実行不可能なものとして扱われます。そのため、特別な場合分けなしに go help の出力に自然に現れます。

main() 関数は次の順序で初期化を進めます。

src/cmd/go/main.go#L98-L221

  1. テレメトリのセットアップ
  2. -C(ディレクトリ変更)フラグの処理 — ツールチェーン選択に正しい作業ディレクトリが必要なため、最初に処理される
  3. toolchain.Select() — 別の Go バージョンを再実行する可能性がある
  4. フラグの解析とサブコマンドの特定
  5. lookupCmd() でコマンドツリーをたどり、対象コマンドを探索
  6. invoke() でコマンドを実行

lookupCmd 関数はツリーを走査し、go mod tidy のようにネストしたサブコマンドを処理します。

src/cmd/go/main.go#L264-L288

flowchart TD
    A["go mod tidy"] --> B["lookupCmd(['mod', 'tidy'])"]
    B --> C["base.Go.Lookup('mod')"]
    C --> D["CmdMod (has subcommands)"]
    D --> E["CmdMod.Lookup('tidy')"]
    E --> F["CmdModTidy (runnable)"]
    F --> G["invoke(CmdModTidy, args)"]

ヒント: invoke 関数はコマンド実行前に環境変数を明示的に設定します。これにより、GOOS や GOARCH などの設定が go コマンドとサブプロセスの間で一致することが保証され、クロスコンパイル時の微妙なバグを防ぎます。

ツールチェーンの選択

Go の機能の中で最も強力でありながら、最も理解されていない機能の一つが自動ツールチェーン選択です。go.modgo 1.23 と記載されていると、ローカルのツールチェーンが古い場合に go コマンドが Go 1.23 を透過的にダウンロードして再実行することがあります。

この処理は main() の 106 行目で行われます。

src/cmd/go/main.go#L106

Select() 関数は go.modgo.work を読み込み、必要なツールチェーンのバージョンを判断します。現在のバイナリが古すぎる場合は、golang.org/toolchain から正しいバージョンをダウンロードし、自身を再起動します。

src/cmd/go/internal/toolchain/select.go#L37-L72

実装では環境変数を調整プロトコルとして使用しています。GOTOOLCHAIN_INTERNAL_SWITCH_VERSION は子プロセスに期待するバージョンを伝え、GOTOOLCHAIN_INTERNAL_SWITCH_COUNT は無限ループを防ぎます(上限は 100 回)。どちらも go testgo run などのユーザープログラムを実行する前に環境から除外されます。

flowchart TD
    A["go build (Go 1.22)"] --> B{"go.mod says go 1.23?"}
    B -->|No| C["Continue normally"]
    B -->|Yes| D["Download go1.23 from<br/>golang.org/toolchain"]
    D --> E["Set GOTOOLCHAIN_INTERNAL_SWITCH_VERSION=go1.23"]
    E --> F["Re-exec: go1.23 build"]
    F --> G{"Am I go1.23?"}
    G -->|Yes| H["Clear env, continue"]
    G -->|No| I["Error: version mismatch"]

この仕組みのおかげで、go.modtoolchain ディレクティブを追加するだけで、チーム全員が同じ Go バージョンを透過的に使うようになります。再現性の高いビルド環境を実現する上で、大きな改善と言えるでしょう。

パッケージのロードと依存関係の解決

go build ./... を実行すると、go コマンドはインポートパスをディスク上の実際のパッケージに解決し、ソースファイルを読み込み、完全な依存関係グラフを構築する必要があります。この処理は loadmodload という二つの主要パッケージに分担されています。

modload パッケージはモジュールレベルの解決を担います。init.gogo.mod を読み込み、モジュールグラフを解決し、各インポートパスをどのモジュールが提供するかを決定します。

src/cmd/go/internal/modload/init.go#L1-L10

次に load パッケージが解決済みのモジュールパスを受け取り、ソースファイルの一覧、ビルド制約、依存関係、コンパイルフラグを含む Package オブジェクトを構築します。ここで //go:build タグが評価され、プラットフォーム固有のファイルが絞り込まれます。

flowchart LR
    A["Import path<br/>'net/http'"] --> B["modload: resolve<br/>module + version"]
    B --> C["load: read source<br/>files + constraints"]
    C --> D["Package object<br/>(files, deps, flags)"]
    D --> E["Build action graph"]

ロードプロセスは意図的に遅延評価されており、依存関係グラフを辿りながら必要に応じてパッケージをロードします。これにより、一部のパッケージしか必要としないコマンドでも、モジュールグラフ全体を事前にロードする無駄を省けます。

ビルドアクショングラフと実行

go build の核心はアクショングラフです。work パッケージが構築・実行するビルド操作の DAG(有向非巡回グラフ)で、並列に処理されます。

src/cmd/go/internal/work/build.go#L29-L46

CmdBuild 変数はビルドコマンドのメタデータと詳細なヘルプテキストを定義しています。実際のコンパイルはアクショングラフを通じてオーケストレーションされ、各ノードはパッケージのコンパイル、バイナリのリンク、go vet の実行といった作業単位を表します。

アクション間には依存関係があります。すべてのパッケージがコンパイルされるまでバイナリをリンクできませんし、インポートしているパッケージがコンパイルされるまで自身をコンパイルできません。エグゼキューターは -p フラグ(デフォルトは GOMAXPROCS)で上限を設けながら、アクションを並列に実行します。

sequenceDiagram
    participant User
    participant CmdBuild
    participant Loader
    participant ActionGraph
    participant Executor

    User->>CmdBuild: go build ./cmd/app
    CmdBuild->>Loader: Load packages
    Loader-->>CmdBuild: Package DAG
    CmdBuild->>ActionGraph: Create compile + link actions
    ActionGraph-->>Executor: Topologically sorted actions
    Executor->>Executor: Run in parallel (GOMAXPROCS workers)
    Note over Executor: compile pkg A, compile pkg B (parallel)
    Note over Executor: compile pkg C (depends on A)
    Note over Executor: link binary (depends on all)
    Executor-->>User: Binary written to disk

各コンパイルアクションは cmd/compile をサブプロセスとして起動し、最終的なリンクアクションは cmd/link を起動します。go コマンドがコンパイラの内部を直接呼び出すことはなく、常にサブプロセスとして実行します。この明確な分離があるからこそ、go build -x で実行されるすべての外部コマンドが可視化できるのです。

ヒント: go build -x ./... を実行すると、go ツールが裏で実行するすべてのコマンドを確認できます。cgo やクロスコンパイルに関連するビルドの問題を調査するときに非常に役立ちます。

Minimum Version Selection(MVS)

Go のモジュールシステムは Minimum Version Selection(MVS)というアルゴリズムを使用しています。Russ Cox が設計したこのアルゴリズムは、npm や pip などのシステムとは根本的に異なるアプローチで依存関係を解決します。

src/cmd/go/internal/mvs/mvs.go#L1-L45

Reqs インターフェースは依存関係グラフを抽象化します。

type Reqs interface {
    Required(m module.Version) ([]module.Version, error)
    Max(p, v1, v2 string) string
}

MVS はすべての要件を満たす最小限のモジュールバージョンの集合を計算します。モジュール A が B v1.2.0 を要求し、モジュール C が B v1.3.0 を要求している場合、MVS は両方を満たす最小バージョンとして B v1.3.0 を選択します。要件を超えて新しいバージョンを選ぶことは決してありません。

flowchart TD
    A["Main module"] -->|requires| B["mod A v1.0"]
    A -->|requires| C["mod B v1.2"]
    B -->|requires| C2["mod B v1.1"]
    B -->|requires| D["mod C v1.0"]
    C -->|requires| D2["mod C v1.3"]

    style C fill:#90EE90
    style D2 fill:#90EE90

    E["MVS Result:<br/>A v1.0, B v1.2, C v1.3"]

このアルゴリズムの重要な特性として、ロックファイルなしにビルドを再現できる点が挙げられます。go.sum ファイルは完全性の検証(暗号ハッシュ)を提供しますが、依存関係の集合を確定するには go.mod だけで十分です。MVS が決定論的であり、同じ入力に対して常に同じ出力を生成するからです。

実装では par パッケージを通じてネットワークリクエストを並列化し、依存関係グラフ全体でモジュールの検索を重ね合わせています。BuildList 関数がコアのエントリーポイントで、要件グラフを幅優先で走査しながら各モジュールの最大必要バージョンを計算します。

コマンドからバイナリへ

go build からバイナリ出力までの全経路を追ってきました。サブコマンドのディスパッチがビルドハンドラを特定し、ツールチェーン選択が正しい Go バージョンを保証し、モジュールロードが依存関係を解決し、アクショングラフが並列作業をスケジューリングし、MVS が再現性のあるモジュール解決を保証します。

次回は cmd/compile、つまり go コマンドがサブプロセスとして起動するコンパイラの内部を追っていきます。レキシング、パース、型検査、エスケープ解析、SSA 最適化パイプラインを通じて Go のソースコードがどのように機械語命令へと変換されるかを解き明かしていきましょう。