テンプレートエンジンとバックトレース

最近 haml の別実装 を書いてみていて、コード生成部分でバックトレースのことを考える必要があることに途中で気付いて、haml や slim が生成するコード中の謎の改行の理由がわかった話。

Ruby でよく使われる HTML テンプレートエンジンとして hamlslim がある。 どちらのテンプレートエンジンも、大まかなしくみとしては、

  1. ソース言語から Ruby のコードを生成 (コンパイル)
  2. 生成した Ruby のコードを eval してメソッドに変換
  3. render の際は、適切にインスタンス変数やローカル変数等を与えてメソッド呼び出し

という実装になっている。

haml も slim も、テンプレート中に (ほぼ) 任意の Ruby の式を埋め込むことができる。 すると、テンプレートの render 中に例外が発生することも当然ありうる。 このとき、バックトレースにはちゃんと例外の発生箇所が記録されていてほしい。

テンプレートエンジンのソース言語は Ruby ではないので、例外発生箇所を正しく記録するためには、Ruby の式が埋め込まれる箇所に関してはソース言語とコンパイル後のコードの間で行番号を一致させる必要がある。 たとえば、

%div
  %ul
    - @items.each do |x|
      %li= 'item: ' + x

という haml テンプレートに対して、haml 4.0.6 は次のようなコードを生成する。

_hamlout.buffer << "<div>\n<ul>\n";

@items.each do |x|
_hamlout.buffer << "<li>#{_hamlout.format_script_false_true_false_true_false_true_true(('item: ' + x
));}</li>\n";end;_hamlout.buffer << "</ul>\n</div>\n";;_erbout

このように途中に謎の空行を挟みつつ、Ruby の式が埋め込まれている @items.each'item: ' + x に関してはソース言語と行番号が一致するようにコンパイルされている。 この対応のおかげで、主に開発中に例外が発生した際のバックトレースがテンプレート言語内であっても正確に表示できるようになっている。

C のコードを生成するようなツールだと #line ディレクティブを使って行番号をあわせていることがあるけど、Ruby にはそのようなディレクティブは無いので、コンパイルする際にがんばって行番号をあわせる必要がある。

Unicorn の graceful restart と環境変数

Unicorn の graceful restart は無停止でのデプロイを可能にして非常に便利だが、fork を用いて実装されている都合で古いプロセスから新しいプロセスに環境変数が引き継がれるため、そのことに起因するトラブルがいくつかある。

dotenv の設定が書き変わらない

設定情報を dotenv で管理している人も多いと思うけど、環境変数を使っているので罠がある。

例えば最初に .env に MEMCACHE_SERVERS=memcache-server-001:11211 と書いてあったとする。 このとき Unicorn を起動すると、dotenv によって MEMCACHE_SERVERS=memcache-server-001:11211環境変数に追加される。

その後、接続先として memcache-server-002:11211 を追加したくなって .env を編集して MEMCACHE_SERVERS=memcache-server-001:11211,memcache-server-002:11211 に変える。 ここで Unicorn を graceful restart すると、古い MEMCACHE_SERVERS の値は次のプロセスにも引き継がれ、dotenv はデフォルトでは既にある環境変数を上書きしないため、新しいプロセスでも MEMCACHE_SERVERS の値は古いままで、memcache-server-002:11211 が追加されない。

Bundler のバージョンが上がらない

Bundler環境変数 RUBYLIBRUBYOPT を指定することで、Ruby の起動時に Bundler を読み込ませセットアップを行うようなしくみになっている。 RUBYLIB には初回起動時の Bundler の libdir が入っており、Unicorn を graceful restart してもこの環境変数が引き継がれるため、同じく引き継がれた RUBYOPT=-rbundler/setup によりずっと初回起動時の Bundler の libdir から bundler/setup がロードされ続ける。

解決策

どちらも before_exec でなんとかすることができそう。

