Files
keycloak-pattern/docs/guides/experiments/a8-rolling-restart.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

31 KiB
Raw Blame History

A-8 재현 가이드 — 배포할 때마다 로그아웃되는지 직접 확인한다

해설 문서: docs/experiment-a8-rolling-restart.md · 증거 원문: docs/evidence/a8-rolling-restart/

이 가이드가 끝나면

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

보게 되는 것 어디서
파드가 전부 교체되는 동안 외부가 계속 200 인 것 5초 간격 curl 시계열
재시작 전에 발급한 토큰이 재시작 후에도 통하는 것 상주 탐침 파드
DB 세션 수가 그대로인 것 PostgreSQL OFFLINE_USER_SESSION
캐시만 0 으로 비워지는 것 Prometheus approximate_entries_unique
클러스터가 스스로 다시 붙는 것 vendor_cluster_size
「무중단」이 관측 해상도에 달려 있다는 것 표본이 9개뿐인 시계열

전제

  • 05-keycloak · 06-observability 가 끝나 있다.
  • A-0 을 먼저 하면 좋다. 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • 터미널 두 개를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다), 하나는 재시작·관찰용.

주의 — 이건 파괴적이지 않다. 그래서 더 조심한다

rollout restart정상 작업이다. 되돌릴 것이 없고, 잘못돼도 클러스터가 스스로 회복한다. 전 구간 약 15~20분.

그래서 함정이 다르다. 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고, 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다. 실제로 원래 실행이 그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.

다른 실험과 겹치지 않게 한다. 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.

표시 규약

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

