ずっとKubernetesについて書いてきましたが、ここらへんでコンテナのリソース…… つまりアプリケーション・サーバーとしてのリソースの試算について考えてみます。別にコンテナじゃなくてインスタンスでも考え方は同じなので、タイトルからEKSとか抜いてシンプル回帰しております。
ぶっちゃけ、リソースの試算と一口に言っても、実際には色んな要素が入り混じって変化するので、色んな考え方があると思います。なので最初に明記しておくと、今回は特定の項目に着目し、面白半分、もう半分は真面目に計算したコンテンツという感じでよーそろーです。
はじめに
なぜこんな試算をする気になったかというと、コンテナの設定をする上で、1インスタンス(Node)あたりのリソースがこれくらいなら、1コンテナあたりにどのくらいのリソースを割り当てて、コンテナ内部のアプリケーション用プロセスは1プロセスあたり何メモリだったら、何接続分のプロセスを起動することになるのか、という疑問に対してある程度の明確な答えを持っておきたいと思ったからです。ほとんどの場合、負荷試験をして、接続数やサーバー台数を調整して、余裕をもって捌ければOK!って感じでいいと思うのですが、それってあんまり解答としては形になっていなくて、経験則寄りの対処でしかないです。こういう数値を入力すれば、こういう数値が解答されます!みたいなの考えてみたら楽しそうだなウフフ!! でも、もう21世紀だしどっかに例があるかもだけど、やりたいからやるの精神でGOです。
参考リンク
接続数や負荷試験について、など。目的
最大の目的は、アプリケーション・サーバーに流れてくるリクエストを受けるための接続数となるプロセス数を、いくつに設定するのがベターなのかを知ることです。それがわかれば、コンテナに割り当てる固定メモリ容量の見当がつくので、それを微調整することでNodeに対して効率的なリソース量ひいては1NodeあたりのPod数を決めることができます。
サーバーによっては、プロセスがスレッドだったり、その数が動的変化したりするかもですが、そのへんは最大で考えるなりなんなり適当に読み替えてもらえればと思います。
待機接続数とメモリ容量を算出するスクリプト
最初、メモ帳でツラツラ計算してたら、面倒くさくなってスクリプトに書いていったものなので、変数名とかクソ適当ですいませんなヤツです。とりあえず貼り付けてから、考えをまとめていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
#!/bin/env python3 import math # # config # # タイプ毎のCPU性能 # http://blog.father.gedow.net/2019/04/25/container-on-aws-part6/ instance_types = { "c5.large" : 1960, "c4.large" : 1918, "c3.large" : 1769, "m5.large" : 1769, "m4.large" : 1477, "r5.large" : 1769, "r4.large" : 1477, } bench_type = "c4.large" # ベンチマークを採った時のインスタンスタイプ bench_m_req = 200 # 1リクエストあたりの平均CPU使用量(m/req) wait_m_req = 100 # 1リクエストあたりの平均wait時間。iowait, DB接続など proc_memory = 500 # 1プロセスあたりの使用メモリ(MB) current_type = "c5.large" # 今、算出したいインスタンスタイプ(CPUの指定) current_vcpu = 2 # 実際に割り当てたいvCPU数 target_cpu = 50 # AutoscaingのCPU条件(%) allowance_rate = 1.2 # 最終的に接続数に余裕をどれくらい持たせるかのレーティング one_vcpu_m = 1000 # 1vCPUの(m) # # calculation # current_m_req = round(bench_m_req * (instance_types[bench_type] / instance_types[current_type]), 1) conn_only_cpu = current_vcpu all_time_m_req = current_m_req + wait_m_req base_vcpu_m = math.ceil(all_time_m_req / one_vcpu_m) * one_vcpu_m base_second = round(base_vcpu_m / one_vcpu_m) if base_second == 1: base_second = "" conn_all_cpu = round(conn_only_cpu * (all_time_m_req / current_m_req), 1) conn_1req_s_at_conn = round(current_vcpu * (base_vcpu_m / current_m_req) * (target_cpu / 100), 1) req_1conn_at_scale = round(conn_1req_s_at_conn / conn_all_cpu, 1) req_s_at_1vcpu = round(base_vcpu_m / all_time_m_req, 1) req_s_max = round(conn_all_cpu * req_s_at_1vcpu, 1) simple_req_s_max = round(current_vcpu * (base_vcpu_m / current_m_req), 1) req_s_autoscale = round(conn_all_cpu * req_s_at_1vcpu * (target_cpu / 100), 1) allowance_conn = math.ceil(conn_all_cpu * allowance_rate) required_memory = allowance_conn * proc_memory print("vCPU = %d" % (current_vcpu)) print("cpu + wait = %dm + %dm" % (bench_m_req, wait_m_req)) print("memory/proc = %dMB" % (proc_memory)) print("targetCPU = %d%%" % (target_cpu)) print() print("%s m\t\t# ベンチ採ったCPUとの比較で、実際に計測に使うタイプの平均CPU処理時間" % current_m_req) print() print("%s connect\t# CPU処理のみで考えた最大同時接続数" % conn_only_cpu) print("%s m/s\t# wait込みの1リクエストあたりの処理時間" % all_time_m_req) print("%s m\t\t# リクエスト処理時間に対するベースとなるCPU最大持ち時間" % base_vcpu_m) print() print("%s connect\t# CPUパワーを使い切るために必要な同時接続数" % conn_all_cpu) print("%s connect\t# スケールアウト時に1接続あたり1req/sになる接続数" % conn_1req_s_at_conn) print("%s req/conn\t# スケールアウト時の1接続あたりのリクエスト数" % req_1conn_at_scale) print() print("%s req/%ss\t# 1vCPUで処理できるベース時間あたりのリクエスト数" % (req_s_at_1vcpu, base_second)) print("%s req/%ss\t# 最大同時接続時に捌ける最大リクエスト数" % (req_s_max, base_second)) print("%s req/%ss\t# 単純なCPU性能による計算と、↑は近似値となるする" % (simple_req_s_max, base_second)) print("%s req/%ss\t# スケールアウト時にさばく秒間リクエスト数" % (req_s_autoscale, base_second)) print() print("%s connect\t# 余裕をもたせた接続数" % allowance_conn) print("%s MB\t\t# 最低限必要なメモリ容量(MB)" % required_memory) |
この入力値での実行結果の出力はこうです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
vCPU = 2 cpu + wait = 200m + 100m memory/proc = 500MB targetCPU = 50% 195.7 m # ベンチ採ったCPUとの比較で、実際に計測に使うタイプの平均CPU処理時間 2 connect # CPU処理のみで考えた最大同時接続数 295.7 m/s # wait込みの1リクエストあたりの処理時間 1000 m # リクエスト処理時間に対するベースとなるCPU最大持ち時間 3.0 connect # CPUパワーを使い切るために必要な同時接続数 5.1 connect # スケールアウト時に1接続あたり1req/sになる接続数 1.7 req/conn # スケールアウト時の1接続あたりのリクエスト数 3.4 req/s # 1vCPUで処理できるベース時間あたりのリクエスト数 10.2 req/s # 最大同時接続時に捌ける最大リクエスト数 10.2 req/s # 単純なCPU性能による計算と、↑は近似値となるする 5.1 req/s # スケールアウト時にさばく秒間リクエスト数 4 connect # 余裕をもたせた接続数 2000 MB # 最低限必要なメモリ容量(MB) |
入力値について
この計算では3つの事前調査が必要な入力値があります。別に願望で入れてみても問題ありません。処理時間 CPUとWait
アプリケーション・サーバーがリクエストを受けて、処理して返すという内容において、自身のCPU処理にかけた時間と、DBやKVSなどのバックエンドや外部APIにかかった時間の、平均値を取得しておきます。上のスクリプトの例では、1リクエストあたり CPU 200m (=0.2s) と wait 100m (=0.1s) としています。データ採取は、NewRelicなどのアプリケーションの監視サービスを利用すれば、普通に結果で見れます。別に負荷試験のように大量のリクエストを流す必要はなくて、できるだけ全部の……もしくは上位50%くらいまでのリクエストを流してチェックし、本番ぽいリクエスト割合を定義して、平均値を計算したらよいです。全体の処理時間 – バックエンド時間 とかでも CPU時間ぽいものが出せますね(厳密には他に色々あるのはわかってますが、簡潔に)。
プロセスあたりのメモリ
起動してから、何千何万と処理していくうちに膨れていくものですが、これは負荷試験として流し込んでみて、だいたい上昇が止まったと思えるくらいの数値を、プロセスのRSS列でメモリ使用量を確認します。今回は 500MB として入力しています。それ以外
は、スクリプトのコメントの通りです。CPUモデルでの比率とか、まぁ遊びみたいなものですが、なんとなく含めてみました。CPUと同時接続数の関係をイメージしてみる
基本的かつ極端な考えとして、1vCPU = 1000m/s とすると、1リクエストに1秒かかる場合、1vCPU あたり 1 req/s しか捌けないことになります。2vCPU だったら、2 req/s ですね。そのため、2vCPUで用意する最大接続数としては、やはり 2 ということになります。3 接続用意しても、2つの処理が走っていたら、どうせCPUが空いていないので受け入れられないからです。
では、なぜ人はvCPUより多くの最大接続数を用意しておくかというと、CPU処理以外の待ち時間にヒントがあると考えます。CPUパワーを使っていない間は、より多くのリクエストを受け入れる余裕があるということです。
処理の全てがCPUパワーの場合と、一部はバックエンド処理を含む場合で、CPUフル活動状態では接続数とどのような雰囲気になっているのか絵で書いてみます。
全てがセルフCPUパワー = 待ち時間がない場合
2vCPU = 2000m/s での稼働とします。1000 m/req , 2 connection , 100% requests
1000 m/req の処理を、2接続で受け入れる絵をこんな感じとします。いわゆるCPU100%で、新規接続も受け入れられません。200 m/req , 2 connection , 100% requests
平均 200 m/req を間髪を入れずに受けた場合は、10 req/s です。実際には、CPU50%前後でスケールアウトさせるので、こんなパンパンになりません。が、CPU50% ってのは適度にバラけてそうなることもあれば、この絵の前半がそのままパンパンで、後半が空になってもメトリクス的には 50% なので、一瞬のタイミングだけ切り取ればパンパンになる可能性を秘めています。なので、分散アルゴリズムには、少しは気を使いたいお年頃です。
200 m/req , 3 connection , 100% requests
200 m/req を 3接続 で受け入れても、同期間に処理できるのは2vCPU分だけなので、捌けるのは 10 req/s と変わりません。ただ、パンパンに受け入れるという条件の場合は、処理が進まなくとも空いている接続数がある分、リクエストを受け入れるという行為はできるかもしれません。接続数不足のエラーで返るか、無理に受け入れて結局タイムアウトなどのエラーになるか、偶然処理が進むか、それはわかりません。
200 m/req , 5 connection , 100% requests
同じく 200 m/req で 5接続 だとこう。10 req/s というのは変わりませんが、プロセスが休憩して受け入れる猶予が増える、という意味では単純に最大同時接続数を増やすのはメリットにも見えます。
ただ、接続猶予がなくてエラーとして返してしまうのか、無理に受け入れて待機処理が増えるのか、どちらがリソース全体として復帰しやすさという観点で健全かは、断定することは難しいかもしれません。
また、断定する必要もあまりありません。なぜなら、こうなる前にスケールアウトする前提でシステムを組むからです。しかし、もし想定外のトラフィックによってパンパンになりエラーが発生した時、どういう理由でエラーが起きているのか、起きる可能性があるのか、を理解しようとしておくことには、トラブルシューティングの力量という観点では大いなる意味と結果を呼ぶ、と信じたい。
200 m/req , 10 connection , 100% requests
10 接続だと、こう。ここまでくると、プロセスあたりの遊びが多すぎて、メモリの無駄の方が多い、と断定できそうです。しかし、それはこの平均処理時間が 200m と仮定しているからそう見えるだけで、中には 800m がいるかもしれないわけで、必ずしもそうとは言い切れないのがモニョる原因です。
とはいえ、vCPU数に対して、これ以上に接続数を増やしすぎても過剰であることは真でしょう。過剰に待機するくらいなら、適切十分な量のプロセスのメモリが想定外に膨れ上がる対策の予備としたり、ページキャッシュ分として余しておいたほうが効果的ということです。
バックエンドも利用する = 待ち時間がある場合
ここまでは異世界の話で、こっから現実に帰ってきます。AppのCPU処理が 200m/req のままで、他のサーバーやサービスにリクエストを送ってレスポンスが返ってくるのを待つ時間を 100m/req ~とし、2vCPU のまま各接続数でどのようになるか、です。CPU 200 m/req , wait 100 m/req , 2 connection , 100% requests
waitがない場合は、パンパンに 10 req/s 処理していましたが、待ち時間が入ったことで 6~7 req/s しか処理できなくなってしまいました。CPUパワー的には、2000m あるものの 1400m までしか利用できないことになります。CPU使用率でいえば、せいぜい 70% が限界です。どれだけ大量のトラフィックが流れ込もうとも、70% までしかチカラを出しきれないサーバー・リソースになるのです。
スケールアウト条件が 50% だとすると、5 req/s がそれに値するのですが、そもそも 7 req/s しか捌けないリソースに対して、5 req/s が条件となると、それは果たして 50% と言えるのか……?トンチが始まってしまいそうな時点でダメそうなのは確定的です。
CPU 200 m/req , wait 100 m/req , 3 connection , 100% requests
では、接続数を増やすとどうなるか。3接続あれば、ほぼ 10 req/s の性能は発揮できそうです。ただ、リクエストとリクエストの間に猶予がないのは変わらないので、タイミング次第では接続エラーとなるリスクは含んでいます。
とはいえ、2接続で消化不良にさせるよりは、リソースを使い切れるこの状態の方が健康的といえそうです。
CPU 200 m/req , wait 100 m/req , 5 connection , 100% requests
もっと接続を増やすと、待ち時間が無い場合とあまり変わらない絵になりました。こうなると、wait時間と接続数の関係がどのようなものか、wait時間を増やしてみると何かがわかるかもしれません。
CPU 200 m/req , wait 200 m/req , 2 connection , 100% requests
待ち時間を 200 m/req に増やしてみました。CPU : wait = 1 : 1 になったことで、CPUパワー的には 10 req/s 捌けるはずが、5 req/s しか捌けません。
CPU 200 m/req , wait 200 m/req , 3 connection , 100% requests
それではよくないので接続数を増やしてみるも、7 req/s 程度しか捌けず、CPUパワーも 30% ほど余っています。封印されし古の力を解き放つには・・・?
CPU 200 m/req , wait 200 m/req , 5 connection , 100% requests
5接続あれば、ほぼちょうどCPUパワーをフルに使った 10 req/s をさばくことができそうになりました。1接続内でのリクエスト間にも隙間ができているので、最大パワー発揮可能&接続エラーリスク低し、となれば机上の理論としてはまぁまぁ良いところをついているのではないでしょうか。
待機時間と接続数の関係
ここまでの流れでわかったことは、ということです。
具体的な調整としては、まずリソースを使い切るための接続数として
リソースをフルに使える接続数 = vCPU数 * (全体処理時間/req / CPU処理時間/req)
であり、さらに接続の受け入れ猶予や、処理時間の平均から上下にブレる程度を考慮して、それにさらに 1.5 ~ 2.0 くらいを掛けた数字が、実際に待機させるほどよい最大接続数、ということになります(なりそうです)。
どんだけ余裕持たせればいいねんとか、平均からの正確なブレなんてわからんとか、アプリケーション・サーバーにも一応ディスクとメモリの iowait があるやんけ、とか色々握りつぶして大きなポイントに注目した考察がこれ、という感じです。
スケールアウト 50% のリクエスト状態
不測の急増トラフィックにおいて、リソースを使い切りつつ接続も適度に捌くには、という視点で絵を書いてきましたが、実戦ではたいていの場合 Autoscaling が発動してスケールアウトするので、そこまでは到達しません。あくまでも、用意したリソースは使いきれるようにしとかなアカン、というのがここまでの流れです。仮にスケールアウト条件を、平均CPU使用率50% とします。実際に 50% に到達し、監視メトリックが更新され、スケーリング・システムが増加処理を実行し、リソースが追加され利用可能になる、までの流れがあるので、実際には 50% ではなく 55~60% になった時点でようやくスケールアウトしたと言ってよいかもしれません。
と、まぁそういう細かいことは置いといて、さきほどの条件の続きで、リクエスト数が半分の50%になった時の絵がこんな風になります。
CPU 200 m/req , wait 200 m/req , 3 connection , 50% requests
リクエスト数が減ったことで、パンパンに詰まっていたのが解消されました。ここから少し待てば増設されて、また少しリクエスト数が減って楽になる、の繰り返しです。ここだけ見れば健全ですが、リクエスト数が増えていくと結局70%あたりで頭打ちになるのは先程と同じなので、もう少し接続に余裕をもたせてみます。
CPU 200 m/req , wait 200 m/req , 5 connection , 50% requests
5接続だと、CPU 50% 時点で、1接続あたり 1req/s を担当するという、なんとも平穏な雰囲気になりました。これは 1リクエストあたり平均400m ですが、一部のリクエストにDB処理が遅くて 600m や 800m がいても、1接続あたり 1req/s という調整結果には影響がないことになります。ここから徐々に60, 80, 100% とリクエスト数が増えたとしても、接続数に余裕を持ちつつCPUリソースをフルに使い切れるのは、先程確認した通りです。
この、かなり健全に見える調整は、targetCPU% を軸に立ててみると面白そうです。
vCPUとtargetCPUから試算する接続数
ということで、↑の健全ぽい状態を表す接続数を式にしてみました。1接続あたり1req/sになる接続数 = vCPU * (1000m / CPU/req) * targetCPU%
2vCPU, CPU 200m/req, targetCPU 50% で 5接続となります。targetCPU 80% なら 8接続 おいておけば 1接続 1req/s を保てますし、targetCPU 20% なら 2接続 しかいりません。
しかし、この式には問題点があります。targetCPU% を小さくしたり、waitが長いと、リソースを使い切れなくなる接続数になるという点です。また、CPU処理時間が小さくなりすぎると、逆に多すぎる接続数になりメモリの消費量に無駄が多くなってしまいます。
なので、この数字は決めた接続数と比較してみてみると、スケールアウト時のリクエスト受付状態をおよそ想像できて楽しいネ☆くらいのものになりそうです。
接続数と使用メモリ量の調整
基本方針
基本は下記2点を重視するという前提で──1つ目を満たすためにこの式で最低限の接続数を満たし、
リソースをフルに使える接続数 = vCPU数 * (全体処理時間/req / CPU処理時間/req)
あとは、どれくらいの余裕をもたせるかという話になります。余裕をもたせるといっても、それは多少の偏りが起きても大丈夫とか、CPU 100% 近くなっても接続猶予がある、という意味なので、そうなる前にスケールアウトするという前提があるため、あまり余裕をもたせてもメモリの無駄遣いになってしまいます。
余裕をもたせつつ──
メモリの無駄になりすぎず──
一般的な経験則とも辻褄が合う──
というなんとも非理論的な最終結論として、最後に 1.2 をかけている次第です(笑)
最初のスクリプト例でいえば、2vCPU にて 1接続あたり 500MB で、もろもろ条件を入力したら、4接続 2000MB以上 で設定しましょうということになります。色々入力値を変えてみても、まぁそれなりの数字になります。
インスタンスタイプに合わせる
それなりの数字が出た上で、さらに考えることがあります。インスタンスタイプのリソース量をどうするか、です。コンテナで言えば Node であるし、さらに 1Pod あたりのリソース量も決める必要があります。AWSでいえば、代表的なインスタンスタイプ・カテゴリにC, M, Rがあって、大雑把にこんな雰囲気になっています。
CPU | メモリ | |
C系 | 強い | 少ない |
M系 | 普通 | 普通 |
R系 | 普通 | 多い |
プロセスあたりのメモリ量が少なければ C系 が Good になりますし、多ければ十分な接続数を確保するために CPU性能を落としてでも M, R系 にする必要がある。かもしれませんし、処理速度を優先して C系 にするのもまた判断としては正解かもしれません。
コンテナの場合は、さらに Pod リソースに分割されるため、できるだけ (Nodeリソース / Pod台数) に近いリソース調整にした方が、余すことなく有意義に使い切れるという面があります。
さらに混乱したければ、Spot Fleet でタイプを混在させたときにどうなるか、も考えることになるでしょうし、
そもそもアプリケーションのメモリもっと小さくなんねーの?みたいな話に及ぶかもしれませんし、
より安定した仕組みのミドルウェアを探す旅に出るハメになるかもしれません。
考えていけば、より多くの項目が出てくるでしょう。
本番に勝る試算はないし、その試算もやりきろうとする暇があるなら、実際に負荷試験をぶっ放して調整した方が早い、というのがまぁ実情ですね。
今回のように面白半分や経験則による試算をしつつ、
負荷試験をもって改善しつつ余裕を持ったリソース量を用意し、
本番リリース後に負荷状況やサービス価値に合わせて少しずつ適切なリソース量に縮小していく、
というのが、インフラ業の基本であり奥義みたいな、念能力とまではいかないまでも、まぁ職人芸の一種かもしれません。
さらにサービスは数日単位で更新されるし、インフラも数ヶ月単位でアップデートされていくし、サービスは解のない生き物ですな!
丹精込めて可愛がっていきましょう:-)