# A-2 — PostgreSQL 이 죽으면 어떻게 되는가 브랜치 `feature/keycloak-a2-database-loss` · 증거 [`docs/evidence/a2-database-loss/`](evidence/a2-database-loss/) · 2026-09-04 11:56–11:58 KST · Keycloak 26.7.0 선행: [`A-0`](experiment-00-session-replication.md) · [`A-1`](experiment-a1-jgroups-transport-block.md) --- ## 구조 ![A-2 구조 — DB 정지 시 살아남는 노드가 없다](diagrams/a2-database-loss.svg) > 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md). > 실험대 전체 구조는 [`diagrams/lab-topology.svg`](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 정지 전후로 같은 토큰**을 쓸 수 있다. ```bash 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. 주입 ```bash 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 --- 오류 본문 --- {"error":"unknown_error","error_description":"For more on this error consult the server log."} ``` ### ① 이 500 인 것이 중요하다 **캐시에 세션을 들고 있어도 refresh 는 실패한다.** A-1 에서는 로그아웃된 세션을 캐시로 `200` 을 줬다. 왜 여기서는 안 되는가. ``` refresh 처리 ├── 세션이 존재하는가 → 캐시로 답할 수 있다 └── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다 ``` A-0 에서 잡은 SQL 그대로다. ```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 ``` ```json { "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 은 움직이지 않았다](evidence/a2-database-loss/a2-up-stayed-1-during-outage.png) **전 구간 평평하다.** (11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.) `up` 은 **Prometheus 가 `/metrics` 를 긁는 데 성공했는가**만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다. **기능은 전멸했는데.** | 지표 | 이 장애에서 | |---|---| | `up` | **1 — 아무것도 알려주지 않는다** | | 파드 `Ready` | **false — 여기서 드러난다** | | 외부 HTTP 코드 | **503 — 사용자가 겪는 것** | > **A-0 에서 나는 `up` 을 "가장 중요한 합성 지표"라고 썼다.** > 절반만 맞다. `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은 > 못 잡는다. 후자가 운영에서 훨씬 흔하다. > > **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.** 이 실험대에는 아직 `kube-state-metrics` 가 없어 파드 readiness 가 지표로 남지 않는다. **관측 스택에 빠진 것을 이 실험이 찾아냈다** — 보완 항목이다. --- ## 8. 복구 — 자동이었다 ```bash 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 의 백오프 때문에 회복이 늦어진다. --- ## 9. 재현 절차 (명령어) ```bash # 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://:8080/realms/master/protocol/openid-connect/token \ -d grant_type=password -d client_id=admin-cli \ -d username=admin -d password= > /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 가 지표로 안 남는다** — 보완 필요 |