Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 f45a2a2aaa docs: B-5 — the pod stays Ready while every request hangs
Stopping Redis returns HTTP 000 rather than an error because the client waits on reconnect, and the pod keeps serving traffic because the redis health indicator is not in the readiness group even though /actuator/health returns 503. That is the mirror image of A-2, where Keycloak put its database check in readiness and the pods left the Service.

Turning on AOF with config set created the appendonlydir and still lost everything on pod deletion, because /data was the container filesystem; adding a PVC makes the same setting work. Volume first, persistence setting second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:29:17 +09:00
DongHyeonkaandClaude Opus 5 7dc0a3e5da docs: B-4 — the edge does not overwrite the headers it never sets
Two headers of the same name both arrive rather than one overwriting the other, because nginx only replaces headers it sets with proxy_set_header. A comma inside a role name is indistinguishable from the delimiter, and the size limit is a cliff: Tomcat returns 400 around 8KB and the connection dies around 16KB, so the same cause produces two different-looking failures.

Forged identity headers reach the upstream untouched while the JWT-protected paths return 401, which is Q4's own point that a header-fed upstream has nothing to verify against. By Q4's checklist that answer alone points at the BFF structure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:23:41 +09:00
DongHyeonkaandClaude Opus 5 b16e1dccf7 docs: B-3 — concurrent refresh does not lose a race, it destroys the session
Five simultaneous refreshes with one token return a single 200, and that winner's new token is already dead. Reuse detection removes the client session while the user session stays, which is why the other responses read Session doesn't have required client rather than a reuse error.

