技術部の笹田です。Ruby 3.2 無事にリリースされて良かったよかった。
Rubyインタプリタは複雑なプログラムなので、当然のごとくバグが入ってきます。Rubyインタプリタ開発者は、これに対していろんな対策をしています。たとえば、テストを書いて、CI環境でチェックするとか、今となっては当然のことを、当然のごとくやっています(RubyCIやchkbuild、ruby/spec: The Ruby Spec Suite aka ruby/specなどの整備や、実行環境の日々のメンテナンスの成果です)。
これに追加して、個人的にテストをとにかくたくさん繰り返し行うマシン群を用意しています。テストの実行頻度をなるべくあげて、「時々しか発生しない」というバグを炙り出して、Rubyインタプリタの品質向上を目指すためです。本稿ではそんな、ちょっとだけ変わったテスト環境についての話をご紹介します。
このテスト環境を用意するために、いろいろな方にご支援いただいております。本稿では感謝の意をこめて、そのご支援をご紹介させていただきます。
バグを「炙り出す」必要性
定期的にテストを実行する環境
よくある CI/CD の文脈では、リポジトリへのコミット(PR)単位でテストを実行します。もし問題がでたら、そのコミットに問題があることがわかるためです。GitHub Actions などでよく対象にするのはそんなテストです。
つまり、
「バグは修正に混入する → 修正ごとにテストを走らせることで、そのバグを見つけることができる」
という仮説のもとに定期的にテストする環境を用意するわけです。
この問題に対処するため、Ruby インタプリタ開発では、次のようなテスト環境を用意して利用しています。
- GitHub Actions による PR 単位、push 単位でのテスト環境
- chkbuild による網羅的なテスト環境(rubyci)
1 も 2 も、基本的には直前に入った修正に問題がないか、チェックするための仕組みです。
2 は、いろいろな OS などの環境で、毎回 clean build して逐次テストすることで、正確なテスト結果を出します。ただ、時間がかかるため、2時間に1度程度、実行されています。
現在は、計算機の多くは一般社団法人 Ruby Association などからのご支援を受けて AWS 環境に構築しています。また、GitHub actions は GitHub 様から計算機資源の提供をいただいています。
そういえば、Shopify では、彼らの(おそらく膨大な)アプリケーションのテストをRubyの開発版で行っていただいているそうです。助かりますね。
So, throughout this year we've been investing into the stability of Ruby. Starting early September 2022, we increased our efforts even more by running a Ruby HEAD checkout every hour on our Core monolith CI. We also setup an early access ARM cluster to test the new YJIT backend.
— Ufuk Kayserilioglu (@paracycle) 2022年12月21日
ときどき落ちるテストを発見するテスト環境
Rubyインタプリタくらいの規模のソフトウェアになると、何も変わらないのに、時々落ちる、という現象にあたることがあります。また、修正はあっても、その修正では考えられない理由でテストが落ちる、ということもあります。こういうのを flaky test などということがあります。これには、いくつか理由が考えられます。
- テストが悪い
- 「時間」や「システムの状況」など外部要因に起因するテスト
- すでにバグは混入しているが、運が悪い(良い)ときにしか見つからない
経験上一番多いのは 1 のテストが悪いというものです。たとえば、テストするメソッドの順番に依存していたりすると、何かの拍子に問題が生じることになります。タイミングがシビアなテストを書いていると、ちょっとタイミングがずれて時々失敗する、みたいなこともあります(マシンスペックが変わって失敗する、とかもありますね)。
2 の外部要因に起因するテストも、テストが悪い、と言えなくもないですが、時々あります。たとえば ruby/zlibのテストが何もしていないのに失敗するようになった話 - @znz blogで紹介されている例は、特定の時刻でタイムスタンプが特定のデータを生成してしまい、テストが失敗してしまう、というものでした(テストを修正して解決)。
まぁ、上記は「テストが悪い」の範疇なので、インタプリタ自体の品質には直接関係ありません。ただ、これらを放置するとテスト結果を確認するのが億劫になるので、出来るかぎり早く修正する必要があります。われ窓理論ですね。
で、3の運が悪い(良い)と現れる問題が、インタプリタの品質にとって大切になります。1万回に1度、運が悪いと出現するようなバグでも、1日に利用者が1万人いるソフトウェアだと、1日に1度は踏んでしまうかもしれません。というか、踏んじゃいます。さらに悪いと、脆弱性のもとになってしまうかもしれません。
この手のバグが出やすいのは次のような場面です。
- 自動メモリ管理(GC)
- キャッシュを用いているもの
- 並行・並列実行をしているもの
- ネットワークなど、外部のシステムを利用しているもの
どれも、非決定的、つまり2度実行しても同じ結果にならないような挙動を持ち込みやすい部分です(そして私が良く扱う分野です)。ほかにも、システムによるメモリアドレスのランダマイズなど、「あれ、さっきと結果が違うぞ?」という状況を作る原因はいろいろあります。
で、いろんな工夫が考えられるのですが、われわれは「とにかく数を実行してみる」という手法を用いています。単純ですね。1万回に1度出るなら、1万回動かせば再現するだろう、という話です。
つまり、
「あまり出現しないバグがすでに混入している → テストの試行回数を増やせば、このようなバグを踏む確率が高くなる(炙り出せる)」
という仮説のもとに、とにかくたくさんテストを実行するテスト環境があるといいなぁと思うわけです。
先ほどご紹介した chkbuild では一日に 12 回程度(これに環境の数だけ掛け算)、GitHub actions ではイベントごと、ということで、「沢山実行する」というにはちょっと足りません。そこで、独自にテスト環境を作って5年くらい運用しています。
もともとは GC 開発時のデバッグで「時々起こる」バグに業を煮やし、1台でずっとテストを走らせていたことから始めました。
始めた当時は、while make up all test-all; do date; done
というコマンドで無限にテストを走らせました(失敗したら止まります)。ただ、これだと結果を確認するためにターミナルを見なければならず、また意図しないところで停止ししていると気付くことができません。また、スケールも難しいので、環境一式を作りこんでいった感じです。
たくさんテストを実行するための工夫
テストをたくさん実行するためには、次のような工夫を行いました。
- マシンを複数台使う(スケールアウト)
- 性能の良いマシンを使う(スケールアップ)
- 1マシンで複数のテストを同時に実行してハードウェアリソースを使い切る
- 1回のビルド・テストの試行時間を短くする
それぞれご紹介します。
利用するマシンの用意
お金があればクラウドでマシンを沢山用意してスケールアウトするのが一番確実(そして、慣れた人には簡単)なのですが、個人で行っている活動なので、用意できる金額に限りがあります。また、この手の計算機リソースを使い切る用途は、安いクラウドサービスにはあわないというものがあります。
自宅のスペースに若干の余裕があったので、現在は実マシンをてきとうにおいて運用しています(子どもたちが大きくなると余裕はなくなるため、この活動もそこで終了しそう)。AWS などの料金表をにらめっこしてみたのですが、やはり実マシンが一番安いですね...(割引プランをいろいろ探せばもっと安いんだろうか)。10万円弱で 8 cores 16 threads のちょっとした良いマシンが買えるのでありがたいです。現在は、4台のマシンで運用しています。
- 1台: 6年前に買ったマシン
- 1台: ThinkCentre M75q Gen 2(ガーネットテック373株式会社様にご提供いただきました:ガーネットテック373株式会社、開発用マシンを提供しRubyインタプリタ開発を支援 - ガーネットテック373株式会社)6万円くらい
- 2台: MINISFORUM EliteMini HX90(GitHub sponsors(Sponsor @ko1 on GitHub Sponsors)の収益で購入させていただきました)8万円くらい x 2
新しいマシンはどれも小さいです。以前はミドルタワーのマシンを並べていたんですが、さすがにむっちゃ邪魔で...。HX90 は先日のブラックフライデーでちょっと安かったので買ってしまいました。
テストの実行時間はCPUの動作周波数にきれいに相関していました。速ければはやいほど良い。
メモリは1つのテストスイートを走らせる程度なら、ビルドや各テストを並列実行しても2GB程度あれば十分なようで、意外にもそんなに必要ありませんでした。
電力計をつけているのですが、見ていると全部で 400Wh のあたりを上下している感じです。東京電力の料金 スタンダードプラン(関東)|電気料金プラン|東京電力エナジーパートナー株式会社を見ると、301kWh を超えると 30.57円/1kWh のようですので、この数字をもとに計算してみると 30.57円/kWh * 0.4kWh * 24h * 30d = 約 8804 円。まぁ1万円弱くらい。こちらも GitHub sponsors の収益で一部補填させていただいております。
(ちなみに、この電気代には先ほど紹介した rubyci/chkbuild で利用している Mac mini 3台が入っています。Mac mini は一般社団法人日本Rubyの会のご支援で購入したものです)
今は寒いからいいんですが、暑い時期は(エアコンを入れなかったので)ファンがすごい音をたてていました。火事が心配。今のところ、連続稼働でも1年以上は動いています。ただ、5年たったらミドルタワーのマシン2台が壊れました。小さいマシンはもっと寿命短そう。
マシン代は(古いのはおいといて)22万円で3年で減価償却するとして7万円/年くらい。電気代は大雑把に12万円/年。つまり20万円/年くらいでしょうか。場所代とメンテ人件費が要らないのでやっぱり安いですね。落ちたら大変、ってシステムもないので、SLA も要らない。まぁ、家でマシン並べるのは趣味ですよねぇ。
余談ですが、物理マシンを手元においているのはベンチマークをとるため、という側面もあります。クラウド上のマシンだと、インスタンスガチャみたいな話もあるので、なるべく物理マシンを利用したいというところです。例えば https://rubybench.github.io/のマシンは我が家でホストしているマシンになります(このマシンも日本Rubyの会様にご提供いただきました、ありがとうございます)。新しい機能のベンチマークを真面目にとらないといけないときは、動かしているテストをとめてベンチマークしたりしています(ベンチマークのために複数台必要になることがあるためです)。
ビルド・テストプロセスの並列実行
テストの回数を増やすために、テストスイートを実行するプロセスを1つのマシン上で複数起動する、という方法があります。
テストスイートを実行すると、リソースを消費するときと暇なときがあるので、あるテスト実行プロセスが暇なときに別のテスト実行プロセスを走らせることで全体のパフォーマンス向上を目指すという考え方です。ただ、同時実行テストプロセス数が多すぎるとリソースの取り合いにリソースが消費されてしまうため、全体のパフォーマンスは悪化する危険があります。
単純にテストプロセスを複数実行すると、テスト同士で干渉することがあったので(たとえば、ファイルシステムやネットワークのポート)、いろいろ試行錯誤しながら Docker コンテナで設定をいくつかいじれば大丈夫であると突き止めました。今は 1つのマシン上で 22 の Docker コンテナがそれぞれ同時にテストスイートを実行するようにしています(build-ruby/run_sp2.rb at master ・ ko1/build-ruby)。メモリは 32GB で何とか足りています(ただし、後述する RAM ディスクはあきらめました)。
ビルド・テスト時間の短縮
最新版のRubyをビルドしてテストスイートすべてを走り終わるまでの時間を短縮するため、次のような工夫をしています。
- コンパイル結果などを再利用する
- RAMディスクを用いる
- ビルド・テストを並行処理する
rubyci.org に掲載されているテスト実行は、テストの結果を確実にするため、一切のコンパイル結果などの再利用をしません。ただ、今回は数を稼ぐことが目標なので、コンパイル結果を積極的に再利用するようにしています。ただし、再利用を起因とする問題もたまにあるので、2度連続で失敗したときは、一度コンパイル結果などをすべて消し、まっさらな状態からビルドするようにしています。
メモリが比較的余っている環境では、ビルド結果はすべてRAM ディスク(tmpfs)を用いて、ちょっとでもビルドが速くなるようにしています。ただ、これどれくらい効くかは微妙です。性能に関連しそうなデータは、OSが勝手にメモリ上にキャッシュに載せたりするためです。なんとなく速いような気がする、という気持ちの問題みたいな側面が大きいです。
ビルドの並列実行は make -jN
とするやつです。10年くらい前は結構これに起因するバグもあったんですが、今ではほぼ問題なく並列ビルドできています。
テストを並列に実行する、というのは、テストスイートを分割し、その結果を並列に実行するというものです。この環境で実行するRubyのテストは大雑把にわけて3グループあるのですが、そのうち1つが以前より並列処理に対応していました。数を稼ぐという目標のために、さらに1つのグループ(btest)を並列実行可能にするように書き換えました。
これらの工夫により、速いマシンを占有して「最新版をビルド→テストの実行」を繰り返し実行している環境では、「最新版をビルド→テストの実行」が2分弱程度で終わることができるようになっています。つまり、常にリポジトリから最新版の Ruby を取得しテストするため、テストが通らなくなるような問題のあるコミットをすると、はやいと2分程度でテストの失敗通知が得られるようになっています(結果は Slack に通知される)。
テスト回数
これらの工夫により、1日に2000回程度のビルド・テスト実行ができるようになっています。5日で1万回。
バグを炙り出す工夫
テストを増やす
バグが混入されているにしても、そのバグを絶対踏まないコードしかなければ、そのバグを検出することはできません。そのため、広範なテストが必要になります。すでに Ruby は大きなテストセットをもっているため、それを利用しています。
また、Rubyインタプリタのソースコードには、たくさんのアサーション(プログラムのこの箇所では必ずこうなっているだろう、という状態の表明)が入っています。これも、テストの一種と考えられるでしょう。自分がコーディングする部分では、このようなアサーションを増やすことで、おかしな状態を検出できるようにしています。
これらのアサーションは多くはデバッグビルドでのみチェックが有効になります。そのため、走らせているいくつかの環境でデバッグビルドを用いて実行させています。
テストについて、理想的には、著名なアプリやライブラリを持ってきて、そのテストを最新の開発版 Ruby で動かすと良いと思うのですが、そこまで手が回っていません。
テストパターンを増やす
実行するテストはすべて一緒ではなく、さまざまなパターンでテストを走らせることでバグを炙り出そうとしています。
- いろいろなパラメータでビルドした Ruby インタプリタでテストを実行。
- ビルド環境(コンパイラ)のバージョンを変えてテストを実行。
- テストの順番をランダムにして実行。たとえば、テストの実行順によってメソッドキャッシュの状況が変わるので、そこで発見できるバグがあるかもしれない。
- テストを繰り返し実行。同じく、同じテストを繰り返し行うことで、メソッドキャッシュの状況が変わる可能性がある。
この辺を一括で記述することができるように、設定に従って Ruby をビルドし、テストを走らせるソフトウェアを書きました(ko1/build-ruby: Build Ruby from source code.)。設定一覧は例えばこんな感じ: https://github.com/ko1/build-ruby/blob/master/docker/ruby/targets.yaml
エラーへの対処
予期しない問題に対処するため、いくつか工夫しています。
- 全実行ログの記録
- 無限に停止するこことをふせぐためにタイムアウトを設定可能に
- タイムアウトがあったら、gdb で関連プロセスのバックトレースをダンプ
- core を吐くような異常終了時には core をダウンロードできるように
- 失敗が続いたらデータを全部削除したり、実行間隔をあけたり
しかし、テストが失敗しても結局原因はわからないことも多いです。もう少し工夫したいところです。
結果を確認するためのシステムの整備
結果を集約するサイト ci.rvm.jp を作っています。見る人は限られているので、DB は SQLite3 という雑さ(なので遅い)。本当にヨワヨワサーバなので、リンクにもしていません。
失敗ページを見ると、何がまずいかわかりやすいように stderr への出力だけ実行結果の概要ページで見えるようにするなど、ちょっと工夫しています(が、世のCIサイトは無限にやってそうな話ですね)。
テストが失敗したら Slack での通知(Rubyコミッタの方々が見ているところ宛て)とメールでの通知(これは私にだけ宛て)が飛ぶようになっています。必ず失敗するようなコミットが稀に入ってしまうのですが、そのときは通知がひどいことになります。
余談:その他の考えられる工夫
非決定的な挙動をテストするためにはいろいろな手法が考えられます。
例えば、入出力やスレッドスケジューリングなど、外部のイベントをすべて決定的になるようにOSなどのレベルで整備するものです。つまり、いろんな工夫で同じプログラム(と外部からの入力)については必ず同じ結果を返すようにする、というものです。一度、問題を発見できたら、その問題が必ず再現できる、となればはかどりそうですね。ただ、研究レベルではいろいろ聞いたことがあるんですが、実際どれくらい実用になるんでしょうね。
形式手法を用いて網羅的なテストや、網羅しやすくするデータを自動的に生成する、といった手法も考えられます。こういうのできるとかっこいいですよね。
成果
毎日数千回の試行があると、けっこうバタバタ失敗するため、最初はかなり頑張って修正しました。主にテストの不備が多いので、だいぶ頑張って修正しました。
また、タイミングに起因するバグも修正することができました。手元のメモに残っているパッチだとこんなものがありました。
- fix marking T_NONE object bug. · ruby/ruby@4c9f3ce
- fix passing wrong
passed_bmethod_me
. · ruby/ruby@3cb6952
おわりに
本稿では、品質向上のために個人的に行っている「テストの実行回数を増やしてレアなバグを見つける」ための仕組みについてご紹介しました。
プログラムにはバグはつきものですし、大きく複雑なプログラムのバグを見つけるのは大変です。今回は、そんな試行錯誤の一端をご紹介しました。今回ご紹介したものは、とにかく力業、という感じなので、もう少し科学的アプローチもできればいいなぁ、と思っています。いい方法知っていたら教えてください。
記事中でも言及したとおり、この仕組みは多くのご支援を受けて実現しております(ご紹介できなかった方もいるかもしれないですが、ごめんなさい、感謝してます)。とくに最近はSponsor @ko1 on GitHub Sponsors経由でご支援をいただいており、助かっています。改めて御礼申し上げます。
マシンについては、数年前に某社から不要になったメモリ3桁GBのごついラックマウントマシンを3台いただきまして、それを別の某N社に設置させていただき、これらを含めて運用してきました(マシンの運用はN社様にずっと面倒見てもらっていました)。先日、これらのマシンがさすがに古かろうということで撤去されたので、その供養と感謝を込めてこの記事を執筆しました。再度感謝いたします。
では、良いお年をお迎えください。
... 今年は「Ruby 3.2 の XXX 自慢したい」記事がないのですが、それについてはまた来年ご紹介します。