Tuning Ruby on Rails on AWS ECS

Created
Aug 7, 2023 6:20 AM
Tags
ECSRuby on Rails
Editor
Wataru Ito
image

エンジニアの伊藤です。

前回の記事で、弊社和田よりAWS ECS (Fargate) を紹介いたしましたが、今回は実際に AWS ECS  (Fargate) 上で Application を稼働させた際にチューニングポイントとなった箇所を紹介いたします。

この記事で取り上げるモデルケースはシンプルにこれだけです。

  • Web Server
    • ECS Task 1 = nginx
    • ECS Task 2 = puma + Ruby on Rails
  • RDBMS
    • Amazon Aurora MySQL

ご自分でチューニングされる方を前提に話を進めていきますが、弊社では本記事で紹介する対応をサービスとして提供しておりますので、ぜひご活用ください!

一番お伝えしたかったことは書けましたのでもう思い残すことはありませんが、始めていきます。

ポイントは他の言語や Web Application Framework、RDBMS においても基本的には変わりませんが、Lighthouse で計測されるようなユーザ体験(Estimated Input Latency 等)は、本記事のスコープから外しインフラ・アプリケーションサーバレベルまでに絞っております(もちろん結果的に寄与する部分はあります)。

参考: Web Application 2018 From Performance Perspective

tl;dr - チューニングの目標値を決める

チューニングに終わりはありません。

故に、どこまでが必要なことでどこからが拘りになってしまうのか始めに線を引くことをオススメいたします。ゴールを決めましょう(ご存知の方は読み飛ばして次に進んでください)。

俳優の阿部寛さんのホームページの域まで到達できればやりきった!と言えるかも知れませんが、現実的には難しいものです。とりわけ、機能やデータが増える度に応答性能は劣化していくのが世の常ですので、元初の目標値を設定することは今後の運用上においても重要です。

ポイント

チューニング前の限界値を知る

  1. トップページや購入ページ、商品一覧などの速度を測定し限界値を知る(1秒毎に10並列のような)
  2. ユーザ導線にあるページすべてで行うことが望ましい
  3. Gatling や Jmeter でシナリオを作って試験するのがよい

Hourly Active User

  1. 業態にもよりますが Daily Active User の 20% 程度見れば十分
  2. 例えば、5万DAUが目標であれば、1時間に1万人を最大 HAU と設定

1 ユーザあたりの平均 pv

  1. 仮に 10pv/user であれば、1万人 * 10pv / (60sec * 60min) = 27.77...pv/sec という予測が成り立つ
  2. 完全にアクセスが均されることは考えにくいため、1.5倍程度余裕を見て、大凡 42pv/sec 捌けるリソースを用意すれば良い

予算との兼ね合い

  1. 予算は有限だが、サービスの安定性は重要
  2. HostOS 障害に対する冗長性を確保する為、Web サーバは1台余分に積み増す必要がある(N+1)
  3. よってN台で42pv/sec を達成できればゴール
  4. 負荷タイミングが決まっているのであれば「その時間帯のみ目標値を達成する」でもよい

のように押さえていくことで、目標値が求められますね。

肌感ですが、この規模を c4.large 2台で達成できないとすれば大分遅いように感じます。

ECS のサイジング

image

CPU、Memory それぞれに分けて考えていきます。Network 帯域は多くの場合、問題にならないので割愛します。

CPU

本来的には、1pv 辺りに必要とされる CPU Usage の平均値を求め、ECU で正規化するのがよいのですが、Fargate では ECU に基づいたサイジングが出来ないようです(困ったので同僚に相談したところ「それはオンプレ脳だぞ」と諭されるアットホームな職場です)。

Fargate の ECU は公式公開されておりませんし、CPU ガチャ問題があるようですが、「AutoScaling を切った状態で、CPU Usage を100%まで使いきれるか」のみを確認できれば AutoScaling に任せる指標を得られる為、ひとまず OK です。CPU の割当のみ過たなければ、使い切れる筈ですが、もし使い切れなかった場合は puma の worker 数もしくは thread 数が足りないと考えられます(後述)。

また、CPU Units の割当ですが、Fargate では ECS Container によって消費されるリソースを差っ引く必要がない為、自身の Task だけで使い切って問題ありません。vCPU 2 だとすれば、units は 1024 * 2 = 2048 あるため、nginx を動かす Task は 256, puma + Rails を動かす Task には残りの 1792 という配分で問題ありません。

Memory

Rails の場合、CoW が効きづらく比較的 Worker がファットになりがちな為、以下のようにサイジングするのがよいでしょう。これ以降は、ECS であっても EC2 や On-premise なサーバでの運用と同じ考え方で問題ありません。

  • Worker
    • 本数は vCPU に合わせる
      • 2 vCPU なら Worker は 2
        • CPU を使い切れないようなら増やしてみる
      • 消費メモリは 1 Worker につき 300MiB 程度と見積もるのが無難
    • Threads は公式ガイダンスに合わせて 16
      • こちらも CPU を使い切れないようなら増やしてもよい
  • Connection Pool
    • Threads と同値

業務用 Application など、大量に fetch するような用途が見込まれる場合、1 Worker 辺りが消費するメモリサイズの見積もりを増やしてください。

