Spark Connect on Kubernetes #2: Operator로 만드는 Control Plane
안녕하세요. 토스증권 Data Infra팀 박지원입니다.
지난 편에서는 Spark Connect 서버를 여러 replica로 나누고 Gateway로 요청을 분산시켰습니다. 한 Driver에 몰리던 사용자를 분산해 장애와 리소스 경합을 줄이기 위해 새 세션은 한가한 서버에 배치하고, 기존 세션은 원래 서버로 보냈어요.
그런데 Gateway가 요청을 보낼 서버들은 누가 만들고 관리할까요? Driver가 죽으면 다시 띄워야 하고, 이미지나 설정을 바꾸려면 실행 중인 서버를 교체해야 해요. 이미 세션이 붙어 있는 서버를 그냥 내릴 수는 없고요.
이번 편은 이 서버들의 생명주기를 관리하는 Control Plane 이야기입니다. 개별 서버의 실행과 복구는 Apache Spark Operator에 맡기고, 여러 서버를 하나의 서비스로 운영하는 Spark Tenant Operator를 만들었어요. 두 계층의 역할과 서버 교체 과정을 살펴본 뒤, 장기 실행 중 겪은 Driver 메모리 문제도 다룰게요.

여러 Spark Connect 서버를 어떻게 관리할까
대화형 분석과 무거운 파이프라인을 같은 서버에서 실행하면 자원 경합이 생겨요. 그래서 용도별로 서버를 묶고 이미지, 자원, Spark 설정과 replica 수를 지정했어요. 이 서버 그룹을 테넌트라고 부릅니다.
아래 표는 용도에 따라 테넌트 설정을 나눈 예시예요.
두 Operator의 역할 나누기
테넌트를 운영하려면 개별 서버의 장애를 복구하고 테넌트 전체의 서버 수를 유지해야 해요. 스펙이나 설정이 바뀌면 서버를 교체해야 하고요. 저희는 이 역할을 두 Operator로 나눴습니다.
Kubernetes에서 Deployment가 여러 Pod의 수와 교체를 관리하는 것처럼, 저희는 Spark Tenant Operator가 여러 SparkApplication을 관리해요. SparkTenant에 원하는 설정과 서버 수를 선언하면 Tenant Operator가 그에 맞춰 SparkApplication을 생성하고 교체해요. 각 Application의 Driver를 실행하고 상태를 관측하며 실패 시 복구하는 일은 Apache Spark Operator가 맡습니다.

단일 서버의 기동과 복구는 Apache Spark Operator로
Driver Pod가 생성된 뒤에도 이미지 pull과 프로세스 시작, Spark 초기화를 거쳐야 Connect 요청을 받을 수 있어요. 이 과정에서 실패할 수도 있고, 요청을 처리하던 Driver가 종료될 수도 있습니다.
Apache Spark Operator는 기동 상태를 관측하고 단계별로 상태를 관리해요. 저희는 실패 시 정해진 한도 안에서 Driver를 새로 생성하도록 구성했습니다. 같은 SparkApplication 안에서 새 attempt가 시작되며, 기존 세션은 복원되지 않아요.

주요 상태와 재시작 경로를 단순화한 그림입니다. 실패는 기동 중에도 발생할 수 있어요. DriverReady 이후에는 Executor 확보 상태에 따라 RunningHealthy 등으로 변경됩니다.
Apache Spark Operator의 재시도가 끝나면 Tenant Operator가 종료된 Application을 삭제하고 새로 생성해요. 다만 drain 중이 아닌 정상 서버가 하나 이상 남아 있을 때만 진행하며, 하나도 없으면 장애로 판단하고 자동 교체를 보류해요. 새 Application을 계속 만들어 Apache Spark Operator의 재시도 한도를 우회하면, 같은 실패가 끝없이 반복될 수 있기 때문이에요.
준비된 서버로 전환하고 기존 세션 마무리하기
서버가 정상적으로 실행 중이어도 교체가 필요한 때가 있어요. Spark 버전을 올리거나 Driver 메모리를 늘리는 경우입니다. 테넌트 설정을 바꾸면 Tenant Operator가 변경된 설정으로 새 서버들을 준비해요.
문제는 기존 서버에서 사용자가 여전히 작업하고 있다는 점이에요. 세션 상태가 Driver 메모리에 남아 있어서, 새 서버가 준비됐다고 기존 서버를 바로 내릴 수는 없습니다. 그래서 서버를 준비하는 시점과 신규 세션을 전환하는 시점, 기존 서버를 종료하는 시점을 나눴어요.
새 generation의 서버를 모두 준비한 뒤 신규 세션의 목적지를 전환하는 Blue-Green 방식을 사용했어요. 기존 세션의 요청은 계속 원래 Driver로 보내고, 기존 서버는 세션 종료를 기다리는 drain 과정을 거쳐 정리합니다.
교체 전후의 서버 집합은 generation으로 구분합니다. attempt는 같은 Application의 실행 시도를 구분하는 값이에요. Application이 재시작돼도 generation은 그대로이고, 새 generation을 만들면 새로운 Application들이 각자의 생명주기를 시작해요.

