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

36 KiB
Raw Blame History

B-5 재현 가이드 — Redis 를 내리고 파드가 Ready 인 채로 계속 실패하는 것을 본다

해설 문서: docs/experiment-b5-redis-loss-persistence.md · 증거 원문: docs/evidence/b5-redis-loss/

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
오류가 아니라 멈추는 것 (HTTP 000) 밖에서 curl
/actuator/health503 인데 파드는 1/1 Ready health 그룹별 응답
Service 엔드포인트에 두 파드가 그대로 남아 있는 것 endpointslice
손대지 않아도 재시작 0회로 회복하는 것 get pods · Lettuce
AOF 를 켰는데 재시작 후 dbsize 0 인 것 볼륨 없는 /data
볼륨 위에서는 살아남는 PVC 를 붙인 뒤 같은 시험

전제

  • 05-keycloak · 06-observability 가 끝나 있다.
  • B-1 · B-2 가 끝나 세션은 Redis, 토큰은 PostgreSQL 로 나뉘어 있다. 나뉘어 있어야 각각 죽여볼 수 있다 — 이 실험은 Redis 만 죽인다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • 브라우저로 https://app1.hyeonworks.com/로그인해 둔다 (labuser / labpass). Redis 에 세션이 하나는 있어야 「잃는 것」이 보인다.
  • Redis 는 redis.keycloak-lab.svc:6379, 파드는 kc-lab-2 에 고정되어 있다.

주의 — 이건 저장소를 지우는 실험이다

Redis 를 0대로 내리고, 나중에 볼륨 없이 파드를 지운다. 그 안의 세션은 돌아오지 않는다. 로그인한 사용자는 전부 로그아웃된다. 실험대에서만 한다. 전 구간 약 30분. 중간에 그만두려면 5-1 의 한 줄이면 된다.

표시 규약

표시
실측 2026-09-04 14:2414:28 KST 실행 기록의 출력 원문. 증거 파일에 그대로 있다
형태 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다

