おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

wkhtmltopdfの次どうするか問題

wkhtmltopdfというライブラリは、HTMLからPDFを生成してくれるライブラリです。Rubyからだとwicked_pdfpdfkit 経由で使われることが多いです。

さてそんなwkhtmltopdfですが、GitHubリポジトリ を見るとわかるようにアーカイブされてしまっています。公式ページ に経緯が書いてありますが、要約するとwkhtmltopdfが依存しているQtWebkitのメンテが止まったのが原因でメンテが続けられなくなったということのようです。

wkhtmltopdfが参照しているQtWebkitはかなり古いものなので、モダンなブラウザでの描画と差分が出たり、脆弱性を付かれたりする可能性がありそう。なのでなにか別のやり方に乗り換えたい。

選択肢を考えてみる

乗り換え先は具体的に何がいいの、というと僕も自信を持ってこれだ!という解を持っていないのですが、headless chromeを使ってHTMLをPDFに変換するという方法がぱっと思い浮かびます。そしてchromeを動かすライブラリとしては ferrum がいいかな?となっているところです。同系統のgemには他にも grover などのgemがありますが、これはpuppetterを経由してchromeを実行します。ferrumは直接chromeを操作するので、登場人物が一つ減る分運用が楽そう。

しかしそもそもchromeを使ってHTMLをPDFに変換するのってメモリ容量やCPUへの負荷はどれくらいかかるものでしょうか?まだなにも計測していないのですが、wkhtmltopdfのときよりも負荷が高そうな雰囲気を感じます。(もちろん要件次第ですが)Thinreports などの直接PDFを生成するツールを活用したほうが良かったりするかもしれません。

と、以上のように考えていたら煮詰まってきました。そこでいったんここまでの状態でまとめて、PDFに詳しい人からのご意見を聞けたらいいなと思ってブログとして公開しておきます。だれか知見を持っている人アドバイスください :pray:

余談

wkhtmltopdf自体のインストール方法はいくつかありますが、wicked_pdfやPDFKitのREADMEなどで紹介されている wkhtmltopdf_binary_gem を使ってていることが多いかと思います。このgemは現在メンテナンスが滞り気味で、例えばaarch64(M1 mac)用のバイナリに現状対応していません。M1 macで開発していて困っている人、それなりにいるのではないでしょうか。

とりあえずdebianに関してはこのPR で対応済みなので、現状M1で使えなくて困るぞ、というのであればこのブランチを使ってください。

(2024/05/10追記)PRがマージされて新しいバージョンがリリースされたので、M1 macでもwkhtmltopdf_binary_gemを使えるようになっています

失敗したテストのログだけを出力するぞ

表題の通りのことができるgem、CiLoggerが便利ですよという話です。

私達は大量のテストをCI上で実行しています。テスト結果を見れば失敗理由が自明なものもありますが、E2Eテストなどでよく起きる「たまに失敗するテスト」の調査はログやスクリーンショットなど、可能な限りの情報を集めないと根本原因がつかめないことが多いです。

