ぽよメモ

レガシーシステム考古学専攻

ISUCON12予選突破の記録~絶望の4年間~

はじめに

ISUCON12予選にチーム ツナ缶 として参加し、ギリギリ滑り込んで初めて予選を突破したのでその備忘録です。
下記の様な分担で参加していました。

  • pudding(自分):インフラ担当
  • taxio:アプリ担当
  • shanpu:アプリ担当

この記事では過去の参加記録と共に、主にインフラ周りについて書いています。

インフラ担当ではあるのですが普通にGoのAPIサーバは書けるので、インデックスを貼ったり細かいコードの改善は自分も担当しました。

これまでの記録

大会名 結果 備考
ISUCON8 予選落ち taxioと二人で参加
ISUCON9 予選落ち shanpu参戦
ISUCON10 予選落ち taxioとpuddingは社会人になって初参加
ISUCON11 予選落ち サークルの後輩が初参戦で本戦に行き自信喪失*1
ISUCON12 本戦出場 shanpuは社会人になって初参加

初参戦はISUCON8だったようです。リポジトリを見たら「わからん」って書いてありました。わからんかったらしい。

ISUCON8のリポジトリのメモ

今回で5回目の参加ですが、タイトルは3人チームになってからの4年という感じで受け取ってもらえれば。
これまでは上位25位にかすりもしない結果で、上位チームの解説を読んで「これをこの時間で思いつく人たちに勝たないと上位には行けないんだな」と自信をなくしていました。

社会人になって1・2年目にもそれなりに経験値は上がったような感覚はあったものの、結局上位入りは遠く彼方にあり、少し諦めに似たような感覚がずっとありました。競技自体は面白くとても楽しめるので参加しているが、予選突破は半ば諦めているような心情でした。

参加前の準備

方針の決定

2週間ほど前にオンラインで会議をしました。

ISUCON10・ISUCON11ではNewRelicを使ってプロファイリングを進めていましたが、以下の様な課題がありました。

  • 導入に取られる時間が多い
  • 撤去するのもそれなりに大変
  • データが取れる粒度が粗い(NewRelic Infrastructureだと30秒ごととかだった記憶)

ISUCON11ではNewRelic Infrastructureをやめてhtopなど基本的なコマンドに変更しました。実際のところこれで大きな問題は無かったため今回はコマンド類 + netdataにしました。netdataは1秒ごとのデータが取れてインストールも簡単なので便利ですね。各ホストへはsshでポートフォワードして接続することとしました。

アプリ側でも一度基本に立ち返り、NewRelicをやめてpprof + alp + pt-query-digest に戻ることにしました。この段階ではもし無理だったらNewRelicを入れようという話をしていましたが、結局入れることはありませんでした。

改善〜デプロイ〜ベンチを回すサイクルの高速化

ISUCON8〜ISUCON10までは、自分がインフラ担当として全員の変更のデプロイを担うという構成にしていました。しかし、この構成では改善の適用速度が自分によって律速されてしまうため、ベンチを回すコストが高くなってしまっていました。

そこでISUCON11からは一人一台サーバを割り当てて各自が自由にデプロイできるようにすることとし、ansible playbookとMakefileを用意しました。構成はおおよそ以下の様になっています。

.
├── Makefile
├── TARGET
├── ansible.cfg
├── backup
│   ├── isu1
│   │   ├── etc
│   │   └── lib
│   ├── isu2
│   │   ├── etc
│   │   └── lib
│   └── isu3
│       ├── etc
│       └── lib
├── inventories
│   ├── group_vars
│   │   ├── all.yml
│   │   ├── db.yml
│   │   └── nginx.yml
│   ├── host_vars
│   │   ├── isu1.yml
│   │   ├── isu2.yml
│   │   └── isu3.yml
│   └── hosts.yml
├── playbooks
└── roles

Makefilemake deploy すると現在の状態をデプロイできるようにansible-playbookコマンドを叩く単なるラッパーです。

TARGETファイルがキモで、デプロイ先のホスト名を書いておくと make deploy する際に ansible-playbook --limit $(cat TARGET) するようになっています。これによって全員が自分の担当するサーバにのみプロビジョニングを実行します。複数台構成になってきたときにも対象ホストをカンマ区切りで増やすだけで対応出来るため、最初から最後まで各自の手元から make deploy するだけでデプロイできるようになっています。

backup以下にはサーバの /etc や /lib 以下の変更したいファイルを最初にバックアップしておき、それを編集してansibleで適用します。インフラ担当である自分が一台で検証した内容を他のメンバーのサーバにも容易に展開できます。また改善が進んで複数台構成になると各サーバの設定は少しずつ異なるものになりますが、最初から別々にしておくことで無理なく表現することが出来ます。