이 실험대는 그 뒤로 바뀌었다. 지금 매니페스트 (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. 파드와 노드

확인

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, RESTARTS0
  • Redis 는 하나다. replica 가 없다 — 그래서 0으로 내리면 전면 정지다
  • Redis 와 PostgreSQL 이 같은 노드(kc-lab-2)다. 매니페스트가 nodeSelector 로 고정한다 — A-4(노드 상실)에서 두 저장소가 한꺼번에 없어지게 하려는 배치다

10.42.0.5210.42.1.12403-health-groups.txt 에 남은 실제 BFF 파드 IP 다. 4-3 에서 이 두 주소가 다시 나온다.

1-2. Redis 가 지금 무엇을 들고 있나

확인

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

=== 기준선 ===
  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 가 이상하다」로 읽게 된다.

확인

sudo kubectl -n keycloak-lab get pod -l app=redis \
  -o jsonpath='{.items[0].spec.volumes}'; echo

형태 — 지금 매니페스트 기준

[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]

확인 — 그 볼륨이 /data 에 붙어 있나

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 를 켜면 성실히 /dataappendonlydir 을 만들고 매 쓰기를 기록한다. 거짓말이 아니라 정말로 기록한다. 다만 그 디렉터리가 어디 있는지를 모를 뿐이다.

emptyDir 도 마찬가지다 — 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어진다. 「볼륨을 붙였다」와 「영속 볼륨을 붙였다」는 다르다.

1-4. 세 경로를 정상 상태에서 잰다

주입 후에 볼 것을 주입 전에 똑같은 명령으로 먼저 봐 둔다.

확인

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 는 첫 줄만 남겼다

=== 외부 진입점 정상 확인 ===
  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/** 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다)

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 에 있고, 그 본문 항목 자리는 비어 있다

=== /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 이 실제로 무엇을 보는지 매니페스트에서 확인한다

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대로 내린다

여기부터 상태가 바뀐다. 되돌리는 명령을 먼저 읽어 둔다.

되돌리기

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. 적용

하기

date '+%H:%M:%S 정지'
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=0

실측02-redis-down.txt

=== ① Redis 정지 ===
  정지: 14:26:30
deployment.apps/redis scaled
  삭제 완료

시각을 반드시 적어 둔다. 5절에서 「언제부터 회복됐나」를 붙일 때 쓴다.


3. 주입이 실제로 걸렸는지 확인한다

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.

3-1. Redis 파드가 정말 없나

확인

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 가 정말 못 붙고 있나

응답이 없는 것과 붙지 못하는 것은 다르다. 로그가 이유를 말한다.

확인

sudo kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10

실측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. 엉뚱한 것을 죽이지 않았나

확인

sudo kubectl -n keycloak-lab get pods

어디를 봐야 하는가bff 두 개의 RESTARTS 가 여전히 0, 그리고 postgres 가 살아 있는 것. postgres 까지 내렸다면 이건 B-5 가 아니라 전면 장애를 재는 것이다.

실측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 와 똑같은 명령

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

=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
  /                      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 의 연결·명령 타임아웃을 짧게 잡으면 000500 이 된다. 500 이 000 보다 낫다 — 적어도 말은 하기 때문이다.

4-2. ★ 그런데 파드는 Ready 를 유지한다

이것이 이 실험의 가장 중요한 발견이다.

확인

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

=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
  /actuator/health             HTTP server
  /actuator/health/readiness   HTTP 200
  /actuator/health/liveness    HTTP 200

=== /actuator/health 본문 (Redis 항목이 있는가) ===


=== /actuator/health/readiness 본문 ===
{"status":"UP"}

어디를 봐야 하는가readiness200 이고 {"status":"UP"}.

첫 줄의 HTTP server 는 상태 코드가 아니다 — 측정이 실패한 것이다. 값이 들어와야 할 자리에 엉뚱한 문자열이 들어와 있다. 503 이라는 값은 02-redis-down.txt 쪽 측정에서 나왔다.

빈 값이나 이상한 값을 「측정 결과」로 읽지 않는다. 그건 「측정 실패」다. A-1 에서도 빈 문자열을 「변화」로 읽어 판정이 틀어진 적이 있다. 이상하면 그 자리에서 다시 친다 — 손으로 하나씩 치는 이유가 이것이다.

이 결과가 의미하는 것 — 전체 상태는 DOWN 인데 kubelet 이 보는 그룹은 UP 이다. 그래서 파드를 빼지 않는다.

   /actuator/health              redis: DOWN  →  전체 DOWN  →  503
   /actuator/health/readiness    readinessState 만  →  UP  →  kubelet: "정상"

4-3. Service 엔드포인트에 둘 다 남아 있다

확인

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

=== 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 멈춤 — 진단이 어렵다

의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다. 넣기로 정했다면 명시한다.

management:
  endpoint:
    health:
      group:
        readiness:
          include: readinessState, redis     # 넣으려면 명시해야 한다

liveness 에는 넣지 않는다. liveness 가 실패하면 kubelet 이 파드를 죽인다. Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또 죽는다 — 재시작해도 안 나아지는 문제에 재시작을 거는 것이다. 5-2 가 그 반대 증거다.


5. 복구 ① — 되돌리고 자동 회복을 본다

5-1. 되돌린다

하기

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

=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out

5-2. 손대지 않고 회복하는지 본다

BFF 를 재시작하고 싶은 충동을 참는다. 재시작하면 「스스로 회복하는가」를 영영 알 수 없다.

확인

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

  /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 에서 확인했다). 원래 측정 당시에는 없었다. 볼륨을 떼야 그때를 재현한다.

되돌리기먼저 읽어 둔다

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 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 과 똑같은 명령으로 떨어진 것을 본다

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 를 켜고 키를 심는다

하기

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

  --- 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. 파드를 지운다

하기

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

  --- 파드를 지운다 ---
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. 볼륨을 되돌리고 같은 시험을 다시 한다

하기

sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

확인 — 1-3 과 똑같은 명령으로 볼륨이 돌아온 것을 본다

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

하기 — 키를 심고 다시 지운다

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

=== 영속 볼륨 위에서 다시 시험 ===
  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: 1written-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 도 노드에 못박힌다

확인

sudo kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo

형태

local-path

local-path 는 노드의 디렉터리다. A-4 에서 본 것과 같다 — 노드가 죽으면 볼륨도 함께 접근 불가가 되고, 파드는 다른 노드로 못 옮겨간다.

영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.


7. 복구 ② · 원상복구 확인표

7-1. 실험이 심은 키를 지운다

하기

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 (후속 조사)

=== 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 refresh 경쟁 Redis lock 을 쓴다면 여기서 갱신이 멈춘다. DB 행 잠금이 유리한 이유가 이 실험으로 보강된다
B-6 암호화 key 교체 Redis 가 이제 영속적이므로 key 를 바꾸면 옛 데이터가 남아 있다
D-1 백업·복구 local-path PVC 는 노드에 묶여 있다 — 노드가 안 돌아오면 백업뿐
구성 readiness 그룹에 무엇을 넣을지 명시적으로 정한다. 기본값은 결정이 아니다
구성 Redis 클라이언트에 타임아웃을 걸어 000500 으로 바꾼다