Files
keycloak-pattern/docs/experiment-a2-database-loss.md
T
DongHyeonkaandClaude Opus 5 e0d27d47ce docs: correct the places where documents contradicted their own evidence
An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier.

Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:35:49 +09:00

12 KiB
Raw Blame History

A-2 — PostgreSQL 이 죽으면 어떻게 되는가

브랜치 feature/keycloak-a2-database-loss · 증거 docs/evidence/a2-database-loss/ · 2026-09-04 11:5611:58 KST · Keycloak 26.7.0

선행: A-0 · A-1


구조

A-2 구조 — DB 정지 시 살아남는 노드가 없다

다이어그램 규약은 diagrams/_style.md. 실험대 전체 구조는 diagrams/lab-topology.svg.


0. 결론부터

예측 결과
즉시 전면 장애 맞다. 외부 진입점 503, 양쪽 노드 NotReady
캐시에 있어도 못 쓴다 맞다. 캐시를 가진 노드도 500
up = 1 인 채로 전면 장애가 났다 (관측의 함정)
DB 복귀 15초 만에 재시작 없이 자동 회복

A-1 과 정반대다. A-1 은 한쪽만 빠지고 서비스가 계속됐지만, A-2 는 살아남는 노드가 없다.


1. 설계 — 네 경로를 구분해서 본다

A-1 에서 "룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다" 를 확인했다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 그 가설을 가른다.

# 경로 무엇을 보는가
캐시를 가진 노드에서 refresh 캐시가 DB 를 대신할 수 있는가
캐시가 없는 노드에서 refresh 완전한 DB 의존
새 로그인 쓰기 경로
이미 발급된 토큰으로 조회 서명만으로 되는 경로

access token 수명이 60초이므로, 토큰 발급 → DB 정지 → 시험을 그 안에 끝내야 한다.

계측 도구를 바꿨다

A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다. 여기서는 상주 탐침 파드를 하나 띄우고 exec 로 단계를 이어간다. 토큰을 파드 안 파일에 남겨 DB 정지 전후로 같은 토큰을 쓸 수 있다.

kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s

2. 기준선

keycloak-0   ready=true   10.42.1.67   kc-lab-2
keycloak-1   ready=true   10.42.0.35   kc-lab-1
postgres     ready=true   10.42.1.24   kc-lab-2

cluster_size keycloak-0 = 2
cluster_size keycloak-1 = 2

세션을 양쪽에 하나씩 만들고, A-0 대로 각자 자기 노드에만 캐시되는 것을 확인했다.

  keycloak-0 에서 로그인  sid=EAXV5HcG2J1BZ3vnwONf64AQ
  keycloak-1 에서 로그인  sid=McyTj5lj3n_JqApCXeuAHExc
  → 캐시  keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)

3. 주입

kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
정지 시각: 11:56:04
삭제 완료: 11:56:04     ← 즉시

4. 결과 — 네 경로

  ① 캐시를 가진 노드(keycloak-0)에서 refresh   HTTP 500
  ② 캐시가 없는 노드(keycloak-1)에서 refresh   HTTP 500
  ③ 새 로그인                                  HTTP 500
  ④ 관리 API (세션 조회 필요)                   HTTP 500   ← 5절의 재측정값

  --- 오류 본문 ---
  {"error":"unknown_error","error_description":"For more on this error consult the server log."}

④ 의 첫 측정은 오염됐다03-four-paths.txt 에는 HTTP 000000{"error":"HTTP 401 Unauthorized"}401 이 남아 있다. curl -w %{http_code} 출력에 본문이 섞인 것이고, 재시도가 000 을 세 번 찍은 뒤 401 이 왔다. 위 표의 500 은 5절에서 다시 잰 값이며, 첫 측정을 그대로 쓰지 않았다.

① 이 500 인 것이 중요하다

캐시에 세션을 들고 있어도 refresh 는 실패한다.

A-1 에서는 로그아웃된 세션을 캐시로 200 을 줬다. 왜 여기서는 안 되는가.

   refresh 처리
     ├── 세션이 존재하는가        →  캐시로 답할 수 있다
     └── LAST_SESSION_REFRESH 갱신 →  DB 쓰기가 필요하다   ← 여기서 죽는다

A-0 에서 잡은 SQL 그대로다.

update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...

캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다. refresh 는 이름과 달리 쓰기 연산이다.

로그가 말하는 원인

Caused by: java.net.ConnectException: Connection refused
  at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect
  at io.agroal.pool.ConnectionPool$CreateConnectionTask.call

agroal 은 Quarkus 의 커넥션 풀이다. 풀이 새 커넥션을 만들지 못한다.


5. 살아남은 것 — 상태가 필요 없는 경로

  JWKS 엔드포인트(realm 공개키)   HTTP 200
  realm 메타데이터(.well-known)   HTTP 200
  관리 API (세션 조회 필요)        HTTP 500

realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답한다.

이론적으로는 이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다는 뜻이다. 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 여기까지가 말할 수 있는 범위다 — B층에서 확인한다.

