기반 가이드 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>
39 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 | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 |
|
|
cdac9b8178391311d8eca1ebc6cac15bb62d79af |
롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
rollout restart 로 파드 둘을 교체한 뒤에도 토큰이 아직 통하는지 보는 절차다. 로그인해서 refresh token 과 sid 를 탐침 파드 안 파일에 담아 두고 교체한 다음 그 토큰을 쓴다. 외부 응답 시계열도 함께 잰다. 전 구간 15~20분이고 되돌릴 것이 없다.
관계
- 롤링 재시작은 세션을 남기고 캐시만 지웠다 이 절차가 재는 것을 그 기록이 결론으로 적는다.
- persistent-user-sessions 가 세션의 거처를 정한다 세션이 살아남는 까닭이 그 기능이고, 그것이 꺼져 있으면 이 절차는 정반대 답을 낸다.
- 산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다 여기의 가용성 루프가 그 점검에서 실행 가능한 형태로 고쳐진 명령 가운데 하나다.
- persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
같은 시험을 옛 기본값 위에서 치면
200이400 Session not active로 바뀐다. - 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다 「세션은 DB 에 있고 캐시는 사본」이라는 모델을 거기서 세웠고, 여기서 파드를 통째로 갈아 그 모델을 확인한다.
본문
읽기 전에 — 어디서 치는가
명령은 전부 [lab host] 에서 kubectl 로 친다. 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. 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] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
터미널은 둘을 연다. 하나는 가용성 감시용이라 루프가 도는 동안 붙잡혀 있고, 하나는 재시작과 관찰용이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | keycloak-lab · 관측 스택은 observability |
| 대상 | StatefulSet keycloak 파드 둘 · Deployment postgres 하나 |
| 탐침 파드 | a8-probe — curlimages/curl:8.11.1, sleep 7200, --restart=Never |
| 전제 args | ["start"] — 플래그가 붙어 있으면 이 절차가 아니다 |
| 가용성 루프 | 5초 간격 48회 · --max-time 4 · 외부 진입점으로 |
| 무중단의 전제 | replica 2 와 readiness 프로브 |
| 도구 | jq 가 이 실험대에 없다. Prometheus 출력은 tr 과 grep 으로 자른다 |
이 실험이 가르는 것
운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다.
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
│
└─ 세션이 거기 있었다면?
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을 세웠다. 그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 하고, 틀리다면 배포가 곧 전원 로그아웃이다.
| 예측 | |
|---|---|
| A-0 모델 (persistent) | 재시작해도 세션 생존 |
| 옛 방식 (volatile) | 재시작하면 전원 로그아웃 |
둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다. 그리고 이 절차는 가용성도 같이 잰다 — 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제가 된다.
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 파드가 전부 교체되는 동안 외부가 계속 200 인 것, 재시작 전에 발급한 토큰이 재시작 후에도 통하는 것, DB 세션 수가 그대로인 것, 캐시만 0 으로 비워지는 것, 클러스터가 스스로 다시 붙는 것, 그리고 「무중단」이 관측 해상도에 달려 있다는 것.
전제와 되돌리기
앞선 구축 단계 05-keycloak 과 06-observability 가 끝나 있어야 한다. A-0 을 먼저 하면 좋다 — 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
이건 파괴적이지 않다. 그래서 더 조심한다. rollout restart 는 정상 작업이고 되돌릴 것이 없으며 잘못돼도 클러스터가 스스로 회복한다. 그 대신 함정이 다르다 — 재는 것이 「안 깨졌나」라서 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽고, 원래 실행이 실제로 그랬다.
다른 실험과 겹치지 않게 한다. 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.
정말 되돌려야 하면 이 명령이 있다. 다만 중간에 rollout status 를 Ctrl-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, RESTARTS 가 0, 그리고 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 컨테이너에는 curl 도 wget 도 없어서 kubectl exec keycloak-0 -- curl 은 exit 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 bytes 면 cat /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_on 과 last_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 다), 두 나이가 다른 것(44s 와 66s 의 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_size 가 1 에서 안 올라오면 클러스터가 다시 안 붙었다. 파드 로그에서 멤버 수를 본다.
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 -- curl 이 exit 127 |
Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
a8-probe 를 다시 못 만든다 |
앞선 실행의 파드가 그 이름으로 남아 있다 | delete pod a8-probe --ignore-not-found |
무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 재시작 전 DB 세션 151 과 sid = XLcgQWRiJrTkuNZcJsNeT_2j, 비밀번호 길이 19, rollout status 와 가용성 루프가 섞인 출력 전문, 재시작 뒤 파드 나이 44s 와 66s 및 RESTARTS 0, 가용성 시계열의 200 아홉 개, 재시작 전 토큰의 HTTP 200, DB 행의 1788495513 에서 1788495577 로의 변화, 전체 세션 151 (재시작 전 151), 캐시 0.0 과 1.0 및 cluster_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 이 인접한 주제다.