Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.
Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.
The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:
A-0 emptying the session table without a restart leaves cache entries
that get counted as replication arriving
A-2 dropping -o /dev/null fuses body and status into one string
A-3 presence of "ready to accept connections" instead of its timestamp
B-2 row count alone reads an UPDATE as nothing having happened
B-4 tr ',' '\n' splits ["admin","editor"] so only admin is seen
B-7 no login screen means the cookie died and SSO re-authenticated
C-1 counting sessions without joining realm counts your own kcadm one
D-1 kubectl exec without -i restores nothing and still exits 0
D-4a "ran with error output" is what success looks like
Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.
Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.
Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
831 lines
33 KiB
Markdown
831 lines
33 KiB
Markdown
# B-0 재현 가이드 — 아무것도 주지 않았을 때 Spring 이 무엇을 고르는지 본다
|
||
|
||
해설 문서: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) ·
|
||
증거 원문: [`docs/evidence/b0-bff-redis-deploy/`](../../evidence/b0-bff-redis-deploy/)
|
||
|
||
## 이 가이드가 끝나면
|
||
|
||
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||
|
||
| 보게 되는 것 | 어디서 |
|
||
|---|---|
|
||
| 돌고 있는 인스턴스가 **실제로 고른 구현체 이름** | `/actuator/beans` |
|
||
| Redis 도 Spring Session 도 **하나도 구성되지 않은 것** | 같은 곳 |
|
||
| 조회 키에 **session ID 가 없다**는 것 | 빈 이름 하나가 그대로 설명이다 |
|
||
| **replica 2 에서 로그인 자체가 실패하는 것** | 브라우저 · `/login?error` |
|
||
| replica 를 1 로 줄이면 되는 것 | 같은 브라우저 |
|
||
| 브라우저에 토큰이 **0개**인 것 | `/bff/token-boundary` |
|
||
|
||
## 전제
|
||
|
||
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다. A층 실험은 안 해도 된다.
|
||
- **브라우저가 필요하다.** 인가 코드 흐름은 **왕복이 두 번**이라 `curl` 로
|
||
대신할 수 없다. `https://app1.hyeonworks.com/` 이 당신 브라우저에서 열려야 한다.
|
||
- BFF 이미지는 **워크스테이션에서 빌드해서 두 노드에 밀어 넣는다.**
|
||
레지스트리가 없으므로 `imagePullPolicy: Never` 다.
|
||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 으로 읽는다.
|
||
|
||
## 주의 — ★ 저장소를 먼저 붙이면 이 실험은 성립하지 않는다
|
||
|
||
B-0 의 질문은 **「아무것도 주지 않았을 때 자동구성이 무엇을 고르는가」**다.
|
||
Redis 를 먼저 연결하면 잴 것이 없어진다. **Redis 는 배포만 하고 BFF 에 연결하지
|
||
않는다.** 연결은 [B-1](b1-redis-session-store.md) 에서 한다.
|
||
|
||
**그리고 이 저장소의 현재 소스는 이미 B-1·B-2 를 거친 뒤 상태다.**
|
||
`bff-redis.yaml` 에는 `SPRING_SESSION_STORE_TYPE=redis` 가 있고, `SecurityConfig`
|
||
에는 `JdbcOAuth2AuthorizedClientService` 빈이 있다. **그대로 배포하면 B-2 의
|
||
결과를 재게 된다.** 어느 브랜치에도 B-0 시점의 파일은 남아 있지 않다 —
|
||
[2-1](#2-1-b-0-상태로-되돌린다--네-파일) 에서 손으로 되돌린다.
|
||
|
||
전 구간 약 40~60분(빌드 시간 포함). 배포한 것을 지우는 명령은 5절에 있다.
|
||
|
||
## 표시 규약
|
||
|
||
| 표시 | 뜻 |
|
||
|---|---|
|
||
| **실측** | 2026-09-04 13:39–13:46 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
|
||
|
||
파드 이름·IP·빈 개수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||
|
||
---
|
||
|
||
# 0. 왜 이 실험을 하는가
|
||
|
||
Q1 이 직접 요구한 확인이다.
|
||
|
||
> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로
|
||
> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다.
|
||
|
||
```
|
||
빈을 직접 만들지 않으면
|
||
└─ Spring Boot 가 조건에 따라 고른다
|
||
└─ 무엇을 골랐는지는 코드 어디에도 안 적혀 있다
|
||
└─ 돌아가는 인스턴스에 물어봐야 안다
|
||
```
|
||
|
||
**추측으로도 답은 나온다.** 「저장소를 안 붙였으니 메모리겠지.」 맞다.
|
||
**그런데 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.**
|
||
|
||
빈 이름 하나가 이 층 전체의 문제를 담고 있는데, **그 이름은 추측으로 안 나온다.**
|
||
찍어 봐야 나온다. 그게 이 실험이다.
|
||
|
||
---
|
||
|
||
# 1. 기준선 — 배포하기 전에
|
||
|
||
넓은 것부터 좁혀 간다.
|
||
|
||
```
|
||
노드 자원 → 네임스페이스에 무엇이 있나 → 이미지가 두 노드에 있나 → Keycloak realm
|
||
```
|
||
|
||
## 1-1. 노드에 자원이 있나
|
||
|
||
**확인**
|
||
```bash
|
||
free -m
|
||
sudo kubectl top nodes
|
||
```
|
||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||
```
|
||
=== 배포 전 자원 ===
|
||
Mem: 11648 7329 280 4 4377 4319
|
||
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
|
||
kc-lab-1 115m 5% 2192Mi 44%
|
||
kc-lab-2 121m 6% 1324Mi 33%
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 노드 메모리 사용률. 여기서는 `44%` · `33%` 다.
|
||
|
||
**이 결과가 의미하는 것** — BFF 는 JVM 이고 replica 가 2 다. 매니페스트는
|
||
`requests: 320Mi` · `limits: 512Mi` 로 잡혀 있다. **여유가 없으면 파드가
|
||
`Pending` 이거나 OOM 으로 죽는데, 그걸 「Spring 설정 문제」로 읽게 된다.**
|
||
배포 전에 한 번 보고 시작한다.
|
||
|
||
## 1-2. 네임스페이스에 무엇이 있나
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get all
|
||
sudo kubectl -n keycloak-lab get secret,ingress
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `keycloak` StatefulSet 과 `postgres` 가 있고,
|
||
**`bff` · `redis` 는 없는 것.**
|
||
|
||
**이 결과가 의미하는 것** — 이미 있으면 앞 실험의 잔재이고, 그 위에 배포하면
|
||
「내가 만든 것」과 「원래 있던 것」이 섞인다. 있으면 5-1 로 먼저 지운다.
|
||
|
||
> `get all` 은 워크로드 계열만 보여준다. **Secret·PVC·Ingress 는 안 나온다.**
|
||
> 그래서 두 줄로 나눠 친다.
|
||
|
||
## 1-3. Keycloak realm 을 준비한다
|
||
|
||
BFF 가 붙을 realm 과 클라이언트가 있어야 한다. **없으면 배포는 성공하는데
|
||
로그인에서 막힌다.**
|
||
|
||
**하기** — `kcadm` 에 로그인한다. Keycloak 이미지 안에 있는 도구다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||
```
|
||
|
||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||
> ```bash
|
||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||
> ```
|
||
> **실측** — `19`
|
||
|
||
**하기** — realm 과 클라이언트
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
create realms -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
|
||
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
create clients -r keycloak-patterns \
|
||
-s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \
|
||
-s 'redirectUris=["https://app1.hyeonworks.com/*"]'
|
||
```
|
||
|
||
**되돌리기**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
delete realms/keycloak-patterns
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `accessTokenLifespan=60`.
|
||
|
||
**이 결과가 의미하는 것** — **B-3(refresh 경쟁)을 위해 미리 짧게 잡는 것이다.**
|
||
만료를 기다리는 시간이 짧아야 재현이 된다. 지금 정해 두면 나중에 realm 을
|
||
다시 안 만든다.
|
||
|
||
**확인** — 만들어졌나
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get realms/keycloak-patterns --fields realm,enabled,accessTokenLifespan
|
||
```
|
||
|
||
로그인할 사용자도 하나 만든다.
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
create users -r keycloak-patterns -s username=labuser -s enabled=true
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
set-password -r keycloak-patterns --username labuser --new-password 'lab-user-change-me'
|
||
```
|
||
|
||
**★ 이 비밀번호는 브라우저에 직접 칠 것이므로 당신이 정한다.** 위 값은 예시고,
|
||
**실제로 쓸 값은 셸 히스토리에 남지 않게** 하려면 `kcadm.sh` 를 대화식으로
|
||
쓰거나 나중에 관리 콘솔에서 바꾼다.
|
||
|
||
---
|
||
|
||
# 2. 준비 — 배포에서 겪은 문제 다섯 가지를 먼저 읽는다
|
||
|
||
**이 절을 건너뛰면 다섯 번 막힌다.** 전부 이 실험대가 실제로 겪은 것이다.
|
||
|
||
## 2-1. B-0 상태로 되돌린다 — 네 파일
|
||
|
||
현재 소스는 B-1·B-2 의 결과를 담고 있다. **B-0 을 재려면 그 배선을 빼야 한다.**
|
||
|
||
**되돌리기** — 실험이 끝나면 원래대로 돌린다
|
||
```bash
|
||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||
deploy/lab/k8s/bff-redis.yaml
|
||
```
|
||
|
||
**하기 ①** — `bff/pom.xml` 에서 두 블록을 지운다
|
||
```bash
|
||
vim bff/pom.xml
|
||
```
|
||
| 지울 의존성 | 왜 |
|
||
|---|---|
|
||
| `spring-session-data-redis` | 있으면 `SessionRepository` 가 Redis 로 갈린다 (B-1) |
|
||
| `spring-boot-starter-data-redis` | 있으면 Redis 연결 빈이 잔뜩 생긴다 (B-1) |
|
||
| `spring-boot-starter-jdbc` · `postgresql` · `h2` | B-2 의 `JdbcOAuth2AuthorizedClientService` 용 |
|
||
|
||
**하기 ②** — `SecurityConfig.java` 에서 **빈 두 개**를 지운다
|
||
```bash
|
||
vim bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||
```
|
||
```java
|
||
// 지운다 — B-2 가 넣은 것. 이게 있으면 자동구성이 고를 기회가 없다
|
||
@Bean
|
||
OAuth2AuthorizedClientService authorizedClientService(...) { ... }
|
||
|
||
// 지운다 — 이것도 직접 만들면 "자동구성이 골랐다" 가 아니다
|
||
@Bean
|
||
OAuth2AuthorizedClientManager authorizedClientManager(...) { ... }
|
||
```
|
||
관련 `import` (`JdbcOAuth2AuthorizedClientService`, `JdbcOperations`, 매니저 계열)도
|
||
같이 지운다. **`bffSecurity` 빈은 남긴다** — `/actuator/**` 를 열어 주는 것이
|
||
그 안에 있다(문제 ④).
|
||
|
||
**하기 ③** — `application.yml` 에서 세 블록을 지운다
|
||
```bash
|
||
vim bff/src/main/resources/application.yml
|
||
```
|
||
| 지울 블록 | 왜 |
|
||
|---|---|
|
||
| `spring.session` | `store-type` 기본값이 **`redis`** 다. 남겨 두면 의존성만 빼도 경고가 난다 |
|
||
| `spring.data.redis` | Redis 연결 설정 |
|
||
| `spring.datasource` · `spring.sql.init` | B-2 의 JDBC 용 |
|
||
|
||
**하기 ④** — `deploy/lab/k8s/bff-redis.yaml` 의 `bff` 컨테이너에서 env 를 지운다
|
||
```bash
|
||
vim deploy/lab/k8s/bff-redis.yaml
|
||
```
|
||
```yaml
|
||
# 지운다 — B-1 · B-2 가 넣은 것
|
||
- name: SPRING_SESSION_STORE_TYPE
|
||
- name: REDIS_HOST
|
||
- name: REDIS_PORT
|
||
- name: BFF_DB_URL
|
||
- name: BFF_DB_USER
|
||
- name: BFF_DB_PASSWORD
|
||
```
|
||
|
||
**Redis Deployment·Service·PVC 는 그대로 둔다.** 배포는 하되 **연결만 안 한다** —
|
||
그게 B-0 의 구성이다.
|
||
|
||
**확인** — 무엇을 지웠는지 눈으로 본다
|
||
```bash
|
||
git diff --stat
|
||
git diff bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java
|
||
```
|
||
|
||
## 2-2. 문제 ① — 소스 없이 빌드 산출물만 커밋되어 있었다
|
||
|
||
원래 실행은 `bff/` 에 이런 상태를 만났다.
|
||
|
||
```
|
||
bff/target/classes/... 9개 파일
|
||
bff/src/ 없음
|
||
```
|
||
|
||
`.gitignore` 에 `target/` 이 없어 **클래스 파일만** 커밋되어 있었고 소스는
|
||
다른 브랜치에 있었다.
|
||
|
||
**확인** — 지금 당신의 저장소는 어떤가
|
||
```bash
|
||
ls bff/src/main/java/com/example/keycloakpattern/bff/
|
||
```
|
||
|
||
없으면 가져온다.
|
||
```bash
|
||
git checkout origin/develop-keycloak-pattern3 -- bff/
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — **빌드 산출물이 커밋되어 있으면 「빌드가 되는데
|
||
바꿔도 안 바뀐다」가 된다.** 소스가 있는지부터 본다.
|
||
|
||
## 2-3. 문제 ② — 빌드 실패 원인이 마지막 15줄에 없다
|
||
|
||
**하기**
|
||
```bash
|
||
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||
echo "exit=$?"
|
||
```
|
||
|
||
**확인** — 실패했으면 전체 로그에서 찾는다
|
||
```bash
|
||
grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log
|
||
```
|
||
|
||
**실측** — 원래 실행이 만난 것
|
||
```
|
||
org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys
|
||
```
|
||
|
||
`management:` 아래에 `endpoint:` 블록을 **하나 더** 넣어서 난 오류였다.
|
||
이미 있는데 또 넣은 것이다.
|
||
|
||
**이 결과가 의미하는 것** — **`docker build` 기본 출력은 마지막 몇 줄만 보여준다.**
|
||
Maven 스택트레이스는 그 위에 있다. `--progress=plain` 으로 전체를 파일로 받고
|
||
`grep` 으로 찾는다.
|
||
|
||
> `yamllint` 는 이 실험대에 깔려 있지 않다. YAML 중복 키는 **빌드가 잡아 준다** —
|
||
> 다만 그 메시지를 보려면 위처럼 해야 한다.
|
||
|
||
## 2-4. 문제 ③ — 환경변수에 기본값이 없으면 테스트가 죽는다
|
||
|
||
```yaml
|
||
# 이러면 테스트에서 컨텍스트가 안 뜬다 — 테스트는 그 환경변수를 모른다
|
||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth
|
||
|
||
# 기본값을 준다
|
||
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
|
||
```
|
||
|
||
**확인** — 지금 파일이 그렇게 되어 있나
|
||
```bash
|
||
grep -n 'KC_ISSUER' bff/src/main/resources/application.yml
|
||
```
|
||
|
||
## 2-5. 문제 ④ — actuator 가 인증에 막혀 200 인데 로그인 페이지
|
||
|
||
`/actuator/beans` 를 불렀는데 `200` 이 왔다. **내용은 Keycloak 로그인 페이지였다.**
|
||
`-L` 로 리다이렉트를 따라간 결과다.
|
||
|
||
```java
|
||
// SecurityConfig 의 permitAll 목록
|
||
"/actuator/health",
|
||
"/actuator/health/**",
|
||
// 실험대 전용 — 운영에서는 절대 열지 않는다
|
||
"/actuator/**"
|
||
```
|
||
|
||
> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다. 이 함정은
|
||
> `-o /dev/null -w '%{http_code}'` 만 쓸 때 **절대 안 보인다.**
|
||
|
||
## 2-6. 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway`
|
||
|
||
`/actuator/beans` 는 **117KB** 다. nginx → Traefik 을 거치면서 실패했다.
|
||
|
||
**실측** — [`experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) 1절
|
||
```
|
||
$ curl https://app1.hyeonworks.com/actuator/beans
|
||
Bad Gateway
|
||
```
|
||
|
||
**해결** — 파드 안에서 직접 받는다. **alpine 기반 JRE 이미지에는 `wget` 이 있다.**
|
||
(Keycloak 이미지와 다른 점이다 — 거기엔 curl 도 wget 도 없다.)
|
||
|
||
## 2-7. 이미지를 두 노드에 밀어 넣는다
|
||
|
||
레지스트리가 없다. `imagePullPolicy: Never` 라서 **두 노드에 각각 있어야 한다.**
|
||
|
||
**하기** — 워크스테이션에서
|
||
```bash
|
||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||
```
|
||
|
||
**확인** — 두 노드에 들어갔나
|
||
```bash
|
||
sudo k3s ctr images ls | grep keycloak-pattern-bff
|
||
ssh kc-lab-2 'sudo k3s ctr images ls | grep keycloak-pattern-bff'
|
||
```
|
||
|
||
**한쪽만 있으면** 그 노드에 스케줄된 replica 만 뜬다. `ErrImageNeverPull` 로
|
||
나타난다.
|
||
|
||
---
|
||
|
||
# 3. 배포
|
||
|
||
**되돌리기** — 먼저 읽어 둔다
|
||
```bash
|
||
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||
```
|
||
|
||
## 3-1. 적용
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||
```
|
||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||
```
|
||
=== 배포 ===
|
||
secret/bff-secrets created
|
||
deployment.apps/redis created
|
||
service/redis created
|
||
deployment.apps/bff created
|
||
service/bff created
|
||
ingress.networking.k8s.io/bff created
|
||
|
||
deployment "redis" successfully rolled out
|
||
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
|
||
deployment "bff" successfully rolled out
|
||
```
|
||
|
||
## 3-2. 배포 구성 — 무엇이 어디에 있나
|
||
|
||
```
|
||
브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica)
|
||
│
|
||
├──▶ Keycloak (realm: keycloak-patterns)
|
||
└──▶ echo (resource server 대역)
|
||
|
||
redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다
|
||
```
|
||
|
||
**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** 이 상태를 먼저 재는 것이 B-0 이다.
|
||
|
||
### 브라우저용 URL 과 백채널 URL 을 분리한다
|
||
|
||
```yaml
|
||
authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다
|
||
token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리
|
||
```
|
||
```yaml
|
||
- name: KC_ISSUER_EXTERNAL
|
||
value: https://auth.hyeonworks.com/realms/keycloak-patterns
|
||
- name: KC_ISSUER_INTERNAL
|
||
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
|
||
```
|
||
|
||
**브라우저가 보는 이름과 서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다.**
|
||
`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다 — 없으면 Spring 이
|
||
`redirect_uri` 를 `http://` 로 만들어 Keycloak 이 거부한다.
|
||
|
||
---
|
||
|
||
# 4. 배포가 실제로 걸렸는지 확인한다
|
||
|
||
## 4-1. 파드가 두 노드에 하나씩 떴나
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||
sudo kubectl -n keycloak-lab get pods -o wide -l app=redis
|
||
```
|
||
**실측** — [`01-deploy.txt`](../../evidence/b0-bff-redis-deploy/01-deploy.txt)
|
||
```
|
||
bff-574c6d658b-8cz4x true kc-lab-1
|
||
bff-574c6d658b-zpkbp true kc-lab-2
|
||
redis-568bd7c4-5c5vc true kc-lab-2
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **BFF 두 개가 서로 다른 노드에 있는 것.**
|
||
|
||
**이 결과가 의미하는 것** — `topologySpreadConstraints` 가 일했다. **「다른
|
||
인스턴스」가 진짜 다른 기계여야** 이 층의 질문이 성립한다. 같은 노드의 다른
|
||
프로세스면 재는 의미가 절반이다.
|
||
|
||
`Pending` 이면 `describe pod` 의 Events 를 본다. `ErrImageNeverPull` 이면
|
||
2-7 로 돌아간다.
|
||
|
||
## 4-2. 밖에서 닿나
|
||
|
||
**확인**
|
||
```bash
|
||
curl -I https://app1.hyeonworks.com/
|
||
```
|
||
**형태**
|
||
```
|
||
HTTP/2 200
|
||
content-type: text/html
|
||
```
|
||
|
||
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
|
||
```
|
||
=== 외부 진입점 ===
|
||
https://app1.hyeonworks.com/ HTTP 200
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 상태 줄과 **`content-type`.** 2-5 의 함정 때문이다.
|
||
`text/html` 이 왔다고 그게 **당신의** HTML 이라는 보장은 없다. 다음 절에서
|
||
내용까지 본다.
|
||
|
||
> `-I` 는 헤더만 본다. 여기서는 **닿는지**를 물어보는 것이라 이 형태가 맞다.
|
||
> 나중에 여러 번 재서 비교할 때는 `-o /dev/null -w '%{http_code}'` 를 쓴다.
|
||
|
||
---
|
||
|
||
# 5. 관찰 — 자동구성이 실제로 고른 것
|
||
|
||
## 5-1. `/actuator/beans` 를 파드 안에서 받는다
|
||
|
||
**하기** — 파드 이름을 먼저 잡는다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||
echo "$BFF"
|
||
```
|
||
|
||
**하기** — 파드 안에서 받아 파일로 저장한다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans.json
|
||
wc -c /tmp/beans.json
|
||
```
|
||
**형태**
|
||
```
|
||
119552 /tmp/beans.json
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 크기가 **10만 바이트 대**인 것. 원래 실행에서 **117KB**
|
||
였다. `0` 이면 못 받은 것이고, 몇 백 바이트면 **로그인 페이지나 오류 본문**이다.
|
||
|
||
**확인** — 진짜 JSON 인지 앞부분을 본다
|
||
```bash
|
||
head -c 200 /tmp/beans.json ; echo
|
||
```
|
||
**형태**
|
||
```json
|
||
{"contexts":{"keycloak-bff":{"beans":{"actuatorEndpointsSupplier":{"aliases":[],"scope":"singleton","type":"org.springframework
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `{"contexts":{"keycloak-bff"` 로 시작하는 것.
|
||
`<!DOCTYPE html` 로 시작하면 **2-5 의 함정**이다 — 로그인 페이지를 받았다.
|
||
|
||
## 5-2. `jq` 없이 빈 목록을 읽는다
|
||
|
||
**빈 하나는 이런 모양이다.**
|
||
```
|
||
"이름":{"aliases":[],"scope":"singleton","type":"패키지.클래스", ...}
|
||
```
|
||
|
||
**이름과 타입이 이 한 덩어리 안에 같이 있다.** 그러니 그 덩어리만 뽑으면 된다.
|
||
|
||
**확인** — 전체 빈 수. **미검증**
|
||
```bash
|
||
grep -o '"aliases":\[' /tmp/beans.json | wc -l
|
||
```
|
||
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
|
||
```
|
||
컨텍스트: keycloak-bff
|
||
전체 빈 수: 321
|
||
```
|
||
|
||
**확인** — 이름과 타입을 한 줄로. **미검증**
|
||
```bash
|
||
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans.json \
|
||
| sed 's/{"aliases".*"type":"/ -> /' \
|
||
| grep -i authorizedclient
|
||
```
|
||
**형태**
|
||
```
|
||
"authorizedClientManager" -> org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||
"authorizedClientRepository" -> org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||
"authorizedClientService" -> org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 화살표 오른쪽의 **클래스 이름 끝부분.**
|
||
|
||
### ★ 여기서 원래 실행이 실제로 넘어졌다
|
||
|
||
**실측** — [`02-autoconfiguration.txt`](../../evidence/b0-bff-redis-deploy/02-autoconfiguration.txt)
|
||
```
|
||
File "<stdin>", line 9
|
||
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
|
||
^
|
||
SyntaxError: unexpected character after line continuation character
|
||
```
|
||
|
||
**JSON 을 파이썬 한 줄로 파싱하려다 따옴표 이스케이프에서 깨졌다.**
|
||
빈 목록은 결국 다음 시도에서 나왔고, 그 결과가
|
||
[`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt) 다.
|
||
|
||
> **`jq` 가 있으면 그걸 쓴다. 없으면 `grep` 으로 충분하다.**
|
||
> 이 실험대에는 `jq` 가 없다. 없는 도구를 전제로 한 명령은 **진단 도중에
|
||
> 패키지를 깔러 나가게 만든다.** 그러지 않으려고 위 형태를 쓴다.
|
||
|
||
## 5-3. B-0 의 답
|
||
|
||
**실측** — [`03-beans-analysis.txt`](../../evidence/b0-bff-redis-deploy/03-beans-analysis.txt)
|
||
```
|
||
--- 세션 · 토큰 저장소 관련 ---
|
||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||
clientRegistrationRepository -> InMemoryClientRegistrationRepository
|
||
|
||
--- Redis / Spring Session 이 구성되었는가 ---
|
||
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
|
||
```
|
||
|
||
**확인** — Redis 와 Spring Session 이 정말 없는지 직접 센다
|
||
```bash
|
||
grep -ci 'RedisSessionRepository\|SpringHttpSessionConfiguration\|LettuceConnectionFactory' /tmp/beans.json
|
||
```
|
||
**형태**
|
||
```
|
||
0
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `0`.
|
||
|
||
**이 결과가 의미하는 것** — 의존성 자체가 없으니 자동구성이 걸릴 조건이 없다.
|
||
**세션은 서블릿 컨테이너(Tomcat)의 기본 `StandardSession` 에 있다.** 즉 **인스턴스
|
||
메모리**다.
|
||
|
||
| 빈 | 구현체 | 뜻 |
|
||
|---|---|---|
|
||
| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 |
|
||
| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 |
|
||
| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유)를 쓴다 |
|
||
| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 |
|
||
| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` |
|
||
| Redis / Spring Session | **없음** | 의존성 자체가 없다 |
|
||
|
||
## 5-4. ★ 이름 하나가 이 층 전체의 문제다
|
||
|
||
`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` — **이름이 곧 설명이다.**
|
||
|
||
```
|
||
요청이 인증되어 있으면
|
||
└─▶ OAuth2AuthorizedClientService 에 위임
|
||
└─▶ 키: (clientRegistrationId, principalName)
|
||
└─ session ID 가 없다 ★
|
||
인증되어 있지 않으면
|
||
└─▶ HttpSession 에 임시 보관
|
||
```
|
||
|
||
**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 같은 항목을
|
||
본다.** 한쪽에서 토큰을 갱신하면 다른 쪽 것을 덮어쓴다.
|
||
|
||
> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가
|
||
> 없기 때문**이다. 「Session Store 를 공유 저장소로 바꾸는 것만으로는 충분하지
|
||
> 않다」의 기제가 이 빈 하나에 들어 있다.
|
||
>
|
||
> **이것이 추측으로는 안 나오는 부분이다.** 「메모리겠지」까지는 맞혔어도
|
||
> **조회 키가 무엇인지는 빈 이름을 봐야 안다.**
|
||
|
||
---
|
||
|
||
# 6. ★ 예상 못 한 것 — replica 2개에서 로그인 자체가 안 된다
|
||
|
||
**여기부터는 브라우저로 한다.**
|
||
|
||
## 6-1. 증상
|
||
|
||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 을 열고 로그인한다.
|
||
|
||
**형태** — 주소창이 이렇게 끝난다
|
||
```
|
||
https://app1.hyeonworks.com/login?error
|
||
```
|
||
|
||
**확인** — 로그를 본다. **두 파드를 다 봐야 한다**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab logs -l app=bff --tail=100 --prefix
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **아무 오류도 없다.**
|
||
|
||
**이 결과가 의미하는 것** — Spring Security 는 **로그인 실패를 DEBUG 로만
|
||
남긴다.** 「로그에 아무것도 없으니 애플리케이션 문제가 아니다」로 읽으면 틀린다.
|
||
**증상은 있는데 로그가 없는 상태**이고, 그럴 때는 가설을 세워 시험한다.
|
||
|
||
## 6-2. 가설
|
||
|
||
```
|
||
① 브라우저 → 앱 → IdP 로 리다이렉트 (state·PKCE verifier 를 저장)
|
||
② IdP → 브라우저 → 앱의 콜백 (저장한 것을 꺼내 검증)
|
||
```
|
||
|
||
**인가 코드 흐름은 왕복이 두 번이고, 두 번 다 같은 인스턴스로 가야 한다.**
|
||
저장 위치가 `HttpSession` 이고 그게 **인스턴스 메모리**이므로, 콜백이 다른
|
||
replica 로 가면 저장된 인가 요청이 없어 실패한다.
|
||
|
||
**5-3 에서 본 「SessionRepository 없음」이 이 가설의 근거다.**
|
||
|
||
## 6-3. 검증 — replica 를 1로 줄인다
|
||
|
||
**되돌리기** — 먼저 읽어 둔다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
|
||
```
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=1
|
||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||
```
|
||
|
||
**하기** — 브라우저에서 다시 로그인한다. **쿠키를 먼저 지운다** (앞선 실패의
|
||
세션이 남아 있으면 결과가 섞인다).
|
||
|
||
**실측** — [`b0-bff-login-success-single-replica.png`](../../evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png)
|
||
```
|
||
replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로)
|
||
replica 1 → 로그인 성공
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — **가설 확정.**
|
||
|
||
> **「다중 인스턴스에서 어떻게 운영할 것인가」는 로그인한 뒤의 문제가 아니라
|
||
> 로그인 자체의 문제다.** [B-2](b2-multi-instance-session.md) 의
|
||
> 검증 1번(「한쪽에서 로그인한 뒤 다른 인스턴스로 요청」)보다 **앞선 단계**다.
|
||
> 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다.
|
||
|
||
## 6-4. 토큰 경계 — 브라우저에 무엇이 있나
|
||
|
||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/bff/token-boundary`
|
||
|
||
**실측** — [`b0-bff-token-boundary.png`](../../evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png)
|
||
```json
|
||
{"pattern":"AP3-backend-for-frontend","principal":"labuser",
|
||
"accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true,
|
||
"browserTokenCount":0,"csrfProtectionEnabled":true}
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 값.
|
||
|
||
| 필드 | 값 | 뜻 |
|
||
|---|---|---|
|
||
| `accessTokenStoredOnServer` | `true` | 서버가 access token 을 들고 있다 |
|
||
| `refreshTokenStoredOnServer` | `true` | refresh token 도 서버에 있다 |
|
||
| **`browserTokenCount`** | **`0`** | **브라우저에는 토큰이 하나도 없다** |
|
||
|
||
**이 결과가 의미하는 것** — **BFF 패턴이 성립한다.** 브라우저는 세션 쿠키만
|
||
들고 있고 토큰은 전부 서버에 있다. 이 세 값이 [B-1](b1-redis-session-store.md)
|
||
에서 어떻게 바뀌는지가 다음 실험의 요지다. **지금 값을 적어 둔다.**
|
||
|
||
---
|
||
|
||
# 7. 복구
|
||
|
||
## 7-1. replica 를 되돌린다
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab scale deployment/bff --replicas=2
|
||
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=180s
|
||
```
|
||
|
||
**[B-1](b1-redis-session-store.md) 로 이어서 갈 것이라면 배포는 그대로 둔다.**
|
||
거기서 같은 파드에 Redis 를 붙인다.
|
||
|
||
## 7-2. 소스 변경을 되돌린다
|
||
|
||
**하기**
|
||
```bash
|
||
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||
bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java \
|
||
deploy/lab/k8s/bff-redis.yaml
|
||
git status --short
|
||
```
|
||
|
||
**★ 잊으면 다음에 `apply` 할 때 B-0 구성이 다시 배포된다.**
|
||
|
||
## 7-3. 전부 지울 때
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl delete -f deploy/lab/k8s/bff-redis.yaml
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
delete realms/keycloak-patterns
|
||
```
|
||
|
||
**★ PVC 는 `delete -f` 로 같이 지워진다.** Redis 데이터도 사라진다.
|
||
|
||
## 7-4. 원상복구 확인표
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| replica | `sudo kubectl -n keycloak-lab get deploy bff` | `2/2` |
|
||
| 소스 | `git status --short` | 출력 없음 |
|
||
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | 두 노드에 하나씩 |
|
||
| Keycloak | `sudo kubectl -n keycloak-lab get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running` |
|
||
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||
| 임시 파일 | `rm -f /tmp/beans.json /tmp/build.log` | — |
|
||
|
||
> **★ actuator 를 열어 둔 채로 두지 않는다.** `/actuator/beans` 와
|
||
> `/actuator/env` 는 **내부 구조와 설정값을 그대로 드러낸다.** 실험대라서
|
||
> 여는 것이고, 운영이라면 `health` 만 남긴다.
|
||
|
||
> **이 실험이 재지 않은 것** — 스티키 세션(세션 어피니티)을 켜면 replica 2 에서
|
||
> 로그인이 되는지는 재지 않았다. 「같은 인스턴스로 보내면 된다」는 추론이지
|
||
> 측정이 아니다.
|
||
|
||
---
|
||
|
||
# 막히면
|
||
|
||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| 빈 목록에 `RedisSessionRepository` 가 있다 | **B-1·B-2 배선이 남아 있다** | 2-1 을 다시. `git diff` 로 확인 |
|
||
| `authorizedClientService` 가 `Jdbc...` 다 | `SecurityConfig` 의 명시 빈이 남아 있다 | 2-1 하기 ② |
|
||
| `/actuator/beans` 가 `Bad Gateway` | 응답이 117KB 라 프록시가 못 넘긴다 | 파드 안에서 받는다 — 5-1 |
|
||
| `/actuator/beans` 가 `200` 인데 HTML | **Keycloak 로그인 페이지다** | `head -c 200` 으로 내용 확인 — 5-1 |
|
||
| `beans.json` 이 0 바이트 | 파드 이름이 틀렸거나 포트가 다르다 | `get pods -l app=bff`, 포트는 `8083` |
|
||
| 빌드가 실패하는데 원인이 안 보인다 | 마지막 15줄에 없다 | `--progress=plain` + 파일 — 2-3 |
|
||
| 테스트에서 컨텍스트가 안 뜬다 | 환경변수에 기본값이 없다 | 2-4 |
|
||
| 파드가 `ErrImageNeverPull` | 그 노드에 이미지가 없다 | 두 노드에 각각 import — 2-7 |
|
||
| 파드가 `Pending` | 노드 메모리 부족 | `describe pod` Events, `top nodes` — 1-1 |
|
||
| 브라우저가 `/login?error` | **replica 2 + 스티키 없음** | replica 1 로 줄여 확인 — 6-3 |
|
||
| BFF 로그에 오류가 없다 | Spring Security 는 로그인 실패를 DEBUG 로만 남긴다 | 로그 없음을 「문제 없음」으로 읽지 않는다 — 6-1 |
|
||
| 로그인 후 리다이렉트가 `http://` 로 간다 | `SERVER_FORWARD_HEADERS_STRATEGY` 가 없다 | 매니페스트 env 확인 — 3-2 |
|
||
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 5-2 |
|
||
| `kcadm` 이 `401` | `config credentials` 를 안 했거나 만료됐다 | 1-3 을 다시 |
|
||
|
||
---
|
||
|
||
# 다음
|
||
|
||
| 실험 | B-0 이 남긴 것 |
|
||
|---|---|
|
||
| [B-1](b1-redis-session-store.md) 저장소 결정 | **전환 후 이 빈들을 다시 찍는다.** 「Redis 붙였다」고 믿는데 자동구성이 안 걸리는 경우가 흔하다 |
|
||
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그게 검증 0번이다 |
|
||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어 뒀다 |
|
||
| 운영 | actuator `beans`/`env` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
|