pglass / how-do-i-locust

A quick overview of Locust, the load testing tool

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How do I Locust.

Locust generates load and does pretty much nothing else. It comes with a web server with UI controls out of the box. It can also run in a single-master, multiple-worker configuration in which the workers generate load and the master aggregates reports from the workers.

You write a locust file, which is just regular python file

# a descriptively named locust file
something.py

Put a task set in your locust file. Each task typically contains a particular API operation.

import locust
class MyTaskSet(locust.TaskSet):

    @locust.task
    def do_get(self):
        self.client.get('/resources/{id}')

Then define other test parameters, including the api endpoint to hit and the wait time:

class MyLocust(locust.HttpLocust):
    task_set = MyTaskSet

    min_wait = 900
    max_wait = 1100

    host = 'http://localhost:8000'

The min/max wait times control the amount of time each simulated user waits between executing tasks. Each user will execute a task at random, wait a random amount of time between min_wait and max_wait, and then repeat.

At this point, you can start locust's web server:

$ locust -f something.py

Then you can start the test in one of two ways:

  1. Go to localhost:8089 in your browser and type in numbers and click start.
  2. POST localhost:8089/swarm with {"locust_count": 3, "hatch_rate": 1}

The locust count is the total number of users to spawn. The hatch rate is the number of users to spawn per second, starting from zero when load generation first begins. (The "hatching" period is the rampup period from when the test first starts until the max number of users is reached.)

Each user does the following:

  1. Pick one of the tasks from your locust file
  2. Run the task (execute that task function)
  3. Pick a random wait time between min_wait and max_wait (specified in your locust file)
  4. Wait that amount of time
  5. Repeat from 1

But how do I use workers?

Locust can be run in a single-master, multiple-worker configuration. The workers do all the load generation, while the master controls and monitors. Every (by default) 3 seconds, a worker sends a single report for all requests made on that worker in the last 3 seconds. The master receives these reports from all of its workers and consolidates them in real time.

The master controls the starting and stopping of load generation on the workers. The master cannot start/stop the locust process running on the workers. This means you need to create servers and start locust's processes yourself.

To start the master process:

$ locust -f something.py --master

You must start the master process before the workers. Then start the workers:

$ locust -f something.py --worker --master-host=<master-ip>

You should be able see the clients connect/disconnect in the master's logs.

Random tips

Detecting whether you're the master or worker