inventories以下には各種変数の定義が、playbooks・rolesにはそれぞれplaybookのyamlやroleのyamlが置いてあります。アプリのデプロイに直接関係があるもの以外に例えば

  • スロークエリログを有効/無効化する
  • netdataをインストールする
  • 指定したファイル/ディレクトリのバックアップを取る
  • ログをローテートする

などのロールを用意していました。

当日の動きとしては以下の様になります。

  1. 担当するホストを決めておく
  2. puddingがssh configを用意してメンバーに展開
  3. 初期バックアップを取ってplaybookを微調整
  4. 後は各自で make deploy

ドキュメントの用意

今まではリポジトリのissueにスニペットやリンクをペッと貼って終わりにしていましたが、毎年やっていて無駄だなと思うようになったので今年からは簡単なドキュメントを用意しました。

isu12q/docs at master · pddg/isu12q · GitHub

  • アプリケーションログの見方
    • journalctlの使い方など
  • スロークエリログの解析の仕方
    • mysqldumpslowの見方
    • pt-query-digestの見方
  • アクセスログ解析の仕方
    • nginxのログフォーマット
    • alpの使い方
  • サーバの状態の観測方法
    • netdataへのアクセスの仕方
    • htopの使い方
  • MySQLの非同期レプリケーションに関する簡単なメモ
    • 結局使わなかった

競技中は細かい説明をすることなく「ドキュメントがあるので見て」と言って済ませていました。もう少し充実させたいですね。

当日やったこと

自分がやったことは細かい改善と構成変更が主なので、直接スコアに響くことは少なめでした。アプリ側の改善は他の二人のブログをご参照頂ければと思います。

10:00〜11:00 初期セットアップ

ssh configを書いてデータをバックアップしてalpのためにログフォーマットを仕込んでスロークエリログを有効化して……などをやっていました。isucon10の予選問題を参考にplaybookを仕込んでいたのでいくつか微調整が必要となり、結局50分ほどここで消費していました。その間taxioにはレギュレーションやコードを見てもらっていたので、完全に手を止めさせていた時間は少なかったと信じています。

11:00〜11:30 作戦会議

アプリについて認識を合わせた後、各自でコードを読んで改善ポイントのあたりをつけました。
このあたりで既にSQLiteからMySQLへの移行はやめておいた方がいいのではという話になっていました。結局負荷の要因がMySQLに移るだけで、かけた時間に対して明確にスコア改善に繋がるか怪しいという話をしていた覚えがあります。

11:30〜13:45 細かい改善・レビュー・バグ取り

自分は主にメインのロジックに関わらない細かい改善を担当し、public.pemの読み込みを一回にしたり*2MySQLの方にインデックスを張ったりしていました。

github.com

13:45〜14:30 docker外し

アプリケーションはdocker-composeで動いていたため、余計なオーバーヘッドかかってる可能性ありそうということで外しにかかりました。結果スコアへの影響は明確にはありませんでしたが……(そりゃそう)*3

remove docker-compose by pddg · Pull Request #26 · pddg/isu12q · GitHub

なぜか isuports package の方をビルドして動かねぇ……とか言っていた無駄な時間があり、本当に無駄でした。普段なら3秒で気付けたはず……思うようにスコアが伸びず焦っていたんでしょうね。

14:30〜15:15 MySQL移行を諦めきれず裏でトライ

sqlite3-to-sql が吐くダンプを見て厳しそう〜と思いつつ、数十分程度で終わるなら十分アリだなと思いとりあえず簡単にスクリプトを組んで試していました。参加した方なら分かると思いますが、1.db すら終わることは無かったのでサクッと切り上げて辞めました。

この時点で最後までテナントのDBをMySQLに移すことはせず現状で改善していく方針とすることになりました。

15:15〜16:00 SQLiteへのクエリでトランザクションを使う

SQLiteに詳しいわけではないのですが、調べている限り多数のINSERTではトランザクションを使うと高速化するらしいということがわかっていました。どうせflockを外すためにはトランザクションが必要であったため、半信半疑でトランザクションを入れて一部のflockを外しました。
use transaction to insert into tenant database by pddg · Pull Request #33 · pddg/isu12q · GitHub

このときshanpuが取り組んでいた別の改善(row_num外し)*4と合わせるとスコアが15000を超えそこまでの最高スコアの2倍以上になったので、競技中で一番興奮しました。

16:00〜16:30 サーバ1台をMySQL専用にする

