AWS 기술 블로그
롯데이커머스 사례로 본 Amazon EKS 운영 안정성 확보하기
롯데이커머스는 온라인 쇼핑몰인 롯데온을 서비스하고 있으며, 약 40여개의 마이크로서비스로 구성되어 있습니다.
롯데온은 이커머스 시장에서 지속적으로 성장함에 따라 시스템으로 유입되는 트래픽 또한 지속적으로 증가하고 있습니다. 특히 할인 행사 기간에는 트래픽이 급격하게 증가하는 추세을 보입니다. 트래픽의 변화에 따라 애플리케이션이 실행되는 Amazon EKS Pod가 확장 되거나 축소되는데 고객은 이 과정에서 예상치 못한 문제를 겪었습니다. 또한 애플리케이션 업데이트 시 이미 제거된 EKS Pod로 여전히 요청이 전달되는 문제를 겪기도 했습니다. 이번 게시글에서는 Amazon EKS 운영 중 겪을 수 있는 문제들을 롯데이커머스의 사례를 중심으로 소개하고 해결방법을 제시합니다.
Amazon EKS란?
Amazon Elastic Kubernetes Service(Amazon EKS)는 오픈소스 Kubernetes를 효과적으로 운영할 수 있는 관리형 Kubernetes 서비스입니다. Kubernetes란 컨테이너로 실행되는 애플리케이션의 배포, 확장 및 관리를 자동화할 수 있는 오픈 소스 시스템입니다. Kubernetes는 다양한 비즈니스 요구사항을 수용할 수 있을 만큼 유연하지만 그만큼 관리의 어려움도 있습니다.
오픈소스 Kubernetes와 완벽히 호환되는 Amazon EKS는 Kubernetes 컨트롤 플레인을 관리하는 역할을 합니다. Kubernetes의 컨트롤 플레인은 컨테이너 스케쥴링 및 컨테이너 가용성 관리, 클러스터에서 발생하는 데이터를 저장하는 등의 중요한 작업을 수행합니다.
고객은 컨트롤 플레인 관리를 EKS에 맡기고, 컨테이너가 실행되는 데이터 플레인만 관리하면 되므로 운영 부담을 크게 줄일 수 있습니다. 특히 Amazon EKS에서 AWS Fargate를 사용하는 경우 데이터 플레인의 인프라 관리까지 AWS에 위임하여 관리할 서버 없이 Kubernetes를 안정적으로 운영할 수 있습니다.
롯데이커머스가 Amazon EKS를 선택한 이유
롯데이커머스는 1996년도부터 온프레미스 기반의 모놀리식 아키텍처로 온라인 쇼핑몰을 운영해왔습니다. 이 때 고객은 서버 증설에 3개월 이상이 소요되어 대량의 트래픽에 대응하지 못하는 문제를 겪었습니다. 또한 물리적인 인프라까지 직접 운영해야 하고, 애플리케이션 배포에 많은 시간이 소요되어 시장 변화에 빠르게 대응하기가 어려웠습니다.
이후 새롭게 진행하는 프로젝트들에서는 점차 서비스를 분리하고, 그동안 쌓은 노하우를 바탕으로 롯데온에 Amazon EKS 기반의 마이크로서비스 아키텍처를 적용하였습니다. 아래 그림은 AWS Summit Korea 2022에서 “롯데ON, 클라우드 네이티브로 거듭나기” 세션에서 소개했던 아키텍처입니다.
그렇다면 롯데이커머스에서는 AWS의 수많은 서비스들 중 왜 Amazon EKS를 선택했을까요?
첫 번째로 AWS의 완전 관리형 서비스라는 것이었습니다. 롯데이커머스 내에 Kubernetes를 운영할 수 있는 인력이 부족했기 때문에 운영 부담을 최소화해야 했습니다. Amazon EKS를 사용하면 배포 자동화와 서비스간의 통신, 문제가 발생했을 경우의 자가 복구 등 마이크로서비스 운영에 효과적인 기능들을 제공합니다.
두 번째는 손쉽게 AWS의 다른 서비스들과의 통합이 가능하다는 점이었습니다. AWS IAM 서비스로 사용자를 인증하여 보안을 강화하고, Amazon ECR로 컨테이너 이미지를 효율적으로 관리할 수 있었습니다. 또한 Application Load Balancer의 리스너에 정의된 규칙에 따라 하나의 도메인으로 여러 서비스에 요청을 분배할 수 있었습니다. Pod에서 발생하는 데이터를 영속적으로 보관해야하는 경우 Amazon EBS와 Amazon EFS를 Pod에 마운트하여 데이터를 저장할 수 있었습니다. 롯데이커머스에서는 이 외에도 여러 AWS 서비스들을 통합한 아키텍처를 운영하고 있습니다.
마지막으로 마이크로 서비스로 구성된 컨테이너 기반의 애플리케이션을 효율적으로 운영하기 위해서는 자동화가 필요했습니다. 자동화가 안 된다면 운영에 상당히 많은 시간을 소비 해야 하기 때문에 생산성이 떨어지고, 사용자 실수가 발생할 수 있습니다. 이는 365일 24시간 운영해야하는 이커머스 비즈니스에 심각한 영향을 줄 수 있습니다. 또한 대규모 트래픽에도 서비스를 안정적으로 운영하기 위해 확장 가능하면서도 비용 효율적이어야 합니다. 롯데이커머스는 이와 같은 요구사항들을 충족하기 위해 Amazon EKS를 선택했습니다.
Amazon EKS Pod 축소(Scale in) 시 발생했던 문제
사전 지식 : Amazon EKS의 Auto Scaling 기능
Amazon EKS는 Pod 리소스를 관리하기 위해 몇 가지 오토스케일링 기능을 제공합니다.
먼저 Cluster Autoscaler는 Pod가 실행될 리소스가 부족한 경우 작업자 노드를 확장하는 역할을 합니다. 반대로 실행 중인 Pod에 비해 작업자 노드의 수가 많은 경우 작업자 노드를 축소합니다. 여기서 작업자 노드는 Pod가 실행되는 EC2 인스턴스를 의미합니다. 즉, Cluster Autoscaler는 EC2 인스턴스 수를 조정하기 위해서 AWS로 API 요청을 하게 되고, 결과적으로 오토스케일링 그룹의 desired 값을 조정합니다. 이는 Kubernetes 자체의 기능이 아닌 부가적인 기능이기 때문에 애드온으로 실행되는 Cluster Autoscaler가 이를 담당합니다.
Horizontal Pod Autoscaler(HPA)는 Pod의 평균 리소스 사용량에 따라 Pod 수를 조정하는 역할을 합니다. Vertical Pod Autoscaler(VPA)는 하나의 Pod가 사용할 수 있는 리소스 사용량 한도를 늘릴 수 있습니다. HPA 및 VPA는 오픈소스 Kubernetes에 내장되어 기본으로 제공되는 기능입니다.
마지막으로 karpenter는 AWS가 오픈 소스로 출시한 애드온 입니다. 오토스케일링 그룹의 설정을 변경하는 Cluster AutoScaler에 비해 EC2 API를 사용하여 더 다양한 인스턴스 유형과 빠른 확장의 장점이 있습니다.
롯데이커머스에서는 Cluster Autoscaler와 Horizontal Pod Autoscaler를 사용합니다. Amazon EKS 클러스터를 운영 중에 HPA로 인해 Pod가 축소될 때 발생했던 문제를 기술하고, 그 해결방법에 대해 설명하겠습니다.
문제 발생 원인
롯데이커머스에서 서비스 중인 롯데온은 국내 상위권의 온라인 쇼핑몰이기 때문에 할인 행사를 진행하면 대규모의 사용자가 접속하여 서비스를 이용합니다. 이런 이유로 고객은 부하테스트를 필수로 진행합니다. 클라이언트의 요청은 Kubernetes의 ingress 오브젝트로부터 생성된 Application LoadBalancer로 전달 됩니다. Application Loadbalancer는 대상 그룹에 속한 작업자 노드로 트래픽을 분배하고, 최종적으로 Pod로 전달됩니다. 이 때 Pod들은 Kubernetes의 HPA 오브젝트를 통해 확장 및 축소됩니다.
Pod의 사용량이 증가하면 Kubernetes의 Horizontal Pod Autoscaler는 Amazon EKS 클러스터의 작업자 노드에 새로운 Pod를 추가합니다. 이를 Scale out이라고 합니다. 반대로 컴퓨팅 리소스에 여유가 생기게 되면 리소스가 낭비되지 않도록 Pod 수를 줄입니다. 이를 Scale in 이라고 하는데, 이 때 일정 시간 동안 오류를 반환하는 문제가 발생하였습니다.
이 문제의 원인은 Pod의 수명 주기 동작에 있습니다. 먼저 Kubernetes의 아키텍처를 살펴보면서 Pod가 어떻게 생성되고 삭제되는지 알아보겠습니다.
아래 그림과 같이 Kubernetes 클러스터에는 마스터 노드와 작업자 노드가 존재합니다. 마스터 노드에 포함된 API 서버는 Kubernetes 클러스터의 핵심 컴포넌트입니다. Kubernetes 내 여러 컴포넌트들 간의 통신은 대부분 API 서버를 거치게 됩니다. 작업자 노드에는 kubelet이 Agent 방식으로 노드에 설치되고 kube-proxy는 Kubernetes의 DaemonSet으로 실행됩니다. 각 작업자 노드와 API 서버 간의 통신은 kubelet이 담당합니다.
만약 Kubernetes 클러스터 관리자가 kubectl 명령어로 Pod를 생성하는 명령어를 실행하면 API 서버로 요청이 전달되고, API 서버는 Pod를 생성하는 역할을 담당하는 컨트롤러 매니저들에게 API를 전달합니다. 최종적으로는 kubelet으로 Pod 생성 명령을 전달됩니다. 명령을 수신한 kubelet은 작업자 노드에 컨테이너를 실행합니다. 유사한 방식으로 삭제 절차도 진행됩니다.
Pod의 삭제 과정을 더 깊이 있게 살펴보겠습니다. 아래 그림과 같이 kubectl 명령을 통해 API 서버가 Pod 삭제 명령을 수신할 경우 최종적으로 Kubelet에 삭제 명령이 전달됩니다. 이 후 Kubelet은 대상 Pod를 삭제하는 절차를 수행합니다.
Kubernetes에는 Service라는 리소스가 존재합니다. Service는 다수의 Pod들을 대표하는 Endpoint를 생성합니다. 이는 마치 로드밸런서와 같이 유입되는 트래픽을 분배하는 역할을 합니다. 이 때 내부적으로 작업자 노드의 iptables rule을 사용합니다. 여기서 Service 변경사항에 따라 iptables rule을 수정하는 역할은 kube-proxy가 담당합니다.
Kubernetes에서 Pod의 Scale in은 한번에 모든 Pod를 제거하지 않습니다. 설정된 비율에 따라 아래 그림과 같이 Kubernetes의 컴포넌트 중 Endpoints controller는 Service Endpoint에 지정된 Pod들 중 삭제 대상 Pod를 로드 밸런싱 대상에서 제외시킵니다.
Kubernetes 내 컴포넌트들 간에는 상호 의존성을 갖지 않고 API 서버를 통해서 통신을 합니다. 이러한 이유로 Service Endpoint에서 대상 Pod를 제거하는 요청을 API 서버에 전달하게 되고, 최종적으로 대상 작업자 노드의 kube-proxy를 통해 작업자 노드의 iptables rule을 수정하게 됩니다.
그 결과 클라이언트의 요청이 더이상 종료된 Pod로 전송되지 않게 됩니다. 컨테이너를 삭제하는 절차와 iptables를 수정하는 절차는 아래와 같이 동시에 실행되며, 고객이 겪은 문제는 이와 같은 동시성으로 인해 발생했습니다.
Endpoint에서 Pod의 ip가 제거되기 전에 컨테이너가 삭제되어버리면 외부에서 유입되는 트래픽이 여전히 삭제된 컨테이너에게 전달되면서 502 에러가 발생하게 되는 것입니다.
문제 해결
그렇다면 이 문제는 어떻게 해결해야 할까요? 롯데이커머스의 해결책은 Pod의 컨테이너의 종료를 지연시켜 Service의 Endpoint에서 대상 Pod가 정상적으로 제외될 수 있도록 충분히 기다리는 것이었습니다. 이를 위해서는 kubelet이 Pod 내 컨테이너들을 종료시키는 절차에 대해 알아볼 필요가 있습니다. kubectl 명령어로 삭제 요청이 Kubernetes의 API 서버로 전달 되면 최종적으로 작업자 노드의 kubelet으로 전달됩니다.
kubelet은 Pod 내 컨테이너들에서 실행되는 애플리케이션을 종료하기 위해 SIGTERM 시그널을 보냅니다. 하나의 Pod 안에는 여러 개의 컨테이너가 실행될 수 있는데 각 컨테이너의 PID 1번 프로세스에게 SIGTERM 명령을 실행하는 것입니다. SIGTERM 시그널의 특징은 애플리케이션이 종료 로직을 실행할 수 있도록 일정 시간동안 기다려 준다는 것입니다.
여기서 Pod 설정에 terminationGracePeriodSeconds라는 설정값이 존재하는데 설정된 시간(기본 30초)만큼 기다린 후에도 애플리케이션이 종료되지 않을 경우 SIGKILL 시그널로 강제 종료 시킵니다. 컨테이너 내에서 실행되는 모든 프로세스들은 PID 1번 프로세스로부터 fork 된 것이기 때문에 PID 1번 프로세스가 종료된다면 컨테이너 또한 종료됩니다. 물론 30초 내에 모든 컨테이너가 종료된다면 더 기다리지 않고 Pod가 종료됩니다.
import signal
import time
class GracefulKiller:
kill_now = False
def __init__(self):
signal.signal(signal.SIGTERM, self.exit_gracefully)
def exit_gracefully(self, *args):
self.kill_now = True
if __name__ == '__main__':
killer = GracefulKiller()
while not killer.kill_now:
time.sleep(1)
print("SIGTERM 시그널로 인해 프로그램이 종료되었습니다.")
위의 Python으로 작성된 코드의 exit_gracefully 함수와 같이 애플리케이션 코드 상에서 SIGTERM 시그널을 수신했을 때의 종료 로직을 구현할 수 있습니다. 이 부분에 Pod의 종료를 일정 시간 동안 기다리도록 코드를 구현합니다. 주의해야할 사항은 애플리케이션에서 너무 오랜 시간 동안 대기를 하게 된다면 그만큼 Pod 재시작이나 업데이트 절차가 지연됩니다.
이 경우 아래와 같이 Pod에 terminationGracePeriodSeconds를 설정하여 최대로 기다리는 시간을 설정하여 무한정 대기하는 것을 방지할 수 있습니다.
더 간단한 방법으로는 Pod 설정 중 Prestop hook 설정을 사용하는 것입니다. Prestop hook은 Pod의 수명 주기를 관리하기 위한 명령어 또는 script를 지정할 수 있습니다. 그러면 Pod 종료 시 해당 스크립트를 실행하게 되는데 여기에 sleep 명령을 넣게 되면 컨테이너가 삭제되는 시간을 잠시 늦출 수 있습니다. 이 때도 무한정 대기를 방지하기 위해 terminationGracePeriodSeconds 설정은 필요합니다.