# 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** 의 조합이다 |