
우리 프로젝트를 구성하고 있는 쿠버네티스 아키텍처를 설명하고자 한다.
아키텍처를 보기 전에 반드시 알아야 할 개념에 대해 정리해보겠다.
kube-apiserver : 모든 요청의 진입접으로 쿠버네티스 API를 노출하는 컴포넌트다.
etcd : 분산 key-value 저장소로서 클러스터의 모든 데이터를 저장한다.
kube-scheduler : 새로 생성된 Pod를 어느 노드에 배치할 지 결정한다.
등등 지휘자 컴퓨터라고 생각하면 된다.
kubelet : 각 노드에서 실행되는 에이전트로 API 서버와 통신하며 Pod 스펙을 받아 실행한다.
kube-proxy : 네트워크 프록시 역할로 로드밸런싱 처리를 담당한다.
Container Runtime : 실제 컨테이너를 실행하는 소프트웨어이다. Docker, containerd 등
등등 서비스를 실행시켜주는 나머지 컴퓨터들 라고 생각하면 된다.
Pod - 가장 작은 배포 단위, 하나 이상의 컨테이너를 포함한다.
Service - Pod 집합에 대한 네트워크 접근을 제공한다.
Namespace - 클러스터 내 리소스를 논리적으로 분리한다.
Ingress - 클러스터 외부에서 내부 서비스로의 HTTP/HTTPS 라우팅을 맡는다.
Cluster - 여러개의 마스터 노드, 여러개의 워커 노드로 구성된 하나의 큰 시스템

우리의 전체 아키텍처이다. 최대한 깔끔히 보기 좋게 간단하게 정리해 보았는데 이제 한 부분씩 정리해보고자 한다.

Ingress는 클러스터 외부에서 내부 서비스로 들어오는 HTTP/HTTPS 트래픽을 관리하는 첫 진입점이다. nginx Ingress Controller가 워커 노드에서 Pod로 실행되며, hostNetwork 모드를 사용해 워커 노드의 80/443 포트를 직접 사용한다.
cert-manager를 사용해서 Let's Encrypt로부터 SSL 인증서를 자동으로 발급받고 갱신한다. HTTP 요청은 자동으로 HTTPS로 리다이렉트되며, TLS 1.2와 1.3을 지원한다.
라우팅 규칙은 경로 기반으로 설정했다. corazyarcade.kro.kr/ 경로는 react-server로 라우팅되어 프론트엔드를 제공하고, corazyarcade.kro.kr/api 경로는 gateway-server로 라우팅되어 백엔드 API를 처리한다. 이렇게 하나의 도메인에서 경로만으로 프론트엔드와 백엔드를 분리 운영할 수 있다.
쿠키 기반 Session Affinity를 적용해서 같은 사용자의 요청이 동일한 Gateway Pod로 라우팅되도록 했다.

Gateway는 모든 클라이언트 요청이 거쳐가는 단일 진입점으로, Spring Cloud Gateway를 사용해 구현했다. Ingress로부터 /api 경로의 모든 요청을 받아서 적절한 마이크로서비스로 라우팅하는 역할을 담당한다.
Gateway의 핵심 역할은 JWT 기반 인증 처리다. AbstractGatewayFilterFactory를 상속받은 JwtAuthenticationFilter를 구현해서 모든 요청에 대해 JWT 토큰을 검증한다. 검증이 완료되면 토큰에서 추출한 사용자 정보를 HTTP 헤더에 담아 각 마이크로서비스로 전달한다. 이렇게 하면 각 마이크로서비스는 별도의 인증 로직 없이 Gateway에서 전달받은 사용자 정보를 신뢰하고 사용할 수 있다.
Gateway는 replica를 2개로 설정했다. 모든 트래픽의 진입점이기 때문에 Gateway에 장애가 발생하면 전체 서비스가 마비된다. 따라서 SPOF(Single Point of Failure)를 방지하기 위해 최소 2개의 Pod를 운영하며, Ingress의 Session Affinity 설정과 함께 안정적인 서비스를 제공한다.

