Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

Go製アプリケーション/ライブラリにおけるメンテナンス性を重視したGoのバージョン管理戦略

2024-08-28 GOTOOLCHAIN=auto時にはtoolchainディレクティブに指定したものより新しいGoがインストールされていても戻るわけではないという話を追記しました。

Go言語では半年に1回メジャーリリース(マイナーバージョンの更新)がやってきます。ちょうどこの8月にGo 1.23がリリースされたばかりです。Go言語のメジャーリリースは最新2つ分までサポートされるポリシーであることがhttps://go.dev/doc/devel/releaseに書かれています。現在であればGo 1.23やGo 1.22はサポートされており、Go 1.21はサポートが切れているということです。

また、サポートされているバージョンでは、不定期でマイナーリリース(パッチバージョンの更新)がやってきます。バグ修正や脆弱性対応がメインですね。

Goがリリースされると、Goでアプリケーションを作ったり、ライブラリを公開したりしているみなさんはなんらかの対応を取ることでしょう。一口にGoのバージョンといっても、

  • Go言語のバイナリのバージョン(=go versionで表示されるバージョン・Goのコンテナイメージのタグ)
  • ビルドする際のツールチェインのバージョン(=go.modのtoolchainディレクティブ)
  • ビルドができる最小バージョン(=go.modのgoディレクティブ)
  • 動作を保証する(サポートする)最小バージョン
  • テストを行うバージョン

といくつかあります。

このエントリでは、アプリケーションやライブラリといったリポジトリの性質ごと、前述の様々な「Goのバージョン」をどのように管理、アップデートすると良いのかを紹介します。私は個人でも仕事でもたくさんのGoのリポジトリをメンテナンスしているので、メンテナンスコストの小ささに重きをおいた選択をしています。Goのメジャーリリースのたびに様々なファイルを書き換えなくてはならず大変だと思っている方はぜひ読んでみてください。

一般論

先ほど紹介した「Goのバージョン」には以下のような大小関係が成り立ちます。

  • (ビルドができる最小バージョン)≦(ビルドする際のツールチェインのバージョン)
  • (ビルドができる最小バージョン)≦(動作を保証する最小バージョン)
  • (Go言語のバイナリのバージョン)≧ 1.21.0

ビルドができる最小バージョンというのはGo 1.21以降goディレクティブで指定されるものですが、これはめちゃくちゃ積極的に上げたいものではないと考えています。ビルドできる=サポートするとは一般に言えないでしょうし、なんらかの事情でGoのバージョンを上げられない人が自己責任で利用するのすら拒絶する強い理由もありません。もちろん、新しいセマンティクスを利用したい場合などでは、ライブラリとしてサポートするGoのバージョンポリシーの許す範囲でgoディレクティブを上げることになります。

goディレクティブを積極的に上げる場合でも、ライブラリや、アプリケーションでも公開packageがあるようなコードでは、最低限サポートされているGoのメジャーリリースの下限ではビルドできるようにしておくと良いでしょう。以下のissueは私がk1lowさんにgoディレクティブを上げすぎないでほしいですとお願いしたものです。

github.com

また、Go言語のバイナリのバージョンと、紹介した他の動的な「Goのバージョン」とには特に大小関係が成り立たせる必要がないことにも注意が必要です。Go 1.21から前方互換性を向上させるツールチェインの仕組みが導入され、go.modのtoolchainディレクティブやGOTOOLCHAIN環境変数を参照し、必要であれば新しいGoのツールチェインをダウンロードして使ってくれます。

go.dev

ツールチェインの仕組みがあるので、現代においてgoenvのような複数のGoバイナリを同居させるサードパーティのツールも基本的には不要でしょう。

以上の話を簡単にまとめると

  • Go言語のバイナリのバージョン
    • →現代では正直何でも良い
  • ビルドする際のツールチェインのバージョン(=go.modのtoolchainディレクティブ)
    • →やっぱり最新が最高
  • ビルドができる最小バージョン(=go.modのgoディレクティブ)
    • →利用したい言語機能が増えたときにはじめてアップデートすれば良い
    • →少なくともサポートされているメジャーリリースはカバーしたい

ということです。

アプリケーションでの設定例

ここではArthur1/otlcという拙作のCLIツールのリポジトリを例にして、アプリケーションリポジトリでの具体的な設定をご紹介します。

まず、go.modのtoolchainには、このエントリ執筆当時最新のGoバージョンであるgo 1.23.0を指定しています。toolchainディレクティブを利用するとGoのリリース後にRenovateがtoolchainディレクティブを更新するPull Requestを開いてくれるので、マージするだけでGoのバージョンアップデートが簡単にできます。Pull Request起因でCIを走らせることで新しいバージョンでのテストを行うこともできますね。

一方、go.modのgoディレクティブは更新せず1.22.0のままにしています。私がビルド成果物を提供していないようなCPUアーキテクチャ・OS向けのバイナリを利用者が各自でビルドすることも考えられます。その際に利用できるGoのバージョンを必要以上に狭めたくないからです。

