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>
16 KiB
grpc-observability 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 —
src/mainproduction 4파일 354줄, test 1파일 172줄 축자 통독 완료.STRUCTURAL_ONLY잔여 없음. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-observabilitySSOT owner:grpc-observabilityintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
allowed_dependencies:["grpc-core-api"]runtime_memberships:[](EVD-325)
| 항목 | 수 |
|---|---|
| production Java 파일 | 4 (354 LOC) |
| test Java 파일 | 1 (172 LOC) |
| build 파일 | build.gradle 12줄 |
| test 메서드(실행 확인) | 10 (EVD-325) |
| 선언된 의존 | project 1 + vendor 1 (micrometer-core) |
파일별 LOC:
| 파일 | LOC | 성격 |
|---|---|---|
GrpcMetricCardinalityPolicy |
123 | 태그 허용/거절 판정 (static 유틸) |
GrpcObservationConvention |
99 | Micrometer 등록 (유일한 상태 보유 클래스) |
GrpcRpcObservation |
78 | 논리 RPC 관측 record |
GrpcStreamObservation |
54 | 스트림 수명 관측 record |
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
build.gradle |
1 | FULL_READ |
12줄 전문 |
main/…/observability/*.java |
4 | FULL_READ |
4파일 전 본문 축자 확인 (cycle 2) |
test/…/GrpcMetricCardinalityPolicyTest.java |
1 | FULL_READ |
172줄, 10개 @Test 전부 단언 대상 확인 |
STRUCTURAL_ONLY 0 · UNCLASSIFIED 0.
1. 모듈의 정체와 경계
// build.gradle:3-5
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
// identifier as a tag.
세 층위를 구별한다 — 논리 RPC, 물리 시도, 스트림 수명주기.
Micrometer 를 api 로 노출하는 이유도 build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 GrpcObservationConvention 의 생성자와 boundedTags 반환형이 Micrometer 타입(MeterRegistry, Tags)이므로 그 서술은 코드와 일치한다.
2. 의존성과 런타임 배선
grpc-core-api 에서 쓰는 타입은 넷이다 — GrpcMethodName, GrpcStatusCode, RpcType, GrpcCompletionOutcome. 네 타입 모두 GrpcRpcObservation 의 record 성분이다. GrpcStreamObservation 은 GrpcMethodName 하나만 쓴다.
배선 없음(EVD-325). runtime_memberships 가 비어 있고, 저장소 어디에서도 new GrpcObservationConvention(...) 을 만드는 production 코드가 없다.
3. 컴포넌트 지도
GrpcMetricCardinalityPolicy 태그 키 allowlist 8 · 명시적 거절 11 · 값 패턴 1
GrpcObservationConvention meter 이름 7개 상수 · record 오버로드 2개
GrpcRpcObservation 9성분 record · tags() 7태그
GrpcStreamObservation 7성분 record · tags() 5태그
4. 계약·불변식
4.1 allowlist 가 기본 거절이고 거절 목록은 메시지를 위한 것이다
violations(Map) 의 판정 순서가 셋이다.
if (FORBIDDEN_TAGS.contains(key)) → "its value space grows with traffic…"
if (!ALLOWED_TAGS.contains(key)) → "not on the bounded allowlist [...]"
if (UNBOUNDED_VALUE.matcher(value)) → "looks like an identifier or a credential"
클래스 javadoc 이 두 목록이 겹치는 이유를 적는다 — "Everything unlisted is refused anyway; naming the dangerous ones gives the refusal a message that says why rather than just that." 즉 FORBIDDEN_TAGS 는 판정을 바꾸지 않고 진단만 바꾼다. 두 번째 분기가 이미 그것들을 거절한다.
허용 태그 8개: grpc.service · grpc.method · grpc.rpc_type · grpc.status · grpc.channel_profile · grpc.completion_outcome · grpc.retry_bucket · grpc.stream_termination_reason.
명시적 거절 11개: actor_id · tenant_id · object_id · stream_id · idempotency_key · request · response · metadata · authorization · error_detail · trace_id.
4.2 값 검사는 세 형태만 잡는다
Pattern.compile("(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*")
UUID · sha256: 접두 · bearer 접두. 숫자 id, 이메일, 호스트명은 잡히지 않는다. 그리고 . 은 기본적으로 개행에 맞지 않으므로 값에 개행이 섞이면 matches() 가 거짓이 된다.
4.3 재시도는 값이 아니라 버킷이다
retryBucket(int) 이 1-based 시도 수를 받아 0/1/2/3+ 로 접는다. 0 이하는 던진다. javadoc 이 이유를 적는다 — "an attempt count is unbounded in principle and the distinction anyone acts on is first attempt, one retry, several."
4.4 논리 호출과 물리 시도의 분리
GrpcRpcObservation javadoc:
"A retried call is one observation with a retry bucket, and three attempt events beneath it; recording three separate calls instead makes the success rate read as 33% when the caller in fact got its answer."
그 분리가 GrpcObservationConvention.record(GrpcRpcObservation) 에서 실제로 그렇게 구현되어 있다 — RPC_DURATION 타이머는 1회, RPC_ATTEMPTS 카운터는 attempts 만큼 증가. 같은 태그 집합을 쓴다.
4.5 조건부 기록 둘
if (observation.completionOutcome().requiresReconciliation()) → COMPLETION_UNKNOWN 카운터
if (!observation.queueWaitTime().isZero()) → QUEUE_WAIT 타이머
대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.
4.6 생성자 검증의 비대칭 — 의도된 쪽
GrpcRpcObservation 의 검증에서 duration 과 queueWaitTime 은 음수를 거부하고 deadlineRemaining 은 존재만 요구한다. 그리고 unusedDeadline() 이 음수일 때 빈 값을 돌려준다. 마감을 넘긴 호출을 표현하기 위한 것으로 읽히고, 두 메서드가 그 해석과 일관된다.
4.7 스트림은 지속 시간이 아니라 무엇이 움직였는지로 잰다
GrpcStreamObservation javadoc:
"Duration percentiles are meaningless here — a healthy subscription lasts an hour and an unhealthy one lasts an hour — so what is recorded instead is what actually distinguishes them: how many messages moved, how often the writer stalled waiting for the transport, and how it ended."
그리고 tags() 주석이 "The stream id is deliberately absent" 라고 적는다. 실제로 grpc.stream_id 는 FORBIDDEN_TAGS 에도 있어 두 겹으로 막힌다.
10. 테스트 레인
10 tests, 0 failures, 0 skipped. 전부 GrpcMetricCardinalityPolicyTest(172줄).
| 테스트 | 붙드는 것 |
|---|---|
onlyBoundedTagsAreAllowed |
allowlist 원소 |
unboundedIdentifierTagsAreRefused |
식별자 5종 거절 + 메시지 문구 |
contentBearingTagsAreRefused |
페이로드·메타데이터·오류 상세 3종 |
anIdentifierShapedValueIsRefused |
허용 키 + UUID/Bearer 값 |
unlistedTagsAreRefused |
목록 밖 키 + 메시지에 allowlist |
attemptsAreBucketed |
1→0, 2→1, 4→3+, 99→3+, 0→예외 |
aRetriedCallIsOneObservation |
타이머 1 · 시도 카운터 3 |
completionUnknownIsCountedSeparately |
전용 카운터 |
streamsAreMeasuredByMessagesAndStalls |
grpc.stream_id 부재 · 메시지·스톨 카운터 |
anUnboundedTagThrowsRatherThanBeingDropped |
등록 거부가 던지기 |
12. negative-space probes
12.1 도달성. 블록 전체가 배선되지 않았다(EVD-325). unusedDeadline()·retried()·consumerFellBehind()·allowedTags()·forbiddenTags()·retryBuckets() 의 production 호출자 0.
리프 밖 참조도 0 이다.
$ grep -rn "grpc.observability" --include=*.java src/ | grep -v /grpc-observability/
grpc-core-api/…/GrpcStableModuleCatalog.java:30: "grpc-observability", ← 목록 안의 문자열
그런데 두 모듈이 이 리프를 api 로 노출한다.
grpc/grpc-testkit/build.gradle:53 api project(':grpc:grpc-observability')
grpc/grpc-spring-boot-starter/build.gradle:16 api project(':grpc:grpc-observability')
api 는 그 모듈을 쓰는 쪽까지 Micrometer 를 포함한 이 리프의 타입을 물려받는다는 선언인데, 두 모듈 어느 자바 파일도 dev.caskeleton.grpc.observability 를 import 하지 않는다. 스타터 쪽은 같은 형태의 미사용 의존을 셋 더 들고 있다(grpc-spring-boot-starter §12.3).
이 리프의 build.gradle 은 Micrometer 를 api 로 두는 이유를 적어 두었다 — 공개 서명이 Micrometer 타입을 이름으로 부르므로 배선하려면 그것을 명명해야 한다. 그 논거는 이 리프를 실제로 쓰는 모듈에 대해 성립한다. 지금은 쓰지 않는 두 모듈이 그 전파를 받고 있다.
12.2 대조군 — 세 개의 카디널리티/노출 정책.
| 위치 | 막는 것 | 배선 |
|---|---|---|
messaging CardinalityGuard |
지표 태그 폭발 | 없음 (EVD-316) |
grpc-observability GrpcMetricCardinalityPolicy |
태그 키 allowlist + 값 형태 | 없음 |
grpc-policy GrpcErrorExposurePolicy |
클라이언트에 보낼 수 없는 문자열 | 블록 미배선 |
12.3 중복 장치. GrpcStreamTerminationReason 이 grpc-policy 에 열거형으로 존재한다. 이 리프의 GrpcStreamObservation.terminationReason 은 String 이다. §17.2 참조.
12.4 문서 드리프트. build.gradle 주석이 거절 대상으로 든 다섯(actor·tenant·object·stream·idempotency)이 FORBIDDEN_TAGS 에 전부 있다. 드리프트 없음.
16. 확인하지 못한 것
- 이 리프를 실제
MeterRegistry에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다. UNBOUNDED_VALUE를 우회하는 값 형태(숫자 id·이메일 등)를 실행으로 확인하지 않았다. 정규식 형태로 판정했다.- §17.1-b 의 "마감 잔량에 해당하는 meter 가 없다" 는 meter 이름 상수 일곱 개와 두
record오버로드 본문으로 판정했다. 다른 이름의 상수가 그 역할을 겸하는지는 이름만 보고 배제했다. - 두 모듈의
api의존이 미사용이라는 것(§12.1)은 패키지 이름 grep 으로 판정했다.
17. 손볼 것
17.1 P3 — queueHighWatermark 는 요구되고 검증되지만 아무도 읽지 않는다
GrpcStreamObservation 의 7성분 중 queueHighWatermark 만 소비자가 없다.
GrpcStreamObservation.java:23 long queueHighWatermark, ← 선언
GrpcStreamObservation.java:31 … || queueHighWatermark < 0 ← 검증
그 외 저장소 전체 매치 0
tags() 에 없고, GrpcObservationConvention.record(GrpcStreamObservation) 이 등록하는 세 meter(STREAM_LIFETIME·STREAM_MESSAGES·STREAM_FLOW_CONTROL_STALLS) 어디에도 들어가지 않는다. 테스트도 250L 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 즉 서술과 구현은 일치하고, 어긋난 것은 필수 생성자 인자라는 점이다. 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
수정은 둘 중 하나다 — STREAM_QUEUE_HIGH_WATERMARK gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
17.1-b P3 — deadlineRemaining 도 meter 가 없다. javadoc 은 그것이 기록된다고 말한다
§17.1 과 같은 형태가 GrpcRpcObservation 에도 있고, 이쪽은 클래스 javadoc 이 명시적으로 어긋난다.
"{@code deadlineRemaining} and {@code queueWaitTime} are recorded because they are the two numbers that explain a latency change without being latency. A p99 that doubles during a rollout is a different incident depending on whether callers were queueing."
두 값을 함께 들면서 "기록된다"고 단언하는데, record(GrpcRpcObservation) 이 등록하는 meter 는 넷이다.
Timer.builder(RPC_DURATION)…record(observation.duration());
registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts());
if (…requiresReconciliation()) registry.counter(COMPLETION_UNKNOWN, tags).increment();
if (!observation.queueWaitTime().isZero()) Timer.builder(QUEUE_WAIT)…record(observation.queueWaitTime());
queueWaitTime 은 QUEUE_WAIT 타이머로 나간다. deadlineRemaining 은 나가는 곳이 없다 — meter 이름 상수 일곱 개 중에도 마감 잔량에 해당하는 것이 없고, tags() 에도 들어가지 않는다(태그로 넣으면 카디널리티가 터지므로 그것이 옳다).
그래서 이 성분을 읽는 코드는 unusedDeadline() 하나이고, 그 메서드의 production 호출자는 0 이다(§12.1).
§4.6 과의 관계. §4.6 은 이 성분의 검증 비대칭(음수 허용)이 "마감을 넘긴 호출을 표현하기 위한 것" 이라고 읽었다. 그 해석은 그대로 유효하다 — 다만 그 표현이 도달하는 곳이 아직 없다. 관측값으로서는 §17.1 의 queueHighWatermark 와 같은 처지다.
수정. queueWaitTime 과 같은 형태로 타이머를 하나 더 둔다(마감을 넘긴 경우는 unusedDeadline() 이 이미 빈 값으로 구분해 주므로 기록 대상에서 빼면 된다). 아니면 javadoc 의 "recorded" 를 "carried" 로 낮춘다. 지금은 관측 대상 둘을 나란히 약속하고 하나만 내보낸다.
17.2 P3 — 허용 태그 8개 중 둘은 값이 자유 문자열이고, 그중 하나는 bounded 열거형이 이미 존재한다
값 검사는 키가 allowlist 를 통과한 뒤 UNBOUNDED_VALUE 세 형태만 본다. 그런데 태그 값의 출처는 균일하지 않다.
| 태그 | 값 출처 | 유계 |
|---|---|---|
grpc.service · grpc.method |
GrpcMethodName |
서비스/메서드 수만큼 |
grpc.rpc_type · grpc.status · grpc.completion_outcome |
열거형 | 예 |
grpc.retry_bucket |
retryBucket() 4값 |
예 |
grpc.channel_profile |
String (null 이면 "server") |
아니오 |
grpc.stream_termination_reason |
String, 비어 있지 않기만 하면 됨 |
아니오 |
GrpcStreamObservation 의 검증은 terminationReason 이 널이 아니고 공백이 아닌지만 본다. 호출자가 예외 메시지나 원격 상태 문자열을 그대로 넣으면 그 태그의 값 공간이 트래픽과 함께 자란다 — 이 클래스가 존재하는 이유로 든 바로 그 실패다.
그리고 그 개념의 bounded 열거형이 이미 저장소에 있다 — grpc-policy 의 GrpcStreamTerminationReason.
쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 allowed_dependencies 는 ["grpc-core-api"] 뿐이고 그 열거형은 grpc-policy 에 있다. 그래서 수정은 열거형을 grpc-core-api 로 옮기거나, violations 가 두 자유 문자열 태그에 대해 허용값 집합을 받도록 서명을 넓히는 것이다.
확인된 설계(문제 아님)
- 논리 RPC / 물리 시도 / 스트림 수명주기를 구별한 것. 재시도가 있는 시스템에서 호출 한 번이 무엇인지가 층위마다 다르고,
record구현이 그 구별을 실제로 지킨다. - 거절이 드롭이 아니라 던지기인 것.
boundedTags의 javadoc 이 이유를 적는다 — 드롭하면 넣은 쪽이 계속 쓰고 첫 증상이 프로덕션 백엔드의 시계열 거부가 된다. FORBIDDEN_TAGS를 진단 전용으로 둔 것. 판정은 allowlist 가 하고, 이 목록은 왜 거절인지만 바꾼다.- 스트림 id 를 두 겹으로 막은 것.
tags()에서 빼고FORBIDDEN_TAGS에도 둔다. long → double확대 변환을 명시하고 이유를 주석에 적은 것.
Source anchors
src/grpc/grpc-observability/build.gradle:1-12
main/…/observability/GrpcMetricCardinalityPolicy.java:1-123
main/…/observability/GrpcObservationConvention.java:1-99
main/…/observability/GrpcRpcObservation.java:1-78
main/…/observability/GrpcStreamObservation.java:1-54
test/…/observability/GrpcMetricCardinalityPolicyTest.java:1-172
src/grpc/grpc-policy/…/streaming/GrpcStreamTerminationReason.java (대비)
src/messaging/messaging-observability/…/CardinalityGuard.java (대비)