dotenv については Dotenv.overload を呼ぶことで .env の内容で環境変数を上書きできる。 なので、before_exec でこれを呼ぶことによって新しい設定が使われるようにできる。 もし .env 以外の方法でセットしている環境変数があった場合、そこでセットされたものよりも .env が優先されるようになってしまうけど、まぁ .env 使ってるときにそういう環境変数は無いと思う。

before_exec do |server|
  Dotenv.overload
end

Bundler については RUBYLIB を最新の Bundler を指すように書き換えてやればいいわけで、ややトリッキーな方法だが以下のような before_exec を書くことでできそう。 もしデフォルトで RUBYLIB, RUBYOPT, GEM_HOME が存在するような環境なら、env.delete のかわりにその値をセットするようにすればいいはず。

before_exec do |server|
  env = ENV.to_hash
  %w[RUBYLIB RUBYOPT GEM_HOME].each do |key|
    env.delete(key)
  end
  rubylib = IO.popen([env, 'bundle', 'exec', 'ruby', '-e', 'puts ENV["RUBYLIB"]', unsetenv_others: true], &:read).chomp
  ENV['RUBYLIB'] = rubylib
end

追記

RUBYLIB を出力するときにわざわざ ruby を使う必要は無いという指摘があった。たしかに bundle exec env でよさそう。

before_exec do |server|
  env = ENV.to_hash
  %w[RUBYLIB RUBYOPT GEM_HOME].each do |key|
    env.delete(key)
  end
  rubylib = IO.popen([env, 'bundle', 'exec', 'env', unsetenv_others: true], &:read).slice(/^RUBYLIB=(.+)$/, 1).chomp
  ENV['RUBYLIB'] = rubylib
end

pt3_drv を使ってる人は Linux 3.18 以上に上げるときに注意

Linux で PT3 を利用している人は pt3_drv を使ってる人が多いと思うけど、Linux 3.18 から earth-pt3 という DVB 版の PT3 ドライバが入るようになって、そのままだと pt3_drv と競合して /dev/pt3video* が生えてこなくなる。

% uname -r
3.18.2-2-ARCH
% zgrep CONFIG_DVB_PT3 /proc/config.gz
CONFIG_DVB_PT3=m

DVB 版に移行してもいいと思うけど、今まで通り pt3_drv を使い続けたい場合は earth-pt3 を blacklist に入れておけばいい。

% cat /etc/modprobe.d/earth-pt3.conf
blacklist earth-pt3

2014年の思い出

しゃかいじんいちねんめ

新生活

無事ストレートで修士課程を終えて社会人になった。 それに合わせて3月に引っ越した。飲み会等でオフィスやその付近でぐだぐだしていても歩いて帰れるので便利。 部屋の広さとか立地とかそれに対する家賃とかには満足してるんだけど、浴室にやや不満があるので遠くないうちにまた引っ越すかも…… 大学で地理的に離れてしまった地元の友人の中に就職のタイミングで上京してきた人もいて、そういう人と会いやすくなったりした。

仕事

最初の一ヶ月は研修受けたりほうれん草収穫したり1週間で Android アプリ作ったりしてた。 その後、Rails のバージョンアップに関連した作業をしたり、アセットまわりを改修したり、小さい Web アプリケーションを golang で書いて Docker を使って動かそうとしたりしていた。 だいたい6, 7割は Web アプリケーションを書いてて、2, 3割が puppet や itamae 書いたりトラブル・アラート対応したりといったかんじだったと思う。 Web アプリケーションを書くといっても新規に機能を追加することはあんまり無く、レガシーっぽいところに対処したり、独自のモンキーパッチでレールから外れたり gem を更新しにくくなったりしているところを何とかしたりしていた。

LT

イベントでの発表は実はこれまで一回だけあったんだけど、今年は RubyKaigi 2014 LTRails複数DB Casual Talks の2回あって、どちらも switch_point の話をした。 プレゼンに対して苦手意識があって、実際伝わりにくい発表だったと思うので、伝え方とか自慢の仕方とかをもっと意識したい。 まぁ switch_point に関しては使わずに済むならそれに越したことはない gem なので、これくらいでちょうどいいのかもしれない……

