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

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

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

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

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

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

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

39 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
0b64d23a-f82a-44b4-ad54-e079578977c4 SETUP reproduce-a8-rolling-restart 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다 session-custody-across-nodes Keycloak 두 노드가 같은 세션을 읽는 경로 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/0b64d23a-f82a-44b4-ad54-e079578977c4/edit
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-8
cdac9b8178391311d8eca1ebc6cac15bb62d79af

롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다

rollout restart 로 파드 둘을 교체한 뒤에도 토큰이 아직 통하는지 보는 절차다. 로그인해서 refresh token 과 sid 를 탐침 파드 안 파일에 담아 두고 교체한 다음 그 토큰을 쓴다. 외부 응답 시계열도 함께 잰다. 전 구간 15~20분이고 되돌릴 것이 없다.

관계

  • 롤링 재시작은 세션을 남기고 캐시만 지웠다 이 절차가 재는 것을 그 기록이 결론으로 적는다.
  • persistent-user-sessions 가 세션의 거처를 정한다 세션이 살아남는 까닭이 그 기능이고, 그것이 꺼져 있으면 이 절차는 정반대 답을 낸다.
  • 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다 여기의 가용성 루프가 그 점검에서 실행 가능한 형태로 고쳐진 명령 가운데 하나다.
  • persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다 같은 시험을 옛 기본값 위에서 치면 200400 Session not active 로 바뀐다.
  • 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다 「세션은 DB 에 있고 캐시는 사본」이라는 모델을 거기서 세웠고, 여기서 파드를 통째로 갈아 그 모델을 확인한다.

본문

읽기 전에 — 어디서 치는가

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

원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

터미널은 둘을 연다. 하나는 가용성 감시용이라 루프가 도는 동안 붙잡혀 있고, 하나는 재시작과 관찰용이다.

무엇
네임스페이스 keycloak-lab · 관측 스택은 observability
대상 StatefulSet keycloak 파드 둘 · Deployment postgres 하나
탐침 파드 a8-probecurlimages/curl:8.11.1, sleep 7200, --restart=Never
전제 args ["start"] — 플래그가 붙어 있으면 이 절차가 아니다
가용성 루프 5초 간격 48회 · --max-time 4 · 외부 진입점으로
무중단의 전제 replica 2 와 readiness 프로브
도구 jq 가 이 실험대에 없다. Prometheus 출력은 trgrep 으로 자른다

이 실험이 가르는 것

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

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

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

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

둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다. 그리고 이 절차는 가용성도 같이 잰다 — 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제가 된다.

이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 파드가 전부 교체되는 동안 외부가 계속 200 인 것, 재시작 전에 발급한 토큰이 재시작 후에도 통하는 것, DB 세션 수가 그대로인 것, 캐시만 0 으로 비워지는 것, 클러스터가 스스로 다시 붙는 것, 그리고 「무중단」이 관측 해상도에 달려 있다는 것.

전제와 되돌리기

앞선 구축 단계 05-keycloak06-observability 가 끝나 있어야 한다. A-0 을 먼저 하면 좋다 — 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.

이건 파괴적이지 않다. 그래서 더 조심한다. rollout restart 는 정상 작업이고 되돌릴 것이 없으며 잘못돼도 클러스터가 스스로 회복한다. 그 대신 함정이 다르다 — 재는 것이 「안 깨졌나」라서 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽고, 원래 실행이 실제로 그랬다.

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

정말 되돌려야 하면 이 명령이 있다. 다만 중간에 rollout statusCtrl-C 로 끊어도 롤아웃 자체는 계속 진행되므로 끝날 때까지 두는 편이 낫다.

kubectl -n keycloak-lab rollout undo statefulset/keycloak

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

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

1. 파드와 나이와 replica 수를 적어 둔다

목적 — 재시작 전의 AGE 를 확보하고 replica 가 2 인지 확인한다.

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 를 적어 둔다. 재시작 후 이 값이 초 단위로 바뀌는 것으로 「정말 재시작됐다」를 판정한다. keycloak 파드가 둘인 것도 함께 본다. 그것이 무중단의 전제이고 하나면 반드시 끊긴다.

왜 필요한가rollout restart 는 파드를 삭제하고 새로 만들기 때문에 RESTARTS 가 안 오른다. 재시작 여부를 RESTARTS 로 보면 아무 일도 안 일어났다고 읽게 된다.

문제가 생기면keycloak 파드가 하나뿐이면 이 절차의 가용성 측정은 성립하지 않는다.

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"