마이크로 서비스들을 설계할 때, 중요하게 생각한 부분만 정리하자면 Auth 서버도 SPOF를 방지해야 한다고 생각했다. 그렇게 Replica 2개로 구성을 하였다. (relay-server도 부하가 예상되어 Pod을 두개로 구성하였다.)
Self-healing
Kubernetes의 Self-healing 기능을 활용해서 서비스의 안정성을 확보했다. Deployment의 replicas 설정을 통해 원하는 Pod 수를 정의하면, Kubernetes Controller가 지속적으로 현재 상태를 모니터링하면서 Pod이 죽거나 장애가 발생하면 자동으로 새로운 Pod을 생성해서 복구한다.
추가로 Spring Boot Actuator의 health check 엔드포인트를 활용해서 Liveness Probe와 Readiness Probe를 설정했다. Liveness Probe는 10초마다 /actuator/health를 호출해서 Pod의 정상 작동 여부를 확인하고, 3번 연속 실패하면 해당 Pod을 자동으로 재시작한다. Readiness Probe는 Pod이 트래픽을 받을 준비가 되었는지 확인해서, 준비되지 않은 Pod에는 요청을 보내지 않도록 한다.
예를 들어 relay-server에서 메모리 누수로 인해 응답 불가 상태가 되면, Liveness Probe가 실패를 감지하고 Kubernetes가 자동으로 해당 Pod을 재시작한다. 재시작 중에도 다른 replica Pod이 트래픽을 처리하기 때문에 서비스 중단 없이 복구가 가능하다.
독립적인 스케일링
MSA의 가장 큰 장점 중 하나는 각 서비스를 독립적으로 확장할 수 있다는 점이다. 릴레이 코딩 게임에 사용자가 몰려서 relay-server의 부하가 증가하면, relay-server만 replica를 2개에서 5개로 늘려서 트래픽을 분산시킬 수 있다. 반면 auth-server는 로그인 요청이 상대적으로 적기 때문에 2개로 유지하고, compile-server는 비동기 처리를 하기 때문에 1개로도 충분하다.
만약 모놀리식 아키텍처였다면 특정 기능의 부하가 증가해도 전체 애플리케이션을 통째로 복사해야 하기 때문에 불필요한 리소스가 함께 증가한다. 하지만 MSA는 필요한 서비스만 선택적으로 확장할 수 있어서 리소스를 효율적으로 사용할 수 있다.
추가로 HPA(Horizontal Pod Autoscaler)를 설정하면 CPU 사용률이나 메모리 사용량에 따라 자동으로 Pod 수를 조정할 수 있다. 예를 들어 relay-server의 CPU 사용률이 70%를 초과하면 자동으로 replica를 증가시키고, 부하가 줄어들면 다시 감소시켜서 비용을 최적화할 수 있다.

클러스터와 애플리케이션의 상태를 실시간으로 모니터링하기 위해 Prometheus, Grafana, Alertmanager로 구성된 모니터링 스택을 구축했다. 모든 모니터링 컴포넌트는 monitoring 네임스페이스에 배포되어 운영 환경과 분리했다.

