はじめに
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 の出力もスッキリして運用管理もしやすくなります。


