Locustfile 작성하기

본문은 locustio, 즉 locust 1.0이하를 기준으로 작성되었습니다. 새로운 버젼에서는 호환이 되지 않음을 알려드립니다. 현재기준 최신버젼, locust 1.0.3을 소개한 문서를 참고하세요.

Locustfile은 일반 파이썬 프로그램 파일입니다. 특별히 다른점 하나는 최소 하나이상의 class가 정의 되어있어야한다는 점이에요. 앞으로 이 class를 locust class라고 부를게요. 이 class는 Locust라는 class를 상속받아서 만들어져야합니다.

Locust class

여러분이 만드실, 이 locust class는요, 하나의 사용자를 통해서 호출되어 지는 명령함수들을 모아놓은거라고 보시면 되는데요. 사실 Locust 실행전에 동시접속자수를 여러명으로 설정해서 traffic을 조정할수 있는데 굳이 하나의 사용자라는 용어를 사용한 이유는 바로, Locust가 이 class를 호출 하는 과정에서 사용자 한명당, 하나의객체(instance)를 만들어서 실행을 하기 때문에 그렇게 말한거에요. 자 그러면 이 Locust Class에 어떤 것들이 보통 정의가 되는지 알아보자구요.

여러분이 만드실, 이 locust class는요, 하나의 사용자를 통해서 호출되어 지는 명령함수들을 모아놓은거라고 보시면 되는데요. 사실 Locust 실행전에 동시접속자수를 여러명으로 설정해서 traffic을 조정할수 있는데 굳이 하나의 사용자라는 용어를 사용한 이유는 바로, Locust가 이 class를 호출 하는 과정에서 사용자 한명당, 하나의객체(instance)를 만들어서 실행을 하기 때문에 그렇게 말한거에요. 자 그러면 이 Locust Class에 어떤 것들이 보통 정의가 되는지 알아보자구요.

task_set

클래스 변수로 task_set이란게 있는데요. 얘는 TaskSet class를 가지고 있는 애에요. TaskSet은 앞으로 이야기할건데요, 간단하게 말하자면 사용자가 어떤 행동을 하도록 할것인지에 대한 자세한 내용들을 담은 클래스에요.

wait_time

wait_time은 Locust class의 클래스 함수에요. 이건요 task하나 하고나서 다음 task하기 전에 얼마나 기다릴지를 알려주는 함수인데요, Locust에 정의된 함수들 중에는 wait_time함수를 반환해 주는 애들도 있어요. 그중에 대표적인게 between이라는 함순데요. 얘는 task를 하나 실행하고나서 넘겨받은 min하고 max시간 사이에서 시간을 random으로 가져와서 다음 task시작하기 전에 그만큼 기다려주는 애에요. 시간을 정해서 wait_time으로 만들어주면 Locust class가 알아서 중간에 그만큼 쉬어줘요. 그밖에 기다려주는 함수로는 constantconstant_pacing이 있어요.

아래의 예제를 보시면, 각 user별로 task하나가 끝날때마다 5에서 15초를 random으로 기다리게 될거에요.

from locust import Locust, TaskSet, task, between

class MyTaskSet(TaskSet):
    @task
    def my_task(self):
        print("executing my_task")

class User(Locust):
    task_set = MyTaskSet
    wait_time = between(5, 15)

wait_time 함수는요 숫자를 반환하는데요, 바로 “초”단위 숫자입니다. 이 함수는 TaskSet 클래스에서도 정의할수 있는데요, 단 그 경우에는 해당 task에만 적용된답니다.

wait_time함수를 Locust나 TaskSet클래스에 직접 만들어서 구현할수도 있는데요, 아래에 처음에는 1초쉬고, 그다음엔 2초, 3초 이렇게 1초씩 늘어나면서 쉬도록 하는 wait_time함수를 만들어봤어요.

class MyLocust(Locust):
    task_set = MyTaskSet
    last_wait_time = 0
    def wait_time(self):
        self.last_wait_time += 1
        return self.last_wait_time

weight

만약에 locustfile.py안에 Locust 클래스가 하나 이상 정의가 되어있는데, 명령어를 실행할때 어떤걸 쓸지 locusts를 지정하지 않았다면, 기존에 정의된 클래수중에 하나를 random으로 하나 선택해서 실행하게 됩니다. 그게 싫으면 명령어를 날릴때 어떤 locusts를 사용할지 골라서 명시하면됩니다. 아래와 같이 하면 두개중에 하나만 랜덤으로 실행하겠죠?

