Files
keycloak-pattern/docs/guides/experiments/a2-database-loss.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

40 KiB
Raw Blame History

A-2 재현 가이드 — PostgreSQL 을 내리고 살아남는 노드가 있는지 직접 본다

해설 문서: docs/experiment-a2-database-loss.md · 증거 원문: docs/evidence/a2-database-loss/

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
캐시에 세션을 가진 노드도 refresh 가 500 인 것 상주 탐침 파드
JWKS 와 .well-known200 으로 살아 있는 것 같은 파드
Ready 파드가 0개, ready 주소가 빈 목록인 것 endpointslice
정문이 503 을 주는 것 밖에서 curl
database connections 만 DOWN 인 헬스 본문 health/ready
up = 1 인 채로 전면 장애가 나 있는 것 Prometheus
15초 만에 재시작 0회로 스스로 돌아오는 것 get pods

전제

  • A-0 을 먼저 한다. 「세션은 DB 가 공유한다」를 손으로 확인해 두지 않으면 이 실험의 500 을 해석할 수 없다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • 네임스페이스는 keycloak-lab, Prometheus 는 observability 다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
  • jq 는 이 실험대 어디에도 없다. 이 가이드는 jq 를 쓰지 않는다.

주의 — 이건 전면 장애를 만드는 실험이다

정문(https://auth.hyeonworks.com)이 실제로 503 이 된다. 이 실험대를 쓰는 다른 작업이 있으면 멈춘다. 정지 구간은 1분 남짓으로 짧게 잡는다. 되돌리는 명령은 하나뿐이고 5. 복구 에 있다. 중간에 그만두려면 그것만 치면 된다.

표시 규약

표시
실측 2026-09-04 11:5311:58 KST 실행 기록의 출력 원문. 증거 파일에 그대로 있다
형태 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다

IP·파드 이름·sid 는 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의 실제 값이다.


0. 왜 이 실험을 하는가

A-1 에서 **「룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도 캐시를 가진 노드가 200 을 줬다.

그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다. 그 가설을 가른다.

예측
캐시가 DB 를 대신한다면 캐시를 가진 노드는 살아남는다 — 부분 장애
대신하지 못한다면 전면 장애

그리고 A-1 과의 대비가 이 실험의 진짜 값이다.

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

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

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

1. 기준선 — DB 를 내리기 전에

시험군만 재는 측정은 측정이 아니다. 정지 후에 볼 것을 정지 전에 똑같은 명령으로 먼저 봐 둔다.

넓은 것부터 좁혀 간다.

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

1-1. 파드와 노드

확인

sudo kubectl -n keycloak-lab get pods -o wide

실측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, RESTARTS0
  • postgres 가 어느 노드에 있는가. 원래 실행에서는 kc-lab-2, 즉 keycloak-0같은 노드
  • IP 세 개를 적어 둔다

이 결과가 의미하는 것postgreskeycloak-0 이 같은 노드에 있다는 사실은 이 실험에서는 상관없지만, A-4(노드 상실)에서는 결정적이다. 그 노드를 죽이면 A-2 가 함께 일어난다.

IP 는 변수로 잡아 둔다.

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

  keycloak-0=10.42.1.67  keycloak-1=10.42.0.35

1-2. 클러스터가 정상인가

확인

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

한 줄짜리 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다. 어떤 라벨이 붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.

읽기 좋게 자른다. 미검증

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

  cluster_size keycloak-1   = 2
  cluster_size keycloak-0   = 2

어디를 봐야 하는가 — 두 줄이고 값이 둘 다 2.

이 결과가 의미하는 것 — A-1 의 분단이 완전히 회복된 상태에서 시작한다. 여기가 1 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다.

1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다

A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다. --rm 파드는 매번 만들고 지우므로 느리고 경합이 있고, 토큰을 단계 사이로 넘길 수 없다.

이 실험은 DB 정지 전에 발급한 토큰을 정지 후에 써야 한다. 그래서 파드를 하나 띄워 두고 exec 로 단계를 이어간다.

하기

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

pod/a2-probe condition met

되돌리기

sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:

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

실측19

확인 — 환경변수가 들어갔나

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 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.

이제부터는 이 파드 안에서 친다. 셸에 들어가는 편이 편하다.

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

프롬프트가 / $ 로 바뀐다. 나올 때는 exit파드는 안 지워진다 (--rm 이 없다).

1-4. 양쪽 노드에 세션을 하나씩 만든다

이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야 한다. 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

실측02-setup-sessions.txt

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

어디를 봐야 하는가 — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나 base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.

1-5. 세션이 각자 노드에만 캐시되었는가

확인 — 밖에서. Keycloak 이미지에는 curl 이 없으므로 Prometheus 에 묻는다

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

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

어디를 봐야 하는가cachesessions 인 두 줄. 값이 서로 다르다.

이 결과가 의미하는 것keycloak-0 은 캐시를 가졌고 keycloak-1 은 없다. 이제 ① 과 ② 를 구분해서 물을 수 있다.

keycloak-10 인 것은 스크레이프 지연 때문이다. 방금 로그인했으므로 다음 15초 스크레이프에서 1 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」. 중요한 것은 「양쪽이 다르다」가 아니라 「keycloak-0 이 확실히 가지고 있다」다. ① 의 해석에 필요한 것은 그것뿐이다.

확인 — DB 에는 몇 건인가

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

=== DB 온라인 세션 ===
  2

이 숫자를 적어 둔다. 복구 후에 세션이 살아남았는지 볼 대조군이다.

1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다

이 절을 건너뛰면 뒤의 500 이 아무 의미가 없다.

④ 에 쓸 클라이언트 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"

형태 — 객체 하나짜리 배열. "id" 가 맨 앞에 있다

[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}]