It seems normal to use the same locust file on the master and worker (I've never tried using a master locust file and a separate, different worker locust file. Even if it works, you lose the ability to run your tests in non-distributed mode.)

But sometimes I wanted to do certain things only on the master (like saving a report to disk) and some things only on the worker (like doing some data prep before starting load generation).

From what I can tell, locust doesn't provide a way to detect which mode you're running in. What I did was check for the command line arguments:

import sys

def is_master():
    return '--master' in sys.argv

def is_worker():
    return '--worker' in sys.argv
Event hooks

Locust has some nifty event hooks that let you execute a function when that event is fired. Locust has some built-in events, or you can create your own:

import locust.events

my_event = locust.events.EventHook()

def handler1(a, b): print "handled", a, b
def handler2(a, b): print "sum", a + b

my_event += handler1
my_event += handler2

# invokes each event handler in the order you inserted them
# note that you have to use keyworded arguments when you call .fire()
my_event.fire(a=1, b=2)
Pre-test actions

There are several different points at which you can do something before the test starts. Some of these are kind of non-obvious, and I ran into situations where a setup task was being run every time a new user was spawned (during rampup) instead of just when load generation started. Anyway, these are the points at which you can do something before/after your test:

  1. At process startup: You can do whatever you like in the global scope of your locust file, but this will only occur once for the entire time your locust process is running. I read config files and do other "framework" setup, like registering event handlers or preparing integration with another service.
import locust
# read the config file once, at the start of the locust process
CONF = get_config()
# only register this event handler once
locust.events.locust_start_hatching += get_auth_token

class MyTasks(locust.TaskSet):
    ...
  1. At test start: You click the start button to start load generation You can hook into this by attaching event handlers to the locust.events.locust_start_hatching event:
def do_thing():
    # do a thing
locust.events.locust_start_hatching += do_thing

This will call do_thing exactly once when you press the start button. If you're running locust in master/worker mode, then locust.events.locust_start_hatching fires only on workers, and locust.events.master_start_hatching fires only on the master.

  1. At user spawn time: You can do per-user setup in two places. Locust will call the on_start method when a Locust user is started:
class MyTasks(locust.TaskSet):
    def on_start(self):
        # Each locust user gets a different id
        self.random_id = str(uuid.uuid4())

Locust creates a new instance of your TaskSet class once per user, so you can also do setup in the class constructor (I think this is less preferred):

class MyTasks(locust.TaskSet):
    def __init__(self, *args, **kwargs):
        super(MyTasks, self).__init__(*args, **kwargs)
        # Each locust user gets a different id
        self.random_id = str(uuid.uuid4())
Grouping requests in the report

By default, locust will use /resources/{uuid} as the name that shows up in the summary report. But if your the url has an id in it, you'll get a separate entry in locust's report for each id.

Instead, you can provide an explicit name argument for Locust to use in its report:

@locust.task
def do_get(self):
    self.client.get('/resources/{id}', name='/resources/UUID')
Manually marking requests as success/failure

Normally, locust automatically records a success or failure by looking at the http status code. You can use a with block to override this:

with self.client.post('/things', catch_response=True) as post_resp:
    if post_resp.json()['status'] == 'ACTIVE':
        post_resp.success()
    elif post_resp.json()['status'] == 'ERROR':
        post_resp.failure("Saw ERROR status")
    else:
        post_resp.failure("Unknown error")
Achieving precise request rates

You can compute the approximate request rate using the average wait time and the total number of users. This will be inaccurate though, because it doesn't take into account the time each user spends executing a task. This is a problem when you have long running tasks (e.g. if you need to poll).

For example, consider a long running task like this:

@task
def do_sleep(self):
    gevent.sleep(60)

A locust user will enter this task and sleep for 60 seconds. If you have a small number of users, chances are they'll all eventually get stuck sleeping in this task, unable to continue executing other tasks, so your request rates will plummet. (Due to reasons, you should actually use gevent.sleep() instead of time.sleep(). See the next section for more on this.)

Instead, you want the task to take as short a time as possible. To do this, you can spawn a new greenlet that sleeps in the background:

def _do_async_thing_handler(self):
    gevent.sleep(60)

@task
def do_async_thing(self):
    gevent.spawn(self._do_async_thing_handler)

This accomplishes two things:

  1. Your task (do_async_thing) returns effectively immediately, and the locust user can go on to do other things. This means your actual request rate should much be closer to what you expect.
  2. Your task's functionality (_do_async_thing_handler) continues running in the background and will terminate on its own.
How do I sleep

With gevent, you should use gevent.sleep instead of time.sleep to avoid "blocking the world", or causing all your task functions to block/sleep. Why? Because gevent operates entirely within a single OS thread. Calling time.sleep actually sleeps the thread, which means gevent cannot continue executing your task functions.

However, Locust runs gevent's monkey patching, during which gevent replaces certain functions from the Python standard library with gevent-friendly versions. During this monkey patching step, time.sleep is replaced with gevent.sleep. After monkey patching occurs, time.sleep is the gevent.sleep function and you have nothing to worry about.

But, how do I not block the world? Either,

  • Call gevent.sleep explicitly within your Locust task functions and other Locust-related code, OR
  • Ensure gevent is monkey patched, so that time.sleep can be used safely. Locust runs monkey patching for you, but you must have performed an import locust prior to using time.sleep. (You can also call gevent.monkey.patch_all() explicitly yourself, if you need to).

Monkey patching gevent is a must when you are using an external library that can't be modified to use gevent.sleep.

Asynchronous polling

You can use a similar pattern as above to poll for a status. One thing to watch out for is having too many greenlets running in the background on a single machine (you can lower the polling frequency or run in distributed mode to help manage this).

First, I added functions to report the result of asynchronous operations to Locust. These use the native request_success and request_failure events.

def async_success(name, start_time, resp):
    locust.events.request_success.fire(
        request_type=resp.request.method,
        name=name,
        response_time=int((time.monotonic() - start_time) * 1000),
        response_length=len(resp.content),
    )

def async_failure(name, start_time, resp, message):
    locust.events.request_failure.fire(
        request_type=resp.request.method,
        name=name,
        response_time=int((time.monotonic() - start_time) * 1000),
        exception=Exception(message),
    )

Then, I implemented polling logic in an async "handler" function and called the async_success/async_failure functions above when polling is complete. These calculate the total elapsed time for the async operation, and report the result to Locust. I also added the usual @task function to spawn the async handler function in the background.

def _do_async_thing_handler(self, timeout=600):
    post_resp = self.client.post('/things')
    if not post_resp.ok:
        return
    id = post_resp.json()['id']

    # Now poll for an ACTIVE status
    start_time = time.monotonic()
    end_time = start_time + timeout
    while time.monotonic() < end_time:
        r = self.client.get('/things/' + id)
        if r.ok and r.json()['status'] == 'ACTIVE':
            async_success('POST /things/ID - async', start_time, post_resp)
            return
        elif r.ok and r.json()['status'] == 'ERROR':
            async_failure('POST /things/ID - async', start_time, post_resp,
                          'Failed - saw ERROR status')
            return

        # IMPORTANT: Sleep must be monkey-patched by gevent (typical), or else
        # use gevent.sleep to avoid blocking the world.
        time.sleep(1)
    async_failure('POST /things/ID - async', start_time, post_resp,
                  'Failed - timed out after %s seconds' % timeout)

@task
def do_async_thing(self):
    gevent.spawn(self._do_async_thing_handler)

The key things here are:

  1. Spawning a new greenlet in your @task function so that other Locust tasks are not blocked by the long-running polling loop. This ensures that the do_async_thing task is executed at a predictable (request) rate, that will not be affected by the time spent in the polling loop.
  2. Calling the async_success/async_failure functions to compute the duration until the async operation was finished. These used the request_success/request_failure event hooks to report the result to Locust, which is included in the request metrics.

(Another issue here: When I stop load generation, any greenlets running in the background will continue running - until the greenlets finish running, or until the process is terminated. I tracked my greenlets in a list and used an event hook to kill them all when the test is stopped.)

About

A quick overview of Locust, the load testing tool