$ locust -f locustfile.py WebUserLocust MobileUserLocust

그런데 여러분이 두개중에 어떤 특정 클래스를 좀더 자주 호출하고 싶다 그러면요. 그때 사용되어지는게 바로 Locust 클래스 변수, weight이에요. 아래의 예제를 실행하면 WebUserLocust가 MobileUserLocust보다 3배 더 많이 실행하도록 선택될거에요.

class WebUserLocust(Locust):
    weight = 3
    ...
class MobileUserLocust(Locust):
    weight = 1
    ...

host

host는 URL prefix를 저장하는데 사용되는 클래스 변수에요. 보통 실행할때 Locust에서 제공하는 Web UI를 사용해서 설정하기도 하고요, command line으로 실행할때 --host옵션으로 받아다가 Locust.host에 저장하기도 해요.

이게 Locust클래스에 한번 셋팅이 되면요, –host옵션 안주고 다시 호출하거나, 아니면 Web UI에서 없애라는 요청이 올때까지 계속 URL앞에 prefix로 붙여서 작업을 수행합니다.

TaskSet class

만약에 Locust클래스가 한명의 사용자가 아니라, 동시에 여러명이 실행하도록 설정을 했다면, TaskSet이 locust의 두뇌를 대표한다고 볼수 있습니다. 각 Locust클래스는 반드시 task_set 에 가지고 있어야하고, 그값은 TaskSet클래스 여야합니다.

하나의 TaskSet클래스는 이름에서 알수있다시피, task들의 집합이라고 할수 있어요. 이 task들은 일반적으로 python callable함수들이고, 예를 들어 경매사이트를 Load test한다고 치면, 첫페이지를 로딩하고, 그다음에 물건을 찾고, 그다음에 bidding을 하는것과 같은거죠.

Load test가 시작이 되면, 각 instance들은 동시다발적 Locust클래스에 의해서 그들이 원하는 TaskSet을 실행하기 시작하는것이죠. 각 task를 실행하고 나서는, 아까 배웠듯이 Locust.wait_time을 실행해줘서 중간에 쉬는시간을 갖습니다. 기억나시죠? TaskSet.wait_time이 있으면 task수행후에 Locust.wait_time대신에 TaskSet.wait_time가 실행이 된다고 아까 말씀드렸잖아요. 그리고 다 기다리고 나면 다음 task을 찾아서 실행하고, 기다리고를 계속 반복합니다.

Declaring tasks

TastSet에 task들을 정의하는 가장 전형적인 방법이 바로 task 데코레이터를 이용하는 방법입니다. 아래 예제를 보시면요,

from locust import Locust, TaskSet,task
class MyTaskSet(TaskSet):
    @task
    def my_task(self):
        print("Locust instance (%r) executing my_task" % (self.locust))

class MyLocust(Locust):
    task_set = MyTaskSet

데코레이션 @task에 weight을 지정할수도 있습니다. 아래의 예제는 task2를 task1보다 두배 더 돌리는 코드입니다.

from locust import Locust, TaskSet, task
from locust.wait_time import between

class MyTaskSet(TaskSet):
    wait_time = between(5, 15)
    @task(3)
    def task(self):
       pass
    @task(6)
    def task2(self):
        pass

class MyLocust(Locust):
    task_set = MyTaskSet

tasks

사용희 편리성을 위해 @task 데코레이터를 이용해서 task를 정의하는게 편리하기도 하고, 가장 많이 사용되는 방법이기도 합니다. 또다른 방법으로는 TaskSet 클래스안에 tasks를 정의하는 방법도 있습니다. 사실 @task 데코레이터를 이용한 방법은 바로 TaskSet클래스안의 tasks를 가져오는거에요.

tasks변수의 값으로는 하나의 호출가능한 형태의 함수들을 모아놓은 list가 될수도 있구요, 또는 {callsble: int}형태의 dict가 들어갈수 도 있어요. task들 하나의 인자를 받는 호출가능한 형태의 함수입니다. TaskSet클래스의 instance가 그 task를 실행합니다. 아래에 간단한 예제를 봐주세요. 사실 이 locustfile은 아무것도 실행하지 않을거에요.

from locust import Locust, TastSet

def my_task(l):
    pass

