BlockChain

포트 할당 함수를 네 번 고쳤다 — 매번 다른 이유로

  • #BlockChain
  • #AI
  • #Claude Code
  • #Kubernetes
  • #멀티테넌시

배경: 기업 하나에 클러스터 하나

  • 폐쇄망에서 여러 기업이 각자 블록체인 네트워크를 쓰는 구조
  • 격리 단위는 기업 1개 = kind 클러스터 1개 — 한 서버 위에 클러스터가 여러 개 뜬다
  • 클러스터마다 호스트 포트가 필요하다(API 서버, Ingress, 대시보드, RPC…)
  • 그래서 기업마다 포트 범위를 하나씩 나눠주는 함수가 있다

이 포트 할당 경로에서 성격이 완전히 다른 버그가 네 번 났다. 하루가 안 되는 사이에. 앞의 셋은 같은 함수였고, 마지막 하나는 다른 클래스였다 — 그게 마지막 하나가 늦게 발견된 이유이기도 하다.

① 소프트 삭제된 기업을 빼고 있었다

  • 증상 — 새 기업의 kind 클러스터 생성이 실패
  • 원인 — 사용 중인 포트를 셀 때 del_yn = true인 기업을 제외
-List<Agency> existingAgencies = agencyRepository.findAllByDelYnFalseAndClusterStatusNot(
-        ClusterStatus.INACTIVE);
+List<Agency> existingAgencies = agencyRepository.findAllByClusterStatusNot(
+        ClusterStatus.INACTIVE);
  • 소프트 삭제(행을 지우지 않고 del_yn = true 표시만 하는 삭제)의 함정
  • DB에서는 안 보이는데, 실제 kind 클러스터는 살아서 포트를 물고 있다
  • 애플리케이션이 “지웠다”고 판단하는 시점과 실물이 사라지는 시점이 다르다

여기서 배웠어야 할 것: 포트는 DB의 소유가 아니라 OS의 소유다. DB 기준으로 세면 틀린다.

② 7분 뒤, 같은 줄을 또 고쳤다

①의 수정은 del_yn 필터만 걷어냈다. 그런데 ClusterStatus.INACTIVE는 여전히 빼고 있었다. 7분 만에 드러났다.

-List<Agency> existingAgencies = agencyRepository.findAllByClusterStatusNot(ClusterStatus.INACTIVE);
+List<Agency> existingAgencies = agencyRepository.findAll();

같은 커밋에서 설정도 바꿨다.

port-range-start: 10000
port-range-size: 10   # 1000 이었다
  • 기업당 1000개씩 예약해뒀는데 스크립트가 실제 쓰는 건 10개였다
  • 1000개씩이면 65535 안에서 기업 55개가 한계 — 실사용과 무관하게 예약만으로 고갈된다

그리고 여기서 방향을 바꿨다. 조건을 계속 다듬는 대신 재사용을 아예 포기했다.

  • 삭제됐든 INACTIVE든 전부 포함해서 max(마지막 포트) + 1 부터 할당
  • 충돌 가능성이 근본적으로 사라진다 — 한 번 나간 번호는 다시 안 나간다

③ 같은 날 저녁, 쿼리를 분리했다

findAll()은 지나치게 솔직한 쿼리다. 다른 화면의 소프트 삭제 필터 작업과 얽히면서, 포트 할당 전용 쿼리로 떼어냈다.

-List<Agency> existingAgencies = agencyRepository.findAll();
+List<Agency> existingAgencies = agencyRepository.findAllForPortRangeAllocation();
@Query("SELECT a FROM Agency a")
List<Agency> findAllForPortRangeAllocation();
  • 하는 일은 findAll()과 같다. 다른 건 이름이 의도를 말한다는 점뿐
  • 누군가 나중에 “삭제된 기업이 왜 여기 나와?” 하고 필터를 붙이는 사고를 막는다
  • 이름 자체가 “여기서는 삭제된 것도 포함해야 한다”는 주석 역할

성능도 동작도 안 바뀌는 커밋이지만, ①이 왜 났는지 생각하면 이게 진짜 수정이다.

④ 다음날, 층위가 다른 누락이 나왔다

①~③은 전부 기업과 기업 사이의 충돌이었다. 이번 건 한 기업 안에서의 충돌이다.

기업의 포트 범위 안에는 서비스마다 자리가 미리 정해져 있다. 괄호 안 숫자는 범위 시작점으로부터의 오프셋이다 — 범위가 10000부터면 Grafana는 10004다.

DASHBOARD(3, ...),  GRAFANA(4, ...),        PROMETHEUS(5, ...),
BESU_RPC(6, ...),   BESU_WS(7, ...),
BLOCKSCOUT(8, ...), FABRIC_EXPLORER(9, ...);
  • 그런데 “사용 중인 포트”를 셀 때 DB 엔티티에 저장된 포트만 셌다
  • 위 고정 오프셋들은 집계에서 빠져 있었다 → 이미 쓰는 자리를 또 내줄 수 있다
