기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과 keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지 밟았고 밟은 범위를 편마다 적었다. 명령이 못 도는 것을 고쳤다. - kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다. 라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다 - `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표 - busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다 - `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을 못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다 - 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v` - `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어 그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다 - B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다 - D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는 `kc-lab-edge` 의 `certbot.service` 다 - `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로 - `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다 결과가 조건부인 것을 갈랐다. - readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다 - 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다 - A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다 - A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다 증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은 길이만 적었고 화면에 찍힌 토큰은 가렸다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
742 lines
42 KiB
Markdown
742 lines
42 KiB
Markdown
---
|
||
id: 3421185f-5f3c-4263-9455-6243306e9fc9
|
||
kind: SETUP
|
||
slug: reproduce-c1-multi-app-sso
|
||
title: 두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다
|
||
topic: trust-handed-over-at-the-edge
|
||
topicName: 위조 신원 헤더와 로그아웃 전파
|
||
project: keycloak-session-store
|
||
status: 게시 전
|
||
studio: "https://hyeonworks.com/studio/documents/3421185f-5f3c-4263-9455-6243306e9fc9/edit"
|
||
pinnedVersions:
|
||
- name: keycloak-pattern-bff
|
||
version: lab
|
||
- name: Redis
|
||
version: 7.4.x
|
||
source:
|
||
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-1
|
||
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서
|
||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||
---
|
||
|
||
# 두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다
|
||
|
||
app1 과 app2 를 한 번의 로그인으로 묶은 뒤 IdP 세션만 끊고, 두 앱의 Redis 키가 글자 하나까지 같은지 터미널 출력으로 판정하는 절차다. 세션을 전부 지우고 시작하므로 실험대에서만 한다. 약 20분.
|
||
|
||
## 관계
|
||
|
||
- **백채널 로그아웃은 양쪽 다 없었다**
|
||
이 절차가 관측한 「로그아웃이 안 퍼진다」의 원인을 그 기록이 셋으로 나눠 판정한다. 여기서는 원인을 묻지 않고 현상까지만 친다.
|
||
- **클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다**
|
||
Keycloak 세션이 캐시로 답한다는 것을 그쪽이 먼저 확인했다. 그래서 이 절차에서 `logout-all` 이 오류도 안 내고 세션도 안 줄인다.
|
||
- **세션과 인가된 클라이언트는 조회 키가 다르다**
|
||
같은 Redis 에 접두사가 다른 두 세션이 나란히 놓이는 것을 그 기록이 키 설계 쪽에서 설명한다.
|
||
- **IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다**
|
||
이 절차가 만든 상태 위에 선다. 이어서 할 생각이면 빌린 이름을 아직 돌려주지 않는다.
|
||
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
|
||
먼저 해 둬야 하는 편이다. 여기서 app1 로 쓰는 BFF 를 거기서 세운다.
|
||
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
|
||
먼저 해 둬야 하는 편이다. 여기서 app2 로 쓰는 oauth2-proxy 와 빌린 Ingress 를 거기서 만든다.
|
||
|
||
## 본문
|
||
|
||
<!-- body:start -->
|
||
|
||
## 읽기 전에 — 어디서 치는가
|
||
|
||
명령은 `[lab host]` 에서 `kubectl` 로 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 반입한 가이드의 전제 문장은 `sudo kubectl` 로 적혀 있지만 같은 폴더의 README 가 반대로 적고, 본문 명령 블록에도 `sudo kubectl` 은 한 번도 없다. `sudo` 를 붙이면 root 환경으로 돌아 사용자 홈의 kubeconfig 를 못 본다.
|
||
|
||
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
|
||
|
||
`[밖에서]` 라벨이 붙은 `curl` 은 클러스터 밖에서 공개 이름을 두드린다는 뜻이다. 어느 기계에서 치라는 줄은 가이드에 없으므로(unknown), `https://app1.hyeonworks.com` 이 풀리는 기계면 어디서든 친다. 거기로 가는 `ssh` 명령도 가이드에 없다.
|
||
|
||
앱이 둘 필요하다. app1 은 BFF(Backend for Frontend, 브라우저 대신 토큰을 들고 있는 백엔드)이고 app2 는 oauth2-proxy 다.
|
||
|
||
브라우저도 필요하다. 인가 코드 흐름은 브라우저와 Keycloak 사이를 두 번 왕복하고, 두 번째 왕복에서 화면이 뜨는가 안 뜨는가가 이 절차의 관측 대상이다. `curl` 로는 「로그인 화면이 안 떴다」를 볼 수단이 없다. 브라우저 창 하나와 터미널 하나를 나란히 둔다.
|
||
|
||
| 무엇 | 값 |
|
||
|---|---|
|
||
| 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` |
|
||
| 앱 | 둘. 둘 다 realm `keycloak-patterns` 를 본다 |
|
||
| 빌리는 이름 | `app2.hyeonworks.com` — 인증서가 세 이름만 덮어서 B-7 이 Grafana 에서 빌렸다 |
|
||
| 브라우저 | 같은 창의 새 탭으로 app2 를 연다. 시크릿 창은 SSO 쿠키가 없어 다른 결과가 나온다 |
|
||
| 주입 수단 | 브라우저로 app1 에 로그인하고 app2 를 방문한다 |
|
||
| 파괴 | 시작할 때 Keycloak 세션 테이블과 Redis 를 비우고 StatefulSet 을 재시작한다 |
|
||
| 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없다 |
|
||
| 전 구간 | 약 20분. Keycloak 재시작에만 1~2분 |
|
||
|
||
## 이 실험이 가르는 것
|
||
|
||
원래 질문은 한 줄이었다.
|
||
|
||
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
|
||
|
||
「달라진다」에는 방향이 둘 섞여 있다. 편해지는 쪽과 위험해지는 쪽이다. 위험 쪽의 통념은 이렇다.
|
||
|
||
| | 예측 |
|
||
|---|---|
|
||
| 통념 | SSO 를 붙이면 IdP 가 단일 장애점이 된다. IdP 가 죽으면 다 죽는다 |
|
||
| 실측 | 절반만 맞다. 로그인 **경로**는 그렇고, 이미 로그인한 사용자는 아니다 |
|
||
|
||
둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 갈린다. 수명이 세 층으로 나뉘어 있어서 그 셋을 따로 건드릴 수 있다.
|
||
|
||
```text
|
||
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
|
||
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
|
||
③ access token 60초
|
||
|
||
①을 지워도 ②는 자기 수명을 산다
|
||
```
|
||
|
||
로그아웃이 지우는 것은 ① 뿐이다. 이 실험대에는 세션을 정반대로 다루는 앱이 둘 있어서 ②가 살아남는 모습을 두 형태로 동시에 볼 수 있다.
|
||
|
||
```text
|
||
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
|
||
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
|
||
|
||
둘 다 realm keycloak-patterns
|
||
```
|
||
|
||
가이드는 이것을 우연히 좋은 실험대라고 적는다. B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이 같은 IdP 를 쓰면서 세션을 다르게 다룬다.
|
||
|
||
절차를 끝까지 밟으면 여섯을 자기 화면에서 보게 된다. 두 번째 앱이 로그인 화면 없이 열리는 것, `user session 1` 에 `client session 2` 가 매달린 구조, 구조가 다른 두 앱이 같은 user session 을 공유하는 것, IdP 세션을 죽여도 두 앱이 열리는 것, `logout-all` 이 오류 없이 아무것도 안 하는 것, realm 을 안 보고 세면 `master` 의 admin 세션에 속는다.
|
||
|
||
## 전제와 되돌리기
|
||
|
||
- B-2 의 app1(BFF)과 B-7 의 app2(oauth2-proxy)가 둘 다 떠 있다. 없으면 SSO 가 아니라 로그인 한 번이다.
|
||
- `app2.hyeonworks.com` 은 Grafana 에서 빌린 이름이다. 이 실험이 끝나면 Ingress 를 돌려준다.
|
||
- 브라우저가 있어야 한다. `curl` 로는 「로그인 화면이 안 떴다」를 볼 수 없다.
|
||
- Keycloak 이미지에는 `curl` 도 `wget` 도 없다(`exit 127`). `kcadm.sh` 는 파드 안에 있으므로 항상 `kubectl exec` 로 감싼다.
|
||
- `jq` 는 이 실험대에 깔려 있지 않다.
|
||
- `~/grafana-ingress-backup.yaml` 이 손에 있어야 한다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 절차는 가이드에 없다(unknown). B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다.
|
||
|
||
:::danger
|
||
|
||
이 실험은 세션을 전부 지우고 시작한다. 비교할 상태를 만들려고 Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고, Keycloak StatefulSet 을 재시작한다. 그 순간 지금 로그인해 있는 모든 사람이 끊긴다.
|
||
|
||
:::
|
||
|
||
되돌리기는 둘이고 둘 다 먼저 읽어 둔다. 하나는 세션을 다시 깨끗하게 만드는 것이라 주입 전 절차와 같은 명령이다.
|
||
|
||
```bash label="[lab host] ① 세션을 비우고 캐시를 버린다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||
```
|
||
|
||
다른 하나는 빌린 이름을 돌려준다. ②는 먼저 지우고 나중에 올리므로, 백업 파일이 비어 있으면 Grafana 가 안 열리는 채로 끝난다. 실제로 칠 때는 복구 3 절의 확인 두 줄을 먼저 친다.
|
||
|
||
```bash label="[lab host] ② 빌린 Ingress 를 걷고 Grafana 것을 올린다"
|
||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||
```
|
||
|
||
:::warning
|
||
|
||
C-2 를 이어서 할 생각이면 아직 돌려주지 않는다. C-2 가 두 앱을 그대로 쓴다.
|
||
|
||
:::
|
||
|
||
지운 세션은 안 돌아온다. 이 실험의 파괴에는 되돌리기가 없고, 다시 로그인하는 것이 복구다.
|
||
|
||
## 주입 전에 같은 명령으로 먼저 본다
|
||
|
||
넓은 것부터 좁혀 간다. 이 층에서는 그 경로가 한 번 꺾인다. 세션을 지우려다 안 지워지는 것을 먼저 보고, 그다음에 세는 법을 고친다.
|
||
|
||
```text
|
||
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
|
||
```
|
||
|
||
### 1. 앱 둘과 Ingress 둘이 살아 있는가
|
||
|
||
**목적** — SSO 가 성립할 조건을 확인한다. 앱이 하나면 이 실험은 로그인 한 번이다.
|
||
|
||
**행동** — 클러스터 안을 먼저 보고, 밖에서 두 이름을 두드린다.
|
||
|
||
```bash label="[lab host] ① 파드와 Ingress 를 통째로 본다"
|
||
kubectl -n keycloak-lab get pods -o wide
|
||
kubectl -n keycloak-lab get ingress
|
||
```
|
||
|
||
```bash label="[밖에서] ② 두 이름의 상태 코드만 뽑는다"
|
||
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/
|
||
```
|
||
|
||
**예상 결과** — ① 은 `bff` 와 `oauth2-proxy` 가 둘 다 `Running` 이고 Ingress 에 `app1.hyeonworks.com` 과 `app2.hyeonworks.com` 이 둘 다 있다. ② 의 실측은 이렇다(observed, `01-baseline.txt`).
|
||
|
||
```text
|
||
app1 HTTP 200 / app2 HTTP 200
|
||
```
|
||
|
||
**왜 필요한가** — 두 앱이 같은 realm 을 보고 있어야 client session 이 하나의 user session 아래 붙는다.
|
||
|
||
**문제가 생기면** — `app2` 가 Grafana 로 가면 B-7 의 Ingress 가 없다. 그쪽 백업과 적용을 먼저 한다.
|
||
|
||
### 2. kcadm 을 로그인시킨다
|
||
|
||
**목적** — 관리 API 를 칠 수 있게 한다.
|
||
|
||
**행동** — 관리자 비밀번호를 Secret 에서 읽어 명령 치환으로 넘긴다.
|
||
|
||
```bash label="[lab host] 비밀번호를 화면에 찍지 않고 로그인한다"
|
||
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)"
|
||
```
|
||
|
||
**예상 결과** — 오류 없이 끝나고 다음 `kcadm` 호출이 `401` 을 안 낸다.
|
||
|
||
**왜 필요한가** — 값이 명령 치환 안에서만 흐르므로 터미널에도 셸 히스토리에도 남지 않는다. 길이를 확인하는 명령은 B-0 절에 있다.
|
||
|
||
**문제가 생기면** — 아래에서 Keycloak 파드를 재시작하면 이 세션이 사라지고 이후 모든 `kcadm` 이 `401` 이 된다. 그때 이 명령을 다시 친다.
|
||
|
||
### 3. 가장 자연스러운 방법으로 세션을 지워 본다
|
||
|
||
**목적** — realm 전체 로그아웃이 실제로 무엇을 하는지 본다.
|
||
|
||
**행동** — 관리 API 를 치고 남은 세션을 센다.
|
||
|
||
```bash label="[lab host] ① realm 전체 로그아웃을 건다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
create realms/keycloak-patterns/logout-all
|
||
```
|
||
|
||
이 실험대는 세는 쪽을 스크립트로 돌렸고 증거에 SQL 원문이 없다. 따라 하는 사람은 가이드가 손으로 치기 좋게 고친 아래 형태를 친다. **2026-09-17 에 쳐서 돌았다**(observed) — 그때까지 미검증이던 줄이다.
|
||
|
||
```bash label="[lab host] ② 남은 세션을 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session where offline_flag='0'"
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `01-baseline.txt`).
|
||
|
||
```text
|
||
=== 깨끗한 상태로 초기화 ===
|
||
DELETE 1
|
||
|
||
=== 기준선 ===
|
||
Keycloak 온라인 세션: 4
|
||
Redis 키: 0
|
||
```
|
||
|
||
`4` 다. 0 이 아니다. `logout-all` 이 오류도 안 내고 세션도 안 줄였다. A-1 에서 확인한 대로 Keycloak 은 세션을 DB 에서 읽되 캐시로 답한다. 관리 API 가 무효화를 걸어도 각 노드의 캐시가 안 바뀌면 세션은 살아 있는 것처럼 보인다.
|
||
|
||
**왜 필요한가** — 해설 문서의 이 값은 한 번 정정됐다. 처음에는 `0` 으로 인쇄됐는데 증거 `01-baseline.txt` 는 `4` 이고, `0` 은 다음 단계의 값이었다. 여기서는 4 가 나온다.
|
||
|
||
**문제가 생기면** — 다음 단계로 간다. 이 단계에서 0 을 만들려고 애쓰지 않는다.
|
||
|
||
### 4. DB 를 직접 지우고 캐시를 버린다
|
||
|
||
**목적** — `keycloak-patterns` 세션 수와 Redis 키를 둘 다 0 으로 만든다.
|
||
|
||
**2026-09-17 실측**(observed) — `logout-all` 을 친 **뒤에도** 세션이 그대로 `6` 건이었다. 이 방법으로는 안 지워진다는 것이 이 절의 결론이고, 그래서 아래 4절로 간다.
|
||
|
||
**행동** — 자식 테이블부터 지우고, 앱 세션을 비우고, 프로세스를 새로 띄운다.
|
||
|
||
```bash label="[lab host] ① 자식 테이블부터 지운다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||
```
|
||
|
||
2026-09-17 실측이다(observed). **자식 쪽이 훨씬 많다** — 부모부터 지우려 했다면 외래키에 걸렸을 것이다.
|
||
|
||
```text
|
||
DELETE 1633
|
||
DELETE 6
|
||
```
|
||
|
||
원래 실행의 모양은 이렇다(observed).
|
||
|
||
```text
|
||
DELETE 2
|
||
DELETE 4
|
||
```
|
||
|
||
```bash label="[lab host] ② 앱 세션도 비운다"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||
```
|
||
|
||
```bash label="[lab host] ③ 캐시를 버리려면 프로세스를 새로 띄운다"
|
||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||
```
|
||
|
||
③ 이 끝나면 `keycloak-0` 파드가 새로 뜨고 그 안에 있던 kcadm 세션 파일이 함께 사라진다. 2026-09-17 에 그대로 겪었다(observed) — 롤아웃 직후 `kcadm` 이 이렇게 끝난다.
|
||
|
||
```text
|
||
No server specified. Use --server, or 'kcadm.sh config credentials'.
|
||
```
|
||
그러니 롤아웃이 끝나는 대로 주입 전 2 절의 `config credentials` 를 다시 친다. 건너뛰면 뒤에 나오는 `kcadm` 이 전부 `401` 을 내는데, 그 사실은 관찰 1 절에 가서야 보인다.
|
||
|
||
```bash label="[lab host] ④ 두 저장소를 다시 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session where offline_flag='0'"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `01-baseline.txt`).
|
||
|
||
```text
|
||
Redis 키: 0
|
||
```
|
||
|
||
Redis 키 0, 세션 수 0 이어야 한다.
|
||
|
||
**왜 필요한가** — 캐시가 답하는 한 관리 API 로는 0 을 만들 수 없다. 행을 지우는 명령과 캐시를 버리는 명령이 따로 있다.
|
||
|
||
:::danger
|
||
|
||
`flushall` 은 이 Redis 전체를 지운다. BFF 세션과 oauth2-proxy 세션은 물론 B-5 가 남긴 `b5:pvc` 까지 전부다. 깨끗한 상태를 만드는 이 단계에서만 치고, 실험 도중에는 쓰지 않는다(B-7a 참고).
|
||
|
||
:::
|
||
|
||
**문제가 생기면** — 여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다. 그리고 재시작으로 `kcadm` 세션이 날아갔으므로 주입 전 2 절의 `config credentials` 를 다시 친다.
|
||
|
||
### 5. 세는 법을 고친다
|
||
|
||
**목적** — 숫자에 realm 을 붙인다.
|
||
|
||
**행동** — 두 형태를 나란히 친다. 이 실험대가 쓴 쪽이 틀린 방법이다.
|
||
|
||
```bash label="[lab host] ① 전체를 세는 쪽 — 틀린 방법이다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session where offline_flag='0'"
|
||
```
|
||
|
||
따라 하는 사람은 `realm` 을 조인한다. 가이드가 미검증으로 표시한 형태다(unknown).
|
||
|
||
```bash label="[lab host] ② realm 을 조인하는 쪽"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select us.user_session_id, r.name as realm,
|
||
(select count(*) from offline_client_session cs
|
||
where cs.user_session_id=us.user_session_id) as clients
|
||
from offline_user_session us join realm r on r.id=us.realm_id
|
||
where us.offline_flag='0'"
|
||
```
|
||
|
||
**예상 결과** — `realm` 열이 나온다. `keycloak-patterns` 만이 이 실험의 대상이다. `master` 행은 4 절 뒤에 `config credentials` 를 다시 친 사람에게만 보인다 — 그 한 줄이 admin 세션을 새로 만든다. 4 절이 `master` 세션까지 지우고 재시작이 kcadm 세션을 날렸으므로, 아직 다시 안 쳤으면 아무 행도 안 나오고 그것도 맞는 상태다.
|
||
|
||
**왜 필요한가** — `offline_user_session` 에는 모든 realm 의 세션이 들어 있고, `kcadm` 을 쓰는 순간 `master` realm 에 admin 세션이 생긴다. 그냥 세면 내가 만든 잡음을 남의 세션으로 읽는다. 원래 실행은 이 한 열 때문에 「안 지워졌다」로 오독할 뻔했다. 여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다. 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
|
||
|
||
**문제가 생기면** — 이 단계를 건너뛰면 관찰 절의 결론을 반대로 읽는다.
|
||
|
||
## 주입
|
||
|
||
주입은 브라우저로 한다. 두 앱에 차례로 들어가면 SSO 상태가 된다. 파괴적인 조작은 관찰 절에 있고 여기까지는 초기화를 다시 하면 되돌아온다.
|
||
|
||
### 1. app1 에 로그인한다
|
||
|
||
**목적** — 첫 로그인으로 IdP 세션을 만든다.
|
||
|
||
**행동** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 B-0 에서 만든 계정 `labuser` 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다. 그 비밀번호는 B-0 의 `set-password` 로 따라 하는 사람이 정하는 값이다.
|
||
|
||
**예상 결과** — Keycloak 로그인 화면이 뜨고 주소창이 이렇게 바뀐다(observed, 해설 문서에 남은 형태).
|
||
|
||
```text
|
||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
|
||
?client_id=bff-confidential&...
|
||
→ Sign in to keycloak-patterns
|
||
```
|
||
|
||
**왜 필요한가** — 첫 앱에서는 로그인 화면이 나온다. 이것이 둘째 단계의 대조군이고, 이걸 안 보면 app2 에서 안 뜬 것이 특별한 일인지 알 수 없다.
|
||
|
||
**문제가 생기면** — 화면이 안 뜨면 이전 실험의 쿠키가 남아 있다. `auth.hyeonworks.com` 의 쿠키를 지우고 다시 연다.
|
||
|
||
### 2. 로그인 직후 상태를 잰다
|
||
|
||
**목적** — app2 를 방문하기 전의 대조값을 잡는다.
|
||
|
||
**행동** — 주입 전 5 절에서 고친 조인 쿼리에 realm 조건을 붙여 친다. 같은 이유로 미검증이다(unknown).
|
||
|
||
```bash label="[lab host] ① keycloak-patterns 세션만 본다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select us.user_session_id, r.name as realm,
|
||
(select count(*) from offline_client_session cs
|
||
where cs.user_session_id=us.user_session_id) as clients
|
||
from offline_user_session us join realm r on r.id=us.realm_id
|
||
where us.offline_flag='0' and r.name='keycloak-patterns'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `02-after-app1-login.txt`).
|
||
|
||
```text
|
||
=== app1 로그인 직후 Keycloak 세션 ===
|
||
user_session_id | client_sessions
|
||
--------------------------+-----------------
|
||
oqOjHekin4JU-BZjgQLjUByW | 1
|
||
(1 row)
|
||
```
|
||
|
||
```bash label="[lab host] ② 앱 세션 쪽을 본다"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
실측은 이렇다(observed).
|
||
|
||
```text
|
||
Redis 키: 1
|
||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||
PostgreSQL authorized client: 1 행
|
||
```
|
||
|
||
`authorized client` 를 세는 줄도 미검증이다(unknown).
|
||
|
||
```bash label="[lab host] ③ 토큰이 들어간 행을 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from oauth2_authorized_client"
|
||
```
|
||
|
||
**예상 결과** — `client_sessions` 가 1 이고 Redis 키가 하나다. Redis 키 이름의 접두사 `bff:session:sessions:` 는 BFF 가 만든 세션이라는 뜻이고, 뒤에서 프록시 것과 갈라진다.
|
||
|
||
**왜 필요한가** — `user_session_id` 를 적어 둔다. 뒤에서 계속 쓴다. 한 번 로그인했는데 상태가 세 곳에 생겼다. Keycloak 세션, Redis 세션, PostgreSQL 토큰이고 관찰 절에서 이 셋의 운명이 갈린다.
|
||
|
||
**문제가 생기면** — 행이 0 이면 로그인이 아직 안 끝났다. 브라우저에서 app1 이 실제로 열렸는지 본다.
|
||
|
||
### 3. 같은 창의 새 탭에서 app2 를 연다
|
||
|
||
**목적** — SSO 상태를 만든다.
|
||
|
||
**행동** — 같은 브라우저의 새 탭에서 `https://app2.hyeonworks.com/api/echo` 를 연다.
|
||
|
||
**예상 결과** — 로그인 화면이 뜨지 않는다(observed, `c1-sso-app2-no-login-screen.png`). app2 는 Keycloak 으로 리다이렉트했지만 Keycloak 에 이미 세션이 있어서 묻지 않고 바로 돌려보냈다.
|
||
|
||
**왜 필요한가** — SSO 를 만드는 것은 `auth.hyeonworks.com` 에 붙은 브라우저 쿠키다.
|
||
|
||
**문제가 생기면** — 다른 브라우저나 시크릿 창에서 열면 안 된다. 창이 다르면 그 쿠키가 없어 로그인 화면이 뜬다.
|
||
|
||
## 주입 검증
|
||
|
||
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 여기서는 「두 번째 로그인」이 아니라 「같은 로그인에 앱이 하나 붙은 것」인지를 가른다.
|
||
|
||
### 1. 같은 user session 인가
|
||
|
||
```bash label="[lab host] 주입 전과 똑같은 줄을 친다 — user session 과 client session 수"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select us.user_session_id, r.name as realm,
|
||
(select count(*) from offline_client_session cs
|
||
where cs.user_session_id=us.user_session_id) as clients
|
||
from offline_user_session us join realm r on r.id=us.realm_id
|
||
where us.offline_flag='0' and r.name='keycloak-patterns'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-after-app2-visit.txt`).
|
||
|
||
```text
|
||
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
|
||
user_session_id | client_sessions
|
||
--------------------------+-----------------
|
||
oqOjHekin4JU-BZjgQLjUByW | 2
|
||
(1 row)
|
||
```
|
||
|
||
`user_session_id` 가 앞과 같고 `client_sessions` 만 1 에서 2 로 늘었다. SSO 의 데이터 구조가 이렇게 생겼다.
|
||
|
||
```text
|
||
user session (사용자 · 브라우저 하나당 하나)
|
||
├─ client session : bff-confidential
|
||
└─ client session : oauth2-proxy
|
||
```
|
||
|
||
### 2. 어느 클라이언트가 붙었는가
|
||
|
||
조인해야 이름이 나온다. 가이드는 이 줄도 미검증으로 표시한다. 증거에 SQL 원문이 없고 출력만 있다(unknown).
|
||
|
||
아래 쿼리의 `oqOjHekin4JU-BZjgQLjUByW` 는 원래 실행의 세션 id 다. 치기 전에 주입 2 절에서 적어 둔 자기 값으로 갈아 끼운다. 그대로 치면 조건에 걸리는 행이 없어 `(0 rows)` 가 나오고, 그것을 「두 앱이 안 붙었다」로 읽으면 판정이 뒤집힌다.
|
||
|
||
```bash label="[lab host] 클라이언트 이름을 조인해서 본다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select cs.client_id, c.client_id as name
|
||
from offline_client_session cs join client c on c.id = cs.client_id
|
||
where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-after-app2-visit.txt`).
|
||
|
||
```text
|
||
=== 어느 클라이언트가 붙었는가 ===
|
||
client_id | name
|
||
--------------------------------------+------------------
|
||
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
|
||
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
|
||
(2 rows)
|
||
```
|
||
|
||
`client_id` 열은 UUID 이고 사람이 아는 이름은 `client` 테이블에 있다. 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지 알 수 없다. 구조가 다른 두 앱이 같은 user session 아래에 나란히 있고, Keycloak 은 앱이 세션을 어떻게 다루는지 모른다.
|
||
|
||
`user_session_id` 는 따라 하는 사람의 환경에서 다르다. 2 절에서 적어 둔 값으로 바꿔 친다.
|
||
|
||
### 3. 저장소에는 무엇이 늘었는가
|
||
|
||
```bash label="[lab host] 주입 전과 똑같은 줄을 친다 — Redis 키 목록"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-after-app2-visit.txt`).
|
||
|
||
```text
|
||
=== 저장소 상태 ===
|
||
Redis 키:
|
||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||
PostgreSQL authorized client: 1 행
|
||
```
|
||
|
||
같은 Redis 에 접두사가 다른 두 세션이 있다. `bff:session:sessions:` 는 Spring Session 이 쓰는 이름이고 `_oauth2_proxy-` 는 프록시가 쓰는 이름이다. 「세션 저장소를 공유한다」는 말이 「같은 Redis 를 쓴다」일 뿐 「같은 세션을 본다」가 아니다. 둘은 서로의 키를 모른다. B-7a 에서 `FLUSHDB` 를 금지한 이유가 여기 있다.
|
||
|
||
### 4. 왜 두 층으로 나뉘어 있는가
|
||
|
||
Keycloak 은 세션을 `user session`(사람 하나)과 `client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다. A층과 B층에서 본 두 사건이 서로 다른 층을 건드렸다.
|
||
|
||
| | 무엇이 사라졌나 | 결과 |
|
||
|---|---|---|
|
||
| A-3 DB 크래시 | `user_session` 행이 통째로 | 모든 앱이 끊긴다 |
|
||
| B-3 refresh 재사용 탐지 | `client_session` 만 | 그 앱만 끊긴다 |
|
||
|
||
두 층이 나뉘어 있는 까닭이 SSO 다. 앱 하나의 사고가 다른 앱으로 번지지 않게 하려면 client session 이 따로 있어야 한다. 한 층뿐이었다면 B-3 의 재사용 탐지 한 번에 모든 앱이 끊긴다.
|
||
|
||
## 관찰
|
||
|
||
지우는 대상은 ①(IdP 세션) 하나다. ②(앱 세션)와 ③(토큰)은 손대지 않는다. 되돌리기는 다시 로그인하는 것이라 파괴적이지만 회복은 쉽다.
|
||
|
||
### 1. 세션 id 를 지목하는 방법은 안 먹는다
|
||
|
||
지우는 방법을 고르는 데서 하나가 걸러진다. 가이드가 미검증으로 표시했다(unknown).
|
||
|
||
```bash label="[lab host] 세션 id 로 지운다 — 안 먹는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
|
||
```
|
||
|
||
이 id 는 이 실험대의 값이다. 위 출력에서 자기 세션 id 로 갈아 끼운다. 안 바꾸면 남의 세션을 지워 아무 일도 안 일어나는데, 이 절의 판정이 「오류도 안 나고 세션도 안 줄어든다」라 그 둘이 화면에서 구별되지 않는다.
|
||
|
||
오류도 안 나고 세션도 안 줄어든다. 앞의 `logout-all` 과 같은 유형이다.
|
||
|
||
### 2. 사용자 단위로 끊는다
|
||
|
||
**목적** — IdP 세션만 끊는다.
|
||
|
||
**행동** — 사용자 id 를 먼저 잡아 눈으로 확인하고, 그다음에 로그아웃을 건다.
|
||
|
||
```bash label="[lab host] 두 단계로 나눠 친다"
|
||
USERID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get users -r keycloak-patterns -q username=labuser --fields id \
|
||
--format csv --noquotes | tail -1)
|
||
echo "$USERID"
|
||
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
create "users/$USERID/logout" -r keycloak-patterns
|
||
```
|
||
|
||
**예상 결과** — `echo "$USERID"` 가 UUID 한 줄이다.
|
||
|
||
**왜 필요한가** — 비어 있거나 여러 줄이면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다. 가이드가 자리표시자를 두지 않으려고 두 단계로 나눴다고 적는다. 한 줄로 이어 붙이면 `$USERID` 가 비었을 때 그 사실이 안 보인다.
|
||
|
||
**문제가 생기면** — 출력이 여러 줄이면 `--fields id` 가 다른 열을 함께 줬다. 먼저 `echo` 로 확인하고 다음 명령으로 넘어간다.
|
||
|
||
### 3. IdP 세션이 realm 별로 어떻게 남았는지 본다
|
||
|
||
```bash label="[lab host] realm 을 조인해서 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select us.user_session_id, r.name as realm,
|
||
(select count(*) from offline_client_session cs
|
||
where cs.user_session_id=us.user_session_id) as clients
|
||
from offline_user_session us join realm r on r.id=us.realm_id
|
||
where us.offline_flag='0'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `04-sso-session-killed.txt`).
|
||
|
||
```text
|
||
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
|
||
남은 Keycloak 세션: 1
|
||
|
||
=== 남은 세션의 realm 과 client ===
|
||
user_session_id | realm | clients
|
||
--------------------------+--------+---------
|
||
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
|
||
(1 row)
|
||
```
|
||
|
||
「남은 세션 1」과 「그 1의 realm 이 `master`」를 같이 본다. `keycloak-patterns` 세션은 0 이고, 남은 하나는 `kcadm` 을 쳐서 생긴 admin 세션이다.
|
||
|
||
:::warning
|
||
|
||
이 실험에서 가장 잘 틀리는 곳이 여기다. 「1이 남았네, 로그아웃이 안 먹었구나」로 읽으면 결론이 통째로 뒤집힌다. 숫자 옆에 realm 을 안 붙이면 그 숫자는 아무 뜻이 없다.
|
||
|
||
:::
|
||
|
||
### 4. 앱 세션을 주입 검증과 같은 명령으로 본다
|
||
|
||
```bash label="[lab host] 앞에서 친 것과 똑같은 줄이다"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
실측은 이렇다(observed, `04-sso-session-killed.txt`).
|
||
|
||
```text
|
||
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
|
||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||
PostgreSQL authorized client: 1 행
|
||
|
||
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
|
||
```
|
||
|
||
키 이름이 앞과 글자 하나까지 같다. 아무것도 안 지워졌다. 로그아웃은 ①만 지웠고 ②도 ③도 그대로다.
|
||
|
||
### 5. 브라우저로 두 앱을 다시 연다
|
||
|
||
아까 그 브라우저에서 `https://app1.hyeonworks.com/` 과 `https://app2.hyeonworks.com/api/echo` 를 연다. 둘 다 로그인 화면 없이 그대로 열린다(observed, `c1-apps-alive-after-idp-logout.png`).
|
||
|
||
:::warning
|
||
|
||
이 스크린샷과 app2 첫 방문 때의 스크린샷은 바이트 단위로 동일한 파일이다(md5 `2c703176…`). 두 시점의 화면이 실제로 같은 내용이었기 때문이고 조작은 아니지만, 그래서 두 시점을 구별하는 증거가 되지 못한다. 구별은 `03-after-app2-visit.txt` 와 `04-sso-session-killed.txt` 의 터미널 출력이 한다.
|
||
|
||
:::
|
||
|
||
화면이 같아 보인다는 것 자체가 이 실험의 결론이라 화면만으로는 증명이 안 된다. 판정은 `client_sessions` 가 1 에서 2 로 늘어난 출력과, IdP 세션을 지운 뒤에도 그대로인 Redis 키 두 줄이 한다.
|
||
|
||
### 6. 그러면 언제 끊기는가
|
||
|
||
앱은 매 요청마다 IdP 에 물어보지 않는다. 자기 세션이 살아 있으면 그걸로 답하고, 그래서 ①이 사라진 것을 모른다.
|
||
|
||
| | 언제 끊기는가 |
|
||
|---|---|
|
||
| BFF | access token 이 만료되어 refresh 를 시도할 때 → `Session not active` |
|
||
| oauth2-proxy | 쿠키 만료(1시간) 또는 토큰 갱신을 시도할 때 |
|
||
|
||
즉시가 아니라 지연되어 끊긴다. 최대 지연은 access token 수명(60초)이 아니라 앱이 다음에 IdP 를 부를 때까지다. B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다. 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고, 두 방향 모두 「한쪽만 지우면 다른 쪽이 안 지워진다」다.
|
||
|
||
실제로 끊기는 순간을 보려면 수명 두 값을 읽고 기다린다. 가이드는 이 줄을 미검증으로 표시했고 기다려서 확인하지도 않았다(unknown).
|
||
|
||
```bash label="[lab host] 수명 두 값을 읽는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
|
||
```
|
||
|
||
### 7. SSO 의 대가
|
||
|
||
| | 앱이 하나일 때 | SSO 일 때 |
|
||
|---|---|---|
|
||
| 로그인 | 앱마다 | 한 번 |
|
||
| IdP 가 죽으면 | 그 앱만 로그인 불가 | 모든 앱이 로그인 불가 |
|
||
| 이미 로그인한 사용자 | — | 영향 없다. 앱 세션이 살아 있다 |
|
||
| 로그아웃 | 그 앱만 | 전 앱을 끊으려면 백채널 로그아웃이 필요 |
|
||
| 세션 수명 | 하나 | 세 층이 각자. 어긋나면 예측이 어렵다 |
|
||
|
||
IdP 는 로그인 경로의 단일 장애점이지 이미 로그인한 사용자의 단일 장애점이 아니다. A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
|
||
|
||
```text
|
||
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
|
||
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
|
||
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
|
||
```
|
||
|
||
장애가 곧바로 전면에 드러나지 않고 앱 세션 수명만큼 늦게 몰려온다. 전 앱을 끊으려면 백채널 로그아웃이 필요하고, 그게 되는지는 C-2 가 잰다.
|
||
|
||
## 복구와 원상복구 확인표
|
||
|
||
### 1. 세션을 정리한다
|
||
|
||
**목적** — 다음 실험을 깨끗한 상태에서 시작한다.
|
||
|
||
**행동** — 주입 전 절차와 같은 명령이다.
|
||
|
||
```bash label="[lab host] 세션을 비우고 캐시를 버린다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||
```
|
||
|
||
**예상 결과** — 롤아웃이 끝나고 realm 조인 쿼리가 `keycloak-patterns` 0 을 준다.
|
||
|
||
**왜 필요한가** — 그냥 둬도 된다. 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고 IdP 세션은 이미 없다. 다음 실험을 깨끗하게 시작하려고 정리한다.
|
||
|
||
**문제가 생기면** — 재시작 뒤 `kcadm` 이 `401` 이면 `config credentials` 를 다시 친다.
|
||
|
||
### 2. 브라우저 쿠키를 지운다
|
||
|
||
`auth.hyeonworks.com` 과 `app1` 과 `app2` 의 쿠키를 지우거나 시크릿 창을 새로 연다. 서버 세션을 다 지워도 브라우저에 낡은 쿠키가 남고, 다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 그것이다.
|
||
|
||
### 3. 빌린 이름을 돌려준다
|
||
|
||
**목적** — Grafana 가 쓰던 `app2.hyeonworks.com` 을 원래 주인에게 돌린다.
|
||
|
||
**행동** — 빌린 Ingress 를 먼저 지우고 백업을 올린 뒤 밖에서 확인한다.
|
||
|
||
**지우기 전에 백업 파일이 쓸 만한지 본다.** 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown).
|
||
|
||
```bash label="[lab host] ⓪ 백업 파일이 쓸 만한지 본다"
|
||
wc -l ~/grafana-ingress-backup.yaml
|
||
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||
```
|
||
|
||
줄 수가 나오고 둘째 줄이 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 가 나오면 아래 `delete` 를 치지 않는다. B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다.
|
||
|
||
```bash label="[lab host] C-2 를 이어서 하지 않을 때만 친다"
|
||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||
```
|
||
|
||
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
|
||
|
||
**왜 필요한가** — 인증서가 `auth` 와 `app1` 과 `app2` 세 이름만 덮어서 네 번째 이름을 만들 수 없었고, 그래서 B-7 이 Grafana 의 이름을 잠시 빌렸다. 돌려주지 않으면 실험이 끝나도 Grafana 가 안 열린다.
|
||
|
||
**문제가 생기면** — C-2 를 이어서 할 생각이면 이 단계를 건너뛰고 C-2 가 끝난 뒤에 친다. ⓪이 `No such file or directory` 를 냈다면 `delete` 를 치기 전이므로 Grafana 는 아직 살아 있다. B-7 의 백업 단계를 다시 읽고, 양쪽 경로에 다 없으면 Ingress 를 손대지 않은 채로 둔다.
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| Keycloak 세션 | realm 조인 쿼리 | `keycloak-patterns` 0. `master` 는 있을 수 있다 |
|
||
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
|
||
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
|
||
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
|
||
| Ingress | `kubectl -n observability get ingress grafana` | 있다. 돌려줬다면 |
|
||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||
|
||
## 막히면
|
||
|
||
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| `logout-all` 이 오류 없이 아무 일도 안 한다 | 캐시. DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 후 `rollout restart` |
|
||
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 |
|
||
| 기준 세션이 0 이 아니라 4 다 | 원래 실행도 4 였다. 해설의 `0` 은 정정됐다 | 주입 전 3 절의 정정 문단 |
|
||
| 로그아웃했는데 세션이 1 남았다 | `master` 의 admin 세션이다. `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 |
|
||
| `kcadm` 이 전부 `401` | 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 |
|
||
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 |
|
||
| app2 에서 로그인 화면이 뜬다 | 다른 브라우저나 시크릿 창이다. SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 |
|
||
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 의 백업과 적용을 먼저 |
|
||
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 주입 검증 2 절의 조인 쿼리 |
|
||
| Redis 를 비웠더니 app1 도 끊겼다 | `flushall` 은 BFF 세션도 지운다 | 깨끗한 상태를 만들 때만 쓴다 |
|
||
| 스크린샷 두 장이 똑같다 | 실제로 같은 파일이다. 조작이 아니다 | 구별은 터미널 출력이 한다 |
|
||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
|
||
|
||
## 이 실험대에서 아직 못 밟은 단계
|
||
|
||
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
|
||
|
||
- **남은 것** — app1·app2 로그인과 두 앱의 Redis 키 대조. 지금까지 밟은 것 — 세션 정리 구간 전부(`logout-all` 이 안 듣는 것, 자식 1633·부모 6, 롤아웃이 kcadm 세션을 날리는 것).
|
||
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
|
||
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
|
||
|
||
## 무엇이 관측이고 무엇이 아닌가
|
||
|
||
이 절차의 숫자는 `2026-09-04 14:44–14:48 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||
|
||
- (observed) 앱 둘의 `app1 HTTP 200 / app2 HTTP 200`, `logout-all` 뒤의 「Keycloak 온라인 세션: 4 · Redis 키: 0」, DB 삭제와 재시작 뒤의 「Redis 키: 0」, app1 로그인 직후의 `user_session_id` 와 `client_sessions 1`, app2 방문 뒤의 같은 id 와 `client_sessions 2`, 클라이언트 UUID 둘과 이름, Redis 키 두 줄, 사용자 단위 로그아웃 뒤의 「남은 세션 1」과 그 세션의 realm `master`, 그 뒤에도 Redis 키 두 줄이 글자 하나까지 같은 것.
|
||
- (observed) 브라우저 화면 둘. 하나는 app2 가 로그인 화면 없이 열렸고, 다른 하나는 IdP 세션을 지운 뒤에도 두 앱이 열렸다. 두 파일은 md5 `2c703176…` 로 동일하다. 그래서 두 시점을 구별하는 증거로는 못 쓰고, 구별은 터미널 출력이 한다.
|
||
- (unknown) realm 을 조인해 세션을 세는 쿼리, 클라이언트 이름을 조인하는 쿼리, `oauth2_authorized_client` 를 세는 줄, `kcadm delete sessions/<id>`, 수명 두 값을 읽는 `get realms … --fields`. 가이드가 전부 미검증으로 표시했다. 원래 실행은 스크립트로 돌렸고 증거에 SQL 원문이 없다.
|
||
- 비밀은 옮기지 않았다. 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 `labuser` 만 옮겼고 비밀번호는 안 옮겼다. 세션 id 와 Redis 키 이름과 클라이언트 UUID 는 식별자라 그대로 적었다.
|
||
- 버전은 이 편이 직접 잰 값이 아니다(inferred). C-1 출력에는 판 번호가 한 번도 안 찍혔고, C층은 B층 위에서 이어 돌았으므로 판을 물을 때는 같은 실험대의 B층 출력을 본다.
|
||
- 이 절차가 재지 않은 것 — 「언제 끊기는가」를 실제로 기다려서 확인하지 않았다. IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고 app1 을 새로고침하면 `Session not active` 가 나와야 한다는 것은 추론이고, 재려면 그렇게 한다고 가이드는 적는다.
|
||
|
||
<!-- body:end -->
|