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>
754 lines
31 KiB
Markdown
754 lines
31 KiB
Markdown
# 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:19–13: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** 의 조합이다 |
|