IP·파드 이름·sid·세션 수는 당신 환경에서 다르다. 이 문서는 자리표시자 (<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.


0. 왜 이 실험을 하는가

운영에서 가장 자주 겪는 일이다. 장애가 아니라 정상 작업인데도 사용자가 로그아웃되면 그건 사고다.

   배포한다  →  파드가 교체된다  →  프로세스 메모리가 사라진다
                                        │
                                        └─ 세션이 거기 있었다면?

A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을 세웠다. 그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다. 틀리다면 배포가 곧 전원 로그아웃이다.

예측
A-0 모델 (persistent) 재시작해도 세션 생존
옛 방식 (volatile) 재시작하면 전원 로그아웃

둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다.

그리고 이 실험은 가용성도 같이 잰다. 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제다.


1. 기준선 — 재시작하기 전에

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

넓은 것부터 좁혀 간다.

파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터

1-1. 파드와 나이 — 나이가 판정 근거다

확인

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

형태

NAME                        READY  STATUS   RESTARTS  AGE  IP           NODE
keycloak-0                  1/1    Running  0         2d   10.42.1.94   kc-lab-2
keycloak-1                  1/1    Running  0         2d   10.42.0.45   kc-lab-1
postgres-7b474b88c8-t6rrf   1/1    Running  0         5d   10.42.0.22   kc-lab-1

어디를 봐야 하는가

  • READY 가 둘 다 1/1, RESTARTS0
  • AGE — 이 값을 적어 둔다. 재시작 후 이 값이 초 단위로 바뀌는 것이 「정말 재시작됐다」의 증거다
  • replica 가 2 인 것 — 무중단의 전제다. 1 이면 반드시 끊긴다

이 결과가 의미하는 것rollout restart 는 파드를 삭제하고 새로 만든다. 그래서 RESTARTS안 오른다. 재시작 여부를 RESTARTS 로 보면 「아무 일도 안 일어났다」로 읽는다. AGE 로 본다.

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"

1-2. args 가 ["start"] 인가 — 이 실험의 전제

확인

sudo kubectl -n keycloak-lab get statefulset keycloak \
  -o jsonpath='{.spec.template.spec.containers[0].args}' ; echo

형태

["start"]

어디를 봐야 하는가 — 플래그가 없는 것.

이 결과가 의미하는 것persistent-user-sessions 가 기본으로 켜져 있다. --features-disabled=persistent-user-sessions 가 붙어 있으면 이 실험은 정반대 결과를 낸다 — 그건 A-7 이다. 앞 실험이 되돌리지 않고 끝냈다면 여기서 잡힌다.

1-3. 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"

형태

 offline_flag | count
--------------+-------
 0            |   151

어디를 봐야 하는가offline_flag = '0' 이 온라인 세션이다.

실측01-restart-availability.txt

  DB 세션 수: 151

이 숫자를 적어 둔다. 재시작 후 같은 값이 나오는 것이 4-3 의 판정이다.

숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다. 그래서 이 실험은 개수 말고 특정 sid 하나를 따로 추적한다.

1-4. 상주 탐침 파드 — StatefulSet 밖에 있어야 한다

Keycloak 컨테이너에는 curlwget 도 없다(exit 127).

그리고 이 실험은 탐침이 재시작을 넘어 살아 있어야 한다. 토큰을 재시작 전에 받아서 재시작 후에 써야 하기 때문이다.

   토큰을 어디에 두나
     ├─ Keycloak 파드 안        → 같이 죽는다. 못 쓴다
     ├─ 내 셸 변수              → 되지만 화면·히스토리에 남는다
     └─ 단독 탐침 파드의 /tmp   → StatefulSet 과 무관하게 산다   ★

하기

sudo kubectl -n keycloak-lab run a8-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/a8-probe --timeout=120s

되돌리기

sudo kubectl -n keycloak-lab delete pod a8-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 a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

형태

K0=10.42.1.94 K1=10.42.0.45 PW길이=19

PW길이=0 이면 --env 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.

1-5. ★ 토큰을 파드 안에 보관한다 — 여기가 이 실험의 함정이다

하기 — 로그인해서 응답을 /tmp/tok 에, 거기서 뽑은 값을 /tmp/rt · /tmp/sid

sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p"  /tmp/tok \
     | cut -d. -f2 | base64 -d 2>/dev/null \
     | sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
   echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'

실측01-restart-availability.txt

=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
  sid = XLcgQWRiJrTkuNZcJsNeT_2j

어디를 봐야 하는가두 값이 다 채워졌는지.

출력
rt 1188 bytes / sid XLcg... 정상
rt 1 bytes 빈 문자열 + 개행. 파싱 실패
sid 가 비어 있음 base64 패딩 때문에 잘렸다. sid 없이 진행하고 4-3 은 개수로 본다

★ 원래 실행이 실제로 빠진 함정

처음 재현 절차는 /tmp/tok 에 쓰고 /tmp/rt 를 읽었다. /tmp/rt 를 만드는 줄이 빠져 있었다. 그러면 빈 문자열이 refresh_token= 으로 전송되는데, 그래도 400 이 아니라 통과한 것처럼 보였다.

왜 위험한가 — 이 실험의 판정이 「재시작 후 refresh 가 200 인가」다. 빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로 거짓이 된다. 그리고 그 오류는 아무 에러도 안 낸다.

그래서 길이를 찍는다. wc -c 한 번이 이 실험 전체를 지킨다.

확인 — 못 미더우면 파일을 직접 본다

sudo kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
sudo kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo

형태

-rw-r--r--    1 curl_use curl_gro      1188 Sep  4 13:19 /tmp/rt
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU

어디를 봐야 하는가/tmp/rt 의 크기가 네 자리이고 내용이 eyJ 로 시작하는 것. eyJ 는 base64 로 인코딩된 {" 다. JWT 는 전부 이렇게 시작한다.

1-6. 대조군 — 재시작 전에 refresh 가 되는 것

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

확인

sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
     "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

형태

200

★ 이 refresh 로 토큰이 회전했다. /tmp/rt 의 값은 이제 쓰인 토큰이다. 다시 채워 둔다. 안 그러면 4-1 의 400 이 「재시작 때문」인지 「재사용 때문」인지 구별되지 않는다.

sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=password -d client_id=admin-cli \
     -d username=admin -d "password=$PW" > /tmp/tok
   sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
   sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p"  /tmp/tok \
     | cut -d. -f2 | base64 -d 2>/dev/null \
     | sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
   echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'

이 sid 가 최종 추적 대상이다. 적어 둔다.

확인 — 그 세션이 DB 에 실제로 있는지 지금 본다

sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, created_on, last_session_refresh from offline_user_session
      where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"

형태

     user_session_id      | created_on | last_session_refresh
--------------------------+------------+----------------------
 XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 |           1788495513
(1 row)

어디를 봐야 하는가 — 행이 1개 있고, created_onlast_session_refresh같다. 아직 갱신한 적이 없다.

이 결과가 의미하는 것 — 세션이 DB 에 있다. 재시작 후에 이 행이 그대로 있고 last_session_refresh 만 올라가는 것이 4-2 의 판정이다.

1-7. 캐시와 클러스터 크기

확인

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

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

형태

{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757040000.1,"2"]},
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757040000.1,"2"]}]}}

라벨을 보고 나면 읽기 좋게 자른다. 미검증

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]'

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