2. args 가 기본값인지 확인한다

목적persistent-user-sessions 가 켜져 있는 상태에서 재는지 본다.

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 가 붙어 있으면 이 절차는 정반대 결과를 낸다.

문제가 생기면 — 플래그가 보이면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린 뒤에 시작한다.

3. DB 세션 수를 적어 둔다

목적 — 재시작 후에 견줄 값을 확보한다.

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"

예상 결과

  DB 세션 수: 151

왜 필요한가 — 재시작 후 같은 값이 나오는지가 뒤의 판정에 들어간다. 숫자는 환경마다 다르고 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 그래서 이 절차는 개수 말고 특정 sid 하나를 따로 추적한다.

문제가 생기면(0 rows) 가 나오면 세션이 없거나 volatile 이다. 2번으로 돌아간다.

4. 상주 탐침 파드를 StatefulSet 밖에 띄운다

목적 — 재시작을 넘어 토큰을 들고 있을 장치를 만든다.

   토큰을 어디에 두나
     ├─ Keycloak 파드 안        → 같이 죽는다. 못 쓴다
     ├─ 내 셸 변수              → 되지만 화면·히스토리에 남는다
     └─ 단독 탐침 파드의 /tmp   → StatefulSet 과 무관하게 산다   ★
kubectl -n keycloak-lab run a8-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/a8-probe --timeout=120s

비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
19
kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'

예상 결과 — 두 IP 가 보이고 PW길이 가 0 이 아니다.

왜 필요한가 — Keycloak 컨테이너에는 curlwget 도 없어서 kubectl exec keycloak-0 -- curlexit 127 로 끝난다.

문제가 생기면PW길이=0 이면 --env 가 빈 값을 받았다. 파드를 지우고 ① 부터 다시 한다. --rm 이 없는 상주 파드라 지우지 않으면 같은 이름이 그대로 있어 ① 이 AlreadyExists 로 거절되고, 이 절차를 두 번째 칠 때도 같은 곳에서 걸린다.

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

5. 토큰과 sid 를 파드 안에 담고 길이를 확인한다

목적 — 재시작을 넘겨 쓸 값을 파일에 남기고, 그 파일이 비어 있지 않은지 본다.

이 단계에 이 실험의 함정이 있다.

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)"'

예상 결과

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

두 값이 다 채워졌는지 본다.

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

왜 필요한가 — 원래 실행이 실제로 빠진 함정이 여기 있다. 첫 재현 절차는 /tmp/tok 에 쓰고 /tmp/rt 를 읽었는데 /tmp/rt 를 만드는 줄이 빠져 있었다. 그러면 빈 문자열이 refresh_token= 으로 전송되는데, 그래도 400 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다. 이 실험의 판정이 「재시작 후 refresh 가 200 인가」이므로, 빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로 거짓이 된다. wc -c 한 번이 이 시험 전체를 지킨다.

못 미더우면 파일을 직접 본다.

kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
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 는 전부 이렇게 시작한다.

문제가 생기면rt 1 bytescat /tmp/tok 으로 응답 본문을 본다.

6. 대조군 — 재시작 전에 refresh 가 되는 것을 본다

목적 — 뒤의 200 이 무엇과 견준 값인지 확보한다.

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 의 값은 이제 이미 쓴 토큰이라 다시 채워야 하고, 안 채우면 뒤의 400 이 재시작 때문인지 재사용 때문인지 구별되지 않는다. 5번의 ① 과 같은 명령을 그대로 다시 친다.

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 가 최종 추적 대상이다. 7번부터 끝까지 이 값을 쓰므로 적어 둔다. ① 의 200 은 대조군이고, 그 대조군을 잡느라 소비한 토큰을 ② 가 메운다.

문제가 생기면 — ① 에서 400 이 나오면 5번의 파일 확인으로 돌아간다. ② 의 출력이 rt 1 bytes 면 5번의 ② 로 파일을 직접 본다.

7. 그 세션이 지금 DB 에 있는지 sid 로 본다

목적 — 재시작 전의 행 상태를 기록한다.

원 가이드의 질의는 sid 를 셸 치환으로 집어넣어 psql -c 문자열 안에 kubectl exec 이 한 번 더 들어간다. 따라 하는 사람은 방금 적어 둔 sid 를 그대로 친다 — 앞 명령이 이미 그 값을 화면에 보여 줬고, 명령 하나가 한 가지 일만 한다. 이 두 단계 형태는 이 실험대에서 치지 않았다(unknown).

kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid
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='{{SID}}'"

sid 는 로그인할 때마다 새로 생긴다. 이 실험대의 값은 XLcgQWRiJrTkuNZcJsNeT_2j 였다(observed).

예상 결과 — 모양은 이렇고 값은 환경마다 다르다.

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

행이 1개 있고 created_onlast_session_refresh 가 같다. 아직 갱신한 적이 없다.

왜 필요한가 — 재시작 후에 이 행이 그대로 있고 last_session_refresh 만 올라가는 것이 뒤의 판정이다.

문제가 생기면(0 rows) 가 나오면 sid 를 잘못 옮겼거나 그 세션이 이미 사라졌다. 5번부터 다시 한다.

8. 캐시와 클러스터 크기를 미리 본다

목적 — 재시작 후 0 이 되는 값을 먼저 확보한다.

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

모양은 이렇고 값은 환경마다 다르다.

{"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"]}]}}

라벨을 보고 나면 읽기 좋게 자른다. 아래 형태는 가이드가 미검증으로 표시한 줄이다(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]'

예상 결과 — 결과가 두 줄이고 값이 둘 다 2 다. 세션 캐시 엔트리 수도 같은 형태로 보면 0 이 아닌 값이 나온다.

왜 필요한가 — 재시작 후 캐시가 0 이 되고 클러스터 크기가 다시 2 로 돌아오는 것이 뒤의 판정이다.

문제가 생기면 — 빈 결과가 오면 0 이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.

주입

9. 가용성 감시를 먼저 띄우고 재시작한다

목적 — 재시작 중 외부 응답을 5초 간격으로 기록하면서 파드를 교체하고, 그 사이에 엔드포인트가 어떻게 움직이는지 본다.

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

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초 간격보다 짧게 잡은 것인데, 타임아웃이 간격보다 길면 요청이 밀려 시계열이 어긋난다.

첫 번째 터미널에서 재시작한다. rollout restart 는 바로 돌아오고, 파드 교체는 그 뒤에 백그라운드로 진행된다.

date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak

엔드포인트는 여기서 봐야 보인다. 파드가 서비스에서 빠졌다 돌아오는 것은 롤아웃이 도는 동안에만 나타나고, 끝난 뒤에 치면 ready 주소가 늘 둘로 나온다. 두 번째 터미널은 ① 의 루프에 붙잡혀 있으므로 이 터미널에서 몇 번 반복해서 친다. 찍힌 것을 어떻게 읽는지는 15번에서 적는다.

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 -n keycloak-lab rollout status statefulset/keycloak --timeout=420s

예상 결과 — 아래는 두 터미널의 출력이 한 파일에 섞여 기록된 것이다. 200 이 가용성 루프, Waiting for...rollout status 다. 이 실험대는 ②④ 를 한 블록으로 연달아 쳤고 아래는 그때의 출력이다. 사이에 ③ 을 끼우면 rollout status 가 그만큼 늦게 시작하므로 Waiting for 줄 수가 이와 다를 수 있다.

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

0 out of 2 에서 1 out of 2 를 거쳐 complete 로 한 번에 하나씩 가고 그 사이사이에 200 이 계속 찍힌다.

왜 필요한가 — ① 을 ② 보다 늦게 띄우면 첫 파드가 내려가는 구간을 통째로 놓친다. ③ 도 마찬가지로 ④ 뒤로 밀면 놓친다. 시각도 반드시 적어 둔다.

문제가 생기면 — ④ 가 타임아웃이면 파드가 Ready 를 못 받고 있다. describe pod 의 Events 와 logs --previous 를 본다. ④ 를 Ctrl-C 로 끊어도 롤아웃 자체는 계속 진행된다.

주입 검증

10. 파드가 진짜 바뀌었는지 AGE 로 본다

목적 — 「세션이 살아남았다」가 의미를 갖는 조건을 확인한다.

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 가 초 단위인 것(앞에서 2d 였던 것이 44s 다), 두 나이가 다른 것(44s66s 의 22초 차이가 롤링의 간격이고, 둘이 같으면 동시에 내려간 것이라 무중단이 아니다), 그리고 RESTARTS 가 여전히 0 인 것.

왜 필요한가rollout restart 는 파드를 지우고 새로 만들므로 재시작 카운터가 새 파드에서 0 부터 시작한다. 판정에 RESTARTS 를 쓰면 안 된다는 것이 여기서 드러난다.

문제가 생기면AGE 가 예전 값이면 롤아웃이 안 끝났다. 9번의 ④ 로 돌아간다.

