Sinatra製簡易WEBアプリのパフォーマンス調査 on Fargate(EC2と比較しながら...のはずだった)

:::note info
この記事はLITALICO Advent Calendar 2023のカレンダー3の12日目の記事です。
https://qiita.com/advent-calendar/2023/litalico
:::

# はじめに

## 自己紹介

株式会社LITALICOでWEBエンジニアをやっています。ti-aiutoと申します。

普段は個人向けのWEBサービスの開発を担当していて、特にモノリスなアプリケーションを持続可能な形に保つこと、フロントエンドの開発しやすい環境を整えることに関心があります。

今年度から新設された基盤グループというところに異動して、セキュリティ面・パフォーマンス面や開発効率向上などの観点から色々と動いていく仕事をしています。

## この記事の背景

今年の2月に、とあるアプリケーションのEC2 → ECS on Fargateの移行を行いました。

その話は別途記事としてご紹介する予定なのですが、その中でFargateに移行した途端にLatencyが倍に悪化するということがありました。

調べていくと、Fargateはスペック面では良いとは言えないという話が見つかり、それを実際に自分で確かめてみたくなったというのが背景になります。

> My recommendation for developers, if network throughput and IO performance are important for your application better to use EC2.
> 
> https://stackoverflow.com/questions/67983075/performance-issue-with-aws-fargate

> EC2からECSに移行した際、コンピューティングにFargateを選択すると同等のパフォーマンスを出すのにコストが+40%かかった。
> 
> https://www.docswell.com/s/integrated1453/K6Q8XK-operation-of-ecs-on-ec2

> FargateのCPUはお世辞にも良いとはいえず、それなりの型落ちを含むのは間違いない。んでもって、割り当てられるCPUモデルの性能に結構な落差があるのがわかった。
>
> https://blog.father.gedow.net/2021/05/21/aws-ecs-fargate-cpu/

## この記事でやりたいこと

* 移行当時の環境を念頭において、実際にEC2とFargateのスペックを比較してみます
* （別件ですが関連して、worker数だけを増やしていったときにスループットがどう変化するのかも調査してみます）

## 方法

CPUをゴリゴリ使う処理と、クエリをゴリゴリ叩く処理をSinatra製WEBアプリとして準備して、実際にAWS上で動かしてみます。

負荷計測にはk6を使い、計測のしやすさのため、同じタスク内で起動しているPumaに対してリクエストする形にします。同じタスク内に入れることで多少数値が悪く出る可能性はあるかもしれませんが、今回はそこは許容するとします。

実行環境については、参考記事のように使用されているCPUの型番もログに出力して見ていくことにします。

## 前提

パフォーマンス計測には次のコードを使います。

実際に本番環境で稼働しているアプリケーションをマルチスレッド化していないため、今回の計測でもマルチスレッドは考慮していません。

ちなみに現時点で本番環境でマルチスレッド化していない理由ですが、マルチスレッドを有効化した際に稀にレスポンスタイムが劇的に悪化するという怪しげな挙動が見られたので、現時点では無効化して運用しています。

### 負荷をかける処理

