--- id: 95e29500-b535-4246-86db-d569ed814904 kind: SETUP slug: reproduce-a1-jgroups-transport-block title: 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다 topic: session-custody-across-nodes topicName: Keycloak 두 노드가 같은 세션을 읽는 경로 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/95e29500-b535-4246-86db-d569ed814904/edit" pinnedVersions: - name: Keycloak version: 26.7.0 - name: curlimages/curl version: 8.11.1 source: - final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다 NetworkPolicy 로 8080 과 9000 만 열어 JGroups 트랜스포트인 TCP 7800 만 끊는 절차다. 그러고도 25분 동안 클러스터가 안 깨지는 것을 보고 conntrack 표에서 까닭을 찾은 뒤, 정책이 걸린 채로 파드를 지워 분단을 만든다. 전 구간 약 30분. ## 관계 - **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다** 이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다. - **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다** 정책을 걸었는데 `vendor_cluster_size` 가 25분 내내 2 였던 것이 그 아홉 건 중 하나다. - **readiness 가 깨진 노드를 시야에서 먼저 치운다** 분단된 노드가 스스로 Service 에서 빠져 정문이 `200` 을 유지한 까닭을 다룬다. - **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다** 이 편의 원래 실행이 빈 문자열을 「변화」로 읽고 빠져나온 판정 조건을 담고 있다. - **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다** 이 편의 대조군 값(`200`·`400`)이 거기서 나온다. 먼저 해 두지 않으면 차단 후의 `200` 이 무엇과 다른지 알 수 없다. - **한 방향만 끊어 보고 raw PREROUTING 까지 내려간다** 같은 7800 을 `iptables` 로 한 방향만 끊는 편이고, conntrack 으로 연결 방향을 먼저 보는 순서를 그쪽에서 다시 쓴다. ## 본문 ## 읽기 전에 — 어디서 치는가 `kubectl` 은 `[lab host]` 에서 친다. `conntrack` 은 노드 자체를 건드리는 명령이라 게스트 셸이 필요하고 두 노드 모두에서 봐야 한다 — 이쪽 노드는 그대로 치고 반대 노드는 `ssh kc-lab-2` 로 붙어서 친다. `kubectl` 에는 `sudo` 를 붙이지 않는다. root 홈에 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 코드블록마다 어느 셸인지 붙여 두었다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. 주입에 쓰는 매니페스트 경로 `deploy/lab/k8s/a1-block-jgroups-transport.yaml` 는 저장소 상대경로다. 체크아웃을 `kc-lab-1` 의 어디에 뒀는지는 원 가이드에 없고 거기로 옮기는 명령도 없으므로(unknown), 이 상대경로가 그대로 통하는 디렉터리에서 시작한다. 다른 디렉터리에서 치면 `cat` 도 `kubectl apply` 도 파일을 못 찾고 끝난다. 터미널은 둘을 연다. 하나는 임시 curl 파드용, 하나는 관찰용이다. 그래서 `[lab host]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` | | 막는 포트 | `7800`(트랜스포트). `57800`(FD_SOCK2 = `bind_port + 50000`)도 함께 막힌다 | | 주입 수단 | NetworkPolicy `a1-block-jgroups-transport` — 허용 목록이라 8080·9000 만 연다 | | 탐침 파드 | `kc-probe` — `curlimages/curl:8.11.1`, `--rm -it`, `--restart=Never` | | 분단 판정 | `jgroups_ping` 의 `coord = t` 가 두 줄 | | 걸리는 시간 | 전 구간 약 30분 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | ## 이 실험이 가르는 것 A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다. 끊을 때 두 가지를 갈라야 한다. ```text 디스커버리 노드가 서로를 어떻게 찾는가 → PostgreSQL 의 JGROUPS_PING 테이블 트랜스포트 실제로 어떻게 말하는가 → TCP 7800 ``` 트랜스포트만 막으면 DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태가 된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 까닭이 여기 있다. 절차를 끝까지 밟으면 NetworkPolicy 를 걸었는데도 클러스터가 안 깨지는 상태, `coord = t` 가 두 줄인 split brain, 분단인데도 교차 노드 refresh 가 `200` 인 것, 로그아웃했는데 반대편이 `200` 을 주는 것, 분단된 노드가 스스로 Service 에서 빠지는 것, 90초 만에 자동으로 다시 붙는 것을 자기 화면에서 보게 된다. ## 전제와 되돌리기 - `05-keycloak` · `06-observability` 가 끝나 있다. - 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 단일 노드에서는 이 고장을 만들 수 없다. - `kc-lab-2` 에 `ssh` 로 붙을 수 있어야 한다. **conntrack 은 두 노드 모두에서** 봐야 한다. **이건 상태를 부수는 실험이다.** Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. **실험대에서만 한다.** 중간에 그만두려면 아래 한 줄이면 된다. ```bash label="[lab host] 중간에 그만둘 때 치는 한 줄" kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport ``` ## 주입 전에 같은 명령으로 먼저 본다 차단 후에 볼 것을 차단 전에 똑같은 명령으로 먼저 봐 둔다. 시험군만 재는 측정은 측정이 아니다. ```text 노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험 ``` ### 1. 두 파드가 서로 다른 노드에 있는가 **무엇을 보는가** — 파드 둘의 상태와 배치, 그리고 뒤에서 쓸 파드 주소. ```bash label="[lab host] ① 노드를 본다" kubectl get nodes ``` ```bash label="[lab host] ② 파드가 어느 노드에 있는지 본다" 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 $K1" ``` **어디를 보나** — `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, `NODE` 열이 서로 다른지. 실측은 `10.42.1.43 10.42.0.35` 였다(observed). **이 값이 뜻하는 것** — `RESTARTS` 는 뒤에서 다시 센다. 이 값이 오르면 주입이 엉뚱한 곳을 건드렸다. ### 2. 기존 정책이 없는가 **무엇을 보는가** — 네임스페이스에 이미 걸린 NetworkPolicy. ```bash label="[lab host] 네임스페이스의 정책 목록" kubectl -n keycloak-lab get networkpolicy ``` **어디를 보나** — 실측은 이렇다(observed, `01-baseline-cluster.txt`). ```text No resources found in keycloak-lab namespace. ``` **이 값이 뜻하는 것** — NetworkPolicy 는 합집합으로 허용되므로 두 개가 겹치면 무엇이 열려 있는지 한눈에 안 보인다. ### 3. 양쪽 클러스터 뷰가 같은 줄인가 **무엇을 보는가** — 두 노드가 같은 멤버 목록을 찍고 있는지. ```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 ``` **어디를 보나** — 두 줄이 완전히 같은지. 실측은 이렇다(observed). ```text 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-0-30843` 의 뒤 숫자는 JGroups 가 붙인 것이고 파드가 재시작되면 바뀐다. 나중에 `keycloak-0-26403` 이 나오면 같은 파드의 새 인스턴스다. ### 4. 디스커버리 테이블에 둘 다 등록돼 있는가 **무엇을 보는가** — `JGROUPS_PING` 의 세 열. ```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" ``` **어디를 보나** — `coord` 열의 `t` 개수. 실측은 이렇다(observed). ```text name | ip | coord ------------------+-----------------+------- keycloak-0-30843 | 10.42.1.43:7800 | f keycloak-1-48749 | 10.42.0.35:7800 | t (2 rows) ``` **이 값이 뜻하는 것** — 여기서 셋이 서로 다른 것을 말한다. 로그는 그때 그렇게 보였다는 기록이고, 테이블은 지금 등록되어 있다는 것이며, 지표는 지금 그 노드가 그렇게 안다는 것이다. A-1 에서 이 셋이 갈린다. ### 5. 두 노드가 아는 멤버 수를 지표로 본다 **무엇을 보는가** — 각 노드가 스스로 세는 클러스터 크기. ```bash label="[lab host] ① 한 줄짜리 JSON 을 통째로 본다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` 라벨을 보고 나면 읽기 좋게 자른다. 아래 두 줄은 가이드가 미검증으로 표시했고(unknown), 둘째 줄은 `jq` 가 깔려 있는 환경용이라 이 실험대에서는 쓸 수 없다. ```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]' ``` ```bash label="[lab host] ③ jq 가 있는 환경이라면 이 형태 — 이 실험대에는 jq 가 없다" 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 label="[lab host] 병합 이벤트 계수기" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events' ``` 실측은 이렇다(observed, `02-control-before-block.txt`). ```text vendor_jgroups_merge3_get_num_merge_events 0.0 (양쪽 노드) vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드) ``` ### 6. 대조군 시험을 차단 전에 한 번 돌린다 **무엇을 보는가** — 정상 클러스터에서 교차 노드 refresh 와 로그아웃 전파가 어떤 코드를 주는지. 임시 파드를 띄우고 그 안에서 A-0 과 같은 순서로 로그인·refresh·로그아웃을 친다. 임시 파드인 까닭은 셋이다. Keycloak 이미지에 `curl` 이 없어 Keycloak 파드 안에서는 못 치고, 토큰을 단계 사이로 넘겨야 하니 한 셸 안에서 다 끝내야 하며, Service 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 「어느 노드인가」라서 파드 주소로 직접 친다. ```bash label="[lab host] 임시 curl 파드를 띄우고 그 안의 셸로 들어간다" 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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sh ``` 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 파드 안에서는 `echo ${#PW}` 로 길이만 본다. 파드 안 셸에서 먼저 `keycloak-0` 에 로그인한다. 뒤의 refresh 가 쓰는 `$TOK` 와 `$RT` 가 여기서 생기므로 이 블록을 건너뛰면 다음 명령이 빈 문자열을 보낸다. ```sh label="[탐침 파드] ① keycloak-0 에 로그인해 refresh token 을 잡는다" TOK=/realms/master/protocol/openid-connect/token 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"` 로 에러 본문을 본다. 같은 응답에 access token 도 들어 있지만 탐침으로 쓰지 않는다. access token 은 60초짜리고 그동안은 서버에 안 물어보므로, 노드가 세션 저장소를 실제로 뒤져야 답할 수 있는 refresh 를 쓴다. ```sh label="[탐침 파드] ② 반대편 노드에서 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') ``` 마지막 줄이 `RT` 를 다시 담는 까닭은 refresh token 이 회전하기 때문이다. 갱신할 때마다 새 것이 나오므로, 옛 것을 계속 쓰면 뒤에 나오는 `400` 이 무효화 때문인지 재사용 때문인지 갈리지 않는다. 실측은 이렇다(observed, `02-control-before-block.txt`). ```text sid tAWs2gCPr6SOcD4jDR9-_CzB keycloak-1 에서 refresh: 200 ``` **이 값이 뜻하는 것** — 이 `200` 이 대조군이다. 차단 후에도 200 이면 원래 되던 것이 그대로 된 것이고, 차단 후 400 이면 내가 깨뜨렸다는 뜻이다. 로그아웃 전파도 대조군을 잡는다. ```sh label="[탐침 파드] ③ 반대편에서 로그아웃하고 발급 노드에서 다시 갱신해 본다" 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` 다음에 이 두 줄이 나온다. ```text {"error":"invalid_grant","error_description":"Session not active"} 400 ``` 이 `400` 은 A-0 에서 측정한 값이고, A-1 의 대조군 기록에는 refresh `200` 만 있고 로그아웃 단계가 없다. 그래서 차단 전에 직접 재 두는 편이 낫다. 대조군을 다 잡았으면 파드 셸에서 나온다. 나가지 않으면 `kc-probe` 가 그대로 살아 있어서, 뒤에서 같은 이름으로 다시 띄울 때 `AlreadyExists` 로 거절된다. ```sh label="[탐침 파드] ④ 나온다. --rm 이 파드를 지운다" exit ``` ## 주입 ### 1. NetworkPolicy 로 7800 만 뺀다 **목적** — 8080 과 9000 은 열어 둔 채 7800 으로 오는 새 연결만 막는다. ```bash label="[lab host] ① 매니페스트를 먼저 읽는다" cat deploy/lab/k8s/a1-block-jgroups-transport.yaml ``` 파일은 앞 31행이 영어 주석이고 그 아래가 매니페스트다. 주석 31행을 뺀 본문 전문은 이렇다. 매니페스트 안에 남은 `#` 주석도 파일에 적힌 영어 그대로다. ```yaml label="a1-block-jgroups-transport.yaml — 주석을 뺀 본문 전문" apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: a1-block-jgroups-transport namespace: keycloak-lab spec: podSelector: matchLabels: app: keycloak policyTypes: [Ingress] ingress: - ports: - { port: 8080, protocol: TCP } # HTTP — must stay open - { port: 9000, protocol: TCP } # health + metrics — must stay open # 7800 is absent on purpose. That is the whole experiment. ``` ① 에서 이 매니페스트를 못 찾으면 파일을 직접 만든다. 같은 경로를 에디터로 열어 위 열다섯 줄을 그대로 넣고 저장한다. `printf` 나 `cat </dev/null | grep 7800 ``` :::warning **게스트에 `conntrack` 이 깔려 있지 않다.** cloud-init 이 까는 것은 `curl` 과 `nftables` 뿐이라 이 명령은 `sudo: conntrack: command not found` 로 끝나는데, **`2>/dev/null` 이 그 한 줄을 지우고 종료 코드도 `0` 이다.** 화면에는 아무것도 안 나오고, 그 빈 화면은 「7800 연결이 없다 = 정책이 먹었다」로 읽힌다 — 이 절이 보여 주려는 것과 정반대다. 2026-09-17 에 갓 만든 게스트에서 그대로 겪었다(observed). 두 노드 모두에 먼저 깐다. ::: ```bash label="[lab host] 없으면 먼저 깐다" sudo apt install -y conntrack ``` 깔고 나면 문서가 적은 두 줄이 그대로 나온다(2026-09-17, observed). ```text tcp 6 86398 ESTABLISHED src=10.42.0.13 dst=10.42.1.7 sport=59725 dport=7800 src=10.42.1.7 dst=10.42.0.13 sport=7800 dport=59725 [ASSURED] mark=0 use=1 tcp 6 85885 ESTABLISHED src=10.42.0.13 dst=10.42.1.7 sport=38439 dport=57800 src=10.42.1.7 dst=10.42.0.13 sport=57800 dport=38439 [ASSURED] mark=0 use=1 ``` **깔지 않고 보는 방법도 있다.** 커널 표를 직접 읽으면 같은 내용이 나온다 — 앞에 `ipv4 2` 두 칸이 더 붙고 `zone=0` 이 더 붙는 것만 다르다(observed). ```bash label="[kc-lab-1] 패키지 없이 커널 표를 직접 읽는다" sudo grep 7800 /proc/net/nf_conntrack ``` 반대편 노드는 붙어서 친다. 아래 세 줄 형태는 이 실험대에서 치지 않았다(unknown) — 원래 실행은 `ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'` 한 줄로 쳤고, 한 줄에 원격 접속과 원격 셸의 인용을 겹쳐 놓는 대신 행동 하나를 명령 하나로 나눴다. ```bash label="[lab host] ① 반대편 노드에 붙는다" ssh kc-lab-2 ``` ```bash label="[kc-lab-2] ② 같은 표를 본다" sudo conntrack -L 2>/dev/null | grep 7800 ``` **이 노드에도 따로 깔아야 한다.** 패키지는 노드마다이고, 여기서도 안 깔린 채로 치면 위와 같은 빈 화면이 나온다. ```bash label="[kc-lab-2] ③ 나온다" exit ``` 폴더 README 는 게스트 셸이 필요한 것을 `nft`·`tc`·`systemctl` 처럼 노드 자체를 건드리는 명령뿐이라고 적는데, `conntrack` 이 그 경우다. 실측은 이렇다(observed, `05-conntrack-problem.txt`). ```text --- 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」 요약을 지우려는 것이고, **처음에는 반드시 빼고 친다** — 위에서 본 `command not found` 가 거기로 나가기 때문이다. 상태 열을 읽는다 — `ESTABLISHED` 는 양방향 통신이 성립해 규칙 평가를 건너뛰고, `[ASSURED]` 는 표가 꽉 차도 안 지워지는 오래된 연결이며, `SYN_SENT [UNREPLIED]` 가 정책이 동작하고 있다는 것을 보여 준다. `dport=57800` 도 ESTABLISHED 로 살아 있다. NetworkPolicy 는 이미 붙어 있는 연결을 떼어내지 못하므로, 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면 새 연결만 막히고 진행 중인 연결은 계속된다. 같은 7800 을 한 방향만 끊는 A-5 가 NetworkPolicy 대신 `iptables` 의 raw PREROUTING 으로 간 까닭도 여기서 나왔다 — 그 체인은 conntrack 조회보다 먼저 평가된다. ### 5. conntrack 항목을 튜플 그대로 지운다 **목적** — 규칙 평가를 건너뛰게 만들던 기존 연결 기록을 표에서 없앤다. 네 값은 바로 위 4단계의 `conntrack -L` 출력에서 읽는다. 한 줄에 `src=` `dst=` `sport=` `dport=` 가 두 벌 나오는데 **앞의 한 벌이 원 방향, 뒤의 한 벌이 응답 방향**이고 두 벌을 각각 한 번씩 지운다. 포트는 ephemeral 이라 재시작할 때마다 바뀐다. ```bash label="[kc-lab-1] -L 출력의 네 값을 그대로 옮겨 한 항목씩 지운다" sudo conntrack -D -p tcp -s -d --sport --dport ``` 이 실험대에서는 7800 두 방향과 57800 한 방향, 이렇게 세 번 쳤다(observed). 그대로 옮겨 치면 자기 실험대에는 없는 튜플이라 0건이 나온다. ```text 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` 에서도 같은 일을 한다. 서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있으므로, 그쪽에 붙어 그쪽 `-L` 출력을 보고 옮긴다. ```bash label="[lab host] ① 반대편 노드에 붙는다" ssh kc-lab-2 ``` ```bash label="[kc-lab-2] ② 이 노드의 표를 다시 보고 네 값을 읽는다" sudo conntrack -L 2>/dev/null | grep 7800 ``` ```bash label="[kc-lab-2] ③ 읽은 값으로 지운다" sudo conntrack -D -p tcp -s -d --sport --dport ``` ```bash label="[kc-lab-2] ④ 나온다" exit ``` **예상 결과** — 삭제 건수가 나온다. **왜 필요한가** — 위 소켓 출력이 보여 준 대로 ESTABLISHED 인 연결은 규칙 평가 앞에서 통과한다. 그 기록을 지워야 다음 패킷이 정책을 만난다. **문제가 생기면** — `0 flow entries have been deleted` 면 튜플이 틀린 것이고, `--dport 7800` 만 주면 0건이 나온다. 원래 실행에서 실제로 그렇게 나왔다. 여기에 정직하게 적어 둘 것이 있다(observed). 원래 실행에서 conntrack 을 지운 뒤에도 `vendor_cluster_size` 는 계속 2 였다. 해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 정정했고, 실제 하락은 파드가 재시작된 4초 뒤에 일어났다. 이 단계만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다. ### 6. 정책이 걸린 채로 파드를 지워 분단을 확정한다 **목적** — 새로 뜨는 노드가 7800 으로 JOIN 을 보내다 실패하게 만들어 분단을 확실히 만든다. ```bash label="[lab host] ① 시각을 남기고 파드를 지운다" date '+%H:%M:%S 재시작' kubectl -n keycloak-lab delete pod keycloak-0 ``` ```bash label="[lab host] ② 새 파드와 새 주소를 다시 잡는다" kubectl -n keycloak-lab get pods -o wide | grep keycloak K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') echo "$K0" ``` **예상 결과** — 실측은 이렇다(observed, `08-restart-forced-partition.txt`). ```text 재시작 시각: 11:46:07 pod "keycloak-0" deleted from keycloak-lab namespace keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z ``` **왜 필요한가** — StatefulSet 이 같은 이름으로 곧바로 다시 만들지만 주소는 바뀐다. `10.42.1.43` 에서 `10.42.1.67` 로 바뀌었고, 새 주소를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿는다. `echo "$K0"` 가 빈 줄이면 파드에 아직 주소가 붙지 않은 것이다. 이 파드는 분단 때문에 Ready 가 되지 않으므로 `wait --for=condition=Ready` 로 기다리면 안 되고, 첫 줄의 `get pods -o wide` 에 `IP` 가 찍힐 때까지 ② 를 다시 친다. 빈 값을 그대로 두고 넘어가면 `http://:8080` 으로 요청이 나가고 그 실패를 분단으로 읽는다. **문제가 생기면** — 파드가 `Pending` 에서 안 넘어가면 노드 상태부터 본다. ## 관찰 ```bash label="[lab host] 두 노드가 아는 멤버 수" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` 실측은 이렇다(observed). ```text 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 이다. 서로를 멤버로 안 세고 있고, 로그가 까닭을 말한다. ```bash label="[lab host] 합류 시도와 클러스터 뷰를 한 번에 본다" kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20 ``` 실측은 이렇다(observed). ```text 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] ``` 새로 뜬 `keycloak-0` 은 DB 에서 `keycloak-1` 을 찾았고 주소도 안다. 그런데 JOIN 메시지가 7800 으로 안 간다. 디스커버리는 살아 있고 트랜스포트만 죽었다. 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" ``` 실측은 이렇다(observed, `06-partition-observed.txt`). ```text name | ip | coord ------------------+-----------------+------- keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터 keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터 ``` `coord = t` 가 둘이다. 서로를 못 보니까 각자 자기가 대장이라고 생각한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이다. 분단된 노드는 스스로 트래픽에서 빠진다. ```bash label="[lab host] Ready 와 재시작 횟수만 뽑아 본다" kubectl -n keycloak-lab get pods \ -o "custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \ | grep keycloak ``` **`-o` 값을 따옴표로 묶는다.** 묶지 않으면 zsh 가 `[0]` 을 글로브로 읽어 명령이 아예 안 돈다 — 이 실험대의 lab host 가 zsh 다(2026-09-17, observed). ```text zsh: no matches found: custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,... ``` 실측은 `keycloak-0 false 0`, `keycloak-1 true 0` 이다(observed, `11-service-impact.txt`). 2026-09-17 에도 같은 두 줄이 나왔다(observed). **그 `false` 는 파드를 지우자마자 나오지 않는다.** readiness 프로브가 `periodSeconds: 10` · `failureThreshold: 3` 이라 헬스가 먼저 `503` 이 되고 그로부터 최대 30초 뒤에 조건이 뒤집힌다. 2026-09-17 에 지운 지 60초 시점에는 헬스가 이미 `503` 인데 파드는 아직 `1/1 Ready=True` 였다(observed). **그 시점 화면만 보고 「분단이 서비스에 안 잡혔다」로 읽지 않는다** — 1~2분 두고 다시 본다. 까닭은 헬스 본문에 있다. ```bash label="[lab host] 파드 조건을 본다" kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions ``` 이 명령이 내는 것은 조건표다(2026-09-17, observed). ```text Conditions: Type Status PodReadyToStartContainers True Initialized True Ready False ContainersReady False ``` **아래 JSON 은 이 명령의 출력이 아니다.** 헬스 엔드포인트를 직접 쳐야 나온다 — 그 명령이 원 가이드에 없어서 2026-09-17 에 만들어 쳤다(observed). ```bash label="[lab host] 분단된 노드의 헬스 본문을 직접 받는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') kubectl -n keycloak-lab run hp --rm -i --restart=Never \ --image=curlimages/curl:8.11.1 --quiet --env="K0=$K0" \ --command -- sh -c 'curl -s "http://$K0:9000/health/ready"' ``` 실측은 이렇다(observed). ```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 이고, 쿠버네티스가 그 신고를 받아 처리한다. ```bash label="[lab host] Service 뒤에 누가 남았는지 본다" 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" ``` 여기도 `-o` 값을 따옴표로 묶는다. `[*]` 가 앞의 `[0]` 과 같은 이유로 글로브다. 실측은 `ready 주소: [10.42.0.35]`, `notReady : [10.42.1.67]` 다(observed). 2026-09-17 에는 이렇게 나왔다(observed). ```text NAME ADDR READY keycloak-lxk8h [10.42.0.13],[10.42.1.17] true,false ``` `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다. ```bash label="[lab host] 밖에서 정문을 친다" curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` 인증서 단계를 건너뛴 실험대라면 443 을 듣는 것이 없어 여기는 `000` 이다. 그때는 TLS 를 빼고 `Host` 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 `200` 을 받았다(observed). ```bash label="[lab host] TLS 없이 같은 것을 잰다" curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: auth.hyeonworks.com' http://192.168.122.10/realms/master ``` 실측은 정문이 `HTTP 200`, 토큰 발급도 `HTTP 200` 이다(observed). 분단된 노드가 스스로 로드밸런서에서 빠졌고 서비스는 계속됐다. liveness 였다면 재시작을 반복했을 텐데 재시작해도 안 나아지는 문제이므로 readiness 로 격리하는 쪽이 맞는 신호다. 다만 비대칭이라서 살았다 — `keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애이고, 그것이 A-5 의 주제다. 본 시험은 Service 를 쓰면 안 된다. `keycloak-0` 이 NotReady 라 Service 로 보내면 전부 `keycloak-1` 로 간다. 새 주소로 임시 파드를 다시 띄우고 파드 주소로 직접 친다. `keycloak-0` 을 지웠으므로 `$K0` 가 낡았다. 두 주소를 다시 잡고 그 값으로 파드를 띄운다. `$PW` 도 여기서 다시 넣는다 — 앞의 포트 시험 파드에는 안 넣었다. ```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}') 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=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sh ``` ```sh label="[탐침 파드] ② 분단 상태에서 네 단계를 차례로 친다" 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" ``` :::warning **[4] 는 응답 본문을 통째로 찍는다.** 분단 중에는 그 요청이 성공하므로 `access_token` 과 `refresh_token` 이 화면에 그대로 나오고, 화면을 갈무리하면 증거 파일에도 그대로 들어간다. 2026-09-17 실행에서 실제로 그렇게 나와 길이만 남기고 가렸다(observed). 판정에 필요한 것은 코드와 `error` 뿐이므로, 갈무리할 생각이면 아래 형태로 친다. ::: ```sh label="[탐침 파드] [4] 를 토큰 없이 재는 형태" curl -s -X POST "http://$K0:8080$TOK" \ -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT" \ -w '\n%{http_code}\n' -o /tmp/r sed -n 's/.*"error":"\([^"]*\)".*/error=\1/p' /tmp/r ``` `200` 이면 `error` 줄이 안 나오고, 그 없음이 곧 「로그아웃이 전파되지 않았다」다. 네 단계를 다 쳤으면 파드 셸에서 나온다. 뒤의 명령은 전부 `[lab host]` 이고, 나가지 않으면 `kubectl` 도 `psql` 도 없는 `curlimages/curl` 안에서 치게 된다. ```sh label="[탐침 파드] ③ 나온다. --rm 이 파드를 지운다" exit ``` 실측은 이렇다(observed, `09-cross-node-under-partition.txt`). ```text [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] 에서 로그아웃 전파는 예측이 틀렸다. 대조군에서 400 이던 곳이 200 이다. [4] 의 200 이 로그아웃이 아예 안 됐다는 뜻인지 확인한다. sid 는 [1] 에서 JWT payload 를 풀어 화면에 찍고 적어 둔 그 값이다. 로그인할 때마다 새로 생기므로 자기 실행의 값을 넣는다. ```bash label="[lab host] 그 세션의 DB 행이 남아 있는지 본다" 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='{{SID}}'" ``` 이 실험대의 값은 `nShl5TaBrZnKStDqaspjgmJB` 였다(observed). 실측은 이렇다(observed, `10-logout-not-propagated.txt`). ```text user_session_id | offline_flag | last_session_refresh -----------------+--------------+---------------------- (0 rows) ← DB 행은 삭제되었다 ``` ```bash label="[lab host] 세션 캐시 엔트리를 노드별로 본다" 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` 이다(observed). 캐시에는 그 세션이 있다. ```text keycloak-1 로그아웃 │ ├──▶ PostgreSQL 행 삭제 ✔ 되었다 │ └──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다 │ keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘ ``` 룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 세션 조회는 PostgreSQL 을 타고 세션 무효화는 클러스터 메시지(7800)를 타므로, 7800 을 막으면 조회는 정상이고 무효화만 전파되지 않는다. 실제 사용자도 로그아웃이 안 되는지는 따로 답이 있다 — 아니다. 파드 주소로 직접 쳤기 때문이고, 실제 사용자는 nginx → Traefik → Service 를 거치는데 NotReady 인 `keycloak-0` 은 거기서 빠져 있다. ## 복구와 원상복구 확인표 ### 1. 정책을 지우고 재형성을 기다린다 **목적** — 7800 을 다시 열어 두 노드가 하나의 뷰로 합쳐지게 한다. ```bash label="[lab host] ① 시각을 남기고 정책을 지운다" date '+%H:%M:%S 해제' kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport ``` ```bash label="[lab host] ② 30초 간격으로 멤버 수를 몇 번 친다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` **예상 결과** — 실측은 이렇다(observed, `12-recovery.txt`). ```text 해제 시각: 11:49:58 networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace ``` ```text +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 이면 MERGE3 주기 밖이거나 정책이 안 지워진 것이므로 `get networkpolicy` 부터 본다. ### 2. 누가 붙였는지 카운터로 확인한다 **목적** — 재형성이 저절로 일어난 것인지 확인한다. ```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' ``` **예상 결과** — 실측은 `merge_events keycloak-0 = 1`, `merge_events keycloak-1 = 1` 이다(observed). 주입 전에 `0.0` 이던 값이 1 이다. **왜 필요한가** — MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는 JGroups 프로토콜이고, `0 → 1` 로 오른 카운터가 그 프로토콜이 실제로 일했다고 말한다. **문제가 생기면** — 값이 그대로 0 이면 재형성이 다른 경로로 일어났거나 아직 안 일어난 것이므로 `vendor_cluster_size` 를 다시 본다. 코디네이터도 하나로 돌아온다. 실측은 이렇다(observed). ```text keycloak-0-26403 | 10.42.1.67:7800 | t keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다 ``` 코디네이터가 `keycloak-1` 에서 `keycloak-0` 으로 넘어갔다. 코디네이터는 특권이 아니라 역할이며 병합 시 재선출되므로 주입 전과 달라도 정상이다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | 정책 | `kubectl -n keycloak-lab get networkpolicy` | `No resources found` | | 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` | | Service | `kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** | | 클러스터 뷰 | `kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 | | 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 **하나** | | 지표 | `vendor_cluster_size` | 양쪽 `2` | | 임시 파드 | `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` | conntrack 은 지운 채로 두면 된다. 표는 새 패킷이 오면 다시 채워진다. ## 막히면 | 증상 | 원인 | 확인 | |---|---|---| | 정책을 걸었는데 지표가 안 변한다 | conntrack 의 ESTABLISHED 가 먼저 통과시킨다 | `sudo conntrack -L 2>/dev/null \| grep 7800` | | `conntrack -D` 가 `0 flow entries` | 튜플이 틀렸다. `--dport` 만으로는 0건 | `-L` 출력의 src/dst/sport/dport 를 **그대로** 옮긴다 | | conntrack 을 지웠는데도 계속 2 | **이 실험은 그것만으로 분단되는지 판정 못 했다** | 정책이 걸린 채 파드를 재시작한다 | | 파드가 재시작을 반복한다 (`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 로 직접** | | 재시작 뒤 아무 데도 안 닿는다 | **파드 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` 가 빈 값 | | 값이 빈 문자열인데 「변했다」로 읽힌다 | **원래 실행이 이 실수를 했다** | 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다 | | 복구 후 2~3분이 지나도 1 | MERGE3 주기 밖이거나 정책이 안 지워졌다 | `get networkpolicy` 로 먼저 확인 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 11:38–11:52 KST` 에 돈 한 번의 실행에서 나왔다(observed). - (observed) 차단 `11:38:08`·재시작 `11:46:07`·해제 `11:49:58`, 25분 내내 2 이던 `vendor_cluster_size`, `/proc/net/tcp6` 의 `01`, conntrack 네 줄, `coord = t` 가 둘, 분단 중 교차 refresh `200` 과 로그아웃 후 `200`, DB 행 0 과 캐시 1, 90초 재형성, `merge_events` 가 `0 → 1`. - (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력과 `jq` 형태. `jq` 는 이 실험대에 아예 없다. - (unknown) `ssh kc-lab-2` 로 들어가서 `conntrack -L` 을 따로 치는 세 줄 형태. 이 실험대는 `ssh kc-lab-2 '...'` 한 줄로 쳤다. - (unknown) 매니페스트가 놓인 체크아웃의 위치와 그 디렉터리로 옮기는 명령, 그리고 파일이 없을 때 여는 에디터 명령. 원 가이드는 `cat` 과 `kubectl apply` 를 저장소 상대경로로만 적는다. - 이 실험이 판정하지 못한 것 — conntrack 삭제만으로 분단이 만들어지는지. 해설 문서가 「3분 뒤 분단」이라고 썼다가 정정했고, 실제 하락은 파드 재시작 4초 뒤였다. - 이 실험이 재지 않은 것 — `keycloak-0` 캐시에 있던 낡은 엔트리가 병합 후 어떻게 되는지. 궁금하면 재형성 뒤에 `vendor_statistics_approximate_entries_unique{cache="sessions"}` 를 다시 본다. - 가이드가 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). 원래 실행은 임시 파드를 20초마다 띄워 지표를 긁었고 `+20초 suspected(k0 k1) = []` 처럼 빈 값과 개수가 안 맞는 값이 섞였다. 판정 조건이 `[ "$R" != "0.0 0.0 " ]` 이어서 빈 문자열을 변화로 읽고 즉시 빠져나왔다.