asyncio 뽀개기 3 - SIGTERM (CTRL+C) 올바르게 처리하기

Image not Found

asyncio를 사용하는 서버라면 graceful shutdown을 할 수 있어야합니다. Eventloop에 task를 등록하는 구조이기 때문에 graceful shutdown을 하지 않으면 유저 혹은 다른 서버의 요청이 버려지는 현상이 발생할 수 있습니다.
이전 포스트들에서 asyncio의 핵심 요소들의 동작 방식을 이해했다면, 이번 포스트에서는 signal을 이해하고 Eventloop에 signal handler를 추가함으로써 응용 방법을 살펴볼 예정입니다.


signal 이란?

Unix에서 signal이란 프로세스에 특정한 의미를 담아 보내는 inter-process communication (IPC) 입니다. 의미에 따라서 signal의 종류가 나뉘어있습니다. POSIX 호환 OS라면 (Linux, MacOS도 포함) 터미널에서 kill -l를 실행해서 현재 OS가 지원하는 signal의 종류를 확인할 수 있습니다.
signal은 프로세스가 실행 중이라면 언제든 발생할 수 있고, 발생 시 해당 프로세스는 실행을 멈추고 signal 처리를 하게 됩니다. 여기서 언제든이 중요한데, 서버가 이제 막 켜져서 사용할 DB의 health check 중간에도 발생할 수 있고, 사용자 요청을 처리하는 중에 발생할 수도 있고, 심지어 signal handling을 하는 중에도 발생할 수 있습니다.

로컬에서 signal 테스트해 보기

로컬에서 kill 커맨드로 확인해보실 수 있습니다. kill -KILL <PID>를 실행하면 process id가 <PID>인 프로세스에 SIGKILL을 보낼 수 있습니다. CLI에서 KILL대신 kill -l에 나오는 이름을 적으면 그 signal을 보낼 수 있습니다.
몇몇 signal은 kill 없이도 TTY로 보낼 수 있는데, 터미널에서 프로세스 종료할 때 쓰이는 CTRL + C가 그 예시이고 SIGINT를 의미합니다.

대표적인 signal

Linux의 signal manpage를 보면 아주 다양한 signal이 존재합니다. 하지만 서버 개발자가 신경 써야 할 signal 3개만 꼽자면 SIGTERM, SIGKILL과 SIGINT입니다.

SIGTERM

프로세스에게 종료를 부탁하는 의미로 생각할 수 있습니다. 이 signal을 받은 프로세스는 처리를 마무리하고 정상 종료해야 하기 때문에 graceful shutdown에 적합합니다. docker stopkubectl delete pod 모두 즉시 SIGTERM을 보냅니다.

SIGKILL

프로세스에게 시간을 주지 않고 즉시 종료할 때 사용합니다. docker kill을 실행하면 컨테이너에 즉시 SIGKILL을 보냅니다. docker stopkubectl delete pod 모두 처음에는 SIGTERM을 보내지만, 만약 CLI에서 설정한 시간 동안 프로세스가 종료되지 않으면 SIGKILL을 다시 보내서 즉시 종료합니다.

SIGINT

위에서 설명한 것처럼 터미널에서 프로세스를 종료하고자 Ctrl + C를 입력할 때 사용됩니다. Docker나 Kubernetes에서 이 시그널이 활용되진 않지만, 로컬에서 개발할 때 자주 사용하기도 합니다. gunicorn에서 즉시 종료할 때 사용되기도 합니다.

signal handler

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를 추가하지 않았는데?

웬만한 서버 프레임위크들은 자체적으로 signal handler를 가지고 있습니다. gunicorn을 사용한다면 기본적인 핸들링을 해주고, FastAPI, Django 등에서도 서버 종료 시점에 핸들러를 실행할 수 있도록 지원하고 있습니다. 이런 프레임워크들은 signal 자체를 추상화해서 프레임워크 기능으로 제공하지만, 웹 프레임워크를 쓰지 않는 서버의 경우 직접 signal을 신경 써야 합니다.

Python에서의 signal

Python에서도 asyncio 이전부터 signal 관련 기능을 signal 모듈에서 지원하고 있었습니다. 또한 SIGINT (Ctrl + C)가 발생할 때 Python 코드 내부에서 KeyboardInterrupt 에러를 발생시킵니다. KeyboardInterruptBaseException의 자식 클래스이기 때문에 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를 안전하게 처리할 수 없습니다.

asyncio에서 signal

위 예제의 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에서 처리할 수 없기 때문입니다.

활용 1: signal handler에서 Eventloop 조작하기

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)

활용 2: signal handler로 async 함수 등록하기

기본적으로 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 실행까지 딜레이가 있을 수 있습니다.

활용 3: 현재 eventloop에서 실행중인 모든 task 취소하기

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하기 (클릭)


참고문서

You May Also Like

버즈빌, 아마도 당신이 원하던 회사!

지원하기