これは、前人未踏の地に飛び込んでいった漢たちの物語・・・
ツッコミ所がいっぱいあると思います。どんどんツッコんで賑やかしてください。ISUCONの経験がそんなにあるわけではないので、アホみたいなことをしているかも。俺をBIGにしてください!
また、書いている話は「ISUCONやそれに近しい局面ではこう」という話なので、人類普遍の真実を述べているわけではない。
あらすじ
ISUCON14お疲れさまでした。
ISUCONといえば例年いろいろな言語で参考実装が公開され、そのうちどれかを選んで改善していくというスタイルが一般的、というか普通なのだが、自分たちは参考実装が一切存在しないScala 3で突入し、1000点ちょっとを獲得し、最後らへんでnginxの調子がおかしくなって0点となり散っていった。そういう人生も良いと思います。
ちなみに測定とかの話は出てきません。測定まで行けなかった物語です。歌や詩に近く、花や雷鳴に近いものです。
薄氷を踏め!着剣!
構成(人間)
- 2人
- Scala慣れしていて関数型もろもろの心得がある 1人
- Scala慣れしている 1人
ISUCONやると聞いていてもたってもいられずメン募したのだが年末ということでなかなか集められなかった。みんな忙しい。次回は集まるといいですね。
やっぱり2人だと繰り出せる技にも限界があり、こういう構成にしたほうが良くないっスか?みたいな会話も生まれづらいかなと思った。目玉は多ければ多いほどいい
構成(硬件)
- インスタンス2台をwebに
- インスタンス1台をDBに
我々はただでさえハンデを背負っているので、あんましインフラ側で気合を入れるのはよそうということで一般的な構成にした。というか、なかなか実装が終わらなかったので2台にした意味はそんなになかった。
構成(軟件)
ソフトウェアの方ではいろいろ工夫した点がある。
デプロイ
デプロイには色々手法があると思うけれど、GitHubにプッシュしたら自動的にリリースビルドされるという機構にした。GitHub Actionsが動いて、最新のセマンティックバージョンを読みに行き、それのパッチバージョンを++
して新たにタグを打つのだ。
on: push: branches: - main workflow_dispatch: name: Release jobs: build: name: Create Release runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup java uses: actions/setup-java@v4 with: distribution: 'corretto' # as you like java-version: 21 cache: 'sbt' - uses: sbt/setup-sbt@v1 - name: Build with sbt run: sbt dist # we only treat patchver - uses: actions-ecosystem/action-get-latest-tag@v1 id: get-latest-tag - id: increment-patch run: | TAG=$(echo ${{ steps.get-latest-tag.outputs.tag }}) # increment patch version perl -pe 's/(\d+)$/$1+1/e' <<< $TAG | sed -e 's/\(.*\)/nexttag=\1/' > $GITHUB_OUTPUT - uses: ncipollo/release-action@v1 with: artifacts: "target/universal/*.zip" tag: ${{ steps.increment-patch.outputs.nexttag }} body: | Released.
これが実物。仕事で培ったPerlパワーが発揮され、なんか良い感じにセマンティックバージョンをカキカキしている。
こうすると何が便利かというと、全部ビルドされていて番号が付いている状態になっているのでいざという時はダウンロードし直したらすぐ戻せるはず、という算段。まぁ実際は壊れる所まで行けなかったが・・・
じゃあビルドしたリリースはどうやって引いてくるのかというと、fetch
という小さなツールを利用した。
こいつはGitHubから指定したタグのリリースのアセットを取りに行ってくれる大変便利なツールだ。こういう便利なものをScalaで作りたいね。で、こいつの凄いところはセマンティックバージョニングを認識できること。
% ./fetch --repo https://github.com/isucon-2024-scala/server --tag '>0.0.1' --release-asset 'server-1.0-SNAPSHOT.zip' --github-oauth-token '${PAT}' --progress .
こんな感じで呼び出すと、0.0.1
より大きな最新バージョンを自動的に探して拾ってくれる。これが便利で、このおかげで最新のタグを指定する必要がない。
ここから、以下のようなデプロイスクリプトを即興で組んだ(shellcheckなんてしていないので汚いのは御愛嬌)。
#!/bin/bash set -ex # we need envvar PAT if [ -z "$PAT" ]; then echo "Please set the envvar PAT" exit 1 fi # we need tag. if omitted, it will deploy the latest release (git) if [ -z "$TAG" ]; then TAG='>0.0.1' echo "TAG is not set. Deploying the latest release with tag $TAG" fi # we need ssh key if [ ! -z "$SSH_KEY" ]; then SSHARGS="-i $SSH_KEY" fi # specify hosts as params hosts=$@ # we need at least one host if [ -z "$hosts" ]; then echo "Please specify at least one host:" echo " $0 host1 host2 ..." exit 1 fi CMD="./fetch --repo https://github.com/isucon-2024-scala/server --tag '${TAG}' --release-asset 'server-1.0-SNAPSHOT.zip' --github-oauth-token '${PAT}' --progress ." CMD2="rm -rf server-1.0-SNAPSHOT" CMD3="unzip server-1.0-SNAPSHOT.zip" for host in $hosts do echo "Deploying to $host" ssh ${SSHARGS} isucon@$host "${CMD}" ssh ${SSHARGS} isucon@$host "${CMD2}" || true ssh ${SSHARGS} isucon@$host "${CMD3}" ssh ${SSHARGS} isucon@$host "systemctl --user restart server" done
このコマンドはローカルから実行する。するとsshでISUCON側のサーバに突っ込んでいってfetch
を実行してくれるというわけ。並行実行なんて洒落た真似をしても良かったが、ビルド時間のほうがどうせ長いので無視した。これそのものもScala Scriptで書き直したら一瞬で並行実行できるねぇ〜
ちなみに環境変数$TAG
をつけると任意のバージョンに戻すことも可能。賢いなぁ〜
手元からrsync
とかでアーティファクトを押し込んでも良かったのではないか?という説もある。
アーティファクト
Scala、というかJVM言語の一般的なデプロイフォーマットはJARとかWARというやつだが、今回はコンフィグファイルとかSQLのスキーマのアセットがあるので、Play Frameworkに標準でついてくるdist
コマンドでZIPファイルを生成することにした。ZIPファイルは展開して内部のスクリプトを叩くと勝手にJavaを探して起動してくれる。
サーバ起動
さて前掲のスクリプトからも分かる通り、サーバの起動にはsystemdを使っている。昔はdaemontoolsを使っていたんじゃよ、ワッハッハ
この手のサーバを生かさず殺さずにしておくツールとしては他にもpm2とかforeverとかServer::Starterといったものがあり、こうしたツールにはだいたいスケールアップ機能みたいなやつがあって、同じプロセスを沢山立ててくれる。
じゃあなんでこれを使わなかったかというと、その必要がないからだ。たとえばNodeはシングルスレッド言語なので、一台のプロセスで捌ける限界に当たったときにはプロセスをスポーンして増やすしかない。Perlでも(なんかすごいライブラリを利用しなければ)同様だ。 しかしScalaやJavaは最初からマルチスレッド言語であり、基本的にそれで限界が来たらCPUを使いきっているということなので、プロセスを増やしてもしょうがない。Scalaには高性能な非同期IOライブラリであるCats Effect(ざっくり言うと、RustのTokioだ)があり、相当カリカリにCPUのコアを使ってくれる。
そこでsystemdの出番というわけだ。こいつは十分な仕事をしてくれる。寝てたら起こしてくれ。遺言を取ってくれ。環境変数を入れてくれ。このあたりができればだいたい問題ない。
systemdはシステム用のツールと思われがちだが、ユーザランドでも実行できる。今回は~/.config/systemd/user/server.service
に以下のようなユニットファイルを置いた:
# systemd unit file for service [Unit] description="application server" [Service] Type=simple ExecStart=/home/isucon/server-1.0-SNAPSHOT/bin/server ExecStop=/bin/kill -15 $MAINPID # user unitなのでuser/groupは自明であり使えない #User=ec2-user #Group=ec2-user Restart=always RestartSec=5 # -Xmxに加えてJVMがメモリを消費するため、ベッタリで指定しないこと Environment=JAVA_OPTS="-Xms1024M -Xmx3584M -XX:+UseG1GC" Environment=JAVA_HOME="/home/isucon/.sdkman/candidates/java/current" Environment=PORT=8080 Environment=DB_USERNAME="isucon" Environment=DB_PASSWORD="isucon" Environment=DB_URL="jdbc:mysql://192.168.0.13:3306/isuride" # マッチング間隔(秒) Environment=ISUCON_MATCHING_INTERVAL=0.5
ところでJVMの管理にはSDKMANというやつを使った。一発でJVMが入るので大変便利である。Javaのイメージに反して省メモリで高性能なGraalVMだってすぐに使える。今回も最新のGraalVMコミュニティエディションを利用した。
SDKMANの欠点は、インストール時に本当にバカみたいなデカさでAAが表示されることだ。
ところでGraalVMは「事前にコンパイルするので実行可能なJavaのバイナリがすぐ得られるコンパイラ」みたいな紹介をされることもあるが、それはAOT(Ahead on Time)モードで実行した場合の話で、普通にいつものJVMのようにJITを効かせて爆走するモードも用意されており、こいつがメチャクチャ速い(非常に高度な実行時最適化が走るらしい)。
昔はGraalVMは遅い遅いと言われていたのだが、最近は普通にプロダクションに入れていい水準だと思う。ただ今のところZGCといった最新のJavaにあるようなトガったガベージコレクタが使えないといった差異はある。今回はおとなしくG1GCを採用した。
systemdの話に戻るけど、ユニットファイルは一度決めたらほぼ動かさない。が最初のうちはだいたい壊していじるもんなのでデプロイスクリプトをここでも用意した:
#!/bin/bash set -ex cd $(dirname $0) # we need ssh key if [ ! -z "$SSH_KEY" ]; then SSHARGS="-i $SSH_KEY" fi # specify hosts as params hosts=$@ # we need at least one host if [ -z "$hosts" ]; then echo "Please specify at least one host:" echo " $0 host1 host2 ..." exit 1 fi CMD="cat > ~/.config/systemd/user/server.service" CMD2="systemctl --user daemon-reload" for host in $hosts do echo "Deploying to $host" ssh ${SSHARGS} isucon@$host "${CMD}" < systemd/server.service ssh ${SSHARGS} isucon@$host "${CMD2}" done
まぁそんな面白みはないですな
このあたりは昔から自宅サーバをいじりまくっていたので手癖で書けた。最近はCopilotもあるので「ああいうコード出してくれ!」というときにそれを出してくれて便利ですね。擬似的に労働集約的なパワーを個人た手にしたいま、時代は素朴に戻っていくのだろうか?
サーバの実装
既に言及した気がするけれど、以下の構成で書いた:
- Scala 3.5.2
- Play Framework 3.0.6
- OpenAPIのスキーマからモデルやルーティングは自動生成
- sbt 1.10.6
- Doobie 1.0.0-RC6
この他にもライブラリとして色々なグッズを使った。JSONライブラリのCirce、非同期ライブラリのCats Effect、ロギングライブラリのScribe、OS関連の汎用ライブラリos-libといった具合。このへんは自分のお気に入りなので使い勝手は知っている。基本的に、サッパリして明快に動くものが好きだ。ありがたいことに、これらは高度に抽象化されていながらサッパリ動くという神業のようなライブラリで、本当によく出来ているなと感じる。Cats Effectはもうちょっとドキュメンテーションを頑張ってほしいが・・・
ここからちょっと核技術についてクダクダと書く。感想エントリなのでまあいいでしょう。みんなそれが読みたいんだろう?面白い技術があるな〜くらいの気持ちで見てください。
Scala 3.5.2
現時点で利用できる最高のScalaが3.5.2だ。簡潔な構文が追加で取り入れられ、ビルドが早くなり、よりパワフルな機能が生えた。往年のScalaを使っている人には、Enumが生えてimplicitが整理されて型に区別される別名を付けられるようになった、と言ったら驚いてもらえると思う:
Play 3.0.6
一言であらわすなら、PlayはScala界のRailsだ。Scalaのことを知らない人にとってはこれが一番分かりやすい。通り一遍のものがあって、テンプレートを展開したらもう動く状態になっている。ISUCONにはお誂え向きだ(と言いつつ、ある程度先読みしてロジックが何もないサーバを用意してロギングなどの準備はしていた)。
実のところPlayを使うかは悩んだ。APIサーバを書くだけなら、http4s(シンプルでFP寄りのライブラリだ)とかCask(PythonのFlaskみたいなやつだ)でいいからだ。それでもPlayにしたのは、何か困ったときのリソース的余裕――つまりネットに便利グッズやお助け記事が転がっている確率――にある程度見込みがあるからだ。中身の理解についてはhttp4sのほうが圧倒的だが、「無いので作らないといけないことがわかった」ならいくら詳しくても終わりだ。Playなら、ググったらだいたいなんとかなる。
しかし詰まったところは詰まった。認証まわりだ。DBアクセスをしなければならず、DBアクセスは非同期処理として実装しておいたので、互いにちょっとカチ合うのだ。型がやわめの言語であったら多少無理をしてでも「つなぎ込む」ことができるのだが、Scalaはちょっと堅物な委員長タイプなので骨が折れる。 Cats Effectは非同期処理を効率的に書くことにかけては最高のライブラリだが、ちょっと周りのお友達と仲良くしないところがあって、この子の言う通りに御膳立てしてあげないといけないのだ。またPlayもフレームワークなので、俺の言う通りに書いてくれ、といった所があり、まぁ月並な言い方になるが、型パズルだ。このあたりはどうしてもLLに軍配が上がるように見える。その分頑健に作れるということでもあるのだが、実装スピードが最優先のISUCONでは、ちょっとデバフになってしまう。
良い点もあった。Playには最初からGuiceというGoogleのDIライブラリが入っているので、最後の最後でモジュールをガチャガチャくっつけるという面倒がないのだ。アノテーションだけでDIできるというのも良い。Playを選択していなかったら、使い慣れたAirframe DIを使っていたかもしれない。
sbt 1.10.6
Scalaのビルドツールとして不動の地位を築いているのがsbt(酢豚の略称とされている)だ。
これもバージョンを追うごとにキャッシュ機構などの改善が入り、往年の「コンパイル遅すぎてコーヒー飲みに行く」ツールではない。普通に良いツールだ*1。
他方sbtは独特の思想?というか世界があり、敬遠されることもある。慣れたら何事もなく書けるようになるのだが。重厚なドキュメントではなく「全体を軽く流していく記事」的コンテンツが必要かもと思っている。そのうち書くか。
ところで他のビルドツールとしてはmillがある。なんとPlayはmill用のプラグインがあり、ビルドできるのだが今回は扱わなかった。
Doobie 1.0.0-RC6
今回で一番チャレンジングな技術選定を行ったのがここ。DBライブラリだ。Doobieは関数型指向のDBライブラリで、Cats Effectとの相性が良い――というかCats Effectの上に作られている。他の言語で厄介になりがちなトランザクションの取り回しにも強みを持つ。せっかくなので紹介させてほしい。
Doobieでは、クエリは次のように書く:
val q = sql"select * from chairs" .query[infra.model.Chairs] .to[Vector]
これはselect * from chairs
を実行し、infra.model.Chairs
というモデルに詰め込み(この処理は全自動でできる)、結果はVector[Chairs]
としてよこしてくれ、というクエリだ。
クエリを実行するには.transact
を使う:
q.transact(xa) // => IO[Vector[Chairs]] // xaはDBコネクション
その名の通り、transact
は1つのトランザクションで行われる。そしてその結果は非同期処理なのでIO
型(非同期処理の結果を表す型)で渡される。
そして面白いことに、クエリがfor
式で合成できる:
val q1 = /* snip */.query[Int].to[Vector] val q2 = /* snip */.query[String].to[Vector] val q3 = /* snip */.query[Boolean].to[Vector] val qs = for res1 <- q1 res2 <- q2 res3 <- q3 yield (res1, res2, res3)
合成する理由はどこにあるのか?
qs.transact(xa) // => IO[Vector[(Int, String, Boolean)]]
クエリを合成すると、同一トランザクションに押し込めるのだ!Doobieの世界観では、常に1つの「クエリ」がトランザクションで実行される。その代わり、クエリ同士は勝手に合成できて1つにできるので問題ない、というわけ。トランザクションを運んで管理するのではなく、必要な範囲でクエリを合成し、それがトランザクションされる、という世界観なのだ。
ところで、関数型寄りのシステムで重視されるのはコンポーザビリティ、すなわち合成可能性だ。いちいち中身を取り出してガチャガチャいじって、また箱に戻す手間を踏まなくても、そのまま合成して使えますよ、というところに強みがある。たとえば、電車がある駅から別の列車とガチャリと接続して走ってくれるおかげで、いったん全員降ろして詰め直すことをせずに済んでいる。電車は合成可能性の見本だ。同じように、Scalaのような関数型言語のエッセンスを取り入れた言語では、関数を適用してからまた別の関数に適用する代わりに、関数同士をまずくっつけてから適用できる。あるデータ型をJSONに変換する方法が分かっているなら、多少細工をして別のデータ型をJSONにする方法がすぐ得られる。自在にくっつけたり、矢印を引いたりする、レゴブロックLv.200みたいな楽しみがあるのだ。
しかし時としてその合成可能性がライブラリの世界観に閉じてしまうことがあり、そうなると(ライブラリの世界観の中では完璧に合成できるが故に)苦労しなければならないことも多い。例えば、同一トランザクションでクエリを処理しながら別の処理を行う、といった処理が面倒になってしまう(自分の使い方が悪いだけかもしれないが)。こうなると、一番柔軟な部品は人間になってしまう。
ちょっと脱線した。DoobieではなくScalikeJDBCという薄いライブラリを使う選択肢もあったのだが、ちょっと遊びたくなってしまったのだ。
その他のライブラリたち
CirceはPlay FrameworkでJSONを使うために導入した。CirceとPlayを繋ぐ便利なライブラリがあり、これを利用するとスムーズに使えるのだ。Playには最初からJSON用のライブラリが組込まれているが、データモデルから自動的にJSONを導出してくれる機能の使い勝手でCirceを選ぶことが多い。
Cats EffectはDBアクセスなどの非同期IOを取り仕切るために導入した・・・がこれはちょっと過大だったように思う。
Scribeは以下で解説している。
os-libはmysql
コマンドを直接呼ぶのに利用した。
実装してみた感想
強い型はあると便利なのだが、本当の本当に欲しいときには出現してほしいし、割とどうでもいいときにはどっか買い物にでも行っててほしい。特に強いライブラリを入れていないバニラのScalaは強力な型推論によってこの塩梅がうまく保たれるのだが、最初から型にフォーカスしたライブラリ(Cats Effectは穏当なほうだがDoobieはかなり露骨だ)を使い始めると、居心地の悪い思いをすることがある(特に、実装しながら頭をひねっていく、ISUCONやスタートアップのような局面では)。TypeScriptのような言語は下地となる言語に強い型が無いゆえにこの塩梅が強制的に保たれるのかもしれない。明示的になんらかの道具を使うことを強制されると、その道具を知らない状態からの出発は困難だ。しかし暗黙的になんらかの道具がうまい具合に選択されるという仕組みでは結構破綻しやすい(ごく稀に、奇跡的な抽象化が成立することもある)。だいたいどういったシチュエーションでも同じように書く、という、定型性のようなもの(また別次元の抽象性)がライブラリには求められることだなあ(詠嘆)。Circeとかはそうなっているので頭空っぽでもコード詰め込める。定型性を突き詰めた言語がGolangだと思う。あれはあれで、LispやSchemeにも似た、突き詰めの美学がある。
より具体的に掘り下げてみる(分かる人向けの説明をする)。これは思考の整理も兼ねているので雑多だが・・・
モナディックな思想を取り入れた道具が二階建て、つまりネストしだす(今回の例ではCats Effect 3とDoobieだ)と、その間を値を持って移動しなければならなかったり、for
式の連鎖、またはネストになる(別種のモナド自体は自然に合成できないため)。これがとんだワルガキで、全然思うようにならない。いったん整理するために関数に切り分けるのが定石なのだが、この時点で一段階抽象化を挟んでしまっている。切り分けるのは善に思えるが、切り分けると定義を書かなければならないからコストが発生するのだ。そのコストが常に発生するのはISUCON的文脈では大変になる。仕様がある程度固まっているようなときは却って心強いのだが、時間制限があるうちにどんどん書く、といったシチュエーションでは大変。
そして、現実問題として、文脈は多種多様なやつが容赦なく突然登場する。非同期IOで、DBクエリの中で、ループを回しつつ、あるかもしれないし無いかもしれない値の処遇を良い感じに決めなければならない。モナディックな道具は統一された文脈では驚くべき力を発揮するが、混淆した場面では重荷になる。カッコで括るなどして、一回ある文脈に潜ったら好き放題できてほしい、つまりなんらかの透過性が欲しいが(Cats EffectのIO
はそういう感覚がある)、他方で異なる文脈同士があってもこれを気にせず統合した1つの塊として扱えたい、という気持ちがある。
そこで、「途中でバッサリ中断したり文脈の枝を刈る余地を残すような、フォールバック的な仕組みを外から注入する」といった機構があると便利ですねという話になるのだが(つまり、都合の良いモナドのDIをやりたい)、そのへんはエフェクトシステムとかExtensible EffectとかAlgebraic Effectとかが扱ってそうな範囲ですね・・・(脱線)
また脱線した。ここまで書くとScala全然ダメじゃねえか!みたいな事を言っているように思えるかもしれないけれど、それ以外の部分では最高に満足していて、パターンマッチや型で守られているという安心感、大抵の定型処理は自動的に生成されるのでボイラープレートを書かなくて良い快適さ、コレクションメソッドの多彩さ、作り込まれたオブジェクト指向システムには非常に助けられた。愛がゆえの気付きである。あくまでISUCONでは大変という話で、仕様をうまく表現した頑丈なシステムを高効率で動かしたい、といった用途にScalaはうってつけだ。
ちなみに、Scalaで参戦するのは初めての体験だった。予習もしていたのだけれど、やはりまだ自分にとってはハードルが高かった。リファレンス実装が無いということはなかなか大変なものだ。だが不思議と満足な気持ちもあった。死ぬほど書いたのだから・・・
次へ
今回で懲りてやめるつもりはない。また次回も、その次も同じ言語で参加するだろう。次回までに、もっとWeb開発をneatにまとめられる構成を生み出せると良いなと思う。そうすればみんなが得をする。お疲れ様でした。
*1:最近知ったのが、コンパイルのたびにsbtを起動している人がいるという話だ。遅いに決まっている!sbtは起動しっぱなしでその上でコマンドを発行するのだ