Fail2ban 这类工具虽然功能强大,但对于轻量级的云服务器或者个人 VPS 来说,有时显得过于臃肿。它们依赖 Python 环境,需要常驻后台守护进程,并且会产生大量的日志读写。如果你只是想在 Ubuntu 上实现一个简单的“自动封禁恶意 IP”功能,完全可以通过纯 Shell 脚本配合系统自带的日志分析工具来实现。这种方式资源占用极低,且逻辑透明,非常容易调试和优化。
我们直接面对的核心问题是:脚本在处理大量日志时,效率会急剧下降,甚至导致 CPU 飙升。传统的做法是使用 cat 或 tail 读取整个日志文件,然后用 grep 进行模式匹配,再通过 awk 提取 IP,最后用 sort 和 uniq 统计。当访问日志达到几百兆甚至上 G 时,这种管道操作会消耗大量内存和计算资源。优化的关键在于改变数据处理的方式,从“全文扫描”转向“增量读取”和“高效去重”。
利用 journalctl 替代直接读取文本日志很多教程还在教你直接去读 /var/log/auth.log 或者自定义的访问日志。但在现代 Ubuntu 系统中,系统日志主要由 journald 管理。直接 grep 文本文件不仅慢,还容易因为日志轮转导致数据丢失。使用 journalctl 是更高效的选择。它支持按时间范围过滤,并且可以指定输出格式,避免了庞大的文本解析开销。
例如,我们想要获取过去 5 分钟内 SSH 登录失败的 IP,可以这样写:
journalctl -u ssh --since "5 min ago" -o json | jq -r '._HOSTNAME, .MESSAGE' | grep "Failed password" | grep -oP 'from \K\S+'
这段代码直接利用 journalctl 的二进制索引能力,速度远快于 grep 全量日志。其中 -o json 配合 jq 工具,可以精准提取字段,避免了正则表达式匹配到无关内容的干扰。如果你的系统里没有 jq,也可以用 awk 处理默认输出,但 json 格式在脚本中的可维护性更好。
使用 ipset 替代大量 iptables 规则脚本优化中另一个常被忽视的性能杀手是 iptables 规则的管理。传统的脚本每封禁一个 IP,就执行一次 iptables -A INPUT -s [IP] -j DROP。当封禁列表积累到几千条时,网络栈在匹配数据包时需要线性遍历这些规则,这会显著增加延迟。而且,脚本每次运行时都要先 iptables -L 检查 IP 是否已被封禁,这个过程本身就极慢。
正确的做法是使用 ipset。ipset 使用哈希表来存储 IP 地址,即便有数万个 IP,匹配时间也是常数级别的。在脚本初始化时创建集合:
ipset create blacklist hash:ip hashsize 4096 timeout 86400 -exist
这里的 timeout 86400 表示封禁时间为 24 小时,到期自动解封,无需额外写清理脚本。封禁 IP 时只需:
ipset add blacklist [恶意IP] -exist
然后在 iptables 中只需一条规则引用这个集合:
iptables -I INPUT -m set --match-set blacklist src -j DROP
这种组合将 iptables 的规则复杂度从 O(n) 降到了 O(1),是脚本能否长期稳定运行的关键。
通过关联数组实现内存级去重在脚本单次运行周期内,同一个恶意 IP 可能会多次触发规则(例如同一个 IP 在一分钟内暴力破解了 50 次)。如果不做去重,脚本可能会反复调用 ipset add 或者发送大量重复的告警通知。虽然 ipset 本身会忽略重复添加,但减少不必要的系统调用仍然能提升性能。
在 Bash 脚本中,可以声明关联数组作为临时缓存:
declare -A seen_ips
# 在循环处理日志时
if [[ -z "${seen_ips[$ip]}" ]]; then
ipset add blacklist "$ip" -exist
seen_ips[$ip]=1
fi
这样,在脚本的本次执行中,每个 IP 只会被处理一次。对于攻击高峰时段,这种优化能减少大量的冗余操作。
精准匹配与误封规避一个容易被忽略的优化点是正则表达式的精准度。很多脚本为了省事,直接用 grep "Failed" 或者 grep "error" 来匹配日志,这在生产环境中非常危险。比如,一个正常的用户在网页表单中输入了包含 “Failed” 字样的内容,可能会被反向代理记录到日志中,如果不加区分地封禁,就会误伤真实用户。
针对 SSH 暴力破解,应该严格匹配原始日志中的特定字段。例如,对于 SSHD 日志,最好匹配具体的失败原因代码:
grep -E "sshd.*Failed password for (invalid user )?[a-zA-Z0-9_]+ from [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"
对于 Web 服务器日志,不要仅凭 404 状态码就封禁,这极有可能误封搜索引擎爬虫。应该结合请求频率和特定的恶意 Payload(如尝试访问 /wp-admin、/.env 等路径)来综合判断。可以在脚本中设置阈值,例如 60 秒内触发特定恶意规则超过 10 次才加入黑名单。
引入基于文件锁的并发控制当恶意流量爆发时,cron 定时任务可能会重叠执行。如果前一个脚本实例还在处理日志,后一个实例又启动了,不仅会造成资源争抢,还可能导致 ipset 操作冲突。优化后的脚本必须包含非阻塞的锁机制。
使用 flock 是最佳实践:
exec 200>/var/run/ban_ip.lock flock -n 200 || exit 0 # 脚本主体逻辑 flock -u 200
这段代码确保了任何时候只有一个脚本实例在运行。如果锁被占用,新的实例直接退出,不会堆积进程。
日志轮转与时间窗口的配合脚本的逻辑中,时间窗口的设定直接决定了封禁效果和资源占用。窗口太短,抓不到低频攻击者;窗口太长,脚本处理的数据量太大。如果你的日志量非常大,建议不要使用 journalctl 的 --since 去读一个很大的时间范围,而是结合系统日志轮转机制。
一个巧妙的做法是利用 journald 的游标。脚本每次运行结束时,将当前的日志游标写入一个临时文件:
journalctl -u ssh -n 1 --show-cursor | grep cursor | cut -d' ' -f3- > /tmp/last_cursor
下次运行时,从该游标处开始读取:
journalctl -u ssh --after-cursor "$(cat /tmp/last_cursor)"
这样实现了完美的增量读取,无论日志量多大,脚本每次只需处理新增的几十行日志,执行时间从分钟级骤降到毫秒级。这是让脚本从“能用”跨越到“好用”的核心优化。
白名单机制与动态调整任何自动封禁脚本都必须内置白名单保护,防止把自己锁在服务器外面。优化不仅仅是加一个静态的 IP 列表,而是要动态获取本机的出口 IP 以及管理员的常用 IP。
在脚本头部动态获取当前登录会话的 IP 并加入白名单:
whitelist=("127.0.0.1" "192.168.0.0/16")
# 动态获取当前 SSH 连接的 IP
current_ips=$(who -u | grep -oP '(\d+\.\d+\.\d+\.\d+)' | sort -u)
for ip in $current_ips; do
ipset add whitelist "$ip" -exist 2>/dev/null
done
在封禁逻辑执行前,先检查 IP 是否在白名单集合中。这样即使你在外地出差用手机流量登录,脚本也会自动识别并保护你的当前 IP,极大降低了误封风险。
脚本的完整骨架与执行流程综合上述优化点,一个生产级别的脚本骨架应该是这样的逻辑流:首先初始化 ipset 集合(黑名单与白名单),并确保 iptables 规则存在;接着获取文件锁;然后通过 journalctl 游标增量读取日志;在循环中利用关联数组去重,并严格正则匹配恶意行为;检查白名单后进行封禁;最后更新游标并释放锁。
这种纯 Shell 的方案,不需要安装任何额外的软件包,只依赖系统自带的 ipset、journalctl 和基础 GNU 工具。在 1 核 1G 的轻量云服务器上,处理每秒数百条的日志攻击毫无压力,且 CPU 占用几乎可以忽略不计。相比于动辄占用上百兆内存的 Fail2ban,这种脚本化的轻量级方案更适合追求极致性能与资源控制的场景。