무엇을 자르는지 눈으로 본 다음 잘라낸다. 미검증

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":" 를 고른다.

네 경로를 정상 상태에서 한 번 돌린다

하기 — 파드 안에서

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 을 본다.

이 결과가 의미하는 것 — 네 경로가 전부 200 인 것이 기준선이다. 정지 후에 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"

형태

JWKS      200
well-known 200

확인 — 밖에서 정문도 재 둔다

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master

형태

200

exit 으로 파드 셸에서 나온다. 파드는 그대로 둔다.


2. 주입 — PostgreSQL 을 0대로 내린다

여기부터 상태가 바뀐다. 되돌리는 명령을 먼저 읽어 둔다.

되돌리기

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

2-2. 적용

하기

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

=== [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 파드가 정말 없나

확인

sudo kubectl -n keycloak-lab get pods -o wide

어디를 봐야 하는가postgres 로 시작하는 줄이 한 개도 없다. Terminating 으로 남아 있으면 아직 안 끝난 것이다. wait 가 통과했으면 없다.

확인 — Deployment 쪽도 본다

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 에 못 붙고 있나

확인 — 로그가 원인을 말한다

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

실측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 가 안 죽은 것이다.

Connection refused 이지 timed out 이 아니다. Service 는 남아 있지만 뒤에 파드가 없어 연결이 즉시 거부된다. 네트워크를 막았다면 timeout 이 나왔을 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다.

3-3. 엉뚱한 것을 죽이지 않았나

확인

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

keycloak-0   false   0
keycloak-1   false   0

어디를 봐야 하는가READYfalse 인데 RESTARTS 가 여전히 0.

이 결과가 의미하는 것 — 파드는 죽지 않았다. 트래픽에서 빠졌을 뿐이다. RESTARTS 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을 재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다.

restarts=0 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.


4. 효과를 관찰한다

4-1. 네 경로

하기 — 탐침 파드 안에서. 1-6 과 똑같은 명령을 다시 친다

sudo kubectl -n keycloak-lab exec -it a2-probe -- 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 · ④ 는 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"

실측

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

어디를 봐야 하는가 — 네 줄 전부 500. 그리고 본문이 아무것도 말해 주지 않는다. 원인은 3-2 의 서버 로그에만 있다.

★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다

원래 실행의 증거 파일에는 이렇게 남아 있다.

실측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 에서 잡은 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 의 ⑤ 를 그대로

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

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

어디를 봐야 하는가 — 같은 파드, 같은 포트인데 경로에 따라 200500 이 갈린다.

이 결과가 의미하는 것realm 공개키와 메타데이터는 메모리에 있으므로 DB 없이도 응답한다. 이론적으로는 이미 JWKS 를 캐시한 리소스 서버는 토큰 검증을 계속할 수 있다는 뜻이다.

다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 여기까지가 말할 수 있는 범위다. B층에서 확인한다.

그리고 정문으로는 이것도 못 쓴다. 다음 절 때문이다.

4-4. 전면 장애 — 살아남는 노드가 없다

확인 — Service 가 어느 파드를 잡고 있나

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

=== 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

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

sudo 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

실측

=== 외부 진입점 ===
  https://auth.hyeonworks.com/realms/master   HTTP 503

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

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 이 없으므로 파드 밖에서 묻는다

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

형태 — 한 줄 JSON 이 나온다. 한 번은 그대로 본다.

실측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 로도 같은 것이 보인다

sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions

4-6. 관측의 함정 — up = 1 인 채로 전면 장애

확인

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

=== ★ 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 이고, 11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.

이 결과가 의미하는 것upPrometheus 가 /metrics 를 긁는 데 성공했는가만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다. 기능은 전멸했는데.

지표 이 장애에서
up 1 — 아무것도 알려주지 않는다
파드 Ready false — 여기서 드러난다
외부 HTTP 코드 503 — 사용자가 겪는 것

A-0 에서는 up 을 「가장 중요한 합성 지표」라고 썼다. 절반만 맞다. up대상이 사라진 것을 잡지만 대상이 살아서 못 쓰는 것은 못 잡는다. 후자가 운영에서 훨씬 흔하다.

알림은 up 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.

확인 — 그럼 readiness 를 지표로 볼 수 있나

sudo 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 만 보고 있으면 알 수 없다. 관측 스택에 빠진 것을 이 실험이 찾아냈다.


5. 복구

5-1. 되돌린다

하기

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

=== 복구 — 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초 간격으로 몇 번 친다

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. 재시작 없이 회복한 것이 맞나

확인

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. 세션이 살아남았나

확인

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 이 그 숫자를 잰다.

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 이 없으므로 직접 지워야 한다

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 -- curlexit 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 DB 강제 종료 정상 정지는 하나도 안 잃었다. 강제 종료는? synchronous_commit OFF 의 대가
A-4 노드 상실 postgreskc-lab-2 에 있다 — 그 노드를 죽이면 A-2 가 함께 일어난다
D-1 백업·복구 여기서는 DB 가 되살아났다. 데이터가 사라졌다면?
관측 스택 kube-state-metrics 가 없어 파드 readiness 가 지표로 안 남는다 — 보완 필요
전부 알림을 up 에 걸지 않는다. readiness 와 외부 응답 코드에 건다