メインコンテンツまでスキップ

Istio-レートリミット設定

· 約14分

本番の API が一度攻撃で叩かれたのを機に、レートリミットをアプリケーション層からゲートウェイ層へ移しました。この記事では、Istio IngressGateway 上で Envoy RateLimit + Redis によるグローバルレートリミットを実現する完全な設定と、ハマりやすいいくつかのマッチングの細部を整理します。

背景と課題

マイクロサービスアーキテクチャでは、システムは通常 Istio IngressGateway を通じて統一的な入口を外部に提供します。トラフィックが急増したとき(イベント時のトラフィック、クローラー、API が叩かれる)、レートリミットの仕組みがないと、バックエンドで次のような問題が起こりえます。

  • API が高頻度で呼び出され、サービスの CPU やデータベースの負荷が過大になる
  • 突発的なトラフィックがシステムの雪崩や連鎖障害を引き起こす
  • コアな API が悪意を持って叩かれ、正常なユーザーのアクセスを圧迫する

各サービスの中でそれぞれレートリミットを実装するのももちろん可能ですが、ルールが分散し、基準がバラバラになりますし、しかもリクエストはすでに業務プロセスまで到達しているため、リソースはやはり消費されてしまいます。より合理的なやり方は、ゲートウェイ層で統一的にレートリミットをかけることです。リクエストがバックエンドに入る前にトラフィックガバナンスを完了させます。

Istio のデータプレーンはまさに Envoy Proxy であり、生来この能力を備えています。Envoy RateLimit フィルター + 独立した ratelimit サービス + Redis を通じて、ゲートウェイのレプリカをまたぐグローバルなレートリミットを実現でき、特定パスに対してアクセス頻度の制御を行えます。

原理の概説

このパイプライン全体には三つの役割があります。それらの関係を理解すれば、後の YAML は難しくありません。

1)Envoy RateLimit フィルター。IngressGateway の HTTP フィルターチェーンに掛かっており、各リクエストが通過する際、ルート上に設定された actions に従って一組の descriptor(ディスクリプタ)を生成し、domain とともに gRPC で ratelimit サービスに問い合わせます——このリクエストを通すか否か、を。

2)ratelimit サービス(envoyproxy/ratelimit)。独立してデプロイされた gRPC サービスで、ConfigMap 内のクォータルールを読み込み、ディスクリプタを受け取るとルールを照会してクォータを消費し、OK または OVER_LIMIT を返します。

3)Redis。クォータのカウンターのストレージです。カウントが Redis にあるからこそ、複数のゲートウェイレプリカが同一のカウントを共有でき、これでこそ「グローバル」なレートリミットと呼べます——ローカルレートリミット(local rate limit)では各 Envoy インスタンスがそれぞれ別々に計算することになります。

マッチングのカギは次の点にあります。ルート上の actions が生成するディスクリプタの組み合わせは、ConfigMap 内の descriptors の key/value の階層と完全に対応していなければならず、なおかつ両者の domain が一致していて初めて、クォータがヒットします。どこか一箇所でも噛み合わないと、レートリミットは静かに機能しなくなり、リクエストはすべて通ってしまいます。

どんなときに使うか

典型的なのは、低頻度だが機微で、叩かれたときの代償が大きいこの種の API です。

/api/login
/api/send-code
/api/payment

ログイン API が叩かれると大量のパスワード検証とリスクコントロール計算が走ります。認証コード送信 API が叩かれると直接 SMS 費用が燃えます。決済 API は下流のリソースに関わります。これらの API はそもそも QPS が高くないので、ごく小さなクォータを与えるだけで悪意あるトラフィックの大部分を防げますし、誤って正常なユーザーを巻き込む範囲も小さくて済みます。

完全な設定

以下はそのまま流用できる一式の設定で、四つの部分に分かれています。Secret で Redis のパスワードを保存、ConfigMap でクォータを定義、ratelimit サービス本体、そして二つの EnvoyFilter がそれぞれフィルターの取り付けとディスクリプタの付与を担当します。

# --------------------------------------------
# 0) Redis のパスワードは Secret に置く(より安全)
# Secret で機微な情報(Redis のパスワードなど)を保存し、平文がイメージや Pod 環境変数の履歴に入るのを避ける
# --------------------------------------------
apiVersion: v1
kind: Secret
metadata:
name: ratelimit-redis-secret # Secret 名。後続の Deployment が secretKeyRef で参照する
namespace: istio-system # ratelimit サービスと同じ名前空間に置き、参照と管理を容易にする
type: Opaque
stringData:
REDIS_AUTH: "password" # 平文は K8s が base64 エンコードして保存してくれる

