Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 b5528fae87 docs: index all 23 experiments with what each measured
One table per experiment with its branch and result, plus the nine injections that silently did nothing and the five predictions that turned out wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:11:32 +09:00
DongHyeonkaandClaude Opus 5 4864d837f1 docs: D-4 — the certificate is fine and the renewal itself went untested
Three SAN entries and no wildcard is the constraint that cost something real in B-7, where oauth2-proxy had to borrow Grafana's app2 hostname because a fourth name was not available. The served chain is four deep and verifies, so fullchain.pem is configured rather than the cert.pem mistake that only breaks clients without a cached intermediate.

The forced renewal and the reload behaviour could not be measured because sudo on the host asks for a password, the same silent failure first noticed in B-7. nginx reload is graceful by design, but this lab has repeatedly shown that by design is not the same as measured, so it is recorded as untested rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:09:36 +09:00
DongHyeonkaandClaude Opus 5 027c24ee27 docs: D-3 — only RBAC actually hides anything
Every secret in the lab prints in four commands, while kubectl describe shows just a byte count and creates the impression that something is hidden. k3s reports encryption at rest disabled and the plaintext password is present in state.db, so one node disk carries the whole cluster's secrets, and inside the pod they are ordinary environment variables visible to exec, /proc and crash dumps.

The default service account cannot read secrets, which makes RBAC the one control doing real work here and the thing worth tightening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:07:35 +09:00
DongHyeonkaandClaude Opus 5 df140ab218 docs: D-2 — rolling back the image does not roll back the schema
Downgrading from 26.7.0 to 26.0 fails with liquibase ValidationFailedException on a changeset checksum, which is stricter than an unknown migration: the old version knows the changeset but its definition differs. The pod goes CrashLoopBackOff and never starts.

The StatefulSet stopped the rollout at the first pod, so the other kept serving and the front door stayed at 200, which replica 1 would not have done. The failed start never touched the schema, so restoring the image was enough; had the migration already applied, the D-1 database restore would have been the only way back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:05:36 +09:00
DongHyeonkaandClaude Opus 5 df5af95cb3 docs: D-1 — an empty database still answered 200
Dropping the schema left Keycloak serving realm metadata and JWKS from its Infinispan cache, so the front door stayed at 200 while only the paths that read the database failed. That is a different shape from A-2, where the connection itself broke and readiness pulled the pods out of the Service; here the connection is fine and the tables are simply gone, which the health check does not notice.

Restoring the pg_dump took one second with zero errors and no pod restart, and the row counts matched the backup exactly, sessions included. The real RPO is the backup interval plus the synchronous_commit loss measured in A-3, and this dump sits in the host's /tmp, which is the same failure domain as the thing it protects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:56 +09:00
DongHyeonkaandClaude Opus 5 6c310c93b7 docs: C-2 — nothing propagates because nobody implemented the receiving end
Neither client had a backchannel logout URL and the BFF has no oidcLogout configuration, so the three candidate paths all answer 302, which is the authentication redirect rather than a handler. Setting the URL on the identity provider alone changed nothing: with a live session, logging the user out emptied the Keycloak side and left the Redis session untouched.

