feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+846
@@ -0,0 +1,846 @@
|
||||
---
|
||||
id: 4f56ed58-fc82-4ed1-ac87-c356b30c34f7
|
||||
kind: SETUP
|
||||
slug: reproduce-a0-session-sharing-path
|
||||
title: 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/4f56ed58-fc82-4ed1-ac87-c356b30c34f7/edit"
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
source:
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-0
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다
|
||||
|
||||
세션이 Infinispan 복제로 공유되는지 두 노드가 같은 PostgreSQL 을 읽어서 공유되는지를 손으로 가르는 절차다. 세션 테이블을 비우고 재시작해 0 에서 출발한 뒤, 상주 탐침 파드에서 시험 넷을 차례로 친다. 전 구간 약 40분이고 A층 뒤의 아홉 편이 이 결과 위에 선다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
|
||||
이 절차가 낸 결론을 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
|
||||
- **persistent-user-sessions 가 세션의 거처를 정한다**
|
||||
이 절차의 모든 숫자가 그 설정이 켜진 상태에서 나온다. 끄면 같은 명령이 다른 답을 낸다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
시험 0 에서 반대편 노드의 응답만 재고 발급 노드에 같은 요청을 안 보내면 `403` 을 복제 실패로 읽는다. 그 규칙을 편 기록이다.
|
||||
- **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
|
||||
여기서 잰 교차 노드 refresh `200` 과 로그아웃 뒤 `400` 이 그 편의 대조군 값이 된다.
|
||||
- **PostgreSQL 을 진짜로 크래시시키고 잃은 로그인을 센다**
|
||||
시험 0d 에서 잡은 `SET LOCAL synchronous_commit TO OFF` 한 줄의 대가를 그 편이 건수로 잰다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 과 `psql` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl` 에 `sudo` 를 붙이지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] sudo 를 붙이는 쪽이 틀린 형태다"
|
||||
kubectl -n keycloak-lab get pods # 이렇게
|
||||
sudo kubectl -n keycloak-lab get pods # 이렇게 치면 안 된다
|
||||
```
|
||||
|
||||
`sudo` 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다. root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 막힌 곳은 클러스터가 아니라 `kubectl` 이 어느 설정 파일을 읽느냐다.
|
||||
|
||||
터미널은 둘을 연다. 하나는 탐침 파드 셸용이라 붙잡혀 있고, 하나는 관찰용이다. 그래서 `[kc-lab-1]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
|
||||
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
|
||||
| 탐침 파드 | `kc-probe` — `curlimages/curl:8.11.1`, `--rm -it`, `--restart=Never` |
|
||||
| 주 계기 | `vendor_statistics_approximate_entries_unique` 와 PostgreSQL 문장 로그 |
|
||||
| 스크레이프 간격 | 15초. 지표를 다시 묻기 전에 30초 기다린다 |
|
||||
| 걸리는 시간 | 전 구간 약 40분 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
앞 단계에서 Keycloak 2노드 클러스터를 세우고 로그에서 `ISPN000094` 멤버 2개를 확인했다면 아는 것은 「클러스터가 떴다」까지다. 그 위에 장애를 주입해도 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.
|
||||
|
||||
갈라야 할 것은 둘이다.
|
||||
|
||||
```text
|
||||
두 노드가 같은 답을 한다
|
||||
│
|
||||
├── (a) Infinispan 이 세션을 복제했다 ← 통념
|
||||
│
|
||||
└── (b) 두 노드가 같은 PostgreSQL 을 본다 ← 확인할 것
|
||||
```
|
||||
|
||||
「반대편에서도 된다」만 보면 (a) 와 (b) 의 결과가 같아서 구별이 안 된다. 그래서 시험을 넷으로 나눈다.
|
||||
|
||||
| 시험 | 무엇을 가르나 |
|
||||
|---|---|
|
||||
| **0** 교차 노드 사용 | 반대편이 그 세션을 쓸 수 있는가 (여기까지는 (a)·(b) 구별 안 됨) |
|
||||
| **0b** 캐시 계수기 델타 | 로그인 하나에 반대편 캐시가 **움직이는가** |
|
||||
| **0c** 엔트리 소유 | 엔트리가 **어느 노드에** 생기는가 |
|
||||
| **0d** SQL 포획 | 반대편이 **정말 DB 를 읽는가** — 추론을 관측으로 바꾼다 |
|
||||
|
||||
절차를 끝까지 밟으면 한 노드에서 만든 세션을 반대편이 갱신하는 것, 반대편에서 로그아웃하면 원래 노드가 `400` 을 주는 것, 로그인을 받은 노드의 캐시만 늘고 반대편은 `+0` 인 것, 캐시 합계와 DB 총계가 `7 + 5 = 12` 로 맞는 것, 반대편 노드가 날린 `SELECT`·`UPDATE` 문장, 그 트랜잭션 안의 `SET LOCAL synchronous_commit TO OFF` 를 자기 화면에서 보게 된다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` · `06-observability` 가 끝나 있다.
|
||||
- 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 같은 노드면 이 실험이 성립하지 않는다.
|
||||
- 게스트 셸이 따로 필요한 것은 `nft`·`tc`·`systemctl` 처럼 노드 자체를 건드리는 명령뿐이고 이 편에는 그런 명령이 없다.
|
||||
|
||||
**이건 상태를 부수는 실험이다.** 세션 테이블을 비우고, StatefulSet 을 재시작하고, PostgreSQL 의 문장 로깅을 켠다. **실험대에서만 한다.** 지운 세션은 돌아오지 않는다. 되돌릴 수 있는 것은 문장 로깅 하나이고, 켜기 전에 끄는 명령을 먼저 읽어 둔다.
|
||||
|
||||
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 묶음"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
|
||||
-c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```text
|
||||
노드 → 파드 → 클러스터 뷰(로그) → 디스커버리(DB) → DB 세션 수 → 노드별 캐시 → 탐침 고르기
|
||||
```
|
||||
|
||||
### 1. 두 파드가 서로 다른 노드에 있는가
|
||||
|
||||
**무엇을 보는가** — 파드가 둘 다 Ready 이고 다른 기계에 나뉘어 있는지.
|
||||
|
||||
```bash label="[kc-lab-1] ① 노드를 본다"
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 파드가 어느 노드에 있는지 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**어디를 보나** — `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, 그리고 `NODE` 열이 서로 다른지. `postgres` 가 어느 노드에 있는지도 적어 둔다.
|
||||
|
||||
**이 값이 뜻하는 것** — 두 Keycloak 파드가 같은 노드에 있으면 이 실험은 성립하지 않는다. 원래 실행에서는 `keycloak-0` 이 `kc-lab-2`, `keycloak-1` 이 `kc-lab-1` 이었다(observed). 파드 번호와 노드 번호가 어긋나므로 이름만 보고 짐작하지 않는다. `postgres` 의 위치는 A-2 와 A-3 에서 쓴다.
|
||||
|
||||
### 2. 파드 IP 두 개를 변수에 담는다
|
||||
|
||||
**무엇을 보는가** — 뒤의 모든 요청이 향할 주소.
|
||||
|
||||
```bash label="[kc-lab-1] 파드 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"
|
||||
```
|
||||
|
||||
**어디를 보나** — 두 값이 빈 문자열이 아닌지.
|
||||
|
||||
**이 값이 뜻하는 것** — 실측은 이렇게 나왔다(observed, `01-cross-node-session.txt`).
|
||||
|
||||
```text
|
||||
=== 대상 ===
|
||||
keycloak-0 10.42.1.43 kc-lab-2
|
||||
keycloak-1 10.42.0.35 kc-lab-1
|
||||
```
|
||||
|
||||
### 3. 클러스터 뷰를 로그에서 읽는다
|
||||
|
||||
**무엇을 보는가** — 두 노드가 서로를 멤버로 세고 있는지.
|
||||
|
||||
```bash label="[kc-lab-1] 양쪽 로그에서 마지막 클러스터 뷰 한 줄씩"
|
||||
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||
```
|
||||
|
||||
**어디를 보나** — 괄호 안의 멤버 수. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
2026-09-04 00:52:09,294 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
|
||||
```
|
||||
|
||||
한 줄을 토막으로 끊으면 이렇게 읽힌다.
|
||||
|
||||
```text
|
||||
[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
|
||||
└── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘
|
||||
│ └─ 멤버 수
|
||||
└─ 뷰 ID (바뀔 때마다 1 증가)
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 멤버가 2 다. 이 줄이 증명하는 범위는 거기까지이고, 멤버가 둘이라는 것과 세션이 오간다는 것은 다른 말이다.
|
||||
|
||||
### 4. 디스커버리 테이블을 본다
|
||||
|
||||
**무엇을 보는가** — 노드가 서로를 찾는 길인 `JGROUPS_PING` 테이블에 무엇이 등록돼 있는지.
|
||||
|
||||
```bash label="[kc-lab-1] 디스커버리 테이블 세 열"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
|
||||
**어디를 보나** — `coord` 열에 `t` 가 정확히 하나인지. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-1-48749 | 10.42.0.35:7800 | t
|
||||
keycloak-0-30843 | 10.42.1.43:7800 | f
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 이 테이블은 지금 등록되어 있다는 것만 말한다. 로그는 그때 그렇게 보였다는 기록이고 둘은 다른 것을 말한다.
|
||||
|
||||
### 5. 세션이 사는 테이블을 확인한다
|
||||
|
||||
**무엇을 보는가** — 온라인 세션이 어느 테이블에 들어가는지.
|
||||
|
||||
```bash label="[kc-lab-1] 테이블 목록"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt"
|
||||
```
|
||||
|
||||
**어디를 보나** — `USER_SESSION` 이라는 이름이 목록에 없는 것. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
public | auth_session | table | keycloak
|
||||
public | jgroups_ping | table | keycloak
|
||||
public | offline_client_session | table | keycloak
|
||||
public | offline_user_session | table | keycloak
|
||||
public | revoked_token | table | keycloak
|
||||
public | root_auth_session | table | keycloak
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `persistent-user-sessions`(Keycloak 26 기본값)는 새 테이블을 만들지 않고 기존 오프라인 세션 테이블을 재사용한다. `offline_flag` 컬럼으로 구분하고 `'0'` 이 일반 로그인, `'1'` 이 `offline_access` 다. 기본키가 `(user_session_id, offline_flag)` 복합키인 까닭이 여기 있고, 이 절차의 모든 질의는 `offline_flag='0'` 이다.
|
||||
|
||||
```bash label="[kc-lab-1] 지금 몇 건인지 센다"
|
||||
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"
|
||||
```
|
||||
|
||||
### 6. 노드별 캐시 엔트리를 밖에서 묻는다
|
||||
|
||||
**무엇을 보는가** — 이 실험의 주 계기인 캐시 엔트리 수.
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어 `exec` 로 물으면 `exit 127` 이 난다. Prometheus 가 15초마다 이미 긁고 있으므로 밖에서 묻는 쪽이 짧다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
|
||||
```
|
||||
|
||||
처음 한 번은 자르지 않고 그대로 본다. 어떤 라벨이 붙어 있는지 알아야 다음부터 무엇으로 거를지 정할 수 있다. 라벨을 보고 나면 읽기 좋게 자른다 — 아래 줄은 가이드가 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ② 라벨을 보고 나서 필요한 줄만 자른다"
|
||||
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]'
|
||||
```
|
||||
|
||||
**어디를 보나** — `cache` 가 `sessions` 인 두 줄과 그 값.
|
||||
|
||||
**이 값이 뜻하는 것** — `clientSessions`·`work` 같은 다른 캐시도 같이 나오므로 `cache` 라벨을 반드시 확인한다. 중괄호를 URL 에 그대로 넣으면 `wget` 이 싫어할 수 있어 쿼리에 라벨 필터를 걸지 않고 받은 뒤에 거른다.
|
||||
|
||||
### 7. 탐침을 무엇으로 할지 정한다
|
||||
|
||||
**무엇을 보는가** — 어떤 요청을 보내야 세션 공유를 재는 것이 되는지.
|
||||
|
||||
첫 판본은 `userinfo` 로 쟀고 `http_code=403` 을 복제 실패로 읽을 뻔했다. 발급 노드에 같은 요청을 나란히 보내 보니 이랬다(observed).
|
||||
|
||||
```text
|
||||
--- userinfo, scope 없음 ---
|
||||
k0(발급노드) 403
|
||||
k1(반대편) 403
|
||||
--- 403 본문 ---
|
||||
WWW-Authenticate: Bearer realm="master", error="insufficient_scope",
|
||||
error_description="Missing openid scope"
|
||||
```
|
||||
|
||||
양쪽 다 403 이었고 원인은 복제가 아니라 요청에 `openid` scope 가 없다는 것이었다. 오히려 두 노드가 똑같이 답했다는 사실 자체가 일치의 증거였다. 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지 아무 의미가 없다.
|
||||
|
||||
| 탐침 | 하는 일 | 적합한가 |
|
||||
|---|---|---|
|
||||
| `userinfo` | 서명 검증 + scope 확인 | **아니다.** 세션을 몰라도 통과할 수 있다 |
|
||||
| **`refresh_token` 그랜트** | 세션을 찾고, 살아 있는지 보고, 갱신 시각을 쓴다 | **그렇다** |
|
||||
|
||||
refresh token 은 회전한다. 한 번 쓰면 옛 것이 무효가 되므로 반대편 노드에 먼저 써야 하고, 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이 사라진다.
|
||||
|
||||
판정은 세션 개수가 아니라 sid 로 한다. 관리 API 의 `active=2` 를 보고 판정하려던 첫 시도는 스크립트 자체가 로그인을 두 번 했기 때문에(시험용 + 관리 API 호출용) 실패했다. 같은 문자열이 세 곳에 나온다.
|
||||
|
||||
```text
|
||||
JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW
|
||||
↕ 같은 값
|
||||
DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW
|
||||
↕ 같은 값
|
||||
Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW
|
||||
```
|
||||
|
||||
## 주입
|
||||
|
||||
주입은 둘이다. 첫째는 출발값을 0 으로 만드는 것이고, 둘째는 시험 0d 직전에 몇 초만 켜는 문장 로깅이다.
|
||||
|
||||
### 1. 세션 테이블을 비우고 StatefulSet 을 재시작한다
|
||||
|
||||
**목적** — DB 행과 캐시 엔트리를 동시에 0 으로 만들어, 뒤에 세는 숫자가 이 실험이 만든 것만 담게 한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 온라인·오프라인 세션 행을 지운다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_user_session"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 재시작 시각을 남기고 롤아웃을 건다"
|
||||
date '+%H:%M:%S 재시작'
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 새 파드가 다 설 때까지 블록한다"
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
**예상 결과** — ③ 이 돌아오면 두 파드가 새로 떠 있다. 세션이 전부 지워지고 두 파드가 재시작된 상태이며 되돌릴 수 없다.
|
||||
|
||||
**왜 필요한가** — DB 만 지우면 캐시 엔트리가 그대로 있어 출발값이 어긋난다. 원래 실행에서 정리하려고 `delete from offline_user_session` 만 했더니 캐시 합계 19 와 DB 총계 15 가 맞지 않았다(observed). ② 의 시각은 나중에 Grafana 로 시계열을 볼 때 캐시가 0 으로 떨어진 절벽을 찾는 데 쓴다.
|
||||
|
||||
**문제가 생기면** — ③ 이 타임아웃으로 끝나면 파드 목록부터 보고, 파드가 안 뜨면 앞 단계인 `05-keycloak` 로 돌아간다.
|
||||
|
||||
### 2. PostgreSQL 문장 로깅 — 여기서 켜지 않는다
|
||||
|
||||
**목적** — 둘째 주입의 자리를 밝혀 둔다. 명령은 「관찰」의 시험 0d 안에 있다.
|
||||
|
||||
문장 로깅은 몇 초만 켠다. 여기서 켜면 시험 0·0b·0c 를 켠 채로 돌게 되고, 그 셋은 로그인과 refresh 를 수십 번 보내므로 로그가 폭주해 시험 0d 에서 찾아야 할 열두 줄이 묻힌다. 켜는 명령과 그 검증은 시험 0d 의 첫 두 단계다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 본다.
|
||||
|
||||
### 파드가 새로 떴고 IP 가 바뀌었는가
|
||||
|
||||
```bash label="[kc-lab-1] 새 파드와 새 IP 를 다시 잡는다"
|
||||
kubectl -n keycloak-lab get pods -o wide | grep 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"
|
||||
```
|
||||
|
||||
`AGE` 가 방금이고 `RESTARTS` 가 `0`(새 파드다), 그리고 IP 가 아까 적어 둔 값과 다른지 본다. IP 를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿고, 이 상태를 복제 실패로 읽는 실수가 이 실험에서 가장 흔하다.
|
||||
|
||||
### DB 에 세션이 한 행도 없는가
|
||||
|
||||
```bash label="[kc-lab-1] 남은 행을 센다"
|
||||
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"
|
||||
```
|
||||
|
||||
한 행도 없어야 한다. 행이 남아 있으면 `delete` 가 실패했거나 그 사이 누가 로그인했다.
|
||||
|
||||
### 캐시가 양쪽 다 0 인가
|
||||
|
||||
앞의 미검증 형태(`tr`·`grep` 줄)를 다시 치고 `cache":"sessions"` 인 두 줄이 다 `0` 인지 본다. 한쪽만 확인하고 넘어가면 원래 있던 값을 나중에 복제가 왔다고 읽는다. Prometheus 는 15초마다 긁으므로 재시작 직후에 물으면 옛 값이 나올 수 있어 30초쯤 기다렸다가 다시 친다.
|
||||
|
||||
## 관찰
|
||||
|
||||
상주 탐침 파드를 띄운다. Keycloak 이미지에 `curl` 이 없고, 토큰을 단계 사이로 넘겨야 하며, Service 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 어느 노드인가이므로 파드 IP 로 직접 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 탐침 파드를 띄우고 그 안의 셸로 들어간다"
|
||||
kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 \
|
||||
--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 -- sh
|
||||
```
|
||||
|
||||
셸에서 `exit` 하면 `--rm` 이 파드를 지운다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 존재와 길이만 밖에서 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
실측은 `19` 다(observed). 파드 안에서도 값이 들어왔는지 길이로만 본다.
|
||||
|
||||
```sh label="[탐침 파드] 환경변수가 들어왔는지 길이로 본다"
|
||||
echo "K0=$K0 K1=$K1 PW길이=${#PW}"
|
||||
```
|
||||
|
||||
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이므로 나가서 다시 띄운다.
|
||||
|
||||
### 시험 0 — 반대편 노드가 그 세션을 쓸 수 있는가
|
||||
|
||||
`keycloak-0` 에서 로그인하고 응답을 한 번 통째로 본다.
|
||||
|
||||
```sh label="[탐침 파드] ① 발급 노드에 로그인하고 응답을 그대로 본다"
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"
|
||||
```
|
||||
|
||||
`expires_in` 과 `refresh_expires_in` 을 본다. 실측은 이렇다(observed, `01-cross-node-session.txt`).
|
||||
|
||||
```text
|
||||
=== [1] keycloak-0 에서 로그인 ===
|
||||
sid jiv3rVZi1VeaO07oVJkL_MYW
|
||||
sub None
|
||||
iss https://auth.hyeonworks.com/realms/master
|
||||
access 수명 60초
|
||||
refresh 수명 1800초 typ=Refresh
|
||||
refresh jti 7669cc49-4778-851f-3c49-65f76964ae8e
|
||||
```
|
||||
|
||||
access token 은 60초짜리고 그동안은 서버에 안 물어본다. 그래서 탐침이 access token 이면 안 된다. `sub` 이 없는 것은 `admin-cli` 에 `scope` 없이 direct grant 를 하면 클레임이 `azp, exp, iat, iss, jti, scope, sid, typ` 뿐이기 때문이고(observed), 앞의 `userinfo` 403 과 원인이 같다.
|
||||
|
||||
```sh label="[탐침 파드] ② 토큰을 변수에 담고 길이로만 확인한다"
|
||||
R=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW")
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 access=${#AT}자"
|
||||
```
|
||||
|
||||
`refresh=1187자 access=2043자` 같은 모양이 나온다. 길이가 `0자` 면 로그인이 실패한 것이고 `echo "$R"` 로 에러 본문을 본다.
|
||||
|
||||
JWT 의 가운데 토막이 클레임이다. 먼저 통째로 디코드해 눈으로 보고 그다음에 sid 만 잘라낸다. 두 줄 다 미검증이다(unknown).
|
||||
|
||||
```sh label="[탐침 파드] ③ 클레임을 통째로 디코드해 본다"
|
||||
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
|
||||
```sh label="[탐침 파드] ④ sid 만 뽑는다"
|
||||
SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "SID=$SID"
|
||||
```
|
||||
|
||||
base64 패딩 때문에 끝이 깨져 보일 수 있고(`2>/dev/null` 이 그 불평을 지운다), `sid` 는 앞쪽에 있어서 대개 보인다.
|
||||
|
||||
같은 sid 가 두 노드 모두에서 보이는지 물으려면 `admin-cli` 의 내부 id 가 필요하다. 응답을 한 번 그대로 보고 무엇을 자르는지 눈으로 본 다음 잘라낸다. 잘라내는 줄은 미검증이다(unknown).
|
||||
|
||||
```sh label="[탐침 파드] ⑤ 클라이언트 목록 응답을 그대로 본다"
|
||||
curl -s -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
|
||||
```
|
||||
|
||||
```sh label="[탐침 파드] ⑥ 첫 번째 id 만 잘라낸다"
|
||||
CID=$(curl -s -H "Authorization: Bearer $AT" \
|
||||
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
|
||||
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
|
||||
echo "CID=$CID"
|
||||
```
|
||||
|
||||
`sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 뒤쪽의 다른 `id` 를 잡을 수 있다. `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고르기 때문이고, `tr ',' '\n' | grep -m1` 은 첫 번째 것을 고르므로 안전하다.
|
||||
|
||||
```sh label="[탐침 파드] ⑦ 같은 질문을 두 노드에 던진다"
|
||||
for H in "$K0" "$K1"; do
|
||||
echo -n "$H : "
|
||||
curl -s -H "Authorization: Bearer $AT" \
|
||||
"http://$H:8080/admin/realms/master/clients/$CID/user-sessions?max=100" \
|
||||
| grep -c "$SID"
|
||||
done
|
||||
```
|
||||
|
||||
이 `for` 루프도 미검증이다(unknown). 실측은 이렇다(observed, `01-cross-node-session.txt`).
|
||||
|
||||
```text
|
||||
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
|
||||
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
|
||||
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
|
||||
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
|
||||
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
|
||||
```
|
||||
|
||||
세션이 2개인 것은 실험 도구가 만든 잡음이고 판정에 안 쓴다. 여기까지는 (a) 와 (b) 를 구별하지 못한다.
|
||||
|
||||
시험군은 회전 때문에 반대편에 먼저 쓴다.
|
||||
|
||||
```sh label="[탐침 파드] ⑧ 반대편 노드에서 refresh 한다"
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
|
||||
echo "$R" | tail -1
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
```
|
||||
|
||||
`200`, 그리고 새 토큰의 sid 가 같은 값이어야 한다. sid 가 바뀌었다면 세션을 이어받지 않고 새로 만들었다는 뜻이다. 매번 `RT` 를 다시 담는다 — 옛 것을 계속 쓰면 나중에 나오는 400 이 무효화 때문인지 재사용 때문인지 알 수 없게 된다.
|
||||
|
||||
무효화가 반대 방향으로도 가는지 본다.
|
||||
|
||||
```sh label="[탐침 파드] ⑨ 반대편에서 로그아웃하고 발급 노드에서 다시 갱신해 본다"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
|
||||
-d client_id=admin-cli -d "refresh_token=$RT"
|
||||
|
||||
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
=== [6] keycloak-1 을 통해 로그아웃 ===
|
||||
http_code=204
|
||||
|
||||
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
|
||||
HTTP 400 ← 기대대로
|
||||
error invalid_grant
|
||||
error_description Session not active
|
||||
```
|
||||
|
||||
이 `400` 을 적어 둔다. A-1 에서 7800 을 막으면 같은 곳이 `200` 으로 바뀌고, 그것이 A-1 의 결론이다.
|
||||
|
||||
### 시험 0b — 복제인가, 같은 DB 를 본 것인가
|
||||
|
||||
로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다. 복제라면 반대편도 같이 늘고, 같은 DB 를 보는 것뿐이라면 반대편은 안 움직인다. 전값을 재고, `keycloak-0` 에만 로그인 한 번을 넣고, 30초 기다렸다가 후값을 같은 명령으로 잰다.
|
||||
|
||||
```sh label="[탐침 파드] 발급 노드에만 로그인 한 번을 넣고 나간다"
|
||||
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"
|
||||
exit
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-cache-delta.txt`).
|
||||
|
||||
```text
|
||||
=== keycloak-0 (로그인을 받은 노드) ===
|
||||
계수기 캐시 전 후 증가
|
||||
rpc.replication_count clientSessions 1 1 +0
|
||||
rpc.replication_count sessions 1 1 +0
|
||||
approximate_entries_unique clientSessions 1 2 +1 ←
|
||||
approximate_entries_unique sessions 1 2 +1 ←
|
||||
hits clientSessions 2 2 +0
|
||||
hits sessions 2 2 +0
|
||||
misses clientSessions 2 3 +1 ←
|
||||
misses sessions 3 4 +1 ←
|
||||
stores clientSessions 2 3 +1 ←
|
||||
stores sessions 2 3 +1 ←
|
||||
|
||||
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
|
||||
계수기 캐시 전 후 증가
|
||||
rpc.replication_count clientSessions 7 7 +0
|
||||
rpc.replication_count sessions 7 7 +0
|
||||
approximate_entries_unique clientSessions 0 0 +0
|
||||
approximate_entries_unique sessions 0 0 +0
|
||||
hits clientSessions 4 4 +0
|
||||
hits sessions 4 4 +0
|
||||
misses clientSessions 0 0 +0
|
||||
misses sessions 0 0 +0
|
||||
stores clientSessions 1 1 +0
|
||||
stores sessions 1 1 +0
|
||||
```
|
||||
|
||||
`keycloak-1` 열이 전부 `+0` 이다. 엔트리도 0, 저장도 0 이고 `keycloak-1` 의 `sessions` 엔트리는 처음부터 끝까지 0 이다. `rpc.replication_count` 가 `1`·`7` 로 0 이 아닌 것에 속으면 안 된다 — 이 계수기는 세션 캐시만의 것이 아니라 클러스터가 다른 용무로 주고받은 것까지 센다. 판정은 증가분이 0 이라는 것으로 한다.
|
||||
|
||||
### 시험 0c — 엔트리는 어느 노드에 있는가
|
||||
|
||||
반대편 노드에 로그인을 몰아주면 분산 캐시(owners=1)와 로컬 캐시가 갈린다.
|
||||
|
||||
0b 에서 `exit` 했으므로 `--rm` 이 탐침 파드를 이미 지웠다. 0c 와 0d 는 파드 안에서 치므로 같은 명령으로 다시 띄운다.
|
||||
|
||||
```bash label="[kc-lab-1] 탐침 파드를 다시 띄운다"
|
||||
kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 \
|
||||
--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 -- sh
|
||||
```
|
||||
|
||||
```sh label="[탐침 파드] ① 반대편 노드에 로그인 5회를 몰아준다"
|
||||
for i in 1 2 3 4 5; do
|
||||
curl -s -o /dev/null -w '%{http_code} ' -X POST \
|
||||
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW"
|
||||
done; echo
|
||||
```
|
||||
|
||||
30초 기다렸다가 관찰용 터미널에서 엔트리를 잰다. 「주입 전에」 §6 의 `tr`·`grep` 형태를 그대로 쓰고, `cache":"sessions"` 인 두 줄의 값을 적어 둔다. 스크레이프 간격이 15초라 바로 물으면 옛 값이 나온다.
|
||||
|
||||
그다음 같은 루프를 `$K1` 만 `$K0` 로 바꿔 한 번 더 친다. 원 가이드는 이 두 번째 루프를 「`$K1` 을 `$K0` 로 바꿔 5회 더」라고 문장으로만 적었다.
|
||||
|
||||
```sh label="[탐침 파드] ② 이번엔 발급 노드에 5회를 몰아준다"
|
||||
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
|
||||
```
|
||||
|
||||
30초 기다렸다 같은 방법으로 다시 잰다. 실측은 이렇다(observed, `03-cache-ownership.txt`).
|
||||
|
||||
```text
|
||||
keycloak-0 = 10.42.1.43 (kc-lab-2)
|
||||
keycloak-1 = 10.42.0.35 (kc-lab-1)
|
||||
|
||||
단계 k0 entries k1 entries
|
||||
시작 2.0 0.0
|
||||
keycloak-1 에 로그인 5회 2.0 5.0
|
||||
keycloak-0 에 로그인 5회 7.0 5.0
|
||||
|
||||
=== 대조: PostgreSQL 에는 몇 건인가 ===
|
||||
online 세션 12
|
||||
```
|
||||
|
||||
대각선이다. 한 번에 한 쪽만 늘고, `7 + 5 = 12` 로 DB 총계와 맞으므로 어느 엔트리도 두 번 세어지지 않았다.
|
||||
|
||||
```bash label="[kc-lab-1] DB 총계를 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
캐시 설정은 파일에서 읽을 수 없다. 파드의 `/opt/keycloak/conf/cache-ispn.xml` 은 `<cache-container name="keycloak"><transport/></cache-container>` 뿐이고 Keycloak 26 은 캐시를 코드에서 만든다. 위 결론은 설정을 읽어서가 아니라 동작을 측정해서 얻었다.
|
||||
|
||||
### 시험 0d — SQL 을 직접 잡는다
|
||||
|
||||
0b·0c 까지는 추론이다. 여기서 둘째 주입인 문장 로깅을 켠다. 앞의 세 시험이 끝난 지금 켜는 것이고, 시험 0d 가 끝나면 이 절의 마지막에서 곧바로 끈다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 문장 로깅과 클라이언트 주소 접두사를 켜고 같은 명령에서 reload 한다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system set log_statement='all'" \
|
||||
-c "alter system set log_line_prefix='%m [%p] %h '" \
|
||||
-c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
`pg_reload_conf` 가 `t` 를 돌려준다. `%h` 가 클라이언트 주소를 로그 줄 앞에 남기는데, 이것이 없으면 어느 파드가 보낸 질의인지 구별할 수 없어 이 시험의 판정이 성립하지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 두 설정이 실제로 적용됐는지 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement" -c "show log_line_prefix"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `04-read-path-sql.txt`).
|
||||
|
||||
```text
|
||||
log_statement = all
|
||||
log_line_prefix = %m [%p] %h
|
||||
```
|
||||
|
||||
`log_statement` 가 아직 `none` 이면 `alter system` 이 `postgresql.auto.conf` 에 쓰기만 하고 `pg_reload_conf()` 가 안 돈 상태다.
|
||||
|
||||
이제 요청을 딱 한 번 보낸다. 여러 번 보내면 로그에서 어느 트랜잭션이 어느 요청인지 구별하기 어려워진다.
|
||||
|
||||
```sh label="[탐침 파드] ③ 로그인 한 번 · 반대편에서 refresh 한 번"
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
R=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW")
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "SID=$SID"
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
=== 요청 ===
|
||||
SID=jSt9GEPVQLJsO-1CeJjVgltg
|
||||
K1_ENTRIES_BEFORE=5.0
|
||||
REFRESH_ON_K1=200
|
||||
K1_ENTRIES_AFTER=5.0
|
||||
```
|
||||
|
||||
`%h` 가 남긴 IP 로 걸러 `keycloak-1` 이 보낸 것만 본다. 거르는 명령은 관찰용 터미널에서 치는데 거기에는 `K0`·`K1` 이 없다. 두 값은 「주입 검증」에서 잡았는데 그 터미널을 지금 탐침 파드 셸이 붙잡고 있으므로, 관찰용 터미널에서 두 줄을 다시 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 관찰용 터미널에서도 파드 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"
|
||||
```
|
||||
|
||||
두 값이 「주입 검증」에서 본 것과 같아야 한다. 이 두 줄을 건너뛰고 다음 블록을 치면 `grep "$K1"` 이 `grep ""` 가 되어 모든 줄이 통과하므로, 두 파드가 날린 문장을 `keycloak-1` 만 걸러 낸 것으로 읽게 되고 뒤에서 세는 건수도 양쪽이 같은 값으로 나온다. 화면에는 아무 경고도 안 뜬다.
|
||||
|
||||
```bash label="[kc-lab-1] 반대편 노드가 날린 문장만 추린다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||
| grep "$K1" | grep 'LOG: execute'
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `04-read-path-sql.txt`).
|
||||
|
||||
```text
|
||||
select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,...,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2))
|
||||
select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked
|
||||
select pcse1_0.CLIENT_ID,...,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (...) in (($1,$2,$3,$4,$5))
|
||||
select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where ... for no key update of pcse1_0 skip locked
|
||||
update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and ... and VERSION=$8
|
||||
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
|
||||
SET LOCAL synchronous_commit TO OFF
|
||||
COMMIT
|
||||
```
|
||||
|
||||
`keycloak-1` 은 세션을 DB 에서 읽고 DB 에 쓴다. 파라미터는 `DETAIL` 줄에 있다. sid 는 ③ 이 `SID=` 로 화면에 찍은 값을 옮겨 넣는다 — 그 변수는 탐침 파드 안에만 있어서 `[kc-lab-1]` 셸에서는 `$SID` 가 빈 문자열이다. 로그인할 때마다 새로 생기는 값이기도 하다.
|
||||
|
||||
```bash label="[kc-lab-1] 그 sid 가 들어간 줄만 앞에서 120자씩 본다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||
| grep '{{SID}}' | cut -c1-120
|
||||
```
|
||||
|
||||
이 실험대의 값은 `jSt9GEPVQLJsO-1CeJjVgltg` 였다(observed).
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||
...
|
||||
2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
|
||||
2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0'
|
||||
2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-...', ...
|
||||
2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0'
|
||||
```
|
||||
|
||||
증거 파일에는 IP 대신 `[keycloak-0]` `[keycloak-1]` 이 적혀 있다. 원래 실행 스크립트가 `sed` 로 IP 를 파드 이름으로 바꿔 놓은 것이고, 따라 하는 화면에는 `10.42.0.35` 같은 IP 가 그대로 나온다. pid 도 본다 — `81407` 은 `keycloak-0` 의 연결, `81376` 은 `keycloak-1` 의 연결이며 pid 가 트랜잭션의 경계를 가른다.
|
||||
|
||||
이 갱신 트랜잭션은 `01:12:34.934` 의 `BEGIN` 에서 `01:12:34.947` 의 `COMMIT` 까지 13밀리초다. `BEGIN` 과 `COMMIT` 은 sid 를 파라미터로 달지 않아서 위 `grep` 에 안 걸린다. 그래서 화면에 남는 마지막 줄이 `.946` 이고, 거기까지만 세면 12 가 나온다. 경계 두 줄은 같은 pid `81376` 연결에서 나왔고, 실험 기록에 `pid=… |` 꼴로 옮겨 적힌 것으로만 남아 있다(observed). 자기 화면에서 그 둘을 보려면 sid 필터를 빼고 로그에 찍힌 그 pid 로 다시 걸러야 하는데, 그 명령은 원 가이드에 없다(unknown). 그 두 줄이 하는 일은 트랜잭션의 경계를 긋는 것이다. 뒤에 나오는 `SET LOCAL synchronous_commit TO OFF` 가 같은 트랜잭션 안에서 `COMMIT` 직전에 나왔다는 판정이 거기서 나오고, 원 가이드는 그 확인을 「pid 로 경계를 확인했다」로 적는다.
|
||||
|
||||
파드별 질의 건수는 미검증 형태로 센다(unknown). 여기서도 sid 는 ③ 이 찍은 자기 값이다.
|
||||
|
||||
```bash label="[kc-lab-1] 두 파드가 각각 몇 줄을 날렸는지 센다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||
| grep '{{SID}}' | grep -c "$K0"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||
| grep '{{SID}}' | grep -c "$K1"
|
||||
```
|
||||
|
||||
실측은 `6 [keycloak-1]` 과 `6 [keycloak-0]` 이다(observed). sid 하나에 대해 `keycloak-0` 이 6건(로그인), `keycloak-1` 이 6건(갱신)을 날렸다.
|
||||
|
||||
위 실측의 `K1_ENTRIES_BEFORE=5.0` 과 `K1_ENTRIES_AFTER=5.0` 이 같다. `keycloak-1` 은 남의 세션을 DB 에서 읽어 처리하고도 캐시에 담지 않았다. 캐시에 담기는 것은 그 노드가 로그인시켜 만든 세션뿐이고 남의 세션은 매번 DB 에서 읽는다. 세션 어피니티가 정확성이 아니라 성능 문제인 까닭이 여기 있다.
|
||||
|
||||
같은 로그에 jdbc-ping 하트비트도 보인다(observed).
|
||||
|
||||
```text
|
||||
01:12:37.551 pid=81369 | BEGIN
|
||||
01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1
|
||||
01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...)
|
||||
01:12:37.553 pid=81369 | COMMIT
|
||||
```
|
||||
|
||||
디스커버리는 별도 연결(pid 가 다르다)에서 주기적으로 자기 행을 지우고 다시 넣는다. 디스커버리와 트랜스포트가 다른 경로라는 것이 로그에서 눈으로 확인되고, A-1 이 그 둘을 갈라 끊는다.
|
||||
|
||||
13밀리초짜리 그 트랜잭션에는 셋이 들어 있었다. 낙관적 락(`VERSION` 컬럼), `FOR NO KEY UPDATE ... SKIP LOCKED`, 그리고 `SET LOCAL synchronous_commit TO OFF` 다. 전역 설정은 다르다.
|
||||
|
||||
```bash label="[kc-lab-1] 전역 설정을 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show synchronous_commit"
|
||||
```
|
||||
|
||||
전역은 `on` 이고 Keycloak 이 세션 트랜잭션에만 `SET LOCAL` 로 끈다. `SET LOCAL` 은 그 트랜잭션이 끝나면 되돌아간다. PostgreSQL 이 갑자기 죽으면 직전 수백 밀리초의 세션 쓰기가 사라질 수 있고, A-3 이 그 숫자를 잰다.
|
||||
|
||||
여기까지가 탐침 파드에서 칠 것의 마지막이다. 파드 셸을 붙잡고 있던 터미널에서 나온다. 나가지 않으면 `--rm` 이 파드를 안 지우고 원상복구 확인표의 `kc-probe` 줄이 `NotFound` 가 아니게 된다.
|
||||
|
||||
```sh label="[탐침 파드] 나온다. --rm 이 파드를 지운다"
|
||||
exit
|
||||
```
|
||||
|
||||
문장 로깅은 곧바로 끈다.
|
||||
|
||||
```bash label="[kc-lab-1] 문장 로깅을 끄고 꺼졌는지 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
|
||||
-c "select pg_reload_conf()"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
|
||||
켜 둔 채로 다음 실험에 들어가면 안 된다. 로그인 루프를 도는 A-3 에서 `log_statement='all'` 을 켜 두면 로그가 폭주한다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
이 실험은 세션을 만들 뿐 클러스터를 부수지 않는다. 되돌릴 것은 둘이다.
|
||||
|
||||
### 1. 문장 로깅을 끈 상태로 되돌린다
|
||||
|
||||
**목적** — 다음 실험이 옛 설정 위에서 돌지 않게 한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 두 설정을 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement" -c "show log_line_prefix"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② none 이 아니면 두 설정을 되돌리고 reload 한다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
|
||||
-c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**예상 결과** — `log_statement` 가 `none` 이다.
|
||||
|
||||
**왜 필요한가** — A-3 은 초당 14건으로 로그인을 도는데 문장 로깅이 켜져 있으면 로그가 폭주하고 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.
|
||||
|
||||
**문제가 생기면** — `pg_reload_conf()` 를 다시 친다. `alter system` 만으로는 적용되지 않는다.
|
||||
|
||||
### 2. 실험이 만든 세션을 정리한다
|
||||
|
||||
**목적** — DB 행과 캐시 엔트리를 함께 비워 다음 실험의 출발값을 0 으로 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 세션 행을 지운다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_user_session"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 파드를 갈아 끼워 캐시를 비운다"
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
**예상 결과** — 두 파드가 새로 뜨고 세션 캐시가 양쪽 다 0 이 된다.
|
||||
|
||||
**왜 필요한가** — 재시작을 빼면 DB 만 비워지고 캐시가 남아 다음 실험의 출발값이 어긋난다.
|
||||
|
||||
**문제가 생기면** — 아래 확인표의 항목을 위에서부터 하나씩 친다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| 클러스터 뷰 | `kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||
| 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 **하나** |
|
||||
| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` |
|
||||
| 캐시 | `vendor_statistics_approximate_entries_unique` | `sessions` 두 줄 다 `0` |
|
||||
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
|
||||
| 탐침 파드 | `kubectl -n keycloak-lab get pod kc-probe` | `NotFound` (없어야 정상) |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
탐침 파드가 지워지지 않았으면 직접 지운다.
|
||||
|
||||
```bash label="[kc-lab-1] --rm 이 안 먹었을 때"
|
||||
kubectl -n keycloak-lab delete pod kc-probe --ignore-not-found
|
||||
```
|
||||
|
||||
## 막히면
|
||||
|
||||
아래는 전부 이 실험대가 실제로 겪은 증상이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 띄우거나 Prometheus 에 묻는다 |
|
||||
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 를 다시 |
|
||||
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — `0` 이면 `--env` 가 빈 값 |
|
||||
| 반대편 응답만 보고 「복제 실패」로 읽었다 | **대조군이 없다** | 발급 노드에 같은 요청을 나란히 |
|
||||
| `userinfo` 가 양쪽 다 `403` | **`openid` scope 가 없다.** 복제와 무관 | 본문의 `insufficient_scope` |
|
||||
| 세션 개수가 계속 어긋난다 | **관리 API 호출도 세션을 만든다** | 개수 말고 **sid** 로 본다 |
|
||||
| 캐시 합계와 DB 총계가 안 맞는다 | **DB 만 지우고 파드를 재시작 안 했다** | `rollout restart statefulset/keycloak` |
|
||||
| 로그인했는데 지표가 안 움직인다 | Prometheus 스크레이프는 15초 간격 | 30초 기다렸다 다시 |
|
||||
| `rpc.replication_count` 가 0 이 아니라 당황 | 세션 캐시만의 계수기가 아니다 | 절대값이 아니라 **증가분**으로 본다 |
|
||||
| `CID` 가 엉뚱한 값이다 | `sed` 의 `.*` 가 탐욕적이라 **마지막** `"id"` 를 잡는다 | `tr ',' '\n' \| grep -m1 '"id"'` |
|
||||
| 문장 로깅을 켰는데 SQL 이 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 |
|
||||
| 로그에 어느 파드인지 안 나온다 | `log_line_prefix` 에 `%h` 가 없다 | `show log_line_prefix` |
|
||||
| 다음 실험에서 postgres 로그가 폭주한다 | **문장 로깅을 껐는지 확인 안 했다** | `show log_statement` 가 `none` |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 09:52–10:14 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 파드 IP `10.42.1.43`·`10.42.0.35`, 뷰 ID `5` 와 멤버 `(2)`, `jgroups_ping` 의 `coord = t` 하나, 캐시 델타 전량, `7 + 5 = 12`, `keycloak-1` 이 날린 SQL 여덟 줄, pid `81407`/`81376`, 비밀번호 길이 `19`.
|
||||
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력, JWT 를 디코드해 sid 를 뽑는 `sed` 줄, `CID` 를 뽑는 줄, 두 노드에 `grep -c` 를 도는 `for` 루프, 파드별 질의 건수를 세는 두 줄. 가이드가 미검증으로 표시했고 원래 실행은 스크립트로 했다.
|
||||
- 트랜잭션 경계 시각 `01:12:34.934` 와 `01:12:34.947` 은 실험 기록 한 벌에만 있다. `04-read-path-sql.txt` 에 보존된 것은 sid 가 걸린 `DETAIL` 줄과 시각 없는 문장 목록이라 타임스탬프가 붙은 `BEGIN`/`COMMIT` 이 없다. 그래서 13밀리초를 증거 원문으로 다시 확인할 수는 없다.
|
||||
- 캐시 설정은 파일에서 읽을 수 없다. `cache-ispn.xml` 에는 `<transport/>` 뿐이고, 「세션은 DB 로 공유된다」는 판정은 설정을 읽어서가 아니라 동작을 측정해서 얻었다.
|
||||
- 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). `kubectl run --rm -i ... | grep` 로 받았더니 중간 조각이 통째로 사라져 `keycloak-1` 의 스냅샷과 다음 마커가 함께 없어졌고, 전값이 0 으로 잡히면서 가짜 델타가 만들어졌다. 그때 리포트는 `keycloak-1` 이 `+9`, `+7` 증가한 것처럼 보였다 — 없는 복제가 있는 것처럼 보이는 오류다. 다른 하나는 중첩 인용이다. `ssh host '... $VAR ...'` 안에 다시 `sh -c "..."` 를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨졌고, 첫 시도에서 파드 IP 가 빈 문자열이 되어 아무 출력도 나오지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
---
|
||||
id: 95e29500-b535-4246-86db-d569ed814904
|
||||
kind: SETUP
|
||||
slug: reproduce-a1-jgroups-transport-block
|
||||
title: 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/95e29500-b535-4246-86db-d569ed814904/edit"
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
source:
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-1
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다
|
||||
|
||||
NetworkPolicy 로 8080 과 9000 만 열어 JGroups 트랜스포트인 TCP 7800 만 끊는 절차다. 그러고도 25분 동안 클러스터가 안 깨지는 것을 보고 conntrack 표에서 까닭을 찾은 뒤, 정책이 걸린 채로 파드를 지워 분단을 만든다. 전 구간 약 30분.
|
||||
|
||||
## 관계
|
||||
|
||||
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
|
||||
이 절차가 낸 결론을 담은 기록이다. 여기에는 치는 순서만 있다.
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
정책을 걸었는데 `vendor_cluster_size` 가 25분 내내 2 였던 것이 그 아홉 건 중 하나다.
|
||||
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
|
||||
분단된 노드가 스스로 Service 에서 빠져 정문이 `200` 을 유지한 까닭을 다룬다.
|
||||
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
|
||||
이 편의 원래 실행이 빈 문자열을 「변화」로 읽고 빠져나온 판정 조건을 담고 있다.
|
||||
- **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다**
|
||||
이 편의 대조군 값(`200`·`400`)이 거기서 나온다. 먼저 해 두지 않으면 차단 후의 `200` 이 무엇과 다른지 알 수 없다.
|
||||
- **한 방향만 끊어 보고 raw PREROUTING 까지 내려간다**
|
||||
같은 7800 을 `iptables` 로 한 방향만 끊는 편이고, conntrack 으로 연결 방향을 먼저 보는 순서를 그쪽에서 다시 쓴다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
`kubectl` 은 `[kc-lab-1]` 에서 친다. `conntrack` 은 노드 자체를 건드리는 명령이라 게스트 셸이 필요하고 두 노드 모두에서 봐야 한다 — 이쪽 노드는 그대로 치고 반대 노드는 `ssh kc-lab-2` 로 붙어서 친다. `kubectl` 에는 `sudo` 를 붙이지 않는다. root 홈에 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 코드블록마다 어느 셸인지 붙여 두었다.
|
||||
|
||||
주입에 쓰는 매니페스트 경로 `deploy/lab/k8s/a1-block-jgroups-transport.yaml` 는 저장소 상대경로다. 체크아웃을 `kc-lab-1` 의 어디에 뒀는지는 원 가이드에 없고 거기로 옮기는 명령도 없으므로(unknown), 이 상대경로가 그대로 통하는 디렉터리에서 시작한다. 다른 디렉터리에서 치면 `cat` 도 `kubectl apply` 도 파일을 못 찾고 끝난다.
|
||||
|
||||
터미널은 둘을 연다. 하나는 임시 curl 파드용, 하나는 관찰용이다. 그래서 `[kc-lab-1]` 라벨이 붙은 블록이 `[탐침 파드]` 블록 사이에 끼어 있으면 **관찰용 터미널에서 친다** — 파드 셸을 나가라는 뜻이 아니다. 나가라고 할 때는 `exit` 를 블록으로 따로 적는다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
|
||||
| 막는 포트 | `7800`(트랜스포트). `57800`(FD_SOCK2 = `bind_port + 50000`)도 함께 막힌다 |
|
||||
| 주입 수단 | NetworkPolicy `a1-block-jgroups-transport` — 허용 목록이라 8080·9000 만 연다 |
|
||||
| 탐침 파드 | `kc-probe` — `curlimages/curl:8.11.1`, `--rm -it`, `--restart=Never` |
|
||||
| 분단 판정 | `jgroups_ping` 의 `coord = t` 가 두 줄 |
|
||||
| 걸리는 시간 | 전 구간 약 30분 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
A-0 은 세션이 Infinispan 복제가 아니라 PostgreSQL 로 공유된다고 측정했다. Keycloak 24 이전 자료는 세션이 7800 으로 복제된다고 말한다. 통념은 7800 을 막으면 세션 공유가 깨진다고 예측하고 A-0 모델은 안 깨진다고 예측하므로, 7800 만 끊어 보면 둘 중 어느 쪽이 틀렸는지 판정된다.
|
||||
|
||||
끊을 때 두 가지를 갈라야 한다.
|
||||
|
||||
```text
|
||||
디스커버리 노드가 서로를 어떻게 찾는가 → PostgreSQL 의 JGROUPS_PING 테이블
|
||||
트랜스포트 실제로 어떻게 말하는가 → TCP 7800
|
||||
```
|
||||
|
||||
트랜스포트만 막으면 DB 에는 둘 다 등록되어 있는데 메시지는 안 가는 상태가 된다. 단일 노드에서는 만들 수 없는 고장이고, 이 실험대가 VM 두 대인 까닭이 여기 있다.
|
||||
|
||||
절차를 끝까지 밟으면 NetworkPolicy 를 걸었는데도 클러스터가 안 깨지는 상태, `coord = t` 가 두 줄인 split brain, 분단인데도 교차 노드 refresh 가 `200` 인 것, 로그아웃했는데 반대편이 `200` 을 주는 것, 분단된 노드가 스스로 Service 에서 빠지는 것, 90초 만에 자동으로 다시 붙는 것을 자기 화면에서 보게 된다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` · `06-observability` 가 끝나 있다.
|
||||
- 두 Keycloak 파드가 서로 다른 노드에 있어야 한다. 단일 노드에서는 이 고장을 만들 수 없다.
|
||||
- `kc-lab-2` 에 `ssh` 로 붙을 수 있어야 한다. **conntrack 은 두 노드 모두에서** 봐야 한다.
|
||||
|
||||
**이건 상태를 부수는 실험이다.** Keycloak 클러스터를 실제로 분단시키고 파드를 재시작한다. **실험대에서만 한다.** 중간에 그만두려면 아래 한 줄이면 된다.
|
||||
|
||||
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
|
||||
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
차단 후에 볼 것을 차단 전에 똑같은 명령으로 먼저 봐 둔다. 시험군만 재는 측정은 측정이 아니다.
|
||||
|
||||
```text
|
||||
노드 → 파드 → 정책 → 클러스터 뷰(로그) → 디스커버리(DB) → 지표(Prometheus) → 대조군 시험
|
||||
```
|
||||
|
||||
### 1. 두 파드가 서로 다른 노드에 있는가
|
||||
|
||||
**무엇을 보는가** — 파드 둘의 상태와 배치, 그리고 뒤에서 쓸 파드 주소.
|
||||
|
||||
```bash label="[kc-lab-1] ① 노드를 본다"
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 파드가 어느 노드에 있는지 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
```bash label="[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}')
|
||||
echo "$K0 $K1"
|
||||
```
|
||||
|
||||
**어디를 보나** — `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, `NODE` 열이 서로 다른지. 실측은 `10.42.1.43 10.42.0.35` 였다(observed).
|
||||
|
||||
**이 값이 뜻하는 것** — `RESTARTS` 는 뒤에서 다시 센다. 이 값이 오르면 주입이 엉뚱한 곳을 건드렸다.
|
||||
|
||||
### 2. 기존 정책이 없는가
|
||||
|
||||
**무엇을 보는가** — 네임스페이스에 이미 걸린 NetworkPolicy.
|
||||
|
||||
```bash label="[kc-lab-1] 네임스페이스의 정책 목록"
|
||||
kubectl -n keycloak-lab get networkpolicy
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-baseline-cluster.txt`).
|
||||
|
||||
```text
|
||||
No resources found in keycloak-lab namespace.
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — NetworkPolicy 는 합집합으로 허용되므로 두 개가 겹치면 무엇이 열려 있는지 한눈에 안 보인다.
|
||||
|
||||
### 3. 양쪽 클러스터 뷰가 같은 줄인가
|
||||
|
||||
**무엇을 보는가** — 두 노드가 같은 멤버 목록을 찍고 있는지.
|
||||
|
||||
```bash label="[kc-lab-1] 양쪽 로그에서 마지막 클러스터 뷰 한 줄씩"
|
||||
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||
kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||
```
|
||||
|
||||
**어디를 보나** — 두 줄이 완전히 같은지. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
keycloak-0: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
|
||||
keycloak-1: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `keycloak-0-30843` 의 뒤 숫자는 JGroups 가 붙인 것이고 파드가 재시작되면 바뀐다. 나중에 `keycloak-0-26403` 이 나오면 같은 파드의 새 인스턴스다.
|
||||
|
||||
### 4. 디스커버리 테이블에 둘 다 등록돼 있는가
|
||||
|
||||
**무엇을 보는가** — `JGROUPS_PING` 의 세 열.
|
||||
|
||||
```bash label="[kc-lab-1] 디스커버리 테이블 세 열"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
|
||||
**어디를 보나** — `coord` 열의 `t` 개수. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-0-30843 | 10.42.1.43:7800 | f
|
||||
keycloak-1-48749 | 10.42.0.35:7800 | t
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 여기서 셋이 서로 다른 것을 말한다. 로그는 그때 그렇게 보였다는 기록이고, 테이블은 지금 등록되어 있다는 것이며, 지표는 지금 그 노드가 그렇게 안다는 것이다. A-1 에서 이 셋이 갈린다.
|
||||
|
||||
### 5. 두 노드가 아는 멤버 수를 지표로 본다
|
||||
|
||||
**무엇을 보는가** — 각 노드가 스스로 세는 클러스터 크기.
|
||||
|
||||
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
라벨을 보고 나면 읽기 좋게 자른다. 아래 두 줄은 가이드가 미검증으로 표시했고(unknown), 둘째 줄은 `jq` 가 깔려 있는 환경용이라 이 실험대에서는 쓸 수 없다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 라벨을 보고 나서 필요한 줄만 자른다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ jq 가 있는 환경이라면 이 형태 — 이 실험대에는 jq 가 없다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| jq -r '.data.result[] | "\(.metric.pod) \(.metric.node) \(.value[1])"'
|
||||
```
|
||||
|
||||
**어디를 보나** — 결과가 두 줄이고 값이 둘 다 `2` 인지. 한 노드만 보면 분단을 놓친다. 분단되면 한쪽만 1 이 되는 경로가 있다.
|
||||
|
||||
JGroups 카운터도 지금 0 인 것을 봐 둔다.
|
||||
|
||||
```bash label="[kc-lab-1] 병합 이벤트 계수기"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-control-before-block.txt`).
|
||||
|
||||
```text
|
||||
vendor_jgroups_merge3_get_num_merge_events 0.0 (양쪽 노드)
|
||||
vendor_jgroups_fd_sock2_get_num_suspected_members 0.0 (양쪽 노드)
|
||||
```
|
||||
|
||||
### 6. 대조군 시험을 차단 전에 한 번 돌린다
|
||||
|
||||
**무엇을 보는가** — 정상 클러스터에서 교차 노드 refresh 와 로그아웃 전파가 어떤 코드를 주는지.
|
||||
|
||||
임시 파드를 띄우고 그 안에서 A-0 과 같은 순서로 로그인·refresh·로그아웃을 친다. 임시 파드인 까닭은 셋이다. Keycloak 이미지에 `curl` 이 없어 Keycloak 파드 안에서는 못 치고, 토큰을 단계 사이로 넘겨야 하니 한 셸 안에서 다 끝내야 하며, Service 로 보내면 어느 노드가 처리했는지 알 수 없다. 이 실험의 질문 자체가 「어느 노드인가」라서 파드 주소로 직접 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 임시 curl 파드를 띄우고 그 안의 셸로 들어간다"
|
||||
kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 \
|
||||
--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 -- sh
|
||||
```
|
||||
|
||||
비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 파드 안에서는 `echo ${#PW}` 로 길이만 본다.
|
||||
|
||||
파드 안 셸에서 먼저 `keycloak-0` 에 로그인한다. 뒤의 refresh 가 쓰는 `$TOK` 와 `$RT` 가 여기서 생기므로 이 블록을 건너뛰면 다음 명령이 빈 문자열을 보낸다.
|
||||
|
||||
```sh label="[탐침 파드] ① keycloak-0 에 로그인해 refresh token 을 잡는다"
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
R=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW")
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 access=${#AT}자"
|
||||
```
|
||||
|
||||
`refresh=1187자 access=2043자` 같은 모양이 나온다. 길이가 `0자` 면 로그인이 실패한 것이고 `echo "$R"` 로 에러 본문을 본다.
|
||||
|
||||
같은 응답에 access token 도 들어 있지만 탐침으로 쓰지 않는다. access token 은 60초짜리고 그동안은 서버에 안 물어보므로, 노드가 세션 저장소를 실제로 뒤져야 답할 수 있는 refresh 를 쓴다.
|
||||
|
||||
```sh label="[탐침 파드] ② 반대편 노드에서 refresh 해 본다"
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
|
||||
echo "$R" | tail -1
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
```
|
||||
|
||||
마지막 줄이 `RT` 를 다시 담는 까닭은 refresh token 이 회전하기 때문이다. 갱신할 때마다 새 것이 나오므로, 옛 것을 계속 쓰면 뒤에 나오는 `400` 이 무효화 때문인지 재사용 때문인지 갈리지 않는다.
|
||||
|
||||
실측은 이렇다(observed, `02-control-before-block.txt`).
|
||||
|
||||
```text
|
||||
sid tAWs2gCPr6SOcD4jDR9-_CzB
|
||||
keycloak-1 에서 refresh: 200
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 이 `200` 이 대조군이다. 차단 후에도 200 이면 원래 되던 것이 그대로 된 것이고, 차단 후 400 이면 내가 깨뜨렸다는 뜻이다.
|
||||
|
||||
로그아웃 전파도 대조군을 잡는다.
|
||||
|
||||
```sh label="[탐침 파드] ③ 반대편에서 로그아웃하고 발급 노드에서 다시 갱신해 본다"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
|
||||
-d client_id=admin-cli -d "refresh_token=$RT"
|
||||
|
||||
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
|
||||
```
|
||||
|
||||
정상 클러스터에서는 `204` 다음에 이 두 줄이 나온다.
|
||||
|
||||
```text
|
||||
{"error":"invalid_grant","error_description":"Session not active"}
|
||||
400
|
||||
```
|
||||
|
||||
이 `400` 은 A-0 에서 측정한 값이고, A-1 의 대조군 기록에는 refresh `200` 만 있고 로그아웃 단계가 없다. 그래서 차단 전에 직접 재 두는 편이 낫다.
|
||||
|
||||
대조군을 다 잡았으면 파드 셸에서 나온다. 나가지 않으면 `kc-probe` 가 그대로 살아 있어서, 뒤에서 같은 이름으로 다시 띄울 때 `AlreadyExists` 로 거절된다.
|
||||
|
||||
```sh label="[탐침 파드] ④ 나온다. --rm 이 파드를 지운다"
|
||||
exit
|
||||
```
|
||||
|
||||
## 주입
|
||||
|
||||
### 1. NetworkPolicy 로 7800 만 뺀다
|
||||
|
||||
**목적** — 8080 과 9000 은 열어 둔 채 7800 으로 오는 새 연결만 막는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 매니페스트를 먼저 읽는다"
|
||||
cat deploy/lab/k8s/a1-block-jgroups-transport.yaml
|
||||
```
|
||||
|
||||
파일은 앞 31행이 영어 주석이고 그 아래가 매니페스트다. 주석 31행을 뺀 본문 전문은 이렇다. 매니페스트 안에 남은 `#` 주석도 파일에 적힌 영어 그대로다.
|
||||
|
||||
```yaml label="a1-block-jgroups-transport.yaml — 주석을 뺀 본문 전문"
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: a1-block-jgroups-transport
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: keycloak
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- ports:
|
||||
- { port: 8080, protocol: TCP } # HTTP — must stay open
|
||||
- { port: 9000, protocol: TCP } # health + metrics — must stay open
|
||||
# 7800 is absent on purpose. That is the whole experiment.
|
||||
```
|
||||
|
||||
① 에서 이 매니페스트를 못 찾으면 파일을 직접 만든다. 같은 경로를 에디터로 열어 위 열다섯 줄을 그대로 넣고 저장한다. `printf` 나 `cat <<EOF` 로 찍어 내지 않는다. 주입 검증에서 `describe` 로 대조할 것이 이 줄들이고, 덧붙이는 형태로 쓰면 같은 절차를 두 번 밟았을 때 파일에 같은 내용이 두 벌 쌓인다. 파일을 여는 명령은 원 가이드에 없다(unknown).
|
||||
|
||||
매니페스트 안에 `metadata.namespace` 가 `keycloak-lab` 으로 적혀 있다. ② 의 `apply` 에는 `-n` 이 없으니 네임스페이스는 이 줄이 정한다. 손으로 옮겨 적다 이 줄을 빠뜨리면 정책이 `default` 네임스페이스에 걸린다. 거기에는 `app=keycloak` 인 파드가 없으니 정책은 만들어지는데 아무 파드도 안 잡힌다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 적용하고 시각을 남긴다"
|
||||
kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
|
||||
date '+%H:%M:%S 적용'
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `03-block-applied.txt`).
|
||||
|
||||
```text
|
||||
networkpolicy.networking.k8s.io/a1-block-jgroups-transport created
|
||||
적용 시각: 11:38:08
|
||||
```
|
||||
|
||||
**왜 필요한가** — NetworkPolicy 는 방화벽이 아니라 허용 목록이다. 「7800 을 거부」라고 쓸 방법이 없고, 파드가 `policyTypes: [Ingress]` 를 가진 정책에 선택되는 순간 모든 인바운드가 거부되며 규칙에 적힌 것만 통과한다. 7800 은 빠뜨림으로써 막힌다. 그 구조 때문에 두 허용 규칙이 결정적이다 — 8080 을 빼면 Traefik 과 상대 노드의 REST 호출이 전부 끊기고, 9000 을 빼면 readiness 프로브가 실패해 kubelet 이 파드를 죽여 엉뚱한 이유로 클러스터가 깨진다. 덤으로 57800 도 막힌다. FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을 쓰는데, 손으로 「7800 거부」 규칙을 쓰면 이 포트를 빠뜨리기 쉽고 허용 목록 방식은 8080·9000 외 전부 거부이므로 자동으로 같이 막힌다.
|
||||
|
||||
**문제가 생기면** — 적용 시각을 반드시 적어 둔다. 이 실험은 시각이 겹친 것을 인과로 잘못 읽었다가 나중에 정정했다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 본다. 「아무 일도 없었다」는 「영향이 없다」와 구별되지 않아서, 걸렸는지를 결과와 따로 확인하지 않으면 둘이 같은 화면으로 보인다. 원 가이드는 이 순서를 이 실험이 남긴 가장 큰 교훈으로 적고, 뒤의 실험 전부에 같은 확인을 요구한다. 이 편에서는 그 확인이 「안 걸렸다」를 먼저 알려 준다.
|
||||
|
||||
### 1. 정책이 어떤 파드를 잡았는가
|
||||
|
||||
```bash label="[kc-lab-1] 정책 목록과 상세"
|
||||
kubectl -n keycloak-lab get networkpolicy
|
||||
kubectl -n keycloak-lab describe networkpolicy a1-block-jgroups-transport
|
||||
```
|
||||
|
||||
`To Port` 목록에 7800 이 없는 것, 그리고 `PodSelector` 가 `app=keycloak` 인 것을 본다. 오타로 아무 파드도 안 잡히면 정책은 걸렸는데 아무 일도 일어나지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 파드가 죽지 않았는지 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
```
|
||||
|
||||
실측은 `keycloak-0 ready=true restarts=0`, `keycloak-1 ready=true restarts=0` 이다(observed). `RESTARTS` 가 여전히 0 이면 9000 을 제대로 열어 뒀다는 뜻이다.
|
||||
|
||||
### 2. 열어 둔 포트는 살아 있고 막은 포트는 죽었는가
|
||||
|
||||
```bash label="[kc-lab-1] 임시 파드를 띄운다"
|
||||
kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 --env="K0=$K0" --env="K1=$K1" --command -- sh
|
||||
```
|
||||
|
||||
```sh label="[탐침 파드] 포트 셋에 차례로 붙어 본다"
|
||||
curl -s -o /dev/null -w '9000 %{http_code}\n' --max-time 5 "http://$K0:9000/health/ready"
|
||||
curl -s -o /dev/null -w '8080 %{http_code}\n' --max-time 5 "http://$K0:8080/realms/master"
|
||||
curl -s -o /dev/null -w '7800 %{http_code}\n' --max-time 5 "http://$K0:7800/" ; echo "exit=$?"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
9000 도달: 10.42.1.43:9000 health=200 / 10.42.0.35:9000 health=200
|
||||
8080 도달: 10.42.1.43:8080 root=200 / 10.42.0.35:8080 root=200
|
||||
7800: curl exit=7 (연결 실패)
|
||||
```
|
||||
|
||||
curl 종료코드로 읽는다 — `7` 은 연결 자체가 안 됨, `28` 은 `--max-time` 초과로 SYN 이 조용히 버려지고 있음, `0` 은 닿았다는 뜻이라 정책이 안 걸렸다.
|
||||
|
||||
포트 셋을 다 봤으면 또 나온다. 이 파드도 `kc-probe` 라 살려 두면 본 시험에서 같은 이름이 겹친다.
|
||||
|
||||
```sh label="[탐침 파드] 나온다"
|
||||
exit
|
||||
```
|
||||
|
||||
### 3. 그런데 클러스터가 안 깨졌다
|
||||
|
||||
```bash label="[kc-lab-1] 두 노드가 아는 멤버 수를 다시 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `07-cluster-size.txt`).
|
||||
|
||||
```text
|
||||
=== vendor_cluster_size — 지난 25분 (차단 11:38:08) ===
|
||||
keycloak-0: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
|
||||
keycloak-1: 11:38=2 11:39=2 11:40=2 11:41=2 11:42=2 11:43=2 11:44=2 11:45=2
|
||||
```
|
||||
|
||||
여기서 실험 실패라고 결론 내리면 틀린다. 파드 안 소켓을 본다. Keycloak 이미지에는 `ss` 도 없으므로 `/proc` 을 직접 읽는다.
|
||||
|
||||
```bash label="[kc-lab-1] 7800 은 16진수로 1E78 이다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- cat /proc/net/tcp6 | grep 1E78
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
=== /proc/net/tcp6 · 7800 = 0x1E78 ===
|
||||
keycloak-0: ...2B012A0A:1E78 ...23002A0A:9C57 01 ← 01 = ESTABLISHED
|
||||
keycloak-1: ...23002A0A:9C57 ...2B012A0A:1E78 01
|
||||
(10.42.0.35:40023 → 10.42.1.43:7800)
|
||||
```
|
||||
|
||||
포트는 16진수다. `7800 = 0x1E78` 이고 세 번째 열 `01` 이 TCP 상태이며 `01` 이 ESTABLISHED 다. 기존 연결이 멀쩡히 살아 있다.
|
||||
|
||||
까닭은 conntrack 이다.
|
||||
|
||||
```text
|
||||
패킷 도착
|
||||
│
|
||||
├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ] ← 여기서 통과해버린다
|
||||
│
|
||||
└─▶ [ NetworkPolicy 규칙 평가 ] ← 여기까지 오지 않는다
|
||||
```
|
||||
|
||||
### 4. conntrack 을 두 노드 모두에서 본다
|
||||
|
||||
```bash label="[kc-lab-1] 이 노드의 conntrack 표를 본다"
|
||||
sudo conntrack -L 2>/dev/null | grep 7800
|
||||
```
|
||||
|
||||
반대편 노드는 붙어서 친다. 아래 세 줄 형태는 이 실험대에서 치지 않았다(unknown) — 원래 실행은 `ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'` 한 줄로 쳤고, 한 줄에 원격 접속과 원격 셸의 인용을 겹쳐 놓는 대신 행동 하나를 명령 하나로 나눴다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 반대편 노드에 붙는다"
|
||||
ssh kc-lab-2
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] ② 같은 표를 본다"
|
||||
sudo conntrack -L 2>/dev/null | grep 7800
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] ③ 나온다"
|
||||
exit
|
||||
```
|
||||
|
||||
폴더 README 는 게스트 셸이 필요한 것을 `nft`·`tc`·`systemctl` 처럼 노드 자체를 건드리는 명령뿐이라고 적는데, `conntrack` 이 그 경우다.
|
||||
|
||||
실측은 이렇다(observed, `05-conntrack-problem.txt`).
|
||||
|
||||
```text
|
||||
--- kc-lab-1 ---
|
||||
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 src=10.42.1.43 dst=10.42.0.35 sport=7800 dport=40023 [ASSURED] mark=0 use=1
|
||||
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 src=10.42.1.43 dst=10.42.0.35 sport=57800 dport=50477 [ASSURED] mark=0 use=1
|
||||
--- kc-lab-2 ---
|
||||
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ...
|
||||
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED] ...
|
||||
```
|
||||
|
||||
`2>/dev/null` 은 `conntrack` 이 stderr 로 찍는 「N flow entries have been shown」 요약을 지우려는 것이고, 처음에는 빼고 쳐서 그 줄도 한번 본다. 상태 열을 읽는다 — `ESTABLISHED` 는 양방향 통신이 성립해 규칙 평가를 건너뛰고, `[ASSURED]` 는 표가 꽉 차도 안 지워지는 오래된 연결이며, `SYN_SENT [UNREPLIED]` 가 정책이 동작하고 있다는 것을 보여 준다. `dport=57800` 도 ESTABLISHED 로 살아 있다. NetworkPolicy 는 이미 붙어 있는 연결을 떼어내지 못하므로, 보안 사고 대응으로 「지금 당장 이 통신을 끊어라」에 NetworkPolicy 를 걸면 새 연결만 막히고 진행 중인 연결은 계속된다. 같은 7800 을 한 방향만 끊는 A-5 가 NetworkPolicy 대신 `iptables` 의 raw PREROUTING 으로 간 까닭도 여기서 나왔다 — 그 체인은 conntrack 조회보다 먼저 평가된다.
|
||||
|
||||
### 5. conntrack 항목을 튜플 그대로 지운다
|
||||
|
||||
**목적** — 규칙 평가를 건너뛰게 만들던 기존 연결 기록을 표에서 없앤다.
|
||||
|
||||
네 값은 바로 위 4단계의 `conntrack -L` 출력에서 읽는다. 한 줄에 `src=` `dst=` `sport=` `dport=` 가 두 벌 나오는데 **앞의 한 벌이 원 방향, 뒤의 한 벌이 응답 방향**이고 두 벌을 각각 한 번씩 지운다. 포트는 ephemeral 이라 재시작할 때마다 바뀐다.
|
||||
|
||||
```bash label="[kc-lab-1] -L 출력의 네 값을 그대로 옮겨 한 항목씩 지운다"
|
||||
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
|
||||
```
|
||||
|
||||
이 실험대에서는 7800 두 방향과 57800 한 방향, 이렇게 세 번 쳤다(observed). 그대로 옮겨 치면 자기 실험대에는 없는 튜플이라 0건이 나온다.
|
||||
|
||||
```text
|
||||
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 40023 --dport 7800
|
||||
sudo conntrack -D -p tcp -s 10.42.1.43 -d 10.42.0.35 --sport 7800 --dport 40023
|
||||
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 50477 --dport 57800
|
||||
```
|
||||
|
||||
`kc-lab-2` 에서도 같은 일을 한다. 서버 쪽 노드에는 튜플이 뒤집혀 기록되어 있으므로, 그쪽에 붙어 그쪽 `-L` 출력을 보고 옮긴다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 반대편 노드에 붙는다"
|
||||
ssh kc-lab-2
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] ② 이 노드의 표를 다시 보고 네 값을 읽는다"
|
||||
sudo conntrack -L 2>/dev/null | grep 7800
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] ③ 읽은 값으로 지운다"
|
||||
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] ④ 나온다"
|
||||
exit
|
||||
```
|
||||
|
||||
**예상 결과** — 삭제 건수가 나온다.
|
||||
|
||||
**왜 필요한가** — 위 소켓 출력이 보여 준 대로 ESTABLISHED 인 연결은 규칙 평가 앞에서 통과한다. 그 기록을 지워야 다음 패킷이 정책을 만난다.
|
||||
|
||||
**문제가 생기면** — `0 flow entries have been deleted` 면 튜플이 틀린 것이고, `--dport 7800` 만 주면 0건이 나온다. 원래 실행에서 실제로 그렇게 나왔다.
|
||||
|
||||
여기에 정직하게 적어 둘 것이 있다(observed). 원래 실행에서 conntrack 을 지운 뒤에도 `vendor_cluster_size` 는 계속 2 였다. 해설 문서는 처음에 「conntrack 삭제 → 3분 뒤 분단」이라고 썼다가 증거를 다시 보고 정정했고, 실제 하락은 파드가 재시작된 4초 뒤에 일어났다. 이 단계만으로 분단이 만들어지는지는 이 실험이 판정하지 못했다.
|
||||
|
||||
### 6. 정책이 걸린 채로 파드를 지워 분단을 확정한다
|
||||
|
||||
**목적** — 새로 뜨는 노드가 7800 으로 JOIN 을 보내다 실패하게 만들어 분단을 확실히 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 시각을 남기고 파드를 지운다"
|
||||
date '+%H:%M:%S 재시작'
|
||||
kubectl -n keycloak-lab delete pod keycloak-0
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 새 파드와 새 주소를 다시 잡는다"
|
||||
kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
echo "$K0"
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `08-restart-forced-partition.txt`).
|
||||
|
||||
```text
|
||||
재시작 시각: 11:46:07
|
||||
pod "keycloak-0" deleted from keycloak-lab namespace
|
||||
keycloak-0 false 10.42.1.67 2026-09-04T02:44:23Z
|
||||
```
|
||||
|
||||
**왜 필요한가** — StatefulSet 이 같은 이름으로 곧바로 다시 만들지만 주소는 바뀐다. `10.42.1.43` 에서 `10.42.1.67` 로 바뀌었고, 새 주소를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿는다.
|
||||
|
||||
`echo "$K0"` 가 빈 줄이면 파드에 아직 주소가 붙지 않은 것이다. 이 파드는 분단 때문에 Ready 가 되지 않으므로 `wait --for=condition=Ready` 로 기다리면 안 되고, 첫 줄의 `get pods -o wide` 에 `IP` 가 찍힐 때까지 ② 를 다시 친다. 빈 값을 그대로 두고 넘어가면 `http://:8080` 으로 요청이 나가고 그 실패를 분단으로 읽는다.
|
||||
|
||||
**문제가 생기면** — 파드가 `Pending` 에서 안 넘어가면 노드 상태부터 본다.
|
||||
|
||||
## 관찰
|
||||
|
||||
```bash label="[kc-lab-1] 두 노드가 아는 멤버 수"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
keycloak-0: 11:45:27=1 11:45:57=1 11:46:27=1 11:46:57=1 11:47:27=1
|
||||
keycloak-1: ... 11:43:57=2 11:44:27=1 11:44:57=1 ... 11:47:27=1
|
||||
```
|
||||
|
||||
양쪽 다 1 이다. 서로를 멤버로 안 세고 있고, 로그가 까닭을 말한다.
|
||||
|
||||
```bash label="[kc-lab-1] 합류 시도와 클러스터 뷰를 한 번에 본다"
|
||||
kubectl -n keycloak-lab logs keycloak-0 | grep -E "GMS|ISPN000094" | tail -20
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
GMS: JOIN(keycloak-0-26403) sent to keycloak-1-48749 timed out ← 10회
|
||||
GMS: too many JOIN attempts (10): becoming singleton ← 포기
|
||||
ISPN000094: new cluster view [keycloak-0-26403|0] (1) [keycloak-0-26403]
|
||||
```
|
||||
|
||||
새로 뜬 `keycloak-0` 은 DB 에서 `keycloak-1` 을 찾았고 주소도 안다. 그런데 JOIN 메시지가 7800 으로 안 간다. 디스커버리는 살아 있고 트랜스포트만 죽었다.
|
||||
|
||||
split brain 은 DB 한 줄로 확인된다.
|
||||
|
||||
```bash label="[kc-lab-1] 코디네이터가 몇인지 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `06-partition-observed.txt`).
|
||||
|
||||
```text
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터
|
||||
keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터
|
||||
```
|
||||
|
||||
`coord = t` 가 둘이다. 서로를 못 보니까 각자 자기가 대장이라고 생각한다. 분단을 확인하는 가장 짧은 명령이 이 한 줄이다.
|
||||
|
||||
분단된 노드는 스스로 트래픽에서 빠진다.
|
||||
|
||||
```bash label="[kc-lab-1] Ready 와 재시작 횟수만 뽑아 본다"
|
||||
kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
|
||||
| grep keycloak
|
||||
```
|
||||
|
||||
실측은 `keycloak-0 false 0`, `keycloak-1 true 0` 이다(observed, `11-service-impact.txt`). 까닭은 헬스 본문에 있다.
|
||||
|
||||
```bash label="[kc-lab-1] 파드 조건을 본다"
|
||||
kubectl -n keycloak-lab describe pod keycloak-0 | grep -A5 Conditions
|
||||
```
|
||||
|
||||
실측은 이렇다(observed).
|
||||
|
||||
```json
|
||||
{ "status": "DOWN",
|
||||
"checks": [
|
||||
{ "name": "Keycloak cluster health check", "status": "DOWN",
|
||||
"data": { "Failing since": "2026-09-04 02:45:14,251" } },
|
||||
{ "name": "Keycloak database connections async health check", "status": "UP" } ] }
|
||||
```
|
||||
|
||||
Keycloak 은 클러스터 분단을 readiness 로 신고한다. DB 는 UP 인데 클러스터가 DOWN 이고, 쿠버네티스가 그 신고를 받아 처리한다.
|
||||
|
||||
```bash label="[kc-lab-1] Service 뒤에 누가 남았는지 본다"
|
||||
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
|
||||
```
|
||||
|
||||
실측은 `ready 주소: [10.42.0.35]`, `notReady : [10.42.1.67]` 다(observed). `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜨므로 쓰지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 밖에서 정문을 친다"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||
```
|
||||
|
||||
실측은 정문이 `HTTP 200`, 토큰 발급도 `HTTP 200` 이다(observed). 분단된 노드가 스스로 로드밸런서에서 빠졌고 서비스는 계속됐다. liveness 였다면 재시작을 반복했을 텐데 재시작해도 안 나아지는 문제이므로 readiness 로 격리하는 쪽이 맞는 신호다. 다만 비대칭이라서 살았다 — `keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. 양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애이고, 그것이 A-5 의 주제다.
|
||||
|
||||
본 시험은 Service 를 쓰면 안 된다. `keycloak-0` 이 NotReady 라 Service 로 보내면 전부 `keycloak-1` 로 간다. 새 주소로 임시 파드를 다시 띄우고 파드 주소로 직접 친다.
|
||||
|
||||
`keycloak-0` 을 지웠으므로 `$K0` 가 낡았다. 두 주소를 다시 잡고 그 값으로 파드를 띄운다. `$PW` 도 여기서 다시 넣는다 — 앞의 포트 시험 파드에는 안 넣었다.
|
||||
|
||||
```bash label="[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}')
|
||||
kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 \
|
||||
--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 -- sh
|
||||
```
|
||||
|
||||
```sh label="[탐침 파드] ② 분단 상태에서 네 단계를 차례로 친다"
|
||||
TOK=/realms/master/protocol/openid-connect/token
|
||||
|
||||
# [1] keycloak-0 에서 로그인
|
||||
R=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=password -d client_id=admin-cli \
|
||||
-d username=admin -d "password=$PW")
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo # sid 를 적어 둔다
|
||||
|
||||
# [2] keycloak-1 에서 refresh
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
|
||||
echo "$R" | tail -1
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
# [3] keycloak-1 에서 로그아웃
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
|
||||
-d client_id=admin-cli -d "refresh_token=$RT"
|
||||
|
||||
# [4] keycloak-0 에서 재갱신 시도
|
||||
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
|
||||
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
|
||||
```
|
||||
|
||||
네 단계를 다 쳤으면 파드 셸에서 나온다. 뒤의 명령은 전부 `[kc-lab-1]` 이고, 나가지 않으면 `kubectl` 도 `psql` 도 없는 `curlimages/curl` 안에서 치게 된다.
|
||||
|
||||
```sh label="[탐침 파드] ③ 나온다. --rm 이 파드를 지운다"
|
||||
exit
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `09-cross-node-under-partition.txt`).
|
||||
|
||||
```text
|
||||
[1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB
|
||||
[2] keycloak-1 에서 refresh HTTP 200 ← 예측대로
|
||||
[3] keycloak-1 에서 로그아웃 HTTP 204
|
||||
[4] keycloak-0 에서 재갱신 시도 HTTP 200 ← 400 이어야 했다
|
||||
```
|
||||
|
||||
[2] 에서 세션 공유는 예측이 맞았다. 클러스터가 갈라졌는데도 한쪽에서 만든 세션을 반대쪽이 갱신했으니 세션은 7800 으로 다니지 않는다. [4] 에서 로그아웃 전파는 예측이 틀렸다. 대조군에서 400 이던 곳이 200 이다.
|
||||
|
||||
[4] 의 200 이 로그아웃이 아예 안 됐다는 뜻인지 확인한다. sid 는 [1] 에서 JWT payload 를 풀어 화면에 찍고 적어 둔 그 값이다. 로그인할 때마다 새로 생기므로 자기 실행의 값을 넣는다.
|
||||
|
||||
```bash label="[kc-lab-1] 그 세션의 DB 행이 남아 있는지 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select user_session_id, offline_flag, last_session_refresh
|
||||
from offline_user_session where user_session_id='{{SID}}'"
|
||||
```
|
||||
|
||||
이 실험대의 값은 `nShl5TaBrZnKStDqaspjgmJB` 였다(observed).
|
||||
|
||||
실측은 이렇다(observed, `10-logout-not-propagated.txt`).
|
||||
|
||||
```text
|
||||
user_session_id | offline_flag | last_session_refresh
|
||||
-----------------+--------------+----------------------
|
||||
(0 rows) ← DB 행은 삭제되었다
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] 세션 캐시 엔트리를 노드별로 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique{cache="sessions"}'
|
||||
```
|
||||
|
||||
실측은 `keycloak-1 kc-lab-1 = 0`, `keycloak-0 kc-lab-2 = 1` 이다(observed). 캐시에는 그 세션이 있다.
|
||||
|
||||
```text
|
||||
keycloak-1 로그아웃
|
||||
│
|
||||
├──▶ PostgreSQL 행 삭제 ✔ 되었다
|
||||
│
|
||||
└──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다
|
||||
│
|
||||
keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘
|
||||
```
|
||||
|
||||
룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다. 세션 조회는 PostgreSQL 을 타고 세션 무효화는 클러스터 메시지(7800)를 타므로, 7800 을 막으면 조회는 정상이고 무효화만 전파되지 않는다. 실제 사용자도 로그아웃이 안 되는지는 따로 답이 있다 — 아니다. 파드 주소로 직접 쳤기 때문이고, 실제 사용자는 nginx → Traefik → Service 를 거치는데 NotReady 인 `keycloak-0` 은 거기서 빠져 있다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
### 1. 정책을 지우고 재형성을 기다린다
|
||||
|
||||
**목적** — 7800 을 다시 열어 두 노드가 하나의 뷰로 합쳐지게 한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 시각을 남기고 정책을 지운다"
|
||||
date '+%H:%M:%S 해제'
|
||||
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 30초 간격으로 멤버 수를 몇 번 친다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `12-recovery.txt`).
|
||||
|
||||
```text
|
||||
해제 시각: 11:49:58
|
||||
networkpolicy.networking.k8s.io "a1-block-jgroups-transport" deleted from keycloak-lab namespace
|
||||
```
|
||||
|
||||
```text
|
||||
+30초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
|
||||
+60초 keycloak-0=1 keycloak-1=1 | Ready 파드 2 개
|
||||
+90초 keycloak-0=2 keycloak-1=2 ← 재형성
|
||||
```
|
||||
|
||||
**왜 필요한가** — 90초 만에 자동으로 다시 붙었고 사람 손이 필요 없었다. 누가 붙였는지는 카운터가 말한다.
|
||||
|
||||
**문제가 생기면** — 2~3분이 지나도 1 이면 MERGE3 주기 밖이거나 정책이 안 지워진 것이므로 `get networkpolicy` 부터 본다.
|
||||
|
||||
### 2. 누가 붙였는지 카운터로 확인한다
|
||||
|
||||
**목적** — 재형성이 저절로 일어난 것인지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 병합 이벤트 계수기를 다시 읽는다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 `merge_events keycloak-0 = 1`, `merge_events keycloak-1 = 1` 이다(observed). 주입 전에 `0.0` 이던 값이 1 이다.
|
||||
|
||||
**왜 필요한가** — MERGE3 는 split brain 을 감지해 갈라진 뷰를 병합하는 JGroups 프로토콜이고, `0 → 1` 로 오른 카운터가 그 프로토콜이 실제로 일했다고 말한다.
|
||||
|
||||
**문제가 생기면** — 값이 그대로 0 이면 재형성이 다른 경로로 일어났거나 아직 안 일어난 것이므로 `vendor_cluster_size` 를 다시 본다.
|
||||
|
||||
코디네이터도 하나로 돌아온다. 실측은 이렇다(observed).
|
||||
|
||||
```text
|
||||
keycloak-0-26403 | 10.42.1.67:7800 | t
|
||||
keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다
|
||||
```
|
||||
|
||||
코디네이터가 `keycloak-1` 에서 `keycloak-0` 으로 넘어갔다. 코디네이터는 특권이 아니라 역할이며 병합 시 재선출되므로 주입 전과 달라도 정상이다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 정책 | `kubectl -n keycloak-lab get networkpolicy` | `No resources found` |
|
||||
| 파드 | `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)`, 양쪽 동일 |
|
||||
| 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t` 가 **하나** |
|
||||
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| 임시 파드 | `kubectl -n keycloak-lab get pod kc-probe` | `NotFound` (없어야 정상) |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
conntrack 은 지운 채로 두면 된다. 표는 새 패킷이 오면 다시 채워진다.
|
||||
|
||||
## 막히면
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 정책을 걸었는데 지표가 안 변한다 | conntrack 의 ESTABLISHED 가 먼저 통과시킨다 | `sudo conntrack -L 2>/dev/null \| grep 7800` |
|
||||
| `conntrack -D` 가 `0 flow entries` | 튜플이 틀렸다. `--dport` 만으로는 0건 | `-L` 출력의 src/dst/sport/dport 를 **그대로** 옮긴다 |
|
||||
| conntrack 을 지웠는데도 계속 2 | **이 실험은 그것만으로 분단되는지 판정 못 했다** | 정책이 걸린 채 파드를 재시작한다 |
|
||||
| 파드가 재시작을 반복한다 (`RESTARTS` 증가) | **9000 을 안 열었다.** readiness 실패 → kubelet 이 죽인다 | `describe pod` 의 Events. 매니페스트에 9000 이 있는지 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 임시 curl 파드를 띄우거나 Prometheus 에 묻는다 |
|
||||
| refresh 가 계속 `keycloak-1` 로만 간다 | Service 로 보냈다. NotReady 파드는 빠진다 | **파드 IP 로 직접** |
|
||||
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** (`10.42.1.43 → 10.42.1.67`) | `get pod -o jsonpath='{.status.podIP}'` 다시 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}` — 0 이면 `--env` 가 빈 값 |
|
||||
| 값이 빈 문자열인데 「변했다」로 읽힌다 | **원래 실행이 이 실수를 했다** | 빈 값은 「측정 실패」다. 판정 조건에서 빼고 다시 잰다 |
|
||||
| 복구 후 2~3분이 지나도 1 | MERGE3 주기 밖이거나 정책이 안 지워졌다 | `get networkpolicy` 로 먼저 확인 |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 11:38–11:52 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 차단 `11:38:08`·재시작 `11:46:07`·해제 `11:49:58`, 25분 내내 2 이던 `vendor_cluster_size`, `/proc/net/tcp6` 의 `01`, conntrack 네 줄, `coord = t` 가 둘, 분단 중 교차 refresh `200` 과 로그아웃 후 `200`, DB 행 0 과 캐시 1, 90초 재형성, `merge_events` 가 `0 → 1`.
|
||||
- (unknown) `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력과 `jq` 형태. `jq` 는 이 실험대에 아예 없다.
|
||||
- (unknown) `ssh kc-lab-2` 로 들어가서 `conntrack -L` 을 따로 치는 세 줄 형태. 이 실험대는 `ssh kc-lab-2 '...'` 한 줄로 쳤다.
|
||||
- (unknown) 매니페스트가 놓인 체크아웃의 위치와 그 디렉터리로 옮기는 명령, 그리고 파일이 없을 때 여는 에디터 명령. 원 가이드는 `cat` 과 `kubectl apply` 를 저장소 상대경로로만 적는다.
|
||||
- 이 실험이 판정하지 못한 것 — conntrack 삭제만으로 분단이 만들어지는지. 해설 문서가 「3분 뒤 분단」이라고 썼다가 정정했고, 실제 하락은 파드 재시작 4초 뒤였다.
|
||||
- 이 실험이 재지 않은 것 — `keycloak-0` 캐시에 있던 낡은 엔트리가 병합 후 어떻게 되는지. 궁금하면 재형성 뒤에 `vendor_statistics_approximate_entries_unique{cache="sessions"}` 를 다시 본다.
|
||||
- 가이드가 스크립트를 안 쓰는 까닭도 측정 실패에서 나왔다(observed). 원래 실행은 임시 파드를 20초마다 띄워 지표를 긁었고 `+20초 suspected(k0 k1) = []` 처럼 빈 값과 개수가 안 맞는 값이 섞였다. 판정 조건이 `[ "$R" != "0.0 0.0 " ]` 이어서 빈 문자열을 변화로 읽고 즉시 빠져나왔다.
|
||||
|
||||
<!-- body:end -->
|
||||
+956
@@ -0,0 +1,956 @@
|
||||
---
|
||||
id: b8d7db33-afdc-4e49-9eab-ed4edbbe398e
|
||||
kind: SETUP
|
||||
slug: reproduce-a7-volatile-comparison
|
||||
title: persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/b8d7db33-afdc-4e49-9eab-ed4edbbe398e/edit"
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
- name: persistent-user-sessions
|
||||
version: v1
|
||||
source:
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7
|
||||
sourceRevision: 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 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
|
||||
기본값에서 세션 공유가 안 깨지던 그 주입을 여기서 다시 건다. 이번에는 깨진다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령을 치는 곳이 둘이다. 대부분은 `[kc-lab-1]` 에서 `kubectl` 로 치고, `iptables` 만 노드 자체를 건드리므로 `kc-lab-1` 과 `kc-lab-2` 에 각각 들어가 친다. 코드블록마다 `label` 로 어디서 치는지 붙였다.
|
||||
|
||||
`kubectl` 에 `sudo` 를 붙이지 않는다. 실험 폴더의 README 가 까닭을 적는다 — `sudo` 를 붙이면 root 환경으로 돌아 그 kubeconfig 를 못 본다. root 홈에는 `~/.kube/config` 가 없으므로 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝나고, 그러면 클러스터가 아니라 누구의 설정 파일을 읽느냐가 문제인데 클러스터를 의심하게 된다.
|
||||
|
||||
```bash label="[kc-lab-1] 두 형태의 차이"
|
||||
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층의 결론 여섯은 전부 하나의 전제 위에 있다.
|
||||
|
||||
```text
|
||||
Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다
|
||||
│
|
||||
├─ A-0 세션은 PostgreSQL 에 있다
|
||||
├─ A-1 7800 을 끊어도 세션 공유가 안 깨진다
|
||||
├─ A-2 DB 를 내리면 로그인이 실패한다
|
||||
└─ A-8 롤링 재시작을 해도 세션이 산다
|
||||
```
|
||||
|
||||
A-1 은 인터넷 자료의 통념과 어긋난 답을 냈고 그 까닭을 「26 이 기본값을 바꿨기 때문」이라고 설명했다. 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해야 판정된다. 자료가 틀린 것이 아니라 버전이 다른 것이라면 옛 설정에서는 통념이 맞아야 한다.
|
||||
|
||||
```text
|
||||
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` 쪽은 이 두 줄이다.
|
||||
|
||||
```bash label="[kc-lab-1] 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).
|
||||
|
||||
```bash label="[kc-lab-1] 이 노드의 규칙을 지운다"
|
||||
sudo iptables -t raw -F PREROUTING
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] 반대 노드로 붙는다"
|
||||
ssh kc-lab-2
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-2] 원격 셸에서 같은 것을 지우고 나온다"
|
||||
sudo iptables -t raw -F PREROUTING
|
||||
exit
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
전환 후에 볼 것을 전환 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
|
||||
|
||||
```text
|
||||
노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가
|
||||
```
|
||||
|
||||
### 1. 파드 배치를 보고 두 파드 IP 를 잡는다
|
||||
|
||||
**목적** — 두 Keycloak 파드가 서로 다른 노드에 있는지 확인하고, 뒤에서 쓸 IP 를 변수에 담는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 노드와 파드를 넓게 본다"
|
||||
kubectl get nodes
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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` 에 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 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"
|
||||
```
|
||||
|
||||
```text
|
||||
10.42.1.94 10.42.0.45
|
||||
```
|
||||
|
||||
**왜 필요한가** — 두 파드가 같은 노드에 있으면 뒤의 노드 간 차단이 아무것도 끊지 않는다. 그리고 이 절차는 롤아웃을 세 번 하므로 IP 를 세 번 다시 잡는다.
|
||||
|
||||
**문제가 생기면** — `NODE` 가 같으면 여기서 멈추고 배치부터 고친다.
|
||||
|
||||
### 2. 지금 args 를 적어 둔다
|
||||
|
||||
**목적** — 복구할 때 되돌릴 문자열을 확보한다.
|
||||
|
||||
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
|
||||
kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
["start"]
|
||||
```
|
||||
|
||||
**왜 필요한가** — 플래그가 하나도 없으므로 26 의 기본값으로 돌고 있고 `persistent-user-sessions` 가 켜져 있다. 복구 단계가 이 문자열로 되돌린다.
|
||||
|
||||
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 먼저 그것부터 되돌린다.
|
||||
|
||||
### 3. DB 에 세션 행이 있는 것을 센다
|
||||
|
||||
**목적** — persistent 에서 로그인이 DB 행을 만든다는 것을 전환 전에 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 온라인 세션과 offline token 을 나눠 센다"
|
||||
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"
|
||||
```
|
||||
|
||||
**예상 결과** — 모양은 이렇고 숫자는 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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` 로 끝난다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
|
||||
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
|
||||
```
|
||||
|
||||
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 존재와 길이만 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
```text
|
||||
19
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
|
||||
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` 과 견줄 값을 먼저 확보한다.
|
||||
|
||||
응답을 한 번은 통째로 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 로그인 응답 전문을 본다"
|
||||
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"'
|
||||
```
|
||||
|
||||
```json
|
||||
{"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 는 노드가 세션 저장소를 실제로 뒤져야 답할 수 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 토큰을 파드 안 파일에 담고 길이를 찍는다"
|
||||
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"'
|
||||
```
|
||||
|
||||
```text
|
||||
rt 1188 bytes
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 반대 노드에서 그 토큰으로 갱신한다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
|
||||
```
|
||||
|
||||
**왜 필요한가** — 이 `200` 을 안 재 두면 뒤의 `400` 이 무엇과 견준 값인지 말할 수 없다. 그리고 `rt` 가 `1 bytes` 면 빈 문자열에 개행만 들어갔다. 파싱이 실패했거나 로그인이 실패한 것인데, 그 상태로 진행하면 빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.
|
||||
|
||||
**문제가 생기면** — `1 bytes` 가 나오면 `cat /tmp/tok` 으로 본문을 본다. refresh token 은 회전하므로 이어서 또 쓰려면 새로 로그인해서 `/tmp/rt` 를 다시 채운다.
|
||||
|
||||
### 6. 이 버전에서 정말 끌 수 있는지 확인한다
|
||||
|
||||
**목적** — 기능 목록에 이름이 있는지 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 빌드 기능 목록에서 이름을 찾는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
|
||||
| tr ',' '\n' | grep -i persistent
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
persistent-user-sessions[:v1] ← 목록에 있다
|
||||
```
|
||||
|
||||
`--help-all` 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. `tr ',' '\n'` 이 그것을 줄로 쪼갠다. 처음 한 번은 `grep` 없이 쳐서 어떤 기능들이 있는지 통째로 본다.
|
||||
|
||||
**왜 필요한가** — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다.
|
||||
|
||||
**문제가 생기면** — 아무것도 안 나오면 먼저 `grep` 을 떼고 출력 전체를 본다.
|
||||
|
||||
## 주입
|
||||
|
||||
주입은 셋이다. 여기서 치는 것은 첫째뿐이고, 7800·57800 양방향 차단과 PostgreSQL 정지는 A-1 과 A-2 를 다시 치는 순서 안에서 넣는다. 그 둘의 명령과 되돌리기는 그 단계에 적었다.
|
||||
|
||||
### 7. 세션 테이블을 비운다
|
||||
|
||||
**목적** — 전환 후 「DB 0건」이 성립할 수 있게 옛 행을 먼저 없앤다.
|
||||
|
||||
```bash label="[kc-lab-1] 온라인·오프라인 세션 행을 전부 지운다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_user_session"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
DELETE 151
|
||||
```
|
||||
|
||||
**왜 필요한가** — volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. 이 한 줄을 빼먹으면 전환 뒤에도 테이블에 행이 보이고, 그것을 「전환이 안 됐다」로 읽게 된다. 되돌리는 방법은 없다 — 지운 세션은 돌아오지 않는다. 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만, 운영에서 이 명령은 전원 로그아웃이다.
|
||||
|
||||
**문제가 생기면** — 삭제 건수가 0 이면 이미 비어 있다. 그대로 다음으로 간다.
|
||||
|
||||
### 8. args 를 volatile 로 바꾼다
|
||||
|
||||
**목적** — `persistent-user-sessions` 를 끄고 롤아웃이 끝날 때까지 기다린다.
|
||||
|
||||
방법은 둘이고 매니페스트를 고치는 쪽을 권한다. 무엇이 바뀌었는지 파일에 남는다.
|
||||
|
||||
먼저 매니페스트를 편집기로 연다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 저장소의 매니페스트를 연다"
|
||||
vim deploy/lab/k8s/keycloak-cluster.yaml
|
||||
```
|
||||
|
||||
`args` 줄을 이렇게 고친다.
|
||||
|
||||
```yaml
|
||||
# 149번째 줄 근처
|
||||
args: ["start", "--features-disabled=persistent-user-sessions"]
|
||||
```
|
||||
|
||||
고친 파일을 적용한다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 적용한다"
|
||||
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||
```
|
||||
|
||||
파일을 안 건드리고 싶으면 patch 를 쓴다.
|
||||
|
||||
```bash label="[kc-lab-1] 파일 대신 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"]}]'
|
||||
```
|
||||
|
||||
전환 시각을 적고 롤아웃을 기다린다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 전환 시각을 남기고 롤아웃을 기다린다"
|
||||
date '+%H:%M:%S 전환'
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
statefulset.apps/keycloak configured
|
||||
Waiting for 1 pods to be ready...
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
**왜 필요한가** — `configured` 가 나와야 한다. `unchanged` 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다.
|
||||
|
||||
**문제가 생기면** — 타임아웃이 나면 `--timeout=500s` 로 다시 치고, `logs keycloak-0` 에 빌드 진행이 보이는지 확인한다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에 주입이 의도한 것만 건드렸는지 본다. 주입 ②와 ③의 검증은 그 주입을 친 단계 안에 있다 — 주입마다 검증이 따로 붙는다.
|
||||
|
||||
### 9. args 와 파드가 둘 다 새것인지 본다
|
||||
|
||||
**목적** — 선언만 바뀌고 프로세스는 그대로인 상태를 걸러 낸다.
|
||||
|
||||
```bash label="[kc-lab-1] 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
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
["start","--features-disabled=persistent-user-sessions"]
|
||||
```
|
||||
|
||||
`AGE` 가 방금이고 `RESTARTS` 가 `0` 인지 함께 본다.
|
||||
|
||||
**왜 필요한가** — StatefulSet 의 `spec` 은 바뀌었는데 파드가 옛것이면 persistent 를 재면서 volatile 이라고 적게 된다.
|
||||
|
||||
**문제가 생기면** — 파드 나이가 예전 값이면 롤아웃이 안 끝났다. 8번의 `rollout status` 로 돌아간다.
|
||||
|
||||
IP 가 바뀌었으므로 다시 잡고, 탐침 파드도 지우고 새 IP 로 다시 띄운다. 탐침의 `K0`·`K1` 은 만들 때 고정된 값이라 롤아웃 뒤에는 낡았고, 낡은 주소로 친 curl 은 아무 데도 안 닿는다. 여기서는 아직 파드 안에 지킬 파일이 없으므로 지우고 다시 만들어도 잃을 것이 없다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 롤아웃 뒤 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"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
|
||||
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 문자열이 아니라 동작이 바뀐 것을 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 한쪽 노드에만 다섯 번 로그인한다"
|
||||
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'
|
||||
```
|
||||
|
||||
```text
|
||||
200 200 200 200 200
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 같은 질의로 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"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
|
||||
offline_flag | count
|
||||
--------------+-------
|
||||
(0 rows)
|
||||
```
|
||||
|
||||
**왜 필요한가** — 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. 세션이 메모리에만 있다. 전환 판정은 이 질의로 한다.
|
||||
|
||||
**문제가 생기면** — 행이 있으면 7번의 `delete from offline_user_session` 을 건너뛰었다. 지우고 다시 로그인한다.
|
||||
|
||||
### 11. 캐시 엔트리 수로는 두 모드를 못 가른다는 것을 확인한다
|
||||
|
||||
**목적** — 다음 사람이 이 지표로 판정하지 않도록, 두 모드가 같은 값을 낸다는 것을 눈으로 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
|
||||
```
|
||||
|
||||
```json
|
||||
{"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).
|
||||
|
||||
```bash label="[kc-lab-1] ② 캐시 이름과 파드와 값만 세로로 늘어놓는다"
|
||||
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]'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
keycloak-0 sessions 캐시 5.0 건
|
||||
keycloak-1 sessions 캐시 0.0 건
|
||||
```
|
||||
|
||||
**왜 필요한가** — persistent 였을 때와 똑같은 숫자다. `approximate_entries_unique` 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다.
|
||||
|
||||
**문제가 생기면** — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
|
||||
|
||||
## 관찰
|
||||
|
||||
앞에서 친 것과 완전히 같은 명령을 순서대로 다시 친다. A-0 · A-8 · A-1 · A-2 차례다.
|
||||
|
||||
### 12. A-0 을 다시 돌린다 — 교차 노드는 여전히 200 이다
|
||||
|
||||
**목적** — 겉보기 결과가 persistent 때와 같은지 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 로그인하고 반대 노드에서 갱신한다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== 교차 노드 세션은 되는가 ===
|
||||
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
|
||||
```
|
||||
|
||||
**왜 필요한가** — DB 는 0건인데 `200` 이다. 경로가 완전히 달라졌는데 겉보기 답이 같다.
|
||||
|
||||
```text
|
||||
persistent : keycloak-1 이 PostgreSQL 을 읽어서 답했다
|
||||
volatile : keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다
|
||||
```
|
||||
|
||||
구별하려면 그 경로를 끊어 봐야 하고, 14번이 그것을 한다.
|
||||
|
||||
**문제가 생기면** — `400` 이 나오면 `/tmp/rt` 를 다시 안 채웠다. 로그인부터 다시 친다.
|
||||
|
||||
### 13. A-8 을 다시 돌린다 — 롤링 재시작이 곧 로그아웃이다
|
||||
|
||||
**목적** — 재시작 전에 발급한 토큰이 재시작 후에도 통하는지 본다.
|
||||
|
||||
재시작 전에 로그인해서 토큰과 `sid` 를 파드 안에 담는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 토큰을 담고 access token 의 클레임을 편다"
|
||||
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 의 가운데 토막이 클레임이다. 모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```json
|
||||
{"exp":1757046060,"iat":1757046000,"jti":"...","typ":"Bearer","azp":"admin-cli",
|
||||
"sid":"aVwYnzKZFFvMqD3bpSeiILuM",...}
|
||||
```
|
||||
|
||||
```text
|
||||
=== [A-8 재실행] 재시작 전 로그인 ===
|
||||
sid = aVwYnzKZFFvMqD3bpSeiILuM
|
||||
```
|
||||
|
||||
`sid` 를 적어 둔다. base64 패딩 때문에 끝이 깨져 보일 수 있고 `2>/dev/null` 이 그 불평을 지운다. `sid` 는 앞쪽에 있어서 대개 보인다.
|
||||
|
||||
그다음 재시작한다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 시각을 남기고 롤링 재시작을 건다"
|
||||
date '+%H:%M:%S 재시작'
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
```text
|
||||
statefulset.apps/keycloak restarted
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
파드 IP 를 다시 잡는다. 탐침은 다시 띄우지 않는다 — `/tmp/rt` 가 같이 사라진다. 그래서 새 IP 를 명령줄에 직접 넘긴다. `$K0` 는 `[kc-lab-1]` 셸의 변수이고 지금 값은 롤아웃 전 것이므로 먼저 다시 잡는다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 롤아웃 뒤 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"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ④ 재시작 전 토큰으로 갱신을 시도한다"
|
||||
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).
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== ★ 재시작 전 토큰이 아직 통하는가 (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 다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 양쪽 노드에 raw DROP 을 넣는다"
|
||||
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 차단'
|
||||
```
|
||||
|
||||
| 노드 | 그 노드에 있는 파드 | 규칙의 `-d` |
|
||||
|---|---|---|
|
||||
| `kc-lab-1` | `keycloak-1` | `$K1` |
|
||||
| `kc-lab-2` | `keycloak-0` | `$K0` |
|
||||
|
||||
**뒤의 두 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 가 들어간다. `$K0` 는 `[kc-lab-1]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[kc-lab-1]` 에서 펴서 보내므로 이 두 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
|
||||
|
||||
NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다.
|
||||
|
||||
```text
|
||||
패킷 도착
|
||||
├─▶ raw PREROUTING ← conntrack 보다 먼저. 여기서 끊는다
|
||||
├─▶ conntrack: ESTABLISHED 면 통과
|
||||
└─▶ NetworkPolicy 평가 ← 여기까지 오지 않는다
|
||||
```
|
||||
|
||||
`raw` 테이블은 CNI 가 안 쓰는 테이블이라 규칙이 밀려나지도 않는다. 57800 을 같이 막는 까닭은 장애 감지 채널 FD_SOCK2 가 `bind_port + 50000` 을 쓰기 때문이다. 7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다.
|
||||
|
||||
주입이 걸렸는지 양쪽 카운터를 둘 다 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 두 노드의 규칙과 카운터를 본다"
|
||||
sudo iptables -t raw -L PREROUTING -n -v
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||
```
|
||||
|
||||
모양은 이렇고 숫자는 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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초 간격으로 몇 번 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 두 노드가 각각 아는 멤버 수를 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||
```
|
||||
|
||||
```text
|
||||
차단 적용 (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초쯤 걸린다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
|
||||
|
||||
split brain 은 DB 한 줄로 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 디스커버리 테이블의 코디네이터를 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select name, ip, coord from jgroups_ping order by name"
|
||||
```
|
||||
|
||||
```text
|
||||
name | ip | coord
|
||||
------------------+-----------------+-------
|
||||
keycloak-0-30843 | 10.42.1.99:7800 | t
|
||||
keycloak-1-48749 | 10.42.0.46:7800 | t
|
||||
```
|
||||
|
||||
`coord = t` 가 둘이면 분단이고 정상일 때는 하나다.
|
||||
|
||||
대조군을 먼저 재고 시험군을 잰다. 대조군은 같은 노드에서 갱신한다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 대조군 — 로그인한 노드에서 갱신한다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ⑥ 시험군 — 새로 로그인해서 반대 노드에서 갱신한다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== ★ 분단 상태에서 교차 노드 세션 (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 이 회전하기 때문이다.
|
||||
|
||||
```text
|
||||
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번의 ① 을 쓴다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 로그인만 해서 /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
|
||||
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
|
||||
| cut -d. -f2 | base64 -d 2>/dev/null; echo'
|
||||
```
|
||||
|
||||
그다음 DB 를 내린다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 시각을 남기고 DB 를 0 replica 로 내린다"
|
||||
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 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다.
|
||||
|
||||
두 경로를 차례로 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 캐시를 가진 노드에서 refresh"
|
||||
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)"'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ④ 새 로그인"
|
||||
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"'
|
||||
```
|
||||
|
||||
**예상 결과** — 이 실험대에서는 이 값이 나왔다. 아래 ①② 는 증거 파일의 번호이고 위 명령의 ③④ 와 차례가 같다.
|
||||
|
||||
```text
|
||||
① 캐시를 가진 노드에서 refresh HTTP 500
|
||||
② 새 로그인 HTTP 200
|
||||
```
|
||||
|
||||
persistent 에서는 순서가 거꾸로였다. 새 로그인이 `500` 이었는데, 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 통과한다.
|
||||
|
||||
```text
|
||||
로그인에 필요한 것
|
||||
├─ 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 를 되살린다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ DB 를 다시 올린다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
```
|
||||
|
||||
```text
|
||||
deployment.apps/postgres scaled
|
||||
deployment "postgres" successfully rolled out
|
||||
```
|
||||
|
||||
volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용자와 클라이언트와 취소 토큰은 여전히 DB 에 있고, 세션만 메모리로 옮겼다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
순서가 있다. `iptables` 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, `args` 를 되돌린다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 두 노드의 raw 규칙을 확인한다"
|
||||
sudo iptables -t raw -L PREROUTING -n
|
||||
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② DB 파드를 본다"
|
||||
kubectl -n keycloak-lab get pods -l app=postgres
|
||||
```
|
||||
|
||||
`Running` 이 아니면 `scale deployment/postgres --replicas=1` 을 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 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
|
||||
```
|
||||
|
||||
매니페스트를 고쳤다면 파일도 같이 되돌린다. 안 그러면 다음에 `apply` 할 때 volatile 로 다시 간다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 매니페스트의 변경을 확인하고 되돌린다"
|
||||
git diff deploy/lab/k8s/keycloak-cluster.yaml
|
||||
git checkout -- deploy/lab/k8s/keycloak-cluster.yaml
|
||||
```
|
||||
|
||||
```text
|
||||
=== persistent 모드로 원복 ===
|
||||
statefulset.apps/keycloak configured
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
```
|
||||
|
||||
`args` 문자열만 보고 끝내지 않는다. 새 IP 로 탐침을 다시 띄우고 로그인을 한 번 한 다음 DB 행을 센다. 원복도 롤아웃이므로 여기서도 파드 주소가 바뀌었다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 새 IP 로 탐침을 다시 띄운다"
|
||||
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
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ⑥ 로그인을 한 번 한다"
|
||||
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"'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ⑦ 로그인 뒤 온라인 세션 행을 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
```text
|
||||
["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` |
|
||||
|
||||
```bash label="[kc-lab-1] ⑧ 탐침 파드를 지운다"
|
||||
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]` 이다.
|
||||
|
||||
가이드가 미검증으로 표시한 것(unknown)은 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력이다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
|
||||
|
||||
측정이 샌 곳이 하나 있다. `cluster_size` 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
|
||||
|
||||
DB 정지 뒤의 `200 / 500` 은 조건부다. 캐시 온도에 따라 `400 / 400` 이나 `200 / 200` 도 나오고, 셋을 가르는 절차는 A-7a 에 있다. A-7 이 세운 원인 가설도 A-7a 가 틀린 것으로 확정했다.
|
||||
|
||||
이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+855
@@ -0,0 +1,855 @@
|
||||
---
|
||||
id: 21dce25a-a165-47dc-bb40-2ed9f6f9efea
|
||||
kind: SETUP
|
||||
slug: reproduce-a7a-volatile-cause
|
||||
title: 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/21dce25a-a165-47dc-bb40-2ed9f6f9efea/edit"
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
- name: persistent-user-sessions
|
||||
version: v1
|
||||
source:
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7a
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
|
||||
|
||||
PostgreSQL 문장 로깅을 켜고 volatile 상태의 로그인과 refresh 가 각각 SQL 을 몇 개 쏘는지 화면에서 직접 보는 절차다. 이어서 재현 셋을 `rollout restart` 로 갈라 치면 같은 설정에서 `400` 과 `500` 과 `200` 이 차례로 나온다. 약 40분.
|
||||
|
||||
## 관계
|
||||
|
||||
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
|
||||
이 절차가 재현 A·B·C 로 갈라 잰 것을 그 기록이 결론으로 적는다.
|
||||
- **persistent-user-sessions 가 세션의 거처를 정한다**
|
||||
여기서 끄는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
|
||||
- **persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다**
|
||||
이 절차가 확정하는 `500` 이 거기서 나왔다. 그쪽은 주입이 args 와 `iptables` 와 DB 정지이고 계기가 교차 노드 응답 코드이며, 여기는 주입이 문장 로깅과 args 와 DB 정지이고 계기가 표식과 문장 로그다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
주입이 셋이라 검증도 셋이고, 표식이 로그에 들어갔는지를 확인하지 않으면 뒤의 구간 자르기가 통째로 헛돈다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. 이 절차에는 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl` 에 `sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
|
||||
|
||||
이 편의 시각은 UTC 다. 증거의 `11:18:49` 는 KST 로 `20:18` 이고 같은 순간이다. PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이고, 로그 시각과 `date` 를 견줄 때 이걸 잊으면 9시간을 헤맨다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
|
||||
| 탐침 파드 | `a7a-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
|
||||
| 켜는 것 | `log_statement = 'all'` · 반드시 `pg_reload_conf()` 까지 |
|
||||
| 끄는 기능 | `--features-disabled=persistent-user-sessions` |
|
||||
| 표식 | `MARK_TEST` · `MARK_LOGIN_START` · `MARK_LOGIN_END` · `MARK_REFRESH_START` · `MARK_REFRESH_END` · `MARK_R1`~`MARK_R_END` |
|
||||
| 소음 | `JGROUPS_PING` 폴링이 5초마다 로그를 채운다 |
|
||||
|
||||
터미널은 둘을 연다. 하나는 표식과 요청용, 하나는 로그 관찰용이다.
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
A-7 은 이렇게 끝났다.
|
||||
|
||||
> 측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는 `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||
|
||||
그럴듯하고, 틀렸다.
|
||||
|
||||
```text
|
||||
가설을 세우는 것 → 괜찮다
|
||||
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
|
||||
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
|
||||
```
|
||||
|
||||
「refresh 가 어느 테이블 때문에 실패하는가」는 Keycloak 소스를 읽지 않고도 답할 수 있다. DB 가 실제로 받은 문장을 보면 된다. 확정해 보니 원인만 틀린 게 아니었다 — 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다.
|
||||
|
||||
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 로그인이 SQL 을 0개 쏘는 것, refresh 가 쏘는 딱 한 문장의 이름이 `CLIENT_SCOPE_CLIENT` 인 것, 그 문장이 첫 refresh 에만 나오는 것, `REVOKED_TOKEN` 이 한 번도 안 나오는 것, 같은 설정에서 `400` 과 `500` 과 `200` 이 전부 나오는 것, 그리고 실패한 SQL 을 Keycloak 로그가 직접 지목하는 것.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
앞선 구축 단계 `05-keycloak` 이 끝나 있어야 한다. A-7 을 먼저 한다 — 이 절차는 A-7 이 남긴 가설을 확정하는 것이고, 거기서 본 `500` 에서 출발한다. A-3 에서 문장 로깅을 해 봤으면 같은 기법이다.
|
||||
|
||||
주입이 셋이고 복구도 셋이다.
|
||||
|
||||
- PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
|
||||
- Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
|
||||
- PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다
|
||||
|
||||
실험대에서만 한다. 중간에 그만두려면 복구 절을 위에서부터 그대로 친다.
|
||||
|
||||
표식을 넣는 방식에서 이 절차가 원 실행과 갈라진다. 원 실행은 표식을 셸 함수로 감쌌다.
|
||||
|
||||
```bash label="[kc-lab-1] 이 실험대는 이렇게 했다 (observed)"
|
||||
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
|
||||
```
|
||||
|
||||
짧고 편한데 출력을 `/dev/null` 로 버린다. 표식이 실제로 로그에 들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이고, 로깅이 안 켜져 있었다면 표식 없는 로그를 한참 뒤에 `awk` 로 자르다가 알게 된다.
|
||||
|
||||
따라 하는 사람은 표식을 한 줄씩 손으로 넣는다. 느리지만 그 즉시 보이고, 안 보이면 그 즉시 안다. 아래 절차가 전부 그 형태다.
|
||||
|
||||
```bash label="[kc-lab-1] 따라 하는 사람은 이 형태로 친다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_TEST'"
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
```text
|
||||
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
|
||||
```
|
||||
|
||||
### 1. 파드 셋이 전부 떠 있는지 본다
|
||||
|
||||
**목적** — 이 절차가 내렸다 올릴 `postgres` 가 지금 있는지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 네임스페이스의 파드를 넓게 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
**왜 필요한가** — 셋 다 `Running` 이어야 하고 `postgres` 가 특히 그렇다. 이 절차는 그것을 세 번 내렸다 올린다.
|
||||
|
||||
**문제가 생기면** — `postgres` 가 없으면 `scale deployment/postgres --replicas=1` 부터 친다.
|
||||
|
||||
### 2. 문장 로깅이 지금 꺼져 있는지 본다
|
||||
|
||||
**목적** — 지금 쌓이는 로그가 이 실험 것인지 앞 실험 것인지 가른다.
|
||||
|
||||
```bash label="[kc-lab-1] 현재 설정값을 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
log_statement
|
||||
---------------
|
||||
none
|
||||
```
|
||||
|
||||
**왜 필요한가** — `all` 이면 앞 실험이 켜 둔 채 끝낸 것이고, 지금 쌓인 로그가 어느 실험 것인지 구별할 수 없다.
|
||||
|
||||
**문제가 생기면** — `all` 이 나오면 먼저 끄고 로그가 한 바퀴 돌 때까지 기다린 뒤에 시작한다.
|
||||
|
||||
### 3. 지금 args 를 적어 둔다
|
||||
|
||||
**목적** — 복구에서 되돌릴 문자열을 확보한다.
|
||||
|
||||
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
|
||||
kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
["start"]
|
||||
```
|
||||
|
||||
**왜 필요한가** — 복구 단계가 이 값 그대로 되돌린다.
|
||||
|
||||
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린다.
|
||||
|
||||
### 4. 탐침 파드를 StatefulSet 밖에 띄운다
|
||||
|
||||
**목적** — Keycloak 을 여러 번 재시작해도 죽지 않는 요청 장치를 세운다.
|
||||
|
||||
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="K0=$K0" \
|
||||
--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/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
```text
|
||||
19
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
|
||||
kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
|
||||
```
|
||||
|
||||
**예상 결과** — IP 가 보이고 `PW길이` 가 0 이 아니다.
|
||||
|
||||
**왜 필요한가** — 명령줄에 평문 비밀번호를 쓰면 파드 안 `ps` 에도 셸 히스토리에도 남는다. 원래 실험의 재현 절차에 그 형태가 그대로 적혀 있었다.
|
||||
|
||||
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
### 5. 로그가 지금 무엇으로 차 있는지 본다
|
||||
|
||||
**목적** — 켜기 전의 로그를 한 번 봐 두고, 켠 뒤의 소음과 견준다.
|
||||
|
||||
```bash label="[kc-lab-1] 마지막 20줄을 본다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=20
|
||||
```
|
||||
|
||||
**예상 결과** — 조용하다. 여기까지는 에러만 찍힌다.
|
||||
|
||||
**왜 필요한가** — 다음 절에서 로깅을 켜면 JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링이 로그를 계속 채운다. 그 소음을 먼저 봐 두면 나중에 `grep -v JGROUPS_PING` 으로 거르는 까닭을 안다.
|
||||
|
||||
**문제가 생기면** — 지금 SQL 이 줄줄이 나오면 로깅이 이미 켜져 있다. 2번으로 돌아간다.
|
||||
|
||||
## 주입
|
||||
|
||||
주입 셋을 차례로 넣는다. 셋 다 되돌리는 명령을 먼저 읽어 둔다.
|
||||
|
||||
### 6. PostgreSQL 문장 로깅을 켠다
|
||||
|
||||
**목적** — 서버가 받은 모든 SQL 을 로그에 찍게 한다.
|
||||
|
||||
되돌리는 명령을 먼저 읽어 둔다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 되돌리는 명령 — 먼저 읽어 둔다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 문장 로깅을 켜고 설정을 다시 읽힌다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
|
||||
```
|
||||
|
||||
**예상 결과** — `ALTER SYSTEM` 과 `pg_reload_conf` 가 차례로 돌고, 곧 로그가 차기 시작한다.
|
||||
|
||||
**왜 필요한가** — 애플리케이션을 고치지 않고 「이 요청이 DB 를 어떻게 쓰는지」를 밖에서 볼 수 있다. 이것 없이 하면 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
|
||||
|
||||
**문제가 생기면** — 로그가 안 차면 `pg_reload_conf()` 가 안 돌았다. `alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.
|
||||
|
||||
### 7. Keycloak 을 volatile 로 바꾼다
|
||||
|
||||
**목적** — 세션을 메모리로 옮겨 A-7 이 본 조건을 만든다.
|
||||
|
||||
되돌리는 명령을 먼저 읽어 둔다.
|
||||
|
||||
```bash label="[kc-lab-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
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② persistent-user-sessions 를 끈다"
|
||||
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"]}]'
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
파드 IP 가 바뀌었으므로 탐침 파드를 다시 띄운다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 탐침을 지우고 새 IP 로 다시 띄운다"
|
||||
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--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/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
**예상 결과** — 롤아웃이 `partitioned roll out complete` 로 끝나고 탐침이 Ready 가 된다.
|
||||
|
||||
**왜 필요한가** — 빌드 옵션이라 기동할 때 재빌드가 일어나 오래 걸린다. 그래서 `--timeout=500s` 를 준다.
|
||||
|
||||
**문제가 생기면** — 타임아웃이 나면 같은 명령을 다시 치고 `logs keycloak-0` 에 빌드 진행이 보이는지 본다.
|
||||
|
||||
### 8. PostgreSQL 을 내린다
|
||||
|
||||
**목적** — DB 가 없는 구간을 만들어 캐시가 무엇을 대신하는지 본다.
|
||||
|
||||
```bash label="[kc-lab-1] DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
**예상 결과** — `postgres` 파드가 목록에서 사라진다.
|
||||
|
||||
**왜 필요한가** — 세 재현마다 한 번씩, 모두 세 번 내린다. 각 재현에서 내리는 시점이 다르고 그 시점이 곧 캐시 온도를 정한다.
|
||||
|
||||
**문제가 생기면** — 파드가 안 사라지면 `--timeout` 을 늘려서 다시 기다린다. `delete pod` 은 쓰지 않는다 — Deployment 가 곧바로 새로 만든다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
주입이 셋이라 검증도 셋이다. 로깅이 켜졌는지, 표식이 로그에 들어가는지, volatile 전환이 동작으로도 바뀌었는지를 따로 본다.
|
||||
|
||||
### 9. 로깅이 실제로 켜졌고 로그가 차기 시작했는지 본다
|
||||
|
||||
**목적** — 설정값과 실제 출력을 둘 다 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 설정값을 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
|
||||
```text
|
||||
log_statement
|
||||
---------------
|
||||
all
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 로그가 차는지 본다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=10
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
|
||||
```
|
||||
|
||||
**왜 필요한가** — `JGROUPS_PING` 이 계속 나오는 것이 앞에서 예고한 소음이고, 이게 안 보이면 로깅이 안 켜졌다.
|
||||
|
||||
**문제가 생기면** — `none` 이 나오면 `pg_reload_conf()` 를 다시 친다.
|
||||
|
||||
### 10. 표식이 로그에 들어가는지 본다
|
||||
|
||||
**목적** — 구간을 자를 수 있는 상태인지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 표식을 하나 넣는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_TEST'"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 그 표식이 로그에 있는지 본다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
|
||||
```
|
||||
|
||||
**왜 필요한가** — `statement: select 'MARK_TEST'` 가 보이면 이제 표식과 표식 사이만 잘라 볼 수 있다.
|
||||
|
||||
**문제가 생기면** — 안 보이면 9번의 로깅 확인으로 돌아간다.
|
||||
|
||||
### 11. volatile 전환을 args 와 동작으로 둘 다 본다
|
||||
|
||||
**목적** — 선언과 동작이 같이 바뀌었는지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] args · 로그인 응답 코드 · DB 행 수를 이어서 본다"
|
||||
kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
kubectl -n keycloak-lab exec a7a-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"'
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
volatile 전환 확인
|
||||
args: ["start","--features-disabled=persistent-user-sessions"]
|
||||
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
||||
```
|
||||
|
||||
**왜 필요한가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 행이 안 생기는 것으로 판정한다.
|
||||
|
||||
**문제가 생기면** — 행 수가 0 이 아니면 옛 행이 남아 있다. A-7 처럼 `delete from offline_user_session` 을 먼저 하고 다시 잰다.
|
||||
|
||||
문장 로그가 지금 요청을 잡고 있는지도 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 최근 60초의 로그 끝을 본다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
|
||||
```
|
||||
|
||||
이 시점에서는 거의 `JGROUPS_PING` 뿐일 텐데, 그게 이 실험의 첫 발견이다. 지금은 「내 요청이 어디 있는지 모르겠다」로만 보이고, 구간을 나눠야 보인다.
|
||||
|
||||
## 관찰
|
||||
|
||||
표식 → 요청 → 표식 순으로 치고 `awk` 로 그 사이를 자른다.
|
||||
|
||||
### 12. 로그인이 무슨 SQL 을 쏘는지 본다
|
||||
|
||||
**목적** — 로그인 한 번이 DB 에 무엇을 보내는지 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 표식 · 로그인 · 표식을 차례로 친다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_LOGIN_START'"
|
||||
kubectl -n keycloak-lab exec a7a-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"'
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_LOGIN_END'"
|
||||
```
|
||||
|
||||
```text
|
||||
rt 1188 bytes
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 로그를 파일로 받아 구간을 자른다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||
↑ 사이에 아무것도 없다
|
||||
```
|
||||
|
||||
**왜 필요한가** — 두 줄뿐이고 로그인은 SQL 을 0개 쏜다. realm 과 사용자와 클라이언트가 전부 Infinispan 캐시에 있고 volatile 이라 세션 쓰기도 없다. `awk '/A/,/B/'` 는 A 가 나온 줄부터 B 가 나온 줄까지 출력한다. 로그를 파일로 먼저 받는 까닭은 같은 로그를 여러 구간으로 반복해서 잘라 볼 것이기 때문이다.
|
||||
|
||||
**문제가 생기면** — `rt 1 bytes` 면 파싱이 실패했고, 그 상태로 다음을 하면 빈 토큰을 보내고 엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
|
||||
|
||||
### 13. refresh 가 쏘는 한 문장의 이름을 읽는다
|
||||
|
||||
**목적** — A-7 의 가설이 지목한 테이블이 실제로 나오는지 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 표식 · refresh · 표식을 차례로 친다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_REFRESH_START'"
|
||||
kubectl -n keycloak-lab exec a7a-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 deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_REFRESH_END'"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 그 구간을 자른다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||
11:18:52.137 statement: BEGIN
|
||||
11:18:52.137 execute <unnamed>/C_107:
|
||||
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||
11:18:52.148 execute S_2: COMMIT
|
||||
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||
```
|
||||
|
||||
세 가지를 본다 — `BEGIN` 과 `COMMIT` 사이에 `select` 가 하나뿐인 것, 테이블 이름이 `CLIENT_SCOPE_CLIENT` 인 것, `parameters` 줄의 `$2 = 'f'`.
|
||||
|
||||
가설이 지목한 테이블이 정말 없는지 직접 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 그 구간에서 REVOKED_TOKEN 을 센다"
|
||||
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
|
||||
```
|
||||
|
||||
```text
|
||||
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
||||
```
|
||||
|
||||
**왜 필요한가** — `DEFAULT_SCOPE='f'` 가 그 문장을 읽는 열쇠다. Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
|
||||
|
||||
| | 뜻 | `DEFAULT_SCOPE` |
|
||||
|---|---|---|
|
||||
| default scope | 항상 붙는다 | `t` |
|
||||
| optional scope | 요청이 `scope=` 로 달라고 해야 붙는다 | `f` |
|
||||
|
||||
refresh 는 새 access token 을 만든다. 그 토큰에 어떤 스코프를 담을지 정하려면 이 클라이언트가 요청할 수 있는 optional 스코프가 무엇인지 알아야 하고, 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다. 로그인 때는 이미 결정된 것을 쓰지만 refresh 는 다시 계산한다. 이 조회가 실패하면 토큰을 만들 수 없어 `500` 이 된다. `400 Session not active` 와 달리 세션 문제가 아니어서, A-7 이 세션 계열 테이블을 의심한 것이 자연스러웠지만 빗나갔다.
|
||||
|
||||
그 UUID 가 어느 클라이언트인지 궁금하면 물어본다. UUID 는 렐름을 만들 때 정해지므로 실험대마다 다르다. ②가 자른 구간의 `parameters` 줄에 있는 `$1` 값을 그대로 옮겨 넣는다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 그 UUID 가 어느 클라이언트인지 묻는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "select id, client_id from client where id='{{CLIENT_UUID}}'"
|
||||
```
|
||||
|
||||
이 실험대의 값은 `131a9912-b578-4b9c-b16a-97518704077e` 였다(observed).
|
||||
|
||||
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
|
||||
|
||||
**문제가 생기면** — 구간에 표식이 두 번 나오면 로그를 여러 번 받아 구간이 겹쳤다. `--tail` 을 줄이거나 새 표식 이름을 쓴다.
|
||||
|
||||
### 14. 그 조회가 한 번뿐인 것을 본다
|
||||
|
||||
**목적** — 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않는다는 것을 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 표식을 사이사이에 넣으며 refresh 를 돈다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_R1'"
|
||||
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -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)" > /tmp/tok
|
||||
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select 'MARK_R2'"
|
||||
```
|
||||
|
||||
같은 모양으로 `MARK_R3` 과 `MARK_R_END` 까지 두 번 더 한다. 매번 `/tmp/rt` 를 다시 채운다 — refresh token 은 회전하고, 옛것을 계속 쓰면 나오는 오류가 무효화 때문인지 재사용 때문인지 구별되지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 표식 넷 사이를 통째로 자른다"
|
||||
kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||
statement: select 'MARK_R1'
|
||||
statement: select 'MARK_R2'
|
||||
statement: select 'MARK_R3'
|
||||
statement: select 'MARK_R_END'
|
||||
↑ SQL 0건
|
||||
```
|
||||
|
||||
**왜 필요한가** — 표식 네 줄만 있고 그 사이에 아무것도 없다. 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않으므로, DB 를 언제 내리느냐에 따라 답이 달라진다.
|
||||
|
||||
**문제가 생기면** — `400 Session not active` 가 나오면 옛 refresh token 을 재사용했다. 매번 `/tmp/rt` 를 갱신한다.
|
||||
|
||||
### 15. 재현 A — 완전 냉시동이면 로그인부터 400 이다
|
||||
|
||||
**목적** — 캐시가 전부 빈 상태에서 DB 를 내렸을 때의 답을 잰다.
|
||||
|
||||
캐시는 Keycloak 을 재시작해야만 식는다.
|
||||
|
||||
```text
|
||||
Infinispan 캐시 = 프로세스 메모리
|
||||
│
|
||||
└─ 파드가 살아 있는 한 안 식는다
|
||||
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
|
||||
```
|
||||
|
||||
이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다. 이미 더워진 캐시에서 계속 재게 되므로 A 와 B 를 재도 C 의 답이 나오고, 「A-7 이 틀렸다」는 엉뚱한 결론에 이른다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 재시작하고 곧바로 DB 를 내린다"
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
재시작과 DB 정지 사이에 아무 요청도 보내지 않는다. 한 번이라도 로그인하면 캐시가 더워져서 이건 재현 B 가 된다. 파드 IP 가 바뀌었으므로 탐침을 다시 띄운 다음 로그인을 본문까지 본다.
|
||||
|
||||
탐침의 `K0` 는 만들 때 고정된 값이라 재시작 뒤에는 낡았다. 지우고 새 IP 로 다시 만든다. 이 블록을 건너뛰면 뒤의 curl 이 없는 주소로 가고, 그 침묵을 「DB 가 없어서 실패」로 읽게 된다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
|
||||
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--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/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 로그인을 본문과 함께 본다"
|
||||
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -w "\n%{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"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
로그인 400 {"error":"unauthorized_client",
|
||||
"error_description":"Unexpected error when authenticating client"}
|
||||
```
|
||||
|
||||
`unauthorized_client` 이고 `invalid_grant` 가 아니다. 세션 문제가 아니라 클라이언트를 못 찾았다. 왜인지는 Keycloak 로그가 직접 말한다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 실패한 SQL 을 Keycloak 로그에서 뽑는다"
|
||||
kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||
```
|
||||
|
||||
```text
|
||||
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
|
||||
authenticating client: org.hibernate.exception.GenericJDBCException:
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||
```
|
||||
|
||||
**왜 필요한가** — 대괄호가 두 쌍이다. 앞은 DB 가 준 오류, 뒤는 실패한 SQL 원문이고 `grep -oE` 가 그 두 쌍만 뽑는다. A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었는데 냉시동에서는 클라이언트 조회조차 캐시에 없어서 로그인부터 실패한다.
|
||||
|
||||
**문제가 생기면** — 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다. 로그인이 `200` 이 나오면 재시작 후 요청을 한 번이라도 보낸 것이므로 이 재현을 처음부터 다시 한다.
|
||||
|
||||
### 16. 재현 B — 로그인만 한 번 하면 refresh 가 500 이다
|
||||
|
||||
**목적** — A-7 이 본 그 조건을 그대로 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
탐침을 새 IP 로 다시 띄운 뒤 로그인 한 번만 한다. 15번과 같은 이유로 여기서도 탐침을 다시 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
|
||||
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--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/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 로그인 한 번으로 캐시를 절반만 데운다"
|
||||
kubectl -n keycloak-lab exec a7a-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"'
|
||||
```
|
||||
|
||||
여기서 refresh 를 하면 재현 C 가 된다. 8번의 DB 정지를 친 다음에 refresh 한다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ DB 가 없는 상태에서 refresh 한다"
|
||||
kubectl -n keycloak-lab exec a7a-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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
로그인 200
|
||||
refresh 500 {"error":"unknown_error"}
|
||||
```
|
||||
|
||||
실패한 SQL 을 15번과 같은 `grep -oE` 로 뽑으면 이렇게 나온다.
|
||||
|
||||
```text
|
||||
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||
administrator command]
|
||||
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||
```
|
||||
|
||||
**왜 필요한가** — 13번에서 문장 로깅이 「이 문장을 쏜다」를 보여 줬고 여기서는 「이 문장이 실패했다」가 나온다. 둘이 만나면 가설이 아니라 확정이 된다. `500 unknown_error` 인 까닭도 이제 안다 — 세션은 멀쩡하고, 토큰을 조립하다가 DB 가 없어서 못 만든 것을 Keycloak 이 사용자 오류로 분류할 방법이 없어서 `unknown_error` 를 준다.
|
||||
|
||||
**문제가 생기면** — `200 / 200` 이 나오면 로그인 뒤 refresh 를 미리 했다. 로그인 한 번만 하고 DB 를 내린다.
|
||||
|
||||
### 17. 재현 C — 미리 세 번 갱신해 두면 둘 다 200 이다
|
||||
|
||||
**목적** — 캐시가 완전히 더운 상태의 답을 잰다.
|
||||
|
||||
DB 를 살리고, 재시작하고, 탐침을 새로 만들고, 로그인하고, refresh 를 3회 미리 돌린 뒤 DB 를 내린다. 앞 절들의 명령을 그대로 다시 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||
```
|
||||
|
||||
파드가 새로 떴으므로 `K0` 가 낡았다. 탐침도 그 값을 `--env` 로 박아 뒀으니 같이 다시 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
|
||||
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never --env="K0=$K0" \
|
||||
--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/a7a-probe --timeout=120s
|
||||
```
|
||||
|
||||
새 탐침에는 `/tmp/rt` 가 없다. 16번의 ③ 과 같은 명령으로 다시 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 로그인해서 /tmp/rt 를 새로 만든다"
|
||||
kubectl -n keycloak-lab exec a7a-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"'
|
||||
```
|
||||
|
||||
**여기서 재현 C 가 끊긴다.** 다음에 와야 할 것은 DB 를 내리기 전에 refresh 를 세 번 돌려 캐시를 마저 채우는 단계인데, **그 세 번을 치는 명령이 원본 가이드에 없다**(unknown). 가이드는 「refresh 3회를 미리 돌린 뒤」라고 쓰고 그 세 번의 명령도, 회전하는 refresh token 을 `/tmp/rt` 에 매번 다시 쓰는 형태도 남기지 않았다. 14번의 ① 이 표식 사이에서 refresh 를 한 번 돌리며 `/tmp/rt` 를 갱신하는 형태를 갖고 있지만, 그것을 세 번 돌리는 것이 가이드가 말한 그 3회와 같은지는 확인되지 않았다. **이 단계를 채우지 못하면 아래 ④⑤ 를 쳐도 재현 B 와 같은 상태이고 `500` 이 나온다.**
|
||||
|
||||
그 세 번을 돌렸다고 보고, 8번과 같은 명령으로 DB 를 내린다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 로그인과 refresh 를 이어서 친다"
|
||||
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||
'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
|
||||
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
|
||||
curl -s -o /dev/null -w "refresh %{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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
||||
로그인 200
|
||||
refresh 200 ← A-7 의 표와 정반대다
|
||||
```
|
||||
|
||||
**왜 필요한가** — 같은 설정, 같은 명령, 세 개의 답이 나왔다.
|
||||
|
||||
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|
||||
|---|---|---|---|
|
||||
| 완전 냉시동 (재시작 직후) | `400` | `400` | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
|
||||
| CLIENT 만 더움 ← A-7 이 본 것 | `200` | `500` | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
|
||||
| 완전히 더움 | `200` | `200` | 없음 (SQL 0건) |
|
||||
|
||||
무엇이 다른지는 `kubectl get` 어디에도 안 나온다. 캐시 온도는 보이지 않는 상태이고, A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다.
|
||||
|
||||
```text
|
||||
volatile + DB 정지의 결과
|
||||
= "무엇을 하느냐"가 아니라
|
||||
"그 경로가 이미 캐시를 채웠느냐"
|
||||
```
|
||||
|
||||
persistent 기본값에는 이 조건부성이 없다. 세션 자체를 DB 에 쓰므로 DB 가 없으면 캐시 온도와 무관하게 실패한다. 이것은 volatile 고유의 성질이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
|
||||
|
||||
**문제가 생기면** — 세 재현이 전부 `200/200` 이면 재시작을 건너뛰어 캐시가 계속 더웠다. 재현마다 `rollout restart` 를 넣는다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
셋을 순서대로 되돌린다. DB 가 살아 있어야 나머지가 된다.
|
||||
|
||||
```bash label="[kc-lab-1] ① DB 를 올리고 Ready 까지 기다린다"
|
||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
|
||||
```
|
||||
|
||||
문장 로깅을 끈다. 잊으면 다음 실험이 전부 오염된다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 문장 로깅을 끄고 값을 다시 읽는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "show log_statement"
|
||||
```
|
||||
|
||||
```text
|
||||
log_statement
|
||||
---------------
|
||||
none
|
||||
```
|
||||
|
||||
왜 급한가 — A-3 은 수백 건의 로그인을 최대한 빨리 돈다. `log_statement='all'` 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고, 로그가 폭주하고 디스크 입출력이 늘어 크래시 타이밍 자체가 달라진다. 다음 실험의 측정값이 이 설정 때문에 바뀐다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 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
|
||||
```
|
||||
|
||||
`args` 문자열만 보고 끝내지 않는다. 탐침을 새 IP 로 띄우고 로그인을 한 번 한 다음 행을 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 로그인 뒤 온라인 세션 행을 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
0 이 아니어야 한다. 로그인 후 행이 생기면 persistent 로 돌아온 것이고, 원래 재현 절차도 마지막에 이 한 줄을 둔다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
|
||||
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
|
||||
| DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
|
||||
| 동작 | 로그인 뒤 `select count(*) ...` | 세션 행이 생긴다 |
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||
| 탐침 파드 | `kubectl -n keycloak-lab get pod a7a-probe` | `NotFound` |
|
||||
| 임시 파일 | `ls /tmp/pg.log` | 지워도 된다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 탐침과 임시 파일을 치운다"
|
||||
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||
rm -f /tmp/pg.log
|
||||
```
|
||||
|
||||
## 막히면
|
||||
|
||||
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 |
|
||||
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` |
|
||||
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
|
||||
| 로그 시각이 9시간 어긋난다 | 컨테이너 로그가 UTC 다 | `date -u` 와 비교한다 |
|
||||
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 |
|
||||
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 |
|
||||
| 세 재현이 전부 `200/200` | 재시작을 건너뛰어 캐시가 계속 더웠다 | 재현마다 `rollout restart` |
|
||||
| 재현 A 가 `200` 이 나온다 | 재시작 후 요청을 한 번이라도 보냈다 | 재시작 뒤 바로 DB 정지 |
|
||||
| 재현 B 가 `200/200` | 로그인 뒤 refresh 를 미리 했다 | 로그인 한 번만 하고 DB 정지 |
|
||||
| `JDBC exception` grep 이 빈 출력 | `--tail` 이 짧거나 정규식이 안 맞는다 | `grep -i 'JDBC exception'` 으로 먼저 넓게 |
|
||||
| 재시작 뒤 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | 탐침을 지우고 새 IP 로 다시 띄운다 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
|
||||
| 다음 실험의 postgres 로그가 폭주한다 | 문장 로깅을 끄지 않았다 | `show log_statement` 가 `none` |
|
||||
| 다음 실험의 세션이 안 살아남는다 | volatile 로 둔 채 끝냈다 | 로그인 뒤 행 수 확인 |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 실험대가 실제로 본 것(observed)은 volatile 전환 확인의 세 줄(`args` · 로그인 `200` · 행수 `0`), 로그인 구간의 표식 두 줄 `11:18:49.461` 과 `11:18:49.743` 및 그 사이 SQL 0건, refresh 구간의 다섯 줄과 `CLIENT_SCOPE_CLIENT` 문장 전문과 파라미터 `$1 = '131a9912-b578-4b9c-b16a-97518704077e'` 및 `$2 = 'f'`, `REVOKED_TOKEN` 0건, 연속 refresh 3회의 표식 네 줄과 SQL 0건, 재현 A 의 `400 unauthorized_client` 와 `select ce1_0.ID from CLIENT ...` 실패 SQL, 재현 B 의 `200` 과 `500 unknown_error` 및 `CLIENT_SCOPE_CLIENT` 실패 SQL, 재현 C 의 `200` 과 `200`, 비밀번호 길이 `19`, 표식 시험의 `statement: select 'MARK_TEST'` 다.
|
||||
|
||||
이 편에는 가이드가 미검증으로 표시한 명령이 하나도 없다(unknown 이 0건이다). 표식을 감싼 셸 함수 `m()` 은 원 실행이 실제로 썼고(observed), 그것을 한 줄씩 손으로 푸는 형태가 가이드의 권고다.
|
||||
|
||||
시각 표기는 UTC 다. 증거 파일과 위 인용이 전부 UTC 이고 KST 로는 `20:18–20:24` 이며, PostgreSQL 컨테이너가 UTC 로 찍기 때문이다.
|
||||
|
||||
A-7 에서 틀린 것으로 확정된 것이 둘이다. 원인 테이블을 `REVOKED_TOKEN` 으로 본 가설, 그리고 「volatile 이면 DB 없이 로그인된다」는 서술이다. 냉시동에서는 로그인부터 실패한다.
|
||||
|
||||
이 절차가 재지 않은 것은 캐시가 얼마나 오래 더운지다. `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로 한참 뒤에 다시 재면 또 다른 답이 나올 수도 있다. 그것까지 확인하려면 재현 C 뒤에 시간을 두고 같은 시험을 반복해야 한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
---
|
||||
id: 0b64d23a-f82a-44b4-ad54-e079578977c4
|
||||
kind: SETUP
|
||||
slug: reproduce-a8-rolling-restart
|
||||
title: 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/0b64d23a-f82a-44b4-ad54-e079578977c4/edit"
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
source:
|
||||
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-8
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다
|
||||
|
||||
`rollout restart` 로 파드 둘을 교체한 뒤에도 토큰이 아직 통하는지 보는 절차다. 로그인해서 refresh token 과 `sid` 를 탐침 파드 안 파일에 담아 두고 교체한 다음 그 토큰을 쓴다. 외부 응답 시계열도 함께 잰다. 전 구간 15~20분이고 되돌릴 것이 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **롤링 재시작은 세션을 남기고 캐시만 지웠다**
|
||||
이 절차가 재는 것을 그 기록이 결론으로 적는다.
|
||||
- **persistent-user-sessions 가 세션의 거처를 정한다**
|
||||
세션이 살아남는 까닭이 그 기능이고, 그것이 꺼져 있으면 이 절차는 정반대 답을 낸다.
|
||||
- **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다**
|
||||
여기의 가용성 루프가 그 점검에서 실행 가능한 형태로 고쳐진 명령 가운데 하나다.
|
||||
- **persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다**
|
||||
같은 시험을 옛 기본값 위에서 치면 `200` 이 `400 Session not active` 로 바뀐다.
|
||||
- **세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 손으로 가른다**
|
||||
「세션은 DB 에 있고 캐시는 사본」이라는 모델을 거기서 세웠고, 여기서 파드를 통째로 갈아 그 모델을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl` 에 `sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
|
||||
|
||||
터미널은 둘을 연다. 하나는 가용성 감시용이라 루프가 도는 동안 붙잡혀 있고, 하나는 재시작과 관찰용이다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `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` 으로 자른다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다.
|
||||
|
||||
```text
|
||||
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
|
||||
│
|
||||
└─ 세션이 거기 있었다면?
|
||||
```
|
||||
|
||||
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을 세웠다. 그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 하고, 틀리다면 배포가 곧 전원 로그아웃이다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| A-0 모델 (persistent) | 재시작해도 세션 생존 |
|
||||
| 옛 방식 (volatile) | 재시작하면 전원 로그아웃 |
|
||||
|
||||
둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다. 그리고 이 절차는 가용성도 같이 잰다 — 세션이 살아도 재시작 중에 서비스가 끊기면 그것대로 문제가 된다.
|
||||
|
||||
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것, 재시작 전에 발급한 토큰이 재시작 후에도 통하는 것, DB 세션 수가 그대로인 것, 캐시만 0 으로 비워지는 것, 클러스터가 스스로 다시 붙는 것, 그리고 「무중단」이 관측 해상도에 달려 있다는 것.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
앞선 구축 단계 `05-keycloak` 과 `06-observability` 가 끝나 있어야 한다. A-0 을 먼저 하면 좋다 — 「세션은 DB 에 있고 캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
|
||||
|
||||
이건 파괴적이지 않다. 그래서 더 조심한다. `rollout restart` 는 정상 작업이고 되돌릴 것이 없으며 잘못돼도 클러스터가 스스로 회복한다. 그 대신 함정이 다르다 — 재는 것이 「안 깨졌나」라서 측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽고, 원래 실행이 실제로 그랬다.
|
||||
|
||||
다른 실험과 겹치지 않게 한다. 롤링 재시작 중에 다른 주입이 들어가 있으면 무엇 때문에 무엇이 일어났는지 구별되지 않는다.
|
||||
|
||||
정말 되돌려야 하면 이 명령이 있다. 다만 중간에 `rollout status` 를 `Ctrl-C` 로 끊어도 롤아웃 자체는 계속 진행되므로 끝날 때까지 두는 편이 낫다.
|
||||
|
||||
```bash label="[kc-lab-1] 직전 리비전으로 되돌린다"
|
||||
kubectl -n keycloak-lab rollout undo statefulset/keycloak
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
```text
|
||||
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
|
||||
```
|
||||
|
||||
### 1. 파드와 나이와 replica 수를 적어 둔다
|
||||
|
||||
**목적** — 재시작 전의 `AGE` 를 확보하고 replica 가 2 인지 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 파드를 노드와 함께 넓게 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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` 파드가 하나뿐이면 이 절차의 가용성 측정은 성립하지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 두 파드 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"
|
||||
```
|
||||
|
||||
### 2. args 가 기본값인지 확인한다
|
||||
|
||||
**목적** — `persistent-user-sessions` 가 켜져 있는 상태에서 재는지 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
|
||||
kubectl -n keycloak-lab get statefulset keycloak \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
["start"]
|
||||
```
|
||||
|
||||
**왜 필요한가** — 플래그가 없으므로 `persistent-user-sessions` 가 기본으로 켜져 있다. `--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 절차는 정반대 결과를 낸다.
|
||||
|
||||
**문제가 생기면** — 플래그가 보이면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린 뒤에 시작한다.
|
||||
|
||||
### 3. DB 세션 수를 적어 둔다
|
||||
|
||||
**목적** — 재시작 후에 견줄 값을 확보한다.
|
||||
|
||||
```bash label="[kc-lab-1] 온라인 세션과 offline token 을 나눠 센다"
|
||||
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"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
DB 세션 수: 151
|
||||
```
|
||||
|
||||
**왜 필요한가** — 재시작 후 같은 값이 나오는지가 뒤의 판정에 들어간다. 숫자는 환경마다 다르고 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 그래서 이 절차는 개수 말고 특정 `sid` 하나를 따로 추적한다.
|
||||
|
||||
**문제가 생기면** — `(0 rows)` 가 나오면 세션이 없거나 volatile 이다. 2번으로 돌아간다.
|
||||
|
||||
### 4. 상주 탐침 파드를 StatefulSet 밖에 띄운다
|
||||
|
||||
**목적** — 재시작을 넘어 토큰을 들고 있을 장치를 만든다.
|
||||
|
||||
```text
|
||||
토큰을 어디에 두나
|
||||
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
|
||||
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
|
||||
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
|
||||
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
|
||||
```
|
||||
|
||||
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
```text
|
||||
19
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
|
||||
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` 로 거절되고, 이 절차를 두 번째 칠 때도 같은 곳에서 걸린다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 탐침을 지우고 ① 로 돌아간다"
|
||||
kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
|
||||
```
|
||||
|
||||
### 5. 토큰과 sid 를 파드 안에 담고 길이를 확인한다
|
||||
|
||||
**목적** — 재시작을 넘겨 쓸 값을 파일에 남기고, 그 파일이 비어 있지 않은지 본다.
|
||||
|
||||
이 단계에 이 실험의 함정이 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 로그인해서 refresh token 과 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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== [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` 한 번이 이 시험 전체를 지킨다.
|
||||
|
||||
못 미더우면 파일을 직접 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 파일 크기와 앞 40바이트를 본다"
|
||||
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
|
||||
```
|
||||
|
||||
```text
|
||||
-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` 이 무엇과 견준 값인지 확보한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 같은 노드에서 갱신해 본다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
200
|
||||
```
|
||||
|
||||
이 refresh 로 토큰이 회전했다. `/tmp/rt` 의 값은 이제 이미 쓴 토큰이라 다시 채워야 하고, 안 채우면 뒤의 `400` 이 재시작 때문인지 재사용 때문인지 구별되지 않는다. 5번의 ① 과 같은 명령을 그대로 다시 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 다시 로그인해서 두 파일을 새로 만든다"
|
||||
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).
|
||||
|
||||
```bash label="[kc-lab-1] ① sid 를 화면에서 읽는다"
|
||||
kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 읽은 값을 그대로 넣어 행을 찾는다"
|
||||
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).
|
||||
|
||||
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```text
|
||||
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 이 되는 값을 먼저 확보한다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 한 줄짜리 JSON 을 통째로 본다"
|
||||
kubectl -n observability exec deploy/prometheus -- \
|
||||
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||
```
|
||||
|
||||
모양은 이렇고 값은 환경마다 다르다.
|
||||
|
||||
```json
|
||||
{"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).
|
||||
|
||||
```bash label="[kc-lab-1] ② 파드와 값만 세로로 늘어놓는다"
|
||||
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초 간격으로 기록하면서 파드를 교체하고, 그 사이에 엔드포인트가 어떻게 움직이는지 본다.
|
||||
|
||||
두 번째 터미널에서 루프를 돌린다. 재시작보다 먼저 시작해야 끊김 구간을 놓치지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1 · 두 번째 터미널] ① 5초 간격으로 48번 외부를 친다"
|
||||
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` 는 바로 돌아오고, 파드 교체는 그 뒤에 백그라운드로 진행된다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 시각을 남기고 롤링 재시작을 건다"
|
||||
date '+%H:%M:%S 재시작'
|
||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
```
|
||||
|
||||
**엔드포인트는 여기서 봐야 보인다.** 파드가 서비스에서 빠졌다 돌아오는 것은 롤아웃이 도는 동안에만 나타나고, 끝난 뒤에 치면 ready 주소가 늘 둘로 나온다. 두 번째 터미널은 ① 의 루프에 붙잡혀 있으므로 이 터미널에서 몇 번 반복해서 친다. 찍힌 것을 어떻게 읽는지는 15번에서 적는다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 롤아웃이 도는 동안 엔드포인트를 몇 번 본다"
|
||||
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
|
||||
```
|
||||
|
||||
그다음 롤아웃이 끝날 때까지 기다린다. 이 명령은 끝날 때까지 터미널을 붙잡는다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 롤아웃이 끝날 때까지 기다린다"
|
||||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
|
||||
```
|
||||
|
||||
**예상 결과** — 아래는 두 터미널의 출력이 한 파일에 섞여 기록된 것이다. `200` 이 가용성 루프, `Waiting for...` 가 `rollout status` 다. 이 실험대는 ②④ 를 한 블록으로 연달아 쳤고 아래는 그때의 출력이다. 사이에 ③ 을 끼우면 `rollout status` 가 그만큼 늦게 시작하므로 `Waiting for` 줄 수가 이와 다를 수 있다.
|
||||
|
||||
```text
|
||||
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 로 본다
|
||||
|
||||
**목적** — 「세션이 살아남았다」가 의미를 갖는 조건을 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] 파드 나이와 재시작 카운터를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== [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 를 명령줄로 넘긴다.
|
||||
|
||||
```bash label="[kc-lab-1] 새 파드 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. 가용성 시계열을 읽고 표본 수를 센다
|
||||
|
||||
**목적** — 끊김이 관측됐는지 보고, 그 관측이 무엇까지 말할 수 있는지 정한다.
|
||||
|
||||
두 번째 터미널의 출력을 읽는다.
|
||||
|
||||
```text
|
||||
200 200 200 200 200 200 200 200 200
|
||||
```
|
||||
|
||||
**예상 결과** — `200` 이 9개이고 비200 이 없다.
|
||||
|
||||
루프는 48회로 잡았는데 남은 표본은 9개다. 원 기록이 그 차이를 설명하지 않는다(unknown) — 루프를 중간에 끊었는지, 기록에 앞부분만 옮겼는지 알 수 없다. 표본 수를 셀 때는 루프 횟수가 아니라 화면에 실제로 찍힌 개수를 센다.
|
||||
|
||||
**왜 필요한가** — 「무중단」이라고 쓰기 전에 표본 수를 본다.
|
||||
|
||||
```text
|
||||
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
|
||||
│
|
||||
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
|
||||
```
|
||||
|
||||
실제로 더 촘촘히 재니 끊김이 나왔다. 후속 작업에서 1초 간격과 3초 타임아웃으로 다른 전환을 재 본 값이 이렇다.
|
||||
|
||||
```text
|
||||
200 ×24 000 200 ×19
|
||||
```
|
||||
|
||||
`000` 은 서버 오류가 아니라 `--max-time 3` 타임아웃이다. 파드 전환 순간 요청 하나가 3초를 넘겼다.
|
||||
|
||||
| 쓰면 안 되는 문장 | 정확한 문장 |
|
||||
|---|---|
|
||||
| 「무중단이었다」 | 「5초 해상도에서 끊김이 관측되지 않았다」 |
|
||||
|
||||
더 촘촘히 보고 싶으면 루프를 이렇게 바꾼다. 가이드가 미검증으로 표시한 형태다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1 · 두 번째 터미널] 1초 간격 150회로 더 촘촘히 잰다"
|
||||
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 로 보낸다.
|
||||
|
||||
셸 인용이 세 겹이 되는 형태이고, 가이드는 여기에 다른 형태를 제시하지 않는다. 탐침을 다시 띄우면 토큰이 사라지기 때문이다.
|
||||
|
||||
```bash label="[kc-lab-1] 재시작 전 토큰으로 갱신을 시도한다"
|
||||
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)"'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== [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 잡기를 다시 한다.
|
||||
|
||||
### 13. DB 행의 두 시각을 견준다
|
||||
|
||||
**목적** — 응답 코드만이 아니라 쓰기까지 정상인지 본다.
|
||||
|
||||
적어 둔 `sid` 를 넣어 7번의 ② 와 같은 질의를 다시 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 같은 행을 다시 찾는다"
|
||||
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}}'"
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== [4] DB 에 그 세션이 남아 있는가 ===
|
||||
user_session_id | created_on | last_session_refresh
|
||||
--------------------------+------------+----------------------
|
||||
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
|
||||
(1 row)
|
||||
```
|
||||
|
||||
두 숫자의 차이를 본다.
|
||||
|
||||
```text
|
||||
1788495577 - 1788495513 = 64초
|
||||
│ │
|
||||
│ └─ 재시작 전에 세션이 만들어진 시각
|
||||
└─ 재시작 후의 refresh 가 기록된 시각
|
||||
```
|
||||
|
||||
두 값은 유닉스 시각(초)이라 사람이 읽는 형태로 보려면 이렇게 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 두 유닉스 시각을 사람이 읽는 형태로 바꾼다"
|
||||
date -d @1788495513 ; date -d @1788495577
|
||||
```
|
||||
|
||||
**왜 필요한가** — `200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다. A-1 에서 실제로 그런 일이 있었다. 여기서는 새 파드가 DB 에서 세션을 읽었고 갱신 시각을 DB 에 되썼으므로 그 가능성이 없다.
|
||||
|
||||
**문제가 생기면** — `last_session_refresh` 가 안 올랐으면 본 시험을 하기 전에 조회했다. 순서는 refresh 를 먼저 하고 조회한다.
|
||||
|
||||
전체 세션 수도 함께 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 온라인 세션 전체를 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||
```
|
||||
|
||||
```text
|
||||
전체 온라인 세션: 151 (재시작 전 151)
|
||||
```
|
||||
|
||||
3번에서 적어 둔 값과 같다. 한 건도 안 잃었다. `sid` 하나가 살아남은 것과 전체가 살아남은 것은 다른 주장이라 둘 다 본다. 관리 API 호출이 세션을 만들기 때문에 몇 건 늘어날 수는 있고, 크게 줄었다면 그게 문제가 된다.
|
||||
|
||||
### 14. 캐시가 비워지고 클러스터가 다시 붙는 것을 본다
|
||||
|
||||
**목적** — 재시작이 무엇을 지우고 무엇을 남겼는지 가른다.
|
||||
|
||||
```bash label="[kc-lab-1] 캐시 엔트리 수와 클러스터 크기를 이어서 본다"
|
||||
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]'
|
||||
```
|
||||
|
||||
**예상 결과**
|
||||
|
||||
```text
|
||||
=== [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 의 모델이 여기서 그대로 확인된다.
|
||||
|
||||
```text
|
||||
재시작 전: 캐시 N건 + DB 151건
|
||||
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
|
||||
```
|
||||
|
||||
캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다. 룩어사이드 캐시의 성질이다.
|
||||
|
||||
**문제가 생기면** — `cluster_size` 가 `1` 에서 안 올라오면 클러스터가 다시 안 붙었다. 파드 로그에서 멤버 수를 본다.
|
||||
|
||||
### 15. 무중단이 되는 까닭을 엔드포인트에서 본다
|
||||
|
||||
**목적** — 파드가 서비스에서 언제 빠지고 언제 돌아오는지 본다.
|
||||
|
||||
```text
|
||||
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` 를 본다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
주입이 정상 작업이었으므로 되돌릴 것이 없다. 정리만 한다.
|
||||
|
||||
```bash label="[kc-lab-1] 탐침 파드를 지운다"
|
||||
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 이 인접한 주제다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user