```ruby
require 'bigdecimal'
require 'mysql2'

# https://docs.ruby-lang.org/ja/latest/library/bigdecimal.html
#
# Calculates 3.1415.... (the number of times that a circle's diameter
# will fit around the circle) using J. Machin's formula.
#
def big_pi(sig) # sig: Number of significant figures
  exp    = -sig
  pi     = BigDecimal('0')
  two    = BigDecimal('2')
  m25    = BigDecimal('-0.04')
  m57121 = BigDecimal('-57121')

  u = BigDecimal('1')
  k = BigDecimal('1')
  w = BigDecimal('1')
  t = BigDecimal('-80')
  while u.nonzero? && u.exponent >= exp
    t *= m25
    u = t.div(k, sig)
    pi  += u
    k   += two
  end

  u = BigDecimal('1')
  k = BigDecimal('1')
  w = BigDecimal('1')
  t = BigDecimal('956')
  while u.nonzero? && u.exponent >= exp
    t   = t.div(m57121, sig)
    u   = t.div(k, sig)
    pi  += u
    k   += two
  end
  pi
end

module Weight
  module_function

  # メソッド内の数値は手元のIntelMacで100ms強くらいになるように調整した値
  def calc_pi
    100.times do
      big_pi(200)
    end
  end

  def push_array_hash
    5000.times do
      array = []
      100.times do
        array.push({ value: 12_345 }) # 適当な値
      end
    end
  end

  def concat_string
    50.times do
      result = ''
      1000.times do
        result += 'hogefugapiyo'
      end
    end
  end

  def mysql2_client
    @mysql2_client ||= Mysql2::Client.new(host: ENV['DB_SERVER_HOST'], database: ENV['DB_SERVER_DB'],
                                          username: ENV['DB_SERVER_USERNAME'], password: ENV['DB_SERVER_PASSWORD'])
  end

  def wait_for_db_many
    50.times do
      mysql2_client.query('SELECT 1')
    end
  end

  def wait_for_db_long
    5.times do
      mysql2_client.query('SELECT sleep(0.05)')
    end
  end
end
```

### アプリケーション本体

```ruby
require 'sinatra'
require './weight'

get '/hello_world' do
  'Hello world!'
end

get '/concat_string' do
  Weight.concat_string
  'done'
end

get '/calc_pi' do
  Weight.calc_pi
  'done'
end

get '/push_array_hash' do
  Weight.push_array_hash
  'done'
end

get '/wait_for_db_many' do
  Weight.wait_for_db_many
  'done'
end

get '/wait_for_db_long' do
  Weight.wait_for_db_long
  'done'
end
```

なお接続先のRDSインスタンスは、実際の稼働環境のスペックも考慮に入れつつ `db.r6g.large` を使っています。 

# 実験その1

## やること

何はともあれ動かして計測してみます。vCPU数は少なめにしたほうが大きく差が出ると考えたため、まずは `vCPU=2` で試してみます。

## 仮説

* CPUゴリゴリ使う系については、参考記事ではCPUパフォーマンスのベンチマークがFargateのほうが悪かったので、そのような結果になるはず
* クエリゴリゴリ叩く系については、Stackoverflowの回答や実際に本番稼働しているアプリケーションの過去の傾向から、Fargateのほうがだいぶ悪く出るはず

## 前提

* `vCPU=2, RAM=3or4GB` でのスペックを比較していきます
    * EC2のほうは `t3.medium`, `c5.large` , `r5.large` を使います
* Pumaのパラメータは `workers=3, threads=1:1` です

## 結果...以前に

いくつか設定を変えて何回か回しているうちに気づいてしまったのですが、FargateのときのCPU型番と `t3.medium` と `c5.large` の型番が同じになっています。

成功リクエスト数の数値を見ても、必ずしもEC2のほうが勝っているとも言い難い状況です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/f5d9e33f-a0f0-06cd-d715-022cfb3ad5cb.png)

参考記事では「型落ち品」が使われているという話がありましたが、時間が経つについて「型落ち品」もかつての最新版のCPUに置き換わっていく可能性があるわけで、今割り当てられているCPUはいうほどスペックが低くはないのでは？という疑惑が出てきました。

# 実験その2

## やること

実際に本番環境で使われていたのと同じ状況に近づけるため `vCPU=4` で試してみます。

## 仮説

* 実験その1と同様、FargateのCPUでそこそこ新しいものが割り当てられていれば、大きな差はつかないはず

## 前提

* `vCPU=4, RAM=16GB` でのスペックを比較していきます
    * EC2のほうは `r5.xlarge`　, `r6i.xlarge` を使います
* Pumaのパラメータは `workers=6, threads=1:1` です

## 結果

かつて実際に本番環境で使っていた `r5.xlarge` との比較については、なんとFargateのほうが良い数値が出ています！

参考までにPassmarkのベンチマーク結果を検索してみても、 `8259CL` は30463点、 `8375C` のほうは55705点ということで、後者のCPUが割り当てられた場合は高いパフォーマンスを発揮できそうです。