if (agency.isClusterActive()) {
    for (ServicePortType type : ServicePortType.values()) {
        usedPorts.add(type.getPort(agency.getPortRangeStart()));
    }
}

이 누락은 포트를 세는 두 곳, allocatePort()getUsedPorts()에 똑같이 있었다. 양쪽에 다 넣었는데 — 넣은 내용이 서로 달랐다.

  • getUsedPorts(): 오프셋 0·1·2(K8s API, Ingress HTTP/HTTPS)를 손으로 먼저 넣고, 그다음 위 enum을 돈다 → 0~9 전부
  • allocatePort(): enum만 돈다 → 3~9만

즉 실제로 포트를 내주는 쪽인 allocatePort()는 오프셋 0~2를 지금도 비어 있다고 본다. 클러스터가 떠 있고 아직 Fabric/Besu 엔티티 포트가 없는 상태라면, 할당 루프가 범위 시작점부터 훑다가 K8s API 서버 포트를 그대로 내준다.

글을 쓰며 코드를 다시 읽다가 발견했다. ④를 고칠 때 나는 “양쪽 다 고쳤다”고 생각했는데, 실제로는 한쪽에만 인프라 포트를 넣었다.

지식이 두 곳에 흩어져 있으면 한 곳만 고치고 끝난다 — ④의 교훈으로 이렇게 적으려 했는데, 정작 그 수정 자체가 그 교훈의 사례가 됐다.

지금 이 코드에 남아 있는 것

글을 쓰며 다시 읽어보니, 고친 것보다 안 고친 것이 더 눈에 띈다.

포트 회수가 반쪽만 있다.

  • 클러스터를 지우면 portRangeStart/Endnull로 되돌리긴 한다
  • 그런데 할당은 max(마지막 포트) + 1지워진 게 마침 제일 높은 범위일 때만 재사용된다
  • 10000, 10010, 10020 중 가운데를 지워도 다음 할당은 10030이다. 구멍은 안 메워진다
  • 생성·삭제를 반복하면 실사용량과 무관하게 번호가 단조 증가한다

여유 포트가 0개다.

  • 고정 오프셋이 0~9로 정확히 10개, 범위 크기도 10
  • 즉 기업당 자유롭게 쓸 포트가 남지 않는다
  • ②에서 1000 → 10으로 줄인 것이 만든 새 제약이다. 하나를 고치면서 다른 하나를 좁혔다

여기서 바로 위의 비대칭이 겹친다. 여유가 없으면 깔끔하게 “고갈”로 실패하는 게 맞는데, allocatePort()는 0~2를 비었다고 보므로 예외 대신 인프라 포트를 내준다. 실패가 실패처럼 안 생겼다는 점에서 고갈보다 나쁘다.

이름이 거짓말하는 메서드가 있다.

  • releasePort()라는 이름의 메서드 두 개가 실제로는 로그만 찍는다
  • 주석에 “실제 포트는 엔티티 삭제 시 자동 해제됨”이라 적혀 있다
  • 동작은 맞을지 몰라도, 이름은 호출하는 쪽에 거짓 약속을 한다

한계 체크는 두 단계로 있다 — 범위 할당 시 65535 초과 검사, 범위 내 개별 포트 소진 검사. 고갈을 감지는 하지만 회수는 안 한다.

남는 교훈

같은 함수에서 버그가 반복되면 조건이 아니라 접근이 틀린 것이다.

  • ①과 ②는 둘 다 “어떤 기업을 셀 것인가”를 다듬는 수정이었다. 7분 간격으로 두 번
  • 세 번째로 조건을 다듬는 대신 재사용을 포기하자 그 계열의 버그가 멈췄다
  • 조건을 정교하게 만드는 대신 조건이 필요 없는 구조로 가는 게 나을 때가 있다

정확한 자원 관리와 단순한 자원 관리는 다른 목표다.

  • 정확한 쪽: 빈 구멍을 추적해 재사용 — 포트를 아끼지만 상태가 복잡하고 위 4개 같은 버그가 산다
  • 단순한 쪽: 번호를 계속 늘린다 — 포트를 낭비하지만 틀릴 여지가 거의 없다
  • 지금 규모에선 후자가 맞다. 다만 그건 규모가 작아서지 설계가 옳아서가 아니다. 이 구분을 흐리면 나중에 이유 없이 그 구조를 지키게 된다

“어디까지가 내 관할인가”를 착각하면 첫 버그가 난다.

  • 포트는 DB가 아니라 OS의 자원이다. DB 상태로 세는 순간 실물과 어긋난다
  • 소프트 삭제는 애플리케이션 안에서만 유효한 개념인데, 그 밖의 자원에까지 적용하려 한 게 ①이었다