そろそろ自分が専用のサーバ一台を抱えているのは無駄になってきたのでそれをMySQL専用とすることにしました。
use isu3 as database by pddg · Pull Request #39 · pddg/isu12q · GitHub

  • bind-addressを書き換える
  • 残りのサーバの環境変数をいじってそのサーバに向ける
  • ベンチ回して計測

この時点でMySQL専用サーバはメモリ・CPU共に余力があったので改善のウェイトを下げました。

16:30〜17:00 無駄なWHEREを削る

shanpuとtaxioが裏でバンバン改善してくれていたので、自分は相変わらずちょっとした改善を続けていました。
remove tenant id condition by pddg · Pull Request #45 · pddg/isu12q · GitHub

SQLiteの移行を諦めるという選択をとったことで、テナントDBに対するクエリで tenant_id でフィルタしている部分は必要なかったので外しました。テナントDBへの問い合わせなのか、MySQLの方への問い合わせなのかを目grepして確かめていたので、やることに対しておもったより消耗しました。

17:00〜17:15 テナントDBにインデックスを張る

ずっと手を付けずにいた部分にようやく手を付けました。各サーバの ${HOME}/initial_data 以下にある各データベースファイルにクエリを流し込みました。
が、あんまりちゃんと計測せずに適当に残っている player_id と competition_id にだけインデックスを張ったのはあまり良くなかったなと反省しています。

レギュレーションを読んでinitial_dataに手を入れることに問題は無いはず、とメンバーと共に何度も確認しましたが競技終了後に3回レギュレーション読み直したくらい不安になりました。

17:15〜17:30 サーバ1台をnginx専用にする

netdataを眺めているとnginxが5~10%くらいCPUを食っていたので別サーバに分けました。
use isu1 as nginx, isu2 as app, isu3 as db by pddg · Pull Request #51 · pddg/isu12q · GitHub

ここで最終構成が確定し、nginx、アプリ、MySQLにそれぞれ1台ずつ割り当てた3台構成になりました。

この頃はどうやったらテナントDBをシャーディングできたか、みたいな話をしていた覚えがあります。力不足を感じますね。

17:30〜17:45 再起動試験

そろそろやろうということで再起動して試験しました。単に各ホストへsshして sudo reboot し、ベンチが回るか確認しただけです。

チームメンバーには言っていませんでしたがさらっとisucon-env-checkerも叩いていました。

17:45〜17:55 flock外し

トランザクションを使うようにしていたので実際このflockは必要なくなっているはずでした*5
ただ不安だったので1箇所外してはベンチを回し、を繰り返して落ちたらその時点で切り戻すという判断をしました。

不整合で失格などにはならなかったのですが、逆にスコアが下がったので1箇所だけ外して残りはそのままとしました。

Rm flock by pddg · Pull Request #54 · pddg/isu12q · GitHub

最終スコアは 23158 でした。🙏🙏🙏🙏 祈り 🙏🙏🙏🙏 を捧げてゲーム終了としました。

1時間前のスコアと比較すると10位以内に入っていて、今までの中では最も予選突破できる可能性が高そうということで、嬉しくなってスクショを撮りました。

競技終了間際のダッシュボード

感想戦でプロファイリングツール外したらスコア爆伸びした報告がよく見られたので、再起動試験で大量にふるい落とされない限りこれは厳しいな……と思っていました。翌日予選突破とわかり、リアルにちょっと飛び跳ねました*6

現実を受け入れられない僕

アプリ担当のやったこと

このままではインデックス張ってトランザクション使って複数台分散しただけで本戦行けたように見えてしまいそうだったので、アプリ担当がやってくれたこともメモ程度に書いておきます*7

振り返り

良かったところ

  • 一人一台を割り当て全員がデプロイできるようにすることで、インフラ担当が他のことをやっている間にもアプリの改善を進めてもらうことができた
  • スロークエリログやアクセスログの解析ドキュメントをまとめていたので、当日はそれを参照すれば十分ボトルネックの解析ができた
  • MySQLのインデックスについてちゃんとカバリングインデックスを意識することができた
  • ちゃんとスコアが上がったことで自分たちが積み重ねてきたものに対して少し自信が付いた

改善したいところ

  • デプロイ→ベンチ→結果の収集の流れの体験が良くなかった
    • スロークエリログの解析結果やpprofの結果を自動で回収させる仕組みがあるともっと良かった
  • 事前に用意したansible playbookの細かいバグで詰まった
    • ログローテーションの設定が壊れていたり、typoがあったり……すぐ分かるミスを事前の検証で洗い出せていなかった
  • アプリ側のロジックと合わせたDBの最適化まで手が回らなかった
    • 例えばvisit_historyはインデックス張るだけでなく、そもそもこんなにデータいらないんじゃないかとかそういう方面に気を回す余裕が無かった

