Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.
Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.
The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:
A-0 emptying the session table without a restart leaves cache entries
that get counted as replication arriving
A-2 dropping -o /dev/null fuses body and status into one string
A-3 presence of "ready to accept connections" instead of its timestamp
B-2 row count alone reads an UPDATE as nothing having happened
B-4 tr ',' '\n' splits ["admin","editor"] so only admin is seen
B-7 no login screen means the cookie died and SSO re-authenticated
C-1 counting sessions without joining realm counts your own kcadm one
D-1 kubectl exec without -i restores nothing and still exits 0
D-4a "ran with error output" is what success looks like
Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.
Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.
Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39 KiB
A-5 재현 가이드 — 한 방향만 끊어 보고, 왜 안 갈라지는지 직접 본다
해설 문서: docs/experiment-a5-asymmetric-partition.md ·
증거 원문: docs/evidence/a5-asymmetric-partition/
이 가이드가 끝나면
당신 터미널에서 이것들을 직접 본다.
| 보게 되는 것 | 어디서 |
|---|---|
| 규칙을 넣었는데 0 패킷인 상태 | iptables -L -n -v 의 카운터 |
| kube-router 가 내 규칙을 아래로 밀어내는 것 | FORWARD 체인의 줄 번호 |
| JGroups 연결 방향이 A-1 때와 반대인 것 | conntrack -L |
| 단방향 차단이 스스로 낫는 것 | 연결이 뒤집혀 재연결 |
coord = t 가 둘인 split brain |
PostgreSQL JGROUPS_PING |
| 그런데 한쪽만 DOWN 이고 외부는 200 인 것 | health/ready · endpointslice |
MergeView 로 50초 만에 합쳐지는 것 |
Keycloak 로그 |
전제
05-keycloak·06-observability가 끝나 있다.A-1을 먼저 해 두면 훨씬 이해가 빠르다. 이 실험은 A-1 이 실패한 자리에서 시작한다.- 명령은
kc-lab-1에서 친다.kubectl은sudo로 쓴다. kc-lab-2에는ssh kc-lab-2로 붙는다. iptables 는 두 노드에 각각 넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.- 터미널 두 개를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용.
주의 — 이건 상태를 부수는 실험이다
Keycloak 클러스터를 실제로 분단시킨다. 실험대에서만 한다. 전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다. 중간에 그만두려면 두 줄이면 된다.
sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'
-F FORWARD는 그 체인 전체를 비운다. 이 실험대의FORWARD정책은ACCEPT이고 실제 규칙은 kube-router·kube-proxy 가 자기 체인에 두므로 잠시 뒤 스스로 복구된다. 그래도 지우기 전에sudo iptables -S FORWARD로 무엇이 있었는지 한 번 보고 지운다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 12:28–12:46 KST 실행 기록의 출력 원문. 증거 파일에 그대로 있다 |
| 형태 | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| 미검증 | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·포트 번호는 당신 환경에서 다르다. 이 문서는 자리표시자
(<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
전부 위 실행 기록의 실제 값이다.
0. 왜 이 실험을 하는가
A-1 이 열어 둔 질문이 하나 있었다.
「
keycloak-1은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고,keycloak-0은 합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다.」
그 경로를 찾는 것이 이 실험이다. 그리고 A-1 은 도구도 하나 남겼다.
| A-1 이 배운 것 | |
|---|---|
| NetworkPolicy | 기존 연결을 못 끊는다. conntrack 의 ESTABLISHED 가 먼저 통과시킨다 |
| 그래서 | 이번엔 iptables 로 직접 간다 |
그런데 iptables 에도 벽이 세 개 있었다. 이 가이드의 절반은 그 세 번의 실패를 일부러 다시 밟는 것이다. 셋 다 화면에는 **「아무 일도 없었다」**로 보이기 때문에, 겪어 보지 않으면 다음에도 똑같이 속는다.
실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다
성공 수신측 노드의 raw PREROUTING → 19 패킷
그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다
1. 기준선 — 아무것도 넣기 전에
시험군만 재는 측정은 측정이 아니다. 특히 이 실험은 주입이 걸리기 전과 후가 화면상 똑같이 보이므로, 기준선이 없으면 실패를 성공으로 읽는다.
1-1. 파드 IP 와 노드 — 이 값이 곧 규칙의 인자다
확인
sudo kubectl -n keycloak-lab get pods -o wide
실측 — 01-injection.txt
keycloak-0=10.42.1.77 (kc-lab-2) keycloak-1=10.42.0.42 (kc-lab-1)
keycloak-0 1/1 Running 0 11m
keycloak-1 1/1 Running 1 (2m48s ago) 155m
postgres-7b474b88c8-9cmsv 1/1 Running 0 14m
어디를 봐야 하는가
NODE와 파드 번호가 어긋난다 —keycloak-0이kc-lab-2에 있다. iptables 를 어느 노드에 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데 패킷은 안 걸린다- IP 가 A-1 때와 다르다 (
10.42.1.43→10.42.1.77). 파드가 재시작되면 바뀐다. 여기 적힌 값을 그대로 쓰지 말고 지금 뽑는다
변수로 잡아 둔다. 파드가 재시작되면 다시 잡는다.
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "K0=$K0 K1=$K1"
keycloak-1의RESTARTS가 1 인 것도 보인다. A-4 에서 노드를 껐다 켠 흔적이다. 직전 실험의 잔재가 남아 있는지 확인하는 자리이기도 하다.
1-2. 클러스터가 지금 하나인가
확인
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
형태
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | f
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
어디를 봐야 하는가 — coord 열에 t 가 정확히 하나.
둘이면 이미 갈라져 있는 것이고, 그 상태에서 주입해 봐야 아무것도 판정 못 한다.
확인 — 로그가 말하는 뷰
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
형태
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]
뷰 ID(|13)를 적어 둔다. 이 실험의 판정 기준이 이 숫자의 변화다.
1-3. 지표 — 그리고 이 자리에서 원 실행이 넘어졌다
Keycloak 컨테이너에는 curl 도 wget 도 없다(exit 127). 밖에서 Prometheus 에
묻는 것이 가장 짧다.
확인
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
형태 — 한 줄 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-1","node":"kc-lab-1"},"value":[1757037600.123,"2"]},
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-0","node":"kc-lab-2"},"value":[1757037600.123,"2"]}]}}
라벨을 보고 나면 읽기 좋게 자른다 (jq 는 이 실험대에 없다). 미검증
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
어디를 봐야 하는가 — 두 줄이고 값이 둘 다 2.
★ 원 실행의 기준선은 남지 않았다. 값을 뽑으려고 붙인 파이썬 한 줄이 죽었기 때문이다. —
01-injection.txtTraceback (most recent call last): File "<string>", line 3, in <module> for r in json.load(sys.stdin)["data"]["result"]: print(f" cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}") json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)입력이 비어 있었다. 그런데 파서가 죽으면서 원본도 같이 사라졌다 — 화면에 남은 것은 파이썬 스택트레이스뿐이고, Prometheus 가 무엇을 돌려줬는지는 아무도 모른다. 원본을 먼저 보고 나중에 자르면 이런 일이 없다. 이 가이드가
wget원문을 먼저 보여 주는 이유다.
1-4. ★ 연결 방향 — 이 실험에서 가장 중요한 기준선
어느 쪽이 클라이언트이고 어느 쪽이 서버인가. 이걸 모르면 규칙을 엉뚱한 노드에 넣게 된다.
확인 — 두 노드 모두에서 본다
sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
실측 — 해설 문서 1절 (실패 ② 에서 인용된 원 실행의 연결)
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
──────────── ────────────────────
keycloak-0 가 클라이언트 keycloak-1 이 서버
어디를 봐야 하는가 — dport=7800 인 쪽이 서버다. src 가 클라이언트.
이 결과가 의미하는 것 — A-1 때와 방향이 반대다. A-1 에서는
10.42.0.35:40023 → 10.42.1.43:7800, 즉 keycloak-1 이 걸었다. 지금은
keycloak-0 이 건다.
JGroups 의 TCP 연결 방향은 고정이 아니다. 먼저 뜬 쪽, 먼저 JOIN 을 건 쪽에 따라 달라진다. 파드가 재시작될 때마다 바뀔 수 있다. 가정하지 말고 매번
conntrack -L로 본다.
2>/dev/null 은 conntrack 이 stderr 로 찍는 「N flow entries have been shown」
요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.
1-5. 밖에서 보이는 상태
확인
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
여러 번 재서 비교할 것이므로 이제부터는 코드만 뽑는다.
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
200 이어야 한다.
2. 주입 시도 ① — filter 테이블 최상단 (실패한다)
일부러 실패하는 단계다. 건너뛰지 않는 편이 좋다. 이 실패의 모양을 봐 둬야 다음에 자기 규칙을 의심할 수 있다.
되돌리기 — 먼저 읽어 둔다
ssh kc-lab-2 'sudo iptables -F FORWARD'
2-1. 넣는다
A-1 의 NetworkPolicy 는 conntrack 에 막혔다. FORWARD 최상단에 넣으면
conntrack 승인보다 먼저 평가될 것이라는 게 이 시도의 가설이다.
하기 — keycloak-0(수신측이라고 가정한 쪽) 이 있는 노드에
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
57800 도 같이 막는다. FD_SOCK2(장애 감지 채널)는
bind_port + 50000을 쓴다. 7800 만 막으면 장애 감지는 계속 통해서 분단이 어정쩡해진다.
확인 — 방금 넣은 것이 실제로 1번인가
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
실측 — 01-injection.txt
Chain FORWARD (policy ACCEPT)
num target prot opt source destination
1 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:57800
2 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:7800
주입 시각: 12:28:23
넣은 직후에는 맞게 보인다. 여기서 만족하고 넘어가면 속는다.
2-2. 잠시 뒤 다시 본다 — ★ 밀려나 있다
확인 — 1~2분 뒤 같은 명령을 다시
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
실측 — 해설 문서 1절 (실패 ①)
num pkts bytes target
1 232 377K KUBE-ROUTER-FORWARD /* kube-router netpol */ ← 다시 1번이 되었다
2 0 0 DROP tcp dpt:57800
3 0 0 DROP tcp dpt:7800 ← 0 패킷
어디를 봐야 하는가 — 두 가지를 동시에 본다.
| 열 | 무엇을 말하는가 |
|---|---|
num |
내 규칙이 1번이 아니다. kube-router 체인이 위로 돌아왔다 |
pkts |
0. 이 규칙에는 패킷이 단 한 개도 도달하지 않았다 |
이 결과가 의미하는 것 — kube-router 가 주기적으로 자기 체인을 FORWARD
최상단에 다시 삽입한다. 내가 1번에 넣어도 곧 2번, 3번으로 밀려나고,
kube-router 체인이 패킷을 먼저 처리해 버린다.
직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁한다. 넣는 것으로 끝이 아니다. 패킷 카운터로 확인해야 한다.
정직하게 — 이 확인을 담았어야 할 증거 파일
02-injection-verify.txt는 원 실험 시점에 0바이트로 저장됐다. 리다이렉션이 stdout 만 받았는데 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 사후에 다시 수집한 것이며, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다. 남아 있는 것은 구조적 사실 하나 — kube-router 체인이FORWARD1번을 차지하고 있다는 것뿐이다. 당신은 지금 실제 카운터를 볼 수 있다. 이 단계를 건너뛰지 않는 이유다.
2-3. 그래서 아무 일도 안 일어난다
확인 — 25초 간격으로 몇 번 본다
sudo kubectl -n keycloak-lab get pods | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
실측 — 01-injection.txt
+25초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+50초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
...
+200초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
★ 여기서 「비대칭 차단은 클러스터를 안 가른다」고 결론 내리면 틀린다. 결론이 우연히 맞더라도 근거가 없다. 규칙에 패킷이 0 개 왔으니 이 관찰은 아무것도 측정하지 않았다.
2-4. 치운다
하기
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 57800 -j DROP"
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'
-D 는 넣을 때와 똑같은 인자를 줘야 지워진다. 안 지워지면 줄 번호로:
sudo iptables -D FORWARD 3.
3. 주입 시도 ② — raw 테이블로 옮긴다 (그래도 0 패킷)
3-1. 개념 — netfilter 처리 순서
패킷 도착
│
├─▶ raw PREROUTING ← conntrack 보다 먼저. NOTRACK·DROP 용
│
├─▶ conntrack 조회/생성 ← 여기서 ESTABLISHED 가 결정된다
│
├─▶ mangle PREROUTING
├─▶ nat PREROUTING
├─▶ filter FORWARD ← NetworkPolicy·kube-router 가 여기 있다
└─▶ 목적지 파드
| 어디에 넣는가 | 기존 연결을 끊는가 | CNI 와 경쟁하는가 |
|---|---|---|
| NetworkPolicy (filter) | 못 끊는다 — conntrack 이 먼저 통과시킨다 (A-1) | 없음 |
| filter FORWARD 직접 | 순서에 따라 | 경쟁한다 (kube-router 가 밀어낸다) — 2절 |
| raw PREROUTING | 끊는다 | 없다 — CNI 가 안 쓰는 테이블 |
진짜 네트워크 분단을 흉내내려면 raw 테이블이 맞다.
3-2. 넣는다 — 아직 같은 노드, 같은 목적지
되돌리기
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
하기
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
확인 — 카운터
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
실측 — 03-raw-table-injection.txt
=== [검증] 이번엔 패킷이 걸렸는가 ===
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:57800
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:7800
어디를 봐야 하는가 — pkts 가 여전히 0. 이번에는 CNI 와 경쟁하지도
않는데 0 이다.
3-3. 왜 0 인가 — 1-4 를 다시 본다
확인
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
1-4 에서 본 것이 답이다.
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
└── keycloak-0 ──┘ └── keycloak-1 ──┘
(클라이언트) (서버, 7800 을 듣는 쪽)
이 결과가 의미하는 것 — 10.42.1.77(keycloak-0)은 이 연결의 출발지다.
-d 10.42.1.77 --dport 7800 은 존재하지 않는 패킷을 노린 규칙이었다.
7800 으로 들어가는 패킷은 10.42.0.42(keycloak-1) 쪽으로 간다.
내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다)
실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다)
규칙을 넣은 노드도 틀렸다. 목적지 파드가 있는 노드에서 잡아야 한다.
3-4. 치운다
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
지우기 전에 -L 로 무엇이 있는지 본다. -F 는 체인 전체를 비운다.
4. 주입 성공 — 수신측 노드의 raw PREROUTING
4-1. 넣는다
되돌리기
sudo iptables -t raw -F PREROUTING
하기 — 이번에는 kc-lab-1(keycloak-1 이 있는 노드) 에, keycloak-1 의
IP 를 목적지로
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
date '+%H:%M:%S 주입'
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
주입: 12:33:58
4-2. 이번엔 걸리는가 — 카운터가 유일한 판정 기준이다
확인
sudo iptables -t raw -L PREROUTING -n -v
실측 — 같은 파일
pkts bytes target prot opt in out source destination
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
19 2938 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
어디를 봐야 하는가 — 7800 규칙의 pkts 가 19. 드디어 걸린다.
57800 은 아직 0 인 것도 정보다. FD_SOCK2 는 이미 붙어 있는 연결을 쓰고 있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다 —
실측 — 05-reconnect-observed.txt
=== 차단 규칙 누적 카운터 ===
19 1096 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
21 3058 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
카운터 판정표
pkts뜻 할 일 0아무것도 측정하지 않았다 해석 금지. 방향과 테이블을 다시 본다 조금씩 는다 재연결 시도가 막히고 있다 관찰로 넘어간다 폭증한다 대상이 너무 넓다 -d·--dport를 좁힌다
5. 효과를 관찰한다 — 단방향은 클러스터를 못 가른다
5-1. 파드와 외부
확인 — 25초 간격
sudo kubectl -n keycloak-lab get pods | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
+25초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+50초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+75초 - | keycloak-0:1/1 keycloak-1:0/1 | 외부 200
+100초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+125초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
어디를 봐야 하는가 — +75초 에 keycloak-1 이 한 번 0/1 로
흔들렸다가 +100초 에 돌아온다.
이 결과가 의미하는 것 — 주입이 닿기는 했다(2절의 아무 일 없음과 다르다). 그런데 스스로 나았다.
5-2. 뷰가 변했나 — 그리고 로그 시각의 함정
확인
sudo kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094
여기서 시각을 비교하려다 대부분 한 번은 틀린다.
당신 셸의 date 12:33:58 KST
컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다
Keycloak 컨테이너는 UTC 로 찍는다. KST 는 UTC+9 이므로 9시간을 빼서 맞춰 본다. 이걸 모르면 「주입 전 로그」와 「주입 후 로그」를 정반대로 가른다.
실측 — 06-view-history-and-cleanup.txt
2026-09-04 03:33:49 | MergeView::[keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)],
어디를 봐야 하는가 — 뷰 13, 멤버 (2), 그리고 MergeView.
이 결과가 의미하는 것 — ★ 여기가 이 실험의 가장 미묘한 자리다.
해설 문서는 처음에 「주입 이후 뷰 변화가 하나도 없었다」고 썼다. 맞는 말이다. 그런데 그 「주입 전부터 그대로」의 「전」이 9초였다.
03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다
03:33:58 내 주입 ← 9초 뒤
앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였던 것이다.
로그 한 줄만 보고 「변화 없음」이라고 말하면 안 된다. 그 줄이 언제 생겼는지를 함께 본다.
grep에 시각이 같이 나오는 형태를 쓰는 이유가 이것이다.결론 자체(주입 이후 뷰가 변하지 않았다)는 유지된다. 다만 기준선이 9초짜리 였다는 사실은 함께 적어야 정직하다.
5-3. ★ 왜 안 갈라졌나 — 연결이 뒤집혔다
확인 — 1-4 와 똑같은 명령을 다시 친다. 그게 대조의 방법이다
sudo conntrack -L 2>/dev/null | grep 7800
실측 — 05-reconnect-observed.txt
tcp 6 299 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800 src=10.42.1.77 dst=10.42.0.42 sport=7800 dport=48473
tcp 6 86232 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=44205 dport=57800 src=10.42.1.77 dst=10.42.0.42 sport=57800 dport=44205
어디를 봐야 하는가 — src 와 dst 를 1-4 와 나란히 놓는다.
차단 전: src=10.42.1.77 → dst=10.42.0.42:7800 ← 내가 막은 방향
차단 후: src=10.42.0.42 → dst=10.42.1.77:7800 ← 열린 방향으로 다시 붙었다
이 결과가 의미하는 것 — JGroups 는 막힌 연결이 죽자 반대 방향으로 새로 연결했다. 그리고 FD_SOCK2 가 상대를 의심하기 전에 복구가 끝났다.
확인 — 의심 카운터로 뒷받침한다
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
실측 — 06-view-history-and-cleanup.txt
keycloak-0 merge_events=1.0 suspected=0.0
keycloak-1 merge_events=1.0 suspected=0.0
keycloak-0 cluster_size=2.0
keycloak-1 cluster_size=2.0
suspected = 0. 아무도 상대를 의심하지 않았다 — 끊긴 적이 없는 것과
같다. (merge_events = 1 은 5-2 의 9초 전 병합의 것이다.)
한 방향만 막는 것으로는 JGroups 를 가를 수 없다. 두 노드는 서로에게 연결을 걸 수 있으므로, 한쪽 길이 막히면 다른 길로 간다.
운영적으로는 좋은 소식이다 — 단방향 방화벽 오설정은 자가 치유된다. 반대로 분단을 재현하려는 실험자에게는 함정이다.
6. 양방향 차단 — 갈라지지만 전면 장애는 아니다
6-1. 반대 노드에도 넣는다
되돌리기 — 두 줄이다. 이제 양쪽에 있다
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
하기 — kc-lab-1 의 규칙은 그대로 두고, kc-lab-2 에 반대 방향을 추가
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
확인 — 양쪽 카운터를 다 본다. 한쪽만 걸리면 그건 여전히 단방향이다
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
실측 — 07-bidirectional-block.txt
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
주입: 12:40:25
6-2. 이번에는 갈라진다
확인 — 25초 간격
sudo kubectl -n keycloak-lab get pods | grep keycloak
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
실측 — 같은 파일
+75초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
+100초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+125초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
...
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
어디를 봐야 하는가 — 세 가지가 한 줄에 있다.
keycloak-1이0/1로 내려가서 안 돌아온다 (5-1 과 다르다)- ready 주소가 둘에서 하나로 줄었다
- 외부는 계속
200
kubectl get endpoints는 v1.33 부터 deprecated 라 경고가 뜬다.endpointslice를 본다.
확인 — 뷰
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
실측 — 같은 파일
keycloak-0 [keycloak-0-24309(v=16.0.12)|14] (1) [keycloak-0-24309(v=16.0.12)]
keycloak-1 [keycloak-1-45480(v=16.0.12)|14] (1) [keycloak-1-45480(v=16.0.12)]
뷰 ID 는 둘 다 14 인데 멤버는 각자 1 명이다. 같은 번호의 다른 세계다.
6-3. split brain 을 DB 한 줄로 확인한다
확인
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
실측 — 08-coordinator-and-recovery.txt
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | t
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
어디를 봐야 하는가 — coord = t 가 둘. 1-2 에서 하나였던 것과 대조한다.
분단을 확인하는 가장 짧은 명령이 이것이다. 로그를 두 번 긁는 것보다 빠르고, 지표보다 정확하다.
6-4. ★ 그런데 한쪽만 DOWN 이다
Keycloak 컨테이너에 curl 이 없으므로 임시 파드에서 묻는다.
하기 — 상주 파드를 띄운다
sudo kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 1800
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s
되돌리기
sudo kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found
왜
--rm -it짜리 일회용 파드를 안 쓰나. 원 실행이 그렇게 했다가 붙지 못했다. —08-coordinator-and-recovery.txtwarning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state파드가 만들어지고 명령이 끝나 버리기 전에 붙어야 하는 경주가 된다. 관찰을 여러 번 반복할 것이라면 상주 파드가 항상 낫다.
확인 — 양쪽 헬스체크
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"
실측 — 같은 파일
--- keycloak-0 ---
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
{"name":"Keycloakclusterhealthcheck","status":"UP"}
--- keycloak-1 ---
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
어디를 봐야 하는가 — 맨 앞의 "status". keycloak-0 은 UP, keycloak-1
은 DOWN. 그리고 keycloak-1 쪽에서 DB 체크는 UP 인 것도 본다 —
DB 때문이 아니라 클러스터 때문이다.
이 결과가 의미하는 것 — A-1 의 열린 질문에 대한 답이다.
| keycloak-0 | keycloak-1 | |
|---|---|---|
| 분단 전 역할 | 코디네이터 (뷰 13 의 발행자) | 일반 멤버 |
| 분단 후 자기 인식 | 「멤버가 하나 나갔다」 — 정상 사건 | 「코디네이터를 잃었다」 — 비정상 |
| 헬스체크 | UP | DOWN |
| Service 엔드포인트 | 남는다 | 빠진다 |
Keycloak 의 클러스터 헬스체크는 비대칭이다. 코디네이터였던 쪽은 자기가 정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 완전 분단조차 용량 저하로 끝나고 전면 장애가 되지 않는다.
A-2(DB 상실)에서는 양쪽이 동시에 DOWN 이었다. 차이는 이것이다 — DB 는 모두가 의존하는 하나지만, 클러스터 멤버십은 서로 상대적이다.
Grafana 에서 같은 것을 그림으로 본다 —
a5-cluster-size-bidirectional-block.png.
7. 복구
7-1. 지운다
확인 — 지우기 전에 무엇이 있는지 본다
sudo iptables -t raw -L PREROUTING -n -v --line-numbers
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
하기
date '+%H:%M:%S 해제'
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
실측 — 08-coordinator-and-recovery.txt
=== 차단 해제 ===
해제: 12:44:37
7-2. 자동으로 다시 붙는지 본다
확인 — 25초 간격
sudo kubectl -n keycloak-lab get pods | grep keycloak
실측 — 같은 파일
+25초 keycloak-0:1/1 keycloak-1:0/1
+50초 keycloak-0:1/1 keycloak-1:1/1
→ 복구 완료
50초. 사람 개입 없음.
7-3. 누가 붙였나 — MergeView
확인
sudo kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -1
실측 — 같은 파일
keycloak-0 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
keycloak-1 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
어디를 봐야 하는가 — 뷰 ID 가 15, 멤버 (2), 양쪽이 같은 줄.
[keycloak-0-24309|13] (2) ← 정상
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
뷰 ID 는 단조 증가하므로 「언제 몇 번 갈라졌는지」를 로그만으로 셀 수 있다.
7-4. 캐시별 재분배 로그
확인
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6
실측 — 해설 문서 4절 ·
원문은 06-view-history-and-cleanup.txt
[Context=work] ISPN100007: After merge (or coordinator change) ...
[Context=clientSessions] ISPN100007: After merge ...
[Context=offlineSessions] ISPN100007: After merge ...
[Context=loginFailures] ISPN100007: After merge ...
[Context=actionTokens] ISPN100007: After merge ...
증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 형태도 한 번 본다.
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
ISPN100007 은 병합(또는 코디네이터 변경) 후 캐시별 토폴로지 재계산이다.
캐시가 여럿이므로 로그도 캐시 수만큼 나온다. 한 줄만 보고 「한 번
재분배됐다」고 세면 틀린다 — work·clientSessions·offlineSessions·
loginFailures·actionTokens 가 각각 찍는다.
7-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| raw 규칙 | sudo iptables -t raw -S PREROUTING |
-P PREROUTING ACCEPT 만 |
| filter 규칙 | sudo iptables -S FORWARD | head -5 |
내가 넣은 DROP 이 없음 |
| (반대 노드) | ssh kc-lab-2 'sudo iptables -t raw -S PREROUTING' |
같음 |
| 파드 | sudo kubectl -n keycloak-lab get pods |
keycloak 둘 다 1/1 Running |
| 디스커버리 | 6-3 의 psql | coord = t 가 하나 |
| 뷰 | logs keycloak-0 | grep ISPN000094 | tail -1 |
멤버 (2), 양쪽 동일 |
| 지표 | vendor_cluster_size |
양쪽 2 |
| Service | get endpointslice -l kubernetes.io/service-name=keycloak |
ready 주소 둘 |
| 탐침 파드 | sudo kubectl -n keycloak-lab get pod a5-probe |
지웠으면 NotFound |
| 밖 | curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master |
200 |
conntrack 은 건드리지 않아도 된다. 차단이 풀리면 새 연결이 스스로 성립한다.
이 실험이 재지 않은 것 — 분단 중에 세션이 어떻게 되는지는 재지 않았다 (그건 A-1 4-5 의 주제다). 여기서는 누가 살아남는가만 봤다.
막히면
전부 이 실험대가 실제로 겪은 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 규칙을 넣었는데 아무 일도 없다 | 카운터가 0 이면 아무것도 측정 안 된 것 | iptables -L -n -v 의 pkts — 4-2 |
| 내 규칙이 1번이 아니다 | kube-router 가 자기 체인을 재삽입한다 | --line-numbers 로 순서 — 2-2 |
raw 인데도 0 패킷 |
연결 방향을 잘못 짚었다 | conntrack -L | grep 7800 — 3-3 |
| conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | 두 노드 모두에서 본다 — 1-4 |
| 단방향인데 안 갈라진다 | 정상이다. 열린 방향으로 재연결한다 | conntrack 의 src/dst 뒤집힘 — 5-3 |
| 로그에 변화가 없어 보인다 | 컨테이너 로그는 UTC. KST 와 9시간 차 | logs 의 시각에서 9를 뺀다 — 5-2 |
| 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 언제 생겼는지 본다 — 5-2 |
-D 로 규칙이 안 지워진다 |
넣을 때와 인자가 다르다 | 줄 번호로 지운다: -D FORWARD 3 |
| 임시 파드에 attach 실패 | --rm -it 는 경주가 된다 |
상주 파드를 쓴다 — 6-4 |
kubectl exec keycloak-0 -- curl 이 exit 127 |
이미지에 curl 도 wget 도 없다 | 탐침 파드나 Prometheus |
| 지표를 파이썬으로 자르다 죽었다 | 원본까지 같이 사라진다 | wget 원문을 먼저 본다 — 1-3 |
get endpoints 가 경고를 찍는다 |
v1.33 부터 deprecated | get endpointslice -l kubernetes.io/service-name=... |
| 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 — 4-2 |
| 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | 두 노드 모두 -t raw -S PREROUTING |
실험자를 위한 한 장 요약
| 상황 | 확인 방법 |
|---|---|
| 규칙을 넣었는데 안 걸린다 | iptables -L -n -v 의 패킷 카운터 |
| 방향을 모르겠다 | sudo conntrack -L 2>/dev/null | grep 7800 — dport 쪽이 서버 |
| CNI 가 밀어낸다 | raw 테이블을 쓴다 |
| 갈라졌는지 알고 싶다 | JGROUPS_PING.coord, ISPN000094, vendor_cluster_size |
| 누가 살아남을지 알고 싶다 | 분단 전 코디네이터가 누구였는가 |
다음
| 실험 | A-5 가 남긴 질문 |
|---|---|
| A-6 지연 주입 | tc netem 도 똑같은 함정. 인터페이스를 잘못 고르면 카운터가 0 이다 |
| A-7 volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 세션이 갈라진다 |
| A-1 로 되돌아가서 | 분단 중 로그아웃이 전파되지 않는다. 그 상태를 여기서 다시 만들 수 있다 |
| 운영 | 단방향 방화벽 오설정은 자가 치유된다. 양방향이어야 사고가 된다 |