Python学習14日目です!
Webサービスのセキュリティ対策の一つである、レートリミットの方法について学んでいきます。
レートリミットとは?
レートリミットは、特定のIPアドレスやユーザーから、時間あたりに受け付けるリクエストの数を制限する仕組みです。
これにより、Webサービスを以下のような問題から守ることができます。
- ブルートフォース攻撃の緩和: ログイン試行などを短時間に大量に行う攻撃を防ぎます。
- サーバー負荷の安定化: 特定のユーザーやボット、クローラーなどによる過剰なアクセスからサーバーを守ります。
- APIの公平な利用: APIの利用回数を制限することで、一部のユーザがリソースを独占すること防ぎ、全ユーザーに安定したサービスを提供します。
レートリミットの方法:NginxとFlask-Limiterの使い分け
Gemini先生にレートリミットを行う方法を質問してみると、今回の環境であるPython + Flask + Gunicorn + Nginxという環境では、Flask-Limiterというライブラリを使う方法と、Nginxの機能を使用する方法があると教えてくれました。
Flask-Limiterによるレートリミット
Flask-Limiterは、Flaskアプリケーションの内部で、Pythonコードによってきめ細やかなレートリミットを実現するライブラリです。
メリット
- 高い柔軟性: 「ユーザーIDごと」「APIキーごと」など、アプリケーションの内部情報に応じたきめ細かな制限をかけることができます。
- 設定が容易: Pythonのコード内で完結するため、インフラの知識が少なくても導入しやすいです。
デメリット
- パフォーマンス: リクエストを一度アプリケーションで受け取ってから処理するため、Nginxに比べて若干のオーバーヘッドがあります。
- 保護範囲: アプリケーションが処理しきれないほどの大量リクエスト(DDos攻撃など)からサーバー全体を守るのには不向きです。
Nginxによるレートリミット
Nginxで行うレートリミットは、リクエストがWebサービス(Flask)に到達する前に、Webサーバーの段階で制限をかける方法です。
メリット
- 高性能:アプリケーションに負荷をかける前にリクエストを弾くため、非常に高速で効率的です。
- 包括的な保護: サイト全体や特定のURLパスをまとめて保護できます。
デメリット
- 柔軟性に欠ける: 基本的にIPアドレス単位での制限となり、「ユーザーIDごと」や「APIキーごと」といったアプリケーションの内部情報に基づいた複雑な制御は困難です。
最強の組み合わせ:両方を活用してサービスを守る
Flask-LimiterとNginxでは、それぞれ異なる役割を持つため、両方を組み合わせて使うことが理想的になります。
- 第一防衛線: Nginx
- 精密な制御: Flask-Limiter
Nginxで大まかで広範囲なレートリミットを設定します。(例: 1つのIPアドレスあたり1秒に10リクエストまで等)
これにより、単純で大規模なブルートフォース攻撃やDDoS攻撃のアクセスを、Flaskへ到達する前に弾き、サーバー全体を保護します。
Nginxを通過してきたリクエストに対し、アプリケーション内部でより文脈に沿った細かい制限をかけます。(例:ログイン機能は1分間に3回まで等)
このように、Nginxでサーバ全体の安全を守り、Flask-Limiterで詳細な制御を行うというように、役割分担をさせることが最も理想的な構成になります。
Flask-Limiterによるレートリミットの実装方法
Flask-Limiterを導入し、Pythonのコードでレートリミットを実装する方法を学んでいきます。
ライブラリのインストール
Pythonoの仮想環境内でFlask-Limiterのインストールを行います。
(html_converter) $ uv pip install Flask-Limiter
app.pyの編集
app.pyを修正して、Flask-Limiterの機能を組み込みます。
モジュールのインポート
app.pyの冒頭に、Flask-Limiter関連のモジュールをインポートします。
# 必要なモジュールをインポート import html import os from flask import Flask, render_template, request from flask_limiter import Limiter # 追加 from flask_limiter.util import get_remote_address # 追加 from flask_wtf import FlaskForm from wtforms import TextAreaField, SubmitField from wtforms.validators import DataRequired, Length from dotenv import load_dotenv
from flask_limiter import Limiter
Flask-Limiter本体であるLimiterクラスをインポートします。
from flask_limiter.util import get_remote_address
リクエスト元のIPアドレスを取得するためのget_remote_address関数をインポートします。
IPアドレスごとにリクエストをカウントするために使用します。
レートリミット(Limiter)の初期化
Limiterのインスタンスを作成し、Flaskアプリケーションに紐付けます。
limiter = Limiter( get_remote_address, app=app, default_limits=["200 per day", "50 per hour"], storage_uri="memory://" )
get_remote_address
get_remote_addressは、Flask-Limiterライブラリが提供する関数で、リクエストを送信してきたクライアントのIPアドレスを取得する機能を持っています。
Limiterは、取得したIPアドレスごとにリクエスト回数をカウントします。
app=app
作成したFlaskアプリのインスタンス(app)をLimiterに渡すことで、レートリミットをサービス全体で有効にします。
- 左辺のapp: Limiterクラスが受け取る引数の名前で、「ここにFlaskアプリの本体を入れてください」という意味です。
- 右辺のapp: app = Flask(__name__)のようにして作成した、Flaskアプリケーションのインスタンスです。
default_limits=["200 per day", "50 per hour"]
サービス全体にかけるデフォルトのレートリミット制限を設定します。
上記の場合、1日200回、1時間に50回までという制限になり、特定のルートに制限をかけ忘れても、最低限の保護が働きます。
レートリミットのルールは以下の書式の文字列で設定します。
"[回数] per [単位時間]" または "[回数]/[単位時間]"
指定できる時間単位は以下の通りです。
単位 | 別名 |
---|---|
second | s, sec, second, seconds |
minute | m, min, minute, minutes |
hour | h, hr, hour, hours |
day | d, day, days |
month | month, months |
year | y, year, years |
storage_uri="memory://"
カウント情報をどこに保存するかを指定します。
開発・テスト用であれば、サーバーを再起動するとリセットされる"memory://"で十分です。
本番環境では、Redisなどのデータベースを使うのが一般的です。
レートリミットの設定
レートリミットをかけたいルート(index関数)の直前に、@limiter.limit()デコレーターを追加します。
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("15 per minute") # 追加このルートは1分あたり15回まで
# ---------------------------------------------
def index():
form = HtmlEscapeForm()
escaped_html = None
### 以下省略 ###
@limiter.limit("15 per minute")
limiterインスタンスが提供する、レートリミット用のデコレーターです。
ここでは、"15 per minute"で「1分あたり15回まで」という制限を指定しています。
16回目のリクエストが来てしまった場合、limiterはそのリクエストをブロックし、index()関数は実行されません。
代わりに、クライアントには「429 Too Many Requests」というエラーが自動的に返されます。
設定反映と動作確認:レートリミットが機能するか試してみよう
app.pyにFlask-Limiterの設定を追記したら、Gunicornサービスを再起動して設定を反映させます。
設定の反映
systemdでGunicornを管理しているため、以下のコマンドでサービスを再起動します。
$ sudo systemctl restart html_converter.service
動作確認
Gunicornの再起動が完了したら、Webブラウザでサービスにアクセスし、レートリミットが正しく機能しているかを確認してみましょう。
- サービスの通常動作を確認
- レートリミットが発動するか確認
これまで通りHTMLコードの変換が問題なく行えることを確認します。
1分間に16回変換を行って、「Too Many Requests」が表示されることを確認します。
このエラーメッセージが表示されれば、Flask-Limiterが正しく機能しています
Nginxによるレートリミットの実装方法
Nginxの機能を使って、リクエストがFlaskへ到達する前にレートリミットをかける方法を学んでいきます。
共有メモリゾーンの定義
Nginxは、リクエスト数をカウントするために共有メモリゾーンという領域を使います。
まずは、その共有メモリゾーンを定義する設定ファイルを作成します。
$ sudo vi /etc/nginx/conf.d/limit_req_zone.conf
設定内容は以下のとおりです。
limit_req_zone $binary_remote_addr zone=html_converter:10m rate=10r/m;
各設定項目の意味は以下のとおりです。
- limit_req_zone: レートリミットのための共有メモリゾーンを定義するNginxのディレクティブです。
- $binary_remote_addr: リクエスト元のクライアントのIPアドレスをキーとする設定です。
- zone=html_converter:10m: html_converterという名前で10MBのメモリ領域を確保します。
この領域にIPアドレスごとのリクエスト情報が記録されます。 - rate=10r/m: 1分あたり10リクエスト(10 requests/minute)に制限します。
1r/sなら1秒あたり1リクエストになります。
制限の適用
バーチャルホスト設定ファイル(html_converter.conf)を編集し、上記で設定した共有メモリゾーンを使って、レートリミットを適用します。
$ cd /etc/nginx/conf.d $ sudo cp -p html_converter.conf html_converter.conf_$(date "+%Y%m%d_%H%M%S") $ sudo vi html_converter.conf
location /ブロックに以下の設定を追加します。
location / {
include proxy_params;
limit_req zone=html_converter burst=5 nodelay; # 追加
proxy_pass http://app_server;
}
- limit_req: 定義したlimit_req_zoneの制限を適用するNginxのディレクティブです。
- zone html_converter: 先ほど定義したhtml_converterという名前の共有メモリゾーンを使用します。
- burst=5:共有メモリゾーンで設定したレート(10r/m)を超えても、最大5リクエストまでは即座にエラーとしないで一時的にリクエストを受け付けます。
- nodelay:burstで受け付けたリクエストを待たせることなく即座に処理します。
これがない場合、リクエストはバッファリングされ、レート制限に合わせてゆっくり処理されます。
設定の反映
Nginxの設定ファイルにレートリミットを追加したら、設定を反映させて実際に動くか確認しましょう。
nginx -tで設定ファイルの文法チェックを行ってから、Nginxを再起動します。
$ sudo nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful $ sudo systemctl restart nginx
動作確認
実際にWebブラウザでHTML変換サービスにアクセスして、変換作業を短時間で続けて行いレートリミットが正しく機能しているか確認してみましょう。
レート制限に該当すると、ブラウザ上に「503 Service Temporarily Unavailable」というエラーメッセージがブラウザ上に表示されます。
Nginxのエラーログには、以下のようなログが記録されます。
2025/08/20 14:12:21 [error] 12773#12773: *1 limiting requests, excess: 5.262 by zone "html_converter" 以下省略
このログメッセージは、Nginxが設定されたレート制限を超えたリクエストをブロックしたことを示しています。
このエラーが確認できれば、Nginxによるレートリミットが正しく機能していることになります。
レートリミットの制限以下でもエラーが表示される理由
短時間で複数回HTML変換サービスを使用すると、共有メモリゾーンで設定した回数以下でもレートリミットの制限に引っかってしまうことがあります。
この現象は、Nginxのレートリミットが単純な合計回数ではなく、「平均レート」を制御するものであり、リクエストが短時間に集中すると、合計回数が少なくても制限に達してしまうためです。
例えば、rate=10r/mと設定した場合は、1分間に10回までOKという訳ではなく、平均して6秒に1回(60秒÷10回)までを許可するという設定になります。
また、今回は「html_converter.conf」で「limit_req zone=html_converter burst=5 nodelay;」という設定も行っているため、「burst=5 nodelay」で追加5リクエストまでは即時に処理されます。
ですので、6秒間に1+5=6リクエストまでは処理されて、7リクエスト目でレートリミットの許容範囲を超えて、エラーとなってしまいます。
エラーメッセージを「429 Too Many Requests」に変更する方法
レートリミットに達した際、Nginxがデフォルトで返すエラーメッセージは「503 Service Temporarily Unavailable」です。
これはサービスの一時的な不具合と誤解されやすく、ユーザーにとって分かりにくいことがあります。
そこで、より正確に「リクエストが多すぎる」ことを伝えるために、エラーメッセージを「429 Too Many Requests」に変更することもできます。
変更方法は簡単で、「limit_req_status 429;」という設定を以下の場所に追加するだけです。
location / {
include proxy_params;
limit_req zone=html_converter burst=5 nodelay;
limit_req_status 429; # 追加
proxy_pass http://app_server;
}
設定の反映と動作確認
設定ファイルを編集したら、Nginxを再起動して変更を反映させます。
$ sudo nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful $ sudo systemctl restart nginx
動作確認
Webブラウザでサービスにアクセスし、短時間の間に続けて変換を行って、レートリミットの制限を超えた場合のエラーメッセージが「429 Too Many Requests」になっていることを確認してください。
この設定を行うことで、ユーザーは「なぜサービスが使えないのか」を正確に理解でき、より親切なサービスになります。
本日のまとめ:レートリミットでサービスを保護!
今回はFlask-LimiterとNginxによるレートリミットの実装方法を学びました。
どちらも驚くほど簡単に実装でき、Webサービスを不正な大量アクセスから守り、安定稼働を保つために非常に有効な手段だと感じました。
「どれくらいのリミットを設定すればいいか?」という悩みは尽きませんが、これはサービスの利用状況を観察しながら、ユーザー体験を損なわないように調整していくのが理想的でしょう。
Python + Flask環境でのセキュリティ対策を総括
これで、3回にわたるPython + Flask環境でのセキュリティ対策の学習が完了しました。
- 入力検証:WTFormsとFlask-WTFを使用
- CSRF (Cross-Site Request Forgery) 対策:WTFormsとFlask-WTFを使用
- レートリミット:Flask-LimiterとNginxを使用
どれもWebサービスにとって不可欠なセキュリティ対策ですが、幸いなことにPythonには便利なライブラリが豊富に揃っており導入も簡単です。
今後は、どんなWebサービスを作成する場合でも、今回学んだこれらのセキュリティ対策を必ず実装していこうと思います。
この知識が、より安全で信頼性の高いサービスを作るための土台となるはずです。
最新のapp.py
# 必要なモジュールをインポート
import html
import os
from flask import Flask, render_template, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from dotenv import load_dotenv
# .envファイルから環境変数をロード
load_dotenv()
# Flaskアプリケーションのインスタンスを作成
app = Flask(__name__)
# 環境変数からSECRET_KEYの値を取得
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY')
# Limiterを初期化し、アプリに適用
limiter = Limiter(
get_remote_address,
app=app,
default_limits=["200 per day", "50 per hour"],
storage_uri="memory://"
)
# ---------------------------------------------
# HTMLエスケープフォームの定義
class HtmlEscapeForm(FlaskForm):
html_code = TextAreaField(
'変換したいHTMLコードを入力してください(最大10,000文字):',
validators=[
DataRequired(message="HTMLコードは必須です。"),
Length(max=10000, message="入力されたHTMLコードが長すぎます(最大10,000文字)。")
]
)
submit = SubmitField('変換')
# '/' (ルートURL) にアクセスがあった場合の処理を定義
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("15 per minute")
def index():
form = HtmlEscapeForm()
escaped_html = None
if form.validate_on_submit():
original_html_input = form.html_code.data
decoded_html = html.unescape(original_html_input)
escaped_html = html.escape(decoded_html)
return render_template('index.html', form=form, escaped_html=escaped_html)
コメント