확인 — 세션 캐시 엔트리 수도 지금 봐 둔다

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]'

0 이 아닌 값이 나올 것이다. 재시작 후 0 이 되는 것이 4-4 의 판정이다.


2. 주입 — 롤링 재시작

여기부터 상태가 바뀐다. 다만 되돌릴 것은 없다.

되돌리기 — 롤링 재시작은 정상 작업이라 되돌리는 명령이 없다. 중간에 멈추려면 rollout statusCtrl-C 로 끊으면 되지만 롤아웃 자체는 계속 진행된다. 끝날 때까지 두는 편이 낫다. 정말 되돌려야 하면:

sudo kubectl -n keycloak-lab rollout undo statefulset/keycloak

2-1. 가용성 감시를 먼저 띄운다

두 번째 터미널에서 돌린다. 재시작보다 먼저 시작해야 끊김 구간을 놓치지 않는다.

하기

for i in $(seq 1 48); do
  printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \
    https://auth.hyeonworks.com/realms/master)"
  sleep 5
done
echo

어디를 봐야 하는가 — 숫자가 5초마다 하나씩 붙는다. 200 이 아닌 값이 보이면 그 자리가 끊김이다.

여기서 -w '%{http_code}' 를 쓰는 이유 — 48번 반복해서 비교할 값만 필요하기 때문이다. 무엇이 잘못됐는지 알아보려면 그때 curl -v 로 한 번 보면 된다. 두 형태는 용도가 다르다.

--max-time 4 는 5초 간격보다 짧게 잡은 것이다. 타임아웃이 간격보다 길면 요청이 밀려 시계열이 어긋난다.

2-2. 재시작한다

첫 번째 터미널에서 친다.

하기

date '+%H:%M:%S 재시작'
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s

실측01-restart-availability.txt

statefulset.apps/keycloak restarted
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...

어디를 봐야 하는가 — 위 원문은 두 터미널의 출력이 한 파일에 섞여 기록된 것이다. 200 이 가용성 루프, Waiting for...rollout status.

  • 0 out of 21 out of 2complete — 한 번에 하나씩 간다
  • 그 사이사이에 200 이 계속 찍힌다

시각을 반드시 적어 둔다. 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.


3. 주입이 실제로 걸렸는지 확인한다

결과를 해석하기 전에, 정말 재시작됐는지부터 본다. 「세션이 살아남았다」는 결론은 파드가 진짜 바뀌었을 때만 의미가 있다.

3-1. 파드가 정말 새것인가

확인

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

실측02-session-survival.txt

=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0                  1/1   Running   0     44s
keycloak-1                  1/1   Running   0     66s

어디를 봐야 하는가 — 세 가지다.

  • AGE 가 초 단위다 — 1-1 에서 2d 였던 것이 44s 다. 진짜 새 파드다
  • 두 나이가 다르다 (44s vs 66s) — 한 번에 하나씩 내렸다는 증거다. 22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
  • RESTARTS여전히 0 — 파드가 재시작된 게 아니라 교체됐기 때문이다

이 결과가 의미하는 것RESTARTS 를 판정에 쓰면 안 된다는 것이 여기서 보인다. rollout restart 는 파드를 지우고 새로 만들므로 재시작 카운터는 새 파드에서 0 부터 시작한다.

★ 파드 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"

★ 탐침 파드는 다시 띄우면 안 된다. /tmp/rt/tmp/sid 가 같이 사라진다. 탐침 안의 K0 환경변수는 낡았으므로, 새 IP 를 명령줄에 직접 넘긴다. 4절의 명령이 그렇게 되어 있다.

3-2. 가용성 시계열을 읽는다

두 번째 터미널의 출력을 본다.

실측01-restart-availability.txt

  (위 숫자열이 재시작 중 외부 응답 코드의 시계열)
200  200 200 200 200  200 200 200 200

어디를 봐야 하는가200 이 9개. 비200 이 없다.

★ 「무중단」이라고 쓰기 전에 표본 수를 본다

   9개 표본 × 5초 간격  =  약 45초를 9번 들여다본 것
        │
        └─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다

실제로 더 촘촘히 재니 끊김이 나왔다. 후속 작업에서 1초 간격·3초 타임아웃 으로 D-2 롤백 전환을 재보니:

실측experiment-followup-untested-items.md 2절

200 ×24  000  200 ×19

000 은 서버 오류가 아니라 --max-time 3 타임아웃이다. 파드 전환 순간 요청 하나가 3초를 넘겼다.