Comparing policies shows rotation off passes all five and keeps the session, while raising refreshTokenMaxReuse to one still destroys it. Since no retry can recover a removed client session, Q2's own criterion resolves to a lock, and a database row lock is the natural place because its lifetime is tied to the connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:20:04 +09:00
16 changed files with 1039 additions and 4 deletions
+28 -4
View File
@@ -24,9 +24,24 @@ stringData:
# Base64 in etcd is not encryption — see D-3. # Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret KEYCLOAK_CLIENT_SECRET: bff-lab-secret
--- ---
# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses # Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to # nothing here, because /data is the container filesystem and dies with the
# reason about when the starting point is "nothing survives". # container — the appendonlydir was created and then thrown away. Persistence
# configuration without a volume is decoration.
#
# So the volume comes first, and only then does `--appendonly yes` mean anything.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: keycloak-lab
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -47,16 +62,25 @@ spec:
containers: containers:
- name: redis - name: redis
image: redis:7.4-alpine image: redis:7.4-alpine
args: ["redis-server", "--save", "", "--appendonly", "no"] # appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
# Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
ports: ports:
- containerPort: 6379 - containerPort: 6379
name: redis name: redis
readinessProbe: readinessProbe:
exec: { command: ["redis-cli", "ping"] } exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3 initialDelaySeconds: 3
volumeMounts:
- name: data
mountPath: /data
resources: resources:
requests: { memory: 32Mi, cpu: 20m } requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi } limits: { memory: 128Mi }
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
@@ -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,18 @@
# B-3 — Refresh Token 동시 갱신 경쟁 증거
2026-09-04 15:1515:25 KST
해설: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-concurrent-refresh.txt` | 같은 토큰으로 동시 5회 — **1개만 200**, 나머지는 `Maximum allowed refresh token reuse exceeded``Session doesn't have required client` **두 종류** 오류 |
| `02-session-impact.txt` | **★ 이긴 요청의 새 토큰조차 400.** user_session 행은 남아 있고 `revoked_token` 은 0건 |
| `03-client-session-removed.txt` | **기제 확정 (대조군 포함)** — 경쟁 세션 `client_sessions=0`, 정상 세션 `client_sessions=1` |
| `04-policy-comparison.txt` | 정책 3종 비교 — rotation OFF 는 **5/5 성공·세션 생존**, maxReuse=1 은 **여전히 세션 파괴** |
## 핵심 네 줄
1. **"하나는 성공"이 아니다.** 이긴 요청이 받은 토큰도 곧바로 쓸 수 없다.
2. **재사용 탐지가 client session 을 제거한다.** user session 은 껍데기로 남아 `Session doesn't have required client` 가 된다.
3. **`refreshTokenMaxReuse` 를 올려도 안 된다.** 동시 요청 수만큼 올려야 하고 그러면 rotation 의 목적이 사라진다.
4. **재시도로 회복되지 않으므로 Q2 의 답은 lock 이다.** 그리고 lock 은 저장소 쪽(가급적 DB 행 잠금)에 있어야 한다.
@@ -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,14 @@
# B-4 — Edge 인가 범위 증거
2026-09-04 15:2515:35 KST
해설: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-header-handling.txt` | ① 동명 헤더가 **둘 다 도착**(`['admin','editor']`)하고 값 안의 쉼표를 구분자와 구별할 수 없다 · ② 8KB 에서 Tomcat 400, 16KB 에서 연결 끊김 — **자르지 않고 거부** · ④ 위조 신원 헤더가 그대로 도착, JWT 경로는 401 |
## 핵심 세 줄
1. **Q4 의 「nginx 가 동명 헤더를 덮어쓴다」는 조건부다.** nginx 는 자기가 `proxy_set_header` 한 헤더만 덮어쓰고, 나머지는 통과시킨다 — 지금 `X-Auth-Request-*` 는 통과한다.
2. **크기는 절벽이다.** 점진적으로 나빠지지 않고 8KB 에서 전면 400 이 되며, role 이 많은 사용자만 깨진다.
3. **헤더를 인가 근거로 쓰면 위조 가능성이 곧 권한 상승이다.** 2홉 실험의 결론이 여기서는 신원 자체에 적용된다.
@@ -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
+17
View File
@@ -0,0 +1,17 @@
# B-5 — Redis 상실과 영속화 증거
2026-09-04 15:3515:50 KST
해설: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-baseline.txt` | 정지 전 — Redis 1키, PostgreSQL 1행, `save`/`appendonly no`, 외부 200 |
| `02-redis-down.txt` | 정지 후 — `/bff/token-boundary` **`HTTP 000`(멈춤)**, `/actuator/health` 503, **파드는 1/1 Ready 유지**, Lettuce 재연결 스택 |
| `03-health-groups.txt` | **핵심**`/actuator/health` 503 인데 `/actuator/health/readiness``{"status":"UP"}`. Service 엔드포인트에 두 파드 모두 남아 있다 |
| `04-persistence.txt` | 복구는 자동(재시작 0회) · **AOF 를 켰는데 파드 삭제 후 `dbsize 0`** · PVC 를 붙인 뒤 `written-on-pvc` **생존** |
## 핵심 세 줄
1. **파드가 Ready 를 유지한 채 계속 실패한다.** `redis` 헬스 지표가 readiness 그룹에 없기 때문이며, A-2 에서 Keycloak 이 NotReady 가 된 것과 정반대다.
2. **오류가 아니라 멈춤이다.** `HTTP 000` — 빠른 실패가 안 되어 있어 사용자는 멈춘 화면을 본다.
3. **볼륨 없이 AOF 만 켜는 것은 장식이다.** `appendonlydir` 까지 만들어지지만 컨테이너와 함께 사라진다.
@@ -0,0 +1,270 @@
# B-3 — 같은 refresh token 으로 동시에 갱신하면 → Q2
브랜치 `feature/keycloak-b3-refresh-token-contention` ·
증거 [`docs/evidence/b3-refresh-contention/`](evidence/b3-refresh-contention/) ·
2026-09-04 15:1515:25 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 공유되어야 경쟁이 성립한다
**대응 질문** — [Q2 · Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
> Q2 가 남긴 것: *"실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다."*
---
## 0. 결론부터
**"하나는 성공하고 하나는 실패한다"가 아니다. 세션이 파괴된다.**
```
5개를 동시에 보냈을 때 (rotation ON, maxReuse=0)
요청 1: 400 "Maximum allowed refresh token reuse exceeded"
요청 2: 400 "Session doesn't have required client"
요청 3: 400 "Session doesn't have required client"
요청 4: 400 "Session doesn't have required client"
요청 5: 200 (토큰 발급됨)
★ 그런데 5번이 받은 토큰으로 다시 갱신하면 → 400
```
**이긴 요청조차 쓸 수 없는 토큰을 받는다.**
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| **A** rotation ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
| **B** rotation OFF | **5 / 5** | 200 | **1 — 생존** |
| **C** rotation ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
**Q2 의 판정 기준***"실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도."*
**재시도로 회복되지 않는다.** 세션 자체가 없어지므로 답은 **lock** 이다.
---
## 1. 전제를 Q2 에 맞춘다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**기본값은 rotation 이 꺼져 있었다.** Q2 는 *"realm 이 refresh token rotation 과
재사용 허용 0회를 쓰게 되어서"* 를 전제로 하므로 맞춰야 한다.
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
```
> **`revokeRefreshToken` 이 rotation 스위치다.** 이름이 "회전"이 아니라
> "취소"인 것이 헷갈리는데, **켜면 새 토큰을 줄 때 옛 토큰을 무효화**한다.
> `refreshTokenMaxReuse` 는 그 위에서 **몇 번까지 봐줄 것인가**이다.
---
## 2. 재현 — 진짜 동시성을 만든다
B-2 에서 토큰이 PostgreSQL 로 공유되므로 두 replica 가 같은 항목을 본다.
다만 **Keycloak 쪽 동작을 분리해서 보려면** BFF 를 거치지 않는 편이 낫다.
```bash
# 파드 안에서 5개를 동시에 띄우고 wait
i=1; while [ $i -le 5 ]; do
( curl -s -o /tmp/b$i -w "%{http_code}" -X POST $KC \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d refresh_token=$RT > /tmp/c$i ) &
i=$((i+1)); done
wait
```
**순차 실행이면 재현되지 않는다.** `&` 로 띄우고 `wait` 해야 진짜로 겹친다.
---
## 3. 무슨 일이 일어났는가 — 기제
오류 메시지가 **두 종류**인 것이 단서였다.
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
DB 로 확인했다.
```sql
select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>';
```
```
경쟁을 겪은 세션: BvFiB01Rntz1FcLdf7zG4BNt client_sessions = 0 ← 제거됨
정상 세션(대조군): JT-XuepgutWcE273QwAnIXta client_sessions = 1
```
**user session 은 남고 client session 만 제거된다.**
```
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워진다
```
그래서 오류가 `"Session doesn't have required client"` 다 —
**세션은 있는데 그 클라이언트 몫이 없다.**
### 그래서 이긴 요청도 죽는다
```
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
**애플리케이션은 200 을 받았으므로 성공했다고 믿는다.**
다음 요청에서야 끊긴 것을 안다. **오류가 지연되어 나타난다.**
---
## 4. 정책을 바꿔 비교했다
### 구성 B — rotation OFF
```
1: 200 2: 200 3: 200 4: 200 5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
**전부 성공하고 세션도 멀쩡하다.** 같은 refresh token 을 계속 쓸 수 있으므로
경쟁 자체가 성립하지 않는다.
**대신 잃는 것** — 토큰이 유출되면 **만료까지 계속 쓸 수 있다.**
rotation 의 목적이 그 창을 좁히는 것이었다.
### 구성 C — rotation ON · maxReuse=1
```
1: 200
2: 400 "Session doesn't have required client"
3: 200
4: 400 "Maximum allowed refresh token reuse exceeded"
5: 400 "Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
```
**허용치를 1로 올려도 세션은 파괴됐다.**
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면
> **rotation 의 보안 목적이 사라진다.** 값을 올려 버티려는 시도는
> "몇 개까지 동시에 올 것인가"를 맞춰야 하는 문제로 바뀔 뿐이다.
---
## 5. Q2 검증 항목 대조
| # | Q2 의 검증 | 결과 |
|---|---|---|
| 1 | 동시 갱신 시 각 replica 동작 | **1개만 200, 나머지 400. 그런데 200 도 무효** |
| 2 | 사용자 화면에 로그인 만료로 보이나 일시 오류로 보이나 | **로그인 만료로 보인다** — 세션이 실제로 없어졌으므로 |
| 3 | 새 token 을 다시 읽어 **재시도하면 성공하는가** | **★ 실패한다.** client session 이 없어 어떤 토큰도 안 통한다 |
| 4 | 한 곳에서만 갱신할지 / 각자 하고 재시도할지 | **재시도로는 회복 불가 → 한 곳에서만** |
| 5 | lock 을 어디에 두고 얼마나 / 잡은 채 죽으면 | **아래 6절** |
| 6 | 갱신 실패를 로그인 만료와 구분할 수 있는가 | **구분할 필요가 없다 — 실제로 로그인 만료다** |
| 7 | rotation 전제를 바꿔서 비교 | **구성 B/C 로 측정 완료** |
**3번이 이 실험의 핵심이다.** Q2 는 "재시도하면 성공하는가"를 열어뒀는데,
**답은 아니오**이고 그래서 판정 기준이 자동으로 lock 쪽으로 결정된다.
---
## 6. 그래서 무엇을 해야 하는가
### lock 이 필요하다 — 그런데 어디에
```
BFF replica 1 ─┐
├─▶ 같은 (client, principal) 항목
BFF replica 2 ─┘
```
**lock 은 저장소 쪽에 있어야 한다.** 프로세스 안의 `synchronized`
replica 를 넘지 못한다.
| 후보 | |
|---|---|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE`**A-0 에서 Keycloak 자신이 쓰는 방식** |
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
**첫 번째가 자연스럽다** — 토큰이 이미 PostgreSQL 에 있고(B-2),
Keycloak 도 세션 갱신에 같은 기법을 쓴다.
```sql
-- A-0 에서 Keycloak 이 실제로 쓰는 것
select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
```
### lock 을 잡은 채 죽으면 (Q2 미지수 5번)
| 방식 | 프로세스가 죽으면 |
|---|---|
| **DB 행 잠금** | **연결이 끊기면 자동 해제** — 가장 안전하다 |
| Redis lock + TTL | TTL 만료까지 막힌다. TTL 이 짧으면 **중복 갱신**, 길면 **정지** |
**DB 잠금이 이 문제에서 유리한 이유가 여기 있다** — 잠금의 수명이
**연결의 수명**과 묶여 있어 따로 관리할 것이 없다.
**B-5(Redis 상실)에서 Redis lock 의 이 약점을 재볼 수 있다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 전제 맞추기
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
# 2. refresh token 하나 확보 (direct grant)
curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
curl -s -o /dev/null -w '%{http_code}' -X POST $KC -d grant_type=refresh_token -d refresh_token=$NEW
# 5. 기제 확인 — client session 이 지워졌는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>'"
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-5** Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다** |
| **B-6** 암호화 key 교체 | 같은 "동시 접근" 문제의 다른 얼굴 |
| **A-6** 지연 주입 (기록 정정) | A-6 에서 낙관적 락 충돌이 0 이었던 이유가 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **여기서처럼 같은 항목을 갱신할 때**다 |
| 설계 | **재시도로 회복되지 않는다 → lock.** Q2 의 판정 기준이 결정됐다 |
@@ -0,0 +1,247 @@
# B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4
브랜치 `feature/keycloak-b4-edge-authorization-scope` ·
증거 [`docs/evidence/b4-edge-authorization/`](evidence/b4-edge-authorization/) ·
2026-09-04 15:2515:35 KST
선행: [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) — 헤더 신뢰 경계
**대응 질문** — [Q4 · Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope)
---
## 0. 결론부터
| Q4 의 미지수 | 측정 결과 |
|---|---|
| ① 다중 값 구분자·escaping | **값 안의 쉼표와 구분자를 구별할 수 없다.** 동명 헤더는 **둘 다 도착한다** |
| ② 크기 상한 초과 시 | **자르지 않고 거부한다.** 거부 계층이 둘이고 증상이 다르다 (400 / 연결 끊김) |
| ③ role 변경 반영 시점 | **아래 4절** |
| ④ upstream 이 값을 검증하는가 | **아무것도 검증하지 않는다.** 위조 헤더가 그대로 도착한다 |
**그리고 Q4 가 「확인한 사실」로 적어둔 것 하나가 측정과 어긋났다.**
---
## 1. Q4 의 전제 하나를 정정한다
> Q4 확인한 사실: *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다."*
측정하면 그렇지 않다.
```
보냄: X-Auth-Request-Roles: admin
X-Auth-Request-Roles: editor
도착: ['admin', 'editor'] ← 둘 다 살아서 도착했다
```
### 왜 어긋나는가 — 조건이 빠져 있다
**nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.**
설정하지 않은 헤더는 **손대지 않고 그대로 흘려보낸다.** 그리고 HTTP 는
같은 이름의 헤더가 여러 번 오는 것을 허용한다.
```nginx
proxy_set_header X-Forwarded-Proto https; # ← 이건 덮어쓴다 (2홉 실험에서 확인)
# X-Auth-Request-Roles 에 대한 설정이 없다 # ← 이건 통과한다
```
> **"nginx 가 덮어쓴다"는 명제는 조건부다.**
> 덮어쓰려면 **그 헤더를 명시적으로 설정해야 한다.**
> Q4 의 제약 *"전달할 헤더는 allowlist 로 해야 하고 client 가 보낸 동명 헤더는
> 항상 덮어써야 한다"* 는 옳고, **지금은 그렇게 되어 있지 않다.**
### 보안적 함의
Edge 가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를
`admin` 으로 함께 보내면 **둘 다 upstream 에 도착한다.**
```
edge 가 붙인 것: X-Auth-Request-Roles: viewer
공격자가 보낸 것: X-Auth-Request-Roles: admin
upstream 이 받는 것: ['viewer', 'admin'] 또는 ['admin', 'viewer']
└─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다
```
**어느 것을 고르느냐가 프레임워크 구현에 달려 있다.** Spring 의
`request.getHeader()` 는 **첫 번째**를 돌려준다. 순서는 프록시가 정한다.
---
## 2. 구분자 문제 → Q4 ①
```
(a) X-Auth-Request-Roles: admin,editor,viewer → 도착 ['admin,editor,viewer']
(c) X-Auth-Request-Roles: role-with,comma → 도착 ['role-with,comma']
```
**(a) 와 (c) 가 도착 시점에 구별되지 않는다.**
```
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
```
**role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.**
Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다.**
| 대안 | |
|---|---|
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다** |
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다 (②) |
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 위조도 구분자도 해결된다 → **BFF 구조** |
**세 번째가 Q4 가 도달하려는 결론이다.**
---
## 3. 크기 상한 → Q4 ②
```
1000 → 200, 도착 1000
4000 → 200, 도착 4000
8000 → 400 (Tomcat 의 HTML 오류 페이지)
16000 → 000 (응답 자체를 못 받음)
32000 → 000
```
**자르지 않는다. 거부한다.** 그리고 **거부하는 계층이 둘**이다.
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|---|---|---|
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** |
> **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼,
> 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다.**
### 실무적 의미
```
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
```
**점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은
**사용자마다 다르다** — role 이 많은 사용자만 깨진다.
**Q4 의 가정** *"헤더 종류가 늘어나면 정해야 할 계약도 늘어난다"*
크기에서도 성립하며, **한계가 있다**는 것이 이 측정이다.
---
## 4. upstream 은 아무것도 검증하지 않는다 → Q4 ④
인증 없이 신원 헤더를 위조해 보냈다.
```
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
```
```java
.requestMatchers("/actuator/health", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
```
**JWT 경로는 서명을 검증하므로 위조가 안 된다. 헤더 경로는 검증할 대상이 없다.**
> Q4 확인한 사실 — *"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을
> 검증할 방법이 없다."* **정확하다. 그리고 그것이 이 구조의 본질적 한계다.**
>
> 2홉 실험에서 **헤더 위조로 `serverName: evil.example.com` 을 만든 것과 같은
> 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.**
### 그래서 세 곳이 독립적으로 필요하다
2홉 실험의 결론이 그대로 적용된다.
| 필요한 것 | 지금 상태 |
|---|---|
| ① 외부에서 upstream 으로 **직접 가는 경로 차단** | NetworkPolicy 패턴 확립됨 (2홉 실험) |
| ② edge 에서 **동명 헤더 덮어쓰기** | **★ 안 되어 있다** (1절) |
| ③ upstream 에서 **내부 credential 검증** | **★ controller 한 곳에만 있다** (Q4 제약) |
**셋 중 하나라도 빠지면 나머지 둘이 무의미하다.**
---
## 5. Q4 의 설계 판단 5문항 — 측정에 근거해 답한다
> *2번부터 5번 중 하나라도 그렇다면 헤더를 늘리기보다 BFF 구조로 구성하자.*
| # | 질문 | 이 실험이 주는 답 |
|---|---|---|
| 1 | 전달할 claim 이 계속 늘어나는가 | **늘면 8KB 절벽이 있다** (3절). 크기가 상한을 정한다 |
| 2 | role·tenant 변경이 **즉시 반영**돼야 하는가 | 헤더는 **edge 가 세션을 갱신할 때까지 옛 값**이다 |
| 3 | 정책이 애플리케이션 **도메인을 알아야** 하는가 | 안다면 edge 가 도메인을 알아야 하고, **경계가 무너진다** |
| 4 | 헤더 값이 **인가 판단의 근거**가 되는가 | **★ 그렇다면 위조 가능성이 곧 권한 상승이다** (4절) |
| 5 | **서비스별 정책 차이**가 커지는가 | edge 설정이 서비스 수만큼 늘어난다 |
**4번이 이 실험에서 가장 무겁다.** 헤더를 인가 근거로 쓰는 순간,
**헤더 신뢰 경계 세 곳이 모두 완전해야만** 안전하다. 하나라도 새면
**인증 우회가 아니라 권한 상승**이다.
> **결론 — 2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.**
> 그리고 이 실험대에는 이미 BFF(B-0~B-3)가 있다. 두 구조를 같은
> 실험대에서 비교할 수 있는 상태다.
---
## 6. 남긴 것
| 항목 | 상태 |
|---|---|
| ③ role 변경 반영 시점 | **미측정.** oauth2-proxy 가 없어 "proxy session" 이 존재하지 않는다 |
| ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 |
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요**`proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
**③ 은 oauth2-proxy 배포가 선행이며, 그것은 B-7 의 주제와 겹친다.**
---
## 7. 재현 절차 (명령어)
```bash
# ① 동명 헤더 — 덮어쓰는가 합치는가 통과시키는가
curl -s -H "X-Auth-Request-Roles: admin" -H "X-Auth-Request-Roles: editor" \
https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -A3 roles
# ② 크기 상한 — 어디서 어떻게 깨지는가
for n in 1000 4000 8000 16000; do
V=$(python3 -c "print('r'*$n)")
curl -s -o /tmp/o -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
https://app1.hyeonworks.com/api/echo
done
# ④ 위조가 통하는가
curl -s -H "X-Auth-Request-User: administrator" \
-H "X-Auth-Request-Roles: realm-admin" \
https://app1.hyeonworks.com/api/echo
# 대조 — JWT 를 요구하는 경로
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/me
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-7** oauth2-proxy | ③(반영 시점)을 재려면 proxy session 이 있어야 한다 |
| **C-1** SSO | 헤더 기반과 BFF 기반이 **SSO 에서 어떻게 다른가** |
| 코드 | `permitAll` 을 좁히고 internal token 검증을 **공통 경계**로 옮긴다 (Q4 제약) |
| 설정 | nginx 에서 `X-Auth-Request-*`**명시적으로 덮어쓴다** |
@@ -0,0 +1,254 @@
# B-5 — Redis 가 죽으면, 그리고 재시작하면 무엇이 남는가
브랜치 `feature/keycloak-b5-redis-loss-persistence` ·
증거 [`docs/evidence/b5-redis-loss/`](evidence/b5-redis-loss/) ·
2026-09-04 15:3515:50 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어야 각각 죽여볼 수 있다
---
## 0. 결론부터
| | 결과 |
|---|---|
| Redis 정지 시 요청 | **오류가 아니라 멈춘다** (`HTTP 000`) |
| `/actuator/health` | **503** |
| **파드 readiness** | **`UP` 유지 — 트래픽을 계속 받으며 계속 실패한다** |
| 복구 | 자동. **BFF 재시작 0회** |
| **AOF 를 켰는데 재시작 후 전부 소실** | **볼륨이 없었다.** 영속화 설정만으로는 아무것도 안 남는다 |
| PVC 를 붙인 뒤 | **살아남는다** |
**A-2(Keycloak DB 상실)와 정반대의 실패 모양이다.** 거기서는 헬스체크가
파드를 트래픽에서 빼줬는데, 여기서는 안 빼준다.
---
## 1. Redis 정지 — 오류가 아니라 정지다
```bash
kubectl -n keycloak-lab scale deployment/redis --replicas=0
```
```
/ HTTP 200 ← permitAll 정적 페이지
/bff/token-boundary HTTP 000 ← ★ 응답이 없다
/actuator/health HTTP 503
```
`000` 은 curl 이 응답을 못 받았다는 뜻이다. **오류를 돌려주는 것이 아니라
매달려 있다.** Lettuce 가 재연결을 시도하며 타임아웃을 기다리기 때문이다.
```
io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect
java.base/sun.nio.ch.Net.pollConnect
```
> **"빨리 실패하기(fail fast)"가 안 되어 있다.** 사용자는 오류 화면 대신
> **멈춘 화면**을 본다. 이것이 A-6(지연 주입)에서 본 것과 같은 문제다 —
> **느린 실패가 빠른 실패보다 나쁘다.**
---
## 2. 그런데 파드는 Ready 를 유지한다 — 가장 중요한 발견
```
/actuator/health HTTP 503
/actuator/health/readiness HTTP 200 {"status":"UP"}
/actuator/health/liveness HTTP 200
Service ready: [10.42.0.52 10.42.1.124] ← 둘 다 트래픽을 받는다
```
### 개념 — health group
Spring Boot 는 헬스 지표를 **그룹**으로 나눈다.
```
/actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
/actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
/actuator/health/liveness liveness 그룹
```
**`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
그래서 전체 상태는 `DOWN` 인데 readiness 는 `UP` 이다.
kubelet 은 `/actuator/health/readiness` 를 보므로 **파드를 빼지 않는다.**
### A-2 와의 대비
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|---|---|---|
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
| 파드 상태 | **NotReady** | **Ready 유지** |
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
**Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다.**
어느 쪽이 옳은지는 상황에 달렸다.
| readiness 에 넣으면 | 넣지 않으면 |
|---|---|
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
**의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다.**
```yaml
management:
endpoint:
health:
group:
readiness:
include: readinessState, redis # 넣으려면 명시해야 한다
```
---
## 3. 복구는 자동이다
```
/actuator/health HTTP 200
/bff/token-boundary HTTP 302 (세션이 사라져 로그인으로 보냄)
BFF 재시작: 0, 0 회
```
**Lettuce 가 스스로 재연결했다.** A-2 에서 Keycloak 의 커넥션 풀이 그랬던
것과 같다. **liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것**이고,
회복이 더 늦어졌을 것이다.
`302` 는 세션이 사라졌기 때문이다 — Redis 가 비었으므로 로그인 상태가 없다.
**사용자는 로그아웃된다.**
---
## 4. 영속화 — 설정만으로는 아무것도 안 남는다
### 시도 ① AOF 를 켜고 파드를 지운다
```bash
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
```
```
appendonly yes
/data 내용: appendonlydir ← 파일이 실제로 만들어졌다
```
파드를 지운 뒤
```
dbsize: 0
b5:probe (없음)
b5:aof (없음)
appendonly no ← 설정도 되돌아갔다
```
**전부 사라졌다.**
| 왜 | |
|---|---|
| `/data`**컨테이너 파일시스템** | 볼륨이 없으므로 컨테이너와 함께 사라진다 |
| `CONFIG SET`**런타임 전용** | 재기동하면 매니페스트의 `args` 가 이긴다 |
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다.
### 시도 ② PVC 를 붙인다
```yaml
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
```
```
appendonly yes
키 심음: written-on-pvc
--- 파드 삭제 후 ---
dbsize: 1
b5:pvc written-on-pvc ← 살아남았다
```
### 비교
| 구성 | 파드 삭제 후 |
|---|---|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
| AOF **켬**, **PVC** | **생존** |
**순서가 있다 — 볼륨이 먼저고 설정이 나중이다.**
### `appendfsync` 는 여전히 트레이드오프다
```
appendfsync everysec ← 기본값
```
**1초 분량을 잃을 수 있다.** A-3 에서 본 PostgreSQL 의
`synchronous_commit OFF` 와 **같은 모양의 맞바꿈**이다.
| 설정 | 잃는 양 | 비용 |
|---|---|---|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
| **`everysec`** | **최대 1초** | 기본값 |
| `no` | OS 에 맡김 | 가장 빠름 |
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가
다시 로그인해야 한다는 뜻이다.** A-3 에서 Keycloak 이 같은 판단을 했다.
### PVC 도 노드에 못박힌다
`local-path` PVC 이므로 **A-4 에서 본 것과 같다** — 노드가 죽으면
볼륨도 함께 접근 불가가 된다. **영속화는 재시작을 견디게 하지만
노드 상실을 견디게 하지는 않는다.**
---
## 5. 재현 절차 (명령어)
```bash
# ① 정지
kubectl -n keycloak-lab scale deployment/redis --replicas=0
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/bff/token-boundary # 000
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health/readiness
kubectl -n keycloak-lab get endpoints bff -o jsonpath='{.subsets[*].addresses[*].ip}'
# ③ 복구
kubectl -n keycloak-lab scale deployment/redis --replicas=1
# ④ 영속화 — 볼륨 없이 AOF 만 켜본다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize # 0
# ⑤ PVC 를 붙이고 다시
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get k # v
```
---
## 6. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-6** 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
| **D-1** 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다** |
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 빠르게 실패시킨다 |