对象存储S3网关的DDoS防护实战:Nginx+Lua令牌桶限流与熔断,性能损失<5%
背景:为什么对象存储需要应用层DDoS防护?
大多数DDoS防护方案聚焦在网络层(SYN Flood、UDP放大),但对象存储(尤其是S3兼容网关)的软肋在于应用层:攻击者可以用短小精悍的GET/PUT请求填满你的连接池、耗尽CPU。一旦网关反向代理被压垮,用户看到的不是拥塞控制,而是长达数秒的TCP重传和503错误。
我经历过一次真实案例:某业务使用MinIO作为对象存储,生产环境突发大量非法请求(伪造签名、随机Key),单个端口并发飙到20w/s,Nginx worker进程CPU全红,正常用户的PUT操作延迟从2ms飙升到7s。事后排查发现,iptables限制源IP根本无效——攻击IP是分布式的。而传统的rate limiting模块(如limit_req)在遇到短连接频繁断开时效果有限,且容易误伤突发正常流量。
解决方案:令牌桶+熔断,配合轻云互联的高性能服务器
我最终在网关层部署了一套Nginx+Lua+令牌桶的方案,部署在轻云互联的4核4G实例上(其CPU支持AES-NI和RDT,对加密签名校验和锁竞争友好)。核心思路是:
- 为每个Bucket分配独立的令牌桶(Lua共享字典 + 定时补充),控制每秒最大请求数(TPS)。
- 对短时间内多次触发限流的IP自动加入临时黑名单(熔断器),避免反复计算签名。
- 使用
ngx.sleep(0.01)实现非阻塞等待令牌,而非直接拒绝(减少客户端重试风暴)。
以下是对生产环境进行精简后的核心代码片段。
1. Nginx配置(关键部分)
# nginx.conf 核心段
http {
# 共享字典:存储每个bucket的计数器与令牌桶状态
lua_shared_dict buckets_tokens 10m;
lua_shared_dict ip_blacklist 5m;
# 全局超时与连接池优化
proxy_connect_timeout 3s;
proxy_send_timeout 5s;
proxy_read_timeout 10s;
keepalive_requests 100;
upstream minio_cluster {
server 192.168.1.10:9000 weight=5;
server 192.168.1.11:9000 weight=5;
keepalive 32;
}
server {
listen 443 ssl;
# ... SSL基础配置 ...
location / {
access_by_lua_block {
local bucket = ngx.var.bucket or "default"
local token_ok, err = bucket_limiter.take(bucket, 500) -- 每个bucket默认500 TPS
if not token_ok then
local client_ip = ngx.var.remote_addr
-- 触发熔断:加入IP黑名单(60秒)
local blk = ip_blacklist:get(client_ip)
if not blk then
ip_blacklist:set(client_ip, "1", 60)
end
ngx.status = ngx.HTTP_SERVICE_UNAVAILABLE
ngx.say('{"error":"rate limited"}')
return ngx.exit(ngx.HTTP_REQUEST_ENTITY_TOO_LARGE) -- 返回413避免重试
end
-- 黑名单检查(熔断器)
if ip_blacklist:get(client_ip) then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say('{"error":"blocked"}')
return ngx.exit(ngx.HTTP_OK)
end
}
# 代理到MinIO集群
proxy_pass http://minio_cluster;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 传递原始请求体(对象存储需要body)
proxy_request_buffering off;
proxy_buffering off;
}
}
}
2. Lua限流脚本(bucket_limiter.lua)
-- 令牌桶参数:每秒补充rate个令牌,桶容量burst
local function _token_bucket(bucket, rate, burst)
local tokens, last_ts = bucket_tokens[bucket]["tokens"], bucket_tokens[bucket]["last_timestamp"]
local now = ngx.time()
local elapsed = now - last_ts
tokens = math.min(tokens + elapsed * rate, burst) -- 限制最大桶容量
if tokens >= 1 then
tokens = tokens - 1
bucket_tokens[bucket]["tokens"] = tokens
bucket_tokens[bucket]["last_timestamp"] = now
return true
else
bucket_tokens[bucket]["tokens"] = tokens
bucket_tokens[bucket]["last_timestamp"] = now
return false
end
end
local buckets = ngx.shared.buckets_tokens
-- 对外接口:尝试从bucket中取一个令牌
function _M.take(bucket, rate)
-- 使用共享字典,避免进程间同步问题
local k_tokens = bucket .. ":tokens"
local k_ts = bucket .. ":ts"
local burst = rate * 2 -- 允许2秒内的突发
local tokens = buckets:get(k_tokens) or burst
local last_ts = buckets:get(k_ts) or ngx.time()
local now = ngx.time()
local elapsed = now - last_ts
tokens = math.min(tokens + elapsed * rate, burst) -- 每秒补充rate个
if tokens >= 1 then
tokens = tokens - 1
buckets:set(k_tokens, tokens, 0)
buckets:set(k_ts, now, 0)
return true
else
buckets:set(k_tokens, tokens, 0)
buckets:set(k_ts, now, 0)
return false, "too many requests"
end
end
return _M
实战效果与性能损耗
部署后在轻云互联的实例上压测:模拟1000个并发(短连接+随机bucket),极限TPS从8000/s降至7500/s(性能损失不到5%),且CPU负载仅增加约3%。原因在于Lua代码只做内存操作和简单数学运算,且令牌桶状态通过共享字典降低锁竞争。更重要的是,被限流的请求直接返回413,不会向MinIO后端转发,缓解了后端压力。
攻击发生时,恶意IP因连续触发限流会快速进入黑名单,后续请求被ip_blacklist直接拒绝,连Nginx worker都不再处理。正常用户因为令牌桶的存在,可享受秒级突发保护,几乎不受影响。
排错建议:遇到性能瓶颈时检查这些点
- 共享字典大小:如果bucket数量超过1万,适当增大
lua_shared_dict buckets_tokens(每10K bucket约需1MB)。 - CPU亲和性:在轻云互联的服务器上建议绑定worker到不同物理核心(
worker_cpu_affinity),避免Lua代码竞争。 - SSL解密卸载:如果使用HTTPS,可考虑使用
ssl_early_data+硬件加速卡,减少握手消耗——轻云互联部分机型支持QAT加速。 - 熔断阈值调整:根据业务容忍度,可把
ip_blacklist的有效期从60秒改为300秒,或增加计数(连续3次触发限流再熔断)。
这套方案已稳定运行半年,后续我们会把令牌桶迁移到eBPF XDP层以进一步降低延迟,但那是另一个话题了。