14분 읽기

쿠버네티스 Java Client의 캐시 흐름 이해하기

쿠버네티스 컨트롤러를 Java로 만들다 보면 SharedIndexInformer, Indexer, Lister가 함께 등장한다. 세 이름이 비슷해 보이지만 맡은 일은 다르다. Informer는 변경을 받고, Indexer는 객체를 저장하며, Lister는 저장된 객체를 읽는다.

핵심 관계는 다음과 같다.

SharedIndexInformerIndexer를 직접 가지고, Lister는 그 Indexer를 참조해 별도로 만든다.

즉, SharedIndexInformer → Lister → Indexer가 아니라 다음 구조다.

Kubernetes API Server의 LIST와 WATCH가 ListerWatcher, DefaultSharedIndexInformer, Indexer와 Cache로 이어지고, Event Handler와 Worker가 Lister로 같은 Indexer를 조회하는 구조
SharedIndexInformer는 Indexer를 직접 소유하고, Lister는 같은 Indexer를 읽는다.

Informer, Indexer, Lister가 하는 일

구성요소맡은 일API 서버와 통신하는가?
SharedIndexInformerLIST/WATCH를 관리하고 캐시를 갱신하며 이벤트 핸들러를 호출한다예. 내부에서 간접적으로 수행
Indexer객체를 저장하고 기본 키와 보조 인덱스로 찾을 수 있게 한다아니오
ListerIndexer를 읽기 편한 API로 감싼다아니오
ListerWatcherAPI 서버에 LIST와 WATCH 요청을 보낸다

여기서 ListerListerWatcher는 이름만 비슷할 뿐 대상이 다르다.

  • ListerWatcher: API 서버와 통신하는 내부 어댑터
  • Lister: 이미 동기화된 로컬 캐시를 읽는 조회 도우미

Informer는 변경 사항을 받아 온다

Informer는 백그라운드에서 다음 일을 담당한다.

  1. API 서버에 초기 LIST 요청을 보낸다.
  2. 목록을 로컬 캐시에 반영한다.
  3. 목록의 resourceVersion 이후로 WATCH 연결을 연다.
  4. 변경을 받으면 캐시를 갱신하고 onAdd, onUpdate, onDelete를 호출한다.

애플리케이션이 매번 API 서버를 조회하지 않아도 되는 이유가 여기에 있다. 컨트롤러는 변경 알림을 받고, 필요하면 로컬 캐시에서 현재 상태를 다시 읽는다.

Indexer는 객체를 저장하고 분류한다

Java client의 기본 CacheIndexer 인터페이스를 구현한다. 내부적으로는 대략 다음 두 자료구조를 유지한다.

items
  "default/api-pod" → V1Pod 객체

indices
  "namespace" → "default" → ["default/api-pod", ...]
  "byAppName" → "frontend" → ["default/api-pod", ...]

기본 키는 보통 namespace/name 형태다. namespace 인덱스는 기본으로 제공되고, 애플리케이션이 필요한 기준은 addIndexers()로 추가할 수 있다.

Lister는 저장된 객체를 읽는다

Lister는 새로운 캐시를 만들지 않는다. Informer가 가진 Indexer를 그대로 참조한다.

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

V1Pod pod = pods.namespace("default").get("api-pod");
List<V1Pod> allPods = pods.namespace("default").list();

위 코드는 API 서버를 호출하지 않는다. get()list() 모두 Informer가 관리하는 로컬 캐시를 읽는다.

세 구성 요소는 어떻게 연결될까?

Java client의 기본 구현체는 DefaultSharedIndexInformer다. 개념적으로는 다음처럼 볼 수 있다.

SharedIndexInformer<V1Pod>
└── DefaultSharedIndexInformer<V1Pod>
    ├── Indexer<V1Pod>
    │   └── Cache<V1Pod>
    ├── SharedProcessor
    │   └── ResourceEventHandler들
    └── Controller
        ├── ListerWatcher
        └── DeltaFIFO

Lister<V1Pod>
└── 위 Indexer<V1Pod>를 참조

