--- id: b90d719f-39fb-4bab-a263-0e32eedb2b36 kind: SETUP slug: reproduce-a5-asymmetric-partition title: 한 방향만 끊어 보고 raw PREROUTING 까지 내려간다 topic: losing-a-node-or-the-store topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/b90d719f-39fb-4bab-a263-0e32eedb2b36/edit" pinnedVersions: - name: Keycloak version: 26.7.0 - name: curlimages/curl version: 8.11.1 source: - final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-5 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # 한 방향만 끊어 보고 raw PREROUTING 까지 내려간다 `iptables` 로 Keycloak 두 노드 사이의 JGroups 채널을 끊는 절차다. 주입 넷 중 앞의 둘은 일부러 실패시킨다 — 실패한 주입은 화면에 「아무 일도 없었다」로 보이므로 절차의 절반이 패킷 카운터를 읽는 일이다. 전 구간 약 30분. ## 관계 - **노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것** 기계를 끄지 않고 네트워크만 끊었을 때 무엇이 다른지를 그 기록과 견주면 갈린다. - **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다** 여기서 일부러 밟는 실패 둘이 그 아홉 건에 들어 있다. - **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다** 카운터로 주입을 먼저 판정하고 그다음에 클러스터를 보는 이 순서가 그 기준을 따른다. - **readiness 가 깨진 노드를 시야에서 먼저 치운다** 양방향 차단에서 `keycloak-1` 만 `0/1` 로 내려가고 정문이 계속 `200` 을 내는 상태를 이 절차가 만든다. - **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다** 앞 편이고, 거기서 NetworkPolicy 가 기존 연결을 못 끊은 것이 이 편의 출발 조건이다. ## 본문 ## 읽기 전에 — 어디서 치는가 `kubectl` 은 `[lab host]` 에서 친다. `iptables` 는 노드 자체를 건드리는 명령이라 게스트 셸이 필요하고, 두 노드에 각각 넣어야 하며 어느 노드에 넣느냐가 결과를 가른다. 이쪽 노드의 규칙은 `[kc-lab-1]` 에서 그대로 치고, 반대 노드의 규칙은 `ssh kc-lab-2` 로 붙어서 친다. 코드블록마다 어느 셸인지 붙여 두었다. **`[kc-lab-1]` 라벨이 붙은 블록은 게스트 셸이다.** lab host 에서 `ssh kc-lab-1` 로 들어가서 치고, 끝나면 `exit` 로 나온다. `ssh kc-lab-2 '…'` 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 `[lab host]` 로 두었다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. 가이드는 터미널 둘을 권한다 — 하나는 상주 탐침 파드용, 하나는 관찰용이다. 다만 이 절차에는 탐침 파드의 셸 안에서 치는 명령이 하나도 없다. `a5-probe` 는 `kubectl exec` 으로만 쓰므로 명령은 전부 `[lab host]` 에서 치고, `$K0` 와 `$K1` 도 그 셸의 변수다. 다른 터미널에서 관찰 §7 의 헬스체크를 치면 두 변수가 빈 문자열이라 양쪽 다 안 닿고, 그 화면을 「둘 다 DOWN」으로 읽게 된다. 실제로는 한쪽만 `DOWN` 이다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` | | 막는 포트 | `7800`(트랜스포트) 과 `57800`(FD_SOCK2 = `bind_port + 50000`) | | 주입 지점 | `raw PREROUTING`. `filter FORWARD` 는 kube-router 와 경쟁한다 | | 탐침 파드 | `a5-probe` — `curlimages/curl:8.11.1`, `sleep 1800`, `--restart=Never` | | 로그 시각 | 컨테이너는 UTC. KST 에서 9시간을 뺀다 | | 걸리는 시간 | 전 구간 약 30분 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | ## 이 실험이 가르는 것 A-1 이 답하지 못하고 넘긴 물음에서 출발한다. 가이드는 그 물음을 그대로 인용한다. > `keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를 못 해 `DOWN` 이 됐다. 양쪽이 동시에 `DOWN` 이 되는 경로가 있다면 전면 장애다. A-1 은 도구도 하나 남겼다. NetworkPolicy 는 기존 연결을 못 끊는다 — conntrack 의 `ESTABLISHED` 가 먼저 통과시킨다. 그래서 이번에는 `iptables` 로 직접 간다. 그런데 `iptables` 에도 벽이 셋 있다. 이 절차는 그 셋을 일부러 다시 밟는다. ```text 실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다 실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다 성공 수신측 노드의 raw PREROUTING → 19 패킷 그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다 ``` 끝나면 이것들을 자기 화면에서 본다 — 규칙을 넣었는데 0 패킷인 상태를 `iptables -L -n -v` 의 카운터에서, kube-router 가 내 규칙을 아래로 밀어내는 것을 `FORWARD` 체인의 줄 번호에서, JGroups 연결 방향이 A-1 때와 반대인 것을 `conntrack -L` 에서, 단방향 차단이 스스로 낫는 것을 뒤집힌 연결에서, `coord = t` 가 둘인 split brain 을 PostgreSQL `JGROUPS_PING` 에서, 그런데도 한쪽만 `DOWN` 이고 외부는 `200` 인 것을 `health/ready` 와 `endpointslice` 에서, `MergeView` 로 50초 만에 합쳐지는 것을 Keycloak 로그에서. ## 전제와 되돌리기 - `05-keycloak` 과 `06-observability` 가 끝나 있다. - A-1 을 먼저 해 두면 훨씬 이해가 빠르다. 이 절차는 A-1 이 실패한 곳에서 시작한다. - `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. 앞 절의 표가 어느 명령을 어느 셸에서 치는지 적는다. :::warning 이 절차는 Keycloak 클러스터를 실제로 분단시킨다. 실험대에서만 한다. ::: ### 1. 중단하는 방법을 먼저 확인한다 **목적** — 어느 단계에서든 두 노드의 규칙을 한꺼번에 걷어낼 수 있게 해 둔다. **이 실험대는 두 줄로 쳤다.** 둘째 줄에 SSH(Secure Shell, 원격 셸) 접속과 원격 셸의 인용과 세미콜론으로 이은 명령 둘이 한꺼번에 들어 있다. ```bash label="[kc-lab-1] 실제로 친 형태" sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD' ``` **따라 하는 사람은** 반대 노드 쪽을 나눈다. 먼저 붙고, 붙은 다음에 두 줄을 따로 친다. 행동 하나가 명령 하나가 된다. 이 나눈 형태는 이 실험대에서 치지 않았다. 이쪽 노드의 규칙을 먼저 걷어낸다. ```bash label="[lab host] ① 지우기 전에 무엇이 있었는지 본다" ssh kc-lab-1 'sudo iptables -S FORWARD' ``` ```bash label="[lab host] ② kc-lab-1 의 두 체인을 비운다" ssh kc-lab-1 'sudo iptables -t raw -F PREROUTING' ssh kc-lab-1 'sudo iptables -F FORWARD' ``` 그다음 반대 노드에 붙는다. ```bash label="[lab host] ③ 게스트 셸로 들어간다" ssh kc-lab-2 ``` 같은 두 줄을 원격 셸에서 친다. ```bash label="[kc-lab-2] ④ 두 체인을 비우고 나온다" sudo iptables -t raw -F PREROUTING sudo iptables -F FORWARD exit ``` **예상 결과** — `-F` 는 아무것도 찍지 않는다. ① 에 무엇이 있었는지가 유일한 기록이므로 건너뛰지 않는다. **왜 필요한가** — `-F FORWARD` 는 그 체인 전체를 비운다. 이 실험대의 `FORWARD` 정책은 `ACCEPT` 이고 실제 규칙은 kube-router 와 kube-proxy 가 자기 체인에 두므로 잠시 뒤 스스로 복구된다. 그래도 무엇을 지웠는지 모르면 나중에 클러스터가 이상할 때 이 명령 탓인지 가릴 수 없다. **문제가 생기면** — 해제했는데 2~3분째 클러스터가 안 붙으면 반대 노드 규칙이 남아 있는 것이므로 두 노드 모두에서 `-t raw -S PREROUTING` 을 본다. ## 주입 전에 같은 명령으로 먼저 본다 주입이 걸리기 전과 후가 화면상 똑같이 보이는 절차다. 먼저 본 것이 없으면 실패를 성공으로 읽는다. 순서는 이렇다. ```text 파드 IP·노드 → 디스커버리(DB) → 클러스터 뷰(로그) → 지표 → 연결 방향 → 밖 ``` ### 1. 파드 IP 와 노드 배치를 지금 다시 뽑는다 **무엇을 확인하는가** — 어느 파드가 어느 노드에 있고 IP(Internet Protocol 주소)가 무엇인지. ```bash label="[lab host] ① 배치와 IP" kubectl -n keycloak-lab get pods -o wide ``` ```bash label="[lab host] ② 뒤에서 계속 쓸 두 값을 셸 변수에 담는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') echo "K0=$K0 K1=$K1" ``` **출력에서 답이 되는 것** — `NODE` 열과 파드 번호의 짝이다. 실측은 이렇다. ```text 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` 로). 파드가 재시작되면 바뀌므로 여기 적힌 값을 쓰지 말고 ② 로 지금 뽑는다. `keycloak-1` 의 `RESTARTS` 가 1 인 것은 A-4 에서 노드를 껐다 켠 흔적이다. ### 2. 디스커버리와 클러스터 뷰 **무엇을 확인하는가** — 지금 코디네이터가 하나인지, 그리고 뷰 ID 가 몇인지. ```bash label="[lab host] ① 디스커버리는 DB 가 말한다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select name, ip, coord from jgroups_ping order by name" ``` ```bash label="[lab host] ② 뷰는 로그가 말한다" kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1 ``` **출력에서 답이 되는 것** — ① 의 `coord` 열과 ② 의 뷰 ID 다. 모양은 이렇고 숫자는 환경마다 다르다. ```text name | ip | coord ------------------+-----------------+------- keycloak-0-24309 | 10.42.1.77:7800 | f keycloak-1-45480 | 10.42.0.42:7800 | t (2 rows) ``` ```text ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)] ``` **이 결과가 뜻하는 것** — `coord` 열에 `t` 가 정확히 하나여야 한다. 둘이면 이미 갈라져 있고, 그 상태에서 주입해 봐야 아무것도 판정하지 못한다. 뷰 ID(`|13`)를 적어 둔다. 이 절차의 판정 기준이 그 숫자의 변화다. ### 3. 지표는 밖에서 Prometheus 에 묻는다 **무엇을 확인하는가** — 두 노드가 보는 클러스터 크기. Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). ```bash label="[lab host] ① 한 줄짜리 JSON 을 처음 한 번은 그대로 본다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` ```bash label="[lab host] ② 미검증 — 라벨과 값만 남겨 자르는 형태" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \ | tr ',' '\n' | grep -E '"pod":|^"[0-9]' ``` **출력에서 답이 되는 것** — `value` 배열의 두 번째 값이다. 모양은 이렇고 값은 환경마다 다르다. ```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"]}]}} ``` **이 결과가 뜻하는 것** — 두 줄이고 값이 둘 다 `2` 다. 원 실행에서는 이 값이 안 남았다. 값을 뽑으려고 붙인 파이썬 한 줄이 죽으면서 원본까지 같이 사라졌고, 화면에 남은 것은 스택트레이스뿐이다. ```text 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) ``` `wget` 원문을 먼저 보고 나중에 자르면 같은 일이 안 생긴다. ### 4. 연결 방향 — 이 절차에서 가장 중요한 사전 관측이다 **무엇을 확인하는가** — 두 파드 중 어느 쪽이 클라이언트이고 어느 쪽이 서버인지. **이 실험대는 두 줄로 쳤다.** ```bash label="[kc-lab-1] 실제로 친 형태" sudo conntrack -L 2>/dev/null | grep 7800 ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800' ``` :::warning **게스트에 `conntrack` 이 깔려 있지 않다.** cloud-init 이 까는 것은 `curl` 과 `nftables` 뿐이라 이 명령은 `sudo: conntrack: command not found` 로 끝나는데, `2>/dev/null` 이 그 한 줄을 지우고 종료 코드도 `0` 이다. 빈 화면이 「7800 연결이 없다」로 읽힌다. 두 노드에 먼저 `sudo apt install -y conntrack` 을 치거나, 패키지 없이 `sudo grep 7800 /proc/net/nf_conntrack` 으로 커널 표를 직접 읽는다. A-1 에서 재서 확인했다(observed). ::: **따라 하는 사람은** 둘째 줄을 나눈다. 붙고 나서 원격 셸에서 친다. 이 나눈 형태는 이 실험대에서 치지 않았다. ```bash label="[lab host] ① 반대 노드에 붙는다" ssh kc-lab-2 ``` ```bash label="[kc-lab-2] ② 같은 명령을 원격 셸에서" sudo conntrack -L 2>/dev/null | grep 7800 exit ``` **출력에서 답이 되는 것** — `dport=7800` 인 쪽이 서버이고 `src` 가 클라이언트다. ```text ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800 ──────────── ──────────────────── keycloak-0 가 클라이언트 keycloak-1 이 서버 ``` **이 결과가 뜻하는 것** — 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」 요약을 지우는 것이므로, 처음에는 빼고 쳐서 그 줄도 한 번 본다. ### 5. 밖에서 보이는 상태 **무엇을 확인하는가** — 정문이 지금 무엇을 답하는지. ```bash label="[lab host] ① 응답을 통째로 읽는 형태" curl -I --max-time 8 https://auth.hyeonworks.com/realms/master ``` ```bash label="[lab host] ② 여러 번 비교할 것이므로 코드만 뽑는 형태" curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master ``` **출력에서 답이 되는 것** — `200` 이어야 한다. **이 결과가 뜻하는 것** — 주입 뒤에도 이 값이 `200` 으로 남는 것이 이 절차의 결론 하나다. 지금 값을 안 잡아 두면 나중에 그것을 말할 수 없다. ## 주입 주입은 네 번이고 앞의 둘은 일부러 실패한다. 건너뛰지 않는다 — 이 실패의 모양을 봐 둬야 다음에 자기 규칙을 의심할 수 있다. **넷을 연달아 걸지 않는다.** 하나를 걸고 같은 번호의 주입 검증을 친 뒤 그 규칙을 걷어내고 다음으로 간다. 넷이 같은 두 포트를 막으므로 걷어내지 않고 쌓으면 어느 규칙이 잡았는지 가릴 수 없다. 치는 순서는 이렇다. ```text 주입 ① ─▶ 검증 §1 ─▶ 관찰 §1 ─▶ 주입 ① 의 ④⑤ 로 철거 ─▶ 주입 ② ─▶ 검증 §2 ─▶ 주입 ② 의 ④⑤ 로 철거 │ ┌────────────────────────────────────────────────────────────────────────────────┘ ▼ 주입 ③ ─▶ 검증 §3 ─▶ 관찰 §2~§4 ─▶ 주입 ④ (③ 의 규칙은 그대로 둔다) ─▶ 검증 §4 ─▶ 관찰 §5~§7 ─▶ 복구 ``` 주입 ③ 의 규칙만 예외다. 네 번째 주입이 「`kc-lab-1` 의 규칙은 그대로 두고」 반대 방향을 더하는 것이라, 셋째만 걷어내지 않고 이어 간다. ### 1. 시도 ① — filter 테이블 최상단 **목적** — `FORWARD` 최상단에 넣으면 conntrack 승인보다 먼저 평가되리라는 가설을 실제로 확인한다. ```bash label="[lab host] ① 7800 을 FORWARD 1번에 넣는다" ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP" ``` ```bash label="[lab host] ② 57800 도 같이 막는다" ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP" ``` ```bash label="[lab host] ③ 주입 시각" date '+%H:%M:%S 주입' ``` **예상 결과** — `iptables` 는 아무것도 찍지 않는다. `주입: 12:28:23` 만 남는다. 이 줄들은 「전제와 되돌리기」의 중단 절차처럼 나눠 치면 안 된다. 거기는 `ssh kc-lab-2` 로 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` 는 `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 한 줄 형태 그대로 친다. **왜 필요한가** — 57800 을 같이 막는 것은 FD_SOCK2(장애 감지 채널)가 `bind_port + 50000` 을 쓰기 때문이다. 7800 만 막으면 장애 감지는 계속 통해서 분단이 어정쩡해진다. **결과를 본 다음 반드시 걷어낸다.** 주입 검증 §1 을 치고 여기로 와서 ④ 와 ⑤ 를 친다. 시도 ② 는 같은 두 포트를 같은 노드에서 다시 막으므로, `FORWARD` 에 남은 `DROP` 둘을 그대로 두면 어느 테이블이 패킷을 잡았는지 가릴 수 없다. 치우는 명령은 넣을 때와 인자가 같아야 한다. ```bash label="[lab host] ④ 넣을 때와 같은 인자로 지운다" 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" ``` ```bash label="[lab host] ⑤ 지워졌는지 줄 번호로 확인한다" ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5' ``` **문제가 생기면** — ⑤ 에 `DROP` 이 남아 있으면 줄 번호로 지운다 — `sudo iptables -D FORWARD 3`. ### 2. 시도 ② — raw 테이블로 옮긴다 **목적** — conntrack 조회보다 먼저 평가되는 체인에 같은 규칙을 넣는다. netfilter 의 처리 순서가 그 근거다. ```text 패킷 도착 │ ├─▶ 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 가 밀어낸다) | | raw PREROUTING | 끊는다 | 없다 — CNI 가 안 쓰는 테이블 | ```bash label="[lab host] ① 테이블만 바꾸고 노드와 목적지는 그대로 둔다" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP" ``` ```bash label="[lab host] ② 57800 도 같이" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP" ``` ```bash label="[lab host] ③ 주입 시각" date '+%H:%M:%S 주입' ``` **예상 결과** — 규칙이 목록에 보이고, 카운터는 0 으로 남는다. 왜 0 인지는 다음 절이 답한다. **왜 필요한가** — 목적지와 노드는 그대로 두고 테이블만 바꿔야 무엇 때문에 결과가 달라졌는지 하나씩 가릴 수 있다. **이 규칙도 반드시 걷어낸다.** 주입 검증 §2 를 치고 여기로 와서 ④ 와 ⑤ 를 친다. **네 번째 주입이 `kc-lab-2` 의 `raw PREROUTING` 에 글자까지 같은 두 줄을 다시 넣는다.** 걷어내지 않으면 같은 규칙이 두 벌 쌓여 카운터가 네 줄로 갈리고, 검증 §4 의 예상 결과는 그 모양을 적어 두지 않았다. 지우기 전에 `-L` 로 무엇이 있는지 본다. `-F` 는 체인 전체를 비운다. ```bash label="[lab host] ④ 무엇이 있는지 먼저 본다" ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers' ``` ```bash label="[lab host] ⑤ 체인을 비운다" ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' ``` **문제가 생기면** — ④ 에 `kube-router` 규칙이 섞여 있으면 `-F` 대신 줄 번호로 내가 넣은 둘만 지운다. ### 3. 성공한 주입 — 수신측 노드의 raw PREROUTING **목적** — 7800 으로 실제로 들어가는 패킷을 잡는다. 목적지 파드가 있는 노드에서 잡아야 한다. ```bash label="[lab host] ① keycloak-1 의 IP 를 목적지로, 그 파드가 있는 노드에 넣는다" ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP" ``` ```bash label="[lab host] ② 57800 도 같이" ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP" ``` **`ssh kc-lab-1 "…"` 한 줄 형태인 까닭이 반대 노드 때와 같다.** `$K1` 은 `[lab host]` 셸의 변수다. `ssh kc-lab-1` 로 먼저 들어가서 치면 그 셸에는 그 변수가 없어 **빈 문자열**이 들어가고, `-d` 없는 규칙이 걸려 **7800 으로 가는 모든 패킷**이 끊긴다. 2026-09-17 에 게스트 셸에서 확인했다(observed). ```text 게스트 셸의 K1=[] ``` 큰따옴표가 값을 `[lab host]` 에서 펴서 보내므로 이 두 줄은 한 줄 형태 그대로 친다. ```bash label="[lab host] ③ 주입 시각" date '+%H:%M:%S 주입' ``` **예상 결과** ```text === keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 === 주입: 12:33:58 ``` **왜 필요한가** — 시도 ② 는 `10.42.1.77`(keycloak-0)을 목적지로 잡았는데 그것은 이 연결의 출발지다. 7800 으로 들어가는 패킷은 `10.42.0.42`(keycloak-1) 쪽으로 간다. ```text 내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다) 실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다) ``` **문제가 생기면** — 앞의 `conntrack -L` 출력을 다시 본다. 방향이 바뀌었으면 목적지도 바뀐다. ### 4. 네 번째 주입 — 양방향 **목적** — `kc-lab-1` 의 규칙은 그대로 두고 `kc-lab-2` 에 반대 방향을 더해 실제 분단을 만든다. ```bash label="[lab host] ① 반대 방향을 반대 노드에 더한다" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP" ``` ```bash label="[lab host] ② 57800 도 같이" ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP" ``` ```bash label="[lab host] ③ 주입 시각" date '+%H:%M:%S 주입' ``` **예상 결과** ```text === 양방향 차단 — 두 노드 모두에 raw DROP === 주입: 12:40:25 ``` **왜 필요한가** — 한 방향만 막으면 JGroups 가 열린 방향으로 다시 붙는다. 양쪽을 막아야 `coord = t` 가 둘이 된다. 2026-09-17 에 그 「다시 붙는」 과정을 초 단위로 봤다(observed). 한 방향만 막은 채로 두면 **잠깐 `1` 로 떨어졌다가 2분 안에 `2` 로 돌아온다.** ```text 주입 +60초 keycloak-1=1 keycloak-0=1 ← 여기서 멈추면 「분단됐다」로 읽는다 주입 +2분 keycloak-1=2 keycloak-0=2 주입 +3분 keycloak-1=2 keycloak-0=2 ``` **그 첫 값을 결론으로 삼지 않는다.** 한 방향 차단은 한 번 재서 판정하지 않는다. 2~3분 두고 값이 돌아오는지를 본다. 양방향으로 막은 뒤의 실측은 이렇다(observed). ```text kc-lab-1 의 raw PREROUTING 7800: 21건 57800: 21건 kc-lab-2 의 raw PREROUTING 7800: 19건 57800: 0건 멤버 수 keycloak-1=1 keycloak-0=1 jgroups_ping keycloak-0-13476 t · keycloak-1-8002 t ``` 분단을 가장 짧게 증명하는 것은 `coord` 가 둘 다 `t` 로 찍히는 순간이다. **`kc-lab-2` 쪽 57800 카운터만 `0` 인 것도 정상이다** — FD_SOCK2 가 그 방향에서 안 쓰였을 뿐이고, 7800 쪽이 올라갔으면 규칙은 걸렸다. 두 체인을 비우자 2분 안에 `2` 와 `coord` 하나로 돌아왔다(observed). **문제가 생기면** — 양쪽 카운터를 다 본다. 한쪽만 걸리면 그것은 여전히 단방향이다. ## 주입 검증 카운터가 유일한 판정 기준이다. 규칙이 목록에 보이는 것은 검증이 아니다. ### 1. 시도 ① — 넣은 직후에는 맞게 보인다 ```bash label="[lab host] ① 규칙과 카운터를 함께 본다" ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers' ``` 넣은 직후의 실측은 이렇다. ```text 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 ``` 여기서 만족하고 넘어가면 속는다. 1~2분 뒤 같은 명령을 다시 친다. ```text 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(Container Network Interface, 컨테이너 네트워크 플러그인 규격) 가 관리하는 체인과 경쟁하므로, 넣는 것으로 끝나지 않고 패킷 카운터로 확인해야 한다. 이 확인을 담았어야 할 `02-injection-verify.txt` 는 원 실험 시점에 0바이트로 저장됐다. 리다이렉션이 stdout 만 받았는데 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 사후에 다시 수집한 것이고, 원 시점의 `DROP` 규칙은 이미 없어서 재현되지 않는다. 남아 있는 사실은 kube-router 체인이 `FORWARD` 1번을 차지하고 있다는 것 하나이고, 따라 하는 사람은 실제 카운터를 볼 수 있다. ### 2. 시도 ② — CNI 와 경쟁하지도 않는데 0 이다 ```bash label="[lab host] ① raw 체인의 카운터" ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ``` ```text === [검증] 이번엔 패킷이 걸렸는가 === 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 ``` 앞에서 본 `conntrack` 이 답이다. `-d 10.42.1.77 --dport 7800` 은 존재하지 않는 패킷을 노린 규칙이었다. 규칙을 넣은 노드도 틀렸다. ### 3. 성공한 주입 — 처음으로 숫자가 올라간다 ```bash label="[lab host] ① 그 노드의 raw 체인" ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v' ``` ```text 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 는 이미 붙어 있는 연결을 쓰고 있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다. ```text === 차단 규칙 누적 카운터 === 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` 를 좁힌다 | ### 4. 양방향 주입 — 양쪽 카운터를 다 본다 ```bash label="[lab host] ① kc-lab-1" ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v' ``` ```bash label="[lab host] ② 반대 노드" ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v' ``` 한쪽만 걸리면 그것은 여전히 단방향이고, 그 상태에서 클러스터를 봐도 앞 단계와 같은 답만 나온다. ## 관찰 ### 1. 시도 ① 뒤 — 아무 일도 없다 **시도 ① 의 규칙이 `FORWARD` 에 걸려 있는 동안 친다.** 주입 §1 의 ④⑤ 로 걷어낸 뒤에 치면 규칙이 없는 상태를 재게 되는데, 화면은 규칙이 있든 없든 `외부 200` 이라 틀렸다는 신호가 안 나온다. ```bash label="[lab host] ① 25초 간격으로 몇 번 친다" kubectl -n keycloak-lab get pods | grep keycloak ``` ```bash label="[lab host] ② 같은 간격으로 밖에서" curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master ``` ```text +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. 성공한 단방향 주입 뒤 — 흔들렸다가 스스로 낫는다 같은 두 줄이 다른 답을 낸다. ```text +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초` 에 돌아온다. 주입이 닿기는 했고(시도 ① 의 아무 일 없음과 다르다) 스스로 나았다. ### 3. 로그 시각은 UTC 다 ```bash label="[lab host] ① 최근 20분의 뷰 변화" kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094 kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094 ``` 시각을 비교하려다 대부분 한 번은 틀린다. ```text 당신 셸의 date 12:33:58 KST 컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다 ``` Keycloak 컨테이너는 UTC(Coordinated Universal Time, 협정 세계시)로 찍는다. KST(Korea Standard Time, 한국 표준시)는 UTC+9 이므로 9시간을 빼서 맞춰 본다. 이걸 모르면 주입 전 로그와 주입 후 로그를 정반대로 가른다. ```text 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초였다. ```text 03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다 03:33:58 내 주입 ← 9초 뒤 ``` 앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였다. 로그 한 줄만 보고 「변화 없음」이라고 말하지 않고, 그 줄이 언제 생겼는지를 함께 본다. 결론 자체는 유지되지만 먼저 본 상태가 9초짜리였다는 사실을 같이 적어야 정직하다. ### 4. 왜 안 갈라졌나 — 연결이 뒤집혔다 앞에서 친 것과 똑같은 명령을 다시 친다. 그것이 대조하는 방법이다. ```bash label="[lab host] ① 차단 전에 친 것과 같은 명령" ssh kc-lab-1 'sudo conntrack -L 2>/dev/null | grep 7800' ``` ```text 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` 를 앞의 관측과 나란히 놓는다. ```text 차단 전: 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 label="[lab host] ① 의심 카운터" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members' ``` ```bash label="[lab host] ② 병합 횟수" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events' ``` ```text keycloak-0 merge_events=1.0 suspected=0.0 keycloak-1 merge_events=1.0 suspected=0.0 ``` `suspected = 0` 이므로 아무도 상대를 의심하지 않았다. 끊긴 적이 없는 것과 같다. `merge_events = 1` 은 9초 전 병합의 값이다. 한 방향만 막는 것으로는 JGroups 를 가를 수 없다 — 두 노드는 서로에게 연결을 걸 수 있으므로 한쪽 길이 막히면 다른 길로 간다. 운영에서는 단방향 방화벽 오설정이 자가 치유된다는 뜻이고, 분단을 재현하려는 실험자에게는 함정이다. ### 5. 양방향으로 막으면 갈라진다 ```bash label="[lab host] ① 25초 간격으로 파드" kubectl -n keycloak-lab get pods | grep keycloak ``` ```bash label="[lab host] ② Service 에서 빠졌는지" kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o "custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready" ``` ```bash label="[lab host] ③ 같은 간격으로 밖에서" curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master ``` ```text +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` 로 내려가서 안 돌아오고(단방향 때와 다르다), ready 주소가 둘에서 하나로 줄었으며, 외부는 계속 `200` 이다. `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 `endpointslice` 를 본다. 뷰도 갈린다. ```text 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. split brain 은 DB 한 줄로 확인한다 ```bash label="[lab host] ① 앞에서 친 것과 같은 쿼리" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select name, ip, coord from jgroups_ping order by name" ``` ```text 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` 가 둘이다. 앞에서 하나였던 것과 대조한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이고, 로그를 두 번 긁는 것보다 빠르며 지표보다 정확하다. ### 7. 그런데 한쪽만 `DOWN` 이다 Keycloak 컨테이너에 `curl` 이 없으므로 상주 파드를 띄운다. ```bash label="[lab host] ① 상주 탐침을 띄운다" kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \ --restart=Never --command -- sleep 1800 ``` ```bash label="[lab host] ② 뜰 때까지 기다린다" kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s ``` ```bash label="[lab host] ③ 두 노드의 헬스체크를 각각" kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready" kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready" ``` 일회용 파드를 안 쓰는 까닭은 원 실행이 `--rm -it` 로 했다가 붙지 못했기 때문이다. ```text 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 ``` 파드가 뜬 뒤 명령이 끝나 버리기 전에 붙어야 하는 경주가 된다. 관찰을 여러 번 반복할 것이라면 상주 파드가 항상 낫다. **상주 파드라서 `--rm` 이 없다.** 그래서 이 절차를 두 번째 칠 때는 앞선 실행의 `a5-probe` 가 같은 이름으로 이미 있어 ① 이 `AlreadyExists` 로 거절된다. 아래 원상복구 확인표의 삭제 명령을 먼저 치고 ① 로 돌아온다. ```text --- 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 가 모두 의존하는 하나인 데 비해 클러스터 멤버십은 서로 상대적이라는 것이다. ## 복구와 원상복구 확인표 ### 1. 두 노드의 규칙을 걷어낸다 **목적** — 양쪽 `raw PREROUTING` 을 비워 JGroups 가 다시 붙게 한다. 지우기 전에 무엇이 있는지 본다. ```bash label="[lab host] ① kc-lab-1" ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers' ``` ```bash label="[lab host] ② 반대 노드" ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers' ``` 그다음 해제 시각을 찍고 양쪽을 비운다. ```bash label="[lab host] ③ 해제 시각" date '+%H:%M:%S 해제' ``` ```bash label="[lab host] ④ kc-lab-1 을 비운다" ssh kc-lab-1 'sudo iptables -t raw -F PREROUTING' ``` ```bash label="[lab host] ⑤ 반대 노드를 비운다" ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING' ``` **예상 결과** — `해제: 12:44:37` 이 남고, 25초 간격으로 파드를 보면 이렇다. ```text +25초 keycloak-0:1/1 keycloak-1:0/1 +50초 keycloak-0:1/1 keycloak-1:1/1 → 복구 완료 ``` 50초, 사람 개입 없음. conntrack 은 건드리지 않아도 된다 — 차단이 풀리면 새 연결이 스스로 성립한다. **왜 필요한가** — 두 노드 중 한쪽만 비우면 여전히 단방향 차단이 걸려 있는 것이고, 클러스터는 열린 방향으로 붙어 겉보기에 복구된 것처럼 보인다. 여기서 비우는 것은 `raw` 뿐이다. 시도 ① 은 `kc-lab-2` 의 `filter FORWARD` 에 `DROP` 둘을 넣었는데 여기의 ④⑤ 는 그 체인을 안 본다. 주입 §1 의 ④ 를 그때 쳤으면 이미 없고, 건너뛰었으면 지금 남아 있다. 아래 원상복구 확인표의 `filter 규칙` 줄도 `kc-lab-1` 만 보므로 잡히지 않는다. 남아 있다면 「전제와 되돌리기」 §1 의 중단 절차를 친다 — 그쪽이 두 노드의 두 체인을 다 비운다. **문제가 생기면** — 2~3분째 안 붙으면 반대 노드 규칙이 남아 있다. 두 노드 모두에서 `-t raw -S PREROUTING` 을 본다. ### 2. 누가 붙였는지는 MergeView 가 말한다 ```bash label="[lab host] ① 양쪽 로그의 마지막 병합" kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1 kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -1 ``` ```text 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)`, 양쪽이 같은 줄이다. ```text [keycloak-0-24309|13] (2) ← 정상 [keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행 MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다 ``` 뷰 ID 는 단조 증가하므로 언제 몇 번 갈라졌는지를 로그만으로 셀 수 있다. ```bash label="[lab host] ② 캐시별 재분배 로그" kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6 ``` ```text [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 ... ``` 증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 모양도 한 번 본다. ```text 2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo ``` `ISPN100007` 은 병합 또는 코디네이터 변경 후의 캐시별 토폴로지 재계산이다. 캐시가 여럿이므로 로그도 캐시 수만큼 나오고, 한 줄만 보고 한 번 재분배됐다고 세면 틀린다. ### 3. 원상복구 확인표 `filter 규칙` 줄은 `kc-lab-1` 만 본다. 시도 ① 이 `kc-lab-2` 의 `FORWARD` 에 넣은 `DROP` 둘은 이 표로 안 잡히므로, 그쪽이 의심되면 「전제와 되돌리기」 §1 의 중단 절차를 친다. 반대 노드의 `FORWARD` 를 조회하는 명령은 원 가이드에 없다(unknown). | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | 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'` | 같음 | | 파드 | `kubectl -n keycloak-lab get pods` | `keycloak` 둘 다 `1/1 Running` | | 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 하나 | | 뷰 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 | | 지표 | `vendor_cluster_size` | 양쪽 `2` | | Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 둘 | | 탐침 파드 | `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` | ```bash label="[lab host] 탐침 파드를 지운다" kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found ``` ## 막히면 아래는 이 실험대가 실제로 겪은 증상이다. 마지막 줄만 A-2·A-3 에서 겪은 것을 옮겼다 — 탐침 파드를 같은 방식으로 띄우므로 여기서도 그대로 걸린다. | 증상 | 원인 | 확인 | |---|---|---| | 규칙을 넣었는데 아무 일도 없다 | 카운터가 0 이면 아무것도 측정 안 된 것 | `iptables -L -n -v` 의 `pkts` | | 내 규칙이 1번이 아니다 | kube-router 가 자기 체인을 재삽입한다 | `--line-numbers` 로 순서 | | `raw` 인데도 0 패킷 | 연결 방향을 잘못 짚었다 | `conntrack -L \| grep 7800` | | conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | 두 노드 모두에서 본다 | | 단방향인데 안 갈라진다 | 정상이다. 열린 방향으로 재연결한다 | `conntrack` 의 `src`/`dst` 뒤집힘 | | 로그에 변화가 없어 보인다 | 컨테이너 로그는 UTC. KST 와 9시간 차 | `logs` 의 시각에서 9를 뺀다 | | 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 언제 생겼는지 본다 | | `-D` 로 규칙이 안 지워진다 | 넣을 때와 인자가 다르다 | 줄 번호로 지운다: `-D FORWARD 3` | | 임시 파드에 attach 실패 | `--rm -it` 는 경주가 된다 | 상주 파드를 쓴다 | | `kubectl exec keycloak-0 -- curl` 이 `exit 127` | 이미지에 `curl` 도 `wget` 도 없다 | 탐침 파드나 Prometheus | | 지표를 파이썬으로 자르다 죽었다 | 원본까지 같이 사라진다 | `wget` 원문을 먼저 본다 | | `get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` | | 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 | | 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | 두 노드 모두 `-t raw -S PREROUTING` | | `a5-probe` 를 다시 못 만든다 | 앞선 실행의 파드가 그 이름으로 남아 있다 | `delete pod a5-probe --ignore-not-found` | ## 무엇이 관측이고 무엇이 아닌가 - (observed) 파드 IP `10.42.1.77` 과 `10.42.0.42` 와 노드 배치, 뷰 ID `13`→`14`→`15`, 시도 ① 의 `pkts 0` 과 kube-router 가 되찾은 `num 1`, 시도 ② 의 `pkts 0`, 성공한 주입의 `19 2938` 과 이어서 `19 1096` 과 `21 3058`, 주입 `12:28:23` 과 `12:33:58` 과 `12:40:25` 와 해제 `12:44:37`, 단방향에서 `+75초` 의 `0/1` 과 `+100초` 의 복귀, 양방향에서 `+100초` 이후 `0/1` 고정과 ready 주소 하나, `coord = t` 둘, 양쪽 헬스체크의 `UP` 과 `DOWN`, `suspected=0.0` 과 `merge_events=1.0`, 복구 50초, `MergeView` 뷰 `15`, `ISPN100007` 다섯 캐시, `MergeView` 가 `03:33:49` 에 생기고 주입이 `03:33:58` 인 9초 간격. - (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력. 가이드가 미검증으로 표시했다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `conntrack` 과 `iptables -F` 를 따로 치는 두 단계 형태도 이 실험대에서 치지 않았다. 반대 노드의 `filter FORWARD` 를 조회하는 명령은 원 가이드에 아예 없어서 확인표에 넣지 못했다. - 증거가 비어 있는 곳 — 시도 ① 의 카운터를 담았어야 할 `02-injection-verify.txt` 가 원 시점에 0바이트로 저장됐다. 지금 그 파일에 있는 것은 사후 수집이고 원 시점의 `DROP` 규칙은 재현되지 않는다. - 원 실행에 안 남은 것 — 주입 전 `vendor_cluster_size` 값. 파이썬 한 줄이 죽으면서 Prometheus 원본까지 함께 사라졌다. - 이 절차가 재지 않은 것 — 분단 중에 세션이 어떻게 되는지는 재지 않았다(그것은 A-1 의 주제다). 여기서는 누가 살아남는가만 봤다.