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>
1093 lines
46 KiB
Markdown
1093 lines
46 KiB
Markdown
# 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:38–11: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 는 기존 연결을 못 끊는다 |
|