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

14 KiB

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. 모듈의 정체

// 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() — 를 실제로 발화시키는 테스트가 없다.

// 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 거절 목록은 안전이 아니라 메시지를 위해 있다.

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) 을 실행으로 재현하지 않았다. resolverProfileGrpcResolverProfile 정규 생성자 경로로 판정했다.

17. 손볼 것

17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다

GrpcKubernetesProfile 은 세 시간 값을 다룬다.

streamReconnectBudget   // 프로파일이 선언
readinessDrainGrace     // 프로파일이 선언
refreshInterval         // resolverProfile(...) 이 30초로 하드코딩
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 은 복수형으로 서술한다

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 에서 던진다

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) 을 만들고, 그 정규 생성자가 거부한다.

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 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.

수정. violationsexpectedAddressCount < 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