録画

PC で録画し始めてから去年までずっと使い続けてきたスクリプト群をゼロから書き直した。 これにより同じ番組であっても異なる局で重複して録画するようになり、さらに引越しの機会に BS も視聴できるようになったので、ディスクの消費スピードが高まった。 各局で録画するので、L字やテロップがあったときでも適当に補完できるようになった一方、副作用としてテロップのある回を録画する機会が増えて、あえて残すようになった。 社会人になってから時間をとりにくくなったので実際に視聴する番組は更に減った。

アニメ

いなりこんこん恋いろは、アイカツラブライブ2期、プリズマイリヤ2期、東京喰種がよかった。 あと悪魔のリドル、グリザイアの果実も楽しめた。 まだ放送中だけど、SHIROBAKO、クロスアンジュもよさそう。 社会人になったので積極的に円盤を集めていきたい。

漫画・ラノベ

2月くらいから全部 bookwalker で買うようになった (それまでは Kindle Store で買ってた)。 今年になってから読んだ作品でアニメ見てからじゃないものの中だと citrus がすごくよかった。 数年前で止まってたコップクラフトの新刊が出たのも嬉しかったけど、なんか繋ぎみたいな話だったので早く次が欲しい。

Rails複数DB Casual Talksで話した

先日行われた Rails複数DB Casual Talks というイベントで複数 DB に関連するつらい話をした https://speakerdeck.com/eagletmt/fu-shu-dbtorails 。 r/w splitting を行う switch_point については以前の記事でもう少し詳しく書いてます。

質疑のときに出ていた複数アプリケーション間のモデルの共有方法についてちょっと補足。 僕は今年の新卒入社なのでそれ以前にどうやっていたかはよく知らないけど、本体と管理画面のようにモデルを共有するくらい密に結合しているアプリケーションにおいて1つのリポジトリに入れることは、つらさはあるけど理にかなっていると思う。 ちなみに以前は git submodule で共有していたこともあったらしいです。

gem とか git submodule とかでうまく分けられている事例があったら話を聞きたい……

複数アプリケーション + 複数 DB で不必要な establish_connection をしなくする方法、つまり必要なモデルだけロードする方法は1つわかっていて、App 1 から使われるモデル、App 1 と App 2 から使われるモデル、App 2 と App 3 から使われるモデル、…… みたいに細かく Rails Engine として切り出して、それらに合わせて各アプリケーションでそれぞれ必要なものを require するというもの。 ただ、この方法だとモデルファイルが1つのリポジトリの中で色んなディレクトリに散在してしまうことになって、それはそれでイマイチ感がある。 というわけで何かいい方法ないですかねーという話を最後にしたんですが、この状況に悩んでいるのはうちだけな気はしてます。

ISUCON4 本戦に参加して8位でした

ISUCON4 にクックパッド選抜の†空中庭園†《ガーデンプレイス》として @ryot_a_rai@__gfx__ と参加し、結果は8位でした http://isucon.net/archives/41187491.html 。 使用言語で大きくスコアが分かれることはないだろうということで、3人が共通して慣れている言語として Ruby を選びました。

最初に試しにベンチマークを一回実行しつつ app.rb を読んで、一回の実行だけでかなりスワップしていることと、入稿された動画が Redis に保存されていることに気付いて、まずそれを何とかしようとした。 LTSV 形式で書かれた nginx / Apacheアクセスログからレスポンスタイムの情報を出す access-log.rb を用意して、その結果から動画の配信が支配的だということがわかった。 Ruby の初期実装では全く使われていない ADS_DIR という意味深な定数があったので、そのディレクトリへ動画をファイルとして保存するように変更し、Ruby ではなく nginx が返せるようにした。 同時に、CPU コア数に対して多すぎる unicorn のワーカ数を減らしたり、unix domain socket を使うようにしたりする変更がされていた。