なお、安定稼働のために、弊社では puma_worker_killer を導入していますので、PoC を引用しておきます。ご参考まで。

  • RAILS_ENV = development の場合は、default 設定とする (開発段階では不要かつ起動に余計な時間がかかるため)
  • worker の fork は、PumaWorkerKiller から行う (公式ドキュメント通り)
  • puma の各種設定値は環境変数から取得させるが、無ければデフォルト値を利用
    • 弊社の場合、Heap が汚れやすい Application ではデフォルト値で 1h に一度程度、Worker が生まれ変わっています
# frozen_string_literal: true

require 'puma_worker_killer'

# No need to use below configurations for development stage.
return if ENV.key?('RAILS_ENV') && ENV['RAILS_ENV'] == 'development'

before_fork do
  PumaWorkerKiller.config do |config|
    config.ram =(ENV['PUMA_WORKER_KILLER_MAX_MEM'] || 2048).to_i
    config.frequency =(ENV['PUMA_WORKER_KILLER_FREQUENCY'] || 5).to_i
    config.percent_usage =(ENV['PUMA_WORKER_KILLER_MAX_MEM_USAGE'] || 0.98).to_f
    config.rolling_restart_frequency =(ENV['PUMA_WORKER_KILLER_ROLLING_RESTART_FREQ'] || 4 * 3600).to_i
  end
  PumaWorkerKiller.start
end

クエリ改善

image

アクセス・データが少ないうち(今回の目標値とした 42pv/sec も含め)に問題になるレベルでクエリ性能が落ちることは、昨今のハードウェア性能からしてまずありません。Rails Way に乗ったモデル設計ならば、InnoDB Buffer Pool に乗り切るため、NoIndex なクエリであっても捌けてしまいます。が、それゆえにサービスが成長し機能も増えてきた頃に問題が顕在化する為、サービスローンチ前に芽を潰しておくことが肝要です。

では、具体的な方法を説明していきます。

Slow Query の出力設定

Aurora も同様の手順で可能ですので、出力方法はこちらをご参照ください。

自分は、long_query_time をローンチ前のサービスでは 0.1 (sec)、ローンチ後であれば 1 (sec) にすることが多いです。

Query Digest 作成

query digest の作成には、Percona Toolkit の pt-query-digest を使います。

pt-query-digest については、この辺りをご参照ください。

全ログの Download + Query Digest 作成は以下で可能ですが、多すぎる場合は適宜間引いてご確認ください。

※ OSX or Unix (like OS) 上での操作を前提としています。

export target_db_identifier=YOUR_DB_INSTANCE_NAME

mkdir -p ~/work/slowlog/$target_db_identifier
cd ~/work/slowlog/$target_db_identifier

for log_file in $(aws rds describe-db-log-files --db-instance-identifier $target_db_identifier \
               | jq '.DescribeDBLogFiles[].LogFileName | select(. | test("^slowquery"))')
do
    download_file=$(echo $log_file | awk -F/ '{print $NF}')
    aws rds download-db-log-file-portion \
        --db-instance-identifier $target_db_identifier \
        --log-file-name $log_file \
        --output text > $download_file

    pt-query-digest --type=slowlog --limit 100 $download_file > ${download_file}.digest
done

細かい説明は省きますが、Query 改善は出力された ~/work/slowlog/$target_db_identifier 以下の *.digest なファイルを確認し、上から順に改善していくという、尾崎放哉いうところの咳をしても一人、そんな孤独で地道な作業となります。

出力されたクエリが Secondary Index (最も良いのは Primary Key ですが) を使えるよう足りない Index を追加または不要な関数の除去、Where 句を適切に絞り込むなどして修正してきます。その際の道標となるのが EXPLAIN です。MySQLのEXPLAINを徹底解説!! をご参考ください。

よほど重いクエリでなければ、Average は数 ms から遅くとも数十 ms 以内に収まるようになるはずです。

Profiler の活用

image

クエリを改善しても速度があがらない、どこかの処理で詰まっているようだが分からないという時に使います。クエリ改善までの対応が終わっていなければまだ使うべき時ではありません。

Profiler は言語ごとにありますし、好み問題でもありますが、私は Stackprof を愛用しています。導入が簡単で、処理も軽く、結果も分かりやすいといういいことづくめな Profiler です。

使い方は、SpringMT 氏の railsアプリでstackprofを使ってボトルネックを探す + JSON::Schema(2.2.1)の高速化 をご参考いただくと共に、stackprof_path は EFS mount した Volume を指定するなど取り出し可能な PATH をご指定ください (18/09/07 修正: Fargate では EFS を含め Volume Mount できないことを失念していたため訂正いたします)左記の事情のためローカルで Profiling する必要があります。実環境で行うのが最も望ましいので、EFS のサポートが待たれます。。

むすび

image

ここまでの対応を進めますと、少なくとも元の2倍程度は高速化できているものと思います。あまり効果が得られなかったとすれば、元々の処理が速かったのでしょう。

書かれた通りにやったが高速化できなかったという方がいらっしゃいましたら説明がいたらず申し訳ございません。