Ruby on Railsで作ったWebサービスを5倍速くしてメモリを半分以下にした話

  • 331
    いいね
  • 5
    コメント

表示速度の高速化が趣味のzaruです。こんにちは。今回はRuby on Railsで作られた弊社Webサービスの表示速度を約5倍ほど速くしたので、何をしたのかをまとめました。Railsの高速化手法はいたるところで語られていますが、気にせず行きます。

前提や結果など

アーキテクチャとしてはわりと一般的な AWS ELB -> nginx -> Unicorn / MongoDB という構成です。

改善前 改善後
Ruby 2.1 2.3
Rails 4.1 -
MongoDB 2.6 3.2
Redis 2.4 3.2

Ruby・MongoDB・Redisのバージョンアップ、Railsもバージョンアップしたかったけど依存ライブラリの関係で据え置きになりました。

改善前 改善後
ページ読み込み速度 8.49sec 1.69sec
秒間リクエスト数 1.72req 4.99req
Unicorn使用メモリ 1.2GB 500MB

改善前に比べ約5倍表示速度が速くなりました。また、1秒間にさばけるリクエスト数も約3倍ほどになっています。Unicornの1プロセスあたりが使用しているメモリもだいぶ低くなりました。

なお、ページ読み込み速度は、ブラウザでページを表示したときにインジケータのクルクルが止まったときです。Chromeの開発ツールのネットワークタブで赤い文字で Load 1.2sec とか表示されているやつです。GoogleAnalyticsのページ速度でいうと plt というキーでレポートされているものです(参考ページ)。

グラフとか

s1.png

GoogleAnalyticsのグラフです。読み込み時間が下がっています。

s2.png

メモリ使用量です。Zabbixからmackerelに乗り換えたのでグラフが違いますが、使用量が下がって安定しているのがわかります。

s3.png

s4.png

AWS ELBのレイテンシです。不安定なレスポンスが安定してるのがわかります。

戦略

  • まずは計測してボトルネックを洗い出す(推測するな、計測せよ)
    • 計測は主に rack-mini-profilersiege を使った
    • GoogleAnalyticsに送信されるページ表示速度 plt を参考値に使った(Navigation TimingAPIと同値)
  • データベースに仕事をさせない
    • 無駄なクエリを発行しないように
    • インデックスが適切に使われているか確認
    • DBだと重い処理をアプリケーションで請け負う
  • 必要のない機能・仕様を削除する
    • 速度の犠牲になっても良いような機能や仕様を企画チームにプレゼンする
  • ミドルウェアの最新化
    • Rubyはとりあえずバージョンアップ
    • Gemもできればバージョンアップ
  • 外部JavaScriptをなるべく使わない
    • 広告・計測系は本当に必要なのかを確認する
    • SNSパーツはそのまま使わない
  • 非同期にできるものはガンガン非同期にする
  • キャッシュは最後の手段
    • 遅い→キャッシュしようだと後々キャッシュコントロールで死ぬ
    • 遅い→原因を特定→直すがまず最初

なにをやったか

それでは戦略をもとになにをやったかを詳しく書いていきます。

消費メモリのスリム化

まず、高速化の前に消費メモリを削減しました。Unicorn 1プロセス辺り1GB超えてるというのを見たときは自分の目を疑いましたが、どうやら事実のようです(僕が今まで携わったRailsアプリだと150〜300MBくらい)。

また、メモリリークしているのかUnicornのプロセスが起動し続けているとメモリをどんどん食っていき、最終的にはメモリが足りずにプロセスが落ちるという問題を抱えており unicorn-worker-killer を使って一定のメモリを超えるとUnicornのプロセスを再起動するようにしてしのいでいました。

というわけで、Rubyのバージョンアップとコードをスリムにするという2つの路線でやってみました。

Rubyのバージョンアップ

