Files
keycloak-pattern/docs/guides/experiments/a8-rolling-restart.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

754 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A-8 재현 가이드 — 배포할 때마다 로그아웃되는지 직접 확인한다
해설 문서: [`docs/experiment-a8-rolling-restart.md`](../../experiment-a8-rolling-restart.md) ·
증거 원문: [`docs/evidence/a8-rolling-restart/`](../../evidence/a8-rolling-restart/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것 | 5초 간격 `curl` 시계열 |
| **재시작 전에 발급한 토큰이 재시작 후에도 통하는 것** | 상주 탐침 파드 |
| DB 세션 수가 그대로인 것 | PostgreSQL `OFFLINE_USER_SESSION` |
| **캐시만 0 으로 비워지는 것** | Prometheus `approximate_entries_unique` |
| 클러스터가 스스로 다시 붙는 것 | `vendor_cluster_size` |
| 「무중단」이 **관측 해상도에 달려 있다**는 것 | 표본이 9개뿐인 시계열 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- **[A-0](a0-session-replication.md) 을 먼저 하면 좋다.** 「세션은 DB 에 있고
캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 터미널 **두 개**를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다),
하나는 재시작·관찰용.
## 주의 — 이건 파괴적이지 않다. 그래서 더 조심한다
`rollout restart` 는 **정상 작업**이다. 되돌릴 것이 없고, 잘못돼도 클러스터가
스스로 회복한다. 전 구간 약 15~20분.
**그래서 함정이 다르다.** 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고,
**측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다.** 실제로 원래 실행이
그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.
**다른 실험과 겹치지 않게 한다.** 롤링 재시작 중에 다른 주입이 들어가 있으면
무엇 때문에 무엇이 일어났는지 구별되지 않는다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 13:1913:20 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid·세션 수는 **당신 환경에서 다르다.** 이 문서는 자리표시자
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
**운영에서 가장 자주 겪는 일이다.** 장애가 아니라 정상 작업인데도 사용자가
로그아웃되면 그건 사고다.
```
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
└─ 세션이 거기 있었다면?
```
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을
세웠다. **그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다.**
틀리다면 배포가 곧 전원 로그아웃이다.
| | 예측 |
|---|---|
| A-0 모델 (persistent) | 재시작해도 **세션 생존** |
| 옛 방식 (volatile) | 재시작하면 **전원 로그아웃** |
**둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다.**
그리고 이 실험은 **가용성도 같이 잰다.** 세션이 살아도 재시작 중에 서비스가
끊기면 그것대로 문제다.
---
# 1. 기준선 — 재시작하기 전에
**시험군만 재는 측정은 측정이 아니다.** 재시작 후에 볼 것을 재시작 전에
**똑같은 명령으로** 먼저 봐 둔다.
넓은 것부터 좁혀 간다.
```
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
```
## 1-1. 파드와 나이 — **나이가 판정 근거다**
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
```
**어디를 봐야 하는가**
- `READY` 가 둘 다 `1/1`, `RESTARTS``0`
- **`AGE`** — 이 값을 적어 둔다. **재시작 후 이 값이 초 단위로 바뀌는 것이
「정말 재시작됐다」의 증거다**
- **replica 가 2 인 것** — 무중단의 전제다. 1 이면 반드시 끊긴다
**이 결과가 의미하는 것**`rollout restart` 는 파드를 **삭제하고 새로 만든다.**
그래서 `RESTARTS`**안 오른다.** 재시작 여부를 `RESTARTS` 로 보면 「아무 일도
안 일어났다」로 읽는다. **`AGE` 로 본다.**
IP 를 잡아 둔다. 재시작 후 **반드시 다시 잡는다.**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
## 1-2. args 가 `["start"]` 인가 — 이 실험의 전제
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**형태**
```
["start"]
```
**어디를 봐야 하는가** — 플래그가 없는 것.
**이 결과가 의미하는 것**`persistent-user-sessions` 가 기본으로 켜져 있다.
**`--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 실험은
정반대 결과를 낸다** — 그건 [A-7](a7-volatile-comparison.md) 이다. 앞 실험이
되돌리지 않고 끝냈다면 여기서 잡힌다.
## 1-3. DB 세션 수를 적어 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
```
**형태**
```
offline_flag | count
--------------+-------
0 | 151
```
**어디를 봐야 하는가****`offline_flag = '0'` 이 온라인 세션**이다.
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
DB 세션 수: 151
```
**이 숫자를 적어 둔다.** 재시작 후 같은 값이 나오는 것이 4-3 의 판정이다.
> 숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 **개수에는
> 노이즈가 있다.** 그래서 이 실험은 개수 말고 **특정 sid 하나**를 따로 추적한다.
## 1-4. 상주 탐침 파드 — **StatefulSet 밖에 있어야 한다**
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`).
**그리고 이 실험은 탐침이 재시작을 넘어 살아 있어야 한다.** 토큰을 재시작 전에
받아서 재시작 후에 써야 하기 때문이다.
```
토큰을 어디에 두나
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
```
**하기**
```bash
sudo kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
**확인**
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.94 K1=10.42.0.45 PW길이=19
```
`PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
## 1-5. ★ 토큰을 파드 안에 보관한다 — 여기가 이 실험의 함정이다
**하기** — 로그인해서 응답을 `/tmp/tok` 에, 거기서 뽑은 값을 `/tmp/rt` · `/tmp/sid`
```bash
sudo 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)"'
```
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
sid = XLcgQWRiJrTkuNZcJsNeT_2j
```
**어디를 봐야 하는가****두 값이 다 채워졌는지.**
| 출력 | 뜻 |
|---|---|
| `rt 1188 bytes / sid XLcg...` | 정상 |
| **`rt 1 bytes`** | **빈 문자열 + 개행.** 파싱 실패 |
| `sid` 가 비어 있음 | base64 패딩 때문에 잘렸다. sid 없이 진행하고 4-3 은 개수로 본다 |
### ★ 원래 실행이 실제로 빠진 함정
> **처음 재현 절차는 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽었다.** `/tmp/rt` 를 만드는
> 줄이 빠져 있었다. 그러면 **빈 문자열이 `refresh_token=` 으로 전송되는데,
> 그래도 400 이 아니라 통과한 것처럼 보였다.**
**왜 위험한가** — 이 실험의 판정이 「재시작 후 refresh 가 `200` 인가」다.
**빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로
거짓이 된다.** 그리고 그 오류는 **아무 에러도 안 낸다.**
**그래서 길이를 찍는다.** `wc -c` 한 번이 이 실험 전체를 지킨다.
**확인** — 못 미더우면 파일을 직접 본다
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
sudo kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo
```
**형태**
```
-rw-r--r-- 1 curl_use curl_gro 1188 Sep 4 13:19 /tmp/rt
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
```
**어디를 봐야 하는가**`/tmp/rt` 의 크기가 네 자리이고 내용이 `eyJ`
시작하는 것. `eyJ` 는 base64 로 인코딩된 `{"` 다. **JWT 는 전부 이렇게 시작한다.**
## 1-6. 대조군 — 재시작 전에 refresh 가 되는 것
**이 절을 건너뛰면 뒤의 200 이 아무 의미가 없다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**형태**
```
200
```
**★ 이 refresh 로 토큰이 회전했다.** `/tmp/rt` 의 값은 이제 **쓰인 토큰**이다.
다시 채워 둔다. 안 그러면 4-1 의 400 이 「재시작 때문」인지 「재사용 때문」인지
구별되지 않는다.
```bash
sudo 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 가 최종 추적 대상이다.** 적어 둔다.
**확인** — 그 세션이 DB 에 실제로 있는지 지금 본다
```bash
sudo 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='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
```
**형태**
```
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495513
(1 row)
```
**어디를 봐야 하는가** — 행이 **1개** 있고, `created_on`
`last_session_refresh`**같다.** 아직 갱신한 적이 없다.
**이 결과가 의미하는 것** — 세션이 DB 에 있다. **재시작 후에 이 행이 그대로
있고 `last_session_refresh` 만 올라가는 것**이 4-2 의 판정이다.
## 1-7. 캐시와 클러스터 크기
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**형태**
```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"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
```bash
sudo 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`.
**확인** — 세션 캐시 엔트리 수도 지금 봐 둔다
```bash
sudo 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]'
```
**0 이 아닌 값**이 나올 것이다. 재시작 후 **0 이 되는 것**이 4-4 의 판정이다.
---
# 2. 주입 — 롤링 재시작
여기부터 상태가 바뀐다. **다만 되돌릴 것은 없다.**
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌리는 명령이 없다. 중간에
멈추려면 `rollout status``Ctrl-C` 로 끊으면 되지만 **롤아웃 자체는 계속
진행된다.** 끝날 때까지 두는 편이 낫다. 정말 되돌려야 하면:
```bash
sudo kubectl -n keycloak-lab rollout undo statefulset/keycloak
```
## 2-1. 가용성 감시를 먼저 띄운다
**두 번째 터미널**에서 돌린다. **재시작보다 먼저 시작해야** 끊김 구간을 놓치지
않는다.
**하기**
```bash
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초 간격보다 짧게 잡은 것이다. **타임아웃이 간격보다 길면
> 요청이 밀려 시계열이 어긋난다.**
## 2-2. 재시작한다
**첫 번째 터미널**에서 친다.
**하기**
```bash
date '+%H:%M:%S 재시작'
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
```
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
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...
```
**어디를 봐야 하는가** — 위 원문은 두 터미널의 출력이 **한 파일에 섞여 기록된**
것이다. `200` 이 가용성 루프, `Waiting for...``rollout status`.
- **`0 out of 2``1 out of 2``complete`** — 한 번에 하나씩 간다
- 그 사이사이에 **`200` 이 계속 찍힌다**
**시각을 반드시 적어 둔다.** 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 정말 재시작됐는지부터 본다.** 「세션이 살아남았다」는
결론은 **파드가 진짜 바뀌었을 때만** 의미가 있다.
## 3-1. 파드가 정말 새것인가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [6] 파드 나이 — 정말 재시작되었나 ===
keycloak-0 1/1 Running 0 44s
keycloak-1 1/1 Running 0 66s
```
**어디를 봐야 하는가** — 세 가지다.
- **`AGE` 가 초 단위다** — 1-1 에서 `2d` 였던 것이 `44s` 다. 진짜 새 파드다
- **두 나이가 다르다** (`44s` vs `66s`) — **한 번에 하나씩 내렸다는 증거**다.
22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
- `RESTARTS`**여전히 `0`** — 파드가 재시작된 게 아니라 **교체**됐기 때문이다
**이 결과가 의미하는 것**`RESTARTS` 를 판정에 쓰면 안 된다는 것이 여기서
보인다. `rollout restart` 는 파드를 지우고 새로 만들므로 재시작 카운터는
새 파드에서 0 부터 시작한다.
**★ 파드 IP 가 바뀌었다.** 다시 잡는다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**★ 탐침 파드는 다시 띄우면 안 된다.** `/tmp/rt``/tmp/sid` 가 같이 사라진다.
탐침 안의 `K0` 환경변수는 낡았으므로, **새 IP 를 명령줄에 직접 넘긴다.** 4절의
명령이 그렇게 되어 있다.
## 3-2. 가용성 시계열을 읽는다
두 번째 터미널의 출력을 본다.
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
```
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
```
```
200 200 200 200 200 200 200 200 200
```
**어디를 봐야 하는가****`200` 이 9개.** 비200 이 없다.
### ★ 「무중단」이라고 쓰기 전에 표본 수를 본다
```
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
```
**실제로 더 촘촘히 재니 끊김이 나왔다.** 후속 작업에서 **1초 간격·3초 타임아웃**
으로 D-2 롤백 전환을 재보니:
**실측** — [`experiment-followup-untested-items.md`](../../experiment-followup-untested-items.md) 2절
```
200 ×24 000 200 ×19
```
`000` 은 서버 오류가 아니라 **`--max-time 3` 타임아웃**이다. 파드 전환 순간
요청 하나가 3초를 넘겼다.
**그래서 정확한 서술은 이것이다.**
| 쓰면 안 되는 문장 | 정확한 문장 |
|---|---|
| 「무중단이었다」 | 「**5초 해상도에서 끊김이 관측되지 않았다**」 |
**더 촘촘히 보고 싶으면** 2-1 의 루프를 이렇게 바꾼다. **미검증**
```bash
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
```
---
# 4. 효과를 관찰한다
## 4-1. ★ 본 시험 — 재시작 전 토큰이 아직 통하는가
**하기** — 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다
```bash
sudo 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)"'
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
keycloak-0 에서 refresh HTTP 200
```
**어디를 봐야 하는가**`200`, 그리고 **본문에 새 토큰이 들어 있는 것.**
**이 결과가 의미하는 것****파드가 통째로 바뀌었는데 세션이 그대로다.**
새로 뜬 프로세스는 이 세션을 **메모리에서 알던 것이 아니다.** DB 에서 읽었다.
> **400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다.**
> - 1-6 에서 `/tmp/rt` 를 다시 안 채웠다 → 이미 쓴 토큰이다
> - `rt 1 bytes` 를 놓쳤다 → 빈 문자열을 보내고 있다
> - args 에 `--features-disabled=persistent-user-sessions` 가 있다 → 그건 A-7 이다
>
> 셋 다 아니면 그때 결론을 의심한다.
## 4-2. DB 에 그 세션이 남아 있는가 — sid 로 정확히
**확인**
```bash
sudo 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='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [4] DB 에 그 세션이 남아 있는가 ===
user_session_id | created_on | last_session_refresh
--------------------------+------------+----------------------
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
(1 row)
```
**어디를 봐야 하는가****두 숫자의 차이.**
```
1788495577 - 1788495513 = 64초
│ │
│ └─ 재시작 전에 세션이 만들어진 시각
└─ 재시작 후의 refresh 가 기록된 시각
```
**이 결과가 의미하는 것****응답 코드만 200 인 게 아니라 쓰기까지 정상이다.**
새 파드가 DB 에서 세션을 읽었고, 갱신 시각을 **DB 에 되썼다.**
`200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다.
**A-1 에서 실제로 그런 일이 있었다** — 캐시가 DB 와 무관하게 200 을 준 사례다.
여기서는 DB 행이 갱신됐으므로 그 가능성이 없다.
> **두 값은 유닉스 시각(초)이다.** 사람이 읽는 형태로 보려면:
> ```bash
> date -d @1788495513 ; date -d @1788495577
> ```
## 4-3. 전체 세션 수는 그대로인가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
전체 온라인 세션: 151 (재시작 전 151)
```
**어디를 봐야 하는가** — 1-3 에서 적어 둔 값과 같은지.
**이 결과가 의미하는 것****한 건도 안 잃었다.** sid 하나가 살아남은 것과
전체가 살아남은 것은 다른 주장이고, 둘 다 봐야 한다.
> 관리 API 호출이 세션을 만들기 때문에 **몇 건 늘어날 수는 있다.** 크게 줄었다면
> 그게 문제다.
## 4-4. 캐시는 사라진다 — 그게 정상이다
**확인**
```bash
sudo 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]'
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
```
=== [5] 캐시는 어떻게 되었는가 ===
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
```
**어디를 봐야 하는가** — 세 가지다.
- **캐시가 0 이다** — 프로세스 메모리라 재시작에 사라졌다
- **`keycloak-1` 의 1건** — 방금 4-1 의 refresh 를 처리하며 새로 담은 것이다.
0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
- **`cluster_size` 가 다시 2** — 클러스터가 스스로 재형성됐다
**이 결과가 의미하는 것****A-0 의 모델이 그대로 확인된다.**
```
재시작 전: 캐시 N건 + DB 151건
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
```
**캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다.** 룩어사이드
캐시의 성질이다.
> Grafana 로 보면 세션 캐시가 0 으로 떨어지고 `cluster_size` 가 다시 2 가 되는
> 구간이 한 화면에 잡힌다 —
> [`a8-cache-reset-cluster-reformed.png`](../../evidence/a8-rolling-restart/a8-cache-reset-cluster-reformed.png)
## 4-5. 왜 무중단이 되는가
```
StatefulSet 롤링 재시작
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
│ └─ 이 동안 keycloak-0 이 전부 받는다
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
└─ keycloak-0 종료 → ... (반복)
```
**확인** — 엔드포인트가 실제로 그렇게 움직였나. 재시작 중에 봐야 보인다
```bash
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
```
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다.
> `endpointslice` 를 본다.
**이 결과가 의미하는 것****한 번에 하나씩** 내리므로 항상 최소 하나는 Ready 다.
readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로
본 그 메커니즘이 여기서는 **정상 작업을 안전하게** 만든다.
| 무중단의 조건 | 빠지면 |
|---|---|
| **replica ≥ 2** | 하나뿐이면 내리는 동안 아무도 안 받는다 |
| **readiness 프로브** | 아직 기동 중인 파드로 트래픽이 간다 |
**둘 다 있어야 성립한다.** 이 실험대는 파드가 2개라서 됐다.
---
# 5. 복구
**주입이 정상 작업이었으므로 되돌릴 것이 없다.** 정리만 한다.
## 5-1. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
**남겨 두면** 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 **네임스페이스에
정체 모를 파드가 하나 있는 상태**가 된다. 지운다.
## 5-2. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
| 클러스터 뷰 | `sudo 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'"` | 1-3 과 비슷한 값 |
| 탐침 파드 | `sudo 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` |
> **이 실험이 재지 않은 것 셋**
> - **replica 1 에서 어떻게 되는지** — 반드시 끊긴다고 적었지만 재지 않았다
> - **5초보다 짧은 끊김** — 3-2 참조. 후속 작업이 다른 조건에서 `000` 을 잡았다
> - **캐시가 0 에서 다시 차는 데 걸리는 시간** — 「첫 접근만 느려진다」고 썼지만
> 그 「느림」을 재지 않았다. [A-6](a6-latency-injection.md) 이 인접한 주제다
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **refresh 가 200 인데 뭔가 이상하다** | **`/tmp/rt` 가 비어 있다.** 빈 토큰인데 통과한 것처럼 보인다 | `wc -c < /tmp/rt` — 1-5 |
| refresh 가 `400 Session not active` | 1-6 뒤에 `/tmp/rt` 를 안 채웠다. 이미 쓴 토큰이다 | 새로 로그인해서 다시 담는다 |
| refresh 가 `400` 인데 토큰은 맞다 | **args 가 volatile 이다** | `get statefulset ... args` — 1-2. 그건 [A-7](a7-volatile-comparison.md) |
| 재시작 후 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 다시 — 3-1 |
| 탐침을 다시 띄웠더니 토큰이 없다 | **`/tmp/rt` 가 파드와 함께 사라졌다** | 탐침은 재시작 내내 유지한다 — 3-1 |
| `RESTARTS` 가 0 이라 재시작이 안 된 것 같다 | **`rollout restart` 는 파드를 교체한다** | `AGE` 로 본다 — 3-1 |
| `rollout status` 가 타임아웃 | 파드가 Ready 를 못 받는다 | `describe pod` 의 Events, `logs --previous` |
| 가용성 루프에 `000` 이 섞인다 | `--max-time` 초과. **서버 오류가 아니다** | 간격보다 짧은 타임아웃인지 — 2-1 |
| 가용성 루프가 전부 `000` | 루프가 잘못된 URL 을 친다 | `curl -v` 로 한 번 본다 |
| 세션 수가 크게 줄었다 | 다른 실험이 세션을 지웠거나 volatile 이다 | 1-2 · 1-3 을 다시 |
| DB 행의 `last_session_refresh` 가 안 올랐다 | 4-1 을 하기 전에 조회했다 | 순서: refresh → 조회 |
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
---
# 다음
| 실험 | A-8 이 남긴 질문 |
|---|---|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **이 실험을 그대로 반복하면 정반대가 나와야 한다.** 그 한 쌍이 「왜 persistent 인가」의 답이다 |
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 **전제**다 |
| [A-2](a2-database-loss.md) DB 정지 | 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다 |
| 구성 | 무중단은 공짜가 아니라 **replica ≥ 2 + readiness** 의 조합이다 |