Skip to content

fix: leader election 밖에서 쓰던 주기 작업을 리더로 좁힌다 (AIP-3458) - #127

Merged
pranludi merged 1 commit into
developfrom
fix/AIP-3458-gate-periodic-writers-by-leader
Oct 12, 2026
Merged

pranludi merged 1 commit into
developfrom
fix/AIP-3458-gate-periodic-writers-by-leader

Conversation

@pranludi

@pranludi pranludi commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

AIP-3439(leader election)은 "쓰기를 하는 건 워크큐뿐" 이라는 전제로 ControllerManager 만 리더에 묶었습니다. 그 전제가 틀렸습니다.

스스로 스케줄을 잡고 도는 작업이 따로 있고, 그중 둘이 클러스터 오브젝트를 패치합니다. replicas=2 로 올려 AIP-3439 를 검증하던 중 비리더 파드 로그에 [USER-LABEL-SYNC] 가 찍힌 것이 증거입니다.

손상이 나는 종류는 아닙니다(같은 라벨을 양쪽이 계산해 patch). 다만 레플리카 수만큼 중복 쓰기가 되고, 설계 의도와 어긋납니다.

🟡 지금 당장 터지지는 않습니다 — replicas=1 이라 비리더가 없습니다. replicas=2 로 가기 위한 선행 조건 중 하나입니다(나머지: 프로브 AIP-3457, PDB+anti-affinity).

What

LeadershipState 신설

volatile boolean 하나입니다. 리스를 잡을 때 LeaderElectionRunner 가 한 번 세웁니다.

⭐ 되돌리는 메서드가 없습니다. 리스를 잃으면 프로세스가 끝나므로(AIP-3439 결정) 값이 한 방향으로만 바뀝니다. 덕분에 강등 처리·스케줄러 재기동·경쟁 조건이 전부 불필요합니다.

리더로 좁힌 것

대상 이유
UserLabelSynchronizer 전량 스캔 후 merge-patch
ClusterVolumeChildLabelSynchronizer 전량 스캔 후 patchChildLabels
OwnedObjectRoleResweeper 워크큐 재큐잉 — 비리더에서는 꺼내는 쪽이 없어 쌓이기만 합니다

생성자의 self-start 는 그대로 뒀습니다. 스케줄러 스레드는 비리더에서도 돌지만 진입부에서 즉시 반환합니다(주기가 분 단위라 비용 무시 가능). 빈 생성 계약을 바꾸지 않아 변경이 작습니다.

⛔ 일부러 그대로 둔 것

ApiResourceDiscoveryRefresher 는 파드별 CRD 캐시를 갱신합니다. 리더로 묶으면 리더가 아닌 파드의 캐시가 낡아 어드미션 웹훅이 틀린 답을 하거나 요청을 거부합니다. AIP-3439 에서 인포머를 리더 밖에 둬야 했던 것과 같은 이유입니다.

"안전하게 전부 리더로 묶자" 가 이 변경에서 가장 위험한 실수라, 그 방향도 테스트로 막았습니다.

⚠️ 알려진 동작 — 리더도 기동 직후 첫 주기는 건너뜁니다

동기화기들은 initialDelay=0 이라 리스를 잡기 전에 첫 주기가 돕니다. 클러스터 실측에서 그대로 관측됐습니다.

20:38:12  첫 sync 주기 → skip (아직 리스 없음)
20:38:34  리스 획득 (22초 뒤)
20:39:xx  다음 주기 → 정상 실행

둘 다 백스톱 수렴 루프이고 주기가 60초라 다음 주기에 복구되므로 이번 PR 에서는 고치지 않았습니다. 영향은 "기동 직후 최대 한 주기만큼 수렴이 늦다" 입니다. 줄여야 한다고 보시면 initialDelay 를 리스 획득 이후로 미루는 쪽으로 별도 처리하겠습니다.

🔍 리뷰 포인트

  • 회귀 가드가 이 PR 의 본체입니다. 이 문제는 레플리카가 하나인 동안 아무 증상이 없어 리뷰로는 반복해서 놓칩니다(실제로 AIP-3439 에서 놓쳤습니다). PeriodicWorkLeadershipCoverageTest 가 소스에서 @Scheduled·scheduleWithFixedDelay·scheduleAtFixedRate 사용처를 찾아 분류 목록과 대조하므로, 새로 추가하면 깨지고 작성자가 "리더만인가 파드마다인가" 를 판단하게 됩니다
  • ⚠️ 기존 동기화기 테스트의 픽스처를 리더로 세웠습니다. 안 그러면 sync() 가 즉시 반환해 기존 테스트 전부가 조용히 무의미해집니다
  • LeadershipState.markLeader() 를 public 으로 뒀습니다(테스트가 다른 패키지에 있어서). 호출 주체를 javadoc 에 못박았습니다 — 더 좁히는 편이 낫다고 보시면 의견 주세요

돌연변이로 가드를 확인했습니다

심은 회귀 결과
UserLabelSynchronizer 가드 제거 행위 테스트 FAIL
ClusterVolumeChildLabelSynchronizer 가드 제거 행위 테스트 FAIL
새 @Scheduled 클래스 추가 분류 가드 FAIL
캐시 갱신기에 isLeader() 추가 과잉 게이팅 가드 FAIL

