Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-discovery.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

240 lines
14 KiB
Markdown

# grpc-discovery 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 7파일 409줄, test 2파일 220줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-discovery`
> SSOT owner: `grpc-discovery`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-client"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcKubernetesProfile` | 90 |
| `GrpcDiscoveryPolicyValidator` | 67 |
| `GrpcResolverProfile` | 63 |
| `GrpcKubernetesProfileValidator` | 61 |
| `GrpcResolverType` · `GrpcKubernetesRoutingMode` | 45 · 45 |
| `GrpcStableLoadBalancer` | 38 |
| **main 합계 (7파일)** | **409** |
| `GrpcKubernetesProfileTest` · `GrpcDiscoveryPolicyValidatorTest` | 127 · 93 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 7 | `FULL_READ` | 409줄 전 본문 |
| `test/java/**` | 2 | `FULL_READ` | 220줄 전 본문 · 테스트 15개 |
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-5
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator.
```
## 2. 이 리프가 붙드는 한 가지 짝
세 타입이 같은 사실을 다른 각도에서 말한다.
- `GrpcResolverType` — 각 리졸버가 주소를 여럿 돌려줄 수 있는가. `STATIC`·`DNS` 는 예, `UNIX` 는 아니오.
- `GrpcStableLoadBalancer` — 주소 수에 맞는 정책. 1개면 `PICK_FIRST`, 여럿이면 `ROUND_ROBIN`.
- `GrpcKubernetesRoutingMode` — 누가 균형을 잡는가. VIP 는 kube-proxy, headless 는 클라이언트, MESH 는 사이드카.
세 javadoc 이 같은 실패를 다르게 서술한다.
> `GrpcResolverType` — "A resolver that returns one address makes `round_robin` a no-op, and the
> pairing is the most common way a deployment has load balancing on paper and none in practice."
> `GrpcStableLoadBalancer` — "`pick_first` over a headless record pins every request from this
> client to one pod, which shows up as one instance at capacity while the rest are idle."
> `GrpcKubernetesRoutingMode` — "A Service VIP balances per connection in kube-proxy, which for a
> long-lived HTTP/2 connection means it does not balance at all after the first request."
## 3. 두 검증기가 다른 질문에 답한다
`GrpcKubernetesProfileValidator` javadoc 이 분리 이유를 적는다.
> "The resolver validator asks whether a load-balancing policy does anything over the addresses it
> will see; this one asks whether the deployment shape, the retry owner and the stream obligations
> agree with each other. A deployment can have a perfectly coherent resolver profile and still have
> put retries in two places."
| 검사 | 어디 |
|---|---|
| 균형 정책이 주소 수에 대해 무의미한가 | `GrpcDiscoveryPolicyValidator` |
| 재시도 소유자가 라우팅 모드가 요구하는 것과 다른가 | `GrpcKubernetesProfileValidator` |
| VIP 인데 긴 스트림을 싣는가 | 〃 |
| 배수 유예가 재접속 예산보다 짧은가 | 〃 |
## 4. 생성자가 거부하는 것과 검증기가 보고하는 것
`GrpcResolverProfile` 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0.
`GrpcKubernetesProfile` 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0.
두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다.
그리고 그 분담 때문에 검증기의 재시도 소유자 규칙은 일부 조합에서만 발화한다. `MESH` + `GRPC_PLATFORM` 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다.
## 10. 테스트 레인
두 테스트 220줄 · 15개.
`GrpcDiscoveryPolicyValidatorTest` 7개 — Stable 리졸버 셋의 열거, Advanced 스킴과 미지 스킴 거부, 주소 수에 따른 권고, 단일 주소 위의 round_robin 보고, 올바른 짝의 통과, 갱신 주기 0 인 DNS 거부, 단일 엔드포인트 리졸버의 복수 주소 거부.
`GrpcKubernetesProfileTest` 8개 — 라우팅 모드별 균형자·재시도 소유자, 메시 + in-process 재시도의 생성자 거부, 긴 스트림의 두 필수 값, 세 팩토리의 통과, headless 인데 주소 1개, VIP 인데 긴 스트림, 배수 유예 < 재접속 예산, 라우팅 모드가 함의하는 리졸버 프로파일.
**레인에 없는 것 하나.** §4 가 "생성자를 통과해 검증기가 잡는다" 고 설명한 분기 — `retryOwner != routingMode.requiredRetryOwner()` — 를 실제로 발화시키는 테스트가 없다.
```java
// GrpcKubernetesProfileValidator:204
if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { violations.add(); }
```
`routingModesImplyTheirOwners` 는 열거형의 `requiredRetryOwner()` 값만 단언하고 검증기를 부르지 않는다. `aMeshProfileMayNotAlsoRetryInProcess` 는 생성자 쪽을 친다. `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` — 두 검증기 분담을 실증하는 조합 — 은 어느 테스트에도 없다. 규칙은 있고 그것을 붙드는 단언이 없다.
## 12. negative-space probes
**12.1 도달성.** 이 리프 밖의 production 소비자는 하나뿐이다.
| 타입 | leaf 밖 main 참조 |
|---|---:|
| `GrpcDiscoveryPolicyValidator` | 1 — `GrpcPlatformStartupValidator.validateChannels` |
| 나머지 6종 | **0** |
그리고 그 하나의 소비자인 시작 검증기는 시작 시 실행되지 않는다(`grpc-spring-boot-starter` §17.1). 그러므로 Advanced 스킴 거부(`requireStableScheme`)에 도달하는 production 경로가 없다.
**12.2 거절 목록은 안전이 아니라 메시지를 위해 있다.**
```java
private static final List<String> ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka");
if (ADVANCED_SCHEMES.contains(scheme)) { throw new IllegalArgumentException("… Advanced capability …"); }
return GrpcResolverType.forScheme(scheme).orElseThrow(() -> new IllegalArgumentException("unknown resolver scheme …"));
```
두 번째 줄이 이미 허용 목록이다 — `GrpcResolverType` 이 아는 것은 `static`·`dns`·`unix` 셋뿐이고, 그 밖은 전부 `orElseThrow` 로 떨어진다. 그러므로 `xds` 는 거절 목록이 없어도 거부된다.
거절 목록이 하는 일은 **거부 사유를 바꾸는 것**이다 — "unknown resolver scheme" 대신 "Advanced capability with its own control plane and promotion gate". 클래스 javadoc 이 그 구분을 명시한다.
> "`xds:///` in a Stable profile is not a configuration mistake to warn about — it is a capability
> with its own control plane, its own failure modes and its own promotion gate."
읽는 사람에게 중요한 함의: 다섯 번째 Advanced 스킴 이름을 이 목록에 넣지 않아도 **안전은 유지된다.** 빠지면 나빠지는 것은 메시지의 정확도뿐이고, 그것이 이 목록이 감당하는 유일한 부채다. 기본 거절이 바깥을 지킨다.
**12.4 드리프트.** build.gradle 이 서술한 범위(Static/DNS, pick_first/round_robin, VIP/headless/mesh, xDS 거부)가 전부 코드에 있다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 DNS 리졸버로 헤드리스 레코드를 조회해 주소 수를 확인하지 않았다. 이 리프는 그 수를 입력으로 받는다.
- 시작 검증기를 통한 스킴 거부를 실행으로 확인하지 않았다. 그 검증기가 돌지 않는다.
- 테스트를 실행하지 않았다. 15개 전부 본문으로만 확인했다.
- §17.3 의 `violations(profile, 0)` 을 실행으로 재현하지 않았다. `resolverProfile``GrpcResolverProfile` 정규 생성자 경로로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
`GrpcKubernetesProfile` 은 세 시간 값을 다룬다.
```java
streamReconnectBudget // 프로파일이 선언
readinessDrainGrace // 프로파일이 선언
refreshInterval // resolverProfile(...) 이 30초로 하드코딩
```
```java
public GrpcResolverProfile resolverProfile(int expectedAddressCount) {
return new GrpcResolverProfile(
GrpcResolverType.DNS, routingMode.loadBalancingPolicy(), Duration.ofSeconds(30), expectedAddressCount);
}
```
검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반. 셋째는 비교 대상에 없다.
그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다. 롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.
그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished."
수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
### 17.2 P3 — 리졸버 검증기의 규칙이 하나뿐인데 javadoc 은 복수형으로 서술한다
```java
public static List<String> violations(GrpcResolverProfile profile) { } // 규칙 1개
```
javadoc 은 "Checks a discovery configuration for **the things** that look right and are not" 라고 적는다. 실제로 담긴 규칙은 균형 정책의 무의미함 하나다.
나머지 위험 조합은 `GrpcResolverProfile` 정규 생성자가 이미 거부하므로 결과적으로 빈틈은 아니다. 다만 목록으로 보고하는 API 형태와 규칙 하나라는 내용이 어긋나 있어, 다음 사람이 여기에 규칙을 더할 자리로 읽거나 이미 여러 규칙이 있다고 읽는다. §17.1 이 실제로 그 자리다.
### 17.3 P3 — 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
```java
public static List<String> violations(GrpcKubernetesProfile profile, int expectedAddressCount) {
List<String> violations = new ArrayList<>(
GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount)));
```
`profile.resolverProfile(n)``new GrpcResolverProfile(DNS, …, n)` 을 만들고, 그 정규 생성자가 거부한다.
```java
if (expectedAddressCount < 1) {
throw new IllegalArgumentException("a target resolves to at least one address");
}
```
그래서 `violations(profile, 0)` 은 빈 목록도 위반 목록도 아닌 `IllegalArgumentException` 이다. 같은 메서드가 `profile == null` 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다.
**왜 0 이 실제 값인가.** `expectedAddressCount` 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다. 그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다.
`GrpcResolverProfile` 쪽 거부 자체는 옳다. 값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.
**수정.** `violations``expectedAddressCount < 1` 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다. 그러면 이 리프가 답할 수 있는 가장 중요한 배포 상태 하나가 예외가 아니라 목록의 한 줄이 된다.
### 확인된 설계(문제 아님)
- **리졸버의 다중 주소 가능성을 열거형 속성으로 둔 것** — 짝이 맞지 않는 조합을 타입 수준에서 판정할 수 있다.
- **위험한 조합을 생성자가 거부하고 애매한 조합만 검증기가 보고하는 두 겹.**
- **두 검증기를 분리하고 그 이유를 적은 것.**
- **Advanced 스킴을 이름으로 거부하고 그 근거를 적은 것** — 통제 평면과 승격 게이트가 따로 있는 능력이다.
- **알 수 없는 스킴을 기본 거절로 둔 것.**
- **긴 스트림을 싣는 프로파일에 재접속 예산과 배수 유예를 필수로 만든 것.**
- **세 팩토리(`virtualIp` · `headlessStreaming` · `mesh`)가 각자 일관된 조합을 들고 있고, 테스트가 셋 다 위반 0 임을 확인하는 것** — 기본으로 고르는 값이 스스로의 규칙을 만족한다.
- **`GrpcRetryOwner.NONE` 이 "아직 정하지 않았다" 와 구분되는 것** — 그 열거형 javadoc 이 "A method whose owner is NONE has been looked at" 라고 적고, 이 리프의 두 겹 분담이 그 값 덕분에 의미를 갖는다(§4).
---
## Source anchors
```
src/grpc/grpc-discovery/build.gradle:1-9
main/java/…/discovery/GrpcKubernetesProfile.java:1-90
main/java/…/discovery/GrpcDiscoveryPolicyValidator.java:1-67
main/java/…/discovery/GrpcResolverProfile.java:1-63
main/java/…/discovery/GrpcKubernetesProfileValidator.java:1-61
main/java/…/discovery/GrpcResolverType.java:1-45
main/java/…/discovery/GrpcKubernetesRoutingMode.java:1-45
main/java/…/discovery/GrpcStableLoadBalancer.java:1-38
test/java/…/discovery/GrpcKubernetesProfileTest.java:1-127
test/java/…/discovery/GrpcDiscoveryPolicyValidatorTest.java:1-93
src/grpc/grpc-policy/…/resilience/GrpcRetryOwner.java:14-33
```