클라우드 플랫폼은 데이터센터를 구축하지 않아도 앉아서 1분 안에 컴퓨팅 노드 생성 및 네트워크 구축 등 손쉽게 인프라를 구성할 수 있도록 해준다. 그러나 주의해서 인프라를 설계하지 않으면 많은 비용이 발생한다.
사내에서 운영했던 ML 기반 학습 서비스는 사용자들에게 딥러닝 기반의 실습을 제공하기 위해 워크로드에 GPU 가 필요한데 클라우드 컴퓨팅 환경에서는 GPU 사용 비용이 굉장히 비싸다. 비용을 절감할 수 있는 방법이 무엇이 있을까 찾아보다가, GCP 자격증을 공부할 때 알게되었던 선점형 VM을 도입하면 비용이 60~70% 정도 절감할 수 있다는 것을 알게 되었다.
오늘은 운영하던 서비스에 선점형 VM을 도입한 내용을 공유하고자 한다. 참고로 우리는 GCP를 사용하고 있으며 선점형 VM도 GCP를 기준으로 설명할 것이다. AWS에도 spot instance라는 개념이 있다고 하니 각자의 상황에 맞게 도입해보자.
우선 선점형 VM에 대해서 알아보자.
선점형 VM은 최대 24시간 동안 지속되고 가용성을 보장하지 않는 인스턴스이다. 즉 생성 후 최대 24시간 동안 지속되며, 상황에 따라 노드가 종료될 수 있다. 이런 특성 때문에 선점형 VM에서는 SLA(Service Level Agreement)가 적용되지 않는다. (여기서 SLA는 서비스 수준 계약으로, 우리가 클라우드 공급자로 부터 기대하고 있는 서비스 수준을 기술한 문서로, 이를 지키지 못했을 때 구체적인 불이익 등을 명시한 계약이다.)
그러면 선점형 VM은 어떤 작업을 실행하는 데 적합할까? 다음과 같은 경우에 적합할 수 있다.
- short term job
- 내결함성이 있는 job
선점형 VM은 시스템 이벤트로 인해 언제든지 종료될 수 있다. 그러나 시스템 이벤트로 인해 종료될 확률은 일반적으로 낮다. 그러나 선점형 VM 특성상 24시간 동안 실행된 후는 항상 한번은 종료된다. 이 종료되는 현상을 선점이라고 하고 선점 절차는 다음과 같다.
TERMINATED
상태로 전환한다. 사용자의 요청을 직접적으로 처리하는 백엔드 애플리케이션의 경우 선점형 노드를 도입하기는 어렵다. 항상 문제 없이 가동되고 있어야 하기 때문에 최대 수명주기가 24시간에다가 SLA를 보장하지 않는 선점형 VM과는 맞지 않다.
우리 서비스의 ML Worker는 사용자의 머신 러닝 모델 학습 및 추론 요청을 처리한다. ML Worker는 job의 최대 시간이 5분 정도이다. 사용자가 다른 학습컨텐츠를 다 읽고 실제 실습을 돌렸을 때만 가동된다. ML Worker는 GPU를 사용하고 있기 때문에 사용 비용이 상대적으로 비싸다.
ML Worker가 처리하는 작업 특성과, VM 비용을 고려해보았을 때 ML Worker의 인프라로 선점형 VM을 도입하고자 결정하였다.
SLA를 보장하지 않기 때문에 노드가 언제 종료되고 다시 실행될지 모른다. 따라서 ML Worker가 VM이 종료되는 이벤트를 받았을 때 현재 처리하고 있는 파이프라인 Job을 안전하게 처리한 후 사용자에게 어떻게 안내할 것인지 설계해야 한다. 즉 내결함성을 구축해야 한다.
ML Worker는 subscriber로써 사용자로부터 온 모델 학습 및 추론 요청 메시지를 구독하여 파이프라인 모델을 통해 모델의 학습 진행 상태를 데이터베이스에 업데이트한다.
다음의 상황을 고려해서 선점형 VM의 종료 이벤트를 받았을 때 현재 수행되고 있는 파이프라인을 실패 처리 하도록 설계하였다.
자 그러면 이제 노드의 종료 이벤트를 코드 레벨에서 인지하고, 처리를 할 수 있도록 해야한다.
선점 알림은 ACPI G2 Soft Off 신호를 VM에 전송한다. VM이 종료될 때 삭제 논리를 작성하는 가장 쉬운 방법으로 GCP 문서에서는 종료 스크립트를 추천하지만, 우리의 경우 애플리케이션 내부의 처리 로직이 수행되어야 하기 때문에 다른 방법이 필요하다.
문서 를 참고하여 애플리케이션에서 SIGTERM 핸들러를 사용하여 구현하기로 결정하였다. 우리는 GKE(Kubernetes) 환경에서 애플리케이션이 구동되고 있기 때문에 애플리케이션 내에 로직을 구현하기 전에, 구동하고 있는 pod 컨테이너가 해당 이벤트를 정상적으로 받을 수 있는지 실험해 보아야 했다.
간단한 sigterm handler를 작성하였다.
class GracefulKiller:
def __init__(self):
signal.signal(signal.SIGINT, self.exit_gracefully)
signal.signal(signal.SIGTERM, self.exit_gracefully)
def exit_gracefully(self, *args):
print("exit gracefully!!!")
그리고 GKE 내에서 노드 종료 이벤트를 발생시키고 로깅이 잘 되는지 보았다. GCP 문서 어딘가에서는 노드를 직접 종료해보라는 식으로 설명을 했던 것 같은데, 그것보다는 GCP 문서에 나와 있는 대로 VM 종료 이벤트를 발생시키는 선점 시뮬레이션을 하는 것이 훨씬 간편하다.
gcloud CLI에 프로젝트 및 zone을 세팅하고 명령어를 수행해보자.
gcloud compute instances simulate-maintenance-event $PVM \
--zone=$ZONE
참고로 $PVM은 GKE 안에 있는 노드 이름이다.
노드 종료 이벤트를 실행했으나, Pod에는 아무런 로그가 남지 않았다..
ML Worker는 Cloud SQL Auth Proxy 라고 해서 사이드카 패턴으로 DB 암호화 커넥션 관련 컨테이너를 같이 띄우고 있는데, ML Worker container에서는 이벤트를 못 받고, 사이드카 컨테이너만 이벤트를 받았다..
한참을 삽질하다가, Docker 환경이 힌트가 되지 않을까 해서 Docker graceful termination으로 검색해 자료를 찾아보니까 Hynek Schlawack 님께서 포스팅한 자료에 답이 있었다.
즉 내 애플리케이션을 실행하는 커멘드에 문제가 있었는데 다음과 같이 쉘 스크립트를 통해 실행해서였다.
/bin/sh -c python manage.py runworker all
이런 방식으로 실행하면 쉘이 애플리케이션을 새로운 프로세스에서 띄우기 때문에 도커로부터 종료 시그널을 받을 수 없다. 따라서 애플리케이션을 exec command를 사용해서 실행시켜야 종료 시그널을 받을 수 있다.
exec python manage.py runworker all
성공! 이벤트를 받았다.
이제 종료 이벤트를 처리 해야하는 컴포넌트들에 대해 종료 이벤트를 받았을 때 핸들러를 작성하도록 하였다.
먼저 인터페이스를 작성하였다.
class GracefulTerminationService(metaclass=ABCMeta):
"""
Interface for graceful termination.
"""
def __init__(self) -> None:
GracefulTaskKiller.get_instance().register_service(self)
@abstractmethod
def graceful_terminate(self):
"""
In the subclass, when a specific signal or some event is received,
the action to be processed before the system is shutdown should be
specified in the corresponding function.
"""
pass
그리고 GracefulTaskKiller를 확장하였다.
class GracefulTaskKiller(SingletonInstance):
services = set()
def __init__(self):
signal.signal(signal.SIGINT, self.exit_gracefully)
signal.signal(signal.SIGTERM, self.exit_gracefully)
def register_service(self, service: GracefulTerminationService):
self.services.add(service)
def unregister_service(self, service: GracefulTerminationService):
self.services.remove(service)
def exit_gracefully(self, signum, frame):
for service in self.services:
logger.info("exit gracefully!!")
service.graceful_terminate()
sys.exit(0)
GracefulTaskKiller는 전체 프로그램에 하나만 존재 해야하기 때문에 싱글톤 패턴으로 구현하였다. killer는 graceful terminate가 필요한 서비스들의 목록들을 관리하고 있다가, VM 종료 이벤트를 받으면 해당 서비스들의 핸들러를 호출하여 graceful terminate가 가능하도록 하였다.
작성한 코드를 dev 환경에서 테스트 하였는데, 머신러닝 실습을 돌리다가 노드 선점 이벤트를 주게 되면 로그를 띄우면서, 파이프라인을 실행하는 TaskRunner가 파이프라인을 실패처리하고 종료하였다.
선점형 노드를 도입해서 컴퓨팅 비용을 많이 절감할 수 있었고, 또 VM이 종료되어도 애플리케이션에 내결함성을 가지도록 로직을 추가하게 되었다.
추후 파이프라인의 중간 상태를 저장하고 이어서 작업할 수 있는 메커니즘을 설계해서 보다 효율적인 내결함성 로직을 구축하면 더 좋을 것 같다.