Rubyのバージョンが2.1と古かったので最新の2.3に上げました。移行作業は一部のgemバージョンが古かったのでそれをupdateした程度で、特に既存のコードの改修も必要なかったです。これによって、約20%ほどメモリ使用量を減らすことができました。

必要のないgemを消す

次にインストールしているけど実際は使っていないgem達を消しました。今回のWebサービスはリリースしてから2年ほどたっていて、その間にどんどん機能追加がされていました。その結果、当初は使っていたが今は使っていない…とか、gem本来の機能は使ってないんだけど、その中で依存しているファイルを直接使ってしまっていて、消すに消せない…みたいなのが積もっていました。

依存しているgemも含めると約30くらいは削除しました。これによってアプリケーションの軽量化ができて使用メモリ量もだいぶ低くなりました。requireされる数も減るので表示速度にも貢献していると思います。

Rubyにはすごく便利なgemがあって、ついついたくさん入れてしまうけれど、本当にそのgemが必要なのかを判断することが必要だなと思いました。また、似たような機能でどちらを選ぶかの判断軸の一つとして、依存しているgemが大きすぎないか・多すぎないかなども気にしたほうが良いですね。

これによってメモリ使用量を半分以下に抑えることができました。

必要のない機能を削除

最後に自分たちで書いたアプリケーションコードをスリム化しました。2年間機能追加をしていると、当初は必要だったけど今は使われていない機能とかが存在します。これらを企画チームと調整してバッサリ削除しました。メモリ使用量という面ではそれほど貢献はしませんでしたが、コードの可読性が上がったので良かったです。

処理速度の改善

ランキングロジック変更

ページビューに応じた記事ランキングというコンテンツがあります。日別・週間・月間・総合と集計期間が分かれていました。この集計ロジックがちょっとマズい設計になっていたので作り変えました。

改善前

記事データを持っているArticleモデルの例です。

> Article.first

<Article
  _id:1234,
  title: "記事タイトル",
  total_pageview: 10000,
  pageviews: [
    { date: 2016-12-01, view: 500 },
    { date: 2016-12-02, view: 400 },
    { date: 2016-12-03, view: 300 },
    { date: 2016-12-04, view: 400 },
    { date: 2016-12-05, view: 500 },
    { date: 2016-12-06, view: 400 },
    { date: 2016-12-07, view: 300 },
    { date: 2016-12-08, view: 400 },
  ]
>

1日毎に閲覧数をArticleモデル自体に持たせています。これだと日が経つと1レコードあたりのデータ量がどんどん大きくなってしまいます。これをMongoDBのaggregateを使って期間毎に集計していました。データ量が増えてくると集計もかなり時間がかかります。集計結果をキャッシュしていましたが、キャッシュタイミングもバッチで事前キャッシュではなく、最初の閲覧ユーザ1人にコストを払ってもらう形だったので、ユーザ体験的にも良くなかったです。

改善後

サービスの仕様として各記事の日別ページビューをDBに持たせておく必要はなかったので、pageviewsフィールドを削除しました。これだけでfindクエリが倍の速さになりました。

次にRedisを使ってランキングデータを作ることにしました。Redisにはソート済みセット型という便利なものがあります。スコア順に勝手に並び替えてくれるのでランキングにうってつけです。

pageviews = Redis::SortedSet.new("page_views/2016-12-01", :expiration => 86400 * 30)
pageviews.incr(article_id)

こんな感じで日別毎に記事IDをキーとした閲覧ランキングが作れます。

ranking = Redis::SortedSet.new("page_views/2016-12-01")
ranking.revrangebyscore("+inf", "-inf", limit: 10, with_scores: with_scores)

取り出すときはこんな感じです。週間や月間ランキングを出したいときはデータをunionすれば集合結果が手に入ります。

r = Redis::SortedSet.new("page_views/2016-12-01")
r.unionstore(key, ["page_views/2016-12-02", "page_views/2016-12-03"])

Redisなのでレスポンスが高速でリアルタイムにランキングデータを表示することができるようになりました。よほどのトラフィックがない限りはこれでいけると思います。