本戦に向けて

nonyleneくんの記事を読んでこれが本物のインフラ担当ってやつか……ってなりました。

nonylene.hatenablog.jp

普通にこれを真似るのもよさそうではありますが、それは来年にとっておこうかなと思っています。既に慣れたこの仕組みをブラッシュアップした方が本戦で迷わず済みそうな気がするので。
過去の本戦問題もまともに解いたことがないので、初の本戦を全力で楽しんであわよくば100万円もらいたいの気持ちで行きます。

おわりに

特に今回ISUCON初参加した人の中には「一生予選突破できないのでは」と感じている人がもしかしたらいらっしゃるかもしれません。毎回本戦出場みたいな激つよチームばかりが勝っているのではなく、4年同じチームで挑戦しつづけようやく突破できたみたいなチームもあることを知ってもらい、次への活力としてもらえると嬉しいです。


*1:この後輩たちは今年も本戦出場並のスコアをたたき出しており、若者こわい

*2:最終的にtaxioが中身をまるごとコードに埋め込んでくれました

*3:このPR見るとわかりますが普通に環境変数が間違っています。デフォルト値でうまく動いていたので気付いていなかったというオチでした。後で気付いて直しています。

*4:taxioの環境でflakyな整合性エラーによる失格が発生しており、一度revertされてしまいましたが最終的には取り込まれました。なおこのflakyなエラーはtaxioが initial_data を手でいじってしまっていたことによるものでした。即座に他のサーバから初期データのダンプを取ってきて上書きする、という判断を取れずコードが悪いのかずるずる考えてしまったのは大変良くなかったです。反省。

*5:flockではなくmutexにするアイデアも最初に出たのですが、mutexのmapを触る操作でグローバルなロックを取らないとrace conditionでダメなのでは、という話になって実施しませんでした。他のチームを見ていると導入したところもあったようなので、うまくやればできたんでしょうか?

*6:挙動不審だったので喫茶店の店員さんにビビられました

*7:二人はブログ書いてくれないらしいので……

イケてる高級キーボードmountain ergoを買った

My new keyboard...

KBDfansのmountain ergoを買いました。

[Restock] KBDfans mountain ergo keyboard kitkbdfans.com

f:id:pudding_info:20220306105849j:plain

f:id:pudding_info:20220306113821j:plain
背面のロゴ

f:id:pudding_info:20220306113842j:plain
リストレストのロゴは彫り込み

キースイッチ

DUROCK POM T1 Sunflowerを採用しました。

talpkeyboard.net

基本的にリモートワークをしているのでマイクが音をできるだけ拾わないよう静音軸、かつリニアは苦手なのでタクタイルで探していました。以前Choco60を組む時に採用したZilent V2*1でも良かったのですが、ガタつきを感じる点を以前から残念に思っており、せっかくなので新しいものに手を出すことにしました。

最初はDUROCK T1 Shrimp*2を使うつもりだったのですが、ついでに試したT1 Sunflowerの滑らかさにハマったのでこれにしました*3

f:id:pudding_info:20220306113454j:plain
ビルド過程を撮るの忘れてた

このキースイッチは軸の擦れるような感覚がほぼ無く、非常に滑らかに押し込める感触が素晴らしいです。更にガタつきもほぼなく、精度の良さを感じました。押下直後に比較的大きなパンプを感じる点は人を選ぶかも知れませんが、タクタイルスイッチとしての出来は非常に良いと感じました。ファクトリールブ済みで初心者でもオススメしやすいのも良いところですね。願わくばこの静音版が出ますように。

基本的にはファクトリールブの状態で満足だったのですが、スプリングに軽くKrytox GPL 105を塗布するとスプリングの甲高い音の響きが緩和されるような気がしたので、わざわざ一度全部バラしました*4

f:id:pudding_info:20220306112537j:plain
バラすのは10分、戻すのに2時間

総じて非常に満足感の高いスイッチでしたのでオススメです。

キーキャップ

KAT BOW PBTにしました。

KAT BOW PBT Keycaps setkbdfans.com

f:id:pudding_info:20220306113721j:plain
比較的柔らかい印象のキーキャップ

mountain ergoと一緒に発送してもらうと安上がりだったのでKBDfansで買いましたが、国内では今のところ遊舎工房で手に入るようです。

KAT BOW PBT Keycaps setshop.yushakobo.jp

KATはKAT Alphaの印字にじみ問題*5があり少し警戒していましたが、今のところ品質は良さそうで問題は感じていません。
強いて言うなら、思ったより暖色系の白でmountain ergoの硬い白色とはちょっとイメージが違った*6のが残念ではありました。

