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 offer(GrpcResolverUpdate update) { if (closed.get()) { return List.of(…); } List 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) ```