Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a1-jgroups-transport-block.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

51 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
95e29500-b535-4246-86db-d569ed814904 SETUP reproduce-a1-jgroups-transport-block 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다 session-custody-across-nodes Keycloak 두 노드가 같은 세션을 읽는 경로 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/95e29500-b535-4246-86db-d569ed814904/edit
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1
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 없는 kubectlpermission 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), 이 상대경로가 그대로 통하는 디렉터리에서 시작한다. 다른 디렉터리에서 치면 catkubectl apply 도 파일을 못 찾고 끝난다.

터미널은 둘을 연다. 하나는 임시 curl 파드용, 하나는 관찰용이다. 그래서 [lab host] 라벨이 붙은 블록이 [탐침 파드] 블록 사이에 끼어 있으면 관찰용 터미널에서 친다 — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 exit 를 블록으로 따로 적는다.

무엇
네임스페이스 keycloak-lab · 관측 스택은 observability
막는 포트 7800(트랜스포트). 57800(FD_SOCK2 = bind_port + 50000)도 함께 막힌다
주입 수단 NetworkPolicy a1-block-jgroups-transport — 허용 목록이라 8080·9000 만 연다
탐침 파드 kc-probecurlimages/curl:8.11.1, --rm -it, --restart=Never
분단 판정 jgroups_pingcoord = t 가 두 줄
걸리는 시간 전 구간 약 30분
도구 jq 가 이 실험대에 없다. Prometheus 출력은 trgrep 으로 자른다

공개 이름은 랩 안에서 안 풀린다. auth.hyeonworks.com 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 100.83.212.4 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 curl000 을 낸다(2026-09-17, 랩 호스트와 kc-lab-1 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 curl 에는 --resolve <이름>:443:192.168.122.10 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 200 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 --resolve 가 필요 없다.

이 실험이 가르는 것

A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다.

끊을 때 두 가지를 갈라야 한다.

   디스커버리   노드가 서로를 어떻게 찾는가   →  PostgreSQL 의 JGROUPS_PING 테이블
   트랜스포트   실제로 어떻게 말하는가        →  TCP 7800

트랜스포트만 막으면 DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태가 된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 까닭이 여기 있다.

절차를 끝까지 밟으면 NetworkPolicy 를 걸었는데도 클러스터가 안 깨지는 상태, coord = t 가 두 줄인 split brain, 분단인데도 교차 노드 refresh 가 200 인 것, 로그아웃했는데 반대편이 200 을 주는 것, 분단된 노드가 스스로 Service 에서 빠지는 것, 90초 만에 자동으로 다시 붙는 것을 자기 화면에서 보게 된다.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 단일 노드에서는 이 고장을 만들 수 없다.
  • kc-lab-2ssh 로 붙을 수 있어야 한다. conntrack 은 두 노드 모두에서 봐야 한다.

이건 상태를 부수는 실험이다. Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. 실험대에서만 한다. 중간에 그만두려면 아래 한 줄이면 된다.

kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport

주입 전에 같은 명령으로 먼저 본다

차단 후에 볼 것을 차단 전에 똑같은 명령으로 먼저 봐 둔다. 시험군만 재는 측정은 측정이 아니다.

노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험

1. 두 파드가 서로 다른 노드에 있는가

무엇을 보는가 — 파드 둘의 상태와 배치, 그리고 뒤에서 쓸 파드 주소.

kubectl get nodes
kubectl -n keycloak-lab get pods -o wide
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, RESTARTS0, NODE 열이 서로 다른지. 실측은 10.42.1.43 10.42.0.35 였다(observed).

이 값이 뜻하는 것RESTARTS 는 뒤에서 다시 센다. 이 값이 오르면 주입이 엉뚱한 곳을 건드렸다.

2. 기존 정책이 없는가

무엇을 보는가 — 네임스페이스에 이미 걸린 NetworkPolicy.

kubectl -n keycloak-lab get networkpolicy

어디를 보나 — 실측은 이렇다(observed, 01-baseline-cluster.txt).

No resources found in keycloak-lab namespace.

이 값이 뜻하는 것 — NetworkPolicy 는 합집합으로 허용되므로 두 개가 겹치면 무엇이 열려 있는지 한눈에 안 보인다.

3. 양쪽 클러스터 뷰가 같은 줄인가

무엇을 보는가 — 두 노드가 같은 멤버 목록을 찍고 있는지.

kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1

어디를 보나 — 두 줄이 완전히 같은지. 실측은 이렇다(observed).

  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 의 세 열.

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).

       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. 두 노드가 아는 멤버 수를 지표로 본다

무엇을 보는가 — 각 노드가 스스로 세는 클러스터 크기.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

라벨을 보고 나면 읽기 좋게 자른다. 아래 두 줄은 가이드가 미검증으로 표시했고(unknown), 둘째 줄은 jq 가 깔려 있는 환경용이라 이 실험대에서는 쓸 수 없다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
  | tr ',' '\n' | grep -E '"pod":|^"[0-9]'
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 인 것을 봐 둔다.

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).

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 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 「어느 노드인가」라서 파드 주소로 직접 친다.

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 가 여기서 생기므로 이 블록을 건너뛰면 다음 명령이 빈 문자열을 보낸다.

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 를 쓴다.

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).

  sid                    tAWs2gCPr6SOcD4jDR9-_CzB
  keycloak-1 에서 refresh: 200

이 값이 뜻하는 것 — 이 200 이 대조군이다. 차단 후에도 200 이면 원래 되던 것이 그대로 된 것이고, 차단 후 400 이면 내가 깨뜨렸다는 뜻이다.

로그아웃 전파도 대조군을 잡는다.

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

400 은 A-0 에서 측정한 값이고, A-1 의 대조군 기록에는 refresh 200 만 있고 로그아웃 단계가 없다. 그래서 차단 전에 직접 재 두는 편이 낫다.

대조군을 다 잡았으면 파드 셸에서 나온다. 나가지 않으면 kc-probe 가 그대로 살아 있어서, 뒤에서 같은 이름으로 다시 띄울 때 AlreadyExists 로 거절된다.

exit

주입

1. NetworkPolicy 로 7800 만 뺀다

목적 — 8080 과 9000 은 열어 둔 채 7800 으로 오는 새 연결만 막는다.

cat deploy/lab/k8s/a1-block-jgroups-transport.yaml

파일은 앞 31행이 영어 주석이고 그 아래가 매니페스트다. 주석 31행을 뺀 본문 전문은 이렇다. 매니페스트 안에 남은 # 주석도 파일에 적힌 영어 그대로다.

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.

① 에서 이 매니페스트를 못 찾으면 파일을 직접 만든다. 같은 경로를 에디터로 열어 위 열다섯 줄을 그대로 넣고 저장한다. printfcat <<EOF 로 찍어 내지 않는다. 주입 검증에서 describe 로 대조할 것이 이 줄들이고, 덧붙이는 형태로 쓰면 같은 절차를 두 번 밟았을 때 파일에 같은 내용이 두 벌 쌓인다. 파일을 여는 명령은 원 가이드에 없다(unknown).

매니페스트 안에 metadata.namespacekeycloak-lab 으로 적혀 있다. ② 의 apply 에는 -n 이 없으니 네임스페이스는 이 줄이 정한다. 손으로 옮겨 적다 이 줄을 빠뜨리면 정책이 default 네임스페이스에 걸린다. 거기에는 app=keycloak 인 파드가 없으니 정책은 만들어지는데 아무 파드도 안 잡힌다.

kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
date '+%H:%M:%S 적용'

예상 결과 — 실측은 이렇다(observed, 03-block-applied.txt).

networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
적용 시각: 11:38:08

왜 필요한가 — NetworkPolicy 는 방화벽이 아니라 허용 목록이다. 「7800 을 거부」라고 쓸 방법이 없고, 파드가 policyTypes: [Ingress] 를 가진 정책에 선택되는 순간 모든 인바운드가 거부되며 규칙에 적힌 것만 통과한다. 7800 은 빠뜨림으로써 막힌다. 그 구조 때문에 두 허용 규칙이 결정적이다 — 8080 을 빼면 Traefik 과 상대 노드의 REST 호출이 전부 끊기고, 9000 을 빼면 readiness 프로브가 실패해 kubelet 이 파드를 죽여 엉뚱한 이유로 클러스터가 깨진다. 덤으로 57800 도 막힌다. FD_SOCK2(장애 감지 채널)는 bind_port + 50000 을 쓰는데, 손으로 「7800 거부」 규칙을 쓰면 이 포트를 빠뜨리기 쉽고 허용 목록 방식은 8080·9000 외 전부 거부이므로 자동으로 같이 막힌다.

문제가 생기면 — 적용 시각을 반드시 적어 둔다. 이 실험은 시각이 겹친 것을 인과로 잘못 읽었다가 나중에 정정했다.

주입 검증

결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 본다. 「아무 일도 없었다」는 「영향이 없다」와 구별되지 않아서, 걸렸는지를 결과와 따로 확인하지 않으면 둘이 같은 화면으로 보인다. 원 가이드는 이 순서를 이 실험이 남긴 가장 큰 교훈으로 적고, 뒤의 실험 전부에 같은 확인을 요구한다. 이 편에서는 그 확인이 「안 걸렸다」를 먼저 알려 준다.

1. 정책이 어떤 파드를 잡았는가

kubectl -n keycloak-lab get networkpolicy
kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport

To Port 목록에 7800 이 없는 것, 그리고 PodSelectorapp=keycloak 인 것을 본다. 오타로 아무 파드도 안 잡히면 정책은 걸렸는데 아무 일도 일어나지 않는다.

kubectl -n keycloak-lab get pods -o wide | grep keycloak

실측은 keycloak-0 ready=true restarts=0, keycloak-1 ready=true restarts=0 이다(observed). RESTARTS 가 여전히 0 이면 9000 을 제대로 열어 뒀다는 뜻이다.

2. 열어 둔 포트는 살아 있고 막은 포트는 죽었는가

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
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=$?"

실측은 이렇다(observed).

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   (연결 실패)

curl 종료코드로 읽는다 — 7 은 연결 자체가 안 됨, 28--max-time 초과로 SYN 이 조용히 버려지고 있음, 0 은 닿았다는 뜻이라 정책이 안 걸렸다.

포트 셋을 다 봤으면 또 나온다. 이 파드도 kc-probe 라 살려 두면 본 시험에서 같은 이름이 겹친다.

exit

3. 그런데 클러스터가 안 깨졌다

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

실측은 이렇다(observed, 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

여기서 실험 실패라고 결론 내리면 틀린다. 파드 안 소켓을 본다. Keycloak 이미지에는 ss 도 없으므로 /proc 을 직접 읽는다.

kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78

실측은 이렇다(observed).

=== /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 다. 기존 연결이 멀쩡히 살아 있다.

까닭은 conntrack 이다.

   패킷 도착
      │
      ├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ]  ← 여기서 통과해버린다
      │
      └─▶ [ NetworkPolicy 규칙 평가 ]                     ← 여기까지 오지 않는다

4. conntrack 을 두 노드 모두에서 본다

여기부터는 게스트 셸이다. lab host 에서 먼저 들어간다.

ssh kc-lab-1
sudo conntrack -L 2>/dev/null | grep 7800

:::warning

게스트에 conntrack 이 깔려 있지 않다. cloud-init 이 까는 것은 curlnftables 뿐이라 이 명령은 sudo: conntrack: command not found 로 끝나는데, 2>/dev/null 이 그 한 줄을 지우고 종료 코드도 0 이다. 화면에는 아무것도 안 나오고, 그 빈 화면은 「7800 연결이 없다 = 정책이 먹었다」로 읽힌다 — 이 절이 보여 주려는 것과 정반대다. 2026-09-17 에 갓 만든 게스트에서 그대로 겪었다(observed). 두 노드 모두에 먼저 깐다.

:::

sudo apt install -y conntrack

깔고 나면 문서가 적은 두 줄이 그대로 나온다(2026-09-17, observed).

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).

sudo grep 7800 /proc/net/nf_conntrack

반대편 노드는 붙어서 친다. 아래 세 줄 형태는 이 실험대에서 치지 않았다(unknown) — 원래 실행은 ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800' 한 줄로 쳤고, 한 줄에 원격 접속과 원격 셸의 인용을 겹쳐 놓는 대신 행동 하나를 명령 하나로 나눴다.

ssh kc-lab-2
sudo conntrack -L 2>/dev/null | grep 7800

이 노드에도 따로 깔아야 한다. 패키지는 노드마다이고, 여기서도 안 깔린 채로 치면 위와 같은 빈 화면이 나온다.

exit

폴더 README 는 게스트 셸이 필요한 것을 nft·tc·systemctl 처럼 노드 자체를 건드리는 명령뿐이라고 적는데, conntrack 이 그 경우다.

실측은 이렇다(observed, 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/nullconntrack 이 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 이라 재시작할 때마다 바뀐다.

sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>

이 실험대에서는 7800 두 방향과 57800 한 방향, 이렇게 세 번 쳤다(observed). 그대로 옮겨 치면 자기 실험대에는 없는 튜플이라 0건이 나온다.

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 출력을 보고 옮긴다.

ssh kc-lab-2
sudo conntrack -L 2>/dev/null | grep 7800
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
exit

예상 결과 — 삭제 건수가 나온다.

왜 필요한가 — 위 소켓 출력이 보여 준 대로 ESTABLISHED 인 연결은 규칙 평가 앞에서 통과한다. 그 기록을 지워야 다음 패킷이 정책을 만난다.

문제가 생기면0 flow entries have been deleted 면 튜플이 틀린 것이고, --dport 7800 만 주면 0건이 나온다. 원래 실행에서 실제로 그렇게 나왔다.

여기에 정직하게 적어 둘 것이 있다(observed). 원래 실행에서 conntrack 을 지운 뒤에도 vendor_cluster_size 는 계속 2 였다. 해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 정정했고, 실제 하락은 파드가 재시작된 4초 뒤에 일어났다. 이 단계만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.

6. 정책이 걸린 채로 파드를 지워 분단을 확정한다

목적 — 새로 뜨는 노드가 7800 으로 JOIN 을 보내다 실패하게 만들어 분단을 확실히 만든다.

date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab delete pod keycloak-0
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).

재시작 시각: 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 wideIP 가 찍힐 때까지 ② 를 다시 친다. 빈 값을 그대로 두고 넘어가면 http://:8080 으로 요청이 나가고 그 실패를 분단으로 읽는다.

문제가 생기면 — 파드가 Pending 에서 안 넘어가면 노드 상태부터 본다.

관찰

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

실측은 이렇다(observed).

  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 이다. 서로를 멤버로 안 세고 있고, 로그가 까닭을 말한다.

kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20

실측은 이렇다(observed).

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 한 줄로 확인된다.

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).

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-0-26403 | 10.42.1.67:7800 | t     ← 코디네이터
 keycloak-1-48749 | 10.42.0.35:7800 | t     ← 코디네이터

coord = t 가 둘이다. 서로를 못 보니까 각자 자기가 대장이라고 생각한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이다.

분단된 노드는 스스로 트래픽에서 빠진다.

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).

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분 두고 다시 본다.

까닭은 헬스 본문에 있다.

kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions

이 명령이 내는 것은 조건표다(2026-09-17, observed).

Conditions:
  Type                        Status
  PodReadyToStartContainers   True
  Initialized                 True
  Ready                       False
  ContainersReady             False

아래 JSON 은 이 명령의 출력이 아니다. 헬스 엔드포인트를 직접 쳐야 나온다 — 그 명령이 원 가이드에 없어서 2026-09-17 에 만들어 쳤다(observed).

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).

{ "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 이고, 쿠버네티스가 그 신고를 받아 처리한다.

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).

NAME             ADDR                        READY
keycloak-lxk8h   [10.42.0.13],[10.42.1.17]   true,false

kubectl get endpoints 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다.

curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \
  https://auth.hyeonworks.com/realms/master

--resolve 는 위 「읽기 전에」가 적은 까닭으로 붙였다. 인증서 단계를 건너뛴 실험대라면 엣지가 443 을 안 들으므로 그래도 000 이고, 그때는 TLS 를 빼고 Host 헤더를 실어 엣지에 친다 — 2026-09-17 에 이 형태로 200 을 받았다(observed).

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 도 여기서 다시 넣는다 — 앞의 포트 시험 파드에는 안 넣었다.

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
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_tokenrefresh_token 이 화면에 그대로 나오고, 화면을 갈무리하면 증거 파일에도 그대로 들어간다. 2026-09-17 실행에서 실제로 그렇게 나와 길이만 남기고 가렸다(observed). 판정에 필요한 것은 코드와 error 뿐이므로, 갈무리할 생각이면 아래 형태로 친다.

:::

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] 이고, 나가지 않으면 kubectlpsql 도 없는 curlimages/curl 안에서 치게 된다.

exit

실측은 이렇다(observed, 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] 에서 로그아웃 전파는 예측이 틀렸다. 대조군에서 400 이던 곳이 200 이다.

[4] 의 200 이 로그아웃이 아예 안 됐다는 뜻인지 확인한다. sid 는 [1] 에서 JWT payload 를 풀어 화면에 찍고 적어 둔 그 값이다. 로그인할 때마다 새로 생기므로 자기 실행의 값을 넣는다.

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).

 user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows)                                            ← DB 행은 삭제되었다
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). 캐시에는 그 세션이 있다.

   keycloak-1 로그아웃
        │
        ├──▶ PostgreSQL 행 삭제                     ✔ 되었다
        │
        └──▶ keycloak-0 에게 "캐시에서 지워라"       ✗ 7800 이 막혀 못 갔다
                                                        │
   keycloak-0 은 자기 캐시로 200 을 준다  ◀────────────┘

룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 세션 조회는 PostgreSQL 을 타고 세션 무효화는 클러스터 메시지(7800)를 타므로, 7800 을 막으면 조회는 정상이고 무효화만 전파되지 않는다. 실제 사용자도 로그아웃이 안 되는지는 따로 답이 있다 — 아니다. 파드 주소로 직접 쳤기 때문이고, 실제 사용자는 nginx → Traefik → Service 를 거치는데 NotReady 인 keycloak-0 은 거기서 빠져 있다.

복구와 원상복구 확인표

1. 정책을 지우고 재형성을 기다린다

목적 — 7800 을 다시 열어 두 노드가 하나의 뷰로 합쳐지게 한다.

date '+%H:%M:%S 해제'
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

예상 결과 — 실측은 이렇다(observed, 12-recovery.txt).

해제 시각: 11:49:58
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace
  +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. 누가 붙였는지 카운터로 확인한다

목적 — 재형성이 저절로 일어난 것인지 확인한다.

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).

 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 -D0 flow entries 튜플이 틀렸다. --dport 만으로는 0건 -L 출력의 src/dst/sport/dport 를 그대로 옮긴다
conntrack 을 지웠는데도 계속 2 이 실험은 그것만으로 분단되는지 판정 못 했다 정책이 걸린 채 파드를 재시작한다
파드가 재시작을 반복한다 (RESTARTS 증가) 9000 을 안 열었다. readiness 실패 → kubelet 이 죽인다 describe pod 의 Events. 매니페스트에 9000 이 있는지
kubectl exec keycloak-0 -- curlexit 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:3811:52 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) 차단 11:38:08·재시작 11:46:07·해제 11:49:58, 25분 내내 2 이던 vendor_cluster_size, /proc/net/tcp601, conntrack 네 줄, coord = t 가 둘, 분단 중 교차 refresh 200 과 로그아웃 후 200, DB 행 0 과 캐시 1, 90초 재형성, merge_events0 → 1.
  • (unknown) tr ',' '\n' | grep -E 로 자른 Prometheus 출력과 jq 형태. jq 는 이 실험대에 아예 없다.
  • (unknown) ssh kc-lab-2 로 들어가서 conntrack -L 을 따로 치는 세 줄 형태. 이 실험대는 ssh kc-lab-2 '...' 한 줄로 쳤다.
  • (unknown) 매니페스트가 놓인 체크아웃의 위치와 그 디렉터리로 옮기는 명령, 그리고 파일이 없을 때 여는 에디터 명령. 원 가이드는 catkubectl apply 를 저장소 상대경로로만 적는다.
  • 이 실험이 판정하지 못한 것 — conntrack 삭제만으로 분단이 만들어지는지. 해설 문서가 「3분 뒤 분단」이라고 썼다가 정정했고, 실제 하락은 파드 재시작 4초 뒤였다.
  • 이 실험이 재지 않은 것 — keycloak-0 캐시에 있던 낡은 엔트리가 병합 후 어떻게 되는지. 궁금하면 재형성 뒤에 vendor_statistics_approximate_entries_unique{cache="sessions"} 를 다시 본다.
  • 가이드가 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). 원래 실행은 임시 파드를 20초마다 띄워 지표를 긁었고 +20초 suspected(k0 k1) = [] 처럼 빈 값과 개수가 안 맞는 값이 섞였다. 판정 조건이 [ "$R" != "0.0 0.0 " ] 이어서 빈 문자열을 변화로 읽고 즉시 빠져나왔다.