Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 9c3cde457e docs: close nine gaps found by re-reading every open question in full
Cross-checked each question's 남은 미지수, 다음 검증 and 제약 against the plan item by item. Adds B-0 (autoconfiguration actually chosen), B-6 (encryption key rotation) and B-7 (oauth2-proxy cookie secret rotation) as new experiments, plus lock-holder death, rotation-disabled comparison, partial-logout recovery, store latency and the Q4 design checklist. Restores the Redis persistence comparison and records the correct index URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:16:59 +09:00
DongHyeonkaandClaude Opus 5 f1e8c35805 docs: plan all remaining experiments with architecture, injection points and predictions
Twenty experiments across four layers, each with a topology diagram marking where the fault goes in, the metrics to watch, a falsifiable prediction written before the run, and a pass/fail rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:50:23 +09:00
DongHyeonkaandClaude Opus 5 1de6108157 docs: add the prerequisite knowledge this lab assumes
Builds up from HTTP statelessness to why session storage location determines the operational response, so the measurements have context to land in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:24:21 +09:00
DongHyeonkaandClaude Opus 5 22d873eb4f docs: capture the SQL the other node actually runs, and correct the replication claim
PostgreSQL statement logging shows keycloak-1 reading and updating the session created on keycloak-0. The same transaction reveals optimistic locking via VERSION, SKIP LOCKED, and synchronous_commit turned off. Fixes the earlier concept note that credited Infinispan with cross-node propagation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:14:45 +09:00
DongHyeonkaandClaude Opus 5 e5ebaeb623 docs: prove sessions are shared by PostgreSQL, not Infinispan replication
Experiment 0 with three probes: cross-node refresh/logout, cache counter deltas around a single login, and cache entry ownership. Each node caches only what it handled; cache totals sum exactly to the database count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:58:10 +09:00
DongHyeonkaandClaude Opus 5 006da7d490 docs: record observability setup and add concept layers 10-13
Adds Kubernetes resources (StatefulSet, PVC, Secret, RBAC, placement), Keycloak clustering internals (Infinispan, JGroups), Prometheus concepts and virtualization operations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:21:45 +09:00
DongHyeonkaandClaude Opus 5 d5cc2b55a9 fix: grant nodes/proxy so kubelet metrics can be scraped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:14:51 +09:00
DongHyeonkaandClaude Opus 5 0bb0e0ac49 feat: add Prometheus, node-exporter and Grafana for fault-injection observability
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:13:41 +09:00
DongHyeonkaandClaude Opus 5 33878e8880 docs: record multi-node cluster setup, rationale and formation evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:26:45 +09:00
DongHyeonkaandClaude Opus 5 6dce35ec83 feat: deploy Keycloak multi-node cluster with PostgreSQL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:17:25 +09:00
DongHyeonkaandClaude Opus 5 001efd624a docs: correct the memory analysis to distinguish host and guest headroom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:02:11 +09:00
DongHyeonkaandClaude Opus 5 d6f8b9f8b3 docs: restructure roadmap into A/B/operations layers and add five operational items
Adds backup rehearsal, version upgrade, observability, secret management and certificate renewal. Corrects the experiment order so the refresh-token contention test runs after the shared store exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:56:13 +09:00
DongHyeonkaandClaude Opus 5 df4d3b4345 docs: map published open questions to lab coverage and fix experiment order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:44:12 +09:00
DongHyeonkaandClaude Opus 5 61ba5db259 docs: add lab operations guide with tooling, commands and drills
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:39:45 +09:00
DongHyeonkaandClaude Opus 5 bc784fcd6e docs: surface the spoofing bypass finding in the roadmap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:24:52 +09:00
DongHyeonkaandClaude Opus 5 ddcb1c08e6 docs: record the session store lab roadmap and progress
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:20:38 +09:00
DongHyeonkaandClaude Opus 5 e1ba9c5626 docs: record proxy-bypass closure with before and after evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:17:24 +09:00
DongHyeonkaandClaude Opus 5 3af52bb66a feat: narrow Traefik trusted range and restrict echo ingress to Traefik
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:15:28 +09:00
DongHyeonkaandClaude Opus 5 98874b0c6c docs: retake staged evidence screenshots with indented output
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:52:47 +09:00
DongHyeonkaandClaude Opus 5 b708c8d503 feat: indent echo responses for readable evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:48:57 +09:00
DongHyeonkaandClaude Opus 5 7737787937 docs: record the staged fix and post-fix evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:33:40 +09:00
DongHyeonkaandClaude Opus 5 1c1b86e849 feat: let the app interpret forwarded headers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:30:31 +09:00
DongHyeonkaandClaude Opus 5 2294c52095 feat: make Traefik trust forwarded headers from the host nginx
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:29:18 +09:00
DongHyeonkaandClaude Opus 5 69d4502757 docs: collect evidence for the two-hop header contract failure
Control experiment isolates two independent causes: the nginx 443 block still emits X-Forwarded-Proto http, and Traefik rewrites forwarded headers regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:04:38 +09:00
DongHyeonkaandClaude Opus 5 ae1f391598 docs: expand two-hop header contract with switch locations and per-pattern impact
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:57:08 +09:00
DongHyeonkaandClaude Opus 5 844d6f1d33 docs: measure and diagnose the two-hop proxy header contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:35:27 +09:00
DongHyeonkaandClaude Opus 5 a831792c5c docs: map deploy/ assets and record why unapplied configs are kept
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:18:08 +09:00
DongHyeonkaandClaude Opus 5 bcfdeb93ee docs: explain every setting used in the echo manifest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:11:18 +09:00
DongHyeonkaandClaude Opus 5 deae8966b8 docs: record why Docker must not be installed on the lab host
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:53:36 +09:00
DongHyeonkaandClaude Opus 5 6c90468c5a feat: report pod address from echo endpoint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:44:29 +09:00
DongHyeonka 51bb055d61 feat: 2홉 구성 진행 2026-09-03 11:46:56 +09:00
DongHyeonka c22b217fbd chore: 메타데이터 gitignore 2026-09-02 14:44:06 +09:00
donghyeon-ka c07593c471 merge: four-pattern tradeoff matrix 2026-07-25 16:31:44 +09:00
donghyeon-ka d01a60964a docs: compare four authentication patterns 2026-07-25 16:31:44 +09:00
donghyeon-ka 5d7544256a merge: public domain tunnel profile 2026-07-25 16:31:13 +09:00
donghyeon-ka eacc0e86c9 feat: add validated named tunnel profile 2026-07-25 16:31:13 +09:00
donghyeon-ka ac912cb354 merge: HTTPS termination profiles 2026-07-25 16:30:25 +09:00
donghyeon-ka e4cee2a06d feat: add validated HTTPS termination profiles 2026-07-25 16:30:25 +09:00
donghyeon-ka 006b405ad5 merge: reverse proxy header contract 2026-07-25 16:29:32 +09:00
donghyeon-ka 8539d1bf5b feat: define trusted reverse proxy header contract 2026-07-25 16:29:32 +09:00
donghyeon-ka 3473875d9a merge: Google redirect URI policy 2026-07-25 16:28:53 +09:00
donghyeon-ka f077e5038e docs: define exact Google redirect URI policy 2026-07-25 16:28:53 +09:00
donghyeon-ka e4eb7e54f7 merge: federated subject identity contract 2026-07-25 16:28:00 +09:00
89 changed files with 11278 additions and 1 deletions
+7
View File
@@ -9,3 +9,10 @@ e2e/node_modules/
google-e2e/node_modules/ google-e2e/node_modules/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ 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
+5
View File
@@ -15,6 +15,11 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
- AP3: Backend-for-Frontend (BFF) - AP3: Backend-for-Frontend (BFF)
- AP4: Edge forward-auth - 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, 현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
위에서 별도 브랜치로 진행합니다. 위에서 별도 브랜치로 진행합니다.
@@ -1,6 +1,8 @@
package com.example.keycloakpattern; package com.example.keycloakpattern;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class ApiController { public class ApiController {
@@ -18,6 +22,38 @@ public class ApiController {
return Map.of("status", "ok", "service", "keycloak-pattern-api"); 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") @GetMapping("/me")
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) { public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> response = new LinkedHashMap<>(); Map<String, Object> response = new LinkedHashMap<>();
@@ -17,7 +17,8 @@ public class SecurityConfig {
.sessionManagement(session -> .sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
"/api/echo")
.permitAll() .permitAll()
.anyRequest() .anyRequest()
.authenticated()) .authenticated())
@@ -1,9 +1,19 @@
server: server:
port: ${SERVER_PORT:8081} 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: spring:
application: application:
name: keycloak-pattern-api 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: security:
oauth2: oauth2:
resourceserver: resourceserver:
@@ -25,6 +25,18 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.status").value("ok")); .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 @Test
void protectedEndpointRejectsAnonymousRequests() throws Exception { void protectedEndpointRejectsAnonymousRequests() throws Exception {
mockMvc.perform(get("/api/me")) mockMvc.perform(get("/api/me"))
+136
View File
@@ -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:`이면 실행된 것이다.
+37
View File
@@ -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
+56
View File
@@ -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;
}
}
+62
View File
@@ -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
+113
View File
@@ -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
+277
View File
@@ -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
+373
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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 세션 /'
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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)"
+42
View File
@@ -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"
+47
View File
@@ -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."
+47
View File
@@ -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"
@@ -0,0 +1,6 @@
# Keycloak receives HTTP only from the trusted reverse proxy.
KC_HTTP_ENABLED=true
KC_PROXY_HEADERS=xforwarded
KC_HOSTNAME=https://auth.example.test
KC_HOSTNAME_STRICT=true
+14
View File
@@ -0,0 +1,14 @@
server {
listen 8080;
server_name auth.example.test;
location / {
proxy_pass http://keycloak:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+10
View File
@@ -0,0 +1,10 @@
auth.example.test {
tls /etc/tls/tls.crt /etc/tls/tls.key
reverse_proxy keycloak:8080 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Port 443
header_up X-Forwarded-Proto https
}
}
+21
View File
@@ -0,0 +1,21 @@
events {}
http {
server {
listen 443 ssl;
server_name auth.example.test;
ssl_certificate /etc/tls/tls.crt;
ssl_certificate_key /etc/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
tunnel: 00000000-0000-0000-0000-000000000000
credentials-file: /etc/cloudflared/00000000-0000-0000-0000-000000000000.json
ingress:
- hostname: auth.example.test
service: http://reverse-proxy:8080
- service: http_status:404
@@ -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

+636
View File
@@ -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에
있고, 각각은 **자기를 만든 노드 한 곳에만** 캐시되어 있다.
### 그래프로 본 같은 사실
![세션 캐시 엔트리 수](evidence/session-replication/session-cache-entries-per-pod.png)
`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
+37
View File
@@ -0,0 +1,37 @@
# Four Keycloak integration patterns
| 축 | AP1 SPA direct | AP2 token mediator | AP3 BFF | AP4 edge auth |
|---|---|---|---|---|
| OAuth client | public | confidential | confidential | confidential proxy |
| browser 보유물 | access/refresh token | 짧은 handoff code 또는 app token | HttpOnly session cookie | proxy session cookie |
| OAuth code 교환 | browser + PKCE | mediator backend | BFF | oauth2-proxy |
| API bearer 검증 | Spring resource server | mediator/downstream API | BFF 내부 또는 downstream | edge가 인증 후 trusted header |
| server session | 없음 | handoff 상태만 짧게 | 필수 | proxy cookie/session |
| XSS token 탈취면 | 가장 큼 | 축소 | browser token 제거 | browser token 제거 |
| CSRF 주의 | token endpoint/refresh 설계 | app cookie 사용 시 | 필수 방어 | proxy cookie 사용 시 |
| 수평 확장 상태 | 단순 | handoff store 공유 가능 | session store 필요 | proxy 설정에 따름 |
| 주 학습 포인트 | PKCE/JWT/RS | token 경계·one-time handoff | oauth2Login/session/CSRF | auth_request/header trust |
## 선택 기준
- 브라우저에서 OAuth와 token 수명주기를 직접 학습하려면 AP1.
- 브라우저에 upstream token을 주지 않되 API 호출은 bearer 중심으로 유지하려면
AP2.
- token을 browser에서 완전히 제거하고 애플리케이션 단위 인가·세션을
중앙화하려면 AP3.
- 기존 upstream을 수정하기 어렵고 경계에서 일괄 인증하려면 AP4.
Google federation은 다섯 번째 인증 패턴이 아니다. 네 패턴 모두 최종적으로
Keycloak token/session을 소비하며, Google은 Keycloak 앞의 upstream IdP
hop으로 추가된다.
## 이 repository의 실행 증거
- AP1: PKCE SPA, issuer/audience, token storage, refresh/logout 검증
- AP2: confidential client와 one-time access handoff 검증
- AP3: `oauth2Login` session과 CSRF/SameSite 검증
- AP4: oauth2-proxy, nginx `auth_request`, spoofed header 제거 검증
- 공통: local mock Google brokering, First Broker Login, claim/role mapping 검증
각 근거 브랜치와 병합 여부는 `keycloak-branch-manifest.tsv`
`audit-keycloak-branches.sh`로 추적한다.
+24
View File
@@ -0,0 +1,24 @@
# Google redirect URI policy
Google에 등록하는 redirect URI는 애플리케이션 SPA callback이 아니라 Keycloak
broker endpoint다.
```text
https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint
```
규칙:
- production URI는 HTTPS와 고정된 public Keycloak origin을 사용한다.
- wildcard, path prefix, 임시 tunnel hostname을 production OAuth client에
등록하지 않는다.
- 개발·스테이징·운영은 Google OAuth client를 분리한다.
- reverse proxy가 있더라도 Google이 보는 URI와 Keycloak이 생성하는 URI가
byte-for-byte 같아야 한다.
- `configure-google-idp.sh`가 출력하는 URI를 Google Console의 Authorized
redirect URI와 대조한다.
```sh
PUBLIC_KEYCLOAK_URL=https://auth.example.test \
./scripts/verify-google-redirect-uri-policy.sh
```
+20
View File
@@ -0,0 +1,20 @@
# HTTPS termination: nginx or Caddy
두 예제 모두 public `443`에서 TLS를 종료하고 private Docker network의
`keycloak:8080`으로 전달한다. Keycloak 쪽 설정은
`deploy/reverse-proxy/keycloak.env.example`의 hostname/proxy contract를
같이 사용한다.
- nginx: 인증서 배포·갱신을 운영자가 담당할 때 적합하다.
- Caddy: ACME를 통한 인증서 수명주기를 proxy가 담당하게 할 때 간단하다.
- 둘을 동시에 production entry point로 띄우지 않는다.
- 인증서와 private key는 repository 또는 image에 포함하지 않는다.
- HTTP challenge/redirect 및 방화벽의 80/443 허용은 배포 환경에서 별도로
결정한다.
검증 스크립트는 임시 자체 서명 인증서를 만들고 두 vendor image에서 설정을
각각 validate한 뒤 임시 파일을 제거한다.
```sh
./scripts/verify-https-termination-config.sh
```
+377
View File
@@ -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 등 개념 |
+429
View File
@@ -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`이 필요한 이유 |
+291
View File
@@ -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)의 비교표 |
+21
View File
@@ -0,0 +1,21 @@
# Public HTTPS domain for broker callbacks
Google brokering을 반복 테스트할 때는 Cloudflare **named tunnel + 관리
도메인**을 기본 profile로 사용한다. `trycloudflare.com` quick tunnel과
임의 ngrok URL은 일회성 데모용이며 고정 callback으로 간주하지 않는다.
설정 순서:
1. `cloudflared tunnel login`
2. `cloudflared tunnel create keycloak-patterns`
3. 예제 config의 tunnel UUID와 credentials path를 실제 값으로 교체
4. `cloudflared tunnel route dns keycloak-patterns auth.example.test`
5. `cloudflared tunnel run keycloak-patterns`
6. Keycloak `KC_HOSTNAME`과 Google redirect URI를 같은 public host로 설정
컨테이너 안의 `127.0.0.1`은 cloudflared 컨테이너 자신이므로 origin에는
`reverse-proxy:8080` 같은 Compose service DNS를 사용한다. 마지막 catch-all
ingress는 알 수 없는 hostname을 404로 끝낸다.
실 tunnel 생성과 DNS 변경에는 사용자 소유 계정·도메인이 필요하므로 자동
검증은 ingress 파일의 구조까지만 수행한다.
+15
View File
@@ -0,0 +1,15 @@
# Reverse proxy headers
TLS를 reverse proxy에서 종료하면 Keycloak은 브라우저가 사용한 외부 origin을
정확히 알아야 한다. 배포 예제는 다음 계약을 함께 적용한다.
- nginx는 `Host`, `X-Forwarded-Host`, `X-Forwarded-Port`,
`X-Forwarded-Proto`, `X-Forwarded-For`를 덮어쓴다.
- Keycloak은 `KC_PROXY_HEADERS=xforwarded`로 그 헤더 형식을 명시한다.
- `KC_HOSTNAME`은 외부 HTTPS URL로 고정하고 strict hostname 검증을 켠다.
- Keycloak의 8080 포트는 public으로 publish하지 않고 proxy network에서만
접근시킨다. 신뢰되지 않은 클라이언트가 forwarded header를 직접 넣을 수
있으면 안 된다.
`scripts/verify-reverse-proxy-headers.sh`는 양쪽 설정의 짝과 nginx 구문을
검증한다.
File diff suppressed because it is too large Load Diff
+434
View File
@@ -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개 항목 모두 출력 |
+400
View File
@@ -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 프로세스(파드) 이름 |
+393
View File
@@ -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홉 헤더 계약 원본 |
+816
View File
@@ -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`
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
set -eu
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
public_keycloak_url="${PUBLIC_KEYCLOAK_URL:-https://auth.example.test}"
expected="$public_keycloak_url/realms/$realm/broker/google/endpoint"
case "$public_keycloak_url" in
https://*) ;;
*)
echo "PUBLIC_KEYCLOAK_URL must use https outside the local mock environment" >&2
exit 1
;;
esac
case "$public_keycloak_url" in
*\** | */)
echo "PUBLIC_KEYCLOAK_URL must be an exact origin without wildcard/trailing slash" >&2
exit 1
;;
esac
test "$expected" = "https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint"
echo "Google redirect URI policy verified: $expected"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env sh
set -eu
test_dir="$(mktemp -d)"
cleanup() {
rm -rf "$test_dir"
}
trap cleanup EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=auth.example.test" \
-keyout "$test_dir/tls.key" \
-out "$test_dir/tls.crt" >/dev/null 2>&1
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/deploy/tls/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$test_dir:/etc/tls:ro" \
nginx:1.29-alpine nginx -t
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/deploy/tls/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "$test_dir:/etc/tls:ro" \
caddy:2.10.2-alpine caddy validate --config /etc/caddy/Caddyfile
echo "nginx and Caddy HTTPS termination configurations verified"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
config=deploy/tunnel/cloudflared-config.yml
grep -q '^tunnel: [0-9a-f-]*$' "$config"
grep -q '^ - hostname: auth.example.test$' "$config"
grep -q '^ service: http://reverse-proxy:8080$' "$config"
grep -q '^ - service: http_status:404$' "$config"
docker run --rm \
-v "$PWD/$config:/etc/cloudflared/config.yml:ro" \
cloudflare/cloudflared:2025.6.1 \
tunnel --config /etc/cloudflared/config.yml ingress validate
echo "Cloudflare named-tunnel ingress configuration verified"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
config=deploy/reverse-proxy/nginx-keycloak.conf
env_file=deploy/reverse-proxy/keycloak.env.example
grep -q 'proxy_set_header X-Forwarded-Host' "$config"
grep -q 'proxy_set_header X-Forwarded-Port 443' "$config"
grep -q 'proxy_set_header X-Forwarded-Proto https' "$config"
grep -q '^KC_PROXY_HEADERS=xforwarded$' "$env_file"
grep -q '^KC_HOSTNAME=https://' "$env_file"
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/$config:/etc/nginx/conf.d/default.conf:ro" \
nginx:1.29-alpine nginx -t
echo "Reverse-proxy header and Keycloak hostname contracts verified"