iptables 10万件の限界と、ipsetで攻撃元IPを安全&高速に制御する運用術

セキュリティ

はじめに

Webホスティング基盤を運用していると、悪意あるIPアドレス/CIDRのブロックリストはじわじわと膨らんでいきます。弊社の環境では気づいたら1万5千件超のIP/CDIR をブロック対象として管理するようになっていました。

きっかけは .env や .git、wp-login.php へのスキャン攻撃の増加です。ここ数年でこの手の探索的な攻撃が明らかに増えていて、同一IPから1日数万リクエスト来るケースも珍しくありません。ブロックリストが膨らんだのはその対処の結果です。

最初はシンプルに iptables -A でルールを追加するスクリプトを動かしていたのですが、ある日を境に適用に14分以上かかるようになってきました。原因を調査した結果、iptablesのルール数が原因と判明。ipsetへの移行で劇的に改善できたので、実測データとあわせてまとめます。

iptablesのアーキテクチャ的な問題

iptablesのルールマッチングは線形探索です。パケットが届くたびに、ルールを上から順番に評価していきます。

パケット到着
→ ルール1と比較 → マッチしない
→ ルール2と比較 → マッチしない

→ ルール15,000と比較 → マッチ → DROP

1パケットあたり最悪15,000回の比較が発生します。これがトラフィックの多い環境で致命的になります。

さらに今回の実運用スクリプトでは、挿入位置を毎行 –line-numbers で検索しながら -I で追加していたため、ルールが増えるほど指数的に遅くなる構造になっていました。

注目すべきは本番環境の結果です。本番サーバーは種サーバー(Xeon E5-2686 v4)より新しいXeon Platinum 8259CLを搭載しているにもかかわらず、適用時間は14分32秒と最も長くなっています。これはCPU性能の問題ではなく、既存ルール数が多い状態への挿入処理というロジック上の問題であることを示しています。

実際に計測してみた

iptables -A でCIDRを1件ずつ登録した場合の実測値です(ランダム生成した10万件のCIDRを使用)。

環境 CPU 件数 所要時間 (real)
仮想テスト環境 仮想CPU 1core 2.4GHz 1万件 8.2秒
仮想テスト環境 仮想CPU 1core 2.4GHz 10万件 6分57秒
EC2 (種サーバー) Xeon E5-2686 v4 1core 1万件 24.2秒
EC2 (種サーバー) Xeon E5-2686 v4 1core 10万件 7分2秒
EC2 (本番サーバー) Xeon Platinum 8259CL 2core 約1.5万件 14分32秒 ※

※ 本番スクリプトでは、DROPルールを ESTABLISHED,RELATED の直前(既存のACCEPTルールより前)に挿入する必要があるため、毎行 iptables –line-numbers で挿入位置を検索しながら -I で追加しています。これにより既存15,000ルールの評価が毎行発生し、純粋な -A 追加より大幅に遅くなっています。

また、10万件が入った状態で iptables -L を実行するだけで約1.4秒かかります。監視スクリプトやデバッグ時にも影響が出ます。

ipsetとは

ipsetはLinuxカーネルのNetfilterサブシステムが提供する、ハッシュテーブルベースのIP集合管理機能です。

iptablesはルールを上から1行ずつ順番に評価するため、ルール数が増えるほど処理時間も増えます(O(n))。一方ipsetはハッシュテーブルを使っているため、辞書で五十音順に引くようなイメージで一発で検索できます(O(1))。10万件でも100万件でも検索コストはほぼ変わりません。

パケット到着
→ ipsetハッシュテーブルを参照 → O(1)でヒット判定
→ ヒット → DROP

ipsetの実測値

同じ10万件のCIDRリストをipset restoreで投入した結果です。

環境 CPU 操作 所要時間 (real)
仮想テスト環境 仮想CPU 1core 2.4GHz ipset restore 10万件 0.171秒
EC2 種サーバー Xeon E5-2686 v4 1core ipset restore 10万件 0.338秒
EC2 種サーバー Xeon E5-2686 v4 1core ipset swap(切り替え) 0.001秒
EC2 種サーバー Xeon E5-2686 v4 1core ipset list 10万件 0.094秒
EC2 種サーバー (実運用スクリプト) Xeon E5-2686 v4 1core blockip_ipset.sh 約1.5万件 0.624秒

iptables -A 10万件が約7分かかるのに対し、ipset restoreは0.3秒台。約1,200倍の差です。

さらにアトミックスワップ(ipset swap)が0.001秒で完了するため、ブロックリストの更新中にフィルタリングが瞬断することもありません。

iptables vs ipset 比較まとめ

比較項目 iptables直接 ipset使用
10万件の適用時間 約7分 約0.3秒
本番での適用時間 14分32秒 0.624秒
iptablesルール数 10万行 2行(match-setのみ)
検索アルゴリズム O(n) 線形探索 O(1) ハッシュ検索
リスト更新中の瞬断 あり なし(アトミックスワップ)
iptables -L の重さ 1.4秒/10万件 無関係
最大エントリ数 実質的な上限あり 100万件以上対応(maxelemで設定)

