Compare commits
+7
-2
@@ -4,10 +4,15 @@
|
||||
*.iml
|
||||
|
||||
backend/target/
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
build/
|
||||
e2e/node_modules/
|
||||
google-e2e/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
bff/target
|
||||
token-mediator/target
|
||||
|
||||
# lab cloud-init contains a console password; keep the filled copy local
|
||||
deploy/lab/cloud-init/kc-lab.yaml
|
||||
deploy/lab/cloud-init/kc-lab-*.yaml
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
[ 149ms] [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 @@
|
||||
[ 103ms] [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 @@
|
||||
[ 95ms] [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 @@
|
||||
[ 132ms] [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 @@
|
||||
[ 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
|
||||
@@ -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
|
||||
@@ -15,6 +15,11 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
|
||||
- AP3: Backend-for-Frontend (BFF)
|
||||
- AP4: Edge forward-auth
|
||||
|
||||
세션 저장소·refresh token 경쟁·장애 복구는 네 패턴을 가로지르는 별도 축으로
|
||||
`develop-keycloak-session-store` 브랜치에서 진행합니다. 계획과 진행 상황은
|
||||
[`docs/session-store-lab-roadmap.md`](docs/session-store-lab-roadmap.md)에
|
||||
있습니다.
|
||||
|
||||
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
|
||||
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
||||
위에서 별도 브랜치로 진행합니다.
|
||||
@@ -103,39 +108,3 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
|
||||
|
||||
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
|
||||
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
|
||||
|
||||
## AP1: SPA Direct + Resource Server
|
||||
|
||||
`develop-keycloak-pattern1`은 vanilla JavaScript SPA가 `spa-public` client로
|
||||
Authorization Code + PKCE S256 로그인을 수행하는 패턴입니다. access/refresh
|
||||
token은 명시적인 in-memory store에만 보관되므로 새로고침하면 사라집니다.
|
||||
|
||||
```bash
|
||||
./scripts/verify-pattern1.sh
|
||||
```
|
||||
|
||||
브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
|
||||
있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
|
||||
Resource Server가 Bearer JWT를 검증합니다.
|
||||
|
||||
Keycloak의 dedicated audience mapper는 `spa-public` access token에
|
||||
`keycloak-pattern-api`를 추가합니다. Spring은 signature, `iss`, `exp`뿐
|
||||
아니라 이 `aud`도 검사합니다. `verify-pattern1.sh`는 같은 정상 토큰을
|
||||
`deliberately-wrong-audience`를 기대하는 진단 인스턴스에도 제출해 `401`을
|
||||
확인합니다.
|
||||
|
||||
Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss`를
|
||||
발급합니다. 정상 Resource Server는 이 외부 issuer 문자열을 검증하되 JWKS는
|
||||
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
|
||||
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
|
||||
정상이더라도 issuer mismatch로 `401`을 반환합니다.
|
||||
|
||||
token 저장 위치와 XSS 범위는
|
||||
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
|
||||
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
|
||||
header를 관찰할 수 있음을 재현합니다.
|
||||
|
||||
refresh rotation, 소비된 refresh token 재사용, RP-Initiated Logout,
|
||||
revocation과 stateless JWT의 차이는
|
||||
[`docs/ap1-refresh-logout.md`](docs/ap1-refresh-logout.md)에 정리했으며 같은
|
||||
E2E에서 실제 Keycloak 26.7.0 동작을 검증합니다.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -9,6 +11,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ApiController {
|
||||
@@ -18,6 +22,38 @@ public class ApiController {
|
||||
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects what actually reached the application after the proxy chain.
|
||||
*
|
||||
* <p>The reverse proxy contract is defined in {@code docs/reverse-proxy-headers.md}
|
||||
* for a single nginx hop. The lab runs {@code nginx -> Traefik -> pod}, so this
|
||||
* endpoint exists to measure the two-hop result instead of assuming it.
|
||||
*
|
||||
* <p>{@code scheme}, {@code secure} and {@code requestUrl} are the values Keycloak
|
||||
* uses to build the {@code iss} claim and redirect URLs. If forwarded headers are
|
||||
* lost or rewritten, the mismatch shows up here first.
|
||||
*/
|
||||
@GetMapping("/echo")
|
||||
public Map<String, Object> echo(HttpServletRequest request) {
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
for (String name : Collections.list(request.getHeaderNames())) {
|
||||
headers.put(name.toLowerCase(), Collections.list(request.getHeaders(name)));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("headers", headers);
|
||||
response.put("remoteAddr", request.getRemoteAddr());
|
||||
// Pod IP. Identifies which replica answered, which is what makes the
|
||||
// host nginx upstream distribution and the sticky-session switch observable.
|
||||
response.put("localAddr", request.getLocalAddr());
|
||||
response.put("scheme", request.getScheme());
|
||||
response.put("secure", request.isSecure());
|
||||
response.put("serverName", request.getServerName());
|
||||
response.put("serverPort", request.getServerPort());
|
||||
response.put("requestUrl", request.getRequestURL().toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
@@ -27,9 +63,4 @@ public class ApiController {
|
||||
response.put("audience", jwt.getAudience());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/admin")
|
||||
public Map<String, String> adminEndpoint() {
|
||||
return Map.of("status", "ok", "authorization", "admin-role");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final OAuth2Error MISSING_AUDIENCE = new OAuth2Error(
|
||||
"invalid_token",
|
||||
"The required resource audience is missing",
|
||||
null
|
||||
);
|
||||
|
||||
private final String expectedAudience;
|
||||
|
||||
AudienceValidator(String expectedAudience) {
|
||||
this.expectedAudience = expectedAudience;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
if (jwt.getAudience().contains(expectedAudience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
|
||||
@Configuration
|
||||
public class JwtDecoderConfig {
|
||||
|
||||
@Bean
|
||||
JwtDecoder jwtDecoder(
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
||||
@Value("${security.expected-audience}") String expectedAudience
|
||||
) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
|
||||
OAuth2TokenValidator<Jwt> issuerAndTimestamp =
|
||||
JwtValidators.createDefaultWithIssuer(issuer);
|
||||
OAuth2TokenValidator<Jwt> audience = new AudienceValidator(expectedAudience);
|
||||
decoder.setJwtValidator(
|
||||
new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience)
|
||||
);
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class KeycloakRealmRoleConverter
|
||||
implements Converter<Jwt, Collection<GrantedAuthority>> {
|
||||
|
||||
@Override
|
||||
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||
if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) {
|
||||
return List.of();
|
||||
}
|
||||
return roles.stream()
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.map(GrantedAuthority.class::cast)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
@@ -19,40 +13,16 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.cors(Customizer.withDefaults())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
|
||||
"/api/echo")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/admin")
|
||||
.hasRole("admin-role")
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
|
||||
return converter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of(
|
||||
"http://localhost:8088",
|
||||
"http://127.0.0.1:8088"
|
||||
));
|
||||
configuration.setAllowedMethods(List.of("GET", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8081}
|
||||
# Spring ignores X-Forwarded-* unless this is set, so scheme/secure/requestUrl
|
||||
# report the raw connection by default. Keycloak has the same opt-in as
|
||||
# KC_PROXY_HEADERS. Flipping this to "native" is what the two-hop measurement
|
||||
# compares against.
|
||||
forward-headers-strategy: ${SERVER_FORWARD_HEADERS_STRATEGY:none}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: keycloak-pattern-api
|
||||
jackson:
|
||||
serialization:
|
||||
# /api/echo is read by humans and captured as evidence screenshots, so the
|
||||
# response is indented rather than relying on a browser's JSON viewer.
|
||||
indent-output: true
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
@@ -11,9 +21,6 @@ spring:
|
||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
|
||||
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs}
|
||||
|
||||
security:
|
||||
expected-audience: ${SECURITY_EXPECTED_AUDIENCE:keycloak-pattern-api}
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@@ -26,6 +25,18 @@ class ApiSecurityTest {
|
||||
.andExpect(jsonPath("$.status").value("ok"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void echoEndpointReflectsForwardedHeadersWithoutAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/echo")
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", "app1.example.test"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.headers['x-forwarded-proto'][0]").value("https"))
|
||||
.andExpect(jsonPath("$.headers['x-forwarded-host'][0]").value("app1.example.test"))
|
||||
.andExpect(jsonPath("$.requestUrl").exists())
|
||||
.andExpect(jsonPath("$.remoteAddr").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
||||
mockMvc.perform(get("/api/me"))
|
||||
@@ -41,19 +52,4 @@ class ApiSecurityTest {
|
||||
.andExpect(jsonPath("$.subject").value("test-subject"))
|
||||
.andExpect(jsonPath("$.username").value("regular-user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void regularUserCannotCallAdminEndpoint() throws Exception {
|
||||
mockMvc.perform(get("/api/admin").with(jwt()
|
||||
.authorities(new SimpleGrantedAuthority("ROLE_user-role"))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminRoleCanCallAdminEndpoint() throws Exception {
|
||||
mockMvc.perform(get("/api/admin").with(jwt()
|
||||
.authorities(new SimpleGrantedAuthority("ROLE_admin-role"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.authorization").value("admin-role"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
class AudienceValidatorTest {
|
||||
|
||||
private final AudienceValidator validator =
|
||||
new AudienceValidator("keycloak-pattern-api");
|
||||
|
||||
@Test
|
||||
void acceptsRequiredAudience() {
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
|
||||
List.of("account", "keycloak-pattern-api")
|
||||
));
|
||||
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForeignAudience() {
|
||||
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
|
||||
List.of("another-resource")
|
||||
));
|
||||
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors())
|
||||
.extracting(error -> error.getErrorCode())
|
||||
.containsExactly("invalid_token");
|
||||
}
|
||||
|
||||
private Jwt jwtWithAudience(List<String> audience) {
|
||||
Instant now = Instant.now();
|
||||
return new Jwt(
|
||||
"test-token",
|
||||
now,
|
||||
now.plusSeconds(300),
|
||||
Map.of("alg", "none"),
|
||||
Map.of("sub", "test-subject", "aud", audience)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
class KeycloakRealmRoleConverterTest {
|
||||
|
||||
private final KeycloakRealmRoleConverter converter =
|
||||
new KeycloakRealmRoleConverter();
|
||||
|
||||
@Test
|
||||
void mapsRealmRolesWithExactlyOneRolePrefix() {
|
||||
Jwt jwt = new Jwt(
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "none"),
|
||||
Map.of("sub", "subject", "realm_access", Map.of(
|
||||
"roles", List.of("admin-role", "user-role")
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(converter.convert(jwt))
|
||||
.extracting("authority")
|
||||
.containsExactly("ROLE_admin-role", "ROLE_user-role");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRealmAccessProducesNoAuthorities() {
|
||||
Jwt jwt = new Jwt(
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "none"),
|
||||
Map.of("sub", "subject")
|
||||
);
|
||||
|
||||
assertThat(converter.convert(jwt)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# Session store lab
|
||||
|
||||
세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
|
||||
네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
|
||||
|
||||
이 문서는 **절차**만 담는다.
|
||||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [`docs/session-store-lab-roadmap.md`](../../docs/session-store-lab-roadmap.md) | 이 축의 계획과 진행 상황 |
|
||||
| [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md) | 등장 개념 전체 |
|
||||
| [`docs/session-lab-operations.md`](../../docs/session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 |
|
||||
| [`docs/two-hop-proxy-header-contract.md`](../../docs/two-hop-proxy-header-contract.md) | 첫 실험 결과 |
|
||||
|
||||
## 토폴로지
|
||||
|
||||
```
|
||||
브라우저 / SSH (tailnet)
|
||||
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
||||
▼
|
||||
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
|
||||
│ nginx :80 301 → https
|
||||
│
|
||||
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
||||
├──▶ kc-lab-1 .11 k3s server Traefik :80
|
||||
└──▶ kc-lab-2 .12 k3s agent Traefik :80
|
||||
└──▶ Pod
|
||||
```
|
||||
|
||||
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
||||
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
||||
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
| 경로 | 역할 |
|
||||
|---|---|
|
||||
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
||||
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
|
||||
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
||||
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
||||
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
||||
| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
|
||||
| `scripts/verify-lab.sh` | 인프라 상태 점검 |
|
||||
|
||||
## 상태 점검
|
||||
|
||||
```bash
|
||||
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
||||
```
|
||||
|
||||
**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
|
||||
Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
|
||||
|
||||
## 첫 실험 — 2홉 헤더 계약
|
||||
|
||||
[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
|
||||
nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
|
||||
`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
|
||||
|
||||
이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
|
||||
쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
|
||||
|
||||
```bash
|
||||
# 워크스테이션: 이미지 빌드 후 두 노드에 반입
|
||||
./deploy/lab/scripts/build-and-import.sh
|
||||
|
||||
# lab host: 배포
|
||||
kubectl apply -f deploy/lab/k8s/echo.yaml
|
||||
kubectl -n header-lab rollout status deployment/echo
|
||||
|
||||
# 어디서든: 실측
|
||||
./deploy/lab/scripts/measure-proxy-headers.sh
|
||||
```
|
||||
|
||||
관측 대상은 넷이다.
|
||||
|
||||
1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
|
||||
2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
|
||||
3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
|
||||
4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
|
||||
|
||||
3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
|
||||
헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
|
||||
|
||||
## 이미지 배포 경로
|
||||
|
||||
k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
|
||||
|
||||
```
|
||||
워크스테이션 docker build → docker save
|
||||
│ ssh (lab host 경유)
|
||||
▼
|
||||
게스트 sudo k3s ctr images import
|
||||
매니페스트 imagePullPolicy: Never
|
||||
```
|
||||
|
||||
**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
|
||||
Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
|
||||
필요한 것은 자체 빌드 이미지뿐이다.
|
||||
|
||||
**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
|
||||
갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
|
||||
|
||||
## 게스트 재생성
|
||||
|
||||
파괴적 실험 후 초기화하는 경로다.
|
||||
|
||||
```bash
|
||||
virsh destroy kc-lab-1
|
||||
virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
|
||||
virsh vol-delete --pool default kc-lab-1.qcow2
|
||||
|
||||
./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
|
||||
|
||||
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
|
||||
CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
|
||||
하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
|
||||
데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
|
||||
hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
|
||||
것뿐이다.
|
||||
|
||||
게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
|
||||
@@ -0,0 +1,37 @@
|
||||
#cloud-config
|
||||
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
|
||||
# and bakes this into a CIDATA seed image.
|
||||
#
|
||||
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
|
||||
# git because plain_text_passwd is a credential, however disposable.
|
||||
#
|
||||
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
|
||||
# on a parse error: the guest boots as "localhost" with no user and no way in.
|
||||
hostname: kc-lab-__NODE__
|
||||
fqdn: kc-lab-__NODE__
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: donghyeon
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
||||
# run non-interactively and would block on a password prompt.
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
||||
# that cannot be logged into at all, so its own failure log is unreadable.
|
||||
# ssh_pwauth stays false, so this never widens SSH exposure.
|
||||
lock_passwd: false
|
||||
plain_text_passwd: CHANGE_ME
|
||||
ssh_authorized_keys:
|
||||
# Lab host key: needed because automation runs from the lab host, where
|
||||
# agent forwarding is not available.
|
||||
- CHANGE_ME_LAB_HOST_PUBLIC_KEY
|
||||
# Workstation key: lets ProxyJump reach the guest directly.
|
||||
- CHANGE_ME_WORKSTATION_PUBLIC_KEY
|
||||
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
packages:
|
||||
- curl
|
||||
- nftables
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lab entry point. Deployed on the lab host as
|
||||
# /etc/nginx/sites-available/keycloak-lab
|
||||
# and symlinked from sites-enabled/.
|
||||
#
|
||||
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
|
||||
# include /etc/nginx/sites-enabled/*;
|
||||
# inside its http { } block before this file has any effect.
|
||||
#
|
||||
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
|
||||
# the Traefik instance running on each k3s node.
|
||||
|
||||
upstream k3s_traefik {
|
||||
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
|
||||
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
|
||||
# the interesting case: Infinispan still routes correctly, only slower.
|
||||
# ip_hash;
|
||||
server 192.168.122.11:80;
|
||||
server 192.168.122.12:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
||||
# desktop browsers and fails on mobile and curl.
|
||||
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://k3s_traefik;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
|
||||
# boundary: a client-supplied X-Forwarded-For must be discarded, not
|
||||
# extended, or nothing downstream can rely on the value.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Restrict who may reach the echo pods.
|
||||
#
|
||||
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
|
||||
# app's Tomcat valve trusts every private range by default. Both are IP-range
|
||||
# decisions, so any pod in the cluster can forge those headers by talking to the
|
||||
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
|
||||
#
|
||||
# kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
||||
# curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
|
||||
# → serverName evil.example.com, remoteAddr 1.2.3.4
|
||||
#
|
||||
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
|
||||
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
|
||||
# could not name Traefik because its IP changes.
|
||||
#
|
||||
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
|
||||
# pair. Doing only the first leaves this hole.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: echo-allow-traefik-only
|
||||
namespace: header-lab
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: echo
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
# The proxy itself. namespaceSelector and podSelector in one list item are
|
||||
# ANDed, so this is "traefik pods in kube-system" and nothing else.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8081
|
||||
|
||||
# kubelet readiness/liveness probes originate from the node, not from a pod,
|
||||
# so they need their own rule. Without it the probes fail and the pods are
|
||||
# restarted in a loop.
|
||||
#
|
||||
# The probe's source address is the node's flannel bridge (cni0), which
|
||||
# holds the first address of that node's /24:
|
||||
# kc-lab-1 10.42.0.1 kc-lab-2 10.42.1.1
|
||||
# Listing them as /32 keeps this rule from re-admitting arbitrary pods,
|
||||
# which a broader 10.42.0.0/16 block would do and would undo the policy.
|
||||
#
|
||||
# Adding a node means adding its gateway here. Verify with:
|
||||
# kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
|
||||
- from:
|
||||
- ipBlock:
|
||||
cidr: 10.42.0.1/32
|
||||
- ipBlock:
|
||||
cidr: 10.42.1.1/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8081
|
||||
@@ -0,0 +1,113 @@
|
||||
# Header echo workload for the two-hop proxy contract measurement.
|
||||
#
|
||||
# browser -> host nginx (TLS termination) -> Traefik -> this pod
|
||||
#
|
||||
# The image is built from backend/ and imported straight into each node's
|
||||
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: header-lab
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: echo
|
||||
namespace: header-lab
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: echo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: echo
|
||||
spec:
|
||||
# One replica per node so the sticky-session switch on the host nginx
|
||||
# upstream has something observable to route between.
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: echo
|
||||
containers:
|
||||
- name: echo
|
||||
image: keycloak-pattern-api:lab
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8081
|
||||
name: http
|
||||
env:
|
||||
- name: SERVER_PORT
|
||||
value: "8081"
|
||||
# "none" makes the app report the raw connection, so scheme/secure/
|
||||
# requestUrl show what arrives without any forwarded-header handling.
|
||||
# Set to "native" and redeploy to see the same request interpreted
|
||||
# with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
|
||||
# same opt-in, which is why measuring both sides matters here.
|
||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||
value: "native"
|
||||
# The JVM sizes its heap from the container limit, not the host.
|
||||
- name: JAVA_TOOL_OPTIONS
|
||||
value: "-XX:MaxRAMPercentage=70"
|
||||
# /api/echo is permitAll, so the JWT decoder is never exercised.
|
||||
# These stay pointed at the future Keycloak service name.
|
||||
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
|
||||
value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
|
||||
value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: http
|
||||
initialDelaySeconds: 45
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
memory: 320Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: echo
|
||||
namespace: header-lab
|
||||
spec:
|
||||
selector:
|
||||
app: echo
|
||||
ports:
|
||||
- port: 8081
|
||||
targetPort: http
|
||||
name: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: echo
|
||||
namespace: header-lab
|
||||
spec:
|
||||
# k3s ships Traefik as the default ingress controller. Keeping it is what
|
||||
# makes this lab a faithful two-hop replica.
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: app1.hyeonworks.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: echo
|
||||
port:
|
||||
number: 8081
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
# Make Traefik trust the X-Forwarded-* headers that the host nginx sets.
|
||||
#
|
||||
# Without this, Traefik rewrites every forwarded header from its own connection,
|
||||
# which is plain HTTP on port 80. The application then sees scheme=http even
|
||||
# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md.
|
||||
#
|
||||
# k3s installs Traefik through its bundled HelmChart, so values are overridden
|
||||
# with a HelmChartConfig rather than by editing the deployment. k3s reconciles
|
||||
# the chart and recreates the Traefik pod.
|
||||
#
|
||||
# kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
||||
# kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChartConfig
|
||||
metadata:
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
spec:
|
||||
valuesContent: |-
|
||||
ports:
|
||||
web:
|
||||
forwardedHeaders:
|
||||
# Requests arriving from these sources keep their existing
|
||||
# X-Forwarded-* values instead of having them rewritten.
|
||||
#
|
||||
# 10.42.0.0/16 is the pod CIDR. It is required because the traefik
|
||||
# Service uses externalTrafficPolicy: Cluster, so svclb SNATs the
|
||||
# traffic and Traefik sees a pod-network address rather than the
|
||||
# host nginx address.
|
||||
#
|
||||
# The node/host range is deliberately absent. Because svclb SNATs,
|
||||
# the host nginx address never reaches Traefik — measured, not assumed.
|
||||
# Trusting a range that cannot appear only widens the surface.
|
||||
#
|
||||
# Trusting the whole pod CIDR still means any pod in the cluster could
|
||||
# forge these headers, which is why echo-network-policy.yaml restricts
|
||||
# who may reach the application at all.
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16
|
||||
websecure:
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the API image on this workstation and import it into each lab node's
|
||||
# containerd.
|
||||
#
|
||||
# k3s does not run Docker and the lab has no registry, so images are shipped as
|
||||
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
|
||||
# Every node needs its own copy because the scheduler may place the pod anywhere.
|
||||
#
|
||||
# ./deploy/lab/scripts/build-and-import.sh
|
||||
# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
|
||||
NODES="${NODES:-kc-lab-1 kc-lab-2}"
|
||||
LAB_HOST="${LAB_HOST:-test-server}"
|
||||
CONTEXT="${CONTEXT:-backend}"
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
cd "$repo_root"
|
||||
|
||||
echo "==> building ${IMAGE} from ${CONTEXT}/"
|
||||
docker build -t "$IMAGE" "$CONTEXT"
|
||||
|
||||
for node in $NODES; do
|
||||
echo "==> importing into ${node}"
|
||||
# Nested ssh: the workstation cannot reach the guests directly because they
|
||||
# sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
|
||||
# the kc-lab-* aliases.
|
||||
docker save "$IMAGE" \
|
||||
| ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
|
||||
done
|
||||
|
||||
echo "==> verifying"
|
||||
for node in $NODES; do
|
||||
printf ' %-10s ' "$node"
|
||||
ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
|
||||
| grep -c "$IMAGE" \
|
||||
| xargs -I{} echo "{} match(es)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "next: kubectl rollout restart -n header-lab deployment/echo"
|
||||
+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)"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Measure what the nginx -> Traefik chain actually delivers to the application.
|
||||
#
|
||||
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
|
||||
# runs two hops, so the forwarded headers are measured rather than assumed.
|
||||
# Run from anywhere that can resolve the lab hostnames.
|
||||
#
|
||||
# ./deploy/lab/scripts/measure-proxy-headers.sh
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOST:-app1.hyeonworks.com}"
|
||||
URL="https://${HOST}/api/echo"
|
||||
|
||||
jqf() {
|
||||
if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
|
||||
}
|
||||
|
||||
echo "=== 1. baseline: what the app sees for a normal request ==="
|
||||
curl -s "$URL" | jqf '{
|
||||
scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
|
||||
forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
|
||||
}' 2>/dev/null || curl -s "$URL"
|
||||
|
||||
echo
|
||||
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
|
||||
echo " a trusted boundary must overwrite these, not append to them"
|
||||
curl -s "$URL" \
|
||||
-H 'X-Forwarded-For: 1.2.3.4' \
|
||||
-H 'X-Forwarded-Proto: http' \
|
||||
-H 'X-Forwarded-Host: evil.example.com' \
|
||||
-H 'X-Real-IP: 1.2.3.4' \
|
||||
| jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
|
||||
for _ in 1 2 3 4; do
|
||||
curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 4. plain HTTP is redirected, not proxied ==="
|
||||
curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
|
||||
# Run on the lab host.
|
||||
#
|
||||
# ./rebuild-seed.sh 1
|
||||
#
|
||||
# The same content lives in three places: the source YAML, the ISO, and the
|
||||
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
|
||||
# this is a script and not a set of remembered commands.
|
||||
#
|
||||
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
|
||||
# per-instance modules once per instance-id, so an existing guest ignores it.
|
||||
set -euo pipefail
|
||||
|
||||
N="${1:?usage: rebuild-seed.sh <1|2>}"
|
||||
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
|
||||
POOL="${POOL:-default}"
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
cd "$CLOUD_DIR"
|
||||
src="kc-lab-${N}.yaml"
|
||||
iso="seed-kc-lab-${N}.iso"
|
||||
meta="meta-kc-lab-${N}"
|
||||
|
||||
[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
|
||||
|
||||
# A fresh instance-id makes cloud-init treat the guest as new and re-run the
|
||||
# per-instance modules.
|
||||
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
|
||||
"$N" "$(date +%s)" "$N" > "$meta"
|
||||
|
||||
# NoCloud looks for a volume labelled cidata holding files named exactly
|
||||
# user-data and meta-data. -graft-points renames them inside the image so no
|
||||
# staging directory is needed.
|
||||
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
|
||||
"/user-data=${src}" "/meta-data=${meta}"
|
||||
|
||||
size="$(stat -c%s "$iso")"
|
||||
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
|
||||
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
|
||||
virsh vol-upload --pool "$POOL" "$iso" "$iso"
|
||||
|
||||
echo "$iso published to pool '$POOL' ($size bytes)"
|
||||
echo "attach it as a virtio disk, not a SATA cdrom:"
|
||||
echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
|
||||
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
|
||||
echo "to the guest and cloud-init fails with no error anywhere."
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Confirm the lab infrastructure is intact. Run on the lab host.
|
||||
#
|
||||
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
|
||||
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
|
||||
# refused connection means the chain is broken somewhere.
|
||||
set -uo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
|
||||
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
|
||||
fail=0
|
||||
|
||||
check() { # description, expected, actual
|
||||
if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
|
||||
else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
|
||||
}
|
||||
|
||||
echo "== guests =="
|
||||
for name in kc-lab-1 kc-lab-2; do
|
||||
check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
|
||||
done
|
||||
|
||||
echo "== k3s =="
|
||||
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
|
||||
check "nodes Ready" 2 "$ready"
|
||||
lb="$(kubectl -n kube-system get svc traefik \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
|
||||
check "traefik node IPs" 2 "$lb"
|
||||
|
||||
echo "== host nginx =="
|
||||
check "service" active "$(systemctl is-active nginx)"
|
||||
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
|
||||
for ip in $NODE_IPS; do
|
||||
check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
|
||||
done
|
||||
|
||||
echo "== public entry point =="
|
||||
for h in $HOSTS; do
|
||||
check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
|
||||
check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
|
||||
done
|
||||
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
|
||||
|
||||
echo
|
||||
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
|
||||
exit "$fail"
|
||||
@@ -88,58 +88,6 @@ services:
|
||||
- keycloak-net
|
||||
restart: unless-stopped
|
||||
|
||||
app-wrong-audience:
|
||||
profiles:
|
||||
- diagnostics
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
SERVER_PORT: "8081"
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
SECURITY_EXPECTED_AUDIENCE: deliberately-wrong-audience
|
||||
ports:
|
||||
- "127.0.0.1:18081:8081"
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks:
|
||||
- keycloak-net
|
||||
|
||||
app-wrong-issuer:
|
||||
profiles:
|
||||
- diagnostics
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
SERVER_PORT: "8081"
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://wrong-issuer.invalid/realms/keycloak-patterns
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
SECURITY_EXPECTED_AUDIENCE: keycloak-pattern-api
|
||||
ports:
|
||||
- "127.0.0.1:18082:8081"
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks:
|
||||
- keycloak-net
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Account linking UX for the SPA
|
||||
|
||||
두 흐름을 구분한다.
|
||||
|
||||
- 로그인 도중 email collision: Keycloak의 안전한 First Broker Login flow가
|
||||
기존 계정 인증을 요구한다.
|
||||
- 로그인한 사용자가 설정 화면에서 “Google 연결”: Client-Initiated Account
|
||||
Linking URL을 만들어 Keycloak로 redirect한다.
|
||||
|
||||
`createAccountLinkUrl`은 현재 token의 `session_state`, `azp`(issued-for),
|
||||
provider와 nonce를 SHA-256 서명 재료로 사용한다. SPA는 연결 성공 후
|
||||
Account Console 또는 별도 backend read model을 통해 연결 상태를 새로
|
||||
조회해야 하며 email만 보고 “연결됨”을 표시하면 안 된다.
|
||||
|
||||
Unlink는 사용자가 다른 로그인 수단을 갖고 있는지 먼저 안내하고, Keycloak이
|
||||
마지막 federated identity 제거를 거부하면 해당 오류를 그대로 성공처럼
|
||||
처리하지 않는다. production First Broker Login에는 자동 기존-user linking을
|
||||
넣지 않는다.
|
||||
@@ -1,30 +0,0 @@
|
||||
# AP1 refresh rotation and logout
|
||||
|
||||
Realm 실행 profile:
|
||||
|
||||
- Access Token Lifespan: 300초
|
||||
- Revoke Refresh Token: 활성화
|
||||
- Refresh Token Max Reuse: 0
|
||||
|
||||
`e2e/pattern1.mjs`는 token 원문을 출력하지 않고 다음 순서를 실행한다.
|
||||
|
||||
1. browser Authorization Code + PKCE 로그인으로 AT₁/RT₁/ID Token을 받는다.
|
||||
2. `signoutRedirect()`가 `id_token_hint`를 포함한 Keycloak logout endpoint를
|
||||
호출하는지 확인한다.
|
||||
3. logout 이후 새 authorization 요청에서 로그인 화면이 다시 필요한지
|
||||
확인한다.
|
||||
4. 새 RT₁으로 refresh하여 AT₂/RT₂를 받고 RT₂가 RT₁과 다른지 확인한다.
|
||||
5. 이미 소비된 RT₁을 재사용해 성공하지 않는지 확인한다.
|
||||
6. RT₁ 재사용 뒤 RT₂와 realm session 상태가 어떤 결과를 내는지 status로
|
||||
기록한다. 이 결과를 사전에 family invalidation이라고 단정하지 않는다.
|
||||
7. refresh token을 revoke한 뒤 같은 refresh token의 재사용은 실패하지만,
|
||||
이미 발급된 self-contained access JWT는 `exp` 전까지 Resource Server에서
|
||||
계속 `200`인 stateless 함정을 확인한다.
|
||||
|
||||
logout은 브라우저 SSO session을 종료하는 흐름이고 token revocation은 특정
|
||||
token grant를 폐기하는 흐름이다. 둘은 목적과 endpoint가 다르다.
|
||||
|
||||
즉시 access 차단이 필요한 시스템이라면 짧은 access token TTL 외에
|
||||
introspection, reference token 또는 별도 deny-list 같은 stateful 검증을
|
||||
검토해야 한다. 이 AP1 구현은 JWT의 stateless 검증 특성을 의도적으로
|
||||
유지한다.
|
||||
@@ -1,29 +0,0 @@
|
||||
# AP1 token storage trade-off
|
||||
|
||||
AP1에서는 `access_token`, `refresh_token`, `id_token`을
|
||||
`oidc-client-ts`의 명시적인 `InMemoryWebStorage`에만 보관한다.
|
||||
`localStorage`와 `sessionStorage`에는 OAuth token을 저장하지 않는다.
|
||||
|
||||
full-page authorization redirect를 생존해야 하는 일회성 transaction
|
||||
state와 PKCE verifier만 `sessionStorage`를 사용한다. callback 성공 후
|
||||
라이브러리가 해당 transaction state를 제거한다.
|
||||
|
||||
| 저장 위치 | reload 생존 | JavaScript 접근 | AP1 선택 |
|
||||
|---|---:|---:|---:|
|
||||
| 메모리 | 아니요 | 실행 중 가능 | 사용 |
|
||||
| `sessionStorage` | 같은 탭에서 가능 | 가능 | token 저장 금지 |
|
||||
| `localStorage` | 예 | 가능 | token 저장 금지 |
|
||||
| HttpOnly cookie | 가능 | raw token 접근 불가 | AP2/AP3의 서버 소유 경계 |
|
||||
|
||||
메모리 저장은 XSS를 제거하지 않는다. 악성 스크립트가 실행 중 `fetch`를
|
||||
후킹하면 SPA가 붙이는 `Authorization: Bearer ...` 헤더를 관찰할 수 있다.
|
||||
다만 persistent storage를 사용하지 않으므로 reload 이후 탈취 가능한 token
|
||||
복사본이 남지 않는다.
|
||||
|
||||
`e2e/pattern1.mjs`는 다음 두 조건을 동시에 검증한다.
|
||||
|
||||
1. access token이 Web Storage 어디에도 존재하지 않는다.
|
||||
2. 실행 중 fetch hook은 Bearer token을 관찰할 수 있다.
|
||||
|
||||
따라서 결론은 “메모리면 XSS에 안전”이 아니라 “영속 탈취 범위를 줄이지만
|
||||
실행 중 XSS에는 여전히 노출”이다.
|
||||
@@ -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,34 @@
|
||||
수집 시각: 2026-09-03 15:01:30 KST
|
||||
대상: https://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [1] 호스트 nginx 가 주입하는 헤더 ===
|
||||
3: server 192.168.122.11:80;
|
||||
4: server 192.168.122.12:80;
|
||||
8: listen 80 default_server;
|
||||
14: listen 443 ssl default_server;
|
||||
26: proxy_set_header Host $host;
|
||||
27: proxy_set_header X-Forwarded-Host $host;
|
||||
28: proxy_set_header X-Forwarded-Proto http;
|
||||
29: proxy_set_header X-Forwarded-Port 80;
|
||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||
31: proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
=== [2] Traefik entryPoint 인자 (forwardedHeaders 부재 확인) ===
|
||||
["--entryPoints.metrics.address=:9100/tcp"
|
||||
"--entryPoints.traefik.address=:8080/tcp"
|
||||
"--entryPoints.web.address=:8000/tcp"
|
||||
"--entryPoints.websecure.address=:8443/tcp"
|
||||
"--metrics.prometheus.entrypoint=metrics"
|
||||
"--entryPoints.websecure.http.tls=true"
|
||||
→ forwardedHeaders.trustedIPs 인자가 없음 = 기본값(신뢰 안 함)
|
||||
|
||||
=== [3] Traefik 파드 수와 위치 ===
|
||||
traefik-59b7647586-ftwf8 10.42.0.8 kc-lab-1
|
||||
|
||||
=== [4] traefik Service externalTrafficPolicy ===
|
||||
Cluster
|
||||
→ Cluster = svclb 가 SNAT 하여 클라이언트 IP 소실
|
||||
|
||||
=== [5] 앱 파드의 스위치 상태 ===
|
||||
SERVER_PORT=8081
|
||||
SERVER_FORWARD_HEADERS_STRATEGY=none
|
||||
@@ -0,0 +1,87 @@
|
||||
수집 시각: 2026-09-03 15:02:27 KST
|
||||
|
||||
=== [A] 정상 경로 — 브라우저와 같은 요청 ===
|
||||
명령: curl -s https://app1.hyeonworks.com/api/echo
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.1.3
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [B] 대조 실험 1 — nginx 우회, 헤더 없이 Traefik 직접 ===
|
||||
명령: curl http://192.168.122.11/api/echo -H 'Host: app1.hyeonworks.com' (test-server 에서)
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.0.9
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [C] 대조 실험 2 — nginx 우회, 올바른 헤더를 명시해서 ===
|
||||
명령: 위와 동일 + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' -H 'X-Forwarded-For: 203.0.113.7'
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.0.1
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.1.3
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ [C] 에서 https/443/203.0.113.7 을 명시했음에도 http/80/10.42.0.1 이 도달했다.
|
||||
→ Traefik 이 들어온 X-Forwarded-* 를 신뢰하지 않고 재작성한다는 독립적 증거.
|
||||
|
||||
=== [D] 위조 테스트 — 클라이언트가 직접 헤더 주입 ===
|
||||
명령: curl https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||
x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.1.0
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 10.42.1.0
|
||||
x-forwarded-server traefik-59b7647586-ftwf8
|
||||
--- 앱이 해석한 값
|
||||
scheme http
|
||||
secure False
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 80
|
||||
remoteAddr 10.42.0.8
|
||||
localAddr 10.42.0.9
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ evil.example.com 과 1.2.3.4 가 도달하지 않았다 = 신뢰 경계는 작동.
|
||||
|
||||
=== [E] 파드 분배 8회 ===
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||
|
||||
=== [F] HTTP → HTTPS 리다이렉트 ===
|
||||
status=301 location=https://app1.hyeonworks.com/api/echo
|
||||
@@ -0,0 +1,55 @@
|
||||
수집 시각: 2026-09-03 15:32:04 KST
|
||||
단계: A(nginx) + B(Traefik) + C(앱) 모두 적용 후
|
||||
|
||||
=== [1] nginx 가 보내는 값 ===
|
||||
28: proxy_set_header X-Forwarded-Proto https;
|
||||
29: proxy_set_header X-Forwarded-Port 443;
|
||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||
31: proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
=== [2] Traefik entryPoint 인자 ===
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||
|
||||
=== [3] 앱 스위치 ===
|
||||
SERVER_FORWARD_HEADERS_STRATEGY=native
|
||||
|
||||
=== [4] 최종 측정 ===
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 100.123.124.30
|
||||
x-forwarded-server traefik-697889c85-g7xpp
|
||||
--- 앱이 해석한 값
|
||||
scheme https
|
||||
secure True
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
localAddr 10.42.0.10
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [5] 위조 테스트 — 클라이언트가 http/evil/1.2.3.4 를 주입 ===
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 100.123.124.30
|
||||
x-forwarded-server traefik-697889c85-g7xpp
|
||||
--- 앱이 해석한 값
|
||||
scheme https
|
||||
secure True
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
localAddr 10.42.1.6
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
|
||||
★ 주입값이 하나도 반영되지 않았다. nginx 의 $remote_addr 덮어쓰기가 방어한다.
|
||||
|
||||
=== [6] 파드 분배 6회 ===
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||
@@ -0,0 +1,48 @@
|
||||
수집 시각: 2026-09-03 16:16:48 KST
|
||||
주제: 프록시 우회 경로 차단 (NetworkPolicy)
|
||||
|
||||
=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 ===
|
||||
명령: kubectl run ... -- curl http://echo:8081/api/echo \
|
||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||
|
||||
scheme https
|
||||
secure True
|
||||
serverName evil.example.com ← 위조 성공
|
||||
remoteAddr 1.2.3.4 ← 위조 성공
|
||||
requestUrl https://evil.example.com/api/echo
|
||||
|
||||
★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다.
|
||||
trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문.
|
||||
|
||||
=== [2] 적용한 것 ===
|
||||
deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
||||
|
||||
deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용
|
||||
[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}]
|
||||
|
||||
=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) ===
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-forwarded-host app1.hyeonworks.com
|
||||
x-real-ip 100.123.124.30
|
||||
x-forwarded-server traefik-5d6fcf895-wpfhr
|
||||
--- 앱이 해석한 값
|
||||
scheme https
|
||||
secure True
|
||||
serverName app1.hyeonworks.com
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
localAddr 10.42.0.14
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
|
||||
=== [4] 차단 후 — 우회 시도 ===
|
||||
HTTP 000 / curl exit 7
|
||||
HTTP 000 / curl exit 7
|
||||
|
||||
★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다.
|
||||
|
||||
=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) ===
|
||||
echo-54dbd94986-8jmdb 1/1 Running restarts=0
|
||||
echo-54dbd94986-lfltk 1/1 Running restarts=0
|
||||
@@ -0,0 +1,199 @@
|
||||
# 증거 — 2홉 프록시 헤더 계약 (수정 전 상태)
|
||||
|
||||
`docs/two-hop-proxy-header-contract.md`의 진단을 뒷받침하는 원자료.
|
||||
**모두 수정 전 상태에서 수집**했으며, 수정 후 재수집하여 대조한다.
|
||||
|
||||
수집 시각: 2026-09-03 15:01~15:03 KST
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `01-environment.txt` | 수정 전 세 계층의 설정 스냅샷 |
|
||||
| `02-measurements.txt` | 수정 전 측정 · 대조 실험 · 위조 테스트 · 분배 |
|
||||
| `stage-a-nginx-fixed.png` | A 단계 브라우저 화면 |
|
||||
| `stage-b-traefik-trusts.png` | B 단계 브라우저 화면 |
|
||||
| `stage-c-resolved.png` | C 단계 브라우저 화면 |
|
||||
| `04-after-fix.txt` | 수정 후 측정 · 위조 테스트 · 분배 |
|
||||
|
||||
---
|
||||
|
||||
## 확인된 문제는 둘이다
|
||||
|
||||
최초 진단은 "Traefik이 덮어쓴다" 하나였으나, 증거 수집 과정에서
|
||||
**독립된 원인이 두 개**임이 드러났다.
|
||||
|
||||
### 문제 1 — nginx가 애초에 틀린 값을 보낸다
|
||||
|
||||
`01-environment.txt`
|
||||
|
||||
```
|
||||
26: proxy_set_header Host $host;
|
||||
27: proxy_set_header X-Forwarded-Host $host;
|
||||
28: proxy_set_header X-Forwarded-Proto http; ← https 여야 한다
|
||||
29: proxy_set_header X-Forwarded-Port 80; ← 443 이어야 한다
|
||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||
31: proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
`listen 443 ssl` 서버 블록 안인데 `X-Forwarded-Proto`가 `http`다.
|
||||
TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.
|
||||
|
||||
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 **이 두 줄을 함께 바꾸지
|
||||
않아 남은 값**이다. 설정 자체는 문법 오류가 없으므로 `nginx -t`도 통과하고,
|
||||
**아무 경고 없이 잘못된 값이 전파된다.**
|
||||
|
||||
### 문제 2 — Traefik이 올바른 값이 와도 덮어쓴다
|
||||
|
||||
`02-measurements.txt`의 **대조 실험 [C]** 가 이를 독립적으로 증명한다.
|
||||
nginx를 우회해 Traefik에 직접 요청하면서 올바른 헤더를 명시했다.
|
||||
|
||||
```
|
||||
보낸 것 : X-Forwarded-Proto: https
|
||||
X-Forwarded-Port: 443
|
||||
X-Forwarded-For: 203.0.113.7
|
||||
|
||||
도달한 것: x-forwarded-proto http
|
||||
x-forwarded-port 80
|
||||
x-forwarded-for 10.42.0.1
|
||||
```
|
||||
|
||||
**세 값 모두 재작성됐다.** Traefik entryPoint에
|
||||
`forwardedHeaders.trustedIPs`가 설정되지 않아 들어온 헤더를 신뢰하지 않는다.
|
||||
|
||||
`01-environment.txt`의 Traefik 인자 목록에 `forwardedHeaders` 관련 항목이
|
||||
하나도 없는 것이 그 근거다.
|
||||
|
||||
**문제 1만 고쳐서는 해결되지 않는다.** 두 원인이 직렬로 걸려 있다.
|
||||
|
||||
---
|
||||
|
||||
## 브라우저 증거
|
||||
|
||||
스크린샷은 모두 **브라우저가 `/api/echo` 응답을 렌더링한 실제 화면**이다.
|
||||
앱이 정렬된 JSON을 내보내도록 `spring.jackson.serialization.indent-output`을
|
||||
켜두었으므로 브라우저의 JSON 뷰어 설정과 무관하게 동일하게 읽힌다.
|
||||
|
||||
세 장은 **같은 요청을 세 가지 설정 상태에서** 찍은 것이다.
|
||||
|
||||
| 파일 | 켜진 스위치 | 화면에서 확인할 것 |
|
||||
|---|---|---|
|
||||
| `stage-a-nginx-fixed.png` | nginx 만 | `x-forwarded-proto: http` — Traefik 이 덮어씀 |
|
||||
| `stage-b-traefik-trusts.png` | nginx + Traefik | **헤더는 `https`인데 `scheme: http`** |
|
||||
| `stage-c-resolved.png` | 셋 다 | `scheme: https`, `secure: true` |
|
||||
|
||||
**`stage-b`가 가장 중요한 한 장이다.** `x-forwarded-proto: https`가 앱에
|
||||
도착해 있는데도 `scheme: http`, `secure: false`, `requestUrl: http://...`다.
|
||||
**헤더가 도착하는 것과 앱이 그것을 읽는 것은 다른 문제**임을 한 화면이
|
||||
보여준다.
|
||||
|
||||
## 정상으로 확인된 것
|
||||
|
||||
증거 수집에서 **문제가 아니라고 확인된 항목**도 함께 남긴다.
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| TLS 종료 | 정상. 실인증서, `isSecureContext=true` |
|
||||
| `X-Forwarded-Host` | 유지됨 — Traefik이 이것만은 덮어쓰지 않는다 |
|
||||
| 위조 차단 | 클라이언트가 넣은 `evil.example.com`, `1.2.3.4`가 앱에 도달하지 않음 |
|
||||
| 파드 분배 | 8회 요청이 두 파드에 정확히 번갈아 도달 |
|
||||
| HTTP 리다이렉트 | `301 → https://app1.hyeonworks.com/api/echo` |
|
||||
|
||||
**위조가 차단되는 것은 nginx가 막아서가 아니라 Traefik이 전부 덮어쓰기
|
||||
때문**이다. 문제 2를 고치면 이 방어가 nginx의 `$remote_addr` 덮어쓰기로
|
||||
옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 재수집 방법
|
||||
|
||||
```bash
|
||||
# 터미널 증거
|
||||
./deploy/lab/scripts/measure-proxy-headers.sh
|
||||
|
||||
# 개별 확인
|
||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
||||
|
||||
# 대조 실험 (test-server 에서, nginx 우회)
|
||||
curl -s http://192.168.122.11/api/echo \
|
||||
-H 'Host: app1.hyeonworks.com' \
|
||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' \
|
||||
-H 'X-Forwarded-For: 203.0.113.7' | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 후 (2026-09-03 15:32 KST)
|
||||
|
||||
세 스위치를 순서대로 켜며 각 단계를 측정했다. 상세 절차는
|
||||
`docs/two-hop-proxy-header-contract.md` 9~11절.
|
||||
|
||||
| 파일 | 단계 |
|
||||
|---|---|
|
||||
| `stage-a-nginx-fixed.png` | A — nginx 만 고침 |
|
||||
| `stage-b-traefik-trusts.png` | B — Traefik `trustedIPs` 추가 |
|
||||
| `stage-c-resolved.png` | C — 앱 `strategy=native` |
|
||||
| `04-after-fix.txt` | 최종 측정 · 위조 테스트 · 분배 |
|
||||
|
||||
스크린샷은 브라우저가 `/api/echo` 응답을 렌더링한 **실제 화면**이다.
|
||||
|
||||
### 단계별 결과
|
||||
|
||||
| 항목 | 최초 | A | B | C |
|
||||
|---|---|---|---|---|
|
||||
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
||||
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
||||
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
||||
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
||||
|
||||
**A 이후 아무 변화가 없는 것**이 Traefik 덮어쓰기의 증거이고,
|
||||
**B 이후 헤더는 살아났으나 앱 해석은 그대로인 것**이 2번과 3번 스위치가
|
||||
다른 일을 한다는 증거다.
|
||||
|
||||
### 위조 차단 재확인
|
||||
|
||||
`04-after-fix.txt` [5]. 클라이언트가 `X-Forwarded-Proto: http`,
|
||||
`X-Forwarded-Host: evil.example.com`, `X-Forwarded-For: 1.2.3.4`를 주입했으나
|
||||
**하나도 반영되지 않았다.**
|
||||
|
||||
**방어 주체가 바뀌었다.** 수정 전에는 Traefik이 전부 덮어써서 막았고,
|
||||
수정 후에는 nginx의 `$remote_addr`가 막는다. 그래서 nginx에서
|
||||
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 안 된다.
|
||||
|
||||
### 겪은 함정
|
||||
|
||||
`kubectl rollout status`가 완료를 알려도 **helm-controller의 Job이 차트를
|
||||
업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면
|
||||
옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server`
|
||||
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 프록시 우회 차단 (2026-09-03 16:16 KST)
|
||||
|
||||
`05-networkpolicy.txt`
|
||||
|
||||
헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다.
|
||||
|
||||
**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면
|
||||
`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`로 **위조가 성립했다.**
|
||||
|
||||
**적용한 것**
|
||||
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) |
|
||||
| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) |
|
||||
|
||||
**차단 후**
|
||||
|
||||
```
|
||||
정상 경로 scheme=https, remoteAddr=100.123.124.30 동작
|
||||
우회 시도 HTTP 000 / curl exit 7 연결 거부
|
||||
파드 상태 1/1 Running, restarts=0 probe 정상
|
||||
```
|
||||
|
||||
`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라
|
||||
**TCP 연결 자체가 성립하지 않았다**는 뜻이다.
|
||||
|
||||
`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면
|
||||
probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소
|
||||
(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 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`이 필요한 이유 |
|
||||
@@ -0,0 +1,291 @@
|
||||
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
|
||||
|
||||
공개 기록에 등록된 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층 머리에 있다.
|
||||
|
||||
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
|
||||
있고, 순서가 한 곳 뒤집혀 있다.**
|
||||
|
||||
| # | 질문 | 게시 | 로드맵 커버 |
|
||||
|---|---|---|---|
|
||||
| Q1 | [서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) | 2026.08.29 | **부분** |
|
||||
| Q2 | [Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention) | 2026.08.26 | **부분** |
|
||||
| Q3 | [BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) | 2026.08.30 | **부분** |
|
||||
| Q4 | [Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) | 2026.08.31 | **없음** |
|
||||
|
||||
---
|
||||
|
||||
## 발견한 구조적 문제
|
||||
|
||||
### 1. 순서가 뒤집혀 있다
|
||||
|
||||
Q2가 명시한다.
|
||||
|
||||
> 이 경쟁은 **저장소를 공유한 뒤에야 재현**되기 때문에 저장소 결정을 하고
|
||||
> 나서 해당 문제를 이어서 풀어보자.
|
||||
|
||||
즉 **Q3(저장소 결정) → Q2(경쟁 재현)** 순서다. 그런데 로드맵은
|
||||
`refresh-token-concurrency`를 `redis-app-session-store`보다 **앞**에 두었다.
|
||||
|
||||
**Q2를 먼저 시도하면 재현 자체가 불가능하다.** 저장소가 process-local이면
|
||||
두 replica가 같은 refresh token 항목을 보지 않기 때문이다.
|
||||
|
||||
→ 로드맵 순서를 교정한다.
|
||||
|
||||
### 2. Session과 Authorized Client는 조회 키가 다르다
|
||||
|
||||
Q3의 핵심이며 로드맵에 이 구분이 없었다.
|
||||
|
||||
| 상태 | 조회 키 | 저장 위치(현재) |
|
||||
|---|---|---|
|
||||
| Application Session | **session ID** | 서블릿 컨테이너 in-memory |
|
||||
| OAuth2AuthorizedClient | **client registration 이름 + principal name** | 자동구성 in-memory |
|
||||
|
||||
**`session ID`가 조회 키에 없다.** 그래서 같은 사용자가 두 브라우저에서
|
||||
로그인하면 **동일한 authorized client 항목을 공유**한다.
|
||||
|
||||
Q1의 제약이 이를 그대로 지적한다.
|
||||
|
||||
> 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유
|
||||
> 저장소로 변경하는 것만으로는 **충분하지 않다.**
|
||||
|
||||
→ 실험을 "Redis 도입" 하나로 뭉뚱그리면 안 된다. **두 저장소를 각각 설계하고
|
||||
각각 검증해야 한다.**
|
||||
|
||||
### 3. 이미 해결한 문제가 질문에도 있다
|
||||
|
||||
Q1의 제약:
|
||||
|
||||
> Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록
|
||||
> **network에서 강제된 상태가 아니다.**
|
||||
|
||||
이는 2홉 헤더 실험에서 마주친 **프록시 우회 경로**와 같은 문제이며,
|
||||
NetworkPolicy로 닫는 방법을 이미 확립했다
|
||||
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 11절).
|
||||
|
||||
→ Q1에 답할 때 그 패턴을 그대로 재사용한다.
|
||||
|
||||
---
|
||||
|
||||
## Q1. 다중 인스턴스 운영
|
||||
|
||||
**질문이 요구하는 검증 5단계**
|
||||
|
||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||
|---|---|---|---|
|
||||
| 1 | 한쪽에서 로그인 후 **다른 인스턴스로 요청 시 200 유지** | 가능 | 없음 |
|
||||
| 2 | 한 인스턴스 재시작 후 **같은 session cookie로 상태 유지** | 가능 | 없음 |
|
||||
| 3 | 같은 사용자 두 브라우저 → **authorized client 덮어쓰는가** | 가능 | **없음** |
|
||||
| 4 | 한쪽 logout 후 **다른 쪽 요청** | 가능 | 부분 (백채널 로그아웃) |
|
||||
| 5 | **session 만료 ≠ token 만료** 각 경우의 응답과 화면 | 가능 | **없음** |
|
||||
|
||||
**실험대 준비 상태** — BFF를 2 replica로 띄우면 전부 재현된다. 호스트 nginx의
|
||||
`ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은 구성에서 된다.
|
||||
|
||||
**추가로 필요한 것**
|
||||
|
||||
- BFF 이미지 (아직 `bff/` 디렉터리에 소스 없음)
|
||||
- 로그아웃 전파를 관찰할 두 번째 앱 (`app2.hyeonworks.com` 이름은 확보)
|
||||
|
||||
**3번이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는 항목이고,
|
||||
조회 키가 다르다는 사실의 실증이다.
|
||||
|
||||
---
|
||||
|
||||
## Q2. Refresh Token Rotation 경쟁
|
||||
|
||||
**질문이 요구하는 검증 5단계**
|
||||
|
||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||
|---|---|---|---|
|
||||
| 1 | replica 두 대에서 **access token 만료 직후 동시 요청** | 가능 | 있음 |
|
||||
| 2 | **이긴 쪽/지는 쪽 응답** 각각 기록 | 가능 | 부분 |
|
||||
| 3 | 지는 쪽이 **저장된 새 token으로 재시도해 성공하는가** | 가능 | **없음** |
|
||||
| 4 | **지는 쪽 사용자 화면**에 무엇이 보이는가 | 가능 | **없음** |
|
||||
| 5 | **lock 유무를 같은 입력으로 비교** (실패율·지연) | 가능 | **없음** |
|
||||
|
||||
**5번이 결론을 내는 기준이다.**
|
||||
|
||||
> 실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
||||
|
||||
로드맵에 없던 항목인데, **이것이 없으면 질문에 답할 수 없다.**
|
||||
|
||||
**제약을 지켜야 한다**
|
||||
|
||||
- rotation + 재사용 0회는 **전제로 고정**한다. 바꾸지 않고 답한다
|
||||
- 이미 발급된 access token은 만료 전까지 통하므로 **재현은 access token 만료
|
||||
직후에 맞춰 실행**한다. 그렇지 않으면 실패가 화면에 보이지 않는다
|
||||
|
||||
**선행 조건** — Q3의 저장소 공유가 먼저다.
|
||||
|
||||
---
|
||||
|
||||
## Q3. BFF 저장소 결정
|
||||
|
||||
**질문이 요구하는 검증 5단계**
|
||||
|
||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||
|---|---|---|---|
|
||||
| 1 | 인스턴스 두 대에서 **로그인 유지와 재시작 복구** | 가능 | 부분 |
|
||||
| 2 | 저장소를 열어 **refresh token이 평문인가** | 가능 | **없음** |
|
||||
| 3 | **session TTL ≠ token 만료** 그 순간의 응답과 화면 | 가능 | **없음** |
|
||||
| 4 | logout 뒤 **두 store에 잔여 항목이 없는가** | 가능 | 부분 |
|
||||
| 5 | **저장소를 끊은 상태**에서 로그인·API 호출 오류 | 가능 | 있음 |
|
||||
|
||||
**로드맵에 없던 큰 항목 — 후보 비교**
|
||||
|
||||
질문은 "Redis로 간다"가 아니라 **"Redis와 JDBC 중 무엇이 이 접근 패턴에
|
||||
맞는가"** 를 묻는다.
|
||||
|
||||
> 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
||||
|
||||
이 실험대에는 PostgreSQL이 이미 있으므로 **JDBC 후보를 같은 조건에서 비교할
|
||||
수 있다.** Redis만 붙이면 질문의 절반만 답하는 셈이다.
|
||||
|
||||
**2번(평문 확인)의 실행 방법**
|
||||
|
||||
```bash
|
||||
kubectl -n <ns> exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*'
|
||||
kubectl -n <ns> exec -it deploy/redis -- redis-cli GET <key>
|
||||
```
|
||||
|
||||
저장소를 직접 열어 refresh token이 그대로 읽히는지 본다. 읽힌다면
|
||||
암호화 설계가 필요하고, 그 key 교체 절차는 별도 과제다.
|
||||
|
||||
---
|
||||
|
||||
## Q4. Edge 인가 범위 — 로드맵에 전혀 없다
|
||||
|
||||
이 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 **AP4의 인가 범위
|
||||
질문을 빠뜨렸다.**
|
||||
|
||||
**질문이 요구하는 검증**
|
||||
|
||||
| 검증 | 실험대 가능 |
|
||||
|---|---|
|
||||
| role을 헤더에 담고 **다중 값 구분자·escaping** 확인 | 가능 |
|
||||
| **헤더 크기 상한** 초과 시 proxy가 자르는가 요청이 거부되는가 | 가능 |
|
||||
| role 변경 후 **몇 번째 요청부터 반영되는가** | 가능 |
|
||||
| upstream이 헤더 존재만 보는가 값과 service identity까지 보는가 | 가능 |
|
||||
|
||||
**이 실험대에서 특히 잘 맞는 이유**
|
||||
|
||||
nginx의 헤더 처리 특성을 이미 실측했다. 질문이 지적한
|
||||
|
||||
> Nginx는 client가 보낸 동명 헤더를 merge하지 않고 **덮어쓴다.**
|
||||
|
||||
는 2홉 헤더 실험에서 `proxy_set_header X-Forwarded-For $remote_addr`로
|
||||
확인한 그 동작이다. **`X-Auth-Request-*`도 같은 규칙을 따르는지**를 같은
|
||||
방법으로 검증할 수 있다.
|
||||
|
||||
그리고 질문의 제약
|
||||
|
||||
> internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이
|
||||
> 검사를 **공통 경계로 옮겨야** 된다.
|
||||
|
||||
는 코드 변경이므로 `backend/`에서 진행한다.
|
||||
|
||||
---
|
||||
|
||||
## 교정된 실험 순서
|
||||
|
||||
기존 로드맵의 순서를 질문의 의존 관계에 맞춰 조정한다.
|
||||
|
||||
```
|
||||
✅ 환경 구축
|
||||
✅ 2홉 프록시 헤더 계약
|
||||
──────────────────────────────────────────────────────────
|
||||
1. Keycloak 멀티노드 클러스터 형성 (선행 인프라)
|
||||
2. persistent vs volatile 세션 (A층)
|
||||
3. BFF 저장소 결정 → Q3 ★ Q2 의 선행 조건
|
||||
4. 다중 인스턴스 운영 → Q1
|
||||
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
||||
6. Edge 인가 범위 → Q4 ← 새로 추가
|
||||
7. 장애 주입과 복구 (전 항목 공통)
|
||||
```
|
||||
|
||||
**바뀐 점**
|
||||
|
||||
- `refresh-token-concurrency`가 `redis-app-session-store` **뒤로** 이동
|
||||
- 저장소 결정이 **Redis 도입**이 아니라 **Redis vs JDBC 비교**로 확장
|
||||
- **Edge 인가 범위(Q4)** 신규 추가
|
||||
|
||||
## 브랜치 매핑
|
||||
|
||||
| 실험 | 브랜치 | 상태 |
|
||||
|---|---|---|
|
||||
| 멀티노드 클러스터 | `feature/keycloak-multinode-cluster-jdbc-ping` | 존재 |
|
||||
| persistent vs volatile | `feature/keycloak-persistent-vs-volatile-sessions` | 존재 |
|
||||
| BFF 저장소 (Q3) | `feature/keycloak-redis-app-session-store` | 존재 — **범위 확장 필요** |
|
||||
| 다중 인스턴스 (Q1) | — | **없음** |
|
||||
| refresh 경쟁 (Q2) | `feature/keycloak-refresh-token-concurrency` | 존재 |
|
||||
| Edge 인가 (Q4) | — | **없음** |
|
||||
| 장애 주입 | `feature/keycloak-failure-injection-recovery` | 존재 |
|
||||
|
||||
**두 개를 새로 만들어야 한다.**
|
||||
|
||||
```bash
|
||||
git checkout develop-keycloak-session-store
|
||||
git checkout -b feature/keycloak-multi-instance-session-operation
|
||||
git checkout -b feature/keycloak-edge-authorization-scope
|
||||
```
|
||||
|
||||
## 공통 선행 조건 — BFF 구현은 이미 있다
|
||||
|
||||
세 질문(Q1·Q2·Q3)이 모두 **BFF를 2 replica로 띄우는 것**을 전제한다.
|
||||
`develop-keycloak-session-store`의 `bff/`에는 빌드 산출물만 있지만,
|
||||
**`develop-keycloak-pattern3`에 구현이 완성되어 있다.**
|
||||
|
||||
```
|
||||
bff/Dockerfile
|
||||
bff/pom.xml
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/
|
||||
├ BffApplication.java
|
||||
├ BffController.java
|
||||
├ CsrfController.java
|
||||
├ SecurityConfig.java
|
||||
└ SpaCsrfTokenRequestHandler.java
|
||||
bff/src/main/resources/application.yml
|
||||
bff/src/main/resources/static/{index.html,app.js}
|
||||
bff/src/test/java/.../BffControllerTest.java
|
||||
```
|
||||
|
||||
→ 새로 구현할 필요가 없다. **AP3 브랜치에서 이 실험대로 가져온다.**
|
||||
|
||||
```bash
|
||||
git checkout develop-keycloak-session-store
|
||||
git checkout develop-keycloak-pattern3 -- bff/
|
||||
```
|
||||
|
||||
가져온 뒤 확인할 것 — 질문들이 지목한 부분이 코드에 그대로 있는지.
|
||||
|
||||
| 확인 | 어디를 볼 것인가 |
|
||||
|---|---|
|
||||
| Session 저장소가 in-memory 자동구성인가 | `SecurityConfig.java`, `application.yml`에 Spring Session 설정 부재 |
|
||||
| `OAuth2AuthorizedClientService`가 in-memory인가 | Bean 정의 부재 → 자동구성 결과 확인 필요 |
|
||||
| authorized client 조회에 session ID가 없는가 | Spring Security 기본 계약 |
|
||||
|
||||
Q3가 "어떤 구현체가 실제로 쓰이는지는 자동구성 결과까지 확인해야 정확히
|
||||
알 수 있다"고 남긴 미지수를, **기동 후 Bean을 실제로 조회해서** 확정할 수 있다.
|
||||
|
||||
```bash
|
||||
kubectl -n <ns> exec deploy/bff -- \
|
||||
curl -s localhost:8082/actuator/beans | grep -i authorizedClientService
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
| 문서 | 관계 |
|
||||
|---|---|
|
||||
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 문서가 그 순서를 교정한다 |
|
||||
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | Q1의 우회 경로 제약, Q4의 헤더 덮어쓰기 근거 |
|
||||
| [`session-lab-operations.md`](session-lab-operations.md) | 실행 도구와 명령 |
|
||||
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | Q4가 되돌아가는 선택지(BFF)의 비교표 |
|
||||
@@ -1,15 +0,0 @@
|
||||
# Authorization Code + PKCE stages
|
||||
|
||||
1. SPA가 매 로그인마다 고엔트로피 `code_verifier`를 생성한다.
|
||||
2. SHA-256과 Base64URL로 `code_challenge`를 만든다.
|
||||
3. authorization request에는 challenge와 `S256`만 전송한다.
|
||||
4. redirect의 code와 저장해 둔 state를 대조한다.
|
||||
5. token request에 원래 verifier를 보내 code를 교환한다.
|
||||
6. verifier/state/code는 한 번 사용한 뒤 메모리에서 제거한다.
|
||||
|
||||
Keycloak client는 public client이며 implicit와 password grant를 끄고 S256을
|
||||
강제한다. PKCE는 악성 redirect endpoint가 code만 가로챘을 때의 교환을 막지만,
|
||||
SPA 실행 컨텍스트를 장악한 XSS 자체를 막지는 않는다.
|
||||
|
||||
`verify-pkce-flow-stages.sh`는 Web Crypto 단위 테스트, realm client 계약,
|
||||
authorization/token 요청의 필드를 함께 검사한다.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Refresh token rotation contract
|
||||
|
||||
Realm 설정은 refresh token revoke를 활성화하고 재사용 허용 횟수를 0으로 둔다.
|
||||
SPA는 새 token set을 받은 즉시 이전 refresh token을 폐기한다.
|
||||
|
||||
실제 AP1 E2E는 다음을 구분한다.
|
||||
|
||||
- RT1로 refresh하면 RT2가 새로 발급된다.
|
||||
- 이미 소비한 RT1 재사용은 성공하면 안 된다.
|
||||
- 재사용 탐지 후 RT2까지 무효화할지는 Keycloak realm 정책과 동시 요청
|
||||
상황에 따라 관찰한다.
|
||||
- refresh token revoke 이후 refresh는 실패한다.
|
||||
- 이미 발급된 stateless access JWT는 `exp`까지 유효할 수 있다.
|
||||
|
||||
`verify-refresh-token-rotation-contract.sh`는 realm과 E2E assertions의 계약을
|
||||
빠르게 확인한다. 실제 token 수명주기 재현은 `verify-pattern1.sh`가 수행한다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
||||
# 실험대 운영 — 도구 · 명령 · 훈련
|
||||
|
||||
실험을 돌리는 데 반복해서 쓰는 것들. 개념은
|
||||
[`session-lab-concepts.md`](session-lab-concepts.md), 계획은
|
||||
[`session-store-lab-roadmap.md`](session-store-lab-roadmap.md)에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 관측 도구
|
||||
|
||||
### htop
|
||||
|
||||
**설치** — 세 대 모두 배포판이 다르다.
|
||||
|
||||
```bash
|
||||
# lab host (Arch)
|
||||
sudo pacman -S htop
|
||||
|
||||
# 게스트 (Debian) — lab host 에서 한 번에
|
||||
for n in kc-lab-1 kc-lab-2; do
|
||||
ssh $n 'sudo apt-get update -qq && sudo apt-get install -y -qq htop'
|
||||
done
|
||||
```
|
||||
|
||||
**lab host에서 htop을 읽는 법 — VM은 프로세스로 보인다**
|
||||
|
||||
가장 중요한 관점이다. 게스트는 **호스트 입장에서 `qemu-system-x86_64`
|
||||
프로세스 하나**다. 그래서 VM의 메모리 사용량이 곧 그 프로세스의 RES다.
|
||||
|
||||
```
|
||||
pid=4677 RSS=3765MB qemu-system-x86 ← kc-lab-1 (할당 3584M)
|
||||
pid=4770 RSS=2670MB qemu-system-x86 ← kc-lab-2 (할당 2560M)
|
||||
```
|
||||
|
||||
**RSS가 할당량보다 조금 큰 이유**는 QEMU 자체의 에뮬레이션 오버헤드
|
||||
(장치 모델, 버퍼)가 더해지기 때문이다. 게스트가 터치한 페이지만큼만
|
||||
RSS로 잡히므로, 게스트가 메모리를 더 쓰면 RSS도 할당 상한까지 올라간다.
|
||||
|
||||
**htop 안에서 쓸 키**
|
||||
|
||||
| 키 | 동작 | 이 실험대에서 |
|
||||
|---|---|---|
|
||||
| `F5` | 트리 뷰 | `libvirtd` → `qemu-system` 계층 확인 |
|
||||
| `F4` | 필터 | `qemu`만 보기 |
|
||||
| `F6` | 정렬 기준 | `PERCENT_MEM`으로 정렬 |
|
||||
| `M` / `P` | 메모리/CPU 정렬 | |
|
||||
| `u` | 사용자 필터 | `libvirt-qemu`로 VM만 |
|
||||
| `H` | 스레드 숨김 | QEMU는 vCPU마다 스레드라 켜두면 지저분하다 |
|
||||
|
||||
**게스트 안에서 htop을 읽을 때** 보이는 것은 `k3s`(server 또는 agent),
|
||||
`containerd`, `containerd-shim`, 그리고 각 파드의 `java` 프로세스다.
|
||||
Java 힙 상한은 컨테이너 limit의 70%(`-XX:MaxRAMPercentage=70`)이므로
|
||||
512Mi limit이면 약 358Mi다.
|
||||
|
||||
### k9s
|
||||
|
||||
설치는 되어 있다. 별도 구성 없이 `~/.kube/config`를 읽는다.
|
||||
|
||||
```bash
|
||||
k9s
|
||||
```
|
||||
|
||||
| 키 | 동작 |
|
||||
|---|---|
|
||||
| `:` | 명령 모드 — `:pods` `:svc` `:ing` `:nodes` `:events` |
|
||||
| `0` | 전체 네임스페이스 |
|
||||
| `/` | 필터 |
|
||||
| `l` | 로그 |
|
||||
| `d` | describe |
|
||||
| `y` | YAML |
|
||||
| `s` | 파드 안 셸 |
|
||||
| `Ctrl+d` | 파드 삭제 ← **장애 주입에 씀** |
|
||||
| `esc` / `q` | 뒤로 / 종료 |
|
||||
|
||||
`~/.config/k9s/config.yaml`의 `refreshRate`를 2초로 낮추면 노드를 죽였을 때
|
||||
파드 재배치가 실시간으로 보인다.
|
||||
|
||||
### kubectl top
|
||||
|
||||
k3s가 metrics-server를 기본 배포하므로 바로 쓸 수 있다.
|
||||
|
||||
```bash
|
||||
kubectl top nodes
|
||||
kubectl -n header-lab top pods
|
||||
```
|
||||
|
||||
**htop과 보는 층이 다르다.**
|
||||
|
||||
| | 보는 것 |
|
||||
|---|---|
|
||||
| `htop` (lab host) | VM 프로세스 = 게스트 전체 |
|
||||
| `htop` (게스트) | 게스트 안의 프로세스 |
|
||||
| `kubectl top` | 파드·노드 단위, 클러스터 관점 |
|
||||
|
||||
### 상태 점검 스크립트
|
||||
|
||||
```bash
|
||||
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
||||
./deploy/lab/scripts/measure-proxy-headers.sh # 어디서든
|
||||
```
|
||||
|
||||
`verify-lab.sh`는 게스트·k3s·nginx·인증서·공개 진입점을 한 번에 확인하고
|
||||
`lab is healthy`를 출력한다. **`404`가 성공 신호**다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 자원 실측과 예산
|
||||
|
||||
**2026-09-03 기준, Keycloak 배포 전**
|
||||
|
||||
### 호스트 여유와 게스트 여유는 다르다
|
||||
|
||||
가장 오해하기 쉬운 지점이다. 호스트만 보면 절망적으로 보인다.
|
||||
|
||||
```
|
||||
lab host 총 7628MB · 사용 7189MB · 여유 439MB
|
||||
├ qemu #1 RSS 3765MB kc-lab-1 (할당 3584MB) → 상한 도달
|
||||
└ qemu #2 RSS 2633MB kc-lab-2 (할당 2560MB) → 상한 도달
|
||||
```
|
||||
|
||||
그런데 게스트 안을 보면 여유가 있다.
|
||||
|
||||
```
|
||||
kc-lab-1 총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
|
||||
kc-lab-2 총 2480MB · used 580 · buff/cache 1714 · available 1899MB
|
||||
─────────────────
|
||||
게스트 여유 합계 약 3.8GB
|
||||
```
|
||||
|
||||
**왜 이런가** — QEMU의 RSS는 게스트가 **터치한 페이지**만큼이다. 게스트가
|
||||
메모리를 페이지 캐시로 다 채우면 QEMU RSS도 할당 상한까지 올라간다.
|
||||
지금이 그 상태다.
|
||||
|
||||
**그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다.** 게스트 안의
|
||||
페이지 캐시가 밀려날 뿐이다. **QEMU RSS는 이미 천장이다.**
|
||||
|
||||
```
|
||||
확인 방법:
|
||||
ps -eo rss,args --sort=-rss | grep '[q]emu-system' # 호스트에서 본 VM
|
||||
ssh kc-lab-1 free -m # 게스트 안 실제
|
||||
kubectl top nodes # working set
|
||||
```
|
||||
|
||||
세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이다.
|
||||
|
||||
### 배포 예산
|
||||
|
||||
| 워크로드 | 예상 | 배치 |
|
||||
|---|---|---|
|
||||
| Keycloak × 2 | 각 700Mi | 노드당 1개 |
|
||||
| PostgreSQL | 300Mi | kc-lab-1 |
|
||||
| Redis | 100Mi | kc-lab-2 |
|
||||
| BFF × 2 | 각 400Mi | 노드당 1개 |
|
||||
| **합계** | **약 2600Mi** | |
|
||||
|
||||
**게스트 여유 3.8GB 중 2.6GB → 가능하다.** 다만 여기에
|
||||
Prometheus/Grafana(로드맵 10번 관측성)를 얹을 여유는 없다.
|
||||
|
||||
### 대응 — 비용이 없는 것부터
|
||||
|
||||
**1. 끝난 실험은 지운다**
|
||||
|
||||
```bash
|
||||
kubectl delete ns header-lab # 파드 2개 × 150Mi 회수
|
||||
```
|
||||
|
||||
증거는 `docs/evidence/`에 남아 있으므로 워크로드를 유지할 이유가 없다.
|
||||
|
||||
**2. Keycloak 힙을 명시적으로 제한한다**
|
||||
|
||||
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다.
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: JAVA_OPTS_KC_HEAP
|
||||
value: "-Xms256m -Xmx512m"
|
||||
resources:
|
||||
limits:
|
||||
memory: 768Mi
|
||||
```
|
||||
|
||||
**모든 워크로드에 `resources.limits`를 반드시 건다.** 안 걸면 한 파드가
|
||||
게스트 메모리를 다 먹고 다른 파드까지 OOMKilled된다.
|
||||
|
||||
**3. 실험을 순차로 돌린다 — 동시에 다 띄우지 않는다**
|
||||
|
||||
```
|
||||
A층(Keycloak + PostgreSQL) → 결과 기록 → 정리
|
||||
↓
|
||||
B층(BFF + Redis) → 결과 기록 → 정리
|
||||
↓
|
||||
관측성(Prometheus) → 필요할 때만
|
||||
```
|
||||
|
||||
절약책이 아니라 **정상적인 실험 운영 방식**이다. 동시에 띄우면 변수가
|
||||
섞여서 원인 분리가 어려워진다.
|
||||
|
||||
### swap은 쓰지 않는다
|
||||
|
||||
호스트에는 8GB swap이 있지만 **게스트에는 0MB이며, 그것이 맞다.**
|
||||
|
||||
| 이유 | |
|
||||
|---|---|
|
||||
| k3s/kubelet | 기본적으로 swap 을 거부한다 |
|
||||
| 성능 | 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다 |
|
||||
| **측정 오염** | 이 실험대는 **타이밍**(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다 |
|
||||
|
||||
### 근본 해결 — 메모리 증설
|
||||
|
||||
남은 실험이 10개이고 관측성까지 하려면 증설이 가장 확실하다.
|
||||
|
||||
```bash
|
||||
sudo pacman -S dmidecode
|
||||
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
|
||||
```
|
||||
|
||||
| 슬롯 상태 | 조치 |
|
||||
|---|---|
|
||||
| 2슬롯 중 1개만 사용 | 동일 규격 8GB 추가 → 16GB |
|
||||
| 온보드 8GB + 슬롯 1개 | 16GB 추가 → 24GB |
|
||||
| 2슬롯 모두 사용 | 8GB × 2 를 16GB × 2 로 교체 |
|
||||
|
||||
i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM을 쓰며 최대 용량은 보드마다
|
||||
다르므로 `Maximum Capacity` 값을 확인한다. **비용 대비 효과가 가장 크다** —
|
||||
증설하면 Prometheus·Grafana·BFF 2 replica를 동시에 띄우고도 남는다.
|
||||
|
||||
## 3. 자주 쓰는 명령
|
||||
|
||||
### VM (lab host, `LIBVIRT_DEFAULT_URI=qemu:///system`)
|
||||
|
||||
```bash
|
||||
virsh list --all # 상태
|
||||
virsh domstate kc-lab-1
|
||||
virsh domblklist kc-lab-1 # 붙은 디스크
|
||||
virsh net-dhcp-leases default # 게스트 IP
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 화면 (PNG 로 저장됨)
|
||||
virsh send-key kc-lab-1 --codeset linux KEY_ENTER
|
||||
|
||||
virsh destroy kc-lab-1 # 전원 강제 차단 = 노드 상실
|
||||
virsh start kc-lab-1 # 재기동
|
||||
virsh shutdown kc-lab-1 # ACPI 정상 종료
|
||||
```
|
||||
|
||||
**`destroy`는 파일을 지우지 않는다.** 전원 코드를 뽑는 것에 해당한다.
|
||||
정의와 디스크를 지우는 것은 `undefine`이다.
|
||||
|
||||
### 클러스터
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
kubectl get pods -A -o wide
|
||||
kubectl -n <ns> logs -f deployment/<name>
|
||||
kubectl -n <ns> describe pod <pod>
|
||||
kubectl -n <ns> rollout status deployment/<name>
|
||||
kubectl -n <ns> rollout restart deployment/<name>
|
||||
kubectl -n <ns> rollout undo deployment/<name> # 직전 버전으로
|
||||
|
||||
# 설정 스위치 껐다 켜기 — 실험의 기본 동작
|
||||
kubectl -n <ns> set env deployment/<name> KEY=VALUE
|
||||
|
||||
# 임시 파드로 클러스터 안에서 테스트
|
||||
kubectl -n <ns> run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
|
||||
curl -s http://<svc>:<port>/path
|
||||
```
|
||||
|
||||
### 이미지 반입
|
||||
|
||||
k3s는 containerd를 쓰고 레지스트리가 없다. **자체 빌드 이미지는 매번 이
|
||||
경로를 탄다.**
|
||||
|
||||
```bash
|
||||
# 워크스테이션에서
|
||||
docker build -t keycloak-pattern-api:lab backend
|
||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
|
||||
# 확인
|
||||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||||
```
|
||||
|
||||
**두 노드 모두**에 넣는다. 스케줄러가 어디에 배치할지 모른다.
|
||||
매니페스트는 `imagePullPolicy: Never`여야 한다.
|
||||
|
||||
`ctr`이 아니라 **`k3s ctr`** 이다. 시스템에 별도 `ctr`이 있으면 다른 소켓을
|
||||
보게 되어 "성공했는데 파드는 못 찾는" 상태가 된다.
|
||||
|
||||
### 저장소·브랜치
|
||||
|
||||
```bash
|
||||
# 워크스테이션 — 작성·커밋
|
||||
git add -A && git commit -m "..." && git push origin develop-keycloak-session-store
|
||||
|
||||
# lab host — 받기만 (읽기 전용으로 운용)
|
||||
cd ~/workspace/keycloak-pattern && git pull
|
||||
|
||||
# 실험별 브랜치 이동
|
||||
git checkout feature/keycloak-multinode-cluster-jdbc-ping
|
||||
```
|
||||
|
||||
**lab host의 저장소는 읽기 전용으로 쓴다.** 거기서 편집하면 드리프트가
|
||||
생긴다 — nginx 설정에서 실제로 겪었다
|
||||
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절).
|
||||
|
||||
### 호스트 nginx
|
||||
|
||||
```bash
|
||||
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정
|
||||
```
|
||||
|
||||
**`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
|
||||
내려간다. `-T`(대문자)는 include까지 펼친 최종 설정을 출력하므로
|
||||
"파일을 고쳤는데 반영이 안 된다" 상황의 확인 수단이다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 손에 익혀야 할 훈련
|
||||
|
||||
개념은 알지만 직접 해보지 않은 조작들. **남은 실험 5개 중 4개가 훈련 3
|
||||
위에 서 있다.**
|
||||
|
||||
### 훈련 1 — 이미지 반입
|
||||
|
||||
앞으로 BFF·token-mediator를 올릴 때마다 반복된다.
|
||||
|
||||
```bash
|
||||
docker build -t keycloak-pattern-api:lab backend
|
||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||||
```
|
||||
|
||||
### 훈련 2 — 설정 스위치 껐다 켜기
|
||||
|
||||
**설정을 바꿔가며 비교하는 것이 이 실험대의 본체**다.
|
||||
|
||||
```bash
|
||||
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=none
|
||||
kubectl -n header-lab rollout status deployment/echo
|
||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
||||
# → "http" / false 로 바뀐다
|
||||
|
||||
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=native
|
||||
kubectl -n header-lab rollout status deployment/echo
|
||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
||||
# → "https" / true 로 돌아온다
|
||||
```
|
||||
|
||||
### 훈련 3 — 노드를 죽였다 살리기
|
||||
|
||||
**가장 중요하다.** 장애 실험의 전제 조작이다.
|
||||
|
||||
```bash
|
||||
# 죽이기 — 전원 차단에 해당
|
||||
virsh destroy kc-lab-2
|
||||
|
||||
# 관찰 (NotReady 로 바뀌는 데 40초 안팎)
|
||||
kubectl get nodes
|
||||
kubectl -n header-lab get pods -o wide
|
||||
kubectl get events -A --sort-by=.lastTimestamp | tail -20
|
||||
|
||||
# 서비스가 살아있는지
|
||||
curl -sI https://app1.hyeonworks.com/api/echo | head -1
|
||||
|
||||
# 되살리기
|
||||
virsh start kc-lab-2
|
||||
kubectl get nodes # Ready 복귀
|
||||
kubectl -n header-lab get pods -o wide
|
||||
```
|
||||
|
||||
**이번에 특별히 확인할 것** — 현재 **Traefik은 replica 1**이고
|
||||
`kc-lab-2`에 있다. 그 노드를 죽이면 **진입점 자체가 사라지는지**,
|
||||
아니면 다른 노드로 재배치되어 복구되는지 관찰한다.
|
||||
|
||||
| 관찰 | 의미 |
|
||||
|---|---|
|
||||
| `curl`이 계속 200 | svclb가 남은 노드로 흘려보냄 + Traefik 재배치 성공 |
|
||||
| `curl`이 실패했다가 복구 | 재배치에 걸린 시간만큼 다운타임 |
|
||||
| `curl`이 계속 실패 | Traefik replica를 2로 늘려야 한다 |
|
||||
|
||||
이 결과에 따라 **Keycloak 배포 전에 Traefik replica를 조정할지** 결정한다.
|
||||
|
||||
```bash
|
||||
# 필요하다면
|
||||
kubectl -n kube-system scale deployment/traefik --replicas=2
|
||||
```
|
||||
|
||||
**망가져도 된다.** `virt-install` 한 줄로 재생성되며
|
||||
([`deploy/lab/README.md`](../deploy/lab/README.md) 게스트 재생성),
|
||||
그러라고 만든 실험대다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 실행 주체 감사 (2026-09-03)
|
||||
|
||||
무엇을 직접 했고 무엇을 대행했는지의 기록. **대행한 항목은 언젠가 직접
|
||||
해야 하는 것들**이다.
|
||||
|
||||
### 직접 수행
|
||||
|
||||
VM 생성 · cloud-init 작성 · SSH 키와 config · k3s server/agent 설치 ·
|
||||
kubeconfig 구성 · 호스트 nginx 설치와 설정 · certbot DNS-01 인증서 발급 ·
|
||||
Cloudflare DNS 레코드 · git 저장소 클론과 브랜치 · `echo.yaml` 최초 배포 ·
|
||||
nginx `X-Forwarded-Proto` 수정
|
||||
|
||||
### 대행 수행
|
||||
|
||||
| 작업 | 언제 다시 필요한가 |
|
||||
|---|---|
|
||||
| 이미지 빌드 → 노드 반입 (2회차 이후) | 자체 이미지를 올릴 때마다 |
|
||||
| `kubectl apply` (traefik HelmChartConfig, NetworkPolicy) | 모든 배포 |
|
||||
| `kubectl set env` / `rollout restart` | 설정 비교 실험마다 |
|
||||
| HelmChartConfig 재조정 대기와 파드 확인 | Traefik 설정 변경 시 |
|
||||
| git 브랜치 생성 · 커밋 · push | 실험마다 |
|
||||
| Playwright 증거 수집 | 브라우저 관점이 필요할 때마다 |
|
||||
| `kubectl run` 임시 파드 위조 테스트 | 클러스터 내부 검증 시 |
|
||||
| 설정 되돌렸다 복구 (단계별 스크린샷) | 비교 증거를 만들 때 |
|
||||
|
||||
### 아직 한 번도 하지 않은 것
|
||||
|
||||
| 항목 | 필요해지는 시점 |
|
||||
|---|---|
|
||||
| **`virsh destroy` + 재생성** | **장애 실험 전부** |
|
||||
| `rebuild-seed.sh` | cloud-init 을 바꿀 때 |
|
||||
| k9s 실사용 | 장애 중 상태 관찰 |
|
||||
| `kubectl delete ns` | 실험 정리, 메모리 회수 |
|
||||
|
||||
### 만들었지만 미검증이었던 것 → 2026-09-03 확인 완료
|
||||
|
||||
| 스크립트 | 결과 |
|
||||
|---|---|
|
||||
| `verify-lab.sh` | 정상 — `lab is healthy` |
|
||||
| `measure-proxy-headers.sh` | 정상 — 4개 항목 모두 출력 |
|
||||
@@ -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 프로세스(파드) 이름 |
|
||||
@@ -0,0 +1,393 @@
|
||||
# 세션 저장소 실험 축 — 계획과 진행
|
||||
|
||||
`develop-keycloak-session-store` 브랜치가 담당하는 작업의 전체 지도.
|
||||
**무엇이 끝났고 무엇이 남았는지**를 여기서 추적한다.
|
||||
|
||||
## 왜 별도 축인가
|
||||
|
||||
네 인증 패턴(AP1~AP4)은 **브라우저와 토큰의 관계**를 비교한다. 이 축은
|
||||
그것과 직교하는 질문을 다룬다 — **세션과 토큰이 서버 쪽 어디에 저장되고,
|
||||
그 저장소가 죽으면 무슨 일이 벌어지는가.**
|
||||
|
||||
초기 검토에서 전제 하나가 교정됐다. **Keycloak은 Redis를 세션 저장소로
|
||||
지원하지 않는다.** 그래서 이 축은 두 계층으로 갈린다.
|
||||
|
||||
| 계층 | 저장소 | 해당 패턴 |
|
||||
|---|---|---|
|
||||
| **A. Keycloak 자체** | 임베디드 Infinispan + PostgreSQL | 네 패턴 공통 |
|
||||
| **B. 애플리케이션 세션** | **Redis** | AP2 / AP3 / AP4 |
|
||||
|
||||
A층은 네 패턴과 무관하게 공통이고, B층은 서버 세션을 갖는 세 패턴에만
|
||||
존재한다. 그래서 이 축을 AP1~AP4 어디에도 넣지 않고 별도로 둔다.
|
||||
|
||||
## 기존 브랜치 레지스트리에 넣지 않는 이유
|
||||
|
||||
`docs/keycloak-branch-manifest.tsv`와 `scripts/audit-keycloak-branches.sh`는
|
||||
**정확히 39개** 브랜치를 강제하고, 각 브랜치가 외부 노트 파일과 1:1로
|
||||
대응하는지 검사한다.
|
||||
|
||||
```sh
|
||||
if [ "$expected_count" -ne 39 ]; then
|
||||
echo "manifest must contain exactly 39 Keycloak branches" >&2
|
||||
```
|
||||
|
||||
이 축의 브랜치를 manifest에 추가하면 그 감사가 깨진다. 원래 39개는
|
||||
**완결된 인벤토리**이므로 건드리지 않고, 이 축은 이 문서로 추적한다.
|
||||
|
||||
## 진행 상황
|
||||
|
||||
```
|
||||
✅ 환경 구축
|
||||
✅ 2홉 프록시 헤더 계약
|
||||
──────────────────────────────────────────────────────────
|
||||
A층 — Keycloak 자체 (공개 질문에 없는 영역)
|
||||
1. 멀티노드 클러스터 형성
|
||||
2. persistent vs volatile 세션
|
||||
|
||||
B층 — 애플리케이션 세션 (공개 열린 질문 대응)
|
||||
3. BFF 저장소 결정 → Q3
|
||||
4. 다중 인스턴스 운영 → Q1
|
||||
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
||||
6. Edge 인가 범위 → Q4
|
||||
|
||||
공통 — 운영 역량
|
||||
7. 장애 주입과 복구
|
||||
8. 백업과 복구 리허설
|
||||
9. 버전 업그레이드
|
||||
10. 관측성
|
||||
11. 비밀 관리
|
||||
12. 인증서 갱신 실측
|
||||
```
|
||||
|
||||
**각 실험의 구조·주입 지점·확인 항목은
|
||||
[`experiment-plan.md`](experiment-plan.md)에 미리 확정해두었다.**
|
||||
순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.
|
||||
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
|
||||
|
||||
### ✅ 완료 — 환경 구축
|
||||
|
||||
2노드 k3s 실험대. 상세는 [`deploy/lab/README.md`](../deploy/lab/README.md),
|
||||
개념은 [`session-lab-concepts.md`](session-lab-concepts.md),
|
||||
운영 도구는 [`session-lab-operations.md`](session-lab-operations.md).
|
||||
|
||||
```
|
||||
브라우저 ─https─▶ 호스트 nginx(TLS 종료) ─▶ Traefik ─▶ Pod
|
||||
kc-lab-1 / kc-lab-2
|
||||
```
|
||||
|
||||
**왜 Docker Compose가 아닌가** — 한 커널에서 "노드 죽이기"는 프로세스
|
||||
죽이기일 뿐이다. 노드 간 방화벽·비대칭 파티션·진짜 노드 상실은 **독립된
|
||||
커널 두 개**가 있어야 성립한다.
|
||||
|
||||
### ✅ 완료 — 2홉 프록시 헤더 계약
|
||||
|
||||
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) ·
|
||||
증거 [`evidence/two-hop-proxy-headers/`](evidence/two-hop-proxy-headers/)
|
||||
|
||||
**확인한 것** — `docs/reverse-proxy-headers.md`의 1홉 계약이 2홉에서
|
||||
성립하지 않는다. 원인이 둘이었고 스위치가 셋이었다.
|
||||
|
||||
| 스위치 | 하는 일 |
|
||||
|---|---|
|
||||
| nginx `proxy_set_header` | 헤더를 **만든다** |
|
||||
| Traefik `forwardedHeaders.trustedIPs` | 받은 헤더를 **전달할지 버릴지** |
|
||||
| 앱 `forward-headers-strategy` / `KC_PROXY_HEADERS` | 도착한 헤더를 **읽을지** |
|
||||
|
||||
**발견한 취약점 — 헤더 신뢰를 켠 순간 위조가 통했다**
|
||||
|
||||
세 스위치를 다 켜고 나니 새 구멍이 생겼다. Traefik을 거치지 않고 앱에 직접
|
||||
요청하면서 헤더를 붙이자 **그대로 통과했다.**
|
||||
|
||||
```
|
||||
serverName evil.example.com ← 위조 성공
|
||||
remoteAddr 1.2.3.4 ← 위조 성공
|
||||
requestUrl https://evil.example.com/api/echo
|
||||
```
|
||||
|
||||
두 신뢰 설정이 모두 **"대역"을 믿기 때문**이다. IP로는 Traefik을 특정할 수
|
||||
없다 — 파드 IP가 재시작마다 바뀐다(측정 중 `10.42.0.8` → `10.42.1.12`로,
|
||||
노드까지 옮겨갔다). **NetworkPolicy는 IP가 아니라 라벨로 지정**하므로 이를
|
||||
닫는다.
|
||||
|
||||
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
||||
이 교훈이 6번(Edge 인가)에서 결정적이 된다 — `X-Auth-Request-*`가 위조되면
|
||||
그것은 쿠키 속성이 아니라 **신원 위조**다.
|
||||
|
||||
**이 결과가 뒤에 미치는 영향** — Keycloak을 올릴 때
|
||||
`KC_PROXY_HEADERS=xforwarded`와 `KC_HOSTNAME=https://auth.hyeonworks.com`을
|
||||
근거를 갖고 넣을 수 있고, 로그인이 깨져도 프록시 원인은 배제하고 볼 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## A층 — Keycloak 자체
|
||||
|
||||
**공개 열린 질문 네 개는 전부 애플리케이션 계층(B층)이다.** Keycloak 자체의
|
||||
클러스터링과 세션 저장을 다루는 질문은 아직 등록되어 있지 않다.
|
||||
|
||||
**그러나 이 두 항목이 이 실험대의 존재 이유에 더 가깝다.** B층 실험은 replica
|
||||
2개면 되므로 Docker Compose로도 상당 부분 가능하지만, A층은 **독립된 커널
|
||||
두 개**를 요구한다. 실험 후 결과를 **새 열린 질문으로 등록할 후보**다.
|
||||
|
||||
### 1. Keycloak 멀티노드 클러스터 형성
|
||||
|
||||
브랜치: `feature/keycloak-multinode-cluster-jdbc-ping`
|
||||
|
||||
**확인할 것**
|
||||
|
||||
- Keycloak 2개 파드가 **서로를 발견해 하나의 클러스터를 이루는가**
|
||||
- Keycloak 26의 기본 디스커버리는 `jdbc-ping` — PostgreSQL의 `JGROUPS_PING`
|
||||
테이블로 서로를 찾는다. 멀티캐스트가 필요 없다
|
||||
- **TCP 7800이 막히면 무엇이 먼저 보이는가** — 디스커버리는 DB로 하지만 실제
|
||||
클러스터 통신은 7800이다. 막으면 "DB에는 서로 등록되는데 클러스터가 안 붙는"
|
||||
증상이 나온다. **단일 노드에서는 재현 불가능한 고장**이며, 이 실험대를
|
||||
2노드로 만든 이유 중 하나다
|
||||
|
||||
**주의** — Traefik이 replica 1이므로 그 파드가 있는 노드를 죽이면 진입점
|
||||
자체가 사라질 수 있다. **훈련 3([`session-lab-operations.md`](session-lab-operations.md))에서
|
||||
먼저 확인**하고 replica 조정 여부를 정한다.
|
||||
|
||||
**설정 근거** — 헤더 계약에서 확정한 값을 그대로 쓴다.
|
||||
|
||||
```
|
||||
KC_HOSTNAME=https://auth.hyeonworks.com
|
||||
KC_HOSTNAME_STRICT=true
|
||||
KC_PROXY_HEADERS=xforwarded
|
||||
KC_HTTP_ENABLED=true
|
||||
```
|
||||
|
||||
### 2. persistent vs volatile 세션
|
||||
|
||||
브랜치: `feature/keycloak-persistent-vs-volatile-sessions`
|
||||
|
||||
**확인할 것**
|
||||
|
||||
- Keycloak 26 기본값은 `persistent-user-sessions` — 세션이 **DB가 진실의
|
||||
원천**이다
|
||||
- `--features-disabled=persistent-user-sessions`로 volatile 전환 시 비교
|
||||
- **PostgreSQL을 죽이면** 각각 어떻게 되는가
|
||||
- **노드 하나를 죽이면** 세션이 살아남는가
|
||||
- **롤링 배포 시 로그아웃되는가** ← 운영에서 가장 자주 겪는 시나리오이며,
|
||||
사실상 persistent를 켜는 진짜 이유다
|
||||
|
||||
이것이 "세션을 DB에 둘 때 vs 안 둘 때"의 Keycloak 버전이다.
|
||||
|
||||
---
|
||||
|
||||
## B층 — 애플리케이션 세션
|
||||
|
||||
공개 열린 질문 네 개에 대응한다. 각 질문이 요구하는 검증 단계는
|
||||
[`open-questions-coverage.md`](open-questions-coverage.md)에 항목별로 있다.
|
||||
|
||||
**공통 선행 조건** — BFF 2 replica. 구현은 `develop-keycloak-pattern3`에
|
||||
이미 있으므로 가져온다.
|
||||
|
||||
```bash
|
||||
git checkout develop-keycloak-pattern3 -- bff/
|
||||
```
|
||||
|
||||
### 3. BFF 저장소 결정 → [Q3](https://hyeonworks.com/questions/bff-session-authorized-client-store)
|
||||
|
||||
브랜치: `feature/keycloak-redis-app-session-store`
|
||||
|
||||
**질문의 핵심은 "Redis 도입"이 아니라 "Redis와 JDBC 중 무엇이 맞는가"다.**
|
||||
PostgreSQL이 이미 있으므로 같은 조건에서 비교할 수 있다.
|
||||
|
||||
**Session과 Authorized Client는 조회 키가 다르다.**
|
||||
|
||||
| 상태 | 조회 키 |
|
||||
|---|---|
|
||||
| Application Session | **session ID** |
|
||||
| OAuth2AuthorizedClient | **registration 이름 + principal name** |
|
||||
|
||||
`session ID`가 없으므로 **같은 사용자의 여러 브라우저가 동일한 authorized
|
||||
client를 공유**한다. 따라서 **두 저장소를 각각 설계해야 한다.**
|
||||
|
||||
검증 5단계 — 로그인 유지·재시작 복구 / **refresh token 평문 여부** /
|
||||
**session TTL ≠ token 만료** / logout 후 잔여 항목 / 저장소 끊김 시 오류.
|
||||
|
||||
### 4. 다중 인스턴스 운영 → [Q1](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
|
||||
|
||||
브랜치: `feature/keycloak-multi-instance-session-operation` **(생성 필요)**
|
||||
|
||||
검증 5단계 — 다른 인스턴스로 요청 시 200 유지 / 재시작 후 session cookie /
|
||||
**authorized client 덮어쓰기** / logout 전파 / 만료 어긋남.
|
||||
|
||||
**3번(덮어쓰기)이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는
|
||||
항목이다.
|
||||
|
||||
**질문의 제약 하나는 이미 해결법을 안다** — "Resource Server의 8081이 host에도
|
||||
열려 있어 BFF만 거치도록 강제되지 않았다"는 2홉 실험의 **프록시 우회 경로와
|
||||
같은 문제**이며, NetworkPolicy 패턴을 그대로 재사용한다.
|
||||
|
||||
호스트 nginx의 `ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은
|
||||
구성에서 된다.
|
||||
|
||||
### 5. Refresh Token 경쟁 → [Q2](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
|
||||
|
||||
브랜치: `feature/keycloak-refresh-token-concurrency`
|
||||
|
||||
**★ 3번 이후여야 한다.** 저장소가 process-local이면 두 replica가 같은 refresh
|
||||
token 항목을 보지 않아 **경쟁 자체가 재현되지 않는다.**
|
||||
|
||||
검증 5단계 — 만료 직후 동시 요청 / 이긴 쪽·지는 쪽 응답 / **지는 쪽이 새
|
||||
token으로 재시도해 성공하는가** / **지는 쪽 사용자 화면** /
|
||||
**lock 유무를 같은 입력으로 비교**.
|
||||
|
||||
**마지막이 결론 기준이다** — *실패가 사용자에게 노출되면 lock, 노출되지 않으면
|
||||
재시도.*
|
||||
|
||||
**제약** — rotation + 재사용 0회는 전제로 고정한다. 그리고 이미 발급된 access
|
||||
token은 만료 전까지 통하므로 **재현은 access token 만료 직후에 맞춰 실행**한다.
|
||||
|
||||
### 6. Edge 인가 범위 → [Q4](https://hyeonworks.com/questions/edge-authorization-scope)
|
||||
|
||||
브랜치: `feature/keycloak-edge-authorization-scope` **(생성 필요)**
|
||||
|
||||
**확인할 것** — role을 헤더에 담고 **다중 값 구분자·escaping** / **헤더 크기
|
||||
상한** 초과 시 자르는가 거부하는가 / role 변경이 **몇 번째 요청부터 반영**되는가
|
||||
/ upstream이 헤더 존재만 보는가 값과 service identity까지 보는가.
|
||||
|
||||
2홉 실험에서 확인한 **nginx가 동명 헤더를 merge하지 않고 덮어쓴다**는 동작이
|
||||
`X-Auth-Request-*`에도 적용되는지 같은 방법으로 검증한다.
|
||||
|
||||
질문의 제약 — internal token 검사가 controller 한 곳에만 있어 **공통 경계로
|
||||
옮겨야** 한다. 코드 변경이므로 `backend/`에서 진행한다.
|
||||
|
||||
---
|
||||
|
||||
## 공통 — 운영 역량
|
||||
|
||||
**여기부터는 "구성했다"가 아니라 "운영해봤다"에 필요한 항목이다.**
|
||||
백업과 업그레이드는 빠지면 티가 난다.
|
||||
|
||||
### 7. 장애 주입과 복구
|
||||
|
||||
브랜치: `feature/keycloak-failure-injection-recovery`
|
||||
|
||||
| 주입 | 방법 |
|
||||
|---|---|
|
||||
| 노드 상실 | `virsh destroy` — 프로세스 kill 이 아닌 진짜 상실 |
|
||||
| 비대칭 파티션 | 한쪽 게스트의 인바운드만 nftables 로 차단 |
|
||||
| JGroups 7800 차단 | NetworkPolicy — 운영에서 쓸 방식 그대로 |
|
||||
| DB 상실 | PostgreSQL 파드 정지 |
|
||||
| Redis 상실 | Redis 파드 정지 |
|
||||
| 지연 주입 | 게스트 안에서 `tc netem` — 커널이 분리돼 있어 안전 |
|
||||
|
||||
**복구 절차**를 각각 기록한다. 실제 장애의 대부분은 완전 사망이 아니라
|
||||
**부분 장애**(느려짐, 일부 실패)이므로 netem 지연을 기본값으로 둔다.
|
||||
|
||||
### 8. 백업과 복구 리허설
|
||||
|
||||
**"백업이 있다"와 "복구해봤다"는 완전히 다르다.**
|
||||
|
||||
- `pg_dump`로 realm·세션·JGROUPS_PING 포함 전체 덤프
|
||||
- **일부러 파괴** — PVC 삭제 또는 DB 드롭
|
||||
- 덤프에서 복구하고 **로그인이 되는지, 기존 세션이 살아나는지** 확인
|
||||
- 복구에 걸린 시간을 기록한다 (RTO)
|
||||
- 백업 시점 이후 데이터가 무엇을 잃는지 확인한다 (RPO)
|
||||
|
||||
Redis 쪽은 `appendonly` 유무에 따른 차이를 함께 본다.
|
||||
|
||||
### 9. Keycloak 버전 업그레이드
|
||||
|
||||
**운영에서 가장 무서운 작업 중 하나다.** realm 마이그레이션과 **DB 스키마
|
||||
변경이 자동으로 실행**되며, 실패하면 되돌리기 어렵다.
|
||||
|
||||
- 현재 26.7.0 → 다음 마이너로 이미지 태그 변경
|
||||
- **업그레이드 전 백업**을 먼저 확보한다 (8번의 전제)
|
||||
- 롤링 중 **기존 세션이 유지되는가** (2번의 persistent 설정과 연결된다)
|
||||
- 스키마 변경 로그를 확인한다
|
||||
- **롤백이 되는가** — 스키마가 바뀐 뒤에는 이전 버전이 뜨지 않을 수 있다
|
||||
|
||||
### 10. 관측성
|
||||
|
||||
지금은 `kubectl top`뿐이라 **장애 중 무슨 일이 있었는지 사후 추적이 안 된다.**
|
||||
|
||||
- `KC_METRICS_ENABLED=true` + `KC_HEALTH_ENABLED=true`
|
||||
- Prometheus + Grafana 배포
|
||||
- 볼 지표 — Infinispan 캐시 항목 수·축출, DB 커넥션 풀 사용률,
|
||||
로그인 성공/실패율, **클러스터 멤버 수**
|
||||
- 장애 주입(7번) 중에 **어떤 지표가 먼저 움직이는지** 기록한다
|
||||
|
||||
### 11. 비밀 관리
|
||||
|
||||
지금 방식대로면 client secret과 DB 비밀번호가 **매니페스트에 평문**으로 들어간다.
|
||||
|
||||
- k8s `Secret`으로 분리
|
||||
- 저장소에는 `.example`만 커밋 (기존 `.env.example` 관례 그대로)
|
||||
- 평문 Secret은 etcd에 base64로만 저장되므로 실제로는 감춰지지 않는다는 점을
|
||||
확인한다 — `kubectl get secret -o yaml`로 직접 본다
|
||||
- SealedSecret 또는 외부 저장소가 필요한 지점을 판단한다
|
||||
|
||||
### 12. 인증서 갱신 실측
|
||||
|
||||
90일을 기다리지 않고 강제로 겪는다.
|
||||
|
||||
```bash
|
||||
sudo certbot renew --force-renewal
|
||||
```
|
||||
|
||||
- nginx reload 타이밍에 **무중단인가**
|
||||
- 갱신 중 진행 중이던 요청은 어떻게 되는가
|
||||
- `certbot-renew.timer`가 실제로 동작하는가 (`--dry-run`이 아니라 실제 갱신)
|
||||
|
||||
---
|
||||
|
||||
## 스코프에서 제외한 것
|
||||
|
||||
### 이 실험대가 재현하지 못하는 것
|
||||
|
||||
| 항목 | 이유 |
|
||||
|---|---|
|
||||
| 성능·처리량 측정 | 단일 물리 머신의 숫자는 운영에 대해 아무것도 말해주지 않는다 |
|
||||
| 실제 AZ 간 지연 | 한 박스 안이라 재현 불가. `tc netem` 으로 근사만 |
|
||||
| ALB 고유 동작 | 자체 스티키 쿠키·60초 idle timeout 은 실물 ALB 가 있어야 한다 |
|
||||
| PostgreSQL HA | 스코프 폭발. "죽으면 어떻게 되나"까지가 현실적 선 |
|
||||
| 멀티 사이트 / cross-site Infinispan | 로컬에서 "사이트"가 가짜라 배우는 것이 적다 |
|
||||
|
||||
**이 실험대가 검증하는 것은 계약(정합성)이지 성능이 아니다.**
|
||||
|
||||
### 구조적으로 줄 수 없는 경험
|
||||
|
||||
실험 설계로는 만들 수 없는 것들. **무엇을 겪지 않았는지 아는 것도 기록의
|
||||
일부다.**
|
||||
|
||||
| 없는 것 | 왜 |
|
||||
|---|---|
|
||||
| **규모** | 수천 세션에서의 커넥션 풀 고갈, Infinispan 캐시 축출 |
|
||||
| **시간** | 몇 달 돌면서 드러나는 디스크 참, 로그 누적, 메모리 누수 |
|
||||
| **다른 사람** | 동시에 만지는 사람, 온콜, 인수인계, "내가 안 바꿨는데 바뀌어 있음" |
|
||||
| **실제 사용자** | 봇, 오래된 클라이언트, 예측 못 한 사용 패턴 |
|
||||
| **클라우드 관리형 컴포넌트** | ALB·RDS·ElastiCache의 고유 동작과 **그것들의 장애 모드** |
|
||||
| **비용** | 운영 판단의 큰 축인데 실험대엔 없다 |
|
||||
| **보안 사고 대응** | 실제 침해, 토큰 유출 후 회수, 감사 로그 추적 |
|
||||
|
||||
**"시간"은 부분적으로 살 수 있다.** 실험이 끝나도 클러스터를 지우지 않고
|
||||
몇 주 켜둔 채로 두면 인증서가 갱신되고, 로그가 쌓이고, 예상 못 한 것이
|
||||
죽는다. 실험 설계로는 만들 수 없는 종류의 관찰이다.
|
||||
|
||||
### 따라서 말할 수 있는 것과 없는 것
|
||||
|
||||
**말할 수 있다** — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고 각
|
||||
저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의
|
||||
헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
|
||||
|
||||
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다"
|
||||
|
||||
**그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.**
|
||||
|
||||
## 관련 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [`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개와의 대조, 순서 근거 |
|
||||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
|
||||
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
|
||||
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | 첫 실험의 측정·진단·수정 |
|
||||
| [`deploy/lab/README.md`](../deploy/lab/README.md) | 실험대 구축·복구 절차 |
|
||||
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | AP1~AP4 비교. "server session" 행이 B층 대상 |
|
||||
| [`refresh-token-rotation.md`](refresh-token-rotation.md) | 회전 계약 (1홉·단일 노드 가정) |
|
||||
| [`reverse-proxy-headers.md`](reverse-proxy-headers.md) | 1홉 헤더 계약 원본 |
|
||||
@@ -1,16 +0,0 @@
|
||||
# Keycloak realm roles to Spring authorization
|
||||
|
||||
`realm_access.roles`의 각 문자열을 `ROLE_` prefix가 붙은 Spring authority로
|
||||
변환한다. `/api/admin`은 `hasRole("admin-role")` 계약이므로 최종 authority는
|
||||
`ROLE_admin-role`이다. `hasRole("ROLE_admin-role")`로 쓰면 prefix가 중복된다.
|
||||
|
||||
검증은 세 층으로 구성된다.
|
||||
|
||||
- converter 단위 테스트: role claim과 claim 부재
|
||||
- MockMvc: regular 403, admin 200
|
||||
- 실제 Authorization Code + PKCE login: Keycloak token의 realm role을
|
||||
Spring Resource Server가 변환해 regular 403/admin 200을 반환
|
||||
|
||||
```sh
|
||||
./scripts/verify-spring-role-mapping.sh
|
||||
```
|
||||
@@ -0,0 +1,816 @@
|
||||
# 2홉 프록시 헤더 계약 — 측정·진단·적용
|
||||
|
||||
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
|
||||
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로
|
||||
성립하는지 측정했다. **성립하지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 이것부터 재는가
|
||||
|
||||
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
|
||||
무엇이었나"** 에 의존한다.
|
||||
|
||||
| 만들어지는 값 | 의존하는 정보 |
|
||||
|---|---|
|
||||
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
|
||||
| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 |
|
||||
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
|
||||
| brute-force 탐지·감사 로그 | 클라이언트 IP |
|
||||
|
||||
그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로,
|
||||
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
|
||||
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
|
||||
틀어진다.
|
||||
|
||||
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
|
||||
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 구조 — 누가 어디에 사는가
|
||||
|
||||
### 물리적 배치
|
||||
|
||||
```
|
||||
┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐
|
||||
│ │
|
||||
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
|
||||
│ /etc/nginx/sites-available/keycloak-lab │
|
||||
│ = deploy/lab/host/nginx-keycloak-lab.conf │
|
||||
│ │
|
||||
│ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
|
||||
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │
|
||||
│ │ ↓ │ │ └────────────────┼──┼─┐
|
||||
│ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘
|
||||
│ │ 클러스터 전체에 하나뿐 │ │ │ │
|
||||
│ │ ↓ │ │ │ │
|
||||
│ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │
|
||||
│ └─────────────────────────────┘ └────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| # | 무엇 | 사는 곳 | 설정 파일 |
|
||||
|---|---|---|---|
|
||||
| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
||||
| 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
|
||||
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` |
|
||||
|
||||
### Traefik은 노드마다 있지 않다
|
||||
|
||||
k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각
|
||||
노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로**
|
||||
전달한다.
|
||||
|
||||
측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이
|
||||
그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는
|
||||
노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다.
|
||||
|
||||
### "홉"은 노드 수가 아니라 프록시 계층 수다
|
||||
|
||||
```
|
||||
홉 1 홉 2 목적지
|
||||
호스트 nginx ──▶ Traefik ──▶ 앱 파드
|
||||
(HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄)
|
||||
```
|
||||
|
||||
**svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지
|
||||
않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 측정 장치
|
||||
|
||||
`backend`의 `/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
|
||||
|
||||
```
|
||||
GET https://app1.hyeonworks.com/api/echo
|
||||
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
|
||||
```
|
||||
|
||||
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
|
||||
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
|
||||
응답했는지 알려준다.
|
||||
|
||||
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
|
||||
`deploy/lab/scripts/measure-proxy-headers.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
|
||||
|
||||
```
|
||||
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
|
||||
│ GET /api/echo │
|
||||
│ Host: app1.hyeonworks.com │
|
||||
│ 전 구간 TLS 로 암호화 │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│ tailnet → 100.83.212.4:443
|
||||
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
|
||||
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
|
||||
│ │
|
||||
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
|
||||
│ Host app1.hyeonworks.com │
|
||||
│ X-Forwarded-Host app1.hyeonworks.com │
|
||||
│ X-Forwarded-Proto https ← 원래 스킴 │
|
||||
│ X-Forwarded-Port 443 │
|
||||
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
|
||||
│ X-Real-IP <클라이언트 IP> │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│ upstream 라운드로빈
|
||||
│ 192.168.122.11:80 또는 .12:80
|
||||
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
|
||||
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
|
||||
│ externalTrafficPolicy: Cluster → SNAT 발생 │
|
||||
│ │
|
||||
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
|
||||
│ → 클라이언트 IP 1차 소실 │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│ 10.42.0.8 (Traefik 파드)
|
||||
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
|
||||
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
|
||||
│ │
|
||||
│ ★ forwardedHeaders.trustedIPs 미설정 │
|
||||
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
|
||||
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
|
||||
│ │
|
||||
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
|
||||
│ X-Forwarded-Port 443 → 80 │
|
||||
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
|
||||
│ X-Real-IP 실IP → 10.42.1.0 │
|
||||
│ X-Forwarded-Host 유지 │
|
||||
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
│ Service → 파드
|
||||
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
|
||||
│ Spring: forward-headers-strategy = none │
|
||||
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
|
||||
│ │
|
||||
│ scheme http │
|
||||
│ secure false │
|
||||
│ requestUrl http://app1.hyeonworks.com/api/echo │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 측정 결과
|
||||
|
||||
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|
||||
|---|---|---|---|
|
||||
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
|
||||
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
|
||||
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
||||
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
||||
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
|
||||
|
||||
앱이 최종적으로 보는 값:
|
||||
|
||||
```
|
||||
scheme http
|
||||
secure false
|
||||
requestUrl http://app1.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
|
||||
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
|
||||
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
|
||||
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
|
||||
값까지 함께 버려진다.
|
||||
|
||||
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
|
||||
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
|
||||
|
||||
---
|
||||
|
||||
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
|
||||
|
||||
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
|
||||
직접 요청하여 원인을 분리했다.
|
||||
|
||||
```bash
|
||||
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
|
||||
curl -s http://192.168.122.11/api/echo \
|
||||
-H 'Host: app1.hyeonworks.com' \
|
||||
-H 'X-Forwarded-Proto: https' \
|
||||
-H 'X-Forwarded-Port: 443' \
|
||||
-H 'X-Forwarded-For: 203.0.113.7'
|
||||
```
|
||||
|
||||
| | 보낸 값 | 도달한 값 |
|
||||
|---|---|---|
|
||||
| `X-Forwarded-Proto` | `https` | **`http`** |
|
||||
| `X-Forwarded-Port` | `443` | **`80`** |
|
||||
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
|
||||
|
||||
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
|
||||
독립적으로 증명된다.
|
||||
|
||||
그리고 이 과정에서 **두 번째 원인**이 드러났다.
|
||||
|
||||
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
|
||||
|
||||
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
|
||||
|
||||
```nginx
|
||||
listen 443 ssl default_server;
|
||||
...
|
||||
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
|
||||
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
|
||||
```
|
||||
|
||||
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
|
||||
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
|
||||
남은 값이다.
|
||||
|
||||
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
|
||||
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
|
||||
|
||||
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
|
||||
|
||||
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
|
||||
|
||||
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
|
||||
|
||||
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 원인 — 독립된 스위치 세 개
|
||||
|
||||
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
|
||||
**하나만 꺼져 있어도 정보가 끊긴다.**
|
||||
|
||||
| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 |
|
||||
| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** |
|
||||
| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 |
|
||||
|
||||
지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래
|
||||
스킴을 모른다.
|
||||
|
||||
### 3번을 구체적으로
|
||||
|
||||
헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`가
|
||||
찍혔다. 도착은 했다.
|
||||
|
||||
그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다.
|
||||
**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다.
|
||||
`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서
|
||||
**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.**
|
||||
|
||||
즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다.
|
||||
|
||||
### 3번은 앱마다 하나씩이다
|
||||
|
||||
1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.**
|
||||
|
||||
```
|
||||
[1] nginx 1개 고정
|
||||
[2] Traefik 1개 고정
|
||||
[3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API …
|
||||
```
|
||||
|
||||
그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다.
|
||||
이것이 이 계약을 문서로 고정해두어야 하는 이유다.
|
||||
|
||||
### 기본값이 "믿지 않음"인 것은 의도된 설계다
|
||||
|
||||
`X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다.
|
||||
"누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다.
|
||||
프레임워크들이 하나같이 기본값을 꺼두는 이유다.
|
||||
|
||||
### 네 번째 요인 — `externalTrafficPolicy: Cluster`
|
||||
|
||||
svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미
|
||||
사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만,
|
||||
**TCP 출발지 주소 자체는 복원되지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 7. 앱 스위치를 켜는 방법
|
||||
|
||||
### Spring Boot
|
||||
|
||||
```yaml
|
||||
server:
|
||||
forward-headers-strategy: native # none | native | framework
|
||||
```
|
||||
|
||||
환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`.
|
||||
|
||||
| 값 | 구현 | 신뢰 IP 제한 |
|
||||
|---|---|---|
|
||||
| `none` (기본) | 무시 | — |
|
||||
| **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** |
|
||||
| `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** |
|
||||
|
||||
**`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`는
|
||||
`internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에
|
||||
해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`라
|
||||
기본값에 들어간다.
|
||||
|
||||
`framework`는 그런 필터가 없어 **누가 보내든 믿는다.**
|
||||
|
||||
**켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다.
|
||||
|
||||
```
|
||||
X-Forwarded-For → request.getRemoteAddr()
|
||||
X-Forwarded-Proto → request.getScheme(), isSecure()
|
||||
X-Forwarded-Port → request.getServerPort()
|
||||
X-Forwarded-Host → request.getServerName()
|
||||
→ 그 결과 getRequestURL() 이 외부 URL 로 재구성됨
|
||||
```
|
||||
|
||||
**애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를
|
||||
바꿔서 넘겨준다.
|
||||
|
||||
### Keycloak
|
||||
|
||||
```
|
||||
KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded
|
||||
```
|
||||
|
||||
| 값 | 읽는 헤더 |
|
||||
|---|---|
|
||||
| `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) |
|
||||
| `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 |
|
||||
| 미설정 | 무시 |
|
||||
|
||||
**Keycloak은 방어가 두 겹이다.**
|
||||
|
||||
| 설정 | 담당 |
|
||||
|---|---|
|
||||
| `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 |
|
||||
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 |
|
||||
|
||||
그래서 `iss`는 `KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와
|
||||
감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다.
|
||||
|
||||
> 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고
|
||||
> `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의.
|
||||
|
||||
### oauth2-proxy
|
||||
|
||||
```
|
||||
--reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true
|
||||
```
|
||||
|
||||
신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다.
|
||||
|
||||
### 뒤쪽에 nginx가 있는 경우
|
||||
|
||||
```nginx
|
||||
set_real_ip_from 10.42.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
```
|
||||
|
||||
신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다.
|
||||
|
||||
### 공통 원리
|
||||
|
||||
어느 프레임워크든 결국 **두 가지를 정하는 일**이다.
|
||||
|
||||
1. **어떤 헤더 형식을 읽을지** — `X-Forwarded-*` vs RFC 7239 `Forwarded`
|
||||
2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록
|
||||
|
||||
두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고,
|
||||
Spring `framework`와 oauth2-proxy는 없다.
|
||||
|
||||
### 켤 때 반드시 같이 봐야 하는 것
|
||||
|
||||
**앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.**
|
||||
|
||||
헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며
|
||||
`X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를
|
||||
발급하거나 IP 기반 제한을 우회**당한다.
|
||||
|
||||
쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로,
|
||||
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다.
|
||||
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
|
||||
|
||||
**"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.**
|
||||
한쪽만 하면 구멍이 된다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 영향 — 패턴별
|
||||
|
||||
| 패턴 | 의존도 | 무엇이 깨지나 |
|
||||
|---|---|---|
|
||||
| AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 |
|
||||
| AP2 token mediator | 중간 | mediator의 redirect URI |
|
||||
| **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 |
|
||||
| **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** |
|
||||
|
||||
### AP3(BFF)에서 왜 중요한가
|
||||
|
||||
**흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"**
|
||||
|
||||
쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로
|
||||
통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다.
|
||||
|
||||
**(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다**
|
||||
|
||||
BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때
|
||||
`redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다.
|
||||
|
||||
```
|
||||
redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak
|
||||
↑ request.getScheme() 에서 온다
|
||||
```
|
||||
|
||||
`scheme=http`면 이렇게 나간다.
|
||||
|
||||
```
|
||||
redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak
|
||||
```
|
||||
|
||||
그러면 두 가지 중 하나가 벌어진다.
|
||||
|
||||
- Keycloak 클라이언트에 `https://...`만 등록돼 있으면
|
||||
→ **`invalid_redirect_uri` 오류로 로그인 거부**
|
||||
- 실수로 `http://...`도 등록해뒀다면
|
||||
→ 브라우저가 https 페이지에서 http로 리다이렉트 →
|
||||
**혼합 콘텐츠 차단 또는 세션 쿠키 유실**
|
||||
|
||||
**세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.**
|
||||
|
||||
**(2) 세션 쿠키의 `Secure` 속성**
|
||||
|
||||
서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie`에 `Secure`를
|
||||
붙일지 정한다. `isSecure()`가 `false`면 **`Secure` 없는 세션 쿠키**가 나간다.
|
||||
|
||||
| 결과 | 내용 |
|
||||
|---|---|
|
||||
| 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 |
|
||||
| **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 |
|
||||
|
||||
두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과
|
||||
**CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계
|
||||
선택지가 통째로 사라진다.
|
||||
|
||||
**(3) 로그아웃 `post_logout_redirect_uri`**
|
||||
|
||||
같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다.
|
||||
|
||||
**정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이
|
||||
아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다.
|
||||
|
||||
### AP4(edge auth)에서 왜 결정적인가
|
||||
|
||||
AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다.
|
||||
|
||||
oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.**
|
||||
|
||||
```
|
||||
X-Auth-Request-User
|
||||
X-Auth-Request-Email
|
||||
X-Auth-Request-Groups
|
||||
X-Auth-Request-Access-Token
|
||||
```
|
||||
|
||||
downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을
|
||||
직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서
|
||||
일괄 인증"할 수 있는 이유다.
|
||||
|
||||
**그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.**
|
||||
|
||||
```
|
||||
공격자가 직접: X-Auth-Request-User: admin
|
||||
프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식
|
||||
```
|
||||
|
||||
지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과
|
||||
의미만 다르다.
|
||||
|
||||
| 헤더군 | 담는 정보 | 위조되면 |
|
||||
|---|---|---|
|
||||
| `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 |
|
||||
| `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** |
|
||||
|
||||
저장소의 `feature/keycloak-header-spoofing-defense` 브랜치
|
||||
(manifest: `ap4 / locally-verified`)가 이 문제를 다룬다.
|
||||
**지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된
|
||||
방어가 2홉에서도 유효한지 다시 확인해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
|
||||
|
||||
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
|
||||
담당하는지 데이터로 확인했다.**
|
||||
|
||||
### A. nginx — 원래 스킴을 사실대로 알린다
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
||||
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
|
||||
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
|
||||
|
||||
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
|
||||
|
||||
```diff
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
- proxy_set_header X-Forwarded-Proto http;
|
||||
- proxy_set_header X-Forwarded-Port 80;
|
||||
+ proxy_set_header X-Forwarded-Proto https;
|
||||
+ proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
**`listen 443 ssl`과 `proxy_pass http://`는 건드리지 않는다.**
|
||||
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
|
||||
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
|
||||
|
||||
배포:
|
||||
|
||||
```bash
|
||||
cd ~/workspace/keycloak-pattern && git pull
|
||||
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
|
||||
```
|
||||
|
||||
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
|
||||
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
|
||||
|
||||
### B. Traefik — nginx의 헤더를 신뢰한다
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
|
||||
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
|
||||
|
||||
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
|
||||
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
|
||||
덮어써야 한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChartConfig
|
||||
metadata:
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
spec:
|
||||
valuesContent: |-
|
||||
ports:
|
||||
web:
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
|
||||
- 192.168.122.0/24 # 노드·호스트 대역
|
||||
websecure:
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- 10.42.0.0/16
|
||||
- 192.168.122.0/24
|
||||
```
|
||||
|
||||
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
|
||||
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
|
||||
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
|
||||
|
||||
적용:
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
||||
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
||||
```
|
||||
|
||||
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
|
||||
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
|
||||
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
|
||||
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
|
||||
|
||||
파드 이름과 인자로 확인한다.
|
||||
|
||||
```bash
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
|
||||
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
|
||||
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
|
||||
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
|
||||
```
|
||||
|
||||
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
|
||||
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
|
||||
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
|
||||
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
|
||||
파드가 없을 때 트래픽이 버려진다.
|
||||
|
||||
### C. 앱 — 도착한 헤더를 해석한다
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
|
||||
| 위치 | `spec.template.spec.containers[0].env` |
|
||||
|
||||
```diff
|
||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||
- value: "none"
|
||||
+ value: "native"
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/lab/k8s/echo.yaml
|
||||
kubectl -n header-lab rollout status deployment/echo --timeout=180s
|
||||
```
|
||||
|
||||
앱마다 스위치 이름이 다르다.
|
||||
|
||||
| 앱 | 설정 | 넣는 곳 |
|
||||
|---|---|---|
|
||||
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
|
||||
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
|
||||
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
|
||||
|
||||
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 단계별 측정 결과
|
||||
|
||||
각 스위치를 하나씩 켜면서 측정한 값이다.
|
||||
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
|
||||
|
||||
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|
||||
|---|---|---|---|---|
|
||||
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
|
||||
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
||||
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
|
||||
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
||||
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
||||
| `secure` | `false` | `false` | **`false`** | **`true`** |
|
||||
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
||||
|
||||
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
|
||||
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
|
||||
|
||||
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
|
||||
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
|
||||
|
||||
**C 이후에야 앱이 원래 요청을 인식한다.**
|
||||
|
||||
최종 상태:
|
||||
|
||||
```
|
||||
x-forwarded-proto https
|
||||
x-forwarded-port 443
|
||||
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
|
||||
scheme https
|
||||
secure True
|
||||
serverPort 443
|
||||
remoteAddr 100.123.124.30
|
||||
requestUrl https://app1.hyeonworks.com/api/echo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 검증
|
||||
|
||||
### 정상 동작
|
||||
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
||||
```
|
||||
|
||||
| 항목 | 기대값 |
|
||||
|---|---|
|
||||
| `x-forwarded-proto` | `https` |
|
||||
| `x-forwarded-port` | `443` |
|
||||
| `x-real-ip` | 실제 클라이언트 IP |
|
||||
| `scheme` | `https` |
|
||||
| `secure` | `true` |
|
||||
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
|
||||
|
||||
### 위조 차단 — 이쪽이 더 중요하다
|
||||
|
||||
헤더 신뢰를 켠 뒤에는 **위조가 여전히 막히는지 반드시 확인해야 한다.**
|
||||
|
||||
```bash
|
||||
curl -s https://app1.hyeonworks.com/api/echo \
|
||||
-H 'X-Forwarded-Proto: http' \
|
||||
-H 'X-Forwarded-Host: evil.example.com' \
|
||||
-H 'X-Forwarded-For: 1.2.3.4' \
|
||||
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
|
||||
```
|
||||
|
||||
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
|
||||
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
|
||||
|
||||
방어의 주체가 바뀌었다는 점에 유의한다.
|
||||
|
||||
| | 수정 전 | 수정 후 |
|
||||
|---|---|---|
|
||||
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
|
||||
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
|
||||
|
||||
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
|
||||
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
|
||||
사슬 앞부분에 남아 신뢰 경계가 무너진다.
|
||||
|
||||
### 프록시 우회 경로 차단
|
||||
|
||||
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
|
||||
있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
|
||||
수 있으므로 실제로 위조가 성립했다.
|
||||
|
||||
```bash
|
||||
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
||||
curl -s http://echo:8081/api/echo \
|
||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||
```
|
||||
|
||||
```
|
||||
serverName evil.example.com ← 위조 성공
|
||||
remoteAddr 1.2.3.4 ← 위조 성공
|
||||
requestUrl https://evil.example.com/api/echo
|
||||
```
|
||||
|
||||
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
|
||||
|
||||
| 계층 | 신뢰 범위 | 지정 방식 |
|
||||
|---|---|---|
|
||||
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
|
||||
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
|
||||
|
||||
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
|
||||
(측정 중 실제로 `10.42.0.8` → `10.42.1.12`로, 노드까지 옮겨갔다).
|
||||
|
||||
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8081
|
||||
```
|
||||
|
||||
`namespaceSelector`와 `podSelector`를 **같은 리스트 항목**에 두면 AND로
|
||||
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
|
||||
주의한다.
|
||||
|
||||
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
|
||||
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
|
||||
**파드가 재시작 루프에 빠진다.**
|
||||
|
||||
```yaml
|
||||
- from:
|
||||
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
|
||||
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
|
||||
```
|
||||
|
||||
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
|
||||
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
|
||||
파드가 다시 들어와 정책이 무의미해진다.
|
||||
|
||||
**적용 후 확인**
|
||||
|
||||
```
|
||||
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
|
||||
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
|
||||
파드 상태 1/1 Running, restarts=0 probe 정상
|
||||
```
|
||||
|
||||
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
||||
앞의 것만 하면 이 구멍이 남는다.
|
||||
|
||||
## 12. 증거
|
||||
|
||||
`docs/evidence/two-hop-proxy-headers/`
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `01-environment.txt` | 수정 전 세 계층 설정 |
|
||||
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
|
||||
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
|
||||
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
|
||||
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
|
||||
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
|
||||
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고
|
||||
|
||||
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
|
||||
- 개념 상세: `docs/session-lab-concepts.md`
|
||||
- 패턴 비교: `docs/four-pattern-tradeoff-matrix.md`
|
||||
- 측정 배포: `deploy/lab/k8s/echo.yaml`
|
||||
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`
|
||||
Generated
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "keycloak-pattern-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "keycloak-pattern-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "keycloak-pattern-e2e",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:pattern1": "node pattern1.mjs",
|
||||
"test:role-mapping": "node role-mapping.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const username = process.env.E2E_USERNAME ?? "regular-user";
|
||||
const password = process.env.E2E_PASSWORD;
|
||||
const frontendUrl = "http://localhost:8088/";
|
||||
const tokenEndpoint =
|
||||
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token";
|
||||
const revokeEndpoint =
|
||||
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/revoke";
|
||||
|
||||
assert.ok(password, "E2E_PASSWORD must be set");
|
||||
|
||||
async function login(page) {
|
||||
await page.locator("#login").click();
|
||||
await page.waitForURL(/localhost:8080/u);
|
||||
await page.locator("#username").waitFor();
|
||||
|
||||
const tokenResponsePromise = page.waitForResponse((response) =>
|
||||
response.url() === tokenEndpoint
|
||||
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
||||
);
|
||||
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await page.locator("#kc-login").click();
|
||||
|
||||
const tokenResponse = await tokenResponsePromise;
|
||||
assert.equal(tokenResponse.status(), 200);
|
||||
const tokenSet = await tokenResponse.json();
|
||||
assert.ok(tokenSet.access_token);
|
||||
assert.ok(tokenSet.refresh_token);
|
||||
assert.ok(tokenSet.id_token);
|
||||
|
||||
await page.waitForURL(frontendUrl);
|
||||
await page.locator('[data-authenticated="true"]').waitFor();
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
async function postForm(url, values) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(values),
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh(refreshToken) {
|
||||
return postForm(tokenEndpoint, {
|
||||
grant_type: "refresh_token",
|
||||
client_id: "spa-public",
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
async function callResource(accessToken, url = "http://localhost:8081/api/me") {
|
||||
return fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
let authorizationUrl;
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/protocol/openid-connect/auth")) {
|
||||
authorizationUrl = new URL(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(frontendUrl);
|
||||
const firstTokenSet = await login(page);
|
||||
|
||||
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
||||
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
||||
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.__xssProbe = { authorization: null };
|
||||
window.fetch = (input, init = {}) => {
|
||||
const headers = new Headers(
|
||||
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||
);
|
||||
const authorization = headers.get("Authorization");
|
||||
if (authorization) {
|
||||
window.__xssProbe.authorization = authorization;
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
|
||||
await page.locator("#call-api").click();
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector("#result")?.textContent ?? "";
|
||||
return text.includes('"httpStatus": 200');
|
||||
});
|
||||
|
||||
const capturedAuthorization = await page.evaluate(
|
||||
() => window.__xssProbe.authorization,
|
||||
);
|
||||
assert.match(capturedAuthorization, /^Bearer /u);
|
||||
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
||||
assert.equal(accessToken, firstTokenSet.access_token);
|
||||
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
||||
);
|
||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
assert.ok(audiences.includes("keycloak-pattern-api"));
|
||||
|
||||
const storageSnapshot = await page.evaluate(() => ({
|
||||
localStorage: Object.values(localStorage),
|
||||
sessionStorage: Object.values(sessionStorage),
|
||||
}));
|
||||
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
|
||||
assert.ok(capturedAuthorization);
|
||||
|
||||
if (process.env.WRONG_AUDIENCE_URL) {
|
||||
assert.equal(
|
||||
(await callResource(accessToken, process.env.WRONG_AUDIENCE_URL)).status,
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (process.env.WRONG_ISSUER_URL) {
|
||||
assert.equal(
|
||||
(await callResource(accessToken, process.env.WRONG_ISSUER_URL)).status,
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const logoutRequestPromise = page.waitForRequest((request) =>
|
||||
request.url().includes("/protocol/openid-connect/logout"),
|
||||
);
|
||||
await page.locator("#logout").click();
|
||||
const logoutRequest = await logoutRequestPromise;
|
||||
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
|
||||
await page.waitForURL(frontendUrl);
|
||||
await page.locator('[data-authenticated="false"]').waitFor();
|
||||
|
||||
const secondTokenSet = await login(page);
|
||||
const rotatedResponse = await refresh(secondTokenSet.refresh_token);
|
||||
assert.equal(rotatedResponse.status, 200);
|
||||
const rotated = await rotatedResponse.json();
|
||||
assert.ok(rotated.refresh_token);
|
||||
assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token);
|
||||
|
||||
const reusedOldResponse = await refresh(secondTokenSet.refresh_token);
|
||||
assert.notEqual(
|
||||
reusedOldResponse.status,
|
||||
200,
|
||||
"a consumed refresh token must not be accepted again",
|
||||
);
|
||||
|
||||
const rotatedAfterReuseResponse = await refresh(rotated.refresh_token);
|
||||
const rotatedAfterReuseStatus = rotatedAfterReuseResponse.status;
|
||||
assert.ok([200, 400, 401].includes(rotatedAfterReuseStatus));
|
||||
|
||||
assert.equal(
|
||||
(await callResource(rotated.access_token)).status,
|
||||
200,
|
||||
"a locally validated access JWT remains usable until exp",
|
||||
);
|
||||
|
||||
await context.clearCookies();
|
||||
await page.reload();
|
||||
await page.locator('[data-authenticated="false"]').waitFor();
|
||||
const thirdTokenSet = await login(page);
|
||||
|
||||
const revokeResponse = await postForm(revokeEndpoint, {
|
||||
token: thirdTokenSet.refresh_token,
|
||||
token_type_hint: "refresh_token",
|
||||
client_id: "spa-public",
|
||||
});
|
||||
assert.equal(revokeResponse.status, 200);
|
||||
assert.notEqual((await refresh(thirdTokenSet.refresh_token)).status, 200);
|
||||
assert.equal(
|
||||
(await callResource(thirdTokenSet.access_token)).status,
|
||||
200,
|
||||
"refresh revoke is not an immediate deny-list for a stateless access JWT",
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.locator('[data-authenticated="false"]').waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
(token) => JSON.stringify({
|
||||
localStorage: Object.values(localStorage),
|
||||
sessionStorage: Object.values(sessionStorage),
|
||||
}).includes(token),
|
||||
thirdTokenSet.access_token,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
console.log(
|
||||
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
|
||||
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const keycloakUrl = "http://localhost:8080";
|
||||
const frontendUrl = "http://localhost:8088/";
|
||||
|
||||
async function accessToken(browser, username, password) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(frontendUrl);
|
||||
const tokenResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/protocol/openid-connect/token")
|
||||
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
||||
);
|
||||
await page.locator("#login").click();
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await page.locator("#kc-login").click();
|
||||
const response = await tokenResponse;
|
||||
assert.equal(response.status(), 200);
|
||||
const token = (await response.json()).access_token;
|
||||
await context.close();
|
||||
return token;
|
||||
}
|
||||
|
||||
async function adminStatus(token) {
|
||||
return (
|
||||
await fetch("http://localhost:8081/api/admin", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
).status;
|
||||
}
|
||||
|
||||
const regularPassword = process.env.REGULAR_USER_PASSWORD;
|
||||
const adminPassword = process.env.ADMIN_USER_PASSWORD;
|
||||
assert.ok(regularPassword && adminPassword);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
|
||||
try {
|
||||
const regularToken = await accessToken(
|
||||
browser,
|
||||
"regular-user",
|
||||
regularPassword,
|
||||
);
|
||||
const regularPayload = JSON.parse(
|
||||
Buffer.from(regularToken.split(".")[1], "base64url").toString(),
|
||||
);
|
||||
assert.ok(regularPayload.realm_access.roles.includes("user-role"));
|
||||
assert.equal(await adminStatus(regularToken), 403);
|
||||
|
||||
const adminToken = await accessToken(browser, "admin-user", adminPassword);
|
||||
const adminPayload = JSON.parse(
|
||||
Buffer.from(adminToken.split(".")[1], "base64url").toString(),
|
||||
);
|
||||
assert.ok(adminPayload.realm_access.roles.includes("admin-role"));
|
||||
assert.equal(await adminStatus(adminToken), 200);
|
||||
console.log("Spring RBAC verified: realm role -> ROLE_ authority -> 403/200");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
RUN npm test && npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY --from=build /workspace/dist/app.js /usr/share/nginx/html/app.js
|
||||
|
||||
+13
-48
@@ -3,66 +3,31 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>AP1 · SPA Direct + Resource Server</title>
|
||||
<title>Keycloak Authentication Patterns</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
max-width: 56rem;
|
||||
margin: 6vh auto;
|
||||
max-width: 48rem;
|
||||
margin: 8vh auto;
|
||||
padding: 0 1.5rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
button {
|
||||
margin: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
code, pre {
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, CanvasText 9%, Canvas);
|
||||
line-height: 1.6;
|
||||
}
|
||||
code {
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
pre {
|
||||
min-height: 8rem;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.notice {
|
||||
border-left: 0.3rem solid #e09f3e;
|
||||
padding-left: 1rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, CanvasText 10%, Canvas);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AP1 · SPA Direct + Resource Server</h1>
|
||||
<p>
|
||||
바닐라 JavaScript SPA가 <code>spa-public</code> client로 Authorization
|
||||
Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
|
||||
전달합니다.
|
||||
</p>
|
||||
<p class="notice">
|
||||
access/refresh token은 메모리에만 존재합니다. 새로고침하면 사라지는 것이
|
||||
이 패턴의 의도된 보안 경계입니다.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<button id="login" type="button">Keycloak 로그인</button>
|
||||
<button id="call-api" type="button" disabled>보호 API 호출</button>
|
||||
<button id="pkce-demo" type="button">수동 PKCE 생성</button>
|
||||
<button id="logout" type="button" disabled>로그아웃</button>
|
||||
</section>
|
||||
|
||||
<p id="session-state" data-authenticated="false">세션 확인 중…</p>
|
||||
<pre id="result" aria-live="polite"></pre>
|
||||
</main>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<h1>Keycloak Authentication Patterns</h1>
|
||||
<p>공통 Docker Compose baseline이 실행 중입니다.</p>
|
||||
<p>
|
||||
공개 API는 <code>/api/public</code>, 보호 API는
|
||||
<code>/api/me</code>에서 확인할 수 있습니다.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,6 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-523
@@ -1,523 +0,0 @@
|
||||
{
|
||||
"name": "keycloak-pattern1-spa",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "keycloak-pattern1-spa",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-client-ts": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
|
||||
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "keycloak-pattern1-spa",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild src/app.js --bundle --format=esm --outfile=dist/app.js --minify --sourcemap",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
function base64Url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
}
|
||||
|
||||
export async function createAccountLinkUrl({
|
||||
keycloakBaseUrl,
|
||||
realm,
|
||||
provider,
|
||||
clientId,
|
||||
redirectUri,
|
||||
sessionState,
|
||||
issuedFor,
|
||||
nonce = crypto.randomUUID(),
|
||||
cryptoApi = crypto,
|
||||
}) {
|
||||
const material = `${nonce}${sessionState}${issuedFor}${provider}`;
|
||||
const digest = await cryptoApi.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(material),
|
||||
);
|
||||
const url = new URL(
|
||||
`${keycloakBaseUrl}/realms/${realm}/broker/${provider}/link`,
|
||||
);
|
||||
url.search = new URLSearchParams({
|
||||
nonce,
|
||||
hash: base64Url(new Uint8Array(digest)),
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
return url;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
InMemoryWebStorage,
|
||||
UserManager,
|
||||
WebStorageStateStore,
|
||||
} from "oidc-client-ts";
|
||||
|
||||
import { createPkcePair } from "./pkce.js";
|
||||
|
||||
const authority = "http://localhost:8080/realms/keycloak-patterns";
|
||||
const backendBaseUrl = "http://localhost:8081";
|
||||
|
||||
const userManager = new UserManager({
|
||||
authority,
|
||||
client_id: "spa-public",
|
||||
redirect_uri: "http://localhost:8088/callback.html",
|
||||
post_logout_redirect_uri: "http://localhost:8088/",
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
automaticSilentRenew: true,
|
||||
monitorSession: false,
|
||||
loadUserInfo: false,
|
||||
userStore: new WebStorageStateStore({
|
||||
store: new InMemoryWebStorage(),
|
||||
}),
|
||||
stateStore: new WebStorageStateStore({
|
||||
store: window.sessionStorage,
|
||||
}),
|
||||
});
|
||||
|
||||
const loginButton = document.querySelector("#login");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const apiButton = document.querySelector("#call-api");
|
||||
const pkceButton = document.querySelector("#pkce-demo");
|
||||
const sessionState = document.querySelector("#session-state");
|
||||
const result = document.querySelector("#result");
|
||||
|
||||
let currentUser = null;
|
||||
|
||||
function renderJson(value) {
|
||||
result.textContent = JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function tokenMetadata(user) {
|
||||
return {
|
||||
subject: user.profile.sub,
|
||||
username: user.profile.preferred_username,
|
||||
expiresAt: new Date(user.expires_at * 1000).toISOString(),
|
||||
accessTokenHeldBy: "browser memory",
|
||||
refreshTokenHeldBy: user.refresh_token ? "browser memory" : "not issued",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSession(user) {
|
||||
currentUser = user;
|
||||
const authenticated = Boolean(user && !user.expired);
|
||||
sessionState.dataset.authenticated = String(authenticated);
|
||||
sessionState.textContent = authenticated
|
||||
? `${user.profile.preferred_username} 로그인됨 · token은 메모리에만 보관`
|
||||
: "로그인되지 않음 · 브라우저 저장소에 token 없음";
|
||||
loginButton.disabled = authenticated;
|
||||
logoutButton.disabled = !authenticated;
|
||||
apiButton.disabled = !authenticated;
|
||||
|
||||
if (authenticated) {
|
||||
renderJson(tokenMetadata(user));
|
||||
}
|
||||
}
|
||||
|
||||
async function finishSigninCallback() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isCallback = window.location.pathname === "/callback.html"
|
||||
&& (params.has("code") || params.has("error"));
|
||||
|
||||
if (!isCallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
return user;
|
||||
}
|
||||
|
||||
async function callProtectedApi() {
|
||||
if (!currentUser || currentUser.expired) {
|
||||
throw new Error("로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendBaseUrl}/api/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentUser.access_token}`,
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
renderJson({
|
||||
httpStatus: response.status,
|
||||
resourceServerResponse: body,
|
||||
tokenBoundary: tokenMetadata(currentUser),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`보호 API가 ${response.status}를 반환했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
loginButton.addEventListener("click", () => userManager.signinRedirect());
|
||||
logoutButton.addEventListener("click", () => userManager.signoutRedirect());
|
||||
apiButton.addEventListener("click", () => {
|
||||
callProtectedApi().catch((error) => renderJson({ error: error.message }));
|
||||
});
|
||||
pkceButton.addEventListener("click", () => {
|
||||
createPkcePair()
|
||||
.then(({ verifier, challenge, method }) => renderJson({
|
||||
method,
|
||||
verifierLength: verifier.length,
|
||||
challengeLength: challenge.length,
|
||||
note: "실제 로그인에서는 oidc-client-ts가 같은 S256 단계를 수행합니다.",
|
||||
}))
|
||||
.catch((error) => renderJson({ error: error.message }));
|
||||
});
|
||||
|
||||
userManager.events.addUserLoaded(renderSession);
|
||||
userManager.events.addUserUnloaded(() => renderSession(null));
|
||||
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
||||
|
||||
try {
|
||||
const callbackUser = await finishSigninCallback();
|
||||
renderSession(callbackUser ?? await userManager.getUser());
|
||||
} catch (error) {
|
||||
renderSession(null);
|
||||
renderJson({ error: error.message });
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
function base64Url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/u, "");
|
||||
}
|
||||
|
||||
export async function createPkcePair(cryptoApi = globalThis.crypto) {
|
||||
const verifierBytes = new Uint8Array(32);
|
||||
cryptoApi.getRandomValues(verifierBytes);
|
||||
const verifier = base64Url(verifierBytes);
|
||||
const digest = await cryptoApi.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge: base64Url(new Uint8Array(digest)),
|
||||
method: "S256",
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const { createAccountLinkUrl } = await import("../src/account-linking.js");
|
||||
|
||||
test("creates a signed client-initiated account-link URL", async () => {
|
||||
const url = await createAccountLinkUrl({
|
||||
keycloakBaseUrl: "https://auth.example.test",
|
||||
realm: "keycloak-patterns",
|
||||
provider: "google",
|
||||
clientId: "spa-public",
|
||||
redirectUri: "https://app.example.test/settings/identity",
|
||||
sessionState: "session-state",
|
||||
issuedFor: "spa-public",
|
||||
nonce: "fixed-nonce",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
url.pathname,
|
||||
"/realms/keycloak-patterns/broker/google/link",
|
||||
);
|
||||
assert.equal(url.searchParams.get("client_id"), "spa-public");
|
||||
assert.equal(url.searchParams.get("nonce"), "fixed-nonce");
|
||||
assert.match(url.searchParams.get("hash"), /^[A-Za-z0-9_-]{43}$/u);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user