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

15 KiB

grpc-advanced-diagnostics 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 4파일 277줄, test 1파일 229줄과 픽스처 1개 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc-advanced/grpc-advanced-diagnostics SSOT owner: grpc-advanced-diagnostics integration/family document: analysis/20-grpc-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지

  • allowed_dependencies: ["grpc-core-api", "grpc-client", "grpc-advanced-bootstrap"]
  • runtime_memberships: [] — build-only (Advanced 가족 전체가 그렇다)
파일 LOC
GrpcAdvancedInfrastructureTestkit 87
GrpcDiagnosticsRedactor 72
GrpcChannelDiagnosticsSnapshot 63
GrpcChannelDiagnosticsPolicy 55
main 합계 277
GrpcChannelDiagnosticsPolicyTest 229
xds/control-plane-snapshot.json 24

Coverage ledger

scope count disposition reason
main/java/** 4 FULL_READ 전 본문 축자 확인
test/java/** 1 FULL_READ 229줄 · 테스트 11개
test/resources/xds/*.json 1 FULL_READ 24줄 픽스처
build.gradle 1 FULL_READ 10줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일

UNCLASSIFIED 0.


1. 모듈의 정체

진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력이 무엇을 상대로 검증되어야 하는지를 이름 짓는 테스트킷 계약을 담는다.

편집기 javadoc 이 왜 이것이 필요한지 적는다.

"Channelz is unusually dangerous to expose because it is genuinely useful: it holds every socket's local and remote address, the security details of each connection, and per-call state… once the endpoint exists the whole of it is one authorization mistake away from being readable."

2. 두 겹의 게이트

GrpcChannelDiagnosticsPolicy 는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다.

"diagnostics need both a network and a role gate; Channelz holds every socket's peer and security detail, so either gate alone is the whole surface"

그리고 등록 판정이 능력 깃발에 걸려 있다. CSDS 는 Channelz 가 켜져 있고 xDS 도 켜져 있을 때만 등록된다.

"A CSDS service on a deployment that does not use xDS answers every query with nothing, which is harmless, and advertises a control-plane surface that does not exist, which is not."

3. 스냅숏이 스스로를 검사한다

GrpcChannelDiagnosticsSnapshot 정규 생성자가 두 가지를 거부한다.

maskedSocketAddresses.stream()
    .filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
      // 마스킹되지 않은 주소
xdsResourceVersions.keySet().stream()
    .filter(GrpcDiagnosticsRedactor::forbiddenField)
      // 금지된 필드 이름

즉 편집을 거치지 않은 값으로는 스냅숏을 만들 수 없다. §17.1 이 그 검사의 범위를 다룬다.

4. 마스킹의 형태

주소는 버리지 않고 가린다.

"An operator has to be able to tell two subchannels apart, and a stable mask does that without publishing where they point… The last two octets go; the first two stay, because 'which subnet' is a real diagnostic question and 'which host' is not one the diagnostics endpoint should answer."

5. 인프라 없는 증거를 거부하는 계약

GrpcAdvancedInfrastructureTestkit 이 능력별로 필요한 실제 인프라를 이름 짓는다.

능력 필요 인프라
GRPC_WEB gRPC-Web 프록시
SERVLET_COMPAT 서블릿 컨테이너
XDS 멈출 수 있는 xDS 통제 평면
KOTLIN 코틀린 툴체인
나머지 11종 없음

근거가 javadoc 에 있다.

"gRPC-Web without a proxy tests a code path no browser will take; a Servlet profile without a container tests the profile object; xDS without a control plane cannot exercise the case that matters, which is the control plane going away. In all three, a suite that runs without the infrastructure passes and establishes nothing, which is worse than not having one."

10. 테스트 레인

11개 테스트 229줄. 두 게이트, CSDS 조건부 등록, 금지 필드 제거, 마스킹, 스냅숏 거부와 수용, 커밋된 xDS 픽스처의 편집, 능력별 인프라 목록을 확인한다.

12. negative-space probes

12.1 도달성. Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없고, 이 리프를 의존 선언한 모듈도 없다.

$ grep -rn "advanced.diagnostics" --include=*.java src/ | grep -v grpc-advanced-diagnostics/
grpc-core-api/…/GrpcStableModuleCatalog.java:42:   "grpc-advanced-diagnostics");   ← 목록 안의 문자열
$ grep -rn "grpc-advanced-diagnostics" --include=*.gradle src/
(매치 없음)

방향을 뒤집으면 이 리프는 grpc-advanced-bootstrap 의 실제 소비자 둘 중 하나다 — GrpcChannelDiagnosticsPolicyGrpcAdvancedModuleGuard.available 을 두 번 부른다(그쪽 §12.1). 이 가족에서 리프끼리 실제로 코드가 닿는 몇 안 되는 자리다.

12.2 선언된 의존 셋 중 둘이 쓰이지 않는다.

api project(':grpc:grpc-core-api')          // import 0
api project(':grpc:grpc-client')            // import 0
api project(':grpc-advanced:grpc-advanced-bootstrap')   // import 4줄

리프의 자바 4파일이 갖는 dev.caskeleton import 는 넷뿐이고 전부 bootstrap 것이다.

GrpcAdvancedInfrastructureTestkit.java:3   GrpcAdvancedCapability
GrpcChannelDiagnosticsPolicy.java:3,4,5    GrpcAdvancedCapability · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard

grpc-client 는 특히 눈에 띈다 — Channelz 진단이 채널을 다루는 주제이므로 의존 선언은 자연스럽게 읽히는데, 이 리프의 스냅숏은 채널 타입을 쓰지 않고 String channelProfileString connectivityState 로 받는다. 진단 값 객체가 채널 타입에서 독립적인 것 자체는 설계로 읽히고, 그렇다면 남은 것은 쓰이지 않는 의존 선언이다.

같은 형태를 세 리프에서 기록했다 — grpc-advanced-edition §12.2(셋 다 미사용), grpc-spring-boot-starter §12.3(셋 미사용), grpc-observability §12.1(두 모듈이 이 리프를 api 로 노출하면서 쓰지 않음).

12.2 대조군. 이 저장소의 다른 편집기와 비교하면 방향이 같다 — grpc-observability 의 태그 정책은 허용 목록으로, 이쪽은 금지 패턴 + 마스킹으로 같은 문제(내용이 관측 표면으로 새는 것)를 푼다.

12.3 대조군 — 같은 두 리터럴이 두 리프에 있다.

// grpc-advanced-diagnostics: GrpcChannelDiagnosticsPolicy.standard()
new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));

// grpc-spring-boot-starter: GrpcPlatformAutoConfiguration.grpcReflectionPolicy(...)
new GrpcReflectionPolicy(properties.getReflectionMode(),
    java.util.Set.of("admin"), java.util.Set.of("ROLE_PLATFORM_ADMIN"));

관리 네트워크 이름과 관리 역할 이름이 같은 값으로 두 곳에 손으로 적혀 있고, 둘을 묶는 상수가 없다. 하나를 바꾸면 다른 하나가 남는다. 스타터 쪽은 그 리터럴이 설정 표면에 노출되지 않는다는 별도 문제도 있다(그쪽 §17.4).

12.4 드리프트. build.gradle 이 서술한 세 요소(Channelz/CSDS 진단, 편집기, 인프라 테스트킷 계약)가 전부 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • 실제 Channelz 서비스를 띄워 스냅숏을 만들지 않았다. 배선 경로가 없다.
  • IPv6 주소로 스냅숏을 만들어 §17.1 을 실행으로 재현하지 않았다. 정규식과 생성자 검사로 판정했다.
  • 테스트를 실행하지 않았다. 11개 전부 본문으로만 확인했다.
  • 두 의존이 쓰이지 않는다는 것(§12.2)은 ^import dev.caskeleton grep 으로 판정했다.

17. 손볼 것

17.1 P2 — 마스킹이 IPv4 만 알고, 그 결과 "마스킹되지 않은 주소" 검사가 나머지 형태를 전부 통과시킨다

private static final Pattern IPV4_WITH_PORT =
    Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");

public static String maskAddress(String address) {
  if (address == null || address.isBlank()) { return "unknown"; }
  return IPV4_WITH_PORT.matcher(address)
      .replaceAll(m -> m.group(1) + "." + m.group(2) + ".x.x");
}

IPv4 가 아닌 주소는 패턴에 맞지 않아 입력 그대로 반환된다.

그리고 스냅숏 생성자의 검사는 이렇게 되어 있다.

.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))

마스킹 결과가 입력과 같으면 이미 마스킹된 것으로 판정한다. 그러므로 IPv4 가 아닌 주소는 전부 이 검사를 통과한다.

입력 maskAddress 결과 생성자 판정
10.4.13.201:9090 10.4.x.x 거부(마스킹 필요)
10.4.x.x 10.4.x.x 수용
[2001:db8::4:13:201]:9090 입력 그대로 수용
pod-3.svc.cluster.local:8080 입력 그대로 수용
unix:/var/run/grpc.sock 입력 그대로 수용

세 번째와 네 번째가 문제다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고(grpc-discovery 전체가 그 주제다), 헤드리스 레코드의 엔드포인트는 파드 DNS 이름이며 이중 스택 클러스터에서는 IPv6 주소다. 편집기가 막으려 한 것이 정확히 그것이다 — "a diagnostics endpoint that publishes peer addresses publishes every tenant's connection."

unix 소켓 경로도 통과한다. 그것은 호스트 파일 시스템 경로다.

테스트가 이것을 볼 수 없다. 테스트의 주소 리터럴이 전부 IPv4 다 — 10.4.13.201:9090 · 10.9.13.201 · 10.4.x.x · 10.5.x.x. IPv6 도 호스트 이름도 없다.

수정. 마스킹을 형태별로 나눈다. IPv6 는 앞 두 그룹만 남기고 나머지를 :x:x 로, 호스트 이름은 최상위 라벨 몇 개만 남기고, 그 밖의 형태는 unknown 으로 접는다. 그리고 검사를 "결과가 입력과 같으면 통과" 가 아니라 "알려진 마스킹 형태와 일치해야 통과" 로 뒤집는다. 지금 형태는 마스킹이 모르는 입력을 전부 안전하다고 판정한다.

17.2 P3 — 금지 필드 검사가 키에만 적용되고 값에는 적용되지 않는다

xdsResourceVersions.keySet().stream().filter(GrpcDiagnosticsRedactor::forbiddenField)

redact(...) 도 같다 — 금지 이름의 키를 버리고, 남은 값은 주소 필드일 때만 마스킹한다. 값 자체가 자격증명 형태인지는 보지 않는다.

grpc-observability 의 태그 정책은 값도 본다(UUID·sha256:·bearer 패턴). 같은 저장소의 두 관측 편집기가 값 검사에서 갈린다.

xDS 자원 버전은 보통 짧은 숫자나 해시라 도달성이 낮다. 기록하는 이유는 두 편집기의 규율이 다르다는 점이다.

17.3 P3 — "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다

이 리프가 능력별로 무엇이 실환경인지 정의한다.

public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) {  }
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) {  }

그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.

public record GrpcAdvancedPromotionEvidence(
    GrpcAdvancedCapability capability, , boolean realEnvironmentTest) {  }
//                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^ 불리언 하나

GrpcAdvancedPromotionGate.evaluate 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.

결과: GrpcAdvancedPromotionEvidence.complete(XDS, 7일)realEnvironmentTest = true 를 그냥 넣는다. xDS 통제 평면이 실제로 있었는지와 무관하다. 이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.

왜 P3 인가. 두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. grpc-advanced-edition §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.

수정. GrpcAdvancedPromotionEvidence.realEnvironmentTest 를 불리언 대신 Set<Infrastructure> availableInfrastructure 로 바꾸고, 게이트가 missingInfrastructure(capability, available) 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다. 의존 방향도 맞는다 — 이 리프가 이미 bootstrap 을 의존하므로, 게이트가 이쪽을 부르려면 방향을 뒤집거나 Infrastructure 열거형을 bootstrap 으로 옮겨야 한다는 점은 함께 정해야 한다.

확인된 설계(문제 아님)

  • 두 게이트를 모두 요구하고 하나만 있으면 생성자가 거부하는 것.
  • CSDS 를 xDS 사용 시에만 등록하는 것과 그 근거 — 존재하지 않는 통제 평면 표면을 광고하지 않는다.
  • 주소를 버리지 않고 가리는 판단 — 두 서브채널을 구별할 수 있어야 한다.
  • 스냅숏이 스스로 편집 여부를 검사하는 것 — 편집을 우회한 값으로는 만들 수 없다(형태 범위는 §17.1).
  • 능력별로 필요한 실제 인프라를 이름 지은 것 — 인프라 없이 통과하는 묶음은 없는 것보다 나쁘다. (승격 게이트와의 연결 없음은 §17.3.)
  • requiredFor 의 switch 가 15개 능력을 전부 나열하고 default 를 두지 않은 것 — 능력이 하나 늘면 이 파일이 컴파일되지 않는다. 새 능력이 조용히 "인프라 불필요" 로 분류되지 않는다.
  • 픽스처가 금지 대상 셋을 일부러 담고 있는 것 — 통제 평면 토큰·피어 인증서·원시 소켓 주소. 파일 안 주석이 그 의도를 적고("so the redactor is tested against data shaped like the real thing rather than against a string somebody invented for the assertion"), 테스트가 편집 전에 그 셋이 실제로 들어 있는지부터 단언한 뒤 편집 결과를 본다.

Source anchors

src/grpc-advanced/grpc-advanced-diagnostics/build.gradle:1-10
main/java/…/diagnostics/GrpcAdvancedInfrastructureTestkit.java:1-87
main/java/…/diagnostics/GrpcDiagnosticsRedactor.java:1-72
main/java/…/diagnostics/GrpcChannelDiagnosticsSnapshot.java:1-63
main/java/…/diagnostics/GrpcChannelDiagnosticsPolicy.java:1-55
test/java/…/diagnostics/GrpcChannelDiagnosticsPolicyTest.java:1-229
test/resources/xds/control-plane-snapshot.json:1-24