feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: commands-written-as-prose-do-not-run
|
||||
title: 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn:
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-재현-가능성
|
||||
- final/document.md#검토한-선택지와-막힌-지점-스크립트를-쓰지-않는다
|
||||
assets:
|
||||
- key: reproducibility-gap
|
||||
file: ../../../final/assets/reproducibility-gap/reproducibility-gap.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a6-latency-injection__04-pool-under-load.txt
|
||||
- ../../../final/evidence/raw/followup__05-command-reproducibility.txt
|
||||
---
|
||||
|
||||
# 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다
|
||||
|
||||
산문으로 적힌 측정 장치 넷을 셸 표현식으로 바꿔 실제로 돌렸더니 한 건이 깨졌다. 22.2초를 낸 A-6 의 부하 생성기였고, 일회성 파드의 stdout 이 유실돼 동시 20건 중 일부만 도착했다. 상주 탐침 안 파일로 모아 회수하는 방식으로 고치고 20/20 을 다시 확인했다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
여기서 깨진 부하 생성기가 그 아홉 건의 마지막 항목이다. 고치고 나서야 아홉 번째 실패로 확정됐다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
주입을 확인하는 명령도 절차의 일부라서, 그 명령이 실제로 도는지까지 확인해야 이 기준이 성립한다.
|
||||
|
||||
## 문제
|
||||
|
||||
절차를 스크립트로 감싸면 무엇을 했는지가 스크립트 안으로 숨는다. 그래서 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.
|
||||
|
||||
명령을 적는 것만으로는 부족했다. 측정 장치 자체를 괄호와 설명으로 적어 둔 곳이 넷 있었고, 그 넷이 헤드라인 수치를 만든 명령이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
산문으로 적혀 있던 측정 장치 : 4곳 (A-6 · A-3 · A-8 · A-1)
|
||||
셸 표현식으로 바꾼 뒤 실제로 돌렸을 때 깨진 것 : 1건 (A-6 의 부하 생성기)
|
||||
깨진 이유 : 일회성 파드의 stdout 이 유실된다
|
||||
고친 방법 : 상주 탐침 + 파드 안 파일 수집
|
||||
고친 뒤 : 동시 20건에서 20/20 도착
|
||||
고친 장치에서 나온 값 : 가장 느린 요청 22.230871초 · 커넥션 획득 대기 최대 20000.0ms
|
||||
|
||||
명령을 실행 가능하게 고쳤다는 것과 고친 명령이 동작한다는 것은 다른 주장이다. 넷 중 셋은 고치자마자 돌았고 하나는 돌지 않았는데, 실제로 실행해 보기 전에는 넷이 같아 보였다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
대상 실험 : A-6 지연 주입 (200ms 를 걸고 동시 20건 로그인)
|
||||
부하 대상 : keycloak-1
|
||||
측정한 것 : 상태코드 · 응답 시간 · agroal 커넥션 풀 지표 · readiness 이벤트
|
||||
처음 쓴 방법 : kubectl run --rm -i 로 일회성 파드를 띄워 stdout 수집
|
||||
고친 방법 : 상주 탐침 안에서 실행하고 파드 안 파일로 모은 뒤 회수
|
||||
스크립트 사용 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 절차 문서에서 괄호와 설명으로 적힌 명령을 찾는다.
|
||||
실행할 수 없는 형태로 적힌 측정 장치를 골라낸다.
|
||||
|
||||
2. 그것을 셸에 붙여넣을 수 있는 표현식으로 바꾼다.
|
||||
|
||||
3. 바꾼 표현식을 실제로 실행한다. 문법 검사로 대신하지 않는다.
|
||||
|
||||
4. 기대한 만큼의 줄이 도착했는지 센다.
|
||||
동시 20건이면 20줄이 있어야 한다.
|
||||
|
||||
5. 줄이 모자라면 출력 경로를 바꾼다.
|
||||
일회성 파드의 stdout 대신 상주 탐침 안 파일에 쓰고 회수한다.
|
||||
|
||||
6. 고친 장치로 다시 측정해 원래 수치가 재현되는지 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 스크립트를 쓰지 않기로 했다
|
||||
|
||||
절차를 스크립트로 감싸면 무엇을 했는지가 스크립트 안으로 숨는다. 파일 이름만 남고 그 안에서 어떤 명령이 어떤 순서로 돌았는지는 다른 파일을 열어야 알 수 있다. 그래서 이 실험대는 모든 절차를 셸에 그대로 붙여넣을 수 있는 명령으로 적었다.
|
||||
|
||||
그런데 나중에 재현 절차를 점검해 보니 셸에 붙여넣을 수 없는 형태로 남아 있는 곳이 넷 있었다.
|
||||
|
||||
## 명령으로 적히지 않은 곳이 넷 있었다
|
||||
|
||||
문서의 명령을 하나씩 봤더니 절차 대부분은 붙여넣으면 도는 형태였고, 걸린 넷은 전부 측정 장치 자체를 적어 둔 곳이었다.
|
||||
|
||||
| 어디 | 무엇이 산문으로 적혀 있었나 |
|
||||
|---|---|
|
||||
| A-6 | `( curl ... ) & 를 20개 띄우고 wait` ← 22.2초의 출처 |
|
||||
| A-3 | `<로그인 반복, sid 를 /tmp/sids 에>` ← RPO 측정 전체 |
|
||||
| A-8 | `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽는다 ← 빈 토큰을 보내고 있었다 |
|
||||
| A-1 | conntrack 튜플을 손으로 적는다 ← 방향이 재시작마다 바뀐다 |
|
||||
|
||||
넷 다 이 실험대의 헤드라인 수치를 만든 명령이다. A-6 의 한 줄이 22.2초를 냈고, A-3 의 한 줄은 RPO(복구 시점 목표 — 장애로 잃을 수 있는 데이터의 시간 범위) 측정 전체를 냈다. A-8 은 적힌 대로 읽으면 토큰을 쓴 파일과 읽는 파일이 달라서 빈 토큰을 보내고 있었고, A-1 은 재시작마다 바뀌는 방향을 사람이 그때그때 적는 형태였다.
|
||||
|
||||
## 바꾼 다음 실제로 돌렸고, 거기서 한 건이 깨졌다
|
||||
|
||||
넷을 전부 셸 표현식으로 바꾸고 문법이 맞는지가 아니라 실제로 실행해서 확인했다. 돌려 보지 않고 「재현 가능하게 고쳤다」고 적는 것은 측정하지 않고 단언하는 일이라, 애초에 이 넷을 걸러 낸 점검이 잡아낸 실수를 그대로 되풀이하는 것이 된다. 그래서 실행 기록을 따로 남겼다. 셋은 그대로 돌았고 A-6 의 부하 생성기가 깨졌다.
|
||||
|
||||
깨진 이유는 표현식이 아니라 출력 경로였다. `kubectl run --rm -i` 로 띄운 일회성 파드는 명령이 끝나면 곧바로 지워지는데, 그 과정에서 stdout 이 유실돼 동시 20건의 결과가 일부만 도착하거나 아예 끊겼다. 두 번 시도해서 두 번 다 출력이 도착하지 않고 세션이 그대로 끊겼다. 셸은 오류를 내지 않고 종료 코드도 0 이므로, 도착한 줄 수를 세기 전에는 실패한 것으로 보이지 않는다.
|
||||
|
||||
같은 함정을 이 실험 시리즈에서 이미 한 번 겪었는데, 재현 절차를 실행 가능하게 고치면서 그 깨진 형태를 그대로 다시 써넣었다.
|
||||
|
||||
출력을 파드 밖으로 흘려보내는 대신 파드 안에 모으는 쪽으로 바꿨다. 상주 탐침 하나를 계속 띄워 두고 그 안에서 요청을 보내 결과를 파일에 쓴 뒤, 측정이 끝나고 파일을 통째로 회수했다.
|
||||
|
||||

|
||||
|
||||
두 화살표가 각각 다른 것을 걸러낸다. 첫 번째를 지나면 붙여넣어 실행할 수 있는 명령이 되고, 두 번째를 지나야 그 명령이 기대한 출력을 내는지 확인된다. 이 실험대에서 넷은 첫 번째를 다 지났고 한 건이 두 번째에서 멈췄다.
|
||||
|
||||
## 고친 장치로 다시 잰 값
|
||||
|
||||
바꾼 탐침으로 동시 20건을 다시 돌렸다. 20줄이 전부 도착했고 상태코드는 모두 200 이었다.
|
||||
|
||||
```text label="고친 부하 생성기로 다시 돌린 동시 20건과 직후의 커넥션 풀"
|
||||
1 200 1.911191
|
||||
1 200 19.053724
|
||||
1 200 22.228466
|
||||
1 200 22.230871
|
||||
agroal_blocking_time_max_milliseconds 20000.0
|
||||
agroal_max_used_count 19.0
|
||||
agroal_acquire_count_total 672.0
|
||||
```
|
||||
|
||||
가장 느린 요청이 22.230871초였고 이것이 A-6 이 보고한 22.2초다. 커넥션 획득 대기 최댓값은 20000.0ms 로 찍혔다. 일회성 파드로 재던 때에는 스무 줄 중 몇 줄이 도착했는지 셀 수 없었으므로, 같은 수치가 스무 건 전부에서 나온다는 것은 고친 장치로 돌린 다음에 확인됐다.
|
||||
|
||||
부하 직후에 함께 수집한 이벤트에는 `keycloak-1` 의 readiness 프로브가 503 과 타임아웃으로 실패한 기록이 남아 있다. 지연을 건 노드가 느려지다 로드밸런서에서 빠지는 구간이고, 응답 시간 20줄과 커넥션 풀 지표와 이 이벤트가 한 파일에 함께 들어 있다.
|
||||
|
||||
## 이 확인이 닿지 않은 곳
|
||||
|
||||
고친 명령을 다른 환경에서 돌려 보지 않았다. 이 실험대의 k3s 와 이 Keycloak 구성에서만 확인했고, 상주 탐침이 필요한 이유였던 일회성 파드의 출력 유실이 다른 런타임에서도 같은 모양으로 나타나는지는 재지 않았다.
|
||||
|
||||
넷 중 셋은 바꾼 표현식이 한 번에 돌았다는 것까지만 확인했다. A-3 과 A-8 과 A-1 의 측정값을 고친 명령으로 처음부터 다시 만들어 원래 수치와 맞춰 보지는 않았다.
|
||||
|
||||
실행 확인을 하면서 A-6 의 단일 요청 지연도 같이 쟀는데, 그것은 지연을 걸지 않은 평시 값이라 본문의 22.2초와 나란히 놓으면 안 된다. 거기서 확인한 것은 명령이 돈다는 것뿐이다.
|
||||
<!-- body:end -->
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nine-injections-that-silently-did-nothing
|
||||
title: 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn:
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다
|
||||
assets:
|
||||
- key: injection-verification
|
||||
file: ../../../final/assets/injection-verification/injection-verification.svg
|
||||
- key: a5-partition-asymmetry
|
||||
file: ../../../final/assets/a5-partition-asymmetry/a5-partition-asymmetry.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a1-jgroups-transport-block__05-conntrack-problem.txt
|
||||
- ../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt
|
||||
---
|
||||
|
||||
# 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다
|
||||
|
||||
주입 명령이 아홉 번 걸리지 않았고, 걸리지 않은 주입은 관측에서 영향이 없는 것과 구별되지 않았다. 아홉 건의 원인은 네트워크·시그널·권한·출력 경로로 매번 달랐다. 공통점은 주입과 결과 관측 사이에 대상 상태를 보는 단계가 없었다는 것 하나였다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
그 기준의 두 번째 규칙이 이 아홉 건에서 나왔다.
|
||||
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
|
||||
주입이 걸린 것을 확인한 다음에도 관측은 틀릴 수 있고, 그 다음 단계를 대조군이 맡는다.
|
||||
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
|
||||
주입이 제대로 걸린 A-2 에서도 이 지표는 1 이었다. 주입 확인과 지표 읽기는 서로를 대신하지 못한다.
|
||||
|
||||
## 문제
|
||||
|
||||
A층 여덟 실험은 예측을 먼저 적어 두고 주입한 뒤 관측하는 순서로 돌렸다. 주입 명령이 오류 없이 끝나면 걸린 것으로 보고 결과를 읽었다.
|
||||
|
||||
주입이 걸리지 않으면 관측에 아무 변화도 나타나지 않는다. 영향이 없어서 변화가 없는 경우와 같은 모습이므로, 이 실험대에서는 두 경우를 가를 방법이 없었다.
|
||||
|
||||
## 결론
|
||||
|
||||
조용히 실패한 주입 : 9건
|
||||
원인이 겹치는 건 : 없음. 아홉 건이 각각 다른 이유로 걸리지 않았다
|
||||
한 실험에서 세 번 실패한 곳 : A-5 비대칭 분단 (표의 4·5·6번)
|
||||
공통 원인 : 주입과 관측 사이에 대상 상태를 보는 단계가 없었다
|
||||
그 뒤 바꾼 것 : 주입한 다음 결과를 보기 전에 대상 상태를 따로 확인한다
|
||||
상태를 보는 수단 : cluster_size · 워커 PID · conntrack 표 · 패킷 카운터
|
||||
|
||||
## 검증 환경
|
||||
|
||||
실험대 : 베어메탈 한 대 위에 VM 두 대
|
||||
test-server : Arch Linux, 12GB, WiFi only
|
||||
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
|
||||
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
|
||||
게스트 sudo : 무암호
|
||||
호스트 sudo : 비밀번호 요구
|
||||
주입에 쓴 것 : NetworkPolicy · iptables · tc · conntrack · kubectl delete · kill
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 예측을 먼저 문서에 적는다. 무엇이 깨지고 무엇이 안 깨질지를 주입 전에 적어 둔다.
|
||||
|
||||
2. 주입 명령을 넣는다. NetworkPolicy 로 포트를 빼거나, iptables 규칙을 끼워 넣거나, tc 로 지연을 걸거나, 파드를 강제 종료한다.
|
||||
|
||||
3. 결과를 보기 전에 대상 상태를 조회한다.
|
||||
conntrack 표에 그 포트의 ESTABLISHED 항목이 남아 있는지, iptables 규칙의 패킷 카운터가 0 인지, tc 필터에 패킷이 걸렸는지를 본다.
|
||||
|
||||
4. 상태가 바뀌지 않았으면 주입이 걸리지 않은 것으로 보고 결과를 읽지 않는다.
|
||||
|
||||
5. 주입 방법을 바꾸고 3번을 다시 한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 여덟 실험이 같은 모양으로 돌았다
|
||||
|
||||
A층 실험은 예측을 먼저 문서에 적어 두고 주입한 뒤 관측하고 마지막에 그 예측과 대조하는 순서였다. 주입은 상태를 일부러 망가뜨리는 명령이다 — NetworkPolicy 에서 포트를 빼 통신을 막고, `tc` 로 지연을 걸고, 파드를 강제 종료하고, `iptables` 규칙을 앞에 끼워 넣는다.
|
||||
|
||||
이 순서에는 확인이 하나 빠져 있었다. 주입 명령이 오류 없이 끝나도 대상이 실제로 그 상태가 됐는지는 보지 않고 바로 결과를 읽었기 때문에, 걸리지 않은 주입은 관측에서 「아무 일도 없었다」로 나타났다. 영향이 없어서 아무 일도 없는 것과 화면에서 같아 보인다. 이 실험대가 시간을 가장 많이 쓴 곳이 여기다.
|
||||
|
||||
## 아홉 건이 각각 다른 이유로 걸리지 않았다
|
||||
|
||||
| # | 무엇을 했나 | 왜 안 먹었나 |
|
||||
|---|---|---|
|
||||
| 1 | NetworkPolicy 로 7800 차단 | conntrack — ESTABLISHED 연결은 규칙 평가를 건너뛴다. `cluster_size` 가 25분간 2 로 남았다 |
|
||||
| 2 | `kubectl delete --grace-period=0 --force` | 크래시가 아니다. 런타임이 SIGTERM 을 보내 PostgreSQL 이 정상 플러시했다 |
|
||||
| 3 | `kill -9 1` | PID 1 은 자기 네임스페이스의 SIGKILL 을 무시한다 |
|
||||
| 4 | `iptables -I FORWARD 1` | kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0) |
|
||||
| 5 | raw 규칙을 한쪽 노드에 | 방향이 뒤집혀 있었다. JGroups 의 client/server 역할은 재시작마다 바뀐다 |
|
||||
| 6 | `tc ... dev eth0` | Debian 은 `enp1s0` 이고, flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다 |
|
||||
| 7 | `spring.sql.init` 로 스키마 생성 | 기본 DDL 이 `blob` 인데 PostgreSQL 은 `bytea` 다. `continue-on-error: true` 가 삼켰다 |
|
||||
| 8 | 호스트에서 `sudo` | 비밀번호를 요구한다. 빈 출력이 곧 실패였다 |
|
||||
| 9 | `kubectl run --rm -i` 로 동시 20건 | 일회성 파드의 stdout 이 유실된다. 20줄 중 일부만 도착하거나 아예 끊긴다 |
|
||||
|
||||
아홉 건을 원인으로 묶어 보려 했지만 묶이지 않았다. 네트워크 계층이 명령을 다르게 해석한 것도 있고, 종료 신호가 크래시가 아니었던 것도 있고, 오류를 삼키는 설정과 권한과 출력 경로도 하나씩 있었다. 아홉 건에 같은 대책을 걸 수 없었다.
|
||||
|
||||
## 게스트에서는 되고 호스트에서는 되지 않던 것
|
||||
|
||||
kc-lab-1 과 kc-lab-2 는 무암호 `sudo` 라 `conntrack`·`tc`·`iptables` 를 그대로 썼지만 호스트인 test-server 는 비밀번호를 요구한다. 이 차이를 모르고 한동안 호스트의 nginx 설정을 읽으려 했는데 계속 빈 출력이 돌아왔고, 그 빈 출력은 명령이 낸 답이 아니라 `sudo` 가 비밀번호를 받지 못해 멈춘 결과였다. 하마터면 빈 로그를 「아무 일도 없음」으로 읽을 뻔했다.
|
||||
|
||||
호스트에서 해야 하는 일은 인증서 강제 갱신과 nginx reload 인데 둘 다 결국 사람이 직접 쳐야 했으므로, D-4 에서는 명령 한 줄을 헛되이 쓰지 않는 것이 설계의 일부가 됐다.
|
||||
|
||||
## 한 실험에서 세 번 — A-5 비대칭 분단
|
||||
|
||||
표의 4·5·6번은 모두 A-5 한 실험에서 나왔다. 세 번 모두 다른 이유였고, 셋 다 화면에서는 「아무 일도 없었다」로 보였다.
|
||||
|
||||
A-5 가 물은 것은 한쪽 방향만 막았을 때 클러스터가 갈라지는가였다. 한 방향만 막으면 JGroups 가 열린 방향으로 재연결하므로 클러스터가 갈라지지 않는다. 양방향을 다 막으면 갈라지기는 하는데 한쪽만 DOWN 이 되어서, 코디네이터 쪽이 살아남고 분단된 쪽은 스스로 로드밸런서에서 빠지며 서비스는 이어진다.
|
||||
|
||||

|
||||
|
||||
그래서 이 실험에서는 주입이 걸리지 않은 것과 비대칭 차단이 원래 클러스터를 가르지 못하는 것이 같은 관측으로 나온다. 세 번의 실패를 하나씩 분리하려면 규칙이 실제로 패킷을 잡았는지부터 따로 봐야 했다.
|
||||
|
||||
## conntrack 표와 패킷 카운터를 직접 조회했다
|
||||
|
||||
1번에서 무엇이 걸리지 않았는지는 conntrack 표를 조회해서 확정했다. conntrack 은 리눅스 커널이 진행 중인 연결을 기억해 두는 표이고, 여기 등록된 흐름의 패킷은 방화벽 규칙을 다시 평가하지 않고 통과한다. NetworkPolicy 를 적용한 뒤 두 노드에서 7800 흐름을 뽑았다.
|
||||
|
||||
```text label="NetworkPolicy 적용 후에도 7800 연결이 conntrack 에 살아 있다"
|
||||
--- kc-lab-1 ---
|
||||
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
|
||||
--- kc-lab-2 ---
|
||||
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1
|
||||
```
|
||||
|
||||
두 노드 모두 7800 으로 가는 ESTABLISHED 항목을 들고 있었다. 쿠버네티스가 넣어 둔 FORWARD 규칙은 전부 `NEW` 상태에만 걸려 있어서, 이미 성립한 연결의 패킷은 NetworkPolicy 평가에 도달하지도 않는다. 규칙이 안 걸린 것이 아니라 규칙은 정확히 걸렸고 패킷이 그 앞에서 지나갔다. `nf_conntrack_tcp_timeout_established` 는 86400 이고 오가는 패킷이 있으면 타이머가 계속 갱신되므로, JGroups 처럼 주기적으로 통신하는 연결은 사실상 영원히 표에 남는다. `cluster_size` 가 25분간 2 로 남은 것은 이상한 일이 아니라 정상 동작이었다.
|
||||
|
||||
항목을 지우는 것도 한 번에 되지 않았다. 두 노드에서 7800 흐름을 지웠는데 돌아온 것은 `0 flow entries have been deleted` 였고 삭제 뒤에도 항목은 두 건씩 남아 있었으며 `cluster_size` 도 2 그대로였다. 클러스터가 실제로 갈라진 것은 정책이 걸린 상태에서 파드를 재시작했을 때다. 처음 쓴 A-1 기록은 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸고, 증거와 대조하면서 그 귀속을 고쳤다.
|
||||
|
||||
6번은 인터페이스를 바꿔 우회했다. Debian 게스트의 물리 인터페이스는 `enp1s0` 이고 flannel VXLAN 이 파드 트래픽을 이미 캡슐화하므로 거기서는 파드 IP 가 보이지 않는다. 캡슐화 전 구간인 `flannel.1` 에 같은 `netem` 을 걸고, 결과를 읽기 전에 필터에 패킷이 걸렸는지를 카운터로 확인했다.
|
||||
|
||||
```text label="flannel.1 에 건 netem 필터의 패킷 카운터와 두 노드 응답 시간"
|
||||
=== [검증] 필터에 패킷이 걸리는가 ===
|
||||
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
|
||||
Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
|
||||
|
||||
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
|
||||
keycloak-0 평균 41 ms 최대 57 ms
|
||||
keycloak-1 평균 1872 ms 최대 1887 ms
|
||||
```
|
||||
|
||||
카운터가 150 패킷으로 올라간 것을 보고 나서 응답 시간을 읽었다. 지연을 걸기 전 두 노드의 로그인 응답은 70ms 와 66ms 였는데, 주입한 지연이 200ms 인데도 `keycloak-1` 의 평균 응답은 1872ms 로 나왔다. 지연을 걸지 않은 `keycloak-0` 은 41ms 였다.
|
||||
|
||||
## 주입한 다음, 결과가 아니라 상태를 본다
|
||||
|
||||
아홉 건은 원인이 제각각이었고 공통점은 한 곳이었다. 주입 명령과 결과 관측 사이에 대상이 실제로 그 상태인지 보는 단계가 없었다. 그래서 그 단계를 넣고 이후 모든 실험에 적용했다. 확인하는 대상은 결과가 아니라 상태다 — `cluster_size`, 워커 PID, conntrack 표, 패킷 카운터.
|
||||
|
||||

|
||||
|
||||
가운데 단계를 건너뛰면 걸리지 않은 주입과 영향이 없는 주입을 결과만 보고 가를 수 없다.
|
||||
|
||||
## 이 아홉 건에서 확인하지 않은 것
|
||||
|
||||
아홉 건을 모두 고친 뒤 처음부터 다시 돌리지는 않았다. 일부는 원래 방법을 고치지 않고 다른 주입 방법으로 우회했다 — 6번은 `eth0` 대신 `flannel.1` 에 걸었고, 9번은 일회성 파드 대신 상주 탐침으로 바꿨다. 원래 방법이 왜 안 걸렸는지까지만 확정했고, 그 방법 자체를 동작하게 만든 것은 아니다.
|
||||
<!-- body:end -->
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: seventy-six-failures-that-were-not-the-servers
|
||||
title: 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는
|
||||
assets:
|
||||
- key: measurement-control
|
||||
file: ../../../final/assets/measurement-control/measurement-control.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt
|
||||
- ../../../final/evidence/raw/d4-certificate-renewal__11-inflight-full.txt
|
||||
---
|
||||
|
||||
# 실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다
|
||||
|
||||
nginx reload 중 감시 로그에 찍힌 실패 76건은 서버에 닿지 않은 요청이었다. 연결수가 0 이고 소요 시간이 50µs 였다. 같은 순간 대조 폴링 49건은 전부 200 이었고, 같은 조건을 100번 반복해도 재현되지 않았다. 주입 전에 대조군 900건을 잡아 두지 않았다면 「갱신 중 대규모 요청 실패」로 적었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
그 기준의 세 번째 규칙을 이 확인이 실제로 써 본 결과다.
|
||||
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
|
||||
이 실험대에서 한 지표만 보고 판정한 다른 경우이고, 방향이 반대다. 그쪽은 이상을 못 봤고 이쪽은 없는 이상을 봤다.
|
||||
- **두 시계에서 온 값을 빼지 않는다**
|
||||
같은 D-4 에서 나온 다른 측정 결함이다. 감시를 돌린 기계와 실험대의 시계가 106초 달랐다.
|
||||
|
||||
## 문제
|
||||
|
||||
인증서 갱신 중 nginx reload 가 진행 중이던 요청을 끊는지 확인해야 했다. 새 연결만 폴링하면 TLS 핸드셰이크가 매번 새로 일어나서 「새 연결을 받아주는가」만 재게 되므로, reload 순간에 실제로 전송 중인 요청을 따로 만들어 감시했다.
|
||||
|
||||
그 감시 로그에 실패가 76건 남았다. 이 값만 보면 reload 가 진행 중 요청을 대량으로 끊은 것으로 읽힌다.
|
||||
|
||||
## 결론
|
||||
|
||||
감시 로그의 실패 : 76건
|
||||
같은 순간 대조 폴링 : 49건 전부 200
|
||||
실패한 요청의 연결수 : 0
|
||||
실패한 요청의 소요 시간 : 50µs
|
||||
같은 조건 재현 : 0/100
|
||||
주입 전 대조군 : 900건 전부 200, 오류 0
|
||||
서버 쪽 원인으로 귀속 : x
|
||||
|
||||
TCP 연결 시도조차 없었고 소요 시간이 DNS 조회보다 짧았으므로 요청이 서버에 닿지 않았다. 같은 순간 다른 경로로 들어간 요청은 전부 200 을 받았다. 훅을 넣고 다시 검증한 D-4a 에서 reload 자체는 새 연결 8856건 전부 200 이었고, 전송 12초째에 reload 를 맞은 42초짜리 요청은 845361바이트를 온전히 받았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
실험 : D-4 인증서 갱신 중 nginx reload
|
||||
호스트 TLS 종단 : nginx, Let's Encrypt 인증서
|
||||
주입 전 대조군 : 0.2초 간격 900회 = 180초
|
||||
대조군 응답시간 : 최소 67 중앙 98 p95 195 최대 1121 평균 106.9 (ms)
|
||||
진행 중 요청 : 845KB 번들을 20k/s 로 받아 한 요청을 42초 동안 유지
|
||||
관측 지점 : 외부 curl · Prometheus 지표 · PostgreSQL 직접 조회
|
||||
시계 : test-server 는 NTP 가 꺼져 있어 106초 빠르다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 주입 전에 평시 오류율을 잰다.
|
||||
0.2초 간격으로 900회 폴링하고 상태코드 분포와 응답시간, TLS 핸드셰이크 횟수를 기록한다.
|
||||
|
||||
2. reload 순간에 전송 중인 요청이 있도록 만든다.
|
||||
845KB 번들을 20k/s 로 내려받아 요청 하나를 42초 동안 유지한다.
|
||||
|
||||
3. 새 연결 폴링과 진행 중 요청 감시를 각각 다른 프로세스로 띄우고 같은 시간대에 돌린다.
|
||||
|
||||
4. 인증서를 강제 갱신하고 nginx 를 reload 한다.
|
||||
|
||||
5. 실패가 나오면 그 순간의 대조 폴링 결과, 연결수, 소요 시간을 함께 읽는다.
|
||||
|
||||
6. 같은 조건을 100회 반복해 재현되는지 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 주입 전에 평시를 먼저 쟀다
|
||||
|
||||
D-4 의 물음은 nginx reload 중 진행 중이던 요청이 어떻게 되는가였다. 주입에 쓸 강제 갱신은 진짜 인증서를 발급하는 명령이라 되돌릴 수 없고 Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다. 그래서 이 실험 전체에서 한 번만 쓰기로 했고, 그 한 번을 헛되게 쓰지 않으려면 잴 것을 주입 전에 전부 재 둬야 했다. 답하기 전에 주입 없는 상태의 오류율부터 잡은 것이 그래서다 — 0.2초 간격으로 900회, 180초 동안 폴링해서 900건 전부 200 이고 오류가 0 이라는 것을 확인했다. 중앙값은 98ms, p95 는 195ms 였다.
|
||||
|
||||
이 폴링에는 한계가 하나 있었다. TLS 핸드셰이크가 900회 전부 일어나서 매 요청이 새 연결이었고, 그래서 이 폴링이 재는 것은 「새 연결을 받아주는가」다. 계획서가 물은 것은 진행 중이던 요청이므로 reload 순간에 실제로 전송 중인 요청이 있어야 했다. 845KB 짜리 번들을 20k/s 로 일부러 느리게 받아 요청 하나를 42초 동안 유지하는 감시를 따로 띄웠다.
|
||||
|
||||
## 76건이 한 초에 몰려 찍혔다
|
||||
|
||||
감시 로그는 146줄이었고 그중 76줄이 `코드=000` 이었다. 나머지 70줄은 `코드=200` 에 `바이트=845361` 이었다.
|
||||
|
||||
```text label="진행 중 요청 감시 — 76줄이 같은 초에 찍혔다"
|
||||
08:14:22 코드=200 바이트=845361 시간=42.338482 연결수=1
|
||||
08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0
|
||||
08:15:04 코드=000 바이트=0 시간=0.000051 연결수=0
|
||||
08:15:04 코드=000 바이트=0 시간=0.000056 연결수=0
|
||||
08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1
|
||||
```
|
||||
|
||||
76줄의 시각이 전부 `08:15:04` 로 같고, 같은 초의 다음 줄은 다시 `코드=200` 에 42.236496초다. 42초씩 걸리던 요청이 한 초 안에 76번 끝났다. 실패가 즉시 돌아오니 다음 요청을 바로 띄우는 감시 루프가 그 1초 동안 폭주했다.
|
||||
|
||||
## 76건을 서버 탓으로 적지 않은 근거 네 가지
|
||||
|
||||
76건을 reload 탓으로 적기 전에 네 가지를 봤다.
|
||||
|
||||
| 근거 | 값 |
|
||||
|---|---|
|
||||
| 같은 순간 폴링 | 49건 전부 200 |
|
||||
| 연결수 | 0 — TCP 연결 시도조차 못 했다 |
|
||||
| 소요 시간 | 50µs — DNS 조회보다 짧다 |
|
||||
| 재현 | 0/100 |
|
||||
|
||||
연결수가 0 이므로 서버까지 패킷이 가지 않았다. 소요 시간 50µs 는 이름 풀이 한 번보다 짧아서 요청이 네트워크로 나간 시간이 아니다. 같은 순간 다른 프로세스가 보낸 49건은 전부 200 을 받았으므로 그 시각에 서버는 요청을 처리하고 있었다. 같은 조건으로 100번 더 돌려도 한 번도 다시 나오지 않았다.
|
||||
|
||||
## 대조군이 없었다면 무엇이 됐나
|
||||
|
||||

|
||||
|
||||
같은 관측이 두 갈래로 갈린다. 평시 오류율을 아는 쪽에서는 76건을 대조군 900건과 견주어 서버 바깥 원인으로 돌릴 수 있고, 모르는 쪽에서는 「갱신 중 대규모 요청 실패」로 적게 된다.
|
||||
|
||||
이 실험대에서 대조군 규칙은 이미 두 번 어겨졌다. A-6 에서는 대조군이 −41% 인데도 「영향 없음」이라고 적었고, A-8 에서는 표본 9개로 무중단을 주장했다. 둘 다 나중에 고쳤다. 세 번째가 이 76건인데, 이번에는 대조군을 먼저 잡아 둔 덕분에 기록에 들어가기 전에 걸렸다.
|
||||
|
||||
## 같은 대조를 문서 전체에 돌렸다
|
||||
|
||||
기록을 다 쓴 뒤 본문의 주장과 증거 파일을 하나씩 맞춰 봤다. 어긋난 곳이 여섯 군데 나왔다.
|
||||
|
||||
| 어디 | 무엇이 어긋났나 |
|
||||
|---|---|
|
||||
| C-1 | 본문은 「세션 0」인데 증거는 4 |
|
||||
| C-2 | `exit code 1` 인 명령의 성공 읽기를 실었다 |
|
||||
| A-1 | 4초 전 파드 재시작이 만든 분단을 conntrack 공으로 돌렸다 |
|
||||
| A-2 | 첫 측정의 `000000{"error":"HTTP 401"}401` 을 숨겼다 |
|
||||
| A-3 | `wal_writer_delay` 를 재지 않고 단언했다 (실측 200ms, 로그인율도 19/s 가 아니라 14/s) |
|
||||
| D-1 | 본문은 RTO 30초, 자기 타임라인은 41초 |
|
||||
|
||||
전부 고치면서 무엇이 어긋났는지를 표로 남겼다. 76건과 이 여섯 건은 같은 방향으로 틀린다 — 숫자 하나를 그 숫자가 나온 조건과 떼어 놓고 읽으면 본문이 자기 증거와 다른 말을 하게 된다.
|
||||
|
||||
## 이 확인이 말하지 않는 것
|
||||
|
||||
76건이 서버 탓이 아니라는 것까지가 이 확인의 범위다. 감시를 돌린 쪽에서 무엇이 그 한 초 동안 76번 즉시 실패했는지는 특정하지 못했다. 감시를 `-s` 로 돌려 curl 의 오류 메시지를 버렸고 종료 코드도 남기지 않았기 때문이고, 재현이 0/100 이라 같은 조건을 다시 만들어 좁힐 수도 없었다. 장치가 왜 실패했는지를 남기지 않은 탓에, 이 76건을 서버 밖으로 돌린 근거는 같은 시각 대조 폴링이 멀쩡했다는 사실 하나였다.
|
||||
|
||||
그래서 감시 자체를 바꿨다. curl 의 종료 코드까지 함께 적고 실패하면 1초 쉬어 루프 폭주를 막는 형태로 갈아끼웠고, 같은 로그의 뒷부분에는 그 뒤로 `curl종료=0` 이 줄마다 붙어 있다. 다음에 같은 일이 생기면 6이 이름 풀이, 7이 연결, 35가 TLS 라는 식으로 종료 코드가 바로 답한다.
|
||||
<!-- body:end -->
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: the-guides-broke-at-the-first-command
|
||||
title: 가이드 26편을 순서대로 따라가니 첫 명령부터 막혔다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-11
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#재현-가이드-26편과-그것을-따라가다-드러난-결함
|
||||
---
|
||||
|
||||
# 가이드 26편을 순서대로 따라가니 첫 명령부터 막혔다
|
||||
|
||||
기반 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다. 2026-09-11 에 가이드 26편을 순서대로 따라가 보니 sudo kubectl 905건 가운데 게스트에서 돌아야 하는 명령은 0건이었다. sudo 는 root 환경이라 lab host 의 ~/.kube/config 를 못 본다. 명령 자체가 아니라 그 명령이 놓인 위치가 틀렸다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
가이드마다 주입 검증 단계를 따로 둔 근거가 이 아홉 건이다.
|
||||
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
|
||||
그 기록은 명령이 산문이라 돌지 않았고, 이 기록은 명령이 도는데 놓인 위치가 틀렸다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
가이드의 다섯 단계 가운데 주입 검증이 이 규칙을 절차로 옮긴 단계다.
|
||||
|
||||
## 문제
|
||||
|
||||
발견을 적은 문서와 별개로, 직접 쳐서 다시 만드는 가이드가 26편 있다. 각 편은 같은 다섯 단계로 적혀 있고 그중 주입 검증이 핵심인 편이 많다. 이 실험대에서 주입은 아홉 번 조용히 실패했고, 실패한 주입은 아무 일도 없었던 것처럼 보여서 영향이 없는 주입과 구별되지 않기 때문이다.
|
||||
|
||||
가이드를 쓴 다음 그것을 처음부터 순서대로 따라가 본 적은 없었다. 2026-09-11 에 실제로 쳐 보니 기반 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다.
|
||||
|
||||
## 결론
|
||||
|
||||
2026-09-11 감사에서 재현을 막는 결함이 계열로 나왔다.
|
||||
|
||||
sudo kubectl 을 쓰는 명령 : 905건
|
||||
그중 게스트에서 도는 것 : 0건
|
||||
저장소 클론 단계가 없는 편 : 05·06
|
||||
그 단계에 없는 리소스를 조회하는 편 : 05
|
||||
|
||||
kubeconfig 는 lab host 의 ~/.kube/config 에 있고 sudo 는 root 환경이라 그 파일을 못 본다. 그래서 기반 7단계를 끝낸 독자가 첫 명령부터 막힌다. 905건 가운데 게스트에서 돌아야 해서 sudo 가 맞는 것은 0건이었고 전부 잘못된 것이었다.
|
||||
|
||||
개별 명령은 전부 실제로 돌았던 것이라 명령만 읽어서는 틀려 보이지 않는다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
감사 대상 : 재현 가이드 26편
|
||||
가이드 위치 : ../source/docs/guides/experiments/
|
||||
kubeconfig 위치 : lab host 의 ~/.kube/config
|
||||
감사일 : 2026-09-11
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 기반 가이드 7단계를 끝낸 상태에서 실험 가이드를 첫 편부터 순서대로 따라간다.
|
||||
|
||||
2. 각 편의 명령을 읽지 말고 실제로 쳐 보고, 막힌 곳과 막힌 이유를 적는다.
|
||||
|
||||
3. 26편에서 sudo kubectl 로 적힌 명령이 몇 건인지 세고, 그중 게스트에서 돌아야 하는 것이 몇 건인지 가른다.
|
||||
|
||||
4. 05·06 편의 kubectl apply -f deploy/... 가 가리키는 저장소가 lab host 에 있는지, 그 앞 단계에 클론이 있는지 확인한다.
|
||||
|
||||
5. 05 편의 -l app=bff 가 조회하는 리소스가 그 단계에 떠 있는지, 함께 실린 출력이 그 단계에서 나온 것인지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
실험 26건의 결과를 적은 문서와 별개로, 같은 실험을 직접 쳐서 다시 만드는 가이드가 26편 있다. 실험 26건을 다 끝낸 뒤 2026-09-11 에 그 26편을 처음부터 순서대로 따라가며 감사했다. 따라가는 동안 실험 계획서에 미해결로 남아 있던 항목 하나가 풀렸고, 재현을 막는 결함도 그때 나왔다 — 기반 가이드 7단계를 끝낸 독자가 실험 가이드의 첫 명령에서 막혔다. 막힌 곳은 네 군데였고, 한 편에서만 나온 실수가 아니라 계열로 나왔다.
|
||||
|
||||
## 가이드 한 편은 다섯 단계로 적혀 있다
|
||||
|
||||
각 편이 따르는 순서는 같다.
|
||||
|
||||
```text label="가이드 한 편의 구조"
|
||||
기준선 → 주입 → 주입 검증 → 관찰 → 복구
|
||||
```
|
||||
|
||||
다섯 단계 가운데 주입 검증이 핵심인 편이 많다. 이 실험대에서 주입은 아홉 번 조용히 실패했는데, 실패한 주입은 아무 일도 없었던 것처럼 보여서 영향이 없는 주입과 구별되지 않는다. 그래서 주입한 다음 그것이 실제로 걸렸는지 확인하는 단계를 따로 둔다.
|
||||
|
||||
## sudo 가 kubeconfig 를 못 본다
|
||||
|
||||
실험 가이드는 26편 전체가 `sudo kubectl` 로 적혀 있고, 세어 보니 905건이었다. 그런데 기반 가이드는 kubeconfig 를 lab host 의 `~/.kube/config` 에 두고, `sudo` 는 root 환경이라 그 파일을 못 본다. 그래서 기반 7단계를 끝내고 실험 가이드로 넘어온 독자는 첫 명령에서 바로 막힌다.
|
||||
|
||||
905건 가운데 게스트(lab host 위에서 도는 VM)에서 돌아야 해서 `sudo` 가 맞는 것은 0건이었다. 905건 전부 잘못 적힌 명령이었다. 이 905건과 0건은 2026-09-11 에 26편을 한 번 따라가며 센 값이고, 그 뒤 다시 세지 않았다.
|
||||
|
||||
## 05·06 편은 아직 없는 것을 가리킨다
|
||||
|
||||
05 편과 06 편은 `kubectl apply -f deploy/...` 를 쓴다. 이 경로는 상대경로인데 기반 가이드에는 저장소를 lab host 에 클론하는 단계가 없어서, 독자의 lab host 에는 그 경로가 가리킬 파일이 없다.
|
||||
|
||||
05 편에는 `-l app=bff` 로 리소스를 조회하는 명령도 있다. BFF(Backend For Frontend) 는 B-0 실험에서 처음 뜨므로 05 편 시점에는 조회할 리소스가 없고, 가이드에 실린 출력도 나중 단계에서 복사해 온 것이었다.
|
||||
|
||||
## 왜 명령만 읽어서는 틀린 곳이 안 보이나
|
||||
|
||||
막힌 네 곳을 나란히 놓으면 이렇다.
|
||||
|
||||
| 무엇이 재현을 막나 | 몇 건인가 |
|
||||
|---|---|
|
||||
| 실험 가이드 전편이 `sudo kubectl` 을 쓴다 | 905건 |
|
||||
| 그중 게스트에서 도는 것 | 0건 |
|
||||
| 기반 가이드가 저장소를 lab host 에 클론하지 않는다 | 05·06 |
|
||||
| 그 단계에 아직 없는 리소스를 조회한다 | 05 |
|
||||
|
||||
네 결함의 원인은 하나로 모인다. 가이드를 쓰면서 순서대로 따라갔을 때 도는지를 검증하지 않고, 나중 시점의 환경에서 확인한 명령과 출력을 그 단계에 적었다.
|
||||
|
||||
개별 명령은 전부 실제로 돌았던 것이라 틀려 보이지 않는다. `-l app=bff` 의 출력도 지어낸 값이 아니라 나중 단계에서 실제로 나온 값을 옮겨 적은 것이었다. 틀린 것은 명령이 아니라 그 명령이 놓인 위치다.
|
||||
|
||||
## 이 감사가 확인하지 않은 것
|
||||
|
||||
고친 가이드를 처음부터 다시 따라가 끝까지 도는지는 확인하지 않았다. 905건과 0건도 2026-09-11 감사 한 번에서 나온 계수다.
|
||||
|
||||
이 감사의 결론을 「가이드를 쓴 뒤 처음부터 순서대로 한 번 따라간다」는 규칙으로 세우려면 어떤 가이드에 적용되고 어떤 가이드에는 적용되지 않는지를 대야 하는데, 그 적용 조건도 예외도 이 26편에서는 나오지 않았다.
|
||||
<!-- body:end -->
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: the-up-metric-cannot-see-alive-but-useless
|
||||
title: up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
basisVersion: Prometheus up 지표 · Keycloak 26.7.0 /metrics · 이 실험대의 스크레이프 대상
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-관측을-어디에
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-관측-도구는-부분집합
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a2-database-loss__04-health-and-service.txt
|
||||
- ../../../final/evidence/raw/a2-database-loss__05-recovery.txt
|
||||
---
|
||||
|
||||
# up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다
|
||||
|
||||
A-2 에서 Keycloak 두 파드의 up 은 1 이었고 같은 실행의 외부 진입점은 503 이었다. up 은 Prometheus 가 /metrics 를 긁는 데 성공했는지만 말하므로, 프로세스가 살아서 그 엔드포인트를 돌려주면 DB 커넥션이 전부 끊겨도 1 이 된다. 이 어긋남을 이 실험대가 만난 것은 A-2 한 번이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
|
||||
A-2 에서 readiness 는 두 파드를 다 DOWN 으로 보고했고 up 은 같은 순간에 둘 다 1 이었다. 두 신호가 같은 장애를 반대로 답했다.
|
||||
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
|
||||
지표 하나만 보고 귀속했을 때 반대 방향으로 틀린 경우다. 그쪽은 서버 탓이 아닌 실패를 서버 탓으로 셀 뻔했고, 이쪽은 서버가 못 쓰는 상태를 정상으로 셌다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
원본 가이드는 이 줄의 예측 칸을 비워 두고 「관측의 함정」이라고 적었다. 미리 적어 둔 예측이 빗나간 것이 아니라, 예측한 적 없이 튀어나온 관측이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Prometheus 가 스스로 붙이는 값
|
||||
|
||||
Prometheus 는 대상이 지표를 보내오기를 기다리지 않고 자기가 긁어 온다. 정해 둔 주소로 주기적으로 요청을 보내고 그 요청이 성공했는지를 스스로 만들어 붙이는 합성 지표가 `up` 이고, 대상이 응답하면 1 이고 못 하면 0 이 된다. 그래서 이 값이 답하는 물음은 하나로 좁다 — 방금 긁으러 간 대상이 지표를 돌려줬는가.
|
||||
|
||||
Keycloak 을 긁는 구성에서는 `/metrics` 엔드포인트 하나가 그 대상이다. 프로세스가 살아 있고 그 엔드포인트가 응답하기만 하면 1 이 되므로, 로그인이 되는지도 토큰이 발급되는지도 DB 커넥션이 살아 있는지도 이 값은 재지 않는다.
|
||||
|
||||
## A-2 에서 1 과 503 이 함께 나왔다
|
||||
|
||||
A-2 는 Keycloak 이 쓰는 PostgreSQL 을 정지시키고 무엇이 깨지는지 보는 실험이었다. DB 가 사라지자 두 파드의 Ready 는 모두 false 가 됐다. `health/ready` 는 네 항목 가운데 `Keycloak database connections async health check` 하나만 DOWN 인 채로 전체 DOWN 을 돌려줬고, Service 엔드포인트에서도 둘 다 notReady 로 빠졌다. 밖에서 `https://auth.hyeonworks.com/realms/master` 를 찍으면 `HTTP 503` 이었다.
|
||||
|
||||
같은 실행에서 Prometheus 에 `up` 을 물은 결과는 이랬다.
|
||||
|
||||
```text
|
||||
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
|
||||
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
|
||||
```
|
||||
|
||||
파드는 재시작하지 않았다 — 복구까지 세어도 두 파드의 재시작 횟수가 0 이다. Keycloak 프로세스가 계속 떠서 `/metrics` 를 돌려주는 동안 `up` 은 1 을 유지했고, 그 1 은 커넥션 풀이 PostgreSQL 에 닿지 못한다는 것과 무관하다. Grafana 에서 `up{job="keycloak"}` 을 그려 보면 장애 구간이 평평하고, 그 그래프에 하나 있는 골은 A-1 에서 파드를 교체한 자국이다.
|
||||
|
||||
그래도 `up` 이 잡아 주는 것이 하나 있다. 대상이 사라지면 스크레이프가 실패해 0 이 되므로 Prometheus 가 대상을 잃은 것은 이 값으로 알 수 있고, 대상이 살아서 못 쓰는 상태만 1 과 구별되지 않는다. A-2 의 장애를 드러낸 신호는 파드의 `Ready` 가 false 인 것과 외부 응답 코드 503 이었다.
|
||||
|
||||
A-0 은 `up` 을 「가장 중요한 합성 지표」라고 적었고, A-2 의 가이드가 그 문장을 「절반만 맞다」고 정정했다. 맞는 절반이 대상이 사라지는 쪽이다. 노드의 전원을 뽑은 A-4 에서는 `kc-lab-2` 쪽이 전부 0 이었다 — `keycloak-0` 과 `node-exporter`, 그리고 노드마다 하나씩인 `kubelet` 두 줄 중 하나다. 가이드는 못 잡는 쪽이 운영에서 훨씬 흔하다고 덧붙였다.
|
||||
|
||||
## 이 실험대가 긁은 대상과 긁지 않은 대상
|
||||
|
||||
`up` 이 붙는 대상은 Prometheus 가 긁도록 설정해 둔 대상뿐이다. 이 실험대가 긁은 것은 keycloak·kubelet·node-exporter·prometheus 넷이고 Redis 와 BFF 와 PostgreSQL 은 대상에 없다. 그래서 B층 실험 대부분에 Grafana 스크린샷이 없다. 안 찍어서가 아니라 그 대상의 지표가 처음부터 없었기 때문이다. 이 실험대는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적어 두었다.
|
||||
|
||||
지금 무엇이 대상인지는 Prometheus 에 직접 묻는다.
|
||||
|
||||
```bash
|
||||
curl -s localhost:19090/api/v1/targets | jq -r '.data.activeTargets[].labels.job' | sort -u
|
||||
```
|
||||
|
||||
## 기능 지표를 함께 보기로 한 이유
|
||||
|
||||
관측 지점을 여럿 둔 것은 A-2 보다 앞이다. 처음에는 밖에서만 쟀는데 A-1 에서 그 방식이 무너졌다 — 7800 을 끊었는데도 외부 응답이 전부 200 이었고, 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이다. 그래서 외부 `curl` 과 Prometheus 지표와 PostgreSQL 직접 조회 셋으로 늘렸고, `up` 을 그대로 믿을 수 없다는 것도 같은 곳에서 나왔다.
|
||||
|
||||
`up == 0` 만 경보 조건으로 걸면 A-2 같은 장애는 경보를 만들지 않는다. 그 장애 동안 두 파드의 `up` 은 1 이었기 때문이다. 그래서 이 실험대는 `up` 과 함께 로그인 성공률이나 에러율 같은 기능 지표를 보기로 적어 두었다. 경보를 건다면 `up` 이 아니라 readiness 와 외부 응답 코드에 건다고도 적었다. 손으로 확인할 때는 두 값을 나란히 찍는다.
|
||||
|
||||
```bash
|
||||
curl -s 'localhost:19090/api/v1/query?query=up' | jq '.data.result[].value'
|
||||
# up 만 보지 말고 기능 지표를 함께 본다
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/realms/master
|
||||
```
|
||||
|
||||
readiness 쪽은 이 실험대에서 지표로 물을 수 없었다. A-2 에서 `kube_pod_status_ready` 를 질의하자 결과가 빈 배열로 돌아왔는데, `kube-state-metrics` 가 없어 파드 readiness 가 지표로 남지 않기 때문이다. Prometheus 만 보고 있으면 이 장애는 드러나지 않는다. 관측 스택에 빠진 것을 이 실험이 찾아냈다. 가이드는 그것을 보완 항목으로 적고 지금은 `kubectl` 로 본다고 덧붙였다. B층의 관측 공백을 정리한 표에도 같은 항목이 「A-2 에서 이미 찾은 항목」으로 다시 적혀 있다.
|
||||
|
||||
## 지금 확인한 범위
|
||||
|
||||
`up` 이 1 인 채로 서비스가 503 이던 것을 이 실험대가 만난 것은 A-2 한 번이고, 「살아 있지만 쓸모없는 상태를 못 본다」는 그 한 번을 읽은 결론이다. 다른 장애 유형에서 같은 어긋남을 다시 본 적은 없다.
|
||||
|
||||
다만 두 값이 한 명령의 출력에 나란히 찍히지는 않았다. `up` 의 1 은 `a2-database-loss__05-recovery.txt` 에, 외부 `HTTP 503` 은 `a2-database-loss__04-health-and-service.txt` 에 있고, 두 캡처는 같은 실행에서 모은 것이라 실행 시각과 리비전이 같다. 원문에 붙은 「1 인데 서비스는 503 이다」도 도구가 찍은 줄이 아니라 실험을 돌린 사람이 그 줄에 덧붙인 주석이다.
|
||||
|
||||
`up` 이 1 이 되는 조건은 Prometheus 가 원래 그렇게 동작한다는 설명이고, 이 실험대가 스크레이프 요청과 `/metrics` 응답을 함께 찍어 그 조건을 확인한 캡처는 없다. 기능 지표를 함께 거는 경보를 실제로 만들어 A-2 를 다시 잡아 본 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: never-subtract-values-from-two-clocks
|
||||
title: 두 시계에서 온 값을 빼지 않는다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
source:
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-두-시계
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/d4a-deploy-hook__01-hook-verified.txt
|
||||
- ../../../final/evidence/raw/followup__03-b4-role-propagation.txt
|
||||
---
|
||||
|
||||
# 두 시계에서 온 값을 빼지 않는다
|
||||
|
||||
서로 다른 기계에서 온 타임스탬프는 빼기 전에 두 시계가 같은지 확인한다. D-4a 에서 test-server 는 dev 머신보다 106초 빨랐다. 보정하지 않고 계산한 D-4 의 공백은 106초 짧았고(2199 → 2305초), 1~2초를 재는 D-4a 에서는 그냥 뺀 값이 107초로 나와 참값보다 약 106초 어긋났다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
|
||||
같은 D-4 의 값이다. 그 실험이 적어 둔 공백 2199초가 이 보정으로 2305초가 됐다.
|
||||
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
|
||||
측정값이 아니라 재는 쪽이 틀린 다른 경우다. 거기서는 적어 둔 명령이 실행되지 않았고, 여기서는 실행된 명령이 남긴 시각이 어긋나 있었다.
|
||||
|
||||
## 목적
|
||||
|
||||
보정하지 않고 뺀 값이 자릿수만 어긋난다고 읽는 것을 막는다.
|
||||
|
||||
D-4a 에서 갱신부터 서빙까지 1~2초를 재려다 걸렸다. test-server 는 NTP 동기가 꺼져 있고 dev 머신보다 106초 빠르다. 그 사실을 적지 않고 뺀 D-4 의 공백은 2199초였는데 보정하면 2305초, 38분 25초다. 틀린 값이 106초 짧게 나왔다.
|
||||
|
||||
같은 왜곡이 D-4a 에서는 결과를 통째로 바꾼다. 그냥 빼면 훅이 발급보다 107초 뒤로 보이는데 참값은 1~2초라 약 106초가 어긋나고, 보정을 반대쪽에 걸면 음수가 나와 훅이 갱신보다 먼저 돈 것이 된다. 자릿수만이 아니라 두 사건의 선후까지 뒤집힌다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 빼기 전에 두 값이 같은 시계에서 왔는지 확인한다
|
||||
|
||||
D-4 의 2199초는 archive/cert2.pem 의 mtime 과 일련번호 관측 시각을 그대로 뺀 값이다. 앞은 test-server 시계이고 뒤는 dev 시계다. 양쪽 다 UTC 로 적혀 있어 뺄 때는 같은 자에서 읽은 것처럼 보인다. 갈리는 단위는 시간대가 아니라 기계다.
|
||||
|
||||
차를 셸이 대신 빼게 하지 않는다. 두 값을 각각 찍어 놓고 눈으로 빼면 어느 값이 어느 기계에서 왔는지가 출력에 남지만, 명령 안에서 빼 버리면 결과 한 줄만 남고 그 정보가 사라진다.
|
||||
|
||||
D-4 의 가이드에는 이 편에만 있는 시각 표기 규약이 하나 있다. 시각마다 어느 시계인지를 붙여 `08:58:52 (dev)` 와 `17:22:13 KST (ts)` 로 적고, 보정한 값은 `08:20:27 (실제)` 로 적는다. 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀린 뒤에 붙은 규약이다.
|
||||
|
||||
### 2. 어느 시계가 밀렸는지는 제3의 기준으로 가른다
|
||||
|
||||
dev 머신은 Google 및 Let's Encrypt ACME 응답과 0초 차였고, test-server 는 Google 기준으로 -105초였다. 두 기계만 맞대면 차이만 나오고 어느 쪽이 맞는지는 안 나온다. test-server 의 timedatectl 은 NTP=no · NTPSynchronized=no 였다.
|
||||
|
||||
### 3. 왜곡값은 여러 번 재서 흔들리는지 본다
|
||||
|
||||
ssh 왕복으로 잰 왜곡은 3회 모두 +106.1초였다. 흔들리면 네트워크 지연이 섞인 것이고 안정적이면 왜곡이다. 이 실험대가 쓴 106초는 이 106.1초를 반올림한 값이다. Google 비교 쪽의 -105초와는 1초 넘게 갈렸다 — 둘 다 test-server 가 앞섰다는 것은 같지만, 보정에 넣은 것은 흔들림을 확인한 ssh 왕복 쪽이다.
|
||||
|
||||
### 4. 왜곡은 주입하기 전에 재 둔다
|
||||
|
||||
D-4 와 D-4a 의 재현 절차는 인증서를 강제 갱신하기 전에 시계부터 잰다. 주입이 끝난 뒤에는 그때 그 시계가 얼마나 어긋나 있었는지를 되짚을 방법이 없기 때문이다. D-4 는 시계를 재지 않고 2199초를 적었고, 나중에 D-4a 가 시계를 재면서 그 값이 2305초로 정정됐다.
|
||||
|
||||
강제 갱신은 진짜 인증서를 발급해 되돌릴 수 없으므로 이 실험 전체에서 한 번만 쓴다. 그 한 번을 헛되이 쓰지 않으려고 주입 전에 잴 것을 여덟 칸으로 적어 두었고, 시계는 그중 일곱 번째다.
|
||||
|
||||
관측도 한 기계에서만 한다. 이 실험은 전부 dev 머신에서 관측했고, 호스트에서만 알 수 있는 파일 mtime 과 훅 로그만 보정해서 썼다. 밖에서 본 것이 이 실험의 답이고, dev 가 이 실험대에서 유일하게 정확한 시계였기 때문이다.
|
||||
|
||||
### 5. 보정한 결과는 독립된 시계로 교차검증한다
|
||||
|
||||
새 인증서의 SCT 는 CT 로그가 자기 시계로 서명한 시각이라 test-server 것도 dev 머신 것도 아니다. 106초를 뺀 타임라인에서 훅의 nginx -t 는 발급 1초 뒤에 놓였다. 보정이 틀린 방향이었으면 훅이 발급보다 앞에 왔을 것이다.
|
||||
|
||||
다만 훅 시각은 초 단위로만 남은 로그 값이고 SCT 에는 밀리초가 붙어 있다. 이 실험대가 적은 「정확히 1초 앞」은 그 반올림을 거친 표현이라 차가 1.000초라는 뜻은 아니다.
|
||||
|
||||
인증서에 적힌 notBefore 는 발급 시각을 재는 기준으로 쓸 수 없다. Let's Encrypt 가 notBefore 를 정확히 한 시간 백데이트하므로 그대로 발급 시각으로 읽으면 한 시간을 잃고, 한 시간을 더한 값도 발급 시각이 아니다. 이 실험대의 두 인증서에서 SCT 는 그 값보다 약 89초 앞섰다.
|
||||
|
||||
### 6. 같은 왜곡이라도 다른 실험에서 잰 값을 섞어 쓰지 않는다
|
||||
|
||||
B-4 는 타임스탬프 둘의 차로 약 107초를 어림했고, D-4a 는 ACME 응답을 제3의 기준으로 두고 106초를 다시 쟀다. 둘 다 test-server 가 앞선 양이다. 보정에 쓸 값은 D-4a 의 106초이고, 107초는 B-4 의 12회를 읽기 위한 어림이다.
|
||||
|
||||
### 7. 음수 지연이 나오면 계산이 아니라 시계를 의심한다
|
||||
|
||||
D-4 와 D-4a 의 「막히면」 표는 공백이나 지연이 음수로 나오는 것을 시계 재는 절차로 돌아갈 신호로 적어 두었다. 선후가 뒤집힌 결과는 자릿수만 어긋난 값과 달리 바로 걸린다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 서로 다른 기계에서 온 타임스탬프의 차이를 잴 때
|
||||
- 로그 시각과 외부 서비스가 찍은 시각을 견줄 때. D-4a 가 견준 것은 훅 로그와 CT 로그의 SCT 다
|
||||
- NTP 동기를 확인하지 않은 호스트가 측정에 끼어 있을 때. test-server 가 그랬다
|
||||
- 앞선 실험이 적어 둔 구간 값을 그대로 인용할 때. D-4 의 2199초가 D-4a 때문에 정정됐다
|
||||
|
||||
## 예외
|
||||
|
||||
- 같은 기계의 단조 시계로만 잰 구간에는 걸리지 않는다
|
||||
- 재는 구간이 왜곡보다 훨씬 길면 값은 틀려도 결론은 남는다. D-4 에서는 오차가 106초여서 결론이 안 바뀌었고, 1~2초를 재는 D-4a 에서는 결과가 완전히 뒤집혔다
|
||||
- 근거는 D-4·D-4a 한 쌍이다. B-4 에서도 같은 두 기계가 약 107초 어긋나 클레임 반영 시각이 변경 시각보다 앞서 보였는데, 그쪽은 어림으로 재고 넘어갔다. 이 실험대 밖의 측정으로 넓혀 확인한 적은 없다
|
||||
|
||||
## 예시
|
||||
|
||||
- test-server : NTP=no · NTPSynchronized=no · dev 머신보다 106초 빠름
|
||||
- dev 머신 → Google : 0초 차. dev 머신 → Let's Encrypt ACME : 0초 차
|
||||
- ssh 왕복 왜곡 3회 : +106.1 / +106.1 / +106.1초. 안정적
|
||||
- test-server → Google : -105초. 같은 왜곡을 다른 방법으로 잰 값이다
|
||||
- 보정식 : 실제 시각 = test-server 시계 − 106초. dev 시계는 보정하지 않는다
|
||||
- D-4 의 공백 : 보정 전 2199초, 보정 후 2305초 = 38분 25초
|
||||
- 보정하지 않은 D-4a : 그냥 빼면 107초, 참값 1~2초보다 약 106초 어긋난다. 보정을 반대쪽에 걸면 음수가 된다
|
||||
- 보정한 D-4a 타임라인 : 발급 다음 초에 훅 nginx -t 와 새 워커 37252 기동, 그다음 초에 훅 nginx -s reload. 발급에서 서빙까지 1~2초
|
||||
- SCT 는 밀리초까지 찍히고 훅 로그는 초 단위다. 「정확히 1초 앞」은 그 차이를 반올림한 표현이다
|
||||
- D-4a 의 새 인증서 : notBefore 는 `Sep 4 11:29:18`, 한 시간을 더하면 `12:29:18`, SCT 는 `12:27:49.05` 다. 약 88.9초 앞선다
|
||||
- B-4 의 약 107초 : 타임스탬프 둘의 차로 어림한 별개 값이다
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: verify-the-injection-landed-separately-from-the-result
|
||||
title: 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다
|
||||
topic: when-the-measurement-lies
|
||||
topicName: 주입이 걸렸는지 무엇으로 아는가
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#결국-지키려던-것은-무엇이었나
|
||||
- final/document.md#문제를-어렵게-만든-제약-주입이-먹지-않는다
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-대조군-없이는
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a1-jgroups-transport-block__07-cluster-size.txt
|
||||
- ../../../final/evidence/raw/a6-latency-injection__03-flannel-injection.txt
|
||||
- ../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt
|
||||
- ../../../final/evidence/raw/d4-certificate-renewal__05-control-no-injection.txt
|
||||
- ../../../final/evidence/raw/d4-certificate-renewal__08-inflight-artifact.txt
|
||||
---
|
||||
|
||||
# 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다
|
||||
|
||||
상태를 일부러 망가뜨리는 실험은 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다. 이 실험대가 위반 사례로 적은 둘은 전부 대조군 규칙이다 — A-6 의 −41% 대조군과 A-8 의 표본 9개다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
두 번째 규칙이 이 아홉 건에서 나왔다. 아홉 건은 규칙을 세우게 만든 실패이고, 규칙을 세운 뒤의 위반 사례가 아니다.
|
||||
- **실패 76 건이 서버 탓이 아니었다 — 대조군이 오보를 막았다**
|
||||
세 번째 규칙을 지켜서 오보를 막은 가장 최근 실험이다. 대조군과 같은 순간의 폴링이 없었으면 측정 장치의 아티팩트가 서버 장애로 기록됐다.
|
||||
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
|
||||
A-2 에서 장애가 실제로 일어나는 동안 이 지표는 1 이었다. 주입이 걸린 것을 확인해도 관측 도구가 그 상태를 보여 주지 않는 경우가 남는다.
|
||||
|
||||
## 목적
|
||||
|
||||
주입 실험의 결과는 세 자리에서 틀릴 수 있다. 첫째, 무엇을 예상했는지를 결과를 본 뒤에 적으면 예측과 결과의 차이가 사라진다. 둘째, 주입이 걸리지 않으면 관측에 아무 변화도 나타나지 않아 영향이 없는 경우와 구별되지 않는다. 셋째, 평시 값을 모르면 변화가 주입 탓인지 다른 이유인지 가를 수 없다. 뒤의 두 자리에서 실제로 문제가 났다 — 주입은 아홉 번 조용히 실패했고, 대조군 규칙은 두 번 어겼다. 첫째 자리를 어긴 사례는 이 실험대의 기록에 없다.
|
||||
|
||||
그 기록은 세 번째가 가장 자주 어겨졌고 치른 값도 가장 컸다고 적는데, 그 문장은 세 규칙의 위반 횟수를 견준 수치가 아니라 기록을 쓴 사람의 판단이다. 숫자로는 세 번째 규칙을 어긴 사례 둘만 남아 있다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 예측을 먼저 적는다
|
||||
|
||||
주입하기 전에 무엇이 일어날 것으로 보는지 적어 둔다. 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없다.
|
||||
|
||||
이 실험대가 그 예측을 적어 둔 곳은 A-0 의 「9. 다음 실험에 대한 예측」이다. A-0 이 평시를 재고 난 직후, 아직 아무것도 주입하기 전에 쓴 표이고, 뒤따르는 절들의 「맞다 / 틀렸다」는 전부 그 표와의 대조다. 표 앞에는 「예측이 빗나가면 그것이야말로 배울 거리다」라고 적혀 있다.
|
||||
|
||||
표에 적힌 실험 번호는 옛 로드맵의 것이라 지금 번호와 어긋난다. 당시의 `B-5` 가 최종 B-3 이고 `A-3 노드 상실` 이 최종 A-4 인데, 번호를 고쳐 적지 않고 원문 그대로 두었다 — 예측을 언제 썼는지가 번호에 남아 있기 때문이다.
|
||||
|
||||
틀린 예측은 지우지 않고 남긴다. 이 실험대에서 빗나간 예측은 다섯이다. B-4 에서는 nginx 가 동명 헤더를 덮어쓸 것으로 봤지만 덮어쓰지 않았고, A-7 에서는 refresh 의 500 이 REVOKED_TOKEN 때문이라고 봤지만 CLIENT_SCOPE_CLIENT 였다. 그중 A-1 의 예측이 틀리지 않았다면 A-0 의 인과 설명이 잘못된 채로 남았을 것이다.
|
||||
|
||||
A-2 의 up 이 1 이던 것은 그 다섯에 들어가지 않는다. 원본 가이드가 그 줄의 예측 칸을 비워 두고 「관측의 함정」이라고 적었으므로, 미리 적어 둔 예측이 빗나간 것이 아니라 예측한 적 없이 튀어나온 관측이다.
|
||||
|
||||
이 규칙을 어긴 사례는 이 실험대의 기록에 적혀 있지 않다.
|
||||
|
||||
### 2. 주입이 걸렸는지를 결과와 따로 확인한다
|
||||
|
||||
「아무 일도 없었다」는 「영향이 없다」와 구별되지 않는다. 주입 뒤에는 대상이 실제로 그 상태인지를 확인하는 단계를 하나 더 둔다. 보는 값은 결과가 아니라 상태다 — cluster_size, 워커 PID(프로세스 식별 번호), conntrack 표, 패킷 카운터.
|
||||
|
||||
주입 명령이 오류 없이 끝난 것은 걸렸다는 뜻이 아니다. NetworkPolicy 로 7800 을 막았을 때 ESTABLISHED 연결은 conntrack 때문에 규칙 평가를 건너뛰었고, cluster_size 는 25분간 2 로 남았다. 명령 자체는 정상 종료했다.
|
||||
|
||||
실패한 주입이 깨끗한 결과를 내기도 한다. A-3 은 DB 를 세 번 죽여 두 번 실패했는데, 그 두 번이 모두 「유실 0건」을 냈다. 무엇을 보면 걸린 것인지 신호를 미리 정해 두지 않았다면 첫 번째 결과를 그대로 답으로 적었을 것이고 결론은 정반대가 됐을 것이다.
|
||||
|
||||
재현 가이드 26편이 이 규칙을 절 구조로 갖고 있다. 편마다 주입 전에 평시를 잡는 절이 먼저 오고, 주입 다음에 「주입 검증」 절이 따로 서고, 그 뒤에 관찰과 복구가 온다.
|
||||
|
||||
이 규칙의 근거인 아홉 번의 실패는 규칙을 세우기 전에 일어났다. 규칙을 세운 뒤에 이 규칙을 어긴 사례는 따로 적혀 있지 않다.
|
||||
|
||||
### 3. 대조군 없이 귀속하지 않는다
|
||||
|
||||
평시를 모르면 이상을 해석할 수 없다. 주입 중에 비200 이 한 번 나왔을 때 평시 오류율을 모르면 그것이 주입 탓인지 알 수 없으므로, D-4 에서는 주입 전에 900건을 재서 오류가 0 이라는 것부터 확인했다.
|
||||
|
||||
대조군이 변하지 않는다고 가정하지 않는다. A-6 에서는 지연을 주입하지 않은 노드 쪽이 오히려 빨라졌다.
|
||||
|
||||
이 실험대가 어긴 사례로 적은 둘이 전부 이 규칙에 걸려 있다. D-4 의 in-flight 76건은 반대로 읽어야 한다 — 거기서는 대조군이 있어서 오보를 막았다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 상태를 일부러 망가뜨리고 그 영향을 재는 실험 전부에 걸린다.
|
||||
- 이 실험대가 적용 범위로 말하는 곳은 아홉 번의 실패 이후 이 실험대에서 돌린 실험까지다. 다른 실험실이나 다른 프로젝트까지 넓힌 근거는 이 실험대의 기록에 없다.
|
||||
- 주입 명령이 오류 없이 끝났을 때도 두 번째 규칙이 걸린다. 종료 코드는 대상의 상태를 말해 주지 않는다.
|
||||
- 대조군은 주입한 뒤가 아니라 주입 전에 잰다. 주입 뒤에 잰 값에는 이미 주입의 영향이 섞여 있다.
|
||||
|
||||
## 예외
|
||||
|
||||
- 주입 없이 평시를 관측하는 측정에는 첫 두 규칙이 걸리지 않는다. 망가뜨릴 대상이 없으므로 예측과 주입 확인이 성립하지 않는다.
|
||||
- 대조군 규칙은 평시 관측에도 걸린다. 무엇을 재든 그 값이 평소 값인지 아닌지를 가르려면 비교할 구간이 필요하다.
|
||||
|
||||
## 예시
|
||||
|
||||
- A-6 지연 주입 : 지연을 넣지 않은 대조군 노드가 70 ms 에서 41 ms 로 빨라졌는데(−41%) 해설 문서는 처음에 영향 없음이라고 적었다. JIT(just-in-time 컴파일) 워밍업이나 캐시처럼 주입과 무관한 변동이었고, 자릿수가 달라 결론 자체는 유지됐다. 나중에 고쳤다.
|
||||
- A-8 롤링 재시작 : 5초 간격 표본 9개로 무중단을 주장했다. 고친 문장은 「5초 해상도에서 끊김이 관측되지 않았다」다. 뒤에 1초 간격에 3초 타임아웃으로 D-2 롤백 전환을 재자 파드가 바뀌는 순간 요청 하나가 3초를 넘겼다.
|
||||
- D-4 in-flight 감시 : 76건이 실패했다. 그대로 적었으면 「갱신 중 대규모 요청 실패」라는 오보가 됐을 것이다. 같은 순간의 폴링은 49건 전부 200 이었고, 실패한 76건은 연결수 0 에 소요 시간 50µs 였고 재현이 0/100 이었다.
|
||||
- A-1 전송 차단 : 주입을 넣은 뒤 cluster_size 를 따로 읽었고, 25분간 2 로 남아 있는 것을 보고 주입이 걸리지 않았다고 판정했다.
|
||||
Reference in New Issue
Block a user