Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md
T
DongHyeonkaandClaude Opus 5 bbd87dc9ab fix(setup): Studio 편집기가 다시 쓰는 공백을 저장소에서 미리 맞춘다
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>
2026-09-17 20:03:53 +09:00

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
name version
Keycloak 26.7.0
name version
curlimages/curl 8.11.1
name version
persistent-user-sessions v1
final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7
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-1kc-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 없는 kubectlpermission denied 로 막힌다 — kubeconfig 는 lab host 의 ~/.kube/config 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 kubectl 블록의 기계 이름을 [lab host] 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.

kubectlsudo 를 붙이지 않는다. 실험 폴더의 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-probecurlimages/curl:8.11.1, sleep 7200, --restart=Never
끄는 기능 --features-disabled=persistent-user-sessions
막는 포트 7800 과 57800 을 raw PREROUTING 에서 양방향으로
도구 jq 가 이 실험대에 없다. Prometheus 출력은 trgrep 으로 자른다

터미널은 둘을 연다. 하나는 관찰용, 하나는 대기용이다.

이 실험이 가르는 것

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-keycloak06-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, RESTARTS0, 그리고 NODE 가 서로 다른지를 본다. 파드 번호와 노드 번호는 어긋난다 — keycloak-0kc-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 컨테이너에는 curlwget 도 없어서 kubectl exec keycloak-0 -- curlexit 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 이 무엇과 견준 값인지 말할 수 없다. 그리고 rt1 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). configuredkubectl 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 가 방금이고 RESTARTS0 인지 함께 본다.

왜 필요한가 — 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). trgrep 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 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.01.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_size2 로 돌아와야 한다 — 분단이 남아 있으면 다음 결과가 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 에 빌드 진행
applyunchanged 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 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
다음 실험 결과가 이상하다 원복을 안 했다 확인표를 전부 통과시킨다

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

이 실험대가 실제로 본 것(observed)은 전환 전 args["start"] 이고 파드 IP 가 10.42.1.9410.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 400Session 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 를 담고 재시작 뒤 400Session not active, 분단에서 대조군 same-node 200 과 시험군 cross-node 400, coord = t 둘, DB 정지 뒤 500200, 원복 뒤 ["start"] 와 세션 행 1. 달랐던 것은 명령이 아니라 화면의 모양이고 위의 ★ 다섯이 그것이다. 원문은 relive-2026-09-17/a7-06..08 에 있다.