テストやバイナリのビルドはGitHub Actionsで行っているのですが、setup-goではversionをstableに設定しています。setup-goにgo-version-file: go.modと記述するのをよく見かけますが、現在のsetup-goはビルド下限であるgoディレクティブしか見てくれません。例えばgoディレクティブに1.22.0と書かれているなら、まずgo 1.22.0が準備され、その後ツールチェインの仕組みでtoolchainディレクティブに指定したgo 1.23.0がダウンロードされるという挙動になってしまいます。stableを指定しておけば、大体の場合(最新に保った)toolchainディレクティブと一致する以上のバイナリを最初から用意してくれるでしょう。この問題はissueがすでにあるので将来に期待。

github.com

otlcはコンテナイメージも配布しており、公式のgolangイメージをビルドステージとして利用しています。Docker HubにあるgolangイメージではGOTOOLCHAIN環境変数がlocalとセットされており、デフォルトでは必要に応じてツールチェインをダウンロードすることができなくなってしまっています。これはパフォーマンスやDockerfileで宣言したバージョンとの一貫性を意識したもののようです。

github.com

このissueでrscさんがやや否定的なコメントしているのに自分も同感です。goのバージョン管理はgoのエコシステムだけに任せたい派なので、DockerfileでGOTOOLCHAIN環境変数をautoに上書きしています。そしてgolangイメージのタグ指定においてもバージョンを指定せず常に新しいものが利用されるようにしています(setup-goでgo-version: stableを選ぶのと同様の理由です)。

GOTOOLCHAIN=auto(デフォルト)時にはtoolchainディレクティブで指定したもの以上のGoのバージョンを許容します。常にGoを最新にしたいというわけではない場合には、あえてgolangのイメージを古いもの(1.21.0とか)に指定しておくと、toolchainディレクティブで指定したバージョンとピッタリ一致させることができます。

こうせずにDockerfileでの宣言を尊重したい方はビルド時のGoのバージョンを上げる際に同時にgolangイメージのタグも更新する仕組みを作ったり、こちらも合わせてDependabotやRenovateで更新したりようにすると良いでしょう。

長々と書いてしまったのでまとめます。

  • go.modにtoolchainディレクティブを用意し、Renovateで最新に保つ
  • setup-goで指定するgoのバージョンはstableで良い。必要であればツールチェインの仕組みで勝手にDLされる
  • DockerのgolangイメージではGOTOOLCHAIN=localになっているのでautoに上書きする

この通りにすることで、Goの新しいバージョンがリリースされたときにはRenovateが開くPull Requestをマージしてリリースワークフローを動かすだけで、ビルドやテストと様々な場所で使われるGoのバージョンが最新に保たれます。

ライブラリでの設定例

ライブラリの場合、Goのバージョンサポートポリシーはそれぞれのライブラリによって異なります。ここでは、Go言語として公式にサポートされている範囲(メジャーリリース2つ分)だけを同様にライブラリとしてもサポートするという前提で解説します。この前提は、メンテナンスに大量の労力を割けないような場合にも十分理にかなう設定かと思います。

ここではArthur1/http-client-cacheという拙作のライブラリのリポジトリを例にして、ライブラリリポジトリでの具体的な設定をご紹介します。

go.modはgo 1.21.0に固定しています。これはこのライブラリでは少なくともGo 1.22以降の新しい言語機能は現時点で利用していないからです。将来的に上がる可能性はありますが、CIなどで継続的に上げていくことはしていません。Go 1.21で利用することは許可しますが、バグが発生した場合などには大人しくGoのバージョンを上げてくださいと言います。

ライブラリですので、toolchainディレクティブは設定していません。

GitHub Actionsでのテスト時には、setup-goに指定するgo-versionにstableとoldstableの2種類を指定しています。stableエイリアスは最新のメジャーリリースの最新のパッチを、oldstableだと1つ前のメジャーリリースの最新のパッチを指します。エントリ執筆現在だとstableが1.23.0、oldstableが1.22.6ですね。サポート対象(CIでテストしておきたいGoのバージョン)をGo言語のサポートポリシーと一致させることで、setup-goのaliasを利用することができ、新しいメジャーリリースが出ても新しいものを追加して古いものを落とすという手間が必要なくなります。

つまり、Goの新しいリリースがあっても、新しい言語機能を使いたくなるまではリポジトリを何もアップデートする必要がないということです。

もちろん、新しいバージョンが出たときにすぐに動作確認しておきたいならpushベースのCIだけでは足りないので、テストを定期実行、あるいは手動で実行すると良いでしょう。

ライブラリかつアプリケーションでの設定

ライブラリかつアプリケーションというのは何かというと、アプリケーションの提供を主目的とするが公開packageを含むものです。

自分のリポジトリから例としてArthur1/mackerel-sesameを挙げます。これはリポジトリはツールを提供していると同時にクライアントライブラリも公開packageとして含んでいます。

この場合、「アプリケーションの設定例」でご紹介したtoolchainの設定と「ライブラリでの設定例」でご紹介したstable, oldstableのテスト戦略を組み合わせる必要があります。自分はアプリケーションバイナリを提供するだけのつもりでも、公開packageがあればそれに依存する人が出てくる可能性があります。もしこれを意図していない場合、アプリケーションを作るのに必要なpackageはすべてinternal/以下に押し込みましょう。

まとめ

Go自体ももちろん、日々利用する様々なツールでGoのバージョンを管理することができます。しかし、Goのバージョンの決定についてはGoのエコシステムにすべて寄せることで、テストやアップデート時の労力を減らすことができます。よろしければご紹介した設定を利用してみてください。ご意見ご感想もお待ちしています。