`r6i.xlarge` のほうは、良い数値が出ている項目もありますが、その項目はブレが大きく出やすいということと、CPU型番が同じである以上は「Fargateは型落ち品が割り当てられるためスペックが低いかも」という前提が崩れてしまうため、これ以上の深追いはやめることにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/5d12cef3-3a74-8726-4276-7fc48a9cf6ce.png)

https://aws.amazon.com/jp/ec2/instance-types/

> Amazon EC2 R5 インスタンスは、vCPU ごとに R4 よりも 5% 多いメモリを提供し、最も大きいサイズでは 768 GiB のメモリが提供されます。
> 新しいインテル Advanced Vector Extension (AVX-512) 命令セットを内蔵した最大 3.1 GHz のインテル Xeon® Platinum 8000 プロセッサ (Skylake 8175M または Cascade Lake 8259CL)


> Amazon R6i インスタンスは、第 3 世代のインテル Xeon スケーラブルプロセッサ (コードネーム: Ice Lake) を搭載しており、メモリ負荷の高いワークロードに最適です。
> 最大 3.5 GHz の第 3 世代インテル Xeon スケーラブルプロセッサ (Ice Lake 8375C)

# 実験その3

## やること

せっかく負荷テストのための実装も準備したことですし、別件で気になっていた `workers` （プロセス数）の数値を増やしていったときにスループットがどう変化するのか？も調べておこうと思います。

簡単に言うと、「ネットワークアクセスなど結果待ちの時間に、別の処理を行ってしまえば、全体で捌ける数は増えるはず！」ということです。

本番稼働しているアプリケーションでは実際に色々数値を変えて試してみたりはしているのですが、今回のような極限まで単純化したセッティングでどうなるかも見てみたいので試してみることにしました。

冒頭に書いた通り今回はマルチスレッドは考慮していませんが、高々数十個のプロセス数での話なのでオーバーヘッドの違いは気にしなくていいだろうということでworker数だけ増やしていくことにします。


## 仮説

* CPUをゴリゴリ使う処理
    * vCPU数まではworker数に比例して成功リクエスト数が増える
    * vCPU数を超えたら、無いものは無いので、それ以上は増えない
        * オーバーヘッド増加により悪化する可能性もありそう
* ネットワークをゴリゴリ使う処理
    * レスポンス待ちの間に他のリクエストを処理できるので、worker数に比例して成功リクエスト数が増える
    * 一定のworker数を超えたらオーバーヘッド増加やRAM消費量の増加により頭打ち・悪化する可能性もありそう
        * 通信に関するオーバーヘッドという観点だと、 `回数多x時間短` のものよりも、 `回数少x時間長` のほうが回数が少ないので頭打ちにはなりにくいはず

## 補足: 「プロセス」「スレッド」とは

「プロセス」と「スレッド」はなんぞやという方は次の解説を読むと雰囲気がつかめると思います。

スレッドを増やすのはメモリ使用効率は良いが考慮事項が増える、プロセスを増やすのはその逆、というところです。

プロセスを増やす場合は、メモリを共有しない分新しく作るときにたくさんデータを配置しないといけない・読み出す場合にも重複が発生するということになるので、増やすときのオーバーヘッドがスレッドを増やすときと比較して相対的に大きいという話もあります。

> プロセスはOSで実行中のプログラムのことで、プロセス内には1つ以上のスレッドが含まれます。CPUのコアに命令をしているのがスレッドです。
> 複数のプロセス同士は、同じメモリ領域を共有できません。しかし、同じプロセス内のスレッド同士は、同じメモリ領域を共有できるので、プロセス内に複数のスレッドを作成した方がメモリの利用効率が上がります。
> 複数のスレッドが同じメモリ領域を共有すると、メモリの利用効率は上がりますが、誤って別のスレッドに影響する情報を書き換えてしまう可能性もあります。いわゆるスレッドセーフではない状態が起こるということです。
> 1つのスレッドしかないプロセスを複数使用すれば、スレッドセーフかどうかを考慮する必要はなくなりますが、複数のスレッドを使う場合と比較して、メモリの利用効率は下がってしまいます。
> 
> https://tech-book.precena.co.jp/software/backend/ruby-on-rails/rails-process-and-thread
> 
> 「マルチプロセスのデメリット」の解説
> https://tech.morikatron.ai/entry/2020/03/13/100000