그래서 정확한 서술은 이것이다.

쓰면 안 되는 문장 정확한 문장
「무중단이었다」 5초 해상도에서 끊김이 관측되지 않았다

더 촘촘히 보고 싶으면 2-1 의 루프를 이렇게 바꾼다. 미검증

for i in $(seq 1 150); do
  printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
    https://auth.hyeonworks.com/realms/master)"
  sleep 1
done
echo

4. 효과를 관찰한다

4-1. ★ 본 시험 — 재시작 전 토큰이 아직 통하는가

하기 — 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다

sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
  'curl -s -w "\n%{http_code}\n" -X POST \
     "http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
     -d grant_type=refresh_token -d client_id=admin-cli \
     -d "refresh_token=$(cat /tmp/rt)"'

실측02-session-survival.txt

=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
  대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
  keycloak-0 에서 refresh  HTTP 200

어디를 봐야 하는가200, 그리고 본문에 새 토큰이 들어 있는 것.

이 결과가 의미하는 것파드가 통째로 바뀌었는데 세션이 그대로다. 새로 뜬 프로세스는 이 세션을 메모리에서 알던 것이 아니다. DB 에서 읽었다.

400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다.

  • 1-6 에서 /tmp/rt 를 다시 안 채웠다 → 이미 쓴 토큰이다
  • rt 1 bytes 를 놓쳤다 → 빈 문자열을 보내고 있다
  • args 에 --features-disabled=persistent-user-sessions 가 있다 → 그건 A-7 이다

셋 다 아니면 그때 결론을 의심한다.

4-2. DB 에 그 세션이 남아 있는가 — sid 로 정확히

확인

sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select user_session_id, created_on, last_session_refresh from offline_user_session
      where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"

실측02-session-survival.txt

=== [4] DB 에 그 세션이 남아 있는가 ===
     user_session_id      | created_on | last_session_refresh
--------------------------+------------+----------------------
 XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 |           1788495577
(1 row)

어디를 봐야 하는가두 숫자의 차이.

   1788495577 - 1788495513 = 64초
        │            │
        │            └─ 재시작 전에 세션이 만들어진 시각
        └─ 재시작 후의 refresh 가 기록된 시각

이 결과가 의미하는 것응답 코드만 200 인 게 아니라 쓰기까지 정상이다. 새 파드가 DB 에서 세션을 읽었고, 갱신 시각을 DB 에 되썼다.

200 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다. A-1 에서 실제로 그런 일이 있었다 — 캐시가 DB 와 무관하게 200 을 준 사례다. 여기서는 DB 행이 갱신됐으므로 그 가능성이 없다.

두 값은 유닉스 시각(초)이다. 사람이 읽는 형태로 보려면:

date -d @1788495513 ; date -d @1788495577

4-3. 전체 세션 수는 그대로인가

확인

sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -tAc "select count(*) from offline_user_session where offline_flag='0'"

실측02-session-survival.txt

  전체 온라인 세션: 151 (재시작 전 151)

어디를 봐야 하는가 — 1-3 에서 적어 둔 값과 같은지.

이 결과가 의미하는 것한 건도 안 잃었다. sid 하나가 살아남은 것과 전체가 살아남은 것은 다른 주장이고, 둘 다 봐야 한다.

관리 API 호출이 세션을 만들기 때문에 몇 건 늘어날 수는 있다. 크게 줄었다면 그게 문제다.

4-4. 캐시는 사라진다 — 그게 정상이다

확인

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]'
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]'

실측02-session-survival.txt

=== [5] 캐시는 어떻게 되었는가 ===
  keycloak-0  sessions 캐시 0.0 건 / cluster_size 2.0
  keycloak-1  sessions 캐시 1.0 건 / cluster_size 2.0

어디를 봐야 하는가 — 세 가지다.

  • 캐시가 0 이다 — 프로세스 메모리라 재시작에 사라졌다
  • keycloak-1 의 1건 — 방금 4-1 의 refresh 를 처리하며 새로 담은 것이다. 0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
  • cluster_size 가 다시 2 — 클러스터가 스스로 재형성됐다

이 결과가 의미하는 것A-0 의 모델이 그대로 확인된다.

   재시작 전:  캐시 N건  +  DB 151건
   재시작 후:  캐시 0건  +  DB 151건      ← 진실은 DB 에 있다

캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다. 룩어사이드 캐시의 성질이다.

Grafana 로 보면 세션 캐시가 0 으로 떨어지고 cluster_size 가 다시 2 가 되는 구간이 한 화면에 잡힌다 — a8-cache-reset-cluster-reformed.png

