ABEMAのエンジニアの黒崎 ( @kuro_m88 )です。

本日 Go 1.27がリリースされたので、さっそくCI環境でバージョンアップの影響を検証してみました。対象はABEMAの広告配信システムのモノレポで、CIが使うGoを1.27へ更新し、実際のコードベースでビルド、テスト、Lintなどを実行しました。

結論から言うと、ほとんどのCIはそのまま通りました。長く運用しているコードベースでもこの結果が得られたのは、Go 1互換性(Go 1 Compatibility)により、公開APIや仕様で定められた挙動が維持されているからです。恩恵を改めて実感しました。

今までもバージョンアップをして困ったケースはほぼなかったのですが、今回は一部の自動生成コードを検証するCIだけが失敗しました。調査した結果、compress/flateに関係する挙動の変更が原因で、詳細を後半で紹介します。

Go 1.27の気になった変更

Go 1.27のアップデートの中から気になったものを簡単にピックアップします。

  • generic methodsが追加されました
    • 型のメソッド自身が型パラメータを宣言できるようになり、従来はパッケージ関数に置く必要があった処理を型の名前空間に置けるようになりました。これまでの制約を回避するために冗長な型パラメータを宣言している場所があったので、個人的にはありがたい変更です。
  • Go 1.25で実験的に導入されたencoding/json/v2encoding/json/jsontextは、Go 1.27で実験扱いを外れ、正式なパッケージとして提供されるようになりました。従来のencoding/jsonもv2実装を基盤にするようになりました。marshal/unmarshalの挙動は維持されますが、エラーメッセージなどの細部は変わる可能性があります。v2を使っていない実装もv2の恩恵があるのがよいですね。
  • go teststdversion vet checkをデフォルトで実行するようになりました。
    • go.modやbuild tagで定まるGo versionより新しい標準ライブラリAPIの利用を検出できます。
  • 標準ライブラリにuuidパッケージが追加されました。
    • UUIDの生成とパースをサポートし、ランダムなUUID v4に加えて、時刻情報を含むUUID v7も扱えます。外部パッケージに頼らずUUIDを扱えるようになるのは嬉しい変更です。

詳細はGo 1.27 Release Notesにまとまっています。

CIのGo toolchainのバージョンを上げてみる

対象リポジトリのCIで使うGo toolchain(コンパイラ)のバージョンを1.27に上げてみました。これで既存コードのGo 1.27との互換性が確かめられます。結果としてテストは通ったのですが、protobufのコード生成の差分チェックが失敗しました。普段は自動生成しているコードを更新せずにコミットしてしまっていないかチェックする目的で動いているチェックです。今回はコードに差分は加えていないので、このチェックが失敗するのは意図しない挙動です。

- // 1249 bytes of a gzipped FileDescriptorProto
+ // 1245 bytes of a gzipped FileDescriptorProto

上記のような差分と16進数リテラルの差分が散見されました。

*.pb.go に埋め込まれているdescriptor

この差分がどの機能に由来するのか追ったところ、protobufのFileDescriptorProto関連の差分でした。FileDescriptorProtoはprotobufのmessage、field、enum、serviceなど、.protoで定義したschemaそのものを表すメタデータです。

生成コードには、次のような部分があります。

func init() {
	proto.RegisterFile("example.proto", fileDescriptorExample)
}

var fileDescriptorExample = []byte{
	// N bytes of a gzipped FileDescriptorProto
	0x1f, 0x8b, // ...
}

descriptorをprotobuf wire formatへmarshalし、さらにgzip圧縮してから[]byteリテラルとして出力していました。この処理はこちらの実装で確認できます。

Go 1.27のcompress/flate変更

Go 1.27ではcompress/flateの圧縮速度が改善されました。リリースノートをよく読むと、encoder実装の変更によりWriterの出力がGo 1.26と異なる可能性があることを明記しています。DEFLATEはcompress/gzipcompress/zlibarchive/zipimage/pngの基盤でもあるため、様々なところで差分が出ることが予想されます。Go 1.27 Release Notes

今回の変更は、圧縮速度を改善するためにgithub.com/klauspost/compress/flate由来の実装を取り込んだものです。圧縮レベルごとの処理、tokenの扱い、Huffman blockの組み立て方などが見直されています。実装取り込みの経緯はissue #75532、具体的な変更は該当コミットで読めます。

一般的な可逆圧縮では、同じ入力から常に同じバイト列を出すことは必須ではなく、展開後に圧縮前と同じ情報が得られれば問題ありません。圧縮ブロックの境界やHuffman符号が変われば、圧縮後の長さもバイト列も変わる可能性が高いです。今回はこの挙動の変更が影響しているようでした。

