Files
document-haness/docs/keycloak-session-store/tech-log-studio/trust-handed-over-at-the-edge/setup/setup-reproduce-c2-backchannel-logout.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 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>
2026-09-17 15:59:42 +09:00

776 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: 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분 |
## 이 실험이 가르는 것
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 이 연결조차 못 했다 = 네트워크가 원인 |
후보 ③은 원인이 아니다. 네트워크는 열려 있고, 그래도 앱 세션은 안 지워졌다. 두 줄을 읽었으면 파드에서 나온다 — `--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 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:5014: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 -->