Reachability is not the blocker here, since a Keycloak pod fetches the app's public URL with a 200, but that is a property of this tailnet split-DNS lab and is the assumption most likely to fail in production, where it fails silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:56:58 +09:00
33 changed files with 1621 additions and 0 deletions
@@ -0,0 +1 @@
- generic [active] [ref=f50e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,16 @@
- generic [ref=f51e3]:
- banner [ref=f51e4]:
- generic [ref=f51e5]: keycloak-patterns
- main [ref=f51e6]:
- heading "Sign in to your account" [level=1] [ref=f51e8]
- generic [ref=f51e12]:
- generic [ref=f51e13]:
- generic [ref=f51e14]: Username or email
- textbox "Username or email" [active] [ref=f51e17]
- generic [ref=f51e18]:
- generic [ref=f51e19]: Password
- generic [ref=f51e21]:
- textbox "Password" [ref=f51e24]
- button "Show password" [ref=f51e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f51e27]:
- button "Sign In" [ref=f51e30] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f52e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f52e3]
- paragraph [ref=f52e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f52e5] [cursor=pointer]
- button "token 경계 확인" [ref=f52e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f52e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f52e8] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [ref=f53e24]
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f54e3]:
- banner [ref=f54e4]:
- generic [ref=f54e5]: keycloak-patterns
- main [ref=f54e6]:
- heading "Sign in to your account" [level=1] [ref=f54e8]
- generic [ref=f54e12]:
- generic [ref=f54e13]:
- generic [ref=f54e14]: Username or email
- textbox "Username or email" [ref=f54e17]
- generic [ref=f54e18]:
- generic [ref=f54e19]: Password
- generic [ref=f54e21]:
- textbox "Password" [ref=f54e24]
- button "Show password" [ref=f54e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f54e27]:
- button "Sign In" [ref=f54e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f54e3]:
- banner [ref=f54e4]:
- generic [ref=f54e5]: keycloak-patterns
- main [ref=f54e6]:
- heading "Sign in to your account" [level=1] [ref=f54e8]
- generic [ref=f54e12]:
- generic [ref=f54e13]:
- generic [ref=f54e14]: Username or email
- textbox "Username or email" [ref=f54e17]: labuser
- generic [ref=f54e18]:
- generic [ref=f54e19]: Password
- generic [ref=f54e21]:
- textbox "Password" [active] [ref=f54e24]: labpass
- button "Show password" [ref=f54e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f54e27]:
- button "Sign In" [ref=f54e30] [cursor=pointer]
@@ -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,17 @@
# C-2 — 백채널 로그아웃 증거
2026-09-04 16:3016:55 KST
해설: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-current-state.txt` | 두 클라이언트 모두 `backchannelLogoutUrl` 없음 · BFF 소스에 `oidcLogout` 없음 · 후보 경로 셋 다 **302**(핸들러 없음) |
| `02-configure-idp.txt` | IdP 쪽에만 `backchannel.logout.url` 설정 (점 표기는 실패, JSON 으로 성공) |
| `03-logout-attempt.txt` | **살아 있는 세션(1)에 로그아웃 → IdP 세션 0, Redis 세션은 1 그대로.** Keycloak·BFF 로그에 흔적 없음 |
| `04-reachability.txt` | **Keycloak 파드가 `app1.hyeonworks.com` 에 `HTTP 200` 으로 닿는다** — 네트워크 문제가 아님 |
## 핵심 세 줄
1. **백채널 로그아웃은 어느 쪽에도 구현되어 있지 않았다.** C-1 이 관측한 "전파 안 됨"의 원인이다.
2. **IdP 쪽만 설정해도 소용없다.** 받을 엔드포인트와 `sid → 세션` 역인덱스가 앱에 있어야 한다.
3. **도달성이 숨은 전제다.** 이 실험대는 닿지만, 앱이 사설망에 있으면 설정해도 조용히 실패한다.
@@ -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
+16
View File
@@ -0,0 +1,16 @@
# D-1 — 백업·복구 리허설 증거
2026-09-04 16:5517:05 KST
해설: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-backup.txt` | `pg_dump --clean --if-exists` — 395KB · 101 테이블 · **세션 데이터 포함** |
| `02-destruction.txt` | `DROP SCHEMA public CASCADE` → 테이블 0개. **그런데 외부는 `HTTP 200`** — Keycloak 이 realm 캐시로 서빙한다 |
| `03-restore.txt` | 깨지는 것과 안 깨지는 것(`certs` 200 / `well-known` 500 / 토큰 400) · **복구 1초 · 오류 0건 · 데이터 완전 일치 · 재시작 0회** |
## 핵심 세 줄
1. **데이터베이스를 통째로 비웠는데 서비스가 200 을 냈다.** 헬스체크는 "DB 가 살아 있다"만 보고 "데이터가 있다"는 안 본다.
2. **복구는 1초, 오류 0건, 재시작 불필요.** 절차가 맞다는 것은 확인됐다.
3. **RPO 는 두 겹이다** — 백업 주기 + A-3 에서 측정한 `synchronous_commit OFF` 손실. 그리고 이번 덤프는 호스트의 `/tmp` 에 있어 **같은 장애 도메인**이다.
@@ -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,16 @@
# D-2 — 버전 업그레이드 증거
2026-09-04 17:0517:15 KST
해설: [`docs/experiment-d2-version-upgrade.md`](../../experiment-d2-version-upgrade.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-pre-upgrade.txt` | 백업 396KB · 이미지 26.7.0 · **마이그레이션 210건** · 세션 4 |
| `02-rollback-attempt.txt` | 26.0 으로 내리자 `Running(0/1) → Error → CrashLoopBackOff`. **`liquibase.exception.ValidationFailedException`** |
| `03-roll-forward.txt` | **서비스는 `HTTP 200` 유지**(ready 주소 1개) · 오류 원인 `1 changesets check sum` · 26.7.0 복귀 후 마이그레이션 210·세션 4 그대로 |
## 핵심 세 줄
1. **롤백은 안 된다.** 체크섬이 안 맞아 Liquibase 가 기동 자체를 거부한다 — "모르는 변경"이 아니라 "아는 변경인데 정의가 다르다".
2. **StatefulSet 이 사고를 절반에서 멈춰줬다.** 한 파드가 남아 외부 200 을 유지했다. replica 1 이었다면 전면 장애다.
3. **실패한 기동은 스키마를 안 건드렸다.** 그래서 이미지만 되돌려도 복구됐다 — 이미 적용된 뒤였다면 DB 복구(D-1)가 필요하다.
@@ -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,15 @@
# D-3 — 비밀 관리 증거
2026-09-04 17:1517:25 KST
해설: [`docs/experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-base64-not-encryption.txt` | 실험대의 **모든 비밀이 명령 네 줄로** 평문 출력. `describe``14 bytes` 만 보여줘 착각을 준다 |
| `02-at-rest.txt` | **`Encryption Status: Disabled`** · `state.db` 안에 비밀번호 평문 **2회 일치** · 파드 안에서는 `KEYCLOAK_CLIENT_SECRET=bff-lab-secret` 환경변수 · `default` SA 는 **읽을 수 없음** |
## 핵심 세 줄
1. **base64 는 감추려는 것이 아니라 YAML 에 바이트를 담기 위한 것이다.** `describe` 가 값을 가려 안전하다는 착각을 준다.
2. **저장소 암호화가 꺼져 있고 노드 디스크에 평문이 있다.** 노드 디스크 하나가 전 클러스터의 비밀이다.
3. **네 경로 중 RBAC 만 제 역할을 한다.** 그것이 실질적 방어선이며, 관리자에게는 아무 방어가 없다.
@@ -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 도 실행할 수 없다.
@@ -0,0 +1,14 @@
# D-4 — 인증서 갱신 증거
2026-09-04 17:2517:35 KST
해설: [`docs/experiment-d4-certificate-renewal.md`](../../experiment-d4-certificate-renewal.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-certificate-state.txt` | SAN 3개(와일드카드 아님) · **체인 4단계, `Verify return code: 0`** · `certbot-renew.timer` enabled·active, 11시간 전 실행 · 88일 남음 · **`sudo: a password is required` 로 강제 갱신 불가** |
## 핵심 세 줄
1. **인증서가 이름 3개만 담는다.** B-7 에서 oauth2-proxy 를 올릴 호스트가 없어 Grafana 의 `app2` 를 빌려야 했던 실제 비용이 여기서 나왔다.
2. **체인이 완전하다** — 단계가 4개이므로 `fullchain.pem` 을 쓰고 있다. 1개면 `cert.pem` 실수이며 캐시 없는 클라이언트에서만 깨진다.
3. **강제 갱신은 못 했다.** 호스트 sudo 가 비밀번호를 요구한다. 타이머가 active 라는 것은 "갱신이 된다"의 확인이 아니다.
+238
View File
@@ -0,0 +1,238 @@
# C-2 — 로그아웃이 전 앱에 퍼지는가 (백채널 로그아웃)
브랜치 `feature/keycloak-c2-backchannel-logout` ·
증거 [`docs/evidence/c2-backchannel-logout/`](evidence/c2-backchannel-logout/) ·
2026-09-04 16:3016:55 KST
선행: [`C-1`](experiment-c1-multi-app-sso.md) — **IdP 로그아웃이 앱에 전파되지 않는다**를 관측했다
---
## 0. 결론부터
| 확인 | 결과 |
|---|---|
| 백채널 로그아웃이 설정되어 있었는가 | **아니다.** 두 클라이언트 모두 `backchannelLogoutUrl` 없음 |
| BFF 에 그 엔드포인트가 있는가 | **아니다.** 소스에 `oidcLogout` 설정이 없다 |
| IdP 쪽만 설정하면 되는가 | **★ 안 된다.** 앱 세션이 그대로 남았다 |
| Keycloak 이 앱 URL 에 닿기는 하는가 | **닿는다** (`HTTP 200`) — 네트워크 문제가 아니다 |
**C-1 이 관측한 "로그아웃이 안 퍼진다" 의 원인은 단순했다 —
아무도 구현하지 않았다.**
---
## 1. 현재 상태 — 어느 쪽에도 없다
### IdP 쪽
```bash
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
```
```
frontchannelLogout : false
(backchannel.logout.url 없음)
```
`oauth2-proxy` 클라이언트도 마찬가지였다.
### 앱 쪽
```bash
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
```
**아무것도 안 나온다.**
Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 **명시적으로 켜야 한다.**
```java
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
```
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
**생기지 않는다.**
```
/logout/connect/back-channel/keycloak HTTP 302 ← 로그인으로 리다이렉트
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
```
**302 는 "그런 핸들러가 없어서 인증 요구로 떨어졌다"는 뜻**이다.
엔드포인트가 있었다면 POST 를 받아 200 이나 400 을 돌려줬을 것이다.
---
## 2. IdP 쪽만 설정하고 시험했다
```bash
kcadm.sh update clients/<id> -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
```
```
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
```
### 살아 있는 세션에 로그아웃을 걸었다
```
=== 로그아웃 전 ===
keycloak-patterns 세션: 1
Redis: 1 키
=== IdP 로그아웃 ===
kcadm.sh create users/<id>/logout -r keycloak-patterns
=== 결과 ===
keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다
Redis: 1 키 ← ★ 앱 세션은 그대로다
```
**IdP 세션만 사라지고 앱 세션은 남았다.** C-1 과 같은 결과다.
### 네트워크 문제가 아님을 확인했다
```
=== Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석: Address: 100.83.212.4
HTTPS 도달: HTTP 200
```
**클러스터 안에서 공개 이름으로 앱에 닿는다.** 이 실험대는 tailnet + split DNS
구성이라 헤어핀이 되는데, **운영에서는 안 되는 경우가 흔하다.**
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
> **설정을 해도 도달하지 못한다.** 그때는 로그도 안 남고 조용히 실패한다.
---
## 3. 그래서 왜 안 퍼졌는가
```
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
```
**②와 ③이 없다.** ①만 설정해도 받을 사람이 없다.
Keycloak 로그에 `backchannel` 문자열이 **0줄**이었다 — 다만 이것만으로
"보내지 않았다"고 단정할 수는 없다 (DEBUG 레벨일 수 있다).
**확실한 것은 앱 세션이 남았다는 관측**이다.
---
## 4. 개념 — 백채널 로그아웃의 구조
```
사용자가 어느 앱에서든 로그아웃
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
```
### `sid` 가 여기서 쓰인다
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
```
logout_token 의 sid → 앱이 "그 sid 로 만든 내 세션"을 찾아 지운다
```
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓰며,
**여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다**
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.**
### 부분 실패는 어떻게 되는가
```
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
```
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 "일부만 로그아웃된 상태"가
생길 확률이 올라간다.
---
## 5. 겪은 문제
| 문제 | |
|---|---|
| `kcadm -s "attributes.backchannel.logout.url=..."` 이 exit 1 | 점 표기가 안 먹는다. **JSON 으로 통째로** 줘야 한다 |
| 첫 시험이 무의미했다 | 로그아웃 전 IdP 세션이 **이미 0** 이었다. 끊을 대상이 없었다 |
| 세션 수를 realm 없이 셌다 | C-1 과 같은 실수 — `master` 의 admin 세션이 섞인다 |
| Keycloak 재시작 후 로그인 폼이 안 넘어갔다 | 인증 세션 쿠키가 무효화된 상태에서 폼을 재사용했다 |
**두 번째가 A층에서 반복한 교훈이다** — **주입 대상이 실제로 존재하는지
먼저 확인한다.** 세션이 없는 상태에서 로그아웃을 걸고 "전파가 안 된다"고
결론지을 뻔했다.
---
## 6. 구현하려면 무엇이 필요한가
| 계층 | 할 일 |
|---|---|
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 (완료) |
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 |
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿이므로) |
| **네트워크** | IdP → 앱 공개 URL 도달 (이 실험대는 됨, 운영은 확인 필요) |
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 |
**마지막이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다
다르게 동작하게 된다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 현재 설정 확인 — 어느 쪽에도 없다
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
# 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로
kcadm.sh update clients/<id> -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}'
# 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서)
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
# 4. 로그아웃하고 앱 세션을 본다
kcadm.sh create users/<user-id>/logout -r keycloak-patterns
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
kubectl -n keycloak-lab run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 \
--command -- curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
---
## 8. 다음에 남기는 것
| | |
|---|---|
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` 공유 |
| **oauth2-proxy** | 백채널 로그아웃 미지원 — SSO 안에서 앱마다 동작이 갈린다 |
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** |
| **B-2 와 연결** | 로그아웃이 지우는 것은 지금도 세 곳 중 하나뿐이다 |
+246
View File
@@ -0,0 +1,246 @@
# D-1 — 백업이 있다와 복구해봤다는 다르다
브랜치 `feature/keycloak-d1-backup-restore` ·
증거 [`docs/evidence/d1-backup-restore/`](evidence/d1-backup-restore/) ·
2026-09-04 16:5517:05 KST
선행: [`A-2`](experiment-a2-database-loss.md) · [`A-3`](experiment-a3-database-crash.md)
---
## 0. 결론부터
| 측정 | 값 |
|---|---|
| 덤프 크기 / 시간 | **395KB · 1초 미만** (101개 테이블) |
| 복구 시간 | **1초** (`15:00:12 → 15:00:13`), **오류 0건** |
| 서비스 회복 | **재시작 없이 15초 이내** (`restarts=0`) |
| 데이터 일치 | **완전 일치** — realms 2 / clients 15 / users 2 / sessions 3 / authclients 1 |
| **RTO** | 약 **30초** (파괴 감지부터 서비스 복귀까지) |
| **RPO** | **마지막 덤프 시점** + A-3 의 `synchronous_commit OFF` 손실 |
**그리고 예상 못 한 것 — 스키마를 통째로 지웠는데 서비스가 `200` 을 계속 냈다.**
---
## 1. 백업
```bash
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
```
```
크기: 394945 bytes (6956 줄)
CREATE TABLE: 101 개
offline_user_session 언급: 13
```
**세션도 덤프에 들어간다.**
```
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, ...)
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-... 7845f394-... 1788500836 0 {"ipAddr...
```
| 옵션 | 뜻 |
|---|---|
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 내지 않는다 |
**두 옵션이 없으면 "이미 존재한다" 오류가 쏟아진다.**
---
## 2. 파괴
```bash
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
```
DROP SCHEMA
CREATE SCHEMA
남은 테이블: 0
```
### ★ 그런데 서비스가 살아 있었다
```
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
keycloak-0 / keycloak-1 1/1 Running
```
**데이터베이스가 통째로 비었는데 `200` 이다.**
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다).
### 무엇이 깨지고 무엇이 안 깨지는가
```
/protocol/openid-connect/certs HTTP 200 ← realm 키는 캐시에 있다
/.well-known/openid-configuration HTTP 500 ← 이건 DB 를 본다
토큰 발급 HTTP 400
```
**부분적으로만 깨진다.** 헬스체크는 통과하고, 일부 엔드포인트는 정상이며,
**로그인만 안 된다.**
```
KEYCLOAK_JDBC_PING2: Failed to fetch the cluster members from the database
```
> **A-2(DB 프로세스 정지)와 다른 모양이다.** 거기서는 커넥션이 아예 안 돼
> readiness 가 DOWN 이 되고 전 파드가 Service 에서 빠졌다.
> **여기서는 커넥션은 되고 테이블만 없다** — 헬스체크가 통과해버린다.
>
> **"DB 가 살아 있다"와 "데이터가 있다"는 다르다.** 헬스체크는 앞의 것만 본다.
---
## 3. 복구
```bash
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
```
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
**1초, 오류 없음.**
```
복구 후: realms 2 | clients 15 | users 2 | sessions 3 | authclients 1
백업 시: realms 2 | clients 15 | users 2 | sessions 3 | authclients 1
```
**완전히 일치한다.**
### 서비스는 재시작 없이 돌아왔다
```
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다.**
A-2 에서 본 것과 같은 자가 회복이다.
### 세션도 살아났다
```
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
```
**`persistent-user-sessions` 덕분에 세션이 백업 대상이 된다** (A-0).
volatile 이었다면 세션은 애초에 DB 에 없으므로 **복구해도 전원 재로그인**이다.
---
## 4. RTO 와 RPO
```
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO ≈ 30초 (이 규모에서는 대부분이 사람의 판단 시간이다)
```
### RPO 는 두 겹이다
```
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
**A-3 에서 "153건 중 4건 유실"을 측정한 것이 여기에 더해진다.**
백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.
### 이 실험대의 규모는 현실적이지 않다
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3 | 수만 |
**복구가 1초인 것은 데이터가 작기 때문**이며, **절차가 맞다는 것만 확인된다.**
시간은 규모에 따라 완전히 달라진다.
---
## 5. 이 실험이 검증한 것과 못 한 것
| | |
|---|---|
| ✔ 덤프에 필요한 것이 다 들어간다 | realm·client·user·session·authorized client |
| ✔ 복구 절차가 동작한다 | `--clean --if-exists` 로 오류 0 |
| ✔ 서비스가 자가 회복한다 | 재시작 불필요 |
| ✘ **노드가 죽은 경우** | A-4 에서 본 대로 **PVC 가 노드에 묶여 있다.** 노드가 안 돌아오면 덤프가 유일한 길인데, **덤프를 어디에 두느냐**가 문제가 된다 |
| ✘ 대규모 복구 시간 | 데이터가 작아 측정 의미가 없다 |
| ✘ 백업 자동화·보존·검증 | 이번엔 손으로 한 번 떴다 |
> **가장 중요한 미검증 항목이 "덤프를 어디에 두는가" 다.**
> 이번 덤프는 `test-server:/tmp` 에 있다. **호스트가 죽으면 같이 사라진다.**
> A-4 에서 PVC 가 노드에 묶인 것을 봤듯, **백업도 같은 장애 도메인에 있으면
> 백업이 아니다.**
---
## 6. 재현 절차 (명령어)
```bash
# 1. 백업 — --clean --if-exists 가 없으면 복구 때 오류가 쏟아진다
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > keycloak-backup.sql
# 2. 무엇이 들어갔는지 확인 (세션이 있어야 한다)
grep -c '^CREATE TABLE' keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' keycloak-backup.sql
# 3. 파괴
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
# 4. ★ 무엇이 깨지는지 확인 — 전부 깨지지 않는다
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
# 5. 복구
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < keycloak-backup.sql
# 6. 데이터 대조 — 백업 시점의 수치와 같아야 한다
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select (select count(*) from realm), (select count(*) from client),
(select count(*) from user_entity),
(select count(*) from offline_user_session where offline_flag='0')"
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **D-2** 버전 업그레이드 | **백업이 전제다.** 스키마 마이그레이션은 되돌리기 어렵다 |
| 운영 | **덤프를 다른 장애 도메인에 둔다** |
| 관측 | **"DB 가 살아 있다"만 보는 헬스체크는 빈 DB 를 통과시킨다** |
+213
View File
@@ -0,0 +1,213 @@
# D-2 — 버전을 올리고 내릴 때 무엇이 일어나는가
브랜치 `feature/keycloak-d2-version-upgrade` ·
증거 [`docs/evidence/d2-version-upgrade/`](evidence/d2-version-upgrade/) ·
2026-09-04 17:0517:15 KST
선행: [`D-1`](experiment-d1-backup-restore.md) — **백업이 전제다** ·
[`A-8`](experiment-a8-rolling-restart.md) — 롤링 재시작이 안전하다는 것이 전제
---
## 0. 결론부터
| 확인 | 결과 |
|---|---|
| **롤백이 되는가** | **★ 안 된다.** `liquibase ValidationFailedException: 1 changesets check sum` |
| 그때 서비스는 | **★ 살아 있다.** 한 파드가 남아 외부 `200` |
| 앞으로 되돌리기 | **된다.** 정상 복구, 데이터 무사 |
| 세션 | **유지** (4개 그대로) |
**"롤백 계획"을 세워두었다면 그 계획은 동작하지 않는다.**
대신 **StatefulSet 의 롤링 업데이트가 사고를 절반에서 멈춰줬다.**
---
## 1. 전제 — 먼저 백업한다
D-1 에서 확인한 절차 그대로.
```bash
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/pre-upgrade.sql
```
```
백업: 396333 bytes
현재 이미지: quay.io/keycloak/keycloak:26.7.0
총 마이그레이션 수: 210
현재 세션: 4
```
### 개념 — `databasechangelog`
Keycloak 은 **Liquibase** 로 스키마를 관리한다. 적용한 변경 하나하나를
`databasechangelog` 테이블에 기록한다.
| 컬럼 | 뜻 |
|---|---|
| `id` / `author` / `filename` | 변경을 식별 |
| **`md5sum`** | **그 변경 정의의 체크섬** |
| `orderexecuted` | 적용 순서 |
**210개가 쌓여 있다.** 이것이 "이 DB 는 어느 버전까지 올라갔는가"의 기록이다.
---
## 2. 롤백을 시도했다 — 26.7.0 → 26.0
```bash
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:26.0
```
```
+20초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
+80초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
+140초 keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
```
```
ERROR: Failed to start server in (production) mode
ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
1 changesets check sum
```
### 왜 실패하는가 — 체크섬 불일치
```
26.7.0 이 적용한 변경 → databasechangelog 에 md5sum 기록
26.0 이 기동하며 검증 → 자기가 아는 그 변경의 md5sum 과 비교
└─ 다르다 → ValidationFailedException
```
**"모르는 변경이 있다" 가 아니라 "아는 변경인데 정의가 다르다" 이다.**
같은 changeset 이 버전 사이에 수정된 것이며, **더 엄격한 실패**다.
> **Liquibase 는 안전을 위해 기동 자체를 거부한다.**
> 스키마를 반쯤 아는 상태로 서비스하느니 안 뜨는 쪽을 고른 설계다.
---
## 3. 그런데 서비스는 살아 있었다
```
https://auth.hyeonworks.com/realms/master HTTP 200
ready 주소: [10.42.1.140] ← 한 파드만
statefulset desired/ready/updated: 2 / 1 / 1
```
**StatefulSet 의 롤링 업데이트가 한 번에 하나씩 바꾸기 때문**이다.
```
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
```
**A-8 에서 "무중단은 replica ≥ 2 와 readiness 의 조합" 이라고 썼는데,
여기서는 그 조합이 잘못된 배포를 절반에서 멈춰줬다.**
| replica 1 이었다면 | |
|---|---|
| 유일한 파드가 CrashLoopBackOff | **전면 장애** |
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
---
## 4. 앞으로 되돌리기
```bash
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:26.7.0
```
```
partitioned roll out complete: 2 new pods have been updated...
keycloak-0 1/1 Running
keycloak-1 1/1 Running 28s
realms|clients|migrations|sessions = 2|15|210|4
외부 진입점 HTTP 200
```
**정상 복구.** 마이그레이션 수도 세션도 그대로다 — **실패한 기동은 스키마를
건드리지 못했다.** Liquibase 가 검증 단계에서 멈췄기 때문이다.
---
## 5. 그래서 업그레이드 계획은 어떻게 세워야 하는가
```
✘ "문제가 생기면 이미지 태그를 되돌린다"
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
✔ "문제가 생기면 백업에서 DB 를 되돌리고 이미지도 되돌린다"
└─ D-1 에서 확인한 절차가 여기서 필요하다
```
| 단계 | |
|---|---|
| 1 | **백업** (D-1) — 이것이 유일한 되돌리기 수단이다 |
| 2 | 이미지 태그 변경 |
| 3 | **첫 파드만 관찰** — StatefulSet 이 멈춰준다 |
| 4 | 실패하면 **이미지를 되돌린다** (스키마가 안 바뀌었으면 이것으로 충분) |
| 5 | 스키마가 이미 바뀌었으면 **DB 도 복구**해야 한다 |
**4와 5를 가르는 것이 "Liquibase 가 검증에서 멈췄는가, 이미 적용했는가" 다.**
이번에는 검증에서 멈춰 4로 끝났다.
---
## 6. 이 실험이 확인한 것과 못 한 것
| | |
|---|---|
| ✔ 롤백이 안 된다는 것 | 체크섬 불일치로 기동 거부 |
| ✔ 실패가 안전하게 격리된다 | StatefulSet + readiness |
| ✔ 실패한 기동은 스키마를 안 건드린다 | 마이그레이션 210 그대로 |
| ✘ **정방향 업그레이드** | **26.7.0 보다 새 이미지가 없어 시험하지 못했다** |
| ✘ 마이그레이션 중 장애 | 스키마 변경 도중 죽으면? |
| ✘ 대규모 마이그레이션 시간 | 데이터가 작아 순식간이다 |
> **정방향을 시험하지 못한 것을 감춰서는 안 된다.**
> 다만 **역방향이 더 위험한 방향**이고, 그것이 실패한다는 사실이
> "롤백 계획" 을 무효로 만든다는 점에서 실무적으로 더 중요한 결과다.
>
> 새 버전이 나오면 같은 절차(백업 → 태그 변경 → 첫 파드 관찰)로 반복한다.
---
## 7. 재현 절차 (명령어)
```bash
# 1. 백업 먼저 (D-1)
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > pre-upgrade.sql
# 2. 현재 마이그레이션 수를 기록
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
# 3. 버전 변경
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:<tag>
# 4. ★ 첫 파드만 본다. 실패하면 StatefulSet 이 멈춘다
kubectl -n keycloak-lab get pods -w
kubectl -n keycloak-lab logs keycloak-1 | grep -iE "liquibase|changeset|validation"
# 5. 서비스가 살아 있는지 (남은 파드가 받는다)
kubectl -n keycloak-lab get endpoints keycloak -o jsonpath='{.subsets[*].addresses[*].ip}'
# 6. 되돌리기 — 스키마가 안 바뀌었으면 이미지만으로 충분
kubectl -n keycloak-lab set image statefulset/keycloak keycloak=quay.io/keycloak/keycloak:26.7.0
```
---
## 8. 다음에 남기는 것
| | |
|---|---|
| **D-3** 비밀 관리 | 업그레이드 시 Secret 도 같이 검토된다 |
| 운영 | **롤백 = 백업 복구**다. 태그만 되돌리는 계획은 반쪽이다 |
| 운영 | **replica ≥ 2** 가 잘못된 배포를 절반에서 멈춘다 |
| 미검증 | 정방향 업그레이드, 마이그레이션 중 장애, 대규모 소요 시간 |
+219
View File
@@ -0,0 +1,219 @@
# D-3 — Secret 은 정말 감춰지는가
브랜치 `feature/keycloak-d3-secret-management` ·
증거 [`docs/evidence/d3-secret-management/`](evidence/d3-secret-management/) ·
2026-09-04 17:1517:25 KST
---
## 0. 결론부터
| 경로 | 감춰지는가 |
|---|---|
| `kubectl get secret -o jsonpath \| base64 -d` | **★ 한 줄로 읽힌다** |
| `kubectl describe secret` | 값을 숨긴다 — **그래서 안전하다고 착각한다** |
| **저장소(at rest)** | **★ 암호화 꺼져 있음.** 저장 파일에 평문이 있다 |
| **파드 안** | **★ 평범한 환경변수다** |
| RBAC 기본값 | **막는다**`default` 서비스계정은 못 읽는다 |
**"Secret 이니까 안전하다" 는 네 가지 중 하나(RBAC)만 맞다.**
---
## 1. 한 줄로 읽힌다
```bash
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
```
```
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
```
**바이트 수만 보여준다.** 이것만 보면 "가려져 있구나" 싶다.
**`get -o jsonpath` 한 번이면 값이 나온다.**
### 개념 — base64 는 인코딩이지 암호화가 아니다
| | 목적 | 되돌리기 |
|---|---|---|
| **인코딩** (base64) | 바이너리를 텍스트로 안전하게 옮기기 | **키 없이 누구나** |
| 암호화 | 키 없이는 못 읽게 하기 | 키가 있어야 |
**Secret 이 base64 를 쓰는 이유는 감추려는 것이 아니라
YAML 에 임의 바이트를 담기 위해서다.**
---
## 2. 저장소에는 평문으로 있다
```bash
ssh kc-lab-1 'sudo k3s secrets-encrypt status'
```
```
Encryption Status: Disabled, no configuration file found
```
**k3s 의 저장소 암호화가 꺼져 있다.** 기본값이다.
```
/var/lib/rancher/k3s/server/db/state.db 13MB
/var/lib/rancher/k3s/server/db/state.db-wal 10MB
```
```bash
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
```
```
state.db 안의 평문 일치: 2
```
**저장 파일 안에 비밀번호가 그대로 있다.**
| 그래서 무엇이 위험한가 | |
|---|---|
| 노드 디스크를 얻으면 | **전 클러스터의 비밀** |
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
| A-4 에서 본 `local-path` PVC | **같은 디스크에 있다** |
> **D-1 에서 "덤프를 같은 장애 도메인에 두면 백업이 아니다" 라고 썼는데,
> 여기서는 "노드 디스크 하나가 모든 비밀" 이다.**
> 백업을 잘 챙겨도 그 백업 안에 비밀이 평문으로 들어간다.
**k3s 는 `--secrets-encryption` 플래그로 켤 수 있다.** 지금은 안 켜져 있다.
---
## 3. 파드 안에서는 환경변수다
```bash
kubectl -n keycloak-lab exec <bff-pod> -- sh -c 'env | grep -iE "secret|password"'
```
```
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
BFF_DB_PASSWORD=lab-postgres-change-me
```
**`env` 한 번이면 나온다.**
| 새는 경로 | |
|---|---|
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
| **크래시 덤프 · 오류 리포트** | 환경변수를 함께 담는 도구가 많다 |
| 자식 프로세스 | 상속된다 |
**볼륨으로 마운트하면 이 중 몇 가지가 줄어든다** — 파일 권한으로 제한할 수
있고 환경변수 덤프에 안 들어간다.
```yaml
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
```
---
## 4. RBAC 은 실제로 막는다
```bash
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
```
```
default SA: no
```
**기본 서비스계정은 Secret 을 못 읽는다.** 쿠버네티스의 기본값이 제한적이다.
> **네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다.**
> 그러므로 "누가 `get secrets` 를 할 수 있는가" 가 실질적인 방어선이며,
> **관리자 권한을 가진 사람에게는 아무 방어가 없다.**
A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼,
**Secret 접근도 리소스 단위로 나눌 수 있다.**
---
## 5. 그래서 무엇을 해야 하는가
```
지금: 매니페스트에 stringData 평문 → git 에 커밋되면 끝
k3s 저장소 암호화 꺼짐
파드 환경변수
```
| 단계 | 얻는 것 |
|---|---|
| ① 매니페스트에서 값을 빼고 **`.example` 만 커밋** | git 유출을 막는다 |
| ② **k3s `--secrets-encryption`** 활성화 | 노드 디스크 유출을 막는다 |
| ③ 환경변수 대신 **볼륨 마운트** | 프로세스·덤프 유출을 줄인다 |
| ④ **SealedSecret / 외부 KMS** | 매니페스트에 암호문만 남는다 |
| ⑤ **RBAC 최소화** | 유일하게 이미 동작하는 방어선을 좁힌다 |
**이 실험대는 ①~④ 중 아무것도 안 하고 있다.** 실험 목적으로는 의도적이지만,
**그 사실을 기록해두지 않으면 그대로 운영에 옮겨간다.**
### 이 실험대의 비밀들은 이미 문서에 있다
`lab-postgres-change-me`, `bff-lab-secret` 같은 값이 **이 저장소의 매니페스트와
문서에 그대로 적혀 있다.** 실험대 전용이며 외부에서 접근할 수 없는 값이지만,
**"실험대니까 괜찮다" 가 습관이 되면 위험하다.** 이름에 `change-me` 를 넣은 것이
그 최소한의 표시다.
---
## 6. 재현 절차 (명령어)
```bash
# 1. 한 줄로 읽힌다
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
# 2. describe 는 감춘다 — 대조
kubectl -n keycloak-lab describe secret bff-secrets
# 3. 저장소 암호화 여부
ssh kc-lab-1 'sudo k3s secrets-encrypt status'
# 4. 저장 파일에 평문이 있는가
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
# 5. 파드 안에서는 환경변수
kubectl -n keycloak-lab exec <pod> -- sh -c 'env | grep -i secret'
# 6. 누가 읽을 수 있는가
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
```
---
## 7. 다음에 남기는 것
| | |
|---|---|
| **D-4** 인증서 갱신 | 인증서 개인키도 같은 문제다 |
| **B-6** 암호화 key | **key 를 Secret 에 두면 이 실험의 결론이 그대로 적용된다** |
| 운영 | **RBAC 이 유일하게 동작하는 방어선이다** |
+182
View File
@@ -0,0 +1,182 @@
# D-4 — 인증서 갱신
브랜치 `feature/keycloak-d4-certificate-renewal` ·
증거 [`docs/evidence/d4-certificate-renewal/`](evidence/d4-certificate-renewal/) ·
2026-09-04 17:2517:35 KST
---
## 0. 결론부터
| 확인 | 결과 |
|---|---|
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
| 갱신 자동화 | **동작 중.** `certbot-renew.timer` enabled·active, 11시간 전 실행됨 |
| 남은 기간 | **88일** (갱신까지 약 58일) |
| **강제 갱신 실측** | **★ 못 했다.** `sudo: a password is required` |
**측정한 것과 못 한 것을 나눠 적는다.** 못 한 것을 안 한 것처럼 쓰면
이 기록 전체의 신뢰가 깎인다.
---
## 1. 인증서 구성 — B-7 에서 실제로 걸린 제약
```
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
```
**세 이름뿐이고 와일드카드가 아니다.**
> **이 제약이 B-7 에서 실제 비용을 만들었다.**
> oauth2-proxy 를 올릴 호스트명이 없어 **Grafana 가 쓰던 `app2` 를 빌려야 했고**,
> 그 때문에 관측 스택의 웹 UI 가 실험 동안 내려가 있었다.
>
> **"인증서에 이름을 몇 개 넣을 것인가" 는 TLS 설정이 아니라
> 나중에 무엇을 배포할 수 있는가를 정하는 결정이다.**
| | 이 실험대 | 와일드카드였다면 |
|---|---|---|
| 새 호스트 추가 | **인증서 재발급 필요** | 바로 가능 |
| DNS-01 검증 | 필요 | 필요 (와일드카드는 DNS-01 만 가능) |
| 노출 | 이름 3개만 | **하위 전체가 한 키에 묶인다** |
---
## 2. 체인이 완전한가 — 흔한 실수 확인
```
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` vs `cert.pem`
certbot 은 두 파일을 만든다.
| 파일 | 내용 | nginx 에 넣으면 |
|---|---|---|
| `cert.pem` | **리프만** | **일부 클라이언트에서 검증 실패** |
| **`fullchain.pem`** | 리프 + 중간 | 정상 |
**브라우저는 중간 인증서를 캐시하고 있어 `cert.pem` 으로도 대개 동작한다.**
그래서 실수해도 개발 중에는 안 드러나고, **캐시가 없는 클라이언트
(모바일 앱, curl, 다른 서버)에서만 깨진다.**
```bash
openssl s_client -connect <host>:443 -servername <host> | grep -E "^ *[0-9] s:"
```
**단계가 2개 이상이면 fullchain 이고, 1개면 cert.pem 을 쓴 것이다.**
이 실험대는 4단계로 정상이다.
---
## 3. 갱신 자동화는 동작한다
```
NEXT LEFT LAST PASSED
Fri 2026-09-04 17:03:46 KST 1h 54min Fri 2026-09-04 03:19:39 KST 11h ago
타이머 enabled: enabled / active: active
```
**하루 두 번 돌고, 11시간 전에 실제로 실행됐다.**
```
만료: Dec 2 00:47:22 2026 GMT
남은 일수: 88일
```
**아직 갱신하지 않은 것이 정상이다** — Let's Encrypt 는 90일 발급이고
certbot 은 **30일 남았을 때** 갱신한다. 지금 실행돼도 아무것도 안 한다.
> **타이머가 돌았다는 것과 갱신이 됐다는 것은 다르다.**
> "타이머가 active 니까 괜찮다" 는 확인이 아니다. **실제 갱신은 58일 뒤**이며,
> 그때 처음으로 절차가 시험된다.
---
## 4. ★ 못 한 것 — 강제 갱신과 무중단 확인
계획서의 D-4 는 이렇게 적혀 있었다.
```bash
sudo certbot renew --force-renewal
```
**실행할 수 없었다.**
```
$ 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)는 무암호라
A층에서 `conntrack`·`tc` 를 자유롭게 썼는데, **호스트는 다르다.**
> **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속
> 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다.
### 그래서 답하지 못한 것
| 계획서의 항목 | 상태 |
|---|---|
| nginx reload 타이밍에 무중단인가 | **미측정** |
| 갱신 중 진행 중이던 요청은 | **미측정** |
| `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) |
### 이론적으로는 무엇을 기대하는가
```
certbot renew → 새 인증서 파일 저장
└─ deploy-hook: nginx -s reload
└─ nginx 는 새 워커를 띄우고 옛 워커는 진행 중 요청을 끝낸 뒤 종료
→ graceful. 진행 중 요청은 옛 인증서로 완결된다
```
**nginx 의 reload 는 설계상 무중단**이지만, **확인하지 않았으므로 그렇게
쓰면 안 된다.** 이 실험대에서 반복해 배운 것이 바로 그것이다 —
A-1 의 NetworkPolicy, A-3 의 `--grace-period=0`, B-5 의 AOF 모두
**"그럴 것이다" 가 틀렸던 사례**다.
---
## 5. 재현 절차 (명령어)
```bash
# 1. 인증서 내용 — 밖에서 볼 수 있다
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| grep -E "^ *[0-9] s:|Verify return code"
# 3. 갱신 자동화
systemctl list-timers certbot-renew.timer --no-pager
systemctl is-enabled certbot-renew.timer
# 4. 강제 갱신 (sudo 필요 — 이 실험대에서는 불가)
sudo certbot renew --force-renewal
# 갱신 중 다른 창에서:
# while true; do curl -s -o /dev/null -w '%{http_code} ' https://auth.hyeonworks.com/realms/master; sleep 1; done
```
---
## 6. 남긴 것
| | |
|---|---|
| **강제 갱신 + 무중단 측정** | sudo 권한이 필요하다 |
| **SAN 확장** | 새 호스트를 쓰려면 재발급 — B-7 에서 실제로 걸렸다 |
| **D-3 과 연결** | 인증서 **개인키**도 같은 비밀 관리 문제다 |
+77
View File
@@ -0,0 +1,77 @@
# 실험 색인 — 23개 전부
각 실험은 **자기 브랜치**에 있고, 해설 문서와 증거 폴더가 한 벌이다.
| # | 실험 | 브랜치 | 한 줄 결과 |
|---|---|---|---|
| **A-0** | 세션 복제 확인 | `...multinode-cluster-jdbc-ping` | 세션 공유는 Infinispan 이 아니라 **PostgreSQL** 이 한다 |
| **A-1** | TCP 7800 차단 | `...a1-jgroups-transport-block` | 세션은 견디지만 **로그아웃 무효화가 7800 을 탄다** |
| **A-2** | DB 정상 정지 | `...a2-database-loss` | 전면 장애. **그런데 `up` 은 1이었다** |
| **A-3** | DB 강제 종료 | `...a3-database-crash` | **153건 중 4건의 로그인이 사라졌다** |
| **A-4** | 노드 전원 차단 | `...a4-node-loss` | **죽은 파드가 산 파드보다 건강해 보인다** |
| **A-5** | 비대칭 파티션 | `...a5-asymmetric-partition` | 단방향은 자가 치유. 완전 분단도 **한쪽은 산다** |
| **A-6** | 지연 주입 | `...a6-latency-injection` | **200ms → 22초** (왕복 × 풀 큐잉) |
| **A-7** | volatile 비교 | `...a7-volatile-comparison` | **세 결과가 정반대로 뒤집힌다** |
| **A-8** | 롤링 재시작 | `...a8-rolling-restart` | 무중단 + 세션 생존 |
| **B-0** | 자동구성 확인 | `...b0-bff-redis-deploy` | 조회 키에 **session id 가 없다** |
| **B-1** | Redis 세션 | `...b1-redis-session-store` | 세션만 옮겨지고 **토큰은 남는다** |
| **B-2** | 다중 인스턴스 | `...b2-multi-instance-session` | 공유는 되지만 **평문·덮어쓰기·로그아웃 미정리** |
| **B-3** | refresh 경쟁 | `...b3-refresh-token-contention` | 경쟁이 아니라 **세션이 파괴된다** |
| **B-4** | Edge 인가 | `...b4-edge-authorization-scope` | nginx 는 **설정하지 않은 헤더를 덮어쓰지 않는다** |
| **B-5** | Redis 상실 | `...b5-redis-loss-persistence` | **파드가 Ready 인 채로 계속 실패한다** |
| **B-6** | key 회전 | `...b6-key-rotation` | 회전은 안전, **옛 키를 버리는 순간이 위험** |
| **B-7** | cookie secret | `...b7-cookie-secret-rotation` | **겹침 구간이 없고 세션이 고아로 남는다** |
| **C-1** | 다중 앱 SSO | `...c1-multi-app-sso` | **IdP 세션을 죽여도 아무도 로그아웃되지 않는다** |
| **C-2** | 백채널 로그아웃 | `...c2-backchannel-logout` | **받는 쪽을 아무도 구현하지 않았다** |
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** |
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | 구성은 정상. **강제 갱신은 못 했다** |
## 문서 지도
| 문서 | 용도 |
|---|---|
| [`session-lab-prerequisites.md`](session-lab-prerequisites.md) | **먼저 읽을 것** — 왜 이런 걸 재는지 |
| [`experiment-plan.md`](experiment-plan.md) | 23개의 구조도·주입 방법·예측 |
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개 대조 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (13층) |
## 반복해서 배운 것 — 주입이 안 걸린 사례
**아홉 번 있었다. 전부 "아무 일도 없었다" 로 보였다.**
| 실험 | 안 걸린 주입 | 원인 |
|---|---|---|
| A-1 | NetworkPolicy 로 7800 차단 | conntrack `ESTABLISHED` 가 먼저 통과시킨다 |
| A-3 | `delete --grace-period=0 --force` | 컨테이너 런타임이 SIGTERM → 정상 종료 |
| A-3 | `kill -9 1` | 컨테이너 안에서 **PID 1 은 SIGKILL 을 무시**한다 |
| A-5 | `iptables -I FORWARD 1` | kube-router 가 자기 체인을 위로 재삽입 |
| A-5 | raw 테이블, 반대 노드 | **연결 방향이 뒤집혀 있었다** |
| A-6 | `tc ... dev eth0` | 인터페이스가 `enp1s0` 이다 |
| A-6 | `enp1s0` 에 파드 IP 필터 | VXLAN 캡슐화로 **안 보인다** |
| B-2 | `spring.sql.init` 스키마 | PostgreSQL 에 없는 `blob` 타입 + `continue-on-error` |
| B-7·D-4 | `sudo nginx -T` 등 | **호스트 sudo 가 비밀번호를 요구**한다 |
**그래서 실험마다 "주입 성공 신호" 를 먼저 정하게 됐다**
`cluster_size` 하락, `not properly shut down` 로그, iptables 패킷 카운터,
`tc -s qdisc``Sent`.
## 예측이 빗나간 곳
| 실험 | 예측 | 실제 |
|---|---|---|
| A-1 | 로그아웃 전파는 안 깨진다 | **깨졌다** — 무효화는 7800 을 탄다 |
| A-2 | `up` 이 잡아줄 것 | **1로 평평했다** |
| A-6 | 낙관적 락 충돌이 는다 | **0건** — 로그인은 새 행을 만든다 |
| B-4 | nginx 가 동명 헤더를 덮어쓴다 | **둘 다 도착했다** |
| B-6 | JWKS 캐시가 유예를 준다 | **즉시 401** |
## 말할 수 있는 것과 없는 것
**말할 수 있다** — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고
각 저장소가 죽으면 무엇이 어떻게 실패하는지 **재현하고 복구했다.**
버전에 따라 결론이 뒤집힌다는 것을 **같은 주입으로 양쪽 다 측정했다.**
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다".
규모·시간·다른 사람·실제 사용자·비용은 이 실험대에 없다.