---
# --------------------------------------------
# 1) Ratelimit クォータ設定(Envoy Ratelimit Server のランタイム設定)
# - この ConfigMap はコンテナに読み取り専用で /data/ratelimit/config にマウントされる
# - domain は Envoy の HTTP フィルター内の domain と完全に一致していなければヒットしない
# - descriptors はレートリミットの「次元の組み合わせ」を記述する(actions を組み合わせて生成されるディスクリプタ)
# --------------------------------------------
apiVersion: v1
kind: ConfigMap
metadata:
name: ratelimit-config
namespace: istio-system
data:
config.yaml: |
domain: ingress-ratelimit # RateLimit フィルターの domain と一致していること
descriptors:
- key: header_match # 最上位 key:HTTP_ROUTE 内の header_value_match に対応(actions 1/2)
value: path-api # 最上位 value:ルート上の actions の descriptor_value から来る
descriptors: # 二段目のディスクリプタ:さらに細分化する(組み合わさって一意のレートリミットキーを形成)
- key: generic_key # HTTP_ROUTE 内の generic_key に対応(actions 2/2)
value: istio-limit-v1 # ルート内で設定した descriptor_value に対応
rate_limit:
unit: second # 時間単位:second / minute / hour / day
requests_per_unit: 5 # クォータ:単位時間内に許可するリクエスト数(ここでは 1 秒 1 回)

---
# --------------------------------------------
# 2) Ratelimit Service(既存の Redis 単体に接続する)
# - envoyproxy/ratelimit でグローバルなスライディングウィンドウ/トークンバケットのレートリミットを実現する設定サービス
# - replicas=1:テスト環境は単一レプリカ。本番では少なくとも 2〜3 レプリカ + Redis の高可用性を推奨
# - readiness/liveness:6070 ポートでヘルスチェックを行う
# - 重要な環境変数:REDIS_URL / REDIS_AUTH / RUNTIME_ROOT / RUNTIME_SUBDIRECTORY
# --------------------------------------------
apiVersion: v1
kind: Service
metadata:
name: ratelimit # Envoy がクラスタ名で解決するためのもの (outbound|8081||ratelimit.istio-system.svc.cluster.local)
namespace: istio-system
spec:
selector: { app: ratelimit } # Deployment の labels と一致させる
ports:
- name: grpc
port: 8081 # gRPC サービスのポート(Envoy RateLimit フィルターがこのポートに接続する)
targetPort: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ratelimit
namespace: istio-system
spec:
replicas: 1 # テストでは 1 に設定。本番では ≥3 とし、HPA/PodDisruptionBudget も整えることを推奨
selector:
matchLabels: { app: ratelimit }
template:
metadata:
labels: { app: ratelimit }
spec:
containers:
- name: ratelimit
image: envoyproxy/ratelimit:875d418c # commit/tag を指定し、バージョンの再現性を保証する。公式の新しめの tag へのアップグレードも検討可
imagePullPolicy: IfNotPresent
command: ["/bin/ratelimit"] # 起動エントリ
ports:
- containerPort: 8080 # HTTP Admin(任意:metrics/デバッグ)
- containerPort: 8081 # gRPC のメインサービスポート
- containerPort: 6070 # ヘルスチェックポート(/healthcheck)
env:
- name: REDIS_SOCKET_TYPE
value: tcp # TCP で Redis に接続する
- name: REDIS_URL
value: "redis://1.1.1.1:6379"# あなたの Redis アドレス(本番では内網ドメイン + Sentinel またはクラスタを推奨)
- name: REDIS_POOL_SIZE
value: "20" # コネクションプールのサイズ。並行度/レプリカ数に応じてチューニング
- name: REDIS_AUTH
valueFrom:
secretKeyRef:
name: ratelimit-redis-secret # Secret からパスワードを注入し、平文を避ける
key: REDIS_AUTH
- name: USE_STATSD
value: "false" # statsd/Prometheus のサイドカーがあれば有効化できる
- name: RUNTIME_ROOT
value: /data # マウントポイントのルートディレクトリに対応
- name: RUNTIME_SUBDIRECTORY
value: ratelimit # サブディレクトリ。最終的な設定パスは /data/ratelimit/config
- name: RUNTIME_IGNOREDOTFILES
value: "true" # 隠しファイルを無視する
volumeMounts:
- name: config
mountPath: /data/ratelimit/config # 上の runtime パスと一致させ、config サブディレクトリに確実にマウントする
readinessProbe:
httpGet:
path: /healthcheck
port: 6070
initialDelaySeconds: 2 # 初期遅延。コールドスタートの誤判定を避ける
periodSeconds: 5 # 探査頻度
livenessProbe:
httpGet:
path: /healthcheck
port: 6070
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: config
configMap:
name: ratelimit-config # 上の ConfigMap をマウントする

