Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a8-rolling-restart.md
T
DongHyeonkaandClaude Opus 5 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

726 lines
40 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.
---
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 -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[lab host]` 에서 `kubectl` 로 친다. 노드 자체를 건드리는 명령이 없어서 게스트에 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl``permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
터미널은 둘을 연다. 하나는 가용성 감시용이라 루프가 도는 동안 붙잡혀 있고, 하나는 재시작과 관찰용이다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `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` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
운영에서 가장 자주 겪는 작업이다. 장애가 아니라 정상 배포인데도 사용자가 로그아웃되면 그건 사고다.
```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="[lab host] 직전 리비전으로 되돌린다"
kubectl -n keycloak-lab rollout undo statefulset/keycloak
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
```
### 1. 파드와 나이와 replica 수를 적어 둔다
**목적** — 재시작 전의 `AGE` 를 확보하고 replica 가 2 인지 확인한다.
```bash label="[lab host] 파드를 노드와 함께 넓게 본다"
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="[lab host] 두 파드 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="[lab host] 컨테이너 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="[lab host] 온라인 세션과 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="[lab host] ① 탐침을 띄우고 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="[lab host] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[lab host] ③ 탐침 안에 값이 들어갔는지 본다"
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="[lab host] ④ 탐침을 지우고 ① 로 돌아간다"
kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
```
### 5. 토큰과 sid 를 파드 안에 담고 길이를 확인한다
**목적** — 재시작을 넘겨 쓸 값을 파일에 남기고, 그 파일이 비어 있지 않은지 본다.
이 단계에 이 실험의 함정이 있다.
```bash label="[lab host] ① 로그인해서 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="[lab host] ② 파일 크기와 앞 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="[lab host] ① 같은 노드에서 갱신해 본다"
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="[lab host] ② 다시 로그인해서 두 파일을 새로 만든다"
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="[lab host] ① sid 를 화면에서 읽는다"
kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid
```
```bash label="[lab host] ② 읽은 값을 그대로 넣어 행을 찾는다"
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="[lab host] ① 한 줄짜리 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="[lab host] ② 파드와 값만 세로로 늘어놓는다"
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 \
--resolve auth.hyeonworks.com:443:192.168.122.10 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="[lab host] ② 시각을 남기고 롤링 재시작을 건다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
```
**엔드포인트는 여기서 봐야 보인다.** 파드가 서비스에서 빠졌다 돌아오는 것은 롤아웃이 도는 동안에만 나타나고, 끝난 뒤에 치면 ready 주소가 늘 둘로 나온다. 두 번째 터미널은 ① 의 루프에 붙잡혀 있으므로 이 터미널에서 몇 번 반복해서 친다. 찍힌 것을 어떻게 읽는지는 15번에서 적는다.
```bash label="[lab host] ③ 롤아웃이 도는 동안 엔드포인트를 몇 번 본다"
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="[lab host] ④ 롤아웃이 끝날 때까지 기다린다"
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="[lab host] 파드 나이와 재시작 카운터를 본다"
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="[lab host] 새 파드 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 \
--resolve auth.hyeonworks.com:443:192.168.122.10 https://auth.hyeonworks.com/realms/master)"
sleep 1
done
echo
```
**문제가 생기면** — 루프가 전부 `000` 이면 잘못된 URL 을 치고 있다. `curl -v` 로 한 번 본다.
## 관찰
### 12. 본 시험 — 재시작 전 토큰이 아직 통하는가
**목적** — 파드 안에 보관해 둔 토큰을 새 파드 IP 로 보낸다.
셸 인용이 세 겹이 되는 형태이고, 가이드는 여기에 다른 형태를 제시하지 않는다. 탐침을 다시 띄우면 토큰이 사라지기 때문이다.
```bash label="[lab host] 재시작 전 토큰으로 갱신을 시도한다"
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 잡기를 다시 한다.
**2026-09-17 실측**(observed) — 롤링 재시작 전후로 같은 토큰을 썼다.
```text
재시작 전 대조군 200
재시작 (파드 둘 교체) AGE 31s · 63s · IP 10.42.1.47→.49, 10.42.0.20→.21
재시작 뒤 같은 refresh 200 · session_state 가 같은 sid
DB 행 created_on 1789625835 그대로 · last_session_refresh 1789625835 → 1789625952
캐시 재시작 전후 모두 keycloak-0/1 sessions 0
클러스터 다시 2
```
**`created_on` 은 그대로이고 `last_session_refresh` 만 117초 뒤로 갔다.** 새로 만든 세션이 아니라 **남아 있던 행을 새 파드가 읽어서 갱신한 것**이고, 그것이 이 편의 결론이다.
**탐침 안의 `$K0` 는 재시작 뒤에 낡는다.** 2026-09-17 에 낡은 값으로 쳐서 `000` 을 받았다(observed) — 문서가 미리 적어 둔 그 함정이다. 새 IP 를 명령줄로 넘겨 다시 쳤다.
```text
새 K0=10.42.1.49 (탐침 안의 낡은 값=10.42.1.47)
```
### 13. DB 행의 두 시각을 견준다
**목적** — 응답 코드만이 아니라 쓰기까지 정상인지 본다.
적어 둔 `sid` 를 넣어 7번의 ② 와 같은 질의를 다시 친다.
```bash label="[lab host] ① 같은 행을 다시 찾는다"
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="[lab host] ② 두 유닉스 시각을 사람이 읽는 형태로 바꾼다"
date -d @1788495513 ; date -d @1788495577
```
**왜 필요한가** — `200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다. A-1 에서 실제로 그런 일이 있었다. 여기서는 새 파드가 DB 에서 세션을 읽었고 갱신 시각을 DB 에 되썼으므로 그 가능성이 없다.
**문제가 생기면** — `last_session_refresh` 가 안 올랐으면 본 시험을 하기 전에 조회했다. 순서는 refresh 를 먼저 하고 조회한다.
전체 세션 수도 함께 본다.
```bash label="[lab host] ③ 온라인 세션 전체를 센다"
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="[lab host] 캐시 엔트리 수와 클러스터 크기를 이어서 본다"
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="[lab host] 탐침 파드를 지운다"
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 -->