別々のサーバがそれぞれローカルにファイルを置くとなると、ファイルをどう配信するかが問題になる。 一番最初は、各サーバに ID を割り当て、動画がアップロードされたときに Redis に自分のサーバ ID も含めて保存するようにし、GET /slots/:slot/ads/:id/asset されたときにその動画がどのサーバにあるのか Redis から引いて、そのサーバへプロキシするようなものを golang で用意するような構成を考えていた。 しかしその構成を実装する前に、動画へのパスは GET /slots/:slot/ad が返す JSON に含まれている URL で決まるのでは? という話が出て、試しにその API から返す JSON に適当なクエリストリングをつけてベンチマークを実行したところ、クエリストリングがついた状態でアクセスがきたので、GET /slots/:slot/ad を変えれば動画のエンドポイントは変えられることがわかった。 そこで、JSON に含める動画の URL にサーバ ID を加えることにして、nginx の設定で自分のサーバ ID が渡されたら自分で配信し、そうでなかったらそのサーバ ID のサーバへ proxy_pass するよう設定した。 また、初期実装ではレポートの元となるログがローカルファイルに出力されているせいで複数台の構成でうまくいかないことがわかったので、ログも Redis に保存するようにした。

この状態で再度ベンチマークを実行したところ、CPU がほとんど使われておらず、I/O 負荷も全然高くなくてネットワークがボトルネックとなっていることがわかった。 最初は CPU が弱い1号機を Redis 専用サーバにして、残り2つをフロントかつアプリケーションサーバとして使おうと考えていたけど、3台ともフロントに置く構成でいくことにした。 また、サーバ間の無駄な通信を避けるために、GET /slots/:slot/ad がパスだけではなくホスト名も含んだ状態で動画の URL を返すようにして、nginx 間の proxy_pass が不要な形にした。 この状態で8000点近いスコアを出せて、結局ほぼこのときのスコアのまま終わってしまった。

この後、hiredis-rb を使うようにしてみたり、リダイレクトを一段減らしたり、Linux や nginx の設定でネットワークパフォーマンスを上げようとしたりしたけれども、どれも大して効果は出なかった。 リモートでのベンチマーク実行のスコアが全然安定せず、普通に±1000点くらいのバラつきがあったので、どれが効果があってどれが無いのか判断に困った。 スタンディングを見ても上位チームのスコアの差はだいたい2000点くらいの範囲に収まっていて、リモートのベンチマーク実行の不安定さを考えるとほとんど差が無いと思っていた。

帯域の制限に悩まされているときに、一度30万くらいの異常なスコアを出したチームがあった。 これを実現するには、どうにかして動画を配信せずに済む方法があると思った。 動画を返すときにちゃんと Last-Modified ついてるよなーという確認はしたんだけど、その先の Cache-Control には全然気付いていなかった。

競技後に聞いた話の中では、グローバル IP とプライベート IP の両方を使うという発想は全然なかったなーと反省した。 たしかに NIC 2つあるんだから、帯域の制限で困ってるんだったら両方使う発想は出てもよかった。 とにかく帯域に悩み続けて、メモリや I/O は十分に余裕があって CPU もほぼほぼ idle という状況だったので、app.rb 内の削れる処理に気付いたとしても「でも CPU は超余裕なんだよな……」となって進めなかった。 自分は今回が初めての参加だったけど、悔しいので来年もきっと出ます。

裸族の集合住宅を安定的に運用するのに必要なこと

ポートマルチプライヤ + eSATA ではなく USB 3.0 を使う

センチュリー 裸族の集合住宅5Bay SATA6G USB3.0&eSATA CRSJ535EU3S6G

センチュリー 裸族の集合住宅5Bay SATA6G USB3.0&eSATA CRSJ535EU3S6G