Sidekiqバージョンアップ

Sidekiqを使ってキュー処理もしていたので、そちらの高速化もやってみました。Sidekiqはバージョン3系と4系ではパフォーマンス差が大きいので、まずバージョンを4に上げました。Sidekiq v4はRedis2.8以上、3推奨だったので、Redisのバージョンも2.4系から3.2系にアップしました。

公式によるとSidekiq v3 800 jobs/sec が v4だと 4500 jobs/sec のスループットが出るらしいです。今回のサービスではそこまでキューの数が多くなかったので体感するところまではいきませんでしたが…。どちらかというとSidekiqのメモリ使用量が減ったのが良かったです。

定番対応

以下は表示速度改善でよく出てくる定番な対応です。

Turbolinks復活

諸事情によりTurbolinksをオフにして運用していましたが、今回を機にJavaScriptまわりを全部書き換えてTurbolinksを復活させました。これをやったことで体感速度をグッと上げることができました。Turbolinksは挙動をちゃんと理解すれば怖くないです。怖くないですが、開発メンバー全員にちゃんと理解してもらわないとバグを生み出してしまうので、それもセットでやる必要があります。

SNSパーツの高速化

SNS拡散のためにTwitterやfacebook、はてブなど公式のSNSパーツを設置していましたが、これらの表示が遅く、JSも読み込みたくなかったのでオリジナルのボタンにしました。シェア数はAjaxで各SNSのWebAPIへ問い合わせした結果をnginxリバプロでキャッシュして表示しました。これだけでもスマホで閲覧したときに体感速度が上がりました。

画像を遅延表示

表示高速化ド定番の画像の遅延表示です。jQuery LazyLoadを使いました。以上。なんですが1点だけ改善ポイントを。そのまま $("img.lazy").lazyload(); とやってしまうとスクロールしたときに画像非表示から少したって表示…という動きが出てしまい自然と目で追うので体験的に良くないです。

そこでjQuery LazyLoadに用意されている threshold というオプションを指定します。これはブラウザ上にまだ表示されていない画像を先読みすることができます。$("img.lazy").lazyload({threshold : 500}); と指定すると500px先の画像を読み込んでくれます。これでスクロールしても遅延読み込みをしていないように見せることができます。

AMP

AMPにも対応しましたが、これは今のところGoogleやはてなブックマークなど特定のプラットフォーム経由でないと効果を発揮しないので、なんとも言えないですが記事がメインのサービスであればやったほうが良いような気がしてます。どちらかというとGoogle検索結果で露出が増えるという副次的効果のほうが大きいかも。

その他

  • N+1撲滅
  • パーシャルレンダリングの抑制

やらなかったこと

HTTP/2

HTTP/2への切り替えを検証し、パフォーマンスが向上することも確認できましたが採用しませんでした。今回のサービスではCDNにAkamaiを利用しています。HTTP/2に切り替えるとなると同じFQDN(AWS)で返却したくなります。しかし、AWSからアセットファイルを配信するとなると転送料金がバカ高くなってしまいます。圧倒的にAkamaiの方が安いのです…。今後はAkamaiでのHTTP/2も検討していきたいです。AWS ALBも登場しましたし、いよいよHTTP/2を使わない材料がなくなってきましたね。やれる環境なら積極的に採用すべきと思います。

さいご

動作速度と機能の豊富さは、反比例するような関係だと思っています。なるべく仕様や設計の時点でシンプルにすることで一定のパフォーマンスが担保できます。逆に速度だけを優先してコードや構成を魔改造してくと、数日後になにをやっているんだこいつは?状態になります。可読性を犠牲にして速度を求めるくらいなら、仕様を変更するか、その処理だけ別の言語にリプレースするなどを検討した方がいいような気がしています。

まぁ、とはいえWebページの表示速度は速くて誰も困らない、むしろ皆嬉しいはずなので日々検証して改善していきたいところですね。

以上です。ありがとうございます。