diff --git a/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log b/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log new file mode 100644 index 0000000..f0f542f --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T02-50-02-217Z.log @@ -0,0 +1,21 @@ +[ 344ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 869ms] [WARNING] is deprecated. Please include @ https://app2.hyeonworks.com/explore?schemaVersion=1&panes=%7B%22cf1%22%3A%7B%22datasource%22%3A%22PBFA97CFB590B2093%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22vendor_cluster_size%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22%7B%7Bpod%7D%7D+on+%7B%7Bnode%7D%7D%22%2C%22datasource%22%3A%7B%22type%22%3A%22prometheus%22%2C%22uid%22%3A%22PBFA97CFB590B2093%22%7D%7D%5D%2C%22range%22%3A%7B%22from%22%3A%22now-45m%22%2C%22to%22%3A%22now%22%7D%7D%7D&orgId=1:0 +[ 1014ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 2039ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 4085ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 7289ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 15556ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 26818ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 32659ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 35930ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 53849ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 61722ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 79450ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 84571ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 87233ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 92665ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 113244ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 119181ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 137390ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 157071ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 166091ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 diff --git a/.playwright-mcp/page-2026-09-04T02-50-03-122Z.yml b/.playwright-mcp/page-2026-09-04T02-50-03-122Z.yml new file mode 100644 index 0000000..d875764 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T02-50-03-122Z.yml @@ -0,0 +1,151 @@ +- generic [active] [ref=e1]: + - generic [ref=e4]: + - link "Skip to main content" [ref=e5] [cursor=pointer]: + - /url: "#pageContent" + - banner [ref=e7]: + - generic [ref=e8]: + - link [ref=e10] [cursor=pointer]: + - /url: / + - img "Grafana" [ref=e11] + - generic [ref=e14]: + - button "Search or jump to..." [ref=e18] [cursor=pointer] + - generic [ref=e19]: ctrl+k + - generic [ref=e23]: + - button "New" [ref=e24] [cursor=pointer] + - button "Help" [ref=e30] [cursor=pointer] + - button "News" [ref=e33] [cursor=pointer] + - button "Profile" [ref=e36] [cursor=pointer]: + - img "User avatar" [ref=e37] + - generic [ref=e38]: + - button "Open menu" [ref=e40] [cursor=pointer] + - navigation "Breadcrumbs" [ref=e43]: + - list [ref=e44]: + - listitem [ref=e45]: + - link "Home" [ref=e46] [cursor=pointer]: + - /url: / + - listitem [ref=e50]: + - link "Explore" [ref=e51] [cursor=pointer]: + - /url: /explore + - listitem [ref=e55]: + - generic "Prometheus" [ref=e56] + - generic [ref=e57]: + - generic [ref=e60]: + - button "Copy shortened URL" [ref=e61] [cursor=pointer] + - button "Open copy link options" [ref=e64] [cursor=pointer] + - button "Toggle top search bar" [ref=e68] [cursor=pointer] + - main [ref=e74]: + - generic [ref=e76]: + - heading "Explore" [level=1] [ref=e77] + - generic [ref=e82]: + - navigation "Explore toolbar" [ref=e84]: + - navigation "Search links" [ref=e86]: + - generic [ref=e87]: + - button "Content outline" [expanded] [ref=e89] [cursor=pointer]: + - generic [ref=e92]: Outline + - generic [ref=e97] [cursor=pointer]: + - img "Prometheus logo" [ref=e99] + - textbox "Select a data source" [ref=e100]: + - /placeholder: "" + - generic [ref=e104]: + - button "Split the pane" [ref=e106] [cursor=pointer]: + - generic [ref=e109]: Split + - button "Add" [ref=e111] [cursor=pointer] + - generic [ref=e116]: + - 'button "Time range selected: Last 45 minutes" [ref=e117] [cursor=pointer]' + - button "Zoom out time range" [ref=e122] [cursor=pointer] + - generic [ref=e126]: + - button "Run query" [ref=e127] [cursor=pointer] + - button "Auto refresh turned off. Choose refresh time interval" [ref=e131] [cursor=pointer] + - generic [ref=e135]: + - generic [ref=e139]: + - button "Collapse outline" [expanded] [ref=e141] [cursor=pointer]: + - img "arrow-from-right" [ref=e142] + - button "Queries" [ref=e145] [cursor=pointer]: + - img "arrow" [ref=e146] + - button "Graph" [ref=e150] [cursor=pointer]: + - img "graph-bar" [ref=e151] + - generic [ref=e158]: + - generic [ref=e160]: + - generic "Query editor row" [ref=e163]: + - generic [ref=e164]: + - generic [ref=e166]: + - generic [ref=e167]: + - button "Collapse query row" [expanded] [ref=e168] [cursor=pointer] + - generic [ref=e171]: + - button "Query editor row title A" [ref=e172] [cursor=pointer]: + - generic [ref=e173]: A + - emphasis [ref=e174]: (Prometheus) + - generic [ref=e175]: + - button "Show data source help" [ref=e177] [cursor=pointer] + - button "Duplicate query" [ref=e181] [cursor=pointer] + - button "Hide response" [ref=e185] [cursor=pointer] + - button "Remove query" [ref=e189] [cursor=pointer] + - button "Drag and drop to reorder" [ref=e192]: + - img "Drag and drop to reorder" [ref=e193] + - generic [ref=e196]: + - generic [ref=e197]: + - button "Kick start your query" [ref=e198] [cursor=pointer] + - generic [ref=e201]: + - generic [ref=e202] [cursor=pointer]: Explain + - generic [ref=e203]: + - checkbox "Explain Toggle switch" [ref=e204] + - generic "Toggle switch" [ref=e205] [cursor=pointer] + - radiogroup [ref=e210]: + - generic [ref=e211]: + - radio "Builder" [ref=e212] [cursor=pointer] + - generic [ref=e213] [cursor=pointer]: Builder + - generic [ref=e214]: + - radio "Code" [checked] [ref=e215] [cursor=pointer] + - generic [ref=e216] [cursor=pointer]: Code + - generic [ref=e218]: + - generic [ref=e220]: + - button "Metrics browser" [ref=e221] [cursor=pointer] + - code [ref=e228]: + - generic [ref=e229]: + - generic [ref=e234]: vendor_cluster_size + - textbox "Editor content;Press Alt+F1 for Accessibility Options." [ref=e239]: vendor_cluster_size + - 'button "Options Legend: {{pod}} on {{node}} Format: Time series Step: auto Type: Range Exemplars: false" [ref=e245] [cursor=pointer]': + - generic [ref=e249]: + - heading "Options" [level=6] [ref=e250] + - generic [ref=e251]: + - generic [ref=e252]: "Legend: {{pod}} on {{node}}" + - generic [ref=e253]: "Format: Time series" + - generic [ref=e254]: "Step: auto" + - generic [ref=e255]: "Type: Range" + - generic [ref=e256]: "Exemplars: false" + - generic [ref=e257]: + - button "Add query" [ref=e258] [cursor=pointer] + - button "Query history" [ref=e262] [cursor=pointer] + - button "Query inspector" [ref=e266] [cursor=pointer] + - main [ref=e270]: + - region [ref=e272]: + - generic [ref=e273]: + - heading "Graph" [level=2] [ref=e275] + - radiogroup [ref=e278]: + - generic [ref=e279]: + - radio "Lines" [checked] [ref=e280] [cursor=pointer] + - generic [ref=e281] [cursor=pointer]: Lines + - generic [ref=e282]: + - radio "Bars" [ref=e283] [cursor=pointer] + - generic [ref=e284] [cursor=pointer]: Bars + - generic [ref=e285]: + - radio "Points" [ref=e286] [cursor=pointer] + - generic [ref=e287] [cursor=pointer]: Points + - generic [ref=e288]: + - radio "Stacked lines" [ref=e289] [cursor=pointer] + - generic [ref=e290] [cursor=pointer]: Stacked lines + - generic [ref=e291]: + - radio "Stacked bars" [ref=e292] [cursor=pointer] + - generic [ref=e293] [cursor=pointer]: Stacked bars + - list [ref=e302]: + - listitem [ref=e303]: + - button "keycloak-0 on kc-lab-2" [ref=e307] [cursor=pointer] + - listitem [ref=e308]: + - button "keycloak-0 on kc-lab-2" [ref=e312] [cursor=pointer] + - listitem [ref=e313]: + - button "keycloak-1 on kc-lab-1" [ref=e317] [cursor=pointer] + - generic [ref=e322]: + - alert + - alert + - complementary + - complementary \ No newline at end of file diff --git a/deploy/lab/k8s/a1-block-jgroups-transport.yaml b/deploy/lab/k8s/a1-block-jgroups-transport.yaml new file mode 100644 index 0000000..f25528f --- /dev/null +++ b/deploy/lab/k8s/a1-block-jgroups-transport.yaml @@ -0,0 +1,46 @@ +# Experiment A-1 — cut the JGroups transport (TCP 7800) while leaving discovery alone. +# +# The point is to separate two things that are easy to conflate: +# +# discovery how the nodes FIND each other -> PostgreSQL JGROUPS_PING table +# transport how they actually TALK -> TCP 7800 +# +# Blocking only the transport produces a state that cannot happen on a single +# node: both members stay registered in the database, so each believes the other +# exists, yet no message gets through. +# +# kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml +# kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport +# +# NetworkPolicy is an ALLOWLIST, not a firewall with deny rules. There is no way +# to write "deny 7800". The moment a pod is selected by a policy carrying +# policyTypes: [Ingress], every inbound port is denied unless a rule permits it. +# So 7800 is blocked by *omission*: 8080 and 9000 are listed, 7800 is not. +# +# That makes the two allow rules load-bearing — get them wrong and the experiment +# measures a dead Keycloak instead of a partitioned cluster: +# +# 8080 the HTTP endpoint. Traefik, the other pod's REST calls, and the probe +# traffic all arrive here. +# 9000 the management port: /health/started, /health/ready, /health/live and +# /metrics. Losing it means the kubelet fails the readiness probe and +# kills the pod — the cluster would break for the wrong reason. +# +# Both rules deliberately omit `from:`, which allows those ports from any source. +# Narrowing the source is not the subject here; the 2-hop experiment already +# established how to do that by label when it matters. +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. diff --git a/docs/evidence/a1-jgroups-transport-block/01-baseline-cluster.txt b/docs/evidence/a1-jgroups-transport-block/01-baseline-cluster.txt new file mode 100644 index 0000000..2c281db --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/01-baseline-cluster.txt @@ -0,0 +1,13 @@ +=== [기준선 1] 클러스터 뷰 — 양쪽 파드의 마지막 ISPN000094 === + 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)] + +=== [기준선 2] JGROUPS_PING — 디스커버리 등록 === + name | ip | coord | coordinated_by +------------------+-----------------+-------+--------------------------------------------- + keycloak-0-30843 | 10.42.1.43:7800 | f | uuid://00000000-0000-0000-0000-000000000007 + keycloak-1-48749 | 10.42.0.35:7800 | t | uuid://00000000-0000-0000-0000-000000000007 +(2 rows) + +=== [기준선 3] 기존 NetworkPolicy === +No resources found in keycloak-lab namespace. diff --git a/docs/evidence/a1-jgroups-transport-block/02-control-before-block.txt b/docs/evidence/a1-jgroups-transport-block/02-control-before-block.txt new file mode 100644 index 0000000..b48d899 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/02-control-before-block.txt @@ -0,0 +1,17 @@ +=== [대조군] 차단 전 — keycloak-0 로그인 → keycloak-1 에서 refresh === + sid tAWs2gCPr6SOcD4jDR9-_CzB + keycloak-1 에서 refresh: 200 + +=== [기준선 4] JGroups 지표 — 양쪽 노드 === +--- K0 (10.42.1.43) --- +vendor_jgroups_stats_bytes_sent_total 31476.0 +vendor_jgroups_merge3_get_num_merge_events 0.0 +vendor_jgroups_merge3_get_views 0.0 +vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 +vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0 +--- K1 (10.42.0.35) --- +vendor_jgroups_merge3_get_views 0.0 +vendor_jgroups_stats_bytes_sent_total 126765.0 +vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0 +vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 +vendor_jgroups_merge3_get_num_merge_events 0.0 diff --git a/docs/evidence/a1-jgroups-transport-block/03-block-applied.txt b/docs/evidence/a1-jgroups-transport-block/03-block-applied.txt new file mode 100644 index 0000000..54384f1 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/03-block-applied.txt @@ -0,0 +1,8 @@ +=== 차단 적용 === +networkpolicy.networking.k8s.io/a1-block-jgroups-transport created +a1-block-jgroups-transport map[app:keycloak] + +적용 시각: 11:38:08 +=== FD_SOCK2 가 상대를 의심하기까지 기다린다 (15초 간격, 최대 3분) === + +15초 suspected(k0 k1) = + → 변화 감지 diff --git a/docs/evidence/a1-jgroups-transport-block/04-after-block-state.txt b/docs/evidence/a1-jgroups-transport-block/04-after-block-state.txt new file mode 100644 index 0000000..da17cd2 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/04-after-block-state.txt @@ -0,0 +1,26 @@ +차단 경과: 11:38:51 (적용 11:38:08) + +=== [차단 후 1] JGroups 지표 === +--- keycloak-0 --- +vendor_jgroups_stats_bytes_sent_total 32857.0 +vendor_jgroups_merge3_get_num_merge_events 0.0 +vendor_jgroups_merge3_get_views 0.0 +vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 +vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0 +--- keycloak-1 --- +vendor_jgroups_merge3_get_views 0.0 +vendor_jgroups_stats_bytes_sent_total 129103.0 +vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0 +vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 +vendor_jgroups_merge3_get_num_merge_events 0.0 + +=== [차단 후 2] 클러스터 뷰 — 갈라졌는가 === + keycloak-0: + keycloak-1: +=== [차단 후 3] JGROUPS_PING — 디스커버리는 살아 있는가 === + name | ip | coord +------------------+-----------------+------- + keycloak-0-30843 | 10.42.1.43:7800 | f + keycloak-1-48749 | 10.42.0.35:7800 | t +(2 rows) + diff --git a/docs/evidence/a1-jgroups-transport-block/05-conntrack-problem.txt b/docs/evidence/a1-jgroups-transport-block/05-conntrack-problem.txt new file mode 100644 index 0000000..7c86074 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/05-conntrack-problem.txt @@ -0,0 +1,16 @@ +=== [문제 확정] NetworkPolicy 적용 후에도 기존 연결이 conntrack 에 살아 있다 === +--- 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 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1 + tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 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 + +=== [조치] 7800 흐름의 conntrack 항목을 지운다 → 다음 패킷이 정책을 다시 탄다 === + 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 conntrack v1.4.7 (conntrack-tools): 0 flow entries have been deleted. + kc-lab-2: tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1 conntrack v1.4.7 (conntrack-tools): 0 flow entries have been deleted. + +=== 삭제 후 7800 conntrack === + kc-lab-1: 2 건 + kc-lab-2: 2 건 diff --git a/docs/evidence/a1-jgroups-transport-block/06-partition-observed.txt b/docs/evidence/a1-jgroups-transport-block/06-partition-observed.txt new file mode 100644 index 0000000..c960c6c --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/06-partition-observed.txt @@ -0,0 +1,13 @@ +관찰 시작: 11:42:03 + +20초 suspected(k0 k1) = [] + +40초 suspected(k0 k1) = [] + +60초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ] + +80초 suspected(k0 k1) = [] + +100초 suspected(k0 k1) = [] + +120초 suspected(k0 k1) = [] + +140초 suspected(k0 k1) = [0.0 ] + +160초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ] + +=== 클러스터 뷰 변화 (최근 8분) === +--- keycloak-0 --- +--- keycloak-1 --- diff --git a/docs/evidence/a1-jgroups-transport-block/07-cluster-size.txt b/docs/evidence/a1-jgroups-transport-block/07-cluster-size.txt new file mode 100644 index 0000000..04b12b4 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/07-cluster-size.txt @@ -0,0 +1,16 @@ +=== vendor_cluster_size — 지난 25분 (차단 11:38:08, conntrack 삭제 11:41) === + keycloak-0: + 11:20=2 11:21=2 11:22=2 11:23=2 11:24=2 11:25=2 11:26=2 11:27=2 11:28=2 11:29=2 11:30=2 11:31=2 11:32=2 11:33=2 11:34=2 11:35=2 11:36=2 11:37=2 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:20=2 11:21=2 11:22=2 11:23=2 11:24=2 11:25=2 11:26=2 11:27=2 11:28=2 11:29=2 11:30=2 11:31=2 11:32=2 11:33=2 11:34=2 11:35=2 11:36=2 11:37=2 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 = 2 멤버 + keycloak-0 = 2 멤버 + +=== 7800 소켓 상태 (파드 내부) === + keycloak-0 2 + keycloak-1 2 +=== conntrack === + kc-lab-1 1 건 + kc-lab-2 1 건 diff --git a/docs/evidence/a1-jgroups-transport-block/08-restart-forced-partition.txt b/docs/evidence/a1-jgroups-transport-block/08-restart-forced-partition.txt new file mode 100644 index 0000000..c4032a2 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/08-restart-forced-partition.txt @@ -0,0 +1,9 @@ +=== 정책이 걸린 상태에서 keycloak-0 을 재시작한다 → 재연결이 막힌다 === +재시작 시각: 11:46:07 +pod "keycloak-0" deleted from keycloak-lab namespace +keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z + +=== 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-0: 11:40:57=2 11:41:27=2 11:41:57=2 11:42:27=2 11:42:57=2 11:43:27=2 11:43:57=2 + keycloak-1: 11:40:57=2 11:41:27=2 11:41:57=2 11:42:27=2 11:42:57=2 11:43:27=2 11:43:57=2 11:44:27=1 11:44:57=1 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1 diff --git a/docs/evidence/a1-jgroups-transport-block/09-cross-node-under-partition.txt b/docs/evidence/a1-jgroups-transport-block/09-cross-node-under-partition.txt new file mode 100644 index 0000000..33afaf5 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/09-cross-node-under-partition.txt @@ -0,0 +1,16 @@ +=== keycloak-0 헬스 상태 === + keycloak-0 = 10.42.1.67 keycloak-1 = 10.42.0.35 + PodReadyToStartContainers=True + Initialized=True + Ready=False ContainersNotReady + ContainersReady=False ContainersNotReady + PodScheduled=True + +=== ★ 본 시험 — 분단 상태에서 교차 노드 세션이 되는가 === + [1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB + [2] keycloak-1 에서 refresh HTTP 200 + [3] keycloak-1 에서 로그아웃 HTTP 204 + [4] keycloak-0 에서 재갱신 시도 HTTP 200 + (400 이면 무효화가 전파된 것) + +=== DB 세션 수 === diff --git a/docs/evidence/a1-jgroups-transport-block/10-logout-not-propagated.txt b/docs/evidence/a1-jgroups-transport-block/10-logout-not-propagated.txt new file mode 100644 index 0000000..88eec93 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/10-logout-not-propagated.txt @@ -0,0 +1,33 @@ +=== 그 sid 가 DB 에 남아 있는가 === + user_session_id | offline_flag | last_session_refresh +-----------------+--------------+---------------------- +(0 rows) + +=== 전체 온라인 세션 수 === + 1 + +=== 노드별 세션 캐시 엔트리 (Prometheus) === + keycloak-1 kc-lab-1 = 0 + keycloak-0 kc-lab-2 = 1 + +=== keycloak-0 이 Ready 가 아닌 이유 — 헬스 응답 === +{ + "status": "DOWN", + "checks": [ + { + "name": "Graceful Shutdown", + "status": "UP" + }, + { + "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" + }, + { + "name": "Keycloak Initialized", diff --git a/docs/evidence/a1-jgroups-transport-block/11-service-impact.txt b/docs/evidence/a1-jgroups-transport-block/11-service-impact.txt new file mode 100644 index 0000000..be5cdcf --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/11-service-impact.txt @@ -0,0 +1,25 @@ +=== 양쪽 노드의 readiness — 둘 다 DOWN 이면 전면 장애다 === +Traceback (most recent call last): + File "", line 3, in + d=json.load(sys.stdin) + File "/usr/lib/python3.14/json/__init__.py", line 298, in load + return loads(fp.read(), + cls=cls, object_hook=object_hook, + parse_float=parse_float, parse_int=parse_int, + parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw) + File "/usr/lib/python3.14/json/__init__.py", line 352, in loads + return _default_decoder.decode(s) + ~~~~~~~~~~~~~~~~~~~~~~~^^^ + File "/usr/lib/python3.14/json/decoder.py", line 348, in decode + raise JSONDecodeError("Extra data", s, end) +json.decoder.JSONDecodeError: Extra data: line 21 column 2 (char 446) + +=== 파드 Ready 상태 === +keycloak-0 false 0 +keycloak-1 true 0 + +=== ★ Service 엔드포인트 — 트래픽을 받는 파드가 남아 있는가 === + ready 주소: [10.42.0.35] notReady : [10.42.1.67] +=== ★ 외부 진입점으로 실제 로그인이 되는가 (nginx→Traefik→Service) === + https://auth.hyeonworks.com/realms/master HTTP 200 + 토큰 발급 HTTP 200 diff --git a/docs/evidence/a1-jgroups-transport-block/12-recovery.txt b/docs/evidence/a1-jgroups-transport-block/12-recovery.txt new file mode 100644 index 0000000..33347e3 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/12-recovery.txt @@ -0,0 +1,24 @@ +=== 차단 해제 === +해제 시각: 11:49:58 +networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace + +=== 자동으로 다시 붙는가 (30초 간격, 최대 4분) === + +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 | Ready 파드 3 개 + → 클러스터 재형성 + +=== 복구 로그 === + keycloak-0: [keycloak-0-26403(v=16.0.12)|0] (1) [keycloak-0-26403(v=16.0.12)] + keycloak-1: [keycloak-1-48749(v=16.0.12)|6] (1) [keycloak-1-48749(v=16.0.12)] + +=== MERGE3 가 합쳤는가 === + merge_events keycloak-1 = 1 + merge_events keycloak-0 = 1 +=== JGROUPS_PING — 코디네이터가 하나로 돌아왔는가 === + name | ip | coord +------------------+-----------------+------- + keycloak-0-26403 | 10.42.1.67:7800 | t + keycloak-1-48749 | 10.42.0.35:7800 | f +(2 rows) + diff --git a/docs/evidence/a1-jgroups-transport-block/README.md b/docs/evidence/a1-jgroups-transport-block/README.md new file mode 100644 index 0000000..251a7b6 --- /dev/null +++ b/docs/evidence/a1-jgroups-transport-block/README.md @@ -0,0 +1,26 @@ +# A-1 — JGroups 트랜스포트(7800) 차단 증거 + +2026-09-04 11:38–11:52 KST · Keycloak 26.7.0 / Infinispan 16.0.12 +해설: [`docs/experiment-a1-jgroups-transport-block.md`](../../experiment-a1-jgroups-transport-block.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-baseline-cluster.txt` | 차단 전 — 양쪽이 뷰 ID 5·멤버 2로 일치, `JGROUPS_PING` 코디네이터 1명 | +| `02-control-before-block.txt` | **대조군** — 차단 전 교차 노드 refresh `200`, JGroups 지표 전부 0 | +| `03-block-applied.txt` | NetworkPolicy 적용. **빈 측정값을 "변화 감지"로 오판한 기록** | +| `04-after-block-state.txt` | 차단 43초 후 — 지표 무변화, `JGROUPS_PING` 그대로 | +| `05-conntrack-problem.txt` | **핵심 문제** — `ESTABLISHED [ASSURED]` 로 기존 연결이 살아 있음. FD_SOCK2 의 **57800** 포트도 함께 드러남 | +| `06-partition-observed.txt` | 임시 curl 파드 폴링의 실패 — 빈 값·개수 불일치 | +| `07-cluster-size.txt` | **`vendor_cluster_size` 가 25분 내내 2** — 분단이 일어나지 않았다는 결정적 증거 | +| `08-restart-forced-partition.txt` | 재연결 강제 후 `2 → 1` | +| `09-cross-node-under-partition.txt` | **본 시험** — 교차 refresh `200`(예측 적중), **로그아웃 후 재갱신 `200`(예측 빗나감)** | +| `10-logout-not-propagated.txt` | 기제 확정 — **DB 행 0건인데 keycloak-0 캐시에 1건**, 헬스체크 `cluster health: DOWN` | +| `11-service-impact.txt` | **분단 노드가 Service 에서 빠짐.** `ready=[10.42.0.35] notReady=[10.42.1.67]`, 외부 로그인 `200` | +| `12-recovery.txt` | 90초 만에 자동 재형성, `merge3_get_num_merge_events = 1`, 코디네이터 재선출 | +| `a1-cluster-size-partition-recovery.png` | Grafana — `vendor_cluster_size` 가 `2 → 1 → 2` 로 움직이는 전 구간 | + +## 핵심 세 줄 + +1. **NetworkPolicy 만으로는 이미 붙어 있는 클러스터를 못 끊는다.** conntrack 의 ESTABLISHED 가 먼저 통과시킨다. +2. **세션 공유는 분단을 견딘다(200).** 통념이 틀렸고 A-0 모델이 맞다. +3. **로그아웃 무효화는 7800 을 탄다.** DB 행이 지워져도 반대편은 낡은 캐시로 200 을 준다 — A-0 의 인과 해석을 정정한다. diff --git a/docs/evidence/a1-jgroups-transport-block/a1-cluster-size-partition-recovery.png b/docs/evidence/a1-jgroups-transport-block/a1-cluster-size-partition-recovery.png new file mode 100644 index 0000000..6449a36 Binary files /dev/null and b/docs/evidence/a1-jgroups-transport-block/a1-cluster-size-partition-recovery.png differ diff --git a/docs/experiment-a1-jgroups-transport-block.md b/docs/experiment-a1-jgroups-transport-block.md new file mode 100644 index 0000000..1ccc33a --- /dev/null +++ b/docs/experiment-a1-jgroups-transport-block.md @@ -0,0 +1,506 @@ +# A-1 — 노드 간 통신(TCP 7800)을 끊으면 무엇이 깨지는가 + +브랜치 `feature/keycloak-a1-jgroups-transport-block` · +증거 [`docs/evidence/a1-jgroups-transport-block/`](evidence/a1-jgroups-transport-block/) · +2026-09-04 11:38–11:52 KST · Keycloak 26.7.0 / Infinispan 16.0.12 + +맥락은 [`session-lab-prerequisites.md`](session-lab-prerequisites.md), +기준선은 [`experiment-00-session-replication.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 만 막는다 + +```bash +kubectl apply -f 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 이 파드를 죽인다** — 엉뚱한 이유로 클러스터가 깨진다 | + +적용 직후 확인했다. + +``` +파드 상태: 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 외 전부 거부이므로. + +### 조치 + +```bash +# 정확한 튜플로 지정해야 지워진다. --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 # 역방향 +``` + +**양쪽 노드에서, 양쪽 방향으로** 지워야 한다. 서버 쪽 노드에는 튜플이 뒤집혀 +기록되어 있다. + +그리고 **즉시 끊기지 않는다.** + +``` +11:41 conntrack 삭제 +11:44 cluster_size 2 → 1 ← 약 3분 뒤 +``` + +TCP 는 상대가 사라졌음을 **재전송 타임아웃**으로 알아낸다. 소켓은 한동안 +`ESTABLISHED` 로 남아 있다. + +--- + +## 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` 은 매번 파드를 +만들고 지우므로 느리고 경합이 있다. + +게다가 첫 시도의 판정 조건이 + +```sh +[ "$R" != "0.0 0.0 " ] && echo "→ 변화 감지" && break +``` + +여서 **빈 문자열을 "변화"로 읽고 즉시 빠져나왔다.** A-0 에서 똑같은 실수를 +했는데 또 했다. + +> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가 +> 그러라고 있는 것이다. + +```bash +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`** 를 발견했다 — 멤버 수를 직접 +알려주는 지표다. 처음부터 이걸 봤어야 했다. + +```bash +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 추이](evidence/a1-jgroups-transport-block/a1-cluster-size-partition-recovery.png) + +정책이 걸린 채 `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-0` 이 `Ready=false` 였다. 이유를 물었더니 + +```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 로 신고한다.** 그리고 쿠버네티스가 +그 신고를 받아 처리했다. + +``` +=== 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. 복구 + +```bash +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]` | 보냈는데 답이 없음 = **차단되고 있다** | + +```bash +sudo conntrack -L | grep 7800 +sudo conntrack -D -p tcp -s -d --sport --dport +``` + +### FD_SOCK2 와 포트 규약 + +| 프로토콜 | 포트 | 하는 일 | +|---|---|---| +| TCP (트랜스포트) | **7800** | 클러스터 메시지 | +| **FD_SOCK2** | **57800** = 7800 + 50000 | 소켓으로 상대 생존 감시 | + +**방화벽 규칙을 손으로 쓸 때 57800 을 빠뜨리기 쉽다.** + +### MERGE3 + +split brain 이 생긴 뒤 **갈라진 뷰를 다시 합치는** JGroups 프로토콜. +주기적으로 다른 코디네이터의 존재를 확인하고, 발견하면 병합을 개시한다. + +```promql +vendor_jgroups_merge3_get_num_merge_events +``` + +### readiness 프로브와 Service 엔드포인트 + +``` + readiness 실패 → 파드가 Service 의 notReadyAddresses 로 이동 + → kube-proxy 가 그 파드로 라우팅하지 않음 + → 살아 있지만 트래픽은 안 받음 +``` + +**liveness 와 다르다.** liveness 실패는 **재시작**, readiness 실패는 +**격리**다. 클러스터 분단처럼 "재시작해도 안 나아지는" 문제에는 readiness 가 +맞는 신호다. + +--- + +## 11. 재현 절차 (명령어) + +```bash +# 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분 +ssh kc-lab-1 'sudo conntrack -D -p tcp -s -d --sport --dport 7800' +ssh kc-lab-2 'sudo conntrack -D -p tcp -s -d --sport 7800 --dport ' + +# 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 는 기존 연결을 못 끊는다 |