Kubernetes

회사마다 쿠버네티스 클러스터를 통째로 하나씩 띄운다 — kind 멀티테넌시

한 줄로

회사를 하나 만들면, 그 회사 전용 쿠버네티스 클러스터가 통째로 하나 생긴다.

네임스페이스가 아니라 클러스터다. 서버 한 대 위에 kind 클러스터가 회사 수만큼 뜬다.

왜 네임스페이스가 아닌가

보통 멀티테넌시(여러 고객이 한 시스템을 나눠 쓰는 구조)는 네임스페이스로 가른다. 가볍기 때문이다.
여기서는 그러지 않았다.

이유는 각 회사가 자기 블록체인 네트워크를 갖기 때문이다. Fabric 이나 Besu 를 깔면
오퍼레이터(CRD 를 감시하며 리소스를 만드는 컨트롤러)와 CRD 가 클러스터 범위로 설치된다.
네임스페이스로 갈라도 이것들은 공유된다. 한 회사가 오퍼레이터 버전을 바꾸면 다른 회사가 같이
영향을 받는다.

그래서 격리 단위를 클러스터로 올렸다. kind(도커 컨테이너 안에 쿠버네티스를 띄우는 도구)라서
이게 가능하다. 클러스터 하나가 그냥 도커 컨테이너 하나다.

만들어지는 순서

회사 생성 API 를 때리면 이렇게 흘러간다.

POST /agencies
  └─ AgencyService.createAgency()          @Transactional
       ├─ 사업자번호 하이픈 제거 → 중복 검사
       ├─ clusterStatus = PROVISIONING 저장
       └─ AgencyCreatedEvent 발행
            │
            ↓  ★ 여기서 HTTP 응답이 이미 나간다
            │
  └─ AgencyEventListener  @TransactionalEventListener(AFTER_COMMIT)
       └─ 별도 스레드
            ├─ 포트 대역 할당
            ├─ SSH 로 원격 서버 접속 → 셸 스크립트 1개 실행
            └─ 성공하면 status = ACTIVE

두 가지가 눈에 띈다.

첫째, 응답이 먼저 나간다. 클러스터 생성은 몇 분씩 걸리므로 HTTP 요청을 붙잡고 있을 수 없다.
그래서 DB 에 PROVISIONING 을 박고 바로 응답한다. 화면은 “생성 중” 을 보여주고 폴링한다.

둘째, AFTER_COMMIT 이다. 트랜잭션이 커밋된 뒤에만 리스너가 돈다. 이게 없으면 아직
커밋 안 된 회사 ID 로 클러스터를 만들기 시작하고, 정작 트랜잭션이 롤백되면 주인 없는 클러스터가
남는다.

클러스터 이름이 곧 회사 ID다

agency.setKindClusterName(agency.getId());

따로 이름을 짓지 않는다. 회사 ID 를 그대로 클러스터 이름으로 쓴다. 덕분에 kubeconfig 파일명도
kubeconfig-{회사ID}.yaml 로 결정되고, 나중에 어떤 클러스터가 어느 회사 것인지 조회할 필요가 없다.

대신 이 규칙에 의존하는 코드가 여기저기 생긴다. 뒤에서 다시 다룬다.

포트를 10개씩 떼어준다

클러스터마다 호스트 포트가 필요하다. API 서버, Ingress, 대시보드, 블록체인 RPC…
서버 한 대에 클러스터가 여러 개니까 겹치면 안 된다.

그래서 회사마다 10개짜리 포트 대역을 준다. 10000 부터 순차로 나간다.

오프셋 용도 노출 방식
+0 API 서버 (6443) 127.0.0.1
+1 Ingress HTTP 0.0.0.0
+2 Ingress HTTPS 0.0.0.0
+3~5 대시보드·Grafana·Prometheus Ingress 경로
+6~9 Besu RPC·WS·Blockscout·Fabric Explorer NodePort

여기서 헷갈리기 쉬운 게 +3~5 는 포트가 있어도 그 포트로 안 들어간다는 점이다.
AccessMode.INGRESS 라서 실제 접근은 +1 포트에 /grafana 같은 경로를 붙인다.
포트 번호는 자리만 잡아둔 셈이다.

API 서버를 127.0.0.1 로만 여는 것도 의도된 것이다. kind 가 발급하는 인증서의
SAN(인증서가 보증하는 주소 목록)에 외부 IP 가 없어서, 외부에서 붙으면 TLS 검증이 깨진다.

포트는 반환되지 않는다

