# A-2 재현 가이드 — PostgreSQL 을 내리고 살아남는 노드가 있는지 직접 본다 해설 문서: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md) · 증거 원문: [`docs/evidence/a2-database-loss/`](../../evidence/a2-database-loss/) ## 이 가이드가 끝나면 당신 터미널에서 이것들을 **직접 본다.** | 보게 되는 것 | 어디서 | |---|---| | 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것 | 상주 탐침 파드 | | JWKS 와 `.well-known` 만 `200` 으로 살아 있는 것 | 같은 파드 | | Ready 파드가 **0개**, `ready` 주소가 **빈 목록**인 것 | `endpointslice` | | 정문이 `503` 을 주는 것 | 밖에서 `curl` | | `database connections` 만 DOWN 인 헬스 본문 | `health/ready` | | **`up = 1` 인 채로 전면 장애가 나 있는 것** | Prometheus | | 15초 만에 **재시작 0회**로 스스로 돌아오는 것 | `get pods` | ## 전제 - [`A-0`](a0-session-replication.md) 을 먼저 한다. 「세션은 DB 가 공유한다」를 손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다. - 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다. - 네임스페이스는 `keycloak-lab`, Prometheus 는 `observability` 다. - 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용. - `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다. ## 주의 — 이건 전면 장애를 만드는 실험이다 **정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다.** 이 실험대를 쓰는 다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는 명령은 하나뿐이고 [5. 복구](#5-복구) 에 있다. 중간에 그만두려면 그것만 치면 된다. ## 표시 규약 | 표시 | 뜻 | |---|---| | **실측** | 2026-09-04 11:53–11:58 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 | | **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 | | **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 | IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의 실제 값이다. --- # 0. 왜 이 실험을 하는가 [A-1](a1-jgroups-transport-block.md) 에서 **「룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 `200` 을 줬다. **그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다.** 그 가설을 가른다. | | 예측 | |---|---| | 캐시가 DB 를 대신한다면 | 캐시를 가진 노드는 **살아남는다** — 부분 장애 | | 대신하지 못한다면 | **전면 장애** | 그리고 A-1 과의 대비가 이 실험의 진짜 값이다. ``` A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하) A-2 DB 정지 → ? (여기서 판정) ``` **네 경로를 구분해서 본다.** 하나만 재면 무엇 때문에 죽었는지 모른다. | # | 경로 | 무엇을 보는가 | |---|---|---| | ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 | | ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 | | ③ | 새 로그인 | 쓰기 경로 | | ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 | --- # 1. 기준선 — DB 를 내리기 전에 **시험군만 재는 측정은 측정이 아니다.** 정지 후에 볼 것을 정지 전에 **똑같은 명령으로** 먼저 봐 둔다. 넓은 것부터 좁혀 간다. ``` 파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험 ``` ## 1-1. 파드와 노드 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o wide ``` **실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt) ``` keycloak-0 true 10.42.1.67 kc-lab-2 keycloak-1 true 10.42.0.35 kc-lab-1 postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2 ``` **어디를 봐야 하는가** - `READY` 가 셋 다 `1/1`, `RESTARTS` 가 `0` - **`postgres` 가 어느 노드에 있는가.** 원래 실행에서는 `kc-lab-2`, 즉 `keycloak-0` 과 **같은 노드**다 - IP 세 개를 적어 둔다 **이 결과가 의미하는 것** — `postgres` 와 `keycloak-0` 이 같은 노드에 있다는 사실은 이 실험에서는 상관없지만, **A-4(노드 상실)에서는 결정적이다.** 그 노드를 죽이면 A-2 가 함께 일어난다. IP 는 변수로 잡아 둔다. ```bash K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') echo "$K0 $K1" ``` **실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt) ``` keycloak-0=10.42.1.67 keycloak-1=10.42.0.35 ``` ## 1-2. 클러스터가 정상인가 **확인** ```bash sudo kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` 한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이 붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자른다. **미검증** ```bash sudo kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \ | tr ',' '\n' | grep -E '"pod":|^"[0-9]' ``` **실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt) ``` cluster_size keycloak-1 = 2 cluster_size keycloak-0 = 2 ``` **어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`. **이 결과가 의미하는 것** — A-1 의 분단이 완전히 회복된 상태에서 시작한다. 여기가 `1` 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다. ## 1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다 **A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다.** `--rm` 파드는 매번 만들고 지우므로 느리고 경합이 있고, **토큰을 단계 사이로 넘길 수 없다.** 이 실험은 **DB 정지 전에 발급한 토큰을 정지 후에 써야** 한다. 그래서 파드를 하나 띄워 두고 `exec` 로 단계를 이어간다. **하기** ```bash sudo kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \ --restart=Never \ --env="K0=$K0" --env="K1=$K1" \ --env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sleep 7200 sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s ``` **실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt) ``` pod/a2-probe condition met ``` **되돌리기** ```bash sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found ``` > **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도 > 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면: > ```bash > sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ > -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c > ``` > **실측** — `19` **확인** — 환경변수가 들어갔나 ```bash sudo kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"' ``` **형태** ``` K0=10.42.1.67 K1=10.42.0.35 PW길이=19 ``` `PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다. **이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다. ```bash sudo kubectl -n keycloak-lab exec -it a2-probe -- sh ``` 프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다** (`--rm` 이 없다). ## 1-4. 양쪽 노드에 세션을 하나씩 만든다 **이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야 한다.** A-0 에서 확인한 성질을 그대로 쓴다 — **각 노드는 자기가 로그인시킨 세션만 캐시한다.** **하기** — 파드 안 셸에서 ```sh TOK=/realms/master/protocol/openid-connect/token for H in "$K0" "$K1"; do echo -n "$H : " curl -s -X POST "http://$H:8080$TOK" \ -d grant_type=password -d client_id=admin-cli \ -d username=admin -d "password=$PW" \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \ | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \ | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' done ``` **실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt) ``` === [준비] 양쪽 노드에 세션을 하나씩 만든다 === keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613 keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613 ``` **어디를 봐야 하는가** — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다. ## 1-5. 세션이 각자 노드에만 캐시되었는가 **확인** — 밖에서. Keycloak 이미지에는 `curl` 이 없으므로 Prometheus 에 묻는다 ```bash sudo kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \ | tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]' ``` **실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt) ``` === [확인] 세션이 각자 노드에만 캐시되었는가 === keycloak-1 = 0 건 keycloak-0 = 1 건 ``` **어디를 봐야 하는가** — `cache` 가 `sessions` 인 두 줄. 값이 서로 다르다. **이 결과가 의미하는 것** — **`keycloak-0` 은 캐시를 가졌고 `keycloak-1` 은 없다.** 이제 ① 과 ② 를 구분해서 물을 수 있다. > **`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다.** 방금 로그인했으므로 > 다음 15초 스크레이프에서 `1` 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀 > 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」. > **중요한 것은 「양쪽이 다르다」가 아니라 「`keycloak-0` 이 확실히 가지고 > 있다」다.** ① 의 해석에 필요한 것은 그것뿐이다. **확인** — DB 에는 몇 건인가 ```bash sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select offline_flag, count(*) from offline_user_session group by offline_flag" ``` **실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt) ``` === DB 온라인 세션 === 2 ``` **이 숫자를 적어 둔다.** 복구 후에 세션이 살아남았는지 볼 대조군이다. ## 1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다 **이 절을 건너뛰면 뒤의 `500` 이 아무 의미가 없다.** ### ④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다 **DB 가 죽은 뒤에는 이 조회 자체가 실패한다.** 미리 잡아 놔야 ④ 를 측정할 수 있다. **확인** — 파드 안에서. 응답을 한 번 그대로 본다 ```sh AT=$(curl -s -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli \ -d username=admin -d "password=$PW" \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') curl -s -H "Authorization: Bearer $AT" \ "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" ``` **형태** — 객체 하나짜리 배열. `"id"` 가 맨 앞에 있다 ```json [{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}] ``` 무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증** ```sh CID=$(curl -s -H "Authorization: Bearer $AT" \ "http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \ | tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4) echo "CID=$CID" ``` > `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수 > 있다.** `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고른다. ### 네 경로를 정상 상태에서 한 번 돌린다 **하기** — 파드 안에서 ```sh R0=$(curl -s -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW") RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') R1=$(curl -s -X POST "http://$K1:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW") RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p') curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \ -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0" curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \ -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1" curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \ "http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100" ``` **형태** — 정상 상태에서는 ``` ① 200 ② 200 ③ 200 ④ 200 ``` **★ `-o /dev/null` 을 빼면 안 된다.** 빼면 본문과 상태코드가 한 줄에 섞여 나온다. 원래 실행이 정확히 이걸 당했다 — [4-1](#4-1-네-경로) 을 본다. **이 결과가 의미하는 것** — 네 경로가 전부 `200` 인 것이 기준선이다. 정지 후에 `500` 이면 「내가 깨뜨린 것」이고, 대조군 없이는 이 구별이 안 된다. ### ⑤ 상태가 필요 없는 경로도 미리 재 둔다 **하기** ```sh curl -s -o /dev/null -w 'JWKS %{http_code}\n' \ "http://$K0:8080/realms/master/protocol/openid-connect/certs" curl -s -o /dev/null -w 'well-known %{http_code}\n' \ "http://$K0:8080/realms/master/.well-known/openid-configuration" ``` **형태** ``` JWKS 200 well-known 200 ``` **확인** — 밖에서 정문도 재 둔다 ```bash curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` **형태** ``` 200 ``` `exit` 으로 파드 셸에서 나온다. **파드는 그대로 둔다.** --- # 2. 주입 — PostgreSQL 을 0대로 내린다 여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.** **되돌리기** ```bash sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1 ``` ## 2-1. 왜 `scale --replicas=0` 인가 | 방법 | 무엇이 일어나나 | |---|---| | **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** | | `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 | | 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** | **`delete pod` 를 쓰면 이 실험이 성립하지 않는다.** DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 네 경로를 다 재고 헬스와 엔드포인트까지 볼 수 있다. > 이것은 **정상 종료**다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤 > 내려간다. **데이터는 하나도 잃지 않는다.** 강제로 죽였을 때 무엇을 잃는지는 > [A-3](a3-database-crash.md) 이 잰다. ## 2-2. 적용 **하기** ```bash date '+%H:%M:%S 정지' sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0 sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s date '+%H:%M:%S 삭제완료' ``` **실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) ``` === [2] PostgreSQL 정지 === 정지 시각: 11:56:04 deployment.apps/postgres scaled pod/postgres-7b474b88c8-sn9ff condition met 삭제 완료: 11:56:04 ``` **어디를 봐야 하는가** — **두 시각이 같다.** 즉시 사라진다. **시각을 반드시 적어 둔다.** 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면 인과를 못 붙인다. > **access token 수명이 60초다.** 1-6 에서 발급한 `AT` 로 ④ 를 재려면 > **발급 → 정지 → 시험을 60초 안에** 끝내야 한다. 60초를 넘기면 ④ 의 `401` > 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면 > 4-1 전에 토큰을 다시 받아 둔다 — 단, **그건 DB 가 있어야 되는 일**이므로 > 순서는 「토큰 발급 → 정지」다. --- # 3. 주입이 실제로 걸렸는지 확인한다 **결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.** ## 3-1. postgres 파드가 정말 없나 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o wide ``` **어디를 봐야 하는가** — `postgres` 로 시작하는 줄이 **한 개도 없다.** `Terminating` 으로 남아 있으면 아직 안 끝난 것이다. `wait` 가 통과했으면 없다. **확인** — Deployment 쪽도 본다 ```bash sudo kubectl -n keycloak-lab get deploy postgres ``` **형태** ``` NAME READY UP-TO-DATE AVAILABLE AGE postgres 0/0 0 0 5d ``` `0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다. ## 3-2. Keycloak 이 실제로 DB 에 못 붙고 있나 **확인** — 로그가 원인을 말한다 ```bash sudo kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection' ``` **실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664) at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645) Caused by: java.net.ConnectException: Connection refused at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219) at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365) ``` **어디를 봐야 하는가** — `Connection refused` 와 `agroal`. **이 결과가 의미하는 것** — `agroal` 은 Quarkus 의 커넥션 풀이다. **풀이 새 커넥션을 만들지 못한다.** 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고 있거나, 애초에 DB 가 안 죽은 것이다. > `Connection refused` 이지 `timed out` 이 아니다. Service 는 남아 있지만 뒤에 > 파드가 없어 **연결이 즉시 거부**된다. 네트워크를 막았다면 timeout 이 나왔을 > 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다. ## 3-3. 엉뚱한 것을 죽이지 않았나 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o custom-columns=\ NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \ | grep keycloak ``` **실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` keycloak-0 false 0 keycloak-1 false 0 ``` **어디를 봐야 하는가** — `READY` 가 `false` 인데 **`RESTARTS` 가 여전히 `0`.** **이 결과가 의미하는 것** — 파드는 **죽지 않았다.** 트래픽에서 빠졌을 뿐이다. `RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다. **이 `restarts=0` 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.** --- # 4. 효과를 관찰한다 ## 4-1. 네 경로 **하기** — 탐침 파드 안에서. 1-6 과 **똑같은 명령**을 다시 친다 ```bash sudo kubectl -n keycloak-lab exec -it a2-probe -- sh ``` ```sh TOK=/realms/master/protocol/openid-connect/token curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \ -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0" curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \ -d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1" curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \ "http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100" ``` > 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라진다. **셸을 > 붙잡고 있는 편이 낫다.** 그래서 터미널 두 개를 열라고 한 것이다. **실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) · ④ 는 [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` ① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500 ② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500 ③ 새 로그인 HTTP 500 ④ 관리 API (세션 조회 필요) HTTP 500 ``` **하기** — 본문도 한 번 그대로 본다 ```sh curl -s -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" ``` **실측** ``` {"error":"unknown_error","error_description":"For more on this error consult the server log."} ``` **어디를 봐야 하는가** — 네 줄 전부 `500`. 그리고 본문이 **아무것도 말해 주지 않는다.** 원인은 3-2 의 서버 로그에만 있다. ### ★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다 원래 실행의 증거 파일에는 이렇게 남아 있다. **실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) ``` ④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401 ``` **읽어 보면 세 가지가 한 줄에 뭉쳐 있다.** ``` HTTP 000000{"error":"HTTP 401 Unauthorized"}401 ─┬──── ──────────┬─────────────────── ─┬─ │ │ └─ 마지막 시도의 상태코드 │ └─ 응답 본문이 그대로 섞였다 └─ 재시도가 세 번 "000" 을 찍었다 (연결 실패) ``` `curl -w '%{http_code}'` 를 쓰면서 **`-o /dev/null` 을 빼면** 본문이 표준출력으로 같이 나온다. 여기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓였다. > **위 표의 ④ `500` 은 5절에서 다시 잰 값이다.** 첫 측정은 그대로 쓰지 않았다. > 오염된 측정은 **버리고 다시 잰다.** 「`401` 인가 `500` 인가」를 추측으로 > 메우면 안 된다. **당신은 1-6 부터 `-o /dev/null` 을 쓰고 있으므로 이 함정을 지난다.** ## 4-2. ① 이 `500` 인 것이 이 실험의 핵심이다 **캐시에 세션을 들고 있어도 refresh 는 실패한다.** A-1 에서는 로그아웃되어 DB 행이 사라진 세션에 대해 캐시를 가진 노드가 `200` 을 줬다. **왜 여기서는 안 되는가.** ``` refresh 처리 ├── 세션이 존재하는가 → 캐시로 답할 수 있다 └── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다 ``` [A-0](a0-session-replication.md) 에서 잡은 SQL 그대로다. ```sql update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ... ``` > **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.** > **refresh 는 이름과 달리 쓰기 연산이다.** | | A-1 (7800 차단) | **A-2 (DB 정지)** | |---|---|---| | DB | 살아 있다 | **없다** | | 캐시가 답할 수 있는 부분 | 세션 존재 확인 → `200` | 세션 존재 확인 → 거기까지 | | DB 가 필요한 부분 | `UPDATE` 는 성공 | **`UPDATE` 실패 → `500`** | ## 4-3. 살아남은 것 — 상태가 필요 없는 경로 **하기** — 파드 안에서, 1-6 의 ⑤ 를 그대로 ```sh curl -s -o /dev/null -w 'JWKS %{http_code}\n' \ "http://$K0:8080/realms/master/protocol/openid-connect/certs" curl -s -o /dev/null -w 'well-known %{http_code}\n' \ "http://$K0:8080/realms/master/.well-known/openid-configuration" ``` **실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` === ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 === JWKS 엔드포인트(realm 공개키) HTTP 200 realm 메타데이터(.well-known) HTTP 200 관리 API(세션 조회 필요) HTTP 500 ``` **어디를 봐야 하는가** — 같은 파드, 같은 포트인데 **경로에 따라 `200` 과 `500` 이 갈린다.** **이 결과가 의미하는 것** — **realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답한다.** 이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다**는 뜻이다. > 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가 말할 수 > 있는 범위**다. B층에서 확인한다. > > **그리고 정문으로는 이것도 못 쓴다.** 다음 절 때문이다. ## 4-4. 전면 장애 — 살아남는 노드가 없다 **확인** — Service 가 어느 파드를 잡고 있나 ```bash sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready ``` **실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` === Service 엔드포인트 === ready : [] ← 비었다 notReady: [10.42.0.35 10.42.1.67] ``` **어디를 봐야 하는가** — **`ready` 가 빈 목록.** 두 IP 가 전부 `notReady` 다. > **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 > 뜬다. 원래 실행 기록에도 그 경고가 두 줄 남아 있다. > > **실측** > ``` > Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice > Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice > ``` > 사람이 눈으로 볼 때는 이쪽이 더 짧다. > ```bash > sudo kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints > ``` **확인** — 밖에서 ```bash curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` **실측** ``` === 외부 진입점 === https://auth.hyeonworks.com/realms/master HTTP 503 ``` 한 번 눈으로 볼 때는 헤더까지 본다. ```bash curl -I https://auth.hyeonworks.com/realms/master ``` **이 결과가 의미하는 것** — **`503` 은 Keycloak 이 준 것이 아니다.** Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이다. 4-3 에서 `200` 이던 JWKS 도 정문으로는 닿지 않는다 — **readiness 게이트가 문을 닫았다.** ### 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 대수보다 우선한다.** ## 4-5. 헬스 본문이 이유를 말한다 **확인** — Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다 ```bash sudo kubectl -n keycloak-lab exec a2-probe -- \ curl -s "http://$K0:9000/health/ready" ``` **형태** — 한 줄 JSON 이 나온다. 한 번은 그대로 본다. **실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt) ``` === health/ready 상세 === 전체: DOWN Graceful Shutdown UP Keycloak cluster health check UP Keycloak database connections async health check DOWN Keycloak Initialized UP ``` **어디를 봐야 하는가** — **네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다.** **이 결과가 의미하는 것** — **헬스체크는 모든 항목이 UP 이어야 UP 이다.** 그리고 **`cluster health` 는 UP** 이다 — 클러스터는 멀쩡하다. A-1 에서는 정확히 반대였다(cluster DOWN, database UP). **같은 `503` 이라도 어느 체크가 DOWN 인지가 장애를 구별한다.** **확인** — `describe` 로도 같은 것이 보인다 ```bash sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions ``` ## 4-6. 관측의 함정 — `up = 1` 인 채로 전면 장애 **확인** ```bash sudo kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=up' \ | tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]' ``` **실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt) ``` === ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) === up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다 up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다 ``` **어디를 봐야 하는가** — 둘 다 `1`. **서비스는 `503` 인데.** Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 **전 구간 평평하다.** 원래 실행의 그림이 [`a2-up-stayed-1-during-outage.png`](../../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 와 외부 응답 코드에 걸어야 한다.** **확인** — 그럼 readiness 를 지표로 볼 수 있나 ```bash sudo kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \ | head -c 300; echo ``` **형태** — 결과가 비어 있다 ```json {"status":"success","data":{"resultType":"vector","result":[]}} ``` **이 결과가 의미하는 것** — 이 실험대에는 아직 `kube-state-metrics` 가 없어 **파드 readiness 가 지표로 남지 않는다.** 즉 지금 이 장애는 **Prometheus 만 보고 있으면 알 수 없다.** **관측 스택에 빠진 것을 이 실험이 찾아냈다.** --- # 5. 복구 ## 5-1. 되돌린다 **하기** ```bash date '+%H:%M:%S 재기동' sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1 sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s ``` **실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt) ``` === 복구 — PostgreSQL 재기동 === 재기동 시각: 11:57:09 deployment.apps/postgres scaled Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated... Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available... deployment "postgres" successfully rolled out ``` ## 5-2. Keycloak 이 스스로 회복하는가 — 손대지 않고 본다 **★ 여기서 Keycloak 을 재시작하고 싶어진다. 참는다.** 재시작하면 이 실험이 답하려던 질문(「사람 개입이 필요한가」)이 사라진다. **확인** — 15초 간격으로 몇 번 친다 ```bash sudo kubectl -n keycloak-lab get pods -o custom-columns=\ NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \ | grep keycloak curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` **실측** ``` === Keycloak 이 스스로 회복하는가 (재시작 없이) === +15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200 → 서비스 복귀 ``` **어디를 봐야 하는가** — `READY` 가 둘 다 `true`, 정문이 `200`. ## 5-3. 재시작 없이 회복한 것이 맞나 **확인** ```bash sudo kubectl -n keycloak-lab get pods -o custom-columns=\ NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak ``` **실측** ``` === 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 === keycloak-0 0 keycloak-1 0 ``` **어디를 봐야 하는가** — **`0`.** 3-3 에서 본 값 그대로다. **이 결과가 의미하는 것** — **커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에 복귀했다.** 사람이 한 일은 DB 를 켠 것뿐이다. | | | |---|---| | 회복 시간 | **약 15초** (DB Ready 이후) | | 사람 개입 | **없음** | | Keycloak 재시작 | **불필요** — `restarts=0` | ### 개념 — readiness 와 liveness 를 가르는 기준 | | 실패하면 | 언제 쓰나 | |---|---|---| | **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) | | **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) | **DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고, DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다. 게다가 재시작하면 **캐시까지 날아간다.** > A-1 에서도 같은 결론이 나왔다. 분단된 노드가 **readiness 로** 빠졌기 때문에 > 재시작 없이 격리만 되었다. **Keycloak 은 두 종류의 장애를 다 readiness 로 > 신고한다.** ## 5-4. 세션이 살아남았나 **확인** ```bash sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select offline_flag, count(*) from offline_user_session group by offline_flag" ``` **실측** ``` === 정지 전 세션이 살아남았는가 === online 세션 5 ``` **어디를 봐야 하는가** — 1-5 에서 적어 둔 값보다 크거나 같다. 실험 중에 로그인을 여러 번 했으므로 늘어나 있다. **이 결과가 의미하는 것** — **세션은 DB 에 있으므로 DB 가 돌아오면 같이 돌아온다.** 정상 종료였기 때문에 하나도 잃지 않았다. > **강제로 죽였다면 어떨까.** `SET LOCAL synchronous_commit TO OFF` 때문에 > 마지막 수백 밀리초의 쓰기가 사라져야 한다. **[A-3](a3-database-crash.md) 이 > 그 숫자를 잰다.** ## 5-5. 원상복구 확인표 | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | DB | `sudo kubectl -n keycloak-lab get deploy postgres` | `1/1` | | 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` | | Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** | | 헬스 | `exec a2-probe -- curl -s "http://$K0:9000/health/ready"` | 전체 `UP` | | 클러스터 | `vendor_cluster_size` | 양쪽 `2` | | 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a2-probe` | 지웠으면 `NotFound` | | 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | **하기** — 탐침 파드를 지운다. `--rm` 이 없으므로 **직접 지워야 한다** ```bash sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found ``` `sleep 7200` 이 끝나면 파드는 `Completed` 로 남는다. **자동으로 사라지지 않는다.** 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 원인이 이것이다. --- # 막히면 전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다. | 증상 | 원인 | 확인 | |---|---|---| | 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 4-1 의 ★ 절. 오염된 측정은 버리고 다시 잰다 | | `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 | | DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` — 2-1 | | ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 — 2-2 | | `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 | | 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 — 4-1 | | `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` — 4-4 | | `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 — 4-6 | | `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 — 4-6 | | 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** — 5-2 | | `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` — 5-5 | | 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 | --- # 왜 이 가이드는 임시 파드를 안 쓰나 A-1 은 관찰을 `kubectl run --rm` 임시 파드로 했고, **그 계측이 실패했다.** 매번 파드를 만들고 지우므로 느리고, 경합이 있고, 빈 출력이 섞였다. 이 실험은 거기에 더해 **토큰을 단계 사이로 넘겨야 한다.** 임시 파드로는 불가능 하다 — 파드가 사라지면 변수도 사라진다. ``` 임시 파드 단계마다 새로 뜬다 → 토큰이 안 넘어간다 · 느리다 · 빈 출력 상주 파드 한 번 띄워 둔다 → exec 로 이어간다 · 파일에 남길 수 있다 ``` **대신 지우는 것을 잊으면 안 된다.** `--rm` 이 없다는 것은 그런 뜻이다. > **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가 > 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다. --- # 다음 | 실험 | A-2 가 남긴 질문 | |---|---| | [A-3](a3-database-crash.md) DB 강제 종료 | **정상 정지는 하나도 안 잃었다. 강제 종료는?** `synchronous_commit OFF` 의 대가 | | A-4 노드 상실 | `postgres` 가 `kc-lab-2` 에 있다 — **그 노드를 죽이면 A-2 가 함께 일어난다** | | D-1 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** | | 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 | | 전부 | **알림을 `up` 에 걸지 않는다.** readiness 와 외부 응답 코드에 건다 |