Docker-in-Docker 아키텍처
Compile Worker는 사용자가 제출한 코드를 안전하게 컴파일하고 실행하는 역할을 담당한다. 릴레이 코딩 게임의 특성상 다양한 언어의 코드를 실행해야 하고, 악의적인 코드로부터 시스템을 보호해야 하기 때문에 Docker-in-Docker(DinD) 구조로 설계했다.
사용자 코드가 들어오면 Worker는 Docker 데몬에게 새로운 컨테이너 생성을 요청한다. Python 코드라면 python:3.9 이미지로, Java 코드라면 openjdk:17 이미지로 격리된 컨테이너를 생성해서 실행한다. 컨테이너 내부에서 코드가 실행되고 결과가 수집되면 즉시 컨테이너를 삭제한다. 이렇게 하면 악의적인 코드가 실행되더라도 격리된 컨테이너 안에서만 영향을 주기 때문에 호스트 시스템과 다른 사용자의 실행 환경에는 전혀 영향을 주지 않는다.
메모리 제한을 512Mi로 설정해서 무한 루프나 메모리 폭탄 같은 리소스 공격도 방어할 수 있다. 또한 emptyDir 볼륨을 사용해서 컴파일 작업 공간을 제공하고, Pod가 재시작되면 자동으로 정리된다.
RabbitMQ 비동기 작업 처리
Compile Worker는 Service 없이 독립적인 Pod로 실행된다. 외부에서 직접 호출하지 않고 RabbitMQ 메시지 큐를 통해 작업을 받아서 처리하는 Consumer 역할이기 때문이다.
사용자가 코드를 제출하면 compile-server가 작업 정보를 RabbitMQ 큐에 넣고 즉시 "처리 중" 응답을 반환한다. Compile Worker는 큐를 지속적으로 polling하면서 작업이 있으면 꺼내서 처리한다. 이런 비동기 방식은 여러 장점이 있다.
첫째, 사용자는 컴파일 완료를 기다리지 않고 바로 응답을 받는다. 컴파일에 10초가 걸려도 사용자 경험에 영향을 주지 않는다. 둘째, Worker가 죽어도 작업이 큐에 남아있어서 재시도가 가능하다. 셋째, Worker를 여러 개 실행하면 RabbitMQ가 자동으로 작업을 분배해서 처리 속도를 높일 수 있다.

Control Plane은 Kubernetes 클러스터 전체를 관리하고 제어하는 중추 역할을 한다. Corazy Arcade 클러스터는 1개의 마스터 노드에 Control Plane 컴포넌트들이 배포되어 있으며, 9개의 워커 노드를 관리한다.
kube-apiserver - 모든 통신의 중심
kube-apiserver는 Kubernetes API를 노출하는 컴포넌트로, 클러스터의 모든 통신이 거쳐가는 진입점이다. kubectl 명령어를 실행하면 apiserver와 통신하고, 다른 Control Plane 컴포넌트들도 apiserver를 통해서만 클러스터 상태에 접근한다.
etcd - 클러스터의 단일 진실 공급원
etcd는 분산 key-value 저장소로, 클러스터의 모든 데이터를 저장하는 데이터베이스다. Deployment, Service, ConfigMap, Secret 같은 모든 리소스 정보와 클러스터의 현재 상태가 etcd에 저장된다.
kube-scheduler - Pod 배치 결정
kube-scheduler는 새로 생성된 Pod를 어느 워커 노드에 배치할지 결정한다. 단순히 무작위로 배치하는 것이 아니라 여러 조건을 고려해서 최적의 노드를 선택한다.
kube-controller-manager - 원하는 상태 유지
kube-controller-manager는 여러 개의 컨트롤러를 실행하면서 클러스터의 원하는 상태(desired state)와 현재 상태(current state)를 지속적으로 비교하고 조정한다.
고려사항
현재 Corazy Arcade는 단일 마스터 노드로 구성되어 있다. 학습과 개발 단계에서는 충분하지만, 프로덕션 환경에서는 마스터 노드가 단일 장애점(SPOF)이 될 수 있다. 마스터 노드에 장애가 발생하면 기존 워커 노드와 Pod는 계속 실행되지만, 새로운 배포나 스케일링 같은 관리 작업을 할 수 없게 된다.
프로덕션 환경으로 확장할 때는 마스터 노드를 3개 이상의 HA(High Availability) 구성으로 변경하는 것이 권장된다. 여러 마스터 노드가 etcd 클러스터를 구성하고, apiserver도 로드밸런서 뒤에서 다중화되어 안정성을 확보할 수 있다.