読者です 読者をやめる 読者になる 読者になる

bundle execを殺す

「バグの希釈」の項目を見て思い出した。

2015年に書いたコードの中で、一番意味不明で気が利いてたなーと自分では思っているやつは「lib/bundler/setup.rbに空のファイルを置く」というものだったりする。会社で少し説明したんだけど、あまりうけなかったのでここに書いておこう。

ユビレジで販売している製品の中にユビレジエクステンションというのがあって、簡単に言うとRaspberry Piに電源とWi-FiアダプタとUSBハブを付けたもので、HTTPでJSONをPOSTするとレシートが印刷できる機械になっている。ふつーにDebianが動いていて、Rubyで書いたHTTPサーバとかLED点滅のためのdaemonとかレシート印刷のためのdaemonとかが動いている。問題は、特定の処理でHTTPサーバとかを再起動するときに、なんか上手く行かない子がいることが、出荷後に判明したことだった。調べてみると、再起動が上手くいかないというのは、タイムアウトしていることがわかった。

なんで上手くいく子と上手くいかない子がいるのかは良くわかっていない。SDカードを読み書きしながら動作しているので、なんかの拍子にSDカードがなんかダメになるんだと思う。SDカードをddでコピーして試すと平気で動いたりする。(これは本題ではない。)

さて、再起動と一言で言ってもなにに時間がかかっているのだろうか。主にprintfデバッグなどを繰り返したところ、Rubyプログラムの先頭にたどり着くまでに時間がかかっていることがわかった。どうしようもないじゃんそれ……とかいいつついろいろ試行錯誤していると、

$ bundle exec ruby -e "puts :hello"

と、

$ ruby -e "puts :hello"

で実行時間が大きく違うことがわかる。後者の方が速い。まあ当たり前感がある。Bundlerを起動してからrubyを起動するのと、rubyだけ起動するのでは、後者の方が速いのは道理だ。そして、出荷済のバージョンではうっかりbundle execしているのだった。

また、普通にGemfileとかGemfile.lockとかがある場所でbunde exec複数同時に実行するとめちゃくちゃ遅くなることもわかった。きっと、複数rubyが一斉にbundler経由でgemをロードすると、CPUを使いすぎるとかディスクアクセスが多すぎるとかで、なかなか終わらないんだろう。ちなみにbundle execしないで3つのrubyを実行すると、あんまり遅くない。(「めちゃくちゃ」というのは「3つ同時に起動すると10倍時間がかかるようになる」くらいの感じ。「あんまり」というのは、3〜4倍くらいの感じ。)

$ bundle exec ruby -e "puts :hello" & bundle exec ruby -e "puts :hello" & bundle exec ruby -e "puts :hello"

これで、「普通に起動したときには上手く起動するのに、(特定の場面で)サービスを再起動したときには上手くいかない」理由もわかった。普通に起動するときには、LED点滅daemonだけは先に起動してから(起動中にLEDを点滅させたいので)レシート印刷daemonとHTTPサーバを起動するが、問題となっている再起動のときには3つを一斉に起動している。2つ同時起動の場合はなんとかタイムアウトせずにすむが、3つだとダメなことがある、そんなラインにタイムアウトを設定してしまったのだろう。

どうやって直したら良いだろうか。いくつか方法がある。

  • サービス起動時のbundle execをやめる
  • サービスを3つ同時に再起動しないようにする
  • タイムアウトを長くする

どれでも良い。簡単に直せる。

……まだ出荷してなかったらね。そして既に出荷は開始されているのだった。

実は、こういうこともあろうかと、最近のバージョンにはiPadアプリ経由で更新ができる仕組みを仕込んである。あるんだけど、上に挙げたやつは、どれもソフトウェアアップデートでは更新ができないファイルに書かれたプログラムなのだった。うーむ。

どうなっているのか、もう少しちゃんと説明しよう。

  • アプリケーションの本体は1個のディレクトリにすべて格納されている。binとかlibとかvendorとかがあって、/var/ubiregi/1.3とかに保存されて、/var/ubiregi/currentみたいなリンクを張る。
  • /etc/init.dに置かれた起動スクリプトbundle exec ruby -I /var/ubiregi/current/lib /var/ubiregi/current/bin/a.rbなどとしてサービスを起動する
  • ソフトウェアアップデートは/usr/local/ubiregi以下に展開すると良い感じになるようなディレクトリをtgzにしたものの形で処理される
  • ソフトウェアアップデートはファイルの展開しかしないので、/etc/init.d以下のファイルを変更することはできない
  • ソフトウェアアップデートはソフトウェアアップデートdaemonによって処理され、このソフトウェアアップデートdaemonをソフトウェアアップデートすることはできない

そういうわけで「bundle execで実行されたアプリケーションが、rubyが実行されるまでの間に、bundle execをなかったことにする」そんな方法がないか考えることになった。(回答は、LOAD_PATHが通っている場所にbundler/setup.rbを置くことである。最初に書いた通り。)

bundle execがやっていることを見ると、次の二つである。

  1. bundle execで実行する実行ファイルをGemfile.lockを見ながら探す
  2. RUBYOPT環境変数-rbundler/setupを設定する

今回は1.は関係ない。RUBYOPTってなんだったっけ。

Rubyインタプリタにデフォルトで渡すオプションを指定します。

環境変数 (Ruby 2.1.0)

なるほど。そして-rrequireだ。つまり、bundle execすると

require 'bundler/setup'

と書かなくて良くなるわけだ。ってことはlib/bundler/setup.rbに空のファイルを置いておけば、Bundlerの処理をスキップできるのではないだろうか。問題は、このRUBYOPTコマンドライン引数の-Iのどっちが先に処理されるかである。(サービス起動のコマンドをもう一度。)

$ bundle exec ruby -I /var/ubiregi/current/lib /var/ubiregi/current/bin/a.rb

-Iの処理がRUBYOPTよりも後なら、-rbundler/setupは標準ライブラリを見に行ってしまうので、本物のbundler/setup.rbをロードしてしまう。-Iの処理が先なら、/var/ubiregi/current/lib/bundler/setup.rbをロードするので、アプリケーションで乗っ取ることができる。rubyのマニュアルにはなんとも書いてないので、実際に実行して試してみるしかない。

-Iが先だった。

というわけで、無事Bundlerを無効にする方法が見つかって、ソフトウェアアップデートによってこの問題は解決されたのだった。bundler/setupがなくなるので、Bundlerの手を借りずにgemをロードしないといけないけど、それはStandaloneモードで解決できる。

--standalone[=<list>]

bundle-install(1) - Install the dependencies specified in your Gemfile

(ちなみにStandaloneモードだと起動がかなり速いので、Gemfile.lockの処理がなんか変なことやってて遅い気がする。わからないけど。)

そういうわけで、空のファイルを追加する謎のコミットによって問題が解決されたのだった。(うっかり不必要にbundle execしてたのが本当の問題なんだけど。)あと、あんまりやらないとは思うけど、うっかりbundler/setup.rbみたいなファイルを作るとbundle execが動かなくなるかもしれないので、注意しましょう。