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>
31 KiB
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에서 친다.kubectl은sudo로 쓴다. - 터미널 두 개를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다), 하나는 재시작·관찰용.
주의 — 이건 파괴적이지 않다. 그래서 더 조심한다
rollout restart 는 정상 작업이다. 되돌릴 것이 없고, 잘못돼도 클러스터가
스스로 회복한다. 전 구간 약 15~20분.
그래서 함정이 다르다. 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고, 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다. 실제로 원래 실행이 그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.
다른 실험과 겹치지 않게 한다. 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 13:19–13: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,RESTARTS가0AGE— 이 값을 적어 둔다. 재시작 후 이 값이 초 단위로 바뀌는 것이 「정말 재시작됐다」의 증거다- 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 컨테이너에는 curl 도 wget 도 없다(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_on 과
last_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 status 를 Ctrl-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 2→1 out of 2→complete— 한 번에 하나씩 간다- 그 사이사이에
200이 계속 찍힌다
시각을 반드시 적어 둔다. 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.
3. 주입이 실제로 걸렸는지 확인한다
결과를 해석하기 전에, 정말 재시작됐는지부터 본다. 「세션이 살아남았다」는 결론은 파드가 진짜 바뀌었을 때만 의미가 있다.
3-1. 파드가 정말 새것인가
확인
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
어디를 봐야 하는가 — 세 가지다.
AGE가 초 단위다 — 1-1 에서2d였던 것이44s다. 진짜 새 파드다- 두 나이가 다르다 (
44svs66s) — 한 번에 하나씩 내렸다는 증거다. 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)"'
=== [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)'"
=== [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'"
전체 온라인 세션: 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]'
=== [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 -- curl 이 exit 127 |
Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
다음
| 실험 | A-8 이 남긴 질문 |
|---|---|
| A-7 volatile 비교 | 이 실험을 그대로 반복하면 정반대가 나와야 한다. 그 한 쌍이 「왜 persistent 인가」의 답이다 |
| D-2 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 전제다 |
| A-2 DB 정지 | 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다 |
| 구성 | 무중단은 공짜가 아니라 replica ≥ 2 + readiness 의 조합이다 |