为什么要进行负载测试
随着 IT 基础架构的不断演进,API 逐渐承载大多数的数据交换。通过负载测试,可以在将 API 发布给用户之前,测试系统的吞吐量上限,以发现设计上的错误或验证系统的负载能力。
通过负载测试,可以得到系统的性能和稳定性数据,帮助开发人员和系统管理员优化和改进系统,提高系统的可靠性和性能,提升用户体验。
Locust 是什么
Locust 是易于使用的、分布式的用户负载测试工具,可以用于 Web 站点或其它系统的负载测试,最后计算出系统能够处理多少并发用户。
Locust 是事件驱动的(使用 gevent),这使得它能够在单进程中处理数以千计的并发用户。虽然可能存在其它在给定的硬件上每秒能发起更多请求的工具,但是每个 Locust 用户的低开销,使它非常适合测试高并发的工作负载。
Locust 的特性
使用 Python 编写测试脚本
Locust 非常小,非常容易 Hack,IO 事件和协程这些重活都被委派给 gevent
安装
pip install locust
easy_install locust
安装 Locust 后,可以在 Shell 中使用 locust 命令。通过运行下面的命令查看可用的选项:
locust --help
每个 HTTP 连接都打开一个新文件描述符。操作系统可能给每个用户所能打开的最大文件数量设置一个较低的限制,如果该限制少于测试中模拟的用户数,将发生失败。因此应该将操作系统默认的最大文件描述符数量增加到比模拟的用户数更大的值。具体如何做,依赖于使用的操作系统。
快速入门
下面是一个简单的例子:
from locust import HttpUser, TaskSet
def login(l):
l.client.post("/login", {"username":"ellen_key", "password":"education"})
def index(l):
l.client.get("/")
def profile(l):
l.client.get("/profile")
class UserBehavior(TaskSet):
tasks = {index: 2, profile: 1}
def on_start(self):
login(self)
class WebsiteUser(HttpUser):
tasks = [UserBehavior]
min_wait = 5000
max_wait = 9000
上述代码中定义多个 Locust 任务,Locust 任务是带有一个参数(TaskSet 实例)的 Python 可调用对象,这些任务被收集到 TaskSet 类的 tasks 属性中。HttpUser 类代表模拟的用户,在这个类中,我们定义模拟的用户在两次执行任务之间应该等待多久。TaskSet 类用于定义用户的行为,TaskSet 可以嵌套 TaskSet。
from locust import HttpUser, TaskSet, task
class UserBehavior(TaskSet):
def on_start(self):
""" on_start is called when a Locust start before any task is scheduled """
self.login()
def login(self):
self.client.post("/login", {"username":"ellen_key", "password":"education"})
@task(2)
def index(self):
self.client.get("/")
@task(1)
def profile(self):
self.client.get("/profile")
class WebsiteUser(HttpUser):
tasks = [UserBehavior]
min_wait = 5000
max_wait = 9000
User 以及 HttpUser(HttpUser 是 User 的子类)支持为每个模拟用户指定在两次执行任务之间等待的最小和最大时间(min_wait 和 max_wait),以及其它用户行为。
启动 Locust
如果上面的文件被命名为 locustfile.py,那么可以在同级目录下,使用如下命令来运行 Locust:
locust --host=http://example.com
如果 locust file 被放在其它地方,我们可以运行:
locust -f ../locust_files/my_locust_file.py --host=http://example.com
locust -f ../locust_files/my_locust_file.py --master --host=http://example.com
locust -f ../locust_files/my_locust_file.py --worker --host=http://example.com
locust -f ../locust_files/my_locust_file.py --worker --master-host=192.168.0.100 --host=http://example.com
打开 Locust 的 Web 接口
启动 Locust 后,可以打开浏览器,访问 http://127.0.0.1:8089,将看到 Locust 的欢迎页面:
编写 locustfile
locustfile 是普通 Python 文件,唯一的要求是它至少要声明一个类 --- 我们管它叫 user 类 --- 它继承自 User 类。
user 类
当使用下面的 locustfile 时,用户在两次执行任务之间将等待 5 - 15 秒:
from locust import User, TaskSet, task
class MyTaskSet(TaskSet):
@task
def my_task(self):
print("executing my_task")
class MyUser(User):
tasks = [MyTaskSet]
min_wait = 5000
max_wait = 15000
也可以在 TaskSet 类中重写 min_wait 和 max_wait 。
可以像这样从相同的文件中运行两个 user:
locust -f locust_file.py WebUser MobileUser
class WebUser(User):
weight = 3
....
class MobileUser(User):
weight = 1
....
TaskSet 类
from locust import User, TaskSet, task
class MyTaskSet(TaskSet):
@task
def my_task(self):
print("User instance (%r) executing my_task" % (self.user))
class MyUser(User):
tasks = [MyTaskSet]
from locust import User, TaskSet, task
class MyTaskSet(TaskSet):
min_wait = 5000
max_wait = 15000
@task(3)
def task1(self):
pass
@task(6)
def task2(self):
pass
class MyUser(User):
tasks = [MyTaskSet]
from locust import User, TaskSet
def my_task(l):
pass
class MyTaskSet(TaskSet):
tasks = [my_task]
class MyUser(User):
tasks = [MyTaskSet]
如果 tasks 属性被指定为列表,那么将随机地从列表中选择将要被执行的任务;如果 tasks 是键为可调用对象,值为整型的字典,那么将使用整型作为比例,随机地选取将要被执行的任务。
{my_task: 3, another_task: 1}
Main user behaviour
Index page
Forum page
Read thread
Reply
New thread
View next page
Browse categories
Watch movie
Filter movies
About page
class ForumPage(TaskSet):
@task(20)
def read_thread(self):
pass
@task(1)
def new_thread(self):
pass
@task(5)
def stop(self):
self.interrupt()
class UserBehaviour(TaskSet):
tasks = {ForumPage: 10}
@task
def index(self):
pass
在上面的例子中,当 UserBehaviour 执行时,ForumPage 将被选择执行,也就是 ForumPage 将开始执行。ForumPage 将从它自己的任务中选择一个,并且执行它,然后等待,等等。
class MyTaskSet(TaskSet):
@task
class SubTaskSet(TaskSet):
@task
def my_task(self):
pass
on_start() 方法
生成 HTTP 请求
class HttpUser:
代表被“孵化”出来的、用于“攻击”要进行负载测试的系统的 HTTP 用户。
用户行为由 tasks 属性来定义。
这个类在初始化时,将创建 client 属性,client 属性是支持在请求之间保持用户会话的 HTTP 客户端。
client = None
from locust import HttpUser, TaskSet, task
class MyTaskSet(TaskSet):
@task(2)
def index(self):
self.client.get("/")
@task(1)
def about(self):
self.client.get("/about/")
class MyUser(HttpUser):
tasks = [MyTaskSet]
min_wait = 5000
max_wait = 15000
用心的读者可能会觉得很奇怪:在 TaskSet 内部我们使用 self.client 而非 self.user.client 引用 HttpSession 实例,我们能这么做是因为 TaskSet 类有一个便捷的、被称作 client 的属性,它简单地返回 self.user.client。
使用 HTTP 客户端
每个 HttpUser 实例都有一个指向 HttpSession 实例的 client 属性。HttpSession 类其实是 requests.Session 的子类,能够使用 get、post、put、delete、head、patch 和 options 方法生成 HTTP 请求,并且响应被报告到 Locust 的统计中。HttpSession 实例在请求之间保持 Cookie,以便它能登陆网站,在请求之间保持会话。也可以从 User 实例的 TaskSet 实例直接引用 client 属性,以便在任务内部,能够很容易地取出 client,生成 HTTP 请求。
下面是一个简单的例子,用于生成到 /about 的 GET 请求(在这个例子中,我们假定 self 是 TaskSet 或 HttpLocust 实例):
response = self.client.get("/about")
print("Response status code:", response.status_code)
print("Response content:", response.content)
response = self.client.post("/login", {"username": "testuser", "password": "secret"})
with client.get("/", catch_response=True) as response:
if response.content != "Success":
response.failure("Got wrong response")
正如可以把响应码为 OK 的请求标记为失败,也可以使用 catch_response
参数和 with 语句将返回错误状态码的请求在统计中报告为成功:
with client.get("/does_not_exist/", catch_response=True) as response:
if response.status_code == 404:
response.success()
将到具有动态参数的 URL 的请求进行分组
对于大多数网站来说,拥有 URL 中包含某种动态参数的页面非常普遍。通常在 Locust 的统计中,将这些 URL 分成一组非常有意义。可以通过给 HttpSession 实例的请求方法传递 name 参数的方式,来完成这件事。比如:
# Statistics for these requests will be grouped under: /blog/?id=[id]
for i in range(10):
client.get("/blog?id=%i" % i, name="/blog?id=[id]")
分布式地运行 Locust
例子
以 Master 模式启动 Locust:
locust -f my_locustfile.py --master
locust -f my_locustfile.py --worker --master-host=192.168.0.14
选项
和 --master 一起使用,决定 master 节点监听哪个网络端口(默认是5557)。Locust 既使用指定的端口号,又使用指定的端口号 + 1,因此如果将该选项设置为 5557,那么 Locust 既使用 5557,也使用 5558
测试非 HTTP 系统
示例:编写 XML-RPC User/Client
假定我们有一个想要进行负载测试的 XML-RPC 服务:
import random
import time
from xmlrpc.server import SimpleXMLRPCServer
def get_time():
time.sleep(random.random())
return time.time()
def get_random_number(low, high):
time.sleep(random.random())
return random.randint(low, high)
server = SimpleXMLRPCServer(("localhost", 8877))
print("Listening on port 8877...")
server.register_function(get_time, "get_time")
server.register_function(get_random_number, "get_random_number")
server.serve_forever()
可以通过包装 xmlrpc.client.ServerProxy 构建通用的 XML-RPC 客户端:
import time
from xmlrpc.client import ServerProxy, Fault
from locust import User, task
class XmlRpcClient(ServerProxy):
"""
XmlRpcClient is a wrapper around the standard library's ServerProxy.
It proxies any function calls and fires the *request* event when they finish,
so that the calls get recorded in Locust.
"""
def __init__(self, host, request_event):
super().__init__(host)
self._request_event = request_event
def __getattr__(self, name):
func = ServerProxy.__getattr__(self, name)
def wrapper(*args, **kwargs):
request_meta = {
"request_type": "xmlrpc",
"name": name,
"start_time": time.time(),
"response_length": 0, # calculating this for an xmlrpc.client response would be too hard
"response": None,
"context": {}, # see HttpUser if you actually want to implement contexts
"exception": None,
}
start_perf_counter = time.perf_counter()
try:
request_meta["response"] = func(*args, **kwargs)
except Fault as e:
request_meta["exception"] = e
request_meta["response_time"] = (time.perf_counter() - start_perf_counter) * 1000
self._request_event.fire(**request_meta) # This is what makes the request actually get logged in Locust
return request_meta["response"]
return wrapper
class XmlRpcUser(User):
"""
A minimal Locust user class that provides an XmlRpcClient to its subclasses
"""
abstract = True # dont instantiate this as an actual user when running Locust
def __init__(self, environment):
super().__init__(environment)
self.client = XmlRpcClient(self.host, request_event=environment.events.request)
# The real user class that will be instantiated and run by Locust
# This is the only thing that is actually specific to the service that we are testing.
class MyUser(XmlRpcUser):
host = "http://127.0.0.1:8877/"
@task
def get_time(self):
self.client.get_time()
@task
def get_random_number(self):
self.client.get_random_number(0, 100)
事件钩子
Locust 有许多事件钩子,可以使用它们以不同方式扩展 Locust。
下面的例子展示如何设置在每个请求完成后触发的事件监听器:
from locust import events
@events.request.add_listener
def my_request_handler(request_type, name, response_time, response_length, response,
context, exception, start_time, url, **kwargs):
if exception:
print(f"Request to {name} failed with exception {exception}")
else:
print(f"Successfully made a request to: {name}")
print(f"The response was {response.text}")
查看 Event Hooks(http://docs.locust.io/en/latest/api.html#events) 获取全部可用的事件列表。
增加 Web 路由
from locust import events
@events.init.add_listener
def on_locust_init(environment, **kw):
@environment.web_ui.app.route("/added_page")
def my_added_page():
return "Another page"
关于Portal Lab
星阑科技 Portal Lab 致力于前沿安全技术研究及能力工具化。主要研究方向为API 安全、应用安全、攻防对抗等领域。实验室成员研究成果曾发表于BlackHat、HITB、BlueHat、KCon、XCon等国内外知名安全会议,并多次发布开源安全工具。未来,Portal Lab将继续以开放创新的态度积极投入各类安全技术研究,持续为安全社区及企业级客户提供高质量技术输出。