11분 읽기

쿠버네티스 Java Client에서 Lister와 Indexer 나눠 쓰기

Informer를 쓰기 시작하면 이런 코드가 자연스럽게 나온다.

Lister<V1Pod> podLister = new Lister<>(podInformer.getIndexer());

그러면 곧 두 가지가 헷갈린다.

  • Lister로 조회할 수 있는데 굳이 Indexer를 직접 써야 할 때가 있을까?
  • Indexer가 namespace와 name으로 객체를 찾는다면, namespace 인덱스는 정확히 무엇일까?

결론부터 말하면 ListerIndexer는 같은 Informer 캐시를 읽는다. Lister는 namespace와 name으로 객체를 쉽게 찾도록 돕고, Indexer는 사용자가 추가한 기준으로 객체 묶음을 찾을 때 쓴다.

이 글은 이전 글인 Informer, Indexer, Lister 전체 구조에서 한 단계 더 들어가, 두 조회 API를 실제로 언제 나눠 쓰는지에 집중한다.

캐시에는 두 가지 조회 방법이 있다

“Indexer는 namespace와 객체 이름으로 인덱싱한다”는 설명은 반만 맞다. Java client의 기본 Cache에는 서로 다른 역할의 두 구조가 있다.

  1. 기본 저장소(primary store): namespace/name을 key로 객체 하나를 저장한다.
  2. 보조 인덱스(secondary index): namespace 같은 값을 기준으로 관련 객체의 key를 묶어 둔다.

payments/checkout-7f8d라는 Pod를 하나 찾을 때는 namespace 인덱스를 훑는 것이 아니다. items.get("payments/checkout-7f8d")에 가까운 primary key 조회다. 반대로 payments namespace의 Pod 전체를 찾을 때는 namespace 보조 인덱스가 쓰인다.

SharedIndexInformer가 하나의 Indexer를 소유하고 Lister가 이를 참조하며, items 기본 저장소와 namespace 및 byNodeName 보조 인덱스가 primary key를 가리키는 구조
Lister와 Indexer는 같은 캐시를 읽는다. 사용자 정의 인덱스도 객체 사본을 만들지 않고 기본 키를 가리킨다.

Cache의 실제 구현은 이 개념을 itemsindices라는 Map으로 나눠 갖고 있다.

items
  "payments/checkout-7f8d" → V1Pod
  "payments/worker-6b9c"   → V1Pod

indices
  "namespace"  → "payments" → { "payments/checkout-7f8d", "payments/worker-6b9c" }
  "byNodeName" → "worker-a" → { "payments/checkout-7f8d" }

이 구조 덕분에 한 Pod는 items에 한 번만 저장되고, 여러 인덱스는 그 Pod의 primary key만 들고 있다. Cacheupdate()는 이전 객체의 인덱스 엔트리를 지운 뒤 새 객체에서 계산한 index value를 다시 넣는다. delete()도 primary store와 모든 인덱스에서 함께 제거한다.

Lister로 이름과 namespace 조회하기

Lister의 구현은 매우 작다. Indexer 하나와 선택적인 namespace를 보관하고, 아래 두 형태의 조회를 제공한다.

Lister<V1Pod> pods = new Lister<>(podInformer.getIndexer());

V1Pod pod = pods.namespace("payments").get("checkout-7f8d");
List<V1Pod> paymentPods = pods.namespace("payments").list();
List<V1Pod> allPods = pods.list();

내부 동작은 다음처럼 다르다.

Lister 호출내부에서 하는 일잘 맞는 질문
namespace("payments").get("checkout-7f8d")getByKey("payments/checkout-7f8d")이 namespace의 이 이름을 가진 Pod가 현재 있는가?
namespace("payments").list()byIndex("namespace", "payments")이 namespace의 Pod를 모두 보고 싶다.
list()indexer.list()현재 캐시 전체를 보고 싶다.

여기서 중요한 포인트는 get(name)이다. Lister는 name만 따로 인덱싱하지 않는다. namespace가 있으면 namespace/name 문자열을 만들어 primary store에 바로 조회한다.

그래서 reconcile 코드에서 “이 키의 현재 객체를 읽는다”는 목적이라면 Lister가 가장 의도를 잘 드러낸다.

V1Pod current = podLister.namespace(namespace).get(name);
if (current == null) {
    // 삭제됐거나, 아직 cache에 없는 상태를 처리한다.
    return;
}

Lister의 반환값도 API 서버에서 새로 받은 응답이 아니라 Informer가 관리하는 로컬 캐시의 객체다. 반환 객체를 직접 수정하면 같은 캐시를 읽는 다른 코드에도 영향을 줄 수 있으므로 읽기 전용으로 다뤄야 한다.

Indexer로 원하는 기준의 Pod 묶기

모든 조회가 namespace/name으로 끝나지는 않는다. 예를 들어 Node drain이나 장애 분석 로직에서 “worker-a에 올라간 Pod를 자주 찾는다”면 매번 전체 Pod를 list()한 뒤 Java stream으로 거르는 것은 아쉽다.

이때 Pod의 spec.nodeName을 기준으로 사용자 정의 인덱스를 추가한다.