파드 IP 가 바뀌었으므로 다시 잡는다. 탐침 파드는 다시 띄우지 않는다 — /tmp/rt/tmp/sid 가 같이 사라진다. 탐침 안의 K0 환경변수는 낡았으므로 새 IP 를 명령줄로 넘긴다.

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"

11. 가용성 시계열을 읽고 표본 수를 센다

목적 — 끊김이 관측됐는지 보고, 그 관측이 무엇까지 말할 수 있는지 정한다.

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

200  200 200 200 200  200 200 200 200

예상 결과200 이 9개이고 비200 이 없다.

루프는 48회로 잡았는데 남은 표본은 9개다. 원 기록이 그 차이를 설명하지 않는다(unknown) — 루프를 중간에 끊었는지, 기록에 앞부분만 옮겼는지 알 수 없다. 표본 수를 셀 때는 루프 횟수가 아니라 화면에 실제로 찍힌 개수를 센다.

왜 필요한가 — 「무중단」이라고 쓰기 전에 표본 수를 본다.

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

실제로 더 촘촘히 재니 끊김이 나왔다. 후속 작업에서 1초 간격과 3초 타임아웃으로 다른 전환을 재 본 값이 이렇다.

200 ×24  000  200 ×19

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

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

더 촘촘히 보고 싶으면 루프를 이렇게 바꾼다. 가이드가 미검증으로 표시한 형태다(unknown).

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

문제가 생기면 — 루프가 전부 000 이면 잘못된 URL 을 치고 있다. curl -v 로 한 번 본다.

관찰

12. 본 시험 — 재시작 전 토큰이 아직 통하는가

목적 — 파드 안에 보관해 둔 토큰을 새 파드 IP 로 보낸다.

셸 인용이 세 겹이 되는 형태이고, 가이드는 여기에 다른 형태를 제시하지 않는다. 탐침을 다시 띄우면 토큰이 사라지기 때문이다.

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 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다. 대조군 시험 뒤에 /tmp/rt 를 다시 안 채웠거나, rt 1 bytes 를 놓쳤거나, args 에 --features-disabled=persistent-user-sessions 가 있거나 셋 중 하나다. 셋 다 아니면 그때 결론을 의심한다.

문제가 생기면 — 아무 데도 안 닿으면 파드 IP 가 바뀐 것을 명령에 반영하지 않았다. 10번의 IP 잡기를 다시 한다.

2026-09-17 실측(observed) — 롤링 재시작 전후로 같은 토큰을 썼다.

재시작 전 대조군            200
재시작 (파드 둘 교체)       AGE 31s · 63s · IP 10.42.1.47→.49, 10.42.0.20→.21
재시작 뒤 같은 refresh      200 · session_state 가 같은 sid
DB 행                       created_on 1789625835 그대로 · last_session_refresh 1789625835 → 1789625952
캐시                        재시작 전후 모두 keycloak-0/1 sessions 0
클러스터                    다시 2

created_on 은 그대로이고 last_session_refresh 만 117초 뒤로 갔다. 새로 만든 세션이 아니라 남아 있던 행을 새 파드가 읽어서 갱신한 것이고, 그것이 이 편의 결론이다.

탐침 안의 $K0 는 재시작 뒤에 낡는다. 2026-09-17 에 낡은 값으로 쳐서 000 을 받았다(observed) — 문서가 미리 적어 둔 그 함정이다. 새 IP 를 명령줄로 넘겨 다시 쳤다.

새 K0=10.42.1.49 (탐침 안의 낡은 값=10.42.1.47)

13. DB 행의 두 시각을 견준다

목적 — 응답 코드만이 아니라 쓰기까지 정상인지 본다.

적어 둔 sid 를 넣어 7번의 ② 와 같은 질의를 다시 친다.

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='{{SID}}'"

예상 결과

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

두 숫자의 차이를 본다.

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

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

date -d @1788495513 ; date -d @1788495577

왜 필요한가200 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다. A-1 에서 실제로 그런 일이 있었다. 여기서는 새 파드가 DB 에서 세션을 읽었고 갱신 시각을 DB 에 되썼으므로 그 가능성이 없다.

문제가 생기면last_session_refresh 가 안 올랐으면 본 시험을 하기 전에 조회했다. 순서는 refresh 를 먼저 하고 조회한다.

전체 세션 수도 함께 본다.

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)

