Skip to content

Latest commit

 

History

History
128 lines (96 loc) · 12.7 KB

File metadata and controls

128 lines (96 loc) · 12.7 KB

v1.20 — Cilium 로드밸런서 백엔드 표현: 서비스별 인스턴스 맵에서 평평한 테이블 행으로

메타데이터

항목 내용
프로젝트 Cilium
관련 릴리스 v1.20.0
관련 이슈/PR cilium/cilium#44511 — loadbalancer: Flatten the backend representation (2026-03-06 머지), cilium/cilium#44310 — Cilium v1.19.0 excessive memory usage
발견 출처 Cilium v1.20.0 릴리스 노트 — "Aggregated Load-Balancer State: Cilium's internal backend representation has been flattened to efficiently support thousands of Services that share the same backends"
검증 근거 PR #44511의 실제 diff(pkg/loadbalancer/backend.go 등)와 PR 본문에 포함된 벤치마크 수치, 실사용자가 올린 이슈 #44310(메모리 급증 재현 리포트 + pprof), 같은 구조를 다루는 이전 버그 PR #43620

개요

Cilium v1.18에서 로드밸런서 컨트롤 플레인이 StateDB 기반으로 재설계되면서(이전 리포트 참고), 하나의 물리적 백엔드(IP:포트)를 참조하는 서비스가 여러 개일 때 이를 "주소당 하나의 행 + 그 안에 서비스별 인스턴스를 담은 중첩 맵"으로 표현했다. 그런데 이 구조가 v1.19.0에서 실사용자의 에이전트 메모리 급증(OOM)을 유발하는 것으로 드러났다 — 하나의 백엔드를 공유하는 서비스 수가 많아질수록, 그 백엔드 행 안의 중첩 맵을 갱신할 때마다 드는 복사 비용이 폭증했기 때문이다. v1.20은 이 중첩 맵을 없애고 **"(서비스, 주소, 우선순위) 조합마다 독립된 행"**으로 백엔드 테이블 자체를 평평하게(flatten) 만들어 이 문제를 해결했다.

사전 지식

v1.18 StateDB 모델에서 "백엔드 공유"를 표현하던 방식

Cilium은 쿠버네티스 Service 하나당 여러 백엔드(파드)를 갖지만, 반대로 같은 백엔드(같은 IP:포트)를 여러 Service가 동시에 참조하는 경우도 흔하다(예: 여러 이름으로 노출된 동일 워크로드, 로컬 리다이렉트 정책 등). v1.18의 StateDB 기반 설계는 이를 다음과 같이 표현했다.

// v1.20 이전
type Backend struct {
    Address   L3n4Addr
    Instances part.Map[BackendInstanceKey, BackendParams] // 서비스별 인스턴스를 담은 중첩 맵
}

type BackendInstanceKey struct {
    ServiceName    ServiceName
    SourcePriority uint8
}

backends 테이블의 행 하나가 하나의 물리적 주소에 대응하고, 그 주소를 참조하는 서비스들의 정보(BackendParams — 포트 이름, 상태, 헬스 등)는 행 내부의 part.Map(StateDB가 쓰는 영속적/불변 맵 자료구조)에 서비스명+우선순위를 키로 삼아 중첩 보관됐다. GetInstance/GetInstancesOfService 같은 메서드가 이 중첩 맵에서 필요한 서비스의 인스턴스를 찾아 꺼내는 역할을 했다.

이 설계가 노린 것과 실제로 부딪힌 문제

이 구조는 "주소 유일성"을 테이블 레벨에서 자연스럽게 보장하면서, 같은 주소를 참조하는 여러 서비스의 서로 다른 설정(포트 이름, 소스 우선순위 등)을 한 행에 모아두려는 의도였다. 문제는 part.Map영속적(persistent) 자료구조라는 점이다 — 이런 자료구조는 갱신할 때 기존 노드를 그대로 두고 바뀐 경로만 새로 만드는 방식(구조적 공유)으로 불변성을 지키는데, 결국 맵에 담긴 엔트리 수가 늘어날수록 삽입/치환 한 번의 비용도 함께 늘어난다. 실사용자가 올린 이슈 #44310은 v1.18.6 → v1.19.0 업그레이드 후 에이전트 메모리 사용량이 치솟아 kubelet이 OOMKill되는 사고를 pprof 스냅샷과 함께 보고했고, v1.18.6으로 되돌리자 문제가 사라졌다고 명시했다 — 즉 이 중첩 맵 구조가 실제 프로덕션 환경에서 확장성 한계에 부딪힌 사례다.

