docs(guides): reproduction guides for all 26 experiments
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8062cc9a19
commit
6f6ab86345
@@ -0,0 +1,112 @@
|
|||||||
|
# 실험 재현 가이드 26편
|
||||||
|
|
||||||
|
각 편은 **직접 쳐서 재현**하는 문서다. 무엇을 발견했는지는
|
||||||
|
[`docs/experiment-*.md`](../../) 가 적고, 여기는 **그것을 다시 만들려면 무엇을
|
||||||
|
어떤 순서로 치는가**를 적는다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[기반 7단계](../)가 끝나 있어야 한다. 특히 [05](../05-keycloak/) 까지는
|
||||||
|
모든 실험의 공통 전제이고, 지표를 보는 실험은 [06](../06-observability/) 도
|
||||||
|
필요하다.
|
||||||
|
|
||||||
|
## 각 편의 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
이 가이드가 끝나면 · 전제 · 주의 · 표시 규약
|
||||||
|
0 왜 이 실험인가
|
||||||
|
1 기준선 ← 주입 전에 평시를 잡는다
|
||||||
|
2 주입
|
||||||
|
3 주입 검증 ← 여기가 대부분의 편에서 가장 중요하다
|
||||||
|
4 관찰
|
||||||
|
5 복구
|
||||||
|
막히면 · 다음
|
||||||
|
```
|
||||||
|
|
||||||
|
**3번이 핵심인 편이 많다.** 이 실험대에서 주입은 아홉 번 조용히 실패했고,
|
||||||
|
실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않는다.
|
||||||
|
그래서 결과를 읽기 전에 대상이 실제로 그 상태인지를 따로 확인한다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 증거 파일에 있는 출력 원문. 그대로 나온다 |
|
||||||
|
| **형태** | 모양만 같고 값은 환경마다 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 고쳐 쓴 형태. 원래 실행에서 그대로 쓰이지는 않았다 |
|
||||||
|
|
||||||
|
## A층 — Keycloak 자체가 깨질 때
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| A-0 | [세션 공유 경로](a0-session-replication.md) | 세션을 나르는 것이 Infinispan 이 아니라 PostgreSQL 이라는 것 |
|
||||||
|
| A-1 | [7800 차단](a1-jgroups-transport-block.md) | 세션 공유는 안 깨지고 로그아웃 전파만 깨진다 |
|
||||||
|
| A-2 | [DB 정지](a2-database-loss.md) | 503 이 나는 동안에도 `up` 이 1 이다 |
|
||||||
|
| A-3 | [DB 크래시](a3-database-crash.md) | 200 을 받은 로그인 153건 중 4건이 DB 에 없다 |
|
||||||
|
| A-4 | [노드 상실](a4-node-loss.md) | 죽은 노드가 40초 동안 `Ready` 로 읽힌다 |
|
||||||
|
| A-5 | [비대칭 분단](a5-asymmetric-partition.md) | 한 방향만 막으면 열린 쪽으로 재연결한다 |
|
||||||
|
| A-6 | [지연 주입](a6-latency-injection.md) | 200밀리초가 두 단계를 지나 22.2초가 된다 |
|
||||||
|
| A-7 | [volatile 비교](a7-volatile-comparison.md) | 설정 하나로 A층 결론 셋이 뒤집힌다 |
|
||||||
|
| A-7a | [volatile 원인 확정](a7a-volatile-cause.md) | 같은 설정이 캐시 온도만으로 400·500·200 세 답을 낸다 |
|
||||||
|
| A-8 | [롤링 재시작](a8-rolling-restart.md) | 세션은 남고 캐시만 사라진다 |
|
||||||
|
|
||||||
|
## B층 — 애플리케이션 쪽 저장소
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| B-0 | [자동구성 확인](b0-bff-redis-deploy.md) | 아무것도 안 주면 Spring 이 무엇을 고르는가 |
|
||||||
|
| B-1 | [Redis 세션 저장소](b1-redis-session-store.md) | 세션은 옮겨지는데 토큰은 안 따라온다 |
|
||||||
|
| B-2 | [다중 인스턴스](b2-multi-instance-session.md) | 저장소를 옮겨도 안 고쳐지는 것 — 원인은 기본키다 |
|
||||||
|
| B-3 | [refresh 경쟁](b3-refresh-token-contention.md) | 이긴 요청의 토큰조차 못 쓴다 |
|
||||||
|
| B-4 | [Edge 인가 범위](b4-edge-authorization-scope.md) | 위조 헤더가 그대로 도착한다 |
|
||||||
|
| B-5 | [Redis 상실·영속화](b5-redis-loss-persistence.md) | 볼륨 없는 영속화 설정은 장식이다 |
|
||||||
|
| B-6 | [키 회전](b6-key-rotation.md) | JWKS 캐시에 유예 구간이 없다 |
|
||||||
|
| B-7 | [cookie secret 회전](b7-cookie-secret-rotation.md) | 겹침 구간을 만들 수 없고 서버 세션이 고아로 남는다 |
|
||||||
|
| B-7a | [고아 세션 정리](b7a-orphan-session.md) | TTL 로 생성 시각을 역산해 골라낸다 |
|
||||||
|
|
||||||
|
## C층 — SSO 와 로그아웃
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| C-1 | [다중 앱 SSO](c1-multi-app-sso.md) | SSO 는 되는데 로그아웃이 안 퍼진다 |
|
||||||
|
| C-2 | [백채널 로그아웃](c2-backchannel-logout.md) | 양쪽 다 없었다 — 한쪽만 고치면 여전히 안 된다 |
|
||||||
|
|
||||||
|
## D층 — 운영
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| D-1 | [백업·복구](d1-backup-restore.md) | 백업이 진짜 백업인지 스키마를 지워서 확인한다 |
|
||||||
|
| D-2 | [버전 업그레이드](d2-version-upgrade.md) | 롤백이 되는 조건은 스키마가 안 움직였을 때다 |
|
||||||
|
| D-3 | [비밀 관리](d3-secret-management.md) | base64 는 인코딩이지 암호화가 아니다 |
|
||||||
|
| D-4 | [인증서 갱신](d4-certificate-renewal.md) | 갱신은 성공했는데 38분 25초 동안 옛 인증서가 나갔다 |
|
||||||
|
| D-4a | [deploy 훅](d4a-deploy-hook.md) | 훅 하나로 그 공백이 1~2초가 된다 |
|
||||||
|
|
||||||
|
## 순서
|
||||||
|
|
||||||
|
A-0 을 먼저 한다. 나머지 A층 결론이 전부 거기서 확인한 「세션이 어디 있는가」
|
||||||
|
위에 서 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
A-0 ─┬─ A-1 ─┬─ A-5
|
||||||
|
│ └─ A-6
|
||||||
|
├─ A-2 ── A-3 ── D-1 ── D-2
|
||||||
|
├─ A-4
|
||||||
|
├─ A-8
|
||||||
|
└─ A-7 ── A-7a ← A층을 다 한 뒤 설정 하나만 바꿔 재실행한다
|
||||||
|
|
||||||
|
B-0 ── B-1 ─┬─ B-2 · B-3 · B-4 · B-5 · B-6
|
||||||
|
└─ B-7 ── B-7a
|
||||||
|
|
||||||
|
C-1 ── C-2 D-3 · D-4 ── D-4a (언제든 독립적으로)
|
||||||
|
```
|
||||||
|
|
||||||
|
**A-7 을 A층 마지막에 두는 이유** — 앞의 실험을 다 마친 뒤 설정 하나만 바꿔
|
||||||
|
재실행하면 **같은 주입에 대한 정반대 결과**를 한 벌로 얻는다.
|
||||||
|
|
||||||
|
## 안전
|
||||||
|
|
||||||
|
각 편의 2번(주입)부터 상태가 바뀐다. 모든 편이 **되돌리는 명령을 주입보다
|
||||||
|
먼저** 보여 주고, 5번에서 원상복구를 확인한다.
|
||||||
|
|
||||||
|
호스트(`test-server`)에서 하는 일은 sudo 비밀번호가 필요해 **사람이 직접
|
||||||
|
쳐야** 한다. D-1 과 D-4 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,956 @@
|
|||||||
|
# A-2 재현 가이드 — PostgreSQL 을 내리고 살아남는 노드가 있는지 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a2-database-loss.md`](../../experiment-a2-database-loss.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a2-database-loss/`](../../evidence/a2-database-loss/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 캐시에 세션을 가진 노드도 refresh 가 `500` 인 것 | 상주 탐침 파드 |
|
||||||
|
| JWKS 와 `.well-known` 만 `200` 으로 살아 있는 것 | 같은 파드 |
|
||||||
|
| Ready 파드가 **0개**, `ready` 주소가 **빈 목록**인 것 | `endpointslice` |
|
||||||
|
| 정문이 `503` 을 주는 것 | 밖에서 `curl` |
|
||||||
|
| `database connections` 만 DOWN 인 헬스 본문 | `health/ready` |
|
||||||
|
| **`up = 1` 인 채로 전면 장애가 나 있는 것** | Prometheus |
|
||||||
|
| 15초 만에 **재시작 0회**로 스스로 돌아오는 것 | `get pods` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`A-0`](a0-session-replication.md) 을 먼저 한다. 「세션은 DB 가 공유한다」를
|
||||||
|
손으로 확인해 두지 않으면 이 실험의 `500` 을 해석할 수 없다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 네임스페이스는 `keycloak-lab`, Prometheus 는 `observability` 다.
|
||||||
|
- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드용, 하나는 관찰용.
|
||||||
|
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
## 주의 — 이건 전면 장애를 만드는 실험이다
|
||||||
|
|
||||||
|
**정문(`https://auth.hyeonworks.com`)이 실제로 `503` 이 된다.** 이 실험대를 쓰는
|
||||||
|
다른 작업이 있으면 멈춘다. 정지 구간은 **1분 남짓**으로 짧게 잡는다. 되돌리는
|
||||||
|
명령은 하나뿐이고 [5. 복구](#5-복구) 에 있다. 중간에 그만두려면 그것만 치면 된다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 11:53–11:58 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
|
||||||
|
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
|
||||||
|
실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
[A-1](a1-jgroups-transport-block.md) 에서 **「룩어사이드 캐시는 읽을 때 DB 와
|
||||||
|
대조하지 않는다」**를 확인했다. 로그아웃되어 DB 행이 사라진 세션에 대해서도
|
||||||
|
캐시를 가진 노드가 `200` 을 줬다.
|
||||||
|
|
||||||
|
**그렇다면 캐시를 가진 노드는 DB 없이도 버틸지 모른다.** 그 가설을 가른다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| 캐시가 DB 를 대신한다면 | 캐시를 가진 노드는 **살아남는다** — 부분 장애 |
|
||||||
|
| 대신하지 못한다면 | **전면 장애** |
|
||||||
|
|
||||||
|
그리고 A-1 과의 대비가 이 실험의 진짜 값이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
A-1 7800 차단 → 한쪽만 빠지고 서비스는 계속됐다 (용량 저하)
|
||||||
|
A-2 DB 정지 → ? (여기서 판정)
|
||||||
|
```
|
||||||
|
|
||||||
|
**네 경로를 구분해서 본다.** 하나만 재면 무엇 때문에 죽었는지 모른다.
|
||||||
|
|
||||||
|
| # | 경로 | 무엇을 보는가 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① | **캐시를 가진 노드**에서 refresh | 캐시가 DB 를 대신할 수 있는가 |
|
||||||
|
| ② | 캐시가 없는 노드에서 refresh | 완전한 DB 의존 |
|
||||||
|
| ③ | 새 로그인 | 쓰기 경로 |
|
||||||
|
| ④ | 이미 발급된 토큰으로 관리 API 조회 | 서명만으로 되는 경로가 있는가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — DB 를 내리기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 정지 후에 볼 것을 정지 전에 **똑같은
|
||||||
|
명령으로** 먼저 봐 둔다.
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → 클러스터 크기 → 탐침 파드 → 양쪽에 세션 하나씩 → 노드별 캐시 → 대조군 시험
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드와 노드
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
keycloak-0 true 10.42.1.67 kc-lab-2
|
||||||
|
keycloak-1 true 10.42.0.35 kc-lab-1
|
||||||
|
postgres-7b474b88c8-sn9ff true 10.42.1.24 kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `READY` 가 셋 다 `1/1`, `RESTARTS` 가 `0`
|
||||||
|
- **`postgres` 가 어느 노드에 있는가.** 원래 실행에서는 `kc-lab-2`, 즉
|
||||||
|
`keycloak-0` 과 **같은 노드**다
|
||||||
|
- IP 세 개를 적어 둔다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `postgres` 와 `keycloak-0` 이 같은 노드에 있다는
|
||||||
|
사실은 이 실험에서는 상관없지만, **A-4(노드 상실)에서는 결정적이다.**
|
||||||
|
그 노드를 죽이면 A-2 가 함께 일어난다.
|
||||||
|
|
||||||
|
IP 는 변수로 잡아 둔다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
echo "$K0 $K1"
|
||||||
|
```
|
||||||
|
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||||
|
```
|
||||||
|
keycloak-0=10.42.1.67 keycloak-1=10.42.0.35
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-2. 클러스터가 정상인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
|
||||||
|
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||||
|
|
||||||
|
읽기 좋게 자른다. **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||||
|
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
cluster_size keycloak-1 = 2
|
||||||
|
cluster_size keycloak-0 = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — A-1 의 분단이 완전히 회복된 상태에서 시작한다.
|
||||||
|
여기가 `1` 이면 A-1 의 잔재가 남은 것이고, 그 위에서 재면 두 실험이 섞인다.
|
||||||
|
|
||||||
|
## 1-3. 상주 탐침 파드를 띄운다 — 계측 도구를 바꾼다
|
||||||
|
|
||||||
|
**A-1 에서 임시 curl 파드가 형편없는 계측 도구임을 확인했다.** `--rm` 파드는
|
||||||
|
매번 만들고 지우므로 느리고 경합이 있고, **토큰을 단계 사이로 넘길 수 없다.**
|
||||||
|
|
||||||
|
이 실험은 **DB 정지 전에 발급한 토큰을 정지 후에 써야** 한다. 그래서 파드를
|
||||||
|
하나 띄워 두고 `exec` 로 단계를 이어간다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run a2-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="K0=$K0" --env="K1=$K1" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
|
||||||
|
```
|
||||||
|
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||||
|
```
|
||||||
|
pod/a2-probe condition met
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||||
|
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||||
|
> ```
|
||||||
|
> **실측** — `19`
|
||||||
|
|
||||||
|
**확인** — 환경변수가 들어갔나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a2-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
K0=10.42.1.67 K1=10.42.0.35 PW길이=19
|
||||||
|
```
|
||||||
|
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
|
||||||
|
|
||||||
|
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
|
||||||
|
```
|
||||||
|
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다**
|
||||||
|
(`--rm` 이 없다).
|
||||||
|
|
||||||
|
## 1-4. 양쪽 노드에 세션을 하나씩 만든다
|
||||||
|
|
||||||
|
**이 실험의 ① 과 ② 를 구분하려면 「캐시를 가진 노드」와 「없는 노드」가 있어야
|
||||||
|
한다.** A-0 에서 확인한 성질을 그대로 쓴다 — **각 노드는 자기가 로그인시킨
|
||||||
|
세션만 캐시한다.**
|
||||||
|
|
||||||
|
**하기** — 파드 안 셸에서
|
||||||
|
```sh
|
||||||
|
TOK=/realms/master/protocol/openid-connect/token
|
||||||
|
for H in "$K0" "$K1"; do
|
||||||
|
echo -n "$H : "
|
||||||
|
curl -s -X POST "http://$H:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||||
|
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||||
|
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||||
|
```
|
||||||
|
=== [준비] 양쪽 노드에 세션을 하나씩 만든다 ===
|
||||||
|
keycloak-0 에서 로그인 sid=EAXV5HcG2J1BZ3vnwONf64AQ 토큰길이=613
|
||||||
|
keycloak-1 에서 로그인 sid=McyTj5lj3n_JqApCXeuAHExc 토큰길이=613
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — sid 두 개가 나온다. 빈 줄이 나오면 로그인이 실패했거나
|
||||||
|
base64 패딩 때문에 sid 를 못 뽑은 것이다. 응답 전체를 한 번 그대로 본다.
|
||||||
|
|
||||||
|
## 1-5. 세션이 각자 노드에만 캐시되었는가
|
||||||
|
|
||||||
|
**확인** — 밖에서. Keycloak 이미지에는 `curl` 이 없으므로 Prometheus 에 묻는다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||||
|
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
**실측** — [`02-setup-sessions.txt`](../../evidence/a2-database-loss/02-setup-sessions.txt)
|
||||||
|
```
|
||||||
|
=== [확인] 세션이 각자 노드에만 캐시되었는가 ===
|
||||||
|
keycloak-1 = 0 건
|
||||||
|
keycloak-0 = 1 건
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `cache` 가 `sessions` 인 두 줄. 값이 서로 다르다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`keycloak-0` 은 캐시를 가졌고 `keycloak-1` 은 없다.**
|
||||||
|
이제 ① 과 ② 를 구분해서 물을 수 있다.
|
||||||
|
|
||||||
|
> **`keycloak-1` 이 `0` 인 것은 스크레이프 지연 때문이다.** 방금 로그인했으므로
|
||||||
|
> 다음 15초 스크레이프에서 `1` 이 될 수 있다. 원래 실행 기록에도 그렇게 적혀
|
||||||
|
> 있다 — 「캐시 keycloak-0 = 1 건 / keycloak-1 = 0 건 (스크레이프 지연)」.
|
||||||
|
> **중요한 것은 「양쪽이 다르다」가 아니라 「`keycloak-0` 이 확실히 가지고
|
||||||
|
> 있다」다.** ① 의 해석에 필요한 것은 그것뿐이다.
|
||||||
|
|
||||||
|
**확인** — DB 에는 몇 건인가
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a2-database-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== DB 온라인 세션 ===
|
||||||
|
2
|
||||||
|
```
|
||||||
|
**이 숫자를 적어 둔다.** 복구 후에 세션이 살아남았는지 볼 대조군이다.
|
||||||
|
|
||||||
|
## 1-6. 대조군 — DB 가 살아 있을 때 네 경로가 전부 되는 것을 먼저 본다
|
||||||
|
|
||||||
|
**이 절을 건너뛰면 뒤의 `500` 이 아무 의미가 없다.**
|
||||||
|
|
||||||
|
### ④ 에 쓸 클라이언트 id 를 지금 뽑아 둔다
|
||||||
|
|
||||||
|
**DB 가 죽은 뒤에는 이 조회 자체가 실패한다.** 미리 잡아 놔야 ④ 를 측정할 수
|
||||||
|
있다.
|
||||||
|
|
||||||
|
**확인** — 파드 안에서. 응답을 한 번 그대로 본다
|
||||||
|
```sh
|
||||||
|
AT=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||||
|
curl -s -H "Authorization: Bearer $AT" \
|
||||||
|
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
|
||||||
|
```
|
||||||
|
**형태** — 객체 하나짜리 배열. `"id"` 가 맨 앞에 있다
|
||||||
|
```json
|
||||||
|
[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli", ...}]
|
||||||
|
```
|
||||||
|
|
||||||
|
무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증**
|
||||||
|
```sh
|
||||||
|
CID=$(curl -s -H "Authorization: Bearer $AT" \
|
||||||
|
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
|
||||||
|
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
|
||||||
|
echo "CID=$CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수
|
||||||
|
> 있다.** `.*` 가 탐욕적이라 줄에서 마지막 `"id":"` 를 고른다.
|
||||||
|
|
||||||
|
### 네 경로를 정상 상태에서 한 번 돌린다
|
||||||
|
|
||||||
|
**하기** — 파드 안에서
|
||||||
|
```sh
|
||||||
|
R0=$(curl -s -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
|
||||||
|
RT0=$(echo "$R0" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
R1=$(curl -s -X POST "http://$K1:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW")
|
||||||
|
RT1=$(echo "$R1" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
|
||||||
|
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
|
||||||
|
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||||
|
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
|
||||||
|
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
|
||||||
|
```
|
||||||
|
**형태** — 정상 상태에서는
|
||||||
|
```
|
||||||
|
① 200
|
||||||
|
② 200
|
||||||
|
③ 200
|
||||||
|
④ 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ `-o /dev/null` 을 빼면 안 된다.** 빼면 본문과 상태코드가 한 줄에 섞여
|
||||||
|
나온다. 원래 실행이 정확히 이걸 당했다 — [4-1](#4-1-네-경로) 을 본다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 네 경로가 전부 `200` 인 것이 기준선이다. 정지 후에
|
||||||
|
`500` 이면 「내가 깨뜨린 것」이고, 대조군 없이는 이 구별이 안 된다.
|
||||||
|
|
||||||
|
### ⑤ 상태가 필요 없는 경로도 미리 재 둔다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```sh
|
||||||
|
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
|
||||||
|
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||||
|
"http://$K0:8080/realms/master/.well-known/openid-configuration"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
JWKS 200
|
||||||
|
well-known 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 밖에서 정문도 재 둔다
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit` 으로 파드 셸에서 나온다. **파드는 그대로 둔다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — PostgreSQL 을 0대로 내린다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 왜 `scale --replicas=0` 인가
|
||||||
|
|
||||||
|
| 방법 | 무엇이 일어나나 |
|
||||||
|
|---|---|
|
||||||
|
| **`scale --replicas=0`** | 파드가 정상 종료되고 **다시 만들어지지 않는다** |
|
||||||
|
| `delete pod` | Deployment 가 **곧바로 새로 만든다** — 몇 초 만에 돌아온다 |
|
||||||
|
| 노드 정지 | Keycloak 도 같이 죽는다 — **두 장애가 섞인다** |
|
||||||
|
|
||||||
|
**`delete pod` 를 쓰면 이 실험이 성립하지 않는다.** DB 가 없는 구간을 원하는
|
||||||
|
만큼 유지할 수 있어야 네 경로를 다 재고 헬스와 엔드포인트까지 볼 수 있다.
|
||||||
|
|
||||||
|
> 이것은 **정상 종료**다. PostgreSQL 은 SIGTERM 을 받고 WAL 을 플러시한 뒤
|
||||||
|
> 내려간다. **데이터는 하나도 잃지 않는다.** 강제로 죽였을 때 무엇을 잃는지는
|
||||||
|
> [A-3](a3-database-crash.md) 이 잰다.
|
||||||
|
|
||||||
|
## 2-2. 적용
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 정지'
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||||
|
date '+%H:%M:%S 삭제완료'
|
||||||
|
```
|
||||||
|
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
|
||||||
|
```
|
||||||
|
=== [2] PostgreSQL 정지 ===
|
||||||
|
정지 시각: 11:56:04
|
||||||
|
deployment.apps/postgres scaled
|
||||||
|
pod/postgres-7b474b88c8-sn9ff condition met
|
||||||
|
삭제 완료: 11:56:04
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 시각이 같다.** 즉시 사라진다.
|
||||||
|
|
||||||
|
**시각을 반드시 적어 둔다.** 뒤에서 「언제부터 변했나」를 볼 때 이 시각이 없으면
|
||||||
|
인과를 못 붙인다.
|
||||||
|
|
||||||
|
> **access token 수명이 60초다.** 1-6 에서 발급한 `AT` 로 ④ 를 재려면
|
||||||
|
> **발급 → 정지 → 시험을 60초 안에** 끝내야 한다. 60초를 넘기면 ④ 의 `401`
|
||||||
|
> 이 「DB 때문」인지 「토큰 만료」인지 구별되지 않는다. 시간이 지났으면
|
||||||
|
> 4-1 전에 토큰을 다시 받아 둔다 — 단, **그건 DB 가 있어야 되는 일**이므로
|
||||||
|
> 순서는 「토큰 발급 → 정지」다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. postgres 파드가 정말 없나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `postgres` 로 시작하는 줄이 **한 개도 없다.**
|
||||||
|
`Terminating` 으로 남아 있으면 아직 안 끝난 것이다. `wait` 가 통과했으면 없다.
|
||||||
|
|
||||||
|
**확인** — Deployment 쪽도 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy postgres
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||||
|
postgres 0/0 0 0 5d
|
||||||
|
```
|
||||||
|
|
||||||
|
`0/0` 이어야 한다. `0/1` 이면 스케일이 안 먹고 파드가 못 뜨는 다른 문제다.
|
||||||
|
|
||||||
|
## 3-2. Keycloak 이 실제로 DB 에 못 붙고 있나
|
||||||
|
|
||||||
|
**확인** — 로그가 원인을 말한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=40 | grep -A3 -i 'connection'
|
||||||
|
```
|
||||||
|
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:664)
|
||||||
|
at io.agroal.pool.ConnectionPool$CreateConnectionTask.call(ConnectionPool.java:645)
|
||||||
|
Caused by: java.net.ConnectException: Connection refused
|
||||||
|
at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:219)
|
||||||
|
at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:365)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Connection refused` 와 `agroal`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `agroal` 은 Quarkus 의 커넥션 풀이다. **풀이 새
|
||||||
|
커넥션을 만들지 못한다.** 이 줄이 없으면 Keycloak 은 아직 옛 커넥션으로 버티고
|
||||||
|
있거나, 애초에 DB 가 안 죽은 것이다.
|
||||||
|
|
||||||
|
> `Connection refused` 이지 `timed out` 이 아니다. Service 는 남아 있지만 뒤에
|
||||||
|
> 파드가 없어 **연결이 즉시 거부**된다. 네트워크를 막았다면 timeout 이 나왔을
|
||||||
|
> 것이고 증상이 훨씬 느리게 나타난다 — 그건 다른 실험이다.
|
||||||
|
|
||||||
|
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
|
||||||
|
| grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
keycloak-0 false 0
|
||||||
|
keycloak-1 false 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `READY` 가 `false` 인데 **`RESTARTS` 가 여전히 `0`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 파드는 **죽지 않았다.** 트래픽에서 빠졌을 뿐이다.
|
||||||
|
`RESTARTS` 가 오르고 있으면 liveness 가 실패하는 것이고, 그 상태에서 무엇을
|
||||||
|
재든 「DB 없는 Keycloak」이 아니라 「재시작 중인 Keycloak」을 재는 것이다.
|
||||||
|
|
||||||
|
**이 `restarts=0` 이 8절의 결론(자동 회복)을 가능하게 하는 조건이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 네 경로
|
||||||
|
|
||||||
|
**하기** — 탐침 파드 안에서. 1-6 과 **똑같은 명령**을 다시 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -it a2-probe -- sh
|
||||||
|
```
|
||||||
|
```sh
|
||||||
|
TOK=/realms/master/protocol/openid-connect/token
|
||||||
|
curl -s -o /dev/null -w '① %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT0"
|
||||||
|
curl -s -o /dev/null -w '② %{http_code}\n' --max-time 10 -X POST "http://$K1:8080$TOK" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT1"
|
||||||
|
curl -s -o /dev/null -w '③ %{http_code}\n' --max-time 10 -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||||
|
curl -s -o /dev/null -w '④ %{http_code}\n' --max-time 10 -H "Authorization: Bearer $AT" \
|
||||||
|
"http://$K0:8080/admin/realms/master/clients/$CID/user-sessions?max=100"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 파드 셸에서 나갔다 들어오면 `RT0` `RT1` `AT` `CID` 가 사라진다. **셸을
|
||||||
|
> 붙잡고 있는 편이 낫다.** 그래서 터미널 두 개를 열라고 한 것이다.
|
||||||
|
|
||||||
|
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt) ·
|
||||||
|
④ 는 [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
① 캐시를 가진 노드(keycloak-0)에서 refresh HTTP 500
|
||||||
|
② 캐시가 없는 노드(keycloak-1)에서 refresh HTTP 500
|
||||||
|
③ 새 로그인 HTTP 500
|
||||||
|
④ 관리 API (세션 조회 필요) HTTP 500
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 본문도 한 번 그대로 본다
|
||||||
|
```sh
|
||||||
|
curl -s -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
{"error":"unknown_error","error_description":"For more on this error consult the server log."}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 네 줄 전부 `500`. 그리고 본문이 **아무것도 말해 주지
|
||||||
|
않는다.** 원인은 3-2 의 서버 로그에만 있다.
|
||||||
|
|
||||||
|
### ★ ④ 의 첫 측정은 오염됐다 — 이 함정에 걸리지 않는다
|
||||||
|
|
||||||
|
원래 실행의 증거 파일에는 이렇게 남아 있다.
|
||||||
|
|
||||||
|
**실측** — [`03-four-paths.txt`](../../evidence/a2-database-loss/03-four-paths.txt)
|
||||||
|
```
|
||||||
|
④ 이미 발급된 access token 으로 관리 API HTTP 000000{"error":"HTTP 401 Unauthorized"}401
|
||||||
|
```
|
||||||
|
|
||||||
|
**읽어 보면 세 가지가 한 줄에 뭉쳐 있다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP 000000{"error":"HTTP 401 Unauthorized"}401
|
||||||
|
─┬──── ──────────┬─────────────────── ─┬─
|
||||||
|
│ │ └─ 마지막 시도의 상태코드
|
||||||
|
│ └─ 응답 본문이 그대로 섞였다
|
||||||
|
└─ 재시도가 세 번 "000" 을 찍었다 (연결 실패)
|
||||||
|
```
|
||||||
|
|
||||||
|
`curl -w '%{http_code}'` 를 쓰면서 **`-o /dev/null` 을 빼면** 본문이 표준출력으로
|
||||||
|
같이 나온다. 여기에 `--retry` 까지 걸려 있어 실패한 시도의 `000` 이 앞에 쌓였다.
|
||||||
|
|
||||||
|
> **위 표의 ④ `500` 은 5절에서 다시 잰 값이다.** 첫 측정은 그대로 쓰지 않았다.
|
||||||
|
> 오염된 측정은 **버리고 다시 잰다.** 「`401` 인가 `500` 인가」를 추측으로
|
||||||
|
> 메우면 안 된다.
|
||||||
|
|
||||||
|
**당신은 1-6 부터 `-o /dev/null` 을 쓰고 있으므로 이 함정을 지난다.**
|
||||||
|
|
||||||
|
## 4-2. ① 이 `500` 인 것이 이 실험의 핵심이다
|
||||||
|
|
||||||
|
**캐시에 세션을 들고 있어도 refresh 는 실패한다.**
|
||||||
|
|
||||||
|
A-1 에서는 로그아웃되어 DB 행이 사라진 세션에 대해 캐시를 가진 노드가 `200` 을
|
||||||
|
줬다. **왜 여기서는 안 되는가.**
|
||||||
|
|
||||||
|
```
|
||||||
|
refresh 처리
|
||||||
|
├── 세션이 존재하는가 → 캐시로 답할 수 있다
|
||||||
|
└── LAST_SESSION_REFRESH 갱신 → DB 쓰기가 필요하다 ← 여기서 죽는다
|
||||||
|
```
|
||||||
|
|
||||||
|
[A-0](a0-session-replication.md) 에서 잡은 SQL 그대로다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1, VERSION=$2 where ...
|
||||||
|
```
|
||||||
|
|
||||||
|
> **캐시는 읽기를 대신할 뿐, 쓰기를 대신하지 못한다.**
|
||||||
|
> **refresh 는 이름과 달리 쓰기 연산이다.**
|
||||||
|
|
||||||
|
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|
||||||
|
|---|---|---|
|
||||||
|
| DB | 살아 있다 | **없다** |
|
||||||
|
| 캐시가 답할 수 있는 부분 | 세션 존재 확인 → `200` | 세션 존재 확인 → 거기까지 |
|
||||||
|
| DB 가 필요한 부분 | `UPDATE` 는 성공 | **`UPDATE` 실패 → `500`** |
|
||||||
|
|
||||||
|
## 4-3. 살아남은 것 — 상태가 필요 없는 경로
|
||||||
|
|
||||||
|
**하기** — 파드 안에서, 1-6 의 ⑤ 를 그대로
|
||||||
|
```sh
|
||||||
|
curl -s -o /dev/null -w 'JWKS %{http_code}\n' \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/certs"
|
||||||
|
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||||
|
"http://$K0:8080/realms/master/.well-known/openid-configuration"
|
||||||
|
```
|
||||||
|
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
=== ④ 다시 — 서명 검증만 필요한 경로는 살아 있는가 ===
|
||||||
|
JWKS 엔드포인트(realm 공개키) HTTP 200
|
||||||
|
realm 메타데이터(.well-known) HTTP 200
|
||||||
|
관리 API(세션 조회 필요) HTTP 500
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 같은 파드, 같은 포트인데 **경로에 따라 `200` 과 `500`
|
||||||
|
이 갈린다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **realm 공개키와 메타데이터는 메모리에 있으므로
|
||||||
|
DB 없이도 응답한다.** 이론적으로는 **이미 JWKS 를 캐시한 리소스 서버는 토큰
|
||||||
|
검증을 계속할 수 있다**는 뜻이다.
|
||||||
|
|
||||||
|
> 다만 이 실험대에는 독립 리소스 서버가 아직 없으므로 **여기까지가 말할 수
|
||||||
|
> 있는 범위**다. B층에서 확인한다.
|
||||||
|
>
|
||||||
|
> **그리고 정문으로는 이것도 못 쓴다.** 다음 절 때문이다.
|
||||||
|
|
||||||
|
## 4-4. 전면 장애 — 살아남는 노드가 없다
|
||||||
|
|
||||||
|
**확인** — Service 가 어느 파드를 잡고 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
```
|
||||||
|
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
=== Service 엔드포인트 ===
|
||||||
|
ready : [] ← 비었다
|
||||||
|
notReady: [10.42.0.35 10.42.1.67]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`ready` 가 빈 목록.** 두 IP 가 전부 `notReady` 다.
|
||||||
|
|
||||||
|
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||||
|
> 뜬다. 원래 실행 기록에도 그 경고가 두 줄 남아 있다.
|
||||||
|
>
|
||||||
|
> **실측**
|
||||||
|
> ```
|
||||||
|
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||||
|
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||||
|
> ```
|
||||||
|
> 사람이 눈으로 볼 때는 이쪽이 더 짧다.
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**확인** — 밖에서
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== 외부 진입점 ===
|
||||||
|
https://auth.hyeonworks.com/realms/master HTTP 503
|
||||||
|
```
|
||||||
|
|
||||||
|
한 번 눈으로 볼 때는 헤더까지 본다.
|
||||||
|
```bash
|
||||||
|
curl -I https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`503` 은 Keycloak 이 준 것이 아니다.** Ready 인
|
||||||
|
백엔드가 하나도 없어서 그 앞의 프록시가 준 것이다. 4-3 에서 `200` 이던 JWKS 도
|
||||||
|
정문으로는 닿지 않는다 — **readiness 게이트가 문을 닫았다.**
|
||||||
|
|
||||||
|
### A-1 과의 대비가 이 실험의 결론이다
|
||||||
|
|
||||||
|
| | A-1 (7800 차단) | **A-2 (DB 정지)** |
|
||||||
|
|---|---|---|
|
||||||
|
| Ready 인 파드 | `keycloak-1` **1개 생존** | **0개** |
|
||||||
|
| Service `ready` | `[10.42.0.35]` | **`[]`** |
|
||||||
|
| 외부 응답 | **200** | **503** |
|
||||||
|
| 성격 | 용량 저하 | **전면 장애** |
|
||||||
|
|
||||||
|
**노드를 몇 대로 늘려도 DB 가 죽으면 전부 같이 죽는다.**
|
||||||
|
**Keycloak 의 대수는 DB 장애에 아무 도움이 되지 않는다.**
|
||||||
|
|
||||||
|
> 「Redis 또는 DB 가 죽으면 어떻게 복구하는가」에 대한 첫 번째 답 —
|
||||||
|
> **복구 이전에, DB 이중화가 Keycloak 대수보다 우선한다.**
|
||||||
|
|
||||||
|
## 4-5. 헬스 본문이 이유를 말한다
|
||||||
|
|
||||||
|
**확인** — Keycloak 이미지에는 `curl` 이 없으므로 파드 밖에서 묻는다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a2-probe -- \
|
||||||
|
curl -s "http://$K0:9000/health/ready"
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** — 한 줄 JSON 이 나온다. 한 번은 그대로 본다.
|
||||||
|
|
||||||
|
**실측** — [`04-health-and-service.txt`](../../evidence/a2-database-loss/04-health-and-service.txt)
|
||||||
|
```
|
||||||
|
=== health/ready 상세 ===
|
||||||
|
전체: DOWN
|
||||||
|
Graceful Shutdown UP
|
||||||
|
Keycloak cluster health check UP
|
||||||
|
Keycloak database connections async health check DOWN
|
||||||
|
Keycloak Initialized UP
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **네 항목 중 하나만 DOWN 인데 전체가 DOWN 이다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **헬스체크는 모든 항목이 UP 이어야 UP 이다.**
|
||||||
|
그리고 **`cluster health` 는 UP** 이다 — 클러스터는 멀쩡하다. A-1 에서는 정확히
|
||||||
|
반대였다(cluster DOWN, database UP). **같은 `503` 이라도 어느 체크가 DOWN 인지가
|
||||||
|
장애를 구별한다.**
|
||||||
|
|
||||||
|
**확인** — `describe` 로도 같은 것이 보인다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab describe pod keycloak-0 | grep -A6 Conditions
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4-6. 관측의 함정 — `up = 1` 인 채로 전면 장애
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=up' \
|
||||||
|
| tr ',' '\n' | grep -E '"job":|"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
|
||||||
|
```
|
||||||
|
=== ★ up 지표는 무엇을 말하는가 (프로세스는 살아 있다) ===
|
||||||
|
up{pod=keycloak-1} = 1 ← 1 인데 서비스는 503 이다
|
||||||
|
up{pod=keycloak-0} = 1 ← 1 인데 서비스는 503 이다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 둘 다 `1`. **서비스는 `503` 인데.**
|
||||||
|
|
||||||
|
Grafana Explore 에서 `up{job="keycloak"}` 을 그려 보면 **전 구간 평평하다.**
|
||||||
|
원래 실행의 그림이
|
||||||
|
[`a2-up-stayed-1-during-outage.png`](../../evidence/a2-database-loss/a2-up-stayed-1-during-outage.png)
|
||||||
|
이고, 11:44 의 짧은 골은 A-1 에서 파드를 교체한 자국이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 `/metrics` 를 긁는 데
|
||||||
|
성공했는가**만 말한다. 프로세스는 멀쩡히 살아 메트릭을 내놓고 있었다.
|
||||||
|
**기능은 전멸했는데.**
|
||||||
|
|
||||||
|
| 지표 | 이 장애에서 |
|
||||||
|
|---|---|
|
||||||
|
| `up` | **1 — 아무것도 알려주지 않는다** |
|
||||||
|
| 파드 `Ready` | **false — 여기서 드러난다** |
|
||||||
|
| 외부 HTTP 코드 | **503 — 사용자가 겪는 것** |
|
||||||
|
|
||||||
|
> **A-0 에서는 `up` 을 「가장 중요한 합성 지표」라고 썼다. 절반만 맞다.**
|
||||||
|
> `up` 은 **대상이 사라진 것**을 잡지만 **대상이 살아서 못 쓰는 것**은 못 잡는다.
|
||||||
|
> 후자가 운영에서 훨씬 흔하다.
|
||||||
|
>
|
||||||
|
> **알림은 `up` 이 아니라 readiness 와 외부 응답 코드에 걸어야 한다.**
|
||||||
|
|
||||||
|
**확인** — 그럼 readiness 를 지표로 볼 수 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=kube_pod_status_ready' \
|
||||||
|
| head -c 300; echo
|
||||||
|
```
|
||||||
|
**형태** — 결과가 비어 있다
|
||||||
|
```json
|
||||||
|
{"status":"success","data":{"resultType":"vector","result":[]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 실험대에는 아직 `kube-state-metrics` 가 없어
|
||||||
|
**파드 readiness 가 지표로 남지 않는다.** 즉 지금 이 장애는 **Prometheus 만
|
||||||
|
보고 있으면 알 수 없다.** **관측 스택에 빠진 것을 이 실험이 찾아냈다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 재기동'
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a2-database-loss/05-recovery.txt)
|
||||||
|
```
|
||||||
|
=== 복구 — PostgreSQL 재기동 ===
|
||||||
|
재기동 시각: 11:57:09
|
||||||
|
deployment.apps/postgres scaled
|
||||||
|
Waiting for deployment "postgres" rollout to finish: 0 out of 1 new replicas have been updated...
|
||||||
|
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
|
||||||
|
deployment "postgres" successfully rolled out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2. Keycloak 이 스스로 회복하는가 — 손대지 않고 본다
|
||||||
|
|
||||||
|
**★ 여기서 Keycloak 을 재시작하고 싶어진다. 참는다.** 재시작하면 이 실험이
|
||||||
|
답하려던 질문(「사람 개입이 필요한가」)이 사라진다.
|
||||||
|
|
||||||
|
**확인** — 15초 간격으로 몇 번 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount \
|
||||||
|
| grep keycloak
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== Keycloak 이 스스로 회복하는가 (재시작 없이) ===
|
||||||
|
+15초 keycloak-0 true keycloak-1 true | 외부 HTTP 200
|
||||||
|
→ 서비스 복귀
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `READY` 가 둘 다 `true`, 정문이 `200`.
|
||||||
|
|
||||||
|
## 5-3. 재시작 없이 회복한 것이 맞나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== 재시작 횟수 — 파드가 죽었다 살아난 것인가, 그대로 회복한 것인가 ===
|
||||||
|
keycloak-0 0
|
||||||
|
keycloak-1 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`0`.** 3-3 에서 본 값 그대로다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **커넥션 풀이 스스로 재연결하고 readiness 가 다시
|
||||||
|
UP 이 되면서 Service 에 복귀했다.** 사람이 한 일은 DB 를 켠 것뿐이다.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 회복 시간 | **약 15초** (DB Ready 이후) |
|
||||||
|
| 사람 개입 | **없음** |
|
||||||
|
| Keycloak 재시작 | **불필요** — `restarts=0` |
|
||||||
|
|
||||||
|
### 개념 — readiness 와 liveness 를 가르는 기준
|
||||||
|
|
||||||
|
| | 실패하면 | 언제 쓰나 |
|
||||||
|
|---|---|---|
|
||||||
|
| **liveness** | **재시작** | 재시작하면 나아지는 문제 (교착, 메모리 누수) |
|
||||||
|
| **readiness** | **트래픽에서 격리** | 재시작해도 안 나아지는 문제 (**의존 대상이 죽음**) |
|
||||||
|
|
||||||
|
**DB 장애에 liveness 를 걸면 재앙이다.** 모든 파드가 무한 재시작하고, DB 가
|
||||||
|
돌아와도 CrashLoopBackOff 의 백오프 때문에 회복이 늦어진다. 게다가 재시작하면
|
||||||
|
**캐시까지 날아간다.**
|
||||||
|
|
||||||
|
> A-1 에서도 같은 결론이 나왔다. 분단된 노드가 **readiness 로** 빠졌기 때문에
|
||||||
|
> 재시작 없이 격리만 되었다. **Keycloak 은 두 종류의 장애를 다 readiness 로
|
||||||
|
> 신고한다.**
|
||||||
|
|
||||||
|
## 5-4. 세션이 살아남았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== 정지 전 세션이 살아남았는가 ===
|
||||||
|
online 세션 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 1-5 에서 적어 둔 값보다 크거나 같다. 실험 중에 로그인을
|
||||||
|
여러 번 했으므로 늘어나 있다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **세션은 DB 에 있으므로 DB 가 돌아오면 같이
|
||||||
|
돌아온다.** 정상 종료였기 때문에 하나도 잃지 않았다.
|
||||||
|
|
||||||
|
> **강제로 죽였다면 어떨까.** `SET LOCAL synchronous_commit TO OFF` 때문에
|
||||||
|
> 마지막 수백 밀리초의 쓰기가 사라져야 한다. **[A-3](a3-database-crash.md) 이
|
||||||
|
> 그 숫자를 잰다.**
|
||||||
|
|
||||||
|
## 5-5. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| DB | `sudo kubectl -n keycloak-lab get deploy postgres` | `1/1` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||||
|
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 헬스 | `exec a2-probe -- curl -s "http://$K0:9000/health/ready"` | 전체 `UP` |
|
||||||
|
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a2-probe` | 지웠으면 `NotFound` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
**하기** — 탐침 파드를 지운다. `--rm` 이 없으므로 **직접 지워야 한다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a2-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
`sleep 7200` 이 끝나면 파드는 `Completed` 로 남는다. **자동으로 사라지지
|
||||||
|
않는다.** 다음 실험에서 `a2-probe` 이름이 이미 있다고 거절당하는 원인이 이것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 출력이 `HTTP 000000{...}401` 처럼 뭉쳐 나온다 | **`-o /dev/null` 을 뺐다.** 본문과 코드가 섞였다 | 4-1 의 ★ 절. 오염된 측정은 버리고 다시 잰다 |
|
||||||
|
| `000` 이 앞에 붙어 나온다 | `--retry` 가 걸려 실패 시도의 코드까지 찍었다 | 재시도를 빼고 `--max-time` 만 쓴다 |
|
||||||
|
| DB 를 내렸는데 몇 초 만에 돌아온다 | **`delete pod` 를 썼다.** Deployment 가 새로 만든다 | `scale --replicas=0` — 2-1 |
|
||||||
|
| ④ 가 `401` 이다 | **access token 이 만료됐다** (수명 60초) | 토큰 발급 → 정지 → 시험을 60초 안에 — 2-2 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 치거나 Prometheus 에 묻는다 |
|
||||||
|
| 파드 셸에 다시 들어갔더니 변수가 없다 | `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 — 4-1 |
|
||||||
|
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` — 4-4 |
|
||||||
|
| `up` 이 1 이라 정상인 줄 알았다 | **`up` 은 스크레이프 성공만 말한다** | readiness 와 외부 코드를 본다 — 4-6 |
|
||||||
|
| `kube_pod_status_ready` 결과가 비었다 | **`kube-state-metrics` 가 이 실험대에 없다** | 보완 항목이다. 지금은 `kubectl` 로 본다 — 4-6 |
|
||||||
|
| 복구했는데 계속 `503` | Keycloak 이 아직 재연결 중이다 | 15~30초 더 기다린다. **재시작하지 않는다** — 5-2 |
|
||||||
|
| `a2-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a2-probe --ignore-not-found` — 5-5 |
|
||||||
|
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a2-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 왜 이 가이드는 임시 파드를 안 쓰나
|
||||||
|
|
||||||
|
A-1 은 관찰을 `kubectl run --rm` 임시 파드로 했고, **그 계측이 실패했다.**
|
||||||
|
매번 파드를 만들고 지우므로 느리고, 경합이 있고, 빈 출력이 섞였다.
|
||||||
|
|
||||||
|
이 실험은 거기에 더해 **토큰을 단계 사이로 넘겨야 한다.** 임시 파드로는 불가능
|
||||||
|
하다 — 파드가 사라지면 변수도 사라진다.
|
||||||
|
|
||||||
|
```
|
||||||
|
임시 파드 단계마다 새로 뜬다 → 토큰이 안 넘어간다 · 느리다 · 빈 출력
|
||||||
|
상주 파드 한 번 띄워 둔다 → exec 로 이어간다 · 파일에 남길 수 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
**대신 지우는 것을 잊으면 안 된다.** `--rm` 이 없다는 것은 그런 뜻이다.
|
||||||
|
|
||||||
|
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
|
||||||
|
> 그러라고 있는 것이고, 사람이 손으로 묻는 것은 상주 파드가 낫다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-2 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [A-3](a3-database-crash.md) DB 강제 종료 | **정상 정지는 하나도 안 잃었다. 강제 종료는?** `synchronous_commit OFF` 의 대가 |
|
||||||
|
| A-4 노드 상실 | `postgres` 가 `kc-lab-2` 에 있다 — **그 노드를 죽이면 A-2 가 함께 일어난다** |
|
||||||
|
| D-1 백업·복구 | 여기서는 DB 가 되살아났다. **데이터가 사라졌다면?** |
|
||||||
|
| 관측 스택 | **`kube-state-metrics` 가 없어 파드 readiness 가 지표로 안 남는다** — 보완 필요 |
|
||||||
|
| 전부 | **알림을 `up` 에 걸지 않는다.** readiness 와 외부 응답 코드에 건다 |
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
# A-3 재현 가이드 — DB 를 진짜로 죽여서 몇 건이 사라지는지 센다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a3-database-crash.md`](../../experiment-a3-database-crash.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a3-database-crash/`](../../evidence/a3-database-crash/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 로그인 트랜잭션에 붙은 `SET LOCAL synchronous_commit TO OFF` | PostgreSQL 문장 로그 |
|
||||||
|
| `--grace-period=0 --force` 가 **크래시가 아니라는 것** | crash recovery 가 없는 재기동 로그 |
|
||||||
|
| 컨테이너 안에서 **PID 1 이 SIGKILL 을 무시하는 것** | 파드 재시작 0, 로그 시각 그대로 |
|
||||||
|
| `not properly shut down` / `redo starts` / `redo done` | 같은 로그 |
|
||||||
|
| **`200` 과 토큰을 받았는데 DB 에 없는 sid** | `comm` 으로 뽑은 차집합 |
|
||||||
|
| `wal_writer_delay = 200ms` 가 기본값이라는 것 | `pg_settings` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`A-0`](a0-session-replication.md) 과 [`A-2`](a2-database-loss.md) 를 먼저 한다.
|
||||||
|
A-0 이 `SET LOCAL synchronous_commit TO OFF` 를 발견했고, 이 실험은 **그
|
||||||
|
대가가 몇 건인지**를 잰다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 네임스페이스는 `keycloak-lab`.
|
||||||
|
- 터미널 **두 개가 반드시 필요하다.** 하나는 로그인 루프를 돌리고(붙잡고 있어야
|
||||||
|
한다), 하나는 그 사이에 DB 를 죽인다.
|
||||||
|
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
## 주의 — 이건 데이터를 잃는 실험이다
|
||||||
|
|
||||||
|
**PostgreSQL 을 강제로 죽이고, 세션 테이블을 두 번 비운다.** 실제로 커밋됐다고
|
||||||
|
응답한 데이터가 사라진다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는
|
||||||
|
방법은 매 단계에 적어 두었다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 11:58–12:05 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
sid 와 건수는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
|
||||||
|
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행
|
||||||
|
기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
[A-2](a2-database-loss.md) 는 DB 를 **정상 종료**시켰다. 세션은 하나도 안
|
||||||
|
없어졌다. 당연하다 — PostgreSQL 은 SIGTERM 을 받으면 WAL 을 플러시하고 내려간다.
|
||||||
|
|
||||||
|
**그런데 [A-0](a0-session-replication.md) 에서 이 한 줄을 잡았다.**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SET LOCAL synchronous_commit TO OFF
|
||||||
|
```
|
||||||
|
|
||||||
|
`COMMIT` 직전, **같은 트랜잭션 안에서** 나온다. 뜻은 이렇다.
|
||||||
|
|
||||||
|
```
|
||||||
|
COMMIT
|
||||||
|
│
|
||||||
|
├─ WAL 버퍼(메모리)에 기록 ← 항상 한다
|
||||||
|
│
|
||||||
|
├─ synchronous_commit = on : 디스크 플러시를 기다렸다가 응답
|
||||||
|
└─ synchronous_commit = off : 기다리지 않고 즉시 응답 ← Keycloak
|
||||||
|
│
|
||||||
|
└─ 크래시 시 이 구간이 사라진다
|
||||||
|
```
|
||||||
|
|
||||||
|
**「사라질 수 있다」와 「몇 건 사라졌다」는 다르다.** 이 실험은 뒤쪽이다.
|
||||||
|
RPO(Recovery Point Objective)를 숫자로 만든다.
|
||||||
|
|
||||||
|
**그리고 이 실험의 절반은 「죽이는 데 실패하는 이야기」다.** 세 번 시도해서
|
||||||
|
세 번째에 성공했고, 앞의 둘은 **「손실 0건」으로 보였지만 실제로는 죽인 적이
|
||||||
|
없었다.** A-1 이 남긴 교훈이 그대로 나온다 — **주입이 실제로 걸렸는지 먼저
|
||||||
|
확인하지 않으면 「아무 일도 없었다」를 결과로 착각한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 설계 확인 — 재기 전에 세 가지를 확인한다
|
||||||
|
|
||||||
|
**측정 설계가 성립하는지부터 본다.** 여기서 하나라도 어긋나면 뒤의 숫자는
|
||||||
|
아무 의미가 없다.
|
||||||
|
|
||||||
|
## 1-1. 눈금이 맞는가 — `LAST_SESSION_REFRESH` 로는 못 잰다
|
||||||
|
|
||||||
|
처음 계획은 「세션 갱신 시각이 되감기는지」 보는 것이었다. 스키마를 보고 접었다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "\d offline_user_session"
|
||||||
|
```
|
||||||
|
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||||
|
```
|
||||||
|
LAST_SESSION_REFRESH 는 integer(초) — 200ms 손실은 보이지 않는다
|
||||||
|
created_on | integer | | not null |
|
||||||
|
last_session_refresh | integer | | not null | 0
|
||||||
|
"idx_user_session_expiration_created" btree (realm_id, offline_flag, remember_me, created_on, user_session_id, user_id)
|
||||||
|
"idx_user_session_expiration_last_refresh" btree (realm_id, offline_flag, remember_me, last_session_refresh, user_session_id, user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 시각 컬럼의 타입이 `integer` 다. **초 단위.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **손실 창은 수백 밀리초인데 눈금이 1초다.**
|
||||||
|
보일 리가 없다. 이 설계는 버린다.
|
||||||
|
|
||||||
|
### 대신 행 존재 여부로 잰다 — 이진 판정
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 1회 = OFFLINE_USER_SESSION 행 1개
|
||||||
|
클라이언트가 sid 를 받았다 = 서버가 COMMIT 했다고 응답했다
|
||||||
|
크래시 후 그 sid 가 없다 = 잃은 것
|
||||||
|
```
|
||||||
|
|
||||||
|
**있거나 없거나**이므로 눈금 문제가 없다. **이 실험이 로그인 수백 건을 도는
|
||||||
|
이유가 이것이다** — 이진 판정을 여러 번 해서 비율로 만든다.
|
||||||
|
|
||||||
|
## 1-2. 로그인도 비동기 커밋인가 — **아니면 설계가 무너진다**
|
||||||
|
|
||||||
|
A-0 에서 잡은 것은 **refresh** 트랜잭션이었다. **로그인(INSERT)도 그런지는
|
||||||
|
확인하지 않았다.** 아니라면 로그인은 안 사라지고, 이 측정 설계 자체가 성립하지
|
||||||
|
않는다.
|
||||||
|
|
||||||
|
### 켠다 — 첫 번째 주입
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 실제로 켜졌나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
log_statement
|
||||||
|
---------------
|
||||||
|
all
|
||||||
|
```
|
||||||
|
|
||||||
|
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. `alter system` 은
|
||||||
|
`postgresql.auto.conf` 에 쓸 뿐이고 **reload 를 해야 적용된다.**
|
||||||
|
|
||||||
|
### 로그인 한 번을 보낸다
|
||||||
|
|
||||||
|
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
sudo kubectl -n keycloak-lab run a3-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="K0=$K0" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a3-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||||
|
> 셸 히스토리에도 남지 않는다. 존재와 길이만 확인하고 싶으면:
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||||
|
> ```
|
||||||
|
> **실측** — `19`
|
||||||
|
>
|
||||||
|
> **★ 명령줄에 비밀번호를 직접 쓰지 않는다.** 원래 실험의 재현 절차에는
|
||||||
|
> 평문 비밀번호가 그대로 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도
|
||||||
|
> 남는다.** `--env` 로 넘긴 값은 그 파드 안에서만 산다.
|
||||||
|
|
||||||
|
**확인** — 환경변수가 들어갔나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
K0=10.42.1.67 PW길이=19
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 로그인 한 번
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그에서 로그인 트랜잭션을 찾는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
|
||||||
|
| grep -E 'BEGIN|insert into OFFLINE|synchronous_commit|COMMIT' | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
|
||||||
|
```
|
||||||
|
=== [설계 확인] 로그인 트랜잭션도 synchronous_commit 을 끄는가 ===
|
||||||
|
--- 로그인 트랜잭션 (INSERT 가 있는 것) ---
|
||||||
|
2:BEGIN
|
||||||
|
5:COMMIT
|
||||||
|
6:BEGIN
|
||||||
|
9:insert into OFFLINE_USER_SESSION (BROKER_SESSION_ID,CREATED_ON,DATA,LAST_SESSION_REFRESH,REALM_ID,REMEMBER_ME,USER_ID,VERSION,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
10:insert into OFFLINE_CLIENT_SESSION (DATA,REALM_ID,TIMESTAMP,VERSION,CLIENT_ID,CLIENT_STORAGE_PROVIDER,EXTERNAL_CLIENT_ID,OFFLINE_FLAG,USER_SESSION_ID) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
11:SET LOCAL synchronous_commit TO OFF
|
||||||
|
12:COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `BEGIN` 과 `COMMIT` 사이에 **`insert into
|
||||||
|
OFFLINE_USER_SESSION` 과 `SET LOCAL synchronous_commit TO OFF` 가 같이 들어
|
||||||
|
있는 것.** 앞의 `BEGIN`/`COMMIT`(2·5줄)은 다른 트랜잭션이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **확인됐고, 함의가 refresh 보다 훨씬 무겁다.**
|
||||||
|
|
||||||
|
| | 잃으면 |
|
||||||
|
|---|---|
|
||||||
|
| refresh 갱신 시각 | 세션 수명이 조금 짧아진다. **사용자는 모른다** |
|
||||||
|
| **로그인 자체** | **토큰은 손에 있는데 세션이 없다.** 다음 요청부터 실패 |
|
||||||
|
|
||||||
|
### ★ 곧바로 끈다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
log_statement
|
||||||
|
---------------
|
||||||
|
none
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 켜 둔 채로 3절에 들어가면 안 된다.** 3절은 수백 건의 로그인을 최대한 빨리
|
||||||
|
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
|
||||||
|
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
|
||||||
|
|
||||||
|
## 1-3. WAL 설정을 지금 재 둔다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select name, setting, unit, source from pg_settings
|
||||||
|
where name in ('commit_delay','synchronous_commit','wal_writer_delay','wal_writer_flush_after')"
|
||||||
|
```
|
||||||
|
**실측** — [`08-wal-settings.txt`](../../evidence/a3-database-crash/08-wal-settings.txt)
|
||||||
|
```
|
||||||
|
=== A-3 이 가정만 하고 재지 않은 값 ===
|
||||||
|
name | setting | unit | source
|
||||||
|
------------------------+---------+------+---------
|
||||||
|
commit_delay | 0 | | default
|
||||||
|
synchronous_commit | on | | default
|
||||||
|
wal_writer_delay | 200 | ms | default
|
||||||
|
wal_writer_flush_after | 128 | 8kB | default
|
||||||
|
(4 rows)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `source` 열이 전부 `default` 다. 아무도 안 건드렸다.
|
||||||
|
그리고 **전역 `synchronous_commit` 은 `on`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **전역 설정만 보면 「우리는 동기 커밋」이라고 믿게
|
||||||
|
된다.** 그런데 1-2 에서 본 대로 **Keycloak 이 자기 트랜잭션에만 `SET LOCAL` 로
|
||||||
|
뒤집는다.** DBA 가 서버 설정만 보고 판단하면 틀린다.
|
||||||
|
|
||||||
|
> **★ 이 값을 지금 재 두는 것이 이 절의 요점이다.** 원래 실험은 결과를 먼저
|
||||||
|
> 쓰고 「`wal_writer_delay` 기본값(200ms)과 맞는다」고 주장했는데, **그 시점에
|
||||||
|
> 이 값을 조회한 적이 없었다.** 나중에 재서 맞기는 했지만 **그때는 추정이었다.**
|
||||||
|
> 해설 문서 5절이 그 정정 기록이다.
|
||||||
|
>
|
||||||
|
> **가정한 값은 재기 전에 재 둔다.** 결과를 본 뒤에 재면 「맞춰 보는」 것이 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 기준선 — 세션 테이블을 비우고 센다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "delete from offline_user_session"
|
||||||
|
```
|
||||||
|
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
|
||||||
|
```
|
||||||
|
=== [정리] 세션 테이블 비우고 루프 잔여 확인 ===
|
||||||
|
DELETE 375
|
||||||
|
남은 세션: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
count
|
||||||
|
-------
|
||||||
|
0
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 비우나** — 크래시 뒤에 「DB 전체 세션 수」와 「내가 만든 세션 수」를 나란히
|
||||||
|
놓고 볼 것이다. 시작이 0 이어야 그 둘이 읽힌다.
|
||||||
|
|
||||||
|
> **캐시는 안 비워도 된다.** 이 실험의 판정은 **DB 행의 존재 여부**이고, 캐시는
|
||||||
|
> 판정에 안 들어간다. A-0 처럼 파드를 재시작할 필요가 없다.
|
||||||
|
|
||||||
|
**확인** — 파드가 정상인지도 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
`keycloak-0` `keycloak-1` `postgres` 가 전부 `1/1 Running` 이고 `RESTARTS` 가
|
||||||
|
`0` 이어야 한다. **`RESTARTS` 값을 적어 둔다** — 3절에서 이 값이 오르는지가
|
||||||
|
주입 판정의 일부다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입 — 세 번 시도한다. 앞의 둘은 실패한다
|
||||||
|
|
||||||
|
**이 절이 이 실험의 본체다.** 순서대로 따라가면 「죽이는 데 실패하는 두 가지
|
||||||
|
방법」을 직접 보게 된다. 건너뛰고 3-6 만 하면 **왜 그게 유일한 방법인지** 모른다.
|
||||||
|
|
||||||
|
## 3-1. 로그인 루프 — 스크립트 파일로 만든다
|
||||||
|
|
||||||
|
루프는 한 줄로 칠 물건이 아니다. **파일로 만든다.**
|
||||||
|
|
||||||
|
### 왜 파일인가
|
||||||
|
|
||||||
|
원래 실행은 이걸 `kubectl exec ... sh -c "..."` 한 줄에 욱여넣었고, **인용이
|
||||||
|
세 겹이 되어 두 번 깨졌다.**
|
||||||
|
|
||||||
|
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||||
|
```
|
||||||
|
=== [1] 빠른 연속 로그인을 백그라운드로 시작 ===
|
||||||
|
루프 시작
|
||||||
|
6초 경과 — 지금까지 성공한 로그인: 0
|
||||||
|
...
|
||||||
|
클라이언트가 200 을 받은 로그인 수: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**0건.** 파드 안에서 `( ... ) &` 로 띄운 루프가 **`exec` 세션이 끝날 때 같이
|
||||||
|
죽었다.** 측정 자체가 없었던 것이다.
|
||||||
|
|
||||||
|
**편집기로 파일을 연다.**
|
||||||
|
```bash
|
||||||
|
vim /tmp/a3-login-loop.sh
|
||||||
|
```
|
||||||
|
```sh
|
||||||
|
# file: /tmp/a3-login-loop.sh — 탐침 파드 안에서 돈다
|
||||||
|
#!/bin/sh
|
||||||
|
# K0 · PW 는 파드 환경변수에서 온다. 여기에 비밀번호를 적지 않는다.
|
||||||
|
TOK=/realms/master/protocol/openid-connect/token
|
||||||
|
: > /tmp/sids
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 400 ]; do
|
||||||
|
AT=$(curl -s --max-time 5 -X POST "http://$K0:8080$TOK" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||||
|
if [ -n "$AT" ]; then
|
||||||
|
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
|
||||||
|
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p' >> /tmp/sids
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
echo "루프 종료: $(wc -l < /tmp/sids) 건"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `/tmp/sids` 에 **클라이언트가 `200` 과 토큰을 실제로
|
||||||
|
받은 것만** 쌓인다. `AT` 가 비면 아무것도 안 적는다. **이 파일이 「서버가
|
||||||
|
COMMIT 했다고 응답한 것」의 목록**이고, 그게 이 실험의 시험군이다.
|
||||||
|
|
||||||
|
**하기** — 파드 안으로 넣는다. `tar` 가 필요 없는 방법이다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -i a3-probe -- sh -c 'cat > /tmp/a3-login-loop.sh' \
|
||||||
|
< /tmp/a3-login-loop.sh
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/a3-login-loop.sh
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
17 /tmp/a3-login-loop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> `kubectl cp` 도 되지만 컨테이너에 `tar` 가 있어야 한다. `cat >` 로 밀어 넣는
|
||||||
|
> 쪽이 어디서나 통한다.
|
||||||
|
|
||||||
|
**하기** — **터미널 ①** 에서 **앞으로 두고** 돌린다. 이 터미널은 붙잡힌다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- sh /tmp/a3-login-loop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ `&` 로 배경에 보내지 않는다.** 그게 원래 실행이 실패한 이유다. 터미널을
|
||||||
|
하나 통째로 이 루프에 쓴다. **미검증** — 원래 실행은 호스트에서 배경 `exec`
|
||||||
|
로 했다.
|
||||||
|
|
||||||
|
**확인** — **터미널 ②** 에서, 얼마나 쌓였는지 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
|
||||||
|
```
|
||||||
|
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||||
|
```
|
||||||
|
=== 로그인 루프 시작 ===
|
||||||
|
8초 후: 112 건
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **8초에 112건이면 초당 약 14건.** 이 속도를 적어 둔다.
|
||||||
|
4-4 에서 손실 건수를 시간으로 환산할 때 쓴다.
|
||||||
|
|
||||||
|
**0건이면 루프가 안 도는 것이다.** 터미널 ① 을 본다. 거기 에러가 있다.
|
||||||
|
|
||||||
|
## 3-2. 시도 ① — `--grace-period=0 --force`
|
||||||
|
|
||||||
|
**「강제 삭제」라는 이름이 붙어 있으니 크래시일 것 같다.** 확인해 본다.
|
||||||
|
|
||||||
|
**하기** — 터미널 ② 에서
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S.%3N 종료'
|
||||||
|
sudo kubectl -n keycloak-lab delete pod -l app=postgres --grace-period=0 --force
|
||||||
|
date '+%H:%M:%S.%3N 반환'
|
||||||
|
```
|
||||||
|
**실측** — [`01-crash-injection.txt`](../../evidence/a3-database-crash/01-crash-injection.txt)
|
||||||
|
```
|
||||||
|
=== [2] PostgreSQL 강제 종료 (SIGKILL) ===
|
||||||
|
종료 시각: 12:00:26.511
|
||||||
|
pod "postgres-7b474b88c8-xc2vt" force deleted from keycloak-lab namespace
|
||||||
|
삭제 반환: 12:00:26.586
|
||||||
|
```
|
||||||
|
|
||||||
|
터미널 ① 의 루프가 에러를 쏟기 시작한다. 그대로 두거나 `Ctrl-C` 로 멈춘다.
|
||||||
|
|
||||||
|
## 3-3. 주입 검증 ① — **crash recovery 가 돌았는가**
|
||||||
|
|
||||||
|
**★ 여기가 이 실험 전체에서 가장 중요한 절이다.** 결과를 세기 전에 **주입 성공
|
||||||
|
신호**를 본다. 이 실험은 그 신호를 미리 정해 뒀다.
|
||||||
|
|
||||||
|
```
|
||||||
|
PostgreSQL 이 정상 종료했다 → pg_control 에 "깨끗하게 종료됨" 표시
|
||||||
|
→ 다음 기동에 아무 말 없이 뜬다
|
||||||
|
|
||||||
|
PostgreSQL 이 즉사했다 → 표시가 없다
|
||||||
|
→ "database system was not properly shut down"
|
||||||
|
→ "redo starts at ..." / "redo done at ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — DB 가 다시 뜰 때까지 기다렸다가 로그를 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept'
|
||||||
|
```
|
||||||
|
**실측** — [`02-design-check.txt`](../../evidence/a3-database-crash/02-design-check.txt)
|
||||||
|
```
|
||||||
|
=== crash recovery 가 실행되었는가 (강제 종료의 흔적) ===
|
||||||
|
2026-09-04 02:58:41.036 UTC [1] LOG: database system is ready to accept connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`ready to accept connections` 한 줄뿐이다.**
|
||||||
|
`not properly shut down` 도 `redo` 도 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **crash recovery 가 돌지 않았다 = 깨끗하게 내려갔다.**
|
||||||
|
|
||||||
|
**하기** — 그런데도 손실을 세어 보면 이렇게 나온다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- wc -l /tmp/sids
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**실측** — [`04-comparison.txt`](../../evidence/a3-database-crash/04-comparison.txt)
|
||||||
|
```
|
||||||
|
=== [5] 전체 대조 — 몇 건이나 사라졌는가 ===
|
||||||
|
클라이언트 성공: 291 건
|
||||||
|
DB 에 존재: 291 건
|
||||||
|
★ 유실: 0 건
|
||||||
|
```
|
||||||
|
|
||||||
|
**0건.** 그런데 이건 **「안 잃었다」가 아니라 「죽인 적이 없는 것」이다.**
|
||||||
|
|
||||||
|
### 왜 안 죽었나 — 시그널 세 가지
|
||||||
|
|
||||||
|
| 신호 | PostgreSQL 의 반응 |
|
||||||
|
|---|---|
|
||||||
|
| **SIGTERM** | **fast shutdown** — 진행 중 트랜잭션을 롤백하고 **WAL 을 플러시**한 뒤 종료 |
|
||||||
|
| SIGINT | smart shutdown — 연결이 끊기길 기다린다 |
|
||||||
|
| **SIGKILL** | **즉사** — 플러시 없음. 다음 기동에 crash recovery |
|
||||||
|
|
||||||
|
`--force --grace-period=0` 는 **API 오브젝트를 즉시 지운다.** 그것뿐이다.
|
||||||
|
컨테이너 런타임은 여전히 정상 종료 절차를 밟고, **PostgreSQL 은 SIGTERM 을
|
||||||
|
받고 얌전히 플러시했다.**
|
||||||
|
|
||||||
|
> **운영에 주는 것 — 장애 훈련이 훈련이 안 될 수 있다.**
|
||||||
|
> 「강제 삭제로 DB 를 죽여 봤는데 아무 문제 없었다」는 결론은 **아무것도 죽이지
|
||||||
|
> 않은 것**일 수 있다. 훈련에는 **주입 성공 신호**가 있어야 한다.
|
||||||
|
|
||||||
|
## 3-4. 시도 ② — 컨테이너 안에서 `kill -9 1`
|
||||||
|
|
||||||
|
**postmaster 는 컨테이너의 PID 1 이다.** 직접 SIGKILL 을 보내면 될 것 같다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S.%3N SIGKILL'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- kill -9 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3-5. 주입 검증 ② — **아무 일도 일어나지 않았다**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=postgres
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres | grep -E 'not properly shut down|redo|ready to accept' | tail -3
|
||||||
|
```
|
||||||
|
**실측** — [`05-true-crash.txt`](../../evidence/a3-database-crash/05-true-crash.txt)
|
||||||
|
```
|
||||||
|
=== [재주입] postmaster(PID 1)에 SIGKILL — 진짜 크래시 ===
|
||||||
|
8초 후 성공 로그인: 110 건
|
||||||
|
SIGKILL: 12:03:21.441
|
||||||
|
최종 성공 로그인: 139 건
|
||||||
|
|
||||||
|
=== [검증] 이번엔 crash recovery 가 돌았는가 ===
|
||||||
|
2026-09-04 02:59:48.427 UTC [1] LOG: database system is ready to accept connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 가지를 같이 본다.**
|
||||||
|
|
||||||
|
1. **`RESTARTS` 가 안 올랐다.** 파드는 재시작하지 않았다
|
||||||
|
2. **로그의 마지막 줄 시각이 `02:59:48` 이다** — 시도 ① 때 뜬 그 시각 그대로다
|
||||||
|
|
||||||
|
**★ 「`ready to accept connections` 줄이 있다」로 판정하면 안 된다.**
|
||||||
|
그 줄은 **아까 뜰 때 찍힌 것**이고 새로 찍힌 게 아니다. **줄의 존재가 아니라
|
||||||
|
시각을 본다.** 원래 실행의 검증 출력이 정확히 이 함정을 보여 준다.
|
||||||
|
|
||||||
|
### 개념 — PID 1 의 시그널 보호
|
||||||
|
|
||||||
|
리눅스 커널은 **PID 1 을 특별 취급한다.** 자기 PID 네임스페이스 안에서 온
|
||||||
|
시그널은 **핸들러가 등록된 것만** 전달된다. **SIGKILL 도 예외가 아니다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
같은 네임스페이스 안에서 → PID 1 은 등록하지 않은 시그널을 무시한다
|
||||||
|
조상 네임스페이스에서 → 전달된다 (노드에서 kill -9 하면 죽는다)
|
||||||
|
```
|
||||||
|
|
||||||
|
부팅 초기에 init 을 실수로 죽여 시스템이 멈추는 것을 막기 위한 장치인데,
|
||||||
|
컨테이너에서는 **「안에서는 PID 1 을 못 죽인다」**로 나타난다.
|
||||||
|
|
||||||
|
> **그래서 크래시 재현은 두 갈래다.**
|
||||||
|
> **(a) 자식 프로세스**를 죽인다 — 다음 절
|
||||||
|
> **(b) 노드에서** 죽인다 — `ssh kc-lab-2 'sudo kill -9 <호스트 PID>'`.
|
||||||
|
> 컨테이너 밖은 조상 네임스페이스이므로 SIGKILL 이 통한다. 이 실험은 (a) 로 했다.
|
||||||
|
|
||||||
|
## 3-6. 시도 ③ — 백엔드 프로세스를 죽인다
|
||||||
|
|
||||||
|
PostgreSQL 은 **postmaster(부모) + 연결마다 백엔드(자식)** 구조다. 자식 하나가
|
||||||
|
비정상 종료하면 **postmaster 는 공유 메모리가 오염됐다고 보고 전체를
|
||||||
|
재초기화한다.** 그게 곧 crash recovery 다.
|
||||||
|
|
||||||
|
**확인** — 먼저 무엇을 죽일지 눈으로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- ps -ef | head -20
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
UID PID PPID C STIME TTY TIME CMD
|
||||||
|
postgres 1 0 0 02:59 ? 00:00:00 postgres
|
||||||
|
postgres 40 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.1.67(41234) idle
|
||||||
|
postgres 41 1 0 02:59 ? 00:00:00 postgres: keycloak keycloak 10.42.0.35(52118) idle
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `PID 1` 이 postmaster 이고, `postgres: keycloak
|
||||||
|
keycloak ...` 이 **Keycloak 이 붙어 있는 백엔드**다. 이 중 하나를 죽인다.
|
||||||
|
|
||||||
|
**터미널 ① 에서 루프를 다시 돌리고 있어야 한다.** 8초쯤 쌓이면:
|
||||||
|
|
||||||
|
**하기** — 터미널 ② 에서
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S.%3N SIGKILL'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
sh -c 'kill -9 $(pgrep -f "postgres: keycloak keycloak" | head -1)'
|
||||||
|
```
|
||||||
|
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||||
|
```
|
||||||
|
=== 백엔드 프로세스에 SIGKILL → postmaster 가 재초기화한다 ===
|
||||||
|
시각: 12:04:22.063
|
||||||
|
최종 성공 로그인: 153 건
|
||||||
|
```
|
||||||
|
|
||||||
|
터미널 ① 의 루프를 `Ctrl-C` 로 멈춘다.
|
||||||
|
|
||||||
|
## 3-7. 주입 검증 ③ — 이번엔 걸렸다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --since=5m \
|
||||||
|
| grep -E 'terminated by signal|reinitializing|not properly shut down|redo|checkpoint complete|ready to accept'
|
||||||
|
```
|
||||||
|
**실측** — [`06-backend-kill-crash.txt`](../../evidence/a3-database-crash/06-backend-kill-crash.txt)
|
||||||
|
```
|
||||||
|
2026-09-04 03:02:35.807 UTC [1] LOG: server process (PID 40) was terminated by signal 9: Killed
|
||||||
|
2026-09-04 03:02:35.807 UTC [1] LOG: terminating any other active server processes
|
||||||
|
2026-09-04 03:02:35.814 UTC [1] LOG: all server processes terminated; reinitializing
|
||||||
|
2026-09-04 03:02:35.896 UTC [2585] LOG: database system was not properly shut down; automatic recovery in progress
|
||||||
|
2026-09-04 03:02:35.899 UTC [2585] LOG: redo starts at 0/23CAB68
|
||||||
|
2026-09-04 03:02:35.904 UTC [2585] LOG: redo done at 0/2529E40 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
|
||||||
|
2026-09-04 03:02:35.923 UTC [2586] LOG: checkpoint complete: wrote 113 buffers (0.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.004 s, total=0.015 s; sync files=27, longest=0.003 s, average=0.001 s; distance=1405 kB, estimate=1405 kB; lsn=0/252A048, redo lsn=0/252A048
|
||||||
|
2026-09-04 03:02:35.926 UTC [1] LOG: database system is ready to accept connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 여섯 줄이 순서대로 나온다.
|
||||||
|
|
||||||
|
| 줄 | 읽는 법 |
|
||||||
|
|---|---|
|
||||||
|
| `terminated by signal 9` | 내가 죽인 그 백엔드다 |
|
||||||
|
| `all server processes terminated; reinitializing` | **postmaster 가 전체를 갈아엎기로 했다** |
|
||||||
|
| **`not properly shut down`** | **주입 성공 신호.** 이게 없으면 결과를 해석하지 않는다 |
|
||||||
|
| `redo starts at 0/23CAB68` → `redo done at 0/2529E40` | 재생된 WAL 구간 |
|
||||||
|
| `checkpoint complete` | 재생 결과를 디스크에 고정했다 |
|
||||||
|
| `ready to accept connections` | **시각이 새로 찍혔다** — 3-5 와 대조한다 |
|
||||||
|
|
||||||
|
**확인** — 파드는 재시작하지 않았다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=postgres -o custom-columns=\
|
||||||
|
NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME RESTARTS
|
||||||
|
postgres-7b474b88c8-xxxxx 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 컨테이너의 PID 1 인 postmaster 는 **살아 있고
|
||||||
|
자식만 갈아치웠다.** 쿠버네티스 관점에서는 아무 일도 없었지만, **데이터
|
||||||
|
관점에서는 전원이 나간 것과 같다.**
|
||||||
|
|
||||||
|
### crash recovery 를 한 줄로
|
||||||
|
|
||||||
|
```
|
||||||
|
기동 시 pg_control 을 읽는다
|
||||||
|
└─ "깨끗하게 종료됨" 표시가 없다
|
||||||
|
└─ "database system was not properly shut down"
|
||||||
|
└─ 마지막 체크포인트부터 WAL 을 재생(redo)
|
||||||
|
└─ 디스크에 안 내려간 커밋은 복구할 수 없다 ← 손실
|
||||||
|
```
|
||||||
|
|
||||||
|
**WAL 에 없는 것은 재생할 수도 없다.** `redo starts` 와 `redo done` 사이가
|
||||||
|
살아 돌아온 구간이고, **그 뒤에 있던 것이 사라진 것**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 결과 — 몇 건이 사라졌나
|
||||||
|
|
||||||
|
## 4-1. 클라이언트가 받은 sid 목록을 꺼낸다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a3-probe -- cat /tmp/sids > /tmp/client-sids.txt
|
||||||
|
wc -l /tmp/client-sids.txt
|
||||||
|
```
|
||||||
|
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
|
||||||
|
```
|
||||||
|
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 파일 한 줄에 sid 하나. 몇 줄인지 적어 둔다.
|
||||||
|
|
||||||
|
**확인** — 눈으로 한 번 본다
|
||||||
|
```bash
|
||||||
|
head -3 /tmp/client-sids.txt
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
CQUfg9HLH29xvhiu6pVlfWOo
|
||||||
|
5gLP4fqmpZBbjhH_d-0TPMMr
|
||||||
|
hkcOv1QskUFmYveMLB6Hljra
|
||||||
|
```
|
||||||
|
|
||||||
|
빈 줄이 섞여 있으면 sid 추출이 실패한 것이다. 그대로 세면 유실 건수가 부풀려진다.
|
||||||
|
|
||||||
|
## 4-2. DB 에 남아 있는 sid 목록을 꺼낸다
|
||||||
|
|
||||||
|
**확인** — 먼저 총계를 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
DB 전체 온라인 세션 : 150 건
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 목록으로 뽑는다. `-tAc` 는 헤더·정렬 없이 값만 준다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select user_session_id from offline_user_session where offline_flag='0'" \
|
||||||
|
> /tmp/db-sids.txt
|
||||||
|
wc -l /tmp/db-sids.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`psql` 의 두 얼굴.** `-c` 는 표를 그려서 사람이 읽기 좋고, `-tAc` 는 값만
|
||||||
|
> 줘서 파이프에 넣기 좋다. **한 번은 `-c` 로 눈으로 보고**, 셀 때만 `-tAc` 를
|
||||||
|
> 쓴다.
|
||||||
|
|
||||||
|
## 4-3. 차집합 — 무엇이 사라졌나
|
||||||
|
|
||||||
|
`comm` 은 **정렬된 두 파일의 차집합**을 낸다. 정렬부터 한다.
|
||||||
|
|
||||||
|
**하기** **미검증**
|
||||||
|
```bash
|
||||||
|
LC_ALL=C sort -u /tmp/client-sids.txt > /tmp/a.txt
|
||||||
|
LC_ALL=C sort -u /tmp/db-sids.txt > /tmp/b.txt
|
||||||
|
comm -23 /tmp/a.txt /tmp/b.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `comm -23` 은 **왼쪽 파일에만 있는 줄**을 낸다.
|
||||||
|
즉 **클라이언트는 받았는데 DB 에는 없는 sid** 다.
|
||||||
|
|
||||||
|
| 옵션 | 무엇을 감추나 |
|
||||||
|
|---|---|
|
||||||
|
| `-1` | 왼쪽에만 있는 줄을 감춘다 |
|
||||||
|
| `-2` | 오른쪽에만 있는 줄을 감춘다 |
|
||||||
|
| `-3` | 양쪽에 다 있는 줄을 감춘다 |
|
||||||
|
|
||||||
|
`-23` 은 2·3 을 감추므로 **왼쪽 전용만 남는다.**
|
||||||
|
|
||||||
|
> **`LC_ALL=C` 를 빼면 안 된다.** `comm` 은 두 파일이 **같은 정렬 순서**임을
|
||||||
|
> 전제한다. 로케일이 다르면 대소문자·기호 순서가 달라져 **멀쩡한 sid 가
|
||||||
|
> 「없는 것」으로 잡힌다.** sid 는 대소문자와 `-` `_` 가 섞인 base64url 이라
|
||||||
|
> 정확히 이 문제에 걸린다.
|
||||||
|
|
||||||
|
**실측** — [`07-loss-result.txt`](../../evidence/a3-database-crash/07-loss-result.txt)
|
||||||
|
```
|
||||||
|
=== 크래시 전후 대조 ===
|
||||||
|
클라이언트가 200 과 토큰을 받은 로그인 : 153 건
|
||||||
|
그중 DB 에 실제로 존재 : 149 건
|
||||||
|
★ 유실 : 4 건
|
||||||
|
|
||||||
|
=== 유실된 sid 목록 ===
|
||||||
|
★ CQUfg9HLH29xvhiu6pVlfWOo ← 토큰은 발급됐는데 세션이 없다
|
||||||
|
★ 5gLP4fqmpZBbjhH_d-0TPMMr ← 토큰은 발급됐는데 세션이 없다
|
||||||
|
★ hkcOv1QskUFmYveMLB6Hljra ← 토큰은 발급됐는데 세션이 없다
|
||||||
|
★ p5XybeQIYmAs818gO4Vl_5ea ← 토큰은 발급됐는데 세션이 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 건수만
|
||||||
|
```bash
|
||||||
|
comm -23 /tmp/a.txt /tmp/b.txt | wc -l
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **로그인이 성공했다고 응답받았는데 세션이 존재하지
|
||||||
|
않는다.** 153건 중 4건, **약 2.6%.**
|
||||||
|
|
||||||
|
**확인** — 대조군. 사라지지 **않은** 것도 하나 본다
|
||||||
|
```bash
|
||||||
|
tail -1 /tmp/client-sids.txt
|
||||||
|
```
|
||||||
|
그 sid 로 DB 를 뒤진다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select user_session_id, created_on, last_session_refresh
|
||||||
|
from offline_user_session where user_session_id='8do0Bw6tkVLDVxgxotE7GosH'"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== 그 토큰이 지금 실제로 쓰이는가 (마지막 sid 로 확인) ===
|
||||||
|
마지막 sid: 8do0Bw6tkVLDVxgxotE7GosH
|
||||||
|
user_session_id | created_on | last_session_refresh
|
||||||
|
--------------------------+------------+----------------------
|
||||||
|
8do0Bw6tkVLDVxgxotE7GosH | 1788490958 | 1788490958
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**대부분은 멀쩡하다.** 그래서 **손실이 잘 안 보인다.**
|
||||||
|
|
||||||
|
## 4-4. 숫자를 어떻게 읽나
|
||||||
|
|
||||||
|
**★ 여기서 성급하게 결론을 붙이지 않는다.** 원래 문서가 그렇게 했다가 정정했다.
|
||||||
|
|
||||||
|
원래 문서는 *「초당 19건 … `wal_writer_delay` 기본값(200ms)과 맞는다」*고 썼는데,
|
||||||
|
**그 시점에 `wal_writer_delay` 를 조회한 적이 없었다.** 그리고 로그인 속도도
|
||||||
|
틀렸다.
|
||||||
|
|
||||||
|
**증거를 다시 읽는다.** 3-1 에서 본 값이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
8초에 112건 ≈ 초당 14건
|
||||||
|
4건 ≈ 약 0.29초 분량
|
||||||
|
```
|
||||||
|
|
||||||
|
**19건이 아니라 14건이고, 0.2초가 아니라 약 0.29초다.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 측정한 손실 | 4건 ≈ **약 0.29초 분량** |
|
||||||
|
| `wal_writer_delay` (1-3 에서 잰 값) | **200 ms** |
|
||||||
|
| 관계 | **같은 자릿수이되 정확히 일치하지는 않는다** |
|
||||||
|
|
||||||
|
**「같은 자릿수」까지가 이 실험이 말할 수 있는 것이다.** `wal_writer_delay`
|
||||||
|
하나가 손실 창을 정하는 것도 아니다 — `wal_writer_flush_after`(128 × 8kB)와
|
||||||
|
체크포인트 타이밍이 함께 작용한다.
|
||||||
|
|
||||||
|
> **재현하면 당신의 숫자는 다르다.** 로그인 속도, 디스크, 죽인 순간이 전부
|
||||||
|
> 다르기 때문이다. **중요한 것은 「4」가 아니라 「0 이 아니다」이고, 그 크기가
|
||||||
|
> WAL 플러시 주기와 같은 자릿수라는 것이다.**
|
||||||
|
|
||||||
|
## 4-5. 사용자에게 어떻게 보이는가
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 성공 → access token + refresh token 을 받음
|
||||||
|
│
|
||||||
|
│ (크래시)
|
||||||
|
▼
|
||||||
|
다음 요청 → access token 은 60초간 통한다
|
||||||
|
│ (서명만 보는 경로라면)
|
||||||
|
▼
|
||||||
|
60초 후 refresh → "Session not active" → 다시 로그인
|
||||||
|
```
|
||||||
|
|
||||||
|
**즉시 드러나지 않는다.** access token 수명 동안은 정상으로 보이다가 갱신
|
||||||
|
시점에 끊긴다. **장애와 증상 사이에 최대 60초의 시차가 있다.**
|
||||||
|
|
||||||
|
> **운영적 함의 — 모니터링은 갱신 실패율을 봐야 한다.** 로그인 성공률만 보면
|
||||||
|
> 이 장애는 안 보인다. 로그인은 `200` 을 줬기 때문이다.
|
||||||
|
|
||||||
|
## 4-6. 이 손실이 「허용된」 이유
|
||||||
|
|
||||||
|
Keycloak 의 판단은 이렇게 읽힌다.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 세션 쓰기는 **매우 잦다** | 로그인마다, refresh 마다 |
|
||||||
|
| 잃어도 **회복 가능하다** | 사용자가 다시 로그인하면 된다 |
|
||||||
|
| 동기 커밋의 비용은 **모든 요청에 붙는다** | 크래시는 드물다 |
|
||||||
|
|
||||||
|
**드문 사고의 비용을 상시 지연으로 지불하지 않겠다는 선택**이다. 합리적이지만,
|
||||||
|
**선택했다는 사실을 알고 있어야 한다.**
|
||||||
|
|
||||||
|
### 바꿀 수 있는가 — 못 바꾼다
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 세션 트랜잭션까지 동기 커밋으로 강제하려면 (지연 대가를 치른다)
|
||||||
|
ALTER DATABASE keycloak SET synchronous_commit = on;
|
||||||
|
```
|
||||||
|
|
||||||
|
**`SET LOCAL` 이 우선하므로 이것으로는 못 막는다.** Keycloak 설정이나 소스
|
||||||
|
수준의 문제다.
|
||||||
|
|
||||||
|
> **RPO 0 이 필요하면 복제(streaming replication)로 푸는 것이 맞다.** 동기
|
||||||
|
> 스탠바이가 있으면 `synchronous_commit` 의 의미가 달라진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구 · 정리
|
||||||
|
|
||||||
|
## 5-1. 문장 로깅이 꺼져 있는지 확인한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement" -c "show log_line_prefix"
|
||||||
|
```
|
||||||
|
`none` 이 아니면 1-2 의 reset 을 다시 친다.
|
||||||
|
|
||||||
|
## 5-2. 실험이 만든 세션을 정리한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "delete from offline_user_session"
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 재시작을 빼면 안 된다.** DB 만 지우면 **캐시 엔트리가 남아** 캐시 합계와
|
||||||
|
DB 총계가 어긋난다. A-0 이 겪은 함정이고, 다음 실험의 기준선을 망친다.
|
||||||
|
|
||||||
|
## 5-3. 탐침 파드를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a3-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
`--rm` 이 없으므로 **자동으로 사라지지 않는다.** `sleep 7200` 이 끝나면
|
||||||
|
`Completed` 로 남는다.
|
||||||
|
|
||||||
|
## 5-4. DB 가 건강한지 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=postgres
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select id, dateexecuted from databasechangelog order by dateexecuted desc limit 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **질의가 그냥 되고, 마이그레이션 이력 세 줄이 나오는
|
||||||
|
것.** 건수는 Keycloak 버전마다 다르므로 숫자를 외울 필요가 없다. 오류 없이
|
||||||
|
읽히면 그것으로 충분하다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — crash recovery 는 **커밋되지 않은 것만 버린다.**
|
||||||
|
스키마와 마이그레이션 이력은 멀쩡하다. 이 실험은 **「데이터 일부 손실」이지
|
||||||
|
「DB 파손」이 아니다.**
|
||||||
|
|
||||||
|
## 5-5. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
|
||||||
|
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
|
||||||
|
| WAL 설정 | `psql -c "show synchronous_commit"` | `on` (전역은 원래 on) |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||||
|
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||||
|
| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a3-probe` | `NotFound` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
로컬 임시 파일도 치운다.
|
||||||
|
```bash
|
||||||
|
rm -f /tmp/client-sids.txt /tmp/db-sids.txt /tmp/a.txt /tmp/b.txt /tmp/a3-login-loop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 유실이 `0건` 이다 | **죽인 적이 없다.** 대개 `--force` 나 `kill -9 1` 을 썼다 | `not properly shut down` 이 로그에 있나 — 3-3·3-7 |
|
||||||
|
| `ready to accept connections` 가 있으니 크래시인 줄 알았다 | **아까 뜰 때 찍힌 줄이다** | **줄의 존재가 아니라 시각**을 본다 — 3-5 |
|
||||||
|
| `kill -9 1` 을 했는데 아무 일도 없다 | **컨테이너 안에서 PID 1 은 SIGKILL 을 무시한다** | 백엔드 프로세스를 죽인다 — 3-6 |
|
||||||
|
| 루프가 `0건` 을 모았다 | **파드 안에서 `&` 로 띄우면 `exec` 종료와 같이 죽는다** | 터미널 하나를 루프에 통째로 쓴다 — 3-1 |
|
||||||
|
| 로그가 폭주하고 크래시 타이밍이 이상하다 | **`log_statement='all'` 을 켠 채로 루프를 돌렸다** | `show log_statement` 가 `none` 인지 — 1-2 |
|
||||||
|
| 멀쩡한 sid 가 「없음」으로 잡힌다 | **`comm` 두 파일의 정렬 순서가 다르다** | `LC_ALL=C sort` 를 양쪽에 — 4-3 |
|
||||||
|
| 유실 건수가 부풀려진다 | `/tmp/sids` 에 빈 줄이 섞였다 | `head -3` 으로 눈으로 본다 — 4-1 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드로 친다 |
|
||||||
|
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | `exec a3-probe -- sh -c 'echo ${#PW}'` — `0` 이면 `--env` 가 빈 값 |
|
||||||
|
| `a3-probe` 를 다시 못 만든다 | 옛 파드가 `Completed` 로 남아 있다 | `delete pod a3-probe --ignore-not-found` — 5-3 |
|
||||||
|
| `pgrep` 이 아무것도 못 찾는다 | Keycloak 이 아직 연결을 안 만들었다 | `ps -ef` 로 먼저 본다 — 3-6 |
|
||||||
|
| 손실 건수를 시간으로 환산했더니 문서와 다르다 | **원래 문서가 속도를 잘못 썼다가 정정했다** | 초당 14건이 실측이다 — 4-4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 왜 이 가이드는 판정 기준을 먼저 적나
|
||||||
|
|
||||||
|
이 실험이 남긴 가장 큰 교훈은 손실 건수가 아니다.
|
||||||
|
|
||||||
|
> **주입 성공 신호를 미리 정한다.**
|
||||||
|
|
||||||
|
세 번의 시도 중 **두 번은 「유실 0건」이라는 깨끗한 결과를 냈다.** 신호를
|
||||||
|
정해 두지 않았다면 첫 번째 결과를 그대로 발표했을 것이고, 결론은
|
||||||
|
**「Keycloak 은 DB 크래시에도 데이터를 잃지 않는다」**가 됐을 것이다.
|
||||||
|
정반대의 결론이다.
|
||||||
|
|
||||||
|
| 실험 | 주입 성공 신호 |
|
||||||
|
|---|---|
|
||||||
|
| A-1 7800 차단 | conntrack 에 `SYN_SENT [UNREPLIED]`, `coord = t` 가 둘 |
|
||||||
|
| A-2 DB 정지 | Keycloak 로그의 `Connection refused` + agroal |
|
||||||
|
| **A-3 DB 크래시** | **`database system was not properly shut down` + `redo starts`** |
|
||||||
|
|
||||||
|
**신호가 없으면 결과를 해석하지 않는다.** 그게 이 절의 전부다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-3 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| D-1 백업·복구 | **진짜 RPO = 백업 주기 + 이 손실.** 둘을 더해야 한다 |
|
||||||
|
| B-6 Redis 영속화 | `appendfsync everysec` 은 **같은 모양의 트레이드오프** |
|
||||||
|
| A-4 노드 상실 | `postgres` 가 `kc-lab-2` 에 있다 — **노드가 죽으면 이것도 함께 일어난다** |
|
||||||
|
| 모니터링 | **로그인 성공률이 아니라 갱신 실패율을 본다** — 4-5 |
|
||||||
|
| 전부 | **주입 성공 신호를 미리 정한다.** 여기서는 crash recovery 로그 |
|
||||||
@@ -0,0 +1,978 @@
|
|||||||
|
# A-4 재현 가이드 — 기계 전원을 뽑고 쿠버네티스가 언제 알아채는지 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a4-node-loss.md`](../../experiment-a4-node-loss.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a4-node-loss/`](../../evidence/a4-node-loss/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 기계는 없는데 쿠버네티스가 40초 동안 `Ready` 라고 말하는 것 | `kubectl get node` 와 외부 `curl` 을 나란히 |
|
||||||
|
| **죽은 파드가 `ready=true`, 산 파드가 `ready=false`** 인 것 | `get pods -o custom-columns` |
|
||||||
|
| 그 와중에 `up` 은 정확히 0 인 것 | Prometheus |
|
||||||
|
| 축출이 5분 뒤에야 시작되는 것 | `tolerationSeconds` 와 파드 상태 |
|
||||||
|
| 새 파드가 **영원히 `Pending`** 인 것 | `describe pod` 의 Events |
|
||||||
|
| StatefulSet 이 대체 파드를 **안 만드는** 것 | `get statefulset` 의 `CURRENT` |
|
||||||
|
| `kubectl` 이 죽어도 컨테이너는 도는 것 | `kc-lab-2` 에서 `crictl ps` |
|
||||||
|
| 관측자가 같이 죽으면 **0 이 아니라 구멍**이 남는 것 | Grafana |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- **명령을 치는 곳이 세 군데다.** 이 실험은 그 구별이 곧 내용이다.
|
||||||
|
|
||||||
|
| 터미널 | 어디 | 무엇을 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A** | `test-server` (VM 호스트) | `virsh` — 전원을 뽑고 다시 넣는다 |
|
||||||
|
| **B** | `kc-lab-1` | `sudo kubectl` — 관찰. **4b 에서는 이 터미널이 죽는다** |
|
||||||
|
| **C** | `test-server` | 밖에서 `curl`. 사용자 시점 |
|
||||||
|
|
||||||
|
- 터미널 A 에서 `virsh` 가 시스템 하이퍼바이저를 보고 있어야 한다.
|
||||||
|
```bash
|
||||||
|
export LIBVIRT_DEFAULT_URI=qemu:///system
|
||||||
|
virsh uri
|
||||||
|
```
|
||||||
|
`qemu:///system` 이 아니면 **VM 이 안 보인다.** [`00-lab-host`](../00-lab-host/) 5절.
|
||||||
|
- 4b 에서는 `kc-lab-2` 에도 붙는다. 터미널 A 와 같은 기계에서 `ssh kc-lab-2`.
|
||||||
|
이름이 안 풀리면 `ssh 192.168.122.12`.
|
||||||
|
|
||||||
|
## 주의 — 이건 기계를 끄는 실험이다
|
||||||
|
|
||||||
|
`virsh destroy` 는 **종료 신호를 보내지 않는다. 전원 코드를 뽑는 것과 같다.**
|
||||||
|
게스트 파일시스템이 더러운 채로 멈춘다. **실험대에서만 한다.**
|
||||||
|
|
||||||
|
전 구간 약 **40분**이다. 4a 에서 축출을 보려면 그것만 7분을 기다려야 한다.
|
||||||
|
어느 시점에서든 그만두려면 터미널 A 에서 한 줄이면 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh start kc-lab-2 ; virsh start kc-lab-1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 12:05–12:23 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
파드 이름·IP·시각은 **당신 환경에서 다르다.** 자리표시자(`<...>`)를 쓰지 않는
|
||||||
|
대신 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
|
||||||
|
실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
A-1 과 A-5 는 **네트워크만** 끊었다. 파드는 살아 있었고, 쿠버네티스는 계속
|
||||||
|
정확한 상태를 알고 있었다. 여기서는 **기계 자체를 없앤다.** 그러면 상태를
|
||||||
|
보고할 주체가 사라진다.
|
||||||
|
|
||||||
|
두 판본으로 나눈다. **어느 노드를 죽이느냐가 전부**이기 때문이다.
|
||||||
|
|
||||||
|
| | 죽이는 노드 | 그 노드에 있는 것 | 묻는 것 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **4a** | `kc-lab-2` (워커) | keycloak-0 · **postgres** · postgres PVC | Keycloak 과 DB 를 **동시에** 잃으면 |
|
||||||
|
| **4b** | `kc-lab-1` (k3s 서버) | keycloak-1 · **Traefik** · 컨트롤 플레인 · 관측 스택 | **들어갈 문**을 잃으면 |
|
||||||
|
|
||||||
|
세 가지를 확인한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
쿠버네티스는 언제 알아채는가 → 40초 (그동안 거짓말을 한다)
|
||||||
|
무엇을 스스로 고치는가 → 축출. 단 5분 뒤
|
||||||
|
무엇을 못 고치는가 → PVC 가 묶인 재배치, StatefulSet 이름
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 전원을 뽑기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 뽑은 뒤에 볼 것을 뽑기 전에 **똑같은
|
||||||
|
명령으로** 먼저 봐 둔다.
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
VM → 노드 → 파드 배치 → 볼륨이 어디 묶여 있나 → 외부 응답 → 관측자가 어디 있나
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. VM 이 둘 다 살아 있나
|
||||||
|
|
||||||
|
**확인** — 터미널 A
|
||||||
|
```bash
|
||||||
|
virsh list --all
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
--------------------------
|
||||||
|
1 kc-lab-1 running
|
||||||
|
2 kc-lab-2 running
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 둘 다 `running`. 앞의 숫자는 **도메인 ID** 이며
|
||||||
|
VM 을 껐다 켜면 바뀐다. 이름으로 다룬다.
|
||||||
|
|
||||||
|
## 1-2. 노드와 파드 배치
|
||||||
|
|
||||||
|
**확인** — 터미널 B
|
||||||
|
```bash
|
||||||
|
sudo kubectl get nodes
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
kc-lab-1 Ready true
|
||||||
|
kc-lab-2 Ready <none>
|
||||||
|
|
||||||
|
a2-probe true kc-lab-2
|
||||||
|
keycloak-0 true kc-lab-2
|
||||||
|
keycloak-1 true kc-lab-1
|
||||||
|
postgres-7b474b88c8-2gf27 true kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`NODE` 열.** 이 실험은 배치가 전부다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `kc-lab-2` 에 **keycloak-0 과 postgres 가 함께**
|
||||||
|
있다. 그래서 4a 는 「Keycloak 한 대를 잃는 실험」이 아니라 **「Keycloak 한 대와
|
||||||
|
DB 를 동시에 잃는 실험」**이다. 배치가 다르면 결과도 다르다 — 먼저 확인한다.
|
||||||
|
|
||||||
|
> `a2-probe` 는 A-2 에서 띄워 두고 안 지운 상주 파드다. 당신 환경에는 없을 수
|
||||||
|
> 있다. 없어도 이 실험에는 지장이 없다.
|
||||||
|
|
||||||
|
## 1-3. ★ 볼륨이 어느 노드에 못박혀 있나
|
||||||
|
|
||||||
|
**이 한 줄이 뒤의 결과를 이미 결정한다.** 4a 에서 「새 파드가 왜 영원히
|
||||||
|
Pending 인가」의 답이 여기 있다.
|
||||||
|
|
||||||
|
**확인** — 어떤 PVC 가 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pvc
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 그 PVC 뒤의 PV 가 어느 노드를 요구하나. 먼저 **읽는 형태**로 한 번 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pvc postgres-data -o jsonpath='{.spec.volumeName}' ; echo
|
||||||
|
sudo kubectl describe pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
|
||||||
|
-o jsonpath='{.spec.volumeName}') | grep -A6 'Node Affinity'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
Node Affinity:
|
||||||
|
Required Terms:
|
||||||
|
Term 0: kubernetes.io/hostname in [kc-lab-2]
|
||||||
|
```
|
||||||
|
|
||||||
|
값만 필요하면 **뽑는 형태**로 줄인다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl get pv $(sudo kubectl -n keycloak-lab get pvc postgres-data \
|
||||||
|
-o jsonpath='{.spec.volumeName}') \
|
||||||
|
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' ; echo
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== PVC 가 어느 노드에 묶여 있는가 (재배치 가능성) ===
|
||||||
|
persistentvolumeclaim/postgres-data → kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 오른쪽의 노드 이름. **그것이 `kc-lab-2` 라면 4a 에서
|
||||||
|
postgres 는 갈 곳이 없다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `local-path` PVC 는 **그 노드의 로컬 디렉터리**다
|
||||||
|
(`/var/lib/rancher/k3s/storage/...`). 노드가 죽으면 볼륨도 같이 죽는다.
|
||||||
|
스케줄러는 그 사실을 `nodeAffinity` 로 알고 있어서, 다른 노드에 파드를
|
||||||
|
**만들지 않는다.** 결함이 아니라 이 실험대의 **조건**이다.
|
||||||
|
|
||||||
|
## 1-4. 밖에서 보이는 상태
|
||||||
|
|
||||||
|
**확인** — 터미널 C. 눈으로 한 번 볼 때는 `-I` 로 충분하다
|
||||||
|
```bash
|
||||||
|
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
HTTP/2 200
|
||||||
|
content-type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
여러 번 재서 비교할 것이므로, 이제부터는 **코드만** 뽑는다.
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a4-node-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== 서비스 정상 확인 ===
|
||||||
|
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--max-time` 을 반드시 준다.** 4b 에서 이 값이 없으면 curl 이 몇 분씩
|
||||||
|
매달린다. 그리고 **타임아웃이 곧 결과**다 — 뒤에서 `000` 이 나오는 이유가
|
||||||
|
그것이다.
|
||||||
|
|
||||||
|
## 1-5. 관측자가 어디 있나 — 미리 알아 둔다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability get pods -o wide
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS NODE
|
||||||
|
grafana-845b5678cf-b6gvc 1/1 Running kc-lab-1
|
||||||
|
prometheus-6774f94f7c-pzr2t 1/1 Running kc-lab-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 관측 스택이 `kc-lab-1` 에 있다. **4a(`kc-lab-2`
|
||||||
|
살해)에서는 Prometheus 가 살아남아 관측이 정확하고, 4b 에서는 관측자가 같이
|
||||||
|
죽는다.** 그 차이를 발견 ⑧ 에서 본다. 지금 알아 두지 않으면 나중에 그래프의
|
||||||
|
빈 구간을 「값이 0」으로 잘못 읽는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 4a — 워커 노드의 전원을 뽑는다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
virsh start kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. `destroy` 와 `shutdown` 의 차이
|
||||||
|
|
||||||
|
| 명령 | 게스트에 무슨 일이 | 이 실험에 |
|
||||||
|
|---|---|---|
|
||||||
|
| `virsh shutdown` | ACPI 종료 신호 → kubelet 이 정상 종료 → 파드가 정리된다 | **쓰면 안 된다** |
|
||||||
|
| **`virsh destroy`** | **전원 차단.** 신호 없음. 마지막 상태가 그대로 얼어붙는다 | 이것이 「노드 상실」이다 |
|
||||||
|
|
||||||
|
`shutdown` 을 쓰면 쿠버네티스가 **정상적인 노드 이탈**로 처리해서
|
||||||
|
이 실험의 발견 ①·② 가 통째로 안 나온다.
|
||||||
|
|
||||||
|
## 2-2. 뽑는다
|
||||||
|
|
||||||
|
**하기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 차단'
|
||||||
|
virsh destroy kc-lab-2
|
||||||
|
```
|
||||||
|
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
|
||||||
|
```
|
||||||
|
차단 시각: 12:07:43
|
||||||
|
Domain 'kc-lab-2' destroyed
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 반드시 적어 둔다.** 40초·5분 같은 숫자는 **이 시각에서 뺀 값**이다.
|
||||||
|
기준점이 없으면 뒤의 관찰은 그냥 나열이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
A-5·A-6 에서는 「규칙을 넣었는데 카운터가 0」이 실패였다. **이 실험의 검증
|
||||||
|
대상은 다르다.** 여기서 믿을 수 있는 것은 **하이퍼바이저**뿐이고, 쿠버네티스가
|
||||||
|
뭐라고 하든 그것은 결과이지 검증이 아니다.
|
||||||
|
|
||||||
|
## 3-1. VM 이 실제로 꺼졌나 — 이것이 유일한 주입 검증이다
|
||||||
|
|
||||||
|
**확인** — 터미널 A
|
||||||
|
```bash
|
||||||
|
virsh list --all
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
1 kc-lab-1 running
|
||||||
|
- kc-lab-2 shut off
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `shut off`. ID 가 `-` 로 바뀐 것도 같은 말이다.
|
||||||
|
|
||||||
|
**확인** — 정말 응답이 없나
|
||||||
|
```bash
|
||||||
|
ping -c 2 -W 2 192.168.122.12
|
||||||
|
```
|
||||||
|
**미검증** — 원 실행에는 이 확인이 없다. `0 received` 가 나오면 꺼진 것이다.
|
||||||
|
|
||||||
|
## 3-2. ★ 그런데 쿠버네티스는 아직 `Ready` 라고 말한다
|
||||||
|
|
||||||
|
**확인** — 터미널 B
|
||||||
|
```bash
|
||||||
|
sudo kubectl get node kc-lab-2
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME STATUS ROLES AGE VERSION
|
||||||
|
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
|
||||||
|
```
|
||||||
|
|
||||||
|
**여기서 「주입이 안 걸렸다」고 결론 내리면 틀린다.** 기계는 3-1 에서 확인한
|
||||||
|
대로 꺼져 있다. 쿠버네티스가 아직 모를 뿐이다.
|
||||||
|
|
||||||
|
노드 상태와 사용자 경험을 **나란히** 봐야 이게 보인다. 터미널 B 에서:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl get node kc-lab-2
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
|
||||||
|
두 줄을 15초 간격으로 몇 번 친다. 손이 아프면 한 줄로 묶는다. **미검증**
|
||||||
|
```bash
|
||||||
|
while true; do
|
||||||
|
printf '%s node=%s 외부=%s\n' "$(date +%H:%M:%S)" \
|
||||||
|
"$(sudo kubectl get node kc-lab-2 --no-headers | awk '{print $2}')" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
|
||||||
|
https://auth.hyeonworks.com/realms/master)"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
```
|
||||||
|
`Ctrl-C` 로 멈춘다.
|
||||||
|
|
||||||
|
**실측** — [`02-worker-node-killed.txt`](../../evidence/a4-node-loss/02-worker-node-killed.txt)
|
||||||
|
```
|
||||||
|
+15초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
|
||||||
|
+30초 node=Ready | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 000
|
||||||
|
+45초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
|
||||||
|
+60초 node=NotReady | keycloak-0=Running postgres-7b474b88c8-2gf27=Running | 외부 HTTP 503
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `+30초` 줄과 `+45초` 줄 사이. **노드 상태가 그때
|
||||||
|
넘어간다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — kube-controller-manager 는 kubelet 의 하트비트가
|
||||||
|
`node-monitor-grace-period`(이 실험대에서 **40초**) 동안 없어야 `NotReady` 로
|
||||||
|
바꾼다. 그 40초 동안 **쿠버네티스는 거짓말을 한다.** 그리고 사용자는 그
|
||||||
|
40초에도 이미 장애를 겪고 있다 — `000` 이 그 증거다.
|
||||||
|
|
||||||
|
> **노드 상태를 알림 근거로 삼으면 항상 늦는다.** 사용자가 먼저 안다.
|
||||||
|
|
||||||
|
## 3-3. 왜 처음 40초는 `503` 이 아니라 `000` 인가
|
||||||
|
|
||||||
|
```
|
||||||
|
000 curl 이 응답 자체를 못 받았다 = 타임아웃 또는 연결 실패
|
||||||
|
503 nginx·Traefik 은 살아 있고 뒤로 보낼 파드가 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
호스트 nginx 의 upstream 에는 **두 노드가 다 들어 있다**
|
||||||
|
([`03-nginx`](../03-nginx/) 1절).
|
||||||
|
|
||||||
|
```
|
||||||
|
upstream k3s_traefik {
|
||||||
|
server 192.168.122.11:80;
|
||||||
|
server 192.168.122.12:80;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
죽은 쪽으로 배분된 요청은 **응답도 거절도 못 받고** `--max-time 8` 에 걸린다.
|
||||||
|
|
||||||
|
> **★ 여기는 이 실험이 답을 못 남긴 자리다.** 증거 파일
|
||||||
|
> [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||||
|
> 의 마지막 절 제목이 「진입점이 처음 40초간 000 이었던 이유 — nginx upstream」
|
||||||
|
> 인데 **그 아래가 비어 있다.** 명령이 아무것도 찍지 못했다.
|
||||||
|
> **당신은 지금 직접 볼 수 있다** — 터미널 C 에서. **미검증**
|
||||||
|
> ```bash
|
||||||
|
> sudo tail -f /var/log/nginx/error.log
|
||||||
|
> ```
|
||||||
|
> `upstream timed out` 이 `192.168.122.12` 에 대해 찍히면 그것이 답이다.
|
||||||
|
> nginx 에러 로그는 2048바이트에서 잘리므로, 잘려 보이면 access 로그를 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다 (4a)
|
||||||
|
|
||||||
|
## 4-1. ★ 죽은 파드가 산 파드보다 건강해 보인다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,PHASE:.status.phase,READY:.status.containerStatuses[0].ready,NODE:.spec.nodeName
|
||||||
|
```
|
||||||
|
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||||
|
```
|
||||||
|
a2-probe Running true kc-lab-2 <none>
|
||||||
|
keycloak-0 Running true kc-lab-2 <none>
|
||||||
|
keycloak-1 Running false kc-lab-1 <none>
|
||||||
|
postgres-7b474b88c8-2gf27 Running true kc-lab-2 <none>
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `keycloak-0` 은 **꺼진 기계 위에서 `ready=true`**,
|
||||||
|
`keycloak-1` 은 **살아 있는데 `ready=false`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
| 파드 | 왜 |
|
||||||
|
|---|---|
|
||||||
|
| `keycloak-0` | kubelet 이 없어 **상태를 갱신할 수 없다.** 마지막으로 보고한 값이 얼어 있다 |
|
||||||
|
| `keycloak-1` | 살아서 **정직하게 보고한다** — DB 가 없으니 readiness 실패 |
|
||||||
|
|
||||||
|
> **파드 상태는 「지금 어떤가」가 아니라 「마지막으로 그렇게 들었다」이다.**
|
||||||
|
> 노드가 죽으면 그 노드 파드의 상태는 **화석**이 된다.
|
||||||
|
|
||||||
|
이유를 이벤트로 확인한다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
10m Warning Unhealthy pod/keycloak-0 Readiness probe failed: Get "http://10.42.1.67:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||||
|
3m15s Warning NodeNotReady pod/postgres-7b474b88c8-2gf27 Node is not ready
|
||||||
|
3m15s Warning NodeNotReady pod/keycloak-0 Node is not ready
|
||||||
|
3m15s Warning NodeNotReady pod/a2-probe Node is not ready
|
||||||
|
2m27s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.35:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||||
|
2s Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
|
||||||
|
```
|
||||||
|
|
||||||
|
**`keycloak-1` 의 실패가 두 종류다.** 처음에는 프로브 자체가 타임아웃되고
|
||||||
|
(`context deadline exceeded`), 나중에는 `503` 을 받는다. Keycloak 이 DB 없음을
|
||||||
|
스스로 판단해 답할 수 있게 된 것이다. **같은 「Unhealthy」라도 층이 다르다.**
|
||||||
|
|
||||||
|
**`Age` 를 반드시 같이 본다.** 노드를 뽑은 것은 `3m15s` 전인데 맨 위 줄은
|
||||||
|
`10m` 짜리다 — **주입보다 앞선 사건**이고, 앞 실험의 잔재다. 이벤트 목록은
|
||||||
|
시간대가 섞여 있으므로 **`Age` 로 먼저 걸러야** 내가 만든 일을 고를 수 있다.
|
||||||
|
|
||||||
|
그리고 주입 이후 `keycloak-0` 에 붙은 이벤트는 `NodeNotReady` **하나뿐**이다.
|
||||||
|
그것은 컨트롤러가 쓴 것이지 kubelet 이 쓴 것이 아니다. **kubelet 이 없으니
|
||||||
|
그 파드에 대해 말해 줄 주체가 없다** — 4-1 의 `ready=true` 가 화석인 이유다.
|
||||||
|
|
||||||
|
## 4-2. Prometheus 는 정확했다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 job 과
|
||||||
|
라벨이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다. 읽기 좋게 자르려면
|
||||||
|
(`jq` 는 이 실험대에 없다) **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=up' \
|
||||||
|
| tr ',' '\n' | grep -E '"job":|"pod":|"node":|^"[0-9]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||||
|
```
|
||||||
|
up{job=keycloak pod=keycloak-1 } = 1
|
||||||
|
up{job=keycloak pod=keycloak-0 } = 0
|
||||||
|
up{job=kubelet pod=- } = 1
|
||||||
|
up{job=kubelet pod=- } = 0
|
||||||
|
up{job=node-exporter pod=kc-lab-1 } = 1
|
||||||
|
up{job=node-exporter pod=kc-lab-2 } = 0
|
||||||
|
up{job=prometheus pod=- } = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `kc-lab-2` 쪽이 전부 `0`. **`kubelet` job 이 두 줄인
|
||||||
|
것**도 본다 — 노드마다 하나씩이라 하나는 1, 하나는 0 이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `up` 은 **Prometheus 가 그 대상을 긁는 데
|
||||||
|
성공했는가**다. 대상이 사라졌으니 실패했고, 그 0 은 **사실**이다.
|
||||||
|
|
||||||
|
**A-2 와 정반대다.** A-2(DB 상실)에서는 `up=1` 인데 서비스가 죽어 있었다.
|
||||||
|
|
||||||
|
| | `up` 이 잡는가 |
|
||||||
|
|---|---|
|
||||||
|
| **대상이 사라짐** (노드 상실) | **잡는다** |
|
||||||
|
| **대상이 살아서 못 씀** (DB 상실) | **못 잡는다** |
|
||||||
|
|
||||||
|
Grafana 에서 같은 것을 그림으로 본다 —
|
||||||
|
[`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png).
|
||||||
|
|
||||||
|
## 4-3. 쿠버네티스가 노드에 무엇을 붙였나
|
||||||
|
|
||||||
|
**확인** — 읽는 형태
|
||||||
|
```bash
|
||||||
|
sudo kubectl describe node kc-lab-2 | grep -A3 Taints
|
||||||
|
```
|
||||||
|
값만 필요하면
|
||||||
|
```bash
|
||||||
|
sudo kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}' ; echo
|
||||||
|
```
|
||||||
|
**실측** — [`03-state-during-loss.txt`](../../evidence/a4-node-loss/03-state-during-loss.txt)
|
||||||
|
```
|
||||||
|
=== 노드 taint — 쿠버네티스가 붙인 것 ===
|
||||||
|
node.kubernetes.io/unreachable=:NoSchedule
|
||||||
|
node.kubernetes.io/unreachable=:NoExecute
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 종류가 붙어 있다.
|
||||||
|
|
||||||
|
| taint | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `NoSchedule` | **새 파드를 여기 보내지 마라** |
|
||||||
|
| `NoExecute` | **이미 있는 파드도 쫓아내라** |
|
||||||
|
|
||||||
|
`NoExecute` 가 붙었는데 왜 아무 일도 안 일어나는가 — 다음 절.
|
||||||
|
|
||||||
|
## 4-4. 축출은 왜 5분 뒤인가
|
||||||
|
|
||||||
|
**확인** — 파드에 붙어 있는 관용을 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab describe pod keycloak-1 | grep -A4 Tolerations
|
||||||
|
```
|
||||||
|
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
|
||||||
|
```
|
||||||
|
=== NoExecute taint 의 tolerationSeconds — 언제 축출되는가 ===
|
||||||
|
node.kubernetes.io/not-ready NoExecute tolerationSeconds=300
|
||||||
|
node.kubernetes.io/unreachable NoExecute tolerationSeconds=300
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `tolerationSeconds=300`. **당신이 쓴 적 없는 값**이다.
|
||||||
|
쿠버네티스가 모든 파드에 자동으로 붙인다.
|
||||||
|
|
||||||
|
```
|
||||||
|
기계 정지
|
||||||
|
│
|
||||||
|
│ 40초 node-monitor-grace-period → 노드 NotReady
|
||||||
|
│
|
||||||
|
│ +300초 tolerationSeconds (NoExecute) → 파드 축출 시작
|
||||||
|
▼
|
||||||
|
총 약 5분 40초 동안 쿠버네티스는 아무것도 하지 않는다
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4-5. 그 5분을 실제로 기다린다
|
||||||
|
|
||||||
|
**확인** — 30초 간격으로 본다. 손으로 치기 싫으면 `watch`
|
||||||
|
```bash
|
||||||
|
watch -n 30 'sudo kubectl -n keycloak-lab get pods -o wide'
|
||||||
|
```
|
||||||
|
**실측** — [`04-eviction-timing.txt`](../../evidence/a4-node-loss/04-eviction-timing.txt)
|
||||||
|
```
|
||||||
|
+240초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
|
||||||
|
+270초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
|
||||||
|
+300초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `+240초` 와 `+270초` 사이. 두 가지가 동시에 일어난다.
|
||||||
|
|
||||||
|
- `kc-lab-2` 의 파드들이 **`Terminating`** 으로 바뀐다
|
||||||
|
- **새 이름의 postgres 파드**(`...-9cmsv`)가 생기고 **`Pending`** 이다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 축출이 시작됐다. 그런데 `Terminating` 이 안 끝나고,
|
||||||
|
새 파드는 뜨지 못한다. 두 문제는 원인이 다르다 — 4-6 과 4-7.
|
||||||
|
|
||||||
|
## 4-6. 새 파드는 왜 영원히 `Pending` 인가
|
||||||
|
|
||||||
|
**확인** — 파드에게 직접 물어본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods --field-selector=status.phase=Pending
|
||||||
|
sudo kubectl -n keycloak-lab describe pod postgres-7b474b88c8-9cmsv | grep -A6 Events
|
||||||
|
```
|
||||||
|
이름은 매번 다르므로 위 `get` 으로 먼저 확인하고 옮겨 적는다.
|
||||||
|
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||||
|
```
|
||||||
|
Events:
|
||||||
|
Type Reason Age From Message
|
||||||
|
---- ------ ---- ---- -------
|
||||||
|
Warning FailedScheduling 4m45s default-scheduler 0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`0/2 nodes are available` 뒤에 이유가 노드 수만큼
|
||||||
|
나열된다.** 이 줄 하나에 두 노드의 사연이 다 들어 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-2 → had untolerated taint(s) (죽은 노드)
|
||||||
|
kc-lab-1 → didn't match PersistentVolume's node affinity
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **1-3 에서 이미 알고 있던 것이 그대로 벌어졌다.**
|
||||||
|
볼륨이 `kc-lab-2` 에 못박혀 있어서 살아 있는 노드로 못 간다. 죽은 노드에는
|
||||||
|
taint 때문에 못 간다. **갈 곳이 없다.**
|
||||||
|
|
||||||
|
> 이건 결함이 아니라 **조건**이다. 이 실험대는 그걸 알고 `local-path` 를
|
||||||
|
> 골랐다. 운영이라면 네트워크 스토리지나 DB 복제가 이 자리를 메워야 한다.
|
||||||
|
> 노드가 영영 안 돌아오면 남는 길은 **백업 복원(D-1)** 뿐이다.
|
||||||
|
|
||||||
|
## 4-7. StatefulSet 은 대체 파드를 만들지 않는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak
|
||||||
|
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||||
|
```
|
||||||
|
keycloak 2 <none> 1
|
||||||
|
keycloak-0 1/1 Terminating 0 30m
|
||||||
|
keycloak-1 0/1 Running 0 143m
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `DESIRED=2` 인데 `CURRENT=1`. 그리고 `keycloak-0` 이
|
||||||
|
**30분째 `Terminating`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| StatefulSet 의 계약 | **같은 이름의 파드는 클러스터에 하나뿐**이어야 한다 |
|
||||||
|
| 컨트롤 플레인이 아는 것 | 노드가 안 보인다 = **파드가 죽었는지 확신할 수 없다** |
|
||||||
|
| 그래서 | 옛 파드를 확실히 지우기 전엔 새 `keycloak-0` 을 못 만든다 |
|
||||||
|
|
||||||
|
`Terminating` 이 안 끝나는 사슬은 이렇다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 삭제 요청
|
||||||
|
└─ kubelet 이 컨테이너를 멈추고 "지웠다"고 보고해야 끝난다
|
||||||
|
└─ kubelet 이 없다 → 보고가 없다 → 영원히 Terminating
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployment 였다면 즉시 새 파드를 만든다.** 이름이 아무래도 되기 때문이다
|
||||||
|
(postgres 가 실제로 그랬다 — 4-5 에서 새 이름의 파드가 생겼다. 다만 갈 곳이
|
||||||
|
없었을 뿐이다). **StatefulSet 의 「안정된 이름」이라는 이득의 반대편 비용**이
|
||||||
|
여기다.
|
||||||
|
|
||||||
|
> **강제로 진행시키는 명령이 있지만, 이 가이드에서는 치지 않는다.**
|
||||||
|
> ```
|
||||||
|
> kubectl -n keycloak-lab delete pod keycloak-0 --grace-period=0 --force
|
||||||
|
> ```
|
||||||
|
> 이것은 **컨테이너가 실제로 죽었는지 모른 채 API 에서 지우는 것**이다.
|
||||||
|
> 노드가 사실은 살아 있고 네트워크만 끊긴 것이라면 **같은 이름의 파드 둘이
|
||||||
|
> 동시에 존재**하게 된다 — 그게 split brain 이고, 이 실험대에서는 5절의
|
||||||
|
> `virsh start` 가 훨씬 안전하고 빠르다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구 (4a)
|
||||||
|
|
||||||
|
## 5-1. 전원을 다시 넣는다
|
||||||
|
|
||||||
|
**하기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 재기동'
|
||||||
|
virsh start kc-lab-2
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a4-node-loss/05-recovery.txt)
|
||||||
|
```
|
||||||
|
재기동 시각: 12:16:31
|
||||||
|
Domain 'kc-lab-2' started
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2. 얼마나 걸리나
|
||||||
|
|
||||||
|
**확인** — 30초 간격
|
||||||
|
```bash
|
||||||
|
sudo kubectl get nodes
|
||||||
|
sudo kubectl -n keycloak-lab get pods
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
+30초 node=Ready | Running 파드 3 개 | 외부 HTTP 503
|
||||||
|
+60초 node=Ready | Running 파드 3 개 | 외부 HTTP 200
|
||||||
|
→ 서비스 복귀
|
||||||
|
```
|
||||||
|
|
||||||
|
**60초. 사람 개입 없이 전부 제자리로 돌아왔다.** `Terminating` 이던 파드도,
|
||||||
|
`Pending` 이던 파드도 kubelet 이 돌아오자 정리됐다.
|
||||||
|
|
||||||
|
> **이 60초는 MTTR 이 아니다.** `virsh start` 를 친 **뒤**의 시간이다.
|
||||||
|
> 실제 장애 구간은 **12:07:43(차단) → 12:17:31(서비스 복귀) ≈ 10분**이고,
|
||||||
|
> 그 대부분은 사람이 관찰하고 결정하는 데 쓴 시간이다. **현실의 MTTR 도
|
||||||
|
> 대개 그렇다.**
|
||||||
|
>
|
||||||
|
> 그리고 본문의 `40초`와 `5분`은 **쿠버네티스 기본값을 인용한 것**이며,
|
||||||
|
> 관측된 전이 시점(+45초, +270초)이 그 값과 모순되지 않는다는 것까지가
|
||||||
|
> 이 실험이 말할 수 있는 범위다. 값 자체를 측정한 것은 아니다.
|
||||||
|
|
||||||
|
## 5-3. 4b 로 넘어가기 전 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| VM | `virsh list --all` | 둘 다 `running` |
|
||||||
|
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `1/1 Running`, `Pending` 없음 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
|
||||||
|
```
|
||||||
|
=== 복구 확인 ===
|
||||||
|
keycloak-0 1/1 Running 0 68s
|
||||||
|
keycloak-1 1/1 Running 0 144m
|
||||||
|
postgres-7b474b88c8-9cmsv 1/1 Running 0 4m20s
|
||||||
|
```
|
||||||
|
|
||||||
|
**`postgres` 의 이름이 바뀌어 있다** (`-2gf27` → `-9cmsv`). 4-5 에서 생겼다가
|
||||||
|
`Pending` 이던 그 파드가 노드가 살아나자 그대로 뜬 것이다. **`keycloak-0` 은
|
||||||
|
이름이 그대로** — StatefulSet 이라 그렇다. 두 컨트롤러의 차이가 이름에 남는다.
|
||||||
|
|
||||||
|
여기까지 안 돌아왔으면 **4b 로 넘어가지 않는다.** 두 고장이 겹치면 무엇이
|
||||||
|
원인인지 못 가린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 4b — 이번엔 컨트롤 플레인을 뽑는다
|
||||||
|
|
||||||
|
## 6-1. 먼저 인벤토리 — 그게 곧 영향 범위다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl get pods -A -o wide --field-selector spec.nodeName=kc-lab-1
|
||||||
|
```
|
||||||
|
**실측** — [`06-control-plane-inventory.txt`](../../evidence/a4-node-loss/06-control-plane-inventory.txt)
|
||||||
|
```
|
||||||
|
keycloak-lab keycloak-1
|
||||||
|
kube-system coredns-54996dc9b4-8k8fj
|
||||||
|
kube-system helm-install-traefik-crd-q29b5
|
||||||
|
kube-system local-path-provisioner-77b9867795-g27z8
|
||||||
|
kube-system metrics-server-6dc596dfb8-7xxq4
|
||||||
|
kube-system svclb-traefik-5eb6a9a1-qwwk5
|
||||||
|
kube-system traefik-5d6fcf895-wpfhr
|
||||||
|
observability grafana-845b5678cf-b6gvc
|
||||||
|
observability node-exporter-9qk9w
|
||||||
|
observability prometheus-6774f94f7c-pzr2t
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `traefik`. **진입점이 여기 있다.**
|
||||||
|
|
||||||
|
**확인** — 진입점이 몇 개인가
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n kube-system get deploy traefik
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
traefik 1 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`replicas=1`. 진입점이 단일 장애점이다.**
|
||||||
|
이 노드를 뽑으면 클러스터로 들어갈 문이 사라진다. 4a 와 결과가 다른 이유가
|
||||||
|
여기서 이미 예측된다.
|
||||||
|
|
||||||
|
## 6-2. 뽑는다
|
||||||
|
|
||||||
|
**되돌리기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
virsh start kc-lab-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 차단'
|
||||||
|
virsh destroy kc-lab-1
|
||||||
|
```
|
||||||
|
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||||
|
```
|
||||||
|
차단 시각: 12:18:08
|
||||||
|
Domain 'kc-lab-1' destroyed
|
||||||
|
```
|
||||||
|
|
||||||
|
**터미널 B 가 여기서 죽는다.** SSH 세션이 그대로 끊긴다. 놀랄 일이 아니다.
|
||||||
|
|
||||||
|
## 6-3. 주입 검증 — `kubectl` 이 죽은 것이 곧 증거다
|
||||||
|
|
||||||
|
**확인** — 터미널 A 나 C 에서
|
||||||
|
```bash
|
||||||
|
sudo kubectl get nodes
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
kubectl: Unable to connect to the server: dial tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Unable to connect to the server`. API 서버가
|
||||||
|
`kc-lab-1:6443` 에 있었으므로 **당연한 결과**다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 4a 에서는 「쿠버네티스가 뭐라고 하는가」를 물을 수
|
||||||
|
있었다. **여기서는 물어볼 상대 자체가 없다.** 이 실험의 관찰 도구가 통째로
|
||||||
|
바뀐다.
|
||||||
|
|
||||||
|
## 6-4. 밖에서는 어떻게 보이나
|
||||||
|
|
||||||
|
**확인** — 터미널 C. 20초 간격으로 두 주소를 본다
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'auth=%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
curl -s -o /dev/null -w 'grafana=%{http_code}\n' --max-time 8 https://grafana.hyeonworks.com/
|
||||||
|
```
|
||||||
|
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||||
|
```
|
||||||
|
+20초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||||
|
+60초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||||
|
+120초 외부 auth=000 grafana=502 | kubectl: Unable to connect to the server: dial tcp
|
||||||
|
+160초 외부 auth=000 grafana=000 | kubectl: Unable to connect to the server: dial tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `+120초` 의 **`grafana=502` 한 줄.** 나머지는 전부
|
||||||
|
`000` 인데 여기만 다르다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`000` 과 `503`/`502` 는 서로 다른 층의 고장을
|
||||||
|
가리킨다.**
|
||||||
|
|
||||||
|
| 코드 | 어디까지 살아 있는가 |
|
||||||
|
|---|---|
|
||||||
|
| **`503`** (4a) | nginx·Traefik 은 살아 있고 **뒤에 보낼 파드가 없다** |
|
||||||
|
| **`502`** (4b, 한 번) | nginx 가 **연결 실패를 제때 판정해** 자기 힘으로 502 를 만들었다 |
|
||||||
|
| **`000`** (4b, 대부분) | nginx 가 죽은 주소를 기다리다 **우리 `--max-time 8` 이 먼저 끝났다** |
|
||||||
|
|
||||||
|
`502` 가 한 번이라도 찍혔다는 것이 **nginx 는 살아 있었다**는 증거다.
|
||||||
|
같은 고장인데 코드가 흔들리는 이유는 **타임아웃 경주**다.
|
||||||
|
|
||||||
|
## 6-5. ★ 그런데 워크로드는 살아 있다
|
||||||
|
|
||||||
|
`kubectl` 이 없으니 **노드의 컨테이너 런타임에 직접 묻는다.**
|
||||||
|
|
||||||
|
**확인** — 터미널 A 에서 살아남은 노드로
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo crictl ps --name keycloak'
|
||||||
|
```
|
||||||
|
**실측** — [`07-control-plane-loss.txt`](../../evidence/a4-node-loss/07-control-plane-loss.txt)
|
||||||
|
```
|
||||||
|
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
|
||||||
|
e5f777900b762 60e153026e8f5 4 minutes ago Running keycloak 0 640d4dafaefb3 keycloak-0 keycloak-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `STATE` 가 `Running`, `ATTEMPT` 가 `0`.
|
||||||
|
**API 서버가 없는데도 컨테이너는 돌고 있다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
죽은 것: API 서버 · 스케줄러 · coredns · Traefik · Prometheus · Grafana
|
||||||
|
산 것: keycloak-0 · postgres · containerd
|
||||||
|
문제: 들어갈 문(Traefik)이 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
> **컨트롤 플레인 상실 ≠ 워크로드 상실.**
|
||||||
|
> 이미 떠 있는 것은 계속 돈다. **새로 뜨거나 옮기거나 고치는 것이 안 될 뿐.**
|
||||||
|
|
||||||
|
전체 목록도 본다.
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo crictl ps'
|
||||||
|
```
|
||||||
|
`crictl` 이 소켓을 못 찾으면 k3s 의 것을 직접 준다. **미검증**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock ps'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6-6. 관측자가 같이 죽으면 0 이 아니라 구멍이 남는다
|
||||||
|
|
||||||
|
**지금은 확인할 수 없다.** Prometheus 도 Grafana 도 `kc-lab-1` 에 있었다.
|
||||||
|
**그것이 이 발견이다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
대상이 죽음 → up = 0 → "언제 죽었는지" 알 수 있다
|
||||||
|
관측자가 죽음 → 데이터 없음 → "그때 무슨 일이 있었는지" 모른다
|
||||||
|
```
|
||||||
|
|
||||||
|
복구 뒤에 Grafana 에서 `up` 그래프를 다시 열어 **12:18–12:23 구간이 0 이
|
||||||
|
아니라 빈칸**인 것을 확인한다. 6-8 에서 한다.
|
||||||
|
|
||||||
|
## 6-7. 복구
|
||||||
|
|
||||||
|
**하기** — 터미널 A
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 재기동'
|
||||||
|
virsh start kc-lab-1
|
||||||
|
```
|
||||||
|
**실측** — [`08-control-plane-recovery.txt`](../../evidence/a4-node-loss/08-control-plane-recovery.txt)
|
||||||
|
```
|
||||||
|
재기동: 12:23:39
|
||||||
|
Domain 'kc-lab-1' started
|
||||||
|
|
||||||
|
+30초 외부=502 | kc-lab-1=Ready kc-lab-2=Ready
|
||||||
|
+60초 외부=200 | kc-lab-1=Ready kc-lab-2=Ready
|
||||||
|
→ 서비스 복귀 (총 60초)
|
||||||
|
```
|
||||||
|
|
||||||
|
**여기서도 60초.** `+30초` 의 `502` 는 **nginx 가 먼저 살아나고 Traefik 이
|
||||||
|
아직 안 뜬** 중간 상태다. 4b 내내 보던 `000` 과 층이 다르다.
|
||||||
|
|
||||||
|
## 6-8. 복구 후에 확인할 것
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
keycloak-0 1/1 Running 0 7m57s
|
||||||
|
keycloak-1 1/1 Running 1 (<invalid> ago) 151m
|
||||||
|
postgres-7b474b88c8-9cmsv 1/1 Running 0 11m
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
|
||||||
|
|
||||||
|
- `keycloak-1` 의 `RESTARTS` 가 **1** — `kc-lab-1` 위에 있었으니 당연하다
|
||||||
|
- `AGE` 가 `151m` 인데 재시작은 방금 — **AGE 는 파드가 만들어진 시각**이지
|
||||||
|
컨테이너가 시작한 시각이 아니다
|
||||||
|
- **`(<invalid> ago)`** — 재시작 시각이 API 서버 시계보다 미래로 보일 때 나온다.
|
||||||
|
**원인은 이 실험이 확정하지 않았다.** 잠시 뒤 다시 치면 정상 값으로 바뀐다
|
||||||
|
|
||||||
|
**확인** — Grafana. 6-6 에서 예고한 구멍
|
||||||
|
```
|
||||||
|
브라우저로 Grafana 를 열어 up{job="keycloak"} 그래프를 12:15–12:30 으로 본다
|
||||||
|
```
|
||||||
|
**실측** — [`a4-up-dropped-per-node.png`](../../evidence/a4-node-loss/a4-up-dropped-per-node.png)
|
||||||
|
그림에서 12:18–12:23 은 **선이 0 으로 내려간 것이 아니라 아예 끊겨 있다.**
|
||||||
|
|
||||||
|
**Grafana 로그인이 풀려 있다.** Grafana 데이터가 `emptyDir` 이라 파드
|
||||||
|
재시작에 사라진다. Prometheus 는 PVC 라 지표가 남았다 — 다만 관측자가 죽어
|
||||||
|
있던 구간의 데이터는 애초에 수집되지 않았다. **의도한 설계대로 동작했고,
|
||||||
|
그 설계의 한계도 함께 드러났다.**
|
||||||
|
|
||||||
|
Prometheus 를 `port-forward` 로 보고 있었다면 **다시 연결해야 한다** (실측
|
||||||
|
기록의 마지막 줄이 그것이다).
|
||||||
|
|
||||||
|
## 6-9. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| VM | `virsh list --all` | 둘 다 `running` |
|
||||||
|
| 노드 | `sudo kubectl get nodes` | 둘 다 `Ready` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | 전부 `1/1 Running` |
|
||||||
|
| 진입점 | `sudo kubectl -n kube-system get deploy traefik` | `1/1` |
|
||||||
|
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||||
|
| 관측 | Prometheus `up` | 전부 1 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것** — 노드가 **영영 안 돌아오는** 경우는 재지 않았다.
|
||||||
|
> `local-path` PVC 가 그 노드와 함께 사라진 상태에서의 복구는 **D-1(백업·복원)**
|
||||||
|
> 의 주제다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `virsh` 가 도메인을 못 찾는다 | `qemu:///session` 을 보고 있다 | `virsh uri` — `system` 이어야 한다 |
|
||||||
|
| VM 을 껐는데 노드가 `Ready` | **정상.** `node-monitor-grace-period` 40초 | `virsh list --all` 로 전원을 먼저 본다 — 3-1 |
|
||||||
|
| 5분이 지나도 축출이 안 온다 | 40초 + `tolerationSeconds=300` = **5분 40초** | `describe pod \| grep -A4 Tolerations` |
|
||||||
|
| 새 파드가 계속 `Pending` | **PVC 가 죽은 노드에 못박혀 있다** | `describe pod` 의 `FailedScheduling` — 4-6 |
|
||||||
|
| `keycloak-0` 이 30분째 `Terminating` | StatefulSet + kubelet 없음. **정상이다** | `get statefulset` 의 `CURRENT` — 4-7 |
|
||||||
|
| `--force` 로 지우고 싶다 | 노드가 살아 있으면 **중복 실행**이 된다 | 치지 말고 `virsh start` — 4-7 |
|
||||||
|
| `kubectl` 이 전혀 안 된다 (4b) | **API 서버가 죽은 노드에 있었다.** 정상 | `ssh kc-lab-2 'sudo crictl ps'` — 6-5 |
|
||||||
|
| `crictl` 이 소켓을 못 찾는다 | k3s 는 자기 containerd 소켓을 쓴다 | `--runtime-endpoint unix:///run/k3s/containerd/containerd.sock` |
|
||||||
|
| `503` 을 기대했는데 `000` | 층이 다르다. nginx 가 죽은 주소를 기다린다 | `--max-time` 을 늘려 보면 `502` 가 나온다 — 6-4 |
|
||||||
|
| `curl` 이 몇 분씩 안 끝난다 | `--max-time` 을 안 줬다 | 모든 외부 확인에 `--max-time 8` |
|
||||||
|
| 그래프의 그 구간이 0 으로 보인다 | **0 이 아니라 데이터 없음이다** | 점 사이가 이어져 있는지 본다 — 6-6 |
|
||||||
|
| Grafana 로그인이 풀렸다 | 데이터가 `emptyDir` | 재시작마다 그렇다. PVC 로 바꾸면 남는다 |
|
||||||
|
| Prometheus 가 갑자기 안 보인다 | `port-forward` 가 끊겼다 | 다시 연다 |
|
||||||
|
| `RESTARTS` 가 `1 (<invalid> ago)` | 재시작 직후에 나온다. **원인 미확정** | 잠시 뒤 다시 친다 — 6-8 |
|
||||||
|
| 4b 결과가 4a 와 섞인다 | 4a 복구를 확인하지 않고 넘어갔다 | 5-3 확인표를 통과한 뒤 시작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 실험이 남기는 구성 숙제
|
||||||
|
|
||||||
|
관찰만 하고 끝내면 아깝다. **두 가지는 지금 고칠 수 있다.**
|
||||||
|
|
||||||
|
| 발견 | 고치는 방향 |
|
||||||
|
|---|---|
|
||||||
|
| Traefik `replicas=1` 이라 진입점이 단일 장애점 | `replicas=2` 로 늘리거나 DaemonSet 으로 |
|
||||||
|
| Grafana 가 `emptyDir` 이라 재시작마다 세션이 사라짐 | PVC 를 붙인다 |
|
||||||
|
| 관측 스택이 실험 대상 노드에 함께 있음 | 노드가 둘뿐이라 완전히는 못 피한다. **아는 것이 먼저** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-4 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [A-5](../../experiment-a5-asymmetric-partition.md) 비대칭 파티션 | 여기서는 노드가 **완전히** 사라졌다. **부분 단절은 더 고약하다** |
|
||||||
|
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | **PVC 가 노드에 묶여 있다.** 노드가 영영 안 돌아오면 백업이 유일한 길 |
|
||||||
|
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | 여기서는 `up=0` 이 정확했다. **느려짐은 `up` 이 못 잡는다** |
|
||||||
|
| 전부 | **주입 검증의 기준을 먼저 정한다.** 여기서는 쿠버네티스가 아니라 하이퍼바이저가 기준이었다 |
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
# A-5 재현 가이드 — 한 방향만 끊어 보고, 왜 안 갈라지는지 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a5-asymmetric-partition.md`](../../experiment-a5-asymmetric-partition.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a5-asymmetric-partition/`](../../evidence/a5-asymmetric-partition/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 규칙을 넣었는데 **0 패킷**인 상태 | `iptables -L -n -v` 의 카운터 |
|
||||||
|
| kube-router 가 내 규칙을 **아래로 밀어내는** 것 | `FORWARD` 체인의 줄 번호 |
|
||||||
|
| JGroups 연결 방향이 **A-1 때와 반대**인 것 | `conntrack -L` |
|
||||||
|
| 단방향 차단이 **스스로 낫는** 것 | 연결이 뒤집혀 재연결 |
|
||||||
|
| `coord = t` 가 둘인 split brain | PostgreSQL `JGROUPS_PING` |
|
||||||
|
| 그런데 **한쪽만 DOWN 이고 외부는 200** 인 것 | `health/ready` · `endpointslice` |
|
||||||
|
| `MergeView` 로 50초 만에 합쳐지는 것 | Keycloak 로그 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- [`A-1`](a1-jgroups-transport-block.md) 을 먼저 해 두면 훨씬 이해가 빠르다.
|
||||||
|
**이 실험은 A-1 이 실패한 자리에서 시작한다.**
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- `kc-lab-2` 에는 `ssh kc-lab-2` 로 붙는다. **iptables 는 두 노드에 각각
|
||||||
|
넣어야 하고, 어느 노드에 넣느냐가 이 실험의 핵심이다.**
|
||||||
|
- 터미널 **두 개**를 열어 두면 편하다. 하나는 상주 탐침 파드용, 하나는 관찰용.
|
||||||
|
|
||||||
|
## 주의 — 이건 상태를 부수는 실험이다
|
||||||
|
|
||||||
|
Keycloak 클러스터를 실제로 분단시킨다. **실험대에서만 한다.**
|
||||||
|
전 구간 약 30분이고, 되돌리는 방법은 매 단계에 적어 두었다.
|
||||||
|
중간에 그만두려면 두 줄이면 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING ; sudo iptables -F FORWARD'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`-F FORWARD` 는 그 체인 전체를 비운다.** 이 실험대의 `FORWARD` 정책은
|
||||||
|
> `ACCEPT` 이고 실제 규칙은 kube-router·kube-proxy 가 **자기 체인에** 두므로
|
||||||
|
> 잠시 뒤 스스로 복구된다. 그래도 지우기 전에 **`sudo iptables -S FORWARD` 로
|
||||||
|
> 무엇이 있었는지 한 번 보고** 지운다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 12:28–12:46 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
IP·파드 이름·포트 번호는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||||
|
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
|
||||||
|
전부 위 실행 기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
A-1 이 열어 둔 질문이 하나 있었다.
|
||||||
|
|
||||||
|
> *「`keycloak-1` 은 멤버가 하나 줄어든 정상적인 사건이라 Ready 를 유지했고,
|
||||||
|
> `keycloak-0` 은 합류 자체를 못 해 DOWN 이 됐다. **양쪽이 동시에 DOWN 이 되는
|
||||||
|
> 경로가 있다면 전면 장애다.**」*
|
||||||
|
|
||||||
|
그 경로를 찾는 것이 이 실험이다. 그리고 A-1 은 도구도 하나 남겼다.
|
||||||
|
|
||||||
|
| | A-1 이 배운 것 |
|
||||||
|
|---|---|
|
||||||
|
| NetworkPolicy | **기존 연결을 못 끊는다.** conntrack 의 `ESTABLISHED` 가 먼저 통과시킨다 |
|
||||||
|
| 그래서 | 이번엔 iptables 로 직접 간다 |
|
||||||
|
|
||||||
|
**그런데 iptables 에도 벽이 세 개 있었다.** 이 가이드의 절반은 그 세 번의
|
||||||
|
실패를 **일부러 다시 밟는 것**이다. 셋 다 화면에는 **「아무 일도 없었다」**로
|
||||||
|
보이기 때문에, 겪어 보지 않으면 다음에도 똑같이 속는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
실패 ① filter FORWARD 최상단에 넣었는데 → CNI 가 밀어낸다
|
||||||
|
실패 ② raw 로 옮겼는데도 0 패킷 → 연결 방향을 잘못 짚었다
|
||||||
|
성공 수신측 노드의 raw PREROUTING → 19 패킷
|
||||||
|
그런데 그래도 안 갈라진다 → 반대 방향으로 재연결한다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 아무것도 넣기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 특히 이 실험은 **주입이 걸리기 전과
|
||||||
|
후가 화면상 똑같이 보이므로**, 기준선이 없으면 실패를 성공으로 읽는다.
|
||||||
|
|
||||||
|
## 1-1. 파드 IP 와 노드 — 이 값이 곧 규칙의 인자다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||||
|
```
|
||||||
|
keycloak-0=10.42.1.77 (kc-lab-2) keycloak-1=10.42.0.42 (kc-lab-1)
|
||||||
|
keycloak-0 1/1 Running 0 11m
|
||||||
|
keycloak-1 1/1 Running 1 (2m48s ago) 155m
|
||||||
|
postgres-7b474b88c8-9cmsv 1/1 Running 0 14m
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- **`NODE` 와 파드 번호가 어긋난다** — `keycloak-0` 이 `kc-lab-2` 에 있다.
|
||||||
|
iptables 를 **어느 노드에** 넣을지 정할 때 이걸 헷갈리면 규칙은 걸리는데
|
||||||
|
패킷은 안 걸린다
|
||||||
|
- **IP 가 A-1 때와 다르다** (`10.42.1.43` → `10.42.1.77`). 파드가 재시작되면
|
||||||
|
바뀐다. 여기 적힌 값을 그대로 쓰지 말고 **지금 뽑는다**
|
||||||
|
|
||||||
|
변수로 잡아 둔다. **파드가 재시작되면 다시 잡는다.**
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
echo "K0=$K0 K1=$K1"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `keycloak-1` 의 `RESTARTS` 가 **1** 인 것도 보인다. A-4 에서 노드를 껐다
|
||||||
|
> 켠 흔적이다. **직전 실험의 잔재가 남아 있는지 확인하는 자리**이기도 하다.
|
||||||
|
|
||||||
|
## 1-2. 클러스터가 지금 하나인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select name, ip, coord from jgroups_ping order by name"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
name | ip | coord
|
||||||
|
------------------+-----------------+-------
|
||||||
|
keycloak-0-24309 | 10.42.1.77:7800 | f
|
||||||
|
keycloak-1-45480 | 10.42.0.42:7800 | t
|
||||||
|
(2 rows)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`coord` 열에 `t` 가 정확히 하나.**
|
||||||
|
둘이면 이미 갈라져 있는 것이고, 그 상태에서 주입해 봐야 아무것도 판정 못 한다.
|
||||||
|
|
||||||
|
**확인** — 로그가 말하는 뷰
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
ISPN000094: [keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**뷰 ID(`|13`)를 적어 둔다.** 이 실험의 판정 기준이 이 숫자의 변화다.
|
||||||
|
|
||||||
|
## 1-3. 지표 — 그리고 이 자리에서 원 실행이 넘어졌다
|
||||||
|
|
||||||
|
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 밖에서 Prometheus 에
|
||||||
|
묻는 것이 가장 짧다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||||
|
```
|
||||||
|
**형태** — 한 줄 JSON 이 통째로 나온다. 처음 한 번은 그대로 본다
|
||||||
|
```json
|
||||||
|
{"status":"success","data":{"resultType":"vector","result":[
|
||||||
|
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-1","node":"kc-lab-1"},"value":[1757037600.123,"2"]},
|
||||||
|
{"metric":{"__name__":"vendor_cluster_size","pod":"keycloak-0","node":"kc-lab-2"},"value":[1757037600.123,"2"]}]}}
|
||||||
|
```
|
||||||
|
라벨을 보고 나면 읽기 좋게 자른다 (`jq` 는 이 실험대에 없다). **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||||
|
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 줄이고 값이 둘 다 `2`.
|
||||||
|
|
||||||
|
> **★ 원 실행의 기준선은 남지 않았다.** 값을 뽑으려고 붙인 파이썬 한 줄이
|
||||||
|
> 죽었기 때문이다. — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||||
|
> ```
|
||||||
|
> Traceback (most recent call last):
|
||||||
|
> File "<string>", line 3, in <module>
|
||||||
|
> for r in json.load(sys.stdin)["data"]["result"]: print(f" cluster_size {r["metric"].get("pod"):12} = {r["value"][1]}")
|
||||||
|
> json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
|
||||||
|
> ```
|
||||||
|
> **입력이 비어 있었다.** 그런데 파서가 죽으면서 **원본도 같이 사라졌다** —
|
||||||
|
> 화면에 남은 것은 파이썬 스택트레이스뿐이고, Prometheus 가 무엇을 돌려줬는지는
|
||||||
|
> 아무도 모른다. **원본을 먼저 보고 나중에 자르면** 이런 일이 없다.
|
||||||
|
> 이 가이드가 `wget` 원문을 먼저 보여 주는 이유다.
|
||||||
|
|
||||||
|
## 1-4. ★ 연결 방향 — 이 실험에서 가장 중요한 기준선
|
||||||
|
|
||||||
|
**어느 쪽이 클라이언트이고 어느 쪽이 서버인가.** 이걸 모르면 규칙을 엉뚱한
|
||||||
|
노드에 넣게 된다.
|
||||||
|
|
||||||
|
**확인** — 두 노드 모두에서 본다
|
||||||
|
```bash
|
||||||
|
sudo conntrack -L 2>/dev/null | grep 7800
|
||||||
|
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 1절 (실패 ② 에서 인용된 원 실행의 연결)
|
||||||
|
```
|
||||||
|
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
|
||||||
|
──────────── ────────────────────
|
||||||
|
keycloak-0 가 클라이언트 keycloak-1 이 서버
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `dport=7800` 인 쪽이 **서버**다. `src` 가 클라이언트.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **A-1 때와 방향이 반대다.** A-1 에서는
|
||||||
|
`10.42.0.35:40023 → 10.42.1.43:7800`, 즉 `keycloak-1` 이 걸었다. 지금은
|
||||||
|
`keycloak-0` 이 건다.
|
||||||
|
|
||||||
|
> **JGroups 의 TCP 연결 방향은 고정이 아니다.** 먼저 뜬 쪽, 먼저 JOIN 을 건
|
||||||
|
> 쪽에 따라 달라진다. 파드가 재시작될 때마다 바뀔 수 있다.
|
||||||
|
> **가정하지 말고 매번 `conntrack -L` 로 본다.**
|
||||||
|
|
||||||
|
`2>/dev/null` 은 `conntrack` 이 stderr 로 찍는 「N flow entries have been shown」
|
||||||
|
요약을 지우려는 것이다. 처음에는 빼고 쳐서 그 줄도 한번 본다.
|
||||||
|
|
||||||
|
## 1-5. 밖에서 보이는 상태
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -I --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
여러 번 재서 비교할 것이므로 이제부터는 코드만 뽑는다.
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
`200` 이어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 시도 ① — `filter` 테이블 최상단 (실패한다)
|
||||||
|
|
||||||
|
**일부러 실패하는 단계다.** 건너뛰지 않는 편이 좋다. 이 실패의 모양을 봐 둬야
|
||||||
|
다음에 자기 규칙을 의심할 수 있다.
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -F FORWARD'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 넣는다
|
||||||
|
|
||||||
|
A-1 의 NetworkPolicy 는 conntrack 에 막혔다. **`FORWARD` 최상단에 넣으면
|
||||||
|
conntrack 승인보다 먼저 평가될 것**이라는 게 이 시도의 가설이다.
|
||||||
|
|
||||||
|
**하기** — `keycloak-0`(수신측이라고 **가정한** 쪽) 이 있는 노드에
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 7800 -j DROP"
|
||||||
|
ssh kc-lab-2 "sudo iptables -I FORWARD 1 -p tcp -d $K0 --dport 57800 -j DROP"
|
||||||
|
date '+%H:%M:%S 주입'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **57800 도 같이 막는다.** FD_SOCK2(장애 감지 채널)는 `bind_port + 50000` 을
|
||||||
|
> 쓴다. 7800 만 막으면 **장애 감지는 계속 통해서** 분단이 어정쩡해진다.
|
||||||
|
|
||||||
|
**확인** — 방금 넣은 것이 실제로 1번인가
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
|
||||||
|
```
|
||||||
|
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||||
|
```
|
||||||
|
Chain FORWARD (policy ACCEPT)
|
||||||
|
num target prot opt source destination
|
||||||
|
1 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:57800
|
||||||
|
2 DROP 6 -- 0.0.0.0/0 10.42.1.77 tcp dpt:7800
|
||||||
|
주입 시각: 12:28:23
|
||||||
|
```
|
||||||
|
|
||||||
|
**넣은 직후에는 맞게 보인다.** 여기서 만족하고 넘어가면 속는다.
|
||||||
|
|
||||||
|
## 2-2. 잠시 뒤 다시 본다 — ★ 밀려나 있다
|
||||||
|
|
||||||
|
**확인** — 1~2분 뒤 같은 명령을 다시
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -L FORWARD -n -v --line-numbers'
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 1절 (실패 ①)
|
||||||
|
```
|
||||||
|
num pkts bytes target
|
||||||
|
1 232 377K KUBE-ROUTER-FORWARD /* kube-router netpol */ ← 다시 1번이 되었다
|
||||||
|
2 0 0 DROP tcp dpt:57800
|
||||||
|
3 0 0 DROP tcp dpt:7800 ← 0 패킷
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 가지를 동시에 본다.
|
||||||
|
|
||||||
|
| 열 | 무엇을 말하는가 |
|
||||||
|
|---|---|
|
||||||
|
| `num` | 내 규칙이 **1번이 아니다.** kube-router 체인이 위로 돌아왔다 |
|
||||||
|
| **`pkts`** | **0.** 이 규칙에는 패킷이 단 한 개도 도달하지 않았다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **kube-router 가 주기적으로 자기 체인을 `FORWARD`
|
||||||
|
최상단에 다시 삽입한다.** 내가 1번에 넣어도 곧 2번, 3번으로 밀려나고,
|
||||||
|
kube-router 체인이 패킷을 먼저 처리해 버린다.
|
||||||
|
|
||||||
|
> **직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁한다.**
|
||||||
|
> **넣는 것으로 끝이 아니다. 패킷 카운터로 확인해야 한다.**
|
||||||
|
|
||||||
|
> **정직하게** — 이 확인을 담았어야 할 증거 파일
|
||||||
|
> [`02-injection-verify.txt`](../../evidence/a5-asymmetric-partition/02-injection-verify.txt)
|
||||||
|
> 는 **원 실험 시점에 0바이트로 저장됐다.** 리다이렉션이 stdout 만 받았는데
|
||||||
|
> 출력이 stderr 로 갔던 것으로 보인다. 지금 그 파일에 들어 있는 것은 **사후에
|
||||||
|
> 다시 수집한 것**이며, 원 시점의 DROP 규칙은 이미 없어서 재현되지 않는다.
|
||||||
|
> 남아 있는 것은 구조적 사실 하나 — kube-router 체인이 `FORWARD` 1번을
|
||||||
|
> 차지하고 있다는 것뿐이다.
|
||||||
|
> **당신은 지금 실제 카운터를 볼 수 있다.** 이 단계를 건너뛰지 않는 이유다.
|
||||||
|
|
||||||
|
## 2-3. 그래서 아무 일도 안 일어난다
|
||||||
|
|
||||||
|
**확인** — 25초 간격으로 몇 번 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측** — [`01-injection.txt`](../../evidence/a5-asymmetric-partition/01-injection.txt)
|
||||||
|
```
|
||||||
|
+25초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
+50초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
...
|
||||||
|
+200초 | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 여기서 「비대칭 차단은 클러스터를 안 가른다」고 결론 내리면 틀린다.**
|
||||||
|
결론이 우연히 맞더라도 **근거가 없다.** 규칙에 패킷이 0 개 왔으니
|
||||||
|
**이 관찰은 아무것도 측정하지 않았다.**
|
||||||
|
|
||||||
|
## 2-4. 치운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 7800 -j DROP"
|
||||||
|
ssh kc-lab-2 "sudo iptables -D FORWARD -p tcp -d $K0 --dport 57800 -j DROP"
|
||||||
|
ssh kc-lab-2 'sudo iptables -L FORWARD -n --line-numbers | head -5'
|
||||||
|
```
|
||||||
|
`-D` 는 **넣을 때와 똑같은 인자**를 줘야 지워진다. 안 지워지면 줄 번호로:
|
||||||
|
`sudo iptables -D FORWARD 3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입 시도 ② — `raw` 테이블로 옮긴다 (그래도 0 패킷)
|
||||||
|
|
||||||
|
## 3-1. 개념 — netfilter 처리 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
패킷 도착
|
||||||
|
│
|
||||||
|
├─▶ raw PREROUTING ← conntrack 보다 먼저. NOTRACK·DROP 용
|
||||||
|
│
|
||||||
|
├─▶ conntrack 조회/생성 ← 여기서 ESTABLISHED 가 결정된다
|
||||||
|
│
|
||||||
|
├─▶ mangle PREROUTING
|
||||||
|
├─▶ nat PREROUTING
|
||||||
|
├─▶ filter FORWARD ← NetworkPolicy·kube-router 가 여기 있다
|
||||||
|
└─▶ 목적지 파드
|
||||||
|
```
|
||||||
|
|
||||||
|
| 어디에 넣는가 | 기존 연결을 끊는가 | CNI 와 경쟁하는가 |
|
||||||
|
|---|---|---|
|
||||||
|
| NetworkPolicy (filter) | **못 끊는다** — conntrack 이 먼저 통과시킨다 (A-1) | 없음 |
|
||||||
|
| filter FORWARD 직접 | 순서에 따라 | **경쟁한다** (kube-router 가 밀어낸다) — 2절 |
|
||||||
|
| **raw PREROUTING** | **끊는다** | **없다** — CNI 가 안 쓰는 테이블 |
|
||||||
|
|
||||||
|
**진짜 네트워크 분단을 흉내내려면 `raw` 테이블이 맞다.**
|
||||||
|
|
||||||
|
## 3-2. 넣는다 — 아직 같은 노드, 같은 목적지
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
|
||||||
|
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
|
||||||
|
date '+%H:%M:%S 주입'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 카운터
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||||
|
```
|
||||||
|
**실측** — [`03-raw-table-injection.txt`](../../evidence/a5-asymmetric-partition/03-raw-table-injection.txt)
|
||||||
|
```
|
||||||
|
=== [검증] 이번엔 패킷이 걸렸는가 ===
|
||||||
|
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
|
||||||
|
pkts bytes target prot opt in out source destination
|
||||||
|
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:57800
|
||||||
|
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.1.77 tcp dpt:7800
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`pkts` 가 여전히 0.** 이번에는 CNI 와 경쟁하지도
|
||||||
|
않는데 0 이다.
|
||||||
|
|
||||||
|
## 3-3. 왜 0 인가 — 1-4 를 다시 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo conntrack -L 2>/dev/null | grep 7800'
|
||||||
|
```
|
||||||
|
1-4 에서 본 것이 답이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
|
||||||
|
└── keycloak-0 ──┘ └── keycloak-1 ──┘
|
||||||
|
(클라이언트) (서버, 7800 을 듣는 쪽)
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `10.42.1.77`(keycloak-0)은 이 연결의 **출발지**다.
|
||||||
|
`-d 10.42.1.77 --dport 7800` 은 **존재하지 않는 패킷**을 노린 규칙이었다.
|
||||||
|
7800 으로 **들어가는** 패킷은 `10.42.0.42`(keycloak-1) 쪽으로 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
내가 막은 것: → 10.42.1.77:7800 (그런 패킷이 없다)
|
||||||
|
실제 흐름: → 10.42.0.42:7800 (여기를 막아야 한다)
|
||||||
|
```
|
||||||
|
|
||||||
|
**규칙을 넣은 노드도 틀렸다.** 목적지 파드가 있는 노드에서 잡아야 한다.
|
||||||
|
|
||||||
|
## 3-4. 치운다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n --line-numbers'
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||||
|
```
|
||||||
|
**지우기 전에 `-L` 로 무엇이 있는지 본다.** `-F` 는 체인 전체를 비운다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 주입 성공 — 수신측 노드의 `raw PREROUTING`
|
||||||
|
|
||||||
|
## 4-1. 넣는다
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -F PREROUTING
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 이번에는 **`kc-lab-1`(keycloak-1 이 있는 노드)** 에, `keycloak-1` 의
|
||||||
|
IP 를 목적지로
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
|
||||||
|
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
|
||||||
|
date '+%H:%M:%S 주입'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
|
||||||
|
```
|
||||||
|
=== keycloak-1(수신측)으로 들어가는 7800/57800 만 DROP — kc-lab-1 에 넣는다 ===
|
||||||
|
주입: 12:33:58
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4-2. 이번엔 걸리는가 — 카운터가 유일한 판정 기준이다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -L PREROUTING -n -v
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
pkts bytes target prot opt in out source destination
|
||||||
|
0 0 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
|
||||||
|
19 2938 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **7800 규칙의 `pkts` 가 19.** 드디어 걸린다.
|
||||||
|
|
||||||
|
**57800 은 아직 0 인 것도 정보다.** FD_SOCK2 는 이미 붙어 있는 연결을 쓰고
|
||||||
|
있어서 새 연결을 시도하지 않았다. 조금 지나면 이쪽에도 숫자가 올라간다 —
|
||||||
|
|
||||||
|
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
|
||||||
|
```
|
||||||
|
=== 차단 규칙 누적 카운터 ===
|
||||||
|
19 1096 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:57800
|
||||||
|
21 3058 DROP 6 -- * * 0.0.0.0/0 10.42.0.42 tcp dpt:7800
|
||||||
|
```
|
||||||
|
|
||||||
|
> **카운터 판정표**
|
||||||
|
>
|
||||||
|
> | `pkts` | 뜻 | 할 일 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `0` | **아무것도 측정하지 않았다** | 해석 금지. 방향과 테이블을 다시 본다 |
|
||||||
|
> | 조금씩 는다 | 재연결 시도가 막히고 있다 | 관찰로 넘어간다 |
|
||||||
|
> | 폭증한다 | 대상이 너무 넓다 | `-d`·`--dport` 를 좁힌다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 효과를 관찰한다 — 단방향은 클러스터를 못 가른다
|
||||||
|
|
||||||
|
## 5-1. 파드와 외부
|
||||||
|
|
||||||
|
**확인** — 25초 간격
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측** — [`04-correct-direction.txt`](../../evidence/a5-asymmetric-partition/04-correct-direction.txt)
|
||||||
|
```
|
||||||
|
+25초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
+50초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
+75초 - | keycloak-0:1/1 keycloak-1:0/1 | 외부 200
|
||||||
|
+100초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
+125초 - | keycloak-0:1/1 keycloak-1:1/1 | 외부 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `+75초` 에 `keycloak-1` 이 **한 번 `0/1` 로
|
||||||
|
흔들렸다가 `+100초` 에 돌아온다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 주입이 **닿기는 했다**(2절의 아무 일 없음과 다르다).
|
||||||
|
그런데 **스스로 나았다.**
|
||||||
|
|
||||||
|
## 5-2. 뷰가 변했나 — 그리고 로그 시각의 함정
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m | grep ISPN000094
|
||||||
|
```
|
||||||
|
|
||||||
|
**여기서 시각을 비교하려다 대부분 한 번은 틀린다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
당신 셸의 date 12:33:58 KST
|
||||||
|
컨테이너 로그의 시각 03:33:58 ← 같은 순간이다. UTC 다
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keycloak 컨테이너는 UTC 로 찍는다.** KST 는 UTC+9 이므로 **9시간을 빼서**
|
||||||
|
맞춰 본다. 이걸 모르면 「주입 전 로그」와 「주입 후 로그」를 정반대로 가른다.
|
||||||
|
|
||||||
|
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||||
|
```
|
||||||
|
2026-09-04 03:33:49 | MergeView::[keycloak-0-24309(v=16.0.12)|13] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(v=16.0.12)],
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 뷰 `13`, 멤버 `(2)`, 그리고 **`MergeView`**.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것 — ★ 여기가 이 실험의 가장 미묘한 자리다.**
|
||||||
|
|
||||||
|
해설 문서는 처음에 「주입 이후 뷰 변화가 하나도 없었다」고 썼다. 맞는 말이다.
|
||||||
|
그런데 그 **「주입 전부터 그대로」의 「전」이 9초였다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
03:33:49 MergeView 로 뷰 13 이 만들어짐 ← 그 직전에는 |12] (1), 즉 분단 상태였다
|
||||||
|
03:33:58 내 주입 ← 9초 뒤
|
||||||
|
```
|
||||||
|
|
||||||
|
앞선 실패한 주입 시도들이 만든 흔들림이 막 봉합된 직후였던 것이다.
|
||||||
|
|
||||||
|
> **로그 한 줄만 보고 「변화 없음」이라고 말하면 안 된다.**
|
||||||
|
> **그 줄이 언제 생겼는지**를 함께 본다. `grep` 에 시각이 같이 나오는 형태를
|
||||||
|
> 쓰는 이유가 이것이다.
|
||||||
|
>
|
||||||
|
> 결론 자체(주입 이후 뷰가 변하지 않았다)는 유지된다. 다만 **기준선이 9초짜리
|
||||||
|
> 였다**는 사실은 함께 적어야 정직하다.
|
||||||
|
|
||||||
|
## 5-3. ★ 왜 안 갈라졌나 — 연결이 뒤집혔다
|
||||||
|
|
||||||
|
**확인** — 1-4 와 **똑같은 명령**을 다시 친다. 그게 대조의 방법이다
|
||||||
|
```bash
|
||||||
|
sudo conntrack -L 2>/dev/null | grep 7800
|
||||||
|
```
|
||||||
|
**실측** — [`05-reconnect-observed.txt`](../../evidence/a5-asymmetric-partition/05-reconnect-observed.txt)
|
||||||
|
```
|
||||||
|
tcp 6 299 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800 src=10.42.1.77 dst=10.42.0.42 sport=7800 dport=48473
|
||||||
|
tcp 6 86232 ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=44205 dport=57800 src=10.42.1.77 dst=10.42.0.42 sport=57800 dport=44205
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `src` 와 `dst` 를 1-4 와 나란히 놓는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
차단 전: src=10.42.1.77 → dst=10.42.0.42:7800 ← 내가 막은 방향
|
||||||
|
차단 후: src=10.42.0.42 → dst=10.42.1.77:7800 ← 열린 방향으로 다시 붙었다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **JGroups 는 막힌 연결이 죽자 반대 방향으로 새로
|
||||||
|
연결했다.** 그리고 FD_SOCK2 가 상대를 의심하기 전에 복구가 끝났다.
|
||||||
|
|
||||||
|
**확인** — 의심 카운터로 뒷받침한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_fd_sock2_get_num_suspected_members'
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events'
|
||||||
|
```
|
||||||
|
**실측** — [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||||
|
```
|
||||||
|
keycloak-0 merge_events=1.0 suspected=0.0
|
||||||
|
keycloak-1 merge_events=1.0 suspected=0.0
|
||||||
|
```
|
||||||
|
```
|
||||||
|
keycloak-0 cluster_size=2.0
|
||||||
|
keycloak-1 cluster_size=2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`suspected = 0`.** 아무도 상대를 의심하지 않았다 — **끊긴 적이 없는 것과
|
||||||
|
같다.** (`merge_events = 1` 은 5-2 의 9초 전 병합의 것이다.)
|
||||||
|
|
||||||
|
> **한 방향만 막는 것으로는 JGroups 를 가를 수 없다.**
|
||||||
|
> 두 노드는 서로에게 연결을 걸 수 있으므로, **한쪽 길이 막히면 다른 길로 간다.**
|
||||||
|
>
|
||||||
|
> 운영적으로는 좋은 소식이다 — **단방향 방화벽 오설정은 자가 치유된다.**
|
||||||
|
> 반대로 **분단을 재현하려는 실험자에게는 함정**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 양방향 차단 — 갈라지지만 전면 장애는 아니다
|
||||||
|
|
||||||
|
## 6-1. 반대 노드에도 넣는다
|
||||||
|
|
||||||
|
**되돌리기** — 두 줄이다. 이제 양쪽에 있다
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -F PREROUTING
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — `kc-lab-1` 의 규칙은 그대로 두고, `kc-lab-2` 에 반대 방향을 추가
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
|
||||||
|
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
|
||||||
|
date '+%H:%M:%S 주입'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 양쪽 카운터를 다 본다. **한쪽만 걸리면 그건 여전히 단방향이다**
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -L PREROUTING -n -v
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`07-bidirectional-block.txt`](../../evidence/a5-asymmetric-partition/07-bidirectional-block.txt)
|
||||||
|
```
|
||||||
|
=== 양방향 차단 — 두 노드 모두에 raw DROP ===
|
||||||
|
주입: 12:40:25
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6-2. 이번에는 갈라진다
|
||||||
|
|
||||||
|
**확인** — 25초 간격
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o custom-columns=ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
+75초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
|
||||||
|
+100초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||||
|
+125초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||||
|
...
|
||||||
|
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지가 한 줄에 있다.
|
||||||
|
|
||||||
|
- `keycloak-1` 이 **`0/1` 로 내려가서 안 돌아온다** (5-1 과 다르다)
|
||||||
|
- ready 주소가 **둘에서 하나로** 줄었다
|
||||||
|
- **외부는 계속 `200`**
|
||||||
|
|
||||||
|
> `kubectl get endpoints` 는 v1.33 부터 deprecated 라 경고가 뜬다.
|
||||||
|
> `endpointslice` 를 본다.
|
||||||
|
|
||||||
|
**확인** — 뷰
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
keycloak-0 [keycloak-0-24309(v=16.0.12)|14] (1) [keycloak-0-24309(v=16.0.12)]
|
||||||
|
keycloak-1 [keycloak-1-45480(v=16.0.12)|14] (1) [keycloak-1-45480(v=16.0.12)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**뷰 ID 는 둘 다 14 인데 멤버는 각자 1 명이다.** 같은 번호의 다른 세계다.
|
||||||
|
|
||||||
|
## 6-3. split brain 을 DB 한 줄로 확인한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select name, ip, coord from jgroups_ping order by name"
|
||||||
|
```
|
||||||
|
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||||
|
```
|
||||||
|
name | ip | coord
|
||||||
|
------------------+-----------------+-------
|
||||||
|
keycloak-0-24309 | 10.42.1.77:7800 | t
|
||||||
|
keycloak-1-45480 | 10.42.0.42:7800 | t
|
||||||
|
(2 rows)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`coord = t` 가 둘.** 1-2 에서 하나였던 것과 대조한다.
|
||||||
|
|
||||||
|
**분단을 확인하는 가장 짧은 명령이 이것이다.** 로그를 두 번 긁는 것보다 빠르고,
|
||||||
|
지표보다 정확하다.
|
||||||
|
|
||||||
|
## 6-4. ★ 그런데 한쪽만 DOWN 이다
|
||||||
|
|
||||||
|
Keycloak 컨테이너에 `curl` 이 없으므로 **임시 파드에서 묻는다.**
|
||||||
|
|
||||||
|
**하기** — 상주 파드를 띄운다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run a5-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --command -- sleep 1800
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a5-probe --timeout=120s
|
||||||
|
```
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a5-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> **왜 `--rm -it` 짜리 일회용 파드를 안 쓰나.** 원 실행이 그렇게 했다가
|
||||||
|
> 붙지 못했다. — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||||
|
> ```
|
||||||
|
> warning: couldn't attach to pod/a5-h, falling back to streaming logs: Internal error occurred: error attaching to container: container is in CONTAINER_EXITED state
|
||||||
|
> ```
|
||||||
|
> 파드가 만들어지고 **명령이 끝나 버리기 전에** 붙어야 하는 경주가 된다.
|
||||||
|
> 관찰을 여러 번 반복할 것이라면 **상주 파드가 항상 낫다.**
|
||||||
|
|
||||||
|
**확인** — 양쪽 헬스체크
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K0:9000/health/ready"
|
||||||
|
sudo kubectl -n keycloak-lab exec a5-probe -- curl -s "http://$K1:9000/health/ready"
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
--- keycloak-0 ---
|
||||||
|
{"status":"UP","checks":[{"name":"GracefulShutdown","status":"UP"}
|
||||||
|
{"name":"KeycloakInitialized","status":"UP"}
|
||||||
|
{"name":"Keycloakclusterhealthcheck","status":"UP"}
|
||||||
|
|
||||||
|
--- keycloak-1 ---
|
||||||
|
{"status":"DOWN","checks":[{"name":"GracefulShutdown","status":"UP"}
|
||||||
|
{"name":"Keycloakdatabaseconnectionsasynchealthcheck","status":"UP"}
|
||||||
|
{"name":"KeycloakInitialized","status":"UP"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 맨 앞의 `"status"`. **`keycloak-0` 은 UP, `keycloak-1`
|
||||||
|
은 DOWN.** 그리고 `keycloak-1` 쪽에서 **DB 체크는 UP** 인 것도 본다 —
|
||||||
|
DB 때문이 아니라 클러스터 때문이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것 — A-1 의 열린 질문에 대한 답이다.**
|
||||||
|
|
||||||
|
| | keycloak-0 | keycloak-1 |
|
||||||
|
|---|---|---|
|
||||||
|
| 분단 전 역할 | **코디네이터** (뷰 13 의 발행자) | 일반 멤버 |
|
||||||
|
| 분단 후 자기 인식 | 「멤버가 하나 나갔다」 — **정상 사건** | 「코디네이터를 잃었다」 — **비정상** |
|
||||||
|
| 헬스체크 | **UP** | **DOWN** |
|
||||||
|
| Service 엔드포인트 | **남는다** | 빠진다 |
|
||||||
|
|
||||||
|
**Keycloak 의 클러스터 헬스체크는 비대칭이다.** 코디네이터였던 쪽은 자기가
|
||||||
|
정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 **완전 분단조차 용량 저하로
|
||||||
|
끝나고 전면 장애가 되지 않는다.**
|
||||||
|
|
||||||
|
> **A-2(DB 상실)에서는 양쪽이 동시에 DOWN 이었다.** 차이는 이것이다 —
|
||||||
|
> **DB 는 모두가 의존하는 하나지만, 클러스터 멤버십은 서로 상대적이다.**
|
||||||
|
|
||||||
|
Grafana 에서 같은 것을 그림으로 본다 —
|
||||||
|
[`a5-cluster-size-bidirectional-block.png`](../../evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. 복구
|
||||||
|
|
||||||
|
## 7-1. 지운다
|
||||||
|
|
||||||
|
**확인** — 지우기 전에 무엇이 있는지 본다
|
||||||
|
```bash
|
||||||
|
sudo iptables -t raw -L PREROUTING -n -v --line-numbers
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v --line-numbers'
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 해제'
|
||||||
|
sudo iptables -t raw -F PREROUTING
|
||||||
|
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
|
||||||
|
```
|
||||||
|
**실측** — [`08-coordinator-and-recovery.txt`](../../evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt)
|
||||||
|
```
|
||||||
|
=== 차단 해제 ===
|
||||||
|
해제: 12:44:37
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7-2. 자동으로 다시 붙는지 본다
|
||||||
|
|
||||||
|
**확인** — 25초 간격
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
+25초 keycloak-0:1/1 keycloak-1:0/1
|
||||||
|
+50초 keycloak-0:1/1 keycloak-1:1/1
|
||||||
|
→ 복구 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
**50초. 사람 개입 없음.**
|
||||||
|
|
||||||
|
## 7-3. 누가 붙였나 — `MergeView`
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep MergeView | tail -1
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 | grep MergeView | tail -1
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
keycloak-0 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
|
||||||
|
keycloak-1 MergeView::[keycloak-0-24309(v=16.0.12)|15] (2) [keycloak-0-24309(v=16.0.12), keycloak-1-45480(
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 뷰 ID 가 **15**, 멤버 `(2)`, **양쪽이 같은 줄.**
|
||||||
|
|
||||||
|
```
|
||||||
|
[keycloak-0-24309|13] (2) ← 정상
|
||||||
|
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
|
||||||
|
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**뷰 ID 는 단조 증가**하므로 「언제 몇 번 갈라졌는지」를 로그만으로 셀 수 있다.
|
||||||
|
|
||||||
|
## 7-4. 캐시별 재분배 로그
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN100007 | tail -6
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 4절 ·
|
||||||
|
원문은 [`06-view-history-and-cleanup.txt`](../../evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
|
||||||
|
```
|
||||||
|
[Context=work] ISPN100007: After merge (or coordinator change) ...
|
||||||
|
[Context=clientSessions] ISPN100007: After merge ...
|
||||||
|
[Context=offlineSessions] ISPN100007: After merge ...
|
||||||
|
[Context=loginFailures] ISPN100007: After merge ...
|
||||||
|
[Context=actionTokens] ISPN100007: After merge ...
|
||||||
|
```
|
||||||
|
|
||||||
|
증거 파일의 원문은 한 줄이 길어 잘려 있다. 그 형태도 한 번 본다.
|
||||||
|
```
|
||||||
|
2026-09-04 03:32:59,874 INFO [org.infinispan.CLUSTER] (non-blocking-thread--p2-t2) [Context=work] ISPN100007: After merge (or coo
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ISPN100007` 은 병합(또는 코디네이터 변경) 후 캐시별 토폴로지 재계산**이다.
|
||||||
|
**캐시가 여럿이므로 로그도 캐시 수만큼 나온다.** 한 줄만 보고 「한 번
|
||||||
|
재분배됐다」고 세면 틀린다 — `work`·`clientSessions`·`offlineSessions`·
|
||||||
|
`loginFailures`·`actionTokens` 가 각각 찍는다.
|
||||||
|
|
||||||
|
## 7-5. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| raw 규칙 | `sudo iptables -t raw -S PREROUTING` | `-P PREROUTING ACCEPT` 만 |
|
||||||
|
| filter 규칙 | `sudo iptables -S FORWARD \| head -5` | 내가 넣은 `DROP` 이 없음 |
|
||||||
|
| (반대 노드) | `ssh kc-lab-2 'sudo iptables -t raw -S PREROUTING'` | 같음 |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | `keycloak` 둘 다 `1/1 Running` |
|
||||||
|
| 디스커버리 | 6-3 의 psql | **`coord = t` 가 하나** |
|
||||||
|
| 뷰 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)`, 양쪽 동일 |
|
||||||
|
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
|
||||||
|
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a5-probe` | 지웠으면 `NotFound` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
conntrack 은 건드리지 않아도 된다. **차단이 풀리면 새 연결이 스스로 성립한다.**
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것** — 분단 중에 **세션이 어떻게 되는지**는 재지 않았다
|
||||||
|
> (그건 A-1 4-5 의 주제다). 여기서는 **누가 살아남는가**만 봤다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 규칙을 넣었는데 아무 일도 없다 | **카운터가 0 이면 아무것도 측정 안 된 것** | `iptables -L -n -v` 의 `pkts` — 4-2 |
|
||||||
|
| 내 규칙이 1번이 아니다 | **kube-router 가 자기 체인을 재삽입한다** | `--line-numbers` 로 순서 — 2-2 |
|
||||||
|
| `raw` 인데도 0 패킷 | **연결 방향을 잘못 짚었다** | `conntrack -L \| grep 7800` — 3-3 |
|
||||||
|
| conntrack 에 아무것도 안 보인다 | 반대 노드에서 봤다 | **두 노드 모두에서** 본다 — 1-4 |
|
||||||
|
| 단방향인데 안 갈라진다 | **정상이다. 열린 방향으로 재연결한다** | `conntrack` 의 `src`/`dst` 뒤집힘 — 5-3 |
|
||||||
|
| 로그에 변화가 없어 보인다 | **컨테이너 로그는 UTC.** KST 와 9시간 차 | `logs` 의 시각에서 9를 뺀다 — 5-2 |
|
||||||
|
| 「주입 전부터 그대로」인데 미심쩍다 | 그 「전」이 9초일 수 있다 | 앞 뷰가 **언제** 생겼는지 본다 — 5-2 |
|
||||||
|
| `-D` 로 규칙이 안 지워진다 | 넣을 때와 인자가 다르다 | 줄 번호로 지운다: `-D FORWARD 3` |
|
||||||
|
| 임시 파드에 attach 실패 | `--rm -it` 는 경주가 된다 | **상주 파드**를 쓴다 — 6-4 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **이미지에 curl 도 wget 도 없다** | 탐침 파드나 Prometheus |
|
||||||
|
| 지표를 파이썬으로 자르다 죽었다 | **원본까지 같이 사라진다** | `wget` 원문을 먼저 본다 — 1-3 |
|
||||||
|
| `get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||||
|
| 57800 카운터만 0 이다 | FD_SOCK2 가 아직 재연결을 안 했다 | 조금 기다렸다 다시 본다 — 4-2 |
|
||||||
|
| 해제했는데 2~3분째 안 붙는다 | 반대 노드 규칙이 남아 있다 | **두 노드 모두** `-t raw -S PREROUTING` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 실험자를 위한 한 장 요약
|
||||||
|
|
||||||
|
| 상황 | 확인 방법 |
|
||||||
|
|---|---|
|
||||||
|
| 규칙을 넣었는데 안 걸린다 | `iptables -L -n -v` 의 **패킷 카운터** |
|
||||||
|
| 방향을 모르겠다 | `sudo conntrack -L 2>/dev/null \| grep 7800` — `dport` 쪽이 서버 |
|
||||||
|
| CNI 가 밀어낸다 | **`raw` 테이블**을 쓴다 |
|
||||||
|
| 갈라졌는지 알고 싶다 | `JGROUPS_PING.coord`, `ISPN000094`, `vendor_cluster_size` |
|
||||||
|
| 누가 살아남을지 알고 싶다 | **분단 전 코디네이터가 누구였는가** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-5 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [A-6](../../experiment-a6-latency-injection.md) 지연 주입 | **`tc netem` 도 똑같은 함정.** 인터페이스를 잘못 고르면 카운터가 0 이다 |
|
||||||
|
| [A-7](../../experiment-a7-volatile-comparison.md) volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 **세션이 갈라진다** |
|
||||||
|
| [A-1](a1-jgroups-transport-block.md) 로 되돌아가서 | 분단 중 **로그아웃이 전파되지 않는다.** 그 상태를 여기서 다시 만들 수 있다 |
|
||||||
|
| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
# A-6 재현 가이드 — 끊지 않고 200ms 만 넣어 22초를 만든다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a6-latency-injection.md`](../../experiment-a6-latency-injection.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a6-latency-injection/`](../../evidence/a6-latency-injection/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| `eth0` 이라는 인터페이스가 **없다**는 것 | `ip -brief link` |
|
||||||
|
| 스크립트가 **「적용완료」를 찍었는데 아무것도 안 걸린 것** | `tc -s qdisc` 카운터 |
|
||||||
|
| `enp1s0` 에서는 **파드 IP 가 안 보이는** 것 | VXLAN 캡슐화 |
|
||||||
|
| 200ms 가 **1,872ms** 가 되는 것 | 두 노드 응답 시간 비교 |
|
||||||
|
| 동시 20건이 **22.2초**까지 계단으로 늘어나는 것 | 상주 탐침이 모은 파일 |
|
||||||
|
| 커넥션 획득에 **20초**를 기다린 요청 | `agroal_blocking_time_max_milliseconds` |
|
||||||
|
| **readiness 프로브가 같은 줄에 서서** 타임아웃되는 것 | `kubectl get events` |
|
||||||
|
| 예측했던 낙관적 락 충돌이 **0건**인 것 | Keycloak 로그 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- [`A-5`](a5-asymmetric-partition.md) 를 먼저 해 두면 좋다. **「주입을 넣은 것과
|
||||||
|
걸린 것은 다르다」가 여기서 세 번째로 나온다.**
|
||||||
|
- `kubectl` 은 **`kc-lab-1` 에서 `sudo`** 로 친다.
|
||||||
|
- `tc` 는 **`kc-lab-2` 에서** 친다(`ssh kc-lab-2`). postgres 가 그 노드에 있다.
|
||||||
|
- 터미널 두 개면 편하다. 하나는 부하·측정, 하나는 이벤트 관찰.
|
||||||
|
|
||||||
|
## 주의 — 이건 상태를 부수는 실험이다
|
||||||
|
|
||||||
|
Keycloak 한 대를 **느려지게** 만든다. 파드가 재시작될 수 있고 readiness 가
|
||||||
|
빠진다. **실험대에서만 한다.** 전 구간 약 30분이다.
|
||||||
|
|
||||||
|
중간에 그만두려면 한 줄이면 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 13:10–13:35 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
IP·파드 이름·인터페이스 이름은 **당신 환경에서 다를 수 있다.** 자리표시자
|
||||||
|
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
A-2 는 DB 를 **완전히** 세웠고, A-4 는 기계를 **통째로** 껐다. 둘 다 즉시
|
||||||
|
드러났다. `503` 이 나오고 `up` 이 0 이 됐다.
|
||||||
|
|
||||||
|
**실제 장애의 대부분은 그렇지 않다. 느려지기만 한다.** 그리고 느려짐은
|
||||||
|
사망보다 **진단하기 어렵다 — 헬스체크가 통과하기 때문이다.**
|
||||||
|
|
||||||
|
이 실험이 묻는 것은 하나다.
|
||||||
|
|
||||||
|
```
|
||||||
|
200밀리초를 넣으면 애플리케이션은 200밀리초 느려지는가?
|
||||||
|
```
|
||||||
|
|
||||||
|
답은 **아니다.** 두 군데에서 곱해진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 설계 — 왜 이 배치가 그대로 A/B 실험이 되는가
|
||||||
|
|
||||||
|
## 1-1. 무엇이 어느 노드에 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||||
|
```
|
||||||
|
postgres 10.42.1.76 (kc-lab-2)
|
||||||
|
keycloak-0 10.42.1.77 (kc-lab-2) → DB 와 같은 노드, cni0 로 직행
|
||||||
|
keycloak-1 10.42.0.42 (kc-lab-1) → DB 와 다른 노드, VXLAN 을 건넌다 ← 여기에 지연을 건다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **postgres 와 `keycloak-0` 이 같은 노드**인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-2 kc-lab-1
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ postgres │ │ keycloak-1 │
|
||||||
|
│ keycloak-0 │ │ │
|
||||||
|
│ └─ cni0 로 직행 │◀─ VXLAN ──▶│ └─ 오버레이 경유 │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
지연 없음 여기만 느려진다
|
||||||
|
```
|
||||||
|
|
||||||
|
**postgres 가 보내는 패킷 중 노드를 건너가는 것만** 지연시키면
|
||||||
|
`keycloak-1` 의 DB 접근만 느려지고 `keycloak-0` 은 그대로다.
|
||||||
|
**대조군이 같은 실험 안에 있다.** 파드를 두 개 더 띄울 필요도, 다른 시간대와
|
||||||
|
비교할 필요도 없다.
|
||||||
|
|
||||||
|
> **배치가 다르면 이 실험은 성립하지 않는다.** 두 Keycloak 이 모두 DB 와 다른
|
||||||
|
> 노드에 있으면 대조군이 없고, 모두 같은 노드에 있으면 시험군이 없다.
|
||||||
|
> 먼저 확인한다.
|
||||||
|
|
||||||
|
변수로 잡아 둔다.
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
PG=$(sudo kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].status.podIP}')
|
||||||
|
echo "K0=$K0 K1=$K1 PG=$PG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-2. 상주 탐침 파드를 먼저 띄운다
|
||||||
|
|
||||||
|
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 그리고 이 실험은
|
||||||
|
**같은 요청을 수십 번 반복**해야 하므로 파드를 매번 만들면 안 된다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="K0=$K0" --env="K1=$K1" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 1800
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 값이 들어갔나. **비밀번호는 길이만 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW=${#PW}자"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
K0=10.42.1.77 K1=10.42.0.42 PW=32자
|
||||||
|
```
|
||||||
|
|
||||||
|
`PW=0자` 면 시크릿이 안 넘어간 것이다. 그 상태로 재면 **전부 401 을 재게 된다.**
|
||||||
|
|
||||||
|
> **★ `kubectl run --rm -i` 로 부하를 주면 안 된다.**
|
||||||
|
> 원 실행이 그렇게 했다가 **동시 20건의 출력을 잃었다.** 파드가 만들어지고
|
||||||
|
> 지워지는 사이에 stdout 을 붙잡는 경주가 되고, 20줄 중 일부만 도착하거나
|
||||||
|
> 아예 끊긴다. **결과는 파드 안 파일에 모으고 끝나면 한 번에 꺼낸다.**
|
||||||
|
> 이 가이드의 모든 부하 명령이 그 형태다.
|
||||||
|
|
||||||
|
> **탐침의 `K0`/`K1` 은 만들 때 고정된다.** Keycloak 파드가 재시작되면 IP 가
|
||||||
|
> 바뀌고 탐침의 값은 낡는다. 그때는 탐침을 지우고 다시 만든다.
|
||||||
|
> 이걸 놓치면 **「아무 데도 안 닿음」을 「지연」으로 착각한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 기준선 — 주입 전에 같은 명령으로 먼저 잰다
|
||||||
|
|
||||||
|
## 2-1. 요청 하나를 눈으로 본다
|
||||||
|
|
||||||
|
먼저 **읽는 형태**로 한 번 친다. 시간이 어디서 드는지 봐야 나중에 무엇이
|
||||||
|
변했는지 안다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||||
|
curl -s -o /dev/null \
|
||||||
|
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
|
||||||
|
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
connect 0.001 ttfb 0.065 total 0.066
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `connect` 와 `ttfb` 의 차이.
|
||||||
|
|
||||||
|
| 값 | 무엇의 시간인가 |
|
||||||
|
|---|---|
|
||||||
|
| `time_connect` | 탐침 → Keycloak **TCP 연결**. 이 실험에서 **거의 안 변한다** |
|
||||||
|
| `time_starttransfer` | 첫 바이트까지 = **Keycloak 이 DB 와 대화한 시간**. 여기가 폭발한다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 지연은 **탐침과 Keycloak 사이**가 아니라
|
||||||
|
**Keycloak 과 DB 사이**에 넣는다. 그래서 `connect` 는 그대로고 `ttfb` 만 는다.
|
||||||
|
주입 후에 이 두 값을 다시 보면 **어디에 지연이 걸렸는지 한눈에 판정된다.**
|
||||||
|
|
||||||
|
응답이 `401` 이나 `400` 이면 `-o /dev/null` 을 빼고 본문을 본다.
|
||||||
|
|
||||||
|
## 2-2. 반복해서 평균을 낸다
|
||||||
|
|
||||||
|
**확인** — 20회, 원본을 파일에 모은다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||||
|
rm -f /tmp/base-k1 ; i=0
|
||||||
|
while [ $i -lt 20 ]; do
|
||||||
|
curl -s -o /dev/null -w "%{time_total}\n" \
|
||||||
|
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
|
||||||
|
>> /tmp/base-k1
|
||||||
|
i=$((i+1))
|
||||||
|
done'
|
||||||
|
```
|
||||||
|
**원본을 먼저 본다.** 평균만 보면 한 건이 튄 것을 놓친다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1
|
||||||
|
```
|
||||||
|
그 다음 줄여서 본다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/base-k1 \
|
||||||
|
| awk '{s+=$1} END {printf "%d회 평균 %.0f ms\n", NR, s*1000/NR}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`$K1` 을 `$K0` 로 바꿔 **대조군도 똑같이** 잰다.
|
||||||
|
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== 기준선 지연 — 각 노드에서 로그인 20회 ===
|
||||||
|
keycloak-0 평균 70 ms
|
||||||
|
keycloak-1 평균 66 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 값이 **비슷한 것**. 지금 `keycloak-1` 이 오히려
|
||||||
|
4ms 빠르다. **VXLAN 을 건너는 쪽이 더 빠를 수도 있는 수준의 차이**이며,
|
||||||
|
그래서 뒤에 나올 28배가 의심의 여지 없이 주입 탓이 된다.
|
||||||
|
|
||||||
|
> **횟수를 주입 전후로 똑같이 맞춘다.** 기준선은 20회로 쟀고, 해설 문서의
|
||||||
|
> 재현 절차에는 15회로 적혀 있다. 횟수가 다르면 평균도 달라진다.
|
||||||
|
> **비교할 두 값은 같은 명령으로 만든다.**
|
||||||
|
|
||||||
|
## 2-3. 커넥션 풀 지표에 무엇이 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
|
||||||
|
'curl -s "http://$K1:9000/metrics" | grep "^agroal_"'
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/a6-latency-injection/01-baseline.txt)
|
||||||
|
```
|
||||||
|
agroal_acquire_count_total
|
||||||
|
agroal_active_count
|
||||||
|
agroal_available_count
|
||||||
|
agroal_awaiting_count
|
||||||
|
agroal_blocking_time_average_milliseconds
|
||||||
|
agroal_blocking_time_max_milliseconds
|
||||||
|
agroal_blocking_time_total_milliseconds
|
||||||
|
agroal_creation_count_total
|
||||||
|
agroal_creation_time_average_milliseconds
|
||||||
|
agroal_creation_time_max_milliseconds
|
||||||
|
agroal_creation_time_total_milliseconds
|
||||||
|
agroal_destroy_count_total
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `agroal_*` 이 **JDBC 커넥션 풀** 지표다
|
||||||
|
(Agroal 은 Quarkus 의 풀 구현이다). 이 실험의 핵심 증거가 여기서 나온다.
|
||||||
|
|
||||||
|
> **당신 출력은 이보다 길 것이다.** 위 목록은 알파벳순으로 `destroy_count_total`
|
||||||
|
> 에서 끊겨 있다 — 원 실행이 앞부분만 남긴 것이다. 실제로는 뒤에
|
||||||
|
> `agroal_max_used_count` 같은 것이 더 있고, 6-4 에서 그 값을 쓴다.
|
||||||
|
> **증거 파일이 짧다고 지표가 없는 것이 아니다.**
|
||||||
|
|
||||||
|
| 지표 | 무엇을 말하는가 |
|
||||||
|
|---|---|
|
||||||
|
| `blocking_time_max` | **커넥션을 받으려고 가장 오래 기다린 시간** |
|
||||||
|
| `max_used_count` | 풀이 최대 몇 개까지 늘었나 |
|
||||||
|
| `awaiting_count` | **지금** 줄 서 있는 요청 수 |
|
||||||
|
| `active_count` | **지금** 쓰이고 있는 커넥션 수 |
|
||||||
|
|
||||||
|
**`awaiting_count` 와 `active_count` 는 순간값이다.** 부하가 끝나면 0 으로
|
||||||
|
돌아간다 — **부하 중에 읽어야 보인다.** `blocking_time_max` 는 누적이라
|
||||||
|
나중에 읽어도 남아 있다.
|
||||||
|
|
||||||
|
지금 값을 적어 둔다. 나중에 오른 것을 보려면 지금 값이 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입 시도 ① — `eth0` (그런 인터페이스가 없다)
|
||||||
|
|
||||||
|
**일부러 실패하는 단계다.** 이 실패의 모양이 이 실험이 남긴 가장 실용적인
|
||||||
|
교훈이다.
|
||||||
|
|
||||||
|
## 3-1. 넣어 본다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc qdisc add dev eth0 root handle 1: prio'
|
||||||
|
```
|
||||||
|
**실측** — [`02-delay-injected.txt`](../../evidence/a6-latency-injection/02-delay-injected.txt)
|
||||||
|
```
|
||||||
|
Cannot find device "eth0"
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄이면 끝날 일이다. **그런데 원 실행은 이걸 스크립트로 돌렸다.**
|
||||||
|
|
||||||
|
**실측** — 같은 파일, 원문 그대로
|
||||||
|
```
|
||||||
|
=== 주입: postgres(10.42.1.76) 가 보내는 패킷만 200ms 지연 (kc-lab-2 eth0) ===
|
||||||
|
prio qdisc 로 밴드를 나누고, u32 필터로 출발지 IP 가 postgres 인 것만 3번 밴드로 보낸다
|
||||||
|
Cannot find device "eth0"
|
||||||
|
Cannot find device "eth0"
|
||||||
|
적용완료
|
||||||
|
Cannot find device "eth0"
|
||||||
|
Cannot find device "eth0"
|
||||||
|
주입: 13:14:55
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`적용완료` 가 에러 사이에 끼어 있다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **「적용완료」는 스크립트가 찍은 글자이지 커널이
|
||||||
|
한 말이 아니다.** `tc` 는 네 번 다 실패했는데 스크립트는 그대로 다음 절로
|
||||||
|
넘어갔고, 문서에는 시각까지 찍혔다.
|
||||||
|
|
||||||
|
> **명령의 성공을 「에러가 안 보인다」로 판정하면 안 된다.**
|
||||||
|
> 에러는 보였는데 그 사이에 성공 메시지가 있었을 뿐이다.
|
||||||
|
> 손으로 한 줄씩 치면 이 실수를 할 수 없다 — **이 가이드에 스크립트가 없는
|
||||||
|
> 이유다.**
|
||||||
|
|
||||||
|
그리고 그 상태에서 잰 「검증」이 이랬다.
|
||||||
|
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
=== [검증] 지연이 실제로 걸렸는가 — 두 노드 비교 ===
|
||||||
|
keycloak-0 평균 43 ms 최대 64 ms
|
||||||
|
keycloak-1 평균 47 ms 최대 70 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 노드가 여전히 같다. 이것이 「안 걸렸다」는 신호였다.** 검증 절이 값을
|
||||||
|
찍기만 하고 **판정하지 않으면** 이렇게 그냥 지나간다.
|
||||||
|
|
||||||
|
## 3-2. 인터페이스 이름을 확인한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'ip -brief link'
|
||||||
|
```
|
||||||
|
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
|
||||||
|
```
|
||||||
|
flannel.1 UNKNOWN a6:b2:62:04:c1:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
|
||||||
|
cni0 UP 5a:77:1a:e2:b0:a4 <BROADCAST,MULTICAST,UP,LOWER_UP>
|
||||||
|
```
|
||||||
|
게스트의 물리 인터페이스는 `enp1s0` 이다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`eth0` 이 없다.**
|
||||||
|
|
||||||
|
| 이름 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `enp1s0` | **게스트의 물리(가상) NIC.** 노드 간 실제 트래픽이 나가는 곳 |
|
||||||
|
| `flannel.1` | **VXLAN 터널.** 노드를 건너는 파드 트래픽이 여기로 들어간다 |
|
||||||
|
| `cni0` | **노드 안 브리지.** 같은 노드 파드끼리는 여기서 끝난다 |
|
||||||
|
|
||||||
|
Debian 클라우드 이미지는 **예측 가능한 인터페이스 이름**을 쓴다.
|
||||||
|
|
||||||
|
```
|
||||||
|
enp1s0
|
||||||
|
│ │ └─ s0 : slot 0
|
||||||
|
│ └──── p1 : PCI bus 1
|
||||||
|
└────── en : ethernet
|
||||||
|
```
|
||||||
|
|
||||||
|
이름이 **하드웨어 위치에서** 나오므로 NIC 순서가 바뀌어도 이름이 안 바뀐다.
|
||||||
|
그 대신 `eth0` 이라고 적힌 인터넷의 모든 예제가 안 돈다.
|
||||||
|
|
||||||
|
> `flannel.1` 의 상태가 `UNKNOWN` 인 것은 정상이다. 터널 장치는 캐리어 개념이
|
||||||
|
> 없어서 `UP` 대신 `UNKNOWN` 으로 보고한다. **고장이 아니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 주입 시도 ② — `enp1s0` (파드 IP 가 안 보인다)
|
||||||
|
|
||||||
|
`eth0` 을 `enp1s0` 으로 고치면 될 것 같다. **안 된다.** 이유가 이 실험의 핵심
|
||||||
|
개념이다.
|
||||||
|
|
||||||
|
## 4-1. 무엇이 문제인가
|
||||||
|
|
||||||
|
노드 간 파드 통신은 **flannel VXLAN 으로 캡슐화**된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
원래 패킷: src=10.42.1.76(postgres) dst=10.42.0.42(keycloak-1)
|
||||||
|
│
|
||||||
|
▼ flannel.1 에서 캡슐화
|
||||||
|
실제 패킷: src=192.168.122.12(노드) dst=192.168.122.11(노드) UDP 8472
|
||||||
|
└─ 안쪽에 원래 패킷이 통째로 들어 있다
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
enp1s0 로 나간다
|
||||||
|
```
|
||||||
|
|
||||||
|
**`enp1s0` 에서 `match ip src 10.42.1.76` 은 절대 일치하지 않는다.**
|
||||||
|
그 IP 는 페이로드 안에 있고, 헤더에는 노드 IP 만 있다.
|
||||||
|
|
||||||
|
## 4-2. 눈으로 확인한다
|
||||||
|
|
||||||
|
**확인** — 실제로 무엇이 나가는지 본다. **미검증**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tcpdump -i enp1s0 -n -c 5 udp port 8472'
|
||||||
|
```
|
||||||
|
노드 IP 사이의 UDP 8472 만 보이고 `10.42.x.x` 는 안 보인다.
|
||||||
|
|
||||||
|
같은 시간에 터널 쪽을 보면 파드 IP 가 보인다. **미검증**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tcpdump -i flannel.1 -n -c 5 host 10.42.1.76'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **원 실행에는 이 확인이 없다.** `eth0` 실패 뒤 곧바로 `flannel.1` 로 갔다.
|
||||||
|
> 그래서 「`enp1s0` 에 걸면 0 패킷」이라는 **출력 원문은 이 실험에 없다** —
|
||||||
|
> 구조에서 나온 결론이다. 당신이 직접 보고 싶으면 위 `tcpdump` 두 줄이면 된다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **오버레이 네트워크에서는 「어느 인터페이스에
|
||||||
|
거는가」가 「무엇을 볼 수 있는가」를 정한다.**
|
||||||
|
|
||||||
|
| 인터페이스 | 파드 IP 가 보이나 | 무엇을 지연시키게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| `cni0` | 보인다 | **같은 노드 안** 통신만 |
|
||||||
|
| **`flannel.1`** | **보인다 (캡슐화 직전)** | **노드를 건너는** 파드 통신 |
|
||||||
|
| `enp1s0` | **안 보인다** | 노드 간 **모든** 것 (SSH·k3s 포함) |
|
||||||
|
|
||||||
|
`enp1s0` 에 `netem` 을 root 로 걸면 **`kubectl` 도 SSH 도 같이 느려진다.**
|
||||||
|
그러면 무엇이 원인인지 못 가린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 주입 성공 — `flannel.1` 에 건다
|
||||||
|
|
||||||
|
## 5-1. 거는 순서
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다. 이 한 줄이 세 가지를 다 지운다
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 root handle 1: prio"
|
||||||
|
ssh kc-lab-2 "sudo tc qdisc add dev flannel.1 parent 1:3 handle 30: netem delay 200ms"
|
||||||
|
ssh kc-lab-2 "sudo tc filter add dev flannel.1 protocol ip parent 1:0 prio 3 \
|
||||||
|
u32 match ip src $PG/32 flowid 1:3"
|
||||||
|
date '+%H:%M:%S 주입'
|
||||||
|
```
|
||||||
|
|
||||||
|
**한 줄씩 친다.** 앞 줄이 실패하면 뒤 줄은 붙을 곳이 없어서 다른 에러를 낸다.
|
||||||
|
|
||||||
|
## 5-2. 개념 — `tc` 의 계층 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
qdisc (큐 규율) 인터페이스에 붙는 패킷 스케줄러
|
||||||
|
├─ prio 우선순위 밴드 3개로 나눈다
|
||||||
|
│ ├─ 1:1 (기본)
|
||||||
|
│ ├─ 1:2 (기본)
|
||||||
|
│ └─ 1:3 ← 여기에 netem 을 붙인다
|
||||||
|
└─ filter 어떤 패킷을 어느 밴드로 보낼지
|
||||||
|
```
|
||||||
|
|
||||||
|
**`netem` 을 root 에 바로 붙이면 모든 트래픽이 느려진다.**
|
||||||
|
`prio` + `filter` 를 쓰면 **고른 트래픽만** 느려진다. 이 실험은
|
||||||
|
**postgres 가 보내는 것만** 골라야 하므로 세 단계가 필요하다.
|
||||||
|
|
||||||
|
세 줄이 하는 일을 나눠 읽으면 이렇다.
|
||||||
|
|
||||||
|
| 줄 | 하는 일 |
|
||||||
|
|---|---|
|
||||||
|
| `qdisc ... root handle 1: prio` | 밴드 3개짜리 분류기를 만든다 |
|
||||||
|
| `qdisc ... parent 1:3 handle 30: netem delay 200ms` | 3번 밴드에 **200ms 지연**을 붙인다 |
|
||||||
|
| `filter ... match ip src $PG/32 flowid 1:3` | **출발지가 postgres 인 패킷**을 3번 밴드로 보낸다 |
|
||||||
|
|
||||||
|
## 5-3. ★ 걸렸는지 카운터로 확인한다 — 그리고 0 을 오해하지 않는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1'
|
||||||
|
```
|
||||||
|
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt) · **넣은 직후**
|
||||||
|
```
|
||||||
|
qdisc prio 1: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
|
||||||
|
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
|
||||||
|
backlog 0b 0p requeues 0
|
||||||
|
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
|
||||||
|
Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)
|
||||||
|
backlog 0b 0p requeues 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Sent 0 pkt` 이다. 그런데 이건 실패가 아니다.**
|
||||||
|
|
||||||
|
A-5 에서 `pkts 0` 은 「규칙이 안 걸렸다」였다. **여기서는 다르다** —
|
||||||
|
아직 **아무 패킷도 지나가지 않았을 뿐**이다. postgres 는 요청이 있어야 답한다.
|
||||||
|
|
||||||
|
**하기** — 트래픽을 한 번 만든다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||||
|
curl -s -o /dev/null -w "%{time_total}\n" \
|
||||||
|
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 다시 센다
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
=== [검증] 필터에 패킷이 걸리는가 ===
|
||||||
|
qdisc netem 30: parent 1:3 limit 1000 delay 200ms
|
||||||
|
Sent 18388 bytes 150 pkt (dropped 0, overlimits 0 requeues 0)
|
||||||
|
backlog 0b 0p requeues 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`150 pkt`.** 실제로 지연 밴드를 통과했다.
|
||||||
|
|
||||||
|
> **판정표 — `netem` 의 `Sent`**
|
||||||
|
>
|
||||||
|
> | 상태 | 뜻 | 할 일 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | 부하 전 `0 pkt` | 아직 트래픽이 없다 | 요청을 한 번 보내고 다시 센다 |
|
||||||
|
> | **부하 후에도 `0 pkt`** | **필터가 아무것도 못 잡았다** | IP·인터페이스·방향을 다시 본다 |
|
||||||
|
> | `pkt` 이 는다 | 걸렸다 | 관찰로 넘어간다 |
|
||||||
|
> | `dropped` 가 는다 | `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
|
||||||
|
|
||||||
|
**A-1·A-5 와 같은 교훈이 세 번째로 나왔다. 주입을 넣은 것과 걸린 것은 다르다.**
|
||||||
|
|
||||||
|
필터 자체도 볼 수 있다. **미검증**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc filter show dev flannel.1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 6-1. 단일 요청 — 지연은 곱해진다
|
||||||
|
|
||||||
|
**확인** — 2-1 과 **똑같은 명령**을 다시 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||||
|
curl -s -o /dev/null \
|
||||||
|
-w "connect %{time_connect} ttfb %{time_starttransfer} total %{time_total}\n" \
|
||||||
|
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`connect` 는 그대로인데 `ttfb` 만 폭발**하는지 본다. 그러면 지연이
|
||||||
|
**의도한 구간에** 걸린 것이다.
|
||||||
|
|
||||||
|
그 다음 2-2 와 똑같이 반복해서 두 노드를 잰다.
|
||||||
|
|
||||||
|
**실측** — [`03-flannel-injection.txt`](../../evidence/a6-latency-injection/03-flannel-injection.txt)
|
||||||
|
```
|
||||||
|
=== 두 노드 지연 비교 (기준선: k0=70ms k1=66ms) ===
|
||||||
|
keycloak-0 평균 41 ms 최대 57 ms
|
||||||
|
keycloak-1 평균 1872 ms 최대 1887 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `keycloak-1` 이 **66 → 1,872 ms, 28배.**
|
||||||
|
|
||||||
|
> **★ 대조군도 변했다.** `keycloak-0` 은 기준선 70ms 에서 41ms 로 **41%
|
||||||
|
> 빨라졌다.** 주입과 무관한 변동(JIT 워밍업, 캐시)이며, 해설 문서가 처음에
|
||||||
|
> 「영향 없음」이라고 쓴 것은 **부정확했다.** 자릿수가 달라 결론은 유지되지만,
|
||||||
|
> **대조군이 안 변한다고 가정하면 안 된다.** 당신 실행에서도 볼 것이다.
|
||||||
|
|
||||||
|
## 6-2. 왜 200ms 가 1,872ms 가 되는가
|
||||||
|
|
||||||
|
A-0 에서 잡은 로그인 트랜잭션의 SQL 이 답이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
BEGIN
|
||||||
|
select ... from OFFLINE_USER_SESSION ...
|
||||||
|
select VERSION ... for no key update skip locked
|
||||||
|
select ... from OFFLINE_CLIENT_SESSION ...
|
||||||
|
select VERSION ... for no key update skip locked
|
||||||
|
insert into OFFLINE_USER_SESSION ...
|
||||||
|
insert into OFFLINE_CLIENT_SESSION ...
|
||||||
|
SET LOCAL synchronous_commit TO OFF
|
||||||
|
COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**왕복이 아홉 번이다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
200 ms × 9 왕복 ≈ 1,800 ms 실측 1,872 ms
|
||||||
|
```
|
||||||
|
|
||||||
|
> **★ `9` 는 SQL 목록을 센 것이고 패킷을 추적한 값이 아니다.** 자릿수가 맞는다는
|
||||||
|
> 것까지가 이 계산이 말할 수 있는 범위이며, **왕복 수를 확정하려면 `tc -s` 의
|
||||||
|
> 패킷 수를 요청 수로 나누거나 패킷 캡처가 필요하다.**
|
||||||
|
|
||||||
|
> **네트워크 지연은 왕복 횟수만큼 증폭된다.**
|
||||||
|
> 「DB 가 200ms 느려졌다」는 「애플리케이션이 200ms 느려졌다」가 아니다.
|
||||||
|
> **쿼리 수를 줄이는 것이 지연 환경에서 결정적인 이유**가 이것이다.
|
||||||
|
|
||||||
|
## 6-3. 동시 부하 — 여기서 진짜 고장이 난다
|
||||||
|
|
||||||
|
**여기가 이 실험의 본 시험이다.** 순차로 20번 돌리면 큐잉이 재현되지 않는다.
|
||||||
|
**동시에** 20건을 보내야 한다.
|
||||||
|
|
||||||
|
**하기** — 백그라운드로 띄우고 `wait`. 결과는 파드 안 파일에 모은다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c '
|
||||||
|
rm -f /tmp/load ; i=0
|
||||||
|
while [ $i -lt 20 ]; do
|
||||||
|
( curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --max-time 60 \
|
||||||
|
-X POST "http://$K1:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d "password=$PW" \
|
||||||
|
>> /tmp/load ) &
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
wait'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 다 모였는지부터 센다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
|
||||||
|
wc -l /tmp/load.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**`20` 이 아니면 수집이 샌 것이다.** 그 상태의 숫자는 해석하지 않는다.
|
||||||
|
|
||||||
|
**확인** — 원본을 본다
|
||||||
|
```bash
|
||||||
|
cat /tmp/load.txt
|
||||||
|
```
|
||||||
|
그 다음 상태 코드와 시간을 나눠 본다.
|
||||||
|
```bash
|
||||||
|
awk '{print $1}' /tmp/load.txt | sort | uniq -c
|
||||||
|
awk '{print $2}' /tmp/load.txt | sort -g
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||||
|
```
|
||||||
|
=== 동시 부하 20건을 keycloak-1 에 — 커넥션 풀이 견디는가 ===
|
||||||
|
1 200 1.911191
|
||||||
|
1 200 1.913766
|
||||||
|
1 200 1.958374
|
||||||
|
1 200 1.981620
|
||||||
|
1 200 10.539402
|
||||||
|
1 200 11.951943
|
||||||
|
1 200 13.351102
|
||||||
|
1 200 14.785832
|
||||||
|
1 200 16.189533
|
||||||
|
1 200 17.625166
|
||||||
|
1 200 19.053724
|
||||||
|
1 200 20.495883
|
||||||
|
1 200 21.905932
|
||||||
|
1 200 22.228466
|
||||||
|
1 200 22.230871
|
||||||
|
1 200 3.441366
|
||||||
|
1 200 4.841075
|
||||||
|
1 200 6.257489
|
||||||
|
1 200 7.704608
|
||||||
|
1 200 9.104792
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가 — 두 가지다.**
|
||||||
|
|
||||||
|
**① 순서가 이상하다.** `10.5` 가 `3.4` 보다 앞에 있다. 원 실행이 `sort` 를
|
||||||
|
**사전순**으로 썼기 때문이다(맨 앞의 `1` 은 `uniq -c` 가 붙인 개수다).
|
||||||
|
문자열로 정렬하면 `"10.5" < "3.4"` 다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sort /tmp/load.txt # 사전순 — 10.5 가 3.4 앞에 온다
|
||||||
|
sort -g /tmp/load.txt # 수치순 — 이걸 써야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**시간 값을 정렬할 때는 `sort -g`.** 이걸 놓치면 「최대값」을 잘못 읽는다.
|
||||||
|
|
||||||
|
**② 숫자를 순서대로 놓으면 계단이다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
1.9 → 3.4 → 4.8 → 6.2 → 7.7 → 9.1 → 10.5 → ... → 22.2
|
||||||
|
──── ──── ──── ────
|
||||||
|
약 1.4초 간격 — 앞 요청이 커넥션을 놓아줄 때까지 줄을 선다
|
||||||
|
```
|
||||||
|
|
||||||
|
**전부 성공(200)했지만 응답 시간이 1.9초에서 22.2초까지 늘어난다.**
|
||||||
|
**전형적인 큐잉이다.** 커넥션 수는 유한하고, 각 요청이 커넥션을 1.9초씩
|
||||||
|
붙잡으므로 뒤에 온 요청은 그만큼 기다린다.
|
||||||
|
|
||||||
|
> **`200` 만 보는 감시는 이 장애를 못 본다.** 상태 코드는 전부 정상이다.
|
||||||
|
> **응답 시간 분포를 봐야 한다.**
|
||||||
|
|
||||||
|
## 6-4. 커넥션 풀 지표가 증언한다
|
||||||
|
|
||||||
|
**부하가 끝나자마자** 읽는다. 늦으면 순간값이 0 으로 돌아간다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a6-probe -- sh -c \
|
||||||
|
'curl -s "http://$K1:9000/metrics" | grep -E "^agroal_(blocking_time|max_used|acquire|active|available|awaiting)"'
|
||||||
|
```
|
||||||
|
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||||
|
```
|
||||||
|
=== 부하 직후 커넥션 풀 ===
|
||||||
|
agroal_blocking_time_max_milliseconds 20000.0
|
||||||
|
agroal_max_used_count 19.0
|
||||||
|
agroal_acquire_count_total 672.0
|
||||||
|
agroal_active_count 0.0
|
||||||
|
agroal_awaiting_count 0.0
|
||||||
|
agroal_blocking_time_average_milliseconds 281.0
|
||||||
|
agroal_available_count 19.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
| 값 | 읽는 법 |
|
||||||
|
|---|---|
|
||||||
|
| `blocking_time_max 20000.0` | **커넥션을 받으려고 20초를 기다린 요청이 있었다** |
|
||||||
|
| `max_used_count 19.0` | 풀이 19개까지 늘어났다 |
|
||||||
|
| `blocking_time_average 281.0` | 평균은 0.3초. **평균만 보면 아무 일도 없어 보인다** |
|
||||||
|
| `active_count 0.0` · `awaiting_count 0.0` | **순간값. 부하가 끝나서 0 이다** |
|
||||||
|
|
||||||
|
**평균과 최대의 간격이 이 장애의 모양이다.** 평균 281ms 짜리 그래프에서는
|
||||||
|
아무도 20초를 보지 못한다.
|
||||||
|
|
||||||
|
Grafana 에서 같은 것을 그림으로 본다 —
|
||||||
|
[`a6-connection-pool-blocking.png`](../../evidence/a6-latency-injection/a6-connection-pool-blocking.png).
|
||||||
|
|
||||||
|
## 6-5. 그리고 헬스체크가 무너진다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
sudo kubectl -n keycloak-lab get pods
|
||||||
|
```
|
||||||
|
**실측** — [`04-pool-under-load.txt`](../../evidence/a6-latency-injection/04-pool-under-load.txt)
|
||||||
|
```
|
||||||
|
keycloak-0 1/1 Running 0 60m
|
||||||
|
keycloak-1 1/1 Running 1 (51m ago) 3h24m
|
||||||
|
52m Normal TaintManagerEviction pod/keycloak-1 Cancelling deletion of Pod keycloak-lab/keycloak-1
|
||||||
|
32m Warning Unhealthy pod/keycloak-1 Readiness probe failed: HTTP probe failed with statuscode: 503
|
||||||
|
89s Warning Unhealthy pod/keycloak-1 Readiness probe failed: Get "http://10.42.0.42:9000/health/ready": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`89s` 짜리 줄.** 그것이 지금 주입의 결과다.
|
||||||
|
`32m`·`52m` 짜리는 **A-4 의 잔재**다(노드를 껐다 켠 흔적).
|
||||||
|
|
||||||
|
> **이벤트를 볼 때는 `Age` 를 먼저 본다.** 이벤트 목록은 한 시간 전 것까지
|
||||||
|
> 섞여 있다. 방금 일어난 일만 골라야 한다.
|
||||||
|
|
||||||
|
**두 실패의 차이가 중요하다.**
|
||||||
|
|
||||||
|
| 메시지 | 무슨 일 |
|
||||||
|
|---|---|
|
||||||
|
| `HTTP probe failed with statuscode: 503` | Keycloak 이 **답은 했다.** 스스로 DOWN 이라고 말했다 |
|
||||||
|
| **`context deadline exceeded`** | **답 자체를 못 했다.** 프로브가 줄에서 기다리다 끝났다 |
|
||||||
|
|
||||||
|
**readiness 프로브 자체가 타임아웃됐다.** 헬스체크도 같은 커넥션 풀 줄에 선다.
|
||||||
|
|
||||||
|
## 6-6. 연쇄 고장의 모양
|
||||||
|
|
||||||
|
```
|
||||||
|
DB 가 느려진다
|
||||||
|
↓
|
||||||
|
요청이 커넥션을 오래 붙잡는다
|
||||||
|
↓
|
||||||
|
커넥션 풀이 고갈된다
|
||||||
|
↓
|
||||||
|
새 요청이 줄을 선다 (최대 20초)
|
||||||
|
↓
|
||||||
|
헬스체크도 줄에 선다 → 타임아웃 → NotReady
|
||||||
|
↓
|
||||||
|
그 노드가 로드밸런서에서 빠진다
|
||||||
|
↓
|
||||||
|
★ 남은 노드로 트래픽이 몰린다 → 그 노드도 같은 길을 간다
|
||||||
|
```
|
||||||
|
|
||||||
|
**마지막 화살표가 무서운 부분이다. 느려짐은 전파된다.**
|
||||||
|
A-2(DB 완전 정지)는 즉시 503 으로 드러나 오히려 명확했지만,
|
||||||
|
**느려짐은 살아 있는 노드를 하나씩 무너뜨린다.**
|
||||||
|
|
||||||
|
## 6-7. 빗나간 예측 — 낙관적 락 충돌은 늘지 않았다
|
||||||
|
|
||||||
|
계획서에는 이렇게 적혀 있었다.
|
||||||
|
|
||||||
|
> **낙관적 락 충돌 증가** — 트랜잭션이 길어져 `VERSION` 충돌이 늘어야 한다
|
||||||
|
|
||||||
|
**확인** — 지연 구간의 로그를 센다. **미검증** (원 실행의 정확한 패턴은 기록에 없다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 --since=20m \
|
||||||
|
| grep -icE 'optimistic|StaleState|version.*conflict'
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
|
||||||
|
```
|
||||||
|
=== 낙관적 락 충돌이 늘었는가 — 지연 중 로그 ===
|
||||||
|
관련 로그 줄수: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**하나도 없었다.** 이유가 명확하다.
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 → 매번 새 세션 행을 INSERT → 다툴 상대가 없다
|
||||||
|
refresh → 같은 세션 행을 UPDATE → 여기서 다툰다
|
||||||
|
```
|
||||||
|
|
||||||
|
**충돌은 같은 행을 동시에 고칠 때만 일어난다.** 로그인 부하로는 재현되지
|
||||||
|
않는다. 이건 **B-3(refresh 토큰 경쟁)의 영역**이며, 거기서 지연을 함께 주면
|
||||||
|
충돌률이 올라갈 것이다.
|
||||||
|
|
||||||
|
> 예측을 적어 두지 않았다면 「충돌이 없네」 하고 넘어갔을 것이다.
|
||||||
|
> **빗나간 예측이 다음 실험의 설계를 정해 준다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. 복구
|
||||||
|
|
||||||
|
## 7-1. 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 해제'
|
||||||
|
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'
|
||||||
|
```
|
||||||
|
**실측** — [`05-recovery.txt`](../../evidence/a6-latency-injection/05-recovery.txt)
|
||||||
|
```
|
||||||
|
=== 지연 해제 ===
|
||||||
|
해제완료
|
||||||
|
qdisc noqueue 0: root refcnt 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`noqueue`.** `prio` 도 `netem` 도 없다.
|
||||||
|
`root` 를 지우면 그 아래 자식 qdisc 와 filter 가 **같이** 사라진다.
|
||||||
|
|
||||||
|
## 7-2. 즉시 회복하는지 본다
|
||||||
|
|
||||||
|
**확인** — **2-2 의 반복 측정 명령을 그대로 다시 친다.** 그 명령의 첫 줄이
|
||||||
|
`rm -f /tmp/base-k1` 이므로 파일은 새로 만들어진다. 두 노드 다 잰다.
|
||||||
|
|
||||||
|
같은 명령이어야 비교가 된다. 다른 명령으로 잰 값은 기준선과 나란히 놓을 수 없다.
|
||||||
|
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
=== 회복 확인 ===
|
||||||
|
keycloak-0 평균 43 ms
|
||||||
|
keycloak-1 평균 51 ms
|
||||||
|
keycloak-0 1/1 Running 0 61m
|
||||||
|
keycloak-1 1/1 Running 1 (52m ago) 3h24m
|
||||||
|
```
|
||||||
|
|
||||||
|
**파드 재시작 없이 즉시 회복.** `RESTARTS` 가 안 늘었다 — 이 실험은
|
||||||
|
readiness 를 흔들었을 뿐 파드를 죽이지는 않았다. **커넥션 풀도 스스로
|
||||||
|
정상화됐다.**
|
||||||
|
|
||||||
|
## 7-3. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| qdisc | `ssh kc-lab-2 'sudo tc qdisc show dev flannel.1'` | `noqueue` |
|
||||||
|
| (물리 쪽도) | `ssh kc-lab-2 'sudo tc qdisc show dev enp1s0'` | 시도 ① 잔재가 없어야 한다 |
|
||||||
|
| 응답 시간 | 2-2 의 반복 측정 | 기준선과 같은 자릿수 |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
|
||||||
|
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 풀 | `agroal_awaiting_count` · `agroal_active_count` | `0` |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a6-probe` | 지웠으면 `NotFound` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
탐침을 지운다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a6-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> `agroal_blocking_time_max_milliseconds` 는 **누적이라 20000 인 채로 남는다.**
|
||||||
|
> 파드를 재시작해야 0 이 된다. **그대로 두는 편이 낫다** — 「이 노드가 한 번
|
||||||
|
> 20초를 기다린 적이 있다」는 기록이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Cannot find device "eth0"` | **이 게스트의 NIC 는 `enp1s0` 이다** | `ip -brief link` — 3-2 |
|
||||||
|
| 스크립트가 「적용완료」인데 지연이 없다 | **성공 메시지는 스크립트가 찍은 것** | `tc -s qdisc` 카운터 — 3-1 |
|
||||||
|
| `enp1s0` 에 걸었는데 안 걸린다 | **VXLAN 안에 파드 IP 가 숨어 있다** | `flannel.1` 에 건다 — 4절 |
|
||||||
|
| `Sent 0 pkt` | 부하 **전**이면 정상. 부하 **후**면 필터가 틀렸다 | 요청 한 번 보내고 다시 센다 — 5-3 |
|
||||||
|
| 지연이 양쪽 다 늘었다 | `netem` 을 `root` 에 직접 붙였다 | `prio` + `filter` 로 골라 낸다 — 5-2 |
|
||||||
|
| `kubectl` 이나 SSH 까지 느려졌다 | `enp1s0` 에 걸었다 | `tc qdisc del dev enp1s0 root` |
|
||||||
|
| 20줄 중 몇 줄만 온다 | **`kubectl run --rm -i` 로 동시 실행하면 stdout 이 샌다** | 상주 파드 + 파일 — 1-2 |
|
||||||
|
| 최대값이 `9.1` 로 보인다 | `sort` 가 **사전순**이다 | `sort -g` — 6-3 |
|
||||||
|
| `blocking_time` 이 0 이다 | 부하가 끝나고 한참 뒤에 읽었다 | **부하 직후**에 읽는다 — 6-4 |
|
||||||
|
| `awaiting_count` 가 늘 0 이다 | **순간값이다** | 부하가 도는 **중에** 읽는다 |
|
||||||
|
| 로그인이 전부 `401` | `PW` 가 안 넘어갔다 | `exec a6-probe -- sh -c 'echo ${#PW}'` — 1-2 |
|
||||||
|
| 갑자기 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 다시 만든다 — 1-2 |
|
||||||
|
| 이벤트가 과거 것과 섞인다 | 이벤트는 한 시간 전 것도 남는다 | `Age` 를 먼저 본다 — 6-5 |
|
||||||
|
| 대조군도 값이 변했다 | **정상이다.** JIT·캐시 변동 | 자릿수로 판정한다 — 6-1 |
|
||||||
|
| `dropped` 가 늘어난다 | `netem` 의 `limit 1000` 을 넘겼다 | 부하를 줄이거나 `limit` 을 올린다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 실험이 남기는 관측 숙제
|
||||||
|
|
||||||
|
**지금 관측 스택에는 지연 분포 지표가 없다.** `agroal_blocking_time_*` 은
|
||||||
|
있지만 히스토그램이 아니라 **평균과 최대뿐**이다. 6-4 에서 본 대로
|
||||||
|
평균 281ms 와 최대 20,000ms 사이에 무엇이 있었는지는 알 수 없다.
|
||||||
|
|
||||||
|
```promql
|
||||||
|
# 있으면 좋았을 것
|
||||||
|
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
| 알게 된 것 | 함의 |
|
||||||
|
|---|---|
|
||||||
|
| 지연은 **왕복 횟수만큼 곱해진다** | DB 지연 대책은 「쿼리 수 줄이기」가 먼저다 |
|
||||||
|
| 커넥션 풀에서 **한 번 더 곱해진다** | 풀 크기와 타임아웃이 장애 반경을 정한다 |
|
||||||
|
| **헬스체크도 줄에 선다** | 프로브 타임아웃이 풀 대기보다 짧아야 격리가 제때 된다 |
|
||||||
|
| 느려짐은 **전파된다** | 노드를 빼면 남은 노드가 더 빨리 무너진다 |
|
||||||
|
| `up` 도 readiness 도 **늦게 반응** | **응답 시간 분포(p95/p99)를 봐야 한다** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-6 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-3](../../experiment-b3-refresh-token-contention.md) refresh 경쟁 | **지연을 함께 주면 낙관적 락 충돌이 재현될 것** — 여기서는 안 됐다 |
|
||||||
|
| [B-1](../../experiment-b1-redis-session-store.md) 저장소 지연 | **같은 기법을 Redis 앞에 쓴다.** `flannel.1` · `prio` · `filter` 그대로 |
|
||||||
|
| [A-4](a4-node-loss.md) 노드 상실 | 거기서는 `up=0` 이 정확했다. **여기서는 `up=1` 인 채로 무너진다** |
|
||||||
|
| 관측 보완 | **응답 시간 히스토그램**이 없다 |
|
||||||
|
| 전부 | **주입이 걸렸는지 카운터로 먼저 확인한다.** 세 실험 연속으로 같은 교훈 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,891 @@
|
|||||||
|
# A-7a 재현 가이드 — DB 에게 직접 물어서 원인을 확정하고, 같은 설정에서 세 가지 답을 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a7a-volatile-cause/`](../../evidence/a7a-volatile-cause/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 로그인이 SQL 을 **0개** 쏘는 것 | PostgreSQL 문장 로그 |
|
||||||
|
| refresh 가 쏘는 **딱 한 문장**의 이름 | 같은 로그 — `CLIENT_SCOPE_CLIENT` |
|
||||||
|
| 그 문장이 **첫 refresh 에만** 나오는 것 | 표식 사이 SQL 0건 |
|
||||||
|
| A-7 이 지목한 `REVOKED_TOKEN` 이 **한 번도 안 나오는 것** | 같은 로그 |
|
||||||
|
| 같은 설정에서 **400 · 500 · 200 셋이 다 나오는 것** | 캐시 온도 세 상태 |
|
||||||
|
| 실패한 SQL 을 Keycloak 로그가 **직접 지목하는 것** | `JDBC exception executing SQL [...]` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다.
|
||||||
|
- **[A-7](a7-volatile-comparison.md) 을 먼저 한다.** 이 실험은 A-7 이 남긴
|
||||||
|
가설을 확정하는 것이고, A-7 의 4-4 에서 본 `500` 이 출발점이다.
|
||||||
|
- [A-3](a3-database-crash.md) 의 문장 로깅을 해 봤으면 3절이 익숙할 것이다.
|
||||||
|
같은 기법이다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 터미널 **두 개**를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.
|
||||||
|
|
||||||
|
## 주의 — 주입이 세 개다. 복구도 세 개다
|
||||||
|
|
||||||
|
1. PostgreSQL **문장 로깅**을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
|
||||||
|
2. Keycloak 을 **volatile** 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
|
||||||
|
3. PostgreSQL 을 **여러 번 내렸다 올린다** → 마지막에 올라와 있어야 한다
|
||||||
|
|
||||||
|
**실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어
|
||||||
|
두었다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 그대로 친다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 11:18–11:24 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
> **시각이 UTC 다.** 증거 파일의 `11:18:49` 는 KST 로 20:18 이다. 문서 상단의
|
||||||
|
> `20:18–20:24 KST` 와 같은 시각이며, **PostgreSQL 컨테이너가 UTC 로 로그를
|
||||||
|
> 찍기 때문**이다. 로그 시각과 `date` 를 비교할 때 이걸 잊으면 9시간을 헤맨다.
|
||||||
|
|
||||||
|
UUID·IP·파드 이름은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||||
|
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
A-7 은 이렇게 끝났다.
|
||||||
|
|
||||||
|
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는 `REVOKED_TOKEN`
|
||||||
|
> 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지** 확인하려면 그 테이블을
|
||||||
|
> 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||||
|
|
||||||
|
**그럴듯하다. 그리고 틀렸다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
가설을 세우는 것 → 괜찮다
|
||||||
|
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
|
||||||
|
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
|
||||||
|
```
|
||||||
|
|
||||||
|
「refresh 가 어느 테이블 때문에 실패하는가」는 **추측으로 답할 문제가 아니다.**
|
||||||
|
Keycloak 소스를 읽는 대신 **DB 가 실제로 받은 문장**을 보면 된다.
|
||||||
|
|
||||||
|
그리고 확정해 보니 원인만 틀린 게 아니었다. **A-7 의 표 자체가 조건부였다.**
|
||||||
|
같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. **한 번 재고 표로 적으면
|
||||||
|
안 되는 종류의 측정**이었던 것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 켜기 전에 지금 상태를 본다
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE IP NODE
|
||||||
|
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
|
||||||
|
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
|
||||||
|
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 셋 다 `Running`. **`postgres` 가 있어야 한다** —
|
||||||
|
이 실험은 그것을 내렸다 올렸다 한다.
|
||||||
|
|
||||||
|
## 1-2. 문장 로깅이 꺼져 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
log_statement
|
||||||
|
---------------
|
||||||
|
none
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `none`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 앞 실험이 켜 둔 채 끝내지 않았다. `all` 이면
|
||||||
|
**누가 켜 두었는지 모르는 상태**이고, 그대로 진행하면 지금 쌓인 로그가 어느
|
||||||
|
실험 것인지 구별할 수 없다. 그때는 먼저 끄고, 로그가 한 바퀴 돌 때까지 기다린다.
|
||||||
|
|
||||||
|
## 1-3. 지금 args 가 무엇인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
["start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 값을 적어 둔다.** 5-3 에서 이대로 되돌린다.
|
||||||
|
|
||||||
|
## 1-4. 탐침 파드
|
||||||
|
|
||||||
|
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
|
||||||
|
**이 실험은 Keycloak 을 여러 번 재시작하므로 탐침은 반드시 StatefulSet 밖에
|
||||||
|
있어야 한다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="K0=$K0" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||||||
|
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||||
|
> ```
|
||||||
|
> **실측** — `19`
|
||||||
|
>
|
||||||
|
> **★ 명령줄에 평문 비밀번호를 쓰지 않는다.** 원래 실험의 재현 절차에는 그대로
|
||||||
|
> 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도 남는다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
K0=10.42.1.94 PW길이=19
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-5. 로그가 지금 무엇으로 차 있나 — **이걸 알아야 걸러 낼 수 있다**
|
||||||
|
|
||||||
|
**확인** — 로깅을 켜기 전에 한 번 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=20
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 조용하다. 여기까지는 에러만 찍힌다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 로그가 조용한 것이 기준선이다. 다음 절에서 켜면
|
||||||
|
**JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링**이 로그를 계속 채운다. 그것이
|
||||||
|
소음이고, 4절에서 `grep -v JGROUPS_PING` 으로 거른다. **소음을 먼저 봐 두면
|
||||||
|
거르는 이유를 안다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 두 개를 순서대로 넣는다
|
||||||
|
|
||||||
|
## 2-1. 주입 ① PostgreSQL 문장 로깅
|
||||||
|
|
||||||
|
### 개념 — 문장 로깅은 무엇인가
|
||||||
|
|
||||||
|
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 **모든 SQL** 을 로그에
|
||||||
|
찍는다. 애플리케이션을 고치지 않고 **「이 요청이 DB 를 어떻게 쓰는지」** 를
|
||||||
|
밖에서 볼 수 있다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** 「refresh 가 어느 테이블 때문에 실패하는가」를 확정하려면
|
||||||
|
DB 가 실제로 받은 문장을 봐야 한다. Keycloak 안을 들여다볼 필요가 없다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블
|
||||||
|
이름을 골라 가설로 적게 되고, **그게 틀려도 아무도 모른다.**
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 실제로 켜졌나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
log_statement
|
||||||
|
---------------
|
||||||
|
all
|
||||||
|
```
|
||||||
|
|
||||||
|
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. **`alter system` 은
|
||||||
|
`postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.**
|
||||||
|
|
||||||
|
**확인** — 로그가 실제로 차기 시작했나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=10
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`JGROUPS_PING` 이 계속 나온다.** 1-5 에서 예고한 소음이다.
|
||||||
|
이게 안 보이면 로깅이 안 켜진 것이다.
|
||||||
|
|
||||||
|
## 2-2. 주입 ② volatile 전환
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||||
|
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||||
|
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
|
||||||
|
"value":["start","--features-disabled=persistent-user-sessions"]}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
```
|
||||||
|
|
||||||
|
**빌드 옵션이라 기동 시 재빌드가 일어나 오래 걸린다.** `--timeout=500s` 를 주는
|
||||||
|
이유다.
|
||||||
|
|
||||||
|
**★ 파드 IP 가 바뀌었다.** 탐침 파드를 다시 띄운다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --env="K0=$K0" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. args 와 동작을 둘 다 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW"'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
volatile 전환 확인
|
||||||
|
args: ["start","--features-disabled=persistent-user-sessions"]
|
||||||
|
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 **행이
|
||||||
|
안 생기는 것**이 volatile 의 증거다.
|
||||||
|
|
||||||
|
> 행 수가 0 이 아니면 옛 행이 남아 있는 것이다. A-7 의 2-1 처럼
|
||||||
|
> `delete from offline_user_session` 을 먼저 하고 다시 잰다.
|
||||||
|
|
||||||
|
## 3-2. 문장 로그가 지금 요청을 잡고 있나
|
||||||
|
|
||||||
|
**확인** — 방금 로그인 직후에 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `JGROUPS_PING` 말고 다른 것이 섞여 있는지.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 시점에서는 **거의 `JGROUPS_PING` 뿐일 것**이다.
|
||||||
|
그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다.
|
||||||
|
**구간을 나눠야 볼 수 있다.** 그게 다음 절이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 표식으로 구간을 나눈다
|
||||||
|
|
||||||
|
로그는 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이 로그인이고 어느 것이
|
||||||
|
refresh 인지 가르려면 **경계를 찍어야 한다.**
|
||||||
|
|
||||||
|
**개념** — `psql` 로 아무 `select` 나 보내면 **그 문장 자체가 로그에 남는다.**
|
||||||
|
그러면 리터럴 문자열이 로그 안의 이정표가 된다.
|
||||||
|
|
||||||
|
**하기** — 표식 하나를 넣어 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_TEST'"
|
||||||
|
```
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `statement: select 'MARK_TEST'` 가 보이는 것.
|
||||||
|
안 보이면 로깅이 안 켜졌다(2-1 로 돌아간다).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이제 **표식과 표식 사이만 잘라 볼 수 있다.**
|
||||||
|
|
||||||
|
## 4-2. 로그인이 무슨 SQL 을 쏘는가
|
||||||
|
|
||||||
|
**하기** — 표식 → 로그인 → 표식. **세 명령을 붙여서 친다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_LOGIN_START'"
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" > /tmp/tok
|
||||||
|
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||||
|
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_LOGIN_END'"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
rt 1188 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ `1 bytes` 면 파싱이 실패한 것이다.** 그 상태로 4-3 을 하면 빈 토큰을 보내고
|
||||||
|
엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
|
||||||
|
|
||||||
|
**확인** — 표식 사이를 잘라 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||||
|
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||||
|
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||||
|
↑ 사이에 아무것도 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 줄뿐이다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **로그인은 SQL 을 0개 쏜다.** realm·사용자·클라이언트가
|
||||||
|
전부 Infinispan 캐시에 있고, volatile 이라 세션 쓰기도 없다. DB 없이 완결된다 —
|
||||||
|
A-7 이 적은 그대로다.
|
||||||
|
|
||||||
|
> `awk '/A/,/B/'` 는 **A 가 나온 줄부터 B 가 나온 줄까지** 출력한다. 로그를 구간으로
|
||||||
|
> 자를 때 이보다 짧게 쓰는 방법은 없다. 파일로 먼저 받는 것은 같은 로그를 여러
|
||||||
|
> 구간으로 반복해서 잘라 볼 것이기 때문이다.
|
||||||
|
|
||||||
|
## 4-3. ★ refresh 는 딱 한 문장을 쏜다 — 그리고 가설이 지목한 것이 아니다
|
||||||
|
|
||||||
|
**하기** — 표식 → refresh → 표식
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_REFRESH_START'"
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)"'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_REFRESH_END'"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||||
|
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||||
|
11:18:52.137 statement: BEGIN
|
||||||
|
11:18:52.137 execute <unnamed>/C_107:
|
||||||
|
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||||
|
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||||
|
11:18:52.148 execute S_2: COMMIT
|
||||||
|
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지다.
|
||||||
|
|
||||||
|
- **문장이 하나뿐이다.** `BEGIN` / `COMMIT` 사이에 `select` 한 개
|
||||||
|
- 테이블 이름이 **`CLIENT_SCOPE_CLIENT`** 다
|
||||||
|
- `parameters` 줄의 **`$2 = 'f'`**
|
||||||
|
|
||||||
|
**확인** — 가설이 지목한 테이블이 정말 없는지 직접 센다
|
||||||
|
```bash
|
||||||
|
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
|
||||||
|
```
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **A-7 의 가설은 틀렸다.** 그럴듯했지만 로그가
|
||||||
|
아니라고 말한다. 그리고 이제 **로그가 지목하는 문장**이 있다.
|
||||||
|
|
||||||
|
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
|
||||||
|
|
||||||
|
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
|
||||||
|
|
||||||
|
| | 뜻 | `DEFAULT_SCOPE` |
|
||||||
|
|---|---|---|
|
||||||
|
| default scope | 항상 붙는다 | `t` |
|
||||||
|
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
|
||||||
|
|
||||||
|
**왜 여기 나오나.** refresh 는 **새 access token 을 만든다.** 그 토큰에 어떤
|
||||||
|
스코프를 담을지 정하려면 「이 클라이언트가 요청 가능한 optional 스코프가
|
||||||
|
무엇인가」를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다. **로그인
|
||||||
|
때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.**
|
||||||
|
|
||||||
|
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
|
||||||
|
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이 세션 계열
|
||||||
|
테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
|
||||||
|
|
||||||
|
**확인** — 그 UUID 가 어느 클라이언트인지 궁금하면 물어본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select id, client_id from client where id='131a9912-b578-4b9c-b16a-97518704077e'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**당신 환경에서는 UUID 가 다르다.** 위 로그의 `$1` 값을 그대로 넣는다.
|
||||||
|
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
|
||||||
|
|
||||||
|
## 4-4. 그 조회는 한 번뿐이다 — 여기서 표가 흔들리기 시작한다
|
||||||
|
|
||||||
|
**하기** — refresh 를 연속 3회. 사이사이 표식을 넣는다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_R1'"
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)" > /tmp/tok
|
||||||
|
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||||
|
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select 'MARK_R2'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 매번 `/tmp/rt` 를 다시 채운다.** refresh token 은 회전한다. 옛 것을 계속 쓰면
|
||||||
|
나오는 오류가 **무효화 때문인지 재사용 때문인지 구별되지 않는다.**
|
||||||
|
|
||||||
|
같은 모양으로 `MARK_R3` · `MARK_R_END` 까지 두 번 더 한다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
|
||||||
|
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||||
|
statement: select 'MARK_R1'
|
||||||
|
statement: select 'MARK_R2'
|
||||||
|
statement: select 'MARK_R3'
|
||||||
|
statement: select 'MARK_R_END'
|
||||||
|
↑ SQL 0건
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **표식 네 줄만 있고 그 사이에 아무것도 없다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지
|
||||||
|
않는다.** 그러면 이런 질문이 따라온다.
|
||||||
|
|
||||||
|
> **DB 를 언제 내리느냐에 따라 답이 달라지는 것 아닌가?**
|
||||||
|
|
||||||
|
그렇다. 그게 다음 절이다.
|
||||||
|
|
||||||
|
## 4-5. ★ 같은 설정에서 답이 셋으로 갈린다 — 셋 다 재현한다
|
||||||
|
|
||||||
|
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
|
||||||
|
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
|
||||||
|
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
|
||||||
|
|
||||||
|
**★ 한 번만 재고 넘어가면 반드시 틀린 표를 쓰게 된다.** A-7 이 그렇게 했다.
|
||||||
|
셋 다 재현해야 한다.
|
||||||
|
|
||||||
|
**되돌리기** — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 캐시를 식히는 방법 — **Keycloak 재시작이 유일하다**
|
||||||
|
|
||||||
|
```
|
||||||
|
Infinispan 캐시 = 프로세스 메모리
|
||||||
|
│
|
||||||
|
└─ 파드가 살아 있는 한 안 식는다
|
||||||
|
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다.** 이미 더워진 캐시에서 계속
|
||||||
|
재게 되므로 **A·B 를 재도 C 의 답(200/200)이 나오고**, 「A-7 이 틀렸다」는 엉뚱한
|
||||||
|
결론에 도달한다.
|
||||||
|
|
||||||
|
### 재현 A — 완전 냉시동이면 로그인부터 400
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 재시작과 DB 정지 사이에 아무 요청도 보내지 않는다.** 한 번이라도 로그인하면
|
||||||
|
캐시가 더워져서 이건 재현 B 가 된다.
|
||||||
|
|
||||||
|
파드 IP 가 바뀌었으므로 탐침을 다시 띄운다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --env="K0=$K0" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 로그인. **본문까지 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
로그인 400 {"error":"unauthorized_client",
|
||||||
|
"error_description":"Unexpected error when authenticating client"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `unauthorized_client`. **`invalid_grant` 가 아니다.**
|
||||||
|
세션 문제가 아니라 **클라이언트를 못 찾은 것**이다.
|
||||||
|
|
||||||
|
**확인** — 왜인지는 Keycloak 로그가 직접 말한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||||
|
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
|
||||||
|
authenticating client: org.hibernate.exception.GenericJDBCException:
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||||
|
administrator command]
|
||||||
|
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 대괄호가 **두 쌍**이다. 앞은 **DB 가 준 오류**,
|
||||||
|
뒤는 **실패한 SQL 원문**. `grep -oE` 로 그 두 쌍만 뽑는 이유가 이것이다.
|
||||||
|
|
||||||
|
> 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저
|
||||||
|
> 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었다.
|
||||||
|
냉시동에서는 아니다.** 클라이언트 조회조차 캐시에 없기 때문이다.
|
||||||
|
|
||||||
|
### 재현 B — A-7 이 본 그 조건
|
||||||
|
|
||||||
|
**하기** — DB 를 살리고, 재시작하고, **로그인만 한 번** 하고, DB 를 내린다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
```
|
||||||
|
|
||||||
|
탐침을 새 IP 로 다시 띄운 뒤(재현 A 와 같은 명령), **로그인 한 번만** 한다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" > /tmp/tok
|
||||||
|
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||||
|
echo "rt $(wc -c < /tmp/rt) bytes"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 여기서 refresh 를 하면 안 된다.** 하는 순간 재현 C 가 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 이제 refresh
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)"'
|
||||||
|
```
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
로그인 200
|
||||||
|
refresh 500 {"error":"unknown_error"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 실패한 SQL
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||||
|
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||||
|
administrator command]
|
||||||
|
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **4-3 에서 본 그 문장이다.** 문장 로깅이 「이 문장을
|
||||||
|
쏜다」를 보여줬고, 여기서는 「이 문장이 실패했다」를 보여준다. **두 개가 만나면
|
||||||
|
가설이 아니라 확정이다.**
|
||||||
|
|
||||||
|
**`500 unknown_error` 인 이유도 이제 안다.** 세션은 멀쩡하다. 토큰을 조립하다가
|
||||||
|
DB 가 없어서 못 만든 것이고, Keycloak 은 그걸 사용자 오류로 분류할 방법이 없어서
|
||||||
|
`unknown_error` 를 준다.
|
||||||
|
|
||||||
|
### 재현 C — 완전히 더우면 둘 다 200
|
||||||
|
|
||||||
|
**하기** — DB 를 살리고, 재시작하고, **refresh 를 3회 미리 돌린 뒤** DB 를 내린다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
```
|
||||||
|
|
||||||
|
탐침을 새 IP 로 다시 띄운 뒤, 로그인 1회 + refresh 3회(4-4 와 같은 형태로
|
||||||
|
`/tmp/rt` 를 매번 갱신하며).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 로그인과 refresh 를 둘 다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
|
||||||
|
curl -s -o /dev/null -w "refresh %{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)"'
|
||||||
|
```
|
||||||
|
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
|
||||||
|
```
|
||||||
|
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
||||||
|
로그인 200
|
||||||
|
refresh 200 ← A-7 의 표와 정반대다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **같은 설정, 같은 명령, 세 개의 답.** 무엇이 다른지는
|
||||||
|
`kubectl get` 어디에도 안 나온다. **캐시 온도는 보이지 않는 상태다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
volatile + DB 정지의 결과
|
||||||
|
= "무엇을 하느냐"가 아니라
|
||||||
|
"그 경로가 이미 캐시를 채웠느냐"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의
|
||||||
|
> 함정이다.** 상태가 결과를 바꾸는데 그 상태가 안 보인다.
|
||||||
|
|
||||||
|
> **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로 DB 가
|
||||||
|
> 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile 고유의
|
||||||
|
> 성질**이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
**세 개를 순서대로 되돌린다.** 순서가 있다 — DB 가 살아 있어야 나머지가 된다.
|
||||||
|
|
||||||
|
## 5-1. PostgreSQL 을 되살린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2. ★ 문장 로깅을 끈다 — 잊으면 다음 실험이 전부 오염된다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "alter system reset log_statement" -c "select pg_reload_conf()"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "show log_statement"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
log_statement
|
||||||
|
---------------
|
||||||
|
none
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 급한가** — [A-3](a3-database-crash.md) 은 수백 건의 로그인을 최대한 빨리
|
||||||
|
돈다. `log_statement='all'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
|
||||||
|
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
|
||||||
|
즉 **다음 실험의 측정값이 이 설정 때문에 바뀐다.**
|
||||||
|
|
||||||
|
## 5-3. args 를 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||||
|
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-4. 정말 persistent 로 돌아왔는지 — 동작으로 확인한다
|
||||||
|
|
||||||
|
**args 문자열만 보고 끝내지 않는다.**
|
||||||
|
|
||||||
|
**하기** — 탐침을 새 IP 로 띄우고 로그인 한 번
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --env="K0=$K0" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 600
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||||
|
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **0 이 아니어야 한다.** 로그인 후 행이 생기면 persistent 다.
|
||||||
|
원래 재현 절차가 마지막에 이 한 줄을 두는 이유가 이것이다.
|
||||||
|
|
||||||
|
## 5-5. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| **문장 로깅** | `psql -c "show log_statement"` | **`none`** |
|
||||||
|
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
|
||||||
|
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
|
||||||
|
| **동작** | 위 5-4 | 로그인 후 세션 행이 **생긴다** |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||||
|
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a7a-probe` | `NotFound` |
|
||||||
|
| 임시 파일 | `ls /tmp/pg.log` | 지워도 된다 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
|
||||||
|
rm -f /tmp/pg.log
|
||||||
|
```
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것** — 캐시가 「얼마나 오래」 더운지는 재지 않았다.
|
||||||
|
> `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로, **한참 뒤에 다시 재면
|
||||||
|
> 또 다른 답이 나올 수도 있다.** 그것까지 확인하려면 재현 C 뒤에 시간을 두고
|
||||||
|
> 같은 시험을 반복해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 — 2-1 |
|
||||||
|
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` — 4-2 |
|
||||||
|
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
|
||||||
|
| 로그 시각이 9시간 어긋난다 | **컨테이너 로그가 UTC 다** | `date -u` 와 비교한다 |
|
||||||
|
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 — 4-4 |
|
||||||
|
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 — 4-2 |
|
||||||
|
| 세 재현이 전부 `200/200` | **재시작을 건너뛰어 캐시가 계속 더웠다** | 재현마다 `rollout restart` — 4-5 |
|
||||||
|
| 재현 A 가 `200` 이 나온다 | 재시작 후 요청을 한 번이라도 보냈다 | 재시작 → **바로** DB 정지 |
|
||||||
|
| 재현 B 가 `200/200` | 로그인 뒤 refresh 를 미리 했다 | 로그인 **한 번만** 하고 DB 정지 |
|
||||||
|
| `JDBC exception` grep 이 빈 출력 | `--tail` 이 짧거나 정규식이 안 맞는다 | `grep -i 'JDBC exception'` 으로 먼저 넓게 |
|
||||||
|
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 새 IP 로 다시 띄운다 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
|
||||||
|
| 다음 실험의 postgres 로그가 폭주한다 | **문장 로깅을 끄지 않았다** | `show log_statement` 가 `none` — 5-2 |
|
||||||
|
| 다음 실험의 세션이 안 살아남는다 | **volatile 로 둔 채 끝냈다** | 5-4 의 행 수 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 왜 이 가이드는 표식을 손으로 넣게 하나
|
||||||
|
|
||||||
|
원래 실행은 표식을 셸 함수로 감쌌다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
|
||||||
|
```
|
||||||
|
|
||||||
|
짧고 편하다. 그런데 **출력을 `/dev/null` 로 버린다.** 표식이 실제로 로그에
|
||||||
|
들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이다. 로깅이 안 켜져
|
||||||
|
있었다면 **표식 없는 로그를 한참 뒤에 `awk` 로 자르다가** 알게 된다.
|
||||||
|
|
||||||
|
이 가이드는 표식을 **한 줄씩 손으로** 넣는다. 느리지만 그 자리에서 보이고,
|
||||||
|
안 보이면 그 자리에서 안다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-7a 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **그 표에 조건을 붙여야 한다.** 「로그인 200 · refresh 500」은 캐시가 반쯤 더울 때만 참이다 |
|
||||||
|
| [A-3](a3-database-crash.md) DB 크래시 | 같은 문장 로깅 기법. **RPO 를 재는 데 쓴다** |
|
||||||
|
| [A-2](a2-database-loss.md) DB 정지 | persistent 에서는 캐시 온도와 무관하게 실패한다 — 대조군 |
|
||||||
|
| 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
# A-8 재현 가이드 — 배포할 때마다 로그아웃되는지 직접 확인한다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-a8-rolling-restart.md`](../../experiment-a8-rolling-restart.md) ·
|
||||||
|
증거 원문: [`docs/evidence/a8-rolling-restart/`](../../evidence/a8-rolling-restart/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 파드가 전부 교체되는 동안 외부가 계속 `200` 인 것 | 5초 간격 `curl` 시계열 |
|
||||||
|
| **재시작 전에 발급한 토큰이 재시작 후에도 통하는 것** | 상주 탐침 파드 |
|
||||||
|
| DB 세션 수가 그대로인 것 | PostgreSQL `OFFLINE_USER_SESSION` |
|
||||||
|
| **캐시만 0 으로 비워지는 것** | Prometheus `approximate_entries_unique` |
|
||||||
|
| 클러스터가 스스로 다시 붙는 것 | `vendor_cluster_size` |
|
||||||
|
| 「무중단」이 **관측 해상도에 달려 있다**는 것 | 표본이 9개뿐인 시계열 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- **[A-0](a0-session-replication.md) 을 먼저 하면 좋다.** 「세션은 DB 에 있고
|
||||||
|
캐시는 사본이다」라는 모델이 여기서 그대로 확인된다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 터미널 **두 개**를 열어 둔다. 하나는 가용성 감시용(루프가 돌고 있어야 한다),
|
||||||
|
하나는 재시작·관찰용.
|
||||||
|
|
||||||
|
## 주의 — 이건 파괴적이지 않다. 그래서 더 조심한다
|
||||||
|
|
||||||
|
`rollout restart` 는 **정상 작업**이다. 되돌릴 것이 없고, 잘못돼도 클러스터가
|
||||||
|
스스로 회복한다. 전 구간 약 15~20분.
|
||||||
|
|
||||||
|
**그래서 함정이 다르다.** 이 실험이 재는 것은 「깨졌나」가 아니라 「안 깨졌나」이고,
|
||||||
|
**측정을 잘못하면 안 깨진 것처럼 보이기가 너무 쉽다.** 실제로 원래 실행이
|
||||||
|
그랬다 — 1-5 의 파일 이름 함정을 반드시 읽는다.
|
||||||
|
|
||||||
|
**다른 실험과 겹치지 않게 한다.** 롤링 재시작 중에 다른 주입이 들어가 있으면
|
||||||
|
무엇 때문에 무엇이 일어났는지 구별되지 않는다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 13:19–13:20 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
IP·파드 이름·sid·세션 수는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||||
|
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
**운영에서 가장 자주 겪는 일이다.** 장애가 아니라 정상 작업인데도 사용자가
|
||||||
|
로그아웃되면 그건 사고다.
|
||||||
|
|
||||||
|
```
|
||||||
|
배포한다 → 파드가 교체된다 → 프로세스 메모리가 사라진다
|
||||||
|
│
|
||||||
|
└─ 세션이 거기 있었다면?
|
||||||
|
```
|
||||||
|
|
||||||
|
A-0 은 「세션의 진실은 PostgreSQL 에 있고 Infinispan 캐시는 사본」이라는 모델을
|
||||||
|
세웠다. **그 모델이 맞다면 파드를 통째로 갈아도 세션은 살아야 한다.**
|
||||||
|
틀리다면 배포가 곧 전원 로그아웃이다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| A-0 모델 (persistent) | 재시작해도 **세션 생존** |
|
||||||
|
| 옛 방식 (volatile) | 재시작하면 **전원 로그아웃** |
|
||||||
|
|
||||||
|
**둘 중 하나는 틀렸고, 재시작 전에 받은 토큰을 재시작 후에 써 보면 판정된다.**
|
||||||
|
|
||||||
|
그리고 이 실험은 **가용성도 같이 잰다.** 세션이 살아도 재시작 중에 서비스가
|
||||||
|
끊기면 그것대로 문제다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 재시작하기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 재시작 후에 볼 것을 재시작 전에
|
||||||
|
**똑같은 명령으로** 먼저 봐 둔다.
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드·나이 → args → DB 세션 수 → 상주 탐침 → 토큰 확보 → 대조군 시험 → 캐시·클러스터
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드와 나이 — **나이가 판정 근거다**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE IP NODE
|
||||||
|
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
|
||||||
|
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
|
||||||
|
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`
|
||||||
|
- **`AGE`** — 이 값을 적어 둔다. **재시작 후 이 값이 초 단위로 바뀌는 것이
|
||||||
|
「정말 재시작됐다」의 증거다**
|
||||||
|
- **replica 가 2 인 것** — 무중단의 전제다. 1 이면 반드시 끊긴다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `rollout restart` 는 파드를 **삭제하고 새로 만든다.**
|
||||||
|
그래서 `RESTARTS` 는 **안 오른다.** 재시작 여부를 `RESTARTS` 로 보면 「아무 일도
|
||||||
|
안 일어났다」로 읽는다. **`AGE` 로 본다.**
|
||||||
|
|
||||||
|
IP 를 잡아 둔다. 재시작 후 **반드시 다시 잡는다.**
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
echo "$K0 $K1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-2. args 가 `["start"]` 인가 — 이 실험의 전제
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
["start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 플래그가 없는 것.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `persistent-user-sessions` 가 기본으로 켜져 있다.
|
||||||
|
**`--features-disabled=persistent-user-sessions` 가 붙어 있으면 이 실험은
|
||||||
|
정반대 결과를 낸다** — 그건 [A-7](a7-volatile-comparison.md) 이다. 앞 실험이
|
||||||
|
되돌리지 않고 끝냈다면 여기서 잡힌다.
|
||||||
|
|
||||||
|
## 1-3. DB 세션 수를 적어 둔다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
offline_flag | count
|
||||||
|
--------------+-------
|
||||||
|
0 | 151
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`offline_flag = '0'` 이 온라인 세션**이다.
|
||||||
|
|
||||||
|
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||||
|
```
|
||||||
|
DB 세션 수: 151
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 숫자를 적어 둔다.** 재시작 후 같은 값이 나오는 것이 4-3 의 판정이다.
|
||||||
|
|
||||||
|
> 숫자는 당신 환경에서 다르다. 관리 API 호출도 세션을 만들기 때문에 **개수에는
|
||||||
|
> 노이즈가 있다.** 그래서 이 실험은 개수 말고 **특정 sid 하나**를 따로 추적한다.
|
||||||
|
|
||||||
|
## 1-4. 상주 탐침 파드 — **StatefulSet 밖에 있어야 한다**
|
||||||
|
|
||||||
|
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없다(`exit 127`).
|
||||||
|
|
||||||
|
**그리고 이 실험은 탐침이 재시작을 넘어 살아 있어야 한다.** 토큰을 재시작 전에
|
||||||
|
받아서 재시작 후에 써야 하기 때문이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
토큰을 어디에 두나
|
||||||
|
├─ Keycloak 파드 안 → 같이 죽는다. 못 쓴다
|
||||||
|
├─ 내 셸 변수 → 되지만 화면·히스토리에 남는다
|
||||||
|
└─ 단독 탐침 파드의 /tmp → StatefulSet 과 무관하게 산다 ★
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="K0=$K0" --env="K1=$K1" \
|
||||||
|
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
|
||||||
|
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||||
|
> ```
|
||||||
|
> **실측** — `19`
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
K0=10.42.1.94 K1=10.42.0.45 PW길이=19
|
||||||
|
```
|
||||||
|
|
||||||
|
`PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
|
||||||
|
|
||||||
|
## 1-5. ★ 토큰을 파드 안에 보관한다 — 여기가 이 실험의 함정이다
|
||||||
|
|
||||||
|
**하기** — 로그인해서 응답을 `/tmp/tok` 에, 거기서 뽑은 값을 `/tmp/rt` · `/tmp/sid` 에
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
|
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" > /tmp/tok
|
||||||
|
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||||
|
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
|
||||||
|
| cut -d. -f2 | base64 -d 2>/dev/null \
|
||||||
|
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
|
||||||
|
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||||
|
```
|
||||||
|
=== [1] 재시작 전 로그인 — 토큰을 파드 안에 보관 ===
|
||||||
|
sid = XLcgQWRiJrTkuNZcJsNeT_2j
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 값이 다 채워졌는지.**
|
||||||
|
|
||||||
|
| 출력 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `rt 1188 bytes / sid XLcg...` | 정상 |
|
||||||
|
| **`rt 1 bytes`** | **빈 문자열 + 개행.** 파싱 실패 |
|
||||||
|
| `sid` 가 비어 있음 | base64 패딩 때문에 잘렸다. sid 없이 진행하고 4-3 은 개수로 본다 |
|
||||||
|
|
||||||
|
### ★ 원래 실행이 실제로 빠진 함정
|
||||||
|
|
||||||
|
> **처음 재현 절차는 `/tmp/tok` 에 쓰고 `/tmp/rt` 를 읽었다.** `/tmp/rt` 를 만드는
|
||||||
|
> 줄이 빠져 있었다. 그러면 **빈 문자열이 `refresh_token=` 으로 전송되는데,
|
||||||
|
> 그래도 400 이 아니라 통과한 것처럼 보였다.**
|
||||||
|
|
||||||
|
**왜 위험한가** — 이 실험의 판정이 「재시작 후 refresh 가 `200` 인가」다.
|
||||||
|
**빈 토큰을 보내고 받은 응답을 「세션이 살아 있다」로 읽으면 결론이 통째로
|
||||||
|
거짓이 된다.** 그리고 그 오류는 **아무 에러도 안 낸다.**
|
||||||
|
|
||||||
|
**그래서 길이를 찍는다.** `wc -c` 한 번이 이 실험 전체를 지킨다.
|
||||||
|
|
||||||
|
**확인** — 못 미더우면 파일을 직접 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- ls -l /tmp/tok /tmp/rt /tmp/sid
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- head -c 40 /tmp/rt ; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
-rw-r--r-- 1 curl_use curl_gro 1188 Sep 4 13:19 /tmp/rt
|
||||||
|
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldU
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `/tmp/rt` 의 크기가 네 자리이고 내용이 `eyJ` 로
|
||||||
|
시작하는 것. `eyJ` 는 base64 로 인코딩된 `{"` 다. **JWT 는 전부 이렇게 시작한다.**
|
||||||
|
|
||||||
|
## 1-6. 대조군 — 재시작 전에 refresh 가 되는 것
|
||||||
|
|
||||||
|
**이 절을 건너뛰면 뒤의 200 이 아무 의미가 없다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||||
|
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 이 refresh 로 토큰이 회전했다.** `/tmp/rt` 의 값은 이제 **쓰인 토큰**이다.
|
||||||
|
다시 채워 둔다. 안 그러면 4-1 의 400 이 「재시작 때문」인지 「재사용 때문」인지
|
||||||
|
구별되지 않는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
|
'curl -s -X POST "http://$K0:8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d "password=$PW" > /tmp/tok
|
||||||
|
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
|
||||||
|
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
|
||||||
|
| cut -d. -f2 | base64 -d 2>/dev/null \
|
||||||
|
| sed -n "s/.*\"sid\":\"\([^\"]*\)\".*/\1/p" > /tmp/sid
|
||||||
|
echo "rt $(wc -c < /tmp/rt) bytes / sid $(cat /tmp/sid)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 sid 가 최종 추적 대상이다.** 적어 둔다.
|
||||||
|
|
||||||
|
**확인** — 그 세션이 DB 에 실제로 있는지 지금 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
|
||||||
|
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
user_session_id | created_on | last_session_refresh
|
||||||
|
--------------------------+------------+----------------------
|
||||||
|
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495513
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 행이 **1개** 있고, `created_on` 과
|
||||||
|
`last_session_refresh` 가 **같다.** 아직 갱신한 적이 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 세션이 DB 에 있다. **재시작 후에 이 행이 그대로
|
||||||
|
있고 `last_session_refresh` 만 올라가는 것**이 4-2 의 판정이다.
|
||||||
|
|
||||||
|
## 1-7. 캐시와 클러스터 크기
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
|
||||||
|
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||||
|
|
||||||
|
**형태**
|
||||||
|
```json
|
||||||
|
{"status":"success","data":{"resultType":"vector","result":[
|
||||||
|
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757040000.1,"2"]},
|
||||||
|
{"metric":{"__name__":"vendor_cluster_size","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757040000.1,"2"]}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||||
|
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 결과가 두 줄이고 값이 둘 다 `2`.
|
||||||
|
|
||||||
|
**확인** — 세션 캐시 엔트리 수도 지금 봐 둔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||||
|
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**0 이 아닌 값**이 나올 것이다. 재시작 후 **0 이 되는 것**이 4-4 의 판정이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 롤링 재시작
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **다만 되돌릴 것은 없다.**
|
||||||
|
|
||||||
|
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌리는 명령이 없다. 중간에
|
||||||
|
멈추려면 `rollout status` 를 `Ctrl-C` 로 끊으면 되지만 **롤아웃 자체는 계속
|
||||||
|
진행된다.** 끝날 때까지 두는 편이 낫다. 정말 되돌려야 하면:
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout undo statefulset/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 가용성 감시를 먼저 띄운다
|
||||||
|
|
||||||
|
**두 번째 터미널**에서 돌린다. **재시작보다 먼저 시작해야** 끊김 구간을 놓치지
|
||||||
|
않는다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 48); do
|
||||||
|
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 \
|
||||||
|
https://auth.hyeonworks.com/realms/master)"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자가 5초마다 하나씩 붙는다. `200` 이 아닌 값이 보이면
|
||||||
|
그 자리가 끊김이다.
|
||||||
|
|
||||||
|
> **여기서 `-w '%{http_code}'` 를 쓰는 이유** — 48번 반복해서 **비교할 값**만
|
||||||
|
> 필요하기 때문이다. 무엇이 잘못됐는지 알아보려면 그때 `curl -v` 로 한 번 보면
|
||||||
|
> 된다. 두 형태는 용도가 다르다.
|
||||||
|
|
||||||
|
> `--max-time 4` 는 5초 간격보다 짧게 잡은 것이다. **타임아웃이 간격보다 길면
|
||||||
|
> 요청이 밀려 시계열이 어긋난다.**
|
||||||
|
|
||||||
|
## 2-2. 재시작한다
|
||||||
|
|
||||||
|
**첫 번째 터미널**에서 친다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 재시작'
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||||
|
```
|
||||||
|
statefulset.apps/keycloak restarted
|
||||||
|
200 Waiting for partitioned roll out to finish: 0 out of 2 new pods have been updated...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
200 200 200 200 Waiting for partitioned roll out to finish: 1 out of 2 new pods have been updated...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
Waiting for 1 pods to be ready...
|
||||||
|
200 200 200 200 partitioned roll out complete: 2 new pods have been updated...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 위 원문은 두 터미널의 출력이 **한 파일에 섞여 기록된**
|
||||||
|
것이다. `200` 이 가용성 루프, `Waiting for...` 가 `rollout status`.
|
||||||
|
|
||||||
|
- **`0 out of 2` → `1 out of 2` → `complete`** — 한 번에 하나씩 간다
|
||||||
|
- 그 사이사이에 **`200` 이 계속 찍힌다**
|
||||||
|
|
||||||
|
**시각을 반드시 적어 둔다.** 뒤에서 지표가 「언제부터 변했나」를 볼 때 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 정말 재시작됐는지부터 본다.** 「세션이 살아남았다」는
|
||||||
|
결론은 **파드가 진짜 바뀌었을 때만** 의미가 있다.
|
||||||
|
|
||||||
|
## 3-1. 파드가 정말 새것인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||||
|
```
|
||||||
|
=== [6] 파드 나이 — 정말 재시작되었나 ===
|
||||||
|
keycloak-0 1/1 Running 0 44s
|
||||||
|
keycloak-1 1/1 Running 0 66s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지다.
|
||||||
|
|
||||||
|
- **`AGE` 가 초 단위다** — 1-1 에서 `2d` 였던 것이 `44s` 다. 진짜 새 파드다
|
||||||
|
- **두 나이가 다르다** (`44s` vs `66s`) — **한 번에 하나씩 내렸다는 증거**다.
|
||||||
|
22초 차이가 롤링의 간격이다. 둘이 같으면 동시에 내려간 것이고 무중단이 아니다
|
||||||
|
- `RESTARTS` 는 **여전히 `0`** — 파드가 재시작된 게 아니라 **교체**됐기 때문이다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `RESTARTS` 를 판정에 쓰면 안 된다는 것이 여기서
|
||||||
|
보인다. `rollout restart` 는 파드를 지우고 새로 만들므로 재시작 카운터는
|
||||||
|
새 파드에서 0 부터 시작한다.
|
||||||
|
|
||||||
|
**★ 파드 IP 가 바뀌었다.** 다시 잡는다.
|
||||||
|
```bash
|
||||||
|
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
echo "$K0 $K1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 탐침 파드는 다시 띄우면 안 된다.** `/tmp/rt` 와 `/tmp/sid` 가 같이 사라진다.
|
||||||
|
탐침 안의 `K0` 환경변수는 낡았으므로, **새 IP 를 명령줄에 직접 넘긴다.** 4절의
|
||||||
|
명령이 그렇게 되어 있다.
|
||||||
|
|
||||||
|
## 3-2. 가용성 시계열을 읽는다
|
||||||
|
|
||||||
|
두 번째 터미널의 출력을 본다.
|
||||||
|
|
||||||
|
**실측** — [`01-restart-availability.txt`](../../evidence/a8-rolling-restart/01-restart-availability.txt)
|
||||||
|
```
|
||||||
|
(위 숫자열이 재시작 중 외부 응답 코드의 시계열)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
200 200 200 200 200 200 200 200 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`200` 이 9개.** 비200 이 없다.
|
||||||
|
|
||||||
|
### ★ 「무중단」이라고 쓰기 전에 표본 수를 본다
|
||||||
|
|
||||||
|
```
|
||||||
|
9개 표본 × 5초 간격 = 약 45초를 9번 들여다본 것
|
||||||
|
│
|
||||||
|
└─ 5초보다 짧은 끊김은 이 측정으로 잡히지 않는다
|
||||||
|
```
|
||||||
|
|
||||||
|
**실제로 더 촘촘히 재니 끊김이 나왔다.** 후속 작업에서 **1초 간격·3초 타임아웃**
|
||||||
|
으로 D-2 롤백 전환을 재보니:
|
||||||
|
|
||||||
|
**실측** — [`experiment-followup-untested-items.md`](../../experiment-followup-untested-items.md) 2절
|
||||||
|
```
|
||||||
|
200 ×24 000 200 ×19
|
||||||
|
```
|
||||||
|
|
||||||
|
`000` 은 서버 오류가 아니라 **`--max-time 3` 타임아웃**이다. 파드 전환 순간
|
||||||
|
요청 하나가 3초를 넘겼다.
|
||||||
|
|
||||||
|
**그래서 정확한 서술은 이것이다.**
|
||||||
|
|
||||||
|
| 쓰면 안 되는 문장 | 정확한 문장 |
|
||||||
|
|---|---|
|
||||||
|
| 「무중단이었다」 | 「**5초 해상도에서 끊김이 관측되지 않았다**」 |
|
||||||
|
|
||||||
|
**더 촘촘히 보고 싶으면** 2-1 의 루프를 이렇게 바꾼다. **미검증**
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 150); do
|
||||||
|
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
|
https://auth.hyeonworks.com/realms/master)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. ★ 본 시험 — 재시작 전 토큰이 아직 통하는가
|
||||||
|
|
||||||
|
**하기** — 새 파드 IP 로, 파드 안에 보관해 둔 토큰을 쓴다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
|
'curl -s -w "\n%{http_code}\n" -X POST \
|
||||||
|
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli \
|
||||||
|
-d "refresh_token=$(cat /tmp/rt)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||||
|
```
|
||||||
|
=== [3] 재시작 전 발급한 refresh token 이 아직 통하는가 ===
|
||||||
|
대상 sid: XLcgQWRiJrTkuNZcJsNeT_2j
|
||||||
|
keycloak-0 에서 refresh HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `200`, 그리고 **본문에 새 토큰이 들어 있는 것.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **파드가 통째로 바뀌었는데 세션이 그대로다.**
|
||||||
|
새로 뜬 프로세스는 이 세션을 **메모리에서 알던 것이 아니다.** DB 에서 읽었다.
|
||||||
|
|
||||||
|
> **400 이 나왔다면 먼저 의심할 것은 결론이 아니라 토큰이다.**
|
||||||
|
> - 1-6 에서 `/tmp/rt` 를 다시 안 채웠다 → 이미 쓴 토큰이다
|
||||||
|
> - `rt 1 bytes` 를 놓쳤다 → 빈 문자열을 보내고 있다
|
||||||
|
> - args 에 `--features-disabled=persistent-user-sessions` 가 있다 → 그건 A-7 이다
|
||||||
|
>
|
||||||
|
> 셋 다 아니면 그때 결론을 의심한다.
|
||||||
|
|
||||||
|
## 4-2. DB 에 그 세션이 남아 있는가 — sid 로 정확히
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
|
||||||
|
where offline_flag='0' and user_session_id='$(sudo kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||||
|
```
|
||||||
|
=== [4] DB 에 그 세션이 남아 있는가 ===
|
||||||
|
user_session_id | created_on | last_session_refresh
|
||||||
|
--------------------------+------------+----------------------
|
||||||
|
XLcgQWRiJrTkuNZcJsNeT_2j | 1788495513 | 1788495577
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 숫자의 차이.**
|
||||||
|
|
||||||
|
```
|
||||||
|
1788495577 - 1788495513 = 64초
|
||||||
|
│ │
|
||||||
|
│ └─ 재시작 전에 세션이 만들어진 시각
|
||||||
|
└─ 재시작 후의 refresh 가 기록된 시각
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **응답 코드만 200 인 게 아니라 쓰기까지 정상이다.**
|
||||||
|
새 파드가 DB 에서 세션을 읽었고, 갱신 시각을 **DB 에 되썼다.**
|
||||||
|
|
||||||
|
`200` 만 봤다면 「캐시에 뭔가 남아서 답한 것 아닌가」를 배제할 수 없다.
|
||||||
|
**A-1 에서 실제로 그런 일이 있었다** — 캐시가 DB 와 무관하게 200 을 준 사례다.
|
||||||
|
여기서는 DB 행이 갱신됐으므로 그 가능성이 없다.
|
||||||
|
|
||||||
|
> **두 값은 유닉스 시각(초)이다.** 사람이 읽는 형태로 보려면:
|
||||||
|
> ```bash
|
||||||
|
> date -d @1788495513 ; date -d @1788495577
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 4-3. 전체 세션 수는 그대로인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||||
|
```
|
||||||
|
전체 온라인 세션: 151 (재시작 전 151)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 1-3 에서 적어 둔 값과 같은지.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **한 건도 안 잃었다.** sid 하나가 살아남은 것과
|
||||||
|
전체가 살아남은 것은 다른 주장이고, 둘 다 봐야 한다.
|
||||||
|
|
||||||
|
> 관리 API 호출이 세션을 만들기 때문에 **몇 건 늘어날 수는 있다.** 크게 줄었다면
|
||||||
|
> 그게 문제다.
|
||||||
|
|
||||||
|
## 4-4. 캐시는 사라진다 — 그게 정상이다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
|
||||||
|
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
|
||||||
|
sudo kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
|
||||||
|
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-survival.txt`](../../evidence/a8-rolling-restart/02-session-survival.txt)
|
||||||
|
```
|
||||||
|
=== [5] 캐시는 어떻게 되었는가 ===
|
||||||
|
keycloak-0 sessions 캐시 0.0 건 / cluster_size 2.0
|
||||||
|
keycloak-1 sessions 캐시 1.0 건 / cluster_size 2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지다.
|
||||||
|
|
||||||
|
- **캐시가 0 이다** — 프로세스 메모리라 재시작에 사라졌다
|
||||||
|
- **`keycloak-1` 의 1건** — 방금 4-1 의 refresh 를 처리하며 새로 담은 것이다.
|
||||||
|
0 이 아니라고 「캐시가 살아남았다」로 읽지 않는다
|
||||||
|
- **`cluster_size` 가 다시 2** — 클러스터가 스스로 재형성됐다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **A-0 의 모델이 그대로 확인된다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
재시작 전: 캐시 N건 + DB 151건
|
||||||
|
재시작 후: 캐시 0건 + DB 151건 ← 진실은 DB 에 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
**캐시가 통째로 날아가도 정확성은 유지되고 첫 접근만 느려진다.** 룩어사이드
|
||||||
|
캐시의 성질이다.
|
||||||
|
|
||||||
|
> Grafana 로 보면 세션 캐시가 0 으로 떨어지고 `cluster_size` 가 다시 2 가 되는
|
||||||
|
> 구간이 한 화면에 잡힌다 —
|
||||||
|
> [`a8-cache-reset-cluster-reformed.png`](../../evidence/a8-rolling-restart/a8-cache-reset-cluster-reformed.png)
|
||||||
|
|
||||||
|
## 4-5. 왜 무중단이 되는가
|
||||||
|
|
||||||
|
```
|
||||||
|
StatefulSet 롤링 재시작
|
||||||
|
│
|
||||||
|
├─ keycloak-1 종료 → Service 엔드포인트에서 빠짐
|
||||||
|
│ └─ 이 동안 keycloak-0 이 전부 받는다
|
||||||
|
├─ keycloak-1 기동 → readiness UP → 엔드포인트 복귀
|
||||||
|
│
|
||||||
|
└─ keycloak-0 종료 → ... (반복)
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 엔드포인트가 실제로 그렇게 움직였나. 재시작 중에 봐야 보인다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가 뜬다.
|
||||||
|
> `endpointslice` 를 본다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **한 번에 하나씩** 내리므로 항상 최소 하나는 Ready 다.
|
||||||
|
readiness 프로브가 이 전환을 정확히 맞춰준다. A-2 에서 「장애를 격리하는 장치」로
|
||||||
|
본 그 메커니즘이 여기서는 **정상 작업을 안전하게** 만든다.
|
||||||
|
|
||||||
|
| 무중단의 조건 | 빠지면 |
|
||||||
|
|---|---|
|
||||||
|
| **replica ≥ 2** | 하나뿐이면 내리는 동안 아무도 안 받는다 |
|
||||||
|
| **readiness 프로브** | 아직 기동 중인 파드로 트래픽이 간다 |
|
||||||
|
|
||||||
|
**둘 다 있어야 성립한다.** 이 실험대는 파드가 2개라서 됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
**주입이 정상 작업이었으므로 되돌릴 것이 없다.** 정리만 한다.
|
||||||
|
|
||||||
|
## 5-1. 탐침 파드를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod a8-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
**남겨 두면** 7200초 뒤에 스스로 끝나지만, 그 안에 다른 실험을 하면 **네임스페이스에
|
||||||
|
정체 모를 파드가 하나 있는 상태**가 된다. 지운다.
|
||||||
|
|
||||||
|
## 5-2. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
|
||||||
|
| Service | `sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||||
|
| 지표 | `vendor_cluster_size` | 양쪽 `2` |
|
||||||
|
| 세션 | `psql -tAc "select count(*) from offline_user_session where offline_flag='0'"` | 1-3 과 비슷한 값 |
|
||||||
|
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a8-probe` | `NotFound` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것 셋**
|
||||||
|
> - **replica 1 에서 어떻게 되는지** — 반드시 끊긴다고 적었지만 재지 않았다
|
||||||
|
> - **5초보다 짧은 끊김** — 3-2 참조. 후속 작업이 다른 조건에서 `000` 을 잡았다
|
||||||
|
> - **캐시가 0 에서 다시 차는 데 걸리는 시간** — 「첫 접근만 느려진다」고 썼지만
|
||||||
|
> 그 「느림」을 재지 않았다. [A-6](a6-latency-injection.md) 이 인접한 주제다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| **refresh 가 200 인데 뭔가 이상하다** | **`/tmp/rt` 가 비어 있다.** 빈 토큰인데 통과한 것처럼 보인다 | `wc -c < /tmp/rt` — 1-5 |
|
||||||
|
| refresh 가 `400 Session not active` | 1-6 뒤에 `/tmp/rt` 를 안 채웠다. 이미 쓴 토큰이다 | 새로 로그인해서 다시 담는다 |
|
||||||
|
| refresh 가 `400` 인데 토큰은 맞다 | **args 가 volatile 이다** | `get statefulset ... args` — 1-2. 그건 [A-7](a7-volatile-comparison.md) |
|
||||||
|
| 재시작 후 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 다시 — 3-1 |
|
||||||
|
| 탐침을 다시 띄웠더니 토큰이 없다 | **`/tmp/rt` 가 파드와 함께 사라졌다** | 탐침은 재시작 내내 유지한다 — 3-1 |
|
||||||
|
| `RESTARTS` 가 0 이라 재시작이 안 된 것 같다 | **`rollout restart` 는 파드를 교체한다** | `AGE` 로 본다 — 3-1 |
|
||||||
|
| `rollout status` 가 타임아웃 | 파드가 Ready 를 못 받는다 | `describe pod` 의 Events, `logs --previous` |
|
||||||
|
| 가용성 루프에 `000` 이 섞인다 | `--max-time` 초과. **서버 오류가 아니다** | 간격보다 짧은 타임아웃인지 — 2-1 |
|
||||||
|
| 가용성 루프가 전부 `000` | 루프가 잘못된 URL 을 친다 | `curl -v` 로 한 번 본다 |
|
||||||
|
| 세션 수가 크게 줄었다 | 다른 실험이 세션을 지웠거나 volatile 이다 | 1-2 · 1-3 을 다시 |
|
||||||
|
| DB 행의 `last_session_refresh` 가 안 올랐다 | 4-1 을 하기 전에 조회했다 | 순서: refresh → 조회 |
|
||||||
|
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | A-8 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **이 실험을 그대로 반복하면 정반대가 나와야 한다.** 그 한 쌍이 「왜 persistent 인가」의 답이다 |
|
||||||
|
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | 롤링 재시작이 안전하다는 것이 업그레이드의 **전제**다 |
|
||||||
|
| [A-2](a2-database-loss.md) DB 정지 | 여기서 「전환을 맞춰준」 readiness 가 거기서는 「장애를 격리」한다 |
|
||||||
|
| 구성 | 무중단은 공짜가 아니라 **replica ≥ 2 + readiness** 의 조합이다 |
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
# 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |
|
||||||
@@ -0,0 +1,852 @@
|
|||||||
|
# B-1 재현 가이드 — Redis 를 붙이고, 무엇이 옮겨졌고 무엇이 안 옮겨졌는지 찍어서 확인한다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b1-redis-session-store/`](../../evidence/b1-redis-session-store/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| **쿠버네티스가 넣지도 않은 환경변수로 파드를 죽이는 것** | `logs` · `printenv` |
|
||||||
|
| 그것이 `enableServiceLinks: false` 로 고쳐지는 것 | 롤아웃 성공 |
|
||||||
|
| 빈이 **321 → 402 (+81)** 로 늘어나는 것 | `/actuator/beans` |
|
||||||
|
| **그런데 authorized client 는 하나도 안 바뀐 것** | 같은 곳 |
|
||||||
|
| Redis 안의 키·필드·TTL, 그리고 **토큰이 없는 것** | `redis-cli` |
|
||||||
|
| 세션이 **Java 네이티브 직렬화**인 것 | `\xac\xed` |
|
||||||
|
| **「로그인은 되어 있는데 아무것도 못 하는」 상태** | 브라우저 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`B-0`](b0-bff-redis-deploy.md) 이 끝나 있다. **B-0 의 답(빈 세 개의 이름)을
|
||||||
|
손에 들고 시작한다** — 이 실험은 그 값들이 어떻게 바뀌는지를 재는 것이다.
|
||||||
|
- **브라우저가 필요하다.** 인가 코드 흐름은 왕복이 두 번이라 `curl` 로 대신할 수 없다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- `jq` 는 이 실험대에 **깔려 있지 않다.** 이 가이드는 `grep` 과 `redis-cli` 로 읽는다.
|
||||||
|
|
||||||
|
## 주의 — 이건 애플리케이션 구성을 바꾸는 실험이다
|
||||||
|
|
||||||
|
의존성과 설정을 바꿔 **다시 빌드하고 다시 배포한다.** 되돌리려면 소스 변경을
|
||||||
|
되돌리고 다시 빌드해야 하므로, **`git status` 가 깨끗한 상태에서 시작한다.**
|
||||||
|
|
||||||
|
전 구간 약 40분(빌드 시간 포함). 되돌리는 방법은 매 단계에 적어 두었다.
|
||||||
|
|
||||||
|
**★ 2-3 은 일부러 고장 난 상태로 배포한다.** 함정을 직접 보기 위해서다. 건너뛰고
|
||||||
|
싶으면 [2-4](#2-4-고침--enableservicelinks-false) 부터 시작해도 결과는 같다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 13:59–14:03 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 다른 방법을 썼다 |
|
||||||
|
|
||||||
|
파드 이름·세션 ID·Service IP·TTL 은 **당신 환경에서 다르다.** 이 문서는
|
||||||
|
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
B-0 이 답을 냈다. 세션도 토큰도 **인스턴스 메모리**에 있고, 그래서 replica 2 에서는
|
||||||
|
로그인조차 안 된다.
|
||||||
|
|
||||||
|
**처방은 뻔해 보인다 — 공유 저장소를 붙이면 된다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Redis 를 붙인다 → 상태가 공유된다 → 다중 인스턴스가 된다
|
||||||
|
↑
|
||||||
|
정말 그런가?
|
||||||
|
```
|
||||||
|
|
||||||
|
이 실험이 재는 것은 **「붙였다」와 「공유된다」 사이의 거리**다.
|
||||||
|
|
||||||
|
| | 물어볼 것 |
|
||||||
|
|---|---|
|
||||||
|
| 무엇이 옮겨졌나 | `/actuator/beans` 를 다시 찍는다 |
|
||||||
|
| **무엇이 안 옮겨졌나** | **같은 곳.** 안 바뀐 것을 확인하는 게 더 중요하다 |
|
||||||
|
| 옮겨진 것 안에 무엇이 들었나 | Redis 를 직접 연다 |
|
||||||
|
| 사용자에게는 어떻게 보이나 | 브라우저 |
|
||||||
|
|
||||||
|
그리고 배포 첫 시도에서 **쿠버네티스가 내 설정을 덮어쓰는** 함정을 만난다.
|
||||||
|
그게 1절과 2절의 절반이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 붙이기 전에
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
BFF 가 돌고 있나 → B-0 의 답 세 개 → Redis 가 비어 있나 → ★ 파드 안 환경변수
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. BFF 가 B-0 구성으로 돌고 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
bff-574c6d658b-8cz4x 1/1 Running 0 20m 10.42.0.51 kc-lab-1
|
||||||
|
bff-574c6d658b-zpkbp 1/1 Running 0 20m 10.42.1.52 kc-lab-2
|
||||||
|
redis-568bd7c4-5c5vc 1/1 Running 0 20m 10.42.1.53 kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — BFF 두 개가 **서로 다른 노드**에 있고, Redis 가 떠 있는 것.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Redis 는 **배포만 되어 있고 아직 연결되지 않았다.**
|
||||||
|
B-0 이 그렇게 만들어 뒀다. 이제 연결한다.
|
||||||
|
|
||||||
|
## 1-2. B-0 의 답을 다시 확인한다 — before 값
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||||
|
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||||
|
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-before.json
|
||||||
|
wc -c /tmp/beans-before.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 빈 수와 관련 빈 세 개. **미검증**
|
||||||
|
```bash
|
||||||
|
grep -o '"aliases":\[' /tmp/beans-before.json | wc -l
|
||||||
|
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-before.json \
|
||||||
|
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||||
|
| grep -iE 'authorizedclient|sessionRepository'
|
||||||
|
```
|
||||||
|
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
|
||||||
|
```
|
||||||
|
빈 수: 321 → 402 (+81)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
authorizedClientService
|
||||||
|
before: InMemoryOAuth2AuthorizedClientService
|
||||||
|
authorizedClientRepository
|
||||||
|
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||||
|
authorizedClientManager
|
||||||
|
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 빈 수 **321**, `sessionRepository` 는 **아예 없다.**
|
||||||
|
|
||||||
|
**★ 이 세 줄과 숫자를 적어 둔다.** 4-1 의 비교 대상이 이것이고, **비교 없이는
|
||||||
|
「안 바뀌었다」를 말할 수 없다.**
|
||||||
|
|
||||||
|
## 1-3. Redis 가 비어 있는지 본다
|
||||||
|
|
||||||
|
**확인** — 먼저 살아 있는지
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli info server | head
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
PONG
|
||||||
|
# Server
|
||||||
|
redis_version:7.4.x
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 키가 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
(integer) 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`0`.** 비어 있어야 4-2 에서 「내가 만든 것」이라고
|
||||||
|
말할 수 있다.
|
||||||
|
|
||||||
|
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 서버를 블로킹한다. 지금은 키가
|
||||||
|
> 0개라 차이가 없지만, 습관이 되면 운영에서 사고가 난다.
|
||||||
|
|
||||||
|
## 1-4. ★ 파드 안 환경변수를 미리 본다 — 함정이 여기 있다
|
||||||
|
|
||||||
|
**아직 아무것도 안 바꿨는데** 파드 안에 Redis 관련 환경변수가 이미 있다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
한 번은 통째로 본다. 그다음 걸러 본다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
REDIS_SERVICE_HOST=10.43.57.116
|
||||||
|
REDIS_SERVICE_PORT=6379
|
||||||
|
REDIS_PORT=tcp://10.43.57.116:6379
|
||||||
|
REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379
|
||||||
|
REDIS_PORT_6379_TCP_ADDR=10.43.57.116
|
||||||
|
REDIS_PORT_6379_TCP_PORT=6379
|
||||||
|
REDIS_PORT_6379_TCP_PROTO=tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`REDIS_PORT` 의 값이 포트 번호가 아니라 URL 이다.**
|
||||||
|
|
||||||
|
### 개념 — Service Links
|
||||||
|
|
||||||
|
**무엇인가.** 쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link
|
||||||
|
시절의 환경변수를 파드에 자동으로 넣는다. 옛 Docker 링크 호환을 위한 기능이고,
|
||||||
|
**기본값이 켜짐**이다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** Service 이름이 `redis` 이므로 `REDIS_*` 가 들어온다.
|
||||||
|
그리고 애플리케이션 설정도 `${REDIS_PORT:6379}` 를 읽는다. **이름이 겹친다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Service 이름이 redis 이면
|
||||||
|
REDIS_SERVICE_HOST=10.43.57.116
|
||||||
|
REDIS_SERVICE_PORT=6379
|
||||||
|
REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제
|
||||||
|
```
|
||||||
|
|
||||||
|
**`<SVCNAME>_PORT` 는 포트 번호가 아니라 URL 형태다.**
|
||||||
|
|
||||||
|
**없거나 틀리면.** 매니페스트에 `REDIS_PORT: "6379"` 를 명시하면 그게 이긴다 —
|
||||||
|
**그런데 명시를 안 하면 자동 주입이 이긴다.** 그리고 오류 메시지는 당신이 쓰지도
|
||||||
|
않은 값을 지목한다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **지금은 아무 일도 안 일어난다.** 애플리케이션이
|
||||||
|
그 변수를 안 읽기 때문이다. **다음 절에서 읽기 시작하는 순간 파드가 죽는다.**
|
||||||
|
|
||||||
|
> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 Service 이름일수록 위험하다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — Redis 를 붙인다
|
||||||
|
|
||||||
|
**되돌리기** — 먼저 읽어 둔다
|
||||||
|
```bash
|
||||||
|
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||||
|
deploy/lab/k8s/bff-redis.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 의존성 **두 개**를 함께 넣는다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim bff/pom.xml
|
||||||
|
```
|
||||||
|
```xml
|
||||||
|
<!-- spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
|
||||||
|
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
|
||||||
|
둘 다 있어야 자동구성이 걸린다 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.session</groupId>
|
||||||
|
<artifactId>spring-session-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 하나만 넣으면 조용히 in-memory 로 남는다.** 오류도 안 난다. 그래서
|
||||||
|
4-1 에서 **찍어서 확인**하는 절차가 필요하다.
|
||||||
|
|
||||||
|
## 2-2. 설정을 넣는다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim bff/src/main/resources/application.yml
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
session:
|
||||||
|
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
|
||||||
|
timeout: ${SPRING_SESSION_TIMEOUT:30m}
|
||||||
|
redis:
|
||||||
|
namespace: bff:session
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 ② — 테스트가 Redis 를 찾다가 죽는다
|
||||||
|
|
||||||
|
`spring-session-data-redis` 를 넣으면 **컨텍스트 기동 시 Redis 에 붙으려 한다.**
|
||||||
|
테스트에는 Redis 가 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
|
||||||
|
```
|
||||||
|
```java
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
|
||||||
|
// 테스트는 Redis 를 띄우지 않는다
|
||||||
|
"spring.session.store-type=none",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 한 줄이 없으면 빌드가 테스트 단계에서 죽는다.** 그리고 그 실패 메시지는
|
||||||
|
Redis 연결 오류라서 **「배포 환경 문제」로 읽히기 쉽다.** 실패한 곳은 빌드다.
|
||||||
|
|
||||||
|
## 2-3. ★ 일부러 `enableServiceLinks` 없이 배포한다
|
||||||
|
|
||||||
|
**함정을 직접 본다.** 이미 아는 함정을 문서에서 읽는 것과, 자기 터미널에서
|
||||||
|
그 오류 메시지를 만나는 것은 다르다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim deploy/lab/k8s/bff-redis.yaml
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
# enableServiceLinks: false ← 아직 넣지 않는다
|
||||||
|
containers:
|
||||||
|
- name: bff
|
||||||
|
env:
|
||||||
|
- name: SPRING_SESSION_STORE_TYPE
|
||||||
|
value: redis
|
||||||
|
- name: REDIS_HOST
|
||||||
|
value: redis.keycloak-lab.svc
|
||||||
|
# REDIS_PORT 를 일부러 안 준다 — 자동 주입이 어떻게 이기는지 본다
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 빌드하고 두 노드에 밀어 넣고 배포한다
|
||||||
|
```bash
|
||||||
|
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||||
|
echo "exit=$?"
|
||||||
|
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||||
|
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 2-4 가 곧 되돌리기다. 지금 멈추려면:
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout undo deployment/bff
|
||||||
|
```
|
||||||
|
|
||||||
|
### 무엇이 일어나는지 순서대로 본다
|
||||||
|
|
||||||
|
**확인 ①** — 넓게
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE
|
||||||
|
bff-695646ddb-kzs9k 0/1 CrashLoopBackOff 3 (20s ago) 90s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ②** — 왜인지 물어본다. **로그보다 먼저 이벤트를 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab describe pod -l app=bff | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ③** — 로그. 죽은 뒤라면 `--previous`
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=bff --previous --tail=40
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
|
||||||
|
```
|
||||||
|
Failed to bind properties under 'spring.data.redis.port' to int:
|
||||||
|
Property: spring.data.redis.port
|
||||||
|
Value: "${REDIS_PORT:6379}"
|
||||||
|
Reason: failed to convert java.lang.String to int
|
||||||
|
(caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379")
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 마지막 줄의 **`"tcp://10.43.57.116:6379"`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **내가 쓴 적 없는 값이 오류에 나온다.**
|
||||||
|
1-4 에서 미리 본 그 환경변수다. 쿠버네티스가 넣었다.
|
||||||
|
|
||||||
|
> **이 오류를 「Redis 가 안 떠서」로 읽기 쉽다.** 실제로 Redis 는 멀쩡하다.
|
||||||
|
> **파드가 Redis 에 붙어 보지도 못하고 설정 바인딩에서 죽었다.** 메시지가
|
||||||
|
> `Failed to bind properties` 라고 말하고 있다 — 연결 오류가 아니다.
|
||||||
|
|
||||||
|
**확인** — Redis 는 멀쩡한지 확인해 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-4. 고침 — `enableServiceLinks: false`
|
||||||
|
|
||||||
|
**두 가지 처방이 있다.**
|
||||||
|
|
||||||
|
| 처방 | 문제 |
|
||||||
|
|---|---|
|
||||||
|
| 환경변수 이름을 바꾼다 (`BFF_REDIS_PORT` 등) | **다음 사람이 같은 함정에 다시 빠진다** |
|
||||||
|
| **주입 자체를 끈다** | 근본 처방 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim deploy/lab/k8s/bff-redis.yaml
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
enableServiceLinks: false # 근본 처방
|
||||||
|
containers:
|
||||||
|
- name: bff
|
||||||
|
env:
|
||||||
|
- name: SPRING_SESSION_STORE_TYPE
|
||||||
|
value: redis
|
||||||
|
- name: REDIS_HOST
|
||||||
|
value: redis.keycloak-lab.svc
|
||||||
|
- name: REDIS_PORT
|
||||||
|
value: "6379"
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||||
|
```
|
||||||
|
**실측** — [`01-servicelinks-trap.txt`](../../evidence/b1-redis-session-store/01-servicelinks-trap.txt)
|
||||||
|
```
|
||||||
|
deployment.apps/bff configured
|
||||||
|
deployment "bff" successfully rolled out
|
||||||
|
bff-576d869c6d-bshvl true kc-lab-2
|
||||||
|
bff-695646ddb-kzs9k true kc-lab-1
|
||||||
|
bff-695646ddb-vjqzf true kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **세 줄이다.** replica 는 2인데 파드가 3개 보인다.
|
||||||
|
**롤아웃 전환 중에 찍은 것**이고, 옛 ReplicaSet 의 파드가 아직 종료 전이다.
|
||||||
|
잠시 뒤 두 개가 된다.
|
||||||
|
|
||||||
|
**확인** — 주입이 정말 사라졌나
|
||||||
|
```bash
|
||||||
|
BFF=$(sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||||
|
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- printenv | grep -i redis
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
REDIS_HOST=redis.keycloak-lab.svc
|
||||||
|
REDIS_PORT=6379
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`REDIS_SERVICE_HOST` 계열이 전부 사라졌고**, 내가 준
|
||||||
|
두 개만 남은 것. `REDIS_PORT` 가 `6379` 다.
|
||||||
|
|
||||||
|
## 2-5. 문제 ③ — 리소스 서버가 아예 없었다
|
||||||
|
|
||||||
|
API 호출이 `500` 이었다. **원인은 토큰이 아니었다.**
|
||||||
|
|
||||||
|
**확인** — 로그를 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs "$BFF" --tail=100 | grep -iE 'exception|error'
|
||||||
|
```
|
||||||
|
**실측** — [`experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) 1절
|
||||||
|
```
|
||||||
|
java.nio.channels.UnresolvedAddressException
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`UnresolvedAddressException`.** DNS 다.
|
||||||
|
|
||||||
|
`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 이었는데 `echo` 는
|
||||||
|
**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 다른 네임스페이스의 서비스는 <svc>.<ns>.svc 로 부른다
|
||||||
|
- name: RESOURCE_API_BASE_URL
|
||||||
|
value: http://echo.header-lab.svc:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 그 서비스가 실제로 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n header-lab get svc echo
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`500` 을 보고 「토큰이 없어서」라고 읽을 뻔했다.** 로그를 보니 DNS 였다.
|
||||||
|
> **증상과 원인을 붙이기 전에 로그를 본다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. 파드가 떴고 Redis 에 붙었나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide -l app=bff
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||||
|
wget -qO- http://localhost:8083/actuator/health
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```json
|
||||||
|
{"status":"UP","components":{"redis":{"status":"UP","details":{"version":"7.4.x"}},...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`redis` 컴포넌트가 있고 `UP` 인 것.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — B-0 에서는 이 컴포넌트가 **아예 없었다.**
|
||||||
|
`spring-boot-starter-data-redis` 가 헬스 인디케이터를 같이 들고 왔다.
|
||||||
|
**건강 체크에 새 항목이 생긴 것 자체가 자동구성이 걸렸다는 신호다.**
|
||||||
|
|
||||||
|
## 3-2. 로그인이 되나 — replica 2 에서
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 로 로그인한다.
|
||||||
|
**쿠키를 먼저 지운다.**
|
||||||
|
|
||||||
|
**실측** — [`b1-login-works-two-replicas.png`](../../evidence/b1-redis-session-store/b1-login-works-two-replicas.png)
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인이 된다.** B-0 에서 `replica 2` 로는 `/login?error`
|
||||||
|
였던 그 자리다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 인가 요청(state·PKCE verifier)이 이제 **Redis** 에
|
||||||
|
있으므로 콜백이 다른 인스턴스로 가도 찾을 수 있다. **B-0 이 replica 를 1로
|
||||||
|
줄여야 했던 문제는 고쳐졌다.**
|
||||||
|
|
||||||
|
**여기서 멈추면 「Redis 를 붙였더니 다 해결됐다」로 끝난다. 그게 이 실험이
|
||||||
|
막으려는 결론이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰
|
||||||
|
|
||||||
|
## 4-1. ★ 자동구성이 실제로 무엇을 바꿨나 — B-0 의 방법을 그대로
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec "$BFF" -- \
|
||||||
|
wget -qO- http://localhost:8083/actuator/beans > /tmp/beans-after.json
|
||||||
|
grep -o '"aliases":\[' /tmp/beans-after.json | wc -l
|
||||||
|
```
|
||||||
|
**실측** — [`02-autoconfig-after.txt`](../../evidence/b1-redis-session-store/02-autoconfig-after.txt)
|
||||||
|
```
|
||||||
|
빈 수: 321 → 402 (+81)
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 세션 저장소 계열. **미검증**
|
||||||
|
```bash
|
||||||
|
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||||
|
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||||
|
| grep -iE 'session|redis'
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
--- 세션 저장소 관련 (새로 생긴 것) ---
|
||||||
|
★ cookieSerializer -> DefaultCookieSerializer
|
||||||
|
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
|
||||||
|
★ sessionRepository -> RedisSessionRepository
|
||||||
|
★ springSessionRepositoryFilter -> SessionRepositoryFilter
|
||||||
|
★ redisConnectionFactory -> LettuceConnectionFactory
|
||||||
|
★ redisTemplate -> RedisTemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — ★ **안 바뀐 것.** 이쪽이 핵심이다. **미검증**
|
||||||
|
```bash
|
||||||
|
grep -o '"[A-Za-z0-9_.$-]*":{"aliases":\[[^]]*\],"scope":"[a-z]*","type":"[^"]*"' /tmp/beans-after.json \
|
||||||
|
| sed 's/{"aliases".*"type":"/ -> /' \
|
||||||
|
| grep -i authorizedclient
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
--- OAuth2 authorized client — 바뀌었는가? ---
|
||||||
|
authorizedClientService
|
||||||
|
before: InMemoryOAuth2AuthorizedClientService
|
||||||
|
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
|
||||||
|
authorizedClientRepository
|
||||||
|
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||||
|
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
|
||||||
|
authorizedClientManager
|
||||||
|
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||||
|
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
Application Session ──▶ Redis (인증 상태, principal, 인가 요청)
|
||||||
|
OAuth2AuthorizedClient ──▶ 프로세스 메모리 (access token, refresh token)
|
||||||
|
```
|
||||||
|
|
||||||
|
**「Redis 를 붙였다」가 「상태가 공유된다」를 뜻하지 않는다.**
|
||||||
|
`spring.session.store-type` 은 **HttpSession** 을 갈아끼우는 설정이고,
|
||||||
|
`OAuth2AuthorizedClient` 는 **그 설정과 무관한 다른 저장소**다.
|
||||||
|
|
||||||
|
> **찍어서 확인하지 않으면 이 사실을 알 방법이 없다.** 로그인은 되고, 화면도
|
||||||
|
> 뜨고, 파드도 건강하다. **B-0 을 실험으로 만든 이유가 이것이다** — before 가
|
||||||
|
> 있어야 after 를 읽는다.
|
||||||
|
|
||||||
|
## 4-2. Redis 안에 무엇이 들어갔나
|
||||||
|
|
||||||
|
**확인** — 키가 생겼나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
|
```
|
||||||
|
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||||
|
```
|
||||||
|
=== Redis 에 무엇이 들어 있는가 ===
|
||||||
|
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||||
|
총 키 수: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 네임스페이스가 **`bff:session`** 이다. `application.yml`
|
||||||
|
의 `spring.session.redis.namespace` 가 그대로 접두어가 됐다.
|
||||||
|
|
||||||
|
**하기** — 키 이름을 변수로 잡는다
|
||||||
|
```bash
|
||||||
|
KEY=$(sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | tr -d '\r')
|
||||||
|
echo "$KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ `grep -v expires` 가 필요한 이유** — Spring Session 은 만료 추적용 키
|
||||||
|
(`bff:session:expirations:*` · `bff:session:sessions:expires:*`)도 만든다.
|
||||||
|
그것을 잡으면 다음 명령이 빈 결과를 낸다.
|
||||||
|
|
||||||
|
**★ `tr -d '\r'`** — `redis-cli` 출력이 CR 을 달고 올 수 있다. 그대로 쓰면 키가
|
||||||
|
안 맞는데 오류는 안 난다.
|
||||||
|
|
||||||
|
**확인** — 타입과 필드
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli type "$KEY"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "$KEY"
|
||||||
|
```
|
||||||
|
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||||
|
```
|
||||||
|
타입: hash
|
||||||
|
필드: sessionAttr:SPRING_SECURITY_CONTEXT
|
||||||
|
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
|
||||||
|
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
|
||||||
|
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
|
||||||
|
필드: lastAccessedTime
|
||||||
|
필드: maxInactiveInterval
|
||||||
|
필드: creationTime
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **필드 목록에 토큰이 없다.**
|
||||||
|
|
||||||
|
### ★ refresh token 은 Redis 에 **없다**
|
||||||
|
|
||||||
|
「저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다」가 검증 항목이었다.
|
||||||
|
**답은 더 앞에 있었다 — 애초에 들어가지 않는다.**
|
||||||
|
|
||||||
|
**「토큰 암호화를 어떻게 할까」를 고민하기 전에, 토큰이 그 저장소에 가지도
|
||||||
|
않는다는 것을 먼저 알아야 한다.**
|
||||||
|
|
||||||
|
**확인** — TTL
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$KEY"
|
||||||
|
```
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
=== TTL (Q3 검증 3번 — session TTL) ===
|
||||||
|
TTL: 1772 초
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `1772`. `spring.session.timeout=30m`(1800초)에서 방금
|
||||||
|
지난 만큼 줄어든 값이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **세션 TTL 1772초와 access token 수명 60초가 처음부터
|
||||||
|
어긋나 있다.** 어느 쪽에 맞출지는 선택이 아니라 **이미 어긋나 있고 그 간극을
|
||||||
|
누가 메우는가**의 문제다. B-3 의 주제다.
|
||||||
|
|
||||||
|
## 4-3. 직렬화는 Java 네이티브다
|
||||||
|
|
||||||
|
**확인** — 값의 바이트를 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --no-raw hgetall "$KEY" | head -4
|
||||||
|
```
|
||||||
|
**실측** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||||
|
```
|
||||||
|
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
|
||||||
|
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`\xac\xed` 로 시작한다.**
|
||||||
|
|
||||||
|
> **`--no-raw` 를 쓰는 이유** — 바이너리를 이스케이프해서 보여준다. 안 쓰면
|
||||||
|
> 터미널이 제어문자를 먹고 화면이 깨진다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다.
|
||||||
|
|
||||||
|
| 결과 | |
|
||||||
|
|---|---|
|
||||||
|
| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 |
|
||||||
|
| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 |
|
||||||
|
| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 |
|
||||||
|
|
||||||
|
**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 바뀌면
|
||||||
|
Redis 에 남은 세션이 깨질 수 있다.
|
||||||
|
|
||||||
|
## 4-4. ★ 사용자에게는 어떻게 보이나 — 가장 중요한 부분
|
||||||
|
|
||||||
|
**하기** — 파드를 전부 교체한다. Redis 덕을 보는지 확인하는 것이다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 롤링 재시작은 정상 작업이라 되돌릴 것이 없다.
|
||||||
|
|
||||||
|
**하기** — **로그인은 그대로 둔 채** 브라우저에서
|
||||||
|
`https://app1.hyeonworks.com/bff/token-boundary` 를 연다.
|
||||||
|
|
||||||
|
**실측** — [`b1-token-boundary-after-redis.png`](../../evidence/b1-redis-session-store/b1-token-boundary-after-redis.png)
|
||||||
|
```json
|
||||||
|
{"pattern":"AP3-backend-for-frontend",
|
||||||
|
"principal":"labuser", ← 세션은 Redis 에서 복원되었다
|
||||||
|
"accessTokenStoredOnServer":false, ← 토큰은 사라졌다
|
||||||
|
"refreshTokenStoredOnServer":false,
|
||||||
|
"browserTokenCount":0,
|
||||||
|
"csrfProtectionEnabled":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`principal` 은 살아 있는데 두 토큰이 `false` 다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 관점: 로그인되어 있다고 나온다
|
||||||
|
실제: BFF 가 사용자를 대신해 아무것도 못 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다.
|
||||||
|
**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까.
|
||||||
|
|
||||||
|
> **이것이 「부분적으로만 공유했을 때」의 실패 모양이다.**
|
||||||
|
> **완전히 로그아웃되는 편이 차라리 낫다** — 적어도 사용자가 다시 로그인한다.
|
||||||
|
> 지금은 화면상 로그인 상태라 사용자가 아무것도 안 한다.
|
||||||
|
|
||||||
|
### B-0 과 나란히 놓으면
|
||||||
|
|
||||||
|
| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** |
|
||||||
|
|---|---|---|
|
||||||
|
| `principal` | labuser | labuser |
|
||||||
|
| `accessTokenStoredOnServer` | **true** | **false** |
|
||||||
|
| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** |
|
||||||
|
|
||||||
|
> **★ 스크린샷으로 시점을 구별하지 않는다.**
|
||||||
|
> [`README.md`](../../evidence/b1-redis-session-store/README.md) 가 적어 둔 대로,
|
||||||
|
> `b1-login-works-two-replicas.png` 와 `b1-token-boundary-after-redis.png` 는
|
||||||
|
> **동일 파일**이다. 세 시점 모두 `accessTokenStoredOnServer: false` 인 같은
|
||||||
|
> 화면이었기 때문이다. **시점 구별은 터미널 출력과 Redis/DB 조회가 한다.**
|
||||||
|
|
||||||
|
## 4-5. 그래서 무엇을 해야 하는가
|
||||||
|
|
||||||
|
`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 **따로** 필요하다.
|
||||||
|
|
||||||
|
| 후보 | |
|
||||||
|
|---|---|
|
||||||
|
| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** |
|
||||||
|
| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 |
|
||||||
|
| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 |
|
||||||
|
|
||||||
|
**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다.
|
||||||
|
세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.**
|
||||||
|
대신 세션이 커진다. **[B-2](b2-multi-instance-session.md) 에서
|
||||||
|
이 선택지를 비교한다.**
|
||||||
|
|
||||||
|
**「두 상태를 같은 저장소에 둘지 나눌지」는 선택지가 아니다 — 이미 나뉘어 있고,
|
||||||
|
나뉜 채로 두면 깨진다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. B-2 로 이어갈 것이면 그대로 둔다
|
||||||
|
|
||||||
|
이 구성이 B-2 의 출발점이다. **아무것도 안 되돌린다.**
|
||||||
|
|
||||||
|
## 5-2. B-0 상태로 되돌릴 때
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
git checkout -- bff/pom.xml bff/src/main/resources/application.yml \
|
||||||
|
bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java \
|
||||||
|
deploy/lab/k8s/bff-redis.yaml
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 되돌린 뒤에는 다시 빌드해서 다시 밀어 넣어야 한다.** 소스만 되돌리면
|
||||||
|
클러스터에는 여전히 옛 이미지가 돈다.
|
||||||
|
```bash
|
||||||
|
docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1
|
||||||
|
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||||
|
docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart deployment/bff
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/bff --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-3. Redis 를 비운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushdb
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — **없다.** 지운 세션은 돌아오지 않는다. 로그인한 사용자는 전부
|
||||||
|
로그아웃된다. **실험대라서 하는 일이다.**
|
||||||
|
|
||||||
|
세션 하나만 지우고 싶으면:
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 소스 | `git status --short` | 출력 없음 (B-0 로 되돌릴 때) |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide -l app=bff` | `2/2`, 두 노드에 하나씩 |
|
||||||
|
| Redis | `sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping` | `PONG` |
|
||||||
|
| Redis 키 | `... redis-cli dbsize` | 의도한 값 |
|
||||||
|
| Keycloak | `sudo kubectl -n keycloak-lab get pods \| grep keycloak` | 둘 다 `1/1 Running` |
|
||||||
|
| 밖 | `curl -I https://app1.hyeonworks.com/` | `200` |
|
||||||
|
| 임시 파일 | `rm -f /tmp/beans-before.json /tmp/beans-after.json /tmp/build.log` | — |
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것 셋**
|
||||||
|
> - **Redis 를 끊었을 때 무엇이 나는지** — B-5 의 주제다
|
||||||
|
> - **로그아웃 뒤 두 저장소에 무엇이 남는지** — B-2 로 넘긴다
|
||||||
|
> - **저장소 지연이 화면 지연으로 얼마나 번역되는지** — B-2 이후
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| **파드가 `CrashLoopBackOff`, 오류에 `tcp://...:6379`** | **쿠버네티스가 `REDIS_PORT` 를 주입했다** | `printenv \| grep -i redis` — 1-4 · 2-3 |
|
||||||
|
| 위 오류를 「Redis 가 죽어서」로 읽었다 | 메시지가 `Failed to bind properties` 다 | `redis-cli ping` 으로 Redis 를 따로 확인 |
|
||||||
|
| `enableServiceLinks` 를 넣었는데 그대로 | 파드가 아직 옛 것이다 | `rollout restart` 후 `printenv` 다시 |
|
||||||
|
| 빌드가 Redis 연결 오류로 죽는다 | **테스트가 Redis 를 찾는다** | `spring.session.store-type=none` — 2-2 |
|
||||||
|
| `sessionRepository` 가 안 생긴다 | **의존성을 하나만 넣었다.** 오류 없이 in-memory 로 남는다 | 두 개 다 있는지 `pom.xml` — 2-1 |
|
||||||
|
| `redis-cli --scan` 이 비어 있다 | 아직 로그인 안 했다 | 브라우저로 로그인 후 다시 |
|
||||||
|
| `hkeys` 가 빈 결과 | **만료 추적 키를 잡았다** | `grep -v expires` — 4-2 |
|
||||||
|
| 키가 맞는데 명령이 안 먹는다 | 출력에 CR 이 붙었다 | `tr -d '\r'` — 4-2 |
|
||||||
|
| 값이 깨져서 터미널이 이상해진다 | 바이너리를 그대로 찍었다 | `--no-raw` — 4-3 |
|
||||||
|
| API 호출이 `500` 인데 토큰은 멀쩡 | **DNS 다.** 다른 네임스페이스의 서비스 | 로그의 `UnresolvedAddressException` — 2-5 |
|
||||||
|
| `/actuator/beans` 가 `Bad Gateway` | 응답이 커서 프록시가 못 넘긴다 | 파드 안에서 받는다 — 4-1 |
|
||||||
|
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep` 으로 읽는다 — 4-1 |
|
||||||
|
| 로그인은 되는데 API 가 전부 실패 | **이게 이 실험의 결론이다** | `token-boundary` 의 두 `false` — 4-4 |
|
||||||
|
| 스크린샷으로 시점을 구별하려다 헷갈린다 | **두 파일이 동일하다** | 터미널 출력과 Redis 조회로 구별 — 4-4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-1 이 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 4-5 의 세 후보를 비교한다 |
|
||||||
|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** |
|
||||||
|
| [B-5](../../experiment-b5-redis-loss-persistence.md) Redis 소실 | 이제 잃을 것이 생겼다. `/data` 가 볼륨인지부터 본다 |
|
||||||
|
| [D-2](d2-version-upgrade.md) 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 |
|
||||||
|
| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
# B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b2-multi-instance-session/`](../../evidence/b2-multi-instance-session/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 파드는 `1/1 Running` 인데 테이블이 없는 상태 | `psql` 의 `Did not find any relation` |
|
||||||
|
| 조회 키에 session id 가 **없다**는 것 | `\d oauth2_authorized_client` 의 `PRIMARY KEY` |
|
||||||
|
| `bytea` 안에 든 **평문 JWT** | `convert_from(refresh_token_value,'UTF8')` |
|
||||||
|
| 두 번째 로그인이 같은 행을 덮어쓰는 것 | 행 수 1 그대로 · `md5` 만 바뀜 |
|
||||||
|
| 로그아웃이 **셋 중 하나만** 지우는 것 | Redis 0 키 · PostgreSQL 1 행 · Keycloak 2 세션 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- [`B-0`](b0-bff-redis-deploy.md) · [`B-1`](b1-redis-session-store.md)
|
||||||
|
이 끝나 **BFF 가 replica 2개**로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||||
|
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||||
|
- **브라우저가 필요하다.** BFF 는 authorization code 흐름이라 로그인을
|
||||||
|
`curl` 로 만들 수 없다. `https://app1.hyeonworks.com/` 에 붙어
|
||||||
|
`labuser` / `labpass` 로 들어간다. realm 은 `keycloak-patterns`.
|
||||||
|
- 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고
|
||||||
|
터미널에서 저장소를 세는 왕복이 이 실험의 전부다.
|
||||||
|
|
||||||
|
## 주의 — 이건 상태를 바꾸는 실험이다
|
||||||
|
|
||||||
|
DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. **실험대에서만 한다.**
|
||||||
|
전 구간 약 25분이고, 되돌리는 방법은 매 단계에 적어 두었다.
|
||||||
|
중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:09–14:13 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 UUID·해시는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||||
|
|
||||||
|
세션 UUID·md5·타임스탬프는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||||||
|
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
|
||||||
|
전부 위 실행 기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
B-1 이 **Application Session 만** Redis 로 옮겼다. 그러자 사용자는 로그인
|
||||||
|
상태로 보이는데 **BFF 에는 access token 이 없는** 상태가 만들어졌다.
|
||||||
|
세션과 토큰이 서로 다른 것에 들어 있고, 한쪽만 옮겼기 때문이다.
|
||||||
|
|
||||||
|
토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 **무엇이 같이 고쳐지고
|
||||||
|
무엇이 안 고쳐지는가**다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| 통념 | 공유 저장소로 옮기면 **다중 인스턴스 문제가 해결된다** |
|
||||||
|
| B-2 모델 | 인스턴스 간 공유만 해결되고 **브라우저 간 격리와 로그아웃 정리는 그대로** |
|
||||||
|
|
||||||
|
> **개념 — 「어디에 두는가」와 「어떻게 찾는가」는 독립이다.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
|
||||||
|
> 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 이 실험이 판정하는 것은 두 번째다. 그리고 **키는 코드가 아니라 스키마에
|
||||||
|
> 박혀 있다** — 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없다.
|
||||||
|
> 1-3 에서 그 줄을 직접 본다.
|
||||||
|
|
||||||
|
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데
|
||||||
|
**셋이 서로 다른 시스템에 있다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
① HttpSession Redis Spring Security 가 지운다
|
||||||
|
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
|
||||||
|
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 두 번째 로그인을 만들기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 덮어쓰기를 보려면 **덮어쓰이기 전의
|
||||||
|
행**이 있어야 하고, 로그아웃 정리를 보려면 **로그아웃 전의 세 숫자**가 있어야
|
||||||
|
한다. 넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드와 노드
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**형태** — IP 와 해시는 당신 것과 다르다
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE IP NODE
|
||||||
|
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
|
||||||
|
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
|
||||||
|
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||||
|
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||||
|
```
|
||||||
|
`10.42.0.52` 와 `10.42.1.124` 는 [B-5 의 증거](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||||
|
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
|
||||||
|
`nodeSelector` 로 **`kc-lab-2` 에 고정**해 둔다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `bff` 가 **두 개**이고 `READY` 가 둘 다 `1/1`
|
||||||
|
- **`NODE` 가 서로 다르다** — 같은 노드에 몰려 있으면 「다른 인스턴스」가
|
||||||
|
같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의
|
||||||
|
`topologySpreadConstraints` 가 이걸 벌려 놓는다
|
||||||
|
- `RESTARTS` 가 `0` — 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 실험의 질문(Q1)은 **요청이 로그인을 처리하지
|
||||||
|
않은 인스턴스에 떨어질 수 있어서** 생긴다. replica 가 하나면 질문 자체가
|
||||||
|
성립하지 않는다.
|
||||||
|
|
||||||
|
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||||
|
```
|
||||||
|
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||||||
|
```
|
||||||
|
deployment.apps/bff configured
|
||||||
|
deployment "bff" successfully rolled out
|
||||||
|
bff-555df79c97-6j86w 1/1 Running 0 44s
|
||||||
|
bff-555df79c97-vgg6g 1/1 Running 0 22s
|
||||||
|
```
|
||||||
|
위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.
|
||||||
|
|
||||||
|
## 1-2. 테이블이 실제로 있는가 — 없으면 여기서 멈춘다
|
||||||
|
|
||||||
|
**이 실험은 원래 여기서 한 번 넘어졌다.** 파드는 떴고 Hikari 도 붙었는데
|
||||||
|
테이블이 없었다. 그리고 **아무도 그것을 신고하지 않았다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||||
|
```
|
||||||
|
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||||||
|
```
|
||||||
|
=== oauth2_authorized_client 테이블이 생겼는가 ===
|
||||||
|
Did not find any relation named "oauth2_authorized_client".
|
||||||
|
command terminated with exit code 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 이 두 줄이 나오면 **아직 아무것도 저장되지 않는
|
||||||
|
상태**다. 테이블이 있으면 컬럼 표가 나온다(1-3).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 스키마 초기화가 **조용히 실패**했다.
|
||||||
|
|
||||||
|
> **개념 — `blob` 은 PostgreSQL 에 없는 타입이다.**
|
||||||
|
>
|
||||||
|
> Spring Security 는 DDL 을 **두 벌** 번들한다.
|
||||||
|
>
|
||||||
|
> | 파일 | 토큰 컬럼 타입 |
|
||||||
|
> |---|---|
|
||||||
|
> | `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
|
||||||
|
> | `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
|
||||||
|
>
|
||||||
|
> 기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 난다. 그리고
|
||||||
|
> `spring.sql.init.continue-on-error: true` 가 켜져 있으면 **그 실패가
|
||||||
|
> 삼켜지고 파드는 정상으로 보인다.**
|
||||||
|
>
|
||||||
|
> `continue-on-error` 는 **「없어도 되는 초기화」에만** 쓴다. 여기서는
|
||||||
|
> 없으면 안 되는 초기화였다.
|
||||||
|
>
|
||||||
|
> **정정 노트** — 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언
|
||||||
|
> 차이" 였다가 정정됐다. **Liquibase 가 아니다.** 여기서 스키마를 태우는
|
||||||
|
> 것은 Spring Boot 의 `spring.sql.init` 이고, DDL 은
|
||||||
|
> `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는
|
||||||
|
> Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
|
||||||
|
|
||||||
|
### 없으면 만든다 — 이건 명령이 아니라 파일이다
|
||||||
|
|
||||||
|
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 **파일로 만든다.** 터미널에
|
||||||
|
붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
vim /tmp/oauth2-pg.sql
|
||||||
|
```
|
||||||
|
```sql
|
||||||
|
-- file: /tmp/oauth2-pg.sql
|
||||||
|
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
|
||||||
|
CREATE TABLE oauth2_authorized_client (
|
||||||
|
client_registration_id varchar(100) NOT NULL,
|
||||||
|
principal_name varchar(200) NOT NULL,
|
||||||
|
access_token_type varchar(100) NOT NULL,
|
||||||
|
access_token_value bytea NOT NULL,
|
||||||
|
access_token_issued_at timestamp NOT NULL,
|
||||||
|
access_token_expires_at timestamp NOT NULL,
|
||||||
|
access_token_scopes varchar(1000) DEFAULT NULL,
|
||||||
|
refresh_token_value bytea DEFAULT NULL,
|
||||||
|
refresh_token_issued_at timestamp DEFAULT NULL,
|
||||||
|
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
PRIMARY KEY (client_registration_id, principal_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
**실측** — 위 DDL 은 [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||||
|
의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다.
|
||||||
|
|
||||||
|
**하기** — 태운다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
|
||||||
|
```
|
||||||
|
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||||
|
```
|
||||||
|
=== 적용 ===
|
||||||
|
CREATE TABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — **이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다.**
|
||||||
|
정말 처음 상태로 되돌리려면:
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
|
||||||
|
```
|
||||||
|
|
||||||
|
> `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안
|
||||||
|
> 일어나고 오류도 안 난다 — `kubectl exec` 는 stdin 을 기본으로 연결하지
|
||||||
|
> 않는다.
|
||||||
|
|
||||||
|
## 1-3. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||||||
|
```
|
||||||
|
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||||||
|
```
|
||||||
|
Table "public.oauth2_authorized_client"
|
||||||
|
Column | Type | Collation | Nullable | Default
|
||||||
|
-------------------------+-----------------------------+-----------+----------+-------------------------
|
||||||
|
client_registration_id | character varying(100) | | not null |
|
||||||
|
principal_name | character varying(200) | | not null |
|
||||||
|
access_token_type | character varying(100) | | not null |
|
||||||
|
access_token_value | bytea | | not null |
|
||||||
|
access_token_issued_at | timestamp without time zone | | not null |
|
||||||
|
access_token_expires_at | timestamp without time zone | | not null |
|
||||||
|
access_token_scopes | character varying(1000) | | | NULL::character varying
|
||||||
|
refresh_token_value | bytea | | |
|
||||||
|
refresh_token_issued_at | timestamp without time zone | | |
|
||||||
|
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
|
||||||
|
Indexes:
|
||||||
|
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **맨 아래 `Indexes:` 줄** 하나다.
|
||||||
|
|
||||||
|
```
|
||||||
|
PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||||
|
└── "keycloak" ──┘ └── "labuser" ──┘
|
||||||
|
세션 id 가 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든
|
||||||
|
`(keycloak, labuser)` 라는 **한 행**을 쓴다. B-0 에서 빈 이름
|
||||||
|
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작했던 것이
|
||||||
|
**테이블 정의로 확정된다.**
|
||||||
|
|
||||||
|
**저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.**
|
||||||
|
4-1 에서 그것을 눈으로 확인한다.
|
||||||
|
|
||||||
|
## 1-4. 세 저장소를 세는 명령을 정해 둔다
|
||||||
|
|
||||||
|
4절에서 이 세 숫자를 **로그아웃 전후로** 비교한다. 지금 형태를 확정해 두고,
|
||||||
|
매번 같은 명령을 친다. 다른 명령으로 재면 비교가 아니다.
|
||||||
|
|
||||||
|
**확인 ①** — Redis 의 BFF 세션
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||||
|
```
|
||||||
|
**형태** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||||||
|
의 B-1 측정과 같은 모양이다
|
||||||
|
```
|
||||||
|
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 Redis 를 잡아 두고 전 키를
|
||||||
|
> 훑는다. `--scan` 은 커서로 나눠 돌아 블로킹하지 않는다.
|
||||||
|
>
|
||||||
|
> **`dbsize` 는 이 실험에서 부정확하다.** Redis 하나를 BFF 와 oauth2-proxy
|
||||||
|
> (B-7)가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다.
|
||||||
|
> **접두어로 걸러 세는 것**이 맞다.
|
||||||
|
|
||||||
|
**확인 ②** — PostgreSQL 의 authorized client
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ③** — Keycloak 의 SSO 세션. 온라인 세션도 `offline_user_session` 에
|
||||||
|
`offline_flag = 0` 으로 들어 있다(B-3 에서 확인된 성질이다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak \
|
||||||
|
-c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다.
|
||||||
|
같은 숫자를 DB 쪽에서 보는 형태다. 관리 API 로 보려면:
|
||||||
|
```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)"
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get client-session-stats -r keycloak-patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||||||
|
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 1-5. 브라우저로 로그인하고 대조군 행을 잡는다
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 열고
|
||||||
|
**Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간다. 그다음
|
||||||
|
**token 경계 확인** 을 누른다.
|
||||||
|
|
||||||
|
**실측** — 해설 문서 3절
|
||||||
|
```json
|
||||||
|
{"principal":"labuser",
|
||||||
|
"accessTokenStoredOnServer":true, ← B-1 에서는 false 였다
|
||||||
|
"refreshTokenStoredOnServer":true,
|
||||||
|
"browserTokenCount":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `accessTokenStoredOnServer` 가 `true`.
|
||||||
|
`browserTokenCount` 가 `0` 인 것이 BFF 패턴의 정의다 — **토큰이 브라우저에
|
||||||
|
없다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — B-1 에서는 이 값이 `false` 로 나올 수 있었다.
|
||||||
|
authorized client 가 프로세스 메모리에 있어 **로그인을 처리하지 않은 replica**
|
||||||
|
가 답하면 아무것도 못 찾았기 때문이다. 지금은 두 replica 가 같은 PostgreSQL
|
||||||
|
행을 본다.
|
||||||
|
|
||||||
|
> 이 값이 지금도 `false` 로 나온다면 **테이블은 만들었는데 옛 세션을 쓰고
|
||||||
|
> 있는 것**이다. 증거의 [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png)
|
||||||
|
> 가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
|
||||||
|
|
||||||
|
**확인** — 지금 행을 잡아 둔다. **이 md5 와 `issued_at` 이 대조군이다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select client_registration_id, principal_name, access_token_issued_at,
|
||||||
|
md5(access_token_value) as at_md5
|
||||||
|
from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||||
|
```
|
||||||
|
=== [현재] 같은 사용자의 항목 ===
|
||||||
|
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||||
|
------------------------+----------------+----------------------------+----------------------------------
|
||||||
|
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
|
||||||
|
(1 row)
|
||||||
|
|
||||||
|
행 수: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `(1 row)` 와 `at_md5`. **둘 다 종이에 적어 둔다.**
|
||||||
|
2절 뒤에 이 두 값을 다시 본다.
|
||||||
|
|
||||||
|
> **왜 토큰 값이 아니라 md5 인가.** 값 자체는 **지금 쓸 수 있는 자격증명**
|
||||||
|
> 이라 터미널 스크롤백에 남기면 안 된다. md5 는 「같은가 다른가」만 답하고
|
||||||
|
> 그게 이 절이 물어보는 전부다.
|
||||||
|
|
||||||
|
크기도 같이 봐 둔다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select client_registration_id, principal_name, access_token_type,
|
||||||
|
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
|
||||||
|
from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 3절. (이 표는 `.txt` 증거에는 없고 문서에만 남아 있다)
|
||||||
|
```
|
||||||
|
client_registration_id | principal_name | access_token_type | at_len | rt_len
|
||||||
|
------------------------+----------------+-------------------+--------+--------
|
||||||
|
keycloak | labuser | Bearer | 1431 | 744
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 「두 번째 브라우저」를 만든다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 방법을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 로그인하면
|
||||||
|
새 세션이 만들어지고 원래 상태로 돌아온다.
|
||||||
|
|
||||||
|
## 2-1. 실제로는 브라우저를 두 개 쓰지 않는다 — 왜 등가인가
|
||||||
|
|
||||||
|
증거 [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||||
|
에는 이렇게 적혀 있다.
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
|
||||||
|
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
|
||||||
|
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
|
||||||
|
```
|
||||||
|
|
||||||
|
**「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
|
||||||
|
이었다.** 조회 키가 `(clientRegistrationId, principalName)` 이므로
|
||||||
|
**브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가**다.
|
||||||
|
|
||||||
|
> **다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다.**
|
||||||
|
> 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, **측정하지 않은 것을
|
||||||
|
> 측정한 것처럼 적었다**고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면
|
||||||
|
> 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야
|
||||||
|
> 하고, 다르면 그게 더 중요한 발견이다.
|
||||||
|
|
||||||
|
## 2-2. Redis 의 BFF 세션만 지운다
|
||||||
|
|
||||||
|
**지우기 전에 무엇을 지울지 눈으로 본다.** 이 Redis 는 BFF 혼자 쓰는 것이
|
||||||
|
아니다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
bff:session:sessions:c63c39ee-...
|
||||||
|
bff:session:expires:c63c39ee-...
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `_oauth2_proxy-` 로 시작하는 키가 섞여 있는지.
|
||||||
|
있으면 **`FLUSHALL` 을 치면 안 된다** — B-7 의 oauth2-proxy 세션까지 날아가
|
||||||
|
그쪽 실험이 오염된다. 접두어로 골라 지운다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
|
||||||
|
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||||
|
date '+%H:%M:%S 세션 삭제'
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 스크립트였다. 이 형태는
|
||||||
|
[후속 문서 §3](../../experiment-followup-untested-items.md) 이 oauth2-proxy
|
||||||
|
세션을 지울 때 쓴 것과 같은 모양이다.
|
||||||
|
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
(integer) 2
|
||||||
|
16:21:03 세션 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 적어 둔다.** 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로
|
||||||
|
「새 로그인이 실제로 일어났는가」를 판정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. Redis 에서 BFF 세션만 사라졌나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 첫 명령은 **아무것도 안 나와야** 하고, 두 번째는
|
||||||
|
**아까와 같아야** 한다. 두 번째까지 비었으면 `FLUSHALL` 을 친 것이고,
|
||||||
|
B-7 세션을 날린 것이다.
|
||||||
|
|
||||||
|
## 3-2. 파드를 죽이지 않았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** 세션을 지우는 것은
|
||||||
|
BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고,
|
||||||
|
그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.
|
||||||
|
|
||||||
|
## 3-3. 다음 요청이 정말 새 로그인을 만드는가
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고
|
||||||
|
**token 경계 확인** 을 누른다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인 화면이 뜨지 않고 그냥 들어가진다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Redis 세션은 지워졌지만 **Keycloak SSO 세션은
|
||||||
|
살아 있다.** 그래서 BFF 가 `/oauth2/authorization/keycloak` 으로 보내면
|
||||||
|
Keycloak 이 화면 없이 즉시 코드를 돌려주고, **새 로그인 한 벌이 조용히
|
||||||
|
만들어진다.** 이것이 「모의 두 번째 브라우저」다.
|
||||||
|
|
||||||
|
> **이 조용한 재인증이 6절에서 다시 나온다.** 여기서는 편리하지만
|
||||||
|
> 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다.
|
||||||
|
> **같은 성질의 양면**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 행이 늘었는가, 덮어써졌는가
|
||||||
|
|
||||||
|
**확인** — 1-5 와 **똑같은 명령**을 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select client_registration_id, principal_name, access_token_issued_at,
|
||||||
|
md5(access_token_value) as at_md5
|
||||||
|
from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||||
|
```
|
||||||
|
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
|
||||||
|
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||||
|
------------------------+----------------+----------------------------+----------------------------------
|
||||||
|
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
|
||||||
|
(1 row)
|
||||||
|
|
||||||
|
행 수: 1
|
||||||
|
|
||||||
|
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지를 **한꺼번에** 본다.
|
||||||
|
|
||||||
|
| 값 | 대조군(1-5) | 지금 | 읽는 법 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 행 수 | `(1 row)` | `(1 row)` | **INSERT 가 아니다** |
|
||||||
|
| `at_md5` | `675af228…` | `e19a63fc…` | **내용은 바뀌었다** |
|
||||||
|
| `issued_at` | `05:10:46` | `05:12:13` | 2-2 의 삭제 시각 뒤 = 새 로그인 맞다 |
|
||||||
|
|
||||||
|
셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면
|
||||||
|
「새 행이 생겼나?」로 읽힌다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **UPDATE 다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 A 로그인 → (keycloak, labuser) 행 생성
|
||||||
|
브라우저 B 로그인 → 같은 행을 덮어쓴다
|
||||||
|
└─ A 의 토큰은 사라진다
|
||||||
|
```
|
||||||
|
|
||||||
|
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
|
||||||
|
당장은 아무 증상이 없다. 증상은 나중에 나온다.
|
||||||
|
|
||||||
|
| 언제 문제가 되는가 | |
|
||||||
|
|---|---|
|
||||||
|
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
|
||||||
|
| refresh 회전이 켜져 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → [B-3](b3-refresh-token-contention.md) |
|
||||||
|
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
|
||||||
|
|
||||||
|
## 4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
|
||||||
|
|
||||||
|
**1-3 의 `PRIMARY KEY` 줄을 다시 본다.** 그 줄이 답이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
InMemory → PostgreSQL → Redis → 직접 구현
|
||||||
|
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
|
||||||
|
`OAuth2AuthorizedClientRepository` 쪽 이야기다.**
|
||||||
|
|
||||||
|
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|
||||||
|
|---|---|---|
|
||||||
|
| `JdbcOAuth2AuthorizedClientService` | **불필요** (같은 인터페이스) | 안 고쳐짐 |
|
||||||
|
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
|
||||||
|
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
|
||||||
|
|
||||||
|
**이 실험이 두 번째를 고르지 않은 이유**는 Q3 가 "Redis 와 JDBC 중 무엇"을
|
||||||
|
물었기 때문이고, 그 대가로 조회 키 문제가 남았다. **선택이 남긴 자국을
|
||||||
|
측정한 것**이지 실수가 아니다.
|
||||||
|
|
||||||
|
## 4-3. 저장된 것이 평문인가
|
||||||
|
|
||||||
|
**먼저 길이만 본다.** 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select length(refresh_token_value) from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 3절의 `rt_len`
|
||||||
|
```
|
||||||
|
744
|
||||||
|
```
|
||||||
|
|
||||||
|
**744 바이트다.** 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 이 형태는 **화면에
|
||||||
|
남는 양을 줄인** 것이다.
|
||||||
|
|
||||||
|
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||||||
|
의 앞부분(원래 실행이 찍은 길이 그대로)
|
||||||
|
```
|
||||||
|
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
|
||||||
|
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`eyJ` 로 시작한다.** 그것이 `{"` 의 base64 다.
|
||||||
|
JWT 는 예외 없이 이렇게 시작한다.
|
||||||
|
|
||||||
|
> **`convert_from` 이 성공한다는 것 자체가 답이다.** 암호화된 바이트라면
|
||||||
|
> UTF-8 로 디코드되지 않고 오류가 난다. **읽힌다 = 텍스트다.**
|
||||||
|
|
||||||
|
정말 JWT 인지 헤더를 풀어 본다.
|
||||||
|
|
||||||
|
**확인** — **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
|
||||||
|
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||||
|
```
|
||||||
|
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||||||
|
```
|
||||||
|
=== 저장된 바이트를 그대로 디코드한 결과 ===
|
||||||
|
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
|
||||||
|
refresh_token 페이로드(앞부분):
|
||||||
|
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||||
|
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||||
|
|
||||||
|
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
|
||||||
|
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는
|
||||||
|
토큰을 얻는다.** 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든
|
||||||
|
그대로 쓸 수 있다. `Spring Security 기본 구현은 저장 시 암호화하지 않는다.`
|
||||||
|
암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
|
||||||
|
|
||||||
|
> **원래 실행은 여기서 한 번 넘어졌다.** 증거 파일에 그 실패가 그대로 있다.
|
||||||
|
> ```
|
||||||
|
> === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
|
||||||
|
> File "<string>", line 3
|
||||||
|
> h=open(/tmp/hdr.txt).read().strip()
|
||||||
|
> ^
|
||||||
|
> SyntaxError: invalid syntax
|
||||||
|
> ```
|
||||||
|
> **파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것**이다. 셸 안에
|
||||||
|
> 프로그램을 밀어 넣으면 이렇게 된다 — 문법 오류가 측정 결과 자리에
|
||||||
|
> 남는다. `cut` 과 `base64 -d` 로 충분하고, 그건 문법이 틀릴 자리가 없다.
|
||||||
|
|
||||||
|
## 4-4. 로그아웃 — 세 저장소를 한 번에 센다
|
||||||
|
|
||||||
|
**로그아웃 전에 세 숫자를 먼저 잡는다.** 1-4 에서 정한 명령 그대로다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
|
||||||
|
```
|
||||||
|
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||||||
|
```
|
||||||
|
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
|
||||||
|
로그아웃 전
|
||||||
|
Redis: 1 키
|
||||||
|
PostgreSQL: 1 행
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 로그아웃한다. **화면에 로그아웃 버튼이 없다** —
|
||||||
|
`index.html` 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
|
||||||
|
CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다
|
||||||
|
(`F12` → Console, 로그인된 app1 탭에서).
|
||||||
|
|
||||||
|
**미검증**
|
||||||
|
```js
|
||||||
|
const csrf = await (await fetch('/bff/csrf')).json();
|
||||||
|
const token = decodeURIComponent(
|
||||||
|
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
|
||||||
|
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
|
||||||
|
console.log(r.status, r.url);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **왜 셸이 아니라 브라우저인가** — 세션 쿠키가 `HttpOnly` 라 `curl` 로
|
||||||
|
> 로그인 상태를 재현할 수 없다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게
|
||||||
|
> 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`), 그래서 이
|
||||||
|
> 조각이 성립한다. 해설 문서 8절은 같은 일을 **form 파라미터 `_csrf`** 로
|
||||||
|
> 적었다 — 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
|
||||||
|
|
||||||
|
**되돌리기** — 브라우저에서 다시 로그인한다.
|
||||||
|
|
||||||
|
**확인** — 로그아웃 후, **같은 세 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select principal_name, access_token_issued_at, access_token_expires_at
|
||||||
|
from oauth2_authorized_client"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||||
|
```
|
||||||
|
**실측** — [`05-logout-cleanup.txt`](../../evidence/b2-multi-instance-session/05-logout-cleanup.txt)
|
||||||
|
```
|
||||||
|
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
|
||||||
|
Redis 세션 : 0 키
|
||||||
|
PostgreSQL 토큰 : 1 행
|
||||||
|
|
||||||
|
principal_name | access_token_issued_at | access_token_expires_at
|
||||||
|
----------------+----------------------------+----------------------------
|
||||||
|
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
|
||||||
|
(1 row)
|
||||||
|
|
||||||
|
|
||||||
|
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
|
||||||
|
|
||||||
|
=== Keycloak 쪽 SSO 세션은? ===
|
||||||
|
Keycloak 온라인 세션: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 숫자를 나란히 놓는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
로그아웃 후:
|
||||||
|
Redis 세션 : 0 키 ← 정리됨
|
||||||
|
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
|
||||||
|
Keycloak SSO : 2 세션 ← 남아 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **셋 중 하나만 지워졌다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
로그아웃
|
||||||
|
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
|
||||||
|
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
|
||||||
|
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
|
||||||
|
```
|
||||||
|
|
||||||
|
**남은 행의 `access_token_expires_at` 이 `issued_at` 의 60초 뒤**인 것도 같이
|
||||||
|
본다(`accessTokenLifespan=60`). **access token 은 이미 만료됐지만 같은 행의
|
||||||
|
refresh token 은 아직 살아 있다** — 그리고 그건 4-3 에서 본 대로 평문이다.
|
||||||
|
|
||||||
|
## 4-5. 「로그아웃했는데 다시 들어가진다」
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 다시 연다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인 화면이 안 뜨고 그냥 들어가진다.**
|
||||||
|
3-3 에서 본 것과 같은 조용한 재인증이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 애플리케이션 세션은 지웠는데 **IdP 세션은 그대로**
|
||||||
|
이므로 IdP 가 화면 없이 새 세션을 만들어 준다. 사용자 입장에서는
|
||||||
|
**로그아웃이 안 된 것**이다.
|
||||||
|
|
||||||
|
| 필요한 것 | 방법 |
|
||||||
|
|---|---|
|
||||||
|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
|
||||||
|
| Keycloak 세션 종료 | **RP-initiated logout** — `OidcClientInitiatedLogoutSuccessHandler` |
|
||||||
|
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
|
||||||
|
|
||||||
|
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
|
||||||
|
바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 남은 행을 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"delete from oauth2_authorized_client where principal_name = 'labuser'"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
DELETE 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 브라우저에서 다시 로그인하면 행이 다시 만들어진다.
|
||||||
|
**표 자체는 지우지 않는다** — B-3 이 이 표를 쓴다.
|
||||||
|
|
||||||
|
## 5-2. Keycloak SSO 세션을 끊는다
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 아래 주소를 연다. RP 가 안 보내 주니 사람이 직접 간다.
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||||
|
```
|
||||||
|
**미검증** — 이 실험은 여기까지 재지 않았다. 확인 화면이 뜨면 승인한다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `offline_flag = 0` 의 개수가 줄어드는지.
|
||||||
|
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.**
|
||||||
|
0 이 안 되어도 놀랄 일이 아니다.
|
||||||
|
|
||||||
|
## 5-3. Redis 세션을 되돌린다
|
||||||
|
|
||||||
|
지운 세션은 되돌아오지 않는다. **브라우저에서 다시 로그인하는 것이 복구다.**
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||||
|
| 표 | `sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
|
||||||
|
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
|
||||||
|
| **B-7 세션** | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | **2절 전과 같아야 한다** |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 **조용히 실패**했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 — 1-2 |
|
||||||
|
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
|
||||||
|
| `token-boundary` 가 계속 `false` | 테이블은 만들었는데 **옛 세션**을 쓰고 있다 | 로그아웃 후 재로그인 — [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png) 가 그 상태다 |
|
||||||
|
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec` 에 **`-i` 가 없다** | `exec -i deploy/postgres` |
|
||||||
|
| 행 수가 2 로 늘었다 | principal 이 다르다(다른 사용자로 로그인) | `select principal_name from oauth2_authorized_client` |
|
||||||
|
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
|
||||||
|
| B-7 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 2-2 |
|
||||||
|
| 파이썬 한 줄로 디코드하다 `SyntaxError` | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)) | `cut -d. -f1 \| base64 -d` 로 충분하다 |
|
||||||
|
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf` 의 `headerName` 을 그대로 쓴다 |
|
||||||
|
| 로그아웃했는데 다시 들어가진다 | **버그가 아니다.** Keycloak SSO 세션이 살아 있다 | 4-5 · 5-2 |
|
||||||
|
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 왜 이 가이드에는 스크린샷 대신 숫자가 있나
|
||||||
|
|
||||||
|
증거의 [`b2-tokens-shared-across-instances.png`](../../evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
|
||||||
|
는 **B-0 의 `b0-bff-token-boundary.png` 와 동일 파일**이다(md5 `9ed00537…`).
|
||||||
|
두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다.
|
||||||
|
|
||||||
|
**그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지
|
||||||
|
못한다.** 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 —
|
||||||
|
이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
|
||||||
|
|
||||||
|
> **같은 화면은 같은 증거가 아니다.** 화면이 같아도 그 아래 상태는 다를 수
|
||||||
|
> 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-2 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 **두 브라우저가 같은 refresh token 을 다툰다** |
|
||||||
|
| [B-4](b4-edge-authorization-scope.md) Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 **다른 얼굴**로 나온다 |
|
||||||
|
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 **각각 죽여볼 수 있다** |
|
||||||
|
| 코드 | **평문 refresh token** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
|
||||||
@@ -0,0 +1,835 @@
|
|||||||
|
# B-3 재현 가이드 — 같은 refresh token 을 다섯 번 동시에 던지고 세션이 사라지는 것을 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b3-refresh-contention/`](../../evidence/b3-refresh-contention/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 다섯 중 하나만 `200` 이고 나머지는 `400` | 파드 안 `curl` · `&` 와 `wait` |
|
||||||
|
| 오류 메시지가 **두 종류**인 것 | `Maximum allowed refresh token reuse exceeded` / `Session doesn't have required client` |
|
||||||
|
| **이긴 요청이 받은 토큰조차 못 쓰는 것** | 그 토큰으로 한 번 더 갱신 → `400` |
|
||||||
|
| user session 은 남고 **client session 만 사라진** 것 | PostgreSQL, 정상 세션과 나란히 |
|
||||||
|
| `refreshTokenMaxReuse` 를 올려도 안 되는 것 | 구성 A/B/C 비교 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- [`B-2`](b2-multi-instance-session.md) 가 끝나 있다. **토큰이 공유되어야
|
||||||
|
경쟁이 성립한다** — 다만 이 실험은 Keycloak 쪽 동작만 분리해 보려고
|
||||||
|
**BFF 를 거치지 않고** 토큰 엔드포인트를 직접 친다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- **브라우저는 필요 없다.** direct grant(`grant_type=password`)로 토큰을
|
||||||
|
만들므로 전 구간을 터미널에서 한다.
|
||||||
|
- realm 은 `keycloak-patterns`, 사용자는 `labuser` / `labpass`,
|
||||||
|
클라이언트는 `bff-confidential`.
|
||||||
|
|
||||||
|
## 주의 — 이건 realm 설정을 바꾸는 실험이다
|
||||||
|
|
||||||
|
`revokeRefreshToken` 을 켠다. **realm 전체에 걸린다** — 그 realm 을 쓰는 다른
|
||||||
|
실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. **실험대에서만 한다.**
|
||||||
|
전 구간 약 20분이고, 되돌리는 명령은 [2-2](#2-2-적용) 와 [5-1](#5-1-realm-설정을-되돌린다)
|
||||||
|
에 있다. 중간에 그만두려면 5-1 의 한 줄이면 된다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:16–14:17 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 sid·길이는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||||
|
|
||||||
|
sid·토큰 길이는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||||
|
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위
|
||||||
|
실행 기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
B-2 가 토큰을 PostgreSQL 로 옮겼다. 그래서 **두 replica 가 같은 행을 본다.**
|
||||||
|
그리고 조회 키에 session id 가 없으니 **같은 사용자의 두 브라우저도 같은 행을
|
||||||
|
본다.** 그 행에는 refresh token 이 하나 들어 있다.
|
||||||
|
|
||||||
|
**둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.**
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| 통념 | **하나는 성공하고 하나는 실패한다.** 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다 |
|
||||||
|
| B-3 이 재는 것 | 진짜 그런가. **그리고 이긴 쪽은 멀쩡한가** |
|
||||||
|
|
||||||
|
이 구별이 설계를 가른다.
|
||||||
|
|
||||||
|
```
|
||||||
|
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
|
||||||
|
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**재시도로 회복되면 lock 이 필요 없고, 회복 안 되면 lock 말고 답이 없다.**
|
||||||
|
그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시
|
||||||
|
쓸 수 있나」**다. 4-4 가 그 자리다.
|
||||||
|
|
||||||
|
> **개념 — 재사용 탐지(reuse detection)란 무엇인가.**
|
||||||
|
>
|
||||||
|
> 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화한다. 그런데
|
||||||
|
> 무효화된 옛 토큰이 **다시 들어오면** 두 가지 중 하나다.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> ① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
|
||||||
|
> ② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **서버는 둘을 구별할 수 없다.** 그래서 OAuth 2.0 보안 권고는 **안전한
|
||||||
|
> 쪽으로 가정하고 세션 전체를 무효화**하라고 말한다. 이 실험이 보는
|
||||||
|
> 파괴는 **버그가 아니라 그 규격이 시키는 대로 동작한 결과**다.
|
||||||
|
> 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 회전을 켜기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 회전이 꺼진 상태에서 같은 명령을
|
||||||
|
먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」
|
||||||
|
때문인지 구별된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드가 정상인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE IP NODE
|
||||||
|
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
|
||||||
|
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
|
||||||
|
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — Keycloak 이 **둘 다** `1/1`. 하나가 NotReady 면
|
||||||
|
Service 가 전부 한쪽으로 보내고, 그러면 **동시성이 한 노드 안에서만** 생긴다.
|
||||||
|
이 실험은 그래도 재현되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.
|
||||||
|
|
||||||
|
## 1-2. realm 이 지금 무엇으로 설정되어 있나
|
||||||
|
|
||||||
|
kcadm 은 먼저 로그인해야 쓸 수 있다. **한 번 하면 파드 안에 세션이 남는다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```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
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get realms/keycloak-patterns \
|
||||||
|
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```json
|
||||||
|
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 값 전부.
|
||||||
|
|
||||||
|
| 값 | 뜻 | 지금 |
|
||||||
|
|---|---|---|
|
||||||
|
| `revokeRefreshToken` | **회전 스위치** | `false` — **꺼져 있다** |
|
||||||
|
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
|
||||||
|
| `accessTokenLifespan` | access token 수명(초) | `60` |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **기본값은 회전이 꺼져 있다.** Q2 는
|
||||||
|
*"realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서"* 를
|
||||||
|
전제로 하므로, **그 전제를 만드는 것이 이 실험의 주입**이다. 지금 그대로
|
||||||
|
재면 Q2 와 다른 것을 재게 된다.
|
||||||
|
|
||||||
|
`accessTokenLifespan=60` 은 B-0 에서 **이 실험을 위해** 넣어 둔 값이다.
|
||||||
|
만료를 기다리는 시간이 짧아야 재현이 된다.
|
||||||
|
|
||||||
|
## 1-3. 상주 탐침 파드를 띄운다
|
||||||
|
|
||||||
|
Keycloak 이미지에는 `curl` 도 `wget` 도 없다(`exit 127`). 그리고 이 실험은
|
||||||
|
**토큰을 단계 사이로 넘겨야** 한다 — 발급받은 토큰을 뒤 단계에서 써야 하므로
|
||||||
|
`--rm` 임시 파드로는 안 된다. **파드를 하나 띄워 두고 `exec` 로 이어간다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never \
|
||||||
|
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
|
||||||
|
--env="CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
|
||||||
|
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
|
||||||
|
--command -- sleep 7200
|
||||||
|
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
pod/b3-probe condition met
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 환경변수가 들어갔나. **값이 아니라 길이만 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
|
||||||
|
```
|
||||||
|
`CS길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
|
||||||
|
|
||||||
|
> **왜 Service 로 가는가.** A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드
|
||||||
|
> IP 로 직접 쳤다. 여기는 반대다 — **replica 를 넘는 경쟁**이 질문이므로
|
||||||
|
> Service 가 요청을 흩는 것이 오히려 필요한 조건이다.
|
||||||
|
|
||||||
|
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -it b3-probe -- sh
|
||||||
|
```
|
||||||
|
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다**
|
||||||
|
(`--rm` 이 없다).
|
||||||
|
|
||||||
|
## 1-4. 토큰 하나를 발급받고 sid 를 뽑는다
|
||||||
|
|
||||||
|
**하기 ①** — 파드 안에서. **처음 한 번은 응답을 통째로 본다**
|
||||||
|
```sh
|
||||||
|
curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
|
||||||
|
```
|
||||||
|
**형태** — 한 줄 JSON 이 나온다
|
||||||
|
```json
|
||||||
|
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
|
||||||
|
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `expires_in` 이 60. 1-2 에서 본 `accessTokenLifespan`
|
||||||
|
그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct
|
||||||
|
grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름/비밀번호다.
|
||||||
|
|
||||||
|
**하기 ②** — 변수에 담고 sid 를 뽑는다
|
||||||
|
```sh
|
||||||
|
R=$(curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||||
|
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||||
|
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "refresh=${#RT}자 SID=$SID"
|
||||||
|
```
|
||||||
|
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||||
|
```
|
||||||
|
=== [1] refresh token 하나 확보 ===
|
||||||
|
토큰 길이: 811
|
||||||
|
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
|
||||||
|
sid: BvFiB01Rntz1FcLdf7zG4BNt
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`SID` 를 종이에 적어 둔다.** 4-5 에서 DB 를 뒤질 때
|
||||||
|
이 값이 필요하고, 그때는 **파드 밖**이라 변수가 안 넘어간다.
|
||||||
|
|
||||||
|
> `sid` 가 빈 줄이면 base64 패딩 때문이다. `sed 's/$/==/'` 가 그 보정이고,
|
||||||
|
> 그래도 안 나오면 **base64url 문자(`-` `_`)** 때문일 수 있다. 그때는:
|
||||||
|
> ```sh
|
||||||
|
> echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||||
|
> | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||||
|
> ```
|
||||||
|
> **미검증** — 원래 실행은 위쪽 형태를 썼다. 페이로드 전체가 나오면 그 안에서
|
||||||
|
> `"sid"` 를 눈으로 찾는다.
|
||||||
|
|
||||||
|
## 1-5. 대조군 — 지금은 무엇이 되는가
|
||||||
|
|
||||||
|
### ① 순차로 다섯 번 갱신한다
|
||||||
|
|
||||||
|
**하기** — 파드 안에서. `&` 없이, 한 번에 하나씩
|
||||||
|
```sh
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d "refresh_token=$RT")
|
||||||
|
echo "순차 $i: $(echo "$R" | tail -1)"
|
||||||
|
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**미검증** — 증거 파일에는 순차 실행 기록이 없다. 해설 문서는
|
||||||
|
**"순차 실행이면 재현되지 않는다"** 고 말하며, 이 절은 그것을 당신 손으로
|
||||||
|
확인하는 자리다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 다섯 줄 전부 `200` 이어야 한다.
|
||||||
|
|
||||||
|
> **회전이 켜지면 `RT` 를 매번 다시 담아야 한다.** 위 루프가 그렇게 되어
|
||||||
|
> 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「내가 옛
|
||||||
|
> 토큰을 썼기」 때문인지 구별이 안 된다. **이 실험에서 가장 흔한 자기오염이다.**
|
||||||
|
|
||||||
|
### ② 경쟁을 겪지 않은 세션은 어떻게 생겼나
|
||||||
|
|
||||||
|
4-5 에서 볼 DB 모양을 **지금 미리 본다.** 이게 없으면 나중에 나오는 `0` 이
|
||||||
|
「경쟁 때문」인지 「원래 그런 표」인지 모른다.
|
||||||
|
|
||||||
|
**확인** — **파드 밖**(kc-lab-1)에서. 아래의 sid 자리에는 **1-4 에서 적어 둔
|
||||||
|
당신의 `SID`** 를 넣는다. 여기 실린 값은 원래 실행의 대조군 세션 것이다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select us.user_session_id, us.offline_flag,
|
||||||
|
(select count(*) from offline_client_session cs
|
||||||
|
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||||
|
from offline_user_session us
|
||||||
|
where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"
|
||||||
|
```
|
||||||
|
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||||
|
의 대조군 부분
|
||||||
|
```
|
||||||
|
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||||||
|
새 sid: JT-XuepgutWcE273QwAnIXta
|
||||||
|
user_session_id | client_sessions
|
||||||
|
--------------------------+-----------------
|
||||||
|
JT-XuepgutWcE273QwAnIXta | 1
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`client_sessions = 1`.** 정상 세션은 이렇게 생겼다.
|
||||||
|
|
||||||
|
> **개념 — user session 과 client session 은 다른 것이다.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> user session "이 브라우저는 labuser 로 로그인함"
|
||||||
|
> ├─ client session : bff-confidential
|
||||||
|
> └─ client session : oauth2-proxy
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 **user session 하나
|
||||||
|
> 아래에 client session 이 여럿** 달린다. 그게 SSO 다.
|
||||||
|
> **재사용 탐지는 이 중 client session 만 제거한다** — 4-5 에서 그것을 본다.
|
||||||
|
>
|
||||||
|
> 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데,
|
||||||
|
> `offline_flag` 열이 그것을 가른다. 위 출력의 `offline_flag = 0` 이
|
||||||
|
> 「온라인 세션」이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 회전을 켠다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 무엇을 켜는가 — 이름이 헷갈린다
|
||||||
|
|
||||||
|
> **개념 — `revokeRefreshToken` 이 회전 스위치다.**
|
||||||
|
>
|
||||||
|
> 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을
|
||||||
|
> 줄 때 **옛 토큰을 무효화**한다. 그 결과가 회전이다.
|
||||||
|
>
|
||||||
|
> | 설정 | 뜻 |
|
||||||
|
> |---|---|
|
||||||
|
> | `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
|
||||||
|
> | `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** |
|
||||||
|
>
|
||||||
|
> **`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다.**
|
||||||
|
> 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니
|
||||||
|
> 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 이유다.
|
||||||
|
|
||||||
|
## 2-2. 적용
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||||
|
date '+%H:%M:%S 회전 켬'
|
||||||
|
```
|
||||||
|
**형태** — 성공하면 아무 말도 안 한다(무소식이 좋은 소식이다)
|
||||||
|
```
|
||||||
|
14:16:12 회전 켬
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 적어 둔다.** 4절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다.
|
||||||
|
**켜기 전에 발급한 토큰으로 재면 안 된다** — 발급 시점의 정책이 아니라 검증
|
||||||
|
시점의 정책이 적용되므로 섞여서 해석이 안 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. 설정이 실제로 바뀌었나
|
||||||
|
|
||||||
|
**확인** — 1-2 와 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get realms/keycloak-patterns \
|
||||||
|
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```json
|
||||||
|
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `revokeRefreshToken` 이 `true`. `false` 그대로면
|
||||||
|
`update` 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은
|
||||||
|
**실패해도 조용할 때가 있다** — 반드시 다시 읽어서 확인한다.
|
||||||
|
|
||||||
|
## 3-2. 엉뚱한 것을 죽이지 않았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** realm 설정 변경은 재시작을
|
||||||
|
일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로
|
||||||
|
재면 「경쟁」이 아니라 「재시작」을 재게 된다.
|
||||||
|
|
||||||
|
## 3-3. 회전이 실제로 동작하는가 — 한 번만 갱신해 본다
|
||||||
|
|
||||||
|
**동시성을 넣기 전에, 회전 자체가 도는지 확인한다.**
|
||||||
|
|
||||||
|
**하기** — 파드 안에서. 새 토큰을 하나 받고, **한 번 갱신한 뒤 옛 것을 다시 쓴다**
|
||||||
|
```sh
|
||||||
|
R=$(curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||||
|
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||||
|
```
|
||||||
|
**미검증** — 이 절은 이 가이드가 덧붙인 사전 확인이다. 증거 파일에는 없다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **1회차 `200`, 2회차 `400`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 옛 토큰이 무효화된다 = 회전이 켜졌다.
|
||||||
|
2회차도 `200` 이면 **회전이 안 켜진 것**이고, 그 상태로 4절을 돌리면 다섯 개가
|
||||||
|
전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 깨끗한 토큰을 하나 새로 받는다
|
||||||
|
|
||||||
|
3-3 에서 쓴 토큰은 이미 무효다. **새로 시작한다.**
|
||||||
|
|
||||||
|
**하기** — 파드 안에서
|
||||||
|
```sh
|
||||||
|
R=$(curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||||
|
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||||
|
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "refresh=${#RT}자 SID=$SID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**`SID` 를 다시 적어 둔다.** 4-5 에서 쓴다.
|
||||||
|
|
||||||
|
## 4-2. ★ 동시에 다섯 개 — `&` 와 `wait` 이 없으면 재현되지 않는다
|
||||||
|
|
||||||
|
**이 절이 이 실험의 전부다.** 순차로 돌리면 아무 일도 안 일어난다(1-5 에서
|
||||||
|
직접 봤다). 진짜로 겹쳐야 한다.
|
||||||
|
|
||||||
|
**하기** — 파드 안에서
|
||||||
|
```sh
|
||||||
|
i=1
|
||||||
|
while [ $i -le 5 ]; do
|
||||||
|
( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 스크립트였다. 이 형태는 손으로 치기 좋게 고친 것이고,
|
||||||
|
**본문과 응답 코드를 파일로 갈라 놓아 순서대로 다시 읽을 수 있게** 했다.
|
||||||
|
다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없다 —
|
||||||
|
그래서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 셸 문법 세 조각이 전부다.
|
||||||
|
|
||||||
|
```
|
||||||
|
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
|
||||||
|
wait 띄운 것이 전부 끝날 때까지 기다린다
|
||||||
|
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
|
||||||
|
```
|
||||||
|
|
||||||
|
**`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다.**
|
||||||
|
`wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다.
|
||||||
|
|
||||||
|
## 4-3. 결과 — 오류가 두 종류다
|
||||||
|
|
||||||
|
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||||
|
```
|
||||||
|
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
|
||||||
|
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
|
||||||
|
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||||
|
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||||
|
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||||
|
요청 5: HTTP 200 {"access_token":"...(발급됨)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **성공 개수가 아니라 오류 메시지가 두 종류인 것.**
|
||||||
|
|
||||||
|
| 메시지 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
|
||||||
|
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 만약 「하나만 이기고 나머지는 진다」였다면 지는
|
||||||
|
쪽 메시지는 **전부 같아야** 한다. 두 종류라는 것은 **중간에 상태가 바뀌었다**
|
||||||
|
는 뜻이다. 그 바뀐 상태가 무엇인지가 4-5 다.
|
||||||
|
|
||||||
|
> 성공한 번호는 당신 환경에서 다르다. 증거에서는 5번이었지만 순서는
|
||||||
|
> 스케줄링에 달렸다. **몇 번이 이겼는가는 아무 의미가 없다.**
|
||||||
|
|
||||||
|
## 4-4. ★ 이긴 요청의 토큰을 다시 써 본다 — 여기서 진짜 답이 나온다
|
||||||
|
|
||||||
|
**하기** — 파드 안에서. 다섯 응답 중 `refresh_token` 이 들어 있는 것을 꺼낸다
|
||||||
|
```sh
|
||||||
|
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
|
||||||
|
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
|
||||||
|
echo "새 refresh token 길이: ${#NEW}"
|
||||||
|
|
||||||
|
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d "client_secret=$CS" -d "refresh_token=$NEW"
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||||
|
```
|
||||||
|
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||||||
|
새 refresh token 길이: 810
|
||||||
|
그 토큰으로 다시 갱신: HTTP 400
|
||||||
|
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`400`.** 그리고 메시지가
|
||||||
|
`Session doesn't have required client`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **이긴 요청조차 쓸 수 없는 토큰을 받았다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
|
||||||
|
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
|
||||||
|
```
|
||||||
|
|
||||||
|
**오류가 지연되어 나타난다.** 200 을 받은 코드는 성공했다고 믿고 토큰을
|
||||||
|
저장한다. 끊긴 것은 **그다음 요청에서** 안다. 로그를 볼 때 원인 시각과 증상
|
||||||
|
시각이 어긋나 보이는 이유가 이것이다.
|
||||||
|
|
||||||
|
> **여기서 「재시도하면 되지 않나」가 무너진다.** 새 토큰을 다시 읽어
|
||||||
|
> 재시도해도 **그 토큰이 이미 무효**다. 재시도할 대상이 없다.
|
||||||
|
|
||||||
|
## 4-5. 기제 확정 — 무엇이 사라졌는가
|
||||||
|
|
||||||
|
**확인** — **파드 밖**에서. `SID` 는 4-1 에서 적어 둔 값이다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select us.user_session_id, us.offline_flag, us.last_session_refresh
|
||||||
|
from offline_user_session us
|
||||||
|
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||||
|
```
|
||||||
|
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
|
||||||
|
user_session_id | offline_flag | last_session_refresh
|
||||||
|
--------------------------+--------------+----------------------
|
||||||
|
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **행이 남아 있다.** 세션이 통째로 지워진 것이 아니다.
|
||||||
|
그러면 왜 `Session doesn't have required client` 인가. **client session 을 센다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select us.user_session_id, us.offline_flag,
|
||||||
|
(select count(*) from offline_client_session cs
|
||||||
|
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||||
|
from offline_user_session us
|
||||||
|
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||||
|
```
|
||||||
|
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||||
|
```
|
||||||
|
=== user session 과 client session 을 나눠서 본다 ===
|
||||||
|
user_session_id | offline_flag | client_sessions
|
||||||
|
--------------------------+--------------+-----------------
|
||||||
|
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`client_sessions = 0`.** 1-5 의 대조군은 `1` 이었다.
|
||||||
|
**같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||||||
|
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
|
||||||
|
```
|
||||||
|
|
||||||
|
그래서 오류 문구가 정확히 그 말을 한다 — **세션은 있는데 그 클라이언트 몫이
|
||||||
|
없다.** 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.
|
||||||
|
|
||||||
|
## 4-6. 폐기 목록에 실린 것이 아니다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
|
"select count(*) as revoked_count from revoked_token"
|
||||||
|
```
|
||||||
|
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||||
|
```
|
||||||
|
=== [5] revoked_token 테이블 ===
|
||||||
|
revoked_count
|
||||||
|
---------------
|
||||||
|
0
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`0`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 「토큰을 블랙리스트에 올려서 막는다」가 아니다.
|
||||||
|
**client session 이 사라져서 검증할 대상이 없어진 것**이다. 토큰을 지우는
|
||||||
|
방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 **그 client 에 대한
|
||||||
|
모든 토큰이 한꺼번에 죽는다.** 4-4 의 결과가 그것이다.
|
||||||
|
|
||||||
|
## 4-7. 시간선 — 왜 이긴 쪽도 죽는가
|
||||||
|
|
||||||
|
```
|
||||||
|
t0 5개가 동시에 도착
|
||||||
|
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||||||
|
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||||||
|
t3 ★ client session 제거
|
||||||
|
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||||||
|
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||||||
|
```
|
||||||
|
|
||||||
|
**t3 와 t4 의 순서가 전부다.** 응답을 만들던 요청은 이미 「성공」이 확정된
|
||||||
|
상태로 나가고, 그 사이 바닥이 빠진다.
|
||||||
|
|
||||||
|
## 4-8. 정책을 바꿔 비교한다
|
||||||
|
|
||||||
|
**한 번 더 재기 전에 세션을 새로 만든다.** 파괴된 세션으로 재면 전부 400 이다.
|
||||||
|
|
||||||
|
### 구성 B — 회전 OFF
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update realms/keycloak-patterns -s revokeRefreshToken=false
|
||||||
|
```
|
||||||
|
파드 안에서 **4-1 → 4-2 → 4-4 → 4-5 를 그대로 반복**한다.
|
||||||
|
|
||||||
|
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||||
|
```
|
||||||
|
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
|
||||||
|
sid=iW1CGyO7COdyJLryIrCt3njk
|
||||||
|
1: 200
|
||||||
|
2: 200
|
||||||
|
3: 200
|
||||||
|
4: 200
|
||||||
|
5: 200
|
||||||
|
성공 5 / 5
|
||||||
|
이긴 토큰 재사용: HTTP 200
|
||||||
|
남은 client_session: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **전부 200 이고 세션도 멀쩡하다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 같은 refresh token 을 계속 쓸 수 있으므로
|
||||||
|
**경쟁 자체가 성립하지 않는다.** 대신 잃는 것 — 토큰이 유출되면 **만료까지
|
||||||
|
계속 쓸 수 있다.** 회전의 목적이 그 창을 좁히는 것이었다.
|
||||||
|
|
||||||
|
### 구성 C — 회전 ON · 재사용 1회 허용
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
|
||||||
|
```
|
||||||
|
다시 반복한다.
|
||||||
|
|
||||||
|
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||||
|
```
|
||||||
|
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
|
||||||
|
sid=72c04JCdr0NpCHGQmXWW2wM8
|
||||||
|
1: 200
|
||||||
|
2: 400 "error_description":"Session doesn't have required client"
|
||||||
|
3: 200
|
||||||
|
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
|
||||||
|
5: 400 "error_description":"Session doesn't have required client"
|
||||||
|
성공 2 / 5
|
||||||
|
이긴 토큰 재사용: HTTP 400
|
||||||
|
남은 client_session: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 성공이 1에서 2로 늘었지만 **`남은 client_session: 0`**
|
||||||
|
은 그대로다.
|
||||||
|
|
||||||
|
### 세 구성을 나란히
|
||||||
|
|
||||||
|
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** 회전 ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
|
||||||
|
| **B** 회전 OFF | **5 / 5** | 200 | **1 — 생존** |
|
||||||
|
| **C** 회전 ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
|
||||||
|
|
||||||
|
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
|
||||||
|
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 **회전의 보안
|
||||||
|
> 목적이 사라진다.** 값을 올려 버티려는 시도는 "몇 개까지 동시에 올
|
||||||
|
> 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
|
||||||
|
|
||||||
|
**그래서 답은 lock 이다.** 그리고 lock 은 **저장소 쪽**에 있어야 한다 —
|
||||||
|
프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
|
||||||
|
|
||||||
|
| 후보 | |
|
||||||
|
|---|---|
|
||||||
|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE` — **A-0 에서 Keycloak 자신이 쓰는 방식** |
|
||||||
|
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||||||
|
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||||||
|
|
||||||
|
**잠금의 수명이 연결의 수명과 묶이는 것**이 DB 잠금의 이점이다. 프로세스가
|
||||||
|
죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면
|
||||||
|
**중복 갱신**, 길면 **정지**다 — 그 약점은 [B-5](b5-redis-loss-persistence.md)
|
||||||
|
에서 다시 만난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. realm 설정을 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||||
|
date '+%H:%M:%S 회전 끔'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 1-2 와 똑같은 명령으로 다시 읽는다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get realms/keycloak-patterns \
|
||||||
|
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```json
|
||||||
|
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**1-2 의 실측과 세 값이 전부 같아야 한다.** `accessTokenLifespan` 이 60 이
|
||||||
|
아니면 다른 것도 건드린 것이다.
|
||||||
|
|
||||||
|
## 5-2. 탐침 파드를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-3. 실험이 만든 세션을 정리한다
|
||||||
|
|
||||||
|
파괴된 세션의 `user_session_id` 행은 그대로 남는다. **TTL 로 스스로 사라지지만**
|
||||||
|
바로 치우고 싶으면 브라우저에서 아래를 연다.
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||||
|
```
|
||||||
|
**미검증** — 이 실험은 여기까지 재지 않았다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||||
|
```
|
||||||
|
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.** 0 이 안 되어도
|
||||||
|
놀랄 일이 아니다.
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| realm | 위 5-1 의 `get realms/...` | `revokeRefreshToken : false` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||||
|
| 탐침 | `sudo kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
|
||||||
|
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| **다섯 개가 전부 `200`** | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &` 와 `wait` 이 있는지 — 4-2 |
|
||||||
|
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 3-1 로 다시 읽는다. 3-3 이 `200/400` 이어야 한다 |
|
||||||
|
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
|
||||||
|
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 — 4-2 |
|
||||||
|
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 — 1-5 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 — 1-3 |
|
||||||
|
| `CS길이=0` | secret 이름/키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
|
||||||
|
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
|
||||||
|
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 — 1-2 |
|
||||||
|
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 — 1-4 |
|
||||||
|
| 4-5 에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
|
||||||
|
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 5-1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 왜 이 가이드에는 부하 도구가 없나
|
||||||
|
|
||||||
|
동시성 5는 `ab` 도 `k6` 도 필요 없다. **셸의 `&` 와 `wait` 이면 충분하고,
|
||||||
|
그 편이 무엇이 일어났는지 더 잘 보인다** — 요청 다섯 개의 본문을 전부 파일로
|
||||||
|
갖고 있으니 나중에 다시 읽을 수 있다.
|
||||||
|
|
||||||
|
부하 도구는 **개수를 늘려야 할 때** 쓴다. 이 실험이 묻는 것은 개수가 아니라
|
||||||
|
**「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다.
|
||||||
|
|
||||||
|
> **재현에 필요한 최소치를 찾는 것이 실험 설계다.** 다섯 개를 쓴 이유는
|
||||||
|
> 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지, 다섯이 필요해서가
|
||||||
|
> 아니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-3 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다.** 그 약점을 직접 본다 |
|
||||||
|
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **왜 두 브라우저가 같은 토큰을 다투는가** — 조회 키가 원인이다 |
|
||||||
|
| A-6 지연 주입 | 거기서 낙관적 락 충돌이 0 이었던 이유가 여기서 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **같은 항목을 갱신할 때**다 |
|
||||||
|
| 설계 | **재시도로 회복되지 않는다 → lock.** 그리고 lock 은 저장소 쪽, 가급적 DB 행 잠금 |
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
# B-4 재현 가이드 — 신원 헤더를 위조해 보내고 그대로 도착하는 것을 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b4-edge-authorization/`](../../evidence/b4-edge-authorization/) ·
|
||||||
|
③ 반영 시점: [후속 문서 §3](../../experiment-followup-untested-items.md) ·
|
||||||
|
[`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 동명 헤더 두 개가 **둘 다 도착**하는 것 | `curl -H … -H …` · echo 응답 |
|
||||||
|
| 값 안의 쉼표를 구분자와 **구별할 수 없는** 것 | 같은 자리 |
|
||||||
|
| 8KB 에서 `400`, 16KB 에서 **응답 자체가 없는** 것 | 크기 훑기 |
|
||||||
|
| 인증 없이 보낸 **위조 신원**이 그대로 도착하는 것 | 같은 자리 |
|
||||||
|
| JWT 를 요구하는 경로는 `401` 인 것 | 대조군 |
|
||||||
|
| IdP 에서 값을 바꿔도 **12회 요청 동안 옛 값**인 것 | 브라우저 콘솔 (쿠키가 HttpOnly 라 curl 로 못 한다) |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`03-nginx`](../03-nginx/) · [`04-tls`](../04-tls/) · [`05-keycloak`](../05-keycloak/)
|
||||||
|
가 끝나 있다.
|
||||||
|
- `app1.hyeonworks.com` 이 **경로에 따라 둘로 갈린다.** `/` 는 BFF,
|
||||||
|
**`/api` 는 `header-lab` 네임스페이스의 echo 앱**이다. 이 실험은 `/api/echo`
|
||||||
|
만 쓴다 — 도착한 헤더를 그대로 되돌려주는 앱이다.
|
||||||
|
- 4절부터는 **`app2.hyeonworks.com` 을 Grafana 에서 잠시 빌린다.** 인증서가
|
||||||
|
`auth` · `app1` · `app2` 만 덮으므로 네 번째 이름을 만들 수 없다.
|
||||||
|
**끝나면 되돌린다** — [5-4](#5-4-grafana-ingress-를-되돌린다).
|
||||||
|
- 4절은 **브라우저가 필요하다.** oauth2-proxy 세션 쿠키가 `HttpOnly` 라
|
||||||
|
`curl` 로 로그인 상태를 재현할 수 없다. 이유는 [4-1](#4-1--왜-curl-로-못-하는가).
|
||||||
|
- `kubectl` 은 **`kc-lab-1` 에서 `sudo`** 로 친다. 5-1 의 nginx 설정만
|
||||||
|
**랩 호스트(`test-server`)** 에서 한다 — 다른 기계다.
|
||||||
|
- 앞의 `curl` 은 어디서 쳐도 된다. 밖에서 치는 편이 「공격자 관점」에 가깝다.
|
||||||
|
|
||||||
|
## 주의 — 앞부분은 안전하고 뒷부분은 상태를 바꾼다
|
||||||
|
|
||||||
|
| 절 | 무엇을 하나 | 되돌릴 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1~3 | **요청만 보낸다.** 클러스터 상태가 안 바뀐다 | 없음 |
|
||||||
|
| 4 | Grafana 에서 app2 를 빌리고 **IdP 의 사용자 속성을 바꾼다** | Ingress · email 값 · 세션 |
|
||||||
|
| 5 | **nginx 설정을 바꾼다** (호스트) | 설정 파일 |
|
||||||
|
|
||||||
|
전 구간 약 30분. **1~3 만 하고 멈춰도 이 실험의 결론 대부분이 나온다.**
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:23 KST(①②④) 및 07:51–07:53 UTC(③) 실행 기록의 **출력 원문** |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||||
|
|
||||||
|
증거 파일의 `['admin', 'editor']` 같은 표기는 **스크립트가 정리한 것**이다.
|
||||||
|
`curl` 로 직접 보면 같은 값이 JSON 배열 `["admin","editor"]` 로 온다 —
|
||||||
|
echo 앱이 헤더 이름마다 **값의 목록**을 돌려주기 때문이다. 이 가이드는 둘을
|
||||||
|
구별해 표시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
Edge(oauth2-proxy·nginx)가 인증을 끝내고 **신원을 헤더로 뒤에 넘기는** 구조가
|
||||||
|
있다. `X-Auth-Request-User`, `X-Auth-Request-Roles` 같은 것들이다. 뒤쪽
|
||||||
|
애플리케이션은 그 헤더를 읽어 사용자를 안다.
|
||||||
|
|
||||||
|
**그러면 그 헤더는 무엇이 보증하는가.**
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| Q4 가 「확인한 사실」로 적어둔 것 | *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다"* |
|
||||||
|
| B-4 가 재는 것 | 진짜 그런가. **그리고 upstream 은 무엇을 검증하는가** |
|
||||||
|
|
||||||
|
이 실험은 **네 가지를 따로 잰다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
① 여러 값을 어떻게 넣는가 쉼표? 헤더를 여러 개? → 구별할 수 있나
|
||||||
|
② 커지면 어떻게 되는가 잘리나? 거부되나?
|
||||||
|
③ IdP 에서 바꾸면 언제 반영되나
|
||||||
|
④ 위조하면 통하는가 ★ 여기가 권한의 문제다
|
||||||
|
```
|
||||||
|
|
||||||
|
> **개념 — 왜 ④ 가 「인증 우회」가 아니라 「권한 상승」인가.**
|
||||||
|
>
|
||||||
|
> 헤더가 **누구인지**만 말하면 위조는 인증 우회다. 그런데 헤더가
|
||||||
|
> **무엇을 할 수 있는지**(role)까지 말하면, 위조는 **권한 상승**이 된다.
|
||||||
|
> 로그인한 일반 사용자가 자기 요청에 `X-Auth-Request-Roles: admin` 을
|
||||||
|
> 한 줄 더 붙이는 것으로 끝난다.
|
||||||
|
>
|
||||||
|
> 그래서 이 구조는 **세 곳이 동시에 성립해야만** 안전하다.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> ① 외부 → upstream 직접 경로 차단 (NetworkPolicy)
|
||||||
|
> ② edge 에서 동명 헤더 덮어쓰기 (proxy_set_header)
|
||||||
|
> ③ upstream 에서 내부 credential 검증 (공통 경계)
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **하나라도 빠지면 나머지 둘이 무의미하다.** 이 실험은 ② 가 빠져 있다는
|
||||||
|
> 것을 재고, 그 결과로 ④ 가 성립한다는 것을 재고, ③ 이 한 곳에만 있다는
|
||||||
|
> 것을 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 위조하기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 「위조 헤더가 도착했다」고 말하려면
|
||||||
|
**아무것도 안 붙였을 때 무엇이 도착하는지**를 먼저 봐야 한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
경로 확인 → echo 응답 통째로 보기 → 대조군(아무것도 안 붙임) → nginx 가 지금 뭘 설정하나
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 어느 이름이 어디로 가는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl get ingress -A
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||||
|
header-lab echo traefik app1.hyeonworks.com 80 5d
|
||||||
|
keycloak-lab bff traefik app1.hyeonworks.com 80 3d
|
||||||
|
keycloak-lab keycloak traefik auth.hyeonworks.com 80 6d
|
||||||
|
observability grafana traefik app2.hyeonworks.com 80 6d
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `app1` 이 **두 줄**이다. 같은 호스트에 Ingress 가
|
||||||
|
둘이고, 경로로 갈린다.
|
||||||
|
|
||||||
|
**확인** — 어느 경로가 어디로 가는지 눈으로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n header-lab describe ingress echo | grep -A5 Rules
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
Rules:
|
||||||
|
Host Path Backends
|
||||||
|
---- ---- --------
|
||||||
|
app1.hyeonworks.com
|
||||||
|
/api echo:8081 (10.42.0.61:8081,10.42.1.72:8081)
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `https://app1.hyeonworks.com/api/echo` 는 **BFF 가
|
||||||
|
아니라 echo 앱**으로 간다. 이 실험이 재는 것은 BFF 가 아니라 **헤더를 그대로
|
||||||
|
받아 쓰는 upstream** 이므로 이쪽이 맞다.
|
||||||
|
|
||||||
|
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||||
|
> 뜬다. 위처럼 `describe ingress` / `describe svc` 를 보거나
|
||||||
|
> `get endpointslice -l kubernetes.io/service-name=echo` 를 본다.
|
||||||
|
|
||||||
|
## 1-2. echo 응답을 한 번 통째로 본다
|
||||||
|
|
||||||
|
**나중에 걸러 보려면 먼저 통째로 봐야 한다.** 어떤 키가 있는지 알아야 무엇으로
|
||||||
|
거를지 정할 수 있다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
**형태** — 한 줄 JSON 이 통째로 나온다
|
||||||
|
```json
|
||||||
|
{"headers":{"host":["app1.hyeonworks.com"],"x-forwarded-host":["app1.hyeonworks.com"],
|
||||||
|
"x-forwarded-proto":["https"],"x-forwarded-port":["443"],"x-forwarded-for":["..."],
|
||||||
|
"x-real-ip":["..."],"user-agent":["curl/8.5.0"],"accept":["*/*"]},
|
||||||
|
"remoteAddr":"...","localAddr":"10.42.1.72","scheme":"https","secure":true,
|
||||||
|
"serverName":"app1.hyeonworks.com","serverPort":443,
|
||||||
|
"requestUrl":"https://app1.hyeonworks.com/api/echo"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `headers` 의 값이 **전부 배열**이다. HTTP 가 같은 이름의 헤더를 여러 번
|
||||||
|
허용하기 때문이고, **2-1 의 결과를 읽을 수 있는 이유**가 이것이다
|
||||||
|
- `x-forwarded-proto` 가 `https` — nginx 가 `proxy_set_header` 로 **설정한**
|
||||||
|
헤더다. 1-4 에서 이 목록을 확인한다
|
||||||
|
- `scheme` / `secure` / `serverName` — Keycloak 이 `iss` 클레임과 리다이렉트를
|
||||||
|
만들 때 쓰는 값들이다. 2홉 실험이 이 세 개를 봤다
|
||||||
|
|
||||||
|
`jq` 는 이 실험대에 **깔려 있지 않다.** 걸러 볼 때는 `grep -o` 를 쓴다.
|
||||||
|
|
||||||
|
**확인** — **미검증**
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-forwarded-proto":\[[^]]*\]'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
"x-forwarded-proto":["https"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`tr ',' '\n' | grep` 은 여기서 쓰면 안 된다.** 값 배열이
|
||||||
|
> `["admin","editor"]` 처럼 쉼표를 품고 있어서 **배열이 두 줄로 잘린다.**
|
||||||
|
> 첫 줄만 보고 「하나만 도착했다」로 읽게 된다 — 이 실험이 가장 조심해야 할
|
||||||
|
> 오독이다. `grep -o '…\[[^]]*\]'` 는 대괄호 안을 통째로 뽑는다.
|
||||||
|
|
||||||
|
## 1-3. 대조군 — 아무것도 안 붙였을 때
|
||||||
|
|
||||||
|
**확인** — **미검증**
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request[^]]*\]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **아무것도 안 나와야 한다.** `x-auth-request-*` 는
|
||||||
|
edge 가 붙이는 헤더인데, `app1` 앞에는 oauth2-proxy 가 없으므로 지금은 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **이 자리가 비어 있다는 것이 대조군이다.**
|
||||||
|
2절에서 여기에 값이 나타나면 그건 **내가 보낸 것이 도착한 것**이다. 이 확인을
|
||||||
|
건너뛰면 「원래 있던 것」과 「내가 넣은 것」이 구별되지 않는다.
|
||||||
|
|
||||||
|
## 1-4. nginx 가 지금 무엇을 설정하고 있나
|
||||||
|
|
||||||
|
**확인** — **랩 호스트(`test-server`)** 에서
|
||||||
|
```bash
|
||||||
|
sudo grep proxy_set_header /etc/nginx/sites-available/keycloak-lab
|
||||||
|
```
|
||||||
|
**형태** — [`03-nginx`](../03-nginx/) 가 세운 설정 그대로다
|
||||||
|
```
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`X-Auth-Request-*` 가 목록에 없다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 그리고 그것이 2절의 결과를 전부 설명한다.
|
||||||
|
|
||||||
|
> **개념 — nginx 의 헤더 처리는 조건부다.**
|
||||||
|
>
|
||||||
|
> ```nginx
|
||||||
|
> proxy_set_header X-Forwarded-Proto https; # 설정한 것 → 덮어쓴다
|
||||||
|
> # X-Auth-Request-Roles 설정 없음 # 안 한 것 → 그대로 흘려보낸다
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.**
|
||||||
|
> 설정하지 않은 헤더는 **손대지 않고 통과**시킨다. 「nginx 가 덮어쓴다」는
|
||||||
|
> 명제는 **조건부**이며, 그 조건이 빠지면 틀린 문장이 된다.
|
||||||
|
>
|
||||||
|
> Q4 가 「확인한 사실」로 적어둔 *"Nginx는 client가 보낸 동명 헤더를
|
||||||
|
> merge하지 않고 덮어쓴다"* 는 **조건이 빠져 있어 어긋난다.**
|
||||||
|
> 2-1 이 그것을 재는 자리다.
|
||||||
|
|
||||||
|
> **`sudo` 가 아무 결과도 안 주면 실패한 것이다.** 랩 호스트의 sudo 는
|
||||||
|
> **비밀번호를 요구한다**(`sudo -n -l` → `sudo: a password is required`).
|
||||||
|
> D-4 후속 작업이 이 사실을 늦게 발견해서 시간을 버렸다. 빈 출력을
|
||||||
|
> 「설정이 없다」로 읽지 말고 **비밀번호를 넣어 다시 친다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 헤더를 위조해서 보낸다
|
||||||
|
|
||||||
|
**이 절은 클러스터 상태를 바꾸지 않는다.** 요청을 보낼 뿐이다. 그래서
|
||||||
|
되돌릴 것이 없다 — 그리고 **그 사실 자체가 이 실험의 무게**다. 아무것도
|
||||||
|
설치하지 않고 아무 권한도 없이, `curl` 한 줄로 여기까지 된다.
|
||||||
|
|
||||||
|
## 2-1. 동명 헤더 두 개 — 덮어쓰는가, 합치는가, 통과시키는가
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
|
||||||
|
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 스크립트가 응답을 정리했다. 위는 같은 값을 `grep` 으로
|
||||||
|
뽑는 형태다.
|
||||||
|
|
||||||
|
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||||
|
```
|
||||||
|
(b) 동명 헤더 두 개
|
||||||
|
보냄: X-Auth-Request-Roles: admin
|
||||||
|
X-Auth-Request-Roles: editor
|
||||||
|
도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **값이 두 개**다. `curl` 로 직접 보면
|
||||||
|
`"x-auth-request-roles":["admin","editor"]` 로 보인다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 셋 중 어느 것도 아니다.
|
||||||
|
|
||||||
|
| 가설 | 도착했을 모양 | 실제 |
|
||||||
|
|---|---|---|
|
||||||
|
| 덮어쓴다 | `["editor"]` 하나 | ✗ |
|
||||||
|
| 합친다 | `["admin, editor"]` 한 문자열 | ✗ |
|
||||||
|
| **통과시킨다** | **`["admin","editor"]`** | **✔** |
|
||||||
|
|
||||||
|
Edge 가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를
|
||||||
|
`admin` 으로 함께 보내면 **둘 다 upstream 에 도착한다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
edge 가 붙인 것: X-Auth-Request-Roles: viewer
|
||||||
|
공격자가 보낸 것: X-Auth-Request-Roles: admin
|
||||||
|
upstream 이 받는 것: ["viewer","admin"] 또는 ["admin","viewer"]
|
||||||
|
└─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spring 의 `request.getHeader()` 는 첫 번째를 돌려준다. 그 순서는 프록시가
|
||||||
|
정한다.** 애플리케이션 코드 어디에도 이 결정이 안 적혀 있다.
|
||||||
|
|
||||||
|
## 2-2. 값 안의 쉼표 — 구분자와 구별할 수 있는가
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
curl -s -H 'X-Auth-Request-Roles: admin,editor,viewer' \
|
||||||
|
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||||
|
curl -s -H 'X-Auth-Request-Roles: role-with,comma' \
|
||||||
|
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||||
|
```
|
||||||
|
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||||
|
```
|
||||||
|
(a) 쉼표 구분 한 개 헤더
|
||||||
|
보냄: X-Auth-Request-Roles: admin,editor,viewer
|
||||||
|
도착: ['admin,editor,viewer'] ← 문자열 하나 그대로
|
||||||
|
...
|
||||||
|
(c) 값 안에 구분자가 들어간 경우
|
||||||
|
보냄: X-Auth-Request-Roles: role-with,comma
|
||||||
|
도착: ['role-with,comma'] ← (a) 와 구별 불가
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **(a) 와 (c) 가 도착 시점에 똑같이 생겼다.**
|
||||||
|
둘 다 값이 **하나**인 배열이고, 그 안에 쉼표가 있다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
|
||||||
|
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
|
||||||
|
```
|
||||||
|
|
||||||
|
**role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.**
|
||||||
|
Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다** —
|
||||||
|
애플리케이션이 「쉼표 쓰지 마세요」라고 정할 수 있는 자리가 아니다.
|
||||||
|
|
||||||
|
| 대안 | |
|
||||||
|
|---|---|
|
||||||
|
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다**(2-1) |
|
||||||
|
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다(2-3) |
|
||||||
|
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 **위조도 구분자도 해결된다** → BFF 구조 |
|
||||||
|
|
||||||
|
## 2-3. 크기를 키운다 — 자르나, 거부하나
|
||||||
|
|
||||||
|
**먼저 한 번은 읽는 형태로 본다.** 무엇이 돌아오는지 봐야 뒤의 숫자를 읽을 수 있다.
|
||||||
|
|
||||||
|
**하기** — **미검증**. 원래 실행은 `python3 -c "print('r'*$n)"` 로 값을 만들었다.
|
||||||
|
파이썬 없이 만든다
|
||||||
|
```bash
|
||||||
|
V=$(head -c 8000 /dev/zero | tr '\0' 'r'); echo "만든 길이 ${#V}"
|
||||||
|
curl -i -s -H "X-Auth-Request-Roles: $V" https://app1.hyeonworks.com/api/echo | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 상태줄과 본문. 8000 에서는 **Tomcat 의 HTML 오류
|
||||||
|
페이지**가 온다. JSON 이 아니라 HTML 이라는 것 자체가 「애플리케이션까지
|
||||||
|
갔는데 파싱 전에 잘렸다」는 신호다.
|
||||||
|
|
||||||
|
이제 여러 크기를 **비교**한다. 비교가 목적이니 여기서는 코드만 뽑는 형태가 맞다.
|
||||||
|
|
||||||
|
**하기** — **미검증**
|
||||||
|
```bash
|
||||||
|
for n in 1000 4000 8000 16000 32000; do
|
||||||
|
V=$(head -c "$n" /dev/zero | tr '\0' 'r')
|
||||||
|
curl -s -o /dev/null -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
|
||||||
|
https://app1.hyeonworks.com/api/echo
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||||
|
```
|
||||||
|
=== Q4 ② 헤더 크기 상한 ===
|
||||||
|
보낸 길이 1000 → HTTP 200, 도착 길이 1000
|
||||||
|
보낸 길이 4000 → HTTP 200, 도착 길이 4000
|
||||||
|
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
|
||||||
|
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
|
||||||
|
보낸 길이 32000 → HTTP 000
|
||||||
|
|
||||||
|
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`000` 과 `400` 이 다른 것**이다.
|
||||||
|
|
||||||
|
| curl 이 찍는 값 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `400` | 응답을 받았다. **서버가 거부했다** |
|
||||||
|
| `000` | **응답 자체를 못 받았다.** 연결이 끊겼거나 아예 안 열렸다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **자르지 않는다. 거부한다.** 그리고 **거부하는
|
||||||
|
계층이 둘**이다.
|
||||||
|
|
||||||
|
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
|
||||||
|
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** |
|
||||||
|
|
||||||
|
> **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼,
|
||||||
|
> 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다** —
|
||||||
|
> 앞의 것은 앱 로그를 뒤지게 하고 뒤의 것은 방화벽을 뒤지게 한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
|
||||||
|
```
|
||||||
|
|
||||||
|
**점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은
|
||||||
|
**사용자마다 다르다** — role 이 많은 사용자만 깨진다. 테스트 계정으로는
|
||||||
|
영원히 안 보인다.
|
||||||
|
|
||||||
|
## 2-4. 신원 자체를 위조한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
curl -s \
|
||||||
|
-H 'X-Auth-Request-User: administrator' \
|
||||||
|
-H 'X-Auth-Request-Email: admin@example.com' \
|
||||||
|
-H 'X-Auth-Request-Roles: realm-admin,superuser' \
|
||||||
|
https://app1.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||||
|
```
|
||||||
|
=== Q4 ④ upstream 이 검증하는가 ===
|
||||||
|
아무 인증 없이 보냄:
|
||||||
|
x-auth-request-user ['administrator']
|
||||||
|
x-auth-request-email ['admin@example.com']
|
||||||
|
x-auth-request-roles ['realm-admin,superuser']
|
||||||
|
remoteAddr 100.123.124.30
|
||||||
|
→ 그대로 도착. 검증 없음.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **1-3 에서 비어 있던 자리에 값이 들어와 있다.**
|
||||||
|
그리고 `remoteAddr` 이 **내 주소**다 — 숨지도 않았다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **로그인하지 않았다.** 쿠키도 토큰도 없다.
|
||||||
|
헤더 세 줄이 전부다. upstream 은 그것을 그대로 받는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 「통한 것」인지 확인한다
|
||||||
|
|
||||||
|
**「도착했다」와 「통했다」는 다르다.** 도착해도 아무도 안 읽으면 무해하다.
|
||||||
|
그래서 **읽는 쪽이 검증을 하는지**를 대조군으로 확인한다.
|
||||||
|
|
||||||
|
## 3-1. JWT 를 요구하는 경로는 어떻게 되나
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
for p in /api/echo /api/me /api/protected; do
|
||||||
|
curl -s -o /dev/null -w "$p %{http_code}\n" \
|
||||||
|
-H 'X-Auth-Request-User: administrator' \
|
||||||
|
-H 'X-Auth-Request-Roles: realm-admin' \
|
||||||
|
"https://app1.hyeonworks.com$p"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-header-handling.txt`](../../evidence/b4-edge-authorization/01-header-handling.txt)
|
||||||
|
```
|
||||||
|
대조 — JWT 를 요구하는 경로:
|
||||||
|
/api/echo HTTP 200 (permitAll)
|
||||||
|
/api/me HTTP 401
|
||||||
|
/api/protected HTTP 401
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **같은 위조 헤더인데 결과가 갈린다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 위조 헤더는 `/api/echo` 를 열어 준 것이 아니다.
|
||||||
|
거기는 원래 `permitAll` 이라 열려 있었다. `/api/me` 는 **401** 이다 —
|
||||||
|
**헤더로는 인증이 안 된다.**
|
||||||
|
|
||||||
|
**실측** — 같은 파일의 SecurityConfig 발췌
|
||||||
|
```
|
||||||
|
backend SecurityConfig:
|
||||||
|
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3-2. 그래서 무엇이 다른가
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT 경로 → 서명이 있다 → 검증할 대상이 있다 → 위조가 안 된다
|
||||||
|
헤더 경로 → 서명이 없다 → 검증할 대상이 없다 → ★ 위조를 구별할 방법이 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**`request.getHeader("X-Auth-Request-User")` 는 그 값이 어디서 왔는지 모른다.**
|
||||||
|
edge 가 붙였는지 클라이언트가 붙였는지 구별할 정보가 값 안에 없다.
|
||||||
|
|
||||||
|
> Q4 가 「확인한 사실」로 적어둔 *"upstream은 JWT를 입력으로 받지 않아서
|
||||||
|
> 헤더로 넘어온 값을 검증할 방법이 없다"* — **정확하다. 그리고 그것이 이
|
||||||
|
> 구조의 본질적 한계다.**
|
||||||
|
>
|
||||||
|
> 2홉 실험에서 헤더 위조로 `serverName: evil.example.com` 을 만든 것과
|
||||||
|
> **같은 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.**
|
||||||
|
|
||||||
|
**여기까지가 요청만으로 되는 부분이다.** 여기서 멈춰도 ①②④ 는 다 봤다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 두 번째 주입 — IdP 에서 클레임을 바꾼다
|
||||||
|
|
||||||
|
**여기부터 상태가 바뀐다.** ③ 「role 변경은 언제 반영되는가」를 재려면
|
||||||
|
**edge 세션이 실제로 있어야** 하므로 oauth2-proxy 가 필요하고, 그것이
|
||||||
|
app2 를 쓴다.
|
||||||
|
|
||||||
|
## 4-0. app2 를 Grafana 에서 빌린다 — 되돌리는 것을 먼저 만든다
|
||||||
|
|
||||||
|
**하기** — **백업이 먼저다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
|
||||||
|
wc -l /tmp/grafana-ingress-backup.yaml
|
||||||
|
sudo kubectl -n observability delete ingress grafana
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt) 의 첫 줄
|
||||||
|
```
|
||||||
|
grafana ingress 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — [5-4](#5-4-grafana-ingress-를-되돌린다). **지금 확인해 둔다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`wc -l` 을 왜 치나** — 백업 파일이 **비어 있는데 삭제부터 하는** 사고를
|
||||||
|
> 막는다. 0 줄이면 그 자리에서 멈춘다. 파일이 생겼는지 확인하지 않고 원본을
|
||||||
|
> 지우는 것이 이런 작업에서 가장 흔한 사고다.
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 `https://app2.hyeonworks.com/` 를 열고
|
||||||
|
`labuser` / `labpass` 로 로그인한다.
|
||||||
|
|
||||||
|
**확인** — 세션이 생겼나. **지우기 전에 항상 목록을 먼저 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
의 기준선
|
||||||
|
```
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
type=string ttl=3568초 크기=3510바이트
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키 이름이 `_oauth2_proxy-` 로 시작한다.**
|
||||||
|
밑줄로 시작하고 안쪽은 밑줄이다. `'oauth2-proxy*'` 같은 패턴은 **하나도 안
|
||||||
|
맞는다** — 그러면 「세션이 없다」로 오독하고, 이어서 지우는 명령이 조용히
|
||||||
|
아무것도 안 지운다. **목록을 먼저 보는 이유가 이것이다.**
|
||||||
|
|
||||||
|
## 4-1. ★ 왜 curl 로 못 하는가
|
||||||
|
|
||||||
|
**확인** — oauth2-proxy 가 쿠키를 어떻게 만드는지 로그가 말한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep -i 'Cookie settings' | head -1
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
|
||||||
|
httponly:true expiry:1h0m0s ... refresh:disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`httponly:true`** 와 **`refresh:disabled`**.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
- `httponly:true` — **JS 도 못 읽고, 브라우저 밖으로 꺼낼 수도 없다.**
|
||||||
|
그래서 `curl -b` 로 로그인 상태를 흉내 낼 수 없다. **이 측정은 브라우저
|
||||||
|
안에서 해야 한다.** 여기서 「curl 로 하면 되지 않나」를 붙들면 몇 시간이
|
||||||
|
간다 — 원래 실행도 그래서 Playwright 로 연 브라우저를 썼다
|
||||||
|
- `refresh:disabled` — **4-4 의 결과를 미리 말해 준다.** `--cookie-refresh`
|
||||||
|
가 없으면 세션은 토큰을 다시 받지 않는다
|
||||||
|
|
||||||
|
## 4-2. 기준선 — 지금 무슨 값이 나가고 있나
|
||||||
|
|
||||||
|
**`X-Auth-Request-Roles` 대신 `x-forwarded-email` 을 쓴다.** role 을 헤더로
|
||||||
|
내보내려면 추가 설정이 필요한데, **「IdP 의 클레임 변경이 언제 반영되는가」는
|
||||||
|
어느 클레임이든 같은 질문**이다.
|
||||||
|
|
||||||
|
**하기** — 로그인된 app2 탭에서 `F12` → Console
|
||||||
|
```js
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await (await fetch('/api/echo')).text();
|
||||||
|
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
```
|
||||||
|
=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
|
||||||
|
2026-09-04T07:51:23.862Z req#1 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
|
2026-09-04T07:51:24.304Z req#2 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
|
2026-09-04T07:51:24.722Z req#3 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `labuser@example.com`. **이 값이 대조군이다.**
|
||||||
|
|
||||||
|
## 4-3. IdP 에서 값을 바꾼다
|
||||||
|
|
||||||
|
**하기** — 셸에서
|
||||||
|
```bash
|
||||||
|
UID=$(sudo 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 "uid=$UID"
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
|
||||||
|
date -u '+%Y-%m-%dT%H:%M:%SZ 변경'
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — [5-3](#5-3-idp-값을-되돌린다). 지금 명령을 확인해 둔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update users/$UID -r keycloak-patterns -s email=labuser@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — IdP 쪽은 정말 바뀌었나. **바뀌지 않은 것을 「반영 안 됨」으로
|
||||||
|
읽지 않기 위해** 반드시 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get users/$UID -r keycloak-patterns --fields email
|
||||||
|
```
|
||||||
|
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
```
|
||||||
|
=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
|
||||||
|
변경 시각(UTC): 2026-09-04T07:53:32.000Z
|
||||||
|
IdP 의 값:
|
||||||
|
[ {
|
||||||
|
"email" : "changed-labuser@example.com"
|
||||||
|
} ]
|
||||||
|
oauth2-proxy 세션: 1 개 (그대로 살아 있다)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **IdP 값은 바뀌었고 세션은 그대로 1개**다.
|
||||||
|
이 두 줄이 있어야 다음 절의 「옛 값」이 「반영 안 됨」이라고 말할 수 있다.
|
||||||
|
|
||||||
|
## 4-4. 요청을 반복한다 — 몇 번째부터 바뀌나
|
||||||
|
|
||||||
|
**하기** — 브라우저 콘솔에서. 0.5초 간격으로 12번
|
||||||
|
```js
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const r = await (await fetch('/api/echo')).text();
|
||||||
|
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||||
|
await new Promise(s => setTimeout(s, 500));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
```
|
||||||
|
=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
|
||||||
|
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:57.489Z req#3 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:58.018Z req#4 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:58.602Z req#5 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:59.217Z req#6 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:59.743Z req#7 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:00.342Z req#8 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:00.964Z req#9 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:01.574Z req#10 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:02.187Z req#11 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
|
||||||
|
→ 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **12줄이 전부 같다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Q4 는 「몇 번째 요청부터 반영되는지」를 물었는데,
|
||||||
|
**답은 「요청으로는 안 된다」이다.** 요청 횟수가 아니라 **세션의 나이**가 정한다.
|
||||||
|
|
||||||
|
## 4-5. ★ 두 시계가 어긋나 있다 — 그래서 이 결론이 성립한다
|
||||||
|
|
||||||
|
**브라우저 타임스탬프와 서버 타임스탬프를 그대로 비교하면 안 된다.**
|
||||||
|
|
||||||
|
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
```
|
||||||
|
=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
|
||||||
|
개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
|
||||||
|
test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
|
||||||
|
→ test-server 가 약 107초 앞선다.
|
||||||
|
브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **107초.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 보정 전에는 12회의 타임스탬프(`07:51:56~`)가
|
||||||
|
변경 시각(`07:53:32`)보다 **앞서 보인다.** 그대로 읽으면 「변경 전에 잰
|
||||||
|
것」이 되어 **결론이 통째로 무너진다.** 보정하면 12회는 변경 **11초 뒤**이고,
|
||||||
|
그래야 「변경 후에도 옛 값」이라는 결론이 선다.
|
||||||
|
|
||||||
|
**확인** — 당신 환경의 어긋남을 잰다
|
||||||
|
```bash
|
||||||
|
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
```
|
||||||
|
그리고 브라우저 콘솔에서
|
||||||
|
```js
|
||||||
|
new Date().toISOString()
|
||||||
|
```
|
||||||
|
두 값의 차가 보정값이다.
|
||||||
|
|
||||||
|
> **두 기계의 로그를 나란히 놓기 전에 시계를 확인한다.** D-4 는 이 확인을
|
||||||
|
> 안 해서 인증서 공백을 처음에 잘못 계산했고, 나중에 **38분 25초**로
|
||||||
|
> 정정했다. 같은 실수가 여기서도 났고, **증거 파일에 보정값을 적어 두는
|
||||||
|
> 것**으로 처리했다.
|
||||||
|
|
||||||
|
## 4-6. 세션을 지우고 재인증시킨다
|
||||||
|
|
||||||
|
**하기** — **지우기 전에 목록을 본다**(4-0 의 이유)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' \
|
||||||
|
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||||
|
```
|
||||||
|
**미검증** — 후속 문서 §3 에 실린 형태를 **실제 키 이름에 맞춰 고친 것**이다.
|
||||||
|
패턴이 안 맞으면 아무것도 안 지워지고 오류도 안 난다 — 앞 명령의 목록과
|
||||||
|
`del` 이 돌려주는 개수를 대조한다.
|
||||||
|
|
||||||
|
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 접근하면 새
|
||||||
|
세션이 만들어진다(그게 이 절의 목적이다).
|
||||||
|
|
||||||
|
**하기** — 브라우저에서 app2 를 새로고침한 뒤 콘솔에서 3회
|
||||||
|
```js
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await (await fetch('/api/echo')).text();
|
||||||
|
console.log(new Date().toISOString(), r.match(/x-forwarded-email[^,]*/)[0]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**실측** — [`03-b4-role-propagation.txt`](../../evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
```
|
||||||
|
=== [6] 재인증 후 (브라우저 fetch) ===
|
||||||
|
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **새 값이 나온다.** 그리고 **로그인 화면은 안 떴다** —
|
||||||
|
Keycloak SSO 가 살아 있어 조용히 재인증됐다(B-2 4-5 와 같은 성질이다).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 후 12회 요청(6.4초) → labuser@example.com (옛 값)
|
||||||
|
세션 삭제 후 재인증 → changed-labuser@example.com (새 값)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **개념 — 세션은 로그인 시점의 스냅샷이다.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> 로그인 → IdP 가 준 클레임을 세션에 담는다
|
||||||
|
> 이후 요청 → 세션에서 읽어 헤더로 내보낸다
|
||||||
|
> └─ IdP 를 다시 부르지 않는다
|
||||||
|
> IdP 에서 변경 → 세션은 모른다
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> | 설정 | 반영 시점 |
|
||||||
|
> |---|---|
|
||||||
|
> | 지금 (`--cookie-refresh` 없음) | **쿠키 만료(1시간) 또는 재인증까지 안 됨** |
|
||||||
|
> | `--cookie-refresh=5m` | 최대 5분 |
|
||||||
|
>
|
||||||
|
> **권한을 뺏는 변경이 최대 1시간 늦게 반영된다.** 해고된 사용자의 세션이
|
||||||
|
> 한 시간 더 산다는 뜻이고, 이것이 Q4 의 설계 판단 2번(**즉시 반영이
|
||||||
|
> 필요한가**)에 직접 답한다 — 즉시가 필요하면 헤더 방식은 맞지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 고치기와 복구
|
||||||
|
|
||||||
|
## 5-1. nginx 에서 동명 헤더를 덮어쓴다 — 먼저 지워야 한다
|
||||||
|
|
||||||
|
**이 실험대는 이 수정을 적용한 적이 없다.** 해설 문서 6절이 「남긴 것」으로
|
||||||
|
분류한 항목이다. 아래는 **미검증**이며, 적용하려면 랩 호스트에서 사람이
|
||||||
|
직접 친다.
|
||||||
|
|
||||||
|
**하기** — **랩 호스트(`test-server`)** 에서. **백업이 먼저다**
|
||||||
|
```bash
|
||||||
|
sudo cp /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-available/keycloak-lab.b4-backup
|
||||||
|
ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup
|
||||||
|
sudo vi /etc/nginx/sites-available/keycloak-lab
|
||||||
|
```
|
||||||
|
`location / { ... }` 안, 기존 `proxy_set_header` 들 옆에 넣는다.
|
||||||
|
```nginx
|
||||||
|
# B-4 — 클라이언트가 보낸 X-Auth-Request-* 를 먼저 지운다.
|
||||||
|
# 빈 값으로 set 해야 "설정한 헤더"가 되어 통과가 아니라 덮어쓰기가 된다.
|
||||||
|
proxy_set_header X-Auth-Request-User "";
|
||||||
|
proxy_set_header X-Auth-Request-Email "";
|
||||||
|
proxy_set_header X-Auth-Request-Roles "";
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`""` 로 먼저 지우는 것**이 핵심이다.
|
||||||
|
|
||||||
|
> **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다**(1-4). 그러니
|
||||||
|
> 「덮어쓰게 하려면 먼저 설정해야」 하고, 붙일 값이 없을 때 설정하는 방법이
|
||||||
|
> **빈 문자열**이다. `proxy_set_header X-Auth-Request-Roles "";` 는
|
||||||
|
> nginx 에서 **그 헤더를 upstream 으로 보내지 않는다**는 뜻이다.
|
||||||
|
>
|
||||||
|
> edge 가 진짜 값을 붙여야 하는 자리라면 **지운 뒤에 다시 설정**한다.
|
||||||
|
> 순서가 반대면 클라이언트 값이 살아남는다.
|
||||||
|
|
||||||
|
**하기** — 문법을 보고 적용한다
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `nginx -t` 의 **마지막 줄**. `syntax is ok` 와
|
||||||
|
`test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 은 통과를
|
||||||
|
막지 않는다. 실패면 `&&` 가 reload 를 **막아 준 것**이고 지금 돌고 있는
|
||||||
|
nginx 는 옛 설정 그대로다.
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo cp /etc/nginx/sites-available/keycloak-lab.b4-backup /etc/nginx/sites-available/keycloak-lab
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2. 고쳐졌는지 같은 명령으로 다시 잰다
|
||||||
|
|
||||||
|
**하기** — 2-1 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
curl -s -H 'X-Auth-Request-Roles: admin' -H 'X-Auth-Request-Roles: editor' \
|
||||||
|
https://app1.hyeonworks.com/api/echo | grep -o '"x-auth-request-roles":\[[^]]*\]'
|
||||||
|
```
|
||||||
|
**미검증** — 이 실험대는 여기까지 재지 않았다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **아무것도 안 나와야 한다**(1-3 의 대조군과 같아진다).
|
||||||
|
값이 그대로 나오면 reload 가 안 갔거나 다른 `server` 블록을 고친 것이다.
|
||||||
|
워커 PID 가 바뀌었는지로 reload 여부를 판정한다.
|
||||||
|
```bash
|
||||||
|
systemctl status nginx --no-pager | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-3. IdP 값을 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
UID=$(sudo 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)
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update users/$UID -r keycloak-patterns -s email=labuser@example.com
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get users/$UID -r keycloak-patterns --fields email
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `"email" : "labuser@example.com"`.
|
||||||
|
**4-6 에서 배운 대로, 되돌려도 살아 있는 세션에는 즉시 반영되지 않는다.**
|
||||||
|
세션을 한 번 더 지우면 확실하다.
|
||||||
|
|
||||||
|
## 5-4. Grafana Ingress 를 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl get ingress -A | grep app2
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `app2` 를 잡고 있는 Ingress가 **`observability/grafana`
|
||||||
|
하나**여야 한다. 둘이 남아 있으면 어느 쪽이 이길지는 컨트롤러가 정한다 —
|
||||||
|
**되돌린 것이 아니라 경합을 만든 것**이다.
|
||||||
|
|
||||||
|
> **oauth2-proxy Deployment 자체는 남겨도 된다.** Ingress 만 떼면 app2 로는
|
||||||
|
> 안 들어간다. B-7 을 이어서 할 거라면 그대로 두는 편이 낫다.
|
||||||
|
|
||||||
|
## 5-5. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| app2 | `sudo kubectl get ingress -A \| grep app2` | `observability/grafana` **하나만** |
|
||||||
|
| Grafana | `curl -s -o /dev/null -w '%{http_code}\n' https://app2.hyeonworks.com/` | Grafana 가 답한다 (`200` 또는 로그인 `302`) |
|
||||||
|
| app1 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/echo` | `200` |
|
||||||
|
| IdP | `… kcadm.sh get users/$UID -r keycloak-patterns --fields email` | `labuser@example.com` |
|
||||||
|
| nginx | `sudo nginx -t` (호스트) | `test is successful` |
|
||||||
|
| nginx 백업 | `ls -l /etc/nginx/sites-available/keycloak-lab.b4-backup` | 되돌렸으면 지워도 된다 |
|
||||||
|
| Redis | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | 로그아웃했으면 없거나, 새 세션 하나 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 동명 헤더가 **하나만 도착한 것처럼** 보인다 | **`tr ',' '\n'` 으로 잘랐다.** 값 배열이 두 줄로 쪼개진다 | `grep -o '…\[[^]]*\]'` 로 대괄호째 뽑는다 — 1-2 |
|
||||||
|
| `jq: command not found` | **이 실험대에 `jq` 가 없다** | `grep -o` 로 뽑거나 응답을 통째로 본다 |
|
||||||
|
| `python3 -m json.tool` 을 쓰라고 되어 있다 | 해설 문서 7절의 형태다. 값 생성도 `python3 -c` 였다 | `head -c N /dev/zero \| tr '\0' 'r'` — 2-3 |
|
||||||
|
| 16000 에서 `000` 이 나온다 | **오류가 아니라 측정 결과다.** nginx 가 연결을 끊는다 | `400`(Tomcat)과 `000`(nginx)을 구별한다 — 2-3 |
|
||||||
|
| `sudo grep` 이 빈 결과 | **호스트 sudo 는 비밀번호를 요구한다** | `sudo -n -l` 로 확인. D-4 가 이 조용한 실패에 걸렸다 |
|
||||||
|
| Redis 에서 세션이 안 보인다 | 패턴이 틀렸다. 키는 **`_oauth2_proxy-`** 로 시작한다 | 먼저 `--scan` 만 쳐서 이름을 눈으로 본다 — 4-0 |
|
||||||
|
| `xargs … del` 이 아무것도 안 지운다 | 같은 원인. 패턴이 안 맞으면 **조용히** 0건 | 목록 개수와 `del` 반환 개수를 대조 — 4-6 |
|
||||||
|
| `curl -b` 로 로그인 상태가 재현이 안 된다 | **쿠키가 `HttpOnly` 다.** 꺼낼 수 없다 | 브라우저 콘솔에서 잰다 — 4-1 |
|
||||||
|
| 12회가 **변경 시각보다 앞서** 보인다 | **두 시계가 107초 어긋나 있었다** | 보정값을 먼저 잰다 — 4-5 |
|
||||||
|
| 값이 안 바뀐다 | **버그가 아니다.** 세션이 새로 만들어져야 한다 | 4-6 · `--cookie-refresh` |
|
||||||
|
| app2 가 Grafana 도 프록시도 아닌 것을 준다 | Ingress 가 **둘 다 남아 있다** | `get ingress -A \| grep app2` — 5-4 |
|
||||||
|
| `/api/me` 가 `200` 이다 | 위조가 통한 것이 **아니라** 진짜 JWT 를 보낸 것이다 | 헤더만 보냈는지 다시 본다 — 3-1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 실험이 재지 않은 것
|
||||||
|
|
||||||
|
| 항목 | 왜 |
|
||||||
|
|---|---|
|
||||||
|
| 5-1 의 nginx 수정 효과 | **적용한 적이 없다.** 해설 문서가 「남긴 것」으로 분류했다 |
|
||||||
|
| `X-Auth-Request-Roles` 자체의 반영 시점 | role 을 헤더로 내보내려면 추가 설정이 필요해 **`x-forwarded-email` 로 대체**했다. 「클레임 변경이 언제 반영되는가」는 어느 클레임이든 같다 |
|
||||||
|
| `--cookie-refresh=5m` 을 켠 뒤의 반영 시점 | 표의 「최대 5분」은 설정의 정의이지 **이 실험대에서 잰 값이 아니다** |
|
||||||
|
| upstream 의 내부 credential 검증 | controller 한 곳에만 있다. **공통 경계로 옮기는 것은 코드 변경**이라 이 실험 밖이다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-4 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| B-7 oauth2-proxy | cookie secret 을 회전하면 **저장소에 무엇이 남는가**. app2 를 빌리는 절차가 같다 |
|
||||||
|
| B-7a 고아 세션 | 4-6 에서 지운 그 키들의 **수명과 정리 규칙** |
|
||||||
|
| [B-2](b2-multi-instance-session.md) BFF | 같은 질문을 **서버 보관 토큰**으로 풀면 어떻게 다른가 |
|
||||||
|
| 설계 | **2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.** 두 구조가 같은 실험대에 다 있다 |
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
# B-5 재현 가이드 — Redis 를 내리고 파드가 `Ready` 인 채로 계속 실패하는 것을 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b5-redis-loss/`](../../evidence/b5-redis-loss/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 오류가 아니라 **멈추는** 것 (`HTTP 000`) | 밖에서 `curl` |
|
||||||
|
| `/actuator/health` 는 `503` 인데 **파드는 `1/1 Ready`** | health 그룹별 응답 |
|
||||||
|
| Service 엔드포인트에 **두 파드가 그대로** 남아 있는 것 | `endpointslice` |
|
||||||
|
| 손대지 않아도 **재시작 0회로 회복**하는 것 | `get pods` · Lettuce |
|
||||||
|
| **AOF 를 켰는데 재시작 후 `dbsize 0`** 인 것 | 볼륨 없는 `/data` |
|
||||||
|
| 볼륨 위에서는 **살아남는** 것 | PVC 를 붙인 뒤 같은 시험 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||||
|
- [`B-1`](b1-redis-session-store.md) · [`B-2`](b2-multi-instance-session.md)
|
||||||
|
가 끝나 **세션은 Redis, 토큰은 PostgreSQL** 로 나뉘어 있다.
|
||||||
|
나뉘어 있어야 **각각 죽여볼 수 있다** — 이 실험은 Redis 만 죽인다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 브라우저로 `https://app1.hyeonworks.com/` 에 **로그인해 둔다**
|
||||||
|
(`labuser` / `labpass`). Redis 에 세션이 하나는 있어야 「잃는 것」이 보인다.
|
||||||
|
- Redis 는 `redis.keycloak-lab.svc:6379`, 파드는 `kc-lab-2` 에 고정되어 있다.
|
||||||
|
|
||||||
|
## 주의 — 이건 저장소를 지우는 실험이다
|
||||||
|
|
||||||
|
Redis 를 0대로 내리고, 나중에 **볼륨 없이 파드를 지운다.** 그 안의 세션은
|
||||||
|
**돌아오지 않는다.** 로그인한 사용자는 전부 로그아웃된다. **실험대에서만 한다.**
|
||||||
|
전 구간 약 30분. 중간에 그만두려면 [5-1](#5-1-되돌린다) 의 한 줄이면 된다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:24–14:28 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 IP·시각은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||||
|
|
||||||
|
**이 실험대는 그 뒤로 바뀌었다.** 지금 매니페스트
|
||||||
|
([`bff-redis.yaml`](../../../deploy/lab/k8s/bff-redis.yaml))에는 **B-5 의 결론이
|
||||||
|
이미 반영되어** PVC 와 `--appendonly yes` 가 들어 있다. 그래서 6절은
|
||||||
|
「볼륨 없는 상태를 다시 만드는」 단계부터 시작한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
A-2 에서 Keycloak 의 PostgreSQL 을 내렸다. 그때는 이렇게 됐다.
|
||||||
|
|
||||||
|
```
|
||||||
|
DB 정지 → 헬스체크 실패 → 파드 NotReady → Service 에서 빠짐 → 밖에서 503
|
||||||
|
```
|
||||||
|
|
||||||
|
**명확한 실패였다.** 503 은 「지금 안 된다」고 말하고, 클라이언트는 재시도든
|
||||||
|
포기든 결정할 수 있다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| 통념 | 의존 저장소가 죽으면 **헬스체크가 알아서 파드를 빼 준다** |
|
||||||
|
| B-5 가 재는 것 | 진짜 그런가. **그리고 이번에는 무엇을 보고 판단하는가** |
|
||||||
|
|
||||||
|
그리고 두 번째 질문이 붙는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Redis 를 다시 띄우면 → 세션이 남아 있나?
|
||||||
|
```
|
||||||
|
|
||||||
|
**「영속화를 켜 두면 남는다」가 통념이다.** 이 실험은 그 통념이 쿠버네티스에서
|
||||||
|
어떻게 어긋나는지를 잰다. 그래서 **1-3 이 이 가이드에서 가장 중요한 절**이다 —
|
||||||
|
영속화를 논하기 전에 **`/data` 가 무엇인지부터** 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — Redis 를 내리기 전에
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → Redis 내용 · 영속화 설정 → ★ /data 가 볼륨인가 → 세 경로 → health 그룹
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드와 노드
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME READY STATUS RESTARTS AGE IP NODE
|
||||||
|
bff-555df79c97-6j86w 1/1 Running 0 17m 10.42.0.52 kc-lab-1
|
||||||
|
bff-555df79c97-vgg6g 1/1 Running 0 16m 10.42.1.124 kc-lab-2
|
||||||
|
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||||
|
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `bff` 가 **둘 다** `1/1`, `RESTARTS` 가 `0`
|
||||||
|
- **Redis 는 하나다.** replica 가 없다 — 그래서 0으로 내리면 전면 정지다
|
||||||
|
- Redis 와 PostgreSQL 이 **같은 노드**(`kc-lab-2`)다. 매니페스트가
|
||||||
|
`nodeSelector` 로 고정한다 — A-4(노드 상실)에서 **두 저장소가 한꺼번에**
|
||||||
|
없어지게 하려는 배치다
|
||||||
|
|
||||||
|
`10.42.0.52` 와 `10.42.1.124` 는 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||||
|
에 남은 실제 BFF 파드 IP 다. **4-3 에서 이 두 주소가 다시 나온다.**
|
||||||
|
|
||||||
|
## 1-2. Redis 가 지금 무엇을 들고 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli ping
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get save
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== 기준선 ===
|
||||||
|
Redis 키: 1
|
||||||
|
PostgreSQL 토큰: 1 행
|
||||||
|
Redis 영속화 설정:
|
||||||
|
save = save
|
||||||
|
appendonly no
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지.
|
||||||
|
|
||||||
|
| 값 | 그때 | 뜻 |
|
||||||
|
|---|---|---|
|
||||||
|
| 키 수 | `1` | 로그인 세션 하나 |
|
||||||
|
| `save` | 빈 값 | **RDB 스냅샷이 꺼져 있다** |
|
||||||
|
| `appendonly` | `no` | **AOF 도 꺼져 있다** |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 그때는 **영속화가 아예 꺼져 있었다.**
|
||||||
|
지금 당신 환경은 다를 것이다 — 매니페스트가 `--appendonly yes` 로 시작하므로
|
||||||
|
`appendonly yes` 가 나온다. **그 차이가 6절의 출발점이다.**
|
||||||
|
|
||||||
|
> `save` 출력의 값이 비어 있는 것과 키가 없는 것은 다르다.
|
||||||
|
> `config get save` 는 항상 두 줄(이름·값)을 돌려주고, 값 줄이 비어 있으면
|
||||||
|
> 「스냅샷 조건 없음」이다. 증거의 `save = save` 는 그 두 줄이 한 줄로
|
||||||
|
> 붙어 찍힌 모양이다.
|
||||||
|
|
||||||
|
## 1-3. ★ `/data` 가 볼륨인가 — 영속화를 말하기 전에 여기부터 본다
|
||||||
|
|
||||||
|
**이 절을 건너뛰면 6절의 결과를 오해한다.** 「AOF 를 켰는데 안 남는다」를
|
||||||
|
「Redis 가 이상하다」로 읽게 된다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||||
|
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||||
|
```
|
||||||
|
**형태** — 지금 매니페스트 기준
|
||||||
|
```json
|
||||||
|
[{"name":"data","persistentVolumeClaim":{"claimName":"redis-data"}}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 그 볼륨이 `/data` 에 붙어 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||||
|
-o jsonpath='{.items[0].spec.containers[0].volumeMounts}'; echo
|
||||||
|
sudo kubectl -n keycloak-lab get pvc
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||||
|
redis-data Bound pvc-... 1Gi RWO local-path 3d
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지가 **전부** 성립해야 한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
① volumes 에 항목이 있다 ← 없으면 컨테이너 파일시스템이다
|
||||||
|
② volumeMounts 의 mountPath 가 /data ← 다른 데 붙었으면 소용없다
|
||||||
|
③ PVC 가 Bound ← Pending 이면 파드가 안 뜬다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 셋 중 하나라도 빠지면 **`appendonly yes` 는
|
||||||
|
장식이다.** 파일은 만들어지고 로그도 정상인데 재시작하면 사라진다.
|
||||||
|
6절에서 그것을 직접 만든다.
|
||||||
|
|
||||||
|
> **개념 — 컨테이너 파일시스템은 컨테이너와 함께 죽는다.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> /data 가 볼륨이 아니다 → 이미지 위의 쓰기 가능 레이어에 쓴다
|
||||||
|
> → 컨테이너가 없어지면 그 레이어도 없어진다
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Redis 는 이것을 모른다. `appendonly yes` 를 켜면 성실히 `/data` 에
|
||||||
|
> `appendonlydir` 을 만들고 매 쓰기를 기록한다. **거짓말이 아니라 정말로
|
||||||
|
> 기록한다.** 다만 그 디렉터리가 어디 있는지를 모를 뿐이다.
|
||||||
|
>
|
||||||
|
> `emptyDir` 도 마찬가지다 — 컨테이너 재시작은 견디지만 **파드가 없어지면
|
||||||
|
> 같이 없어진다.** 「볼륨을 붙였다」와 「영속 볼륨을 붙였다」는 다르다.
|
||||||
|
|
||||||
|
## 1-4. 세 경로를 정상 상태에서 잰다
|
||||||
|
|
||||||
|
**주입 후에 볼 것을 주입 전에 똑같은 명령으로 먼저 봐 둔다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
for p in / /bff/token-boundary /actuator/health; do
|
||||||
|
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/b5-redis-loss/01-baseline.txt) 는 첫 줄만 남겼다
|
||||||
|
```
|
||||||
|
=== 외부 진입점 정상 확인 ===
|
||||||
|
https://app1.hyeonworks.com/ HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`000` 이 아닌 것.** 그게 판정 기준의 전부다.
|
||||||
|
|
||||||
|
| 경로 | 정상일 때 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | `200` | `permitAll` 정적 페이지. **Redis 를 안 탄다** |
|
||||||
|
| `/bff/token-boundary` | `200` 또는 로그인으로 보내는 `3xx` | 세션이 필요하다 — **Redis 를 탄다** |
|
||||||
|
| `/actuator/health` | `200` | 모든 지표의 합 |
|
||||||
|
|
||||||
|
**미검증** — 셸의 `curl` 에는 로그인 쿠키가 없으므로 두 번째는 보통 `3xx` 다.
|
||||||
|
**`200` 이든 `3xx` 든 상관없다** — 이 실험이 보는 것은 **응답이 오는가**이고,
|
||||||
|
`3xx` 를 만드는 과정에서도 BFF 는 세션을 만들려고 **Redis 를 건드린다.**
|
||||||
|
|
||||||
|
> **`--max-time` 을 반드시 붙인다.** 4-1 에서 이 요청은 **응답이 안 온다.**
|
||||||
|
> 타임아웃이 없으면 터미널이 붙잡힌 채로 있고, 그 상태를 「멈춤」이 아니라
|
||||||
|
> 「내 터미널이 이상함」으로 읽게 된다.
|
||||||
|
|
||||||
|
## 1-5. health 그룹을 미리 본다
|
||||||
|
|
||||||
|
**4-2 의 놀라움은 기준선을 봐 둬야 놀라움이 된다.**
|
||||||
|
|
||||||
|
**확인** — `/actuator/**` 는 이 실험대에서 열려 있다(운영에서는 절대 안 연다)
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/actuator/health; echo
|
||||||
|
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||||
|
curl -s https://app1.hyeonworks.com/actuator/health/liveness; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **첫 번째 응답의 본문에 `redis` 항목이 있는지**,
|
||||||
|
그리고 **두 번째 응답에는 없는지.** 세 응답이 서로 다른 것을 본다는 것이
|
||||||
|
이 절의 전부다.
|
||||||
|
|
||||||
|
**실측** — 정지 후의 값은 [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||||
|
에 있고, 그 본문 항목 자리는 **비어 있다**
|
||||||
|
```
|
||||||
|
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||||
|
|
||||||
|
|
||||||
|
=== /actuator/health/readiness 본문 ===
|
||||||
|
{"status":"UP"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**첫 번째 칸이 비어 있는 것은 측정 실패다.** 파드 안에서 본문을 받아오려다
|
||||||
|
못 받았다. **당신은 지금 밖에서 직접 재 두는 것이 낫다** — 뒤에서 이 자리를
|
||||||
|
비교하게 된다.
|
||||||
|
|
||||||
|
> **개념 — Spring Boot 의 health group.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> /actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
|
||||||
|
> /actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
|
||||||
|
> /actuator/health/liveness liveness 그룹
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
|
||||||
|
> 그리고 kubelet 이 보는 것은 매니페스트가 지정한 경로 —
|
||||||
|
> `readinessProbe.httpGet.path: /actuator/health/readiness` — 다.
|
||||||
|
>
|
||||||
|
> **전체는 DOWN 인데 readiness 는 UP 인 상태**가 성립하고, 4-2 가 그것이다.
|
||||||
|
|
||||||
|
**확인** — kubelet 이 실제로 무엇을 보는지 매니페스트에서 확인한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy bff \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].readinessProbe.httpGet.path}'; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
/actuator/health/readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 ① — Redis 를 0대로 내린다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 왜 `scale --replicas=0` 인가
|
||||||
|
|
||||||
|
| 방법 | 만들어지는 상태 |
|
||||||
|
|---|---|
|
||||||
|
| `delete pod` | Deployment 가 **즉시 새로 만든다.** 몇 초짜리 공백이라 관찰할 시간이 없다 |
|
||||||
|
| **`scale --replicas=0`** | **없는 상태가 유지된다.** 내가 되돌릴 때까지 |
|
||||||
|
| NetworkPolicy 로 6379 차단 | 「연결 거부」와 「응답 없음」이 섞인다. A-1 에서 본 대로 **기존 연결은 안 끊긴다** |
|
||||||
|
|
||||||
|
**「저장소가 없어진 상태」를 안정적으로 유지하는 것이 목적**이므로 두 번째다.
|
||||||
|
그리고 이 방법은 **파드가 사라지므로 주입 여부를 눈으로 확인하기 쉽다**(3-1).
|
||||||
|
|
||||||
|
## 2-2. 적용
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 정지'
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=0
|
||||||
|
```
|
||||||
|
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||||
|
```
|
||||||
|
=== ① Redis 정지 ===
|
||||||
|
정지: 14:26:30
|
||||||
|
deployment.apps/redis scaled
|
||||||
|
삭제 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 반드시 적어 둔다.** 5절에서 「언제부터 회복됐나」를 붙일 때 쓴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. Redis 파드가 정말 없나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=redis
|
||||||
|
sudo kubectl -n keycloak-lab get deploy redis
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
No resources found in keycloak-lab namespace.
|
||||||
|
|
||||||
|
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||||
|
redis 0/0 0 0 3d
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `0/0`. `1/1` 이면 스케일이 안 먹었거나 다른
|
||||||
|
네임스페이스를 건드린 것이고, 그 상태에서 재는 것은 전부 무의미하다.
|
||||||
|
|
||||||
|
## 3-2. BFF 가 정말 못 붙고 있나
|
||||||
|
|
||||||
|
**응답이 없는 것과 붙지 못하는 것은 다르다.** 로그가 이유를 말한다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=bff --tail=40 | grep -iE 'redis|connect|netty' | tail -10
|
||||||
|
```
|
||||||
|
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||||
|
```
|
||||||
|
=== BFF 로그 ===
|
||||||
|
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
|
||||||
|
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
|
||||||
|
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
|
||||||
|
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||||
|
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`pollConnect` · `finishConnect`.** 연결을 **맺는
|
||||||
|
중**이라는 뜻이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이미 실패한 것이 아니라 **아직 시도 중**이다.
|
||||||
|
Lettuce(Netty 기반 Redis 클라이언트)가 재연결을 시도하며 타임아웃을 기다린다.
|
||||||
|
**4-1 의 `000` 이 여기서 나온다.**
|
||||||
|
|
||||||
|
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`bff` 두 개의 `RESTARTS` 가 여전히 0**, 그리고
|
||||||
|
**postgres 가 살아 있는 것.** postgres 까지 내렸다면 이건 B-5 가 아니라
|
||||||
|
전면 장애를 재는 것이다.
|
||||||
|
|
||||||
|
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||||
|
```
|
||||||
|
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
|
||||||
|
bff-555df79c97-6j86w 1/1 Running 0 17m
|
||||||
|
bff-555df79c97-vgg6g 1/1 Running 0 16m
|
||||||
|
```
|
||||||
|
|
||||||
|
**여기서 이미 답이 절반 나와 있다** — Redis 가 없는데 **`1/1`** 이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. `000` 은 오류가 아니라 멈춤이다
|
||||||
|
|
||||||
|
**확인** — 1-4 와 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
for p in / /bff/token-boundary /actuator/health; do
|
||||||
|
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt)
|
||||||
|
```
|
||||||
|
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
|
||||||
|
/ HTTP 200
|
||||||
|
/bff/token-boundary HTTP 000
|
||||||
|
/actuator/health HTTP 503
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 값이 **서로 다르다.**
|
||||||
|
|
||||||
|
| 코드 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `200` | 정적 페이지는 산다 — **Redis 를 안 타는 경로** |
|
||||||
|
| **`000`** | **응답 자체를 못 받았다.** curl 이 기다리다 포기했다 |
|
||||||
|
| `503` | 헬스 엔드포인트는 **대답은 한다** — 다만 DOWN 이라고 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **오류를 돌려주는 것이 아니라 매달려 있다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
빠른 실패: 요청 → 즉시 503 → 사용자는 오류 화면을 본다. 재시도할지 정할 수 있다
|
||||||
|
느린 실패: 요청 → ………… → 사용자는 멈춘 화면을 본다. 아무것도 정할 수 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**「빨리 실패하기(fail fast)」가 안 되어 있다.** A-6(지연 주입)에서 본 것과
|
||||||
|
같은 문제다 — **느린 실패가 빠른 실패보다 나쁘다.** 브라우저 탭도, 그 앞의
|
||||||
|
로드밸런서도, 그 앞의 사용자도 전부 붙잡힌다.
|
||||||
|
|
||||||
|
**응답 본문도 비어 있다.**
|
||||||
|
|
||||||
|
**실측** — 같은 파일
|
||||||
|
```
|
||||||
|
--- token-boundary 응답 본문 ---
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
**본문이 없다는 것은 「오류 페이지조차 못 만들었다」**는 뜻이다.
|
||||||
|
|
||||||
|
> **고치려면 클라이언트에 타임아웃을 건다.** Lettuce 의 연결·명령 타임아웃을
|
||||||
|
> 짧게 잡으면 `000` 이 `500` 이 된다. **500 이 000 보다 낫다** — 적어도
|
||||||
|
> 말은 하기 때문이다.
|
||||||
|
|
||||||
|
## 4-2. ★ 그런데 파드는 `Ready` 를 유지한다
|
||||||
|
|
||||||
|
**이것이 이 실험의 가장 중요한 발견이다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'health %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health
|
||||||
|
curl -s -o /dev/null -w 'readiness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/readiness
|
||||||
|
curl -s -o /dev/null -w 'liveness %{http_code}\n' --max-time 10 https://app1.hyeonworks.com/actuator/health/liveness
|
||||||
|
curl -s https://app1.hyeonworks.com/actuator/health/readiness; echo
|
||||||
|
```
|
||||||
|
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||||
|
```
|
||||||
|
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
|
||||||
|
/actuator/health HTTP server
|
||||||
|
/actuator/health/readiness HTTP 200
|
||||||
|
/actuator/health/liveness HTTP 200
|
||||||
|
|
||||||
|
=== /actuator/health 본문 (Redis 항목이 있는가) ===
|
||||||
|
|
||||||
|
|
||||||
|
=== /actuator/health/readiness 본문 ===
|
||||||
|
{"status":"UP"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `readiness` 가 **`200` 이고 `{"status":"UP"}`**.
|
||||||
|
|
||||||
|
> **첫 줄의 `HTTP server` 는 상태 코드가 아니다 — 측정이 실패한 것이다.**
|
||||||
|
> 값이 들어와야 할 자리에 엉뚱한 문자열이 들어와 있다. `503` 이라는 값은
|
||||||
|
> [`02-redis-down.txt`](../../evidence/b5-redis-loss/02-redis-down.txt) 쪽
|
||||||
|
> 측정에서 나왔다.
|
||||||
|
>
|
||||||
|
> **빈 값이나 이상한 값을 「측정 결과」로 읽지 않는다.** 그건 「측정 실패」다.
|
||||||
|
> A-1 에서도 빈 문자열을 「변화」로 읽어 판정이 틀어진 적이 있다.
|
||||||
|
> 이상하면 그 자리에서 다시 친다 — 손으로 하나씩 치는 이유가 이것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 전체 상태는 DOWN 인데 **kubelet 이 보는 그룹은 UP**
|
||||||
|
이다. 그래서 **파드를 빼지 않는다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
/actuator/health redis: DOWN → 전체 DOWN → 503
|
||||||
|
/actuator/health/readiness readinessState 만 → UP → kubelet: "정상"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4-3. Service 엔드포인트에 둘 다 남아 있다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=bff \
|
||||||
|
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
```
|
||||||
|
**실측** — [`03-health-groups.txt`](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||||||
|
```
|
||||||
|
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
|
||||||
|
ready: [10.42.0.52 10.42.1.124]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 주소가 그대로 ready 다.** 1-1 에서 본 그 두 IP.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **두 파드가 계속 트래픽을 받으며 계속 실패한다.**
|
||||||
|
어느 replica 로 가도 결과는 같으므로 **재시도해도 소용없다.**
|
||||||
|
|
||||||
|
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 라 경고가
|
||||||
|
> 뜬다. 해설 문서 5절의 재현 절차에는 옛 형태(`get endpoints bff`)가 실려
|
||||||
|
> 있다 — `endpointslice` 로 본다.
|
||||||
|
|
||||||
|
## 4-4. A-2 와의 대비가 이 실험의 결론이다
|
||||||
|
|
||||||
|
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|
||||||
|
|---|---|---|
|
||||||
|
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
|
||||||
|
| 파드 상태 | **NotReady** | **Ready 유지** |
|
||||||
|
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
|
||||||
|
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
|
||||||
|
|
||||||
|
**Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다.**
|
||||||
|
어느 쪽이 옳은지는 상황에 달렸다.
|
||||||
|
|
||||||
|
| readiness 에 넣으면 | 넣지 않으면 |
|
||||||
|
|---|---|
|
||||||
|
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
|
||||||
|
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
|
||||||
|
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
|
||||||
|
|
||||||
|
**의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다.**
|
||||||
|
넣기로 정했다면 명시한다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
management:
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
group:
|
||||||
|
readiness:
|
||||||
|
include: readinessState, redis # 넣으려면 명시해야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
> **liveness 에는 넣지 않는다.** liveness 가 실패하면 kubelet 이 파드를
|
||||||
|
> **죽인다.** Redis 가 없어서 죽인 파드는 다시 떠도 Redis 가 없으므로 또
|
||||||
|
> 죽는다 — **재시작해도 안 나아지는 문제에 재시작을 거는 것**이다.
|
||||||
|
> 5-2 가 그 반대 증거다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구 ① — 되돌리고 자동 회복을 본다
|
||||||
|
|
||||||
|
## 5-1. 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 복구'
|
||||||
|
sudo kubectl -n keycloak-lab scale deployment/redis --replicas=1
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||||
|
```
|
||||||
|
=== 복구 ===
|
||||||
|
deployment.apps/redis scaled
|
||||||
|
deployment "redis" successfully rolled out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2. 손대지 않고 회복하는지 본다
|
||||||
|
|
||||||
|
**BFF 를 재시작하고 싶은 충동을 참는다.** 재시작하면 「스스로 회복하는가」를
|
||||||
|
영영 알 수 없다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
for p in /actuator/health /bff/token-boundary; do
|
||||||
|
curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 10 "https://app1.hyeonworks.com$p"
|
||||||
|
done
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||||
|
```
|
||||||
|
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||||
|
```
|
||||||
|
/actuator/health HTTP 200
|
||||||
|
/bff/token-boundary HTTP 302
|
||||||
|
BFF 재시작 필요했나: 0,0 회 재시작
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`재시작 0,0`.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **Lettuce 가 스스로 재연결했다.** A-2 에서
|
||||||
|
Keycloak 의 커넥션 풀이 그랬던 것과 같다. **liveness 를 Redis 에 걸었다면
|
||||||
|
파드가 재시작됐을 것**이고, 회복이 더 늦어졌을 것이다.
|
||||||
|
|
||||||
|
**`302` 는 실패가 아니다.** 세션이 사라졌으므로 로그인으로 보내는 것이다.
|
||||||
|
**Redis 가 비었으므로 로그인 상태가 없다 — 사용자는 로그아웃된다.**
|
||||||
|
|
||||||
|
> **여기가 6절로 넘어가는 다리다.** 「Redis 를 다시 띄웠는데 왜 세션이
|
||||||
|
> 없나」가 다음 질문이고, 답은 「영속화가 없었으니까」다. 그럼 켜면 되나?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 주입 ② — 영속화를 켜고 파드를 지운다
|
||||||
|
|
||||||
|
## 6-1. 볼륨이 없던 상태를 다시 만든다
|
||||||
|
|
||||||
|
**지금 실험대에는 이미 PVC 가 붙어 있다**(1-3 에서 확인했다). 원래 측정
|
||||||
|
당시에는 없었다. **볼륨을 떼야 그때를 재현한다.**
|
||||||
|
|
||||||
|
**되돌리기** — **먼저 읽어 둔다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment redis --type=json \
|
||||||
|
-p '[{"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts"},
|
||||||
|
{"op":"remove","path":"/spec/template/spec/volumes"}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 **반대 순서**였다(볼륨 없는 상태에서 시작해 PVC 를
|
||||||
|
붙였다). 지금 실험대에서 같은 관찰을 하려면 이 방향이 된다.
|
||||||
|
|
||||||
|
**확인** — 1-3 과 **똑같은 명령**으로 떨어진 것을 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||||
|
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **빈 줄이 나와야 한다.** 여기서 여전히 PVC 가 보이면
|
||||||
|
패치가 안 먹은 것이고, 그 상태로 6-3 을 하면 **당연히 살아남는다** — 그리고
|
||||||
|
그걸 「영속화가 잘 된다」로 오독한다.
|
||||||
|
|
||||||
|
> **PVC 자체는 지우지 않는다.** Deployment 에서 참조만 뗐다. 6-5 에서
|
||||||
|
> `apply` 로 되돌리면 같은 PVC 에 다시 붙는다. **PVC 를 지우면
|
||||||
|
> `local-path` 프로비저너가 노드의 디렉터리까지 지운다.**
|
||||||
|
|
||||||
|
## 6-2. AOF 를 켜고 키를 심는다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- ls -la /data
|
||||||
|
```
|
||||||
|
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||||
|
```
|
||||||
|
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
|
||||||
|
appendonly yes
|
||||||
|
total 12
|
||||||
|
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
|
||||||
|
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
|
||||||
|
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`appendonlydir` 이 실제로 만들어졌다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **Redis 는 시킨 대로 했다.** 설정도 `yes` 고
|
||||||
|
디렉터리도 있고 파일도 쓰인다. **여기서 「영속화가 켜졌다」고 결론 내리면
|
||||||
|
틀린다** — 어디에 쓰는지를 안 봤기 때문이다. 1-3 에서 이미 본 대로 지금
|
||||||
|
`/data` 는 **컨테이너 파일시스템**이다.
|
||||||
|
|
||||||
|
## 6-3. 파드를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod -l app=redis
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:aof
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||||
|
```
|
||||||
|
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||||
|
```
|
||||||
|
--- 파드를 지운다 ---
|
||||||
|
deployment "redis" successfully rolled out
|
||||||
|
재기동 후:
|
||||||
|
dbsize: 0
|
||||||
|
b5:probe
|
||||||
|
b5:aof
|
||||||
|
appendonly no
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 가지가 같이 사라졌다.**
|
||||||
|
|
||||||
|
| 사라진 것 | 왜 |
|
||||||
|
|---|---|
|
||||||
|
| **데이터** | `/data` 가 컨테이너 파일시스템이었다 — 컨테이너와 함께 없어졌다 |
|
||||||
|
| **설정** | `CONFIG SET` 은 **런타임 전용**이다. 재기동하면 매니페스트의 `args` 가 이긴다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
|
||||||
|
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
|
||||||
|
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다. 그리고 재시작은
|
||||||
|
> 노드 정비·이미지 갱신·OOM 어느 것으로든 일어난다.
|
||||||
|
|
||||||
|
**설정이 되돌아간 것도 따로 중요하다.** `CONFIG SET` 으로 고친 값은
|
||||||
|
`CONFIG REWRITE` 를 하지 않으면 파일에 안 남고, 컨테이너에서는 그 파일 자체가
|
||||||
|
안 남는다. **런타임 설정으로 영속 동작을 정하려는 시도는 두 겹으로 실패한다.**
|
||||||
|
|
||||||
|
## 6-4. 볼륨을 되돌리고 같은 시험을 다시 한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 1-3 과 **똑같은 명령**으로 볼륨이 돌아온 것을 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pod -l app=redis \
|
||||||
|
-o jsonpath='{.items[0].spec.volumes}'; echo
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli config get appendonly
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 키를 심고 다시 지운다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:pvc "written-on-pvc"
|
||||||
|
sudo kubectl -n keycloak-lab delete pod -l app=redis
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deployment/redis --timeout=180s
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli get b5:pvc
|
||||||
|
```
|
||||||
|
**실측** — [`04-persistence.txt`](../../evidence/b5-redis-loss/04-persistence.txt)
|
||||||
|
```
|
||||||
|
=== 영속 볼륨 위에서 다시 시험 ===
|
||||||
|
appendonly yes
|
||||||
|
키 심음: written-on-pvc
|
||||||
|
sed: -e expression #1, char 8: unknown option to 's'
|
||||||
|
|
||||||
|
--- 파드를 지운다 ---
|
||||||
|
deployment "redis" successfully rolled out
|
||||||
|
재기동 후:
|
||||||
|
dbsize: 1
|
||||||
|
b5:pvc written-on-pvc
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`dbsize: 1` 과 `written-on-pvc`.** 살아남았다.
|
||||||
|
|
||||||
|
> 중간의 `sed: -e expression #1, char 8: unknown option to 's'` 는
|
||||||
|
> **원래 실행의 스크립트가 낸 오류**이고 측정과는 무관하다. 값에 `/` 가
|
||||||
|
> 들어간 문자열을 `sed 's/.../.../'` 에 그대로 넣으면 이렇게 된다.
|
||||||
|
> **증거 파일에 남은 오류를 지우지 않은 것**은, 그것이 「이 줄은 스크립트가
|
||||||
|
> 만든 것」이라는 표시이기 때문이다.
|
||||||
|
|
||||||
|
## 6-5. 순서가 있다
|
||||||
|
|
||||||
|
| 구성 | 파드 삭제 후 |
|
||||||
|
|---|---|
|
||||||
|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
|
||||||
|
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
|
||||||
|
| AOF **켬**, **PVC** | **생존** |
|
||||||
|
|
||||||
|
**볼륨이 먼저고 설정이 나중이다.** 순서를 바꾸면 두 번째 줄이 된다 —
|
||||||
|
그리고 두 번째 줄은 **첫 번째 줄과 결과가 같은데 안심하고 있다는 점에서
|
||||||
|
더 나쁘다.**
|
||||||
|
|
||||||
|
### `appendfsync` 는 여전히 트레이드오프다
|
||||||
|
|
||||||
|
```
|
||||||
|
appendfsync everysec ← 기본값
|
||||||
|
```
|
||||||
|
|
||||||
|
| 설정 | 잃는 양 | 비용 |
|
||||||
|
|---|---|---|
|
||||||
|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
|
||||||
|
| **`everysec`** | **최대 1초** | 기본값 |
|
||||||
|
| `no` | OS 에 맡김 | 가장 빠름 |
|
||||||
|
|
||||||
|
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가 다시
|
||||||
|
로그인해야 한다는 뜻이다.** A-3 에서 본 PostgreSQL 의 `synchronous_commit OFF`
|
||||||
|
와 **같은 모양의 맞바꿈**이고, 거기서 Keycloak 이 같은 판단을 했다.
|
||||||
|
|
||||||
|
### PVC 도 노드에 못박힌다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl get pvc -n keycloak-lab redis-data -o jsonpath='{.spec.storageClassName}'; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
local-path
|
||||||
|
```
|
||||||
|
|
||||||
|
**`local-path` 는 노드의 디렉터리다.** A-4 에서 본 것과 같다 — **노드가 죽으면
|
||||||
|
볼륨도 함께 접근 불가**가 되고, 파드는 다른 노드로 못 옮겨간다.
|
||||||
|
|
||||||
|
**영속화는 재시작을 견디게 하지만 노드 상실을 견디게 하지는 않는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. 복구 ② · 원상복구 확인표
|
||||||
|
|
||||||
|
## 7-1. 실험이 심은 키를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del b5:aof b5:pvc b5:probe
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `b5:` 로 시작하는 키가 없는 것. **`FLUSHALL` 은
|
||||||
|
치지 않는다** — BFF 세션과 oauth2-proxy 세션이 같은 Redis 에 있다.
|
||||||
|
|
||||||
|
## 7-2. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| Redis | `sudo kubectl -n keycloak-lab get deploy redis` | `1/1` |
|
||||||
|
| 볼륨 | `… get pod -l app=redis -o jsonpath='{.items[0].spec.volumes}'` | `persistentVolumeClaim` 이 보인다 |
|
||||||
|
| PVC | `sudo kubectl -n keycloak-lab get pvc` | `redis-data` `Bound` |
|
||||||
|
| 영속화 | `… exec deploy/redis -- redis-cli config get appendonly` | `yes` |
|
||||||
|
| BFF | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1`, `RESTARTS 0` |
|
||||||
|
| 엔드포인트 | `… get endpointslice -l kubernetes.io/service-name=bff` | ready 주소 **둘** |
|
||||||
|
| 실험 키 | `… redis-cli --scan` | `b5:*` 없음 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://app1.hyeonworks.com/` | `200` |
|
||||||
|
|
||||||
|
**로그인 세션은 돌아오지 않는다.** 브라우저에서 다시 로그인하는 것이 복구다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `curl` 이 안 끝나고 터미널이 붙잡힌다 | **그게 이 실험의 결과다.** `000` 이 되는 과정이다 | `--max-time` 을 붙인다 — 1-4 |
|
||||||
|
| `000` 을 서버 오류로 읽는다 | `000` 은 **응답을 못 받았다**는 curl 의 표기다 | `400`/`503` 과 구별한다 — 4-1 |
|
||||||
|
| **AOF 를 켰는데 안 남는다** | **`/data` 가 볼륨이 아니다** | 결론 내리기 전에 1-3 을 먼저 |
|
||||||
|
| 6-3 에서 데이터가 **살아남는다** | 볼륨 제거 패치가 안 먹었다 | `get pod … spec.volumes` 가 **비어야** 한다 — 6-1 |
|
||||||
|
| `config set` 한 값이 재기동 후 사라진다 | **런타임 전용이다.** 매니페스트 `args` 가 이긴다 | 6-3 |
|
||||||
|
| `/actuator/health` 응답 자리에 이상한 문자열 | **측정 실패다.** 값이 아니다 (`HTTP server`) | 그 자리에서 다시 친다 — 4-2 |
|
||||||
|
| 파드가 `NotReady` 가 되기를 기다린다 | **안 된다.** `redis` 지표가 readiness 그룹에 없다 | 4-2 · 4-4 |
|
||||||
|
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33 부터 deprecated | `get endpointslice -l kubernetes.io/service-name=bff` |
|
||||||
|
| 회복 후 로그인이 풀려 있다 | **정상이다.** Redis 가 비었으니 세션이 없다 | `302` 는 실패가 아니다 — 5-2 |
|
||||||
|
| BFF 를 재시작해 버렸다 | 「스스로 회복하는가」를 못 재게 된다 | 다시 2절부터. 손대지 않고 기다린다 |
|
||||||
|
| PVC 가 `Pending` | `local-path` 프로비저너가 없거나 노드가 안 맞는다 | `describe pvc redis-data` 의 Events |
|
||||||
|
| 다른 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 7-1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 실험이 관측에 남긴 숙제
|
||||||
|
|
||||||
|
**Grafana 에 이 실험의 그래프가 없다.** 안 찍은 것이 아니라 **지표가 없다.**
|
||||||
|
|
||||||
|
**실측** — [`04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
|
||||||
|
(후속 조사)
|
||||||
|
```
|
||||||
|
=== B층 구성 요소의 지표가 있는가 ===
|
||||||
|
redis_up 시계열 0개
|
||||||
|
redis_connected_clients 시계열 0개
|
||||||
|
pg_up 시계열 0개
|
||||||
|
pg_stat_database_numbackends 시계열 0개
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus 가 긁는 대상에 **Redis·PostgreSQL·BFF 가 애초에 없다.**
|
||||||
|
A층이 Grafana 증거를 남길 수 있었던 것은 Keycloak 이 `/metrics` 를 내놓고
|
||||||
|
그것을 scrape 대상에 넣어 뒀기 때문이다.
|
||||||
|
|
||||||
|
> **관측은 「나중에 붙이는 것」이 아니라 실험 설계에 포함되어야 한다.**
|
||||||
|
> 이 실험에서 「Redis 가 언제 끊겼고 언제 붙었나」를 초 단위로 보고 싶다면
|
||||||
|
> `redis_exporter` 가 먼저 있어야 하고, 그건 실험이 끝난 뒤에는 못 만든다.
|
||||||
|
|
||||||
|
| 대상 | 방법 |
|
||||||
|
|---|---|
|
||||||
|
| Redis | `redis_exporter` 사이드카 또는 Deployment |
|
||||||
|
| PostgreSQL | `postgres_exporter` |
|
||||||
|
| BFF | 이미 actuator 가 있다 — `/actuator/prometheus` 노출 + scrape 추가 |
|
||||||
|
| 파드 readiness | `kube-state-metrics` (A-2 에서 이미 찾은 항목) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-5 가 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **Redis lock 을 쓴다면 여기서 갱신이 멈춘다.** DB 행 잠금이 유리한 이유가 이 실험으로 보강된다 |
|
||||||
|
| B-6 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
|
||||||
|
| D-1 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
|
||||||
|
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다.** 기본값은 결정이 아니다 |
|
||||||
|
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 `000` 을 `500` 으로 바꾼다 |
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
# B-6 재현 가이드 — 서명 키를 회전하고, 옛 키를 버리는 순간을 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b6-key-rotation/`](../../evidence/b6-key-rotation/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 토큰 헤더에 어느 키로 서명했는지가 적혀 있는 것 | JWT 첫 토막 (`kid`) |
|
||||||
|
| 키를 「추가」했는데 옛 키가 JWKS 에 그대로 남는 것 | `.../openid-connect/certs` |
|
||||||
|
| 옛 토큰과 새 토큰이 **둘 다 200** 인 무중단 구간 | echo `/api/me` |
|
||||||
|
| 옛 키를 지운 **직후** 옛 토큰이 401 이 되는 것 | 같은 엔드포인트 |
|
||||||
|
| 리소스 서버를 재시작해도 여전히 401 인 것 | `rollout restart deploy/echo` |
|
||||||
|
| `kcadm` 의 필터가 오류 없이 빈 결과를 주는 것 | `get components -q type=...` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns` 에
|
||||||
|
클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
|
||||||
|
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 실험의 401/200 은
|
||||||
|
전부 그 앱이 판정한다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||||
|
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||||
|
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). 그래서 JWKS 와
|
||||||
|
토큰은 **`kc-lab-1` 호스트에서** 공개 이름으로 친다. `kcadm.sh` 만 파드 안에서
|
||||||
|
돈다 — 항상 `kubectl exec` 로 감싼다.
|
||||||
|
- 이 실험대에는 **`jq` 가 없다.** JSON 은 `tr` 과 `grep` 으로 자른다.
|
||||||
|
|
||||||
|
## 주의 — 이건 되돌릴 수 없는 실험이다
|
||||||
|
|
||||||
|
**서명 키 공급자를 실제로 지운다. 지운 키는 돌아오지 않는다.**
|
||||||
|
같은 이름으로 공급자를 다시 만들어도 **새 키 쌍이 생기고 `kid` 가 다르다.**
|
||||||
|
그러니 옛 키로 서명된 토큰은 **영구히** 검증되지 않는다.
|
||||||
|
|
||||||
|
**실험대에서만 한다.** 전 구간 약 15분이고, 3절까지는 아무것도 안 깨진다.
|
||||||
|
파괴가 시작되는 지점은 [4. 관찰](#4-관찰--옛-키를-제거한다) 이며,
|
||||||
|
그 앞에 경고를 다시 붙여 두었다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:30–14:32 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||||
|
|
||||||
|
> 해설 문서 머리에 적힌 `15:50–16:00 KST` 는 **문서를 쓴 시각**이고,
|
||||||
|
> 증거 파일의 mtime 은 `14:30–14:32 KST` 다. **실측으로 인용하는 것은 뒤쪽**이다.
|
||||||
|
|
||||||
|
`kid`·컴포넌트 id 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||||
|
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
|
||||||
|
기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
Q3 의 미지수 3 은 이렇게 물었다.
|
||||||
|
|
||||||
|
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
|
||||||
|
> 저장된 값은 어떻게 읽는가."*
|
||||||
|
|
||||||
|
**질문이 두 갈래로 갈린다.**
|
||||||
|
|
||||||
|
| | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| ① 토큰 **저장소**의 암호화 key | **존재하지 않는다.** B-2 에서 `bytea` 안이 JWT 문자열 그대로임을 확인했다 |
|
||||||
|
| ② 토큰 **서명** key (Keycloak realm) | 존재하고 회전 가능하다 — **이 실험이 잰다** |
|
||||||
|
|
||||||
|
①이 없으므로 교체할 것도 없다. 그래서 이 가이드는 ②만 친다. 그리고 ②에서
|
||||||
|
본 모양이 나중에 ①을 설계할 때 그대로 쓰인다.
|
||||||
|
|
||||||
|
**그리고 이 실험은 예측이 틀린 실험이다.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 예측 | 리소스 서버가 JWKS 를 캐시하니, 옛 키를 지워도 **한동안은 통할 것** |
|
||||||
|
| **실측** | **유예가 없다. 제거 직후 바로 401 이다** |
|
||||||
|
|
||||||
|
이유는 뒤에서 본다. **캐시를 유예 기간으로 기대하면 안 된다**는 것이 이 실험이
|
||||||
|
남긴 한 줄이고, 그것을 당신 터미널에서 확인하는 것이 이 가이드의 목적이다.
|
||||||
|
|
||||||
|
핵심은 **두 동작을 분리해서 보는 것**이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
|
||||||
|
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
|
||||||
|
```
|
||||||
|
|
||||||
|
「교체」라는 한 단어가 실제로는 **서로 성질이 정반대인 두 조작**이다.
|
||||||
|
회전이 위험한 게 아니라 **옛 키를 언제 버리느냐**가 위험하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 아무것도 바꾸기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 제거 후에 볼 것을 제거 전에 **똑같은
|
||||||
|
명령으로** 먼저 봐 둔다. 그래야 「원래 그랬던 것」과 「내가 바꾼 것」이 구별된다.
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. kcadm 을 먼저 로그인시킨다
|
||||||
|
|
||||||
|
`kcadm.sh` 는 **파드 안 파일에 세션을 저장한다.** 파드가 재시작되면 사라지고,
|
||||||
|
그 뒤 모든 명령이 `401` 로 떨어진다. **맨 앞에서 한 번 해 둔다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```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
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 1-2. 지금 어떤 키 공급자가 있나
|
||||||
|
|
||||||
|
**확인** — 통째로 받아서 눈으로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get components -r keycloak-patterns --fields id,name,providerId
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** — JSON 배열이 여러 줄로 나온다. `providerId` 가 `rsa-generated` 인
|
||||||
|
항목이 서명 키 공급자이고, `hmac-generated`·`aes-generated` 등이 함께 나온다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `"name" : "rsa-generated"` 인 항목의 `"id"`.
|
||||||
|
**4절에서 지울 대상이 이것이다.** 지금 적어 둔다.
|
||||||
|
|
||||||
|
### ★ 여기서 조용한 실패를 하나 만난다
|
||||||
|
|
||||||
|
「키 공급자만 걸러 보자」는 자연스러운 시도가 **빈 결과**를 준다.
|
||||||
|
|
||||||
|
**미검증** — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
**오류도 종료코드도 없이 비어 있다.** 「키 공급자가 하나도 없구나」로 읽으면
|
||||||
|
이 실험 전체가 무너진다. **`-q` 필터를 믿지 말고 `--fields` 로 전체를 받는다.**
|
||||||
|
|
||||||
|
> A층 내내 반복해 만난 유형이다 — **조용한 실패.** 빈 출력은 「없다」가 아니라
|
||||||
|
> 「이 명령으로는 안 보인다」일 수 있다. 다른 명령으로 한 번 더 확인한다.
|
||||||
|
|
||||||
|
## 1-3. JWKS 원문을 한 번 통째로 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||||
|
```
|
||||||
|
|
||||||
|
**줄바꿈 없이 한 줄로 길게 나온다. 그래도 처음 한 번은 그대로 본다.**
|
||||||
|
어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||||
|
|
||||||
|
**실측** — 첫머리.
|
||||||
|
[`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||||
|
에 남은 조각 그대로다
|
||||||
|
```
|
||||||
|
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||||
|
```
|
||||||
|
|
||||||
|
그 뒤로 `kty`·`alg`·`use`·`n`·`e` 가 이어지고 다음 키가 온다.
|
||||||
|
**`kid` 마다 `alg` 가 따로 붙는다** — 이 사실이 바로 아래에서 쓰인다.
|
||||||
|
|
||||||
|
읽을 만하게 자른다. `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||||
|
| tr ',' '\n' | grep kid
|
||||||
|
```
|
||||||
|
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||||
|
```
|
||||||
|
JWKS kid 목록:
|
||||||
|
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||||
|
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`kid` 는 두 개인데 이 실험이 세는 RS256 키는 하나다.**
|
||||||
|
|
||||||
|
같은 파일의 바로 윗줄이 그렇게 말한다.
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
JWKS 의 RS256 키 수: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**세는 단위가 다르다.** JWKS 에는 서명 키만 실리는 게 아니다. 이 realm 에서는
|
||||||
|
암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고, 그것도 `kid` 를 갖는다.
|
||||||
|
**`grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.**
|
||||||
|
|
||||||
|
알고리즘까지 보고 세려면 키 단위로 잘라야 한다. JWKS 는 키 하나가 `}` 로
|
||||||
|
끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다. **미검증**
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||||
|
| tr '}' '\n' | grep -c RS256
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak 자신에게 묻는 편이 더 확실하다 — **이쪽이 1순위 도구다. 미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get keys -r keycloak-patterns
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — 키마다 붙는 `algorithm` 과 `status`.
|
||||||
|
`RS256` 이면서 `ACTIVE` 인 것이 **지금 서명에 쓰이는 키**다.
|
||||||
|
|
||||||
|
> 이 두 명령은 원래 실행 기록에 출력이 없다. **당신 출력에서 필드 이름을 직접
|
||||||
|
> 확인한다.** 위에 인용한 「RS256 키 수: 1」만이 실측이다.
|
||||||
|
|
||||||
|
## 1-4. 토큰을 하나 받고, 그 토큰의 kid 를 본다
|
||||||
|
|
||||||
|
**하기** — direct grant 로 받는다. 클라이언트 비밀은 Secret 에서 꺼내 쓴다
|
||||||
|
```bash
|
||||||
|
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
|
||||||
|
CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
|
||||||
|
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
|
||||||
|
OLD=$(curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
|
||||||
|
-d username=labuser -d password=labpass -d scope=openid \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "${#OLD}자"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
2043자
|
||||||
|
```
|
||||||
|
`0자` 로 나오면 토큰을 못 받은 것이다. 변수에 담지 말고 응답을 그대로 찍어
|
||||||
|
본문을 읽는다.
|
||||||
|
|
||||||
|
> **이 토큰이 이 실험의 시험체다.** 변수 이름을 `OLD` 로 둔 이유는, 회전이
|
||||||
|
> 끝난 뒤에도 **이것이 「옛 키로 서명된 토큰」으로 남아야** 하기 때문이다.
|
||||||
|
> 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다.
|
||||||
|
|
||||||
|
**확인** — JWT 의 **첫 토막**이 헤더다. 거기 `kid` 가 있다
|
||||||
|
```bash
|
||||||
|
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```json
|
||||||
|
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||||
|
```
|
||||||
|
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||||
|
```
|
||||||
|
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `kid` 가 1-3 의 JWKS 목록에 있는 값과 같은가.
|
||||||
|
**이것이 이 실험의 뼈대다.** 토큰이 자기 서명 키를 스스로 밝히고 있다.
|
||||||
|
|
||||||
|
> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다).
|
||||||
|
> 헤더는 짧아서 대개 온전히 보인다.
|
||||||
|
|
||||||
|
### 개념 — `kid` 가 있어서 여러 키를 동시에 운용할 수 있다
|
||||||
|
|
||||||
|
**무엇인가.** `kid` 는 key ID 다. 서명한 쪽이 **어느 키를 썼는지**를 토큰 헤더에
|
||||||
|
적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고,
|
||||||
|
키가 바뀌는 순간 옛 토큰은 전부 죽는다. **`kid` 가 겹침 구간을 가능하게 한다.**
|
||||||
|
|
||||||
|
**없거나 틀리면.** 겹침이 불가능해진다 — 그 사례를 B-7 에서 본다.
|
||||||
|
oauth2-proxy 의 쿠키에는 `kid` 에 해당하는 표시가 없고, 그래서
|
||||||
|
`--cookie-secret` 도 단수다.
|
||||||
|
|
||||||
|
## 1-5. 그 토큰이 지금 통하는가 — 대조군
|
||||||
|
|
||||||
|
**이 절을 건너뛰면 뒤의 401 은 아무 의미가 없다.** 「원래 안 됐던 것」과
|
||||||
|
「내가 깨뜨린 것」을 구별할 수단이 이것뿐이다.
|
||||||
|
|
||||||
|
먼저 응답을 통째로 한 번 본다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 상태줄과 본문. 200 이면 `subject` 같은 클레임이 돌아온다.
|
||||||
|
401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. **이 헤더를 한 번 봐 두면
|
||||||
|
뒤에서 401 이 났을 때 「왜」를 묻는 자리가 생긴다.**
|
||||||
|
|
||||||
|
이제부터는 여러 번 비교해야 하므로 코드만 뽑는다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||||
|
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||||
|
```
|
||||||
|
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
|
||||||
|
```
|
||||||
|
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
|
||||||
|
/api/me HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 회전 전에는 통한다. **이 200 이 기준선이다.**
|
||||||
|
|
||||||
|
> **원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤다.**
|
||||||
|
> 이 가이드가 공개 이름을 쓰는 것은 **`kc-lab-1` 에서는 클러스터 DNS 가 안
|
||||||
|
> 풀리기 때문**이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo`
|
||||||
|
> 로 보내므로 **도달하는 앱은 같다.** 인용한 `HTTP 200` 은 원래 실행의 값이다.
|
||||||
|
|
||||||
|
> **access token 은 60초짜리다**(이 realm 은 `accessTokenLifespan=60`).
|
||||||
|
> 1분을 넘기면 회전과 무관하게 401 이 난다. **뒤에서 401 을 만나면 먼저
|
||||||
|
> 「만료인가 키 문제인가」를 갈라야 한다** — 3-3 에 그 방법을 적어 두었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 우선순위가 더 높은 키 공급자를 추가한다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기** — 방금 만든 공급자를 지우면 원래대로 돌아간다.
|
||||||
|
**id 는 2-2 가 출력하는 값이고, 그 줄을 그대로 옮겨 친다.** 원래 실행에서는
|
||||||
|
`7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns
|
||||||
|
```
|
||||||
|
2절만 되돌리는 것은 안전하다. **되돌릴 수 없는 것은 4절이다.**
|
||||||
|
|
||||||
|
## 2-1. 무엇을 하는 것인가 — 먼저 읽는다
|
||||||
|
|
||||||
|
**Keycloak 의 키 회전은 「바꾸기」가 아니라 「더 높은 우선순위로 추가하기」다.**
|
||||||
|
|
||||||
|
기존 공급자는 그대로 두고, `priority` 가 더 큰 공급자를 하나 더 만든다.
|
||||||
|
그러면 **발급은 새 키로 가고, 검증은 둘 다 받는다.** 옛 키는 아무 데도 안 갔다.
|
||||||
|
|
||||||
|
```
|
||||||
|
t0 키 A 만 있다. 발급: A, 검증: A
|
||||||
|
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
|
||||||
|
t2 키 A 제거. 발급: B, 검증: B
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 실험이 재는 것은 t1 이 무중단인가(2~3절)와, t2 가 언제 안전한가(4절)다.**
|
||||||
|
|
||||||
|
## 2-2. 추가한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
create components -r keycloak-patterns \
|
||||||
|
-s name=rsa-rotated -s providerId=rsa-generated \
|
||||||
|
-s providerType=org.keycloak.keys.KeyProvider \
|
||||||
|
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
|
||||||
|
date '+%H:%M:%S 추가'
|
||||||
|
```
|
||||||
|
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||||
|
```
|
||||||
|
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
|
||||||
|
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **돌아온 id 를 적어 둔다.** 되돌릴 때 쓴다.
|
||||||
|
그리고 `config.priority` 가 **기존 공급자보다 큰지** — 기본값은 100 이고
|
||||||
|
여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 **발급에 쓰이지 않아**
|
||||||
|
3-2 에서 kid 가 안 바뀐다.
|
||||||
|
|
||||||
|
> `config.*` 값이 **대괄호로 감싼 배열**인 것에 주의한다. `-s config.priority=200`
|
||||||
|
> 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 추가가 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. JWKS 에 두 키가 함께 있는가
|
||||||
|
|
||||||
|
**확인** — 1-3 과 **똑같은 명령**을 다시 친다
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||||
|
| tr ',' '\n' | grep kid
|
||||||
|
```
|
||||||
|
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||||
|
```
|
||||||
|
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
|
||||||
|
RS256 키 수: 2
|
||||||
|
kid 목록:
|
||||||
|
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||||
|
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||||
|
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **옛 `kid`(`OY-caYDN…`)가 목록에 그대로 있다.**
|
||||||
|
새 것이 하나 늘었고, 아무것도 사라지지 않았다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 「회전」이라는 말과 달리 **아무것도 교체되지 않았다.**
|
||||||
|
JWKS 는 「지금 검증에 쓸 수 있는 키 전부」를 싣는 목록이고, 추가는 그 목록을
|
||||||
|
늘릴 뿐이다.
|
||||||
|
|
||||||
|
## 3-2. 새 토큰은 어느 키로 서명되는가
|
||||||
|
|
||||||
|
**하기** — 지금 새로 하나 받는다. **`OLD` 은 건드리지 않는다**
|
||||||
|
```bash
|
||||||
|
NEW=$(curl -s -X POST "$KC" \
|
||||||
|
-d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
|
||||||
|
-d username=labuser -d password=labpass -d scope=openid \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||||
|
```
|
||||||
|
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||||
|
```
|
||||||
|
=== [5] 새 토큰은 어느 키로 서명되는가 ===
|
||||||
|
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `kid` 가 **우선순위 200 짜리 새 키**로 바뀌었다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 발급은 우선순위가 가장 높은 키로 간다.
|
||||||
|
**여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.** 2-2 로 돌아간다.
|
||||||
|
|
||||||
|
## 3-3. ★ 둘 다 통하는가 — 무중단 구간의 실측
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||||
|
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||||
|
curl -s -o /dev/null -w 'new %{http_code}\n' \
|
||||||
|
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
|
||||||
|
```
|
||||||
|
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/02-rotation.txt)
|
||||||
|
```
|
||||||
|
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
|
||||||
|
옛 토큰 /api/me HTTP 200
|
||||||
|
새 토큰 /api/me HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **둘 다 200.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **키 추가는 무중단이다.** 새 토큰은 새 키로 서명되고,
|
||||||
|
옛 토큰은 **JWKS 에 아직 있는 옛 키로 검증된다.** 사용자는 아무것도 못 느낀다.
|
||||||
|
|
||||||
|
> **여기서 `old` 가 401 이면 두 가지 중 하나다.**
|
||||||
|
> ① 토큰이 만료됐다(60초). ② 뭔가 다른 것을 건드렸다.
|
||||||
|
> **가르는 법** — 옛 토큰의 `exp` 를 본다. JWT 의 **가운데 토막**이 클레임이다.
|
||||||
|
> ```bash
|
||||||
|
> echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||||
|
> date +%s
|
||||||
|
> ```
|
||||||
|
> `exp` 가 지금보다 작으면 만료다. 1-4 로 돌아가 다시 받되, **이번에는 추가
|
||||||
|
> 전에** 받아야 「옛 키로 서명된 토큰」이 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰 — 옛 키를 제거한다
|
||||||
|
|
||||||
|
## ★ 여기서부터 되돌릴 수 없다
|
||||||
|
|
||||||
|
**이 절이 이 실험의 본 시험이다. 그리고 되돌릴 수 없다.**
|
||||||
|
지우는 것은 키 공급자이고, 그 안의 **개인키가 함께 사라진다.**
|
||||||
|
같은 이름으로 다시 만들어도 **다른 키 쌍**이 생긴다.
|
||||||
|
|
||||||
|
계속하기 전에 확인한다.
|
||||||
|
|
||||||
|
- 이 realm 이 **실험대 전용**인가
|
||||||
|
- 지금 살아 있는 세션 중에 **잃으면 곤란한 것**이 있는가
|
||||||
|
- 3-3 의 `old 200` 을 **실제로 봤는가** (안 봤다면 401 이 나와도 원인을 못 가른다)
|
||||||
|
|
||||||
|
## 4-1. 지울 대상을 정확히 고른다
|
||||||
|
|
||||||
|
**확인** — 1-2 와 같은 명령. `-q` 는 여전히 안 먹는다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get components -r keycloak-patterns --fields id,name,providerId
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `"name" : "rsa-generated"` 인 항목의 id.
|
||||||
|
방금 만든 것은 `"name" : "rsa-rotated"` 다. **둘을 바꿔 지우면 실험이 뒤집힌다.**
|
||||||
|
|
||||||
|
목록이 길면 그 항목 주변만 잘라 본다. `"id"` 는 `"name"` 보다 **위**에 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get components -r keycloak-patterns --fields id,name,providerId \
|
||||||
|
| grep -B2 '"name" : "rsa-generated"'
|
||||||
|
```
|
||||||
|
|
||||||
|
원래 실행에서 지운 것은 이것이다.
|
||||||
|
|
||||||
|
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||||
|
```
|
||||||
|
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
|
||||||
|
제거 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
`980ee9b7…` 로 시작하는 것이 옛 공급자의 id 이고, 그것이 `OY-caYDN…` 키를
|
||||||
|
갖고 있었다. **당신 환경의 id 는 다르다.** 증거에 남은 것도 앞 8자뿐이니
|
||||||
|
**전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.**
|
||||||
|
|
||||||
|
## 4-2. 지운다
|
||||||
|
|
||||||
|
**하기** — 위 출력에서 고른 id 를 변수에 넣고 지운다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLDID=980ee9b7-... # ← 4-1 의 출력에서 그대로 옮긴다. 환경마다 다르다
|
||||||
|
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
delete components/"$OLDID" -r keycloak-patterns
|
||||||
|
date '+%H:%M:%S 제거'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 조용히 끝나면 성공이다. **시각을 적어 둔다.**
|
||||||
|
|
||||||
|
## 4-3. JWKS 에서 사라졌는가
|
||||||
|
|
||||||
|
**확인** — 또 같은 명령이다
|
||||||
|
```bash
|
||||||
|
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
|
||||||
|
| tr ',' '\n' | grep kid
|
||||||
|
```
|
||||||
|
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||||
|
```
|
||||||
|
=== [8] JWKS 에서 사라졌는가 ===
|
||||||
|
RS256 키 수: 1
|
||||||
|
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||||
|
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`OY-caYDN…` 이 없다.** RS256 은 다시 1개다.
|
||||||
|
`gokjn0…` 은 처음부터 끝까지 그대로 있다 — 서명 키가 아니기 때문이다(1-3).
|
||||||
|
|
||||||
|
## 4-4. ★ 옛 토큰은 이제 어떻게 되는가
|
||||||
|
|
||||||
|
**확인** — 3-3 과 **똑같은 두 줄**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'old %{http_code}\n' \
|
||||||
|
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
|
||||||
|
curl -s -o /dev/null -w 'new %{http_code}\n' \
|
||||||
|
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
|
||||||
|
```
|
||||||
|
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||||
|
```
|
||||||
|
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
|
||||||
|
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
|
||||||
|
새 토큰 /api/me HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **옛 토큰 401, 새 토큰 200.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 제거는 **즉시** 반영된다.
|
||||||
|
괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 **측정하기 전에 적어 둔
|
||||||
|
예상**이고, **옆의 401 이 그 예상을 부정한 값이다.** 증거 파일에 예상과 결과가
|
||||||
|
나란히 남아 있는 셈이다.
|
||||||
|
|
||||||
|
> **새 토큰도 401 이면** 제거를 잘못했다 — 새 공급자를 지운 것이다.
|
||||||
|
> `kid` 를 다시 확인한다(3-2). 아니면 그냥 만료다(3-3 의 박스).
|
||||||
|
|
||||||
|
## 4-5. 캐시가 구해주지 않는다 — 재시작으로 확인한다
|
||||||
|
|
||||||
|
여기까지 보면 「리소스 서버가 아직 JWKS 를 캐시하고 있어서 우연히 401 인가?」
|
||||||
|
라는 의심이 남는다. **캐시를 비워 보면 갈린다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n header-lab rollout restart deploy/echo
|
||||||
|
sudo kubectl -n header-lab rollout status deploy/echo --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
|
||||||
|
```
|
||||||
|
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
|
||||||
|
deployment "echo" successfully rolled out
|
||||||
|
옛 토큰 /api/me HTTP 401
|
||||||
|
새 토큰 /api/me HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **재시작 전과 후가 같다.** 401 / 200.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 401 은 캐시 상태와 무관하다.
|
||||||
|
**캐시는 유예를 주지 않았다.**
|
||||||
|
|
||||||
|
### 왜 그런가
|
||||||
|
|
||||||
|
Spring 의 `NimbusJwtDecoder` 는 **모르는 `kid` 를 만나면 JWKS 를 다시
|
||||||
|
가져온다.** 캐시는 「이미 아는 키를 다시 안 받으려는」 장치이지 「옛 키를
|
||||||
|
붙잡아 두는」 장치가 아니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
옛 토큰 도착
|
||||||
|
│
|
||||||
|
├─▶ kid = OY-caYDN… → 캐시에 없다
|
||||||
|
│ │
|
||||||
|
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
|
||||||
|
│
|
||||||
|
└─▶ 새로 받은 JWKS 에도 없다 → 401
|
||||||
|
```
|
||||||
|
|
||||||
|
**캐시가 오히려 제거를 빨리 반영시킨다.** 예측이 정확히 반대였던 이유다.
|
||||||
|
|
||||||
|
> **유예는 캐시로 만드는 것이 아니라, 옛 키를 JWKS 에 남겨 두는 기간으로
|
||||||
|
> 만들어야 한다.** 이것이 이 실험의 한 줄이다.
|
||||||
|
|
||||||
|
## 4-6. 그래서 겹침 구간은 얼마나 길어야 하는가
|
||||||
|
|
||||||
|
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
|
||||||
|
|
||||||
|
| 이 실험대에서 | |
|
||||||
|
|---|---|
|
||||||
|
| access token | 60초 |
|
||||||
|
| refresh token | 1800초 (30분) |
|
||||||
|
| **필요한 겹침** | **최소 30분** |
|
||||||
|
|
||||||
|
**확인** — 이 값들은 realm 설정이다. 직접 본다. **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 「교체하는 동안」의 길이를 정하는 것은 **key 가
|
||||||
|
아니라 그 key 로 만든 것의 수명**이다. 30분짜리 refresh token 을 발급하면서
|
||||||
|
겹침을 5분만 두면 **25분어치의 토큰을 죽이는 것**이다.
|
||||||
|
|
||||||
|
### ①에 적용하면 — 저장소를 암호화한다면
|
||||||
|
|
||||||
|
```
|
||||||
|
쓰기: 새 key 하나로만
|
||||||
|
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
|
||||||
|
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
|
||||||
|
```
|
||||||
|
|
||||||
|
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
|
||||||
|
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유이고,
|
||||||
|
그것이 없을 때 어떻게 되는지가 다음 실험(B-7)이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. ★ 옛 키는 돌아오지 않는다
|
||||||
|
|
||||||
|
**이 실험에는 「원상복구」가 없다.** 지운 키 공급자는 개인키와 함께 사라졌다.
|
||||||
|
같은 이름으로 다시 만들면 **새 키 쌍**이 생기고 `kid` 가 다르므로, 옛 토큰은
|
||||||
|
그래도 401 이다.
|
||||||
|
|
||||||
|
**정상 상태는 「새 키 하나만 남은 상태」다.** 4-3 의 출력이 그 상태이고,
|
||||||
|
실험 전과 다르지만 **깨진 상태가 아니다.**
|
||||||
|
|
||||||
|
## 5-2. 실험이 남긴 것을 정리한다
|
||||||
|
|
||||||
|
| 남은 것 | 어떻게 | |
|
||||||
|
|---|---|---|
|
||||||
|
| `rsa-rotated` 공급자 | **그냥 둔다.** 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
|
||||||
|
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
|
||||||
|
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
|
||||||
|
|
||||||
|
**이름이 거슬리면** 새 공급자를 하나 더 만들고(2-2) `rsa-rotated` 를 지우면
|
||||||
|
된다. **다만 그것 역시 또 한 번의 회전이고, 또 하나의 새 키다.**
|
||||||
|
|
||||||
|
## 5-3. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 서명 키 | `curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 **하나** |
|
||||||
|
| 새 토큰 | 1-4 의 발급 + 1-5 의 `/api/me` | `200` |
|
||||||
|
| 리소스 서버 | `sudo kubectl -n header-lab get pods` | `echo` 가 `1/1 Running` |
|
||||||
|
| Keycloak | `sudo kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
|
||||||
|
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것** — 겹침 구간을 실제로 30분 유지하며 그 사이에
|
||||||
|
> 발급된 refresh token 이 t2 이후 어떻게 되는지는 측정하지 않았다.
|
||||||
|
> 재려면 2절과 4절 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로
|
||||||
|
> 4절 뒤에 갱신을 시도한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `kcadm get components -q type=...` 가 빈 결과 | **`-q` 필터가 안 먹는다. 오류도 없다** | `--fields id,name,providerId` 로 전체를 받는다 — 1-2 |
|
||||||
|
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 — 1-1 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | JWKS·토큰은 `kc-lab-1` 호스트에서 친다 |
|
||||||
|
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 — 1-3 |
|
||||||
|
| kid 를 세니 2개인데 문서는 1개라고 한다 | **RS256 이 아닌 암호화 키가 섞여 있다** | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` — 1-3 |
|
||||||
|
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 **배열**인지 — 2-2 |
|
||||||
|
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 **토큰이 만료**됐다(60초) | 클레임의 `exp` 와 `date +%s` 비교 — 3-3 |
|
||||||
|
| 제거했는데 **새** 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 — 4-1 |
|
||||||
|
| 제거했는데 옛 토큰이 **200** | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 — 4-1 |
|
||||||
|
| 「캐시 때문일 것」이라 재시작을 기다린다 | **캐시는 유예를 주지 않는다** | 재시작 전후가 같다 — 4-5 |
|
||||||
|
| 지운 키를 되살리려 한다 | **되살릴 수 없다.** 같은 이름 ≠ 같은 키 | 5-1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-6 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-7](../../experiment-b7-cookie-secret-rotation.md) cookie secret | **같은 모양의 문제인데 `kid` 가 없다.** 겹침 구간을 만들 수 있는가 — 답은 「없다」 |
|
||||||
|
| [D-2](../../experiment-d2-version-upgrade.md) 버전 업그레이드 | Redis 의 Java 직렬화 세션도 같은 **「옛 형식을 읽을 수 있는가」** 문제다 |
|
||||||
|
| 설계 | 저장소를 암호화한다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
|
||||||
|
| 전부 | **빈 출력은 「없다」가 아니다.** `-q` 필터 하나가 조용히 실패했다 |
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
# B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b7-cookie-secret/`](../../evidence/b7-cookie-secret/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| replica 두 개가 상태를 안 나누고도 로그인이 되는 것 | oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica) |
|
||||||
|
| 큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 | Traefik 직접 vs nginx |
|
||||||
|
| `--cookie-secret` 이 **단수**라는 것 | `oauth2-proxy --help` |
|
||||||
|
| 옛 쿠키가 `session ticket cookie failed validation` 로 죽는 것 | 프록시 로그 |
|
||||||
|
| **로그인 화면 없이 조용히 재로그인**되는 것 | 브라우저 |
|
||||||
|
| ★ 프록시가 **지우지 못한** 서버 세션이 Redis 에 남는 것 | `redis-cli --scan` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns` 에
|
||||||
|
클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
|
||||||
|
- Redis 가 `redis.keycloak-lab.svc:6379` 로 떠 있다.
|
||||||
|
- **브라우저가 필요하다.** 쿠키가 `HttpOnly` 이고 OIDC 흐름을 끝까지 걸어야
|
||||||
|
세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 이 실험대에는 **`jq` 도 `yamllint` 도 없다.**
|
||||||
|
|
||||||
|
## 주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다
|
||||||
|
|
||||||
|
**두 가지를 건드린다.**
|
||||||
|
|
||||||
|
1. **`app2.hyeonworks.com` 은 평소 `observability` 네임스페이스의 Grafana 로
|
||||||
|
간다.** 인증서가 `auth`·`app1`·`app2` 세 이름만 덮고 있어서 네 번째 이름을
|
||||||
|
못 만든다. 그래서 **Grafana 의 Ingress 를 잠시 내리고 빌린다.**
|
||||||
|
**반드시 되돌린다** — [5-3](#5-3--grafana-ingress-를-되돌린다) 이 그 절차다.
|
||||||
|
백업을 뜨는 것이 [1-1](#1-1-먼저-grafana-ingress-를-백업한다) 의 첫 명령인 이유다.
|
||||||
|
2. **secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.**
|
||||||
|
실험대에서만 한다.
|
||||||
|
|
||||||
|
전 구간 약 20분이다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 친다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:35–14:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||||
|
|
||||||
|
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
|
||||||
|
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
|
||||||
|
> 수집 시각이 `14:35–14:42 KST` 인 것은 **같은 시간대의 같은 순간**이다(KST = UTC+9).
|
||||||
|
> 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
|
||||||
|
|
||||||
|
파드 이름·Redis 키·쿠키 값은 **당신 환경에서 다르다.** 이 문서는
|
||||||
|
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
Q1 의 미지수 7 은 이렇게 물었다.
|
||||||
|
|
||||||
|
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
|
||||||
|
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
|
||||||
|
|
||||||
|
B-6 에서 Keycloak 은 **두 키를 동시에 들고** 무중단으로 회전했다. `kid` 가
|
||||||
|
있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| B-6 의 모양대로라면 | oauth2-proxy 도 **겹침 구간을 만들 수 있을 것** |
|
||||||
|
| **실측** | **★ 없다.** `--cookie-secret` 은 단수이고 쿠키에 key 식별자가 없다 |
|
||||||
|
|
||||||
|
**그리고 예측하지 않았던 것이 하나 더 나온다** — 사용자는 아무것도 못 느끼는데
|
||||||
|
**서버 쪽에 지워지지 않는 세션이 남는다.** 그 「지우지 못한다」를 이어서 재는
|
||||||
|
것이 [B-7a](b7a-orphan-session.md) 다.
|
||||||
|
|
||||||
|
핵심은 **상태를 어디에 두었는가**다.
|
||||||
|
|
||||||
|
```
|
||||||
|
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
|
||||||
|
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
|
||||||
|
대신 secret 이 단일 지점
|
||||||
|
```
|
||||||
|
|
||||||
|
**공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 아무것도 바꾸기 전에
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 먼저 Grafana Ingress 를 백업한다
|
||||||
|
|
||||||
|
**이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.**
|
||||||
|
|
||||||
|
**확인** — 지금 app2 가 무엇인지 먼저 본다
|
||||||
|
```bash
|
||||||
|
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||||
|
```
|
||||||
|
**형태** — Grafana 로 가고 있으면 `302` 로 `/login` 을 가리킨다.
|
||||||
|
|
||||||
|
**하기** — 백업을 뜨고, 파일이 비지 않았는지 확인한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
|
||||||
|
wc -l ~/grafana-ingress-backup.yaml
|
||||||
|
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 줄 수가 **0 이 아니고**, `app2.hyeonworks.com` 이
|
||||||
|
**1회 이상** 잡혀야 한다. `0` 이면 백업이 빈 파일이고, 그 상태로 진행하면
|
||||||
|
복구할 것이 없다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability delete ingress grafana
|
||||||
|
```
|
||||||
|
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||||
|
```
|
||||||
|
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
|
||||||
|
grafana ingress 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — `sudo kubectl apply -f ~/grafana-ingress-backup.yaml`
|
||||||
|
|
||||||
|
## 1-2. oauth2-proxy 를 배포한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||||
|
```
|
||||||
|
secret/oauth2-proxy-secrets created
|
||||||
|
deployment.apps/oauth2-proxy created
|
||||||
|
service/oauth2-proxy created
|
||||||
|
ingress.networking.k8s.io/oauth2-proxy created
|
||||||
|
deployment "oauth2-proxy" successfully rolled out
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — `sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml`
|
||||||
|
|
||||||
|
## 1-3. replica 두 개가 서로 다른 노드에 있는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||||
|
```
|
||||||
|
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
|
||||||
|
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **파드 두 개, 서로 다른 노드.** 그리고 **파드 이름의
|
||||||
|
끝 다섯 글자**를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을
|
||||||
|
했는지」를 이 글자로 가른다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — replica 가 둘이라는 것이 Q1 의 질문 자체다.
|
||||||
|
하나면 「공유」라는 말이 성립하지 않는다.
|
||||||
|
|
||||||
|
## 1-4. 진입점이 살아 있는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/
|
||||||
|
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping
|
||||||
|
```
|
||||||
|
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
|
||||||
|
```
|
||||||
|
=== 진입점 확인 ===
|
||||||
|
https://app2.hyeonworks.com/ HTTP 302
|
||||||
|
/ping HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
| 경로 | 정상 | 뜻 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — **프록시가 일하고 있다** |
|
||||||
|
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — **프록시 자체는 살아 있다** |
|
||||||
|
|
||||||
|
**두 값이 갈라지는 것이 중요하다.** `/ping` 도 안 되면 프록시가 안 뜬 것이고,
|
||||||
|
`/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.
|
||||||
|
|
||||||
|
## 1-5. ★ 502 가 나면 — 계층을 가른다
|
||||||
|
|
||||||
|
원래 구성에서 **콜백이 계속 502** 였다. 이 절은 그때 무엇을 쳤는지다.
|
||||||
|
**502 를 안 만났으면 읽고 넘어간다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
**502 는 「누가 냈는지」를 안 알려 준다.** 앞단 nginx 인지, 그 뒤 Traefik 인지,
|
||||||
|
파드인지. **한 겹씩 벗겨서 좁힌다.**
|
||||||
|
|
||||||
|
**확인** — nginx 를 건너뛰고 Traefik 에 직접 묻는다
|
||||||
|
```bash
|
||||||
|
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
|
||||||
|
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
|
||||||
|
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **Traefik 직접은 정상이다.** 그러면 502 를 내는 것은
|
||||||
|
그 앞의 nginx 다. 그리고 502 는 **쿠키를 설정하는 응답에서만** 났다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에
|
||||||
|
담는다.** 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
|
||||||
|
|
||||||
|
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
|
||||||
|
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시
|
||||||
|
> 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
|
||||||
|
|
||||||
|
**해결** — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
"--session-store-type=redis"
|
||||||
|
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ 그리고 여기서 조용한 실패를 하나 만난다
|
||||||
|
|
||||||
|
nginx 설정을 보려던 시도가 계속 **빈 결과**였다.
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
$ sudo -n true
|
||||||
|
sudo: a password is required
|
||||||
|
```
|
||||||
|
|
||||||
|
**`test-server`(호스트)의 sudo 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는
|
||||||
|
무암호라 A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
|
||||||
|
|
||||||
|
**앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가
|
||||||
|
조용히 실패한 것이었다.** 호스트에서 무언가가 빈 결과를 주면 **먼저
|
||||||
|
`sudo -n true` 를 쳐 본다.**
|
||||||
|
|
||||||
|
## 1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다
|
||||||
|
|
||||||
|
**확인** — 키 이름만 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||||
|
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
"CLIENT_SECRET"
|
||||||
|
"COOKIE_SECRET_A"
|
||||||
|
"COOKIE_SECRET_B"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 길이만 본다. **값은 절대 찍지 않는다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||||
|
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
|
||||||
|
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
|
||||||
|
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **oauth2-proxy 는 정확히 16·24·32 바이트만 받는다.**
|
||||||
|
매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다.
|
||||||
|
**미검증** — 원래 실행 기록에 이 명령의 출력은 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **회전 대상이 미리 두 개 준비되어 있다.**
|
||||||
|
이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.
|
||||||
|
|
||||||
|
## 1-7. 로그인해서 세션을 하나 만든다
|
||||||
|
|
||||||
|
**하기** — 브라우저에서
|
||||||
|
```
|
||||||
|
https://app2.hyeonworks.com/api/echo → labuser / labpass
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의
|
||||||
|
JSON 이 보인다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — upstream 이 받은 헤더가 그대로 찍힌다.
|
||||||
|
|
||||||
|
**실측** — [`b7-oauth2proxy-login-success.png`](../../evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
|
||||||
|
```json
|
||||||
|
"x-forwarded-email" : [ "labuser@example.com" ],
|
||||||
|
"x-forwarded-preferred-username" : [ "labuser" ],
|
||||||
|
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
|
||||||
|
"x-forwarded-proto" : [ "https" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
|
||||||
|
> Forward-Auth 구조의 신원 전달 방식이고, **B-4 의 결론이 그대로 적용된다** —
|
||||||
|
> edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
|
||||||
|
|
||||||
|
## 1-8. 세션이 Redis 에 들어갔는가
|
||||||
|
|
||||||
|
**확인** — 먼저 통째로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
=== 세션이 Redis 에 들어갔는가 ===
|
||||||
|
b5:pvc
|
||||||
|
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
|
||||||
|
dbsize: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`dbsize` 는 2 인데 세션은 하나다.**
|
||||||
|
`b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`dbsize` 로 세션을 세면 틀린다.**
|
||||||
|
이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
> `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹한다.
|
||||||
|
> 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
|
||||||
|
|
||||||
|
## 1-9. 쿠키가 「티켓」인지 확인한다
|
||||||
|
|
||||||
|
세션 저장소를 Redis 로 옮기면 쿠키에는 **세션 전체가 아니라 티켓**만 담긴다.
|
||||||
|
|
||||||
|
**하기** — 브라우저 개발자 도구 → Application/저장소 → Cookies → `_oauth2_proxy`
|
||||||
|
|
||||||
|
**실측** — 해설 문서에 남은 값
|
||||||
|
```
|
||||||
|
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
|
||||||
|
└─ 세션 전체가 아니라 티켓이다 (약 180자)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `|` 로 나뉜 **세 토막**과 전체 길이.
|
||||||
|
|
||||||
|
```
|
||||||
|
_oauth2_proxy=<ticket>|<timestamp>|<mac>
|
||||||
|
└─ Redis 키를 여기서 계산한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 쿠키가 짧아졌고(그래서 502 가 사라졌고),
|
||||||
|
**Redis 키 이름은 이 티켓에서 계산된다.** 4절의 「지우지 못한다」가 여기서
|
||||||
|
결정된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — secret 을 A 에서 B 로 바꾼다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_A"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 먼저 「겹칠 수 있는가」를 묻는다
|
||||||
|
|
||||||
|
**바꾸기 전에 이것부터 확인한다.** B-6 의 무중단 회전이 여기서도 되는지가
|
||||||
|
Q1 의 질문 자체이기 때문이다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
|
||||||
|
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`string`. 복수형이 아니다.**
|
||||||
|
`--cookie-secrets` 도 `--old-cookie-secret` 도 목록에 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **겹침 구간을 만들 수단이 아예 없다.**
|
||||||
|
B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid` 가
|
||||||
|
있어서였다. **oauth2-proxy 의 쿠키에는 그런 식별자가 없다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
|
||||||
|
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 한 줄이 이 실험의 답이다.** 나머지는 「그래서 실제로 무슨 일이 나는가」다.
|
||||||
|
|
||||||
|
## 2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다
|
||||||
|
|
||||||
|
아래 patch 는 `env/1` 을 지목한다. **매니페스트의 순서에 달린 값이다.**
|
||||||
|
그대로 믿지 말고 확인한다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `OAUTH2_PROXY_COOKIE_SECRET` 이 **몇 번째인가**
|
||||||
|
(0부터 센다). 위 형태에서는 두 번째이므로 `env/1` 이다. 순서가 다르면
|
||||||
|
patch 의 숫자를 고친다. **틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.**
|
||||||
|
|
||||||
|
## 2-3. 바꾼다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date -u '+%H:%M:%S UTC 회전'
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_B"}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
=== ★ secret 을 A → B 로 교체한다 ===
|
||||||
|
deployment.apps/oauth2-proxy patched
|
||||||
|
deployment "oauth2-proxy" successfully rolled out
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 UTC 로 적어 둔다.** 프록시 로그가 UTC 이고, [B-7a](b7a-orphan-session.md)
|
||||||
|
의 정리 규칙이 **이 시각을 기준으로** 고아를 고른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 교체가 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. 지금 어느 키를 참조하는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
현재 secret 키: COOKIE_SECRET_B
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `COOKIE_SECRET_B`. **Deployment 의 참조가 바뀐 것이지
|
||||||
|
Secret 의 내용이 바뀐 것이 아니다.** 두 값 다 그대로 있고 어느 쪽을 읽을지만
|
||||||
|
바뀌었다 — 그래서 되돌리기가 한 줄이다.
|
||||||
|
|
||||||
|
## 3-2. ★ 그런데 Redis 는 그대로다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
Redis 세션은 그대로인가: 2 키
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세션 수가 **회전 전과 같다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **회전 자체는 아무 일도 일으키지 않는다.**
|
||||||
|
여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면
|
||||||
|
**누군가 옛 쿠키를 들고 와야** 한다. 그게 4절이다.
|
||||||
|
|
||||||
|
> 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이
|
||||||
|
> [B-7a](b7a-orphan-session.md) 의 기준선이다.
|
||||||
|
|
||||||
|
## 3-3. 파드가 실제로 새로 떴는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **파드 이름이 1-3 과 다르다.** 같으면 patch 가 아무
|
||||||
|
필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰 — 옛 쿠키를 들고 가 본다
|
||||||
|
|
||||||
|
## 4-1. 브라우저로 다시 연다
|
||||||
|
|
||||||
|
**하기** — 1-7 에서 로그인한 **그 브라우저 그대로**
|
||||||
|
```
|
||||||
|
https://app2.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||||
|
|
||||||
|
**실측** — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **Keycloak SSO 세션이 살아 있어서 조용히 재인증이
|
||||||
|
일어났다.** 쿠키는 분명히 무효가 됐는데 **사용자 눈에는 아무 일도 없었다.**
|
||||||
|
|
||||||
|
> **여기가 이 실험에서 가장 오해하기 쉬운 자리다.** 「로그인 화면이 안 떴으니
|
||||||
|
> 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. **쿠키는 죽었고,
|
||||||
|
> 사용자는 실제로 재인증을 거쳤다.** SSO 가 그 사실을 가려 준 것뿐이다.
|
||||||
|
> **IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.**
|
||||||
|
|
||||||
|
## 4-2. 로그가 무슨 일이 났는지 말한다
|
||||||
|
|
||||||
|
**확인** — 먼저 최근 로그를 그대로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
**한 번은 통째로 본다.** 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
|
||||||
|
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다 — **replica 가 둘이므로 이게 없으면
|
||||||
|
누가 무엇을 했는지 못 가린다.**
|
||||||
|
|
||||||
|
이제 좁힌다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
|
||||||
|
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 줄이 다른 말을 하고 있다.**
|
||||||
|
|
||||||
|
| 줄 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `stored_session.go:94` | 쿠키를 열 수 없다 → **세션을 지우겠다** |
|
||||||
|
| `stored_session.go:97` | **그 지우기가 실패했다** → `error decoding ticket to clear session` |
|
||||||
|
|
||||||
|
**94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.**
|
||||||
|
|
||||||
|
이어지는 줄이 사용자 쪽 이야기다.
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||||
|
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Initiating login` 과 `AuthSuccess` 가 **같은 초**에 있다.
|
||||||
|
**로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다.** 4-1 에서 화면이
|
||||||
|
깜빡였던 것이 이것이다.
|
||||||
|
|
||||||
|
## 4-3. ★ Redis 에 고아가 남는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
|
||||||
|
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
|
||||||
|
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
|
||||||
|
총: 2 개
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키가 둘이다.** 뒤엣것(`b26111f…`)은 1-8 에서 본
|
||||||
|
회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **사용자는 하나인데 서버 세션이 둘이다.**
|
||||||
|
옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. **고아다.**
|
||||||
|
|
||||||
|
## 4-4. 왜 못 지우는가 — 티켓과 키의 관계
|
||||||
|
|
||||||
|
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 **티켓**만 담긴다(1-9).
|
||||||
|
티켓은 두 부분이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
티켓 = <세션 ID>.<암호화 키>
|
||||||
|
│ └─ 값을 복호화할 키
|
||||||
|
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 봉인되어 있다.
|
||||||
|
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
|
||||||
|
|
||||||
|
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는
|
||||||
|
알지만 **「그 세션이 Redis 어디에 있다」를 모른다.** 그래서 `removing session`
|
||||||
|
을 시도하고 실패한다(4-2 의 97 번 줄).
|
||||||
|
|
||||||
|
```
|
||||||
|
secret 교체
|
||||||
|
└─ 옛 티켓을 못 푼다
|
||||||
|
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
|
||||||
|
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
|
||||||
|
```
|
||||||
|
|
||||||
|
**로그인한 사용자 수만큼 고아가 생긴다.** 여기서 이 실험은 멈췄다.
|
||||||
|
「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를
|
||||||
|
[B-7a](b7a-orphan-session.md) 가 이어서 잰다. **답은 「지울 수 있다」이고,
|
||||||
|
「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.**
|
||||||
|
|
||||||
|
## 4-5. 덤 — replica 를 넘어도 되는 이유
|
||||||
|
|
||||||
|
로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
|
||||||
|
| grep -E 'Initiating login|AuthSuccess'
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서에 남은 형태
|
||||||
|
```
|
||||||
|
--- replica 8p5hl ---
|
||||||
|
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||||
|
GET "/api/echo" ← 흐름을 시작한 replica
|
||||||
|
|
||||||
|
--- replica b9928 ---
|
||||||
|
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||||
|
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
| | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|
||||||
|
|---|---|---|
|
||||||
|
| BFF | **서버 메모리(HttpSession)** | 콜백이 다른 인스턴스로 가면 **실패** (B-0) |
|
||||||
|
| oauth2-proxy | **쿠키 (secret 으로 봉인)** | **secret 만 같으면 성공** |
|
||||||
|
|
||||||
|
**Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다** — 공유할 상태가 없고,
|
||||||
|
공유할 것은 **k8s Secret 하나뿐**이다. 대신 그 하나가 단일 지점이 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. secret 을 A 로 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date -u '+%H:%M:%S UTC 되돌림'
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_A"}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **이것도 회전이다.** B 로 만든 세션이 이번에는 고아가
|
||||||
|
된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.
|
||||||
|
|
||||||
|
## 5-2. 고아를 정리한다
|
||||||
|
|
||||||
|
**확인** — 지금 몇 개 남았는지 센다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
**세 가지 선택지가 있다.**
|
||||||
|
|
||||||
|
| | 언제 | |
|
||||||
|
|---|---|---|
|
||||||
|
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
|
||||||
|
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | [B-7a](b7a-orphan-session.md) 의 규칙 |
|
||||||
|
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
|
||||||
|
|
||||||
|
**하기** — 전부 지울 때. **`b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).
|
||||||
|
> 1-8 에서 `dbsize` 가 2 였던 이유를 여기서 다시 쓴다.
|
||||||
|
|
||||||
|
## 5-3. ★ Grafana Ingress 를 되돌린다
|
||||||
|
|
||||||
|
**이것을 빠뜨리면 Grafana 가 안 열린다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 실제로 Grafana 로 돌아갔는지 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n observability get ingress grafana
|
||||||
|
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — Ingress 가 `observability` 에 다시 있고, `app2` 응답이
|
||||||
|
1-1 에서 본 모양으로 돌아왔는가.
|
||||||
|
|
||||||
|
> **두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다.** oauth2-proxy 것을
|
||||||
|
> **먼저 지우고** Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가
|
||||||
|
> 컨트롤러 판단에 맡겨진다.
|
||||||
|
|
||||||
|
**oauth2-proxy 전체를 걷어내려면**
|
||||||
|
```bash
|
||||||
|
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||||
|
```
|
||||||
|
**다만 [B-7a](b7a-orphan-session.md) 와 [C-1](c1-multi-app-sso.md) 이 이 배포를
|
||||||
|
그대로 쓴다.** 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도
|
||||||
|
그 실험이 끝난 뒤로 미룬다.
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
|
||||||
|
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
|
||||||
|
| Ingress (빌린 것) | `sudo kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 **없다** (걷어냈다면) |
|
||||||
|
| Ingress (Grafana) | `sudo kubectl -n observability get ingress grafana` | **있다** |
|
||||||
|
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 콜백이 `502 Bad Gateway` | **쿠키가 크다.** `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다 |
|
||||||
|
| 호스트에서 nginx 설정·로그가 **빈 결과** | **`sudo` 가 조용히 실패했다** | `sudo -n true` → `sudo: a password is required` — 1-5 |
|
||||||
|
| `--cookie-secrets` 를 찾는데 없다 | **단수다.** 겹침 구간이 애초에 없다 | `--help \| grep cookie-secret` — 2-1 |
|
||||||
|
| patch 뒤 프록시가 기동에서 죽는다 | **env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다** | `env[*].name` 순서 확인 — 2-2 |
|
||||||
|
| secret 을 바꿨는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 3-2 |
|
||||||
|
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | **SSO 가 재인증을 가렸다.** 쿠키는 죽었다 | 로그의 `Initiating login` + `AuthSuccess` — 4-2 |
|
||||||
|
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` — 4-2 |
|
||||||
|
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-8 |
|
||||||
|
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | **호스트에서 파드 IP 는 안 닿는다** ([`02`](../../evidence/b7-cookie-secret/02-cookie-portability.txt)) | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
|
||||||
|
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | **브라우저를 쓴다** — 전제 |
|
||||||
|
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약의 박스 |
|
||||||
|
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `sudo kubectl -n keycloak-lab get ingress` |
|
||||||
|
| 실험이 끝났는데 Grafana 가 안 열린다 | **Ingress 복구를 안 했다** | 5-3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-7 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [B-7a](b7a-orphan-session.md) 고아 세션 | **정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가** — 셋 다 답이 나온다 |
|
||||||
|
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **app1(BFF)과 app2(oauth2-proxy)가 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
|
||||||
|
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
|
||||||
|
| 운영 | secret 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
# B-7a 재현 가이드 — 고아 세션을 TTL 로 골라내 지운다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md) ·
|
||||||
|
증거 원문: [`docs/evidence/b7a-orphan-session/`](../../evidence/b7a-orphan-session/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 회전만으로는 Redis 가 **안 변하는** 것 | 회전 직후 `--scan` |
|
||||||
|
| 옛 쿠키를 들고 온 **그 순간** 고아가 생기는 것 | 프록시 로그 + Redis |
|
||||||
|
| 새 세션과 고아가 이름·타입·**크기까지** 같은 것 | `type` · `strlen` |
|
||||||
|
| TTL 이 요청을 보내도 **갱신되지 않는** 것 | 30초 간격 3회 |
|
||||||
|
| TTL 로 역산한 생성 시각이 로그와 **1초** 차이인 것 | `AuthSuccess` 시각과 대조 |
|
||||||
|
| 고아만 지워도 산 세션은 `200` 인 것 | 브라우저 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`B-7`](b7-cookie-secret-rotation.md) 이 끝나 있다. oauth2-proxy 가
|
||||||
|
`app2.hyeonworks.com` 에서 돌고 있고 **세션 저장소가 Redis** 여야 한다.
|
||||||
|
이 실험은 B-7 이 「지우지 못했다」로 멈춘 자리에서 시작한다.
|
||||||
|
- **브라우저가 필요하다.** 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 이 실험대에는 **`jq` 가 없다.** Redis 는 자기 CLI 로 묻는다.
|
||||||
|
- **시각은 전부 UTC 로 다룬다.** 이 실험의 결론이 시각 계산이라 여기서
|
||||||
|
섞이면 전부 틀린다 — [1-4](#1-4-시계를-맞춰-둔다) 에서 확인한다.
|
||||||
|
|
||||||
|
## 주의 — 이건 남의 세션을 실제로 지우는 실험이다
|
||||||
|
|
||||||
|
`redis-cli del` 로 세션 키를 지운다. **산 사람의 세션을 잘못 지우면 그 사람은
|
||||||
|
재로그인해야 한다**(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는
|
||||||
|
측정되지 않았지만, **실험대에서만 한다.**
|
||||||
|
|
||||||
|
**B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다** —
|
||||||
|
[5-3](#5-3-원래-자리로-돌려놓는다) 이 그 절차다.
|
||||||
|
|
||||||
|
전 구간 약 20분이고, 그중 **TTL 을 세 번 재는 데 1분**이 그대로 든다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 11:29–11:34 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||||
|
|
||||||
|
Redis 키 이름과 TTL 은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||||
|
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
|
||||||
|
기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
B-7 은 여기서 멈췄다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[stored_session.go:97] Error removing session:
|
||||||
|
error decoding ticket to clear session: session ticket cookie failed validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||||
|
|
||||||
|
그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. **그런데 못 지우는
|
||||||
|
주체가 누구인지를 안 갈랐다.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| B-7 이 남긴 말 | **「★ 지우지 못했다」** |
|
||||||
|
| B-7a 가 묻는 것 | 그건 **oauth2-proxy 의 한계인가, Redis 의 한계인가** |
|
||||||
|
|
||||||
|
**답은 oauth2-proxy 의 한계다.** 프록시는 티켓을 못 풀어 키를 계산 못 하지만,
|
||||||
|
**운영자는 키를 직접 안다.** `--scan` 하면 다 보인다.
|
||||||
|
|
||||||
|
그러면 다음 물음이 생긴다 — **보이긴 하는데 어느 것이 고아인가.**
|
||||||
|
이 실험이 실제로 재는 것은 그 판별이고, 답은 **TTL 하나**다.
|
||||||
|
|
||||||
|
세 물음을 차례로 잰다.
|
||||||
|
|
||||||
|
```
|
||||||
|
(1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가
|
||||||
|
(2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가
|
||||||
|
(3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 회전하기 전에
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. ★ `refresh:disabled` 를 먼저 확인한다
|
||||||
|
|
||||||
|
**이 한 단어가 5절 규칙 전체의 전제다.** 여기가 `disabled` 가 아니면
|
||||||
|
이 가이드의 결론은 당신 환경에서 성립하지 않는다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
|
||||||
|
```
|
||||||
|
**실측** — [`b7-cookie-secret/03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
|
||||||
|
```
|
||||||
|
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 값이다.
|
||||||
|
|
||||||
|
| 값 | 이 실험에서 | |
|
||||||
|
|---|---|---|
|
||||||
|
| `expiry:1h0m0s` | **3600초** | 5절의 역산식에 그대로 들어간다 |
|
||||||
|
| **`refresh:disabled`** | **TTL 이 요청으로 갱신되지 않는다** | 이게 `enabled` 면 역산이 무너진다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — TTL 이 고정이면 **TTL 은 생성 시각의 정확한
|
||||||
|
함수**다. 4-3 에서 그 식을 세우고 5-1 에서 그걸로 고아를 고른다.
|
||||||
|
|
||||||
|
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
"--cookie-secure=true"
|
||||||
|
"--cookie-expire=1h"
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — **`--cookie-refresh` 가 목록에 없어야 한다.**
|
||||||
|
없으면 `refresh:disabled` 다.
|
||||||
|
|
||||||
|
## 1-2. 세션을 하나 만든다
|
||||||
|
|
||||||
|
**하기** — 브라우저에서
|
||||||
|
```
|
||||||
|
https://app2.hyeonworks.com/api/echo → labuser / labpass
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — upstream 의 JSON 이 보이면 세션이 생긴 것이다.
|
||||||
|
|
||||||
|
## 1-3. Redis 를 있는 그대로 본다
|
||||||
|
|
||||||
|
**한 번은 통째로, 필드를 하나씩 본다.** 나중에 루프로 묶더라도 처음에는
|
||||||
|
`type`·`ttl`·`strlen` 이 각각 무엇을 답하는지 봐 두어야 한다.
|
||||||
|
|
||||||
|
**확인** — 무엇이 있나
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[기준선] 회전 전 — 11:29:42 UTC
|
||||||
|
secret = COOKIE_SECRET_A
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
type=string ttl=3568초 크기=3510바이트
|
||||||
|
dbsize=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 그 키 하나에 대해 셋을 묻는다. **키 이름은 위 출력에서 가져온다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
| 명령 | 답하는 질문 | 이 실험에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | 무슨 자료형인가 | 전부 `string` — **구분에 못 쓴다** |
|
||||||
|
| `strlen` | 몇 바이트인가 | 전부 `3510` — **구분에 못 쓴다** |
|
||||||
|
| **`ttl`** | 몇 초 남았나 | **유일하게 다른 값** |
|
||||||
|
|
||||||
|
**`ttl` 이 `-1` 이면** 만료가 안 걸린 키다(이 실험의 대상이 아니다).
|
||||||
|
**`-2` 면** 키가 없다 — 이름을 잘못 옮긴 것이다.
|
||||||
|
|
||||||
|
키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다.
|
||||||
|
**한 줄짜리고, 하는 일이 이름 그대로다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
R() { sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. **느리다.** 키가 수백 개면
|
||||||
|
> 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
|
||||||
|
|
||||||
|
## 1-4. 시계를 맞춰 둔다
|
||||||
|
|
||||||
|
**이 실험은 시각 계산이 결론이다.** 프록시 로그는 **UTC** 이고, 당신 셸의
|
||||||
|
`date` 는 KST 일 것이다. 섞이면 9시간이 틀어진다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
date; date -u
|
||||||
|
timedatectl show -p NTP -p NTPSynchronized
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
NTP=yes
|
||||||
|
NTPSynchronized=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `NTPSynchronized=yes`. 그리고 **앞으로 `date` 는
|
||||||
|
전부 `-u` 를 붙여 친다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을
|
||||||
|
같은 축에 놓을 수 있게 된다. 4-6 의 「1초 오차」는 이 축이 맞아야 나온다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 1차 회전 A → B. 시각을 반드시 기록한다
|
||||||
|
|
||||||
|
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_A"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 회전 시각을 변수에 담는다
|
||||||
|
|
||||||
|
**★ 이 값이 5절 규칙의 절반이다.** 안 적어 두면 나중에 고아를 못 고른다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"
|
||||||
|
```
|
||||||
|
**실측** — 원래 실행의 1차 회전 시각
|
||||||
|
```
|
||||||
|
11:29:56 UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-2. 바꾼다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_B"}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `successfully rolled out`. 그리고 **env 배열의 인덱스가
|
||||||
|
당신 매니페스트와 맞는지**는 B-7 의 [2-2](b7-cookie-secret-rotation.md#2-2-env-배열의-어느-칸을-바꾸는지-먼저-확인한다) 에서
|
||||||
|
확인했다. 안 했으면 지금 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. 어느 키를 참조하는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
COOKIE_SECRET_B
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3-2. ★ 그런데 Redis 는 그대로다
|
||||||
|
|
||||||
|
**확인** — 1-3 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K ttl=$(R ttl $K)"
|
||||||
|
done
|
||||||
|
R dbsize
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[주입] 1차 회전 A → B — 11:29:56 UTC
|
||||||
|
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키 수가 회전 전과 같다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기서 「실험 실패」라고 결론 내리면 틀린다.
|
||||||
|
**회전은 방아쇠가 아니라 조건이다.** 실제로 벌어지는 것은
|
||||||
|
**누군가 옛 쿠키를 들고 오는 순간**이다.
|
||||||
|
|
||||||
|
> A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 자리다.
|
||||||
|
> **주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.**
|
||||||
|
|
||||||
|
## 3-3. 브라우저로 다시 연다 — 여기서 고아가 생긴다
|
||||||
|
|
||||||
|
**하기** — 1-2 에서 로그인한 **그 브라우저 그대로**
|
||||||
|
```
|
||||||
|
https://app2.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 그 순간의 로그
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
브라우저가 접근한 순간(11:30:27) 로그:
|
||||||
|
[stored_session.go:94] Error loading cookied session:
|
||||||
|
session ticket cookie failed validation: <nil>, removing session
|
||||||
|
[stored_session.go:97] Error removing session:
|
||||||
|
error decoding ticket to clear session: session ticket cookie failed validation
|
||||||
|
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||||
|
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`AuthSuccess` 의 시각을 적어 둔다.**
|
||||||
|
`11:30:27`. **4-6 에서 이 숫자와 역산값을 맞춰 본다.**
|
||||||
|
|
||||||
|
**확인** — Redis
|
||||||
|
```bash
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K ttl=$(R ttl $K)"
|
||||||
|
done
|
||||||
|
R dbsize
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
Redis:
|
||||||
|
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||||
|
dbsize=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키가 둘.** 그리고 로그인 화면을 안 봤다는 사실.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Keycloak SSO 가 살아 있어 **조용히 재인증**됐다.
|
||||||
|
B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰 — 어느 것이 고아인가
|
||||||
|
|
||||||
|
## 4-1. ★ Redis 값만 보고는 구분할 수 없다
|
||||||
|
|
||||||
|
**확인** — 두 키를 나란히 놓는다
|
||||||
|
```bash
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[측정 1] ★ Redis 만 보고는 구분할 수 없다
|
||||||
|
키 type strlen ttl
|
||||||
|
_oauth2_proxy-87faa1c9…(새) string 3510 3558
|
||||||
|
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 열을 하나씩 지운다.
|
||||||
|
|
||||||
|
| 신호 | 새 세션 | 고아 | 쓸 수 있나 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 이름 접두사 | `_oauth2_proxy-` | 같다 | ✗ |
|
||||||
|
| 이름 뒷부분 | 불투명한 32자 hex | 같은 성질 | ✗ — 사용자·시각·상태 어느 것도 안 담긴다 |
|
||||||
|
| `type` | `string` | `string` | ✗ |
|
||||||
|
| **`strlen`** | **3510** | **3510** | ✗ — **바이트 단위로 같다** |
|
||||||
|
| `ttl` | 3558 | 3480 | **✓ 이것뿐이다** |
|
||||||
|
|
||||||
|
값을 직접 봐도 소용없다. **암호화되어 있다.**
|
||||||
|
|
||||||
|
**확인** — 바이너리를 이스케이프해 보여 준다
|
||||||
|
```bash
|
||||||
|
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
|
||||||
|
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
|
||||||
|
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`--no-raw` 를 안 붙이면 터미널이 깨진다.** 세션 값은 바이너리다.
|
||||||
|
> 붙이면 `\xNN` 로 이스케이프해서 보여 준다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 값이 다르다는 것은 알 수 있지만 **어느 쪽이
|
||||||
|
고아인지는 말해 주지 않는다.** 뜻을 읽을 수 없기 때문이다.
|
||||||
|
**다른 것은 TTL 하나뿐이다.**
|
||||||
|
|
||||||
|
## 4-2. TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||||
|
|
||||||
|
**TTL 을 신호로 쓰려면 그것이 믿을 만한지부터 재야 한다.**
|
||||||
|
두 가지를 확인한다 — ① 실제로 줄어드는가 ② 요청을 보내면 되살아나는가.
|
||||||
|
|
||||||
|
**확인** — 30초 간격으로 세 번. 여기에 1분이 그대로 든다
|
||||||
|
```bash
|
||||||
|
for i in 1 2 3; do
|
||||||
|
date -u '+%H:%M:%S'
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
printf " %s ttl=%s\n" "$K" "$(R ttl $K)"
|
||||||
|
done
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||||
|
30초 간격 3회:
|
||||||
|
t+00초 새=3557 고아=3479
|
||||||
|
t+30초 새=3526 고아=3448
|
||||||
|
t+60초 새=3494 고아=3417
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **30초에 30초씩 준다.** 그리고 **두 값의 차가 거의
|
||||||
|
고정**되어 있다 — 3557−3479 = 78, 3526−3448 = 78, 3494−3417 = **77**.
|
||||||
|
**차이가 (1초 안에서) 고정이라는 것이 「둘 다 생성 시각에만 달렸다」는 뜻이다.**
|
||||||
|
그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, **4-6 에서 나오는
|
||||||
|
「1초 오차」와 같은 것**이다.
|
||||||
|
|
||||||
|
이제 ②를 확인한다. **브라우저로 요청을 몇 번 보낸 뒤** 다시 잰다.
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
|
||||||
|
살아있는 세션 ttl=3464 ← 계속 줄어든다
|
||||||
|
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **쓰고 있어도 TTL 이 안 늘어난다.**
|
||||||
|
1-1 에서 본 `refresh:disabled` 가 여기서 값으로 확인됐다.
|
||||||
|
따라서 **고아는 생성 후 정확히 1시간에 사라진다.** 무한정 쌓이지 않는다.
|
||||||
|
|
||||||
|
## 4-3. 개념 — TTL 갱신 여부가 왜 결정적인가
|
||||||
|
|
||||||
|
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
|
||||||
|
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
|
||||||
|
|
||||||
|
**왜 여기 나오나.** TTL 이 고정이면 이 식이 성립한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
생성시각 = 지금 - (cookie-expire - TTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 한 줄이 5절의 정리 규칙 전체를 만든다.** `cookie-expire` 는 1-1 에서
|
||||||
|
`1h0m0s` = 3600 으로 확인했다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** **`--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.**
|
||||||
|
활발히 쓰는 세션일수록 TTL 이 크게 남아 「방금 만들어진 것」처럼 보이고,
|
||||||
|
오래 안 쓴 산 세션은 TTL 이 작아 **고아로 오판되어 지워진다.**
|
||||||
|
|
||||||
|
> **그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이
|
||||||
|
> 오히려 정직하다.** 골라내는 척하면서 산 세션을 죽이는 것보다 낫다.
|
||||||
|
> **이 가이드의 5절은 `refresh:disabled` 일 때만 유효하다.**
|
||||||
|
|
||||||
|
## 4-4. 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||||
|
|
||||||
|
**되돌리기가 없는 조작이다. 지우기 전에 어느 키인지 두 번 확인한다.**
|
||||||
|
지금은 TTL 이 작은 쪽이 고아다(4-1).
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
R dbsize
|
||||||
|
R --scan --pattern '_oauth2_proxy-*'
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||||
|
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
|
||||||
|
dbsize 2 → 1
|
||||||
|
남은 키: _oauth2_proxy-87faa1c9…
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **반환값 `1`.** `0` 이면 그 키가 없었던 것이다
|
||||||
|
(이름을 잘못 옮겼다).
|
||||||
|
|
||||||
|
**확인** — 산 세션이 멀쩡한지. **브라우저로 다시 연다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
삭제 직후 브라우저 요청 (11:32:49):
|
||||||
|
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **200. 산 세션은 영향이 없다.**
|
||||||
|
|
||||||
|
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||||
|
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. **운영자는 키를 직접 안다.**
|
||||||
|
> 0절의 물음 (2)에 대한 답이 이것이다.
|
||||||
|
|
||||||
|
## 4-5. 누적한다 — 회전할 때마다
|
||||||
|
|
||||||
|
**한 번 더 회전해 본다.** 고아가 일회성인지 누적인지가 갈린다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
|
||||||
|
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_A"}]'
|
||||||
|
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 **브라우저로 다시 연다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K ttl=$(R ttl $K)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
[측정 4] ★ 누적한다 — 회전할 때마다
|
||||||
|
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
|
||||||
|
|
||||||
|
키 TTL 생성시각(추정) 판정
|
||||||
|
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
|
||||||
|
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
|
||||||
|
dbsize=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`87faa1c9…` 의 신분이 바뀌었다.**
|
||||||
|
3-3 에서 「새 세션」이던 것이 여기서는 고아다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **1차 회전을 살아남았던 세션이 2차 회전에서
|
||||||
|
고아가 됐다.** 회전 1회 = **그 시점 로그인 사용자 수**만큼의 고아.
|
||||||
|
고아는 사건이 아니라 **회전의 고정 비용**이다.
|
||||||
|
|
||||||
|
## 4-6. ★ 역산이 실제로 맞는지 검증한다
|
||||||
|
|
||||||
|
**규칙을 쓰기 전에 규칙 자체를 검증한다.** 위 표의 「생성시각(추정)」은
|
||||||
|
4-3 의 식으로 나온 값이고, 우리에겐 대조할 실측이 하나 있다 —
|
||||||
|
**3-3 의 `AuthSuccess` 로그 시각.**
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
|
||||||
|
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **1초.** TTL 이 초 단위 정수라 반올림에서 나올 수
|
||||||
|
있는 크기다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **TTL 역산은 추정이 아니라 측정에 가깝다.**
|
||||||
|
그래서 다음 규칙을 안심하고 쓸 수 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
생성시각 < 회전시각 → 그 키는 고아다
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 성립하는가** — 회전 **이후에** 만들어진 세션은 **새 secret 으로**
|
||||||
|
만들어졌으므로 반드시 유효하다. 그러니 회전 이전 생성분만 고르면 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 정리와 복구
|
||||||
|
|
||||||
|
## 5-1. ★ 먼저 눈으로 보고, 그 다음에 지운다
|
||||||
|
|
||||||
|
**`del` 을 바로 붙이지 않는다.** 같은 루프를 `echo` 로 한 번 돌려
|
||||||
|
**무엇이 지워질지 읽는다.**
|
||||||
|
|
||||||
|
**확인** — 지우지 않는 판. `ROT` 은 2-1(또는 4-5의 `ROT2`)에서 담아 둔 값이다
|
||||||
|
```bash
|
||||||
|
NOW=$(date -u +%s); EXP=3600
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||||
|
if [ "$C" -lt "$ROT" ]; then
|
||||||
|
echo "고아 $K (생성 $(date -u -d @$C +%H:%M:%S))"
|
||||||
|
else
|
||||||
|
echo "산것 $K (생성 $(date -u -d @$C +%H:%M:%S))"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **「산것」이 정확히 지금 로그인해 있는 사람 수만큼
|
||||||
|
있는가.** 아니면 `ROT` 이 틀렸거나 `EXP` 가 3600 이 아니다.
|
||||||
|
|
||||||
|
**`NOW` 를 루프 밖에서 한 번만 잡는 것이 중요하다.** 안에서 잡으면 키마다
|
||||||
|
기준 시각이 달라진다.
|
||||||
|
|
||||||
|
**하기** — 확인한 뒤에 지운다
|
||||||
|
```bash
|
||||||
|
NOW=$(date -u +%s); EXP=3600
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||||
|
if [ "$C" -lt "$ROT" ]; then
|
||||||
|
echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
R dbsize
|
||||||
|
```
|
||||||
|
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
|
||||||
|
```
|
||||||
|
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
|
||||||
|
산 세션은 남고 고아만 사라졌다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 지운 뒤 브라우저로 한 번 더 열어 본다.
|
||||||
|
**열리면 산 세션이 안 다친 것이다**(4-4).
|
||||||
|
|
||||||
|
> **`dbsize` 는 이 Redis 전체를 센다.** BFF 세션과 B-5 가 남긴 키도 들어 있다.
|
||||||
|
> 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이고,
|
||||||
|
> **당신 환경에서는 다를 수 있다.** 세션만 세려면 `--scan --pattern` 을 쓴다.
|
||||||
|
|
||||||
|
## 5-2. 전제가 깨졌을 때 — 정직한 대안
|
||||||
|
|
||||||
|
`--cookie-refresh` 가 켜져 있으면 5-1 을 **쓰면 안 된다**(4-3).
|
||||||
|
그때는 전부 지우고 모두 재인증시킨다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 에는 BFF 세션도 들어 있다.
|
||||||
|
> 패턴으로 좁히는 것이 이 실험대에서는 필수다.
|
||||||
|
|
||||||
|
## 5-3. 원래 자리로 돌려놓는다
|
||||||
|
|
||||||
|
**하기** — secret 을 A 로
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
|
||||||
|
```
|
||||||
|
`COOKIE_SECRET_B` 로 나오면 A 로 되돌린다(2절의 되돌리기).
|
||||||
|
4-5 에서 이미 A 로 돌아왔다면 그대로 둔다.
|
||||||
|
|
||||||
|
**하기** — B-7 에서 Grafana Ingress 를 빌렸다면 **여기서 돌려준다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||||
|
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||||
|
|
||||||
|
> **[C-1](c1-multi-app-sso.md) 을 이어서 할 생각이면 아직 돌려주지 않는다.**
|
||||||
|
> C-1 이 app2 를 그대로 쓴다. 그 대신 **C-1 이 끝난 뒤에 반드시 복구한다.**
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
|
||||||
|
| 세션 | `R --scan --pattern '_oauth2_proxy-*'` | 지금 로그인한 사람 수만큼만 |
|
||||||
|
| 다른 키 | `R --scan --pattern '*'` | `b5:pvc`·BFF 세션이 **살아 있다** (안 지웠어야 한다) |
|
||||||
|
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
|
||||||
|
| 셸 변수 | `unset ROT ROT2 NOW EXP` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 회전했는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 — 3-2·3-3 |
|
||||||
|
| 고아와 산 세션이 구분이 안 간다 | **이름·타입·크기가 같다.** 값은 암호화 | **TTL 만이 신호다** — 4-1 |
|
||||||
|
| `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` — 4-1 |
|
||||||
|
| `ttl` 이 `-1` | 만료가 안 걸린 키다 | 이 실험의 대상이 아니다 |
|
||||||
|
| `ttl` 이 `-2` · `del` 이 `0` | **그 키가 없다** | 키 이름을 `--scan` 출력에서 다시 옮긴다 |
|
||||||
|
| 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 — 1-1 |
|
||||||
|
| 역산이 9시간 어긋난다 | **`date` 를 로컬로 쳤다** | 전부 `date -u` — 1-4 |
|
||||||
|
| **산 세션이 고아로 잡힌다** | **`--cookie-refresh` 가 켜져 있다** | 기동 로그의 `refresh:disabled` 확인. 켜져 있으면 5-2 |
|
||||||
|
| 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인하면 된다. SSO 가 살아 있으면 조용히 지나간다 |
|
||||||
|
| `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc`·BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-3 |
|
||||||
|
| `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | **같은 Redis 에 BFF 세션이 있다** | 패턴으로 좁혀 지운다 — 5-2 |
|
||||||
|
| 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 |
|
||||||
|
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | B-7a 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [B-7](b7-cookie-secret-rotation.md) cookie secret | **「지우지 못했다」가 정정됐다** — 프록시가 못 하는 것이지 불가능한 것이 아니다 |
|
||||||
|
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다.** 지울 때 패턴을 좁혀야 하는 이유 |
|
||||||
|
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | 회전의 진짜 비용은 **재로그인이 아니라 저장소에 남는 것**이다 |
|
||||||
|
| 운영 | 회전 시각을 **UTC epoch 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
# C-1 재현 가이드 — 앱 둘에 SSO 를 걸고, IdP 세션을 죽여 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md) ·
|
||||||
|
증거 원문: [`docs/evidence/c1-multi-app-sso/`](../../evidence/c1-multi-app-sso/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 두 번째 앱이 **로그인 화면 없이** 열리는 것 | 브라우저 |
|
||||||
|
| `user session 1` 에 `client session 2` 가 매달린 구조 | PostgreSQL |
|
||||||
|
| 서로 다른 구조의 두 앱이 같은 user session 을 공유하는 것 | `client` 조인 |
|
||||||
|
| **IdP 세션을 죽여도 두 앱이 그대로 열리는 것** | 브라우저 + Redis |
|
||||||
|
| `logout-all` 이 오류 없이 아무것도 안 하는 것 | 세션 수가 안 변한다 |
|
||||||
|
| realm 을 안 보고 세면 `master` 의 admin 세션에 속는 것 | `realm` 조인 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`B-2`](../../experiment-b2-multi-instance-session.md) 의 **app1(BFF)** 과
|
||||||
|
[`B-7`](b7-cookie-secret-rotation.md) 의 **app2(oauth2-proxy)** 가 **둘 다** 떠 있다.
|
||||||
|
이 실험은 그 둘이 있어야 성립한다 — 없으면 SSO 가 아니라 로그인 한 번이다.
|
||||||
|
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다(B-7 의 주의).
|
||||||
|
이 실험이 끝나면 [5-3](#5-3-빌린-것을-돌려준다) 에서 되돌린다.
|
||||||
|
- **브라우저가 필요하다.** SSO 는 브라우저 쿠키가 만드는 현상이고, `curl` 로는
|
||||||
|
「로그인 화면이 안 떴다」를 볼 수 없다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). `kcadm.sh` 는
|
||||||
|
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.**
|
||||||
|
- 이 실험대에는 **`jq` 가 없다.**
|
||||||
|
|
||||||
|
## 주의 — 이건 세션을 전부 지우고 시작하는 실험이다
|
||||||
|
|
||||||
|
기준선을 만들려고 **Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고,
|
||||||
|
Keycloak StatefulSet 을 재시작한다.** 그 순간 **지금 로그인해 있는 모든 사람이
|
||||||
|
끊긴다.** 실험대에서만 한다.
|
||||||
|
|
||||||
|
전 구간 약 20분이고, Keycloak 재시작에 1~2분이 든다.
|
||||||
|
중간에 그만두려면 [5. 복구](#5-복구) 로 간다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:44–14:48 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했고 SQL 원문은 기록에 없다 |
|
||||||
|
|
||||||
|
세션 id·Redis 키·클라이언트 UUID 는 **당신 환경에서 다르다.** 이 문서는
|
||||||
|
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
예시로 실린 값은 전부 위 수집 기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
원래 질문은 한 줄이었다.
|
||||||
|
|
||||||
|
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
|
||||||
|
|
||||||
|
「달라진다」에는 두 방향이 섞여 있다 — 편해지는 쪽과 위험해지는 쪽.
|
||||||
|
위험 쪽의 통념은 이렇다.
|
||||||
|
|
||||||
|
| | 예측 |
|
||||||
|
|---|---|
|
||||||
|
| 통념 | SSO 를 붙이면 **IdP 가 단일 장애점**이 된다. IdP 가 죽으면 다 죽는다 |
|
||||||
|
| **실측** | **절반만 맞다.** 로그인 **경로**는 그렇고, **이미 로그인한 사용자**는 아니다 |
|
||||||
|
|
||||||
|
**둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 판정된다.** 그게 이 실험이다.
|
||||||
|
|
||||||
|
핵심은 **수명이 세 층으로 나뉘어 있다는 것**이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
|
||||||
|
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
|
||||||
|
③ access token 60초
|
||||||
|
|
||||||
|
①을 지워도 ②는 자기 수명을 산다
|
||||||
|
```
|
||||||
|
|
||||||
|
**로그아웃이 지우는 것은 ① 뿐이다.** 이 실험대에는 서로 완전히 다르게
|
||||||
|
세션을 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
|
||||||
|
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
|
||||||
|
|
||||||
|
둘 다 realm keycloak-patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이
|
||||||
|
같은 IdP 를 쓰면서 세션을 정반대로 다룬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 깨끗한 상태를 만든다
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 두 앱이 다 떠 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
sudo kubectl -n keycloak-lab get ingress
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — `bff` 와 `oauth2-proxy` 가 **둘 다** `Running` 이고,
|
||||||
|
Ingress 에 `app1.hyeonworks.com` 과 `app2.hyeonworks.com` 이 **둘 다** 있는가.
|
||||||
|
|
||||||
|
**확인** — 밖에서
|
||||||
|
```bash
|
||||||
|
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/
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||||
|
```
|
||||||
|
app1 HTTP 200 / app2 HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`app2` 가 Grafana 로 간다면** B-7 의 Ingress 가 없는 것이다.
|
||||||
|
> B-7 의 [1-1~1-2](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다) 를 먼저 한다.
|
||||||
|
|
||||||
|
## 1-2. 세션을 지우려고 시도한다 — 그리고 실패를 본다
|
||||||
|
|
||||||
|
**kcadm 을 먼저 로그인시킨다.** 파드가 재시작되면 세션이 사라지고 이후 모든
|
||||||
|
명령이 `401` 이 된다. **1-3 에서 실제로 재시작하므로 그때 다시 해야 한다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```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 exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
create realms/keycloak-patterns/logout-all
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 세션이 정말 지워졌는지 센다. **미검증** (증거에는 이 SQL 의 원문이 없다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||||
|
```
|
||||||
|
=== 깨끗한 상태로 초기화 ===
|
||||||
|
DELETE 1
|
||||||
|
|
||||||
|
=== 기준선 ===
|
||||||
|
Keycloak 온라인 세션: 4
|
||||||
|
Redis 키: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`4`.** 0 이 아니다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`logout-all` 이 안 먹었다.** 오류도 안 났다.
|
||||||
|
세션이 그대로 4개 남아 있다.
|
||||||
|
|
||||||
|
> **★ 해설 문서 정정** — 이 문서는 처음에 이 값을 `0` 으로 인쇄했다.
|
||||||
|
> 증거 [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt) 는 **`4`** 다.
|
||||||
|
> **`0` 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다.**
|
||||||
|
> 이 가이드는 증거를 따른다 — **여기서 4 가 나오는 것이 정상이다.**
|
||||||
|
|
||||||
|
**왜 안 먹었나** — **캐시 때문이다.** A-1 에서 확인했듯 Keycloak 은 세션을
|
||||||
|
DB 에서 읽되 **캐시로 답한다.** 관리 API 가 무효화를 걸어도 각 노드의 캐시가
|
||||||
|
그대로면 세션은 살아 있는 것처럼 보인다.
|
||||||
|
|
||||||
|
## 1-3. 그래서 DB 를 직접 지우고 Keycloak 을 재시작한다
|
||||||
|
|
||||||
|
**하기** — 자식 테이블부터 지운다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
DELETE 2
|
||||||
|
DELETE 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — 앱 세션도 비운다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`flushall` 은 이 Redis 전체를 지운다.** BFF 세션·oauth2-proxy 세션·
|
||||||
|
> B-5 가 남긴 `b5:pvc` 까지 전부다. **기준선을 만드는 자리라서 의도한 것**이고,
|
||||||
|
> 실험 도중에는 절대 쓰지 않는다([B-7a](b7a-orphan-session.md) 5-2 참고).
|
||||||
|
|
||||||
|
**하기** — 캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 없다. **지운 세션은 안 돌아온다.** 다시 로그인하면 된다.
|
||||||
|
|
||||||
|
**확인** — 이제 비었는가
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
|
||||||
|
```
|
||||||
|
Redis 키: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — Redis 키 **0**, 세션 수 **0**.
|
||||||
|
여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다.
|
||||||
|
|
||||||
|
**★ kcadm 세션이 날아갔다.** 1-2 의 `config credentials` 를 **다시 친다.**
|
||||||
|
|
||||||
|
## 1-4. ★ 세는 법을 먼저 고친다 — realm 을 본다
|
||||||
|
|
||||||
|
**이 절을 건너뛰면 4절의 결론을 반대로 읽는다.**
|
||||||
|
|
||||||
|
`offline_user_session` 에는 **모든 realm 의 세션**이 들어 있다. 그리고
|
||||||
|
`kcadm` 을 쓰는 순간 **`master` realm 에 admin 세션이 생긴다.**
|
||||||
|
그러니 그냥 세면 **내가 만든 노이즈를 남의 세션으로 읽는다.**
|
||||||
|
|
||||||
|
**확인** — 틀린 방법(전체를 센다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 맞는 방법(realm 을 조인한다). **미검증**
|
||||||
|
```bash
|
||||||
|
sudo 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` 는 **당신이 방금 `kcadm` 을 쳐서 생긴 것**이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 한 열 때문에 원래 실행은 **「안 지워졌다」로
|
||||||
|
오독할 뻔했다.** 해설 문서가 「세 번째가 특히 위험했다」고 쓴 것이 이 실수다.
|
||||||
|
|
||||||
|
> **여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다.**
|
||||||
|
> 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 두 앱에 차례로 들어간다
|
||||||
|
|
||||||
|
여기서 SSO 상태를 만든다. **되돌리기는 간단하다** — 5절의 초기화를 다시 하면
|
||||||
|
된다. 파괴적인 조작은 4절에 있다.
|
||||||
|
|
||||||
|
## 2-1. app1 에 로그인한다 — 로그인 화면이 나온다
|
||||||
|
|
||||||
|
**하기** — 브라우저에서
|
||||||
|
```
|
||||||
|
https://app1.hyeonworks.com/ → labuser / labpass
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **Keycloak 로그인 화면이 뜨는가.**
|
||||||
|
주소창이 이렇게 바뀐다.
|
||||||
|
|
||||||
|
**실측** — 해설 문서에 남은 형태
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||||
|
?client_id=bff-confidential&...
|
||||||
|
→ Sign in to keycloak-patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 첫 앱에서는 **당연히 로그인 화면이 나온다.**
|
||||||
|
이것이 2-2 의 대조군이다. **이걸 안 보면 「app2 에서 안 뜬 것」이 특별한
|
||||||
|
일인지 알 수 없다.**
|
||||||
|
|
||||||
|
## 2-2. 로그인 직후 상태를 잰다
|
||||||
|
|
||||||
|
**확인** — 1-4 의 맞는 쿼리를 그대로 쓴다
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
```
|
||||||
|
**실측** — [`02-after-app1-login.txt`](../../evidence/c1-multi-app-sso/02-after-app1-login.txt)
|
||||||
|
```
|
||||||
|
=== app1 로그인 직후 Keycloak 세션 ===
|
||||||
|
user_session_id | client_sessions
|
||||||
|
--------------------------+-----------------
|
||||||
|
oqOjHekin4JU-BZjgQLjUByW | 1
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`user_session_id` 를 적어 둔다.** 2-3 과 3절에서
|
||||||
|
계속 쓴다. 그리고 **`client_sessions` 가 1** 이다.
|
||||||
|
|
||||||
|
**확인** — 저장소 두 곳
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
Redis 키: 1
|
||||||
|
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||||
|
PostgreSQL authorized client: 1 행
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — Redis 키 이름의 **접두사 `bff:session:sessions:`**.
|
||||||
|
이 접두사가 「BFF 가 만든 세션」이라는 뜻이고, 3절에서 프록시 것과 갈라진다.
|
||||||
|
|
||||||
|
`authorized client` 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이다. **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from oauth2_authorized_client"
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **한 번 로그인했는데 상태가 세 곳에 생겼다.**
|
||||||
|
Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 4절에서 이 셋의 운명이 갈린다.
|
||||||
|
|
||||||
|
## 2-3. app2 를 방문한다 — 여기가 SSO 다
|
||||||
|
|
||||||
|
**하기** — **같은 브라우저의 새 탭**에서
|
||||||
|
```
|
||||||
|
https://app2.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||||
|
|
||||||
|
**실측** — 뜨지 않았다.
|
||||||
|

|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **SSO 가 동작한다.** app2 는 Keycloak 으로
|
||||||
|
리다이렉트했지만, Keycloak 에 이미 세션이 있어서 **묻지 않고 바로 돌려보냈다.**
|
||||||
|
|
||||||
|
> **다른 브라우저나 시크릿 창에서 열면 안 된다.** SSO 를 만드는 것은
|
||||||
|
> `auth.hyeonworks.com` 에 붙은 **브라우저 쿠키**다. 창이 다르면 쿠키가 없고,
|
||||||
|
> 그러면 로그인 화면이 뜨는 것이 정상이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 만든 구조를 확인한다
|
||||||
|
|
||||||
|
## 3-1. user session 하나에 client session 둘
|
||||||
|
|
||||||
|
**확인** — 2-2 와 **똑같은 쿼리**
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
```
|
||||||
|
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||||
|
```
|
||||||
|
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
|
||||||
|
user_session_id | client_sessions
|
||||||
|
--------------------------+-----------------
|
||||||
|
oqOjHekin4JU-BZjgQLjUByW | 2
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`user_session_id` 가 2-2 와 같고, `client_sessions`
|
||||||
|
만 1 → 2 로 늘었다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **두 번째 로그인이 아니라 같은 로그인에 앱이
|
||||||
|
하나 붙은 것이다.** 이것이 SSO 의 데이터 구조다.
|
||||||
|
|
||||||
|
```
|
||||||
|
user session (사용자 · 브라우저 하나당 하나)
|
||||||
|
├─ client session : bff-confidential
|
||||||
|
└─ client session : oauth2-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3-2. 어느 클라이언트가 붙었는가
|
||||||
|
|
||||||
|
**확인** — **미검증** (증거에는 이 SQL 의 원문이 없다. 출력은 실측이다)
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
```
|
||||||
|
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||||
|
```
|
||||||
|
=== 어느 클라이언트가 붙었는가 ===
|
||||||
|
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 은 앱이 세션을 어떻게 다루는지 모르고,
|
||||||
|
알 필요도 없다.
|
||||||
|
|
||||||
|
## 3-3. 저장소 세 곳이 각자 무엇을 들고 있는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
|
||||||
|
```
|
||||||
|
=== 저장소 상태 ===
|
||||||
|
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](b7a-orphan-session.md) 에서 `FLUSHDB` 를 금지한 이유가 이것이다.
|
||||||
|
|
||||||
|
### 개념 — 두 층으로 나뉘어 있는 이유
|
||||||
|
|
||||||
|
**무엇인가.** Keycloak 은 세션을 `user session`(사람 하나)과
|
||||||
|
`client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.
|
||||||
|
|
||||||
|
| | 무엇이 사라졌나 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A-3** DB 크래시 | `user_session` 행이 통째로 | **모든 앱이 끊긴다** |
|
||||||
|
| **B-3** refresh 재사용 탐지 | **`client_session` 만** | **그 앱만 끊긴다** |
|
||||||
|
|
||||||
|
**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 사고가 다른 앱으로 번지지
|
||||||
|
않게 하려면 client session 이 따로 있어야 한다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 한 층뿐이라면 B-3 의 재사용 탐지 한 번이 **모든 앱을**
|
||||||
|
끊었을 것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰 — IdP 세션만 죽인다
|
||||||
|
|
||||||
|
## 4-1. 무엇을 지우는지 먼저 정한다
|
||||||
|
|
||||||
|
**지우려는 것은 ①(IdP 세션)뿐이다.** ②(앱 세션)와 ③(토큰)은 손대지 않는다.
|
||||||
|
**그 구분이 이 실험의 전부다.**
|
||||||
|
|
||||||
|
**되돌리기** — 다시 로그인하면 된다. 파괴적이지만 회복은 쉽다.
|
||||||
|
|
||||||
|
## 4-2. 지우는 방법을 고른다 — 두 개는 안 먹는다
|
||||||
|
|
||||||
|
**하기** — 세션 id 를 지목해서 지운다. **미검증 · 안 먹는다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **오류도 안 나고 세션도 안 줄어든다.**
|
||||||
|
1-2 의 `logout-all` 과 같은 유형이다.
|
||||||
|
|
||||||
|
**하기** — 사용자 단위로 끊는다. **이건 먹는다**
|
||||||
|
```bash
|
||||||
|
USERID=$(sudo 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"
|
||||||
|
|
||||||
|
sudo 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` 가 다른 것을 잡은
|
||||||
|
것이다. 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다.
|
||||||
|
|
||||||
|
> **자리표시자를 두지 않으려고 두 단계로 나눴다.** 한 줄로 이어 붙일 수도
|
||||||
|
> 있지만, **그러면 UID 가 비었을 때 그 사실이 안 보인다.**
|
||||||
|
|
||||||
|
## 4-3. IdP 쪽은 정말 끊겼는가 — realm 을 보고 센다
|
||||||
|
|
||||||
|
**확인** — 1-4 의 맞는 쿼리
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
```
|
||||||
|
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
|
||||||
|
```
|
||||||
|
=== 사용자 단위 로그아웃 (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 세션**이다.
|
||||||
|
|
||||||
|
> **★ 여기가 이 실험에서 가장 잘 틀리는 자리다.** 「1이 남았네, 로그아웃이
|
||||||
|
> 안 먹었구나」로 읽으면 4-4 의 결론이 통째로 뒤집힌다. **숫자 옆에 realm 을
|
||||||
|
> 붙이지 않으면 그 숫자는 아무 뜻이 없다.**
|
||||||
|
|
||||||
|
## 4-4. ★ 앱 세션은 그대로 남아 있다
|
||||||
|
|
||||||
|
**확인** — 3-3 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
|
||||||
|
```
|
||||||
|
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
|
||||||
|
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||||
|
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||||
|
PostgreSQL authorized client: 1 행
|
||||||
|
|
||||||
|
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키 이름이 3-3 과 글자 하나까지 같다.**
|
||||||
|
아무것도 안 지워졌다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **로그아웃은 ①만 지웠다.** ②도 ③도 아무도 안 건드렸다.
|
||||||
|
|
||||||
|
## 4-5. 브라우저로 두 앱을 다시 연다
|
||||||
|
|
||||||
|
**하기** — 아까 그 브라우저에서
|
||||||
|
```
|
||||||
|
https://app1.hyeonworks.com/
|
||||||
|
https://app2.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||||||
|
|
||||||
|
**실측** — 둘 다 로그인 화면 없이 그대로 열렸다.
|
||||||
|

|
||||||
|
|
||||||
|
> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 2-3 의 스크린샷은
|
||||||
|
> **바이트 단위로 동일한 파일**이다(md5 `2c703176…`). 두 시점의 화면이 실제로
|
||||||
|
> 같은 내용이었기 때문이며 조작이 아니지만, **그래서 두 시점을 구별하는 증거가
|
||||||
|
> 되지 못한다.** 구별은 [`03-`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt) 과
|
||||||
|
> [`04-`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt) 의 터미널 출력이 한다 —
|
||||||
|
> `client_sessions` 1→2, 그리고 IdP 세션 삭제 후에도 Redis 키가 남아 있는 것.
|
||||||
|
> **화면이 같아 보인다는 것 자체가 이 실험의 결론**이라, 화면만으로는 증명이 안 된다.
|
||||||
|
|
||||||
|
## 4-6. 왜 그런가 — 세 개의 독립된 수명
|
||||||
|
|
||||||
|
```
|
||||||
|
① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초
|
||||||
|
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
|
||||||
|
③ access token 60초
|
||||||
|
|
||||||
|
①을 지워도 ②는 자기 수명을 산다
|
||||||
|
```
|
||||||
|
|
||||||
|
**앱은 매 요청마다 IdP 에 물어보지 않는다.** 자기 세션이 살아 있으면 그걸로
|
||||||
|
답한다. **그래서 ①이 사라진 것을 모른다.**
|
||||||
|
|
||||||
|
**그러면 언제 알게 되는가.**
|
||||||
|
|
||||||
|
| | 언제 끊기는가 |
|
||||||
|
|---|---|
|
||||||
|
| BFF | access token 이 만료되어 **refresh 를 시도할 때** → `Session not active` |
|
||||||
|
| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신을 시도**할 때 |
|
||||||
|
|
||||||
|
**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이 아니라
|
||||||
|
**앱이 다음에 IdP 를 부를 때까지**다.
|
||||||
|
|
||||||
|
> **B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다.**
|
||||||
|
> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고,
|
||||||
|
> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다.
|
||||||
|
> **두 방향 모두 「한쪽만 지우면 다른 쪽이 남는다」이다.**
|
||||||
|
|
||||||
|
**확인** — 실제로 끊기는 순간을 보고 싶으면 기다린다. **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
|
||||||
|
```
|
||||||
|
그 시간이 지난 뒤 app1 을 새로고침하면 로그인 화면으로 떨어진다.
|
||||||
|
|
||||||
|
## 4-7. 그래서 SSO 의 대가는 무엇인가
|
||||||
|
|
||||||
|
원래 질문에 대한 답이다.
|
||||||
|
|
||||||
|
| | 앱이 하나일 때 | **SSO 일 때** |
|
||||||
|
|---|---|---|
|
||||||
|
| 로그인 | 앱마다 | **한 번** |
|
||||||
|
| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** |
|
||||||
|
| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) |
|
||||||
|
| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** |
|
||||||
|
| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 |
|
||||||
|
|
||||||
|
**IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일
|
||||||
|
장애점이 아니다.** A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
|
||||||
|
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
|
||||||
|
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
|
||||||
|
```
|
||||||
|
|
||||||
|
**장애가 즉시 전면화되지 않고 「앱 세션 수명만큼 지연되어 몰려온다」.**
|
||||||
|
이것이 SSO 구조의 장애 모양이고, **모니터링이 어려운 이유**다.
|
||||||
|
|
||||||
|
**그리고 마지막 줄이 다음 실험을 부른다** — 전 앱을 끊으려면 백채널
|
||||||
|
로그아웃이 필요하다. **그게 되는지는 [C-2](c2-backchannel-logout.md) 가 잰다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 세션을 정리한다
|
||||||
|
|
||||||
|
**하기** — 1-3 과 같은 절차
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
**그냥 둬도 된다.** 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고,
|
||||||
|
IdP 세션은 이미 없다. **정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.**
|
||||||
|
|
||||||
|
## 5-2. 브라우저 쿠키를 지운다
|
||||||
|
|
||||||
|
**하기** — `auth.hyeonworks.com`·`app1`·`app2` 의 쿠키를 지우거나
|
||||||
|
**시크릿 창을 새로 연다.**
|
||||||
|
|
||||||
|
**왜** — 서버 세션을 다 지워도 **브라우저에 낡은 쿠키가 남는다.**
|
||||||
|
다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 이것이다.
|
||||||
|
|
||||||
|
## 5-3. 빌린 것을 돌려준다
|
||||||
|
|
||||||
|
**app2 는 Grafana 의 이름이다.** C-2 를 이어서 하지 않을 거라면 지금 되돌린다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||||
|
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||||
|
|
||||||
|
> **[C-2](c2-backchannel-logout.md) 를 이어서 할 생각이면 아직 돌려주지 않는다.**
|
||||||
|
> C-2 가 두 앱을 그대로 쓴다. **그 대신 C-2 가 끝난 뒤에 반드시 복구한다.**
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| Keycloak 세션 | 1-4 의 realm 조인 쿼리 | `keycloak-patterns` **0** (`master` 는 있을 수 있다) |
|
||||||
|
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
|
||||||
|
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
|
||||||
|
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것** — 4-6 의 「언제 끊기는가」를 **실제로 기다려서
|
||||||
|
> 확인하지 않았다.** IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고
|
||||||
|
> app1 을 새로고침하면 `Session not active` 가 나와야 한다. 재려면 그렇게 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `logout-all` 이 오류 없이 아무 일도 안 한다 | **캐시.** DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 + `rollout restart` — 1-3 |
|
||||||
|
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 — 4-2 |
|
||||||
|
| 기준선 세션이 0 이 아니라 4 다 | **원래 실행도 4 였다.** 해설의 `0` 은 정정됐다 | 1-2 의 정정 박스 |
|
||||||
|
| 로그아웃했는데 세션이 1 남았다 | **`master` 의 admin 세션이다.** 당신이 `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 — 1-4·4-3 |
|
||||||
|
| `kcadm` 이 전부 `401` | **재시작으로 kcadm 세션이 날아갔다** | `config credentials` 를 다시 — 1-2 |
|
||||||
|
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 — 4-2 |
|
||||||
|
| app2 에서 로그인 화면이 뜬다 | **다른 브라우저·시크릿 창이다.** SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 — 2-3 |
|
||||||
|
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 1-1~1-2 |
|
||||||
|
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 3-2 |
|
||||||
|
| Redis 를 비웠더니 app1 도 끊겼다 | **`flushall` 은 BFF 세션도 지운다** | 기준선에서만 쓴다 — 1-3 |
|
||||||
|
| 스크린샷 두 장이 똑같다 | **실제로 같은 파일이다.** 조작이 아니다 | 구별은 터미널 출력이 한다 — 4-5 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | C-1 이 남긴 질문 |
|
||||||
|
|---|---|
|
||||||
|
| [C-2](c2-backchannel-logout.md) 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 안 퍼진다. **원인은 아무도 구현하지 않았기 때문이다** |
|
||||||
|
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | `user_session` 을 잃으면 **전 앱이 끊긴다.** 백업 범위에 들어간다 |
|
||||||
|
| [A-3](a3-database-crash.md) DB 크래시 | 여기서 본 두 층 구조가 거기서 「전체 소실 vs 일부 소실」로 갈렸다 |
|
||||||
|
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
# C-2 재현 가이드 — 로그아웃이 왜 안 퍼지는지 양쪽에서 확인한다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md) ·
|
||||||
|
증거 원문: [`docs/evidence/c2-backchannel-logout/`](../../evidence/c2-backchannel-logout/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 두 클라이언트 어디에도 `backchannel.logout.url` 이 없는 것 | `kcadm get clients` |
|
||||||
|
| BFF 소스에 `oidcLogout` 이 한 줄도 없는 것 | `grep -rn` |
|
||||||
|
| 후보 경로 셋이 전부 `302` 인 것 — **핸들러가 없다는 뜻** | `curl` |
|
||||||
|
| **IdP 쪽만 설정해도 앱 세션이 그대로 남는 것** | Redis |
|
||||||
|
| Keycloak 이 앱 공개 URL 에 `200` 으로 **닿는** 것 | 임시 curl 파드 |
|
||||||
|
| 로그에 `backchannel` 이 **0줄**인 것, 그리고 그것으로 단정하면 안 되는 이유 | Keycloak 로그 |
|
||||||
|
| **끊을 세션이 없는 상태로 시험해 무의미해지는 것** | 로그아웃 전 세션 수 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`C-1`](c1-multi-app-sso.md) 이 끝나 있다. app1(BFF)·app2(oauth2-proxy)가
|
||||||
|
둘 다 살아 있고, **IdP 로그아웃이 앱에 전파되지 않는다**를 이미 관측했다.
|
||||||
|
이 실험은 **그 원인을 찾는** 실험이다.
|
||||||
|
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다. 끝나면 되돌린다 —
|
||||||
|
[5-3](#5-3-빌린-것을-돌려준다).
|
||||||
|
- **BFF 소스 트리**(`bff/src/main/java/`)를 볼 수 있어야 한다. 1-2 가 그것을 읽는다.
|
||||||
|
- **브라우저가 필요하다.** 살아 있는 세션을 만들어야 시험이 성립한다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). `kcadm.sh` 는
|
||||||
|
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.** 도달성 시험은
|
||||||
|
**임시 curl 파드**로 한다.
|
||||||
|
- 이 실험대에는 **`jq` 가 없다.**
|
||||||
|
|
||||||
|
## 주의 — 이건 클라이언트 설정을 바꾸는 실험이다
|
||||||
|
|
||||||
|
`bff-confidential` 클라이언트의 **`attributes` 를 통째로 교체한다.**
|
||||||
|
JSON 으로 주는 방식이라 **기존 속성이 같이 날아갈 수 있다.**
|
||||||
|
그래서 [2-1](#2-1-지금-attributes-를-먼저-저장해-둔다) 의 첫 명령이 백업이다.
|
||||||
|
|
||||||
|
세션도 지운다. 실험대에서만 한다. 전 구간 약 20분.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:50–14:53 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||||
|
|
||||||
|
> **★ 출처 하나에 주의가 붙어 있다.** 해설 문서 2절이 인쇄한 「설정이 들어갔다」
|
||||||
|
> 확인 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||||
|
> 에서 나온 것이 **아니다.** 그 파일에는 **`command terminated with exit code 1`**
|
||||||
|
> 이 남아 있다 — 점 표기로 시도한 **실패한 첫 시도**다. 성공 출력은 그 뒤 별도로
|
||||||
|
> 실행한 조회에서 나왔다. **실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은
|
||||||
|
> 잘못이었고**, 이 가이드는 3-1 에서 그 둘을 갈라 적는다.
|
||||||
|
|
||||||
|
클라이언트 UUID·IP 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||||
|
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
[C-1](c1-multi-app-sso.md) 이 이것을 관측했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 안 퍼졌는지는 안 물었다.** 후보가 셋 있다.
|
||||||
|
|
||||||
|
| 후보 | 판정하는 법 |
|
||||||
|
|---|---|
|
||||||
|
| ① IdP 에 **보낼 주소**가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
|
||||||
|
| ② 앱에 **받을 엔드포인트**가 없다 | 소스와 실제 경로를 본다 |
|
||||||
|
| ③ IdP 가 앱에 **못 닿는다** (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 예상 | 셋 중 하나가 원인일 것 |
|
||||||
|
| **실측** | **①과 ②가 둘 다 없었다.** ③은 문제가 아니었다(`HTTP 200`) |
|
||||||
|
|
||||||
|
**C-1 이 관측한 「로그아웃이 안 퍼진다」의 원인은 단순했다 — 아무도
|
||||||
|
구현하지 않았다.**
|
||||||
|
|
||||||
|
그리고 이 실험이 실제로 증명하는 것은 그 다음이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
①만 고친다 → 여전히 안 퍼진다
|
||||||
|
```
|
||||||
|
|
||||||
|
**양쪽이 다 있어야 동작한다.** 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는
|
||||||
|
것이 이 주제에서 가장 흔한 실패다. 이 가이드는 **그 실패를 일부러 재현한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 어느 쪽에도 없다
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. IdP 쪽 — 클라이언트 속성을 본다
|
||||||
|
|
||||||
|
**kcadm 을 먼저 로그인시킨다.**
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```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 exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
|
||||||
|
```
|
||||||
|
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||||
|
```
|
||||||
|
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
|
||||||
|
--- bff-confidential ---
|
||||||
|
"frontchannelLogout" : false,
|
||||||
|
--- oauth2-proxy ---
|
||||||
|
"frontchannelLogout" : false,
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **있는 것이 아니라 없는 것을 본다.**
|
||||||
|
`backchannel.logout.url` 이 목록에 **없다.** 나온 것은 `frontchannelLogout` 뿐이다.
|
||||||
|
|
||||||
|
> **「없다」를 확인하는 법.** `grep backchannel` 로 걸러서 빈 출력을 보면
|
||||||
|
> 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 —
|
||||||
|
> B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다.
|
||||||
|
> **`--fields attributes` 로 통째로 받아 눈으로 훑는다.**
|
||||||
|
> 다른 값(`frontchannelLogout`)이 보이는 것이 「명령은 먹었다」의 증거다.
|
||||||
|
|
||||||
|
## 1-2. 앱 쪽 — 소스에 받을 자리가 있는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
||||||
|
```
|
||||||
|
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||||
|
```
|
||||||
|
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **아무것도 안 나온다.** 헤더 아래가 비어 있다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Spring Security 6.2+ 는 백채널 로그아웃을
|
||||||
|
**지원하지만 명시적으로 켜야 한다.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
|
||||||
|
```
|
||||||
|
|
||||||
|
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
|
||||||
|
**생기지 않는다.** 소스에 없으니 경로도 없다.
|
||||||
|
|
||||||
|
> `grep` 이 빈 출력을 줄 때는 **경로가 맞는지 먼저 의심한다.**
|
||||||
|
> `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를
|
||||||
|
> 뒤져도 `grep` 은 조용히 0건을 준다.
|
||||||
|
|
||||||
|
## 1-3. 소스 말고 **실제로** 그 경로가 있는지 친다
|
||||||
|
|
||||||
|
**소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다.** 직접 친다.
|
||||||
|
|
||||||
|
**확인** — 먼저 응답을 통째로 한 번 본다
|
||||||
|
```bash
|
||||||
|
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 상태줄과 `Location` 헤더. **302 라면 어디로 보내는가.**
|
||||||
|
로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고,
|
||||||
|
**그런 핸들러가 없어서 기본 규칙에 걸린 것**이다.
|
||||||
|
|
||||||
|
이제 후보 셋을 나란히 잰다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||||
|
```
|
||||||
|
=== 실제로 그 경로가 있는가 ===
|
||||||
|
/logout/connect/back-channel/keycloak HTTP 302
|
||||||
|
/backchannel-logout HTTP 302
|
||||||
|
/oauth2/sign_out HTTP 302
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **셋 다 302.**
|
||||||
|
|
||||||
|
| 응답 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `302` | **그런 핸들러가 없어서 인증 요구로 떨어졌다** |
|
||||||
|
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
|
||||||
|
| `404` | 라우팅 자체가 없다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 302 는 **「없다」의 증거**다. 엔드포인트가 있었다면
|
||||||
|
POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다.
|
||||||
|
|
||||||
|
**후보 ②가 확정됐다.** ①은 1-1 에서 확정됐다.
|
||||||
|
|
||||||
|
## 1-4. ★ 끊을 세션이 있기는 한가 — 이걸 안 보면 실험이 무의미해진다
|
||||||
|
|
||||||
|
**원래 실행이 여기서 한 번 헛돌았다.**
|
||||||
|
|
||||||
|
**확인** — C-1 에서 배운 대로 **realm 을 조인해서** 센다
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||||
|
```
|
||||||
|
=== 로그아웃 전 상태 ===
|
||||||
|
Redis: 2 키
|
||||||
|
keycloak-patterns 세션: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **IdP 세션이 0 이다.** Redis 에는 키가 2개 있는데
|
||||||
|
Keycloak 쪽은 비어 있다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **이 상태에서 로그아웃을 걸면 아무 일도 안 난다.**
|
||||||
|
끊을 대상이 없기 때문이다. 그리고 「앱 세션이 그대로다」를 보고
|
||||||
|
**「전파가 안 되는구나」로 결론지을 뻔했다.**
|
||||||
|
|
||||||
|
> **★ 이것이 이 실험에서 가장 빠지기 쉬운 함정이다.** 주입은 정상적으로
|
||||||
|
> 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. **틀린 것은 전제뿐이다.**
|
||||||
|
> A층 내내 반복한 교훈 — **주입 대상이 실제로 존재하는지 먼저 확인한다.**
|
||||||
|
|
||||||
|
**하기** — 그러니 세션을 만든다. 브라우저에서
|
||||||
|
```
|
||||||
|
https://app1.hyeonworks.com/ → labuser / labpass
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 다시 센다
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt) 의 두 번째 시험
|
||||||
|
```
|
||||||
|
=== 로그아웃 전 — 실제 세션이 있는가 ===
|
||||||
|
keycloak-patterns 세션: 1
|
||||||
|
Redis: 1 키
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **세션 수가 1 이상.** 여기서 0 이면 로그인이 안 된 것이다.
|
||||||
|
**0 인 채로 2절로 넘어가지 않는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — IdP 쪽에만 설정한다
|
||||||
|
|
||||||
|
**의도적으로 한쪽만 고친다.** 「①만 있으면 되는가」가 이 실험의 질문이다.
|
||||||
|
|
||||||
|
## 2-1. 지금 attributes 를 먼저 저장해 둔다
|
||||||
|
|
||||||
|
**되돌리기가 이 백업에 달렸다.** JSON 으로 통째로 넣는 방식이라 기존 속성이
|
||||||
|
덮인다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
CID=$(sudo 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"
|
||||||
|
|
||||||
|
sudo 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
|
||||||
|
```
|
||||||
|
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||||
|
```
|
||||||
|
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
|
||||||
|
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `echo "$CID"` 가 **UUID 한 줄**인가.
|
||||||
|
비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고,
|
||||||
|
그 상태로 다음 명령을 치면 **엉뚱한 클라이언트를 고친다.**
|
||||||
|
|
||||||
|
> 이 UUID 는 [C-1 3-2](c1-multi-app-sso.md#3-2-어느-클라이언트가-붙었는가) 에서
|
||||||
|
> `bff-confidential` 로 확인한 바로 그 값이다(`9055fa46-…`).
|
||||||
|
> **당신 환경의 값은 다르다. 위 명령이 뽑아 준다.**
|
||||||
|
|
||||||
|
## 2-2. ★ 점 표기는 안 먹는다
|
||||||
|
|
||||||
|
**하기** — 원래 실행이 처음에 친 것. **실패한다**
|
||||||
|
```bash
|
||||||
|
sudo 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"
|
||||||
|
```
|
||||||
|
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||||
|
```
|
||||||
|
command terminated with exit code 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **종료코드 1.** 이건 조용한 실패가 **아니다** —
|
||||||
|
실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려
|
||||||
|
**「왜」는 안 보인다.**
|
||||||
|
|
||||||
|
**왜 안 되나** — 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`)
|
||||||
|
`kcadm` 의 점 표기와 충돌한다. **JSON 으로 통째로 준다.**
|
||||||
|
|
||||||
|
## 2-3. JSON 으로 넣는다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo 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"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 2-1 의 백업을 보고 원래 값으로 다시 `update` 한다.
|
||||||
|
백업이 `{ }` 처럼 비어 있었다면
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. 설정이 실제로 들어갔는가
|
||||||
|
|
||||||
|
**확인** — 1-1 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||||
|
```
|
||||||
|
**실측** — 해설 문서 2절이 인쇄한 값. **위 「출처 주의」가 붙는 자리다**
|
||||||
|
```
|
||||||
|
backchannel.logout.session.required = true
|
||||||
|
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 속성이 **둘 다** 있는가.
|
||||||
|
`url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
|
||||||
|
|
||||||
|
> **이 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||||
|
> 에 없다.** 그 파일은 2-2 의 실패로 끝나고, 위 값은 **그 뒤 별도로 실행한
|
||||||
|
> 조회**에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 자리이므로
|
||||||
|
> **당신은 지금 직접 재 두는 편이 낫다.**
|
||||||
|
|
||||||
|
## 3-2. 이 시점의 앱 상태를 적어 둔다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||||
|
```
|
||||||
|
=== 로그인 상태를 만든다 ===
|
||||||
|
(브라우저에 이미 세션이 있다)
|
||||||
|
Keycloak 세션: 2
|
||||||
|
Redis: 2 키
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키 이름을 그대로 적어 둔다.** 4-3 에서 **글자 하나까지
|
||||||
|
같은지**를 볼 것이다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 관찰 — 로그아웃을 걸고 앱 세션을 본다
|
||||||
|
|
||||||
|
## 4-1. 시각을 적고 로그아웃한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 로그아웃'
|
||||||
|
USERID=$(sudo 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"
|
||||||
|
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
create "users/$USERID/logout" -r keycloak-patterns
|
||||||
|
```
|
||||||
|
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||||
|
```
|
||||||
|
=== ★ IdP 로그아웃 → 백채널 알림 ===
|
||||||
|
시각: 14:54:21
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각이 필요한 이유** — 뒤에서 로그를 뒤질 때 **「이 순간 전후」로 좁히기
|
||||||
|
위해서**다. `--since` 만으로는 어느 시도인지 안 갈린다.
|
||||||
|
|
||||||
|
## 4-2. IdP 쪽은 끊겼는가
|
||||||
|
|
||||||
|
**확인** — 1-4 와 같은 쿼리
|
||||||
|
```bash
|
||||||
|
sudo 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'"
|
||||||
|
```
|
||||||
|
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||||
|
```
|
||||||
|
=== IdP 세션은 실제로 끊겼는가 ===
|
||||||
|
keycloak-patterns 세션: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **0.** 로그아웃 자체는 동작했다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **주입은 성공했다.** 이제 앱 쪽을 볼 자격이 생겼다.
|
||||||
|
여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 남아 있어도 그건 당연한
|
||||||
|
결과라 아무것도 판정하지 못한다.
|
||||||
|
|
||||||
|
## 4-3. ★ 앱 세션은 그대로다
|
||||||
|
|
||||||
|
**확인** — 3-2 와 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||||
|
```
|
||||||
|
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||||
|
```
|
||||||
|
=== 앱 세션이 정리되었는가 ===
|
||||||
|
Redis: 2 키
|
||||||
|
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||||
|
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||||
|
```
|
||||||
|
그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도
|
||||||
|
```
|
||||||
|
=== 앱 세션 ===
|
||||||
|
Redis: 1 키
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **개수도 이름도 그대로다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **IdP 쪽만 설정해도 소용없다.**
|
||||||
|
①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과다.
|
||||||
|
|
||||||
|
## 4-4. 로그에 흔적이 있는가 — 그리고 그것으로 무엇을 말할 수 있는가
|
||||||
|
|
||||||
|
**확인** — Keycloak 양쪽 노드
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
|
||||||
|
```
|
||||||
|
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||||
|
```
|
||||||
|
=== Keycloak 로그 전체에서 backchannel 흔적 ===
|
||||||
|
keycloak-0: 0 줄
|
||||||
|
keycloak-1: 0 줄
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 앱 쪽
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
|
||||||
|
```
|
||||||
|
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||||
|
```
|
||||||
|
=== BFF 로그 — 백채널 요청이 도착했는가 ===
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 양쪽 다 비어 있다.
|
||||||
|
|
||||||
|
**★ 그런데 여기서 결론을 넓히면 안 된다.**
|
||||||
|
|
||||||
|
| 이 출력이 말하는 것 | 말하지 않는 것 |
|
||||||
|
|---|---|
|
||||||
|
| 로그에 `backchannel` 문자열이 없다 | **Keycloak 이 요청을 안 보냈다** |
|
||||||
|
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
|
||||||
|
|
||||||
|
**로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다.** 「0줄」은 「안 보냈다」의
|
||||||
|
증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다.
|
||||||
|
|
||||||
|
> **확실한 것은 앱 세션이 남았다는 관측이다.** 그것은 직접 봤다(4-3).
|
||||||
|
> **관측한 것과 추론한 것을 섞지 않는다.** 로그 0줄을 근거로 「Keycloak 이
|
||||||
|
> 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때
|
||||||
|
> 결론 전체의 신뢰가 무너진다.
|
||||||
|
|
||||||
|
## 4-5. 네트워크 문제인가 — 후보 ③을 판정한다
|
||||||
|
|
||||||
|
**앱 세션이 안 지워지는 이유가 「요청이 못 닿아서」일 수도 있다.**
|
||||||
|
그러면 구현이 아니라 네트워크를 고쳐야 한다. **갈라야 한다.**
|
||||||
|
|
||||||
|
Keycloak 파드에는 `curl` 이 없으므로 **같은 네임스페이스에 임시 파드**를 띄운다.
|
||||||
|
|
||||||
|
**하기** — **미검증** (원래 실행의 명령 원문은 기록에 없다. 출력은 실측이다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --command -- sh
|
||||||
|
```
|
||||||
|
|
||||||
|
파드 안에서
|
||||||
|
```sh
|
||||||
|
nslookup app1.hyeonworks.com
|
||||||
|
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||||||
|
```
|
||||||
|
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||||
|
```
|
||||||
|
=== ★ 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 이 연결조차 못 했다 = **네트워크가 원인** |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **후보 ③은 원인이 아니다.**
|
||||||
|
네트워크는 열려 있고, 그래도 세션은 남았다.
|
||||||
|
|
||||||
|
`exit` 으로 파드에서 나온다. `--rm` 이 지워 준다.
|
||||||
|
|
||||||
|
> **임시 파드는 Keycloak 파드의 완전한 대역이 아니다.** 같은 네임스페이스라
|
||||||
|
> DNS 와 대체로 같은 경로를 타지만, **NetworkPolicy 나 사이드카가 걸려 있으면
|
||||||
|
> 결과가 갈릴 수 있다.** 이 실험대에는 그런 것이 없어서 대역이 성립했다.
|
||||||
|
> 확인: `sudo kubectl -n keycloak-lab get networkpolicy` 가 비어 있는가.
|
||||||
|
|
||||||
|
**그리고 이 200 은 이 실험대의 특수 사정이다.**
|
||||||
|
|
||||||
|
이 실험대는 **tailnet + split DNS** 구성이라 클러스터 안에서 공개 이름을
|
||||||
|
불러도 되돌아온다(헤어핀). **운영에서는 안 되는 경우가 흔하다.**
|
||||||
|
|
||||||
|
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
|
||||||
|
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
|
||||||
|
> **설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.**
|
||||||
|
|
||||||
|
## 4-6. 그래서 왜 안 퍼졌는가
|
||||||
|
|
||||||
|
```
|
||||||
|
IdP 로그아웃
|
||||||
|
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (2절에서 설정함)
|
||||||
|
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 (1-2·1-3)
|
||||||
|
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**②와 ③이 없다. ①만 설정해도 받을 사람이 없다.**
|
||||||
|
|
||||||
|
이것이 이 실험의 결론이고, **「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서
|
||||||
|
확인한 것**이 이 가이드의 값이다.
|
||||||
|
|
||||||
|
## 4-7. 개념 — 백채널 로그아웃의 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자가 어느 앱에서든 로그아웃
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
|
||||||
|
│
|
||||||
|
├──POST──▶ app1 의 backchannel.logout.url
|
||||||
|
└──POST──▶ app2 의 backchannel.logout.url
|
||||||
|
본문: logout_token (JWT)
|
||||||
|
{ "sid": "...", "sub": "...", "events": {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sid` 가 여기서 쓰인다
|
||||||
|
|
||||||
|
**무엇인가.** `sid` 는 Keycloak 의 user session 식별자다.
|
||||||
|
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** logout token 에 실려 오는 것이 `sid` 이고,
|
||||||
|
**앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
|
||||||
|
```
|
||||||
|
|
||||||
|
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
|
||||||
|
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 엔드포인트가 있어도 **어느 세션을 지울지 모른다.**
|
||||||
|
그리고 **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다** —
|
||||||
|
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** BFF 는 replica 2개다.
|
||||||
|
|
||||||
|
### 부분 실패는 어떻게 되는가
|
||||||
|
|
||||||
|
```
|
||||||
|
app1 로그아웃 성공, app2 는 응답 없음
|
||||||
|
└─ Keycloak 은 재시도하는가? 얼마나?
|
||||||
|
└─ 사용자는 app2 에서 여전히 로그인 상태다
|
||||||
|
```
|
||||||
|
|
||||||
|
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 「일부만 로그아웃된 상태」가
|
||||||
|
생길 확률이 올라간다. **이 실험은 그 재시도 동작을 측정하지 않았다.**
|
||||||
|
|
||||||
|
## 4-8. 구현하려면 무엇이 필요한가
|
||||||
|
|
||||||
|
| 계층 | 할 일 | 이 실험대의 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 | **완료** (2절) |
|
||||||
|
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
|
||||||
|
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿) | 없음 |
|
||||||
|
| **네트워크** | IdP → 앱 공개 URL 도달 | **됨** (4-5). 운영은 확인 필요 |
|
||||||
|
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | — |
|
||||||
|
|
||||||
|
**마지막 줄이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
|
||||||
|
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게
|
||||||
|
동작하게 된다.** C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데,
|
||||||
|
**그 공유가 로그아웃까지는 안 간다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 클라이언트 속성을 되돌린다
|
||||||
|
|
||||||
|
**하기** — 2-1 의 백업을 먼저 읽는다
|
||||||
|
```bash
|
||||||
|
cat ~/c2-bff-attributes-backup.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 원래 무엇이 있었는가. 비어 있었으면 빈 객체로,
|
||||||
|
값이 있었으면 그 값으로 되돌린다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 1-1 과 같은 명령으로 사라졌는지 본다.
|
||||||
|
|
||||||
|
> **그대로 둬도 무방하다.** 받을 엔드포인트가 없으므로 이 설정 하나로는
|
||||||
|
> 아무 일도 안 일어난다 — **그게 이 실험의 결론이었다.**
|
||||||
|
> 다만 나중에 앱을 고쳤을 때 **왜 갑자기 동작하는지 모르게 되므로**,
|
||||||
|
> 실험이 남긴 설정이라는 것을 기억하거나 지운다.
|
||||||
|
|
||||||
|
## 5-2. 세션을 정리한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||||
|
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저 쿠키(`auth`·`app1`·`app2`)도 지우거나 시크릿 창을 새로 연다.
|
||||||
|
|
||||||
|
## 5-3. 빌린 것을 돌려준다
|
||||||
|
|
||||||
|
**app2 는 Grafana 의 이름이다.** C 층이 끝났으면 여기서 되돌린다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||||
|
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||||
|
백업 파일이 없으면 [B-7 1-1](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다)
|
||||||
|
을 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.**
|
||||||
|
|
||||||
|
oauth2-proxy 배포까지 걷어내려면
|
||||||
|
```bash
|
||||||
|
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-4. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 클라이언트 속성 | 1-1 의 `get clients … --fields attributes` | `backchannel.logout.url` 이 **없다** (지웠다면) |
|
||||||
|
| Keycloak 세션 | 1-4 의 realm 조인 카운트 | `0` |
|
||||||
|
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
|
||||||
|
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod c2probe` | `NotFound` (없어야 정상) |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` |
|
||||||
|
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||||
|
|
||||||
|
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
> **이 실험이 재지 않은 것**
|
||||||
|
> · ②·③을 실제로 **구현한 뒤** 전파가 되는지 — 코드를 고쳐야 한다
|
||||||
|
> · Keycloak 이 요청을 보내기는 했는지 (DEBUG 로그를 켜지 않았다)
|
||||||
|
> · 부분 실패 시 **재시도 정책** (4-7)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| **로그아웃했는데 아무 변화가 없다** | **로그아웃 전 세션이 이미 0 이었다** | realm 조인해서 먼저 센다 — 1-4 |
|
||||||
|
| `kcadm -s "attributes.backchannel.logout.url=…"` 이 `exit 1` | **점 표기가 안 먹는다** | JSON 으로 통째로 — 2-2·2-3 |
|
||||||
|
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 **통째로 교체**한다 | 먼저 백업 — 2-1 |
|
||||||
|
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 — 2-1 |
|
||||||
|
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다 — 1-4 (C-1 과 같은 실수) |
|
||||||
|
| `grep -rn … bff/src/main/java/` 가 빈 출력 | **정말 없거나**, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 — 1-2 |
|
||||||
|
| 후보 경로가 `404` 가 아니라 `302` | **핸들러가 없어 인증 요구로 떨어진 것** | 302 도 「없다」의 신호다 — 1-3 |
|
||||||
|
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | **DEBUG 레벨이면 안 찍힌다** | 판정 근거로 쓰지 않는다 — 4-4 |
|
||||||
|
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | **운영에서는 그게 정상일 수 있다** — 4-5 |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 — 4-5 |
|
||||||
|
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 — 1-1 |
|
||||||
|
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | **인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다** | 새 탭에서 주소부터 다시 연다 |
|
||||||
|
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 5-3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| | C-2 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` **공유 저장소** (BFF replica 2개) |
|
||||||
|
| **oauth2-proxy** | 백채널 로그아웃 미지원 — **SSO 안에서 앱마다 동작이 갈린다** |
|
||||||
|
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** — 로그도 안 남는다 |
|
||||||
|
| [C-1](c1-multi-app-sso.md) 과 연결 | 두 앱이 user session 을 공유한다. **그 공유가 로그아웃까지는 안 간다** |
|
||||||
|
| [B-2](../../experiment-b2-multi-instance-session.md) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 |
|
||||||
|
| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 |
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
# D-1 재현 가이드 — 스키마를 통째로 지우고 백업이 진짜 백업인지 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md) ·
|
||||||
|
증거 원문: [`docs/evidence/d1-backup-restore/`](../../evidence/d1-backup-restore/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 덤프 파일 안에 세션 행이 실제로 들어 있는 것 | `grep` |
|
||||||
|
| 데이터베이스를 통째로 비웠는데 정문이 `200` 인 것 | 밖에서 `curl` |
|
||||||
|
| 파드가 `1/1 Running` 인 채로 테이블이 0개인 것 | `get pods` · `psql` |
|
||||||
|
| `certs` 200 · `well-known` 500 · 토큰 400 으로 **부분만** 깨지는 것 | `curl` 세 번 |
|
||||||
|
| 복구가 1초 만에 오류 0건으로 끝나는 것 | `psql < 덤프` |
|
||||||
|
| 세션까지 되살아나는 것 | `offline_user_session` |
|
||||||
|
| **덤프가 DB 와 같은 기계 위에 놓여 있는 것** | `ls -l` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`A-2`](a2-database-loss.md) 를 먼저 하면 좋다. 「DB 프로세스가 죽었을 때」의
|
||||||
|
모양을 봐 둬야 이 실험의 `200` 이 얼마나 이상한지 안다.
|
||||||
|
- [`A-3`](a3-database-crash.md) 도 먼저다. RPO 의 두 번째 겹이 거기서 나온다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||||||
|
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||||||
|
- 네임스페이스는 `keycloak-lab` 이다.
|
||||||
|
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||||
|
- **덤프를 다른 기계로 옮기는 단계(5-6)만 호스트(`test-server`)가 필요하고,
|
||||||
|
호스트의 `sudo` 는 비밀번호를 묻는다.** 그 부분은 사람이 직접 친다.
|
||||||
|
|
||||||
|
## 주의 — 이건 데이터베이스를 비우는 실험이다
|
||||||
|
|
||||||
|
`DROP SCHEMA public CASCADE` 는 **realm·client·user·세션을 전부 지운다.**
|
||||||
|
되돌리는 수단은 당신이 방금 뜬 덤프 파일 **하나뿐**이다. 그래서 이 가이드는
|
||||||
|
**덤프를 검증하기 전에는 2절로 넘어가지 않는다.** 전 구간 약 20분이고,
|
||||||
|
파괴 구간 자체는 1분 안쪽으로 잡는다. 중간에 그만두려면
|
||||||
|
[5-1. 되돌린다](#5-1-되돌린다) 의 명령 하나면 된다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 14:57–15:00 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
파드 이름·세션 id·바이트 수는 **당신 환경에서 다르다.** 이 문서는
|
||||||
|
자리표시자(`<...>`)를 쓰지 않는 대신 그 값을 뽑는 명령을 먼저 적는다.
|
||||||
|
예시로 실린 값은 전부 위 실행 기록의 실제 값이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
「백업이 있다」와 「복구해 봤다」는 다른 문장이다. 백업 스크립트가 매일 도는
|
||||||
|
것과, 그 파일로 실제로 서비스를 되살릴 수 있는 것 사이에는 시험되지 않은
|
||||||
|
가정이 여러 개 있다.
|
||||||
|
|
||||||
|
이 실험은 그중 둘을 판정한다.
|
||||||
|
|
||||||
|
| # | 질문 | 어떻게 가르나 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① | 덤프에 **필요한 것이 다 들어가는가** | 특히 **세션**. 안 들어가면 복구 후 전원 재로그인이다 |
|
||||||
|
| ② | **복구 절차가 실제로 도는가** | 오류 없이 끝나고 데이터가 일치하는가 |
|
||||||
|
|
||||||
|
그리고 부수 질문이 하나 붙는다 — **DB 가 비면 무엇이 깨지는가.**
|
||||||
|
이게 A-2 와 대비되는 지점이고, 실제로 이 실험에서 가장 놀라운 결과가 나왔다.
|
||||||
|
|
||||||
|
```
|
||||||
|
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠짐
|
||||||
|
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ?
|
||||||
|
```
|
||||||
|
|
||||||
|
**커넥션은 되는데 테이블이 없는 상태**는 단일 장애 주입으로는 잘 안 만들어진다.
|
||||||
|
그래서 이 실험이 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 지우기 전에
|
||||||
|
|
||||||
|
**시험군만 재는 측정은 측정이 아니다.** 파괴 후에 볼 것을 파괴 전에 **똑같은
|
||||||
|
명령으로** 먼저 봐 둔다. 복구가 「완전 일치」인지 판정하려면 일치시킬 상대가
|
||||||
|
있어야 한다.
|
||||||
|
|
||||||
|
넓은 것부터 좁혀 간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
파드 → 데이터 개수 → 세션 → 밖에서 본 상태 → 덤프 → ★ 덤프 검증 → 덤프의 위치
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 파드가 다 떠 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
```
|
||||||
|
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt) 의
|
||||||
|
파괴 직후 목록이지만, 파괴 **전후가 같다**는 것이 이 실험의 결과이므로 기준선으로도 읽는다
|
||||||
|
```
|
||||||
|
bff-555df79c97-6j86w 1/1 Running 0 49m
|
||||||
|
bff-555df79c97-vgg6g 1/1 Running 0 49m
|
||||||
|
keycloak-0 1/1 Running 0 4m15s
|
||||||
|
keycloak-1 1/1 Running 0 4m38s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
- `READY` 가 전부 `1/1`
|
||||||
|
- **`RESTARTS` 가 `0`** — 뒤에서 이 값이 오르면 파괴가 엉뚱한 것을 건드린 것이다
|
||||||
|
- `postgres` 파드가 있는지, 어느 노드에 있는지
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 지금은 전부 정상이다. 이 표의 값을 적어 둔다.
|
||||||
|
**복구 판정에서 「재시작 없이 돌아왔는가」를 볼 때 `RESTARTS` 를 비교한다.**
|
||||||
|
|
||||||
|
## 1-2. 데이터가 얼마나 있나
|
||||||
|
|
||||||
|
**처음 한 번은 읽는 형태로 친다.** 값만 뽑는 형태부터 배우면 psql 이 무엇을
|
||||||
|
돌려주는지 모르게 된다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from realm"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
count
|
||||||
|
-------
|
||||||
|
2
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 하나와 `(1 row)`. 여기서 오류가 나면 뒤의 모든
|
||||||
|
단계가 무의미하다. `psql: error: connection to server ... failed` 면 DB 가 아직
|
||||||
|
안 붙은 것이고, `relation "realm" does not exist` 면 **이미 스키마가 없는 것**이다.
|
||||||
|
|
||||||
|
이제 다섯 개를 한 줄로 모은다. **비교할 값이 필요할 때만** 이 형태를 쓴다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select (select count(*) from realm), (select count(*) from client),
|
||||||
|
(select count(*) from user_entity),
|
||||||
|
(select count(*) from offline_user_session where offline_flag='0')"
|
||||||
|
```
|
||||||
|
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||||
|
```
|
||||||
|
realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||||
|
```
|
||||||
|
|
||||||
|
> **실측 줄에는 값이 다섯이고 위 명령은 넷을 뽑는다.** 원래 실행 스크립트는
|
||||||
|
> 「인가된 클라이언트(authclients)」를 하나 더 셌는데, 해설 문서의 재현 절차에는
|
||||||
|
> 그 쿼리가 남아 있지 않다. **없는 컬럼을 지어내지 않고 넷으로 둔다** — 판정에는
|
||||||
|
> 넷으로 충분하고, 다섯째가 필요하면 당신이 세는 쿼리를 정해서 **양쪽에 같이**
|
||||||
|
> 쓰면 된다.
|
||||||
|
>
|
||||||
|
> 손으로 치면 이름표 없이 `2|15|2|3` 만 나온다. `-tAc` 는 **헤더 없이(`-t`)
|
||||||
|
> 정렬 없이(`-A`) 한 줄만**이라는 뜻이고, 여러 값을 나란히 비교할 때 이 형태가
|
||||||
|
> 편하다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 넷. **이 줄을 그대로 복사해 둔다.**
|
||||||
|
복구 후에 같은 명령을 쳐서 **문자 단위로 같은지** 본다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 값들이 「복구가 성공했다」의 판정 조건이다.
|
||||||
|
하나라도 다르면 복구가 부분적으로만 된 것이다.
|
||||||
|
|
||||||
|
## 1-3. 세션이 DB 에 있나 — 이게 덤프에 들어갈지가 관건이다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select user_session_id, offline_flag, realm_id from offline_user_session"
|
||||||
|
```
|
||||||
|
**형태** — 행이 몇 개 있고 id 가 어떻게 생겼는지만 본다
|
||||||
|
```
|
||||||
|
user_session_id | offline_flag | realm_id
|
||||||
|
--------------------------+--------------+--------------------------------------
|
||||||
|
E1q5xI7tt4U_WhZpW7rEPIF2 | 0 | 7845f394-723a-4d07-b530-c7416b2e1d31
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **행이 0개면 안 된다.** 0개면 이 실험의 ①(세션이
|
||||||
|
덤프에 들어가는가)을 판정할 수 없다. 관리 콘솔에 한 번 로그인해서 세션을
|
||||||
|
만들고 다시 본다.
|
||||||
|
|
||||||
|
> **`offline_flag` 를 눈여겨본다.** 1-2 의 개수 쿼리는 `offline_flag='0'` 만
|
||||||
|
> 셌고, 이 쿼리는 전부 나열한다. **세는 쿼리와 나열하는 쿼리가 다른 것을
|
||||||
|
> 세고 있다** — 실제로 원래 실행에서도 개수는 `3`, 나열은 `4 rows` 였다
|
||||||
|
> ([`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)).
|
||||||
|
> 두 숫자가 다르다고 놀라지 말고, **복구 전후에 같은 쿼리끼리** 비교한다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 세션이 DB 테이블에 있다는 것은
|
||||||
|
`persistent-user-sessions` 가 켜져 있다는 뜻이다(A-0). **그래서 세션이 백업
|
||||||
|
대상이 된다.** volatile 이었다면 세션은 애초에 DB 에 없고, 복구해도 전원
|
||||||
|
재로그인이다 — 백업의 가치가 달라진다.
|
||||||
|
|
||||||
|
## 1-4. 밖에서 정상인가
|
||||||
|
|
||||||
|
**처음 한 번은 응답을 읽는다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -I https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
|
||||||
|
헤더가 통째로 나온다. `HTTP/2 200`, `content-type: application/json` 을 본다.
|
||||||
|
같은 것을 반복해서 재고 비교할 때만 코드만 뽑는다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
|
||||||
|
```
|
||||||
|
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||||
|
(이것도 **파괴 직후** 값이다 — 그게 결과다)
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||||
|
https://app1.hyeonworks.com/ HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 둘 다 `200`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 지금은 당연히 200 이다. **문제는 3절에서도 이
|
||||||
|
값이 200 이라는 것**이고, 그래서 이 두 줄은 「정상 판정에 쓸 수 없는 지표」의
|
||||||
|
예시로 남는다.
|
||||||
|
|
||||||
|
## 1-5. 백업을 뜬다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 백업 시작'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
|
||||||
|
--clean --if-exists > /tmp/keycloak-backup.sql
|
||||||
|
date '+%H:%M:%S 백업 완료'
|
||||||
|
```
|
||||||
|
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||||
|
```
|
||||||
|
시작: 14:59:30
|
||||||
|
완료: 14:59:30
|
||||||
|
크기: 394945 bytes (6956 줄)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 시각 두 줄과 파일 크기. 이 규모에서는 **1초 미만**이다.
|
||||||
|
|
||||||
|
### 개념 — `--clean --if-exists` 가 없으면 복구가 실패한다
|
||||||
|
|
||||||
|
| 옵션 | 무엇을 하나 | 없으면 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 | `already exists` 오류가 쏟아진다 |
|
||||||
|
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 안 낸다 | 깨끗한 DB 에 복구할 때 오류가 쏟아진다 |
|
||||||
|
|
||||||
|
**둘은 짝이다.** `--clean` 만 주면 「빈 DB 에 복구」가 깨지고, `--if-exists`
|
||||||
|
만 주면 아무 효과가 없다(DROP 문 자체가 안 만들어진다).
|
||||||
|
|
||||||
|
> **왜 이 실험에서는 어차피 빈 DB 인데 필요한가.** 이 실험은 `DROP SCHEMA` 로
|
||||||
|
> 완전히 비우고 복구하지만, **실제 사고는 대개 그렇지 않다.** 반쯤 남은 DB 에
|
||||||
|
> 덤프를 밀어 넣는 상황이 훨씬 흔하고, 그때 이 두 옵션이 있고 없고가 갈린다.
|
||||||
|
|
||||||
|
**되돌리기** — 이 단계는 읽기만 한다. 파일이 마음에 안 들면 지우고 다시 뜬다.
|
||||||
|
```bash
|
||||||
|
rm -f /tmp/keycloak-backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-6. ★ 덤프를 검증한다 — 여기를 건너뛰면 2절은 자살행위다
|
||||||
|
|
||||||
|
**「파일이 생겼다」는 「복구할 수 있다」가 아니다.** `pg_dump` 가 중간에
|
||||||
|
실패해도 파일은 남고, 크기도 0 이 아니다.
|
||||||
|
|
||||||
|
**확인 ①** 파일이 실제로 있고 크기가 말이 되는가
|
||||||
|
```bash
|
||||||
|
ls -l /tmp/keycloak-backup.sql
|
||||||
|
wc -l /tmp/keycloak-backup.sql
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
크기: 394945 bytes (6956 줄)
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ②** 테이블 정의가 다 들어갔는가
|
||||||
|
```bash
|
||||||
|
grep -c '^CREATE TABLE' /tmp/keycloak-backup.sql
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
포함된 테이블 수: 101
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 101 이라는 **절대값이 중요한 게 아니라**, 1-2 에서
|
||||||
|
본 DB 와 자릿수가 맞는지가 중요하다. 두 자리로 떨어지면 덤프가 잘린 것이다.
|
||||||
|
|
||||||
|
**확인 ③** 마지막 줄이 정상 종료인가
|
||||||
|
```bash
|
||||||
|
tail -3 /tmp/keycloak-backup.sql
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `dump complete`. **이 줄이 없으면 덤프가 중간에
|
||||||
|
끊긴 것이고, 그 파일로는 복구가 안 된다.** 이 한 줄이 「파일이 생겼다」와
|
||||||
|
「덤프가 끝났다」를 가른다.
|
||||||
|
|
||||||
|
**확인 ④** ★ 세션이 들어 있는가 — 이 실험의 질문 ①
|
||||||
|
```bash
|
||||||
|
grep -c 'offline_user_session' /tmp/keycloak-backup.sql
|
||||||
|
grep -A3 'COPY public.offline_user_session' /tmp/keycloak-backup.sql | cut -c1-110
|
||||||
|
```
|
||||||
|
**실측** — [`01-backup.txt`](../../evidence/d1-backup-restore/01-backup.txt)
|
||||||
|
```
|
||||||
|
offline_user_session 언급: 13
|
||||||
|
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
|
||||||
|
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr
|
||||||
|
2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `COPY` 줄 **다음에 실제 데이터 행이 붙어 있는가.**
|
||||||
|
`COPY ... FROM stdin;` 바로 뒤에 `\.` 만 있으면 **테이블 정의만 들어가고 행은
|
||||||
|
비어 있는 것**이다. 그건 세션을 백업하지 못한 덤프다.
|
||||||
|
|
||||||
|
> `cut -c1-110` 은 `data` 열의 JSON 이 화면을 뒤덮는 것을 막으려는 것이다.
|
||||||
|
> 처음 한 번은 `cut` 없이 쳐서 한 행이 얼마나 긴지 봐 둔다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **세션이 덤프에 들어간다.** 질문 ①의 답은
|
||||||
|
「들어간다」이며, 그 근거는 이 `COPY` 블록이다. 5-4 에서 이 id 들이 되살아나는
|
||||||
|
것을 확인한다.
|
||||||
|
|
||||||
|
## 1-7. ★ 덤프가 지금 어디에 있는가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ls -l /tmp/keycloak-backup.sql
|
||||||
|
df -h /tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 경로. `/tmp` 다. **이 파일은 지금 `kubectl` 을 친
|
||||||
|
그 기계의 디스크에 있다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — A-4 에서 **`local-path` PVC 가 노드에 못박혀
|
||||||
|
있는 것**을 봤다. 그 노드가 안 돌아오면 DB 볼륨도 안 돌아온다. 그때 유일한
|
||||||
|
길이 덤프인데, **덤프도 같은 기계에 있으면 같이 사라진다.**
|
||||||
|
|
||||||
|
> **같은 장애 도메인에 있는 백업은 백업이 아니다.**
|
||||||
|
> 원래 실행에서도 덤프는 `test-server:/tmp` 에 있었고, 해설 문서는 그것을
|
||||||
|
> **「가장 중요한 미검증 항목」**으로 기록했다. 옮기는 절차는 5-6 에 있다 —
|
||||||
|
> **파괴 전에 읽어만 두고, 실제 이동은 복구가 끝난 뒤에 한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 스키마를 통째로 지운다
|
||||||
|
|
||||||
|
여기부터 데이터가 사라진다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||||
|
|
||||||
|
**되돌리기** (5절에서 자세히 한다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
< /tmp/keycloak-backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 명령이 유일한 되돌리기 수단이다.** 1-6 의 확인 ①~④ 를 통과하지 않았으면
|
||||||
|
지금 돌아가서 한다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 파괴'
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
```
|
||||||
|
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||||
|
```
|
||||||
|
=== ★ 파괴 — 스키마를 통째로 지운다 ===
|
||||||
|
시각: 14:59:47
|
||||||
|
DROP SCHEMA
|
||||||
|
CREATE SCHEMA
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `DROP SCHEMA` 와 `CREATE SCHEMA` 두 줄. `NOTICE:
|
||||||
|
drop cascades to 101 other objects` 같은 줄이 함께 나오는 것이 정상이다.
|
||||||
|
|
||||||
|
**시각을 반드시 적어 둔다.** 5-5 의 RTO 는 이 시각에서 시작한다.
|
||||||
|
|
||||||
|
> **왜 `CREATE SCHEMA public` 을 붙이나.** `public` 스키마 자체를 지우면
|
||||||
|
> 복구 스크립트가 들어갈 자리가 없다. 지우는 것은 **안의 객체**이고,
|
||||||
|
> 빈 스키마는 남겨 둬야 `pg_dump` 출력이 그대로 들어간다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**결과를 해석하기 전에, 의도한 것만 지워졌는지 먼저 본다.**
|
||||||
|
|
||||||
|
## 3-1. 테이블이 0개인가
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from pg_tables where schemaname='public'"
|
||||||
|
```
|
||||||
|
**미검증** — 원래 실행은 스크립트로 셌다. 그 결과는 이렇다.
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
남은 테이블: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `0`. 여기서 101 이 그대로 나오면 `DROP` 이 다른
|
||||||
|
데이터베이스에 걸린 것이다(`-d` 인자를 본다).
|
||||||
|
|
||||||
|
**확인** — 애플리케이션 테이블이 정말 없는지 직접 물어본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from realm"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
ERROR: relation "realm" does not exist
|
||||||
|
LINE 1: select count(*) from realm
|
||||||
|
^
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **커넥션은 성립하고 SQL 도 파싱된다. 테이블만
|
||||||
|
없다.** 이 구별이 이 실험의 전부다. A-2 에서는 여기가
|
||||||
|
`connection to server ... failed` 였다.
|
||||||
|
|
||||||
|
## 3-2. ★ 그런데 밖은 멀쩡하다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
|
||||||
|
```
|
||||||
|
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||||
|
https://app1.hyeonworks.com/ HTTP 200
|
||||||
|
keycloak-0 1/1 Running 0 4m15s
|
||||||
|
keycloak-1 1/1 Running 0 4m38s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `1/1`, `RESTARTS 0`, 그리고 **`200`**.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **데이터베이스가 통째로 비었는데 정문이 200 이다.**
|
||||||
|
|
||||||
|
여기서 「파괴가 실패했다」고 읽으면 틀린다. 3-1 에서 테이블이 0개인 것을
|
||||||
|
이미 봤다. 파괴는 성공했고, **관측 지점이 그것을 못 보는 것**이다.
|
||||||
|
|
||||||
|
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
|
||||||
|
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다). 캐시는 읽을 때 DB 와
|
||||||
|
대조하지 않는다 — A-1 에서 로그아웃한 세션이 반대편에서 `200` 을 받았던 것과
|
||||||
|
**같은 성질**이다.
|
||||||
|
|
||||||
|
## 3-3. 엉뚱한 것을 죽이지 않았나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **ready 주소가 여전히 둘.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **아무 파드도 Service 에서 빠지지 않았다.**
|
||||||
|
A-2 에서는 여기가 빈 목록이었다. readiness 프로브가 통과하고 있다는 뜻이고,
|
||||||
|
그 이유는 4-3 에서 본다.
|
||||||
|
|
||||||
|
> `kubectl get endpoints` 는 v1.33+ 에서 deprecated 다. 실제로 이 실험대에서
|
||||||
|
> 그 경고를 봤다 — 4절 「막히면」 표에 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 무엇이 깨지고 무엇이 안 깨지나
|
||||||
|
|
||||||
|
**전부 깨지지 않는다.** 세 경로를 나눠서 친다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'certs %{http_code}\n' \
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
|
||||||
|
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||||
|
```
|
||||||
|
/.well-known/openid-configuration HTTP 500
|
||||||
|
/protocol/openid-connect/certs HTTP 200
|
||||||
|
토큰 발급 (DB 쓰기 필요) HTTP 400
|
||||||
|
```
|
||||||
|
|
||||||
|
토큰 발급은 값이 필요하므로 따로 친다. **미검증** — 원래 실행은 스크립트였다
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '토큰 %{http_code}\n' -X POST \
|
||||||
|
https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin \
|
||||||
|
-d "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
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 값이 **다 다르다**는 것.
|
||||||
|
|
||||||
|
| 경로 | 코드 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| `certs` (JWKS) | **200** | realm 키가 캐시에 있다. DB 를 안 본다 |
|
||||||
|
| `.well-known` | **500** | 이 응답을 만들려면 DB 를 본다 |
|
||||||
|
| 토큰 발급 | **400** | 세션을 **써야** 한다 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **부분적으로만 깨진다.** 헬스체크는 통과하고,
|
||||||
|
일부 엔드포인트는 정상이며, **로그인만 안 된다.**
|
||||||
|
|
||||||
|
운영에서 이 모양이 왜 고약한가 — 「사이트가 떴는가」를 재는 감시(정문 200,
|
||||||
|
JWKS 200)는 전부 초록이고, **사용자만 못 들어온다.** 이 실험의 감시 항목은
|
||||||
|
`/realms/master` 가 아니라 **토큰 발급**이어야 한다.
|
||||||
|
|
||||||
|
## 4-2. 로그가 이유를 말한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=50
|
||||||
|
```
|
||||||
|
**실측** — [`02-destruction.txt`](../../evidence/d1-backup-restore/02-destruction.txt)
|
||||||
|
```
|
||||||
|
2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
|
||||||
|
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`WARN` 이지 `ERROR` 가 아니다.** 그리고 내용은
|
||||||
|
「클러스터 멤버를 못 가져온다」다 — `JGROUPS_PING` 테이블도 같이 지워졌기
|
||||||
|
때문이다(A-1 에서 그 테이블을 봤다).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 디스커버리가 깨졌는데도 **로그 레벨이 WARN 이라
|
||||||
|
대시보드의 에러 카운터에 안 잡힐 수 있다.** 3-2 의 `200`, 4-1 의 부분 정상,
|
||||||
|
여기의 `WARN` — **세 관측이 전부 「괜찮다」 쪽으로 기운다.**
|
||||||
|
|
||||||
|
## 4-3. 개념 — 「DB 가 살아 있다」와 「데이터가 있다」는 다르다
|
||||||
|
|
||||||
|
```
|
||||||
|
A-2 DB 프로세스 정지 → 커넥션 실패 → readiness DOWN → 파드가 Service 에서 빠진다
|
||||||
|
D-1 스키마만 삭제 → 커넥션 정상 → readiness UP → ★ 파드가 그대로 트래픽을 받는다
|
||||||
|
```
|
||||||
|
|
||||||
|
**헬스체크는 커넥션만 본다.** 그래서 빈 데이터베이스를 통과시킨다.
|
||||||
|
|
||||||
|
이건 Keycloak 의 버그가 아니다. 「DB 에 붙을 수 있는가」는 프로브가 답할 수
|
||||||
|
있는 질문이고, 「데이터가 온전한가」는 프로브가 답할 수 없는 질문이다.
|
||||||
|
후자를 재려면 **업무 트랜잭션 하나를 실제로 돌리는 감시**(예: 토큰 발급)가
|
||||||
|
따로 있어야 한다.
|
||||||
|
|
||||||
|
| 재는 것 | 이 사고에서 |
|
||||||
|
|---|---|
|
||||||
|
| 파드 `Ready` | 초록 |
|
||||||
|
| 정문 `200` | 초록 |
|
||||||
|
| JWKS `200` | 초록 |
|
||||||
|
| **토큰 발급** | **400** ← 유일하게 정직한 지표 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 되돌린다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 복구 시작'
|
||||||
|
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
< /tmp/keycloak-backup.sql > /tmp/restore.log 2>&1
|
||||||
|
date '+%H:%M:%S 복구 완료'
|
||||||
|
```
|
||||||
|
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||||
|
```
|
||||||
|
시작: 15:00:12
|
||||||
|
완료: 15:00:13
|
||||||
|
오류 줄: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ `-i` 를 빠뜨리면 아무 일도 안 일어난다 — 그리고 오류도 안 난다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql ... < dump.sql # ✘
|
||||||
|
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- psql ... < dump.sql # ✔
|
||||||
|
```
|
||||||
|
|
||||||
|
`-i` 는 **표준입력을 파드 안으로 연결하라**는 뜻이다. 없으면 파드 안의 psql 은
|
||||||
|
빈 입력을 받고 **정상 종료한다.** 셸은 오류를 내지 않고, 종료 코드도 0 이며,
|
||||||
|
`date` 두 줄은 「1초 만에 끝났다」고 찍힌다. **복구된 것과 구별되지 않는다.**
|
||||||
|
|
||||||
|
구별하는 유일한 방법은 5-2 의 데이터 대조다. **그래서 대조는 선택이 아니다.**
|
||||||
|
|
||||||
|
**확인** — 오류 줄을 센다
|
||||||
|
```bash
|
||||||
|
grep -ci '^ERROR' /tmp/restore.log
|
||||||
|
tail -5 /tmp/restore.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `0`. 0 이 아니면 어떤 줄이 실패했는지 본다.
|
||||||
|
`--clean --if-exists` 로 뜬 덤프를 빈 DB 에 넣으면 오류가 0 인 것이 정상이다.
|
||||||
|
|
||||||
|
## 5-2. 데이터를 대조한다 — 여기가 진짜 판정이다
|
||||||
|
|
||||||
|
**확인** — 1-2 와 **똑같은 명령**을 친다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select (select count(*) from realm), (select count(*) from client),
|
||||||
|
(select count(*) from user_entity),
|
||||||
|
(select count(*) from offline_user_session where offline_flag='0')"
|
||||||
|
```
|
||||||
|
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||||
|
```
|
||||||
|
복구 후: realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||||
|
백업 시: realms|clients|users|sessions|authclients = 2|15|2|3|1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 줄이 문자 단위로 같은가.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 완전 일치. 질문 ②(복구 절차가 도는가)의 답이
|
||||||
|
「돈다」인 근거가 이 두 줄이다. **여기가 다르면 그 앞의 모든 「성공」 표시는
|
||||||
|
무의미하다** — 5-1 의 `-i` 를 빠뜨렸는지 먼저 의심한다.
|
||||||
|
|
||||||
|
## 5-3. 서비스가 재시작 없이 돌아오는가
|
||||||
|
|
||||||
|
**손대지 않고 기다린다.** 여기서 파드를 재시작하면 「자가 회복하는가」라는
|
||||||
|
질문 자체가 사라진다.
|
||||||
|
|
||||||
|
**확인** — 15초쯤 뒤
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w 'well-known %{http_code}\n' \
|
||||||
|
https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`03-restore.txt`](../../evidence/d1-backup-restore/03-restore.txt)
|
||||||
|
```
|
||||||
|
+15초 well-known=200 토큰발급=200
|
||||||
|
→ 재시작 없이 회복
|
||||||
|
|
||||||
|
keycloak-0 restarts=0
|
||||||
|
keycloak-1 restarts=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 500 이던 `well-known` 이 `200` 이 된 것, 그리고
|
||||||
|
**`RESTARTS` 가 여전히 0** 인 것.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **커넥션 풀이 이미 붙어 있었으므로 테이블이
|
||||||
|
돌아오자마자 동작했다.** A-2 에서 본 것과 같은 자가 회복이다. 파드를 만질
|
||||||
|
필요가 없다 — 만졌다면 「복구 절차에 파드 재시작이 필요하다」는 잘못된 절차가
|
||||||
|
문서에 남았을 것이다.
|
||||||
|
|
||||||
|
## 5-4. 세션이 살아났나
|
||||||
|
|
||||||
|
**확인** — 1-3 과 같은 쿼리
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select user_session_id, offline_flag from offline_user_session"
|
||||||
|
```
|
||||||
|
**실측** — 원래 실행은 realm 이름을 함께 뽑았다
|
||||||
|
```
|
||||||
|
user_session_id | realm
|
||||||
|
--------------------------+-------------------
|
||||||
|
E1q5xI7tt4U_WhZpW7rEPIF2 | master
|
||||||
|
2ap3DyRiBF8OdMiqCodsJ0mp | master
|
||||||
|
Zsk4QcgXf_qgyMKzde5AG-Fz | master
|
||||||
|
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
|
||||||
|
(4 rows)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **1-6 확인 ④ 의 덤프 안에서 봤던 id 가 그대로
|
||||||
|
있는가.** `E1q5xI7tt4U_WhZpW7rEPIF2` 가 덤프의 `COPY` 블록에도, 복구된
|
||||||
|
테이블에도 있다 — **파일에서 DB 로 실제로 넘어온 것을 눈으로 잇는다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 세션이 백업에서 복원된다. 로그인 상태가 유지된다.
|
||||||
|
|
||||||
|
## 5-5. RTO 와 RPO 를 계산한다
|
||||||
|
|
||||||
|
**확인** — 적어 둔 시각 셋을 나란히 놓는다
|
||||||
|
```
|
||||||
|
14:59:47 파괴
|
||||||
|
15:00:12 복구 시작
|
||||||
|
15:00:13 복구 완료
|
||||||
|
~15:00:28 서비스 정상 확인
|
||||||
|
|
||||||
|
RTO = 41초
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 41초 중 **복구 명령 자체는 1초**다. 나머지는
|
||||||
|
「파괴를 알아채고 무엇을 할지 정하는 시간」이며, 이 실험에서는 이미 알고
|
||||||
|
있었으므로 25초였다. **실제 사고에서는 이 부분이 대부분을 차지한다.**
|
||||||
|
|
||||||
|
### RPO 는 두 겹이다
|
||||||
|
|
||||||
|
```
|
||||||
|
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
|
||||||
|
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
|
||||||
|
|
||||||
|
실제 RPO = ① + ②
|
||||||
|
```
|
||||||
|
|
||||||
|
A-3 은 **클라이언트가 200 을 받은 로그인 153건 중 4건이 DB 에 없었다**는 것을
|
||||||
|
측정했다. **백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.**
|
||||||
|
|
||||||
|
### 이 실험대의 규모는 현실적이지 않다
|
||||||
|
|
||||||
|
| | 이 실험대 | 운영 |
|
||||||
|
|---|---|---|
|
||||||
|
| 덤프 크기 | 395KB | GB~TB |
|
||||||
|
| 복구 시간 | 1초 | 분~시간 |
|
||||||
|
| 세션 수 | 3~4 | 수만 |
|
||||||
|
|
||||||
|
**복구가 1초인 것은 데이터가 작기 때문**이고, 이 실험이 확인한 것은
|
||||||
|
**절차가 맞다는 것**뿐이다. 시간은 규모에 따라 완전히 달라진다.
|
||||||
|
|
||||||
|
## 5-6. ★ 덤프를 다른 장애 도메인으로 옮긴다 — 사람이 쳐야 하는 부분
|
||||||
|
|
||||||
|
**여기가 이 실험이 「못 했다」로 남긴 항목이다.** 덤프는 아직 DB 와 같은
|
||||||
|
기계에 있다.
|
||||||
|
|
||||||
|
### 무엇을 사람이 쳐야 하나
|
||||||
|
|
||||||
|
| 하는 일 | 어디서 | sudo |
|
||||||
|
|---|---|---|
|
||||||
|
| 덤프 뜨기 · 복구 | `kc-lab-1` | 게스트는 **무암호** — 스크립트로도 된다 |
|
||||||
|
| 덤프를 호스트의 사용자 홈에 두기 | `test-server` | 필요 없다 |
|
||||||
|
| **덤프를 root 소유 경로(`/var/backups` 등)에 두기** | `test-server` | **비밀번호를 묻는다 — 사람이 친다** |
|
||||||
|
|
||||||
|
**호스트의 `sudo` 는 비대화 실행이 반드시 실패한다.** 실제로 그 벽에 부딪힌
|
||||||
|
기록이 있다.
|
||||||
|
|
||||||
|
**실측** — [`d4-certificate-renewal/01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||||||
|
```
|
||||||
|
$ sudo -n -l
|
||||||
|
sudo: a password is required
|
||||||
|
```
|
||||||
|
|
||||||
|
`-n` 은 「비밀번호를 물어보지 말라」는 뜻이고, 호스트에서는 그게 곧 실패다.
|
||||||
|
**그러므로 백업을 호스트의 보호된 경로에 두는 단계는 자동화할 수 없다.**
|
||||||
|
`ssh -t` 로 붙어 사람이 비밀번호를 쳐야 한다(`-t` 가 없으면 sudo 가
|
||||||
|
비밀번호를 읽을 tty 가 없다).
|
||||||
|
|
||||||
|
**하기** — **미검증**. 이 실험대는 여기까지 하지 않았다. 호스트 이름과 경로는
|
||||||
|
당신 배치에 맞춘다
|
||||||
|
```bash
|
||||||
|
# ① kc-lab-1 에서 호스트로 — sudo 없이 사용자 홈에
|
||||||
|
scp /tmp/keycloak-backup.sql test-server:~/keycloak-backup-2026-09-04.sql
|
||||||
|
|
||||||
|
# ② 보호된 경로로 옮기는 것은 호스트에서 사람이 친다 (비밀번호 프롬프트)
|
||||||
|
ssh -t test-server 'sudo install -m600 -o root -g root \
|
||||||
|
~/keycloak-backup-2026-09-04.sql /var/backups/keycloak-backup-2026-09-04.sql'
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 옮긴 파일이 온전한가. **크기를 양쪽에서 세서 비교한다**
|
||||||
|
```bash
|
||||||
|
wc -c /tmp/keycloak-backup.sql
|
||||||
|
ssh test-server 'wc -c ~/keycloak-backup-2026-09-04.sql'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 숫자가 같은가. 다르면 전송이 잘린 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이것으로도 **부족하다.** 호스트는 VM 두 대를
|
||||||
|
품고 있는 기계이므로, 호스트가 죽으면 게스트도 덤프도 같이 간다.
|
||||||
|
**진짜 요건은 「다른 기계」가 아니라 「다른 장애 도메인」이다.**
|
||||||
|
|
||||||
|
> **이 실험이 확인하지 않은 것** — 백업 자동화, 보존 주기, 복구 리허설의
|
||||||
|
> 정기 실행. 이번엔 손으로 한 번 떴고, 한 번 되돌렸다. 그것만 참이다.
|
||||||
|
|
||||||
|
## 5-7. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 테이블 | `psql -c "select count(*) from pg_tables where schemaname='public'"` | 101 |
|
||||||
|
| 데이터 | 5-2 의 `-tAc` 한 줄 | 백업 시점과 **문자 단위로 동일** |
|
||||||
|
| 세션 | `select count(*) from offline_user_session` | 파괴 전과 같은 수 |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `1/1 Running`, `RESTARTS 0` |
|
||||||
|
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
| **로그인** | 4-1 의 토큰 발급 | **`200`** ← 이것이 진짜 판정 |
|
||||||
|
| 덤프 | `ls -l /tmp/keycloak-backup.sql` | 남겨 둔다. 다음 실험(D-2)의 전제다 |
|
||||||
|
|
||||||
|
**덤프는 지우지 않는다.** [D-2](d2-version-upgrade.md) 가 이 파일을 전제로 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
전부 이 실험대가 **실제로 겪은** 증상이거나, 이 절차에서 실제로 갈리는
|
||||||
|
지점이다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 복구가 1초 만에 끝났는데 데이터가 없다 | **`exec` 에 `-i` 가 없다.** 오류도 안 난다 | 5-2 의 대조. `-i` 를 붙여 다시 |
|
||||||
|
| 복구에서 `already exists` 가 쏟아진다 | 덤프를 `--clean --if-exists` 없이 떴다 | `grep -c '^DROP TABLE' /tmp/keycloak-backup.sql` — 0 이면 그것이다 |
|
||||||
|
| 덤프 파일은 있는데 복구가 중간에 멈춘다 | 덤프가 잘렸다 | `tail -3` 에 `dump complete` 가 있는가 — 1-6 확인 ③ |
|
||||||
|
| 파괴했는데 정문이 계속 `200` | **정상이다.** realm 캐시가 서빙한다 | 토큰 발급으로 판정 — 4-1 |
|
||||||
|
| `psql: relation "realm" does not exist` | 파괴가 걸린 것이다 | 그게 3-1 의 기대 출력이다 |
|
||||||
|
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||||
|
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 밖에서 `curl` 로 친다 |
|
||||||
|
| 세션 개수가 나열한 행 수와 다르다 | 개수 쿼리에 `offline_flag='0'` 필터가 있다 | **같은 쿼리끼리** 비교 — 1-3 |
|
||||||
|
| 백업이 0바이트다 | `pg_dump` 가 인증에서 막혔다 | `-U keycloak -d keycloak` 를 확인. 파일을 지우고 다시 뜬다 |
|
||||||
|
| 호스트에서 `sudo` 가 안 먹는다 | **호스트 sudo 는 비밀번호를 요구한다** | `ssh -t` 로 붙어 사람이 친다 — 5-6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 가이드에 스크립트가 없는 이유
|
||||||
|
|
||||||
|
원래 실행은 백업·파괴·복구를 스크립트 하나로 돌렸다. 그래서 증거 파일의
|
||||||
|
줄이 `realms|clients|users|sessions|authclients = 2|15|2|3|1` 처럼 이름표가
|
||||||
|
붙은 형태다.
|
||||||
|
|
||||||
|
**그 형태는 사람이 치는 형태가 아니다.** 그리고 이 실험에서는 스크립트가
|
||||||
|
특히 위험하다 — **`DROP SCHEMA` 와 복구가 한 파일에 있으면 중간에서 멈췄을 때
|
||||||
|
무엇이 실행됐는지 알 수 없다.** 파괴는 손으로 치고, 그 직후에 눈으로 확인하고,
|
||||||
|
복구도 손으로 친다. 각 단계 사이에 사람이 서 있어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | D-1 이 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [D-2](d2-version-upgrade.md) 버전 업그레이드 | **백업이 전제다.** 스키마가 바뀐 뒤에는 태그를 되돌려도 안 뜬다 |
|
||||||
|
| [D-3](d3-secret-management.md) 비밀 관리 | **덤프 안에 무엇이 들어 있는지** 생각한다. 백업을 잘 챙길수록 비밀도 잘 복사된다 |
|
||||||
|
| [A-4](a4-node-loss.md) 노드 상실 | PVC 가 노드에 못박혀 있다. **덤프가 같은 노드에 있으면 둘 다 잃는다** |
|
||||||
|
| 관측 | **「DB 가 살아 있다」만 보는 헬스체크는 빈 DB 를 통과시킨다.** 업무 트랜잭션을 재는 감시가 따로 필요하다 |
|
||||||
|
| 전부 | **복구해 보지 않은 백업은 백업이 아니다.** 그리고 복구 판정은 `200` 이 아니라 데이터 대조로 한다 |
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
# D-2 재현 가이드 — 태그를 올리고 내려 보고 롤백이 되는 조건을 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-d2-version-upgrade.md`](../../experiment-d2-version-upgrade.md) ·
|
||||||
|
증거 원문: [`docs/evidence/d2-version-upgrade/`](../../evidence/d2-version-upgrade/) ·
|
||||||
|
[`docs/evidence/followup/`](../../evidence/followup/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 업그레이드 전후로 `databasechangelog` 행 수가 그대로인 것 | `psql -tAc` |
|
||||||
|
| 파드가 하나씩 갈리는 동안 정문이 계속 `200` 인 것 | 1초 폴링 |
|
||||||
|
| 같은 스키마에서는 **롤백이 되는 것** | 태그를 되돌리고 다시 폴링 |
|
||||||
|
| 전환 순간의 `000` 이 서버 오류가 **아닌** 것 | `--max-time` |
|
||||||
|
| 스키마가 바뀐 방향에서 `ValidationFailedException` 으로 기동이 거부되는 것 | `logs keycloak-1` |
|
||||||
|
| 그때도 서비스가 살아 있는 것 — StatefulSet 이 절반에서 멈춘다 | `endpointslice` |
|
||||||
|
| 실패한 기동이 스키마를 **안 건드린** 것 | 다시 `databasechangelog` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- [`D-1`](d1-backup-restore.md) 이 끝나 있고 **덤프가 손에 있다.** 이 실험의
|
||||||
|
되돌리기 수단은 태그가 아니라 그 파일일 수 있다.
|
||||||
|
- [`A-8`](../../experiment-a8-rolling-restart.md) — 롤링 재시작이 무중단이라는
|
||||||
|
것이 전제다.
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
- 네임스페이스는 `keycloak-lab` 이다.
|
||||||
|
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||||
|
- 터미널 **두 개**를 열어 두면 편하다. 하나는 가용성 폴링용, 하나는 관찰용.
|
||||||
|
|
||||||
|
## 주의 — 이건 실제로 버전을 바꾸는 실험이다
|
||||||
|
|
||||||
|
이미지 태그를 세 번 바꾼다(정방향 → 롤백 → 그리고 선택적으로 **실패하는**
|
||||||
|
방향). 마지막 것은 파드를 `CrashLoopBackOff` 로 만든다. **되돌리는 명령은
|
||||||
|
각 절 첫머리에 있고, 전부 태그 한 줄이다.** 전 구간 약 20분이며, 중간에
|
||||||
|
그만두려면 [6. 복구](#6-복구) 의 첫 명령 하나면 된다.
|
||||||
|
|
||||||
|
**그리고 이 실험은 백업 없이 시작하지 않는다.** 스키마가 움직이는 방향으로
|
||||||
|
가면 태그로는 못 돌아온다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 15:00–15:26 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
|
||||||
|
|
||||||
|
**실측이 두 실행에서 나온다.** 처음 D-2 실행(15:00–15:10, 역방향 26.0)과
|
||||||
|
후속 실행(15:22–15:26, 26.7.3 정방향과 롤백)이다. 어느 쪽인지 매번 적는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
「문제가 생기면 이미지 태그를 되돌린다」는 거의 모든 배포 계획서에 적혀 있다.
|
||||||
|
**그 계획이 언제 동작하고 언제 동작하지 않는가**를 가른다.
|
||||||
|
|
||||||
|
Keycloak 은 **Liquibase** 로 스키마를 관리한다. 적용한 변경 하나하나가
|
||||||
|
`databasechangelog` 테이블에 행으로 쌓이고, 각 행에는 그 변경 정의의
|
||||||
|
**체크섬(`md5sum`)**이 들어 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
컨테이너가 뜬다
|
||||||
|
└─▶ Liquibase 가 databasechangelog 를 읽는다
|
||||||
|
└─▶ 자기가 아는 changeset 의 체크섬과 대조한다
|
||||||
|
├─ 같다 → 기동
|
||||||
|
└─ 다르다 → ValidationFailedException. 기동 거부
|
||||||
|
```
|
||||||
|
|
||||||
|
**「모르는 변경이 있다」가 아니라 「아는 변경인데 정의가 다르다」이며, 더
|
||||||
|
엄격한 실패다.** 그래서 판정 기준은 이렇게 된다.
|
||||||
|
|
||||||
|
| 이렇게 묻지 말고 | 이렇게 묻는다 |
|
||||||
|
|---|---|
|
||||||
|
| 「26.7.3 에서 26.7.0 으로 내려도 되나?」 | **「`databasechangelog` 의 행 수가 바뀌었나?」** |
|
||||||
|
|
||||||
|
> **★ 이 가이드는 정정된 결론을 따른다.**
|
||||||
|
> 해설 문서는 처음에 「롤백은 안 된다」고 단정했다가 후속 실험에서 정정했다.
|
||||||
|
>
|
||||||
|
> | 버전 차 | `databasechangelog` | 롤백 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | 26.7.0 → **26.0** | 체크섬 불일치 | **불가** |
|
||||||
|
> | 26.7.0 ↔ **26.7.3** | **210 → 210, 변화 없음** | **가능** |
|
||||||
|
>
|
||||||
|
> **판단 기준은 버전 번호가 아니라 행 수의 변화다.** 이 가이드는 그 숫자를
|
||||||
|
> 재는 법부터 가르친다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 태그를 바꾸기 전에
|
||||||
|
|
||||||
|
**여기서 재 두지 않으면 나중에 다시 잴 수 없는 값이 하나 있다** —
|
||||||
|
업그레이드 **전**의 `databasechangelog` 행 수다. 올린 뒤에는 그 값이 지워지고,
|
||||||
|
「롤백해도 되는가」를 판정할 근거가 사라진다.
|
||||||
|
|
||||||
|
```
|
||||||
|
백업 → 현재 태그 → ★ 마이그레이션 수 → 세션 → 클러스터 뷰 → 가용성 대조군
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1-1. 백업이 먼저다
|
||||||
|
|
||||||
|
**하기** — D-1 의 절차 그대로
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
|
||||||
|
--clean --if-exists > /tmp/pre-upgrade.sql
|
||||||
|
ls -l /tmp/pre-upgrade.sql
|
||||||
|
tail -3 /tmp/pre-upgrade.sql
|
||||||
|
```
|
||||||
|
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
|
||||||
|
(첫 실행) · [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)(후속 실행)
|
||||||
|
```
|
||||||
|
백업: 396333 bytes
|
||||||
|
백업: 395375 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 크기, 그리고 `tail` 의 `dump complete`.
|
||||||
|
**이 파일이 없으면 이 실험을 하지 않는다.** 5절에서 왜인지 나온다.
|
||||||
|
|
||||||
|
## 1-2. 지금 무엇이 돌고 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
|
||||||
|
```
|
||||||
|
**실측** — [`01-pre-upgrade.txt`](../../evidence/d2-version-upgrade/01-pre-upgrade.txt)
|
||||||
|
```
|
||||||
|
quay.io/keycloak/keycloak:26.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 태그. **`latest` 로 되어 있으면 이 실험이 성립하지
|
||||||
|
않는다** — 무엇에서 무엇으로 가는지 말할 수 없기 때문이다.
|
||||||
|
|
||||||
|
StatefulSet 에 적힌 것과 **파드가 실제로 돌리고 있는 것**은 다를 수 있다
|
||||||
|
(적용 중이거나, 롤아웃이 멈춰 있으면).
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
|
||||||
|
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 파드의 IMAGE 가 **서로 같고** StatefulSet 과도
|
||||||
|
같은가, `READY` 가 둘 다 `true`, `RESTARTS` 가 `0`.
|
||||||
|
|
||||||
|
## 1-3. ★ 마이그레이션 수 — 이 숫자가 이 실험의 전부다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select count(*) from databasechangelog"
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
count
|
||||||
|
-------
|
||||||
|
210
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
비교용으로 값만 뽑는 형태도 익혀 둔다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from databasechangelog"
|
||||||
|
```
|
||||||
|
**실측** — 두 실행 모두
|
||||||
|
```
|
||||||
|
총 마이그레이션 수: 210
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 하나. **이 값을 화면 밖에 적어 둔다.**
|
||||||
|
|
||||||
|
무엇이 마지막으로 적용됐는지도 한 번 본다. 나중에 「스키마가 언제 움직였나」를
|
||||||
|
물을 때 여기를 본다.
|
||||||
|
|
||||||
|
**확인** — **미검증**(원래 실행은 개수만 셌다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select id, author, orderexecuted, dateexecuted from databasechangelog
|
||||||
|
order by orderexecuted desc limit 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `dateexecuted` 의 가장 최근 값. **그게 이 DB 의
|
||||||
|
스키마가 마지막으로 움직인 시각이다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 210 은 「이 DB 는 여기까지 올라갔다」는 기록이다.
|
||||||
|
업그레이드 후에 **211 이상이 되면 스키마가 움직인 것이고, 그 순간부터
|
||||||
|
태그만으로는 못 돌아온다.**
|
||||||
|
|
||||||
|
## 1-4. 세션 — 업그레이드가 로그인 상태를 날리는지 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
현재 세션: 4 (첫 실행)
|
||||||
|
세션 전: 3 (후속 실행)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자. 0 이면 관리 콘솔에 한 번 로그인해서 만든다.
|
||||||
|
**0인 채로 업그레이드하면 「세션이 유지되는가」를 판정할 수 없다.**
|
||||||
|
|
||||||
|
## 1-5. 클러스터 뷰 — Infinispan 판까지 적어 둔다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||||
|
```
|
||||||
|
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt) 의
|
||||||
|
**업그레이드 후** 값
|
||||||
|
```
|
||||||
|
cluster: [keycloak-1-11418(v=16.0.14)|47] (2) [keycloak-1-11418(v=16.0.14), keycloak-0-58996(v=16.0.14)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `(v=16.0.12)` 같은 괄호 안의 판, 그리고 멤버 수 `(2)`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Keycloak 태그를 바꾸면 **함께 실린 Infinispan 판도
|
||||||
|
같이 바뀐다.** 후속 실행에서 `16.0.12 → 16.0.14` 로 올라갔다. 클러스터 프로토콜
|
||||||
|
호환성 문제가 있다면 여기서 드러나므로, 업그레이드 후에 **이 줄이 멤버 2로
|
||||||
|
다시 서는지** 보는 것이 판정 항목 하나다.
|
||||||
|
|
||||||
|
## 1-6. 새 태그가 실제로 있는지 확인한다
|
||||||
|
|
||||||
|
**확인** — 레지스트리에 무엇이 있나. **처음 한 번은 그대로 본다**
|
||||||
|
```bash
|
||||||
|
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄짜리 JSON 이 통째로 나온다. 어떤 필드가 있는지 보고 나서 자른다.
|
||||||
|
|
||||||
|
**미검증** — `jq` 가 없으므로 이 실험대에서는 이렇게 읽는다
|
||||||
|
```bash
|
||||||
|
curl -s "https://quay.io/api/v1/repository/keycloak/keycloak/tag/?limit=40&onlyActiveTags=true" \
|
||||||
|
| tr ',' '\n' | grep '"name"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `26.7.1` · `26.7.2` · `26.7.3` 이 있는가.
|
||||||
|
**처음 D-2 를 할 때 이걸 안 해서 정방향을 시험하지 못했다** — 「26.7.0 보다
|
||||||
|
새 이미지가 없다」고 적었지만 실제로는 셋이나 있었다.
|
||||||
|
|
||||||
|
## 1-7. 가용성 대조군 — 폴링을 먼저 띄운다
|
||||||
|
|
||||||
|
**주입 중에 나온 `000` 한 건을 해석하려면 평시 오류율을 알아야 한다.**
|
||||||
|
|
||||||
|
**하기** — 1초 간격으로 150회, 뒤에서 돌린다
|
||||||
|
```bash
|
||||||
|
( for i in $(seq 1 150); do
|
||||||
|
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
|
https://auth.hyeonworks.com/realms/master)"
|
||||||
|
sleep 1
|
||||||
|
done > /tmp/d2-avail.txt ) &
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기** — 그만 재려면
|
||||||
|
```bash
|
||||||
|
kill %1
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 30초쯤 두고 먼저 평시를 센다
|
||||||
|
```bash
|
||||||
|
tr ' ' '\n' < /tmp/d2-avail.txt | grep -c 200
|
||||||
|
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `uniq -c` 의 **줄이 몇 개인가.** 한 줄이면 전부 같은
|
||||||
|
코드였다는 뜻이다. 두 줄 이상이면 **평시에 이미 오류가 있는 것**이고, 그
|
||||||
|
상태로 주입하면 주입 중의 오류를 귀속할 수 없다.
|
||||||
|
|
||||||
|
> **`--max-time 3` 을 기억해 둔다.** 4-4 에서 나오는 `000` 이 이 값 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 ① — 정방향 업그레이드 (26.7.0 → 26.7.3)
|
||||||
|
|
||||||
|
**되돌리기를 먼저 읽는다.**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**단, 이 되돌리기가 유효한 것은 `databasechangelog` 가 안 바뀌었을 때뿐이다.**
|
||||||
|
바뀌었으면 되돌리기는 「덤프 복구 + 태그 되돌리기」다(5절).
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 태그 변경'
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.7.3
|
||||||
|
```
|
||||||
|
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||||
|
```
|
||||||
|
시작: 15:22:59
|
||||||
|
statefulset.apps/keycloak image updated
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `image updated` 한 줄. **이건 「적용됐다」가 아니라
|
||||||
|
「접수됐다」다.** 실제 교체는 지금부터 일어난다.
|
||||||
|
|
||||||
|
**하기** — 끝날 때까지 블록한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||||
|
date '+%H:%M:%S 롤아웃 완료'
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
partitioned roll out complete: 2 new pods have been updated...
|
||||||
|
완료: 15:24:26
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `2 new pods have been updated`. 87초 걸렸다.
|
||||||
|
|
||||||
|
> **`rollout status` 가 안 끝나고 매달려 있으면 그게 신호다.** StatefulSet 은
|
||||||
|
> 파드 하나가 Ready 가 되기 전에는 다음 파드를 안 건드린다. 즉 **매달림 =
|
||||||
|
> 첫 파드가 안 뜬다.** 다른 터미널에서 `get pods -w` 로 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
## 3-1. 파드가 새 이미지로 돌고 있나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o custom-columns=\
|
||||||
|
NAME:.metadata.name,IMAGE:.spec.containers[0].image,READY:.status.containerStatuses[0].ready,\
|
||||||
|
RESTARTS:.status.containerStatuses[0].restartCount | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||||
|
```
|
||||||
|
quay.io/keycloak/keycloak:26.7.3
|
||||||
|
keycloak-0 1/1 Running restarts=0
|
||||||
|
keycloak-1 1/1 Running restarts=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`RESTARTS` 가 `0`.** 여기가 0 인 것이 중요하다.
|
||||||
|
교체는 **새 파드를 만드는 것**이지 같은 파드를 재시작하는 것이 아니다.
|
||||||
|
`RESTARTS` 가 올라가 있으면 새 파드가 기동에 실패해 재시작을 반복하는 것이다.
|
||||||
|
|
||||||
|
**확인** — 실제로 새 파드인지는 나이로 본다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
|
||||||
|
```
|
||||||
|
**실측** — 첫 실행의 롤포워드 직후
|
||||||
|
```
|
||||||
|
keycloak-0 1/1 Running 0 10m
|
||||||
|
keycloak-1 1/1 Running 0 28s
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `AGE`. 하나씩 갈리므로 **나이가 다르다.** 둘 다 방금
|
||||||
|
생긴 나이면 동시에 갈린 것이고, 그건 무중단이 아니다.
|
||||||
|
|
||||||
|
## 3-2. 버전이 정말 바뀌었나 — 파드가 자기 입으로 말하게 한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -i 'Keycloak 26' | tail -1
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
Keycloak 26.7.3
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 로그가 말하는 판. 이미지 태그와 다르면 **태그가
|
||||||
|
재사용된 것**이다(같은 태그가 다른 내용을 가리키는 경우).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 끊겼나
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||||
|
```
|
||||||
|
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||||
|
```
|
||||||
|
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||||
|
...
|
||||||
|
200 응답: 87 회
|
||||||
|
비200 : 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 줄이 하나뿐이고 그 값이 `200` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **정방향 업그레이드는 무중단이었다.** 87회 요청이
|
||||||
|
전부 200 이다. 파드가 하나씩 갈리는 동안 남은 파드가 받았다.
|
||||||
|
|
||||||
|
> **「무중단」은 관측 해상도에 달려 있다.** 이건 1초 간격·3초 타임아웃으로
|
||||||
|
> 잰 결과다. 더 촘촘히 보면 더 보일 수 있다 — 실제로 D-4 에서 0.2초 간격으로
|
||||||
|
> 재니 다른 것이 보였다.
|
||||||
|
|
||||||
|
## 4-2. 그림으로도 남아 있다
|
||||||
|
|
||||||
|
Grafana 스크린샷이 증거에 있다 —
|
||||||
|
[`d2-upgrade-window.png`](../../evidence/d2-version-upgrade/d2-upgrade-window.png).
|
||||||
|
|
||||||
|
**무엇이 보이나** — `cluster_size` 가 **2 → 1 → 2 를 두 번** 반복하고, 파드별
|
||||||
|
`up` 시계열이 끝나고 새 시계열이 시작된다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **2 → 1 → 2 가 두 번**인 것. 파드가 둘이므로 교체도
|
||||||
|
두 번이고, 그때마다 클러스터가 잠시 한 명이 된다. **한 번만 보이면 두 파드가
|
||||||
|
동시에 갈린 것이다.**
|
||||||
|
|
||||||
|
## 4-3. ★ 스키마가 움직였나 — 이 실험의 판정
|
||||||
|
|
||||||
|
**확인** — 1-3 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from databasechangelog"
|
||||||
|
```
|
||||||
|
**실측** — [`followup/01`](../../evidence/followup/01-d2-forward-upgrade.txt)
|
||||||
|
```
|
||||||
|
마이그레이션 후: 210 (전: 210)
|
||||||
|
세션 후: 3 (전: 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **전과 후가 같은가.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것**
|
||||||
|
|
||||||
|
| 결과 | 뜻 | 되돌리는 법 |
|
||||||
|
|---|---|---|
|
||||||
|
| **행 수가 그대로** | 스키마가 안 움직였다 | **태그만 되돌리면 된다** |
|
||||||
|
| 행 수가 늘었다 | 새 changeset 이 적용됐다 | **덤프 복구 + 태그 되돌리기** |
|
||||||
|
|
||||||
|
26.7.0 → 26.7.3 은 **패치 릴리스라 스키마가 그대로**였다. 그래서 롤백이
|
||||||
|
가능하다는 가설이 섰고, 다음 절에서 시험한다.
|
||||||
|
|
||||||
|
## 4-4. 가설 시험 — 같은 스키마에서 롤백해 본다
|
||||||
|
|
||||||
|
**되돌리기** — 이 절 자체가 되돌리기다. 다시 올리려면 태그를 26.7.3 으로.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 롤백'
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||||
|
```
|
||||||
|
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
|
||||||
|
```
|
||||||
|
시작: 15:25:08
|
||||||
|
partitioned roll out complete: 2 new pods have been updated...
|
||||||
|
완료: 15:25:53
|
||||||
|
|
||||||
|
keycloak-0 1/1 Running restarts=0
|
||||||
|
keycloak-1 1/1 Running restarts=0
|
||||||
|
Keycloak 26.7.0
|
||||||
|
마이그레이션: 210
|
||||||
|
세션: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **파드가 뜬다.** 이게 가설의 답이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **스키마가 안 바뀌었으면 태그를 되돌리는 것으로
|
||||||
|
충분하다.** 마이그레이션 210 그대로, 세션 3 그대로, 재시작 0.
|
||||||
|
|
||||||
|
### 전환 순간의 `000` 한 번을 오해하지 않는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
tr ' ' '\n' < /tmp/d2-avail.txt | sort | uniq -c
|
||||||
|
grep -n '000' /tmp/d2-avail.txt
|
||||||
|
```
|
||||||
|
**실측** — [`followup/02`](../../evidence/followup/02-d2-rollback-same-schema.txt)
|
||||||
|
```
|
||||||
|
200 응답: 43 회 / 비200: 1
|
||||||
|
|
||||||
|
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||||
|
200 200 200 200 000 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
|
||||||
|
200 200 200 200
|
||||||
|
비200 값: 000
|
||||||
|
|
||||||
|
=== 대조: 정방향 업그레이드 때는 ===
|
||||||
|
200: 87 / 비200: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `000` 이다. **`500` 도 `502` 도 `503` 도 아니다.**
|
||||||
|
|
||||||
|
`000` 은 **curl 이 HTTP 상태 코드를 하나도 못 받았다**는 뜻이며, 여기서는
|
||||||
|
`--max-time 3` 을 넘긴 것이다. 서버가 오류를 돌려준 것이 아니라 **3초 안에
|
||||||
|
응답이 안 왔다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 파드 전환 순간 요청 하나가 3초를 넘겼다. 정방향에서
|
||||||
|
0회, 역방향에서 1회다. **끊긴 것과 느린 것은 다르고, 그 구별은 코드가 아니라
|
||||||
|
`--max-time` 을 알고 있어야 된다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. ★ 대조 — 스키마가 움직인 방향에서는 무슨 일이 나는가 (선택)
|
||||||
|
|
||||||
|
**여기부터는 일부러 실패시킨다.** 앞의 4절까지로 이 실험의 판정은 끝났다.
|
||||||
|
이 절은 「행 수가 바뀌었을 때」가 실제로 어떤 모양인지 보려는 것이다.
|
||||||
|
|
||||||
|
**되돌리기 — 먼저 읽는다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 26.0 으로 내린다'
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 이번에는 `rollout status` 로 기다리지 말고 **눈으로 본다**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -w
|
||||||
|
```
|
||||||
|
**실측** — [`02-rollback-attempt.txt`](../../evidence/d2-version-upgrade/02-rollback-attempt.txt)
|
||||||
|
```
|
||||||
|
시각: 15:02:20
|
||||||
|
statefulset.apps/keycloak image updated
|
||||||
|
+20초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||||
|
+40초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||||
|
+60초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||||
|
+80초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
|
||||||
|
+100초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||||
|
+120초 keycloak-0:Running(1/1) keycloak-1:Error(0/1)
|
||||||
|
+140초 keycloak-0:Running(1/1) keycloak-1:CrashLoopBackOff(0/1)
|
||||||
|
+160초 keycloak-0:Running(1/1) keycloak-1:Running(0/1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 가지다.
|
||||||
|
|
||||||
|
- `keycloak-1` 이 `Running(0/1) → Error → CrashLoopBackOff` 를 오간다.
|
||||||
|
**`Running` 인데 `0/1` 인 상태를 「떴다」로 읽으면 안 된다** — 컨테이너
|
||||||
|
프로세스는 살아 있지만 readiness 를 통과하지 못한 것이고, 곧 죽는다.
|
||||||
|
- **`keycloak-0` 은 내내 `1/1` 이다.** StatefulSet 이 안 건드렸다.
|
||||||
|
|
||||||
|
`Ctrl-C` 로 빠져나온다.
|
||||||
|
|
||||||
|
## 5-1. 왜 실패했는지 물어본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -iE 'liquibase|changeset|validation'
|
||||||
|
```
|
||||||
|
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||||
|
```
|
||||||
|
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: liquibase.exception.ValidationFailedException: Validation Failed:
|
||||||
|
1 changesets check sum
|
||||||
|
2026-09-04 06:03:25,877 ERROR [org.keycloak.quarkus.runtime.cli.ExecutionExceptionHandler] (main) ERROR: Validation Failed:
|
||||||
|
1 changesets check sum
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`1 changesets check sum`.** 개수가 1이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 26.7.0 이 적용한 changeset 하나를 26.0 도 알고
|
||||||
|
있는데, **정의가 다르다.** 같은 changeset 이 버전 사이에 수정된 것이다.
|
||||||
|
Liquibase 는 스키마를 반쯤 아는 상태로 서비스하느니 **기동 자체를 거부**한다.
|
||||||
|
|
||||||
|
> 파드가 이미 죽어서 로그가 안 나오면 **직전 컨테이너의 로그**를 본다.
|
||||||
|
> ```bash
|
||||||
|
> sudo kubectl -n keycloak-lab logs keycloak-1 --previous
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 5-2. 그런데 서비스는 살아 있다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
sudo kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o custom-columns=NAME:.metadata.name,ADDR:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready
|
||||||
|
sudo kubectl -n keycloak-lab get statefulset keycloak
|
||||||
|
```
|
||||||
|
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||||
|
```
|
||||||
|
https://auth.hyeonworks.com/realms/master HTTP 200
|
||||||
|
ready 주소: [10.42.1.140] ← 한 파드만
|
||||||
|
statefulset desired/ready/updated: 2 / 1 / 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — ready 주소가 **하나**, 그리고 `desired/ready/updated`
|
||||||
|
가 **2 / 1 / 1**.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **StatefulSet 의 롤링 업데이트가 사고를 절반에서
|
||||||
|
멈춰줬다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak-1 을 26.0 으로 → 기동 실패 → Ready 가 안 됨
|
||||||
|
└─ StatefulSet 은 keycloak-0 을 건드리지 않는다
|
||||||
|
└─ keycloak-0 (26.7.0) 이 계속 서비스한다
|
||||||
|
```
|
||||||
|
|
||||||
|
| replica 1 이었다면 | |
|
||||||
|
|---|---|
|
||||||
|
| 유일한 파드가 CrashLoopBackOff | **전면 장애** |
|
||||||
|
| 되돌리려면 사람이 개입 | 그동안 계속 다운 |
|
||||||
|
|
||||||
|
**A-8 에서 「무중단은 replica ≥ 2 와 readiness 의 조합」이라고 썼는데, 여기서는
|
||||||
|
그 조합이 잘못된 배포를 절반에서 멈춰줬다.**
|
||||||
|
|
||||||
|
## 5-3. 실패한 기동이 스키마를 건드렸나
|
||||||
|
|
||||||
|
**확인** — 세 번째로 같은 명령
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from databasechangelog"
|
||||||
|
```
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
realms|clients|migrations|sessions = 2|15|210|4
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **210 그대로.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **Liquibase 가 검증 단계에서 멈췄으므로 스키마를
|
||||||
|
건드리지 못했다.** 그래서 이 사고는 「태그만 되돌리면 되는」 쪽에 남았다.
|
||||||
|
|
||||||
|
**여기가 4번과 5번을 가르는 지점이다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
✔ Liquibase 가 검증에서 멈췄다 → 이미지만 되돌리면 끝
|
||||||
|
✘ 이미 적용한 뒤였다 → DB 복구(D-1)까지 해야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 복구
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
date '+%H:%M:%S 복귀'
|
||||||
|
sudo kubectl -n keycloak-lab set image statefulset/keycloak \
|
||||||
|
keycloak=quay.io/keycloak/keycloak:26.7.0
|
||||||
|
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
|
||||||
|
```
|
||||||
|
**실측** — [`03-roll-forward.txt`](../../evidence/d2-version-upgrade/03-roll-forward.txt)
|
||||||
|
```
|
||||||
|
statefulset.apps/keycloak image updated
|
||||||
|
partitioned roll out complete: 2 new pods have been updated...
|
||||||
|
keycloak-0 1/1 Running 0 10m
|
||||||
|
keycloak-1 1/1 Running 0 28s
|
||||||
|
|
||||||
|
realms|clients|migrations|sessions = 2|15|210|4
|
||||||
|
외부 진입점 HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`kubectl rollout undo statefulset/keycloak` 도 있다.** 이 실험은 쓰지 않았고
|
||||||
|
> (**미검증**), 쓰더라도 **되돌아가는 것은 이미지뿐이다.** 스키마가 움직였다면
|
||||||
|
> undo 도 같은 벽에 부딪힌다.
|
||||||
|
|
||||||
|
## 6-1. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 태그 | `get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].image}'` | 시작할 때의 태그 |
|
||||||
|
| 파드 | `get pods -o wide \| grep keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||||
|
| 클러스터 | `logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
|
||||||
|
| **마이그레이션** | `psql -tAc "select count(*) from databasechangelog"` | **210 — 시작할 때와 같다** |
|
||||||
|
| 세션 | `psql -tAc "select count(*) from offline_user_session"` | 시작할 때와 같다 |
|
||||||
|
| Service | `get endpointslice -l kubernetes.io/service-name=keycloak` | ready 주소 **둘** |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
| 폴링 | `jobs` | 남아 있으면 `kill %1` |
|
||||||
|
| 덤프 | `ls -l /tmp/pre-upgrade.sql` | 남겨 둔다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. 그래서 업그레이드 계획은 어떻게 쓰는가
|
||||||
|
|
||||||
|
```
|
||||||
|
✘ "문제가 생기면 이미지 태그를 되돌린다"
|
||||||
|
└─ 스키마가 이미 바뀌었으면 옛 버전이 안 뜬다
|
||||||
|
|
||||||
|
✔ "업그레이드 전에 databasechangelog 를 세어 두고,
|
||||||
|
바뀌었으면 백업에서 DB 를 되돌린 뒤 태그를 되돌린다"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 단계 | |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **백업**(D-1). 스키마가 움직인 뒤에는 이것만이 되돌리기 수단이다 |
|
||||||
|
| 2 | **`databasechangelog` 행 수를 적어 둔다** — 나중에는 못 잰다 |
|
||||||
|
| 3 | 태그 변경 |
|
||||||
|
| 4 | **첫 파드만 관찰** — StatefulSet 이 멈춰준다 |
|
||||||
|
| 5 | 행 수를 다시 센다. **그대로면** 태그만 되돌려도 된다 |
|
||||||
|
| 6 | **늘었으면** DB 복구 + 태그 되돌리기 |
|
||||||
|
|
||||||
|
## 이 실험이 확인한 것과 못 한 것
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| ✔ 정방향(26.7.0 → 26.7.3) 무중단 | 87회 전부 200 |
|
||||||
|
| ✔ 같은 스키마에서 롤백 가능 | 43/1, 그 1은 `--max-time` 타임아웃 |
|
||||||
|
| ✔ 스키마가 바뀐 방향은 기동 거부 | `1 changesets check sum` |
|
||||||
|
| ✔ 실패가 절반에서 격리된다 | StatefulSet + readiness |
|
||||||
|
| ✔ 실패한 기동은 스키마를 안 건드린다 | 210 그대로 |
|
||||||
|
| ✘ **스키마가 실제로 늘어나는 업그레이드** | **이 실험대에서는 재현하지 못했다.** 26.7.x 사이에는 변경이 없다 |
|
||||||
|
| ✘ 마이그레이션 도중 장애 | 스키마 변경 중에 죽으면? |
|
||||||
|
| ✘ 대규모 마이그레이션 시간 | 데이터가 작아 순식간이다 |
|
||||||
|
|
||||||
|
> **가장 중요한 미검증이 첫 줄이다.** 「행 수가 늘면 태그로 못 돌아온다」는
|
||||||
|
> **역방향(26.0)에서 관측한 실패를 근거로 한 추론**이며, 실제로 행 수가 늘어난
|
||||||
|
> 뒤 되돌려 본 적은 없다. 메이저 업그레이드를 할 때 이 실험을 다시 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `rollout status` 가 안 끝난다 | **첫 파드가 안 뜬다.** StatefulSet 이 기다린다 | 다른 터미널에서 `get pods -w` — 5절 |
|
||||||
|
| 파드가 `Running` 인데 `0/1` | 프로세스는 살아 있고 readiness 미통과 | `logs` 를 본다. 「떴다」로 읽지 않는다 |
|
||||||
|
| 로그가 안 나온다 | 파드가 이미 죽었다 | `logs keycloak-1 --previous` |
|
||||||
|
| **업그레이드 전 행 수를 안 적었다** | 그 값은 이제 DB 에 없다 | 덤프에서 복원한다 — 아래 |
|
||||||
|
| 비200 이 `000` 이다 | 서버 오류가 아니라 **`--max-time` 타임아웃** | `--max-time` 값을 늘려 다시 재 본다 — 4-4 |
|
||||||
|
| `kubectl get endpoints` 가 경고를 찍는다 | v1.33+ 에서 deprecated. **실측으로 이 경고를 봤다** | `get endpointslice -l kubernetes.io/service-name=...` |
|
||||||
|
| 두 파드가 동시에 갈렸다 | `podManagementPolicy: Parallel` | `get statefulset keycloak -o yaml \| grep podManagement` |
|
||||||
|
| 새 태그를 못 찾는다 | 레지스트리에서 확인 안 했다 | 1-6 |
|
||||||
|
|
||||||
|
**업그레이드 전 행 수를 안 적었을 때** — 덤프 안에 그 테이블이 통째로 들어
|
||||||
|
있다. **미검증**
|
||||||
|
```bash
|
||||||
|
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' /tmp/pre-upgrade.sql | wc -l
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — 나온 수에서 **2를 뺀다**(`COPY` 줄과 `\.` 줄).
|
||||||
|
이게 백업 시점의 행 수다. **D-1 의 덤프가 여기서 한 번 더 값을 한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | D-2 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [D-1](d1-backup-restore.md) 백업·복구 | **롤백 = 백업 복구**인 경우가 있다. 태그만 되돌리는 계획은 반쪽이다 |
|
||||||
|
| [D-3](d3-secret-management.md) 비밀 관리 | 업그레이드할 때 Secret 도 같이 검토된다 |
|
||||||
|
| [A-8](../../experiment-a8-rolling-restart.md) 롤링 재시작 | **replica ≥ 2 가 잘못된 배포를 절반에서 멈춘다** |
|
||||||
|
| 운영 | **판정은 버전 번호가 아니라 `databasechangelog` 의 행 수다** |
|
||||||
|
| 전부 | **재기 전에 못 재는 값을 먼저 적어 둔다.** 업그레이드 전 행 수가 그것이다 |
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
# D-3 재현 가이드 — Secret 이 어디까지 감춰지는지 네 경로로 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md) ·
|
||||||
|
증거 원문: [`docs/evidence/d3-secret-management/`](../../evidence/d3-secret-management/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| `describe` 는 `14 bytes` 만 보여주는 것 | `describe secret` |
|
||||||
|
| 같은 값이 **한 줄로** 평문이 되는 것 | `get -o jsonpath \| base64 -d` |
|
||||||
|
| 저장소 암호화가 **꺼져 있는** 것 | `k3s secrets-encrypt status` |
|
||||||
|
| 노드 디스크의 저장 파일 안에 **평문이 있는** 것 | `grep -c` on `state.db` |
|
||||||
|
| **그 grep 이 `0` 을 돌려주는데도 안전하지 않은** 것 | 같은 명령, 다른 키 |
|
||||||
|
| 파드 안에서는 그냥 **환경변수**인 것 | `env` · `/proc/1/environ` |
|
||||||
|
| RBAC 은 실제로 막는 것 | `auth can-i` |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||||
|
**k3s 서버의 저장 파일도 이 노드에 있다** — 그래서 4-2 를 여기서 칠 수 있다.
|
||||||
|
- 게스트(`kc-lab-1`/`kc-lab-2`)의 `sudo` 는 **무암호**다. 호스트와 다르다.
|
||||||
|
- 네임스페이스는 `keycloak-lab` 이다.
|
||||||
|
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
|
||||||
|
- [`B-6`](../../experiment-b6-key-rotation.md) ·
|
||||||
|
[`B-7`](../../experiment-b7-cookie-secret-rotation.md) 를 이미 했다면 이 실험의
|
||||||
|
결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이다.
|
||||||
|
|
||||||
|
## 주의 — 이건 비밀을 화면에 띄우는 실험이다
|
||||||
|
|
||||||
|
**이 실험의 명령 몇 개는 비밀번호를 터미널에 그대로 찍는다.** 그게 결론이기
|
||||||
|
때문에 피할 수 없지만, 그 값은 **스크롤백·화면 공유·터미널 로그**에 남는다.
|
||||||
|
|
||||||
|
이 가이드는 그래서 이렇게 한다.
|
||||||
|
|
||||||
|
- **남의 진짜 비밀은 길이(`wc -c`)와 키 이름까지만 본다.**
|
||||||
|
- **값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다**
|
||||||
|
(2절). 지워도 되는 값이므로 찍어도 된다.
|
||||||
|
- 실측으로 실린 값들은 **이 저장소의 매니페스트와 문서에 이미 적혀 있는
|
||||||
|
실험대 전용 값**이다(`change-me` 가 이름에 들어 있는 이유가 그것이다).
|
||||||
|
|
||||||
|
파괴적인 단계는 없다. 만드는 것은 카나리아 Secret 하나뿐이고
|
||||||
|
[5. 복구](#5-복구) 에서 지운다. 전 구간 약 15분.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 15:05–15:06 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이다.
|
||||||
|
**그 문장이 실제로 무엇을 막아 주는지**를 네 경로로 나눠 판정한다.
|
||||||
|
|
||||||
|
| # | 경로 | 누가 쓰나 | 예측 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① | 쿠버네티스 API (`get secret`) | 클러스터에 접근하는 사람 | ? |
|
||||||
|
| ② | **노드 디스크의 저장 파일** | 디스크·백업·스냅샷을 얻은 사람 | ? |
|
||||||
|
| ③ | **파드 안의 프로세스** | `exec` 권한이 있는 사람, 크래시 덤프 | ? |
|
||||||
|
| ④ | RBAC | 권한이 없는 주체 | ? |
|
||||||
|
|
||||||
|
**핵심 개념부터 짚는다.**
|
||||||
|
|
||||||
|
| | 목적 | 되돌리기 |
|
||||||
|
|---|---|---|
|
||||||
|
| **인코딩** (base64) | 바이너리를 텍스트로 안전하게 **옮기기** | **키 없이 누구나** |
|
||||||
|
| 암호화 | 키 없이는 못 **읽게** 하기 | 키가 있어야 |
|
||||||
|
|
||||||
|
**Secret 이 base64 를 쓰는 이유는 감추려는 것이 아니라 YAML 에 임의 바이트를
|
||||||
|
담기 위해서다.** 그런데 `kubectl describe` 가 값을 가려서 보여주기 때문에
|
||||||
|
「가려져 있구나」라는 인상이 남는다 — 이 실험은 그 인상과 사실 사이의 거리를
|
||||||
|
잰다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 무엇이 있는지부터 본다
|
||||||
|
|
||||||
|
## 1-1. Secret 목록
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret
|
||||||
|
```
|
||||||
|
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||||
|
```
|
||||||
|
bff-secrets Opaque keys=1
|
||||||
|
keycloak-lab-secrets Opaque keys=2
|
||||||
|
oauth2-proxy-secrets Opaque keys=3
|
||||||
|
```
|
||||||
|
|
||||||
|
> 실측 줄은 원래 실행 스크립트가 정리해 찍은 것이다. 손으로 치면
|
||||||
|
> `NAME / TYPE / DATA / AGE` 네 칸이 나오고, `DATA` 열이 위의 `keys=` 에 해당한다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 이름과 `DATA` 열(키 개수). **`TYPE` 이 `Opaque` 인
|
||||||
|
것도 본다** — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는
|
||||||
|
뜻이지 **감춘다는 뜻이 아니다.**
|
||||||
|
|
||||||
|
## 1-2. `describe` 는 값을 감춘다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab describe secret bff-secrets
|
||||||
|
```
|
||||||
|
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||||
|
```
|
||||||
|
Type: Opaque
|
||||||
|
|
||||||
|
Data
|
||||||
|
====
|
||||||
|
KEYCLOAK_CLIENT_SECRET: 14 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **키 이름과 바이트 수만 나온다.** 값이 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 화면이 「Secret 은 감춰진다」는 인상의 출처다.
|
||||||
|
`describe` 는 **일부러** 값을 안 찍는다. 그런데 그건 `describe` 라는 명령의
|
||||||
|
동작이지, **저장이나 전송의 성질이 아니다.**
|
||||||
|
|
||||||
|
## 1-3. 값을 안 보고 확인하는 법 — 평소에는 이렇게 한다
|
||||||
|
|
||||||
|
**남의 비밀을 다룰 때 기본 자세다.** 키 이름과 길이만 본다.
|
||||||
|
|
||||||
|
**확인** — 키 이름만
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
|
||||||
|
| tr ',' '\n' | grep -o '"[A-Z_]*"'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
"KC_BOOTSTRAP_ADMIN_PASSWORD"
|
||||||
|
"POSTGRES_PASSWORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 길이만
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
22
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 하나. **값이 화면에 없다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 「Secret 이 제대로 들어갔는가」를 확인하는 데는
|
||||||
|
길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개
|
||||||
|
**「비었는가 아닌가」**이지 값 자체가 아니다.
|
||||||
|
|
||||||
|
> `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와
|
||||||
|
> 같다. 값이 비었으면 `0` 이 나온다 — **`0` 은 「Secret 은 있는데 그 키가
|
||||||
|
> 비었다」는 뜻이고, 배포 실패의 흔한 원인이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 카나리아 Secret 하나를 만든다
|
||||||
|
|
||||||
|
**여기부터 상태가 바뀐다.** 바뀌는 것은 Secret 하나뿐이다.
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete secret d3-canary
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2-1. 왜 카나리아를 쓰나
|
||||||
|
|
||||||
|
4절에서 **저장 파일 안을 grep 해야 한다.** 그러려면 **찾을 문자열을 알고
|
||||||
|
있어야** 하는데, 진짜 비밀번호를 grep 인자로 쓰면 그 값이 셸 히스토리와
|
||||||
|
프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다.
|
||||||
|
|
||||||
|
**그래서 「찾아도 아무 피해가 없는 값」을 하나 심는다.** 실험 대상이 값 자체가
|
||||||
|
아니라 **경로**이기 때문에 이렇게 해도 결론은 같다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab create secret generic d3-canary \
|
||||||
|
--from-literal=CANARY=d3-canary-zq7v-do-not-use
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
secret/d3-canary created
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `created`. 이미 있다면 `AlreadyExists` 가 나온다 —
|
||||||
|
그럼 지우고 다시 만든다.
|
||||||
|
|
||||||
|
> **이 값은 아무 데도 쓰이지 않는다.** 어떤 파드도 참조하지 않으므로 지워도
|
||||||
|
> 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 이유는, 나중에 저장 파일
|
||||||
|
> 어딘가에서 이 문자열을 다시 만났을 때 **무엇인지 알아보기 위해서**다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret d3-canary
|
||||||
|
sudo kubectl -n keycloak-lab describe secret d3-canary
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
Data
|
||||||
|
====
|
||||||
|
CANARY: 26 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 여기서도 `describe` 는 바이트 수만 준다. **1-2 와
|
||||||
|
같은 화면이다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다 — 네 경로를 하나씩 연다
|
||||||
|
|
||||||
|
## 4-1. ① API — 한 줄로 읽힌다
|
||||||
|
|
||||||
|
**하기** — 카나리아로 먼저 해 본다. 값을 알고 있으므로 무엇이 나올지 예측된다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get secret d3-canary \
|
||||||
|
-o jsonpath='{.data.CANARY}' | base64 -d; echo
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
d3-canary-zq7v-do-not-use
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **2-1 에서 심은 값이 그대로 나온다.**
|
||||||
|
|
||||||
|
같은 명령이 실제 비밀에도 그대로 듣는다. 원래 실행이 네 개를 뽑은 결과가 이것이다.
|
||||||
|
|
||||||
|
**실측** — [`01-base64-not-encryption.txt`](../../evidence/d3-secret-management/01-base64-not-encryption.txt)
|
||||||
|
```
|
||||||
|
keycloak-lab-secrets/POSTGRES_PASSWORD = lab-postgres-change-me
|
||||||
|
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = lab-admin-change-me
|
||||||
|
bff-secrets/KEYCLOAK_CLIENT_SECRET = bff-lab-secret
|
||||||
|
oauth2-proxy-secrets/COOKIE_SECRET_A = lab-cookie-secret-aaaaaaaaaaaaaa
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **실험대의 모든 비밀이 명령 네 줄로 나온다.**
|
||||||
|
`describe` 가 `14 bytes` 라고 했던 그 값이 `bff-lab-secret` (14자)이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — ① 은 **막지 않는다.** base64 는 인코딩이고
|
||||||
|
`base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 **누가 이 명령을
|
||||||
|
칠 수 있는가**이며, 그건 ④(RBAC)의 문제로 넘어간다.
|
||||||
|
|
||||||
|
> **이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다.** 카나리아로 한 번
|
||||||
|
> 확인했으면 기제는 같다. 진짜 비밀은 1-3 의 길이 확인으로 충분하다.
|
||||||
|
|
||||||
|
## 4-2. ② 저장소 — 노드 디스크에 평문이 있다
|
||||||
|
|
||||||
|
### 먼저 암호화 설정을 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo k3s secrets-encrypt status
|
||||||
|
```
|
||||||
|
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||||
|
```
|
||||||
|
Encryption Status: Disabled, no configuration file found
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Disabled`, 그리고 **`no configuration file found`**.
|
||||||
|
설정 파일이 아예 없다 — 껐다기보다 **켠 적이 없다**는 뜻이고, 이게 기본값이다.
|
||||||
|
|
||||||
|
### 저장 파일이 어디 있는지 본다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo ls -l /var/lib/rancher/k3s/server/db/
|
||||||
|
```
|
||||||
|
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||||
|
```
|
||||||
|
total 23336
|
||||||
|
drwx------ 2 root root 4096 Sep 2 09:12 .
|
||||||
|
drwx------ 8 root root 4096 Sep 4 03:23 ..
|
||||||
|
-rw-r--r-- 1 root root 13078528 Sep 4 06:05 state.db
|
||||||
|
-rw-r--r-- 1 root root 32768 Sep 4 06:06 state.db-shm
|
||||||
|
-rw-r--r-- 1 root root 10769712 Sep 4 06:06 state.db-wal
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 파일이 **셋**이다.
|
||||||
|
|
||||||
|
| 파일 | 무엇인가 |
|
||||||
|
|---|---|
|
||||||
|
| `state.db` | 본체 |
|
||||||
|
| `state.db-wal` | **아직 본체에 합쳐지지 않은 최근 쓰기** |
|
||||||
|
| `state.db-shm` | 공유 메모리 인덱스 |
|
||||||
|
|
||||||
|
**k3s 는 etcd 대신 SQLite 를 쓴다.** 「저장소(at rest)」의 자리는 같다 —
|
||||||
|
etcd 를 쓰는 클러스터라면 이 자리가 etcd 의 데이터 디렉터리다.
|
||||||
|
|
||||||
|
> **`-wal` 이 10MB 나 되는 것을 봐 둔다.** 방금 만든 카나리아는 **아직 본체에
|
||||||
|
> 없을 가능성이 높다.** 아래에서 이게 함정이 된다.
|
||||||
|
|
||||||
|
### ★ 파일 안을 찾아본다
|
||||||
|
|
||||||
|
**하기** — 카나리아부터
|
||||||
|
```bash
|
||||||
|
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
|
||||||
|
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||||
|
```
|
||||||
|
**미검증** — 이 실험대는 카나리아 대신 실제 값으로 쟀다. 그 결과가 아래다.
|
||||||
|
|
||||||
|
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||||
|
```
|
||||||
|
=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
|
||||||
|
state.db 안의 평문 일치: 2
|
||||||
|
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
|
||||||
|
client secret 평문 등장 횟수: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **두 줄의 값이 다르다. `2` 와 `0`.**
|
||||||
|
|
||||||
|
같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것 — 이 절에서 제일 중요한 문장이다.**
|
||||||
|
|
||||||
|
> **`grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에
|
||||||
|
> 이 형태로는 못 찾았다」이다.**
|
||||||
|
|
||||||
|
`2` 가 나온 순간 ②의 답은 이미 정해졌다 — **저장 파일에 평문이 있다.**
|
||||||
|
`0` 이 나온 키에 대해 「그건 안전한가 보다」라고 읽으면, **같은 파일에 평문이
|
||||||
|
들어 있는 것을 이미 본 뒤에 그러는 것이다.**
|
||||||
|
|
||||||
|
**0 이 나왔을 때 다음에 볼 곳** — **미검증**. 이 실험은 원인을 가리지 않았다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo grep -c 'bff-lab-secret' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||||
|
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c 'bff-lab-secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
| 왜 안 나올 수 있나 | 확인 |
|
||||||
|
|---|---|
|
||||||
|
| 아직 `-wal` 에만 있다 | `-wal` 을 같이 grep |
|
||||||
|
| 값이 페이지 경계를 넘어 잘렸다 | `strings` 로 한 번 더 |
|
||||||
|
| 그 키가 그 시점에 없었다 | `get secret` 으로 존재 확인 |
|
||||||
|
|
||||||
|
> **`grep -c` 는 바이너리 파일에도 듣는다.** 평소의 `grep` 은 바이너리를 만나면
|
||||||
|
> `Binary file ... matches` 한 줄만 찍고 내용을 안 보여주는데, `-c` 는 개수만
|
||||||
|
> 세므로 그대로 숫자가 나온다. **값 자체를 화면에 안 띄운다는 점에서도 이
|
||||||
|
> 형태가 맞다** — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.
|
||||||
|
|
||||||
|
**그래서 무엇이 위험한가**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 노드 디스크를 얻으면 | **전 클러스터의 비밀** |
|
||||||
|
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
|
||||||
|
| A-4 에서 본 `local-path` PVC | **같은 디스크에 있다** |
|
||||||
|
| D-1 의 덤프 | 같은 기계에 뒀다면 **거기도 같이** |
|
||||||
|
|
||||||
|
**D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는
|
||||||
|
「노드 디스크 하나가 모든 비밀」이다.** 백업을 잘 챙길수록 비밀도 잘 복사된다.
|
||||||
|
|
||||||
|
**k3s 는 `--secrets-encryption` 플래그로 켤 수 있다.** 지금은 안 켜져 있고,
|
||||||
|
**이 가이드는 켜지 않는다** — 켜는 것은 서버 재시작과 기존 Secret 재암호화를
|
||||||
|
수반하고, 이 실험대에서 시험하지 않았다(**미검증**).
|
||||||
|
|
||||||
|
## 4-3. ③ 파드 안 — 평범한 환경변수다
|
||||||
|
|
||||||
|
**확인** — 어느 파드를 볼지 먼저 정한다
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||||||
|
```
|
||||||
|
|
||||||
|
**하기** — **미검증**(원래 실행은 파드 이름을 직접 지정했다)
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'
|
||||||
|
```
|
||||||
|
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||||
|
```
|
||||||
|
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
|
||||||
|
BFF_DB_PASSWORD=lab-postgres-change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`env` 한 번이면 나온다.** 그리고 `bff-lab-secret`
|
||||||
|
은 4-1 에서 API 로 뽑은 값과 **같다** — 두 경로가 같은 평문에 닿는다.
|
||||||
|
|
||||||
|
`exec` 이 `deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라
|
||||||
|
친다.
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab get pod -l app=bff \
|
||||||
|
--field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**같은 파드 안의 다른 프로세스도 본다.** 이게 「환경변수」의 진짜 성질이다.
|
||||||
|
|
||||||
|
**하기** — **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab exec deploy/bff -- \
|
||||||
|
sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 같은 값이 나오는가. `/proc/<pid>/environ` 은 그
|
||||||
|
프로세스의 환경변수를 그대로 담고 있고, **같은 UID 의 아무 프로세스나 읽는다.**
|
||||||
|
|
||||||
|
| 새는 경로 | |
|
||||||
|
|---|---|
|
||||||
|
| `kubectl exec` 권한이 있는 사람 | 바로 본다 |
|
||||||
|
| 같은 파드의 다른 프로세스 | `/proc/<pid>/environ` |
|
||||||
|
| **크래시 덤프 · 오류 리포트** | 환경변수를 함께 담는 도구가 많다 |
|
||||||
|
| 자식 프로세스 | 상속된다 |
|
||||||
|
|
||||||
|
**볼륨으로 마운트하면 이 중 몇 가지가 줄어든다** — 파일 권한으로 제한할 수
|
||||||
|
있고, 환경변수 덤프에 안 들어간다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumeMounts:
|
||||||
|
- name: secrets
|
||||||
|
mountPath: /etc/secrets
|
||||||
|
readOnly: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**줄어드는 것이지 없어지는 것이 아니다.** `exec` 권한이 있으면 파일도 읽는다.
|
||||||
|
|
||||||
|
## 4-4. ④ RBAC — 유일하게 막는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo kubectl auth can-i get secrets -n keycloak-lab \
|
||||||
|
--as=system:serviceaccount:keycloak-lab:default
|
||||||
|
```
|
||||||
|
**실측** — [`02-at-rest.txt`](../../evidence/d3-secret-management/02-at-rest.txt)
|
||||||
|
```
|
||||||
|
default SA: no
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`no`** 한 단어.
|
||||||
|
|
||||||
|
**어떤 권한이 있는지 통째로 보려면** — **미검증**
|
||||||
|
```bash
|
||||||
|
sudo kubectl auth can-i --list -n keycloak-lab \
|
||||||
|
--as=system:serviceaccount:keycloak-lab:default
|
||||||
|
sudo kubectl -n keycloak-lab get role,rolebinding
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 기본 서비스계정은 Secret 을 못 읽는다.
|
||||||
|
**명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문**이다. RBAC 은
|
||||||
|
기본이 거부이고, Role 을 붙여야 할 수 있게 된다.
|
||||||
|
|
||||||
|
> **네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다.** 그러므로 실질적인
|
||||||
|
> 방어선은 「누가 `get secrets` 를 할 수 있는가」이며,
|
||||||
|
> **관리자 권한을 가진 사람에게는 아무 방어가 없다.**
|
||||||
|
>
|
||||||
|
> A-0 의 관측 스택에서 `nodes/proxy` 서브리소스를 따로 줘야 했던 것처럼,
|
||||||
|
> Secret 접근도 **리소스 단위로 나눌 수 있다.**
|
||||||
|
|
||||||
|
## 4-5. 네 경로 정리
|
||||||
|
|
||||||
|
| # | 경로 | 감춰지는가 | 무엇이 뚫나 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① | `get -o jsonpath \| base64 -d` | **아니다** | 클러스터 접근 권한 |
|
||||||
|
| — | `describe secret` | 값을 숨긴다 | **그래서 안전하다고 착각한다** |
|
||||||
|
| ② | 저장 파일(`state.db`) | **아니다.** 암호화 꺼짐 | 노드 디스크·백업·스냅샷 |
|
||||||
|
| ③ | 파드 안 | **아니다.** 평범한 환경변수 | `exec` · `/proc` · 크래시 덤프 |
|
||||||
|
| ④ | RBAC | **막는다** | 관리자 권한 |
|
||||||
|
|
||||||
|
**「Secret 이니까 안전하다」는 네 가지 중 하나(RBAC)만 맞다.**
|
||||||
|
그리고 ②·③ 은 **쿠버네티스 API 를 한 번도 거치지 않고** 평문에 닿는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구
|
||||||
|
|
||||||
|
## 5-1. 카나리아를 지운다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo kubectl -n keycloak-lab delete secret d3-canary
|
||||||
|
sudo kubectl -n keycloak-lab get secret
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
secret "d3-canary" deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 1-1 의 목록으로 돌아왔는가. 세 개다.
|
||||||
|
|
||||||
|
## 5-2. ★ 지웠다고 파일에서 없어지지는 않는다
|
||||||
|
|
||||||
|
**확인** — **미검증**. 이 실험은 삭제 후를 재지 않았다
|
||||||
|
```bash
|
||||||
|
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
|
||||||
|
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 0 이 나오면 「이 시점에 이 형태로는 안 보인다」이고,
|
||||||
|
0 이 아니면 **지운 Secret 의 평문이 아직 파일에 남아 있는 것**이다.
|
||||||
|
어느 쪽이든 **4-2 의 결론은 안 바뀐다** — 판정은 이미 `2` 에서 났다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 데이터베이스 파일은 지운 행의 자리를 즉시
|
||||||
|
0으로 덮어쓰지 않는다. **「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는
|
||||||
|
다른 사건**이며, 비밀이 유출됐을 때 실제로 해야 하는 일은 삭제가 아니라
|
||||||
|
**회전(rotation)**인 이유가 여기 있다 — B-6·B-7 의 주제다.
|
||||||
|
|
||||||
|
## 5-3. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 돌아왔을 때 |
|
||||||
|
|---|---|---|
|
||||||
|
| 카나리아 | `sudo kubectl -n keycloak-lab get secret d3-canary` | `NotFound` |
|
||||||
|
| Secret 목록 | `sudo kubectl -n keycloak-lab get secret` | 세 개 |
|
||||||
|
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` (아무것도 안 건드렸다) |
|
||||||
|
| **터미널** | `history \| tail -40` | **비밀번호가 찍힌 줄이 어디까지 남았는지 본다** |
|
||||||
|
|
||||||
|
> **이 실험의 진짜 뒷정리는 스크롤백이다.** 4-1 을 실제 비밀로 쳤다면 그 값이
|
||||||
|
> 터미널 버퍼와 셸 히스토리에 남아 있다. 실험대 값이라 지금은 상관없지만,
|
||||||
|
> **같은 절차를 운영에서 하면 그게 유출 경로가 된다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 그래서 무엇을 해야 하는가
|
||||||
|
|
||||||
|
```
|
||||||
|
지금: 매니페스트에 stringData 평문 → git 에 커밋되면 끝
|
||||||
|
k3s 저장소 암호화 꺼짐
|
||||||
|
파드 환경변수
|
||||||
|
```
|
||||||
|
|
||||||
|
| 단계 | 얻는 것 | 이 실험대 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 매니페스트에서 값을 빼고 **`.example` 만 커밋** | git 유출을 막는다 | 안 함 |
|
||||||
|
| ② **k3s `--secrets-encryption`** 활성화 | 노드 디스크 유출을 막는다 | 안 함 (**미검증**) |
|
||||||
|
| ③ 환경변수 대신 **볼륨 마운트** | 프로세스·덤프 유출을 줄인다 | 안 함 |
|
||||||
|
| ④ **SealedSecret / 외부 KMS** | 매니페스트에 암호문만 남는다 | 안 함 |
|
||||||
|
| ⑤ **RBAC 최소화** | 유일하게 이미 동작하는 방어선을 좁힌다 | 기본값 그대로 |
|
||||||
|
|
||||||
|
**이 실험대는 ①~④ 중 아무것도 안 하고 있다.** 실험 목적으로는 의도적이지만,
|
||||||
|
**그 사실을 기록해두지 않으면 그대로 운영에 옮겨간다.** 값 이름에 `change-me`
|
||||||
|
를 넣어 둔 것이 그 최소한의 표시다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `grep` 이 `0` 인데 안전하다고 읽힌다 | **`0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」** | `-wal` 과 `strings` 로 한 번 더 — 4-2 |
|
||||||
|
| `grep` 이 `Binary file matches` 만 찍는다 | 바이너리 파일이다 | `-c` 를 쓴다(개수만). 값을 안 띄우는 이점도 있다 |
|
||||||
|
| `k3s secrets-encrypt` 가 없다 | **서버 노드가 아니다** | `kc-lab-1`(control-plane)에서 친다 |
|
||||||
|
| `state.db` 가 `Permission denied` | root 전용 디렉터리 | 게스트 sudo 는 무암호다. `sudo` 를 붙인다 |
|
||||||
|
| `exec deploy/bff` 가 실패한다 | 파드가 종료 중이거나 여럿이다 | `--field-selector=status.phase=Running` 으로 이름을 고른다 — 4-3 |
|
||||||
|
| `auth can-i` 가 `yes` 라고 한다 | 그 SA 에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 |
|
||||||
|
| 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 |
|
||||||
|
| 비밀번호를 화면에 찍어 버렸다 | 4-1 을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, **운영이면 회전한다** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | D-3 이 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [B-6](../../experiment-b6-key-rotation.md) key 회전 | **key 를 Secret 에 두면 이 실험의 결론이 그대로 적용된다** |
|
||||||
|
| [B-7](../../experiment-b7-cookie-secret-rotation.md) 쿠키 비밀 회전 | 유출 대응은 삭제가 아니라 **회전**이다 — 5-2 |
|
||||||
|
| [D-1](d1-backup-restore.md) 백업 | **덤프에도 같은 문제가 있다.** 백업을 잘 챙길수록 비밀도 잘 복사된다 |
|
||||||
|
| [D-4](d4-certificate-renewal.md) 인증서 | **개인키(`privkey.pem`)도 같은 비밀 관리 문제다** |
|
||||||
|
| 운영 | **RBAC 이 유일하게 동작하는 방어선이다.** 관리자에게는 아무 방어가 없다 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
|||||||
|
# D-4a 재현 가이드 — 훅 파일 하나가 38분을 1초로 만드는 것을 직접 본다
|
||||||
|
|
||||||
|
해설 문서: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md) ·
|
||||||
|
증거 원문: [`docs/evidence/d4a-deploy-hook/`](../../evidence/d4a-deploy-hook/)
|
||||||
|
|
||||||
|
## 이 가이드가 끝나면
|
||||||
|
|
||||||
|
당신 터미널에서 이것들을 **직접 본다.**
|
||||||
|
|
||||||
|
| 보게 되는 것 | 어디서 |
|
||||||
|
|---|---|
|
||||||
|
| 훅 디렉터리가 비어 있는 것 → 파일 하나를 넣는 것 | `ls -l` |
|
||||||
|
| certbot 이 **`ran with error output`** 이라고 찍는데 **실패가 아닌** 것 | certbot 출력 원문 |
|
||||||
|
| 마스터는 그대로고 워커만 **자동으로** 갈리는 것 | `ps -eo lstart` |
|
||||||
|
| 서빙 인증서가 **그 자리에서** 바뀌는 것 | `openssl s_client` |
|
||||||
|
| 발급에서 서빙까지 **1~2초**인 것 | SCT + 보정한 훅 시각 |
|
||||||
|
| 보정하지 않으면 **훅이 발급보다 104초 먼저** 돈 것이 되는 것 | 같은 계산 |
|
||||||
|
| `notBefore` 가 **발급 시각이 아닌** 것 | 인증서 필드 |
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
- **[`D-4`](d4-certificate-renewal.md) 를 먼저 한다.** 특히 두 가지가 없으면
|
||||||
|
이 실험은 성립하지 않는다.
|
||||||
|
- **1-6** — 「reload 판정은 워커 PID 로 한다」는 기준
|
||||||
|
- **1-7** — 두 기계 시계의 왜곡을 **미리** 재 둔 값
|
||||||
|
- 관찰은 **dev 에서**, 주입은 **`test-server` 에서 사람이** 친다.
|
||||||
|
- **호스트의 `sudo` 는 비밀번호를 요구한다.** 이 실험의 주입은 전부 그쪽이다.
|
||||||
|
- 이 호스트의 certbot 은 **5.7.0**, **nginx 플러그인은 없다.**
|
||||||
|
|
||||||
|
## 주의 — 인증서를 한 장 더 쓴다
|
||||||
|
|
||||||
|
`certbot renew --force-renewal` 을 **또** 한 번 친다. D-4 에서 한 번 썼다면
|
||||||
|
이번이 두 번째이고, **Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장
|
||||||
|
쓴 셈**이 된다. 세어 두고, 절차만 확인하려면 `--dry-run` 을 먼저 쓴다.
|
||||||
|
|
||||||
|
**그리고 이 실험의 주입은 되돌리지 않는 편이 낫다.** 훅은 결함을 고치는
|
||||||
|
파일이다. 지우면 D-4 의 상태로 돌아간다 — 지우는 명령은
|
||||||
|
[5-1. 남기는 이유](#5-1-남기는-이유) 에 있다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 2026-09-04 12:27 UTC(실제) 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||||
|
| **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** |
|
||||||
|
| **형태** | 값이 매번 달라지는 출력 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 고친 형태이거나, 이 실험이 하지 않은 확장 |
|
||||||
|
|
||||||
|
## ★ 시각 표기 규약
|
||||||
|
|
||||||
|
**이 실험은 1~2초를 잰다. 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.**
|
||||||
|
|
||||||
|
| 표기 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| `12:27:49 (실제)` | 보정한 값. 외부 기준과 일치 |
|
||||||
|
| `21:29:36 KST (ts)` | test-server 시계. **106초 빠르다** |
|
||||||
|
| `12:29:05 (dev)` | 개발 머신 시계. 보정 불필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. 왜 이 실험을 하는가
|
||||||
|
|
||||||
|
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
|
||||||
|
|
||||||
|
| D-4 가 남긴 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||||
|
|
||||||
|
**처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것**은, 이 실험대가
|
||||||
|
스물세 번 경계해 온 바로 그 실수다. 그래서 별도 실험으로 분리했다.
|
||||||
|
|
||||||
|
판정할 것은 셋이다.
|
||||||
|
|
||||||
|
| # | 질문 | 무엇으로 가르나 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① | 훅이 **실행되는가** | certbot 출력 |
|
||||||
|
| ② | nginx 가 **정말 reload 되는가** | **워커 PID** (문구가 아니라) |
|
||||||
|
| ③ | **얼마나 빠른가** | SCT ↔ 보정한 훅 시각 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 기준선 — 훅을 넣기 전에
|
||||||
|
|
||||||
|
## 1-1. 워커 PID — 판정 기준을 먼저 잡는다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||||
|
```
|
||||||
|
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||||
|
```
|
||||||
|
585 1 ... Thu Sep 3 19:00:39 nginx: master process
|
||||||
|
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 마스터 PID 와 워커 PID **두 숫자**, 그리고 워커의
|
||||||
|
`lstart`. **이 세 값을 적어 둔다. 4-3 의 판정이 이 값과의 비교다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 워커 28829 는 D-4 에서 **사람이** `nginx -s reload`
|
||||||
|
를 쳐서 생긴 것이다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다.
|
||||||
|
**마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명**이라는 것을 D-4 에서
|
||||||
|
확인했고, 이 실험은 그 기준을 그대로 쓴다.
|
||||||
|
|
||||||
|
## 1-2. 지금 서빙 중인 인증서
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -serial -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `serial`. **적어 둔다.** 4-4 에서 이 값이 바뀐다.
|
||||||
|
|
||||||
|
**확인** — 발급 시각의 외부 기준도 지금 봐 둔다
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Timestamp` 두 줄. **CT 로그가 자기 시계로 서명한
|
||||||
|
시각**이고, 이 실험대의 두 기계와 무관한 제3의 기준이다. 4-5 에서 이 값이
|
||||||
|
심판이 된다.
|
||||||
|
|
||||||
|
## 1-3. 훅 디렉터리가 비어 있는가 — 사람이 친다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
|
||||||
|
```
|
||||||
|
**실측** — [`d4-certificate-renewal/12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
|
||||||
|
```
|
||||||
|
/etc/letsencrypt/renewal-hooks/deploy/:
|
||||||
|
total 8
|
||||||
|
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
|
||||||
|
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **`total 8` 과 `.` `..` 뿐.**
|
||||||
|
|
||||||
|
> **`sudo` 없이 치면 `Permission denied` 다.** 그 빈 출력을 「비어 있다」로 읽는
|
||||||
|
> 것이 D-4 에서 실제로 걸렸던 함정이다.
|
||||||
|
|
||||||
|
## 1-4. ★ 시계 왜곡을 먼저 잰다 — 나중에 재면 값을 해석할 수 없다
|
||||||
|
|
||||||
|
**이 실험의 답은 1~2초다.** 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다.
|
||||||
|
**그리고 왜곡은 사후에 되짚을 수 없다** — 지금 재 둔다.
|
||||||
|
|
||||||
|
**확인** — 왕복 사이에 상대 시각을 끼워 세 번 잰다
|
||||||
|
```bash
|
||||||
|
for i in 1 2 3; do
|
||||||
|
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||||
|
echo "A=$A B=$B C=$C"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 줄 각각에서 `B` 와 `(A+C)/2` 의 차이를 **눈으로**
|
||||||
|
뺀다. 그리고 **세 번의 값이 서로 비슷한가** — 흔들리면 네트워크 지연이 섞인
|
||||||
|
것이고, 안정적이면 진짜 왜곡이다.
|
||||||
|
|
||||||
|
**확인** — 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||||
|
```bash
|
||||||
|
date -u
|
||||||
|
curl -sI https://www.google.com | grep -i '^date:'
|
||||||
|
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
|
||||||
|
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
|
||||||
|
```
|
||||||
|
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||||
|
```
|
||||||
|
dev → Google 차이 +0초
|
||||||
|
dev → Let's Encrypt ACME 차이 +0초
|
||||||
|
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
|
||||||
|
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `NTPSynchronized`. 이 호스트는 **`no`** 다.
|
||||||
|
그리고 세 번 다 `+106.1` 로 흔들리지 않았다는 것.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
실제 시각 = test-server 시계 − 106초
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 Let's Encrypt 의 `Date:` 도 보나** — 이 실험이 재는 사건의 한쪽 끝이
|
||||||
|
**Let's Encrypt 의 발급**이기 때문이다. 그쪽 기준과 dev 가 일치한다는 것을
|
||||||
|
확인해 두면, 4-5 의 비교가 같은 시간축 위에서 성립한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 주입 — 파일 하나
|
||||||
|
|
||||||
|
**되돌리기 — 먼저 읽는다**
|
||||||
|
```bash
|
||||||
|
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
**단, 지우면 D-4 의 상태로 돌아간다.** 이 파일은 고장이 아니라 고침이다.
|
||||||
|
|
||||||
|
## 2-1. 개념 — `pre/` · `deploy/` · `post/` 는 다르다
|
||||||
|
|
||||||
|
| 디렉터리 | 언제 실행되나 |
|
||||||
|
|---|---|
|
||||||
|
| `pre/` | 갱신 **시도** 전 |
|
||||||
|
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
|
||||||
|
| `post/` | 갱신 여부와 **무관하게** 매번 |
|
||||||
|
|
||||||
|
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 **갱신이
|
||||||
|
없는 날에도 하루 두 번 nginx 를 reload** 하게 된다 — 아무 이득 없이 워커만
|
||||||
|
갈아치우는 셈이다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때,
|
||||||
|
즉 **실제로 갱신했을 때만** 돈다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
|
||||||
|
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
|
||||||
|
|
||||||
|
## 2-2. 왜 `nginx -t &&` 를 앞에 두는가
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 **새 워커를 못
|
||||||
|
띄운다.** `-t` 로 먼저 검사하고 통과할 때만 reload 한다.
|
||||||
|
|
||||||
|
**실패하면 옛 워커가 그대로 서비스를 계속한다** — 인증서는 안 바뀌지만
|
||||||
|
**서비스는 죽지 않는다.** 이 순서 하나가 「인증서가 안 바뀐다」와
|
||||||
|
「사이트가 내려간다」를 가른다.
|
||||||
|
|
||||||
|
> **`restart` 를 쓰지 않는 이유**도 같다. **실측(호스트)** 로 확인한
|
||||||
|
> `nginx.service` 의 유효 설정은 `Restart=on-failure` · `RestartUSec=100ms` ·
|
||||||
|
> `StartLimitBurst=5` · `StartLimitIntervalUSec=10s` 다. 설정이 깨진 채
|
||||||
|
> `restart` 를 걸면 **10초 안에 5번 실패하고 systemd 가 포기한다** — nginx 가
|
||||||
|
> 내려간 채로 멈춘다.
|
||||||
|
|
||||||
|
## 2-3. sudo 없는 곳에 파일을 미리 만들어 둔다
|
||||||
|
|
||||||
|
**사람이 비밀번호를 치며 실행할 명령은 짧을수록 좋다.** 내용 작성은 sudo 가
|
||||||
|
필요 없는 곳에서 미리 해 둔다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
|
||||||
|
ssh test-server 'cat /tmp/reload-nginx.sh'
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
#!/bin/sh
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 줄이 맞게 들어갔는가. **`#!/bin/sh` 가 첫 줄이어야
|
||||||
|
한다.**
|
||||||
|
|
||||||
|
> **`/tmp` 를 여기서 쓰는 것은 괜찮다.** 이건 당신의 대화형 셸이 쓰는 `/tmp`
|
||||||
|
> 이기 때문이다. 다만 **`certbot-renew.service` 는 `PrivateTmp=true`**
|
||||||
|
> (**실측(호스트)**)라 **그 서비스가 보는 `/tmp` 은 다른 곳**이다 — 훅이
|
||||||
|
> 나중에 `/tmp` 에 로그를 남기도록 만들면 **타이머가 돌렸을 때 그 파일을 밖에서
|
||||||
|
> 찾을 수 없다**(**미검증** — 이 실험은 훅에 로그를 넣지 않았다).
|
||||||
|
> 훅의 로그는 `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다.
|
||||||
|
|
||||||
|
## 2-4. 설치 — 여기부터 사람이 친다
|
||||||
|
|
||||||
|
**하기** — 호스트에 붙어서 직접 친다
|
||||||
|
```bash
|
||||||
|
ssh -t test-server
|
||||||
|
```
|
||||||
|
호스트의 셸에서:
|
||||||
|
```bash
|
||||||
|
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||||
|
```
|
||||||
|
|
||||||
|
**되돌리기**
|
||||||
|
```bash
|
||||||
|
sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> 원래 실행은 이 전부를 **한 줄**로 쳤다. 사람이 비밀번호를 한 번만 치게
|
||||||
|
> 하려는 것이다. 참고로 적어 둔다.
|
||||||
|
> ```bash
|
||||||
|
> ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
|
||||||
|
> /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
|
||||||
|
> > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
|
||||||
|
> ```
|
||||||
|
> **읽기는 어렵다.** 처음 할 때는 위처럼 한 줄씩 치고, 익숙해지면 합친다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||||
|
|
||||||
|
**갱신을 걸기 전에** 훅이 제자리에, 실행 가능한 상태로 있는지 본다.
|
||||||
|
**한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.**
|
||||||
|
|
||||||
|
## 3-1. 파일이 그 자리에 있고 실행 비트가 있는가
|
||||||
|
|
||||||
|
**하기** — 호스트 셸에서
|
||||||
|
```bash
|
||||||
|
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
total 4
|
||||||
|
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가 — 세 가지다.**
|
||||||
|
|
||||||
|
- **`x` 비트** (`-rwxr-xr-x`). 없으면 certbot 이 그냥 건너뛴다
|
||||||
|
- **디렉터리가 `deploy/`** 인가. `post/` 에 들어가면 매번 돈다
|
||||||
|
- 소유자가 `root`
|
||||||
|
|
||||||
|
**확인** — 손으로 한 번 돌려 본다. **이게 가장 확실한 사전 점검이다**
|
||||||
|
```bash
|
||||||
|
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
```
|
||||||
|
**형태**
|
||||||
|
```
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `test is successful`. **이때 워커 PID 도 바뀐다**
|
||||||
|
(이 스크립트는 실제로 reload 한다). 1-1 을 다시 재서 새 값을 기준선으로 삼는다.
|
||||||
|
|
||||||
|
## 3-2. certbot 이 훅을 부르는지 먼저 본다
|
||||||
|
|
||||||
|
**하기** — **미검증**. 원래 실행은 곧바로 강제 갱신을 했다
|
||||||
|
```bash
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 출력에 `Running deploy-hook command` 계열의 줄이
|
||||||
|
나오는가, 그리고 `simulated renewals` 요약.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안
|
||||||
|
깎는다.** 훅이 **호출되는지**까지만 말해 준다. **호출된 훅이 nginx 를 정말
|
||||||
|
갈아 끼웠는지는 dry-run 으로 알 수 없다** — 그래서 4절이 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 효과를 관찰한다
|
||||||
|
|
||||||
|
## 4-1. 강제 갱신 — 사람이 친다
|
||||||
|
|
||||||
|
**되돌리기 — 없다.** 인증서 한 장을 실제로 발급한다.
|
||||||
|
|
||||||
|
**하기** — 호스트 셸에서
|
||||||
|
```bash
|
||||||
|
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
|
||||||
|
sudo certbot renew --force-renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
**시각을 기록하되 어느 시계인지 반드시 적는다.** 호스트에서 찍은 것은
|
||||||
|
`(ts)` 이고 **106초 빠르다.**
|
||||||
|
|
||||||
|
## 4-2. ★ certbot 출력 — 함정이 여기 있다
|
||||||
|
|
||||||
|
**실측** — [`02-certbot-with-hook.txt`](../../evidence/d4a-deploy-hook/02-certbot-with-hook.txt)
|
||||||
|
```
|
||||||
|
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||||
|
Hook 'deploy-hook' ran with error output:
|
||||||
|
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||||
|
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||||
|
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Congratulations, all renewals succeeded:
|
||||||
|
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가 — 다섯 줄을 하나씩 읽는다.**
|
||||||
|
|
||||||
|
| 줄 | 실제 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `Hook 'deploy-hook' ran with error output:` | **훅이 실행됐고, stderr 에 뭔가 있었다** |
|
||||||
|
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. **갱신과 무관** |
|
||||||
|
| `nginx: … test is successful` | **`nginx -t` 통과** |
|
||||||
|
| `[notice] … signal process started` | **`nginx -s reload` 가 신호를 보냈다** |
|
||||||
|
| `Congratulations, all renewals succeeded` | 갱신 성공 |
|
||||||
|
|
||||||
|
### ★ `ran with error output` 은 실패가 아니다
|
||||||
|
|
||||||
|
**certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙인다.** 종료 코드를
|
||||||
|
말하는 것이 아니다. 여기서 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고,
|
||||||
|
**내용은 전부 성공**이다.
|
||||||
|
|
||||||
|
> **로그에서 `error` 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로
|
||||||
|
> 오독한다.** 그리고 반대 방향도 위험하다 — 이 실험은 **훅이 진짜로 실패했을 때
|
||||||
|
> certbot 이 무엇을 찍는지 재지 않았다**(**미검증**).
|
||||||
|
>
|
||||||
|
> **그래서 판정은 문구가 아니라 다음 절의 워커 PID 로 한다.**
|
||||||
|
|
||||||
|
## 4-3. 판정 — 워커가 교체됐다
|
||||||
|
|
||||||
|
**확인** — 1-1 과 **똑같은 명령**
|
||||||
|
```bash
|
||||||
|
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||||
|
```
|
||||||
|
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
|
||||||
|
```
|
||||||
|
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||||||
|
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가**
|
||||||
|
|
||||||
|
| | 전 | 후 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 마스터 | **585** | **585** | 그대로 |
|
||||||
|
| 워커 | 28829 | **37252** | **바뀌었다** |
|
||||||
|
| 워커 `lstart` | Fri Sep 4 18:00:35 (ts) | **Fri Sep 4 21:29:36 (ts)** | 방금 떴다 |
|
||||||
|
| 워커 `etimes` | — | **74** | 74초 전 |
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **마스터 PID 는 유지되고 워커만 바뀌었다.**
|
||||||
|
D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 **그대로 작동한다.**
|
||||||
|
그리고 이번에는 사람이 아니라 **훅이** 했다.
|
||||||
|
|
||||||
|
> **`etimes` 74 를 같이 보는 이유** — PID 는 우연히 재사용될 수 있다.
|
||||||
|
> `lstart` 와 `etimes` 가 「방금」을 가리켜야 진짜 새 워커다.
|
||||||
|
|
||||||
|
## 4-4. 서빙 인증서가 바뀌었다
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -serial -dates -ext subjectAltName
|
||||||
|
```
|
||||||
|
**실측** — [`03-after-state.txt`](../../evidence/d4a-deploy-hook/03-after-state.txt)
|
||||||
|
```
|
||||||
|
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||||
|
notBefore=Sep 4 11:29:18 2026 GMT
|
||||||
|
notAfter=Dec 3 11:29:17 2026 GMT
|
||||||
|
X509v3 Subject Alternative Name:
|
||||||
|
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `serial` 이 1-2 에서 적어 둔 값과 **다른가.**
|
||||||
|
D-4 의 인증서(`06C7CB…EA1D`)에서 바뀌었다. SAN 은 세 이름 그대로다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **훅 하나로 ①②③ 중 ①②가 끝났다.** 남은 것은
|
||||||
|
「얼마나 빨랐나」다.
|
||||||
|
|
||||||
|
## 4-5. ★ 얼마나 빨랐나 — 시계 보정이 여기서 결과를 정한다
|
||||||
|
|
||||||
|
**가진 시각은 셋이고, 두 개는 다른 시계에서 왔다.**
|
||||||
|
|
||||||
|
| 사건 | 원래 값 | 어느 시계 |
|
||||||
|
|---|---|---|
|
||||||
|
| 인증서 발급 | SCT `Sep 4 12:27:49.054 GMT` | **CT 로그** (독립) |
|
||||||
|
| 훅의 `nginx -t` | 로그 `2026/09/04 21:29:36` | **(ts)** |
|
||||||
|
| 새 워커 기동 | `lstart Fri Sep 4 21:29:36` | **(ts)** |
|
||||||
|
| 훅의 `nginx -s reload` | 로그 `2026/09/04 21:29:37` | **(ts)** |
|
||||||
|
|
||||||
|
**확인** — 새 인증서의 SCT
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||||
|
```
|
||||||
|
**실측** — [`01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||||||
|
```
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||||
|
```
|
||||||
|
|
||||||
|
**보정한다** — `(ts)` 값에서 106초를 뺀다.
|
||||||
|
|
||||||
|
```
|
||||||
|
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
|
||||||
|
12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) − 106초
|
||||||
|
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) − 106초
|
||||||
|
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) − 106초
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **발급에서 서빙까지 1~2초.**
|
||||||
|
|
||||||
|
### 보정이 자기 검증된다
|
||||||
|
|
||||||
|
**독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 정확히 놓인다.**
|
||||||
|
|
||||||
|
**보정하지 않으면 어떻게 되나** — 훅 로그 `12:29:36 (ts→UTC)` 에서 SCT
|
||||||
|
`12:27:49` 를 빼면 **+107초**, 즉 **훅이 발급보다 104초 먼저 실행된 것**이 된다.
|
||||||
|
**물리적으로 불가능하다.**
|
||||||
|
|
||||||
|
> **음수 지연이 나오면 계산이 아니라 시계를 의심한다.** 그리고 그 의심을
|
||||||
|
> 가르는 것은 **제3의 시계**다 — 여기서는 CT 로그의 SCT 였다.
|
||||||
|
|
||||||
|
### ★ `notBefore` 로는 계산하지 않는다
|
||||||
|
|
||||||
|
인증서에는 `notBefore=Sep 4 11:29:18` 이라고 적혀 있다. **이건 발급 시각이
|
||||||
|
아니다.**
|
||||||
|
|
||||||
|
**Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다** — 클라이언트
|
||||||
|
시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다.
|
||||||
|
|
||||||
|
그리고 한 시간을 더한 값(`12:29:18`)을 발급 시각으로 그대로 쓰지도 않는다.
|
||||||
|
이 실험대의 두 인증서에서 **SCT 는 그보다 일관되게 약 89초 앞섰다.**
|
||||||
|
|
||||||
|
| 인증서 | `notBefore` | `notBefore` + 1시간 | SCT | 차이 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D-4 이전 것 | `Sep 3 00:47:23` | `01:47:23` | `01:45:53.18` | 약 89.8초 |
|
||||||
|
| D-4a 새것 | `Sep 4 11:29:18` | `12:29:18` | `12:27:49.05` | 약 88.9초 |
|
||||||
|
|
||||||
|
**이 차이의 원인은 이 실험이 규명하지 않았다.** 다만 **시각의 기준으로는
|
||||||
|
SCT 를 쓴다** — 그것이 보정을 자기 검증한 값이기 때문이다.
|
||||||
|
|
||||||
|
**`notBefore` 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.**
|
||||||
|
|
||||||
|
## 4-6. D-4 와의 대조
|
||||||
|
|
||||||
|
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|
||||||
|
|---|---|---|
|
||||||
|
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
|
||||||
|
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | **certbot deploy 훅** |
|
||||||
|
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
|
||||||
|
| 차이 | | **약 1150배** |
|
||||||
|
|
||||||
|
**바뀐 것은 파일 하나, 두 줄이다.**
|
||||||
|
|
||||||
|
## 4-7. 부수 정정 — D-4 의 2199초는 틀렸다
|
||||||
|
|
||||||
|
**이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다.**
|
||||||
|
|
||||||
|
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||||
|
(**test-server 시계**)과 일련번호 관측(**dev 시계**)을 **그대로 뺀** 값이었다.
|
||||||
|
|
||||||
|
| | 시각 (실제 UTC) |
|
||||||
|
|---|---|
|
||||||
|
| 새 인증서 디스크 기록 | **08:20:27** ← mtime `17:22:13 KST (ts)` − 106초 |
|
||||||
|
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
|
||||||
|
| **공백** | **2305초 = 38분 25초** |
|
||||||
|
|
||||||
|
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지
|
||||||
|
> 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
|
||||||
|
> **1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 복구 — 훅은 남긴다
|
||||||
|
|
||||||
|
## 5-1. 남기는 이유
|
||||||
|
|
||||||
|
**이 주입은 고장이 아니라 고침이다.** 지우면 D-4 의 상태로 돌아가고,
|
||||||
|
그 결함은 **다음 실제 갱신(약 89일 뒤)에 인증서 만료로** 나타난다.
|
||||||
|
|
||||||
|
정말 지워야 한다면:
|
||||||
|
```bash
|
||||||
|
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||||
|
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
|
||||||
|
```
|
||||||
|
**어디를 봐야 하는가** — 다시 `total 8`.
|
||||||
|
|
||||||
|
## 5-2. 남은 미검증 — 타이머가 스스로 갱신하는 경로
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
|
||||||
|
|
||||||
|
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는 시간이
|
||||||
|
지나야 시험할 수 있다. 다만 그 경로도 **같은 `certbot renew` 를 부르고 같은
|
||||||
|
`deploy/` 훅을 실행**하므로 남은 미지수는 「타이머가 뜨는가」 하나이고,
|
||||||
|
그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
|
||||||
|
|
||||||
|
**그날이 오면 무엇을 볼 것인가** — 두 줄이면 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -serial -enddate
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 워커 `lstart` 가 **갱신 시각 근처인가**, 그리고
|
||||||
|
`notAfter` 가 밀렸는가. **문구가 아니라 이 둘이다.**
|
||||||
|
|
||||||
|
## 5-3. 원상복구 확인표
|
||||||
|
|
||||||
|
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|
||||||
|
|---|---|---|
|
||||||
|
| 훅 | `sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/` | `-rwxr-xr-x … reload-nginx.sh` **(남긴다)** |
|
||||||
|
| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 새것 |
|
||||||
|
| 서빙 인증서 | `openssl … -serial -dates` | 4-4 의 새 일련번호 |
|
||||||
|
| 체인 | D-4 1-2 | 4단계, `Verify return code: 0` |
|
||||||
|
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||||||
|
| 임시 파일 | `ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt'` | 지워도 된다. 훅은 `/etc` 에 설치됐다 |
|
||||||
|
| 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ran with error output` 을 보고 실패로 판단했다 | **stderr 에 뭔가 있으면 무조건 붙는 문구다** | **워커 PID** — 4-3 |
|
||||||
|
| 훅이 아예 안 불렸다 | `x` 비트가 없거나 `deploy/` 가 아니다 | `sudo ls -l …/deploy/` — 3-1 |
|
||||||
|
| 훅은 돌았는데 워커가 그대로 | `nginx -t` 가 실패해 `&&` 뒤가 안 돌았다 | 훅을 손으로 실행 — 3-1 |
|
||||||
|
| 워커도 마스터도 바뀌었다 | reload 가 아니라 **재시작**됐다 | `lstart` 두 줄을 본다 |
|
||||||
|
| 지연이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-4 로 돌아간다 |
|
||||||
|
| 발급 시각이 한 시간 어긋난다 | **`notBefore` 를 발급 시각으로 읽었다** | SCT 를 본다 — 4-5 |
|
||||||
|
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 — 1-4 |
|
||||||
|
| 호스트 명령이 조용히 빈 결과 | **sudo 가 비밀번호를 못 물었다** | `ssh -t` 로 다시 |
|
||||||
|
| 훅 로그를 `/tmp` 에 썼는데 안 보인다 | **`certbot-renew.service` 는 `PrivateTmp=true`** | `logger` 로 저널에 보내거나 `/var/log` 아래에 쓴다(**미검증**) |
|
||||||
|
| nginx 경고가 계속 거슬린다 | `types_hash_max_size` 기본값 | 갱신과 무관하다. 고치려면 `nginx.conf` 를 손본다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이 실험이 남기는 한 문장
|
||||||
|
|
||||||
|
**처방을 적었으면 시험한다.**
|
||||||
|
|
||||||
|
D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었다. **그 처방이 듣는지
|
||||||
|
확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다.** 그런데 확인하지 않은
|
||||||
|
채로 문서에 남았다면, 「고치는 법」 항목은 **다음 갱신일까지 아무도 시험하지
|
||||||
|
않은 문장**으로 남았을 것이다 — 그리고 그날이 바로 시험할 수 없는 날이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 다음
|
||||||
|
|
||||||
|
| 실험 | D-4a 가 남긴 것 |
|
||||||
|
|---|---|
|
||||||
|
| [D-4](d4-certificate-renewal.md) 인증서 갱신 | **공백 수치가 2305초로 정정됐다** — 4-7 |
|
||||||
|
| [D-3](d3-secret-management.md) 비밀 관리 | 새 `privkey2.pem` 도 같은 문제를 안고 있다 |
|
||||||
|
| [04-TLS](../04-tls/) 구축 단계 | 이 훅은 **구축 절차에 들어가야 한다.** 사후에 붙이는 것이 아니다 |
|
||||||
|
| 관측 | **밖에서 `notAfter` 를 재는 감시**가 로그 감시보다 정직하다 |
|
||||||
|
| 전부 | **판정 기준은 문구가 아니라 상태다.** 여기서는 워커 PID 였다 |
|
||||||
|
| 전부 | **1~2초를 재려면 시계부터 잰다.** 106초는 그 자체로 결론을 뒤집는다 |
|
||||||
Reference in New Issue
Block a user