他のかわいらしいキーキャップと悩みはしましたが、必要なコンベックスキー*7の長さ(2uと2.75u)の合うセットがこれくらいしかなく、実際のところ選択肢がほぼないという状況でした。GMK Nimbus*8の色が好みなので、届いたらそれに変えようかなと思っています。

ファームウェア

公式で案内されている通り、ydkb.io を使うことになります。 ydkb.io

正直言うとVIAとかにして欲しかったという気持ちはありますが、仕方ありません。書き込み方は簡単で

  1. ydkb.ioでキーマップを作る
  2. ydkb.ioから作ったファームウェアをダウンロード(MOUNTAIN.BINみたいなファイルをダウンロードできる)
  3. mountain ergoのesc(一番左上のキー)を押しながらUSBケーブルを接続
  4. 書き込み可能な外付けディスクとして見えるようになるので、MOUNTAIN.BINをコピペ
  5. 既にあるMOUNTAIN.BINを上書き
  6. USBケーブルを挿し直し

これで適用が完了します。公式の手順ではMacの場合は上書きせず消してから書き込めと書いてあったんですが、これをすると空き容量不足で書き込めなかったため単に上書きしました。それでもうまく動いているのでたぶん大丈夫っぽいです。

一つだけ罠ポイントを紹介しておきます。Windowsで自作キーボードをUS配列として使っている人はご存じかと思いますが、変換・無変換キーはうまく認識されません。ydkb.ioを使っていても例外ではなく、キーマップで変換・無変換キーを割り当ててもWindowsからは別のキーとして認識されます。自分は代わりにF13、F14を割り当て、PowerToysのKeyboard managerを使ってそれぞれ無変換/変換にリマップしています。

f:id:pudding_info:20220306120841p:plain
PowerToysマジ便利

かかった金額

項目名 値段
mountain ergo本体
(Top/bottom E-white, aluminium plate, Hot swap PCB)
$462
KAT BOW PBT $79
Route package protection*9 $11.33
小計 66297

キースイッチが

項目名 値段
お試し購入 2050円
DUROCK T1 Sunflower 7710円
小計 9760円

総額76057円でした。

届く過程で一度日本に入ってきてそのままハノイに出て行ってしまい、また日本に入ってくるみたいな謎ルートを通っているのを見てゲラゲラ笑えた点などを加味しても高いですね。まぁ仕方ない。
キーキャップとキースイッチ以外はほんとにほぼ全部入っているので、こんなもんかなという印象です。

なんで買ったの?

Choco60には非常に満足していました。慣れ親しんだ物理配列、カスタマイズできる機能、あとGopherくんキーキャップ*10がかわいい。

f:id:pudding_info:20220306114439j:plain
おだんごGopherくん

一番大きな理由としてはメンタル的なところでした。ここしばらく特に良いことがなく、むしろ世界情勢はどんどん悪い方向にいっており、気分が滅入ってきていたことでパフォーマンスが下がり気味になっていました。ちょうど賞与も入ったので、自分の精神的な鼓舞のために前から気になっていたAlice配列のお高いキーボードを買ってみました。
どの程度効果があるかは分かりませんが、少なくとも使っていて楽しいですし、作っている最中も(いろいろあったけど)わくわくしたので体験としては非常に良かったです。

あとは前述したとおり前からAlice配列のキーボードは気になっており、昨年はMeridian R2を買うかどうか二週間くらい悩んで結局やめていたというのもありました。

ai03.com

基本的には分割キーボード以外を使うつもりはなかったのですが、どうしても筐体が軽く打鍵感がおもちゃっぽくなりがちである点などから、ちょっと気分転換してみてもよいかなと思っています。今年は後半にKinesis Advantage 360のカスタム可能モデルが発売される(かも)という話もあり、それまでしばらくはmountain ergoとChoco60を併用してみるつもりです。

まとめ

f:id:pudding_info:20220306123132j:plain
作業領域をもう少し俯瞰で見た様子

mountain ergoのいいところ

  • 非常に重くしっかりしたつくりの美しい筐体(鈍器)
  • 心地よい打鍵感
  • 全部入りなので後はキーキャップとキースイッチを揃えれば組めるという初心者設計
  • USB-C対応・キーマップはブラウザがあれば編集可能

mountain ergoの微妙なところ

  • ydkb.ioに依存している
  • 値段が高い
  • 合うキーキャップセットが限られている
  • リストレストはもう少し明るい色の選択肢が欲しかった
  • 初心者向けではあるけど、ビルドガイドは付属していないのでやっぱり難易度は高め