그런데 정문으로는 이것도 못 쓴다. 아래 6절 때문이다.


6. 전면 장애 — 살아남는 노드가 없다

=== 파드 Ready ===
keycloak-0   false   restarts=0
keycloak-1   false   restarts=0

=== Service 엔드포인트 ===
  ready   : []                              ← 비었다
  notReady: [10.42.0.35 10.42.1.67]

=== 외부 진입점 ===
  https://auth.hyeonworks.com/realms/master   HTTP 503
{ "status": "DOWN",
  "checks": [
    { "name": "Keycloak cluster health check",                    "status": "UP"   },
    { "name": "Keycloak database connections async health check", "status": "DOWN" },
    { "name": "Keycloak Initialized",                             "status": "UP"   } ] }

cluster health 는 UP 인데 database connections 가 DOWN 이라 전체가 DOWN 이다. 헬스체크는 모든 항목이 UP 이어야 UP 이다.

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 대수보다 우선한다.


7. 관측의 함정 — up = 1 인 채로 전면 장애

  up{pod=keycloak-1} = 1
  up{pod=keycloak-0} = 1     ← 서비스는 503 인데

up 은 움직이지 않았다

전 구간 평평하다. (11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.)

upPrometheus 가 /metrics 를 긁는 데 성공했는가만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다. 기능은 전멸했는데.

지표 이 장애에서
up 1 — 아무것도 알려주지 않는다
파드 Ready false — 여기서 드러난다
외부 HTTP 코드 503 — 사용자가 겪는 것

A-0 에서 나는 up 을 "가장 중요한 합성 지표"라고 썼다. 절반만 맞다. up대상이 사라진 것을 잡지만 대상이 살아서 못 쓰는 것은 못 잡는다. 후자가 운영에서 훨씬 흔하다.

알림은 up 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.

이 실험대에는 아직 kube-state-metrics 가 없어 파드 readiness 가 지표로 남지 않는다. 관측 스택에 빠진 것을 이 실험이 찾아냈다 — 보완 항목이다.


8. 복구 — 자동이었다

kubectl -n keycloak-lab scale deployment/postgres --replicas=1
  재기동 시각: 11:57:09
  +15초  keycloak-0 true  keycloak-1 true  | 외부 HTTP 200
  → 서비스 복귀

  재시작 횟수:  keycloak-0 = 0,  keycloak-1 = 0
  정지 전 세션: online 세션 5 건 살아남음
회복 시간 약 15초 (DB Ready 이후)
사람 개입 없음
Keycloak 재시작 불필요restarts=0
세션 살아남음 — DB 에 있으므로

커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에 복귀했다. readiness 를 쓴 설계의 이득이 여기서 나온다 — liveness 였다면 파드가 재시작되어 캐시까지 날아갔을 것이다.

개념 — readiness 와 liveness 를 가르는 기준

실패하면 언제 쓰나
liveness 재시작 재시작하면 나아지는 문제 (교착, 메모리 누수)
readiness 트래픽에서 격리 재시작해도 안 나아지는 문제 (의존 대상이 죽음)

DB 장애에 liveness 를 걸면 재앙이다. 모든 파드가 무한 재시작하고, DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다.



증거 파일

증거 수집 시각: 2026-09-04 11:53 11:56 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-baseline.txt 터미널 원문
02-setup-sessions.txt 터미널 원문
03-four-paths.txt 터미널 원문
04-health-and-service.txt 터미널 원문
05-recovery.txt 터미널 원문
a2-up-stayed-1-during-outage.png 스크린샷

파일별 상세는 evidence/a2-database-loss/README.md.

9. 재현 절차 (명령어)

# 0. 상주 탐침 (임시 파드는 계측에 부적합 — A-1 참조)
kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
  --restart=Never --command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s

# 1. 토큰 발급 (access 60초 안에 시험을 끝내야 한다)
kubectl -n keycloak-lab exec a2-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.json'

# 2. DB 정지
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s

# 3. 네 경로
kubectl -n keycloak-lab exec a2-probe -- curl -s -o /dev/null -w '%{http_code}\n' ...

# 4. 영향 범위
kubectl -n keycloak-lab get endpoints keycloak \
  -o jsonpath='{.subsets[*].addresses[*].ip}'          # 비어 있으면 전면 장애
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master

# 5. up 이 거짓말하는 것을 확인
curl -s "http://localhost:19090/api/v1/query?query=up%7Bjob=%22keycloak%22%7D"

# 6. 복구
kubectl -n keycloak-lab scale deployment/postgres --replicas=1

10. 다음 실험에 남기는 것

실험 이 실험이 준 것
A-3 DB 강제 종료 정상 정지는 데이터를 안 잃었다. 강제 종료는? (synchronous_commit OFF)
A-4 노드 상실 postgres 가 kc-lab-2 에 있으므로 그 노드를 죽이면 A-2 가 함께 일어난다
D-1 백업·복구 여기서는 DB 가 되살아났다. 데이터가 사라졌다면?
관측 스택 kube-state-metrics 가 없어 파드 readiness 가 지표로 안 남는다 — 보완 필요