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>
12 KiB
A-2 — PostgreSQL 이 죽으면 어떻게 되는가
브랜치 feature/keycloak-a2-database-loss ·
증거 docs/evidence/a2-database-loss/ ·
2026-09-04 11:56–11:58 KST · Keycloak 26.7.0
구조
다이어그램 규약은
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 인데
전 구간 평평하다. (11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.)
up 은 Prometheus 가 /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 가 지표로 안 남는다 — 보완 필요 |
