首页 / 资讯中心 / 文章详情

拒绝服务压测实战:从连接队列、并发容量到限流防护

拒绝服务压测实战:从连接队列、并发容量到限流防护 ★ FEATURED ARTICLE
简介围绕DOS拒绝服务攻击原理整理的小型源代码包面向网络安全初学者与攻防技术研究者用于在受控实验环境中观察拒绝服务攻击的实现思路与代码结构进而理解SYN Flood、Ping Flood、UDP Flood等常见手法的触发机制与资源消耗路径。压缩包共6个文件约11KB以Visual C工程文件为主体含1个cpp源码、dsp与dsw工程配置、opt与plg编译选项以及ncb浏览数据库可直接在旧版VC环境中打开并阅读主逻辑工程结构完整而体量精简适合逐行对照分析。目前已有517人学习下载在同类教学素材中具有一定参考价值。对于希望从代码层面认识攻击流程并据此设计防火墙过滤、限速策略与流量清洗等防护方案的读者这份材料可作为理解攻击面与防御切入点的辅助样本阅读时需结合协议细节与工程配置把握其运行条件并仅在合法合规的实验场景中使用。1. 从「DOS拒绝服务攻击源代码」说起多数人真正缺的是可复现的压测代码搜「DOS拒绝服务攻击源代码」的人最后基本会分流到两个方向一个想验证自己的服务到底扛不扛得住一个想给别人的服务添堵。前者是容量评估后者是违法。我在实际工作里碰到的需求几乎全落在前者——某个接口上线前要摸清并发上限某次线上抖动要复现是不是连接队列被打满了。所以这篇不聊怎么打人聊的是拒绝服务在协议层和应用层靠什么成立怎么在自己有授权的环境里用最小代码把它复现出来、量出来再把防护参数一条条对上。读完之后你手上应该有一份能反复跑的压测脚本而不是一份到处流传、跑起来先把自己机器拖垮的来路不明的代码。2. 拒绝服务的四条资源路径带宽、连接表、工作线程、上游依赖很多人对拒绝服务的想象还停留在「流量特别大」这一层真上手才发现把一台机器打趴下根本不需要多大流量。一个每秒两千请求、每个请求睡两秒的接口就能让默认配置的服务彻底失去响应能力出口带宽连百分之一都没用到。所以第一步不是找代码是把「什么资源会被耗尽」这件事拆清楚拆清楚了才知道该测什么、该看哪些指标。2.1 四条路径各自的表征完全不同第一条是链路带宽和包速率饱和。特征是出入口带宽曲线顶到上限而服务进程 CPU 不高请求根本进不来丢包发生在网卡或上游设备。这条路的成本最高因为要堆量防御手段也最成熟通常是清洗和扩容。第二条是内核连接表耗尽。这条最隐蔽也最常见。每个连接在内核里都要占一个槽位还有 conntrack 表、监听队列 backlog、accept queue。特征很明确服务 CPU 平稳但ss -s里timewait数量暴涨或者ss -lnt的 Recv-Q 一直贴着 Send-Q 的上限不动——这就是 accept queue 满了新连接被内核直接丢掉客户端看到的是握手超时而不是 502。第三条是应用工作线程或进程池被占满。每个请求进来占一个 workerworker 在等数据库、等下游、或者干脆在 sleep新请求就在队列里排队。特征是 P99 延迟持续爬升但 CPU 利用率上不去因为线程都在阻塞等待不是在算。这条路是应用层拒绝服务的核心也是最容易被忽略的代码里一个没有超时的下游调用等于把 worker 的生死交给了别人。第四条是上游依赖被打穿。你的服务本身很健康但数据库连接池只有 20 个压测流量一进来连接池瞬间耗尽后续请求全部卡在获取连接这一步。表现出来是你的服务慢实际瓶颈在下一跳。把这四条放在一起看就明白了所谓「拒绝服务」不是一种攻击手法是四类资源瓶颈被同时或分别触碰的结果。你做压测的目的就是找出自己的系统最先倒在哪一条上。2.2 所谓「源代码」在不同层次上根本不是一回事网上流传的这类代码多数是按层次分的而每一层的门槛和后果差得很远。最底层是原始套接字自己构造 IP 头和 TCP 头往外发。这类代码要 root 权限要处理校验和、分片、网卡卸载现代内核和中间设备对畸形包的处理早就不是十几年前的样子很多代码跑起来第一件事是被本机防火墙拦掉第二件事是因为源地址不可达而收不到回包。写这类代码的实际收益远低于它带来的麻烦。往上一层是传输层握手。核心思路是只发握手的第一步不收尾让服务端维持大量半开连接把连接表占满。这条路对源地址有要求实现上依赖伪造而现在的入口设备基本都有针对性的队列保护。我不建议在这个方向上花时间不是因为难是因为它离「验证自己系统容量」这个目标太远。再往上是应用层。这一层最讽刺实现门槛低到几十行 HTTP 请求就够一个没有限流、没有超时、没有并发上限的接口用最普通的客户端代码就能把它压到不可用。这也正好说明了防护的必要性——门槛越低越不能指望「没人会这么干」。所以当你看到「源代码」三个字时先问自己一句我要的是能打人的东西还是能度量自己系统的工具如果是后者那你要写的其实是压测客户端。2.3 为什么我坚持用压测代码代替攻击代码三个理由都是踩过之后才明白的。第一是授权。压测是在你自己有权限的地址上跑跑完能光明正大地看日志、看监控、看内核计数。攻击代码跑完你连结果都不敢留存。第二是可度量。压测脚本必须输出速率、延迟分位、错误数这些数字才能和基线对比才能得出结论「这个配置能撑 3000 并发」。攻击代码通常只输出「成功了」没有信息量。第三是可回归。今天调了一个参数明天要验证有没有效果压测脚本可以原样再跑一遍。这才是工程上真正有价值的部分——把一次性的验证变成可以反复执行的动作。后面的章节就按这个思路走先搭一个能被观测的被测服务再写两个版本的压测客户端最后把观测到的现象翻译成具体的配置参数。3. 搭一套最小可复现实验台被测服务、代理层和三个观测开关实验台不用复杂一台机器、三个进程就够被测服务、一层反向代理、压测客户端。分成两段是为了能分别观察「代理层的连接队列」和「应用层的工作线程」这两个瓶颈的位置不一样混在一起就分不清是谁先撑不住。3.1 用三十行代码造一个「慢接口」被测服务要故意留一个慢路径否则你压不出线程占满的效果。下面这段用标准库起一个多线程 HTTP 服务/fast立即返回/slow固定睡 200 毫秒模拟一次下游查询。# server.py —— 被测服务/slow 固定耗时用来观察工作线程被占满 import time, json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DELAY 0.2 # 单请求处理耗时秒模拟一次数据库查询 class Handler(BaseHTTPRequestHandler): protocol_version HTTP/1.1 # 开启长连接才能观察到连接堆积 def do_GET(self): if self.path /fast: body b{ok:true} else: time.sleep(DELAY) # 慢路径占住一个线程 body json.dumps({ok: True}).encode() self.send_response(200) self.send_header(Content-Type, application/json) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass # 压测时关访问日志否则磁盘先被打满 if __name__ __main__: ThreadingHTTPServer((0.0.0.0, 8080), Handler).serve_forever()逻辑说明ThreadingHTTPServer每个连接起一个线程线程数没有上限所以它不会因为线程池小而卡住会一直吃到文件描述符或内存——这正好方便你观察「系统级」而不是「框架级」的极限。protocol_version设成 HTTP/1.1 是为了让连接可以复用否则每个请求都新建连接你测到的全是握手开销。参数说明DELAY是这个实验里最重要的旋钮。设成 0.2 秒意味着单线程每秒只能处理 5 个请求50 个并发就能把吞吐锁死在 250 req/s 左右把它改成 0.01同样的并发能跑到几千。改这个值你能直观看到「瓶颈从线程挪到 CPU」的全过程。3.2 代理层故意把队列设小在被测服务前面挂一层反向代理并且故意把backlog调小是为了让连接队列溢出这件事早点发生、看得见。# nginx.conf —— 代理层故意缩小 accept queue方便观察溢出 worker_processes 1; events { worker_connections 1024; } http { upstream backend { server 127.0.0.1:8080; keepalive 32; # 到后端的连接复用减少 TIME_WAIT } server { listen 8000 backlog128; # 关键故意设小压测时很快能看到 Recv-Q 顶满 location / { proxy_pass http://backend; proxy_read_timeout 5s; # 后端迟迟不回就断开避免连接无限堆积 proxy_connect_timeout 1s; } } }逻辑说明backlog128是这行配置的核心。它决定了内核为这个监听套接字维护的 accept queue 长度。当代理层来不及 accept 时新完成的连接就堆在这个队列里队列满了内核按策略丢弃或不再响应你在客户端看到的是连接超时或 RST而不是 5xx。参数说明keepalive 32是到上游的连接池大小它决定了代理层同时能向后端发起多少条长连接这个值设得太小代理层自己就成了瓶颈。proxy_read_timeout必须设不设的话后端卡住时代理层的连接会一直挂着这是很多线上故障的直接原因。3.3 三个必须提前打开的观测开关没有观测的压测等于白跑。我一般固定看三处第一处是内核连接状态。ss -s看总览重点盯timewait和closed的数量级ss -lnt看监听队列Recv-Q 是当前排队的连接数Send-Q 是上限Recv-Q 长期贴着 Send-Q 就是溢出前兆。第二处是文件描述符。ulimit -n在多数发行版默认是 1024这个值会在你还没打到服务瓶颈之前就把客户端自己限制住。跑之前先ulimit -n 65535跑的时候用ls /proc/$(pgrep -f server.py)/fd | wc -l看服务端实际开了多少。第三处是延迟分位而不是平均值。平均值会把长尾吃掉一个平均 30 毫秒的接口可能 P99 是 3 秒。压测脚本必须自己算 P50/P95/P99或者至少把每次请求的耗时都记录下来。3.4 基线先知道正常长什么样正式压之前先用 1 个并发跑 60 秒把这条曲线记下来。单并发下/slow的延迟应该稳定在 200 毫秒出头吞吐 5 req/s 左右错误 0。这个数字没有任何意义但它是标尺——后面并发升到 50、200、1000 时你才能判断延迟的上升是线性的正常排队还是突然拐弯撞到瓶颈。基线还有一个作用验证你的客户端脚本本身没问题。如果单并发就跑出 30% 的错误率那问题在脚本不在被测服务。4. 压测客户端怎么写线程版摸上限异步版推千级并发客户端脚本要满足三个条件能控制并发数、能控制持续时间、能输出分位延迟。线程版写起来最简单适合把并发从 1 推到几百异步版在单机上能推到几千连接适合测连接队列。两个都给你。4.1 线程版五十行拿到第一组数字# load_sync.py —— 线程版N 个线程各自循环发请求适合几百并发 import threading, time, http.client TARGET (127.0.0.1, 8000) PATH /slow CONCURRENCY 50 # 并发线程数 DURATION 30 # 持续秒数 TIMEOUT 3 # 单请求超时必须设否则卡死的连接会拖垮整个脚本 lat, err [], [0] lock threading.Lock() def worker(stop_at): # 每线程一条长连接避免把 TCP 握手算进延迟 conn http.client.HTTPConnection(*TARGET, timeoutTIMEOUT) while time.time() stop_at: t0 time.perf_counter() try: conn.request(GET, PATH) resp conn.getresponse() resp.read() with lock: lat.append(time.perf_counter() - t0) except Exception: with lock: err[0] 1 conn.close() # 出错的连接不能复用 conn http.client.HTTPConnection(*TARGET, timeoutTIMEOUT) conn.close() stop_at time.time() DURATION threads [threading.Thread(targetworker, args(stop_at,)) for _ in range(CONCURRENCY)] for t in threads: t.start() for t in threads: t.join() lat.sort() n len(lat) print(f样本 {n} 错误 {err[0]}) if n: p lambda q: lat[min(int(n * q), n - 1)] * 1000 print(fP50 {p(0.50):.1f}ms P95 {p(0.95):.1f}ms P99 {p(0.99):.1f}ms) print(f实际吞吐 {n / DURATION:.0f} req/s)逻辑说明每个线程维护一条长连接循环发请求测的是「服务端同时处理 N 个请求」的能力而不是「每秒能建立多少连接」。所有线程共享同一个stop_at保证同时开始同时结束否则样本会被早退出的线程污染。延迟统计用perf_counter而不是time.time后者在有些平台上精度只有毫秒级。参数说明CONCURRENCY是主旋钮。从 10 开始每次翻倍观察吞吐什么时候不再上涨、P99 什么时候开始拐弯——那个点就是当前配置的容量上限。TIMEOUT一定要小于服务端的proxy_read_timeout否则你会看到大量「客户端还在等、服务端已经断开」的假错误。DURATION不少于 30 秒太短的话 JIT 预热、连接池建立这些一次性开销会污染结果。这个版本的天花板在 GIL 和线程切换上。实测单机推到 500 线程左右客户端自己的 CPU 就吃满了此时你测到的是客户端的极限不是服务端的。要突破这个限制得换异步。4.2 异步版把并发从百级推到千级# load_async.py —— 异步版单进程推几千连接每次新建连接以压 accept queue import asyncio, time HOST, PORT 127.0.0.1, 8000 CONCURRENCY 2000 # 同时保持的在途请求数 DURATION 30 CONN_TIMEOUT 2 # 建连超时压队列时必须设小 READ_TIMEOUT 5 async def one(sem, lat, err, stop_at): while time.time() stop_at: async with sem: t0 time.perf_counter() try: # 每个请求新建连接这条路径才会打到 listen backlog r, w await asyncio.wait_for( asyncio.open_connection(HOST, PORT), CONN_TIMEOUT) w.write(bGET /slow HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n) await w.drain() await asyncio.wait_for(r.read(), READ_TIMEOUT) w.close() lat.append(time.perf_counter() - t0) except Exception: err[0] 1 async def main(): lat, err [], [0] sem asyncio.Semaphore(CONCURRENCY) # 用信号量精确控制在途数量 stop_at time.time() DURATION await asyncio.gather(*[one(sem, lat, err, stop_at) for _ in range(CONCURRENCY)]) lat.sort() n len(lat) print(f样本 {n} 错误 {err[0]}) if n: p lambda q: lat[min(int(n * q), n - 1)] * 1000 print(fP50 {p(0.50):.1f}ms P95 {p(0.95):.1f}ms P99 {p(0.99):.1f}ms) print(f实际吞吐 {n / DURATION:.0f} req/s) asyncio.run(main())逻辑说明每个协程在拿到信号量之后才建连接、发请求、读完、关闭形成一个完整的短连接生命周期。这里刻意不复用连接因为你要测的如果是代理层的 accept queue就必须持续制造新连接复用连接测的是并发处理能力两者不是一回事。参数说明CONCURRENCY在这里的含义变了——线程版是「同时有 N 个线程在跑」异步版是「同时在途的请求数是 N」。CONN_TIMEOUT必须设小压队列时大量连接会卡在握手上超时设成 10 秒的话脚本会一直挂着不返回。Connection: close是关键去掉它连接会被复用队列就压不出来了。跑之前记得ulimit -n 655352000 并发意味着客户端至少需要 2000 个文件描述符加上内核的 TIME_WAIT 占用1024 的默认值会在第 1000 个连接时直接报Too many open files。4.3 四个参数怎么定并发、时长、超时、是否复用参数线程版含义异步版含义调整策略并发数线程数在途请求数从 10 开始每次翻倍直到吞吐不再上涨持续时间总时长总时长不少于 30 秒稳态至少占 80%单请求超时连接读取建连/读取分开建连超时必须小于代理层 connect_timeout连接复用默认复用默认不复用测处理能力复用测队列不复用这张表的用法是先固定「复用 线程版」测出服务端的处理容量再切到「不复用 异步版」测出连接队列的承受能力。两组数字往往差一个数量级两个都要有因为它们对应完全不同的防护手段。5. 避坑与排查五个把结论带偏的典型现象5.1 客户端 CPU 先满被测服务毫无压力现象并发加到 200客户端机器 CPU 飙到 100%服务端监控曲线纹丝不动吞吐停在几百。原因线程版的 GIL 加上每次请求的 Python 层解析客户端自身的开销已经超过服务端。你测的是自己的脚本。解决把压测脚本和被测服务放到两台机器上中间走真实网络或者直接切到异步版单进程的协程开销远低于线程。判断方法很简单压测时看客户端top如果它比服务端还忙结论就不可信。5.2 并发设了 2000实际吞吐只有 300但 P99 很低现象脚本报的 P99 只有几十毫秒看起来很健康但吞吐上不去错误数却是 0。原因文件描述符被限制了。异步版在open_connection时如果 fd 不够会抛异常被except吞掉错误计数没加样本也没进延迟数组于是看起来「又快又没错误」实际有大量请求根本没发出去。解决先把err[0]和样本数打印出来做交叉验证——正常情况样本数应该约等于「吞吐 × 时长」。开跑前ulimit -n 65535跑的过程中用ls /proc/pid/fd | wc -l确认实际用量。5.3 TIME_WAIT 堆到几万端口不够用现象跑了几轮之后报Cannot assign requested addressss -s里 timewait 数量几万。原因短连接由客户端主动关闭每条连接都会在客户端侧进入 TIME_WAIT 并占一个本地端口默认 60 秒才释放。两万个连接意味着两万个端口被占住本地端口范围根本不够。解决测处理能力时改成连接复用确实要测短连接就把轮次之间留足间隔或者把net.ipv4.ip_local_port_range调宽。不建议随手改 TIME_WAIT 相关的内核开关它会掩盖真实问题等上线后照样翻车。5.4 压测一停服务反而挂了现象脚本停止的瞬间服务端的错误率飙升几秒后才恢复。原因压测期间请求在队列里堆积脚本一停队列里的请求被集中处理瞬时压力反而高于压测期间。这个现象在 accept queue 被打满时特别明显。解决不要瞬间停。把并发分成几档逐步降下来每档停 10 秒。同时观察ss -lnt的 Recv-Q降到个位数再完全停止。这个细节决定了你的压测会不会反过来把测试环境搞挂。5.5 指标很好看线上照样出事现象压测报告写着 P99 80 毫秒、零错误上线后高峰期照样超时。原因压测的请求间隔太均匀。真实流量是有突发的——秒杀、定时任务、批量导入瞬间能到平均值的十几倍。均匀压测只能测出平均容量测不出突发承受力。解决在脚本里加一个突发模式比如每 10 秒把所有并发在 200 毫秒内打出去一次其余时间按正常速率跑。这个模式下 P99 会难看很多但那个数字才接近真实。6. 从压测数字到防护参数限流、队列和观测的落地方式压测跑完你手上应该有两组数字服务端能扛多少并发处理、连接队列能排多长。这两个数字直接对应两类配置别混着调。第一类是入口限流对应「别让请求无限制进来」。在代理层加两层限制按来源的请求速率、按来源的并发连接数。# 在 http 块里定义限流区 http { limit_req_zone $binary_remote_addr zoneperip:10m rate20r/s; limit_conn_zone $binary_remote_addr zoneconn:10m; server { listen 8000 backlog1024; # 队列按压测结果给别照抄默认值 location /slow { limit_req zoneperip burst40 nodelay; # 允许 40 个突发超出直接拒 limit_conn conn 50; # 单来源最多 50 条并发 proxy_pass http://backend; } } }rate20r/s是稳态速率burst40是允许的突发量nodelay表示突发额度内的请求立即放行而不是排队——不加这三个参数限流本身会变成一个延迟源。limit_conn 50要和压测出来的单来源并发上限对齐设太大等于没设设太小正常用户会被误伤。这两个值我一般按「压测容量的 70%」给留出余量。第二类是内核队列对应「撑住瞬时冲击」。backlog要和net.core.somaxconn一起看两个取小值生效只改一个等于没改。# 临时生效写进 /etc/sysctl.conf 才是持久化 sysctl -w net.core.somaxconn4096 # accept queue 上限 sysctl -w net.ipv4.tcp_max_syn_backlog8192 # 半开连接队列上限 ulimit -n 65535 # 文件描述符服务进程也要设注意这几个数字是缓解不是根治。队列加长只是把「拒绝」推迟了几秒真正决定系统能不能扛住的是处理速率——队列再长只要消费速度低于到达速度早晚还是满。我见过太多把somaxconn调到 65535 就以为解决了的配置高峰期照样 502。第三类是观测。压测时盯的三个指标上线后要变成常态告警监听队列的 Recv-Q 与 Send-Q 比值持续超过 0.8、服务的 P99 连续三分钟超过基线的两倍、工作线程或协程的在途数接近上限。这三个信号出现的顺序通常就是故障发生的顺序早一步发现就能早一步扩容或限流。这套东西我做过几轮之后最大的教训是别在压测报告里只写「支持多少并发」。那个数字脱离了延迟分位和错误率毫无意义而且换个接口就失效。真正能沉淀下来的是脚本本身——同一份脚本改个路径、改个并发随时能对新接口跑一遍十分钟出结论。我现在接手任何新服务第一件事不是看代码是把这块的实验台搭起来跑一遍基线。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站