後方互換性は壊れていない

Goの後方互換性の方針との整合性が気になるところですが、この挙動の変化をもってしても、Go 1.27に破壊的変更が導入されたとは言えません。

あくまでもDEFLATEの中でどのように圧縮するかが変わっただけで、DEFLATEの仕様そのものは守っています。古いdecoderも新しいencoderが出した正しいDEFLATEストリームを展開でき、新しいdecoderも古いストリームを展開できます。Go 1互換性が保証するのは公開APIや仕様として定められた挙動であり、仕様を満たす範囲内での挙動の完全一致までは含みません。

しかしながら、今回のアプリケーションのCI環境では圧縮後のデータ(生成されたソースコード)をバイトレベルで比較していたため差分が検出されました。

旧世代の proto generatorと現行 generatorの違い

大部分の生成コードには差分がありませんでしたが、一部の*.pb.goだけに差分が出ていました。その違いを追っていくと、差分の出たコードと出なかったコードでは、使用しているprotobuf generatorの世代が異なることが分かりました。以下のような差があります。

観点 旧世代 現行世代
主なmodule github.com/golang/protobuf google.golang.org/protobuf
descriptorの保持 gzip済み[]byte 圧縮前のraw descriptor
gzipする場所 generatorの実行時 runtimeで必要になった時
代表的な生成コード proto.RegisterFileXXX_ protoreflectprotoimplTypeBuilder

現行世代のproto generatorはraw descriptorをGoの文字列リテラルとして生成し、必要な場合だけruntimeでgzipします。圧縮結果を生成ソースに書き込まないため、Goのcompress/flate実装が変わっても、この理由で*.pb.goの差分が出ることはありません。

厳密にいうと、github.com/golang/protobufというmodule名だけで完全に旧実装のソースコードが生成されると判断することはできません。Go protobuf API v1とAPI v2の移行では、github.com/golang/protobuf v1.4.0以降が新実装を内部利用する互換レイヤーになっています。古い生成コードを動かしながらruntimeを更新する経路が用意されているため、今回に関してはバージョンアップしつつ古い世代のprotobufを使っているコードをなくす方針としました。Go protobuf API v2の解説

go.modを変えていなくても生成物は変わる

今回のもう一つのポイントは、go.modgo directiveと、toolchainのバージョンは同じとは限らないことです。

例えば、moduleが次の状態だとします。

項目 バージョン
go.mod go 1.26
toolchain go 1.27

go.modgo directiveは、moduleが前提にする言語・module挙動のバージョンを表します。しかし、generatorバイナリがどのGo toolchainでビルドされ、どの標準ライブラリを内包しているかまでは固定しません。

toolchainがGo 1.27であれば、go.modのバージョンに限らずGo 1.27のcompress/flateを使います。呼び出し元のmoduleがまだgo 1.26を指定していても、生成処理の出力は変わり得ます。go directiveとtoolchain directiveの役割はGo Modules Referenceで確認できます。

今回のprotobufの件以外にもアプリケーションコードで生成されたものをテストコードに埋めていて、それがCI等で差分として検出されるケースがあるかもしれません。外部のライブラリを使っているケースにおいてはバージョンが違えば結果も変わる可能性がある認識はありましたが、標準ライブラリは挙動が非常に安定しているため、たまにこのようなことがあるのは改めて認識しておいたほうがよさそうです。対策としては、バージョン差異があったとしても、問題ない範囲でテストやチェックが通るような検証方法を実装するのがよさそうです。

開発環境とCI環境で厳密にツールチェーンのバージョンを固定する方法もありますが、開発環境の運用しやすさとGoの後方互換性維持のレベルの高さからするとできれば避けたい選択肢です。

まとめ

Go 1.27を既存のモノレポへ適用した結果、ほとんどのCIはそのまま通りました。今までもそうでしたが、今回もGoの互換性維持のメリットが確認できました。一方、protobuf生成コードのチェックが失敗したことにより、protobufのコード生成方式による生成物の違いや、toolchainのバージョン間での互換性の範囲を考慮しないといけない事例を知りました。

みなさんもぜひGoのバージョンを積極的に更新して、Goの進化を楽しみましょう!

アバター画像
2015年新卒入社 ABEMA / サイバーエージェントCTO統括室所属。バックエンドの実装やインフラ、セキュリティを担当。趣味で中古サーバやネットワーク機器を買ってデータセンタに設置して運用しています。