メンタルを救う代わりに財布は犠牲になったのだ……これは致し方のない犠牲。
皆さんにも気分転換にお高いキーボードに変えてみるというソリューション、おすすめしておきます。


*1:poyo.hatenablog.jp

*2:talpkeyboard.net

*3:@社の人 今度から会議中にカタカタ聞こえたらたぶん僕です

*4:気のせいかも知れません

*5:talpkeyboard.net

*6:写真では思ったよりわからないですね

*7:スペースバーのような、上に凸の形状になっているキー

*8:geekhack.org

*9:輸送途中の貨物の紛失や破損に対する保証。気がついたら付いてました(よく読んでなかった……)。

*10:kochikeyboard.stores.jp

go.modについての陥りやすい誤解

はじめに

 これはあくあたん工房アドベントカレンダー 2021 11日目の記事です。
 ポエムを書いていたら気分が暗くなったので、消して自分の過去のメモを記事にすることにしました。そんな解釈するやつおらへんやろwwと是非笑って読んでください。

 2023-09-19追記:Go 1.21からいくつか挙動に変更が入ったので加筆しています。これまでの内容には触らずそのままに、1.21以降における挙動を追記しているのでご注意ください。

go.modにおけるGoのバージョン指定

注意:この節ではGo 1.21未満における挙動について解説します。1.21以降では異なる挙動になります

go.modには go 1.17 のように、Goのバージョンを指定するディレクティブがあります。

  • 陥りやすい誤解:指定したバージョンでのみビルドする。
  • 実際の挙動:指定したバージョンまでの言語機能のみが利用できる。

https://golang.org/ref/mod#go-mod-file-go

the go directive still affects use of new language features

go 1.15 と書いた場合にでもGo 1.17でビルドすることは出来ます。ただしGo 1.17や1.16で入った新しい言語機能を使うことは出来ません。上のページにも下記の様な例が掲載されています。

For example, if a module has the directive go 1.12, its packages may not use numeric literals like 1_000_000, which were introduced in Go 1.13.

依存先のgoディレクティブの方が古いバージョンを指す場合

以下のGoを使ってビルドします。

❯ go version
go version go1.17.5 darwin/arm64

こんな感じでファイルを用意しました。

.
├── A
│   ├── go.mod
│   └── hello.go
├── B
│   ├── go.mod
│   └── hello.go
├── go.mod
└── main.go

ルート直下のgo.modでA・Bをそれぞれrequire && replaceしています

module main-module

go 1.17

require (
    A v0.0.0
    B v0.0.0
)

replace A => ./A
replace B => ./B

各モジュールA・BはHelloという関数を持っており、自身のモジュール名をしゃべります(↓はモジュールAの場合)。

package A

import "fmt"

func Hello() {
    fmt.Println("This is module A.")
}

ルート直下のmain.goでそれらの関数を呼びます。

package main

import (
    "A"
    "B"
)

func main() {
    A.Hello()
    B.Hello()
}

ただし、go.modで指定するgoディレクティブをそれぞれ以下の様に設定します。

  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.17
--- A/go.mod 2021-12-11 09:35:59.000000000 +0900
+++ B/go.mod  2021-12-11 09:36:05.000000000 +0900
@@ -1,3 +1,3 @@
-module A
+module B

-go 1.17
+go 1.12

この状態でgo run main.goすると、正しくビルドされ実行できます。

❯ go run ./main.go
This is module A.
This is module B.

また、go 1.17を指定しているモジュールAやmainモジュールでは、Go 1.17までの言語機能を全て使うことが出来ます。ただしmodule B内でGo 1.12よりも新しい言語機能を使うことは出来ません。ここではリファレンスに掲載されていた例にならい、1_000_000 というリテラルを使ってみることにします。

package B

import "fmt"

func Hello() {
    fmt.Println("This is module B.")
    // Go 1.13以上でしか使えないリテラル
    fmt.Println(1_000_000)
}
❯ go run ./main.go
# B
B/hello.go:8:17: underscores in numeric literals requires go1.13 or later (-lang was set to go1.12; check go.mod)

依存先のgoディレクティブの方が新しいバージョンを指す場合

例えば以下の様な場合です。

  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.12

普通にビルド・実行ができます。

❯ go run ./main.go
This is module A.
This is module B.

ここで、モジュールAでGo 1.12では使えない機能を使ってみます。

package A

import "fmt"

func Hello() {
    fmt.Println("This is module A.")
    fmt.Println(1_000_000)
}

これもまたビルド・実行することができます。

❯ go run ./main.go
This is module A.
1000000
This is module B.