3번에서 적어 둔 값과 같다. 한 건도 안 잃었다. sid 하나가 살아남은 것과 전체가 살아남은 것은 다른 주장이라 둘 다 본다. 관리 API 호출이 세션을 만들기 때문에 몇 건 늘어날 수는 있고, 크게 줄었다면 그게 문제가 된다.

14. 캐시가 비워지고 클러스터가 다시 붙는 것을 본다

목적 — 재시작이 무엇을 지우고 무엇을 남겼는지 가른다.

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]'
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건(방금 refresh 를 처리하며 새로 담은 값이므로 0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다), 그리고 cluster_size 가 다시 2 인 것.

왜 필요한가 — A-0 의 모델이 여기서 그대로 확인된다.

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

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

문제가 생기면cluster_size1 에서 안 올라오면 클러스터가 다시 안 붙었다. 파드 로그에서 멤버 수를 본다.

15. 무중단이 되는 까닭을 엔드포인트에서 본다

목적 — 파드가 서비스에서 언제 빠지고 언제 돌아오는지 본다.

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

실제로 그렇게 움직이는지는 재시작 중에 쳐야 보인다. 그 명령이 9번의 ③ 이므로 여기서 읽는 것은 그때 화면에 찍힌 값이다. 지금 다시 쳐도 롤아웃이 이미 끝났으므로 ready 주소는 둘로만 나온다.

예상 결과 — 재시작 중에는 ready 주소가 하나로 줄었다가 둘로 돌아온다.

왜 필요한가 — 한 번에 하나씩 내리므로 항상 최소 하나는 Ready 이고, readiness 프로브가 이 전환을 맞춰 준다. A-2 에서 장애를 격리하는 장치로 본 그 메커니즘이 여기서는 정상 작업을 안전하게 만든다.

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

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

문제가 생기면kubectl get endpoints 는 쓰지 않는다. v1.33 부터 deprecated 라 경고가 뜨므로 endpointslice 를 본다.

복구와 원상복구 확인표

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

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

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

항목 명령 돌아왔을 때
파드 kubectl -n keycloak-lab get pods -o wide keycloak 둘 다 1/1 Running
Service kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ready 주소 둘
클러스터 뷰 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'" 재시작 전과 비슷한 값
탐침 파드 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

막히면

아래는 이 실험대가 실제로 겪은 증상이다. 마지막 줄만 A-2·A-3 에서 겪은 것을 옮겼다 — 탐침 파드를 같은 방식으로 띄우므로 여기서도 그대로 걸린다.

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

무엇이 관측이고 무엇이 아닌가

이 실험대가 실제로 본 것(observed)은 재시작 전 DB 세션 151sid = XLcgQWRiJrTkuNZcJsNeT_2j, 비밀번호 길이 19, rollout status 와 가용성 루프가 섞인 출력 전문, 재시작 뒤 파드 나이 44s66sRESTARTS 0, 가용성 시계열의 200 아홉 개, 재시작 전 토큰의 HTTP 200, DB 행의 1788495513 에서 1788495577 로의 변화, 전체 세션 151 (재시작 전 151), 캐시 0.01.0cluster_size 2.0, 후속 작업의 200 ×24 000 200 ×19 다.

가이드가 미검증으로 표시한 것(unknown)은 tr ',' '\n' | grep -E 로 자른 Prometheus 출력과 1초 간격·3초 타임아웃 루프다. sid 를 화면에서 읽어 질의에 직접 넣는 두 단계 형태도 이 실험대에서 치지 않았다.

원래 실행이 실제로 빠졌던 곳이 하나 있다. 첫 재현 절차에 /tmp/rt 를 만드는 줄이 없어서 빈 문자열이 refresh_token= 으로 전송됐는데, 400 이 아니라 통과한 것처럼 보였고 아무 에러도 안 났다.

해상도에 걸린 주장이 하나 있다. 「무중단」이 아니라 「5초 해상도에서 끊김이 관측되지 않았다」이고 표본은 9개다. 1초 간격으로 잰 후속 작업은 다른 조건에서 000 을 하나 잡았다. 루프를 48회로 돌렸는데 표본이 9개인 까닭은 원 기록에 없어서 여기서도 못 적는다(unknown).

이 절차가 재지 않은 것이 셋이다. replica 1 에서 어떻게 되는지(반드시 끊긴다고 적었지만 재지 않았다), 5초보다 짧은 끊김, 그리고 캐시가 0 에서 다시 차는 데 걸리는 시간이다. 「첫 접근만 느려진다」고 썼지만 그 느림을 재지 않았고, A-6 이 인접한 주제다.