🔍

Go Module Proxyの動作とLICENSEファイルの影響

に公開

はじめにの前に

Asakusa.go#6 でのLTで、「Module Proxyのマニアックな話」というタイトルで発表しました。ただ、5分に詰め込みすぎたため、早足の内容になってしまったかつ、説明不足の部分があったため、記事にしようと思います!

※この記事は2025年11月6日時点での情報を元にしています。

はじめに

Ted Unangstさんという方が以下のブログでModule Proxyの動作について疑問を呈しました。
その後、Russ Coxさんとのやりとりを踏まえて、幾つかのissueが立てられています。

参考ブログ

参考issue

本記事では、これらのブログとissueをもとに、Go Module Proxyの基本的なところから、マニアックなところまでを解説します。
スライドの内容にプラスして、入りきらなかった内容もこれを機に記事にしていきます。

Go Module Proxyとは

まずは基本的なところから。
Go Module Proxyは、モジュールのダウンロード元として機能するHTTPサーバーです。Goチームはproxy.golang.orgでモジュールミラーを管理しており、デフォルトで使用されます。

基本的な動作は次のとおりです。

  1. goコマンドがproxy.golang.orgに接続
  2. プロキシにキャッシュがあればそれを返す
  3. キャッシュがなければVCS(バージョン管理システム)から取得
  4. 取得したモジュールをキャッシュして返す

この仕組みにより、モジュールの可用性が向上し、個別サーバーへの負荷が軽減されます。

問題の発生

Ted Unangstさんは自身のサーバー(humungus.tedunangst.com)に対して、proxy.golang.orgから以下のような挙動を観測しました。

  • 数分おきにhg clone(Mercurial)リクエストが送られてくる
  • 毎回フルクローンが実行される(hg pullのような差分取得ではない)
  • 1つの新しいタグがプロキシに届くと、複数のマシンから同時にクローンリクエストが発生

この問題に対して、Tedさんは24時間に1回のみクローンを許可し、それ以上のリクエストには429 (Too Many Requests)を返す実装を行いました。
しかし、ログには429レスポンスが連続して記録されており、プロキシが何度もクローンを試みていることが確認されました。
ブログのタイトルにあるように、「一体何が起きているんだ?」ということで、Goチームとのやり取りが始まります。

1. LICENSEファイルの影響

調査の結果、LICENSEファイルの有無がキャッシュ動作に大きく影響することが明らかになりました。
LICENSEファイルの有無によって、以下のような違いがあります。

LICENSEファイルがある場合

  • モジュールプロキシに無期限にキャッシュされる
  • バックグラウンド更新は一切行われない
  • pkg.go.devにドキュメントが表示される

LICENSEファイルがない場合

  • 30日でキャッシュ期限切れ
  • キャッシュにない場合、オンデマンドで上流サーバーから取得
  • 25日経過後にバックグラウンド更新を開始(最近アクセスがあった場合のみ)
  • pkg.go.devにドキュメントが表示されない

2. cached-onlyエンドポイントとindex.golang.org

この問題の調査の過程で、もう1つマニアックなことがわかりました。proxy.golang.orgには、実はcached-onlyエンドポイントというものが用意されています。
https://proxy.golang.org/ には以下のように書いてあるのですが、ご存知だった方は少ないかもしれません笑。

通常のエンドポイント

GOPROXY=https://proxy.golang.org

動作

  1. キャッシュをチェック
  2. なければ上流から取得
  3. キャッシュして返す

cached-onlyエンドポイント

GOPROXY=https://proxy.golang.org/cached-only

動作

  1. キャッシュをチェック
  2. キャッシュにあるものだけを返す
  3. 上流へのアクセスは一切なし

index.golang.orgとは

Tedさんの問題を理解するために、index.golang.orgについても説明します。

index.golang.orgは、proxy.golang.orgによって新たにキャッシュされたモジュールバージョンを提供するものです。以下のような情報がJSON形式で提供されています。
https://index.golang.org/index からそのフィードを見ることができます。

{"Path":"example.com/foo/bar","Version":"v1.0.0","Timestamp":"2024-01-01T12:00:00Z"}

これは、新しいGoモジュールを発見したり、エコシステム全体の動きを監視したりする目的でも利用されています。

cached-onlyエンドポイントの使い所

cached-onlyエンドポイントは、大量のモジュールを一括でダウンロードする際に推奨されています。
キャッシュに存在するモジュールのみが取得され、上流サーバーへの不要なトラフィックを回避できます。

Tedさんのケースでは、Pythonスクリプトがindex.golang.orgを監視していて、新しいタグを見つけると全タグをダウンロードしていました。このスクリプトがcached-onlyエンドポイントを使っていなかったことも、トラフィック急増の一因になっていました。

Russ Coxさんも以下のようにcached-onlyエンドポイントをおすすめしています笑。

3. バックグラウンド更新の動作

さらに、モジュールプロキシは上流VCSサーバーから情報を定期的に再取得してキャッシュを更新しています。

更新対象

更新される情報は大きく3つあります。

1つ目は、モジュールzipファイル(LICENSEなしの場合のみ) です。これは30日で期限切れになり、25日経過後に更新が始まります。更新対象となるのは、過去1日以内にアクセスがあった場合のみです。

