# 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 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: (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 는 기존 연결을 못 끊는다 |