기반 가이드 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>
42 KiB
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 |
|
|
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 으로 자른다 |
이 실험이 가르는 것
A-1 에서 룩어사이드 캐시는 읽을 때 데이터베이스와 대조하지 않는다는 것을 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 200 을 줬다. 그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 캐시가 DB 를 대신한다면 그 노드는 살아남아 부분 장애가 되고, 대신하지 못한다면 전면 장애가 된다.
A-1 과의 대비가 이 실험의 값이다.
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분 남짓으로 짧게 잡는다. 되돌리는 명령은 아래 한 줄이다.
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
이 값이 뜻하는 것 — 원래 실행에서 postgres 는 kc-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-1 이 0 인 것은 스크레이프 지연 때문이다. 방금 로그인했으므로 다음 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 refused 와 agroal 을 본다. 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 인 것은 다르다.
READY 가 false 인데 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.txt 와 04-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
같은 파드, 같은 포트인데 경로에 따라 200 과 500 이 갈린다. 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 -- 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층에서 확인한다.