2つ目は、バージョンクエリ@v/main.info@v/latest.infoなど)です。これは1時間で期限切れになり、25分経過後に更新されます。更新対象となるのは、過去1日以内にアクセスがあった場合のみです。

3つ目は、バージョンリスト@v/list)です。これは3時間で期限切れになり、25分経過後に更新されます。更新対象となるのは、過去3日以内にアクセスがあった場合のみです。

いずれも「最近アクセスがあった場合のみ」という条件付きです。

読み取り増幅という問題

ここで大きな問題があります。現在の設定では、低頻度でアクセスされるモジュールに対して過剰な更新トラフィックが発生してしまいます。

例を挙げると、3日に1回アクセスされるモジュールのバージョンリストの場合、このようになります。

1リクエスト → 3日間の更新を正当化
3日 × 24時間 × (60分 / 25分) = 172.8回の上流アクセス

つまり、読み取り増幅率が172.8倍です。キャッシュしない場合よりも172倍も多くのトラフィックが発生してしまうことになります。

バージョンクエリも同様で、1日に1回アクセスされる場合は57.6倍の増幅が発生します。

1リクエスト → 1日間の更新を正当化
24時間 × (60分 / 25分) = 57.6回の上流アクセス

これは、更新間隔(25分ごと)と「最近」の定義(3日以内、1日以内)が噛み合っていないことが原因です。更新は25分ごとなのに、「最近アクセスされた」の判定が数日分残っているため、こういうことが起きてしまいます。

Go 1.26での改善予定

この問題はIssue #75191で追跡されていて、改善案が出ています。

基本的な方針は、「更新間隔」と「最近」の定義を揃えるというものです。

具体的には、以下のようになる予定です。

  • ライセンスなしモジュールzip:更新は25日経過後、「最近」は過去25日以内
  • バージョンクエリ:更新は25分経過後、「最近」は過去25分以内
  • バージョンリスト:更新は25分経過後、「最近」は過去25分以内

この変更によって、高頻度でアクセスされるモジュール(25分に1回以上)には影響がありません。引き続き25分ごとに更新されます。

一方、低頻度でアクセスされるモジュール(25分に1回未満)では、上流サーバーへの負荷が大幅に削減されます。最悪のケースでの読み取り増幅率が1倍に改善されるので、プロキシがバックグラウンド更新によってキャッシュしない場合よりも多くのトラフィックを送ることがなくなるということです。

4. VCS別の動作

更新時の動作は、VCSの種類によって大きく異なります。

Git

Gitの場合は最適化されていて、git clone --depth=1で履歴なしのシャロークローンを実行します。さらに、変更検出のための軽量チェック機能が実装されています。

軽量チェックの仕組み

Issue #75120によると、Gitリポジトリに対してproxy.golang.orgは以下のような最適化を行っています:

When using Git, both of those fetches are further optimized by saving a hash of a subset of the information available from "git ls-remote". Refreshes run "git ls-remote" and recompute that hash. If it matches the cached hash, then the cached information is still up-to-date and can have its expiry extended, without downloading any extra data from the repository.

具体的には、以下のような流れで動作します。

  1. git ls-remoteでリモートリポジトリのハッシュを取得(数KB程度の軽量な操作)
  2. 前回保存したハッシュと比較
  3. ハッシュが同じ → フルクローンをスキップし、キャッシュ期限だけ延長
  4. ハッシュが異なる → フルクローンを実行

この軽量チェックの仕組みは、goコマンドではgo mod download -reusego list -reuseというフラグとして実装されています。

Issue #75119によると、proxy.golang.orgはこの-reuseフラグを使用してキャッシュの鮮度をチェックしています。

It is used by the Go module mirror (proxy.golang.org) when checking whether its cached information is up-to-date.

Mercurial(改善予定)

一方、Mercurialでは軽量チェック機能が実装されていないため、厳しい状況です。Tedさんのブログによると、

The first issue is that whenever the proxy refreshes a module hosted by mercurial, it performs a full clone.

Mercurialにはgit ls-remoteに相当する軽量な機能がなく、hg identify--tags--branchesフラグはあるものの、リモートリポジトリでは無効になっています。そのため、現時点では毎回フルクローンを実行する必要があります。

この問題はIssue #75119で追跡されており、Mercurial向けの-reuseフラグの実装が予定されています。Mercurialの拡張機能APIとリモートプロトコルを活用することで、実装するらしいです。

Go 1.26でMercurialサポートが実装されれば、proxy.golang.orgはそれを使い始め、Mercurialリポジトリへのトラフィックが大幅に削減されることが期待されます。Tedさんが遭遇したような問題の解決にも貢献するかもしれません。

まとめ

Go Module Proxyの動作について、4つのニッチなポイントを解説しました。

  1. LICENSEファイルの影響
  2. cached-onlyエンドポイントとindex.golang.org
  3. バックグラウンド更新の動作
  4. VCS別の動作

Tedさんの報告がきっかけで、普段あまり意識されないGo Module Proxyの内部動作が少し理解できたような気がします。こういった問題報告とコミュニティの対応を通じて、Goのエコシステムが少しずつ改善されていくのは良いことだと思います。

参考

Discussion