변경 분석

하나의 주소 행 + 중첩 맵의 확장성 한계

[변경 전] 백엔드 테이블의 행 하나 = 물리적 주소 하나, 서비스별 정보는 행 내부 중첩 맵에 보관
┌─────────────────────────────────────────────────────────────┐
│ backends 테이블 (색인: Address)                                │
│ ┌───────────────────────────────────────────────────────┐   │
│ │ Address: 10.0.1.5:8080                                  │   │
│ │ Instances: part.Map[BackendInstanceKey]BackendParams     │   │
│ │   (svcA, prio0) → {PortNames, State, ...}                │   │
│ │   (svcB, prio0) → {PortNames, State, ...}                │   │
│ │   ...  (서비스 수가 늘수록 이 맵 자체가 커짐)                │   │
│ │   (svc2000, prio0) → {...}                                │   │
│ └───────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
   서비스 하나만 갱신해도 영속적 맵(part.Map) 특성상
   구조적 공유 비용이 맵 크기(=공유 서비스 수)에 비례해 증가

PR #44511이 첨부한 벤치마크(Benchmark_UpsertBackends_SharedBackendManyServices, 2000개 서비스가 백엔드 하나를 공유하는 시나리오)는 이 비용을 그대로 드러낸다.

변경 전 변경 후
처리 시간 (ns/op) 3,182,529,066 756,108
메모리 (B/op) 962,720,856 (~962MB) 552,204 (~552KB)
할당 횟수 (allocs/op) 16,146,352 14,062

PR 설명에 따르면 이 시나리오에서 약 4000배 빠르고 메모리는 1GB에서 약 500KB 수준으로 줄었다. Backend 구조체 자체도 실제로 새로 정의됐는데, 기존 BackendParams(중첩 맵 안의 값 타입)가 이제 최상위 Backend 구조체로 승격되고 ServiceName 필드가 추가됐다 — 더 이상 "주소당 하나의 컨테이너 객체"가 아니라 "(서비스, 주소, 우선순위) 조합마다 하나의 독립된 백엔드 행"이 된 것이다.

// v1.20
type Backend struct {
    ServiceName    ServiceName // 이 백엔드 인스턴스가 속한 서비스 (신규)
    Address        L3n4Addr
    // ... 기존 BackendParams의 필드들(PortNames, State, Zone 등)이 그대로 여기로 이동
    sourcePriority uint8
}

type BackendKey struct {
    ServiceName    ServiceName
    Address        L3n4Addr
    SourcePriority uint8
}

테이블의 기본(유일) 색인이 이제 BackendKey(서비스명 → 주소 → 우선순위 순으로 정렬되는 복합 키)가 되고, 기존처럼 주소만으로 조회해야 하는 경로(예: BPF reconciler가 "이 주소를 가진 물리적 백엔드가 뭔지" 확인)를 위해 address라는 별도의 비유일(non-unique) 보조 색인을 유지한다. 서비스 하나의 백엔드 목록을 나열하는 ListBackendsByServiceName, 서비스+주소 조합으로 특정 인스턴스들(소스 우선순위별)을 나열하는 ListBackendsByServiceNameAndAddress는 모두 이 정렬된 키에 대한 접두사(prefix) 스캔으로 구현된다.

설계 결정 및 트레이드오프