ただし、main.goの中でGo 1.12よりも新しい機能を直接使うことは出来ません。

package main

import (
    "A"
    "B"
    "fmt"
)

func main() {
    A.Hello()
    B.Hello()
    // これを足す
    fmt.Println(1_000_000)
}
❯ go run ./main.go
# command-line-arguments
./main.go:13:17: underscores in numeric literals requires go1.13 or later (-lang was set to go1.12; check go.mod)

goのバージョンよりgoディレクティブが先行する場合

ここまでは全てGo 1.17.5でビルドしてきましたが、試しにGo 1.16くらいでビルドしてみます。

❯ go version
go version go1.16.12 darwin/arm64
  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.17

を指定していてかつGo 1.17以降でのみ使えるような機能を含まない場合。

❯ go run ./main.go
This is module A.
This is module B.

このときGo 1.17でしか使えないような機能を使うとビルドに失敗します。例えばGo1.17でmathモジュールに追加された MaxInt を参照するとエラーになります。

❯ go run ./main.go
# command-line-arguments
./main.go:13:17: undefined: math.MaxInt
note: module requires Go 1.17

goディレクティブまとめ

  • あるモジュール内ではgoディレクティブで指定したバージョンまでの言語機能が使える。
  • 自身より先行するgoディレクティブを指定するモジュールを使える(1.21未満の場合)
    • ただし最初の制約により、自身の中では自身の指定したバージョンまでの機能しか使えない。
  • 自身より小さいバージョンを指定するモジュールも使える。
    • ただし最初の制約により、そのモジュールの中ではそのモジュールの指定したバージョンまでの機能しか使えない。
  • goバージョンよりgoディレクティブが先行していても、そのgoバージョンまでで使用できる機能だけで構成されていればビルドできる。

実際にはもっと細かい違いなどがあるはずなので、これ以上は公式のリファレンスを読みましょう。

https://go.dev/ref/mod#go-mod-file-go

1.21以降のgo.modにおけるGoのバージョン指定

Go 1.21のリリースノートで言及されていました。挙動が以下の様に変更されています。

  • 陥りやすい誤解:指定したバージョンでのみビルドする。
  • 実際の挙動:指定したバージョン以降でのみビルド可能

Go 1.21 Release Notes - The Go Programming Language

Go 1.21 now reads the go line in a go.work or go.mod file as a strict minimum requirement: go 1.21.0 means that the workspace or module cannot be used with Go 1.20 or with Go 1.21rc1.

これはGo 1.21以降のみに適用されるため、例えば go 1.21 と書かれた(ただしgo 1.21の新機能を含まない)goモジュールをgo 1.20.xでビルドすることは可能です。

require時のバージョンの指定

require ディレクティブでバージョンを指定できます。go get モジュールパス@バージョン などとすると指定のバージョンに固定できます。

  • 陥りやすい誤解:このモジュールはそのバージョン以外は許容しない。
  • 実際の挙動:そのバージョン以上、次のメジャーバージョン未満を許容する。

例えば require A v1.0.0 とすると、このAの取り得るバージョンの範囲はv1.0.0からv2.0.0未満となります。ただし勝手に変わるわけではなく、他の依存先が他のAのバージョンに依存しているわけでないならv1.0.0が利用されます。詳しくは次節で説明します。

この挙動は同じメジャーバージョンの間で完全な後方互換性が保たれることが前提となっています。自身でモジュールを作る時は、この点に十分配慮する必要があります。

Minimal version selection

Goがモジュールのバージョンを選択する際のアルゴリズムです。

  • 陥りやすい誤解:そのモジュールの一番小さいバージョンを使う*1
  • 実際の挙動:依存を全部考慮したときに要件を満たす最小バージョンを選ぶ

以下に公式の仕様があります。

https://golang.org/ref/mod#minimal-version-selection

つまり、あるモジュールAについてモジュールBには require A v1.1.0、モジュールCには require A v1.3.0 という制約があったとき、BとCを同時に利用しAのバージョンを明示しない場合はv1.3.0が利用されます。また、BとCを同時に利用しかつrequire A v1.1.0 などとより低いバージョンを明示しても、v1.3.0が利用されます。

バージョンを完全に固定したい場合は replace を利用することになります。例えばv1.1.0 に強制的に固定したい場合は replace A => A v1.1.0 とします。これは利用するモジュール全てに影響し、強制的にv1.1.0を使うことになります*2

v2などメジャーバージョンが変わる場合はインポートするモジュールパスが異なる、という制約があるため、同時にrequireに指定して使うことが出来るはずです*3

