프로그램의 유형에 따라 명확한 종료 시점 없이 반복적인 작업을 수행해야 하는 경우가 있다. 그런데, 영원한 것은 없지 않나! 언젠가는 종료를 시켜야 한다면, 어떻게 해야 안전하게 무한 루프를 빠져나올 수 있는지 알아보자.
안전하게 무한 루프 탈출하기 - Handling SIGTERM in kubernetes with python
프로세스 내부에서 루프 종료시키기
안전한 종료 설계하기
무한 루프가 멈추지 않고 평생 동작하면 좋겠지만, 점검을 위해 일시적으로 소프트웨어를 중단해야 하는 경우가 생길 수 있다. 코드에 while True
를 사용하여 반복 작업을 수행하는 프로그램을 작성하면, 테스트를 할 때마다 "ctrl + c" 키를 누르고 keyboard interrupt로 루프를 빠져나오는 수고를 감수해야 한다.
프로세스가 영원히 실행 상태로 머무를 것이라고 가정해버리면 어떻게 종료를 시켜야 하는지에 대해 고민을 소홀히 하게 된다. 경우에 따라 의도치 않게 작업이 종료되면서 시스템에 악영향을 미칠 수도 있다. 예를 들어, DB connection을 열어놓고 제대로 닫지 않으면 orphan 상태의 connection 수가 계속 증가한다. DB에 설정된 connection 수 제한에 걸리게 되면 다른 모든 프로세스들이 일시적으로 DB를 사용할 수 없다. 그리고, 일부 시나리오에서는 malloc
같은 방법을 통해 메모리를 할당받고 제대로 free
하지 않을 경우 메모리 누수가 발생할 수도 있다.
따라서, 루프가 끝날 때 안전하게 종료되도록 신경을 쓰는 것이 매우 중요하다. Python에서는 보통 종료에 대한 처리를 돕기 위해 finally
를 사용한다. 프로세스 상태가 정상이든 exception이 발생했든 상관없이 항상 마지막에 finally
부분 코드가 실행되도록 보장해준다. 따라서, 되도록이면 루프 부분을 try
와 finally
로 감싸서 종료에 대한 처리를 해주는 것이 좋다.
import time
def do_something():
print("Processing...")
time.sleep(3)
raise Exception("Error!")
def cleanup():
# Close DB connection
# Free memory
# ...
print("Cleanup!")
def loop():
try:
while True:
do_something()
except Exception as e:
print(e)
finally:
cleanup()
>>> loop()
Processing...
Error!
Cleanup!
빠져나올 구멍 만들기
안전한 종료에 대한 처리를 올바르게 작성했다고 하더라도, while True
를 통한 무한 루프를 사용한다면 의도적으로 오류를 발생시키거나 컴퓨터 전원을 내리거나 프로세스를 강제 종료하는 등 비정상적인 방식으로만 루프를 빠져나올 수 있다.
따라서, while True
의 사용을 피하고, 외부에서 종료 요청을 받아줄 수 있도록 만드는 것이 좋다. 가장 간단한 방식은 bool
타입의 변수로 일종의 flag를 만들어두고, 외부에서 해당 변수의 값을 변경하여 루프를 빠져나올 수 있도록 코드를 작성하는 것이다.
class Task:
def __init__(self) -> None:
self.running = False
...
def start(self) -> None:
self.running = True
while self.running:
self.do_something()
def stop(self) -> None:
self.running = False
위 코드를 보면 while True
대신 running
이라는 bool
타입의 변수를 사용해서 루프를 빠져나올 수 있도록 만들었다. 그리고 stop
이라는 메서드를 제공하여 running
값을 False
로 바꿀 수 있게 했다. 아주 간단하지만 루프를 정상적으로 끝낼 수 있는 방법이 생겼다.
Event 객체를 활용한 멀티스레딩 지원
사실 앞서 살펴본 flag 방식의 코드를 싱글 스레드로 돌리면 while True
를 사용한 코드와 별반 다를 게 없다. 한 번 start
메서드가 호출되면, 메인 스레드가 block되기 때문에 stop
메서드를 호출할 기회 자체가 없어진다. 즉, 루프를 종료시키는 기능을 제공한다는 것은 기본적으로 멀티스레딩을 염두해둔 것이라고 봐도 무방하다. 루프를 실행시키는 스레드를 백그라운드에서 동작시켜두고, 메인 스레드 또는 다른 자식 스레드에서 요청에 따라 루프를 종료하도록 만들어야 한다.
백그라운드에서 루프를 실행시키는 스레드가 하나라면 문제가 없지만, 만약 백그라운드에 동시에 여러 개의 스레드를 돌려서 병렬 처리를 하고 싶다면 어떻게 될까? 앞서 살펴본 코드에 의하면, stop
메서드를 호출하는 순간 모든 스레드의 루프가 종료될 것이다. 애초에 이러한 동작이 의도된 것이라면 더이상 얘기할 것이 없지만, 조금 더 깊게 생각해보자. 병렬로 동작하고 있는 스레드 중 일부 스레드만 종료시키고 싶다면 어떻게 구현해야 할까?
각각의 스레드를 독립적으로 종료시킬 수 있도록 만드려면 threading.Event
타입의 객체를 사용하면 좋다. Event
객체는 thread-safe하며 사용법도 직관적이고 간편하다. 단순히 set
이라는 메서드와 is_set
이라는 메서드를 통해 flag처럼 사용할 수 있다. 루프를 실행하는 thread에 전달하는 메서드의 인자로 Event
객체를 전달하도록 만들고, while
문의 조건에 이 객체의 상태를 확인하도록 하면 된다.
from threading import Event
def do_something(stop_event: Event) -> None:
while not stop_event.is_set():
print("Processing...")
time.sleep(3)
print("Complete.")
def stop(stop_event: Event) -> None:
stop_event.set()
>>> stop_event = Event()
>>> thread = Thread(target=do_something, kwargs={"stop_event": stop_event})
>>> thread.start()
Processing...
Processing...
Processing...
>>> stop(stop_event)
Complete.
위 코드처럼 구현하면 함께 종료되어야 하는 스레드들을 grouping하기 좋다. 쉽게 말해서, 각각의 스레드에 서로 다른 Event
객체를 전달하면 각 스레드를 하나씩 개별적으로 종료해야 한다. 그런데, 동일한 Event
객체를 여러 스레드에게 전달하면 해당 Event
객체 하나를 set하는 것만으로 여러 스레드를 동시에 종료시킬 수 있게 된다.
프로세스 외부에서 루프 종료시키기
강제 종료 신호 핸들링하기
앞서 코드를 통해 프로세스 내부에서 무한 루프를 탈출하는 방법에 대해 살펴보았다. 그런데, 프로세스 외부 트리거를 통해 루프를 종료시키는 방법은 없을까? 물론 다양한 방법이 있다. 예를 들어, 프로세스 자체를 REST API로 만들어서 HTTP 요청을 통해 루프를 종료시키는 API를 제공할 수도 있고, CLI를 제공하여 터미널 명령어를 통해 종료 요청을 받을 수도 있다. 이러한 방법들은 fastapi
나 argparse
같은 패키지를 활용하여 직관적으로 구현할 수 있기 때문에 이 글에서는 자세히 다루지 않겠다.
이 글에서 중점적으로 생각하는 것은 "안전하게 루프를 종료하는 것"이다. 마냥 루프를 종료하라는 사용자의 요청을 기다릴 수는 없는 노릇이다. 처음에도 언급했듯이, 프로세스는 언제 어디에서 종료될지 모른다. 하드웨어의 전원을 내려버리는 것은 어쩔 수 없지만, 적어도 소프트웨어를 강제로 종료하는 상황은 대응할 수 있어야 한다.
보통 프로세스를 강제 종료시킬 때는 SIGTERM, SIGINT, 그리고 SIGKILL이라는 신호를 전달한다. 세 가지 신호에 대해 대략적으로 설명하면 다음과 같다.
- SIGTERM: 프로세스 종료 전 필요한 작업을 하도록 여유를 주는 관대한 신호 (15번)
- SIGINT: "ctrl + c" 키로 keyboard interrupt를 발생시킬 때 전달되는 신호 (2번)
- SIGKILL: 프로세스 종료 전 필요한 작업을 허용하지 않고 즉시 종료시키는 신호 (9번)
세 가지 신호 중에서 SIGTERM과 SIGINT는 프로세스가 종료되기 전에 필요한 작업을 수행할 수 있는 기회를 준다. 예를 들어, 앞서 살펴봤던 finally
를 활용한 코드가 동작할 수 있도록 기다려준다. SIGKILL의 경우에는 즉시 종료가 원칙이기 때문에 안전 종료 로직을 수행할 수 없다.
Python에서는 이러한 신호들이 감지되었을 때 적절한 로직을 수행하도록 handler에 등록하는 방법을 제공한다. 앞서 살펴봤던 Event
객체를 사용하는 예제를 참고하여 강제 종료 신호가 들어올 경우 루프를 종료시키는 코드를 작성해보자. Python의 signal
이라는 패키지를 사용해서 핸들링 메서드를 등록해주면 된다. 다음 코드를 통해 자세히 살펴보자.
# test.py
import signal
import time
from typing import Any, Dict, Tuple
from threading import Event
class Worker:
def __init__(self) -> None:
self.stop_event = Event()
def handle_sigterm(*args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
print("SIGTERM detected.")
self.stop()
def handle_sigint(*args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
print("SIGINT detected.")
self.stop()
def handle_sigkill(*args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
print("SIGKILL detected.")
self.stop()
signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigint)
# signal.signal(signal.SIGKILL, handle_sigkill)
def work(self) -> None:
try:
while not self.stop_event.is_set():
print("Processing...")
time.sleep(3)
except Exception as e:
print(f"Error: {e}")
finally:
print("Cleanup!")
def stop(self) -> None:
self.stop_event.set()
if __name__ == "__main__":
worker = Worker()
worker.work()
생성자 부분에서 signal.signal
이라는 메서드를 통해 각 신호에 대한 처리 메서드를 등록해주는 부분을 확인할 수 있다. SIGKILL 신호에 대한 처리 메서드를 등록하는 부분은 주석처리되어 있는데, SIGKILL에 대한 핸들러를 등록하는 것은 애초에 허용되지 않기 때문이다. 만약 주석을 해제하고 실행한다면 다음과 같은 오류를 만나게 된다.
$ python test.py
...
OSError: [Errno 22] Invalid argument
SIGKILL을 제외한 신호들을 핸들링하는 로직들을 등록한 뒤에 코드를 실행해보자. 코드가 실행된 상태에서 "ctrl + c"를 통해 keyboard interrupt를 발생시키면 다음과 같이 정상적으로 프로그램이 종료되면서 "Cleanup!"이라는 메시지가 출력되는 것을 확인할 수 있다. SIGTERM과 SIGKILL을 발생시키고 핸들링하는 과정에 대해서는 이어지는 파트에서 자세히 살펴보자.
$ python test.py
Processing...
^CSIGINT detected.
Cleanup!
터미널 명령어로 종료시키기
앞서 작성한 test.py
파일의 코드가 실행되고 있는 상태에서 keyboard interrupt 외에 강제 종료시킬 수 있는 다른 방법은 없을까? Keyboard interrupt를 사용하지 않는다면, 코드가 실행되고 있는 터미널은 block 상태가 되므로 터미널 자체를 종료하는 것 외에는 프로세스를 종료시킬 수 있는 방법이 없다. 다른 터미널에서 프로세스를 종료시켜야 한다.
ps aux
와 kill
명령어를 활용하면 다른 터미널의 프로세스를 강제 종료시킬 수 있다. ps aux
명령어는 실행중인 process의 PID를 얻기 위해 사용한다. 그리고, kill
명령어는 특정 ID를 가진 process를 강제 종료시키기 위해 사용한다. kill
명령어는 옵션을 통해 SIGINT, SIGTERM, SIGKILL 등 다양한 신호를 프로세스에 전달할 수 있다. 사용법을 살펴보자.
$ ps aux | grep 'python'
littlemobs 30653 0.0 0.0 410437632 8448 s015 S+ 11:30PM 0:00.03 python test.py
우선 한 터미널에서 python test.py
명령어를 통해 코드를 실행시켜둔 상태에서 위 명령어를 입력해보면, 프로세스의 PID를 확인할 수 있다. 두 번째 숫자가 PID에 해당한다. 이 PID를 kill
명령어에 전달하면 해당 PID를 가진 프로세스를 강제 종료시킬 수 있다. 이때 옵션으로 신호의 번호를 부여하면 특정 신호를 프로세스에 전달해준다. SIGTERM은 15번, SIGINT는 2번, SIGKILL은 9번이다. 예시를 통해 각각의 신호를 test.py
에 전달해보자.
SIGTERM 발생시키기
$ kill -15 30653
Processing...
SIGTERM detected.
Cleanup!
SIGINT 발생시키기
$ kill -2 30653
Processing...
SIGINT detected.
Cleanup!
SIGKILL 발생시키기
$ kill -9 30653
Processing...
[1] 30653 killed python test.py
배포 환경에서 안전 종료 테스트하기
배포를 위한 준비
지금까지 프로세스 내외부적으로 안전하게 루프를 탈출시키는 코드를 작성하는 방법에 대해 살펴보았다. 코드가 생각보다 단순해서 미심쩍을 수도 있다. 과연 실제 배포 환경에서도 이 간단한 코드가 의도한 대로 잘 동작할까? 루프를 포함한 코드를 docker image로 빌드한 뒤에 container로 실행시켜서 다양한 시나리오에서 의도한 안전 종료 로직이 잘 동작하는지 확인해보자. 그리고, kubernetes 환경에 pod 형태로 배포하여 여러 추가 시나리오들을 테스트해보자. 이 과정을 수행하기 위해서는 다음과 같은 준비가 필요하다. 이어지는 파트에서 자세히 살펴보겠다.
test.py
코드를 docker image로 만들기 위해 Dockerfile 작성하기Dockerfile
을 사용해 image로 빌드하기- Kubernetes에 배포하기 위해 pod와 deployment를 정의하는 yaml 파일 작성하기
Docker container 종료시키기
우선 Dockerfile
은 다음과 같이 아주 간단하게 작성해서 test.py
파일과 같은 위치에 저장하자. 이후 docker build
명령어를 통해 이미지를 빌드하면 된다. 이미지의 repository와 tag는 임의로 myloop:test
로 정했다.
FROM python:3.9.21-alpine3.21
ENV WORKSPACE=/opt/test
RUN mkdir -p ${WORKSPACE}
WORKDIR $WORKSPACE
COPY test.py ${WORKSPACE}
ENTRYPOINT [ "python", "test.py"]
$ docker build -t myloop:test -f Dockerfile .
...
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
myloop test e25244d2c493 54 seconds ago 53.6MB
빌드가 완료된 이미지는 docker run
명령어를 통해서 다음과 같이 간단하게 실행시켜볼 수 있다. docker run
명령어를 실행한 터미널이 block 상태가 되면서 바로 "Processing..."이라는 메시지가 반복적으로 출력되는 것을 확인할 수 있다.
$ docker run -it --name myloop myloop:test
Processing...
docker run
을 실행한 터미널과 다른 터미널에서 docker stop
명령어를 통해 실행해둔 컨테이너를 종료시켜보자. 그리고 docker run
을 실행했던 터미널로 돌아가보면 정상적으로 "Cleanup!"이라는 메시지가 출력되어 있는 것을 확인할 수 있다.
$ docker stop myloop
myloop
Processing...
SIGTERM detected.
Cleanup!
docker stop
명령어를 통해 컨테이너를 종료시키면 SIGTERM을 전달한다는 사실을 알 수 있다. 코드의 finally
부분에 포함된 안전 종료 로직이 실행된다. 추가적으로 다음과 같이 종료된 컨테이너의 상태를 확인해보자. Exited (0)
은 정상적으로 종료되었음을 의미한다.
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
dcae70a65eaf myloop:test "python test.py" About a minute ago Exited (0) 40 seconds ago myloop
docker rm myloop
명령어를 통해 종료된 컨테이너를 제거한 뒤 다시 docker run
명령어를 통해 실행시켜보자. 이번엔 SIGKILL을 발생시킬 차례다. docker kill
명령어를 통해 컨테이너를 종료시키면 된다. docker run
명령어를 실행했던 터미널로 돌아가보면 "Processing..." 외에 다른 메시지 없이 곧바로 종료되어 있는 것을 확인할 수 있다.
$ docker kill myloop
Processing...
종료된 컨테이너의 상태를 확인해보면 SIGTERM을 전달했을 때와 달리 Exited (137)
상태가 되어 있을 것이다. 이때 137번 종료 코드는 SIGKILL에 의해 프로세스가 종료되었음을 의미한다. docker kill
명령어 사용에 주의해야 한다는 점을 깨닫는 순간이다.
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bc1b5ae3354d myloop:test "python test.py" 13 seconds ago Exited (137) 6 seconds ago myloop
Kubernetes pod 종료시키기
Kubernetes에 pod 형태로 띄운 뒤 강제 종료시킨다면 어떤식으로 프로세스에 종료 신호가 전달될까? Kubernetes에 test.py
코드를 배포하여 확인해보자. 우선 앞서 빌드한 이미지의 repository를 적절한 곳으로 변경 후 push해두어야 한다. 설명을 위해 myimages/myloop
라는 repository 이름을 사용한다고 가정하겠다.
$ docker tag myloop:test myimages/myloop:test
...
$ docker push myimages/myloop:test
풍부한 시나리오를 경험해보기 위해 pod 형태와 deployment 형태 두 가지 경우를 모두 살펴보자. 우선 pod로 만들기 위해서는 다음과 같이 간단하게 yaml 파일을 작성하면 된다.
# pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: myloop-pod
spec:
containers:
- name: myloop-container
image: myimages/myloop:test
env:
- name: PYTHONUNBUFFERED
value: "1"
이제 이 yaml을 통해 pod를 실행한 뒤, 강제로 종료했을 때 어떤 로그가 찍히는지 확인해보자. 우선 kubectl apply
명령어를 통해 pod를 실행한 뒤에 k9s
같은 도구를 사용해서 container log를 실시간으로 확인해보자. 그 다음 kubectl delete
명령어를 통해 pod를 강제 종료하면 다음과 같이 SIGTERM이 전달된다는 사실을 확인할 수 있다.
$ kubectl apply -f pod.yaml
pod/myloop-pod created
$ kubectl delete pod myloop-pod
pod "myloop-pod" deleted
Processing...
SIGTERM detected.
Cleanup!
Stream closed EOF for default/myloop-pod (myloop-container)
Deployment 형태로 배포하는 경우도 살펴보자. Yaml 파일은 다음과 같이 작성하면 된다.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myloop-deployment
spec:
replicas: 1
selector:
matchLabels:
app: myloop
template:
metadata:
labels:
app: myloop
spec:
containers:
- name: myloop-container
image: myimages/myloop:test
env:
- name: PYTHONUNBUFFERED
value: "1"
이번에도 pod의 경우와 마찬가지로 k9s
같은 도구를 활용해서 container log를 실시간으로 확인할 수 있도록 해두고, deployment를 종료하면 어떤 종료 신호가 전달되는지 확인해보자.
$ kubectl apply -f deployment.yaml
deployment.apps/myloop-deployment created
$ kubectl delete pod myloop-pod
deployment.apps "myloop-deployment" deleted
Processing...
SIGTERM detected.
Cleanup!
Stream closed EOF for default/myloop-deployment-65b6788d45-nqphm (myloop-container)
Deployment를 재시작하는 경우에는 어떨까? kubectl delete
명령어 대신 kubectl rollout restart
명령어를 통해 확인해보자.
$ kubectl apply -f deployment.yaml
deployment.apps/myloop-deployment created
$ kubectl rollout restart deployment myloop-deployment
deployment.apps/myloop-deployment restarted
Processing...
SIGTERM detected.
Cleanup!
Stream closed EOF for default/myloop-deployment-65b6788d45-nz2cm (myloop-container)
정리하자면, kubernetes 환경에서 pod나 deployment를 통해 app을 배포한 뒤 종료 또는 재시작 명령어를 전달하면 기본적으로 SIGTERM을 발생시킨다. 직접적으로 SIGKILL을 전달하는 방법은 없다. 그렇다면 kubernetes 환경에서 컨테이너에 SIGKILL을 전달할 수 있는 방법은 아예 없는 것일까?
Kubernetes에는 이 글에서 다루었던 grace period라는 개념이 있다. Kubernetes는 pod 종료 요청을 받았을 때 우선 SIGTERM을 컨테이너에 보낸 뒤 grace period로 설정된 시간만큼 기다려준다. 만약 지정된 시간동안 기다렸는데도 컨테이너가 종료되지 않는다면 SIGKILL을 발생시킨다. 이 과정을 직접 테스트해보고 싶다면, SIGTERM이 발생된 경우 grace period보다 긴 시간 동안 기다리도록 python 코드의 handler 등록 부분을 수정하면 된다. 이 글에서는 굳이 예제를 따로 다루지는 않겠다.
More Posts
Python FastAPI로 파일 업로드 및 다운로드 가능한 web server 개발하기
FastAPI를 활용하면 파일을 업로드하거나 다운로드할 수 있는 web server를 매우 간단하게 구현할 수 있다. 예제 코드와 함께 최대한 간단하게 파일 업로드 및 다운로드 API를 구현하고 테스트하는 방법을 알아보자.
Python에서 decimal의 precision 문제와 수의 표현 범위
Python의 기본 자료형인 float은 정밀한 수를 담거나 연산할 때 한계가 있다. 좀 더 정밀한 수를 다루기 위해서 decimal이라는 자료형을 사용하는데, 여전히 일부 연산에서는 precision 관련 오류가 발생한다. 어떤 문제가 있는지 살펴보자.
Kubernetes legacy version들의 최후 - Kubernetes apt install GPG error
2024년 11월 초부터 ubuntu에 kubernetes 클러스터를 구성하기 위해 kubeadm, kubelet, kubectl 설치하려는데 GPG error가 발생하고 있다. Kubernetes의 package repository에서 이전 버전들을 더 이상 제공하지 않는 것일까? 어떤 문제인지 살펴보자.
Comments