중요한 점은 DefaultSharedIndexInformerLister 필드를 가지고 있지 않다는 것이다. 기본 구현체에는 Indexer 필드가 있고, getIndexer()가 이 객체를 반환한다. 애플리케이션이 그 반환값으로 Lister를 생성한다.

Spring Bean으로 등록하면 무슨 일이 생길까?

CRD를 사용하는 기존 코드와 같은 형태를 Kubernetes 기본 리소스인 Pod로 바꾸면 다음과 같다.

@Bean
public SharedIndexInformer<V1Pod> podInformer(
        SharedInformerFactory factory,
        CoreV1Api coreApi,
        ControllerProperties properties) {
    return factory.sharedIndexInformerFor(
        (CallGeneratorParams params) ->
            coreApi.listPodForAllNamespaces()
                .resourceVersion(params.resourceVersion)
                .watch(params.watch)
                .timeoutSeconds(params.timeoutSeconds)
                .buildCall(null),
        V1Pod.class,
        V1PodList.class,
        resyncMillis(properties));
}

@Bean
public Lister<V1Pod> podLister(
        @Qualifier("podInformer") SharedIndexInformer<V1Pod> podInformer) {
    return new Lister<>(podInformer.getIndexer());
}

factory.sharedIndexInformerFor(...)를 호출하면 Factory는 대략 다음 작업을 한다.

CoreV1Api

LIST/WATCH 호출을 감싼 ListerWatcher 생성

new Cache<V1Pod>()

new DefaultSharedIndexInformer<>(ListerWatcher, Cache, ...)

여기서 CoreV1Api는 Informer가 API 서버와 통신할 수 있도록 전달하는 재료다. Informer가 직접 CoreV1Api를 애플리케이션 코드에 노출하는 방식은 아니다. Factory가 API 호출을 ListerWatcher로 감싼 뒤 Controller에 연결한다.

주의할 점은 Bean 등록과 Informer 시작이 별개라는 사실이다. 실제 LIST/WATCH는 보통 다음 호출 이후 시작된다.

factory.startAllRegisteredInformers();

Spring Bean을 만들었다고 바로 Watch 연결이 열리는 것은 아니다. 애플리케이션 초기화 코드에서 Factory를 시작해야 한다.

LIST로 처음 받고 WATCH로 계속 지켜보기

Informer가 시작되면 대체로 다음 순서로 움직인다.

Informer가 API Server에 LIST 요청을 보내고 DeltaFIFO, Indexer와 Cache를 거쳐 Event Handler에 콜백을 보내며, 이후 WATCH 변경도 같은 흐름으로 처리하는 순서
초기 LIST와 이후 WATCH 변경 모두 캐시 반영이 먼저이고, 이벤트 핸들러 호출이 그 다음이다.

WATCH는 바뀐 필드만 보내지 않는다

Watch 이벤트는 API 서버가 변경을 알리는 메시지다. 하지만 일반적인 리소스 Watch에서 API 서버가 status.phase 같은 변경 필드만 보내는 것은 아니다.

type: MODIFIED
object:
  metadata: ...
  spec: ...
  status: ...

MODIFIED 이벤트에는 수정된 리소스 객체가 함께 온다. 바뀐 필드 목록만 오는 것이 아니라, 해당 시점의 Pod 전체 상태가 전달된다고 이해하면 된다.

다만 API 서버의 Watch 이벤트와 Java Informer의 콜백은 구분해야 한다.

구분생성 주체예시
Watch 이벤트API 서버ADDED, MODIFIED, DELETED
Informer 콜백Java client InformeronAdd, onUpdate, onDelete

둘이 항상 1:1로 대응하는 것은 아니다. 최초 LIST 결과도 Informer에서는 onAdd로 전달될 수 있고, resync는 API 서버의 변경이 없어도 객체를 다시 처리할 수 있다. Watch 연결이 끊겨 다시 LIST하는 과정에서도 콜백이 추가로 발생할 수 있다.

이벤트보다 캐시를 먼저 바꾸는 이유

Java client의 DefaultSharedIndexInformer는 변경을 처리할 때 먼저 Indexer를 갱신하고, 그 다음 이벤트 핸들러에 알린다.

Watch 이벤트 수신
  → DeltaFIFO
  → Indexer add / update / delete
  → ResourceEventHandler 호출