そんなときに、特に考えずRailsデフォルトの設定(config.log_level #=> :debug)のままにしておくと、膨大なログの中から該当するテストに関連する行を探し当てる作業が必要になります。これは事前の準備なしではほぼ不可能です。

事前の準備として簡単に思いつく方法は、テスト前後で「どのテストが開始/終了したか」をログに出力することです。

config.around do |example|
  Rails.logger.debug("start example at #{example.location}")
  example.run
  Rails.logger.debug("finish example at #{example.location}")
end

これで頑張れば失敗したテストに関連するログを探せるようになりました。が、やってみるとわかるのですが大量のログから該当行を探し当てるのは面倒です。それでも他の方法がなかったので長い間これで運用を続けていました。

そんな折ふと「そもそもCIでほしいのは失敗したテストのログだけであり、他は不要では?」と思いついたのでCiLoggerを作ってみました。このgemの内部ではloggerへの書き込みをいったんメモリ上に保持しておき、テストが失敗したらファイルに書き出す、という処理を行っています。これでしばらく運用してみて、仮説(失敗したログだけあれば良い)が正しそうであることを実感しています。

また、GitHub ActionsやCircleCIなどはArtifactの容量によって課金されるので、CiLoggerを使うとそれらの料金を抑えられるのもメリットです。よければ使ってみてください。

注意点

現時点(v0.3.0)ではRSpecのみに対応しています。minitestで使いたい人はPRお願いします :pray:

余談

開発当初は、CiLoggerを使うとログファイルへの書き込み量が減るので速度改善効果もあるだろうと想定していたのですが、少なくとも僕が測定した限りでは優位な速度差が出ませんでした。CiLoggerを使わずにlog_level:errorにしても速度差が出なかったので、計測方法が悪いのかなにか見落としがあるのか…。知見ある人いたら教えて下さい><

iCARE Dev Meetup #34で登壇しました

【iCARE Dev Meetup #34】10年続くRailsアプリ開発のために大事なこと - connpass

所感

長期間アプリケーションを無理なく運用していくには内部品質を一定以上に保つ必要があり、それに注力するためには発表で話した「ふつうのこと」に対するエンジニアをはじめとした会社全体の理解が大事なので僕たちは頑張ってコミュニケーションとっていきましょう、そしてふつうを普通にやっていきましょうということが言いたかったのですが、それだけだとRails風味が0なのと抽象的かつ実現が困難な話に終止してしまうので、推しgem(ViewComponent)の紹介を追加して具体的なRails味を出してみました。

どの会社の発表も、みんな泥臭く汗をかいて頑張っているんだな、というのが伝わり自分もやっていくぞ!という気持ちが高まる良い発表でした!今日もやっていきます(\( ⁰⊖⁰)/)

`Thread.current[:hoge]`はスレッドローカル変数を参照していると思いきや実際はファイバーローカル変数だった

このエントリはSmartHR Advent Calendar 2021の23日目の記事です。

SmartHRでは毎週「Rubyist@SmartHR(仮)」という名の定例ミーティング*1が行われています。このミーティングはバックエンドエンジニアが集まり、チームをまたいだ情報共有や相談をすることを目的としています。その中では僕がTipsなどを共有する「willnetさんのありがたいお言葉」というコーナーが常設されています。

このエントリでは、そのコーナーで共有した内容をひとつ紹介します*2

Thread#[]で取得できる値はファイバーローカル変数なのだった

アプリケーションのコードではあまり見かけませんが、ライブラリ中でスレッドセーフを意識している設定を読むと

Thread.current[:locale] = :ja

のようになっているのをよく見かけます。それで僕はThread#[]はスレッドローカル変数を返すのだな、と長年思いこんでいたのですがそれは大きな勘違いでした。Thread#[]=で設定した値はファイバーを切り替えると参照できなくなってしまいます。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current[:locale] = :ja
  puts 'in another fiber'
  puts "locale: #{Thread.current[:locale]}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current[:locale]}"

実行結果

in another fiber
locale: ja
in main fiber
locale: 

ではファイバーを切り替えても同一スレッドであれば同じ値を返すようにしたいときにはどうすればよいでしょうか?

スレッドローカル変数を扱いたいときは別のメソッドを使う

Thread#thread_variable_getThread#thread_variable_setを使います。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current.thread_variable_set(:locale, :ja)
  puts 'in another fiber'
  puts "locale: #{Thread.current.thread_variable_get(:locale)}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current.thread_variable_get(:locale)}"

実行結果

in another fiber
locale: ja
in main fiber
locale: ja

よく読むとるりまにもちゃんと書いてありますね…。

Thread#[]Thread#[]= を用いたスレッド固有の変数は Fiber を切り替えると異なる変数を返す事に注意してください。

それがどうしたんですか?

Rails6.1までは、恐らくスレッドローカル変数のつもりでファイバーローカル変数(Thread#[], Thread#[]=)が使われている箇所がありました。例えばCurrentAttributesの設定値はファイバーローカル変数を使っています。

rails/current_attributes.rb at f0506126cb98616444b359162361d2aaaa329f46 · rails/rails

これは、たとえばpumaのようなスレッドベースのアプリケーションサーバのどこかで複数のファイバーを使っていると問題になります。ファイバーAで設定したファイバーローカル変数は(当たり前ですが)ファイバーBでは見えません。これは期待している挙動ではないでしょう。

いまどきのよくあるRailsアプリケーションでは明示的に複数ファイバーを使うことはあまりなさそうに思えます。が、例えば内部でファイバーを使っているgemに依存性があるかもしれません。また、明示的にファイバーを使わなくてもEnumeratorで外部イテレータを使っているところではファイバーが使われています(参考: class Enumerator (Ruby 3.0.0 リファレンスマニュアル) )。外部イテレータもいまどきのよくあるRailsアプリケーションで明示的に使われることは少なそうですが、これも依存しているgemで採用されている可能性はあります。

下記のコードは外部イテレータでファイバーローカル変数とスレッドローカル変数をアサインしたあとに参照したサンプルコードです。アサインしたはずのファイバーローカル変数が参照できていない事がわかります。

o = Object.new
def o.each
  yield Thread.current[:fiber_local] = 'hi'
  yield Thread.current.thread_variable_set(:thread_local, 'hihi')
end

e = o.to_enum
e.next #=> 'hi'
e.next #=> 'hihi'
Thread.current[:fiber_local] #=> nil
Thread.current.thread_variable_get(:thread_local) #=> 'hihi'

まれに問題になりそうなのはわかったけど…それで?

素直に考えるとRailsの内部でファイバーローカル変数を使っている箇所をスレッドローカル変数に変更すれば良いように思えます。が、みんながスレッドベースのアプリケーションサーバを使っているとは限らないのでした。falconなどのファイバーベースのアプリケーションサーバを使いたい人たちにとってはファイバーローカル変数でないと困ります。

そこで、Rails7.0からは、設定としてスレッドローカル変数を使うかファイバーローカル変数を使うか選択できるようになりました。

Introduce ActiveSupport::IsolatedExecutionState for internal use · rails/rails@540d2f4

デフォルトでは:threadが設定されているので、pumaなどのスレッドベースのサーバを使っている人はRails7.0に上げれば問題は起きなくなりそうです。falconを使いたい人は下記のように明示的にfiberを使うようにする必要があります。

Rails.application.config.active_support.isolation_level = :fiber

そんなすぐRails7.0にあげられないぞ、という人はそれっぽい問題が起きたときにこのことを思い出せるようにしておいてください。そして早く7.0にできるように頑張りましょう。

まとめ

  • Rubyでのスレッドローカル変数とファイバーローカル変数の違い
  • 関連したRailsでの問題と解決の事例

について書きました。

Rails本体に関してはRails7.0以降は問題が起きなさそうですが、他のgemでスレッドローカル変数を意図してThread.current#[]=を使っていそうなところがちらほらあり、ちゃんと考えると何らかの対応(ex: Railsのように設定でスレッドローカルとファイバーローカルを切り替える)をしないといけないんだろうなーという気がしています(が、全てのgemで対応するのはなかなか難しそうですね…)。

*1:バックエンドミーティングやバックエンド定例とも呼ばれています

*2:ちなみに去年のアドベントカレンダーも同じように同じようにありがたいお言葉コーナーからの切り出し記事でした https://blog.willnet.in/entry/2020/12/11/100000

Rack::RuntimeがRailsのデフォルトRackミドルウェアから削除された

Rack::RuntimeというRackミドルウェアがあります。これはリクエストを処理するのにかかった時間を"X-Runtime"というレスポンスヘッダに含める、というものです。コードはこれ↓

rack/runtime.rb at master · rack/rack

これはRailsのデフォルトのRackミドルウェアであり、特に何もしない限り有効になっています。

このX-Runtimeが、タイミング攻撃で使われている事例があったとのことで、デフォルトから削除になりました。

Remove Rack::Runtime and deprecate referencing it · rails/rails@7bfcf4b

これにより、明示的にRack::Runtimeをミドルウェアで使う宣言をしない限りはRack::Runtimeは使えなくなります(Rails7.0から)。

実際にはX-Runtimeの値を見なくても、レスポンスが帰ってくる時間を計測すれば(ネットワークを経由することによる多少の誤差はありつつも)タイミング攻撃はできてしまうのでRack::Runtimeの削除の効果は限定的です。しかし普段の開発で使ってないならRails7.0を待たずに削除しておくのがいいんじゃないかな、と個人的には思っています。↓のコードでデフォルトRackミドルウェアから消せるので、消したい方はこれで消してみてください。

# config/application.rb

config.middleware.delete Rack::Runtime

追記

削除のコミットがrevertされたので結局Rails7.0でも使えます。revertされたのは上の方で書いた

レスポンスが帰ってくる時間を計測すれば(ネットワークを経由することによる多少の誤差はありつつも)タイミング攻撃はできてしまうのでRack::Runtimeの削除の効果は限定的です

が理由のようです

Add back Rack::Runtime to the default middleware stack. · rails/rails@4ace047

Gimeiのバージョン1.0.0をリリースした

から、そろそろ1.0.0にしてもいいんじゃないかな、という気持ちになり1.0.0のリリースに踏み切りました。

ランダムで名前を返す、というコアな機能は最初のリリースからほぼ変わってないのですが

  • 住所も返せる(ex: Gimei.address)
  • テスト内でユニークな名前が返ることが保証できる記法がある(ex: Gimei.unique.name)
  • 名前と住所のデータを遅延読み込みするようになった

などなど、8年で地味に着実に改善が進んでいます。どうぞご利用ください。

willnet/gimei: random Japanese name and address generator

iCARE Dev MeetupでHotwireについて話した

僕がお手伝いしているiCAREさん主催のミートアップ、iCARE Dev Meetupで、最近発表されたBasecamp社製jsフレームワークであるHotwireについて話しました。

【iCARE Dev Meetup #18】技術顧問が語る、Ruby on Rails実践開発 - connpass

動画も公開されているので気になる方は探してみてください*1

所感

弊社サービスであるsavanna.ioはHotwireを使って作っています。Hotwireはつい最近発表されましたが、その前身となるフレームワークであるturbolinksStimulusの組み合わせで数年間開発していました。

なので「サーバサイドはHTMLを返し、jsは最小限」というスタイルが少人数で開発するチームにとてもマッチすることは身を持って体験しています。Vue.jsやReactを採用していたら機能を開発するスピードはもっと遅かったに違いありません。

もちろん適材適所なのでHotwireが適さない分野もありますが、Hotwireが適しているのに無理して他のjsライブラリを使っているプロジェクトはたくさんあるんじゃないかなあ…と推測しています。この発表でちょっとでもそんなプロジェクトが減りみんながハッピーになると嬉しいです(\( ⁰⊖⁰)/)

*1:恥ずかしいのでリンクは貼らない