## 補足: よく参照されるやつ

実際の設定では下記のようなことが語られています。

（worker数96にするのは今回だけだよという意味を込めて）

> 「1サーバーにCPUの個数より多くの子プロセスを割り当てるべきではない」とよく言われます。その一部は本当ですし、出発点としては適切です。しかし実際のCPU使用率は、自分で監視したうえで最適化を行うべき値です。実際には、多くのアプリのプロセス数は、利用できるハイパースレッド数の1.25〜1.5倍に落ち着くでしょう。
> ...
> プロセス数の場合は現在の設定による測定値を定期的にチェックして適切にチェックすることをおすすめしますが、スレッド数の場合はそれとは異なり、アプリサーバーのプロセスごとのスレッド数を5に設定して「後は忘れる」でもたいてい大丈夫です。
>
> https://techracho.bpsinc.jp/hachi8833/2022_06_14/47696


https://techracho.bpsinc.jp/hachi8833/2022_06_14/47696

## 結果

結果は次のようになりました。各worker数ごとに3回ずつ計測しています。

※CPUの型番が違うものが2回出現したのでそこだけ計測し直しました。

* Hello World!を出力するだけの場合はvCPU数と同程度で頭打ちにはなりつつも、worker数を増やすとバラツキが減る傾向にあった
* CPUごりごり使う系は予想通りvCPU数で頭打ちになり、あまりにもworker数を増やすとパフォーマンスが低下しているようにも見える
    * CPUの得意分野により文字列操作・配列ハッシュ操作・数値計算で差が開くかもしれないと思ったが、そうでもなかった
* クエリ実行の傾向も予想通りworker数を増やすほどパフォーマンスが改善しているが、worker数を増やしすぎると悪影響が出ているように見える

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/46b184b9-3fb1-4b50-1fb2-0c9abf67dfed.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/2218d471-8cd5-c033-e7aa-95b752570ea0.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/c409e8ec-d4cd-8094-6df7-6281cd80f209.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31474/7ab13d33-4b0e-c6b2-a993-8b208d9467c1.png)


## ここからの学び

実際に稼働するアプリケーションはそれなりにCPUを使いつつIOも使いつつなので↑の中間のどこかに位置するかと思いますが、それぞれのアプリケーションの特性に合わせて適切なプロセス数・スレッド数を選択していくことで場合によってはかなりの性能改善ができることが確かめられたと思います。

意外だったのは、CPUゴリゴリ使う系で頭打ちになったあとの性能悪化が思ったほど見られないというところです。そこのところはPumaが？OSが？いい感じにやってくれているのでしょう。
上記で触れられている「worker数1.25-1.5倍、スレッド数5」を今回のvCPU4の場合に当てはめると41.55で、一般的なアプリケーションはDBを使う割合は半分もいかないだろうと考えるとだいぶ多めになるのかなと思いましたが、悪影響が出にくいのなら「そんなもんでいいだろう」というニュアンスなのも納得です。

実際には、

* 厳密には一リクエストあたりのレスポンス時間が悪化していないか？という観点も見る必要がありそう
* CPU使用率が常時100%では困るので、適切な水準になるよう控えめな数値に調整する
* 今回はRAM8GBでworker数96という暴挙に出ていますが、実際にはアプリケーションのRAMは1プロセスで数百MB使うので、利用できるRAM容量とも相談しつつ、スレッド数も増やしつつ

というところかなと思います。

# 終わりに

当初思っていたのとは違った結論にはなってしまいましたが（実はお蔵入りも考えた）、Fargateもどんどん進化していることがわかってよかったと思います。

必ずしも新しいCPUが割り当てられるとは限りませんが、今回見た中では9割以上は新しいもので起動したので、それなりに新しい環境の恩恵に預かれるのではないかと思います。

ただ参考記事では「CPUガチャの落差が大きくてむしろ扱いづらい」という観点もあったので、場合によっては要注意かもしれません。

明日は @taka-fujita さんが担当します。お楽しみに！