AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까
버즈빌은 2023년 한 해 동안 월간 약 1.2억, 연 기준으로 14억에 달하는 AWS 비용을 절약하였습니다. 그 경험과 팁을 여러 차례에 걸쳐 공유합니다. AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까 (준비중) …
Read Articleasyncio를 사용하는 서버라면 graceful shutdown을 할 수 있어야합니다.
Eventloop에 task를 등록하는 구조이기 때문에 graceful shutdown을 하지 않으면 유저 혹은 다른 서버의 요청이 버려지는 현상이 발생할 수 있습니다.
이전 포스트들에서 asyncio의 핵심 요소들의 동작 방식을 이해했다면,
이번 포스트에서는 signal을 이해하고 Eventloop에 signal handler를 추가함으로써 응용 방법을 살펴볼 예정입니다.
Unix에서 signal이란 프로세스에 특정한 의미를 담아 보내는 inter-process communication (IPC) 입니다.
의미에 따라서 signal의 종류가 나뉘어있습니다.
POSIX 호환 OS라면 (Linux, MacOS도 포함) 터미널에서 kill -l
를 실행해서 현재 OS가 지원하는 signal의 종류를 확인할 수 있습니다.
signal은 프로세스가 실행 중이라면 언제든 발생할 수 있고, 발생 시 해당 프로세스는 실행을 멈추고 signal 처리를 하게 됩니다.
여기서 언제든이 중요한데, 서버가 이제 막 켜져서 사용할 DB의 health check 중간에도 발생할 수 있고,
사용자 요청을 처리하는 중에 발생할 수도 있고, 심지어 signal handling을 하는 중에도 발생할 수 있습니다.
로컬에서 kill
커맨드로 확인해보실 수 있습니다.
kill -KILL <PID>
를 실행하면 process id가 <PID>
인 프로세스에 SIGKILL을 보낼 수 있습니다.
CLI에서 KILL
대신 kill -l
에 나오는 이름을 적으면 그 signal을 보낼 수 있습니다.
몇몇 signal은 kill
없이도 TTY로 보낼 수 있는데, 터미널에서 프로세스 종료할 때 쓰이는 CTRL + C
가 그 예시이고 SIGINT를 의미합니다.
Linux의 signal manpage를 보면 아주 다양한 signal이 존재합니다. 하지만 서버 개발자가 신경 써야 할 signal 3개만 꼽자면 SIGTERM, SIGKILL과 SIGINT입니다.
프로세스에게 종료를 부탁하는 의미로 생각할 수 있습니다.
이 signal을 받은 프로세스는 처리를 마무리하고 정상 종료해야 하기 때문에 graceful shutdown에 적합합니다.
docker stop
과 kubectl delete pod
모두 즉시 SIGTERM을 보냅니다.
프로세스에게 시간을 주지 않고 즉시 종료할 때 사용합니다.
docker kill
을 실행하면 컨테이너에 즉시 SIGKILL을 보냅니다.
docker stop
과 kubectl delete pod
모두 처음에는 SIGTERM을 보내지만,
만약 CLI에서 설정한 시간 동안 프로세스가 종료되지 않으면 SIGKILL을 다시 보내서 즉시 종료합니다.
위에서 설명한 것처럼 터미널에서 프로세스를 종료하고자 Ctrl + C를 입력할 때 사용됩니다. Docker나 Kubernetes에서 이 시그널이 활용되진 않지만, 로컬에서 개발할 때 자주 사용하기도 합니다. gunicorn에서 즉시 종료할 때 사용되기도 합니다.
signal이 발생했을 때 실행할 코드를 의미합니다. OS에서 소유하는 default signal handler라는 게 있고, signal 종류마다 서로 다른 handler를 가지고 있습니다. 예를 들어 SIGKILL의 default signal handler는 프로세스를 종료시키는 동작을 합니다. 프로세스가 직접 자신만을 위한 signal handler를 정의할 수 있는데, 이것을 user defined signal handler라고 합니다. OS는 해당 프로세스에 user defined handler가 있으면 그것을 실행하고, 없다면 default handler를 실행합니다. 단 두 가지 예외가 있는데, SIGKILL과 SIGSTOP은 항상 default handler가 사용됩니다.
웬만한 서버 프레임위크들은 자체적으로 signal handler를 가지고 있습니다. gunicorn을 사용한다면 기본적인 핸들링을 해주고, FastAPI, Django 등에서도 서버 종료 시점에 핸들러를 실행할 수 있도록 지원하고 있습니다. 이런 프레임워크들은 signal 자체를 추상화해서 프레임워크 기능으로 제공하지만, 웹 프레임워크를 쓰지 않는 서버의 경우 직접 signal을 신경 써야 합니다.
Python에서도 asyncio 이전부터 signal 관련 기능을 signal 모듈에서 지원하고 있었습니다.
또한 SIGINT (Ctrl + C)가 발생할 때 Python 코드 내부에서 KeyboardInterrupt
에러를 발생시킵니다.
KeyboardInterrupt
는 BaseException
의 자식 클래스이기 때문에 try-catch
로 잡아서 핸들링할 수 있습니다.
asyncio에서는 loop.add_signal_handler가 추가됐고,
asyncio를 사용하는 경우 signal.signal()
대신 이 함수를 사용해야 합니다.
내부 코드를 보면,
결국 signal.signal()
을 사용하지만 Eventloop가 깨어나는 것을 보장하는 장치도 포함되어있기 때문입니다.
KeyboardInterrupt
만 사용하면 안될까?⚠
KeyboardInterrupt
은 터미널에서 프로그램 종료를 위해 사용하는 Ctrl + C(SIGINT)에만 반응합니다. SIGTERM, SIGKILL과는 관련이 없지만, signal handler의 필요성과 signal의 특성을 설명하기 위해서 예시로 사용합니다.
import asyncio
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(some_coroutine_function())
except KeyboardInterrupt:
print('Interrupted')
some_terminating_task()
finally:
loop.close()
print('Eventloop closed')
signal handler없이 KeyboardInterrupt
를 이용해서 Ctrl + C 입력을 대비하는 프로그램입니다.
간단하고 명료하므로 문제가 없어 보이지만, 사실 signal을 처리하기엔 충분하지 않습니다.
signal은 언제든 발생할 수 있다는 특징이 있으므로 except 내부인 terminating_some_tasks()
실행 중이나 finally 내부에서도 발생할 수 있습니다.
그렇다면 상술한 가능성을 차단하기 위해서 except나 finally 내부를 또다시 try-except KeyboardInterrupt
로 감싸면 해결이 될까요?
불행하게도 그 except 구문 내부에서도 signal이 발생할 수 있습니다.
결국 KeyboardInterrupt
로는 SIGINT를 안전하게 처리할 수 없습니다.
위 예제의 SIGINT를 포함해서 production 환경에서 lifecycle에 사용되는 SIGTERM를 제대로 처리하려면 loop.add_signal_handler를 사용해야 합니다.
import asyncio
import signal
loop = asyncio.new_event_loop()
def sig_handler():
print('Shutdown')
some_terminating_task()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, sig_handler)
try:
loop.run_until_complete(some_coroutine_function())
finally:
loop.close()
print('Eventloop closed')
중요한 것은 loop.add_signal_handler
를 최대한 빨리 실행하는 것입니다.
signal은 언제든 발생할 수 있으므로 loop.add_signal_handler
이전에 시간이 소요되는 초기화 작업이 존재한다면,
초기화 도중에 발생하는 signal은 signal handler에서 처리할 수 없기 때문입니다.
signal handler는 파라미터를 받을 수 있으므로 Eventloop을 넘겨서 조작할 수 있습니다. 위 예제의 일부분만 수정해보겠습니다.
def sig_handler(loop):
print('Shutdown')
loop.stop()
some_terminating_task()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, sig_handler, loop)
기본적으로 signal handler는 async
키워드가 없는 일반 함수여야 합니다.
하지만 만약 handler 내부에서 await
키워드를 쓰고 싶다면 loop.create_task
를 사용할 수 있습니다.
async def sig_handler(loop):
pass
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda l: l.create_task(sig_handler(l)), loop)
다만 꼭 알아둬야 할 점은, 일반 함수를 handler로 등록하면 signal이 발생했을 때 곧바로 handler 내용이 실행되지만,
loop.create_task
를 사용했을 경우 곧바로 실행되지 않을 수도 있다는 것입니다.
asyncio 뽀개기 1편에서 설명한 것처럼,
loop.create_task
는 파라미터로 주어진 코루틴을 바로 실행하는 게 아니라 Eventloop에 실행을 위한 등록만 합니다.
그 때문에 만약 Eventloop에 등록된 task가 너무 많거나 CPU intensive 한 task가 실행권을 양보하고 있지 않다면,
signal 발생 시점부터 handler 실행까지 딜레이가 있을 수 있습니다.
SIGTERM과 SIGINT는 종료를 위해 사용되기 때문에 현재 Eventloop에서 실행 중인 모든 task를 종료하고 싶은 때도 있습니다.
async def sig_handler(loop):
tasks = []
for t in asyncio.all_tasks():
if t == asyncio.current_task():
continue
tasks.append(t)
t.cancel()
await asyncio.gather(*tasks)
loop.stop()
t.cancel()
호출 시 해당 task에 asyncio.CancelledError
가 발생하므로 task는 그에 맞는 에러 핸들링을 해야 합니다.
asyncio.gather
에서는 task들이 asyncio.CancelledError
를 다 처리하고 코루틴을 끝내길 기다립니다.
위의 활용 방안들을 종합해서 실행할 수 있는 예제를 하나 소개합니다.
아래 스크립트는 1초에 한 번 sleep
이라는 메시지를 출력하는 무한루프를 실행합니다.
여기에 SIGINT 혹은 SIGTERM의 handler를 추가하는데, 그 무한루프의 종료시키는 코드가 포함되어있습니다.
추가로 실행만으로 출력을 확인할 수 있게 send_sigterm_after_5_seconds
를 추가했습니다.
이 함수는 실행 후 5초 이후에 스크립트 자신에게 SIGTERM을 보냅니다.
만약 SIGTERM의 handler가 잘 설정되어있다면 5초 이후에 stopping loop...
를 볼 수 있습니다.
import asyncio
import signal
import os
shutdown: bool = False
def sig_handler() -> None:
""" signal handler. `print_and_sleep`의 무한루프를 종료시킨다. """
print('stopping loop...')
global shutdown
shutdown = True
async def print_and_sleep(loop) -> None:
""" 1초에 한번 `sleep`이라는 문자를 출력하는 무한루프 """
while not shutdown:
print('sleep')
await asyncio.sleep(1)
loop.stop()
async def send_sigterm_after_5_seconds() -> None:
""" 5초 이후에 스크립트 자신에게 SIGTERM을 보낸다. """
await asyncio.sleep(5)
os.kill(os.getpid(), signal.SIGTERM)
def main() -> None:
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, sig_handler)
loop.create_task(print_and_sleep(loop))
loop.create_task(send_sigterm_after_5_seconds())
try:
loop.run_forever()
print('loop stopped')
finally:
loop.close()
print('loop closed')
if __name__ == '__main__':
main()
# SIGINT 없이 스크립트 실행 후 5초 동안의 출력
# sleep
# sleep
# sleep
# sleep
# sleep
# stopping loop...
# loop stopped
# loop closed
Dockerfile을 작성할 때 ENTRYPOINT를 잘못 작성하거나 script를 사용할 때 애플리케이션이 signal을 받지 못하는 문제가 발생할 수 있습니다. https://hynek.me/articles/docker-signals/에 자세한 원인과 가이드가 나와 있습니다.
이번 포스트에서는 signal에 대해서 알아보면서 asyncio에서 올바르게 signal을 처리하는 방법을 살펴봤습니다. 다음 포스트에서는 지금까지의 포스트 내용을 기반으로 실제 프로덕션 서버에서 graceful shutdown을 구현했던 사례를 소개하겠습니다.
버즈빌 테크 리크루터와 Coffee Chat하기 (클릭)
버즈빌은 2023년 한 해 동안 월간 약 1.2억, 연 기준으로 14억에 달하는 AWS 비용을 절약하였습니다. 그 경험과 팁을 여러 차례에 걸쳐 공유합니다. AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까 (준비중) …
Read Article들어가며 안녕하세요, 버즈빌 데이터 엔지니어 Abel 입니다. 이번 포스팅에서는 데이터 파이프라인 CI 테스트에 소요되는 시간을 어떻게 7분대에서 3분대로 개선하였는지에 대해 소개하려 합니다. 배경 이전에 버즈빌의 데이터 플랫폼 팀에서 ‘셀프 서빙 데이터 …
Read Article