Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b5-redis-loss.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:01:55 +09:00

41 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
af9645a0-8ca8-481d-9624-69fce6449b7c SETUP reproduce-b5-redis-loss Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다 where-application-state-lives 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/af9645a0-8ca8-481d-9624-69fce6449b7c/edit
name version
Redis 7.4.x
name version
netty-transport 4.1.135.Final
final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5
cdac9b8178391311d8eca1ebc6cac15bb62d79af

Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다

Redis 를 0대로 내렸을 때 무엇이 멈추는지 재는 절차다. 세 경로와 health 그룹과 Service 엔드포인트를 보고, 이어서 볼륨을 뗀 채 파드를 지워 영속화 설정만으로 무엇이 남는지 본다. 세션은 돌아오지 않으므로 실험대에서만 하고, 되돌리려면 매니페스트를 다시 적용한다.

관계

  • 볼륨 없는 영속화와 유예 없는 키 회전 이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
  • readiness 가 깨진 노드를 시야에서 먼저 치운다 A-2 는 readiness 가 파드를 뺐고 여기는 안 뺀다. 무엇이 그 차이를 만드는지 다룬다.
  • up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다 파드가 Ready 인 채로 계속 실패하는 동안 지표가 무엇을 말하는지 다룬다.
  • PostgreSQL 을 정상 종료시키고 네 경로를 잰다 같은 모양의 실험을 Keycloak 쪽에서 한 편이다. 두 결과를 견주는 것이 이 절차의 결론이다.
  • Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다 먼저 해 둬야 하는 편이다. 세션이 Redis 에 있어야 잃는 것이 보인다.

본문

읽기 전에 — 어디서 치는가

명령은 kc-lab-1 에서 kubectl 로 친다. kubectlsudo 를 붙이지 않는다 — root 홈에는 kubeconfig 가 없어 localhost:8080 으로 붙으려다 끝난다. 브라우저는 시작 전에 한 번 쓴다. 세션이 Redis 에 하나는 있어야 잃는 것이 보인다.

무엇
네임스페이스 keycloak-lab
주입 수단 ① scale deployment/redis --replicas=0 — 없는 상태가 유지된다
주입 수단 ② volumeMountsvolumes 를 patch 로 떼고 파드를 지운다
Redis redis.keycloak-lab.svc:6379, 파드는 kc-lab-2 에 고정
재는 경로 셋 — / · /bff/token-boundary · /actuator/health
전 구간 약 30분
잃는 것 로그인 세션. 돌아오지 않는다

이 실험대는 그 뒤로 바뀌었다. 지금 매니페스트(bff-redis.yaml)에는 B-5 의 결론이 이미 반영되어 PVC 와 --appendonly yes 가 들어 있다. 그래서 둘째 주입은 볼륨 없는 상태를 다시 만드는 단계부터 시작한다. 원래 실행은 반대 순서였다 — 볼륨 없는 상태에서 시작해 PVC 를 붙였다(unknown).

이 실험이 가르는 것

A-2 에서 Keycloak 의 PostgreSQL 을 내렸을 때는 이렇게 됐다.

   DB 정지  →  헬스체크 실패  →  파드 NotReady  →  Service 에서 빠짐  →  밖에서 503

명확한 실패였다. 503 은 지금 안 된다고 말하고, 클라이언트는 재시도든 포기든 정할 수 있다. 통념은 의존 저장소가 죽으면 헬스체크가 알아서 파드를 빼 준다는 것이고, 이 절차는 진짜 그런지와 이번에는 무엇을 보고 판단하는지를 잰다.

두 번째 질문이 붙는다.

   Redis 를 다시 띄우면  →  세션이 남아 있나?

영속화를 켜 두면 된다는 통념이 쿠버네티스에서 어떻게 어긋나는지를 잰다. 그래서 영속화를 논하기 전에 /data 가 무엇인지부터 보는 절이 이 절차에서 가장 무겁다.

전제와 되돌리기

  • 05-keycloak · 06-observability 가 끝나 있다.
  • B-1 · B-2 가 끝나 세션은 Redis, 토큰은 PostgreSQL 로 나뉘어 있다. 나뉘어 있어야 각각 죽여볼 수 있고, 이 절차는 Redis 만 죽인다.
  • 브라우저로 https://app1.hyeonworks.com/ 에 로그인해 둔다(labuser / labpass).