---
# --------------------------------------------
# 3) IngressGateway にグローバルな RateLimit フィルターを注入 + ratelimit を指す CLUSTER
# - EnvoyFilter を使って HTTP Router の前にグローバルな ratelimit HTTP フィルターを挿入する
# - workloadSelector:ゲートウェイのワークロードにのみ作用するよう限定する(label: istio=ingressgateway)
# - rate_limit_service:上で定義した ratelimit gRPC に接続する(クラスタ名/authority 経由)
# - Lua レスポンスフィルターを追加:バックエンドが 429 を返したとき、統一 JSON に書き換える
# --------------------------------------------
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: gateway-global-limit-filter
namespace: istio-system
spec:
workloadSelector:
labels:
istio: ingressgateway # IngressGateway のインスタンスにのみ作用する
configPatches:
# 3.1 router の前に ratelimit HTTP フィルターを挿入する(グローバルに有効)
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager # HTTP コネクションマネージャ
subFilter:
name: envoy.filters.http.router # Router の前に挿入する
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
domain: ingress-ratelimit # ConfigMap 内の domain と一致していること
failure_mode_deny: false # バックエンドの ratelimit サービスが異常なときにリクエストを拒否するか。false=通す(推奨)
timeout: 10s # ratelimit サービスへのアクセスのタイムアウト
rate_limit_service:
grpc_service:
envoy_grpc:
cluster_name: outbound|8081||ratelimit.istio-system.svc.cluster.local # Istio が自動生成するクラスタ名
authority: ratelimit.istio-system.svc.cluster.local # HTTP/2 Host (SNI)。省略可
transport_api_version: V3 # v3 API を使う(推奨)
# 3.2 ポート 1035 の Listener に Lua レスポンスフィルターをさらに挿入する(任意:この待受ポートにのみ有効)
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
portNumber: 1035 # 1035 ポートの Listener にのみ有効(あなたのゲートウェイがこのポートを公開している)
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inlineCode: |
function envoy_on_response(handle)
local s = tonumber(handle:headers():get(":status") or "0")
if s == 429 then
local body = '{"code":429,"message":"Too many requests. Please try again later."}'
handle:headers():replace("content-type", "application/json")
handle:headers():replace("content-length", tostring(#body))
handle:body(true):setBytes(body)
end
end
# 注意:
# - ゲートウェイが 1035 を待ち受けていない(または複数ポートの)場合、このフィルターを全ポートに拡大すべきか確認すること(portNumber 条件を外す)
# - ローカルレートリミットでローカル拒否時に自機で 429 を生成し、この Lua で統一的に書き換えることもできる

---
# --------------------------------------------
# 4) VirtualHost に per-route のレートリミットアクションを注入する
# - 指定した Route(または vhost 全体)に actions を設定し、ConfigMap とマッチするディスクリプタを生成する
# - ここでは二段の action を使う:
# a) header_value_match::path が /api プレフィックスにヒットしたとき、descriptor_value=path-api を書き込む
# b) generic_key:さらに descriptor_value=istio-limit-v1 を追加する
# - 二段の組み合わせ => (key=header_match,value=path-api) + (key=generic_key,value=istio-limit-v1)
# これがちょうど ConfigMap と対応し、「1 秒 1 回」のレートリミットが有効になる
# --------------------------------------------
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: gateway-limit-descriptor-svc
namespace: istio-system
spec:
workloadSelector:
labels:
istio: ingressgateway # 作用範囲をゲートウェイに限定する
configPatches:
- applyTo: HTTP_ROUTE
match:
context: GATEWAY
routeConfiguration:
vhost:
name: host:port # この vhost 名に注意:通常は "<host>:<port>"
patch:
operation: MERGE
value:
route:
rate_limits:
- actions:
- header_value_match:
headers:
- name: ":path" # 疑似ヘッダー :path をマッチ。プレフィックスで判定
prefix_match: "/api"
expect_match: true # マッチしたときのみこのディスクリプタを追加する
descriptor_value: "path-api" # ConfigMap の最上位 key/value に対応
- generic_key:
descriptor_value: "istio-limit-v1" # ConfigMap の二段目 key/value に対応

いくつかの設定ポイントを掘り下げておきます。

1)二つの EnvoyFilter はどちらも欠かせません。一つ目は ratelimit フィルターをフィルターチェーンに取り付け、ratelimit サービスをどこに探しに行くかを伝えるだけです。「どのリクエストがどんなディスクリプタを持ってレートリミットに問い合わせるか」を実際に決めるのは二つ目です——ルートに rate_limits.actions がなければ、フィルターはリクエストに対して何もしません。

2)failure_mode_deny: false。ratelimit サービスや Redis が落ちたとき、拒否ではなく通す選択をします。レートリミットは保護手段であり、新たな単一障害点になるべきではありません。API が「利用不可でもいいから叩かれてはならない」ほど機微でない限り、false のままにすることを推奨します。