モジュールのバージョン

  • 陥りやすい誤解:セマンティックバージョニングに従うタグでしか指定できない
  • 実際の挙動:特定のコミットハッシュを指すことができる

go.modの仕様には疑似バージョン(Pseudo version)というものが記載されています。
https://go.dev/ref/mod#pseudo-versions

これは特定のコミットハッシュからセマンティックバージョンに対応したバージョン文字列に変換するための仕様です。例えば v0.0.0-20191109021931-daa7c04131f5 のようなものです。つまり

<既にあるバージョン+1>-<commitの日時>-<12桁のコミットハッシュ>

というバージョンが構成されます。既にあるバージョン+1とは、vX.Y.Z が既にあり指しているコミットハッシュがそれよりも新しい場合、vX.Y.Z+1 になるという意味です。ここでバージョンがまだ一つも付けられていない場合に v0.0.0 が採用されます。

golang.org/x/* などにはこのバージョニングを採用しているものが複数有ります*4。例えば golang/x/netリポジトリ(ミラーですが)を見るとタグが一つも付いていないことがわかります。

github.com

これを利用すると、vendoringはしたもののglideやdepに触れることなく今に至る古のGoプロダクトを、破壊的なことをせずにgo modulesに対応させることができます*5。ただしvendoringしたときのコミットないしバージョンがわかっていることが前提になります。

replaceの波及先

  • 陥りやすい誤解:replaceディレクティブは常に有効
  • 実際の挙動:replaceディレクティブは、main moduleに書かれたもののみが有効。

あるモジュールA内のmainパッケージをビルドするとします。ここで、モジュールAは別のモジュールBに依存しているとします。このとき、モジュールBのgo.modに記述されたreplaceは無視されます。

https://go.dev/ref/mod#go-mod-file-replace

replace directives only apply in the main module’s go.mod file and are ignored in other modules. See Minimal version selection for details.

依存先が別のパスにreplaceしている場合

依存先でのバージョンのreplaceは単に無視されてビルドされますが、モジュールパスを書き換えてしまっている場合にどうなるかやってみます。

.
├── A
│   ├── go.mod
│   └── hello.go
├── B
│   ├── go.mod
│   └── hello.go
├── go.mod
└── main.go

モジュールAはモジュールBに依存し、main.goはモジュールAに依存しています。ここで、AからBを使うためにreplaceを使ってローカルパスを指すようにします。

module A

go 1.17

require B v0.0.0

replace B => ../B

そしてルート直下のgo.modでこのAをrequireします。

module main-module

go 1.17

require A v0.0.0

replace A => ./A

A・Bは自身のモジュール名をしゃべる関数Helloを持っていますが、AのHello内でBのHelloも呼ぶことにします。

package A

import (
    "B"
    "fmt"
)

func Hello() {
    fmt.Println("This is module A")
    B.Hello()
}

このAのHello関数をmain.goで呼びます。

package main

import "A"

func main() {
    A.Hello()
}

このビルドは失敗し、非常にわかりにくいエラーが出ます。

❯ go run ./main.go
A/main.go:4:5: missing go.sum entry for module providing package B (imported by A); to add:
    go get A@v0.0.0

replaceできていないので、go buildではBを通常のモジュールとして認識し、チェックサムを確認しようとします(たぶん)。しかし、go.sumにBについての記述がないので、ちゃんとgo getできてないんじゃない?というエラーを吐いているという事だと思います(たぶん)。

ここでルート直下のgo.modでBに対するreplaceを書いてやると正しくビルドすることができます。

module main-module

go 1.17

require A v0.0.0

replace A => ./A
replace B => ./B
❯ go run ./main.go
This is module A
This is module B

go.sum

  • 陥りやすい誤解:package-lock.jsonのようなロックファイル
  • 実際の挙動:モジュールのあるバージョンのハッシュ値を記録したもの

https://golang.org/ref/mod#go-sum-files

これはロックファイルではありません。モジュールのハッシュを計算し、記憶しているだけです。 これにより、同じバージョン・同じモジュール名で改ざんされたモジュールがあったとき、改ざんされたことを検知できるようになっています(なので、リポジトリに含めておいた方が良い)。

まとめ

公式リファレンスを読みましょう。

golang.org

以上です。アドベントカレンダーの他の記事も、是非お楽しみください。


*1:だってminimumをselectするって書いてあるじゃん!

*2:ただし後述するように、これはmainモジュールのgo.modに書かれたreplaceのみが反映されるため、依存先のreplaceは機能しません

*3:たぶん

*4:タグが付いているものもありますgithub.com

*5:みんなもうgo modules使ってるからこんなことはしなくて大丈夫だよね!!