Files
keycloak-pattern/docs/guides/experiments/b5-redis-loss-persistence.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 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>
2026-09-07 18:29:00 +09:00

884 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 으로 바꾼다 |