Files
document-haness/docs/keycloak-session-store/tech-log-studio/losing-a-node-or-the-store/setup/setup-reproduce-a2-database-loss.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

42 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
bf169fef-f900-4247-88f8-427742ae3fe9 SETUP reproduce-a2-database-loss PostgreSQL 을 정상 종료시키고 네 경로를 잰다 losing-a-node-or-the-store PostgreSQL 을 내리고 노드 전원을 뽑았을 때 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/bf169fef-f900-4247-88f8-427742ae3fe9/edit
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-2
cdac9b8178391311d8eca1ebc6cac15bb62d79af

PostgreSQL 을 정상 종료시키고 네 경로를 잰다

PostgreSQL 을 정상 종료시키고 refresh 와 새 로그인과 관리 API 조회가 각각 어떻게 되는지 주입 전후에 같은 명령으로 재는 절차다. 정문이 실제로 503 이 되므로 정지 구간을 1분 남짓으로 짧게 잡고, 복구는 데이터베이스를 다시 켜는 것 하나다.

관계

  • 200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다 정상 종료와 강제 종료의 차이를 건수로 보여 주는 기록이다. 이 절차는 그 대조군인 정상 종료 쪽을 만든다.
  • up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다 정문이 503 인 동안 up 이 양쪽 다 1 이었던 것을 다룬다.
  • readiness 가 깨진 노드를 시야에서 먼저 치운다 Ready 파드가 0개가 되고 ready 주소가 빈 목록이 되는 경로를 다룬다.
  • 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다 먼저 해 둬야 하는 편이다. 세션을 데이터베이스가 공유한다는 것을 손으로 확인해 두지 않으면 여기서 나오는 500 을 해석할 수 없다.
  • PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다 같은 데이터베이스를 SIGKILL 로 죽이는 편이다. 정상 종료가 아무것도 잃지 않는다는 이 편의 결과가 그쪽의 대조군이 된다.

본문

읽기 전에 — 어디서 치는가

명령은 전부 [lab host] 에서 kubectlpsql 로 친다. 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. kubectlsudo 를 붙이지 않는다 — root 홈에는 ~/.kube/config 가 없어 localhost:8080 으로 붙으려다 connection refused 로 끝난다.

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission 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-probecurlimages/curl:8.11.1, sleep 7200, --restart=Never
재는 경로 넷 — 캐시 있는 노드 refresh · 없는 노드 refresh · 새 로그인 · 관리 API
시간 제약 access token 수명 60초. 토큰 발급 · 정지 · 시험을 그 안에 끝낸다
정지 구간 1분 남짓. 그동안 정문이 실제로 503 이 된다
도구 jq 가 이 실험대에 없다. Prometheus 출력은 trgrep 으로 자른다

이 실험이 가르는 것

A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 200 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다.

A-1 과의 대비가 이 실험의 값이다.

   A-1  7800 차단   →  한쪽만 빠지고 서비스는 계속됐다   (용량 저하)
   A-2  DB 정지     →  ?                                 (여기서 판정)

네 경로를 구분해서 본다. 하나만 재면 무엇 때문에 죽었는지 모른다.

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

절차를 끝까지 밟으면 캐시에 세션을 가진 노드도 refresh 가 500 인 것, JWKS 와 .well-known200 으로 살아 있는 것, 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분 남짓으로 짧게 잡는다. 되돌리는 명령은 아래 한 줄이다.

kubectl -n keycloak-lab scale deployment/postgres --replicas=1

주입 전에 같은 명령으로 먼저 본다

파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험

1. postgres 가 어느 노드에 있는가

무엇을 보는가 — 세 파드의 상태와 배치.

kubectl -n keycloak-lab get pods -o wide

어디를 보나 — 실측은 이렇다(observed, 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

이 값이 뜻하는 것 — 원래 실행에서 postgreskc-lab-2, 즉 keycloak-0 과 같은 노드에 있었다. 이 실험에서는 상관없지만 A-4(노드 상실)에서는 결정적이다 — 그 노드를 죽이면 A-2 가 함께 일어난다.

2. 파드 주소 두 개를 변수에 담는다

무엇을 보는가 — 뒤의 모든 요청이 향할 주소.

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. 클러스터가 정상인지 먼저 확인한다

무엇을 보는가 — 두 노드가 아는 멤버 수.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'

라벨을 보고 나서 자른다. 아래 줄은 가이드가 미검증으로 표시했다(unknown).

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 로 단계를 이어간다.

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 이 없으므로 끝나면 직접 지운다. 지우는 명령은 복구 절에 있다.

비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인한다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

실측은 19 다(observed).

kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

PW길이=0 이면 --env 가 빈 값을 넘긴 것이므로 파드를 지우고 다시 띄운다. 이제부터는 파드 셸에 들어가 친다. 나올 때는 exit 이고 파드는 안 지워진다.

kubectl -n keycloak-lab exec -it a2-probe -- sh

5. 양쪽 노드에 세션을 하나씩 만든다

무엇을 보는가 — ① 과 ② 를 구분하려면 캐시를 가진 노드와 없는 노드가 있어야 한다. A-0 에서 확인한 성질을 그대로 쓴다 — 각 노드는 자기가 로그인시킨 세션만 캐시한다.

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).

