Files
keycloak-pattern/docs/guides/experiments/a1-jgroups-transport-block.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
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>
2026-09-07 18:29:00 +09:00

1093 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A-1 재현 가이드 — 7800 을 끊고 무엇이 깨지는지 직접 본다
해설 문서: [`docs/experiment-a1-jgroups-transport-block.md`](../../experiment-a1-jgroups-transport-block.md) ·
증거 원문: [`docs/evidence/a1-jgroups-transport-block/`](../../evidence/a1-jgroups-transport-block/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| NetworkPolicy 를 걸었는데 클러스터가 안 깨지는 상태 | `conntrack -L` · `vendor_cluster_size` |
| `coord = t` 가 두 줄인 split brain | PostgreSQL `JGROUPS_PING` |
| 분단인데도 교차 노드 refresh 가 `200` | 임시 curl 파드 |
| 로그아웃했는데 반대편이 `200` 을 주는 상태 | 같은 파드 |
| 분단된 노드가 스스로 Service 에서 빠지는 것 | `endpointslice` |
| 90초 만에 자동으로 다시 붙는 것 | `merge3_get_num_merge_events` |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. conntrack 은 **두 노드 모두에서** 봐야 한다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 임시 curl 파드용(붙잡고 있어야 한다),
하나는 관찰용.
## 주의 — 이건 상태를 부수는 실험이다
Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. **실험대에서만 한다.**
전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다.
중간에 그만두려면 [5. 복구](#5-복구) 의 첫 명령 하나면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:3811:52 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 하는가
A-0 이 「세션은 Infinispan 복제가 아니라 PostgreSQL 로 공유된다」를 측정했다.
그런데 Keycloak 24 이전 자료는 「세션은 7800 으로 복제된다」고 말한다.
| | 예측 |
|---|---|
| 통념 | 7800 을 막으면 **세션 공유가 깨진다** |
| A-0 모델 | 7800 을 막아도 **안 깨진다** |
둘 중 하나는 틀렸고, **7800 만 끊어 보면 판정된다.** 그게 이 실험이다.
핵심은 **두 가지를 분리해서 끊는 것**이다.
```
디스커버리 노드가 서로를 어떻게 찾는가 → PostgreSQL 의 JGROUPS_PING 테이블
트랜스포트 실제로 어떻게 말하는가 → TCP 7800
```
트랜스포트만 막으면 **DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태**가
된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 이유다.
---
# 1. 기준선 — 아무것도 넣기 전에
**시험군만 재는 측정은 측정이 아니다.** 차단 후에 볼 것을 차단 전에 **똑같은
명령으로** 먼저 봐 둔다. 그래야 나중에 「원래 그랬던 것」과 「내가 바꾼 것」이
구별된다.
넓은 것부터 좁혀 간다.
```
노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험
```
## 1-1. 노드와 파드
**확인**
```bash
sudo kubectl get nodes
```
**형태**
```
NAME STATUS ROLES AGE VERSION
kc-lab-1 Ready control-plane,master 12d v1.33.x+k3s1
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
```
둘 다 `Ready` 여야 한다. 여기서부터 어긋나면 이 실험의 결과는 전부 무의미하다.
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
bff-... 1/1 Running 0 3d 10.42.0.41 kc-lab-1
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
oauth2-proxy-... 1/1 Running 0 3d 10.42.0.44 kc-lab-1
postgres-... 1/1 Running 0 5d 10.42.0.22 kc-lab-1
redis-... 1/1 Running 0 3d 10.42.0.23 kc-lab-1
```
**어디를 봐야 하는가**
- `READY` 가 둘 다 `1/1`
- **`RESTARTS``0`** — 뒤에서 이 값이 오르면 주입이 엉뚱한 것을 건드린 것이다
- **`NODE` 가 서로 다르다** — 같은 노드에 있으면 이 실험은 성립하지 않는다
(파드 간 통신이 노드를 안 넘어간다)
- `IP` 두 개를 적어 둔다. 뒤에서 계속 쓴다
**이 결과가 의미하는 것**`keycloak-0``kc-lab-2`, `keycloak-1``kc-lab-1`
에 있다. **파드 번호와 노드 번호가 어긋난다.** 뒤에서 conntrack 을 볼 때 이걸
헷갈리면 엉뚱한 노드를 뒤지게 된다.
IP 는 변수로 잡아 둔다. 파드가 재시작되면 **바뀌므로** 그때 다시 잡는다.
```bash
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 $K1"
```
**실측**
```
10.42.1.43 10.42.0.35
```
## 1-2. 기존 NetworkPolicy 가 없다는 것
**확인**
```bash
sudo kubectl -n keycloak-lab get networkpolicy
```
**실측** — [`01-baseline-cluster.txt`](../../evidence/a1-jgroups-transport-block/01-baseline-cluster.txt)
```
No resources found in keycloak-lab namespace.
```
**왜 확인하나** — 이미 정책이 하나라도 걸려 있으면 결과가 그것과 섞인다.
NetworkPolicy 는 **합집합으로 허용**되므로 두 개가 겹치면 무엇이 열려 있는지
한눈에 안 보인다. 비어 있어야 「내가 넣은 것만이 원인」이라고 말할 수 있다.
## 1-3. 클러스터 뷰 — 로그가 말하는 것
**확인**
```bash
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-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
keycloak-1: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
```
**어디를 봐야 하는가**
```
[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
└── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘
│ └─ 멤버 수
└─ 뷰 ID (바뀔 때마다 1 증가)
```
**이 결과가 의미하는 것** — 뷰 ID `5`, 멤버 `2`, 그리고 **양쪽이 완전히 같은 줄을
찍고 있다.** 이게 「하나의 클러스터」다. 두 줄이 달라지면 그때가 분단이다.
`keycloak-0-30843` 의 뒤 숫자는 JGroups 가 붙인 것이고 **파드가 재시작되면
바뀐다.** 나중에 `keycloak-0-26403` 이 나오면 같은 파드의 새 인스턴스다.
## 1-4. 디스커버리 — DB 가 말하는 것
**확인**
```bash
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-30843 | 10.42.1.43:7800 | f
keycloak-1-48749 | 10.42.0.35:7800 | t
(2 rows)
```
**어디를 봐야 하는가****`coord` 열에 `t` 가 정확히 하나.**
**이 결과가 의미하는 것** — 두 노드가 서로를 찾을 수 있고, 코디네이터가 하나로
합의되어 있다. 이 테이블은 **「지금 등록되어 있다」**만 말한다. 실제로 메시지가
오가는지는 말하지 않는다 — 이 실험이 갈라놓을 지점이 정확히 여기다.
> **셋이 서로 다른 것을 본다.**
> 로그 = 「그때 그렇게 보였다」, 테이블 = 「지금 등록되어 있다」,
> 지표 = 「지금 그 노드가 그렇게 안다」. A-1 에서 이 셋이 갈린다.
## 1-5. 지표 — 각 노드가 자기가 아는 멤버 수를 말한다
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 그래서 **밖에서
Prometheus 에 묻는 것이 가장 짧다.** 15초마다 이미 긁고 있다.
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**형태**
```json
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757037600.123,"2"]},
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757037600.123,"2"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
```bash
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]'
```
`jq` 가 깔려 있다면 이쪽이 낫다. **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| jq -r '.data.result[] | "\(.metric.pod) \(.metric.node) \(.value[1])"'
```
**어디를 봐야 하는가****결과가 두 줄이고, 값이 둘 다 `2`.**
**이 결과가 의미하는 것** — 두 노드가 각각 자기가 아는 멤버 수를 보고한다.
분단되면 **한쪽만 1 이 될 수도 있다.** 한 노드만 보면 분단을 놓친다.
JGroups 카운터도 지금 0 인 것을 봐 둔다. 나중에 오르는지 보려면 지금 값이 필요하다.
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
```
**실측** — [`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt)
```
vendor_jgroups_merge3_get_num_merge_events 0.0 (양쪽 노드)
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드)
```
## 1-6. 대조군 — 차단 전에 본 시험을 한 번 그대로 돌린다
**이 절을 건너뛰면 뒤의 숫자는 아무 의미가 없다.** A-0 에서 배운 규칙이다.
### 왜 임시 파드를 쓰나
- Keycloak 이미지에 `curl` 이 없다 → 파드 안에서는 못 친다
- 토큰을 단계 사이로 넘겨야 한다 → **한 셸 안에서** 다 해야 한다
- Service 로 가면 **어느 노드가 처리했는지 알 수 없다** → 파드 IP 로 직접 친다.
이 실험의 질문 자체가 「어느 노드인가」다
**하기** — 임시 파드를 띄우고 그 안의 셸에 들어간다
```bash
sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
**되돌리기** — 셸에서 `exit` 하면 `--rm` 이 파드를 지운다.
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다(히스토리에는 `$(...)` 문자열만 남는다).
> 길이만 확인하고 싶으면 밖에서:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
여기부터는 **파드 안 셸**이다. 프롬프트가 `/ $` 로 바뀐다.
**하기 ①**`keycloak-0` 에서 로그인한다. 이 노드가 세션의 출생지다
```sh
TOK=/realms/master/protocol/openid-connect/token
curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"
```
**형태** — 한 줄 JSON 이 나온다. 한 번은 통째로 본다
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"}
```
**어디를 봐야 하는가**`expires_in` 60, `refresh_expires_in` 1800.
**access token 은 60초짜리고 그동안은 서버에 안 물어본다.** 그래서 이 실험의
탐침은 access token 이 아니라 **refresh** 다 — refresh 는 노드가 세션 저장소를
실제로 뒤져야 답할 수 있다.
**하기 ②** — 토큰을 변수에 담는다
```sh
R=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 access=${#AT}자"
```
**형태**
```
refresh=1187자 access=2043자
```
길이가 `0자` 로 나오면 로그인이 실패한 것이다. `echo "$R"` 로 에러 본문을 본다.
**하기 ③** — 이 세션의 sid 를 확인한다. JWT 의 가운데 토막이 클레임이다
**미검증**
```sh
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**형태**
```json
{"exp":1757037660,"iat":1757037600,"jti":"...","iss":"http://10.42.1.43:8080/realms/master",
"sub":"...","typ":"Bearer","azp":"admin-cli","sid":"tAWs2gCPr6SOcD4jDR9-_CzB",...}
```
`"sid"` 값을 적어 둔다. **뒤에서 DB 를 직접 뒤질 때 이 값이 필요하다.**
> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다).
> `sid` 는 앞쪽에 있어서 대개 보인다. 그래도 안 보이면 sid 없이 진행하고,
> DB 확인은 [4-6](#4-6-기제-확정--db-는-지워졌는데-캐시가-답한다) 의 개수 세기로 대신한다.
**하기 ④** — 대조군 본 시험. **반대편 노드에서 refresh**
```sh
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
```
**실측** — [`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt)
```
sid tAWs2gCPr6SOcD4jDR9-_CzB
keycloak-1 에서 refresh: 200
```
**이 결과가 의미하는 것** — 차단 전에는 교차 노드 refresh 가 된다. **이 200 이
기준선이다.** 차단 후에도 200 이면 「원래 되던 게 그대로 되는 것」이고,
차단 후 400 이면 「내가 깨뜨린 것」이다. 대조군 없이는 이 구별이 안 된다.
> **refresh token 은 회전한다.** 갱신할 때마다 새 것이 나오므로 **매번 `RT` 를
> 다시 담는다.** 옛 것을 계속 쓰면 나중에 나오는 400 이 무효화 때문인지 재사용
> 때문인지 알 수 없게 된다.
**하기 ⑤** — 로그아웃 전파도 대조군을 잡는다
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
-d client_id=admin-cli -d "refresh_token=$RT"
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
```
**형태** — 정상 클러스터에서는
```
204
{"error":"invalid_grant","error_description":"Session not active"}
400
```
**이 결과가 의미하는 것**`keycloak-1` 에서 로그아웃하면 `keycloak-0` 에서도
갱신이 막힌다. **무효화가 전파된다.**
> 이 400 은 A-0 에서 측정한 값이다. A-1 의 대조군 기록
> ([`02-control-before-block.txt`](../../evidence/a1-jgroups-transport-block/02-control-before-block.txt))
> 에는 refresh 200 만 있고 로그아웃 단계는 없다. **당신은 지금 직접 재 두는
> 것이 낫다** — 뒤에서 이 자리가 200 으로 바뀌는 것이 이 실험의 결론이다.
`exit` 로 파드에서 나온다.
---
# 2. 주입 — NetworkPolicy 로 7800 만 막는다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```
## 2-1. 무엇을 적용하는가 — 먼저 읽는다
**확인**
```bash
cat deploy/lab/k8s/a1-block-jgroups-transport.yaml
```
핵심은 이 부분이다.
```yaml
spec:
podSelector: { matchLabels: { app: keycloak } }
policyTypes: [Ingress]
ingress:
- ports:
- { port: 8080, protocol: TCP } # HTTP — 열어둔다
- { port: 9000, protocol: TCP } # health+metrics — 열어둔다
# 7800 은 일부러 없다
```
### 개념 — NetworkPolicy 는 방화벽이 아니라 **허용 목록**이다
**「7800 을 거부」라고 쓸 방법이 없다.** 파드가 `policyTypes: [Ingress]` 를 가진
정책에 선택되는 순간 **모든 인바운드가 거부**되고, 규칙에 적힌 것만 통과한다.
그래서 7800 은 **빠뜨림으로써** 막힌다.
이 구조 때문에 두 허용 규칙이 **결정적**이다. 잘못 쓰면 분단된 클러스터가 아니라
**죽은 Keycloak 을 측정하게 된다.**
| 포트 | 빼면 |
|---|---|
| 8080 | Traefik·상대 노드의 REST 호출이 전부 끊긴다 |
| **9000** | **readiness 프로브가 실패해 kubelet 이 파드를 죽인다** — 엉뚱한 이유로 클러스터가 깨진다 |
**덤으로 57800 도 막힌다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000`
쓴다. 손으로 「7800 거부」 규칙을 쓰면 이걸 빠뜨리기 쉽지만, 허용 목록 방식은
8080·9000 외 전부 거부이므로 **자동으로 같이 막힌다.**
## 2-2. 적용
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
date '+%H:%M:%S 적용'
```
**실측** — [`03-block-applied.txt`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt)
```
networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
적용 시각: 11:38:08
```
**시각을 반드시 적어 둔다.** 뒤에서 Prometheus 로 「언제부터 변했나」를 볼 때
이 시각이 없으면 인과를 못 붙인다. 실제로 이 실험은 **시각이 겹친 것을 인과로
잘못 읽었다가 나중에 정정했다** — 해설 문서 4절의 ★ 정정.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
이 실험이 남긴 가장 큰 교훈이 여기 있다.
## 3-1. 정책이 어떤 파드를 잡았나
**확인**
```bash
sudo kubectl -n keycloak-lab get networkpolicy
sudo kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport
```
**형태**
```
PodSelector: app=keycloak
Allowing ingress traffic:
To Port: 8080/TCP
To Port: 9000/TCP
From: <any> (traffic not restricted by source)
Policy Types: Ingress
```
**어디를 봐야 하는가**`To Port` 목록에 **7800 이 없는 것**. 그게 전부다.
`PodSelector``app=keycloak` 인 것도 확인한다. 오타로 아무 파드도 안 잡히면
정책은 걸렸는데 아무 일도 안 일어난다.
## 3-2. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측**
```
파드 상태: keycloak-0 ready=true restarts=0
keycloak-1 ready=true restarts=0
```
**어디를 봐야 하는가****`RESTARTS` 가 여전히 0.**
**이 결과가 의미하는 것** — 9000 을 제대로 열어 둬서 readiness 프로브가 살아 있다.
여기서 `RESTARTS` 가 오르고 `READY``0/1` 이면 **9000 을 막은 것**이고,
그 상태에서 무엇을 재든 「분단된 클러스터」가 아니라 「죽은 파드」를 재는 것이다.
즉시 정책을 지우고 매니페스트를 다시 본다.
## 3-3. 열어 둔 포트는 살아 있나 · 막은 포트는 죽었나
**하기** — 임시 파드를 다시 띄운다
```bash
sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 --env="K0=$K0" --env="K1=$K1" --command -- sh
```
파드 안에서:
```sh
curl -s -o /dev/null -w '9000 %{http_code}\n' --max-time 5 "http://$K0:9000/health/ready"
curl -s -o /dev/null -w '8080 %{http_code}\n' --max-time 5 "http://$K0:8080/realms/master"
curl -s -o /dev/null -w '7800 %{http_code}\n' --max-time 5 "http://$K0:7800/" ; echo "exit=$?"
```
**실측** — [`03`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt) ·
[`05`](../../evidence/a1-jgroups-transport-block/05-conntrack-problem.txt)
```
9000 도달: 10.42.1.43:9000 health=200 / 10.42.0.35:9000 health=200
8080 도달: 10.42.1.43:8080 root=200 / 10.42.0.35:8080 root=200
7800: curl exit=7 (연결 실패)
```
**어디를 봐야 하는가** — 8080·9000 은 `200`, 7800 은 **curl 종료코드**.
| curl exit | 뜻 |
|---|---|
| `7` | 연결 자체가 안 됨 |
| `28` | `--max-time` 초과 = SYN 이 조용히 버려지고 있음 |
| `0` | **닿았다 — 정책이 안 걸린 것이다** |
7 이든 28 이든 「안 닿는다」이고, 정책은 걸린 것이다.
> **임시 파드는 정책에 안 잡힌다.** `podSelector` 가 `app=keycloak` 이라
> 이 파드의 인바운드는 제한되지 않는다. 그런데도 7800 에 못 닿는 이유는
> **정책이 목적지(Keycloak 파드)의 인바운드를 막기 때문**이다. 출발지가
> 무엇이든 상관없다.
`exit` 으로 나온다.
## 3-4. ★ 그런데 클러스터가 안 깨졌다
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**실측** — [`07-cluster-size.txt`](../../evidence/a1-jgroups-transport-block/07-cluster-size.txt)
```
=== vendor_cluster_size — 지난 25분 (차단 11:38:08) ===
keycloak-0: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
keycloak-1: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
```
**신규 연결은 분명히 막히는데 클러스터는 25분 내내 2 다.**
여기서 「실험 실패」라고 결론 내리면 틀린다. 파드 안 소켓을 본다.
**확인** — Keycloak 이미지에는 `ss` 도 없다. `/proc` 을 직접 읽는다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78
```
**실측**
```
=== /proc/net/tcp6 · 7800 = 0x1E78 ===
keycloak-0: ...2B012A0A:1E78 ...23002A0A:9C57 01 ← 01 = ESTABLISHED
keycloak-1: ...23002A0A:9C57 ...2B012A0A:1E78 01
(10.42.0.35:40023 → 10.42.1.43:7800)
```
**어디를 봐야 하는가** — 포트는 **16진수**다. `7800 = 0x1E78`. 세 번째 열
`01` 이 TCP 상태이며 **`01` = ESTABLISHED**.
**이 결과가 의미하는 것****기존 연결이 멀쩡히 살아 있다.**
## 3-5. 왜 그런가 — conntrack
```
패킷 도착
├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ] ← 여기서 통과해버린다
└─▶ [ NetworkPolicy 규칙 평가 ] ← 여기까지 오지 않는다
```
리눅스 방화벽은 성능을 위해 **이미 성립한 연결을 먼저 통과**시킨다.
NetworkPolicy 는 그 뒤에 있으므로 **신규 연결(SYN)만** 걸러낸다.
**확인** — 두 노드 모두에서 본다
```bash
sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
```
**실측** — [`05-conntrack-problem.txt`](../../evidence/a1-jgroups-transport-block/05-conntrack-problem.txt)
```
--- 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
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1
--- kc-lab-2 ---
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ...
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] ...
```
> `2>/dev/null` 은 `conntrack` 이 stderr 로 찍는 「N flow entries have been shown」
> 요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.
**어디를 봐야 하는가** — 상태 열.
| 상태 | 뜻 |
|---|---|
| `ESTABLISHED` | **양방향 통신 성립 — 규칙 평가를 건너뛴다** |
| `[ASSURED]` | 충분히 오래된 연결. 표가 꽉 차도 안 지워진다 |
| `SYN_SENT [UNREPLIED]` | 보냈는데 답이 없음 = **정책이 동작하고 있다는 증거** |
**`dport=57800` 도 있다.** FD_SOCK2 채널이며, 이것도 ESTABLISHED 로 살아 있다.
> **운영적 함의 — NetworkPolicy 는 이미 붙어 있는 것을 떼어내지 못한다.**
> 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면
> **새 연결만 막히고 진행 중인 연결은 계속된다.** 끊으려면 conntrack 을 지우거나
> 파드를 재시작해야 한다.
## 3-6. conntrack 항목을 지운다
위 출력의 **값을 그대로** 넣는다. 튜플이 정확해야 지워진다.
**하기**`kc-lab-1` 에서
```bash
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 40023 --dport 7800
sudo conntrack -D -p tcp -s 10.42.1.43 -d 10.42.0.35 --sport 7800 --dport 40023
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 50477 --dport 57800
```
`kc-lab-2` 에서도 같은 일을 한다. **서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있다.**
**형태**
```
conntrack v1.4.7 (conntrack-tools): 1 flow entries have been deleted.
```
**어디를 봐야 하는가** — 삭제 건수. `0 flow entries have been deleted`
**튜플이 틀린 것**이다. `--dport 7800` 만 주면 0 건이 나온다 — 실제로 원래
실행에서 그렇게 나왔다.
**확인** — 다시 세어 본다
```bash
sudo conntrack -L 2>/dev/null | grep -c 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep -c 7800'
```
### ★ 여기서 정직하게 알아 둘 것
원래 실행에서 **conntrack 을 지운 뒤에도 `vendor_cluster_size` 는 계속 2 였다.**
해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고
정정했다. 실제 하락은 **파드가 재시작된 4초 뒤**에 일어났다.
**즉, 이 절만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.**
확실하게 분단을 만드는 방법은 다음 절이다.
## 3-7. 정책이 걸린 채 파드를 재시작한다 — 이게 분단을 만든다
**하기**
```bash
date '+%H:%M:%S 재시작'
sudo kubectl -n keycloak-lab delete pod keycloak-0
```
**실측** — [`08-restart-forced-partition.txt`](../../evidence/a1-jgroups-transport-block/08-restart-forced-partition.txt)
```
재시작 시각: 11:46:07
pod "keycloak-0" deleted from keycloak-lab namespace
keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z
```
**되돌리기** — StatefulSet 이 같은 이름으로 곧바로 다시 만든다. 별도 조치 없음.
> 정책이 걸린 채 파드가 **스스로** 재시작하는 일도 있다. 원래 실행에서 실제로
> 그랬다(`startTime 11:44:23`). `RESTARTS` 나 `startTime` 이 이미 바뀌어 있으면
> `delete` 를 칠 필요도 없다.
**확인** — 새 파드가 뜨고 IP 가 바뀐 것을 본다
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
echo "$K0"
```
**실측**
```
10.42.1.67 ← 10.42.1.43 에서 바뀌었다
```
**★ IP 가 바뀌었다는 것을 반드시 반영한다.** 아까 띄운 임시 파드의 `K0` 환경변수는
낡았다. 뒤에서 파드를 다시 띄울 때 새 IP 로 띄운다. 이걸 놓치면 「아무 데도 안 닿음」을
「분단」으로 착각한다.
---
# 4. 효과를 관찰한다
## 4-1. 클러스터 크기가 떨어졌나
**확인** — 2~4분에 걸쳐 몇 번 친다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**실측**
```
keycloak-0: 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1
keycloak-1: ... 11:43:57=2 11:44:27=1 11:44:57=1 ... 11:47:27=1
```
**어디를 봐야 하는가****양쪽 다 1.** 서로를 멤버로 안 세고 있다.
## 4-2. 로그가 이유를 말한다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20
```
**실측**
```
GMS: JOIN(keycloak-0-26403) sent to keycloak-1-48749 timed out ← 10회
GMS: too many JOIN attempts (10): becoming singleton ← 포기
ISPN000094: new cluster view [keycloak-0-26403|0] (1) [keycloak-0-26403]
```
```bash
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**실측**
```
ISPN000094: [keycloak-1-48749|6] (1) [keycloak-1-48749]
```
**이 결과가 의미하는 것** — 새로 뜬 `keycloak-0` 은 DB 에서 `keycloak-1`
**찾았다.** 주소도 안다. 그런데 **JOIN 메시지가 7800 으로 안 간다.** 10번 시도하고
포기해서 혼자가 됐다. 디스커버리는 살아 있고 트랜스포트만 죽은 상태다.
## 4-3. split brain 을 DB 한 줄로 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**실측** — [`06`](../../evidence/a1-jgroups-transport-block/06-partition-observed.txt)
```
name | ip | coord
------------------+-----------------+-------
keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터
keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터
```
**어디를 봐야 하는가****`coord = t` 가 둘.**
**이 결과가 의미하는 것** — 교과서적인 split brain 이다. 서로를 못 보니까 각자
자기가 대장이라고 생각한다. **기준선(1-4)에서 `t` 가 하나였던 것과 대조한다.**
분단을 확인하는 가장 짧은 명령이 이것이다.
## 4-4. 분단된 노드가 스스로 빠진다 — 예상 못 한 발견
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
```
**실측** — [`11-service-impact.txt`](../../evidence/a1-jgroups-transport-block/11-service-impact.txt)
```
keycloak-0 false 0
keycloak-1 true 0
```
**왜 `keycloak-0` 만 false 인가** — 물어본다.
```bash
sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions
```
**실측** — 헬스 응답 원문
```json
{ "status": "DOWN",
"checks": [
{ "name": "Keycloak cluster health check", "status": "DOWN",
"data": { "Failing since": "2026-09-04 02:45:14,251" } },
{ "name": "Keycloak database connections async health check", "status": "UP" } ] }
```
**Keycloak 은 클러스터 분단을 readiness 로 신고한다.** DB 는 UP 인데 클러스터가
DOWN 이다. 그리고 쿠버네티스가 그 신고를 받아 처리한다.
**확인** — Service 에서 빠졌는지
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
**실측**
```
ready 주소: [10.42.0.35] ← keycloak-1 만 트래픽을 받는다
notReady : [10.42.1.67] ← keycloak-0 은 제외되었다
```
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다.
> `endpointslice` 를 본다.
**확인** — 밖에서는 멀쩡한가
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**실측**
```
https://auth.hyeonworks.com/realms/master HTTP 200
토큰 발급 HTTP 200
```
**이 결과가 의미하는 것** — **분단된 노드가 스스로 로드밸런서에서 빠졌고, 서비스는
계속됐다.** liveness 였다면 재시작을 반복했을 텐데, 재시작해도 안 나아지는 문제이므로
**readiness(격리)가 맞는 신호**다.
> **다만 비대칭이라서 살았다.** `keycloak-1` 은 「멤버가 하나 줄어든」 정상적인
> 사건이라 Ready 를 유지했고, `keycloak-0` 은 **합류 자체를 못 해** DOWN 이 됐다.
> 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다 — A-5 의 주제.
## 4-5. 본 시험 — 분단 상태에서 세션은 어떻게 되는가
**Service 를 쓰면 안 된다.** `keycloak-0` 이 NotReady 라 Service 로 보내면 전부
`keycloak-1` 로 간다. **파드 IP 로 직접** 친다.
**하기** — 새 IP 로 임시 파드를 다시 띄운다
```bash
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}')
sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
파드 안에서 **1-6 과 똑같은 순서**를 다시 한다.
```sh
TOK=/realms/master/protocol/openid-connect/token
# [1] keycloak-0 에서 로그인
R=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo # sid 를 적어 둔다
# [2] keycloak-1 에서 refresh
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
# [3] keycloak-1 에서 로그아웃
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
-d client_id=admin-cli -d "refresh_token=$RT"
# [4] keycloak-0 에서 재갱신 시도
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
```
**실측** — [`09-cross-node-under-partition.txt`](../../evidence/a1-jgroups-transport-block/09-cross-node-under-partition.txt)
```
[1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB
[2] keycloak-1 에서 refresh HTTP 200 ← 예측대로
[3] keycloak-1 에서 로그아웃 HTTP 204
[4] keycloak-0 에서 재갱신 시도 HTTP 200 ← 400 이어야 했다
```
**[2] 세션 공유 — 예측이 맞았다.** 클러스터가 갈라졌는데도 한쪽에서 만든 세션을
반대쪽이 갱신했다. **세션은 7800 으로 다니지 않는다.** 통념이 틀렸다.
**[4] 로그아웃 전파 — 예측이 틀렸다.** 대조군(1-6)에서 400 이던 자리가 200 이다.
**로그아웃한 세션이 반대편에서 살아 있다.**
`exit` 으로 나온다.
## 4-6. 기제 확정 — DB 는 지워졌는데 캐시가 답한다
**[4] 의 200 이 「로그아웃이 아예 안 됐다」는 뜻인지 확인해야 한다.** DB 를 본다.
sid 는 위 [1] 에서 적어 둔 값이다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag, last_session_refresh
from offline_user_session where user_session_id='nShl5TaBrZnKStDqaspjgmJB'"
```
**실측** — [`10-logout-not-propagated.txt`](../../evidence/a1-jgroups-transport-block/10-logout-not-propagated.txt)
```
user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows) ← DB 행은 삭제되었다
```
> sid 를 못 뽑았다면 개수로 본다. 로그인 전후·로그아웃 전후로 세 번 친다.
> ```bash
> sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
> -c "select offline_flag, count(*) from offline_user_session group by 1"
> ```
> 관리 API 호출도 세션을 만들기 때문에 **개수는 노이즈가 있다.** sid 로 보는 편이 정확하다.
**확인** — 노드별 세션 캐시 엔트리
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique{cache="sessions"}'
```
**실측**
```
keycloak-1 kc-lab-1 = 0
keycloak-0 kc-lab-2 = 1 ← 캐시에는 남아 있다
```
> 중괄호 때문에 `wget` 이 싫어하면 라벨을 빼고 걸러 낸다. **미검증**
> ```bash
> sudo kubectl -n observability exec deploy/prometheus -- \
> wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
> | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
> ```
**이 결과가 의미하는 것**
```
keycloak-1 로그아웃
├──▶ PostgreSQL 행 삭제 ✔ 되었다
└──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다
keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘
```
**룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다.** 캐시에 있으면 그걸로 답한다.
캐시 무효화는 **클러스터 메시지(7800)를 타고** 간다.
| | 세션 **조회** | 세션 **무효화** |
|---|---|---|
| 경로 | PostgreSQL | **클러스터 메시지 (7800)** |
| 7800 차단 시 | 정상 | **전파되지 않음** |
> **그럼 실제 사용자도 로그아웃이 안 되나?** 아니다. 당신은 Service 를 우회해
> **파드 IP 로 직접** 쳤다. 실제 사용자는 nginx → Traefik → Service 를 거치고,
> **NotReady 인 `keycloak-0` 은 거기서 빠져 있다.** 정문으로 들어오면 낡은 캐시에
> 닿지 않는다. 4-4 의 readiness 게이트가 막는다.
---
# 5. 복구
## 5-1. 정책을 지운다
**하기**
```bash
date '+%H:%M:%S 해제'
sudo kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```
**실측** — [`12-recovery.txt`](../../evidence/a1-jgroups-transport-block/12-recovery.txt)
```
해제 시각: 11:49:58
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace
```
## 5-2. 자동으로 다시 붙는지 본다
**확인** — 30초 간격으로 몇 번 친다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**실측**
```
+30초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
+60초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
+90초 keycloak-0=2 keycloak-1=2 ← 재형성
```
**90초 만에 자동으로 다시 붙었다. 사람 손이 필요 없었다.**
2~3분 기다려도 1 이면 [막히면](#막히면) 표를 본다.
## 5-3. 누가 붙였나 — MERGE3
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
```
**실측**
```
merge_events keycloak-0 = 1
merge_events keycloak-1 = 1
```
**어디를 봐야 하는가** — 기준선(1-5)에서 `0.0` 이던 값이 **1** 이다.
**이 결과가 의미하는 것** — MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는
JGroups 프로토콜이다. 주기적으로 다른 코디네이터의 존재를 확인하고, 발견하면
병합을 개시한다. **지표가 `0 → 1` 로 오른 것이 「MERGE3 가 실제로 일했다」는 증거다.**
## 5-4. 코디네이터가 하나로 돌아왔나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**실측**
```
keycloak-0-26403 | 10.42.1.67:7800 | t
keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다
```
**코디네이터가 `keycloak-1` 에서 `keycloak-0` 으로 넘어갔다.** 코디네이터는
특권이 아니라 **역할**이며 병합 시 재선출된다. 기준선과 달라도 정상이다.
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 정책 | `sudo kubectl -n keycloak-lab get networkpolicy` | `No resources found` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 |
| 디스커버리 | 위 5-4 | `coord = t`**하나** |
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod kc-probe` | `NotFound` (없어야 정상) |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
```bash
sudo kubectl -n keycloak-lab delete pod kc-probe --ignore-not-found
```
conntrack 은 지운 채로 두면 된다. **표는 새 패킷이 오면 다시 채워진다.**
> **이 실험이 재지 않은 것** — 4-6 에서 `keycloak-0` 캐시에 남아 있던 낡은 엔트리가
> 병합 후 어떻게 되는지는 측정하지 않았다. 궁금하면 5-2 뒤에
> `vendor_statistics_approximate_entries_unique{cache="sessions"}` 를 다시 본다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 정책을 걸었는데 지표가 안 변한다 | conntrack 의 ESTABLISHED 가 먼저 통과시킨다 | `sudo conntrack -L 2>/dev/null \| grep 7800` — 3-5 |
| `conntrack -D``0 flow entries` | 튜플이 틀렸다. `--dport` 만으로는 0건 | `-L` 출력의 src/dst/sport/dport 를 **그대로** 옮긴다 |
| conntrack 을 지웠는데도 계속 2 | **이 실험은 그것만으로 분단되는지 판정 못 했다** | 정책이 걸린 채 파드를 재시작한다 — 3-7 |
| 파드가 재시작을 반복한다 (`RESTARTS` 증가) | **9000 을 안 열었다.** readiness 실패 → kubelet 이 죽인다 | `describe pod` 의 Events. 매니페스트에 9000 이 있는지 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 임시 curl 파드를 띄우거나 Prometheus 에 묻는다 |
| refresh 가 계속 `keycloak-1` 로만 간다 | Service 로 보냈다. NotReady 파드는 빠진다 | **파드 IP 로 직접** — 4-5 |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** (`10.42.1.43 → 10.42.1.67`) | `get pod -o jsonpath='{.status.podIP}'` 다시 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — 0 이면 `--env` 가 빈 값 |
| 값이 빈 문자열인데 「변했다」로 읽힌다 | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/a1-jgroups-transport-block/03-block-applied.txt)) | 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다 |
| 복구 후 2~3분이 지나도 1 | MERGE3 주기 밖이거나 정책이 안 지워졌다 | `get networkpolicy` 로 먼저 확인 |
---
# 왜 이 가이드에는 스크립트가 없나
원래 실행은 임시 파드를 20초마다 띄워 지표를 긁는 스크립트로 관찰했다.
그 결과가 이렇다. — [`06-partition-observed.txt`](../../evidence/a1-jgroups-transport-block/06-partition-observed.txt)
```
+20초 suspected(k0 k1) = []
+60초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ]
+140초 suspected(k0 k1) = [0.0 ]
```
**빈 값과 개수가 안 맞는 값이 섞였다.** `kubectl run --rm` 은 매번 파드를 만들고
지우므로 느리고 경합이 있다. 게다가 판정 조건이 `[ "$R" != "0.0 0.0 " ]` 이어서
**빈 문자열을 「변화」로 읽고 즉시 빠져나왔다.**
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
> 그러라고 있는 것이다.
그래서 이 가이드는 관찰을 **한 번에 하나씩 손으로** 친다. 값이 이상하면 그 자리에서
보이고, 다시 칠 수 있고, 무엇을 봤는지 남는다.
---
# 다음
| 실험 | A-1 이 남긴 질문 |
|---|---|
| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | **양쪽이 동시에 NotReady 가 되는 경로가 있는가.** 여기서는 비대칭이라 살았다 |
| [A-2](../../experiment-a2-database-loss.md) DB 정지 | 캐시가 DB 와 대조하지 않으므로 **캐시에 있는 세션은 DB 없이도 읽힐 수 있다** |
| [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 같은 주입에서 세션 공유가 **깨져야** 한다. A-1 이 그 대조군 |
| 전부 | **주입이 실제로 걸렸는지 먼저 확인한다.** NetworkPolicy 는 기존 연결을 못 끊는다 |