저장소를 지우는 실험이다. Redis 를 0대로 내리고 나중에 볼륨 없이 파드를 지운다. 그 안의 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 중간에 그만두려면 한 줄이면 된다.

kubectl -n keycloak-lab scale deployment/redis --replicas=1

볼륨을 뗀 뒤에는 매니페스트를 다시 적용해 되돌린다. 그 두 줄이 둘째 주입의 유일한 되돌리기다. deploy/lab/k8s/bff-redis.yaml 은 저장소 안의 상대 경로다 — 저장소를 체크아웃한 디렉터리에서 쳐야 풀리고, 다른 디렉터리에서 치면 경로가 없다는 오류로 끝나 볼륨이 안 돌아온다. 그 체크아웃이 kc-lab-1 의 어디에 있는지는 원본 가이드에 없다(unknown).

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

주입 전에 같은 명령으로 먼저 본다

파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹

1. 파드가 어디에 몇 개 있는가

무엇을 보는가 — 파드 넷의 상태와 배치.

kubectl -n keycloak-lab get pods -o wide

어디를 보나 — 모양은 이렇다(observed).

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.124 는 실제 BFF 파드 IP 이고, 관찰 절에서 이 두 주소가 다시 나온다.

2. Redis 안에 무엇이 있고 영속화가 어떻게 설정돼 있는가

무엇을 보는가 — 키 수와 두 영속화 설정.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly

어디를 보나 — 실측은 이렇다(observed, 01-baseline.txt).

=== 기준선 ===
  Redis 키: 1
  PostgreSQL 토큰: 1 행
  Redis 영속화 설정:
    save = save	
    appendonly	no
그때
키 수 1 로그인 세션 하나
save 빈 값 RDB 스냅샷이 꺼져 있다
appendonly no AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)도 꺼져 있다

이 값이 뜻하는 것 — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 --appendonly yes 로 시작하므로 appendonly yes 가 나오고, 그 차이가 둘째 주입의 출발 조건이다.

save 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. config get save 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 save = save 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.

3. /data 가 볼륨인가

무엇을 보는가 — 영속화를 말하기 전에 확인할 셋. 이 확인을 건너뛰면 「AOF 를 켰는데 안 남는다」를 「Redis 가 이상하다」로 읽게 된다.

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

지금 매니페스트 기준의 모양은 이렇다(observed).