class MyTaskSet(TaskSet):
    tasks = [my_task]

class MyLocust(Locust):
    task_set = MyTaskSet

만약 tasks가 list로 정의가 되었다면, 그 안의 각 task가 실행이 될것이구요, 어떤 것이 실행될지는 tasks에 의해서 random하게 선택될거에요. 근데 만약에 tasks가 dict로 정의가 되어있다면 (callable함수를 key로 갖고, 정수를 값으로 갖는 형태), 역시나 task는 random하게 선택이 되겠지만, 값으로 설정된 ratio에 의해 실행되는 빈도수가 달라집니다. 예를들어,

{my_task: 3, another_task: 1}

위와 같이 선언이 되었다고 하면, my_task가 another_task보다 3배정도 더 많이 실행되게 됩니다.

TaskSets can be nested

TaskSet에서 매우 중요한 속성중의 하나가 바로 TaskSet이 nested형태로 제공될수 있다는 점인데요, 사실 실제 웹사이트들은 단편적으로 만들어진게 아니라 단계별로 실행되어져야 하는 경우가 대부분입니다. 예를 한번 들어보자면,

  • 메인 사용자 행동
    • 첫페이지
    • 포럼페이지
      • 포럼 읽기
        • 답변하기
      • 새로운 포럼 작성
      • 다음 페이지 보기
    • 카테고리 페이지
      • 동영상 시청
      • 동영상 검색
    • 소개 페이지

위에서 보시다시피, 어떤 특정 task가 실행된 후에 그 다음 task를 명시해야할 경우가 있는데 그럴때 사용할수 있는 방법이 tasks변수의 호출할수 있는 함수넣을 자리에 함수대신 또다른 TaskSet을 넣는거에요.

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의 tasks가 task를 선택할때, 또다른 TaskSet인 ForumPage를 선택하게 되겠죠, 그러면 그때 ForumPage가 실행되고 다시 ForumPage.tasks가 그 안의 함수들을 실행하게 되는 형태가 되는거에요.

여기서 중요한거 한가지, 위의 코드에서 마지막에 보시면 interrupt라는 함수가 사용된 stop이라는 task가 있죠? 이게 뭐냐면요, 이제 ForumPage.tasks가 그 안의 함수들을 랜덤으로 돌아가면서 실행을 하다가, stop을 실행하게 되면, 그때 바로 ForumPage실행을 멈추고 UserBehaviour로 돌아가서 그 다음 task를 실행하라는 거에요. 만약에 ForumPage어디에도 interrupt()함수가 호출되는곳이 없다면, Locust는 ForumPage task들을 멈추지 않고 영원히 돌리게 될거에요. 그래서 대강 ForumPage에서 사용자들을 얼마정도 있다가 돌아가겠거니 싶을때, task 데코레이션에 각 함수들의 호출 빈도를 지정함으로써 어느정도 포럼에서 머물다가 나와서 다른데도 구경하고 하게끔 interrupt()를 불러주는게 중요해요.

그리고, nested형태의 TaskSet은 @task데코러에터를 이용해서 클래스 안에서 inline으로 구현할수도 있어요. 일단 task를 정의하듯이 그렇게 말에요.

class MyTaskSet(TaskSet):
    @task
    class SubTaskSet(TaskSet):
        @task
        def my_task(self):
            pass

Locust 객체나 TaskSet의 부모객체에 접근하기

TaskSet객체 안에는 locust라는 포인트랑 parent라는 포인트가 있는데요, 얘네들을 이용해서 부모객체나 TaskSet을 호출한 Locust객체에 용이하게 접근할수가 있어요.

TaskSequence클래스

TaskSequence클래스는 일종의 TaskSet이에요. 근데 얘는 특이한점이 task들이 빈도수에 따라 random으로 실행되는게 아니라, 순차적으로 실행을 한다는 점이 일반 TaskSet이랑은 다른점이에요. 구현은 아래와 같이 TaskSequence를 상속받아서 클래스를 정의하고 @seq_task로 각 task함수들을 정의합니다.

class MyTaskSequence(TaskSequence):
    @seq_task(1)
    def first_task(self):
        pass
    @seq_task(2)
    def second_task(self):
        pass
    @seq_task(3)
    @task(10)
    def third_task(self):
        pass

