# A-5 재현 가이드 — 한 방향만 끊어 보고, 왜 안 갈라지는지 직접 본다 해설 문서: [`docs/experiment-a5-asymmetric-partition.md`](../../experiment-a5-asymmetric-partition.md) · 증거 원문: [`docs/evidence/a5-asymmetric-partition/`](../../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`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다. - [`A-1`](a1-jgroups-transport-block.md) 을 먼저 해 두면 훨씬 이해가 빠르다. **이 실험은 A-1 이 실패한 자리에서 시작한다.** - 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다. - `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. **iptables 는 두 노드에 각각 넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.** - 터미널 **두 개**를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용. ## 주의 — 이건 상태를 부수는 실험이다 Keycloak 클러스터를 실제로 분단시킨다. **실험대에서만 한다.** 전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다. 중간에 그만두려면 두 줄이면 된다. ```bash 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 와 노드 — 이 값이 곧 규칙의 인자다 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o wide ``` **실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/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`). 파드가 재시작되면 바뀐다. 여기 적힌 값을 그대로 쓰지 말고 **지금 뽑는다** 변수로 잡아 둔다. **파드가 재시작되면 다시 잡는다.** ```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=$K0 K1=$K1" ``` > `keycloak-1` 의 `RESTARTS` 가 **1** 인 것도 보인다. A-4 에서 노드를 껐다 > 켠 흔적이다. **직전 실험의 잔재가 남아 있는지 확인하는 자리**이기도 하다. ## 1-2. 클러스터가 지금 하나인가 **확인** ```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-24309 | 10.42.1.77:7800 | f keycloak-1-45480 | 10.42.0.42:7800 | t (2 rows) ``` **어디를 봐야 하는가** — **`coord` 열에 `t` 가 정확히 하나.** 둘이면 이미 갈라져 있는 것이고, 그 상태에서 주입해 봐야 아무것도 판정 못 한다. **확인** — 로그가 말하는 뷰 ```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 ``` **형태** ``` 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 에 묻는 것이 가장 짧다. **확인** ```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","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` 는 이 실험대에 없다). **미검증** ```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]' ``` **어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`. > **★ 원 실행의 기준선은 남지 않았다.** 값을 뽑으려고 붙인 파이썬 한 줄이 > 죽었기 때문이다. — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt) > ``` > Traceback (most recent call last): > File "", line 3, in > 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. ★ 연결 방향 — 이 실험에서 가장 중요한 기준선 **어느 쪽이 클라이언트이고 어느 쪽이 서버인가.** 이걸 모르면 규칙을 엉뚱한 노드에 넣게 된다. **확인** — 두 노드 모두에서 본다 ```bash 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. 밖에서 보이는 상태 **확인** ```bash curl -I --max-time 8 https://auth.hyeonworks.com/realms/master ``` 여러 번 재서 비교할 것이므로 이제부터는 코드만 뽑는다. ```bash curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master ``` `200` 이어야 한다. --- # 2. 주입 시도 ① — `filter` 테이블 최상단 (실패한다) **일부러 실패하는 단계다.** 건너뛰지 않는 편이 좋다. 이 실패의 모양을 봐 둬야 다음에 자기 규칙을 의심할 수 있다. **되돌리기** — 먼저 읽어 둔다 ```bash ssh kc-lab-2 'sudo iptables -F FORWARD' ``` ## 2-1. 넣는다 A-1 의 NetworkPolicy 는 conntrack 에 막혔다. **`FORWARD` 최상단에 넣으면 conntrack 승인보다 먼저 평가될 것**이라는 게 이 시도의 가설이다. **하기** — `keycloak-0`(수신측이라고 **가정한** 쪽) 이 있는 노드에 ```bash 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번인가 ```bash ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers' ``` **실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/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분 뒤 같은 명령을 다시 ```bash 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`](../../evidence/a5-asymmetric-partition/02-injection-verify.txt) > 는 **원 실험 시점에 0바이트로 저장됐다.** 리다이렉션이 stdout 만 받았는데 > 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 **사후에 > 다시 수집한 것**이며, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다. > 남아 있는 것은 구조적 사실 하나 — kube-router 체인이 `FORWARD` 1번을 > 차지하고 있다는 것뿐이다. > **당신은 지금 실제 카운터를 볼 수 있다.** 이 단계를 건너뛰지 않는 이유다. ## 2-3. 그래서 아무 일도 안 일어난다 **확인** — 25초 간격으로 몇 번 본다 ```bash 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`](../../evidence/a5-asymmetric-partition/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. 치운다 **하기** ```bash 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. 넣는다 — 아직 같은 노드, 같은 목적지 **되돌리기** ```bash ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' ``` **하기** ```bash 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 주입' ``` **확인** — 카운터 ```bash ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ``` **실측** — [`03-raw-table-injection.txt`](../../evidence/a5-asymmetric-partition/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 를 다시 본다 **확인** ```bash 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. 치운다 ```bash 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. 넣는다 **되돌리기** ```bash sudo iptables -t raw -F PREROUTING ``` **하기** — 이번에는 **`kc-lab-1`(keycloak-1 이 있는 노드)** 에, `keycloak-1` 의 IP 를 목적지로 ```bash 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 주입' ``` **실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt) ``` === keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 === 주입: 12:33:58 ``` ## 4-2. 이번엔 걸리는가 — 카운터가 유일한 판정 기준이다 **확인** ```bash 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`](../../evidence/a5-asymmetric-partition/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초 간격 ```bash 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 ``` **실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt) ``` +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. 뷰가 변했나 — 그리고 로그 시각의 함정 **확인** ```bash 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`](../../evidence/a5-asymmetric-partition/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 와 **똑같은 명령**을 다시 친다. 그게 대조의 방법이다 ```bash sudo conntrack -L 2>/dev/null | grep 7800 ``` **실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/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 가 상대를 의심하기 전에 복구가 끝났다. **확인** — 의심 카운터로 뒷받침한다 ```bash 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`](../../evidence/a5-asymmetric-partition/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. 반대 노드에도 넣는다 **되돌리기** — 두 줄이다. 이제 양쪽에 있다 ```bash sudo iptables -t raw -F PREROUTING ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' ``` **하기** — `kc-lab-1` 의 규칙은 그대로 두고, `kc-lab-2` 에 반대 방향을 추가 ```bash 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 주입' ``` **확인** — 양쪽 카운터를 다 본다. **한쪽만 걸리면 그건 여전히 단방향이다** ```bash sudo iptables -t raw -L PREROUTING -n -v ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ``` **실측** — [`07-bidirectional-block.txt`](../../evidence/a5-asymmetric-partition/07-bidirectional-block.txt) ``` === 양방향 차단 — 두 노드 모두에 raw DROP === 주입: 12:40:25 ``` ## 6-2. 이번에는 갈라진다 **확인** — 25초 간격 ```bash 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` 를 본다. **확인** — 뷰 ```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-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 한 줄로 확인한다 **확인** ```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" ``` **실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/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` 이 없으므로 **임시 파드에서 묻는다.** **하기** — 상주 파드를 띄운다 ```bash 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 ``` **되돌리기** ```bash sudo kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found ``` > **왜 `--rm -it` 짜리 일회용 파드를 안 쓰나.** 원 실행이 그렇게 했다가 > 붙지 못했다. — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt) > ``` > warning: 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 > ``` > 파드가 만들어지고 **명령이 끝나 버리기 전에** 붙어야 하는 경주가 된다. > 관찰을 여러 번 반복할 것이라면 **상주 파드가 항상 낫다.** **확인** — 양쪽 헬스체크 ```bash 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`](../../evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png). --- # 7. 복구 ## 7-1. 지운다 **확인** — 지우기 전에 무엇이 있는지 본다 ```bash sudo iptables -t raw -L PREROUTING -n -v --line-numbers ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers' ``` **하기** ```bash 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`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt) ``` === 차단 해제 === 해제: 12:44:37 ``` ## 7-2. 자동으로 다시 붙는지 본다 **확인** — 25초 간격 ```bash 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` **확인** ```bash 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. 캐시별 재분배 로그 **확인** ```bash sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6 ``` **실측** — 해설 문서 4절 · 원문은 [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/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](../../experiment-a6-latency-injection.md) 지연 주입 | **`tc netem` 도 똑같은 함정.** 인터페이스를 잘못 고르면 카운터가 0 이다 | | [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 **세션이 갈라진다** | | [A-1](a1-jgroups-transport-block.md) 로 되돌아가서 | 분단 중 **로그아웃이 전파되지 않는다.** 그 상태를 여기서 다시 만들 수 있다 | | 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |