docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,320 @@
raw — 명령 출력 원문 (정본)
===================
이 폴더가 증거의 정본이다. SVG 는 표현물이고 여기 원문 없이는 만들지 않는다.
파일 이름은 <실험 폴더>__<원본 파일명> 이다. 원본 저장소에서는 실험마다
폴더가 나뉘어 있었고 파일명이 겹쳤다(01-baseline.txt 등). 이 저장소의 감사
스크립트는 raw/ 최상위만 훑으므로 이름을 평평하게 펴고 접두사로 구분했다.
각 파일의 실행 메타는 ../meta/<같은 이름>.json 에 있다.
명령 전문은 그 json 의 sourceDoc 이 가리키는 해설 문서의 「재현 절차」 절에 있다.
[a1-jgroups-transport-block]
a1-jgroups-transport-block__01-baseline-cluster.txt
차단 전 — 양쪽이 뷰 ID 5·멤버 2로 일치, JGROUPS_PING 코디네이터 1명
a1-jgroups-transport-block__02-control-before-block.txt
대조군 — 차단 전 교차 노드 refresh 200, JGroups 지표 전부 0
a1-jgroups-transport-block__03-block-applied.txt
NetworkPolicy 적용. 빈 측정값을 "변화 감지"로 오판한 기록
a1-jgroups-transport-block__04-after-block-state.txt
차단 43초 후 — 지표 무변화, JGROUPS_PING 그대로
a1-jgroups-transport-block__05-conntrack-problem.txt
핵심 문제 — ESTABLISHED [ASSURED] 로 기존 연결이 살아 있음. FD_SOCK2 의 57800 포트도 함께 드러남
a1-jgroups-transport-block__06-partition-observed.txt
임시 curl 파드 폴링의 실패 — 빈 값·개수 불일치
a1-jgroups-transport-block__07-cluster-size.txt
vendor_cluster_size 가 25분 내내 2 — 분단이 일어나지 않았다는 결정적 증거
a1-jgroups-transport-block__08-restart-forced-partition.txt
재연결 강제 후 2 → 1
a1-jgroups-transport-block__09-cross-node-under-partition.txt
본 시험 — 교차 refresh 200(예측 적중), 로그아웃 후 재갱신 200(예측 빗나감)
a1-jgroups-transport-block__10-logout-not-propagated.txt
기제 확정 — DB 행 0건인데 keycloak-0 캐시에 1건, 헬스체크 cluster health: DOWN
a1-jgroups-transport-block__11-service-impact.txt
분단 노드가 Service 에서 빠짐. ready=[10.42.0.35] notReady=[10.42.1.67], 외부 로그인 200
a1-jgroups-transport-block__12-recovery.txt
90초 만에 자동 재형성, merge3_get_num_merge_events = 1, 코디네이터 재선출
[a2-database-loss]
a2-database-loss__01-baseline.txt
정지 전 — 양쪽 Ready, cluster_size=2
a2-database-loss__02-setup-sessions.txt
양쪽 노드에 세션 하나씩. 캐시는 각자 노드에만
a2-database-loss__03-four-paths.txt
네 경로 전부 500. 캐시를 가진 노드도 실패 — refresh 는 쓰기다
a2-database-loss__04-health-and-service.txt
전면 장애 증거 — Ready 파드 0개, ready 주소=[], 외부 503, database connections: DOWN. JWKS·.well-known 은 200
a2-database-loss__05-recovery.txt
up=1 인 채로 503. DB 복귀 15초 후 재시작 0회로 자동 회복, 세션 5건 생존
[a3-database-crash]
a3-database-crash__01-crash-injection.txt
첫 시도 실패 — 파드 안 백그라운드 루프가 exec 종료와 함께 죽어 0건 수집
a3-database-crash__02-design-check.txt
핵심 설계 확인 — 로그인 트랜잭션도 SET LOCAL synchronous_commit TO OFF 로 커밋한다
a3-database-crash__03-loss-measurement.txt
--grace-period=0 --force 주입
a3-database-crash__04-comparison.txt
유실 0건 — 그러나 crash recovery 가 안 돌았다. 죽인 적이 없는 것
a3-database-crash__05-true-crash.txt
kill -9 1 시도 — 컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다
a3-database-crash__06-backend-kill-crash.txt
성공한 주입 — 백엔드에 SIGKILL → not properly shut down / redo starts / redo done
a3-database-crash__07-loss-result.txt
결과: 153건 중 4건 유실. 토큰은 발급됐는데 세션 행이 없는 sid 목록
a3-database-crash__08-wal-settings.txt
(원본 README 에 설명이 없다 — 파일 머리말 참조)
[a4-node-loss]
a4-node-loss__01-baseline.txt
차단 전 — 양쪽 Ready, PVC 가 kc-lab-2 에 못박혀 있음(재배치 불가의 원인), 외부 200
a4-node-loss__02-worker-node-killed.txt
virsh destroy kc-lab-2 — 40초간 노드가 Ready 로 남아 있고 외부는 이미 000. 이후 503
a4-node-loss__03-state-during-loss.txt
죽은 파드가 ready=true, 산 파드가 ready=false. up 은 정확히 0. unreachable taint
a4-node-loss__04-eviction-timing.txt
tolerationSeconds=300 — 5분 뒤 축출, 새 postgres 는 Pending
a4-node-loss__05-recovery.txt
FailedScheduling: didn't match PersistentVolume's node affinity, StatefulSet DESIRED=2 CURRENT=1. 노드 복귀 후 60초
a4-node-loss__06-control-plane-inventory.txt
kc-lab-1 에 있는 것 목록 — Traefik replicas=1
a4-node-loss__07-control-plane-loss.txt
virsh destroy kc-lab-1 — 외부 000, kubectl 불통. 그런데 crictl ps 로 보면 keycloak-0 은 Running
a4-node-loss__08-control-plane-recovery.txt
60초 만에 복귀
[a5-asymmetric-partition]
a5-asymmetric-partition__01-injection.txt
첫 시도 — iptables -I FORWARD 1
a5-asymmetric-partition__02-injection-verify.txt
실패 확인 — kube-router 가 자기 체인을 위로 재삽입, DROP 규칙 0 패킷
a5-asymmetric-partition__03-raw-table-injection.txt
raw 테이블로 이동. 그래도 0 패킷
a5-asymmetric-partition__04-correct-direction.txt
연결 방향이 반대였다. 수신측 노드로 옮기니 19/21 패킷 차단
a5-asymmetric-partition__05-reconnect-observed.txt
핵심 — 연결이 열린 방향으로 뒤집혀 재연결. 뷰 변화 없음, suspected=0
a5-asymmetric-partition__06-view-history-and-cleanup.txt
뷰 ID 이력. 주입 이후 변화 없음 확인
a5-asymmetric-partition__07-bidirectional-block.txt
양방향 차단 → 뷰 14, 멤버 1개씩. 그런데 ready=[10.42.1.77] 로 한쪽은 남고 외부 200
a5-asymmetric-partition__08-coordinator-and-recovery.txt
15] (2)
[a6-latency-injection]
a6-latency-injection__01-baseline.txt
배치 설명(A/B 가 되는 이유), agroal_* 지표 목록, 기준선 70ms / 66ms
a6-latency-injection__02-delay-injected.txt
첫 시도 실패 — Cannot find device "eth0" (Debian 은 enp1s0)
a6-latency-injection__03-flannel-injection.txt
성공 — flannel.1 에 걸어야 파드 IP 가 보인다. netem Sent 150 pkt 로 검증. k0=41ms vs k1=1872ms
a6-latency-injection__04-pool-under-load.txt
동시 20건 — 응답이 1.9초에서 22.2초까지 계단. blocking_time_max=20000ms, max_used_count=19, readiness 프로브 타임아웃
a6-latency-injection__05-recovery.txt
해제 즉시 43ms / 51ms 회복. 낙관적 락 충돌 0건(예측 빗나감)
[a7-volatile-comparison]
a7-volatile-comparison__01-switch-to-volatile.txt
--features-disabled=persistent-user-sessions 적용, args 확인
a7-volatile-comparison__02-a0-rerun.txt
DB 0건인데 교차 노드 refresh 200 — 경로가 DB 에서 클러스터로 바뀌었다
a7-volatile-comparison__03-a8-rerun-restart.txt
롤링 재시작 후 400 Session not active — persistent 에서는 200 이었다
a7-volatile-comparison__04-a1-rerun-partition.txt
7800 차단 시 교차 노드 400 — persistent 에서는 200. 대조군(같은 노드)은 200 유지
a7-volatile-comparison__05-a2-rerun-db-loss.txt
DB 정지 중 새 로그인 200(persistent 에서는 500), refresh 는 500
a7-volatile-comparison__06-restore-persistent.txt
원복 확인 — args: ["start"], 로그인 후 DB 1건, 외부 200
[a7a-volatile-cause]
a7a-volatile-cause__01-cause-determined.txt
가설이 틀렸다. REVOKED_TOKEN 이 아니라 CLIENT_SCOPE_CLIENT 조회다. 문장 로깅으로 잡고, 실패 로그가 그 SQL 을 직접 지목한다. 그리고 같은 설정에서 캐시 온도만으로 400/500/200 셋이 나온다
[a8-rolling-restart]
a8-rolling-restart__01-restart-availability.txt
재시작 전 로그인(sid 기록), 롤링 재시작 중 외부 진입점 9회 모두 200
a8-rolling-restart__02-session-survival.txt
재시작 전 토큰으로 refresh 200, DB 행 생존(last_session_refresh 갱신 확인), 세션 수 151 → 151, 캐시 0으로 초기화, 파드 나이 44초/66초
[b0-bff-redis-deploy]
b0-bff-redis-deploy__01-deploy.txt
배포 전 자원, Redis·BFF 롤아웃, 두 노드에 하나씩 배치됨
b0-bff-redis-deploy__02-autoconfiguration.txt
첫 조회 시도(파싱 실패)와 외부 진입점 HTTP 200
b0-bff-redis-deploy__03-beans-analysis.txt
B-0 의 답 — InMemoryOAuth2AuthorizedClientService, AuthenticatedPrincipalOAuth2AuthorizedClientRepository, Redis·Spring Session 없음
[b1-redis-session-store]
b1-redis-session-store__01-servicelinks-trap.txt
enableServiceLinks: false 적용 후 롤아웃 성공 — 쿠버네티스가 주입한 REDIS_PORT=tcp://... 가 설정을 덮어쓴 문제
b1-redis-session-store__02-autoconfig-after.txt
핵심 — 빈 321→402(+81). sessionRepository → RedisSessionRepository 로 바뀌었지만 authorizedClientService 는 InMemory 그대로
b1-redis-session-store__03-redis-contents.txt
Redis 키 1개, 필드는 SPRING_SECURITY_CONTEXT 뿐. 토큰 없음. Java 직렬화(\xac\xed), TTL 1772초
[b2-multi-instance-session]
b2-multi-instance-session__01-jdbc-store-deploy.txt
JDBC 저장소로 배포. 테이블이 조용히 안 만들어졌다
b2-multi-instance-session__02-schema.txt
원인 — 기본 DDL 은 blob(PostgreSQL 에 없음), -postgres.sql 판본이 따로 있다. PRIMARY KEY (client_registration_id, principal_name) — 조회 키 문제가 DDL 에 박혀 있다
b2-multi-instance-session__03-plaintext-tokens.txt
Q3 검증 2번 — bytea 안이 JWT 문자열 그대로. 디코드하면 {"alg":"HS512",...}
b2-multi-instance-session__04-overwrite-test.txt
Q1 검증 3번 — 같은 사용자 재로그인 시 행 수 1 그대로, issued_at 과 md5 만 바뀜 = UPDATE(덮어쓰기)
b2-multi-instance-session__05-logout-cleanup.txt
Q1 검증 4번 — Redis 0키 / PostgreSQL 1행 잔존 / Keycloak SSO 2세션 잔존
[b3-refresh-contention]
b3-refresh-contention__01-concurrent-refresh.txt
같은 토큰으로 동시 5회 — 1개만 200, 나머지는 Maximum allowed refresh token reuse exceeded 와 Session doesn't have required client 두 종류 오류
b3-refresh-contention__02-session-impact.txt
★ 이긴 요청의 새 토큰조차 400. user_session 행은 남아 있고 revoked_token 은 0건
b3-refresh-contention__03-client-session-removed.txt
기제 확정 (대조군 포함) — 경쟁 세션 client_sessions=0, 정상 세션 client_sessions=1
b3-refresh-contention__04-policy-comparison.txt
정책 3종 비교 — rotation OFF 는 5/5 성공·세션 생존, maxReuse=1 은 여전히 세션 파괴
[b4-edge-authorization]
b4-edge-authorization__01-header-handling.txt
① 동명 헤더가 둘 다 도착(['admin','editor'])하고 값 안의 쉼표를 구분자와 구별할 수 없다 · ② 8KB 에서 Tomcat 400, 16KB 에서 연결 끊김 — 자르지 않고 거부 · ④ 위조 신원 헤더가 그대로 도착, JWT 경로는 401
[b5-redis-loss]
b5-redis-loss__01-baseline.txt
정지 전 — Redis 1키, PostgreSQL 1행, save/appendonly no, 외부 200
b5-redis-loss__02-redis-down.txt
정지 후 — /bff/token-boundary HTTP 000(멈춤), /actuator/health 503, 파드는 1/1 Ready 유지, Lettuce 재연결 스택
b5-redis-loss__03-health-groups.txt
핵심 — /actuator/health 503 인데 /actuator/health/readiness 는 {"status":"UP"}. Service 엔드포인트에 두 파드 모두 남아 있다
b5-redis-loss__04-persistence.txt
복구는 자동(재시작 0회) · AOF 를 켰는데 파드 삭제 후 dbsize 0 · PVC 를 붙인 뒤 written-on-pvc 생존
[b6-key-rotation]
b6-key-rotation__01-before-rotation.txt
회전 전 — 토큰 kid=OY-caYDN..., JWKS RS256 1개, /api/me 200
b6-key-rotation__02-rotation.txt
우선순위 200 공급자 추가 → JWKS RS256 2개, 새 토큰은 새 kid, 옛 토큰도 새 토큰도 200 (무중단)
b6-key-rotation__03-old-key-removed.txt
옛 공급자 제거 → JWKS 1개, 옛 토큰 즉시 401. 리소스 서버 재시작 후에도 동일
[b7-cookie-secret]
b7-cookie-secret__01-deploy.txt
양 노드에 replica 하나씩. / 302, /ping 200
b7-cookie-secret__02-cookie-portability.txt
curl 로 흐름을 완주하려던 시도 — 파드 IP 는 호스트에서 안 닿는다
b7-cookie-secret__03-rotation.txt
--cookie-secret string 단수 확인 · 교체 후 session ticket cookie failed validation · Error removing session · Redis 에 고아 세션 2개
[b7a-orphan-session]
b7a-orphan-session__01-orphan-lifecycle.txt
회전 2회로 고아가 누적하는 것 · TTL 이 1초/초로 줄고 요청으로 갱신되지 않는 것 · Redis 만으로는 구분 불가(이름·타입·크기 동일, 값 암호화) · redis-cli del 로 지워도 산 세션은 200 · TTL 역산 정리 규칙이 1초 오차로 맞는 것
[c1-multi-app-sso]
c1-multi-app-sso__01-baseline.txt
초기화 시도 — logout-all 이 안 먹어 세션 4개가 남았다
c1-multi-app-sso__02-after-app1-login.txt
app1 로그인 후 — user session 1 · client session 1 · Redis 1 · DB 1행
c1-multi-app-sso__03-after-app2-visit.txt
app2 방문 후 client session 1 → 2, bff-confidential 과 oauth2-proxy 가 같은 user session 에 붙음. Redis 에 두 종류 세션
c1-multi-app-sso__04-sso-session-killed.txt
IdP 세션 삭제 후 — 앱 세션 셋 다 남아 있다. realm 을 join 해 보고서야 남은 것이 master 세션임을 확인
[c2-backchannel-logout]
c2-backchannel-logout__01-current-state.txt
두 클라이언트 모두 backchannelLogoutUrl 없음 · BFF 소스에 oidcLogout 없음 · 후보 경로 셋 다 302(핸들러 없음)
c2-backchannel-logout__02-configure-idp.txt
IdP 쪽에만 backchannel.logout.url 설정 (점 표기는 실패, JSON 으로 성공)
c2-backchannel-logout__03-logout-attempt.txt
살아 있는 세션(1)에 로그아웃 → IdP 세션 0, Redis 세션은 1 그대로. Keycloak·BFF 로그에 흔적 없음
c2-backchannel-logout__04-reachability.txt
Keycloak 파드가 app1.hyeonworks.com 에 HTTP 200 으로 닿는다 — 네트워크 문제가 아님
[d1-backup-restore]
d1-backup-restore__01-backup.txt
pg_dump --clean --if-exists — 395KB · 101 테이블 · 세션 데이터 포함
d1-backup-restore__02-destruction.txt
DROP SCHEMA public CASCADE → 테이블 0개. 그런데 외부는 HTTP 200 — Keycloak 이 realm 캐시로 서빙한다
d1-backup-restore__03-restore.txt
깨지는 것과 안 깨지는 것(certs 200 / well-known 500 / 토큰 400) · 복구 1초 · 오류 0건 · 데이터 완전 일치 · 재시작 0회
[d2-version-upgrade]
d2-version-upgrade__01-pre-upgrade.txt
백업 396KB · 이미지 26.7.0 · 마이그레이션 210건 · 세션 4
d2-version-upgrade__02-rollback-attempt.txt
26.0 으로 내리자 Running(0/1) → Error → CrashLoopBackOff. liquibase.exception.ValidationFailedException
d2-version-upgrade__03-roll-forward.txt
서비스는 HTTP 200 유지(ready 주소 1개) · 오류 원인 1 changesets check sum · 26.7.0 복귀 후 마이그레이션 210·세션 4 그대로
[d3-secret-management]
d3-secret-management__01-base64-not-encryption.txt
실험대의 모든 비밀이 명령 네 줄로 평문 출력. describe 는 14 bytes 만 보여줘 착각을 준다
d3-secret-management__02-at-rest.txt
Encryption Status: Disabled · state.db 안에 비밀번호 평문 2회 일치 · 파드 안에서는 KEYCLOAK_CLIENT_SECRET=bff-lab-secret 환경변수 · default SA 는 읽을 수 없음
[d4-certificate-renewal]
d4-certificate-renewal__01-certificate-state.txt
SAN 3개(와일드카드 아님) · 체인 4단계, Verify return code: 0 · certbot-renew.timer enabled·active, 11시간 전 실행 · 88일 남음 · sudo: a password is required 로 강제 갱신 불가
d4-certificate-renewal__05-control-no-injection.txt
대조군 1 — 잡음 바닥. 0.2초 × 900회 / 180초 동안 900 전부 200, 오류 0. 중앙 98ms · p95 195ms. TLS 핸드셰이크 900/900 = 매 요청이 새 연결
d4-certificate-renewal__06-inflight-control.txt
대조군 2 — '진행 중이던 요청' 장치. 845KB 를 --limit-rate 20k 로 받아 요청을 42초간 살려 둔다. 무주입 시 200 · 845361바이트 · 연결수 1
d4-certificate-renewal__07-renewal-hook-missing.txt
nginx 는 reload 된 적이 없다 — 마스터 585·워커 586 이 같은 시각 기동, 22.4시간째. 유닛은 ExecStart=certbot -q renew 가 전부. crt.sh 는 SCT 가 박힌 인증서를 0건으로 답한다
d4-certificate-renewal__08-inflight-artifact.txt
76건 실패는 서버 탓이 아니다 — 같은 순간 폴링 49건 전부 200, 연결수=0, 50µs, 재현 0/100. 대조군이 오보를 막았다
d4-certificate-renewal__09-serial-timeline.txt
일련번호 564표본. 08:10:51 ~ 08:58:47 옛 것 → 08:58:52 새 것
d4-certificate-renewal__10-reload-poll-window.txt
reload 전후 60초 새 연결 원문 — 비200 0건, 최대 373ms
d4-certificate-renewal__11-inflight-full.txt
in-flight 전체 50건. 08:58:40 시작 요청이 08:58:52 reload 를 관통해 845361바이트 전량 수신
d4-certificate-renewal__12-certbot-state.txt
cert2.pem 09-04 17:22:13 기록됨 · renewal-hooks/{deploy,post,pre}/ 셋 다 비었음 · 플러그인 목록에 nginx 없음
d4-certificate-renewal__13-verdict.txt
판정 전문 — 38분 25초 공백(428회 관측) + reload 무중단(8856건 0실패)
[d4a-deploy-hook]
d4a-deploy-hook__01-hook-verified.txt
판정 전문. 훅 실행 로그 · 워커 PID 교체 · 시계 보정과 SCT 교차검증 · D-4 와의 대조
d4a-deploy-hook__02-certbot-with-hook.txt
certbot renew --force-renewal 원문. Hook 'deploy-hook' ran 과 all renewals succeeded
d4a-deploy-hook__03-after-state.txt
실행 후 nginx 프로세스와 서빙 인증서
[followup]
followup__01-d2-forward-upgrade.txt
D-2 정방향 26.7.0 → 26.7.3. 백업 396KB · 87회 요청 전부 200(무중단) · 마이그레이션 210 → 210(스키마 변경 없음) · 세션 3 유지 · Infinispan 16.0.12 → 16.0.14
followup__02-d2-rollback-same-schema.txt
스키마가 안 바뀌면 롤백이 된다 — 26.7.3 → 26.7.0 성공. 다만 전환 순간 000 1회(3초 타임아웃)
followup__03-b4-role-propagation.txt
B-4 ③ IdP 에서 값을 바꿔도 12회 요청·6초 동안 옛 값. 세션 삭제 후 재인증에서야 새 값
followup__04-observability-gap.txt
B층에 관측이 없다 — Prometheus 는 keycloak·kubelet·node-exporter·prometheus 만 긁는다. Redis·BFF·PostgreSQL 지표가 0개
followup__05-command-reproducibility.txt
재현 절차 명령을 실제로 돌려본 기록. 산문이던 측정 장치를 셸 실행형으로 바꾼 뒤 실행 검증 — 4건 중 1건(동시 20건 부하)이 조용히 실패했고 상주 탐침 방식으로 고쳐 20/20 수집
[keycloak-multinode-cluster]
keycloak-multinode-cluster__01-cluster-formed.txt
파드 배치·클러스터 뷰 로그·JGROUPS_PING·OIDC discovery·자원
[session-replication]
session-replication__01-cross-node-session.txt
클러스터 2멤버 확인 → keycloak-0 로그인 → 같은 sid 가 양쪽에서 보임 → keycloak-1 이 refresh 성공(200) → keycloak-1 로그아웃 → keycloak-0 갱신 실패(400) → DB 행 삭제 확인
session-replication__02-cache-delta.txt
로그인 하나를 사이에 둔 양쪽 노드의 캐시 계수기. keycloak-1 은 전부 +0
session-replication__03-cache-ownership.txt
로그인을 반대편에 몰아준 결과. 요청을 받은 노드에서만 엔트리가 는다. 캐시 합 7+5 = DB 12
session-replication__04-read-path-sql.txt
PostgreSQL 문장 로깅으로 잡은 keycloak-1 이 실제로 날린 SQL. SELECT ... FROM OFFLINE_USER_SESSION 로 남의 세션을 읽고 UPDATE ... where VERSION=$5 로 쓴다. 같은 트랜잭션에 SET LOCAL synchronous_commit TO OFF 가 들어 있다
[two-hop-proxy-headers]
two-hop-proxy-headers__01-environment.txt
수정 전 세 계층의 설정 스냅샷
two-hop-proxy-headers__02-measurements.txt
수정 전 측정 · 대조 실험 · 위조 테스트 · 분배
two-hop-proxy-headers__04-after-fix.txt
최종 측정 · 위조 테스트 · 분배
two-hop-proxy-headers__05-networkpolicy.txt
(원본 README 에 설명이 없다 — 파일 머리말 참조)
@@ -0,0 +1,13 @@
=== [기준선 1] 클러스터 뷰 — 양쪽 파드의 마지막 ISPN000094 ===
keycloak-0: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
keycloak-1: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
=== [기준선 2] JGROUPS_PING — 디스커버리 등록 ===
name | ip | coord | coordinated_by
------------------+-----------------+-------+---------------------------------------------
keycloak-0-30843 | 10.42.1.43:7800 | f | uuid://00000000-0000-0000-0000-000000000007
keycloak-1-48749 | 10.42.0.35:7800 | t | uuid://00000000-0000-0000-0000-000000000007
(2 rows)
=== [기준선 3] 기존 NetworkPolicy ===
No resources found in keycloak-lab namespace.
@@ -0,0 +1,17 @@
=== [대조군] 차단 전 — keycloak-0 로그인 → keycloak-1 에서 refresh ===
sid tAWs2gCPr6SOcD4jDR9-_CzB
keycloak-1 에서 refresh: 200
=== [기준선 4] JGroups 지표 — 양쪽 노드 ===
--- K0 (10.42.1.43) ---
vendor_jgroups_stats_bytes_sent_total 31476.0
vendor_jgroups_merge3_get_num_merge_events 0.0
vendor_jgroups_merge3_get_views 0.0
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0
vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0
--- K1 (10.42.0.35) ---
vendor_jgroups_merge3_get_views 0.0
vendor_jgroups_stats_bytes_sent_total 126765.0
vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0
vendor_jgroups_merge3_get_num_merge_events 0.0
@@ -0,0 +1,8 @@
=== 차단 적용 ===
networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
a1-block-jgroups-transport map[app:keycloak]
적용 시각: 11:38:08
=== FD_SOCK2 가 상대를 의심하기까지 기다린다 (15초 간격, 최대 3분) ===
+15초 suspected(k0 k1) =
→ 변화 감지
@@ -0,0 +1,26 @@
차단 경과: 11:38:51 (적용 11:38:08)
=== [차단 후 1] JGroups 지표 ===
--- keycloak-0 ---
vendor_jgroups_stats_bytes_sent_total 32857.0
vendor_jgroups_merge3_get_num_merge_events 0.0
vendor_jgroups_merge3_get_views 0.0
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0
vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0
--- keycloak-1 ---
vendor_jgroups_merge3_get_views 0.0
vendor_jgroups_stats_bytes_sent_total 129103.0
vendor_jgroups_nakack2_get_xmit_table_missing_messages 0.0
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0
vendor_jgroups_merge3_get_num_merge_events 0.0
=== [차단 후 2] 클러스터 뷰 — 갈라졌는가 ===
keycloak-0:
keycloak-1:
=== [차단 후 3] JGROUPS_PING — 디스커버리는 살아 있는가 ===
name | ip | coord
------------------+-----------------+-------
keycloak-0-30843 | 10.42.1.43:7800 | f
keycloak-1-48749 | 10.42.0.35:7800 | t
(2 rows)
@@ -0,0 +1,16 @@
=== [문제 확정] NetworkPolicy 적용 후에도 기존 연결이 conntrack 에 살아 있다 ===
--- kc-lab-1 ---
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1
--- kc-lab-2 ---
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1
=== [조치] 7800 흐름의 conntrack 항목을 지운다 → 다음 패킷이 정책을 다시 탄다 ===
kc-lab-1: tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1 conntrack v1.4.7 (conntrack-tools): 0 flow entries have been deleted.
kc-lab-2: tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] src=10.42.0.35 dst=10.42.1.58 sport=7800 dport=34824 mark=0 use=1 conntrack v1.4.7 (conntrack-tools): 0 flow entries have been deleted.
=== 삭제 후 7800 conntrack ===
kc-lab-1: 2 건
kc-lab-2: 2 건
@@ -0,0 +1,13 @@
관찰 시작: 11:42:03
+20초 suspected(k0 k1) = []
+40초 suspected(k0 k1) = []
+60초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ]
+80초 suspected(k0 k1) = []
+100초 suspected(k0 k1) = []
+120초 suspected(k0 k1) = []
+140초 suspected(k0 k1) = [0.0 ]
+160초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ]
=== 클러스터 뷰 변화 (최근 8분) ===
--- keycloak-0 ---
--- keycloak-1 ---
@@ -0,0 +1,16 @@
=== vendor_cluster_size — 지난 25분 (차단 11:38:08, conntrack 삭제 11:41) ===
keycloak-0:
11:20=2 11:21=2 11:22=2 11:23=2 11:24=2 11:25=2 11:26=2 11:27=2 11:28=2 11:29=2 11:30=2 11:31=2 11:32=2 11:33=2 11:34=2 11:35=2 11:36=2 11:37=2 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
keycloak-1:
11:20=2 11:21=2 11:22=2 11:23=2 11:24=2 11:25=2 11:26=2 11:27=2 11:28=2 11:29=2 11:30=2 11:31=2 11:32=2 11:33=2 11:34=2 11:35=2 11:36=2 11:37=2 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
=== 현재 값 ===
keycloak-1 = 2 멤버
keycloak-0 = 2 멤버
=== 7800 소켓 상태 (파드 내부) ===
keycloak-0 2
keycloak-1 2
=== conntrack ===
kc-lab-1 1 건
kc-lab-2 1 건
@@ -0,0 +1,9 @@
=== 정책이 걸린 상태에서 keycloak-0 을 재시작한다 → 재연결이 막힌다 ===
재시작 시각: 11:46:07
pod "keycloak-0" deleted from keycloak-lab namespace
keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z
=== cluster_size 추이 ===
keycloak-0: 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1
keycloak-0: 11:40:57=2 11:41:27=2 11:41:57=2 11:42:27=2 11:42:57=2 11:43:27=2 11:43:57=2
keycloak-1: 11:40:57=2 11:41:27=2 11:41:57=2 11:42:27=2 11:42:57=2 11:43:27=2 11:43:57=2 11:44:27=1 11:44:57=1 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1
@@ -0,0 +1,16 @@
=== keycloak-0 헬스 상태 ===
keycloak-0 = 10.42.1.67 keycloak-1 = 10.42.0.35
PodReadyToStartContainers=True
Initialized=True
Ready=False ContainersNotReady
ContainersReady=False ContainersNotReady
PodScheduled=True
=== ★ 본 시험 — 분단 상태에서 교차 노드 세션이 되는가 ===
[1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB
[2] keycloak-1 에서 refresh HTTP 200
[3] keycloak-1 에서 로그아웃 HTTP 204
[4] keycloak-0 에서 재갱신 시도 HTTP 200
(400 이면 무효화가 전파된 것)
=== DB 세션 수 ===
@@ -0,0 +1,33 @@
=== 그 sid 가 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows)
=== 전체 온라인 세션 수 ===
1
=== 노드별 세션 캐시 엔트리 (Prometheus) ===
keycloak-1 kc-lab-1 = 0
keycloak-0 kc-lab-2 = 1
=== keycloak-0 이 Ready 가 아닌 이유 — 헬스 응답 ===
{
"status": "DOWN",
"checks": [
{
"name": "Graceful Shutdown",
"status": "UP"
},
{
"name": "Keycloak cluster health check",
"status": "DOWN",
"data": {
"Failing since": "2026-09-04 02:45:14,251"
}
},
{
"name": "Keycloak database connections async health check",
"status": "UP"
},
{
"name": "Keycloak Initialized",
@@ -0,0 +1,25 @@
=== 양쪽 노드의 readiness — 둘 다 DOWN 이면 전면 장애다 ===
Traceback (most recent call last):
File "<string>", line 3, in <module>
d=json.load(sys.stdin)
File "/usr/lib/python3.14/json/__init__.py", line 298, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
File "/usr/lib/python3.14/json/__init__.py", line 352, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.14/json/decoder.py", line 348, in decode
raise JSONDecodeError("Extra data", s, end)
json.decoder.JSONDecodeError: Extra data: line 21 column 2 (char 446)
=== 파드 Ready 상태 ===
keycloak-0 false 0
keycloak-1 true 0
=== ★ Service 엔드포인트 — 트래픽을 받는 파드가 남아 있는가 ===
ready 주소: [10.42.0.35] notReady : [10.42.1.67]
=== ★ 외부 진입점으로 실제 로그인이 되는가 (nginx→Traefik→Service) ===
https://auth.hyeonworks.com/realms/master HTTP 200
토큰 발급 HTTP 200
@@ -0,0 +1,24 @@
=== 차단 해제 ===
해제 시각: 11:49:58
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace
=== 자동으로 다시 붙는가 (30초 간격, 최대 4분) ===
+30초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
+60초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
+90초 keycloak-0=2 keycloak-1=2 | Ready 파드 3 개
→ 클러스터 재형성
=== 복구 로그 ===
keycloak-0: [keycloak-0-26403(v=16.0.12)|0] (1) [keycloak-0-26403(v=16.0.12)]
keycloak-1: [keycloak-1-48749(v=16.0.12)|6] (1) [keycloak-1-48749(v=16.0.12)]
=== MERGE3 가 합쳤는가 ===
merge_events keycloak-1 = 1
merge_events keycloak-0 = 1
=== JGROUPS_PING — 코디네이터가 하나로 돌아왔는가 ===
name | ip | coord
------------------+-----------------+-------
keycloak-0-26403 | 10.42.1.67:7800 | t
keycloak-1-48749 | 10.42.0.35:7800 | f
(2 rows)
@@ -0,0 +1,14 @@
=== A-2 기준선 — 클러스터가 정상으로 돌아왔는가 ===
keycloak-0 true 10.42.1.67 kc-lab-2
keycloak-1 true 10.42.0.35 kc-lab-1
postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2
cluster_size keycloak-1 = 2
cluster_size keycloak-0 = 2
=== 노드별 세션 캐시 (실험 설계에 필요) ===
keycloak-1 kc-lab-1 = 0 건
keycloak-0 kc-lab-2 = 0 건
=== DB 온라인 세션 ===
2
@@ -0,0 +1,10 @@
pod/a2-probe condition met
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
keycloak-1 = 0 건
keycloak-0 = 1 건
@@ -0,0 +1,16 @@
=== [1] 토큰을 새로 발급 (access 수명 60초) ===
발급 완료 sid=RKXQGAgkuLtouFMVPTFmp_0_
=== [2] PostgreSQL 정지 ===
정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
삭제 완료: 11:56:04
=== [3] 네 경로를 즉시 시험 ===
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
③ 새 로그인 HTTP 500
--- 오류 본문 (새 로그인) ---
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
@@ -0,0 +1,29 @@
=== 파드 Ready 상태 — DB 가 없으면 어떻게 되는가 ===
keycloak-0 false 0
keycloak-1 false 0
=== Service 엔드포인트 ===
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
notReady: [10.42.0.35 10.42.1.67]
=== health/ready 상세 ===
전체: DOWN
Graceful Shutdown UP
Keycloak cluster health check UP
Keycloak database connections async health check DOWN
Keycloak Initialized UP
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
JWKS 엔드포인트(realm 공개키) HTTP 200
realm 메타데이터(.well-known) HTTP 200
관리 API(세션 조회 필요) HTTP 500
=== 외부 진입점 ===
https://auth.hyeonworks.com/realms/master HTTP 503
=== Keycloak 로그 — 실제 오류 ===
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
Caused by: java.net.ConnectException: Connection refused
at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)
@@ -0,0 +1,21 @@
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
=== 복구 — PostgreSQL 재기동 ===
재기동 시각: 11:57:09
deployment.apps/postgres scaled
Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
→ 서비스 복귀
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
keycloak-0 0
keycloak-1 0
=== 정지 전 세션이 살아남았는가 ===
online 세션 5
@@ -0,0 +1,19 @@
=== [준비] 손실 측정 설계 확인 ===
LAST_SESSION_REFRESH 는 integer(초) — 200ms 손실은 보이지 않는다
created_on | integer | | not null |
last_session_refresh | integer | | not null | 0
"idx_user_session_expiration_created" btree (realm_id, offline_flag, remember_me, created_on, user_session_id, user_id)
"idx_user_session_expiration_last_refresh" btree (realm_id, offline_flag, remember_me, last_session_refresh, user_session_id, user_id)
→ 대신 행 존재 여부로 잰다. 로그인 하나 = 행 하나 = 이진 판정
전역 synchronous_commit: on
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
루프 시작
6초 경과 — 지금까지 성공한 로그인: 0
=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
종료 시각: 12:00:26.511
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
삭제 반환: 12:00:26.586
클라이언트가 200 을 받은 로그인 수: 0
@@ -0,0 +1,14 @@
deployment "postgres" successfully rolled out
=== crash recovery 가 실행되었는가 (강제 종료의 흔적) ===
2026-09-04 02:58:41.036 UTC [1] LOG: database system is ready to accept connections
=== [설계 확인] 로그인 트랜잭션도 synchronous_commit 을 끄는가 ===
--- 로그인 트랜잭션 (INSERT 가 있는 것) ---
2:BEGIN
5:COMMIT
6:BEGIN
9:insert into OFFLINE_USER_SESSION (BROKER_SESSION_ID,CREATED_ON,DATA,LAST_SESSION_REFRESH,REALM_ID,REMEMBER_ME,USER_ID,VERSION,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
10:insert into OFFLINE_CLIENT_SESSION (DATA,REALM_ID,TIMESTAMP,VERSION,CLIENT_ID,CLIENT_STORAGE_PROVIDER,EXTERNAL_CLIENT_ID,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
11:SET LOCAL synchronous_commit TO OFF
12:COMMIT
@@ -0,0 +1,17 @@
=== [1] 로그인 루프 시작 (호스트에서 백그라운드로 exec — 세션이 살아 있어야 한다) ===
8초 동안 클라이언트가 200 을 받은 로그인: 106 건
=== [2] SIGKILL ===
종료: 12:01:32.981
반환: 12:01:33.236
최종 성공 로그인 수: 110 건
마지막 sid: FimM-krSybBACP2qIvshLWwU
마지막 sid: EwFfFwOIfqiv8N5GQ5OjtsVq
마지막 sid: CJX-PxFkS7rUc_9FQgB7iw1f
마지막 sid: 1EFK7SgUA4M7tq_SkC_BD2er
마지막 sid: _LiqTczuyxlpOs3T3xs25SLv
=== [3] PostgreSQL 재기동 후 crash recovery 확인 ===
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
@@ -0,0 +1,27 @@
=== [4] 클라이언트가 받은 sid 가 DB 에 있는가 ===
클라이언트가 200 을 받은 sid: 291 건
DB 온라인 세션 총계: 375
--- 마지막 15건을 하나씩 조회 ---
TCCOYnVlN30Y2fGyJEsVJ_Lq 있음
vBcllIkKWovh-FXN9tSzmxAe 있음
eMpN_ywUdRTks9uBE9aTumPK 있음
aPC_T0yrlMjrlskcLAp0AvY6 있음
UBPxmduB-ahGHBg636sY3AEz 있음
HLnBloNX9R3qQJSkpOnkNqL4 있음
_KPJS30IAqVhkTJGHcCxKvrM 있음
rq3caZ9MkyMYlFSSzRjQLygD 있음
ivvQm70hjl55DPpF7vpYmz_E 있음
81mx-rmi-tAeogHx3-su3z2q 있음
WnvNDH93uzcz1XNFbaMSXk1A 있음
DXPAIhjO5sGpCUlcD8IEoS8L 있음
aZMvl4IwPdK-rC_7bG005Z5C 있음
eIuBCprfWA5x0glcgSKYrrX0 있음
ozES5kEeu2IFf_cfcC_jFlbF 있음
마지막 15건 중 유실: 0 건
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
클라이언트 성공: 291 건
DB 에 존재: 291 건
★ 유실: 0 건
@@ -0,0 +1,12 @@
=== [정리] 세션 테이블 비우고 루프 잔여 확인 ===
DELETE 375
남은 세션: 0
=== [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 ===
8초 후 성공 로그인: 110 건
SIGKILL: 12:03:21.441
최종 성공 로그인: 139 건
=== [검증] 이번엔 crash recovery 가 돌았는가 ===
deployment "postgres" successfully rolled out
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
@@ -0,0 +1,17 @@
=== 로그인 루프 시작 ===
8초 후: 112 건
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
시각: 12:04:22.063
최종 성공 로그인: 153 건
=== [검증] crash recovery 가 돌았는가 ===
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
2026-09-04 03:02:35.807 UTC [1] LOG: server process (PID 40) was terminated by signal 9: Killed
2026-09-04 03:02:35.807 UTC [1] LOG: terminating any other active server processes
2026-09-04 03:02:35.814 UTC [1] LOG: all server processes terminated; reinitializing
2026-09-04 03:02:35.896 UTC [2585] LOG: database system was not properly shut down; automatic recovery in progress
2026-09-04 03:02:35.899 UTC [2585] LOG: redo starts at 0/23CAB68
2026-09-04 03:02:35.904 UTC [2585] LOG: redo done at 0/2529E40 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
2026-09-04 03:02:35.923 UTC [2586] LOG: checkpoint complete: wrote 113 buffers (0.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.004 s, total=0.015 s; sync files=27, longest=0.003 s, average=0.001 s; distance=1405 kB, estimate=1405 kB; lsn=0/252A048, redo lsn=0/252A048
2026-09-04 03:02:35.926 UTC [1] LOG: database system is ready to accept connections
@@ -0,0 +1,19 @@
=== 크래시 전후 대조 ===
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재 : 149 건
★ 유실 : 4 건
DB 전체 온라인 세션 : 150 건
=== 유실된 sid 목록 ===
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
(1 row)
@@ -0,0 +1,9 @@
=== A-3 이 가정만 하고 재지 않은 값 ===
name | setting | unit | source
------------------------+---------+------+---------
commit_delay | 0 | | default
synchronous_commit | on | | default
wal_writer_delay | 200 | ms | default
wal_writer_flush_after | 128 | 8kB | default
(4 rows)
@@ -0,0 +1,20 @@
=== A-4 기준선 ===
kc-lab-1 Ready true
kc-lab-2 Ready <none>
a2-probe true kc-lab-2
keycloak-0 true kc-lab-2
keycloak-1 true kc-lab-1
postgres-7b474b88c8-2gf27 true kc-lab-2
=== PVC 가 어느 노드에 묶여 있는가 (재배치 가능성) ===
persistentvolumeclaim/postgres-data → kc-lab-2
=== 서비스 정상 확인 ===
https://auth.hyeonworks.com/realms/master HTTP 200
=== VM 상태 ===
--------------------------
1 kc-lab-1 running
2 kc-lab-2 running
@@ -0,0 +1,17 @@
=== 워커 노드(kc-lab-2) 전원 차단 — virsh destroy 는 종료 신호가 없다 ===
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
+15초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
+30초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
+45초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+60초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+75초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+90초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+105초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+120초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+135초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+150초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+165초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
+180초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
@@ -0,0 +1,28 @@
=== 파드 상태의 진실 — Running 인데 노드가 없다 ===
a2-probe Running true kc-lab-2 <none>
keycloak-0 Running true kc-lab-2 <none>
keycloak-1 Running false kc-lab-1 <none>
postgres-7b474b88c8-2gf27 Running true kc-lab-2 <none>
=== 재배치가 시도되었는가 ===
10m Warning Unhealthy pod/keycloak-0 Readiness probe failed: Get "http://10.42.1.67:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
3m15s Warning NodeNotReady pod/postgres-7b474b88c8-2gf27 Node is not ready
3m15s Warning NodeNotReady pod/keycloak-0 Node is not ready
3m15s Warning NodeNotReady pod/a2-probe Node is not ready
2m27s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.35:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2s Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
=== 노드 taint — 쿠버네티스가 붙인 것 ===
node.kubernetes.io/unreachable=:NoSchedule
node.kubernetes.io/unreachable=:NoExecute
=== Prometheus 가 본 것 (kc-lab-1 에 있어 살아남았다) ===
up{job=keycloak pod=keycloak-1 } = 1
up{job=keycloak pod=keycloak-0 } = 0
up{job=kubelet pod=- } = 1
up{job=kubelet pod=- } = 0
up{job=node-exporter pod=kc-lab-1 } = 1
up{job=node-exporter pod=kc-lab-2 } = 0
up{job=prometheus pod=- } = 1
=== 진입점이 처음 40초간 000 이었던 이유 — nginx upstream ===
@@ -0,0 +1,15 @@
=== nginx 설정 위치 찾기 ===
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
=== 5분 축출 시점까지 관찰 ===
+210초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
+240초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
+270초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+300초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+330초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+360초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+390초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+420초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
@@ -0,0 +1,18 @@
=== 새 postgres 가 Pending 인 이유 ===
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 4m45s default-scheduler 0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
=== keycloak-0 대체 파드가 안 생기는 이유 (StatefulSet) ===
keycloak 2 <none> 1
keycloak-0 1/1 Terminating 0 30m
keycloak-1 0/1 Running 0 143m
=== 복구 — 노드 재기동 ===
재기동 시각: 12:16:31
Domain 'kc-lab-2' started
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
→ 서비스 복귀
@@ -0,0 +1,19 @@
=== 복구 확인 ===
keycloak-0 1/1 Running 0 68s
keycloak-1 1/1 Running 0 144m
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
=== kc-lab-1(k3s server)에 무엇이 있는가 — 이게 곧 영향 범위다 ===
keycloak-lab keycloak-1
kube-system coredns-54996dc9b4-8k8fj
kube-system helm-install-traefik-crd-q29b5
kube-system local-path-provisioner-77b9867795-g27z8
kube-system metrics-server-6dc596dfb8-7xxq4
kube-system svclb-traefik-5eb6a9a1-qwwk5
kube-system traefik-5d6fcf895-wpfhr
observability grafana-845b5678cf-b6gvc
observability node-exporter-9qk9w
observability prometheus-6774f94f7c-pzr2t
=== Traefik replica 수 (진입점의 단일 장애점인가) ===
traefik 1 1
@@ -0,0 +1,19 @@
=== 컨트롤 플레인 노드(kc-lab-1) 전원 차단 ===
차단 시각: 12:18:08
Domain 'kc-lab-1' destroyed
+20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+40초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+80초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+100초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
+140초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
=== 살아 있는 노드에서 직접 확인 — 워크로드는 도는가 ===
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
e5f777900b762 60e153026e8f5 4 minutes ago Running keycloak 0 640d4dafaefb3 keycloak-0 keycloak-lab
6
@@ -0,0 +1,13 @@
=== 컨트롤 플레인 노드 복구 ===
재기동: 12:23:39
Domain 'kc-lab-1' started
+30초 외부=502 | kc-lab-1=Ready kc-lab-2=Ready
+60초 외부=200 | kc-lab-1=Ready kc-lab-2=Ready
→ 서비스 복귀 (총 60초)
=== 최종 상태 ===
keycloak-0 1/1 Running 0 7m57s
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
(prometheus port-forward 재연결 필요)
@@ -0,0 +1,42 @@
=== A-5 기준선 ===
keycloak-0=10.42.1.77 (kc-lab-2) keycloak-1=10.42.0.42 (kc-lab-1)
keycloak-0 1/1 Running 0 11m
keycloak-1 1/1 Running 1 (2m48s ago) 155m
postgres-7b474b88c8-9cmsv 1/1 Running 0 14m
Traceback (most recent call last):
File "<string>", line 3, in <module>
for r in json.load(sys.stdin)["data"]["result"]: print(f" cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}")
~~~~~~~~~^^^^^^^^^^^
File "/usr/lib/python3.14/json/__init__.py", line 298, in load
return loads(fp.read(),
cls=cls, object_hook=object_hook,
parse_float=parse_float, parse_int=parse_int,
parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
File "/usr/lib/python3.14/json/__init__.py", line 352, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.14/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/json/decoder.py", line 363, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
=== 주입: keycloak-0 으로 들어가는 7800/57800 만 DROP (한 방향) ===
A-1 의 NetworkPolicy 는 conntrack ESTABLISHED 에 막혀 기존 연결을 못 끊었다.
FORWARD 최상단에 넣으면 conntrack 승인보다 먼저 평가된다.
Chain FORWARD (policy ACCEPT)
num target prot opt source destination
1 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:57800
2 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:7800
주입 시각: 12:28:23
=== 관찰 — 어느 쪽이 먼저 상대를 의심하는가 ===
+25초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+50초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+75초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+100초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+125초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+150초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+175초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+200초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
@@ -0,0 +1,19 @@
# 주의 — 이 파일은 원 실험 시점에 0바이트로 저장됐다.
# 리다이렉션이 stdout 만 받았는데 출력이 stderr 로 갔거나 tee 앞 파이프가
# 비어 있었던 것으로 보인다. README 는 그 사이 파일 내용을 서술하고 있었는데,
# 그것은 화면에서 본 것을 적은 것이지 이 파일에서 온 것이 아니었다.
#
# 아래는 사후에 다시 수집한 것이며, 원 시점의 DROP 규칙(0 패킷)은 이미
# 제거되어 재현되지 않는다. 구조적 사실(kube-router 가 자기 체인을 FORWARD
# 최상단에 유지한다)만 확인할 수 있다.
# 원 실험의 결정적 증거는 04-correct-direction.txt 의 패킷 카운터 19/21 이다.
=== A-5 재수집 — filter 테이블 규칙이 CNI 체인에 밀리는 것 ===
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 1690 3386K KUBE-ROUTER-FORWARD 0 -- * * 0.0.0.0/0 0.0.0.0/0 /* kube-router netpol - TEMCG2JMHZYE7H7T */
2 7 612 KUBE-PROXY-FIREWALL 0 -- * * 0.0.0.0/0 0.0.0.0/0 ctstate NEW /* kubernetes load balancer firewall */
3 40 13196 KUBE-FORWARD 0 -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes forwarding rules */
(원 실험 시점의 규칙은 이미 제거됐다. 아래는 kube-router 가 자기 체인을
FORWARD 최상단에 유지한다는 구조적 사실만 보여준다 — 그것이 실패 원인이었다.)
@@ -0,0 +1,16 @@
=== 실패한 규칙 제거 ===
제거완료
=== raw 테이블 PREROUTING 에 넣는다 — conntrack 보다 먼저 실행된다 ===
netfilter 순서: raw PREROUTING → conntrack → mangle → nat → filter
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:57800
2 0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:7800
주입: 12:32:35
=== [검증] 이번엔 패킷이 걸렸는가 ===
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:57800
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:7800
@@ -0,0 +1,18 @@
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
주입: 12:33:58
=== [검증] 패킷이 걸리는가 ===
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
19 2938 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
=== 관찰 ===
+25초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+50초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+75초 - | keycloak-0:1/1 keycloak-1:0/1 | 외부 200
+100초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+125초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+150초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+175초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+200초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
@@ -0,0 +1,19 @@
=== 지금 연결 방향은? (차단은 → 10.42.0.42:7800 만) ===
tcp 6 299 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800 src=10.42.1.77 dst=10.42.0.42 sport=7800 dport=48473
tcp 6 86232 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=44205 dport=57800 src=10.42.1.77 dst=10.42.0.42 sport=57800 dport=44205
=== 차단 규칙 누적 카운터 ===
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
19 1096 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
21 3058 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
=== 클러스터 멤버 (파드에서 직접) ===
keycloak-0 cluster_size=2.0
keycloak-1 cluster_size=2.0
=== 최근 뷰 로그 ===
keycloak-0: [keycloak-0-24309(v=16.0.12)|12] (1) [keycloak-0-24309(v=16.0.12)]
keycloak-1: [keycloak-1-45480(v=16.0.12)|12] (1) [keycloak-1-45480(v=16.0.12)]
@@ -0,0 +1,28 @@
=== 뷰 변화 전체 이력 (차단 12:33:58 전후) ===
--- keycloak-0 ---
2026-09-04 03:33:49 | during merge: CacheTopology{id=37, phase=NO_REBALANCE, rebalanceId=11, currentCH=DefaultConsistentHash{ns=25
2026-09-04 03:33:49,398 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t1) [Context=offlineSessions] ISPN100007: After me
2026-09-04 03:33:49 | during merge: CacheTopology{id=37, phase=NO_REBALANCE, rebalanceId=11, currentCH=DefaultConsistentHash{ns=25
2026-09-04 03:33:49,399 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t1) [Context=loginFailures] ISPN100007: After merg
2026-09-04 03:33:49 | during merge: CacheTopology{id=37, phase=NO_REBALANCE, rebalanceId=11, currentCH=DefaultConsistentHash{ns=25
2026-09-04 03:33:49,402 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t1) [Context=actionTokens] ISPN100007: After merge
--- keycloak-1 ---
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
2026-09-04 03:32:59,879 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=clientSessions] ISPN100007: After mer
2026-09-04 03:32:59,879 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t1) [Context=offlineSessions] ISPN100007: After me
2026-09-04 03:32:59,883 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t1) [Context=loginFailures] ISPN100007: After merg
2026-09-04 03:32:59,884 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=actionTokens] ISPN100007: After merge
2026-09-04 03:33:49 | MergeView::[keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)],
=== MERGE3 병합 이벤트 ===
keycloak-0 merge_events=1.0 suspected=0.0
keycloak-1 merge_events=1.0 suspected=0.0
=== 차단 해제 ===
해제완료
keycloak-0 cluster_size=2.0
warning: couldn't attach to pod/a5-f, falling back to streaming logs: unable to upgrade connection: container a5-f not found in pod a5-f_keycloak-lab
keycloak-0 cluster_size=2.0
keycloak-1 cluster_size=2.0
keycloak-0 1/1 Running 0 22m
keycloak-1 1/1 Running 1 (13m ago) 166m
@@ -0,0 +1,17 @@
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
주입: 12:40:25
+25초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
+50초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
+75초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
+100초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+125초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+150초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+175초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+200초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
=== 뷰 상태 ===
keycloak-0 [keycloak-0-24309(v=16.0.12)|14] (1) [keycloak-0-24309(v=16.0.12)]
keycloak-1 [keycloak-1-45480(v=16.0.12)|14] (1) [keycloak-1-45480(v=16.0.12)]
@@ -0,0 +1,39 @@
=== 왜 한쪽만 DOWN 인가 — 코디네이터 여부 확인 ===
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | t
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
=== 양쪽 헬스체크 상세 ===
--- keycloak-0 ---
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
{"name":"Keycloakclusterhealthcheck","status":"UP"}
--- keycloak-1 ---
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
warning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state
--- keycloak-0 ---
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
{"name":"Keycloakclusterhealthcheck","status":"UP"}
--- keycloak-1 ---
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
{"name":"KeycloakInitialized","status":"UP"}
=== 차단 해제 ===
해제: 12:44:37
+25초 keycloak-0:1/1 keycloak-1:0/1
+50초 keycloak-0:1/1 keycloak-1:1/1
→ 복구 완료
keycloak-0 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
keycloak-1 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
@@ -0,0 +1,22 @@
=== 배치 — 왜 A/B 가 되는가 ===
postgres 10.42.1.76 (kc-lab-2)
keycloak-0 10.42.1.77 (kc-lab-2) → DB 와 같은 노드, cni0 로 직행
keycloak-1 10.42.0.42 (kc-lab-1) → DB 와 다른 노드, VXLAN 을 건넌다 ← 여기에 지연을 건다
=== 사용 가능한 커넥션 풀 지표 ===
agroal_acquire_count_total
agroal_active_count
agroal_available_count
agroal_awaiting_count
agroal_blocking_time_average_milliseconds
agroal_blocking_time_max_milliseconds
agroal_blocking_time_total_milliseconds
agroal_creation_count_total
agroal_creation_time_average_milliseconds
agroal_creation_time_max_milliseconds
agroal_creation_time_total_milliseconds
agroal_destroy_count_total
=== 기준선 지연 — 각 노드에서 로그인 20회 ===
keycloak-0 평균 70 ms
keycloak-1 평균 66 ms
@@ -0,0 +1,25 @@
=== 주입: postgres(10.42.1.76) 가 보내는 패킷만 200ms 지연 (kc-lab-2 eth0) ===
prio qdisc 로 밴드를 나누고, u32 필터로 출발지 IP 가 postgres 인 것만 3번 밴드로 보낸다
Cannot find device "eth0"
Cannot find device "eth0"
적용완료
Cannot find device "eth0"
Cannot find device "eth0"
주입: 13:14:55
=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
keycloak-0 평균 43 ms 최대 64 ms
keycloak-1 평균 47 ms 최대 70 ms
=== 커넥션 풀 상태 ===
--- keycloak-0 ---
agroal_blocking_time_max_milliseconds 102.0
agroal_active_count 0.0
agroal_awaiting_count 0.0
agroal_blocking_time_average_milliseconds 0.0
agroal_available_count 2.0
agroal_blocking_time_max_milliseconds 164.0
agroal_active_count 0.0
agroal_awaiting_count 0.0
agroal_blocking_time_average_milliseconds 0.0
agroal_available_count 2.0
@@ -0,0 +1,20 @@
=== 오버레이 인터페이스 확인 ===
flannel.1 UNKNOWN a6:b2:62:04:c1:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
cni0 UP 5a:77:1a:e2:b0:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
=== flannel.1 에 주입 — 여기서는 파드 IP 가 보인다 (캡슐화 전) ===
qdisc prio 1: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
=== [검증] 필터에 패킷이 걸리는가 ===
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
keycloak-0 평균 41 ms 최대 57 ms
keycloak-1 평균 1872 ms 최대 1887 ms
@@ -0,0 +1,37 @@
=== 동시 부하 20건을 keycloak-1 에 — 커넥션 풀이 견디는가 ===
1 200 1.911191
1 200 1.913766
1 200 1.958374
1 200 1.981620
1 200 10.539402
1 200 11.951943
1 200 13.351102
1 200 14.785832
1 200 16.189533
1 200 17.625166
1 200 19.053724
1 200 20.495883
1 200 21.905932
1 200 22.228466
1 200 22.230871
1 200 3.441366
1 200 4.841075
1 200 6.257489
1 200 7.704608
1 200 9.104792
=== 부하 직후 커넥션 풀 ===
agroal_blocking_time_max_milliseconds 20000.0
agroal_max_used_count 19.0
agroal_acquire_count_total 672.0
agroal_active_count 0.0
agroal_awaiting_count 0.0
agroal_blocking_time_average_milliseconds 281.0
agroal_available_count 19.0
=== readiness 가 흔들렸는가 ===
keycloak-0 1/1 Running 0 60m
keycloak-1 1/1 Running 1 (51m ago) 3h24m
52m Normal TaintManagerEviction pod/keycloak-1 Cancelling deletion of Pod keycloak-lab/keycloak-1
32m Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
89s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.42:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
@@ -0,0 +1,12 @@
=== 지연 해제 ===
해제완료
qdisc noqueue 0: root refcnt 2
=== 회복 확인 ===
keycloak-0 평균 43 ms
keycloak-1 평균 51 ms
keycloak-0 1/1 Running 0 61m
keycloak-1 1/1 Running 1 (52m ago) 3h24m
=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
관련 로그 줄수: 0
@@ -0,0 +1,18 @@
=== 비교를 위해 세션을 비운다 ===
DELETE 151
=== volatile 모드로 전환 ===
namespace/keycloak-lab unchanged
secret/keycloak-lab-secrets configured
persistentvolumeclaim/postgres-data unchanged
deployment.apps/postgres unchanged
service/postgres unchanged
statefulset.apps/keycloak configured
service/keycloak-headless unchanged
service/keycloak unchanged
ingress.networking.k8s.io/keycloak unchanged
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
=== [검증] 정말 꺼졌는가 ===
["start","--features-disabled=persistent-user-sessions"]
@@ -0,0 +1,15 @@
keycloak-0=10.42.1.94 keycloak-1=10.42.0.45
=== [A-0 재실행] keycloak-0 에만 로그인 5회 → 캐시가 어디에 담기는가 ===
로그인완료
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
offline_flag | count
--------------+-------
(0 rows)
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
@@ -0,0 +1,13 @@
=== [A-8 재실행] 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
=== 롤링 재시작 ===
statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
--- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
=== 캐시 상태 ===
keycloak-1 sessions 캐시 1.0 건
@@ -0,0 +1,21 @@
=== [A-1 재실행] volatile 에서 7800 을 막으면 ===
keycloak-0=10.42.1.99 keycloak-1=10.42.0.46
[대조군] 차단 전 교차 노드 refresh
keycloak-1 에서 refresh HTTP 200
차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
분단이 성립할 때까지 대기...
+25초 cluster_size(k0 k1) = [2.0 2.0 ]
+50초 cluster_size(k0 k1) = [1.0 ]
+75초 cluster_size(k0 k1) = [1.0 ]
+100초 cluster_size(k0 k1) = []
+125초 cluster_size(k0 k1) = [1.0 ]
+150초 cluster_size(k0 k1) = [1.0 ]
+175초 cluster_size(k0 k1) = [1.0 ]
+200초 cluster_size(k0 k1) = []
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
--- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
@@ -0,0 +1,12 @@
차단 해제, 클러스터 재형성 대기...
=== [A-2 재실행] volatile 에서 DB 를 내리면 — 세션이 메모리에 있으니 살아남는가? ===
DB 정지 전 로그인 완료
deployment.apps/postgres scaled
postgres 정지
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200
=== DB 복구 후 원복 ===
deployment.apps/postgres scaled
deployment "postgres" successfully rolled out
@@ -0,0 +1,12 @@
=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...
=== [검증] persistent 로 돌아왔는가 — 로그인 후 DB 에 행이 생기는가 ===
["start"]
로그인
DB 온라인 세션: 1 건 (1 이면 persistent 복귀)
keycloak-0 1/1 Running 0 67s
keycloak-1 1/1 Running 0 89s
postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
외부 진입점 HTTP 200
@@ -0,0 +1,117 @@
A-7a — volatile 모드에서 refresh 가 500 인 진짜 이유
=======================================================
수집: 2026-09-04 11:18 ~ 11:24 UTC · kc-lab-1 에서 sudo kubectl
A-7 이 남긴 가설
----------------
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
★ 이 가설은 틀렸다.
방법 — PostgreSQL 문장 로깅 (A-3 기법)
---------------------------------------
ALTER SYSTEM SET log_statement = 'all'; SELECT pg_reload_conf();
표식을 넣어 로그인과 refresh 가 각각 어떤 SQL 을 쏘는지 구분했다.
select 'MARK_LOGIN_START' ... 로그인 ... select 'MARK_LOGIN_END'
select 'MARK_REFRESH_START' ... refresh ... select 'MARK_REFRESH_END'
volatile 전환 확인
args: ["start","--features-disabled=persistent-user-sessions"]
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
[측정 1] 로그인은 SQL 을 0개 쏜다
----------------------------------
11:18:49.461 statement: select 'MARK_LOGIN_START'
11:18:49.743 statement: select 'MARK_LOGIN_END'
↑ 사이에 아무것도 없다
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있어 DB 를 안 본다.
[측정 2] ★ refresh 는 CLIENT_SCOPE_CLIENT 를 본다
--------------------------------------------------
11:18:52.009 statement: select 'MARK_REFRESH_START'
11:18:52.137 statement: BEGIN
11:18:52.137 execute <unnamed>/C_107:
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
11:18:52.148 execute S_2: COMMIT
11:18:52.253 statement: select 'MARK_REFRESH_END'
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
`DEFAULT_SCOPE='f'` 이므로 **선택적(optional) 클라이언트 스코프** 조회다.
[측정 3] 그 조회는 한 번뿐이다 — 캐시에 들어간다
-------------------------------------------------
연속 refresh 3회, 전부 200. 표식 사이 SQL:
statement: select 'MARK_R1'
statement: select 'MARK_R2'
statement: select 'MARK_R3'
statement: select 'MARK_R_END'
↑ SQL 0건
첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지 않는다.
════ 그래서 A-7 의 표 자체가 조건부였다 ════
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 측정했다.
┌──────────────────────┬────────┬─────────┬──────────────────────────────┐
│ 캐시 상태 │ 로그인 │ refresh │ 실패한 SQL │
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
│ 완전 냉시동 │ 400 │ 400 │ select ce1_0.ID from CLIENT │
│ (재시작 직후) │ │ │ where CLIENT_ID=? REALM_ID=?│
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
│ CLIENT 만 더움 │ 200 │ ★ 500 │ select cscme1_0.SCOPE_ID │
│ ← A-7 이 본 상태 │ │ │ from CLIENT_SCOPE_CLIENT │
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
│ 완전히 더움 │ 200 │ 200 │ 없음 (SQL 0건) │
└──────────────────────┴────────┴─────────┴──────────────────────────────┘
[재현 A] 완전 냉시동 — 로그인부터 400
keycloak 재시작 → postgres 정지 → 로그인
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
authenticating client: org.hibernate.exception.GenericJDBCException:
JDBC exception executing SQL [FATAL: terminating connection due to
administrator command]
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
→ A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었지만,
**냉시동에서는 그것도 안 된다.**
[재현 B] ★ A-7 이 본 그 조건 — 로그인 200, refresh 500
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
로그인 200
refresh 500 {"error":"unknown_error"}
JDBC exception executing SQL [FATAL: terminating connection due to
administrator command]
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
★ 로그가 실패한 SQL 을 직접 지목한다. CLIENT_SCOPE_CLIENT 다.
[재현 C] 완전히 더움 — 둘 다 200
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
로그인 200
refresh 200 ← A-7 의 표와 정반대다
════ 결론 ════
1. **원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회다.**
`CLIENT_SCOPE_CLIENT` 를 `DEFAULT_SCOPE='f'` 로 조회하는 그 한 문장이다.
2. **A-7 의 "volatile → refresh 500" 은 캐시가 차가울 때만 참이다.**
더워지면 200 이 된다. A-7 은 그 조건을 몰랐고, 그래서 표에 적지 않았다.
3. **"volatile 이면 DB 없이 로그인된다" 도 조건부다.**
냉시동에서는 400 이다. 클라이언트 조회조차 캐시에 없기 때문이다.
4. **일반화** — volatile 모드에서 DB 정지 시의 동작은 "무엇을 하느냐"가
아니라 **"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은
명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는
**한 번 재보고 표로 적으면 안 된다** — A-7 이 그렇게 했다.
@@ -0,0 +1,17 @@
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
sid = XLcgQWRiJrTkuNZcJsNeT_2j
DB 세션 수: 151
=== [2] 롤링 재시작 중 가용성 — 5초 간격으로 외부 진입점 확인 ===
statefulset.apps/keycloak restarted
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
@@ -0,0 +1,19 @@
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
=== [4] DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
(1 row)
전체 온라인 세션: 151 (재시작 전 151)
=== [5] 캐시는 어떻게 되었는가 ===
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
@@ -0,0 +1,21 @@
=== 배포 전 자원 ===
Mem: 11648 7329 280 4 4377 4319
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
kc-lab-1 115m 5% 2192Mi 44%
kc-lab-2 121m 6% 1324Mi 33%
=== 배포 ===
secret/bff-secrets created
deployment.apps/redis created
service/redis created
deployment.apps/bff created
service/bff created
ingress.networking.k8s.io/bff created
deployment "redis" successfully rolled out
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
deployment "bff" successfully rolled out
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
@@ -0,0 +1,13 @@
=== B-0: 자동구성이 실제로 고른 구현체 ===
Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에,
어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다"
File "<stdin>", line 9
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
^
SyntaxError: unexpected character after line continuation character
=== HttpSession 은 어디에 있는가 (서블릿 컨테이너 기본) ===
=== 외부 진입점 ===
https://app1.hyeonworks.com/ HTTP 200
@@ -0,0 +1,29 @@
=== B-0 — 자동구성이 실제로 고른 구현체 ===
컨텍스트: keycloak-bff
전체 빈 수: 321
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
--- OAuth2 클라이언트 관련 전체 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration -> OAuth2ClientAutoConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration -> OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration -> OAuth2ClientWebSecurityAutoConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration -> OAuth2ClientConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration -> OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration
spring.security.oauth2.client-org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -> OAuth2ClientProperties
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
@@ -0,0 +1,5 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-576d869c6d-bshvl true kc-lab-2
bff-695646ddb-kzs9k true kc-lab-1
bff-695646ddb-vjqzf true kc-lab-2
@@ -0,0 +1,64 @@
=== B-1 — Redis 를 붙인 뒤 자동구성이 실제로 바뀌었는가 ===
빈 수: 321 → 402 (+81)
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration -> SessionAutoConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration
★ org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration -> SessionRepositoryFilterConfiguration
★ org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration -> SpringHttpSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ rememberMeServicesCookieSerializerCustomizer -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration$$Lambda/0x00007f364e69fa60
★ sessionEventHttpSessionListenerAdapter -> SessionEventHttpSessionListenerAdapter
★ sessionRepository -> RedisSessionRepository
★ sessionRepositoryFilterRegistration -> DelegatingFilterProxyRegistrationBean
★ spring.session-org.springframework.boot.autoconfigure.session.SessionProperties -> SessionProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ springSessionRepositoryFilter -> SessionRepositoryFilter
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
--- Redis 연결 빈 (새로 생긴 것) ---
★ keyValueMappingContext -> RedisMappingContext
★ lettuceMetrics -> LettuceMetricsAutoConfiguration$$Lambda/0x00007f364e56f4d0
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration -> RedisHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration -> RedisReactiveHealthContributorAutoConfiguration
★ org.springframework.boot.actuate.autoconfigure.metrics.redis.LettuceMetricsAutoConfiguration -> LettuceMetricsAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration -> LettuceConnectionConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration -> RedisAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration -> RedisReactiveAutoConfiguration
★ org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration -> RedisRepositoriesAutoConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ reactiveRedisTemplate -> ReactiveRedisTemplate
★ reactiveStringRedisTemplate -> ReactiveStringRedisTemplate
★ redisConnectionDetails -> PropertiesRedisConnectionDetails
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisConverter -> MappingRedisConverter
★ redisCustomConversions -> RedisCustomConversions
★ redisHealthContributor -> RedisReactiveHealthIndicator
★ redisKeyValueAdapter -> RedisKeyValueAdapter
★ redisKeyValueTemplate -> RedisKeyValueTemplate
★ redisMappingConfiguration#0 -> MappingConfiguration
★ redisReferenceResolver -> ReferenceResolverImpl
★ redisTemplate -> RedisTemplate
★ sessionRepository -> RedisSessionRepository
★ spring.data.redis-org.springframework.boot.autoconfigure.data.redis.RedisProperties -> RedisProperties
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
★ stringRedisTemplate -> StringRedisTemplate
@@ -0,0 +1,21 @@
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
=== 세션 키의 내용 — refresh token 이 있는가 (Q3 검증 2번) ===
키: bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
타입: hash
필드: sessionAttr:SPRING_SECURITY_CONTEXT
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
필드: lastAccessedTime
필드: maxInactiveInterval
필드: creationTime
=== 필드 값에 토큰 문자열이 보이는가 ===
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x02L\x00\x1eauthorizedClientRegistrationIdt\x00\x12Ljava/lang/String;L\x00\tprincipalt\x00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xr\x00Gorg.springframework.security.authentication.AbstractAuthenticationToken\xd3\xaa(~nGd\x0e\x02\x00\x03Z\x00\rauthenticatedL\x00\x0bauthoritiest\x00\x16Ljava/util/Collection;L\x00\adetailst\x00\x12Ljava/lang/Object;xp\x01sr\x00&java.util.Collections$UnmodifiableList\xfc\x0f%1\xb5\xec\x8e\x10\x02\x00\x01L\x00\x04listt\x00\x10Ljava/util/List;xr\x00,java.util.Collect
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
@@ -0,0 +1,8 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-555df79c97-6j86w 1/1 Running 0 44s
bff-555df79c97-vgg6g 1/1 Running 0 22s
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
@@ -0,0 +1,33 @@
=== PostgreSQL 전용 스키마 ===
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
access_token_type varchar(100) NOT NULL,
access_token_value bytea NOT NULL,
access_token_issued_at timestamp NOT NULL,
access_token_expires_at timestamp NOT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
refresh_token_value bytea DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (client_registration_id, principal_name)
);
=== 적용 ===
CREATE TABLE
Table "public.oauth2_authorized_client"
Column | Type | Collation | Nullable | Default
-------------------------+-----------------------------+-----------+----------+-------------------------
client_registration_id | character varying(100) | | not null |
principal_name | character varying(200) | | not null |
access_token_type | character varying(100) | | not null |
access_token_value | bytea | | not null |
access_token_issued_at | timestamp without time zone | | not null |
access_token_expires_at | timestamp without time zone | | not null |
access_token_scopes | character varying(1000) | | | NULL::character varying
refresh_token_value | bytea | | |
refresh_token_issued_at | timestamp without time zone | | |
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
Indexes:
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
@@ -0,0 +1,20 @@
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
=== access token 도 ===
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPWS1jYVlETkdvUDRITUF6LVE5VVBUVS1ETTFpODk2TnV6VVp1NmdmQ3FNIn0.eyJleHAi
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
File "<string>", line 3
h=open(/tmp/hdr.txt).read().strip()
^
SyntaxError: invalid syntax
=== 저장된 바이트를 그대로 디코드한 결과 ===
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
refresh_token 페이로드(앞부분):
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
@@ -0,0 +1,25 @@
=== [현재] 같은 사용자의 항목 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)
행 수: 1
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
(1 row)
행 수: 1
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
@@ -0,0 +1,14 @@
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
Redis 세션 : 0 키
PostgreSQL 토큰 : 1 행
principal_name | access_token_issued_at | access_token_expires_at
----------------+----------------------------+----------------------------
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
(1 row)
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
=== Keycloak 쪽 SSO 세션은? ===
Keycloak 온라인 세션: 2
@@ -0,0 +1,11 @@
=== [1] refresh token 하나 확보 ===
토큰 길이: 811
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
sid: BvFiB01Rntz1FcLdf7zG4BNt
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 5: HTTP 200 {"access_token":"...(발급됨)
@@ -0,0 +1,16 @@
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
@@ -0,0 +1,14 @@
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
@@ -0,0 +1,24 @@
=== 구성 A: rotation ON (revokeRefreshToken=true, maxReuse=0) — 앞서 측정 ===
성공 1 / 5, 세션 파괴됨
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
sid=72c04JCdr0NpCHGQmXWW2wM8
1: 200
2: 400 "error_description":"Session doesn't have required client"
3: 200
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
5: 400 "error_description":"Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
@@ -0,0 +1,40 @@
=== Q4 ① 다중 값 role — 구분자와 동명 헤더 ===
(a) 쉼표 구분 한 개 헤더
보냄: X-Auth-Request-Roles: admin,editor,viewer
도착: ['admin,editor,viewer'] ← 문자열 하나 그대로
(b) 동명 헤더 두 개
보냄: X-Auth-Request-Roles: admin
X-Auth-Request-Roles: editor
도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다
(c) 값 안에 구분자가 들어간 경우
보냄: X-Auth-Request-Roles: role-with,comma
도착: ['role-with,comma'] ← (a) 와 구별 불가
=== Q4 ② 헤더 크기 상한 ===
보낸 길이 1000 → HTTP 200, 도착 길이 1000
보낸 길이 4000 → HTTP 200, 도착 길이 4000
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
보낸 길이 32000 → HTTP 000
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
=== Q4 ④ upstream 이 검증하는가 ===
아무 인증 없이 보냄:
x-auth-request-user ['administrator']
x-auth-request-email ['admin@example.com']
x-auth-request-roles ['realm-admin,superuser']
remoteAddr 100.123.124.30
→ 그대로 도착. 검증 없음.
대조 — JWT 를 요구하는 경로:
/api/echo HTTP 200 (permitAll)
/api/me HTTP 401
/api/protected HTTP 401
backend SecurityConfig:
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
@@ -0,0 +1,9 @@
=== 기준선 ===
Redis 키: 1
PostgreSQL 토큰: 1 행
Redis 영속화 설정:
save = save
appendonly no
=== 외부 진입점 정상 확인 ===
https://app1.hyeonworks.com/ HTTP 200
@@ -0,0 +1,25 @@
=== ① Redis 정지 ===
정지: 14:26:30
deployment.apps/redis scaled
삭제 완료
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
/ HTTP 200
/bff/token-boundary HTTP 000
/actuator/health HTTP 503
--- token-boundary 응답 본문 ---
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w 1/1 Running 0 17m
bff-555df79c97-vgg6g 1/1 Running 0 16m
=== health 상세 ===
=== BFF 로그 ===
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
@@ -0,0 +1,13 @@
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
/actuator/health HTTP server
/actuator/health/readiness HTTP 200
/actuator/health/liveness HTTP 200
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
ready: [10.42.0.52 10.42.1.124]
@@ -0,0 +1,39 @@
=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
/actuator/health HTTP 200
/bff/token-boundary HTTP 302
BFF 재시작 필요했나: 0,0 회 재시작
=== ② 영속화 — 지금 설정으로 재시작하면 무엇이 남는가 ===
키 심음: before-restart
dbsize: 4
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
appendonly yes
total 12
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
persistentvolumeclaim/redis-data created
deployment.apps/redis configured
deployment "redis" successfully rolled out
=== 영속 볼륨 위에서 다시 시험 ===
appendonly yes
키 심음: written-on-pvc
sed: -e expression #1, char 8: unknown option to 's'
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 1
b5:pvc written-on-pvc
@@ -0,0 +1,9 @@
=== [1] 회전 전: 토큰 발급 + JWKS 상태 ===
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
@@ -0,0 +1,16 @@
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
@@ -0,0 +1,16 @@
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
@@ -0,0 +1,13 @@
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
@@ -0,0 +1,9 @@
=== curl 로 OAuth 흐름을 완주한다 (nginx 우회, Traefik 직접) ===
로그인 폼 action: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_co...
쿠키 항아리:
len=0
KC_AUTH_SESSION_HASH len=64
=== 두 replica 모두 이 쿠키를 받아들이는가 ===
10.42.1.135 /oauth2/auth HTTP 000
10.42.1.134 /oauth2/auth HTTP 000
@@ -0,0 +1,31 @@
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
=== oauth2-proxy 가 cookie secret 을 여러 개 받는가 ===
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
현재 secret 키: COOKIE_SECRET_B
Redis 세션은 그대로인가: 2 키
=== secret 교체 후 oauth2-proxy 로그 — 옛 쿠키를 어떻게 처리했나 ===
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
100.123.124.30 - cb8c0ec1-1d87-479c-9aef-e1d9158a5829 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, lik
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
100.123.124.30 - 28af938f-08b5-4e15-9094-8d9591a18a3f - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/
[2026/09/04 05:41:58] [providers.go:146] Warning: Your provider supports PKCE methods ["plain" "S256"], but you have not enabled one with --code-challenge-method
[2026/09/04 05:41:58] [oauthproxy.go:172] OAuthProxy configured for OpenID Connect Client ID: oauth2-proxy
[2026/09/04 05:41:58] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - labuser@example.com [2026/09/04 05:42:18] [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com user:27df5ea9-8703
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/oauth2/callback?state=j7eKInWCrYqRyi5LVDGDjtLrIBJCpwdkmzoJVdhJUc0%3A%2Fapi%2Fecho&
100.123.124.30 - a3074807-5143-49b9-b77c-e7e2eb90ac24 - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/favicon.ico" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebK
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
@@ -0,0 +1,147 @@
B-7a — cookie secret 회전이 남기는 고아 세션의 수명과 정리
=============================================================
수집: 2026-09-04 11:29 ~ 11:34 UTC · Redis + oauth2-proxy 로그 + Playwright
B-7 이 남긴 것
--------------
| 관찰 | |
|---|---|
| 옛 쿠키 | 검증 실패 — session ticket cookie failed validation |
| 사용자 경험 | Keycloak SSO 가 살아 있어 조용히 재로그인 |
| **서버 쪽 세션** | **★ 지우지 못했다** |
`Error removing session: error decoding ticket to clear session`
→ **티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
B-7 은 여기서 멈췄다. 남은 물음 셋을 잰다.
(1) 고아의 TTL 은 실제로 줄어드는가 — 정말 사라지긴 하는가
(2) 운영자가 직접 지울 수 있는가 · 지우면 산 세션이 다치는가
(3) ★ 어느 키가 고아인지 구분할 수 있는가
[기준선] 회전 전 — 11:29:42 UTC
------------------------------------
secret = COOKIE_SECRET_A
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
dbsize=1
설정: --cookie-expire=1h --session-store-type=redis
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
httponly:true expiry:1h0m0s ... refresh:disabled
[주입] 1차 회전 A → B — 11:29:56 UTC
--------------------------------------
kubectl patch deployment oauth2-proxy ... COOKIE_SECRET_B
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
브라우저가 접근한 순간(11:30:27) 로그:
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
→ B-7 의 관찰 그대로 재현. Keycloak SSO 가 살아 있어 로그인 화면 없이 통과했다.
Redis:
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
dbsize=2
[측정 1] ★ Redis 만 보고는 구분할 수 없다
-------------------------------------------
키 type strlen ttl
_oauth2_proxy-87faa1c9…(새) string 3510 3558
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
· 이름 접두사가 같다 (_oauth2_proxy-)
· 뒤는 불투명한 32자 hex — 사용자·시각·상태 어느 것도 안 담긴다
· 타입이 같다, 크기가 **바이트 단위로 같다** (3510)
· 값은 암호화되어 있다
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
→ **다른 것은 TTL 뿐이다.**
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
-----------------------------------------------------------
30초 간격 3회:
t+00초 새=3557 고아=3479
t+30초 새=3526 고아=3448
t+60초 새=3494 고아=3417
1초에 1초씩. 고아는 **생성 후 정확히 1시간에 사라진다.**
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
살아있는 세션 ttl=3464 ← 계속 줄어든다
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
★ 이것이 다음 측정의 열쇠가 된다 — TTL 이 갱신되지 않으므로
TTL 은 **생성 시각의 정확한 함수**다.
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
----------------------------------------------------------
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
dbsize 2 → 1
남은 키: _oauth2_proxy-87faa1c9…
삭제 직후 브라우저 요청 (11:32:49):
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
→ **200. 산 세션은 영향이 없다.**
oauth2-proxy 는 못 지우지만 **운영자는 지울 수 있다.**
[측정 4] ★ 누적한다 — 회전할 때마다
-------------------------------------
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
키 TTL 생성시각(추정) 판정
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
dbsize=2
**1차 회전에서 살아남았던 세션이 2차 회전에서 고아가 됐다.**
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
[측정 5] ★ 그래서 정리 규칙을 유도할 수 있다
----------------------------------------------
TTL 이 갱신되지 않으므로(측정 2):
생성시각 = 지금 - (cookie-expire - TTL)
이 값이 **회전 시각보다 이르면 그 키는 고아다.** 회전 이후에 만들어진
세션은 새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
**1초 오차.** 추정이 아니라 사실상 정확하다.
실행:
NOW=$(date -u +%s); ROT=<회전 시각 epoch>
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
T=$(redis-cli ttl "$K")
C=$(( NOW - (3600 - T) ))
[ $C -lt $ROT ] && redis-cli del "$K"
done
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
산 세션은 남고 고아만 사라졌다.
════ 결론 ════
1. **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.**
프록시는 티켓을 못 풀어 키를 계산할 수 없다. 운영자는 키를 직접 안다.
2. **고아는 반드시 사라진다 — 생성 후 1시간.** TTL 이 갱신되지 않기 때문에
"쓰고 있으면 안 지워진다" 같은 일이 없다. 다만 그 1시간 동안은 남는다.
3. **어느 것이 고아인지는 Redis 값으로 알 수 없다.** 이름·타입·크기가
같고 값은 암호화되어 있다. **TTL 만이 신호다.**
4. **그 TTL 로 정리 규칙이 유도된다.** 회전 시각 이전에 생성된 키는 전부
고아다. 1초 오차로 정확히 골라낼 수 있고, 실제로 골라내 지웠다.
5. **전제가 하나 있다 — `--cookie-refresh` 를 켜면 이 규칙이 깨진다.**
TTL 이 갱신되면 생성 시각을 역산할 수 없기 때문이다. 그때는 회전 후
`FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다.
@@ -0,0 +1,11 @@
=== 깨끗한 상태로 초기화 ===
DELETE 1
=== 기준선 ===
Keycloak 온라인 세션: 4
Redis 키: 0
=== 두 앱의 구조 ===
app1.hyeonworks.com → BFF (서버 세션: Redis + PostgreSQL)
app2.hyeonworks.com → oauth2-proxy (쿠키 티켓 + Redis)
둘 다 realm keycloak-patterns 를 쓴다
@@ -0,0 +1,9 @@
=== app1 로그인 직후 Keycloak 세션 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
(1 row)
Redis 키: 1
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
@@ -0,0 +1,20 @@
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2
(1 row)
=== 어느 클라이언트가 붙었는가 ===
client_id | name
--------------------------------------+------------------
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)
=== 저장소 상태 ===
Redis 키:
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
@@ -0,0 +1,25 @@
=== ★ Keycloak 의 SSO 세션 하나를 죽인다 ===
남은 Keycloak 세션: 1
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
남은 Keycloak 세션: 1
=== 앱 세션은 남아 있는가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
=== 남은 세션의 realm 과 client ===
user_session_id | realm | clients
--------------------------+--------+---------
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
(1 row)
=== 브라우저에서 두 앱을 다시 열면 어떻게 되는가 ===
(IdP 세션이 사라졌으면 재로그인이 필요해야 한다)
@@ -0,0 +1,12 @@
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
"frontchannelLogout" : false,
--- oauth2-proxy ---
"frontchannelLogout" : false,
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
=== 실제로 그 경로가 있는가 ===
/logout/connect/back-channel/keycloak HTTP 302
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
@@ -0,0 +1,8 @@
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
command terminated with exit code 1
=== 로그인 상태를 만든다 ===
(브라우저에 이미 세션이 있다)
Keycloak 세션: 2
Redis: 2 키
@@ -0,0 +1,28 @@
=== 로그아웃 전 상태 ===
Redis: 2 키
keycloak-patterns 세션: 0
=== ★ IdP 로그아웃 — Keycloak 이 백채널 알림을 보낼 것이다 ===
시각: 14:53:29
=== Keycloak 로그 — 백채널 요청을 보냈는가, 결과는 ===
=== BFF 로그 — 백채널 요청이 도착했는가 ===
=== 앱 세션이 정리되었는가 ===
Redis: 2 키
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
=== 로그아웃 전 — 실제 세션이 있는가 ===
keycloak-patterns 세션: 1
Redis: 1 키
=== ★ IdP 로그아웃 → 백채널 알림 ===
시각: 14:54:21
=== Keycloak 로그 ===
=== BFF 로그 — 요청이 왔는가 ===
=== 앱 세션 ===
Redis: 1 키
@@ -0,0 +1,15 @@
=== IdP 세션은 실제로 끊겼는가 ===
keycloak-patterns 세션: 0
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석:
Address: 100.83.212.4
Non-authoritative answer:
HTTPS 도달:
HTTP 200 (0 이면 못 닿음)
=== Keycloak 로그 전체에서 backchannel 흔적 ===
keycloak-0: 0 줄
keycloak-1: 0 줄
@@ -0,0 +1,15 @@
=== 백업 전 상태 ===
realms|clients|users|sessions|authclients = 2|15|2|3|1
=== pg_dump — 전체 덤프 ===
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
포함된 테이블 수: 101
=== 덤프에 세션이 들어 있는가 ===
offline_user_session 언급: 13
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr
2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501029 0 {"ipAddr
Zsk4QcgXf_qgyMKzde5AG-Fz 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr
@@ -0,0 +1,17 @@
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
남은 테이블: 0
=== 서비스 영향 ===
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
bff-555df79c97-6j86w 1/1 Running 0 49m
bff-555df79c97-vgg6g 1/1 Running 0 49m
keycloak-0 1/1 Running 0 4m15s
keycloak-1 1/1 Running 0 4m38s
=== Keycloak 이 무엇을 말하는가 ===
2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
@@ -0,0 +1,29 @@
=== 무엇이 실제로 깨지는가 ===
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
=== ★ 복구 — 덤프에서 되돌린다 ===
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
=== 복구 후 데이터 ===
realms|clients|users|sessions|authclients = 2|15|2|3|1
=== 복구 직후 — 재시작 없이 되는가 ===
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
=== 복구 전 세션이 살아났는가 ===
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
=== 파드 재시작 횟수 ===
keycloak-0 restarts=0
keycloak-1 restarts=0
@@ -0,0 +1,9 @@
=== D-1 의 교훈: 업그레이드 전에 백업한다 ===
백업: 396333 bytes
=== 현재 버전과 스키마 상태 ===
quay.io/keycloak/keycloak:26.7.0
총 마이그레이션 수: 210
=== 로그인 상태 만들기 (업그레이드 후 살아남는지 볼 것) ===
현재 세션: 4
@@ -0,0 +1,17 @@
=== ★ 롤백 시도: 26.7.0 → 26.0 ===
시각: 15:02:20
statefulset.apps/keycloak image updated
+20초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+40초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+60초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+80초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
+100초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+120초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
+140초 keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
+160초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
=== 새 파드가 무엇을 말하는가 ===
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Failed to start server in (production) mode
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) For more details run the same command passing the '--verbose' option. Also you can use '--help' to see the detai
@@ -0,0 +1,20 @@
=== 서비스는 살아 있는가 (StatefulSet 롤링이 막아줬다) ===
https://auth.hyeonworks.com/realms/master HTTP 200
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
ready 주소: [10.42.1.140]sed: -e expression #1, char 27: unknown option to 's'
=== Liquibase 오류 상세 ===
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
1 changesets check sum
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
1 changesets check sum
=== ★ 앞으로 되돌린다 (26.7.0) ===
statefulset.apps/keycloak image updated
partitioned roll out complete: 2 new pods have been updated...
keycloak-0 1/1 Running 0 10m
keycloak-1 1/1 Running 0 28s
=== 데이터는 무사한가 ===
realms|clients|migrations|sessions = 2|15|210|4
외부 진입점 HTTP 200
@@ -0,0 +1,17 @@
=== 실험대의 Secret 목록 ===
bff-secrets Opaque keys=1
keycloak-lab-secrets Opaque keys=2
oauth2-proxy-secrets Opaque keys=3
=== ★ base64 는 암호화가 아니다 — 한 줄로 읽힌다 ===
keycloak-lab-secrets/POSTGRES_PASSWORD = lab-postgres-change-me
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = lab-admin-change-me
bff-secrets/KEYCLOAK_CLIENT_SECRET = bff-lab-secret
oauth2-proxy-secrets/COOKIE_SECRET_A = lab-cookie-secret-aaaaaaaaaaaaaa
=== describe 는 값을 감춘다 (그래서 안전하다고 착각한다) ===
Type: Opaque
Data
====
KEYCLOAK_CLIENT_SECRET: 14 bytes
@@ -0,0 +1,23 @@
=== k3s 의 데이터 저장소 ===
Encryption Status: Disabled, no configuration file found
=== 저장 파일 ===
total 23336
drwx------ 2 root root 4096 Sep 2 09:12 .
drwx------ 8 root root 4096 Sep 4 03:23 ..
-rw-r--r-- 1 root root 13078528 Sep 4 06:05 state.db
-rw-r--r-- 1 root root 32768 Sep 4 06:06 state.db-shm
-rw-r--r-- 1 root root 10769712 Sep 4 06:06 state.db-wal
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
client secret 평문 등장 횟수: 0
=== 누가 Secret 을 읽을 수 있는가 ===
default SA: no
(Role 이 없으면 네임스페이스에 별도 제한이 없다는 뜻)
=== 파드 안에서는 어떻게 보이는가 ===
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
BFF_DB_PASSWORD=lab-postgres-change-me
@@ -0,0 +1,38 @@
=== 현재 인증서 (외부 관측, sudo 불필요) ===
subject=CN = auth.hyeonworks.com
issuer=C = US, O = Let's Encrypt, CN = YE2
notBefore=Sep 3 00:47:23 2026 GMT
notAfter=Dec 2 00:47:22 2026 GMT
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
→ 세 호스트가 같은 인증서를 쓴다 (SAN 3개, 와일드카드 아님)
=== 체인 완결성 (fullchain vs cert 실수 확인) ===
0 s:CN = auth.hyeonworks.com
1 s:C = US, O = Let's Encrypt, CN = YE2
2 s:C = US, O = ISRG, CN = Root YE
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
Verify return code: 0 (ok)
→ 중간 인증서가 함께 제공된다. fullchain.pem 이 올바로 설정되어 있다.
=== 갱신 자동화 ===
NEXT LEFT LAST PASSED UNIT
Fri 2026-09-04 17:03:46 KST 1h 54min Fri 2026-09-04 03:19:39 KST 11h ago certbot-renew.timer
타이머 enabled: enabled
타이머 active: active
=== 남은 기간 ===
만료: Dec 2 00:47:22 2026 GMT
남은 일수: 88일
Let's Encrypt 90일 발급 · 30일 남으면 갱신 → 실제 갱신까지 약 58일
=== 강제 갱신은 하지 못했다 ===
$ sudo -n -l
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required
→ test-server 의 sudo 는 비밀번호를 요구한다 (게스트 kc-lab-1/2 는 무암호).
certbot renew --force-renewal 도 nginx reload 도 실행할 수 없다.

Some files were not shown because too many files have changed in this diff Show More