--- id: bf169fef-f900-4247-88f8-427742ae3fe9 kind: SETUP slug: reproduce-a2-database-loss title: PostgreSQL 을 정상 종료시키고 네 경로를 잰다 topic: losing-a-node-or-the-store topicName: PostgreSQL 을 내리고 노드 전원을 뽑았을 때 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/bf169fef-f900-4247-88f8-427742ae3fe9/edit" pinnedVersions: - name: Keycloak version: 26.7.0 - name: curlimages/curl version: 8.11.1 source: - final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-2 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # PostgreSQL 을 정상 종료시키고 네 경로를 잰다 PostgreSQL 을 정상 종료시키고 refresh 와 새 로그인과 관리 API 조회가 각각 어떻게 되는지 주입 전후에 같은 명령으로 재는 절차다. 정문이 실제로 `503` 이 되므로 정지 구간을 1분 남짓으로 짧게 잡고, 복구는 데이터베이스를 다시 켜는 것 하나다. ## 관계 - **200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다** 정상 종료와 강제 종료의 차이를 건수로 보여 주는 기록이다. 이 절차는 그 대조군인 정상 종료 쪽을 만든다. - **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다** 정문이 `503` 인 동안 `up` 이 양쪽 다 1 이었던 것을 다룬다. - **readiness 가 깨진 노드를 시야에서 먼저 치운다** Ready 파드가 0개가 되고 `ready` 주소가 빈 목록이 되는 경로를 다룬다. - **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다** 먼저 해 둬야 하는 편이다. 세션을 데이터베이스가 공유한다는 것을 손으로 확인해 두지 않으면 여기서 나오는 `500` 을 해석할 수 없다. - **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다** 같은 데이터베이스를 SIGKILL 로 죽이는 편이다. 정상 종료가 아무것도 잃지 않는다는 이 편의 결과가 그쪽의 대조군이 된다. ## 본문 ## 읽기 전에 — 어디서 치는가 명령은 전부 `[lab host]` 에서 `kubectl` 과 `psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. `kubectl` 에 `sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. 터미널은 둘을 연다. 하나는 탐침 파드 셸용이라 붙잡혀 있고, 하나는 관찰용이다. 그래서 `[lab host]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` | | 주입 수단 | `scale deployment/postgres --replicas=0` — 정상 종료다 | | 탐침 파드 | `a2-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` | | 재는 경로 | 넷 — 캐시 있는 노드 refresh · 없는 노드 refresh · 새 로그인 · 관리 API | | 시간 제약 | access token 수명 60초. 토큰 발급 · 정지 · 시험을 그 안에 끝낸다 | | 정지 구간 | 1분 남짓. 그동안 정문이 실제로 `503` 이 된다 | | 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 | **공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. ## 이 실험이 가르는 것 A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 `200` 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다. A-1 과의 대비가 이 실험의 값이다. ```text A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하) A-2 DB 정지 → ? (여기서 판정) ``` 네 경로를 구분해서 본다. 하나만 재면 무엇 때문에 죽었는지 모른다. | # | 경로 | 무엇을 보는가 | |---|---|---| | ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 | | ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 | | ③ | 새 로그인 | 쓰기 경로 | | ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 | 절차를 끝까지 밟으면 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것, JWKS 와 `.well-known` 만 `200` 으로 살아 있는 것, Ready 파드가 0개이고 `ready` 주소가 빈 목록인 것, 정문이 `503` 을 주는 것, 헬스 네 항목 중 `database connections` 만 DOWN 인 본문, `up = 1` 인 채로 전면 장애가 나 있는 것, 15초 만에 재시작 0회로 스스로 돌아오는 것을 자기 화면에서 보게 된다. ## 전제와 되돌리기 - **A-0 을 먼저 한다.** 세션은 DB 가 공유한다는 것을 손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다. - A-1 의 분단이 풀려 있어야 한다. `vendor_cluster_size` 가 양쪽 `2` 가 아니면 두 실험이 섞인다. **이건 전면 장애를 만드는 실험이다.** 정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다. 이 실험대를 쓰는 다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는 명령은 아래 한 줄이다. ```bash label="[lab host] 중간에 그만둘 때 치는 한 줄" kubectl -n keycloak-lab scale deployment/postgres --replicas=1 ``` ## 주입 전에 같은 명령으로 먼저 본다 ```text 파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험 ``` ### 1. postgres 가 어느 노드에 있는가 **무엇을 보는가** — 세 파드의 상태와 배치. ```bash label="[lab host] 파드 배치를 본다" kubectl -n keycloak-lab get pods -o wide ``` **어디를 보나** — 실측은 이렇다(observed, `01-baseline.txt`). ```text 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 ``` **이 값이 뜻하는 것** — 원래 실행에서 `postgres` 는 `kc-lab-2`, 즉 `keycloak-0` 과 같은 노드에 있었다. 이 실험에서는 상관없지만 A-4(노드 상실)에서는 결정적이다 — 그 노드를 죽이면 A-2 가 함께 일어난다. ### 2. 파드 주소 두 개를 변수에 담는다 **무엇을 보는가** — 뒤의 모든 요청이 향할 주소. ```bash label="[lab host] 파드 주소를 변수에 담는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') echo "$K0 $K1" ``` **어디를 보나** — 실측은 `keycloak-0=10.42.1.67 keycloak-1=10.42.0.35` 다(observed). ### 3. 클러스터가 정상인지 먼저 확인한다 **무엇을 보는가** — 두 노드가 아는 멤버 수. ```bash label="[lab host] ① 한 줄짜리 JSON 을 통째로 본다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` 라벨을 보고 나서 자른다. 아래 줄은 가이드가 미검증으로 표시했다(unknown). ```bash label="[lab host] ② 필요한 줄만 자른다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \ | tr ',' '\n' | grep -E '"pod":|^"[0-9]' ``` **어디를 보나** — 실측은 `cluster_size keycloak-1 = 2`, `cluster_size keycloak-0 = 2` 다(observed). **이 값이 뜻하는 것** — 여기가 `1` 이면 A-1 의 분단이 안 풀린 것이고, 그 위에서 재면 두 실험이 섞인다. ### 4. 상주 탐침 파드를 띄운다 **무엇을 보는가** — 계측 도구를 A-1 에서 바꾸는 까닭. `--rm` 임시 파드는 매번 만들고 지우므로 느리고 경합이 있고, 토큰을 단계 사이로 넘길 수 없다. 이 실험은 DB 정지 전에 발급한 토큰을 정지 후에 써야 하므로 파드를 하나 띄워 두고 `exec` 로 단계를 이어간다. ```bash label="[lab host] ① 상주 탐침 파드를 띄우고 Ready 까지 기다린다" kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \ --restart=Never \ --env="K0=$K0" --env="K1=$K1" \ --env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \ --command -- sleep 7200 kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s ``` `--rm` 이 없으므로 끝나면 직접 지운다. 지우는 명령은 복구 절에 있다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인한다. ```bash label="[lab host] ② 비밀번호의 길이만 센다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c ``` 실측은 `19` 다(observed). ```bash label="[lab host] ③ 파드 안에도 값이 들어왔는지 길이로 본다" kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"' ``` `PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이므로 파드를 지우고 다시 띄운다. 이제부터는 파드 셸에 들어가 친다. 나올 때는 `exit` 이고 파드는 안 지워진다. ```bash label="[lab host] ④ 파드 셸로 들어간다" kubectl -n keycloak-lab exec -it a2-probe -- sh ``` ### 5. 양쪽 노드에 세션을 하나씩 만든다 **무엇을 보는가** — ① 과 ② 를 구분하려면 캐시를 가진 노드와 없는 노드가 있어야 한다. A-0 에서 확인한 성질을 그대로 쓴다 — 각 노드는 자기가 로그인시킨 세션만 캐시한다. ```sh label="[탐침 파드] 두 노드에 각각 로그인하고 sid 를 뽑는다" 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 ``` **어디를 보나** — 실측은 이렇다(observed, `02-setup-sessions.txt`). ```text === [준비] 양쪽 노드에 세션을 하나씩 만든다 === keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613 keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613 ``` **이 값이 뜻하는 것** — 빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이므로 응답 전체를 한 번 그대로 본다. ### 6. 세션이 각자 노드에만 캐시됐는가 ```bash label="[lab host] 노드별 캐시 엔트리" 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]' ``` 실측은 이렇다(observed, `02-setup-sessions.txt`). ```text === [확인] 세션이 각자 노드에만 캐시되었는가 === keycloak-1 = 0 건 keycloak-0 = 1 건 ``` `keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다. 방금 로그인했으므로 다음 15초 스크레이프에서 `1` 이 될 수 있고, 원래 실행 기록에도 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」으로 적혀 있다. 판정에 쓰는 값은 양쪽이 다른지가 아니라 `keycloak-0` 이 확실히 가지고 있는지다. ```bash label="[lab host] DB 세션 수도 적어 둔다" 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" ``` 실측은 온라인 세션 `2` 다(observed). 복구 후에 세션이 살아남았는지 볼 대조군이므로 적어 둔다. ### 7. ④ 에 쓸 클라이언트 id 를 미리 뽑는다 **무엇을 보는가** — DB 가 죽은 뒤에는 이 조회 자체가 실패하므로 지금 뽑아 둔다. ```sh label="[탐침 파드] ① 응답을 한 번 그대로 본다" 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" ``` 잘라내는 줄은 미검증이다(unknown). ```sh label="[탐침 파드] ② 첫 번째 id 만 잘라낸다" 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" ``` ### 8. 네 경로를 정상 상태에서 한 번 돌린다 ```sh label="[탐침 파드] 토큰 둘을 받고 네 경로를 차례로 친다" 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` 이다. **이 값이 뜻하는 것** — `-o /dev/null` 을 빼면 안 된다. 빼면 본문과 상태코드가 한 줄에 섞여 나오고, 원래 실행이 정확히 그것을 당했다. 상태가 필요 없는 경로도 미리 재 둔다. ```sh label="[탐침 파드] 서명 검증만 필요한 두 경로" 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" ``` 밖에서 정문도 재 둔다. ```bash label="[lab host] 밖에서 본 정문" curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master ``` ## 주입 ### 1. 데이터베이스를 정상 종료시킨다 **목적** — PostgreSQL 파드를 없애고 다시 만들어지지 않게 한다. 다른 두 방법으로는 이 실험이 성립하지 않는다. | 방법 | 무엇이 일어나나 | |---|---| | **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** | | `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 | | 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** | ```bash label="[lab host] ① 시각을 남기고 replica 를 0 으로 내린다" date '+%H:%M:%S 정지' kubectl -n keycloak-lab scale deployment/postgres --replicas=0 ``` ```bash label="[lab host] ② 파드가 사라질 때까지 기다리고 시각을 남긴다" kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s date '+%H:%M:%S 삭제완료' ``` **예상 결과** — 실측은 이렇다(observed, `03-four-paths.txt`). ```text === [2] PostgreSQL 정지 === 정지 시각: 11:56:04 deployment.apps/postgres scaled pod/postgres-7b474b88c8-sn9ff condition met 삭제 완료: 11:56:04 ``` 두 시각이 같다. 즉시 사라진다. 두 시각은 반드시 적어 둔다 — 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면 인과를 못 붙인다. **왜 필요한가** — 이것은 정상 종료다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤 내려가므로 데이터는 하나도 잃지 않는다. 강제로 죽였을 때 무엇을 잃는지는 A-3 이 잰다. 그리고 access token 수명이 60초라 위에서 발급한 `AT` 로 ④ 를 재려면 발급 → 정지 → 시험을 60초 안에 끝내야 한다. 60초를 넘기면 ④ 의 `401` 이 DB 때문인지 토큰 만료인지 구별되지 않는다. 시간이 지났으면 토큰을 다시 받아 두되 그건 DB 가 있어야 되는 일이므로 순서는 토큰 발급 다음이 정지다. **문제가 생기면** — 몇 초 만에 파드가 돌아왔다면 `delete pod` 를 썼다. `scale --replicas=0` 인지 다시 본다. ## 주입 검증 ### 1. postgres 파드가 하나도 없는가 ```bash label="[lab host] 파드와 Deployment 를 함께 본다" kubectl -n keycloak-lab get pods -o wide kubectl -n keycloak-lab get deploy postgres ``` `postgres` 로 시작하는 줄이 한 개도 없어야 하고 Deployment 는 `0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다. ### 2. Keycloak 이 실제로 DB 를 못 잡고 있는가 ```bash label="[lab host] 커넥션 풀 쪽 로그를 본다" kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection' ``` 실측은 이렇다(observed, `04-health-and-service.txt`). ```text 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 가 안 죽었다. `timed out` 이 아니라 `Connection refused` 가 나오는 까닭은 Service 는 있고 뒤에 파드가 없어 연결이 즉시 거부되기 때문이다. ### 3. 파드가 죽지 않고 트래픽에서만 빠졌는가 ```bash label="[lab host] Ready 와 재시작 횟수만 뽑아 본다" kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount" \ | grep keycloak ``` 실측은 `keycloak-0 false 0`, `keycloak-1 false 0` 이다(observed). 2026-09-17 에도 같은 두 줄을 받았다(observed). **그 `false` 는 DB 를 내리자마자 나오지 않는다.** readiness 프로브가 `periodSeconds: 10` · `failureThreshold: 3` 이라 헬스가 먼저 실패하고 그로부터 최대 30초 뒤에 조건이 뒤집힌다. 2026-09-17 에 내린 직후에는 네 경로가 이미 전부 `500` 인데 파드는 아직 `true true` 였고 정문도 `200` 이었다. 약 50초 뒤에 `false false` · 정문 `503` 이 됐다(observed). ```text 정지 직후 네 경로 500 500 500 500 · ready true,true · 정문 200 약 50초 뒤 ready false,false · 엔드포인트 false,false · 정문 503 ``` **그 50초 사이의 화면을 보고 「DB 가 없어도 멀쩡하다」로 읽지 않는다.** 이미 500 인데 아직 트래픽에서 안 빠졌을 뿐이다. 이 편이 잡으려는 것도 그 어긋남이다 — 되는 것과 Ready 인 것은 다르다. `READY` 가 `false` 인데 `RESTARTS` 는 여전히 `0` 이다. 파드는 죽지 않았고 트래픽에서 빠졌을 뿐이다. `RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재게 된다. 이 `restarts=0` 이 자동 회복이라는 결론을 가능하게 하는 조건이다. ## 관찰 탐침 파드 안에서 주입 전과 똑같은 명령을 다시 친다. 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라지므로 셸을 붙잡고 있는 편이 낫다. ```bash label="[lab host] 셸을 놓쳤으면 다시 들어간다" kubectl -n keycloak-lab exec -it a2-probe -- sh ``` **여기서 시계를 본다.** 7번에서 `AT` 를 받은 지 60초가 지났으면 ④ 는 `401` 을 준다. 그 `401` 은 DB 때문이 아니라 토큰이 만료돼서 나온 것이라, 이 실험이 재려는 값이 아니다. 60초를 넘겼으면 ④ 는 이 회차에서 못 잰다 — DB 가 없는 동안에는 새 토큰을 받을 수 없다(③ 이 이미 `500` 이다). 복구 절까지 가서 ④ 만 다시 쳐도 안 된다 — 그때는 DB 가 살아 있어 `200` 이 나오므로 이 표가 묻는 값이 아니다. ④ 가 필요하면 복구한 뒤 토큰을 새로 받고 주입부터 다시 돈다. ①·②·③ 은 `AT` 를 쓰지 않으므로 그대로 쓴다. 주입 전 8번에서 친 것과 글자까지 같은 블록이다. ```sh label="[탐침 파드] 네 경로를 다시 친다" 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" ``` 실측은 이렇다(observed, `03-four-paths.txt` 와 `04-health-and-service.txt`). ```text ① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500 ② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500 ③ 새 로그인 HTTP 500 ④ 관리 API (세션 조회 필요) HTTP 500 ``` 본문도 한 번 그대로 본다. ```sh label="[탐침 파드] 500 의 본문을 본다" curl -s -X POST "http://$K0:8080$TOK" \ -d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" ``` 실측은 이렇다(observed). ```text {"error":"unknown_error","error_description":"For more on this error consult the server log."} ``` 본문이 아무것도 말해 주지 않는다. 원인은 주입 검증에서 본 서버 로그에만 있다. ④ 의 첫 측정은 오염됐다. 원래 실행의 증거 파일에는 이렇게 남아 있다(observed, `03-four-paths.txt`). ```text ④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401 ``` 세 가지가 한 줄에 뭉쳐 있다. ```text HTTP 000000{"error":"HTTP 401 Unauthorized"}401 ─┬──── ──────────┬─────────────────── ─┬─ │ │ └─ 마지막 시도의 상태코드 │ └─ 응답 본문이 그대로 섞였다 └─ 재시도가 세 번 "000" 을 찍었다 (연결 실패) ``` `curl -w '%{http_code}'` 를 쓰면서 `-o /dev/null` 을 빼면 본문이 표준출력으로 같이 나오고, 거기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓인다. 위 표의 ④ `500` 은 아래 「서명 검증만 필요한 두 경로와 ④」 블록에서 DB 가 아직 내려가 있는 동안 다시 잰 값이다. 첫 측정은 쓰지 않았다 — 오염된 측정은 버리고 다시 잰다. ① 이 `500` 인 것이 이 실험의 답이다. 캐시에 세션을 들고 있어도 refresh 는 실패한다. ```text refresh 처리 ├── 세션이 존재하는가 → 캐시로 답할 수 있다 └── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다 ``` A-0 에서 잡은 SQL 그대로다. ```sql label="A-0 의 문장 로그에서 잡은 갱신 문장" update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ... ``` 캐시는 읽기를 대신할 뿐 쓰기를 대신하지 못한다. refresh 는 이름과 달리 쓰기 연산이다. 상태가 필요 없는 경로는 살아남는다. ④ 도 여기서 한 번 더 친다 — 표에 적은 ④ `500` 이 이 블록이 낸 값이다. 셋을 한 블록에 두면 세 경로의 답이 한 화면에 나란히 나온다. ```sh label="[탐침 파드] 서명 검증만 필요한 두 경로와 ④ 를 다시 친다" 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" 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" ``` 실측은 이렇다(observed, `04-health-and-service.txt`). ```text === ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 === JWKS 엔드포인트(realm 공개키) HTTP 200 realm 메타데이터(.well-known) HTTP 200 관리 API(세션 조회 필요) HTTP 500 ``` 같은 파드, 같은 포트인데 경로에 따라 `200` 과 `500` 이 갈린다. realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답하고, 이론적으로는 이미 JWKS 를 캐시한 리소스 서버가 토큰 검증을 계속할 수 있다는 뜻이다. 이 실험대에는 독립 리소스 서버가 아직 없으므로 거기까지가 말할 수 있는 범위다. ```bash label="[lab host] Service 뒤에 누가 남았는지 본다" 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" ``` 실측은 이렇다(observed, `04-health-and-service.txt`). ```text === Service 엔드포인트 === ready : [] ← 비었다 notReady: [10.42.0.35 10.42.1.67] ``` `ready` 가 빈 목록이다. `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨고, 원래 실행 기록에도 그 경고가 두 줄 남아 있다(observed). ```text 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 label="[lab host] 같은 것을 짧게 보는 형태" kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints ``` 밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다. ```bash label="[lab host] 정문을 코드로 한 번, 헤더로 한 번" curl -s -o /dev/null -w '%{http_code}\n' --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master curl -I --resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master ``` 실측은 `https://auth.hyeonworks.com/realms/master HTTP 503` 이다(observed). 이 `503` 은 Keycloak 이 준 것이 아니다. Ready 인 백엔드가 하나도 없어서 그 앞의 프록시가 준 것이고, `200` 이던 JWKS 도 정문으로는 닿지 않는다. | | A-1 (7800 차단) | **A-2 (DB 정지)** | |---|---|---| | Ready 인 파드 | `keycloak-1` **1개 생존** | **0개** | | Service `ready` | `[10.42.0.35]` | **`[]`** | | 외부 응답 | **200** | **503** | | 성격 | 용량 저하 | **전면 장애** | 노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다. Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다. 헬스 본문이 까닭을 말한다. Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다. 묻는 명령은 관찰용 터미널에서 치는데 `"http://$K0:9000/..."` 는 큰따옴표라 파드 안이 아니라 그 터미널의 셸이 편다. 두 값은 2번에서 잡았는데 그 터미널을 지금 탐침 파드 셸이 붙잡고 있으므로, 관찰용 터미널에서 두 줄을 다시 친다. ```bash label="[lab host] 관찰용 터미널에서도 파드 주소를 잡는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') echo "$K0 $K1" ``` DB 가 없어도 이 두 줄은 된다. 파드 주소는 쿠버네티스 API 가 답하고, 주입 검증 3번에서 본 대로 Keycloak 파드는 재시작하지 않았으므로(`RESTARTS 0`) 값도 2번과 같다. 이 두 줄을 건너뛰고 다음 블록을 치면 `curl -s "http://:9000/health/ready"` 가 되어 URL 이 거부되고 빈 출력이 나온다. 전면 장애 한복판이라 그 빈 줄을 「헬스 엔드포인트까지 죽었다」로 읽기 쉬운데, 실제로는 헬스가 응답하고 네 항목 중 하나만 DOWN 이다. ```bash label="[lab host] 탐침 파드를 통해 헬스를 묻는다" kubectl -n keycloak-lab exec a2-probe -- \ curl -s "http://$K0:9000/health/ready" ``` 실측은 이렇다(observed, `04-health-and-service.txt`). ```text === 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 인지가 장애를 구별한다. 관측의 함정이 여기 있다. ```bash label="[lab host] up 지표를 본다" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=up' \ | tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]' ``` 실측은 이렇다(observed, `05-recovery.txt`). ```text === ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) === up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다 up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다 ``` Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 전 구간 평평하다. 원래 실행의 그림이 `a2-up-stayed-1-during-outage.png` 이고, `11:44` 의 짧은 골은 A-1 에서 파드를 교체한 자국이다. Prometheus 가 `/metrics` 를 긁는 데 성공하기만 하면 `up` 이 1 이 되므로, 프로세스는 멀쩡히 살아 메트릭을 내놓고 기능은 전멸한 상태에서도 1 이다. | 지표 | 이 장애에서 | |---|---| | `up` | **1 — 아무것도 알려주지 않는다** | | 파드 `Ready` | **false — 여기서 드러난다** | | 외부 HTTP 코드 | **503 — 사용자가 겪는 것** | A-0 은 `up` 을 「가장 중요한 합성 지표」라고 썼는데 절반만 맞다. 대상이 사라진 것은 잡지만 대상이 살아서 못 쓰는 것은 못 잡고, 후자가 운영에서 훨씬 흔하다. 알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 건다. 그럼 readiness 를 지표로 볼 수 있는지 물어본다. ```bash label="[lab host] 파드 readiness 지표가 있는지 묻는다" 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 만 보고 있으면 알 수 없고, 관측 스택에 빠진 것을 이 실험이 찾아냈다. ## 복구와 원상복구 확인표 ### 1. 데이터베이스를 다시 켠다 **목적** — PostgreSQL 을 되살리고 Keycloak 이 스스로 돌아오는지 본다. ```bash label="[lab host] ① 시각을 남기고 replica 를 1 로 올린다" date '+%H:%M:%S 재기동' kubectl -n keycloak-lab scale deployment/postgres --replicas=1 ``` ```bash label="[lab host] ② 롤아웃이 끝날 때까지 기다린다" kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s ``` **예상 결과** — 실측은 이렇다(observed, `05-recovery.txt`). ```text === 복구 — 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 ``` **왜 필요한가** — 여기서 Keycloak 을 재시작하고 싶어진다. 참는다. 재시작하면 이 실험이 답하려던 물음(「사람 개입이 필요한가」)이 사라진다. **문제가 생기면** — 롤아웃이 타임아웃으로 끝나면 노드 상태와 PVC 를 본다. ### 2. Keycloak 이 재시작 없이 돌아오는지 15초 간격으로 본다 **목적** — 사람이 한 일이 DB 를 켠 것뿐인지 확인한다. ```bash label="[lab host] ① Ready 와 정문을 함께 친다" 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' --resolve auth.hyeonworks.com:443:192.168.122.10 \ https://auth.hyeonworks.com/realms/master ``` ```bash label="[lab host] ② 재시작 횟수만 따로 센다" kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount" | grep keycloak ``` **예상 결과** — 실측은 이렇다(observed). ```text === Keycloak 이 스스로 회복하는가 (재시작 없이) === +15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200 → 서비스 복귀 ``` ②의 실측은 `keycloak-0 0`, `keycloak-1 0` 이다(observed). 주입 검증에서 본 값 그대로다. 2026-09-17 에 다시 재 보니 `scale --replicas=1` 에서 두 파드가 `true` 로 돌아오기까지 **36초**였다(observed). 그 가운데 절반쯤은 PostgreSQL 파드가 뜨는 시간이다. ```text 14:29:20 복구 deployment "postgres" successfully rolled out 14:29:56 ready=true,true 정문=200 재시작=0,0 ``` 「약 15초」는 **DB 가 Ready 가 된 뒤부터** 센다. `scale` 을 친 순간부터 세면 더 걸리므로, 30초쯤 지나도 `503` 이면 그때 기다린다. **④ 는 이 시점에 `401` 이 정상이다.** 2026-09-17 실행에서 ①②③ 이 `200` 인데 ④ 만 `401` 이었다(observed). access token 이 60초짜리라 정지 구간을 지나오며 만료됐을 뿐 DB 와는 무관하다. 위 7번의 시계 경고가 이것을 미리 말해 둔다. **왜 필요한가** — 커넥션 풀이 스스로 재연결하고 readiness 가 다시 UP 이 되면서 Service 에 복귀했다. 회복 시간은 DB Ready 이후 약 15초, Keycloak 재시작은 불필요(`restarts=0`)였다. liveness 는 실패하면 재시작이라 재시작하면 나아지는 문제(교착, 메모리 누수)에 쓰고, readiness 는 실패하면 트래픽에서 격리라 재시작해도 안 나아지는 문제(의존 대상이 죽음)에 쓴다. DB 장애에 liveness 를 걸면 모든 파드가 무한 재시작하고, DB 가 돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어지며, 재시작하면 캐시까지 날아간다. **문제가 생기면** — 계속 `503` 이면 Keycloak 이 아직 재연결 중이다. 15~30초 더 기다리고 재시작하지 않는다. ### 3. 세션이 살아남았는지 세고 탐침 파드를 지운다 **목적** — 정상 종료가 데이터를 잃지 않았다는 것을 숫자로 확인하고 실험 도구를 치운다. ```bash label="[lab host] ① 세션 수를 다시 센다" 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" ``` ```bash label="[lab host] ② 탐침 파드를 직접 지운다" kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found ``` **예상 결과** — 실측은 `online 세션 5` 다(observed). 주입 전에 적어 둔 값보다 크거나 같다. 실험 중에 로그인을 여러 번 했으므로 늘어나 있다. **왜 필요한가** — 세션은 DB 에 있으므로 DB 가 돌아오면 같이 돌아오고, 정상 종료였기 때문에 하나도 잃지 않았다. 탐침 파드는 `sleep 7200` 이 끝나면 `Completed` 로 남고 자동으로 사라지지 않는다. 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 까닭이 거기 있다. **문제가 생기면** — 세션 수가 주입 전보다 적으면 이 실험 밖에서 누가 지운 것이다. A-3 의 정리 단계가 돌았는지 본다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | DB | `kubectl -n keycloak-lab get deploy postgres` | `1/1` | | 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` | | Service | `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` | | 탐침 파드 | `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` | 표는 위에서 아래로 한 번에 치지 않는다. 막혔을 때 해당하는 줄만 짚는다. 헬스 줄의 `$K0` 는 관찰 절에서 관찰용 터미널에 잡아 둔 값이라 그 터미널을 닫았으면 그 두 줄부터 다시 친다. 헬스 줄 자체는 탐침 파드가 남아 있을 때만 되므로 ② 로 지웠으면 건너뛴다. ## 막히면 | 증상 | 원인 | 확인 | |---|---|---| | 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 오염된 측정은 버리고 다시 잰다 | | `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 | | DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` | | ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 | | `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 | | 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 | | `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` | | `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 | | `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 | | 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** | | `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` | | 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 11:53–11:58 KST` 에 돈 한 번의 실행에서 나왔다(observed). - (observed) 정지 `11:56:04`·재기동 `11:57:09`, 네 경로 전부 `500`, JWKS 와 `.well-known` 이 `200`, `ready : []`, 정문 `503`, 헬스 네 항목 중 `database connections` 만 DOWN, `up` 이 양쪽 `1`, `kube_pod_status_ready` 결과가 빈 배열, `restarts=0`, 복구 세션 `5`, 비밀번호 길이 `19`. - (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력 셋과 `CID` 를 뽑는 줄. 가이드가 미검증으로 표시했다. - 한 번은 버린 측정이 있다 — ④ 의 첫 측정 `HTTP 000000{"error":"HTTP 401 Unauthorized"}401` 은 `-o /dev/null` 을 빼고 `--retry` 를 걸어 나온 오염된 값이라 쓰지 않았다. 표의 `500` 은 DB 가 내려가 있는 동안 서명 검증 경로와 함께 다시 잰 값이다. - ④ 를 언제 다시 쟀는지를 원본과 다르게 적었다(unknown). 원본 가이드는 그 `500` 을 「복구 절에서 다시 잰 값」이라고 쓰는데 복구 절에는 ④ 를 다시 재는 명령이 없고, DB 가 선 뒤에 재면 `500` 이 나올 수 없다. 증거 파일 `04-health-and-service.txt` 의 「④ 다시」 절은 정문 `503` · `ready : []` 와 같은 묶음에 들어 있어 정지 구간에 찍혔다. 그래서 이 기록은 재측정을 관찰 절에 넣었고, 원본이 「복구 절」이라고 쓴 것이 무엇을 가리키는지는 확인하지 못했다. - 말할 수 있는 범위가 여기까지인 것이 하나 있다 — JWKS 가 살아 있으므로 「이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다」는 이론이고, 이 실험대에 독립 리소스 서버가 없어 확인하지 못했다. B층에서 확인한다.