=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
  keycloak-0 에서 로그인  sid=EAXV5HcG2J1BZ3vnwONf64AQ  토큰길이=613
  keycloak-1 에서 로그인  sid=McyTj5lj3n_JqApCXeuAHExc  토큰길이=613

이 값이 뜻하는 것 — 빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이므로 응답 전체를 한 번 그대로 본다.

6. 세션이 각자 노드에만 캐시됐는가

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).

=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
  keycloak-1   = 0 건
  keycloak-0   = 1 건

keycloak-10 인 것은 스크레이프 지연 때문이다. 방금 로그인했으므로 다음 15초 스크레이프에서 1 이 될 수 있고, 원래 실행 기록에도 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」으로 적혀 있다. 판정에 쓰는 값은 양쪽이 다른지가 아니라 keycloak-0 이 확실히 가지고 있는지다.

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 가 죽은 뒤에는 이 조회 자체가 실패하므로 지금 뽑아 둔다.

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).

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. 네 경로를 정상 상태에서 한 번 돌린다

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 을 빼면 안 된다. 빼면 본문과 상태코드가 한 줄에 섞여 나오고, 원래 실행이 정확히 그것을 당했다.

상태가 필요 없는 경로도 미리 재 둔다.

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' https://auth.hyeonworks.com/realms/master

주입

1. 데이터베이스를 정상 종료시킨다

목적 — PostgreSQL 파드를 없애고 다시 만들어지지 않게 한다.

다른 두 방법으로는 이 실험이 성립하지 않는다.

방법 무엇이 일어나나
scale --replicas=0 파드가 정상 종료되고 다시 만들어지지 않는다
delete pod Deployment 가 곧바로 새로 만든다 — 몇 초 만에 돌아온다
노드 정지 Keycloak 도 같이 죽는다 — 두 장애가 섞인다
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
date '+%H:%M:%S 삭제완료'

예상 결과 — 실측은 이렇다(observed, 03-four-paths.txt).

=== [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 파드가 하나도 없는가

kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab get deploy postgres

postgres 로 시작하는 줄이 한 개도 없어야 하고 Deployment 는 0/0 이어야 한다. 0/1 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.

2. Keycloak 이 실제로 DB 를 못 잡고 있는가

kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'

실측은 이렇다(observed, 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 refusedagroal 을 본다. agroal 은 Quarkus 의 커넥션 풀이고 풀이 새 커넥션을 만들지 못한다. 이 줄이 없으면 Keycloak 이 아직 옛 커넥션으로 버티고 있거나 애초에 DB 가 안 죽었다. timed out 이 아니라 Connection refused 가 나오는 까닭은 Service 는 있고 뒤에 파드가 없어 연결이 즉시 거부되기 때문이다.

3. 파드가 죽지 않고 트래픽에서만 빠졌는가

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).

정지 직후      네 경로 500 500 500 500 · ready true,true  · 정문 200
약 50초 뒤     ready false,false · 엔드포인트 false,false · 정문 503

그 50초 사이의 화면을 보고 「DB 가 없어도 멀쩡하다」로 읽지 않는다. 이미 500 인데 아직 트래픽에서 안 빠졌을 뿐이다. 이 편이 잡으려는 것도 그 어긋남이다 — 되는 것과 Ready 인 것은 다르다.

READYfalse 인데 RESTARTS 는 여전히 0 이다. 파드는 죽지 않았고 트래픽에서 빠졌을 뿐이다. RESTARTS 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재게 된다. 이 restarts=0 이 자동 회복이라는 결론을 가능하게 하는 조건이다.

관찰

탐침 파드 안에서 주입 전과 똑같은 명령을 다시 친다. 파드 셸에서 나갔다 들어오면 RT0 RT1 AT CID 가 사라지므로 셸을 붙잡고 있는 편이 낫다.

kubectl -n keycloak-lab exec -it a2-probe -- sh

여기서 시계를 본다. 7번에서 AT 를 받은 지 60초가 지났으면 ④ 는 401 을 준다. 그 401 은 DB 때문이 아니라 토큰이 만료돼서 나온 것이라, 이 실험이 재려는 값이 아니다. 60초를 넘겼으면 ④ 는 이 회차에서 못 잰다 — DB 가 없는 동안에는 새 토큰을 받을 수 없다(③ 이 이미 500 이다). 복구 절까지 가서 ④ 만 다시 쳐도 안 된다 — 그때는 DB 가 살아 있어 200 이 나오므로 이 표가 묻는 값이 아니다. ④ 가 필요하면 복구한 뒤 토큰을 새로 받고 주입부터 다시 돈다. ①·②·③ 은 AT 를 쓰지 않으므로 그대로 쓴다.

주입 전 8번에서 친 것과 글자까지 같은 블록이다.

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.txt04-health-and-service.txt).

  ① 캐시를 가진 노드(keycloak-0)에서 refresh   HTTP 500
  ② 캐시가 없는 노드(keycloak-1)에서 refresh   HTTP 500
  ③ 새 로그인                                  HTTP 500
  ④ 관리 API (세션 조회 필요)                   HTTP 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).

