test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.
막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다
검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다
certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754
독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다
관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건
남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
797 lines
45 KiB
Markdown
797 lines
45 KiB
Markdown
---
|
||
id: 5296a106-4c42-437d-b721-33a5e53a045c
|
||
kind: SETUP
|
||
slug: reproduce-c2-backchannel-logout
|
||
title: IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다
|
||
topic: trust-handed-over-at-the-edge
|
||
topicName: 위조 신원 헤더와 로그아웃 전파
|
||
project: keycloak-session-store
|
||
status: 게시 전
|
||
studio: "https://hyeonworks.com/studio/documents/5296a106-4c42-437d-b721-33a5e53a045c/edit"
|
||
pinnedVersions:
|
||
- name: curlimages/curl
|
||
version: 8.11.1
|
||
- name: keycloak-pattern-bff
|
||
version: lab
|
||
source:
|
||
- final/document.md#c층-재현-절차-두-편을-직접-치는-순서-c-2
|
||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||
---
|
||
|
||
# IdP 쪽에만 로그아웃 주소를 넣고 한쪽만 고치면 안 퍼지는 것을 확인한다
|
||
|
||
IdP 쪽에만 백채널 로그아웃 URL 을 넣고 로그아웃을 건 뒤, Redis 키 이름이 개수도 글자도 안 바뀌는지 확인하는 절차다. 받을 엔드포인트는 일부러 없는 채로 둔다. 약 20분.
|
||
|
||
## 관계
|
||
|
||
- **백채널 로그아웃은 양쪽 다 없었다**
|
||
이 절차가 판정한 후보 셋과 그 결론을 그 기록이 발견 쪽에서 적는다. 여기서는 명령과 출력만 친다.
|
||
- **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션**
|
||
로그아웃이 안 퍼져서 남는 세션을 그쪽은 TTL 로 골라내 지운다. 여기서는 남는 데까지만 본다.
|
||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||
이 절차가 그 기준을 한 겹 더 앞으로 당긴 자국이다. 주입이 걸렸는지가 아니라 끊을 세션이 있는지부터 확인한다.
|
||
- **두 앱을 한 로그인으로 묶고 IdP 세션만 끊어 앱 세션이 남는지 본다**
|
||
이 절차의 전제다. 그쪽이 만든 두 앱과 빌린 이름을 그대로 쓴다.
|
||
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
|
||
app2 와 빌린 Grafana Ingress 를 거기서 만든다. 마지막에 돌려주는 것도 그쪽 백업 파일이다.
|
||
|
||
## 본문
|
||
|
||
<!-- body:start -->
|
||
|
||
## 읽기 전에 — 어디서 치는가
|
||
|
||
명령은 `[lab host]` 에서 `kubectl` 로 친다. `kubectl` 에 `sudo` 를 붙이지 않는다. 앱 소스를 뒤지는 `grep -rn` 한 단계만 저장소를 체크아웃해 둔 워크스테이션에서 치고, 밖에서 후보 경로를 두드리는 `curl` 은 개발 머신에서 친다. 도달성을 재는 두 줄은 클러스터 안에 띄운 임시 파드 안에서 친다.
|
||
|
||
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
|
||
|
||
C-1 이 세운 두 앱을 그대로 쓴다. app1 은 BFF(Backend for Frontend, 브라우저 대신 토큰을 들고 있는 백엔드)이고 app2 는 oauth2-proxy 다.
|
||
|
||
브라우저도 필요하다. 인가 코드 흐름은 브라우저와 Keycloak 사이를 두 번 왕복하므로 `curl` 로 살아 있는 세션을 만들 수 없고, 이 절차는 끊을 세션이 있어야 성립한다.
|
||
|
||
| 무엇 | 값 |
|
||
|---|---|
|
||
| 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` |
|
||
| 전제 | C-1 이 끝나 있다. 두 앱이 둘 다 살아 있다 |
|
||
| 주입 수단 | `bff-confidential` 클라이언트의 `attributes` 를 JSON 으로 통째로 교체한다 |
|
||
| 일부러 안 고치는 것 | 앱의 받을 엔드포인트. 후보 ①만 넣고 무슨 일이 나는지 본다 |
|
||
| 소스 트리 | `bff/src/main/java/` 를 볼 수 있어야 한다 |
|
||
| 도구 | `jq` 는 이 실험대에 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없다 |
|
||
| 임시 파드 | `curlimages/curl:8.11.1` · 이름 `c2probe` · `--rm` 으로 띄운다 |
|
||
| 전 구간 | 약 20분 |
|
||
|
||
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
|
||
|
||
## 이 실험이 가르는 것
|
||
|
||
C-1 이 관측한 것에서 출발한다.
|
||
|
||
```text
|
||
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
|
||
```
|
||
|
||
C-1 은 왜 안 퍼졌는지를 안 물었다. 후보가 셋 있고 각각 판정하는 방법이 다르다.
|
||
|
||
| 후보 | 판정하는 법 |
|
||
|---|---|
|
||
| ① IdP 에 보낼 주소가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
|
||
| ② 앱에 받을 엔드포인트가 없다 | 소스와 실제 경로를 본다 |
|
||
| ③ IdP 가 앱에 못 닿는다 (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
|
||
|
||
| | |
|
||
|---|---|
|
||
| 예상 | 셋 중 하나가 원인일 것 |
|
||
| 실측 | ①과 ②가 둘 다 없었다. ③은 문제가 아니었다(`HTTP 200`) |
|
||
|
||
원인은 단순했다. 아무도 구현하지 않았다. 이 실험이 실제로 증명하는 것은 그다음이다.
|
||
|
||
```text
|
||
①만 고친다 → 여전히 안 퍼진다
|
||
```
|
||
|
||
양쪽이 다 있어야 동작한다. 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는 것이 이 주제에서 가장 흔한 실패이고, 이 절차는 그 실패를 일부러 재현한다.
|
||
|
||
:::warning
|
||
|
||
출처 하나에 주의가 붙어 있다. 해설 문서 2절이 인쇄한 「설정이 들어갔다」 확인 출력은 `02-configure-idp.txt` 에서 나오지 않았다. 그 파일에는 `command terminated with exit code 1` 이 남아 있다. 점 표기로 시도한 실패한 첫 시도다. 성공 출력은 그 뒤 별도로 실행한 조회에서 나왔다. 실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은 잘못이었고, 아래 주입 검증 절이 그 둘을 갈라 적는다.
|
||
|
||
:::
|
||
|
||
## 전제와 되돌리기
|
||
|
||
- C-1 이 끝나 있다. app1(BFF)과 app2(oauth2-proxy)가 둘 다 살아 있고 IdP 로그아웃이 앱에 전파되지 않는다를 이미 관측했다. 이 실험은 그 원인을 찾는다.
|
||
- `app2.hyeonworks.com` 은 Grafana 에서 빌린 이름이다. 끝나면 되돌린다.
|
||
- BFF 소스 트리(`bff/src/main/java/`)를 볼 수 있어야 한다.
|
||
- 브라우저가 필요하다. 살아 있는 세션을 만들어야 시험이 성립한다.
|
||
- Keycloak 이미지에는 `curl` 도 `wget` 도 없다(`exit 127`). 도달성 시험은 임시 curl 파드로 한다.
|
||
- `jq` 는 이 실험대에 깔려 있지 않다.
|
||
- `~/grafana-ingress-backup.yaml` 이 손에 있어야 한다. 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 절차는 가이드에 없다(unknown). B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다.
|
||
|
||
:::danger
|
||
|
||
이 실험은 클라이언트 설정을 바꾼다. `bff-confidential` 클라이언트의 `attributes` 를 통째로 교체한다. JSON 으로 주는 방식이라 기존 속성이 같이 날아갈 수 있다. 그래서 주입 절의 첫 명령이 백업이다. 세션도 지운다.
|
||
|
||
:::
|
||
|
||
되돌리기는 백업한 값으로 다시 `update` 하는 것이고, 백업이 `{ }` 처럼 비어 있었다면 빈 객체로 되돌린다. 아래 한 줄이 그 빈 객체 갈래다 — 백업에 값이 있었던 사람이 그 값을 다시 넣는 명령은 가이드에 없다(unknown). 그쪽은 복구 1 절에서 다시 짚는다. `$CID` 는 주입 1 절에서 잡는 클라이언트 UUID 이므로, 여기를 먼저 읽는 지금은 아직 비어 있다. 실제로 칠 때는 그 절의 `CID=` 를 친 다음이다.
|
||
|
||
```bash label="[lab host] 클라이언트 속성을 되돌린다 — $CID 를 잡은 뒤에 친다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||
```
|
||
|
||
그대로 둬도 무방하다고 가이드는 적는다. 받을 엔드포인트가 없으므로 이 설정 하나로는 아무 일도 안 일어나고, 그것이 이 실험의 결론이었다. 다만 나중에 앱을 고쳤을 때 왜 갑자기 동작하는지 모르게 되므로, 실험이 남긴 설정이라는 것을 기억하거나 지운다.
|
||
|
||
## 주입 전에 같은 명령으로 먼저 본다
|
||
|
||
넓은 것부터 좁혀 간다. 마지막 한 칸이 이 편에서 새로 붙었다.
|
||
|
||
```text
|
||
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
|
||
```
|
||
|
||
### 0. 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` 을 안 낸다.
|
||
|
||
**왜 필요한가** — 값이 명령 치환 안에서만 흐르므로 터미널에도 셸 히스토리에도 남지 않는다.
|
||
|
||
**문제가 생기면** — Keycloak 파드가 재시작되면 이 세션이 날아가고 이후 모든 `kcadm` 이 `401` 이 된다. 그때 이 명령을 다시 친다.
|
||
|
||
### 1. IdP 쪽 클라이언트 속성을 통째로 본다
|
||
|
||
**목적** — 후보 ①을 판정한다.
|
||
|
||
**행동** — 두 클라이언트를 각각 본다.
|
||
|
||
```bash label="[lab host] 속성을 통째로 받아 눈으로 훑는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
|
||
```
|
||
|
||
**예상 결과** — 2026-09-17 에도 세 경로가 전부 `302` 였고 `Location` 이 로그인 시작점을 가리켰다(observed).
|
||
|
||
```text
|
||
HTTP/1.1 302 Found
|
||
Location: http://app1.hyeonworks.com/oauth2/authorization/keycloak
|
||
|
||
/logout/connect/back-channel/keycloak 302
|
||
/backchannel-logout 302
|
||
/oauth2/sign_out 302
|
||
```
|
||
|
||
세 후보 어느 것도 백채널 엔드포인트가 아니다. **`302` 는 「그 경로가 있다」가 아니라 「인증이 필요한 요청으로 처리됐다」**를 뜻한다. 그런 핸들러가 없어 기본 규칙에 걸렸다. `Location` 이 로그인 시작점을 가리켜 그것을 증명한다.
|
||
|
||
원래 실행의 실측은 이렇다(observed, `01-current-state.txt`).
|
||
|
||
```text
|
||
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
|
||
--- bff-confidential ---
|
||
"frontchannelLogout" : false,
|
||
--- oauth2-proxy ---
|
||
"frontchannelLogout" : false,
|
||
```
|
||
|
||
있는 것이 아니라 없는 것을 본다. `backchannel.logout.url` 이 목록에 없고 `frontchannelLogout` 하나만 나온다.
|
||
|
||
**왜 필요한가** — `grep backchannel` 로 걸러서 빈 출력을 보면 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다. B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다. `--fields attributes` 로 통째로 받아 눈으로 훑고, 다른 값(`frontchannelLogout`)이 보이는 것을 「명령은 먹었다」의 증거로 쓴다.
|
||
|
||
**문제가 생기면** — 두 줄 다 아무것도 안 나오면 `kcadm` 이 `401` 이다. 0 절을 다시 친다.
|
||
|
||
### 2. 앱 소스를 본다
|
||
|
||
**목적** — 후보 ②를 소스 쪽에서 판정한다.
|
||
|
||
**행동** — 저장소를 체크아웃해 둔 곳에서 두 낱말을 찾는다. `bff/src/main/java/` 는 그 체크아웃의 루트에서 푸는 상대 경로다. 체크아웃을 어디에 뒀는지도, 워크스테이션으로 가는 `ssh` 명령도 가이드에 없으므로(unknown), 그 경로가 풀리는 디렉터리에서 친다.
|
||
|
||
```bash label="[워크스테이션] 소스에서 두 낱말을 찾는다"
|
||
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `01-current-state.txt`).
|
||
|
||
```text
|
||
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
|
||
|
||
```
|
||
|
||
아무것도 안 나온다. 헤더 아래가 비어 있다. Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 명시적으로 켜야 한다.
|
||
|
||
```java
|
||
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
|
||
```
|
||
|
||
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가 생기지 않는다.
|
||
|
||
**왜 필요한가** — 소스에 없으니 경로도 없다. 다만 빈 출력에는 원인이 둘이므로 다음 단계에서 배포된 앱을 직접 두드린다.
|
||
|
||
2026-09-17 에 같은 저장소에서 다시 쳤다(observed). `bff/src/main/java/` 는 있는데 출력이 비었다 — **경로를 잘못 짚은 것이 아니라 정말 없다.**
|
||
|
||
**문제가 생기면** — `grep` 이 빈 출력을 줄 때는 경로가 맞는지 먼저 의심한다. `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를 뒤져도 `grep` 은 조용히 0건을 준다.
|
||
|
||
### 3. 배포된 앱의 실제 경로를 본다
|
||
|
||
**목적** — 소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이라 직접 친다.
|
||
|
||
**행동** — 먼저 응답을 통째로 한 번 보고, 그다음 후보 셋을 나란히 잰다.
|
||
|
||
```bash label="[밖에서] ① 응답을 통째로 본다"
|
||
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
|
||
```
|
||
|
||
상태줄과 `Location` 헤더를 본다. 302 라면 어디로 보내는가. 로그인 페이지로 보내면 인증이 필요한 요청으로 처리됐다는 뜻이고, 그런 핸들러가 없어서 기본 규칙에 걸렸다.
|
||
|
||
```bash label="[밖에서] ② 후보 셋의 상태 코드만 뽑는다"
|
||
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
|
||
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
|
||
done
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `01-current-state.txt`).
|
||
|
||
```text
|
||
=== 실제로 그 경로가 있는가 ===
|
||
/logout/connect/back-channel/keycloak HTTP 302
|
||
/backchannel-logout HTTP 302
|
||
/oauth2/sign_out HTTP 302
|
||
```
|
||
|
||
| 응답 | 뜻 |
|
||
|---|---|
|
||
| `302` | 그런 핸들러가 없어서 인증 요구로 떨어졌다 |
|
||
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
|
||
| `404` | 라우팅 자체가 없다 |
|
||
|
||
**왜 필요한가** — 302 는 「없다」의 증거다. 엔드포인트가 있었다면 `POST` 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다. 후보 ②가 확정됐고 ①은 앞에서 확정됐다.
|
||
|
||
**문제가 생기면** — 302 를 「있다」로 읽으면 판정이 뒤집힌다. 읽는 형태로 한 번 친 ①의 출력에서 `Location` 이 로그인 페이지를 가리키는지 확인한다.
|
||
|
||
### 4. 끊을 세션이 있기는 한가
|
||
|
||
**목적** — 주입할 대상이 있는지 확인한다. 이 칸이 이 편에서 새로 붙었고, 원래 실행이 여기서 한 번 헛돌았다.
|
||
|
||
**행동** — C-1 에서 배운 대로 realm 을 조인해서 센다.
|
||
|
||
```bash label="[lab host] ① realm 을 조인해 세고 Redis 도 본다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
원래 실행에서 나온 값은 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== 로그아웃 전 상태 ===
|
||
Redis: 2 키
|
||
keycloak-patterns 세션: 0
|
||
```
|
||
|
||
IdP 세션이 0 이다. Redis 에는 키가 2개 있는데 Keycloak 쪽은 비어 있다. 이 상태에서 로그아웃을 걸면 아무 일도 안 난다. 끊을 대상이 없기 때문이다. 그리고 「앱 세션이 안 지워졌다」를 보고 「전파가 안 되는구나」로 결론지을 뻔했다. 주입은 정상적으로 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. 틀린 것은 전제뿐이다.
|
||
|
||
**행동** — 그러니 세션을 만든다. 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 `labuser` 로 로그인한다. 가이드는 이 줄에 계정 이름과 비밀번호를 나란히 적지만 여기에는 이름만 옮긴다. 그 비밀번호는 B-0 의 `set-password` 로 따라 하는 사람이 정하는 값이다. 그리고 다시 센다.
|
||
|
||
```bash label="[lab host] ② 똑같은 두 줄을 다시 친다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
**예상 결과** — 두 번째 시험의 실측은 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== 로그아웃 전 — 실제 세션이 있는가 ===
|
||
keycloak-patterns 세션: 1
|
||
Redis: 1 키
|
||
```
|
||
|
||
세션 수가 1 이상이어야 한다.
|
||
|
||
**왜 필요한가** — 여기서 0 이면 로그인이 안 된 것이고, 0 인 채로 주입 절로 넘어가면 결과가 무엇이 나와도 판정하지 못한다.
|
||
|
||
**문제가 생기면** — 0 이면 브라우저에서 app1 이 실제로 열렸는지 본다. 시크릿 창에서 열면 로그인 화면이 뜬다.
|
||
|
||
## 주입
|
||
|
||
의도적으로 한쪽만 고친다. 「①만 있으면 되는가」가 이 실험의 질문이다.
|
||
|
||
### 1. 지금 attributes 를 저장해 둔다
|
||
|
||
**목적** — 되돌릴 값을 파일로 만든다.
|
||
|
||
**행동** — 클라이언트 UUID 를 먼저 잡아 눈으로 확인하고, 지금 속성을 파일로 받는다.
|
||
|
||
```bash label="[lab host] UUID 를 잡고 속성을 파일로 받는다"
|
||
CID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
|
||
--format csv --noquotes | tail -1)
|
||
echo "$CID"
|
||
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get "clients/$CID" -r keycloak-patterns --fields attributes \
|
||
| tee ~/c2-bff-attributes-backup.json
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `02-configure-idp.txt`).
|
||
|
||
```text
|
||
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
|
||
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
|
||
```
|
||
|
||
`echo "$CID"` 가 UUID 한 줄인지 본다. 이 UUID 는 C-1 에서 `bff-confidential` 로 확인한 바로 그 값이고, 따라 하는 사람의 환경에서는 다르다.
|
||
|
||
`tee` 는 파일에 쓰면서 같은 것을 화면에도 찍는다. 그 화면에 `attributes` 를 담은 JSON 대신 kcadm 의 오류 문구가 나오면 `$CID` 를 잘못 잡았고, 그 파일로는 되돌리지 못한다. 그때는 다음 절로 넘어가지 말고 `echo "$CID"` 부터 다시 본다 — 백업이 망가진 것을 다 끝난 뒤 복구 1 절에서 처음 알게 되면 그때는 이미 원래 속성이 날아간 뒤다.
|
||
|
||
**왜 필요한가** — `attributes=` 는 통째로 교체하므로 기존 속성이 같이 날아갈 수 있고, 되돌리기가 이 백업 파일에 달려 있다.
|
||
|
||
**문제가 생기면** — `$CID` 가 비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고, 그 상태로 다음 명령을 치면 엉뚱한 클라이언트를 고친다.
|
||
|
||
### 2. 점 표기로 넣으면 죽는다
|
||
|
||
**목적** — 원래 실행이 처음 친 형태와 그 결과를 확인한다.
|
||
|
||
**행동** — 점 표기로 한 번 쳐 본다.
|
||
|
||
```bash label="[lab host] 점 표기 — 실패한다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update "clients/$CID" -r keycloak-patterns \
|
||
-s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `02-configure-idp.txt`).
|
||
|
||
```text
|
||
command terminated with exit code 1
|
||
```
|
||
|
||
**왜 필요한가** — 종료코드 1 이다. 조용한 실패가 아니라 실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려 「왜」는 안 보인다. 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`) `kcadm` 의 점 표기와 충돌한다.
|
||
|
||
**문제가 생기면** — 다음 단계로 간다. 이 형태를 고쳐 쓰려고 애쓰지 않는다.
|
||
|
||
### 3. JSON 으로 통째로 준다
|
||
|
||
**목적** — 후보 ①만 넣는다.
|
||
|
||
**행동** — 두 속성을 한 JSON 으로 준다.
|
||
|
||
```bash label="[lab host] JSON 으로 통째로 교체한다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update "clients/$CID" -r keycloak-patterns \
|
||
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
|
||
"backchannel.logout.session.required":"true"}'
|
||
```
|
||
|
||
**예상 결과** — 오류 없이 끝난다. 들어갔는지는 다음 절에서 따로 잰다.
|
||
|
||
**왜 필요한가** — 속성 이름에 점이 있어 점 표기로는 못 넣는다.
|
||
|
||
**문제가 생기면** — 이 실험대는 설정 JSON 을 명령줄에 직접 줬다(observed). 사람이 내용을 읽으면서 고쳐야 하는 값을 셸 한 줄에 담은 형태이고, 따라 하는 사람이 파일로 만들어 넣는 형태는 가이드에 없다(unknown). 없는 명령은 지어내지 않으므로 이 절차에도 없다.
|
||
|
||
## 주입 검증
|
||
|
||
결과를 해석하기 전에 주입이 의도한 것을 정확히 했는지 먼저 본다. 이 편에서는 그 확인 자체에 출처 문제가 붙어 있다.
|
||
|
||
### 1. 두 속성이 둘 다 들어갔는가
|
||
|
||
```bash label="[lab host] 주입 전에 친 것과 똑같은 줄이다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||
```
|
||
|
||
해설 문서 2절이 인쇄한 값은 이렇다(observed).
|
||
|
||
```text
|
||
backchannel.logout.session.required = true
|
||
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
||
```
|
||
|
||
두 속성이 둘 다 있는지 본다. `url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
|
||
|
||
:::warning
|
||
|
||
이 출력은 `02-configure-idp.txt` 에 없다. 그 파일은 점 표기 실패로 끝나고, 위 값은 그 뒤 별도로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 하나씩 짝지어지지 않는 유일한 곳이므로 따라 하는 사람은 지금 직접 재 두는 편이 낫다고 가이드는 적는다.
|
||
|
||
:::
|
||
|
||
### 2. 이 시점의 Redis 키 이름을 적어 둔다
|
||
|
||
```bash label="[lab host] 키 이름을 그대로 적어 둔다"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
실측은 이렇다(observed, `02-configure-idp.txt`).
|
||
|
||
```text
|
||
=== 로그인 상태를 만든다 ===
|
||
(브라우저에 이미 세션이 있다)
|
||
Keycloak 세션: 2
|
||
Redis: 2 키
|
||
```
|
||
|
||
위 실측의 「Keycloak 세션: 2 · Redis: 2 키」는 원래 실행의 첫 패스 값이다. 주입 전 4 절의 두 번째 시험까지 밟은 사람 화면에는 거기서 본 「세션: 1 · Redis: 1 키」가 그대로 나온다. 주입 1~3 절은 세션을 만들지 않으므로 두 숫자는 4 절에서 본 것과 같아야 하고, 다르면 그 사이에 브라우저가 한 번 더 로그인했다.
|
||
|
||
키 이름을 그대로 적어 둔다. 관찰 절에서 글자 하나까지 같은지를 본다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
|
||
|
||
## 관찰
|
||
|
||
### 1. 시각을 적고 로그아웃한다
|
||
|
||
**목적** — IdP 세션을 끊는다.
|
||
|
||
**행동** — 시각을 남기고, 사용자 id 를 잡아 눈으로 확인하고, 로그아웃을 건다.
|
||
|
||
```bash label="[lab host] 시각을 남기고 사용자 단위로 끊는다"
|
||
date '+%H:%M:%S 로그아웃'
|
||
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
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== ★ IdP 로그아웃 → 백채널 알림 ===
|
||
시각: 14:54:21
|
||
```
|
||
|
||
**왜 필요한가** — 시각은 뒤에서 로그를 뒤질 때 「이 순간 전후」로 좁히려고 적는다. `--since` 만으로는 어느 시도인지 안 갈린다.
|
||
|
||
**문제가 생기면** — `$USERID` 가 비었으면 `echo` 로 먼저 확인하고 다음 명령으로 넘어간다.
|
||
|
||
### 2. IdP 세션이 끊겼는지 먼저 본다
|
||
|
||
```bash label="[lab host] realm 을 조인해 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `04-reachability.txt`).
|
||
|
||
```text
|
||
=== IdP 세션은 실제로 끊겼는가 ===
|
||
keycloak-patterns 세션: 0
|
||
```
|
||
|
||
0 이다. 로그아웃 자체는 동작했다. 이제 앱 쪽을 볼 자격이 생겼다. 여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 안 지워져 있어도 그건 당연한 결과라 아무것도 판정하지 못한다.
|
||
|
||
### 3. 앱 세션을 주입 검증과 같은 명령으로 본다
|
||
|
||
```bash label="[lab host] 앞에서 친 것과 똑같은 줄이다"
|
||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== 앱 세션이 정리되었는가 ===
|
||
Redis: 2 키
|
||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||
```
|
||
|
||
세션이 실제로 1개 있던 두 번째 시험에서도 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== 앱 세션 ===
|
||
Redis: 1 키
|
||
```
|
||
|
||
개수도 이름도 그대로다. ①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과이고, IdP 쪽만 설정해도 소용없다.
|
||
|
||
### 4. 로그에 흔적이 있는지 본다
|
||
|
||
Keycloak 양쪽 노드와 앱 쪽이다.
|
||
|
||
```bash label="[lab host] ① Keycloak 두 노드를 센다"
|
||
kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
|
||
kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
|
||
```
|
||
|
||
실측은 이렇다(observed, `04-reachability.txt`).
|
||
|
||
```text
|
||
=== Keycloak 로그 전체에서 backchannel 흔적 ===
|
||
keycloak-0: 0 줄
|
||
keycloak-1: 0 줄
|
||
```
|
||
|
||
```bash label="[lab host] ② BFF 쪽 도착 흔적을 본다"
|
||
kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-logout-attempt.txt`).
|
||
|
||
```text
|
||
=== BFF 로그 — 백채널 요청이 도착했는가 ===
|
||
|
||
```
|
||
|
||
양쪽 다 비어 있다.
|
||
|
||
| 이 출력이 말하는 것 | 말하지 않는 것 |
|
||
|---|---|
|
||
| 로그에 `backchannel` 문자열이 없다 | Keycloak 이 요청을 안 보냈다 |
|
||
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
|
||
|
||
:::danger
|
||
|
||
로그 레벨이 `DEBUG` 였다면 안 찍혔을 수 있다. 「0줄」은 「안 보냈다」의 증거가 아니라 「기본 로그 레벨에서는 안 보인다」일 뿐이다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다. 로그 0줄을 근거로 「Keycloak 이 안 보냈다」고 쓰면, 나중에 `DEBUG` 를 켜서 보냈다는 게 밝혀졌을 때 결론 전체의 신뢰가 무너진다.
|
||
|
||
:::
|
||
|
||
### 5. 임시 파드로 도달성을 잰다
|
||
|
||
**목적** — 후보 ③을 판정한다.
|
||
|
||
**행동** — Keycloak 파드에는 `curl` 이 없으므로 같은 네임스페이스에 임시 파드를 띄운다. 가이드가 미검증으로 표시한 줄이고 원래 실행의 명령 원문은 기록에 없다(unknown). 출력은 실측이다.
|
||
|
||
```bash label="[lab host] ① 임시 파드를 띄우고 들어간다"
|
||
kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
|
||
--image=curlimages/curl:8.11.1 --command -- sh
|
||
```
|
||
|
||
파드 안에서 두 줄을 친다.
|
||
|
||
```sh label="[탐침 파드] ② 이름을 풀고 실제로 닿는지 본다"
|
||
nslookup app1.hyeonworks.com
|
||
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||
```
|
||
|
||
**예상 결과** — 실측은 이렇다(observed, `04-reachability.txt`).
|
||
|
||
```text
|
||
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
|
||
DNS 해석:
|
||
Address: 100.83.212.4
|
||
|
||
Non-authoritative answer:
|
||
|
||
HTTPS 도달:
|
||
HTTP 200 (0 이면 못 닿음)
|
||
```
|
||
|
||
| 값 | 뜻 |
|
||
|---|---|
|
||
| `Address: 100.83.212.4` | 클러스터 안에서 공개 이름이 풀린다 |
|
||
| `HTTP 200` | 실제로 닿는다 |
|
||
| `HTTP 000` | curl 이 연결조차 못 했다 = 네트워크가 원인 |
|
||
|
||
|
||
**★ 지금 배치에서는 주소가 다르고, 닿는 까닭도 다르다**(2026-09-17, observed). 판정(`200`)은 같은데 `nslookup` 이 내놓는 주소가 `100.83.212.4` 가 아니라 엣지 게스트의 `192.168.122.10` 이다.
|
||
|
||
```text label="탐침 파드에서 받은 그대로"
|
||
Server: 10.43.0.10
|
||
Address: 10.43.0.10:53
|
||
|
||
|
||
Name: app1.hyeonworks.com
|
||
Address: 192.168.122.10
|
||
--- HTTPS ---
|
||
app1 200
|
||
curl exit=0
|
||
```
|
||
|
||
아래 :::warning 이 적은 「tailnet 과 split DNS 의 헤어핀」은 이제 이 실험대의 사정이 아니다. [03] 이 CoreDNS 에 `coredns-custom` 항목을 넣어 세 이름을 엣지로 보내기 때문에 풀린다. **저절로 풀리지 않는다. 넣어야 풀린다** — 그 항목을 안 넣으면 클러스터 안에서 이 이름이 안 풀리고, 이 절은 `000` 으로 끝난다. 그러면 「네트워크가 원인이 아니다」를 못 보이고 후보 ③을 배제하지 못한다.
|
||
|
||
`kubectl -n keycloak-lab get networkpolicy` 도 같은 날 다시 쳐 `No resources found in keycloak-lab namespace.` 를 받았다(observed) — 대역이 성립한다는 근거는 그대로다.
|
||
|
||
후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. 두 줄을 읽었으면 파드에서 나온다 — `--rm` 이 나가는 순간 파드를 지운다.
|
||
|
||
```sh label="[탐침 파드] ③ 파드에서 나온다"
|
||
exit
|
||
```
|
||
|
||
여기서부터 다시 `kc-lab-1` 이다.
|
||
|
||
**왜 필요한가** — 앱 세션이 안 지워지는 까닭이 「요청이 못 닿아서」일 수도 있고, 그러면 구현이 아니라 네트워크를 고쳐야 한다.
|
||
|
||
**문제가 생기면** — 임시 파드는 Keycloak 파드의 완전한 대역이 아니다. 같은 네임스페이스라 DNS 와 대체로 같은 경로를 타지만, NetworkPolicy 나 사이드카가 걸려 있으면 결과가 갈릴 수 있다. 이 실험대에는 그런 것이 없어서 대역이 성립했고, 확인은 `kubectl -n keycloak-lab get networkpolicy` 가 비어 있는지로 한다. 그 한 줄은 ③으로 나온 뒤 `kc-lab-1` 에서 친다 — `curlimages/curl` 이미지에는 `kubectl` 이 없어서 탐침 안에서 치면 못 찾는다고 끝난다.
|
||
|
||
:::warning
|
||
|
||
이 200 은 이 실험대의 특수 사정이다. tailnet 과 split DNS 구성이라 클러스터 안에서 공개 이름을 불러도 되돌아온다(헤어핀). 운영에서는 안 되는 경우가 흔하다. 앱이 사설망에 있고 IdP 가 밖에 있으면 설정을 해도 도달하지 못하고, 그때는 로그도 안 남고 조용히 실패한다.
|
||
|
||
:::
|
||
|
||
### 6. 그래서 왜 안 퍼졌나
|
||
|
||
세 단계로 정리된다.
|
||
|
||
```text
|
||
IdP 로그아웃
|
||
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (주입 절에서 설정함)
|
||
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다
|
||
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
|
||
```
|
||
|
||
②와 ③이 없다. ①만 설정해도 받을 사람이 없다. 가이드는 「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서 확인한 것을 이 편의 값으로 적는다.
|
||
|
||
구조는 이렇게 생겼다.
|
||
|
||
```text
|
||
사용자가 어느 앱에서든 로그아웃
|
||
│
|
||
▼
|
||
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
|
||
│
|
||
├──POST──▶ app1 의 backchannel.logout.url
|
||
└──POST──▶ app2 의 backchannel.logout.url
|
||
본문: logout_token (JWT)
|
||
{ "sid": "...", "sub": "...", "events": {...} }
|
||
```
|
||
|
||
`sid` 는 Keycloak 의 user session 식별자이고, A-0 에서 확인한 그 `sid` 다. JWT 와 DB 와 관리 API 에서 같은 문자열이었던 값이다. logout token 에 실려 오는 것이 `sid` 이고, 앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.
|
||
|
||
```text
|
||
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
|
||
```
|
||
|
||
그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다. Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다. 엔드포인트가 있어도 그 역인덱스가 없으면 어느 세션을 지울지 모른다. 인스턴스가 여럿이면 그 레지스트리도 공유 저장소여야 한다. B-1 과 B-2 에서 겪은 것과 같은 문제가 한 겹 더 있고, BFF 는 replica 2개다.
|
||
|
||
부분 실패도 이 구조에서 나온다.
|
||
|
||
```text
|
||
app1 로그아웃 성공, app2 는 응답 없음
|
||
└─ Keycloak 은 재시도하는가? 얼마나?
|
||
└─ 사용자는 app2 에서 여전히 로그인 상태다
|
||
```
|
||
|
||
로그아웃은 원자적이지 않다. 앱이 늘어날수록 「일부만 로그아웃된 상태」가 생길 확률이 올라간다. 이 실험은 그 재시도 동작을 측정하지 않았다.
|
||
|
||
구현하려면 무엇이 필요한지도 가이드가 표로 적는다.
|
||
|
||
| 계층 | 할 일 | 이 실험대의 상태 |
|
||
|---|---|---|
|
||
| IdP | 클라이언트마다 `backchannel.logout.url` 설정 | 완료 |
|
||
| 앱 | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
|
||
| 앱 | `OidcSessionRegistry` 를 공유 저장소로 (인스턴스가 여럿) | 없음 |
|
||
| 네트워크 | IdP → 앱 공개 URL 도달 | 됨. 운영은 확인 필요 |
|
||
| oauth2-proxy | 지원하지 않는다. 별도 방안이 필요하다 | — |
|
||
|
||
마지막 줄이 C-1 과 맞물린다. app1(BFF)은 구현할 수 있지만 app2(oauth2-proxy)는 못 한다. 한 SSO 안에서 로그아웃 전파가 앱마다 다르게 동작하게 된다. C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데, 그 공유가 로그아웃까지는 안 간다.
|
||
|
||
## 복구와 원상복구 확인표
|
||
|
||
### 1. 클라이언트 속성을 되돌린다
|
||
|
||
**목적** — 실험이 남긴 설정을 지운다.
|
||
|
||
**행동** — 백업을 먼저 읽고, 읽은 값으로 되돌린다.
|
||
|
||
```bash label="[lab host] ① 백업에 무엇이 있었는지 본다"
|
||
cat ~/c2-bff-attributes-backup.json
|
||
```
|
||
|
||
원래 무엇이 있었는지 본다. 비어 있었으면 아래 ②가 그 갈래다.
|
||
|
||
**값이 있었으면 여기서 멈춘다.** 그 값을 다시 넣는 명령은 원본 가이드에 없다(unknown). 지어내지 않으므로 이 절차에도 없다. 주입 3 절의 `attributes=` 가 통째로 교체했으니 원래 속성은 ①이 찍어 낸 `~/c2-bff-attributes-backup.json` 안에만 있다. 그 파일을 지우지 않는다 — 되돌릴 명령을 구하면 그 파일이 있어야 쓸 수 있다. 그리고 ②를 치지 않는다. `attributes={}` 는 지금 들어 있는 것과 함께 원래 값까지 비운다.
|
||
|
||
```bash label="[lab host] ② 빈 객체로 되돌리는 경우 — 백업이 비어 있었을 때만 친다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||
```
|
||
|
||
**예상 결과** — 주입 전에 친 `get clients … --fields attributes` 에서 `backchannel.logout.url` 이 사라진다.
|
||
|
||
**왜 필요한가** — 그대로 둬도 아무 일도 안 일어나지만, 나중에 앱을 고쳤을 때 왜 갑자기 동작하는지 모르게 된다.
|
||
|
||
**문제가 생기면** — `$CID` 가 셸에서 날아갔으면 주입 1 절의 `CID=` 를 다시 친다.
|
||
|
||
### 2. 세션을 정리한다
|
||
|
||
```bash label="[lab host] C-1 과 같은 네 줄이다"
|
||
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
|
||
```
|
||
|
||
브라우저 쿠키(`auth` 와 `app1` 과 `app2`)도 지우거나 시크릿 창을 새로 연다.
|
||
|
||
### 3. 빌린 이름을 돌려준다
|
||
|
||
**목적** — Grafana 가 쓰던 `app2.hyeonworks.com` 을 원래 주인에게 돌린다. C 층이 끝났으면 여기서 돌려준다.
|
||
|
||
**행동** — 빌린 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] ① 빌린 것을 걷고 백업을 올린다"
|
||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||
kubectl apply -f ~/grafana-ingress-backup.yaml
|
||
curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3
|
||
```
|
||
|
||
**예상 결과** — `app2` 응답이 Grafana 로 돌아간다.
|
||
|
||
**왜 필요한가** — 인증서가 `auth` 와 `app1` 과 `app2` 세 이름만 덮어서 네 번째 이름을 만들 수 없었고, 그래서 B-7 이 Grafana 의 이름을 잠시 빌렸다. 돌려주지 않으면 실험이 끝나도 Grafana 가 안 열린다.
|
||
|
||
**문제가 생기면** — ⓪이 `No such file or directory` 를 냈다면 `delete` 를 치기 전이므로 Grafana Ingress 는 아직 없고 oauth2-proxy 것은 아직 있다. B-7 의 백업 단계를 다시 읽고, 두 경로에 다 없으면 Ingress 를 손대지 않은 채로 두고 여기서 멈춘다. oauth2-proxy 배포까지 걷어내려면 한 줄이 더 있다.
|
||
|
||
```bash label="[lab host] ② 배포까지 걷어낼 때만 친다"
|
||
kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||
```
|
||
|
||
②의 `deploy/lab/k8s/b7-oauth2-proxy.yaml` 은 주입 전 2 절에서 `grep` 을 친 그 체크아웃 안에 있는 파일이다. `kc-lab-1` 에도 같은 체크아웃이 있는지는 가이드가 적지 않으므로(unknown), 그 경로가 풀리는 디렉터리에서 친다. `-n` 이 없으니 네임스페이스는 파일 안에 적힌 값을 따른다.
|
||
|
||
### 4. 임시 파드가 남았는지 본다
|
||
|
||
먼저 아래 확인표의 `kubectl -n keycloak-lab get pod c2probe` 로 남았는지 보고, `NotFound` 면 더 할 것이 없다. `--rm` 으로 안 지워졌으면 직접 지운다.
|
||
|
||
```bash label="[lab host] 남아 있으면 지운다"
|
||
kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
|
||
```
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| 클라이언트 속성 | `get clients … --fields attributes` | `backchannel.logout.url` 이 없다. 지웠다면 |
|
||
| Keycloak 세션 | realm 조인 카운트 | `0` |
|
||
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
|
||
| 임시 파드 | `kubectl -n keycloak-lab get pod c2probe` | `NotFound` 여야 정상 |
|
||
| 파드 | `kubectl -n keycloak-lab get pods` | 전부 `Running` |
|
||
| Ingress | `kubectl -n observability get ingress grafana` | 있다 |
|
||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||
|
||
## 막히면
|
||
|
||
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| 로그아웃했는데 아무 변화가 없다 | 로그아웃 전 세션이 이미 0 이었다 | realm 조인해서 먼저 센다 |
|
||
| `kcadm -s "attributes.backchannel.logout.url=…"` 이 `exit 1` | 점 표기가 안 먹는다 | JSON 으로 통째로 |
|
||
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 통째로 교체한다 | 먼저 백업 |
|
||
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 |
|
||
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다. C-1 과 같은 실수 |
|
||
| `grep -rn … bff/src/main/java/` 가 빈 출력 | 정말 없거나, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 |
|
||
| 후보 경로가 `404` 가 아니라 `302` | 핸들러가 없어 인증 요구로 떨어졌다 | 302 도 「없다」의 신호다 |
|
||
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | `DEBUG` 레벨이면 안 찍힌다 | 판정 근거로 쓰지 않는다 |
|
||
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | 운영에서는 그게 정상일 수 있다 |
|
||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 |
|
||
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 |
|
||
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | 인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다 | 새 탭에서 주소부터 다시 연다 |
|
||
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 빌린 이름을 돌려준다 |
|
||
|
||
## 무엇이 관측이고 무엇이 아닌가
|
||
|
||
이 절차의 숫자는 `2026-09-04 14:50–14:53 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||
|
||
- (observed) 두 클라이언트의 `attributes` 에 `frontchannelLogout` 만 있는 것, `grep -rn` 이 헤더 아래를 비워 둔 것, 후보 경로 셋이 전부 `HTTP 302` 인 것, 로그아웃 전 「Redis: 2 키 · keycloak-patterns 세션: 0」과 두 번째 시험의 「세션: 1 · Redis: 1 키」, 점 표기 시도의 `command terminated with exit code 1`, 클라이언트 UUID `9055fa46-6abb-4d6d-a339-8a9183bbf26d`, 로그아웃 시각 `14:54:21`, 로그아웃 뒤 `keycloak-patterns 세션: 0`, 그 뒤에도 Redis 키 두 줄이 같은 것, Keycloak 두 노드의 `backchannel` 0줄과 BFF 로그의 빈 출력, 임시 파드에서 본 `Address: 100.83.212.4` 와 `HTTP 200`.
|
||
- (observed·출처 주의) 설정이 들어간 것을 확인한 두 줄은 `02-configure-idp.txt` 에 없다. 그 파일은 점 표기 실패로 끝나고, 그 값은 뒤에 따로 실행한 조회에서 나왔다. 증거 파일과 인쇄된 값이 하나씩 짝지어지지 않는 유일한 곳이다.
|
||
- (unknown) 임시 curl 파드를 띄우는 `kubectl run c2probe …` 한 줄. 가이드가 미검증으로 표시했고 원래 실행의 명령 원문이 기록에 없다. 설정 JSON 을 파일로 만들어 넣는 형태도 가이드에 없다. 이 실험대는 명령줄에 직접 줬다.
|
||
- 로그 0줄로는 아무것도 단정하지 않았다. 「Keycloak 이 요청을 안 보냈다」는 이 출력으로 나오지 않는다. 기본 로그 레벨에서 안 보이는 것과 구별되지 않기 때문이고, `DEBUG` 를 켜서 다시 재지는 않았다(unknown).
|
||
- 비밀은 옮기지 않았다. 관리자 비밀번호는 명령 치환으로만 넘어가고 화면에 안 찍힌다. 브라우저 로그인 줄에서는 계정 이름 `labuser` 만 옮겼다. 클라이언트 UUID 와 Redis 키 이름은 식별자라 그대로 적었고, `backchannel.logout.url` 은 설정값이라 원문대로 적었다.
|
||
- 버전은 한 줄만 이 편이 직접 잰 값이다. `curlimages/curl:8.11.1` 은 이 편이 띄운 임시 파드의 출력이고(observed) 나머지는 B층 값이다(inferred).
|
||
- 이 실험대의 `HTTP 200` 은 구성 덕이다. tailnet 과 split DNS 라 클러스터 안에서 공개 이름이 되돌아온다(헤어핀). 운영에서 같은 값이 나온다고 볼 근거는 없다.
|
||
- 이 절차가 재지 않은 것 — ②와 ③을 실제로 구현한 뒤 전파가 되는지(코드를 고쳐야 한다), Keycloak 이 요청을 보내기는 했는지(`DEBUG` 로그를 안 켰다), 부분 실패 시의 재시도 정책.
|
||
|
||
<!-- body:end -->
|