はじめに
GMOコネクトの永田です。
ある案件で数万rps(requests per sec)の性能試験をする機会があり、Locust FastHttpUserを使って実現しました。
数万rpsまでいくと負荷をかける側(Locust worker側)もかなりの負荷になり、実現時にいくつかポイントがありましたので、それをまとめます。
まとめ
- (1) Locustでよく使われるHttpUserとは別に、性能が良いFastHttpUserがある
- HttpUserとFastHttpUserでは、CookieのSet/Getの仕方が違う
- (2) Connection poolをCloseしないとSessionが同じサーバーインスタンスに着弾する
- (3) Multi Region時、特定Regionにのみ着弾する
- (4) Fargate Spotの枯渇
- (5) Locust LoadTestShapeで、ユーザー数の増減シナリオを柔軟に定義できる
(1) Locust FastHttpUserの利用
数万rpsまでいくと負荷をかける側(Locust worker側)もかなりの負荷になり、Locust workerのシナリオの最適化が必要になります。
LocustでHTTP Requestに対して負荷をかける場合、一般的には「HttpUser」を使うかと思いますが、これは高機能な反面、性能はあまりよくありません。
そのため、Locustには「FastHttpUser」と呼ばれるclassも準備されており、公式ドキュメント上は5倍〜6倍速いとあります。実際に使ってみたところ、シナリオにもよるのですが4倍ぐらいは早くなっていました。
sometimes increasing the maximum number of requests per second on a given hardware by as much as 5x-6x.
FastHttpUserでのCookieの利用
FastHttpUserはHttpUserとほぼ同じAPIを提供しているのですが、公式ページにも記載があるように、1点苦労したのがCookieのSet/Getです。
FastHttpUser/geventhttpclient is very similar to HttpUser/python-requests, but sometimes there are subtle differences. This is particularly true if you work with the client library’s internals, e.g. when manually managing cookies.
HttpUserであれば次のようにCookieのSet/Getができます。
# SetCookie in requests
with self.client.get(
"/api/hogehoge",
name="/api/hogehoge",
params={
"a": "aaa",
},
cookies={"cookie_name": "cookie_value"}, # ここでSet-Cookie可能
) as response:
# Get Cookie from response
cookies = response.cookies
cookie_xxx = cookies.get("cookie_name")
ところがFastHttpUserだと上記ではSet/Getともに同じ手段では出来なく、公式ドキュメントにも実現方式が全くのっていませんでした。
Github Issue( https://github.com/locustio/locust/issues/861 )に、cookiejar
を使っているよ!という、ありがたいヒントがあったので、それを元にソースを解析することにしました。
FastHttpUserのソースから分かったのは、Locust Taskの中で self.client.cookiejar
とすればCookieJar自体に直接アクセスが可能そう、という情報のみでした。
Python Cookiejarの解析
次に、Pythonの公式ドキュメントからcookiejar自体の使い方を調べました。
set_xxx
を見ていたところ以下のAPIが使えそうでした。
CookieJar.set_cookie(cookie)
与えられた Cookie を、それが設定されるべきかどうかのポリシーのチェックを行わずに設定します。
そのため、Cookie自体の生成方法を調べましたが、期待していたような説明はありませんでした😭
http.cookiejar のユーザが自分で Cookie インスタンスを作成することは想定されていません。かわりに、必要に応じて CookieJar インスタンスの make_cookies() を呼ぶことになっています。
「いや、requests前にSet-Cookieしたいんだけど」ってことで仕方ないのでソースを解析することにします。
cookiejarの初期化はここですが、parameterの説明がありません。
雰囲気で大体の意味は分かりますが、いくつか不明なところがあったので、実際にサーバーでSet-Cookieされた結果のCookieに対し __dict__
で中身を確認した結果、以下のようなコードでrequest前にSet-Cookieすることができました。(公式な手順ではないため将来的に使える保証はありません)
def on_start(self):
self.cookie_domain = '.test.example.com' # Locustシナリオ対象のHost
self.cookie_name = 'cookie_name'
@task
def test_xxx(self):
# see: https://github.com/python/cpython/blob/main/Lib/http/cookiejar.py#L762
# Cookie(version, name, value,
# port, port_specified,
# domain, domain_specified, domain_initial_dot, path, path_specified,
# secure, expires, discard,
# comment, comment_url, rest, rfc2109)
c = Cookie(0, self.cookie_name, "TODO:Cookie_Value",
None, False,
self.cookie_domain, True, False, '/', True,
True, 1765242244, False,
None, None, {'SameSite': 'None'}, False)
# see: https://github.com/locustio/locust/blob/f02363a1750faec566aad58b605a37678203f966/locust/contrib/fasthttp.py#L85
self.client.cookiejar.set_cookie(c)
with self.client.get(
...
(2) Connection poolをCloseしないとSessionが同じサーバーインスタンスに着弾する
FastHttpUserで快適に負荷試験ができるね!と試していたところ、想定外の挙動が発生しました。
負荷対象のシステムをAWS NLB + ECS on Fargate(golang)のAutoScalingで組んでいたのですが、AutoScalingで台数が増えても最初からあるECS taskにrequestが集中し、負荷分散ができていませんでした。
色々と確認したところ、LocustでHTTP SessionのConnection Poolを実施しているらしく、後でECS taskが増えてもConnection PoolでPoolされたSessionを使い回すことにより、最初からあるECS taskにrequestが集中してしまっていたようでした。
存続期間の長い TCP 接続を確認する。(中略)これは、たとえば、複数の HTTP リクエストを送受信するために頻繁に接続を再利用しているクライアントが、インスタンスレベルで不均衡なトラフィックを生成する可能性があることを意味します。
By default, a User will reuse the same TCP/HTTP connection (unless it breaks somehow). To more realistically simulate new browsers connecting to your application this connection can be manually closed.
ただ、毎回pool closeをするとLocust workerの負荷が非常に高くなってしまい、負荷をかけられる側(golang)のtask数の100倍ぐらい(この案件では3,000vCPU😇)ぐらい必要な見込みでした。
そのため妥協して、10回に1回程度、Closeすることにしました。(それでも負荷かけられる側の10倍のvCPUが必要でしたが。。。)
## see: https://docs.locust.io/en/stable/increase-performance.html#connection-handling
# if random mod 10, close pool
if random.randint(0, 9) == 0:
self.client.client.clientpool.close()
(3) Multi Region時、特定Regionにのみ着弾する
上記までで、これで快適に負荷試験ができるね!(2回目ぐらい)と、Locust worker=1台で試していたところ、片方のRegionにのみrequestが集中するという事象が発生しました。
今回のシナリオでは、AWS Tokyo/OsakaにそれぞれNLB+ECSを設置し、Route53 加重ルーティングでtokyo NLB50%: osaka NLB50%の分散としていました。
しかし、片方のRegionにのみrequestが偏っており、期待していたシナリオにはなっていませんでした。
10分程度動かしていたところ、2分ぐらいでRegionが切り替わったり切り替わらなかったりという挙動をしており、Locust worker側があやしいな、と調査しました。
似たような課題はstack overflowにもあり、DNS Client cacheが怪しそうだなとは思いましたが、対応策が面倒そうだったので、Locust worker(Fargate Spot)の数を増やすことで雑に回避しました😇
(仮に4台なら片方リージョンのみに偏る可能性は、1/16の確率)
(4) Fargate Spotの枯渇
これで快適に負荷試験ができるね!(3回目ぐらい)、と最大負荷を試そうとしたところ、Tokyo Regionで動かしていたLocust workerのFarget Spot(desiredCount=80)の起動に失敗しました。
あれ?とログを見たところ、以下のログがでていました。
service xxx-locust-worker was unable to place a task. Reason: Capacity is unavailable at this time. Please try again later or in a different availability zone. For more information, see the Troubleshooting section of the Amazon ECS Developer Guide.
80vCPUぐらいでも時間帯によってはキャパシティないのねーと、この時は時間帯をずらして問題を回避しました。
なので、多くのvCPUで負荷試験をするぞ!って人は、Regionや時間帯を調整したり、Spotではない普通のFargateを使うなどの検討が必要そうです。
(5) Locust LoadTestShapeで、ユーザー数の増減シナリオを柔軟に定義できる
これは問題になったというか、チーム内での認知度がなかったのでメモとして残しておきます。
LocustはパラメータやUI上の設定により、最大User数や1秒毎の増加User数(spawn_rate)を指定できます。
単純なシナリオであればこれで問題ありませんが、指数関数的にUserが増加するパターンなどそのままでは表現できないシナリオもあるかと思います。
Locustではこのようなケース向けにCustom Load Shapesの機能を提供しています。
既存のLocustシナリオ(FastHttpUserでも良い)に、以下のような定義を加えるだけです。
class StagesShapeWithCustomUsers(LoadTestShape):
stages = [
{"duration": 60, "users": 50, "spawn_rate": 1},
{"duration": 120, "users": 150, "spawn_rate": 2},
{"duration": 180, "users": 450, "spawn_rate": 5},
{"duration": 240, "users": 1250, "spawn_rate": 14},
{"duration": 420, "users": 1250, "spawn_rate": 1}
]
def tick(self):
run_time = self.get_run_time()
for stage in self.stages:
if run_time < stage["duration"]:
tick_data = (stage["users"], stage["spawn_rate"])
return tick_data
return None
注意点として、spawn_rateを0にしたい(User数を増やさない)場合でも、spawn_rateを1以上にしないとエラーになります。span_rateを1にしてもusersで指定した数以上は増えないため、実質的にspawn_rate=0を実現できます。
(再掲)まとめ
- (1) Locustでよく使われるHttpUserとは別に、性能が良いFastHttpUserがある
- HttpUserとFastHttpUserでは、CookieのSet/Getの仕方が違う
- (2) Connection poolをCloseしないとSessionが同じサーバーインスタンスに着弾する
- (3) Multi Region時、特定Regionにのみ着弾する
- (4) Fargate Spotの枯渇
- (5) Locust LoadTestShapeで、ユーザー数の増減シナリオを柔軟に定義できる
弊社では、AWSを使ったサービスの開発や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。