「このシステムは DDoS に耐えられますか」は、お客様と技術的な話をするときによくいただく質問です。 正直な答えは「はい」や「いいえ」ではなく、どの種類の DDoS を指しているのかという問い返しになります。 回線に 20 Gbps のゴミトラフィックを流し込む攻撃、ほとんどデータを送らないまま数千本の接続を開いたまま保持する bot、 そして最も重い検索 endpoint を毎秒 50 回呼ぶスクリプト。どれもシステムを停止させられます。しかし、この三つを同じ仕組みで 止めることはできません。それぞれ別の層で処理する必要があります。
本記事では、私たちが実際に運用している防御層を一つずつ見ていきます。エッジの OpenResty 設定、キャッシュ、API gateway の中の制限機構、そしてアプリケーション自体に置いた rate limit です。さらに重要な点として、それぞれの選択の裏にあるトレード オフも明記します。実運用を経て初めて見えてきた教訓も含みます。
1. 三種類の攻撃、三つの異なる遮断地点
全体を貫く原則は単純です。早く止めるほど安い。エッジで即座に弾かれた request のコストはほぼゼロです。 しかしその request がアプリケーションまで入り込むと、接続を一本占有し、JWT の検証を行わせ、さらに Postgres へのクエリを 何本か引き起こします。そして Postgres は通常、最も複製しにくい層です。だから防御は多層に積む必要があります。最も安く止め られる層で止め、残りだけを次の層へ通します。
| 攻撃の種類 | システムを停止させる仕組み | どこで止めるか |
|---|---|---|
| Volumetric(L3/L4) | 回線が飽和するまでゴミ帯域を流し込む | 回線上、サーバーに届く前 |
| 接続リソースの枯渇(Slowloris) | 数千本の接続を開いたまま 1 バイトずつ送る。帯域は不要 | エッジ:timeout と接続数の制限 |
| アプリケーション層(L7) | 最も高価な endpoint をピンポイントで呼ぶ — 検索、ログイン、AI | gateway とアプリケーション自体 |
はっきり言っておくべきことがあります。volumetric 攻撃は、攻撃を受けているサーバー自身の上で動くソフトウェアでは 止められません。回線が飽和した時点で、どれほど精巧な nginx 設定も意味を持ちません。パケットが拒否されるためにサー バーへ到達することすらないからです。その層はインフラ事業者、CDN、あるいは前段に立つ scrubbing サービスの担当です。開発チーム がよりよく制御できるのは残り二つ、接続リソースの枯渇とアプリケーション層攻撃です。以降はこの二つに絞ります。
2. エッジ層:入口に置く、最も安い制限
システムに入るすべての request は、まず OpenResty を通ります。ここは最も単純な制限を置くのに適した場所です。速度の制限、 接続数の制限、timeout、body サイズ。これらの制限は業務を理解しませんが、非常に安く、後段のすべてを守ります。以下の設定は、 今お読みいただいているこのページで実際に動いているものです:
# IP ごとの上限:約 5 request/秒、短時間のバーストは 20 request まで許容
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# timeout を締める — Slowloris のようなゴミ接続を切る
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
reset_timedout_connection on;
# 静的ページ:大きな body を受け取る理由はない
client_max_body_size 2m;
large_client_header_buffers 4 8k;
limit_req_status 429;
limit_conn_status 429;
server_tokens off;
この三系統の制限はそれぞれ別の問題を扱いますが、最も忘れられがちなのが timeout です。
limit_req は呼び出しが速すぎる相手を止めます。limit_conn は並列接続を開きすぎる相手を止めます。
そして timeout は、ほとんど何もしないタイプの攻撃を止めます。すなわち Slowloris です。大量の接続を開き、
各接続では「動作中」という状態を保つためにヘッダーを数バイトずつ細く送るだけです。この攻撃は帯域をほとんど使わないため、
トラフィックのグラフは正常に見えることがあります。しかし接続スロットを食い尽くし、実ユーザーの入る余地がなくなります。これを
処理するのは速度の制限ではなく、明示的な timeout です。client_header_timeout 10s は、10 秒以内にヘッダーを
送り終えなければ接続を閉じる、という意味です。
小さいながら変更する価値が大きいのが limit_req_status 429 です。nginx は既定で、上限を超えたとき 503 を返す
ことが多いです。503 は「サーバーが壊れている」と読まれやすい一方、実際の問題は「client の呼び出しが速すぎる」ことです。429 は
Retry-After と組み合わせれば、状況をより正確に伝えます。client はどれだけ待てばよいか分かり、監視システムも
backend がダウンしたと誤検知しません。
エッジに置いた上限は、その後段のすべてを守ります。まだ書かれていないコードさえも守ります。アプリケーションに置いた上限は、 あなたが付け忘れなかった route だけを守ります。
3. 最も安い request は、backend に決して届かない request
遮断の話を続ける前に、吸収の話をする必要があります。実際のところ、私たちが最も高負荷を支えられている層は rate limit ではなく、キャッシュです。
Z-EDU では、公開 route に decorator @HttpCache で Cache-Control ヘッダーを付けています。
これにより CDN/Traefik がエッジで response を返せるので、request は NestJS まで届く必要がありません。講座一覧ページへの
トラフィックが急増したとき、それが実在の人でも bot でも、負荷の大部分はここで吸収され、backend はほとんど気づきません。
DDoS 対策として、これは非常に価値のある層です。圧力をアプリケーションからキャッシュへ移すからです。そして
キャッシュのほうがはるかに安く、拡張も容易です。
ただしキャッシュが効くのは、request がキャッシュにヒットするときだけです。攻撃者は URL にランダムなパラメーターを
付けて request を cache-miss に追い込み、負荷をデータベースへ押し下げられます。ここで二層目が必要になります。
CacheService.wrap は、同じキーの miss を一度の計算にまとめます(single-flight)。100 件の request が同じ
キーで miss しても、実際に計算しに行くのは 1 件だけで、残る 99 件はその結果を待ちます。この仕組みは本来、ピーク時にキャッシュ
が失効したときの cache stampede を防ぐためのものですが、cache-busting に対しても非常に有効です。三層のキャッシュについては
multi-tenant SaaS の三層キャッシュで別途書いています。
4. Gateway:すべての route に一つの数字は、間違った数字
Z-Cloud Workspace では、すべての request が OpenResty 上で動く API gateway を通ります。rate limit は Lua で書いています。 API 全体に一つの共通上限を置くべきでない理由が、ここで非常にはっきり見えます。
問題は、endpoint ごとに「価格」が違うことです。ファイル一覧の読み取りは数ミリ秒で済むかもしれません。AI モデルの呼び出しは CPU と、プロバイダーへ支払う実費を消費します。ログインは Keycloak と、意図的に遅く設計されたパスワードのハッシュ計算を伴い ます。読み取りに十分ゆるい共通上限を設定すれば、ログインには緩すぎます。ログインに十分厳しく設定すれば、通常のユーザーは軽い 機能を使っているだけで 429 に当たり続けます。
そこで gateway は各 request をグループに分類し、グループごとに個別の上限を適用します:
| グループ | 上限 / 60 秒 | 理由 |
|---|---|---|
auth_sensitive |
10 | ログイン、認証。実在の人が 1 分に 10 回もログインすることはない |
ai |
20 | 1 回の呼び出しが、モデルのプロバイダーへ支払う実費になる |
heavy |
30 | 検索、エクスポート、形式変換 — CPU を消費する |
write |
60 | DB への書き込み |
read |
300 | 読み取り。多くはキャッシュに支えられている |
realtime |
600 | チャットと共同編集はもともと通信量が多い — ここで止めると製品が壊れる |
tenant_total |
5000 | 企業単位の上限。ユーザー数にかかわらず適用 |
最後の行が存在するのは、システムが multi-tenant(複数の企業が一つのシステムを共有し、データは完全に分離される)だからです。
上の各上限はユーザーごとに数えます。しかし、従業員が三百人いる企業や、正規のアカウントで動いている不具合のあるスクリプトは、
どのユーザーも個人の上限を超えないまま共有リソースを使い切ってしまうことがあります。tenant_total は tenant 全体
の上限で、一社の障害が他のお客様を巻き込まないようにするためのものです。multi-tenant のシステムでは、「うるさい隣人」のほうが
標的型の攻撃より一般的で、その発生源は悪意ある人物より、不具合のある連携であることが多いです。
固定ウィンドウではなく、スライディングウィンドウで数える
最も単純な数え方は固定ウィンドウです。1 分ごとにカウンターを一つ持ち、分が変わればリセットします。この方式には時間境界そのもの に穴があります。10 request/分という上限の場合、client は 59 秒目に 10 request を送り、61 秒目にさらに 10 request を送れ ます。実際には数秒間に 20 request ですが、帳簿の上では違反していません。
そこで gateway はスライディングウィンドウを使います。現在のウィンドウ内の request 数に、経過時間で重み付けした直前ウィンドウ の有効分を加算します。この計算全体は Redis 上の一つのアトミックな Lua スクリプトの中で走ります。読み取り、 計算、カウンターの加算を一度の呼び出しで行います。gateway は複数の replica を持つため、この点は非常に重要です。二つの replica が同じキーを読んでから書けば、負荷が最も高いとき、つまり rate limit が最も正確であってほしいときに、上限が静かに突破され得ます。
IP だけでなく、アイデンティティでキーを取る
IP が常に良いキーとは限りません。オフィス全体が単一の IP でインターネットに出ていることもありますし、モバイルキャリアが非常に 多くの契約者を一つの NAT アドレスの背後にまとめることもあります。IP だけで遮断すると、操作が多すぎる一人のユーザーのせいで、 その会社全体が 429 を受けかねません。逆に botnet が数千の IP を持っていれば、IP 単位の上限はほとんど何も止められません。
そこで gateway は、アイデンティティが特定できる場合はアイデンティティで数えることを優先します。ログイン済みの request は、ユーザーセッションから導出したキーで数えます。キーはハッシュ化され、生のトークンは Redis に保存しません。ユーザーを特定 できない場合にのみ、IP へフォールバックします。実際の結果として、IP を共有するオフィス全体でも個人ごとに上限が効き、攻撃者が IP を切り替え続けても、使用中のアカウントに紐づく上限からは逃れられません。
5. アプリケーションの中:認証より前に止める
gateway は終点ではありません。Z-Auto では、NestJS の API 側にも独自の rate limit があります。ここで最も重要なのは
実行順序です。RateLimitGuard はグローバル guard の最初に登録されています。つまり
JWT 認証の guard よりも先に走ります。
// app.module.ts — この順序には意味がある
providers: [
{ provide: APP_GUARD, useClass: RateLimitGuard }, // ← 最初に
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
]
この順序を逆にすると、ゴミ request も一つ残らず JWT の署名検証を通り、場合によってはユーザーのクエリまで引き起こし、
そのうえで上限超過として拒否されます。429 は返しますが、攻撃が枯渇させたかったまさにその種類のリソースを消費した後
です。カウンターを先に置けば、ある IP からの 121 番目の request は Redis の INCR 一回だけで弾かれ、業務コードに
触れることすらありません。
カウンターは Redis を使うので、API の全 replica が一つの上限を共有します。各プロセスの RAM に置いたカウンターではこれができ ません。replica を 4 本動かせば、実効の上限はコードに書いた数字の 4 倍になります。上限の構成は三層に分かれます。機微な endpoint 専用のグループ、公開 route の既定グループ、ログイン済み route の既定グループです。
| バケット | 上限 | 備考 |
|---|---|---|
| ショールームのログイン | 10 / 60 秒 | パスワードの総当たりを遅くする |
| システム管理者のログイン | 8 / 60 秒 | より厳しく — 価値の高い攻撃面 |
| アカウント登録 | 10 / 300 秒 | アカウントの大量作成を防ぐ |
| プランの申し込み | 5 / 600 秒 | 決済に触れる操作 |
| 公開 route(既定) | 120 / 60 秒 | 外部に露出する面 |
| ログイン済み route(既定) | 600 / 60 秒 | 実ユーザーは訪問者より操作量が多い |
小さいながら非常に実務的な点が二つあります。第一に、上限はシステムを動かしたまま調整できます。管理者が管理 画面で直接編集し、上書き値は Redis に保存され、API の全 replica が数秒で新しい値を受け取ります。再デプロイは不要です。障害 対応中に 10 秒で調整できることは、ビルドして再デプロイし直すのとは大きく違います。第二に、ログは、ある IP が 閾値を超えた時点で一行だけ警告を記録し、その後に遮断された各 request については追記しません。毎分数万の request が あるとき、過剰なログ出力はそれ自体が新たな障害になり得ます。ディスクが埋まり、logging pipeline が詰まり、あるいはアプリケー ション自体が遅くなります。
rate limit を免除する route が二種類あり、理由は明確です。/health を免除するのは、load balancer が絶えず呼ぶ
からです。これを上限に数えると、システムが自らの監視ツールを遮断してしまいかねません。決済の webhook も免除します。ここで 429
を返すと実害が出るからです。決済ゲートウェイは取引成功を通知したのに、システムが「速すぎる」として拒否し、お客様は支払ったのに
注文が記録されない、という事態になります。この種の endpoint は、rate limit ではなく送信元の署名検証で守らなければなりません。
rate limit は量を止めるための道具です。1 件の request を失うことが金銭の損失になる endpoint は、上限ではなく認証で 守らなければなりません。
6. 最後の層:攻撃者が多くの request を必要としないとき
ここまでの対策の大半は、「攻撃=大量の request」という前提に立っています。しかし、多くの request を必要としない濫用もあります。 各 request が十分に高価でさえあればよいのです。純粋な rate limit では足りず、防御層が具体的な業務を理解しなければ ならない場面です。
Z-Auto の相談申し込みフォームでは、一層目が honeypot です。実ユーザーには見えない入力欄を一つ置きます。
実在の人はこの欄を埋めませんが、bot は目に入る欄をすべて埋めるのが普通です。その欄に値があれば、API は { ok: true }
を返しつつ、データベースには何も書きません。この方法は bot に検知されたことを知らせず、Redis も消費しないので、他のカウンター
に障害が起きていても動き続けます。
二層目は、IP だけでなく業務上のキーによる制限です。一つの IP からの lead 数を制限すると同時に、同じ電話番号 に対する lead 数も制限します。bot が IP を変えるのはかなり容易ですが、実在の電話番号を変えるのははるかに高くつきます。一般原則 として、自分が測りやすいものではなく、攻撃者が最も偽装しにくいものの上で数えてください。
三層目は、AI の時代に非常に忘れられやすいものを守ります。請求書です。storefront 上の AI アシスタントは言語 モデルを呼び出し、呼び出しごとに実費が発生します。攻撃者はシステムを落とす必要すらありません。一晩中コンスタントに呼び続けるだけ で、翌朝あなたは不快な請求書を受け取ります。そこでアシスタントは三層で守られています。IP 単位の上限、httpOnly cookie で訪問者 ごとに紐づけた日次の上限、そしてショールームのクレジット台帳です。呼び出しごとにクレジットを差し引き、尽きたら停止します。順序が 非常に重要です。上限を超えたとき、システムはモデルを呼び出す前に定型文で応答します。モデルを呼んだ後に止めても、止めら れるのは response だけで、コストはすでに発生しています。
7. 最も難しい問い:Redis が死んだらどうするか
上記のカウンターはすべて Redis に依存しています。だから避けられない問いが一つあります。Redis が応答しないとき、次の request は通すのか、止めるのか。
すべての場合に正しい答えはありません。あるのは二種類のリスクだけで、自分が受け入れられるほうを選ぶしかありません。 Fail-closed は、上限を確認できないなら request を止める、という方針です。防御の観点ではより安全ですが、 Redis の一障害がシステム全体の障害に化ける可能性があります。何も悪いことをしていないユーザーまで拒否されます。 Fail-open は、確認できないなら request を通す、という方針です。システムは提供を続けますが、その間、制限の層 はほぼ機能しません。
私たちはすべての endpoint に同じ方針を選んではいません。既定は fail-open です。大半の API では、数分間だけ制限のかかっていない
トラフィックを通すほうが、副次的なインフラの障害を理由に実在のお客様全員を拒否するより害が小さいからです。しかし gateway の
auth_sensitive グループは fail-closed です。Redis にアクセスできないとき、ログインの endpoint
は自由に開放されるのではなく、gateway の共有メモリ上のローカルカウンターへ切り替わります。このカウンターは gateway の replica
ごとに別々に数えるため精度が落ち、実効の上限は設定より緩くなります。しかしユーザーのパスワードに関しては、「精度が落ちる」ほうが
制限が何もないよりはるかにましです。
fail-open は妥当な選択です。静かな fail-open はそうではありません。あなたの制限機構が誰にも知られないまま停止している なら、それは縮退モードではありません — 穴です。
だから Redis の状態は前提ではなく、監視対象の指標です。rate limit のレポートは数値とともに redisHealthy を返し
ます。これは、必要になる前に組んでおかなければならない種類のアラートです。
8. 代償を払って初めて得られた二つの教訓
置き場所を間違えた制限機構は、無いより悪い
Z-Auto の Traefik 層の rate limit 設定はすでに書いてありますが、現在は意図的に無効化しています。理由はこうです。システムは Docker Swarm 上で動いており、Swarm の既定のルーティングモードでは、Traefik はすべての client について ingress 層の IP を見ます。ユーザーの本当の IP ではありません。その状態で IP 単位の rate limit を有効にすると、世界中のユーザーが 一つのカウンターにまとめられます。結果は「攻撃者を止められる」ことではなく、システムが自分のお客様に 429 を返し、成功した DDoS とまったく同じ見た目になることです。ネットワーク設定によって Traefik が本当の IP を見られるようになるまで、無効のままにしてい ます。
教訓は Swarm よりはるかに一般的です。制限機構は、それが数えるキーが正しいときにのみ正しく機能します。 IP 単位の上限を有効にする前に、一つ答えてください。この層が見ている IP は、ユーザーの IP なのか、それともすぐ前に立つ proxy の IP なのか。
client のヘッダーを信じるのは、自分で扉を開けること
この問いには、同じくらい危険な裏面があります。reverse proxy の背後では、client の本当の IP は X-Forwarded-For
ヘッダーに入っています。しかしヘッダーは client が自分で送るものです。だからアプリケーションが X-Forwarded-For
を無批判に読むなら、誰でも request ごとに新しい IP をでっち上げるだけで、あなたの rate limit 全体を無力化できます。制限機構は
動き続け、グラフも綺麗なまま、そして何も止めていません。
X-Forwarded-For が信頼できるのは、その前に立つ proxy が自分のものであり、その proxy を迂回する経路が
request に一切ない場合だけです。同じ原則により、Z-Cloud Workspace の gateway は、他の何をするよりも先に、着信 request から
内部の信頼ヘッダーをすべて削除します:
-- strip-trust-headers.lua — すべての request の先頭で実行
ngx.req.clear_header("X-Gateway-Verified")
ngx.req.clear_header("X-Verified-User-Id")
ngx.req.clear_header("X-Verified-User-Email")
ngx.req.clear_header("X-Tenant-Code")
-- …
後段のサービスは、呼び出し元が誰かを知るためにこれらのヘッダーを信頼します。これらは gateway が JWT を検証した後に設定するもの
です。gateway が先に削除しなければ、client は X-Verified-User-Id を自分で添えて送るだけで、誰にでもなれてしまい
ます。原則はこうです。内部システムが信頼するヘッダーはすべて、入口で削除し、それを検証した層だけが再設定する。
これは DDoS の範囲を超えますが、根は同じです — 「client が与えたデータ」と「システムが自ら検証した事実」との境界です。
9. 測っていなければ、攻撃されていることに気づけない
数値の裏付けがない rate limit は、怖くて調整できない rate limit です。締めれば本当のお客様を誤って止めるのが怖く、緩めれば 突破されるのが怖い。だから上限の判定は毎回記録されます(best-effort で、request の経路上には置きません)。そして管理画面は 三つの問いに答えられます:
- 上限グループごとの遮断率。遮断率が恒常的に高いグループは、たいてい攻撃の兆候ではありません — 上限が厳しす ぎる兆候であり、止められているのはあなたのお客様です。
- 最も多く遮断された IP の一覧。サイズに上限のある集合に保持します。この上限は意図的なものです。「遮断された すべての IP」の一覧は、まさに攻撃を受けている最中に無制限に膨張します — つまり攻撃の追跡機構そのものが、メモリを枯渇させる 手段になってしまいます。
- Redis が生きているか。上で述べたとおり、これは「制限の層は本当に機能しているのか」への答えだからです。
私たちの他のシステムにおける観測の哲学とも共通します。アラートは CPU とメモリだけでなく、業務の状態を語る指標の上に 組まなければなりません。よく設計された L7 攻撃は、CPU が正常値のままであなたのシステムを停止させられます。
10. チェックリスト
自分のシステムを見直すなら、順番に答える価値のある問いは以下です:
- timeout は締めてありますか。最も安く、最も忘れられがちなものです。これがないと、細く送り続ける接続が あなたのスロットを無期限に占有できます。
- IP ごとの同時接続数に上限はありますか。速度の制限と接続の制限は、別のものを止めています。
- body サイズは route ごとに上限がありますか。アップロードの endpoint は 500MB を受け取る必要があります が、ログインの endpoint には不要です — そこでも 500MB を受け取るなら、攻撃者にメモリを枯渇させる手段を贈ったことになります。
- 上限は高価な route と安価な route を区別していますか。すべての endpoint に共通の一つの数字は、常に厳し すぎ、かつ緩すぎます。
- カウンターはどこにありますか。プロセスの RAM の中なら、あなたの実際の上限は replica の数だけ掛け算されて います。
- 数えるキーは偽装できますか。それが client 自身の送るヘッダー由来なら、答えは「できる」です。
- グループごとに fail-open か fail-closed かを書き出してありますか。そして縮退モードに落ちたときのアラート はありますか。
- 呼び出しごとに費用が発生する endpoint はどれですか。それらの endpoint には速度の上限だけでなく費用の上限 が必要です — しかも、費用が発生する前に止めなければなりません。
- 攻撃されたとき、ログでディスクが埋まりませんか。閾値を超えた時点で一行だけ書き、request ごとには書かない ことです。
まとめ
以上のどの層も、それ単体で「DDoS を防げる」わけではありません。エッジの設定は、検索 API を叩くスクリプトからは救ってくれません。 アプリケーション内の rate limit は、Slowloris からは救ってくれません。そして本記事のどれもが、十分に大きな帯域の洪水からは救っ てくれません — その層はソフトウェアチームの範囲外であり、逆のことを言う人は何かを売り込もうとしています。
本当に効くのは、これが多層の問題だと受け入れることです。エッジでは安いから timeout と接続数の制限を締める。backend に届かない request はコストゼロなので、キャッシュで吸収する。gateway では route を本当の価格で分類する。アプリケーションではカウンターを 認証より前に置く。そして費用を消費する endpoint については、速度の層だけでなく費用の層で止める。最後に、Redis が動かないときに 何が起きるのかを明文化し、その決定そのものの上にアラートを組んでください。
私たちがこれまで解きほぐしてきた「DDoS 障害」の大半は、悪意ある人物によるものではありませんでした。ループに陥ったお客様の連携、
robots.txt を読まないクローラー bot、あるいは自分自身の API を指数関数的に呼び返す cron job です。本記事の防御層
は、本物の攻撃を止めるのと同じ仕組みで、こうした状況も処理します。だからこそ、誰も自分を攻撃する理由などないと思っていても、構築
する価値があるのです。