⚠️ 첫 검증은 가짜였습니다. UserLabelSynchronizer 테스트를 처음에 verifyNoInteractions(discovery) 로 썼는데, 가드를 지워도 통과했습니다(그 전에 예외로 빠집니다). LIST(buildCall GET) 검증으로 바꿔 돌연변이가 실제로 잡히는 것을 확인했습니다.

Test plan

  • ./gradlew clean test — 302 tests, 0 failed, 0 skipped
  • cluster10 에 이 브랜치 이미지(1.10.0-33)를 올려 replicas=2 에서 실측 — 아래 결과
  • 확인 후 replicas=1 로 되돌리고 임시 로그 레벨 제거

클러스터 검증 결과 (cluster10, 2026-10-09)

리더 …-4pgw2(vnode2.pnode3) / 비리더 …-dlwrl(vnode6.pnode10).

① 리더 전용 가드가 실제로 갈랐다

로그 리더 비리더
[USER-LABEL-SYNC] started 1 started 0 (skip 2)
[CV-CHILD-LABEL-SYNC] started 1 started 0 (skip 2)

⚠️ 원래 Test plan 의 "비리더 로그에 [CV-CHILD-LABEL-SYNC] 가 안 찍히는지" 항목은 그대로는 무의미했습니다. 이 동기화기는 기본 INFO 레벨에서 아무것도 찍지 않기 때문에, 가드가 없어도 로그는 비어 있습니다. LOGGING_LEVEL_…_MUTATING_SERVICE=DEBUG 를 걸어 skip 로그가 찍히는 것(= 가드를 거쳐 돌아 나갔다) 을 양성 신호로 삼아야 판정이 됩니다.

② 과잉 게이팅은 없다 (반대 방향 확인)

비리더에서 ApiResourceDiscovery 관련 로그 103줄 — 캐시 갱신기는 계속 돕니다. not the leader 로그는 전체 4줄뿐이라 가드가 의도한 둘에만 붙은 것이 확인됩니다.

③ 비리더의 어드미션 웹훅 정상

port-forward pod/…-dlwrl 로 비리더에 직접 쏴서 3경로 전부 HTTP 200 · allowed=true · patch 반환:
/api/v1/admissionreviews · /api/v1/workloadlabel/mutate · /api/v1/userrelationship/mutate

④ 파드 생성 영향 없음 — dry-run 생성 12/12 통과, 양쪽 파드 ERROR 0건.

🚀 Release Version

v5.2.0

AIP-3439 는 "쓰기를 하는 건 워크큐뿐" 이라는 전제로 ControllerManager 만
리더에 묶었다. 틀렸다. 스스로 스케줄을 잡고 도는 작업이 따로 있고 그중
둘이 객체를 패치한다. replicas=2 로 올렸을 때 비리더 파드 로그에
[USER-LABEL-SYNC] 가 찍힌 것이 증거다.

LeadershipState(volatile boolean) 를 두고 리스를 잡을 때 한 번 세운다.
리스를 잃으면 프로세스가 끝나므로 값은 한 방향으로만 바뀐다 — 강등 처리,
스케줄러 재기동, 경쟁 조건이 전부 불필요하다.

리더로 좁힌 것:
- UserLabelSynchronizer / ClusterVolumeChildLabelSynchronizer — 전량 스캔 후 patch
- OwnedObjectRoleResweeper — 워크큐 재큐잉. 비리더에서는 꺼내는 쪽이 없어
  쌓이기만 한다

⛔ 그대로 둔 것 — ApiResourceDiscoveryRefresher 는 파드별 CRD 캐시를 갱신한다.
리더로 묶으면 리더가 아닌 파드의 캐시가 낡아 어드미션 웹훅이 틀린 답을 한다.
AIP-3439 에서 인포머를 리더 밖에 둬야 했던 것과 같은 이유다.

회귀 가드가 본체다. 이 문제는 레플리카가 하나인 동안 아무 증상이 없어
리뷰로는 반복해서 놓친다. PeriodicWorkLeadershipCoverageTest 가 소스에서
@scheduled·scheduleWithFixedDelay·scheduleAtFixedRate 사용처를 찾아
분류 목록과 대조하므로, 새로 추가하면 깨지고 작성자가 "리더만인가
파드마다인가" 를 판단하게 된다. 반대 방향(과하게 묶기)도 같이 고정했다.

돌연변이 4회로 가드를 확인했다:
- ULS 가드 제거 → 행위 테스트 FAIL
- CV 가드 제거 → 행위 테스트 FAIL
- 새 @scheduled 클래스 추가 → 분류 가드 FAIL
- 캐시 갱신기에 isLeader() 추가 → 과잉 게이팅 가드 FAIL

⚠️ ULS 의 첫 검증은 verifyNoInteractions(discovery) 였는데 가드를 지워도
통과했다(그 전에 예외로 빠진다). LIST(buildCall GET) 검증으로 바꿔
돌연변이가 실제로 잡히는 것을 확인했다.

기존 동기화기 테스트는 픽스처를 리더로 세웠다 — 아니면 sync() 가 즉시
반환해 테스트 전부가 조용히 무의미해진다.

검증: ./gradlew clean test — 302 tests, 0 failed, 0 skipped
@pranludi
pranludi merged commit ecefb18 into develop Oct 12, 2026
4 checks passed
@pranludi
pranludi deleted the fix/AIP-3458-gate-periodic-writers-by-leader branch October 12, 2026 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant