Files
keycloak-pattern/docs/experiment-a1-jgroups-transport-block.md
T
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
The audit found ~80 placeholders, and the damaging ones were where the
measuring apparatus itself was prose rather than a command:

  a6  "( curl ... ) & 를 20개 띄우고 wait"  — the 22.2s headline came from this
  a3  "<로그인 반복, sid 를 /tmp/sids 에>"  — the whole RPO measurement
  a3  "<sid 목록>"                          — the control it is compared against
  a5  "<수신 파드IP>"                       — the injection
  a8  writes /tmp/tok, reads /tmp/rt        — self-inconsistent, sent an empty token
  b3  $KC / $RT / $NEW never assigned
  c2  bare kcadm.sh with no kubectl exec
  a1  conntrack tuples written by hand, though the direction flips per restart

Each is now a shell-expandable form: pod IPs from jsonpath, the admin password
from the secret, ids from kcadm --format csv, conntrack tuples derived from
"conntrack -L" with awk rather than transcribed.

Then the rewritten commands were executed against the live cluster, and one
of them failed — the 20-way load generator, written as "kubectl run --rm -i",
lost its output stream twice in a row. That is a trap this series already hit
once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that
collects into a file and is cat-ed once; verified 20/20 lines.

Evidence: docs/evidence/followup/05-command-reproducibility.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:01:54 +09:00

21 KiB
Raw Blame History

A-1 — 노드 간 통신(TCP 7800)을 끊으면 무엇이 깨지는가

브랜치 feature/keycloak-a1-jgroups-transport-block · 증거 docs/evidence/a1-jgroups-transport-block/ · 2026-09-04 11:3811:52 KST · Keycloak 26.7.0 / Infinispan 16.0.12

맥락은 session-lab-prerequisites.md, 기준선은 experiment-00-session-replication.md.


0. 결론부터

예측 결과
세션 공유는 안 깨진다 맞다. 교차 노드 refresh 가 200
로그아웃 전파는 안 깨진다 틀렸다. 400 이어야 할 것이 200
NetworkPolicy 만으로는 분단이 일어나지 않는다 (예상 못 함)
분단된 노드가 스스로 로드밸런서에서 빠진다 (예상 못 함)

예측 하나가 빗나갔고, 예상하지 못한 것이 둘 나왔다. 그중 하나는 실험 방법 자체를 무효화할 뻔했다.


1. 왜 이 실험인가

A-0 에서 세션은 Infinispan 복제가 아니라 PostgreSQL 로 공유된다는 것을 측정했다. 그렇다면 통념과 정면으로 어긋난다.

통념 (Keycloak 24 이전 자료) 세션은 7800 으로 복제된다 → 막으면 세션 공유가 깨진다
A-0 측정 세션은 DB 로 공유된다 → 막아도 안 깨진다

둘 중 하나는 틀렸고, 이 실험이 판정한다.


2. 기준선

=== [기준선 1] 클러스터 뷰 ===
  keycloak-0: [keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
  keycloak-1: [keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]

=== [기준선 2] JGROUPS_PING ===
 keycloak-0-30843 | 10.42.1.43:7800 | f
 keycloak-1-48749 | 10.42.0.35:7800 | t     ← 코디네이터는 하나

=== [기준선 4] JGroups 지표 (양쪽 동일) ===
  fd_sock2_get_num_suspected_members   0.0
  merge3_get_num_merge_events          0.0
  nakack2_get_xmit_table_missing       0.0

대조군 — 차단 전에 같은 절차를 그대로 한 번 돌린다.

=== [대조군] keycloak-0 로그인 → keycloak-1 에서 refresh ===
  sid                    tAWs2gCPr6SOcD4jDR9-_CzB
  keycloak-1 에서 refresh: 200

A-0 에서 배운 규칙이다 — 시험군만 재는 측정은 측정이 아니다.


3. 주입 — NetworkPolicy 로 7800 만 막는다

kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.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 이 파드를 죽인다 — 엉뚱한 이유로 클러스터가 깨진다

적용 직후 확인했다.

파드 상태:  keycloak-0 ready=true restarts=0
            keycloak-1 ready=true restarts=0
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

주입이 의도한 것만 건드렸음을 먼저 확인한 뒤에 결과를 해석한다.


4. 문제 ① — NetworkPolicy 만으로는 분단이 안 된다

가장 중요한 발견이며, 하마터면 실험 전체를 무효로 만들 뻔했다.

차단 후 지표가 꿈쩍도 하지 않았다. 신규 연결은 분명히 막히는데.

=== 7800 신규 연결 ===
  10.42.1.43:7800  curl exit=7   (연결 실패)
  10.42.1.43:9000  curl exit=28  (연결됨, telnet 이라 대기 → 타임아웃)

그런데 파드 내부 소켓을 보니

=== /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)

기존 연결이 멀쩡히 살아 있다.

왜 그런가 — conntrack

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

리눅스 방화벽은 성능을 위해 이미 성립한 연결을 먼저 통과시킨다. NetworkPolicy 는 그 뒤에 있으므로 신규 연결(SYN)만 걸러낸다.

=== conntrack 확인 ===
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ... [ASSURED]
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 ... [ASSURED]
tcp 6    33 SYN_SENT    src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED]
                        ─────────────────────────────────────────────────────────────
                        신규 연결은 응답을 못 받는다 = 정책이 동작하고는 있다

운영적 함의 — NetworkPolicy 는 이미 붙어 있는 것을 떼어내지 못한다. 보안 사고 대응으로 "지금 당장 이 통신을 끊어라"에 NetworkPolicy 를 적용하면, 새 연결만 막히고 진행 중인 연결은 계속된다. 끊으려면 conntrack 을 지우거나 파드를 재시작해야 한다.

덤 — 57800 포트도 있다

sport=50477 dport=57800 — FD_SOCK2 는 bind_port + 50000 을 쓴다. 7800 만 막고 57800 을 열어두면 장애 감지 채널이 남는다. 이 실험의 허용 목록 방식은 둘 다 자동으로 막았다 — 8080·9000 외 전부 거부이므로.

조치

# 정확한 튜플로 지정해야 지워진다. --dport 만으로는 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   # 역방향

양쪽 노드에서, 양쪽 방향으로 지워야 한다. 서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있다.

★ 정정 — conntrack 삭제가 분단을 만들었다고 볼 근거가 없다

이 문서는 처음에 이렇게 썼다.

11:41  conntrack 삭제
11:44  cluster_size 2 → 1        ← 약 3분 뒤

증거를 다시 보면 그 인과가 성립하지 않는다.

시각 증거
11:41 conntrack 삭제. 직후 07-cluster-size.txt11:45 까지 전부 2
11:44:23 keycloak-0 파드의 startTime — 스스로 재시작했다
11:44:27 cluster_size 2 → 1
11:46:07 내가 delete pod 를 실행 (이미 떨어진 뒤)

하락은 conntrack 삭제 3분 뒤가 아니라 파드 재시작 4초 뒤에 일어났다. 같은 문서 6절이 "정책이 걸린 채 재시작되자" 라고 쓴 것이 맞고, 4절의 "conntrack 삭제 → 3분 뒤 분단" 은 시각이 겹친 것을 인과로 읽은 것이다.

conntrack 삭제 자체가 무의미했다는 뜻은 아니다 — 다만 이 실험은 그것만으로 분단이 되는지 판정하지 못했다. 판정한 것은 A-5 이고, 거기서 raw 테이블이 필요하다는 것이 드러났다.

TCP 가 재전송 타임아웃으로 상대를 알아채는 것은 사실이지만, 이 실험에서 그 경로가 발동했다는 증거는 없다.


5. 문제 ② — 계측 도구가 잘못됐다

임시 curl 파드로 20초마다 지표를 긁었더니 이런 결과가 나왔다.

  +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 " ] && echo "→ 변화 감지" && break

여서 빈 문자열을 "변화"로 읽고 즉시 빠져나왔다. A-0 에서 똑같은 실수를 했는데 또 했다.

임시 파드는 계측 도구가 아니다. 15초마다 이미 긁고 있는 Prometheus 가 그러라고 있는 것이다.

kubectl -n observability port-forward svc/prometheus 19090:9090 &
curl -s "http://localhost:19090/api/v1/query_range?query=vendor_cluster_size&start=$START&end=$END&step=60"

그리고 이 과정에서 vendor_cluster_size 를 발견했다 — 멤버 수를 직접 알려주는 지표다. 처음부터 이걸 봤어야 했다.

curl -s "http://localhost:19090/api/v1/label/__name__/values" | grep -E "cluster|member|view"

6. 진짜 분단이 일어난 순간

=== vendor_cluster_size ===
keycloak-1: 11:43:57=2  11:44:27=1  ...  11:51:28=2
keycloak-0: 11:43:57=2  (파드 교체)  11:45:27=1  ...  11:51:28=2

cluster_size 추이

정책이 걸린 채 keycloak-0 이 재시작되자, 로그가 정확히 말해준다.

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-1 쪽도 혼자가 되었다.

ISPN000094: [keycloak-1-48749|6] (1) [keycloak-1-48749]

DB 에는 둘 다 있는데 클러스터는 안 붙는다 — 예측한 그 상태

=== JGROUPS_PING ===
       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 이며, 데이터베이스 한 줄로 확인된다. 디스커버리(DB)는 살아 있고 트랜스포트(7800)만 죽은 상태다.

단일 노드에서는 만들 수 없는 고장이며, 이 실험대를 2 VM 으로 만든 이유다.


7. 본 시험 — 분단 상태에서 세션은 어떻게 되는가

  [1] keycloak-0 로그인          sid=nShl5TaBrZnKStDqaspjgmJB
  [2] keycloak-1 에서 refresh     HTTP 200      ← 예측대로
  [3] keycloak-1 에서 로그아웃    HTTP 204
  [4] keycloak-0 에서 재갱신 시도 HTTP 200      ← 400 이어야 했다

[2] 세션 공유 — 예측이 맞았다

클러스터가 갈라졌는데도 한쪽에서 만든 세션을 반대쪽이 갱신했다. A-0 의 모델이 맞고, 통념이 틀렸다. 세션은 7800 으로 다니지 않는다.

[4] 로그아웃 전파 — 예측이 틀렸다

A-0 에서는 같은 절차가 400 invalid_grant / Session not active 였다. 분단 상태에서는 200 이다. 로그아웃한 세션이 반대편에서 살아 있다.

기제를 확정했다.

=== 그 sid 가 DB 에 남아 있는가 ===
 user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows)                                            ← DB 행은 삭제되었다

=== 노드별 세션 캐시 엔트리 ===
  keycloak-1   kc-lab-1   = 0
  keycloak-0   kc-lab-2   = 1                       ← 캐시에는 남아 있다
   keycloak-1 로그아웃
        │
        ├──▶ PostgreSQL 행 삭제                     ✔ 되었다
        │
        └──▶ keycloak-0 에게 "캐시에서 지워라"       ✗ 7800 이 막혀 못 갔다
                                                        │
   keycloak-0 은 자기 캐시로 200 을 준다  ◀────────────┘

A-0 의 결론을 정정한다

A-0 에서 나는 이렇게 썼다.

로그아웃과 함께 DB 행이 사라졌다 → 무효화가 DB 삭제로 전파된다

그 인과는 틀렸다. DB 행 삭제는 일어나지만, 반대편 노드는 DB 를 다시 읽지 않는다. 자기 캐시에 있으면 그걸로 답한다.

룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 캐시 무효화는 클러스터 메시지(7800)를 타고 간다.

A-0 에서 400 이 나온 것은 DB 덕분이 아니라 그때는 7800 이 살아 있어서였다. 두 실험을 붙여야 비로소 정확한 그림이 나온다.

세션 조회 세션 무효화
경로 PostgreSQL 클러스터 메시지 (7800)
7800 차단 시 정상 전파되지 않음

8. 그런데 안전장치가 있었다 — 예상 못 한 발견

keycloak-0Ready=false 였다. 이유를 물었더니

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

=== Service 엔드포인트 ===
  ready 주소: [10.42.0.35]     ← keycloak-1 만 트래픽을 받는다
  notReady : [10.42.1.67]      ← keycloak-0 은 제외되었다

=== 외부 진입점 ===
  https://auth.hyeonworks.com/realms/master   HTTP 200
  토큰 발급                                    HTTP 200

분단된 노드가 스스로 로드밸런서에서 빠졌고, 서비스는 계속되었다.

그래서 7절의 로그아웃 우회는 어떻게 봐야 하나

내가 한 것 Service 를 우회해 파드 IP 로 직접 호출
실제 사용자 nginx → Traefik → Service → Ready 인 파드만

정문으로 들어오면 낡은 캐시에 닿지 않는다. readiness 게이트가 막는다.

다만 이건 비대칭이라서 살았다. keycloak-1 은 원래 뷰에서 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, keycloak-0 은 합류 자체를 못 해 DOWN 이 되었다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다. A-5(비대칭 파티션)에서 이어서 본다.


9. 복구

kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
  +30초   keycloak-0=1 keycloak-1=1
  +60초   keycloak-0=1 keycloak-1=1
  +90초   keycloak-0=2 keycloak-1=2   ← 재형성

90초 만에 자동으로 다시 붙었다. 사람 손이 필요 없었다.

=== MERGE3 가 합쳤는가 ===
  merge_events keycloak-0 = 1
  merge_events keycloak-1 = 1

MERGE3 가 한 일이다. split brain 을 감지해 뷰를 병합하는 프로토콜이며, 지표가 0 → 1 로 올라간 것이 그 증거다.

=== JGROUPS_PING ===
 keycloak-0-26403 | 10.42.1.67:7800 | t
 keycloak-1-48749 | 10.42.0.35:7800 | f     ← 코디네이터가 하나로 돌아왔다

코디네이터가 keycloak-1 에서 keycloak-0 으로 넘어갔다. 코디네이터는 특권이 아니라 역할이며, 병합 시 재선출된다.


10. 개념 정리

conntrack — 연결 추적

리눅스 커널이 진행 중인 연결을 기억하는 표. 패킷마다 규칙을 다시 평가하지 않기 위해 존재한다.

상태
NEW 첫 패킷(SYN)
ESTABLISHED 양방향 통신이 성립함 — 규칙 평가를 건너뛴다
[ASSURED] 충분히 오래된 연결. 표가 꽉 차도 안 지워진다
SYN_SENT [UNREPLIED] 보냈는데 답이 없음 = 차단되고 있다
sudo conntrack -L | grep 7800
# 위 출력의 src/dst/sport/dport 를 그대로 넣는다. 한 줄로 하려면:
sudo conntrack -L 2>/dev/null | grep 7800 | awk '{
  for(i=1;i<=NF;i++){ split($i,a,"="); t[a[1]]=a[2] }
  print "sudo conntrack -D -p tcp -s "t["src"]" -d "t["dst"]" --sport "t["sport"]" --dport "t["dport"]
}' | sh

FD_SOCK2 와 포트 규약

프로토콜 포트 하는 일
TCP (트랜스포트) 7800 클러스터 메시지
FD_SOCK2 57800 = 7800 + 50000 소켓으로 상대 생존 감시

