Experiment 0 with three probes: cross-node refresh/logout, cache counter deltas around a single login, and cache entry ownership. Each node caches only what it handled; cache totals sum exactly to the database count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
9.0 KiB
Bash
Executable File
208 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Experiment 0 — is a session created on one Keycloak node usable on the other?
|
|
#
|
|
# Forming a cluster is not the same as sharing session state. The Infinispan log
|
|
# says "cluster view (2)", but that only proves the members found each other.
|
|
#
|
|
# Design notes, learned the hard way:
|
|
#
|
|
# * Every probe has a CONTROL. A result from the far node means nothing unless
|
|
# the same call against the issuing node is also measured. The first version
|
|
# of this script reported "403 on keycloak-1" as if it were a replication
|
|
# failure; the issuing node returned 403 too, and the cause was a missing
|
|
# openid scope. Measure both, always.
|
|
#
|
|
# * Sessions are tracked by SID, not by count. Both the test login and the
|
|
# admin API calls create sessions for the same user, so counts are noisy.
|
|
# A specific session id either appears in a node's answer or it does not.
|
|
#
|
|
# * The probe is the REFRESH TOKEN grant, not userinfo. userinfo only validates
|
|
# a signature and can succeed on a node that knows nothing about the session.
|
|
# Refreshing requires the node to find the session, check it is alive, and
|
|
# write back a new refresh time — it actually touches the session store.
|
|
#
|
|
# Talks to pod IPs directly: going through nginx/Traefik would hide which node
|
|
# handled each request, which is the entire question.
|
|
#
|
|
# ./deploy/lab/scripts/experiment-session-replication.sh
|
|
set -uo pipefail
|
|
|
|
NS="${NS:-keycloak-lab}"
|
|
OUT="${OUT:-/tmp/session-replication}"
|
|
mkdir -p "$OUT"
|
|
|
|
PSQL="kubectl -n $NS exec deploy/postgres -- psql -U keycloak -d keycloak -tAc"
|
|
|
|
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
|
echo
|
|
|
|
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
|
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
|
K0_NODE=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.spec.nodeName}')
|
|
K1_NODE=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.spec.nodeName}')
|
|
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
|
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
|
|
|
echo "=== 대상 ==="
|
|
printf ' keycloak-0 %-14s %s\n' "$K0_IP" "$K0_NODE"
|
|
printf ' keycloak-1 %-14s %s\n' "$K1_IP" "$K1_NODE"
|
|
echo
|
|
|
|
echo "=== [0] 실험 전 DB 세션 ==="
|
|
$PSQL "select offline_flag, count(*) from offline_user_session group by offline_flag" 2>/dev/null \
|
|
| sed 's/^/ offline_flag=/' || echo " (없음)"
|
|
echo
|
|
|
|
# 파드 하나 안에서 전 단계를 실행한다. 단계마다 파드를 새로 띄우면 토큰을
|
|
# 단계 사이로 넘길 수 없다.
|
|
kubectl -n "$NS" run kc-probe --rm -i --restart=Never \
|
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
|
|
set -u
|
|
K0='http://$K0_IP:8080'; K1='http://$K1_IP:8080'
|
|
TOKEN_EP='/realms/master/protocol/openid-connect/token'
|
|
jget() { sed -n \"s/.*\\\"\$1\\\":\\\"\\([^\\\"]*\\)\\\".*/\\1/p\"; }
|
|
|
|
# ── [1] keycloak-0 에서 로그인. 이 노드가 세션의 출생지다 ──────────────────
|
|
LOGIN=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
|
|
-d grant_type=password -d client_id=admin-cli \
|
|
-d username=admin -d 'password=$ADMIN_PW')
|
|
echo '###STEP1_LOGIN'; echo \"\$LOGIN\"
|
|
|
|
AT=\$(echo \"\$LOGIN\" | jget access_token)
|
|
RT=\$(echo \"\$LOGIN\" | jget refresh_token)
|
|
|
|
# ── [2] 관리 API 조회용 토큰. 세션 오염을 피하려고 따로 하나만 더 만든다 ──
|
|
ADMTOK=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
|
|
-d grant_type=password -d client_id=admin-cli \
|
|
-d username=admin -d 'password=$ADMIN_PW' | jget access_token)
|
|
CID=\$(curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
|
\"\$K0/admin/realms/master/clients?clientId=admin-cli\" | jget id | head -1)
|
|
|
|
# ── [3] 두 노드에 같은 질문을 한다: admin-cli 의 세션 목록 ────────────────
|
|
echo '###STEP3_SESSIONS_K0'
|
|
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
|
\"\$K0/admin/realms/master/clients/\$CID/user-sessions?max=100\"
|
|
echo
|
|
echo '###STEP3_SESSIONS_K1'
|
|
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
|
|
\"\$K1/admin/realms/master/clients/\$CID/user-sessions?max=100\"
|
|
echo
|
|
|
|
# ── [4] 대조군: keycloak-0 이 발급한 refresh token 을 keycloak-0 에 쓴다 ──
|
|
# 먼저 반대편에 써야 하므로 여기서는 쓰지 않고, 순서를 [5] 뒤로 미룬다.
|
|
# refresh token 은 회전(rotation)되므로 한 번 쓰면 옛 것이 무효가 된다.
|
|
# 따라서 '반대편 먼저'가 유일하게 의미 있는 순서다.
|
|
|
|
# ── [5] 시험군: keycloak-0 이 발급한 refresh token 을 keycloak-1 에 쓴다 ──
|
|
echo '###STEP5_REFRESH_ON_K1'
|
|
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K1\$TOKEN_EP\" \
|
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\"
|
|
|
|
RT2=\$(curl -s -X POST \"\$K1\$TOKEN_EP\" \
|
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\" \
|
|
| jget refresh_token)
|
|
|
|
# ── [6] 무효화가 반대 방향으로도 전파되는가 ───────────────────────────────
|
|
# keycloak-1 에서 로그아웃시키고, keycloak-0 에서 갱신을 시도한다.
|
|
echo '###STEP6_LOGOUT_VIA_K1'
|
|
curl -s -o /dev/null -w 'http_code=%{http_code}\n' -X POST \"\$K1/realms/master/protocol/openid-connect/logout\" \
|
|
-d client_id=admin-cli -d \"refresh_token=\$RT2\"
|
|
|
|
echo '###STEP7_REFRESH_ON_K0_AFTER_LOGOUT'
|
|
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K0\$TOKEN_EP\" \
|
|
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT2\"
|
|
echo '###END'
|
|
" > "$OUT/raw.txt" 2>&1
|
|
|
|
sed -i '/^pod .* deleted$/d' "$OUT/raw.txt"
|
|
|
|
python3 - "$OUT/raw.txt" <<'PY' | tee "$OUT/report.txt"
|
|
import base64, json, sys
|
|
|
|
raw = open(sys.argv[1]).read()
|
|
blocks, cur = {}, None
|
|
for line in raw.splitlines():
|
|
if line.startswith('###'):
|
|
cur = line[3:]; blocks[cur] = []
|
|
elif cur is not None:
|
|
blocks[cur].append(line)
|
|
get = lambda k: '\n'.join(blocks.get(k, [])).strip()
|
|
|
|
def j(s):
|
|
try: return json.JSONDecoder().raw_decode(s.strip())[0]
|
|
except Exception: return None
|
|
|
|
def claims(tok):
|
|
p = tok.split('.')[1]; p += '=' * (-len(p) % 4)
|
|
return json.loads(base64.urlsafe_b64decode(p))
|
|
|
|
login = j(get('STEP1_LOGIN'))
|
|
if not login or 'access_token' not in login:
|
|
print('로그인 실패:', get('STEP1_LOGIN')[:300]); sys.exit(1)
|
|
|
|
ac = claims(login['access_token'])
|
|
rc = claims(login['refresh_token'])
|
|
SID = ac['sid']
|
|
print('=== [1] keycloak-0 에서 로그인 ===')
|
|
print(f" sid {SID}")
|
|
print(f" sub {ac.get('sub')}")
|
|
print(f" iss {ac.get('iss')}")
|
|
print(f" access 수명 {ac['exp']-ac['iat']}초")
|
|
print(f" refresh 수명 {rc['exp']-rc['iat']}초 typ={rc.get('typ')}")
|
|
print(f" refresh jti {rc.get('jti')}")
|
|
|
|
print()
|
|
print('=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===')
|
|
for step, who in (('STEP3_SESSIONS_K0', 'keycloak-0 (발급 노드)'),
|
|
('STEP3_SESSIONS_K1', 'keycloak-1 (반대편)')):
|
|
d = j(get(step))
|
|
if d is None:
|
|
print(f' {who:24} 파싱 실패: {get(step)[:120]}'); continue
|
|
ids = [s.get('id') for s in d]
|
|
mark = '보임 ✔' if SID in ids else '없음 ✘'
|
|
print(f' {who:24} 세션 {len(ids)}개 중 대상 sid → {mark}')
|
|
for s in d:
|
|
if s.get('id') == SID:
|
|
print(f" ipAddress={s.get('ipAddress')} start={s.get('start')} lastAccess={s.get('lastAccess')}")
|
|
|
|
def show(step, title, expect):
|
|
print(); print(f'=== {title} ===')
|
|
body = get(step)
|
|
code = [l for l in body.splitlines() if l.startswith('http_code=')]
|
|
code = code[0].split('=')[1] if code else '?'
|
|
d = j(body)
|
|
ok = '기대대로' if code == expect else f'기대({expect})와 다름'
|
|
print(f' HTTP {code} ← {ok}')
|
|
if d and 'access_token' in d:
|
|
c = claims(d['access_token'])
|
|
same = '동일 ✔' if c.get('sid') == SID else f"다름 ✘ ({c.get('sid')})"
|
|
print(f' 새 토큰의 sid → {same}')
|
|
elif d:
|
|
print(f" error {d.get('error')}")
|
|
print(f" error_description {d.get('error_description')}")
|
|
|
|
show('STEP5_REFRESH_ON_K1',
|
|
'[5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용', '200')
|
|
|
|
print(); print('=== [6] keycloak-1 을 통해 로그아웃 ===')
|
|
print(' ' + get('STEP6_LOGOUT_VIA_K1').strip())
|
|
|
|
show('STEP7_REFRESH_ON_K0_AFTER_LOGOUT',
|
|
'[7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파)', '400')
|
|
|
|
open('/tmp/session-replication/sid.txt','w').write(SID)
|
|
PY
|
|
|
|
SID=$(cat /tmp/session-replication/sid.txt 2>/dev/null)
|
|
echo
|
|
echo "=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ==="
|
|
echo " 대상 sid: $SID"
|
|
$PSQL "select user_session_id, offline_flag, created_on, last_session_refresh
|
|
from offline_user_session where user_session_id='$SID'" 2>/dev/null \
|
|
| sed 's/^/ /' | grep -q . \
|
|
&& $PSQL "select user_session_id||' | flag='||offline_flag||' | created='||created_on||' | refresh='||last_session_refresh
|
|
from offline_user_session where user_session_id='$SID'" 2>/dev/null | sed 's/^/ /' \
|
|
|| echo " 행 없음 — 로그아웃으로 삭제되었다"
|
|
echo
|
|
echo " 전체 세션 수: $($PSQL 'select count(*) from offline_user_session' 2>/dev/null)"
|