go.mod の require に v1.3.0 と書いてあっても、ビルドに使われるのが v1.3.1 になることがあります。Go Modules のバージョン解決アルゴリズム MVS(Minimal version selection、最小バージョン選択)の仕様どおりの動作。バグではありません。Go 1.26.4 で最小構成を作り、選ばれるバージョンがどう決まるかを実測しました。
requireは「固定」ではなく「下限」
rsc.io/quoteで最小構成を作る
公式チュートリアルでも使われる rsc.io/quote で試します。go mod init と go mod tidy だけの最小構成です。
mkdir /tmp/gomod-demo && cd /tmp/gomod-demo
go mod init example.com/demo
cat > main.go <<'EOF'
package main
import (
"fmt"
"rsc.io/quote"
)
func main() {
fmt.Println(quote.Hello())
}
EOF
go mod tidy
go run .
go: finding module for package rsc.io/quote
go: downloading rsc.io/quote v1.5.2
go: downloading rsc.io/sampler v1.3.0
go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
Hello, world.
生成された go.mod がこちら。go mod tidy は直接使う rsc.io/quote だけでなく、依存の依存も // indirect 付きで書き込みます。
module example.com/demo
go 1.26.4
require rsc.io/quote v1.5.2
require (
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
rsc.io/sampler v1.3.0 // indirect
)
golang.org/x/text の長いバージョンは疑似バージョン(pseudo-version)です。タグの無いコミットを「v0.0.0 + コミット日時 + ハッシュ12桁」の形式でバージョン化したもので、手で書くものではありません。
要求がぶつかったら高い方が勝つ
rsc.io/quote v1.5.2 は rsc.io/sampler v1.3.0 を要求しています。この状態で自分のモジュールから v1.3.1 を要求すると、要求の衝突が起きます。
go get rsc.io/sampler@v1.3.1
go list -m all
go: upgraded rsc.io/sampler v1.3.0 => v1.3.1
example.com/demo
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.1
quote の「v1.3.0 が欲しい」と自分の「v1.3.1 が欲しい」を両方満たすのは v1.3.1 です。requireに書かれたバージョンは「これを使え」ではなく「これ以上を使え」という下限指定として働きます。逆に言うと、go.mod に v1.3.0 と書いてあるモジュールが v1.3.1 でビルドされることは普通に起きます。
go mod graphで要求関係を確認する
誰が何を要求しているかは go mod graph で見えます。左が要求元、右が要求先。
go mod graph
example.com/demo go@1.26.4
example.com/demo golang.org/x/text@v0.0.0-20170915032832-14c0d48ead0c
example.com/demo rsc.io/quote@v1.5.2
example.com/demo rsc.io/sampler@v1.3.1
go@1.26.4 toolchain@go1.26.4
rsc.io/quote@v1.5.2 rsc.io/sampler@v1.3.0
rsc.io/sampler@v1.3.1 golang.org/x/text@v0.0.0-20170915032832-14c0d48ead0c
rsc.io/sampler@v1.3.0 golang.org/x/text@v0.0.0-20170915032832-14c0d48ead0c
rsc.io/quote@v1.5.2 rsc.io/sampler@v1.3.0 の行を見ると、quote の要求は v1.3.0 のまま残っています。グラフは「要求」の一覧であって、実際に選ばれるバージョンとは別物です。選ばれた結果を見るのは go list -m all の仕事になります。
MVSはなぜ「最小」なのか
公式リファレンス(go.dev/ref/mod)の “Minimal version selection (MVS)” セクションは、このアルゴリズムを「各モジュールについて要求された中で最も高いバージョン(highest required version)を選ぶ」と説明しています。最大を選ぶのに「最小」と呼ぶのは、全員の要求を満たす組み合わせのうち最小のものを選ぶからです。要求より新しいバージョンが出ていても、勝手には上がらない。
npm・pipとの解決方式の違い
| Go Modules | npm | pip | |
|---|---|---|---|
| バージョン指定 | 下限(それ以上の最小) | 範囲(^1.2.0 など) | 範囲(>=1.2 など) |
| 新バージョンへの追従 | 追わない | 範囲内の最新を取る | 最新を取る |
| 再現性の担保 | go.mod だけで決定 | package-lock.json が必要 | lock ファイルが必要 |
npm や pip は「範囲内の最新」を選ぶため、ロックファイルが無いと実行のたびに結果が変わりえます。MVS は同じ go.mod からは常に同じビルドリストを計算するので、ロックファイルという仕組み自体が要りません。ここが後述の go.sum の誤解につながります。
ダウングレードも同じ計算で決まる
sampler を v1.2.0 まで下げるとどうなるか。quote v1.5.2 は v1.3.0 以上を要求しているので、そのままでは両立しません。
go get rsc.io/sampler@v1.2.0
go: downloading rsc.io/sampler v1.2.0
go: downloading rsc.io/quote v1.4.0
go: downgraded rsc.io/quote v1.5.2 => v1.4.0
go: downgraded rsc.io/sampler v1.3.1 => v1.2.0
go get は sampler だけでなく、要求元の quote ごと v1.4.0 に引き下げました。v1.2.0 と共存できる quote を探した結果です。1つ下げたら要求元が連鎖して下がる。go get のログに downgraded が複数行出たときは、この連鎖が起きています。
go.sumはロックファイルではない
go.sum を package-lock.json に相当するものと説明している記事を時々見ますが、役割が違います。前節のとおりバージョンの決定は go.mod と MVS だけで完結していて、go.sum は決定に一切関与しません。公式リファレンスの “go.sum files” セクションにある役割は暗号学的ハッシュによる検証です。ダウンロードしたモジュールが、最初に取得したものや sum.golang.org が記録しているものと同一かを確認します。
1バージョンに2行ある意味
先ほどのプロジェクトの go.sum は 13 行あり、1つのバージョンに 2 行ずつ並びます。
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
rsc.io/sampler v1.3.1 h1:F0c3J2nQCdk9ODsNhU3sElnvPIxM/xV1c/qZuAeZmac=
rsc.io/sampler v1.3.1/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
h1: の行がモジュール全体(zipアーカイブ)のハッシュ、/go.mod の行が go.mod ファイル単体のハッシュです。分かれている理由は次の実験で見えてきます。
使っていないバージョンの行が残る理由
いま実際に使われる sampler は v1.3.1 だけです。なのに go.sum には v1.3.0 のエントリが残る。go mod tidy -diff(Go 1.23 で追加)で、tidy が何を消すつもりか書き換えずに確認できます。
go mod tidy -diff
diff current/go.sum tidy/go.sum
--- current/go.sum
+++ tidy/go.sum
@@ -2,7 +2,6 @@
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=
-rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
rsc.io/sampler v1.3.1 h1:F0c3J2nQCdk9ODsNhU3sElnvPIxM/xV1c/qZuAeZmac=
rsc.io/sampler v1.3.1/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
削除されるのは v1.3.0 の h1: 行だけ。/go.mod の行は tidy 後も残ります。MVS がビルドリストを計算するとき、選ばれなかった v1.3.0 の go.mod も読んで要求を集める必要があるからです。zip 本体は要らないがメタデータは要る。2 行に分かれているのはこのためです。触ってみるまで、tidy がハッシュを行単位で選別しているとは思っていませんでした。
go.sumを消したらどうなるか
go.sum はバージョンを決めないので、消しても go mod tidy で再生成できます。ただし GOSUMDB(既定値 sum.golang.org)による照合が効かない社内モジュールでは、再生成は「いま取得できたもの」を信頼する操作になります。Git コンフリクトで雑にマージするより、一度消して tidy し直す方が安全です。バージョン自体は go.mod が守っているので、go.sum の再生成で選択が変わることはありません。
go mod whyとtidy -diffで依存を点検する
go mod whyの引数はパッケージ、-mでモジュール
go mod why は引数をパッケージとして解釈します。モジュールのつもりで聞くと、返ってくる答えがずれる。
go mod why golang.org/x/text
go mod why -m rsc.io/sampler
# golang.org/x/text
(main module does not need package golang.org/x/text)
# rsc.io/sampler
example.com/demo
rsc.io/quote
rsc.io/sampler
x/text はビルドに必要なのに「不要」と出ています。import されているのは golang.org/x/text/language のようなサブパッケージで、golang.org/x/text というパッケージそのものは誰も import していないためです。モジュール単位で聞くには -m を付けます。-m 付きの出力は main モジュールから該当モジュールまでの import 経路です。
tidy -diffの終了コードをCIに使う
tidy -diff は差分があると終了コード 1 を返します。実測でも echo $? は 1 でした。CI に go mod tidy -diff を 1 行置くだけで、go.mod の整理漏れをマージ前に検出できます。
GOPROXYとモジュールキャッシュの動き
取得はプロキシ経由がデフォルト
Go 1.13 以降、モジュール取得は GitHub などの VCS 直接ではなく Google 運営のミラーを経由します。既定値を確認します。
go env GOPROXY GOSUMDB GOMODCACHE
https://proxy.golang.org,direct
sum.golang.org
/Users/moha/go/pkg/mod
GOPROXY はカンマ区切りで順に試します。direct は「プロキシを使わず VCS から直接取得する」という予約語。proxy.golang.org に無いモジュールへのフォールバックとして働きます。取得物の検証は GOSUMDB が指す sum.golang.org が担います。
キャッシュはGOPROXYプロトコルの形そのまま
go コマンドはダウンロードしたモジュールを GOMODCACHE 配下に保存します。手元の環境では 1.1GB。中身を見ると、見覚えのある並びが出てきます。
ls "$(go env GOMODCACHE)/cache/download/rsc.io/quote/@v/"
list
v1.5.2.info
v1.5.2.lock
v1.5.2.mod
v1.5.2.zip
v1.5.2.ziphash
@v/list や .info、.mod、.zip という並びは、GOPROXY プロトコルの URL パス(/@v/list、/@v/v1.5.2.zip など)と同じです。ローカルキャッシュ自体が静的なプロキシサーバーのレイアウトになっていて、GOPROXY=file://... でそのまま配信元にもできます。.mod だけあって .zip が無いバージョンも存在し、前節の「go.mod だけ読むケース」がキャッシュ構造にも現れています。
プライベートリポジトリはGOPRIVATEで外す
社内の Git リポジトリを import すると、既定では proxy.golang.org に取りに行って失敗します。公開プロキシに社内モジュール名を送らないためにも、GOPRIVATE を設定します。
go env -w GOPRIVATE="github.com/myorg/*,*.corp.example.com"
GOPRIVATE に一致したモジュールは、プロキシもチェックサムDBも通らない。プロキシだけ・検証だけを個別に外したい場合は GONOPROXY と GONOSUMDB に分けて設定できます。
go.mod が管理するのは import する依存だけではありません。Go 1.24 からは開発ツールも tool ディレクティブで go.mod に載せられます。詳細はGoのtoolディレクティブで開発ツールを管理する—tools.goからの移行手順に書きました。また、go.sum の行数は依存の数だけ伸びるので、標準ライブラリで置き換えられる依存は外す価値があります。Go 1.27の標準uuidパッケージ—google/uuidを外せる条件で書いた uuid はその代表例です。
まとめ
- require は下限指定。実際のバージョンは MVS が「全要求の最大」で決め、go get のダウングレードは要求元ごと下げる
- go.sum はロックファイルではなく検証用ハッシュ。バージョン固定は go.mod の役割で、tidy 後も選ばれないバージョンの
/go.mod行は残る go mod tidy -diffは差分があると終了コード 1。CI での整理漏れ検出に使える- 取得は proxy.golang.org 経由が既定。社内リポジトリは GOPRIVATE で除外する
依存解決の仕様は go.dev/ref/mod に全部書いてありますが、量が多く最初に読む資料には向きません。今回の rsc.io/quote の 3 モジュール構成なら、MVS・go.sum・キャッシュの動きを 10 分で一通り再現できます。