이렇게 정의하면, 위에서 부터 @seq_task순서대로 차례로 실행이 되는데, 추가로 세번째 함수에 @task데코레이터의 값이 10이니까 마지막 함수는 10번 실행합니다. 보시다시피, 하나의 함수에 @seq_task@task 막 섞어서 쓰실수 있으시구요. 또 TaskSets의 nested형태를 구현할때도 TaskSequences랑 위아래 막 섞어서 정의하실수 있으세요.

Setups, Teardowns, on_start, 그리고 on_stop

Locust는 추가적으로 Locust 레벨에서는 setup그리고 teardown을 제공하고, TaskSet 레벨에서는 setup, teardown, on_start 그리고 on_stop을 제공합니다.

Setup과 Teardowns

Locust나 TaskSet의 setupteardown은 오직 한번만 실행이 됩니다. setup은 task들이 실행하기 전에 우선적으로 실행이 되고요, teardown은 모든 task가 전부다 실행되고 나가기 전에 마지막에 실행이 됩니다. 이 함수들을 재정의함으로써 작업이 실행하기 전에 준비작업을 할수도 있고, 작업을 최종적으로 종료하기 전에 실행하면서 어질러놨던거를 정리를 하고 나갈수도 있게 됩니다.

on_start와 on_stop

on_starton_stop 메써드는 TaskSet 클래스에서만 정의할수 있는데요. on_start는 어떤 사용자가 어떤 특정 TaskSet클래스를 실행했을때 호출되고, on_stop메써드는 TaskSet이 종료될때 실행이 됩니다.

각 함수가 호출되는 순서

아래는 위에서 설명한 함수들이 호출되는 순서입니다:

  1. Locust setup
  2. TaskSet setup
  3. TaskSet on_start
  4. TaskSet tasks….
  5. TaskSet on_stop
  6. TaskSet teardown
  7. Locust teardown

보통 setup이랑 teardown이 쌍으로 움직입니다.

HTTP요청하기

지금까지 task들을 어떻게 커버할지 Locust사용자 입장에서 계획만 짰자나요. 어떤 서비스의 실제 load test를 하려면 HTTP요청이 빠질수가 없죠. 이걸 손쉽게 하도록 생겨난것이 바로 HttpLocust라는 클래스에요. 이 클래스를 이용하면요, 각 instance가 HttpSession 클래스의 객체를 저장하는 client를 가지게 되고, 이것을 통해서 HTTP요청이 가능해지는거에요.

class HttpLocust

이 클래스의 instance하나가 HTTP 사용자 한명을 대변하고, 여러개의 instance를 통해 시스템을 공격하게 되는데 그게 바로 load test가 되는거죠.

이 사용자가 뭘할지는 해당 클래스안에 task_set라는 변수에 TaskSet을 상속받아 정의한 클래스를 할당함으로써 정의가 됩니다.

이 클래스는 client라는 변수를 만들고, 그 안에 session을 보유한 HTTP client를 갖고 있게 됩니다.

client=None

Locust가 초기화를 하면서 HttpSession의 instance를 만들어서 client에 저장합니다. 이 client는 쿠키저장도 가능하고, HTTP request간에 session도 유지도 합니다.

HttpLocust클래스를 상속해서 나만의 클래스를 정의할때, client를 이용해서 아래와 같이 HTTP요청을 만들수가 있어요.

from locust import HttpLocust, TaskSet, task, between

class MyTaskSet(TaskSet):
    @tast(2)
    def index(self):
        self.client.get("/")
    @task(1)
    def about(self):
        self.client.get("/about/")

class MyLocust(HttpLocust):
    task_set = MyTaskSet
    wait_time = between(5, 15)

위의 코드를 해석하자면, Locust클래스는 HttpLocust클래스를 상속받아 구현했기 때문에 session정보를 저장한 client를 가지게 되고요, task에서 URI를 호출하여 HTTP요청을 할수 있는데, index를 about보다 2배 정도 많이 호출하고, 각 task를 실행한 뒤에는 5초에서 15초정도 쉬어준뒤 다음 task를 실행합니다.

예리한 분들은 눈치채셨겠지만, HttpLocust는 부모 클래스로 정의가 되었는데 의외로 task에서 client에 접근할때 self.locust.client.get()이 아니라 self.client.get()으로 client에 접근하고 있어요. 이거는 사용자들의 편의를 위해서 self.client.get()를 실행하면 self.locust.client.get()가 호출되도록 뒷단에서 그렇게 만들어 놓은거에요.