4-5. 왜 무중단이 되는가

   StatefulSet 롤링 재시작
     │
     ├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
     │     └─ 이 동안 keycloak-0 이 전부 받는다
     ├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
     │
     └─ keycloak-0 종료 → ... (반복)

확인 — 엔드포인트가 실제로 그렇게 움직였나. 재시작 중에 봐야 보인다

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

kubectl get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜬다. endpointslice 를 본다.

이 결과가 의미하는 것한 번에 하나씩 내리므로 항상 최소 하나는 Ready 다. readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로 본 그 메커니즘이 여기서는 정상 작업을 안전하게 만든다.

무중단의 조건 빠지면
replica ≥ 2 하나뿐이면 내리는 동안 아무도 안 받는다
readiness 프로브 아직 기동 중인 파드로 트래픽이 간다

둘 다 있어야 성립한다. 이 실험대는 파드가 2개라서 됐다.


5. 복구

주입이 정상 작업이었으므로 되돌릴 것이 없다. 정리만 한다.

5-1. 탐침 파드를 지운다

하기

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

남겨 두면 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 네임스페이스에 정체 모를 파드가 하나 있는 상태가 된다. 지운다.

5-2. 원상복구 확인표

항목 명령 돌아왔을 때
파드 sudo kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
Service sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소
클러스터 뷰 sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 멤버 (2)
지표 vendor_cluster_size 양쪽 2
세션 psql -tAc "select count(*) from offline_user_session where offline_flag='0'" 1-3 과 비슷한 값
탐침 파드 sudo kubectl -n keycloak-lab get pod a8-probe NotFound
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master 200

이 실험이 재지 않은 것 셋

  • replica 1 에서 어떻게 되는지 — 반드시 끊긴다고 적었지만 재지 않았다
  • 5초보다 짧은 끊김 — 3-2 참조. 후속 작업이 다른 조건에서 000 을 잡았다
  • 캐시가 0 에서 다시 차는 데 걸리는 시간 — 「첫 접근만 느려진다」고 썼지만 그 「느림」을 재지 않았다. A-6 이 인접한 주제다

막히면

전부 이 실험대가 실제로 겪은 증상이다. 지어낸 것은 없다.

증상 원인 확인
refresh 가 200 인데 뭔가 이상하다 /tmp/rt 가 비어 있다. 빈 토큰인데 통과한 것처럼 보인다 wc -c < /tmp/rt — 1-5
refresh 가 400 Session not active 1-6 뒤에 /tmp/rt 를 안 채웠다. 이미 쓴 토큰이다 새로 로그인해서 다시 담는다
refresh 가 400 인데 토큰은 맞다 args 가 volatile 이다 get statefulset ... args — 1-2. 그건 A-7
재시작 후 아무 데도 안 닿는다 파드 IP 가 바뀌었다 get pod -o jsonpath='{.status.podIP}' 다시 — 3-1
탐침을 다시 띄웠더니 토큰이 없다 /tmp/rt 가 파드와 함께 사라졌다 탐침은 재시작 내내 유지한다 — 3-1
RESTARTS 가 0 이라 재시작이 안 된 것 같다 rollout restart 는 파드를 교체한다 AGE 로 본다 — 3-1
rollout status 가 타임아웃 파드가 Ready 를 못 받는다 describe pod 의 Events, logs --previous
가용성 루프에 000 이 섞인다 --max-time 초과. 서버 오류가 아니다 간격보다 짧은 타임아웃인지 — 2-1
가용성 루프가 전부 000 루프가 잘못된 URL 을 친다 curl -v 로 한 번 본다
세션 수가 크게 줄었다 다른 실험이 세션을 지웠거나 volatile 이다 1-2 · 1-3 을 다시
DB 행의 last_session_refresh 가 안 올랐다 4-1 을 하기 전에 조회했다 순서: refresh → 조회
kubectl get endpoints 가 경고를 찍는다 v1.33 부터 deprecated get endpointslice -l kubernetes.io/service-name=...
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다

다음

실험 A-8 이 남긴 질문
A-7 volatile 비교 이 실험을 그대로 반복하면 정반대가 나와야 한다. 그 한 쌍이 「왜 persistent 인가」의 답이다
D-2 버전 업그레이드 롤링 재시작이 안전하다는 것이 업그레이드의 전제
A-2 DB 정지 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다
구성 무중단은 공짜가 아니라 replica ≥ 2 + readiness 의 조합이다