외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.
삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard
이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
ca.java-conventions / ca.quality-conventions / ca.java-library /
ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로
완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로
task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리
CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정
순증감 +3,274 / -7,483.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.0 KiB
Markdown
114 lines
4.0 KiB
Markdown
# 운영 Runbook
|
|
|
|
## 배포 전 체크
|
|
|
|
```bash
|
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
|
./gradlew verifyRuntimeModuleMembership --console=plain
|
|
./gradlew checkstyleMain --console=plain
|
|
```
|
|
|
|
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
|
|
|
- ordered destination + reorder 가능 retry
|
|
- `ordering=KEY` + key resolver 없음
|
|
- payload 상한 > 8,388,608 bytes
|
|
- DLQ 자기 참조 / retry 자기 참조
|
|
- retry·DLQ 그래프 cycle
|
|
- 미등록 retry·DLQ destination
|
|
- M1 destination + manual settlement
|
|
- `AT_LEAST_ONCE` + confirmation `NONE`
|
|
- production profile + topology auto-create
|
|
- broker topology가 manifest와 불일치
|
|
|
|
## 증상별 대응
|
|
|
|
### publish가 AMBIGUOUS로 쏟아진다
|
|
|
|
broker confirm 경로 문제다. 실패가 아니다.
|
|
|
|
1. `PublishEvidence.transmission`이 `MAY_HAVE_BEEN_TRANSMITTED`인지 확인
|
|
2. Kafka: `delivery.timeout.ms`, ISR 상태, leader election 확인
|
|
3. Rabbit: confirm timeout, channel 상태 확인
|
|
4. Outbox를 쓰고 있다면 `status='AMBIGUOUS'` row가 같은 messageId로 재시도 중이다. **정상이다.**
|
|
5. consumer 쪽 Inbox가 중복을 흡수하는지 확인
|
|
|
|
`AMBIGUOUS`를 실패로 취급해 새 messageId로 재발행하지 말 것. 중복이 복구 불가능해진다.
|
|
|
|
### DLQ가 비어 있는데 메시지가 사라졌다
|
|
|
|
DLQ publish 실패 시 source는 settlement되지 않는다. 메시지는 source에 남아 재전달된다.
|
|
|
|
1. `msg.failure-code`가 `DEAD_LETTER_*`인 로그 확인
|
|
2. DLQ destination이 실제로 존재하는지 (topology validation)
|
|
3. DLQ credential에 publish 권한이 있는지
|
|
|
|
### consumer lag이 한 partition에서만 증가한다
|
|
|
|
`ContiguousPartitionOffsetTracker`가 gap에서 멈춘 것이다. 설계된 동작이다.
|
|
|
|
commit은 **연속** 완료 offset까지만 전진한다. offset 11이 아직 실행 중이면
|
|
10과 12가 끝나도 watermark는 10에 머문다. 12를 commit하면 consumer가 죽었을 때 11을 잃는다.
|
|
|
|
1. 해당 partition의 in-flight를 확인
|
|
2. 느린 handler를 찾는다 (`handlerTimeout` 초과 여부)
|
|
3. 필요하면 `PAUSE_PARTITION` retry가 걸려 있는지 확인
|
|
|
|
### 재시도 폭풍
|
|
|
|
`RetryPolicy.jitter=false`인지 확인한다. jitter 없이는 같은 초에 실패한 모든 consumer가
|
|
같은 초에 재시도한다.
|
|
|
|
### shutdown이 오래 걸린다
|
|
|
|
`GracefulShutdownCoordinator`가 in-flight를 기다리는 중이다.
|
|
|
|
- `inFlight()`가 0이 되면 즉시 종료
|
|
- drain deadline(기본 30초) 초과 시 남은 작업을 **unsettled로 포기**한다 → broker가 재전달
|
|
- draining 시작 후 새 retry attempt는 만들지 않는다
|
|
|
|
## Destructive 작업
|
|
|
|
전부 `DestructiveOperationGuard`를 통과해야 한다.
|
|
|
|
| 조건 | 요구 |
|
|
|---|---|
|
|
| admin credential | application runtime은 보유하지 않음 |
|
|
| `AdminApproval` | 유효기간 내 |
|
|
| dry-run | 항상 허용 |
|
|
|
|
### Replay
|
|
|
|
```text
|
|
기본: 격리된 consumer group (replay-<requestId>)
|
|
기존 group 대상: 승인 티켓 필수
|
|
```
|
|
|
|
기존 production group으로 replay하는 것은 "다시 읽기"가 아니라 **live consumer를 되감는 것**이다.
|
|
그 사이의 모든 것이 재처리된다.
|
|
|
|
### Redrive
|
|
|
|
```text
|
|
dry-run으로 후보 수 확인
|
|
→ 승인 획득
|
|
→ batch 100건 이하로 실행
|
|
→ republish CONFIRMED 인 것만 DLQ에서 settlement
|
|
```
|
|
|
|
`redriveId`로 재구동 루프를 추적한다. 같은 메시지가 반복해서 redrive되면
|
|
근본 원인이 해결되지 않은 것이다.
|
|
|
|
### Offset reset
|
|
|
|
`KafkaOffsetResetExecutor`는 승인 predicate를 **생성자 인자**로 받는다.
|
|
승인 소스 없이 조립된 runtime은 물리적으로 reset을 수행할 수 없다.
|
|
|
|
## Topology
|
|
|
|
production topology는 IaC가 만들고 애플리케이션은 **검증만** 한다.
|
|
|
|
`TopologyValidationRuntime`은 모든 불일치를 한 번에 보고하고 startup을 실패시킨다.
|
|
partition 수가 다르면 destination이 광고하는 ordering 보장이 달라지고,
|
|
`min.insync.replicas`가 없으면 `acks=all`의 의미가 달라진다.
|