ipset設定サンプル

基本操作

# インストール(RHEL/AlmaLinux/Rocky)
yum install -y ipset ipset-service


# hash:ip セット作成(maxelemで上限を明示的に指定)
ipset create blocklist hash:ip maxelem 1048576


# IPアドレスを追加
ipset add blocklist 192.168.1.100


# CIDRブロックには hash:net を使う
ipset create blocklist_net hash:net maxelem 1048576
ipset add blocklist_net 203.0.113.0/24

iptablesからipsetを参照するルール

-m set –match-set <セット名> src の1行だけで10万件のIPチェックが完結します。

# ipsetに登録されたIPからのパケットをDROP
iptables -I RH-Firewall-1-INPUT -m set --match-set blocklist     src -j DROP
iptables -I RH-Firewall-1-INPUT -m set --match-set blocklist_net src -j DROP

本番向け: アトミックスワップによる無停止更新

ブロックリストを更新するたびにipsetをdestroyして作り直すと、その瞬間にフィルタリングが無効になります。本番ではアトミックスワップを使います。

#!/bin/bash
# blockip_ipset.sh - ipsetを使ったブロックリスト一括適用スクリプト


SETNAME="blocklist"
SETNAME_TMP="${SETNAME}_tmp"
BLOCKLIST="/path/to/blockip_list.txt"
IPSET_TYPE="hash:ip"
MAXELEM=1048576


# 一時セットを作成(既存なら削除)
ipset destroy "${SETNAME_TMP}" 2>/dev/null
ipset create "${SETNAME_TMP}" ${IPSET_TYPE} maxelem ${MAXELEM}


# restore形式で一括投入
{
  echo "create ${SETNAME_TMP} ${IPSET_TYPE} maxelem ${MAXELEM} -exist"
  while IFS= read -r line; do
    [[ "$line" =~ ^# || -z "$line" ]] && continue
    echo "add ${SETNAME_TMP} ${line}"
  done < "${BLOCKLIST}" } | ipset restore # 本番セットがなければ作成 ipset list "${SETNAME}" &>/dev/null || \
  ipset create "${SETNAME}" ${IPSET_TYPE} maxelem ${MAXELEM}


# アトミックスワップ(この瞬間に切り替わる)
ipset swap "${SETNAME_TMP}" "${SETNAME}"
ipset destroy "${SETNAME_TMP}"


echo "[$(date '+%Y-%m-%d %H:%M:%S')] loaded: \
 $(ipset list ${SETNAME} | grep 'Number of entries' | awk '{print $4}') entries"

CIDRと個別IPの混在対応

実際のブロックリストにはIPアドレス単体とCIDRが混在することが多いです。hash:ipとhash:netを分けて管理します。

#!/bin/bash
SETNAME_IP="blocklist_ip"
SETNAME_NET="blocklist_net"
MAXELEM=1048576


ipset destroy "${SETNAME_IP}_tmp"  2>/dev/null
ipset destroy "${SETNAME_NET}_tmp" 2>/dev/null
ipset create "${SETNAME_IP}_tmp"  hash:ip  maxelem ${MAXELEM}
ipset create "${SETNAME_NET}_tmp" hash:net maxelem ${MAXELEM}


while IFS= read -r line; do
  [[ "$line" =~ ^# || -z "$line" ]] && continue
  if [[ "$line" =~ / ]]; then
    ipset add "${SETNAME_NET}_tmp" "$line" 2>/dev/null
  else
    ipset add "${SETNAME_IP}_tmp"  "$line" 2>/dev/null
  fi
done < /path/to/blockip_list.txt


ipset swap "${SETNAME_IP}_tmp"  "${SETNAME_IP}"
ipset swap "${SETNAME_NET}_tmp" "${SETNAME_NET}"
ipset destroy "${SETNAME_IP}_tmp"
ipset destroy "${SETNAME_NET}_tmp"

永続化

# ipset-serviceで永続化(RHEL系)
systemctl enable ipset
ipset save > /etc/sysconfig/ipset


# または systemdサービスで管理
# /etc/systemd/system/blockip-ipset.service
[Unit]
Description=Load blocklist ipset
Before=iptables.service
After=network.target


[Service]
Type=oneshot
ExecStart=/path/to/blockip_ipset.sh
RemainAfterExit=yes


[Install]
WantedBy=multi-user.target

まとめ

ブロックリストが1万件を超えたあたりからipsetへの移行を検討することをお勧めします。今回の計測では1万件でも iptables -A が24秒かかっており、スクリプトの実行頻度によっては十分に問題になります。

本番環境では既存ルールへの挿入処理が重なって14分32秒まで悪化していました。ipset restoreへの移行で同じ件数が1秒以内に収まります。

ipsetはiptablesルール数を実質2行に抑えられるため、iptables -L の出力もスッキリして運用管理もしやすくなります。