오늘 배운 것

어제 이어서 작업하던 SZ-243의 하위이슈, '파이썬 커맨드로 ECS 태스크 정의 JSON 파일에 동적으로 환경변수 값 넣기' 작업을 해보려고 한다. 사실 틀은 거의 다 짜여져 있는 상황이라, 실제로 커맨드를 넣어 보고 잘 동작하는지만 확인해 주었다. 

 

어제의 코드에서 argument 받는 부분이랑, ${{}} (변수 부분)이 문자열 중간에 있는 경우를 고려해서 해당 케이스를 처리해 주는 코드만 추가하였다. 

import argparse
import json


def replace_ecs_task_definition():

    with open('ecs-task-def.json', 'r') as file:
        task_definition = json.load(file)

    parser = argparse.ArgumentParser()
    parser.add_argument("--aws_account_id", type=str)
    parser.add_argument("--aws_region", type=str)
    parser.add_argument("--aws_region_name", type=str)
    parser.add_argument("--ecr_repository_name", type=str)
    parser.add_argument("--aws_secret_name", type=str)
    parser.add_argument("--aws_access_key_id", type=str)
    parser.add_argument("--aws_secret_access_key", type=str)
    parser.add_argument("--aws_secret_name_prod", type=str)
    args = parser.parse_args()

    global key_map
    key_map = {
        "AWS_ACCOUNT_ID": args.aws_account_id,
        "AWS_REGION": args.aws_region,
        "AWS_REGION_NAME": args.aws_region_name,
        "ECR_REPOSITORY_NAME": args.ecr_repository_name,
        "AWS_SECRET_NAME": args.aws_secret_name,
        "AWS_ACCESS_KEY_ID": args.aws_access_key_id,
        "AWS_SECRET_ACCESS_KEY": args.aws_secret_access_key,
        "AWS_SECRET_NAME_PROD": args.aws_secret_name_prod,
    }
    

    def render_ecs_task_definition(obj):
        if isinstance(obj, dict):
            return {k: render_ecs_task_definition(v) for k, v in obj.items()}
        elif isinstance(obj, list):
            return [render_ecs_task_definition(v) for v in obj]
        elif isinstance(obj, str) and "$" in obj:
            replace_map = dict()
            env_var = obj.replace(" ", "").split("${{")
            env_real_var = []
            for ev in env_var:
                env_real_var.append(ev.split("}}")[0])
        
            for erv in env_real_var:
                if not erv.startswith("secrets"):
                    continue
                secret_value = erv.replace("}}", "").split(".")
                category, name = secret_value[0], secret_value[1]
                if category != 'secrets':
                    continue
                replaced_name = key_map.get(name)
                replace_map[name] = replaced_name

            for k, v in replace_map.items():
                replaced_string = "${{ secrets." + k + " }}"
                obj = obj.replace(replaced_string, v)
            
            return obj
        return obj

    task_definition = render_ecs_task_definition(task_definition)
    with open('ecs-task-def.json', 'w') as file:
        json.dump(task_definition, file, indent=2)


if __name__ == "__main__":
    result = replace_ecs_task_definition()

 

그리고 .yaml 파일에서 기존의 긴 raw json 코드는 지우고 다음과 같은 내용을 추가해주었다. 해당 파일에 명령어를 실행시켜서 동적으로 json 파일에 값을 넣어 주는 커맨드이다. 

- name: Render ECS task definition
  run: |
    python render_ecs_task_definition.py --aws_account_id ${{ secrets.AWS_ACCOUNT_ID }} --aws_region ${{ secrets.AWS_REGION }} --ecr_repository_name ${{ secrets.ECR_REPOSITORY_NAME }} --aws_secret_name ${{ secrets.AWS_SECRET_NAME }} --aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }} --aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }} --aws_secret_name_prod ${{ secrets.AWS_SECRET_NAME_PROD }} --aws_region_name ${{ secrets.AWS_REGION_NAME }}

 

develop 브랜치, 즉 개발 환경에 대해서 커맨드가 잘 작동하니, 나머지 환경인 test와 production 환경에 대해서도 해당 커맨드가 동작하도록 yaml 파일만 바꿔주었다. 


이제는 어제 잠시 보류했던 SZ-243의 하위이슈 'gunicorn, uvicorn을 이용해서 worker를 2개 이상 띄웠음에도 RPS가 그대로인 문제'를 해결해보려고 한다. 당시 멘토님이 조언을 주셨던 부분은 다음과 같다. 

