docs(guides): reproduction guides for all 26 experiments

Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 18:29:00 +09:00
co-authored by Claude Opus 5
parent 8062cc9a19
commit 6f6ab86345
27 changed files with 22678 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
# 실험 재현 가이드 26편
각 편은 **직접 쳐서 재현**하는 문서다. 무엇을 발견했는지는
[`docs/experiment-*.md`](../../) 가 적고, 여기는 **그것을 다시 만들려면 무엇을
어떤 순서로 치는가**를 적는다.
## 전제
[기반 7단계](../)가 끝나 있어야 한다. 특히 [05](../05-keycloak/) 까지는
모든 실험의 공통 전제이고, 지표를 보는 실험은 [06](../06-observability/) 도
필요하다.
## 각 편의 구조
```
이 가이드가 끝나면 · 전제 · 주의 · 표시 규약
0 왜 이 실험인가
1 기준선 ← 주입 전에 평시를 잡는다
2 주입
3 주입 검증 ← 여기가 대부분의 편에서 가장 중요하다
4 관찰
5 복구
막히면 · 다음
```
**3번이 핵심인 편이 많다.** 이 실험대에서 주입은 아홉 번 조용히 실패했고,
실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않는다.
그래서 결과를 읽기 전에 대상이 실제로 그 상태인지를 따로 확인한다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 증거 파일에 있는 출력 원문. 그대로 나온다 |
| **형태** | 모양만 같고 값은 환경마다 다르다 |
| **미검증** | 손으로 치기 좋게 고쳐 쓴 형태. 원래 실행에서 그대로 쓰이지는 않았다 |
## A층 — Keycloak 자체가 깨질 때
| | 가이드 | 무엇을 직접 보게 되나 |
|---|---|---|
| A-0 | [세션 공유 경로](a0-session-replication.md) | 세션을 나르는 것이 Infinispan 이 아니라 PostgreSQL 이라는 것 |
| A-1 | [7800 차단](a1-jgroups-transport-block.md) | 세션 공유는 안 깨지고 로그아웃 전파만 깨진다 |
| A-2 | [DB 정지](a2-database-loss.md) | 503 이 나는 동안에도 `up` 이 1 이다 |
| A-3 | [DB 크래시](a3-database-crash.md) | 200 을 받은 로그인 153건 중 4건이 DB 에 없다 |
| A-4 | [노드 상실](a4-node-loss.md) | 죽은 노드가 40초 동안 `Ready` 로 읽힌다 |
| A-5 | [비대칭 분단](a5-asymmetric-partition.md) | 한 방향만 막으면 열린 쪽으로 재연결한다 |
| A-6 | [지연 주입](a6-latency-injection.md) | 200밀리초가 두 단계를 지나 22.2초가 된다 |
| A-7 | [volatile 비교](a7-volatile-comparison.md) | 설정 하나로 A층 결론 셋이 뒤집힌다 |
| A-7a | [volatile 원인 확정](a7a-volatile-cause.md) | 같은 설정이 캐시 온도만으로 400·500·200 세 답을 낸다 |
| A-8 | [롤링 재시작](a8-rolling-restart.md) | 세션은 남고 캐시만 사라진다 |
## B층 — 애플리케이션 쪽 저장소
| | 가이드 | 무엇을 직접 보게 되나 |
|---|---|---|
| B-0 | [자동구성 확인](b0-bff-redis-deploy.md) | 아무것도 안 주면 Spring 이 무엇을 고르는가 |
| B-1 | [Redis 세션 저장소](b1-redis-session-store.md) | 세션은 옮겨지는데 토큰은 안 따라온다 |
| B-2 | [다중 인스턴스](b2-multi-instance-session.md) | 저장소를 옮겨도 안 고쳐지는 것 — 원인은 기본키다 |
| B-3 | [refresh 경쟁](b3-refresh-token-contention.md) | 이긴 요청의 토큰조차 못 쓴다 |
| B-4 | [Edge 인가 범위](b4-edge-authorization-scope.md) | 위조 헤더가 그대로 도착한다 |
| B-5 | [Redis 상실·영속화](b5-redis-loss-persistence.md) | 볼륨 없는 영속화 설정은 장식이다 |
| B-6 | [키 회전](b6-key-rotation.md) | JWKS 캐시에 유예 구간이 없다 |
| B-7 | [cookie secret 회전](b7-cookie-secret-rotation.md) | 겹침 구간을 만들 수 없고 서버 세션이 고아로 남는다 |
| B-7a | [고아 세션 정리](b7a-orphan-session.md) | TTL 로 생성 시각을 역산해 골라낸다 |
## C층 — SSO 와 로그아웃
| | 가이드 | 무엇을 직접 보게 되나 |
|---|---|---|
| C-1 | [다중 앱 SSO](c1-multi-app-sso.md) | SSO 는 되는데 로그아웃이 안 퍼진다 |
| C-2 | [백채널 로그아웃](c2-backchannel-logout.md) | 양쪽 다 없었다 — 한쪽만 고치면 여전히 안 된다 |
## D층 — 운영
| | 가이드 | 무엇을 직접 보게 되나 |
|---|---|---|
| D-1 | [백업·복구](d1-backup-restore.md) | 백업이 진짜 백업인지 스키마를 지워서 확인한다 |
| D-2 | [버전 업그레이드](d2-version-upgrade.md) | 롤백이 되는 조건은 스키마가 안 움직였을 때다 |
| D-3 | [비밀 관리](d3-secret-management.md) | base64 는 인코딩이지 암호화가 아니다 |
| D-4 | [인증서 갱신](d4-certificate-renewal.md) | 갱신은 성공했는데 38분 25초 동안 옛 인증서가 나갔다 |
| D-4a | [deploy 훅](d4a-deploy-hook.md) | 훅 하나로 그 공백이 1~2초가 된다 |
## 순서
A-0 을 먼저 한다. 나머지 A층 결론이 전부 거기서 확인한 「세션이 어디 있는가」
위에 서 있다.
```
A-0 ─┬─ A-1 ─┬─ A-5
│ └─ A-6
├─ A-2 ── A-3 ── D-1 ── D-2
├─ A-4
├─ A-8
└─ A-7 ── A-7a ← A층을 다 한 뒤 설정 하나만 바꿔 재실행한다
B-0 ── B-1 ─┬─ B-2 · B-3 · B-4 · B-5 · B-6
└─ B-7 ── B-7a
C-1 ── C-2 D-3 · D-4 ── D-4a (언제든 독립적으로)
```
**A-7 을 A층 마지막에 두는 이유** — 앞의 실험을 다 마친 뒤 설정 하나만 바꿔
재실행하면 **같은 주입에 대한 정반대 결과**를 한 벌로 얻는다.
## 안전
각 편의 2번(주입)부터 상태가 바뀐다. 모든 편이 **되돌리는 명령을 주입보다
먼저** 보여 주고, 5번에서 원상복구를 확인한다.
호스트(`test-server`)에서 하는 일은 sudo 비밀번호가 필요해 **사람이 직접
쳐야** 한다. D-1 과 D-4 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+956
View File
@@ -0,0 +1,956 @@
# A-2 재현 가이드 — PostgreSQL 을 내리고 살아남는 노드가 있는지 직접 본다
해설 문서: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md) ·
증거 원문: [`docs/evidence/a2-database-loss/`](../../evidence/a2-database-loss/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것 | 상주 탐침 파드 |
| JWKS 와 `.well-known``200` 으로 살아 있는 것 | 같은 파드 |
| Ready 파드가 **0개**, `ready` 주소가 **빈 목록**인 것 | `endpointslice` |
| 정문이 `503` 을 주는 것 | 밖에서 `curl` |
| `database connections` 만 DOWN 인 헬스 본문 | `health/ready` |
| **`up = 1` 인 채로 전면 장애가 나 있는 것** | Prometheus |
| 15초 만에 **재시작 0회**로 스스로 돌아오는 것 | `get pods` |
## 전제
- [`A-0`](a0-session-replication.md) 을 먼저 한다. 「세션은 DB 가 공유한다」를
손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 네임스페이스는 `keycloak-lab`, Prometheus 는 `observability` 다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
## 주의 — 이건 전면 장애를 만드는 실험이다
**정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다.** 이 실험대를 쓰는
다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는
명령은 하나뿐이고 [5. 복구](#5-복구) 에 있다. 중간에 그만두려면 그것만 치면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:5311:58 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 하는가
[A-1](a1-jgroups-transport-block.md) 에서 **「룩어사이드 캐시는 읽을 때 DB 와
대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도
캐시를 가진 노드가 `200` 을 줬다.
**그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다.** 그 가설을 가른다.
| | 예측 |
|---|---|
| 캐시가 DB 를 대신한다면 | 캐시를 가진 노드는 **살아남는다** — 부분 장애 |
| 대신하지 못한다면 | **전면 장애** |
그리고 A-1 과의 대비가 이 실험의 진짜 값이다.
```
A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하)
A-2 DB 정지 → ? (여기서 판정)
```
**네 경로를 구분해서 본다.** 하나만 재면 무엇 때문에 죽었는지 모른다.
| # | 경로 | 무엇을 보는가 |
|---|---|---|
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
| ③ | 새 로그인 | 쓰기 경로 |
| ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 |
---
# 1. 기준선 — DB 를 내리기 전에
**시험군만 재는 측정은 측정이 아니다.** 정지 후에 볼 것을 정지 전에 **똑같은
명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
```
## 1-1. 파드와 노드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
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
```
**어디를 봐야 하는가**
- `READY` 가 셋 다 `1/1`, `RESTARTS``0`
- **`postgres` 가 어느 노드에 있는가.** 원래 실행에서는 `kc-lab-2`, 즉
`keycloak-0` 과 **같은 노드**다
- IP 세 개를 적어 둔다
**이 결과가 의미하는 것**`postgres``keycloak-0` 이 같은 노드에 있다는
사실은 이 실험에서는 상관없지만, **A-4(노드 상실)에서는 결정적이다.**
그 노드를 죽이면 A-2 가 함께 일어난다.
IP 는 변수로 잡아 둔다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
```
## 1-2. 클러스터가 정상인가
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
읽기 좋게 자른다. **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
cluster_size keycloak-1 = 2
cluster_size keycloak-0 = 2
```
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
**이 결과가 의미하는 것** — A-1 의 분단이 완전히 회복된 상태에서 시작한다.
여기가 `1` 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다.
## 1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다
**A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다.** `--rm` 파드는
매번 만들고 지우므로 느리고 경합이 있고, **토큰을 단계 사이로 넘길 수 없다.**
이 실험은 **DB 정지 전에 발급한 토큰을 정지 후에 써야** 한다. 그래서 파드를
하나 띄워 두고 `exec` 로 단계를 이어간다.
**하기**
```bash
sudo kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
pod/a2-probe condition met
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**확인** — 환경변수가 들어갔나
```bash
sudo kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.67 K1=10.42.0.35 PW길이=19
```
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
```bash
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
```
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit`**파드는 안 지워진다**
(`--rm` 이 없다).
## 1-4. 양쪽 노드에 세션을 하나씩 만든다
**이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야
한다.** A-0 에서 확인한 성질을 그대로 쓴다 — **각 노드는 자기가 로그인시킨
세션만 캐시한다.**
**하기** — 파드 안 셸에서
```sh
TOK=/realms/master/protocol/openid-connect/token
for H in "$K0" "$K1"; do
echo -n "$H : "
curl -s -X POST "http://$H:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p'
done
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
```
**어디를 봐야 하는가** — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나
base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.
## 1-5. 세션이 각자 노드에만 캐시되었는가
**확인** — 밖에서. Keycloak 이미지에는 `curl` 이 없으므로 Prometheus 에 묻는다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
```
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
keycloak-1 = 0 건
keycloak-0 = 1 건
```
**어디를 봐야 하는가**`cache``sessions` 인 두 줄. 값이 서로 다르다.
**이 결과가 의미하는 것****`keycloak-0` 은 캐시를 가졌고 `keycloak-1` 은 없다.**
이제 ① 과 ② 를 구분해서 물을 수 있다.
> **`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다.** 방금 로그인했으므로
> 다음 15초 스크레이프에서 `1` 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀
> 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」.
> **중요한 것은 「양쪽이 다르다」가 아니라 「`keycloak-0` 이 확실히 가지고
> 있다」다.** ① 의 해석에 필요한 것은 그것뿐이다.
**확인** — DB 에는 몇 건인가
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
```
=== DB 온라인 세션 ===
2
```
**이 숫자를 적어 둔다.** 복구 후에 세션이 살아남았는지 볼 대조군이다.
## 1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다
**이 절을 건너뛰면 뒤의 `500` 이 아무 의미가 없다.**
### ④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다
**DB 가 죽은 뒤에는 이 조회 자체가 실패한다.** 미리 잡아 놔야 ④ 를 측정할 수
있다.
**확인** — 파드 안에서. 응답을 한 번 그대로 본다
```sh
AT=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
```
**형태** — 객체 하나짜리 배열. `"id"` 가 맨 앞에 있다
```json
[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}]
```
무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증**
```sh
CID=$(curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
echo "CID=$CID"
```
> `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수
> 있다.** `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고른다.
### 네 경로를 정상 상태에서 한 번 돌린다
**하기** — 파드 안에서
```sh
R0=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
R1=$(curl -s -X POST "http://$K1:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
```
**형태** — 정상 상태에서는
```
① 200
② 200
③ 200
④ 200
```
**`-o /dev/null` 을 빼면 안 된다.** 빼면 본문과 상태코드가 한 줄에 섞여
나온다. 원래 실행이 정확히 이걸 당했다 — [4-1](#4-1-네-경로) 을 본다.
**이 결과가 의미하는 것** — 네 경로가 전부 `200` 인 것이 기준선이다. 정지 후에
`500` 이면 「내가 깨뜨린 것」이고, 대조군 없이는 이 구별이 안 된다.
### ⑤ 상태가 필요 없는 경로도 미리 재 둔다
**하기**
```sh
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
"http://$K0:8080/realms/master/.well-known/openid-configuration"
```
**형태**
```
JWKS 200
well-known 200
```
**확인** — 밖에서 정문도 재 둔다
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**형태**
```
200
```
`exit` 으로 파드 셸에서 나온다. **파드는 그대로 둔다.**
---
# 2. 주입 — PostgreSQL 을 0대로 내린다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
```
## 2-1. 왜 `scale --replicas=0` 인가
| 방법 | 무엇이 일어나나 |
|---|---|
| **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** |
| `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 |
| 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** |
**`delete pod` 를 쓰면 이 실험이 성립하지 않는다.** DB 가 없는 구간을 원하는
만큼 유지할 수 있어야 네 경로를 다 재고 헬스와 엔드포인트까지 볼 수 있다.
> 이것은 **정상 종료**다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤
> 내려간다. **데이터는 하나도 잃지 않는다.** 강제로 죽였을 때 무엇을 잃는지는
> [A-3](a3-database-crash.md) 이 잰다.
## 2-2. 적용
**하기**
```bash
date '+%H:%M:%S 정지'
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
date '+%H:%M:%S 삭제완료'
```
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
```
=== [2] PostgreSQL 정지 ===
정지 시각: 11:56:04
deployment.apps/postgres scaled
pod/postgres-7b474b88c8-sn9ff condition met
삭제 완료: 11:56:04
```
**어디를 봐야 하는가****두 시각이 같다.** 즉시 사라진다.
**시각을 반드시 적어 둔다.** 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면
인과를 못 붙인다.
> **access token 수명이 60초다.** 1-6 에서 발급한 `AT` 로 ④ 를 재려면
> **발급 → 정지 → 시험을 60초 안에** 끝내야 한다. 60초를 넘기면 ④ 의 `401`
> 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면
> 4-1 전에 토큰을 다시 받아 둔다 — 단, **그건 DB 가 있어야 되는 일**이므로
> 순서는 「토큰 발급 → 정지」다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. postgres 파드가 정말 없나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**어디를 봐야 하는가**`postgres` 로 시작하는 줄이 **한 개도 없다.**
`Terminating` 으로 남아 있으면 아직 안 끝난 것이다. `wait` 가 통과했으면 없다.
**확인** — Deployment 쪽도 본다
```bash
sudo kubectl -n keycloak-lab get deploy postgres
```
**형태**
```
NAME READY UP-TO-DATE AVAILABLE AGE
postgres 0/0 0 0 5d
```
`0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.
## 3-2. Keycloak 이 실제로 DB 에 못 붙고 있나
**확인** — 로그가 원인을 말한다
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
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)
```
**어디를 봐야 하는가**`Connection refused``agroal`.
**이 결과가 의미하는 것**`agroal` 은 Quarkus 의 커넥션 풀이다. **풀이 새
커넥션을 만들지 못한다.** 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고
있거나, 애초에 DB 가 안 죽은 것이다.
> `Connection refused` 이지 `timed out` 이 아니다. Service 는 남아 있지만 뒤에
> 파드가 없어 **연결이 즉시 거부**된다. 네트워크를 막았다면 timeout 이 나왔을
> 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다.
## 3-3. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
keycloak-0 false 0
keycloak-1 false 0
```
**어디를 봐야 하는가**`READY``false` 인데 **`RESTARTS` 가 여전히 `0`.**
**이 결과가 의미하는 것** — 파드는 **죽지 않았다.** 트래픽에서 빠졌을 뿐이다.
`RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을
재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다.
**이 `restarts=0` 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.**
---
# 4. 효과를 관찰한다
## 4-1. 네 경로
**하기** — 탐침 파드 안에서. 1-6 과 **똑같은 명령**을 다시 친다
```bash
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
```
```sh
TOK=/realms/master/protocol/openid-connect/token
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
```
> 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라진다. **셸을
> 붙잡고 있는 편이 낫다.** 그래서 터미널 두 개를 열라고 한 것이다.
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) ·
④ 는 [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
③ 새 로그인 HTTP 500
④ 관리 API (세션 조회 필요) HTTP 500
```
**하기** — 본문도 한 번 그대로 본다
```sh
curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
```
**실측**
```
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
```
**어디를 봐야 하는가** — 네 줄 전부 `500`. 그리고 본문이 **아무것도 말해 주지
않는다.** 원인은 3-2 의 서버 로그에만 있다.
### ★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다
원래 실행의 증거 파일에는 이렇게 남아 있다.
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
```
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
```
**읽어 보면 세 가지가 한 줄에 뭉쳐 있다.**
```
HTTP 000000{"error":"HTTP 401 Unauthorized"}401
─┬──── ──────────┬─────────────────── ─┬─
│ │ └─ 마지막 시도의 상태코드
│ └─ 응답 본문이 그대로 섞였다
└─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)
```
`curl -w '%{http_code}'` 를 쓰면서 **`-o /dev/null` 을 빼면** 본문이 표준출력으로
같이 나온다. 여기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓였다.
> **위 표의 ④ `500` 은 5절에서 다시 잰 값이다.** 첫 측정은 그대로 쓰지 않았다.
> 오염된 측정은 **버리고 다시 잰다.** 「`401` 인가 `500` 인가」를 추측으로
> 메우면 안 된다.
**당신은 1-6 부터 `-o /dev/null` 을 쓰고 있으므로 이 함정을 지난다.**
## 4-2. ① 이 `500` 인 것이 이 실험의 핵심이다
**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
A-1 에서는 로그아웃되어 DB 행이 사라진 세션에 대해 캐시를 가진 노드가 `200`
줬다. **왜 여기서는 안 되는가.**
```
refresh 처리
├── 세션이 존재하는가 → 캐시로 답할 수 있다
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
```
[A-0](a0-session-replication.md) 에서 잡은 SQL 그대로다.
```sql
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
```
> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
> **refresh 는 이름과 달리 쓰기 연산이다.**
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|---|---|---|
| DB | 살아 있다 | **없다** |
| 캐시가 답할 수 있는 부분 | 세션 존재 확인 → `200` | 세션 존재 확인 → 거기까지 |
| DB 가 필요한 부분 | `UPDATE` 는 성공 | **`UPDATE` 실패 → `500`** |
## 4-3. 살아남은 것 — 상태가 필요 없는 경로
**하기** — 파드 안에서, 1-6 의 ⑤ 를 그대로
```sh
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
"http://$K0:8080/realms/master/.well-known/openid-configuration"
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
JWKS 엔드포인트(realm 공개키) HTTP 200
realm 메타데이터(.well-known) HTTP 200
관리 API(세션 조회 필요) HTTP 500
```
**어디를 봐야 하는가** — 같은 파드, 같은 포트인데 **경로에 따라 `200``500`
이 갈린다.**
**이 결과가 의미하는 것** — **realm 공개키와 메타데이터는 메모리에 있으므로
DB 없이도 응답한다.** 이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰
검증을 계속할 수 있다**는 뜻이다.
> 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가 말할 수
> 있는 범위**다. B층에서 확인한다.
>
> **그리고 정문으로는 이것도 못 쓴다.** 다음 절 때문이다.
## 4-4. 전면 장애 — 살아남는 노드가 없다
**확인** — Service 가 어느 파드를 잡고 있나
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== Service 엔드포인트 ===
ready : [] ← 비었다
notReady: [10.42.0.35 10.42.1.67]
```
**어디를 봐야 하는가****`ready` 가 빈 목록.** 두 IP 가 전부 `notReady` 다.
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
> 뜬다. 원래 실행 기록에도 그 경고가 두 줄 남아 있다.
>
> **실측**
> ```
> 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
> ```
> 사람이 눈으로 볼 때는 이쪽이 더 짧다.
> ```bash
> sudo kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
> ```
**확인** — 밖에서
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**실측**
```
=== 외부 진입점 ===
https://auth.hyeonworks.com/realms/master HTTP 503
```
한 번 눈으로 볼 때는 헤더까지 본다.
```bash
curl -I https://auth.hyeonworks.com/realms/master
```
**이 결과가 의미하는 것****`503` 은 Keycloak 이 준 것이 아니다.** Ready 인
백엔드가 하나도 없어서 그 앞의 프록시가 준 것이다. 4-3 에서 `200` 이던 JWKS 도
정문으로는 닿지 않는다 — **readiness 게이트가 문을 닫았다.**
### A-1 과의 대비가 이 실험의 결론이다
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|---|---|---|
| Ready 인 파드 | `keycloak-1` **1개 생존** | **0개** |
| Service `ready` | `[10.42.0.35]` | **`[]`** |
| 외부 응답 | **200** | **503** |
| 성격 | 용량 저하 | **전면 장애** |
**노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다.**
**Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.**
> 「Redis 또는 DB 가 죽으면 어떻게 복구하는가」에 대한 첫 번째 답 —
> **복구 이전에, DB 이중화가 Keycloak 대수보다 우선한다.**
## 4-5. 헬스 본문이 이유를 말한다
**확인** — Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다
```bash
sudo kubectl -n keycloak-lab exec a2-probe -- \
curl -s "http://$K0:9000/health/ready"
```
**형태** — 한 줄 JSON 이 나온다. 한 번은 그대로 본다.
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
```
=== health/ready 상세 ===
전체: DOWN
Graceful Shutdown UP
Keycloak cluster health check UP
Keycloak database connections async health check DOWN
Keycloak Initialized UP
```
**어디를 봐야 하는가****네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다.**
**이 결과가 의미하는 것****헬스체크는 모든 항목이 UP 이어야 UP 이다.**
그리고 **`cluster health` 는 UP** 이다 — 클러스터는 멀쩡하다. A-1 에서는 정확히
반대였다(cluster DOWN, database UP). **같은 `503` 이라도 어느 체크가 DOWN 인지가
장애를 구별한다.**
**확인**`describe` 로도 같은 것이 보인다
```bash
sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions
```
## 4-6. 관측의 함정 — `up = 1` 인 채로 전면 장애
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'
```
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
```
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
```
**어디를 봐야 하는가** — 둘 다 `1`. **서비스는 `503` 인데.**
Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 **전 구간 평평하다.**
원래 실행의 그림이
[`a2-up-stayed-1-during-outage.png`](../../evidence/a2-database-loss/a2-up-stayed-1-during-outage.png)
이고, 11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.
**이 결과가 의미하는 것**`up` 은 **Prometheus 가 `/metrics` 를 긁는 데
성공했는가**만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다.
**기능은 전멸했는데.**
| 지표 | 이 장애에서 |
|---|---|
| `up` | **1 — 아무것도 알려주지 않는다** |
| 파드 `Ready` | **false — 여기서 드러난다** |
| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
> **A-0 에서는 `up` 을 「가장 중요한 합성 지표」라고 썼다. 절반만 맞다.**
> `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은 못 잡는다.
> 후자가 운영에서 훨씬 흔하다.
>
> **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.**
**확인** — 그럼 readiness 를 지표로 볼 수 있나
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
| head -c 300; echo
```
**형태** — 결과가 비어 있다
```json
{"status":"success","data":{"resultType":"vector","result":[]}}
```
**이 결과가 의미하는 것** — 이 실험대에는 아직 `kube-state-metrics` 가 없어
**파드 readiness 가 지표로 남지 않는다.** 즉 지금 이 장애는 **Prometheus 만
보고 있으면 알 수 없다.** **관측 스택에 빠진 것을 이 실험이 찾아냈다.**
---
# 5. 복구
## 5-1. 되돌린다
**하기**
```bash
date '+%H:%M:%S 재기동'
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
```
=== 복구 — 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
```
## 5-2. Keycloak 이 스스로 회복하는가 — 손대지 않고 본다
**★ 여기서 Keycloak 을 재시작하고 싶어진다. 참는다.** 재시작하면 이 실험이
답하려던 질문(「사람 개입이 필요한가」)이 사라진다.
**확인** — 15초 간격으로 몇 번 친다
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
| grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
**실측**
```
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
→ 서비스 복귀
```
**어디를 봐야 하는가**`READY` 가 둘 다 `true`, 정문이 `200`.
## 5-3. 재시작 없이 회복한 것이 맞나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
**실측**
```
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
keycloak-0 0
keycloak-1 0
```
**어디를 봐야 하는가****`0`.** 3-3 에서 본 값 그대로다.
**이 결과가 의미하는 것** — **커넥션 풀이 스스로 재연결하고 readiness 가 다시
UP 이 되면서 Service 에 복귀했다.** 사람이 한 일은 DB 를 켠 것뿐이다.
| | |
|---|---|
| 회복 시간 | **약 15초** (DB Ready 이후) |
| 사람 개입 | **없음** |
| Keycloak 재시작 | **불필요**`restarts=0` |
### 개념 — readiness 와 liveness 를 가르는 기준
| | 실패하면 | 언제 쓰나 |
|---|---|---|
| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고, DB 가
돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다. 게다가 재시작하면
**캐시까지 날아간다.**
> A-1 에서도 같은 결론이 나왔다. 분단된 노드가 **readiness 로** 빠졌기 때문에
> 재시작 없이 격리만 되었다. **Keycloak 은 두 종류의 장애를 다 readiness 로
> 신고한다.**
## 5-4. 세션이 살아남았나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**실측**
```
=== 정지 전 세션이 살아남았는가 ===
online 세션 5
```
**어디를 봐야 하는가** — 1-5 에서 적어 둔 값보다 크거나 같다. 실험 중에 로그인을
여러 번 했으므로 늘어나 있다.
**이 결과가 의미하는 것** — **세션은 DB 에 있으므로 DB 가 돌아오면 같이
돌아온다.** 정상 종료였기 때문에 하나도 잃지 않았다.
> **강제로 죽였다면 어떨까.** `SET LOCAL synchronous_commit TO OFF` 때문에
> 마지막 수백 밀리초의 쓰기가 사라져야 한다. **[A-3](a3-database-crash.md) 이
> 그 숫자를 잰다.**
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `sudo kubectl -n keycloak-lab get deploy postgres` | `1/1` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 헬스 | `exec a2-probe -- curl -s "http://$K0:9000/health/ready"` | 전체 `UP` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a2-probe` | 지웠으면 `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
**하기** — 탐침 파드를 지운다. `--rm` 이 없으므로 **직접 지워야 한다**
```bash
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
```
`sleep 7200` 이 끝나면 파드는 `Completed` 로 남는다. **자동으로 사라지지
않는다.** 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 원인이 이것이다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 4-1 의 ★ 절. 오염된 측정은 버리고 다시 잰다 |
| `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 |
| DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` — 2-1 |
| ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 — 2-2 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 |
| 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 — 4-1 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` — 4-4 |
| `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 — 4-6 |
| `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 — 4-6 |
| 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** — 5-2 |
| `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` — 5-5 |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'``0` 이면 `--env` 가 빈 값 |
---
# 왜 이 가이드는 임시 파드를 안 쓰나
A-1 은 관찰을 `kubectl run --rm` 임시 파드로 했고, **그 계측이 실패했다.**
매번 파드를 만들고 지우므로 느리고, 경합이 있고, 빈 출력이 섞였다.
이 실험은 거기에 더해 **토큰을 단계 사이로 넘겨야 한다.** 임시 파드로는 불가능
하다 — 파드가 사라지면 변수도 사라진다.
```
임시 파드 단계마다 새로 뜬다 → 토큰이 안 넘어간다 · 느리다 · 빈 출력
상주 파드 한 번 띄워 둔다 → exec 로 이어간다 · 파일에 남길 수 있다
```
**대신 지우는 것을 잊으면 안 된다.** `--rm` 이 없다는 것은 그런 뜻이다.
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
> 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다.
---
# 다음
| 실험 | A-2 가 남긴 질문 |
|---|---|
| [A-3](a3-database-crash.md) DB 강제 종료 | **정상 정지는 하나도 안 잃었다. 강제 종료는?** `synchronous_commit OFF` 의 대가 |
| A-4 노드 상실 | `postgres``kc-lab-2` 에 있다 — **그 노드를 죽이면 A-2 가 함께 일어난다** |
| D-1 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |
| 전부 | **알림을 `up` 에 걸지 않는다.** readiness 와 외부 응답 코드에 건다 |
@@ -0,0 +1,993 @@
# A-3 재현 가이드 — DB 를 진짜로 죽여서 몇 건이 사라지는지 센다
해설 문서: [`docs/experiment-a3-database-crash.md`](../../experiment-a3-database-crash.md) ·
증거 원문: [`docs/evidence/a3-database-crash/`](../../evidence/a3-database-crash/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF` | PostgreSQL 문장 로그 |
| `--grace-period=0 --force`**크래시가 아니라는 것** | crash recovery 가 없는 재기동 로그 |
| 컨테이너 안에서 **PID 1 이 SIGKILL 을 무시하는 것** | 파드 재시작 0, 로그 시각 그대로 |
| `not properly shut down` / `redo starts` / `redo done` | 같은 로그 |
| **`200` 과 토큰을 받았는데 DB 에 없는 sid** | `comm` 으로 뽑은 차집합 |
| `wal_writer_delay = 200ms` 가 기본값이라는 것 | `pg_settings` |
## 전제
- [`A-0`](a0-session-replication.md) 과 [`A-2`](a2-database-loss.md) 를 먼저 한다.
A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 **그
대가가 몇 건인지**를 잰다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 네임스페이스는 `keycloak-lab`.
- 터미널 **두 개가 반드시 필요하다.** 하나는 로그인 루프를 돌리고(붙잡고 있어야
한다), 하나는 그 사이에 DB 를 죽인다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
## 주의 — 이건 데이터를 잃는 실험이다
**PostgreSQL 을 강제로 죽이고, 세션 테이블을 두 번 비운다.** 실제로 커밋됐다고
응답한 데이터가 사라진다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는
방법은 매 단계에 적어 두었다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:5812:05 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
sid 와 건수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행
기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
[A-2](a2-database-loss.md) 는 DB 를 **정상 종료**시켰다. 세션은 하나도 안
없어졌다. 당연하다 — PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려간다.
**그런데 [A-0](a0-session-replication.md) 에서 이 한 줄을 잡았다.**
```sql
SET LOCAL synchronous_commit TO OFF
```
`COMMIT` 직전, **같은 트랜잭션 안에서** 나온다. 뜻은 이렇다.
```
COMMIT
├─ WAL 버퍼(메모리)에 기록 ← 항상 한다
├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답
└─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak
└─ 크래시 시 이 구간이 사라진다
```
**「사라질 수 있다」와 「몇 건 사라졌다」는 다르다.** 이 실험은 뒤쪽이다.
RPO(Recovery Point Objective)를 숫자로 만든다.
**그리고 이 실험의 절반은 「죽이는 데 실패하는 이야기」다.** 세 번 시도해서
세 번째에 성공했고, 앞의 둘은 **「손실 0건」으로 보였지만 실제로는 죽인 적이
없었다.** A-1 이 남긴 교훈이 그대로 나온다 — **주입이 실제로 걸렸는지 먼저
확인하지 않으면 「아무 일도 없었다」를 결과로 착각한다.**
---
# 1. 설계 확인 — 재기 전에 세 가지를 확인한다
**측정 설계가 성립하는지부터 본다.** 여기서 하나라도 어긋나면 뒤의 숫자는
아무 의미가 없다.
## 1-1. 눈금이 맞는가 — `LAST_SESSION_REFRESH` 로는 못 잰다
처음 계획은 「세션 갱신 시각이 되감기는지」 보는 것이었다. 스키마를 보고 접었다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "\d offline_user_session"
```
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
```
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)
```
**어디를 봐야 하는가** — 두 시각 컬럼의 타입이 `integer` 다. **초 단위.**
**이 결과가 의미하는 것****손실 창은 수백 밀리초인데 눈금이 1초다.**
보일 리가 없다. 이 설계는 버린다.
### 대신 행 존재 여부로 잰다 — 이진 판정
```
로그인 1회 = OFFLINE_USER_SESSION 행 1개
클라이언트가 sid 를 받았다 = 서버가 COMMIT 했다고 응답했다
크래시 후 그 sid 가 없다 = 잃은 것
```
**있거나 없거나**이므로 눈금 문제가 없다. **이 실험이 로그인 수백 건을 도는
이유가 이것이다** — 이진 판정을 여러 번 해서 비율로 만든다.
## 1-2. 로그인도 비동기 커밋인가 — **아니면 설계가 무너진다**
A-0 에서 잡은 것은 **refresh** 트랜잭션이었다. **로그인(INSERT)도 그런지는
확인하지 않았다.** 아니라면 로그인은 안 사라지고, 이 측정 설계 자체가 성립하지
않는다.
### 켠다 — 첫 번째 주입
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
```
**확인** — 실제로 켜졌나
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
all
```
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. `alter system`
`postgresql.auto.conf` 에 쓸 뿐이고 **reload 를 해야 적용된다.**
### 로그인 한 번을 보낸다
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
**하기**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
>
> **★ 명령줄에 비밀번호를 직접 쓰지 않는다.** 원래 실험의 재현 절차에는
> 평문 비밀번호가 그대로 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도
> 남는다.** `--env` 로 넘긴 값은 그 파드 안에서만 산다.
**확인** — 환경변수가 들어갔나
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.67 PW길이=19
```
**하기** — 로그인 한 번
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"'
```
**형태**
```
200
```
### 로그에서 로그인 트랜잭션을 찾는다
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20
```
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
```
=== [설계 확인] 로그인 트랜잭션도 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
```
**어디를 봐야 하는가**`BEGIN``COMMIT` 사이에 **`insert into
OFFLINE_USER_SESSION``SET LOCAL synchronous_commit TO OFF` 가 같이 들어
있는 것.** 앞의 `BEGIN`/`COMMIT`(2·5줄)은 다른 트랜잭션이다.
**이 결과가 의미하는 것****확인됐고, 함의가 refresh 보다 훨씬 무겁다.**
| | 잃으면 |
|---|---|
| refresh 갱신 시각 | 세션 수명이 조금 짧아진다. **사용자는 모른다** |
| **로그인 자체** | **토큰은 손에 있는데 세션이 없다.** 다음 요청부터 실패 |
### ★ 곧바로 끈다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
none
```
**★ 켜 둔 채로 3절에 들어가면 안 된다.** 3절은 수백 건의 로그인을 최대한 빨리
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
## 1-3. WAL 설정을 지금 재 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, setting, unit, source from pg_settings
where name in ('commit_delay','synchronous_commit','wal_writer_delay','wal_writer_flush_after')"
```
**실측** — [`08-wal-settings.txt`](../../evidence/a3-database-crash/08-wal-settings.txt)
```
=== 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)
```
**어디를 봐야 하는가**`source` 열이 전부 `default` 다. 아무도 안 건드렸다.
그리고 **전역 `synchronous_commit` 은 `on`.**
**이 결과가 의미하는 것** — **전역 설정만 보면 「우리는 동기 커밋」이라고 믿게
된다.** 그런데 1-2 에서 본 대로 **Keycloak 이 자기 트랜잭션에만 `SET LOCAL`
뒤집는다.** DBA 가 서버 설정만 보고 판단하면 틀린다.
> **★ 이 값을 지금 재 두는 것이 이 절의 요점이다.** 원래 실험은 결과를 먼저
> 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데, **그 시점에
> 이 값을 조회한 적이 없었다.** 나중에 재서 맞기는 했지만 **그때는 추정이었다.**
> 해설 문서 5절이 그 정정 기록이다.
>
> **가정한 값은 재기 전에 재 둔다.** 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.
---
# 2. 기준선 — 세션 테이블을 비우고 센다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
```
=== [정리] 세션 테이블 비우고 루프 잔여 확인 ===
DELETE 375
남은 세션: 0
```
**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**형태**
```
count
-------
0
```
**왜 비우나** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히
놓고 볼 것이다. 시작이 0 이어야 그 둘이 읽힌다.
> **캐시는 안 비워도 된다.** 이 실험의 판정은 **DB 행의 존재 여부**이고, 캐시는
> 판정에 안 들어간다. A-0 처럼 파드를 재시작할 필요가 없다.
**확인** — 파드가 정상인지도 본다
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
`keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS`
`0` 이어야 한다. **`RESTARTS` 값을 적어 둔다** — 3절에서 이 값이 오르는지가
주입 판정의 일부다.
---
# 3. 주입 — 세 번 시도한다. 앞의 둘은 실패한다
**이 절이 이 실험의 본체다.** 순서대로 따라가면 「죽이는 데 실패하는 두 가지
방법」을 직접 보게 된다. 건너뛰고 3-6 만 하면 **왜 그게 유일한 방법인지** 모른다.
## 3-1. 로그인 루프 — 스크립트 파일로 만든다
루프는 한 줄로 칠 물건이 아니다. **파일로 만든다.**
### 왜 파일인가
원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고, **인용이
세 겹이 되어 두 번 깨졌다.**
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
```
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
루프 시작
6초 경과 — 지금까지 성공한 로그인: 0
...
클라이언트가 200 을 받은 로그인 수: 0
```
**0건.** 파드 안에서 `( ... ) &` 로 띄운 루프가 **`exec` 세션이 끝날 때 같이
죽었다.** 측정 자체가 없었던 것이다.
**편집기로 파일을 연다.**
```bash
vim /tmp/a3-login-loop.sh
```
```sh
# file: /tmp/a3-login-loop.sh — 탐침 파드 안에서 돈다
#!/bin/sh
# K0 · PW 는 파드 환경변수에서 온다. 여기에 비밀번호를 적지 않는다.
TOK=/realms/master/protocol/openid-connect/token
: > /tmp/sids
i=0
while [ "$i" -lt 400 ]; do
AT=$(curl -s --max-time 5 -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -n "$AT" ]; then
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' >> /tmp/sids
fi
i=$((i + 1))
done
echo "루프 종료: $(wc -l < /tmp/sids)"
```
**어디를 봐야 하는가**`/tmp/sids` 에 **클라이언트가 `200` 과 토큰을 실제로
받은 것만** 쌓인다. `AT` 가 비면 아무것도 안 적는다. **이 파일이 「서버가
COMMIT 했다고 응답한 것」의 목록**이고, 그게 이 실험의 시험군이다.
**하기** — 파드 안으로 넣는다. `tar` 가 필요 없는 방법이다
```bash
sudo kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
< /tmp/a3-login-loop.sh
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh
```
**형태**
```
17 /tmp/a3-login-loop.sh
```
> `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. `cat >` 로 밀어 넣는
> 쪽이 어디서나 통한다.
**하기****터미널 ①** 에서 **앞으로 두고** 돌린다. 이 터미널은 붙잡힌다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
```
**`&` 로 배경에 보내지 않는다.** 그게 원래 실행이 실패한 이유다. 터미널을
하나 통째로 이 루프에 쓴다. **미검증** — 원래 실행은 호스트에서 배경 `exec`
로 했다.
**확인****터미널 ②** 에서, 얼마나 쌓였는지 본다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
```
=== 로그인 루프 시작 ===
8초 후: 112 건
```
**어디를 봐야 하는가****8초에 112건이면 초당 약 14건.** 이 속도를 적어 둔다.
4-4 에서 손실 건수를 시간으로 환산할 때 쓴다.
**0건이면 루프가 안 도는 것이다.** 터미널 ① 을 본다. 거기 에러가 있다.
## 3-2. 시도 ① — `--grace-period=0 --force`
**「강제 삭제」라는 이름이 붙어 있으니 크래시일 것 같다.** 확인해 본다.
**하기** — 터미널 ② 에서
```bash
date '+%H:%M:%S.%3N 종료'
sudo kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
date '+%H:%M:%S.%3N 반환'
```
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
```
=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
종료 시각: 12:00:26.511
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
삭제 반환: 12:00:26.586
```
터미널 ① 의 루프가 에러를 쏟기 시작한다. 그대로 두거나 `Ctrl-C` 로 멈춘다.
## 3-3. 주입 검증 ① — **crash recovery 가 돌았는가**
**★ 여기가 이 실험 전체에서 가장 중요한 절이다.** 결과를 세기 전에 **주입 성공
신호**를 본다. 이 실험은 그 신호를 미리 정해 뒀다.
```
PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시
→ 다음 기동에 아무 말 없이 뜬다
PostgreSQL 이 즉사했다 → 표시가 없다
→ "database system was not properly shut down"
→ "redo starts at ..." / "redo done at ..."
```
**확인** — DB 가 다시 뜰 때까지 기다렸다가 로그를 본다
```bash
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'
```
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
```
=== crash recovery 가 실행되었는가 (강제 종료의 흔적) ===
2026-09-04 02:58:41.036 UTC [1] LOG: database system is ready to accept connections
```
**어디를 봐야 하는가****`ready to accept connections` 한 줄뿐이다.**
`not properly shut down``redo` 도 없다.
**이 결과가 의미하는 것****crash recovery 가 돌지 않았다 = 깨끗하게 내려갔다.**
**하기** — 그런데도 손실을 세어 보면 이렇게 나온다
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`04-comparison.txt`](../../evidence/a3-database-crash/04-comparison.txt)
```
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
클라이언트 성공: 291 건
DB 에 존재: 291 건
★ 유실: 0 건
```
**0건.** 그런데 이건 **「안 잃었다」가 아니라 「죽인 적이 없는 것」이다.**
### 왜 안 죽었나 — 시그널 세 가지
| 신호 | PostgreSQL 의 반응 |
|---|---|
| **SIGTERM** | **fast shutdown** — 진행 중 트랜잭션을 롤백하고 **WAL 을 플러시**한 뒤 종료 |
| SIGINT | smart shutdown — 연결이 끊기길 기다린다 |
| **SIGKILL** | **즉사** — 플러시 없음. 다음 기동에 crash recovery |
`--force --grace-period=0`**API 오브젝트를 즉시 지운다.** 그것뿐이다.
컨테이너 런타임은 여전히 정상 종료 절차를 밟고, **PostgreSQL 은 SIGTERM 을
받고 얌전히 플러시했다.**
> **운영에 주는 것 — 장애 훈련이 훈련이 안 될 수 있다.**
> 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 **아무것도 죽이지
> 않은 것**일 수 있다. 훈련에는 **주입 성공 신호**가 있어야 한다.
## 3-4. 시도 ② — 컨테이너 안에서 `kill -9 1`
**postmaster 는 컨테이너의 PID 1 이다.** 직접 SIGKILL 을 보내면 될 것 같다.
**하기**
```bash
date '+%H:%M:%S.%3N SIGKILL'
sudo kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1
```
## 3-5. 주입 검증 ② — **아무 일도 일어나지 않았다**
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3
```
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
```
=== [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 ===
8초 후 성공 로그인: 110 건
SIGKILL: 12:03:21.441
최종 성공 로그인: 139 건
=== [검증] 이번엔 crash recovery 가 돌았는가 ===
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
```
**어디를 봐야 하는가****두 가지를 같이 본다.**
1. **`RESTARTS` 가 안 올랐다.** 파드는 재시작하지 않았다
2. **로그의 마지막 줄 시각이 `02:59:48` 이다** — 시도 ① 때 뜬 그 시각 그대로다
**★ 「`ready to accept connections` 줄이 있다」로 판정하면 안 된다.**
그 줄은 **아까 뜰 때 찍힌 것**이고 새로 찍힌 게 아니다. **줄의 존재가 아니라
시각을 본다.** 원래 실행의 검증 출력이 정확히 이 함정을 보여 준다.
### 개념 — PID 1 의 시그널 보호
리눅스 커널은 **PID 1 을 특별 취급한다.** 자기 PID 네임스페이스 안에서 온
시그널은 **핸들러가 등록된 것만** 전달된다. **SIGKILL 도 예외가 아니다.**
```
같은 네임스페이스 안에서 → PID 1 은 등록하지 않은 시그널을 무시한다
조상 네임스페이스에서 → 전달된다 (노드에서 kill -9 하면 죽는다)
```
부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데,
컨테이너에서는 **「안에서는 PID 1 을 못 죽인다」**로 나타난다.
> **그래서 크래시 재현은 두 갈래다.**
> **(a) 자식 프로세스**를 죽인다 — 다음 절
> **(b) 노드에서** 죽인다 — `ssh kc-lab-2 'sudo kill -9 <호스트 PID>'`.
> 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했다.
## 3-6. 시도 ③ — 백엔드 프로세스를 죽인다
PostgreSQL 은 **postmaster(부모) + 연결마다 백엔드(자식)** 구조다. 자식 하나가
비정상 종료하면 **postmaster 는 공유 메모리가 오염됐다고 보고 전체를
재초기화한다.** 그게 곧 crash recovery 다.
**확인** — 먼저 무엇을 죽일지 눈으로 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20
```
**형태**
```
UID PID PPID C STIME TTY TIME CMD
postgres 1 0 0 02:59 ? 00:00:00 postgres
postgres 40 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.1.67(41234) idle
postgres 41 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.0.35(52118) idle
...
```
**어디를 봐야 하는가**`PID 1` 이 postmaster 이고, `postgres: keycloak
keycloak ...` 이 **Keycloak 이 붙어 있는 백엔드**다. 이 중 하나를 죽인다.
**터미널 ① 에서 루프를 다시 돌리고 있어야 한다.** 8초쯤 쌓이면:
**하기** — 터미널 ② 에서
```bash
date '+%H:%M:%S.%3N SIGKILL'
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
```
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
시각: 12:04:22.063
최종 성공 로그인: 153 건
```
터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다.
## 3-7. 주입 검증 ③ — 이번엔 걸렸다
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=5m \
| grep -E 'terminated by signal|reinitializing|not properly shut down|redo|checkpoint complete|ready to accept'
```
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
```
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
```
**어디를 봐야 하는가** — 여섯 줄이 순서대로 나온다.
| 줄 | 읽는 법 |
|---|---|
| `terminated by signal 9` | 내가 죽인 그 백엔드다 |
| `all server processes terminated; reinitializing` | **postmaster 가 전체를 갈아엎기로 했다** |
| **`not properly shut down`** | **주입 성공 신호.** 이게 없으면 결과를 해석하지 않는다 |
| `redo starts at 0/23CAB68``redo done at 0/2529E40` | 재생된 WAL 구간 |
| `checkpoint complete` | 재생 결과를 디스크에 고정했다 |
| `ready to accept connections` | **시각이 새로 찍혔다** — 3-5 와 대조한다 |
**확인** — 파드는 재시작하지 않았다
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
```
**형태**
```
NAME RESTARTS
postgres-7b474b88c8-xxxxx 0
```
**이 결과가 의미하는 것** — 컨테이너의 PID 1 인 postmaster 는 **살아 있고
자식만 갈아치웠다.** 쿠버네티스 관점에서는 아무 일도 없었지만, **데이터
관점에서는 전원이 나간 것과 같다.**
### crash recovery 를 한 줄로
```
기동 시 pg_control 을 읽는다
└─ "깨끗하게 종료됨" 표시가 없다
└─ "database system was not properly shut down"
└─ 마지막 체크포인트부터 WAL 을 재생(redo)
└─ 디스크에 안 내려간 커밋은 복구할 수 없다 ← 손실
```
**WAL 에 없는 것은 재생할 수도 없다.** `redo starts``redo done` 사이가
살아 돌아온 구간이고, **그 뒤에 있던 것이 사라진 것**이다.
---
# 4. 결과 — 몇 건이 사라졌나
## 4-1. 클라이언트가 받은 sid 목록을 꺼낸다
**하기**
```bash
sudo kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
wc -l /tmp/client-sids.txt
```
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
```
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
```
**어디를 봐야 하는가** — 파일 한 줄에 sid 하나. 몇 줄인지 적어 둔다.
**확인** — 눈으로 한 번 본다
```bash
head -3 /tmp/client-sids.txt
```
**형태**
```
CQUfg9HLH29xvhiu6pVlfWOo
5gLP4fqmpZBbjhH_d-0TPMMr
hkcOv1QskUFmYveMLB6Hljra
```
빈 줄이 섞여 있으면 sid 추출이 실패한 것이다. 그대로 세면 유실 건수가 부풀려진다.
## 4-2. DB 에 남아 있는 sid 목록을 꺼낸다
**확인** — 먼저 총계를 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**실측**
```
DB 전체 온라인 세션 : 150 건
```
**하기** — 목록으로 뽑는다. `-tAc` 는 헤더·정렬 없이 값만 준다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select user_session_id from offline_user_session where offline_flag='0'" \
> /tmp/db-sids.txt
wc -l /tmp/db-sids.txt
```
> **`psql` 의 두 얼굴.** `-c` 는 표를 그려서 사람이 읽기 좋고, `-tAc` 는 값만
> 줘서 파이프에 넣기 좋다. **한 번은 `-c` 로 눈으로 보고**, 셀 때만 `-tAc` 를
> 쓴다.
## 4-3. 차집합 — 무엇이 사라졌나
`comm` 은 **정렬된 두 파일의 차집합**을 낸다. 정렬부터 한다.
**하기** **미검증**
```bash
LC_ALL=C sort -u /tmp/client-sids.txt > /tmp/a.txt
LC_ALL=C sort -u /tmp/db-sids.txt > /tmp/b.txt
comm -23 /tmp/a.txt /tmp/b.txt
```
**어디를 봐야 하는가**`comm -23` 은 **왼쪽 파일에만 있는 줄**을 낸다.
**클라이언트는 받았는데 DB 에는 없는 sid** 다.
| 옵션 | 무엇을 감추나 |
|---|---|
| `-1` | 왼쪽에만 있는 줄을 감춘다 |
| `-2` | 오른쪽에만 있는 줄을 감춘다 |
| `-3` | 양쪽에 다 있는 줄을 감춘다 |
`-23` 은 2·3 을 감추므로 **왼쪽 전용만 남는다.**
> **`LC_ALL=C` 를 빼면 안 된다.** `comm` 은 두 파일이 **같은 정렬 순서**임을
> 전제한다. 로케일이 다르면 대소문자·기호 순서가 달라져 **멀쩡한 sid 가
> 「없는 것」으로 잡힌다.** sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라
> 정확히 이 문제에 걸린다.
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
```
=== 크래시 전후 대조 ===
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
그중 DB 에 실제로 존재 : 149 건
★ 유실 : 4 건
=== 유실된 sid 목록 ===
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
```
**확인** — 건수만
```bash
comm -23 /tmp/a.txt /tmp/b.txt | wc -l
```
**이 결과가 의미하는 것** — **로그인이 성공했다고 응답받았는데 세션이 존재하지
않는다.** 153건 중 4건, **약 2.6%.**
**확인** — 대조군. 사라지지 **않은** 것도 하나 본다
```bash
tail -1 /tmp/client-sids.txt
```
그 sid 로 DB 를 뒤진다.
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, created_on, last_session_refresh
from offline_user_session where user_session_id='8do0Bw6tkVLDVxgxotE7GosH'"
```
**실측**
```
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
(1 row)
```
**대부분은 멀쩡하다.** 그래서 **손실이 잘 안 보인다.**
## 4-4. 숫자를 어떻게 읽나
**★ 여기서 성급하게 결론을 붙이지 않는다.** 원래 문서가 그렇게 했다가 정정했다.
원래 문서는 *「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」*고 썼는데,
**그 시점에 `wal_writer_delay` 를 조회한 적이 없었다.** 그리고 로그인 속도도
틀렸다.
**증거를 다시 읽는다.** 3-1 에서 본 값이다.
```
8초에 112건 ≈ 초당 14건
4건 ≈ 약 0.29초 분량
```
**19건이 아니라 14건이고, 0.2초가 아니라 약 0.29초다.**
| | |
|---|---|
| 측정한 손실 | 4건 ≈ **약 0.29초 분량** |
| `wal_writer_delay` (1-3 에서 잰 값) | **200 ms** |
| 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** |
**「같은 자릿수」까지가 이 실험이 말할 수 있는 것이다.** `wal_writer_delay`
하나가 손실 창을 정하는 것도 아니다 — `wal_writer_flush_after`(128 × 8kB)와
체크포인트 타이밍이 함께 작용한다.
> **재현하면 당신의 숫자는 다르다.** 로그인 속도, 디스크, 죽인 순간이 전부
> 다르기 때문이다. **중요한 것은 「4」가 아니라 「0 이 아니다」이고, 그 크기가
> WAL 플러시 주기와 같은 자릿수라는 것이다.**
## 4-5. 사용자에게 어떻게 보이는가
```
로그인 성공 → access token + refresh token 을 받음
│ (크래시)
다음 요청 → access token 은 60초간 통한다
│ (서명만 보는 경로라면)
60초 후 refresh → "Session not active" → 다시 로그인
```
**즉시 드러나지 않는다.** access token 수명 동안은 정상으로 보이다가 갱신
시점에 끊긴다. **장애와 증상 사이에 최대 60초의 시차가 있다.**
> **운영적 함의 — 모니터링은 갱신 실패율을 봐야 한다.** 로그인 성공률만 보면
> 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다.
## 4-6. 이 손실이 「허용된」 이유
Keycloak 의 판단은 이렇게 읽힌다.
| | |
|---|---|
| 세션 쓰기는 **매우 잦다** | 로그인마다, refresh 마다 |
| 잃어도 **회복 가능하다** | 사용자가 다시 로그인하면 된다 |
| 동기 커밋의 비용은 **모든 요청에 붙는다** | 크래시는 드물다 |
**드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택**이다. 합리적이지만,
**선택했다는 사실을 알고 있어야 한다.**
### 바꿀 수 있는가 — 못 바꾼다
```sql
-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
ALTER DATABASE keycloak SET synchronous_commit = on;
```
**`SET LOCAL` 이 우선하므로 이것으로는 못 막는다.** Keycloak 설정이나 소스
수준의 문제다.
> **RPO 0 이 필요하면 복제(streaming replication)로 푸는 것이 맞다.** 동기
> 스탠바이가 있으면 `synchronous_commit` 의 의미가 달라진다.
---
# 5. 복구 · 정리
## 5-1. 문장 로깅이 꺼져 있는지 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
`none` 이 아니면 1-2 의 reset 을 다시 친다.
## 5-2. 실험이 만든 세션을 정리한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**★ 재시작을 빼면 안 된다.** DB 만 지우면 **캐시 엔트리가 남아** 캐시 합계와
DB 총계가 어긋난다. A-0 이 겪은 함정이고, 다음 실험의 기준선을 망친다.
## 5-3. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found
```
`--rm` 이 없으므로 **자동으로 사라지지 않는다.** `sleep 7200` 이 끝나면
`Completed` 로 남는다.
## 5-4. DB 가 건강한지 본다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=postgres
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, dateexecuted from databasechangelog order by dateexecuted desc limit 3"
```
**어디를 봐야 하는가** — **질의가 그냥 되고, 마이그레이션 이력 세 줄이 나오는
것.** 건수는 Keycloak 버전마다 다르므로 숫자를 외울 필요가 없다. 오류 없이
읽히면 그것으로 충분하다.
**이 결과가 의미하는 것** — crash recovery 는 **커밋되지 않은 것만 버린다.**
스키마와 마이그레이션 이력은 멀쩡하다. 이 실험은 **「데이터 일부 손실」이지
「DB 파손」이 아니다.**
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
| WAL 설정 | `psql -c "show synchronous_commit"` | `on` (전역은 원래 on) |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a3-probe` | `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
로컬 임시 파일도 치운다.
```bash
rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh
```
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force``kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 — 3-3·3-7 |
| `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 — 3-5 |
| `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 — 3-6 |
| 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 — 3-1 |
| 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement``none` 인지 — 1-2 |
| 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 — 4-3 |
| 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 — 4-1 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 친다 |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a3-probe -- sh -c 'echo ${#PW}'``0` 이면 `--env` 가 빈 값 |
| `a3-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a3-probe --ignore-not-found` — 5-3 |
| `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 — 3-6 |
| 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 — 4-4 |
---
# 왜 이 가이드는 판정 기준을 먼저 적나
이 실험이 남긴 가장 큰 교훈은 손실 건수가 아니다.
> **주입 성공 신호를 미리 정한다.**
세 번의 시도 중 **두 번은 「유실 0건」이라는 깨끗한 결과를 냈다.** 신호를
정해 두지 않았다면 첫 번째 결과를 그대로 발표했을 것이고, 결론은
**「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」**가 됐을 것이다.
정반대의 결론이다.
| 실험 | 주입 성공 신호 |
|---|---|
| A-1 7800 차단 | conntrack 에 `SYN_SENT [UNREPLIED]`, `coord = t` 가 둘 |
| A-2 DB 정지 | Keycloak 로그의 `Connection refused` + agroal |
| **A-3 DB 크래시** | **`database system was not properly shut down` + `redo starts`** |
**신호가 없으면 결과를 해석하지 않는다.** 그게 이 절의 전부다.
---
# 다음
| 실험 | A-3 가 남긴 것 |
|---|---|
| D-1 백업·복구 | **진짜 RPO = 백업 주기 + 이 손실.** 둘을 더해야 한다 |
| B-6 Redis 영속화 | `appendfsync everysec`**같은 모양의 트레이드오프** |
| A-4 노드 상실 | `postgres``kc-lab-2` 에 있다 — **노드가 죽으면 이것도 함께 일어난다** |
| 모니터링 | **로그인 성공률이 아니라 갱신 실패율을 본다** — 4-5 |
| 전부 | **주입 성공 신호를 미리 정한다.** 여기서는 crash recovery 로그 |
+978
View File
@@ -0,0 +1,978 @@
# A-4 재현 가이드 — 기계 전원을 뽑고 쿠버네티스가 언제 알아채는지 직접 본다
해설 문서: [`docs/experiment-a4-node-loss.md`](../../experiment-a4-node-loss.md) ·
증거 원문: [`docs/evidence/a4-node-loss/`](../../evidence/a4-node-loss/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 기계는 없는데 쿠버네티스가 40초 동안 `Ready` 라고 말하는 것 | `kubectl get node` 와 외부 `curl` 을 나란히 |
| **죽은 파드가 `ready=true`, 산 파드가 `ready=false`** 인 것 | `get pods -o custom-columns` |
| 그 와중에 `up` 은 정확히 0 인 것 | Prometheus |
| 축출이 5분 뒤에야 시작되는 것 | `tolerationSeconds` 와 파드 상태 |
| 새 파드가 **영원히 `Pending`** 인 것 | `describe pod` 의 Events |
| StatefulSet 이 대체 파드를 **안 만드는** 것 | `get statefulset``CURRENT` |
| `kubectl` 이 죽어도 컨테이너는 도는 것 | `kc-lab-2` 에서 `crictl ps` |
| 관측자가 같이 죽으면 **0 이 아니라 구멍**이 남는 것 | Grafana |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- **명령을 치는 곳이 세 군데다.** 이 실험은 그 구별이 곧 내용이다.
| 터미널 | 어디 | 무엇을 |
|---|---|---|
| **A** | `test-server` (VM 호스트) | `virsh` — 전원을 뽑고 다시 넣는다 |
| **B** | `kc-lab-1` | `sudo kubectl` — 관찰. **4b 에서는 이 터미널이 죽는다** |
| **C** | `test-server` | 밖에서 `curl`. 사용자 시점 |
- 터미널 A 에서 `virsh` 가 시스템 하이퍼바이저를 보고 있어야 한다.
```bash
export LIBVIRT_DEFAULT_URI=qemu:///system
virsh uri
```
`qemu:///system` 이 아니면 **VM 이 안 보인다.** [`00-lab-host`](../00-lab-host/) 5절.
- 4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2`.
이름이 안 풀리면 `ssh 192.168.122.12`.
## 주의 — 이건 기계를 끄는 실험이다
`virsh destroy` 는 **종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다.**
게스트 파일시스템이 더러운 채로 멈춘다. **실험대에서만 한다.**
전 구간 약 **40분**이다. 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다.
어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.
```bash
virsh start kc-lab-2 ; virsh start kc-lab-1
```
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 12:0512:23 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
파드 이름·IP·시각은 **당신 환경에서 다르다.** 자리표시자(`<...>`)를 쓰지 않는
대신 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 하는가
A-1 과 A-5 는 **네트워크만** 끊었다. 파드는 살아 있었고, 쿠버네티스는 계속
정확한 상태를 알고 있었다. 여기서는 **기계 자체를 없앤다.** 그러면 상태를
보고할 주체가 사라진다.
두 판본으로 나눈다. **어느 노드를 죽이느냐가 전부**이기 때문이다.
| | 죽이는 노드 | 그 노드에 있는 것 | 묻는 것 |
|---|---|---|---|
| **4a** | `kc-lab-2` (워커) | keycloak-0 · **postgres** · postgres PVC | Keycloak 과 DB 를 **동시에** 잃으면 |
| **4b** | `kc-lab-1` (k3s 서버) | keycloak-1 · **Traefik** · 컨트롤 플레인 · 관측 스택 | **들어갈 문**을 잃으면 |
세 가지를 확인한다.
```
쿠버네티스는 언제 알아채는가 → 40초 (그동안 거짓말을 한다)
무엇을 스스로 고치는가 → 축출. 단 5분 뒤
무엇을 못 고치는가 → PVC 가 묶인 재배치, StatefulSet 이름
```
---
# 1. 기준선 — 전원을 뽑기 전에
**시험군만 재는 측정은 측정이 아니다.** 뽑은 뒤에 볼 것을 뽑기 전에 **똑같은
명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
```
## 1-1. VM 이 둘 다 살아 있나
**확인** — 터미널 A
```bash
virsh list --all
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
--------------------------
1 kc-lab-1 running
2 kc-lab-2 running
```
**어디를 봐야 하는가** — 둘 다 `running`. 앞의 숫자는 **도메인 ID** 이며
VM 을 껐다 켜면 바뀐다. 이름으로 다룬다.
## 1-2. 노드와 파드 배치
**확인** — 터미널 B
```bash
sudo kubectl get nodes
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
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
```
**어디를 봐야 하는가** — **`NODE` 열.** 이 실험은 배치가 전부다.
**이 결과가 의미하는 것** — `kc-lab-2` 에 **keycloak-0 과 postgres 가 함께**
있다. 그래서 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와
DB 를 동시에 잃는 실험」**이다. 배치가 다르면 결과도 다르다 — 먼저 확인한다.
> `a2-probe` 는 A-2 에서 띄워 두고 안 지운 상주 파드다. 당신 환경에는 없을 수
> 있다. 없어도 이 실험에는 지장이 없다.
## 1-3. ★ 볼륨이 어느 노드에 못박혀 있나
**이 한 줄이 뒤의 결과를 이미 결정한다.** 4a 에서 「새 파드가 왜 영원히
Pending 인가」의 답이 여기 있다.
**확인** — 어떤 PVC 가 있나
```bash
sudo kubectl -n keycloak-lab get pvc
```
**확인** — 그 PVC 뒤의 PV 가 어느 노드를 요구하나. 먼저 **읽는 형태**로 한 번 본다
```bash
sudo kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
sudo kubectl describe pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'
```
**형태**
```
Node Affinity:
Required Terms:
Term 0: kubernetes.io/hostname in [kc-lab-2]
```
값만 필요하면 **뽑는 형태**로 줄인다.
```bash
sudo kubectl get pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
-o jsonpath='{.spec.volumeName}') \
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
=== PVC 가 어느 노드에 묶여 있는가 (재배치 가능성) ===
persistentvolumeclaim/postgres-data → kc-lab-2
```
**어디를 봐야 하는가** — 오른쪽의 노드 이름. **그것이 `kc-lab-2` 라면 4a 에서
postgres 는 갈 곳이 없다.**
**이 결과가 의미하는 것** — `local-path` PVC 는 **그 노드의 로컬 디렉터리**다
(`/var/lib/rancher/k3s/storage/...`). 노드가 죽으면 볼륨도 같이 죽는다.
스케줄러는 그 사실을 `nodeAffinity` 로 알고 있어서, 다른 노드에 파드를
**만들지 않는다.** 결함이 아니라 이 실험대의 **조건**이다.
## 1-4. 밖에서 보이는 상태
**확인** — 터미널 C. 눈으로 한 번 볼 때는 `-I` 로 충분하다
```bash
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
```
**형태**
```
HTTP/2 200
content-type: application/json
```
여러 번 재서 비교할 것이므로, 이제부터는 **코드만** 뽑는다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
```
=== 서비스 정상 확인 ===
https://auth.hyeonworks.com/realms/master HTTP 200
```
**`--max-time` 을 반드시 준다.** 4b 에서 이 값이 없으면 curl 이 몇 분씩
매달린다. 그리고 **타임아웃이 곧 결과**다 — 뒤에서 `000` 이 나오는 이유가
그것이다.
## 1-5. 관측자가 어디 있나 — 미리 알아 둔다
**확인**
```bash
sudo kubectl -n observability get pods -o wide
```
**형태**
```
NAME READY STATUS NODE
grafana-845b5678cf-b6gvc 1/1 Running kc-lab-1
prometheus-6774f94f7c-pzr2t 1/1 Running kc-lab-1
```
**이 결과가 의미하는 것** — 관측 스택이 `kc-lab-1` 에 있다. **4a(`kc-lab-2`
살해)에서는 Prometheus 가 살아남아 관측이 정확하고, 4b 에서는 관측자가 같이
죽는다.** 그 차이를 발견 ⑧ 에서 본다. 지금 알아 두지 않으면 나중에 그래프의
빈 구간을 「값이 0」으로 잘못 읽는다.
---
# 2. 주입 4a — 워커 노드의 전원을 뽑는다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기** — 터미널 A
```bash
virsh start kc-lab-2
```
## 2-1. `destroy` 와 `shutdown` 의 차이
| 명령 | 게스트에 무슨 일이 | 이 실험에 |
|---|---|---|
| `virsh shutdown` | ACPI 종료 신호 → kubelet 이 정상 종료 → 파드가 정리된다 | **쓰면 안 된다** |
| **`virsh destroy`** | **전원 차단.** 신호 없음. 마지막 상태가 그대로 얼어붙는다 | 이것이 「노드 상실」이다 |
`shutdown` 을 쓰면 쿠버네티스가 **정상적인 노드 이탈**로 처리해서
이 실험의 발견 ①·② 가 통째로 안 나온다.
## 2-2. 뽑는다
**하기** — 터미널 A
```bash
date '+%H:%M:%S 차단'
virsh destroy kc-lab-2
```
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
```
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
```
**시각을 반드시 적어 둔다.** 40초·5분 같은 숫자는 **이 시각에서 뺀 값**이다.
기준점이 없으면 뒤의 관찰은 그냥 나열이다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
A-5·A-6 에서는 「규칙을 넣었는데 카운터가 0」이 실패였다. **이 실험의 검증
대상은 다르다.** 여기서 믿을 수 있는 것은 **하이퍼바이저**뿐이고, 쿠버네티스가
뭐라고 하든 그것은 결과이지 검증이 아니다.
## 3-1. VM 이 실제로 꺼졌나 — 이것이 유일한 주입 검증이다
**확인** — 터미널 A
```bash
virsh list --all
```
**형태**
```
1 kc-lab-1 running
- kc-lab-2 shut off
```
**어디를 봐야 하는가** — `shut off`. ID 가 `-` 로 바뀐 것도 같은 말이다.
**확인** — 정말 응답이 없나
```bash
ping -c 2 -W 2 192.168.122.12
```
**미검증** — 원 실행에는 이 확인이 없다. `0 received` 가 나오면 꺼진 것이다.
## 3-2. ★ 그런데 쿠버네티스는 아직 `Ready` 라고 말한다
**확인** — 터미널 B
```bash
sudo kubectl get node kc-lab-2
```
**형태**
```
NAME STATUS ROLES AGE VERSION
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
```
**여기서 「주입이 안 걸렸다」고 결론 내리면 틀린다.** 기계는 3-1 에서 확인한
대로 꺼져 있다. 쿠버네티스가 아직 모를 뿐이다.
노드 상태와 사용자 경험을 **나란히** 봐야 이게 보인다. 터미널 B 에서:
```bash
sudo kubectl get node kc-lab-2
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
두 줄을 15초 간격으로 몇 번 친다. 손이 아프면 한 줄로 묶는다. **미검증**
```bash
while true; do
printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
"$(sudo kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
https://auth.hyeonworks.com/realms/master)"
sleep 15
done
```
`Ctrl-C` 로 멈춘다.
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
```
+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
```
**어디를 봐야 하는가** — `+30초` 줄과 `+45초` 줄 사이. **노드 상태가 그때
넘어간다.**
**이 결과가 의미하는 것** — kube-controller-manager 는 kubelet 의 하트비트가
`node-monitor-grace-period`(이 실험대에서 **40초**) 동안 없어야 `NotReady` 로
바꾼다. 그 40초 동안 **쿠버네티스는 거짓말을 한다.** 그리고 사용자는 그
40초에도 이미 장애를 겪고 있다 — `000` 이 그 증거다.
> **노드 상태를 알림 근거로 삼으면 항상 늦는다.** 사용자가 먼저 안다.
## 3-3. 왜 처음 40초는 `503` 이 아니라 `000` 인가
```
000 curl 이 응답 자체를 못 받았다 = 타임아웃 또는 연결 실패
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
```
호스트 nginx 의 upstream 에는 **두 노드가 다 들어 있다**
([`03-nginx`](../03-nginx/) 1절).
```
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
```
죽은 쪽으로 배분된 요청은 **응답도 거절도 못 받고** `--max-time 8` 에 걸린다.
> **★ 여기는 이 실험이 답을 못 남긴 자리다.** 증거 파일
> [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
> 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」
> 인데 **그 아래가 비어 있다.** 명령이 아무것도 찍지 못했다.
> **당신은 지금 직접 볼 수 있다** — 터미널 C 에서. **미검증**
> ```bash
> sudo tail -f /var/log/nginx/error.log
> ```
> `upstream timed out` 이 `192.168.122.12` 에 대해 찍히면 그것이 답이다.
> nginx 에러 로그는 2048바이트에서 잘리므로, 잘려 보이면 access 로그를 본다.
---
# 4. 효과를 관찰한다 (4a)
## 4-1. ★ 죽은 파드가 산 파드보다 건강해 보인다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
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>
```
**어디를 봐야 하는가** — `keycloak-0` 은 **꺼진 기계 위에서 `ready=true`**,
`keycloak-1` 은 **살아 있는데 `ready=false`.**
**이 결과가 의미하는 것**
| 파드 | 왜 |
|---|---|
| `keycloak-0` | kubelet 이 없어 **상태를 갱신할 수 없다.** 마지막으로 보고한 값이 얼어 있다 |
| `keycloak-1` | 살아서 **정직하게 보고한다** — DB 가 없으니 readiness 실패 |
> **파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이다.**
> 노드가 죽으면 그 노드 파드의 상태는 **화석**이 된다.
이유를 이벤트로 확인한다.
```bash
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
```
**실측** — 같은 파일
```
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
```
**`keycloak-1` 의 실패가 두 종류다.** 처음에는 프로브 자체가 타임아웃되고
(`context deadline exceeded`), 나중에는 `503` 을 받는다. Keycloak 이 DB 없음을
스스로 판단해 답할 수 있게 된 것이다. **같은 「Unhealthy」라도 층이 다르다.**
**`Age` 를 반드시 같이 본다.** 노드를 뽑은 것은 `3m15s` 전인데 맨 위 줄은
`10m` 짜리다 — **주입보다 앞선 사건**이고, 앞 실험의 잔재다. 이벤트 목록은
시간대가 섞여 있으므로 **`Age` 로 먼저 걸러야** 내가 만든 일을 고를 수 있다.
그리고 주입 이후 `keycloak-0` 에 붙은 이벤트는 `NodeNotReady` **하나뿐**이다.
그것은 컨트롤러가 쓴 것이지 kubelet 이 쓴 것이 아니다. **kubelet 이 없으니
그 파드에 대해 말해 줄 주체가 없다** — 4-1 의 `ready=true` 가 화석인 이유다.
## 4-2. Prometheus 는 정확했다
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 job 과
라벨이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자르려면
(`jq` 는 이 실험대에 없다) **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up' \
| tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
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
```
**어디를 봐야 하는가** — `kc-lab-2` 쪽이 전부 `0`. **`kubelet` job 이 두 줄인
것**도 본다 — 노드마다 하나씩이라 하나는 1, 하나는 0 이다.
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 그 대상을 긁는 데
성공했는가**다. 대상이 사라졌으니 실패했고, 그 0 은 **사실**이다.
**A-2 와 정반대다.** A-2(DB 상실)에서는 `up=1` 인데 서비스가 죽어 있었다.
| | `up` 이 잡는가 |
|---|---|
| **대상이 사라짐** (노드 상실) | **잡는다** |
| **대상이 살아서 못 씀** (DB 상실) | **못 잡는다** |
Grafana 에서 같은 것을 그림으로 본다 —
[`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png).
## 4-3. 쿠버네티스가 노드에 무엇을 붙였나
**확인** — 읽는 형태
```bash
sudo kubectl describe node kc-lab-2 | grep -A3 Taints
```
값만 필요하면
```bash
sudo kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
```
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
```
=== 노드 taint — 쿠버네티스가 붙인 것 ===
node.kubernetes.io/unreachable=:NoSchedule
node.kubernetes.io/unreachable=:NoExecute
```
**어디를 봐야 하는가** — 두 종류가 붙어 있다.
| taint | 뜻 |
|---|---|
| `NoSchedule` | **새 파드를 여기 보내지 마라** |
| `NoExecute` | **이미 있는 파드도 쫓아내라** |
`NoExecute` 가 붙었는데 왜 아무 일도 안 일어나는가 — 다음 절.
## 4-4. 축출은 왜 5분 뒤인가
**확인** — 파드에 붙어 있는 관용을 본다
```bash
sudo kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations
```
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
```
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
```
**어디를 봐야 하는가** — `tolerationSeconds=300`. **당신이 쓴 적 없는 값**이다.
쿠버네티스가 모든 파드에 자동으로 붙인다.
```
기계 정지
│ 40초 node-monitor-grace-period → 노드 NotReady
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
```
## 4-5. 그 5분을 실제로 기다린다
**확인** — 30초 간격으로 본다. 손으로 치기 싫으면 `watch`
```bash
watch -n 30 'sudo kubectl -n keycloak-lab get pods -o wide'
```
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
```
+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
```
**어디를 봐야 하는가** — `+240초` 와 `+270초` 사이. 두 가지가 동시에 일어난다.
- `kc-lab-2` 의 파드들이 **`Terminating`** 으로 바뀐다
- **새 이름의 postgres 파드**(`...-9cmsv`)가 생기고 **`Pending`** 이다
**이 결과가 의미하는 것** — 축출이 시작됐다. 그런데 `Terminating` 이 안 끝나고,
새 파드는 뜨지 못한다. 두 문제는 원인이 다르다 — 4-6 과 4-7.
## 4-6. 새 파드는 왜 영원히 `Pending` 인가
**확인** — 파드에게 직접 물어본다
```bash
sudo kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
sudo kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
```
이름은 매번 다르므로 위 `get` 으로 먼저 확인하고 옮겨 적는다.
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
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.
```
**어디를 봐야 하는가** — **`0/2 nodes are available` 뒤에 이유가 노드 수만큼
나열된다.** 이 줄 하나에 두 노드의 사연이 다 들어 있다.
```
kc-lab-2 → had untolerated taint(s) (죽은 노드)
kc-lab-1 → didn't match PersistentVolume's node affinity
```
**이 결과가 의미하는 것** — **1-3 에서 이미 알고 있던 것이 그대로 벌어졌다.**
볼륨이 `kc-lab-2` 에 못박혀 있어서 살아 있는 노드로 못 간다. 죽은 노드에는
taint 때문에 못 간다. **갈 곳이 없다.**
> 이건 결함이 아니라 **조건**이다. 이 실험대는 그걸 알고 `local-path` 를
> 골랐다. 운영이라면 네트워크 스토리지나 DB 복제가 이 자리를 메워야 한다.
> 노드가 영영 안 돌아오면 남는 길은 **백업 복원(D-1)** 뿐이다.
## 4-7. StatefulSet 은 대체 파드를 만들지 않는다
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak
sudo kubectl -n keycloak-lab get pods | grep keycloak
```
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
keycloak 2 <none> 1
keycloak-0 1/1 Terminating 0 30m
keycloak-1 0/1 Running 0 143m
```
**어디를 봐야 하는가** — `DESIRED=2` 인데 `CURRENT=1`. 그리고 `keycloak-0` 이
**30분째 `Terminating`.**
**이 결과가 의미하는 것**
| | |
|---|---|
| StatefulSet 의 계약 | **같은 이름의 파드는 클러스터에 하나뿐**이어야 한다 |
| 컨트롤 플레인이 아는 것 | 노드가 안 보인다 = **파드가 죽었는지 확신할 수 없다** |
| 그래서 | 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다 |
`Terminating` 이 안 끝나는 사슬은 이렇다.
```
파드 삭제 요청
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
```
**Deployment 였다면 즉시 새 파드를 만든다.** 이름이 아무래도 되기 때문이다
(postgres 가 실제로 그랬다 — 4-5 에서 새 이름의 파드가 생겼다. 다만 갈 곳이
없었을 뿐이다). **StatefulSet 의 「안정된 이름」이라는 이득의 반대편 비용**이
여기다.
> **강제로 진행시키는 명령이 있지만, 이 가이드에서는 치지 않는다.**
> ```
> kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
> ```
> 이것은 **컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것**이다.
> 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 **같은 이름의 파드 둘이
> 동시에 존재**하게 된다 — 그게 split brain 이고, 이 실험대에서는 5절의
> `virsh start` 가 훨씬 안전하고 빠르다.
---
# 5. 복구 (4a)
## 5-1. 전원을 다시 넣는다
**하기** — 터미널 A
```bash
date '+%H:%M:%S 재기동'
virsh start kc-lab-2
```
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
```
재기동 시각: 12:16:31
Domain 'kc-lab-2' started
```
## 5-2. 얼마나 걸리나
**확인** — 30초 간격
```bash
sudo kubectl get nodes
sudo kubectl -n keycloak-lab get pods
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — 같은 파일
```
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
→ 서비스 복귀
```
**60초. 사람 개입 없이 전부 제자리로 돌아왔다.** `Terminating` 이던 파드도,
`Pending` 이던 파드도 kubelet 이 돌아오자 정리됐다.
> **이 60초는 MTTR 이 아니다.** `virsh start` 를 친 **뒤**의 시간이다.
> 실제 장애 구간은 **12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분**이고,
> 그 대부분은 사람이 관찰하고 결정하는 데 쓴 시간이다. **현실의 MTTR 도
> 대개 그렇다.**
>
> 그리고 본문의 `40초`와 `5분`은 **쿠버네티스 기본값을 인용한 것**이며,
> 관측된 전이 시점(+45초, +270초)이 그 값과 모순되지 않는다는 것까지가
> 이 실험이 말할 수 있는 범위다. 값 자체를 측정한 것은 아니다.
## 5-3. 4b 로 넘어가기 전 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `1/1 Running`, `Pending` 없음 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
```
=== 복구 확인 ===
keycloak-0 1/1 Running 0 68s
keycloak-1 1/1 Running 0 144m
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
```
**`postgres` 의 이름이 바뀌어 있다** (`-2gf27` → `-9cmsv`). 4-5 에서 생겼다가
`Pending` 이던 그 파드가 노드가 살아나자 그대로 뜬 것이다. **`keycloak-0` 은
이름이 그대로** — StatefulSet 이라 그렇다. 두 컨트롤러의 차이가 이름에 남는다.
여기까지 안 돌아왔으면 **4b 로 넘어가지 않는다.** 두 고장이 겹치면 무엇이
원인인지 못 가린다.
---
# 6. 4b — 이번엔 컨트롤 플레인을 뽑는다
## 6-1. 먼저 인벤토리 — 그게 곧 영향 범위다
**확인**
```bash
sudo kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
```
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
```
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`. **진입점이 여기 있다.**
**확인** — 진입점이 몇 개인가
```bash
sudo kubectl -n kube-system get deploy traefik
```
**실측** — 같은 파일
```
traefik 1 1
```
**이 결과가 의미하는 것** — **`replicas=1`. 진입점이 단일 장애점이다.**
이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 4a 와 결과가 다른 이유가
여기서 이미 예측된다.
## 6-2. 뽑는다
**되돌리기** — 터미널 A
```bash
virsh start kc-lab-1
```
**하기** — 터미널 A
```bash
date '+%H:%M:%S 차단'
virsh destroy kc-lab-1
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
차단 시각: 12:18:08
Domain 'kc-lab-1' destroyed
```
**터미널 B 가 여기서 죽는다.** SSH 세션이 그대로 끊긴다. 놀랄 일이 아니다.
## 6-3. 주입 검증 — `kubectl` 이 죽은 것이 곧 증거다
**확인** — 터미널 A 나 C 에서
```bash
sudo kubectl get nodes
```
**실측** — 같은 파일
```
kubectl: Unable to connect to the server: dial tcp
```
**어디를 봐야 하는가** — `Unable to connect to the server`. API 서버가
`kc-lab-1:6443` 에 있었으므로 **당연한 결과**다.
**이 결과가 의미하는 것** — 4a 에서는 「쿠버네티스가 뭐라고 하는가」를 물을 수
있었다. **여기서는 물어볼 상대 자체가 없다.** 이 실험의 관찰 도구가 통째로
바뀐다.
## 6-4. 밖에서는 어떻게 보이나
**확인** — 터미널 C. 20초 간격으로 두 주소를 본다
```bash
curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
+20초 외부 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
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
```
**어디를 봐야 하는가** — `+120초` 의 **`grafana=502` 한 줄.** 나머지는 전부
`000` 인데 여기만 다르다.
**이 결과가 의미하는 것** — **`000` 과 `503`/`502` 는 서로 다른 층의 고장을
가리킨다.**
| 코드 | 어디까지 살아 있는가 |
|---|---|
| **`503`** (4a) | nginx·Traefik 은 살아 있고 **뒤에 보낼 파드가 없다** |
| **`502`** (4b, 한 번) | nginx 가 **연결 실패를 제때 판정해** 자기 힘으로 502 를 만들었다 |
| **`000`** (4b, 대부분) | nginx 가 죽은 주소를 기다리다 **우리 `--max-time 8` 이 먼저 끝났다** |
`502` 가 한 번이라도 찍혔다는 것이 **nginx 는 살아 있었다**는 증거다.
같은 고장인데 코드가 흔들리는 이유는 **타임아웃 경주**다.
## 6-5. ★ 그런데 워크로드는 살아 있다
`kubectl` 이 없으니 **노드의 컨테이너 런타임에 직접 묻는다.**
**확인** — 터미널 A 에서 살아남은 노드로
```bash
ssh kc-lab-2 'sudo crictl ps --name keycloak'
```
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
```
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
e5f777900b762 60e153026e8f5 4 minutes ago Running keycloak 0 640d4dafaefb3 keycloak-0 keycloak-lab
```
**어디를 봐야 하는가** — `STATE` 가 `Running`, `ATTEMPT` 가 `0`.
**API 서버가 없는데도 컨테이너는 돌고 있다.**
**이 결과가 의미하는 것**
```
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
산 것: keycloak-0 · postgres · containerd
문제: 들어갈 문(Traefik)이 없다
```
> **컨트롤 플레인 상실 ≠ 워크로드 상실.**
> 이미 떠 있는 것은 계속 돈다. **새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.**
전체 목록도 본다.
```bash
ssh kc-lab-2 'sudo crictl ps'
```
`crictl` 이 소켓을 못 찾으면 k3s 의 것을 직접 준다. **미검증**
```bash
ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'
```
## 6-6. 관측자가 같이 죽으면 0 이 아니라 구멍이 남는다
**지금은 확인할 수 없다.** Prometheus 도 Grafana 도 `kc-lab-1` 에 있었다.
**그것이 이 발견이다.**
```
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
```
복구 뒤에 Grafana 에서 `up` 그래프를 다시 열어 **12:1812:23 구간이 0 이
아니라 빈칸**인 것을 확인한다. 6-8 에서 한다.
## 6-7. 복구
**하기** — 터미널 A
```bash
date '+%H:%M:%S 재기동'
virsh start kc-lab-1
```
**실측** — [`08-control-plane-recovery.txt`](../../evidence/a4-node-loss/08-control-plane-recovery.txt)
```
재기동: 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초)
```
**여기서도 60초.** `+30초` 의 `502` 는 **nginx 가 먼저 살아나고 Traefik 이
아직 안 뜬** 중간 상태다. 4b 내내 보던 `000` 과 층이 다르다.
## 6-8. 복구 후에 확인할 것
**확인**
```bash
sudo kubectl -n keycloak-lab get pods
```
**실측** — 같은 파일
```
keycloak-0 1/1 Running 0 7m57s
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
```
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
- `keycloak-1` 의 `RESTARTS` 가 **1** — `kc-lab-1` 위에 있었으니 당연하다
- `AGE` 가 `151m` 인데 재시작은 방금 — **AGE 는 파드가 만들어진 시각**이지
컨테이너가 시작한 시각이 아니다
- **`(<invalid> ago)`** — 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다.
**원인은 이 실험이 확정하지 않았다.** 잠시 뒤 다시 치면 정상 값으로 바뀐다
**확인** — Grafana. 6-6 에서 예고한 구멍
```
브라우저로 Grafana 를 열어 up{job="keycloak"} 그래프를 12:1512:30 으로 본다
```
**실측** — [`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png)
그림에서 12:1812:23 은 **선이 0 으로 내려간 것이 아니라 아예 끊겨 있다.**
**Grafana 로그인이 풀려 있다.** Grafana 데이터가 `emptyDir` 이라 파드
재시작에 사라진다. Prometheus 는 PVC 라 지표가 남았다 — 다만 관측자가 죽어
있던 구간의 데이터는 애초에 수집되지 않았다. **의도한 설계대로 동작했고,
그 설계의 한계도 함께 드러났다.**
Prometheus 를 `port-forward` 로 보고 있었다면 **다시 연결해야 한다** (실측
기록의 마지막 줄이 그것이다).
## 6-9. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| VM | `virsh list --all` | 둘 다 `running` |
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | 전부 `1/1 Running` |
| 진입점 | `sudo kubectl -n kube-system get deploy traefik` | `1/1` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| 관측 | Prometheus `up` | 전부 1 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
> **이 실험이 재지 않은 것** — 노드가 **영영 안 돌아오는** 경우는 재지 않았다.
> `local-path` PVC 가 그 노드와 함께 사라진 상태에서의 복구는 **D-1(백업·복원)**
> 의 주제다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh` 가 도메인을 못 찾는다 | `qemu:///session` 을 보고 있다 | `virsh uri` — `system` 이어야 한다 |
| VM 을 껐는데 노드가 `Ready` | **정상.** `node-monitor-grace-period` 40초 | `virsh list --all` 로 전원을 먼저 본다 — 3-1 |
| 5분이 지나도 축출이 안 온다 | 40초 + `tolerationSeconds=300` = **5분 40초** | `describe pod \| grep -A4 Tolerations` |
| 새 파드가 계속 `Pending` | **PVC 가 죽은 노드에 못박혀 있다** | `describe pod` 의 `FailedScheduling` — 4-6 |
| `keycloak-0` 이 30분째 `Terminating` | StatefulSet + kubelet 없음. **정상이다** | `get statefulset` 의 `CURRENT` — 4-7 |
| `--force` 로 지우고 싶다 | 노드가 살아 있으면 **중복 실행**이 된다 | 치지 말고 `virsh start` — 4-7 |
| `kubectl` 이 전혀 안 된다 (4b) | **API 서버가 죽은 노드에 있었다.** 정상 | `ssh kc-lab-2 'sudo crictl ps'` — 6-5 |
| `crictl` 이 소켓을 못 찾는다 | k3s 는 자기 containerd 소켓을 쓴다 | `--runtime-endpoint unix:///run/k3s/containerd/containerd.sock` |
| `503` 을 기대했는데 `000` | 층이 다르다. nginx 가 죽은 주소를 기다린다 | `--max-time` 을 늘려 보면 `502` 가 나온다 — 6-4 |
| `curl` 이 몇 분씩 안 끝난다 | `--max-time` 을 안 줬다 | 모든 외부 확인에 `--max-time 8` |
| 그래프의 그 구간이 0 으로 보인다 | **0 이 아니라 데이터 없음이다** | 점 사이가 이어져 있는지 본다 — 6-6 |
| Grafana 로그인이 풀렸다 | 데이터가 `emptyDir` | 재시작마다 그렇다. PVC 로 바꾸면 남는다 |
| Prometheus 가 갑자기 안 보인다 | `port-forward` 가 끊겼다 | 다시 연다 |
| `RESTARTS` 가 `1 (<invalid> ago)` | 재시작 직후에 나온다. **원인 미확정** | 잠시 뒤 다시 친다 — 6-8 |
| 4b 결과가 4a 와 섞인다 | 4a 복구를 확인하지 않고 넘어갔다 | 5-3 확인표를 통과한 뒤 시작 |
---
# 이 실험이 남기는 구성 숙제
관찰만 하고 끝내면 아깝다. **두 가지는 지금 고칠 수 있다.**
| 발견 | 고치는 방향 |
|---|---|
| Traefik `replicas=1` 이라 진입점이 단일 장애점 | `replicas=2` 로 늘리거나 DaemonSet 으로 |
| Grafana 가 `emptyDir` 이라 재시작마다 세션이 사라짐 | PVC 를 붙인다 |
| 관측 스택이 실험 대상 노드에 함께 있음 | 노드가 둘뿐이라 완전히는 못 피한다. **아는 것이 먼저** |
---
# 다음
| 실험 | A-4 가 남긴 질문 |
|---|---|
| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | 여기서는 노드가 **완전히** 사라졌다. **부분 단절은 더 고약하다** |
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | **PVC 가 노드에 묶여 있다.** 노드가 영영 안 돌아오면 백업이 유일한 길 |
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | 여기서는 `up=0` 이 정확했다. **느려짐은 `up` 이 못 잡는다** |
| 전부 | **주입 검증의 기준을 먼저 정한다.** 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다 |
@@ -0,0 +1,919 @@
# A-5 재현 가이드 — 한 방향만 끊어 보고, 왜 안 갈라지는지 직접 본다
해설 문서: [`docs/experiment-a5-asymmetric-partition.md`](../../experiment-a5-asymmetric-partition.md) ·
증거 원문: [`docs/evidence/a5-asymmetric-partition/`](../../evidence/a5-asymmetric-partition/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 규칙을 넣었는데 **0 패킷**인 상태 | `iptables -L -n -v` 의 카운터 |
| kube-router 가 내 규칙을 **아래로 밀어내는** 것 | `FORWARD` 체인의 줄 번호 |
| JGroups 연결 방향이 **A-1 때와 반대**인 것 | `conntrack -L` |
| 단방향 차단이 **스스로 낫는** 것 | 연결이 뒤집혀 재연결 |
| `coord = t` 가 둘인 split brain | PostgreSQL `JGROUPS_PING` |
| 그런데 **한쪽만 DOWN 이고 외부는 200** 인 것 | `health/ready` · `endpointslice` |
| `MergeView` 로 50초 만에 합쳐지는 것 | Keycloak 로그 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`A-1`](a1-jgroups-transport-block.md) 을 먼저 해 두면 훨씬 이해가 빠르다.
**이 실험은 A-1 이 실패한 자리에서 시작한다.**
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. **iptables 는 두 노드에 각각
넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.**
- 터미널 **두 개**를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용.
## 주의 — 이건 상태를 부수는 실험이다
Keycloak 클러스터를 실제로 분단시킨다. **실험대에서만 한다.**
전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다.
중간에 그만두려면 두 줄이면 된다.
```bash
sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'
```
> **`-F FORWARD` 는 그 체인 전체를 비운다.** 이 실험대의 `FORWARD` 정책은
> `ACCEPT` 이고 실제 규칙은 kube-router·kube-proxy 가 **자기 체인에** 두므로
> 잠시 뒤 스스로 복구된다. 그래도 지우기 전에 **`sudo iptables -S FORWARD` 로
> 무엇이 있었는지 한 번 보고** 지운다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 12:2812:46 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·포트 번호는 **당신 환경에서 다르다.** 이 문서는 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
전부 위 실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
A-1 이 열어 둔 질문이 하나 있었다.
> *「`keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고,
> `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. **양쪽이 동시에 DOWN 이 되는
> 경로가 있다면 전면 장애다.**」*
그 경로를 찾는 것이 이 실험이다. 그리고 A-1 은 도구도 하나 남겼다.
| | A-1 이 배운 것 |
|---|---|
| NetworkPolicy | **기존 연결을 못 끊는다.** conntrack 의 `ESTABLISHED` 가 먼저 통과시킨다 |
| 그래서 | 이번엔 iptables 로 직접 간다 |
**그런데 iptables 에도 벽이 세 개 있었다.** 이 가이드의 절반은 그 세 번의
실패를 **일부러 다시 밟는 것**이다. 셋 다 화면에는 **「아무 일도 없었다」**로
보이기 때문에, 겪어 보지 않으면 다음에도 똑같이 속는다.
```
실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다
성공 수신측 노드의 raw PREROUTING → 19 패킷
그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다
```
---
# 1. 기준선 — 아무것도 넣기 전에
**시험군만 재는 측정은 측정이 아니다.** 특히 이 실험은 **주입이 걸리기 전과
후가 화면상 똑같이 보이므로**, 기준선이 없으면 실패를 성공으로 읽는다.
## 1-1. 파드 IP 와 노드 — 이 값이 곧 규칙의 인자다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
```
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
```
**어디를 봐야 하는가**
- **`NODE` 와 파드 번호가 어긋난다** — `keycloak-0``kc-lab-2` 에 있다.
iptables 를 **어느 노드에** 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데
패킷은 안 걸린다
- **IP 가 A-1 때와 다르다** (`10.42.1.43``10.42.1.77`). 파드가 재시작되면
바뀐다. 여기 적힌 값을 그대로 쓰지 말고 **지금 뽑는다**
변수로 잡아 둔다. **파드가 재시작되면 다시 잡는다.**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "K0=$K0 K1=$K1"
```
> `keycloak-1` 의 `RESTARTS` 가 **1** 인 것도 보인다. A-4 에서 노드를 껐다
> 켠 흔적이다. **직전 실험의 잔재가 남아 있는지 확인하는 자리**이기도 하다.
## 1-2. 클러스터가 지금 하나인가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**형태**
```
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | f
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
```
**어디를 봐야 하는가****`coord` 열에 `t` 가 정확히 하나.**
둘이면 이미 갈라져 있는 것이고, 그 상태에서 주입해 봐야 아무것도 판정 못 한다.
**확인** — 로그가 말하는 뷰
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**형태**
```
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]
```
**뷰 ID(`|13`)를 적어 둔다.** 이 실험의 판정 기준이 이 숫자의 변화다.
## 1-3. 지표 — 그리고 이 자리에서 원 실행이 넘어졌다
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 밖에서 Prometheus 에
묻는 것이 가장 짧다.
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
**형태** — 한 줄 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다
```json
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-1","node":"kc-lab-1"},"value":[1757037600.123,"2"]},
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-0","node":"kc-lab-2"},"value":[1757037600.123,"2"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다 (`jq` 는 이 실험대에 없다). **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
> **★ 원 실행의 기준선은 남지 않았다.** 값을 뽑으려고 붙인 파이썬 한 줄이
> 죽었기 때문이다. — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
> ```
> 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]}")
> json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
> ```
> **입력이 비어 있었다.** 그런데 파서가 죽으면서 **원본도 같이 사라졌다** —
> 화면에 남은 것은 파이썬 스택트레이스뿐이고, Prometheus 가 무엇을 돌려줬는지는
> 아무도 모른다. **원본을 먼저 보고 나중에 자르면** 이런 일이 없다.
> 이 가이드가 `wget` 원문을 먼저 보여 주는 이유다.
## 1-4. ★ 연결 방향 — 이 실험에서 가장 중요한 기준선
**어느 쪽이 클라이언트이고 어느 쪽이 서버인가.** 이걸 모르면 규칙을 엉뚱한
노드에 넣게 된다.
**확인** — 두 노드 모두에서 본다
```bash
sudo conntrack -L 2>/dev/null | grep 7800
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
```
**실측** — 해설 문서 1절 (실패 ② 에서 인용된 원 실행의 연결)
```
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
──────────── ────────────────────
keycloak-0 가 클라이언트 keycloak-1 이 서버
```
**어디를 봐야 하는가**`dport=7800` 인 쪽이 **서버**다. `src` 가 클라이언트.
**이 결과가 의미하는 것****A-1 때와 방향이 반대다.** A-1 에서는
`10.42.0.35:40023 → 10.42.1.43:7800`, 즉 `keycloak-1` 이 걸었다. 지금은
`keycloak-0` 이 건다.
> **JGroups 의 TCP 연결 방향은 고정이 아니다.** 먼저 뜬 쪽, 먼저 JOIN 을 건
> 쪽에 따라 달라진다. 파드가 재시작될 때마다 바뀔 수 있다.
> **가정하지 말고 매번 `conntrack -L` 로 본다.**
`2>/dev/null``conntrack` 이 stderr 로 찍는 「N flow entries have been shown」
요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.
## 1-5. 밖에서 보이는 상태
**확인**
```bash
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
```
여러 번 재서 비교할 것이므로 이제부터는 코드만 뽑는다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
`200` 이어야 한다.
---
# 2. 주입 시도 ① — `filter` 테이블 최상단 (실패한다)
**일부러 실패하는 단계다.** 건너뛰지 않는 편이 좋다. 이 실패의 모양을 봐 둬야
다음에 자기 규칙을 의심할 수 있다.
**되돌리기** — 먼저 읽어 둔다
```bash
ssh kc-lab-2 'sudo iptables -F FORWARD'
```
## 2-1. 넣는다
A-1 의 NetworkPolicy 는 conntrack 에 막혔다. **`FORWARD` 최상단에 넣으면
conntrack 승인보다 먼저 평가될 것**이라는 게 이 시도의 가설이다.
**하기**`keycloak-0`(수신측이라고 **가정한** 쪽) 이 있는 노드에
```bash
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
```
> **57800 도 같이 막는다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을
> 쓴다. 7800 만 막으면 **장애 감지는 계속 통해서** 분단이 어정쩡해진다.
**확인** — 방금 넣은 것이 실제로 1번인가
```bash
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
```
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
```
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
```
**넣은 직후에는 맞게 보인다.** 여기서 만족하고 넘어가면 속는다.
## 2-2. 잠시 뒤 다시 본다 — ★ 밀려나 있다
**확인** — 1~2분 뒤 같은 명령을 다시
```bash
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
```
**실측** — 해설 문서 1절 (실패 ①)
```
num pkts bytes target
1 232 377K KUBE-ROUTER-FORWARD /* kube-router netpol */ ← 다시 1번이 되었다
2 0 0 DROP tcp dpt:57800
3 0 0 DROP tcp dpt:7800 ← 0 패킷
```
**어디를 봐야 하는가** — 두 가지를 동시에 본다.
| 열 | 무엇을 말하는가 |
|---|---|
| `num` | 내 규칙이 **1번이 아니다.** kube-router 체인이 위로 돌아왔다 |
| **`pkts`** | **0.** 이 규칙에는 패킷이 단 한 개도 도달하지 않았다 |
**이 결과가 의미하는 것** — **kube-router 가 주기적으로 자기 체인을 `FORWARD`
최상단에 다시 삽입한다.** 내가 1번에 넣어도 곧 2번, 3번으로 밀려나고,
kube-router 체인이 패킷을 먼저 처리해 버린다.
> **직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁한다.**
> **넣는 것으로 끝이 아니다. 패킷 카운터로 확인해야 한다.**
> **정직하게** — 이 확인을 담았어야 할 증거 파일
> [`02-injection-verify.txt`](../../evidence/a5-asymmetric-partition/02-injection-verify.txt)
> 는 **원 실험 시점에 0바이트로 저장됐다.** 리다이렉션이 stdout 만 받았는데
> 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 **사후에
> 다시 수집한 것**이며, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다.
> 남아 있는 것은 구조적 사실 하나 — kube-router 체인이 `FORWARD` 1번을
> 차지하고 있다는 것뿐이다.
> **당신은 지금 실제 카운터를 볼 수 있다.** 이 단계를 건너뛰지 않는 이유다.
## 2-3. 그래서 아무 일도 안 일어난다
**확인** — 25초 간격으로 몇 번 본다
```bash
sudo kubectl -n keycloak-lab get pods | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
```
+25초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+50초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
...
+200초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
```
**★ 여기서 「비대칭 차단은 클러스터를 안 가른다」고 결론 내리면 틀린다.**
결론이 우연히 맞더라도 **근거가 없다.** 규칙에 패킷이 0 개 왔으니
**이 관찰은 아무것도 측정하지 않았다.**
## 2-4. 치운다
**하기**
```bash
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 57800 -j DROP"
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'
```
`-D` 는 **넣을 때와 똑같은 인자**를 줘야 지워진다. 안 지워지면 줄 번호로:
`sudo iptables -D FORWARD 3`.
---
# 3. 주입 시도 ② — `raw` 테이블로 옮긴다 (그래도 0 패킷)
## 3-1. 개념 — netfilter 처리 순서
```
패킷 도착
├─▶ raw PREROUTING ← conntrack 보다 먼저. NOTRACK·DROP 용
├─▶ conntrack 조회/생성 ← 여기서 ESTABLISHED 가 결정된다
├─▶ mangle PREROUTING
├─▶ nat PREROUTING
├─▶ filter FORWARD ← NetworkPolicy·kube-router 가 여기 있다
└─▶ 목적지 파드
```
| 어디에 넣는가 | 기존 연결을 끊는가 | CNI 와 경쟁하는가 |
|---|---|---|
| NetworkPolicy (filter) | **못 끊는다** — conntrack 이 먼저 통과시킨다 (A-1) | 없음 |
| filter FORWARD 직접 | 순서에 따라 | **경쟁한다** (kube-router 가 밀어낸다) — 2절 |
| **raw PREROUTING** | **끊는다** | **없다** — CNI 가 안 쓰는 테이블 |
**진짜 네트워크 분단을 흉내내려면 `raw` 테이블이 맞다.**
## 3-2. 넣는다 — 아직 같은 노드, 같은 목적지
**되돌리기**
```bash
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**하기**
```bash
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
```
**확인** — 카운터
```bash
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
**실측** — [`03-raw-table-injection.txt`](../../evidence/a5-asymmetric-partition/03-raw-table-injection.txt)
```
=== [검증] 이번엔 패킷이 걸렸는가 ===
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
```
**어디를 봐야 하는가****`pkts` 가 여전히 0.** 이번에는 CNI 와 경쟁하지도
않는데 0 이다.
## 3-3. 왜 0 인가 — 1-4 를 다시 본다
**확인**
```bash
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
```
1-4 에서 본 것이 답이다.
```
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
└── keycloak-0 ──┘ └── keycloak-1 ──┘
(클라이언트) (서버, 7800 을 듣는 쪽)
```
**이 결과가 의미하는 것**`10.42.1.77`(keycloak-0)은 이 연결의 **출발지**다.
`-d 10.42.1.77 --dport 7800` 은 **존재하지 않는 패킷**을 노린 규칙이었다.
7800 으로 **들어가는** 패킷은 `10.42.0.42`(keycloak-1) 쪽으로 간다.
```
내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다)
실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다)
```
**규칙을 넣은 노드도 틀렸다.** 목적지 파드가 있는 노드에서 잡아야 한다.
## 3-4. 치운다
```bash
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**지우기 전에 `-L` 로 무엇이 있는지 본다.** `-F` 는 체인 전체를 비운다.
---
# 4. 주입 성공 — 수신측 노드의 `raw PREROUTING`
## 4-1. 넣는다
**되돌리기**
```bash
sudo iptables -t raw -F PREROUTING
```
**하기** — 이번에는 **`kc-lab-1`(keycloak-1 이 있는 노드)** 에, `keycloak-1`
IP 를 목적지로
```bash
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
date '+%H:%M:%S 주입'
```
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
```
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
주입: 12:33:58
```
## 4-2. 이번엔 걸리는가 — 카운터가 유일한 판정 기준이다
**확인**
```bash
sudo iptables -t raw -L PREROUTING -n -v
```
**실측** — 같은 파일
```
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
```
**어디를 봐야 하는가****7800 규칙의 `pkts` 가 19.** 드디어 걸린다.
**57800 은 아직 0 인 것도 정보다.** FD_SOCK2 는 이미 붙어 있는 연결을 쓰고
있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다 —
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
```
=== 차단 규칙 누적 카운터 ===
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
```
> **카운터 판정표**
>
> | `pkts` | 뜻 | 할 일 |
> |---|---|---|
> | `0` | **아무것도 측정하지 않았다** | 해석 금지. 방향과 테이블을 다시 본다 |
> | 조금씩 는다 | 재연결 시도가 막히고 있다 | 관찰로 넘어간다 |
> | 폭증한다 | 대상이 너무 넓다 | `-d`·`--dport` 를 좁힌다 |
---
# 5. 효과를 관찰한다 — 단방향은 클러스터를 못 가른다
## 5-1. 파드와 외부
**확인** — 25초 간격
```bash
sudo kubectl -n keycloak-lab get pods | grep keycloak
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
```
+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
```
**어디를 봐야 하는가**`+75초``keycloak-1` 이 **한 번 `0/1`
흔들렸다가 `+100초` 에 돌아온다.**
**이 결과가 의미하는 것** — 주입이 **닿기는 했다**(2절의 아무 일 없음과 다르다).
그런데 **스스로 나았다.**
## 5-2. 뷰가 변했나 — 그리고 로그 시각의 함정
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094
```
**여기서 시각을 비교하려다 대부분 한 번은 틀린다.**
```
당신 셸의 date 12:33:58 KST
컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다
```
**Keycloak 컨테이너는 UTC 로 찍는다.** KST 는 UTC+9 이므로 **9시간을 빼서**
맞춰 본다. 이걸 모르면 「주입 전 로그」와 「주입 후 로그」를 정반대로 가른다.
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
```
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)],
```
**어디를 봐야 하는가** — 뷰 `13`, 멤버 `(2)`, 그리고 **`MergeView`**.
**이 결과가 의미하는 것 — ★ 여기가 이 실험의 가장 미묘한 자리다.**
해설 문서는 처음에 「주입 이후 뷰 변화가 하나도 없었다」고 썼다. 맞는 말이다.
그런데 그 **「주입 전부터 그대로」의 「전」이 9초였다.**
```
03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다
03:33:58 내 주입 ← 9초 뒤
```
앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였던 것이다.
> **로그 한 줄만 보고 「변화 없음」이라고 말하면 안 된다.**
> **그 줄이 언제 생겼는지**를 함께 본다. `grep` 에 시각이 같이 나오는 형태를
> 쓰는 이유가 이것이다.
>
> 결론 자체(주입 이후 뷰가 변하지 않았다)는 유지된다. 다만 **기준선이 9초짜리
> 였다**는 사실은 함께 적어야 정직하다.
## 5-3. ★ 왜 안 갈라졌나 — 연결이 뒤집혔다
**확인** — 1-4 와 **똑같은 명령**을 다시 친다. 그게 대조의 방법이다
```bash
sudo conntrack -L 2>/dev/null | grep 7800
```
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
```
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
```
**어디를 봐야 하는가**`src``dst` 를 1-4 와 나란히 놓는다.
```
차단 전: src=10.42.1.77 → dst=10.42.0.42:7800 ← 내가 막은 방향
차단 후: src=10.42.0.42 → dst=10.42.1.77:7800 ← 열린 방향으로 다시 붙었다
```
**이 결과가 의미하는 것** — **JGroups 는 막힌 연결이 죽자 반대 방향으로 새로
연결했다.** 그리고 FD_SOCK2 가 상대를 의심하기 전에 복구가 끝났다.
**확인** — 의심 카운터로 뒷받침한다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
```
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
```
keycloak-0 merge_events=1.0 suspected=0.0
keycloak-1 merge_events=1.0 suspected=0.0
```
```
keycloak-0 cluster_size=2.0
keycloak-1 cluster_size=2.0
```
**`suspected = 0`.** 아무도 상대를 의심하지 않았다 — **끊긴 적이 없는 것과
같다.** (`merge_events = 1` 은 5-2 의 9초 전 병합의 것이다.)
> **한 방향만 막는 것으로는 JGroups 를 가를 수 없다.**
> 두 노드는 서로에게 연결을 걸 수 있으므로, **한쪽 길이 막히면 다른 길로 간다.**
>
> 운영적으로는 좋은 소식이다 — **단방향 방화벽 오설정은 자가 치유된다.**
> 반대로 **분단을 재현하려는 실험자에게는 함정**이다.
---
# 6. 양방향 차단 — 갈라지지만 전면 장애는 아니다
## 6-1. 반대 노드에도 넣는다
**되돌리기** — 두 줄이다. 이제 양쪽에 있다
```bash
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**하기**`kc-lab-1` 의 규칙은 그대로 두고, `kc-lab-2` 에 반대 방향을 추가
```bash
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 주입'
```
**확인** — 양쪽 카운터를 다 본다. **한쪽만 걸리면 그건 여전히 단방향이다**
```bash
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
**실측** — [`07-bidirectional-block.txt`](../../evidence/a5-asymmetric-partition/07-bidirectional-block.txt)
```
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
주입: 12:40:25
```
## 6-2. 이번에는 갈라진다
**확인** — 25초 간격
```bash
sudo kubectl -n keycloak-lab get pods | grep keycloak
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
```
**실측** — 같은 파일
```
+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
...
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
```
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
- `keycloak-1`**`0/1` 로 내려가서 안 돌아온다** (5-1 과 다르다)
- ready 주소가 **둘에서 하나로** 줄었다
- **외부는 계속 `200`**
> `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜬다.
> `endpointslice` 를 본다.
**확인** — 뷰
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**실측** — 같은 파일
```
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)]
```
**뷰 ID 는 둘 다 14 인데 멤버는 각자 1 명이다.** 같은 번호의 다른 세계다.
## 6-3. split brain 을 DB 한 줄로 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
```
name | ip | coord
------------------+-----------------+-------
keycloak-0-24309 | 10.42.1.77:7800 | t
keycloak-1-45480 | 10.42.0.42:7800 | t
(2 rows)
```
**어디를 봐야 하는가****`coord = t` 가 둘.** 1-2 에서 하나였던 것과 대조한다.
**분단을 확인하는 가장 짧은 명령이 이것이다.** 로그를 두 번 긁는 것보다 빠르고,
지표보다 정확하다.
## 6-4. ★ 그런데 한쪽만 DOWN 이다
Keycloak 컨테이너에 `curl` 이 없으므로 **임시 파드에서 묻는다.**
**하기** — 상주 파드를 띄운다
```bash
sudo kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 1800
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found
```
> **왜 `--rm -it` 짜리 일회용 파드를 안 쓰나.** 원 실행이 그렇게 했다가
> 붙지 못했다. — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
> ```
> warning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state
> ```
> 파드가 만들어지고 **명령이 끝나 버리기 전에** 붙어야 하는 경주가 된다.
> 관찰을 여러 번 반복할 것이라면 **상주 파드가 항상 낫다.**
**확인** — 양쪽 헬스체크
```bash
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"
```
**실측** — 같은 파일
```
--- 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"}
```
**어디를 봐야 하는가** — 맨 앞의 `"status"`. **`keycloak-0` 은 UP, `keycloak-1`
은 DOWN.** 그리고 `keycloak-1` 쪽에서 **DB 체크는 UP** 인 것도 본다 —
DB 때문이 아니라 클러스터 때문이다.
**이 결과가 의미하는 것 — A-1 의 열린 질문에 대한 답이다.**
| | keycloak-0 | keycloak-1 |
|---|---|---|
| 분단 전 역할 | **코디네이터** (뷰 13 의 발행자) | 일반 멤버 |
| 분단 후 자기 인식 | 「멤버가 하나 나갔다」 — **정상 사건** | 「코디네이터를 잃었다」 — **비정상** |
| 헬스체크 | **UP** | **DOWN** |
| Service 엔드포인트 | **남는다** | 빠진다 |
**Keycloak 의 클러스터 헬스체크는 비대칭이다.** 코디네이터였던 쪽은 자기가
정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 **완전 분단조차 용량 저하로
끝나고 전면 장애가 되지 않는다.**
> **A-2(DB 상실)에서는 양쪽이 동시에 DOWN 이었다.** 차이는 이것이다 —
> **DB 는 모두가 의존하는 하나지만, 클러스터 멤버십은 서로 상대적이다.**
Grafana 에서 같은 것을 그림으로 본다 —
[`a5-cluster-size-bidirectional-block.png`](../../evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png).
---
# 7. 복구
## 7-1. 지운다
**확인** — 지우기 전에 무엇이 있는지 본다
```bash
sudo iptables -t raw -L PREROUTING -n -v --line-numbers
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
```
**하기**
```bash
date '+%H:%M:%S 해제'
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
```
=== 차단 해제 ===
해제: 12:44:37
```
## 7-2. 자동으로 다시 붙는지 본다
**확인** — 25초 간격
```bash
sudo kubectl -n keycloak-lab get pods | grep keycloak
```
**실측** — 같은 파일
```
+25초 keycloak-0:1/1 keycloak-1:0/1
+50초 keycloak-0:1/1 keycloak-1:1/1
→ 복구 완료
```
**50초. 사람 개입 없음.**
## 7-3. 누가 붙였나 — `MergeView`
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -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(
```
**어디를 봐야 하는가** — 뷰 ID 가 **15**, 멤버 `(2)`, **양쪽이 같은 줄.**
```
[keycloak-0-24309|13] (2) ← 정상
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
```
**뷰 ID 는 단조 증가**하므로 「언제 몇 번 갈라졌는지」를 로그만으로 셀 수 있다.
## 7-4. 캐시별 재분배 로그
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6
```
**실측** — 해설 문서 4절 ·
원문은 [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
```
[Context=work] ISPN100007: After merge (or coordinator change) ...
[Context=clientSessions] ISPN100007: After merge ...
[Context=offlineSessions] ISPN100007: After merge ...
[Context=loginFailures] ISPN100007: After merge ...
[Context=actionTokens] ISPN100007: After merge ...
```
증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 형태도 한 번 본다.
```
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
```
**`ISPN100007` 은 병합(또는 코디네이터 변경) 후 캐시별 토폴로지 재계산**이다.
**캐시가 여럿이므로 로그도 캐시 수만큼 나온다.** 한 줄만 보고 「한 번
재분배됐다」고 세면 틀린다 — `work`·`clientSessions`·`offlineSessions`·
`loginFailures`·`actionTokens` 가 각각 찍는다.
## 7-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| raw 규칙 | `sudo iptables -t raw -S PREROUTING` | `-P PREROUTING ACCEPT` 만 |
| filter 규칙 | `sudo iptables -S FORWARD \| head -5` | 내가 넣은 `DROP` 이 없음 |
| (반대 노드) | `ssh kc-lab-2 'sudo iptables -t raw -S PREROUTING'` | 같음 |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | `keycloak` 둘 다 `1/1 Running` |
| 디스커버리 | 6-3 의 psql | **`coord = t` 가 하나** |
| 뷰 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 |
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a5-probe` | 지웠으면 `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
conntrack 은 건드리지 않아도 된다. **차단이 풀리면 새 연결이 스스로 성립한다.**
> **이 실험이 재지 않은 것** — 분단 중에 **세션이 어떻게 되는지**는 재지 않았다
> (그건 A-1 4-5 의 주제다). 여기서는 **누가 살아남는가**만 봤다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 규칙을 넣었는데 아무 일도 없다 | **카운터가 0 이면 아무것도 측정 안 된 것** | `iptables -L -n -v``pkts` — 4-2 |
| 내 규칙이 1번이 아니다 | **kube-router 가 자기 체인을 재삽입한다** | `--line-numbers` 로 순서 — 2-2 |
| `raw` 인데도 0 패킷 | **연결 방향을 잘못 짚었다** | `conntrack -L \| grep 7800` — 3-3 |
| conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | **두 노드 모두에서** 본다 — 1-4 |
| 단방향인데 안 갈라진다 | **정상이다. 열린 방향으로 재연결한다** | `conntrack``src`/`dst` 뒤집힘 — 5-3 |
| 로그에 변화가 없어 보인다 | **컨테이너 로그는 UTC.** KST 와 9시간 차 | `logs` 의 시각에서 9를 뺀다 — 5-2 |
| 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 **언제** 생겼는지 본다 — 5-2 |
| `-D` 로 규칙이 안 지워진다 | 넣을 때와 인자가 다르다 | 줄 번호로 지운다: `-D FORWARD 3` |
| 임시 파드에 attach 실패 | `--rm -it` 는 경주가 된다 | **상주 파드**를 쓴다 — 6-4 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **이미지에 curl 도 wget 도 없다** | 탐침 파드나 Prometheus |
| 지표를 파이썬으로 자르다 죽었다 | **원본까지 같이 사라진다** | `wget` 원문을 먼저 본다 — 1-3 |
| `get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 — 4-2 |
| 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | **두 노드 모두** `-t raw -S PREROUTING` |
---
# 실험자를 위한 한 장 요약
| 상황 | 확인 방법 |
|---|---|
| 규칙을 넣었는데 안 걸린다 | `iptables -L -n -v`**패킷 카운터** |
| 방향을 모르겠다 | `sudo conntrack -L 2>/dev/null \| grep 7800``dport` 쪽이 서버 |
| CNI 가 밀어낸다 | **`raw` 테이블**을 쓴다 |
| 갈라졌는지 알고 싶다 | `JGROUPS_PING.coord`, `ISPN000094`, `vendor_cluster_size` |
| 누가 살아남을지 알고 싶다 | **분단 전 코디네이터가 누구였는가** |
---
# 다음
| 실험 | A-5 가 남긴 질문 |
|---|---|
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | **`tc netem` 도 똑같은 함정.** 인터페이스를 잘못 고르면 카운터가 0 이다 |
| [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 **세션이 갈라진다** |
| [A-1](a1-jgroups-transport-block.md) 로 되돌아가서 | 분단 중 **로그아웃이 전파되지 않는다.** 그 상태를 여기서 다시 만들 수 있다 |
| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |
@@ -0,0 +1,926 @@
# A-6 재현 가이드 — 끊지 않고 200ms 만 넣어 22초를 만든다
해설 문서: [`docs/experiment-a6-latency-injection.md`](../../experiment-a6-latency-injection.md) ·
증거 원문: [`docs/evidence/a6-latency-injection/`](../../evidence/a6-latency-injection/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| `eth0` 이라는 인터페이스가 **없다**는 것 | `ip -brief link` |
| 스크립트가 **「적용완료」를 찍었는데 아무것도 안 걸린 것** | `tc -s qdisc` 카운터 |
| `enp1s0` 에서는 **파드 IP 가 안 보이는** 것 | VXLAN 캡슐화 |
| 200ms 가 **1,872ms** 가 되는 것 | 두 노드 응답 시간 비교 |
| 동시 20건이 **22.2초**까지 계단으로 늘어나는 것 | 상주 탐침이 모은 파일 |
| 커넥션 획득에 **20초**를 기다린 요청 | `agroal_blocking_time_max_milliseconds` |
| **readiness 프로브가 같은 줄에 서서** 타임아웃되는 것 | `kubectl get events` |
| 예측했던 낙관적 락 충돌이 **0건**인 것 | Keycloak 로그 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`A-5`](a5-asymmetric-partition.md) 를 먼저 해 두면 좋다. **「주입을 넣은 것과
걸린 것은 다르다」가 여기서 세 번째로 나온다.**
- `kubectl`**`kc-lab-1` 에서 `sudo`** 로 친다.
- `tc`**`kc-lab-2` 에서** 친다(`ssh kc-lab-2`). postgres 가 그 노드에 있다.
- 터미널 두 개면 편하다. 하나는 부하·측정, 하나는 이벤트 관찰.
## 주의 — 이건 상태를 부수는 실험이다
Keycloak 한 대를 **느려지게** 만든다. 파드가 재시작될 수 있고 readiness 가
빠진다. **실험대에서만 한다.** 전 구간 약 30분이다.
중간에 그만두려면 한 줄이면 된다.
```bash
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
```
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:1013:35 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·인터페이스 이름은 **당신 환경에서 다를 수 있다.** 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
A-2 는 DB 를 **완전히** 세웠고, A-4 는 기계를 **통째로** 껐다. 둘 다 즉시
드러났다. `503` 이 나오고 `up` 이 0 이 됐다.
**실제 장애의 대부분은 그렇지 않다. 느려지기만 한다.** 그리고 느려짐은
사망보다 **진단하기 어렵다 — 헬스체크가 통과하기 때문이다.**
이 실험이 묻는 것은 하나다.
```
200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가?
```
답은 **아니다.** 두 군데에서 곱해진다.
---
# 1. 설계 — 왜 이 배치가 그대로 A/B 실험이 되는가
## 1-1. 무엇이 어느 노드에 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
```
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 을 건넌다 ← 여기에 지연을 건다
```
**어디를 봐야 하는가** — **postgres 와 `keycloak-0` 이 같은 노드**인가.
**이 결과가 의미하는 것**
```
kc-lab-2 kc-lab-1
┌──────────────────┐ ┌──────────────────┐
│ postgres │ │ keycloak-1 │
│ keycloak-0 │ │ │
│ └─ cni0 로 직행 │◀─ VXLAN ──▶│ └─ 오버레이 경유 │
└──────────────────┘ └──────────────────┘
지연 없음 여기만 느려진다
```
**postgres 가 보내는 패킷 중 노드를 건너가는 것만** 지연시키면
`keycloak-1` 의 DB 접근만 느려지고 `keycloak-0` 은 그대로다.
**대조군이 같은 실험 안에 있다.** 파드를 두 개 더 띄울 필요도, 다른 시간대와
비교할 필요도 없다.
> **배치가 다르면 이 실험은 성립하지 않는다.** 두 Keycloak 이 모두 DB 와 다른
> 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다.
> 먼저 확인한다.
변수로 잡아 둔다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
PG=$(sudo kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}')
echo "K0=$K0 K1=$K1 PG=$PG"
```
## 1-2. 상주 탐침 파드를 먼저 띄운다
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 그리고 이 실험은
**같은 요청을 수십 번 반복**해야 하므로 파드를 매번 만들면 안 된다.
**하기**
```bash
sudo kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 1800
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
```
**확인** — 값이 들어갔나. **비밀번호는 길이만 본다**
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"'
```
**형태**
```
K0=10.42.1.77 K1=10.42.0.42 PW=32자
```
`PW=0자` 면 시크릿이 안 넘어간 것이다. 그 상태로 재면 **전부 401 을 재게 된다.**
> **★ `kubectl run --rm -i` 로 부하를 주면 안 된다.**
> 원 실행이 그렇게 했다가 **동시 20건의 출력을 잃었다.** 파드가 만들어지고
> 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나
> 아예 끊긴다. **결과는 파드 안 파일에 모으고 끝나면 한 번에 꺼낸다.**
> 이 가이드의 모든 부하 명령이 그 형태다.
> **탐침의 `K0`/`K1` 은 만들 때 고정된다.** Keycloak 파드가 재시작되면 IP 가
> 바뀌고 탐침의 값은 낡는다. 그때는 탐침을 지우고 다시 만든다.
> 이걸 놓치면 **「아무 데도 안 닿음」을 「지연」으로 착각한다.**
---
# 2. 기준선 — 주입 전에 같은 명령으로 먼저 잰다
## 2-1. 요청 하나를 눈으로 본다
먼저 **읽는 형태**로 한 번 친다. 시간이 어디서 드는지 봐야 나중에 무엇이
변했는지 안다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
curl -s -o /dev/null \
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
```
**형태**
```
connect 0.001 ttfb 0.065 total 0.066
```
**어디를 봐야 하는가**`connect``ttfb` 의 차이.
| 값 | 무엇의 시간인가 |
|---|---|
| `time_connect` | 탐침 → Keycloak **TCP 연결**. 이 실험에서 **거의 안 변한다** |
| `time_starttransfer` | 첫 바이트까지 = **Keycloak 이 DB 와 대화한 시간**. 여기가 폭발한다 |
**이 결과가 의미하는 것** — 지연은 **탐침과 Keycloak 사이**가 아니라
**Keycloak 과 DB 사이**에 넣는다. 그래서 `connect` 는 그대로고 `ttfb` 만 는다.
주입 후에 이 두 값을 다시 보면 **어디에 지연이 걸렸는지 한눈에 판정된다.**
응답이 `401` 이나 `400` 이면 `-o /dev/null` 을 빼고 본문을 본다.
## 2-2. 반복해서 평균을 낸다
**확인** — 20회, 원본을 파일에 모은다
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
rm -f /tmp/base-k1 ; i=0
while [ $i -lt 20 ]; do
curl -s -o /dev/null -w "%{time_total}\n" \
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
>> /tmp/base-k1
i=$((i+1))
done'
```
**원본을 먼저 본다.** 평균만 보면 한 건이 튄 것을 놓친다.
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1
```
그 다음 줄여서 본다.
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 \
| awk '{s+=$1} END {printf "%d회 평균 %.0f ms\n", NR, s*1000/NR}'
```
`$K1``$K0` 로 바꿔 **대조군도 똑같이** 잰다.
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
```
=== 기준선 지연 — 각 노드에서 로그인 20회 ===
keycloak-0 평균 70 ms
keycloak-1 평균 66 ms
```
**어디를 봐야 하는가** — 두 값이 **비슷한 것**. 지금 `keycloak-1` 이 오히려
4ms 빠르다. **VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이**이며,
그래서 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다.
> **횟수를 주입 전후로 똑같이 맞춘다.** 기준선은 20회로 쟀고, 해설 문서의
> 재현 절차에는 15회로 적혀 있다. 횟수가 다르면 평균도 달라진다.
> **비교할 두 값은 같은 명령으로 만든다.**
## 2-3. 커넥션 풀 지표에 무엇이 있나
**확인**
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
'curl -s "http://$K1:9000/metrics" | grep "^agroal_"'
```
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
```
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
```
**어디를 봐야 하는가**`agroal_*`**JDBC 커넥션 풀** 지표다
(Agroal 은 Quarkus 의 풀 구현이다). 이 실험의 핵심 증거가 여기서 나온다.
> **당신 출력은 이보다 길 것이다.** 위 목록은 알파벳순으로 `destroy_count_total`
> 에서 끊겨 있다 — 원 실행이 앞부분만 남긴 것이다. 실제로는 뒤에
> `agroal_max_used_count` 같은 것이 더 있고, 6-4 에서 그 값을 쓴다.
> **증거 파일이 짧다고 지표가 없는 것이 아니다.**
| 지표 | 무엇을 말하는가 |
|---|---|
| `blocking_time_max` | **커넥션을 받으려고 가장 오래 기다린 시간** |
| `max_used_count` | 풀이 최대 몇 개까지 늘었나 |
| `awaiting_count` | **지금** 줄 서 있는 요청 수 |
| `active_count` | **지금** 쓰이고 있는 커넥션 수 |
**`awaiting_count``active_count` 는 순간값이다.** 부하가 끝나면 0 으로
돌아간다 — **부하 중에 읽어야 보인다.** `blocking_time_max` 는 누적이라
나중에 읽어도 남아 있다.
지금 값을 적어 둔다. 나중에 오른 것을 보려면 지금 값이 필요하다.
---
# 3. 주입 시도 ① — `eth0` (그런 인터페이스가 없다)
**일부러 실패하는 단계다.** 이 실패의 모양이 이 실험이 남긴 가장 실용적인
교훈이다.
## 3-1. 넣어 본다
**하기**
```bash
ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio'
```
**실측** — [`02-delay-injected.txt`](../../evidence/a6-latency-injection/02-delay-injected.txt)
```
Cannot find device "eth0"
```
한 줄이면 끝날 일이다. **그런데 원 실행은 이걸 스크립트로 돌렸다.**
**실측** — 같은 파일, 원문 그대로
```
=== 주입: 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
```
**어디를 봐야 하는가****`적용완료` 가 에러 사이에 끼어 있다.**
**이 결과가 의미하는 것****「적용완료」는 스크립트가 찍은 글자이지 커널이
한 말이 아니다.** `tc` 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로
넘어갔고, 문서에는 시각까지 찍혔다.
> **명령의 성공을 「에러가 안 보인다」로 판정하면 안 된다.**
> 에러는 보였는데 그 사이에 성공 메시지가 있었을 뿐이다.
> 손으로 한 줄씩 치면 이 실수를 할 수 없다 — **이 가이드에 스크립트가 없는
> 이유다.**
그리고 그 상태에서 잰 「검증」이 이랬다.
**실측** — 같은 파일
```
=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
keycloak-0 평균 43 ms 최대 64 ms
keycloak-1 평균 47 ms 최대 70 ms
```
**두 노드가 여전히 같다. 이것이 「안 걸렸다」는 신호였다.** 검증 절이 값을
찍기만 하고 **판정하지 않으면** 이렇게 그냥 지나간다.
## 3-2. 인터페이스 이름을 확인한다
**확인**
```bash
ssh kc-lab-2 'ip -brief link'
```
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
```
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>
```
게스트의 물리 인터페이스는 `enp1s0` 이다.
**어디를 봐야 하는가****`eth0` 이 없다.**
| 이름 | 무엇 |
|---|---|
| `enp1s0` | **게스트의 물리(가상) NIC.** 노드 간 실제 트래픽이 나가는 곳 |
| `flannel.1` | **VXLAN 터널.** 노드를 건너는 파드 트래픽이 여기로 들어간다 |
| `cni0` | **노드 안 브리지.** 같은 노드 파드끼리는 여기서 끝난다 |
Debian 클라우드 이미지는 **예측 가능한 인터페이스 이름**을 쓴다.
```
enp1s0
│ │ └─ s0 : slot 0
│ └──── p1 : PCI bus 1
└────── en : ethernet
```
이름이 **하드웨어 위치에서** 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀐다.
그 대신 `eth0` 이라고 적힌 인터넷의 모든 예제가 안 돈다.
> `flannel.1` 의 상태가 `UNKNOWN` 인 것은 정상이다. 터널 장치는 캐리어 개념이
> 없어서 `UP` 대신 `UNKNOWN` 으로 보고한다. **고장이 아니다.**
---
# 4. 주입 시도 ② — `enp1s0` (파드 IP 가 안 보인다)
`eth0``enp1s0` 으로 고치면 될 것 같다. **안 된다.** 이유가 이 실험의 핵심
개념이다.
## 4-1. 무엇이 문제인가
노드 간 파드 통신은 **flannel VXLAN 으로 캡슐화**된다.
```
원래 패킷: src=10.42.1.76(postgres) dst=10.42.0.42(keycloak-1)
▼ flannel.1 에서 캡슐화
실제 패킷: src=192.168.122.12(노드) dst=192.168.122.11(노드) UDP 8472
└─ 안쪽에 원래 패킷이 통째로 들어 있다
enp1s0 로 나간다
```
**`enp1s0` 에서 `match ip src 10.42.1.76` 은 절대 일치하지 않는다.**
그 IP 는 페이로드 안에 있고, 헤더에는 노드 IP 만 있다.
## 4-2. 눈으로 확인한다
**확인** — 실제로 무엇이 나가는지 본다. **미검증**
```bash
ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472'
```
노드 IP 사이의 UDP 8472 만 보이고 `10.42.x.x` 는 안 보인다.
같은 시간에 터널 쪽을 보면 파드 IP 가 보인다. **미검증**
```bash
ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76'
```
> **원 실행에는 이 확인이 없다.** `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다.
> 그래서 「`enp1s0` 에 걸면 0 패킷」이라는 **출력 원문은 이 실험에 없다** —
> 구조에서 나온 결론이다. 당신이 직접 보고 싶으면 위 `tcpdump` 두 줄이면 된다.
**이 결과가 의미하는 것** — **오버레이 네트워크에서는 「어느 인터페이스에
거는가」가 「무엇을 볼 수 있는가」를 정한다.**
| 인터페이스 | 파드 IP 가 보이나 | 무엇을 지연시키게 되나 |
|---|---|---|
| `cni0` | 보인다 | **같은 노드 안** 통신만 |
| **`flannel.1`** | **보인다 (캡슐화 직전)** | **노드를 건너는** 파드 통신 |
| `enp1s0` | **안 보인다** | 노드 간 **모든** 것 (SSH·k3s 포함) |
`enp1s0``netem` 을 root 로 걸면 **`kubectl` 도 SSH 도 같이 느려진다.**
그러면 무엇이 원인인지 못 가린다.
---
# 5. 주입 성공 — `flannel.1` 에 건다
## 5-1. 거는 순서
**되돌리기** — 먼저 읽어 둔다. 이 한 줄이 세 가지를 다 지운다
```bash
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
```
**하기**
```bash
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio"
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms"
ssh kc-lab-2 "sudo tc filter add dev flannel.1 protocol ip parent 1:0 prio 3 \
u32 match ip src $PG/32 flowid 1:3"
date '+%H:%M:%S 주입'
```
**한 줄씩 친다.** 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다.
## 5-2. 개념 — `tc` 의 계층 구조
```
qdisc (큐 규율) 인터페이스에 붙는 패킷 스케줄러
├─ prio 우선순위 밴드 3개로 나눈다
│ ├─ 1:1 (기본)
│ ├─ 1:2 (기본)
│ └─ 1:3 ← 여기에 netem 을 붙인다
└─ filter 어떤 패킷을 어느 밴드로 보낼지
```
**`netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다.**
`prio` + `filter` 를 쓰면 **고른 트래픽만** 느려진다. 이 실험은
**postgres 가 보내는 것만** 골라야 하므로 세 단계가 필요하다.
세 줄이 하는 일을 나눠 읽으면 이렇다.
| 줄 | 하는 일 |
|---|---|
| `qdisc ... root handle 1: prio` | 밴드 3개짜리 분류기를 만든다 |
| `qdisc ... parent 1:3 handle 30: netem delay 200ms` | 3번 밴드에 **200ms 지연**을 붙인다 |
| `filter ... match ip src $PG/32 flowid 1:3` | **출발지가 postgres 인 패킷**을 3번 밴드로 보낸다 |
## 5-3. ★ 걸렸는지 카운터로 확인한다 — 그리고 0 을 오해하지 않는다
**확인**
```bash
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1'
```
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt) · **넣은 직후**
```
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
```
**`Sent 0 pkt` 이다. 그런데 이건 실패가 아니다.**
A-5 에서 `pkts 0` 은 「규칙이 안 걸렸다」였다. **여기서는 다르다**
아직 **아무 패킷도 지나가지 않았을 뿐**이다. postgres 는 요청이 있어야 답한다.
**하기** — 트래픽을 한 번 만든다
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
curl -s -o /dev/null -w "%{time_total}\n" \
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
```
**확인** — 다시 센다
```bash
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
```
**실측** — 같은 파일
```
=== [검증] 필터에 패킷이 걸리는가 ===
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
```
**어디를 봐야 하는가****`150 pkt`.** 실제로 지연 밴드를 통과했다.
> **판정표 — `netem` 의 `Sent`**
>
> | 상태 | 뜻 | 할 일 |
> |---|---|---|
> | 부하 전 `0 pkt` | 아직 트래픽이 없다 | 요청을 한 번 보내고 다시 센다 |
> | **부하 후에도 `0 pkt`** | **필터가 아무것도 못 잡았다** | IP·인터페이스·방향을 다시 본다 |
> | `pkt` 이 는다 | 걸렸다 | 관찰로 넘어간다 |
> | `dropped` 가 는다 | `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
**A-1·A-5 와 같은 교훈이 세 번째로 나왔다. 주입을 넣은 것과 걸린 것은 다르다.**
필터 자체도 볼 수 있다. **미검증**
```bash
ssh kc-lab-2 'sudo tc filter show dev flannel.1'
```
---
# 6. 효과를 관찰한다
## 6-1. 단일 요청 — 지연은 곱해진다
**확인** — 2-1 과 **똑같은 명령**을 다시 친다
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
curl -s -o /dev/null \
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
```
**`connect` 는 그대로인데 `ttfb` 만 폭발**하는지 본다. 그러면 지연이
**의도한 구간에** 걸린 것이다.
그 다음 2-2 와 똑같이 반복해서 두 노드를 잰다.
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
```
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
keycloak-0 평균 41 ms 최대 57 ms
keycloak-1 평균 1872 ms 최대 1887 ms
```
**어디를 봐야 하는가**`keycloak-1`**66 → 1,872 ms, 28배.**
> **★ 대조군도 변했다.** `keycloak-0` 은 기준선 70ms 에서 41ms 로 **41%
> 빨라졌다.** 주입과 무관한 변동(JIT 워밍업, 캐시)이며, 해설 문서가 처음에
> 「영향 없음」이라고 쓴 것은 **부정확했다.** 자릿수가 달라 결론은 유지되지만,
> **대조군이 안 변한다고 가정하면 안 된다.** 당신 실행에서도 볼 것이다.
## 6-2. 왜 200ms 가 1,872ms 가 되는가
A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답이다.
```
BEGIN
select ... from OFFLINE_USER_SESSION ...
select VERSION ... for no key update skip locked
select ... from OFFLINE_CLIENT_SESSION ...
select VERSION ... for no key update skip locked
insert into OFFLINE_USER_SESSION ...
insert into OFFLINE_CLIENT_SESSION ...
SET LOCAL synchronous_commit TO OFF
COMMIT
```
**왕복이 아홉 번이다.**
```
200 ms × 9 왕복 ≈ 1,800 ms 실측 1,872 ms
```
> **★ `9` 는 SQL 목록을 센 것이고 패킷을 추적한 값이 아니다.** 자릿수가 맞는다는
> 것까지가 이 계산이 말할 수 있는 범위이며, **왕복 수를 확정하려면 `tc -s` 의
> 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다.**
> **네트워크 지연은 왕복 횟수만큼 증폭된다.**
> 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니다.
> **쿼리 수를 줄이는 것이 지연 환경에서 결정적인 이유**가 이것이다.
## 6-3. 동시 부하 — 여기서 진짜 고장이 난다
**여기가 이 실험의 본 시험이다.** 순차로 20번 돌리면 큐잉이 재현되지 않는다.
**동시에** 20건을 보내야 한다.
**하기** — 백그라운드로 띄우고 `wait`. 결과는 파드 안 파일에 모은다
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
rm -f /tmp/load ; i=0
while [ $i -lt 20 ]; do
( curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --max-time 60 \
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
>> /tmp/load ) &
i=$((i+1))
done
wait'
```
**확인** — 다 모였는지부터 센다
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
wc -l /tmp/load.txt
```
**`20` 이 아니면 수집이 샌 것이다.** 그 상태의 숫자는 해석하지 않는다.
**확인** — 원본을 본다
```bash
cat /tmp/load.txt
```
그 다음 상태 코드와 시간을 나눠 본다.
```bash
awk '{print $1}' /tmp/load.txt | sort | uniq -c
awk '{print $2}' /tmp/load.txt | sort -g
```
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
```
=== 동시 부하 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
```
**어디를 봐야 하는가 — 두 가지다.**
**① 순서가 이상하다.** `10.5``3.4` 보다 앞에 있다. 원 실행이 `sort`
**사전순**으로 썼기 때문이다(맨 앞의 `1``uniq -c` 가 붙인 개수다).
문자열로 정렬하면 `"10.5" < "3.4"` 다.
```bash
sort /tmp/load.txt # 사전순 — 10.5 가 3.4 앞에 온다
sort -g /tmp/load.txt # 수치순 — 이걸 써야 한다
```
**시간 값을 정렬할 때는 `sort -g`.** 이걸 놓치면 「최대값」을 잘못 읽는다.
**② 숫자를 순서대로 놓으면 계단이다.**
```
1.9 → 3.4 → 4.8 → 6.2 → 7.7 → 9.1 → 10.5 → ... → 22.2
──── ──── ──── ────
약 1.4초 간격 — 앞 요청이 커넥션을 놓아줄 때까지 줄을 선다
```
**전부 성공(200)했지만 응답 시간이 1.9초에서 22.2초까지 늘어난다.**
**전형적인 큐잉이다.** 커넥션 수는 유한하고, 각 요청이 커넥션을 1.9초씩
붙잡으므로 뒤에 온 요청은 그만큼 기다린다.
> **`200` 만 보는 감시는 이 장애를 못 본다.** 상태 코드는 전부 정상이다.
> **응답 시간 분포를 봐야 한다.**
## 6-4. 커넥션 풀 지표가 증언한다
**부하가 끝나자마자** 읽는다. 늦으면 순간값이 0 으로 돌아간다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
'curl -s "http://$K1:9000/metrics" | grep -E "^agroal_(blocking_time|max_used|acquire|active|available|awaiting)"'
```
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
```
=== 부하 직후 커넥션 풀 ===
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
```
**어디를 봐야 하는가**
| 값 | 읽는 법 |
|---|---|
| `blocking_time_max 20000.0` | **커넥션을 받으려고 20초를 기다린 요청이 있었다** |
| `max_used_count 19.0` | 풀이 19개까지 늘어났다 |
| `blocking_time_average 281.0` | 평균은 0.3초. **평균만 보면 아무 일도 없어 보인다** |
| `active_count 0.0` · `awaiting_count 0.0` | **순간값. 부하가 끝나서 0 이다** |
**평균과 최대의 간격이 이 장애의 모양이다.** 평균 281ms 짜리 그래프에서는
아무도 20초를 보지 못한다.
Grafana 에서 같은 것을 그림으로 본다 —
[`a6-connection-pool-blocking.png`](../../evidence/a6-latency-injection/a6-connection-pool-blocking.png).
## 6-5. 그리고 헬스체크가 무너진다
**확인**
```bash
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
sudo kubectl -n keycloak-lab get pods
```
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
```
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)
```
**어디를 봐야 하는가****`89s` 짜리 줄.** 그것이 지금 주입의 결과다.
`32m`·`52m` 짜리는 **A-4 의 잔재**다(노드를 껐다 켠 흔적).
> **이벤트를 볼 때는 `Age` 를 먼저 본다.** 이벤트 목록은 한 시간 전 것까지
> 섞여 있다. 방금 일어난 일만 골라야 한다.
**두 실패의 차이가 중요하다.**
| 메시지 | 무슨 일 |
|---|---|
| `HTTP probe failed with statuscode: 503` | Keycloak 이 **답은 했다.** 스스로 DOWN 이라고 말했다 |
| **`context deadline exceeded`** | **답 자체를 못 했다.** 프로브가 줄에서 기다리다 끝났다 |
**readiness 프로브 자체가 타임아웃됐다.** 헬스체크도 같은 커넥션 풀 줄에 선다.
## 6-6. 연쇄 고장의 모양
```
DB 가 느려진다
요청이 커넥션을 오래 붙잡는다
커넥션 풀이 고갈된다
새 요청이 줄을 선다 (최대 20초)
헬스체크도 줄에 선다 → 타임아웃 → NotReady
그 노드가 로드밸런서에서 빠진다
★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다
```
**마지막 화살표가 무서운 부분이다. 느려짐은 전파된다.**
A-2(DB 완전 정지)는 즉시 503 으로 드러나 오히려 명확했지만,
**느려짐은 살아 있는 노드를 하나씩 무너뜨린다.**
## 6-7. 빗나간 예측 — 낙관적 락 충돌은 늘지 않았다
계획서에는 이렇게 적혀 있었다.
> **낙관적 락 충돌 증가** — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다
**확인** — 지연 구간의 로그를 센다. **미검증** (원 실행의 정확한 패턴은 기록에 없다)
```bash
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m \
| grep -icE 'optimistic|StaleState|version.*conflict'
```
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
```
=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
관련 로그 줄수: 0
```
**하나도 없었다.** 이유가 명확하다.
```
로그인 → 매번 새 세션 행을 INSERT → 다툴 상대가 없다
refresh → 같은 세션 행을 UPDATE → 여기서 다툰다
```
**충돌은 같은 행을 동시에 고칠 때만 일어난다.** 로그인 부하로는 재현되지
않는다. 이건 **B-3(refresh 토큰 경쟁)의 영역**이며, 거기서 지연을 함께 주면
충돌률이 올라갈 것이다.
> 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔을 것이다.
> **빗나간 예측이 다음 실험의 설계를 정해 준다.**
---
# 7. 복구
## 7-1. 지운다
**하기**
```bash
date '+%H:%M:%S 해제'
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
```
**확인**
```bash
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'
```
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
```
=== 지연 해제 ===
해제완료
qdisc noqueue 0: root refcnt 2
```
**어디를 봐야 하는가****`noqueue`.** `prio``netem` 도 없다.
`root` 를 지우면 그 아래 자식 qdisc 와 filter 가 **같이** 사라진다.
## 7-2. 즉시 회복하는지 본다
**확인****2-2 의 반복 측정 명령을 그대로 다시 친다.** 그 명령의 첫 줄이
`rm -f /tmp/base-k1` 이므로 파일은 새로 만들어진다. 두 노드 다 잰다.
같은 명령이어야 비교가 된다. 다른 명령으로 잰 값은 기준선과 나란히 놓을 수 없다.
**실측** — 같은 파일
```
=== 회복 확인 ===
keycloak-0 평균 43 ms
keycloak-1 평균 51 ms
keycloak-0 1/1 Running 0 61m
keycloak-1 1/1 Running 1 (52m ago) 3h24m
```
**파드 재시작 없이 즉시 회복.** `RESTARTS` 가 안 늘었다 — 이 실험은
readiness 를 흔들었을 뿐 파드를 죽이지는 않았다. **커넥션 풀도 스스로
정상화됐다.**
## 7-3. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| qdisc | `ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'` | `noqueue` |
| (물리 쪽도) | `ssh kc-lab-2 'sudo tc qdisc show dev enp1s0'` | 시도 ① 잔재가 없어야 한다 |
| 응답 시간 | 2-2 의 반복 측정 | 기준선과 같은 자릿수 |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 풀 | `agroal_awaiting_count` · `agroal_active_count` | `0` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a6-probe` | 지웠으면 `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
탐침을 지운다.
```bash
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
```
> `agroal_blocking_time_max_milliseconds` 는 **누적이라 20000 인 채로 남는다.**
> 파드를 재시작해야 0 이 된다. **그대로 두는 편이 낫다** — 「이 노드가 한 번
> 20초를 기다린 적이 있다」는 기록이다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `Cannot find device "eth0"` | **이 게스트의 NIC 는 `enp1s0` 이다** | `ip -brief link` — 3-2 |
| 스크립트가 「적용완료」인데 지연이 없다 | **성공 메시지는 스크립트가 찍은 것** | `tc -s qdisc` 카운터 — 3-1 |
| `enp1s0` 에 걸었는데 안 걸린다 | **VXLAN 안에 파드 IP 가 숨어 있다** | `flannel.1` 에 건다 — 4절 |
| `Sent 0 pkt` | 부하 **전**이면 정상. 부하 **후**면 필터가 틀렸다 | 요청 한 번 보내고 다시 센다 — 5-3 |
| 지연이 양쪽 다 늘었다 | `netem``root` 에 직접 붙였다 | `prio` + `filter` 로 골라 낸다 — 5-2 |
| `kubectl` 이나 SSH 까지 느려졌다 | `enp1s0` 에 걸었다 | `tc qdisc del dev enp1s0 root` |
| 20줄 중 몇 줄만 온다 | **`kubectl run --rm -i` 로 동시 실행하면 stdout 이 샌다** | 상주 파드 + 파일 — 1-2 |
| 최대값이 `9.1` 로 보인다 | `sort` 가 **사전순**이다 | `sort -g` — 6-3 |
| `blocking_time` 이 0 이다 | 부하가 끝나고 한참 뒤에 읽었다 | **부하 직후**에 읽는다 — 6-4 |
| `awaiting_count` 가 늘 0 이다 | **순간값이다** | 부하가 도는 **중에** 읽는다 |
| 로그인이 전부 `401` | `PW` 가 안 넘어갔다 | `exec a6-probe -- sh -c 'echo ${#PW}'` — 1-2 |
| 갑자기 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 다시 만든다 — 1-2 |
| 이벤트가 과거 것과 섞인다 | 이벤트는 한 시간 전 것도 남는다 | `Age` 를 먼저 본다 — 6-5 |
| 대조군도 값이 변했다 | **정상이다.** JIT·캐시 변동 | 자릿수로 판정한다 — 6-1 |
| `dropped` 가 늘어난다 | `netem``limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
---
# 이 실험이 남기는 관측 숙제
**지금 관측 스택에는 지연 분포 지표가 없다.** `agroal_blocking_time_*`
있지만 히스토그램이 아니라 **평균과 최대뿐**이다. 6-4 에서 본 대로
평균 281ms 와 최대 20,000ms 사이에 무엇이 있었는지는 알 수 없다.
```promql
# 있으면 좋았을 것
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))
```
| 알게 된 것 | 함의 |
|---|---|
| 지연은 **왕복 횟수만큼 곱해진다** | DB 지연 대책은 「쿼리 수 줄이기」가 먼저다 |
| 커넥션 풀에서 **한 번 더 곱해진다** | 풀 크기와 타임아웃이 장애 반경을 정한다 |
| **헬스체크도 줄에 선다** | 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다 |
| 느려짐은 **전파된다** | 노드를 빼면 남은 노드가 더 빨리 무너진다 |
| `up` 도 readiness 도 **늦게 반응** | **응답 시간 분포(p95/p99)를 봐야 한다** |
---
# 다음
| 실험 | A-6 이 남긴 질문 |
|---|---|
| [B-3](../../experiment-b3-refresh-token-contention.md) refresh 경쟁 | **지연을 함께 주면 낙관적 락 충돌이 재현될 것** — 여기서는 안 됐다 |
| [B-1](../../experiment-b1-redis-session-store.md) 저장소 지연 | **같은 기법을 Redis 앞에 쓴다.** `flannel.1` · `prio` · `filter` 그대로 |
| [A-4](a4-node-loss.md) 노드 상실 | 거기서는 `up=0` 이 정확했다. **여기서는 `up=1` 인 채로 무너진다** |
| 관측 보완 | **응답 시간 히스토그램**이 없다 |
| 전부 | **주입이 걸렸는지 카운터로 먼저 확인한다.** 세 실험 연속으로 같은 교훈 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,891 @@
# A-7a 재현 가이드 — DB 에게 직접 물어서 원인을 확정하고, 같은 설정에서 세 가지 답을 본다
해설 문서: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md) ·
증거 원문: [`docs/evidence/a7a-volatile-cause/`](../../evidence/a7a-volatile-cause/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 로그인이 SQL 을 **0개** 쏘는 것 | PostgreSQL 문장 로그 |
| refresh 가 쏘는 **딱 한 문장**의 이름 | 같은 로그 — `CLIENT_SCOPE_CLIENT` |
| 그 문장이 **첫 refresh 에만** 나오는 것 | 표식 사이 SQL 0건 |
| A-7 이 지목한 `REVOKED_TOKEN`**한 번도 안 나오는 것** | 같은 로그 |
| 같은 설정에서 **400 · 500 · 200 셋이 다 나오는 것** | 캐시 온도 세 상태 |
| 실패한 SQL 을 Keycloak 로그가 **직접 지목하는 것** | `JDBC exception executing SQL [...]` |
## 전제
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다.
- **[A-7](a7-volatile-comparison.md) 을 먼저 한다.** 이 실험은 A-7 이 남긴
가설을 확정하는 것이고, A-7 의 4-4 에서 본 `500` 이 출발점이다.
- [A-3](a3-database-crash.md) 의 문장 로깅을 해 봤으면 3절이 익숙할 것이다.
같은 기법이다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.
## 주의 — 주입이 세 개다. 복구도 세 개다
1. PostgreSQL **문장 로깅**을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
2. Keycloak 을 **volatile** 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
3. PostgreSQL 을 **여러 번 내렸다 올린다** → 마지막에 올라와 있어야 한다
**실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어
두었다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 그대로 친다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:1811:24 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
> **시각이 UTC 다.** 증거 파일의 `11:18:49` 는 KST 로 20:18 이다. 문서 상단의
> `20:1820:24 KST` 와 같은 시각이며, **PostgreSQL 컨테이너가 UTC 로 로그를
> 찍기 때문**이다. 로그 시각과 `date` 를 비교할 때 이걸 잊으면 9시간을 헤맨다.
UUID·IP·파드 이름은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
A-7 은 이렇게 끝났다.
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는 `REVOKED_TOKEN`
> 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지** 확인하려면 그 테이블을
> 봐야 하고, 그 경로는 캐시되지 않는다.
**그럴듯하다. 그리고 틀렸다.**
```
가설을 세우는 것 → 괜찮다
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
```
「refresh 가 어느 테이블 때문에 실패하는가」는 **추측으로 답할 문제가 아니다.**
Keycloak 소스를 읽는 대신 **DB 가 실제로 받은 문장**을 보면 된다.
그리고 확정해 보니 원인만 틀린 게 아니었다. **A-7 의 표 자체가 조건부였다.**
같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. **한 번 재고 표로 적으면
안 되는 종류의 측정**이었던 것이다.
---
# 1. 기준선 — 켜기 전에 지금 상태를 본다
넓은 것부터 좁혀 간다.
```
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
```
## 1-1. 파드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
```
**어디를 봐야 하는가** — 셋 다 `Running`. **`postgres` 가 있어야 한다** —
이 실험은 그것을 내렸다 올렸다 한다.
## 1-2. 문장 로깅이 꺼져 있나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
none
```
**어디를 봐야 하는가**`none`.
**이 결과가 의미하는 것** — 앞 실험이 켜 둔 채 끝내지 않았다. `all` 이면
**누가 켜 두었는지 모르는 상태**이고, 그대로 진행하면 지금 쌓인 로그가 어느
실험 것인지 구별할 수 없다. 그때는 먼저 끄고, 로그가 한 바퀴 돌 때까지 기다린다.
## 1-3. 지금 args 가 무엇인가
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**형태**
```
["start"]
```
**이 값을 적어 둔다.** 5-3 에서 이대로 되돌린다.
## 1-4. 탐침 파드
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
**이 실험은 Keycloak 을 여러 번 재시작하므로 탐침은 반드시 StatefulSet 밖에
있어야 한다.**
**하기**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
>
> **★ 명령줄에 평문 비밀번호를 쓰지 않는다.** 원래 실험의 재현 절차에는 그대로
> 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도 남는다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.94 PW길이=19
```
## 1-5. 로그가 지금 무엇으로 차 있나 — **이걸 알아야 걸러 낼 수 있다**
**확인** — 로깅을 켜기 전에 한 번 본다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=20
```
**어디를 봐야 하는가** — 조용하다. 여기까지는 에러만 찍힌다.
**이 결과가 의미하는 것** — 로그가 조용한 것이 기준선이다. 다음 절에서 켜면
**JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링**이 로그를 계속 채운다. 그것이
소음이고, 4절에서 `grep -v JGROUPS_PING` 으로 거른다. **소음을 먼저 봐 두면
거르는 이유를 안다.**
---
# 2. 주입 — 두 개를 순서대로 넣는다
## 2-1. 주입 ① PostgreSQL 문장 로깅
### 개념 — 문장 로깅은 무엇인가
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 **모든 SQL** 을 로그에
찍는다. 애플리케이션을 고치지 않고 **「이 요청이 DB 를 어떻게 쓰는지」** 를
밖에서 볼 수 있다.
**왜 여기 나오나.** 「refresh 가 어느 테이블 때문에 실패하는가」를 확정하려면
DB 가 실제로 받은 문장을 봐야 한다. Keycloak 안을 들여다볼 필요가 없다.
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블
이름을 골라 가설로 적게 되고, **그게 틀려도 아무도 모른다.**
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
```
**확인** — 실제로 켜졌나
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
all
```
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. **`alter system`
`postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.**
**확인** — 로그가 실제로 차기 시작했나
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=10
```
**형태**
```
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
```
**어디를 봐야 하는가****`JGROUPS_PING` 이 계속 나온다.** 1-5 에서 예고한 소음이다.
이게 안 보이면 로깅이 안 켜진 것이다.
## 2-2. 주입 ② volatile 전환
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**하기**
```bash
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
"value":["start","--features-disabled=persistent-user-sessions"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**빌드 옵션이라 기동 시 재빌드가 일어나 오래 걸린다.** `--timeout=500s` 를 주는
이유다.
**★ 파드 IP 가 바뀌었다.** 탐침 파드를 다시 띄운다.
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
---
# 3. 주입이 실제로 걸렸는지 확인한다
## 3-1. args 와 동작을 둘 다 본다
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
volatile 전환 확인
args: ["start","--features-disabled=persistent-user-sessions"]
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
```
**어디를 봐야 하는가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 **행이
안 생기는 것**이 volatile 의 증거다.
> 행 수가 0 이 아니면 옛 행이 남아 있는 것이다. A-7 의 2-1 처럼
> `delete from offline_user_session` 을 먼저 하고 다시 잰다.
## 3-2. 문장 로그가 지금 요청을 잡고 있나
**확인** — 방금 로그인 직후에 친다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
```
**어디를 봐야 하는가**`JGROUPS_PING` 말고 다른 것이 섞여 있는지.
**이 결과가 의미하는 것** — 이 시점에서는 **거의 `JGROUPS_PING` 뿐일 것**이다.
그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다.
**구간을 나눠야 볼 수 있다.** 그게 다음 절이다.
---
# 4. 효과를 관찰한다
## 4-1. 표식으로 구간을 나눈다
로그는 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이 로그인이고 어느 것이
refresh 인지 가르려면 **경계를 찍어야 한다.**
**개념**`psql` 로 아무 `select` 나 보내면 **그 문장 자체가 로그에 남는다.**
그러면 리터럴 문자열이 로그 안의 이정표가 된다.
**하기** — 표식 하나를 넣어 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
```
**형태**
```
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
```
**어디를 봐야 하는가**`statement: select 'MARK_TEST'` 가 보이는 것.
안 보이면 로깅이 안 켜졌다(2-1 로 돌아간다).
**이 결과가 의미하는 것** — 이제 **표식과 표식 사이만 잘라 볼 수 있다.**
## 4-2. 로그인이 무슨 SQL 을 쏘는가
**하기** — 표식 → 로그인 → 표식. **세 명령을 붙여서 친다**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_START'"
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
echo "rt $(wc -c < /tmp/rt) bytes"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_END'"
```
**형태**
```
rt 1188 bytes
```
**`1 bytes` 면 파싱이 실패한 것이다.** 그 상태로 4-3 을 하면 빈 토큰을 보내고
엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
**확인** — 표식 사이를 잘라 본다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
11:18:49.461 statement: select 'MARK_LOGIN_START'
11:18:49.743 statement: select 'MARK_LOGIN_END'
↑ 사이에 아무것도 없다
```
**어디를 봐야 하는가****두 줄뿐이다.**
**이 결과가 의미하는 것****로그인은 SQL 을 0개 쏜다.** realm·사용자·클라이언트가
전부 Infinispan 캐시에 있고, volatile 이라 세션 쓰기도 없다. DB 없이 완결된다 —
A-7 이 적은 그대로다.
> `awk '/A/,/B/'` 는 **A 가 나온 줄부터 B 가 나온 줄까지** 출력한다. 로그를 구간으로
> 자를 때 이보다 짧게 쓰는 방법은 없다. 파일로 먼저 받는 것은 같은 로그를 여러
> 구간으로 반복해서 잘라 볼 것이기 때문이다.
## 4-3. ★ refresh 는 딱 한 문장을 쏜다 — 그리고 가설이 지목한 것이 아니다
**하기** — 표식 → refresh → 표식
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_START'"
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_END'"
```
**형태**
```
200
```
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
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'
```
**어디를 봐야 하는가** — 세 가지다.
- **문장이 하나뿐이다.** `BEGIN` / `COMMIT` 사이에 `select` 한 개
- 테이블 이름이 **`CLIENT_SCOPE_CLIENT`** 다
- `parameters` 줄의 **`$2 = 'f'`**
**확인** — 가설이 지목한 테이블이 정말 없는지 직접 센다
```bash
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
```
**이 결과가 의미하는 것****A-7 의 가설은 틀렸다.** 그럴듯했지만 로그가
아니라고 말한다. 그리고 이제 **로그가 지목하는 문장**이 있다.
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
| | 뜻 | `DEFAULT_SCOPE` |
|---|---|---|
| default scope | 항상 붙는다 | `t` |
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
**왜 여기 나오나.** refresh 는 **새 access token 을 만든다.** 그 토큰에 어떤
스코프를 담을지 정하려면 「이 클라이언트가 요청 가능한 optional 스코프가
무엇인가」를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다. **로그인
때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.**
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이 세션 계열
테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
**확인** — 그 UUID 가 어느 클라이언트인지 궁금하면 물어본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, client_id from client where id='131a9912-b578-4b9c-b16a-97518704077e'"
```
**당신 환경에서는 UUID 가 다르다.** 위 로그의 `$1` 값을 그대로 넣는다.
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
## 4-4. 그 조회는 한 번뿐이다 — 여기서 표가 흔들리기 시작한다
**하기** — refresh 를 연속 3회. 사이사이 표식을 넣는다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R1'"
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
echo "rt $(wc -c < /tmp/rt) bytes"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R2'"
```
**★ 매번 `/tmp/rt` 를 다시 채운다.** refresh token 은 회전한다. 옛 것을 계속 쓰면
나오는 오류가 **무효화 때문인지 재사용 때문인지 구별되지 않는다.**
같은 모양으로 `MARK_R3` · `MARK_R_END` 까지 두 번 더 한다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
연속 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 를 보지
않는다.** 그러면 이런 질문이 따라온다.
> **DB 를 언제 내리느냐에 따라 답이 달라지는 것 아닌가?**
그렇다. 그게 다음 절이다.
## 4-5. ★ 같은 설정에서 답이 셋으로 갈린다 — 셋 다 재현한다
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|---|---|---|---|
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
**★ 한 번만 재고 넘어가면 반드시 틀린 표를 쓰게 된다.** A-7 이 그렇게 했다.
셋 다 재현해야 한다.
**되돌리기** — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
### 캐시를 식히는 방법 — **Keycloak 재시작이 유일하다**
```
Infinispan 캐시 = 프로세스 메모리
└─ 파드가 살아 있는 한 안 식는다
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
```
**이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다.** 이미 더워진 캐시에서 계속
재게 되므로 **A·B 를 재도 C 의 답(200/200)이 나오고**, 「A-7 이 틀렸다」는 엉뚱한
결론에 도달한다.
### 재현 A — 완전 냉시동이면 로그인부터 400
**하기**
```bash
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**★ 재시작과 DB 정지 사이에 아무 요청도 보내지 않는다.** 한 번이라도 로그인하면
캐시가 더워져서 이건 재현 B 가 된다.
파드 IP 가 바뀌었으므로 탐침을 다시 띄운다.
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
**확인** — 로그인. **본문까지 본다**
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -w "\n%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
```
**어디를 봐야 하는가**`unauthorized_client`. **`invalid_grant` 가 아니다.**
세션 문제가 아니라 **클라이언트를 못 찾은 것**이다.
**확인** — 왜인지는 Keycloak 로그가 직접 말한다
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
**실측** — 같은 파일
```
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=?]
```
**어디를 봐야 하는가** — 대괄호가 **두 쌍**이다. 앞은 **DB 가 준 오류**,
뒤는 **실패한 SQL 원문**. `grep -oE` 로 그 두 쌍만 뽑는 이유가 이것이다.
> 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저
> 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다.
**이 결과가 의미하는 것** — **A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었다.
냉시동에서는 아니다.** 클라이언트 조회조차 캐시에 없기 때문이다.
### 재현 B — A-7 이 본 그 조건
**하기** — DB 를 살리고, 재시작하고, **로그인만 한 번** 하고, DB 를 내린다
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤(재현 A 와 같은 명령), **로그인 한 번만** 한다.
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
echo "rt $(wc -c < /tmp/rt) bytes"'
```
**★ 여기서 refresh 를 하면 안 된다.** 하는 순간 재현 C 가 된다.
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**확인** — 이제 refresh
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -w "\n%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
로그인 200
refresh 500 {"error":"unknown_error"}
```
**확인** — 실패한 SQL
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
**실측** — 같은 파일
```
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=?]
```
**어디를 봐야 하는가****4-3 에서 본 그 문장이다.** 문장 로깅이 「이 문장을
쏜다」를 보여줬고, 여기서는 「이 문장이 실패했다」를 보여준다. **두 개가 만나면
가설이 아니라 확정이다.**
**`500 unknown_error` 인 이유도 이제 안다.** 세션은 멀쩡하다. 토큰을 조립하다가
DB 가 없어서 못 만든 것이고, Keycloak 은 그걸 사용자 오류로 분류할 방법이 없어서
`unknown_error` 를 준다.
### 재현 C — 완전히 더우면 둘 다 200
**하기** — DB 를 살리고, 재시작하고, **refresh 를 3회 미리 돌린 뒤** DB 를 내린다
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤, 로그인 1회 + refresh 3회(4-4 와 같은 형태로
`/tmp/rt` 를 매번 갱신하며).
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**확인** — 로그인과 refresh 를 둘 다
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
curl -s -o /dev/null -w "refresh %{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
로그인 200
refresh 200 ← A-7 의 표와 정반대다
```
**이 결과가 의미하는 것****같은 설정, 같은 명령, 세 개의 답.** 무엇이 다른지는
`kubectl get` 어디에도 안 나온다. **캐시 온도는 보이지 않는 상태다.**
```
volatile + DB 정지의 결과
= "무엇을 하느냐"가 아니라
"그 경로가 이미 캐시를 채웠느냐"
```
> **A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의
> 함정이다.** 상태가 결과를 바꾸는데 그 상태가 안 보인다.
> **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로 DB 가
> 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile 고유의
> 성질**이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
---
# 5. 복구
**세 개를 순서대로 되돌린다.** 순서가 있다 — DB 가 살아 있어야 나머지가 된다.
## 5-1. PostgreSQL 을 되살린다
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
```
## 5-2. ★ 문장 로깅을 끈다 — 잊으면 다음 실험이 전부 오염된다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
none
```
**왜 급한가** — [A-3](a3-database-crash.md) 은 수백 건의 로그인을 최대한 빨리
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
**다음 실험의 측정값이 이 설정 때문에 바뀐다.**
## 5-3. args 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
## 5-4. 정말 persistent 로 돌아왔는지 — 동작으로 확인한다
**args 문자열만 보고 끝내지 않는다.**
**하기** — 탐침을 새 IP 로 띄우고 로그인 한 번
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 600
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"'
```
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**어디를 봐야 하는가****0 이 아니어야 한다.** 로그인 후 행이 생기면 persistent 다.
원래 재현 절차가 마지막에 이 한 줄을 두는 이유가 이것이다.
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| **문장 로깅** | `psql -c "show log_statement"` | **`none`** |
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| **동작** | 위 5-4 | 로그인 후 세션 행이 **생긴다** |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a7a-probe` | `NotFound` |
| 임시 파일 | `ls /tmp/pg.log` | 지워도 된다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log
```
> **이 실험이 재지 않은 것** — 캐시가 「얼마나 오래」 더운지는 재지 않았다.
> `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로, **한참 뒤에 다시 재면
> 또 다른 답이 나올 수도 있다.** 그것까지 확인하려면 재현 C 뒤에 시간을 두고
> 같은 시험을 반복해야 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement``all` 인지 — 2-1 |
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` — 4-2 |
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
| 로그 시각이 9시간 어긋난다 | **컨테이너 로그가 UTC 다** | `date -u` 와 비교한다 |
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 — 4-4 |
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 — 4-2 |
| 세 재현이 전부 `200/200` | **재시작을 건너뛰어 캐시가 계속 더웠다** | 재현마다 `rollout restart` — 4-5 |
| 재현 A 가 `200` 이 나온다 | 재시작 후 요청을 한 번이라도 보냈다 | 재시작 → **바로** DB 정지 |
| 재현 B 가 `200/200` | 로그인 뒤 refresh 를 미리 했다 | 로그인 **한 번만** 하고 DB 정지 |
| `JDBC exception` grep 이 빈 출력 | `--tail` 이 짧거나 정규식이 안 맞는다 | `grep -i 'JDBC exception'` 으로 먼저 넓게 |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 새 IP 로 다시 띄운다 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| 다음 실험의 postgres 로그가 폭주한다 | **문장 로깅을 끄지 않았다** | `show log_statement``none` — 5-2 |
| 다음 실험의 세션이 안 살아남는다 | **volatile 로 둔 채 끝냈다** | 5-4 의 행 수 확인 |
---
# 왜 이 가이드는 표식을 손으로 넣게 하나
원래 실행은 표식을 셸 함수로 감쌌다.
```bash
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
```
짧고 편하다. 그런데 **출력을 `/dev/null` 로 버린다.** 표식이 실제로 로그에
들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이다. 로깅이 안 켜져
있었다면 **표식 없는 로그를 한참 뒤에 `awk` 로 자르다가** 알게 된다.
이 가이드는 표식을 **한 줄씩 손으로** 넣는다. 느리지만 그 자리에서 보이고,
안 보이면 그 자리에서 안다.
---
# 다음
| 실험 | A-7a 가 남긴 것 |
|---|---|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **그 표에 조건을 붙여야 한다.** 「로그인 200 · refresh 500」은 캐시가 반쯤 더울 때만 참이다 |
| [A-3](a3-database-crash.md) DB 크래시 | 같은 문장 로깅 기법. **RPO 를 재는 데 쓴다** |
| [A-2](a2-database-loss.md) DB 정지 | persistent 에서는 캐시 온도와 무관하게 실패한다 — 대조군 |
| 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |
@@ -0,0 +1,753 @@
# A-8 재현 가이드 — 배포할 때마다 로그아웃되는지 직접 확인한다
해설 문서: [`docs/experiment-a8-rolling-restart.md`](../../experiment-a8-rolling-restart.md) ·
증거 원문: [`docs/evidence/a8-rolling-restart/`](../../evidence/a8-rolling-restart/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것 | 5초 간격 `curl` 시계열 |
| **재시작 전에 발급한 토큰이 재시작 후에도 통하는 것** | 상주 탐침 파드 |
| DB 세션 수가 그대로인 것 | PostgreSQL `OFFLINE_USER_SESSION` |
| **캐시만 0 으로 비워지는 것** | Prometheus `approximate_entries_unique` |
| 클러스터가 스스로 다시 붙는 것 | `vendor_cluster_size` |
| 「무중단」이 **관측 해상도에 달려 있다**는 것 | 표본이 9개뿐인 시계열 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- **[A-0](a0-session-replication.md) 을 먼저 하면 좋다.** 「세션은 DB 에 있고
캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 터미널 **두 개**를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다),
하나는 재시작·관찰용.
## 주의 — 이건 파괴적이지 않다. 그래서 더 조심한다
`rollout restart` 는 **정상 작업**이다. 되돌릴 것이 없고, 잘못돼도 클러스터가
스스로 회복한다. 전 구간 약 15~20분.
**그래서 함정이 다르다.** 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고,
**측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다.** 실제로 원래 실행이
그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.
**다른 실험과 겹치지 않게 한다.** 롤링 재시작 중에 다른 주입이 들어가 있으면
무엇 때문에 무엇이 일어났는지 구별되지 않는다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:1913:20 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid·세션 수는 **당신 환경에서 다르다.** 이 문서는 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
**운영에서 가장 자주 겪는 일이다.** 장애가 아니라 정상 작업인데도 사용자가
로그아웃되면 그건 사고다.
```
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
└─ 세션이 거기 있었다면?
```
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을
세웠다. **그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다.**
틀리다면 배포가 곧 전원 로그아웃이다.
| | 예측 |
|---|---|
| A-0 모델 (persistent) | 재시작해도 **세션 생존** |
| 옛 방식 (volatile) | 재시작하면 **전원 로그아웃** |
**둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다.**
그리고 이 실험은 **가용성도 같이 잰다.** 세션이 살아도 재시작 중에 서비스가
끊기면 그것대로 문제다.
---
# 1. 기준선 — 재시작하기 전에
**시험군만 재는 측정은 측정이 아니다.** 재시작 후에 볼 것을 재시작 전에
**똑같은 명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
```
## 1-1. 파드와 나이 — **나이가 판정 근거다**
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
```
**어디를 봐야 하는가**
- `READY` 가 둘 다 `1/1`, `RESTARTS``0`
- **`AGE`** — 이 값을 적어 둔다. **재시작 후 이 값이 초 단위로 바뀌는 것이
「정말 재시작됐다」의 증거다**
- **replica 가 2 인 것** — 무중단의 전제다. 1 이면 반드시 끊긴다
**이 결과가 의미하는 것**`rollout restart` 는 파드를 **삭제하고 새로 만든다.**
그래서 `RESTARTS`**안 오른다.** 재시작 여부를 `RESTARTS` 로 보면 「아무 일도
안 일어났다」로 읽는다. **`AGE` 로 본다.**
IP 를 잡아 둔다. 재시작 후 **반드시 다시 잡는다.**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
## 1-2. args 가 `["start"]` 인가 — 이 실험의 전제
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**형태**
```
["start"]
```
**어디를 봐야 하는가** — 플래그가 없는 것.
**이 결과가 의미하는 것**`persistent-user-sessions` 가 기본으로 켜져 있다.
**`--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 실험은
정반대 결과를 낸다** — 그건 [A-7](a7-volatile-comparison.md) 이다. 앞 실험이
되돌리지 않고 끝냈다면 여기서 잡힌다.
## 1-3. DB 세션 수를 적어 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**형태**
```
offline_flag | count
--------------+-------
0 | 151
```
**어디를 봐야 하는가****`offline_flag = '0'` 이 온라인 세션**이다.
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
DB 세션 수: 151
```
**이 숫자를 적어 둔다.** 재시작 후 같은 값이 나오는 것이 4-3 의 판정이다.
> 숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 **개수에는
> 노이즈가 있다.** 그래서 이 실험은 개수 말고 **특정 sid 하나**를 따로 추적한다.
## 1-4. 상주 탐침 파드 — **StatefulSet 밖에 있어야 한다**
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`).
**그리고 이 실험은 탐침이 재시작을 넘어 살아 있어야 한다.** 토큰을 재시작 전에
받아서 재시작 후에 써야 하기 때문이다.
```
토큰을 어디에 두나
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
```
**하기**
```bash
sudo kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**확인**
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.94 K1=10.42.0.45 PW길이=19
```
`PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
## 1-5. ★ 토큰을 파드 안에 보관한다 — 여기가 이 실험의 함정이다
**하기** — 로그인해서 응답을 `/tmp/tok` 에, 거기서 뽑은 값을 `/tmp/rt` · `/tmp/sid`
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
| cut -d. -f2 | base64 -d 2>/dev/null \
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
```
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
sid = XLcgQWRiJrTkuNZcJsNeT_2j
```
**어디를 봐야 하는가****두 값이 다 채워졌는지.**
| 출력 | 뜻 |
|---|---|
| `rt 1188 bytes / sid XLcg...` | 정상 |
| **`rt 1 bytes`** | **빈 문자열 + 개행.** 파싱 실패 |
| `sid` 가 비어 있음 | base64 패딩 때문에 잘렸다. sid 없이 진행하고 4-3 은 개수로 본다 |
### ★ 원래 실행이 실제로 빠진 함정
> **처음 재현 절차는 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽었다.** `/tmp/rt` 를 만드는
> 줄이 빠져 있었다. 그러면 **빈 문자열이 `refresh_token=` 으로 전송되는데,
> 그래도 400 이 아니라 통과한 것처럼 보였다.**
**왜 위험한가** — 이 실험의 판정이 「재시작 후 refresh 가 `200` 인가」다.
**빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로
거짓이 된다.** 그리고 그 오류는 **아무 에러도 안 낸다.**
**그래서 길이를 찍는다.** `wc -c` 한 번이 이 실험 전체를 지킨다.
**확인** — 못 미더우면 파일을 직접 본다
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
sudo kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo
```
**형태**
```
-rw-r--r-- 1 curl_use curl_gro 1188 Sep 4 13:19 /tmp/rt
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
```
**어디를 봐야 하는가**`/tmp/rt` 의 크기가 네 자리이고 내용이 `eyJ`
시작하는 것. `eyJ` 는 base64 로 인코딩된 `{"` 다. **JWT 는 전부 이렇게 시작한다.**
## 1-6. 대조군 — 재시작 전에 refresh 가 되는 것
**이 절을 건너뛰면 뒤의 200 이 아무 의미가 없다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**형태**
```
200
```
**★ 이 refresh 로 토큰이 회전했다.** `/tmp/rt` 의 값은 이제 **쓰인 토큰**이다.
다시 채워 둔다. 안 그러면 4-1 의 400 이 「재시작 때문」인지 「재사용 때문」인지
구별되지 않는다.
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
| cut -d. -f2 | base64 -d 2>/dev/null \
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
```
**이 sid 가 최종 추적 대상이다.** 적어 둔다.
**확인** — 그 세션이 DB 에 실제로 있는지 지금 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
```
**형태**
```
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495513
(1 row)
```
**어디를 봐야 하는가** — 행이 **1개** 있고, `created_on`
`last_session_refresh`**같다.** 아직 갱신한 적이 없다.
**이 결과가 의미하는 것** — 세션이 DB 에 있다. **재시작 후에 이 행이 그대로
있고 `last_session_refresh` 만 올라가는 것**이 4-2 의 판정이다.
## 1-7. 캐시와 클러스터 크기
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**형태**
```json
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757040000.1,"2"]},
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757040000.1,"2"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**어디를 봐야 하는가** — 결과가 두 줄이고 값이 둘 다 `2`.
**확인** — 세션 캐시 엔트리 수도 지금 봐 둔다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**0 이 아닌 값**이 나올 것이다. 재시작 후 **0 이 되는 것**이 4-4 의 판정이다.
---
# 2. 주입 — 롤링 재시작
여기부터 상태가 바뀐다. **다만 되돌릴 것은 없다.**
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌리는 명령이 없다. 중간에
멈추려면 `rollout status``Ctrl-C` 로 끊으면 되지만 **롤아웃 자체는 계속
진행된다.** 끝날 때까지 두는 편이 낫다. 정말 되돌려야 하면:
```bash
sudo kubectl -n keycloak-lab rollout undo statefulset/keycloak
```
## 2-1. 가용성 감시를 먼저 띄운다
**두 번째 터미널**에서 돌린다. **재시작보다 먼저 시작해야** 끊김 구간을 놓치지
않는다.
**하기**
```bash
for i in $(seq 1 48); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \
https://auth.hyeonworks.com/realms/master)"
sleep 5
done
echo
```
**어디를 봐야 하는가** — 숫자가 5초마다 하나씩 붙는다. `200` 이 아닌 값이 보이면
그 자리가 끊김이다.
> **여기서 `-w '%{http_code}'` 를 쓰는 이유** — 48번 반복해서 **비교할 값**만
> 필요하기 때문이다. 무엇이 잘못됐는지 알아보려면 그때 `curl -v` 로 한 번 보면
> 된다. 두 형태는 용도가 다르다.
> `--max-time 4` 는 5초 간격보다 짧게 잡은 것이다. **타임아웃이 간격보다 길면
> 요청이 밀려 시계열이 어긋난다.**
## 2-2. 재시작한다
**첫 번째 터미널**에서 친다.
**하기**
```bash
date '+%H:%M:%S 재시작'
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
```
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
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...
```
**어디를 봐야 하는가** — 위 원문은 두 터미널의 출력이 **한 파일에 섞여 기록된**
것이다. `200` 이 가용성 루프, `Waiting for...``rollout status`.
- **`0 out of 2``1 out of 2``complete`** — 한 번에 하나씩 간다
- 그 사이사이에 **`200` 이 계속 찍힌다**
**시각을 반드시 적어 둔다.** 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 정말 재시작됐는지부터 본다.** 「세션이 살아남았다」는
결론은 **파드가 진짜 바뀌었을 때만** 의미가 있다.
## 3-1. 파드가 정말 새것인가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
```
**어디를 봐야 하는가** — 세 가지다.
- **`AGE` 가 초 단위다** — 1-1 에서 `2d` 였던 것이 `44s` 다. 진짜 새 파드다
- **두 나이가 다르다** (`44s` vs `66s`) — **한 번에 하나씩 내렸다는 증거**다.
22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
- `RESTARTS`**여전히 `0`** — 파드가 재시작된 게 아니라 **교체**됐기 때문이다
**이 결과가 의미하는 것**`RESTARTS` 를 판정에 쓰면 안 된다는 것이 여기서
보인다. `rollout restart` 는 파드를 지우고 새로 만들므로 재시작 카운터는
새 파드에서 0 부터 시작한다.
**★ 파드 IP 가 바뀌었다.** 다시 잡는다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**★ 탐침 파드는 다시 띄우면 안 된다.** `/tmp/rt``/tmp/sid` 가 같이 사라진다.
탐침 안의 `K0` 환경변수는 낡았으므로, **새 IP 를 명령줄에 직접 넘긴다.** 4절의
명령이 그렇게 되어 있다.
## 3-2. 가용성 시계열을 읽는다
두 번째 터미널의 출력을 본다.
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
```
```
200 200 200 200 200 200 200 200 200
```
**어디를 봐야 하는가****`200` 이 9개.** 비200 이 없다.
### ★ 「무중단」이라고 쓰기 전에 표본 수를 본다
```
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
```
**실제로 더 촘촘히 재니 끊김이 나왔다.** 후속 작업에서 **1초 간격·3초 타임아웃**
으로 D-2 롤백 전환을 재보니:
**실측** — [`experiment-followup-untested-items.md`](../../experiment-followup-untested-items.md) 2절
```
200 ×24 000 200 ×19
```
`000` 은 서버 오류가 아니라 **`--max-time 3` 타임아웃**이다. 파드 전환 순간
요청 하나가 3초를 넘겼다.
**그래서 정확한 서술은 이것이다.**
| 쓰면 안 되는 문장 | 정확한 문장 |
|---|---|
| 「무중단이었다」 | 「**5초 해상도에서 끊김이 관측되지 않았다**」 |
**더 촘촘히 보고 싶으면** 2-1 의 루프를 이렇게 바꾼다. **미검증**
```bash
for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
sleep 1
done
echo
```
---
# 4. 효과를 관찰한다
## 4-1. ★ 본 시험 — 재시작 전 토큰이 아직 통하는가
**하기** — 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -w "\n%{http_code}\n" -X POST \
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
```
**어디를 봐야 하는가**`200`, 그리고 **본문에 새 토큰이 들어 있는 것.**
**이 결과가 의미하는 것****파드가 통째로 바뀌었는데 세션이 그대로다.**
새로 뜬 프로세스는 이 세션을 **메모리에서 알던 것이 아니다.** DB 에서 읽었다.
> **400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다.**
> - 1-6 에서 `/tmp/rt` 를 다시 안 채웠다 → 이미 쓴 토큰이다
> - `rt 1 bytes` 를 놓쳤다 → 빈 문자열을 보내고 있다
> - args 에 `--features-disabled=persistent-user-sessions` 가 있다 → 그건 A-7 이다
>
> 셋 다 아니면 그때 결론을 의심한다.
## 4-2. DB 에 그 세션이 남아 있는가 — sid 로 정확히
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [4] DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
(1 row)
```
**어디를 봐야 하는가****두 숫자의 차이.**
```
1788495577 - 1788495513 = 64초
│ │
│ └─ 재시작 전에 세션이 만들어진 시각
└─ 재시작 후의 refresh 가 기록된 시각
```
**이 결과가 의미하는 것****응답 코드만 200 인 게 아니라 쓰기까지 정상이다.**
새 파드가 DB 에서 세션을 읽었고, 갱신 시각을 **DB 에 되썼다.**
`200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다.
**A-1 에서 실제로 그런 일이 있었다** — 캐시가 DB 와 무관하게 200 을 준 사례다.
여기서는 DB 행이 갱신됐으므로 그 가능성이 없다.
> **두 값은 유닉스 시각(초)이다.** 사람이 읽는 형태로 보려면:
> ```bash
> date -d @1788495513 ; date -d @1788495577
> ```
## 4-3. 전체 세션 수는 그대로인가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
전체 온라인 세션: 151 (재시작 전 151)
```
**어디를 봐야 하는가** — 1-3 에서 적어 둔 값과 같은지.
**이 결과가 의미하는 것****한 건도 안 잃었다.** sid 하나가 살아남은 것과
전체가 살아남은 것은 다른 주장이고, 둘 다 봐야 한다.
> 관리 API 호출이 세션을 만들기 때문에 **몇 건 늘어날 수는 있다.** 크게 줄었다면
> 그게 문제다.
## 4-4. 캐시는 사라진다 — 그게 정상이다
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [5] 캐시는 어떻게 되었는가 ===
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
```
**어디를 봐야 하는가** — 세 가지다.
- **캐시가 0 이다** — 프로세스 메모리라 재시작에 사라졌다
- **`keycloak-1` 의 1건** — 방금 4-1 의 refresh 를 처리하며 새로 담은 것이다.
0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
- **`cluster_size` 가 다시 2** — 클러스터가 스스로 재형성됐다
**이 결과가 의미하는 것****A-0 의 모델이 그대로 확인된다.**
```
재시작 전: 캐시 N건 + DB 151건
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
```
**캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다.** 룩어사이드
캐시의 성질이다.
> Grafana 로 보면 세션 캐시가 0 으로 떨어지고 `cluster_size` 가 다시 2 가 되는
> 구간이 한 화면에 잡힌다 —
> [`a8-cache-reset-cluster-reformed.png`](../../evidence/a8-rolling-restart/a8-cache-reset-cluster-reformed.png)
## 4-5. 왜 무중단이 되는가
```
StatefulSet 롤링 재시작
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
│ └─ 이 동안 keycloak-0 이 전부 받는다
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
└─ keycloak-0 종료 → ... (반복)
```
**확인** — 엔드포인트가 실제로 그렇게 움직였나. 재시작 중에 봐야 보인다
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다.
> `endpointslice` 를 본다.
**이 결과가 의미하는 것****한 번에 하나씩** 내리므로 항상 최소 하나는 Ready 다.
readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로
본 그 메커니즘이 여기서는 **정상 작업을 안전하게** 만든다.
| 무중단의 조건 | 빠지면 |
|---|---|
| **replica ≥ 2** | 하나뿐이면 내리는 동안 아무도 안 받는다 |
| **readiness 프로브** | 아직 기동 중인 파드로 트래픽이 간다 |
**둘 다 있어야 성립한다.** 이 실험대는 파드가 2개라서 됐다.
---
# 5. 복구
**주입이 정상 작업이었으므로 되돌릴 것이 없다.** 정리만 한다.
## 5-1. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
**남겨 두면** 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 **네임스페이스에
정체 모를 파드가 하나 있는 상태**가 된다. 지운다.
## 5-2. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
| 세션 | `psql -tAc "select count(*) from offline_user_session where offline_flag='0'"` | 1-3 과 비슷한 값 |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a8-probe` | `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
> **이 실험이 재지 않은 것 셋**
> - **replica 1 에서 어떻게 되는지** — 반드시 끊긴다고 적었지만 재지 않았다
> - **5초보다 짧은 끊김** — 3-2 참조. 후속 작업이 다른 조건에서 `000` 을 잡았다
> - **캐시가 0 에서 다시 차는 데 걸리는 시간** — 「첫 접근만 느려진다」고 썼지만
> 그 「느림」을 재지 않았다. [A-6](a6-latency-injection.md) 이 인접한 주제다
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **refresh 가 200 인데 뭔가 이상하다** | **`/tmp/rt` 가 비어 있다.** 빈 토큰인데 통과한 것처럼 보인다 | `wc -c < /tmp/rt` — 1-5 |
| refresh 가 `400 Session not active` | 1-6 뒤에 `/tmp/rt` 를 안 채웠다. 이미 쓴 토큰이다 | 새로 로그인해서 다시 담는다 |
| refresh 가 `400` 인데 토큰은 맞다 | **args 가 volatile 이다** | `get statefulset ... args` — 1-2. 그건 [A-7](a7-volatile-comparison.md) |
| 재시작 후 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 다시 — 3-1 |
| 탐침을 다시 띄웠더니 토큰이 없다 | **`/tmp/rt` 가 파드와 함께 사라졌다** | 탐침은 재시작 내내 유지한다 — 3-1 |
| `RESTARTS` 가 0 이라 재시작이 안 된 것 같다 | **`rollout restart` 는 파드를 교체한다** | `AGE` 로 본다 — 3-1 |
| `rollout status` 가 타임아웃 | 파드가 Ready 를 못 받는다 | `describe pod` 의 Events, `logs --previous` |
| 가용성 루프에 `000` 이 섞인다 | `--max-time` 초과. **서버 오류가 아니다** | 간격보다 짧은 타임아웃인지 — 2-1 |
| 가용성 루프가 전부 `000` | 루프가 잘못된 URL 을 친다 | `curl -v` 로 한 번 본다 |
| 세션 수가 크게 줄었다 | 다른 실험이 세션을 지웠거나 volatile 이다 | 1-2 · 1-3 을 다시 |
| DB 행의 `last_session_refresh` 가 안 올랐다 | 4-1 을 하기 전에 조회했다 | 순서: refresh → 조회 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
---
# 다음
| 실험 | A-8 이 남긴 질문 |
|---|---|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **이 실험을 그대로 반복하면 정반대가 나와야 한다.** 그 한 쌍이 「왜 persistent 인가」의 답이다 |
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 **전제**다 |
| [A-2](a2-database-loss.md) DB 정지 | 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다 |
| 구성 | 무중단은 공짜가 아니라 **replica ≥ 2 + readiness** 의 조합이다 |
@@ -0,0 +1,830 @@
# B-0 재현 가이드 — 아무것도 주지 않았을 때 Spring 이 무엇을 고르는지 본다
해설 문서: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) ·
증거 원문: [`docs/evidence/b0-bff-redis-deploy/`](../../evidence/b0-bff-redis-deploy/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 돌고 있는 인스턴스가 **실제로 고른 구현체 이름** | `/actuator/beans` |
| Redis 도 Spring Session 도 **하나도 구성되지 않은 것** | 같은 곳 |
| 조회 키에 **session ID 가 없다**는 것 | 빈 이름 하나가 그대로 설명이다 |
| **replica 2 에서 로그인 자체가 실패하는 것** | 브라우저 · `/login?error` |
| replica 를 1 로 줄이면 되는 것 | 같은 브라우저 |
| 브라우저에 토큰이 **0개**인 것 | `/bff/token-boundary` |
## 전제
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다. A층 실험은 안 해도 된다.
- **브라우저가 필요하다.** 인가 코드 흐름은 **왕복이 두 번**이라 `curl`
대신할 수 없다. `https://app1.hyeonworks.com/` 이 당신 브라우저에서 열려야 한다.
- BFF 이미지는 **워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.**
레지스트리가 없으므로 `imagePullPolicy: Never` 다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 으로 읽는다.
## 주의 — ★ 저장소를 먼저 붙이면 이 실험은 성립하지 않는다
B-0 의 질문은 **「아무것도 주지 않았을 때 자동구성이 무엇을 고르는가」**다.
Redis 를 먼저 연결하면 잴 것이 없어진다. **Redis 는 배포만 하고 BFF 에 연결하지
않는다.** 연결은 [B-1](b1-redis-session-store.md) 에서 한다.
**그리고 이 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다.**
`bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고, `SecurityConfig`
에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. **그대로 배포하면 B-2 의
결과를 재게 된다.** 어느 브랜치에도 B-0 시점의 파일은 남아 있지 않다 —
[2-1](#2-1-b-0-상태로-되돌린다--네-파일) 에서 손으로 되돌린다.
전 구간 약 40~60분(빌드 시간 포함). 배포한 것을 지우는 명령은 5절에 있다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:3913:46 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
파드 이름·IP·빈 개수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
Q1 이 직접 요구한 확인이다.
> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로
> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
```
빈을 직접 만들지 않으면
└─ Spring Boot 가 조건에 따라 고른다
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
└─ 돌아가는 인스턴스에 물어봐야 안다
```
**추측으로도 답은 나온다.** 「저장소를 안 붙였으니 메모리겠지.」 맞다.
**그런데 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.**
빈 이름 하나가 이 층 전체의 문제를 담고 있는데, **그 이름은 추측으로 안 나온다.**
찍어 봐야 나온다. 그게 이 실험이다.
---
# 1. 기준선 — 배포하기 전에
넓은 것부터 좁혀 간다.
```
노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
```
## 1-1. 노드에 자원이 있나
**확인**
```bash
free -m
sudo kubectl top nodes
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
=== 배포 전 자원 ===
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%
```
**어디를 봐야 하는가** — 노드 메모리 사용률. 여기서는 `44%` · `33%` 다.
**이 결과가 의미하는 것** — BFF 는 JVM 이고 replica 가 2 다. 매니페스트는
`requests: 320Mi` · `limits: 512Mi` 로 잡혀 있다. **여유가 없으면 파드가
`Pending` 이거나 OOM 으로 죽는데, 그걸 「Spring 설정 문제」로 읽게 된다.**
배포 전에 한 번 보고 시작한다.
## 1-2. 네임스페이스에 무엇이 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get all
sudo kubectl -n keycloak-lab get secret,ingress
```
**어디를 봐야 하는가**`keycloak` StatefulSet 과 `postgres` 가 있고,
**`bff` · `redis` 는 없는 것.**
**이 결과가 의미하는 것** — 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면
「내가 만든 것」과 「원래 있던 것」이 섞인다. 있으면 5-1 로 먼저 지운다.
> `get all` 은 워크로드 계열만 보여준다. **Secret·PVC·Ingress 는 안 나온다.**
> 그래서 두 줄로 나눠 친다.
## 1-3. Keycloak realm 을 준비한다
BFF 가 붙을 realm 과 클라이언트가 있어야 한다. **없으면 배포는 성공하는데
로그인에서 막힌다.**
**하기**`kcadm` 에 로그인한다. Keycloak 이미지 안에 있는 도구다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**하기** — realm 과 클라이언트
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create clients -r keycloak-patterns \
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
**어디를 봐야 하는가**`accessTokenLifespan=60`.
**이 결과가 의미하는 것****B-3(refresh 경쟁)을 위해 미리 짧게 잡는 것이다.**
만료를 기다리는 시간이 짧아야 재현이 된다. 지금 정해 두면 나중에 realm 을
다시 안 만든다.
**확인** — 만들어졌나
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
```
로그인할 사용자도 하나 만든다.
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users -r keycloak-patterns -s username=labuser -s enabled=true
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'
```
**★ 이 비밀번호는 브라우저에 직접 칠 것이므로 당신이 정한다.** 위 값은 예시고,
**실제로 쓸 값은 셸 히스토리에 남지 않게** 하려면 `kcadm.sh` 를 대화식으로
쓰거나 나중에 관리 콘솔에서 바꾼다.
---
# 2. 준비 — 배포에서 겪은 문제 다섯 가지를 먼저 읽는다
**이 절을 건너뛰면 다섯 번 막힌다.** 전부 이 실험대가 실제로 겪은 것이다.
## 2-1. B-0 상태로 되돌린다 — 네 파일
현재 소스는 B-1·B-2 의 결과를 담고 있다. **B-0 을 재려면 그 배선을 빼야 한다.**
**되돌리기** — 실험이 끝나면 원래대로 돌린다
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
deploy/lab/k8s/bff-redis.yaml
```
**하기 ①**`bff/pom.xml` 에서 두 블록을 지운다
```bash
vim bff/pom.xml
```
| 지울 의존성 | 왜 |
|---|---|
| `spring-session-data-redis` | 있으면 `SessionRepository` 가 Redis 로 갈린다 (B-1) |
| `spring-boot-starter-data-redis` | 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1) |
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | B-2 의 `JdbcOAuth2AuthorizedClientService` 용 |
**하기 ②**`SecurityConfig.java` 에서 **빈 두 개**를 지운다
```bash
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
```
```java
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
@Bean
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
```
관련 `import` (`JdbcOAuth2AuthorizedClientService`, `JdbcOperations`, 매니저 계열)도
같이 지운다. **`bffSecurity` 빈은 남긴다** — `/actuator/**` 를 열어 주는 것이
그 안에 있다(문제 ④).
**하기 ③**`application.yml` 에서 세 블록을 지운다
```bash
vim bff/src/main/resources/application.yml
```
| 지울 블록 | 왜 |
|---|---|
| `spring.session` | `store-type` 기본값이 **`redis`** 다. 남겨 두면 의존성만 빼도 경고가 난다 |
| `spring.data.redis` | Redis 연결 설정 |
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
**하기 ④**`deploy/lab/k8s/bff-redis.yaml``bff` 컨테이너에서 env 를 지운다
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
# 지운다 — B-1 · B-2 가 넣은 것
- name: SPRING_SESSION_STORE_TYPE
- name: REDIS_HOST
- name: REDIS_PORT
- name: BFF_DB_URL
- name: BFF_DB_USER
- name: BFF_DB_PASSWORD
```
**Redis Deployment·Service·PVC 는 그대로 둔다.** 배포는 하되 **연결만 안 한다**
그게 B-0 의 구성이다.
**확인** — 무엇을 지웠는지 눈으로 본다
```bash
git diff --stat
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
```
## 2-2. 문제 ① — 소스 없이 빌드 산출물만 커밋되어 있었다
원래 실행은 `bff/` 에 이런 상태를 만났다.
```
bff/target/classes/... 9개 파일
bff/src/ 없음
```
`.gitignore``target/` 이 없어 **클래스 파일만** 커밋되어 있었고 소스는
다른 브랜치에 있었다.
**확인** — 지금 당신의 저장소는 어떤가
```bash
ls bff/src/main/java/com/example/keycloakpattern/bff/
```
없으면 가져온다.
```bash
git checkout origin/develop-keycloak-pattern3 -- bff/
```
**이 결과가 의미하는 것** — **빌드 산출물이 커밋되어 있으면 「빌드가 되는데
바꿔도 안 바뀐다」가 된다.** 소스가 있는지부터 본다.
## 2-3. 문제 ② — 빌드 실패 원인이 마지막 15줄에 없다
**하기**
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
```
**확인** — 실패했으면 전체 로그에서 찾는다
```bash
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
```
**실측** — 원래 실행이 만난 것
```
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
```
`management:` 아래에 `endpoint:` 블록을 **하나 더** 넣어서 난 오류였다.
이미 있는데 또 넣은 것이다.
**이 결과가 의미하는 것****`docker build` 기본 출력은 마지막 몇 줄만 보여준다.**
Maven 스택트레이스는 그 위에 있다. `--progress=plain` 으로 전체를 파일로 받고
`grep` 으로 찾는다.
> `yamllint` 는 이 실험대에 깔려 있지 않다. YAML 중복 키는 **빌드가 잡아 준다** —
> 다만 그 메시지를 보려면 위처럼 해야 한다.
## 2-4. 문제 ③ — 환경변수에 기본값이 없으면 테스트가 죽는다
```yaml
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
# 기본값을 준다
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
```
**확인** — 지금 파일이 그렇게 되어 있나
```bash
grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
```
## 2-5. 문제 ④ — actuator 가 인증에 막혀 200 인데 로그인 페이지
`/actuator/beans` 를 불렀는데 `200` 이 왔다. **내용은 Keycloak 로그인 페이지였다.**
`-L` 로 리다이렉트를 따라간 결과다.
```java
// SecurityConfig 의 permitAll 목록
"/actuator/health",
"/actuator/health/**",
// 실험대 전용 — 운영에서는 절대 열지 않는다
"/actuator/**"
```
> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다. 이 함정은
> `-o /dev/null -w '%{http_code}'` 만 쓸 때 **절대 안 보인다.**
## 2-6. 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway`
`/actuator/beans`**117KB** 다. nginx → Traefik 을 거치면서 실패했다.
**실측** — [`experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) 1절
```
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
```
**해결** — 파드 안에서 직접 받는다. **alpine 기반 JRE 이미지에는 `wget` 이 있다.**
(Keycloak 이미지와 다른 점이다 — 거기엔 curl 도 wget 도 없다.)
## 2-7. 이미지를 두 노드에 밀어 넣는다
레지스트리가 없다. `imagePullPolicy: Never` 라서 **두 노드에 각각 있어야 한다.**
**하기** — 워크스테이션에서
```bash
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
```
**확인** — 두 노드에 들어갔나
```bash
sudo k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
```
**한쪽만 있으면** 그 노드에 스케줄된 replica 만 뜬다. `ErrImageNeverPull`
나타난다.
---
# 3. 배포
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
```
## 3-1. 적용
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
=== 배포 ===
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
```
## 3-2. 배포 구성 — 무엇이 어디에 있나
```
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
├──▶ Keycloak (realm: keycloak-patterns)
└──▶ echo (resource server 대역)
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
```
**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** 이 상태를 먼저 재는 것이 B-0 이다.
### 브라우저용 URL 과 백채널 URL 을 분리한다
```yaml
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
```
```yaml
- name: KC_ISSUER_EXTERNAL
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
```
**브라우저가 보는 이름과 서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.**
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다 — 없으면 Spring 이
`redirect_uri``http://` 로 만들어 Keycloak 이 거부한다.
---
# 4. 배포가 실제로 걸렸는지 확인한다
## 4-1. 파드가 두 노드에 하나씩 떴나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
```
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
```
bff-574c6d658b-8cz4x true kc-lab-1
bff-574c6d658b-zpkbp true kc-lab-2
redis-568bd7c4-5c5vc true kc-lab-2
```
**어디를 봐야 하는가****BFF 두 개가 서로 다른 노드에 있는 것.**
**이 결과가 의미하는 것**`topologySpreadConstraints` 가 일했다. **「다른
인스턴스」가 진짜 다른 기계여야** 이 층의 질문이 성립한다. 같은 노드의 다른
프로세스면 재는 의미가 절반이다.
`Pending` 이면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면
2-7 로 돌아간다.
## 4-2. 밖에서 닿나
**확인**
```bash
curl -I https://app1.hyeonworks.com/
```
**형태**
```
HTTP/2 200
content-type: text/html
```
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
```
=== 외부 진입점 ===
https://app1.hyeonworks.com/ HTTP 200
```
**어디를 봐야 하는가** — 상태 줄과 **`content-type`.** 2-5 의 함정 때문이다.
`text/html` 이 왔다고 그게 **당신의** HTML 이라는 보장은 없다. 다음 절에서
내용까지 본다.
> `-I` 는 헤더만 본다. 여기서는 **닿는지**를 물어보는 것이라 이 형태가 맞다.
> 나중에 여러 번 재서 비교할 때는 `-o /dev/null -w '%{http_code}'` 를 쓴다.
---
# 5. 관찰 — 자동구성이 실제로 고른 것
## 5-1. `/actuator/beans` 를 파드 안에서 받는다
**하기** — 파드 이름을 먼저 잡는다
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "$BFF"
```
**하기** — 파드 안에서 받아 파일로 저장한다
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
wc -c /tmp/beans.json
```
**형태**
```
119552 /tmp/beans.json
```
**어디를 봐야 하는가** — 크기가 **10만 바이트 대**인 것. 원래 실행에서 **117KB**
였다. `0` 이면 못 받은 것이고, 몇 백 바이트면 **로그인 페이지나 오류 본문**이다.
**확인** — 진짜 JSON 인지 앞부분을 본다
```bash
head -c 200 /tmp/beans.json ; echo
```
**형태**
```json
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
```
**어디를 봐야 하는가**`{"contexts":{"keycloak-bff"` 로 시작하는 것.
`<!DOCTYPE html` 로 시작하면 **2-5 의 함정**이다 — 로그인 페이지를 받았다.
## 5-2. `jq` 없이 빈 목록을 읽는다
**빈 하나는 이런 모양이다.**
```
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
```
**이름과 타입이 이 한 덩어리 안에 같이 있다.** 그러니 그 덩어리만 뽑으면 된다.
**확인** — 전체 빈 수. **미검증**
```bash
grep -o '"aliases":\[' /tmp/beans.json | wc -l
```
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
```
컨텍스트: keycloak-bff
전체 빈 수: 321
```
**확인** — 이름과 타입을 한 줄로. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
**형태**
```
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
```
**어디를 봐야 하는가** — 화살표 오른쪽의 **클래스 이름 끝부분.**
### ★ 여기서 원래 실행이 실제로 넘어졌다
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
```
File "<stdin>", line 9
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
^
SyntaxError: unexpected character after line continuation character
```
**JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다.**
빈 목록은 결국 다음 시도에서 나왔고, 그 결과가
[`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt) 다.
> **`jq` 가 있으면 그걸 쓴다. 없으면 `grep` 으로 충분하다.**
> 이 실험대에는 `jq` 가 없다. 없는 도구를 전제로 한 명령은 **진단 도중에
> 패키지를 깔러 나가게 만든다.** 그러지 않으려고 위 형태를 쓴다.
## 5-3. B-0 의 답
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
```
--- 세션 · 토큰 저장소 관련 ---
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
clientRegistrationRepository -> InMemoryClientRegistrationRepository
--- Redis / Spring Session 이 구성되었는가 ---
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
```
**확인** — Redis 와 Spring Session 이 정말 없는지 직접 센다
```bash
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
```
**형태**
```
0
```
**어디를 봐야 하는가**`0`.
**이 결과가 의미하는 것** — 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다.
**세션은 서블릿 컨테이너(Tomcat)의 기본 `StandardSession` 에 있다.** 즉 **인스턴스
메모리**다.
| 빈 | 구현체 | 뜻 |
|---|---|---|
| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 |
| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 |
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유)를 쓴다 |
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` |
| Redis / Spring Session | **없음** | 의존성 자체가 없다 |
## 5-4. ★ 이름 하나가 이 층 전체의 문제다
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`**이름이 곧 설명이다.**
```
요청이 인증되어 있으면
└─▶ OAuth2AuthorizedClientService 에 위임
└─▶ 키: (clientRegistrationId, principalName)
└─ session ID 가 없다 ★
인증되어 있지 않으면
└─▶ HttpSession 에 임시 보관
```
**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을
본다.** 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다.
> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가
> 없기 때문**이다. 「Session Store 를 공유 저장소로 바꾸는 것만으로는 충분하지
> 않다」의 기제가 이 빈 하나에 들어 있다.
>
> **이것이 추측으로는 안 나오는 부분이다.** 「메모리겠지」까지는 맞혔어도
> **조회 키가 무엇인지는 빈 이름을 봐야 안다.**
---
# 6. ★ 예상 못 한 것 — replica 2개에서 로그인 자체가 안 된다
**여기부터는 브라우저로 한다.**
## 6-1. 증상
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
**형태** — 주소창이 이렇게 끝난다
```
https://app1.hyeonworks.com/login?error
```
**확인** — 로그를 본다. **두 파드를 다 봐야 한다**
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
```
**어디를 봐야 하는가****아무 오류도 없다.**
**이 결과가 의미하는 것** — Spring Security 는 **로그인 실패를 DEBUG 로만
남긴다.** 「로그에 아무것도 없으니 애플리케이션 문제가 아니다」로 읽으면 틀린다.
**증상은 있는데 로그가 없는 상태**이고, 그럴 때는 가설을 세워 시험한다.
## 6-2. 가설
```
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
```
**인가 코드 흐름은 왕복이 두 번이고, 두 번 다 같은 인스턴스로 가야 한다.**
저장 위치가 `HttpSession` 이고 그게 **인스턴스 메모리**이므로, 콜백이 다른
replica 로 가면 저장된 인가 요청이 없어 실패한다.
**5-3 에서 본 「SessionRepository 없음」이 이 가설의 근거다.**
## 6-3. 검증 — replica 를 1로 줄인다
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
```
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**하기** — 브라우저에서 다시 로그인한다. **쿠키를 먼저 지운다** (앞선 실패의
세션이 남아 있으면 결과가 섞인다).
**실측** — [`b0-bff-login-success-single-replica.png`](../../evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png)
```
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
replica 1 → 로그인 성공
```
**이 결과가 의미하는 것****가설 확정.**
> **「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라
> 로그인 자체의 문제다.** [B-2](b2-multi-instance-session.md) 의
> 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」)보다 **앞선 단계**다.
> 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
## 6-4. 토큰 경계 — 브라우저에 무엇이 있나
**하기** — 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary`
**실측** — [`b0-bff-token-boundary.png`](../../evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png)
```json
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
"browserTokenCount":0,"csrfProtectionEnabled":true}
```
**어디를 봐야 하는가** — 세 값.
| 필드 | 값 | 뜻 |
|---|---|---|
| `accessTokenStoredOnServer` | `true` | 서버가 access token 을 들고 있다 |
| `refreshTokenStoredOnServer` | `true` | refresh token 도 서버에 있다 |
| **`browserTokenCount`** | **`0`** | **브라우저에는 토큰이 하나도 없다** |
**이 결과가 의미하는 것****BFF 패턴이 성립한다.** 브라우저는 세션 쿠키만
들고 있고 토큰은 전부 서버에 있다. 이 세 값이 [B-1](b1-redis-session-store.md)
에서 어떻게 바뀌는지가 다음 실험의 요지다. **지금 값을 적어 둔다.**
---
# 7. 복구
## 7-1. replica 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
```
**[B-1](b1-redis-session-store.md) 로 이어서 갈 것이라면 배포는 그대로 둔다.**
거기서 같은 파드에 Redis 를 붙인다.
## 7-2. 소스 변경을 되돌린다
**하기**
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
deploy/lab/k8s/bff-redis.yaml
git status --short
```
**★ 잊으면 다음에 `apply` 할 때 B-0 구성이 다시 배포된다.**
## 7-3. 전부 지울 때
**하기**
```bash
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete realms/keycloak-patterns
```
**★ PVC 는 `delete -f` 로 같이 지워진다.** Redis 데이터도 사라진다.
## 7-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| replica | `sudo kubectl -n keycloak-lab get deploy bff` | `2/2` |
| 소스 | `git status --short` | 출력 없음 |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
| Keycloak | `sudo kubectl -n keycloak-lab get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running` |
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
| 임시 파일 | `rm -f /tmp/beans.json /tmp/build.log` | — |
> **★ actuator 를 열어 둔 채로 두지 않는다.** `/actuator/beans` 와
> `/actuator/env` 는 **내부 구조와 설정값을 그대로 드러낸다.** 실험대라서
> 여는 것이고, 운영이라면 `health` 만 남긴다.
> **이 실험이 재지 않은 것** — 스티키 세션(세션 어피니티)을 켜면 replica 2 에서
> 로그인이 되는지는 재지 않았다. 「같은 인스턴스로 보내면 된다」는 추론이지
> 측정이 아니다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 빈 목록에 `RedisSessionRepository` 가 있다 | **B-1·B-2 배선이 남아 있다** | 2-1 을 다시. `git diff` 로 확인 |
| `authorizedClientService``Jdbc...` 다 | `SecurityConfig` 의 명시 빈이 남아 있다 | 2-1 하기 ② |
| `/actuator/beans``Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 — 5-1 |
| `/actuator/beans``200` 인데 HTML | **Keycloak 로그인 페이지다** | `head -c 200` 으로 내용 확인 — 5-1 |
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 — 2-3 |
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | 2-4 |
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import — 2-7 |
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` — 1-1 |
| 브라우저가 `/login?error` | **replica 2 + 스티키 없음** | replica 1 로 줄여 확인 — 6-3 |
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 「문제 없음」으로 읽지 않는다 — 6-1 |
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 — 3-2 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 5-2 |
| `kcadm``401` | `config credentials` 를 안 했거나 만료됐다 | 1-3 을 다시 |
---
# 다음
| 실험 | B-0 이 남긴 것 |
|---|---|
| [B-1](b1-redis-session-store.md) 저장소 결정 | **전환 후 이 빈들을 다시 찍는다.** 「Redis 붙였다」고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그게 검증 0번이다 |
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어 뒀다 |
| 운영 | actuator `beans`/`env`**내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
@@ -0,0 +1,852 @@
# B-1 재현 가이드 — Redis 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다
해설 문서: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) ·
증거 원문: [`docs/evidence/b1-redis-session-store/`](../../evidence/b1-redis-session-store/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| **쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것** | `logs` · `printenv` |
| 그것이 `enableServiceLinks: false` 로 고쳐지는 것 | 롤아웃 성공 |
| 빈이 **321 → 402 (+81)** 로 늘어나는 것 | `/actuator/beans` |
| **그런데 authorized client 는 하나도 안 바뀐 것** | 같은 곳 |
| Redis 안의 키·필드·TTL, 그리고 **토큰이 없는 것** | `redis-cli` |
| 세션이 **Java 네이티브 직렬화**인 것 | `\xac\xed` |
| **「로그인은 되어 있는데 아무것도 못 하는」 상태** | 브라우저 |
## 전제
- [`B-0`](b0-bff-redis-deploy.md) 이 끝나 있다. **B-0 의 답(빈 세 개의 이름)을
손에 들고 시작한다** — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
- **브라우저가 필요하다.** 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep``redis-cli` 로 읽는다.
## 주의 — 이건 애플리케이션 구성을 바꾸는 실험이다
의존성과 설정을 바꿔 **다시 빌드하고 다시 배포한다.** 되돌리려면 소스 변경을
되돌리고 다시 빌드해야 하므로, **`git status` 가 깨끗한 상태에서 시작한다.**
전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.
**★ 2-3 은 일부러 고장 난 상태로 배포한다.** 함정을 직접 보기 위해서다. 건너뛰고
싶으면 [2-4](#2-4-고침--enableservicelinks-false) 부터 시작해도 결과는 같다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:5914:03 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
파드 이름·세션 ID·Service IP·TTL 은 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
B-0 이 답을 냈다. 세션도 토큰도 **인스턴스 메모리**에 있고, 그래서 replica 2 에서는
로그인조차 안 된다.
**처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.**
```
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
정말 그런가?
```
이 실험이 재는 것은 **「붙였다」와 「공유된다」 사이의 거리**다.
| | 물어볼 것 |
|---|---|
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
| **무엇이 안 옮겨졌나** | **같은 곳.** 안 바뀐 것을 확인하는 게 더 중요하다 |
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
| 사용자에게는 어떻게 보이나 | 브라우저 |
그리고 배포 첫 시도에서 **쿠버네티스가 내 설정을 덮어쓰는** 함정을 만난다.
그게 1절과 2절의 절반이다.
---
# 1. 기준선 — 붙이기 전에
넓은 것부터 좁혀 간다.
```
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
```
## 1-1. BFF 가 B-0 구성으로 돌고 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
```
**형태**
```
bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1
bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2
redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2
```
**어디를 봐야 하는가** — BFF 두 개가 **서로 다른 노드**에 있고, Redis 가 떠 있는 것.
**이 결과가 의미하는 것** — Redis 는 **배포만 되어 있고 아직 연결되지 않았다.**
B-0 이 그렇게 만들어 뒀다. 이제 연결한다.
## 1-2. B-0 의 답을 다시 확인한다 — before 값
**하기**
```bash
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
wc -c /tmp/beans-before.json
```
**확인** — 빈 수와 관련 빈 세 개. **미검증**
```bash
grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'authorizedclient|sessionRepository'
```
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
```
빈 수: 321 → 402 (+81)
```
```
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
```
**어디를 봐야 하는가** — 빈 수 **321**, `sessionRepository`**아예 없다.**
**★ 이 세 줄과 숫자를 적어 둔다.** 4-1 의 비교 대상이 이것이고, **비교 없이는
「안 바뀌었다」를 말할 수 없다.**
## 1-3. Redis 가 비어 있는지 본다
**확인** — 먼저 살아 있는지
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
```
**형태**
```
PONG
# Server
redis_version:7.4.x
...
```
**확인** — 키가 있나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**형태**
```
(integer) 0
```
**어디를 봐야 하는가****`0`.** 비어 있어야 4-2 에서 「내가 만든 것」이라고
말할 수 있다.
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 서버를 블로킹한다. 지금은 키가
> 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
## 1-4. ★ 파드 안 환경변수를 미리 본다 — 함정이 여기 있다
**아직 아무것도 안 바꿨는데** 파드 안에 Redis 관련 환경변수가 이미 있다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
```
한 번은 통째로 본다. 그다음 걸러 본다.
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
**형태**
```
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_PROTO=tcp
```
**어디를 봐야 하는가****`REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다.**
### 개념 — Service Links
**무엇인가.** 쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link
시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고,
**기본값이 켜짐**이다.
**왜 여기 나오나.** Service 이름이 `redis` 이므로 `REDIS_*` 가 들어온다.
그리고 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. **이름이 겹친다.**
```
Service 이름이 redis 이면
REDIS_SERVICE_HOST=10.43.57.116
REDIS_SERVICE_PORT=6379
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
```
**`<SVCNAME>_PORT` 는 포트 번호가 아니라 URL 형태다.**
**없거나 틀리면.** 매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그게 이긴다 —
**그런데 명시를 안 하면 자동 주입이 이긴다.** 그리고 오류 메시지는 당신이 쓰지도
않은 값을 지목한다.
**이 결과가 의미하는 것****지금은 아무 일도 안 일어난다.** 애플리케이션이
그 변수를 안 읽기 때문이다. **다음 절에서 읽기 시작하는 순간 파드가 죽는다.**
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 Service 이름일수록 위험하다.**
---
# 2. 주입 — Redis 를 붙인다
**되돌리기** — 먼저 읽어 둔다
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
deploy/lab/k8s/bff-redis.yaml
```
## 2-1. 의존성 **두 개**를 함께 넣는다
```bash
vim bff/pom.xml
```
```xml
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
둘 다 있어야 자동구성이 걸린다 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
```
**★ 하나만 넣으면 조용히 in-memory 로 남는다.** 오류도 안 난다. 그래서
4-1 에서 **찍어서 확인**하는 절차가 필요하다.
## 2-2. 설정을 넣는다
```bash
vim bff/src/main/resources/application.yml
```
```yaml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
session:
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
timeout: ${SPRING_SESSION_TIMEOUT:30m}
redis:
namespace: bff:session
```
### 문제 ② — 테스트가 Redis 를 찾다가 죽는다
`spring-session-data-redis` 를 넣으면 **컨텍스트 기동 시 Redis 에 붙으려 한다.**
테스트에는 Redis 가 없다.
```bash
vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
```
```java
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
// 테스트는 Redis 를 띄우지 않는다
"spring.session.store-type=none",
})
```
**이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다.** 그리고 그 실패 메시지는
Redis 연결 오류라서 **「배포 환경 문제」로 읽히기 쉽다.** 실패한 곳은 빌드다.
## 2-3. ★ 일부러 `enableServiceLinks` 없이 배포한다
**함정을 직접 본다.** 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서
그 오류 메시지를 만나는 것은 다르다.
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
spec:
# enableServiceLinks: false ← 아직 넣지 않는다
containers:
- name: bff
env:
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
# REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
```
**하기** — 빌드하고 두 노드에 밀어 넣고 배포한다
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
echo "exit=$?"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout restart deployment/bff
```
**되돌리기** — 2-4 가 곧 되돌리기다. 지금 멈추려면:
```bash
sudo kubectl -n keycloak-lab rollout undo deployment/bff
```
### 무엇이 일어나는지 순서대로 본다
**확인 ①** — 넓게
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**형태**
```
NAME READY STATUS RESTARTS AGE
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
```
**확인 ②** — 왜인지 물어본다. **로그보다 먼저 이벤트를 본다**
```bash
sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20
```
**확인 ③** — 로그. 죽은 뒤라면 `--previous`
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40
sudo kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
```
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
```
Failed to bind properties under 'spring.data.redis.port' to int:
Property: spring.data.redis.port
Value: "${REDIS_PORT:6379}"
Reason: failed to convert java.lang.String to int
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
```
**어디를 봐야 하는가** — 마지막 줄의 **`"tcp://10.43.57.116:6379"`.**
**이 결과가 의미하는 것****내가 쓴 적 없는 값이 오류에 나온다.**
1-4 에서 미리 본 그 환경변수다. 쿠버네티스가 넣었다.
> **이 오류를 「Redis 가 안 떠서」로 읽기 쉽다.** 실제로 Redis 는 멀쩡하다.
> **파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었다.** 메시지가
> `Failed to bind properties` 라고 말하고 있다 — 연결 오류가 아니다.
**확인** — Redis 는 멀쩡한지 확인해 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
```
**형태**
```
PONG
```
## 2-4. 고침 — `enableServiceLinks: false`
**두 가지 처방이 있다.**
| 처방 | 문제 |
|---|---|
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | **다음 사람이 같은 함정에 다시 빠진다** |
| **주입 자체를 끈다** | 근본 처방 |
```bash
vim deploy/lab/k8s/bff-redis.yaml
```
```yaml
spec:
enableServiceLinks: false # 근본 처방
containers:
- name: bff
env:
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
```
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**실측** — [`01-servicelinks-trap.txt`](../../evidence/b1-redis-session-store/01-servicelinks-trap.txt)
```
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
```
**어디를 봐야 하는가****세 줄이다.** replica 는 2인데 파드가 3개 보인다.
**롤아웃 전환 중에 찍은 것**이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다.
잠시 뒤 두 개가 된다.
**확인** — 주입이 정말 사라졌나
```bash
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
```
**형태**
```
REDIS_HOST=redis.keycloak-lab.svc
REDIS_PORT=6379
```
**어디를 봐야 하는가****`REDIS_SERVICE_HOST` 계열이 전부 사라졌고**, 내가 준
두 개만 남은 것. `REDIS_PORT``6379` 다.
## 2-5. 문제 ③ — 리소스 서버가 아예 없었다
API 호출이 `500` 이었다. **원인은 토큰이 아니었다.**
**확인** — 로그를 본다
```bash
sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
```
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
```
java.nio.channels.UnresolvedAddressException
```
**어디를 봐야 하는가****`UnresolvedAddressException`.** DNS 다.
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo`
**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다.
```yaml
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
```
**확인** — 그 서비스가 실제로 있나
```bash
sudo kubectl -n header-lab get svc echo
```
> **`500` 을 보고 「토큰이 없어서」라고 읽을 뻔했다.** 로그를 보니 DNS 였다.
> **증상과 원인을 붙이기 전에 로그를 본다.**
---
# 3. 주입이 실제로 걸렸는지 확인한다
## 3-1. 파드가 떴고 Redis 에 붙었나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/health
```
**형태**
```json
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
```
**어디를 봐야 하는가****`redis` 컴포넌트가 있고 `UP` 인 것.**
**이 결과가 의미하는 것** — B-0 에서는 이 컴포넌트가 **아예 없었다.**
`spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔다.
**건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.**
## 3-2. 로그인이 되나 — replica 2 에서
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 로 로그인한다.
**쿠키를 먼저 지운다.**
**실측** — [`b1-login-works-two-replicas.png`](../../evidence/b1-redis-session-store/b1-login-works-two-replicas.png)
**어디를 봐야 하는가****로그인이 된다.** B-0 에서 `replica 2` 로는 `/login?error`
였던 그 자리다.
**이 결과가 의미하는 것** — 인가 요청(state·PKCE verifier)이 이제 **Redis**
있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. **B-0 이 replica 를 1로
줄여야 했던 문제는 고쳐졌다.**
**여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이
막으려는 결론이다.**
---
# 4. 관찰
## 4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로
**하기**
```bash
sudo kubectl -n keycloak-lab exec "$BFF" -- \
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l
```
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
```
빈 수: 321 → 402 (+81)
```
**확인** — 세션 저장소 계열. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -iE 'session|redis'
```
**실측** — 같은 파일
```
--- 세션 저장소 관련 (새로 생긴 것) ---
★ cookieSerializer -> DefaultCookieSerializer
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
★ sessionRepository -> RedisSessionRepository
★ springSessionRepositoryFilter -> SessionRepositoryFilter
★ redisConnectionFactory -> LettuceConnectionFactory
★ redisTemplate -> RedisTemplate
```
**확인** — ★ **안 바뀐 것.** 이쪽이 핵심이다. **미검증**
```bash
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
| sed 's/{"aliases".*"type":"/ -> /' \
| grep -i authorizedclient
```
**실측** — 같은 파일
```
--- OAuth2 authorized client — 바뀌었는가? ---
authorizedClientService
before: InMemoryOAuth2AuthorizedClientService
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
authorizedClientRepository
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
authorizedClientManager
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
```
**어디를 봐야 하는가****빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.**
**이 결과가 의미하는 것**
```
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
```
**「Redis 를 붙였다」가 「상태가 공유된다」를 뜻하지 않는다.**
`spring.session.store-type`**HttpSession** 을 갈아끼우는 설정이고,
`OAuth2AuthorizedClient` 는 **그 설정과 무관한 다른 저장소**다.
> **찍어서 확인하지 않으면 이 사실을 알 방법이 없다.** 로그인은 되고, 화면도
> 뜨고, 파드도 건강하다. **B-0 을 실험으로 만든 이유가 이것이다** — before 가
> 있어야 after 를 읽는다.
## 4-2. Redis 안에 무엇이 들어갔나
**확인** — 키가 생겼나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
=== Redis 에 무엇이 들어 있는가 ===
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
총 키 수: 1
```
**어디를 봐야 하는가** — 네임스페이스가 **`bff:session`** 이다. `application.yml`
`spring.session.redis.namespace` 가 그대로 접두어가 됐다.
**하기** — 키 이름을 변수로 잡는다
```bash
KEY=$(sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
echo "$KEY"
```
**`grep -v expires` 가 필요한 이유** — Spring Session 은 만료 추적용 키
(`bff:session:expirations:*` · `bff:session:sessions:expires:*`)도 만든다.
그것을 잡으면 다음 명령이 빈 결과를 낸다.
**`tr -d '\r'`** — `redis-cli` 출력이 CR 을 달고 올 수 있다. 그대로 쓰면 키가
안 맞는데 오류는 안 난다.
**확인** — 타입과 필드
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
타입: 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
```
**어디를 봐야 하는가****필드 목록에 토큰이 없다.**
### ★ refresh token 은 Redis 에 **없다**
「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었다.
**답은 더 앞에 있었다 — 애초에 들어가지 않는다.**
**「토큰 암호화를 어떻게 할까」를 고민하기 전에, 토큰이 그 저장소에 가지도
않는다는 것을 먼저 알아야 한다.**
**확인** — TTL
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
```
**실측** — 같은 파일
```
=== TTL (Q3 검증 3번 — session TTL) ===
TTL: 1772 초
```
**어디를 봐야 하는가**`1772`. `spring.session.timeout=30m`(1800초)에서 방금
지난 만큼 줄어든 값이다.
**이 결과가 의미하는 것** — **세션 TTL 1772초와 access token 수명 60초가 처음부터
어긋나 있다.** 어느 쪽에 맞출지는 선택이 아니라 **이미 어긋나 있고 그 간극을
누가 메우는가**의 문제다. B-3 의 주제다.
## 4-3. 직렬화는 Java 네이티브다
**확인** — 값의 바이트를 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
```
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
```
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...
```
**어디를 봐야 하는가****`\xac\xed` 로 시작한다.**
> **`--no-raw` 를 쓰는 이유** — 바이너리를 이스케이프해서 보여준다. 안 쓰면
> 터미널이 제어문자를 먹고 화면이 깨진다.
**이 결과가 의미하는 것**`\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다.
| 결과 | |
|---|---|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 |
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 바뀌면
Redis 에 남은 세션이 깨질 수 있다.
## 4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분
**하기** — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다
```bash
sudo kubectl -n keycloak-lab rollout restart deployment/bff
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
**하기****로그인은 그대로 둔 채** 브라우저에서
`https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
**실측** — [`b1-token-boundary-after-redis.png`](../../evidence/b1-redis-session-store/b1-token-boundary-after-redis.png)
```json
{"pattern":"AP3-backend-for-frontend",
"principal":"labuser", Redis
"accessTokenStoredOnServer":false,
"refreshTokenStoredOnServer":false,
"browserTokenCount":0,
"csrfProtectionEnabled":true}
```
**어디를 봐야 하는가****`principal` 은 살아 있는데 두 토큰이 `false` 다.**
**이 결과가 의미하는 것**
```
사용자 관점: 로그인되어 있다고 나온다
실제: BFF 가 사용자를 대신해 아무것도 못 한다
```
**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다.
**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까.
> **이것이 「부분적으로만 공유했을 때」의 실패 모양이다.**
> **완전히 로그아웃되는 편이 차라리 낫다** — 적어도 사용자가 다시 로그인한다.
> 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
### B-0 과 나란히 놓으면
| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** |
|---|---|---|
| `principal` | labuser | labuser |
| `accessTokenStoredOnServer` | **true** | **false** |
| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** |
> **★ 스크린샷으로 시점을 구별하지 않는다.**
> [`README.md`](../../evidence/b1-redis-session-store/README.md) 가 적어 둔 대로,
> `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는
> **동일 파일**이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은
> 화면이었기 때문이다. **시점 구별은 터미널 출력과 Redis/DB 조회가 한다.**
## 4-5. 그래서 무엇을 해야 하는가
`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 **따로** 필요하다.
| 후보 | |
|---|---|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** |
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다.
세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.**
대신 세션이 커진다. **[B-2](b2-multi-instance-session.md) 에서
이 선택지를 비교한다.**
**「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고,
나뉜 채로 두면 깨진다.**
---
# 5. 복구
## 5-1. B-2 로 이어갈 것이면 그대로 둔다
이 구성이 B-2 의 출발점이다. **아무것도 안 되돌린다.**
## 5-2. B-0 상태로 되돌릴 때
**하기**
```bash
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
deploy/lab/k8s/bff-redis.yaml
git status --short
```
**★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다.** 소스만 되돌리면
클러스터에는 여전히 옛 이미지가 돈다.
```bash
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout restart deployment/bff
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
```
## 5-3. Redis 를 비운다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**되돌리기****없다.** 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부
로그아웃된다. **실험대라서 하는 일이다.**
세션 하나만 지우고 싶으면:
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
```
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
| Redis | `sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
| Keycloak | `sudo kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` |
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
| 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — |
> **이 실험이 재지 않은 것 셋**
> - **Redis 를 끊었을 때 무엇이 나는지** — B-5 의 주제다
> - **로그아웃 뒤 두 저장소에 무엇이 남는지** — B-2 로 넘긴다
> - **저장소 지연이 화면 지연으로 얼마나 번역되는지** — B-2 이후
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **파드가 `CrashLoopBackOff`, 오류에 `tcp://...:6379`** | **쿠버네티스가 `REDIS_PORT` 를 주입했다** | `printenv \| grep -i redis` — 1-4 · 2-3 |
| 위 오류를 「Redis 가 죽어서」로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
| `enableServiceLinks` 를 넣었는데 그대로 | 파드가 아직 옛 것이다 | `rollout restart``printenv` 다시 |
| 빌드가 Redis 연결 오류로 죽는다 | **테스트가 Redis 를 찾는다** | `spring.session.store-type=none` — 2-2 |
| `sessionRepository` 가 안 생긴다 | **의존성을 하나만 넣었다.** 오류 없이 in-memory 로 남는다 | 두 개 다 있는지 `pom.xml` — 2-1 |
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
| `hkeys` 가 빈 결과 | **만료 추적 키를 잡았다** | `grep -v expires` — 4-2 |
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` — 4-2 |
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` — 4-3 |
| API 호출이 `500` 인데 토큰은 멀쩡 | **DNS 다.** 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` — 2-5 |
| `/actuator/beans``Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 — 4-1 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 4-1 |
| 로그인은 되는데 API 가 전부 실패 | **이게 이 실험의 결론이다** | `token-boundary` 의 두 `false` — 4-4 |
| 스크린샷으로 시점을 구별하려다 헷갈린다 | **두 파일이 동일하다** | 터미널 출력과 Redis 조회로 구별 — 4-4 |
---
# 다음
| 실험 | B-1 이 남긴 것 |
|---|---|
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 4-5 의 세 후보를 비교한다 |
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
| [B-5](../../experiment-b5-redis-loss-persistence.md) Redis 소실 | 이제 잃을 것이 생겼다. `/data` 가 볼륨인지부터 본다 |
| [D-2](d2-version-upgrade.md) 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |
@@ -0,0 +1,869 @@
# B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
해설 문서: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) ·
증거 원문: [`docs/evidence/b2-multi-instance-session/`](../../evidence/b2-multi-instance-session/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 파드는 `1/1 Running` 인데 테이블이 없는 상태 | `psql``Did not find any relation` |
| 조회 키에 session id 가 **없다**는 것 | `\d oauth2_authorized_client``PRIMARY KEY` |
| `bytea` 안에 든 **평문 JWT** | `convert_from(refresh_token_value,'UTF8')` |
| 두 번째 로그인이 같은 행을 덮어쓰는 것 | 행 수 1 그대로 · `md5` 만 바뀜 |
| 로그아웃이 **셋 중 하나만** 지우는 것 | Redis 0 키 · PostgreSQL 1 행 · Keycloak 2 세션 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`B-0`](b0-bff-redis-deploy.md) · [`B-1`](b1-redis-session-store.md)
이 끝나 **BFF 가 replica 2개**로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- **브라우저가 필요하다.** BFF 는 authorization code 흐름이라 로그인을
`curl` 로 만들 수 없다. `https://app1.hyeonworks.com/` 에 붙어
`labuser` / `labpass` 로 들어간다. realm 은 `keycloak-patterns`.
- 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고
터미널에서 저장소를 세는 왕복이 이 실험의 전부다.
## 주의 — 이건 상태를 바꾸는 실험이다
DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. **실험대에서만 한다.**
전 구간 약 25분이고, 되돌리는 방법은 매 단계에 적어 두었다.
중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:0914:13 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 UUID·해시는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
세션 UUID·md5·타임스탬프는 **당신 환경에서 다르다.** 이 문서는 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
전부 위 실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-1 이 **Application Session 만** Redis 로 옮겼다. 그러자 사용자는 로그인
상태로 보이는데 **BFF 에는 access token 이 없는** 상태가 만들어졌다.
세션과 토큰이 서로 다른 것에 들어 있고, 한쪽만 옮겼기 때문이다.
토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 **무엇이 같이 고쳐지고
무엇이 안 고쳐지는가**다.
| | 예측 |
|---|---|
| 통념 | 공유 저장소로 옮기면 **다중 인스턴스 문제가 해결된다** |
| B-2 모델 | 인스턴스 간 공유만 해결되고 **브라우저 간 격리와 로그아웃 정리는 그대로** |
> **개념 — 「어디에 두는가」와 「어떻게 찾는가」는 독립이다.**
>
> ```
> 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
> 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
> ```
>
> 이 실험이 판정하는 것은 두 번째다. 그리고 **키는 코드가 아니라 스키마에
> 박혀 있다** — 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없다.
> 1-3 에서 그 줄을 직접 본다.
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데
**셋이 서로 다른 시스템에 있다.**
```
① HttpSession Redis Spring Security 가 지운다
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
```
---
# 1. 기준선 — 두 번째 로그인을 만들기 전에
**시험군만 재는 측정은 측정이 아니다.** 덮어쓰기를 보려면 **덮어쓰이기 전의
행**이 있어야 하고, 로그아웃 정리를 보려면 **로그아웃 전의 세 숫자**가 있어야
한다. 넓은 것부터 좁혀 간다.
```
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
```
## 1-1. 파드와 노드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태** — IP 와 해시는 당신 것과 다르다
```
NAME READY STATUS RESTARTS AGE IP NODE
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
postgres-... 1/1 Running 0 5d ... kc-lab-2
redis-... 1/1 Running 0 3d ... kc-lab-2
```
`10.42.0.52``10.42.1.124` 는 [B-5 의 증거](../../evidence/b5-redis-loss/03-health-groups.txt)
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
`nodeSelector`**`kc-lab-2` 에 고정**해 둔다.
**어디를 봐야 하는가**
- `bff` 가 **두 개**이고 `READY` 가 둘 다 `1/1`
- **`NODE` 가 서로 다르다** — 같은 노드에 몰려 있으면 「다른 인스턴스」가
같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의
`topologySpreadConstraints` 가 이걸 벌려 놓는다
- `RESTARTS``0` — 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
**이 결과가 의미하는 것** — 이 실험의 질문(Q1)은 **요청이 로그인을 처리하지
않은 인스턴스에 떨어질 수 있어서** 생긴다. replica 가 하나면 질문 자체가
성립하지 않는다.
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
```
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
```
위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.
## 1-2. 테이블이 실제로 있는가 — 없으면 여기서 멈춘다
**이 실험은 원래 여기서 한 번 넘어졌다.** 파드는 떴고 Hikari 도 붙었는데
테이블이 없었다. 그리고 **아무도 그것을 신고하지 않았다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
```
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
```
**어디를 봐야 하는가** — 이 두 줄이 나오면 **아직 아무것도 저장되지 않는
상태**다. 테이블이 있으면 컬럼 표가 나온다(1-3).
**이 결과가 의미하는 것** — 스키마 초기화가 **조용히 실패**했다.
> **개념 — `blob` 은 PostgreSQL 에 없는 타입이다.**
>
> Spring Security 는 DDL 을 **두 벌** 번들한다.
>
> | 파일 | 토큰 컬럼 타입 |
> |---|---|
> | `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
> | `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
>
> 기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 난다. 그리고
> `spring.sql.init.continue-on-error: true` 가 켜져 있으면 **그 실패가
> 삼켜지고 파드는 정상으로 보인다.**
>
> `continue-on-error` 는 **「없어도 되는 초기화」에만** 쓴다. 여기서는
> 없으면 안 되는 초기화였다.
>
> **정정 노트** — 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언
> 차이" 였다가 정정됐다. **Liquibase 가 아니다.** 여기서 스키마를 태우는
> 것은 Spring Boot 의 `spring.sql.init` 이고, DDL 은
> `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는
> Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
### 없으면 만든다 — 이건 명령이 아니라 파일이다
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 **파일로 만든다.** 터미널에
붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
**하기**
```bash
vim /tmp/oauth2-pg.sql
```
```sql
-- file: /tmp/oauth2-pg.sql
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
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)
);
```
**실측** — 위 DDL 은 [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
`=== PostgreSQL 전용 스키마 ===` 절 원문이다.
**하기** — 태운다
```bash
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
```
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
```
=== 적용 ===
CREATE TABLE
```
**되돌리기****이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다.**
정말 처음 상태로 되돌리려면:
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
```
> `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안
> 일어나고 오류도 안 난다 — `kubectl exec` 는 stdin 을 기본으로 연결하지
> 않는다.
## 1-3. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
```
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
```
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)
```
**어디를 봐야 하는가****맨 아래 `Indexes:` 줄** 하나다.
```
PRIMARY KEY, btree (client_registration_id, principal_name)
└── "keycloak" ──┘ └── "labuser" ──┘
세션 id 가 없다
```
**이 결과가 의미하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든
`(keycloak, labuser)` 라는 **한 행**을 쓴다. B-0 에서 빈 이름
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작했던 것이
**테이블 정의로 확정된다.**
**저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.**
4-1 에서 그것을 눈으로 확인한다.
## 1-4. 세 저장소를 세는 명령을 정해 둔다
4절에서 이 세 숫자를 **로그아웃 전후로** 비교한다. 지금 형태를 확정해 두고,
매번 같은 명령을 친다. 다른 명령으로 재면 비교가 아니다.
**확인 ①** — Redis 의 BFF 세션
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
```
**형태** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
의 B-1 측정과 같은 모양이다
```
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
```
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 Redis 를 잡아 두고 전 키를
> 훑는다. `--scan` 은 커서로 나눠 돌아 블로킹하지 않는다.
>
> **`dbsize` 는 이 실험에서 부정확하다.** Redis 하나를 BFF 와 oauth2-proxy
> (B-7)가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다.
> **접두어로 걸러 세는 것**이 맞다.
**확인 ②** — PostgreSQL 의 authorized client
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
```
**확인 ③** — Keycloak 의 SSO 세션. 온라인 세션도 `offline_user_session`
`offline_flag = 0` 으로 들어 있다(B-3 에서 확인된 성질이다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak \
-c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**미검증** — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다.
같은 숫자를 DB 쪽에서 보는 형태다. 관리 API 로 보려면:
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get client-session-stats -r keycloak-patterns
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
## 1-5. 브라우저로 로그인하고 대조군 행을 잡는다
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 열고
**Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간다. 그다음
**token 경계 확인** 을 누른다.
**실측** — 해설 문서 3절
```json
{"principal":"labuser",
"accessTokenStoredOnServer":true, B-1 false
"refreshTokenStoredOnServer":true,
"browserTokenCount":0}
```
**어디를 봐야 하는가**`accessTokenStoredOnServer``true`.
`browserTokenCount``0` 인 것이 BFF 패턴의 정의다 — **토큰이 브라우저에
없다.**
**이 결과가 의미하는 것** — B-1 에서는 이 값이 `false` 로 나올 수 있었다.
authorized client 가 프로세스 메모리에 있어 **로그인을 처리하지 않은 replica**
가 답하면 아무것도 못 찾았기 때문이다. 지금은 두 replica 가 같은 PostgreSQL
행을 본다.
> 이 값이 지금도 `false` 로 나온다면 **테이블은 만들었는데 옛 세션을 쓰고
> 있는 것**이다. 증거의 [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png)
> 가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
**확인** — 지금 행을 잡아 둔다. **이 md5 와 `issued_at` 이 대조군이다**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_issued_at,
md5(access_token_value) as at_md5
from oauth2_authorized_client"
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== [현재] 같은 사용자의 항목 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)
행 수: 1
```
**어디를 봐야 하는가**`(1 row)``at_md5`. **둘 다 종이에 적어 둔다.**
2절 뒤에 이 두 값을 다시 본다.
> **왜 토큰 값이 아니라 md5 인가.** 값 자체는 **지금 쓸 수 있는 자격증명**
> 이라 터미널 스크롤백에 남기면 안 된다. md5 는 「같은가 다른가」만 답하고
> 그게 이 절이 물어보는 전부다.
크기도 같이 봐 둔다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_type,
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
from oauth2_authorized_client"
```
**실측** — 해설 문서 3절. (이 표는 `.txt` 증거에는 없고 문서에만 남아 있다)
```
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1431 | 744
```
---
# 2. 주입 — 「두 번째 브라우저」를 만든다
여기부터 상태가 바뀐다. **되돌리는 방법을 먼저 읽어 둔다.**
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 로그인하면
새 세션이 만들어지고 원래 상태로 돌아온다.
## 2-1. 실제로는 브라우저를 두 개 쓰지 않는다 — 왜 등가인가
증거 [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
에는 이렇게 적혀 있다.
**실측**
```
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
```
**「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
이었다.** 조회 키가 `(clientRegistrationId, principalName)` 이므로
**브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가**다.
> **다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다.**
> 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, **측정하지 않은 것을
> 측정한 것처럼 적었다**고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면
> 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야
> 하고, 다르면 그게 더 중요한 발견이다.
## 2-2. Redis 의 BFF 세션만 지운다
**지우기 전에 무엇을 지울지 눈으로 본다.** 이 Redis 는 BFF 혼자 쓰는 것이
아니다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**형태**
```
bff:session:sessions:c63c39ee-...
bff:session:expires:c63c39ee-...
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
**어디를 봐야 하는가**`_oauth2_proxy-` 로 시작하는 키가 섞여 있는지.
있으면 **`FLUSHALL` 을 치면 안 된다** — B-7 의 oauth2-proxy 세션까지 날아가
그쪽 실험이 오염된다. 접두어로 골라 지운다.
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
date '+%H:%M:%S 세션 삭제'
```
**미검증** — 원래 실행은 스크립트였다. 이 형태는
[후속 문서 §3](../../experiment-followup-untested-items.md) 이 oauth2-proxy
세션을 지울 때 쓴 것과 같은 모양이다.
**형태**
```
(integer) 2
16:21:03 세션 삭제
```
**시각을 적어 둔다.** 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로
「새 로그인이 실제로 일어났는가」를 판정한다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. Redis 에서 BFF 세션만 사라졌나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
**어디를 봐야 하는가** — 첫 명령은 **아무것도 안 나와야** 하고, 두 번째는
**아까와 같아야** 한다. 두 번째까지 비었으면 `FLUSHALL` 을 친 것이고,
B-7 세션을 날린 것이다.
## 3-2. 파드를 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**어디를 봐야 하는가****`RESTARTS` 가 여전히 0.** 세션을 지우는 것은
BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고,
그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.
## 3-3. 다음 요청이 정말 새 로그인을 만드는가
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고
**token 경계 확인** 을 누른다.
**어디를 봐야 하는가****로그인 화면이 뜨지 않고 그냥 들어가진다.**
**이 결과가 의미하는 것** — Redis 세션은 지워졌지만 **Keycloak SSO 세션은
살아 있다.** 그래서 BFF 가 `/oauth2/authorization/keycloak` 으로 보내면
Keycloak 이 화면 없이 즉시 코드를 돌려주고, **새 로그인 한 벌이 조용히
만들어진다.** 이것이 「모의 두 번째 브라우저」다.
> **이 조용한 재인증이 6절에서 다시 나온다.** 여기서는 편리하지만
> 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다.
> **같은 성질의 양면**이다.
---
# 4. 효과를 관찰한다
## 4-1. 행이 늘었는가, 덮어써졌는가
**확인** — 1-5 와 **똑같은 명령**을 친다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select client_registration_id, principal_name, access_token_issued_at,
md5(access_token_value) as at_md5
from oauth2_authorized_client"
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
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 가 바뀌었으면 → 덮어쓰기다
```
**어디를 봐야 하는가** — 세 가지를 **한꺼번에** 본다.
| 값 | 대조군(1-5) | 지금 | 읽는 법 |
|---|---|---|---|
| 행 수 | `(1 row)` | `(1 row)` | **INSERT 가 아니다** |
| `at_md5` | `675af228…` | `e19a63fc…` | **내용은 바뀌었다** |
| `issued_at` | `05:10:46` | `05:12:13` | 2-2 의 삭제 시각 뒤 = 새 로그인 맞다 |
셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면
「새 행이 생겼나?」로 읽힌다.
**이 결과가 의미하는 것****UPDATE 다.**
```
브라우저 A 로그인 → (keycloak, labuser) 행 생성
브라우저 B 로그인 → 같은 행을 덮어쓴다
└─ A 의 토큰은 사라진다
```
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
당장은 아무 증상이 없다. 증상은 나중에 나온다.
| 언제 문제가 되는가 | |
|---|---|
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
| refresh 회전이 켜져 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → [B-3](b3-refresh-token-contention.md) |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
## 4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
**1-3 의 `PRIMARY KEY` 줄을 다시 본다.** 그 줄이 답이다.
```
InMemory → PostgreSQL → Redis → 직접 구현
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
```
**고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
`OAuth2AuthorizedClientRepository` 쪽 이야기다.**
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
| `JdbcOAuth2AuthorizedClientService` | **불필요** (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
**이 실험이 두 번째를 고르지 않은 이유**는 Q3 가 "Redis 와 JDBC 중 무엇"을
물었기 때문이고, 그 대가로 조회 키 문제가 남았다. **선택이 남긴 자국을
측정한 것**이지 실수가 아니다.
## 4-3. 저장된 것이 평문인가
**먼저 길이만 본다.** 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select length(refresh_token_value) from oauth2_authorized_client"
```
**실측** — 해설 문서 3절의 `rt_len`
```
744
```
**744 바이트다.** 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
```
**미검증** — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 이 형태는 **화면에
남는 양을 줄인** 것이다.
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
의 앞부분(원래 실행이 찍은 길이 그대로)
```
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
```
**어디를 봐야 하는가****`eyJ` 로 시작한다.** 그것이 `{"` 의 base64 다.
JWT 는 예외 없이 이렇게 시작한다.
> **`convert_from` 이 성공한다는 것 자체가 답이다.** 암호화된 바이트라면
> UTF-8 로 디코드되지 않고 오류가 난다. **읽힌다 = 텍스트다.**
정말 JWT 인지 헤더를 풀어 본다.
**확인****미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
```
=== 저장된 바이트를 그대로 디코드한 결과 ===
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 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
```
**이 결과가 의미하는 것** — **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는
토큰을 얻는다.** 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든
그대로 쓸 수 있다. `Spring Security 기본 구현은 저장 시 암호화하지 않는다.`
암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
> **원래 실행은 여기서 한 번 넘어졌다.** 증거 파일에 그 실패가 그대로 있다.
> ```
> === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
> File "<string>", line 3
> h=open(/tmp/hdr.txt).read().strip()
> ^
> SyntaxError: invalid syntax
> ```
> **파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것**이다. 셸 안에
> 프로그램을 밀어 넣으면 이렇게 된다 — 문법 오류가 측정 결과 자리에
> 남는다. `cut` 과 `base64 -d` 로 충분하고, 그건 문법이 틀릴 자리가 없다.
## 4-4. 로그아웃 — 세 저장소를 한 번에 센다
**로그아웃 전에 세 숫자를 먼저 잡는다.** 1-4 에서 정한 명령 그대로다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
```
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
```
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
```
**하기** — 로그아웃한다. **화면에 로그아웃 버튼이 없다**
`index.html` 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다
(`F12` → Console, 로그인된 app1 탭에서).
**미검증**
```js
const csrf = await (await fetch('/bff/csrf')).json();
const token = decodeURIComponent(
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
console.log(r.status, r.url);
```
> **왜 셸이 아니라 브라우저인가** — 세션 쿠키가 `HttpOnly` 라 `curl` 로
> 로그인 상태를 재현할 수 없다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게
> 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`), 그래서 이
> 조각이 성립한다. 해설 문서 8절은 같은 일을 **form 파라미터 `_csrf`** 로
> 적었다 — 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
**되돌리기** — 브라우저에서 다시 로그인한다.
**확인** — 로그아웃 후, **같은 세 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select principal_name, access_token_issued_at, access_token_expires_at
from oauth2_authorized_client"
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**실측** — [`05-logout-cleanup.txt`](../../evidence/b2-multi-instance-session/05-logout-cleanup.txt)
```
=== 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
```
**어디를 봐야 하는가** — 세 숫자를 나란히 놓는다.
```
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
```
**이 결과가 의미하는 것****셋 중 하나만 지워졌다.**
```
로그아웃
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
```
**남은 행의 `access_token_expires_at``issued_at` 의 60초 뒤**인 것도 같이
본다(`accessTokenLifespan=60`). **access token 은 이미 만료됐지만 같은 행의
refresh token 은 아직 살아 있다** — 그리고 그건 4-3 에서 본 대로 평문이다.
## 4-5. 「로그아웃했는데 다시 들어가진다」
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 다시 연다.
**어디를 봐야 하는가****로그인 화면이 안 뜨고 그냥 들어가진다.**
3-3 에서 본 것과 같은 조용한 재인증이다.
**이 결과가 의미하는 것** — 애플리케이션 세션은 지웠는데 **IdP 세션은 그대로**
이므로 IdP 가 화면 없이 새 세션을 만들어 준다. 사용자 입장에서는
**로그아웃이 안 된 것**이다.
| 필요한 것 | 방법 |
|---|---|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
| Keycloak 세션 종료 | **RP-initiated logout**`OidcClientInitiatedLogoutSuccessHandler` |
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.**
---
# 5. 복구
## 5-1. 남은 행을 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"delete from oauth2_authorized_client where principal_name = 'labuser'"
```
**형태**
```
DELETE 1
```
**되돌리기** — 브라우저에서 다시 로그인하면 행이 다시 만들어진다.
**표 자체는 지우지 않는다** — B-3 이 이 표를 쓴다.
## 5-2. Keycloak SSO 세션을 끊는다
**하기** — 브라우저에서 아래 주소를 연다. RP 가 안 보내 주니 사람이 직접 간다.
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
**미검증** — 이 실험은 여기까지 재지 않았다. 확인 화면이 뜨면 승인한다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**어디를 봐야 하는가**`offline_flag = 0` 의 개수가 줄어드는지.
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.**
0 이 안 되어도 놀랄 일이 아니다.
## 5-3. Redis 세션을 되돌린다
지운 세션은 되돌아오지 않는다. **브라우저에서 다시 로그인하는 것이 복구다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 표 | `sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
| **B-7 세션** | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | **2절 전과 같아야 한다** |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 **조용히 실패**했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 — 1-2 |
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
| `token-boundary` 가 계속 `false` | 테이블은 만들었는데 **옛 세션**을 쓰고 있다 | 로그아웃 후 재로그인 — [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png) 가 그 상태다 |
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec`**`-i` 가 없다** | `exec -i deploy/postgres` |
| 행 수가 2 로 늘었다 | principal 이 다르다(다른 사용자로 로그인) | `select principal_name from oauth2_authorized_client` |
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
| B-7 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 2-2 |
| 파이썬 한 줄로 디코드하다 `SyntaxError` | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)) | `cut -d. -f1 \| base64 -d` 로 충분하다 |
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf``headerName` 을 그대로 쓴다 |
| 로그아웃했는데 다시 들어가진다 | **버그가 아니다.** Keycloak SSO 세션이 살아 있다 | 4-5 · 5-2 |
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
---
# 왜 이 가이드에는 스크린샷 대신 숫자가 있나
증거의 [`b2-tokens-shared-across-instances.png`](../../evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
는 **B-0 의 `b0-bff-token-boundary.png` 와 동일 파일**이다(md5 `9ed00537…`).
두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다.
**그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지
못한다.** 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 —
이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
> **같은 화면은 같은 증거가 아니다.** 화면이 같아도 그 아래 상태는 다를 수
> 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
---
# 다음
| 실험 | B-2 가 남긴 질문 |
|---|---|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 **두 브라우저가 같은 refresh token 을 다툰다** |
| [B-4](b4-edge-authorization-scope.md) Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 **다른 얼굴**로 나온다 |
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 **각각 죽여볼 수 있다** |
| 코드 | **평문 refresh token****로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
@@ -0,0 +1,835 @@
# B-3 재현 가이드 — 같은 refresh token 을 다섯 번 동시에 던지고 세션이 사라지는 것을 본다
해설 문서: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md) ·
증거 원문: [`docs/evidence/b3-refresh-contention/`](../../evidence/b3-refresh-contention/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 다섯 중 하나만 `200` 이고 나머지는 `400` | 파드 안 `curl` · `&``wait` |
| 오류 메시지가 **두 종류**인 것 | `Maximum allowed refresh token reuse exceeded` / `Session doesn't have required client` |
| **이긴 요청이 받은 토큰조차 못 쓰는 것** | 그 토큰으로 한 번 더 갱신 → `400` |
| user session 은 남고 **client session 만 사라진** 것 | PostgreSQL, 정상 세션과 나란히 |
| `refreshTokenMaxReuse` 를 올려도 안 되는 것 | 구성 A/B/C 비교 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`B-2`](b2-multi-instance-session.md) 가 끝나 있다. **토큰이 공유되어야
경쟁이 성립한다** — 다만 이 실험은 Keycloak 쪽 동작만 분리해 보려고
**BFF 를 거치지 않고** 토큰 엔드포인트를 직접 친다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **브라우저는 필요 없다.** direct grant(`grant_type=password`)로 토큰을
만들므로 전 구간을 터미널에서 한다.
- realm 은 `keycloak-patterns`, 사용자는 `labuser` / `labpass`,
클라이언트는 `bff-confidential`.
## 주의 — 이건 realm 설정을 바꾸는 실험이다
`revokeRefreshToken` 을 켠다. **realm 전체에 걸린다** — 그 realm 을 쓰는 다른
실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. **실험대에서만 한다.**
전 구간 약 20분이고, 되돌리는 명령은 [2-2](#2-2-적용) 와 [5-1](#5-1-realm-설정을-되돌린다)
에 있다. 중간에 그만두려면 5-1 의 한 줄이면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:1614:17 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 sid·길이는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
sid·토큰 길이는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위
실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-2 가 토큰을 PostgreSQL 로 옮겼다. 그래서 **두 replica 가 같은 행을 본다.**
그리고 조회 키에 session id 가 없으니 **같은 사용자의 두 브라우저도 같은 행을
본다.** 그 행에는 refresh token 이 하나 들어 있다.
**둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.**
| | 예측 |
|---|---|
| 통념 | **하나는 성공하고 하나는 실패한다.** 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다 |
| B-3 이 재는 것 | 진짜 그런가. **그리고 이긴 쪽은 멀쩡한가** |
이 구별이 설계를 가른다.
```
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
```
**재시도로 회복되면 lock 이 필요 없고, 회복 안 되면 lock 말고 답이 없다.**
그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시
쓸 수 있나」**다. 4-4 가 그 자리다.
> **개념 — 재사용 탐지(reuse detection)란 무엇인가.**
>
> 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화한다. 그런데
> 무효화된 옛 토큰이 **다시 들어오면** 두 가지 중 하나다.
>
> ```
> ① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
> ② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
> ```
>
> **서버는 둘을 구별할 수 없다.** 그래서 OAuth 2.0 보안 권고는 **안전한
> 쪽으로 가정하고 세션 전체를 무효화**하라고 말한다. 이 실험이 보는
> 파괴는 **버그가 아니라 그 규격이 시키는 대로 동작한 결과**다.
> 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.
---
# 1. 기준선 — 회전을 켜기 전에
**시험군만 재는 측정은 측정이 아니다.** 회전이 꺼진 상태에서 같은 명령을
먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」
때문인지 구별된다.
```
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
```
## 1-1. 파드가 정상인가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
postgres-... 1/1 Running 0 5d ... kc-lab-2
```
**어디를 봐야 하는가** — Keycloak 이 **둘 다** `1/1`. 하나가 NotReady 면
Service 가 전부 한쪽으로 보내고, 그러면 **동시성이 한 노드 안에서만** 생긴다.
이 실험은 그래도 재현되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.
## 1-2. realm 이 지금 무엇으로 설정되어 있나
kcadm 은 먼저 로그인해야 쓸 수 있다. **한 번 하면 파드 안에 세션이 남는다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
**확인**
```bash
sudo 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 }
```
**어디를 봐야 하는가** — 세 값 전부.
| 값 | 뜻 | 지금 |
|---|---|---|
| `revokeRefreshToken` | **회전 스위치** | `false`**꺼져 있다** |
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
| `accessTokenLifespan` | access token 수명(초) | `60` |
**이 결과가 의미하는 것****기본값은 회전이 꺼져 있다.** Q2 는
*"realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서"*
전제로 하므로, **그 전제를 만드는 것이 이 실험의 주입**이다. 지금 그대로
재면 Q2 와 다른 것을 재게 된다.
`accessTokenLifespan=60` 은 B-0 에서 **이 실험을 위해** 넣어 둔 값이다.
만료를 기다리는 시간이 짧아야 재현이 된다.
## 1-3. 상주 탐침 파드를 띄운다
Keycloak 이미지에는 `curl``wget` 도 없다(`exit 127`). 그리고 이 실험은
**토큰을 단계 사이로 넘겨야** 한다 — 발급받은 토큰을 뒤 단계에서 써야 하므로
`--rm` 임시 파드로는 안 된다. **파드를 하나 띄워 두고 `exec` 로 이어간다.**
**하기**
```bash
sudo kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
--env="CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
```
**형태**
```
pod/b3-probe condition met
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
**확인** — 환경변수가 들어갔나. **값이 아니라 길이만 본다**
```bash
sudo kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
```
**형태**
```
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
```
`CS길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
> **왜 Service 로 가는가.** A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드
> IP 로 직접 쳤다. 여기는 반대다 — **replica 를 넘는 경쟁**이 질문이므로
> Service 가 요청을 흩는 것이 오히려 필요한 조건이다.
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
```bash
sudo kubectl -n keycloak-lab exec -it b3-probe -- sh
```
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit`**파드는 안 지워진다**
(`--rm` 이 없다).
## 1-4. 토큰 하나를 발급받고 sid 를 뽑는다
**하기 ①** — 파드 안에서. **처음 한 번은 응답을 통째로 본다**
```sh
curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
```
**형태** — 한 줄 JSON 이 나온다
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
```
**어디를 봐야 하는가**`expires_in` 이 60. 1-2 에서 본 `accessTokenLifespan`
그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct
grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름/비밀번호다.
**하기 ②** — 변수에 담고 sid 를 뽑는다
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
```
=== [1] refresh token 하나 확보 ===
토큰 길이: 811
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
sid: BvFiB01Rntz1FcLdf7zG4BNt
```
**어디를 봐야 하는가****`SID` 를 종이에 적어 둔다.** 4-5 에서 DB 를 뒤질 때
이 값이 필요하고, 그때는 **파드 밖**이라 변수가 안 넘어간다.
> `sid` 가 빈 줄이면 base64 패딩 때문이다. `sed 's/$/==/'` 가 그 보정이고,
> 그래도 안 나오면 **base64url 문자(`-` `_`)** 때문일 수 있다. 그때는:
> ```sh
> echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
> | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
> ```
> **미검증** — 원래 실행은 위쪽 형태를 썼다. 페이로드 전체가 나오면 그 안에서
> `"sid"` 를 눈으로 찾는다.
## 1-5. 대조군 — 지금은 무엇이 되는가
### ① 순차로 다섯 번 갱신한다
**하기** — 파드 안에서. `&` 없이, 한 번에 하나씩
```sh
for i in 1 2 3 4 5; do
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$RT")
echo "순차 $i: $(echo "$R" | tail -1)"
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
done
```
**미검증** — 증거 파일에는 순차 실행 기록이 없다. 해설 문서는
**"순차 실행이면 재현되지 않는다"** 고 말하며, 이 절은 그것을 당신 손으로
확인하는 자리다.
**어디를 봐야 하는가** — 다섯 줄 전부 `200` 이어야 한다.
> **회전이 켜지면 `RT` 를 매번 다시 담아야 한다.** 위 루프가 그렇게 되어
> 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「내가 옛
> 토큰을 썼기」 때문인지 구별이 안 된다. **이 실험에서 가장 흔한 자기오염이다.**
### ② 경쟁을 겪지 않은 세션은 어떻게 생겼나
4-5 에서 볼 DB 모양을 **지금 미리 본다.** 이게 없으면 나중에 나오는 `0`
「경쟁 때문」인지 「원래 그런 표」인지 모른다.
**확인****파드 밖**(kc-lab-1)에서. 아래의 sid 자리에는 **1-4 에서 적어 둔
당신의 `SID`** 를 넣는다. 여기 실린 값은 원래 실행의 대조군 세션 것이다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(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 = 'JT-XuepgutWcE273QwAnIXta'"
```
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
의 대조군 부분
```
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
```
**어디를 봐야 하는가****`client_sessions = 1`.** 정상 세션은 이렇게 생겼다.
> **개념 — user session 과 client session 은 다른 것이다.**
>
> ```
> user session "이 브라우저는 labuser 로 로그인함"
> ├─ client session : bff-confidential
> └─ client session : oauth2-proxy
> ```
>
> 사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 **user session 하나
> 아래에 client session 이 여럿** 달린다. 그게 SSO 다.
> **재사용 탐지는 이 중 client session 만 제거한다** — 4-5 에서 그것을 본다.
>
> 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데,
> `offline_flag` 열이 그것을 가른다. 위 출력의 `offline_flag = 0` 이
> 「온라인 세션」이다.
---
# 2. 주입 — 회전을 켠다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
```
## 2-1. 무엇을 켜는가 — 이름이 헷갈린다
> **개념 — `revokeRefreshToken` 이 회전 스위치다.**
>
> 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을
> 줄 때 **옛 토큰을 무효화**한다. 그 결과가 회전이다.
>
> | 설정 | 뜻 |
> |---|---|
> | `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
> | `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** |
>
> **`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다.**
> 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니
> 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 이유다.
## 2-2. 적용
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 켬'
```
**형태** — 성공하면 아무 말도 안 한다(무소식이 좋은 소식이다)
```
14:16:12 회전 켬
```
**시각을 적어 둔다.** 4절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다.
**켜기 전에 발급한 토큰으로 재면 안 된다** — 발급 시점의 정책이 아니라 검증
시점의 정책이 적용되므로 섞여서 해석이 안 된다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. 설정이 실제로 바뀌었나
**확인** — 1-2 와 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**형태**
```json
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**어디를 봐야 하는가**`revokeRefreshToken``true`. `false` 그대로면
`update` 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은
**실패해도 조용할 때가 있다** — 반드시 다시 읽어서 확인한다.
## 3-2. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=keycloak
```
**어디를 봐야 하는가****`RESTARTS` 가 여전히 0.** realm 설정 변경은 재시작을
일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로
재면 「경쟁」이 아니라 「재시작」을 재게 된다.
## 3-3. 회전이 실제로 동작하는가 — 한 번만 갱신해 본다
**동시성을 넣기 전에, 회전 자체가 도는지 확인한다.**
**하기** — 파드 안에서. 새 토큰을 하나 받고, **한 번 갱신한 뒤 옛 것을 다시 쓴다**
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
```
**미검증** — 이 절은 이 가이드가 덧붙인 사전 확인이다. 증거 파일에는 없다.
**어디를 봐야 하는가****1회차 `200`, 2회차 `400`.**
**이 결과가 의미하는 것** — 옛 토큰이 무효화된다 = 회전이 켜졌다.
2회차도 `200` 이면 **회전이 안 켜진 것**이고, 그 상태로 4절을 돌리면 다섯 개가
전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.
---
# 4. 효과를 관찰한다
## 4-1. 깨끗한 토큰을 하나 새로 받는다
3-3 에서 쓴 토큰은 이미 무효다. **새로 시작한다.**
**하기** — 파드 안에서
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
**`SID` 를 다시 적어 둔다.** 4-5 에서 쓴다.
## 4-2. ★ 동시에 다섯 개 — `&` 와 `wait` 이 없으면 재현되지 않는다
**이 절이 이 실험의 전부다.** 순차로 돌리면 아무 일도 안 일어난다(1-5 에서
직접 봤다). 진짜로 겹쳐야 한다.
**하기** — 파드 안에서
```sh
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=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
i=$((i+1))
done
wait
for i in 1 2 3 4 5; do
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
done
```
**미검증** — 원래 실행은 스크립트였다. 이 형태는 손으로 치기 좋게 고친 것이고,
**본문과 응답 코드를 파일로 갈라 놓아 순서대로 다시 읽을 수 있게** 했다.
다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없다 —
그래서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
**어디를 봐야 하는가** — 셸 문법 세 조각이 전부다.
```
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
wait 띄운 것이 전부 끝날 때까지 기다린다
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
```
**`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다.**
`wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다.
## 4-3. 결과 — 오류가 두 종류다
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
```
=== [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":"...(발급됨)
```
**어디를 봐야 하는가****성공 개수가 아니라 오류 메시지가 두 종류인 것.**
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
**이 결과가 의미하는 것** — 만약 「하나만 이기고 나머지는 진다」였다면 지는
쪽 메시지는 **전부 같아야** 한다. 두 종류라는 것은 **중간에 상태가 바뀌었다**
는 뜻이다. 그 바뀐 상태가 무엇인지가 4-5 다.
> 성공한 번호는 당신 환경에서 다르다. 증거에서는 5번이었지만 순서는
> 스케줄링에 달렸다. **몇 번이 이겼는가는 아무 의미가 없다.**
## 4-4. ★ 이긴 요청의 토큰을 다시 써 본다 — 여기서 진짜 답이 나온다
**하기** — 파드 안에서. 다섯 응답 중 `refresh_token` 이 들어 있는 것을 꺼낸다
```sh
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
echo "새 refresh token 길이: ${#NEW}"
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$NEW"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
```
**어디를 봐야 하는가****`400`.** 그리고 메시지가
`Session doesn't have required client`.
**이 결과가 의미하는 것****이긴 요청조차 쓸 수 없는 토큰을 받았다.**
```
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
```
**오류가 지연되어 나타난다.** 200 을 받은 코드는 성공했다고 믿고 토큰을
저장한다. 끊긴 것은 **그다음 요청에서** 안다. 로그를 볼 때 원인 시각과 증상
시각이 어긋나 보이는 이유가 이것이다.
> **여기서 「재시도하면 되지 않나」가 무너진다.** 새 토큰을 다시 읽어
> 재시도해도 **그 토큰이 이미 무효**다. 재시도할 대상이 없다.
## 4-5. 기제 확정 — 무엇이 사라졌는가
**확인** — **파드 밖**에서. `SID` 는 4-1 에서 적어 둔 값이다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag, us.last_session_refresh
from offline_user_session us
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
```
**어디를 봐야 하는가****행이 남아 있다.** 세션이 통째로 지워진 것이 아니다.
그러면 왜 `Session doesn't have required client` 인가. **client session 을 센다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(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 = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
```
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
```
**어디를 봐야 하는가****`client_sessions = 0`.** 1-5 의 대조군은 `1` 이었다.
**같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.**
**이 결과가 의미하는 것**
```
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
```
그래서 오류 문구가 정확히 그 말을 한다 — **세션은 있는데 그 클라이언트 몫이
없다.** 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.
## 4-6. 폐기 목록에 실린 것이 아니다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select count(*) as revoked_count from revoked_token"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
```
**어디를 봐야 하는가****`0`.**
**이 결과가 의미하는 것** — 「토큰을 블랙리스트에 올려서 막는다」가 아니다.
**client session 이 사라져서 검증할 대상이 없어진 것**이다. 토큰을 지우는
방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 **그 client 에 대한
모든 토큰이 한꺼번에 죽는다.** 4-4 의 결과가 그것이다.
## 4-7. 시간선 — 왜 이긴 쪽도 죽는가
```
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
**t3 와 t4 의 순서가 전부다.** 응답을 만들던 요청은 이미 「성공」이 확정된
상태로 나가고, 그 사이 바닥이 빠진다.
## 4-8. 정책을 바꿔 비교한다
**한 번 더 재기 전에 세션을 새로 만든다.** 파괴된 세션으로 재면 전부 400 이다.
### 구성 B — 회전 OFF
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false
```
파드 안에서 **4-1 → 4-2 → 4-4 → 4-5 를 그대로 반복**한다.
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
```
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
**어디를 봐야 하는가****전부 200 이고 세션도 멀쩡하다.**
**이 결과가 의미하는 것** — 같은 refresh token 을 계속 쓸 수 있으므로
**경쟁 자체가 성립하지 않는다.** 대신 잃는 것 — 토큰이 유출되면 **만료까지
계속 쓸 수 있다.** 회전의 목적이 그 창을 좁히는 것이었다.
### 구성 C — 회전 ON · 재사용 1회 허용
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
```
다시 반복한다.
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
```
=== 구성 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
```
**어디를 봐야 하는가** — 성공이 1에서 2로 늘었지만 **`남은 client_session: 0`**
은 그대로다.
### 세 구성을 나란히
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| **A** 회전 ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
| **B** 회전 OFF | **5 / 5** | 200 | **1 — 생존** |
| **C** 회전 ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 **회전의 보안
> 목적이 사라진다.** 값을 올려 버티려는 시도는 "몇 개까지 동시에 올
> 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
**그래서 답은 lock 이다.** 그리고 lock 은 **저장소 쪽**에 있어야 한다 —
프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
| 후보 | |
|---|---|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE`**A-0 에서 Keycloak 자신이 쓰는 방식** |
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
**잠금의 수명이 연결의 수명과 묶이는 것**이 DB 잠금의 이점이다. 프로세스가
죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면
**중복 갱신**, 길면 **정지**다 — 그 약점은 [B-5](b5-redis-loss-persistence.md)
에서 다시 만난다.
---
# 5. 복구
## 5-1. realm 설정을 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 끔'
```
**확인** — 1-2 와 똑같은 명령으로 다시 읽는다
```bash
sudo 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 }
```
**1-2 의 실측과 세 값이 전부 같아야 한다.** `accessTokenLifespan` 이 60 이
아니면 다른 것도 건드린 것이다.
## 5-2. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
## 5-3. 실험이 만든 세션을 정리한다
파괴된 세션의 `user_session_id` 행은 그대로 남는다. **TTL 로 스스로 사라지지만**
바로 치우고 싶으면 브라우저에서 아래를 연다.
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
**미검증** — 이 실험은 여기까지 재지 않았다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.** 0 이 안 되어도
놀랄 일이 아니다.
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| realm | 위 5-1 의 `get realms/...` | `revokeRefreshToken : false` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 탐침 | `sudo kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **다섯 개가 전부 `200`** | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &``wait` 이 있는지 — 4-2 |
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 3-1 로 다시 읽는다. 3-3 이 `200/400` 이어야 한다 |
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 — 4-2 |
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 — 1-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 — 1-3 |
| `CS길이=0` | secret 이름/키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 — 1-2 |
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 — 1-4 |
| 4-5 에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 5-1 |
---
# 왜 이 가이드에는 부하 도구가 없나
동시성 5는 `ab``k6` 도 필요 없다. **셸의 `&``wait` 이면 충분하고,
그 편이 무엇이 일어났는지 더 잘 보인다** — 요청 다섯 개의 본문을 전부 파일로
갖고 있으니 나중에 다시 읽을 수 있다.
부하 도구는 **개수를 늘려야 할 때** 쓴다. 이 실험이 묻는 것은 개수가 아니라
**「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다.
> **재현에 필요한 최소치를 찾는 것이 실험 설계다.** 다섯 개를 쓴 이유는
> 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지, 다섯이 필요해서가
> 아니다.
---
# 다음
| 실험 | B-3 이 남긴 질문 |
|---|---|
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다.** 그 약점을 직접 본다 |
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **왜 두 브라우저가 같은 토큰을 다투는가** — 조회 키가 원인이다 |
| A-6 지연 주입 | 거기서 낙관적 락 충돌이 0 이었던 이유가 여기서 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **같은 항목을 갱신할 때**다 |
| 설계 | **재시도로 회복되지 않는다 → lock.** 그리고 lock 은 저장소 쪽, 가급적 DB 행 잠금 |
@@ -0,0 +1,913 @@
# B-4 재현 가이드 — 신원 헤더를 위조해 보내고 그대로 도착하는 것을 본다
해설 문서: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md) ·
증거 원문: [`docs/evidence/b4-edge-authorization/`](../../evidence/b4-edge-authorization/) ·
③ 반영 시점: [후속 문서 §3](../../experiment-followup-untested-items.md) ·
[`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 동명 헤더 두 개가 **둘 다 도착**하는 것 | `curl -H … -H …` · echo 응답 |
| 값 안의 쉼표를 구분자와 **구별할 수 없는** 것 | 같은 자리 |
| 8KB 에서 `400`, 16KB 에서 **응답 자체가 없는** 것 | 크기 훑기 |
| 인증 없이 보낸 **위조 신원**이 그대로 도착하는 것 | 같은 자리 |
| JWT 를 요구하는 경로는 `401` 인 것 | 대조군 |
| IdP 에서 값을 바꿔도 **12회 요청 동안 옛 값**인 것 | 브라우저 콘솔 (쿠키가 HttpOnly 라 curl 로 못 한다) |
## 전제
- [`03-nginx`](../03-nginx/) · [`04-tls`](../04-tls/) · [`05-keycloak`](../05-keycloak/)
가 끝나 있다.
- `app1.hyeonworks.com`**경로에 따라 둘로 갈린다.** `/` 는 BFF,
**`/api``header-lab` 네임스페이스의 echo 앱**이다. 이 실험은 `/api/echo`
만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.
- 4절부터는 **`app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다.** 인증서가
`auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다.
**끝나면 되돌린다** — [5-4](#5-4-grafana-ingress-를-되돌린다).
- 4절은 **브라우저가 필요하다.** oauth2-proxy 세션 쿠키가 `HttpOnly`
`curl` 로 로그인 상태를 재현할 수 없다. 이유는 [4-1](#4-1--왜-curl-로-못-하는가).
- `kubectl`**`kc-lab-1` 에서 `sudo`** 로 친다. 5-1 의 nginx 설정만
**랩 호스트(`test-server`)** 에서 한다 — 다른 기계다.
- 앞의 `curl` 은 어디서 쳐도 된다. 밖에서 치는 편이 「공격자 관점」에 가깝다.
## 주의 — 앞부분은 안전하고 뒷부분은 상태를 바꾼다
| 절 | 무엇을 하나 | 되돌릴 것 |
|---|---|---|
| 1~3 | **요청만 보낸다.** 클러스터 상태가 안 바뀐다 | 없음 |
| 4 | Grafana 에서 app2 를 빌리고 **IdP 의 사용자 속성을 바꾼다** | Ingress · email 값 · 세션 |
| 5 | **nginx 설정을 바꾼다** (호스트) | 설정 파일 |
전 구간 약 30분. **1~3 만 하고 멈춰도 이 실험의 결론 대부분이 나온다.**
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:23 KST(①②④) 및 07:5107:53 UTC(③) 실행 기록의 **출력 원문** |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
증거 파일의 `['admin', 'editor']` 같은 표기는 **스크립트가 정리한 것**이다.
`curl` 로 직접 보면 같은 값이 JSON 배열 `["admin","editor"]` 로 온다 —
echo 앱이 헤더 이름마다 **값의 목록**을 돌려주기 때문이다. 이 가이드는 둘을
구별해 표시한다.
---
# 0. 왜 이 실험을 하는가
Edge(oauth2-proxy·nginx)가 인증을 끝내고 **신원을 헤더로 뒤에 넘기는** 구조가
있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이다. 뒤쪽
애플리케이션은 그 헤더를 읽어 사용자를 안다.
**그러면 그 헤더는 무엇이 보증하는가.**
| | 예측 |
|---|---|
| Q4 가 「확인한 사실」로 적어둔 것 | *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"* |
| B-4 가 재는 것 | 진짜 그런가. **그리고 upstream 은 무엇을 검증하는가** |
이 실험은 **네 가지를 따로 잰다.**
```
① 여러 값을 어떻게 넣는가 쉼표? 헤더를 여러 개? → 구별할 수 있나
② 커지면 어떻게 되는가 잘리나? 거부되나?
③ IdP 에서 바꾸면 언제 반영되나
④ 위조하면 통하는가 ★ 여기가 권한의 문제다
```
> **개념 — 왜 ④ 가 「인증 우회」가 아니라 「권한 상승」인가.**
>
> 헤더가 **누구인지**만 말하면 위조는 인증 우회다. 그런데 헤더가
> **무엇을 할 수 있는지**(role)까지 말하면, 위조는 **권한 상승**이 된다.
> 로그인한 일반 사용자가 자기 요청에 `X-Auth-Request-Roles: admin` 을
> 한 줄 더 붙이는 것으로 끝난다.
>
> 그래서 이 구조는 **세 곳이 동시에 성립해야만** 안전하다.
>
> ```
> ① 외부 → upstream 직접 경로 차단 (NetworkPolicy)
> ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header)
> ③ upstream 에서 내부 credential 검증 (공통 경계)
> ```
>
> **하나라도 빠지면 나머지 둘이 무의미하다.** 이 실험은 ② 가 빠져 있다는
> 것을 재고, 그 결과로 ④ 가 성립한다는 것을 재고, ③ 이 한 곳에만 있다는
> 것을 확인한다.
---
# 1. 기준선 — 위조하기 전에
**시험군만 재는 측정은 측정이 아니다.** 「위조 헤더가 도착했다」고 말하려면
**아무것도 안 붙였을 때 무엇이 도착하는지**를 먼저 봐야 한다.
```
경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나
```
## 1-1. 어느 이름이 어디로 가는가
**확인**
```bash
sudo kubectl get ingress -A
```
**형태**
```
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE
header-lab echo traefik app1.hyeonworks.com 80 5d
keycloak-lab bff traefik app1.hyeonworks.com 80 3d
keycloak-lab keycloak traefik auth.hyeonworks.com 80 6d
observability grafana traefik app2.hyeonworks.com 80 6d
```
**어디를 봐야 하는가**`app1` 이 **두 줄**이다. 같은 호스트에 Ingress 가
둘이고, 경로로 갈린다.
**확인** — 어느 경로가 어디로 가는지 눈으로 본다
```bash
sudo kubectl -n header-lab describe ingress echo | grep -A5 Rules
```
**형태**
```
Rules:
Host Path Backends
---- ---- --------
app1.hyeonworks.com
/api echo:8081 (10.42.0.61:8081,10.42.1.72:8081)
```
**이 결과가 의미하는 것**`https://app1.hyeonworks.com/api/echo` 는 **BFF 가
아니라 echo 앱**으로 간다. 이 실험이 재는 것은 BFF 가 아니라 **헤더를 그대로
받아 쓰는 upstream** 이므로 이쪽이 맞다.
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
> 뜬다. 위처럼 `describe ingress` / `describe svc` 를 보거나
> `get endpointslice -l kubernetes.io/service-name=echo` 를 본다.
## 1-2. echo 응답을 한 번 통째로 본다
**나중에 걸러 보려면 먼저 통째로 봐야 한다.** 어떤 키가 있는지 알아야 무엇으로
거를지 정할 수 있다.
**확인**
```bash
curl -s https://app1.hyeonworks.com/api/echo
```
**형태** — 한 줄 JSON 이 통째로 나온다
```json
{"headers":{"host":["app1.hyeonworks.com"],"x-forwarded-host":["app1.hyeonworks.com"],
"x-forwarded-proto":["https"],"x-forwarded-port":["443"],"x-forwarded-for":["..."],
"x-real-ip":["..."],"user-agent":["curl/8.5.0"],"accept":["*/*"]},
"remoteAddr":"...","localAddr":"10.42.1.72","scheme":"https","secure":true,
"serverName":"app1.hyeonworks.com","serverPort":443,
"requestUrl":"https://app1.hyeonworks.com/api/echo"}
```
**어디를 봐야 하는가**
- `headers` 의 값이 **전부 배열**이다. HTTP 가 같은 이름의 헤더를 여러 번
허용하기 때문이고, **2-1 의 결과를 읽을 수 있는 이유**가 이것이다
- `x-forwarded-proto``https` — nginx 가 `proxy_set_header`**설정한**
헤더다. 1-4 에서 이 목록을 확인한다
- `scheme` / `secure` / `serverName` — Keycloak 이 `iss` 클레임과 리다이렉트를
만들 때 쓰는 값들이다. 2홉 실험이 이 세 개를 봤다
`jq` 는 이 실험대에 **깔려 있지 않다.** 걸러 볼 때는 `grep -o` 를 쓴다.
**확인****미검증**
```bash
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'
```
**형태**
```
"x-forwarded-proto":["https"]
```
> **`tr ',' '\n' | grep` 은 여기서 쓰면 안 된다.** 값 배열이
> `["admin","editor"]` 처럼 쉼표를 품고 있어서 **배열이 두 줄로 잘린다.**
> 첫 줄만 보고 「하나만 도착했다」로 읽게 된다 — 이 실험이 가장 조심해야 할
> 오독이다. `grep -o '…\[[^]]*\]'` 는 대괄호 안을 통째로 뽑는다.
## 1-3. 대조군 — 아무것도 안 붙였을 때
**확인****미검증**
```bash
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'
```
**어디를 봐야 하는가****아무것도 안 나와야 한다.** `x-auth-request-*`
edge 가 붙이는 헤더인데, `app1` 앞에는 oauth2-proxy 가 없으므로 지금은 없다.
**이 결과가 의미하는 것****이 자리가 비어 있다는 것이 대조군이다.**
2절에서 여기에 값이 나타나면 그건 **내가 보낸 것이 도착한 것**이다. 이 확인을
건너뛰면 「원래 있던 것」과 「내가 넣은 것」이 구별되지 않는다.
## 1-4. nginx 가 지금 무엇을 설정하고 있나
**확인****랩 호스트(`test-server`)** 에서
```bash
sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab
```
**형태** — [`03-nginx`](../03-nginx/) 가 세운 설정 그대로다
```
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
```
**어디를 봐야 하는가****`X-Auth-Request-*` 가 목록에 없다.**
**이 결과가 의미하는 것** — 그리고 그것이 2절의 결과를 전부 설명한다.
> **개념 — nginx 의 헤더 처리는 조건부다.**
>
> ```nginx
> proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다
> # X-Auth-Request-Roles 설정 없음 # 안 한 것 → 그대로 흘려보낸다
> ```
>
> **nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.**
> 설정하지 않은 헤더는 **손대지 않고 통과**시킨다. 「nginx 가 덮어쓴다」는
> 명제는 **조건부**이며, 그 조건이 빠지면 틀린 문장이 된다.
>
> Q4 가 「확인한 사실」로 적어둔 *"Nginx는 client가 보낸 동명 헤더를
> merge하지 않고 덮어쓴다"* 는 **조건이 빠져 있어 어긋난다.**
> 2-1 이 그것을 재는 자리다.
> **`sudo` 가 아무 결과도 안 주면 실패한 것이다.** 랩 호스트의 sudo 는
> **비밀번호를 요구한다**(`sudo -n -l` → `sudo: a password is required`).
> D-4 후속 작업이 이 사실을 늦게 발견해서 시간을 버렸다. 빈 출력을
> 「설정이 없다」로 읽지 말고 **비밀번호를 넣어 다시 친다.**
---
# 2. 주입 — 헤더를 위조해서 보낸다
**이 절은 클러스터 상태를 바꾸지 않는다.** 요청을 보낼 뿐이다. 그래서
되돌릴 것이 없다 — 그리고 **그 사실 자체가 이 실험의 무게**다. 아무것도
설치하지 않고 아무 권한도 없이, `curl` 한 줄로 여기까지 된다.
## 2-1. 동명 헤더 두 개 — 덮어쓰는가, 합치는가, 통과시키는가
**하기**
```bash
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
```
**미검증** — 원래 실행은 스크립트가 응답을 정리했다. 위는 같은 값을 `grep` 으로
뽑는 형태다.
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
(b) 동명 헤더 두 개
보냄: X-Auth-Request-Roles: admin
X-Auth-Request-Roles: editor
도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다
```
**어디를 봐야 하는가** — **값이 두 개**다. `curl` 로 직접 보면
`"x-auth-request-roles":["admin","editor"]` 로 보인다.
**이 결과가 의미하는 것** — 셋 중 어느 것도 아니다.
| 가설 | 도착했을 모양 | 실제 |
|---|---|---|
| 덮어쓴다 | `["editor"]` 하나 | ✗ |
| 합친다 | `["admin, editor"]` 한 문자열 | ✗ |
| **통과시킨다** | **`["admin","editor"]`** | **✔** |
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-2. 값 안의 쉼표 — 구분자와 구별할 수 있는가
**하기**
```bash
curl -s -H 'X-Auth-Request-Roles: admin,editor,viewer' \
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
curl -s -H 'X-Auth-Request-Roles: role-with,comma' \
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
```
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
(a) 쉼표 구분 한 개 헤더
보냄: X-Auth-Request-Roles: admin,editor,viewer
도착: ['admin,editor,viewer'] ← 문자열 하나 그대로
...
(c) 값 안에 구분자가 들어간 경우
보냄: X-Auth-Request-Roles: role-with,comma
도착: ['role-with,comma'] ← (a) 와 구별 불가
```
**어디를 봐야 하는가****(a) 와 (c) 가 도착 시점에 똑같이 생겼다.**
둘 다 값이 **하나**인 배열이고, 그 안에 쉼표가 있다.
**이 결과가 의미하는 것**
```
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
```
**role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.**
Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다**
애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 자리가 아니다.
| 대안 | |
|---|---|
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다**(2-1) |
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다(2-3) |
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 **위조도 구분자도 해결된다** → BFF 구조 |
## 2-3. 크기를 키운다 — 자르나, 거부하나
**먼저 한 번은 읽는 형태로 본다.** 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다.
**하기****미검증**. 원래 실행은 `python3 -c "print('r'*$n)"` 로 값을 만들었다.
파이썬 없이 만든다
```bash
V=$(head -c 8000 /dev/zero | tr '\0' 'r'); echo "만든 길이 ${#V}"
curl -i -s -H "X-Auth-Request-Roles: $V" https://app1.hyeonworks.com/api/echo | head -20
```
**어디를 봐야 하는가** — 상태줄과 본문. 8000 에서는 **Tomcat 의 HTML 오류
페이지**가 온다. JSON 이 아니라 HTML 이라는 것 자체가 「애플리케이션까지
갔는데 파싱 전에 잘렸다」는 신호다.
이제 여러 크기를 **비교**한다. 비교가 목적이니 여기서는 코드만 뽑는 형태가 맞다.
**하기****미검증**
```bash
for n in 1000 4000 8000 16000 32000; do
V=$(head -c "$n" /dev/zero | tr '\0' 'r')
curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
https://app1.hyeonworks.com/api/echo
done
```
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
=== Q4 ② 헤더 크기 상한 ===
보낸 길이 1000 → HTTP 200, 도착 길이 1000
보낸 길이 4000 → HTTP 200, 도착 길이 4000
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
보낸 길이 32000 → HTTP 000
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
```
**어디를 봐야 하는가****`000``400` 이 다른 것**이다.
| curl 이 찍는 값 | 뜻 |
|---|---|
| `400` | 응답을 받았다. **서버가 거부했다** |
| `000` | **응답 자체를 못 받았다.** 연결이 끊겼거나 아예 안 열렸다 |
**이 결과가 의미하는 것****자르지 않는다. 거부한다.** 그리고 **거부하는
계층이 둘**이다.
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|---|---|---|
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** |
> **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼,
> 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다** —
> 앞의 것은 앱 로그를 뒤지게 하고 뒤의 것은 방화벽을 뒤지게 한다.
```
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
```
**점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은
**사용자마다 다르다** — role 이 많은 사용자만 깨진다. 테스트 계정으로는
영원히 안 보인다.
## 2-4. 신원 자체를 위조한다
**하기**
```bash
curl -s \
-H 'X-Auth-Request-User: administrator' \
-H 'X-Auth-Request-Email: admin@example.com' \
-H 'X-Auth-Request-Roles: realm-admin,superuser' \
https://app1.hyeonworks.com/api/echo
```
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
=== 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
→ 그대로 도착. 검증 없음.
```
**어디를 봐야 하는가****1-3 에서 비어 있던 자리에 값이 들어와 있다.**
그리고 `remoteAddr` 이 **내 주소**다 — 숨지도 않았다.
**이 결과가 의미하는 것****로그인하지 않았다.** 쿠키도 토큰도 없다.
헤더 세 줄이 전부다. upstream 은 그것을 그대로 받는다.
---
# 3. 주입이 실제로 「통한 것」인지 확인한다
**「도착했다」와 「통했다」는 다르다.** 도착해도 아무도 안 읽으면 무해하다.
그래서 **읽는 쪽이 검증을 하는지**를 대조군으로 확인한다.
## 3-1. JWT 를 요구하는 경로는 어떻게 되나
**하기**
```bash
for p in /api/echo /api/me /api/protected; do
curl -s -o /dev/null -w "$p %{http_code}\n" \
-H 'X-Auth-Request-User: administrator' \
-H 'X-Auth-Request-Roles: realm-admin' \
"https://app1.hyeonworks.com$p"
done
```
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
```
대조 — JWT 를 요구하는 경로:
/api/echo HTTP 200 (permitAll)
/api/me HTTP 401
/api/protected HTTP 401
```
**어디를 봐야 하는가****같은 위조 헤더인데 결과가 갈린다.**
**이 결과가 의미하는 것** — 위조 헤더는 `/api/echo` 를 열어 준 것이 아니다.
거기는 원래 `permitAll` 이라 열려 있었다. `/api/me`**401** 이다 —
**헤더로는 인증이 안 된다.**
**실측** — 같은 파일의 SecurityConfig 발췌
```
backend SecurityConfig:
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
```
## 3-2. 그래서 무엇이 다른가
```
JWT 경로 → 서명이 있다 → 검증할 대상이 있다 → 위조가 안 된다
헤더 경로 → 서명이 없다 → 검증할 대상이 없다 → ★ 위조를 구별할 방법이 없다
```
**`request.getHeader("X-Auth-Request-User")` 는 그 값이 어디서 왔는지 모른다.**
edge 가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다.
> Q4 가 「확인한 사실」로 적어둔 *"upstream은 JWT를 입력으로 받지 않아서
> 헤더로 넘어온 값을 검증할 방법이 없다"* — **정확하다. 그리고 그것이 이
> 구조의 본질적 한계다.**
>
> 2홉 실험에서 헤더 위조로 `serverName: evil.example.com` 을 만든 것과
> **같은 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.**
**여기까지가 요청만으로 되는 부분이다.** 여기서 멈춰도 ①②④ 는 다 봤다.
---
# 4. 두 번째 주입 — IdP 에서 클레임을 바꾼다
**여기부터 상태가 바뀐다.** ③ 「role 변경은 언제 반영되는가」를 재려면
**edge 세션이 실제로 있어야** 하므로 oauth2-proxy 가 필요하고, 그것이
app2 를 쓴다.
## 4-0. app2 를 Grafana 에서 빌린다 — 되돌리는 것을 먼저 만든다
**하기****백업이 먼저다**
```bash
sudo kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
wc -l /tmp/grafana-ingress-backup.yaml
sudo kubectl -n observability delete ingress grafana
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt) 의 첫 줄
```
grafana ingress 삭제
```
**되돌리기** — [5-4](#5-4-grafana-ingress-를-되돌린다). **지금 확인해 둔다**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
```
> **`wc -l` 을 왜 치나** — 백업 파일이 **비어 있는데 삭제부터 하는** 사고를
> 막는다. 0 줄이면 그 자리에서 멈춘다. 파일이 생겼는지 확인하지 않고 원본을
> 지우는 것이 이런 작업에서 가장 흔한 사고다.
**하기** — 브라우저에서 `https://app2.hyeonworks.com/` 를 열고
`labuser` / `labpass` 로 로그인한다.
**확인** — 세션이 생겼나. **지우기 전에 항상 목록을 먼저 본다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
의 기준선
```
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
```
**어디를 봐야 하는가****키 이름이 `_oauth2_proxy-` 로 시작한다.**
밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 **하나도 안
맞는다** — 그러면 「세션이 없다」로 오독하고, 이어서 지우는 명령이 조용히
아무것도 안 지운다. **목록을 먼저 보는 이유가 이것이다.**
## 4-1. ★ 왜 curl 로 못 하는가
**확인** — oauth2-proxy 가 쿠키를 어떻게 만드는지 로그가 말한다
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
httponly:true expiry:1h0m0s ... refresh:disabled
```
**어디를 봐야 하는가****`httponly:true`** 와 **`refresh:disabled`**.
**이 결과가 의미하는 것**
- `httponly:true`**JS 도 못 읽고, 브라우저 밖으로 꺼낼 수도 없다.**
그래서 `curl -b` 로 로그인 상태를 흉내 낼 수 없다. **이 측정은 브라우저
안에서 해야 한다.** 여기서 「curl 로 하면 되지 않나」를 붙들면 몇 시간이
간다 — 원래 실행도 그래서 Playwright 로 연 브라우저를 썼다
- `refresh:disabled`**4-4 의 결과를 미리 말해 준다.** `--cookie-refresh`
가 없으면 세션은 토큰을 다시 받지 않는다
## 4-2. 기준선 — 지금 무슨 값이 나가고 있나
**`X-Auth-Request-Roles` 대신 `x-forwarded-email` 을 쓴다.** role 을 헤더로
내보내려면 추가 설정이 필요한데, **「IdP 의 클레임 변경이 언제 반영되는가」는
어느 클레임이든 같은 질문**이다.
**하기** — 로그인된 app2 탭에서 `F12` → Console
```js
for (let i = 0; i < 3; i++) {
const r = await (await fetch('/api/echo')).text();
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
}
```
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
```
=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
2026-09-04T07:51:23.862Z req#1 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.304Z req#2 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
2026-09-04T07:51:24.722Z req#3 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
```
**어디를 봐야 하는가**`labuser@example.com`. **이 값이 대조군이다.**
## 4-3. IdP 에서 값을 바꾼다
**하기** — 셸에서
```bash
UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser \
--fields id --format csv --noquotes | tail -1)
echo "uid=$UID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'
```
**되돌리기** — [5-3](#5-3-idp-값을-되돌린다). 지금 명령을 확인해 둔다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$UID -r keycloak-patterns -s email=labuser@example.com
```
**확인** — IdP 쪽은 정말 바뀌었나. **바뀌지 않은 것을 「반영 안 됨」으로
읽지 않기 위해** 반드시 본다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users/$UID -r keycloak-patterns --fields email
```
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
```
=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
변경 시각(UTC): 2026-09-04T07:53:32.000Z
IdP 의 값:
[ {
"email" : "changed-labuser@example.com"
} ]
oauth2-proxy 세션: 1 개 (그대로 살아 있다)
```
**어디를 봐야 하는가** — **IdP 값은 바뀌었고 세션은 그대로 1개**다.
이 두 줄이 있어야 다음 절의 「옛 값」이 「반영 안 됨」이라고 말할 수 있다.
## 4-4. 요청을 반복한다 — 몇 번째부터 바뀌나
**하기** — 브라우저 콘솔에서. 0.5초 간격으로 12번
```js
for (let i = 0; i < 12; i++) {
const r = await (await fetch('/api/echo')).text();
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
await new Promise(s => setTimeout(s, 500));
}
```
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
```
=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:57.489Z req#3 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.018Z req#4 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:58.602Z req#5 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.217Z req#6 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:51:59.743Z req#7 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.342Z req#8 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:52:00.964Z req#9 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:52:01.574Z req#10 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:52:02.187Z req#11 HTTP 200 x-forwarded-email=labuser@example.com
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
→ 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.
```
**어디를 봐야 하는가****12줄이 전부 같다.**
**이 결과가 의미하는 것** — Q4 는 「몇 번째 요청부터 반영되는지」를 물었는데,
**답은 「요청으로는 안 된다」이다.** 요청 횟수가 아니라 **세션의 나이**가 정한다.
## 4-5. ★ 두 시계가 어긋나 있다 — 그래서 이 결론이 성립한다
**브라우저 타임스탬프와 서버 타임스탬프를 그대로 비교하면 안 된다.**
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
```
=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
→ test-server 가 약 107초 앞선다.
브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.
```
**어디를 봐야 하는가****107초.**
**이 결과가 의미하는 것** — 보정 전에는 12회의 타임스탬프(`07:51:56~`)가
변경 시각(`07:53:32`)보다 **앞서 보인다.** 그대로 읽으면 「변경 전에 잰
것」이 되어 **결론이 통째로 무너진다.** 보정하면 12회는 변경 **11초 뒤**이고,
그래야 「변경 후에도 옛 값」이라는 결론이 선다.
**확인** — 당신 환경의 어긋남을 잰다
```bash
date -u '+%Y-%m-%dT%H:%M:%SZ'
```
그리고 브라우저 콘솔에서
```js
new Date().toISOString()
```
두 값의 차가 보정값이다.
> **두 기계의 로그를 나란히 놓기 전에 시계를 확인한다.** D-4 는 이 확인을
> 안 해서 인증서 공백을 처음에 잘못 계산했고, 나중에 **38분 25초**로
> 정정했다. 같은 실수가 여기서도 났고, **증거 파일에 보정값을 적어 두는
> 것**으로 처리했다.
## 4-6. 세션을 지우고 재인증시킨다
**하기****지우기 전에 목록을 본다**(4-0 의 이유)
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
```
**미검증** — 후속 문서 §3 에 실린 형태를 **실제 키 이름에 맞춰 고친 것**이다.
패턴이 안 맞으면 아무것도 안 지워지고 오류도 안 난다 — 앞 명령의 목록과
`del` 이 돌려주는 개수를 대조한다.
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 접근하면 새
세션이 만들어진다(그게 이 절의 목적이다).
**하기** — 브라우저에서 app2 를 새로고침한 뒤 콘솔에서 3회
```js
for (let i = 0; i < 3; i++) {
const r = await (await fetch('/api/echo')).text();
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
}
```
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
```
=== [6] 재인증 후 (브라우저 fetch) ===
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
```
**어디를 봐야 하는가****새 값이 나온다.** 그리고 **로그인 화면은 안 떴다**
Keycloak SSO 가 살아 있어 조용히 재인증됐다(B-2 4-5 와 같은 성질이다).
**이 결과가 의미하는 것**
```
변경 후 12회 요청(6.4초) → labuser@example.com (옛 값)
세션 삭제 후 재인증 → changed-labuser@example.com (새 값)
```
> **개념 — 세션은 로그인 시점의 스냅샷이다.**
>
> ```
> 로그인 → IdP 가 준 클레임을 세션에 담는다
> 이후 요청 → 세션에서 읽어 헤더로 내보낸다
> └─ IdP 를 다시 부르지 않는다
> IdP 에서 변경 → 세션은 모른다
> ```
>
> | 설정 | 반영 시점 |
> |---|---|
> | 지금 (`--cookie-refresh` 없음) | **쿠키 만료(1시간) 또는 재인증까지 안 됨** |
> | `--cookie-refresh=5m` | 최대 5분 |
>
> **권한을 뺏는 변경이 최대 1시간 늦게 반영된다.** 해고된 사용자의 세션이
> 한 시간 더 산다는 뜻이고, 이것이 Q4 의 설계 판단 2번(**즉시 반영이
> 필요한가**)에 직접 답한다 — 즉시가 필요하면 헤더 방식은 맞지 않는다.
---
# 5. 고치기와 복구
## 5-1. nginx 에서 동명 헤더를 덮어쓴다 — 먼저 지워야 한다
**이 실험대는 이 수정을 적용한 적이 없다.** 해설 문서 6절이 「남긴 것」으로
분류한 항목이다. 아래는 **미검증**이며, 적용하려면 랩 호스트에서 사람이
직접 친다.
**하기****랩 호스트(`test-server`)** 에서. **백업이 먼저다**
```bash
sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup
ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup
sudo vi /etc/nginx/sites-available/keycloak-lab
```
`location / { ... }` 안, 기존 `proxy_set_header` 들 옆에 넣는다.
```nginx
# B-4 — 클라이언트가 보낸 X-Auth-Request-* 를 먼저 지운다.
# 빈 값으로 set 해야 "설정한 헤더"가 되어 통과가 아니라 덮어쓰기가 된다.
proxy_set_header X-Auth-Request-User "";
proxy_set_header X-Auth-Request-Email "";
proxy_set_header X-Auth-Request-Roles "";
```
**어디를 봐야 하는가****`""` 로 먼저 지우는 것**이 핵심이다.
> **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다**(1-4). 그러니
> 「덮어쓰게 하려면 먼저 설정해야」 하고, 붙일 값이 없을 때 설정하는 방법이
> **빈 문자열**이다. `proxy_set_header X-Auth-Request-Roles "";` 는
> nginx 에서 **그 헤더를 upstream 으로 보내지 않는다**는 뜻이다.
>
> edge 가 진짜 값을 붙여야 하는 자리라면 **지운 뒤에 다시 설정**한다.
> 순서가 반대면 클라이언트 값이 살아남는다.
**하기** — 문법을 보고 적용한다
```bash
sudo nginx -t && sudo systemctl reload nginx
```
**어디를 봐야 하는가**`nginx -t`**마지막 줄**. `syntax is ok`
`test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를
막지 않는다. 실패면 `&&` 가 reload 를 **막아 준 것**이고 지금 돌고 있는
nginx 는 옛 설정 그대로다.
**되돌리기**
```bash
sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
```
## 5-2. 고쳐졌는지 같은 명령으로 다시 잰다
**하기** — 2-1 과 **똑같은 명령**
```bash
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
```
**미검증** — 이 실험대는 여기까지 재지 않았다.
**어디를 봐야 하는가****아무것도 안 나와야 한다**(1-3 의 대조군과 같아진다).
값이 그대로 나오면 reload 가 안 갔거나 다른 `server` 블록을 고친 것이다.
워커 PID 가 바뀌었는지로 reload 여부를 판정한다.
```bash
systemctl status nginx --no-pager | head -20
```
## 5-3. IdP 값을 되돌린다
**하기**
```bash
UID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser \
--fields id --format csv --noquotes | tail -1)
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update users/$UID -r keycloak-patterns -s email=labuser@example.com
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users/$UID -r keycloak-patterns --fields email
```
**어디를 봐야 하는가**`"email" : "labuser@example.com"`.
**4-6 에서 배운 대로, 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다.**
세션을 한 번 더 지우면 확실하다.
## 5-4. Grafana Ingress 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
```
**확인**
```bash
sudo kubectl get ingress -A | grep app2
curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/
```
**어디를 봐야 하는가**`app2` 를 잡고 있는 Ingress가 **`observability/grafana`
하나**여야 한다. 둘이 남아 있으면 어느 쪽이 이길지는 컨트롤러가 정한다 —
**되돌린 것이 아니라 경합을 만든 것**이다.
> **oauth2-proxy Deployment 자체는 남겨도 된다.** Ingress 만 떼면 app2 로는
> 안 들어간다. B-7 을 이어서 할 거라면 그대로 두는 편이 낫다.
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| app2 | `sudo kubectl get ingress -A \| grep app2` | `observability/grafana` **하나만** |
| Grafana | `curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/` | Grafana 가 답한다 (`200` 또는 로그인 `302`) |
| app1 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo` | `200` |
| IdP | `… kcadm.sh get users/$UID -r keycloak-patterns --fields email` | `labuser@example.com` |
| nginx | `sudo nginx -t` (호스트) | `test is successful` |
| nginx 백업 | `ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup` | 되돌렸으면 지워도 된다 |
| Redis | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | 로그아웃했으면 없거나, 새 세션 하나 |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 동명 헤더가 **하나만 도착한 것처럼** 보인다 | **`tr ',' '\n'` 으로 잘랐다.** 값 배열이 두 줄로 쪼개진다 | `grep -o '…\[[^]]*\]'` 로 대괄호째 뽑는다 — 1-2 |
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep -o` 로 뽑거나 응답을 통째로 본다 |
| `python3 -m json.tool` 을 쓰라고 되어 있다 | 해설 문서 7절의 형태다. 값 생성도 `python3 -c` 였다 | `head -c N /dev/zero \| tr '\0' 'r'` — 2-3 |
| 16000 에서 `000` 이 나온다 | **오류가 아니라 측정 결과다.** nginx 가 연결을 끊는다 | `400`(Tomcat)과 `000`(nginx)을 구별한다 — 2-3 |
| `sudo grep` 이 빈 결과 | **호스트 sudo 는 비밀번호를 요구한다** | `sudo -n -l` 로 확인. D-4 가 이 조용한 실패에 걸렸다 |
| Redis 에서 세션이 안 보인다 | 패턴이 틀렸다. 키는 **`_oauth2_proxy-`** 로 시작한다 | 먼저 `--scan` 만 쳐서 이름을 눈으로 본다 — 4-0 |
| `xargs … del` 이 아무것도 안 지운다 | 같은 원인. 패턴이 안 맞으면 **조용히** 0건 | 목록 개수와 `del` 반환 개수를 대조 — 4-6 |
| `curl -b` 로 로그인 상태가 재현이 안 된다 | **쿠키가 `HttpOnly` 다.** 꺼낼 수 없다 | 브라우저 콘솔에서 잰다 — 4-1 |
| 12회가 **변경 시각보다 앞서** 보인다 | **두 시계가 107초 어긋나 있었다** | 보정값을 먼저 잰다 — 4-5 |
| 값이 안 바뀐다 | **버그가 아니다.** 세션이 새로 만들어져야 한다 | 4-6 · `--cookie-refresh` |
| app2 가 Grafana 도 프록시도 아닌 것을 준다 | Ingress 가 **둘 다 남아 있다** | `get ingress -A \| grep app2` — 5-4 |
| `/api/me``200` 이다 | 위조가 통한 것이 **아니라** 진짜 JWT 를 보낸 것이다 | 헤더만 보냈는지 다시 본다 — 3-1 |
---
# 이 실험이 재지 않은 것
| 항목 | 왜 |
|---|---|
| 5-1 의 nginx 수정 효과 | **적용한 적이 없다.** 해설 문서가 「남긴 것」으로 분류했다 |
| `X-Auth-Request-Roles` 자체의 반영 시점 | role 을 헤더로 내보내려면 추가 설정이 필요해 **`x-forwarded-email` 로 대체**했다. 「클레임 변경이 언제 반영되는가」는 어느 클레임이든 같다 |
| `--cookie-refresh=5m` 을 켠 뒤의 반영 시점 | 표의 「최대 5분」은 설정의 정의이지 **이 실험대에서 잰 값이 아니다** |
| upstream 의 내부 credential 검증 | controller 한 곳에만 있다. **공통 경계로 옮기는 것은 코드 변경**이라 이 실험 밖이다 |
---
# 다음
| 실험 | B-4 가 남긴 질문 |
|---|---|
| B-7 oauth2-proxy | cookie secret 을 회전하면 **저장소에 무엇이 남는가**. app2 를 빌리는 절차가 같다 |
| B-7a 고아 세션 | 4-6 에서 지운 그 키들의 **수명과 정리 규칙** |
| [B-2](b2-multi-instance-session.md) BFF | 같은 질문을 **서버 보관 토큰**으로 풀면 어떻게 다른가 |
| 설계 | **2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |
@@ -0,0 +1,883 @@
# B-5 재현 가이드 — Redis 를 내리고 파드가 `Ready` 인 채로 계속 실패하는 것을 본다
해설 문서: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md) ·
증거 원문: [`docs/evidence/b5-redis-loss/`](../../evidence/b5-redis-loss/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 오류가 아니라 **멈추는** 것 (`HTTP 000`) | 밖에서 `curl` |
| `/actuator/health``503` 인데 **파드는 `1/1 Ready`** | health 그룹별 응답 |
| Service 엔드포인트에 **두 파드가 그대로** 남아 있는 것 | `endpointslice` |
| 손대지 않아도 **재시작 0회로 회복**하는 것 | `get pods` · Lettuce |
| **AOF 를 켰는데 재시작 후 `dbsize 0`** 인 것 | 볼륨 없는 `/data` |
| 볼륨 위에서는 **살아남는** 것 | PVC 를 붙인 뒤 같은 시험 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`B-1`](b1-redis-session-store.md) · [`B-2`](b2-multi-instance-session.md)
가 끝나 **세션은 Redis, 토큰은 PostgreSQL** 로 나뉘어 있다.
나뉘어 있어야 **각각 죽여볼 수 있다** — 이 실험은 Redis 만 죽인다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 브라우저로 `https://app1.hyeonworks.com/`**로그인해 둔다**
(`labuser` / `labpass`). Redis 에 세션이 하나는 있어야 「잃는 것」이 보인다.
- Redis 는 `redis.keycloak-lab.svc:6379`, 파드는 `kc-lab-2` 에 고정되어 있다.
## 주의 — 이건 저장소를 지우는 실험이다
Redis 를 0대로 내리고, 나중에 **볼륨 없이 파드를 지운다.** 그 안의 세션은
**돌아오지 않는다.** 로그인한 사용자는 전부 로그아웃된다. **실험대에서만 한다.**
전 구간 약 30분. 중간에 그만두려면 [5-1](#5-1-되돌린다) 의 한 줄이면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:2414:28 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
**이 실험대는 그 뒤로 바뀌었다.** 지금 매니페스트
([`bff-redis.yaml`](../../../deploy/lab/k8s/bff-redis.yaml))에는 **B-5 의 결론이
이미 반영되어** PVC 와 `--appendonly yes` 가 들어 있다. 그래서 6절은
「볼륨 없는 상태를 다시 만드는」 단계부터 시작한다.
---
# 0. 왜 이 실험을 하는가
A-2 에서 Keycloak 의 PostgreSQL 을 내렸다. 그때는 이렇게 됐다.
```
DB 정지 → 헬스체크 실패 → 파드 NotReady → Service 에서 빠짐 → 밖에서 503
```
**명확한 실패였다.** 503 은 「지금 안 된다」고 말하고, 클라이언트는 재시도든
포기든 결정할 수 있다.
| | 예측 |
|---|---|
| 통념 | 의존 저장소가 죽으면 **헬스체크가 알아서 파드를 빼 준다** |
| B-5 가 재는 것 | 진짜 그런가. **그리고 이번에는 무엇을 보고 판단하는가** |
그리고 두 번째 질문이 붙는다.
```
Redis 를 다시 띄우면 → 세션이 남아 있나?
```
**「영속화를 켜 두면 남는다」가 통념이다.** 이 실험은 그 통념이 쿠버네티스에서
어떻게 어긋나는지를 잰다. 그래서 **1-3 이 이 가이드에서 가장 중요한 절**이다 —
영속화를 논하기 전에 **`/data` 가 무엇인지부터** 본다.
---
# 1. 기준선 — Redis 를 내리기 전에
```
파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
```
## 1-1. 파드와 노드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
bff-555df79c97-6j86w 1/1 Running 0 17m 10.42.0.52 kc-lab-1
bff-555df79c97-vgg6g 1/1 Running 0 16m 10.42.1.124 kc-lab-2
postgres-... 1/1 Running 0 5d ... kc-lab-2
redis-... 1/1 Running 0 3d ... kc-lab-2
```
**어디를 봐야 하는가**
- `bff`**둘 다** `1/1`, `RESTARTS``0`
- **Redis 는 하나다.** replica 가 없다 — 그래서 0으로 내리면 전면 정지다
- Redis 와 PostgreSQL 이 **같은 노드**(`kc-lab-2`)다. 매니페스트가
`nodeSelector` 로 고정한다 — A-4(노드 상실)에서 **두 저장소가 한꺼번에**
없어지게 하려는 배치다
`10.42.0.52``10.42.1.124` 는 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
에 남은 실제 BFF 파드 IP 다. **4-3 에서 이 두 주소가 다시 나온다.**
## 1-2. Redis 가 지금 무엇을 들고 있나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
```
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt)
```
=== 기준선 ===
Redis 키: 1
PostgreSQL 토큰: 1 행
Redis 영속화 설정:
save = save
appendonly no
```
**어디를 봐야 하는가** — 세 가지.
| 값 | 그때 | 뜻 |
|---|---|---|
| 키 수 | `1` | 로그인 세션 하나 |
| `save` | 빈 값 | **RDB 스냅샷이 꺼져 있다** |
| `appendonly` | `no` | **AOF 도 꺼져 있다** |
**이 결과가 의미하는 것** — 그때는 **영속화가 아예 꺼져 있었다.**
지금 당신 환경은 다를 것이다 — 매니페스트가 `--appendonly yes` 로 시작하므로
`appendonly yes` 가 나온다. **그 차이가 6절의 출발점이다.**
> `save` 출력의 값이 비어 있는 것과 키가 없는 것은 다르다.
> `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면
> 「스냅샷 조건 없음」이다. 증거의 `save = save` 는 그 두 줄이 한 줄로
> 붙어 찍힌 모양이다.
## 1-3. ★ `/data` 가 볼륨인가 — 영속화를 말하기 전에 여기부터 본다
**이 절을 건너뛰면 6절의 결과를 오해한다.** 「AOF 를 켰는데 안 남는다」를
「Redis 가 이상하다」로 읽게 된다.
**확인**
```bash
sudo kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.volumes}'; echo
```
**형태** — 지금 매니페스트 기준
```json
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
```
**확인** — 그 볼륨이 `/data` 에 붙어 있나
```bash
sudo kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
sudo kubectl -n keycloak-lab get pvc
```
**형태**
```
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
redis-data Bound pvc-... 1Gi RWO local-path 3d
```
**어디를 봐야 하는가** — 세 가지가 **전부** 성립해야 한다.
```
① volumes 에 항목이 있다 ← 없으면 컨테이너 파일시스템이다
② volumeMounts 의 mountPath 가 /data ← 다른 데 붙었으면 소용없다
③ PVC 가 Bound ← Pending 이면 파드가 안 뜬다
```
**이 결과가 의미하는 것** — 셋 중 하나라도 빠지면 **`appendonly yes`
장식이다.** 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
6절에서 그것을 직접 만든다.
> **개념 — 컨테이너 파일시스템은 컨테이너와 함께 죽는다.**
>
> ```
> /data 가 볼륨이 아니다 → 이미지 위의 쓰기 가능 레이어에 쓴다
> → 컨테이너가 없어지면 그 레이어도 없어진다
> ```
>
> Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에
> `appendonlydir` 을 만들고 매 쓰기를 기록한다. **거짓말이 아니라 정말로
> 기록한다.** 다만 그 디렉터리가 어디 있는지를 모를 뿐이다.
>
> `emptyDir` 도 마찬가지다 — 컨테이너 재시작은 견디지만 **파드가 없어지면
> 같이 없어진다.** 「볼륨을 붙였다」와 「영속 볼륨을 붙였다」는 다르다.
## 1-4. 세 경로를 정상 상태에서 잰다
**주입 후에 볼 것을 주입 전에 똑같은 명령으로 먼저 봐 둔다.**
**확인**
```bash
for p in / /bff/token-boundary /actuator/health; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
done
```
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt) 는 첫 줄만 남겼다
```
=== 외부 진입점 정상 확인 ===
https://app1.hyeonworks.com/ HTTP 200
```
**어디를 봐야 하는가****`000` 이 아닌 것.** 그게 판정 기준의 전부다.
| 경로 | 정상일 때 | 왜 |
|---|---|---|
| `/` | `200` | `permitAll` 정적 페이지. **Redis 를 안 탄다** |
| `/bff/token-boundary` | `200` 또는 로그인으로 보내는 `3xx` | 세션이 필요하다 — **Redis 를 탄다** |
| `/actuator/health` | `200` | 모든 지표의 합 |
**미검증** — 셸의 `curl` 에는 로그인 쿠키가 없으므로 두 번째는 보통 `3xx` 다.
**`200` 이든 `3xx` 든 상관없다** — 이 실험이 보는 것은 **응답이 오는가**이고,
`3xx` 를 만드는 과정에서도 BFF 는 세션을 만들려고 **Redis 를 건드린다.**
> **`--max-time` 을 반드시 붙인다.** 4-1 에서 이 요청은 **응답이 안 온다.**
> 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 「멈춤」이 아니라
> 「내 터미널이 이상함」으로 읽게 된다.
## 1-5. health 그룹을 미리 본다
**4-2 의 놀라움은 기준선을 봐 둬야 놀라움이 된다.**
**확인**`/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다)
```bash
curl -s https://app1.hyeonworks.com/actuator/health; echo
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
curl -s https://app1.hyeonworks.com/actuator/health/liveness; echo
```
**어디를 봐야 하는가****첫 번째 응답의 본문에 `redis` 항목이 있는지**,
그리고 **두 번째 응답에는 없는지.** 세 응답이 서로 다른 것을 본다는 것이
이 절의 전부다.
**실측** — 정지 후의 값은 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
에 있고, 그 본문 항목 자리는 **비어 있다**
```
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
```
**첫 번째 칸이 비어 있는 것은 측정 실패다.** 파드 안에서 본문을 받아오려다
못 받았다. **당신은 지금 밖에서 직접 재 두는 것이 낫다** — 뒤에서 이 자리를
비교하게 된다.
> **개념 — Spring Boot 의 health group.**
>
> ```
> /actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
> /actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
> /actuator/health/liveness liveness 그룹
> ```
>
> **`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
> 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로 —
> `readinessProbe.httpGet.path: /actuator/health/readiness` — 다.
>
> **전체는 DOWN 인데 readiness 는 UP 인 상태**가 성립하고, 4-2 가 그것이다.
**확인** — kubelet 이 실제로 무엇을 보는지 매니페스트에서 확인한다
```bash
sudo kubectl -n keycloak-lab get deploy bff \
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
```
**형태**
```
/actuator/health/readiness
```
---
# 2. 주입 ① — Redis 를 0대로 내린다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
```
## 2-1. 왜 `scale --replicas=0` 인가
| 방법 | 만들어지는 상태 |
|---|---|
| `delete pod` | Deployment 가 **즉시 새로 만든다.** 몇 초짜리 공백이라 관찰할 시간이 없다 |
| **`scale --replicas=0`** | **없는 상태가 유지된다.** 내가 되돌릴 때까지 |
| NetworkPolicy 로 6379 차단 | 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 **기존 연결은 안 끊긴다** |
**「저장소가 없어진 상태」를 안정적으로 유지하는 것이 목적**이므로 두 번째다.
그리고 이 방법은 **파드가 사라지므로 주입 여부를 눈으로 확인하기 쉽다**(3-1).
## 2-2. 적용
**하기**
```bash
date '+%H:%M:%S 정지'
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=0
```
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
```
=== ① Redis 정지 ===
정지: 14:26:30
deployment.apps/redis scaled
삭제 완료
```
**시각을 반드시 적어 둔다.** 5절에서 「언제부터 회복됐나」를 붙일 때 쓴다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. Redis 파드가 정말 없나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=redis
sudo kubectl -n keycloak-lab get deploy redis
```
**형태**
```
No resources found in keycloak-lab namespace.
NAME READY UP-TO-DATE AVAILABLE AGE
redis 0/0 0 0 3d
```
**어디를 봐야 하는가**`0/0`. `1/1` 이면 스케일이 안 먹었거나 다른
네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.
## 3-2. BFF 가 정말 못 붙고 있나
**응답이 없는 것과 붙지 못하는 것은 다르다.** 로그가 이유를 말한다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10
```
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
```
=== 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]
```
**어디를 봐야 하는가****`pollConnect` · `finishConnect`.** 연결을 **맺는
중**이라는 뜻이다.
**이 결과가 의미하는 것** — 이미 실패한 것이 아니라 **아직 시도 중**이다.
Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다.
**4-1 의 `000` 이 여기서 나온다.**
## 3-3. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods
```
**어디를 봐야 하는가****`bff` 두 개의 `RESTARTS` 가 여전히 0**, 그리고
**postgres 가 살아 있는 것.** postgres 까지 내렸다면 이건 B-5 가 아니라
전면 장애를 재는 것이다.
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
```
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w 1/1 Running 0 17m
bff-555df79c97-vgg6g 1/1 Running 0 16m
```
**여기서 이미 답이 절반 나와 있다** — Redis 가 없는데 **`1/1`** 이다.
---
# 4. 효과를 관찰한다
## 4-1. `000` 은 오류가 아니라 멈춤이다
**확인** — 1-4 와 **똑같은 명령**
```bash
for p in / /bff/token-boundary /actuator/health; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
done
```
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
```
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
/ HTTP 200
/bff/token-boundary HTTP 000
/actuator/health HTTP 503
```
**어디를 봐야 하는가** — 세 값이 **서로 다르다.**
| 코드 | 뜻 |
|---|---|
| `200` | 정적 페이지는 산다 — **Redis 를 안 타는 경로** |
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
| `503` | 헬스 엔드포인트는 **대답은 한다** — 다만 DOWN 이라고 |
**이 결과가 의미하는 것****오류를 돌려주는 것이 아니라 매달려 있다.**
```
빠른 실패: 요청 → 즉시 503 → 사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
느린 실패: 요청 → ………… → 사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다
```
**「빨리 실패하기(fail fast)」가 안 되어 있다.** A-6(지연 주입)에서 본 것과
같은 문제다 — **느린 실패가 빠른 실패보다 나쁘다.** 브라우저 탭도, 그 앞의
로드밸런서도, 그 앞의 사용자도 전부 붙잡힌다.
**응답 본문도 비어 있다.**
**실측** — 같은 파일
```
--- token-boundary 응답 본문 ---
```
**본문이 없다는 것은 「오류 페이지조차 못 만들었다」**는 뜻이다.
> **고치려면 클라이언트에 타임아웃을 건다.** Lettuce 의 연결·명령 타임아웃을
> 짧게 잡으면 `000` 이 `500` 이 된다. **500 이 000 보다 낫다** — 적어도
> 말은 하기 때문이다.
## 4-2. ★ 그런데 파드는 `Ready` 를 유지한다
**이것이 이 실험의 가장 중요한 발견이다.**
**확인**
```bash
curl -s -o /dev/null -w 'health %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/readiness
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/liveness
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
```
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
```
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
/actuator/health HTTP server
/actuator/health/readiness HTTP 200
/actuator/health/liveness HTTP 200
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
```
**어디를 봐야 하는가**`readiness`**`200` 이고 `{"status":"UP"}`**.
> **첫 줄의 `HTTP server` 는 상태 코드가 아니다 — 측정이 실패한 것이다.**
> 값이 들어와야 할 자리에 엉뚱한 문자열이 들어와 있다. `503` 이라는 값은
> [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt) 쪽
> 측정에서 나왔다.
>
> **빈 값이나 이상한 값을 「측정 결과」로 읽지 않는다.** 그건 「측정 실패」다.
> A-1 에서도 빈 문자열을 「변화」로 읽어 판정이 틀어진 적이 있다.
> 이상하면 그 자리에서 다시 친다 — 손으로 하나씩 치는 이유가 이것이다.
**이 결과가 의미하는 것** — 전체 상태는 DOWN 인데 **kubelet 이 보는 그룹은 UP**
이다. 그래서 **파드를 빼지 않는다.**
```
/actuator/health redis: DOWN → 전체 DOWN → 503
/actuator/health/readiness readinessState 만 → UP → kubelet: "정상"
```
## 4-3. Service 엔드포인트에 둘 다 남아 있다
**확인**
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=bff \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
```
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
ready: [10.42.0.52 10.42.1.124]
```
**어디를 봐야 하는가****두 주소가 그대로 ready 다.** 1-1 에서 본 그 두 IP.
**이 결과가 의미하는 것****두 파드가 계속 트래픽을 받으며 계속 실패한다.**
어느 replica 로 가도 결과는 같으므로 **재시도해도 소용없다.**
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
> 뜬다. 해설 문서 5절의 재현 절차에는 옛 형태(`get endpoints bff`)가 실려
> 있다 — `endpointslice` 로 본다.
## 4-4. 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 # 넣으려면 명시해야 한다
```
> **liveness 에는 넣지 않는다.** liveness 가 실패하면 kubelet 이 파드를
> **죽인다.** Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또
> 죽는다 — **재시작해도 안 나아지는 문제에 재시작을 거는 것**이다.
> 5-2 가 그 반대 증거다.
---
# 5. 복구 ① — 되돌리고 자동 회복을 본다
## 5-1. 되돌린다
**하기**
```bash
date '+%H:%M:%S 복구'
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
```
=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
```
## 5-2. 손대지 않고 회복하는지 본다
**BFF 를 재시작하고 싶은 충동을 참는다.** 재시작하면 「스스로 회복하는가」를
영영 알 수 없다.
**확인**
```bash
for p in /actuator/health /bff/token-boundary; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
done
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
```
/actuator/health HTTP 200
/bff/token-boundary HTTP 302
BFF 재시작 필요했나: 0,0 회 재시작
```
**어디를 봐야 하는가****`재시작 0,0`.**
**이 결과가 의미하는 것****Lettuce 가 스스로 재연결했다.** A-2 에서
Keycloak 의 커넥션 풀이 그랬던 것과 같다. **liveness 를 Redis 에 걸었다면
파드가 재시작됐을 것**이고, 회복이 더 늦어졌을 것이다.
**`302` 는 실패가 아니다.** 세션이 사라졌으므로 로그인으로 보내는 것이다.
**Redis 가 비었으므로 로그인 상태가 없다 — 사용자는 로그아웃된다.**
> **여기가 6절로 넘어가는 다리다.** 「Redis 를 다시 띄웠는데 왜 세션이
> 없나」가 다음 질문이고, 답은 「영속화가 없었으니까」다. 그럼 켜면 되나?
---
# 6. 주입 ② — 영속화를 켜고 파드를 지운다
## 6-1. 볼륨이 없던 상태를 다시 만든다
**지금 실험대에는 이미 PVC 가 붙어 있다**(1-3 에서 확인했다). 원래 측정
당시에는 없었다. **볼륨을 떼야 그때를 재현한다.**
**되돌리기****먼저 읽어 둔다**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
**하기**
```bash
sudo kubectl -n keycloak-lab patch deployment redis --type=json \
-p '[{"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts"},
{"op":"remove","path":"/spec/template/spec/volumes"}]'
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
**미검증** — 원래 실행은 **반대 순서**였다(볼륨 없는 상태에서 시작해 PVC 를
붙였다). 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다.
**확인** — 1-3 과 **똑같은 명령**으로 떨어진 것을 본다
```bash
sudo kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.volumes}'; echo
```
**어디를 봐야 하는가****빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면
패치가 안 먹은 것이고, 그 상태로 6-3 을 하면 **당연히 살아남는다** — 그리고
그걸 「영속화가 잘 된다」로 오독한다.
> **PVC 자체는 지우지 않는다.** Deployment 에서 참조만 뗐다. 6-5 에서
> `apply` 로 되돌리면 같은 PVC 에 다시 붙는다. **PVC 를 지우면
> `local-path` 프로비저너가 노드의 디렉터리까지 지운다.**
## 6-2. AOF 를 켜고 키를 심는다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- ls -la /data
```
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
```
--- 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
```
**어디를 봐야 하는가****`appendonlydir` 이 실제로 만들어졌다.**
**이 결과가 의미하는 것****Redis 는 시킨 대로 했다.** 설정도 `yes`
디렉터리도 있고 파일도 쓰인다. **여기서 「영속화가 켜졌다」고 결론 내리면
틀린다** — 어디에 쓰는지를 안 봤기 때문이다. 1-3 에서 이미 본 대로 지금
`/data` 는 **컨테이너 파일시스템**이다.
## 6-3. 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod -l app=redis
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
```
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
```
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
```
**어디를 봐야 하는가****두 가지가 같이 사라졌다.**
| 사라진 것 | 왜 |
|---|---|
| **데이터** | `/data` 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다 |
| **설정** | `CONFIG SET` 은 **런타임 전용**이다. 재기동하면 매니페스트의 `args` 가 이긴다 |
**이 결과가 의미하는 것**
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다. 그리고 재시작은
> 노드 정비·이미지 갱신·OOM 어느 것으로든 일어난다.
**설정이 되돌아간 것도 따로 중요하다.** `CONFIG SET` 으로 고친 값은
`CONFIG REWRITE` 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가
안 남는다. **런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.**
## 6-4. 볼륨을 되돌리고 같은 시험을 다시 한다
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
```
**확인** — 1-3 과 **똑같은 명령**으로 볼륨이 돌아온 것을 본다
```bash
sudo kubectl -n keycloak-lab get pod -l app=redis \
-o jsonpath='{.items[0].spec.volumes}'; echo
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
```
**하기** — 키를 심고 다시 지운다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
sudo kubectl -n keycloak-lab delete pod -l app=redis
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
```
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
```
=== 영속 볼륨 위에서 다시 시험 ===
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
```
**어디를 봐야 하는가****`dbsize: 1``written-on-pvc`.** 살아남았다.
> 중간의 `sed: -e expression #1, char 8: unknown option to 's'` 는
> **원래 실행의 스크립트가 낸 오류**이고 측정과는 무관하다. 값에 `/` 가
> 들어간 문자열을 `sed 's/.../.../'` 에 그대로 넣으면 이렇게 된다.
> **증거 파일에 남은 오류를 지우지 않은 것**은, 그것이 「이 줄은 스크립트가
> 만든 것」이라는 표시이기 때문이다.
## 6-5. 순서가 있다
| 구성 | 파드 삭제 후 |
|---|---|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
| AOF **켬**, **PVC** | **생존** |
**볼륨이 먼저고 설정이 나중이다.** 순서를 바꾸면 두 번째 줄이 된다 —
그리고 두 번째 줄은 **첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서
더 나쁘다.**
### `appendfsync` 는 여전히 트레이드오프다
```
appendfsync everysec ← 기본값
```
| 설정 | 잃는 양 | 비용 |
|---|---|---|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
| **`everysec`** | **최대 1초** | 기본값 |
| `no` | OS 에 맡김 | 가장 빠름 |
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시
로그인해야 한다는 뜻이다.** A-3 에서 본 PostgreSQL 의 `synchronous_commit OFF`
와 **같은 모양의 맞바꿈**이고, 거기서 Keycloak 이 같은 판단을 했다.
### PVC 도 노드에 못박힌다
**확인**
```bash
sudo kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo
```
**형태**
```
local-path
```
**`local-path` 는 노드의 디렉터리다.** A-4 에서 본 것과 같다 — **노드가 죽으면
볼륨도 함께 접근 불가**가 되고, 파드는 다른 노드로 못 옮겨간다.
**영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.**
---
# 7. 복구 ② · 원상복구 확인표
## 7-1. 실험이 심은 키를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
```
**어디를 봐야 하는가**`b5:` 로 시작하는 키가 없는 것. **`FLUSHALL`
치지 않는다** — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.
## 7-2. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| Redis | `sudo kubectl -n keycloak-lab get deploy redis` | `1/1` |
| 볼륨 | `… get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'` | `persistentVolumeClaim` 이 보인다 |
| PVC | `sudo kubectl -n keycloak-lab get pvc` | `redis-data` `Bound` |
| 영속화 | `… exec deploy/redis -- redis-cli config get appendonly` | `yes` |
| BFF | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` |
| 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** |
| 실험 키 | `… redis-cli --scan` | `b5:*` 없음 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` |
**로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `curl` 이 안 끝나고 터미널이 붙잡힌다 | **그게 이 실험의 결과다.** `000` 이 되는 과정이다 | `--max-time` 을 붙인다 — 1-4 |
| `000` 을 서버 오류로 읽는다 | `000` 은 **응답을 못 받았다**는 curl 의 표기다 | `400`/`503` 과 구별한다 — 4-1 |
| **AOF 를 켰는데 안 남는다** | **`/data` 가 볼륨이 아니다** | 결론 내리기 전에 1-3 을 먼저 |
| 6-3 에서 데이터가 **살아남는다** | 볼륨 제거 패치가 안 먹었다 | `get pod … spec.volumes`**비어야** 한다 — 6-1 |
| `config set` 한 값이 재기동 후 사라진다 | **런타임 전용이다.** 매니페스트 `args` 가 이긴다 | 6-3 |
| `/actuator/health` 응답 자리에 이상한 문자열 | **측정 실패다.** 값이 아니다 (`HTTP server`) | 그 자리에서 다시 친다 — 4-2 |
| 파드가 `NotReady` 가 되기를 기다린다 | **안 된다.** `redis` 지표가 readiness 그룹에 없다 | 4-2 · 4-4 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=bff` |
| 회복 후 로그인이 풀려 있다 | **정상이다.** Redis 가 비었으니 세션이 없다 | `302` 는 실패가 아니다 — 5-2 |
| BFF 를 재시작해 버렸다 | 「스스로 회복하는가」를 못 재게 된다 | 다시 2절부터. 손대지 않고 기다린다 |
| PVC 가 `Pending` | `local-path` 프로비저너가 없거나 노드가 안 맞는다 | `describe pvc redis-data` 의 Events |
| 다른 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 7-1 |
---
# 이 실험이 관측에 남긴 숙제
**Grafana 에 이 실험의 그래프가 없다.** 안 찍은 것이 아니라 **지표가 없다.**
**실측** — [`04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
(후속 조사)
```
=== B층 구성 요소의 지표가 있는가 ===
redis_up 시계열 0개
redis_connected_clients 시계열 0개
pg_up 시계열 0개
pg_stat_database_numbackends 시계열 0개
```
Prometheus 가 긁는 대상에 **Redis·PostgreSQL·BFF 가 애초에 없다.**
A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이 `/metrics` 를 내놓고
그것을 scrape 대상에 넣어 뒀기 때문이다.
> **관측은 「나중에 붙이는 것」이 아니라 실험 설계에 포함되어야 한다.**
> 이 실험에서 「Redis 가 언제 끊겼고 언제 붙었나」를 초 단위로 보고 싶다면
> `redis_exporter` 가 먼저 있어야 하고, 그건 실험이 끝난 뒤에는 못 만든다.
| 대상 | 방법 |
|---|---|
| Redis | `redis_exporter` 사이드카 또는 Deployment |
| PostgreSQL | `postgres_exporter` |
| BFF | 이미 actuator 가 있다 — `/actuator/prometheus` 노출 + scrape 추가 |
| 파드 readiness | `kube-state-metrics` (A-2 에서 이미 찾은 항목) |
---
# 다음
| 실험 | B-5 가 남긴 질문 |
|---|---|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **Redis lock 을 쓴다면 여기서 갱신이 멈춘다.** DB 행 잠금이 유리한 이유가 이 실험으로 보강된다 |
| B-6 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
| D-1 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다.** 기본값은 결정이 아니다 |
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 `000``500` 으로 바꾼다 |
+707
View File
@@ -0,0 +1,707 @@
# B-6 재현 가이드 — 서명 키를 회전하고, 옛 키를 버리는 순간을 직접 본다
해설 문서: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md) ·
증거 원문: [`docs/evidence/b6-key-rotation/`](../../evidence/b6-key-rotation/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 토큰 헤더에 어느 키로 서명했는지가 적혀 있는 것 | JWT 첫 토막 (`kid`) |
| 키를 「추가」했는데 옛 키가 JWKS 에 그대로 남는 것 | `.../openid-connect/certs` |
| 옛 토큰과 새 토큰이 **둘 다 200** 인 무중단 구간 | echo `/api/me` |
| 옛 키를 지운 **직후** 옛 토큰이 401 이 되는 것 | 같은 엔드포인트 |
| 리소스 서버를 재시작해도 여전히 401 인 것 | `rollout restart deploy/echo` |
| `kcadm` 의 필터가 오류 없이 빈 결과를 주는 것 | `get components -q type=...` |
## 전제
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns`
클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 실험의 401/200 은
전부 그 앱이 판정한다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- **Keycloak 이미지에는 `curl``wget` 도 없다**(`exit 127`). 그래서 JWKS 와
토큰은 **`kc-lab-1` 호스트에서** 공개 이름으로 친다. `kcadm.sh` 만 파드 안에서
돈다 — 항상 `kubectl exec` 로 감싼다.
- 이 실험대에는 **`jq` 가 없다.** JSON 은 `tr``grep` 으로 자른다.
## 주의 — 이건 되돌릴 수 없는 실험이다
**서명 키 공급자를 실제로 지운다. 지운 키는 돌아오지 않는다.**
같은 이름으로 공급자를 다시 만들어도 **새 키 쌍이 생기고 `kid` 가 다르다.**
그러니 옛 키로 서명된 토큰은 **영구히** 검증되지 않는다.
**실험대에서만 한다.** 전 구간 약 15분이고, 3절까지는 아무것도 안 깨진다.
파괴가 시작되는 지점은 [4. 관찰](#4-관찰--옛-키를-제거한다) 이며,
그 앞에 경고를 다시 붙여 두었다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:3014:32 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> 해설 문서 머리에 적힌 `15:5016:00 KST` 는 **문서를 쓴 시각**이고,
> 증거 파일의 mtime 은 `14:3014:32 KST` 다. **실측으로 인용하는 것은 뒤쪽**이다.
`kid`·컴포넌트 id 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
Q3 의 미지수 3 은 이렇게 물었다.
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
> 저장된 값은 어떻게 읽는가."*
**질문이 두 갈래로 갈린다.**
| | 상태 |
|---|---|
| ① 토큰 **저장소**의 암호화 key | **존재하지 않는다.** B-2 에서 `bytea` 안이 JWT 문자열 그대로임을 확인했다 |
| ② 토큰 **서명** key (Keycloak realm) | 존재하고 회전 가능하다 — **이 실험이 잰다** |
①이 없으므로 교체할 것도 없다. 그래서 이 가이드는 ②만 친다. 그리고 ②에서
본 모양이 나중에 ①을 설계할 때 그대로 쓰인다.
**그리고 이 실험은 예측이 틀린 실험이다.**
| | |
|---|---|
| 예측 | 리소스 서버가 JWKS 를 캐시하니, 옛 키를 지워도 **한동안은 통할 것** |
| **실측** | **유예가 없다. 제거 직후 바로 401 이다** |
이유는 뒤에서 본다. **캐시를 유예 기간으로 기대하면 안 된다**는 것이 이 실험이
남긴 한 줄이고, 그것을 당신 터미널에서 확인하는 것이 이 가이드의 목적이다.
핵심은 **두 동작을 분리해서 보는 것**이다.
```
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
「교체」라는 한 단어가 실제로는 **서로 성질이 정반대인 두 조작**이다.
회전이 위험한 게 아니라 **옛 키를 언제 버리느냐**가 위험하다.
---
# 1. 기준선 — 아무것도 바꾸기 전에
**시험군만 재는 측정은 측정이 아니다.** 제거 후에 볼 것을 제거 전에 **똑같은
명령으로** 먼저 봐 둔다. 그래야 「원래 그랬던 것」과 「내가 바꾼 것」이 구별된다.
넓은 것부터 좁혀 간다.
```
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
```
## 1-1. kcadm 을 먼저 로그인시킨다
`kcadm.sh`**파드 안 파일에 세션을 저장한다.** 파드가 재시작되면 사라지고,
그 뒤 모든 명령이 `401` 로 떨어진다. **맨 앞에서 한 번 해 둔다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**어디를 봐야 하는가****아무것도 안 나오면 성공이다.** 실패하면 한 줄 오류가 뜬다.
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재만 확인하고 싶으면 길이만 본다.
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
## 1-2. 지금 어떤 키 공급자가 있나
**확인** — 통째로 받아서 눈으로 본다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
```
**형태** — JSON 배열이 여러 줄로 나온다. `providerId``rsa-generated`
항목이 서명 키 공급자이고, `hmac-generated`·`aes-generated` 등이 함께 나온다.
**어디를 봐야 하는가**`"name" : "rsa-generated"` 인 항목의 `"id"`.
**4절에서 지울 대상이 이것이다.** 지금 적어 둔다.
### ★ 여기서 조용한 실패를 하나 만난다
「키 공급자만 걸러 보자」는 자연스러운 시도가 **빈 결과**를 준다.
**미검증** — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
**오류도 종료코드도 없이 비어 있다.** 「키 공급자가 하나도 없구나」로 읽으면
이 실험 전체가 무너진다. **`-q` 필터를 믿지 말고 `--fields` 로 전체를 받는다.**
> A층 내내 반복해 만난 유형이다 — **조용한 실패.** 빈 출력은 「없다」가 아니라
> 「이 명령으로는 안 보인다」일 수 있다. 다른 명령으로 한 번 더 확인한다.
## 1-3. JWKS 원문을 한 번 통째로 본다
**확인**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
**줄바꿈 없이 한 줄로 길게 나온다. 그래도 처음 한 번은 그대로 본다.**
어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**실측** — 첫머리.
[`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
에 남은 조각 그대로다
```
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
그 뒤로 `kty`·`alg`·`use`·`n`·`e` 가 이어지고 다음 키가 온다.
**`kid` 마다 `alg` 가 따로 붙는다** — 이 사실이 바로 아래에서 쓰인다.
읽을 만하게 자른다. `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
**확인**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
```
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
**어디를 봐야 하는가****`kid` 는 두 개인데 이 실험이 세는 RS256 키는 하나다.**
같은 파일의 바로 윗줄이 그렇게 말한다.
**실측**
```
JWKS 의 RS256 키 수: 1
```
**세는 단위가 다르다.** JWKS 에는 서명 키만 실리는 게 아니다. 이 realm 에서는
암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고, 그것도 `kid` 를 갖는다.
**`grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.**
알고리즘까지 보고 세려면 키 단위로 잘라야 한다. JWKS 는 키 하나가 `}`
끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다. **미검증**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 편이 더 확실하다 — **이쪽이 1순위 도구다. 미검증**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get keys -r keycloak-patterns
```
**어디를 봐야 하는가** — 키마다 붙는 `algorithm``status`.
`RS256` 이면서 `ACTIVE` 인 것이 **지금 서명에 쓰이는 키**다.
> 이 두 명령은 원래 실행 기록에 출력이 없다. **당신 출력에서 필드 이름을 직접
> 확인한다.** 위에 인용한 「RS256 키 수: 1」만이 실측이다.
## 1-4. 토큰을 하나 받고, 그 토큰의 kid 를 본다
**하기** — direct grant 로 받는다. 클라이언트 비밀은 Secret 에서 꺼내 쓴다
```bash
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
-d username=labuser -d password=labpass -d scope=openid \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}"
```
**형태**
```
2043자
```
`0자` 로 나오면 토큰을 못 받은 것이다. 변수에 담지 말고 응답을 그대로 찍어
본문을 읽는다.
> **이 토큰이 이 실험의 시험체다.** 변수 이름을 `OLD` 로 둔 이유는, 회전이
> 끝난 뒤에도 **이것이 「옛 키로 서명된 토큰」으로 남아야** 하기 때문이다.
> 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다.
**확인** — JWT 의 **첫 토막**이 헤더다. 거기 `kid` 가 있다
```bash
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**형태**
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
```
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
```
**어디를 봐야 하는가**`kid` 가 1-3 의 JWKS 목록에 있는 값과 같은가.
**이것이 이 실험의 뼈대다.** 토큰이 자기 서명 키를 스스로 밝히고 있다.
> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다).
> 헤더는 짧아서 대개 온전히 보인다.
### 개념 — `kid` 가 있어서 여러 키를 동시에 운용할 수 있다
**무엇인가.** `kid` 는 key ID 다. 서명한 쪽이 **어느 키를 썼는지**를 토큰 헤더에
적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다.
**왜 여기 나오나.** `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고,
키가 바뀌는 순간 옛 토큰은 전부 죽는다. **`kid` 가 겹침 구간을 가능하게 한다.**
**없거나 틀리면.** 겹침이 불가능해진다 — 그 사례를 B-7 에서 본다.
oauth2-proxy 의 쿠키에는 `kid` 에 해당하는 표시가 없고, 그래서
`--cookie-secret` 도 단수다.
## 1-5. 그 토큰이 지금 통하는가 — 대조군
**이 절을 건너뛰면 뒤의 401 은 아무 의미가 없다.** 「원래 안 됐던 것」과
「내가 깨뜨린 것」을 구별할 수단이 이것뿐이다.
먼저 응답을 통째로 한 번 본다.
**확인**
```bash
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**어디를 봐야 하는가** — 상태줄과 본문. 200 이면 `subject` 같은 클레임이 돌아온다.
401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. **이 헤더를 한 번 봐 두면
뒤에서 401 이 났을 때 「왜」를 묻는 자리가 생긴다.**
이제부터는 여러 번 비교해야 하므로 코드만 뽑는다.
**확인**
```bash
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
```
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
```
**이 결과가 의미하는 것** — 회전 전에는 통한다. **이 200 이 기준선이다.**
> **원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤다.**
> 이 가이드가 공개 이름을 쓰는 것은 **`kc-lab-1` 에서는 클러스터 DNS 가 안
> 풀리기 때문**이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo`
> 로 보내므로 **도달하는 앱은 같다.** 인용한 `HTTP 200` 은 원래 실행의 값이다.
> **access token 은 60초짜리다**(이 realm 은 `accessTokenLifespan=60`).
> 1분을 넘기면 회전과 무관하게 401 이 난다. **뒤에서 401 을 만나면 먼저
> 「만료인가 키 문제인가」를 갈라야 한다** — 3-3 에 그 방법을 적어 두었다.
---
# 2. 주입 — 우선순위가 더 높은 키 공급자를 추가한다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기** — 방금 만든 공급자를 지우면 원래대로 돌아간다.
**id 는 2-2 가 출력하는 값이고, 그 줄을 그대로 옮겨 친다.** 원래 실행에서는
`7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns
```
2절만 되돌리는 것은 안전하다. **되돌릴 수 없는 것은 4절이다.**
## 2-1. 무엇을 하는 것인가 — 먼저 읽는다
**Keycloak 의 키 회전은 「바꾸기」가 아니라 「더 높은 우선순위로 추가하기」다.**
기존 공급자는 그대로 두고, `priority` 가 더 큰 공급자를 하나 더 만든다.
그러면 **발급은 새 키로 가고, 검증은 둘 다 받는다.** 옛 키는 아무 데도 안 갔다.
```
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
```
**이 실험이 재는 것은 t1 이 무중단인가(2~3절)와, t2 가 언제 안전한가(4절)다.**
## 2-2. 추가한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create components -r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
date '+%H:%M:%S 추가'
```
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
```
**어디를 봐야 하는가****돌아온 id 를 적어 둔다.** 되돌릴 때 쓴다.
그리고 `config.priority`**기존 공급자보다 큰지** — 기본값은 100 이고
여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 **발급에 쓰이지 않아**
3-2 에서 kid 가 안 바뀐다.
> `config.*` 값이 **대괄호로 감싼 배열**인 것에 주의한다. `-s config.priority=200`
> 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
---
# 3. 추가가 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. JWKS 에 두 키가 함께 있는가
**확인** — 1-3 과 **똑같은 명령**을 다시 친다
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
**어디를 봐야 하는가****옛 `kid`(`OY-caYDN…`)가 목록에 그대로 있다.**
새 것이 하나 늘었고, 아무것도 사라지지 않았다.
**이 결과가 의미하는 것** — 「회전」이라는 말과 달리 **아무것도 교체되지 않았다.**
JWKS 는 「지금 검증에 쓸 수 있는 키 전부」를 싣는 목록이고, 추가는 그 목록을
늘릴 뿐이다.
## 3-2. 새 토큰은 어느 키로 서명되는가
**하기** — 지금 새로 하나 받는다. **`OLD` 은 건드리지 않는다**
```bash
NEW=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
-d username=labuser -d password=labpass -d scope=openid \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
**어디를 봐야 하는가**`kid` 가 **우선순위 200 짜리 새 키**로 바뀌었다.
**이 결과가 의미하는 것** — 발급은 우선순위가 가장 높은 키로 간다.
**여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.** 2-2 로 돌아간다.
## 3-3. ★ 둘 다 통하는가 — 무중단 구간의 실측
**확인**
```bash
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
```
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
**어디를 봐야 하는가****둘 다 200.**
**이 결과가 의미하는 것****키 추가는 무중단이다.** 새 토큰은 새 키로 서명되고,
옛 토큰은 **JWKS 에 아직 있는 옛 키로 검증된다.** 사용자는 아무것도 못 느낀다.
> **여기서 `old` 가 401 이면 두 가지 중 하나다.**
> ① 토큰이 만료됐다(60초). ② 뭔가 다른 것을 건드렸다.
> **가르는 법** — 옛 토큰의 `exp` 를 본다. JWT 의 **가운데 토막**이 클레임이다.
> ```bash
> echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
> date +%s
> ```
> `exp` 가 지금보다 작으면 만료다. 1-4 로 돌아가 다시 받되, **이번에는 추가
> 전에** 받아야 「옛 키로 서명된 토큰」이 된다.
---
# 4. 관찰 — 옛 키를 제거한다
## ★ 여기서부터 되돌릴 수 없다
**이 절이 이 실험의 본 시험이다. 그리고 되돌릴 수 없다.**
지우는 것은 키 공급자이고, 그 안의 **개인키가 함께 사라진다.**
같은 이름으로 다시 만들어도 **다른 키 쌍**이 생긴다.
계속하기 전에 확인한다.
- 이 realm 이 **실험대 전용**인가
- 지금 살아 있는 세션 중에 **잃으면 곤란한 것**이 있는가
- 3-3 의 `old 200`**실제로 봤는가** (안 봤다면 401 이 나와도 원인을 못 가른다)
## 4-1. 지울 대상을 정확히 고른다
**확인** — 1-2 와 같은 명령. `-q` 는 여전히 안 먹는다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
```
**어디를 봐야 하는가**`"name" : "rsa-generated"` 인 항목의 id.
방금 만든 것은 `"name" : "rsa-rotated"` 다. **둘을 바꿔 지우면 실험이 뒤집힌다.**
목록이 길면 그 항목 주변만 잘라 본다. `"id"``"name"` 보다 **위**에 있다.
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId \
| grep -B2 '"name" : "rsa-generated"'
```
원래 실행에서 지운 것은 이것이다.
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
```
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
```
`980ee9b7…` 로 시작하는 것이 옛 공급자의 id 이고, 그것이 `OY-caYDN…` 키를
갖고 있었다. **당신 환경의 id 는 다르다.** 증거에 남은 것도 앞 8자뿐이니
**전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.**
## 4-2. 지운다
**하기** — 위 출력에서 고른 id 를 변수에 넣고 지운다
```bash
OLDID=980ee9b7-... # ← 4-1 의 출력에서 그대로 옮긴다. 환경마다 다르다
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'
```
**어디를 봐야 하는가** — 조용히 끝나면 성공이다. **시각을 적어 둔다.**
## 4-3. JWKS 에서 사라졌는가
**확인** — 또 같은 명령이다
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
```
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
**어디를 봐야 하는가****`OY-caYDN…` 이 없다.** RS256 은 다시 1개다.
`gokjn0…` 은 처음부터 끝까지 그대로 있다 — 서명 키가 아니기 때문이다(1-3).
## 4-4. ★ 옛 토큰은 이제 어떻게 되는가
**확인** — 3-3 과 **똑같은 두 줄**
```bash
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
```
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
```
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
```
**어디를 봐야 하는가****옛 토큰 401, 새 토큰 200.**
**이 결과가 의미하는 것** — 제거는 **즉시** 반영된다.
괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 **측정하기 전에 적어 둔
예상**이고, **옆의 401 이 그 예상을 부정한 값이다.** 증거 파일에 예상과 결과가
나란히 남아 있는 셈이다.
> **새 토큰도 401 이면** 제거를 잘못했다 — 새 공급자를 지운 것이다.
> `kid` 를 다시 확인한다(3-2). 아니면 그냥 만료다(3-3 의 박스).
## 4-5. 캐시가 구해주지 않는다 — 재시작으로 확인한다
여기까지 보면 「리소스 서버가 아직 JWKS 를 캐시하고 있어서 우연히 401 인가?」
라는 의심이 남는다. **캐시를 비워 보면 갈린다.**
**하기**
```bash
sudo kubectl -n header-lab rollout restart deploy/echo
sudo kubectl -n header-lab rollout status deploy/echo --timeout=180s
```
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
```
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
**어디를 봐야 하는가****재시작 전과 후가 같다.** 401 / 200.
**이 결과가 의미하는 것** — 401 은 캐시 상태와 무관하다.
**캐시는 유예를 주지 않았다.**
### 왜 그런가
Spring 의 `NimbusJwtDecoder` 는 **모르는 `kid` 를 만나면 JWKS 를 다시
가져온다.** 캐시는 「이미 아는 키를 다시 안 받으려는」 장치이지 「옛 키를
붙잡아 두는」 장치가 아니다.
```
옛 토큰 도착
├─▶ kid = OY-caYDN… → 캐시에 없다
│ │
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
└─▶ 새로 받은 JWKS 에도 없다 → 401
```
**캐시가 오히려 제거를 빨리 반영시킨다.** 예측이 정확히 반대였던 이유다.
> **유예는 캐시로 만드는 것이 아니라, 옛 키를 JWKS 에 남겨 두는 기간으로
> 만들어야 한다.** 이것이 이 실험의 한 줄이다.
## 4-6. 그래서 겹침 구간은 얼마나 길어야 하는가
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
| 이 실험대에서 | |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| **필요한 겹침** | **최소 30분** |
**확인** — 이 값들은 realm 설정이다. 직접 본다. **미검증**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
```
**이 결과가 의미하는 것** — 「교체하는 동안」의 길이를 정하는 것은 **key 가
아니라 그 key 로 만든 것의 수명**이다. 30분짜리 refresh token 을 발급하면서
겹침을 5분만 두면 **25분어치의 토큰을 죽이는 것**이다.
### ①에 적용하면 — 저장소를 암호화한다면
```
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유이고,
그것이 없을 때 어떻게 되는지가 다음 실험(B-7)이다.
---
# 5. 복구
## 5-1. ★ 옛 키는 돌아오지 않는다
**이 실험에는 「원상복구」가 없다.** 지운 키 공급자는 개인키와 함께 사라졌다.
같은 이름으로 다시 만들면 **새 키 쌍**이 생기고 `kid` 가 다르므로, 옛 토큰은
그래도 401 이다.
**정상 상태는 「새 키 하나만 남은 상태」다.** 4-3 의 출력이 그 상태이고,
실험 전과 다르지만 **깨진 상태가 아니다.**
## 5-2. 실험이 남긴 것을 정리한다
| 남은 것 | 어떻게 | |
|---|---|---|
| `rsa-rotated` 공급자 | **그냥 둔다.** 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
**이름이 거슬리면** 새 공급자를 하나 더 만들고(2-2) `rsa-rotated` 를 지우면
된다. **다만 그것 역시 또 한 번의 회전이고, 또 하나의 새 키다.**
## 5-3. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 서명 키 | `curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 **하나** |
| 새 토큰 | 1-4 의 발급 + 1-5 의 `/api/me` | `200` |
| 리소스 서버 | `sudo kubectl -n header-lab get pods` | `echo``1/1 Running` |
| Keycloak | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
> **이 실험이 재지 않은 것** — 겹침 구간을 실제로 30분 유지하며 그 사이에
> 발급된 refresh token 이 t2 이후 어떻게 되는지는 측정하지 않았다.
> 재려면 2절과 4절 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로
> 4절 뒤에 갱신을 시도한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `kcadm get components -q type=...` 가 빈 결과 | **`-q` 필터가 안 먹는다. 오류도 없다** | `--fields id,name,providerId` 로 전체를 받는다 — 1-2 |
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 — 1-1 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | JWKS·토큰은 `kc-lab-1` 호스트에서 친다 |
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 — 1-3 |
| kid 를 세니 2개인데 문서는 1개라고 한다 | **RS256 이 아닌 암호화 키가 섞여 있다** | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` — 1-3 |
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 **배열**인지 — 2-2 |
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 **토큰이 만료**됐다(60초) | 클레임의 `exp``date +%s` 비교 — 3-3 |
| 제거했는데 **새** 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 — 4-1 |
| 제거했는데 옛 토큰이 **200** | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 — 4-1 |
| 「캐시 때문일 것」이라 재시작을 기다린다 | **캐시는 유예를 주지 않는다** | 재시작 전후가 같다 — 4-5 |
| 지운 키를 되살리려 한다 | **되살릴 수 없다.** 같은 이름 ≠ 같은 키 | 5-1 |
---
# 다음
| 실험 | B-6 이 남긴 질문 |
|---|---|
| [B-7](../../experiment-b7-cookie-secret-rotation.md) cookie secret | **같은 모양의 문제인데 `kid` 가 없다.** 겹침 구간을 만들 수 있는가 — 답은 「없다」 |
| [D-2](../../experiment-d2-version-upgrade.md) 버전 업그레이드 | Redis 의 Java 직렬화 세션도 같은 **「옛 형식을 읽을 수 있는가」** 문제다 |
| 설계 | 저장소를 암호화한다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
| 전부 | **빈 출력은 「없다」가 아니다.** `-q` 필터 하나가 조용히 실패했다 |
@@ -0,0 +1,766 @@
# B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다
해설 문서: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md) ·
증거 원문: [`docs/evidence/b7-cookie-secret/`](../../evidence/b7-cookie-secret/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| replica 두 개가 상태를 안 나누고도 로그인이 되는 것 | oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica) |
| 큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 | Traefik 직접 vs nginx |
| `--cookie-secret` 이 **단수**라는 것 | `oauth2-proxy --help` |
| 옛 쿠키가 `session ticket cookie failed validation` 로 죽는 것 | 프록시 로그 |
| **로그인 화면 없이 조용히 재로그인**되는 것 | 브라우저 |
| ★ 프록시가 **지우지 못한** 서버 세션이 Redis 에 남는 것 | `redis-cli --scan` |
## 전제
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns`
클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
- Redis 가 `redis.keycloak-lab.svc:6379` 로 떠 있다.
- **브라우저가 필요하다.** 쿠키가 `HttpOnly` 이고 OIDC 흐름을 끝까지 걸어야
세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 이 실험대에는 **`jq``yamllint` 도 없다.**
## 주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다
**두 가지를 건드린다.**
1. **`app2.hyeonworks.com` 은 평소 `observability` 네임스페이스의 Grafana 로
간다.** 인증서가 `auth`·`app1`·`app2` 세 이름만 덮고 있어서 네 번째 이름을
못 만든다. 그래서 **Grafana 의 Ingress 를 잠시 내리고 빌린다.**
**반드시 되돌린다** — [5-3](#5-3--grafana-ingress-를-되돌린다) 이 그 절차다.
백업을 뜨는 것이 [1-1](#1-1-먼저-grafana-ingress-를-백업한다) 의 첫 명령인 이유다.
2. **secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.**
실험대에서만 한다.
전 구간 약 20분이다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 친다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:3514:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
> 수집 시각이 `14:3514:42 KST` 인 것은 **같은 시간대의 같은 순간**이다(KST = UTC+9).
> 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
파드 이름·Redis 키·쿠키 값은 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
Q1 의 미지수 7 은 이렇게 물었다.
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
B-6 에서 Keycloak 은 **두 키를 동시에 들고** 무중단으로 회전했다. `kid`
있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.
| | 예측 |
|---|---|
| B-6 의 모양대로라면 | oauth2-proxy 도 **겹침 구간을 만들 수 있을 것** |
| **실측** | **★ 없다.** `--cookie-secret` 은 단수이고 쿠키에 key 식별자가 없다 |
**그리고 예측하지 않았던 것이 하나 더 나온다** — 사용자는 아무것도 못 느끼는데
**서버 쪽에 지워지지 않는 세션이 남는다.** 그 「지우지 못한다」를 이어서 재는
것이 [B-7a](b7a-orphan-session.md) 다.
핵심은 **상태를 어디에 두었는가**다.
```
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
대신 secret 이 단일 지점
```
**공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.**
---
# 1. 기준선 — 아무것도 바꾸기 전에
넓은 것부터 좁혀 간다.
```
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
```
## 1-1. 먼저 Grafana Ingress 를 백업한다
**이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.**
**확인** — 지금 app2 가 무엇인지 먼저 본다
```bash
curl -sI https://app2.hyeonworks.com/ | head -3
```
**형태** — Grafana 로 가고 있으면 `302``/login` 을 가리킨다.
**하기** — 백업을 뜨고, 파일이 비지 않았는지 확인한다
```bash
sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
**어디를 봐야 하는가** — 줄 수가 **0 이 아니고**, `app2.hyeonworks.com`
**1회 이상** 잡혀야 한다. `0` 이면 백업이 빈 파일이고, 그 상태로 진행하면
복구할 것이 없다.
**하기**
```bash
sudo kubectl -n observability delete ingress grafana
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
```
**되돌리기**`sudo kubectl apply -f ~/grafana-ingress-backup.yaml`
## 1-2. oauth2-proxy 를 배포한다
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
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
```
**되돌리기**`sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml`
## 1-3. replica 두 개가 서로 다른 노드에 있는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
```
**어디를 봐야 하는가****파드 두 개, 서로 다른 노드.** 그리고 **파드 이름의
끝 다섯 글자**를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을
했는지」를 이 글자로 가른다.
**이 결과가 의미하는 것** — replica 가 둘이라는 것이 Q1 의 질문 자체다.
하나면 「공유」라는 말이 성립하지 않는다.
## 1-4. 진입점이 살아 있는가
**확인**
```bash
curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
```
**어디를 봐야 하는가**
| 경로 | 정상 | 뜻 |
|---|---|---|
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — **프록시가 일하고 있다** |
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — **프록시 자체는 살아 있다** |
**두 값이 갈라지는 것이 중요하다.** `/ping` 도 안 되면 프록시가 안 뜬 것이고,
`/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.
## 1-5. ★ 502 가 나면 — 계층을 가른다
원래 구성에서 **콜백이 계속 502** 였다. 이 절은 그때 무엇을 쳤는지다.
**502 를 안 만났으면 읽고 넘어간다.**
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**502 는 「누가 냈는지」를 안 알려 준다.** 앞단 nginx 인지, 그 뒤 Traefik 인지,
파드인지. **한 겹씩 벗겨서 좁힌다.**
**확인** — nginx 를 건너뛰고 Traefik 에 직접 묻는다
```bash
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
```
**실측**
```
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
```
**어디를 봐야 하는가****Traefik 직접은 정상이다.** 그러면 502 를 내는 것은
그 앞의 nginx 다. 그리고 502 는 **쿠키를 설정하는 응답에서만** 났다.
**이 결과가 의미하는 것** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에
담는다.** 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시
> 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
**해결** — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
```
**형태**
```
"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
```
### ★ 그리고 여기서 조용한 실패를 하나 만난다
nginx 설정을 보려던 시도가 계속 **빈 결과**였다.
**실측**
```
$ sudo -n true
sudo: a password is required
```
**`test-server`(호스트)의 sudo 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는
무암호라 A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
**앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가
조용히 실패한 것이었다.** 호스트에서 무언가가 빈 결과를 주면 **먼저
`sudo -n true` 를 쳐 본다.**
## 1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다
**확인** — 키 이름만 본다
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**형태**
```
"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"
```
**확인** — 길이만 본다. **값은 절대 찍지 않는다**
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
```
**어디를 봐야 하는가****oauth2-proxy 는 정확히 16·24·32 바이트만 받는다.**
매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다.
**미검증** — 원래 실행 기록에 이 명령의 출력은 없다.
**이 결과가 의미하는 것****회전 대상이 미리 두 개 준비되어 있다.**
이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.
## 1-7. 로그인해서 세션을 하나 만든다
**하기** — 브라우저에서
```
https://app2.hyeonworks.com/api/echo → labuser / labpass
```
**어디를 봐야 하는가** — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의
JSON 이 보인다.
**이 결과가 의미하는 것** — upstream 이 받은 헤더가 그대로 찍힌다.
**실측** — [`b7-oauth2proxy-login-success.png`](../../evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
```json
"x-forwarded-email" : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto" : [ "https" ]
```
> **B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
> Forward-Auth 구조의 신원 전달 방식이고, **B-4 의 결론이 그대로 적용된다** —
> edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
## 1-8. 세션이 Redis 에 들어갔는가
**확인** — 먼저 통째로 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
```
**어디를 봐야 하는가****`dbsize` 는 2 인데 세션은 하나다.**
`b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다.
**이 결과가 의미하는 것****`dbsize` 로 세션을 세면 틀린다.**
이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
> `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹한다.
> 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
## 1-9. 쿠키가 「티켓」인지 확인한다
세션 저장소를 Redis 로 옮기면 쿠키에는 **세션 전체가 아니라 티켓**만 담긴다.
**하기** — 브라우저 개발자 도구 → Application/저장소 → Cookies → `_oauth2_proxy`
**실측** — 해설 문서에 남은 값
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**어디를 봐야 하는가**`|` 로 나뉜 **세 토막**과 전체 길이.
```
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
```
**이 결과가 의미하는 것** — 쿠키가 짧아졌고(그래서 502 가 사라졌고),
**Redis 키 이름은 이 티켓에서 계산된다.** 4절의 「지우지 못한다」가 여기서
결정된다.
---
# 2. 주입 — secret 을 A 에서 B 로 바꾼다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
```
## 2-1. 먼저 「겹칠 수 있는가」를 묻는다
**바꾸기 전에 이것부터 확인한다.** B-6 의 무중단 회전이 여기서도 되는지가
Q1 의 질문 자체이기 때문이다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**어디를 봐야 하는가****`string`. 복수형이 아니다.**
`--cookie-secrets``--old-cookie-secret` 도 목록에 없다.
**이 결과가 의미하는 것****겹침 구간을 만들 수단이 아예 없다.**
B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid`
있어서였다. **oauth2-proxy 의 쿠키에는 그런 식별자가 없다.**
```
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
```
**이 한 줄이 이 실험의 답이다.** 나머지는 「그래서 실제로 무슨 일이 나는가」다.
## 2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다
아래 patch 는 `env/1` 을 지목한다. **매니페스트의 순서에 달린 값이다.**
그대로 믿지 말고 확인한다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
```
**형태**
```
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
```
**어디를 봐야 하는가**`OAUTH2_PROXY_COOKIE_SECRET`**몇 번째인가**
(0부터 센다). 위 형태에서는 두 번째이므로 `env/1` 이다. 순서가 다르면
patch 의 숫자를 고친다. **틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.**
## 2-3. 바꾼다
**하기**
```bash
date -u '+%H:%M:%S UTC 회전'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_B"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
```
**시각을 UTC 로 적어 둔다.** 프록시 로그가 UTC 이고, [B-7a](b7a-orphan-session.md)
의 정리 규칙이 **이 시각을 기준으로** 고아를 고른다.
---
# 3. 교체가 실제로 걸렸는지 확인한다
## 3-1. 지금 어느 키를 참조하는가
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
현재 secret 키: COOKIE_SECRET_B
```
**어디를 봐야 하는가**`COOKIE_SECRET_B`. **Deployment 의 참조가 바뀐 것이지
Secret 의 내용이 바뀐 것이 아니다.** 두 값 다 그대로 있고 어느 쪽을 읽을지만
바뀌었다 — 그래서 되돌리기가 한 줄이다.
## 3-2. ★ 그런데 Redis 는 그대로다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
Redis 세션은 그대로인가: 2 키
```
**어디를 봐야 하는가** — 세션 수가 **회전 전과 같다.**
**이 결과가 의미하는 것****회전 자체는 아무 일도 일으키지 않는다.**
여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면
**누군가 옛 쿠키를 들고 와야** 한다. 그게 4절이다.
> 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이
> [B-7a](b7a-orphan-session.md) 의 기준선이다.
## 3-3. 파드가 실제로 새로 떴는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**어디를 봐야 하는가****파드 이름이 1-3 과 다르다.** 같으면 patch 가 아무
필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.
---
# 4. 관찰 — 옛 쿠키를 들고 가 본다
## 4-1. 브라우저로 다시 연다
**하기** — 1-7 에서 로그인한 **그 브라우저 그대로**
```
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.
**이 결과가 의미하는 것** — **Keycloak SSO 세션이 살아 있어서 조용히 재인증이
일어났다.** 쿠키는 분명히 무효가 됐는데 **사용자 눈에는 아무 일도 없었다.**
> **여기가 이 실험에서 가장 오해하기 쉬운 자리다.** 「로그인 화면이 안 떴으니
> 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. **쿠키는 죽었고,
> 사용자는 실제로 재인증을 거쳤다.** SSO 가 그 사실을 가려 준 것뿐이다.
> **IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.**
## 4-2. 로그가 무슨 일이 났는지 말한다
**확인** — 먼저 최근 로그를 그대로 본다
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
```
**한 번은 통째로 본다.** 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다 — **replica 가 둘이므로 이게 없으면
누가 무엇을 했는지 못 가린다.**
이제 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
[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>
```
**어디를 봐야 하는가****두 줄이 다른 말을 하고 있다.**
| 줄 | 뜻 |
|---|---|
| `stored_session.go:94` | 쿠키를 열 수 없다 → **세션을 지우겠다** |
| `stored_session.go:97` | **그 지우기가 실패했다**`error decoding ticket to clear session` |
**94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.**
이어지는 줄이 사용자 쪽 이야기다.
**실측**
```
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...
```
**어디를 봐야 하는가**`Initiating login``AuthSuccess` 가 **같은 초**에 있다.
**로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다.** 4-1 에서 화면이
깜빡였던 것이 이것이다.
## 4-3. ★ Redis 에 고아가 남는다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
```
**어디를 봐야 하는가****키가 둘이다.** 뒤엣것(`b26111f…`)은 1-8 에서 본
회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.
**이 결과가 의미하는 것****사용자는 하나인데 서버 세션이 둘이다.**
옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. **고아다.**
## 4-4. 왜 못 지우는가 — 티켓과 키의 관계
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 **티켓**만 담긴다(1-9).
티켓은 두 부분이다.
```
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
```
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 봉인되어 있다.
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는
알지만 **「그 세션이 Redis 어디에 있다」를 모른다.** 그래서 `removing session`
을 시도하고 실패한다(4-2 의 97 번 줄).
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** 여기서 이 실험은 멈췄다.
「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를
[B-7a](b7a-orphan-session.md) 가 이어서 잰다. **답은 「지울 수 있다」이고,
「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.**
## 4-5. 덤 — replica 를 넘어도 되는 이유
로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
| grep -E 'Initiating login|AuthSuccess'
```
**실측** — 해설 문서에 남은 형태
```
--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo" ← 흐름을 시작한 replica
--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
```
**어디를 봐야 하는가****시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.**
**이 결과가 의미하는 것**
| | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|---|---|---|
| BFF | **서버 메모리(HttpSession)** | 콜백이 다른 인스턴스로 가면 **실패** (B-0) |
| oauth2-proxy | **쿠키 (secret 으로 봉인)** | **secret 만 같으면 성공** |
**Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다** — 공유할 상태가 없고,
공유할 것은 **k8s Secret 하나뿐**이다. 대신 그 하나가 단일 지점이 된다.
---
# 5. 복구
## 5-1. secret 을 A 로 되돌린다
**하기**
```bash
date -u '+%H:%M:%S UTC 되돌림'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**어디를 봐야 하는가****이것도 회전이다.** B 로 만든 세션이 이번에는 고아가
된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.
## 5-2. 고아를 정리한다
**확인** — 지금 몇 개 남았는지 센다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**세 가지 선택지가 있다.**
| | 언제 | |
|---|---|---|
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | [B-7a](b7a-orphan-session.md) 의 규칙 |
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
**하기** — 전부 지울 때. **`b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
done
```
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).
> 1-8 에서 `dbsize` 가 2 였던 이유를 여기서 다시 쓴다.
## 5-3. ★ Grafana Ingress 를 되돌린다
**이것을 빠뜨리면 Grafana 가 안 열린다.**
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
```
**확인** — 실제로 Grafana 로 돌아갔는지 본다
```bash
sudo kubectl -n observability get ingress grafana
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — Ingress 가 `observability` 에 다시 있고, `app2` 응답이
1-1 에서 본 모양으로 돌아왔는가.
> **두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다.** oauth2-proxy 것을
> **먼저 지우고** Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가
> 컨트롤러 판단에 맡겨진다.
**oauth2-proxy 전체를 걷어내려면**
```bash
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
**다만 [B-7a](b7a-orphan-session.md) 와 [C-1](c1-multi-app-sso.md) 이 이 배포를
그대로 쓴다.** 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도
그 실험이 끝난 뒤로 미룬다.
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
| Ingress (빌린 것) | `sudo kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 **없다** (걷어냈다면) |
| Ingress (Grafana) | `sudo kubectl -n observability get ingress grafana` | **있다** |
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 콜백이 `502 Bad Gateway` | **쿠키가 크다.** `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다 |
| 호스트에서 nginx 설정·로그가 **빈 결과** | **`sudo` 가 조용히 실패했다** | `sudo -n true``sudo: a password is required` — 1-5 |
| `--cookie-secrets` 를 찾는데 없다 | **단수다.** 겹침 구간이 애초에 없다 | `--help \| grep cookie-secret` — 2-1 |
| patch 뒤 프록시가 기동에서 죽는다 | **env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다** | `env[*].name` 순서 확인 — 2-2 |
| secret 을 바꿨는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 3-2 |
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | **SSO 가 재인증을 가렸다.** 쿠키는 죽었다 | 로그의 `Initiating login` + `AuthSuccess` — 4-2 |
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` — 4-2 |
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-8 |
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | **호스트에서 파드 IP 는 안 닿는다** ([`02`](../../evidence/b7-cookie-secret/02-cookie-portability.txt)) | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | **브라우저를 쓴다** — 전제 |
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약의 박스 |
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `sudo kubectl -n keycloak-lab get ingress` |
| 실험이 끝났는데 Grafana 가 안 열린다 | **Ingress 복구를 안 했다** | 5-3 |
---
# 다음
| 실험 | B-7 이 남긴 질문 |
|---|---|
| [B-7a](b7a-orphan-session.md) 고아 세션 | **정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가** — 셋 다 답이 나온다 |
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **app1(BFF)과 app2(oauth2-proxy)가 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |
@@ -0,0 +1,699 @@
# B-7a 재현 가이드 — 고아 세션을 TTL 로 골라내 지운다
해설 문서: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md) ·
증거 원문: [`docs/evidence/b7a-orphan-session/`](../../evidence/b7a-orphan-session/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 회전만으로는 Redis 가 **안 변하는** 것 | 회전 직후 `--scan` |
| 옛 쿠키를 들고 온 **그 순간** 고아가 생기는 것 | 프록시 로그 + Redis |
| 새 세션과 고아가 이름·타입·**크기까지** 같은 것 | `type` · `strlen` |
| TTL 이 요청을 보내도 **갱신되지 않는** 것 | 30초 간격 3회 |
| TTL 로 역산한 생성 시각이 로그와 **1초** 차이인 것 | `AuthSuccess` 시각과 대조 |
| 고아만 지워도 산 세션은 `200` 인 것 | 브라우저 |
## 전제
- [`B-7`](b7-cookie-secret-rotation.md) 이 끝나 있다. oauth2-proxy 가
`app2.hyeonworks.com` 에서 돌고 있고 **세션 저장소가 Redis** 여야 한다.
이 실험은 B-7 이 「지우지 못했다」로 멈춘 자리에서 시작한다.
- **브라우저가 필요하다.** 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 이 실험대에는 **`jq` 가 없다.** Redis 는 자기 CLI 로 묻는다.
- **시각은 전부 UTC 로 다룬다.** 이 실험의 결론이 시각 계산이라 여기서
섞이면 전부 틀린다 — [1-4](#1-4-시계를-맞춰-둔다) 에서 확인한다.
## 주의 — 이건 남의 세션을 실제로 지우는 실험이다
`redis-cli del` 로 세션 키를 지운다. **산 사람의 세션을 잘못 지우면 그 사람은
재로그인해야 한다**(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는
측정되지 않았지만, **실험대에서만 한다.**
**B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다**
[5-3](#5-3-원래-자리로-돌려놓는다) 이 그 절차다.
전 구간 약 20분이고, 그중 **TTL 을 세 번 재는 데 1분**이 그대로 든다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:2911:34 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
Redis 키 이름과 TTL 은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-7 은 여기서 멈췄다.
```
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
```
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. **그런데 못 지우는
주체가 누구인지를 안 갈랐다.**
| | |
|---|---|
| B-7 이 남긴 말 | **「★ 지우지 못했다」** |
| B-7a 가 묻는 것 | 그건 **oauth2-proxy 의 한계인가, Redis 의 한계인가** |
**답은 oauth2-proxy 의 한계다.** 프록시는 티켓을 못 풀어 키를 계산 못 하지만,
**운영자는 키를 직접 안다.** `--scan` 하면 다 보인다.
그러면 다음 물음이 생긴다 — **보이긴 하는데 어느 것이 고아인가.**
이 실험이 실제로 재는 것은 그 판별이고, 답은 **TTL 하나**다.
세 물음을 차례로 잰다.
```
(1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가
(2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가
(3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다
```
---
# 1. 기준선 — 회전하기 전에
넓은 것부터 좁혀 간다.
```
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
```
## 1-1. ★ `refresh:disabled` 를 먼저 확인한다
**이 한 단어가 5절 규칙 전체의 전제다.** 여기가 `disabled` 가 아니면
이 가이드의 결론은 당신 환경에서 성립하지 않는다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
```
**실측** — [`b7-cookie-secret/03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
[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
```
**어디를 봐야 하는가** — 두 값이다.
| 값 | 이 실험에서 | |
|---|---|---|
| `expiry:1h0m0s` | **3600초** | 5절의 역산식에 그대로 들어간다 |
| **`refresh:disabled`** | **TTL 이 요청으로 갱신되지 않는다** | 이게 `enabled` 면 역산이 무너진다 |
**이 결과가 의미하는 것** — TTL 이 고정이면 **TTL 은 생성 시각의 정확한
함수**다. 4-3 에서 그 식을 세우고 5-1 에서 그걸로 고아를 고른다.
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
```
**형태**
```
"--cookie-secure=true"
"--cookie-expire=1h"
```
**어디를 봐야 하는가****`--cookie-refresh` 가 목록에 없어야 한다.**
없으면 `refresh:disabled` 다.
## 1-2. 세션을 하나 만든다
**하기** — 브라우저에서
```
https://app2.hyeonworks.com/api/echo → labuser / labpass
```
**어디를 봐야 하는가** — upstream 의 JSON 이 보이면 세션이 생긴 것이다.
## 1-3. Redis 를 있는 그대로 본다
**한 번은 통째로, 필드를 하나씩 본다.** 나중에 루프로 묶더라도 처음에는
`type`·`ttl`·`strlen` 이 각각 무엇을 답하는지 봐 두어야 한다.
**확인** — 무엇이 있나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[기준선] 회전 전 — 11:29:42 UTC
secret = COOKIE_SECRET_A
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
dbsize=1
```
**확인** — 그 키 하나에 대해 셋을 묻는다. **키 이름은 위 출력에서 가져온다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
**어디를 봐야 하는가**
| 명령 | 답하는 질문 | 이 실험에서 |
|---|---|---|
| `type` | 무슨 자료형인가 | 전부 `string`**구분에 못 쓴다** |
| `strlen` | 몇 바이트인가 | 전부 `3510`**구분에 못 쓴다** |
| **`ttl`** | 몇 초 남았나 | **유일하게 다른 값** |
**`ttl``-1` 이면** 만료가 안 걸린 키다(이 실험의 대상이 아니다).
**`-2` 면** 키가 없다 — 이름을 잘못 옮긴 것이다.
키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다.
**한 줄짜리고, 하는 일이 이름 그대로다.**
```bash
R() { sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
done
```
**형태**
```
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
```
> 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. **느리다.** 키가 수백 개면
> 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
## 1-4. 시계를 맞춰 둔다
**이 실험은 시각 계산이 결론이다.** 프록시 로그는 **UTC** 이고, 당신 셸의
`date` 는 KST 일 것이다. 섞이면 9시간이 틀어진다.
**확인**
```bash
date; date -u
timedatectl show -p NTP -p NTPSynchronized
```
**형태**
```
NTP=yes
NTPSynchronized=yes
```
**어디를 봐야 하는가**`NTPSynchronized=yes`. 그리고 **앞으로 `date`
전부 `-u` 를 붙여 친다.**
**이 결과가 의미하는 것** — 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을
같은 축에 놓을 수 있게 된다. 4-6 의 「1초 오차」는 이 축이 맞아야 나온다.
---
# 2. 주입 — 1차 회전 A → B. 시각을 반드시 기록한다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
```
## 2-1. 회전 시각을 변수에 담는다
**★ 이 값이 5절 규칙의 절반이다.** 안 적어 두면 나중에 고아를 못 고른다.
**하기**
```bash
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"
```
**실측** — 원래 실행의 1차 회전 시각
```
11:29:56 UTC
```
## 2-2. 바꾼다
**하기**
```bash
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_B"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**어디를 봐야 하는가**`successfully rolled out`. 그리고 **env 배열의 인덱스가
당신 매니페스트와 맞는지**는 B-7 의 [2-2](b7-cookie-secret-rotation.md#2-2-env-배열의-어느-칸을-바꾸는지-먼저-확인한다) 에서
확인했다. 안 했으면 지금 한다.
---
# 3. 주입이 걸렸는지 확인한다
## 3-1. 어느 키를 참조하는가
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
**형태**
```
COOKIE_SECRET_B
```
## 3-2. ★ 그런데 Redis 는 그대로다
**확인** — 1-3 과 **똑같은 명령**
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[주입] 1차 회전 A → B — 11:29:56 UTC
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
```
**어디를 봐야 하는가****키 수가 회전 전과 같다.**
**이 결과가 의미하는 것** — 여기서 「실험 실패」라고 결론 내리면 틀린다.
**회전은 방아쇠가 아니라 조건이다.** 실제로 벌어지는 것은
**누군가 옛 쿠키를 들고 오는 순간**이다.
> A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 자리다.
> **주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.**
## 3-3. 브라우저로 다시 연다 — 여기서 고아가 생긴다
**하기** — 1-2 에서 로그인한 **그 브라우저 그대로**
```
https://app2.hyeonworks.com/api/echo
```
**확인** — 그 순간의 로그
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
브라우저가 접근한 순간(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 ...}
```
**어디를 봐야 하는가****`AuthSuccess` 의 시각을 적어 둔다.**
`11:30:27`. **4-6 에서 이 숫자와 역산값을 맞춰 본다.**
**확인** — Redis
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
**실측**
```
Redis:
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
dbsize=2
```
**어디를 봐야 하는가****키가 둘.** 그리고 로그인 화면을 안 봤다는 사실.
**이 결과가 의미하는 것** — Keycloak SSO 가 살아 있어 **조용히 재인증**됐다.
B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
---
# 4. 관찰 — 어느 것이 고아인가
## 4-1. ★ Redis 값만 보고는 구분할 수 없다
**확인** — 두 키를 나란히 놓는다
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 1] ★ Redis 만 보고는 구분할 수 없다
키 type strlen ttl
_oauth2_proxy-87faa1c9…(새) string 3510 3558
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
```
**어디를 봐야 하는가** — 열을 하나씩 지운다.
| 신호 | 새 세션 | 고아 | 쓸 수 있나 |
|---|---|---|---|
| 이름 접두사 | `_oauth2_proxy-` | 같다 | ✗ |
| 이름 뒷부분 | 불투명한 32자 hex | 같은 성질 | ✗ — 사용자·시각·상태 어느 것도 안 담긴다 |
| `type` | `string` | `string` | ✗ |
| **`strlen`** | **3510** | **3510** | ✗ — **바이트 단위로 같다** |
| `ttl` | 3558 | 3480 | **✓ 이것뿐이다** |
값을 직접 봐도 소용없다. **암호화되어 있다.**
**확인** — 바이너리를 이스케이프해 보여 준다
```bash
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
```
**실측**
```
새 "\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 (다르지만 뜻을 읽을 수 없다)
```
> **`--no-raw` 를 안 붙이면 터미널이 깨진다.** 세션 값은 바이너리다.
> 붙이면 `\xNN` 로 이스케이프해서 보여 준다.
**이 결과가 의미하는 것** — 두 값이 다르다는 것은 알 수 있지만 **어느 쪽이
고아인지는 말해 주지 않는다.** 뜻을 읽을 수 없기 때문이다.
**다른 것은 TTL 하나뿐이다.**
## 4-2. TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
**TTL 을 신호로 쓰려면 그것이 믿을 만한지부터 재야 한다.**
두 가지를 확인한다 — ① 실제로 줄어드는가 ② 요청을 보내면 되살아나는가.
**확인** — 30초 간격으로 세 번. 여기에 1분이 그대로 든다
```bash
for i in 1 2 3; do
date -u '+%H:%M:%S'
R --scan --pattern '_oauth2_proxy-*' | while read K; do
printf " %s ttl=%s\n" "$K" "$(R ttl $K)"
done
sleep 30
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
30초 간격 3회:
t+00초 새=3557 고아=3479
t+30초 새=3526 고아=3448
t+60초 새=3494 고아=3417
```
**어디를 봐야 하는가****30초에 30초씩 준다.** 그리고 **두 값의 차가 거의
고정**되어 있다 — 35573479 = 78, 35263448 = 78, 34943417 = **77**.
**차이가 (1초 안에서) 고정이라는 것이 「둘 다 생성 시각에만 달렸다」는 뜻이다.**
그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, **4-6 에서 나오는
「1초 오차」와 같은 것**이다.
이제 ②를 확인한다. **브라우저로 요청을 몇 번 보낸 뒤** 다시 잰다.
**실측**
```
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
살아있는 세션 ttl=3464 ← 계속 줄어든다
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
```
**이 결과가 의미하는 것****쓰고 있어도 TTL 이 안 늘어난다.**
1-1 에서 본 `refresh:disabled` 가 여기서 값으로 확인됐다.
따라서 **고아는 생성 후 정확히 1시간에 사라진다.** 무한정 쌓이지 않는다.
## 4-3. 개념 — TTL 갱신 여부가 왜 결정적인가
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
**왜 여기 나오나.** TTL 이 고정이면 이 식이 성립한다.
```
생성시각 = 지금 - (cookie-expire - TTL)
```
**이 한 줄이 5절의 정리 규칙 전체를 만든다.** `cookie-expire` 는 1-1 에서
`1h0m0s` = 3600 으로 확인했다.
**없거나 틀리면.** **`--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.**
활발히 쓰는 세션일수록 TTL 이 크게 남아 「방금 만들어진 것」처럼 보이고,
오래 안 쓴 산 세션은 TTL 이 작아 **고아로 오판되어 지워진다.**
> **그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이
> 오히려 정직하다.** 골라내는 척하면서 산 세션을 죽이는 것보다 낫다.
> **이 가이드의 5절은 `refresh:disabled` 일 때만 유효하다.**
## 4-4. 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
**되돌리기가 없는 조작이다. 지우기 전에 어느 키인지 두 번 확인한다.**
지금은 TTL 이 작은 쪽이 고아다(4-1).
**하기**
```bash
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
dbsize 2 → 1
남은 키: _oauth2_proxy-87faa1c9…
```
**어디를 봐야 하는가****반환값 `1`.** `0` 이면 그 키가 없었던 것이다
(이름을 잘못 옮겼다).
**확인** — 산 세션이 멀쩡한지. **브라우저로 다시 연다**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
```
**실측**
```
삭제 직후 브라우저 요청 (11:32:49):
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
```
![고아 삭제 후 살아있는 세션](../../evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png)
**이 결과가 의미하는 것****200. 산 세션은 영향이 없다.**
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. **운영자는 키를 직접 안다.**
> 0절의 물음 (2)에 대한 답이 이것이다.
## 4-5. 누적한다 — 회전할 때마다
**한 번 더 회전해 본다.** 고아가 일회성인지 누적인지가 갈린다.
**하기**
```bash
ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
그리고 **브라우저로 다시 연다.**
**확인**
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 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
```
**어디를 봐야 하는가****`87faa1c9…` 의 신분이 바뀌었다.**
3-3 에서 「새 세션」이던 것이 여기서는 고아다.
**이 결과가 의미하는 것** — **1차 회전을 살아남았던 세션이 2차 회전에서
고아가 됐다.** 회전 1회 = **그 시점 로그인 사용자 수**만큼의 고아.
고아는 사건이 아니라 **회전의 고정 비용**이다.
## 4-6. ★ 역산이 실제로 맞는지 검증한다
**규칙을 쓰기 전에 규칙 자체를 검증한다.** 위 표의 「생성시각(추정)」은
4-3 의 식으로 나온 값이고, 우리에겐 대조할 실측이 하나 있다 —
**3-3 의 `AuthSuccess` 로그 시각.**
**실측**
```
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
**1초 오차.** 추정이 아니라 사실상 정확하다.
```
**어디를 봐야 하는가****1초.** TTL 이 초 단위 정수라 반올림에서 나올 수
있는 크기다.
**이 결과가 의미하는 것****TTL 역산은 추정이 아니라 측정에 가깝다.**
그래서 다음 규칙을 안심하고 쓸 수 있다.
```
생성시각 < 회전시각 → 그 키는 고아다
```
**왜 성립하는가** — 회전 **이후에** 만들어진 세션은 **새 secret 으로**
만들어졌으므로 반드시 유효하다. 그러니 회전 이전 생성분만 고르면 된다.
---
# 5. 정리와 복구
## 5-1. ★ 먼저 눈으로 보고, 그 다음에 지운다
**`del` 을 바로 붙이지 않는다.** 같은 루프를 `echo` 로 한 번 돌려
**무엇이 지워질지 읽는다.**
**확인** — 지우지 않는 판. `ROT` 은 2-1(또는 4-5의 `ROT2`)에서 담아 둔 값이다
```bash
NOW=$(date -u +%s); EXP=3600
R --scan --pattern '_oauth2_proxy-*' | while read K; do
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
if [ "$C" -lt "$ROT" ]; then
echo "고아 $K (생성 $(date -u -d @$C +%H:%M:%S))"
else
echo "산것 $K (생성 $(date -u -d @$C +%H:%M:%S))"
fi
done
```
**어디를 봐야 하는가****「산것」이 정확히 지금 로그인해 있는 사람 수만큼
있는가.** 아니면 `ROT` 이 틀렸거나 `EXP` 가 3600 이 아니다.
**`NOW` 를 루프 밖에서 한 번만 잡는 것이 중요하다.** 안에서 잡으면 키마다
기준 시각이 달라진다.
**하기** — 확인한 뒤에 지운다
```bash
NOW=$(date -u +%s); EXP=3600
R --scan --pattern '_oauth2_proxy-*' | while read K; do
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
if [ "$C" -lt "$ROT" ]; then
echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"
fi
done
R dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
산 세션은 남고 고아만 사라졌다.
```
**어디를 봐야 하는가** — 지운 뒤 브라우저로 한 번 더 열어 본다.
**열리면 산 세션이 안 다친 것이다**(4-4).
> **`dbsize` 는 이 Redis 전체를 센다.** BFF 세션과 B-5 가 남긴 키도 들어 있다.
> 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이고,
> **당신 환경에서는 다를 수 있다.** 세션만 세려면 `--scan --pattern` 을 쓴다.
## 5-2. 전제가 깨졌을 때 — 정직한 대안
`--cookie-refresh` 가 켜져 있으면 5-1 을 **쓰면 안 된다**(4-3).
그때는 전부 지우고 모두 재인증시킨다.
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
```
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 에는 BFF 세션도 들어 있다.
> 패턴으로 좁히는 것이 이 실험대에서는 필수다.
## 5-3. 원래 자리로 돌려놓는다
**하기** — secret 을 A 로
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
`COOKIE_SECRET_B` 로 나오면 A 로 되돌린다(2절의 되돌리기).
4-5 에서 이미 A 로 돌아왔다면 그대로 둔다.
**하기** — B-7 에서 Grafana Ingress 를 빌렸다면 **여기서 돌려준다**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
> **[C-1](c1-multi-app-sso.md) 을 이어서 할 생각이면 아직 돌려주지 않는다.**
> C-1 이 app2 를 그대로 쓴다. 그 대신 **C-1 이 끝난 뒤에 반드시 복구한다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
| 세션 | `R --scan --pattern '_oauth2_proxy-*'` | 지금 로그인한 사람 수만큼만 |
| 다른 키 | `R --scan --pattern '*'` | `b5:pvc`·BFF 세션이 **살아 있다** (안 지웠어야 한다) |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
| 셸 변수 | `unset ROT ROT2 NOW EXP` | — |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 회전했는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 — 3-2·3-3 |
| 고아와 산 세션이 구분이 안 간다 | **이름·타입·크기가 같다.** 값은 암호화 | **TTL 만이 신호다** — 4-1 |
| `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` — 4-1 |
| `ttl``-1` | 만료가 안 걸린 키다 | 이 실험의 대상이 아니다 |
| `ttl``-2` · `del``0` | **그 키가 없다** | 키 이름을 `--scan` 출력에서 다시 옮긴다 |
| 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 — 1-1 |
| 역산이 9시간 어긋난다 | **`date` 를 로컬로 쳤다** | 전부 `date -u` — 1-4 |
| **산 세션이 고아로 잡힌다** | **`--cookie-refresh` 가 켜져 있다** | 기동 로그의 `refresh:disabled` 확인. 켜져 있으면 5-2 |
| 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인하면 된다. SSO 가 살아 있으면 조용히 지나간다 |
| `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc`·BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-3 |
| `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | **같은 Redis 에 BFF 세션이 있다** | 패턴으로 좁혀 지운다 — 5-2 |
| 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 |
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약 |
---
# 다음
| 실험 | B-7a 가 남긴 것 |
|---|---|
| [B-7](b7-cookie-secret-rotation.md) cookie secret | **「지우지 못했다」가 정정됐다** — 프록시가 못 하는 것이지 불가능한 것이 아니다 |
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다.** 지울 때 패턴을 좁혀야 하는 이유 |
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | 회전의 진짜 비용은 **재로그인이 아니라 저장소에 남는 것**이다 |
| 운영 | 회전 시각을 **UTC epoch 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |
+716
View File
@@ -0,0 +1,716 @@
# C-1 재현 가이드 — 앱 둘에 SSO 를 걸고, IdP 세션을 죽여 본다
해설 문서: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md) ·
증거 원문: [`docs/evidence/c1-multi-app-sso/`](../../evidence/c1-multi-app-sso/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 두 번째 앱이 **로그인 화면 없이** 열리는 것 | 브라우저 |
| `user session 1``client session 2` 가 매달린 구조 | PostgreSQL |
| 서로 다른 구조의 두 앱이 같은 user session 을 공유하는 것 | `client` 조인 |
| **IdP 세션을 죽여도 두 앱이 그대로 열리는 것** | 브라우저 + Redis |
| `logout-all` 이 오류 없이 아무것도 안 하는 것 | 세션 수가 안 변한다 |
| realm 을 안 보고 세면 `master` 의 admin 세션에 속는 것 | `realm` 조인 |
## 전제
- [`B-2`](../../experiment-b2-multi-instance-session.md) 의 **app1(BFF)**
[`B-7`](b7-cookie-secret-rotation.md) 의 **app2(oauth2-proxy)****둘 다** 떠 있다.
이 실험은 그 둘이 있어야 성립한다 — 없으면 SSO 가 아니라 로그인 한 번이다.
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다(B-7 의 주의).
이 실험이 끝나면 [5-3](#5-3-빌린-것을-돌려준다) 에서 되돌린다.
- **브라우저가 필요하다.** SSO 는 브라우저 쿠키가 만드는 현상이고, `curl` 로는
「로그인 화면이 안 떴다」를 볼 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **Keycloak 이미지에는 `curl``wget` 도 없다**(`exit 127`). `kcadm.sh`
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.**
- 이 실험대에는 **`jq` 가 없다.**
## 주의 — 이건 세션을 전부 지우고 시작하는 실험이다
기준선을 만들려고 **Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고,
Keycloak StatefulSet 을 재시작한다.** 그 순간 **지금 로그인해 있는 모든 사람이
끊긴다.** 실험대에서만 한다.
전 구간 약 20분이고, Keycloak 재시작에 1~2분이 든다.
중간에 그만두려면 [5. 복구](#5-복구) 로 간다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:4414:48 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했고 SQL 원문은 기록에 없다 |
세션 id·Redis 키·클라이언트 UUID 는 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
예시로 실린 값은 전부 위 수집 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
원래 질문은 한 줄이었다.
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
「달라진다」에는 두 방향이 섞여 있다 — 편해지는 쪽과 위험해지는 쪽.
위험 쪽의 통념은 이렇다.
| | 예측 |
|---|---|
| 통념 | SSO 를 붙이면 **IdP 가 단일 장애점**이 된다. IdP 가 죽으면 다 죽는다 |
| **실측** | **절반만 맞다.** 로그인 **경로**는 그렇고, **이미 로그인한 사용자**는 아니다 |
**둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 판정된다.** 그게 이 실험이다.
핵심은 **수명이 세 층으로 나뉘어 있다는 것**이다.
```
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
**로그아웃이 지우는 것은 ① 뿐이다.** 이 실험대에는 서로 완전히 다르게
세션을 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.
```
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
둘 다 realm keycloak-patterns
```
**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이
같은 IdP 를 쓰면서 세션을 정반대로 다룬다.
---
# 1. 기준선 — 깨끗한 상태를 만든다
넓은 것부터 좁혀 간다.
```
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
```
## 1-1. 두 앱이 다 떠 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
sudo kubectl -n keycloak-lab get ingress
```
**어디를 봐야 하는가**`bff``oauth2-proxy`**둘 다** `Running` 이고,
Ingress 에 `app1.hyeonworks.com``app2.hyeonworks.com`**둘 다** 있는가.
**확인** — 밖에서
```bash
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
app1 HTTP 200 / app2 HTTP 200
```
> **`app2` 가 Grafana 로 간다면** B-7 의 Ingress 가 없는 것이다.
> B-7 의 [1-1~1-2](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다) 를 먼저 한다.
## 1-2. 세션을 지우려고 시도한다 — 그리고 실패를 본다
**kcadm 을 먼저 로그인시킨다.** 파드가 재시작되면 세션이 사라지고 이후 모든
명령이 `401` 이 된다. **1-3 에서 실제로 재시작하므로 그때 다시 해야 한다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**하기** — 가장 자연스러운 방법부터 친다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create realms/keycloak-patterns/logout-all
```
**확인** — 세션이 정말 지워졌는지 센다. **미검증** (증거에는 이 SQL 의 원문이 없다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
=== 깨끗한 상태로 초기화 ===
DELETE 1
=== 기준선 ===
Keycloak 온라인 세션: 4
Redis 키: 0
```
**어디를 봐야 하는가****`4`.** 0 이 아니다.
**이 결과가 의미하는 것****`logout-all` 이 안 먹었다.** 오류도 안 났다.
세션이 그대로 4개 남아 있다.
> **★ 해설 문서 정정** — 이 문서는 처음에 이 값을 `0` 으로 인쇄했다.
> 증거 [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt) 는 **`4`** 다.
> **`0` 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다.**
> 이 가이드는 증거를 따른다 — **여기서 4 가 나오는 것이 정상이다.**
**왜 안 먹었나****캐시 때문이다.** A-1 에서 확인했듯 Keycloak 은 세션을
DB 에서 읽되 **캐시로 답한다.** 관리 API 가 무효화를 걸어도 각 노드의 캐시가
그대로면 세션은 살아 있는 것처럼 보인다.
## 1-3. 그래서 DB 를 직접 지우고 Keycloak 을 재시작한다
**하기** — 자식 테이블부터 지운다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
```
**형태**
```
DELETE 2
DELETE 4
```
**하기** — 앱 세션도 비운다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
```
> **`flushall` 은 이 Redis 전체를 지운다.** BFF 세션·oauth2-proxy 세션·
> B-5 가 남긴 `b5:pvc` 까지 전부다. **기준선을 만드는 자리라서 의도한 것**이고,
> 실험 도중에는 절대 쓰지 않는다([B-7a](b7a-orphan-session.md) 5-2 참고).
**하기** — 캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다
```bash
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**되돌리기** — 없다. **지운 세션은 안 돌아온다.** 다시 로그인하면 된다.
**확인** — 이제 비었는가
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
Redis 키: 0
```
**어디를 봐야 하는가** — Redis 키 **0**, 세션 수 **0**.
여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다.
**★ kcadm 세션이 날아갔다.** 1-2 의 `config credentials`**다시 친다.**
## 1-4. ★ 세는 법을 먼저 고친다 — realm 을 본다
**이 절을 건너뛰면 4절의 결론을 반대로 읽는다.**
`offline_user_session` 에는 **모든 realm 의 세션**이 들어 있다. 그리고
`kcadm` 을 쓰는 순간 **`master` realm 에 admin 세션이 생긴다.**
그러니 그냥 세면 **내가 만든 노이즈를 남의 세션으로 읽는다.**
**확인** — 틀린 방법(전체를 센다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
**확인** — 맞는 방법(realm 을 조인한다). **미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, r.name as realm,
(select count(*) from offline_client_session cs
where cs.user_session_id=us.user_session_id) as clients
from offline_user_session us join realm r on r.id=us.realm_id
where us.offline_flag='0'"
```
**어디를 봐야 하는가****`realm` 열.** `keycloak-patterns` 만이 이 실험의
대상이고, `master` 는 **당신이 방금 `kcadm` 을 쳐서 생긴 것**이다.
**이 결과가 의미하는 것** — 이 한 열 때문에 원래 실행은 **「안 지워졌다」로
오독할 뻔했다.** 해설 문서가 「세 번째가 특히 위험했다」고 쓴 것이 이 실수다.
> **여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다.**
> 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
---
# 2. 주입 — 두 앱에 차례로 들어간다
여기서 SSO 상태를 만든다. **되돌리기는 간단하다** — 5절의 초기화를 다시 하면
된다. 파괴적인 조작은 4절에 있다.
## 2-1. app1 에 로그인한다 — 로그인 화면이 나온다
**하기** — 브라우저에서
```
https://app1.hyeonworks.com/ → labuser / labpass
```
**어디를 봐야 하는가****Keycloak 로그인 화면이 뜨는가.**
주소창이 이렇게 바뀐다.
**실측** — 해설 문서에 남은 형태
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=bff-confidential&...
→ Sign in to keycloak-patterns
```
**이 결과가 의미하는 것** — 첫 앱에서는 **당연히 로그인 화면이 나온다.**
이것이 2-2 의 대조군이다. **이걸 안 보면 「app2 에서 안 뜬 것」이 특별한
일인지 알 수 없다.**
## 2-2. 로그인 직후 상태를 잰다
**확인** — 1-4 의 맞는 쿼리를 그대로 쓴다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, r.name as realm,
(select count(*) from offline_client_session cs
where cs.user_session_id=us.user_session_id) as clients
from offline_user_session us join realm r on r.id=us.realm_id
where us.offline_flag='0' and r.name='keycloak-patterns'"
```
**실측** — [`02-after-app1-login.txt`](../../evidence/c1-multi-app-sso/02-after-app1-login.txt)
```
=== app1 로그인 직후 Keycloak 세션 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
(1 row)
```
**어디를 봐야 하는가****`user_session_id` 를 적어 둔다.** 2-3 과 3절에서
계속 쓴다. 그리고 **`client_sessions` 가 1** 이다.
**확인** — 저장소 두 곳
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측**
```
Redis 키: 1
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
```
**어디를 봐야 하는가** — Redis 키 이름의 **접두사 `bff:session:sessions:`**.
이 접두사가 「BFF 가 만든 세션」이라는 뜻이고, 3절에서 프록시 것과 갈라진다.
`authorized client` 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이다. **미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from oauth2_authorized_client"
```
**이 결과가 의미하는 것****한 번 로그인했는데 상태가 세 곳에 생겼다.**
Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 4절에서 이 셋의 운명이 갈린다.
## 2-3. app2 를 방문한다 — 여기가 SSO 다
**하기** — **같은 브라우저의 새 탭**에서
```
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 뜨지 않았다.
![app2 가 로그인 없이 열린다](../../evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png)
**이 결과가 의미하는 것****SSO 가 동작한다.** app2 는 Keycloak 으로
리다이렉트했지만, Keycloak 에 이미 세션이 있어서 **묻지 않고 바로 돌려보냈다.**
> **다른 브라우저나 시크릿 창에서 열면 안 된다.** SSO 를 만드는 것은
> `auth.hyeonworks.com` 에 붙은 **브라우저 쿠키**다. 창이 다르면 쿠키가 없고,
> 그러면 로그인 화면이 뜨는 것이 정상이다.
---
# 3. 주입이 만든 구조를 확인한다
## 3-1. user session 하나에 client session 둘
**확인** — 2-2 와 **똑같은 쿼리**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, r.name as realm,
(select count(*) from offline_client_session cs
where cs.user_session_id=us.user_session_id) as clients
from offline_user_session us join realm r on r.id=us.realm_id
where us.offline_flag='0' and r.name='keycloak-patterns'"
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2
(1 row)
```
**어디를 봐야 하는가****`user_session_id` 가 2-2 와 같고, `client_sessions`
만 1 → 2 로 늘었다.**
**이 결과가 의미하는 것** — **두 번째 로그인이 아니라 같은 로그인에 앱이
하나 붙은 것이다.** 이것이 SSO 의 데이터 구조다.
```
user session (사용자 · 브라우저 하나당 하나)
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
## 3-2. 어느 클라이언트가 붙었는가
**확인****미검증** (증거에는 이 SQL 의 원문이 없다. 출력은 실측이다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select cs.client_id, c.client_id as name
from offline_client_session cs join client c on c.id = cs.client_id
where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== 어느 클라이언트가 붙었는가 ===
client_id | name
--------------------------------------+------------------
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)
```
**어디를 봐야 하는가****`client_id` 열은 UUID 이고, 사람이 아는 이름은
`client` 테이블에 있다.** 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지
알 수 없다.
**이 결과가 의미하는 것** — 구조가 완전히 다른 두 앱이 **같은 user session
아래에 나란히** 있다. Keycloak 은 앱이 세션을 어떻게 다루는지 모르고,
알 필요도 없다.
## 3-3. 저장소 세 곳이 각자 무엇을 들고 있는가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== 저장소 상태 ===
Redis 키:
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
```
**어디를 봐야 하는가** — **같은 Redis 에 접두사가 다른 두 세션**이 있다.
`bff:session:sessions:` 는 Spring Session 이 쓰는 이름이고,
`_oauth2_proxy-` 는 프록시가 쓰는 이름이다.
**이 결과가 의미하는 것** — 「세션 저장소를 공유한다」는 말이 **「같은 Redis 를
쓴다」일 뿐 「같은 세션을 본다」가 아니다.** 둘은 서로의 키를 모른다.
[B-7a](b7a-orphan-session.md) 에서 `FLUSHDB` 를 금지한 이유가 이것이다.
### 개념 — 두 층으로 나뉘어 있는 이유
**무엇인가.** Keycloak 은 세션을 `user session`(사람 하나)과
`client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다.
**왜 여기 나오나.** A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.
| | 무엇이 사라졌나 | 결과 |
|---|---|---|
| **A-3** DB 크래시 | `user_session` 행이 통째로 | **모든 앱이 끊긴다** |
| **B-3** refresh 재사용 탐지 | **`client_session` 만** | **그 앱만 끊긴다** |
**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 사고가 다른 앱으로 번지지
않게 하려면 client session 이 따로 있어야 한다.
**없거나 틀리면.** 한 층뿐이라면 B-3 의 재사용 탐지 한 번이 **모든 앱을**
끊었을 것이다.
---
# 4. 관찰 — IdP 세션만 죽인다
## 4-1. 무엇을 지우는지 먼저 정한다
**지우려는 것은 ①(IdP 세션)뿐이다.** ②(앱 세션)와 ③(토큰)은 손대지 않는다.
**그 구분이 이 실험의 전부다.**
**되돌리기** — 다시 로그인하면 된다. 파괴적이지만 회복은 쉽다.
## 4-2. 지우는 방법을 고른다 — 두 개는 안 먹는다
**하기** — 세션 id 를 지목해서 지운다. **미검증 · 안 먹는다**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
```
**어디를 봐야 하는가****오류도 안 나고 세션도 안 줄어든다.**
1-2 의 `logout-all` 과 같은 유형이다.
**하기** — 사용자 단위로 끊는다. **이건 먹는다**
```bash
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser --fields id \
--format csv --noquotes | tail -1)
echo "$USERID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create "users/$USERID/logout" -r keycloak-patterns
```
**어디를 봐야 하는가**`echo "$USERID"` 가 **UUID 한 줄**인가.
비어 있거나 여러 줄이면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은
것이다. 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다.
> **자리표시자를 두지 않으려고 두 단계로 나눴다.** 한 줄로 이어 붙일 수도
> 있지만, **그러면 UID 가 비었을 때 그 사실이 안 보인다.**
## 4-3. IdP 쪽은 정말 끊겼는가 — realm 을 보고 센다
**확인** — 1-4 의 맞는 쿼리
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, r.name as realm,
(select count(*) from offline_client_session cs
where cs.user_session_id=us.user_session_id) as clients
from offline_user_session us join realm r on r.id=us.realm_id
where us.offline_flag='0'"
```
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
```
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
남은 Keycloak 세션: 1
=== 남은 세션의 realm 과 client ===
user_session_id | realm | clients
--------------------------+--------+---------
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
(1 row)
```
**어디를 봐야 하는가****「남은 세션 1」과 「그 1의 realm 이 `master`」를
같이 본다.**
**이 결과가 의미하는 것**`keycloak-patterns` 세션은 **0** 이다.
남은 하나는 **당신이 `kcadm` 을 쳐서 생긴 admin 세션**이다.
> **★ 여기가 이 실험에서 가장 잘 틀리는 자리다.** 「1이 남았네, 로그아웃이
> 안 먹었구나」로 읽으면 4-4 의 결론이 통째로 뒤집힌다. **숫자 옆에 realm 을
> 붙이지 않으면 그 숫자는 아무 뜻이 없다.**
## 4-4. ★ 앱 세션은 그대로 남아 있다
**확인** — 3-3 과 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
```
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
```
**어디를 봐야 하는가****키 이름이 3-3 과 글자 하나까지 같다.**
아무것도 안 지워졌다.
**이 결과가 의미하는 것****로그아웃은 ①만 지웠다.** ②도 ③도 아무도 안 건드렸다.
## 4-5. 브라우저로 두 앱을 다시 연다
**하기** — 아까 그 브라우저에서
```
https://app1.hyeonworks.com/
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 둘 다 로그인 화면 없이 그대로 열렸다.
![IdP 세션이 없어도 앱은 동작한다](../../evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png)
> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 2-3 의 스크린샷은
> **바이트 단위로 동일한 파일**이다(md5 `2c703176…`). 두 시점의 화면이 실제로
> 같은 내용이었기 때문이며 조작이 아니지만, **그래서 두 시점을 구별하는 증거가
> 되지 못한다.** 구별은 [`03-`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt) 과
> [`04-`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt) 의 터미널 출력이 한다 —
> `client_sessions` 1→2, 그리고 IdP 세션 삭제 후에도 Redis 키가 남아 있는 것.
> **화면이 같아 보인다는 것 자체가 이 실험의 결론**이라, 화면만으로는 증명이 안 된다.
## 4-6. 왜 그런가 — 세 개의 독립된 수명
```
① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
**앱은 매 요청마다 IdP 에 물어보지 않는다.** 자기 세션이 살아 있으면 그걸로
답한다. **그래서 ①이 사라진 것을 모른다.**
**그러면 언제 알게 되는가.**
| | 언제 끊기는가 |
|---|---|
| BFF | access token 이 만료되어 **refresh 를 시도할 때**`Session not active` |
| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신을 시도**할 때 |
**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이 아니라
**앱이 다음에 IdP 를 부를 때까지**다.
> **B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다.**
> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고,
> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다.
> **두 방향 모두 「한쪽만 지우면 다른 쪽이 남는다」이다.**
**확인** — 실제로 끊기는 순간을 보고 싶으면 기다린다. **미검증**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
```
그 시간이 지난 뒤 app1 을 새로고침하면 로그인 화면으로 떨어진다.
## 4-7. 그래서 SSO 의 대가는 무엇인가
원래 질문에 대한 답이다.
| | 앱이 하나일 때 | **SSO 일 때** |
|---|---|---|
| 로그인 | 앱마다 | **한 번** |
| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** |
| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) |
| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** |
| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 |
**IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일
장애점이 아니다.** A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
```
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
```
**장애가 즉시 전면화되지 않고 「앱 세션 수명만큼 지연되어 몰려온다」.**
이것이 SSO 구조의 장애 모양이고, **모니터링이 어려운 이유**다.
**그리고 마지막 줄이 다음 실험을 부른다** — 전 앱을 끊으려면 백채널
로그아웃이 필요하다. **그게 되는지는 [C-2](c2-backchannel-logout.md) 가 잰다.**
---
# 5. 복구
## 5-1. 세션을 정리한다
**하기** — 1-3 과 같은 절차
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**그냥 둬도 된다.** 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고,
IdP 세션은 이미 없다. **정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.**
## 5-2. 브라우저 쿠키를 지운다
**하기**`auth.hyeonworks.com`·`app1`·`app2` 의 쿠키를 지우거나
**시크릿 창을 새로 연다.**
**왜** — 서버 세션을 다 지워도 **브라우저에 낡은 쿠키가 남는다.**
다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 이것이다.
## 5-3. 빌린 것을 돌려준다
**app2 는 Grafana 의 이름이다.** C-2 를 이어서 하지 않을 거라면 지금 되돌린다.
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
> **[C-2](c2-backchannel-logout.md) 를 이어서 할 생각이면 아직 돌려주지 않는다.**
> C-2 가 두 앱을 그대로 쓴다. **그 대신 C-2 가 끝난 뒤에 반드시 복구한다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| Keycloak 세션 | 1-4 의 realm 조인 쿼리 | `keycloak-patterns` **0** (`master` 는 있을 수 있다) |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
> **이 실험이 재지 않은 것** — 4-6 의 「언제 끊기는가」를 **실제로 기다려서
> 확인하지 않았다.** IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고
> app1 을 새로고침하면 `Session not active` 가 나와야 한다. 재려면 그렇게 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `logout-all` 이 오류 없이 아무 일도 안 한다 | **캐시.** DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 + `rollout restart` — 1-3 |
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 — 4-2 |
| 기준선 세션이 0 이 아니라 4 다 | **원래 실행도 4 였다.** 해설의 `0` 은 정정됐다 | 1-2 의 정정 박스 |
| 로그아웃했는데 세션이 1 남았다 | **`master` 의 admin 세션이다.** 당신이 `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 — 1-4·4-3 |
| `kcadm` 이 전부 `401` | **재시작으로 kcadm 세션이 날아갔다** | `config credentials` 를 다시 — 1-2 |
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 — 4-2 |
| app2 에서 로그인 화면이 뜬다 | **다른 브라우저·시크릿 창이다.** SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 — 2-3 |
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 1-1~1-2 |
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 3-2 |
| Redis 를 비웠더니 app1 도 끊겼다 | **`flushall` 은 BFF 세션도 지운다** | 기준선에서만 쓴다 — 1-3 |
| 스크린샷 두 장이 똑같다 | **실제로 같은 파일이다.** 조작이 아니다 | 구별은 터미널 출력이 한다 — 4-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
---
# 다음
| 실험 | C-1 이 남긴 질문 |
|---|---|
| [C-2](c2-backchannel-logout.md) 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 안 퍼진다. **원인은 아무도 구현하지 않았기 때문이다** |
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | `user_session` 을 잃으면 **전 앱이 끊긴다.** 백업 범위에 들어간다 |
| [A-3](a3-database-crash.md) DB 크래시 | 여기서 본 두 층 구조가 거기서 「전체 소실 vs 일부 소실」로 갈렸다 |
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |
@@ -0,0 +1,734 @@
# C-2 재현 가이드 — 로그아웃이 왜 안 퍼지는지 양쪽에서 확인한다
해설 문서: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md) ·
증거 원문: [`docs/evidence/c2-backchannel-logout/`](../../evidence/c2-backchannel-logout/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 두 클라이언트 어디에도 `backchannel.logout.url` 이 없는 것 | `kcadm get clients` |
| BFF 소스에 `oidcLogout` 이 한 줄도 없는 것 | `grep -rn` |
| 후보 경로 셋이 전부 `302` 인 것 — **핸들러가 없다는 뜻** | `curl` |
| **IdP 쪽만 설정해도 앱 세션이 그대로 남는 것** | Redis |
| Keycloak 이 앱 공개 URL 에 `200` 으로 **닿는** 것 | 임시 curl 파드 |
| 로그에 `backchannel` 이 **0줄**인 것, 그리고 그것으로 단정하면 안 되는 이유 | Keycloak 로그 |
| **끊을 세션이 없는 상태로 시험해 무의미해지는 것** | 로그아웃 전 세션 수 |
## 전제
- [`C-1`](c1-multi-app-sso.md) 이 끝나 있다. app1(BFF)·app2(oauth2-proxy)가
둘 다 살아 있고, **IdP 로그아웃이 앱에 전파되지 않는다**를 이미 관측했다.
이 실험은 **그 원인을 찾는** 실험이다.
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다. 끝나면 되돌린다 —
[5-3](#5-3-빌린-것을-돌려준다).
- **BFF 소스 트리**(`bff/src/main/java/`)를 볼 수 있어야 한다. 1-2 가 그것을 읽는다.
- **브라우저가 필요하다.** 살아 있는 세션을 만들어야 시험이 성립한다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **Keycloak 이미지에는 `curl``wget` 도 없다**(`exit 127`). `kcadm.sh`
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.** 도달성 시험은
**임시 curl 파드**로 한다.
- 이 실험대에는 **`jq` 가 없다.**
## 주의 — 이건 클라이언트 설정을 바꾸는 실험이다
`bff-confidential` 클라이언트의 **`attributes` 를 통째로 교체한다.**
JSON 으로 주는 방식이라 **기존 속성이 같이 날아갈 수 있다.**
그래서 [2-1](#2-1-지금-attributes-를-먼저-저장해-둔다) 의 첫 명령이 백업이다.
세션도 지운다. 실험대에서만 한다. 전 구간 약 20분.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:5014:53 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> **★ 출처 하나에 주의가 붙어 있다.** 해설 문서 2절이 인쇄한 「설정이 들어갔다」
> 확인 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
> 에서 나온 것이 **아니다.** 그 파일에는 **`command terminated with exit code 1`**
> 이 남아 있다 — 점 표기로 시도한 **실패한 첫 시도**다. 성공 출력은 그 뒤 별도로
> 실행한 조회에서 나왔다. **실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은
> 잘못이었고**, 이 가이드는 3-1 에서 그 둘을 갈라 적는다.
클라이언트 UUID·IP 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
[C-1](c1-multi-app-sso.md) 이 이것을 관측했다.
```
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
```
**왜 안 퍼졌는지는 안 물었다.** 후보가 셋 있다.
| 후보 | 판정하는 법 |
|---|---|
| ① IdP 에 **보낼 주소**가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
| ② 앱에 **받을 엔드포인트**가 없다 | 소스와 실제 경로를 본다 |
| ③ IdP 가 앱에 **못 닿는다** (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
| | |
|---|---|
| 예상 | 셋 중 하나가 원인일 것 |
| **실측** | **①과 ②가 둘 다 없었다.** ③은 문제가 아니었다(`HTTP 200`) |
**C-1 이 관측한 「로그아웃이 안 퍼진다」의 원인은 단순했다 — 아무도
구현하지 않았다.**
그리고 이 실험이 실제로 증명하는 것은 그 다음이다.
```
①만 고친다 → 여전히 안 퍼진다
```
**양쪽이 다 있어야 동작한다.** 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는
것이 이 주제에서 가장 흔한 실패다. 이 가이드는 **그 실패를 일부러 재현한다.**
---
# 1. 기준선 — 어느 쪽에도 없다
넓은 것부터 좁혀 간다.
```
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
```
## 1-1. IdP 쪽 — 클라이언트 속성을 본다
**kcadm 을 먼저 로그인시킨다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**확인** — 두 클라이언트를 각각 본다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
"frontchannelLogout" : false,
--- oauth2-proxy ---
"frontchannelLogout" : false,
```
**어디를 봐야 하는가****있는 것이 아니라 없는 것을 본다.**
`backchannel.logout.url` 이 목록에 **없다.** 나온 것은 `frontchannelLogout` 뿐이다.
> **「없다」를 확인하는 법.** `grep backchannel` 로 걸러서 빈 출력을 보면
> 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 —
> B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다.
> **`--fields attributes` 로 통째로 받아 눈으로 훑는다.**
> 다른 값(`frontchannelLogout`)이 보이는 것이 「명령은 먹었다」의 증거다.
## 1-2. 앱 쪽 — 소스에 받을 자리가 있는가
**확인**
```bash
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
```
**어디를 봐야 하는가****아무것도 안 나온다.** 헤더 아래가 비어 있다.
**이 결과가 의미하는 것** — Spring Security 6.2+ 는 백채널 로그아웃을
**지원하지만 명시적으로 켜야 한다.**
```java
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
```
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
**생기지 않는다.** 소스에 없으니 경로도 없다.
> `grep` 이 빈 출력을 줄 때는 **경로가 맞는지 먼저 의심한다.**
> `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를
> 뒤져도 `grep` 은 조용히 0건을 준다.
## 1-3. 소스 말고 **실제로** 그 경로가 있는지 친다
**소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다.** 직접 친다.
**확인** — 먼저 응답을 통째로 한 번 본다
```bash
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
```
**어디를 봐야 하는가** — 상태줄과 `Location` 헤더. **302 라면 어디로 보내는가.**
로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고,
**그런 핸들러가 없어서 기본 규칙에 걸린 것**이다.
이제 후보 셋을 나란히 잰다.
**확인**
```bash
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
done
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== 실제로 그 경로가 있는가 ===
/logout/connect/back-channel/keycloak HTTP 302
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
```
**어디를 봐야 하는가****셋 다 302.**
| 응답 | 뜻 |
|---|---|
| `302` | **그런 핸들러가 없어서 인증 요구로 떨어졌다** |
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
| `404` | 라우팅 자체가 없다 |
**이 결과가 의미하는 것** — 302 는 **「없다」의 증거**다. 엔드포인트가 있었다면
POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다.
**후보 ②가 확정됐다.** ①은 1-1 에서 확정됐다.
## 1-4. ★ 끊을 세션이 있기는 한가 — 이걸 안 보면 실험이 무의미해진다
**원래 실행이 여기서 한 번 헛돌았다.**
**확인** — C-1 에서 배운 대로 **realm 을 조인해서** 센다
```bash
sudo 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'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== 로그아웃 전 상태 ===
Redis: 2 키
keycloak-patterns 세션: 0
```
**어디를 봐야 하는가****IdP 세션이 0 이다.** Redis 에는 키가 2개 있는데
Keycloak 쪽은 비어 있다.
**이 결과가 의미하는 것****이 상태에서 로그아웃을 걸면 아무 일도 안 난다.**
끊을 대상이 없기 때문이다. 그리고 「앱 세션이 그대로다」를 보고
**「전파가 안 되는구나」로 결론지을 뻔했다.**
> **★ 이것이 이 실험에서 가장 빠지기 쉬운 함정이다.** 주입은 정상적으로
> 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. **틀린 것은 전제뿐이다.**
> A층 내내 반복한 교훈 — **주입 대상이 실제로 존재하는지 먼저 확인한다.**
**하기** — 그러니 세션을 만든다. 브라우저에서
```
https://app1.hyeonworks.com/ → labuser / labpass
```
**확인** — 다시 센다
```bash
sudo 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'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt) 의 두 번째 시험
```
=== 로그아웃 전 — 실제 세션이 있는가 ===
keycloak-patterns 세션: 1
Redis: 1 키
```
**어디를 봐야 하는가****세션 수가 1 이상.** 여기서 0 이면 로그인이 안 된 것이다.
**0 인 채로 2절로 넘어가지 않는다.**
---
# 2. 주입 — IdP 쪽에만 설정한다
**의도적으로 한쪽만 고친다.** 「①만 있으면 되는가」가 이 실험의 질문이다.
## 2-1. 지금 attributes 를 먼저 저장해 둔다
**되돌리기가 이 백업에 달렸다.** JSON 으로 통째로 넣는 방식이라 기존 속성이
덮인다.
**하기**
```bash
CID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
--format csv --noquotes | tail -1)
echo "$CID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get "clients/$CID" -r keycloak-patterns --fields attributes \
| tee ~/c2-bff-attributes-backup.json
```
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
```
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
```
**어디를 봐야 하는가**`echo "$CID"` 가 **UUID 한 줄**인가.
비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고,
그 상태로 다음 명령을 치면 **엉뚱한 클라이언트를 고친다.**
> 이 UUID 는 [C-1 3-2](c1-multi-app-sso.md#3-2-어느-클라이언트가-붙었는가) 에서
> `bff-confidential` 로 확인한 바로 그 값이다(`9055fa46-…`).
> **당신 환경의 값은 다르다. 위 명령이 뽑아 준다.**
## 2-2. ★ 점 표기는 안 먹는다
**하기** — 원래 실행이 처음에 친 것. **실패한다**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"
```
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
```
command terminated with exit code 1
```
**어디를 봐야 하는가****종료코드 1.** 이건 조용한 실패가 **아니다**
실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려
**「왜」는 안 보인다.**
**왜 안 되나** — 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`)
`kcadm` 의 점 표기와 충돌한다. **JSON 으로 통째로 준다.**
## 2-3. JSON 으로 넣는다
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
```
**되돌리기** — 2-1 의 백업을 보고 원래 값으로 다시 `update` 한다.
백업이 `{ }` 처럼 비어 있었다면
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
---
# 3. 주입이 걸렸는지 확인한다
## 3-1. 설정이 실제로 들어갔는가
**확인** — 1-1 과 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
```
**실측** — 해설 문서 2절이 인쇄한 값. **위 「출처 주의」가 붙는 자리다**
```
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
```
**어디를 봐야 하는가** — 두 속성이 **둘 다** 있는가.
`url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
> **이 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
> 에 없다.** 그 파일은 2-2 의 실패로 끝나고, 위 값은 **그 뒤 별도로 실행한
> 조회**에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 자리이므로
> **당신은 지금 직접 재 두는 편이 낫다.**
## 3-2. 이 시점의 앱 상태를 적어 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
```
=== 로그인 상태를 만든다 ===
(브라우저에 이미 세션이 있다)
Keycloak 세션: 2
Redis: 2 키
```
**어디를 봐야 하는가****키 이름을 그대로 적어 둔다.** 4-3 에서 **글자 하나까지
같은지**를 볼 것이다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
---
# 4. 관찰 — 로그아웃을 걸고 앱 세션을 본다
## 4-1. 시각을 적고 로그아웃한다
**하기**
```bash
date '+%H:%M:%S 로그아웃'
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser --fields id \
--format csv --noquotes | tail -1)
echo "$USERID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create "users/$USERID/logout" -r keycloak-patterns
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== ★ IdP 로그아웃 → 백채널 알림 ===
시각: 14:54:21
```
**시각이 필요한 이유** — 뒤에서 로그를 뒤질 때 **「이 순간 전후」로 좁히기
위해서**다. `--since` 만으로는 어느 시도인지 안 갈린다.
## 4-2. IdP 쪽은 끊겼는가
**확인** — 1-4 와 같은 쿼리
```bash
sudo 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'"
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== IdP 세션은 실제로 끊겼는가 ===
keycloak-patterns 세션: 0
```
**어디를 봐야 하는가****0.** 로그아웃 자체는 동작했다.
**이 결과가 의미하는 것****주입은 성공했다.** 이제 앱 쪽을 볼 자격이 생겼다.
여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 남아 있어도 그건 당연한
결과라 아무것도 판정하지 못한다.
## 4-3. ★ 앱 세션은 그대로다
**확인** — 3-2 와 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== 앱 세션이 정리되었는가 ===
Redis: 2 키
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
```
그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도
```
=== 앱 세션 ===
Redis: 1 키
```
**어디를 봐야 하는가****개수도 이름도 그대로다.**
**이 결과가 의미하는 것****IdP 쪽만 설정해도 소용없다.**
①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과다.
## 4-4. 로그에 흔적이 있는가 — 그리고 그것으로 무엇을 말할 수 있는가
**확인** — Keycloak 양쪽 노드
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== Keycloak 로그 전체에서 backchannel 흔적 ===
keycloak-0: 0 줄
keycloak-1: 0 줄
```
**확인** — 앱 쪽
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== BFF 로그 — 백채널 요청이 도착했는가 ===
```
**어디를 봐야 하는가** — 양쪽 다 비어 있다.
**★ 그런데 여기서 결론을 넓히면 안 된다.**
| 이 출력이 말하는 것 | 말하지 않는 것 |
|---|---|
| 로그에 `backchannel` 문자열이 없다 | **Keycloak 이 요청을 안 보냈다** |
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
**로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다.** 「0줄」은 「안 보냈다」의
증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다.
> **확실한 것은 앱 세션이 남았다는 관측이다.** 그것은 직접 봤다(4-3).
> **관측한 것과 추론한 것을 섞지 않는다.** 로그 0줄을 근거로 「Keycloak 이
> 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때
> 결론 전체의 신뢰가 무너진다.
## 4-5. 네트워크 문제인가 — 후보 ③을 판정한다
**앱 세션이 안 지워지는 이유가 「요청이 못 닿아서」일 수도 있다.**
그러면 구현이 아니라 네트워크를 고쳐야 한다. **갈라야 한다.**
Keycloak 파드에는 `curl` 이 없으므로 **같은 네임스페이스에 임시 파드**를 띄운다.
**하기****미검증** (원래 실행의 명령 원문은 기록에 없다. 출력은 실측이다)
```bash
sudo kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 --command -- sh
```
파드 안에서
```sh
nslookup app1.hyeonworks.com
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석:
Address: 100.83.212.4
Non-authoritative answer:
HTTPS 도달:
HTTP 200 (0 이면 못 닿음)
```
**어디를 봐야 하는가**
| 값 | 뜻 |
|---|---|
| `Address: 100.83.212.4` | 클러스터 안에서 **공개 이름이 풀린다** |
| **`HTTP 200`** | **실제로 닿는다** |
| `HTTP 000` | curl 이 연결조차 못 했다 = **네트워크가 원인** |
**이 결과가 의미하는 것****후보 ③은 원인이 아니다.**
네트워크는 열려 있고, 그래도 세션은 남았다.
`exit` 으로 파드에서 나온다. `--rm` 이 지워 준다.
> **임시 파드는 Keycloak 파드의 완전한 대역이 아니다.** 같은 네임스페이스라
> DNS 와 대체로 같은 경로를 타지만, **NetworkPolicy 나 사이드카가 걸려 있으면
> 결과가 갈릴 수 있다.** 이 실험대에는 그런 것이 없어서 대역이 성립했다.
> 확인: `sudo kubectl -n keycloak-lab get networkpolicy` 가 비어 있는가.
**그리고 이 200 은 이 실험대의 특수 사정이다.**
이 실험대는 **tailnet + split DNS** 구성이라 클러스터 안에서 공개 이름을
불러도 되돌아온다(헤어핀). **운영에서는 안 되는 경우가 흔하다.**
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
> **설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.**
## 4-6. 그래서 왜 안 퍼졌는가
```
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (2절에서 설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 (1-2·1-3)
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
```
**②와 ③이 없다. ①만 설정해도 받을 사람이 없다.**
이것이 이 실험의 결론이고, **「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서
확인한 것**이 이 가이드의 값이다.
## 4-7. 개념 — 백채널 로그아웃의 구조
```
사용자가 어느 앱에서든 로그아웃
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
```
### `sid` 가 여기서 쓰인다
**무엇인가.** `sid` 는 Keycloak 의 user session 식별자다.
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
**왜 여기 나오나.** logout token 에 실려 오는 것이 `sid` 이고,
**앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.**
```
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
```
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다.
**없거나 틀리면.** 엔드포인트가 있어도 **어느 세션을 지울지 모른다.**
그리고 **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다**
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** BFF 는 replica 2개다.
### 부분 실패는 어떻게 되는가
```
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
```
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 「일부만 로그아웃된 상태」가
생길 확률이 올라간다. **이 실험은 그 재시도 동작을 측정하지 않았다.**
## 4-8. 구현하려면 무엇이 필요한가
| 계층 | 할 일 | 이 실험대의 상태 |
|---|---|---|
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 | **완료** (2절) |
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿) | 없음 |
| **네트워크** | IdP → 앱 공개 URL 도달 | **됨** (4-5). 운영은 확인 필요 |
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | — |
**마지막 줄이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게
동작하게 된다.** C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데,
**그 공유가 로그아웃까지는 안 간다.**
---
# 5. 복구
## 5-1. 클라이언트 속성을 되돌린다
**하기** — 2-1 의 백업을 먼저 읽는다
```bash
cat ~/c2-bff-attributes-backup.json
```
**어디를 봐야 하는가** — 원래 무엇이 있었는가. 비어 있었으면 빈 객체로,
값이 있었으면 그 값으로 되돌린다.
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
**확인** — 1-1 과 같은 명령으로 사라졌는지 본다.
> **그대로 둬도 무방하다.** 받을 엔드포인트가 없으므로 이 설정 하나로는
> 아무 일도 안 일어난다 — **그게 이 실험의 결론이었다.**
> 다만 나중에 앱을 고쳤을 때 **왜 갑자기 동작하는지 모르게 되므로**,
> 실험이 남긴 설정이라는 것을 기억하거나 지운다.
## 5-2. 세션을 정리한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
브라우저 쿠키(`auth`·`app1`·`app2`)도 지우거나 시크릿 창을 새로 연다.
## 5-3. 빌린 것을 돌려준다
**app2 는 Grafana 의 이름이다.** C 층이 끝났으면 여기서 되돌린다.
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
백업 파일이 없으면 [B-7 1-1](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다)
을 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.**
oauth2-proxy 배포까지 걷어내려면
```bash
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 클라이언트 속성 | 1-1 의 `get clients … --fields attributes` | `backchannel.logout.url`**없다** (지웠다면) |
| Keycloak 세션 | 1-4 의 realm 조인 카운트 | `0` |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod c2probe` | `NotFound` (없어야 정상) |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
```bash
sudo kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
```
> **이 실험이 재지 않은 것**
> · ②·③을 실제로 **구현한 뒤** 전파가 되는지 — 코드를 고쳐야 한다
> · Keycloak 이 요청을 보내기는 했는지 (DEBUG 로그를 켜지 않았다)
> · 부분 실패 시 **재시도 정책** (4-7)
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **로그아웃했는데 아무 변화가 없다** | **로그아웃 전 세션이 이미 0 이었다** | realm 조인해서 먼저 센다 — 1-4 |
| `kcadm -s "attributes.backchannel.logout.url=…"``exit 1` | **점 표기가 안 먹는다** | JSON 으로 통째로 — 2-2·2-3 |
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 **통째로 교체**한다 | 먼저 백업 — 2-1 |
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 — 2-1 |
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다 — 1-4 (C-1 과 같은 실수) |
| `grep -rn … bff/src/main/java/` 가 빈 출력 | **정말 없거나**, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 — 1-2 |
| 후보 경로가 `404` 가 아니라 `302` | **핸들러가 없어 인증 요구로 떨어진 것** | 302 도 「없다」의 신호다 — 1-3 |
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | **DEBUG 레벨이면 안 찍힌다** | 판정 근거로 쓰지 않는다 — 4-4 |
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | **운영에서는 그게 정상일 수 있다** — 4-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 — 4-5 |
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 — 1-1 |
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | **인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다** | 새 탭에서 주소부터 다시 연다 |
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 5-3 |
---
# 다음
| | C-2 가 남긴 것 |
|---|---|
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` **공유 저장소** (BFF replica 2개) |
| **oauth2-proxy** | 백채널 로그아웃 미지원 — **SSO 안에서 앱마다 동작이 갈린다** |
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** — 로그도 안 남는다 |
| [C-1](c1-multi-app-sso.md) 과 연결 | 두 앱이 user session 을 공유한다. **그 공유가 로그아웃까지는 안 간다** |
| [B-2](../../experiment-b2-multi-instance-session.md) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 |
| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 |
@@ -0,0 +1,831 @@
# D-1 재현 가이드 — 스키마를 통째로 지우고 백업이 진짜 백업인지 직접 본다
해설 문서: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md) ·
증거 원문: [`docs/evidence/d1-backup-restore/`](../../evidence/d1-backup-restore/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 덤프 파일 안에 세션 행이 실제로 들어 있는 것 | `grep` |
| 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것 | 밖에서 `curl` |
| 파드가 `1/1 Running` 인 채로 테이블이 0개인 것 | `get pods` · `psql` |
| `certs` 200 · `well-known` 500 · 토큰 400 으로 **부분만** 깨지는 것 | `curl` 세 번 |
| 복구가 1초 만에 오류 0건으로 끝나는 것 | `psql < 덤프` |
| 세션까지 되살아나는 것 | `offline_user_session` |
| **덤프가 DB 와 같은 기계 위에 놓여 있는 것** | `ls -l` |
## 전제
- [`A-2`](a2-database-loss.md) 를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의
모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
- [`A-3`](a3-database-crash.md) 도 먼저다. RPO 의 두 번째 겹이 거기서 나온다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- 네임스페이스는 `keycloak-lab` 이다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
- **덤프를 다른 기계로 옮기는 단계(5-6)만 호스트(`test-server`)가 필요하고,
호스트의 `sudo` 는 비밀번호를 묻는다.** 그 부분은 사람이 직접 친다.
## 주의 — 이건 데이터베이스를 비우는 실험이다
`DROP SCHEMA public CASCADE`**realm·client·user·세션을 전부 지운다.**
되돌리는 수단은 당신이 방금 뜬 덤프 파일 **하나뿐**이다. 그래서 이 가이드는
**덤프를 검증하기 전에는 2절로 넘어가지 않는다.** 전 구간 약 20분이고,
파괴 구간 자체는 1분 안쪽으로 잡는다. 중간에 그만두려면
[5-1. 되돌린다](#5-1-되돌린다) 의 명령 하나면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:5715:00 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
파드 이름·세션 id·바이트 수는 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신 그 값을 뽑는 명령을 먼저 적는다.
예시로 실린 값은 전부 위 실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는
것과, 그 파일로 실제로 서비스를 되살릴 수 있는 것 사이에는 시험되지 않은
가정이 여러 개 있다.
이 실험은 그중 둘을 판정한다.
| # | 질문 | 어떻게 가르나 |
|---|---|---|
| ① | 덤프에 **필요한 것이 다 들어가는가** | 특히 **세션**. 안 들어가면 복구 후 전원 재로그인이다 |
| ② | **복구 절차가 실제로 도는가** | 오류 없이 끝나고 데이터가 일치하는가 |
그리고 부수 질문이 하나 붙는다 — **DB 가 비면 무엇이 깨지는가.**
이게 A-2 와 대비되는 지점이고, 실제로 이 실험에서 가장 놀라운 결과가 나왔다.
```
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
```
**커넥션은 되는데 테이블이 없는 상태**는 단일 장애 주입으로는 잘 안 만들어진다.
그래서 이 실험이 필요하다.
---
# 1. 기준선 — 지우기 전에
**시험군만 재는 측정은 측정이 아니다.** 파괴 후에 볼 것을 파괴 전에 **똑같은
명령으로** 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가
있어야 한다.
넓은 것부터 좁혀 간다.
```
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
```
## 1-1. 파드가 다 떠 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt) 의
파괴 직후 목록이지만, 파괴 **전후가 같다**는 것이 이 실험의 결과이므로 기준선으로도 읽는다
```
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
```
**어디를 봐야 하는가**
- `READY` 가 전부 `1/1`
- **`RESTARTS``0`** — 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다
- `postgres` 파드가 있는지, 어느 노드에 있는지
**이 결과가 의미하는 것** — 지금은 전부 정상이다. 이 표의 값을 적어 둔다.
**복구 판정에서 「재시작 없이 돌아왔는가」를 볼 때 `RESTARTS` 를 비교한다.**
## 1-2. 데이터가 얼마나 있나
**처음 한 번은 읽는 형태로 친다.** 값만 뽑는 형태부터 배우면 psql 이 무엇을
돌려주는지 모르게 된다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**형태**
```
count
-------
2
(1 row)
```
**어디를 봐야 하는가** — 숫자 하나와 `(1 row)`. 여기서 오류가 나면 뒤의 모든
단계가 무의미하다. `psql: error: connection to server ... failed` 면 DB 가 아직
안 붙은 것이고, `relation "realm" does not exist` 면 **이미 스키마가 없는 것**이다.
이제 다섯 개를 한 줄로 모은다. **비교할 값이 필요할 때만** 이 형태를 쓴다.
**확인**
```bash
sudo 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')"
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
realms|clients|users|sessions|authclients = 2|15|2|3|1
```
> **실측 줄에는 값이 다섯이고 위 명령은 넷을 뽑는다.** 원래 실행 스크립트는
> 「인가된 클라이언트(authclients)」를 하나 더 셌는데, 해설 문서의 재현 절차에는
> 그 쿼리가 남아 있지 않다. **없는 컬럼을 지어내지 않고 넷으로 둔다** — 판정에는
> 넷으로 충분하고, 다섯째가 필요하면 당신이 세는 쿼리를 정해서 **양쪽에 같이**
> 쓰면 된다.
>
> 손으로 치면 이름표 없이 `2|15|2|3` 만 나온다. `-tAc` 는 **헤더 없이(`-t`)
> 정렬 없이(`-A`) 한 줄만**이라는 뜻이고, 여러 값을 나란히 비교할 때 이 형태가
> 편하다.
**어디를 봐야 하는가** — 숫자 넷. **이 줄을 그대로 복사해 둔다.**
복구 후에 같은 명령을 쳐서 **문자 단위로 같은지** 본다.
**이 결과가 의미하는 것** — 이 값들이 「복구가 성공했다」의 판정 조건이다.
하나라도 다르면 복구가 부분적으로만 된 것이다.
## 1-3. 세션이 DB 에 있나 — 이게 덤프에 들어갈지가 관건이다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag, realm_id from offline_user_session"
```
**형태** — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다
```
user_session_id | offline_flag | realm_id
--------------------------+--------------+--------------------------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
...
```
**어디를 봐야 하는가****행이 0개면 안 된다.** 0개면 이 실험의 ①(세션이
덤프에 들어가는가)을 판정할 수 없다. 관리 콘솔에 한 번 로그인해서 세션을
만들고 다시 본다.
> **`offline_flag` 를 눈여겨본다.** 1-2 의 개수 쿼리는 `offline_flag='0'` 만
> 셌고, 이 쿼리는 전부 나열한다. **세는 쿼리와 나열하는 쿼리가 다른 것을
> 세고 있다** — 실제로 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다
> ([`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)).
> 두 숫자가 다르다고 놀라지 말고, **복구 전후에 같은 쿼리끼리** 비교한다.
**이 결과가 의미하는 것** — 세션이 DB 테이블에 있다는 것은
`persistent-user-sessions` 가 켜져 있다는 뜻이다(A-0). **그래서 세션이 백업
대상이 된다.** volatile 이었다면 세션은 애초에 DB 에 없고, 복구해도 전원
재로그인이다 — 백업의 가치가 달라진다.
## 1-4. 밖에서 정상인가
**처음 한 번은 응답을 읽는다.**
**확인**
```bash
curl -I https://auth.hyeonworks.com/realms/master
```
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다.
같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
**확인**
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
(이것도 **파괴 직후** 값이다 — 그게 결과다)
```
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
```
**어디를 봐야 하는가** — 둘 다 `200`.
**이 결과가 의미하는 것** — 지금은 당연히 200 이다. **문제는 3절에서도 이
값이 200 이라는 것**이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의
예시로 남는다.
## 1-5. 백업을 뜬다
**하기**
```bash
date '+%H:%M:%S 백업 시작'
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
date '+%H:%M:%S 백업 완료'
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
```
**어디를 봐야 하는가** — 시각 두 줄과 파일 크기. 이 규모에서는 **1초 미만**이다.
### 개념 — `--clean --if-exists` 가 없으면 복구가 실패한다
| 옵션 | 무엇을 하나 | 없으면 |
|---|---|---|
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 | `already exists` 오류가 쏟아진다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 DB 에 복구할 때 오류가 쏟아진다 |
**둘은 짝이다.** `--clean` 만 주면 「빈 DB 에 복구」가 깨지고, `--if-exists`
만 주면 아무 효과가 없다(DROP 문 자체가 안 만들어진다).
> **왜 이 실험에서는 어차피 빈 DB 인데 필요한가.** 이 실험은 `DROP SCHEMA` 로
> 완전히 비우고 복구하지만, **실제 사고는 대개 그렇지 않다.** 반쯤 남은 DB 에
> 덤프를 밀어 넣는 상황이 훨씬 흔하고, 그때 이 두 옵션이 있고 없고가 갈린다.
**되돌리기** — 이 단계는 읽기만 한다. 파일이 마음에 안 들면 지우고 다시 뜬다.
```bash
rm -f /tmp/keycloak-backup.sql
```
## 1-6. ★ 덤프를 검증한다 — 여기를 건너뛰면 2절은 자살행위다
**「파일이 생겼다」는 「복구할 수 있다」가 아니다.** `pg_dump` 가 중간에
실패해도 파일은 남고, 크기도 0 이 아니다.
**확인 ①** 파일이 실제로 있고 크기가 말이 되는가
```bash
ls -l /tmp/keycloak-backup.sql
wc -l /tmp/keycloak-backup.sql
```
**실측**
```
크기: 394945 bytes (6956 줄)
```
**확인 ②** 테이블 정의가 다 들어갔는가
```bash
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
```
**실측**
```
포함된 테이블 수: 101
```
**어디를 봐야 하는가** — 101 이라는 **절대값이 중요한 게 아니라**, 1-2 에서
본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.
**확인 ③** 마지막 줄이 정상 종료인가
```bash
tail -3 /tmp/keycloak-backup.sql
```
**형태**
```
--
-- PostgreSQL database dump complete
--
```
**어디를 봐야 하는가**`dump complete`. **이 줄이 없으면 덤프가 중간에
끊긴 것이고, 그 파일로는 복구가 안 된다.** 이 한 줄이 「파일이 생겼다」와
「덤프가 끝났다」를 가른다.
**확인 ④** ★ 세션이 들어 있는가 — 이 실험의 질문 ①
```bash
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
```
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
```
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 1788501263 0 {"ipAddr
```
**어디를 봐야 하는가**`COPY`**다음에 실제 데이터 행이 붙어 있는가.**
`COPY ... FROM stdin;` 바로 뒤에 `\.` 만 있으면 **테이블 정의만 들어가고 행은
비어 있는 것**이다. 그건 세션을 백업하지 못한 덤프다.
> `cut -c1-110` 은 `data` 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이다.
> 처음 한 번은 `cut` 없이 쳐서 한 행이 얼마나 긴지 봐 둔다.
**이 결과가 의미하는 것****세션이 덤프에 들어간다.** 질문 ①의 답은
「들어간다」이며, 그 근거는 이 `COPY` 블록이다. 5-4 에서 이 id 들이 되살아나는
것을 확인한다.
## 1-7. ★ 덤프가 지금 어디에 있는가
**확인**
```bash
ls -l /tmp/keycloak-backup.sql
df -h /tmp
```
**어디를 봐야 하는가** — 경로. `/tmp` 다. **이 파일은 지금 `kubectl` 을 친
그 기계의 디스크에 있다.**
**이 결과가 의미하는 것** — A-4 에서 **`local-path` PVC 가 노드에 못박혀
있는 것**을 봤다. 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한
길이 덤프인데, **덤프도 같은 기계에 있으면 같이 사라진다.**
> **같은 장애 도메인에 있는 백업은 백업이 아니다.**
> 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을
> **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 5-6 에 있다 —
> **파괴 전에 읽어만 두고, 실제 이동은 복구가 끝난 뒤에 한다.**
---
# 2. 주입 — 스키마를 통째로 지운다
여기부터 데이터가 사라진다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기** (5절에서 자세히 한다)
```bash
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
**이 명령이 유일한 되돌리기 수단이다.** 1-6 의 확인 ①~④ 를 통과하지 않았으면
지금 돌아가서 한다.
**하기**
```bash
date '+%H:%M:%S 파괴'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
```
**어디를 봐야 하는가**`DROP SCHEMA``CREATE SCHEMA` 두 줄. `NOTICE:
drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
**시각을 반드시 적어 둔다.** 5-5 의 RTO 는 이 시각에서 시작한다.
> **왜 `CREATE SCHEMA public` 을 붙이나.** `public` 스키마 자체를 지우면
> 복구 스크립트가 들어갈 자리가 없다. 지우는 것은 **안의 객체**이고,
> 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.**
## 3-1. 테이블이 0개인가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from pg_tables where schemaname='public'"
```
**미검증** — 원래 실행은 스크립트로 셌다. 그 결과는 이렇다.
**실측**
```
남은 테이블: 0
```
**어디를 봐야 하는가**`0`. 여기서 101 이 그대로 나오면 `DROP` 이 다른
데이터베이스에 걸린 것이다(`-d` 인자를 본다).
**확인** — 애플리케이션 테이블이 정말 없는지 직접 물어본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from realm"
```
**형태**
```
ERROR: relation "realm" does not exist
LINE 1: select count(*) from realm
^
```
**이 결과가 의미하는 것** — **커넥션은 성립하고 SQL 도 파싱된다. 테이블만
없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가
`connection to server ... failed` 였다.
## 3-2. ★ 그런데 밖은 멀쩡하다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
keycloak-0 1/1 Running 0 4m15s
keycloak-1 1/1 Running 0 4m38s
```
**어디를 봐야 하는가**`1/1`, `RESTARTS 0`, 그리고 **`200`**.
**이 결과가 의미하는 것****데이터베이스가 통째로 비었는데 정문이 200 이다.**
여기서 「파괴가 실패했다」고 읽으면 틀린다. 3-1 에서 테이블이 0개인 것을
이미 봤다. 파괴는 성공했고, **관측 지점이 그것을 못 보는 것**이다.
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다). 캐시는 읽을 때 DB 와
대조하지 않는다 — A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과
**같은 성질**이다.
## 3-3. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
**어디를 봐야 하는가****ready 주소가 여전히 둘.**
**이 결과가 의미하는 것****아무 파드도 Service 에서 빠지지 않았다.**
A-2 에서는 여기가 빈 목록이었다. readiness 프로브가 통과하고 있다는 뜻이고,
그 이유는 4-3 에서 본다.
> `kubectl get endpoints` 는 v1.33+ 에서 deprecated 다. 실제로 이 실험대에서
> 그 경고를 봤다 — 4절 「막히면」 표에 있다.
---
# 4. 효과를 관찰한다
## 4-1. 무엇이 깨지고 무엇이 안 깨지나
**전부 깨지지 않는다.** 세 경로를 나눠서 친다.
**확인**
```bash
curl -s -o /dev/null -w 'certs %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
```
토큰 발급은 값이 필요하므로 따로 친다. **미검증** — 원래 실행은 스크립트였다
```bash
curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \
https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli -d username=admin \
-d "password=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 길이만 확인하려면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
**어디를 봐야 하는가** — 세 값이 **다 다르다**는 것.
| 경로 | 코드 | 왜 |
|---|---|---|
| `certs` (JWKS) | **200** | realm 키가 캐시에 있다. DB 를 안 본다 |
| `.well-known` | **500** | 이 응답을 만들려면 DB 를 본다 |
| 토큰 발급 | **400** | 세션을 **써야** 한다 |
**이 결과가 의미하는 것****부분적으로만 깨진다.** 헬스체크는 통과하고,
일부 엔드포인트는 정상이며, **로그인만 안 된다.**
운영에서 이 모양이 왜 고약한가 — 「사이트가 떴는가」를 재는 감시(정문 200,
JWKS 200)는 전부 초록이고, **사용자만 못 들어온다.** 이 실험의 감시 항목은
`/realms/master` 가 아니라 **토큰 발급**이어야 한다.
## 4-2. 로그가 이유를 말한다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=50
```
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
```
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)
```
**어디를 봐야 하는가****`WARN` 이지 `ERROR` 가 아니다.** 그리고 내용은
「클러스터 멤버를 못 가져온다」다 — `JGROUPS_PING` 테이블도 같이 지워졌기
때문이다(A-1 에서 그 테이블을 봤다).
**이 결과가 의미하는 것** — 디스커버리가 깨졌는데도 **로그 레벨이 WARN 이라
대시보드의 에러 카운터에 안 잡힐 수 있다.** 3-2 의 `200`, 4-1 의 부분 정상,
여기의 `WARN`**세 관측이 전부 「괜찮다」 쪽으로 기운다.**
## 4-3. 개념 — 「DB 가 살아 있다」와 「데이터가 있다」는 다르다
```
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
```
**헬스체크는 커넥션만 본다.** 그래서 빈 데이터베이스를 통과시킨다.
이건 Keycloak 의 버그가 아니다. 「DB 에 붙을 수 있는가」는 프로브가 답할 수
있는 질문이고, 「데이터가 온전한가」는 프로브가 답할 수 없는 질문이다.
후자를 재려면 **업무 트랜잭션 하나를 실제로 돌리는 감시**(예: 토큰 발급)가
따로 있어야 한다.
| 재는 것 | 이 사고에서 |
|---|---|
| 파드 `Ready` | 초록 |
| 정문 `200` | 초록 |
| JWKS `200` | 초록 |
| **토큰 발급** | **400** ← 유일하게 정직한 지표 |
---
# 5. 복구
## 5-1. 되돌린다
**하기**
```bash
date '+%H:%M:%S 복구 시작'
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1
date '+%H:%M:%S 복구 완료'
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
### ★ `-i` 를 빠뜨리면 아무 일도 안 일어난다 — 그리고 오류도 안 난다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
```
`-i` 는 **표준입력을 파드 안으로 연결하라**는 뜻이다. 없으면 파드 안의 psql 은
빈 입력을 받고 **정상 종료한다.** 셸은 오류를 내지 않고, 종료 코드도 0 이며,
`date` 두 줄은 「1초 만에 끝났다」고 찍힌다. **복구된 것과 구별되지 않는다.**
구별하는 유일한 방법은 5-2 의 데이터 대조다. **그래서 대조는 선택이 아니다.**
**확인** — 오류 줄을 센다
```bash
grep -ci '^ERROR' /tmp/restore.log
tail -5 /tmp/restore.log
```
**어디를 봐야 하는가**`0`. 0 이 아니면 어떤 줄이 실패했는지 본다.
`--clean --if-exists` 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.
## 5-2. 데이터를 대조한다 — 여기가 진짜 판정이다
**확인** — 1-2 와 **똑같은 명령**을 친다
```bash
sudo 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')"
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
```
**어디를 봐야 하는가****두 줄이 문자 단위로 같은가.**
**이 결과가 의미하는 것** — 완전 일치. 질문 ②(복구 절차가 도는가)의 답이
「돈다」인 근거가 이 두 줄이다. **여기가 다르면 그 앞의 모든 「성공」 표시는
무의미하다** — 5-1 의 `-i` 를 빠뜨렸는지 먼저 의심한다.
## 5-3. 서비스가 재시작 없이 돌아오는가
**손대지 않고 기다린다.** 여기서 파드를 재시작하면 「자가 회복하는가」라는
질문 자체가 사라진다.
**확인** — 15초쯤 뒤
```bash
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
```
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**어디를 봐야 하는가** — 500 이던 `well-known``200` 이 된 것, 그리고
**`RESTARTS` 가 여전히 0** 인 것.
**이 결과가 의미하는 것** — **커넥션 풀이 이미 붙어 있었으므로 테이블이
돌아오자마자 동작했다.** A-2 에서 본 것과 같은 자가 회복이다. 파드를 만질
필요가 없다 — 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가
문서에 남았을 것이다.
## 5-4. 세션이 살아났나
**확인** — 1-3 과 같은 쿼리
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag from offline_user_session"
```
**실측** — 원래 실행은 realm 이름을 함께 뽑았다
```
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
```
**어디를 봐야 하는가** — **1-6 확인 ④ 의 덤프 안에서 봤던 id 가 그대로
있는가.** `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도, 복구된
테이블에도 있다 — **파일에서 DB 로 실제로 넘어온 것을 눈으로 잇는다.**
**이 결과가 의미하는 것** — 세션이 백업에서 복원된다. 로그인 상태가 유지된다.
## 5-5. RTO 와 RPO 를 계산한다
**확인** — 적어 둔 시각 셋을 나란히 놓는다
```
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO = 41초
```
**어디를 봐야 하는가** — 41초 중 **복구 명령 자체는 1초**다. 나머지는
「파괴를 알아채고 무엇을 할지 정하는 시간」이며, 이 실험에서는 이미 알고
있었으므로 25초였다. **실제 사고에서는 이 부분이 대부분을 차지한다.**
### RPO 는 두 겹이다
```
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
A-3 은 **클라이언트가 200 을 받은 로그인 153건 중 4건이 DB 에 없었다**는 것을
측정했다. **백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.**
### 이 실험대의 규모는 현실적이지 않다
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3~4 | 수만 |
**복구가 1초인 것은 데이터가 작기 때문**이고, 이 실험이 확인한 것은
**절차가 맞다는 것**뿐이다. 시간은 규모에 따라 완전히 달라진다.
## 5-6. ★ 덤프를 다른 장애 도메인으로 옮긴다 — 사람이 쳐야 하는 부분
**여기가 이 실험이 「못 했다」로 남긴 항목이다.** 덤프는 아직 DB 와 같은
기계에 있다.
### 무엇을 사람이 쳐야 하나
| 하는 일 | 어디서 | sudo |
|---|---|---|
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 **무암호** — 스크립트로도 된다 |
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
| **덤프를 root 소유 경로(`/var/backups` 등)에 두기** | `test-server` | **비밀번호를 묻는다 — 사람이 친다** |
**호스트의 `sudo` 는 비대화 실행이 반드시 실패한다.** 실제로 그 벽에 부딪힌
기록이 있다.
**실측** — [`d4-certificate-renewal/01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
```
$ sudo -n -l
sudo: a password is required
```
`-n` 은 「비밀번호를 물어보지 말라」는 뜻이고, 호스트에서는 그게 곧 실패다.
**그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다.**
`ssh -t` 로 붙어 사람이 비밀번호를 쳐야 한다(`-t` 가 없으면 sudo 가
비밀번호를 읽을 tty 가 없다).
**하기****미검증**. 이 실험대는 여기까지 하지 않았다. 호스트 이름과 경로는
당신 배치에 맞춘다
```bash
# ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에
scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql
# ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트)
ssh -t test-server 'sudo install -m600 -o root -g root \
~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql'
```
**확인** — 옮긴 파일이 온전한가. **크기를 양쪽에서 세서 비교한다**
```bash
wc -c /tmp/keycloak-backup.sql
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
```
**어디를 봐야 하는가** — 두 숫자가 같은가. 다르면 전송이 잘린 것이다.
**이 결과가 의미하는 것** — 이것으로도 **부족하다.** 호스트는 VM 두 대를
품고 있는 기계이므로, 호스트가 죽으면 게스트도 덤프도 같이 간다.
**진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.**
> **이 실험이 확인하지 않은 것** — 백업 자동화, 보존 주기, 복구 리허설의
> 정기 실행. 이번엔 손으로 한 번 떴고, 한 번 되돌렸다. 그것만 참이다.
## 5-7. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
| 데이터 | 5-2 의 `-tAc` 한 줄 | 백업 시점과 **문자 단위로 동일** |
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `1/1 Running`, `RESTARTS 0` |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| **로그인** | 4-1 의 토큰 발급 | **`200`** ← 이것이 진짜 판정 |
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. 다음 실험(D-2)의 전제다 |
**덤프는 지우지 않는다.** [D-2](d2-version-upgrade.md) 가 이 파일을 전제로 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 이 절차에서 실제로 갈리는
지점이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 복구가 1초 만에 끝났는데 데이터가 없다 | **`exec``-i` 가 없다.** 오류도 안 난다 | 5-2 의 대조. `-i` 를 붙여 다시 |
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것이다 |
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3``dump complete` 가 있는가 — 1-6 확인 ③ |
| 파괴했는데 정문이 계속 `200` | **정상이다.** realm 캐시가 서빙한다 | 토큰 발급으로 판정 — 4-1 |
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 3-1 의 기대 출력이다 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 밖에서 `curl` 로 친다 |
| 세션 개수가 나열한 행 수와 다르다 | 개수 쿼리에 `offline_flag='0'` 필터가 있다 | **같은 쿼리끼리** 비교 — 1-3 |
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
| 호스트에서 `sudo` 가 안 먹는다 | **호스트 sudo 는 비밀번호를 요구한다** | `ssh -t` 로 붙어 사람이 친다 — 5-6 |
---
# 이 가이드에 스크립트가 없는 이유
원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸다. 그래서 증거 파일의
줄이 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가
붙은 형태다.
**그 형태는 사람이 치는 형태가 아니다.** 그리고 이 실험에서는 스크립트가
특히 위험하다 — **`DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때
무엇이 실행됐는지 알 수 없다.** 파괴는 손으로 치고, 그 직후에 눈으로 확인하고,
복구도 손으로 친다. 각 단계 사이에 사람이 서 있어야 한다.
---
# 다음
| 실험 | D-1 이 남긴 것 |
|---|---|
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | **백업이 전제다.** 스키마가 바뀐 뒤에는 태그를 되돌려도 안 뜬다 |
| [D-3](d3-secret-management.md) 비밀 관리 | **덤프 안에 무엇이 들어 있는지** 생각한다. 백업을 잘 챙길수록 비밀도 잘 복사된다 |
| [A-4](a4-node-loss.md) 노드 상실 | PVC 가 노드에 못박혀 있다. **덤프가 같은 노드에 있으면 둘 다 잃는다** |
| 관측 | **「DB 가 살아 있다」만 보는 헬스체크는 빈 DB 를 통과시킨다.** 업무 트랜잭션을 재는 감시가 따로 필요하다 |
| 전부 | **복구해 보지 않은 백업은 백업이 아니다.** 그리고 복구 판정은 `200` 이 아니라 데이터 대조로 한다 |
@@ -0,0 +1,738 @@
# D-2 재현 가이드 — 태그를 올리고 내려 보고 롤백이 되는 조건을 직접 본다
해설 문서: [`docs/experiment-d2-version-upgrade.md`](../../experiment-d2-version-upgrade.md) ·
증거 원문: [`docs/evidence/d2-version-upgrade/`](../../evidence/d2-version-upgrade/) ·
[`docs/evidence/followup/`](../../evidence/followup/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 업그레이드 전후로 `databasechangelog` 행 수가 그대로인 것 | `psql -tAc` |
| 파드가 하나씩 갈리는 동안 정문이 계속 `200` 인 것 | 1초 폴링 |
| 같은 스키마에서는 **롤백이 되는 것** | 태그를 되돌리고 다시 폴링 |
| 전환 순간의 `000` 이 서버 오류가 **아닌** 것 | `--max-time` |
| 스키마가 바뀐 방향에서 `ValidationFailedException` 으로 기동이 거부되는 것 | `logs keycloak-1` |
| 그때도 서비스가 살아 있는 것 — StatefulSet 이 절반에서 멈춘다 | `endpointslice` |
| 실패한 기동이 스키마를 **안 건드린** 것 | 다시 `databasechangelog` |
## 전제
- [`D-1`](d1-backup-restore.md) 이 끝나 있고 **덤프가 손에 있다.** 이 실험의
되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
- [`A-8`](../../experiment-a8-rolling-restart.md) — 롤링 재시작이 무중단이라는
것이 전제다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 네임스페이스는 `keycloak-lab` 이다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 가용성 폴링용, 하나는 관찰용.
## 주의 — 이건 실제로 버전을 바꾸는 실험이다
이미지 태그를 세 번 바꾼다(정방향 → 롤백 → 그리고 선택적으로 **실패하는**
방향). 마지막 것은 파드를 `CrashLoopBackOff` 로 만든다. **되돌리는 명령은
각 절 첫머리에 있고, 전부 태그 한 줄이다.** 전 구간 약 20분이며, 중간에
그만두려면 [6. 복구](#6-복구) 의 첫 명령 하나면 된다.
**그리고 이 실험은 백업 없이 시작하지 않는다.** 스키마가 움직이는 방향으로
가면 태그로는 못 돌아온다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 15:0015:26 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
**실측이 두 실행에서 나온다.** 처음 D-2 실행(15:0015:10, 역방향 26.0)과
후속 실행(15:2215:26, 26.7.3 정방향과 롤백)이다. 어느 쪽인지 매번 적는다.
---
# 0. 왜 이 실험을 하는가
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다.
**그 계획이 언제 동작하고 언제 동작하지 않는가**를 가른다.
Keycloak 은 **Liquibase** 로 스키마를 관리한다. 적용한 변경 하나하나가
`databasechangelog` 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의
**체크섬(`md5sum`)**이 들어 있다.
```
컨테이너가 뜬다
└─▶ Liquibase 가 databasechangelog 를 읽는다
└─▶ 자기가 아는 changeset 의 체크섬과 대조한다
├─ 같다 → 기동
└─ 다르다 → ValidationFailedException. 기동 거부
```
**「모르는 변경이 있다」가 아니라 「아는 변경인데 정의가 다르다」이며, 더
엄격한 실패다.** 그래서 판정 기준은 이렇게 된다.
| 이렇게 묻지 말고 | 이렇게 묻는다 |
|---|---|
| 「26.7.3 에서 26.7.0 으로 내려도 되나?」 | **`databasechangelog` 의 행 수가 바뀌었나?」** |
> **★ 이 가이드는 정정된 결론을 따른다.**
> 해설 문서는 처음에 「롤백은 안 된다」고 단정했다가 후속 실험에서 정정했다.
>
> | 버전 차 | `databasechangelog` | 롤백 |
> |---|---|---|
> | 26.7.0 → **26.0** | 체크섬 불일치 | **불가** |
> | 26.7.0 ↔ **26.7.3** | **210 → 210, 변화 없음** | **가능** |
>
> **판단 기준은 버전 번호가 아니라 행 수의 변화다.** 이 가이드는 그 숫자를
> 재는 법부터 가르친다.
---
# 1. 기준선 — 태그를 바꾸기 전에
**여기서 재 두지 않으면 나중에 다시 잴 수 없는 값이 하나 있다**
업그레이드 **전**의 `databasechangelog` 행 수다. 올린 뒤에는 그 값이 지워지고,
「롤백해도 되는가」를 판정할 근거가 사라진다.
```
백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군
```
## 1-1. 백업이 먼저다
**하기** — D-1 의 절차 그대로
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/pre-upgrade.sql
ls -l /tmp/pre-upgrade.sql
tail -3 /tmp/pre-upgrade.sql
```
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
(첫 실행) · [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)(후속 실행)
```
백업: 396333 bytes
백업: 395375 bytes
```
**어디를 봐야 하는가** — 크기, 그리고 `tail``dump complete`.
**이 파일이 없으면 이 실험을 하지 않는다.** 5절에서 왜인지 나온다.
## 1-2. 지금 무엇이 돌고 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
```
quay.io/keycloak/keycloak:26.7.0
```
**어디를 봐야 하는가** — 태그. **`latest` 로 되어 있으면 이 실험이 성립하지
않는다** — 무엇에서 무엇으로 가는지 말할 수 없기 때문이다.
StatefulSet 에 적힌 것과 **파드가 실제로 돌리고 있는 것**은 다를 수 있다
(적용 중이거나, 롤아웃이 멈춰 있으면).
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
**어디를 봐야 하는가** — 두 파드의 IMAGE 가 **서로 같고** StatefulSet 과도
같은가, `READY` 가 둘 다 `true`, `RESTARTS``0`.
## 1-3. ★ 마이그레이션 수 — 이 숫자가 이 실험의 전부다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from databasechangelog"
```
**형태**
```
count
-------
210
(1 row)
```
비교용으로 값만 뽑는 형태도 익혀 둔다.
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
**실측** — 두 실행 모두
```
총 마이그레이션 수: 210
```
**어디를 봐야 하는가** — 숫자 하나. **이 값을 화면 밖에 적어 둔다.**
무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를
물을 때 여기를 본다.
**확인****미검증**(원래 실행은 개수만 셌다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, author, orderexecuted, dateexecuted from databasechangelog
order by orderexecuted desc limit 5"
```
**어디를 봐야 하는가**`dateexecuted` 의 가장 최근 값. **그게 이 DB 의
스키마가 마지막으로 움직인 시각이다.**
**이 결과가 의미하는 것** — 210 은 「이 DB 는 여기까지 올라갔다」는 기록이다.
업그레이드 후에 **211 이상이 되면 스키마가 움직인 것이고, 그 순간부터
태그만으로는 못 돌아온다.**
## 1-4. 세션 — 업그레이드가 로그인 상태를 날리는지 본다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session"
```
**실측**
```
현재 세션: 4 (첫 실행)
세션 전: 3 (후속 실행)
```
**어디를 봐야 하는가** — 숫자. 0 이면 관리 콘솔에 한 번 로그인해서 만든다.
**0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.**
## 1-5. 클러스터 뷰 — Infinispan 판까지 적어 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
```
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt) 의
**업그레이드 후**
```
cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]
```
**어디를 봐야 하는가**`(v=16.0.12)` 같은 괄호 안의 판, 그리고 멤버 수 `(2)`.
**이 결과가 의미하는 것** — Keycloak 태그를 바꾸면 **함께 실린 Infinispan 판도
같이 바뀐다.** 후속 실행에서 `16.0.12 → 16.0.14` 로 올라갔다. 클러스터 프로토콜
호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 **이 줄이 멤버 2로
다시 서는지** 보는 것이 판정 항목 하나다.
## 1-6. 새 태그가 실제로 있는지 확인한다
**확인** — 레지스트리에 무엇이 있나. **처음 한 번은 그대로 본다**
```bash
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
```
한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다.
**미검증**`jq` 가 없으므로 이 실험대에서는 이렇게 읽는다
```bash
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true" \
| tr ',' '\n' | grep '"name"'
```
**어디를 봐야 하는가**`26.7.1` · `26.7.2` · `26.7.3` 이 있는가.
**처음 D-2 를 할 때 이걸 안 해서 정방향을 시험하지 못했다** — 「26.7.0 보다
새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.
## 1-7. 가용성 대조군 — 폴링을 먼저 띄운다
**주입 중에 나온 `000` 한 건을 해석하려면 평시 오류율을 알아야 한다.**
**하기** — 1초 간격으로 150회, 뒤에서 돌린다
```bash
( for i in $(seq 1 150); do
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
https://auth.hyeonworks.com/realms/master)"
sleep 1
done > /tmp/d2-avail.txt ) &
```
**되돌리기** — 그만 재려면
```bash
kill %1
```
**확인** — 30초쯤 두고 먼저 평시를 센다
```bash
tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
**어디를 봐야 하는가**`uniq -c`**줄이 몇 개인가.** 한 줄이면 전부 같은
코드였다는 뜻이다. 두 줄 이상이면 **평시에 이미 오류가 있는 것**이고, 그
상태로 주입하면 주입 중의 오류를 귀속할 수 없다.
> **`--max-time 3` 을 기억해 둔다.** 4-4 에서 나오는 `000` 이 이 값 때문이다.
---
# 2. 주입 ① — 정방향 업그레이드 (26.7.0 → 26.7.3)
**되돌리기를 먼저 읽는다.**
```bash
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
```
**단, 이 되돌리기가 유효한 것은 `databasechangelog` 가 안 바뀌었을 때뿐이다.**
바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」다(5절).
**하기**
```bash
date '+%H:%M:%S 태그 변경'
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.3
```
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
```
시작: 15:22:59
statefulset.apps/keycloak image updated
```
**어디를 봐야 하는가**`image updated` 한 줄. **이건 「적용됐다」가 아니라
「접수됐다」다.** 실제 교체는 지금부터 일어난다.
**하기** — 끝날 때까지 블록한다
```bash
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
date '+%H:%M:%S 롤아웃 완료'
```
**실측**
```
partitioned roll out complete: 2 new pods have been updated...
완료: 15:24:26
```
**어디를 봐야 하는가**`2 new pods have been updated`. 87초 걸렸다.
> **`rollout status` 가 안 끝나고 매달려 있으면 그게 신호다.** StatefulSet 은
> 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드린다. 즉 **매달림 =
> 첫 파드가 안 뜬다.** 다른 터미널에서 `get pods -w` 로 본다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
## 3-1. 파드가 새 이미지로 돌고 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
```
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
```
quay.io/keycloak/keycloak:26.7.3
keycloak-0 1/1 Running restarts=0
keycloak-1 1/1 Running restarts=0
```
**어디를 봐야 하는가****`RESTARTS``0`.** 여기가 0 인 것이 중요하다.
교체는 **새 파드를 만드는 것**이지 같은 파드를 재시작하는 것이 아니다.
`RESTARTS` 가 올라가 있으면 새 파드가 기동에 실패해 재시작을 반복하는 것이다.
**확인** — 실제로 새 파드인지는 나이로 본다
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측** — 첫 실행의 롤포워드 직후
```
keycloak-0 1/1 Running 0 10m
keycloak-1 1/1 Running 0 28s
```
**어디를 봐야 하는가**`AGE`. 하나씩 갈리므로 **나이가 다르다.** 둘 다 방금
생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.
## 3-2. 버전이 정말 바뀌었나 — 파드가 자기 입으로 말하게 한다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1
```
**실측**
```
Keycloak 26.7.3
```
**어디를 봐야 하는가** — 로그가 말하는 판. 이미지 태그와 다르면 **태그가
재사용된 것**이다(같은 태그가 다른 내용을 가리키는 경우).
---
# 4. 효과를 관찰한다
## 4-1. 끊겼나
**확인**
```bash
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
```
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
```
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
...
200 응답: 87 회
비200 : 0
```
**어디를 봐야 하는가** — 줄이 하나뿐이고 그 값이 `200` 인가.
**이 결과가 의미하는 것****정방향 업그레이드는 무중단이었다.** 87회 요청이
전부 200 이다. 파드가 하나씩 갈리는 동안 남은 파드가 받았다.
> **「무중단」은 관측 해상도에 달려 있다.** 이건 1초 간격·3초 타임아웃으로
> 잰 결과다. 더 촘촘히 보면 더 보일 수 있다 — 실제로 D-4 에서 0.2초 간격으로
> 재니 다른 것이 보였다.
## 4-2. 그림으로도 남아 있다
Grafana 스크린샷이 증거에 있다 —
[`d2-upgrade-window.png`](../../evidence/d2-version-upgrade/d2-upgrade-window.png).
**무엇이 보이나**`cluster_size`**2 → 1 → 2 를 두 번** 반복하고, 파드별
`up` 시계열이 끝나고 새 시계열이 시작된다.
**어디를 봐야 하는가** — **2 → 1 → 2 가 두 번**인 것. 파드가 둘이므로 교체도
두 번이고, 그때마다 클러스터가 잠시 한 명이 된다. **한 번만 보이면 두 파드가
동시에 갈린 것이다.**
## 4-3. ★ 스키마가 움직였나 — 이 실험의 판정
**확인** — 1-3 과 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
```
마이그레이션 후: 210 (전: 210)
세션 후: 3 (전: 3)
```
**어디를 봐야 하는가****전과 후가 같은가.**
**이 결과가 의미하는 것**
| 결과 | 뜻 | 되돌리는 법 |
|---|---|---|
| **행 수가 그대로** | 스키마가 안 움직였다 | **태그만 되돌리면 된다** |
| 행 수가 늘었다 | 새 changeset 이 적용됐다 | **덤프 복구 + 태그 되돌리기** |
26.7.0 → 26.7.3 은 **패치 릴리스라 스키마가 그대로**였다. 그래서 롤백이
가능하다는 가설이 섰고, 다음 절에서 시험한다.
## 4-4. 가설 시험 — 같은 스키마에서 롤백해 본다
**되돌리기** — 이 절 자체가 되돌리기다. 다시 올리려면 태그를 26.7.3 으로.
**하기**
```bash
date '+%H:%M:%S 롤백'
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
```
시작: 15:25:08
partitioned roll out complete: 2 new pods have been updated...
완료: 15:25:53
keycloak-0 1/1 Running restarts=0
keycloak-1 1/1 Running restarts=0
Keycloak 26.7.0
마이그레이션: 210
세션: 3
```
**어디를 봐야 하는가****파드가 뜬다.** 이게 가설의 답이다.
**이 결과가 의미하는 것** — **스키마가 안 바뀌었으면 태그를 되돌리는 것으로
충분하다.** 마이그레이션 210 그대로, 세션 3 그대로, 재시작 0.
### 전환 순간의 `000` 한 번을 오해하지 않는다
**확인**
```bash
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
grep -n '000' /tmp/d2-avail.txt
```
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
```
200 응답: 43 회 / 비200: 1
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
200 200 200 200 000 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
200 200 200 200
비200 값: 000
=== 대조: 정방향 업그레이드 때는 ===
200: 87 / 비200: 0
```
**어디를 봐야 하는가**`000` 이다. **`500``502``503` 도 아니다.**
`000` 은 **curl 이 HTTP 상태 코드를 하나도 못 받았다**는 뜻이며, 여기서는
`--max-time 3` 을 넘긴 것이다. 서버가 오류를 돌려준 것이 아니라 **3초 안에
응답이 안 왔다.**
**이 결과가 의미하는 것** — 파드 전환 순간 요청 하나가 3초를 넘겼다. 정방향에서
0회, 역방향에서 1회다. **끊긴 것과 느린 것은 다르고, 그 구별은 코드가 아니라
`--max-time` 을 알고 있어야 된다.**
---
# 5. ★ 대조 — 스키마가 움직인 방향에서는 무슨 일이 나는가 (선택)
**여기부터는 일부러 실패시킨다.** 앞의 4절까지로 이 실험의 판정은 끝났다.
이 절은 「행 수가 바뀌었을 때」가 실제로 어떤 모양인지 보려는 것이다.
**되돌리기 — 먼저 읽는다**
```bash
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
```
**하기**
```bash
date '+%H:%M:%S 26.0 으로 내린다'
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.0
```
**확인** — 이번에는 `rollout status` 로 기다리지 말고 **눈으로 본다**
```bash
sudo kubectl -n keycloak-lab get pods -w
```
**실측** — [`02-rollback-attempt.txt`](../../evidence/d2-version-upgrade/02-rollback-attempt.txt)
```
시각: 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)
```
**어디를 봐야 하는가** — 두 가지다.
- `keycloak-1``Running(0/1) → Error → CrashLoopBackOff` 를 오간다.
**`Running` 인데 `0/1` 인 상태를 「떴다」로 읽으면 안 된다** — 컨테이너
프로세스는 살아 있지만 readiness 를 통과하지 못한 것이고, 곧 죽는다.
- **`keycloak-0` 은 내내 `1/1` 이다.** StatefulSet 이 안 건드렸다.
`Ctrl-C` 로 빠져나온다.
## 5-1. 왜 실패했는지 물어본다
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'
```
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
```
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
```
**어디를 봐야 하는가****`1 changesets check sum`.** 개수가 1이다.
**이 결과가 의미하는 것** — 26.7.0 이 적용한 changeset 하나를 26.0 도 알고
있는데, **정의가 다르다.** 같은 changeset 이 버전 사이에 수정된 것이다.
Liquibase 는 스키마를 반쯤 아는 상태로 서비스하느니 **기동 자체를 거부**한다.
> 파드가 이미 죽어서 로그가 안 나오면 **직전 컨테이너의 로그**를 본다.
> ```bash
> sudo kubectl -n keycloak-lab logs keycloak-1 --previous
> ```
## 5-2. 그런데 서비스는 살아 있다
**확인**
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
sudo kubectl -n keycloak-lab get statefulset keycloak
```
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
```
https://auth.hyeonworks.com/realms/master HTTP 200
ready 주소: [10.42.1.140] ← 한 파드만
statefulset desired/ready/updated: 2 / 1 / 1
```
**어디를 봐야 하는가** — ready 주소가 **하나**, 그리고 `desired/ready/updated`
**2 / 1 / 1**.
**이 결과가 의미하는 것** — **StatefulSet 의 롤링 업데이트가 사고를 절반에서
멈춰줬다.**
```
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
```
| replica 1 이었다면 | |
|---|---|
| 유일한 파드가 CrashLoopBackOff | **전면 장애** |
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
**A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는
그 조합이 잘못된 배포를 절반에서 멈춰줬다.**
## 5-3. 실패한 기동이 스키마를 건드렸나
**확인** — 세 번째로 같은 명령
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from databasechangelog"
```
**실측**
```
realms|clients|migrations|sessions = 2|15|210|4
```
**어디를 봐야 하는가****210 그대로.**
**이 결과가 의미하는 것** — **Liquibase 가 검증 단계에서 멈췄으므로 스키마를
건드리지 못했다.** 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다.
**여기가 4번과 5번을 가르는 지점이다.**
```
✔ Liquibase 가 검증에서 멈췄다 → 이미지만 되돌리면 끝
✘ 이미 적용한 뒤였다 → DB 복구(D-1)까지 해야 한다
```
---
# 6. 복구
**하기**
```bash
date '+%H:%M:%S 복귀'
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
keycloak=quay.io/keycloak/keycloak:26.7.0
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
```
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
```
> **`kubectl rollout undo statefulset/keycloak` 도 있다.** 이 실험은 쓰지 않았고
> (**미검증**), 쓰더라도 **되돌아가는 것은 이미지뿐이다.** 스키마가 움직였다면
> undo 도 같은 벽에 부딪힌다.
## 6-1. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 태그 | `get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].image}'` | 시작할 때의 태그 |
| 파드 | `get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 클러스터 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| **마이그레이션** | `psql -tAc "select count(*) from databasechangelog"` | **210 — 시작할 때와 같다** |
| 세션 | `psql -tAc "select count(*) from offline_user_session"` | 시작할 때와 같다 |
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| 폴링 | `jobs` | 남아 있으면 `kill %1` |
| 덤프 | `ls -l /tmp/pre-upgrade.sql` | 남겨 둔다 |
---
# 7. 그래서 업그레이드 계획은 어떻게 쓰는가
```
✘ "문제가 생기면 이미지 태그를 되돌린다"
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
✔ "업그레이드 전에 databasechangelog 를 세어 두고,
바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
```
| 단계 | |
|---|---|
| 1 | **백업**(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다 |
| 2 | **`databasechangelog` 행 수를 적어 둔다** — 나중에는 못 잰다 |
| 3 | 태그 변경 |
| 4 | **첫 파드만 관찰** — StatefulSet 이 멈춰준다 |
| 5 | 행 수를 다시 센다. **그대로면** 태그만 되돌려도 된다 |
| 6 | **늘었으면** DB 복구 + 태그 되돌리기 |
## 이 실험이 확인한 것과 못 한 것
| | |
|---|---|
| ✔ 정방향(26.7.0 → 26.7.3) 무중단 | 87회 전부 200 |
| ✔ 같은 스키마에서 롤백 가능 | 43/1, 그 1은 `--max-time` 타임아웃 |
| ✔ 스키마가 바뀐 방향은 기동 거부 | `1 changesets check sum` |
| ✔ 실패가 절반에서 격리된다 | StatefulSet + readiness |
| ✔ 실패한 기동은 스키마를 안 건드린다 | 210 그대로 |
| ✘ **스키마가 실제로 늘어나는 업그레이드** | **이 실험대에서는 재현하지 못했다.** 26.7.x 사이에는 변경이 없다 |
| ✘ 마이그레이션 도중 장애 | 스키마 변경 중에 죽으면? |
| ✘ 대규모 마이그레이션 시간 | 데이터가 작아 순식간이다 |
> **가장 중요한 미검증이 첫 줄이다.** 「행 수가 늘면 태그로 못 돌아온다」는
> **역방향(26.0)에서 관측한 실패를 근거로 한 추론**이며, 실제로 행 수가 늘어난
> 뒤 되돌려 본 적은 없다. 메이저 업그레이드를 할 때 이 실험을 다시 한다.
---
# 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `rollout status` 가 안 끝난다 | **첫 파드가 안 뜬다.** StatefulSet 이 기다린다 | 다른 터미널에서 `get pods -w` — 5절 |
| 파드가 `Running` 인데 `0/1` | 프로세스는 살아 있고 readiness 미통과 | `logs` 를 본다. 「떴다」로 읽지 않는다 |
| 로그가 안 나온다 | 파드가 이미 죽었다 | `logs keycloak-1 --previous` |
| **업그레이드 전 행 수를 안 적었다** | 그 값은 이제 DB 에 없다 | 덤프에서 복원한다 — 아래 |
| 비200 이 `000` 이다 | 서버 오류가 아니라 **`--max-time` 타임아웃** | `--max-time` 값을 늘려 다시 재 본다 — 4-4 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated. **실측으로 이 경고를 봤다** | `get endpointslice -l kubernetes.io/service-name=...` |
| 두 파드가 동시에 갈렸다 | `podManagementPolicy: Parallel` | `get statefulset keycloak -o yaml \| grep podManagement` |
| 새 태그를 못 찾는다 | 레지스트리에서 확인 안 했다 | 1-6 |
**업그레이드 전 행 수를 안 적었을 때** — 덤프 안에 그 테이블이 통째로 들어
있다. **미검증**
```bash
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l
```
**어디를 봐야 하는가** — 나온 수에서 **2를 뺀다**(`COPY` 줄과 `\.` 줄).
이게 백업 시점의 행 수다. **D-1 의 덤프가 여기서 한 번 더 값을 한다.**
---
# 다음
| 실험 | D-2 가 남긴 것 |
|---|---|
| [D-1](d1-backup-restore.md) 백업·복구 | **롤백 = 백업 복구**인 경우가 있다. 태그만 되돌리는 계획은 반쪽이다 |
| [D-3](d3-secret-management.md) 비밀 관리 | 업그레이드할 때 Secret 도 같이 검토된다 |
| [A-8](../../experiment-a8-rolling-restart.md) 롤링 재시작 | **replica ≥ 2 가 잘못된 배포를 절반에서 멈춘다** |
| 운영 | **판정은 버전 번호가 아니라 `databasechangelog` 의 행 수다** |
| 전부 | **재기 전에 못 재는 값을 먼저 적어 둔다.** 업그레이드 전 행 수가 그것이다 |
@@ -0,0 +1,562 @@
# D-3 재현 가이드 — Secret 이 어디까지 감춰지는지 네 경로로 직접 본다
해설 문서: [`docs/experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md) ·
증거 원문: [`docs/evidence/d3-secret-management/`](../../evidence/d3-secret-management/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| `describe``14 bytes` 만 보여주는 것 | `describe secret` |
| 같은 값이 **한 줄로** 평문이 되는 것 | `get -o jsonpath \| base64 -d` |
| 저장소 암호화가 **꺼져 있는** 것 | `k3s secrets-encrypt status` |
| 노드 디스크의 저장 파일 안에 **평문이 있는** 것 | `grep -c` on `state.db` |
| **그 grep 이 `0` 을 돌려주는데도 안전하지 않은** 것 | 같은 명령, 다른 키 |
| 파드 안에서는 그냥 **환경변수**인 것 | `env` · `/proc/1/environ` |
| RBAC 은 실제로 막는 것 | `auth can-i` |
## 전제
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
**k3s 서버의 저장 파일도 이 노드에 있다** — 그래서 4-2 를 여기서 칠 수 있다.
- 게스트(`kc-lab-1`/`kc-lab-2`)의 `sudo` 는 **무암호**다. 호스트와 다르다.
- 네임스페이스는 `keycloak-lab` 이다.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
- [`B-6`](../../experiment-b6-key-rotation.md) ·
[`B-7`](../../experiment-b7-cookie-secret-rotation.md) 를 이미 했다면 이 실험의
결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이다.
## 주의 — 이건 비밀을 화면에 띄우는 실험이다
**이 실험의 명령 몇 개는 비밀번호를 터미널에 그대로 찍는다.** 그게 결론이기
때문에 피할 수 없지만, 그 값은 **스크롤백·화면 공유·터미널 로그**에 남는다.
이 가이드는 그래서 이렇게 한다.
- **남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 본다.**
- **값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다**
(2절). 지워도 되는 값이므로 찍어도 된다.
- 실측으로 실린 값들은 **이 저장소의 매니페스트와 문서에 이미 적혀 있는
실험대 전용 값**이다(`change-me` 가 이름에 들어 있는 이유가 그것이다).
파괴적인 단계는 없다. 만드는 것은 카나리아 Secret 하나뿐이고
[5. 복구](#5-복구) 에서 지운다. 전 구간 약 15분.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 15:0515:06 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장 |
---
# 0. 왜 이 실험을 하는가
「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이다.
**그 문장이 실제로 무엇을 막아 주는지**를 네 경로로 나눠 판정한다.
| # | 경로 | 누가 쓰나 | 예측 |
|---|---|---|---|
| ① | 쿠버네티스 API (`get secret`) | 클러스터에 접근하는 사람 | ? |
| ② | **노드 디스크의 저장 파일** | 디스크·백업·스냅샷을 얻은 사람 | ? |
| ③ | **파드 안의 프로세스** | `exec` 권한이 있는 사람, 크래시 덤프 | ? |
| ④ | RBAC | 권한이 없는 주체 | ? |
**핵심 개념부터 짚는다.**
| | 목적 | 되돌리기 |
|---|---|---|
| **인코딩** (base64) | 바이너리를 텍스트로 안전하게 **옮기기** | **키 없이 누구나** |
| 암호화 | 키 없이는 못 **읽게** 하기 | 키가 있어야 |
**Secret 이 base64 를 쓰는 이유는 감추려는 것이 아니라 YAML 에 임의 바이트를
담기 위해서다.** 그런데 `kubectl describe` 가 값을 가려서 보여주기 때문에
「가려져 있구나」라는 인상이 남는다 — 이 실험은 그 인상과 사실 사이의 거리를
잰다.
---
# 1. 기준선 — 무엇이 있는지부터 본다
## 1-1. Secret 목록
**확인**
```bash
sudo kubectl -n keycloak-lab get secret
```
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
```
bff-secrets Opaque keys=1
keycloak-lab-secrets Opaque keys=2
oauth2-proxy-secrets Opaque keys=3
```
> 실측 줄은 원래 실행 스크립트가 정리해 찍은 것이다. 손으로 치면
> `NAME / TYPE / DATA / AGE` 네 칸이 나오고, `DATA` 열이 위의 `keys=` 에 해당한다.
**어디를 봐야 하는가** — 이름과 `DATA` 열(키 개수). **`TYPE``Opaque`
것도 본다** — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는
뜻이지 **감춘다는 뜻이 아니다.**
## 1-2. `describe` 는 값을 감춘다
**확인**
```bash
sudo kubectl -n keycloak-lab describe secret bff-secrets
```
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
```
Type: Opaque
Data
====
KEYCLOAK_CLIENT_SECRET: 14 bytes
```
**어디를 봐야 하는가****키 이름과 바이트 수만 나온다.** 값이 없다.
**이 결과가 의미하는 것** — 이 화면이 「Secret 은 감춰진다」는 인상의 출처다.
`describe`**일부러** 값을 안 찍는다. 그런데 그건 `describe` 라는 명령의
동작이지, **저장이나 전송의 성질이 아니다.**
## 1-3. 값을 안 보고 확인하는 법 — 평소에는 이렇게 한다
**남의 비밀을 다룰 때 기본 자세다.** 키 이름과 길이만 본다.
**확인** — 키 이름만
```bash
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
| tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**형태**
```
"KC_BOOTSTRAP_ADMIN_PASSWORD"
"POSTGRES_PASSWORD"
```
**확인** — 길이만
```bash
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
```
**형태**
```
22
```
**어디를 봐야 하는가** — 숫자 하나. **값이 화면에 없다.**
**이 결과가 의미하는 것** — 「Secret 이 제대로 들어갔는가」를 확인하는 데는
길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개
**「비었는가 아닌가」**이지 값 자체가 아니다.
> `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와
> 같다. 값이 비었으면 `0` 이 나온다 — **`0` 은 「Secret 은 있는데 그 키가
> 비었다」는 뜻이고, 배포 실패의 흔한 원인이다.**
---
# 2. 주입 — 카나리아 Secret 하나를 만든다
**여기부터 상태가 바뀐다.** 바뀌는 것은 Secret 하나뿐이다.
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete secret d3-canary
```
## 2-1. 왜 카나리아를 쓰나
4절에서 **저장 파일 안을 grep 해야 한다.** 그러려면 **찾을 문자열을 알고
있어야** 하는데, 진짜 비밀번호를 grep 인자로 쓰면 그 값이 셸 히스토리와
프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다.
**그래서 「찾아도 아무 피해가 없는 값」을 하나 심는다.** 실험 대상이 값 자체가
아니라 **경로**이기 때문에 이렇게 해도 결론은 같다.
**하기**
```bash
sudo kubectl -n keycloak-lab create secret generic d3-canary \
--from-literal=CANARY=d3-canary-zq7v-do-not-use
```
**형태**
```
secret/d3-canary created
```
**어디를 봐야 하는가**`created`. 이미 있다면 `AlreadyExists` 가 나온다 —
그럼 지우고 다시 만든다.
> **이 값은 아무 데도 쓰이지 않는다.** 어떤 파드도 참조하지 않으므로 지워도
> 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 이유는, 나중에 저장 파일
> 어딘가에서 이 문자열을 다시 만났을 때 **무엇인지 알아보기 위해서**다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab get secret d3-canary
sudo kubectl -n keycloak-lab describe secret d3-canary
```
**형태**
```
Data
====
CANARY: 26 bytes
```
**어디를 봐야 하는가** — 여기서도 `describe` 는 바이트 수만 준다. **1-2 와
같은 화면이다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다.
---
# 4. 효과를 관찰한다 — 네 경로를 하나씩 연다
## 4-1. ① API — 한 줄로 읽힌다
**하기** — 카나리아로 먼저 해 본다. 값을 알고 있으므로 무엇이 나올지 예측된다
```bash
sudo kubectl -n keycloak-lab get secret d3-canary \
-o jsonpath='{.data.CANARY}' | base64 -d; echo
```
**형태**
```
d3-canary-zq7v-do-not-use
```
**어디를 봐야 하는가****2-1 에서 심은 값이 그대로 나온다.**
같은 명령이 실제 비밀에도 그대로 듣는다. 원래 실행이 네 개를 뽑은 결과가 이것이다.
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
```
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``14 bytes` 라고 했던 그 값이 `bff-lab-secret` (14자)이다.
**이 결과가 의미하는 것** — ① 은 **막지 않는다.** base64 는 인코딩이고
`base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 **누가 이 명령을
칠 수 있는가**이며, 그건 ④(RBAC)의 문제로 넘어간다.
> **이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다.** 카나리아로 한 번
> 확인했으면 기제는 같다. 진짜 비밀은 1-3 의 길이 확인으로 충분하다.
## 4-2. ② 저장소 — 노드 디스크에 평문이 있다
### 먼저 암호화 설정을 본다
**확인**
```bash
sudo k3s secrets-encrypt status
```
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
```
Encryption Status: Disabled, no configuration file found
```
**어디를 봐야 하는가**`Disabled`, 그리고 **`no configuration file found`**.
설정 파일이 아예 없다 — 껐다기보다 **켠 적이 없다**는 뜻이고, 이게 기본값이다.
### 저장 파일이 어디 있는지 본다
**확인**
```bash
sudo ls -l /var/lib/rancher/k3s/server/db/
```
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
```
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` | 본체 |
| `state.db-wal` | **아직 본체에 합쳐지지 않은 최근 쓰기** |
| `state.db-shm` | 공유 메모리 인덱스 |
**k3s 는 etcd 대신 SQLite 를 쓴다.** 「저장소(at rest)」의 자리는 같다 —
etcd 를 쓰는 클러스터라면 이 자리가 etcd 의 데이터 디렉터리다.
> **`-wal` 이 10MB 나 되는 것을 봐 둔다.** 방금 만든 카나리아는 **아직 본체에
> 없을 가능성이 높다.** 아래에서 이게 함정이 된다.
### ★ 파일 안을 찾아본다
**하기** — 카나리아부터
```bash
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
```
**미검증** — 이 실험대는 카나리아 대신 실제 값으로 쟀다. 그 결과가 아래다.
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
```
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
client secret 평문 등장 횟수: 0
```
**어디를 봐야 하는가****두 줄의 값이 다르다. `2` 와 `0`.**
같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다.
**이 결과가 의미하는 것 — 이 절에서 제일 중요한 문장이다.**
> **`grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에
> 이 형태로는 못 찾았다」이다.**
`2` 가 나온 순간 ②의 답은 이미 정해졌다 — **저장 파일에 평문이 있다.**
`0` 이 나온 키에 대해 「그건 안전한가 보다」라고 읽으면, **같은 파일에 평문이
들어 있는 것을 이미 본 뒤에 그러는 것이다.**
**0 이 나왔을 때 다음에 볼 곳****미검증**. 이 실험은 원인을 가리지 않았다.
```bash
sudo grep -c 'bff-lab-secret' /var/lib/rancher/k3s/server/db/state.db-wal
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c 'bff-lab-secret'
```
| 왜 안 나올 수 있나 | 확인 |
|---|---|
| 아직 `-wal` 에만 있다 | `-wal` 을 같이 grep |
| 값이 페이지 경계를 넘어 잘렸다 | `strings` 로 한 번 더 |
| 그 키가 그 시점에 없었다 | `get secret` 으로 존재 확인 |
> **`grep -c` 는 바이너리 파일에도 듣는다.** 평소의 `grep` 은 바이너리를 만나면
> `Binary file ... matches` 한 줄만 찍고 내용을 안 보여주는데, `-c` 는 개수만
> 세므로 그대로 숫자가 나온다. **값 자체를 화면에 안 띄운다는 점에서도 이
> 형태가 맞다** — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.
**그래서 무엇이 위험한가**
| | |
|---|---|
| 노드 디스크를 얻으면 | **전 클러스터의 비밀** |
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
| A-4 에서 본 `local-path` PVC | **같은 디스크에 있다** |
| D-1 의 덤프 | 같은 기계에 뒀다면 **거기도 같이** |
**D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는
「노드 디스크 하나가 모든 비밀」이다.** 백업을 잘 챙길수록 비밀도 잘 복사된다.
**k3s 는 `--secrets-encryption` 플래그로 켤 수 있다.** 지금은 안 켜져 있고,
**이 가이드는 켜지 않는다** — 켜는 것은 서버 재시작과 기존 Secret 재암호화를
수반하고, 이 실험대에서 시험하지 않았다(**미검증**).
## 4-3. ③ 파드 안 — 평범한 환경변수다
**확인** — 어느 파드를 볼지 먼저 정한다
```bash
sudo kubectl -n keycloak-lab get pods -l app=bff
```
**하기****미검증**(원래 실행은 파드 이름을 직접 지정했다)
```bash
sudo kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'
```
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
```
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
BFF_DB_PASSWORD=lab-postgres-change-me
```
**어디를 봐야 하는가****`env` 한 번이면 나온다.** 그리고 `bff-lab-secret`
은 4-1 에서 API 로 뽑은 값과 **같다** — 두 경로가 같은 평문에 닿는다.
`exec``deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라
친다.
```bash
sudo kubectl -n keycloak-lab get pod -l app=bff \
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo
```
**같은 파드 안의 다른 프로세스도 본다.** 이게 「환경변수」의 진짜 성질이다.
**하기****미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/bff -- \
sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'
```
**어디를 봐야 하는가** — 같은 값이 나오는가. `/proc/<pid>/environ` 은 그
프로세스의 환경변수를 그대로 담고 있고, **같은 UID 의 아무 프로세스나 읽는다.**
| 새는 경로 | |
|---|---|
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
| **크래시 덤프 · 오류 리포트** | 환경변수를 함께 담는 도구가 많다 |
| 자식 프로세스 | 상속된다 |
**볼륨으로 마운트하면 이 중 몇 가지가 줄어든다** — 파일 권한으로 제한할 수
있고, 환경변수 덤프에 안 들어간다.
```yaml
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
```
**줄어드는 것이지 없어지는 것이 아니다.** `exec` 권한이 있으면 파일도 읽는다.
## 4-4. ④ RBAC — 유일하게 막는다
**확인**
```bash
sudo kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
```
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
```
default SA: no
```
**어디를 봐야 하는가****`no`** 한 단어.
**어떤 권한이 있는지 통째로 보려면****미검증**
```bash
sudo kubectl auth can-i --list -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
sudo kubectl -n keycloak-lab get role,rolebinding
```
**이 결과가 의미하는 것** — 기본 서비스계정은 Secret 을 못 읽는다.
**명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문**이다. RBAC 은
기본이 거부이고, Role 을 붙여야 할 수 있게 된다.
> **네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다.** 그러므로 실질적인
> 방어선은 「누가 `get secrets` 를 할 수 있는가」이며,
> **관리자 권한을 가진 사람에게는 아무 방어가 없다.**
>
> A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼,
> Secret 접근도 **리소스 단위로 나눌 수 있다.**
## 4-5. 네 경로 정리
| # | 경로 | 감춰지는가 | 무엇이 뚫나 |
|---|---|---|---|
| ① | `get -o jsonpath \| base64 -d` | **아니다** | 클러스터 접근 권한 |
| — | `describe secret` | 값을 숨긴다 | **그래서 안전하다고 착각한다** |
| ② | 저장 파일(`state.db`) | **아니다.** 암호화 꺼짐 | 노드 디스크·백업·스냅샷 |
| ③ | 파드 안 | **아니다.** 평범한 환경변수 | `exec` · `/proc` · 크래시 덤프 |
| ④ | RBAC | **막는다** | 관리자 권한 |
**「Secret 이니까 안전하다」는 네 가지 중 하나(RBAC)만 맞다.**
그리고 ②·③ 은 **쿠버네티스 API 를 한 번도 거치지 않고** 평문에 닿는다.
---
# 5. 복구
## 5-1. 카나리아를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete secret d3-canary
sudo kubectl -n keycloak-lab get secret
```
**형태**
```
secret "d3-canary" deleted
```
**어디를 봐야 하는가** — 1-1 의 목록으로 돌아왔는가. 세 개다.
## 5-2. ★ 지웠다고 파일에서 없어지지는 않는다
**확인****미검증**. 이 실험은 삭제 후를 재지 않았다
```bash
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
```
**어디를 봐야 하는가** — 0 이 나오면 「이 시점에 이 형태로는 안 보인다」이고,
0 이 아니면 **지운 Secret 의 평문이 아직 파일에 남아 있는 것**이다.
어느 쪽이든 **4-2 의 결론은 안 바뀐다** — 판정은 이미 `2` 에서 났다.
**이 결과가 의미하는 것** — 데이터베이스 파일은 지운 행의 자리를 즉시
0으로 덮어쓰지 않는다. **「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는
다른 사건**이며, 비밀이 유출됐을 때 실제로 해야 하는 일은 삭제가 아니라
**회전(rotation)**인 이유가 여기 있다 — B-6·B-7 의 주제다.
## 5-3. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 카나리아 | `sudo kubectl -n keycloak-lab get secret d3-canary` | `NotFound` |
| Secret 목록 | `sudo kubectl -n keycloak-lab get secret` | 세 개 |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` (아무것도 안 건드렸다) |
| **터미널** | `history \| tail -40` | **비밀번호가 찍힌 줄이 어디까지 남았는지 본다** |
> **이 실험의 진짜 뒷정리는 스크롤백이다.** 4-1 을 실제 비밀로 쳤다면 그 값이
> 터미널 버퍼와 셸 히스토리에 남아 있다. 실험대 값이라 지금은 상관없지만,
> **같은 절차를 운영에서 하면 그게 유출 경로가 된다.**
---
# 6. 그래서 무엇을 해야 하는가
```
지금: 매니페스트에 stringData 평문 → git 에 커밋되면 끝
k3s 저장소 암호화 꺼짐
파드 환경변수
```
| 단계 | 얻는 것 | 이 실험대 |
|---|---|---|
| ① 매니페스트에서 값을 빼고 **`.example` 만 커밋** | git 유출을 막는다 | 안 함 |
| ② **k3s `--secrets-encryption`** 활성화 | 노드 디스크 유출을 막는다 | 안 함 (**미검증**) |
| ③ 환경변수 대신 **볼륨 마운트** | 프로세스·덤프 유출을 줄인다 | 안 함 |
| ④ **SealedSecret / 외부 KMS** | 매니페스트에 암호문만 남는다 | 안 함 |
| ⑤ **RBAC 최소화** | 유일하게 이미 동작하는 방어선을 좁힌다 | 기본값 그대로 |
**이 실험대는 ①~④ 중 아무것도 안 하고 있다.** 실험 목적으로는 의도적이지만,
**그 사실을 기록해두지 않으면 그대로 운영에 옮겨간다.** 값 이름에 `change-me`
를 넣어 둔 것이 그 최소한의 표시다.
---
# 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `grep``0` 인데 안전하다고 읽힌다 | **`0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」** | `-wal``strings` 로 한 번 더 — 4-2 |
| `grep``Binary file matches` 만 찍는다 | 바이너리 파일이다 | `-c` 를 쓴다(개수만). 값을 안 띄우는 이점도 있다 |
| `k3s secrets-encrypt` 가 없다 | **서버 노드가 아니다** | `kc-lab-1`(control-plane)에서 친다 |
| `state.db``Permission denied` | root 전용 디렉터리 | 게스트 sudo 는 무암호다. `sudo` 를 붙인다 |
| `exec deploy/bff` 가 실패한다 | 파드가 종료 중이거나 여럿이다 | `--field-selector=status.phase=Running` 으로 이름을 고른다 — 4-3 |
| `auth can-i``yes` 라고 한다 | 그 SA 에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 |
| 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 |
| 비밀번호를 화면에 찍어 버렸다 | 4-1 을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, **운영이면 회전한다** |
---
# 다음
| 실험 | D-3 이 남긴 것 |
|---|---|
| [B-6](../../experiment-b6-key-rotation.md) key 회전 | **key 를 Secret 에 두면 이 실험의 결론이 그대로 적용된다** |
| [B-7](../../experiment-b7-cookie-secret-rotation.md) 쿠키 비밀 회전 | 유출 대응은 삭제가 아니라 **회전**이다 — 5-2 |
| [D-1](d1-backup-restore.md) 백업 | **덤프에도 같은 문제가 있다.** 백업을 잘 챙길수록 비밀도 잘 복사된다 |
| [D-4](d4-certificate-renewal.md) 인증서 | **개인키(`privkey.pem`)도 같은 비밀 관리 문제다** |
| 운영 | **RBAC 이 유일하게 동작하는 방어선이다.** 관리자에게는 아무 방어가 없다 |
File diff suppressed because it is too large Load Diff
+627
View File
@@ -0,0 +1,627 @@
# D-4a 재현 가이드 — 훅 파일 하나가 38분을 1초로 만드는 것을 직접 본다
해설 문서: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md) ·
증거 원문: [`docs/evidence/d4a-deploy-hook/`](../../evidence/d4a-deploy-hook/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 훅 디렉터리가 비어 있는 것 → 파일 하나를 넣는 것 | `ls -l` |
| certbot 이 **`ran with error output`** 이라고 찍는데 **실패가 아닌** 것 | certbot 출력 원문 |
| 마스터는 그대로고 워커만 **자동으로** 갈리는 것 | `ps -eo lstart` |
| 서빙 인증서가 **그 자리에서** 바뀌는 것 | `openssl s_client` |
| 발급에서 서빙까지 **1~2초**인 것 | SCT + 보정한 훅 시각 |
| 보정하지 않으면 **훅이 발급보다 104초 먼저** 돈 것이 되는 것 | 같은 계산 |
| `notBefore`**발급 시각이 아닌** 것 | 인증서 필드 |
## 전제
- **[`D-4`](d4-certificate-renewal.md) 를 먼저 한다.** 특히 두 가지가 없으면
이 실험은 성립하지 않는다.
- **1-6** — 「reload 판정은 워커 PID 로 한다」는 기준
- **1-7** — 두 기계 시계의 왜곡을 **미리** 재 둔 값
- 관찰은 **dev 에서**, 주입은 **`test-server` 에서 사람이** 친다.
- **호스트의 `sudo` 는 비밀번호를 요구한다.** 이 실험의 주입은 전부 그쪽이다.
- 이 호스트의 certbot 은 **5.7.0**, **nginx 플러그인은 없다.**
## 주의 — 인증서를 한 장 더 쓴다
`certbot renew --force-renewal`**또** 한 번 친다. D-4 에서 한 번 썼다면
이번이 두 번째이고, **Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장
쓴 셈**이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다.
**그리고 이 실험의 주입은 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는
파일이다. 지우면 D-4 의 상태로 돌아간다 — 지우는 명령은
[5-1. 남기는 이유](#5-1-남기는-이유) 에 있다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 12:27 UTC(실제) 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** |
| **형태** | 값이 매번 달라지는 출력 |
| **미검증** | 손으로 치기 좋게 고친 형태이거나, 이 실험이 하지 않은 확장 |
## ★ 시각 표기 규약
**이 실험은 1~2초를 잰다. 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.**
| 표기 | 뜻 |
|---|---|
| `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 |
| `21:29:36 KST (ts)` | test-server 시계. **106초 빠르다** |
| `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 |
---
# 0. 왜 이 실험을 하는가
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
| D-4 가 남긴 항목 | 상태 |
|---|---|
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
**처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것**은, 이 실험대가
스물세 번 경계해 온 바로 그 실수다. 그래서 별도 실험으로 분리했다.
판정할 것은 셋이다.
| # | 질문 | 무엇으로 가르나 |
|---|---|---|
| ① | 훅이 **실행되는가** | certbot 출력 |
| ② | nginx 가 **정말 reload 되는가** | **워커 PID** (문구가 아니라) |
| ③ | **얼마나 빠른가** | SCT ↔ 보정한 훅 시각 |
---
# 1. 기준선 — 훅을 넣기 전에
## 1-1. 워커 PID — 판정 기준을 먼저 잡는다
**확인**
```bash
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
```
585 1 ... Thu Sep 3 19:00:39 nginx: master process
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
```
**어디를 봐야 하는가** — 마스터 PID 와 워커 PID **두 숫자**, 그리고 워커의
`lstart`. **이 세 값을 적어 둔다. 4-3 의 판정이 이 값과의 비교다.**
**이 결과가 의미하는 것** — 워커 28829 는 D-4 에서 **사람이** `nginx -s reload`
를 쳐서 생긴 것이다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다.
**마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명**이라는 것을 D-4 에서
확인했고, 이 실험은 그 기준을 그대로 쓴다.
## 1-2. 지금 서빙 중인 인증서
**확인**
```bash
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates
```
**어디를 봐야 하는가**`serial`. **적어 둔다.** 4-4 에서 이 값이 바뀐다.
**확인** — 발급 시각의 외부 기준도 지금 봐 둔다
```bash
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
```
**어디를 봐야 하는가**`Timestamp` 두 줄. **CT 로그가 자기 시계로 서명한
시각**이고, 이 실험대의 두 기계와 무관한 제3의 기준이다. 4-5 에서 이 값이
심판이 된다.
## 1-3. 훅 디렉터리가 비어 있는가 — 사람이 친다
**하기**
```bash
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
**실측** — [`d4-certificate-renewal/12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
```
/etc/letsencrypt/renewal-hooks/deploy/:
total 8
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
```
**어디를 봐야 하는가****`total 8``.` `..` 뿐.**
> **`sudo` 없이 치면 `Permission denied` 다.** 그 빈 출력을 「비어 있다」로 읽는
> 것이 D-4 에서 실제로 걸렸던 함정이다.
## 1-4. ★ 시계 왜곡을 먼저 잰다 — 나중에 재면 값을 해석할 수 없다
**이 실험의 답은 1~2초다.** 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다.
**그리고 왜곡은 사후에 되짚을 수 없다** — 지금 재 둔다.
**확인** — 왕복 사이에 상대 시각을 끼워 세 번 잰다
```bash
for i in 1 2 3; do
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
echo "A=$A B=$B C=$C"
done
```
**어디를 봐야 하는가** — 세 줄 각각에서 `B``(A+C)/2` 의 차이를 **눈으로**
뺀다. 그리고 **세 번의 값이 서로 비슷한가** — 흔들리면 네트워크 지연이 섞인
것이고, 안정적이면 진짜 왜곡이다.
**확인** — 어느 쪽이 맞는지는 외부 기준으로 가른다
```bash
date -u
curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
```
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
```
dev → Google 차이 +0초
dev → Let's Encrypt ACME 차이 +0초
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
```
**어디를 봐야 하는가**`NTPSynchronized`. 이 호스트는 **`no`** 다.
그리고 세 번 다 `+106.1` 로 흔들리지 않았다는 것.
**이 결과가 의미하는 것****dev 가 정확하고 test-server 가 106초 빠르다.**
```
실제 시각 = test-server 시계 106초
```
**왜 Let's Encrypt 의 `Date:` 도 보나** — 이 실험이 재는 사건의 한쪽 끝이
**Let's Encrypt 의 발급**이기 때문이다. 그쪽 기준과 dev 가 일치한다는 것을
확인해 두면, 4-5 의 비교가 같은 시간축 위에서 성립한다.
---
# 2. 주입 — 파일 하나
**되돌리기 — 먼저 읽는다**
```bash
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
```
**단, 지우면 D-4 의 상태로 돌아간다.** 이 파일은 고장이 아니라 고침이다.
## 2-1. 개념 — `pre/` · `deploy/` · `post/` 는 다르다
| 디렉터리 | 언제 실행되나 |
|---|---|
| `pre/` | 갱신 **시도** 전 |
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
| `post/` | 갱신 여부와 **무관하게** 매번 |
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 **갱신이
없는 날에도 하루 두 번 nginx 를 reload** 하게 된다 — 아무 이득 없이 워커만
갈아치우는 셈이다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때,
**실제로 갱신했을 때만** 돈다.
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
## 2-2. 왜 `nginx -t &&` 를 앞에 두는가
```sh
nginx -t && nginx -s reload
```
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 **새 워커를 못
띄운다.** `-t` 로 먼저 검사하고 통과할 때만 reload 한다.
**실패하면 옛 워커가 그대로 서비스를 계속한다** — 인증서는 안 바뀌지만
**서비스는 죽지 않는다.** 이 순서 하나가 「인증서가 안 바뀐다」와
「사이트가 내려간다」를 가른다.
> **`restart` 를 쓰지 않는 이유**도 같다. **실측(호스트)** 로 확인한
> `nginx.service` 의 유효 설정은 `Restart=on-failure` · `RestartUSec=100ms` ·
> `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 다. 설정이 깨진 채
> `restart` 를 걸면 **10초 안에 5번 실패하고 systemd 가 포기한다** — nginx 가
> 내려간 채로 멈춘다.
## 2-3. sudo 없는 곳에 파일을 미리 만들어 둔다
**사람이 비밀번호를 치며 실행할 명령은 짧을수록 좋다.** 내용 작성은 sudo 가
필요 없는 곳에서 미리 해 둔다.
**하기**
```bash
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
ssh test-server 'cat /tmp/reload-nginx.sh'
```
**형태**
```
#!/bin/sh
nginx -t && nginx -s reload
```
**어디를 봐야 하는가** — 두 줄이 맞게 들어갔는가. **`#!/bin/sh` 가 첫 줄이어야
한다.**
> **`/tmp` 를 여기서 쓰는 것은 괜찮다.** 이건 당신의 대화형 셸이 쓰는 `/tmp`
> 이기 때문이다. 다만 **`certbot-renew.service` 는 `PrivateTmp=true`**
> (**실측(호스트)**)라 **그 서비스가 보는 `/tmp` 은 다른 곳**이다 — 훅이
> 나중에 `/tmp` 에 로그를 남기도록 만들면 **타이머가 돌렸을 때 그 파일을 밖에서
> 찾을 수 없다**(**미검증** — 이 실험은 훅에 로그를 넣지 않았다).
> 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다.
## 2-4. 설치 — 여기부터 사람이 친다
**하기** — 호스트에 붙어서 직접 친다
```bash
ssh -t test-server
```
호스트의 셸에서:
```bash
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
```
**되돌리기**
```bash
sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
> 원래 실행은 이 전부를 **한 줄**로 쳤다. 사람이 비밀번호를 한 번만 치게
> 하려는 것이다. 참고로 적어 둔다.
> ```bash
> ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
> /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
> > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
> ```
> **읽기는 어렵다.** 처음 할 때는 위처럼 한 줄씩 치고, 익숙해지면 합친다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**갱신을 걸기 전에** 훅이 제자리에, 실행 가능한 상태로 있는지 본다.
**한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.**
## 3-1. 파일이 그 자리에 있고 실행 비트가 있는가
**하기** — 호스트 셸에서
```bash
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
```
**형태**
```
total 4
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
```
**어디를 봐야 하는가 — 세 가지다.**
- **`x` 비트** (`-rwxr-xr-x`). 없으면 certbot 이 그냥 건너뛴다
- **디렉터리가 `deploy/`** 인가. `post/` 에 들어가면 매번 돈다
- 소유자가 `root`
**확인** — 손으로 한 번 돌려 본다. **이게 가장 확실한 사전 점검이다**
```bash
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**형태**
```
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
**어디를 봐야 하는가**`test is successful`. **이때 워커 PID 도 바뀐다**
(이 스크립트는 실제로 reload 한다). 1-1 을 다시 재서 새 값을 기준선으로 삼는다.
## 3-2. certbot 이 훅을 부르는지 먼저 본다
**하기****미검증**. 원래 실행은 곧바로 강제 갱신을 했다
```bash
sudo certbot renew --dry-run
```
**어디를 봐야 하는가** — 출력에 `Running deploy-hook command` 계열의 줄이
나오는가, 그리고 `simulated renewals` 요약.
**이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안
깎는다.** 훅이 **호출되는지**까지만 말해 준다. **호출된 훅이 nginx 를 정말
갈아 끼웠는지는 dry-run 으로 알 수 없다** — 그래서 4절이 필요하다.
---
# 4. 효과를 관찰한다
## 4-1. 강제 갱신 — 사람이 친다
**되돌리기 — 없다.** 인증서 한 장을 실제로 발급한다.
**하기** — 호스트 셸에서
```bash
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
sudo certbot renew --force-renewal
```
**시각을 기록하되 어느 시계인지 반드시 적는다.** 호스트에서 찍은 것은
`(ts)` 이고 **106초 빠르다.**
## 4-2. ★ certbot 출력 — 함정이 여기 있다
**실측** — [`02-certbot-with-hook.txt`](../../evidence/d4a-deploy-hook/02-certbot-with-hook.txt)
```
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Renewing an existing certificate for auth.hyeonworks.com and 2 more
Hook 'deploy-hook' ran with error output:
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewals succeeded:
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
```
**어디를 봐야 하는가 — 다섯 줄을 하나씩 읽는다.**
| 줄 | 실제 의미 |
|---|---|
| `Hook 'deploy-hook' ran with error output:` | **훅이 실행됐고, stderr 에 뭔가 있었다** |
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. **갱신과 무관** |
| `nginx: … test is successful` | **`nginx -t` 통과** |
| `[notice] … signal process started` | **`nginx -s reload` 가 신호를 보냈다** |
| `Congratulations, all renewals succeeded` | 갱신 성공 |
### ★ `ran with error output` 은 실패가 아니다
**certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙인다.** 종료 코드를
말하는 것이 아니다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고,
**내용은 전부 성공**이다.
> **로그에서 `error` 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로
> 오독한다.** 그리고 반대 방향도 위험하다 — 이 실험은 **훅이 진짜로 실패했을 때
> certbot 이 무엇을 찍는지 재지 않았다**(**미검증**).
>
> **그래서 판정은 문구가 아니라 다음 절의 워커 PID 로 한다.**
## 4-3. 판정 — 워커가 교체됐다
**확인** — 1-1 과 **똑같은 명령**
```bash
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
```
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
```
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
```
**어디를 봐야 하는가**
| | 전 | 후 | 판정 |
|---|---|---|---|
| 마스터 | **585** | **585** | 그대로 |
| 워커 | 28829 | **37252** | **바뀌었다** |
| 워커 `lstart` | Fri Sep 4 18:00:35 (ts) | **Fri Sep 4 21:29:36 (ts)** | 방금 떴다 |
| 워커 `etimes` | — | **74** | 74초 전 |
**이 결과가 의미하는 것****마스터 PID 는 유지되고 워커만 바뀌었다.**
D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 **그대로 작동한다.**
그리고 이번에는 사람이 아니라 **훅이** 했다.
> **`etimes` 74 를 같이 보는 이유** — PID 는 우연히 재사용될 수 있다.
> `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다.
## 4-4. 서빙 인증서가 바뀌었다
**확인**
```bash
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates -ext subjectAltName
```
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
```
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
```
**어디를 봐야 하는가**`serial` 이 1-2 에서 적어 둔 값과 **다른가.**
D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었다. SAN 은 세 이름 그대로다.
**이 결과가 의미하는 것****훅 하나로 ①②③ 중 ①②가 끝났다.** 남은 것은
「얼마나 빨랐나」다.
## 4-5. ★ 얼마나 빨랐나 — 시계 보정이 여기서 결과를 정한다
**가진 시각은 셋이고, 두 개는 다른 시계에서 왔다.**
| 사건 | 원래 값 | 어느 시계 |
|---|---|---|
| 인증서 발급 | SCT `Sep 4 12:27:49.054 GMT` | **CT 로그** (독립) |
| 훅의 `nginx -t` | 로그 `2026/09/04 21:29:36` | **(ts)** |
| 새 워커 기동 | `lstart Fri Sep 4 21:29:36` | **(ts)** |
| 훅의 `nginx -s reload` | 로그 `2026/09/04 21:29:37` | **(ts)** |
**확인** — 새 인증서의 SCT
```bash
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
```
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
```
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
```
**보정한다**`(ts)` 값에서 106초를 뺀다.
```
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) 106초
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) 106초
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) 106초
```
**어디를 봐야 하는가****발급에서 서빙까지 1~2초.**
### 보정이 자기 검증된다
**독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 정확히 놓인다.**
**보정하지 않으면 어떻게 되나** — 훅 로그 `12:29:36 (ts→UTC)` 에서 SCT
`12:27:49` 를 빼면 **+107초**, 즉 **훅이 발급보다 104초 먼저 실행된 것**이 된다.
**물리적으로 불가능하다.**
> **음수 지연이 나오면 계산이 아니라 시계를 의심한다.** 그리고 그 의심을
> 가르는 것은 **제3의 시계**다 — 여기서는 CT 로그의 SCT 였다.
### ★ `notBefore` 로는 계산하지 않는다
인증서에는 `notBefore=Sep 4 11:29:18` 이라고 적혀 있다. **이건 발급 시각이
아니다.**
**Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다** — 클라이언트
시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다.
그리고 한 시간을 더한 값(`12:29:18`)을 발급 시각으로 그대로 쓰지도 않는다.
이 실험대의 두 인증서에서 **SCT 는 그보다 일관되게 약 89초 앞섰다.**
| 인증서 | `notBefore` | `notBefore` + 1시간 | SCT | 차이 |
|---|---|---|---|---|
| D-4 이전 것 | `Sep 3 00:47:23` | `01:47:23` | `01:45:53.18` | 약 89.8초 |
| D-4a 새것 | `Sep 4 11:29:18` | `12:29:18` | `12:27:49.05` | 약 88.9초 |
**이 차이의 원인은 이 실험이 규명하지 않았다.** 다만 **시각의 기준으로는
SCT 를 쓴다** — 그것이 보정을 자기 검증한 값이기 때문이다.
**`notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.**
## 4-6. D-4 와의 대조
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|---|---|---|
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | **certbot deploy 훅** |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
| 차이 | | **약 1150배** |
**바뀐 것은 파일 하나, 두 줄이다.**
## 4-7. 부수 정정 — D-4 의 2199초는 틀렸다
**이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다.**
D-4 에서 적은 **2199초(36분 39초)**`archive/cert2.pem` 의 mtime
(**test-server 시계**)과 일련번호 관측(**dev 시계**)을 **그대로 뺀** 값이었다.
| | 시각 (실제 UTC) |
|---|---|
| 새 인증서 디스크 기록 | **08:20:27** ← mtime `17:22:13 KST (ts)` 106초 |
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
| **공백** | **2305초 = 38분 25초** |
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지
> 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
> **1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.**
---
# 5. 복구 — 훅은 남긴다
## 5-1. 남기는 이유
**이 주입은 고장이 아니라 고침이다.** 지우면 D-4 의 상태로 돌아가고,
그 결함은 **다음 실제 갱신(약 89일 뒤)에 인증서 만료로** 나타난다.
정말 지워야 한다면:
```bash
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
```
**어디를 봐야 하는가** — 다시 `total 8`.
## 5-2. 남은 미검증 — 타이머가 스스로 갱신하는 경로
| 항목 | 상태 |
|---|---|
| `certbot-renew.timer`**실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는 시간이
지나야 시험할 수 있다. 다만 그 경로도 **같은 `certbot renew` 를 부르고 같은
`deploy/` 훅을 실행**하므로 남은 미지수는 「타이머가 뜨는가」 하나이고,
그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
**그날이 오면 무엇을 볼 것인가** — 두 줄이면 된다.
```bash
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -enddate
```
**어디를 봐야 하는가** — 워커 `lstart`**갱신 시각 근처인가**, 그리고
`notAfter` 가 밀렸는가. **문구가 아니라 이 둘이다.**
## 5-3. 원상복구 확인표
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|---|---|---|
| 훅 | `sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/` | `-rwxr-xr-x … reload-nginx.sh` **(남긴다)** |
| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 새것 |
| 서빙 인증서 | `openssl … -serial -dates` | 4-4 의 새 일련번호 |
| 체인 | D-4 1-2 | 4단계, `Verify return code: 0` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
| 임시 파일 | `ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt'` | 지워도 된다. 훅은 `/etc` 에 설치됐다 |
| 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 |
---
# 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `ran with error output` 을 보고 실패로 판단했다 | **stderr 에 뭔가 있으면 무조건 붙는 문구다** | **워커 PID** — 4-3 |
| 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` — 3-1 |
| 훅은 돌았는데 워커가 그대로 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 — 3-1 |
| 워커도 마스터도 바뀌었다 | reload 가 아니라 **재시작**됐다 | `lstart` 두 줄을 본다 |
| 지연이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-4 로 돌아간다 |
| 발급 시각이 한 시간 어긋난다 | **`notBefore` 를 발급 시각으로 읽었다** | SCT 를 본다 — 4-5 |
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 — 1-4 |
| 호스트 명령이 조용히 빈 결과 | **sudo 가 비밀번호를 못 물었다** | `ssh -t` 로 다시 |
| 훅 로그를 `/tmp` 에 썼는데 안 보인다 | **`certbot-renew.service``PrivateTmp=true`** | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(**미검증**) |
| nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 |
---
# 이 실험이 남기는 한 문장
**처방을 적었으면 시험한다.**
D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었다. **그 처방이 듣는지
확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다.** 그런데 확인하지 않은
채로 문서에 남았다면, 「고치는 법」 항목은 **다음 갱신일까지 아무도 시험하지
않은 문장**으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다.
---
# 다음
| 실험 | D-4a 가 남긴 것 |
|---|---|
| [D-4](d4-certificate-renewal.md) 인증서 갱신 | **공백 수치가 2305초로 정정됐다** — 4-7 |
| [D-3](d3-secret-management.md) 비밀 관리 | 새 `privkey2.pem` 도 같은 문제를 안고 있다 |
| [04-TLS](../04-tls/) 구축 단계 | 이 훅은 **구축 절차에 들어가야 한다.** 사후에 붙이는 것이 아니다 |
| 관측 | **밖에서 `notAfter` 를 재는 감시**가 로그 감시보다 정직하다 |
| 전부 | **판정 기준은 문구가 아니라 상태다.** 여기서는 워커 PID 였다 |
| 전부 | **1~2초를 재려면 시계부터 잰다.** 106초는 그 자체로 결론을 뒤집는다 |