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>
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
test/resources/xds/bootstrap.json
|
|
---
|
|
# grpc-advanced-resilience 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 16파일 940줄 + `src/test` 4파일 577줄 + `src/test/resources` 1파일 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/grpc-advanced/grpc-advanced-resilience`
|
|
> SSOT owner: `grpc-advanced-resilience`
|
|
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
|
|
|
---
|
|
|
|
## 0. SSOT identity / 커버리지
|
|
|
|
- `allowed_dependencies`: core-api · policy · client · discovery · advanced-bootstrap
|
|
- `runtime_memberships`: **`[]`** — build-only
|
|
|
|
| 패키지 | 파일 | LOC |
|
|
|---|---:|---:|
|
|
| `resilience` (헤징) | 4 | 242 |
|
|
| `xds` | 4 | 252 |
|
|
| `discovery` (사용자 정의 리졸버·LB) | 7 | 446 |
|
|
|
|
### Coverage ledger
|
|
|
|
| scope | count | disposition | reason |
|
|
|---|---:|---|---|
|
|
| `main/java/**` | 16 | `FULL_READ` | 940줄 전 본문 |
|
|
| `test/java/**` | 4 | `FULL_READ` | 577줄 |
|
|
| `test/resources/xds/bootstrap.json` | 1 | `FULL_READ` | 커밋된 부트스트랩 픽스처 — §12.5 |
|
|
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
|
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
|
|
|
`UNCLASSIFIED` 0.
|
|
|
|
---
|
|
|
|
## 1. 모듈의 정체
|
|
|
|
```groovy
|
|
// build.gradle:3-4
|
|
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
|
|
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
|
|
```
|
|
|
|
## 2. 헤징은 읽기 전용 단항만
|
|
|
|
`GrpcHedgingEligibility` 가 세 조건을 순서대로 본다 — 단항이 아님, 읽기 전용이 아님, 재시도 소유자가 in-process 헤징을 허락하지 않음.
|
|
|
|
> "A hedged mutation runs twice by design rather than by accident — both attempts are in flight, both
|
|
> may reach the server, and an idempotency key does not help because the second attempt is not a
|
|
> retry of a failure but a duplicate of a success in progress. A hedged stream is worse still: two
|
|
> streams deliver two prefixes."
|
|
|
|
멱등 키가 왜 도움이 되지 않는지를 한 문장으로 정리한 것이 이 리프의 핵심 판단이다.
|
|
|
|
## 3. 헤징 예산
|
|
|
|
토큰 버킷이다. 헤지 하나가 `round(1/ratio)` 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다. 상한이 조용한 구간 뒤의 폭주를 제한한다.
|
|
|
|
비율 상한이 0.5 이고 그 근거가 적혀 있다.
|
|
|
|
> "a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a load
|
|
> decision rather than a latency one"
|
|
|
|
그리고 왜 재시도 예산보다 더 급한지도 적는다.
|
|
|
|
> "A retry happens after a failure; a hedge happens on a call that might have succeeded, so a fleet
|
|
> that hedges without a budget doubles its backend load in the steady state and doubles it again the
|
|
> moment latency rises."
|
|
|
|
소비는 정확한 비교 후 교체 루프다 — 이 가족에서 원자성을 제대로 다룬 몇 안 되는 곳이다.
|
|
|
|
## 4. xDS 시작 가드
|
|
|
|
두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다.
|
|
|
|
> "xDS working in a deployment is not the same claim as the platform supporting it: it brings a
|
|
> control plane, its outage modes, its own security boundary and its own version skew, and the
|
|
> Stable support statement covers DNS and static targets. A support matrix that quietly widens is a
|
|
> support matrix nobody can rely on."
|
|
|
|
시작 차단 사유는 둘 — 능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함.
|
|
|
|
> "with xDS the control plane owns it, and defining it in both places makes the winner depend on
|
|
> resolution order"
|
|
|
|
부트스트랩 대조는 세 가지를 본다 — `xds_servers` 선언, 통제 평면 채널의 TLS, 프로파일의 자원 이름공간.
|
|
|
|
> "a client whose bootstrap names a namespace the deployment did not configure subscribes
|
|
> successfully and receives another team's routing. Nothing errors — the control plane answers, the
|
|
> resources parse, and traffic goes somewhere nobody chose."
|
|
|
|
## 5. 사용자 정의 리졸버·LB 안전 규칙
|
|
|
|
리졸버는 주소와 검증된 서비스 설정만 줄 수 있다.
|
|
|
|
> "A resolver runs inside the channel and speaks to something outside the deployment. Everything it
|
|
> can put into an update is therefore attacker-influenced in the worst case."
|
|
|
|
권한 문자열 형태 검사, 개정 번호의 전진 요구, 자격증명 형태 필드 거부 셋이다.
|
|
|
|
선택기는 두 규칙을 받는다 — 리졸버가 준 엔드포인트만 고를 수 있고, 던지면 결정적 대체로 떨어진다.
|
|
|
|
> "a picker that can invent an address can send a request anywhere … a picker bug should degrade the
|
|
> balancing rather than the availability"
|
|
|
|
## 12. negative-space probes
|
|
|
|
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다.
|
|
|
|
**12.2 대조군 — 원자성.** `GrpcHedgingBudget.tryConsume` 이 비교 후 교체 루프를 정확히 쓴다. 같은 가족의 `GrpcAdmissionController.tryAdmit`·`GrpcStreamAdmission.tryAdmit` 은 같은 문제를 비원자적으로 푼다. 정본이 이 리프에 있다.
|
|
|
|
**12.3 리프 밖 참조 0.** 세 패키지 각각을 확인했다 — `advanced.discovery`·`advanced.resilience`·`advanced.xds` 를 import 하는 파일이 이 리프 밖에 없다. `grpc-advanced-bootstrap` 도 포함해서다.
|
|
|
|
**12.4 드리프트.** build.gradle 이 서술한 네 능력이 전부 존재한다.
|
|
|
|
**12.5 대조군 — 커밋된 픽스처를 판정기에 넣는가.** 이 리프는 넣는다.
|
|
|
|
```java
|
|
// GrpcXdsStartupGuardTest: "the committed bootstrap fixture agrees with the profile it is meant to serve"
|
|
String bootstrap = resource("xds/bootstrap.json");
|
|
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile, bootstrap)).isEmpty();
|
|
```
|
|
|
|
같은 자리에서 `grpc-advanced-compat` 은 넣지 않는다 — `envoy.yaml` 을 부분 문자열로만 확인하고 `GrpcWebProxyContract` 에 넣지 않는다(그 리프 §17.3). 두 리프가 같은 재료를 갖고 한 쪽만 고리를 닫았다.
|
|
|
|
## 16. 확인하지 못한 것
|
|
|
|
- 실제 xDS 통제 평면을 세워 부트스트랩 대조를 재현하지 않았다.
|
|
- 헤징 예산의 정상 상태 비율을 부하로 측정하지 않았다. 토큰 계산으로 판정했다.
|
|
|
|
## 17. 손볼 것
|
|
|
|
### 17.1 P3 — 부트스트랩 대조가 문서 어디든의 부분 문자열을 본다
|
|
|
|
```java
|
|
if (!bootstrapJson.contains("\"xds_servers\"")) { … }
|
|
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) { … }
|
|
if (!bootstrapJson.contains(profile.resourceNamespace())) { … }
|
|
```
|
|
|
|
세 검사가 모두 문서 전체에 대한 부분 문자열 포함이다. JSON 파서를 쓰지 않은 이유는 자바독이 밝힌다 — 세 필드를 보려고 파서를 xDS 를 켜는 모든 배포의 실행 클래스패스에 올리지 않겠다는 것이다. 그 판단 자체는 이 저장소의 다른 결정들과 일관된다.
|
|
|
|
다만 검사의 형태가 그 판단보다 느슨하다.
|
|
|
|
- `"tls"` 가 문서 어디에든 있으면 통과한다. 통제 평면 채널이 `insecure` 로 설정되어 있고 다른 곳(예: 서버 리스너 설정)에 `tls` 라는 낱말이 있으면 두 번째 검사가 지나간다.
|
|
- 자원 이름공간이 주석·다른 필드·다른 서버 항목에 있어도 통과한다. 세 번째 검사가 막으려는 것은 "이 클라이언트가 자기 이름공간 밖을 구독하는 것" 인데, 문자열이 어딘가에 있다는 것은 그것이 이 클라이언트의 구독 대상이라는 뜻이 아니다.
|
|
|
|
그리고 이 검사가 막으려는 실패는 자바독이 스스로 "조용하다" 고 적은 것이다 — 아무것도 오류가 되지 않는 종류다. 느슨한 검사와 조용한 실패의 조합이 이 항목을 기록하는 이유다.
|
|
|
|
수정은 파서를 들이지 않고도 가능하다 — `"channel_creds"` 를 포함하는 객체 범위 안에서 `"type"` 값을 찾는 정도의 구조 인식이면 두 번째 검사가 실제 조건에 가까워진다. 또는 파서를 테스트 범위에만 두고 이 가드는 형태를 좁힌 정규식으로 바꾼다.
|
|
|
|
### 17.2 P3 — 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
|
|
|
|
```java
|
|
try {
|
|
chosen = picker.pick(selectable);
|
|
} catch (RuntimeException pickerFailure) {
|
|
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
|
|
}
|
|
if (chosen == null || !selectable.contains(chosen)) {
|
|
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
|
|
}
|
|
```
|
|
|
|
`fallback.pick(selectable)` 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
|
|
|
|
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
|
|
|
|
이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.
|
|
|
|
수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
|
|
|
|
### 17.3 P2 — 리졸버의 개정 가드가 비교 후 교체가 아니다
|
|
|
|
`GrpcCustomResolver` 의 javadoc 이 지키겠다고 하는 것은 명확하다.
|
|
|
|
> "Stale revisions and empty endpoint sets are dropped rather than propagated."
|
|
|
|
빈 집합은 `GrpcEndpointSnapshot` 의 생성자가 지키므로 성립한다. 개정 가드는 그렇지 않다.
|
|
|
|
```java
|
|
public List<String> offer(GrpcResolverUpdate update) {
|
|
if (closed.get()) { return List.of(…); }
|
|
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get()); // ← 읽기
|
|
if (!violations.isEmpty()) { return violations; }
|
|
applied.set(update.snapshot()); // ← 조건 없는 쓰기
|
|
listener.accept(update);
|
|
return List.of();
|
|
}
|
|
```
|
|
|
|
`AtomicReference` 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다. 개정 5 와 6 을 든 두 스레드가 같은 `applied`(개정 4)를 읽으면 둘 다 `supersedes` 를 통과하고, 나중에 `set` 하는 쪽이 이긴다. 6 이 먼저 쓰이고 5 가 덮으면 **채널이 옛 엔드포인트로 되돌아간다** — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.
|
|
|
|
`listener.accept(update)` 도 `set` 밖에 있으므로, `applied` 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다. 채널은 마지막으로 받은 것을 믿는다.
|
|
|
|
같은 형태가 이 가족에 셋이다.
|
|
|
|
| 자리 | 형태 |
|
|
|---|---|
|
|
| `GrpcHedgingBudget.tryConsume`(이 리프) | 비교 후 교체 루프 — 정확 |
|
|
| `GrpcCredentialRotationManager.rotate`·`completeDrain`(grpc-policy §17.2) | 읽고 조건 없이 쓴다 |
|
|
| `GrpcChannelRuntimeRegistry.rotate`(grpc-client) | 같은 형태 |
|
|
| `GrpcCustomResolver.offer`(여기) | 같은 형태 |
|
|
|
|
정본이 같은 리프 안에 있다는 점이 §12.2 의 대조와 같다 — 이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다.
|
|
|
|
**시험이 보지 못하는 이유.** `a stale revision is dropped rather than applied` 는 단일 스레드에서 개정 2 를 적용한 뒤 개정 1 을 제시한다. 순차적으로는 가드가 정확히 작동한다.
|
|
|
|
**등급.** 이 리프가 배선되지 않으므로 P2. 리졸버는 정의상 외부 발견 소스가 밀어 넣는 것이고, 그 소스가 한 스레드만 쓴다는 보장은 이 클래스가 하지 않는다.
|
|
|
|
**수정.** `applied.updateAndGet` 안에서 판정과 교체를 함께 하거나, `compareAndSet(observed, snapshot)` 이 실패하면 다시 읽어 판정한다. 리스너 통지는 성공한 CAS 뒤에 그 CAS 가 이긴 순서로 해야 한다 — 예산 쪽의 `tryConsume` 루프가 같은 리프 안의 본보기다.
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- **헤징을 읽기 전용 단항으로 한정하고, 멱등 키가 왜 도움이 되지 않는지를 명시한 것.**
|
|
- **헤징 예산의 비율 상한 0.5 와 그 근거.**
|
|
- **예산 소비를 정확한 비교 후 교체로 구현한 것.**
|
|
- **xDS 를 Stable 지원으로 광고할 수 없게 상수로 못박은 것.**
|
|
- **애플리케이션과 통제 평면이 재시도를 함께 정의하는 것을 시작 차단 사유로 둔 것.**
|
|
- **부트스트랩과 프로파일의 불일치를 검사 대상으로 삼은 것** — 두 문서를 다른 사람이 다른 저장소에서 쓴다.
|
|
- **리졸버가 자격증명을 실을 수 없게 한 것과 권한 문자열 형태를 제한한 것.**
|
|
- **리졸버 업데이트의 개정 번호 전진을 요구한 것.**
|
|
- **선택기가 리졸버가 준 엔드포인트만 고르게 한 것.**
|
|
|
|
---
|
|
|
|
## Source anchors
|
|
|
|
```
|
|
src/grpc-advanced/grpc-advanced-resilience/build.gradle
|
|
main/java/…/resilience/(GrpcHedgingEligibility · GrpcHedgingBudget · GrpcHedgingPolicy · GrpcHedgingResult)
|
|
main/java/…/xds/(GrpcXdsStartupGuard · GrpcXdsFailurePolicy · GrpcXdsProfile · GrpcXdsResourceSnapshot)
|
|
main/java/…/discovery/(GrpcResolverSafetyPolicy · GrpcLoadBalancerSafetyPolicy · GrpcCustomResolver · GrpcLoadBalancerDecision · GrpcEndpointSnapshot · GrpcEndpointCandidate · GrpcResolverUpdate · GrpcLoadBalancerPicker)
|
|
```
|