Dockerコンテナとして本番実行しているRailsをjemallocで高速化&省メモリ化した話

こんにちは。 メディアサービス開発部 Webアプリケーション開発課のフサギコ(髙﨑)です。

Ruby on Railsによるバックエンドの実装運用とAWSによるサービスインフラの設計構築を中心とした、いわゆるバックエンド方面のテックリードとしてニコニコ漫画を開発しています。

本記事では、私たちが開発・運用しているRails製のjson APIバックエンドにおいて、mallocの実装としてjemallocを使うように変更したことについてお話します。

背景

私たちが2020年4月から開発しているニコニコ漫画のRails製json APIバックエンド(以降「新バックエンド」と呼びます)は、2010年8月のサービス開始以来サービスを支え続けているPHP製アプリケーションのビジネスロジックに関する式年遷宮的移行先であり、当初よりAWS ECS Fargate上で実行しています。*1

今年8月にリリースされたRails 7.2.0において、新規作成したRailsアプリケーションに生成されるDockerfileではjemallocをデフォルトで使うようになったという情報を目にしました。

Railsアプリケーションを実行する際にGNU mallocよりメモリの使用効率が改善されるというjemallocは下記のように他社の技術ブログなどでもたびたび取り上げられていたため知っていましたが、 新バックエンドのDockerfileにどのように組み込むか迷っていたところだったので、Rails 7.2が自動生成するDockerfileを参考に新バックエンドにも導入してみることにしました。

mallocをjemallocに差し替える

Rails 7.2.0が生成するDockerfileでjemallocに関係する箇所は下記の2つです。

Dockerfile

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libsqlite3-0 libvips && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

Dockerイメージのエントリーポイント

# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then
  export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)"
fi

LD_PRELOAD環境変数をこれで初めて知ったのですが、shared libのpathをコロン区切りで設定しておくだけで再コンパイルせずに読み込まれるshared libを差し替えられるのはかなりアクロバティックな動きだな、と思いました。

さて、新バックエンドではdockerコンテナとして起動するときのentrypointはbashではなくRubyで書いたスクリプトを使っているので、下記のようにして前述したbashスクリプトと同様の動作を記述しました。*2

def set_ld_preload
  ld_preload = ENV.fetch('LD_PRELOAD', nil)

  if ld_preload.blank? && (jemalloc_paths = Dir.glob('/usr/lib/*/libjemalloc.so.2')) && !jemalloc_paths.empty?
    ENV['LD_PRELOAD'] = jemalloc_paths.join(':')
  end
end

(中略)

set_ld_preload

(中略)

exec(*ARGV)

導入前後の比較

ニコニコ漫画はAPM*3としてNew Relicを使用しているので、これを用いてレスポンスタイムとメモリ使用率が導入前後でどのように変化したかを比較します。

レスポンスタイム

まずレスポンスタイムについて比較します。

次に示すグラフは、新バックエンドのレスポンスタイムの中央値、平均値、p95(95パーセンタイル)、p99(99パーセンタイル)のそれぞれについて、導入後を実線、導入前の同時間帯を破線として描いたものです。

赤色のp99では概ね10~15ミリ秒程度、紫色のp95でも良いときには10ミリ秒程度短縮されています。中央値と平均値ではほぼ差はみられませんでした。

10時50分ごろの大きな山は、毎日11時に新しいエピソードの公開を狙って発生しているユーザの突入負荷に備えて起動している新しいタスクが、起動直後であるためYJITのJITコンパイルによって高速化されていないことによるものです。

メモリ使用率

次にメモリ使用率について比較します。

次に示すグラフは、ECS Fargateから取得できる、新バックエンドを起動しているタスク群のなかで最もメモリを使用しているタスクのメモリ使用率*4で、導入後を実線、導入前の同時間帯を破線として描いたものです。

全体として、10%程度メモリ使用率を低減できていることがわかります。

10時50分ごろの大きな谷はレスポンスタイムの項で述べた通り11時の突入負荷に備えて新しいタスクを起動しているためのもので、最初に多めにメモリを割り当てておくことでメモリの断片化、消費量の肥大を防止しているのかと思われます。

またこのグラフより、レスポンスタイムのp99やp95が短縮されたのはメモリの断片化が抑えられたことで参照局所性が向上し、CPUのキャッシュの利用効率が良くなったからではないかとも思いました。

まとめ

本記事では、ECS Fargateによってdockerコンテナとして起動しているRailsアプリケーションにjemallocを導入した方法、そして導入した結果レスポンスタイムとメモリ使用率がどのように変化したかについてお話しました。

NewRelicなどのAPMを用いた性能測定はもちろん必要だと思いますが、この程度の手間でp99が220ミリ秒から210ミリ秒へ10ミリ秒程度、4.5パーセントほど高速化されるのであればやって損はないチューニングだと思いました。

さて、あとはお決まりのような感じになりますが、ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好きなWebアプリケーションエンジニアを募集しています。 ご興味がありましたらぜひ下記採用情報ページより、カジュアル面談からでもご連絡ください。

www.bookwalker.co.jp

*1:ニコニコ漫画のサービスインフラ構成の概観については、2年半前の記事のため一部古い箇所もありますが、『ニコニコ漫画のインフラ構成について - BOOK☆WALKER inside』をご覧ください。

*2:nilと空文字の判定を楽にしたかったので 'active_support/core_ext/object/blank' だけrequireしています

*3:Application Perfomance Monitor アプリケーションソフトウェアに組み込んで処理にかかった時間などの性能測定を行うモジュール、あるいはそのマネージドサービスのこと。

*4:すなわちタスクグループのメモリ使用率の最大値