{"error":"unknown_error","error_description":"For more on this error consult the server log."}

본문이 아무것도 말해 주지 않는다. 원인은 주입 검증에서 본 서버 로그에만 있다.

④ 의 첫 측정은 오염됐다. 원래 실행의 증거 파일에는 이렇게 남아 있다(observed, 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 은 아래 「서명 검증만 필요한 두 경로와 ④」 블록에서 DB 가 아직 내려가 있는 동안 다시 잰 값이다. 첫 측정은 쓰지 않았다 — 오염된 측정은 버리고 다시 잰다.

① 이 500 인 것이 이 실험의 답이다. 캐시에 세션을 들고 있어도 refresh 는 실패한다.

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

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

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

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

상태가 필요 없는 경로는 살아남는다. ④ 도 여기서 한 번 더 친다 — 표에 적은 ④ 500 이 이 블록이 낸 값이다. 셋을 한 블록에 두면 세 경로의 답이 한 화면에 나란히 나온다.

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).

=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
  JWKS 엔드포인트(realm 공개키)  HTTP 200
  realm 메타데이터(.well-known)  HTTP 200
  관리 API(세션 조회 필요)       HTTP 500

같은 파드, 같은 포트인데 경로에 따라 200500 이 갈린다. realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답하고, 이론적으로는 이미 JWKS 를 캐시한 리소스 서버가 토큰 검증을 계속할 수 있다는 뜻이다. 이 실험대에는 독립 리소스 서버가 아직 없으므로 거기까지가 말할 수 있는 범위다.

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).

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

ready 가 빈 목록이다. kubectl get endpoints 는 v1.33 부터 deprecated 라 경고가 뜨고, 원래 실행 기록에도 그 경고가 두 줄 남아 있다(observed).

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

사람이 눈으로 볼 때는 이쪽이 더 짧다.

kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints

밖에서 본다. 한 번 눈으로 볼 때는 헤더까지 본다.

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
curl -I 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번에서 잡았는데 그 터미널을 지금 탐침 파드 셸이 붙잡고 있으므로, 관찰용 터미널에서 두 줄을 다시 친다.

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 이다.

kubectl -n keycloak-lab exec a2-probe -- \
  curl -s "http://$K0:9000/health/ready"

실측은 이렇다(observed, 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 인지가 장애를 구별한다.

관측의 함정이 여기 있다.

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).

=== ★ 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 를 지표로 볼 수 있는지 물어본다.

kubectl -n observability exec deploy/prometheus -- \
  wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
  | head -c 300; echo

결과가 비어 있다.

{"status":"success","data":{"resultType":"vector","result":[]}}

이 실험대에는 아직 kube-state-metrics 가 없어 파드 readiness 가 지표로 남지 않는다. 지금 이 장애는 Prometheus 만 보고 있으면 알 수 없고, 관측 스택에 빠진 것을 이 실험이 찾아냈다.

복구와 원상복구 확인표

1. 데이터베이스를 다시 켠다

목적 — PostgreSQL 을 되살리고 Keycloak 이 스스로 돌아오는지 본다.

date '+%H:%M:%S 재기동'
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s

예상 결과 — 실측은 이렇다(observed, 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

왜 필요한가 — 여기서 Keycloak 을 재시작하고 싶어진다. 참는다. 재시작하면 이 실험이 답하려던 물음(「사람 개입이 필요한가」)이 사라진다.

문제가 생기면 — 롤아웃이 타임아웃으로 끝나면 노드 상태와 PVC 를 본다.

2. Keycloak 이 재시작 없이 돌아오는지 15초 간격으로 본다

목적 — 사람이 한 일이 DB 를 켠 것뿐인지 확인한다.

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
kubectl -n keycloak-lab get pods -o "custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount" | grep keycloak

예상 결과 — 실측은 이렇다(observed).

=== 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 파드가 뜨는 시간이다.

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. 세션이 살아남았는지 세고 탐침 파드를 지운다

목적 — 정상 종료가 데이터를 잃지 않았다는 것을 숫자로 확인하고 실험 도구를 치운다.

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"
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 -- curlexit 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:5311:58 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) 정지 11:56:04·재기동 11:57:09, 네 경로 전부 500, JWKS 와 .well-known200, 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층에서 확인한다.