HTTP client사용하기

HTTPLocust의 각 객체들은 HttpSession객체를 client에 저장하고 있어요. HttpSession클래스는요 사실 requests.Session의 서브클래스에요. 그래서 HTTP요청이 가능한건데요. HTTP요청이 get, post, put, delete, head, patch 그리고 options등의 다양한 형태로 요청이 되는데, 그 통계자료를 Locust에서 취합하게 되요. 하나의 HttpSession객체는 쿠키도 가질수 있고, 세션도 공유하기 때문에 웹사이트에서 로그로 사용될수도 있고, 요청간의 연관관계를 이용해서 뭔가 더 다양한 테스트를 할수 있겠죠. client는 위에서 말씀드린대로 TaskSet에서도 바로 접근하실수 있으십니다.

아래는 client를 통해서 GET으로 /about페이지에서 결과를 가져다가 화면에 보여주는 간단한 예제입니다.

response = self.client.get("/about")
print("Response status code:", response.status_code)
print("Response content:", response.text)

POST로 요청할때는 이렇게:

response = self.client.post("/login", {"username":"testuser", "password":"secret"})

Safe mode

HTTP client를 safe_mode로 돌리는 방법이 있습니다. 이게 뭐하는거냐면요, 만약에 어떤 요청이 connection에러가 났다거나, timeout났거나, 그밖에 어떤 에러상황에 처해서 처리에 실패를 했을때, 에러코드 대신에 그냥 빈 dummy response를 object에 넣어서 반환해주는 기능이에요. 해당 요청은 Locust통계에 실패로 보고가 되겠지만 사용자는 그냥 텅빈화면을 받아보게 되기 때문에 별다른 에러처리를 안해도 되는거죠. 대신에 response안에 status_code는 200이 아니라 0을 받게 될거에요.

요청의 성공, 실패 여부 조작하기

기본적으로 요청들은 처음에 failed상태로 요청을 시작했다가 요청이 끝나면 상태를 결과에 맞게 갱신해주게 되어있는데요. 대부분의 경우 이렇게 하면 만사오케이 거든요. 그런데 가끔은 테스트코드를 너무 막짜는 바람에 결과를 200만 받도록 했으면 좋겠는거에요. 예를 들어 404보다 큰 network status를 가지는 결과에 대해서 그냥 200으로 받고 싶은 경우에, 수동으로 결과를 조작하는 기능이 있습니다.

with self.client.get("/", catch_response=True) as response:
    if response.content != b"Success":
        response.failure("Got wrong response")

catch_response를 True로 요청을 하면 결과를 가져오는데 실패한 경우에 실패한 결과값 대신에 캐싱된 데이타를 보여주게 되는데 이렇게 하면, 아래 예제와 같이 최종 사용자에게 404를 넘겨주더라도 Locust통계에는 성공이라고 집계가 됩니다.

with self.client.get("/does_not_exist/", catch_response=True) as response:
    if response.status_code == 404:
        response.success()

URL과 파라메터별로 요청 묶기

어떤 특정 요청들을 묶어서 보고 싶을때, 요청에 name인자를 추가함으로써 통계를 볼때 해당 name으로 모아서 볼수가 있습니다.

# Statistics for these requests will be grouped under: /blog/?id=[id]
for i in range(10):
    self.client.get("/blog?id=%i" % i, name="/blog?id=[id]")

Common libraries

종종 사람들은 여러개의 locustfile들을 common libraries에 넣고 함께 공유하고 싶어 합니다. 그런 경우 우선 project root를 정의하고 모든 locustfile들은 프로젝트 root아래에 있게 하는것이 중요합니다.

아래는 단순한 형태의 구조입니다.

  • project root
    • commonlib_config.py
    • commonlib_auth.py
    • locustfile_web_app.py
    • locustfile_api.py
    • locustfile_ecommerce.py

위의 파일들이 서브구조를 가지면 좀더 깔끔하게 정리가 될것 같죠?

project root

  • __init__.py
  • common/
    • __init__.py
    • config.py
    • auth.py
  • locustfiles/
    • __init__.py
    • web_app.py
    • api.py
    • ecommerce.py

위의 구조로 설계를 한뒤에 아래와 같이 접근을 하실수있습니다

sys.path.append(os.getcwd())
import common.auth

Source: https://docs.locust.io/en/latest/writing-a-locustfile.html