새 서버가 모두 준비될 때까지
설정이 바뀌면 Tenant Operator는 새 generation을 만들어요. 기존 서버는 계속 요청을 처리하고, 새 서버가 목표 수만큼 모두 준비돼 일정 시간 안정적으로 유지되면 전환합니다.

새 서버가 제한 시간 안에 준비되지 않으면 교체를 취소하고 기존 서버를 유지해요. 마지막 성공 설정이 남아 있으면 그 설정으로 되돌려 같은 실패가 반복되지 않도록 합니다.
신규 세션의 목적지만 전환하기
Tenant Operator가 currentGeneration을 변경하면 Gateway는 신규 세션을 새 generation으로 보내요. 기존 서버는 신규 세션을 받지 않는 drain 상태로 남아 원래 세션의 요청을 처리합니다.

전환이 끝나도 기존 서버는 남아 있어요
기존 서버는 남은 세션이 모두 종료되면 삭제해요. 세션 수를 확인할 수 없으면 기다리지만, drain 시작 후 기본 90분이 지나면 서버를 삭제하므로 남아 있는 세션은 끊길 수 있습니다.
업데이트는 신규 세션의 목적지를 새 서버로 전환하면 완료돼요. 기존 서버 정리는 별도로 진행하므로 오래 유지되는 세션이 다음 설정 변경을 막지는 않습니다.
Spark Connect 서버는 언제부터 요청을 받을 수 있을까?
새 서버로 전환하려면 실제로 Connect 요청을 받을 준비가 됐는지 확인해야 해요.
Driver가 Ready면 새 세션을 받아도 될까
Apache Spark Operator의 DriverReady는 Driver Pod의 Ready 조건을 읽은 결과예요. 저희는 이를 새 서버로 전환하는 기준으로 사용하므로, readiness probe가 Connect 서버의 연결 준비 상태를 확인하도록 구성해야 했어요.
처음 시도한 Spark UI의 /connect/json HTTP probe에는 문제가 있었어요. Connect 서버가 뜨기 전에도 해당 경로가 다른 페이지로 리다이렉트됐고, 이 응답이 probe 성공으로 처리됐습니다. UI만 뜬 상태에서 Driver가 Ready로 표시될 수 있었던 거죠.
그래서 Connect 포트에 gRPC 연결을 시도하고 최대 3초 안에 연결이 성립하는지 확인하는 exec probe로 바꿨어요. Spark Tenant Operator가 이 probe를 Driver Pod 템플릿에 넣습니다. 실제 세션이나 쿼리를 실행하지는 않으므로 Executor 용량까지 보장하지는 않아요.
Executor 연결과 Driver Ready가 서로를 기다린다면
Connect 포트를 readiness 기준으로 삼자 Spark의 기동 순서와 맞물리는 문제가 드러났어요. Connect 서버는 SparkContext 초기화가 끝난 뒤 포트를 엽니다. 반면 Executor 생성은 그 초기화 과정에서 시작돼요.
Executor는 Driver의 headless Service 이름을 DNS로 조회해 주소를 찾아요. 기본적으로 Driver Pod가 Ready 상태가 된 뒤에야 이 주소가 DNS에 게시됩니다. Spark에는 이를 고려해 Executor를 생성하기 전에 Driver Ready를 기다리는 로직이 있었어요.
초기화 중에는 Ready를 기다리지만, Ready가 되려면 초기화가 끝나야 하는 상황이 된 거예요.
이 문제는 Executor 수를 고정하는 Static Allocation 환경에서 겪었어요. 초기화 중 생성된 Executor가 Driver 주소를 찾지 못해 실패했고, 실패가 한도까지 누적되면 Driver도 종료됐습니다. 대기 타임아웃 뒤 정상 기동하기도 했지만, 기동 타이밍에 따라 결과가 달라졌어요.

저희는 spark.kubernetes.executor.useDriverPodIP=true로 Executor가 Service DNS 대신 Driver Pod IP로 연결하도록 우회했어요. Connect 요청을 받기 위한 readiness 조건은 유지할 수 있었습니다.
Driver 발견과 요청 수용을 분리하기
DNS 경로에서도 해결할 수 있도록 Spark에 spark.kubernetes.driver.service.publishNotReadyAddresses 설정을 추가했어요. 이 설정을 활성화하면 Ready 전에도 Driver 주소를 게시하고, Executor 생성 전의 Ready 대기도 생략하도록 했습니다.¹
주소만 게시하면 초기화 중 대기가 남고, 대기만 없애면 Executor가 주소를 찾지 못해 두 동작을 함께 바꿨어요. Executor가 Driver에 연결할 준비와 Connect 요청을 받을 준비를 분리한 거예요.

