feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+761
@@ -0,0 +1,761 @@
|
||||
---
|
||||
id: 4ef91d43-1f1d-481a-8b6f-5e015d068578
|
||||
kind: SETUP
|
||||
slug: reproduce-b0-default-session-store
|
||||
title: 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/4ef91d43-1f1d-481a-8b6f-5e015d068578/edit"
|
||||
pinnedVersions:
|
||||
- name: keycloak-pattern-bff
|
||||
version: lab
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-0
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다
|
||||
|
||||
저장소를 하나도 붙이지 않은 BFF 를 두 노드에 배포하고 `/actuator/beans` 로 Spring 이 무엇을 골랐는지 찍어 보는 절차다. B층 뒤의 여덟 편이 이 배포 위에 서므로 Redis 는 띄우기만 하고 연결하지 않는다. 전 구간 약 40~60분이고 빌드 시간이 들어 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
|
||||
이 절차가 뽑아 둔 빈 세 개의 이름이 그 기록에서 after 와 견주는 값이 된다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
|
||||
- **세션과 인가된 클라이언트는 조회 키가 다르다**
|
||||
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 라는 이름 하나가 왜 이 층 전체의 문제인지를 그 기록이 설명한다.
|
||||
- **저장소를 옮기기 전에 조회 키를 본다**
|
||||
여기서 빈 이름을 먼저 찍는 순서를 규칙으로 굳힌 기록이다.
|
||||
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
|
||||
바로 다음 편이다. 이 절차가 만든 배포에 Redis 를 연결하고 같은 명령을 다시 친다.
|
||||
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
|
||||
여기서 이름으로 짐작한 조회 키가 그 편에서 테이블 정의로 확정된다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령이 두 기계에 나뉜다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `kc-lab-1` 에서 `kubectl` 과 `kcadm` 으로 한다. 그래서 코드블록마다 어디서 치는지를 붙여 두었다.
|
||||
|
||||
**시작 전에 셋을 스스로 정해 둔다. 그 명령이 원 가이드에 없다(unknown).**
|
||||
|
||||
첫째, 워크스테이션에서 `kc-lab-1` 로 건너가는 명령이 이 절차에 없다. 라벨은 `[워크스테이션]` 과 `[kc-lab-1]` 을 여섯 번 오가는데 `ssh` 로 들어가는 줄도 `exit` 도 안 나온다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`, 워크스테이션에서 `kc-lab-1` 까지 `test-server` 를 거친다. `[워크스테이션]` 블록은 처음 시작한 셸에서 치고 `[kc-lab-1]` 블록은 그 기계에 붙은 셸에서 친다.
|
||||
|
||||
둘째, 이 절차의 파일 경로가 전부 저장소 상대경로다 — `bff/pom.xml`, `deploy/lab/k8s/bff-redis.yaml`. 어느 디렉터리에서 치는지 정하는 줄이 없으므로 두 기계 각각에서 저장소 루트로 먼저 옮겨 두고 시작한다. 다른 디렉터리에서 치면 `kubectl apply` 가 경로를 못 찾고 끝난다.
|
||||
|
||||
셋째, 워크스테이션에서 고친 파일을 `kc-lab-1` 로 옮기는 단계가 없다. `vim deploy/lab/k8s/bff-redis.yaml` 은 `[워크스테이션]` 이고 그 파일을 읽는 `kubectl apply -f deploy/lab/k8s/bff-redis.yaml` 은 `[kc-lab-1]` 인데, 사이에 파일을 넘기는 명령이 원 가이드에 없다. **안 옮기고 치면 오류가 안 난다** — 손 안 댄 매니페스트가 그대로 적용돼 `SPRING_SESSION_STORE_TYPE=redis` 와 `BFF_DB_*` 가 살아 있는 채로 배포되고, 화면에는 배포 성공만 뜬다. 어긋난 것은 관찰 절의 빈 수가 `321` 이 아닌 다른 숫자로 나올 때 비로소 보인다.
|
||||
|
||||
`kubectl` 에 `sudo` 를 붙이지 않는다. root 홈에는 `~/.kube/config` 가 없어 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다. 반입한 B층 아홉 편의 전제 한 줄만 옛 형태로 `sudo kubectl` 을 적고 있고, 본문 명령 블록에는 한 번도 쓰지 않는다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| 고치는 파일 | 넷 — `bff/pom.xml` · `SecurityConfig.java` · `application.yml` · `bff-redis.yaml` |
|
||||
| 주입 수단 | 편집기로 넷을 되돌린 뒤 다시 빌드해 두 노드에 import |
|
||||
| 무엇을 찍나 | `/actuator/beans` 의 전체 빈 수와 저장소 관련 빈 이름 |
|
||||
| 브라우저 | 필요하다. `https://app1.hyeonworks.com/` 이 열려야 한다 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. 빈 목록은 `grep` 으로 읽는다 |
|
||||
| 걸리는 시간 | 약 40~60분. 빌드 시간이 들어 있다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
코드에 저장소를 직접 만드는 빈이 없으면 무엇이 실제로 쓰이는지는 Spring Boot 의 자동구성 결과까지 봐야 알 수 있다. 원 가이드는 그 문장을 그대로 인용해 시작한다.
|
||||
|
||||
```text
|
||||
빈을 직접 만들지 않으면
|
||||
└─ Spring Boot 가 조건에 따라 고른다
|
||||
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
|
||||
└─ 돌아가는 인스턴스에 물어봐야 안다
|
||||
```
|
||||
|
||||
추측으로도 답은 나온다. 저장소를 안 붙였으니 메모리겠지, 맞다. 그런데 빈 이름 하나가 B층 전체의 문제를 담고 있고 그 이름은 추측으로 안 나온다. 찍어 봐야 나온다.
|
||||
|
||||
절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다 — 돌고 있는 인스턴스가 실제로 고른 구현체 이름, Redis 도 Spring Session 도 하나도 구성되지 않은 것, 조회 키에 session ID 가 없다는 것, replica 2 에서 로그인 자체가 실패하는 것, replica 를 1 로 줄이면 로그인이 되는 것, 브라우저에 토큰이 0개인 것.
|
||||
|
||||
저장소를 먼저 붙이면 이 실험은 성립하지 않는다. Redis 를 미리 연결하면 잴 것이 없어지므로 **Redis 는 배포만 하고 BFF 에 연결하지 않는다.** 연결은 B-1 에서 한다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` 이 끝나 있다. A층 실험은 안 해도 된다.
|
||||
- 브라우저가 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
|
||||
- BFF 이미지는 워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.
|
||||
- `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep` 으로 읽는다.
|
||||
|
||||
B층은 A층과 건드리는 대상이 다르고, 그래서 되돌리기도 다르다. A층은 주입 하나를 되돌리면 끝났는데 여기서는 애플리케이션 소스와 매니페스트를 고치므로 소스를 되돌린 뒤 다시 빌드해 두 노드에 다시 밀어 넣어야 클러스터가 따라온다.
|
||||
|
||||
| 무엇 | A층 | B층 |
|
||||
|---|---|---|
|
||||
| 무엇을 건드리나 | 클러스터 · 네트워크 · 데이터베이스 | 애플리케이션 소스와 매니페스트 |
|
||||
| 되돌리기 | 주입을 되돌린다 | `git checkout` 한 뒤 다시 빌드해 두 노드에 다시 밀어 넣는다 |
|
||||
| 브라우저 | 필요 없다 | B-3 을 뺀 셋은 브라우저가 있어야 한다 |
|
||||
| 이미지 | 이미 떠 있다 | 레지스트리가 없어 `imagePullPolicy: Never` 다. 두 노드에 각각 import 해야 두 replica 가 다 뜬다 |
|
||||
|
||||
저장소의 현재 소스는 이미 B-1 과 B-2 를 거친 뒤 상태다. `bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고 `SecurityConfig` 에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. 그대로 배포하면 B-2 의 결과를 재게 된다. 어느 브랜치에도 B-0 시점의 파일이 없다고 원 가이드가 적어 두었고, 그래서 주입 절의 첫 단계가 손으로 되돌리는 일이다.
|
||||
|
||||
되돌리기는 둘이고 둘 다 시작 전에 읽어 둔다.
|
||||
|
||||
```bash label="[워크스테이션] 소스만 되돌린다"
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] 배포한 것을 통째로 지운다"
|
||||
kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
PVC 는 `delete -f` 로 같이 지워진다. Redis 데이터도 함께 사라진다.
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```text
|
||||
노드 자원 → 네임스페이스에 무엇이 있나 → Keycloak realm → 사용자
|
||||
```
|
||||
|
||||
### 1. 노드에 BFF 두 개가 들어갈 자원이 있는가
|
||||
|
||||
**무엇을 보는가** — 두 노드의 메모리와 CPU 여유.
|
||||
|
||||
```bash label="[kc-lab-1] 메모리와 노드 사용률을 본다"
|
||||
free -m
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`).
|
||||
|
||||
```text
|
||||
=== 배포 전 자원 ===
|
||||
Mem: 11648 7329 280 4 4377 4319
|
||||
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
|
||||
kc-lab-1 115m 5% 2192Mi 44%
|
||||
kc-lab-2 121m 6% 1324Mi 33%
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 노드 메모리 사용률이 `44%` 와 `33%` 다. BFF 는 JVM 이고 replica 가 2 이며 매니페스트가 `requests: 320Mi` · `limits: 512Mi` 로 잡아 둔다. 여유가 없으면 파드가 `Pending` 이거나 메모리 부족으로 죽는데, 그때 증상을 Spring 설정 문제로 읽게 된다.
|
||||
|
||||
### 2. 네임스페이스에 앞 실험의 잔재가 있는가
|
||||
|
||||
**무엇을 보는가** — 지금 무엇이 떠 있는지.
|
||||
|
||||
```bash label="[kc-lab-1] ① 워크로드를 본다"
|
||||
kubectl -n keycloak-lab get all
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② Secret 과 Ingress 는 따로 본다"
|
||||
kubectl -n keycloak-lab get secret,ingress
|
||||
```
|
||||
|
||||
**어디를 보나** — `keycloak` StatefulSet 과 `postgres` 가 있고 `bff` 와 `redis` 는 없어야 한다.
|
||||
|
||||
**이 값이 뜻하는 것** — `bff` 나 `redis` 가 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면 내가 만든 것과 원래 있던 것이 섞인다. 줄을 둘로 나눈 까닭은 `get all` 이 워크로드 계열만 보여 주기 때문이다. Secret 과 PVC 와 Ingress 는 거기 안 나온다.
|
||||
|
||||
### 3. realm 과 클라이언트를 만든다
|
||||
|
||||
**목적** — BFF 가 로그인을 보낼 Keycloak realm 과 클라이언트를 세운다. realm 이 없으면 배포는 성공하는데 로그인에서 막힌다.
|
||||
|
||||
관리 자격증명을 잡는다. `kcadm` 은 Keycloak 이미지 안에 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ① kcadm 에 로그인한다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다. 길이만 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
realm 과 클라이언트를 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ realm 과 confidential 클라이언트를 만든다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
|
||||
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create clients -r keycloak-patterns \
|
||||
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
|
||||
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
|
||||
```
|
||||
|
||||
`-s secret=bff-lab-secret` 은 Keycloak 쪽에 저장되는 값이고, BFF 가 실제로 보내는 값은 배포 매니페스트가 만드는 Secret `bff-secrets` 의 `KEYCLOAK_CLIENT_SECRET` 이다. 둘이 같아야 로그인이 끝까지 간다. 이 절차에는 둘을 견주는 단계가 없으므로, 주입 절에서 `deploy/lab/k8s/bff-redis.yaml` 을 열었을 때 그 칸을 눈으로 확인한다.
|
||||
|
||||
만들어진 값을 되읽는다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ realm 설정 세 칸만 뽑아 본다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
|
||||
```
|
||||
|
||||
**예상 결과** — 비밀번호 길이는 `19` 다(observed). realm 조회는 `realm` 과 `enabled` 와 `accessTokenLifespan` 세 칸만 돌려준다.
|
||||
|
||||
**왜 필요한가** — `accessTokenLifespan=60` 은 B-3 을 위해 미리 짧게 잡는 값이다. 만료를 기다리는 시간이 짧아야 refresh 경쟁이 재현되고, 여기서 정해 두면 나중에 realm 을 다시 안 만든다.
|
||||
|
||||
**문제가 생기면** — `kcadm` 이 `401` 이면 `config credentials` 를 안 했거나 세션이 만료된 것이므로 ① 부터 다시 친다.
|
||||
|
||||
### 4. 로그인할 사용자를 만든다
|
||||
|
||||
**목적** — 브라우저에서 실제로 로그인할 계정을 하나 둔다.
|
||||
|
||||
사용자를 만든다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 사용자를 만든다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create users -r keycloak-patterns -s username=labuser -s enabled=true
|
||||
```
|
||||
|
||||
비밀번호를 준다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호를 준다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'
|
||||
```
|
||||
**★ 뒤의 편들이 이 값을 그대로 쓴다.** 이 실험대는 `labpass` 를 썼고, B-3 과 B-6 의 토큰
|
||||
요청이 `-d password=labpass` 로 그 값을 박아 놓고 있다. **여기서 다른 값을 정했으면 그
|
||||
자리들도 같이 바꿔야 한다** — 안 바꾸면 B-3 의 첫 토큰 요청이 `401` 로 떨어지고, 그것이
|
||||
주입이 안 걸린 것처럼 보인다.
|
||||
|
||||
**예상 결과** — 두 명령 다 조용히 끝난다.
|
||||
|
||||
**왜 필요한가** — 이 비밀번호는 브라우저에 직접 칠 값이므로 따라 하는 사람이 정한다. 위 값은 예시이고, 실제로 쓸 값을 셸 히스토리에 안 남기려면 `kcadm.sh` 를 대화식으로 쓰거나 나중에 관리 콘솔에서 바꾼다고 원 가이드가 적는다.
|
||||
|
||||
**문제가 생기면** — realm 을 통째로 지우면 이 단계가 만든 것이 함께 사라진다.
|
||||
|
||||
```bash label="[kc-lab-1] realm 을 통째로 지운다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete realms/keycloak-patterns
|
||||
```
|
||||
|
||||
## 주입
|
||||
|
||||
주입은 둘이다. 첫째가 소스를 B-0 시점으로 되돌리는 일이고 둘째가 배포다.
|
||||
|
||||
### 1. 파일 넷을 편집기로 열어 B-1·B-2 가 넣은 것을 뺀다
|
||||
|
||||
**목적** — 자동구성이 고를 기회를 만든다. 빈을 직접 만들어 두면 무엇을 골랐는지 재는 실험이 성립하지 않는다.
|
||||
|
||||
무엇을 왜 지우는지 읽으면서 고쳐야 하는 파일이라 넷 다 편집기로 연다.
|
||||
|
||||
의존성을 뺀다.
|
||||
|
||||
```bash label="[워크스테이션] ① 빌드 파일을 연다"
|
||||
vim bff/pom.xml
|
||||
```
|
||||
|
||||
| 지울 의존성 | 왜 |
|
||||
|---|---|
|
||||
| `spring-session-data-redis` | 있으면 `SessionRepository` 가 Redis 로 갈린다 (B-1) |
|
||||
| `spring-boot-starter-data-redis` | 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1) |
|
||||
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | B-2 의 `JdbcOAuth2AuthorizedClientService` 용 |
|
||||
|
||||
명시 빈을 지운다.
|
||||
|
||||
```bash label="[워크스테이션] ② 보안 설정을 연다"
|
||||
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||||
```
|
||||
|
||||
```java
|
||||
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
|
||||
|
||||
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
|
||||
@Bean
|
||||
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
|
||||
```
|
||||
|
||||
관련 `import`(`JdbcOAuth2AuthorizedClientService`, `JdbcOperations`, 매니저 계열)도 같이 지운다. `bffSecurity` 빈은 남긴다. `/actuator/**` 를 열어 주는 것이 그 안에 있고, 없으면 관찰 절이 전부 로그인 페이지를 받는다.
|
||||
|
||||
설정 블록을 뺀다.
|
||||
|
||||
```bash label="[워크스테이션] ③ 애플리케이션 설정을 연다"
|
||||
vim bff/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
| 지울 블록 | 왜 |
|
||||
|---|---|
|
||||
| `spring.session` | `store-type` 기본값이 `redis` 다. 남겨 두면 의존성만 빼도 경고가 난다 |
|
||||
| `spring.data.redis` | Redis 연결 설정 |
|
||||
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
|
||||
|
||||
매니페스트에서 환경변수 여섯을 뺀다.
|
||||
|
||||
```bash label="[워크스테이션] ④ 배포 매니페스트를 연다"
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 지운다 — B-1 · B-2 가 넣은 것
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
- name: REDIS_HOST
|
||||
- name: REDIS_PORT
|
||||
- name: BFF_DB_URL
|
||||
- name: BFF_DB_USER
|
||||
- name: BFF_DB_PASSWORD
|
||||
```
|
||||
|
||||
Redis Deployment 와 Service 와 PVC 는 그대로 둔다. 배포는 하되 연결만 안 하는 것이 B-0 의 구성이다.
|
||||
|
||||
무엇을 지웠는지 눈으로 본다.
|
||||
|
||||
```bash label="[워크스테이션] ⑤ 바뀐 파일과 실제 diff 를 본다"
|
||||
git diff --stat
|
||||
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||||
```
|
||||
|
||||
**예상 결과** — `git diff --stat` 에 위 네 파일만 나온다.
|
||||
|
||||
**왜 필요한가** — 하나라도 덜 지우면 그 위에서 잰 빈 목록이 B-1 이나 B-2 의 것이 된다. 관찰 절의 `321` 이 다른 숫자로 나오면 여기로 돌아온다.
|
||||
|
||||
**문제가 생기면** — `git diff --stat` 에 다섯 번째 파일이 보이면 다른 실험의 변경이 섞인 것이므로 그 파일만 `git checkout` 으로 되돌린다.
|
||||
|
||||
### 2. 이미지를 빌드해 두 노드에 각각 밀어 넣는다
|
||||
|
||||
**목적** — 고친 소스를 두 노드가 다 쓸 수 있는 이미지로 만든다.
|
||||
|
||||
빌드 로그를 파일로 받는다.
|
||||
|
||||
```bash label="[워크스테이션] ① 전체 로그를 파일로 받는다"
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
echo "exit=$?"
|
||||
```
|
||||
|
||||
실패했으면 로그에서 원인 줄만 뽑는다.
|
||||
|
||||
```bash label="[워크스테이션] ② 테스트 결과와 예외만 골라 본다"
|
||||
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
|
||||
```
|
||||
|
||||
이미지를 두 노드에 각각 넣는다. 레지스트리가 없으므로 한 노드에만 넣으면 나머지 replica 가 안 뜬다.
|
||||
|
||||
```bash label="[워크스테이션] ③ 이 실험대가 실제로 친 형태다"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
```
|
||||
|
||||
두 노드에 들어갔는지 확인한다.
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
**예상 결과** — 빌드가 성공하면 `exit=0` 이고, ④ 는 두 노드 모두에서 `keycloak-pattern-bff:lab` 줄을 낸다. 원래 실행이 여기서 만난 빌드 실패는 이렇게 보였다(observed).
|
||||
|
||||
```text
|
||||
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
|
||||
```
|
||||
|
||||
`management:` 아래에 `endpoint:` 블록을 하나 더 넣어서 난 오류다. 이미 있는데 또 넣었다. `yamllint` 는 이 실험대에 없고 YAML 중복 키는 빌드가 잡아 주는데, 그 메시지를 보려면 위처럼 전체 로그를 받아야 한다.
|
||||
|
||||
**왜 필요한가** — `docker build` 기본 출력은 마지막 몇 줄만 보여 주고 Maven 스택트레이스는 그 위에 있다. 그래서 `--progress=plain` 과 파일로 받는 것을 함께 쓴다. ③ 의 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 따라 하는 사람이 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown). 없는 명령을 지어내지 않는다.
|
||||
|
||||
**문제가 생기면** — 이미지가 한쪽에만 있으면 그 노드에 스케줄된 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. ③ 의 두 줄 중 빠진 쪽을 다시 친다.
|
||||
|
||||
### 3. 배포한다
|
||||
|
||||
**목적** — Redis 와 BFF 를 올린다. Redis 는 올리기만 하고 BFF 에 연결하지 않는다.
|
||||
|
||||
매니페스트를 적용하고 롤아웃이 끝날 때까지 기다린다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 적용하고 두 롤아웃을 기다린다"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `01-deploy.txt`).
|
||||
|
||||
```text
|
||||
=== 배포 ===
|
||||
secret/bff-secrets created
|
||||
deployment.apps/redis created
|
||||
service/redis created
|
||||
deployment.apps/bff created
|
||||
service/bff created
|
||||
ingress.networking.k8s.io/bff created
|
||||
|
||||
deployment "redis" successfully rolled out
|
||||
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
|
||||
deployment "bff" successfully rolled out
|
||||
```
|
||||
|
||||
배포된 모양은 이렇다.
|
||||
|
||||
```text
|
||||
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
|
||||
│
|
||||
├──▶ Keycloak (realm: keycloak-patterns)
|
||||
└──▶ echo (resource server 대역)
|
||||
|
||||
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
|
||||
```
|
||||
|
||||
**왜 필요한가** — 브라우저가 가는 주소와 BFF 가 서버끼리 부르는 주소를 나눠 둔 것도 이 매니페스트다.
|
||||
|
||||
```yaml
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
|
||||
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: KC_ISSUER_EXTERNAL
|
||||
value: https://auth.hyeonworks.com/realms/keycloak-patterns
|
||||
- name: KC_ISSUER_INTERNAL
|
||||
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
|
||||
```
|
||||
|
||||
둘을 섞으면 리다이렉트가 깨진다. `SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 까닭으로 들어 있다. 없으면 Spring 이 `redirect_uri` 를 `http://` 로 만들고 Keycloak 이 거부한다.
|
||||
|
||||
**문제가 생기면** — 롤아웃이 타임아웃으로 끝나면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면 이미지 import 로 돌아가고 `Pending` 이면 노드 메모리를 본다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다.
|
||||
|
||||
### 파드 두 개가 서로 다른 노드에 떴는가
|
||||
|
||||
```bash label="[kc-lab-1] BFF 와 Redis 의 배치를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
kubectl -n keycloak-lab get pods -o wide -l app=redis
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `01-deploy.txt`).
|
||||
|
||||
```text
|
||||
bff-574c6d658b-8cz4x true kc-lab-1
|
||||
bff-574c6d658b-zpkbp true kc-lab-2
|
||||
redis-568bd7c4-5c5vc true kc-lab-2
|
||||
```
|
||||
|
||||
BFF 두 개가 서로 다른 노드에 있어야 한다. `topologySpreadConstraints` 가 그 일을 한다. 다른 인스턴스가 진짜 다른 기계여야 이 층의 질문이 성립하고, 같은 노드의 다른 프로세스면 재는 값이 절반만 뜻을 갖는다.
|
||||
|
||||
### 외부 진입점이 이 애플리케이션의 HTML 을 주는가
|
||||
|
||||
```bash label="[kc-lab-1] 상태 줄과 헤더를 읽는 형태로 친다"
|
||||
curl -I https://app1.hyeonworks.com/
|
||||
```
|
||||
|
||||
실측은 `https://app1.hyeonworks.com/ HTTP 200` 이고(observed, `02-autoconfiguration.txt`), 응답 머리는 이렇게 생겼다.
|
||||
|
||||
```text
|
||||
HTTP/2 200
|
||||
content-type: text/html
|
||||
```
|
||||
|
||||
상태 줄과 `content-type` 을 같이 본다. `200` 이 왔다고 그게 이 애플리케이션의 HTML 이라는 보장이 없다. 원래 실행은 `/actuator/beans` 를 불렀을 때 `200` 을 받았는데 내용은 Keycloak 로그인 페이지였다. `-L` 로 리다이렉트를 따라간 결과다. `-o /dev/null -w '%{http_code}'` 만 쓰면 그 차이가 안 보이므로 여기서는 읽는 형태인 `-I` 를 쓰고, 여러 번 재서 비교할 때만 뽑는 형태로 바꾼다.
|
||||
|
||||
## 관찰
|
||||
|
||||
### 1. 빈 목록을 파드 안에서 받는다
|
||||
|
||||
**무엇을 보는가** — 자동구성이 실제로 만든 빈 전부.
|
||||
|
||||
`/actuator/beans` 는 117KB 이고 nginx 와 Traefik 을 거치면서 실패한다(observed, 해설 문서 1절).
|
||||
|
||||
```text
|
||||
$ curl https://app1.hyeonworks.com/actuator/beans
|
||||
Bad Gateway
|
||||
```
|
||||
|
||||
그래서 파드 안에서 직접 받는다. alpine 기반 JRE 이미지에는 `wget` 이 들어 있어서 Keycloak 이미지와 달리 파드 안에서 HTTP 요청을 보낼 수 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 받을 파드 이름을 잡는다"
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
echo "$BFF"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 파드 안에서 받아 크기를 센다"
|
||||
kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
|
||||
wc -c /tmp/beans.json
|
||||
```
|
||||
|
||||
**어디를 보나** — 크기가 10만 바이트 대여야 한다. 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
119552 /tmp/beans.json
|
||||
```
|
||||
|
||||
`0` 이면 못 받은 것이고 몇 백 바이트면 로그인 페이지나 오류 본문이다. 앞부분을 열어 확정한다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 앞 200바이트만 본다"
|
||||
head -c 200 /tmp/beans.json ; echo
|
||||
```
|
||||
|
||||
```json
|
||||
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `{"contexts":{"keycloak-bff"` 로 시작해야 한다. `<!DOCTYPE html` 로 시작하면 Keycloak 로그인 페이지를 받은 것이므로 `bffSecurity` 빈을 지우지 않았는지 본다.
|
||||
|
||||
### 2. 빈을 세고 저장소 관련 이름을 뽑는다
|
||||
|
||||
**무엇을 보는가** — 전체 빈 수와 저장소 계열 빈의 구현체 이름.
|
||||
|
||||
빈 하나는 이런 모양이고 이름과 타입이 한 덩어리 안에 같이 있다.
|
||||
|
||||
```text
|
||||
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
|
||||
```
|
||||
|
||||
이 실험대는 `jq` 가 없어 `grep` 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ① 빈의 개수를 센다"
|
||||
grep -o '"aliases":\[' /tmp/beans.json | wc -l
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 이름과 타입을 한 줄로 뽑아 거른다"
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -i authorizedclient
|
||||
```
|
||||
|
||||
`jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 적지 않아 그 형태는 여기에도 없다(unknown). 없는 도구를 전제로 한 명령은 진단 도중에 패키지를 깔러 나가게 만든다.
|
||||
|
||||
**어디를 보나** — ② 의 출력은 이렇게 생겼다(observed).
|
||||
|
||||
```text
|
||||
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
|
||||
```
|
||||
|
||||
정리한 실측은 이렇다(observed, `03-beans-analysis.txt`).
|
||||
|
||||
```text
|
||||
컨텍스트: keycloak-bff
|
||||
전체 빈 수: 321
|
||||
```
|
||||
|
||||
```text
|
||||
--- 세션 · 토큰 저장소 관련 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
clientRegistrationRepository -> InMemoryClientRegistrationRepository
|
||||
|
||||
--- Redis / Spring Session 이 구성되었는가 ---
|
||||
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
|
||||
```
|
||||
|
||||
없다는 것은 세어서 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ Redis · Spring Session 계열 빈을 센다"
|
||||
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
|
||||
```
|
||||
|
||||
`0` 이 나온다(observed). 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다. 세션은 서블릿 컨테이너인 Tomcat 의 기본 `StandardSession` 에 있고 그것이 인스턴스 메모리다.
|
||||
|
||||
**이 값이 뜻하는 것** — 다섯 줄을 하나씩 읽으면 이렇다.
|
||||
|
||||
| 빈 | 구현체 | 뜻 |
|
||||
|---|---|---|
|
||||
| `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | 프로세스 메모리. 재시작하면 사라진다 |
|
||||
| `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | principal 기준 조회. session ID 가 없다 |
|
||||
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | service 쪽을 쓴다 |
|
||||
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
|
||||
| SessionRepository | 없음 | Tomcat 의 기본 `StandardSession` |
|
||||
| Redis · Spring Session | 없음 | 의존성 자체가 없다 |
|
||||
|
||||
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 는 이름이 곧 설명이다. 인증된 요청이면 `OAuth2AuthorizedClientService` 에 위임하고, 그 서비스가 쓰는 키에 session ID 가 없다.
|
||||
|
||||
```text
|
||||
요청이 인증되어 있으면
|
||||
└─▶ OAuth2AuthorizedClientService 에 위임
|
||||
└─▶ 키: (clientRegistrationId, principalName)
|
||||
└─ session ID 가 없다 ★
|
||||
인증되어 있지 않으면
|
||||
└─▶ HttpSession 에 임시 보관
|
||||
```
|
||||
|
||||
같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을 보고, 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다. Redis 를 붙여도 이건 안 고쳐진다. 저장소를 공유해도 키에 session ID 가 없기 때문이다. 메모리에 있겠거니 하는 데까지는 추측으로 맞혀도, 조회 키가 무엇인지는 빈 이름을 봐야 안다.
|
||||
|
||||
### 3. 브라우저로 로그인해 본다
|
||||
|
||||
**무엇을 보는가** — replica 2 에서 로그인이 되는지. 원 가이드가 예상 못 한 것으로 적어 둔 부분이다.
|
||||
|
||||
브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
|
||||
|
||||
**어디를 보나** — 주소창이 이렇게 끝난다(observed).
|
||||
|
||||
```text
|
||||
https://app1.hyeonworks.com/login?error
|
||||
```
|
||||
|
||||
로그를 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 두 replica 의 로그를 접두사와 함께 본다"
|
||||
kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
|
||||
```
|
||||
|
||||
아무 오류도 없다. Spring Security 는 로그인 실패를 DEBUG 로만 남긴다. 로그에 아무것도 없으니 애플리케이션 문제가 아니라고 읽으면 틀린다. 증상은 있는데 로그가 없고, 그럴 때는 가설을 세워 시험한다.
|
||||
|
||||
**이 값이 뜻하는 것** — 인가 코드 흐름은 왕복이 두 번이고 두 번 다 같은 인스턴스로 가야 한다.
|
||||
|
||||
```text
|
||||
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
|
||||
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
|
||||
```
|
||||
|
||||
저장 위치가 `HttpSession` 이고 그것이 인스턴스 메모리이므로 콜백이 다른 replica 로 가면 저장된 인가 요청이 없어 실패한다. 앞에서 본 SessionRepository 없음이 이 가설의 근거다.
|
||||
|
||||
### 4. replica 를 1 로 줄여 가설을 시험한다
|
||||
|
||||
**목적** — 왕복 두 번이 같은 인스턴스로 가게 만들어 가설을 가른다.
|
||||
|
||||
replica 를 하나로 줄인다.
|
||||
|
||||
```bash label="[kc-lab-1] ① replica 를 1 로 줄인다"
|
||||
kubectl -n keycloak-lab scale deployment/bff --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
브라우저에서 쿠키를 먼저 지우고 다시 로그인한다.
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `b0-bff-login-success-single-replica.png`).
|
||||
|
||||
```text
|
||||
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
|
||||
replica 1 → 로그인 성공
|
||||
```
|
||||
|
||||
**왜 필요한가** — 가설이 확정된다. 다중 인스턴스에서 어떻게 운영할 것인가는 로그인한 뒤의 문제가 아니라 로그인 자체의 문제이고, B-2 의 검증 1번인 한쪽에서 로그인한 뒤 다른 인스턴스로 요청하기보다 앞선 단계다. 로그인이 끝나야 그 검증을 하는데 로그인부터 막힌다.
|
||||
|
||||
**문제가 생기면** — replica 1 에서도 `/login?error` 가 뜨면 쿠키를 안 지우고 다시 로그인했다. 앞선 실패의 세션이 섞이면 이 시험이 가르는 것이 없어진다.
|
||||
|
||||
### 5. 토큰 경계를 읽는다
|
||||
|
||||
**무엇을 보는가** — 브라우저와 서버 중 어느 쪽이 토큰을 들고 있는지.
|
||||
|
||||
브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `b0-bff-token-boundary.png`).
|
||||
|
||||
```json
|
||||
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
|
||||
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
|
||||
"browserTokenCount":0,"csrfProtectionEnabled":true}
|
||||
```
|
||||
|
||||
| 필드 | 값 | 뜻 |
|
||||
|---|---|---|
|
||||
| `accessTokenStoredOnServer` | `true` | 서버가 access token 을 들고 있다 |
|
||||
| `refreshTokenStoredOnServer` | `true` | refresh token 도 서버에 있다 |
|
||||
| `browserTokenCount` | `0` | 브라우저에는 토큰이 하나도 없다 |
|
||||
|
||||
**이 값이 뜻하는 것** — 브라우저는 세션 쿠키만 들고 있고 토큰은 전부 서버에 있다. 이 세 값을 적어 둬야 B-1 에서 무엇이 바뀌는지 읽을 수 있다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
### 1. replica 를 되돌린다
|
||||
|
||||
```bash label="[kc-lab-1] replica 를 2 로 올린다"
|
||||
kubectl -n keycloak-lab scale deployment/bff --replicas=2
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||||
```
|
||||
|
||||
B-1 로 이어서 갈 것이라면 배포는 그대로 둔다. 거기서 같은 파드에 Redis 를 붙인다.
|
||||
|
||||
### 2. 소스를 되돌린다
|
||||
|
||||
```bash label="[워크스테이션] 네 파일을 되돌리고 확인한다"
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
git status --short
|
||||
```
|
||||
|
||||
잊으면 다음에 `apply` 할 때 B-0 구성이 다시 배포된다. 소스만 되돌리고 끝내면 클러스터에는 여전히 B-0 이미지가 도는데, 다음 편이 곧바로 다시 빌드하므로 B-1 로 이어 갈 때는 재빌드가 그 편의 첫 단계다. 여기서 멈출 것이면 되돌린 소스로 한 번 더 빌드해 두 노드에 다시 import 해야 클러스터가 소스와 같아진다.
|
||||
|
||||
### 3. 전부 지운다
|
||||
|
||||
**B-1 이나 B-2 로 이어서 갈 것이면 이 절을 치지 않는다.** B-1 은 「Redis 는 배포만 되어 있고 아직 연결되지 않았다. B-0 이 그렇게 만들어 뒀다」를 전제로 시작하고 B-2 는 그 위에서 시작한다. 아래 두 줄은 `bff` 와 `redis` Deployment 를 PVC 까지, realm `keycloak-patterns` 를 `labuser` 까지 한꺼번에 없앤다. 치고 나면 B-1 은 배포와 realm 과 사용자를 다시 만드는 데서 시작해야 하는데 그 순서는 B-1 에 안 적혀 있고 이 편의 주입 절과 realm·사용자 단계로 되돌아와야 한다. B층을 여기서 끝낼 때만 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 배포와 realm 을 지운다"
|
||||
kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete realms/keycloak-patterns
|
||||
```
|
||||
|
||||
아래 확인표는 §1 과 §2 까지만 마친 상태를 본다 — 배포는 살아 있고 replica 가 2이며 소스가 깨끗한 상태다. §3 을 친 뒤에 이 표를 돌리면 `get deploy bff` 가 `NotFound` 를 내고 밖의 `curl -I` 도 `200` 을 못 낸다. 그때는 표가 틀린 것이 아니라 잴 대상이 없어졌으므로, §3 을 쳤으면 표를 건너뛰고 마지막 줄의 임시 파일만 지운다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| replica | `kubectl -n keycloak-lab get deploy bff` | `2/2` |
|
||||
| 소스 | `git status --short` | 출력 없음 |
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
|
||||
| Keycloak | `kubectl -n keycloak-lab get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running` |
|
||||
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||||
| 임시 파일 | `rm -f /tmp/beans.json /tmp/build.log` | — |
|
||||
|
||||
마지막 줄의 `rm -f /tmp/beans.json /tmp/build.log` 는 한 줄인데 두 파일이 서로 다른 기계에 있다. `/tmp/beans.json` 은 `kc-lab-1` 에서 만들었고 `/tmp/build.log` 는 워크스테이션에서 만들었으므로 각 기계에서 자기 쪽 파일을 지운다.
|
||||
|
||||
actuator 를 열어 둔 채로 두지 않는다. `/actuator/beans` 와 `/actuator/env` 는 내부 구조와 설정값을 그대로 드러낸다. 실험대라서 여는 것이고 운영이라면 `health` 만 남긴다고 원 가이드가 적는다.
|
||||
|
||||
## 막히면
|
||||
|
||||
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 빈 목록에 `RedisSessionRepository` 가 있다 | B-1·B-2 배선을 덜 지웠다 | 주입 절의 네 파일을 다시. `git diff` 로 확인 |
|
||||
| `authorizedClientService` 가 `Jdbc...` 다 | `SecurityConfig` 의 명시 빈을 안 지웠다 | 같은 절의 둘째 파일 |
|
||||
| `/actuator/beans` 가 `Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 |
|
||||
| `/actuator/beans` 가 `200` 인데 HTML | Keycloak 로그인 페이지다 | `head -c 200` 으로 내용 확인 |
|
||||
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
|
||||
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 |
|
||||
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | `grep -n 'KC_ISSUER' bff/src/main/resources/application.yml` |
|
||||
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import |
|
||||
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` |
|
||||
| 브라우저가 `/login?error` | replica 2 인데 스티키 세션이 없다 | replica 1 로 줄여 확인 |
|
||||
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 문제 없음으로 읽지 않는다 |
|
||||
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 |
|
||||
| `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 |
|
||||
| `kcadm` 이 `401` | `config credentials` 를 안 했거나 만료됐다 | realm 준비 단계를 다시 |
|
||||
|
||||
원래 실행이 겪은 것 가운데 둘은 소스 쪽 사고였다. 하나는 `bff/target/classes/...` 9개 파일만 커밋되어 있고 `bff/src/` 가 없던 상태다. `.gitignore` 에 `target/` 이 없어 클래스 파일만 들어갔고 소스는 다른 브랜치에 있었다. 빌드 산출물이 커밋되어 있으면 빌드는 되는데 소스를 바꿔도 결과가 안 바뀐다.
|
||||
|
||||
```bash label="[워크스테이션] ① 소스가 실제로 있는지 본다"
|
||||
ls bff/src/main/java/com/example/keycloakpattern/bff/
|
||||
```
|
||||
|
||||
```bash label="[워크스테이션] ② 소스가 있는 브랜치에서 가져온다"
|
||||
git checkout origin/develop-keycloak-pattern3 -- bff/
|
||||
```
|
||||
|
||||
다른 하나는 환경변수에 기본값이 없어 테스트가 죽은 것이다. 테스트는 그 환경변수를 모른다.
|
||||
|
||||
```yaml
|
||||
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
|
||||
|
||||
# 기본값을 준다
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
|
||||
```
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 13:39–13:46 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 배포 전 노드 자원 `44%` 와 `33%`, 배포 출력 전문, 파드 세 줄과 그 노드 배치, 외부 진입점 `HTTP 200`, 전체 빈 수 `321`, 저장소 관련 빈 다섯 줄과 「★ 없음」, `/actuator/beans` 가 117KB 이고 프록시에서 `Bad Gateway` 인 것, 비밀번호 길이 `19`, `token-boundary` 의 세 값, replica 2 에서 `/login?error` 이고 replica 1 에서 로그인이 되는 것.
|
||||
- (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 한 줄로 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 로 같은 것을 읽는 형태와, 두 겹 `ssh` 를 나눠 치는 형태는 가이드에 없다.
|
||||
- (observed) 파이썬 한 줄로 JSON 을 파싱하려다 난 `SyntaxError` 도 측정 기록에 있다. 그 시도가 깨진 뒤 `grep` 형태로 다시 받았고, 위에 실은 빈 목록이 그 결과다.
|
||||
- (observed) 빌드 로그의 `processDuplicateKeys` 는 `management:` 아래에 `endpoint:` 를 한 번 더 넣어서 난 것이다. `yamllint` 가 이 실험대에 없어 빌드가 그 오류를 처음 알렸다.
|
||||
- 이 실험이 재지 않은 것 하나 — 스티키 세션을 켜면 replica 2 에서 로그인이 되는지는 재지 않았다. 같은 인스턴스로 보내면 된다는 것은 추론이고 측정이 아니다.
|
||||
|
||||
<!-- body:end -->
|
||||
+792
@@ -0,0 +1,792 @@
|
||||
---
|
||||
id: 1e5d05fa-88f4-4ef5-a402-4a520ae4a52d
|
||||
kind: SETUP
|
||||
slug: reproduce-b1-redis-session-store
|
||||
title: Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/1e5d05fa-88f4-4ef5-a402-4a520ae4a52d/edit"
|
||||
pinnedVersions:
|
||||
- name: keycloak-pattern-bff
|
||||
version: lab
|
||||
- name: Redis
|
||||
version: 7.4.x
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-1
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다
|
||||
|
||||
Redis 를 세션 저장소로 붙이고 B-0 에서 찍어 둔 빈 목록과 견주는 절차다. 의존성 둘을 함께 넣어 다시 빌드하고 두 노드에 밀어 넣은 뒤, 무엇이 옮겨졌는지와 무엇이 안 옮겨졌는지를 같은 명령으로 확인한다. 전 구간 약 40분이고 빌드 시간이 들어 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **세션만 Redis 로 옮기자 토큰이 따라오지 않았다**
|
||||
이 절차가 낸 결과를 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
의존성을 하나만 넣으면 오류 없이 메모리에 남는다. 그 아홉 건 가운데 하나가 이 편에서 나왔다.
|
||||
- **세션과 인가된 클라이언트는 조회 키가 다르다**
|
||||
빈 81개가 늘었는데 인가된 클라이언트 빈 셋이 그대로인 까닭을 그 기록이 설명한다.
|
||||
- **아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다**
|
||||
먼저 해 둬야 하는 편이다. 그 편이 남긴 빈 세 개의 이름과 숫자 `321` 이 여기서 대조군이 된다.
|
||||
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
|
||||
다음 편이다. 여기서 Redis 로 안 옮겨진 인가된 클라이언트를 그 편이 공유 저장소로 옮긴다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
B-0 과 같다. 소스를 고치고 이미지를 만드는 일은 워크스테이션에서 하고, 클러스터를 보고 배포하는 일은 `kc-lab-1` 에서 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 브라우저 창도 하나 열어 둔다.
|
||||
|
||||
**끊기는 곳도 B-0 과 같다. 시작 전에 셋을 스스로 정해 둔다 — 그 명령이 원 가이드에 없다(unknown).**
|
||||
|
||||
첫째, 워크스테이션과 `kc-lab-1` 을 여섯 번 오가는데 건너가는 `ssh` 도 `exit` 도 이 절차에 없다. 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다 — `ssh test-server "ssh kc-lab-1 '...'"`.
|
||||
|
||||
둘째, `bff/pom.xml` 과 `deploy/lab/k8s/bff-redis.yaml` 이 전부 저장소 상대경로다. 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다.
|
||||
|
||||
셋째, 워크스테이션에서 고친 `deploy/lab/k8s/bff-redis.yaml` 을 `kc-lab-1` 로 넘기는 단계가 없다. 이 편에서는 그 누락이 B-0 보다 더 헷갈리게 나온다 — 주입 3번이 넣는 `enableServiceLinks: false` 가 `kc-lab-1` 쪽 파일에 없으면 `apply` 는 성공하는데 파드가 똑같이 `CrashLoopBackOff` 로 남고, 그때 이 편은 「파드가 아직 안 바뀌었다」를 먼저 의심하라고 적는다. 실제로는 고친 파일이 그 기계에 안 간 것이다.
|
||||
|
||||
**아래 코드블록의 라벨은 그 줄을 치는 기계를 가리킨다.** `[워크스테이션 → kc-lab-1]` 이 붙은 블록은 한 블록 안에서 기계가 바뀌므로 어느 줄이 어디인지 블록 뒤에 적어 두었다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| 고치는 파일 | 넷 — `bff/pom.xml` · `application.yml` · `BffControllerTest.java` · `bff-redis.yaml` |
|
||||
| 주입 수단 | 의존성 둘을 넣고 다시 빌드해 두 노드에 import |
|
||||
| 무엇을 찍나 | `/actuator/beans` 의 빈 수와 이름, Redis 의 키·필드·TTL |
|
||||
| 브라우저 | 필요하다. 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. `grep` 과 `redis-cli` 로 읽는다 |
|
||||
| 걸리는 시간 | 약 40분. 빌드 시간이 들어 있다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
B-0 이 답을 냈다. 세션도 토큰도 인스턴스 메모리에 있고, 그래서 replica 2 에서는 로그인조차 안 된다. 처방은 뻔해 보인다 — 공유 저장소를 붙인다.
|
||||
|
||||
```text
|
||||
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
|
||||
↑
|
||||
정말 그런가?
|
||||
```
|
||||
|
||||
이 실험이 재는 것은 붙였다와 공유된다 사이의 거리다. 묻는 것이 넷이고 그중 둘째를 이 실험이 판정한다.
|
||||
|
||||
| | 물어볼 것 |
|
||||
|---|---|
|
||||
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
|
||||
| 무엇이 안 옮겨졌나 | 같은 곳. 안 바뀐 것을 확인하는 쪽이 더 중요하다 |
|
||||
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
|
||||
| 사용자에게는 어떻게 보이나 | 브라우저 |
|
||||
|
||||
절차를 끝까지 밟으면 일곱을 자기 화면에서 보게 된다 — 쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것, 그것이 `enableServiceLinks: false` 로 고쳐지는 것, 빈이 321 에서 402 로 81개 늘어나는 것, 그런데 인가된 클라이언트는 하나도 안 바뀐 것, Redis 안의 키와 필드와 TTL 에 토큰이 없는 것, 세션이 Java 네이티브 직렬화인 것, 로그인은 되어 있는데 아무것도 못 하는 상태.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- B-0 이 끝나 있고, B-0 의 답인 빈 세 개의 이름을 손에 들고 시작한다. 이 실험은 그 값들이 어떻게 바뀌는지를 잰다.
|
||||
- B-0 의 복구 절 §3 「전부 지운다」를 치지 않은 상태여야 한다. 그 절은 `bff` 와 `redis` Deployment 와 realm `keycloak-patterns` 를 함께 없애므로, 쳤으면 아래 첫 확인부터 빈 목록이 나온다. 그 상태라면 B-0 의 주입 절과 realm·사용자 단계를 다시 밟아 배포를 세운 뒤 여기로 온다.
|
||||
- 브라우저가 필요하다.
|
||||
- 명령은 `kc-lab-1` 에서 친다.
|
||||
- `jq` 는 이 실험대에 깔려 있지 않다. 이 절차는 `grep` 과 `redis-cli` 로 읽는다.
|
||||
|
||||
애플리케이션 구성을 바꾸는 실험이라 의존성과 설정을 고쳐 다시 빌드하고 다시 배포한다. 되돌리려면 소스를 되돌린 뒤 또 한 번 빌드해 두 노드에 다시 밀어 넣어야 하므로 `git status` 가 깨끗한 상태에서 시작한다.
|
||||
|
||||
되돌리기는 먼저 읽어 둔다.
|
||||
|
||||
```bash label="[워크스테이션] 소스를 되돌린다"
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
주입 절의 첫 배포는 일부러 고장 난 상태로 한다. 함정을 직접 보기 위해서이고, 건너뛰고 `enableServiceLinks: false` 부터 시작해도 결과는 같다고 원 가이드가 적는다.
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```text
|
||||
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
|
||||
```
|
||||
|
||||
### 1. BFF 두 개와 Redis 가 떠 있는가
|
||||
|
||||
**무엇을 보는가** — 파드 배치.
|
||||
|
||||
```bash label="[kc-lab-1] BFF 와 Redis 의 배치를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
kubectl -n keycloak-lab get pods -o wide -l app=redis
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).
|
||||
|
||||
```text
|
||||
bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1
|
||||
bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2
|
||||
redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — BFF 두 개가 서로 다른 노드에 있고 Redis 가 떠 있다. Redis 는 배포만 되어 있고 아직 연결되지 않았다. B-0 이 그렇게 만들어 뒀다.
|
||||
|
||||
### 2. B-0 의 답을 before 값으로 다시 잡는다
|
||||
|
||||
**무엇을 보는가** — 주입 뒤에 견줄 빈 수와 빈 이름 셋.
|
||||
|
||||
```bash label="[kc-lab-1] ① 빈 목록을 파드 안에서 받는다"
|
||||
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
|
||||
wc -c /tmp/beans-before.json
|
||||
```
|
||||
|
||||
이 실험대는 `jq` 가 없어 `grep` 으로 덩어리를 뽑았다. 원 가이드가 아래 두 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ② 빈을 세고 저장소 계열 이름을 뽑는다"
|
||||
grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -iE 'authorizedclient|sessionRepository'
|
||||
```
|
||||
|
||||
`jq` 가 깔려 있으면 그것을 쓴다고 원 가이드가 적는데 어떤 표현을 쓰라고는 여기서도 적지 않았다(unknown).
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
|
||||
|
||||
```text
|
||||
빈 수: 321 → 402 (+81)
|
||||
```
|
||||
|
||||
```text
|
||||
authorizedClientService
|
||||
before: InMemoryOAuth2AuthorizedClientService
|
||||
authorizedClientRepository
|
||||
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientManager
|
||||
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 지금 빈 수가 `321` 이고 `sessionRepository` 는 아예 없다. 이 세 줄과 숫자를 적어 둔다. 관찰 절이 이 값과 견주고, 견줄 것이 없으면 안 바뀌었다고 말할 수 없다.
|
||||
|
||||
### 3. Redis 가 살아 있고 비어 있는가
|
||||
|
||||
**무엇을 보는가** — 연결 여부와 키 개수.
|
||||
|
||||
```bash label="[kc-lab-1] ① 응답과 판 번호를 읽는 형태로 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 키 개수와 키 이름을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
PONG
|
||||
# Server
|
||||
redis_version:7.4.x
|
||||
...
|
||||
```
|
||||
|
||||
```text
|
||||
(integer) 0
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `0` 이어야 뒤에서 찾은 키를 내가 만든 것이라고 말할 수 있다. `KEYS *` 대신 `--scan` 을 쓰는 까닭은 `KEYS` 가 서버를 블로킹하기 때문이다. 지금은 키가 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
|
||||
|
||||
### 4. 파드 안에 이미 Redis 관련 환경변수가 있는가
|
||||
|
||||
**무엇을 보는가** — 아직 아무것도 안 바꿨는데 들어와 있는 값. 이 실험의 함정이 여기서 시작된다.
|
||||
|
||||
한 번은 통째로 보고 그다음 걸러 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 환경변수를 통째로 본다"
|
||||
kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② Redis 쪽만 거른다"
|
||||
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
REDIS_SERVICE_HOST=10.43.57.116
|
||||
REDIS_SERVICE_PORT=6379
|
||||
REDIS_PORT=tcp://10.43.57.116:6379
|
||||
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
|
||||
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
|
||||
REDIS_PORT_6379_TCP_PORT=6379
|
||||
REDIS_PORT_6379_TCP_PROTO=tcp
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다. 쿠버네티스는 같은 네임스페이스의 모든 Service 마다 옛 Docker 링크 호환용 환경변수를 파드에 자동으로 넣고, 그 기능이 기본으로 켜져 있다. Service 이름이 `redis` 이므로 `REDIS_*` 가 들어오는데 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. 이름이 겹친다.
|
||||
|
||||
```text
|
||||
Service 이름이 redis 이면
|
||||
REDIS_SERVICE_HOST=10.43.57.116
|
||||
REDIS_SERVICE_PORT=6379
|
||||
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
|
||||
```
|
||||
|
||||
매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그쪽이 이긴다. 명시를 안 하면 자동 주입이 이기고, 오류 메시지는 쓰지도 않은 값을 지목한다. `REDIS` 와 `POSTGRES` 와 `MYSQL` 처럼 흔한 Service 이름일수록 위험하다고 원 가이드가 적는다. 지금은 애플리케이션이 그 변수를 안 읽으므로 아무 일도 안 일어나고, 읽기 시작하는 순간 파드가 죽는다.
|
||||
|
||||
## 주입
|
||||
|
||||
### 1. 의존성 둘을 함께 넣는다
|
||||
|
||||
**목적** — `SessionRepository` 를 Redis 로 갈아끼우고 연결을 제공한다. 하나만 넣으면 오류 없이 메모리에 남으므로 둘을 같이 넣는다.
|
||||
|
||||
무엇을 왜 넣는지 읽으면서 고쳐야 하는 파일이라 편집기로 연다.
|
||||
|
||||
```bash label="[워크스테이션] ① 빌드 파일을 연다"
|
||||
vim bff/pom.xml
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
|
||||
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
|
||||
둘 다 있어야 자동구성이 걸린다 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.session</groupId>
|
||||
<artifactId>spring-session-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
설정에 Redis 연결과 세션 저장소를 적는다.
|
||||
|
||||
```bash label="[워크스테이션] ② 애플리케이션 설정을 연다"
|
||||
vim bff/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
session:
|
||||
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
|
||||
timeout: ${SPRING_SESSION_TIMEOUT:30m}
|
||||
redis:
|
||||
namespace: bff:session
|
||||
```
|
||||
|
||||
테스트에는 Redis 가 없으므로 테스트에서만 저장소를 끈다.
|
||||
|
||||
```bash label="[워크스테이션] ③ 테스트를 연다"
|
||||
vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
|
||||
```
|
||||
|
||||
```java
|
||||
@SpringBootTest(properties = {
|
||||
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
|
||||
// 테스트는 Redis 를 띄우지 않는다
|
||||
"spring.session.store-type=none",
|
||||
})
|
||||
```
|
||||
|
||||
**예상 결과** — 세 파일이 `git diff --stat` 에 나온다.
|
||||
|
||||
**왜 필요한가** — `spring-session-data-redis` 를 넣으면 컨텍스트가 뜰 때 Redis 에 붙으려 하고, 테스트에는 Redis 가 없다. `spring.session.store-type=none` 한 줄이 없으면 빌드가 테스트 단계에서 죽는데 그 실패 메시지가 Redis 연결 오류라 배포 환경 문제로 읽히기 쉽다. 실패한 곳은 빌드다.
|
||||
|
||||
**문제가 생기면** — 뒤에서 `sessionRepository` 가 안 생기면 의존성을 하나만 넣은 것이므로 `pom.xml` 에 둘 다 있는지부터 본다.
|
||||
|
||||
### 2. 매니페스트를 일부러 고장 난 채로 올린다
|
||||
|
||||
**목적** — 자동 주입된 `REDIS_PORT` 가 애플리케이션 설정을 어떻게 이기는지 한 번 본다.
|
||||
|
||||
```bash label="[워크스테이션] ① 배포 매니페스트를 연다"
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
# enableServiceLinks: false ← 아직 넣지 않는다
|
||||
containers:
|
||||
- name: bff
|
||||
env:
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis.keycloak-lab.svc
|
||||
# REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
|
||||
```
|
||||
|
||||
빌드해서 두 노드에 밀어 넣고 배포한다. 아래 네 줄이 이 실험대가 실제로 친 형태다(observed).
|
||||
|
||||
```bash label="[워크스테이션 → kc-lab-1] ② 빌드하고 두 노드에 넣고 배포한다"
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
echo "exit=$?"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
```
|
||||
|
||||
**이 블록은 한 블록인데 기계가 둘이다.** 앞의 네 줄(`docker build` · `echo` · `docker save` 두 줄)은 워크스테이션에서 치고, `kubectl` 로 시작하는 아래 두 줄은 `kc-lab-1` 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 `kubectl` 을 치면 클러스터에 못 붙고 끝난다. 그리고 `kubectl apply` 가 읽는 `deploy/lab/k8s/bff-redis.yaml` 은 방금 워크스테이션에서 고친 그 파일이므로, `kc-lab-1` 쪽 체크아웃에도 같은 내용이 있어야 한다. 옮기는 명령은 원 가이드에 없다(unknown).
|
||||
|
||||
가운데 두 줄은 한 줄에 `ssh` 가 두 겹이고 원격 셸의 인용이 겹쳐 있어 나눠 치고 싶어지는데, 원 가이드가 나눈 형태를 적어 두지 않아 여기에도 없다(unknown).
|
||||
|
||||
지금 멈추려면 롤아웃을 되돌린다.
|
||||
|
||||
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
|
||||
kubectl -n keycloak-lab rollout undo deployment/bff
|
||||
```
|
||||
|
||||
**예상 결과** — 파드가 뜨지 않는다. 넓은 것부터 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 파드 상태를 본다"
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
```text
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
|
||||
```
|
||||
|
||||
로그보다 먼저 이벤트를 보고, 그다음 로그를 본다. 지금 파드와 죽기 전 파드를 따로 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 이벤트를 본다"
|
||||
kubectl -n keycloak-lab describe pod -l app=bff | tail -20
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 지금 로그와 죽기 전 로그를 본다"
|
||||
kubectl -n keycloak-lab logs -l app=bff --tail=40
|
||||
kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, 해설 문서 1절).
|
||||
|
||||
```text
|
||||
Failed to bind properties under 'spring.data.redis.port' to int:
|
||||
Property: spring.data.redis.port
|
||||
Value: "${REDIS_PORT:6379}"
|
||||
Reason: failed to convert java.lang.String to int
|
||||
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
|
||||
```
|
||||
|
||||
**왜 필요한가** — 마지막 줄의 `"tcp://10.43.57.116:6379"` 는 매니페스트 어디에도 쓰지 않은 값이다. 주입 전에 `printenv` 로 미리 본 그 환경변수이고 쿠버네티스가 넣었다. Redis 는 멀쩡하고, 파드는 Redis 에 붙어 보지도 못한 채 설정 바인딩에서 죽었다. 메시지가 `Failed to bind properties` 라고 말하고 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑥ Redis 가 멀쩡한지 따로 확인한다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
```
|
||||
|
||||
`PONG` 이 온다(observed).
|
||||
|
||||
**문제가 생기면** — 파드가 정상으로 떴다면 매니페스트에 `REDIS_PORT` 를 이미 줬다. 함정을 건너뛰어도 결과는 같으므로 다음 단계로 간다.
|
||||
|
||||
### 3. 자동 주입을 끄고 다시 올린다
|
||||
|
||||
**목적** — 이름 충돌의 근본을 없앤다.
|
||||
|
||||
처방은 둘인데 하나만 근본 처방이다.
|
||||
|
||||
| 처방 | 문제 |
|
||||
|---|---|
|
||||
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | 다음 사람이 같은 함정에 다시 빠진다 |
|
||||
| 주입 자체를 끈다 | 근본 처방 |
|
||||
|
||||
```bash label="[워크스테이션] ① 배포 매니페스트를 다시 연다"
|
||||
vim deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
enableServiceLinks: false # 근본 처방
|
||||
containers:
|
||||
- name: bff
|
||||
env:
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis.keycloak-lab.svc
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 적용하고 롤아웃을 기다린다"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `01-servicelinks-trap.txt`).
|
||||
|
||||
```text
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-576d869c6d-bshvl true kc-lab-2
|
||||
bff-695646ddb-kzs9k true kc-lab-1
|
||||
bff-695646ddb-vjqzf true kc-lab-2
|
||||
```
|
||||
|
||||
세 줄이다. replica 는 2인데 파드가 3개 보이는 것은 롤아웃 전환 중에 찍어서이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다. 잠시 뒤 두 개가 된다.
|
||||
|
||||
**왜 필요한가** — `enableServiceLinks: false` 는 그 파드에 대해 Service 이름 기반 환경변수 주입 전체를 끈다. 이름 하나를 피해 가는 것과 달라서 다음에 Service 를 하나 더 만들어도 같은 충돌이 안 난다.
|
||||
|
||||
**문제가 생기면** — 고쳤는데 오류가 똑같이 나면 파드가 아직 안 바뀌었다. `rollout restart` 뒤에 `printenv` 를 다시 본다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 읽기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 자동 주입이 정말 사라졌는지부터다.
|
||||
|
||||
### 1. 자동 주입된 환경변수가 사라졌는가
|
||||
|
||||
```bash label="[kc-lab-1] 새 파드 이름을 다시 잡고 환경변수를 본다"
|
||||
BFF=$(kubectl -n keycloak-lab get pod -l app=bff \
|
||||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||
```
|
||||
|
||||
모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
REDIS_HOST=redis.keycloak-lab.svc
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
`REDIS_SERVICE_HOST` 계열이 전부 사라졌고 넘겨준 두 개만 남았다. `REDIS_PORT` 가 `6379` 다.
|
||||
|
||||
### 2. 자동구성이 실제로 걸렸는가
|
||||
|
||||
```bash label="[kc-lab-1] 파드 배치와 헬스를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||
kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/health
|
||||
```
|
||||
|
||||
모양은 이렇다(observed).
|
||||
|
||||
```json
|
||||
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
|
||||
```
|
||||
|
||||
`redis` 컴포넌트가 있고 `UP` 이다. B-0 에서는 이 컴포넌트가 아예 없었다. `spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔고, 건강 체크에 새 항목이 생긴 것이 자동구성이 걸렸다는 신호다.
|
||||
|
||||
### 3. replica 2 에서 로그인이 되는가
|
||||
|
||||
브라우저에서 쿠키를 먼저 지우고 `https://app1.hyeonworks.com/` 로 로그인한다.
|
||||
|
||||
로그인이 된다(observed, `b1-login-works-two-replicas.png`). B-0 에서 replica 2 로는 `/login?error` 였던 그 부분이다. 인가 요청의 state 와 PKCE verifier 가 이제 Redis 에 있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. B-0 이 replica 를 1 로 줄여야 했던 문제는 고쳐졌다. 원 가이드는 여기에 곧바로 경고를 붙인다 — 여기서 멈추면 Redis 를 붙였더니 다 해결됐다로 끝나고, 그것이 이 실험이 막으려는 결론이다.
|
||||
|
||||
## 관찰
|
||||
|
||||
### 1. 빈 목록을 다시 찍어 before 와 견준다
|
||||
|
||||
**무엇을 보는가** — 늘어난 빈과 안 바뀐 빈.
|
||||
|
||||
B-0 의 방법을 그대로 다시 쓴다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 빈 목록을 받고 개수를 센다"
|
||||
kubectl -n keycloak-lab exec "$BFF" -- \
|
||||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
|
||||
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `02-autoconfig-after.txt`).
|
||||
|
||||
```text
|
||||
빈 수: 321 → 402 (+81)
|
||||
```
|
||||
|
||||
새로 생긴 세션 저장소 빈을 뽑는다. 원 가이드가 아래 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ② 세션·Redis 계열 빈을 뽑는다"
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -iE 'session|redis'
|
||||
```
|
||||
|
||||
```text
|
||||
--- 세션 저장소 관련 (새로 생긴 것) ---
|
||||
★ cookieSerializer -> DefaultCookieSerializer
|
||||
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
|
||||
★ sessionRepository -> RedisSessionRepository
|
||||
★ springSessionRepositoryFilter -> SessionRepositoryFilter
|
||||
★ redisConnectionFactory -> LettuceConnectionFactory
|
||||
★ redisTemplate -> RedisTemplate
|
||||
```
|
||||
|
||||
같은 파일에서 인가된 클라이언트 쪽을 따로 뽑는다. 이 실험이 판정하려는 것이 이쪽이다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ authorized client 계열을 뽑는다"
|
||||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||
| grep -i authorizedclient
|
||||
```
|
||||
|
||||
```text
|
||||
--- OAuth2 authorized client — 바뀌었는가? ---
|
||||
authorizedClientService
|
||||
before: InMemoryOAuth2AuthorizedClientService
|
||||
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientRepository
|
||||
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientManager
|
||||
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 빈 81개가 늘었는데 인가된 클라이언트는 하나도 안 바뀌었다.
|
||||
|
||||
```text
|
||||
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
|
||||
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
|
||||
```
|
||||
|
||||
`spring.session.store-type` 은 `HttpSession` 을 갈아끼우는 설정이고 `OAuth2AuthorizedClient` 는 그 설정과 무관한 다른 저장소에 있다. 찍어서 확인하지 않으면 이 사실을 알 방법이 없다. 로그인은 되고 화면도 뜨고 파드도 건강하다. B-0 을 실험으로 만든 까닭이 여기서 드러난다 — before 가 있어야 after 를 읽는다.
|
||||
|
||||
### 2. Redis 를 직접 연다
|
||||
|
||||
**무엇을 보는가** — 옮겨진 것 안에 무엇이 들었는지.
|
||||
|
||||
```bash label="[kc-lab-1] ① 키 개수와 키 이름을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-redis-contents.txt`).
|
||||
|
||||
```text
|
||||
=== Redis 에 무엇이 들어 있는가 ===
|
||||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
총 키 수: 1
|
||||
```
|
||||
|
||||
네임스페이스가 `bff:session` 이다. `application.yml` 의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다.
|
||||
|
||||
키 이름을 변수로 잡는다. 이 줄에는 걸러 내는 조각이 둘 붙어 있다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 세션 키 하나를 변수에 담는다"
|
||||
KEY=$(kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
|
||||
echo "$KEY"
|
||||
```
|
||||
|
||||
`grep -v expires` 가 필요한 까닭은 Spring Session 이 만료 추적용 키인 `bff:session:expirations:*` 와 `bff:session:sessions:expires:*` 도 만들기 때문이다. 그것을 잡으면 다음 명령이 빈 결과를 낸다. `tr -d '\r'` 은 `redis-cli` 출력이 CR 을 달고 오기 때문에 붙인다. 빼면 키가 안 맞는데 오류는 안 난다.
|
||||
|
||||
바로 위 `redis-cli --scan` 이 이미 전체 키를 보여 줬으므로 그 출력에서 키 하나를 눈으로 골라 쳐도 되는데, 원 가이드가 그 두 단계 형태를 적어 두지 않았다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ③ 타입과 필드 이름을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `03-redis-contents.txt`).
|
||||
|
||||
```text
|
||||
타입: hash
|
||||
필드: sessionAttr:SPRING_SECURITY_CONTEXT
|
||||
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
|
||||
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
|
||||
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
|
||||
필드: lastAccessedTime
|
||||
필드: maxInactiveInterval
|
||||
필드: creationTime
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 필드 목록에 토큰이 없다. 저장소를 직접 열어 refresh token 이 평문으로 남는지 확인하는 것이 검증 항목이었는데, 답은 더 앞에 있었다 — 애초에 들어가지 않는다. 토큰 암호화를 어떻게 할지 고민하기 전에 토큰이 그 저장소에 가지도 않는다는 것을 먼저 알아야 한다.
|
||||
|
||||
### 3. TTL 과 값의 바이트를 본다
|
||||
|
||||
```bash label="[kc-lab-1] ① 남은 수명을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
|
||||
```
|
||||
|
||||
```text
|
||||
=== TTL (Q3 검증 3번 — session TTL) ===
|
||||
TTL: 1772 초
|
||||
```
|
||||
|
||||
`spring.session.timeout=30m` 인 1800초에서 방금 지난 만큼 줄어든 값이다. 세션 TTL 1772초와 access token 수명 60초가 처음부터 어긋나 있다. 어느 쪽에 맞출지 고르기 전에 이미 어긋나 있고, 그 간극을 누가 메우는지가 B-3 의 주제다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 값의 앞 네 줄을 이스케이프해서 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
|
||||
```
|
||||
|
||||
```text
|
||||
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
|
||||
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...
|
||||
```
|
||||
|
||||
`\xac\xed` 로 시작한다. Java 직렬화 매직 넘버이고 JSON 이 아니다. `--no-raw` 를 쓰는 것은 바이너리를 이스케이프해서 보여 주기 때문이다. 안 쓰면 터미널이 제어문자를 먹고 화면이 깨진다.
|
||||
|
||||
| 결과 | |
|
||||
|---|---|
|
||||
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
|
||||
| 클래스 버전에 묶인다 | 애플리케이션을 올리면 기존 세션이 역직렬화에 실패할 수 있다 |
|
||||
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
|
||||
|
||||
D-2 의 버전 업그레이드에서 이 성질이 다시 나온다. Spring Security 버전이 바뀌면 Redis 에 있던 세션이 깨질 수 있다.
|
||||
|
||||
### 4. 파드를 전부 교체하고 사용자 화면을 본다
|
||||
|
||||
**목적** — 롤링 재시작으로 Redis 덕을 보는지 확인한다. 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 파드를 전부 교체한다"
|
||||
kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
로그인은 그대로 둔 채 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `b1-token-boundary-after-redis.png`).
|
||||
|
||||
```json
|
||||
{"pattern":"AP3-backend-for-frontend",
|
||||
"principal":"labuser", ← 세션은 Redis 에서 복원되었다
|
||||
"accessTokenStoredOnServer":false, ← 토큰은 사라졌다
|
||||
"refreshTokenStoredOnServer":false,
|
||||
"browserTokenCount":0,
|
||||
"csrfProtectionEnabled":true}
|
||||
```
|
||||
|
||||
**왜 필요한가** — `principal` 은 살아 있는데 두 토큰이 `false` 다.
|
||||
|
||||
```text
|
||||
사용자 관점: 로그인되어 있다고 나온다
|
||||
실제: BFF 가 사용자를 대신해 아무것도 못 한다
|
||||
```
|
||||
|
||||
파드가 전부 교체됐는데 로그인 상태는 살아남았다. Redis 덕분이다. 토큰은 같이 살아남지 못했다. 인스턴스 메모리에 있었으니까. 원 가이드는 이것을 부분적으로만 공유했을 때의 실패 모양이라고 부르고, 완전히 로그아웃되는 편이 차라리 낫다고 적는다. 적어도 사용자가 다시 로그인하는데, 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
|
||||
|
||||
| | B-0 (Redis 없음, replica 1) | B-1 (Redis 세션, replica 2) |
|
||||
|---|---|---|
|
||||
| `principal` | labuser | labuser |
|
||||
| `accessTokenStoredOnServer` | true | false |
|
||||
| 파드 재시작 후 | 로그아웃 | 로그인 상태만 남고 토큰은 소실 |
|
||||
|
||||
**문제가 생기면** — 스크린샷으로 시점을 구별하지 않는다. 증거 폴더의 `README.md` 가 적어 둔 대로 `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는 동일 파일이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은 화면이었고, 시점 구별은 터미널 출력과 Redis·DB 조회가 한다.
|
||||
|
||||
그래서 무엇을 해야 하는가 — `OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 따로 필요하다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. PostgreSQL 이 이미 있다 |
|
||||
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
|
||||
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
|
||||
|
||||
세 번째는 조회 키 문제까지 같이 푼다. 세션 단위로 저장되므로 같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않고, 대신 세션이 커진다. B-2 가 이 선택지를 비교한다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
B-2 로 이어갈 것이면 이 구성이 B-2 의 출발이므로 아무것도 안 되돌린다.
|
||||
|
||||
### 1. 소스를 되돌리고 다시 빌드해 다시 밀어 넣는다
|
||||
|
||||
B-0 상태로 되돌릴 때는 소스만 되돌려서는 안 된다. 클러스터에는 여전히 옛 이미지가 돈다.
|
||||
|
||||
```bash label="[워크스테이션] ① 네 파일을 되돌리고 확인한다"
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
git status --short
|
||||
```
|
||||
|
||||
```bash label="[워크스테이션 → kc-lab-1] ② 다시 빌드해 두 노드에 다시 넣고 다시 배포한다"
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
② 도 한 블록에 기계가 둘이다. `docker` 로 시작하는 앞의 세 줄은 워크스테이션, `kubectl` 로 시작하는 뒤의 세 줄은 `kc-lab-1` 이다. ① 이 되돌린 파일이 `kc-lab-1` 쪽 체크아웃에도 반영돼 있어야 `apply` 가 B-0 구성을 올린다.
|
||||
|
||||
`git status --short` 가 빈 출력이어도 클러스터는 아직 옛 이미지를 쓰고 있다. ② 를 끝내야 소스와 클러스터가 같아진다.
|
||||
|
||||
### 2. Redis 를 비운다
|
||||
|
||||
Redis 를 비우는 것은 되돌릴 수 없다. 지운 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 실험대라서 하는 일이다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 개수를 보고 비우고 다시 센다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
```
|
||||
|
||||
세션 하나만 지우려면 이쪽이다. `$KEY` 는 관찰 2번에서 담아 둔 키이고, 그 뒤로 `rollout restart` 와 브라우저 왕복이 들어가 절차가 40분쯤 걸리므로 터미널을 새로 열었으면 값이 비어 있다. 치기 전에 `echo "$KEY"` 로 키가 나오는지 보고, 안 나오면 관찰 2번의 `KEY=$(...)` 두 줄을 다시 쳐서 담는다. 바로 위 ① 의 `flushdb` 를 이미 쳤으면 지울 키가 없으므로 ② 를 건너뛴다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 잡아 둔 키 하나만 지운다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
|
||||
```
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
|
||||
| 이미지 | `sudo k3s ctr images ls \| grep keycloak-pattern-bff` | 두 노드 모두에 있다 |
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
|
||||
| Redis | `kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
|
||||
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
|
||||
| Keycloak | `kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` |
|
||||
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||||
| 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — |
|
||||
|
||||
표의 두 줄은 한 기계에서 다 못 본다. **이미지** 줄의 `sudo k3s ctr images ls` 는 그 명령을 친 노드 하나만 본다. `kc-lab-1` 에서 치면 `kc-lab-2` 는 안 보이므로, 「두 노드 모두에 있다」를 확인하려면 B-0 이 짝으로 친 `ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'` 도 함께 봐야 한다. 한쪽에만 있으면 그 노드의 replica 만 뜨고 나머지는 `ErrImageNeverPull` 로 나타난다. **임시 파일** 줄도 앞의 두 파일은 `kc-lab-1`, `/tmp/build.log` 는 워크스테이션에 있으므로 각 기계에서 자기 쪽을 지운다.
|
||||
|
||||
## 막히면
|
||||
|
||||
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 파드가 `CrashLoopBackOff` 이고 오류에 `tcp://...:6379` | 쿠버네티스가 `REDIS_PORT` 를 주입했다 | `printenv \| grep -i redis` |
|
||||
| 위 오류를 Redis 가 죽어서로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
|
||||
| `enableServiceLinks` 를 넣었는데 그대로다 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 |
|
||||
| 빌드가 Redis 연결 오류로 죽는다 | 테스트가 Redis 를 찾는다 | `spring.session.store-type=none` |
|
||||
| `sessionRepository` 가 안 생긴다 | 의존성을 하나만 넣었다. 오류 없이 메모리에 남는다 | 두 개 다 있는지 `pom.xml` |
|
||||
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
|
||||
| `hkeys` 가 빈 결과 | 만료 추적 키를 잡았다 | `grep -v expires` |
|
||||
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` |
|
||||
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` |
|
||||
| API 호출이 `500` 인데 토큰은 멀쩡하다 | DNS 다. 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` |
|
||||
| `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 |
|
||||
| `jq: command not found` | 이 실험대에 `jq` 가 없다 | `grep` 으로 읽는다 |
|
||||
| 로그인은 되는데 API 가 전부 실패한다 | 이게 이 실험의 결론이다 | `token-boundary` 의 두 `false` |
|
||||
| 스크린샷으로 시점을 구별하려다 헷갈린다 | 두 파일이 동일하다 | 터미널 출력과 Redis 조회로 구별 |
|
||||
|
||||
`500` 쪽은 원인을 찾는 데 한 번 헛짚었다. 로그를 보니 토큰이 아니라 DNS 였다.
|
||||
|
||||
아래 줄의 `$BFF` 는 주입 검증 1번에서 담아 둔 파드 이름이다. 「막히면」은 아무 때나 펼치는 절이고 그 사이에 관찰 4번의 `rollout restart` 가 파드를 통째로 갈아치우므로, 그대로 치면 `NotFound` 가 온다. 치기 전에 주입 검증 1번의 `BFF=$(...)` 두 줄을 다시 쳐서 이름을 새로 담는다.
|
||||
|
||||
```bash label="[kc-lab-1] 예외만 골라 본다"
|
||||
kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
|
||||
```
|
||||
|
||||
```text
|
||||
java.nio.channels.UnresolvedAddressException
|
||||
```
|
||||
|
||||
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는 `header-lab` 네임스페이스의 8081 이었다. 배포조차 되어 있지 않았다.
|
||||
|
||||
```yaml
|
||||
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
|
||||
- name: RESOURCE_API_BASE_URL
|
||||
value: http://echo.header-lab.svc:8081
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] 그 서비스가 어디 있는지 본다"
|
||||
kubectl -n header-lab get svc echo
|
||||
```
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 13:59–14:03 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 자동 주입된 `REDIS_*` 일곱 줄과 `REDIS_PORT=tcp://10.43.57.116:6379`, `Failed to bind properties` 오류 전문, `enableServiceLinks: false` 뒤의 롤아웃 출력 세 줄, 빈 수 `321 → 402 (+81)`, 새로 생긴 세션 저장소 빈 여섯 줄, 안 바뀐 인가된 클라이언트 빈 세 개의 before 와 after, Redis 키 `bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae` 와 필드 일곱 개, `TTL: 1772 초`, `\xac\xed` 로 시작하는 바이트, 재시작 뒤 `token-boundary` 의 `principal` 생존과 두 토큰 `false`, `UnresolvedAddressException`.
|
||||
- (unknown) 빈을 세는 `grep -o '"aliases":\['` 줄과 이름·타입을 뽑는 `grep`·`sed` 줄. 원 가이드가 미검증으로 표시했다. `jq` 판본과, `--scan` 출력에서 키를 눈으로 골라 치는 두 단계 형태와, 두 겹 `ssh` 를 나눠 치는 형태도 가이드에 없다.
|
||||
- (observed) `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 가 동일 파일이라는 것은 증거 폴더의 `README.md` 가 적어 둔 사실이다. 같은 화면이 세 시점에 나왔기 때문이고, 그래서 시점은 터미널 출력과 저장소 조회로만 갈린다.
|
||||
- 이 실험이 재지 않은 것 셋 — Redis 를 끊었을 때 무엇이 나는지는 B-5 의 주제이고, 로그아웃 뒤 두 저장소에 무엇이 있는지는 B-2 로 넘겼으며, 저장소 지연이 화면 지연으로 얼마나 번역되는지는 B-2 이후에 잰다.
|
||||
|
||||
<!-- body:end -->
|
||||
+780
@@ -0,0 +1,780 @@
|
||||
---
|
||||
id: df2ee798-7f31-4570-a355-11f96c0eea84
|
||||
kind: SETUP
|
||||
slug: reproduce-b2-jdbc-token-store
|
||||
title: 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/df2ee798-7f31-4570-a355-11f96c0eea84/edit"
|
||||
pinnedVersions:
|
||||
- name: keycloak-pattern-bff
|
||||
version: lab
|
||||
- name: Redis
|
||||
version: 7.4.x
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-2
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다
|
||||
|
||||
B-1 이 Redis 로 안 옮긴 토큰을 PostgreSQL 로 옮겨 보는 절차다. `JdbcOAuth2AuthorizedClientService` 를 걸고 기본키 한 줄과 로그아웃 뒤 세 저장소의 숫자를 읽는다. 전 구간 약 25분이고 브라우저와 터미널을 나란히 둔다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
|
||||
이 절차가 낸 결론을 담은 기록이다. 무엇을 발견했는지는 그쪽에 있고 여기에는 치는 순서만 있다.
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
스키마 초기화가 조용히 실패하고 파드는 정상으로 보이는 것이 그 아홉 건 가운데 하나다.
|
||||
- **저장소를 옮기기 전에 조회 키를 본다**
|
||||
`\d oauth2_authorized_client` 를 주입 전에 치는 순서를 규칙으로 굳힌 기록이다.
|
||||
- **세션과 토큰의 저장소를 나눠 각각 설계한다**
|
||||
여기서 나온 덮어쓰기와 한쪽만 정리되는 로그아웃이 그 결정의 근거가 된다.
|
||||
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
|
||||
먼저 해 둬야 하는 편이다. 그 편이 만든 구성 위에서 이 절차가 시작한다.
|
||||
- **같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다**
|
||||
다음 편이다. 여기서 만든 `oauth2_authorized_client` 표를 그 편이 그대로 쓰므로 표를 지우지 않는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 먼저 읽는다 — 이 편만 따라 쳐서는 주입이 재현되지 않는다
|
||||
|
||||
**이 절차에는 JDBC 토큰 저장소 배선을 넣는 단계가 없다.** 배선을 넣는 편집 명령도 그때 친 빌드 명령도 B-2 의 원 가이드에 없고(unknown), 없는 명령을 지어내지 않았다. 아래 절차는 그 배선이 이미 들어간 BFF 가 떠 있는 상태에서 시작한다 — 주입 전 1번이 인용하는 `deployment "bff" successfully rolled out` 두 줄이 그 배포가 남긴 출력이다.
|
||||
|
||||
**B-0 과 B-1 을 글자 그대로 따라 친 사람은 그 상태가 아니다.** B-0 의 주입 절이 `spring-boot-starter-jdbc` · `postgresql` · `h2` 와 `SecurityConfig` 의 명시 빈 둘과 `spring.datasource` 와 `BFF_DB_*` 를 **지우고**, B-1 은 그것을 되살리지 않는다. 그래서 B-0 → B-1 → B-2 순으로 온 BFF 에는 `JdbcOAuth2AuthorizedClientService` 가 없다.
|
||||
|
||||
**그 상태로 쳐도 화면은 정상으로 보인다.** 주입 전 3번이 표를 만들고 `\d oauth2_authorized_client` 도 정의를 돌려주는데, 행만 한 번도 안 생긴다. 그러면 주입 전 6번의 「이 값이 아직 `false` 로 나오면 … 로그아웃하고 다시 로그인한다」가 끝나지 않는 고리가 되어 로그인만 반복하게 된다. 두 번 재로그인해도 `accessTokenStoredOnServer` 가 `false` 이고 표의 행이 0이면 배선이 안 들어간 상태이므로 이 절차를 멈춘다.
|
||||
|
||||
무엇을 넣어야 하는지는 B-0 의 주입 절이 지울 목록으로 적어 두었고, 바로 아래 「전제와 되돌리기」가 그 넷을 표로 옮겨 두었다. 그 넷을 되돌려 넣는 순서와 그때 친 빌드 명령은 원 가이드에 없다(unknown).
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 브라우저에서 버튼을 누르고 터미널에서 저장소를 세는 왕복이 이 절차의 대부분이라 터미널 하나와 브라우저 창 하나를 나란히 둔다. 로그아웃 한 단계만 브라우저 개발자 도구의 콘솔에서 친다.
|
||||
|
||||
예외는 아래 「전제와 되돌리기」의 두 블록뿐이다. 거기서만 워크스테이션의 저장소를 고치고 이미지를 다시 만든다. **그 기계로 건너가는 명령은 이 절차에 없다** — 경로는 이미지를 밀어 넣는 줄 하나에만 드러난다(`ssh test-server "ssh kc-lab-1 '...'"`, 워크스테이션에서 `test-server` 를 거쳐 `kc-lab-1` 로 간다). 그 블록의 `bff/pom.xml` 과 `deploy/lab/k8s/bff-redis.yaml` 도 저장소 상대경로라 어느 디렉터리에서 치는지 적힌 줄이 없으므로, 두 기계 각각에서 저장소 루트로 옮겨 두고 시작한다. 이 셋의 명령이 원 가이드에 없다(unknown).
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| 시작 상태 | B-0 과 B-1 이 끝나 BFF 가 replica 2 이고 Redis 가 세션 저장소다 |
|
||||
| 주입 수단 | Redis 세션을 지우고 같은 사용자로 다시 로그인시킨다 |
|
||||
| 읽어야 하는 한 줄 | `\d oauth2_authorized_client` 의 맨 아래 `Indexes:` |
|
||||
| 브라우저 | 필요하다. 인가 코드 흐름을 `curl` 로 만들 수 없다 |
|
||||
| 세는 저장소 | 셋 — Redis 의 `bff:session:*`, PostgreSQL 의 `oauth2_authorized_client`, Keycloak 세션 |
|
||||
| 걸리는 시간 | 약 25분 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
B-1 이 Application Session 만 Redis 로 옮겼고, 그러자 사용자는 로그인 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다. 세션과 토큰이 서로 다른 것에 들어 있고 한쪽만 옮겼기 때문이다. 토큰도 공유 저장소로 옮기면 그건 고쳐진다. 이 실험이 묻는 것은 무엇이 같이 고쳐지고 무엇이 안 고쳐지는가다.
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | 공유 저장소로 옮기면 다중 인스턴스 문제가 해결된다 |
|
||||
| B-2 모델 | 인스턴스 간 공유만 해결되고 브라우저 간 격리와 로그아웃 정리는 안 바뀐다 |
|
||||
|
||||
어디에 두는가와 어떻게 찾는가는 서로 독립이다.
|
||||
|
||||
```text
|
||||
저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
|
||||
조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
|
||||
```
|
||||
|
||||
이 실험이 판정하는 것은 두 번째이고, 키는 코드가 아니라 스키마에 박혀 있다. 그래서 구현을 바꾸면 되겠지로 넘어갈 수 없고, 주입하기 전에 그 줄을 직접 읽는다.
|
||||
|
||||
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데 셋이 서로 다른 시스템에 있다.
|
||||
|
||||
```text
|
||||
① HttpSession Redis Spring Security 가 지운다
|
||||
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
|
||||
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
|
||||
```
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` 과 `06-observability` 가 끝나 있다.
|
||||
- B-0 과 B-1 이 끝나 BFF 가 replica 2개로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
|
||||
- 명령은 `kc-lab-1` 에서 친다.
|
||||
- 브라우저가 필요하다. `https://app1.hyeonworks.com/` 에 붙어 realm `keycloak-patterns` 의 `labuser` 로 들어간다.
|
||||
- 터미널 하나와 브라우저 창 하나를 나란히 둔다.
|
||||
|
||||
상태를 바꾸는 실험이다. DDL 을 태우고 Redis 세션을 지우고 로그아웃한다. 실험대에서만 한다. 중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
|
||||
|
||||
되돌리기가 어디까지 가는지는 무엇을 되돌리느냐로 갈린다.
|
||||
|
||||
| 무엇을 되돌리나 | 어디까지 가나 |
|
||||
|---|---|
|
||||
| 지운 Redis 세션 | 브라우저에서 다시 로그인한다. 지운 세션 자체는 되살아나지 않는다 |
|
||||
| 덮어쓴 `oauth2_authorized_client` 행 | 다시 로그인하면 새 행이 만들어진다. 덮이기 전 토큰은 돌아오지 않는다 |
|
||||
| 끊은 Keycloak SSO 세션 | 브라우저에서 다시 로그인한다 |
|
||||
| `oauth2_authorized_client` 표 | `drop table` 이 있지만 B-3 이 이 표를 쓰므로 평소에는 치지 않는다 |
|
||||
| JDBC 토큰 저장소 배선 자체 | 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 해야 한다 |
|
||||
|
||||
마지막 줄이 B층과 A층이 갈리는 곳이다. 이 편의 절차 안에는 소스를 고치는 단계가 없고 B-1 이 만든 구성 위에서 시작하는데, JDBC 배선을 걷어내려면 애플리케이션을 다시 빌드해야 한다. B-2 가 소스에 넣은 것이 무엇인지는 B-0 의 주입 절이 지울 목록으로 적어 두었다.
|
||||
|
||||
| B-2 가 넣은 것 | 어느 파일 |
|
||||
|---|---|
|
||||
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | `bff/pom.xml` |
|
||||
| `OAuth2AuthorizedClientService` 와 `OAuth2AuthorizedClientManager` 명시 빈 | `SecurityConfig.java` |
|
||||
| `spring.datasource` · `spring.sql.init` | `application.yml` |
|
||||
| `BFF_DB_URL` · `BFF_DB_USER` · `BFF_DB_PASSWORD` | `deploy/lab/k8s/bff-redis.yaml` |
|
||||
|
||||
그 넷을 되돌리는 명령은 B-0 의 되돌리기와 같은 한 줄이고, 소스만 되돌리면 클러스터에는 여전히 옛 이미지가 도므로 다시 빌드해 두 노드에 다시 밀어 넣는 데까지 가야 한다.
|
||||
|
||||
```bash label="[워크스테이션] 네 파일을 되돌린다"
|
||||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||||
deploy/lab/k8s/bff-redis.yaml
|
||||
```
|
||||
|
||||
```bash label="[워크스테이션 → kc-lab-1] 다시 빌드해 두 노드에 다시 넣고 다시 배포한다"
|
||||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout restart deployment/bff
|
||||
kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||
```
|
||||
|
||||
위 블록은 한 블록인데 기계가 둘이다. `docker` 로 시작하는 앞의 세 줄은 워크스테이션에서 치고, `kubectl` 로 시작하는 뒤의 세 줄은 `kc-lab-1` 에서 친다. 워크스테이션에는 kubeconfig 가 없어 거기서 `kubectl` 을 치면 클러스터에 못 붙고 끝난다. `kubectl apply` 가 읽는 `deploy/lab/k8s/bff-redis.yaml` 은 바로 위에서 `git checkout` 으로 되돌린 그 파일이므로 `kc-lab-1` 쪽 체크아웃에도 같은 내용이 있어야 하는데, 옮기는 명령은 원 가이드에 없다(unknown).
|
||||
|
||||
B-2 자신의 가이드에는 JDBC 배선을 넣는 편집 명령도 그때 친 빌드 명령도 없다(unknown). 증거에 남은 것은 배포 결과 두 줄뿐이고, 위 되돌리기는 B-0 이 적어 둔 목록과 B-1 이 적어 둔 재빌드 순서를 그대로 옮겼다.
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
시험군만 재는 측정은 측정이 아니다. 덮어쓰기를 보려면 덮어쓰이기 전의 행이 있어야 하고, 로그아웃 정리를 보려면 로그아웃 전의 세 숫자가 있어야 한다.
|
||||
|
||||
```text
|
||||
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
|
||||
```
|
||||
|
||||
### 1. BFF 두 개가 다른 노드에 있는가
|
||||
|
||||
**무엇을 보는가** — 파드 배치와 재시작 횟수.
|
||||
|
||||
```bash label="[kc-lab-1] 네임스페이스 전체를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇고 주소와 해시는 환경마다 다르다(observed).
|
||||
|
||||
```text
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||
```
|
||||
|
||||
`10.42.0.52` 와 `10.42.1.124` 는 B-5 의 증거에 남은 실제 BFF 파드 주소다. Redis 와 PostgreSQL 은 매니페스트가 `nodeSelector` 로 `kc-lab-2` 에 고정해 둔다.
|
||||
|
||||
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
|
||||
|
||||
```bash label="[kc-lab-1] 라벨로 BFF 만 본다"
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `01-jdbc-store-deploy.txt`). 위 두 줄은 JDBC 토큰 저장소를 올린 배포 명령이 같이 찍은 것이고, 이 절차는 그 배포가 끝난 뒤부터 시작한다.
|
||||
|
||||
```text
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-555df79c97-6j86w 1/1 Running 0 44s
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 22s
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `bff` 가 두 개이고 `READY` 가 둘 다 `1/1` 이어야 하며 `NODE` 가 서로 달라야 한다. 같은 노드에 몰려 있으면 다른 인스턴스가 같은 커널 위의 다른 프로세스일 뿐이고, `topologySpreadConstraints` 가 이걸 벌려 놓는다. replica 가 하나면 이 실험의 질문이 성립하지 않는다. `RESTARTS` 가 `0` 인 것도 적어 둔다. 뒤에서 이 값이 오르면 건드린 것이 엉뚱한 데 닿았다.
|
||||
|
||||
### 2. 토큰이 들어갈 테이블이 실제로 있는가
|
||||
|
||||
**무엇을 보는가** — `oauth2_authorized_client` 가 있는지. 원래 실행은 여기서 한 번 넘어졌다. 파드는 떴고 Hikari 도 붙었는데 테이블이 없었고 아무도 그것을 신고하지 않았다.
|
||||
|
||||
```bash label="[kc-lab-1] 테이블 정의를 물어본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-jdbc-store-deploy.txt`).
|
||||
|
||||
```text
|
||||
=== oauth2_authorized_client 테이블이 생겼는가 ===
|
||||
Did not find any relation named "oauth2_authorized_client".
|
||||
command terminated with exit code 1
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 이 두 줄이 나오면 아직 아무것도 저장되지 않는 상태다. 스키마 초기화가 조용히 실패했고 원인은 타입 이름 하나다. Spring Security 는 DDL 을 두 벌 번들한다.
|
||||
|
||||
| 파일 | 토큰 컬럼 타입 |
|
||||
|---|---|
|
||||
| `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
|
||||
| `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
|
||||
|
||||
기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 나고, `spring.sql.init.continue-on-error: true` 가 켜져 있으면 그 실패가 삼켜지고 파드는 정상으로 보인다. `continue-on-error` 는 없어도 되는 초기화에만 쓰는 설정인데 여기서는 없으면 안 되는 초기화였다.
|
||||
|
||||
해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언 차이" 였다가 정정됐다. Liquibase 가 아니다. 스키마를 태우는 것은 Spring Boot 의 `spring.sql.init` 이고 DDL 은 `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는 Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
|
||||
|
||||
### 3. PostgreSQL 전용 DDL 을 파일로 만들어 태운다
|
||||
|
||||
**목적** — 토큰이 들어갈 표를 만든다.
|
||||
|
||||
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 파일로 만든다. 터미널에 붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 편집기로 DDL 파일을 만든다"
|
||||
vim /tmp/oauth2-pg.sql
|
||||
```
|
||||
|
||||
```sql
|
||||
-- file: /tmp/oauth2-pg.sql
|
||||
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
|
||||
CREATE TABLE oauth2_authorized_client (
|
||||
client_registration_id varchar(100) NOT NULL,
|
||||
principal_name varchar(200) NOT NULL,
|
||||
access_token_type varchar(100) NOT NULL,
|
||||
access_token_value bytea NOT NULL,
|
||||
access_token_issued_at timestamp NOT NULL,
|
||||
access_token_expires_at timestamp NOT NULL,
|
||||
access_token_scopes varchar(1000) DEFAULT NULL,
|
||||
refresh_token_value bytea DEFAULT NULL,
|
||||
refresh_token_issued_at timestamp DEFAULT NULL,
|
||||
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (client_registration_id, principal_name)
|
||||
);
|
||||
```
|
||||
|
||||
위 DDL 은 `02-schema.txt` 의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다(observed).
|
||||
|
||||
```bash label="[kc-lab-1] ② 파일을 파드 안으로 넘겨 태운다"
|
||||
kubectl -n keycloak-lab exec -i deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `02-schema.txt`).
|
||||
|
||||
```text
|
||||
=== 적용 ===
|
||||
CREATE TABLE
|
||||
```
|
||||
|
||||
**왜 필요한가** — `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안 일어나고 오류도 안 난다. `kubectl exec` 는 stdin 을 기본으로 연결하지 않는다.
|
||||
|
||||
**문제가 생기면** — 되돌리는 명령은 있지만 평소에는 치지 않는다. B-3 이후로도 이 표를 계속 쓴다.
|
||||
|
||||
```bash label="[kc-lab-1] 표를 지운다. 평소에는 치지 않는다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
|
||||
```
|
||||
|
||||
### 4. 기본키를 눈으로 읽는다
|
||||
|
||||
**무엇을 보는가** — 이 편의 답이 박혀 있는 한 줄. 이 줄을 보기 전에는 다음으로 넘어가지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 테이블 정의를 다시 물어본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `02-schema.txt`).
|
||||
|
||||
```text
|
||||
Table "public.oauth2_authorized_client"
|
||||
Column | Type | Collation | Nullable | Default
|
||||
-------------------------+-----------------------------+-----------+----------+-------------------------
|
||||
client_registration_id | character varying(100) | | not null |
|
||||
principal_name | character varying(200) | | not null |
|
||||
access_token_type | character varying(100) | | not null |
|
||||
access_token_value | bytea | | not null |
|
||||
access_token_issued_at | timestamp without time zone | | not null |
|
||||
access_token_expires_at | timestamp without time zone | | not null |
|
||||
access_token_scopes | character varying(1000) | | | NULL::character varying
|
||||
refresh_token_value | bytea | | |
|
||||
refresh_token_issued_at | timestamp without time zone | | |
|
||||
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
|
||||
Indexes:
|
||||
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||
```
|
||||
|
||||
맨 아래 `Indexes:` 줄 하나가 답이다.
|
||||
|
||||
```text
|
||||
PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||
└── "keycloak" ──┘ └── "labuser" ──┘
|
||||
세션 id 가 없다
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든 `(keycloak, labuser)` 라는 한 행을 쓴다. B-0 에서 빈 이름인 `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 로 짐작했던 것이 테이블 정의로 확정된다. 저장소를 Redis 로 바꿔도 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.
|
||||
|
||||
### 5. 세 저장소를 세는 명령을 확정한다
|
||||
|
||||
**무엇을 보는가** — 관찰 절에서 로그아웃 전후로 견줄 숫자 셋. 다른 명령으로 재면 비교가 아니다.
|
||||
|
||||
```bash label="[kc-lab-1] ① BFF 세션 키를 접두어로 골라 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
```
|
||||
|
||||
모양은 B-1 측정과 같다(observed).
|
||||
|
||||
```text
|
||||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
```
|
||||
|
||||
`KEYS *` 대신 `--scan` 을 쓰는 것은 `KEYS` 가 Redis 를 잡아 두고 전 키를 훑기 때문이다. 그리고 `dbsize` 는 이 실험에서 부정확하다. Redis 하나를 BFF 와 B-7 의 oauth2-proxy 가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다. 접두어로 걸러 세는 쪽이 맞다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 토큰 행 수를 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ③ Keycloak 세션을 DB 쪽에서 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak \
|
||||
-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 로 보려면 이쪽이다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 관리 API 로 세는 형태"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get client-session-stats -r keycloak-patterns
|
||||
```
|
||||
|
||||
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않고, 존재와 길이만 보려면 `base64 -d | wc -c` 로 센다.
|
||||
|
||||
### 6. 브라우저로 로그인하고 토큰 경계를 읽는다
|
||||
|
||||
**무엇을 보는가** — 토큰이 이제 서버에 있는지.
|
||||
|
||||
브라우저에서 `https://app1.hyeonworks.com/` 를 열고 Keycloak 로그인을 눌러 `labuser` 로 들어간 뒤 token 경계 확인을 누른다. realm 은 `keycloak-patterns` 이고 비밀번호는 B-0 에서 그 사용자를 만들 때 정한 값이다.
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, 해설 문서 3절).
|
||||
|
||||
```json
|
||||
{"principal":"labuser",
|
||||
"accessTokenStoredOnServer":true, ← B-1 에서는 false 였다
|
||||
"refreshTokenStoredOnServer":true,
|
||||
"browserTokenCount":0}
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `accessTokenStoredOnServer` 가 `true` 다. B-1 에서는 인가된 클라이언트가 프로세스 메모리에 있어 로그인을 처리하지 않은 replica 가 답하면 아무것도 못 찾았고, 지금은 두 replica 가 같은 PostgreSQL 행을 본다. 이 값이 아직 `false` 로 나오면 표는 만들었는데 옛 세션을 쓰고 있는 것이므로 로그아웃하고 다시 로그인한다. 증거의 `b2-before-relogin.png` 가 정확히 그 상태다.
|
||||
|
||||
### 7. 대조군 행을 잡는다
|
||||
|
||||
**무엇을 보는가** — 덮어쓰이기 전의 행 수와 토큰 해시와 발급 시각.
|
||||
|
||||
```bash label="[kc-lab-1] ① 행 수와 토큰 해시와 발급 시각을 함께 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_issued_at,
|
||||
md5(access_token_value) as at_md5
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `04-overwrite-test.txt`).
|
||||
|
||||
```text
|
||||
=== [현재] 같은 사용자의 항목 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
```
|
||||
|
||||
`(1 row)` 와 `at_md5` 둘 다 적어 둔다. 토큰 값이 아니라 md5 를 보는 까닭은, 값 자체가 지금 쓸 수 있는 자격증명이라 터미널 스크롤백에 남기면 안 되기 때문이다. md5 는 같은가 다른가만 답하고 그것이 이 단계가 묻는 전부다.
|
||||
|
||||
크기도 같이 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 두 토큰의 바이트 수를 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_type,
|
||||
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, 해설 문서 3절. 이 표는 `.txt` 증거에는 없고 문서에만 있다).
|
||||
|
||||
```text
|
||||
client_registration_id | principal_name | access_token_type | at_len | rt_len
|
||||
------------------------+----------------+-------------------+--------+--------
|
||||
keycloak | labuser | Bearer | 1431 | 744
|
||||
```
|
||||
|
||||
## 주입
|
||||
|
||||
### 1. 세션을 지우고 같은 사용자로 다시 로그인시킨다
|
||||
|
||||
**목적** — 두 번째 브라우저에 해당하는 상태를 만든다. 조회 키가 `(clientRegistrationId, principalName)` 이므로 브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가다.
|
||||
|
||||
증거 `04-overwrite-test.txt` 는 실제로 한 일을 이렇게 적었다(observed).
|
||||
|
||||
```text
|
||||
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
|
||||
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
|
||||
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
|
||||
```
|
||||
|
||||
해설 문서는 처음에 두 브라우저에서라고 적었다가 측정하지 않은 것을 측정한 것처럼 적었다고 정정했다. 진짜로 두 브라우저를 쓰려면 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 되고, 결과는 같아야 하며 다르면 그게 더 중요한 발견이다.
|
||||
|
||||
지우기 전에 무엇을 지울지 눈으로 본다. 이 Redis 는 BFF 혼자 쓰는 것이 아니다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 전체 키를 한 번 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
|
||||
모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
bff:session:sessions:c63c39ee-...
|
||||
bff:session:expires:c63c39ee-...
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
```
|
||||
|
||||
`_oauth2_proxy-` 로 시작하는 키가 섞여 있으면 `FLUSHALL` 을 치면 안 된다. B-7 의 oauth2-proxy 세션까지 날아가 그쪽 실험이 오염된다. 접두어로 골라 지운다.
|
||||
|
||||
원래 실행은 스크립트를 돌렸고 아래 형태는 원 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 것이다(unknown). 후속 문서 3절이 oauth2-proxy 세션을 지울 때 쓴 것과 같은 모양이다.
|
||||
|
||||
```bash label="[kc-lab-1] ② BFF 세션만 골라 지우고 시각을 남긴다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
|
||||
| xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||
date '+%H:%M:%S 세션 삭제'
|
||||
```
|
||||
|
||||
이 줄에는 B-1 이 같은 출력에 붙였던 `tr -d '\r'` 이 없다. `redis-cli` 출력은 CR 을 달고 오므로 `xargs` 가 넘기는 키 이름이 실제 키와 안 맞을 수 있고, 그때 `del` 은 오류 없이 `(integer) 0` 을 돌려준 뒤 `date` 줄은 그대로 「세션 삭제」를 찍는다. 원 가이드의 이 줄에 그 조각이 없어 여기에도 안 넣었다(unknown).
|
||||
|
||||
**예상 결과** — 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
(integer) 2
|
||||
16:21:03 세션 삭제
|
||||
```
|
||||
|
||||
`(integer) 0` 이 나왔으면 지워진 키가 없다. 그대로 다음으로 가지 말고 주입 검증 1번을 먼저 친다 — 첫 명령에 `bff:session:*` 키가 남아 있으면 세션이 안 지워진 상태이고, 그 위에서 관찰 절을 재면 덮어쓰기가 아니라 아무 일도 안 일어난 것을 재게 된다.
|
||||
|
||||
**왜 필요한가** — 시각을 적어 둔다. 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로 새 로그인이 실제로 일어났는가를 판정한다.
|
||||
|
||||
**문제가 생기면** — `_oauth2_proxy-*` 키까지 사라졌으면 `FLUSHALL` 을 쳐서 B-7 세션까지 지웠다. 그 상태는 이 절차로 되돌릴 수 없고 B-7 쪽에서 다시 로그인해야 한다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 읽기 전에 주입이 의도한 것만 건드렸는지 먼저 본다.
|
||||
|
||||
### 1. BFF 세션만 사라지고 B-7 키는 그대로인가
|
||||
|
||||
```bash label="[kc-lab-1] 두 접두어를 따로 센다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||
```
|
||||
|
||||
첫 명령은 아무것도 안 나와야 하고 두 번째는 주입 전과 같아야 한다. 두 번째까지 비었으면 `FLUSHALL` 을 쳤다.
|
||||
|
||||
### 2. BFF 가 재시작되지 않았는가
|
||||
|
||||
```bash label="[kc-lab-1] 재시작 횟수를 본다"
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
`RESTARTS` 가 여전히 `0` 이어야 한다. 세션을 지우는 것은 BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고, 그 상태로 재면 덮어쓰기가 아니라 파드 재시작을 재게 된다.
|
||||
|
||||
### 3. 조용한 재인증이 실제로 일어났는가
|
||||
|
||||
브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고 token 경계 확인을 누른다. 로그인 화면이 뜨지 않고 그냥 들어가진다. Redis 세션은 지워졌지만 Keycloak SSO 세션은 살아 있어서, BFF 가 `/oauth2/authorization/keycloak` 으로 보내면 Keycloak 이 화면 없이 즉시 코드를 돌려주고 새 로그인 한 벌이 조용히 만들어진다. 이것이 모의 두 번째 브라우저다. 같은 조용한 재인증이 로그아웃 뒤에는 로그아웃했는데 다시 들어가진다로 보인다. 같은 성질의 양면이다.
|
||||
|
||||
## 관찰
|
||||
|
||||
### 1. 행이 늘었는가 덮어써졌는가
|
||||
|
||||
**무엇을 보는가** — 주입 전에 친 것과 똑같은 명령의 결과.
|
||||
|
||||
```bash label="[kc-lab-1] 대조군과 같은 질의를 다시 친다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select client_registration_id, principal_name, access_token_issued_at,
|
||||
md5(access_token_value) as at_md5
|
||||
from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `04-overwrite-test.txt`).
|
||||
|
||||
```text
|
||||
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
|
||||
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
|
||||
```
|
||||
|
||||
세 가지를 한꺼번에 본다.
|
||||
|
||||
| 값 | 대조군 | 지금 | 읽는 법 |
|
||||
|---|---|---|---|
|
||||
| 행 수 | `(1 row)` | `(1 row)` | INSERT 가 아니다 |
|
||||
| `at_md5` | `675af228…` | `e19a63fc…` | 내용은 바뀌었다 |
|
||||
| `issued_at` | `05:10:46` | `05:12:13` | 삭제 시각 뒤 = 새 로그인 맞다 |
|
||||
|
||||
**이 값이 뜻하는 것** — 셋 중 하나만 보면 틀린다. 행 수만 보면 아무 일도 없었다로, md5 만 보면 새 행이 생겼나로 읽힌다. UPDATE 다.
|
||||
|
||||
```text
|
||||
브라우저 A 로그인 → (keycloak, labuser) 행 생성
|
||||
브라우저 B 로그인 → 같은 행을 덮어쓴다
|
||||
└─ A 의 토큰은 사라진다
|
||||
```
|
||||
|
||||
A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다. 같은 사용자이므로 당장은 아무 증상이 없고 증상은 나중에 나온다.
|
||||
|
||||
| 언제 문제가 되는가 | |
|
||||
|---|---|
|
||||
| B 가 로그아웃하면 | A 도 같이 끊긴다. 행이 지워지므로 |
|
||||
| refresh 회전이 켜져 있으면 | A 와 B 가 같은 refresh token 을 다툰다 → B-3 |
|
||||
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
|
||||
|
||||
저장소를 바꾸면 고쳐지는가 — 안 고쳐진다. `PRIMARY KEY` 줄이 답이다.
|
||||
|
||||
```text
|
||||
InMemory → PostgreSQL → Redis → 직접 구현
|
||||
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
|
||||
```
|
||||
|
||||
고치려면 조회 키에 세션을 넣어야 하고, 그것은 저장소가 아니라 `OAuth2AuthorizedClientRepository` 쪽 이야기다.
|
||||
|
||||
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|
||||
|---|---|---|
|
||||
| `JdbcOAuth2AuthorizedClientService` | 불필요 (같은 인터페이스) | 안 고쳐짐 |
|
||||
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
|
||||
| `HttpSessionOAuth2AuthorizedClientRepository` | 필요 (Repository 로 바꿔야) | 고쳐짐 |
|
||||
|
||||
이 실험이 세 번째를 고르지 않은 것은 Q3 가 Redis 와 JDBC 중 무엇을 물었기 때문이고, 그 대가로 조회 키 문제가 풀리지 않았다. 선택이 남긴 자국을 측정한 것이지 실수가 아니다.
|
||||
|
||||
### 2. 저장된 토큰이 평문인가
|
||||
|
||||
**무엇을 보는가** — `bytea` 안에 든 것이 암호화된 덩어리인지 JWT 문자열인지.
|
||||
|
||||
값을 찍기 전에 무엇을 찍게 될지 길이로 먼저 안다.
|
||||
|
||||
```bash label="[kc-lab-1] ① refresh token 의 바이트 수만 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select length(refresh_token_value) from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
실측은 `744` 다(observed, 해설 문서 3절의 `rt_len`). 암호화된 덩어리라면 여기서 알 수 없으므로 앞 몇 글자만 본다.
|
||||
|
||||
원래 실행은 앞 200자 남짓을 통째로 찍었다. 아래 형태는 화면에 남는 양을 줄인 것이고 원 가이드가 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ② 앞 40자만 텍스트로 디코드해 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 `03-plaintext-tokens.txt` 에 있고, 원래 실행이 찍은 문자열 가운데 앞 36자만 옮긴다(observed). 그 뒤는 지금 쓸 수 있는 자격증명이라 증거 파일에만 둔다.
|
||||
|
||||
```text
|
||||
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
|
||||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `eyJ` 로 시작한다. 그것이 `{"` 의 base64 이고 JWT 는 예외 없이 이렇게 시작한다. `convert_from` 이 성공하는 것 자체가 답을 준다. 암호화된 바이트라면 UTF-8 로 디코드되지 않고 오류가 나므로, 읽힌다는 것은 텍스트라는 뜻이다.
|
||||
|
||||
정말 JWT 인지 헤더를 풀어 본다. 원 가이드는 이 줄도 미검증으로 표시한다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] ③ 첫 조각만 잘라 base64 로 푼다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
|
||||
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-plaintext-tokens.txt`).
|
||||
|
||||
```text
|
||||
=== 저장된 바이트를 그대로 디코드한 결과 ===
|
||||
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
|
||||
refresh_token 페이로드(앞부분):
|
||||
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||
|
||||
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
|
||||
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
|
||||
```
|
||||
|
||||
DB 읽기 권한만 있으면 쓸 수 있는 토큰을 얻는다. 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든 그대로 쓸 수 있다. Spring Security 기본 구현은 저장할 때 암호화하지 않으므로, 암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
|
||||
|
||||
원래 실행은 여기서 한 번 넘어졌고 증거 파일에 그 실패가 그대로 있다(observed, `03-plaintext-tokens.txt`).
|
||||
|
||||
```text
|
||||
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
|
||||
File "<string>", line 3
|
||||
h=open(/tmp/hdr.txt).read().strip()
|
||||
^
|
||||
SyntaxError: invalid syntax
|
||||
```
|
||||
|
||||
파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨렸다. 셸 안에 프로그램을 밀어 넣으면 문법 오류가 측정 결과 칸에 남는다. `cut` 과 `base64 -d` 로 충분하고 그 둘은 문법이 틀릴 곳이 없다.
|
||||
|
||||
### 3. 로그아웃하면 세 저장소가 다 정리되는가
|
||||
|
||||
**목적** — 로그아웃 전후의 세 숫자를 같은 명령으로 견준다.
|
||||
|
||||
로그아웃 전에 세 숫자를 먼저 잡는다. 주입 전에 정해 둔 명령 그대로다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 로그아웃 전 두 숫자를 잡는다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `04-overwrite-test.txt`).
|
||||
|
||||
```text
|
||||
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
|
||||
로그아웃 전
|
||||
Redis: 1 키
|
||||
PostgreSQL: 1 행
|
||||
```
|
||||
|
||||
화면에 로그아웃 버튼이 없다. `index.html` 에는 로그인과 조회 버튼만 있다. Spring Security 의 로그아웃은 CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다. 로그인된 app1 탭에서 `F12` 를 눌러 Console 로 간다. 원 가이드가 미검증으로 표시한 조각이다(unknown).
|
||||
|
||||
```js label="[브라우저 콘솔] 로그아웃을 POST 로 보낸다"
|
||||
const csrf = await (await fetch('/bff/csrf')).json();
|
||||
const token = decodeURIComponent(
|
||||
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
|
||||
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
|
||||
console.log(r.status, r.url);
|
||||
```
|
||||
|
||||
셸이 아니라 브라우저인 까닭은 세션 쿠키가 `HttpOnly` 라 `curl` 로 로그인 상태를 재현할 수 없기 때문이다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`) 그래서 이 조각이 성립한다. 해설 문서 8절은 같은 일을 form 파라미터 `_csrf` 로 적었는데 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
|
||||
|
||||
로그아웃 후 같은 세 명령을 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 로그아웃 뒤 세 숫자를 같은 명령으로 잡는다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select principal_name, access_token_issued_at, access_token_expires_at
|
||||
from oauth2_authorized_client"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `05-logout-cleanup.txt`).
|
||||
|
||||
```text
|
||||
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
|
||||
Redis 세션 : 0 키
|
||||
PostgreSQL 토큰 : 1 행
|
||||
|
||||
principal_name | access_token_issued_at | access_token_expires_at
|
||||
----------------+----------------------------+----------------------------
|
||||
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
|
||||
(1 row)
|
||||
|
||||
|
||||
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
|
||||
|
||||
=== Keycloak 쪽 SSO 세션은? ===
|
||||
Keycloak 온라인 세션: 2
|
||||
```
|
||||
|
||||
세 숫자를 나란히 놓으면 하나만 지워졌다.
|
||||
|
||||
```text
|
||||
로그아웃 후:
|
||||
Redis 세션 : 0 키 ← 정리됨
|
||||
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
|
||||
Keycloak SSO : 2 세션 ← 남아 있다
|
||||
```
|
||||
|
||||
```text
|
||||
로그아웃
|
||||
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
|
||||
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
|
||||
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
|
||||
```
|
||||
|
||||
**왜 필요한가** — 남은 행의 `access_token_expires_at` 이 `issued_at` 의 60초 뒤인 것도 같이 본다. B-0 에서 `accessTokenLifespan=60` 으로 잡았기 때문이다. access token 은 이미 만료됐는데 같은 행의 refresh token 은 아직 쓸 수 있고 그것은 평문이다.
|
||||
|
||||
브라우저에서 `https://app1.hyeonworks.com/` 를 다시 열면 로그인 화면이 안 뜨고 그냥 들어가진다. 주입 검증에서 본 것과 같은 조용한 재인증이다. 애플리케이션 세션은 지웠는데 IdP 세션은 살아 있으므로 IdP 가 화면 없이 새 세션을 만들어 주고, 사용자 입장에서는 로그아웃이 안 됐다.
|
||||
|
||||
| 필요한 것 | 방법 |
|
||||
|---|---|
|
||||
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
|
||||
| Keycloak 세션 종료 | RP-initiated logout — `OidcClientInitiatedLogoutSuccessHandler` |
|
||||
| 두 곳을 원자적으로 | 한쪽이 실패하면 어떻게 할지 — 정리 순서와 실패 처리를 정해야 한다 |
|
||||
|
||||
Q3 는 미지수 5번으로 「두 store 를 logout 에서 어떻게 한 번에 지우게 되는가」를 남겼는데, 이 실험이 그 답을 냈다 — 지금은 하나도 안 지운다.
|
||||
|
||||
**문제가 생기면** — 로그아웃 POST 가 `403` 이면 CSRF 토큰이 없거나 헤더 이름이 틀린 것이므로 `/bff/csrf` 의 `headerName` 을 그대로 쓴다. 되돌리기는 브라우저에서 다시 로그인하는 것이다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
### 1. 남은 행을 지운다
|
||||
|
||||
```bash label="[kc-lab-1] 이 사용자의 행만 지운다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"delete from oauth2_authorized_client where principal_name = 'labuser'"
|
||||
```
|
||||
|
||||
모양은 `DELETE 1` 이다(observed). 브라우저에서 다시 로그인하면 행이 다시 만들어진다. 표 자체는 지우지 않는다. B-3 이 이 표를 쓴다.
|
||||
|
||||
### 2. Keycloak SSO 세션을 사람이 끊는다
|
||||
|
||||
RP 가 로그아웃을 안 보내 주므로 사람이 직접 끊는다. 브라우저에서 아래 주소를 열고 확인 화면이 뜨면 승인한다. 이 실험은 여기까지 재지 않았다(unknown).
|
||||
|
||||
```text
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] 세션 수가 줄었는지 본다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
|
||||
`offline_flag = 0` 의 개수가 줄어드는지 본다. 관리 API 호출도 세션을 만들기 때문에 개수에는 잡음이 섞이고, 0 이 안 되어도 놀랄 일이 아니다. 지운 Redis 세션은 되돌아오지 않으며 브라우저에서 다시 로그인하는 것이 복구다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 표 | `kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
|
||||
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
|
||||
| B-7 세션 | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | 주입 전과 같아야 한다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||
| 임시 파일 | `rm -f /tmp/oauth2-pg.sql` | — |
|
||||
|
||||
JDBC 배선까지 걷어낼 것이면 전제와 되돌리기 절의 두 블록을 친다. 소스를 되돌리고 다시 빌드해 두 노드에 다시 import 한 뒤 배포해야 클러스터가 소스와 같아진다.
|
||||
|
||||
## 막히면
|
||||
|
||||
원 가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다고 적는다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 조용히 실패했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 |
|
||||
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
|
||||
| `token-boundary` 가 계속 `false` | 표는 만들었는데 옛 세션을 쓰고 있다 | 로그아웃 후 재로그인 — `b2-before-relogin.png` 가 그 상태다 |
|
||||
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec` 에 `-i` 가 없다 | `exec -i deploy/postgres` |
|
||||
| 행 수가 2 로 늘었다 | principal 이 다르다. 다른 사용자로 로그인했다 | `select principal_name from oauth2_authorized_client` |
|
||||
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
|
||||
| B-7 실험이 갑자기 깨진다 | `FLUSHALL` 을 쳤다. 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 |
|
||||
| 파이썬 한 줄로 디코드하다 `SyntaxError` | 원래 실행이 이 실수를 했다 | `cut -d. -f1 \| base64 -d` 로 충분하다 |
|
||||
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf` 의 `headerName` 을 그대로 쓴다 |
|
||||
| 로그아웃했는데 다시 들어가진다 | 버그가 아니다. Keycloak SSO 세션이 살아 있다 | RP-initiated logout 을 사람이 연다 |
|
||||
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 14:09–14:13 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 테이블이 없을 때의 `Did not find any relation ...` 와 `exit code 1`, `CREATE TABLE`, 컬럼 표 전문과 `PRIMARY KEY, btree (client_registration_id, principal_name)`, 대조군 행의 `2026-09-04 05:10:46.927192` 와 `675af2286bfc2fd9d2bab7bc8f391df7`, 재로그인 뒤의 `2026-09-04 05:12:13.018828` 와 `e19a63fc5aa18bd0a68b3e19dff16b3b` 와 `(1 row)`, `at_len 1431` 과 `rt_len 744`, 디코드한 두 JWT 헤더와 페이로드 앞부분, 로그아웃 뒤 `Redis 0 키 · PostgreSQL 1 행 · Keycloak 온라인 세션 2`, `access_token_expires_at` 이 `issued_at` 의 60초 뒤인 것.
|
||||
- (unknown) Keycloak 세션을 DB 쪽에서 세는 질의, `--scan | xargs ... redis-cli del` 로 BFF 세션만 지우는 줄, `left(convert_from(...), 40)` 으로 앞 40자만 찍는 줄, `cut` 과 `tr` 과 `base64 -d` 로 헤더를 푸는 줄, 브라우저 콘솔의 로그아웃 조각, RP-initiated logout 주소. 원 가이드가 전부 미검증으로 표시했다.
|
||||
- 원래 실행과 다르게 적은 곳 — refresh token 값은 증거 파일에 200자 남짓이 있지만 여기에는 앞 36자만 옮겼다. 나머지는 지금 쓸 수 있는 자격증명이라 옮기지 않는다. `at_md5` 두 개는 해시라 그대로 적었다.
|
||||
- (observed) 파이썬 한 줄로 JWT 헤더를 디코드하려다 난 `SyntaxError` 도 증거 파일에 있다. 그 시도가 깨진 뒤 `cut` 과 `base64 -d` 로 다시 받았다.
|
||||
- 스크린샷으로는 판정하지 못한다 — `b2-tokens-shared-across-instances.png` 는 B-0 의 `b0-bff-token-boundary.png` 와 동일 파일이다(md5 `9ed00537…`). 두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다. 증명은 표가 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다.
|
||||
- 이 실험이 재지 않은 것 — 진짜 두 브라우저를 열어 같은 결과가 나오는지는 재지 않았다. 세션을 지우고 다시 로그인하는 것이 등가인 까닭은 조회 키가 같기 때문이라는 추론이고 측정이 아니다. RP-initiated logout 을 열었을 때 세션 수가 실제로 줄어드는지도 재지 않았다.
|
||||
- 이 편의 절차에는 소스를 고치는 단계가 없다(unknown). JDBC 토큰 저장소를 넣은 편집과 빌드는 증거에 배포 결과 두 줄로만 남았고, 되돌리기 절의 파일 목록은 B-0 이 적어 둔 지울 목록에서 가져왔다.
|
||||
|
||||
<!-- body:end -->
|
||||
+719
@@ -0,0 +1,719 @@
|
||||
---
|
||||
id: 405c4206-9b59-491f-aed1-8b97cfd9f584
|
||||
kind: SETUP
|
||||
slug: reproduce-b3-refresh-contention
|
||||
title: 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/405c4206-9b59-491f-aed1-8b97cfd9f584/edit"
|
||||
pinnedVersions:
|
||||
- name: curlimages/curl
|
||||
version: 8.11.1
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
|
||||
|
||||
같은 refresh token 다섯 개를 동시에 던져 회전 경쟁을 만들고, 이긴 요청이 받은 토큰과 그 세션의 client session 을 세는 절차다. BFF 를 거치지 않고 토큰 엔드포인트를 직접 치며, 되돌리기는 realm 설정 한 줄이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다**
|
||||
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 무엇이 부서졌는지는 그쪽이 적는다.
|
||||
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
|
||||
두 replica 가 같은 행을 본다는 것이 이 경쟁의 전제다. 그 전제를 만든 편이다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
다섯이 전부 `200` 일 때 그것이 「경쟁이 없었다」인지 「주입이 안 걸렸다」인지를 가르는 기준이다.
|
||||
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
|
||||
먼저 해 둬야 하는 편이다. 토큰이 공유되어야 경쟁이 성립한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. 토큰을 주고받는 `curl` 만 탐침 파드 안에서 치는데, Keycloak 이미지에 `curl` 도 `wget` 도 없기 때문이다(`exit 127`). 브라우저는 필요 없다 — direct grant(`grant_type=password`)로 토큰을 만들므로 전 구간이 터미널에서 끝난다.
|
||||
|
||||
터미널은 둘을 연다. 하나는 탐침 파드 셸을 붙잡고 있고, 다른 하나로 데이터베이스를 뒤진다. 파드 안에서 잡은 `RT` 와 `SID` 는 파드 밖으로 따라가지 않는다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| realm · 사용자 · 클라이언트 | `keycloak-patterns` · `labuser` / `labpass` · `bff-confidential` |
|
||||
| 주입 수단 | `revokeRefreshToken=true` — realm 전체에 걸린다 |
|
||||
| 탐침 파드 | `b3-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
|
||||
| 치는 곳 | 토큰 엔드포인트를 직접. BFF 를 거치지 않는다 |
|
||||
| 동시성 | 다섯. 셸의 `&` 와 `wait` 으로 만든다 |
|
||||
| 전 구간 | 약 20분 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `sed` 로 자른다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
B-2 가 토큰을 PostgreSQL 로 옮겼고 두 replica 가 같은 행을 본다. 조회 키에 세션 id 가 없으니 같은 사용자의 두 브라우저도 같은 행을 본다. 그 행에는 refresh token 이 하나 들어 있다. 둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.
|
||||
|
||||
통념은 하나가 성공하고 하나가 실패하며, 실패한 쪽은 새 토큰을 다시 읽어 재시도하면 된다고 본다. 이 절차는 진짜 그런지와, 이긴 쪽은 멀쩡한지를 잰다.
|
||||
|
||||
```text
|
||||
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
|
||||
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
|
||||
```
|
||||
|
||||
그래서 재야 할 것은 몇 개가 성공했나가 아니라 **이긴 요청의 토큰을 다시 쓸 수 있나**다.
|
||||
|
||||
재사용 탐지(reuse detection)가 배경에 있다. 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화하는데, 무효화된 옛 토큰이 다시 들어오면 두 가지 중 하나다.
|
||||
|
||||
```text
|
||||
① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
|
||||
② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
|
||||
```
|
||||
|
||||
서버는 둘을 구별할 수 없다. 그래서 OAuth 2.0 보안 권고는 안전한 쪽으로 가정하고 세션 전체를 무효화하라고 말한다. 이 절차가 보는 파괴는 버그가 아니라 규격이 시키는 대로 동작한 결과이고, 그래서 답이 「고쳐 달라」가 아니라 「겹치지 않게 하라」가 된다.
|
||||
|
||||
끝까지 밟으면 다섯 중 하나만 `200` 이고 나머지가 `400` 인 것, 오류 문구가 두 종류인 것, 이긴 요청이 받은 토큰조차 못 쓰는 것, user session 은 남고 client session 만 사라진 것, `refreshTokenMaxReuse` 를 올려도 안 되는 것을 자기 화면에서 보게 된다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` · `06-observability` 가 끝나 있다.
|
||||
- **B-2 가 끝나 있다.** 토큰이 공유되어야 경쟁이 성립한다. 다만 이 절차는 Keycloak 쪽 동작만 갈라 보려고 BFF 를 거치지 않고 토큰 엔드포인트를 직접 친다.
|
||||
|
||||
**realm 설정을 바꾸는 실험이다.** `revokeRefreshToken` 을 켜면 realm 전체에 걸리고, 같은 realm 을 쓰는 다른 작업이 영향을 받는다. B-2 의 BFF 로그인도 그 안에 든다. 실험대에서만 하고, 중간에 그만두려면 아래 한 줄이면 된다.
|
||||
|
||||
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
시험군만 재는 측정은 측정이 아니다. 회전이 꺼진 상태에서 같은 명령을 먼저 돌려 두어야, 나중에 나오는 `400` 이 원래 그런 것인지 내가 켠 것 때문인지 갈린다.
|
||||
|
||||
```text
|
||||
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
|
||||
```
|
||||
|
||||
### 1. Keycloak 이 둘 다 Ready 인가
|
||||
|
||||
**무엇을 보는가** — 파드 셋의 상태와 배치.
|
||||
|
||||
```bash label="[kc-lab-1] 파드 배치를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
|
||||
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — Keycloak 이 둘 다 `1/1` 이어야 한다. 하나가 NotReady 면 Service 가 요청을 전부 한쪽으로 보내고, 그러면 동시성이 한 노드 안에서만 생긴다. 재현은 되지만 replica 를 넘는 경쟁이라고 말할 수 없게 된다.
|
||||
|
||||
### 2. kcadm 에 로그인해 둔다
|
||||
|
||||
**목적** — realm 설정을 읽고 바꾸는 명령을 쓸 수 있게 한다.
|
||||
|
||||
① 파드 안에서 관리 세션을 만든다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 안 남는다.
|
||||
|
||||
```bash label="[kc-lab-1] kcadm 관리 세션을 만든다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
**예상 결과** — 성공하면 아무것도 안 나온다.
|
||||
|
||||
**왜 필요한가** — 한 번 하면 파드 안에 세션이 남아 뒤의 `get` · `update` 가 전부 그것을 쓴다.
|
||||
|
||||
**문제가 생기면** — 비밀번호가 실제로 있는지는 값이 아니라 길이로 본다.
|
||||
|
||||
```bash label="[kc-lab-1] 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
### 3. realm 의 세 값을 읽는다
|
||||
|
||||
**무엇을 보는가** — 주입이 건드릴 스위치와 건드리지 않을 값.
|
||||
|
||||
```bash label="[kc-lab-1] realm 의 세 값을 읽는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed).
|
||||
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
| 값 | 뜻 | 지금 |
|
||||
|---|---|---|
|
||||
| `revokeRefreshToken` | 회전 스위치 | `false` — 꺼져 있다 |
|
||||
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
|
||||
| `accessTokenLifespan` | access token 수명(초) | `60` |
|
||||
|
||||
**이 값이 뜻하는 것** — 기본값은 회전이 꺼져 있다. 「회전과 재사용 허용 0회를 쓰는 realm」이 재려는 상태이므로, 그 상태를 만드는 것이 이 절차의 주입이다. 지금 그대로 재면 다른 것을 재게 된다. `accessTokenLifespan=60` 은 B-0 이 이 실험을 위해 넣어 둔 값이고, 만료를 기다리는 시간이 짧아야 재현이 된다. 이 값은 주입이 끝난 뒤에도 `60` 이어야 한다.
|
||||
|
||||
### 4. 상주 탐침 파드를 띄운다
|
||||
|
||||
**목적** — 발급받은 토큰을 다음 단계로 넘길 수 있는 셸을 만든다.
|
||||
|
||||
`--rm` 임시 파드는 매번 만들고 지우므로 토큰을 단계 사이로 못 넘긴다. 이 실험은 앞 단계에서 받은 토큰을 뒤 단계에서 써야 하므로 파드를 하나 띄워 두고 `exec` 로 이어간다.
|
||||
|
||||
**중간에 그만뒀다가 다시 시작하는 것이면 먼저 지운다.** `b3-probe` 라는 이름이 이미 있으면 아래 `run` 은 그 이름이 이미 있다며 거절하고, 남아 있는 파드가 들고 있는 `KC` 와 `CS` 는 지난번에 넣은 값이다. 지우는 명령은 이 절 끝 「문제가 생기면」에 있다.
|
||||
|
||||
① 파드를 띄우고 Ready 까지 기다린다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 상주 탐침 파드를 띄운다"
|
||||
kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
|
||||
--env="CS=$(kubectl -n keycloak-lab get secret bff-secrets \
|
||||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
|
||||
```
|
||||
|
||||
② 환경변수가 들어갔는지 값이 아니라 길이로 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 넘어간 값의 길이를 센다"
|
||||
kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
|
||||
```
|
||||
|
||||
③ 파드 셸로 들어간다. 프롬프트가 `/ $` 로 바뀐다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 파드 셸로 들어간다"
|
||||
kubectl -n keycloak-lab exec -it b3-probe -- sh
|
||||
```
|
||||
|
||||
**예상 결과** — ①은 `pod/b3-probe condition met`, ②는 아래 모양이다(observed).
|
||||
|
||||
```text
|
||||
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
|
||||
```
|
||||
|
||||
**왜 필요한가** — 요청이 Service 로 간다. A-1·A-2 는 어느 노드가 답했나가 질문이라 파드 IP 로 직접 쳤지만, 여기는 replica 를 넘는 경쟁이 질문이므로 Service 가 요청을 흩는 것이 오히려 필요한 조건이다. `--rm` 이 없으므로 `exit` 해도 파드는 안 지워지고, 지우는 명령은 복구 절에 있다.
|
||||
|
||||
**문제가 생기면** — `CS길이=0` 이면 `--env` 가 빈 값을 넘겼다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
```bash label="[kc-lab-1] 탐침 파드를 지운다"
|
||||
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
### 5. 토큰을 하나 받는다
|
||||
|
||||
**무엇을 보는가** — 응답에 무엇이 들어 있는지. 나중에 걸러 보려면 먼저 통째로 봐야 한다.
|
||||
|
||||
```sh label="[탐침 파드] ① 응답을 통째로 본다"
|
||||
curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
|
||||
```
|
||||
|
||||
**어디를 보나** — 한 줄 JSON 이 나온다(모양은 observed).
|
||||
|
||||
```json
|
||||
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
|
||||
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
|
||||
```
|
||||
|
||||
`expires_in` 이 60 이다 — 앞에서 읽은 `accessTokenLifespan` 그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름이나 비밀번호다.
|
||||
|
||||
**다음에 쓸 값을 변수에 담는다.** 원래 실행도 이 형태였다(observed).
|
||||
|
||||
```sh label="[탐침 파드] ② 변수에 담고 sid 를 뽑는다"
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
|
||||
실측은 토큰 길이 `811`, jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c`, sid `BvFiB01Rntz1FcLdf7zG4BNt` 다(observed).
|
||||
|
||||
**`SID` 를 종이에 적어 둔다.** 관찰 절에서 데이터베이스를 뒤질 때 이 값이 필요하고, 그때는 파드 밖이라 변수가 안 넘어간다.
|
||||
|
||||
`sid` 가 빈 줄로 나오면 base64 패딩이나 base64url 문자(`-` `_`) 때문이다. 위 ②는 패딩만 채우고 아래 줄은 base64url 문자만 바꾸므로, 둘 중 하나씩만 고치는 셈이다. 둘을 한 줄에 같이 넣은 형태는 원본 가이드에 없다(unknown). 아래 형태로 페이로드 전체를 찍고 그 안에서 `"sid"` 를 눈으로 찾아 손으로 옮기는 것이 이 문서에 있는 방법이다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```sh label="[탐침 파드] sid 가 안 나올 때 페이로드를 통째로 찍는다"
|
||||
echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
|
||||
### 6. 대조군 하나 — 순차로 다섯 번 갱신한다
|
||||
|
||||
**무엇을 보는가** — 겹치지 않으면 무슨 일이 일어나는지. 파드 안에서 `&` 없이 친다.
|
||||
|
||||
```sh label="[탐침 파드] 순차로 다섯 번 갱신한다"
|
||||
for i in 1 2 3 4 5; do
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT")
|
||||
echo "순차 $i: $(echo "$R" | tail -1)"
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
done
|
||||
```
|
||||
|
||||
**어디를 보나** — 다섯 줄 전부 `200` 이어야 한다. 증거 파일에는 순차 실행 기록이 없고(unknown), 해설 문서가 순차 실행이면 재현되지 않는다고 말한다. 따라 하는 사람이 자기 손으로 확인하는 순서다.
|
||||
|
||||
**이 값이 뜻하는 것** — 루프가 갱신마다 `RT` 를 다시 담는다. 회전이 켜지면 옛 것을 계속 쓸 수 없고, 그대로 두면 뒤에 나오는 `400` 이 경쟁 때문인지 옛 토큰을 썼기 때문인지 갈리지 않는다. 이 실험에서 가장 흔한 자기오염이다.
|
||||
|
||||
### 7. 대조군 둘 — 경쟁을 겪지 않은 세션의 모양
|
||||
|
||||
**무엇을 보는가** — 정상 세션의 `client_sessions` 가 몇인가. 파드 밖에서 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 정상 세션의 client session 을 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `03-client-session-removed.txt`).
|
||||
|
||||
```text
|
||||
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||||
새 sid: JT-XuepgutWcE273QwAnIXta
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
JT-XuepgutWcE273QwAnIXta | 1
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `client_sessions = 1` 이 정상 세션의 모양이다. 위 질의의 sid 는 원래 실행의 값이므로 따라 하는 사람은 자기 `SID` 를 넣는다. 안 바꾸고 치면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 이 대조군이 없으면 나중에 나오는 `0` 이 경쟁 때문인지 원래 그런 표인지 모른다.
|
||||
|
||||
user session 과 client session 은 서로 다르다.
|
||||
|
||||
```text
|
||||
user session "이 브라우저는 labuser 로 로그인함"
|
||||
├─ client session : bff-confidential
|
||||
└─ client session : oauth2-proxy
|
||||
```
|
||||
|
||||
사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 user session 하나 아래에 client session 이 여럿 달린다. 그게 SSO 다. 재사용 탐지는 이 중 client session 만 제거한다. 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데, `offline_flag` 열이 그것을 가른다 — 위 출력의 `offline_flag = 0` 이 온라인 세션이다.
|
||||
|
||||
## 주입
|
||||
|
||||
**목적** — 회전과 재사용 허용 0회를 켠다.
|
||||
|
||||
`revokeRefreshToken` 이 회전 스위치다. 이름이 회전(rotation)이 아니라 취소(revoke)인데, 켜면 새 토큰을 줄 때 옛 토큰을 무효화하고 그 결과가 회전이다.
|
||||
|
||||
| 설정 | 뜻 |
|
||||
|---|---|
|
||||
| `revokeRefreshToken` | 회전 스위치. 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
|
||||
| `refreshTokenMaxReuse` | 그 위에서 몇 번까지 봐줄 것인가 |
|
||||
|
||||
`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다. 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니 봐줄 횟수를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색으로 보이는 까닭도 거기 있다.
|
||||
|
||||
① 회전을 켜고 시각을 남긴다.
|
||||
|
||||
```bash label="[kc-lab-1] 회전을 켜고 시각을 남긴다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 켬'
|
||||
```
|
||||
|
||||
**예상 결과** — 성공하면 아무 말도 안 하고 시각만 찍힌다(모양은 observed).
|
||||
|
||||
```text
|
||||
14:16:12 회전 켬
|
||||
```
|
||||
|
||||
**왜 필요한가** — 관찰 절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다. 켜기 전에 발급한 토큰으로 재면 발급 시점의 정책이 아니라 검증 시점의 정책이 적용되어 섞이고, 그러면 해석이 안 된다.
|
||||
|
||||
**문제가 생기면** — 주입 검증으로 넘어가 설정을 다시 읽는다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 설정은 똑같은 명령으로 다시 읽는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① realm 을 똑같은 명령으로 다시 읽는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
|
||||
모양은 이렇다(observed).
|
||||
|
||||
```json
|
||||
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
`revokeRefreshToken` 이 `true` 여야 한다. `false` 그대로면 `update` 가 다른 realm 에 갔거나 kcadm 세션이 만료됐다. kcadm 은 실패해도 조용할 때가 있어 반드시 다시 읽어서 확인한다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 재시작이 올랐는지 본다"
|
||||
kubectl -n keycloak-lab get pods -l app=keycloak
|
||||
```
|
||||
|
||||
`RESTARTS` 가 여전히 0 이어야 한다. realm 설정 변경은 재시작을 일으키지 않으므로, 여기서 재시작이 올랐다면 다른 것을 건드렸다. 그 상태로 재면 경쟁이 아니라 재시작을 재게 된다.
|
||||
|
||||
**동시성을 넣기 전에 회전 자체가 도는지 확인한다.** 새 토큰을 하나 받고 한 번 갱신한 뒤 옛 것을 다시 쓴다. 가이드는 이 단계를 증거 파일에 없는 사전 확인이라고 적는다(unknown).
|
||||
|
||||
```sh label="[탐침 파드] ③ 회전이 실제로 도는지 두 번 쳐서 본다"
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
|
||||
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
```
|
||||
|
||||
1회차 `200`, 2회차 `400` 이다. 옛 토큰이 무효화된다는 것이 회전이 켜졌다는 뜻이다. 2회차도 `200` 이면 회전이 안 켜진 것이고, 그 상태로 관찰 절을 돌리면 다섯 개가 전부 `200` 으로 나온다 — 그건 경쟁이 없었다는 뜻이 아니라 주입이 안 걸렸다는 뜻이다.
|
||||
|
||||
## 관찰
|
||||
|
||||
사전 확인에서 쓴 토큰은 이미 무효다. 깨끗한 토큰을 하나 새로 받고 `SID` 를 다시 적어 둔다.
|
||||
|
||||
```sh label="[탐침 파드] ① 깨끗한 토큰을 새로 받는다"
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
|
||||
**동시에 다섯 개를 던진다.** 원래 실행은 스크립트였고, 아래는 가이드가 손으로 치기 좋게 고쳐 미검증으로 표시한 형태다(unknown). 본문과 응답 코드를 파일로 갈라 순서대로 다시 읽게 했다.
|
||||
|
||||
```sh label="[탐침 파드] ② 같은 토큰으로 동시에 다섯 번 갱신한다"
|
||||
i=1
|
||||
while [ $i -le 5 ]; do
|
||||
( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
|
||||
i=$((i+1))
|
||||
done
|
||||
wait
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
|
||||
done
|
||||
```
|
||||
|
||||
셸 문법 세 조각이 전부다.
|
||||
|
||||
```text
|
||||
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
|
||||
wait 띄운 것이 전부 끝날 때까지 기다린다
|
||||
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
|
||||
```
|
||||
|
||||
`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다. `wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다. 다섯 개가 같은 터미널에 동시에 쓰면 어느 줄이 어느 요청인지 알 수 없어서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
|
||||
|
||||
실측은 이렇다(observed, `01-concurrent-refresh.txt`).
|
||||
|
||||
```text
|
||||
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
|
||||
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
|
||||
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 5: HTTP 200 {"access_token":"...(발급됨)
|
||||
```
|
||||
|
||||
성공 개수가 아니라 오류 메시지가 두 종류인 것을 본다.
|
||||
|
||||
| 메시지 | 뜻 |
|
||||
|---|---|
|
||||
| `Maximum allowed refresh token reuse exceeded` | 재사용 탐지가 발동 |
|
||||
| `Session doesn't have required client` | 그 여파 — client session 이 이미 없다 |
|
||||
|
||||
하나만 이기고 나머지가 진 것이라면 지는 쪽 메시지가 전부 같아야 한다. 두 종류라는 것은 중간에 상태가 바뀌었다는 뜻이다. 성공한 번호는 환경마다 다르고 증거에서는 5번이었지만 순서는 스케줄링이 정한다 — 몇 번이 이겼는가는 아무 의미가 없다.
|
||||
|
||||
**이긴 요청의 토큰을 다시 써 본다.** 여기서 진짜 답이 나온다.
|
||||
|
||||
```sh label="[탐침 파드] ③ 이긴 요청이 받은 토큰을 꺼내 다시 쓴다"
|
||||
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
|
||||
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo "새 refresh token 길이: ${#NEW}"
|
||||
|
||||
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$NEW"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||||
|
||||
```text
|
||||
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||||
새 refresh token 길이: 810
|
||||
그 토큰으로 다시 갱신: HTTP 400
|
||||
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
```
|
||||
|
||||
이긴 요청조차 쓸 수 없는 토큰을 받았다.
|
||||
|
||||
```text
|
||||
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
|
||||
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
|
||||
```
|
||||
|
||||
오류가 지연되어 나타난다. `200` 을 받은 코드는 성공했다고 믿고 토큰을 저장하고, 끊긴 것은 그다음 요청에서 안다. 로그를 볼 때 원인 시각과 증상 시각이 어긋나 보이는 까닭도 여기 있다. 재시도하면 되지 않나가 여기서 무너진다 — 새 토큰을 다시 읽어 재시도해도 그 토큰이 이미 무효라 재시도할 대상이 없다.
|
||||
|
||||
**무엇이 사라졌는지는 데이터베이스가 말한다.** 파드 밖에서 치고, sid 는 앞에서 적어 둔 값을 넣는다.
|
||||
|
||||
**아래 두 블록에 박힌 `'BvFiB01Rntz1FcLdf7zG4BNt'` 를 자기 `SID` 로 바꾼다.** 그것은 원래 실행의 sid 라, 그대로 붙여넣으면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 두 블록 모두 바꿔야 한다 — 한쪽만 바꾸면 두 출력이 서로 다른 세션을 말한다. 출력은 마지막 줄부터 읽는다. `(1 row)` 면 그 sid 의 세션을 찾았고, `(0 rows)` 면 sid 를 안 바꿨거나 다른 값을 넣었다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 그 sid 의 세션이 남아 있는가"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag, us.last_session_refresh
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||||
|
||||
```text
|
||||
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
|
||||
user_session_id | offline_flag | last_session_refresh
|
||||
--------------------------+--------------+----------------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
|
||||
(1 row)
|
||||
```
|
||||
|
||||
행이 있다. 세션이 통째로 지워진 것이 아니다. 그러면 왜 `Session doesn't have required client` 인가 — client session 을 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑤ 같은 sid 의 client session 을 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-client-session-removed.txt`).
|
||||
|
||||
```text
|
||||
=== user session 과 client session 을 나눠서 본다 ===
|
||||
user_session_id | offline_flag | client_sessions
|
||||
--------------------------+--------------+-----------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**`(0 rows)` 와 위 출력의 `client_sessions 0` 은 다른 답이다.** 앞은 그 sid 의 행을 아예 못 찾았다는 뜻이고, 뒤는 행을 찾았는데 그 안의 개수가 0 이다. 화면에서 `0` 두 개가 비슷해 보이지만 판정은 뒤에서만 나온다.
|
||||
|
||||
`client_sessions = 0` 이고 대조군은 `1` 이었다. 같은 명령에 다른 결과가 나온 것이 이 실험의 판정이다.
|
||||
|
||||
```text
|
||||
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||||
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
|
||||
```
|
||||
|
||||
오류 문구가 정확히 그 말을 한다 — 세션은 있는데 그 클라이언트 몫이 없다. 메시지를 오해해서 세션이 만료됐다로 읽으면 엉뚱한 곳을 고치게 된다.
|
||||
|
||||
폐기 목록에 실린 것도 아니다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑥ 폐기 목록을 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select count(*) as revoked_count from revoked_token"
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||||
|
||||
```text
|
||||
=== [5] revoked_token 테이블 ===
|
||||
revoked_count
|
||||
---------------
|
||||
0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
`0` 이다. 토큰을 블랙리스트에 올려서 막은 것이 아니라 client session 이 사라져서 검증할 대상이 없어졌다. 토큰을 지우는 방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 에 대한 모든 토큰이 한꺼번에 죽는다.
|
||||
|
||||
왜 이긴 쪽도 죽는지는 시간선이 말한다.
|
||||
|
||||
```text
|
||||
t0 5개가 동시에 도착
|
||||
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||||
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||||
t3 ★ client session 제거
|
||||
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||||
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||||
```
|
||||
|
||||
t3 와 t4 의 순서가 전부다. 응답을 만들던 요청은 이미 성공이 확정된 상태로 나가고, 그 사이 바닥이 빠진다.
|
||||
|
||||
**정책을 바꿔 두 번 더 잰다.** 한 번 더 재기 전에 세션을 새로 만든다 — 파괴된 세션으로 재면 전부 `400` 이다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑦ 구성 B — 회전을 끈다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false
|
||||
```
|
||||
|
||||
관찰 절의 ① 새 토큰 발급 → ② 동시 다섯 개 → ③ 이긴 토큰 재사용 → ⑤ client session 세기를 그 순서대로 다시 친다. ①·②·③ 은 탐침 파드 셸 안에서 치고 ⑤ 는 파드 밖에서 친다. 스크롤백을 되돌려 치는 것으로는 안 된다 — ①이 새 `SID` 를 만들고, ⑤의 질의에는 방금 만든 그 값을 넣어야 한다.
|
||||
|
||||
```text
|
||||
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
|
||||
sid=iW1CGyO7COdyJLryIrCt3njk
|
||||
1: 200
|
||||
2: 200
|
||||
3: 200
|
||||
4: 200
|
||||
5: 200
|
||||
성공 5 / 5
|
||||
이긴 토큰 재사용: HTTP 200
|
||||
남은 client_session: 1
|
||||
```
|
||||
|
||||
전부 `200` 이고 세션도 멀쩡하다(observed, `04-policy-comparison.txt`). 같은 refresh token 을 계속 쓸 수 있으므로 경쟁 자체가 성립하지 않는다. 대신 잃는 것이 있다 — 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전의 목적이 그 창을 좁히는 것이었다.
|
||||
|
||||
```bash label="[kc-lab-1] ⑧ 구성 C — 회전을 켜고 재사용 1회를 허용한다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
|
||||
```
|
||||
|
||||
```text
|
||||
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
|
||||
sid=72c04JCdr0NpCHGQmXWW2wM8
|
||||
1: 200
|
||||
2: 400 "error_description":"Session doesn't have required client"
|
||||
3: 200
|
||||
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
|
||||
5: 400 "error_description":"Session doesn't have required client"
|
||||
성공 2 / 5
|
||||
이긴 토큰 재사용: HTTP 400
|
||||
남은 client_session: 0
|
||||
```
|
||||
|
||||
성공이 1에서 2로 늘었지만 `남은 client_session: 0` 은 그대로다(observed, 같은 파일).
|
||||
|
||||
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|
||||
|---|---|---|---|
|
||||
| A 회전 ON · maxReuse=0 | 1 / 5 | 400 | 0 — 파괴 |
|
||||
| B 회전 OFF | 5 / 5 | 200 | 1 — 생존 |
|
||||
| C 회전 ON · maxReuse=1 | 2 / 5 | 400 | 0 — 파괴 |
|
||||
|
||||
`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다. 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 회전의 보안 목적이 사라진다. 값을 올려 버티려는 시도는 몇 개까지 동시에 올 것인가를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
|
||||
|
||||
그래서 답은 잠금이고, 잠금은 저장소 쪽에 있어야 한다 — 프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| PostgreSQL 행 잠금 | `SELECT ... FOR UPDATE` — A-0 에서 Keycloak 자신이 쓰는 방식 |
|
||||
| Redis 분산 잠금 | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||||
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||||
|
||||
데이터베이스 잠금은 잠금의 수명이 연결의 수명과 묶인다. 프로세스가 죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis 잠금은 TTL 이 짧으면 중복 갱신, 길면 정지이고, 그 약점은 B-5 에서 다시 만난다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
### 1. realm 설정을 되돌린다
|
||||
|
||||
**목적** — 같은 realm 을 쓰는 다른 작업이 회전을 물려받지 않게 한다.
|
||||
|
||||
① 두 값을 한 번에 되돌리고 시각을 남긴다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 회전을 끄고 시각을 남긴다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 끔'
|
||||
```
|
||||
|
||||
② 똑같은 명령으로 다시 읽는다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 되돌아갔는지 다시 읽는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
|
||||
**예상 결과** — 주입 전에 읽은 세 값과 전부 같아야 한다(모양은 observed).
|
||||
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**왜 필요한가** — 구성 C 에서 `refreshTokenMaxReuse` 를 `1` 로 올렸으므로 그것까지 같이 되돌려야 한다. `accessTokenLifespan` 이 60 이 아니면 다른 것도 건드렸다.
|
||||
|
||||
**문제가 생기면** — kcadm 세션이 만료됐을 수 있다. `config credentials` 를 다시 친다.
|
||||
|
||||
### 2. 탐침 파드를 지우고 세션을 정리한다
|
||||
|
||||
**목적** — 실험 도구를 치우고 파괴된 세션을 남기지 않는다.
|
||||
|
||||
① 파드를 직접 지운다. `--rm` 이 없으므로 자동으로 사라지지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 탐침 파드를 지운다"
|
||||
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
② 파괴된 세션의 행은 TTL 로 스스로 사라진다. 바로 치우고 싶으면 브라우저에서 아래를 연다. 이 실험은 여기까지 재지 않았다(unknown).
|
||||
|
||||
```text
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||
```
|
||||
|
||||
③ 세션 수를 센다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 세션 수를 센다"
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
|
||||
**예상 결과** — 관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다. `0` 이 안 되어도 놀랄 일이 아니다.
|
||||
|
||||
**왜 필요한가** — 다음 실험에서 `b3-probe` 이름이 이미 있다고 거절당하는 것을 막는다.
|
||||
|
||||
**문제가 생기면** — 파드가 `Completed` 로 남아 있으면 같은 `delete` 를 다시 친다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| realm | 위 `get realms/...` | `revokeRefreshToken : false` |
|
||||
| 파드 | `kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 탐침 | `kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
|
||||
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
|
||||
|
||||
## 막히면
|
||||
|
||||
가이드는 이 표를 두고 전부 이 실험대가 실제로 겪은 증상이거나 그 기록에서 곧바로 따라 나오는 것이라고 적는다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| 다섯 개가 전부 `200` | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &` 와 `wait` 이 있는지 |
|
||||
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 주입 검증을 다시. 사전 확인이 `200/400` 이어야 한다 |
|
||||
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
|
||||
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 |
|
||||
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 |
|
||||
| `CS길이=0` | secret 이름이나 키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
|
||||
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
|
||||
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 |
|
||||
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 |
|
||||
| 데이터베이스 질의에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
|
||||
| 파드 셸에 다시 들어갔더니 변수가 없다 | 그 `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 |
|
||||
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 realm 을 되돌린다 |
|
||||
|
||||
부하 도구가 없는 것도 설계다. 동시성 5는 `ab` 도 `k6` 도 필요 없고 셸의 `&` 와 `wait` 이면 충분하며, 그 편이 무엇이 일어났는지 더 잘 보인다 — 요청 다섯 개의 본문을 전부 파일로 갖고 있으니 나중에 다시 읽는다. 부하 도구는 개수를 늘려야 할 때 쓴다. 이 절차가 묻는 것은 개수가 아니라 겹치면 무엇이 부서지는가이고, 그건 둘만 겹쳐도 답이 나온다. 다섯 개를 쓴 것은 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 14:16–14:17 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 주입 전 realm 의 세 값 `{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }`, 토큰 길이 `811` 과 jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c` 와 sid `BvFiB01Rntz1FcLdf7zG4BNt`, 동시 다섯 요청의 상태 코드와 오류 문구 두 종류, 이긴 토큰의 길이 `810` 과 그 토큰으로 다시 갱신했을 때의 `HTTP 400`, 그 sid 의 `offline_flag 0` · `last_session_refresh 1788498996` · `client_sessions 0`, 대조군 세션 `JT-XuepgutWcE273QwAnIXta` 의 `client_sessions 1`, `revoked_count 0`, 구성 B·C 의 sid 와 다섯 코드와 `남은 client_session` 값, `CS길이=15`.
|
||||
- (unknown) `&` 와 `wait` 으로 다섯을 동시에 띄우는 while 루프, sid 를 base64url 로 다시 푸는 줄, 순차 다섯 번 갱신 루프, 회전이 도는지 보는 사전 확인 두 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트로 했다. 순차 실행은 증거 파일에 기록 자체가 없다.
|
||||
- 이 실험이 재지 않은 것 — BFF 를 거쳐 같은 경쟁이 나는지는 재지 않았다. 여기서는 Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤다. RP-initiated logout 으로 파괴된 세션을 치우는 것도, 동시성을 5보다 늘리면 어떻게 되는지도 재지 않았다.
|
||||
- 추론이지 측정이 아닌 것 — `refreshTokenMaxReuse ≥ N-1` 이어야 한다는 것은 A·C 두 구성에서 관측한 결과에서 따라 나온 것이고, N 을 바꿔 가며 재 보지는 않았다. 잠금 후보 셋도 어느 것을 넣어 재현이 사라지는지 재지 않았다 — 이 실험은 무엇이 부서지는가까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
---
|
||||
id: af9645a0-8ca8-481d-9624-69fce6449b7c
|
||||
kind: SETUP
|
||||
slug: reproduce-b5-redis-loss
|
||||
title: Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/af9645a0-8ca8-481d-9624-69fce6449b7c/edit"
|
||||
pinnedVersions:
|
||||
- name: Redis
|
||||
version: 7.4.x
|
||||
- name: netty-transport
|
||||
version: 4.1.135.Final
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-5
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# Redis 를 0대로 내리고 파드가 Ready 를 유지하는지 본다
|
||||
|
||||
Redis 를 0대로 내렸을 때 무엇이 멈추는지 재는 절차다. 세 경로와 health 그룹과 Service 엔드포인트를 보고, 이어서 볼륨을 뗀 채 파드를 지워 영속화 설정만으로 무엇이 남는지 본다. 세션은 돌아오지 않으므로 실험대에서만 하고, 되돌리려면 매니페스트를 다시 적용한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **볼륨 없는 영속화와 유예 없는 키 회전**
|
||||
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 결론은 그쪽이 적는다.
|
||||
- **readiness 가 깨진 노드를 시야에서 먼저 치운다**
|
||||
A-2 는 readiness 가 파드를 뺐고 여기는 안 뺀다. 무엇이 그 차이를 만드는지 다룬다.
|
||||
- **up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다**
|
||||
파드가 `Ready` 인 채로 계속 실패하는 동안 지표가 무엇을 말하는지 다룬다.
|
||||
- **PostgreSQL 을 정상 종료시키고 네 경로를 잰다**
|
||||
같은 모양의 실험을 Keycloak 쪽에서 한 편이다. 두 결과를 견주는 것이 이 절차의 결론이다.
|
||||
- **Redis 를 붙이고 무엇이 옮겨졌는지 빈 목록으로 견준다**
|
||||
먼저 해 둬야 하는 편이다. 세션이 Redis 에 있어야 잃는 것이 보인다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 `kc-lab-1` 에서 `kubectl` 로 친다. `kubectl` 에 `sudo` 를 붙이지 않는다 — root 홈에는 kubeconfig 가 없어 `localhost:8080` 으로 붙으려다 끝난다. 브라우저는 시작 전에 한 번 쓴다. 세션이 Redis 에 하나는 있어야 잃는 것이 보인다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` |
|
||||
| 주입 수단 ① | `scale deployment/redis --replicas=0` — 없는 상태가 유지된다 |
|
||||
| 주입 수단 ② | `volumeMounts` 와 `volumes` 를 patch 로 떼고 파드를 지운다 |
|
||||
| Redis | `redis.keycloak-lab.svc:6379`, 파드는 `kc-lab-2` 에 고정 |
|
||||
| 재는 경로 | 셋 — `/` · `/bff/token-boundary` · `/actuator/health` |
|
||||
| 전 구간 | 약 30분 |
|
||||
| 잃는 것 | 로그인 세션. 돌아오지 않는다 |
|
||||
|
||||
**이 실험대는 그 뒤로 바뀌었다.** 지금 매니페스트(`bff-redis.yaml`)에는 B-5 의 결론이 이미 반영되어 PVC 와 `--appendonly yes` 가 들어 있다. 그래서 둘째 주입은 볼륨 없는 상태를 다시 만드는 단계부터 시작한다. 원래 실행은 반대 순서였다 — 볼륨 없는 상태에서 시작해 PVC 를 붙였다(unknown).
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
A-2 에서 Keycloak 의 PostgreSQL 을 내렸을 때는 이렇게 됐다.
|
||||
|
||||
```text
|
||||
DB 정지 → 헬스체크 실패 → 파드 NotReady → Service 에서 빠짐 → 밖에서 503
|
||||
```
|
||||
|
||||
명확한 실패였다. `503` 은 지금 안 된다고 말하고, 클라이언트는 재시도든 포기든 정할 수 있다. 통념은 의존 저장소가 죽으면 헬스체크가 알아서 파드를 빼 준다는 것이고, 이 절차는 진짜 그런지와 이번에는 무엇을 보고 판단하는지를 잰다.
|
||||
|
||||
두 번째 질문이 붙는다.
|
||||
|
||||
```text
|
||||
Redis 를 다시 띄우면 → 세션이 남아 있나?
|
||||
```
|
||||
|
||||
영속화를 켜 두면 된다는 통념이 쿠버네티스에서 어떻게 어긋나는지를 잰다. 그래서 영속화를 논하기 전에 `/data` 가 무엇인지부터 보는 절이 이 절차에서 가장 무겁다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` · `06-observability` 가 끝나 있다.
|
||||
- B-1 · B-2 가 끝나 세션은 Redis, 토큰은 PostgreSQL 로 나뉘어 있다. 나뉘어 있어야 각각 죽여볼 수 있고, 이 절차는 Redis 만 죽인다.
|
||||
- 브라우저로 `https://app1.hyeonworks.com/` 에 로그인해 둔다(`labuser` / `labpass`).
|
||||
|
||||
**저장소를 지우는 실험이다.** Redis 를 0대로 내리고 나중에 볼륨 없이 파드를 지운다. 그 안의 세션은 돌아오지 않고 로그인한 사용자는 전부 로그아웃된다. 중간에 그만두려면 한 줄이면 된다.
|
||||
|
||||
```bash label="[kc-lab-1] 중간에 그만둘 때 치는 한 줄"
|
||||
kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||
```
|
||||
|
||||
볼륨을 뗀 뒤에는 매니페스트를 다시 적용해 되돌린다. 그 두 줄이 둘째 주입의 유일한 되돌리기다. `deploy/lab/k8s/bff-redis.yaml` 은 저장소 안의 상대 경로다 — 저장소를 체크아웃한 디렉터리에서 쳐야 풀리고, 다른 디렉터리에서 치면 경로가 없다는 오류로 끝나 볼륨이 안 돌아온다. 그 체크아웃이 `kc-lab-1` 의 어디에 있는지는 원본 가이드에 없다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] 볼륨을 뗀 뒤에 되돌리는 두 줄"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
```text
|
||||
파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
|
||||
```
|
||||
|
||||
### 1. 파드가 어디에 몇 개 있는가
|
||||
|
||||
**무엇을 보는가** — 파드 넷의 상태와 배치.
|
||||
|
||||
```bash label="[kc-lab-1] 파드 배치를 본다"
|
||||
kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
|
||||
**어디를 보나** — 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
bff-555df79c97-6j86w 1/1 Running 0 17m 10.42.0.52 kc-lab-1
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 16m 10.42.1.124 kc-lab-2
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `bff` 가 둘 다 `1/1` 이고 `RESTARTS` 가 `0` 이다. Redis 는 하나라 replica 가 없고, 0으로 내리면 전면 정지다. Redis 와 PostgreSQL 이 같은 노드(`kc-lab-2`)인 것은 매니페스트가 `nodeSelector` 로 고정한 결과이고, A-4(노드 상실)에서 두 저장소가 한꺼번에 없어지게 하려는 배치다. `10.42.0.52` 와 `10.42.1.124` 는 실제 BFF 파드 IP 이고, 관찰 절에서 이 두 주소가 다시 나온다.
|
||||
|
||||
### 2. Redis 안에 무엇이 있고 영속화가 어떻게 설정돼 있는가
|
||||
|
||||
**무엇을 보는가** — 키 수와 두 영속화 설정.
|
||||
|
||||
```bash label="[kc-lab-1] Redis 의 내용과 영속화 설정을 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-baseline.txt`).
|
||||
|
||||
```text
|
||||
=== 기준선 ===
|
||||
Redis 키: 1
|
||||
PostgreSQL 토큰: 1 행
|
||||
Redis 영속화 설정:
|
||||
save = save
|
||||
appendonly no
|
||||
```
|
||||
|
||||
| 값 | 그때 | 뜻 |
|
||||
|---|---|---|
|
||||
| 키 수 | `1` | 로그인 세션 하나 |
|
||||
| `save` | 빈 값 | RDB 스냅샷이 꺼져 있다 |
|
||||
| `appendonly` | `no` | AOF(append-only file, 쓰기를 순서대로 적어 두는 파일)도 꺼져 있다 |
|
||||
|
||||
**이 값이 뜻하는 것** — 그때는 영속화가 아예 꺼져 있었다. 지금 환경은 아마 다르다 — 매니페스트가 `--appendonly yes` 로 시작하므로 `appendonly yes` 가 나오고, 그 차이가 둘째 주입의 출발 조건이다.
|
||||
|
||||
`save` 출력의 값이 비어 있는 것과 그 설정 자체가 없는 것은 다르다. `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면 스냅샷 조건이 없다는 뜻이다. 증거의 `save = save` 는 그 두 줄이 한 줄로 붙어 찍힌 모양이다.
|
||||
|
||||
### 3. `/data` 가 볼륨인가
|
||||
|
||||
**무엇을 보는가** — 영속화를 말하기 전에 확인할 셋. 이 확인을 건너뛰면 「AOF 를 켰는데 안 남는다」를 「Redis 가 이상하다」로 읽게 된다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 파드에 볼륨이 붙어 있는가"
|
||||
kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||
```
|
||||
|
||||
지금 매니페스트 기준의 모양은 이렇다(observed).
|
||||
|
||||
```json
|
||||
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 어디에 붙었는지와 PVC 상태를 본다"
|
||||
kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
|
||||
kubectl -n keycloak-lab get pvc
|
||||
```
|
||||
|
||||
```text
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
redis-data Bound pvc-... 1Gi RWO local-path 3d
|
||||
```
|
||||
|
||||
**어디를 보나** — 셋이 전부 성립해야 한다.
|
||||
|
||||
```text
|
||||
① volumes 에 항목이 있다 ← 없으면 컨테이너 파일시스템이다
|
||||
② volumeMounts 의 mountPath 가 /data ← 다른 데 붙었으면 소용없다
|
||||
③ PVC 가 Bound ← Pending 이면 파드가 안 뜬다
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 하나라도 빠지면 `appendonly yes` 는 장식이다. 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
|
||||
|
||||
```text
|
||||
/data 가 볼륨이 아니다 → 이미지 위의 쓰기 가능 레이어에 쓴다
|
||||
→ 컨테이너가 없어지면 그 레이어도 없어진다
|
||||
```
|
||||
|
||||
Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에 `appendonlydir` 을 만들고 매 쓰기를 기록한다. 거짓말이 아니라 정말로 기록하고, 다만 그 디렉터리가 어디 있는지를 모른다. `emptyDir` 도 마찬가지다 — 컨테이너 재시작은 견디지만 파드가 없어지면 같이 없어진다. 볼륨을 붙였다와 영속 볼륨을 붙였다는 다르다.
|
||||
|
||||
### 4. 세 경로를 정상 상태에서 한 번 돌린다
|
||||
|
||||
**무엇을 보는가** — 주입 후에 볼 세 경로를 주입 전에 똑같은 명령으로.
|
||||
|
||||
```bash label="[kc-lab-1] 세 경로의 상태 코드를 뽑는다"
|
||||
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
|
||||
```
|
||||
|
||||
**어디를 보나** — 증거에는 첫 줄만 남았다(observed, `01-baseline.txt`).
|
||||
|
||||
```text
|
||||
=== 외부 진입점 정상 확인 ===
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
```
|
||||
|
||||
`000` 이 아닌 것이 판정 기준의 전부다.
|
||||
|
||||
| 경로 | 정상일 때 | 왜 |
|
||||
|---|---|---|
|
||||
| `/` | `200` | `permitAll` 정적 페이지. Redis 를 안 탄다 |
|
||||
| `/bff/token-boundary` | `200` 또는 로그인으로 보내는 `3xx` | 세션이 필요하다 — Redis 를 탄다 |
|
||||
| `/actuator/health` | `200` | 모든 지표의 합 |
|
||||
|
||||
**이 값이 뜻하는 것** — 셸의 `curl` 에는 로그인 쿠키가 없으므로 두 번째는 보통 `3xx` 이고, 가이드가 이 대목을 미검증으로 표시했다(unknown). `200` 이든 `3xx` 든 상관없다 — 이 절차가 보는 것은 응답이 오는가이고, `3xx` 를 만드는 과정에서도 BFF 는 세션을 만들려고 Redis 를 건드린다.
|
||||
|
||||
**`--max-time` 을 반드시 붙인다.** 주입 뒤 이 요청은 응답이 안 온다. 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 멈춤이 아니라 내 터미널이 이상함으로 읽게 된다.
|
||||
|
||||
### 5. health 그룹 셋이 서로 다른지 본다
|
||||
|
||||
**무엇을 보는가** — 세 응답의 본문. `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다).
|
||||
|
||||
```bash label="[kc-lab-1] ① 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
|
||||
```
|
||||
|
||||
**어디를 보나** — 첫 번째 응답의 본문에 `redis` 항목이 있는지, 두 번째 응답에는 없는지를 본다. 세 응답이 서로 다르다는 것을 보는 것이 이 확인의 전부다.
|
||||
|
||||
정지 후 값이 증거에 이렇게 남아 있고, 그 본문 항목 칸은 비어 있다(observed, `03-health-groups.txt`).
|
||||
|
||||
```text
|
||||
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||
|
||||
|
||||
=== /actuator/health/readiness 본문 ===
|
||||
{"status":"UP"}
|
||||
```
|
||||
|
||||
**첫 칸이 비어 있는 것은 측정 실패다.** 파드 안에서 본문을 받아오려다 못 받았다. 밖에서 직접 재 두는 편이 낫다 — 뒤에서 이 값을 비교하게 된다.
|
||||
|
||||
```text
|
||||
/actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
|
||||
/actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
|
||||
/actuator/health/liveness liveness 그룹
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② kubelet 이 보는 경로를 확인한다"
|
||||
kubectl -n keycloak-lab get deploy bff \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
|
||||
```
|
||||
|
||||
```text
|
||||
/actuator/health/readiness
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다. 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로다. 전체는 DOWN 인데 readiness 는 UP 인 상태가 성립한다.
|
||||
|
||||
## 주입
|
||||
|
||||
주입은 둘이다. 첫째는 Redis 를 0대로 내리고, 둘째는 볼륨을 뗀 채 영속화만 켜고 파드를 지운다. **둘째는 첫째를 되돌린 뒤에 한다.**
|
||||
|
||||
내리는 방법을 고른 이유부터 본다.
|
||||
|
||||
| 방법 | 만들어지는 상태 |
|
||||
|---|---|
|
||||
| `delete pod` | Deployment 가 **즉시 새로 만든다.** 몇 초짜리 공백이라 관찰할 시간이 없다 |
|
||||
| **`scale --replicas=0`** | **없는 상태가 유지된다.** 내가 되돌릴 때까지 |
|
||||
| NetworkPolicy 로 6379 차단 | 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 기존 연결은 안 끊긴다 |
|
||||
|
||||
저장소가 없어진 상태를 안정적으로 유지하는 것이 목적이므로 두 번째를 쓴다. 파드가 사라지므로 주입 여부를 눈으로 확인하기도 쉽다.
|
||||
|
||||
### 1. Redis 를 0대로 내린다
|
||||
|
||||
**목적** — Redis 가 없는 상태를 만들고 그 상태를 유지한다.
|
||||
|
||||
① 시각을 남기고 replica 를 0으로 내린다.
|
||||
|
||||
```bash label="[kc-lab-1] 시각을 남기고 Redis 를 0대로 내린다"
|
||||
date '+%H:%M:%S 정지'
|
||||
kubectl -n keycloak-lab scale deployment/redis --replicas=0
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `02-redis-down.txt`).
|
||||
|
||||
```text
|
||||
=== ① Redis 정지 ===
|
||||
정지: 14:26:30
|
||||
deployment.apps/redis scaled
|
||||
삭제 완료
|
||||
```
|
||||
|
||||
**왜 필요한가** — 시각을 반드시 적어 둔다. 언제부터 회복됐나를 붙일 때 쓴다.
|
||||
|
||||
**문제가 생기면** — 파드가 몇 초 만에 돌아왔다면 `delete pod` 를 쳤다. `scale --replicas=0` 으로 다시 한다.
|
||||
|
||||
### 2. 볼륨을 떼고 영속화만 켠다
|
||||
|
||||
**목적** — 영속화 설정은 켜져 있고 `/data` 는 컨테이너 파일시스템인 상태를 만든다. 첫째 주입을 되돌린 뒤에 한다.
|
||||
|
||||
**⓪ 먼저 Redis 를 다시 올린다.** 바로 앞 절에서 0대로 내려 두었고, 이 절의 명령은 전부 파드가 살아 있어야 한다. 올리지 않고 이어 치면 `exec deploy/redis` 가 붙을 파드를 못 찾는다.
|
||||
|
||||
```bash label="[kc-lab-1] ⓪ 앞 절의 주입을 되돌린다"
|
||||
kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
① `volumeMounts` 와 `volumes` 를 함께 뗀다. 가이드가 이 방향을 미검증으로 표시했다(unknown) — 원래 실행은 반대 순서였다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 볼륨 참조를 떼고 롤아웃을 기다린다"
|
||||
kubectl -n keycloak-lab patch deployment redis --type=json \
|
||||
-p '[{"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts"},
|
||||
{"op":"remove","path":"/spec/template/spec/volumes"}]'
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
② AOF 를 켜고 키를 심은 뒤 `/data` 를 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ② AOF 를 켜고 키를 심는다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- ls -la /data
|
||||
```
|
||||
|
||||
**예상 결과** — `appendonly yes` 가 나오고 `/data` 에 `appendonlydir` 이 생긴다.
|
||||
|
||||
**왜 필요한가** — **PVC 자체는 지우지 않는다.** Deployment 에서 참조만 뗐고, 나중에 `apply` 로 되돌리면 같은 PVC 에 다시 붙는다. PVC 를 지우면 `local-path` 프로비저너가 노드의 디렉터리까지 지운다.
|
||||
|
||||
**문제가 생기면** — 패치가 안 먹으면 `spec.volumes` 가 여전히 PVC 를 보여 준다. 주입 검증에서 그것부터 본다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다.
|
||||
|
||||
**네 확인이 같은 시점을 보지 않는다.** ①②③ 은 첫째 주입이 걸려 있는 동안에만 성립한다 — 주입 1 절을 친 직후, 주입 2 절의 ⓪ 으로 Redis 를 다시 올리기 전에 본다. ④ 는 둘째 주입을 친 뒤라 Redis 가 1대로 살아 있을 때 본다. 절 순서대로 위에서 아래로 한 번에 치면 ① 이 `1/1` 을 내는데, 그것은 스케일이 안 먹은 증상이 아니라 ⓪ 이 제대로 올린 결과다.
|
||||
|
||||
```bash label="[kc-lab-1] ① Redis 가 0대인가"
|
||||
kubectl -n keycloak-lab get pods -l app=redis
|
||||
kubectl -n keycloak-lab get deploy redis
|
||||
```
|
||||
|
||||
모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
No resources found in keycloak-lab namespace.
|
||||
|
||||
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||
redis 0/0 0 0 3d
|
||||
```
|
||||
|
||||
`0/0` 이어야 한다. `1/1` 이면 스케일이 안 먹었거나 다른 네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.
|
||||
|
||||
**응답이 없는 것과 붙지 못하는 것은 다르다.** 로그가 이유를 말한다.
|
||||
|
||||
```bash label="[kc-lab-1] ② BFF 로그에서 연결 시도를 찾는다"
|
||||
kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-redis-down.txt`).
|
||||
|
||||
```text
|
||||
=== BFF 로그 ===
|
||||
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
|
||||
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
|
||||
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
|
||||
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||
```
|
||||
|
||||
`pollConnect` 와 `finishConnect` 는 연결을 맺는 중이라는 뜻이다. 이미 실패한 것이 아니라 아직 시도 중이고, Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다. 관찰 절의 `000` 이 여기서 나온다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 엉뚱한 것을 죽이지 않았는지 본다"
|
||||
kubectl -n keycloak-lab get pods
|
||||
```
|
||||
|
||||
```text
|
||||
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
|
||||
bff-555df79c97-6j86w 1/1 Running 0 17m
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 16m
|
||||
```
|
||||
|
||||
`bff` 두 개의 `RESTARTS` 가 여전히 0 이고 postgres 가 살아 있어야 한다(observed). postgres 까지 내렸다면 B-5 가 아니라 전면 장애를 재게 된다. 여기서 이미 답이 절반 나와 있다 — Redis 가 없는데 `1/1` 이다.
|
||||
|
||||
**둘째 주입도 걸렸는지 본다.** 볼륨 확인은 주입 전과 똑같은 명령이다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 볼륨이 정말 떨어졌는가"
|
||||
kubectl -n keycloak-lab get pod -l app=redis \
|
||||
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||
```
|
||||
|
||||
**빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면 패치가 안 먹은 것이고, 그 상태로 파드를 지우면 당연히 살아남는다 — 그리고 그걸 영속화가 잘 된다고 오독한다.
|
||||
|
||||
**빈 줄에는 뜻이 둘이다.** 이 명령은 `app=redis` 라벨이 붙은 파드 중 첫째를 골라 그 파드의 `spec.volumes` 를 찍는다. Redis 가 0대면 고를 파드가 없어 아무것도 안 나오고, 그 화면은 볼륨을 뗐을 때와 구별되지 않는다. 그래서 이 줄을 읽기 전에 위 ① 의 `get pods -l app=redis` 로 Redis 파드가 하나 `Running` 인지부터 본다. 파드가 있는데 빈 줄이면 볼륨이 떨어졌고, 파드가 없으면 이 명령은 아직 아무 말도 하지 않았다.
|
||||
|
||||
```text
|
||||
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
|
||||
appendonly yes
|
||||
total 12
|
||||
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
|
||||
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
|
||||
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
|
||||
```
|
||||
|
||||
`appendonlydir` 이 실제로 만들어졌다(observed, `04-persistence.txt`). Redis 는 시킨 대로 했다 — 설정도 `yes` 고 디렉터리도 있고 파일도 쓰인다. **여기서 영속화가 켜졌다고 결론 내리면 틀린다.** 어디에 쓰는지를 안 봤기 때문이고, 지금 `/data` 는 컨테이너 파일시스템이다.
|
||||
|
||||
## 관찰
|
||||
|
||||
**`000` 은 오류가 아니라 멈춤이다.** 주입 전과 똑같은 명령을 친다.
|
||||
|
||||
```bash label="[kc-lab-1] 세 경로를 다시 친다"
|
||||
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
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-redis-down.txt`).
|
||||
|
||||
```text
|
||||
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
|
||||
/ HTTP 200
|
||||
/bff/token-boundary HTTP 000
|
||||
/actuator/health HTTP 503
|
||||
```
|
||||
|
||||
| 코드 | 뜻 |
|
||||
|---|---|
|
||||
| `200` | 정적 페이지는 산다 — Redis 를 안 타는 경로 |
|
||||
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
|
||||
| `503` | 헬스 엔드포인트는 대답은 한다 — 다만 DOWN 이라고 |
|
||||
|
||||
오류를 돌려주는 것이 아니라 매달려 있다.
|
||||
|
||||
```text
|
||||
빠른 실패: 요청 → 즉시 503 → 사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
|
||||
느린 실패: 요청 → ………… → 사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다
|
||||
```
|
||||
|
||||
빨리 실패하기(fail fast)가 안 되어 있다. A-6(지연 주입)에서 본 것과 같은 문제이고, 브라우저 탭도 그 앞의 로드밸런서도 그 앞의 사용자도 전부 붙잡힌다. 응답 본문도 비어 있다(observed, 같은 파일).
|
||||
|
||||
```text
|
||||
--- token-boundary 응답 본문 ---
|
||||
|
||||
|
||||
```
|
||||
|
||||
본문이 없다는 것은 오류 페이지조차 못 만들었다는 뜻이다. 고치려면 클라이언트에 타임아웃을 건다. Lettuce 의 연결·명령 타임아웃을 짧게 잡으면 `000` 이 `500` 이 되고, `500` 이 `000` 보다 낫다 — 적어도 말은 하기 때문이다.
|
||||
|
||||
**그런데 파드는 `Ready` 를 유지한다.** 이 절차의 가장 중요한 발견이다.
|
||||
|
||||
```bash label="[kc-lab-1] 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
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-health-groups.txt`).
|
||||
|
||||
```text
|
||||
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
|
||||
/actuator/health HTTP server
|
||||
/actuator/health/readiness HTTP 200
|
||||
/actuator/health/liveness HTTP 200
|
||||
|
||||
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||
|
||||
|
||||
=== /actuator/health/readiness 본문 ===
|
||||
{"status":"UP"}
|
||||
```
|
||||
|
||||
`readiness` 가 `200` 이고 `{"status":"UP"}` 이다.
|
||||
|
||||
**첫 줄의 `HTTP server` 는 상태 코드가 아니라 측정이 실패한 것이다.** 값이 들어와야 할 칸에 엉뚱한 문자열이 들어와 있고, `503` 이라는 값은 `02-redis-down.txt` 쪽 측정에서 나왔다. 빈 값이나 이상한 값을 측정 결과로 읽지 않는다 — 그건 측정 실패다. A-1 에서도 빈 문자열을 변화로 읽어 판정이 틀어진 적이 있다. 이상하면 그 칸을 다시 친다.
|
||||
|
||||
```text
|
||||
/actuator/health redis: DOWN → 전체 DOWN → 503
|
||||
/actuator/health/readiness readinessState 만 → UP → kubelet: "정상"
|
||||
```
|
||||
|
||||
그래서 Service 에서 파드를 빼지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 엔드포인트가 아직 ready 인지 본다"
|
||||
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=bff \
|
||||
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||
```
|
||||
|
||||
```text
|
||||
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
|
||||
ready: [10.42.0.52 10.42.1.124]
|
||||
```
|
||||
|
||||
두 주소가 그대로 ready 다(observed). 주입 전에 본 그 두 IP 이고, 두 파드가 계속 트래픽을 받으며 계속 실패한다. 어느 replica 로 가도 결과가 같으므로 재시도해도 소용없다. `kubectl get endpoints` 는 쓰지 않는다 — v1.33 부터 deprecated 라 경고가 뜨고, 해설 문서 5절의 재현 절차에는 옛 형태(`get endpoints bff`)가 실려 있다.
|
||||
|
||||
A-2 와의 대비가 이 절차의 결론이다.
|
||||
|
||||
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|
||||
|---|---|---|
|
||||
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
|
||||
| 파드 상태 | **NotReady** | **Ready 유지** |
|
||||
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
|
||||
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
|
||||
|
||||
Keycloak 은 자기 의존성을 readiness 에 넣었고 이 BFF 는 안 넣었다. 어느 쪽이 옳은지는 상황에 달렸다.
|
||||
|
||||
| readiness 에 넣으면 | 넣지 않으면 |
|
||||
|---|---|
|
||||
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
|
||||
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
|
||||
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
|
||||
|
||||
의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다. 넣기로 정했다면 명시한다.
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: readinessState, redis # 넣으려면 명시해야 한다
|
||||
```
|
||||
|
||||
liveness 에는 넣지 않는다. liveness 가 실패하면 kubelet 이 파드를 죽이는데, Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또 죽는다 — 재시작해도 안 나아지는 문제에 재시작을 걸게 된다.
|
||||
|
||||
**첫째 주입을 되돌리고 손대지 않는다.** BFF 를 재시작하고 싶은 충동을 참는다 — 재시작하면 스스로 회복하는가를 영영 알 수 없다.
|
||||
|
||||
```bash label="[kc-lab-1] ① Redis 를 다시 올린다"
|
||||
date '+%H:%M:%S 복구'
|
||||
kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
①의 실측은 이렇다(observed, `04-persistence.txt`).
|
||||
|
||||
```text
|
||||
=== 복구 ===
|
||||
deployment.apps/redis scaled
|
||||
deployment "redis" successfully rolled out
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 회복했는지와 재시작 횟수를 본다"
|
||||
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"
|
||||
done
|
||||
kubectl -n keycloak-lab get pods -l app=bff
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `04-persistence.txt`).
|
||||
|
||||
```text
|
||||
/actuator/health HTTP 200
|
||||
/bff/token-boundary HTTP 302
|
||||
BFF 재시작 필요했나: 0,0 회 재시작
|
||||
```
|
||||
|
||||
`재시작 0,0` 이다. Lettuce 가 스스로 재연결했다. A-2 에서 Keycloak 의 커넥션 풀이 그랬던 것과 같고, liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것이며 회복이 더 늦어졌을 것이다. `302` 는 실패가 아니다 — 세션이 사라졌으므로 로그인으로 보내는 것이고, Redis 가 비었으니 사용자는 로그아웃된다. 여기서 다음 질문이 나온다 — Redis 를 다시 띄웠는데 왜 세션이 없나. 답은 영속화가 없었으니까다. 그럼 켜면 되나.
|
||||
|
||||
**둘째 주입의 결과가 그 답이다.** 볼륨 없이 AOF 만 켠 채 파드를 지운다.
|
||||
|
||||
`rollout status` 가 돌아와도 지운 파드가 아직 종료 중일 수 있다. 이어지는 `exec deploy/redis` 가 그 파드에 붙으면 명령이 실패하거나 지우기 전 숫자를 낸다. 이 절차의 판정이 바로 그 `dbsize` 이므로, 숫자가 이상하면 `get pods -l app=redis` 로 `Running` 하나만 남았는지 보고 다시 친다.
|
||||
|
||||
```bash label="[kc-lab-1] ③ 볼륨 없이 파드를 지우고 남은 것을 센다"
|
||||
kubectl -n keycloak-lab delete pod -l app=redis
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||
```
|
||||
|
||||
```text
|
||||
--- 파드를 지운다 ---
|
||||
deployment "redis" successfully rolled out
|
||||
재기동 후:
|
||||
dbsize: 0
|
||||
b5:probe
|
||||
b5:aof
|
||||
appendonly no
|
||||
```
|
||||
|
||||
두 가지가 같이 사라졌다(observed, 같은 파일).
|
||||
|
||||
| 사라진 것 | 왜 |
|
||||
|---|---|
|
||||
| **데이터** | `/data` 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다 |
|
||||
| **설정** | `CONFIG SET` 은 **런타임 전용**이다. 재기동하면 매니페스트의 `args` 가 이긴다 |
|
||||
|
||||
쿠버네티스에서 영속화 설정만 켜는 것은 장식이다. `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — 파일은 만들어지고 로그도 정상이며, 사라지는 것은 재시작 순간뿐이다. 그리고 재시작은 노드 정비·이미지 갱신·OOM(out of memory, 메모리가 모자라 커널이 프로세스를 죽이는 일) 어느 것으로든 일어난다. 설정이 되돌아간 것도 따로 중요하다. `CONFIG SET` 으로 고친 값은 `CONFIG REWRITE` 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가 안 남는다. 런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.
|
||||
|
||||
볼륨을 되돌리고 같은 시험을 다시 하면 결과가 갈린다.
|
||||
|
||||
```bash label="[kc-lab-1] ④ 볼륨을 되돌리고 같은 시험을 다시 한다"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
|
||||
kubectl -n keycloak-lab delete pod -l app=redis
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
|
||||
```
|
||||
|
||||
```text
|
||||
=== 영속 볼륨 위에서 다시 시험 ===
|
||||
appendonly yes
|
||||
키 심음: written-on-pvc
|
||||
sed: -e expression #1, char 8: unknown option to 's'
|
||||
|
||||
--- 파드를 지운다 ---
|
||||
deployment "redis" successfully rolled out
|
||||
재기동 후:
|
||||
dbsize: 1
|
||||
b5:pvc written-on-pvc
|
||||
```
|
||||
|
||||
`dbsize: 1` 과 `written-on-pvc` 로 살아남았다(observed, 같은 파일). 중간의 `sed: -e expression #1, char 8: unknown option to 's'` 는 원래 실행의 스크립트가 낸 오류이고 측정과는 무관하다. 값에 `/` 가 들어간 문자열을 `sed 's/.../.../'` 에 그대로 넣으면 이렇게 된다. 증거 파일에서 그 오류를 지우지 않은 것은 그것이 「이 줄은 스크립트가 만든 것」이라는 표시이기 때문이다.
|
||||
|
||||
| 구성 | 파드 삭제 후 |
|
||||
|---|---|
|
||||
| AOF **끔**, 볼륨 없음 | 전부 소실 |
|
||||
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
|
||||
| AOF **켬**, **PVC** | **생존** |
|
||||
|
||||
볼륨이 먼저고 설정이 나중이다. 순서를 바꾸면 두 번째 줄이 되고, 두 번째 줄은 첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서 더 나쁘다.
|
||||
|
||||
`appendfsync` 는 그래도 맞바꿈이다. 기본값은 `appendfsync everysec` 이다.
|
||||
|
||||
| 설정 | 잃는 양 | 비용 |
|
||||
|---|---|---|
|
||||
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
|
||||
| **`everysec`** | **최대 1초** | 기본값 |
|
||||
| `no` | OS 에 맡김 | 가장 빠름 |
|
||||
|
||||
세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시 로그인해야 한다는 뜻이다. A-3 에서 본 PostgreSQL 의 `synchronous_commit OFF` 와 같은 모양의 맞바꿈이고, 거기서 Keycloak 이 같은 판단을 했다.
|
||||
|
||||
PVC 도 노드에 못박힌다.
|
||||
|
||||
```bash label="[kc-lab-1] PVC 의 스토리지 클래스를 본다"
|
||||
kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo
|
||||
```
|
||||
|
||||
```text
|
||||
local-path
|
||||
```
|
||||
|
||||
`local-path` 는 노드의 디렉터리다. A-4 에서 본 것과 같다 — 노드가 죽으면 볼륨도 함께 접근 불가가 되고 파드는 다른 노드로 못 옮겨간다. 영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.
|
||||
|
||||
**이 실험은 관측에 숙제를 남겼다.** Grafana 에 이 실험의 그래프가 없는데, 안 찍은 것이 아니라 지표가 없다.
|
||||
|
||||
```text
|
||||
=== B층 구성 요소의 지표가 있는가 ===
|
||||
redis_up 시계열 0개
|
||||
redis_connected_clients 시계열 0개
|
||||
pg_up 시계열 0개
|
||||
pg_stat_database_numbackends 시계열 0개
|
||||
```
|
||||
|
||||
Prometheus 가 긁는 대상에 Redis·PostgreSQL·BFF 가 애초에 없다(observed, `04-observability-gap.txt`). A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이 `/metrics` 를 내놓고 그것을 scrape 대상에 넣어 뒀기 때문이다. 관측은 나중에 붙이는 것이 아니라 실험 설계에 포함되어야 한다 — Redis 가 언제 끊겼고 언제 붙었나를 초 단위로 보고 싶다면 `redis_exporter` 가 먼저 있어야 하고, 그건 실험이 끝난 뒤에는 못 만든다.
|
||||
|
||||
가이드는 무엇을 어떻게 붙일지까지 적어 두었다.
|
||||
|
||||
| 지표가 없는 대상 | 무엇을 붙이나 |
|
||||
|---|---|
|
||||
| Redis | `redis_exporter` 사이드카 또는 Deployment |
|
||||
| PostgreSQL | `postgres_exporter` |
|
||||
| BFF | 이미 actuator 가 있다 — `/actuator/prometheus` 노출 + scrape 추가 |
|
||||
| 파드 readiness | `kube-state-metrics` (A-2 에서 이미 찾은 항목) |
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
관찰 절이 이미 둘을 되돌렸다 — replica 를 1로 올렸고, `apply` 로 볼륨을 다시 붙였다. 이제 실험이 심은 키를 지우고 일곱 항목을 대조한다.
|
||||
|
||||
### 1. 매니페스트를 다시 적용해 볼륨과 설정을 되돌린다
|
||||
|
||||
**목적** — Deployment 를 저장소에 있는 모양으로 되돌린다.
|
||||
|
||||
① 관찰 절에서 이미 쳤더라도 한 번 더 친다. `apply` 는 같은 결과를 낸다.
|
||||
|
||||
```bash label="[kc-lab-1] 매니페스트를 다시 적용한다"
|
||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||
```
|
||||
|
||||
**예상 결과** — `deployment "redis" successfully rolled out` 이 나온다.
|
||||
|
||||
**왜 필요한가** — patch 로 뗀 `volumeMounts` 와 `volumes` 가 여기서 돌아온다. `CONFIG SET` 으로 켠 `appendonly` 는 이미 재기동에서 매니페스트의 `args` 에 졌으므로 따로 되돌릴 것이 없다.
|
||||
|
||||
**문제가 생기면** — PVC 가 `Pending` 이면 `describe pvc redis-data` 의 Events 를 본다.
|
||||
|
||||
### 2. 실험이 심은 키를 지운다
|
||||
|
||||
**목적** — `b5:` 로 시작하는 키 셋만 치운다.
|
||||
|
||||
① 접두어로만 지운다. **`FLUSHALL` 은 치지 않는다** — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.
|
||||
|
||||
```bash label="[kc-lab-1] 실험 키만 지우고 남은 키를 본다"
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
|
||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||
```
|
||||
|
||||
**예상 결과** — `--scan` 출력에 `b5:*` 가 없다.
|
||||
|
||||
**왜 필요한가** — `FLUSHALL` 을 치면 B-4 와 B-7 이 쓰는 `_oauth2_proxy-` 키까지 날아가고, 그 실험들이 뒤에 가서 갑자기 깨진다.
|
||||
|
||||
**문제가 생기면** — 지워지지 않으면 키 이름을 `--scan` 으로 먼저 눈으로 본다.
|
||||
|
||||
### 3. 일곱 항목을 대조한다
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| Redis | `kubectl -n keycloak-lab get deploy redis` | `1/1` |
|
||||
| 볼륨 | `… get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'` | `persistentVolumeClaim` 이 보인다 |
|
||||
| PVC | `kubectl -n keycloak-lab get pvc` | `redis-data` `Bound` |
|
||||
| 영속화 | `… exec deploy/redis -- redis-cli config get appendonly` | `yes` |
|
||||
| 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` 이 안 끝나고 터미널이 붙잡힌다 | **그게 이 실험의 결과다.** `000` 이 되는 과정이다 | `--max-time` 을 붙인다 |
|
||||
| `000` 을 서버 오류로 읽는다 | `000` 은 **응답을 못 받았다**는 curl 의 표기다 | `400`/`503` 과 구별한다 |
|
||||
| **AOF 를 켰는데 안 남는다** | **`/data` 가 볼륨이 아니다** | 결론 내리기 전에 `spec.volumes` 를 먼저 |
|
||||
| 파드를 지웠는데 데이터가 **살아남는다** | 볼륨 제거 패치가 안 먹었다 | `get pod … spec.volumes` 가 **비어야** 한다 |
|
||||
| `config set` 한 값이 재기동 후 사라진다 | **런타임 전용이다.** 매니페스트 `args` 가 이긴다 | 재기동 후 `config get appendonly` |
|
||||
| `/actuator/health` 응답 칸에 이상한 문자열 | **측정 실패다.** 값이 아니다 (`HTTP server`) | 그 칸을 다시 친다 |
|
||||
| 파드가 `NotReady` 가 되기를 기다린다 | **안 된다.** `redis` 지표가 readiness 그룹에 없다 | health 그룹별 응답 |
|
||||
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=bff` |
|
||||
| 회복 후 로그인이 풀려 있다 | **정상이다.** Redis 가 비었으니 세션이 없다 | `302` 는 실패가 아니다 |
|
||||
| BFF 를 재시작해 버렸다 | 「스스로 회복하는가」를 못 재게 된다 | 주입부터 다시. 손대지 않고 기다린다 |
|
||||
| PVC 가 `Pending` | `local-path` 프로비저너가 없거나 노드가 안 맞는다 | `describe pvc redis-data` 의 Events |
|
||||
| 다른 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 14:24–14:28 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||||
|
||||
- (observed) 주입 전 Redis 키 `1` 개 · PostgreSQL 토큰 `1` 행 · `save` 빈 값 · `appendonly no`, 정지 시각 `14:26:30`, 정지 후 세 경로의 `200` · `000` · `503` 과 빈 응답 본문, BFF 로그의 `pollConnect` · `finishConnect` 스택, 정지 중에도 `bff` 두 개가 `1/1` · `RESTARTS 0` 인 것, health 그룹의 `readiness 200` 과 `{"status":"UP"}`, 엔드포인트에 남은 `10.42.0.52` · `10.42.1.124`, 복구 후 `HTTP 200` · `HTTP 302` 와 `0,0 회 재시작`, `appendonlydir` 이 만들어진 `ls -la /data` 출력, 볼륨 없이 파드를 지운 뒤의 `dbsize: 0` · `appendonly no`, PVC 위에서 지운 뒤의 `dbsize: 1` · `written-on-pvc`, 후속 조사의 네 지표가 전부 시계열 0개인 것.
|
||||
- **측정 실패를 값으로 읽지 않는다**(observed) — `03-health-groups.txt` 의 `/actuator/health HTTP server` 는 상태 코드가 아니고, 같은 파일의 `/actuator/health` 본문 칸도 비어 있다. `503` 은 `02-redis-down.txt` 쪽 측정에서 나온 값이다.
|
||||
- (observed) `04-persistence.txt` 에 섞인 `sed: -e expression #1, char 8: unknown option to 's'` 는 원래 실행의 스크립트가 낸 오류이고 측정과 무관하다. 증거 파일에서 지우지 않았다.
|
||||
- (unknown) 볼륨을 떼는 `patch deployment redis --type=json` 줄. **원래 실행은 반대 순서로 했다** — 볼륨 없는 상태에서 시작해 PVC 를 붙였고, 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다. 셸 `curl` 에 로그인 쿠키가 없어 `/bff/token-boundary` 가 `3xx` 로 나오는 것도 가이드가 미검증으로 표시했다.
|
||||
- 이 실험이 재지 않은 것 — Lettuce 타임아웃을 줄여 `000` 이 `500` 이 되는지는 재지 않았다. 「500 이 000 보다 낫다」까지가 이 실험의 결론이고 그 설정을 넣어 다시 잰 기록은 없다. `readiness` 그룹에 `redis` 를 넣었을 때 A-2 와 같은 모양이 되는지도 재지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
---
|
||||
id: 7447ccbf-1800-43a4-a9d2-8ac774965c4b
|
||||
kind: SETUP
|
||||
slug: reproduce-b6-key-rotation
|
||||
title: 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
|
||||
topic: where-application-state-lives
|
||||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit"
|
||||
pinnedVersions:
|
||||
- name: curl
|
||||
version: 8.5.0
|
||||
source:
|
||||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
---
|
||||
|
||||
# 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
|
||||
|
||||
realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.
|
||||
|
||||
## 관계
|
||||
|
||||
- **볼륨 없는 영속화와 유예 없는 키 회전**
|
||||
이 절차가 만드는 `옛 401 · 새 200` 을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
|
||||
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
|
||||
여기서 `-q` 필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다.
|
||||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||||
이 절차는 주입 검증에서 `old 200` 을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다.
|
||||
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
|
||||
같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서 `kid` 가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 읽기 전에 — 어디서 치는가
|
||||
|
||||
명령은 전부 `[kc-lab-1]` 에서 친다. Keycloak 이미지에는 `curl` 도 `wget` 도 없어서(`exit 127`) 파드 안에서 HTTP 요청을 보낼 수 없다. JWKS(JSON Web Key Set, 서버가 공개키를 싣는 목록)와 토큰은 호스트에서 공개 이름으로 치고, `kcadm.sh` 만 `kubectl exec` 로 감싸 파드 안에서 돌린다.
|
||||
|
||||
터미널은 하나면 된다. 붙잡아 두어야 하는 셸이 없고, 대신 `OLD` 과 `NEW` 두 변수를 끝까지 들고 가므로 중간에 터미널을 닫지 않는다.
|
||||
|
||||
| 무엇 | 값 |
|
||||
|---|---|
|
||||
| 네임스페이스 | `keycloak-lab` · 리소스 서버는 `header-lab` |
|
||||
| realm | `keycloak-patterns` — 클라이언트 `bff-confidential`, 사용자 `labuser` |
|
||||
| 주입 수단 | `kcadm.sh create components` — `priority` 가 더 높은 RSA 공급자를 하나 더 만든다 |
|
||||
| 판정하는 쪽 | 리소스 서버 `echo`. 이 절차의 401 과 200 은 전부 그 앱이 낸다 |
|
||||
| 시간 제약 | access token 수명 60초. 토큰을 받고 1분 안에 그 토큰으로 친다 |
|
||||
| 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 |
|
||||
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `tr` 과 `grep` 으로 자른다 |
|
||||
|
||||
## 이 실험이 가르는 것
|
||||
|
||||
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.
|
||||
|
||||
| 어느 키인가 | 지금 상태 |
|
||||
|---|---|
|
||||
| 토큰 **저장소**의 암호화 키 | 존재하지 않는다. B-2 에서 `bytea` 안이 JWT 문자열 그대로였다 |
|
||||
| 토큰 **서명** 키 (Keycloak realm) | 존재하고 회전할 수 있다 — 이 절차가 잰다 |
|
||||
|
||||
앞의 것이 없으므로 교체할 것도 없다. 그래서 이 절차는 뒤의 것만 치고, 거기서 본 모양이 나중에 앞의 것을 설계할 때 쓰인다.
|
||||
|
||||
원래 실행은 예측이 빗나간 실험이었다. 리소스 서버가 JWKS 를 캐시하니 옛 키를 지워도 한동안은 통할 것이라고 적어 두었는데, 제거 직후 바로 401 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.
|
||||
|
||||
```text
|
||||
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
|
||||
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
|
||||
```
|
||||
|
||||
절차를 끝까지 밟으면 RS256 키 수가 `1 → 2 → 1` 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.
|
||||
|
||||
## 전제와 되돌리기
|
||||
|
||||
- `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
|
||||
- B-0 이 끝나 BFF 가 떠 있다.
|
||||
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 절차의 401 과 200 은 전부 그 앱이 판정한다.
|
||||
|
||||
**이건 되돌릴 수 없는 실험이다.** 지우는 것은 서명 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 `kid` 가 달라지므로, 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다.
|
||||
|
||||
되돌릴 수 있는 것은 주입 하나다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 화면에 찍어 주는 값이고 그 줄을 그대로 옮겨 친다 — 원래 실행에서는 `7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다. 아래 블록에 박힌 값이 그 원래 실행의 id 라, 8 절을 친 뒤에 그 출력이 찍어 준 자기 id 로 바꿔야 지워진다. 8 절을 치기 전에는 지울 공급자가 없다.
|
||||
|
||||
```bash label="[kc-lab-1] 관찰 절로 넘어가기 전에 그만둘 때 — id 를 8 절 출력의 자기 값으로 바꾼다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns
|
||||
```
|
||||
|
||||
## 주입 전에 같은 명령으로 먼저 본다
|
||||
|
||||
제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
|
||||
|
||||
```text
|
||||
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
|
||||
```
|
||||
|
||||
### 1. kcadm 세션을 파드 안에 만든다
|
||||
|
||||
**목적** — 뒤의 모든 `kcadm.sh` 명령이 관리 API 로 인증되게 한다.
|
||||
|
||||
**행동** — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.
|
||||
|
||||
```bash label="[kc-lab-1] ① kcadm 에 로그인한다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
|
||||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
```
|
||||
|
||||
**예상 결과** — ① 은 아무것도 안 나오면 성공이다. 실패하면 한 줄짜리 오류가 뜬다. ② 는 0 이 아닌 수를 낸다.
|
||||
|
||||
**왜 필요한가** — `kcadm.sh` 는 파드 안 파일에 세션을 저장한다. 파드가 재시작되면 그 파일이 사라지고 그다음 모든 명령이 `401` 로 떨어지므로 맨 앞에서 한 번 해 둔다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다.
|
||||
|
||||
**문제가 생기면** — 중간에 `kcadm` 이 전부 `Unauthorized` 로 바뀌면 파드가 재시작된 것이다. ① 을 다시 친다.
|
||||
|
||||
### 2. 키 공급자 목록을 통째로 받는다
|
||||
|
||||
**무엇을 보는가** — 이 realm 에 어떤 키 공급자가 있고 각각의 id 가 무엇인지.
|
||||
|
||||
```bash label="[kc-lab-1] 공급자 목록을 필드 셋으로 받는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId
|
||||
```
|
||||
|
||||
**어디를 보나** — JSON 배열이 여러 줄로 나온다. `providerId` 가 `rsa-generated` 인 항목이 서명 키 공급자이고 `hmac-generated` · `aes-generated` 등이 함께 나온다. `"name" : "rsa-generated"` 인 항목의 `"id"` 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.
|
||||
|
||||
**이 값이 뜻하는 것** — 여기서 「키 공급자만 걸러 보자」는 시도가 빈 결과를 준다. 가이드가 이 줄을 미검증으로 표시했다(unknown) — 원래 실행에서 이렇게 치고 아무것도 못 받았다.
|
||||
|
||||
```bash label="[kc-lab-1] 이렇게 치면 조용히 빈 결과다 (unknown)"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
|
||||
```
|
||||
|
||||
오류도 종료코드도 없이 비어 있다. 「키 공급자가 하나도 없구나」로 읽으면 이 절차 전체가 무너진다. 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있고, `--fields` 로 전체를 받아 눈으로 고른다.
|
||||
|
||||
### 3. JWKS 원문을 한 번 통째로 본다
|
||||
|
||||
**무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
|
||||
|
||||
```bash label="[kc-lab-1] ① JWKS 를 자르지 않고 본다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||
```
|
||||
|
||||
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`).
|
||||
|
||||
```text
|
||||
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
```
|
||||
|
||||
그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
|
||||
|
||||
```bash label="[kc-lab-1] ② kid 만 뽑아 본다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
|
||||
|
||||
```text
|
||||
JWKS kid 목록:
|
||||
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `kid` 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).
|
||||
|
||||
```text
|
||||
JWKS 의 RS256 키 수: 1
|
||||
```
|
||||
|
||||
세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고 그것도 `kid` 를 갖는다. `grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.
|
||||
|
||||
### 4. RS256 만 세는 두 형태를 알아 둔다
|
||||
|
||||
**무엇을 보는가** — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).
|
||||
|
||||
JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 키 단위로 잘라 RS256 만 센다 (unknown)"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr '}' '\n' | grep -c RS256
|
||||
```
|
||||
|
||||
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
|
||||
|
||||
```bash label="[kc-lab-1] ② Keycloak 에 직접 묻는다 (unknown)"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get keys -r keycloak-patterns
|
||||
```
|
||||
|
||||
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
|
||||
|
||||
**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.
|
||||
|
||||
### 5. 시험체가 될 옛 토큰을 하나 받아 둔다
|
||||
|
||||
**목적** — 회전 전에 발급된 토큰을 확보한다. 이 토큰 하나가 이 절차의 시험체다.
|
||||
|
||||
**행동** — 토큰 엔드포인트와 클라이언트 비밀을 변수에 담고 direct grant 로 받는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 옛 키로 서명된 토큰을 받고 길이만 본다"
|
||||
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" \
|
||||
-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')
|
||||
echo "${#OLD}자"
|
||||
```
|
||||
|
||||
**예상 결과** — 길이만 나온다. 원래 실행의 모양은 이렇다(observed).
|
||||
|
||||
```text
|
||||
2043자
|
||||
```
|
||||
|
||||
**왜 필요한가** — 변수 이름이 `OLD` 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.
|
||||
|
||||
**문제가 생기면** — `0자` 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 `curl` 을 그대로 쳐서 응답 본문을 읽는다.
|
||||
|
||||
### 6. 그 토큰이 어느 키로 서명됐는지 읽는다
|
||||
|
||||
**무엇을 보는가** — JWT 의 첫 토막이 헤더이고 거기 `kid` 가 있다.
|
||||
|
||||
```bash label="[kc-lab-1] 토큰 헤더를 디코드한다"
|
||||
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
|
||||
**어디를 보나** — 원래 실행의 모양은 이렇다(observed).
|
||||
|
||||
```json
|
||||
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||
```
|
||||
|
||||
증거 파일에는 이렇게 남아 있다(observed, `01-before-rotation.txt`).
|
||||
|
||||
```text
|
||||
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — `kid` 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다. `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 `kid` 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(`2>/dev/null` 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.
|
||||
|
||||
### 7. 그 토큰이 지금 통하는지 본다
|
||||
|
||||
**무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 상태줄과 본문을 함께 본다"
|
||||
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 상태 코드만 뽑는다"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||
```
|
||||
|
||||
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
|
||||
|
||||
```text
|
||||
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
|
||||
/api/me HTTP 200
|
||||
```
|
||||
|
||||
**이 값이 뜻하는 것** — 회전 전에는 통한다. 원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤고, 이 절차가 공개 이름을 쓰는 까닭은 `kc-lab-1` 에서 클러스터 DNS 가 안 풀리기 때문이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo` 로 보내므로 도달하는 앱은 같다. 그리고 access token 은 60초짜리다(이 realm 은 `accessTokenLifespan=60`). 1분을 넘기면 회전과 무관하게 401 이 나온다.
|
||||
|
||||
## 주입
|
||||
|
||||
### 8. 우선순위가 더 높은 RSA 공급자를 추가한다
|
||||
|
||||
**목적** — 발급은 새 키로 가고 검증은 옛 키와 새 키를 둘 다 받는 상태를 만든다.
|
||||
|
||||
Keycloak 의 키 회전은 바꾸기가 아니라 더 높은 우선순위로 추가하기다. 기존 공급자는 그대로 두고 `priority` 가 더 큰 공급자를 하나 더 만든다.
|
||||
|
||||
```text
|
||||
t0 키 A 만 있다. 발급: A, 검증: A
|
||||
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
|
||||
t2 키 A 제거. 발급: B, 검증: B
|
||||
```
|
||||
|
||||
**행동** — 공급자를 만들고 시각을 남긴다.
|
||||
|
||||
```bash label="[kc-lab-1] priority 200 짜리 RSA 공급자를 만든다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create components -r keycloak-patterns \
|
||||
-s name=rsa-rotated -s providerId=rsa-generated \
|
||||
-s providerType=org.keycloak.keys.KeyProvider \
|
||||
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
|
||||
date '+%H:%M:%S 추가'
|
||||
```
|
||||
|
||||
**예상 결과** — 실측은 이렇다(observed, `02-rotation.txt`).
|
||||
|
||||
```text
|
||||
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
|
||||
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
|
||||
```
|
||||
|
||||
돌아온 id 를 적어 둔다. 전제 절의 되돌리기가 그 값을 쓴다.
|
||||
|
||||
**왜 필요한가** — `config.priority` 가 기존 공급자보다 커야 발급이 새 키로 간다. 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 `kid` 가 안 바뀐다. `config.*` 값이 대괄호로 감싼 배열인 것에도 주의한다 — `-s config.priority=200` 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
|
||||
|
||||
**문제가 생기면** — 생성이 거절되면 `config.*` 의 대괄호부터 본다.
|
||||
|
||||
## 주입 검증
|
||||
|
||||
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 주입 전과 똑같은 명령을 다시 친다.
|
||||
|
||||
### 9. JWKS 에 옛 키가 남아 있는가
|
||||
|
||||
```bash label="[kc-lab-1] 3 절과 똑같은 줄을 다시 친다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-rotation.txt`).
|
||||
|
||||
```text
|
||||
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
|
||||
RS256 키 수: 2
|
||||
kid 목록:
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
```
|
||||
|
||||
옛 `kid`(`OY-caYDN…`)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.
|
||||
|
||||
### 10. 새 토큰은 어느 키로 서명되는가
|
||||
|
||||
`OLD` 은 건드리지 않는다.
|
||||
|
||||
```bash label="[kc-lab-1] 새 토큰을 받고 헤더를 읽는다"
|
||||
NEW=$(curl -s -X POST "$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')
|
||||
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-rotation.txt`).
|
||||
|
||||
```text
|
||||
=== [5] 새 토큰은 어느 키로 서명되는가 ===
|
||||
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
|
||||
```
|
||||
|
||||
`kid` 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 `kid` 가 안 바뀌었다면 `priority` 를 낮게 준 것이다.
|
||||
|
||||
### 11. 둘 다 통해야 겹치는 구간이 무중단이다
|
||||
|
||||
```bash label="[kc-lab-1] 두 토큰을 같은 두 줄로 친다"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" 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
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `02-rotation.txt`).
|
||||
|
||||
```text
|
||||
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
|
||||
옛 토큰 /api/me HTTP 200
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.
|
||||
|
||||
여기서 `old` 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 `exp` 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.
|
||||
|
||||
```bash label="[kc-lab-1] 만료인지 아닌지 가른다"
|
||||
echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
date +%s
|
||||
```
|
||||
|
||||
`exp` 가 지금보다 작으면 만료다. 다시 받아야 하는데, 순서 그대로 5 절만 다시 치면 8 절이 만든 `rsa-rotated` 가 이미 `priority` 200 이라 새로 받은 토큰도 새 키로 서명된다. 8 절이 화면에 찍어 준 id 로 그 공급자부터 지우고(전제 절의 되돌리기 한 줄), 5 절로 토큰을 받고, 8 절을 다시 쳐서 추가한 뒤 11 절로 온다.
|
||||
|
||||
## 관찰
|
||||
|
||||
**여기서부터 되돌릴 수 없다.** 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 11 절의 `old 200` 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.
|
||||
|
||||
**넷째로 `$OLD` 에 시간이 얼마나 남았는지 본다.** access token 이 60초짜리라, 12 절에서 목록을 받아 눈으로 id 를 고르고 13 절에서 그 id 를 손으로 옮겨 적는 동안 만료된다. 그러면 15 절의 `old 401` 이 키 제거 때문인지 만료 때문인지 안 갈린다 — 이 절차의 결론이 바로 그 401 이라, 여기서 못 가르면 아무것도 못 잰다. 11 절의 만료 확인 두 줄을 지금 한 번 쳐서 `exp` 와 `date +%s` 의 차이를 보고, 12·13 절을 칠 만큼 안 남았으면 11 절이 안내한 대로 `$OLD` 를 다시 받고 온다.
|
||||
|
||||
### 12. 지울 대상을 정확히 고른다
|
||||
|
||||
**무엇을 보는가** — 남길 것과 지울 것의 id. `-q` 는 여전히 안 먹는다.
|
||||
|
||||
```bash label="[kc-lab-1] ① 목록을 다시 받는다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId
|
||||
```
|
||||
|
||||
목록이 길면 그 항목 둘레만 잘라 본다. `"id"` 는 `"name"` 보다 위에 나온다.
|
||||
|
||||
```bash label="[kc-lab-1] ② 지울 항목 둘레만 본다"
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get components -r keycloak-patterns --fields id,name,providerId \
|
||||
| grep -B2 '"name" : "rsa-generated"'
|
||||
```
|
||||
|
||||
**어디를 보나** — 남길 것이 `"name" : "rsa-rotated"` 이고 지울 것이 `"name" : "rsa-generated"` 다.
|
||||
|
||||
**이 값이 뜻하는 것** — 둘을 바꿔 지우면 실험이 뒤집힌다. 원래 실행에서 옛 공급자의 id 는 `980ee9b7…` 로 시작했고 그것이 `OY-caYDN…` 키를 갖고 있었다. 환경마다 id 가 다르고 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 출력에서 그대로 옮겨 온다.
|
||||
|
||||
### 13. 옛 공급자를 지운다
|
||||
|
||||
**목적** — 옛 서명 키를 JWKS 에서 없앤다.
|
||||
|
||||
**행동** — 위 출력의 id 를 변수에 옮기고 지운다. 아래 블록은 그대로 붙여넣으면 안 된다. 첫 줄의 `980ee9b7-...` 은 원래 실행의 값이므로 12 절 출력에서 읽은 자기 id 로 바꾼다. 안 바꾸고 치면 없는 컴포넌트를 지우라는 요청이 되어 옛 공급자는 살아 있고, 15 절이 `옛 200` 을 내 결론이 뒤집힌다.
|
||||
|
||||
```bash label="[kc-lab-1] 옛 공급자를 지우고 시각을 남긴다"
|
||||
OLDID=980ee9b7-... # ← 위 출력에서 그대로 옮긴다. 환경마다 다르다
|
||||
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
delete components/"$OLDID" -r keycloak-patterns
|
||||
date '+%H:%M:%S 제거'
|
||||
```
|
||||
|
||||
**예상 결과** — 조용히 끝나면 성공이다. 증거 파일에는 이렇게 남아 있다(observed, `03-old-key-removed.txt`).
|
||||
|
||||
```text
|
||||
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
|
||||
제거 완료
|
||||
```
|
||||
|
||||
**왜 필요한가** — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.
|
||||
|
||||
**문제가 생기면** — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 `kid` 를 대조한다.
|
||||
|
||||
### 14. JWKS 에서 사라졌는지 본다
|
||||
|
||||
```bash label="[kc-lab-1] 또 같은 줄을 친다"
|
||||
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||
| tr ',' '\n' | grep kid
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-old-key-removed.txt`).
|
||||
|
||||
```text
|
||||
=== [8] JWKS 에서 사라졌는가 ===
|
||||
RS256 키 수: 1
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
```
|
||||
|
||||
`OY-caYDN…` 이 없고 RS256 은 다시 하나다. `gokjn0…` 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.
|
||||
|
||||
### 15. 두 토큰을 같은 두 줄로 다시 친다
|
||||
|
||||
**치기 전에 `$OLD` 가 아직 안 죽었는지 본다.** 11 절의 만료 확인 두 줄을 그대로 다시 친다. `exp` 가 `date +%s` 보다 작으면 아래에서 나올 `old 401` 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.
|
||||
|
||||
```bash label="[kc-lab-1] 11 절과 똑같은 두 줄"
|
||||
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||
-H "Authorization: Bearer $OLD" 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
|
||||
```
|
||||
|
||||
실측은 이렇다(observed, `03-old-key-removed.txt`).
|
||||
|
||||
```text
|
||||
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
|
||||
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
|
||||
|
||||
**`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).
|
||||
|
||||
### 16. 리소스 서버를 재시작해 캐시를 비운다
|
||||
|
||||
**목적** — 401 이 캐시 상태 때문인지 가른다.
|
||||
|
||||
```bash label="[kc-lab-1] echo 를 다시 띄우고 기다린다"
|
||||
kubectl -n header-lab rollout restart deploy/echo
|
||||
kubectl -n header-lab rollout status deploy/echo --timeout=180s
|
||||
```
|
||||
|
||||
그다음 15 절의 두 줄을 다시 친다. 실측은 이렇다(observed, `03-old-key-removed.txt`).
|
||||
|
||||
```text
|
||||
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
|
||||
deployment "echo" successfully rolled out
|
||||
옛 토큰 /api/me HTTP 401
|
||||
새 토큰 /api/me HTTP 200
|
||||
```
|
||||
|
||||
**왜 필요한가** — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 `NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.
|
||||
|
||||
```text
|
||||
옛 토큰 도착
|
||||
│
|
||||
├─▶ kid = OY-caYDN… → 캐시에 없다
|
||||
│ │
|
||||
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
|
||||
│
|
||||
└─▶ 새로 받은 JWKS 에도 없다 → 401
|
||||
```
|
||||
|
||||
캐시가 오히려 제거를 빨리 반영시킨다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만든다.
|
||||
|
||||
**문제가 생기면** — 재시작 뒤 옛 토큰이 200 이면 지운 것이 그 토큰의 키가 아니다. 12 절의 목록과 6 절의 `kid` 를 대조한다.
|
||||
|
||||
### 겹치는 구간은 얼마나 길어야 하나
|
||||
|
||||
**이 절차는 그 길이를 재지 않았다.** 추가와 제거가 연달아 일어났고 전 구간이 약 15분이다. 아래 값은 realm 설정에서 따라 나온 추론이다.
|
||||
|
||||
| 이 실험대에서 | 수명 |
|
||||
|---|---|
|
||||
| access token | 60초 |
|
||||
| refresh token | 1800초 (30분) |
|
||||
| 필요한 겹침 | 최소 30분 — 앞 두 값에서 따라 나온 추론이고 측정하지 않았다 |
|
||||
|
||||
겹침의 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명과 같다. 실제로 재려면 추가와 제거 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다. 이 절차에는 그 단계가 없다.
|
||||
|
||||
수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
|
||||
|
||||
```bash label="[kc-lab-1] realm 의 수명 세 값을 받는다 (unknown)"
|
||||
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분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
|
||||
|
||||
```text
|
||||
쓰기: 새 key 하나로만
|
||||
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
|
||||
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
|
||||
```
|
||||
|
||||
저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다. 그것이 없을 때 무슨 일이 나는지는 B-7 이 잰다.
|
||||
|
||||
## 복구와 원상복구 확인표
|
||||
|
||||
**이 절차에는 원상복구가 없다.** 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 `kid` 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 새 키 하나만 남은 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.
|
||||
|
||||
| 남은 것 | 어떻게 | 왜 |
|
||||
|---|---|---|
|
||||
| `rsa-rotated` 공급자 | 그냥 둔다. 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
|
||||
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
|
||||
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
|
||||
|
||||
이름이 거슬리면 새 공급자를 하나 더 만들고 `rsa-rotated` 를 지운다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 서명 키 | `curl -s …/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 하나 |
|
||||
| 새 토큰 | 토큰 발급 + `/api/me` | `200` |
|
||||
| 리소스 서버 | `kubectl -n header-lab get pods` | `echo` 가 `1/1 Running` |
|
||||
| Keycloak | `kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
|
||||
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
|
||||
|
||||
## 막히면
|
||||
|
||||
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| `kcadm get components -q type=...` 가 빈 결과 | `-q` 필터가 안 먹는다. 오류도 없다 | `--fields id,name,providerId` 로 전체를 받는다 |
|
||||
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | JWKS 와 토큰은 `kc-lab-1` 호스트에서 친다 |
|
||||
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 |
|
||||
| kid 를 세니 2개인데 문서는 1개라고 한다 | RS256 이 아닌 암호화 키가 섞여 있다 | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` |
|
||||
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 배열인지 |
|
||||
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 토큰이 만료됐다(60초) | 클레임의 `exp` 와 `date +%s` 비교 |
|
||||
| 제거했는데 새 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 |
|
||||
| 제거했는데 옛 토큰이 200 | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 |
|
||||
| 「캐시 때문일 것」이라 재시작을 기다린다 | 캐시는 유예를 주지 않는다 | 재시작 전후가 같다 |
|
||||
| 지운 키를 되살리려 한다 | 되살릴 수 없다. 같은 이름과 같은 키는 다르다 | 새 키 하나만 남은 상태가 정상이다 |
|
||||
|
||||
## 무엇이 관측이고 무엇이 아닌가
|
||||
|
||||
이 절차의 숫자는 `2026-09-04 14:30–14:32 KST` 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 `15:50–16:00 KST` 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.
|
||||
|
||||
- (observed) 회전 전 JWKS 의 `kid` 두 개(`gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4` · `OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM`)와 RS256 키 수 1, 발급 토큰의 `kid` 가 `OY-caYDN…` 인 것, 그 토큰의 `/api/me HTTP 200`, 추가한 공급자의 id `7902af43-a0cc-4ebd-ad25-04d563854d16`, 회전 후 RS256 키 수 2 와 늘어난 `kid` `1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84`, 새 토큰의 `kid` 가 그것인 것, 겹치는 구간의 `옛 200 · 새 200`, 제거 후 RS256 키 수 1 과 `옛 401 · 새 200`, `echo` 를 재시작한 뒤에도 `옛 401 · 새 200` 인 것.
|
||||
- 비밀은 길이와 존재만 적었다. 클라이언트 비밀은 `CS` 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 `wc -c` 로 길이만 본다. 토큰은 `2043자` 라는 길이만 옮겼고 값은 증거 파일에 있다. `kid` 와 공급자 id 는 공개 식별자라 그대로 적었다.
|
||||
- (unknown) `-q type=org.keycloak.keys.KeyProvider` 로 거르는 줄(조용히 빈 결과를 준다), `tr '}' '\n' | grep -c RS256` 로 RS256 만 세는 줄, `kcadm get keys` 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
|
||||
- 판 번호는 이 편의 출력에 하나도 안 찍혔다. B층 아홉 편 가운데 판 번호가 남은 것은 다섯 편이고 B-6 은 거기 없다(observed). `curl 8.5.0` 은 같은 실험대의 B-4 가 echo 앱에서 되돌려받은 user-agent 이지 이 편이 잰 값이 아니다(inferred).
|
||||
- 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user