import io.kubernetes.client.informer.SharedIndexInformer;
import io.kubernetes.client.openapi.models.V1Pod;
import java.util.List;
import java.util.Map;

public void addPodIndexes(SharedIndexInformer<V1Pod> podInformer) {
    podInformer.addIndexers(
        Map.of(
            "byNodeName",
            pod -> {
                String nodeName = pod.getSpec() == null
                    ? null
                    : pod.getSpec().getNodeName();

                return nodeName == null || nodeName.isBlank()
                    ? List.of()
                    : List.of(nodeName);
            }));
}

이 함수는 Informer를 생성한 직후, 시작하기 전에 호출해야 한다.

SharedIndexInformer<V1Pod> podInformer = createPodInformer(...);
addPodIndexes(podInformer);  // factory.startAllRegisteredInformers() 이전

factory.startAllRegisteredInformers();

이후에는 IndexerbyIndex()로 바로 조회한다.

List<V1Pod> podsOnWorkerA =
    podInformer.getIndexer().byIndex("byNodeName", "worker-a");

Function<ApiType, List<String>>List<String>을 반환한다는 점도 눈여겨볼 만하다. 한 객체가 index value를 여러 개 가질 수 있기 때문이다. 예를 들어 Pod를 모든 owner reference의 kind/name으로 묶고 싶다면 owner마다 하나씩 key를 반환할 수 있다. 값이 없을 때는 빈 리스트를 반환하면 그 Pod는 해당 인덱스에 넣지 않는다.

사용자 정의 인덱스는 왜 Indexer로 읽을까?

Lister에는 indexName을 받는 생성자가 있지만, 일반적인 사용 목적은 namespace와 name 조회다. namespace("payments") 메서드도 항상 기본 namespace 인덱스를 사용한다.

더 중요한 것은 Lister.get(name)이 사용자 정의 인덱스를 사용하지 않는다는 점이다. Lister는 get()에서 기본 키 형식의 문자열을 만들기 때문이다. byNodeName처럼 하나의 값으로 여러 Pod를 찾는 인덱스에는 Indexer.byIndex()를 사용해야 한다.

정리하면 다음처럼 나누면 된다.

필요한 조회권장 API이유
namespace와 name으로 Pod 하나 조회Lister.namespace(ns).get(name)Kubernetes 리소스 키 조회라는 의도가 분명하다.
특정 namespace의 Pod 목록Lister.namespace(ns).list()기본 namespace index를 감싼 편의 API다.
모든 Pod 목록Lister.list() 또는 Indexer.list()단순 전체 조회다. 일반 코드에서는 Lister가 읽기 의도를 잘 드러낸다.
Node, label 값, owner처럼 원하는 기준으로 묶어 조회Indexer.byIndex()사용자 정의 인덱스 이름과 조회 값을 명시해야 한다.
index 등록·점검Indexer.addIndexers(), getIndexers()캐시 구조 자체를 다루는 작업이다.

Informer를 시작하기 전에 인덱스 등록하기

이 부분은 실제 구현을 알고 있으면 이유가 명확하다.

DefaultSharedIndexInformer.addIndexers()는 informer가 이미 시작됐다면 예외를 던진다. 그 아래 Cache.addIndexers()도 cache에 객체가 하나라도 있으면 예외를 던진다. 실행 중인 cache에 인덱스를 추가하려면 기존 객체를 전부 다시 색인해야 하는데, Java client는 이 동작을 자동으로 해 주지 않는다.

따라서 Bean을 구성할 때 다음 순서를 지켜야 한다.

SharedIndexInformer 생성
  → 사용자 정의 인덱스 등록
  → event handler 등록
  → factory.startAllRegisteredInformers()
  → hasSynced() 확인 후 worker 시작

인덱스 함수는 Informer가 객체를 추가·수정·삭제할 때마다 호출된다. 이 함수에서 외부 시스템을 호출하거나 무거운 계산을 하면 캐시 갱신이 느려질 수 있다. metadataspec처럼 객체 안에서 빠르게 읽을 수 있고 쉽게 바뀌지 않는 문자열을 조회 값으로 쓰는 것이 좋다.

Lister와 Indexer 고르는 기준

ListerIndexer는 난이도에 따라 나눈 API가 아니다. 필요한 조회 방식에 따라 고르면 된다.

  • Lister는 namespace/name이라는 Kubernetes 리소스 식별 방식으로 현재 cache를 읽는다.
  • Indexer는 기본 저장소와 보조 인덱스를 제공한다. 사용자 정의 조회 기준이 필요할 때 직접 사용한다.
  • 기본 키 namespace/name과 기본 namespace 인덱스는 서로 다르다. 전자는 객체 하나를 찾고, 후자는 namespace 안의 객체 묶음을 찾는다.
  • 사용자 정의 인덱스는 시작 전에 등록하고, 캐시에서 받은 객체는 읽기 전용으로 사용한다.

이 기준을 잡아 두면 일반적인 reconcile은 Lister로 단순하게 유지하면서, Node·owner·라벨처럼 반복 조회가 많은 운영 요구만 Indexer로 명시적으로 최적화할 수 있다.

참고 자료