Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3cde457e | ||
|
|
f1e8c35805 | ||
|
|
1de6108157 | ||
|
|
22d873eb4f | ||
|
|
e5ebaeb623 | ||
|
|
006da7d490 | ||
|
|
d5cc2b55a9 | ||
|
|
0bb0e0ac49 | ||
|
|
33878e8880 | ||
|
|
6dce35ec83 |
@@ -0,0 +1,22 @@
|
|||||||
|
[ 1289ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/login:0
|
||||||
|
[ 1417ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "username"): (More info: https://goo.gl/9p2vKq) %o @ https://app2.hyeonworks.com/login:0
|
||||||
|
[ 9024ms] [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
|
||||||
|
[ 9130ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/:0
|
||||||
|
[ 9456ms] [WARNING] Deprecation warning: value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.
|
||||||
|
Arguments:
|
||||||
|
[0] _isAMomentObject: true, _isUTC: false, _useUTC: false, _l: undefined, _i: Thu, 27 Aug 2026 13:03:49, _f: undefined, _strict: undefined, _locale: [object Object]
|
||||||
|
Error
|
||||||
|
at a.createFromInputFallback (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:3)
|
||||||
|
at an (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:25647)
|
||||||
|
at un (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29355)
|
||||||
|
at aa (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29221)
|
||||||
|
at on (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:28938)
|
||||||
|
at sa (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29715)
|
||||||
|
at A (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29748)
|
||||||
|
at a (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:621:89)
|
||||||
|
at f (https://app2.hyeonworks.com/public/build/3719.c065b2e146c4c8347d51.js:1:4635)
|
||||||
|
at u (https://app2.hyeonworks.com/public/build/322.177b4bb01c5d74f9b28f.js:2473:47448) @ https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:620
|
||||||
|
[ 9605ms] [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
|
||||||
|
[ 10620ms] [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
|
||||||
|
[ 11527ms] [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
|
||||||
|
[ 13875ms] [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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[ 144ms] [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
|
||||||
|
[ 153ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/explore:0
|
||||||
|
[ 1077ms] [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
|
||||||
|
[ 2101ms] [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
|
||||||
|
[ 2922ms] [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
|
||||||
|
[ 7323ms] [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
|
||||||
|
[ 9370ms] [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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[ 271ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/explore?schemaVersion=1&orgId=1&panes=%7B%22a%22%3A%7B%22datasource%22%3A%22PBFA97CFB590B2093%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22vendor_statistics_approximate_entries_unique%7Bcache%3D%5C%22sessions%5C%22%7D%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22%7B%7Bpod%7D%7D%20on%20%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-15m%22%2C%22to%22%3A%22now%22%7D%7D%7D:0
|
||||||
|
[ 346ms] [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
|
||||||
|
[ 1512ms] [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
|
||||||
|
[ 2433ms] [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
|
||||||
|
[ 6941ms] [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
|
||||||
|
[ 13188ms] [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
|
||||||
|
[ 21578ms] [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
|
||||||
|
[ 25998ms] [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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[ 766ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step1.html:0
|
||||||
|
[ 781ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step2.html:0
|
||||||
|
[ 17929ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step1.html:0
|
||||||
|
[ 17949ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step2.html:0
|
||||||
|
[ 17981ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/login-status-iframe.html:0
|
||||||
|
[ 18447ms] [WARNING] For accessibility reasons an aria-label should be specified on nav groups if a title isn't @ https://auth.hyeonworks.com/resources/9v5yc/admin/keycloak.v2/assets/main-BbID33M6.js:7
|
||||||
|
[ 18462ms] [WARNING] For accessibility reasons an aria-label should be specified on nav groups if a title isn't @ https://auth.hyeonworks.com/resources/9v5yc/admin/keycloak.v2/assets/main-BbID33M6.js:7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 75ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/questions:0
|
||||||
|
[ 100ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
[ 295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
[ 149ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
[ 125ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
[ 91ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
[ 132ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
[ 88ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
[ 239ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 105ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 108ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
- generic [ref=f7e3]:
|
|
||||||
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
|
|
||||||
- /url: "#main-content"
|
|
||||||
- banner [ref=f7e5]:
|
|
||||||
- generic [ref=f7e6]:
|
|
||||||
- link "TechLog 홈" [ref=f7e8] [cursor=pointer]:
|
|
||||||
- /url: /
|
|
||||||
- text: TechLog
|
|
||||||
- generic [ref=f7e9]:
|
|
||||||
- button "TechLog 검색 열기" [ref=f7e11] [cursor=pointer]: 검색
|
|
||||||
- group [ref=f7e12]:
|
|
||||||
- generic "메뉴" [ref=f7e13] [cursor=pointer]
|
|
||||||
- generic [ref=f7e14]:
|
|
||||||
- paragraph [ref=f7e15]: 화면을 준비하고 있습니다.
|
|
||||||
- generic [ref=f7e16]: TechLog 로딩 중
|
|
||||||
- contentinfo [ref=f7e17]:
|
|
||||||
- generic [ref=f7e18]:
|
|
||||||
- generic [ref=f7e19]:
|
|
||||||
- paragraph [ref=f7e20]: 동현
|
|
||||||
- paragraph [ref=f7e21]: 문제를 재현하고 검증해 실제 운영에 적용할 수 있는 형태로 정리합니다.
|
|
||||||
- generic [ref=f7e22]:
|
|
||||||
- link "프로필" [ref=f7e23] [cursor=pointer]:
|
|
||||||
- /url: /profile
|
|
||||||
- link "변경 기록" [ref=f7e24] [cursor=pointer]:
|
|
||||||
- /url: /releases
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- main [ref=e7]:
|
||||||
|
- status "Loading" [ref=e10]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- main [ref=f3e7]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- main [ref=f6e7]:
|
||||||
|
- status "Loading" [ref=f6e10]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
- generic [ref=f9e4]:
|
||||||
|
- link "Skip to main content" [ref=f9e5] [cursor=pointer]:
|
||||||
|
- /url: "#pageContent"
|
||||||
|
- banner [ref=f9e7]:
|
||||||
|
- generic [ref=f9e8]:
|
||||||
|
- link [ref=f9e10] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img "Grafana" [ref=f9e11]
|
||||||
|
- generic [ref=f9e14]:
|
||||||
|
- button "Search or jump to..." [ref=f9e18] [cursor=pointer]
|
||||||
|
- generic [ref=f9e19]: ctrl+k
|
||||||
|
- generic [ref=f9e23]:
|
||||||
|
- button "New" [ref=f9e24] [cursor=pointer]
|
||||||
|
- button "Help" [ref=f9e30] [cursor=pointer]
|
||||||
|
- button "News" [ref=f9e33] [cursor=pointer]
|
||||||
|
- button "Profile" [ref=f9e36] [cursor=pointer]:
|
||||||
|
- img "User avatar" [ref=f9e37]
|
||||||
|
- generic [ref=f9e38]:
|
||||||
|
- button "Open menu" [ref=f9e40] [cursor=pointer]
|
||||||
|
- navigation "Breadcrumbs" [ref=f9e43]:
|
||||||
|
- list [ref=f9e44]:
|
||||||
|
- listitem [ref=f9e45]:
|
||||||
|
- link "Home" [ref=f9e46] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- listitem [ref=f9e50]:
|
||||||
|
- link "Explore" [ref=f9e51] [cursor=pointer]:
|
||||||
|
- /url: /explore
|
||||||
|
- listitem [ref=f9e55]:
|
||||||
|
- generic "Prometheus" [ref=f9e56]
|
||||||
|
- generic [ref=f9e57]:
|
||||||
|
- button "Show more items" [ref=f9e60] [cursor=pointer]
|
||||||
|
- button "Toggle top search bar" [ref=f9e64] [cursor=pointer]
|
||||||
|
- main [ref=f9e70]:
|
||||||
|
- generic [ref=f9e72]:
|
||||||
|
- heading "Explore" [level=1] [ref=f9e73]
|
||||||
|
- generic [ref=f9e78]:
|
||||||
|
- navigation "Explore toolbar" [ref=f9e80]:
|
||||||
|
- navigation "Search links" [ref=f9e82]:
|
||||||
|
- generic [ref=f9e83]:
|
||||||
|
- button "Content outline" [expanded] [ref=f9e85] [cursor=pointer]:
|
||||||
|
- generic [ref=f9e88]: Outline
|
||||||
|
- generic [ref=f9e93] [cursor=pointer]:
|
||||||
|
- img "Prometheus logo" [ref=f9e95]
|
||||||
|
- textbox "Select a data source" [ref=f9e96]:
|
||||||
|
- /placeholder: ""
|
||||||
|
- button "Show more items" [ref=f9e102] [cursor=pointer]
|
||||||
|
- generic [ref=f9e106]:
|
||||||
|
- generic [ref=f9e110]:
|
||||||
|
- button "Collapse outline" [expanded] [ref=f9e112] [cursor=pointer]:
|
||||||
|
- img "arrow-from-right" [ref=f9e113]
|
||||||
|
- button "Queries" [ref=f9e116] [cursor=pointer]:
|
||||||
|
- img "arrow" [ref=f9e117]
|
||||||
|
- generic [ref=f9e124]:
|
||||||
|
- generic [ref=f9e126]:
|
||||||
|
- generic "Query editor row" [ref=f9e129]:
|
||||||
|
- generic [ref=f9e130]:
|
||||||
|
- generic [ref=f9e132]:
|
||||||
|
- generic [ref=f9e133]:
|
||||||
|
- button "Collapse query row" [expanded] [ref=f9e134] [cursor=pointer]
|
||||||
|
- generic [ref=f9e137]:
|
||||||
|
- button "Query editor row title A" [ref=f9e138] [cursor=pointer]:
|
||||||
|
- generic [ref=f9e139]: A
|
||||||
|
- emphasis [ref=f9e140]: (Prometheus)
|
||||||
|
- generic [ref=f9e141]:
|
||||||
|
- button "Show data source help" [ref=f9e143] [cursor=pointer]
|
||||||
|
- button "Duplicate query" [ref=f9e147] [cursor=pointer]
|
||||||
|
- button "Hide response" [ref=f9e151] [cursor=pointer]
|
||||||
|
- button "Remove query" [ref=f9e155] [cursor=pointer]
|
||||||
|
- button "Drag and drop to reorder" [ref=f9e158]:
|
||||||
|
- img "Drag and drop to reorder" [ref=f9e159]
|
||||||
|
- generic [ref=f9e162]:
|
||||||
|
- generic [ref=f9e163]:
|
||||||
|
- button "Kick start your query" [ref=f9e164] [cursor=pointer]
|
||||||
|
- generic [ref=f9e167]:
|
||||||
|
- generic [ref=f9e168] [cursor=pointer]: Explain
|
||||||
|
- generic [ref=f9e169]:
|
||||||
|
- checkbox "Explain Toggle switch" [ref=f9e170]
|
||||||
|
- generic "Toggle switch" [ref=f9e171] [cursor=pointer]
|
||||||
|
- radiogroup [ref=f9e176]:
|
||||||
|
- generic [ref=f9e177]:
|
||||||
|
- radio "Builder" [ref=f9e178] [cursor=pointer]
|
||||||
|
- generic [ref=f9e179] [cursor=pointer]: Builder
|
||||||
|
- generic [ref=f9e180]:
|
||||||
|
- radio "Code" [checked] [ref=f9e181] [cursor=pointer]
|
||||||
|
- generic [ref=f9e182] [cursor=pointer]: Code
|
||||||
|
- generic [ref=f9e184]:
|
||||||
|
- generic [ref=f9e186]:
|
||||||
|
- button "Loading metrics..." [disabled] [ref=f9e187] [cursor=pointer]
|
||||||
|
- generic [ref=f9e190]: Loading editor
|
||||||
|
- 'button "Options Legend: {{pod}} on {{node}} Format: Time series Step: auto Type: Range Exemplars: false" [ref=f9e198] [cursor=pointer]':
|
||||||
|
- generic [ref=f9e202]:
|
||||||
|
- heading "Options" [level=6] [ref=f9e203]
|
||||||
|
- generic [ref=f9e204]:
|
||||||
|
- generic [ref=f9e205]: "Legend: {{pod}} on {{node}}"
|
||||||
|
- generic [ref=f9e206]: "Format: Time series"
|
||||||
|
- generic [ref=f9e207]: "Step: auto"
|
||||||
|
- generic [ref=f9e208]: "Type: Range"
|
||||||
|
- generic [ref=f9e209]: "Exemplars: false"
|
||||||
|
- generic [ref=f9e210]:
|
||||||
|
- button "Add query" [ref=f9e211] [cursor=pointer]
|
||||||
|
- button "Query history" [ref=f9e215] [cursor=pointer]
|
||||||
|
- button "Query inspector" [ref=f9e219] [cursor=pointer]
|
||||||
|
- generic:
|
||||||
|
- main
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- main [ref=f12e3]:
|
||||||
|
- generic [ref=f12e4]:
|
||||||
|
- progressbar "Contents" [ref=f12e5]
|
||||||
|
- paragraph [ref=f12e8]: Loading the Administration Console
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- generic [active] [ref=f15e1]:
|
||||||
|
- progressbar "Loading" [ref=f15e4]
|
||||||
|
- generic:
|
||||||
|
- list
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [active] [ref=f18e1]: Not Found
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Keycloak multi-node cluster with PostgreSQL.
|
||||||
|
#
|
||||||
|
# Goal of this manifest: two Keycloak pods on two different nodes must discover
|
||||||
|
# each other and form one Infinispan cluster. Keycloak 26 discovers peers through
|
||||||
|
# the database (jdbc-ping) rather than multicast, writing to a JGROUPS_PING table,
|
||||||
|
# but the cluster traffic itself runs over TCP 7800 between the pods. Those are
|
||||||
|
# two separate mechanisms, which is why "registered in the DB but not clustered"
|
||||||
|
# is a real failure mode — and one that a single node cannot reproduce.
|
||||||
|
#
|
||||||
|
# kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||||
|
# kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||||
|
#
|
||||||
|
# Secrets are plain here. Proper secret handling is roadmap item 11; keeping it
|
||||||
|
# visible for now is deliberate so the gap is obvious rather than forgotten.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: keycloak-lab
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: keycloak-lab-secrets
|
||||||
|
namespace: keycloak-lab
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_PASSWORD: lab-postgres-change-me
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: lab-admin-change-me
|
||||||
|
---
|
||||||
|
# PostgreSQL. local-path binds the volume to whichever node the pod lands on, so
|
||||||
|
# the database is effectively pinned to one node. That is not a flaw here: it is
|
||||||
|
# what makes "the database node dies" a meaningful experiment later.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: postgres-data
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate # RWO volume cannot be mounted by two pods at once
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
name: postgres
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: keycloak
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: keycloak
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycloak-lab-secrets
|
||||||
|
key: POSTGRES_PASSWORD
|
||||||
|
# The image refuses to initialise into a non-empty mount, and
|
||||||
|
# local-path volumes are clean, but this keeps the data one level
|
||||||
|
# down so a lost+found or similar never blocks initdb.
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["sh", "-c", "pg_isready -U keycloak -d keycloak"]
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 192Mi
|
||||||
|
cpu: 50m
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: postgres-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: postgres
|
||||||
|
---
|
||||||
|
# Keycloak. A StatefulSet rather than a Deployment so each pod keeps a stable
|
||||||
|
# name (keycloak-0, keycloak-1); cluster membership is far easier to read in
|
||||||
|
# logs and in the JGROUPS_PING table when the identities do not churn.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: keycloak
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
serviceName: keycloak-headless
|
||||||
|
replicas: 2
|
||||||
|
podManagementPolicy: Parallel # both pods start together, so they race to
|
||||||
|
# register — which is the interesting case
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: keycloak
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: keycloak
|
||||||
|
spec:
|
||||||
|
# One pod per node. Two pods on one node would share a kernel and make the
|
||||||
|
# 7800 blocking experiment meaningless.
|
||||||
|
topologySpreadConstraints:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: keycloak
|
||||||
|
containers:
|
||||||
|
- name: keycloak
|
||||||
|
image: quay.io/keycloak/keycloak:26.7.0
|
||||||
|
# "start", not "start-dev". Dev mode forces cache=local and there is
|
||||||
|
# no cluster to form at all.
|
||||||
|
args: ["start"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
- containerPort: 9000
|
||||||
|
name: management
|
||||||
|
- containerPort: 7800
|
||||||
|
name: jgroups
|
||||||
|
env:
|
||||||
|
- name: KC_DB
|
||||||
|
value: postgres
|
||||||
|
- name: KC_DB_URL
|
||||||
|
value: jdbc:postgresql://postgres:5432/keycloak
|
||||||
|
- name: KC_DB_USERNAME
|
||||||
|
value: keycloak
|
||||||
|
- name: KC_DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycloak-lab-secrets
|
||||||
|
key: POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
# Settings confirmed by the two-hop header measurement.
|
||||||
|
# KC_HOSTNAME carries the full external URL, which pins scheme and
|
||||||
|
# host for issuer and redirect URLs regardless of headers.
|
||||||
|
# KC_PROXY_HEADERS is the separate opt-in that lets the forwarded
|
||||||
|
# client address through — the same kind of switch as Spring's
|
||||||
|
# forward-headers-strategy. See docs/two-hop-proxy-header-contract.md.
|
||||||
|
- name: KC_HOSTNAME
|
||||||
|
value: https://auth.hyeonworks.com
|
||||||
|
- name: KC_HOSTNAME_STRICT
|
||||||
|
value: "true"
|
||||||
|
- name: KC_PROXY_HEADERS
|
||||||
|
value: xforwarded
|
||||||
|
- name: KC_HTTP_ENABLED
|
||||||
|
value: "true"
|
||||||
|
|
||||||
|
- name: KC_HEALTH_ENABLED
|
||||||
|
value: "true"
|
||||||
|
- name: KC_METRICS_ENABLED
|
||||||
|
value: "true"
|
||||||
|
|
||||||
|
# Without an explicit cap the JVM sizes its heap from the container
|
||||||
|
# limit and this lab has roughly 3.8GB of guest headroom in total.
|
||||||
|
- name: JAVA_OPTS_KC_HEAP
|
||||||
|
value: "-Xms256m -Xmx512m"
|
||||||
|
|
||||||
|
- name: KC_BOOTSTRAP_ADMIN_USERNAME
|
||||||
|
value: admin
|
||||||
|
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycloak-lab-secrets
|
||||||
|
key: KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
# Keycloak serves health and metrics on the management port (9000),
|
||||||
|
# not on 8080, since version 25.
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/started
|
||||||
|
port: management
|
||||||
|
periodSeconds: 10
|
||||||
|
failureThreshold: 60 # first boot runs an implicit build
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: management
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: management
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 640Mi
|
||||||
|
cpu: 100m
|
||||||
|
limits:
|
||||||
|
memory: 900Mi
|
||||||
|
---
|
||||||
|
# Headless service. Not required for jdbc-ping discovery, which goes through the
|
||||||
|
# database, but it gives each pod a stable DNS name for direct inspection.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: keycloak-headless
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app: keycloak
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
targetPort: http
|
||||||
|
name: http
|
||||||
|
- port: 9000
|
||||||
|
targetPort: management
|
||||||
|
name: management
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: keycloak
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: keycloak
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
targetPort: http
|
||||||
|
name: http
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: keycloak
|
||||||
|
namespace: keycloak-lab
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: auth.hyeonworks.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: keycloak
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# Prometheus + node-exporter + Grafana.
|
||||||
|
#
|
||||||
|
# Purpose: during a fault-injection experiment, know *which signal moved first*.
|
||||||
|
# Without a metrics store the only record is whatever scrolled past in a terminal,
|
||||||
|
# and "the cluster recovered in about a minute" is not a measurement.
|
||||||
|
#
|
||||||
|
# kubectl apply -f deploy/lab/k8s/observability.yaml
|
||||||
|
# kubectl -n observability rollout status deployment/prometheus --timeout=300s
|
||||||
|
#
|
||||||
|
# Placement decision — Prometheus and Grafana are pinned to the control-plane
|
||||||
|
# node (kc-lab-1). An observability stack must not share a failure domain with
|
||||||
|
# the thing it observes. With only two nodes that cannot be fully avoided, so the
|
||||||
|
# rule here is: the node that gets killed in experiments is the *agent*
|
||||||
|
# (kc-lab-2, holding keycloak-0 and postgres), and everything needed to watch
|
||||||
|
# that happen lives on the server node.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: observability
|
||||||
|
---
|
||||||
|
# Prometheus discovers scrape targets by querying the Kubernetes API, so it
|
||||||
|
# needs read access to nodes, services, endpoints and pods. Without this the
|
||||||
|
# kubernetes_sd_configs below silently return no targets.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: observability
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
# nodes/proxy is required in addition to nodes/metrics: the kubelet job
|
||||||
|
# reaches each node through the API server's proxy subresource
|
||||||
|
# (/api/v1/nodes/<name>/proxy/metrics). Without it every kubelet target
|
||||||
|
# fails with 403 Forbidden while the other jobs stay green — a partial
|
||||||
|
# failure that is easy to miss unless the target list is checked.
|
||||||
|
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
- nonResourceURLs: ["/metrics"]
|
||||||
|
verbs: [get]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: prometheus
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: prometheus
|
||||||
|
namespace: observability
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: prometheus-config
|
||||||
|
namespace: observability
|
||||||
|
data:
|
||||||
|
prometheus.yml: |
|
||||||
|
global:
|
||||||
|
# 15s is short for production but right here: a node loss should show up
|
||||||
|
# within a couple of samples, not a minute later.
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# Prometheus scraping itself. Useful as a control: if this target is down,
|
||||||
|
# the problem is Prometheus, not the thing being measured.
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
# Keycloak. Metrics live on the management port 9000, not 8080 — the same
|
||||||
|
# split that the health probes use. KC_METRICS_ENABLED=true is already set
|
||||||
|
# on the StatefulSet.
|
||||||
|
#
|
||||||
|
# Discovery is by endpoints rather than a static list because pod IPs
|
||||||
|
# change on every restart; that was observed directly when the lab was
|
||||||
|
# power-cycled and every pod came back with a new address.
|
||||||
|
- job_name: keycloak
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: endpoints
|
||||||
|
namespaces:
|
||||||
|
names: [keycloak-lab]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
|
||||||
|
action: keep
|
||||||
|
regex: keycloak-headless;management
|
||||||
|
- source_labels: [__meta_kubernetes_pod_name]
|
||||||
|
target_label: pod
|
||||||
|
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||||
|
target_label: node
|
||||||
|
|
||||||
|
# node-exporter, one per node via DaemonSet. This is what answers
|
||||||
|
# "did the machine die or did the process die".
|
||||||
|
- job_name: node-exporter
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: endpoints
|
||||||
|
namespaces:
|
||||||
|
names: [observability]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_service_name]
|
||||||
|
action: keep
|
||||||
|
regex: node-exporter
|
||||||
|
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||||
|
target_label: node
|
||||||
|
|
||||||
|
# The kubelet's own metrics, reached through the API server proxy so no
|
||||||
|
# extra port needs opening.
|
||||||
|
- job_name: kubelet
|
||||||
|
scheme: https
|
||||||
|
tls_config:
|
||||||
|
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||||
|
insecure_skip_verify: true
|
||||||
|
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: node
|
||||||
|
relabel_configs:
|
||||||
|
- action: labelmap
|
||||||
|
regex: __meta_kubernetes_node_label_(.+)
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: kubernetes.default.svc:443
|
||||||
|
- source_labels: [__meta_kubernetes_node_name]
|
||||||
|
regex: (.+)
|
||||||
|
target_label: __metrics_path__
|
||||||
|
replacement: /api/v1/nodes/${1}/proxy/metrics
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: prometheus-data
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate # RWO volume; two pods cannot mount it at once
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: prometheus
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: prometheus
|
||||||
|
spec:
|
||||||
|
serviceAccountName: prometheus
|
||||||
|
# See the placement note at the top of this file.
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 65534 # the image runs as nobody and must own the volume
|
||||||
|
containers:
|
||||||
|
- name: prometheus
|
||||||
|
image: prom/prometheus:v3.1.0
|
||||||
|
args:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
# 7 days is far more than an experiment needs and keeps the volume
|
||||||
|
# small enough that it never becomes the reason a node fills up.
|
||||||
|
- --storage.tsdb.retention.time=7d
|
||||||
|
- --web.enable-lifecycle
|
||||||
|
ports:
|
||||||
|
- containerPort: 9090
|
||||||
|
name: http
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/prometheus
|
||||||
|
- name: data
|
||||||
|
mountPath: /prometheus
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /-/ready, port: http }
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: { path: /-/healthy, port: http }
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
resources:
|
||||||
|
requests: { memory: 256Mi, cpu: 50m }
|
||||||
|
limits: { memory: 640Mi }
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: prometheus-config
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: prometheus-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: prometheus
|
||||||
|
ports:
|
||||||
|
- port: 9090
|
||||||
|
targetPort: http
|
||||||
|
---
|
||||||
|
# node-exporter. A DaemonSet so every node reports, including one that is about
|
||||||
|
# to be killed — the last samples before it goes silent are the interesting part.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: node-exporter
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: node-exporter
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: node-exporter
|
||||||
|
spec:
|
||||||
|
# Host namespaces: the point is to measure the machine, not the container.
|
||||||
|
hostNetwork: true
|
||||||
|
hostPID: true
|
||||||
|
tolerations:
|
||||||
|
- operator: Exists # must also run on tainted nodes
|
||||||
|
containers:
|
||||||
|
- name: node-exporter
|
||||||
|
image: prom/node-exporter:v1.8.2
|
||||||
|
args:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/host/root
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
|
||||||
|
ports:
|
||||||
|
- containerPort: 9100
|
||||||
|
name: metrics
|
||||||
|
hostPort: 9100
|
||||||
|
volumeMounts:
|
||||||
|
- { name: proc, mountPath: /host/proc, readOnly: true }
|
||||||
|
- { name: sys, mountPath: /host/sys, readOnly: true }
|
||||||
|
- { name: rootfs, mountPath: /host/root, readOnly: true, mountPropagation: HostToContainer }
|
||||||
|
resources:
|
||||||
|
requests: { memory: 32Mi, cpu: 20m }
|
||||||
|
limits: { memory: 96Mi }
|
||||||
|
volumes:
|
||||||
|
- { name: proc, hostPath: { path: /proc } }
|
||||||
|
- { name: sys, hostPath: { path: /sys } }
|
||||||
|
- { name: rootfs, hostPath: { path: / } }
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: node-exporter
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
clusterIP: None # headless: Prometheus wants each pod, not a VIP
|
||||||
|
selector:
|
||||||
|
app: node-exporter
|
||||||
|
ports:
|
||||||
|
- port: 9100
|
||||||
|
targetPort: metrics
|
||||||
|
name: metrics
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: grafana
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: grafana
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
containers:
|
||||||
|
- name: grafana
|
||||||
|
image: grafana/grafana:11.4.0
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: GF_SECURITY_ADMIN_USER
|
||||||
|
value: admin
|
||||||
|
- name: GF_SECURITY_ADMIN_PASSWORD
|
||||||
|
value: lab-grafana-change-me
|
||||||
|
# Grafana builds absolute URLs for redirects and asset paths. Behind
|
||||||
|
# the nginx -> Traefik chain it must be told the external address,
|
||||||
|
# for exactly the reason Keycloak needs KC_HOSTNAME. Without it,
|
||||||
|
# login redirects come back as http://<pod-ip>:3000.
|
||||||
|
- name: GF_SERVER_ROOT_URL
|
||||||
|
value: https://app2.hyeonworks.com
|
||||||
|
volumeMounts:
|
||||||
|
- name: datasources
|
||||||
|
mountPath: /etc/grafana/provisioning/datasources
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /api/health, port: http }
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
resources:
|
||||||
|
requests: { memory: 128Mi, cpu: 50m }
|
||||||
|
limits: { memory: 320Mi }
|
||||||
|
volumes:
|
||||||
|
- name: datasources
|
||||||
|
configMap:
|
||||||
|
name: grafana-datasources
|
||||||
|
---
|
||||||
|
# Provisioning the datasource as a file means Grafana comes up already wired to
|
||||||
|
# Prometheus. Clicking through the UI would leave the configuration only in
|
||||||
|
# Grafana's own database, which is emptyDir here and disappears on restart.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-datasources
|
||||||
|
namespace: observability
|
||||||
|
data:
|
||||||
|
prometheus.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus.observability.svc:9090
|
||||||
|
isDefault: true
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: grafana
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: http
|
||||||
|
---
|
||||||
|
# Grafana is published on app2.hyeonworks.com because that name is already in
|
||||||
|
# the wildcard-free certificate (auth / app1 / app2) and is otherwise unused.
|
||||||
|
# It moves when app2 is needed for the SSO experiment.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: observability
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: app2.hyeonworks.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Experiment 0c — where does a session entry actually live?
|
||||||
|
#
|
||||||
|
# Experiment 0b showed keycloak-1's session cache never moved when keycloak-0
|
||||||
|
# handled a login. That leaves two explanations:
|
||||||
|
#
|
||||||
|
# (a) a DISTRIBUTED cache with owners=1 — entries are spread across nodes by
|
||||||
|
# consistent hashing, and this one happened to land on keycloak-0;
|
||||||
|
# (b) a LOCAL cache — each node only ever caches what it handled itself.
|
||||||
|
#
|
||||||
|
# They are distinguished by driving logins at the OTHER node. Under (a) the
|
||||||
|
# entries would keep landing on both nodes regardless of who was asked. Under
|
||||||
|
# (b) the count rises only on the node that received the request.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NS="${NS:-keycloak-lab}"
|
||||||
|
N="${N:-5}"
|
||||||
|
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
|
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo " keycloak-0 = $K0_IP ($(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.spec.nodeName}'))"
|
||||||
|
echo " keycloak-1 = $K1_IP ($(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.spec.nodeName}'))"
|
||||||
|
echo
|
||||||
|
|
||||||
|
kubectl -n "$NS" run kc-own --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
|
||||||
|
O=/tmp/o; : > \$O
|
||||||
|
ent() {
|
||||||
|
curl -s --retry 3 --max-time 20 http://\$1:9000/metrics \
|
||||||
|
| grep -E '^vendor_statistics_approximate_entries_unique.cache=.sessions' \
|
||||||
|
| awk '{print \$NF}'
|
||||||
|
}
|
||||||
|
login() { i=0; while [ \$i -lt $N ]; do
|
||||||
|
curl -s -o /dev/null -X POST http://\$1:8080/realms/master/protocol/openid-connect/token \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d 'password=$ADMIN_PW'
|
||||||
|
i=\$((i+1)); done; sleep 5; }
|
||||||
|
{
|
||||||
|
printf '%-32s %12s %12s\n' '단계' 'k0 entries' 'k1 entries'
|
||||||
|
printf '%-32s %12s %12s\n' '시작' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
|
||||||
|
login $K1_IP
|
||||||
|
printf '%-32s %12s %12s\n' 'keycloak-1 에 로그인 ${N}회' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
|
||||||
|
login $K0_IP
|
||||||
|
printf '%-32s %12s %12s\n' 'keycloak-0 에 로그인 ${N}회' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
|
||||||
|
} >> \$O
|
||||||
|
cat \$O
|
||||||
|
" 2>&1 | grep -v '^pod .* deleted$'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 대조: PostgreSQL 에는 몇 건인가 ==="
|
||||||
|
kubectl -n "$NS" exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session where offline_flag='0'" 2>/dev/null | sed 's/^/ online 세션 /'
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Experiment 0b — does the Infinispan cache itself replicate, or do both nodes
|
||||||
|
# merely agree because they read the same database?
|
||||||
|
#
|
||||||
|
# Experiment 0 proved the two nodes give the same answers. That alone does NOT
|
||||||
|
# prove Infinispan replicated anything: with persistent-user-sessions (the
|
||||||
|
# Keycloak 26 default) the session is written to PostgreSQL, so two nodes reading
|
||||||
|
# one database would agree even with the cache disabled entirely.
|
||||||
|
#
|
||||||
|
# This script separates the two by measuring the cache counters on BOTH nodes
|
||||||
|
# around a single login. If the write on keycloak-0 shows up as cache activity
|
||||||
|
# on keycloak-1, the replication is real and not a database artifact.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NS="${NS:-keycloak-lab}"
|
||||||
|
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
|
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 파드 출력을 스트리밍으로 받으면 조각이 유실된다. 실제로 첫 시도에서
|
||||||
|
# keycloak-1 의 스냅샷과 그 다음 마커가 통째로 사라져 델타가 0 으로 보였다.
|
||||||
|
# 파드 안에서 파일로 모았다가 마지막에 한 번만 내보낸다.
|
||||||
|
kubectl -n "$NS" run kc-delta --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
|
||||||
|
set -u
|
||||||
|
K0='http://$K0_IP'; K1='http://$K1_IP'
|
||||||
|
O=/tmp/o.txt; : > \$O
|
||||||
|
snap() {
|
||||||
|
curl -s --retry 3 --retry-connrefused --max-time 20 \$1:9000/metrics \
|
||||||
|
| grep -E '^vendor_(statistics_(stores|hits|misses|approximate_entries_unique)|rpc_manager_replication_count)\{cache=\"(sessions|clientSessions)\"' \
|
||||||
|
| sed 's/,cache_manager=\"keycloak\"//; s/,node=\"[^\"]*\"//' >> \$O
|
||||||
|
}
|
||||||
|
echo '###BEFORE_K0' >> \$O; snap \$K0
|
||||||
|
echo '###BEFORE_K1' >> \$O; snap \$K1
|
||||||
|
echo '###LOGIN' >> \$O
|
||||||
|
curl -s -o /dev/null -w 'http_code=%{http_code}\n' -X POST \
|
||||||
|
\"\$K0:8080/realms/master/protocol/openid-connect/token\" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d 'password=$ADMIN_PW' >> \$O
|
||||||
|
sleep 5
|
||||||
|
echo '###AFTER_K0' >> \$O; snap \$K0
|
||||||
|
echo '###AFTER_K1' >> \$O; snap \$K1
|
||||||
|
echo '###END' >> \$O
|
||||||
|
cat \$O
|
||||||
|
" 2>&1 | grep -v '^pod .* deleted$' > /tmp/cache-delta.txt
|
||||||
|
|
||||||
|
python3 - /tmp/cache-delta.txt <<'PY'
|
||||||
|
import re, sys
|
||||||
|
raw = open(sys.argv[1]).read()
|
||||||
|
blocks, cur = {}, None
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if line.startswith('###'):
|
||||||
|
cur = line[3:]; blocks[cur] = {}
|
||||||
|
elif cur and '{' in line:
|
||||||
|
m = re.match(r'(\S+?)\{cache="(\w+)"\}\s+(\S+)', line)
|
||||||
|
if m:
|
||||||
|
blocks[cur][(m.group(1), m.group(2))] = float(m.group(3))
|
||||||
|
|
||||||
|
print('=== 로그인은 keycloak-0 에만 보냈다 ===')
|
||||||
|
code = [l for l in raw.splitlines() if l.startswith('http_code=')]
|
||||||
|
print(' 로그인 응답: ' + (code[0] if code else '없음'))
|
||||||
|
for n in ('BEFORE_K0','BEFORE_K1','AFTER_K0','AFTER_K1'):
|
||||||
|
if not blocks.get(n):
|
||||||
|
print(f' !! {n} 스냅샷이 비었다 — 델타를 신뢰할 수 없다')
|
||||||
|
print()
|
||||||
|
hdr = f" {'계수기':<42} {'캐시':<15} {'전':>8} {'후':>8} {'증가':>7}"
|
||||||
|
for node in ('K0', 'K1'):
|
||||||
|
who = 'keycloak-0 (로그인을 받은 노드)' if node == 'K0' else 'keycloak-1 (아무 요청도 받지 않은 노드)'
|
||||||
|
print(f'=== {who} ===')
|
||||||
|
print(hdr)
|
||||||
|
b, a = blocks.get(f'BEFORE_{node}', {}), blocks.get(f'AFTER_{node}', {})
|
||||||
|
for k in sorted(set(b) | set(a)):
|
||||||
|
before, after = b.get(k[0:2], 0.0), a.get(k[0:2], 0.0)
|
||||||
|
d = after - before
|
||||||
|
mark = ' ←' if d else ''
|
||||||
|
name = k[0].replace('vendor_statistics_', '').replace('vendor_rpc_manager_', 'rpc.')
|
||||||
|
print(f" {name:<42} {k[1]:<15} {before:>8.0f} {after:>8.0f} {d:>+7.0f}{mark}")
|
||||||
|
print()
|
||||||
|
PY
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Experiment 0d — capture the actual SQL that the OTHER node runs.
|
||||||
|
#
|
||||||
|
# Experiments 0b/0c showed that session entries never appear in keycloak-1's
|
||||||
|
# memory, yet keycloak-1 can use a session keycloak-0 created. The conclusion
|
||||||
|
# "keycloak-1 reads it from PostgreSQL" was an inference, not an observation.
|
||||||
|
#
|
||||||
|
# This script turns on statement logging in PostgreSQL for a few seconds, sends
|
||||||
|
# ONE refresh request to keycloak-1 for a session born on keycloak-0, and greps
|
||||||
|
# the database log for that session id. If the inference is right, the SQL is
|
||||||
|
# there, issued from keycloak-1's pod IP.
|
||||||
|
#
|
||||||
|
# It also checks whether serving that request makes keycloak-1 cache the session
|
||||||
|
# — which sharpens "each node caches what it handled" from "what it logged in"
|
||||||
|
# to "what it touched".
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NS="${NS:-keycloak-lab}"
|
||||||
|
PSQL="kubectl -n $NS exec deploy/postgres -- psql -U keycloak -d keycloak -tAc"
|
||||||
|
|
||||||
|
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
|
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo " keycloak-0 = $K0_IP (세션을 만드는 노드)"
|
||||||
|
echo " keycloak-1 = $K1_IP (읽기만 하는 노드)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# %h 를 넣어야 어느 파드가 보낸 질의인지 로그에서 구분된다.
|
||||||
|
echo "=== PostgreSQL 문장 로깅을 켠다 ==="
|
||||||
|
$PSQL "alter system set log_statement='all'" >/dev/null 2>&1
|
||||||
|
$PSQL "alter system set log_line_prefix='%m [%p] %h '" >/dev/null 2>&1
|
||||||
|
$PSQL "select pg_reload_conf()" >/dev/null 2>&1
|
||||||
|
echo " log_statement = $($PSQL 'show log_statement' 2>/dev/null)"
|
||||||
|
echo " log_line_prefix = $($PSQL 'show log_line_prefix' 2>/dev/null)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 로그 커서를 잡아둔다. 이 줄 수 이후만 본다.
|
||||||
|
LOG_BEFORE=$(kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
RESULT=$(kubectl -n "$NS" run kc-readpath --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
|
||||||
|
O=/tmp/o; : > \$O
|
||||||
|
TOKEN_EP='/realms/master/protocol/openid-connect/token'
|
||||||
|
jget() { sed -n \"s/.*\\\"\$1\\\":\\\"\\([^\\\"]*\\)\\\".*/\\1/p\"; }
|
||||||
|
ent() {
|
||||||
|
curl -s --retry 3 --max-time 20 http://\$1:9000/metrics \
|
||||||
|
| grep -E '^vendor_statistics_approximate_entries_unique.cache=.sessions' | awk '{print \$NF}'
|
||||||
|
}
|
||||||
|
# keycloak-0 에서 로그인한다
|
||||||
|
L=\$(curl -s -X POST \"http://$K0_IP:8080\$TOKEN_EP\" -d grant_type=password \
|
||||||
|
-d client_id=admin-cli -d username=admin -d 'password=$ADMIN_PW')
|
||||||
|
SID=\$(echo \"\$L\" | jget access_token | cut -d. -f2 | sed 's/\$/==/' | base64 -d 2>/dev/null | jget sid)
|
||||||
|
RT=\$(echo \"\$L\" | jget refresh_token)
|
||||||
|
echo \"SID=\$SID\" >> \$O
|
||||||
|
echo \"K1_ENTRIES_BEFORE=\$(ent $K1_IP)\" >> \$O
|
||||||
|
sleep 2
|
||||||
|
# 반대편 노드에 refresh 를 딱 한 번 보낸다
|
||||||
|
# 인용을 한 겹 더 쌓으면 curl 이 URL 을 통째로 못 읽는다. 실제로 000 이 나왔다.
|
||||||
|
CODE=\$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
\"http://$K1_IP:8080\$TOKEN_EP\" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\")
|
||||||
|
echo \"REFRESH_ON_K1=\$CODE\" >> \$O
|
||||||
|
sleep 3
|
||||||
|
echo \"K1_ENTRIES_AFTER=\$(ent $K1_IP)\" >> \$O
|
||||||
|
cat \$O
|
||||||
|
" 2>&1 | grep -v '^pod .* deleted$')
|
||||||
|
|
||||||
|
echo "=== 요청 ==="
|
||||||
|
echo "$RESULT" | sed 's/^/ /'
|
||||||
|
SID=$(echo "$RESULT" | sed -n 's/^SID=//p')
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== PostgreSQL 문장 로깅을 끈다 ==="
|
||||||
|
$PSQL "alter system reset log_statement" >/dev/null 2>&1
|
||||||
|
$PSQL "alter system reset log_line_prefix" >/dev/null 2>&1
|
||||||
|
$PSQL "select pg_reload_conf()" >/dev/null 2>&1
|
||||||
|
echo " log_statement = $($PSQL 'show log_statement' 2>/dev/null)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== keycloak-1 이 실제로 보낸 SQL 문장 ==="
|
||||||
|
echo " (파라미터가 \$1 로 묶여 있어, sid 는 바로 아래 DETAIL 줄에 있다)"
|
||||||
|
echo
|
||||||
|
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
|
||||||
|
| tail -n +$((LOG_BEFORE + 1)) \
|
||||||
|
| grep -F "$K1_IP" | grep -E "LOG: execute" \
|
||||||
|
| sed 's/.*execute [^:]*: //' | sed 's/^/ /' | head -12
|
||||||
|
echo
|
||||||
|
echo "=== 그 sid 를 언급한 SQL — 누가 보냈는가 ==="
|
||||||
|
echo " 찾는 sid: $SID"
|
||||||
|
echo
|
||||||
|
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
|
||||||
|
| tail -n +$((LOG_BEFORE + 1)) \
|
||||||
|
| grep -F "$SID" \
|
||||||
|
| sed -e "s/$K0_IP/[keycloak-0]/g" -e "s/$K1_IP/[keycloak-1]/g" \
|
||||||
|
| cut -c1-220 \
|
||||||
|
| head -20
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 요약: 파드별 질의 건수 ==="
|
||||||
|
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
|
||||||
|
| tail -n +$((LOG_BEFORE + 1)) \
|
||||||
|
| grep -F "$SID" \
|
||||||
|
| grep -oE "^[0-9-]+ [0-9:.]+ [A-Z]+ \[[0-9]+\] [0-9.]+" \
|
||||||
|
| awk '{print $NF}' | sort | uniq -c \
|
||||||
|
| sed -e "s/$K0_IP/[keycloak-0]/" -e "s/$K1_IP/[keycloak-1]/" -e 's/^/ /'
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Experiment 0 — is a session created on one Keycloak node usable on the other?
|
||||||
|
#
|
||||||
|
# Forming a cluster is not the same as sharing session state. The Infinispan log
|
||||||
|
# says "cluster view (2)", but that only proves the members found each other.
|
||||||
|
#
|
||||||
|
# Design notes, learned the hard way:
|
||||||
|
#
|
||||||
|
# * Every probe has a CONTROL. A result from the far node means nothing unless
|
||||||
|
# the same call against the issuing node is also measured. The first version
|
||||||
|
# of this script reported "403 on keycloak-1" as if it were a replication
|
||||||
|
# failure; the issuing node returned 403 too, and the cause was a missing
|
||||||
|
# openid scope. Measure both, always.
|
||||||
|
#
|
||||||
|
# * Sessions are tracked by SID, not by count. Both the test login and the
|
||||||
|
# admin API calls create sessions for the same user, so counts are noisy.
|
||||||
|
# A specific session id either appears in a node's answer or it does not.
|
||||||
|
#
|
||||||
|
# * The probe is the REFRESH TOKEN grant, not userinfo. userinfo only validates
|
||||||
|
# a signature and can succeed on a node that knows nothing about the session.
|
||||||
|
# Refreshing requires the node to find the session, check it is alive, and
|
||||||
|
# write back a new refresh time — it actually touches the session store.
|
||||||
|
#
|
||||||
|
# Talks to pod IPs directly: going through nginx/Traefik would hide which node
|
||||||
|
# handled each request, which is the entire question.
|
||||||
|
#
|
||||||
|
# ./deploy/lab/scripts/experiment-session-replication.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NS="${NS:-keycloak-lab}"
|
||||||
|
OUT="${OUT:-/tmp/session-replication}"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
PSQL="kubectl -n $NS exec deploy/postgres -- psql -U keycloak -d keycloak -tAc"
|
||||||
|
|
||||||
|
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||||
|
echo
|
||||||
|
|
||||||
|
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
K0_NODE=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.spec.nodeName}')
|
||||||
|
K1_NODE=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.spec.nodeName}')
|
||||||
|
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
|
echo "=== 대상 ==="
|
||||||
|
printf ' keycloak-0 %-14s %s\n' "$K0_IP" "$K0_NODE"
|
||||||
|
printf ' keycloak-1 %-14s %s\n' "$K1_IP" "$K1_NODE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== [0] 실험 전 DB 세션 ==="
|
||||||
|
$PSQL "select offline_flag, count(*) from offline_user_session group by offline_flag" 2>/dev/null \
|
||||||
|
| sed 's/^/ offline_flag=/' || echo " (없음)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 파드 하나 안에서 전 단계를 실행한다. 단계마다 파드를 새로 띄우면 토큰을
|
||||||
|
# 단계 사이로 넘길 수 없다.
|
||||||
|
kubectl -n "$NS" run kc-probe --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
|
||||||
|
set -u
|
||||||
|
K0='http://$K0_IP:8080'; K1='http://$K1_IP:8080'
|
||||||
|
TOKEN_EP='/realms/master/protocol/openid-connect/token'
|
||||||
|
jget() { sed -n \"s/.*\\\"\$1\\\":\\\"\\([^\\\"]*\\)\\\".*/\\1/p\"; }
|
||||||
|
|
||||||
|
# ── [1] keycloak-0 에서 로그인. 이 노드가 세션의 출생지다 ──────────────────
|
||||||
|
LOGIN=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d 'password=$ADMIN_PW')
|
||||||
|
echo '###STEP1_LOGIN'; echo \"\$LOGIN\"
|
||||||
|
|
||||||
|
AT=\$(echo \"\$LOGIN\" | jget access_token)
|
||||||
|
RT=\$(echo \"\$LOGIN\" | jget refresh_token)
|
||||||
|
|
||||||
|
# ── [2] 관리 API 조회용 토큰. 세션 오염을 피하려고 따로 하나만 더 만든다 ──
|
||||||
|
ADMTOK=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d 'password=$ADMIN_PW' | jget access_token)
|
||||||
|
CID=\$(curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
||||||
|
\"\$K0/admin/realms/master/clients?clientId=admin-cli\" | jget id | head -1)
|
||||||
|
|
||||||
|
# ── [3] 두 노드에 같은 질문을 한다: admin-cli 의 세션 목록 ────────────────
|
||||||
|
echo '###STEP3_SESSIONS_K0'
|
||||||
|
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
||||||
|
\"\$K0/admin/realms/master/clients/\$CID/user-sessions?max=100\"
|
||||||
|
echo
|
||||||
|
echo '###STEP3_SESSIONS_K1'
|
||||||
|
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
||||||
|
\"\$K1/admin/realms/master/clients/\$CID/user-sessions?max=100\"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── [4] 대조군: keycloak-0 이 발급한 refresh token 을 keycloak-0 에 쓴다 ──
|
||||||
|
# 먼저 반대편에 써야 하므로 여기서는 쓰지 않고, 순서를 [5] 뒤로 미룬다.
|
||||||
|
# refresh token 은 회전(rotation)되므로 한 번 쓰면 옛 것이 무효가 된다.
|
||||||
|
# 따라서 '반대편 먼저'가 유일하게 의미 있는 순서다.
|
||||||
|
|
||||||
|
# ── [5] 시험군: keycloak-0 이 발급한 refresh token 을 keycloak-1 에 쓴다 ──
|
||||||
|
echo '###STEP5_REFRESH_ON_K1'
|
||||||
|
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K1\$TOKEN_EP\" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\"
|
||||||
|
|
||||||
|
RT2=\$(curl -s -X POST \"\$K1\$TOKEN_EP\" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\" \
|
||||||
|
| jget refresh_token)
|
||||||
|
|
||||||
|
# ── [6] 무효화가 반대 방향으로도 전파되는가 ───────────────────────────────
|
||||||
|
# keycloak-1 에서 로그아웃시키고, keycloak-0 에서 갱신을 시도한다.
|
||||||
|
echo '###STEP6_LOGOUT_VIA_K1'
|
||||||
|
curl -s -o /dev/null -w 'http_code=%{http_code}\n' -X POST \"\$K1/realms/master/protocol/openid-connect/logout\" \
|
||||||
|
-d client_id=admin-cli -d \"refresh_token=\$RT2\"
|
||||||
|
|
||||||
|
echo '###STEP7_REFRESH_ON_K0_AFTER_LOGOUT'
|
||||||
|
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K0\$TOKEN_EP\" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT2\"
|
||||||
|
echo '###END'
|
||||||
|
" > "$OUT/raw.txt" 2>&1
|
||||||
|
|
||||||
|
sed -i '/^pod .* deleted$/d' "$OUT/raw.txt"
|
||||||
|
|
||||||
|
python3 - "$OUT/raw.txt" <<'PY' | tee "$OUT/report.txt"
|
||||||
|
import base64, json, sys
|
||||||
|
|
||||||
|
raw = open(sys.argv[1]).read()
|
||||||
|
blocks, cur = {}, None
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if line.startswith('###'):
|
||||||
|
cur = line[3:]; blocks[cur] = []
|
||||||
|
elif cur is not None:
|
||||||
|
blocks[cur].append(line)
|
||||||
|
get = lambda k: '\n'.join(blocks.get(k, [])).strip()
|
||||||
|
|
||||||
|
def j(s):
|
||||||
|
try: return json.JSONDecoder().raw_decode(s.strip())[0]
|
||||||
|
except Exception: return None
|
||||||
|
|
||||||
|
def claims(tok):
|
||||||
|
p = tok.split('.')[1]; p += '=' * (-len(p) % 4)
|
||||||
|
return json.loads(base64.urlsafe_b64decode(p))
|
||||||
|
|
||||||
|
login = j(get('STEP1_LOGIN'))
|
||||||
|
if not login or 'access_token' not in login:
|
||||||
|
print('로그인 실패:', get('STEP1_LOGIN')[:300]); sys.exit(1)
|
||||||
|
|
||||||
|
ac = claims(login['access_token'])
|
||||||
|
rc = claims(login['refresh_token'])
|
||||||
|
SID = ac['sid']
|
||||||
|
print('=== [1] keycloak-0 에서 로그인 ===')
|
||||||
|
print(f" sid {SID}")
|
||||||
|
print(f" sub {ac.get('sub')}")
|
||||||
|
print(f" iss {ac.get('iss')}")
|
||||||
|
print(f" access 수명 {ac['exp']-ac['iat']}초")
|
||||||
|
print(f" refresh 수명 {rc['exp']-rc['iat']}초 typ={rc.get('typ')}")
|
||||||
|
print(f" refresh jti {rc.get('jti')}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===')
|
||||||
|
for step, who in (('STEP3_SESSIONS_K0', 'keycloak-0 (발급 노드)'),
|
||||||
|
('STEP3_SESSIONS_K1', 'keycloak-1 (반대편)')):
|
||||||
|
d = j(get(step))
|
||||||
|
if d is None:
|
||||||
|
print(f' {who:24} 파싱 실패: {get(step)[:120]}'); continue
|
||||||
|
ids = [s.get('id') for s in d]
|
||||||
|
mark = '보임 ✔' if SID in ids else '없음 ✘'
|
||||||
|
print(f' {who:24} 세션 {len(ids)}개 중 대상 sid → {mark}')
|
||||||
|
for s in d:
|
||||||
|
if s.get('id') == SID:
|
||||||
|
print(f" ipAddress={s.get('ipAddress')} start={s.get('start')} lastAccess={s.get('lastAccess')}")
|
||||||
|
|
||||||
|
def show(step, title, expect):
|
||||||
|
print(); print(f'=== {title} ===')
|
||||||
|
body = get(step)
|
||||||
|
code = [l for l in body.splitlines() if l.startswith('http_code=')]
|
||||||
|
code = code[0].split('=')[1] if code else '?'
|
||||||
|
d = j(body)
|
||||||
|
ok = '기대대로' if code == expect else f'기대({expect})와 다름'
|
||||||
|
print(f' HTTP {code} ← {ok}')
|
||||||
|
if d and 'access_token' in d:
|
||||||
|
c = claims(d['access_token'])
|
||||||
|
same = '동일 ✔' if c.get('sid') == SID else f"다름 ✘ ({c.get('sid')})"
|
||||||
|
print(f' 새 토큰의 sid → {same}')
|
||||||
|
elif d:
|
||||||
|
print(f" error {d.get('error')}")
|
||||||
|
print(f" error_description {d.get('error_description')}")
|
||||||
|
|
||||||
|
show('STEP5_REFRESH_ON_K1',
|
||||||
|
'[5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용', '200')
|
||||||
|
|
||||||
|
print(); print('=== [6] keycloak-1 을 통해 로그아웃 ===')
|
||||||
|
print(' ' + get('STEP6_LOGOUT_VIA_K1').strip())
|
||||||
|
|
||||||
|
show('STEP7_REFRESH_ON_K0_AFTER_LOGOUT',
|
||||||
|
'[7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파)', '400')
|
||||||
|
|
||||||
|
open('/tmp/session-replication/sid.txt','w').write(SID)
|
||||||
|
PY
|
||||||
|
|
||||||
|
SID=$(cat /tmp/session-replication/sid.txt 2>/dev/null)
|
||||||
|
echo
|
||||||
|
echo "=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ==="
|
||||||
|
echo " 대상 sid: $SID"
|
||||||
|
$PSQL "select user_session_id, offline_flag, created_on, last_session_refresh
|
||||||
|
from offline_user_session where user_session_id='$SID'" 2>/dev/null \
|
||||||
|
| sed 's/^/ /' | grep -q . \
|
||||||
|
&& $PSQL "select user_session_id||' | flag='||offline_flag||' | created='||created_on||' | refresh='||last_session_refresh
|
||||||
|
from offline_user_session where user_session_id='$SID'" 2>/dev/null | sed 's/^/ /' \
|
||||||
|
|| echo " 행 없음 — 로그아웃으로 삭제되었다"
|
||||||
|
echo
|
||||||
|
echo " 전체 세션 수: $($PSQL 'select count(*) from offline_user_session' 2>/dev/null)"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
수집 시각: 2026-09-03 17:24:54 KST
|
||||||
|
대상: Keycloak 26.7.0 × 2 + PostgreSQL 16, k3s 2노드
|
||||||
|
|
||||||
|
=== [1] 파드 배치 ===
|
||||||
|
keycloak-0 1/1 10.42.1.18 kc-lab-2
|
||||||
|
keycloak-1 1/1 10.42.0.16 kc-lab-1
|
||||||
|
postgres-7b474b88c8-bw7b8 1/1 10.42.1.19 kc-lab-2
|
||||||
|
|
||||||
|
=== [2] 클러스터 뷰 로그 (Infinispan) ===
|
||||||
|
-- keycloak-0 --
|
||||||
|
2026-09-03 08:18:23,359 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)]
|
||||||
|
2026-09-03 08:18:23,433 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`, physical addresses are `[10.42.1.18:7800]`
|
||||||
|
-- keycloak-1 --
|
||||||
|
2026-09-03 08:18:23,269 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)]
|
||||||
|
2026-09-03 08:18:23,282 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster
|
||||||
|
2026-09-03 08:18:23,286 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster
|
||||||
|
|
||||||
|
=== [3] JGROUPS_PING 테이블 구조 ===
|
||||||
|
Table "public.jgroups_ping"
|
||||||
|
Column | Type | Collation | Nullable | Default
|
||||||
|
----------------+------------------------+-----------+----------+---------
|
||||||
|
address | character varying(200) | | not null |
|
||||||
|
name | character varying(200) | | |
|
||||||
|
cluster_name | character varying(200) | | not null |
|
||||||
|
ip | character varying(200) | | not null |
|
||||||
|
coord | boolean | | |
|
||||||
|
last_update | bigint | | |
|
||||||
|
coordinated_by | character varying(200) | | |
|
||||||
|
Indexes:
|
||||||
|
"constraint_jgroups_ping" PRIMARY KEY, btree (address)
|
||||||
|
|
||||||
|
|
||||||
|
=== [4] JGROUPS_PING 등록 내역 ===
|
||||||
|
name | cluster_name | ip | coord
|
||||||
|
------------------+--------------+-----------------+-------
|
||||||
|
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
|
||||||
|
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
|
||||||
|
(2 rows)
|
||||||
|
|
||||||
|
|
||||||
|
=== [5] 외부 접근 — OIDC discovery ===
|
||||||
|
issuer https://auth.hyeonworks.com/realms/master
|
||||||
|
authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth
|
||||||
|
token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token
|
||||||
|
end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout
|
||||||
|
jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs
|
||||||
|
|
||||||
|
★ 전부 https. 첫 실험에서 확정한 KC_HOSTNAME + KC_PROXY_HEADERS 조합이 작동한다.
|
||||||
|
|
||||||
|
=== [6] 자원 사용 ===
|
||||||
|
keycloak-0 8m 594Mi
|
||||||
|
keycloak-1 9m 593Mi
|
||||||
|
postgres-7b474b88c8-bw7b8 3m 67Mi
|
||||||
|
--- 노드 ---
|
||||||
|
kc-lab-1 2248Mi (65%)
|
||||||
|
kc-lab-2 1447Mi (58%)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 증거 — Keycloak 멀티노드 클러스터 형성
|
||||||
|
|
||||||
|
`docs/keycloak-multinode-cluster.md`의 근거 자료.
|
||||||
|
**정상적으로 클러스터가 형성된 상태**에서 수집했으며, 이후 고장을 주입한
|
||||||
|
뒤 이것과 대조한다.
|
||||||
|
|
||||||
|
수집 시각: 2026-09-03 17:24 KST
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `01-cluster-formed.txt` | 파드 배치·클러스터 뷰 로그·JGROUPS_PING·OIDC discovery·자원 |
|
||||||
|
|
||||||
|
## 이 상태에서 확인된 것
|
||||||
|
|
||||||
|
**클러스터 뷰가 멤버 2를 보고한다**
|
||||||
|
|
||||||
|
```
|
||||||
|
ISPN000094: Received new cluster view for channel ISPN:
|
||||||
|
[keycloak-1-26938|1] (2) [keycloak-1-26938, keycloak-0-49501]
|
||||||
|
ISPN100000: Node keycloak-0-49501 joined the cluster
|
||||||
|
ISPN000079: physical addresses are [10.42.1.18:7800]
|
||||||
|
```
|
||||||
|
|
||||||
|
**디스커버리와 통신 경로가 한 테이블에 다 보인다**
|
||||||
|
|
||||||
|
```
|
||||||
|
name | cluster_name | ip | coord
|
||||||
|
------------------+--------------+-----------------+-------
|
||||||
|
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
|
||||||
|
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
|
||||||
|
```
|
||||||
|
|
||||||
|
`name`/`cluster_name`은 **DB 디스커버리**의 결과이고, `ip`의 `:7800`은
|
||||||
|
**실제 통신 경로**다. 7800을 막으면 이 표는 그대로 채워지면서 클러스터 뷰만
|
||||||
|
깨질 것으로 예상한다 — 다음 실험의 가설이다.
|
||||||
|
|
||||||
|
`coord = t` 인 `keycloak-1`이 코디네이터다.
|
||||||
|
|
||||||
|
**배치** — 서로 다른 노드에 하나씩. PostgreSQL은 `kc-lab-2`에 있으므로
|
||||||
|
**그 노드를 죽이면 Keycloak 하나와 DB가 동시에 사라진다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-0 10.42.1.18 kc-lab-2
|
||||||
|
keycloak-1 10.42.0.16 kc-lab-1
|
||||||
|
postgres 10.42.1.19 kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**issuer가 https로 발급된다** — 첫 실험(2홉 헤더 계약)의 결론이 적용된 결과다.
|
||||||
|
|
||||||
|
## 재수집
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get pods -o wide
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
|
||||||
|
|
||||||
|
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1)
|
||||||
|
kubectl -n keycloak-lab exec "$PG" -- \
|
||||||
|
psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;"
|
||||||
|
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool
|
||||||
|
kubectl -n keycloak-lab top pods
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
===================================================================
|
||||||
|
실험 0 — 한 노드에서 만든 세션이 다른 노드에서 쓰이는가
|
||||||
|
===================================================================
|
||||||
|
|
||||||
|
### 사전 확인: 클러스터가 2 멤버로 형성되었는가
|
||||||
|
2026-09-04 00:52:09,294 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
|
||||||
|
name | ip | coord
|
||||||
|
------------------+-----------------+-------
|
||||||
|
keycloak-1-48749 | 10.42.0.35:7800 | t
|
||||||
|
keycloak-0-30843 | 10.42.1.43:7800 | f
|
||||||
|
(2 rows)
|
||||||
|
|
||||||
|
|
||||||
|
수집 시각: 2026-09-04 09:54:29 KST
|
||||||
|
|
||||||
|
=== 대상 ===
|
||||||
|
keycloak-0 10.42.1.43 kc-lab-2
|
||||||
|
keycloak-1 10.42.0.35 kc-lab-1
|
||||||
|
|
||||||
|
=== [0] 실험 전 DB 세션 ===
|
||||||
|
|
||||||
|
=== [1] keycloak-0 에서 로그인 ===
|
||||||
|
sid jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
sub None
|
||||||
|
iss https://auth.hyeonworks.com/realms/master
|
||||||
|
access 수명 60초
|
||||||
|
refresh 수명 1800초 typ=Refresh
|
||||||
|
refresh jti 7669cc49-4778-851f-3c49-65f76964ae8e
|
||||||
|
|
||||||
|
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
|
||||||
|
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
|
||||||
|
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
|
||||||
|
|
||||||
|
=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
|
||||||
|
HTTP 200 ← 기대대로
|
||||||
|
새 토큰의 sid → 동일 ✔
|
||||||
|
|
||||||
|
=== [6] keycloak-1 을 통해 로그아웃 ===
|
||||||
|
http_code=204
|
||||||
|
|
||||||
|
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
|
||||||
|
HTTP 400 ← 기대대로
|
||||||
|
error invalid_grant
|
||||||
|
error_description Session not active
|
||||||
|
|
||||||
|
=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
|
||||||
|
대상 sid: jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
행 없음 — 로그아웃으로 삭제되었다
|
||||||
|
|
||||||
|
전체 세션 수: 1
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
===================================================================
|
||||||
|
실험 0b — Infinispan 이 복제한 것인가, DB 를 같이 본 것인가
|
||||||
|
===================================================================
|
||||||
|
|
||||||
|
수집 시각: 2026-09-04 09:54:41 KST
|
||||||
|
|
||||||
|
=== 로그인은 keycloak-0 에만 보냈다 ===
|
||||||
|
로그인 응답: http_code=200
|
||||||
|
|
||||||
|
=== keycloak-0 (로그인을 받은 노드) ===
|
||||||
|
계수기 캐시 전 후 증가
|
||||||
|
rpc.replication_count clientSessions 1 1 +0
|
||||||
|
rpc.replication_count sessions 1 1 +0
|
||||||
|
approximate_entries_unique clientSessions 1 2 +1 ←
|
||||||
|
approximate_entries_unique sessions 1 2 +1 ←
|
||||||
|
hits clientSessions 2 2 +0
|
||||||
|
hits sessions 2 2 +0
|
||||||
|
misses clientSessions 2 3 +1 ←
|
||||||
|
misses sessions 3 4 +1 ←
|
||||||
|
stores clientSessions 2 3 +1 ←
|
||||||
|
stores sessions 2 3 +1 ←
|
||||||
|
|
||||||
|
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
|
||||||
|
계수기 캐시 전 후 증가
|
||||||
|
rpc.replication_count clientSessions 7 7 +0
|
||||||
|
rpc.replication_count sessions 7 7 +0
|
||||||
|
approximate_entries_unique clientSessions 0 0 +0
|
||||||
|
approximate_entries_unique sessions 0 0 +0
|
||||||
|
hits clientSessions 4 4 +0
|
||||||
|
hits sessions 4 4 +0
|
||||||
|
misses clientSessions 0 0 +0
|
||||||
|
misses sessions 0 0 +0
|
||||||
|
stores clientSessions 1 1 +0
|
||||||
|
stores sessions 1 1 +0
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
===================================================================
|
||||||
|
실험 0c — 세션 엔트리는 어느 노드에 있는가 (로컬 캐시인가 분산인가)
|
||||||
|
===================================================================
|
||||||
|
|
||||||
|
수집 시각: 2026-09-04 09:54:54 KST
|
||||||
|
keycloak-0 = 10.42.1.43 (kc-lab-2)
|
||||||
|
keycloak-1 = 10.42.0.35 (kc-lab-1)
|
||||||
|
|
||||||
|
단계 k0 entries k1 entries
|
||||||
|
시작 2.0 0.0
|
||||||
|
keycloak-1 에 로그인 5회 2.0 5.0
|
||||||
|
keycloak-0 에 로그인 5회 7.0 5.0
|
||||||
|
|
||||||
|
=== 대조: PostgreSQL 에는 몇 건인가 ===
|
||||||
|
online 세션 12
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
===================================================================
|
||||||
|
실험 0d — 반대편 노드가 정말 DB 에서 읽는가 (SQL 을 직접 잡는다)
|
||||||
|
===================================================================
|
||||||
|
|
||||||
|
수집 시각: 2026-09-04 10:14:17 KST
|
||||||
|
keycloak-0 = 10.42.1.43 (세션을 만드는 노드)
|
||||||
|
keycloak-1 = 10.42.0.35 (읽기만 하는 노드)
|
||||||
|
|
||||||
|
=== PostgreSQL 문장 로깅을 켠다 ===
|
||||||
|
log_statement = all
|
||||||
|
log_line_prefix = %m [%p] %h
|
||||||
|
|
||||||
|
=== 요청 ===
|
||||||
|
SID=jSt9GEPVQLJsO-1CeJjVgltg
|
||||||
|
K1_ENTRIES_BEFORE=5.0
|
||||||
|
REFRESH_ON_K1=200
|
||||||
|
K1_ENTRIES_AFTER=5.0
|
||||||
|
|
||||||
|
=== PostgreSQL 문장 로깅을 끈다 ===
|
||||||
|
log_statement = none
|
||||||
|
|
||||||
|
=== keycloak-1 이 실제로 보낸 SQL 문장 ===
|
||||||
|
(파라미터가 $1 로 묶여 있어, sid 는 바로 아래 DETAIL 줄에 있다)
|
||||||
|
|
||||||
|
select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,puse1_0.BROKER_SESSION_ID,puse1_0.CREATED_ON,puse1_0.DATA,puse1_0.LAST_SESSION_REFRESH,puse1_0.REALM_ID,puse1_0.REMEMBER_ME,puse1_0.USER_ID,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2))
|
||||||
|
select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked
|
||||||
|
select pcse1_0.CLIENT_ID,pcse1_0.CLIENT_STORAGE_PROVIDER,pcse1_0.EXTERNAL_CLIENT_ID,pcse1_0.OFFLINE_FLAG,pcse1_0.USER_SESSION_ID,pcse1_0.DATA,pcse1_0.REALM_ID,pcse1_0.TIMESTAMP,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (pcse1_0.CLIENT_ID,pcse1_0.CLIENT_STORAGE_PROVIDER,pcse1_0.EXTERNAL_CLIENT_ID,pcse1_0.OFFLINE_FLAG,pcse1_0.USER_SESSION_ID) in (($1,$2,$3,$4,$5))
|
||||||
|
select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where pcse1_0.USER_SESSION_ID=$1 and pcse1_0.OFFLINE_FLAG=$2 and pcse1_0.CLIENT_ID=$3 and pcse1_0.EXTERNAL_CLIENT_ID=$4 and pcse1_0.CLIENT_STORAGE_PROVIDER=$5 for no key update of pcse1_0 skip locked
|
||||||
|
update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and CLIENT_STORAGE_PROVIDER=$4 and EXTERNAL_CLIENT_ID=$5 and OFFLINE_FLAG=$6 and USER_SESSION_ID=$7 and VERSION=$8
|
||||||
|
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
|
||||||
|
SET LOCAL synchronous_commit TO OFF
|
||||||
|
COMMIT
|
||||||
|
DELETE from JGROUPS_PING WHERE address=$1
|
||||||
|
INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
COMMIT
|
||||||
|
DELETE from JGROUPS_PING WHERE address=$1
|
||||||
|
|
||||||
|
=== 그 sid 를 언급한 SQL — 누가 보냈는가 ===
|
||||||
|
찾는 sid: jSt9GEPVQLJsO-1CeJjVgltg
|
||||||
|
|
||||||
|
2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:32.852 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:32.860 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:32.862 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:32.863 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = NULL, $2 = '1788484352', $3 = '{"ipAddress":"10.42.1.50","authMethod":"openid-connect","rememberMe":false,"started":0,"notes":{"KC_DEVICE_NOTE":"
|
||||||
|
2026-09-04 01:12:32.864 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '{"authMethod":"openid-connect","notes":{"clientId":"131a9912-b578-4b9c-b16a-97518704077e","userSessionStartedAt":"1788484352","iss":"https://aut
|
||||||
|
2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0'
|
||||||
|
2026-09-04 01:12:34.937 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||||
|
2026-09-04 01:12:34.938 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0', $3 = '131a9912-b578-4b9c-b16a-97518704077e', $4 = 'local', $5 = 'local'
|
||||||
|
2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-b578-4b9c-b16a-97518704077e', $4 = 'local', $5 = 'local', $6 = '0', $7 = 'jSt9GEPVQLJsO-1CeJjVgltg', $8 =
|
||||||
|
2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0'
|
||||||
|
|
||||||
|
=== 요약: 파드별 질의 건수 ===
|
||||||
|
6 [keycloak-1]
|
||||||
|
6 [keycloak-0]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 실험 0 — 세션 복제 증거
|
||||||
|
|
||||||
|
수집: 2026-09-04 09:54 KST · Keycloak 26 / Infinispan 16.0.12 / PostgreSQL 16
|
||||||
|
해설: [`docs/experiment-00-session-replication.md`](../../experiment-00-session-replication.md)
|
||||||
|
|
||||||
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|
|---|---|
|
||||||
|
| `01-cross-node-session.txt` | 클러스터 2멤버 확인 → keycloak-0 로그인 → 같은 sid 가 양쪽에서 보임 → **keycloak-1 이 refresh 성공(200)** → keycloak-1 로그아웃 → **keycloak-0 갱신 실패(400)** → DB 행 삭제 확인 |
|
||||||
|
| `02-cache-delta.txt` | 로그인 하나를 사이에 둔 양쪽 노드의 캐시 계수기. **keycloak-1 은 전부 +0** |
|
||||||
|
| `03-cache-ownership.txt` | 로그인을 반대편에 몰아준 결과. **요청을 받은 노드에서만 엔트리가 는다.** 캐시 합 7+5 = DB 12 |
|
||||||
|
| `session-cache-entries-per-pod.png` | 위 사실의 시계열. 파란 선(keycloak-1)이 0에 붙어 있는 동안 초록 선(keycloak-0)만 14까지 오른다 |
|
||||||
|
| `keycloak-admin-sessions.png` | 관리 콘솔의 Sessions 화면. 브라우저는 nginx→Traefik 을 거쳐 두 파드 중 하나에 닿지만 **어느 파드가 만든 세션이든 전부 보인다** |
|
||||||
|
|
||||||
|
## 핵심 한 줄
|
||||||
|
|
||||||
|
클러스터는 형성되지만 **세션 엔트리는 노드를 건너가지 않는다.**
|
||||||
|
두 노드가 같은 답을 하는 이유는 Infinispan 복제가 아니라 **같은 PostgreSQL** 이다.
|
||||||
|
|
||||||
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|
|---|---|
|
||||||
|
| `04-read-path-sql.txt` | PostgreSQL 문장 로깅으로 잡은 **keycloak-1 이 실제로 날린 SQL**. `SELECT ... FROM OFFLINE_USER_SESSION` 로 남의 세션을 읽고 `UPDATE ... where VERSION=$5` 로 쓴다. 같은 트랜잭션에 `SET LOCAL synchronous_commit TO OFF` 가 들어 있다 |
|
||||||
|
|
||||||
|
## 추론이 관측이 된 지점
|
||||||
|
|
||||||
|
0b·0c 는 "keycloak-1 메모리에 없는데 쓸 수 있으니 DB 에서 읽었을 것"이라는
|
||||||
|
**추론**이었다. 0d 에서 그 SQL 을 파드 IP 와 함께 직접 잡았다.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,636 @@
|
|||||||
|
# 실험 0 — 한 노드에서 만든 세션이 다른 노드에서 쓰이는가
|
||||||
|
|
||||||
|
로드맵 A-0. 이후 모든 장애 실험의 기준선이다.
|
||||||
|
|
||||||
|
> **맥락이 안 잡히면 먼저 읽을 것** —
|
||||||
|
> [`docs/session-lab-prerequisites.md`](session-lab-prerequisites.md).
|
||||||
|
> 왜 세션이 문제가 되는지, Keycloak이 세션을 어디에 두는지, 그래서 이 실험이
|
||||||
|
> 무엇을 가르려는 것인지를 바닥부터 세워둔 문서다.
|
||||||
|
|
||||||
|
- 실행 스크립트 — [`deploy/lab/scripts/experiment-session-replication.sh`](../deploy/lab/scripts/experiment-session-replication.sh),
|
||||||
|
[`experiment-cache-replication-delta.sh`](../deploy/lab/scripts/experiment-cache-replication-delta.sh),
|
||||||
|
[`experiment-cache-ownership.sh`](../deploy/lab/scripts/experiment-cache-ownership.sh),
|
||||||
|
[`experiment-session-read-path.sh`](../deploy/lab/scripts/experiment-session-read-path.sh)
|
||||||
|
- 증거 — [`docs/evidence/session-replication/`](evidence/session-replication/)
|
||||||
|
- 수집 시각 — 2026-09-04 09:54 KST, Keycloak 26 / Infinispan 16.0.12 / PostgreSQL 16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론부터
|
||||||
|
|
||||||
|
| 물음 | 답 |
|
||||||
|
|---|---|
|
||||||
|
| 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가 | **그렇다** |
|
||||||
|
| 로그아웃이 반대 방향으로 전파되는가 | **그렇다** |
|
||||||
|
| **그 공유는 Infinispan 복제 덕분인가** | **아니다** |
|
||||||
|
| 그럼 무엇이 공유하는가 | **PostgreSQL** — 반대편 노드가 날린 SQL을 직접 잡았다 |
|
||||||
|
|
||||||
|
**클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다.**
|
||||||
|
로그에는 `(2) [keycloak-0, keycloak-1]`이 찍히고 `JGROUPS_PING`에도 둘 다
|
||||||
|
등록되어 있지만, **세션 엔트리는 노드 사이를 건너가지 않는다.**
|
||||||
|
|
||||||
|
각 노드는 **자기가 처리한 로그인만** 캐시한다. 두 노드가 같은 답을 내놓는
|
||||||
|
이유는 복제가 아니라 **같은 데이터베이스를 보기 때문**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 이 실험이 첫 번째인가
|
||||||
|
|
||||||
|
앞선 작업에서 Keycloak 2노드 클러스터를 세우고 `ISPN000094`로 멤버 2개를
|
||||||
|
확인했다. 거기서 멈추면 **"클러스터가 떴다"까지만 아는 것**이고, 그 위에서
|
||||||
|
장애를 주입해봐야 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.
|
||||||
|
|
||||||
|
기준선이 없으면 이런 잘못된 추론을 하게 된다.
|
||||||
|
|
||||||
|
> 7800을 막았더니 세션이 깨졌다 → 역시 세션은 7800으로 복제되는구나
|
||||||
|
|
||||||
|
실제로는 7800으로 세션이 오가지 않는다는 것을 **먼저** 알아야, 7800을 막았을
|
||||||
|
때 깨지는 것이 무엇인지 정확히 말할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 실험 설계에서 배운 것 세 가지
|
||||||
|
|
||||||
|
측정값보다 **어떻게 측정할지**에서 더 많이 틀렸다. 세 번 고쳤다.
|
||||||
|
|
||||||
|
### 2-1. 대조군 없는 측정은 해석할 수 없다
|
||||||
|
|
||||||
|
첫 판본은 이렇게 보고했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
=== [4] keycloak-0 이 발급한 토큰을 keycloak-1 이 받는가 ===
|
||||||
|
http_code=403
|
||||||
|
```
|
||||||
|
|
||||||
|
**403을 "복제 실패"로 읽을 뻔했다.** 발급 노드에도 같은 요청을 보내보니
|
||||||
|
|
||||||
|
```
|
||||||
|
--- userinfo, scope 없음 ---
|
||||||
|
k0(발급노드) 403
|
||||||
|
k1(반대편) 403
|
||||||
|
--- 403 본문 ---
|
||||||
|
WWW-Authenticate: Bearer realm="master", error="insufficient_scope",
|
||||||
|
error_description="Missing openid scope"
|
||||||
|
```
|
||||||
|
|
||||||
|
**양쪽 다 403이었다.** 원인은 복제가 아니라 요청에 `openid` scope가 없다는
|
||||||
|
것이었다. 오히려 **두 노드가 똑같이 답했다는 사실 자체가 일치의 증거**였다.
|
||||||
|
|
||||||
|
> **원칙** — 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지
|
||||||
|
> 아무 의미가 없다. 시험군만 재는 측정은 측정이 아니다.
|
||||||
|
|
||||||
|
### 2-2. 개수가 아니라 식별자로 추적한다
|
||||||
|
|
||||||
|
`client-session-stats`가 `active=2`를 돌려줬다. 그런데 스크립트 자체가
|
||||||
|
로그인을 두 번 하고(시험용 + 관리 API 호출용) 있었다. **개수는 실험 도구가
|
||||||
|
만든 잡음에 그대로 오염된다.**
|
||||||
|
|
||||||
|
바꾼 방식: 토큰의 `sid`를 뽑아, 각 노드의 세션 목록에 **그 sid가 있는지**를
|
||||||
|
본다. 개수가 몇이든 상관없다.
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-3. 세션 저장소를 실제로 건드리는 탐침을 골라야 한다
|
||||||
|
|
||||||
|
| 탐침 | 하는 일 | 적합한가 |
|
||||||
|
|---|---|---|
|
||||||
|
| `userinfo` | 서명 검증 + scope 확인 | **아니다.** 세션을 몰라도 통과할 수 있다 |
|
||||||
|
| **`refresh_token` 그랜트** | 세션을 찾고, 살아있는지 보고, 갱신 시각을 쓴다 | **그렇다** |
|
||||||
|
|
||||||
|
refresh는 **읽고 쓴다.** 그래서 "저 노드가 이 세션을 정말로 아는가"에 답한다.
|
||||||
|
|
||||||
|
여기에 더해 refresh token은 **회전(rotation)** 된다 — 한 번 쓰면 옛 것이
|
||||||
|
무효가 된다. 따라서 **반대편 노드에 먼저 써야** 한다. 발급 노드에 먼저 쓰면
|
||||||
|
시험군에 쓸 토큰이 사라진다. 대조군과 시험군의 순서가 강제된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 실험 0 — 교차 노드 세션 사용
|
||||||
|
|
||||||
|
### 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c "delete from offline_user_session"
|
||||||
|
kubectl -n keycloak-lab rollout restart statefulset/keycloak # 캐시를 비운다
|
||||||
|
./deploy/lab/scripts/experiment-session-replication.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
nginx나 Traefik을 거치지 않고 **파드 IP로 직접** 말을 건다. 로드밸런서를
|
||||||
|
거치면 어느 노드가 처리했는지가 감춰지는데, 그게 바로 이 실험의 질문이다.
|
||||||
|
|
||||||
|
### 결과 — [`01-cross-node-session.txt`](evidence/session-replication/01-cross-node-session.txt)
|
||||||
|
|
||||||
|
```
|
||||||
|
### 사전 확인: 클러스터가 2 멤버로 형성되었는가
|
||||||
|
ISPN000094: Received new cluster view for channel ISPN:
|
||||||
|
[keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749, keycloak-0-30843]
|
||||||
|
|
||||||
|
name | ip | coord
|
||||||
|
------------------+-----------------+-------
|
||||||
|
keycloak-1-48749 | 10.42.0.35:7800 | t
|
||||||
|
keycloak-0-30843 | 10.42.1.43:7800 | f
|
||||||
|
|
||||||
|
=== 대상 ===
|
||||||
|
keycloak-0 10.42.1.43 kc-lab-2
|
||||||
|
keycloak-1 10.42.0.35 kc-lab-1
|
||||||
|
|
||||||
|
=== [1] keycloak-0 에서 로그인 ===
|
||||||
|
sid jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
iss https://auth.hyeonworks.com/realms/master
|
||||||
|
access 수명 60초
|
||||||
|
refresh 수명 1800초 typ=Refresh
|
||||||
|
|
||||||
|
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
|
||||||
|
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
|
||||||
|
|
||||||
|
=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
|
||||||
|
HTTP 200 ← 기대대로
|
||||||
|
새 토큰의 sid → 동일 ✔
|
||||||
|
|
||||||
|
=== [6] keycloak-1 을 통해 로그아웃 ===
|
||||||
|
http_code=204
|
||||||
|
|
||||||
|
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
|
||||||
|
HTTP 400 ← 기대대로
|
||||||
|
error invalid_grant
|
||||||
|
error_description Session not active
|
||||||
|
|
||||||
|
=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
|
||||||
|
행 없음 — 로그아웃으로 삭제되었다
|
||||||
|
```
|
||||||
|
|
||||||
|
**네 가지가 모두 기대대로다.**
|
||||||
|
|
||||||
|
| | 확인된 것 |
|
||||||
|
|---|---|
|
||||||
|
| 조회 | 같은 sid가 양쪽에서 보인다 |
|
||||||
|
| **쓰기** | keycloak-0의 refresh token을 keycloak-1이 받아 갱신했고, **sid가 유지된다** |
|
||||||
|
| **역방향 무효화** | keycloak-1의 로그아웃이 keycloak-0의 갱신을 막았다 |
|
||||||
|
| 영속 | 로그아웃과 함께 DB 행이 사라졌다 |
|
||||||
|
|
||||||
|
**`sid`는 JWT 안에만 있는 값이 아니다.** PostgreSQL의
|
||||||
|
`OFFLINE_USER_SESSION.user_session_id` 컬럼에 **문자 그대로** 들어 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실험 0b — 복제인가, 같은 DB를 본 것인가
|
||||||
|
|
||||||
|
실험 0은 "두 노드가 같은 답을 한다"까지만 증명한다. **그것으로는 Infinispan이
|
||||||
|
복제했다고 말할 수 없다.** `persistent-user-sessions`(Keycloak 26 기본값)에서는
|
||||||
|
세션이 PostgreSQL에 기록되므로, **캐시를 아예 꺼도 두 노드는 같은 답을 한다.**
|
||||||
|
|
||||||
|
가르는 방법: 로그인 한 번을 사이에 두고 **양쪽 노드의 캐시 계수기**를 잰다.
|
||||||
|
|
||||||
|
### 결과 — [`02-cache-delta.txt`](evidence/session-replication/02-cache-delta.txt)
|
||||||
|
|
||||||
|
```
|
||||||
|
=== 로그인은 keycloak-0 에만 보냈다 ===
|
||||||
|
로그인 응답: http_code=200
|
||||||
|
|
||||||
|
=== keycloak-0 (로그인을 받은 노드) ===
|
||||||
|
계수기 캐시 전 후 증가
|
||||||
|
approximate_entries_unique sessions 1 2 +1 ←
|
||||||
|
stores sessions 2 3 +1 ←
|
||||||
|
misses sessions 3 4 +1 ←
|
||||||
|
rpc.replication_count sessions 1 1 +0
|
||||||
|
|
||||||
|
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
|
||||||
|
approximate_entries_unique sessions 0 0 +0
|
||||||
|
stores sessions 1 1 +0
|
||||||
|
hits sessions 4 4 +0
|
||||||
|
rpc.replication_count sessions 7 7 +0
|
||||||
|
```
|
||||||
|
|
||||||
|
**keycloak-1의 계수기가 하나도 움직이지 않았다.** 엔트리도 0, 저장도 0.
|
||||||
|
|
||||||
|
그리고 keycloak-1의 `sessions` 캐시 엔트리는 **처음부터 끝까지 0**이다.
|
||||||
|
keycloak-0이 세션을 9개 들고 있는 동안에도 0이었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실험 0c — 엔트리는 어느 노드에 있는가
|
||||||
|
|
||||||
|
0b의 결과에는 두 가지 설명이 가능하다.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| (a) **분산 캐시 + owners=1** | 일관 해싱으로 흩어지는데 이번 건이 우연히 keycloak-0에 떨어졌다 |
|
||||||
|
| (b) **로컬 캐시** | 각 노드는 자기가 처리한 것만 캐시한다 |
|
||||||
|
|
||||||
|
**반대편 노드에 로그인을 몰아주면 갈린다.** (a)라면 어느 쪽에 요청하든 엔트리는
|
||||||
|
양쪽에 흩어진다. (b)라면 **요청을 받은 노드에서만** 는다.
|
||||||
|
|
||||||
|
### 결과 — [`03-cache-ownership.txt`](evidence/session-replication/03-cache-ownership.txt)
|
||||||
|
|
||||||
|
```
|
||||||
|
단계 k0 entries k1 entries
|
||||||
|
시작 2.0 0.0
|
||||||
|
keycloak-1 에 로그인 5회 2.0 5.0 ← k0 그대로, k1 만 +5
|
||||||
|
keycloak-0 에 로그인 5회 7.0 5.0 ← k0 만 +5, k1 그대로
|
||||||
|
|
||||||
|
=== 대조: PostgreSQL 에는 몇 건인가 ===
|
||||||
|
online 세션 12 ← 7 + 5 = 12, 정확히 일치
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b)다.** 그리고 **7 + 5 = 12**로 DB 총계와 정확히 맞는다 — 모든 세션이 DB에
|
||||||
|
있고, 각각은 **자기를 만든 노드 한 곳에만** 캐시되어 있다.
|
||||||
|
|
||||||
|
### 그래프로 본 같은 사실
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`vendor_statistics_approximate_entries_unique{cache="sessions"}` — Grafana Explore.
|
||||||
|
|
||||||
|
**파란 선(keycloak-1)이 0에 붙어 있는 동안 초록 선(keycloak-0)만 14까지
|
||||||
|
올라간다.** 파란 선은 09:50, 즉 **keycloak-1에 직접 로그인을 보낸 순간에만**
|
||||||
|
5로 뛴다. 중간의 절벽은 캐시를 비우려고 파드를 재시작한 지점이다.
|
||||||
|
|
||||||
|
> 캐시 설정은 파일에서 읽을 수 없다. 파드의 `/opt/keycloak/conf/cache-ispn.xml`은
|
||||||
|
> `<cache-container name="keycloak"><transport/></cache-container>` 뿐이고,
|
||||||
|
> Keycloak 26은 캐시를 **코드에서** 만든다. 그래서 위 결론은 설정을 읽어서가
|
||||||
|
> 아니라 **동작을 측정해서** 얻었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 실험 0d — 반대편 노드가 정말 DB에서 읽는가
|
||||||
|
|
||||||
|
0b·0c까지는 **추론**이었다. "keycloak-1의 메모리에 없는데 쓸 수 있으니 DB에서
|
||||||
|
읽었을 것이다" — 그럴듯하지만 **SQL을 본 적은 없다.**
|
||||||
|
|
||||||
|
PostgreSQL의 문장 로깅을 몇 초만 켜고, keycloak-0에서 만든 세션에 대해
|
||||||
|
**keycloak-1에 refresh를 딱 한 번** 보낸 뒤 로그를 뒤졌다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alter system set log_statement='all';
|
||||||
|
alter system set log_line_prefix='%m [%p] %h '; -- %h 로 파드 IP 를 남긴다
|
||||||
|
select pg_reload_conf();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 잡힌 트랜잭션 — [`04-read-path-sql.txt`](evidence/session-replication/04-read-path-sql.txt)
|
||||||
|
|
||||||
|
```
|
||||||
|
01:12:34.934 pid=81376 | BEGIN
|
||||||
|
01:12:34.934 pid=81376 | select ... from OFFLINE_USER_SESSION where (OFFLINE_FLAG,USER_SESSION_ID) in (($1,$2))
|
||||||
|
01:12:34.936 pid=81376 | select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
|
||||||
|
01:12:34.937 pid=81376 | select ... from OFFLINE_CLIENT_SESSION where (...) in ((...))
|
||||||
|
01:12:34.938 pid=81376 | select VERSION from OFFLINE_CLIENT_SESSION ... for no key update skip locked
|
||||||
|
01:12:34.944 pid=81376 | update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where ... and VERSION=$8
|
||||||
|
01:12:34.946 pid=81376 | update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where ... and VERSION=$5
|
||||||
|
01:12:34.946 pid=81376 | SET LOCAL synchronous_commit TO OFF
|
||||||
|
01:12:34.947 pid=81376 | COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
이 연결의 클라이언트 IP는 `10.42.0.35` — **keycloak-1의 파드 IP**다.
|
||||||
|
sid 하나에 대해 keycloak-0이 6건(로그인), keycloak-1이 6건(갱신)을 날렸다.
|
||||||
|
|
||||||
|
```
|
||||||
|
=== 요약: 파드별 질의 건수 ===
|
||||||
|
6 [keycloak-1]
|
||||||
|
6 [keycloak-0]
|
||||||
|
```
|
||||||
|
|
||||||
|
**추론이 관측이 되었다.** keycloak-1은 세션을 DB에서 읽고, DB에 쓴다.
|
||||||
|
|
||||||
|
### 여기서 딸려 나온 것 세 가지
|
||||||
|
|
||||||
|
이 13밀리초짜리 트랜잭션 하나에 **원래 질문들의 답이 절반쯤 들어 있다.**
|
||||||
|
|
||||||
|
#### (1) 낙관적 락 — `VERSION` 컬럼
|
||||||
|
|
||||||
|
```sql
|
||||||
|
update OFFLINE_USER_SESSION
|
||||||
|
set LAST_SESSION_REFRESH=$1, VERSION=$2
|
||||||
|
where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
|
||||||
|
─────────────
|
||||||
|
읽을 때의 버전과 같을 때만 쓴다
|
||||||
|
```
|
||||||
|
|
||||||
|
읽은 뒤 다른 노드가 먼저 고쳤다면 `VERSION`이 달라져 **`UPDATE`가 0행을
|
||||||
|
갱신하고 실패한다.** 잠금을 오래 잡지 않고 충돌을 사후에 검출하는 방식이다.
|
||||||
|
|
||||||
|
**리프레시 토큰 동시 갱신 경쟁(로드맵 B-5)이 여기서 갈린다.** 두 요청이
|
||||||
|
같은 세션을 동시에 갱신하면 하나는 이 검사에서 진다.
|
||||||
|
|
||||||
|
#### (2) `FOR NO KEY UPDATE ... SKIP LOCKED`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select VERSION from OFFLINE_USER_SESSION
|
||||||
|
where USER_SESSION_ID=$1 and OFFLINE_FLAG=$2
|
||||||
|
for no key update of puse1_0 skip locked
|
||||||
|
────────────── ────────────
|
||||||
|
키가 아닌 컬럼만 잠근다 잠긴 행은 건너뛴다 (기다리지 않는다)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 절 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `FOR NO KEY UPDATE` | 행을 잠그되 **외래키 참조는 막지 않는다.** `FOR UPDATE`보다 약해 경합이 준다 |
|
||||||
|
| **`SKIP LOCKED`** | 이미 잠긴 행을 **기다리지 않고 건너뛴다** |
|
||||||
|
|
||||||
|
`SKIP LOCKED`가 핵심이다. 같은 세션에 동시 요청이 몰려도 **줄을 서지 않는다.**
|
||||||
|
대기 대신 낙관적 락 실패로 처리한다 — 처리량을 위해 **지연 대신 재시도**를
|
||||||
|
고른 설계다.
|
||||||
|
|
||||||
|
#### (3) `SET LOCAL synchronous_commit TO OFF` — 내구성을 일부 포기한다
|
||||||
|
|
||||||
|
**같은 트랜잭션 안에서**, `COMMIT` 직전에 나온다. pid로 경계를 확인했다.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 기본값 `on` | `COMMIT`이 **WAL이 디스크에 내려간 뒤** 돌아온다 |
|
||||||
|
| **`off`** | **WAL 플러시를 기다리지 않고** 즉시 돌아온다 |
|
||||||
|
|
||||||
|
**결과: PostgreSQL이 갑자기 죽으면 직전 수백 밀리초의 세션 갱신이 사라질 수
|
||||||
|
있다.** 커밋했다고 응답해놓고 없어진다.
|
||||||
|
|
||||||
|
Keycloak이 이걸 의도적으로 켠 이유는 명확하다 — `LAST_SESSION_REFRESH` 갱신은
|
||||||
|
**초당 수백 번 일어나고, 몇백 밀리초쯤 잃어도 사용자가 다시 갱신하면 그만**이다.
|
||||||
|
로그인·로그아웃 같은 것과 달리 잃어도 되는 쓰기다.
|
||||||
|
|
||||||
|
> **DB 복구 실험(A-2)에서 그대로 관측될 지점이다.** PostgreSQL을 정상 종료가
|
||||||
|
> 아니라 강제 종료시키면, 마지막 몇백 밀리초의 세션 갱신이 실제로 없어져야
|
||||||
|
> 한다. 이건 버그가 아니라 **설계된 트레이드오프**다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c "show synchronous_commit" # 전역 기본값은 on
|
||||||
|
```
|
||||||
|
|
||||||
|
전역 설정은 `on`이고, **Keycloak이 세션 트랜잭션에만 `SET LOCAL`로 끈다.**
|
||||||
|
`SET LOCAL`은 그 트랜잭션이 끝나면 되돌아간다.
|
||||||
|
|
||||||
|
### 덤: 캐시는 읽어도 채워지지 않는다
|
||||||
|
|
||||||
|
```
|
||||||
|
K1_ENTRIES_BEFORE=5.0
|
||||||
|
REFRESH_ON_K1=200
|
||||||
|
K1_ENTRIES_AFTER=5.0 ← 갱신을 처리하고도 그대로
|
||||||
|
```
|
||||||
|
|
||||||
|
**keycloak-1은 남의 세션을 DB에서 읽어 처리하고도 캐시에 담지 않았다.**
|
||||||
|
|
||||||
|
0c에서 세운 모델 "각 노드는 자기가 처리한 것만 캐시한다"를 더 좁혀야 한다.
|
||||||
|
|
||||||
|
> 캐시에 담기는 것은 **그 노드가 로그인시켜 만든 세션**뿐이다.
|
||||||
|
> 남의 세션은 매번 DB에서 읽는다.
|
||||||
|
|
||||||
|
로드밸런서가 세션을 만든 노드가 아닌 쪽으로 요청을 보내면 **매번 DB를 친다.**
|
||||||
|
세션 어피니티(sticky session)가 정확성이 아니라 **성능** 문제인 이유가 이것이다.
|
||||||
|
|
||||||
|
### 덤 2: jdbc-ping 하트비트가 그대로 보인다
|
||||||
|
|
||||||
|
```
|
||||||
|
01:12:37.551 pid=81369 | BEGIN
|
||||||
|
01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1
|
||||||
|
01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...)
|
||||||
|
01:12:37.553 pid=81369 | COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**디스커버리는 별도 연결(pid=81369)에서 주기적으로 자기 행을 지우고 다시
|
||||||
|
넣는다.** 세션 트래픽과 완전히 분리된 경로다 — 11층에서 말한 "디스커버리와
|
||||||
|
트랜스포트는 다른 경로"가 로그에서 눈으로 확인된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 그래서 무엇이 세션을 공유하는가
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 (keycloak-0)
|
||||||
|
│
|
||||||
|
├──▶ PostgreSQL OFFLINE_USER_SESSION ← 진실의 원천. 양쪽이 본다
|
||||||
|
│
|
||||||
|
└──▶ keycloak-0 로컬 캐시 ← 자기 것만. 건너가지 않는다
|
||||||
|
|
||||||
|
keycloak-1 이 그 세션을 물으면
|
||||||
|
│
|
||||||
|
└──▶ 자기 캐시에 없음 → PostgreSQL 에서 읽는다
|
||||||
|
```
|
||||||
|
|
||||||
|
| 계층 | 역할 | 노드 간 공유 |
|
||||||
|
|---|---|---|
|
||||||
|
| **PostgreSQL** | 진실의 원천 | **여기서 일어난다** |
|
||||||
|
| **Infinispan `sessions`** | 자기 노드가 처리한 세션의 룩어사이드 캐시 | **일어나지 않는다** |
|
||||||
|
| **Infinispan 클러스터** | 무효화 메시지, `work` 캐시 등 | 형성은 되어 있다 |
|
||||||
|
|
||||||
|
이건 **Keycloak 26의 의도된 설계**다. `persistent-user-sessions`가 기본이 되면서
|
||||||
|
DB가 진실의 원천이 됐고, 세션 캐시는 **복제할 이유가 없어졌다.** 복제를 하면
|
||||||
|
네트워크와 메모리를 쓰면서 DB와 캐시 두 벌을 정합하게 유지해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 개념
|
||||||
|
|
||||||
|
### 8-1. `persistent-user-sessions`
|
||||||
|
|
||||||
|
Keycloak 25에서 도입되고 **26에서 기본값**이 된 기능. 사용자 세션을
|
||||||
|
Infinispan에만 두지 않고 **데이터베이스에 기록**한다.
|
||||||
|
|
||||||
|
| | 켜져 있을 때 (기본) | 꺼져 있을 때 (volatile) |
|
||||||
|
|---|---|---|
|
||||||
|
| 진실의 원천 | **PostgreSQL** | Infinispan |
|
||||||
|
| 전체 재시작 후 | **세션이 남는다** | 전부 사라진다 |
|
||||||
|
| 노드 간 공유 | DB가 한다 | **복제가 해야 한다** |
|
||||||
|
| 로그인당 비용 | DB 쓰기 | 네트워크 복제 |
|
||||||
|
|
||||||
|
**이 실험의 결론은 전부 "켜져 있을 때"의 이야기다.** 끄면 다른 그림이 나오고,
|
||||||
|
그 비교가 로드맵 A-2다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec keycloak-0 -- \
|
||||||
|
/opt/keycloak/bin/kc.sh show-config 2>/dev/null | grep -i feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8-2. 온라인 세션이 `OFFLINE_` 테이블에 들어간다
|
||||||
|
|
||||||
|
**`USER_SESSION` 테이블은 존재하지 않는다.** 처음에 이걸 찾다가 없어서 당황했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
public | auth_session | table | keycloak
|
||||||
|
public | jgroups_ping | table | keycloak
|
||||||
|
public | offline_client_session | table | keycloak
|
||||||
|
public | offline_user_session | table | keycloak
|
||||||
|
public | revoked_token | table | keycloak
|
||||||
|
public | root_auth_session | table | keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
`persistent-user-sessions`는 **기존 오프라인 세션 테이블을 재사용**하고
|
||||||
|
`offline_flag` 컬럼으로 구분한다.
|
||||||
|
|
||||||
|
| `offline_flag` | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| **`'0'`** | **온라인 세션** (일반 로그인) |
|
||||||
|
| `'1'` | 오프라인 세션 (`offline_access`) |
|
||||||
|
|
||||||
|
기본키가 `(user_session_id, offline_flag)` 복합키인 이유다 — 같은 세션 id가
|
||||||
|
온라인/오프라인 두 행으로 존재할 수 있다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select offline_flag, count(*) from offline_user_session group by offline_flag;
|
||||||
|
select user_session_id, offline_flag, created_on, last_session_refresh
|
||||||
|
from offline_user_session where user_session_id = '<sid>';
|
||||||
|
```
|
||||||
|
|
||||||
|
**이름이 내용을 배신하는 스키마다.** 운영에서 "온라인 세션이 DB 어디 있냐"를
|
||||||
|
찾을 때 이걸 모르면 한참 헤맨다.
|
||||||
|
|
||||||
|
### 8-3. `sid` — 토큰과 DB를 잇는 열쇠
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
↕ 같은 값
|
||||||
|
DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
↕ 같은 값
|
||||||
|
Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW
|
||||||
|
```
|
||||||
|
|
||||||
|
세 곳에서 같은 문자열이다. **장애를 추적할 때 이 값 하나로 토큰·DB·관리 API를
|
||||||
|
꿰뚫을 수 있다.** 백채널 로그아웃의 `sid` 클레임도 이것이다.
|
||||||
|
|
||||||
|
### 8-4. `openid` scope가 없으면 OIDC 토큰이 아니다
|
||||||
|
|
||||||
|
`admin-cli`에 `scope` 없이 direct grant를 하면 나오는 클레임은 이렇다.
|
||||||
|
|
||||||
|
```
|
||||||
|
--- access_token ---
|
||||||
|
클레임: azp, exp, iat, iss, jti, scope, sid, typ
|
||||||
|
typ = Bearer | sub = None | sid = OxikTqdHCJ7ESm2GPKI1oa6c
|
||||||
|
```
|
||||||
|
|
||||||
|
**`sub`이 없다.** OIDC가 아니라 순수 OAuth2 액세스 토큰이기 때문이다.
|
||||||
|
`sub`은 OIDC가 요구하는 클레임이고, `openid` scope가 있어야 붙는다.
|
||||||
|
|
||||||
|
같은 이유로 `userinfo`가 403 `insufficient_scope`를 준다 — userinfo는 OIDC
|
||||||
|
엔드포인트다. **두 현상은 하나의 원인**이다.
|
||||||
|
|
||||||
|
### 8-5. 룩어사이드(lookaside) 캐시
|
||||||
|
|
||||||
|
```
|
||||||
|
읽기: 캐시 확인 → 없으면 DB → 캐시에 채움
|
||||||
|
쓰기: DB 에 쓰고 → 캐시에도 씀
|
||||||
|
```
|
||||||
|
|
||||||
|
캐시가 **DB 앞에 서 있되 DB를 대체하지 않는** 구조. 캐시를 통째로 날려도
|
||||||
|
정확성은 유지되고 느려지기만 한다. Keycloak 26의 세션 캐시가 이 모양이다.
|
||||||
|
|
||||||
|
이 성질이 **노드 상실 실험(A-3)의 결과를 미리 결정한다** — 노드가 죽으면
|
||||||
|
그 노드의 캐시는 사라지지만 세션은 DB에 있으므로 살아남아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 다음 실험에 대한 예측
|
||||||
|
|
||||||
|
기준선이 생겼으므로 **틀릴 수 있는 예측**을 세울 수 있다. 예측이 빗나가면
|
||||||
|
그것이야말로 배울 거리다.
|
||||||
|
|
||||||
|
| 실험 | 예측 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A-1** TCP 7800 차단 | **세션 공유는 안 깨진다.** 대신 무효화 전파와 `work` 캐시가 깨진다 | 세션은 7800으로 오가지 않는다 |
|
||||||
|
| **A-2** DB 손실 | **즉시 전면 장애.** 캐시에 있는 세션도 못 쓴다 | DB가 진실의 원천 |
|
||||||
|
| **A-2'** DB **강제** 종료 | 직전 수백 ms 의 세션 갱신이 **사라진다** | `synchronous_commit OFF` |
|
||||||
|
| **B-5** 동시 갱신 경쟁 | 한쪽이 `VERSION` 검사에서 지고 재시도한다 | 낙관적 락 |
|
||||||
|
| **A-3** 노드 상실 (kc-lab-2) | **세션은 살아남는다.** 죽은 노드의 캐시만 사라진다 | 룩어사이드 |
|
||||||
|
| **A-4** volatile 비교 | 7800 차단이 **A-1과 정반대로** 치명적이 된다 | 그때는 캐시가 진실의 원천 |
|
||||||
|
|
||||||
|
특히 A-1은 **직관과 어긋나는 예측**이다. "클러스터 포트를 막으면 세션이
|
||||||
|
깨진다"가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 겪은 함정
|
||||||
|
|
||||||
|
### 10-1. kubectl 스트림에서 출력이 통째로 사라졌다
|
||||||
|
|
||||||
|
`kubectl run --rm -i ... | grep` 로 받으면 **중간 조각이 유실됐다.**
|
||||||
|
keycloak-1의 스냅샷과 그 다음 마커가 함께 없어져, 전값이 0으로 잡히면서
|
||||||
|
**가짜 델타가 만들어졌다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
###BEFORE_K1 ← 여기 있어야 할 지표 20줄과
|
||||||
|
http_code=200 다음 마커 ###LOGIN 이 통째로 사라졌다
|
||||||
|
###AFTER_K0
|
||||||
|
```
|
||||||
|
|
||||||
|
이때 리포트는 keycloak-1이 `+9`, `+7` 증가한 것처럼 보였다. **없는 복제가
|
||||||
|
있는 것처럼 보이는, 가장 나쁜 종류의 오류다.**
|
||||||
|
|
||||||
|
| 고친 방법 | |
|
||||||
|
|---|---|
|
||||||
|
| 파드 안에서 파일로 모으고 마지막에 `cat` 한 번 | 스트리밍 중 유실을 없앤다 |
|
||||||
|
| 스냅샷이 비면 **경고를 출력**한다 | 조용히 0으로 계산되는 것을 막는다 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for n in ('BEFORE_K0','BEFORE_K1','AFTER_K0','AFTER_K1'):
|
||||||
|
if not blocks.get(n):
|
||||||
|
print(f' !! {n} 스냅샷이 비었다 — 델타를 신뢰할 수 없다')
|
||||||
|
```
|
||||||
|
|
||||||
|
**계측 코드는 자기가 실패했는지 스스로 말해야 한다.**
|
||||||
|
|
||||||
|
### 10-2. DB에서 직접 지우면 캐시는 남는다
|
||||||
|
|
||||||
|
정리하려고 `delete from offline_user_session`을 실행했더니, **캐시 엔트리는
|
||||||
|
그대로 남아** 캐시 합계(19)와 DB 총계(15)가 어긋났다.
|
||||||
|
|
||||||
|
> 운영에서 세션 테이블을 직접 손대면 캐시와 DB가 갈라진다. 세션을 지울 때는
|
||||||
|
> 관리 API(`logout-all`)를 쓰거나, DB를 건드렸다면 **파드를 재시작**해야 한다.
|
||||||
|
|
||||||
|
이 실험의 최종 수치는 **파드 재시작 후** 다시 잰 것이다.
|
||||||
|
|
||||||
|
### 10-3. Keycloak 이미지에는 `curl`이 없다
|
||||||
|
|
||||||
|
`kubectl exec keycloak-0 -- curl` 은 실패한다. 임시 `curlimages/curl` 파드를
|
||||||
|
띄워 파드 네트워크 안에서 호출했다. 파드 IP는 클러스터 밖에서 닿지 않으므로
|
||||||
|
이 방법이 사실상 유일하다.
|
||||||
|
|
||||||
|
### 10-4. 중첩 셸의 변수 치환
|
||||||
|
|
||||||
|
`ssh host '... $VAR ...'` 안에 다시 `sh -c "..."` 를 넣으면 인용이 세 겹이 되어
|
||||||
|
치환이 조용히 깨진다. 첫 시도에서 파드 IP가 빈 문자열이 되어 아무 출력도
|
||||||
|
나오지 않았다.
|
||||||
|
|
||||||
|
**스크립트 파일로 만들어 `scp` 로 옮기는 쪽이 옳다.** 재현도 되고 저장소에
|
||||||
|
남는다. `deploy/lab/scripts/` 아래 세 스크립트가 그 결과다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 재현
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 깨끗한 상태로 되돌린다 (DB 비우고 캐시 비우기)
|
||||||
|
ssh test-server '
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c "delete from offline_user_session"
|
||||||
|
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s'
|
||||||
|
|
||||||
|
# 2. 세 실험을 순서대로
|
||||||
|
ssh test-server '/tmp/experiment-session-replication.sh' # 교차 노드 사용
|
||||||
|
ssh test-server '/tmp/experiment-cache-replication-delta.sh' # 복제인가 DB인가
|
||||||
|
ssh test-server '/tmp/experiment-cache-ownership.sh' # 엔트리 위치
|
||||||
|
|
||||||
|
# 3. 그래프
|
||||||
|
# https://app2.hyeonworks.com/explore
|
||||||
|
# vendor_statistics_approximate_entries_unique{cache="sessions"}
|
||||||
|
# Legend: {{pod}} on {{node}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 확인용 명령 모음
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 클러스터 멤버
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c "select name, ip, coord from jgroups_ping"
|
||||||
|
|
||||||
|
# DB 세션
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||||
|
|
||||||
|
# 노드별 캐시 엔트리 (파드 안에서)
|
||||||
|
curl -s http://<pod-ip>:9000/metrics \
|
||||||
|
| grep 'approximate_entries_unique{cache="sessions"'
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
|||||||
|
# Keycloak 멀티노드 클러스터 — 구성과 형성 확인
|
||||||
|
|
||||||
|
로드맵 1번. 세션 저장소 실험 전부의 선행 인프라다.
|
||||||
|
브랜치 `feature/keycloak-multinode-cluster-jdbc-ping`.
|
||||||
|
|
||||||
|
**결과 — 두 파드가 서로 다른 노드에서 하나의 Infinispan 클러스터를 이뤘다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 무엇을 확인하려는가
|
||||||
|
|
||||||
|
Keycloak 26은 **디스커버리와 클러스터 통신을 서로 다른 경로로** 처리한다.
|
||||||
|
|
||||||
|
| 단계 | 경로 | 실패하면 |
|
||||||
|
|---|---|---|
|
||||||
|
| **디스커버리** — 서로를 찾는다 | PostgreSQL의 `JGROUPS_PING` 테이블 | 상대의 존재 자체를 모른다 |
|
||||||
|
| **클러스터 통신** — 실제로 대화한다 | **TCP 7800** (파드 간 직접) | **DB에는 등록되는데 클러스터가 안 붙는다** |
|
||||||
|
|
||||||
|
두 번째 줄이 이 실험대를 2노드로 만든 이유다. **단일 노드에서는 이 고장을
|
||||||
|
재현할 수 없다** — 같은 커널 안에서는 막을 경계가 없기 때문이다.
|
||||||
|
|
||||||
|
먼저 **정상적으로 붙는 상태**를 확보하고 실측값을 남긴다. 그래야 다음 실험에서
|
||||||
|
깨뜨렸을 때 무엇이 달라졌는지 비교할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 배포한 구성과 그 근거
|
||||||
|
|
||||||
|
매니페스트: [`deploy/lab/k8s/keycloak-cluster.yaml`](../deploy/lab/k8s/keycloak-cluster.yaml)
|
||||||
|
|
||||||
|
### 2-1. 왜 StatefulSet인가
|
||||||
|
|
||||||
|
Deployment를 쓰면 파드 이름이 `keycloak-7d9f8b-x4k2p`처럼 매번 바뀐다.
|
||||||
|
StatefulSet은 **`keycloak-0`, `keycloak-1`로 고정**된다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: StatefulSet
|
||||||
|
spec:
|
||||||
|
serviceName: keycloak-headless
|
||||||
|
replicas: 2
|
||||||
|
podManagementPolicy: Parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 실험에서 이름 안정성이 중요한 이유** — 클러스터 멤버십을 읽는 곳이 두
|
||||||
|
군데인데(Infinispan 로그, `JGROUPS_PING` 테이블) 이름이 계속 바뀌면 대조가
|
||||||
|
어렵다. 실제로 Infinispan은 `keycloak-0-49501`처럼 **파드 이름 + 랜덤 접미사**를
|
||||||
|
노드 식별자로 쓴다.
|
||||||
|
|
||||||
|
**`podManagementPolicy: Parallel`** — 기본값 `OrderedReady`는 0번이 Ready가 된
|
||||||
|
뒤에야 1번을 만든다. `Parallel`은 **동시에 시작**하므로 두 파드가 DB에 등록을
|
||||||
|
경쟁하게 되고, 그것이 운영에서 실제로 일어나는 상황이다.
|
||||||
|
|
||||||
|
### 2-2. 왜 `start`이고 `start-dev`가 아닌가
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
args: ["start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`start-dev`는 **`cache=local`을 강제**한다. 클러스터가 아예 형성되지 않는다.
|
||||||
|
저장소의 `docker-compose.yml`이 `start-dev`를 쓰는 것은 단일 인스턴스 학습용이며,
|
||||||
|
이 실험대에서는 쓸 수 없다.
|
||||||
|
|
||||||
|
`--optimized`는 붙이지 않았다. 붙이려면 사전 `build`가 필요하고, 없으면
|
||||||
|
첫 기동에 **암묵적 build가 실행되어 60~90초**가 걸린다. 그래서 아래처럼
|
||||||
|
`startupProbe`를 넉넉하게 준다.
|
||||||
|
|
||||||
|
### 2-3. 노드당 하나씩 배치
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
topologySpreadConstraints:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels: { app: keycloak }
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 파드가 한 노드에 몰리면 7800 차단 실험이 무의미해진다.** 같은 커널 안의
|
||||||
|
루프백 통신이라 막을 대상이 없기 때문이다.
|
||||||
|
|
||||||
|
`ScheduleAnyway`를 고른 이유는 장애 실험 때문이다. `DoNotSchedule`이면 노드
|
||||||
|
하나를 죽였을 때 남은 파드가 **배치되지 못하고 Pending에 머문다.**
|
||||||
|
|
||||||
|
### 2-4. 헬스체크는 9000 포트다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- { containerPort: 8080, name: http }
|
||||||
|
- { containerPort: 9000, name: management }
|
||||||
|
- { containerPort: 7800, name: jgroups }
|
||||||
|
|
||||||
|
startupProbe: { httpGet: { path: /health/started, port: management }, failureThreshold: 60 }
|
||||||
|
readinessProbe:{ httpGet: { path: /health/ready, port: management } }
|
||||||
|
livenessProbe: { httpGet: { path: /health/live, port: management } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keycloak 25부터 health와 metrics가 8080이 아니라 관리 포트 9000으로 옮겨졌다.**
|
||||||
|
8080으로 프로브를 걸면 404가 나고 파드가 영원히 Ready가 되지 않는다.
|
||||||
|
|
||||||
|
`KC_HEALTH_ENABLED=true`를 켜야 엔드포인트가 노출된다.
|
||||||
|
|
||||||
|
`startupProbe`의 `failureThreshold: 60` × `periodSeconds: 10` = **최대 10분**을
|
||||||
|
기다린다. 첫 기동의 암묵적 build 때문이다. 이게 없으면 liveness가 먼저 발동해
|
||||||
|
**재시작 루프**에 빠진다.
|
||||||
|
|
||||||
|
### 2-5. 환경변수 — 첫 실험에서 확정한 값
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- { name: KC_HOSTNAME, value: https://auth.hyeonworks.com }
|
||||||
|
- { name: KC_HOSTNAME_STRICT, value: "true" }
|
||||||
|
- { name: KC_PROXY_HEADERS, value: xforwarded }
|
||||||
|
- { name: KC_HTTP_ENABLED, value: "true" }
|
||||||
|
```
|
||||||
|
|
||||||
|
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md)에서
|
||||||
|
측정으로 확정한 조합이다.
|
||||||
|
|
||||||
|
| 설정 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `KC_HOSTNAME`에 **전체 URL** | 스킴·호스트를 **고정**한다. 헤더와 무관하게 `iss`가 https로 발급된다 |
|
||||||
|
| `KC_HOSTNAME_STRICT=true` | Host 헤더를 믿지 않는다. 조작으로 흐름을 돌릴 여지를 없앤다 |
|
||||||
|
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 forwarded 헤더에서 가져온다 |
|
||||||
|
| `KC_HTTP_ENABLED=true` | 앞단이 TLS를 끊었으므로 평문 HTTP를 받는다 |
|
||||||
|
|
||||||
|
**이 실험을 먼저 하지 않았다면** 지금 `iss`가 `http://10.42.x.x`로 나왔을 것이고,
|
||||||
|
원인을 세션 쪽에서 찾느라 헤맸을 것이다.
|
||||||
|
|
||||||
|
### 2-6. 힙 상한
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- { name: JAVA_OPTS_KC_HEAP, value: "-Xms256m -Xmx512m" }
|
||||||
|
resources:
|
||||||
|
requests: { memory: 640Mi, cpu: 100m }
|
||||||
|
limits: { memory: 900Mi }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다. 이 실험대의 게스트 여유가
|
||||||
|
약 3.8GB이므로 명시적으로 잡는다. 실측 결과 **파드당 약 590Mi**로 안정됐다.
|
||||||
|
|
||||||
|
### 2-7. PostgreSQL — 볼륨이 노드에 고정된다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
storageClassName: local-path
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
env:
|
||||||
|
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
|
||||||
|
```
|
||||||
|
|
||||||
|
k3s 기본 `local-path` 프로비저너는 **파드가 배치된 노드의 로컬 디스크**에
|
||||||
|
볼륨을 만든다. 따라서 PostgreSQL은 그 노드에 묶인다.
|
||||||
|
|
||||||
|
**이것은 결함이 아니라 실험 조건이다.** 나중에 "데이터베이스가 있는 노드가
|
||||||
|
죽으면" 시나리오가 그래서 의미를 갖는다.
|
||||||
|
|
||||||
|
- `strategy: Recreate` — RWO 볼륨은 두 파드가 동시에 마운트할 수 없다.
|
||||||
|
기본값 `RollingUpdate`면 새 파드가 볼륨을 못 잡고 멈춘다
|
||||||
|
- `PGDATA`를 한 단계 아래로 — 마운트 지점에 `lost+found` 같은 것이 있으면
|
||||||
|
`initdb`가 거부한다
|
||||||
|
|
||||||
|
### 2-8. 헤드리스 서비스는 왜 두는가
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: Service
|
||||||
|
metadata: { name: keycloak-headless }
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
```
|
||||||
|
|
||||||
|
**jdbc-ping 디스커버리에는 필요 없다.** DB로 서로를 찾기 때문이다.
|
||||||
|
개별 파드에 안정된 DNS 이름으로 접근해 상태를 조회하기 위해 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 실행한 명령
|
||||||
|
|
||||||
|
### 3-1. 브랜치와 정리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션
|
||||||
|
cd ~/workspace/keycloak-pattern
|
||||||
|
git checkout -b feature/keycloak-multinode-cluster-jdbc-ping
|
||||||
|
git merge --no-edit develop-keycloak-session-store
|
||||||
|
|
||||||
|
# lab host — 끝난 실험을 지워 메모리를 회수한다
|
||||||
|
kubectl delete ns header-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
정리 후 게스트 사용량이 `kc-lab-1 1593Mi(46%)` / `kc-lab-2 872Mi(35%)`로 떨어졌다.
|
||||||
|
|
||||||
|
### 3-2. 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션 — 매니페스트 작성 후
|
||||||
|
git add deploy/lab/k8s/keycloak-cluster.yaml
|
||||||
|
git commit -m "feat: deploy Keycloak multi-node cluster with PostgreSQL"
|
||||||
|
git push -u origin feature/keycloak-multinode-cluster-jdbc-ping
|
||||||
|
|
||||||
|
# lab host
|
||||||
|
cd ~/workspace/keycloak-pattern
|
||||||
|
git fetch origin
|
||||||
|
git checkout -b feature/keycloak-multinode-cluster-jdbc-ping origin/feature/keycloak-multinode-cluster-jdbc-ping
|
||||||
|
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**PostgreSQL을 먼저 기다린다.** Keycloak이 DB 없이 뜨면 기동에 실패한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||||
|
```
|
||||||
|
|
||||||
|
이미지를 당겨오고 암묵적 build가 도는 첫 기동은 **수 분** 걸린다.
|
||||||
|
|
||||||
|
### 3-3. 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 파드 배치 — 서로 다른 노드에 있어야 한다
|
||||||
|
kubectl -n keycloak-lab get pods -o wide
|
||||||
|
|
||||||
|
# 클러스터 뷰 — Infinispan 로그
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
|
||||||
|
|
||||||
|
# 디스커버리 테이블
|
||||||
|
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1)
|
||||||
|
kubectl -n keycloak-lab exec "$PG" -- \
|
||||||
|
psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;"
|
||||||
|
|
||||||
|
# 외부 접근과 issuer
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool
|
||||||
|
|
||||||
|
# 자원
|
||||||
|
kubectl -n keycloak-lab top pods
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 확인된 사실
|
||||||
|
|
||||||
|
증거 원자료: [`evidence/keycloak-multinode-cluster/`](evidence/keycloak-multinode-cluster/)
|
||||||
|
|
||||||
|
### 4-1. 클러스터가 형성됐다
|
||||||
|
|
||||||
|
```
|
||||||
|
ISPN000094: Received new cluster view for channel ISPN:
|
||||||
|
[keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501]
|
||||||
|
↑ 멤버 수
|
||||||
|
ISPN100000: Node keycloak-0-49501 joined the cluster
|
||||||
|
ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`,
|
||||||
|
physical addresses are `[10.42.1.18:7800]`
|
||||||
|
```
|
||||||
|
|
||||||
|
두 파드가 **동일한 뷰**를 보고 있고, 물리 주소가 **7800**임이 로그에 찍힌다.
|
||||||
|
|
||||||
|
### 4-2. 디스커버리와 통신이 분리되어 있다
|
||||||
|
|
||||||
|
```
|
||||||
|
name | cluster_name | ip | coord
|
||||||
|
------------------+--------------+-----------------+-------
|
||||||
|
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
|
||||||
|
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블 하나에 두 메커니즘이 다 보인다.**
|
||||||
|
|
||||||
|
- `name`·`cluster_name` — **DB로 하는 디스커버리**의 결과
|
||||||
|
- `ip` 컬럼의 `:7800` — **실제 통신이 일어날 경로**
|
||||||
|
|
||||||
|
`coord`가 `t`인 `keycloak-1`이 코디네이터다. 이 노드를 죽였을 때 인계가
|
||||||
|
일어나는지가 다음 실험 항목이다.
|
||||||
|
|
||||||
|
전체 스키마는 `address / name / cluster_name / ip / coord / last_update /
|
||||||
|
coordinated_by`이며 기본키는 `address`다.
|
||||||
|
|
||||||
|
### 4-3. 노드당 하나씩 배치됐다
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-0 10.42.1.18 kc-lab-2
|
||||||
|
keycloak-1 10.42.0.16 kc-lab-1
|
||||||
|
postgres 10.42.1.19 kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
파드 IP 대역이 노드를 알려준다(`10.42.0.x` = kc-lab-1, `10.42.1.x` = kc-lab-2).
|
||||||
|
**독립된 커널 두 개에 하나씩** 떴으므로 7800 차단 실험의 전제가 성립한다.
|
||||||
|
|
||||||
|
PostgreSQL이 `kc-lab-2`에 있다는 점도 기록해둔다. **`kc-lab-2`를 죽이면
|
||||||
|
Keycloak 하나와 데이터베이스가 동시에 사라진다.**
|
||||||
|
|
||||||
|
### 4-4. 2홉 헤더 계약이 실제로 작동한다
|
||||||
|
|
||||||
|
```
|
||||||
|
issuer https://auth.hyeonworks.com/realms/master
|
||||||
|
authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth
|
||||||
|
token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token
|
||||||
|
end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout
|
||||||
|
jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs
|
||||||
|
```
|
||||||
|
|
||||||
|
**전부 `https`이고 외부 호스트명이다.** 첫 실험의 결론이 그대로 값을 했다.
|
||||||
|
|
||||||
|
### 4-5. 자원
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-0 594Mi
|
||||||
|
keycloak-1 593Mi
|
||||||
|
postgres 67Mi
|
||||||
|
──────────────────────
|
||||||
|
kc-lab-1 2248Mi (65%)
|
||||||
|
kc-lab-2 1447Mi (58%)
|
||||||
|
```
|
||||||
|
|
||||||
|
예상(파드당 700Mi)보다 적다. `JAVA_OPTS_KC_HEAP` 제한이 작동했다.
|
||||||
|
BFF와 Redis를 추가할 여유가 남아 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 겪은 함정
|
||||||
|
|
||||||
|
### `JGROUPS_PING` 컬럼명은 자료마다 다르다
|
||||||
|
|
||||||
|
오래된 문서에는 `own_addr`, `ping_data` 같은 이름이 나오지만 **Keycloak 26의
|
||||||
|
실제 스키마는 다르다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
address / name / cluster_name / ip / coord / last_update / coordinated_by
|
||||||
|
```
|
||||||
|
|
||||||
|
쿼리 전에 `\d jgroups_ping`으로 확인한다.
|
||||||
|
|
||||||
|
### Keycloak 컨테이너에 `curl`이 없다
|
||||||
|
|
||||||
|
메트릭을 파드 안에서 조회하려다 실패했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
sh: line 1: curl: command not found
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak 공식 이미지는 최소 구성이다. 메트릭을 볼 때는 포트포워딩하거나
|
||||||
|
임시 파드를 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab port-forward keycloak-0 9000:9000 &
|
||||||
|
curl -s localhost:9000/metrics | grep -i cluster
|
||||||
|
|
||||||
|
# 또는
|
||||||
|
kubectl -n keycloak-lab run m --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
|
||||||
|
curl -s http://keycloak-0.keycloak-headless:9000/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 첫 기동이 느린 것은 정상이다
|
||||||
|
|
||||||
|
`--optimized` 없이 `start`하면 **암묵적 build**가 실행된다. `startupProbe`를
|
||||||
|
넉넉히 주지 않으면 liveness가 먼저 발동해 재시작 루프에 빠진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 다음 실험 — 깨뜨려서 무엇이 보이는지
|
||||||
|
|
||||||
|
정상 상태를 확보했으므로 이제 의도적으로 고장을 만든다.
|
||||||
|
|
||||||
|
| 실험 | 방법 | 확인할 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| **7800 차단** | NetworkPolicy로 파드 간 7800만 차단 | **DB엔 등록되는데 클러스터가 안 붙는** 증상. 로그에 무엇이 먼저 보이는가 |
|
||||||
|
| **노드 상실** | `virsh destroy kc-lab-2` | 코디네이터 인계가 일어나는가. PostgreSQL도 같이 죽는다는 점에 유의 |
|
||||||
|
| **DB 상실** | postgres 파드 정지 | 이미 형성된 클러스터는 버티는가. 새 로그인은? |
|
||||||
|
|
||||||
|
**7800 차단부터 하는 것이 좋다.** 되돌리기가 가장 쉽고(NetworkPolicy 삭제),
|
||||||
|
증상이 로그에 선명하게 남는다.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
| 문서 | 관계 |
|
||||||
|
|---|---|
|
||||||
|
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 실험은 로드맵 1번 |
|
||||||
|
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `KC_HOSTNAME`·`KC_PROXY_HEADERS` 값의 근거 |
|
||||||
|
| [`session-lab-operations.md`](session-lab-operations.md) | 명령·자원 예산 |
|
||||||
|
| [`session-lab-concepts.md`](session-lab-concepts.md) | StatefulSet·프로브·PVC 등 개념 |
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
# 관측성 — Prometheus · node-exporter · Grafana
|
||||||
|
|
||||||
|
로드맵 10번. 장애 주입 실험보다 **먼저** 세운다.
|
||||||
|
|
||||||
|
**왜 먼저인가** — 나중에 세우면 이미 지나간 장애의 지표를 볼 수 없다.
|
||||||
|
"클러스터가 1분쯤 뒤에 복구됐다"는 측정이 아니라 인상이다.
|
||||||
|
로드맵에 *"장애 주입 중에 어떤 지표가 먼저 움직이는지 기록한다"*고 적어둔 항목은
|
||||||
|
관측이 먼저 서 있어야만 가능하다.
|
||||||
|
|
||||||
|
메모리를 8GB → 12GB로 증설한 뒤에야 올릴 수 있게 됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 무엇을 세웠나
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Grafana ──────────┐
|
||||||
|
브라우저 ──────▶│ app2.hyeonworks.com│ 대시보드
|
||||||
|
└─────────┬──────────┘
|
||||||
|
│ PromQL
|
||||||
|
┌─────────▼──────────┐
|
||||||
|
│ Prometheus │ 수집·저장 (TSDB, 7일)
|
||||||
|
└─────────┬──────────┘
|
||||||
|
│ scrape (15초)
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
Keycloak :9000 node-exporter :9100 kubelet
|
||||||
|
(앱 지표) (머신 지표) (컨테이너 지표)
|
||||||
|
```
|
||||||
|
|
||||||
|
매니페스트: [`deploy/lab/k8s/observability.yaml`](../deploy/lab/k8s/observability.yaml)
|
||||||
|
|
||||||
|
| 구성요소 | 역할 | 실측 메모리 |
|
||||||
|
|---|---|---|
|
||||||
|
| Prometheus | 수집·저장·질의 | 164Mi |
|
||||||
|
| node-exporter (DaemonSet) | 노드당 하나, 머신 지표 | 8Mi × 2 |
|
||||||
|
| Grafana | 시각화 | 65Mi |
|
||||||
|
| **합계** | | **약 245Mi** |
|
||||||
|
|
||||||
|
예상(550Mi)보다 훨씬 적다. 실험대 규모에서는 관측성 비용이 거의 무시할 수준이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 왜 kube-prometheus-stack을 쓰지 않았나
|
||||||
|
|
||||||
|
Helm 차트 하나로 끝내는 방법이 있지만 **평범한 매니페스트를 직접 썼다.**
|
||||||
|
|
||||||
|
| | kube-prometheus-stack | 직접 작성 |
|
||||||
|
|---|---|---|
|
||||||
|
| 설치 | Helm 한 줄 | 매니페스트 400줄 |
|
||||||
|
| 메모리 | 1.5GB 이상 | **245Mi** |
|
||||||
|
| 포함 | Operator, Alertmanager, 대시보드 다수, kube-state-metrics | 필요한 것만 |
|
||||||
|
| **보이는 것** | 추상화 뒤에 숨음 | **스크레이프 설정·RBAC·relabel 이 눈에 보임** |
|
||||||
|
|
||||||
|
세 번째 줄이 결정적이다. 이 실험대의 목적은 **인과를 직접 확인하는 것**이므로,
|
||||||
|
"어떻게 타깃을 찾는가"가 YAML에 드러나 있어야 한다. Operator를 쓰면
|
||||||
|
`ServiceMonitor` 하나만 보이고 그 아래는 감춰진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구성 결정과 근거
|
||||||
|
|
||||||
|
### 3-1. 관측 스택의 배치 — 장애 도메인 분리
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
**관측 시스템은 관측 대상과 같은 장애 도메인에 있으면 안 된다.** 죽는 순간을
|
||||||
|
기록해야 하는데 같이 죽으면 기록이 남지 않는다.
|
||||||
|
|
||||||
|
노드가 둘뿐이라 완전히 피할 수는 없다. 그래서 규칙을 정했다.
|
||||||
|
|
||||||
|
| 노드 | 역할 | 실험에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| **kc-lab-1** (k3s **server**) | control plane · Traefik · coredns · metrics-server · local-path-provisioner | **관측 스택을 여기 둔다. 죽이지 않는다** |
|
||||||
|
| **kc-lab-2** (k3s **agent**) | keycloak-0 · postgres | **장애 주입 대상** |
|
||||||
|
|
||||||
|
`kubernetes.io/hostname`으로 못박지 않고 **`node-role.kubernetes.io/control-plane`
|
||||||
|
라벨**을 쓴 이유는 의미가 드러나기 때문이다 — "컨트롤 플레인 노드에 둔다"는
|
||||||
|
의도가 호스트 이름보다 오래간다.
|
||||||
|
|
||||||
|
### 3-2. 앞선 판단을 정정했다
|
||||||
|
|
||||||
|
배치를 조사하기 전에는 **"노드 상실 실험은 `kc-lab-1`을 죽여서 하자"**고
|
||||||
|
적었다. 그 노드에 Keycloak 하나만 있다고 생각했기 때문이다. **틀렸다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-1 (server) keycloak-1, traefik, coredns, metrics-server, local-path-provisioner
|
||||||
|
kc-lab-2 (agent) keycloak-0, postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
`kc-lab-1`을 죽이면 **API 서버·DNS·인그레스가 한꺼번에 사라진다.** 노드 상실이
|
||||||
|
아니라 **컨트롤 플레인 상실**이며, `kubectl`조차 동작하지 않는다.
|
||||||
|
|
||||||
|
**깨끗한 워커 노드 상실 실험은 `kc-lab-2`를 죽이는 것이다.** 그때도 변수가
|
||||||
|
둘(keycloak-0 + postgres)이지만, 클러스터 제어는 살아 있고 관측도 계속된다.
|
||||||
|
|
||||||
|
### 3-3. 스크레이프 주기 15초
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
운영에서는 30~60초가 흔하지만 여기서는 짧게 잡았다. **노드가 죽는 순간을
|
||||||
|
두어 샘플 안에 잡아야** "무엇이 먼저 움직였나"를 말할 수 있다.
|
||||||
|
60초면 장애와 복구가 같은 샘플에 뭉개진다.
|
||||||
|
|
||||||
|
### 3-4. 타깃을 정적 목록으로 두지 않는다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: endpoints
|
||||||
|
namespaces: { names: [keycloak-lab] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켰을 때 모든 파드가
|
||||||
|
새 주소를 받는 것을 직접 확인했다(`10.42.1.22` → `10.42.1.25`).
|
||||||
|
정적 목록을 적어두면 그때마다 깨진다.
|
||||||
|
|
||||||
|
쿠버네티스 API에 물어보는 방식(service discovery)이므로 **파드가 옮겨다녀도
|
||||||
|
따라간다.** Traefik의 `trustedIPs`에 개별 IP를 적을 수 없었던 것과 같은 이유다.
|
||||||
|
|
||||||
|
### 3-5. relabel — 발견한 것을 걸러내고 이름을 붙인다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
|
||||||
|
action: keep
|
||||||
|
regex: keycloak-headless;management
|
||||||
|
- source_labels: [__meta_kubernetes_pod_name]
|
||||||
|
target_label: pod
|
||||||
|
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||||
|
target_label: node
|
||||||
|
```
|
||||||
|
|
||||||
|
service discovery는 네임스페이스의 **모든 엔드포인트**를 가져온다. 그중
|
||||||
|
필요한 것만 남기고 나머지는 버리는 것이 `keep`이다.
|
||||||
|
|
||||||
|
- 첫 규칙 — `keycloak-headless` 서비스의 `management` 포트만 남긴다.
|
||||||
|
8080(http)까지 긁으면 애플리케이션 트래픽 포트에 헛되이 요청이 간다
|
||||||
|
- 나머지 두 규칙 — **`pod`과 `node` 라벨을 붙인다.** 이것이 없으면
|
||||||
|
"어느 파드가, 어느 노드에서" 라는 질문에 답할 수 없다.
|
||||||
|
노드 상실 실험에서 결정적이다
|
||||||
|
|
||||||
|
### 3-6. Keycloak 지표는 9000 포트다
|
||||||
|
|
||||||
|
헬스체크와 같은 관리 포트다. `KC_METRICS_ENABLED=true`가 이미 StatefulSet에
|
||||||
|
설정돼 있다. **8080을 긁으면 지표가 나오지 않는다.**
|
||||||
|
|
||||||
|
### 3-7. node-exporter는 DaemonSet + 호스트 네임스페이스
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: DaemonSet
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
hostNetwork: true
|
||||||
|
hostPID: true
|
||||||
|
tolerations:
|
||||||
|
- operator: Exists
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DaemonSet** — 노드마다 정확히 하나. 죽을 노드에도 있어야 **꺼지기 직전의
|
||||||
|
마지막 샘플**이 남는다
|
||||||
|
- **`hostNetwork`/`hostPID`** — 측정 대상이 컨테이너가 아니라 **머신**이다.
|
||||||
|
컨테이너 네임스페이스 안에서 보면 자기 자신만 보인다
|
||||||
|
- **`tolerations: operator: Exists`** — 어떤 taint가 걸린 노드에도 뜬다.
|
||||||
|
관측이 빠지는 노드가 있으면 안 된다
|
||||||
|
|
||||||
|
### 3-8. Prometheus 저장소는 PVC
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
storageClassName: local-path
|
||||||
|
--storage.tsdb.retention.time=7d
|
||||||
|
```
|
||||||
|
|
||||||
|
`emptyDir`로 두면 파드가 재시작될 때 **장애 실험의 기록이 통째로 사라진다.**
|
||||||
|
사후 추적이 목적이므로 영속 저장이 필요하다.
|
||||||
|
|
||||||
|
`local-path`는 노드에 고정되므로 Prometheus도 `kc-lab-1`에 묶인다.
|
||||||
|
`nodeSelector`와 방향이 같아 문제가 되지 않는다.
|
||||||
|
|
||||||
|
보존 7일은 실험 기간보다 넉넉하면서 **볼륨이 노드를 채우는 원인이 되지 않을**
|
||||||
|
크기다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 65534
|
||||||
|
```
|
||||||
|
|
||||||
|
`prom/prometheus` 이미지는 `nobody`(65534)로 실행된다. `fsGroup`이 없으면
|
||||||
|
새로 만들어진 볼륨의 소유자가 root라 **쓰기 권한이 없어 기동에 실패한다.**
|
||||||
|
|
||||||
|
### 3-9. Grafana에도 외부 URL을 알려줘야 한다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: GF_SERVER_ROOT_URL
|
||||||
|
value: https://app2.hyeonworks.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keycloak의 `KC_HOSTNAME`과 정확히 같은 성격의 설정이다.** Grafana도
|
||||||
|
리다이렉트와 자산 경로에 절대 URL을 만든다. 이 값이 없으면 로그인 리다이렉트가
|
||||||
|
`http://<파드IP>:3000`으로 나간다.
|
||||||
|
|
||||||
|
2홉 헤더 계약에서 확인한 원리가 여기서도 그대로 적용된다 —
|
||||||
|
**프록시 뒤의 애플리케이션은 자기가 외부에서 어떤 주소로 보이는지 모른다.**
|
||||||
|
|
||||||
|
### 3-10. 데이터소스는 파일로 프로비저닝
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumeMounts:
|
||||||
|
- name: datasources
|
||||||
|
mountPath: /etc/grafana/provisioning/datasources
|
||||||
|
```
|
||||||
|
|
||||||
|
UI에서 클릭으로 추가하면 Grafana 자체 DB에만 남는다. 그 DB는 여기서
|
||||||
|
`emptyDir`이므로 **파드가 재시작되면 사라진다.** 파일로 두면 항상 같은 상태로
|
||||||
|
뜬다.
|
||||||
|
|
||||||
|
### 3-11. Grafana를 `app2`에 붙인 이유
|
||||||
|
|
||||||
|
인증서에 들어 있는 이름이 `auth` / `app1` / `app2` 셋뿐이고 `app2`가 비어
|
||||||
|
있었다. **SSO 실험에서 `app2`가 필요해지면 옮긴다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행한 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션 — 매니페스트 작성 후
|
||||||
|
git add deploy/lab/k8s/observability.yaml
|
||||||
|
git commit -m "feat: add Prometheus, node-exporter and Grafana"
|
||||||
|
git push origin feature/keycloak-multinode-cluster-jdbc-ping
|
||||||
|
|
||||||
|
# lab host
|
||||||
|
cd ~/workspace/keycloak-pattern && git pull
|
||||||
|
kubectl apply -f deploy/lab/k8s/observability.yaml
|
||||||
|
|
||||||
|
kubectl -n observability rollout status deployment/prometheus --timeout=300s
|
||||||
|
kubectl -n observability rollout status daemonset/node-exporter --timeout=180s
|
||||||
|
kubectl -n observability rollout status deployment/grafana --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 — 배포 성공과 타깃 수집은 다른 문제다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability run q --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||||||
|
curl -s "http://prometheus.observability.svc:9090/api/v1/targets?state=any" > /tmp/targets.json
|
||||||
|
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
d,_ = json.JSONDecoder().raw_decode(open('/tmp/targets.json').read())
|
||||||
|
ts = d['data']['activeTargets']
|
||||||
|
print(f\"{sum(1 for t in ts if t['health']=='up')}/{len(ts)} up\")
|
||||||
|
for t in ts:
|
||||||
|
if t['health'] != 'up': print(t['labels'], t.get('lastError'))
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 겪은 함정
|
||||||
|
|
||||||
|
### kubelet 타깃이 403 Forbidden
|
||||||
|
|
||||||
|
첫 배포에서 **7개 중 5개만 up**이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
DOWN kubelet kc-lab-1 server returned HTTP status 403 Forbidden
|
||||||
|
DOWN kubelet kc-lab-2 server returned HTTP status 403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
원인은 RBAC였다. kubelet 지표는 **API 서버의 proxy 서브리소스**를 통해
|
||||||
|
가져온다.
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/v1/nodes/<name>/proxy/metrics
|
||||||
|
─────
|
||||||
|
```
|
||||||
|
|
||||||
|
이 경로에는 `nodes`나 `nodes/metrics`가 아니라 **`nodes/proxy`** 권한이
|
||||||
|
필요하다.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- resources: [nodes, nodes/metrics, services, endpoints, pods]
|
||||||
|
+ resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||||||
|
```
|
||||||
|
|
||||||
|
**다른 잡은 전부 정상이었다.** 이런 부분 실패는 타깃 목록을 직접 확인하지
|
||||||
|
않으면 드러나지 않는다. `rollout status`는 "성공"이라고 말한다.
|
||||||
|
|
||||||
|
### `kubectl run --rm -i`의 출력에 종료 메시지가 섞인다
|
||||||
|
|
||||||
|
```
|
||||||
|
json.decoder.JSONDecodeError: Extra data: line 1 column 54973
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl run --rm`은 컨테이너 출력 뒤에 `pod "q" deleted`를 덧붙인다.
|
||||||
|
JSON 파서가 그 뒤를 만나면 실패한다.
|
||||||
|
|
||||||
|
**해결** — `raw_decode`로 앞쪽의 완전한 JSON만 읽는다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
d, _ = json.JSONDecoder().raw_decode(raw)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 실험에 쓸 지표
|
||||||
|
|
||||||
|
메트릭 이름이 **1506개** 수집된다. 그중 장애 실험에서 볼 것들이다.
|
||||||
|
|
||||||
|
### 가장 중요한 것 — `up`
|
||||||
|
|
||||||
|
```promql
|
||||||
|
up
|
||||||
|
up{job="keycloak"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus가 타깃을 긁는 데 성공했는가를 0/1로 알려주는 **합성 지표**다.
|
||||||
|
타깃이 응답하지 않으면 0이 된다.
|
||||||
|
|
||||||
|
**노드나 파드가 죽는 순간 가장 먼저 움직이는 신호**이며, 다른 모든 지표가
|
||||||
|
사라지는 것과 달리 `up`은 **0이라는 값으로 남는다.** 그래서 "언제부터 죽었나"를
|
||||||
|
사후에 알 수 있다.
|
||||||
|
|
||||||
|
### JGroups — 7800 차단 실험의 핵심
|
||||||
|
|
||||||
|
```promql
|
||||||
|
vendor_jgroups_fd_sock2_get_num_suspected_members
|
||||||
|
vendor_jgroups_merge3_get_views
|
||||||
|
vendor_jgroups_tcp_get_different_cluster_messages
|
||||||
|
```
|
||||||
|
|
||||||
|
| 지표 | 무엇을 말하는가 |
|
||||||
|
|---|---|
|
||||||
|
| `fd_sock2_..._suspected_members` | **FD_SOCK2가 의심하는 멤버 수.** 현재 두 파드 모두 `0`. 7800이 막히면 상대를 suspect 하기 시작한다 |
|
||||||
|
| `merge3_get_views` | **MERGE3가 처리한 뷰 수.** split brain 후 다시 합칠 때 움직인다 |
|
||||||
|
| `tcp_get_different_cluster_messages` | 다른 클러스터로부터 온 메시지 |
|
||||||
|
|
||||||
|
**7800 차단 실험의 가설** — `JGROUPS_PING` 테이블은 그대로 채워진 채
|
||||||
|
`suspected_members`가 0에서 1로 오르고, 클러스터 뷰가 각각 1로 쪼개진다.
|
||||||
|
|
||||||
|
### 노드 지표
|
||||||
|
|
||||||
|
```promql
|
||||||
|
node_memory_MemAvailable_bytes
|
||||||
|
node_load1
|
||||||
|
node_network_receive_bytes_total
|
||||||
|
node_filesystem_avail_bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**"머신이 죽었나 프로세스가 죽었나"** 를 가르는 데 쓴다. 파드는 사라졌는데
|
||||||
|
node-exporter가 살아 있으면 프로세스 문제이고, 둘 다 사라지면 머신 문제다.
|
||||||
|
|
||||||
|
### Keycloak 애플리케이션 지표
|
||||||
|
|
||||||
|
```promql
|
||||||
|
keycloak_session_expiration_task_seconds_count
|
||||||
|
```
|
||||||
|
|
||||||
|
`keycloak_` 접두 지표는 아직 적다. 세션 관련 지표는 **실제 로그인이 발생해야**
|
||||||
|
나타나므로, 세션 복제 실험 이후 다시 조사한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 접근
|
||||||
|
|
||||||
|
| | 주소 | 계정 |
|
||||||
|
|---|---|---|
|
||||||
|
| Grafana | `https://app2.hyeonworks.com` | `admin` / `lab-grafana-change-me` |
|
||||||
|
| Prometheus | 클러스터 내부 `prometheus.observability.svc:9090` | — |
|
||||||
|
|
||||||
|
Prometheus UI를 직접 보려면 포트포워딩한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability port-forward svc/prometheus 9090:9090
|
||||||
|
# http://localhost:9090/targets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grafana 비밀번호가 매니페스트에 평문이다.** 로드맵 11번(비밀 관리)에서
|
||||||
|
정리한다. 지금 드러내 두는 것은 의도이며, 감춰두면 잊어버린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 자원 실측
|
||||||
|
|
||||||
|
```
|
||||||
|
grafana 65Mi
|
||||||
|
prometheus 164Mi
|
||||||
|
node-exporter 8Mi × 2
|
||||||
|
────────────────────────
|
||||||
|
합계 약 245Mi
|
||||||
|
|
||||||
|
kc-lab-1 2045Mi (41%)
|
||||||
|
kc-lab-2 1131Mi (28%)
|
||||||
|
호스트 여유 3957MB
|
||||||
|
```
|
||||||
|
|
||||||
|
메모리 증설(8GB → 12GB) 전이었다면 kc-lab-1이 60%를 넘겼을 것이다.
|
||||||
|
증설이 이 항목을 가능하게 했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 다음
|
||||||
|
|
||||||
|
관측이 서 있으므로 이제 고장을 주입하면 **무엇이 먼저 움직였는지**가 기록된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
0. 세션 복제 확인 ← 로그인 세션을 만들어 두 노드에 복제되는지
|
||||||
|
1. TCP 7800 차단 ← suspected_members 와 JGROUPS_PING 대조
|
||||||
|
2. DB 상실 ← postgres 파드 정지
|
||||||
|
3. 노드 상실 ← kc-lab-2 (agent) 를 죽인다. kc-lab-1 이 아니다
|
||||||
|
```
|
||||||
|
|
||||||
|
각 실험 전후로 같은 PromQL을 실행해 대조한다.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
| 문서 | 관계 |
|
||||||
|
|---|---|
|
||||||
|
| [`keycloak-multinode-cluster.md`](keycloak-multinode-cluster.md) | 관측 대상의 구성 |
|
||||||
|
| [`session-lab-concepts.md`](session-lab-concepts.md) | Prometheus·RBAC·DaemonSet 등 개념 |
|
||||||
|
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 로드맵 10번 |
|
||||||
|
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `GF_SERVER_ROOT_URL`이 필요한 이유 |
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
|
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
|
||||||
|
|
||||||
공개 기록(`hyeonworks.com/questions`)에 등록된 KeyCloak Patterns 열린 질문
|
공개 기록에 등록된 KeyCloak Patterns 열린 질문 네 개를, 이 실험대가 실제로
|
||||||
네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과.
|
검증할 수 있는지 대조한 결과.
|
||||||
|
|
||||||
|
> **목록 경로는 [`/explore/questions`](https://hyeonworks.com/explore/questions)**
|
||||||
|
> 다. `/questions` 는 404 이고 개별 문서만 `/questions/<slug>` 로 열린다.
|
||||||
|
>
|
||||||
|
> **2026-09-04 재확인** — Playwright 로 네 문서를 전문 재독하고
|
||||||
|
> 「남은 미지수」·「다음 검증」·「제약」을 항목 단위로 대조한 결과
|
||||||
|
> **계획에 빠진 항목 9개**를 찾아 보강했다. 항목별 실험 번호 대조표는
|
||||||
|
> [`experiment-plan.md`](experiment-plan.md) B층 머리에 있다.
|
||||||
|
|
||||||
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
|
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
|
||||||
있고, 순서가 한 곳 뒤집혀 있다.**
|
있고, 순서가 한 곳 뒤집혀 있다.**
|
||||||
|
|||||||
@@ -2826,17 +2826,606 @@ SSH 공개키 두 줄이다. 공개키 자체는 비밀이 아니지만, **저
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10층. 쿠버네티스 리소스 — 이 실험대에서 실제로 쓴 것들
|
||||||
|
|
||||||
|
5층이 k3s 자체라면 여기는 그 위에 올린 리소스들이다.
|
||||||
|
|
||||||
|
### 워크로드 세 종류 — 무엇을 언제 쓰는가
|
||||||
|
|
||||||
|
| | 보장하는 것 | 이 실험대에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Deployment** | 파드 N개를 유지. 이름은 매번 바뀐다 | postgres, grafana, prometheus, echo |
|
||||||
|
| **StatefulSet** | **안정된 이름**(`-0`, `-1`)과 순서 | **keycloak** |
|
||||||
|
| **DaemonSet** | **노드마다 정확히 하나** | node-exporter, svclb |
|
||||||
|
|
||||||
|
**StatefulSet을 Keycloak에 쓴 이유** — Infinispan이 **파드 이름 + 랜덤 접미사**를
|
||||||
|
클러스터 노드 식별자로 쓴다(`keycloak-0-49501`). Deployment면 이름이
|
||||||
|
`keycloak-7d9f8b-x4k2p`처럼 매번 달라져서, 로그와 `JGROUPS_PING` 테이블을
|
||||||
|
대조하기가 어려워진다.
|
||||||
|
|
||||||
|
**`podManagementPolicy`**
|
||||||
|
|
||||||
|
| 값 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| `OrderedReady` (기본) | `-0`이 Ready가 된 뒤에야 `-1`을 만든다 |
|
||||||
|
| **`Parallel`** | **동시에 시작한다** |
|
||||||
|
|
||||||
|
이 실험대는 `Parallel`을 쓴다. 두 파드가 **동시에 클러스터 등록을 시도하는 것**이
|
||||||
|
운영에서 실제로 일어나는 상황이기 때문이다.
|
||||||
|
|
||||||
|
**DaemonSet을 node-exporter에 쓴 이유** — replica 수를 지정하지 않는다.
|
||||||
|
노드가 늘면 자동으로 늘고, 줄면 준다. **죽을 노드에도 반드시 있어야**
|
||||||
|
꺼지기 직전의 마지막 샘플이 남는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get deploy,sts,ds -A
|
||||||
|
```
|
||||||
|
|
||||||
|
### 저장소 — PVC · PV · StorageClass
|
||||||
|
|
||||||
|
```
|
||||||
|
PersistentVolumeClaim (PVC) "5Gi 짜리 읽기쓰기 볼륨을 주세요" ← 요청
|
||||||
|
│ storageClassName: local-path
|
||||||
|
▼
|
||||||
|
StorageClass 어떻게 만들지 아는 프로비저너
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PersistentVolume (PV) 실제로 만들어진 볼륨 ← 결과
|
||||||
|
```
|
||||||
|
|
||||||
|
**PVC는 요청서, PV는 실물이다.** 파드는 PVC 이름만 알면 되고, 그 뒤가
|
||||||
|
로컬 디스크인지 NFS인지 클라우드 블록 스토리지인지 몰라도 된다.
|
||||||
|
|
||||||
|
**`accessModes`**
|
||||||
|
|
||||||
|
| 값 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| **`ReadWriteOnce` (RWO)** | **한 노드에서만** 읽기/쓰기 |
|
||||||
|
| `ReadOnlyMany` | 여러 노드에서 읽기만 |
|
||||||
|
| `ReadWriteMany` | 여러 노드에서 읽기/쓰기 (NFS 등) |
|
||||||
|
|
||||||
|
**RWO가 `strategy: Recreate`를 강제한다.** 기본값 `RollingUpdate`는 새 파드를
|
||||||
|
띄운 뒤 옛 파드를 내리는데, RWO 볼륨은 **두 파드가 동시에 마운트할 수 없어서**
|
||||||
|
새 파드가 영원히 Pending에 머문다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
type: Recreate # 옛 파드를 먼저 내리고 새 파드를 띄운다
|
||||||
|
```
|
||||||
|
|
||||||
|
**k3s의 `local-path` 프로비저너 — 볼륨이 노드에 못박힌다**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"nodeAffinity": {
|
||||||
|
"required": { "nodeSelectorTerms": [{
|
||||||
|
"matchExpressions": [{ "key": "kubernetes.io/hostname", "values": ["kc-lab-2"] }]
|
||||||
|
}]}
|
||||||
|
}
|
||||||
|
경로: /var/lib/rancher/k3s/storage/pvc-<uuid>_<ns>_<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**그 노드의 로컬 디스크에 디렉터리를 만드는 것이 전부**다. 따라서
|
||||||
|
**PVC를 쓰는 파드는 그 노드를 벗어날 수 없다.**
|
||||||
|
|
||||||
|
| 결과 | |
|
||||||
|
|---|---|
|
||||||
|
| 노드가 죽으면 | **파드가 다른 노드로 재배치되지 못한다** |
|
||||||
|
| 실험 관점 | **결함이 아니라 조건이다.** "DB가 있는 노드가 죽으면"이 의미를 갖는다 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pvc -A
|
||||||
|
kubectl get pv
|
||||||
|
kubectl get pv <name> -o jsonpath='{.spec.nodeAffinity}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secret — 감춰지지 않는다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: Secret
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_PASSWORD: lab-postgres-change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
`stringData`는 평문으로 쓰고 쿠버네티스가 base64로 인코딩해 저장한다.
|
||||||
|
`data`는 직접 base64로 넣는다.
|
||||||
|
|
||||||
|
**base64는 암호화가 아니라 인코딩이다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄로 읽힌다. etcd에도 그대로 들어 있다.
|
||||||
|
|
||||||
|
| 그래도 Secret을 쓰는 이유 | |
|
||||||
|
|---|---|
|
||||||
|
| RBAC로 접근을 나눌 수 있다 | ConfigMap과 별도로 권한 관리 |
|
||||||
|
| 로그·`describe`에 값이 안 찍힌다 | 사고로 노출될 확률이 준다 |
|
||||||
|
| 볼륨·env 주입 방식이 표준화된다 | |
|
||||||
|
|
||||||
|
**진짜 보호는 별도 계층이다** — SealedSecret, 외부 KMS, 또는 클라우드
|
||||||
|
시크릿 매니저. 로드맵 11번의 주제다.
|
||||||
|
|
||||||
|
### RBAC — ServiceAccount · ClusterRole · Binding
|
||||||
|
|
||||||
|
Prometheus가 쿠버네티스 API에 물어서 타깃을 찾으려면 **읽기 권한**이 필요하다.
|
||||||
|
|
||||||
|
```
|
||||||
|
ServiceAccount 파드가 쓰는 신원 (누구인가)
|
||||||
|
│
|
||||||
|
ClusterRoleBinding 신원과 권한을 잇는다
|
||||||
|
│
|
||||||
|
ClusterRole 무엇을 할 수 있는가 (리소스 × 동사)
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Role`과 `ClusterRole`의 차이** — `Role`은 한 네임스페이스 안에서만,
|
||||||
|
`ClusterRole`은 클러스터 전체에서 유효하다. 노드는 네임스페이스에 속하지
|
||||||
|
않으므로 **노드를 읽으려면 반드시 `ClusterRole`**이다.
|
||||||
|
|
||||||
|
**서브리소스가 따로 있다 — 실제로 걸린 함정**
|
||||||
|
|
||||||
|
`nodes`, `nodes/metrics`, `nodes/proxy`는 **서로 다른 권한**이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/v1/nodes/<name>/proxy/metrics
|
||||||
|
─────
|
||||||
|
이 경로에는 nodes/proxy 가 필요
|
||||||
|
```
|
||||||
|
|
||||||
|
`nodes/proxy`를 빠뜨렸을 때 kubelet 타깃만 **403 Forbidden**으로 실패하고
|
||||||
|
나머지 잡은 전부 정상이었다. **부분 실패라 `rollout status`는 성공이라고
|
||||||
|
말한다.** 타깃 목록을 직접 봐야 드러난다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
|
||||||
|
kubectl describe clusterrole prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
### 배치 제어 — nodeSelector · 라벨 · taint
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
**호스트 이름 대신 역할 라벨을 쓴다.** `kubernetes.io/hostname: kc-lab-1`로
|
||||||
|
못박으면 노드 이름이 바뀔 때 깨지고, **왜 거기 두는지가 드러나지 않는다.**
|
||||||
|
|
||||||
|
k3s는 server 노드에 `node-role.kubernetes.io/control-plane=true`를 붙인다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes --show-labels
|
||||||
|
kubectl get nodes -l node-role.kubernetes.io/control-plane=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**taint와 toleration**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **taint** | 노드에 붙는 "여기 오지 마" 표시 |
|
||||||
|
| **toleration** | 파드가 갖는 "그래도 갈 수 있음" 면제권 |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tolerations:
|
||||||
|
- operator: Exists # 어떤 taint 든 무시한다
|
||||||
|
```
|
||||||
|
|
||||||
|
node-exporter에 이걸 주는 이유는 **관측이 빠지는 노드가 있으면 안 되기**
|
||||||
|
때문이다. taint가 걸린 노드에서도 떠야 한다.
|
||||||
|
|
||||||
|
**배치를 정하는 세 수단의 차이**
|
||||||
|
|
||||||
|
| 수단 | 성격 |
|
||||||
|
|---|---|
|
||||||
|
| `nodeSelector` | **반드시** 그 라벨의 노드에 |
|
||||||
|
| `topologySpreadConstraints` | **골고루** 퍼뜨린다 |
|
||||||
|
| taint / toleration | 노드가 **거부**하고 파드가 **면제**받는다 |
|
||||||
|
|
||||||
|
### k3s server와 agent — 죽였을 때가 다르다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes -o custom-columns=\
|
||||||
|
'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane'
|
||||||
|
```
|
||||||
|
|
||||||
|
| | kc-lab-1 (**server**) | kc-lab-2 (**agent**) |
|
||||||
|
|---|---|---|
|
||||||
|
| 실행 | API 서버 · 스케줄러 · etcd(SQLite) | kubelet · containerd |
|
||||||
|
| 이 실험대에서 | keycloak-1 · traefik · **coredns** · metrics-server · local-path-provisioner | keycloak-0 · postgres |
|
||||||
|
| 죽이면 | **`kubectl`이 안 된다. DNS·인그레스도 사라진다** | 클러스터 제어는 살아 있다 |
|
||||||
|
|
||||||
|
**노드 상실 실험은 agent를 죽이는 것이다.** server를 죽이는 것은 노드 상실이
|
||||||
|
아니라 **컨트롤 플레인 상실**이며 성격이 완전히 다르다.
|
||||||
|
|
||||||
|
이 사실을 모르고 "keycloak 하나만 있는 노드를 죽이자"고 계획했다가
|
||||||
|
실제 배치를 조회한 뒤 정정했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11층. Keycloak 클러스터링 내부 — Infinispan과 JGroups
|
||||||
|
|
||||||
|
### 두 층으로 되어 있다
|
||||||
|
|
||||||
|
```
|
||||||
|
Infinispan 분산 캐시. "세션을 어디에 두고 어떻게 복제할까"
|
||||||
|
│
|
||||||
|
JGroups 그룹 통신. "누가 멤버이고 어떻게 메시지를 주고받을까"
|
||||||
|
│
|
||||||
|
TCP 7800 실제 소켓
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak은 Infinispan을 쓰고, Infinispan은 JGroups 위에서 돈다.
|
||||||
|
로그의 `org.infinispan.CLUSTER`와 `vendor_jgroups_*` 지표가 각각 이 두 층이다.
|
||||||
|
|
||||||
|
### 디스커버리와 트랜스포트는 다른 경로다
|
||||||
|
|
||||||
|
**이것이 이 실험대를 2노드로 만든 이유다.**
|
||||||
|
|
||||||
|
| 단계 | 경로 | 끊기면 |
|
||||||
|
|---|---|---|
|
||||||
|
| **디스커버리** — 서로를 찾는다 | PostgreSQL `JGROUPS_PING` 테이블 | 상대의 존재를 모른다 |
|
||||||
|
| **트랜스포트** — 실제로 대화한다 | **TCP 7800** | **DB엔 등록되는데 클러스터가 안 붙는다** |
|
||||||
|
|
||||||
|
`JGROUPS_PING` 한 테이블에 두 메커니즘이 다 보인다.
|
||||||
|
|
||||||
|
```
|
||||||
|
name | cluster_name | ip | coord
|
||||||
|
------------------+--------------+-----------------+-------
|
||||||
|
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
|
||||||
|
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
|
||||||
|
───────────────────────────── ──── ─
|
||||||
|
디스커버리 결과 트랜스포트 경로 코디네이터
|
||||||
|
```
|
||||||
|
|
||||||
|
전체 스키마는 `address / name / cluster_name / ip / coord / last_update /
|
||||||
|
coordinated_by`이고 기본키는 `address`다.
|
||||||
|
|
||||||
|
> 오래된 자료에는 `own_addr`, `ping_data` 같은 컬럼명이 나오지만 Keycloak 26의
|
||||||
|
> 실제 스키마는 위와 같다. 쿼리 전에 `\d jgroups_ping`으로 확인한다.
|
||||||
|
|
||||||
|
**`jdbc-ping`을 쓰는 이유** — 예전에는 UDP 멀티캐스트로 서로를 찾았다.
|
||||||
|
쿠버네티스나 클라우드에서는 멀티캐스트가 막혀 있는 경우가 많아,
|
||||||
|
**이미 있는 데이터베이스를 게시판처럼 쓰는** 방식으로 바뀌었다.
|
||||||
|
Keycloak 26의 기본값이다.
|
||||||
|
|
||||||
|
### 코디네이터
|
||||||
|
|
||||||
|
`coord = t` 인 노드가 **코디네이터**다. 뷰 변경을 확정하고 리밸런싱을
|
||||||
|
주도한다. 특별한 권한이 아니라 **역할**이며, 그 노드가 사라지면 남은 멤버가
|
||||||
|
인계받는다.
|
||||||
|
|
||||||
|
실험대를 전원 종료했다 켰을 때 코디네이터가 `keycloak-1` → `keycloak-0`으로
|
||||||
|
바뀌는 것을 관찰했다. **먼저 뜬 쪽이 맡는다.**
|
||||||
|
|
||||||
|
### 클러스터 뷰
|
||||||
|
|
||||||
|
```
|
||||||
|
ISPN000094: Received new cluster view for channel ISPN:
|
||||||
|
[keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501]
|
||||||
|
─────────────────────────── ─ ─ ────────────────────────────────────
|
||||||
|
뷰를 만든 코디네이터 뷰 ID 멤버 수 멤버 목록
|
||||||
|
```
|
||||||
|
|
||||||
|
**뷰(view)는 "지금 이 순간의 멤버 명단"** 이다. 멤버가 들어오거나 나가면
|
||||||
|
새 뷰가 발행되고 뷰 ID가 올라간다.
|
||||||
|
|
||||||
|
| 로그 코드 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `ISPN000094` | 새 클러스터 뷰를 받았다 |
|
||||||
|
| `ISPN000079` | 자기 주소와 물리 주소(7800) |
|
||||||
|
| `ISPN100000` | 노드가 합류했다 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 JGroups 프로토콜 — 지표 이름에 그대로 나온다
|
||||||
|
|
||||||
|
| 프로토콜 | 하는 일 | 관련 지표 |
|
||||||
|
|---|---|---|
|
||||||
|
| **GMS** (Group Membership Service) | 멤버십 관리, 뷰 발행 | `vendor_jgroups_gms_*` |
|
||||||
|
| **FD_SOCK2** (Failure Detection) | **TCP 소켓으로 상대 생존 감시** | `..._get_num_suspected_members` |
|
||||||
|
| **MERGE3** | **split brain 후 다시 합치기** | `..._merge3_get_views` |
|
||||||
|
| **NAKACK2** | 신뢰성 있는 메시지 전달, 재전송 | `..._nakack2_*` |
|
||||||
|
| **TCP** | 트랜스포트 | `..._tcp_*` |
|
||||||
|
|
||||||
|
**7800을 막으면 FD_SOCK2가 먼저 반응한다.** 소켓 연결이 끊기면 상대를
|
||||||
|
suspect 하고, GMS가 그 멤버를 뷰에서 제외한다. 각자 자기만 있는 뷰가 되면
|
||||||
|
**split brain**이고, 통신이 복구되면 MERGE3가 합친다.
|
||||||
|
|
||||||
|
### 세션은 어디에 있는가 — 두 곳이되 역할이 다르다
|
||||||
|
|
||||||
|
Keycloak 26의 기본값 `persistent-user-sessions`에서는
|
||||||
|
|
||||||
|
| 저장소 | 역할 | 노드 간 공유 |
|
||||||
|
|---|---|---|
|
||||||
|
| **PostgreSQL** | **진실의 원천.** 재시작에도 살아남는다 | **여기서만 일어난다** |
|
||||||
|
| **Infinispan `sessions`** | **자기 노드가 로그인시킨 세션만** 담는 룩어사이드 캐시 | **일어나지 않는다** |
|
||||||
|
|
||||||
|
> **처음에 이 표에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고 썼는데
|
||||||
|
> 틀렸다.** 실험 0에서 측정해보니 세션 엔트리는 노드 사이를 건너가지 않는다.
|
||||||
|
> 두 노드가 같은 답을 하는 이유는 복제가 아니라 같은 DB를 보기 때문이고,
|
||||||
|
> 반대편 노드가 실제로 날리는 `SELECT ... FROM OFFLINE_USER_SESSION` 을
|
||||||
|
> PostgreSQL 로그에서 직접 잡았다.
|
||||||
|
> → [`docs/experiment-00-session-replication.md`](experiment-00-session-replication.md)
|
||||||
|
|
||||||
|
`--features-disabled=persistent-user-sessions`로 끄면 Infinispan만 남는
|
||||||
|
**volatile** 모드가 되고, 그때는 캐시가 곧 진실의 원천이므로 **복제가
|
||||||
|
반드시 일어나야 한다.** 이 둘의 차이가 로드맵 2번의 주제다.
|
||||||
|
|
||||||
|
### 세션 쓰기 트랜잭션의 세 가지 설계 결정
|
||||||
|
|
||||||
|
PostgreSQL 문장 로깅으로 잡은 갱신 트랜잭션 하나에 다 들어 있다.
|
||||||
|
|
||||||
|
| 보이는 것 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `update ... where ... and VERSION=$5` | **낙관적 락.** 읽을 때의 버전과 같을 때만 쓴다 |
|
||||||
|
| `for no key update ... skip locked` | 잠긴 행을 **기다리지 않고 건너뛴다.** 대기 대신 재시도 |
|
||||||
|
| **`SET LOCAL synchronous_commit TO OFF`** | **WAL 플러시를 기다리지 않고 커밋한다** |
|
||||||
|
|
||||||
|
마지막 것이 특히 중요하다 — **DB가 강제 종료되면 직전 수백 밀리초의 세션
|
||||||
|
갱신이 사라질 수 있다.** 버그가 아니라 의도된 트레이드오프다.
|
||||||
|
`LAST_SESSION_REFRESH` 갱신은 매우 잦고, 잃어도 사용자가 다시 갱신하면 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12층. 관측성 — Prometheus의 구조
|
||||||
|
|
||||||
|
### 세 부분으로 되어 있다
|
||||||
|
|
||||||
|
```
|
||||||
|
수집(scrape) ──▶ 저장(TSDB) ──▶ 질의(PromQL)
|
||||||
|
15초마다 로컬 디스크 Grafana 또는 API
|
||||||
|
HTTP GET /metrics 시계열
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prometheus는 pull 방식이다.** 대상이 보내주는 것이 아니라 Prometheus가
|
||||||
|
주기적으로 `/metrics`를 긁어간다.
|
||||||
|
|
||||||
|
| 결과 | |
|
||||||
|
|---|---|
|
||||||
|
| 대상이 죽으면 | 긁기가 실패하고 **`up`이 0이 된다** — 죽은 사실 자체가 데이터가 된다 |
|
||||||
|
| 방화벽 방향 | Prometheus → 대상. 대상이 Prometheus 주소를 알 필요가 없다 |
|
||||||
|
| 짧은 작업 | 긁히기 전에 끝나면 잡히지 않는다 (Pushgateway가 필요한 경우) |
|
||||||
|
|
||||||
|
### exporter 패턴
|
||||||
|
|
||||||
|
애플리케이션이 Prometheus 형식을 모를 때, **번역기**를 옆에 둔다.
|
||||||
|
|
||||||
|
| exporter | 무엇을 노출하는가 |
|
||||||
|
|---|---|
|
||||||
|
| **node-exporter** | 머신 — CPU, 메모리, 디스크, 네트워크 |
|
||||||
|
| kube-state-metrics | 쿠버네티스 오브젝트 상태 |
|
||||||
|
| postgres-exporter | PostgreSQL 내부 통계 |
|
||||||
|
|
||||||
|
**Keycloak과 Traefik은 exporter가 필요 없다.** 자체적으로 Prometheus 형식
|
||||||
|
엔드포인트를 제공한다(`KC_METRICS_ENABLED=true`).
|
||||||
|
|
||||||
|
### 서비스 디스커버리 — 타깃을 적어두지 않는다
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: endpoints
|
||||||
|
namespaces: { names: [keycloak-lab] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켜니 모든 파드가
|
||||||
|
새 주소를 받았다(`10.42.1.22` → `10.42.1.25`). 정적 목록은 그때마다 깨진다.
|
||||||
|
|
||||||
|
`role`에 따라 무엇을 찾을지가 달라진다.
|
||||||
|
|
||||||
|
| role | 찾는 것 |
|
||||||
|
|---|---|
|
||||||
|
| `endpoints` | 서비스 뒤의 실제 파드들 ← 애플리케이션 지표 |
|
||||||
|
| `node` | 노드 |
|
||||||
|
| `pod` | 파드 직접 |
|
||||||
|
| `service` | 서비스 |
|
||||||
|
|
||||||
|
### relabel — 걸러내고 이름을 붙인다
|
||||||
|
|
||||||
|
디스커버리는 **전부 다** 가져온다. 그중 필요한 것만 남기는 것이 relabel이다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
|
||||||
|
action: keep
|
||||||
|
regex: keycloak-headless;management
|
||||||
|
- source_labels: [__meta_kubernetes_pod_name]
|
||||||
|
target_label: pod
|
||||||
|
```
|
||||||
|
|
||||||
|
| `action` | 하는 일 |
|
||||||
|
|---|---|
|
||||||
|
| `keep` | regex에 맞는 것만 남긴다 |
|
||||||
|
| `drop` | 맞는 것을 버린다 |
|
||||||
|
| `replace` (기본) | 라벨 값을 만든다 |
|
||||||
|
| `labelmap` | 메타 라벨을 일반 라벨로 복사 |
|
||||||
|
|
||||||
|
**`__`로 시작하는 라벨은 내부용**이며 저장되지 않는다. `__meta_*`는
|
||||||
|
디스커버리가 붙여준 정보이고, 필요하면 `target_label`로 옮겨야 남는다.
|
||||||
|
|
||||||
|
**`pod`과 `node` 라벨을 붙이는 것이 실험에서 결정적이다.** 없으면
|
||||||
|
"어느 파드가, 어느 노드에서"에 답할 수 없다.
|
||||||
|
|
||||||
|
### 메트릭 타입
|
||||||
|
|
||||||
|
| 타입 | 성질 | 예 |
|
||||||
|
|---|---|---|
|
||||||
|
| **counter** | **누적. 줄지 않는다** (재시작 시 0으로) | `..._requests_total` |
|
||||||
|
| **gauge** | 오르내린다 | `node_memory_MemAvailable_bytes` |
|
||||||
|
| **histogram** | 구간별 분포 + 합계 + 개수 | `..._seconds_bucket/_sum/_count` |
|
||||||
|
| summary | 분위수를 클라이언트가 계산 | |
|
||||||
|
|
||||||
|
**counter는 그대로 보면 의미가 없다.** 변화율을 봐야 한다.
|
||||||
|
|
||||||
|
```promql
|
||||||
|
rate(http_requests_total[5m])
|
||||||
|
```
|
||||||
|
|
||||||
|
**histogram은 세 지표가 한 벌**이다. `_bucket`으로 분위수를 계산한다.
|
||||||
|
|
||||||
|
```promql
|
||||||
|
histogram_quantile(0.95, rate(keycloak_session_expiration_task_seconds_bucket[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `up` — 가장 중요한 합성 지표
|
||||||
|
|
||||||
|
```promql
|
||||||
|
up
|
||||||
|
up{job="keycloak"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus가 **직접 만드는** 지표다. 긁기에 성공하면 1, 실패하면 0.
|
||||||
|
|
||||||
|
**장애 실험에서 이것이 핵심인 이유** — 다른 지표는 대상이 죽으면 **사라진다.**
|
||||||
|
사라진 데이터로는 "언제부터 죽었나"를 알 수 없다. `up`은 **0이라는 값으로
|
||||||
|
남기 때문에** 사후에 시각을 특정할 수 있다.
|
||||||
|
|
||||||
|
```promql
|
||||||
|
up == 0 # 지금 죽은 타깃
|
||||||
|
changes(up[1h]) # 1시간 동안 몇 번 오르내렸나
|
||||||
|
min_over_time(up[10m]) # 10분 중 한 번이라도 죽었나
|
||||||
|
```
|
||||||
|
|
||||||
|
### TSDB와 보존 기간
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
--storage.tsdb.path=/prometheus
|
||||||
|
--storage.tsdb.retention.time=7d
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 디스크에 시계열로 저장한다. **보존 기간이 지나면 삭제**되므로 볼륨이
|
||||||
|
무한히 커지지 않는다.
|
||||||
|
|
||||||
|
`emptyDir`에 두면 파드 재시작 시 **실험 기록이 통째로 사라진다.**
|
||||||
|
사후 추적이 목적이면 PVC여야 한다.
|
||||||
|
|
||||||
|
### 관측 시스템의 장애 도메인
|
||||||
|
|
||||||
|
**관측 시스템은 관측 대상과 같이 죽으면 안 된다.** 죽는 순간을 기록해야
|
||||||
|
하는데 같이 죽으면 기록이 없다.
|
||||||
|
|
||||||
|
노드가 둘뿐인 실험대에서는 완전히 피할 수 없으므로 **규칙으로 정한다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-1 (server) 관측 스택을 둔다. 죽이지 않는다
|
||||||
|
kc-lab-2 (agent) 장애 주입 대상
|
||||||
|
```
|
||||||
|
|
||||||
|
`nodeSelector`로 못박아 실험이 재현 가능하게 만든다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13층. 가상화 운영 — 실행 중 바꾸는 것들
|
||||||
|
|
||||||
|
### VM 메모리 재배분 — 게스트를 다시 만들지 않는다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh setmaxmem kc-lab-1 5120M --config
|
||||||
|
virsh setmem kc-lab-1 5120M --config
|
||||||
|
```
|
||||||
|
|
||||||
|
| 명령 | 바꾸는 것 |
|
||||||
|
|---|---|
|
||||||
|
| `setmaxmem` | **상한**. 부팅 시 게스트가 보는 총량 |
|
||||||
|
| `setmem` | **현재 할당**. 상한 이하여야 한다 |
|
||||||
|
|
||||||
|
**순서가 중요하다.** 현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem`이
|
||||||
|
먼저다.
|
||||||
|
|
||||||
|
| 플래그 | 적용 범위 |
|
||||||
|
|---|---|
|
||||||
|
| `--config` | 영구 정의. **다음 부팅부터** |
|
||||||
|
| `--live` | 실행 중인 도메인에 즉시 |
|
||||||
|
| 둘 다 | 지금과 앞으로 |
|
||||||
|
|
||||||
|
`setmaxmem --live`는 대개 거부된다 — 게스트가 부팅 시 메모리 맵을 정하기
|
||||||
|
때문이다. **상한을 바꾸려면 게스트를 껐다 켜야 한다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh dominfo kc-lab-1 | grep -i memory
|
||||||
|
ssh kc-lab-1 free -m # 게스트가 실제로 인식한 값
|
||||||
|
```
|
||||||
|
|
||||||
|
호스트에서 8GB→12GB로 물리 증설한 뒤 이 방법으로 재배분했다.
|
||||||
|
**게스트 재생성이나 디스크 조작은 전혀 필요 없었다.**
|
||||||
|
|
||||||
|
### 안전한 종료 순서
|
||||||
|
|
||||||
|
전원을 내리기 전에 **위에서부터** 정리한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 애플리케이션 — 클러스터에서 정상 탈퇴
|
||||||
|
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0
|
||||||
|
kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s
|
||||||
|
|
||||||
|
# 2. 데이터베이스 — 마지막에, 충분한 시간을 주고
|
||||||
|
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s
|
||||||
|
|
||||||
|
# 3. 게스트 — ACPI 정상 종료
|
||||||
|
virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2
|
||||||
|
|
||||||
|
# 4. 호스트
|
||||||
|
sudo systemctl poweroff
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 순서가 중요한가** — `virsh shutdown`은 게스트 systemd가 k3s를 멈추고,
|
||||||
|
k3s가 컨테이너에 SIGTERM을 보낸다. 유예 시간이 짧으면 **PostgreSQL이
|
||||||
|
강제 종료되어 다음 기동에 crash recovery가 돈다.** 미리 내려두면 그 위험이
|
||||||
|
없다.
|
||||||
|
|
||||||
|
**clean shutdown 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`postmaster.pid`가 남아 있지 않아야 정상**이다. 남아 있으면 비정상 종료였고
|
||||||
|
다음 기동에 복구 절차가 실행된다.
|
||||||
|
|
||||||
|
### 복구 순서 — 종료의 역순
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh start kc-lab-1 && virsh start kc-lab-2
|
||||||
|
kubectl get nodes # Ready 2개 대기
|
||||||
|
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
kubectl -n keycloak-lab rollout status deployment/postgres
|
||||||
|
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**PostgreSQL이 먼저다.** Keycloak이 DB 없이 뜨면 기동에 실패한다.
|
||||||
|
|
||||||
|
**스케일을 0으로 내려두면 자동으로 복구되지 않는다.** 명시적으로 올려야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 아직 기록하지 않은 개념
|
## 아직 기록하지 않은 개념
|
||||||
|
|
||||||
실험 설계 단계에서 아래 항목을 이 문서에 추가한다.
|
실험을 진행하면서 이 문서에 추가한다.
|
||||||
|
|
||||||
- Infinispan, `DIST_SYNC`, `numOwners`, 캐시별 설정
|
- `persistent-user-sessions` / `volatile-user-sessions` 의 실제 차이 (로드맵 2번)
|
||||||
- JGroups, `JDBC_PING`, 디스커버리와 트랜스포트의 분리, TCP 7800
|
- refresh token rotation·revoke·max reuse 와 동시 갱신 경쟁 (로드맵 5번)
|
||||||
- `persistent-user-sessions` / `volatile-user-sessions`
|
|
||||||
- 원격 Infinispan(Hot Rod)과 multi-site
|
|
||||||
- refresh token rotation, revoke, max reuse, 동시 갱신 경쟁
|
|
||||||
- SSO 세션 vs 애플리케이션 세션, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID`
|
- SSO 세션 vs 애플리케이션 세션, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID`
|
||||||
- 백채널 로그아웃과 `sid` 역인덱스
|
- 백채널 로그아웃과 `sid` 역인덱스
|
||||||
- 쿠키 `Secure` / `SameSite` / `HttpOnly`
|
|
||||||
- Redis 영속화(RDB/AOF)와 세션 복구
|
- Redis 영속화(RDB/AOF)와 세션 복구
|
||||||
- `tc netem`, OOM killer와 `oom_score`, fsync와 페이지 캐시
|
- Spring Session / `OAuth2AuthorizedClientService` 의 저장 구조
|
||||||
|
- `tc netem` 지연 주입
|
||||||
|
- OOM killer 와 `oom_score`
|
||||||
|
- fsync 와 페이지 캐시, EBS IOPS
|
||||||
|
|
||||||
|
### 이번에 채운 것 (2026-09-04)
|
||||||
|
|
||||||
|
10~13층으로 기록 완료 — StatefulSet·DaemonSet, PVC/PV/StorageClass,
|
||||||
|
Secret, RBAC 와 서브리소스, nodeSelector·taint, k3s server/agent 차이,
|
||||||
|
Infinispan·JGroups(디스커버리 vs 트랜스포트, GMS/FD_SOCK2/MERGE3),
|
||||||
|
Prometheus(pull·SD·relabel·메트릭 타입·`up`·TSDB), VM 메모리 재배분,
|
||||||
|
안전한 종료·복구 순서.
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# 이 실험을 이해하기 위한 선수 지식
|
||||||
|
|
||||||
|
실험 결과를 먼저 들이밀었더니 맥락이 사라졌다. 이 문서는 **왜 이런 걸
|
||||||
|
측정하고 있는지**를 바닥부터 세운다.
|
||||||
|
|
||||||
|
읽는 순서가 곧 의존 관계다. 아는 절은 건너뛰어도 되지만, 3장까지는
|
||||||
|
"세션"이라는 말의 뜻이 계속 바뀌므로 훑고 가는 편이 낫다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 출발점 — 당신이 원래 물은 것
|
||||||
|
|
||||||
|
> Keycloak이 여러 개일 경우, 세션 저장소를 Redis나 별도 저장소로 쓸 경우,
|
||||||
|
> Redis와 DB에 분리해서 세션과 토큰을 관리할 때 어떻게 달라지는지.
|
||||||
|
> SSO를 추가하면 어떻게 달라지는지. Redis 또는 DB가 죽으면 어떻게 복구하는지.
|
||||||
|
|
||||||
|
이 질문에 답하려면 **"세션이 어디에 있는가"** 를 정확히 알아야 한다.
|
||||||
|
지금 하고 있는 실험은 전부 그 한 문장을 쪼갠 것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. HTTP는 기억이 없다
|
||||||
|
|
||||||
|
모든 것의 출발점.
|
||||||
|
|
||||||
|
```
|
||||||
|
요청 1: GET /login → 서버
|
||||||
|
요청 2: GET /mypage → 서버 ← 서버는 요청 1을 기억하지 못한다
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 요청은 **하나하나가 완전히 독립적**이다. 서버 입장에서 두 번째 요청은
|
||||||
|
생판 처음 보는 사람이 보낸 것과 구별되지 않는다.
|
||||||
|
|
||||||
|
그래서 "로그인했다"는 사실을 **어딘가 저장**해야 한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 서버
|
||||||
|
┌──────────────┐ ┌────────────────────────┐
|
||||||
|
│ 쿠키 │ │ 세션 저장소 │
|
||||||
|
│ SESSIONID= │ ──── 매 요청 ────▶ │ abc123 → { │
|
||||||
|
│ abc123 │ 이 값만 보냄 │ user: "홍길동", │
|
||||||
|
└──────────────┘ │ 로그인시각: ... │
|
||||||
|
│ } │
|
||||||
|
작은 표만 들고 다닌다 └────────────────────────┘
|
||||||
|
실제 내용은 여기 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
| 용어 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **쿠키** | 브라우저가 들고 다니는 **작은 표(번호표)**. 보통 세션 ID만 들어 있다 |
|
||||||
|
| **세션** | 서버가 그 번호에 대해 기억하는 **실제 내용** |
|
||||||
|
|
||||||
|
**서버가 1대면 여기서 이야기가 끝난다.** 문제는 2대부터다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서버가 2대가 되는 순간 — 이 실험의 진짜 출발점
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 요청 ──▶ 서버 A A의 메모리에 "abc123 = 홍길동" 기록
|
||||||
|
다음 요청 ──▶ 서버 B B: "abc123? 그런 거 모르는데" → 로그아웃 화면
|
||||||
|
```
|
||||||
|
|
||||||
|
**이게 전부다.** 분산 세션이라는 주제 전체가 이 한 장면에서 나온다.
|
||||||
|
|
||||||
|
푸는 방법은 셋뿐이다.
|
||||||
|
|
||||||
|
| 방법 | 어떻게 | 대가 |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. 고정 배정** (sticky session) | 같은 사람은 항상 같은 서버로 보낸다 | **그 서버가 죽으면 그 사람 세션은 사라진다.** 부하도 안 고르게 퍼진다 |
|
||||||
|
| **2. 복제** | 서버끼리 메모리 내용을 서로 보낸다 | 서버가 N대면 트래픽이 N² 로 는다. 어긋남(불일치)이 생긴다 |
|
||||||
|
| **3. 공유 저장소** | 세션을 바깥(DB·Redis)에 두고 모두가 본다 | **그게 죽으면 전체가 멈춘다.** 매 요청마다 네트워크 왕복 |
|
||||||
|
|
||||||
|
> **당신이 원래 물은 "Redis나 별도 저장소를 쓰면"이 바로 3번**이다.
|
||||||
|
> 그리고 "Redis나 DB가 죽으면 어떻게 복구하나"는 3번의 대가를 묻는 것이다.
|
||||||
|
|
||||||
|
**Keycloak도 예외가 아니다.** Keycloak을 2대 띄우면 정확히 이 문제가 생긴다.
|
||||||
|
Keycloak이 이걸 어떻게 풀었는지가 실험 0의 주제다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Keycloak은 무엇이고, 왜 세션을 갖는가
|
||||||
|
|
||||||
|
### 3-1. 하는 일
|
||||||
|
|
||||||
|
Keycloak은 **로그인을 대신 해주는 서버**다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자] [내 앱] [Keycloak]
|
||||||
|
│ │ │
|
||||||
|
│─ 접속 ──────▶│ │
|
||||||
|
│◀─ "Keycloak 가서 로그인하고 와" ────│
|
||||||
|
│──────────────────── 로그인 ───────▶│
|
||||||
|
│◀─────────────── 토큰 발급 ─────────│
|
||||||
|
│─ 토큰 들고 ──▶│ │
|
||||||
|
│ │─ 이 토큰 유효해? ──▶│
|
||||||
|
```
|
||||||
|
|
||||||
|
내 앱은 비밀번호를 저장하지도, 검증하지도 않는다. 그 일을 Keycloak이 한다.
|
||||||
|
|
||||||
|
### 3-2. 그래서 **세션이 두 겹**이 된다
|
||||||
|
|
||||||
|
여기가 헷갈리는 지점이다. "세션"이라는 말이 두 가지를 가리킨다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Keycloak 의 SSO 세션 │
|
||||||
|
│ "이 브라우저는 홍길동으로 로그인되어 있다" │
|
||||||
|
│ 쿠키 이름: KEYCLOAK_IDENTITY │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ 앱1 의 세션 │ │ 앱2 의 세션 │
|
||||||
|
│ (또는 토큰) │ │ (또는 토큰) │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| | 누가 갖는가 | 사라지면 |
|
||||||
|
|---|---|---|
|
||||||
|
| **SSO 세션** | **Keycloak** | 모든 앱에서 다시 로그인해야 한다 |
|
||||||
|
| 앱 세션 | 각 애플리케이션 | 그 앱만 다시 들어가면 된다 |
|
||||||
|
|
||||||
|
**SSO가 되는 원리가 이것이다.** 앱1에서 로그인하면 Keycloak에 SSO 세션이
|
||||||
|
생긴다. 앱2로 가면 Keycloak이 "이 브라우저 이미 로그인했네" 하고 **로그인
|
||||||
|
화면 없이** 바로 토큰을 준다.
|
||||||
|
|
||||||
|
> **그래서 Keycloak의 세션이 사라지면 SSO 전체가 깨진다.**
|
||||||
|
> 당신 질문의 "SSO를 추가하면 어떻게 달라지는지"가 여기 걸린다.
|
||||||
|
> 앱이 하나일 때는 그 앱만 재로그인이지만, SSO에서는 **전 앱이 동시에** 터진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 토큰이 있는데 왜 세션이 필요한가
|
||||||
|
|
||||||
|
가장 흔한 오해다. "JWT는 stateless라서 서버가 기억할 게 없다"는 말은
|
||||||
|
**반만 맞다.**
|
||||||
|
|
||||||
|
### 4-1. 토큰이 두 종류다
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 성공
|
||||||
|
│
|
||||||
|
├──▶ access token 수명 짧음 (이 실험대: 60초)
|
||||||
|
│ JWT. 서명이 붙어 있어 서버가 아무것도 기억 안 해도 검증된다
|
||||||
|
│ → 진짜 stateless
|
||||||
|
│
|
||||||
|
└──▶ refresh token 수명 김 (이 실험대: 1800초 = 30분)
|
||||||
|
access token 이 만료되면 이걸로 새로 받는다
|
||||||
|
→ 서버가 세션을 기억하고 있어야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
| | access token | refresh token |
|
||||||
|
|---|---|---|
|
||||||
|
| 검증 방식 | **서명만 보면 됨** | **서버 세션 조회 필요** |
|
||||||
|
| 취소 | **불가능** (만료를 기다려야) | 가능 |
|
||||||
|
| 수명 | 짧게 (분 단위) | 길게 (시간~일) |
|
||||||
|
|
||||||
|
### 4-2. 그래서 이렇게 된다
|
||||||
|
|
||||||
|
```
|
||||||
|
0초 로그인 세션 생성
|
||||||
|
0초 access token 발급 이후 60초간은 서버에 안 물어봐도 됨
|
||||||
|
60초 access token 만료
|
||||||
|
60초 refresh 요청 ─────▶ 서버: "이 세션 살아 있나?" ◀── 여기서 세션 필요
|
||||||
|
60초 새 access token
|
||||||
|
120초 또 만료 → 또 refresh → 또 세션 조회
|
||||||
|
```
|
||||||
|
|
||||||
|
**60초마다 세션 저장소를 친다.** access token 수명이 짧을수록 세션 저장소
|
||||||
|
부하가 커진다 — 보안과 성능의 맞바꿈이 여기서 일어난다.
|
||||||
|
|
||||||
|
### 4-3. 로그아웃도 세션이 있어야 한다
|
||||||
|
|
||||||
|
**로그아웃 = 세션 삭제**다. 세션이 없으면 로그아웃이라는 개념 자체가 없다.
|
||||||
|
이미 발급된 access token은 서명이 유효하므로 만료 전까지 계속 통과한다.
|
||||||
|
|
||||||
|
> 그래서 access token 수명을 60초로 짧게 잡는다. 로그아웃해도 최대 60초는
|
||||||
|
> 살아 있다는 뜻이고, 그 이상은 refresh 가 막히므로 끝난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Keycloak은 세션을 어디에 두는가 — **버전에 따라 답이 다르다**
|
||||||
|
|
||||||
|
이 실험 전체가 여기에 걸려 있다.
|
||||||
|
|
||||||
|
### 5-1. 두 개의 후보
|
||||||
|
|
||||||
|
| | 무엇 | 성질 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Infinispan** | Keycloak **안에 내장된** 분산 캐시. Java 라이브러리 | 메모리. 빠름. 프로세스가 죽으면 사라짐 |
|
||||||
|
| **데이터베이스** | PostgreSQL 등 바깥의 DB | 디스크. 느림. 재시작해도 남음 |
|
||||||
|
|
||||||
|
**Infinispan은 별도로 설치하는 물건이 아니다.** Keycloak 프로세스 안에서 도는
|
||||||
|
라이브러리다. Redis처럼 따로 띄우는 게 아니다 — 이걸 헷갈리면 전체가 안 맞는다.
|
||||||
|
|
||||||
|
### 5-2. 버전별로 이렇게 바뀌었다
|
||||||
|
|
||||||
|
| 버전 | 진실의 원천 | 전체 재시작하면 |
|
||||||
|
|---|---|---|
|
||||||
|
| ~24 | **Infinispan (메모리)** | **세션 전부 소멸** |
|
||||||
|
| 25 | 선택 (`persistent-user-sessions` 옵션) | 설정에 따라 |
|
||||||
|
| **26 (지금 이 실험대)** | **데이터베이스** | **세션 살아남음** |
|
||||||
|
|
||||||
|
**이게 결정적이다.** 인터넷에 있는 Keycloak 클러스터링 자료 대부분은
|
||||||
|
24 이전 기준이라 **"세션은 Infinispan이 노드끼리 복제한다"** 고 쓰여 있다.
|
||||||
|
26에서는 더 이상 사실이 아니다.
|
||||||
|
|
||||||
|
> 제가 처음에 개념 문서에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고
|
||||||
|
> 써둔 것도 이 옛 모델을 그대로 옮긴 것이었다. 실험 0에서 틀렸음이 드러났다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Infinispan / JGroups / 7800 — 이름들의 정체
|
||||||
|
|
||||||
|
실험 로그에 계속 나오는 이름들이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Keycloak 프로세스
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ Infinispan "세션을 어디 두고 어떻게 │ ← 캐시 계층
|
||||||
|
│ 나눌까" │
|
||||||
|
│ │ │
|
||||||
|
│ JGroups "누가 우리 멤버이고 │ ← 그룹 통신 계층
|
||||||
|
│ 어떻게 메시지를 주고받나" │
|
||||||
|
│ │ │
|
||||||
|
│ TCP 7800 실제 소켓 │ ← 네트워크
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 이름 | 정체 |
|
||||||
|
|---|---|
|
||||||
|
| **Infinispan** | Keycloak 내장 캐시. 세션·realm 설정·로그인 실패 횟수 등을 담는다 |
|
||||||
|
| **JGroups** | Infinispan이 노드끼리 대화할 때 쓰는 하부 라이브러리 |
|
||||||
|
| **TCP 7800** | JGroups가 쓰는 포트. **노드 간 통신 경로** |
|
||||||
|
| **jdbc-ping** | 서로를 **찾는** 방법. DB의 `JGROUPS_PING` 테이블을 게시판처럼 쓴다 |
|
||||||
|
| `ISPN000094` | "새 멤버 명단을 받았다"는 로그 코드 |
|
||||||
|
|
||||||
|
**찾는 것과 대화하는 것이 다른 경로다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
디스커버리 (서로를 찾는다) → PostgreSQL JGROUPS_PING 테이블
|
||||||
|
트랜스포트 (실제 대화) → TCP 7800
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 왜 쿠버네티스와 노드 2대가 나오는가
|
||||||
|
|
||||||
|
당신 질문은 "Keycloak이 여러 개일 경우"였다. 그걸 **진짜로** 재현하려면
|
||||||
|
Keycloak 프로세스 2개가 **서로 다른 기계**에 있어야 한다.
|
||||||
|
|
||||||
|
| 방식 | 노드 상실을 실험할 수 있나 |
|
||||||
|
|---|---|
|
||||||
|
| Docker 컨테이너 2개 (한 기계) | **못 한다.** 커널이 하나라 "기계가 죽는" 상황을 못 만든다 |
|
||||||
|
| **VM 2대 + k3s** | **된다.** 하나를 전원 차단할 수 있다 |
|
||||||
|
|
||||||
|
그래서 이 실험대는 VM 2대(`kc-lab-1`, `kc-lab-2`) 위에 k3s를 올렸다.
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-1 (k3s 서버) kc-lab-2 (k3s 에이전트)
|
||||||
|
├─ keycloak-1 ├─ keycloak-0
|
||||||
|
├─ traefik, coredns └─ postgres
|
||||||
|
└─ prometheus, grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
**`keycloak-0` / `keycloak-1` 은 Keycloak 프로세스**이고,
|
||||||
|
**`kc-lab-1` / `kc-lab-2` 는 그것들이 올라간 기계**다. 이름이 비슷해서
|
||||||
|
헷갈리기 쉬운데 계층이 다르다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 그래서 실험 0은 무엇을 알아내려 한 것인가
|
||||||
|
|
||||||
|
### 8-1. 답해야 할 실무 질문
|
||||||
|
|
||||||
|
> Keycloak을 2대로 늘렸다. **한 대가 죽으면 로그인한 사람들은 어떻게 되나?**
|
||||||
|
> **DB가 죽으면?** **노드 사이 네트워크가 끊기면?**
|
||||||
|
|
||||||
|
이 질문들에 답하려면 **정상일 때 무엇이 어디에 있는지**를 먼저 알아야 한다.
|
||||||
|
그게 없으면 장애를 일으켜도 무엇이 왜 깨졌는지 해석할 수 없다.
|
||||||
|
|
||||||
|
### 8-2. 그래서 실험 0의 질문은 두 개다
|
||||||
|
|
||||||
|
```
|
||||||
|
질문 A. 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가?
|
||||||
|
↓ 답: 그렇다
|
||||||
|
|
||||||
|
질문 B. 그 공유는 무엇 덕분인가?
|
||||||
|
(a) Infinispan 이 메모리를 복제해서
|
||||||
|
(b) 둘 다 같은 DB 를 봐서
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8-3. **B를 구분해야 하는 이유** — 운영 대응이 정반대다
|
||||||
|
|
||||||
|
| 상황 | (a) 복제라면 | (b) DB라면 |
|
||||||
|
|---|---|---|
|
||||||
|
| 노드 간 7800 끊김 | **세션 공유 깨짐** | **멀쩡** |
|
||||||
|
| DB 죽음 | 한동안 버팀 | **즉시 전면 장애** |
|
||||||
|
| 노드 1대 죽음 | 세션 살아남음 | 세션 살아남음 |
|
||||||
|
| 성능 병목 | 노드 간 네트워크 | **DB, 커넥션 풀** |
|
||||||
|
| 튜닝할 곳 | JGroups 설정 | **DB 인덱스, 커넥션 수** |
|
||||||
|
| 노드를 10대로 늘리면 | **복제 트래픽 폭증** | DB 부하 증가 |
|
||||||
|
|
||||||
|
**같은 증상에 정반대 처방이 나온다.** 그래서 추측이 아니라 측정으로
|
||||||
|
확정해야 했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 왜 하필 "캐시 엔트리 개수"를 셌는가
|
||||||
|
|
||||||
|
질문 B를 가르는 가장 직접적인 방법이기 때문이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-0 에만 로그인을 보낸다
|
||||||
|
│
|
||||||
|
└──▶ 그리고 keycloak-1 의 메모리를 들여다본다
|
||||||
|
|
||||||
|
그 세션이 들어와 있으면 → (a) 복제한 것
|
||||||
|
비어 있으면 → (b) DB 로 공유한 것
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak은 자기 캐시에 몇 개가 들었는지를 `/metrics` 로 알려준다.
|
||||||
|
|
||||||
|
```
|
||||||
|
vendor_statistics_approximate_entries_unique{cache="sessions"} 7.0
|
||||||
|
───────────── ───
|
||||||
|
세션 캐시 7개 들어 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
**측정 결과: keycloak-1은 계속 0이었다.** keycloak-0이 14개를 들고 있는
|
||||||
|
동안에도 0. 그리고 keycloak-1에 직접 로그인을 보낸 순간에만 늘었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
단계 k0 k1
|
||||||
|
시작 2 0
|
||||||
|
keycloak-1 에 로그인 5회 2 5 ← k0 안 늘어남
|
||||||
|
keycloak-0 에 로그인 5회 7 5 ← k1 안 늘어남
|
||||||
|
|
||||||
|
PostgreSQL 세션 수: 12 = 7 + 5 ← 캐시 합과 정확히 일치
|
||||||
|
```
|
||||||
|
|
||||||
|
**각 노드는 자기가 처리한 것만 캐시한다. 메모리는 건너가지 않는다.**
|
||||||
|
→ 답은 **(b)**.
|
||||||
|
|
||||||
|
그리고 마지막으로 PostgreSQL 로그를 켜서, keycloak-1이 **실제로 날리는
|
||||||
|
SELECT 문**을 잡았다. 추측이 아니라는 것을 못 박기 위해서다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 이 사실이 당신 운영에 뜻하는 것
|
||||||
|
|
||||||
|
| 알게 된 것 | 실무적 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 세션은 DB에 있다 | **DB가 단일 장애점이다.** HA·백업 계획이 Keycloak 대수보다 중요하다 |
|
||||||
|
| 메모리는 로컬 캐시일 뿐 | Keycloak을 몇 대로 늘려도 **노드 간 트래픽은 안 는다.** 대신 DB 부하가 는다 |
|
||||||
|
| 남의 세션은 캐시 안 함 | **sticky session 은 정확성이 아니라 성능 문제다.** 없어도 동작하지만 DB를 더 친다 |
|
||||||
|
| refresh 마다 DB 읽기+쓰기 | access token 수명을 줄이면 **DB 부하가 그만큼 는다** |
|
||||||
|
| `synchronous_commit OFF` | **DB가 강제 종료되면 직전 수백 ms 갱신이 사라진다** (의도된 설계) |
|
||||||
|
| 낙관적 락 (`VERSION`) | **동시에 refresh 하면 한쪽이 진다.** 클라이언트에 재시도가 필요하다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 앞으로 할 실험과 각각이 답하는 질문
|
||||||
|
|
||||||
|
| # | 실험 | 답하는 실무 질문 | 예측 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A-0** | ✅ 세션 복제 확인 | 정상일 때 세션은 어디 있나 | — (완료) |
|
||||||
|
| **A-1** | TCP 7800 차단 | 노드 간 네트워크가 끊기면? | 세션 공유는 **안 깨짐**. 무효화 전파가 깨질 것 |
|
||||||
|
| **A-2** | DB 정지 | **DB가 죽으면?** | **즉시 전면 장애** |
|
||||||
|
| A-2' | DB 강제 종료 | 복구하면 뭘 잃나 | 직전 수백 ms 세션 갱신 소멸 |
|
||||||
|
| **A-3** | 노드 1대 전원 차단 | **Keycloak 한 대가 죽으면?** | 세션 살아남음 |
|
||||||
|
| **A-4** | volatile 모드 비교 | 옛 방식(24 이전)은 뭐가 다른가 | A-1이 **정반대로** 치명적이 됨 |
|
||||||
|
| B-5 | 동시 refresh 경쟁 | 토큰 갱신이 겹치면? | 한쪽이 낙관적 락에서 짐 |
|
||||||
|
| — | SSO 다중 앱 | **SSO를 붙이면 뭐가 달라지나** | Keycloak 세션 하나가 전 앱을 좌우 |
|
||||||
|
|
||||||
|
**A-1이 특히 중요하다.** 통념("클러스터 포트 막으면 세션 깨짐")과
|
||||||
|
이번 측정("세션은 7800으로 안 다님")이 정면으로 어긋나므로, **둘 중 하나는
|
||||||
|
틀렸다.** 실험이 판정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 용어 빠른 참조
|
||||||
|
|
||||||
|
| 용어 | 한 줄 |
|
||||||
|
|---|---|
|
||||||
|
| **세션** | 서버가 "이 사람 로그인했음"을 기억하는 것 |
|
||||||
|
| **SSO 세션** | Keycloak이 가진 세션. 이게 죽으면 전 앱 재로그인 |
|
||||||
|
| **access token** | 짧게 사는 JWT. 서명만으로 검증. 취소 불가 |
|
||||||
|
| **refresh token** | access token을 새로 받는 표. **세션 조회가 필요** |
|
||||||
|
| **sid** | 세션 식별자. JWT·DB·관리 API에서 **같은 문자열** |
|
||||||
|
| **Infinispan** | Keycloak **내장** 캐시 (별도 설치 아님) |
|
||||||
|
| **JGroups** | Infinispan의 노드 간 통신 라이브러리 |
|
||||||
|
| **jdbc-ping** | DB 테이블로 서로를 찾는 방식 |
|
||||||
|
| **7800** | 노드 간 통신 포트 |
|
||||||
|
| **persistent-user-sessions** | 세션을 DB에 저장하는 기능. **KC 26 기본값** |
|
||||||
|
| **`OFFLINE_USER_SESSION`** | 이름과 달리 **온라인 세션도** 여기 있다 (`offline_flag='0'`) |
|
||||||
|
| **낙관적 락** | 읽을 때 버전과 같을 때만 쓰기. 충돌은 사후 검출 |
|
||||||
|
| **kc-lab-1/2** | VM(기계) 이름 |
|
||||||
|
| **keycloak-0/1** | Keycloak 프로세스(파드) 이름 |
|
||||||
@@ -59,7 +59,9 @@ if [ "$expected_count" -ne 39 ]; then
|
|||||||
12. 인증서 갱신 실측
|
12. 인증서 갱신 실측
|
||||||
```
|
```
|
||||||
|
|
||||||
**순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.**
|
**각 실험의 구조·주입 지점·확인 항목은
|
||||||
|
[`experiment-plan.md`](experiment-plan.md)에 미리 확정해두었다.**
|
||||||
|
순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.
|
||||||
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
|
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
|
||||||
|
|
||||||
### ✅ 완료 — 환경 구축
|
### ✅ 완료 — 환경 구축
|
||||||
@@ -378,6 +380,9 @@ sudo certbot renew --force-renewal
|
|||||||
|
|
||||||
| 문서 | 내용 |
|
| 문서 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [`experiment-plan.md`](experiment-plan.md) | **실험 20개의 구조도·주입 방법·관측 지점·예측** |
|
||||||
|
| [`session-lab-prerequisites.md`](session-lab-prerequisites.md) | 이 실험을 이해하기 위한 선수 지식 |
|
||||||
|
| [`experiment-00-session-replication.md`](experiment-00-session-replication.md) | A-0 결과 — 세션 공유의 실체 |
|
||||||
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
|
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
|
||||||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
|
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
|
||||||
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
|
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
|
||||||
|
|||||||
Reference in New Issue
Block a user