こんにちは。技術部プラットフォームグループのharukinです。
今回は、NGINXのngx_http_limit_req_moduleでの$binary_remote_addr
を使用して、IPアドレス単位のレートリミットを設定した話をします。NGINXへの理解が浅い状況から、データに基づく統計手法を用いて通常リクエストと異なるリクエストを見分ける方法を探っていく過程についてもご紹介します。
これは🎄GMOペパボエンジニア Advent Calendar 2023の15日目の記事です。
背景
私たちのサービスでは、一般ユーザーの商品購入リクエストとは異なる、機械的で短期間に集中するようなリクエストをブロックする必要があります。このような対策が不十分であると、ユーザー体験に悪影響を及ぼす可能性があります。
先日、運用しているシステムにおける一時的なリクエスト増加への対策としてNGINXでIPアドレス単位のレートリミットを設定することにしました。
やったこと
当初のNGINXの理解
ペパボに入社した後、資格取得支援制度を活用してLPIC-2を取得しました。その過程でNGINXの設定と管理、およびリバースプロキシサーバーの基本的な知識を習得しました。
NGINXの理解の進展
それからNGINXのレートリミットを実現する際の設定値の理解を進めました。
- rate : 許可されるリクエスト数
- burst : 短期間に許可される余分なリクエスト数
- nodelay : burst内のリクエストを即時処理する数
次に、rate, burst, nodelayの組み合わせを考えました。
- rateのみ、burstは未設定は、ハードリミットのような挙動
- 許可されるリクエスト数はrate以下に制限される
- 一瞬でも許可されるリクエスト数がrateを上回ることを許可しない
- rateとburstを設定は、ソフトリミットのような挙動
- 許可されるリクエスト数はrateを超過することはない
- burstに到達したリクエストは遅延が発生する
- rateとburst、nodelayを設定は、スロットリングのような挙動
- 許可されるリクエスト数はrateを一時的に超過することがある
- burstに到達したリクエストは遅延が発生せずに即座処理される
これらの3つの候補から、今回は「rateとburst、nodelayを設定」の組み合わせを採用しました。これにより、一時的なリクエストの急増に柔軟に対応しながらサービス全体へのリクエストを安定して制御できます。
適切なrateとburstの値を考える
ここまでで、レートリミットについての理解を深めてきました。特に重要なのは、適切なrateとburstの値を設定することです。機械的で短期間に集中するようなリクエストをブロックしながらも、一般ユーザーの商品購入リクエストを阻害しないように配慮する必要があります。
この課題を対処するために、社内の先輩エンジニアであるpyamaさんから標準偏差を利用してrateとburstの値をロジカルに決定する方法をアドバイスいただきました。これにより、過去のデータと統計手法を用いた効果的なアプローチができるようになりました。
標準偏差を活用してrateとburstの値を決定
標準偏差はデータの平均からのばらつきを示す指標です。この指標に基づいて考えました。
𝜇±𝜎の区間に入る確率は約68%
𝜇±2.0𝜎の区間に入る確率は約95%
𝜇±2.5𝜎の区間に入る確率は約98.76% # 許可されるリクエスト数
𝜇±3.0𝜎の区間に入る確率は約99.7% # 短期間に許可される余分なリクエスト数
具体的には、BigQueryを使用して値を求めました。
最初に𝜇±2.5𝜎の区間に入る「許可されるリクエスト数」の値を求めて、rateに設定します。
-- 直近3カ月の各IPアドレス(`remote_addr`)からの秒単位のリクエスト数を集計します
WITH
requests_per_second AS (
SELECT
remote_addr,
TIMESTAMP_TRUNC(time, SECOND) as rounded_time,
COUNT(*) as request_count
FROM
`プロジェクト.ログデータセット.アクセスログテーブル`
WHERE
-- 直近3ヶ月のデータに絞り込む
time BETWEEN TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH)) AND CURRENT_TIMESTAMP()
GROUP BY
remote_addr, rounded_time
),
-- IPアドレス(`remote_addr`)ごとに秒単位の平均リクエスト数と標準偏差を計算します
stats AS (
SELECT
-- 秒単位の平均リクエスト数
AVG(request_count) as avg_requests_per_second,
-- 秒単位の標準偏差
STDDEV(request_count) as stddev_requests_per_second
FROM
requests_per_second
WHERE
-- 秒間あたりのリクエストが10件以上のものを対象
request_count > 10
),
-- 𝜇±2.5𝜎の範囲に収まる「許可されるリクエスト数」の値を求めます
outliers AS (
SELECT
rps.remote_addr,
rps.rounded_time,
rps.request_count
FROM
requests_per_second rps,
stats
WHERE
rps.request_count > (avg_requests_per_second + (2.5 * stddev_requests_per_second))
)
-- 最終結果
SELECT *
FROM outliers
ORDER BY request_count ASC;
次に𝜇±3.0𝜎の区間に入る「短期間に許可される余分なリクエスト数」の値を求めて、burstに設定します。先に計算した「許可されるリクエスト数」の値を求めるSQLのWHERE句を、2.5の係数から3.0に変更することで導き出すことができます。
ここで使用する値は仮のものですが、NGINXの設定ファイルにrateを80、burstを95を設定します。
http {
# ...
# IPアドレスごとに10mのメモリを割り当て、rateは1秒あたり80リクエスト
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=80r/s;
server {
location / {
# burstは1秒あたり95リクエスト、nodelayを設定して即座処理される
limit_req zone=mylimit burst=95 nodelay;
# ...
}
}
}
最後にabコマンドを使用してテストを実施し、期待通りのレートリミットの結果が得られることを確認します。
どういった効果があったか
データに基づいて設定したNGINXのレートリミットが実際にもたらした効果を具体的に示すため、2023年12月1日にサービスのリクエストを処理する4台のサーバーから得たデータを取り上げます。
# サーバー1 : 6,913件のリクエストをブロック
$ sudo zcat /var/log/nginx/error.log-20231201.gz | grep 'limiting requests' | grep 'mylimit' | wc -l
6913
# サーバー2 : 4,232件のリクエストをブロック
$ sudo zcat /var/log/nginx/error.log-20231201.gz | grep 'limiting requests' | grep 'mylimit' | wc -l
4232
# サーバー3 : 5,271件のリクエストをブロック
$ sudo zcat /var/log/nginx/error.log-20231201.gz | grep 'limiting requests' | grep 'mylimit' | wc -l
5271
# サーバー4 : 4,123件のリクエストをブロック
$ sudo zcat /var/log/nginx/error.log-20231201.gz | grep 'limiting requests' | grep 'mylimit' | wc -l
4123
該当日には約20,000件のリクエストがブロックされていました。ログを分析すると、これらのリクエストはかつて問題を引き起こしていた機械的で短期間に集中するようなリクエストであることが明らかになりました。結果として、夜間を含む特定の時間帯にリクエストが集中してもレイテンシの悪化は発生せず、一般ユーザーの商品購入リクエストへの影響も軽減されました。
まとめ
今回は、NGINXのレートリミットを設定した一連の取り組みとその効果についてご紹介しました。データに基づく統計手法を用いて適切な意思決定を行う重要性についても学ぶことができました。この考え方を他のシステムやサービスにも生かしていきたいと思っています。
最後までお読みいただき、ありがとうございました!