여러 쿠버네티스 클러스터를 백엔드 하나로 다루는 법: 설정 증설 대신 위임 서비스
k8s 멀티테넌시 서비스 오픈 이후 여러 클러스터에 워크로드를 실행해야 하는 요구사항이 생겼습니다.
설정을 늘리는 대신 위임 서비스를 둔 과정과 그 판단 근거를 정리합니다.
TL;DR
k8s 멀티테넌시 서비스 오픈 이후 여러 클러스터에 워크로드를 실행해야 하는 요구사항이 발생했습니다.
클러스터별 설정을 늘리는 방법 대신 위임 서비스(delegator)를 두고 k8s 의존을 그 뒤로 분리해, 클러스터를 추가해도 재배포 없이 DB 등록으로 끝나게 했습니다.
대안과 전제, 왜 그렇게 판단했는지는 아래에서 풀어 씁니다.
배경: 애플리케이션이 k8s에 워크로드를 만든다
GPU 워크로드를 쿠버네티스(k8s) 클러스터에서 실행하는 서비스를 개발하고 운영하고 있습니다. 애플리케이션(백엔드)이 컨트롤플레인 역할을 맡아 사용자의 워크로드 생성·관리 요청을 받아 k8s API로 실행합니다. 애플리케이션이 곧 k8s API의 클라이언트입니다.
초기 오픈은 단일 클러스터였습니다. 애플리케이션은 사용자 워크로드와 같은 클러스터 안에 있었고, 그 클러스터의 k8s API 서버와 통신하는 방식이었습니다. 애플리케이션과 API 서버가 같은 네트워크 안에 있어 도달성(요청이 상대에 닿는지)을 신경 쓸 일이 없었습니다.
문제: 단일 클러스터 전제가 깨졌다
오픈 이후 다음 버전을 준비하며 멀티클러스터 요구가 생겼습니다. 애플리케이션이 서로 다른 클러스터의 k8s API와 각각 통신해야 했습니다. “클러스터가 하나”라는 전제, 그 위에 얹혀 있던 “API 서버 주소도 하나”라는 전제가 함께 무너졌습니다.
대안: 설정을 늘릴까, 위임 계층을 둘까
가장 손쉬운 길은 클러스터별 k8s 접속 설정을 애플리케이션에 추가하는 것이었습니다. 클러스터가 적다면 이 방법도 괜찮습니다.
걸린 건 클러스터가 계속 늘어난다는 점이었습니다. 여러 리전에 걸친 다양한 환경의 클러스터를 다뤄야 하는 요구사항이 있었습니다. 클러스터가 늘 때마다 kubeconfig·인증·엔드포인트 설정이 코드나 설정 파일에 쌓이면, 애플리케이션이 클러스터 개수와 접속 방식에 직접 결합됩니다. 결합도가 높아집니다.
헥사고날로 도메인을 포트 뒤에 두면 도메인은 k8s·전송 세부에서 분리된 채 유지됩니다. 그러나 애플리케이션이 모든 클러스터의 kubeconfig·자격증명을 직접 쥔다는 사실은 남습니다. 전 클러스터의 자격증명을 한 곳이 보유해 사고 범위가 커지고, 클러스터를 추가하려면 설정 변경과 재배포가 따라옵니다.
그래서 위임 서비스를 뒀습니다. k8s와 직접 통신하는 책임을 위임 서비스로 옮기고 애플리케이션은 “어느 클러스터의 위임 서비스로 보낼지”만 알면 됩니다. 그 주소(클러스터별 위임 서비스 URL)는 DB 설정으로 관리합니다. 클러스터를 추가해도 애플리케이션 재배포 없이 DB 등록으로 끝납니다.
각 클러스터의 자격증명도 애플리케이션이 아니라 해당 위임 서비스가 갖습니다. 어댑터는 클러스터별 k8s 접속 세부가 아니라 위임 서비스 계약 하나만 알면 됩니다. 실행 플랫폼 교체도 같은 구조가 흡수합니다. 당시 GPU 스케줄링을 k8s 대신 docker + Slurm으로 옮기는 안이 검토되고 있었는데, k8s 관련 기능이 위임 서비스 뒤에 모여 있어 실행 플랫폼이 바뀌어도 애플리케이션은 그대로 두고 어댑터만 교체하면 됩니다.
설계의 실체: 포트는 도메인에, 전송은 어댑터에
격리는 코드의 이음새로 드러납니다. 도메인은 포트 인터페이스에만 의존합니다.
// 도메인이 의존하는 포트 (전송·k8s 세부 없음)
public interface RemoteExecutionPort {
void createWorkload(Workload workload);
// ...
}
전송(HTTP)은 어댑터에만 있습니다. 어댑터가 clusterId -> delegatorUrl -> client 순서로 대상 위임 서비스를 해석합니다.
// 포트 구현 = 전송 계층
public class RemoteExecutionAdapter implements RemoteExecutionPort {
private DelegatorClient getClient(ClusterId clusterId) {
var cluster = clusterQuery.getBy(clusterId);
return delegatorClientFactory.getClient(URI.create(cluster.delegatorUrl()));
}
}
동적 baseUrl은 팩토리가 클러스터별로 캐싱합니다.
// 위임 서비스 클라이언트 팩토리
public DelegatorClient getClient(URI baseUrl) {
Objects.requireNonNull(baseUrl, "baseUrl must not be null");
var normalizedUrl = normalizeUrl(baseUrl);
return clientCache.get(normalizedUrl, url -> {
var restClient = restClientBuilder.baseUrl(url).build();
// ... HttpServiceProxyFactory 로 DelegatorClient 생성
});
}
핵심은 여기입니다. 도메인은 RemoteExecutionPort에만 의존하고 전송·주소·k8s 세부는 전부 어댑터 밖에 있습니다. 그래서 전송을 gRPC 스트림이나 브로커로 바꿔도 어댑터를 하나 더 구현할 뿐 도메인 코드는 그대로입니다.
판단: 지금 규모에 맞춘 선택, 미뤄 둔 결정
현 규모(신뢰망 안 소수 클러스터, 동기 명령 중심)에서는 위임 서비스 + 동기 REST + DB 등록으로 충분하다고 판단했습니다. 과한 것(멀티클러스터 서비스 메시, 브로커)을 미리 들이지 않았습니다.
비가역적(irreversible) 결정은 도달성, 곧 어느 쪽이 연결을 여느냐입니다. 지금은 애플리케이션이 위임 서비스로 직접 요청을 보냅니다. 신뢰망 안에선 닿지만, 리전이 갈려 위임 서비스가 방화벽·NAT 뒤로 가면 이 방향이 막혀 위임 서비스가 먼저 연결을 여는 구조로 뒤집어야 합니다.
그래서 이 결정만 포트 뒤에 격리해 두고 멀티리전이 실제 요구가 되는 순간(last responsible moment)까지 미뤘습니다. 그 시점엔 어댑터를 하나 더 구현하면 됩니다. 도메인은 그대로입니다.
“설정만 늘렸다면”과 비교하면 차이가 분명합니다. 설정 증설은 당장은 빠르지만 클러스터 증가와 실행 플랫폼 변경을 코드나 설정에 계속 반영해야 합니다. 위임 서비스는 그 변화를 데이터(DB 등록)와 어댑터 교체 지점으로 모아 도메인이 흔들리지 않게 했습니다.
이 글은 Claude와 함께 작업했습니다.