1. debug = True로 임시로 설정하기

2. uvicorn worker를 빼고 gunicorn으로만 명령어 설정하기

3. --log-level debug 부분도 빼기. 

 

왜 이런 피드백을 주셨을까 나름대로 유추해 보자면, 1번의 경우는 debug 모드를 켜야 에러를 잡는 데 더 수월해서였을 것 같다. 3번의 경우는 굳이 세부적인 부분(trace 다음으로 로그를 많이 찍는 게 debug니까)까지 로그를 찍을 필요가 없거나, 그렇게 했을 때 너무 많은 정보들이 로그로 찍혀서 로그를 보기 어렵기 때문일 것이라고 추측했다. 

 

그리고 2번을 잘 모르겠었다. 나는 이전 포스트에서처럼 "gunicorn은 여러 개의 uvicorn 프로세스를 통합해서 관리하도록 도와주는 역할을 한다"고 이해했다. 그러면 uvicorn 없이 gunicorn만 있으면 여러 개의 worker로 요청을 받지 못하는 거 아닌가? 그런데 왜 gunicorn만 사용해 보라고 하시는 건지 단박에 이해가 되지는 않았다. 

 

알고보니 gunicorn은 uvicorn 등의 다른 백엔드(라이브러리)를 통해서 worker를 관리하기도 하지만, 자체적으로도 여러 개의 worker들을 관리할 수 있는 라이브러리였다. 즉 이전 포스트에서 내가 이해했던 내용들 중에는 잘못된 부분이 있어서 헷갈렸던 것 같다. 

 

그리고 GPT 피셜, 위의 2번 조언을 주신 이유는 크게 두 가지라고 한다. 첫 번째는 gunicorn과 uvicorn이 서로 worker들을 관리하려고 해서 중복 관리가 일어나서 그로 인해 오버헤드가 발생할 수 있기 때문이다. 두 번째는 gunicorn과 uvicorn이 모두 worker를 관리하기 때문에 문제가 발생했을 경우 어느 쪽에서 문제가 발생한 건지 파악이 어려울 수 있다고 한다. 이 부분은 실제로 내가 디버깅하면서 어려움을 겪었던 부분이기에 더 공감이 갔다. 

 

그리고 gunicorn 공식문서에 따르면 gunicorn을 단독으로 worker를 관리하는 데 사용하는 경우에 대해서 설명해 주고 있었다. 특히 이 부분이 신기했는데, gunicorn의 구조가 master process가 worker process들을 관리하는 구조라고 한다. 요청들은 worker process에서 전적으로 처리하며, master process는 worker process들을 관리할 뿐 worker process가 어떤 클라이언트에 대해서 어떤 요청을 처리하는지는 전혀 모른다고 한다. 아마 뭔진 모르겠지만 pre-fork worker model과 관련이 있지 않을까 싶다. 

 

그리고 worker들의 타입도 여러 가지라고 한다. 가장 많이 사용되고 우리가 기본값으로 사용하는 것은 'Sync Worker'이다. 이 worker는 한 번에 하나의 요청만 처리하며, http에서 keep-alive 헤더(tcp 헤더였던 것 같은데 헷갈린다)를 통해 keep-alive connection을 유지하려고 해도 요청에 대해 응답을 리턴하는 즉시 해당 요청에 대해서는 커넥션을 끊는다고 한다. 

 

그럼 나는 그냥 기본값인 Sync Worker를 쓰면 되는건가 싶었는데 자기한테 맞는 worker type을 어떻게 고르는지를 알려주는 부분이 또 있었다. 여기에 따르면 long pooling이나 websocket 등을 사용하거나 외부 API로 요청을 보내는 경우, 즉 응답을 받는데 정해진 시간이 아닌 undefined time이 걸릴 수 있는 경우는 async worker를 사용해야 한다고 한다. 현재는 websocket을 사용하지는 않지만 외부 API(openAI)를 사용하고 있어서 async worker를 사용해야 할지 고민이 되었다. 아니면 특정 요청(AI를 사용하는 요청)에 대해서만 async worker를 사용하고 싶은데 그런 건 안되려나?

 

