Kubernetes 컨트롤러를 Java로 작성하다 보면 SharedIndexInformer, Indexer, Lister가 함께 등장한다. 이름만 보면 Informer 안에 Lister가 있고, Lister가 Indexer를 감싸는 것처럼 보인다. 실제 Java client의 구조는 조금 다르다.
핵심 관계는 다음과 같다.
SharedIndexInformer는Indexer를 직접 가지고,Lister는 그 Indexer를 참조해 별도로 만든다.
즉, SharedIndexInformer → Lister → Indexer가 아니라 다음 구조다.
세 구성요소의 역할
| 구성요소 | 맡은 일 | API 서버와 통신하는가? |
|---|---|---|
| SharedIndexInformer | LIST/WATCH를 관리하고 캐시를 갱신하며 이벤트 핸들러를 호출한다 | 예. 내부에서 간접적으로 수행 |
| Indexer | 객체를 저장하고 기본 키와 보조 인덱스로 찾을 수 있게 한다 | 아니오 |
| Lister | Indexer를 읽기 편한 API로 감싼다 | 아니오 |
| ListerWatcher | API 서버에 LIST와 WATCH 요청을 보낸다 | 예 |
여기서 Lister와 ListerWatcher는 이름만 비슷할 뿐 대상이 다르다.
ListerWatcher: API 서버와 통신하는 내부 어댑터Lister: 이미 동기화된 로컬 캐시를 읽는 조회 도우미
Informer
Informer는 백그라운드에서 다음 일을 담당한다.
- API 서버에 초기
LIST요청을 보낸다. - 목록을 로컬 캐시에 반영한다.
- 목록의
resourceVersion이후로WATCH연결을 연다. - 변경을 받으면 캐시를 갱신하고
onAdd,onUpdate,onDelete를 호출한다.
애플리케이션이 매번 API 서버를 조회하지 않아도 되는 이유가 여기에 있다. 컨트롤러는 변경 알림을 받고, 필요하면 로컬 캐시에서 현재 상태를 다시 읽는다.
Indexer와 Cache
Java client의 기본 Cache가 Indexer 인터페이스를 구현한다. 내부적으로는 대략 다음 두 자료구조를 유지한다.
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가 관리하는 로컬 캐시를 읽는다.
SharedIndexInformer의 실제 내부 구조
Java client의 기본 구현체는 DefaultSharedIndexInformer다. 개념적으로는 다음처럼 볼 수 있다.
SharedIndexInformer<V1Pod>
└── DefaultSharedIndexInformer<V1Pod>
├── Indexer<V1Pod>
│ └── Cache<V1Pod>
├── SharedProcessor
│ └── ResourceEventHandler들
└── Controller
├── ListerWatcher
└── DeltaFIFO
Lister<V1Pod>
└── 위 Indexer<V1Pod>를 참조
중요한 점은 DefaultSharedIndexInformer가 Lister 필드를 가지고 있지 않다는 것이다. 기본 구현체에는 Indexer 필드가 있고, getIndexer()가 이 객체를 반환한다. 애플리케이션이 그 반환값으로 Lister를 생성한다.
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에 연결한다.
또 하나 주의할 점은 빈 등록과 Informer 시작은 별개라는 사실이다. 실제 LIST/WATCH는 보통 다음 호출 이후 시작된다.
factory.startAllRegisteredInformers();
Spring Bean으로 등록했다고 해서 생성 즉시 Watch 연결이 열리는 것은 아니다. 애플리케이션의 초기화 코드나 별도의 lifecycle 설정에서 Factory를 시작해야 한다.
LIST와 WATCH, 그리고 이벤트 처리 순서
Informer가 시작되면 대체로 다음 순서로 움직인다.
Watch에서 받는 것은 필드 diff가 아니다
Watch 이벤트는 API 서버가 변경을 알리는 메시지다. 하지만 일반적인 리소스 Watch에서 API 서버가 status.phase 같은 변경 필드만 보내는 것은 아니다.
type: MODIFIED
object:
metadata: ...
spec: ...
status: ...
MODIFIED 이벤트에는 수정된 리소스 객체가 함께 온다. 따라서 “Pod의 특정 필드가 바뀌었다”는 diff라기보다 “이 Pod의 현재 표현이 이것이다”에 가깝다.
다만 API 서버의 Watch 이벤트와 Java Informer의 콜백은 구분해야 한다.
| 구분 | 생성 주체 | 예시 |
|---|---|---|
| Watch 이벤트 | API 서버 | ADDED, MODIFIED, DELETED |
| Informer 콜백 | Java client Informer | onAdd, 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가 이미 실행 중일 때 호출할 수 없다. 캐시가 운영 중인 상태에서 인덱스 구조를 바꾸면 기존 객체 전체를 다시 색인해야 하기 때문이다.
hasSynced()를 확인해야 하는 이유
Informer가 생성됐다고 캐시가 곧바로 완성되는 것은 아니다. 초기 LIST가 끝나고 그 결과가 캐시에 반영되기 전에는 list()가 빈 결과를 반환할 수 있다.
factory.startAllRegisteredInformers();
while (!podInformer.hasSynced()) {
Thread.sleep(100);
}
List<V1Pod> pods = podLister.list();
초기 동기화 전의 빈 결과를 “클러스터에 Pod가 없다”로 해석하면 잘못된 삭제나 생성 작업을 시작할 수 있다. Controller를 시작할 때도 초기 동기화 완료 여부를 lifecycle의 일부로 다루는 편이 좋다.
실무에서 기억할 기준
| 하고 싶은 일 | 사용할 것 |
|---|---|
| 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로 다시 조회하는지를 한 흐름으로 이해할 수 있다.