Studio 의 블록 편집기는 본문을 화면에 풀 때 공백을 정규화한다. 이어진 빈 줄 둘을 하나로 줄이고, 닫는 코드펜스 뒤에 빈 줄이 없으면 하나를 넣는다. 저장된 값은 저장소와 바이트가 같지만, 사람이 편집 화면을 열고 저장을 누르는 순간 그 공백이 저장소와 갈린다 — 내용은 그대로인데 SHA 만 달라져서 어느 쪽이 정본인지 알 수 없게 된다. setup 35편을 훑어 9편 11곳을 찾아 저장소 쪽을 편집기와 같은 모양으로 맞췄다. 빈 줄만 움직였고 내용 줄은 하나도 바꾸지 않았다(추가 2 · 삭제 9). - 이어진 빈 줄 둘 → 하나: d4 · d4a(3곳) · a7 · b7a · c2 · b0 · tear-down - 닫는 펜스 뒤 빈 줄 추가: c1 · create-three-guests 코드블록 안의 빈 줄과 frontmatter 는 건드리지 않았다. 관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 · verify-tech-log-tree error 0 · verify-project-layout error 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
56 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 | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| b8d7db33-afdc-4e49-9eab-ed4edbbe398e | SETUP | reproduce-a7-volatile-comparison | persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다 | session-custody-across-nodes | Keycloak 두 노드가 같은 세션을 읽는 경로 | keycloak-session-store | 게시 전 | https://hyeonworks.com/studio/documents/b8d7db33-afdc-4e49-9eab-ed4edbbe398e/edit |
|
|
cdac9b8178391311d8eca1ebc6cac15bb62d79af |
persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
persistent-user-sessions 를 끈 옛 기본값 위에서 A층 실험 넷을 다시 치는 절차다. A-0 과 A-1 과 A-2 와 A-8 을 명령 한 글자도 바꾸지 않고 그대로 친다. 교차 노드 refresh 는 200 인데 DB 세션 행은 (0 rows) 가 된다. 전 구간 40~60분.
관계
- 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
이 절차의 마지막 측정값
① 500 · ② 200이 조건부라는 것을 그 기록이 확정한다. - persistent-user-sessions 가 세션의 거처를 정한다 여기서 끄고 켜는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
- 버전과 설정을 결과와 함께 적는다 같은 명령이 26.7.0 기본값과 옛 기본값에서 정반대 답을 내므로, 결과만 옮겨 적으면 틀린 말이 된다.
- 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
이 절차가 남긴
500의 원인을 확정하는 후속 절차이고, 주입도 복구도 따로 선다. - 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
기본값에서 그 시험이
200인 것을 먼저 재 둬야 여기의400이 뒤집힘으로 읽힌다. - 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다 기본값에서 세션 공유가 안 깨지던 그 주입을 여기서 다시 건다. 이번에는 깨진다.
본문
읽기 전에 — 어디서 치는가
명령을 치는 곳이 둘이다. 대부분은 [lab host] 에서 kubectl 로 치고, iptables 만 노드 자체를 건드리므로 kc-lab-1 과 kc-lab-2 에 각각 들어가 친다. 코드블록마다 label 로 어디서 치는지 붙였다.
[kc-lab-1] 라벨이 붙은 블록은 게스트 셸이다. lab host 에서 ssh kc-lab-1 로 들어가서 치고, 끝나면 exit 로 나온다. ssh kc-lab-2 '…' 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 [lab host] 로 두었다.
원 가이드는 이 명령들을 kc-lab-1 에서 치라고 적었다. 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 sudo 없는 kubectl 이 permission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
kubectl 에 sudo 를 붙이지 않는다. 실험 폴더의 README 가 까닭을 적는다 — sudo 를 붙이면 root 환경으로 돌아 그 kubeconfig 를 못 본다. root 홈에는 ~/.kube/config 가 없으므로 localhost:8080 으로 붙으려다 connection refused 로 끝나고, 그러면 클러스터가 아니라 누구의 설정 파일을 읽느냐가 문제인데 클러스터를 의심하게 된다.
kubectl -n keycloak-lab get pods # 이렇게
sudo kubectl -n keycloak-lab get pods # 이렇게 치면 안 된다
| 무엇 | 값 |
|---|---|
| 네임스페이스 | keycloak-lab · 관측 스택은 observability |
| 대상 | StatefulSet keycloak 파드 둘 · Deployment postgres 하나 |
| 탐침 파드 | a7-probe — curlimages/curl:8.11.1, sleep 7200, --restart=Never |
| 끄는 기능 | --features-disabled=persistent-user-sessions |
| 막는 포트 | 7800 과 57800 을 raw PREROUTING 에서 양방향으로 |
| 도구 | jq 가 이 실험대에 없다. Prometheus 출력은 tr 과 grep 으로 자른다 |
터미널은 둘을 연다. 하나는 관찰용, 하나는 대기용이다.
이 실험이 가르는 것
A층의 결론 여섯은 전부 하나의 전제 위에 있다.
Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다
│
├─ A-0 세션은 PostgreSQL 에 있다
├─ A-1 7800 을 끊어도 세션 공유가 안 깨진다
├─ A-2 DB 를 내리면 로그인이 실패한다
└─ A-8 롤링 재시작을 해도 세션이 산다
A-1 은 인터넷 자료의 통념과 어긋난 답을 냈고 그 까닭을 「26 이 기본값을 바꿨기 때문」이라고 설명했다. 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해야 판정된다. 자료가 틀린 것이 아니라 버전이 다른 것이라면 옛 설정에서는 통념이 맞아야 한다.
persistent (KC 25+, 26 기본) volatile (KC 24 이전)
로그인 ─▶ PostgreSQL (진실) 로그인 ─▶ Infinispan (진실)
조회 ─▶ 캐시 없으면 DB 조회 ─▶ 클러스터에서 찾는다
공유 ─▶ 같은 DB 를 본다 공유 ─▶ 7800 을 통한 복제
이 절차를 끝까지 치면 다섯을 손으로 보게 된다. 로그인했는데 DB 세션 테이블이 0건인 것, 그런데도 교차 노드 refresh 가 200 인 것, 롤링 재시작 한 번에 전원이 로그아웃되는 것, 7800 을 끊으면 이번에는 세션 공유가 깨지는 것, 그리고 DB 를 내렸는데 새 로그인이 되는 것.
전제와 되돌리기
앞선 구축 단계 05-keycloak 과 06-observability 가 끝나 있어야 한다. 그리고 A-1 과 A-2 와 A-8 을 먼저 해 두는 편이 좋다. 이 절차는 그 셋의 대조군이고, 먼저 잰 값을 알고 있어야 뒤집힘이 보인다.
이건 클러스터의 동작 모드를 바꾸는 실험이다. 전환하는 순간 기존 세션이 전부 사라지고 되돌릴 때 또 한 번 사라진다. --features-disabled 는 빌드 옵션이라 기동할 때 재빌드가 일어나 롤아웃이 평소보다 오래 걸린다. 실험대에서만 한다.
원복을 잊으면 이후 실험이 전부 오염된다. A-0 부터 A-6 까지의 결론은 전부 persistent 기본값 조건이다.
중간에 그만두려면 두 가지를 되돌린다. args 쪽은 이 두 줄이다.
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
iptables 쪽은 두 노드에서 각각 지운다. 이 실험대는 ssh kc-lab-2 '...' 한 줄로 쳤고, 따라 하는 사람은 먼저 붙은 다음 원격 셸에서 치면 된다. 나눈 형태는 이 실험대에서 치지 않았다(unknown).
sudo iptables -t raw -F PREROUTING
ssh kc-lab-2
sudo iptables -t raw -F PREROUTING
exit
주입 전에 같은 명령으로 먼저 본다
전환 후에 볼 것을 전환 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가
1. 파드 배치를 보고 두 파드 IP 를 잡는다
목적 — 두 Keycloak 파드가 서로 다른 노드에 있는지 확인하고, 뒤에서 쓸 IP 를 변수에 담는다.
kubectl get nodes
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, 그리고 NODE 가 서로 다른지를 본다. 파드 번호와 노드 번호는 어긋난다 — keycloak-0 이 kc-lab-2 에 있다.
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"
10.42.1.94 10.42.0.45
왜 필요한가 — 두 파드가 같은 노드에 있으면 뒤의 노드 간 차단이 아무것도 끊지 않는다. 그리고 이 절차는 롤아웃을 세 번 하므로 IP 를 세 번 다시 잡는다.
문제가 생기면 — NODE 가 같으면 여기서 멈추고 배치부터 고친다.
2. 지금 args 를 적어 둔다
목적 — 복구할 때 되돌릴 문자열을 확보한다.
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
예상 결과
["start"]
왜 필요한가 — 플래그가 하나도 없으므로 26 의 기본값으로 돌고 있고 persistent-user-sessions 가 켜져 있다. 복구 단계가 이 문자열로 되돌린다.
문제가 생기면 — 이미 --features-disabled=persistent-user-sessions 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 먼저 그것부터 되돌린다.
3. DB 에 세션 행이 있는 것을 센다
목적 — persistent 에서 로그인이 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"
예상 결과 — 모양은 이렇고 숫자는 환경마다 다르다.
offline_flag | count
--------------+-------
0 | 151
offline_flag = '0' 이 온라인 세션이고 '1' 은 offline token 이라 이 실험과 무관하다.
왜 필요한가 — 전환 후에 같은 질의가 (0 rows) 를 내놓는지가 첫 판정이다. 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 여기서는 0 이 아니라는 것만 본다.
문제가 생기면 — (0 rows) 가 지금 나오면 이미 volatile 이다. 2번으로 돌아간다.
4. 상주 탐침 파드를 띄운다
목적 — 롤링 재시작을 넘어 토큰을 들고 있을 파드를 StatefulSet 밖에 세운다.
Keycloak 컨테이너에는 curl 도 wget 도 없어서 kubectl exec keycloak-0 -- curl 은 exit 127 로 끝난다.
kubectl -n keycloak-lab run a7-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/a7-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 a7-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
예상 결과 — 두 IP 가 보이고 PW길이 가 0 이 아니다.
왜 필요한가 — 탐침이 StatefulSet 안에 있으면 롤링 재시작에 같이 죽어서 재시작 전 토큰을 재시작 후에 쓸 수 없다.
문제가 생기면 — PW길이=0 이면 --env 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
5. 교차 노드 refresh 가 지금은 되는 것을 본다
목적 — 뒤에서 나올 400 과 견줄 값을 먼저 확보한다.
응답을 한 번은 통째로 본다.
kubectl -n keycloak-lab exec a7-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"'
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"}
expires_in 이 60 이다. access token 은 60초짜리고 그동안은 서버에 안 물어보므로, 이 실험의 탐침은 access token 이 아니라 refresh 다. refresh 는 노드가 세션 저장소를 실제로 뒤져야 답할 수 있다.
kubectl -n keycloak-lab exec a7-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
echo "rt $(wc -c < /tmp/rt) bytes"'
rt 1188 bytes
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
예상 결과
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
왜 필요한가 — 이 200 을 안 재 두면 뒤의 400 이 무엇과 견준 값인지 말할 수 없다. 그리고 rt 가 1 bytes 면 빈 문자열에 개행만 들어갔다. 파싱이 실패했거나 로그인이 실패한 것인데, 그 상태로 진행하면 빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.
문제가 생기면 — 1 bytes 가 나오면 cat /tmp/tok 으로 본문을 본다. refresh token 은 회전하므로 이어서 또 쓰려면 새로 로그인해서 /tmp/rt 를 다시 채운다.
6. 이 버전에서 정말 끌 수 있는지 확인한다
목적 — 기능 목록에 이름이 있는지 본다.
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
| tr ',' '\n' | grep -i persistent
예상 결과
persistent-user-sessions[:v1] ← 목록에 있다
★ 2026-09-17 에 쳤더니 두 줄이 나왔다(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다.
persistent-user-sessions[:v1]
persistent-user-sessions
--help-all 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. tr ',' '\n' 이 그것을 줄로 쪼갠다. 처음 한 번은 grep 없이 쳐서 어떤 기능들이 있는지 통째로 본다.
왜 필요한가 — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다.
문제가 생기면 — 아무것도 안 나오면 먼저 grep 을 떼고 출력 전체를 본다.
주입
주입은 셋이다. 여기서 치는 것은 첫째뿐이고, 7800·57800 양방향 차단과 PostgreSQL 정지는 A-1 과 A-2 를 다시 치는 순서 안에서 넣는다. 그 둘의 명령과 되돌리기는 그 단계에 적었다.
7. 세션 테이블을 비운다
목적 — 전환 후 「DB 0건」이 성립할 수 있게 옛 행을 먼저 없앤다.
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
예상 결과
DELETE 151
왜 필요한가 — volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. 이 한 줄을 빼먹으면 전환 뒤에도 테이블에 행이 보이고, 그것을 「전환이 안 됐다」로 읽게 된다. 되돌리는 방법은 없다 — 지운 세션은 돌아오지 않는다. 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만, 운영에서 이 명령은 전원 로그아웃이다.
문제가 생기면 — 삭제 건수가 0 이면 이미 비어 있다. 그대로 다음으로 간다.
8. args 를 volatile 로 바꾼다
목적 — persistent-user-sessions 를 끄고 롤아웃이 끝날 때까지 기다린다.
방법은 둘이고 매니페스트를 고치는 쪽을 권한다. 무엇이 바뀌었는지 파일에 남는다.
먼저 매니페스트를 편집기로 연다.
vim deploy/lab/k8s/keycloak-cluster.yaml
args 줄을 이렇게 고친다.
# 149번째 줄 근처
args: ["start", "--features-disabled=persistent-user-sessions"]
고친 파일을 적용한다.
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
파일을 안 건드리고 싶으면 patch 를 쓴다.
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
"value":["start","--features-disabled=persistent-user-sessions"]}]'
전환 시각을 적고 롤아웃을 기다린다.
date '+%H:%M:%S 전환'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
예상 결과
statefulset.apps/keycloak configured
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
★ patch 로 바꾸면 configured 가 안 나온다(2026-09-17, observed). configured 는 kubectl apply 가 내는 말이고, patch 는 statefulset.apps/keycloak patched 를 낸다. 아래 「configured 가 나와야 한다」는 매니페스트를 고쳐 apply 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다.
statefulset.apps/keycloak patched
이 실험대에서 patch 로 쳤을 때 롤아웃은 18:02:41 에 시작해 18:03:47 에 끝났다. 재빌드까지 66초다.
왜 필요한가 — configured 가 나와야 한다. unchanged 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 --timeout=60s 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다.
문제가 생기면 — 타임아웃이 나면 --timeout=500s 로 다시 치고, logs keycloak-0 에 빌드 진행이 보이는지 확인한다.
주입 검증
결과를 해석하기 전에 주입이 의도한 것만 건드렸는지 본다. 주입 ②와 ③의 검증은 그 주입을 친 단계 안에 있다 — 주입마다 검증이 따로 붙는다.
9. args 와 파드가 둘 다 새것인지 본다
목적 — 선언만 바뀌고 프로세스는 그대로인 상태를 걸러 낸다.
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab get pods -o wide | grep keycloak
예상 결과
["start","--features-disabled=persistent-user-sessions"]
AGE 가 방금이고 RESTARTS 가 0 인지 함께 본다.
왜 필요한가 — StatefulSet 의 spec 은 바뀌었는데 파드가 옛것이면 persistent 를 재면서 volatile 이라고 적게 된다.
문제가 생기면 — 파드 나이가 예전 값이면 롤아웃이 안 끝났다. 8번의 rollout status 로 돌아간다.
IP 가 바뀌었으므로 다시 잡고, 탐침 파드도 지우고 새 IP 로 다시 띄운다. 탐침의 K0·K1 은 만들 때 고정된 값이라 롤아웃 뒤에는 낡았고, 낡은 주소로 친 curl 은 아무 데도 안 닿는다. 여기서는 아직 파드 안에 지킬 파일이 없으므로 지우고 다시 만들어도 잃을 것이 없다.
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"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
kubectl -n keycloak-lab run a7-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/a7-probe --timeout=120s
10. 로그인 5회 뒤 DB 행 수를 센다
목적 — args 문자열이 아니라 동작이 바뀐 것을 본다.
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'for i in 1 2 3 4 5; do
curl -s -o /dev/null -w "%{http_code} " -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"
done; echo'
200 200 200 200 200
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 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
offline_flag | count
--------------+-------
(0 rows)
왜 필요한가 — 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. 세션이 메모리에만 있다. 전환 판정은 이 질의로 한다.
문제가 생기면 — 행이 있으면 7번의 delete from offline_user_session 을 건너뛰었다. 지우고 다시 로그인한다.
11. 캐시 엔트리 수로는 두 모드를 못 가른다는 것을 확인한다
목적 — 다음 사람이 이 지표로 판정하지 않도록, 두 모드가 같은 값을 낸다는 것을 눈으로 본다.
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757046000.1,"5"]},
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757046000.1,"0"]}]}}
라벨을 보고 나면 읽기 좋게 자른다. 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
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]'
예상 결과
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
★ 위 두 줄은 이 명령의 출력이 아니다(2026-09-17, observed). tr 과 grep 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 sessions 만이 아니라 캐시 전부를 담고 있다.
"cache":"sessions"
"pod":"keycloak-1"}
"82"]}
"cache":"clientSessions"
"pod":"keycloak-1"}
"82"]}
세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다.
왜 필요한가 — persistent 였을 때와 똑같은 숫자다. approximate_entries_unique 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다.
문제가 생기면 — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
관찰
앞에서 친 것과 완전히 같은 명령을 순서대로 다시 친다. A-0 · A-8 · A-1 · A-2 차례다.
12. A-0 을 다시 돌린다 — 교차 노드는 여전히 200 이다
목적 — 겉보기 결과가 persistent 때와 같은지 본다.
kubectl -n keycloak-lab exec a7-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
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
예상 결과
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
왜 필요한가 — DB 는 0건인데 200 이다. 경로가 완전히 달라졌는데 겉보기 답이 같다.
persistent : keycloak-1 이 PostgreSQL 을 읽어서 답했다
volatile : keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다
구별하려면 그 경로를 끊어 봐야 하고, 14번이 그것을 한다.
2026-09-17 에 새 실험대에서 12번과 14번을 이어서 쳤다(observed). 같은 명령 한 벌이 전환 전후로 정반대 답을 냈다.
volatile · 분단 없음 로그인 sid=_szXQIOVaE8_v2-b-frn9npt 교차 노드 refresh 200
DB 세션 행 (0 rows)
캐시 keycloak-0 sessions 1 · keycloak-1 sessions 0
volatile · 7800 차단 로그인 sid=9tpo41BVY32uJdYOyv8maA5j 교차 노드 refresh 400
로그아웃 400 · 발급 노드 재갱신 400 invalid_grant / Invalid refresh token
DB 세션 행 0
persistent 에서는 같은 것을 재면 200 이 나왔다. A-1 에서 7800 을 막고 잰 값이 200·204·200 이었고 여기서는 400·400·400 이다. 그 한 칸이 두 모드의 차이를 전부 말한다 — persistent 는 DB 를 읽고, volatile 은 7800 을 탄다.
문제가 생기면 — 400 이 나오면 /tmp/rt 를 다시 안 채웠다. 로그인부터 다시 친다.
13. A-8 을 다시 돌린다 — 롤링 재시작이 곧 로그아웃이다
목적 — 재시작 전에 발급한 토큰이 재시작 후에도 통하는지 본다.
재시작 전에 로그인해서 토큰과 sid 를 파드 안에 담는다.
kubectl -n keycloak-lab exec a7-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; echo'
access token 의 가운데 토막이 클레임이다. 모양은 이렇고 값은 환경마다 다르다.
{"exp":1757046060,"iat":1757046000,"jti":"...","typ":"Bearer","azp":"admin-cli",
"sid":"aVwYnzKZFFvMqD3bpSeiILuM",...}
=== [A-8 재실행] 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
sid 를 적어 둔다. base64 패딩 때문에 끝이 깨져 보일 수 있고 2>/dev/null 이 그 불평을 지운다. sid 는 앞쪽에 있어서 대개 보인다.
그다음 재시작한다.
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...
파드 IP 를 다시 잡는다. 탐침은 다시 띄우지 않는다 — /tmp/rt 가 같이 사라진다. 그래서 새 IP 를 명령줄에 직접 넘긴다. $K0 는 [lab host] 셸의 변수이고 지금 값은 롤아웃 전 것이므로 먼저 다시 잡는다.
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"
kubectl -n keycloak-lab exec a7-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)"'
인용이 세 겹이다. 바깥 작은따옴표를 닫고, 셸이 $K0 를 펴게 큰따옴표로 감싸고, 다시 작은따옴표를 연다. 파드 안 셸에는 이미 펴진 IP 문자열이 들어간다. 무엇이 들어가는지 echo 로 한 번 찍어 보는 확인은 이 실험대에서 치지 않았다(unknown).
예상 결과
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
--- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
왜 필요한가 — 본문을 반드시 본다. 400 만 보면 토큰이 이상한가로 읽히지만 Session not active 는 서버가 그 세션을 모른다는 뜻이고, 토큰 자체는 멀쩡하다. 캐시도 함께 보면 keycloak-1 에 1건이 있는데, 그것은 방금 실패한 요청이 새로 만든 세션이다. 옛 세션 5건은 어디에도 없다.
문제가 생기면 — 200 이 나오면 args 가 아직 기본값이다. 9번으로 돌아간다.
14. A-1 을 다시 돌린다 — 이번에는 세션 공유가 깨진다
목적 — 7800 과 57800 을 양방향으로 버리고 교차 노드가 끊기는지 본다.
두 노드에 각각 규칙을 넣는다. 규칙의 -d 는 그 노드에 있는 파드의 IP 다. kubeconfig 가 lab host 에만 있으므로 두 노드 모두 거기서 한 줄 ssh 로 친다.
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}')
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP"
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'
| 노드 | 그 노드에 있는 파드 | 규칙의 -d |
|---|---|---|
kc-lab-1 |
keycloak-1 |
$K1 |
kc-lab-2 |
keycloak-0 |
$K0 |
네 ssh 줄은 중단 절차처럼 나눠 치면 안 된다. 거기서는 ssh kc-lab-2 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 $K0 와 $K1 이 들어간다. 둘 다 [lab host] 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 -d 없는 규칙이 걸린다. 큰따옴표가 그 값을 [lab host] 에서 펴서 보내므로 이 네 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 echo "$K0" 와 echo "$K1" 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
원 가이드는 앞 두 줄을 kc-lab-1 안에서 직접 쳤다. 지우지 않고 남기는 까닭은 아래 실패 출력이 이 실험대에서 실제로 받은 것이기 때문이다. 따라 칠 형태는 위의 ① 이다.
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}')
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'
★ 이 블록은 kc-lab-1 에서 안 돈다(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 kubectl 이 막히고, $K0 와 $K1 이 빈 문자열이 된다. 그 상태로 iptables 가 이어지면 -d 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 kc-lab-1 에는 kc-lab-2 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 date 는 그대로 차단 을 찍는다.
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied
Bad argument `7800'
Bad argument `57800'
Host key verification failed.
Host key verification failed.
09:04:49 차단
찍힌 시각을 주입 시각으로 적으면 A-6 의 적용완료 와 같은 함정이다. 그때 두 노드의 raw PREROUTING 은 비어 있었다.
NetworkPolicy 대신 iptables 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다.
패킷 도착
├─▶ raw PREROUTING ← conntrack 보다 먼저. 여기서 끊는다
├─▶ conntrack: ESTABLISHED 면 통과
└─▶ NetworkPolicy 평가 ← 여기까지 오지 않는다
raw 테이블은 CNI 가 안 쓰는 테이블이라 규칙이 밀려나지도 않는다. 57800 을 같이 막는 까닭은 장애 감지 채널 FD_SOCK2 가 bind_port + 50000 을 쓰기 때문이다. 7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다.
주입이 걸렸는지 양쪽 카운터를 둘 다 본다.
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
원 가이드는 앞 줄을 kc-lab-1 안에서 직접 치고 뒤 줄만 ssh 로 보냈다. 그 기계에는 kc-lab-2 의 호스트 키가 없어 뒤 줄이 Host key verification failed 로 끝난다(2026-09-17, observed).
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
모양은 이렇고 숫자는 환경마다 다르다.
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
19 1140 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:7800
0 0 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:57800
규칙이 목록에 있는데 pkts 가 0 이면 패킷이 그 경로로 안 오는 것이고 분단은 안 만들어졌다. A-5 가 이 함정에 두 번 빠졌다.
분단이 성립할 때까지 25초 간격으로 몇 번 친다.
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
분단이 성립할 때까지 대기...
+25초 cluster_size(k0 k1) = [2.0 2.0 ]
+50초 cluster_size(k0 k1) = [1.0 ]
+75초 cluster_size(k0 k1) = [1.0 ]
+100초 cluster_size(k0 k1) = []
+125초 cluster_size(k0 k1) = [1.0 ]
2.0 2.0 이 1.0 으로 떨어지는 데 50초쯤 걸린다.
★ 이 실험대에서는 +75초 에 떨어졌고 시계열에 빈 값이 없었다(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 2 2 · 2 2 · 1 1 · 1 1 · 1 1 이었다. 차단을 푼 뒤 양쪽이 2 로 돌아오는 데는 +60초 가 걸렸다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
split brain 은 DB 한 줄로 확인한다.
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
name | ip | coord
------------------+-----------------+-------
keycloak-0-30843 | 10.42.1.99:7800 | t
keycloak-1-48749 | 10.42.0.46:7800 | t
coord = t 가 둘이면 분단이고 정상일 때는 하나다.
대조군을 먼저 재고 시험군을 잰다. 대조군은 같은 노드에서 갱신한다.
kubectl -n keycloak-lab exec a7-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
curl -s -o /dev/null -w "same-node %{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)"'
kubectl -n keycloak-lab exec a7-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
curl -s -w "\ncross-node %{http_code}\n" -X POST \
"http://'"$K1"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
예상 결과
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
--- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
왜 필요한가 — 대조군을 같이 재야 차단이 모든 것을 망가뜨린 게 아니라 교차 노드만 끊었다고 말할 수 있다. 시험군은 매번 새로 로그인해서 새 토큰으로 한다. refresh token 이 회전하기 때문이다.
persistent : 세션 ── PostgreSQL ──▶ 양쪽이 본다 7800 무관
volatile : 세션 ── 클러스터(7800) ─▶ 상대에게 간다 7800 필수
「이 실험이 가르는 것」에서 미뤄 둔 판정이 여기서 난다. 통념은 24 이전에서 맞고, 틀린 것은 자료가 아니라 버전을 확인하지 않고 적용하는 것이다.
문제가 생기면 — 교차 노드가 계속 200 이면 차단이 한쪽만 걸렸다. 두 노드 카운터를 둘 다 본다.
다음으로 넘어가기 전에 차단을 푼다. 명령은 전제와 되돌리기 절의 두 형태와 같다. 양쪽 vendor_cluster_size 가 2 로 돌아와야 한다 — 분단이 남아 있으면 다음 결과가 DB 때문인지 분단 때문인지 구별되지 않는다.
15. A-2 를 다시 돌린다 — 새 로그인은 되는데 refresh 가 안 된다
목적 — DB 를 내리고 두 경로를 잰다.
내리기 전에 로그인해서 /tmp/rt 를 채운다. 12번의 명령을 쓰지 않는다 — 그 블록은 로그인한 다음 곧바로 반대 노드에서 refresh 까지 해서 방금 받은 토큰을 소모한다. refresh token 은 한 번 쓰면 회전하므로 /tmp/rt 에는 이미 쓴 값이 남고, DB 를 내린 뒤의 500 이 DB 때문인지 재사용 때문인지 구별되지 않는다. 로그인만 하고 끝나는 13번의 ① 을 쓴다.
kubectl -n keycloak-lab exec a7-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; echo'
인용이 세 겹인 것이 여기서도 중요하다. 작은따옴표 안에 그냥 $K0 를 두면 그것을 펴는 것은 파드 안 셸이고, 그 값은 탐침을 만들 때 --env 로 구워 넣은 것이다. 12번의 rollout restart 로 파드 IP 가 바뀐 뒤라 그 값은 낡았다 — 요청이 없는 주소로 나가고, 그때 나오는 실패가 이 절의 예상 결과인 500 과 화면에서 같아 보인다. 위 ④ 와 같은 형태로 [lab host] 셸의 지금 값을 넘긴다.
그다음 DB 를 내린다.
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
delete pod 이 아니라 scale --replicas=0 인 까닭은 Deployment 가 지운 파드를 곧바로 새로 만들기 때문이다. DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다.
두 경로를 차례로 친다.
kubectl -n keycloak-lab exec a7-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)"'
kubectl -n keycloak-lab exec a7-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=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"'
예상 결과 — 이 실험대에서는 이 값이 나왔다. 아래 ①② 는 증거 파일의 번호이고 위 명령의 ③④ 와 차례가 같다.
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200
persistent 에서는 순서가 거꾸로였다. 새 로그인이 500 이었는데, 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 통과한다.
로그인에 필요한 것
├─ realm 설정 → Infinispan `realms` 캐시에 있다
├─ 사용자 자격 → `users` 캐시에 있다
└─ 세션 저장 → volatile 이므로 메모리
→ DB 없이 완결된다
왜 필요한가 — 여기서 잰 두 숫자를 그대로 표에 옮기면 틀린 표가 된다. 같은 설정에서 캐시 온도만으로 답이 셋으로 갈리고, 이 값은 마침 롤아웃 뒤 로그인을 몇 번 했고 refresh 는 안 한 상태에서 쟀다. 캐시 온도는 kubectl get 어디에도 안 나오는 상태라 한 번 재고 넘어가면 조건을 모르는 채 결과만 남는다. 셋을 갈라 재는 절차는 A-7a 에 있고, A-7 이 남긴 「refresh 가 500 인 이유는 REVOKED_TOKEN 조회일 것」이라는 가설은 거기서 틀린 것으로 확정됐다. 실제 문장은 CLIENT_SCOPE_CLIENT 조회다.
문제가 생기면 — 200 / 200 이 나오면 캐시가 이미 더워졌다. 틀린 측정이 아니라 다른 상태를 잰 것이므로 A-7a 로 간다. 로그인이 400 unauthorized_client 면 완전 냉시동이고, 그것도 A-7a 가 가른다.
마지막으로 DB 를 되살린다.
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
deployment.apps/postgres scaled
deployment "postgres" successfully rolled out
volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용자와 클라이언트와 취소 토큰은 여전히 DB 에 있고, 세션만 메모리로 옮겼다.
복구와 원상복구 확인표
순서가 있다. iptables 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, args 를 되돌린다.
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
원 가이드는 앞 줄을 kc-lab-1 안에서 직접 치고 뒤 줄만 ssh 로 보냈다. 그 기계에는 kc-lab-2 의 호스트 키가 없어 뒤 줄이 Host key verification failed 로 끝난다(2026-09-17, observed).
sudo iptables -t raw -L PREROUTING -n
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
kubectl -n keycloak-lab get pods -l app=postgres
Running 이 아니면 scale deployment/postgres --replicas=1 을 친다.
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
매니페스트를 고쳤다면 파일도 같이 되돌린다. 안 그러면 다음에 apply 할 때 volatile 로 다시 간다.
git diff deploy/lab/k8s/keycloak-cluster.yaml
git checkout -- deploy/lab/k8s/keycloak-cluster.yaml
=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...
args 문자열만 보고 끝내지 않는다. 새 IP 로 탐침을 다시 띄우고 로그인을 한 번 한 다음 DB 행을 센다. 원복도 롤아웃이므로 여기서도 파드 주소가 바뀌었다.
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
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}')
kubectl -n keycloak-lab run a7-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/a7-probe --timeout=120s
kubectl -n keycloak-lab exec a7-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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
["start"]
로그인
DB 온라인 세션: 1 건 (1 이면 persistent 복귀)
keycloak-0 1/1 Running 0 67s
keycloak-1 1/1 Running 0 89s
postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
외부 진입점 HTTP 200
앞에서 테이블을 비웠으므로 여기서 세는 값은 방금 만든 세션 하나다. 0 이면 아직 volatile 이다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| args | kubectl -n keycloak-lab get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].args}' |
["start"] |
| 매니페스트 | git diff deploy/lab/k8s/keycloak-cluster.yaml |
출력 없음 |
| 파드 | kubectl -n keycloak-lab get pods -o wide |
keycloak 둘 다 1/1 Running |
| DB | kubectl -n keycloak-lab get pods -l app=postgres |
1/1 Running |
| 동작 | 로그인 뒤 select count(*) ... |
세션 행이 생긴다 |
| iptables | sudo iptables -t raw -L PREROUTING -n (두 노드) |
규칙 없음 |
| 클러스터 | vendor_cluster_size |
양쪽 2 |
| 탐침 파드 | kubectl -n keycloak-lab get pod a7-probe |
NotFound |
| 밖 | curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master |
200 |
★ 마지막 줄의 200 은 이 실험대에서 안 나온다(2026-09-17, observed). auth.hyeonworks.com 이 lab host 에서 100.83.212.4 로 풀리고 그 주소의 443 이 닫혀 있어 000 이 나온다. 엣지 주소를 짚으면 200 이고, 까닭은 A-6 의 같은 줄에 적었다. 나머지 여덟 줄은 이 실험대에서 그대로 통과했다 — args ["start"], git diff 출력 없음, 파드 둘 다 1/1 Running, postgres 1/1 Running, 로그인 뒤 세션 행 1, 두 노드 raw PREROUTING 비어 있음, vendor_cluster_size 양쪽 2, 탐침 NotFound.
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
막히면
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
rollout status 가 타임아웃 |
빌드 옵션이라 재빌드가 일어난다 | --timeout=500s 로 다시. logs keycloak-0 에 빌드 진행 |
apply 가 unchanged |
args 를 안 고쳤거나 다른 파일을 고쳤다 | get statefulset ... -o jsonpath='{...args}' 로 실제 값 |
| 전환했는데 DB 에 행이 있다 | delete 를 건너뛰었다. 옛 행은 안 지워진다 |
delete from offline_user_session 후 다시 로그인 |
캐시가 5 / 0 이라 전환이 안 된 것 같다 |
두 모드가 같은 값을 낸다 | 판정은 DB 행 수로 한다 |
차단했는데 cluster_size 가 계속 2 |
규칙이 안 걸렸거나 pkts 가 0 |
iptables -t raw -L PREROUTING -n -v 의 카운터 |
cluster_size 결과가 비었다 |
측정 실패다. 스크립트가 빈 값을 뱉었다 | 손으로 다시 친다. 빈 값은 판정에서 뺀다 |
교차 노드가 계속 200 |
차단이 한쪽만 걸렸다 = 단방향 | 두 노드 카운터를 둘 다 본다 |
| 재시작 뒤 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | get pod -o jsonpath='{.status.podIP}' 다시 |
refresh 가 400 인데 이유를 모르겠다 |
본문을 안 봤다 | -o /dev/null 을 빼고 Session not active 인지 본다 |
A-2 재실행이 200 / 200 이 나온다 |
캐시가 이미 더워졌다. 틀린 게 아니다 | 조건부다 — A-7a |
로그인이 400 unauthorized_client |
완전 냉시동이다. 클라이언트 조회조차 캐시에 없다 | 이것도 조건부 — A-7a |
kubectl exec keycloak-0 -- curl 이 exit 127 |
Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| 다음 실험 결과가 이상하다 | 원복을 안 했다 | 확인표를 전부 통과시킨다 |
무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 전환 전 args 가 ["start"] 이고 파드 IP 가 10.42.1.94 와 10.42.0.45 였던 것, DELETE 151, 전환 뒤 args 가 ["start","--features-disabled=persistent-user-sessions"] 인 것, 로그인 5회 뒤 (0 rows) 와 캐시 5.0/0.0, 교차 노드 refresh HTTP 200, 재시작 전 sid = aVwYnzKZFFvMqD3bpSeiILuM 와 재시작 뒤 HTTP 400 및 Session not active, 재시작 뒤 캐시 1.0, 분단 대기 시계열 다섯 줄, 대조군 200 과 시험군 400, DB 정지 뒤 ① 500 과 ② 200, 원복 뒤 ["start"] 와 DB 온라인 세션: 1 건 과 외부 200, 비밀번호 길이 19, 기능 목록의 persistent-user-sessions[:v1] 이다.
가이드가 미검증으로 표시한 tr ',' '\n' | grep -E 로 자른 Prometheus 출력은 2026-09-17 에 쳤다(observed). 나오는 모양은 위 11번의 ★ 에 적었다. ssh kc-lab-2 로 들어가 원격 셸에서 iptables 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 echo 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
측정이 샌 곳이 하나 있다. cluster_size 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
DB 정지 뒤의 200 / 500 은 조건부다. 캐시 온도에 따라 400 / 400 이나 200 / 200 도 나오고, 셋을 가르는 절차는 A-7a 에 있다. A-7 이 세운 원인 가설도 A-7a 가 틀린 것으로 확정했다.
이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다.
2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정값은 전부 같은 답이 나왔다 — 전환 전 args ["start"] 와 DELETE 144, 전환 뒤 args ["start","--features-disabled=persistent-user-sessions"], 로그인 5회 200 200 200 200 200 뒤 DB (0 rows), 교차 노드 refresh 200, 재시작 전 sid 를 담고 재시작 뒤 400 과 Session not active, 분단에서 대조군 same-node 200 과 시험군 cross-node 400, coord = t 둘, DB 정지 뒤 500 과 200, 원복 뒤 ["start"] 와 세션 행 1. 달랐던 것은 명령이 아니라 화면의 모양이고 위의 ★ 다섯이 그것이다. 원문은 relive-2026-09-17/a7-06..08 에 있다.