아무튼 이제 왜인지를 알았으니 피드백을 주신 대로 바꿔보자. 기존에 uvicorn과 gunicorn을 같이 사용하던 명령어를 gunicorn만 사용하도록 바꿔 주면 된다. 그리고 '--log-level debug' 부분도 한번 빼 보자. 

 

기존 명령어는 다음과 같다. 

gunicorn onestep_be.asgi:application --bind 0.0.0.0:8000 --timeout 300 -w 2 -k uvicorn.workers.UvicornWorker --log-level debug --access-logfile -

 

위 명령어를 이렇게 바꿔주었다. 

gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application

 

그리고 해당 명령어로 로컬에서 잘 실행되었는지도 임시지만 확인해 보았다. 다행히 잘 실행되더라. 

 

워크플로우도 성공적으로 실행되었고, ECS의 태스크도 최신 태스크 정의를 참고하고 있어서 잘 반영된 것 같았다. 그런데 문득, 이렇게 '최신 태스크 정의를 참고하고 있는가'와 '워크플로우가 성공적으로 실행되었는가'를 직접 확인하지 않고도 서버 상태가 잘 반영되었는지를 확인할 수 있는 방법은 없을까 싶었다. 

 

어쨌든 서버가 성공적으로 배포되었으니 다시 locust를 통해 dev 서버에 요청을 날려보았다. 

 

RPS가 48.7이 떴다. (캡처는 못 했지만 50도 넘었었다.) 

이전 포스트에서는 RPS의 최대값이 30을 거의 못 넘었는데 50 가까이에 있는 걸 보면 RPS가 약 20정도 증가하고 60% 정도 성능이 향상되었다. 어찌됐건 '엄청난' 성능 향상은 아니지만 '유의미하게' 성능이 향상되긴 했다. 

 

그런데 궁금한 점이 생겼다. 

 

오늘 뵌 멘토님께서는 파이썬으로 WAS를 실행하고, t3.micro와 비슷한 스펙에서 실행된다는 것을 감안하면 RPS가 100이 넘으면 꽤 괜찮은 편이라고 하셨다. 설령 지금 정도의 50 RPS이더라도 현재 우리 서비스를 운영하는 데는 큰 문제는 없을 거라고 하셨다. 왜냐하면 지금 상태로도 1분에 약 3000개가 넘는 요청을 처리할 수 있는 상황이기 때문이다. 그런데 다른 멘토님께서는 예전에 RPS 1000을 넘기는 걸 목표로 해 보라고 하셨었다. 사실 RPS가 고고익선인 건 알고 있는데, 이게 단순히 서버 스펙을 늘려서 RPS를 늘리는 것은 결국 비용을 늘려서 이루어낸 결과이기 때문에(당연히 CPU 코어가 무한 개이면 무한 개의 RPS를 처리할 수 있는 것과 같은 의미) 효율성 있게 튜닝은 한 건 아니라는 생각이 들었다. 

 

암튼 그래서 궁금한 점은, 현재 서버 스펙(확실하지 않지만 t3.micro로 추정)으로는 서버 스펙을 늘리지 않는다고 가정하면 어느 정도의 RPS를 목표로 하면 좋을지가 궁금했다! RPS 1000은 한번 달성해보고 싶긴 한데, 서버 스펙을 늘리지 않고도 가능한 결과일지도 궁금하다. 만약 그렇다면 도전해 보고 싶다. 

 

 궁금한 점

1. gunicorn만으로도 worker들을 관리할 수 있는데 uvicorn과 같은 다른 백엔드를 써서 관리하는 이유가 궁금하다. 

2. gunicorn이 pre-fork worker model을 기반으로 한다고 했는데 이 모델이 뭔지도 궁금하다

3. worker type을 고를 때 DDOS 공격에는 async worker가 sync worker보다 덜 취약하다고 해서 이유가 궁금했다. 

4. gunicorn에서 특정 URL 요청에서만 async worker를 사용하도록 설정할 수 있는지도 궁금하다. 

5. 분명 dev.py(개발환경의 설정파일)에서 debug=True로 설정해 주었는데 '/swagger' URL을 입력하면 API 엔드포인트가 안 보이는 이유가 궁금하다. debug 모드와는 별개의 일인 걸까?

6. '최신 태스크 정의를 참고하고 있는지'와 '워크플로우가 성공적으로 실행되었는지'를 확인하지 않고도 서버가 잘 배포되었는지를 확인하는 방법은 없을까?

 

+ Recent posts