이 순서 덕분에 핸들러는 이벤트를 받은 뒤 Lister로 현재 캐시 상태를 다시 조회할 수 있다. 실제 컨트롤러에서는 핸들러에서 무거운 작업을 직접 실행하기보다 다음 패턴을 사용한다.

onUpdate / onAdd / onDelete
  → namespace/name을 작업 큐에 추가
  → Worker가 키를 꺼냄
  → Lister로 현재 Pod를 조회
  → reconcile

이벤트 객체를 큐에 그대로 오래 저장하는 것보다 키를 저장하는 편이 안전하다. 작업이 실행되는 시점에는 같은 리소스에 또 다른 변경이 반영되어 있을 수 있기 때문이다.

필요한 조회 기준 추가하기

자주 조회하는 기준이 있다면 Informer 시작 전에 인덱스를 등록한다.

podInformer.addIndexers(
    Map.of(
        "byAppName",
        pod -> {
            Map<String, String> labels = pod.getMetadata().getLabels();
            String appName = labels == null
                ? null
                : labels.get("app.kubernetes.io/name");
            return appName == null ? List.of() : List.of(appName);
        }));

등록한 뒤에는 다음처럼 조회한다.

List<V1Pod> frontendPods =
    podInformer.getIndexer().byIndex("byAppName", "frontend");

addIndexers()는 Informer가 이미 실행 중일 때 호출할 수 없다. 캐시가 운영 중인 상태에서 인덱스 구조를 바꾸면 기존 객체 전체를 다시 색인해야 하기 때문이다.

캐시 준비가 끝났는지 확인하기

Informer가 생성됐다고 캐시가 곧바로 완성되는 것은 아니다. 초기 LIST가 끝나고 그 결과가 캐시에 반영되기 전에는 list()가 빈 결과를 반환할 수 있다.

factory.startAllRegisteredInformers();

while (!podInformer.hasSynced()) {
    Thread.sleep(100);
}

List<V1Pod> pods = podLister.list();

초기 동기화 전의 빈 결과를 “클러스터에 Pod가 없다”로 오해하면 잘못된 삭제나 생성 작업을 시작할 수 있다. 따라서 Controller가 일을 시작하기 전에 hasSynced()로 캐시 준비가 끝났는지 확인해야 한다.

상황에 맞는 도구 고르기

하고 싶은 일사용할 것
Pod 생성·수정·삭제에 반응Informer의 ResourceEventHandler
특정 namespace의 Pod 하나 조회Lister.namespace(ns).get(name)
특정 namespace의 Pod 목록 조회Lister.namespace(ns).list()
라벨·owner·노드 기준으로 자주 조회사용자 정의 Indexer + byIndex()
API 서버의 최신 응답이 반드시 필요CoreV1Api 직접 호출

Informer 캐시는 API 서버의 로컬 사본이므로 아주 짧은 지연이 있을 수 있다. 강한 최신성이 필요한 작업만 API 서버를 직접 호출하고, 일반적인 reconcile 조회는 Lister를 사용하는 것이 좋다.

또한 Lister나 Indexer가 돌려준 객체는 공유 캐시 객체로 취급해야 한다. 객체를 직접 수정하지 말고, 변경이 필요하면 복사본을 만들거나 API 서버에 별도의 Update/Patch 요청을 보내야 한다.

핵심 정리

Kubernetes Java client의 역할 분담은 다음 한 문장으로 정리할 수 있다.

Informer는 API 서버의 변경을 받아 캐시를 최신 상태로 만들고, Indexer는 캐시를 저장·색인하며, Lister는 그 캐시를 조회한다.

그리고 구조적으로는 다음이 핵심이다.

DefaultSharedIndexInformer
  └── Indexer / Cache

Lister
  └── 위 Indexer를 참조하는 별도 조회 객체

이 구조를 이해하면 sharedIndexInformerFor(...)가 API 호출을 어떻게 내부 구성요소로 연결하는지, 왜 Lister가 네트워크 요청을 만들지 않는지, 왜 이벤트 핸들러에서 키를 큐에 넣고 Lister로 다시 조회하는지를 한 흐름으로 이해할 수 있다.

참고 자료