[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
kubectl -n keycloak-lab get pod -l app=redis \
  -o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
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 는 장식이다. 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.

   /data 가 볼륨이 아니다  →  이미지 위의 쓰기 가능 레이어에 쓴다
                           →  컨테이너가 없어지면 그 레이어도 없어진다

Redis 는 이것을 모른다. appendonly yes 를 켜면 성실히 /dataappendonlydir 을 만들고 매 쓰기를 기록한다. 거짓말이 아니라 정말로 기록하고, 다만 그 디렉터리가 어디 있는지를 모른다. emptyDir 도 마찬가지다 — 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어진다. 볼륨을 붙였다와 영속 볼륨을 붙였다는 다르다.

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

어디를 보나 — 증거에는 첫 줄만 남았다(observed, 01-baseline.txt).

=== 외부 진입점 정상 확인 ===
  https://app1.hyeonworks.com/  HTTP 200

000 이 아닌 것이 판정 기준의 전부다.

경로 정상일 때
/ 200 permitAll 정적 페이지. Redis 를 안 탄다
/bff/token-boundary 200 또는 로그인으로 보내는 3xx 세션이 필요하다 — Redis 를 탄다
/actuator/health 200 모든 지표의 합

이 값이 뜻하는 것 — 셸의 curl 에는 로그인 쿠키가 없으므로 두 번째는 보통 3xx 이고, 가이드가 이 대목을 미검증으로 표시했다(unknown). 200 이든 3xx 든 상관없다 — 이 절차가 보는 것은 응답이 오는가이고, 3xx 를 만드는 과정에서도 BFF 는 세션을 만들려고 Redis 를 건드린다.

--max-time 을 반드시 붙인다. 주입 뒤 이 요청은 응답이 안 온다. 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 멈춤이 아니라 내 터미널이 이상함으로 읽게 된다.

5. health 그룹 셋이 서로 다른지 본다

무엇을 보는가 — 세 응답의 본문. /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 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.

정지 후 값이 증거에 이렇게 남아 있고, 그 본문 항목 칸은 비어 있다(observed, 03-health-groups.txt).

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


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

첫 칸이 비어 있는 것은 측정 실패다. 파드 안에서 본문을 받아오려다 못 받았다. 밖에서 직접 재 두는 편이 낫다 — 뒤에서 이 값을 비교하게 된다.

   /actuator/health              모든 지표의 합   ← redis 지표가 여기 있다
   /actuator/health/readiness    readiness 그룹   ← 기본값은 readinessState 뿐
   /actuator/health/liveness     liveness 그룹
kubectl -n keycloak-lab get deploy bff \
  -o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
/actuator/health/readiness

이 값이 뜻하는 것redis 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다. 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로다. 전체는 DOWN 인데 readiness 는 UP 인 상태가 성립한다.

주입

주입은 둘이다. 첫째는 Redis 를 0대로 내리고, 둘째는 볼륨을 뗀 채 영속화만 켜고 파드를 지운다. 둘째는 첫째를 되돌린 뒤에 한다.

내리는 방법을 고른 이유부터 본다.

방법 만들어지는 상태
delete pod Deployment 가 즉시 새로 만든다. 몇 초짜리 공백이라 관찰할 시간이 없다
scale --replicas=0 없는 상태가 유지된다. 내가 되돌릴 때까지
NetworkPolicy 로 6379 차단 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 기존 연결은 안 끊긴다

저장소가 없어진 상태를 안정적으로 유지하는 것이 목적이므로 두 번째를 쓴다. 파드가 사라지므로 주입 여부를 눈으로 확인하기도 쉽다.

1. Redis 를 0대로 내린다

목적 — Redis 가 없는 상태를 만들고 그 상태를 유지한다.

① 시각을 남기고 replica 를 0으로 내린다.

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

예상 결과 — 실측은 이렇다(observed, 02-redis-down.txt).

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

왜 필요한가 — 시각을 반드시 적어 둔다. 언제부터 회복됐나를 붙일 때 쓴다.

문제가 생기면 — 파드가 몇 초 만에 돌아왔다면 delete pod 를 쳤다. scale --replicas=0 으로 다시 한다.

2. 볼륨을 떼고 영속화만 켠다

목적 — 영속화 설정은 켜져 있고 /data 는 컨테이너 파일시스템인 상태를 만든다. 첫째 주입을 되돌린 뒤에 한다.

⓪ 먼저 Redis 를 다시 올린다. 바로 앞 절에서 0대로 내려 두었고, 이 절의 명령은 전부 파드가 살아 있어야 한다. 올리지 않고 이어 치면 exec deploy/redis 가 붙을 파드를 못 찾는다.

kubectl -n keycloak-lab scale deployment/redis --replicas=1
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

volumeMountsvolumes 를 함께 뗀다. 가이드가 이 방향을 미검증으로 표시했다(unknown) — 원래 실행은 반대 순서였다.

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"}]'
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

② AOF 를 켜고 키를 심은 뒤 /data 를 본다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- ls -la /data

예상 결과appendonly yes 가 나오고 /dataappendonlydir 이 생긴다.

왜 필요한가PVC 자체는 지우지 않는다. Deployment 에서 참조만 뗐고, 나중에 apply 로 되돌리면 같은 PVC 에 다시 붙는다. PVC 를 지우면 local-path 프로비저너가 노드의 디렉터리까지 지운다.

문제가 생기면 — 패치가 안 먹으면 spec.volumes 가 여전히 PVC 를 보여 준다. 주입 검증에서 그것부터 본다.

주입 검증

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

네 확인이 같은 시점을 보지 않는다. ①②③ 은 첫째 주입이 걸려 있는 동안에만 성립한다 — 주입 1 절을 친 직후, 주입 2 절의 ⓪ 으로 Redis 를 다시 올리기 전에 본다. ④ 는 둘째 주입을 친 뒤라 Redis 가 1대로 살아 있을 때 본다. 절 순서대로 위에서 아래로 한 번에 치면 ① 이 1/1 을 내는데, 그것은 스케일이 안 먹은 증상이 아니라 ⓪ 이 제대로 올린 결과다.

kubectl -n keycloak-lab get pods -l app=redis
kubectl -n keycloak-lab get deploy redis

모양은 이렇다(observed).

No resources found in keycloak-lab namespace.

NAME    READY   UP-TO-DATE   AVAILABLE   AGE
redis   0/0     0            0           3d

0/0 이어야 한다. 1/1 이면 스케일이 안 먹었거나 다른 네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.

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

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

실측은 이렇다(observed, 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]

pollConnectfinishConnect 는 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 000 이 여기서 나온다.

kubectl -n keycloak-lab get pods
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w   1/1   Running   0     17m
bff-555df79c97-vgg6g   1/1   Running   0     16m

bff 두 개의 RESTARTS 가 여전히 0 이고 postgres 가 살아 있어야 한다(observed). postgres 까지 내렸다면 B-5 가 아니라 전면 장애를 재게 된다. 여기서 이미 답이 절반 나와 있다 — Redis 가 없는데 1/1 이다.

둘째 주입도 걸렸는지 본다. 볼륨 확인은 주입 전과 똑같은 명령이다.

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

빈 줄이 나와야 한다. 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다.

빈 줄에는 뜻이 둘이다. 이 명령은 app=redis 라벨이 붙은 파드 중 첫째를 골라 그 파드의 spec.volumes 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 get pods -l app=redis 로 Redis 파드가 하나 Running 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다.

  --- 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 이 실제로 만들어졌다(observed, 04-persistence.txt). Redis 는 시킨 대로 했다 — 설정도 yes 고 디렉터리도 있고 파일도 쓰인다. 여기서 영속화가 켜졌다고 결론 내리면 틀린다. 어디에 쓰는지를 안 봤기 때문이고, 지금 /data 는 컨테이너 파일시스템이다.

관찰

000 은 오류가 아니라 멈춤이다. 주입 전과 똑같은 명령을 친다.

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

실측은 이렇다(observed, 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(지연 주입)에서 본 것과 같은 문제이고, 브라우저 탭도 그 앞의 로드밸런서도 그 앞의 사용자도 전부 붙잡힌다. 응답 본문도 비어 있다(observed, 같은 파일).

  --- token-boundary 응답 본문 ---


본문이 없다는 것은 오류 페이지조차 못 만들었다는 뜻이다. 고치려면 클라이언트에 타임아웃을 건다. Lettuce 의 연결·명령 타임아웃을 짧게 잡으면 000500 이 되고, 500000 보다 낫다 — 적어도 말은 하기 때문이다.

그런데 파드는 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

실측은 이렇다(observed, 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 에서도 빈 문자열을 변화로 읽어 판정이 틀어진 적이 있다. 이상하면 그 칸을 다시 친다.

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

그래서 Service 에서 파드를 빼지 않는다.

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
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
  ready: [10.42.0.52 10.42.1.124]

두 주소가 그대로 ready 다(observed). 주입 전에 본 그 두 IP 이고, 두 파드가 계속 트래픽을 받으며 계속 실패한다. 어느 replica 로 가도 결과가 같으므로 재시도해도 소용없다. kubectl get endpoints 는 쓰지 않는다 — v1.33 부터 deprecated 라 경고가 뜨고, 해설 문서 5절의 재현 절차에는 옛 형태(get endpoints bff)가 실려 있다.

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 가 없으므로 또 죽는다 — 재시작해도 안 나아지는 문제에 재시작을 걸게 된다.

첫째 주입을 되돌리고 손대지 않는다. BFF 를 재시작하고 싶은 충동을 참는다 — 재시작하면 스스로 회복하는가를 영영 알 수 없다.

date '+%H:%M:%S 복구'
kubectl -n keycloak-lab scale deployment/redis --replicas=1
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s

①의 실측은 이렇다(observed, 04-persistence.txt).

=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
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
kubectl -n keycloak-lab get pods -l app=bff

실측은 이렇다(observed, 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 가 비었으니 사용자는 로그아웃된다. 여기서 다음 질문이 나온다 — Redis 를 다시 띄웠는데 왜 세션이 없나. 답은 영속화가 없었으니까다. 그럼 켜면 되나.

둘째 주입의 결과가 그 답이다. 볼륨 없이 AOF 만 켠 채 파드를 지운다.

rollout status 가 돌아와도 지운 파드가 아직 종료 중일 수 있다. 이어지는 exec deploy/redis 가 그 파드에 붙으면 명령이 실패하거나 지우기 전 숫자를 낸다. 이 절차의 판정이 바로 그 dbsize 이므로, 숫자가 이상하면 get pods -l app=redisRunning 하나만 남았는지 보고 다시 친다.

kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
  --- 파드를 지운다 ---
deployment "redis" successfully rolled out
  재기동 후:
    dbsize:    0
    b5:probe   
    b5:aof     
    appendonly no

두 가지가 같이 사라졌다(observed, 같은 파일).

사라진 것
데이터 /data 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다
설정 CONFIG SET런타임 전용이다. 재기동하면 매니페스트의 args 가 이긴다

쿠버네티스에서 영속화 설정만 켜는 것은 장식이다. appendonly yes 를 켜고 안심하는 것이 가장 위험하다 — 파일은 만들어지고 로그도 정상이며, 사라지는 것은 재시작 순간뿐이다. 그리고 재시작은 노드 정비·이미지 갱신·OOM(out of memory, 메모리가 모자라 커널이 프로세스를 죽이는 일) 어느 것으로든 일어난다. 설정이 되돌아간 것도 따로 중요하다. CONFIG SET 으로 고친 값은 CONFIG REWRITE 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가 안 남는다. 런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.

볼륨을 되돌리고 같은 시험을 다시 하면 결과가 갈린다.

kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
=== 영속 볼륨 위에서 다시 시험 ===
  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 로 살아남았다(observed, 같은 파일). 중간의 sed: -e expression #1, char 8: unknown option to 's' 는 원래 실행의 스크립트가 낸 오류이고 측정과는 무관하다. 값에 / 가 들어간 문자열을 sed 's/.../.../' 에 그대로 넣으면 이렇게 된다. 증거 파일에서 그 오류를 지우지 않은 것은 그것이 「이 줄은 스크립트가 만든 것」이라는 표시이기 때문이다.

구성 파드 삭제 후
AOF , 볼륨 없음 전부 소실
AOF , 볼륨 없음 전부 소실 (설정은 켰는데)
AOF , PVC 생존

볼륨이 먼저고 설정이 나중이다. 순서를 바꾸면 두 번째 줄이 되고, 두 번째 줄은 첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서 더 나쁘다.

appendfsync 는 그래도 맞바꿈이다. 기본값은 appendfsync everysec 이다.

설정 잃는 양 비용
always 없음 쓰기마다 fsync — 느리다
everysec 최대 1초 기본값
no OS 에 맡김 가장 빠름

세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시 로그인해야 한다는 뜻이다. A-3 에서 본 PostgreSQL 의 synchronous_commit OFF 와 같은 모양의 맞바꿈이고, 거기서 Keycloak 이 같은 판단을 했다.

PVC 도 노드에 못박힌다.

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

local-path 는 노드의 디렉터리다. A-4 에서 본 것과 같다 — 노드가 죽으면 볼륨도 함께 접근 불가가 되고 파드는 다른 노드로 못 옮겨간다. 영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.

이 실험은 관측에 숙제를 남겼다. Grafana 에 이 실험의 그래프가 없는데, 안 찍은 것이 아니라 지표가 없다.

=== B층 구성 요소의 지표가 있는가 ===
  redis_up                         시계열 0개
  redis_connected_clients          시계열 0개
  pg_up                            시계열 0개
  pg_stat_database_numbackends     시계열 0개

Prometheus 가 긁는 대상에 Redis·PostgreSQL·BFF 가 애초에 없다(observed, 04-observability-gap.txt). 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 에서 이미 찾은 항목)

복구와 원상복구 확인표

관찰 절이 이미 둘을 되돌렸다 — replica 를 1로 올렸고, apply 로 볼륨을 다시 붙였다. 이제 실험이 심은 키를 지우고 일곱 항목을 대조한다.

1. 매니페스트를 다시 적용해 볼륨과 설정을 되돌린다

목적 — Deployment 를 저장소에 있는 모양으로 되돌린다.

① 관찰 절에서 이미 쳤더라도 한 번 더 친다. apply 는 같은 결과를 낸다.

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

예상 결과deployment "redis" successfully rolled out 이 나온다.

왜 필요한가 — patch 로 뗀 volumeMountsvolumes 가 여기서 돌아온다. CONFIG SET 으로 켠 appendonly 는 이미 재기동에서 매니페스트의 args 에 졌으므로 따로 되돌릴 것이 없다.

문제가 생기면 — PVC 가 Pending 이면 describe pvc redis-data 의 Events 를 본다.

2. 실험이 심은 키를 지운다

목적b5: 로 시작하는 키 셋만 치운다.

① 접두어로만 지운다. FLUSHALL 은 치지 않는다 — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.

kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan

예상 결과--scan 출력에 b5:* 가 없다.

왜 필요한가FLUSHALL 을 치면 B-4 와 B-7 이 쓰는 _oauth2_proxy- 키까지 날아가고, 그 실험들이 뒤에 가서 갑자기 깨진다.

문제가 생기면 — 지워지지 않으면 키 이름을 --scan 으로 먼저 눈으로 본다.

3. 일곱 항목을 대조한다

항목 명령 돌아왔을 때
Redis kubectl -n keycloak-lab get deploy redis 1/1
볼륨 … get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}' persistentVolumeClaim 이 보인다
PVC kubectl -n keycloak-lab get pvc redis-data Bound
영속화 … exec deploy/redis -- redis-cli config get appendonly yes
BFF 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 을 붙인다
000 을 서버 오류로 읽는다 000응답을 못 받았다는 curl 의 표기다 400/503 과 구별한다
AOF 를 켰는데 안 남는다 /data 가 볼륨이 아니다 결론 내리기 전에 spec.volumes 를 먼저
파드를 지웠는데 데이터가 살아남는다 볼륨 제거 패치가 안 먹었다 get pod … spec.volumes비어야 한다
config set 한 값이 재기동 후 사라진다 런타임 전용이다. 매니페스트 args 가 이긴다 재기동 후 config get appendonly
/actuator/health 응답 칸에 이상한 문자열 측정 실패다. 값이 아니다 (HTTP server) 그 칸을 다시 친다
파드가 NotReady 가 되기를 기다린다 안 된다. redis 지표가 readiness 그룹에 없다 health 그룹별 응답
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=bff
회복 후 로그인이 풀려 있다 정상이다. Redis 가 비었으니 세션이 없다 302 는 실패가 아니다
BFF 를 재시작해 버렸다 「스스로 회복하는가」를 못 재게 된다 주입부터 다시. 손대지 않고 기다린다
PVC 가 Pending local-path 프로비저너가 없거나 노드가 안 맞는다 describe pvc redis-data 의 Events
다른 실험이 갑자기 깨진다 FLUSHALL 을 쳤다. 같은 Redis 를 나눠 쓴다 접두어로만 지운다

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 14:2414:28 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) 주입 전 Redis 키 1 개 · PostgreSQL 토큰 1 행 · save 빈 값 · appendonly no, 정지 시각 14:26:30, 정지 후 세 경로의 200 · 000 · 503 과 빈 응답 본문, BFF 로그의 pollConnect · finishConnect 스택, 정지 중에도 bff 두 개가 1/1 · RESTARTS 0 인 것, health 그룹의 readiness 200{"status":"UP"}, 엔드포인트에 남은 10.42.0.52 · 10.42.1.124, 복구 후 HTTP 200 · HTTP 3020,0 회 재시작, appendonlydir 이 만들어진 ls -la /data 출력, 볼륨 없이 파드를 지운 뒤의 dbsize: 0 · appendonly no, PVC 위에서 지운 뒤의 dbsize: 1 · written-on-pvc, 후속 조사의 네 지표가 전부 시계열 0개인 것.
  • 측정 실패를 값으로 읽지 않는다(observed) — 03-health-groups.txt/actuator/health HTTP server 는 상태 코드가 아니고, 같은 파일의 /actuator/health 본문 칸도 비어 있다. 50302-redis-down.txt 쪽 측정에서 나온 값이다.
  • (observed) 04-persistence.txt 에 섞인 sed: -e expression #1, char 8: unknown option to 's' 는 원래 실행의 스크립트가 낸 오류이고 측정과 무관하다. 증거 파일에서 지우지 않았다.
  • (unknown) 볼륨을 떼는 patch deployment redis --type=json 줄. 원래 실행은 반대 순서로 했다 — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 curl 에 로그인 쿠키가 없어 /bff/token-boundary3xx 로 나오는 것도 가이드가 미검증으로 표시했다.
  • 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 000500 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. readiness 그룹에 redis 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.