Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-admin.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

254 lines
14 KiB
Markdown

# grpc-admin 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 12파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-admin`
> SSOT owner: `grpc-admin`
> integration/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. 모듈의 정체
```groovy
// 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()` 이 단계만 기록하고 아무것도 거절하지 않는다
```java
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 — 비밀 필드 검사가 스냅숏의 네 구획 중 하나에만 적용된다
```java
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 은 조정자가 동기화 없이 가변이고 건강 레지스트리는 "정반대" 라고 적었다. 레지스트리 쪽을 다시 읽으면 자료구조는 정반대이지만 규율은 같은 자리에서 깨진다.
```java
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 (공개 표면 대조)
```