할당 로직에 이런 주석이 있다.

// 삭제·INACTIVE 여부 무관하게 모든 기업의 port_range_end 기준으로 할당 (포트 재사용 금지)

회사를 지워도 그 대역은 안 돌아온다. 다음 회사는 항상 지금까지 나간 것 중 가장 큰 번호 +1
을 받는다.

낭비처럼 보이지만 이유가 있다. 포트를 재사용하면, 정리가 덜 된 이전 클러스터의 컨테이너가
같은 포트를 물고 있을 때 새 회사가 그 포트를 못 잡는다. 진단하기 매우 어려운 종류의 장애다.
65535 까지 쓰면 6천 개 가까운 회사를 만들 수 있으니, 단순함을 택한 게 합리적이다.

스크립트 하나가 나머지를 다 한다

자바는 SSH 로 붙어서 셸 스크립트 하나만 부른다. 그 스크립트가 나머지를 연쇄 호출한다.

단계 하는 일 실패하면
0 로컬 레지스트리 컨테이너 확보 (전 회사 공용) 중단
1 kind create cluster 중단
2 kubeconfig 추출·주소 치환 중단 + 클러스터 삭제
3 cluster-info 로 검증 중단 + 클러스터 삭제
4 부가 컴포넌트 6종 설치 ⚠️ 무시하고 계속

2~3 단계가 실패하면 kind delete cluster 로 되돌린다. 반쯤 만들어진 클러스터를 남기지
않겠다
는 것이다. 좋은 설계다.

4단계는 다르다. 여기에 깔리는 것들은 컴포넌트마다 따로 썼다 —
Ingress ·
Prometheus ·
Grafana ·
Istio ·
쿠버네티스 대시보드.

여기서 걸린 것 — 실패하면 영원히 “생성 중”

코드를 읽다가 발견했다. 프로비저닝이 실패하면 이렇게 처리한다.

} catch (Exception e) {
    agency.setClusterStatus(ClusterStatus.ERROR);
    agencyRepository.save(agency);
    throw e;
}

얼핏 정상이다. ERROR 를 저장하고 예외를 다시 던진다.

그런데 저장이 안 된다. 이 메서드에 @Transactional(propagation = REQUIRES_NEW)
붙어 있기 때문이다. 새 트랜잭션 안에서 예외를 밖으로 던지면 그 트랜잭션은 롤백된다.
방금 저장한 ERROR 도 롤백 대상이다.

결과적으로 DB 에 남는 값은 맨 처음 커밋된 PROVISIONING 이다. 그리고 이벤트 리스너는
예외를 잡아 로그만 찍는다.

} catch (Exception e) {
    log.error("Cluster provisioning failed - Agency ID: {}, ...", ...);
}

즉 실패한 회사는 화면에서 영원히 “생성 중” 으로 보인다. 에러 표시도, 알림도 없다.

빠져나올 방법도 없다

재진입 가드가 이렇게 돼 있다.

if (agency.getKindClusterName() != null) throw CLUSTER_ALREADY_EXISTS;

가드가 상태가 아니라 kindClusterName 을 본다. 실패한 회사는 이 값이 null 이니
재시도 자체는 통과한다. 그런데 재시도를 걸어줄 API 도, 스케줄러도 없다.

ERROR 상태에서 나가는 전이도 정의돼 있지 않다. 손으로 DB 를 고치는 것 말고는 복구 경로가
없다.

정리

  • 격리 단위가 네임스페이스가 아니라 클러스터다. 오퍼레이터와 CRD 가 클러스터 범위라서
  • 클러스터 이름 = 회사 ID. 조회가 필요 없어지지만 그만큼 이 규칙에 묶인다
  • 포트는 회사당 10개, 반환하지 않는다. 재사용이 만드는 장애가 더 비싸다
  • 응답을 먼저 주고 AFTER_COMMIT 이벤트로 뒤를 잇는다 — 커밋 전 프로비저닝을 막는 장치
  • 되돌리기는 스크립트 안에만 있다. 자바 쪽 보상 로직은 없다
  • ⚠️ 실패하면 PROVISIONING 에 영구히 갇힌다. REQUIRES_NEW + 예외 재던지기 조합이
    ERROR 저장을 롤백시킨다

같이 읽을 글:
Ingress 로 포트 3개를 1개로 줄였다 ·
Prometheus 는 한 덩어리로 깔린다 ·
Fabric 네트워크는 어떻게 생기나 ·
Besu 네트워크는 어떻게 생기나