방화벽 규칙을 손으로 쓸 때 57800 을 빠뜨리기 쉽다.

MERGE3

split brain 이 생긴 뒤 갈라진 뷰를 다시 합치는 JGroups 프로토콜. 주기적으로 다른 코디네이터의 존재를 확인하고, 발견하면 병합을 개시한다.

vendor_jgroups_merge3_get_num_merge_events

readiness 프로브와 Service 엔드포인트

   readiness 실패 → 파드가 Service 의 notReadyAddresses 로 이동
                  → kube-proxy 가 그 파드로 라우팅하지 않음
                  → 살아 있지만 트래픽은 안 받음

liveness 와 다르다. liveness 실패는 재시작, readiness 실패는 격리다. 클러스터 분단처럼 "재시작해도 안 나아지는" 문제에는 readiness 가 맞는 신호다.



증거 파일

증거 수집 시각: 2026-09-04 11:34 11:50 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-baseline-cluster.txt 터미널 원문
02-control-before-block.txt 터미널 원문
03-block-applied.txt 터미널 원문
04-after-block-state.txt 터미널 원문
05-conntrack-problem.txt 터미널 원문
06-partition-observed.txt 터미널 원문
07-cluster-size.txt 터미널 원문
08-restart-forced-partition.txt 터미널 원문
09-cross-node-under-partition.txt 터미널 원문
10-logout-not-propagated.txt 터미널 원문
11-service-impact.txt 터미널 원문
12-recovery.txt 터미널 원문
a1-cluster-size-partition-recovery.png 스크린샷

파일별 상세는 evidence/a1-jgroups-transport-block/README.md.

11. 재현 절차 (명령어)

# 0. 기준선
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, ip, coord from jgroups_ping order by name"
kubectl -n observability port-forward svc/prometheus 19090:9090 &
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"

# 1. 차단
kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml

# 2. 주입이 의도한 것만 건드렸는지 확인 (8080/9000 은 살아 있어야 한다)
kubectl -n keycloak-lab get pods -o wide | grep keycloak     # restarts=0 확인

# 3. 기존 연결이 남아 있음을 확인 — 이걸 안 하면 실험이 무효다
ssh kc-lab-1 'sudo conntrack -L | grep 7800'

# 4. conntrack 삭제 (양쪽 노드, 양쪽 방향). 반영까지 약 3분
# 노드마다 자기 conntrack 표에서 7800 튜플을 뽑아 그대로 지운다.
# 방향(누가 client 인지)은 재시작마다 바뀐다 — 그래서 손으로 적으면 틀린다(A-5 에서 실제로 틀렸다).
for N in kc-lab-1 kc-lab-2; do
  ssh $N "sudo conntrack -L 2>/dev/null | grep 7800 | awk '{
    for(i=1;i<=NF;i++){ split(\$i,a,\"=\"); t[a[1]]=a[2] }
    print \"sudo conntrack -D -p tcp -s \"t[\"src\"]\" -d \"t[\"dst\"]\" --sport \"t[\"sport\"]\" --dport \"t[\"dport\"]
  }' | sh"
done

# 5. 분단 확인
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select name, coord from jgroups_ping"        # coord=t 가 둘이면 split brain

# 6. 복구
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
curl -s "http://localhost:19090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events"

12. 다음 실험에 남기는 것

실험 이 실험이 준 것
A-5 비대칭 파티션 양쪽이 동시에 NotReady 가 되는 경로가 있는가. 여기서는 비대칭이라 살았다
A-2 DB 정지 캐시가 DB 와 대조하지 않는다는 사실 → 캐시에 있는 세션은 DB 없이도 읽힐 수 있다
A-7 volatile 비교 같은 주입에서 세션 공유가 깨져야 한다. 이 실험이 그 대조군
전체 주입이 실제로 걸렸는지 먼저 확인한다. NetworkPolicy 는 기존 연결을 못 끊는다