왜 중첩 맵을 없애고 테이블 행 자체를 늘렸는가: StateDB 테이블은 이미 다수의 행을 인덱스 기반으로 효율적으로 저장·조회하도록 설계된 자료구조다. 문제의 근본 원인은 "테이블이 자체적으로 잘하는 일(많은 행을 색인으로 관리하는 것)"을 행 내부의 별도 중첩 맵으로 다시 구현한 데 있었다 — 이 맵은 테이블과 별개로 자기만의 영속성 보장을 하다 보니 공유 비용이 별도로 발생했다. PR은 이 중첩 맵을 걷어내고 "서비스별 인스턴스"를 테이블의 **1급 행(row)**으로 승격시켜, 이미 검증된 테이블 인덱싱 메커니즘 하나에만 의존하도록 단순화했다.

트레이드오프로 받아들인 것 — "주소 유일성" 조회가 더 비싸짐: PR 작성자는 본문에서 이 변경의 단점을 직접 명시한다 — "이제 백엔드 IP 주소들의 집합을 얻기가 더 어려워졌고, 소비하는 쪽에서 직접 중복 제거를 해야 한다." 예전엔 주소가 곧 행의 키였으므로 "이 서비스가 쓰는 물리적 주소 목록"이 자동으로 유일했지만, 이제는 같은 주소를 참조하는 서비스/우선순위 조합마다 별도 행이 생기므로 물리 주소만 필요한 소비자(BPF reconciler 등)는 여러 행 중 중복을 스스로 걸러야 한다. 이를 위해 PreferredBackendsByAddress라는 반복자(iterator)를 추가했는데, 이는 별도의 해시셋 없이 키 정렬 순서(서비스 우선 정렬 후 주소, 우선순위 순)에 기대어 "주소가 바뀌는 시점"만 감지해 선형 스캔으로 중복을 제거한다 — 정렬 순서라는 이미 존재하는 속성을 재사용해 추가 자료구조 없이 트레이드오프를 상쇄한 것이다.

"주소 우선 정렬"에서 "서비스 우선 정렬"로 테이블 정렬 기준 자체가 바뀜: 기존엔 테이블이 주소로 정렬돼 있었지만, 새 기본 색인은 서비스명을 첫 번째 정렬 키로 삼는다. 이는 실제 접근 패턴 — "이 서비스의 백엔드 목록을 나열한다"가 "이 주소를 가진 백엔드가 어느 서비스 것인지 찾는다"보다 훨씬 빈번하다는 판단을 반영한다. 후자의 패턴은 사라지지 않고 별도의 비유일 address 색인으로 여전히 지원되지만, 이제는 "유일 조회"가 아니라 "여러 행을 나열 후 골라내는" 방식으로 바뀌었다.

[변경 후] 백엔드 테이블의 행 하나 = (서비스, 주소, 우선순위) 조합 하나
┌───────────────────────────────────────────────────────────────┐
│ backends 테이블 (기본 색인: BackendKey = 서비스→주소→우선순위)      │
│ ┌───────────────┐ ┌───────────────┐       ┌───────────────┐    │
│ │ svcA,addr,p0  │ │ svcB,addr,p0  │  ...  │ svc2000,addr,p0│    │
│ └───────────────┘ └───────────────┘       └───────────────┘    │
│      (각각 독립된 테이블 행 — 갱신 비용이 서로 영향 없음)            │
│                                                                 │
│ 보조 색인: address (non-unique) → BPF reconciler 등 물리 주소   │
│           조회 시 PreferredBackendsByAddress로 중복 제거 후 사용  │
└───────────────────────────────────────────────────────────────┘

더 살펴볼 점

  • 이 평탄화가 실제로 이슈 #44310을 보고한 사용자의 클러스터 규모에서 메모리 사용량을 어느 정도 낮췄는지 후속 보고가 있을까?
  • "서비스 우선 정렬"로 바뀌면서 주소 기반 조회가 잦은 다른 경로(예: 헬스체크, 클러스터 메시)의 성능에 미치는 영향은 별도로 측정됐을까?
  • v1.19.0에 이미 존재했던 이 회귀가 v1.19.x 패치 릴리스로 백포트됐을까, 아니면 v1.20 업그레이드까지 기다려야 했을까?

참고 자료

참고 외 별도 확인 링크

  • 본문 참고 자료로 충분했다.