The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
grpc-admin 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 —
src/mainproduction 12파일 축자 통독 완료.STRUCTURAL_ONLY잔여 없음. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-adminSSOT owner:grpc-adminintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지
allowed_dependencies:["grpc-core-api", "grpc-server"]runtime_memberships:[]— build-only
| 파일 | LOC |
|---|---|
GrpcDrainCoordinator |
162 |
GrpcServiceHealthRegistry |
154 |
GrpcPlatformSnapshotService |
115 |
GrpcReflectionPolicy |
74 |
GrpcPlatformSnapshot · GrpcAdminExposurePolicy |
71 · 71 |
GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy |
49 · 48 · 43 |
GrpcHealthState · GrpcReflectionMode |
38 · 32 |
GrpcReflectionAccessDecision · GrpcDrainPhase |
28 · 28 |
| test 4파일 | 490 |
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
main/java/** |
12 | FULL_READ |
전 본문 축자 확인 |
test/java/** |
4 | FULL_READ |
490줄 |
build.gradle |
1 | FULL_READ |
8줄 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
UNCLASSIFIED 0.
1. 모듈의 정체
// build.gradle:3-4
// Operational surface: the standard health registry, the reflection exposure policy, the drain
// coordinator, and the secret-free runtime policy snapshot an administrator reads.
2. 건강 레지스트리 — 낙관에서 시작하지 않는다
모든 등록 서비스가 UNKNOWN 에서 시작한다.
"A registry that starts optimistic reports ready during startup, receives traffic before the first dependency check has run, and fails the requests that arrive in that window — the window being exactly the moment a rollout is shifting traffic onto the instance."
그리고 배수 중에는 markServing·markNotServing 이 무시된다.
"a service that reports itself healthy after the drain has started would be routed traffic the instance has already promised not to take."
전역 상태 계산은 세 단계다 — 임계 의존이 하나라도 불건강하면 NOT_SERVING, 아니면 하나라도 NOT_SERVING 이면 NOT_SERVING, 하나라도 SERVING 이면 SERVING, 그 밖에는 UNKNOWN.
3. 배수 순서
READINESS_FALSE → HEALTH_DRAINING → REJECT_NEW_ADMISSION → DRAIN_UNARY → SIGNAL_STREAMS → FORCE_CANCEL
beginDrain 이 앞의 둘을 한 번에 수행하고, 그 전에 rejectNewAdmission 을 부르면 던진다.
"refusing calls before readiness has flipped produces errors for traffic that routing is still sending"
조정자는 잠들지 않는다.
"It is given the current moment and the counts, and returns whether the phase is done; the waiting belongs to the caller, which is what makes every branch of this testable without a clock."
예산은 누적이다 — 스트림 신호 완료 판정이 unaryDrainBudget + streamSignalBudget 을 기준으로 한다.
4. 두 게이트 규칙이 세 곳에 같은 형태로 있다
| 타입 | 두 게이트 |
|---|---|
GrpcReflectionPolicy(ADMIN_ONLY) |
관리 네트워크 + 관리 역할 |
GrpcAdminExposurePolicy |
〃 |
GrpcChannelDiagnosticsPolicy(grpc-advanced-diagnostics) |
〃 |
"a role check alone lets an admin credential leaked to the public network enumerate the schema, and a network check alone lets anyone who reaches the admin network do it."
그리고 반사 가시성과 메서드 인가를 분리한다.
"A method that reflection reveals is not thereby callable, and a method reflection hides is not thereby protected. Conflating the two produces a schema treated as a secret and an authorization check nobody wrote."
5. 스냅숏
권한이 없으면 편집본이 아니라 빈 값을 돌려준다.
"Empty rather than a redacted snapshot: a partial answer tells an unauthorized caller which services exist."
내용이 아니라 해시를 싣는다. 그리고 판본과 시각을 필수로 요구한다 — 사고 중의 질문은 "무엇이 도는가" 가 아니라 "무엇이 바뀌었는가" 다.
driftAgainstRelease 가 양방향을 본다 — 릴리스에 있고 인스턴스에 없는 채널, 인스턴스에 있고 릴리스에 없는 채널을 모두 보고한다.
10. 테스트 레인
네 테스트 490줄. 배수 순서와 예산, 건강 전이, 반사 결정, 스냅숏 게이트와 표류를 확인한다.
12. negative-space probes
12.1 도달성. build-only. 이 리프의 타입 중 셋(GrpcServiceHealthRegistry·GrpcReflectionPolicy·GrpcAdminExposurePolicy·GrpcDrainPolicy)은 grpc-spring-boot-starter 자동 설정이 빈으로 만든다. GrpcDrainCoordinator·GrpcPlatformSnapshotService 는 만들지 않는다.
12.2 대조군. 비밀 필드 패턴이 두 리프에 따로 있다 — 이쪽의 SECRET_FIELD(9종)와 grpc-advanced-diagnostics 의 SENSITIVE_FIELD(10종). 겹치지만 같지 않다. 이쪽에는 api[_-]?key·passphrase 가 있고 저쪽에는 payload·metadata·trace_id 가 있다. 두 표면이 다르므로 목록이 다른 것 자체는 합리적이다.
12.4 드리프트. build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.
16. 확인하지 못한 것
- 실제 서버를 띄워 배수를 돌리지 않았다. 배선 경로가 없다.
GrpcAdmissionController를 배수 중에 호출해 §17.1 을 재현하지 않았다. 두 클래스의 공개 표면으로 판정했다.- §17.4 의 교차를 실행으로 재현하지 않았다.
markServing의 확인과 실행이 분리되어 있고beginDraining의 두 문장 사이에 창이 있다는 것으로 판정했다.
17. 손볼 것
17.1 P2 — rejectNewAdmission() 이 단계만 기록하고 아무것도 거절하지 않는다
public void rejectNewAdmission() {
requireStarted();
phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
public boolean admittingNewCalls() {
return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
조정자는 GrpcAdmissionController 를 협력자로 들고 있는데, 그것을 쓰는 곳은 inFlightAdmitted() 의 조회 하나다.
그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다.
GrpcAdmissionController — tryAdmit · promoteFromQueue · release · inFlight · queued · rejected
close·drain·refuseNew 에 해당하는 것이 없다. 그러므로 배수가 시작된 뒤에도 tryAdmit() 은 용량이 남아 있는 한 계속 승인한다.
admittingNewCalls() 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다. 운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.
테스트가 이것을 볼 수 없다. 배수 테스트가 단언하는 것은 admittingNewCalls() 의 값이고, 단계 이후에 tryAdmit() 이 거절되는지는 어느 테스트도 묻지 않는다.
수정. 승인 제어기에 승인 중단 상태를 두고(stopAdmitting() 과 그것을 보는 tryAdmit), 조정자의 rejectNewAdmission 이 그것을 부르게 한다. 지금 형태에서는 배수 순서를 지키는 장치가 순서 표식만 갖고 있다.
17.2 P3 — 비밀 필드 검사가 스냅숏의 네 구획 중 하나에만 적용된다
List<String> forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes);
if (!forbidden.isEmpty()) {
throw new IllegalArgumentException("a platform snapshot must not carry " + forbidden + "; hashes and names only");
}
메시지는 "a platform snapshot must not carry …" 로 스냅숏 전체를 말한다. 검사 대상은 channelProfileHashes 하나다.
같은 채널 이름 공간을 쓰는 두 맵이 더 있다 — resolverAndLoadBalancerByChannel, retryOwnerByChannel. 그리고 registeredServices 목록과 serviceHealth 맵이 있다. 어느 것도 검사되지 않는다.
세 맵의 키 집합이 같아야 한다는 요구가 없으므로, 어떤 채널이 나머지 두 맵에만 있으면 그 이름은 검사를 지나지 않는다.
grpc-advanced-diagnostics 의 스냅숏은 같은 형태의 자기 검사를 두 구획(주소 목록, 자원 판본 키)에 적용한다. 두 리프의 규율이 갈린다.
수정은 네 구획 전부를 같은 검사에 넣는 것이다. 값이 아니라 키를 보는 검사이므로 비용이 낮다.
17.3 P3 — 배수 조정자가 가변이고 동기화가 없다
phasesRun(ArrayList), startedAt, completedUnaryCalls, signalledStreams 가 평범한 필드다. synchronized·volatile·동시 자료구조가 없다.
같은 리프의 건강 레지스트리는 정반대다 — ConcurrentHashMap 둘과 volatile boolean draining. 즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.
조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적으므로 단일 호출자 전제로 읽을 수 있다. 다만 그 전제가 자바독에 적혀 있지 않고, unaryDrainComplete 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다.
수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.
17.4 P2 — 배수 시작이 확인 후 실행이라, 배수 중에 한 서비스가 다시 SERVING 이 될 수 있다
§17.3 은 조정자가 동기화 없이 가변이고 건강 레지스트리는 "정반대" 라고 적었다. 레지스트리 쪽을 다시 읽으면 자료구조는 정반대이지만 규율은 같은 자리에서 깨진다.
public void markServing(String serviceName) {
requireServiceName(serviceName);
if (draining) { return; } // ← 확인
states.put(serviceName, GrpcHealthState.SERVING); // ← 실행
recomputeGlobal();
}
public void beginDraining() {
draining = true; // ①
states.replaceAll((service, state) -> GrpcHealthState.DRAINING); // ②
}
draining 이 volatile 이므로 가시성은 문제가 아니다. 문제는 순서다. 건강 검사 스레드가 ① 이전에 if (draining) 을 통과하고 ② 이후에 states.put(..., SERVING) 을 실행하면, 그 서비스는 배수 중에 SERVING 으로 남는다. 그리고 같은 호출이 이어서 recomputeGlobal() 을 부르므로 전역 상태까지 SERVING 으로 돌아간다 — anyNotServing 이 거짓이고 anyServing 이 참이기 때문이다.
그러면 ready() 가 참을 답하고, 로드밸런서는 이 인스턴스로 다시 트래픽을 보낸다. 이 클래스의 javadoc 이 막겠다고 한 것이 정확히 그것이다.
"Ignored while draining: a service that reports itself healthy after the drain has started would be routed traffic the instance has already promised not to take."
창은 좁다. 건강 검사는 주기적이고 배수는 한 번이므로, 겹치려면 검사가 배수 시작을 가로질러야 한다. 그리고 겹치는 순간이 정확히 롤아웃 중 — 즉 트래픽이 옮겨지는 중 — 이라는 것이 이 가족의 다른 자리에서 반복해서 나오는 논거다(§17.1 의 "readiness 를 먼저" 도 같은 창을 다룬다).
recordDependencyHealth 도 같은 형태다 — dependencyHealth.put(...) 뒤에 if (!draining) recomputeGlobal(); 을 부르므로, 같은 교차에서 전역을 되살릴 수 있다.
시험이 보지 못하는 이유. 레지스트리 시험은 단일 스레드이고, beginDraining() 뒤에 markServing 을 부르는 사례는 순차적으로만 확인한다 — 그 경로에서는 가드가 정확히 작동한다.
수정. 상태 전이를 하나의 원자 연산으로 만든다 — states.computeIfPresent(service, (k, v) -> draining ? v : SERVING) 처럼 draining 을 맵 연산 안에서 읽거나, beginDraining 이 replaceAll 을 마친 뒤 한 번 더 replaceAll 을 돌려 늦게 들어온 쓰기를 덮는다. 후자는 창을 좁힐 뿐이므로 전자가 맞다.
등급. GrpcServiceHealthRegistry 는 grpc-spring-boot-starter 가 빈으로 만드는 셋 중 하나다(§12.1). 다만 beginDraining() 을 부르는 production 코드는 GrpcDrainCoordinator 이고 그것은 조립되지 않으므로, 오늘 이 창이 열리지는 않는다. 배수를 배선하는 날 §17.1 과 함께 봐야 한다.
확인된 설계(문제 아님)
- 모든 서비스를
UNKNOWN에서 시작하는 것과 그 근거. - 배수 중 건강 보고를 무시하는 것.
- 읽기 준비 해제를 승인 거절보다 먼저 두고, 순서를 어기면 던지는 것.
- 조정자가 잠들지 않고 시각과 개수를 인자로 받는 것 — 모든 분기가 시계 없이 검증 가능하다.
- 두 게이트 규칙을 세 정책에 같은 형태로 둔 것.
- 반사 가시성과 메서드 인가를 분리한 것.
- 권한 없는 호출에 편집본이 아니라 빈 값을 주는 것.
- 스냅숏이 내용이 아니라 해시를 싣고, 판본과 시각을 요구하는 것.
- 표류 비교가 양방향인 것.
Source anchors
src/grpc/grpc-admin/build.gradle:1-8
main/java/…/admin/GrpcDrainCoordinator.java:1-162
main/java/…/admin/GrpcServiceHealthRegistry.java:1-154
main/java/…/admin/GrpcPlatformSnapshotService.java:1-115
main/java/…/admin/GrpcReflectionPolicy.java:1-74
main/java/…/admin/GrpcPlatformSnapshot.java:1-71
main/java/…/admin/GrpcAdminExposurePolicy.java:1-71
main/java/…/admin/(GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy · GrpcHealthState · GrpcReflectionMode · GrpcReflectionAccessDecision · GrpcDrainPhase)
test/java/…/admin/(GrpcDrainCoordinatorTest · GrpcPlatformSnapshotServiceTest · GrpcServiceHealthRegistryTest · GrpcReflectionPolicyTest)
src/grpc/grpc-server/…/server/GrpcAdmissionController.java (공개 표면 대조)