가용성과 진행 상태를 나눠 표현하기
일부 서버가 복구 중이어도 나머지 서버는 요청을 처리할 수 있어요. 그래서 테넌트의 가용성과 진행 중인 동작을 나눠 표시했습니다.
DegradedRestartingUnavailableRestartingReadyUpdating개별 서버의 복구부터 테넌트 단위의 교체까지, 이제 서버 운영의 흐름을 두 Operator로 연결했어요. 원하는 설정과 서버 수를 선언하면 새 서버를 준비하고, 신규 세션을 전환한 뒤 기존 서버를 정리합니다. 실행 중인 세션을 고려한 교체 절차를 갖추면서, 운영 중 발견한 문제를 고친 설정도 같은 방식으로 적용할 수 있게 됐어요.
장기 실행 서버에서 겪은 메모리 문제
Spark Connect 서버를 오래 운영하다 보니 Heap 사용량은 안정적인데 Driver의 전체 메모리는 계속 늘어 OOMKilled로 종료되는 일이 있었어요. 재시작하거나 memory overhead를 늘려도 반복돼 원인을 살펴봤습니다.
Heap 크기와 물리 메모리 할당의 영향을 줄이려고 Xms와 Xmx를 8 GiB로 고정하고 AlwaysPreTouch를 적용했어요. GC 후 Heap 사용량은 약 0.43 GiB였지만 프로세스와 cgroup 메모리는 계속 증가했고, NMT가 추적한 JVM 메모리 변화로도 설명하기 어려웠습니다.
이어서 glibc가 메모리를 할당하고 해제하는 방식을 살펴봤어요. glibc는 스레드 간 할당 경합을 줄이려고 여러 arena를 사용합니다. 그런데 각 arena에 해제된 메모리가 남으면, 다른 arena에서 메모리가 필요해도 이를 바로 재사용하기 어려워요. 저희는 arena 수를 줄이면 이렇게 남는 메모리도 줄어들 수 있다고 보고, MALLOC_ARENA_MAX=2로 제한해 다시 비교했어요.
MALLOC_ARENA_MAX=2설정 후 72시간 관측에서는 메모리가 계속 늘어나는 현상이 나타나지 않았어요.
운영에서는 Hadoop의 기본값 4를 참고하고 코어 수를 고려해, Driver와 Executor 각각에 max(4, cores)를 적용했어요. 테넌트가 직접 지정한 값이 있으면 우선합니다.
Tenant Operator는 새 Application을 만들 때 이 설정을 넣어줘요. 기존 서버에는 앞서 설명한 Blue-Green 교체와 drain 절차로 적용합니다.
마치며
개별 서버의 복구는 Apache Spark Operator에, 테넌트의 서버 수와 교체는 Spark Tenant Operator에 맡겼어요. Gateway와 함께 신규 세션의 전환과 기존 세션의 종료를 나눠 처리하도록 했습니다.
작성 시점 기준으로 배치 파이프라인부터 스트리밍 처리, 대화형 분석까지 다양한 워크로드를 20개 이상의 테넌트로 나눠 운영하고 있어요. 전체 테넌트의 확장 한도를 합하면 수천 vCPU 규모이며, Spark를 활용하는 워크로드도 계속 늘려가고 있습니다.
서버를 관리하는 구조와 함께, 그 안에서 실행되는 Spark의 설정도 조정했어요. 이번 편에서 다룬 두 설정을 정리하면 다음과 같습니다.
spark.kubernetes.executor.useDriverPodIPtruespark.kubernetes.driverEnv.MALLOC_ARENA_MAX / spark.executorEnv.MALLOC_ARENA_MAXmax(4, cores)다음 편에서는 이 실행 환경에서 사용자를 식별하고, 허용된 데이터에 접근하도록 만드는 인증·인가 이야기를 다룰게요.
¹ https://github.com/apache/spark/pull/57242 — 이 설정은 upstream에서 Spark 4.3.0용으로 추가됐으며, 이전 버전에서는 별도 백포트가 필요합니다.
Appendix — Apache Spark Operator를 선택한 이유
저희는 서버의 상태를 얼마나 세분화해 관리하는지를 기준으로 Apache Spark Operator를 선택했어요. 아래는 Kubeflow Spark Operator 2.5.2의 SparkConnect와 Apache Spark Kubernetes Operator 1.0.0의 SparkApplication을 비교한 내용입니다.
SparkConnectSparkApplication