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>
This commit is contained in:
DongHyeonka
2026-09-17 19:38:12 +09:00
co-authored by Claude Opus 5
parent 4a457afde9
commit 32e39e20aa
150 changed files with 6066 additions and 269 deletions
@@ -79,6 +79,8 @@ error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/
| 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 |
| 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 |
**공개 이름은 랩 안에서 안 풀린다.** `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` 가 필요 없다.
## 이 실험이 가르는 것
코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다.
@@ -390,7 +392,14 @@ docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s c
두 노드에 들어갔는지 확인한다.
```bash label="[kc-lab-1] ④ 두 노드의 이미지 목록을 본다"
```bash label="[lab host] ④ 두 노드의 이미지 목록을 본다"
ssh kc-lab-1 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
```
원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed). 두 줄 다 lab host 에서 보내면 둘 다 나온다.
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
sudo k3s ctr images ls | grep keycloak-pattern-bff
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
```
@@ -488,7 +497,7 @@ BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstrai
### 외부 진입점이 이 애플리케이션의 HTML 을 주는가
```bash label="[lab host] 상태 줄과 헤더를 읽는 형태로 친다"
curl -I https://app1.hyeonworks.com/
curl -I --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/
```
실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다.
@@ -508,6 +517,18 @@ content-type: text/html
`/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절).
**★ 지금은 밖에서도 받아진다**(2026-09-17, observed). TLS 를 세우고 같은 주소를 쳤더니 `200` 에 `155395` 바이트가 그대로 왔다. 위 `Bad Gateway` 는 엣지 nginx 의 버퍼나 프록시 체인이 지금과 다른 상태에서 잰 값으로 보인다(inferred).
```bash label="[dev] 밖에서 받아 크기를 센다"
curl -s -o /tmp/beans-out.json -w 'code=%{http_code} size=%{size_download}\n' https://app1.hyeonworks.com/actuator/beans
```
```text label="그 출력"
code=200 size=155395
```
**그래도 아래 「파드 안에서 받는다」를 그대로 쓴다.** 밖에서 받는 것이 이 편의 목적이 아니고, 프록시 설정이 바뀌면 다시 막힐 수 있다. 다만 `Bad Gateway` 를 「이 주소는 원래 밖에서 안 된다」로 읽지는 않는다.
```text
$ curl https://app1.hyeonworks.com/actuator/beans
Bad Gateway
@@ -808,8 +829,10 @@ authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-pa
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 브라우저 로그인(3절 뒤 전부)과 그 로그인이 만드는 `/actuator/beans` 재측정. 지금까지 밟은 것 — 이미지 빌드·반입, realm·클라이언트·사용자 생성, 매니페스트 적용, 빈 437개 확인.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **이 편의 주입은 2026-09-17 에도 밟지 않았다.** 막은 것이 인증서가 아니라 **소스**다 — 주입 1 절이 `keycloak-pattern` 의 파일 넷을 고치라고 하는데, 그 저장소에 이 작업과 무관한 커밋 안 된 변경이 이미 여러 개 있어 손대지 않았다. 이미지를 다시 만들지 않으면 3~5 절의 판정이 성립하지 않는다.
- **그래서 지금 실험대의 BFF 는 B-0 이 아니라 B-1·B-2 상태다**(observed). 빈 목록에 `RedisSessionRepository` · `RedisHttpSessionConfiguration` · `JdbcOAuth2AuthorizedClientService` 가 다 있다. 이 상태에서 replica 2 로 로그인하면 **성공한다** — B-0 은 같은 조건에서 실패한다고 적는다. 두 편을 가르는 것이 세션 저장소 하나임을 반대편에서 확인한 셈이다.
- **밟은 것** — 관찰 1·2(파드 안에서 빈 목록 받기, 저장소 계열 빈 뽑기)와 5 절의 토큰 경계. `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `refreshTokenStoredOnServer=true` · `browserTokenCount=0` 을 내는 것은 이 상태에서도 같다(observed).
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
@@ -579,6 +579,39 @@ echo "$KEY"
바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown).
**★ `head -1` 이 엉뚱한 세션을 집는다**(2026-09-17, observed). 원래 실행은 키가 하나였지만 둘 이상일 때가 있다. 2026-09-17 에 로그인 한 번 뒤 키가 둘이었고, `head -1` 이 고른 쪽에는 **`SPRING_SECURITY_CONTEXT` 가 없었다.**
```text label="키 둘이 담고 있는 것"
bff:session:sessions:b66634a2-… sessionAttr:SPRING_SECURITY_SAVED_REQUEST · maxInactiveInterval · creationTime · lastAccessedTime
bff:session:sessions:c5f7b0c7-… sessionAttr:SPRING_SECURITY_CONTEXT · …AUTHORIZATION_REQUEST · SPRING_SECURITY_LAST_EXCEPTION · lastAccessedTime · maxInactiveInterval · creationTime
```
앞엣것은 로그인 화면으로 보내기 전에 만들어진 세션이고 인증 정보가 없다. 그것을 잡으면 아래 ③ 의 필드 목록에 `SPRING_SECURITY_CONTEXT` 가 안 나오고, 관찰 3 의 `\xac\xed` 도 못 본다 — **이 편이 보여 주려는 것이 통째로 안 보인다.** 오류는 안 나므로 「토큰이 없네」가 아니라 「인증 세션이 아니네」를 먼저 의심해야 하는데, 화면만 봐서는 갈리지 않는다.
키를 내용으로 고르면 몇 개가 있든 맞는 것을 잡는다.
```bash label="[lab host] ②-a 인증된 세션을 내용으로 고른다"
KEY=
for K in $(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | tr -d '\r'); do
HAS=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli hexists "$K" 'sessionAttr:SPRING_SECURITY_CONTEXT' | tr -d '\r')
echo "$HAS $K"
if [ "$HAS" = 1 ]; then KEY=$K; fi
done
echo "고른 키: $KEY"
```
**첫 줄의 `KEY=` 를 뺀 채로 치면 이 절이 통째 무의미해진다.** ② 가 이미 `$KEY` 를 채워 뇌기 때문에, 루프가 한 건도 못 맞혀도 마지막 `echo` 는 **② 가 잘못 고른 그 키를 그대로 찍는다.** 화면은 정상으로 보이는데 고쳐진 것이 없다. 비우고 시작해야 「못 찾았다」 가 빈 줄로 보인다.
키마다 `1` 과 `0` 을 앞에 찍는 것도 같은 까닭이다. 걸러 내는 일을 `grep -q` 에 맡기면 어느 키가 왜 떨어졌는지가 화면에 안 남는다.
```text label="2026-09-17 의 출력 (observed)"
1 bff:session:sessions:0daf4915-a8a6-4b8e-bc97-5e58627eae23
1 bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688
고른 키: bff:session:sessions:1e719069-aae5-47ee-a40b-0c70a0aae688
```
**둘 다 `1` 이면 루프는 마지막 것을 잡는다.** 어느 쪽을 잡아도 `SPRING_SECURITY_CONTEXT` 는 있으니 이 날은 상관이 없었지만, 「맞는 키가 하나뿐」 을 전제하고 읽으면 안 된다.
```bash label="[lab host] ③ 타입과 필드 이름을 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
@@ -597,6 +630,8 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
필드: creationTime
```
**2026-09-17 에 다시 치니 필드가 여섯이었다**(observed) — `sessionAttr:SPRING_SECURITY_SAVED_REQUEST` 하나가 없었고 나머지 여섯은 같았다. 로그인 전에 뭐를 요청했는지에 따라 붙었다 말았다 하는 칸이라, 이 절이 보려는 것—토큰이 없다—은 그대로다.
**이 값이 뜻하는 것** — 필드 목록에 토큰이 없다. 저장소를 직접 열어 refresh token 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
### 3. TTL 과 값의 바이트를 본다
@@ -621,6 +656,15 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" |
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken...
```
**★ 필드 순서를 기대하면 안 된다**(2026-09-17, observed). 위 `head -4` 는 `SPRING_SECURITY_CONTEXT` 가 첫 필드로 나온다고 보고 자른 것인데, Redis 해시의 필드 순서는 보장되지 않는다. 같은 명령을 다시 쳤을 때는 이렇게 나왔다.
```text label="2026-09-17 의 앞 두 줄"
1) "lastAccessedTime"
2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number…"
```
**그래도 요점은 더 세게 확인된다.** `\xac\xed` 가 `SPRING_SECURITY_CONTEXT` 에만 붙는 것이 아니라 `lastAccessedTime` 의 `Long` 하나에도 붙는다. **이 해시의 값은 전부 Java 직렬화다.** 특정 필드를 보려면 `head` 로 자르지 말고 `hget "$KEY" 'sessionAttr:SPRING_SECURITY_CONTEXT'` 로 이름을 대서 꺼낸다.
`\xac\xed` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.
| 결과 | |
@@ -786,8 +830,8 @@ kubectl -n header-lab get svc echo
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 브라우저 로그인 뒤의 Redis 키·필드·TTL 관찰. 지금까지 밟은 것 — 이미지 빌드·반입, 배포, `/actuator/beans` `RedisSessionConfiguration` 확인.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 로그인해서 Redis 키·필드·TTL 까지 봤다. 로그인은 브라우저 대신 쿠키 항아리를 쓴 `curl` 로 인가 코드 흐름을 돌렸고, 왕복 두 번과 쿠키가 그대로 재현된다. replica 2 에서 로그인이 됐고(B-0 은 여기서 실패한다) `bff/token-boundary` 가 `accessTokenStoredOnServer=true` · `browserTokenCount=0` 을 냈다. 브라우저 쪽 쿠키는 `AP3_SESSION` 48자 하나뿐이다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
@@ -348,7 +348,30 @@ kubectl -n keycloak-lab exec deploy/postgres -- \
-c 'select offline_flag, count(*) from offline_user_session group by 1'
```
온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 원 가이드가 미검증으로 표시했다(unknown). 관리 API 로 보려면 이쪽이다.
온라인 세션도 `offline_user_session` 에 `offline_flag = 0` 으로 들어 있다. B-3 에서 확인된 성질이다. 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다. ③ 은 같은 숫자를 DB 쪽에서 보는 형태이고 오래 미검증이었는데, **2026-09-17 에 ②③④ 를 나란히 쳤다**(observed).
```text label="② 의 출력 — 브라우저 로그인 전이라 0 이다"
count
-------
0
```
```text label="③ 의 출력 — 온라인 세션이 offline_flag 0 으로 들어 있다"
offline_flag | count
--------------+-------
0 | 5
```
```text label="④ 의 출력"
[ {
"offline" : "0",
"clientId" : "bff-confidential",
"active" : "4",
"id" : "7ae3362a-2bed-4902-b6af-d7307ea4ad0b"
} ]
```
**③ 과 ④ 는 같은 숫자가 아니다.** DB 는 `5` 인데 관리 API 는 `4` 라고 답했다. 둘이 세는 단위가 다르기 때문이다 — `offline_user_session` 은 **사용자 세션**의 행이고, `client-session-stats` 는 **클라이언트 세션**을 클라이언트마다 센다. 사용자 세션 하나에 클라이언트 세션이 0개일 수도 여럿일 수도 있으므로 두 수는 맞을 이유가 없다. 「③ 은 같은 숫자를 DB 쪽에서 보는 형태」라는 위 문장은 그래서 정확하지 않다. **대조군으로 쓸 때는 둘 중 하나를 골라 끝까지 그것만 쓴다.** 관리 API 로 보려면 이쪽이다.
```bash label="[lab host] ④ 관리 API 로 세는 형태"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
@@ -460,7 +483,20 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:ses
date '+%H:%M:%S 세션 삭제'
```
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown).
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. 그것이 문제가 되는지를 오래 미검증으로 두었는데, **2026-09-17 에 가렸다 — 문제가 안 된다**(observed).
시험용 키 둘을 넣고 위 블록을 한 글자도 안 바꾸고 쳤더니 `del` 이 `5` 를 돌려주고 스캔이 빈손이 됐다. 키가 실제로 지워졌다. 출력에 CR 이 붙는지를 `od -c` 로 직접 봤더니 줄 끝이 `\n` 하나다.
```bash label="[lab host] 스캔 출력의 줄 끝을 바이트로 본다"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' | od -c | head -3
```
```text label="그 출력 — 줄바꿈 하나로 끝난다"
0000000 b f f : s e s s i o n : p r o b
0000020 e 3 \n
```
**갈리는 것은 `redis-cli` 가 아니라 `kubectl exec` 에 tty 가 붙었는지다.** 위 블록은 `-it` 없이 파이프로 받으므로 tty 가 없고 CR 도 없다. B-1 은 tty 가 붙는 형태로 쳤고 그쪽에서는 `tr -d '\r'` 이 필요하다. **그러니 이 줄은 그대로 두는 것이 맞다.**
**예상 결과** — 모양은 이렇다(observed).
@@ -784,8 +820,30 @@ JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 브라우저 로그인 뒤의 `oauth2_authorized_client` 행 관찰과 `accessTokenStoredOnServer` 판정. 지금까지 밟은 것 — 표가 생겨 있고 행이 `0` 인 것까지.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **끝까지 밟았다**(2026-09-17). TLS 를 세우고 로그인 7 절과 관찰 1·2 를 전부 밟았다. **문서의 값과 거의 그대로 나온다.**
```text label="7 절 ② 의 실측"
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1437 | 744
```
`rt_len` 이 `744` 로 문서와 같고 `at_len` 만 `1431` 대신 `1437` 이다 — access token 은 클레임에 따라 길이가 조금씩 달라진다.
- **오래 미검증이던 두 줄도 값을 냈다**(observed). 관찰 2 의 `left(convert_from(...), 40)` 형태가 그대로 돌고, 나온 문자열이 문서에 실린 36자와 **한 글자도 다르지 않다.**
```text label="관찰 2 ② 의 실측 (앞 36자에서 끊는다)"
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
```
- **덮어쓰기도 재현됐다**(observed). 같은 사용자로 새 브라우저에서 한 번 더 로그인했더니 **행 수는 `1` 그대로인데 `at_md5` 가 바뀌었다.** 앞 로그인의 토큰은 그 순간 사라졌다.
```text label="덮어쓰기 전후"
전 행 1 · at_md5 722769ac041a42164d08060e5446d02b
뒤 행 1 · at_md5 5c38b11f27f490ea1d9ee07e73aef2b2 · 발급 2026-09-17 08:05:52
```
- **남은 것** — 관찰 3(로그아웃하면 세 저장소가 다 정리되는가). 브라우저 없이 `POST /logout` 을 치려면 CSRF 토큰이 필요한데 그 구간은 밟지 않았다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
@@ -150,6 +150,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
**이 값이 뜻하는 것** — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, 그 차이가 둘째 주입의 출발 조건이다.
**★ 2026-09-17 에 쳤더니 `save` 도 비어 있지 않았다**(observed). 이 이미지의 기본 스냅샷 조건 셋이 그대로 들어 있다. 그래서 둘째 주입의 출발 조건은 「AOF 만 켠 상태」가 아니라 「AOF 와 스냅샷이 둘 다 켜진 상태」이고, 그래도 결과는 같다 — 볼륨이 없으면 둘 다 컨테이너와 함께 사라진다.
```text
save
3600 1 300 100 60 10000
appendonly
yes
```
같은 날 `redis-cli --scan` 은 아무것도 안 찍었다. 브라우저 로그인을 안 한 실험대라 세션 키가 0개였고, 그 상태에서는 「잃는 것」이 안 보인다. 로그인을 먼저 해 두라는 전제는 이 때문이다.
`save` 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 `save = save` 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.
### 3. `/data` 가 볼륨인가
@@ -201,7 +212,8 @@ Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에
```bash label="[lab host] 세 경로의 상태 코드를 뽑는다"
for p in / /bff/token-boundary /actuator/health; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done
```
@@ -229,9 +241,12 @@ done
**무엇을 보는가** — 세 응답의 본문. `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다).
```bash label="[lab host] ① health 그룹 셋의 본문을 받는다"
curl -s https://app1.hyeonworks.com/actuator/health; echo
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
curl -s https://app1.hyeonworks.com/actuator/health/liveness; echo
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/actuator/health; echo
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/actuator/health/readiness; echo
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/actuator/health/liveness; echo
```
**어디를 보나** — 첫 번째 응답의 본문에 `redis` 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.
@@ -392,6 +407,15 @@ kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|nett
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
```
**★ 이 실험대의 로그는 다른 줄을 냈다**(2026-09-17, observed). 스택이 아니라 Lettuce 의 재연결 로그다. Service 는 남아 있고 뒤에 파드가 없으므로 `Connection refused` 이고, 위의 「맺는 중」과 달리 여기서는 즉시 거절당한 뒤 다시 시도한다. 그래도 요청 쪽은 똑같이 매달린다.
```text
i.l.core.protocol.ConnectionWatchdog : Reconnecting, last destination was redis.keycloak-lab.svc/10.43.44.209:6379
i.l.core.protocol.ConnectionWatchdog : Cannot reconnect to [redis.keycloak-lab.svc/<unresolved>:6379]: Connection refused: redis.keycloak-lab.svc/10.43.44.209:6379
```
그리고 `grep -iE 'redis|connect|netty'` 는 `connect` 때문에 Hikari 의 PostgreSQL 커넥션 경고까지 잡는다. `tail -10` 의 앞쪽 여섯 줄이 그것이었고 Redis 와 무관하다.
`pollConnect` 와 `finishConnect` 는 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 `000` 이 여기서 나온다.
```bash label="[lab host] ③ 엉뚱한 것을 죽이지 않았는지 본다"
@@ -415,6 +439,14 @@ kubectl -n keycloak-lab get pod -l app=redis \
**빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다.
**★ 빈 줄은 안 나온다**(2026-09-17, observed). 쿠버네티스가 서비스 계정 토큰을 `kube-api-access-…` 라는 projected 볼륨으로 자동으로 붙이므로, `volumes` 배열을 통째로 지워도 새 파드에는 그 항목 하나가 다시 들어 있다. 길이가 아니라 이름을 본다 — `persistentVolumeClaim` 이 안 보이면 떨어졌다.
```text
[{"name":"kube-api-access-d7bbm","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}]
```
`volumeMounts` 쪽으로 보면 더 짧다. `/data` 가 없고 `/var/run/secrets/kubernetes.io/serviceaccount` 하나만 남는다. 그리고 이 명령이 고르는 `.items[0]` 은 방금 지운 파드일 수 있다 — 같은 라벨에 `Completed` 파드가 남아 있는 것을 이 실험대에서 봤다.
**빈 줄에는 뜻이 둘이다.** 이 명령은 `app=redis` 라벨이 붙은 파드 중 첫째를 골라 그 파드의 `spec.volumes` 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 `get pods -l app=redis` 로 Redis 파드가 하나 `Running` 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다.
```text
@@ -434,7 +466,8 @@ kubectl -n keycloak-lab get pod -l app=redis \
```bash label="[lab host] 세 경로를 다시 친다"
for p in / /bff/token-boundary /actuator/health; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done
```
@@ -453,6 +486,19 @@ done
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
| `503` | 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고 |
**★ 위 블록의 `--max-time 90` 은 원 가이드의 `--max-time 10` 을 고친 것이다**(2026-09-17, observed). 10초에서 끊으면 `/actuator/health` 도 `000` 이 되어 세 줄이 `200`·`000`·`000` 으로 나오고, 바로 위의 실측과 안 맞는다. 이 실험대에서 그 줄은 `60.046452` 초 뒤에 `503` 을 줬고 `/bff/token-boundary` 는 90초까지 기다려도 안 왔다. 그래서 90초까지 기다리게 하고 `%{time_total}` 로 걸린 시간을 같이 찍는다.
```text
health 503 total 60.046452
token-boundary 000 total 90.001609
```
그러니까 헬스 엔드포인트도 빨리 실패하지 않는다. 60초는 Redis 명령 타임아웃이고, 그때 돌아온 본문의 `redis` 항목은 「붙지 못했다」가 아니라 「명령이 시간을 넘겼다」였다.
```text
"redis":{"status":"DOWN","details":{"error":"org.springframework.dao.QueryTimeoutException: Redis command timed out"}}
```
오류를 돌려주는 것이 아니라 매달려 있다.
```text
@@ -473,10 +519,14 @@ done
**그런데 파드는 `Ready` 를 유지한다.** 이 절차의 가장 중요한 발견이다.
```bash label="[lab host] health 그룹 셋을 코드와 본문으로 본다"
curl -s -o /dev/null -w 'health %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/readiness
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/liveness
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
curl -s -o /dev/null -w 'health %{http_code} total %{time_total}\n' --max-time 90 \
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 \
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/readiness
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 \
--resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/actuator/health/liveness
curl -s --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/actuator/health/readiness; echo
```
실측은 이렇다(observed, `03-health-groups.txt`).
@@ -565,7 +615,8 @@ deployment "redis" successfully rolled out
```bash label="[lab host] ② 회복했는지와 재시작 횟수를 본다"
for p in /actuator/health /bff/token-boundary; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
curl -s -o /dev/null -w "$p %{http_code} total %{time_total}\n" --max-time 90 \
--resolve app1.hyeonworks.com:443:192.168.122.10 "https://app1.hyeonworks.com$p"
done
kubectl -n keycloak-lab get pods -l app=bff
```
@@ -602,6 +653,17 @@ deployment "redis" successfully rolled out
appendonly no
```
**★ 설정이 되돌아가는 것은 이 실험대에서 안 보인다**(2026-09-17, observed). 데이터는 그대로 사라져 `dbsize` 가 `0` 이고 `b5:aof` 는 빈 값인데, `config get appendonly` 는 `no` 가 아니라 `yes` 다. 지금 매니페스트의 `args` 가 이미 `--appendonly yes` 라서, 「재기동하면 매니페스트가 이긴다」는 규칙이 이번에는 켜진 값을 되살린다. 규칙은 그대로 맞다. 매니페스트가 이미 `yes` 라서 되돌아가도 값이 안 바뀔 뿐이다.
```text
0
appendonly
yes
```
볼륨을 되돌리고 같은 시험을 다시 했을 때는 `b5:pvc` 가 `written-on-pvc` 로 살아남았고 `dbsize` 는 `4` 였다. 나머지 셋은 그 사이에 BFF 가 만든 `bff:session:sessions:…` 키다.
두 가지가 같이 사라졌다(observed, 같은 파일).
| 사라진 것 | 왜 |
@@ -738,7 +800,17 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
| BFF | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` |
| 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** |
| 실험 키 | `… redis-cli --scan` | `b5:*` 없음 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 --resolve app1.hyeonworks.com:443:192.168.122.10 https://app1.hyeonworks.com/` | `200` |
**★ 이 편의 `curl` 에 `--resolve` 가 붙어 있는 까닭이다**(2026-09-17, observed). `app1.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어, 이름만 치면 이 줄도 앞의 세 경로도 전부 `000` 이다. 그러면 주입 뒤와 견줄 값이 없다. 원 가이드가 적은 형태는 아래와 같고, 지우지 않고 남긴다.
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 세 줄이 다 000 이다"
for p in / /bff/token-boundary /actuator/health; do
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
done
```
나머지 일곱 줄은 이 실험대에서 그대로 통과했다 — Redis `1/1`, 볼륨에 `persistentVolumeClaim`, PVC `redis-data` `Bound`, `appendonly yes`, BFF 둘 다 `1/1` 이고 `RESTARTS 0`, 엔드포인트 주소 둘, `--scan` 에 `b5:*` 없음.
**로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다.
@@ -771,4 +843,6 @@ kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
- (unknown) 볼륨을 떼는 `patch deployment redis --type=json` 줄. **원래 실행은 반대 순서로 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다.
- 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.
2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정은 전부 같았다 — Redis 를 0대로 내려도 `bff` 둘이 `1/1` 이고 엔드포인트 주소 둘이 그대로 `true,true` 였고, 볼륨 없이 파드를 지우면 `dbsize 0`, 볼륨을 되돌리면 `written-on-pvc` 가 살아남았고, 네 지표는 여전히 시계열 0개였다. 화면이 달랐던 다섯 자리는 위의 ★ 다섯이다. 원문은 `relive-2026-09-17/b5-04..07` 에 있다. 브라우저 로그인 전제는 이날 밟지 않아 세션 키가 0개인 채로 쟀다(unknown) — 「로그인한 사용자가 로그아웃된다」는 이 실행에서 확인하지 않았다.
<!-- body:end -->
@@ -53,6 +53,8 @@ realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공
| 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `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` 가 필요 없다.
## 이 실험이 가르는 것
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.
@@ -172,9 +174,23 @@ No server specified. Use --server, or 'kcadm.sh config credentials'.
**무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
```bash label="[lab host] ① JWKS 를 자르지 않고 본다"
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
**`--resolve` 가 붙은 까닭이다**(2026-09-17, observed). 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. 엣지 nginx 가 게스트로 옮겨 간 뒤로 그렇다.
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
```text label="원 가이드 형태를 랩 호스트에서 쳤을 때"
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server
```
tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿는다. 랩 호스트에서 쳐야 해서 `--resolve` 를 붙였고, 이 편의 `[lab host]` 블록 열두 줄에 전부 같은 것이 걸린다.
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`).
```text
@@ -184,7 +200,8 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
```bash label="[lab host] ② kid 만 뽑아 본다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
@@ -210,21 +227,35 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다.
```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다 (unknown)"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다"
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
```bash label="[lab host] ② Keycloak 에 직접 묻는다 (unknown)"
```bash label="[lab host] ② Keycloak 에 직접 묻는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get keys -r keycloak-patterns
```
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.
**2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다**(observed). 원래 실행 기록에 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다.
```text label="① 의 출력"
1
```
```text label="② 의 출력에서 kid·status·algorithm 만 뽑은 것"
"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917" "status" : "ACTIVE" "algorithm" : "AES"
"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c" "status" : "ACTIVE" "algorithm" : "HS512"
"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" "status" : "ACTIVE" "algorithm" : "RS256"
"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" "status" : "ACTIVE" "algorithm" : "RSA-OAEP"
```
① 이 내는 `1` 과 3단계의 `kid` 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 `RSA-OAEP` 가 하나이며 JWKS 에는 그 둘만 실린다. `AES` 와 `HS512` 는 JWKS 에 안 나온다.
### 5. 시험체가 될 옛 토큰을 하나 받아 둔다
@@ -236,7 +267,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST "$KC" \
OLD=$(curl -s -X POST --resolve auth.hyeonworks.com:443:192.168.122.10 "$KC" \
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
-d username=labuser -d password=labpass -d scope=openid \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
@@ -295,14 +326,16 @@ echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
**무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
```bash label="[lab host] ① 상태줄과 본문을 함께 본다"
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -i -H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
```
200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
```bash label="[lab host] ② 상태 코드만 뽑는다"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
```
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
@@ -359,7 +392,8 @@ Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
### 9. JWKS 에 옛 키가 남아 있는가
```bash label="[lab host] 3 절과 똑같은 줄을 다시 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
@@ -401,9 +435,11 @@ echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```bash label="[lab host] 두 토큰을 같은 두 줄로 친다"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
```
실측은 이렇다(observed, `02-rotation.txt`).
@@ -480,7 +516,8 @@ date '+%H:%M:%S 제거'
### 14. JWKS 에서 사라졌는지 본다
```bash label="[lab host] 또 같은 줄을 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
@@ -501,9 +538,11 @@ curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-con
```bash label="[lab host] 11 절과 똑같은 두 줄"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
-H "Authorization: Bearer $OLD" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
@@ -516,6 +555,27 @@ curl -s -o /dev/null -w 'new %{http_code}\n' \
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
**★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다**(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다.
```text label="제거 직후, 같은 토큰으로 여덟 번"
401 200 401 200 401 200 401 200
```
**`echo` 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다.** 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 `1/1 Running` 인 것은 같은 순간에 확인했다.
한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다.
```text label="같은 상태를 한 번씩 쳤을 때"
제거 6초 뒤 처음 친 것 old 200 ← 캐시가 살아 있는 replica
제거 6초 뒤 다시 친 것 old 401 ← 목록을 새로 받은 replica
```
**그래서 이 절의 판정은 「즉시 401」이 아니라 「인스턴스마다 다르다」다.** 운영에서 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「유예가 없다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred).
**replica 를 1 로 줄이면 이 흔들림이 사라진다.** 무엇을 재려는지에 따라 고른다 — 「제거가 반영되는가」를 보려면 1 로, 「운영에서 무엇이 보이는가」를 보려면 2 로 둔다.
**아래 16 절이 이것을 가르는 단계인데, 재시작이 34초 걸려 60초짜리 토큰으로는 전후를 같은 토큰으로 못 견준다**(observed). 재시작 뒤의 `old 401` 은 캐시가 비워져서인지 토큰이 만료돼서인지 갈리지 않는다. 가르려면 `accessTokenLifespan` 을 늘리거나, 위처럼 **재시작 없이 연속으로 쳐서** 두 replica 의 답이 갈리는 것을 보는 편이 빠르다.
**`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).
### 16. 리소스 서버를 재시작해 캐시를 비운다
@@ -566,12 +626,12 @@ deployment "echo" successfully rolled out
수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```bash label="[lab host] realm 의 수명 세 값을 받는다 (unknown)"
```bash label="[lab host] realm 의 수명 세 값을 받는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
```
겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
**2026-09-17 에 쳤더니 두 값만 돌아왔다**(observed) — `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이고 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. 겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
```text
쓰기: 새 key 하나로만
@@ -623,9 +683,28 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 키 공급자 추가·삭제와 `echo` 가 내는 `401`/`200` 판정 전부. `echo` 가 JWKS 를 `https://auth.hyeonworks.com` 에서 받으므로 인증서가 서야 한다. 지금까지 밟은 것 — realm·사용자·`echo` 배포, JWKS 와 `kid` 읽기, 공급자 목록.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
- **밟았다** — 키 공급자 추가와 삭제, 그리고 그 둘이 JWKS 와 토큰 `kid` 를 어떻게 바꾸는지 전부. 8·9·10·12·13·14 절이 적어 둔 대로 나왔다(observed). 공급자를 추가하니 RS256 이 1 에서 2 로 늘고 새 토큰의 `kid` 가 `priority` 200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 `kid` 가 목록에서 사라졌다.
- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 `echo` 가 JWKS 를 받게 되어 7 절의 대조군 `old 200` 이 나왔고, 8~15 절을 토큰 수명 안(6초)에 끝냈다. 결과는 **11 절까지 문서 그대로**(추가는 무중단, `old 200` · `new 200`)이고 **15 절에서 갈렸다** — 위 ★ 를 본다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 2026-09-17 에 들어갔고 `http` 는 밖에서 `200` 이다(observed).
**11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다**(2026-09-17, observed). `exp` 는 Keycloak 이 게스트 시계로 찍고 `date +%s` 는 랩 호스트 시계로 찍는데, **그 둘이 93초 어긋나 있다.**
```bash label="[lab host] 기계마다 같은 순간에 친다"
date +%s
timedatectl show -p NTP -p NTPSynchronized
```
```text label="같은 순간의 값"
lab host 1789629566 NTP=no NTPSynchronized=no
kc-lab-1 1789629472 NTP=yes NTPSynchronized=yes
kc-lab-2 1789629473 NTP=yes NTPSynchronized=yes
kc-lab-edge 1789629473
```
게스트 셋은 서로 맞고 랩 호스트만 93초 앞선다. 그래서 방금 받은 60초짜리 토큰도 랩 호스트에서 `exp` 를 재면 **이미 33초 전에 만료된 것으로 읽힌다.** D-4 와 D-4a 가 같은 호스트에서 `NTPSynchronized=no` 와 `+106.1` 을 이미 재 두었는데, 이 편은 그것을 모르는 채로 `exp` 비교를 시킨다. 가르려면 두 값을 같은 기계에서 뽑는다 — `kubectl -n keycloak-lab exec keycloak-0 -- date +%s` 로 Keycloak 쪽 시각을 받아 견준다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **realm 의 수명 세 값은 이제 실측이 있다**(observed). 위에서 미검증으로 표시한 줄을 쳐 보니 `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이다. 세 번째 값 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다.
- **그때까지 이 편의 `(observed)` 2026-09-17 값은 위 「밟았다」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가