3)Lua フィルターは体験の最適化です。Envoy はレートリミットにヒットしたとき、デフォルトでは素の 429 を返すので、フロントエンドが統一的に処理しづらいです。この Lua はレスポンス段階で 429 の body を統一された JSON 構造に書き換え、クライアントにとってより親切にします。

ハマりどころと注意点

  • domain が一致しないと、レートリミットは静かに機能しなくなります。フィルター内の domain と ConfigMap 内の domain は一字一句同じでなければなりません。噛み合わないときエラーは出ず、ratelimit サービスがルールを見つけられないだけで、すべて OK を返します。
  • ディスクリプタの階層は完全に対応していなければなりません。ConfigMap で header_match/path-apigeneric_key/istio-limit-v1 をネストする二層構造は、ルート上の二つの action の前後の順序に対応します。action が一つ足りない、value を書き間違える、階層が逆——どれもヒットしません。
  • vhost 名は適当に書けませんrouteConfiguration.vhost.name は通常 "<host>:<port>" の形式で、Envoy が実際に生成するルート設定内の vhost 名と一致させる必要があります。ゲートウェイの config_dump から確認できます。書き間違えると MERGE が効きません。
  • EnvoyFilter がマッチするポートに注意しましょう。上の Lua フィルターは portNumber: 1035 に限定しています。ゲートウェイが別のポートや複数のポートを待ち受けている場合は、それに応じて調整するかこの条件を外してください。さもないと 429 の書き換えが一部のトラフィックにしか効きません。
  • ratelimit サービス自体の可用性。テスト環境では単一レプリカで問題ありませんが、本番では複数レプリカを推奨し、Redis も高可用性を考慮する必要があります。REDIS_POOL_SIZE は並行量とレプリカ数に応じて調整してください。
  • EnvoyFilter は比較的低レベルの拡張メカニズムで、Envoy の設定を直接パッチするため、Envoy 内部の API とバージョン的に結合します。Istio のメジャーバージョンをアップグレードする前に、まずテストクラスタでこの二つの filter が正常に効くかを検証しましょう。
ヒント

レートリミットが効かないときは、パイプラインに沿って順に確認します。ratelimit サービスのログにリクエストが届いているか → domain が一致しているか → ディスクリプタの組み合わせが ConfigMap と揃っているか → vhost 名が正しいか。大半の問題は後ろの三つの「噛み合っていない」に起因します。

まとめ

ゲートウェイ層のグローバルレートリミットの核心は三つのことに尽きます。ルート上の actions がリクエストにディスクリプタを付与し、ratelimit サービスが ConfigMap 内のルールに従ってクォータを照会し、Redis がすべてのゲートウェイレプリカにカウントを共有させる。設定自体は複雑ではなく、難しさは domain と descriptor のマッチングに集中しています——これらの箇所は間違えてもエラーが出ず、ただ黙って効かなくなるだけです。マッチングのパイプラインを整理して理解できれば、あとは業務の必要に応じてクォータを調整するだけです。

COMMENTS